Skip to content

Commit

Permalink
feat(ResponseActions): Add click state
Browse files Browse the repository at this point in the history
Add click state to response actions block. The last currently selected item will remain selected. Clients can also pass in custom tooltips or aria labels based on the clicked state.
  • Loading branch information
rebeccaalpert committed Dec 13, 2024
1 parent f2ba7be commit ef0b576
Show file tree
Hide file tree
Showing 7 changed files with 269 additions and 48 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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: <RedoIcon />
},
download: {
ariaLabel: 'Download',
clickedAriaLabel: 'Downloaded',
// eslint-disable-next-line no-console
onClick: () => console.log('Clicked download'),
tooltipContent: 'Download',
clickedTooltipContent: 'Downloaded',
icon: <DownloadIcon />
},
info: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ 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 `<Message>` component. This object can contain the following customizations: `ariaLabel`, `onClick`, `className`, `isDisabled`, `tooltipContent`, `tooltipProps`, and `icon`.
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 `<Message>` component. This object can contain the following customizations: `ariaLabel`, `clickedAriaLabel`, `onClick`, `className`, `isDisabled`, `tooltipContent`, `tooltipContent`, `tooltipProps`, and `icon`. `clickedAriaLabel` and `clickedTooltipContent` are applied only when a button is clicked. If `clickedAriaLabel` or `clickedTooltipContent` are omitted, they will default to the `ariaLabel` or `tooltipContent` supplied.

```js file="./MessageWithCustomResponseActions.tsx"

Expand Down
52 changes: 52 additions & 0 deletions packages/module/src/ResponseActions/ResponseActionButton.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ResponseActionButton icon={<DownloadIcon />} ariaLabel="Download" clickedAriaLabel="Downloaded" />);
expect(screen.getByRole('button', { name: 'Download' })).toBeTruthy();
});
it('renders aria-label correctly if clicked', () => {
render(
<ResponseActionButton icon={<DownloadIcon />} ariaLabel="Download" clickedAriaLabel="Downloaded" isClicked />
);
expect(screen.getByRole('button', { name: 'Downloaded' })).toBeTruthy();
});
it('renders tooltip correctly if not clicked', async () => {
render(
<ResponseActionButton icon={<DownloadIcon />} 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(
<ResponseActionButton
icon={<DownloadIcon />}
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(<ResponseActionButton icon={<DownloadIcon />} 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(<ResponseActionButton icon={<DownloadIcon />} ariaLabel="Download" isClicked />);
expect(screen.getByRole('button', { name: 'Download' })).toBeTruthy();
});
});
72 changes: 45 additions & 27 deletions packages/module/src/ResponseActions/ResponseActionButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -14,43 +16,59 @@ 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<ResponseActionButtonProps> = ({
ariaLabel,
clickedAriaLabel = ariaLabel,
className,
icon,
isDisabled,
onClick,
tooltipContent,
tooltipProps
}) => (
<Tooltip
id={`pf-chatbot__tooltip-response-action-${tooltipContent}`}
content={tooltipContent}
position="bottom"
entryDelay={tooltipProps?.entryDelay || 0}
exitDelay={tooltipProps?.exitDelay || 0}
distance={tooltipProps?.distance || 8}
animationDuration={tooltipProps?.animationDuration || 0}
{...tooltipProps}
>
<Button
variant="plain"
className={`pf-chatbot__button--response-action ${className ?? ''}`}
aria-label={ariaLabel ?? tooltipContent}
icon={
<Icon isInline size="lg">
{icon}
</Icon>
}
isDisabled={isDisabled}
onClick={onClick}
size="sm"
></Button>
</Tooltip>
);
clickedTooltipContent = tooltipContent,
tooltipProps,
isClicked = false
}) => {
const generateAriaLabel = () => {
if (ariaLabel) {
return isClicked ? clickedAriaLabel : ariaLabel;
}
return isClicked ? clickedTooltipContent : tooltipContent;
};

return (
<Tooltip
id={`pf-chatbot__tooltip-response-action-${tooltipContent}`}
content={isClicked ? clickedTooltipContent : tooltipContent}
position="bottom"
entryDelay={tooltipProps?.entryDelay || 0}
exitDelay={tooltipProps?.exitDelay || 0}
distance={tooltipProps?.distance || 8}
animationDuration={tooltipProps?.animationDuration || 0}
{...tooltipProps}
>
<Button
variant="plain"
className={`pf-chatbot__button--response-action ${isClicked ? 'pf-chatbot__button--response-action-clicked' : ''} ${className ?? ''}`}
aria-label={generateAriaLabel()}
icon={
<Icon isInline size="lg">
{icon}
</Icon>
}
isDisabled={isDisabled}
onClick={onClick}
size="sm"
></Button>
</Tooltip>
);
};

export default ResponseActionButton;
18 changes: 10 additions & 8 deletions packages/module/src/ResponseActions/ResponseActions.scss
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,25 @@
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;
display: flex;
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);
}
108 changes: 103 additions & 5 deletions packages/module/src/ResponseActions/ResponseActions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: <RedoIcon />
},
download: {
ariaLabel: 'Download',
clickedAriaLabel: 'Downloaded',
onClick: jest.fn(),
tooltipContent: 'Download',
clickedTooltipContent: 'Downloaded',
icon: <DownloadIcon />
},
info: {
Expand All @@ -37,6 +42,81 @@ const CUSTOM_ACTIONS = [
];

describe('ResponseActions', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should handle click within group of buttons correctly', async () => {
render(
<ResponseActions
actions={{
positive: { onClick: jest.fn() },
negative: { onClick: jest.fn() },
copy: { onClick: jest.fn() },
share: { onClick: jest.fn() },
listen: { onClick: jest.fn() }
}}
/>
);
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(
<Message
name="Bot"
role="bot"
avatar=""
content="Example with all prebuilt actions"
actions={{
positive: {},
negative: {}
}}
/>
);
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(<ResponseActions actions={{ [type]: { onClick: jest.fn() } }} />);
Expand All @@ -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(<ResponseActions actions={{ [type]: { onClick: jest.fn() } }} />);
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(<ResponseActions actions={{ [type]: { onClick: jest.fn() } }} />);
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' },
Expand Down
Loading

0 comments on commit ef0b576

Please sign in to comment.