diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/MessageWithCustomResponseActions.tsx b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/MessageWithCustomResponseActions.tsx index 493860cd..d658915b 100644 --- a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/MessageWithCustomResponseActions.tsx +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/MessageWithCustomResponseActions.tsx @@ -15,16 +15,20 @@ export const CustomActionExample: React.FunctionComponent = () => ( actions={{ regenerate: { ariaLabel: 'Regenerate', + clickedAriaLabel: 'Regenerated', // eslint-disable-next-line no-console onClick: () => console.log('Clicked regenerate'), tooltipContent: 'Regenerate', + clickedTooltipContent: 'Regenerated', icon: }, download: { ariaLabel: 'Download', + clickedAriaLabel: 'Downloaded', // eslint-disable-next-line no-console onClick: () => console.log('Clicked download'), tooltipContent: 'Download', + clickedTooltipContent: 'Downloaded', icon: }, info: { 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 4a97b6dc..7033369a 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 @@ -63,7 +63,7 @@ You can further customize the avatar by applying an additional class or passing ``` -### Messages actions +### Message actions You can add actions to a message, to allow users to interact with the message content. These actions can include: @@ -79,7 +79,18 @@ You can add actions to a message, to allow users to interact with the message co ### Custom message actions -Beyond the standard message actions (positive, negative, 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: `ariaLabel`, `onClick`, `className`, `isDisabled`, `tooltipContent`, `tooltipProps`, and `icon`. +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: + +- `ariaLabel` +- `onClick` +- `className` +- `isDisabled` +- `tooltipContent` +- `tooltipContent` +- `tooltipProps` +- `icon` + +You can apply a `clickedAriaLabel` and `clickedTooltipContent` once a button is clicked. If either of these props are omitted, their values will default to the `ariaLabel` or `tooltipContent` supplied. ```js file="./MessageWithCustomResponseActions.tsx" diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/demos/Chatbot.md b/packages/module/patternfly-docs/content/extensions/chatbot/examples/demos/Chatbot.md index 4db1d10c..81a8ee63 100644 --- a/packages/module/patternfly-docs/content/extensions/chatbot/examples/demos/Chatbot.md +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/demos/Chatbot.md @@ -66,7 +66,7 @@ This demo displays a basic ChatBot, which includes: 4. [`` and ``](/patternfly-ai/chatbot/ui#content-and-message-box) with: - A `` -- An initial [user ``](/patternfly-ai/chatbot/messages#user-messages) and an initial bot message with [message actions.](/patternfly-ai/chatbot/messages#messages-actions) +- An initial [user ``](/patternfly-ai/chatbot/messages#user-messages) and an initial bot message with [message actions.](/patternfly-ai/chatbot/messages#message-actions) - Logic for enabling auto-scrolling to the most recent message whenever a new message is sent or received using a `scrollToBottomRef` 5. A [``](/patternfly-ai/chatbot/ui#footer) with a [``](/patternfly-ai/chatbot/ui#footnote-with-popover) and a `` that contains the abilities of: @@ -92,7 +92,7 @@ This demo displays an embedded ChatBot. Embedded ChatBots are meant to be placed 3. A [``](/patternfly-ai/chatbot/ui#header) with all built sub-components laid out, including a `` 4. [`` and ``](/patternfly-ai/chatbot/ui#content-and-message-box) with: - A `` - - An initial [user ``](/patternfly-ai/chatbot/messages#user-messages) and an initial bot message with [message actions.](/patternfly-ai/chatbot/messages/#messages-actions) + - An initial [user ``](/patternfly-ai/chatbot/messages#user-messages) and an initial bot message with [message actions.](/patternfly-ai/chatbot/messages/#message-actions) - Logic for enabling auto-scrolling to the most recent message whenever a new message is sent or received using a `scrollToBottomRef` 5. A [``](/patternfly-ai/chatbot/ui#footer) with a [``](/patternfly-ai/chatbot/ui#footnote-with-popover) and a `` that contains the abilities of: - [Speech to text.](/patternfly-ai/chatbot/ui#message-bar-with-speech-recognition-and-file-attachment) diff --git a/packages/module/src/ResponseActions/ResponseActionButton.test.tsx b/packages/module/src/ResponseActions/ResponseActionButton.test.tsx new file mode 100644 index 00000000..9cd3f8d2 --- /dev/null +++ b/packages/module/src/ResponseActions/ResponseActionButton.test.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import userEvent from '@testing-library/user-event'; +import { DownloadIcon } from '@patternfly/react-icons'; +import ResponseActionButton from './ResponseActionButton'; + +describe('ResponseActionButton', () => { + it('renders aria-label correctly if not clicked', () => { + render(} ariaLabel="Download" clickedAriaLabel="Downloaded" />); + expect(screen.getByRole('button', { name: 'Download' })).toBeTruthy(); + }); + it('renders aria-label correctly if clicked', () => { + render( + } ariaLabel="Download" clickedAriaLabel="Downloaded" isClicked /> + ); + expect(screen.getByRole('button', { name: 'Downloaded' })).toBeTruthy(); + }); + it('renders tooltip correctly if not clicked', async () => { + render( + } tooltipContent="Download" clickedTooltipContent="Downloaded" /> + ); + expect(screen.getByRole('button', { name: 'Download' })).toBeTruthy(); + // clicking here just triggers the tooltip; in this button, the logic is divorced from whether it is actually clicked + await userEvent.click(screen.getByRole('button', { name: 'Download' })); + expect(screen.getByRole('tooltip', { name: 'Download' })).toBeTruthy(); + }); + it('renders tooltip correctly if clicked', async () => { + render( + } + tooltipContent="Download" + clickedTooltipContent="Downloaded" + isClicked + /> + ); + expect(screen.getByRole('button', { name: 'Downloaded' })).toBeTruthy(); + // clicking here just triggers the tooltip; in this button, the logic is divorced from whether it is actually clicked + await userEvent.click(screen.getByRole('button', { name: 'Downloaded' })); + expect(screen.getByRole('tooltip', { name: 'Downloaded' })).toBeTruthy(); + }); + it('if clicked variant for tooltip is not supplied, it uses the default', async () => { + render(} tooltipContent="Download" isClicked />); + // clicking here just triggers the tooltip; in this button, the logic is divorced from whether it is actually clicked + await userEvent.click(screen.getByRole('button', { name: 'Download' })); + expect(screen.getByRole('button', { name: 'Download' })).toBeTruthy(); + }); + it('if clicked variant for aria label is not supplied, it uses the default', async () => { + render(} ariaLabel="Download" isClicked />); + expect(screen.getByRole('button', { name: 'Download' })).toBeTruthy(); + }); +}); diff --git a/packages/module/src/ResponseActions/ResponseActionButton.tsx b/packages/module/src/ResponseActions/ResponseActionButton.tsx index 0d54a633..8ef2e5ac 100644 --- a/packages/module/src/ResponseActions/ResponseActionButton.tsx +++ b/packages/module/src/ResponseActions/ResponseActionButton.tsx @@ -4,6 +4,8 @@ import { Button, Icon, Tooltip, TooltipProps } from '@patternfly/react-core'; export interface ResponseActionButtonProps { /** Aria-label for the button. Defaults to the value of the tooltipContent if none provided */ ariaLabel?: string; + /** Aria-label for the button, shown when the button is clicked. Defaults to the value of ariaLabel or tooltipContent if not provided. */ + clickedAriaLabel?: string; /** Icon for the button */ icon: React.ReactNode; /** On-click handler for the button */ @@ -14,43 +16,60 @@ export interface ResponseActionButtonProps { isDisabled?: boolean; /** Content shown in the tooltip */ tooltipContent?: string; + /** Content shown in the tooltip when the button is clicked. Defaults to the value of tooltipContent if not provided. */ + clickedTooltipContent?: string; /** Props to control the PF Tooltip component */ tooltipProps?: TooltipProps; + /** Whether button is in clicked state */ + isClicked?: boolean; } export const ResponseActionButton: React.FunctionComponent = ({ ariaLabel, + clickedAriaLabel = ariaLabel, className, icon, isDisabled, onClick, tooltipContent, - tooltipProps -}) => ( - - - -); + clickedTooltipContent = tooltipContent, + tooltipProps, + isClicked = false +}) => { + const generateAriaLabel = () => { + if (ariaLabel) { + return isClicked ? clickedAriaLabel : ariaLabel; + } + return isClicked ? clickedTooltipContent : tooltipContent; + }; + + return ( + + + + ); +}; export default ResponseActionButton; diff --git a/packages/module/src/ResponseActions/ResponseActions.scss b/packages/module/src/ResponseActions/ResponseActions.scss index 5b305589..e40616a7 100644 --- a/packages/module/src/ResponseActions/ResponseActions.scss +++ b/packages/module/src/ResponseActions/ResponseActions.scss @@ -4,6 +4,7 @@ grid-template-columns: repeat(auto-fit, minmax(0, max-content)); .pf-v6-c-button { + --pf-v6-c-button__icon--Color: var(--pf-t--global--icon--color--subtle); border-radius: var(--pf-t--global--border--radius--pill); width: 2.3125rem; height: 2.3125rem; @@ -11,16 +12,17 @@ align-items: center; justify-content: center; - .pf-v6-c-button__icon { - color: var(--pf-t--global--icon--color--subtle); + &:hover { + --pf-v6-c-button__icon--Color: var(--pf-t--global--icon--color--subtle); } - - // Interactive states - &:hover, &:focus { - .pf-v6-c-button__icon { - color: var(--pf-t--global--icon--color--subtle); - } + --pf-v6-c-button--hover--BackgroundColor: var(--pf-t--global--background--color--action--plain--alt--clicked); + --pf-v6-c-button__icon--Color: var(--pf-t--global--icon--color--regular); } } } + +.pf-v6-c-button.pf-chatbot__button--response-action-clicked { + --pf-v6-c-button--m-plain--BackgroundColor: var(--pf-t--global--background--color--action--plain--alt--clicked); + --pf-v6-c-button__icon--Color: var(--pf-t--global--icon--color--regular); +} diff --git a/packages/module/src/ResponseActions/ResponseActions.test.tsx b/packages/module/src/ResponseActions/ResponseActions.test.tsx index 997bcd81..ac94af7a 100644 --- a/packages/module/src/ResponseActions/ResponseActions.test.tsx +++ b/packages/module/src/ResponseActions/ResponseActions.test.tsx @@ -4,27 +4,32 @@ import '@testing-library/jest-dom'; import ResponseActions from './ResponseActions'; import userEvent from '@testing-library/user-event'; import { DownloadIcon, InfoCircleIcon, RedoIcon } from '@patternfly/react-icons'; +import Message from '../Message'; const ALL_ACTIONS = [ - { type: 'positive', label: 'Good response' }, - { type: 'negative', label: 'Bad response' }, - { type: 'copy', label: 'Copy' }, - { type: 'share', label: 'Share' }, - { type: 'listen', label: 'Listen' } + { type: 'positive', label: 'Good response', clickedLabel: 'Response recorded' }, + { type: 'negative', label: 'Bad response', clickedLabel: 'Response recorded' }, + { type: 'copy', label: 'Copy', clickedLabel: 'Copied' }, + { type: 'share', label: 'Share', clickedLabel: 'Shared' }, + { type: 'listen', label: 'Listen', clickedLabel: 'Listening' } ]; const CUSTOM_ACTIONS = [ { regenerate: { ariaLabel: 'Regenerate', + clickedAriaLabel: 'Regenerated', onClick: jest.fn(), tooltipContent: 'Regenerate', + clickedTooltipContent: 'Regenerated', icon: }, download: { ariaLabel: 'Download', + clickedAriaLabel: 'Downloaded', onClick: jest.fn(), tooltipContent: 'Download', + clickedTooltipContent: 'Downloaded', icon: }, info: { @@ -37,6 +42,81 @@ const CUSTOM_ACTIONS = [ ]; describe('ResponseActions', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + it('should handle click within group of buttons correctly', async () => { + render( + + ); + const goodBtn = screen.getByRole('button', { name: 'Good response' }); + const badBtn = screen.getByRole('button', { name: 'Bad response' }); + const copyBtn = screen.getByRole('button', { name: 'Copy' }); + const shareBtn = screen.getByRole('button', { name: 'Share' }); + const listenBtn = screen.getByRole('button', { name: 'Listen' }); + const buttons = [goodBtn, badBtn, copyBtn, shareBtn, listenBtn]; + buttons.forEach((button) => { + expect(button).toBeTruthy(); + }); + await userEvent.click(goodBtn); + expect(screen.getByRole('button', { name: 'Response recorded' })).toHaveClass( + 'pf-chatbot__button--response-action-clicked' + ); + let unclickedButtons = buttons.filter((button) => button !== goodBtn); + unclickedButtons.forEach((button) => { + expect(button).not.toHaveClass('pf-chatbot__button--response-action-clicked'); + }); + await userEvent.click(badBtn); + expect(screen.getByRole('button', { name: 'Response recorded' })).toHaveClass( + 'pf-chatbot__button--response-action-clicked' + ); + unclickedButtons = buttons.filter((button) => button !== badBtn); + unclickedButtons.forEach((button) => { + expect(button).not.toHaveClass('pf-chatbot__button--response-action-clicked'); + }); + }); + it('should handle click outside of group of buttons correctly', async () => { + // using message just so we have something outside the group that's rendered + render( + + ); + const goodBtn = screen.getByRole('button', { name: 'Good response' }); + const badBtn = screen.getByRole('button', { name: 'Bad response' }); + expect(goodBtn).toBeTruthy(); + expect(badBtn).toBeTruthy(); + + await userEvent.click(goodBtn); + expect(screen.getByRole('button', { name: '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( + 'pf-chatbot__button--response-action-clicked' + ); + expect(goodBtn).not.toHaveClass('pf-chatbot__button--response-action-clicked'); + await userEvent.click(screen.getByText('Example with all prebuilt actions')); + expect(goodBtn).not.toHaveClass('pf-chatbot__button--response-action-clicked'); + expect(badBtn).not.toHaveClass('pf-chatbot__button--response-action-clicked'); + }); it('should render buttons correctly', () => { ALL_ACTIONS.forEach(({ type, label }) => { render(); @@ -53,6 +133,24 @@ describe('ResponseActions', () => { }); }); + it('should swap clicked and non-clicked aria labels on click', async () => { + ALL_ACTIONS.forEach(async ({ type, label, clickedLabel }) => { + 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 }) => { + 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', () => { const actions = [ { type: 'positive', ariaLabel: 'Thumbs up' }, diff --git a/packages/module/src/ResponseActions/ResponseActions.tsx b/packages/module/src/ResponseActions/ResponseActions.tsx index a766a8f9..b8462fe3 100644 --- a/packages/module/src/ResponseActions/ResponseActions.tsx +++ b/packages/module/src/ResponseActions/ResponseActions.tsx @@ -12,6 +12,8 @@ import { TooltipProps } from '@patternfly/react-core'; export interface ActionProps { /** Aria-label for the button */ ariaLabel?: string; + /** Aria-label for the button, shown when the button is clicked. */ + clickedAriaLabel?: string; /** On-click handler for the button */ onClick?: ((event: MouseEvent | React.MouseEvent | KeyboardEvent) => void) | undefined; /** Class name for the button */ @@ -20,6 +22,8 @@ export interface ActionProps { isDisabled?: boolean; /** Content shown in the tooltip */ tooltipContent?: string; + /** Content shown in the tooltip when the button is clicked. */ + clickedTooltipContent?: string; /** Props to control the PF Tooltip component */ tooltipProps?: TooltipProps; /** Icon for custom response action */ @@ -38,74 +42,117 @@ export interface ResponseActionProps { } export const ResponseActions: React.FunctionComponent = ({ actions }) => { + const [activeButton, setActiveButton] = React.useState(); const { positive, negative, copy, share, listen, ...additionalActions } = actions; + const responseActions = React.useRef(null); + + React.useEffect(() => { + const handleClickOutside = (e) => { + if (responseActions.current && !responseActions.current.contains(e.target)) { + setActiveButton(undefined); + } + }; + window.addEventListener('click', handleClickOutside); + + return () => { + window.removeEventListener('click', handleClickOutside); + }; + }, []); + + const handleClick = ( + e: MouseEvent | React.MouseEvent | KeyboardEvent, + id: string, + onClick?: (event: MouseEvent | React.MouseEvent | KeyboardEvent) => void + ) => { + setActiveButton(id); + onClick && onClick(e); + }; + return ( -
+
{positive && ( handleClick(e, 'positive', positive.onClick)} className={positive.className} isDisabled={positive.isDisabled} tooltipContent={positive.tooltipContent ?? 'Good response'} + clickedTooltipContent={positive.clickedTooltipContent ?? 'Response recorded'} tooltipProps={positive.tooltipProps} icon={} + isClicked={activeButton === 'positive'} > )} {negative && ( handleClick(e, 'negative', negative.onClick)} className={negative.className} isDisabled={negative.isDisabled} tooltipContent={negative.tooltipContent ?? 'Bad response'} + clickedTooltipContent={negative.clickedTooltipContent ?? 'Response recorded'} tooltipProps={negative.tooltipProps} icon={} + isClicked={activeButton === 'negative'} > )} {copy && ( handleClick(e, 'copy', copy.onClick)} className={copy.className} isDisabled={copy.isDisabled} tooltipContent={copy.tooltipContent ?? 'Copy'} + clickedTooltipContent={copy.clickedTooltipContent ?? 'Copied'} tooltipProps={copy.tooltipProps} icon={} + isClicked={activeButton === 'copy'} > )} {share && ( handleClick(e, 'share', share.onClick)} className={share.className} isDisabled={share.isDisabled} tooltipContent={share.tooltipContent ?? 'Share'} + clickedTooltipContent={share.clickedTooltipContent ?? 'Shared'} tooltipProps={share.tooltipProps} icon={} + isClicked={activeButton === 'share'} > )} {listen && ( handleClick(e, 'listen', listen.onClick)} className={listen.className} isDisabled={listen.isDisabled} tooltipContent={listen.tooltipContent ?? 'Listen'} + clickedTooltipContent={listen.clickedTooltipContent ?? 'Listening'} tooltipProps={listen.tooltipProps} icon={} + isClicked={activeButton === 'listen'} > )} {Object.keys(additionalActions).map((action) => ( handleClick(e, action, additionalActions[action]?.onClick)} className={additionalActions[action]?.className} isDisabled={additionalActions[action]?.isDisabled} tooltipContent={additionalActions[action]?.tooltipContent} tooltipProps={additionalActions[action]?.tooltipProps} + clickedTooltipContent={additionalActions[action]?.clickedTooltipContent} icon={additionalActions[action]?.icon} + isClicked={activeButton === action} /> ))}