Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Support for toggle buttons in button group #2909

Merged
merged 2 commits into from
Nov 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reworked button group unit tests. Now all tests are split across multiple files focusing on specific categories of tests. The tests ensure full coverage for all button group item types.

In the dev tests we validate dev warnings, fallbacks, and callbacks.

// 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
Loading