Skip to content

Commit

Permalink
feat: Support for toggle buttons in button group (#2909)
Browse files Browse the repository at this point in the history
  • Loading branch information
pan-kot authored Nov 12, 2024
1 parent 774fff4 commit 0763b43
Show file tree
Hide file tree
Showing 18 changed files with 781 additions and 305 deletions.
11 changes: 10 additions & 1 deletion pages/button-group/item-permutations.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import createPermutations from '../utils/permutations';
import PermutationsView from '../utils/permutations-view';
import ScreenshotArea from '../utils/screenshot-area';

const itemPermutations = createPermutations<ButtonGroupProps.IconButton>([
const itemPermutations = createPermutations<ButtonGroupProps.Item>([
// Undefined icon
{
type: ['icon-button'],
Expand Down Expand Up @@ -42,6 +42,15 @@ const itemPermutations = createPermutations<ButtonGroupProps.IconButton>([
</StatusIndicator>,
],
},
// Toggle button
{
type: ['icon-toggle-button'],
id: ['test'],
iconName: ['star'],
pressedIconName: ['star-filled'],
text: ['Add to favorites'],
pressed: [false, true],
},
]);

const menuDropdownPermutations = createPermutations<ButtonGroupProps.MenuDropdown>([
Expand Down
8 changes: 6 additions & 2 deletions pages/button-group/permutations.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,20 @@ const feedbackGroup: ButtonGroupProps.Group = {
text: 'Vote',
items: [
{
type: 'icon-button',
type: 'icon-toggle-button',
id: 'like',
iconName: 'thumbs-up',
pressedIconName: 'thumbs-up-filled',
text: 'Like',
pressed: true,
},
{
type: 'icon-button',
type: 'icon-toggle-button',
id: 'dislike',
iconName: 'thumbs-down',
pressedIconName: 'thumbs-down-filled',
text: 'Dislike',
pressed: false,
},
],
};
Expand Down
50 changes: 38 additions & 12 deletions pages/button-group/test.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,25 +27,36 @@ export default function ButtonGroupPage() {
const ref = React.useRef<ButtonGroupProps.Ref>(null);
const [feedback, setFeedback] = useState<'none' | 'like' | 'dislike'>('none');
const [isFavorite, setFavorite] = useState(false);
const [useExperimentalFeatures, setUseExperimentalFeatures] = useState(false);
const [loadingId, setLoading] = useState<null | string>(null);
const [canSend, setCanSend] = useState(true);
const [canRedo, setCanRedo] = useState(true);

const toggleTexts = {
like: ['Like', 'Liked'],
dislike: ['Dislike', 'Disliked'],
favorite: ['Add to favorites', 'Added to favorites'],
};

const feedbackGroup: ButtonGroupProps.Group = {
type: 'group',
text: 'Vote',
items: [
{
type: 'icon-button',
type: 'icon-toggle-button',
id: 'like',
iconName: feedback === 'like' ? 'thumbs-up-filled' : 'thumbs-up',
text: 'Like',
iconName: 'thumbs-up',
pressedIconName: 'thumbs-up-filled',
text: feedback === 'like' ? toggleTexts.like[1] : toggleTexts.like[0],
pressed: feedback === 'like',
},
{
type: 'icon-button',
type: 'icon-toggle-button',
id: 'dislike',
iconName: feedback === 'dislike' ? 'thumbs-down-filled' : 'thumbs-down',
text: 'Dislike',
iconName: 'thumbs-down',
pressedIconName: 'thumbs-down-filled',
text: feedback === 'dislike' ? toggleTexts.dislike[1] : toggleTexts.dislike[0],
pressed: feedback === 'dislike',
},
],
};
Expand All @@ -55,12 +66,13 @@ export default function ButtonGroupPage() {
text: 'Favorite',
items: [
{
type: 'icon-button',
type: 'icon-toggle-button',
id: 'favorite',
iconName: isFavorite ? 'star-filled' : 'star',
text: 'Add to favorites',
iconName: 'star',
pressedIconName: 'star-filled',
text: isFavorite ? toggleTexts.favorite[1] : toggleTexts.favorite[0],
loading: loadingId === 'favorite',
popoverFeedback: loadingId === 'favorite' ? '...' : isFavorite ? 'Set as favorite' : 'Removed',
pressed: isFavorite,
},
],
};
Expand Down Expand Up @@ -142,6 +154,18 @@ export default function ButtonGroupPage() {
{ id: 'search', iconName: 'search', text: 'Search' },
],
},
{
text: 'Settings',
items: [
{
id: 'experimental-features',
itemType: 'checkbox',
iconName: 'bug',
text: 'Experimental features',
checked: useExperimentalFeatures,
},
],
},
],
};

Expand Down Expand Up @@ -191,9 +215,9 @@ export default function ButtonGroupPage() {
switch (detail.id) {
case 'like':
case 'dislike':
return syncAction(() => setFeedback(prev => (prev !== detail.id ? (detail.id as 'like' | 'dislike') : 'none')));
return syncAction(() => setFeedback(detail.pressed ? (detail.id as 'like' | 'dislike') : 'none'));
case 'favorite':
return asyncAction(() => setFavorite(prev => !prev));
return asyncAction(() => setFavorite(!!detail.pressed));
case 'send':
return syncAction(() => setCanSend(false));
case 'redo':
Expand All @@ -202,6 +226,8 @@ export default function ButtonGroupPage() {
case 'remove':
case 'open':
return asyncAction();
case 'experimental-features':
return syncAction(() => setUseExperimentalFeatures(!!detail.pressed));
default:
return syncAction();
}
Expand Down
37 changes: 32 additions & 5 deletions src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -3816,11 +3816,21 @@ exports[`Documenter definition for button-group matches the snapshot: button-gro
"detailInlineType": {
"name": "InternalButtonGroupProps.ItemClickDetails",
"properties": [
{
"name": "checked",
"optional": true,
"type": "false | true",
},
{
"name": "id",
"optional": false,
"type": "string",
},
{
"name": "pressed",
"optional": true,
"type": "false | true",
},
],
"type": "object",
},
Expand Down Expand Up @@ -3887,15 +3897,32 @@ use the \`id\` attribute, consider setting it on a parent element instead.",
### icon-button

* \`id\` (string) - The unique identifier of the button, used as detail in \`onItemClick\` handler and to focus the button using \`ref.focus(id)\`.
* \`text\` (string) - The name shown as a tooltip or menu text for this button.
* \`disabled\` (optional, boolean) - The disabled state indication for the button.
* \`loading\` (optional, boolean) - The loading state indication for the button.
* \`text\` (string) - The name shown as a tooltip for this button.
* \`disabled\` (optional, boolean) - The disabled state indication for this button.
* \`loading\` (optional, boolean) - The loading state indication for this button.
* \`loadingText\` (optional, string) - The loading text announced to screen readers.
* \`iconName\` (optional, string) - Specifies the name of the icon, used with the [icon component](/components/icon/).
* \`iconAlt\` (optional, string) - Specifies alternate text for the icon when using \`iconUrl\`.
* \`iconUrl\` (optional, string) - Specifies the URL of a custom icon.
* \`iconSvg\` (optional, ReactNode) - Custom SVG icon. Equivalent to the \`svg\` slot of the [icon component](/components/icon/).
* \`popoverFeedback\` (optional, string) - Text that appears when the user clicks the button. Use to provide feedback to the user.
* \`popoverFeedback\` (optional, ReactNode) - Text that appears when the user clicks the button. Use to provide feedback to the user.

### icon-toggle-button

* \`id\` (string) - The unique identifier of the button, used as detail in \`onItemClick\` handler and to focus the button using \`ref.focus(id)\`.
* \`pressed\` (boolean) - The toggle button pressed state.
* \`text\` (string) - The name shown as a tooltip for this button.
* \`disabled\` (optional, boolean) - The disabled state indication for this button.
* \`loading\` (optional, boolean) - The loading state indication for this button.
* \`loadingText\` (optional, string) - The loading text announced to screen readers.
* \`iconName\` (optional, string) - Specifies the name of the icon, used with the [icon component](/components/icon/).
* \`iconUrl\` (optional, string) - Specifies the URL of a custom icon.
* \`iconSvg\` (optional, ReactNode) - Custom SVG icon. Equivalent to the \`svg\` slot of the [icon component](/components/icon/).
* \`pressedIconName\` (optional, string) - Specifies the name of the icon in pressed state, used with the [icon component](/components/icon/).
* \`pressedIconUrl\` (optional, string) - Specifies the URL of a custom icon in pressed state.
* \`pressedIconSvg\` (optional, ReactNode) - Custom SVG icon in pressed state. Equivalent to the \`svg\` slot of the [icon component](/components/icon/).
* \`popoverFeedback\` (optional, ReactNode) - Text that appears when the user clicks the button. Use to provide feedback to the user.
* \`pressedPopoverFeedback\` (optional, ReactNode) - Text that appears when the user clicks the button in pressed state. Defaults to \`popoverFeedback\`.

### menu-dropdown

Expand All @@ -3906,7 +3933,7 @@ use the \`id\` attribute, consider setting it on a parent element instead.",
* \`loadingText\` (optional, string) - The loading text announced to screen readers.
* \`items\` (ButtonDropdownProps.ItemOrGroup[]) - The array of dropdown items that belong to this menu.

group
### group

* \`text\` (string) - The name of the group rendered as ARIA label for this group.
* \`items\` ((ButtonGroupProps.IconButton | ButtonGroupProps.MenuDropdown)[]) - The array of items that belong to this group.
Expand Down
6 changes: 3 additions & 3 deletions src/button-group/__integ__/button-group.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ test(
'shows tooltip when a button is focused',
setup({}, async page => {
await page.click(likeButton.toSelector());
await expect(page.getText(buttonGroup.findTooltip().toSelector())).resolves.toBe('Like');
await expect(page.getText(buttonGroup.findTooltip().toSelector())).resolves.toBe('Liked');

await page.click(createWrapper().find('[data-testid="focus-on-copy"]').toSelector());
await expect(page.isFocused(copyButton.toSelector())).resolves.toBe(true);
Expand All @@ -92,7 +92,7 @@ test(
'hides popover after clicking outside',
setup({}, async page => {
await page.click(likeButton.toSelector());
await expect(page.getText(buttonGroup.findTooltip().toSelector())).resolves.toBe('Like');
await expect(page.getText(buttonGroup.findTooltip().toSelector())).resolves.toBe('Liked');

await page.click(createWrapper().find('#log').toSelector());
await expect(page.isExisting(buttonGroup.findTooltip().toSelector())).resolves.toBe(false);
Expand Down Expand Up @@ -146,7 +146,7 @@ test(
await expect(page.getText(buttonGroup.findTooltip().toSelector())).resolves.toBe('Like');

await page.click(likeButton.toSelector());
await expect(page.getText(buttonGroup.findTooltip().toSelector())).resolves.toBe('Like');
await expect(page.getText(buttonGroup.findTooltip().toSelector())).resolves.toBe('Liked');
})
);

Expand Down
137 changes: 137 additions & 0 deletions src/button-group/__tests__/button-group-dev.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import { warnOnce } from '@cloudscape-design/component-toolkit/internal';

import { ButtonGroupProps } from '../../../lib/components/button-group';
import { renderButtonGroup } from './common';

import buttonStyles from '../../../lib/components/button/styles.css.js';

jest.mock('@cloudscape-design/component-toolkit/internal', () => ({
...jest.requireActual('@cloudscape-design/component-toolkit/internal'),
warnOnce: jest.fn(),
}));

afterEach(() => {
(warnOnce as jest.Mock).mockReset();
});

const emptyGroup: ButtonGroupProps.ItemOrGroup[] = [
{
type: 'group',
text: 'Feedback',
items: [],
},
];

test('warns and renders some icon when no icon specified for icon button', () => {
const { wrapper } = renderButtonGroup({ items: [{ type: 'icon-button', id: 'search', text: 'Search' }] });

expect(warnOnce).toHaveBeenCalledTimes(1);
expect(warnOnce).toHaveBeenCalledWith('ButtonGroup', 'Missing icon for item with id: search');
expect(wrapper.findMenuById('search')!.findAll(`.${buttonStyles.icon}`)).toHaveLength(1);
});

test.each([{ pressed: false }, { pressed: true }])(
'warns and renders some icon when no icon specified for icon toggle button, pressed=$pressed',
({ pressed }) => {
const { wrapper } = renderButtonGroup({
items: [{ type: 'icon-toggle-button', id: 'like', pressed, text: 'Like' }],
});

expect(warnOnce).toHaveBeenCalledTimes(2);
expect(warnOnce).toHaveBeenCalledWith('ButtonGroup', 'Missing icon for item with id: like');
expect(warnOnce).toHaveBeenCalledWith('ButtonGroup', 'Missing pressed icon for item with id: like');
expect(wrapper.findMenuById('like')!.findAll(`.${buttonStyles.icon}`)).toHaveLength(1);
}
);

test('warns if empty group is provided', () => {
renderButtonGroup({ items: emptyGroup });

expect(warnOnce).toHaveBeenCalledTimes(1);
expect(warnOnce).toHaveBeenCalledWith('ButtonGroup', 'Empty group detected. Empty groups are not allowed.');
});

test('uses non-pressed popover feedback if pressed is not provided', () => {
const { wrapper } = renderButtonGroup({
items: [
{
type: 'icon-toggle-button',
id: 'like',
pressed: true,
text: 'Like',
popoverFeedback: 'You like it!',
},
],
});

wrapper.findToggleButtonById('like')!.click();
expect(wrapper.findTooltip()!.getElement()).toHaveTextContent('You like it!');
});

test('handles item click', () => {
const onItemClick = jest.fn();
const { wrapper } = renderButtonGroup({
items: [{ type: 'icon-button', id: 'search', text: 'Search' }],
onItemClick,
});

wrapper.findButtonById('search')!.click();

expect(onItemClick).toHaveBeenCalledWith(expect.objectContaining({ detail: { id: 'search' } }));
});

test('handles toggle item click', () => {
const onItemClick = jest.fn();
const { wrapper } = renderButtonGroup({
items: [
{
type: 'icon-toggle-button',
id: 'like',
pressed: false,
text: 'Like',
},
{
type: 'icon-toggle-button',
id: 'dislike',
pressed: true,
text: 'Dislike',
},
],
onItemClick,
});

wrapper.findButtonById('like')!.click();
wrapper.findButtonById('dislike')!.click();

expect(onItemClick).toHaveBeenCalledWith(expect.objectContaining({ detail: { id: 'like', pressed: true } }));
expect(onItemClick).toHaveBeenCalledWith(expect.objectContaining({ detail: { id: 'dislike', pressed: false } }));
});

test('handles menu click', () => {
const onItemClick = jest.fn();
const { wrapper } = renderButtonGroup({
items: [
{
type: 'menu-dropdown',
id: 'misc',
text: 'Misc',
items: [
{ id: 'dark-mode', itemType: 'checkbox', text: 'Dark mode', checked: false },
{ id: 'compact-mode', itemType: 'checkbox', text: 'Compact mode', checked: true },
],
},
],
onItemClick,
});

wrapper.findMenuById('misc')!.openDropdown();
wrapper.findMenuById('misc')!.findItemById('dark-mode')!.click();
expect(onItemClick).toHaveBeenCalledWith(expect.objectContaining({ detail: { id: 'dark-mode', checked: true } }));

wrapper.findMenuById('misc')!.openDropdown();
wrapper.findMenuById('misc')!.findItemById('compact-mode')!.click();
expect(onItemClick).toHaveBeenCalledWith(expect.objectContaining({ detail: { id: 'compact-mode', checked: false } }));
});
Loading

0 comments on commit 0763b43

Please sign in to comment.