From 75637cd15761b24bd749af9fbce75eef697f28aa Mon Sep 17 00:00:00 2001 From: Eric Olkowski Date: Tue, 10 Feb 2026 14:30:34 -0500 Subject: [PATCH 1/4] feat(ResponseActions): added opt-in for hiding actions until interaction --- .../Messages/MessageWithResponseActions.tsx | 57 +++++--- packages/module/src/Message/Message.scss | 9 ++ packages/module/src/Message/Message.test.tsx | 136 ++++++++++++++++++ packages/module/src/Message/Message.tsx | 13 +- .../ResponseActions/ResponseActions.test.tsx | 59 ++++++++ .../src/ResponseActions/ResponseActions.tsx | 47 ++++-- 6 files changed, 292 insertions(+), 29 deletions(-) diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/MessageWithResponseActions.tsx b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/MessageWithResponseActions.tsx index e53ca1b79..d95d1c406 100644 --- a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/MessageWithResponseActions.tsx +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/MessageWithResponseActions.tsx @@ -4,22 +4,43 @@ import Message from '@patternfly/chatbot/dist/dynamic/Message'; import patternflyAvatar from './patternfly_avatar.jpg'; export const ResponseActionExample: FunctionComponent = () => ( - console.log('Good response') }, - // eslint-disable-next-line no-console - negative: { onClick: () => console.log('Bad response') }, - // eslint-disable-next-line no-console - copy: { onClick: () => console.log('Copy') }, - // eslint-disable-next-line no-console - download: { onClick: () => console.log('Download') }, - // eslint-disable-next-line no-console - listen: { onClick: () => console.log('Listen') } - }} - /> + <> + console.log('Good response') }, + // eslint-disable-next-line no-console + negative: { onClick: () => console.log('Bad response') }, + // eslint-disable-next-line no-console + copy: { onClick: () => console.log('Copy') }, + // eslint-disable-next-line no-console + download: { onClick: () => console.log('Download') }, + // eslint-disable-next-line no-console + listen: { onClick: () => console.log('Listen') } + }} + /> + console.log('Good response') }, + // eslint-disable-next-line no-console + negative: { onClick: () => console.log('Bad response') }, + // eslint-disable-next-line no-console + copy: { onClick: () => console.log('Copy') }, + // eslint-disable-next-line no-console + download: { onClick: () => console.log('Download') }, + // eslint-disable-next-line no-console + listen: { onClick: () => console.log('Listen') } + }} + /> + ); diff --git a/packages/module/src/Message/Message.scss b/packages/module/src/Message/Message.scss index 3b086dcf6..fb0b462e3 100644 --- a/packages/module/src/Message/Message.scss +++ b/packages/module/src/Message/Message.scss @@ -92,6 +92,15 @@ gap: var(--pf-t--global--spacer--sm); } + .pf-m-visible-interaction { + opacity: 0; + } + + &:hover .pf-m-visible-interaction, + .pf-m-visible-interaction:focus-within { + opacity: 1; + } + // targets footnotes specifically .footnotes, .pf-chatbot__message-text.footnotes { diff --git a/packages/module/src/Message/Message.test.tsx b/packages/module/src/Message/Message.test.tsx index 754910caa..ab76ec7dd 100644 --- a/packages/module/src/Message/Message.test.tsx +++ b/packages/module/src/Message/Message.test.tsx @@ -1415,4 +1415,140 @@ describe('Message', () => { expect(screen.getByText('ThumbsUpIcon')).toBeInTheDocument(); expect(screen.queryByText('OutlinedThumbsUpIcon')).not.toBeInTheDocument(); }); + + it('should apply pf-m-visible-interaction class to response actions when showActionsOnInteraction is true', () => { + render( + + ); + + const responseContainer = screen + .getByRole('button', { name: 'Good response' }) + .closest('.pf-chatbot__response-actions'); + expect(responseContainer).toHaveClass('pf-m-visible-interaction'); + }); + + it('should not apply pf-m-visible-interaction class to response actions when showActionsOnInteraction is false', () => { + render( + + ); + + const responseContainer = screen + .getByRole('button', { name: 'Good response' }) + .closest('.pf-chatbot__response-actions'); + expect(responseContainer).not.toHaveClass('pf-m-visible-interaction'); + }); + + it('should not apply pf-m-visible-interaction class to response actions by default', () => { + render( + + ); + + const responseContainer = screen + .getByRole('button', { name: 'Good response' }) + .closest('.pf-chatbot__response-actions'); + expect(responseContainer).not.toHaveClass('pf-m-visible-interaction'); + }); + + it('should apply pf-m-visible-interaction class to grouped actions container when showActionsOnInteraction is true', () => { + render( + + ); + + const responseContainer = screen + .getByRole('button', { name: 'Good response' }) + .closest('.pf-chatbot__response-actions-groups'); + expect(responseContainer).toHaveClass('pf-m-visible-interaction'); + }); + + it('should not apply pf-m-visible-interaction class to grouped actions container when showActionsOnInteraction is false', () => { + render( + + ); + + const responseContainer = screen + .getByRole('button', { name: 'Good response' }) + .closest('.pf-chatbot__response-actions-groups'); + expect(responseContainer).not.toHaveClass('pf-m-visible-interaction'); + }); + + it('should not apply pf-m-visible-interaction class to grouped actions container by default', () => { + render( + + ); + + const responseContainer = screen + .getByRole('button', { name: 'Good response' }) + .closest('.pf-chatbot__response-actions-groups'); + expect(responseContainer).not.toHaveClass('pf-m-visible-interaction'); + }); }); diff --git a/packages/module/src/Message/Message.tsx b/packages/module/src/Message/Message.tsx index 918a6499b..fab7f6147 100644 --- a/packages/module/src/Message/Message.tsx +++ b/packages/module/src/Message/Message.tsx @@ -114,6 +114,10 @@ export interface MessageProps extends Omit, 'role'> { * For finer control of multiple action groups, use persistActionSelection on each group. */ persistActionSelection?: boolean; + /** Flag indicating whether the actions container is only visible when a message is hovered or an action would receive focus. Note + * that setting this to true will append tooltips inline instead of the document.body. + */ + showActionsOnInteraction?: boolean; /** Sources for message */ sources?: SourcesCardProps; /** Label for the English word "AI," used to tag messages with role "bot" */ @@ -214,6 +218,7 @@ export const MessageBase: FunctionComponent = ({ isLoading, actions, persistActionSelection, + showActionsOnInteraction = false, sources, botWord = 'AI', loadingWord = 'Loading message', @@ -382,7 +387,12 @@ export const MessageBase: FunctionComponent = ({ {!isLoading && !isEditable && actions && ( <> {Array.isArray(actions) ? ( -
+
{actions.map((actionGroup, index) => ( = ({ actions={actions} persistActionSelection={persistActionSelection} useFilledIconsOnClick={useFilledIconsOnClick} + showActionsOnInteraction={showActionsOnInteraction} /> )} diff --git a/packages/module/src/ResponseActions/ResponseActions.test.tsx b/packages/module/src/ResponseActions/ResponseActions.test.tsx index c3fba1265..d19699337 100644 --- a/packages/module/src/ResponseActions/ResponseActions.test.tsx +++ b/packages/module/src/ResponseActions/ResponseActions.test.tsx @@ -437,6 +437,65 @@ describe('ResponseActions', () => { expect(customBtn).not.toHaveClass('pf-chatbot__button--response-action-clicked'); }); + it('should apply pf-m-visible-interaction class when showActionsOnInteraction is true', () => { + render( + + ); + + expect(screen.getByTestId('test-id')).toHaveClass('pf-m-visible-interaction'); + }); + + it('should not apply pf-m-visible-interaction class when showActionsOnInteraction is false', () => { + render( + + ); + + expect(screen.getByTestId('test-id')).not.toHaveClass('pf-m-visible-interaction'); + }); + + it('should not apply pf-m-visible-interaction class by default', () => { + render( + + ); + + expect(screen.getByTestId('test-id')).not.toHaveClass('pf-m-visible-interaction'); + }); + + it('should render with custom className', () => { + render( + + ); + + expect(screen.getByTestId('test-id')).toHaveClass('custom-class'); + }); + describe('icon swapping with useFilledIconsOnClick', () => { it('should render outline icons by default', () => { render( diff --git a/packages/module/src/ResponseActions/ResponseActions.tsx b/packages/module/src/ResponseActions/ResponseActions.tsx index 630b3ad2f..91dd971c9 100644 --- a/packages/module/src/ResponseActions/ResponseActions.tsx +++ b/packages/module/src/ResponseActions/ResponseActions.tsx @@ -13,6 +13,7 @@ import { } from '@patternfly/react-icons'; import ResponseActionButton from './ResponseActionButton'; import { ButtonProps, TooltipProps } from '@patternfly/react-core'; +import { css } from '@patternfly/react-styles'; export interface ActionProps extends Omit { /** Aria-label for the button */ @@ -51,6 +52,8 @@ type ExtendedActionProps = ActionProps & { */ export interface ResponseActionProps { + /** Additional classes for the response actions container. */ + className?: string; /** Props for message actions, such as feedback (positive or negative), copy button, share, and listen */ actions: Record & { positive?: ActionProps; @@ -67,12 +70,19 @@ export interface ResponseActionProps { /** When true, automatically swaps to filled icon variants when predefined actions are clicked. * Predefined actions will use filled variants (e.g., ThumbsUpIcon) when clicked and outline variants (e.g., OutlinedThumbsUpIcon) when not clicked. */ useFilledIconsOnClick?: boolean; + /** Flag indicating whether the actions container is only visible when a message is hovered or an action would receive focus. Note + * that setting this to true will append tooltips inline instead of the document.body. + */ + showActionsOnInteraction?: boolean; } export const ResponseActions: FunctionComponent = ({ + className, actions, persistActionSelection = false, - useFilledIconsOnClick = false + useFilledIconsOnClick = false, + showActionsOnInteraction = false, + ...props }) => { const [activeButton, setActiveButton] = useState(); const [clickStatePersisted, setClickStatePersisted] = useState(false); @@ -173,8 +183,25 @@ export const ResponseActions: FunctionComponent = ({ return iconMap[actionName].outlined; }; + // We want to append the tooltip inline so that hovering the tooltip keeps the actions container visible + // when showActionsOnInteraction is true. Otherwise hovering the tooltip causes the actions container + // to disappear but the tooltip will remain visible. + const getTooltipContainer = (): HTMLElement => { + return responseActions.current || document.body; + }; + + const getTooltipProps = (tooltipProps?: TooltipProps) => + ({ + ...(showActionsOnInteraction && { appendTo: getTooltipContainer }), + ...tooltipProps + }) as TooltipProps; + return ( -
+
{positive && ( = ({ isDisabled={positive.isDisabled} tooltipContent={positive.tooltipContent ?? 'Good response'} clickedTooltipContent={positive.clickedTooltipContent ?? 'Good response recorded'} - tooltipProps={positive.tooltipProps} + tooltipProps={getTooltipProps(positive.tooltipProps)} icon={getIcon('positive')} isClicked={activeButton === 'positive'} ref={positive.ref} @@ -203,7 +230,7 @@ export const ResponseActions: FunctionComponent = ({ isDisabled={negative.isDisabled} tooltipContent={negative.tooltipContent ?? 'Bad response'} clickedTooltipContent={negative.clickedTooltipContent ?? 'Bad response recorded'} - tooltipProps={negative.tooltipProps} + tooltipProps={getTooltipProps(negative.tooltipProps)} icon={getIcon('negative')} isClicked={activeButton === 'negative'} ref={negative.ref} @@ -221,7 +248,7 @@ export const ResponseActions: FunctionComponent = ({ isDisabled={copy.isDisabled} tooltipContent={copy.tooltipContent ?? 'Copy'} clickedTooltipContent={copy.clickedTooltipContent ?? 'Copied'} - tooltipProps={copy.tooltipProps} + tooltipProps={getTooltipProps(copy.tooltipProps)} icon={} isClicked={activeButton === 'copy'} ref={copy.ref} @@ -239,7 +266,7 @@ export const ResponseActions: FunctionComponent = ({ isDisabled={edit.isDisabled} tooltipContent={edit.tooltipContent ?? 'Edit '} clickedTooltipContent={edit.clickedTooltipContent ?? 'Editing'} - tooltipProps={edit.tooltipProps} + tooltipProps={getTooltipProps(edit.tooltipProps)} icon={} isClicked={activeButton === 'edit'} ref={edit.ref} @@ -257,7 +284,7 @@ export const ResponseActions: FunctionComponent = ({ isDisabled={share.isDisabled} tooltipContent={share.tooltipContent ?? 'Share'} clickedTooltipContent={share.clickedTooltipContent ?? 'Shared'} - tooltipProps={share.tooltipProps} + tooltipProps={getTooltipProps(share.tooltipProps)} icon={} isClicked={activeButton === 'share'} ref={share.ref} @@ -275,7 +302,7 @@ export const ResponseActions: FunctionComponent = ({ isDisabled={download.isDisabled} tooltipContent={download.tooltipContent ?? 'Download'} clickedTooltipContent={download.clickedTooltipContent ?? 'Downloaded'} - tooltipProps={download.tooltipProps} + tooltipProps={getTooltipProps(download.tooltipProps)} icon={} isClicked={activeButton === 'download'} ref={download.ref} @@ -293,7 +320,7 @@ export const ResponseActions: FunctionComponent = ({ isDisabled={listen.isDisabled} tooltipContent={listen.tooltipContent ?? 'Listen'} clickedTooltipContent={listen.clickedTooltipContent ?? 'Listening'} - tooltipProps={listen.tooltipProps} + tooltipProps={getTooltipProps(listen.tooltipProps)} icon={} isClicked={activeButton === 'listen'} ref={listen.ref} @@ -312,7 +339,7 @@ export const ResponseActions: FunctionComponent = ({ className={additionalActions[action]?.className} isDisabled={additionalActions[action]?.isDisabled} tooltipContent={additionalActions[action]?.tooltipContent} - tooltipProps={additionalActions[action]?.tooltipProps} + tooltipProps={getTooltipProps(additionalActions[action]?.tooltipProps)} clickedTooltipContent={additionalActions[action]?.clickedTooltipContent} icon={additionalActions[action]?.icon} isClicked={activeButton === action} From 31e3d95ad2350eb269f2af2ec89db8f1078ee943 Mon Sep 17 00:00:00 2001 From: Eric Olkowski Date: Tue, 10 Feb 2026 14:38:30 -0500 Subject: [PATCH 2/4] Fixed lint error --- packages/module/src/ResponseActions/ResponseActions.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/module/src/ResponseActions/ResponseActions.tsx b/packages/module/src/ResponseActions/ResponseActions.tsx index 91dd971c9..7c40cb7c6 100644 --- a/packages/module/src/ResponseActions/ResponseActions.tsx +++ b/packages/module/src/ResponseActions/ResponseActions.tsx @@ -186,9 +186,7 @@ export const ResponseActions: FunctionComponent = ({ // We want to append the tooltip inline so that hovering the tooltip keeps the actions container visible // when showActionsOnInteraction is true. Otherwise hovering the tooltip causes the actions container // to disappear but the tooltip will remain visible. - const getTooltipContainer = (): HTMLElement => { - return responseActions.current || document.body; - }; + const getTooltipContainer = (): HTMLElement => responseActions.current || document.body; const getTooltipProps = (tooltipProps?: TooltipProps) => ({ From feec20c09adb5c12f6b2453914939e6e5006e818 Mon Sep 17 00:00:00 2001 From: Eric Olkowski Date: Tue, 17 Feb 2026 14:03:22 -0500 Subject: [PATCH 3/4] Added transition fade --- packages/module/src/Message/Message.scss | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/module/src/Message/Message.scss b/packages/module/src/Message/Message.scss index fb0b462e3..82249e882 100644 --- a/packages/module/src/Message/Message.scss +++ b/packages/module/src/Message/Message.scss @@ -94,6 +94,9 @@ .pf-m-visible-interaction { opacity: 0; + transition-timing-function: var(--pf-t--global--motion--timing-function--default); + transition-duration: var(--pf-t--global--motion--duration--fade--short); + transition-property: opacity; } &:hover .pf-m-visible-interaction, From e990e3f10b927892816ff0ece933d7b195962296 Mon Sep 17 00:00:00 2001 From: Eric Olkowski Date: Wed, 18 Feb 2026 10:37:35 -0500 Subject: [PATCH 4/4] Updated example verbiage per Erin --- .../chatbot/examples/Messages/Messages.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 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 faf94aab3..bb329c046 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 @@ -95,14 +95,16 @@ For example, you can use the default divider to display a "timestamp" for more s ### Message actions -You can add actions to a message, to allow users to interact with the message content. These actions can include: +To let users interact with a bot's responses, you can add support for message actions. While you can customize message actions to your needs, default options include the following: -- Feedback responses that allow users to rate a message as "good" or "bad". -- Copy and share controls that allow users to share the message content with others. -- An edit action to allow users to edit a message they previously sent. This should only be applied to user messages - see the [user messages example](#user-messages) for details on how to implement this action. -- A listen action, that will read the message content out loud. +- Positive and negative feedback: Allows users to rate a message as "good" or "bad." +- Copy: Allows users to copy the message content to their clipboard. +- Download: Allows users to download the message content. +- Listen: Reads the message content out loud using text-to-speech. -**Note:** The logic for the actions is not built into the component and must be implemented by the consuming application. +You can display message actions by default, or use the `showActionsOnInteraction` prop to reveal actions on hover or keyboard focus. + +**Note**: The underlying logic for these actions is not built-in and must be implemented within the consuming application. ```js file="./MessageWithResponseActions.tsx" @@ -140,11 +142,10 @@ When `persistActionSelection` is `true`: ### Message actions that fill -To provide enhanced visual feedback when users interact with response actions, you can enable icon swapping by setting `useFilledIconsOnClick` to `true`. When enabled, the predefined "positive" and "negative" actions will automatically swap to their filled icon counterparts when clicked, replacing the original outlined icon variants. +To provide enhanced visual feedback when users interact with response actions, you can enable icon swapping by setting `useFilledIconsOnClick` to `true`. When enabled, the predefined "positive" and "negative" actions will automatically swap to their filled icon counterparts when clicked, replacing the original outlined icon variants. This is especially useful for actions that are intended to persist (such as the "positive" and "negative" responses), so that a user's selection is more clear and emphasized. - ```js file="./MessageWithIconSwapping.tsx" ```