From 8efd34f199f698eb4f2500abb160521e2b54a95b Mon Sep 17 00:00:00 2001 From: Rebecca Alpert Date: Tue, 28 Oct 2025 15:36:46 -0400 Subject: [PATCH 1/3] feat(ResponseActions): Add option for persistent selections Assisted-by: Cursor --- .../Messages/MessageWithPersistedActions.tsx | 22 ++++ .../chatbot/examples/Messages/Messages.md | 14 ++ packages/module/src/Message/Message.tsx | 8 +- .../ResponseActions/ResponseActions.test.tsx | 123 ++++++++++++++++-- .../src/ResponseActions/ResponseActions.tsx | 56 ++++++-- 5 files changed, 200 insertions(+), 23 deletions(-) create mode 100644 packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/MessageWithPersistedActions.tsx diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/MessageWithPersistedActions.tsx b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/MessageWithPersistedActions.tsx new file mode 100644 index 000000000..e90ac8a58 --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/MessageWithPersistedActions.tsx @@ -0,0 +1,22 @@ +import { FunctionComponent } from 'react'; + +import Message from '@patternfly/chatbot/dist/dynamic/Message'; +import patternflyAvatar from './patternfly_avatar.jpg'; + +export const MessageWithPersistedActions: FunctionComponent = () => ( + console.log('Good response') }, + // eslint-disable-next-line no-console + negative: { onClick: () => console.log('Bad response') }, + // eslint-disable-next-line no-console + listen: { onClick: () => console.log('Listen') } + }} + persistActionSelection + /> +); diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/Messages.md b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/Messages.md index 1391169c4..ef939d145 100644 --- a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/Messages.md +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/Messages.md @@ -108,6 +108,20 @@ Once the component has rendered, user interactions will take precedence over the ``` +### Message actions selection options + +By default, message actions will automatically deselect when clicking outside the component or clicking a different action button. You can opt-in to persist the selection by setting `persistActionSelection` to `true`. + +When `persistActionSelection` is `true`: + +- The selected action will remain selected even when clicking outside the component +- Clicking the same button again will toggle the selection off, though you will have to move your focus elsewhere to see a visual state change +- Clicking a different button will switch the selection to that button + +```js file="./MessageWithPersistedActions.tsx" + +``` + ### Custom message actions Beyond the standard message actions (good response, bad response, copy, share, or listen), you can add custom actions to a bot message by passing an `actions` object to the `` component. This object can contain the following customizations: diff --git a/packages/module/src/Message/Message.tsx b/packages/module/src/Message/Message.tsx index b51c8cc85..02d26c27a 100644 --- a/packages/module/src/Message/Message.tsx +++ b/packages/module/src/Message/Message.tsx @@ -108,6 +108,9 @@ export interface MessageProps extends Omit, 'role'> { actions?: { [key: string]: ActionProps; }; + /** When true, the selected action will persist even when clicking outside the component. + * When false (default), clicking outside or clicking another action will deselect the current selection. */ + persistActionSelection?: boolean; /** Sources for message */ sources?: SourcesCardProps; /** Label for the English word "AI," used to tag messages with role "bot" */ @@ -202,6 +205,7 @@ export const MessageBase: FunctionComponent = ({ timestamp, isLoading, actions, + persistActionSelection, sources, botWord = 'AI', loadingWord = 'Loading message', @@ -499,7 +503,9 @@ export const MessageBase: FunctionComponent = ({ isCompact={isCompact} /> )} - {!isLoading && !isEditable && actions && } + {!isLoading && !isEditable && actions && ( + + )} {userFeedbackForm && } {userFeedbackComplete && ( diff --git a/packages/module/src/ResponseActions/ResponseActions.test.tsx b/packages/module/src/ResponseActions/ResponseActions.test.tsx index 8ec94d92e..43f62ff04 100644 --- a/packages/module/src/ResponseActions/ResponseActions.test.tsx +++ b/packages/module/src/ResponseActions/ResponseActions.test.tsx @@ -6,8 +6,8 @@ import { DownloadIcon, InfoCircleIcon, RedoIcon } from '@patternfly/react-icons' import Message from '../Message'; const ALL_ACTIONS = [ - { type: 'positive', label: 'Good response', clickedLabel: 'Response recorded' }, - { type: 'negative', label: 'Bad response', clickedLabel: 'Response recorded' }, + { type: 'positive', label: 'Good response', clickedLabel: 'Good response recorded' }, + { type: 'negative', label: 'Bad response', clickedLabel: 'Bad response recorded' }, { type: 'copy', label: 'Copy', clickedLabel: 'Copied' }, { type: 'edit', label: 'Edit', clickedLabel: 'Editing' }, { type: 'share', label: 'Share', clickedLabel: 'Shared' }, @@ -81,7 +81,7 @@ describe('ResponseActions', () => { expect(button).toBeTruthy(); }); await userEvent.click(goodBtn); - expect(screen.getByRole('button', { name: 'Response recorded' })).toHaveClass( + expect(screen.getByRole('button', { name: 'Good response recorded' })).toHaveClass( 'pf-chatbot__button--response-action-clicked' ); let unclickedButtons = buttons.filter((button) => button !== goodBtn); @@ -89,7 +89,7 @@ describe('ResponseActions', () => { expect(button).not.toHaveClass('pf-chatbot__button--response-action-clicked'); }); await userEvent.click(badBtn); - expect(screen.getByRole('button', { name: 'Response recorded' })).toHaveClass( + expect(screen.getByRole('button', { name: 'Bad response recorded' })).toHaveClass( 'pf-chatbot__button--response-action-clicked' ); unclickedButtons = buttons.filter((button) => button !== badBtn); @@ -117,13 +117,13 @@ describe('ResponseActions', () => { expect(badBtn).toBeTruthy(); await userEvent.click(goodBtn); - expect(screen.getByRole('button', { name: 'Response recorded' })).toHaveClass( + expect(screen.getByRole('button', { name: 'Good response recorded' })).toHaveClass( 'pf-chatbot__button--response-action-clicked' ); expect(badBtn).not.toHaveClass('pf-chatbot__button--response-action-clicked'); await userEvent.click(badBtn); - expect(screen.getByRole('button', { name: 'Response recorded' })).toHaveClass( + expect(screen.getByRole('button', { name: 'Bad response recorded' })).toHaveClass( 'pf-chatbot__button--response-action-clicked' ); expect(goodBtn).not.toHaveClass('pf-chatbot__button--response-action-clicked'); @@ -238,30 +238,30 @@ describe('ResponseActions', () => { }); it('should be able to call onClick correctly', async () => { - ALL_ACTIONS.forEach(async ({ type, label }) => { + for (const { type, label } of ALL_ACTIONS) { const spy = jest.fn(); render(); await userEvent.click(screen.getByRole('button', { name: label })); expect(spy).toHaveBeenCalledTimes(1); - }); + } }); it('should swap clicked and non-clicked aria labels on click', async () => { - ALL_ACTIONS.forEach(async ({ type, label, clickedLabel }) => { + for (const { type, label, clickedLabel } of ALL_ACTIONS) { render(); expect(screen.getByRole('button', { name: label })).toBeTruthy(); await userEvent.click(screen.getByRole('button', { name: label })); expect(screen.getByRole('button', { name: clickedLabel })).toBeTruthy(); - }); + } }); it('should swap clicked and non-clicked tooltips on click', async () => { - ALL_ACTIONS.forEach(async ({ type, label, clickedLabel }) => { + for (const { type, label, clickedLabel } of ALL_ACTIONS) { render(); expect(screen.getByRole('button', { name: label })).toBeTruthy(); await userEvent.click(screen.getByRole('button', { name: label })); expect(screen.getByRole('tooltip', { name: clickedLabel })).toBeTruthy(); - }); + } }); it('should be able to change aria labels', () => { @@ -322,4 +322,103 @@ describe('ResponseActions', () => { expect(screen.getByTestId(action[key])).toBeTruthy(); }); }); + + // we are testing for the reverse case already above + it('should not deselect when clicking outside when persistActionSelection is true', async () => { + render( + + ); + const goodBtn = screen.getByRole('button', { name: 'Good response' }); + + await userEvent.click(goodBtn); + expect(screen.getByRole('button', { name: 'Good response recorded' })).toHaveClass( + 'pf-chatbot__button--response-action-clicked' + ); + + await userEvent.click(screen.getByText('Test content')); + + expect(screen.getByRole('button', { name: 'Good response recorded' })).toHaveClass( + 'pf-chatbot__button--response-action-clicked' + ); + }); + + it('should switch selection to another button when persistActionSelection is true', async () => { + render( + + ); + const goodBtn = screen.getByRole('button', { name: 'Good response' }); + const badBtn = screen.getByRole('button', { name: 'Bad response' }); + + await userEvent.click(goodBtn); + expect(goodBtn).toHaveClass('pf-chatbot__button--response-action-clicked'); + + await userEvent.click(badBtn); + expect(badBtn).toHaveClass('pf-chatbot__button--response-action-clicked'); + expect(goodBtn).not.toHaveClass('pf-chatbot__button--response-action-clicked'); + }); + + it('should toggle off when clicking the same button when persistActionSelection is true', async () => { + render( + + ); + const goodBtn = screen.getByRole('button', { name: 'Good response' }); + + await userEvent.click(goodBtn); + expect(goodBtn).toHaveClass('pf-chatbot__button--response-action-clicked'); + + await userEvent.click(goodBtn); + expect(goodBtn).not.toHaveClass('pf-chatbot__button--response-action-clicked'); + }); + + it('should work with custom actions when persistActionSelection is true', async () => { + const actions = { + positive: { 'data-testid': 'positive', onClick: jest.fn() }, + negative: { 'data-testid': 'negative', onClick: jest.fn() }, + custom: { + 'data-testid': 'custom', + onClick: jest.fn(), + ariaLabel: 'Custom', + tooltipContent: 'Custom action', + icon: + } + }; + render(); + + const customBtn = screen.getByTestId('custom'); + await userEvent.click(customBtn); + expect(customBtn).toHaveClass('pf-chatbot__button--response-action-clicked'); + + await userEvent.click(customBtn); + expect(customBtn).not.toHaveClass('pf-chatbot__button--response-action-clicked'); + }); }); diff --git a/packages/module/src/ResponseActions/ResponseActions.tsx b/packages/module/src/ResponseActions/ResponseActions.tsx index 5e816668f..793a939f9 100644 --- a/packages/module/src/ResponseActions/ResponseActions.tsx +++ b/packages/module/src/ResponseActions/ResponseActions.tsx @@ -53,11 +53,20 @@ export interface ResponseActionProps { listen?: ActionProps; edit?: ActionProps; }; + /** When true, the selected action will persist even when clicking outside the component. + * When false (default), clicking outside or clicking another action will deselect the current selection. */ + persistActionSelection?: boolean; } -export const ResponseActions: FunctionComponent = ({ actions }) => { +export const ResponseActions: FunctionComponent = ({ + actions, + persistActionSelection = false +}) => { const [activeButton, setActiveButton] = useState(); const [clickStatePersisted, setClickStatePersisted] = useState(false); + + const { positive, negative, copy, edit, share, download, listen, ...additionalActions } = actions; + useEffect(() => { // Define the order of precedence for checking initial `isClicked` const actionPrecedence = ['positive', 'negative', 'copy', 'edit', 'share', 'download', 'listen']; @@ -82,13 +91,21 @@ export const ResponseActions: FunctionComponent = ({ action // Click state is explicitly controlled by consumer. setClickStatePersisted(true); } + // If persistActionSelection is true, all selections are persisted + if (persistActionSelection) { + setClickStatePersisted(true); + } setActiveButton(initialActive); - }, [actions]); + }, [actions, persistActionSelection]); - const { positive, negative, copy, edit, share, download, listen, ...additionalActions } = actions; const responseActions = useRef(null); useEffect(() => { + // Only add click outside listener if not persisting selection + if (persistActionSelection) { + return; + } + const handleClickOutside = (e) => { if (responseActions.current && !responseActions.current.contains(e.target) && !clickStatePersisted) { setActiveButton(undefined); @@ -99,15 +116,26 @@ export const ResponseActions: FunctionComponent = ({ action return () => { window.removeEventListener('click', handleClickOutside); }; - }, [clickStatePersisted]); + }, [clickStatePersisted, persistActionSelection]); const handleClick = ( e: MouseEvent | MouseEvent | KeyboardEvent, id: string, onClick?: (event: MouseEvent | MouseEvent | KeyboardEvent) => void ) => { - setClickStatePersisted(false); - setActiveButton(id); + if (persistActionSelection) { + if (activeButton === id) { + // Toggle off if clicking the same button + setActiveButton(undefined); + } else { + // Set new active button + setActiveButton(id); + } + setClickStatePersisted(true); + } else { + setClickStatePersisted(false); + setActiveButton(id); + } onClick && onClick(e); }; @@ -117,36 +145,38 @@ export const ResponseActions: FunctionComponent = ({ action handleClick(e, 'positive', positive.onClick)} className={positive.className} isDisabled={positive.isDisabled} tooltipContent={positive.tooltipContent ?? 'Good response'} - clickedTooltipContent={positive.clickedTooltipContent ?? 'Response recorded'} + clickedTooltipContent={positive.clickedTooltipContent ?? 'Good response recorded'} tooltipProps={positive.tooltipProps} icon={} isClicked={activeButton === 'positive'} ref={positive.ref} aria-expanded={positive['aria-expanded']} aria-controls={positive['aria-controls']} + aria-pressed={persistActionSelection ? activeButton === 'positive' : undefined} > )} {negative && ( handleClick(e, 'negative', negative.onClick)} className={negative.className} isDisabled={negative.isDisabled} tooltipContent={negative.tooltipContent ?? 'Bad response'} - clickedTooltipContent={negative.clickedTooltipContent ?? 'Response recorded'} + clickedTooltipContent={negative.clickedTooltipContent ?? 'Bad response recorded'} tooltipProps={negative.tooltipProps} icon={} isClicked={activeButton === 'negative'} ref={negative.ref} aria-expanded={negative['aria-expanded']} aria-controls={negative['aria-controls']} + aria-pressed={persistActionSelection ? activeButton === 'negative' : undefined} > )} {copy && ( @@ -165,6 +195,7 @@ export const ResponseActions: FunctionComponent = ({ action ref={copy.ref} aria-expanded={copy['aria-expanded']} aria-controls={copy['aria-controls']} + aria-pressed={persistActionSelection ? activeButton === 'copy' : undefined} > )} {edit && ( @@ -183,6 +214,7 @@ export const ResponseActions: FunctionComponent = ({ action ref={edit.ref} aria-expanded={edit['aria-expanded']} aria-controls={edit['aria-controls']} + aria-pressed={persistActionSelection ? activeButton === 'edit' : undefined} > )} {share && ( @@ -201,6 +233,7 @@ export const ResponseActions: FunctionComponent = ({ action ref={share.ref} aria-expanded={share['aria-expanded']} aria-controls={share['aria-controls']} + aria-pressed={persistActionSelection ? activeButton === 'share' : undefined} > )} {download && ( @@ -219,6 +252,7 @@ export const ResponseActions: FunctionComponent = ({ action ref={download.ref} aria-expanded={download['aria-expanded']} aria-controls={download['aria-controls']} + aria-pressed={persistActionSelection ? activeButton === 'download' : undefined} > )} {listen && ( @@ -237,6 +271,7 @@ export const ResponseActions: FunctionComponent = ({ action ref={listen.ref} aria-expanded={listen['aria-expanded']} aria-controls={listen['aria-controls']} + aria-pressed={persistActionSelection ? activeButton === 'listen' : undefined} > )} @@ -257,6 +292,7 @@ export const ResponseActions: FunctionComponent = ({ action ref={additionalActions[action]?.ref} aria-expanded={additionalActions[action]?.['aria-expanded']} aria-controls={additionalActions[action]?.['aria-controls']} + aria-pressed={persistActionSelection ? activeButton === action : undefined} /> ))} From b43d0b7ddae1e30dcdbbad146f77b52623796ab4 Mon Sep 17 00:00:00 2001 From: Rebecca Alpert Date: Fri, 31 Oct 2025 13:39:42 -0400 Subject: [PATCH 2/3] Update packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/Messages.md Co-authored-by: Erin Donehoo <105813956+edonehoo@users.noreply.github.com> --- .../extensions/chatbot/examples/Messages/Messages.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/Messages.md b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/Messages.md index ef939d145..de03f71d9 100644 --- a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/Messages.md +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/Messages.md @@ -108,15 +108,15 @@ Once the component has rendered, user interactions will take precedence over the ``` -### Message actions selection options +### Message actions persistent selections -By default, message actions will automatically deselect when clicking outside the component or clicking a different action button. You can opt-in to persist the selection by setting `persistActionSelection` to `true`. +By default, message actions will automatically deselect when you click outside the component or on a different action button. To persist the selection instead, set `persistActionSelection` to `true`. When `persistActionSelection` is `true`: -- The selected action will remain selected even when clicking outside the component -- Clicking the same button again will toggle the selection off, though you will have to move your focus elsewhere to see a visual state change -- Clicking a different button will switch the selection to that button +- The selected action will remain selected even when you click outside the component. +- Clicking a different button will still switch the selection to that button. +- Clicking the same action button again will toggle the selection off, though you will have to move your focus elsewhere to see the visual state change. ```js file="./MessageWithPersistedActions.tsx" From 8abf652a8a01b722a7bd4778c5e8e993bfb28ead Mon Sep 17 00:00:00 2001 From: Rebecca Alpert Date: Mon, 3 Nov 2025 09:19:30 -0500 Subject: [PATCH 3/3] Remove aria-pressed --- packages/module/src/ResponseActions/ResponseActions.tsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/packages/module/src/ResponseActions/ResponseActions.tsx b/packages/module/src/ResponseActions/ResponseActions.tsx index 793a939f9..7598f9227 100644 --- a/packages/module/src/ResponseActions/ResponseActions.tsx +++ b/packages/module/src/ResponseActions/ResponseActions.tsx @@ -157,7 +157,6 @@ export const ResponseActions: FunctionComponent = ({ ref={positive.ref} aria-expanded={positive['aria-expanded']} aria-controls={positive['aria-controls']} - aria-pressed={persistActionSelection ? activeButton === 'positive' : undefined} > )} {negative && ( @@ -176,7 +175,6 @@ export const ResponseActions: FunctionComponent = ({ ref={negative.ref} aria-expanded={negative['aria-expanded']} aria-controls={negative['aria-controls']} - aria-pressed={persistActionSelection ? activeButton === 'negative' : undefined} > )} {copy && ( @@ -195,7 +193,6 @@ export const ResponseActions: FunctionComponent = ({ ref={copy.ref} aria-expanded={copy['aria-expanded']} aria-controls={copy['aria-controls']} - aria-pressed={persistActionSelection ? activeButton === 'copy' : undefined} > )} {edit && ( @@ -214,7 +211,6 @@ export const ResponseActions: FunctionComponent = ({ ref={edit.ref} aria-expanded={edit['aria-expanded']} aria-controls={edit['aria-controls']} - aria-pressed={persistActionSelection ? activeButton === 'edit' : undefined} > )} {share && ( @@ -233,7 +229,6 @@ export const ResponseActions: FunctionComponent = ({ ref={share.ref} aria-expanded={share['aria-expanded']} aria-controls={share['aria-controls']} - aria-pressed={persistActionSelection ? activeButton === 'share' : undefined} > )} {download && ( @@ -252,7 +247,6 @@ export const ResponseActions: FunctionComponent = ({ ref={download.ref} aria-expanded={download['aria-expanded']} aria-controls={download['aria-controls']} - aria-pressed={persistActionSelection ? activeButton === 'download' : undefined} > )} {listen && ( @@ -271,7 +265,6 @@ export const ResponseActions: FunctionComponent = ({ ref={listen.ref} aria-expanded={listen['aria-expanded']} aria-controls={listen['aria-controls']} - aria-pressed={persistActionSelection ? activeButton === 'listen' : undefined} > )} @@ -292,7 +285,6 @@ export const ResponseActions: FunctionComponent = ({ ref={additionalActions[action]?.ref} aria-expanded={additionalActions[action]?.['aria-expanded']} aria-controls={additionalActions[action]?.['aria-controls']} - aria-pressed={persistActionSelection ? activeButton === action : undefined} /> ))}