From 4ffe835dad9cbdee36b91cc0faa0f5a412cf140a Mon Sep 17 00:00:00 2001 From: Andrei Zhaleznichenka Date: Mon, 21 Oct 2024 16:10:13 +0200 Subject: [PATCH 1/2] feat: Support for toggle buttons in button group --- pages/button-group/item-permutations.page.tsx | 12 +- pages/button-group/permutations.page.tsx | 10 +- pages/button-group/test.page.tsx | 41 ++- .../__snapshots__/documenter.test.ts.snap | 38 ++- .../__integ__/button-group.test.ts | 6 +- .../__tests__/button-group-dev.test.tsx | 140 +++++++++ .../__tests__/button-group-focus.test.tsx | 66 +++++ .../__tests__/button-group-keyboard.test.tsx | 100 +++++++ .../__tests__/button-group-states.test.tsx | 119 ++++++++ .../__tests__/button-group-tooltips.test.tsx | 103 +++++++ .../__tests__/button-group.test.tsx | 274 ------------------ src/button-group/__tests__/common.tsx | 22 ++ src/button-group/icon-toggle-button-item.tsx | 87 ++++++ src/button-group/interfaces.ts | 51 +++- src/button-group/item-element.tsx | 10 + src/button-group/menu-dropdown-item.tsx | 2 +- src/test-utils/dom/button-group/index.ts | 10 + src/toggle-button/internal.tsx | 2 +- 18 files changed, 791 insertions(+), 302 deletions(-) create mode 100644 src/button-group/__tests__/button-group-dev.test.tsx create mode 100644 src/button-group/__tests__/button-group-focus.test.tsx create mode 100644 src/button-group/__tests__/button-group-keyboard.test.tsx create mode 100644 src/button-group/__tests__/button-group-states.test.tsx create mode 100644 src/button-group/__tests__/button-group-tooltips.test.tsx delete mode 100644 src/button-group/__tests__/button-group.test.tsx create mode 100644 src/button-group/__tests__/common.tsx create mode 100644 src/button-group/icon-toggle-button-item.tsx diff --git a/pages/button-group/item-permutations.page.tsx b/pages/button-group/item-permutations.page.tsx index 3a944d5382..c5c2c69fdf 100644 --- a/pages/button-group/item-permutations.page.tsx +++ b/pages/button-group/item-permutations.page.tsx @@ -11,7 +11,7 @@ import createPermutations from '../utils/permutations'; import PermutationsView from '../utils/permutations-view'; import ScreenshotArea from '../utils/screenshot-area'; -const itemPermutations = createPermutations([ +const itemPermutations = createPermutations([ // Undefined icon { type: ['icon-button'], @@ -42,6 +42,16 @@ const itemPermutations = createPermutations([ , ], }, + // Toggle button + { + type: ['icon-toggle-button'], + id: ['test'], + iconName: ['star'], + pressedIconName: ['star-filled'], + text: ['Add to favorites'], + pressedText: ['Added to favorites'], + pressed: [false, true], + }, ]); const menuDropdownPermutations = createPermutations([ diff --git a/pages/button-group/permutations.page.tsx b/pages/button-group/permutations.page.tsx index 129de7bae2..c87e3d77c7 100644 --- a/pages/button-group/permutations.page.tsx +++ b/pages/button-group/permutations.page.tsx @@ -14,16 +14,22 @@ 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', + pressedText: 'Like', + pressed: true, }, { - type: 'icon-button', + type: 'icon-toggle-button', id: 'dislike', iconName: 'thumbs-down', + pressedIconName: 'thumbs-down-filled', text: 'Dislike', + pressedText: 'Dislike', + pressed: false, }, ], }; diff --git a/pages/button-group/test.page.tsx b/pages/button-group/test.page.tsx index b2f86a54a8..1f48c26039 100644 --- a/pages/button-group/test.page.tsx +++ b/pages/button-group/test.page.tsx @@ -27,6 +27,7 @@ export default function ButtonGroupPage() { const ref = React.useRef(null); const [feedback, setFeedback] = useState<'none' | 'like' | 'dislike'>('none'); const [isFavorite, setFavorite] = useState(false); + const [useExperimentalFeatures, setUseExperimentalFeatures] = useState(false); const [loadingId, setLoading] = useState(null); const [canSend, setCanSend] = useState(true); const [canRedo, setCanRedo] = useState(true); @@ -36,16 +37,22 @@ export default function ButtonGroupPage() { text: 'Vote', items: [ { - type: 'icon-button', + type: 'icon-toggle-button', id: 'like', - iconName: feedback === 'like' ? 'thumbs-up-filled' : 'thumbs-up', + iconName: 'thumbs-up', + pressedIconName: 'thumbs-up-filled', text: 'Like', + pressedText: 'Liked', + pressed: feedback === 'like', }, { - type: 'icon-button', + type: 'icon-toggle-button', id: 'dislike', - iconName: feedback === 'dislike' ? 'thumbs-down-filled' : 'thumbs-down', + iconName: 'thumbs-down', + pressedIconName: 'thumbs-down-filled', text: 'Dislike', + pressedText: 'Disliked', + pressed: feedback === 'dislike', }, ], }; @@ -55,12 +62,14 @@ export default function ButtonGroupPage() { text: 'Favorite', items: [ { - type: 'icon-button', + type: 'icon-toggle-button', id: 'favorite', - iconName: isFavorite ? 'star-filled' : 'star', + iconName: 'star', + pressedIconName: 'star-filled', text: 'Add to favorites', + pressedText: 'Added to favorites', loading: loadingId === 'favorite', - popoverFeedback: loadingId === 'favorite' ? '...' : isFavorite ? 'Set as favorite' : 'Removed', + pressed: isFavorite, }, ], }; @@ -142,6 +151,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, + }, + ], + }, ], }; @@ -191,9 +212,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': @@ -202,6 +223,8 @@ export default function ButtonGroupPage() { case 'remove': case 'open': return asyncAction(); + case 'experimental-features': + return syncAction(() => setUseExperimentalFeatures(!!detail.pressed)); default: return syncAction(); } diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index 82330a588c..a319dfca2f 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -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", }, @@ -3887,15 +3897,33 @@ 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. +* \`pressedText\` (string) - The name shown as a tooltip for this button in pressed state. +* \`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 @@ -3906,7 +3934,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. diff --git a/src/button-group/__integ__/button-group.test.ts b/src/button-group/__integ__/button-group.test.ts index 9e90d9b5ae..dcf11422da 100644 --- a/src/button-group/__integ__/button-group.test.ts +++ b/src/button-group/__integ__/button-group.test.ts @@ -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); @@ -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); @@ -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'); }) ); diff --git a/src/button-group/__tests__/button-group-dev.test.tsx b/src/button-group/__tests__/button-group-dev.test.tsx new file mode 100644 index 0000000000..25075be486 --- /dev/null +++ b/src/button-group/__tests__/button-group-dev.test.tsx @@ -0,0 +1,140 @@ +// 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', pressedText: 'Liked' }], + }); + + 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', + pressedText: 'Liked', + 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', + pressedText: 'Liked', + }, + { + type: 'icon-toggle-button', + id: 'dislike', + pressed: true, + text: 'Dislike', + pressedText: 'Disliked', + }, + ], + 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 } })); +}); diff --git a/src/button-group/__tests__/button-group-focus.test.tsx b/src/button-group/__tests__/button-group-focus.test.tsx new file mode 100644 index 0000000000..9d8a3a3980 --- /dev/null +++ b/src/button-group/__tests__/button-group-focus.test.tsx @@ -0,0 +1,66 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { KeyCode } from '@cloudscape-design/component-toolkit/internal'; + +import { ButtonGroupProps } from '../../../lib/components/button-group'; +import { renderButtonGroup } from './common'; + +const copyButton: ButtonGroupProps.IconButton = { + type: 'icon-button', + id: 'copy', + text: 'Copy', + iconName: 'copy', + popoverFeedback: 'Copied', +}; + +const likeButton: ButtonGroupProps.IconToggleButton = { + type: 'icon-toggle-button', + id: 'like', + pressed: false, + text: 'Like', + pressedText: 'Liked', + iconName: 'thumbs-up', + pressedIconName: 'thumbs-up-filled', + popoverFeedback: 'No longer liking', + pressedPopoverFeedback: 'You like it', +}; + +const menuButton: ButtonGroupProps.MenuDropdown = { + type: 'menu-dropdown', + id: 'menu', + text: 'More actions', + items: [{ id: 'search', text: 'Search' }], +}; + +test('focuses on all item types', () => { + const ref: { current: ButtonGroupProps.Ref | null } = { current: null }; + const { wrapper } = renderButtonGroup({ items: [likeButton, copyButton, menuButton] }, ref); + + ref.current!.focus('copy'); + expect(wrapper.findButtonById('copy')!.getElement()).toHaveFocus(); + + ref.current!.focus('like'); + expect(wrapper.findToggleButtonById('like')!.getElement()).toHaveFocus(); + + ref.current!.focus('menu'); + expect(wrapper.findMenuById('menu')!.findTriggerButton()!.getElement()).toHaveFocus(); +}); + +test('moves focus to menu trigger after menu is dismissed', () => { + const { wrapper } = renderButtonGroup({ items: [likeButton, menuButton] }); + + wrapper.findMenuById('menu')!.openDropdown(); + expect(wrapper.findMenuById('menu')!.findOpenDropdown()).not.toBe(null); + + wrapper.findMenuById('menu')!.findItemById('search')!.click(); + expect(wrapper.findMenuById('menu')!.findOpenDropdown()).toBe(null); + expect(wrapper.findMenuById('menu')!.findTriggerButton()!.getElement()).toHaveFocus(); + + wrapper.findMenuById('menu')!.openDropdown(); + expect(wrapper.findMenuById('menu')!.findOpenDropdown()).not.toBe(null); + + wrapper.findMenuById('menu')!.findTriggerButton()!.keydown(KeyCode.escape); + expect(wrapper.findMenuById('menu')!.findOpenDropdown()).toBe(null); + expect(wrapper.findMenuById('menu')!.findTriggerButton()!.getElement()).toHaveFocus(); +}); diff --git a/src/button-group/__tests__/button-group-keyboard.test.tsx b/src/button-group/__tests__/button-group-keyboard.test.tsx new file mode 100644 index 0000000000..6e60a77313 --- /dev/null +++ b/src/button-group/__tests__/button-group-keyboard.test.tsx @@ -0,0 +1,100 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { fireEvent } from '@testing-library/react'; + +import { KeyCode } from '@cloudscape-design/component-toolkit/internal'; + +import { ButtonGroupProps } from '../../../lib/components/button-group'; +import { renderButtonGroup } from './common'; + +const items: ButtonGroupProps.ItemOrGroup[] = [ + { + type: 'group', + text: 'Feedback', + items: [ + { + type: 'icon-toggle-button', + id: 'like', + pressed: false, + text: 'Like', + pressedText: 'Liked', + iconName: 'thumbs-up', + pressedIconName: 'thumbs-up-filled', + }, + { + type: 'icon-toggle-button', + id: 'dislike', + pressed: false, + disabled: true, + text: 'Dislike', + pressedText: 'Disliked', + iconName: 'thumbs-down', + pressedIconName: 'thumbs-down-filled', + popoverFeedback: 'Disliked!', + pressedPopoverFeedback: 'No longer disliking', + }, + ], + }, + { type: 'icon-button', id: 'copy', iconName: 'copy', text: 'Copy', popoverFeedback: 'Copied' }, + { + type: 'menu-dropdown', + id: 'misc', + text: 'Misc', + items: [ + { id: 'menu-open', iconName: 'file-open', text: 'Open' }, + { id: 'menu-upload', iconName: 'upload', text: 'Upload' }, + ], + }, +]; + +test('navigates button dropdown with keyboard', () => { + const ref: { current: ButtonGroupProps.Ref | null } = { current: null }; + const { wrapper } = renderButtonGroup({ items }, ref); + + ref.current?.focus('like'); + + fireEvent.keyDown(wrapper.getElement(), { keyCode: KeyCode.right }); + expect(wrapper.findButtonById('copy')!.getElement()).toHaveFocus(); + expect(wrapper.findTooltip()!.getElement()).toHaveTextContent('Copy'); + + fireEvent.keyDown(wrapper.getElement(), { keyCode: KeyCode.left }); + expect(wrapper.findToggleButtonById('like')!.getElement()).toHaveFocus(); + expect(wrapper.findTooltip()!.getElement()).toHaveTextContent('Like'); + + fireEvent.keyDown(wrapper.getElement(), { keyCode: KeyCode.end }); + expect(wrapper.findMenuById('misc')!.findTriggerButton()!.getElement()).toHaveFocus(); + expect(wrapper.findTooltip()!.getElement()).toHaveTextContent('Misc'); + + fireEvent.keyDown(wrapper.getElement(), { keyCode: KeyCode.home }); + expect(wrapper.findToggleButtonById('like')!.getElement()).toHaveFocus(); + expect(wrapper.findTooltip()!.getElement()).toHaveTextContent('Like'); + + fireEvent.keyDown(wrapper.getElement(), { keyCode: KeyCode.right, ctrlKey: true }); + expect(wrapper.findButtonById('like')!.getElement()).toHaveFocus(); + expect(wrapper.findTooltip()!.getElement()).toHaveTextContent('Like'); + + fireEvent.keyDown(window, { key: 'Escape' }); + expect(wrapper.findButtonById('like')!.getElement()).toHaveFocus(); + expect(wrapper.findTooltip()).toBe(null); +}); + +test('hides popover with Escape', () => { + const { wrapper } = renderButtonGroup({ items }); + + wrapper.findButtonById('copy')!.click(); + expect(wrapper.findTooltip()!.getElement()).toHaveTextContent('Copied'); + + fireEvent.keyDown(window, { key: 'Escape' }); + expect(wrapper.findTooltip()).toBe(null); +}); + +test('closes menu with Escape', () => { + const { wrapper } = renderButtonGroup({ items }); + + wrapper.findMenuById('misc')!.openDropdown(); + expect(wrapper.findMenuById('misc')!.findOpenDropdown()).not.toBe(null); + + wrapper.findMenuById('misc')!.findTriggerButton()!.keydown(KeyCode.escape); + expect(wrapper.findMenuById('misc')!.findOpenDropdown()).toBe(null); +}); diff --git a/src/button-group/__tests__/button-group-states.test.tsx b/src/button-group/__tests__/button-group-states.test.tsx new file mode 100644 index 0000000000..0b9d0be10c --- /dev/null +++ b/src/button-group/__tests__/button-group-states.test.tsx @@ -0,0 +1,119 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { waitFor } from '@testing-library/react'; + +import { ButtonGroupProps } from '../../../lib/components/button-group'; +import { renderButtonGroup } from './common'; + +const copyButton: ButtonGroupProps.IconButton = { + type: 'icon-button', + id: 'copy', + text: 'Copy', + iconName: 'copy', + popoverFeedback: 'Copied', +}; + +const likeButton: ButtonGroupProps.IconToggleButton = { + type: 'icon-toggle-button', + id: 'like', + pressed: false, + text: 'Like', + pressedText: 'Liked', + iconName: 'thumbs-up', + pressedIconName: 'thumbs-up-filled', + popoverFeedback: 'No longer liking', + pressedPopoverFeedback: 'You like it', +}; + +const menuButton: ButtonGroupProps.MenuDropdown = { + type: 'menu-dropdown', + id: 'menu', + text: 'More actions', + items: [], +}; + +const feedbackGroup: ButtonGroupProps.Group = { + type: 'group', + text: 'Feedback', + items: [likeButton], +}; + +test('all item types have ARIA labels', () => { + const { wrapper } = renderButtonGroup({ items: [feedbackGroup, copyButton, menuButton] }); + + expect(wrapper.find('[role="group"]')!.getElement()).toHaveAccessibleName('Feedback'); + expect(wrapper.findToggleButtonById('like')!.getElement()).toHaveAccessibleName('Like'); + expect(wrapper.findButtonById('copy')!.getElement()).toHaveAccessibleName('Copy'); + expect(wrapper.findMenuById('menu')!.findTriggerButton()!.getElement()).toHaveAccessibleName('More actions'); +}); + +test('toggle button has correct pressed label', () => { + const { wrapper, rerender } = renderButtonGroup({ items: [likeButton] }); + + expect(wrapper.findToggleButtonById('like')!.getElement()).toHaveAttribute('aria-pressed', 'false'); + expect(wrapper.findToggleButtonById('like')!.getElement()).toHaveAccessibleName('Like'); + + rerender({ items: [{ ...likeButton, pressed: true }] }); + + expect(wrapper.findToggleButtonById('like')!.getElement()).toHaveAttribute('aria-pressed', 'true'); + expect(wrapper.findToggleButtonById('like')!.getElement()).toHaveAccessibleName('Liked'); +}); + +test('all item types can be disabled', () => { + const { wrapper } = renderButtonGroup({ + items: [likeButton, copyButton, menuButton].map(item => ({ ...item, disabled: true })), + }); + + expect(wrapper.findToggleButtonById('like')!.getElement()).toBeDisabled(); + expect(wrapper.findButtonById('copy')!.getElement()).toBeDisabled(); + expect(wrapper.findMenuById('menu')!.findTriggerButton()!.getElement()).toBeDisabled(); +}); + +test('all item types can be loading', async () => { + const { wrapper } = renderButtonGroup({ + items: [likeButton, copyButton, menuButton].map(item => ({ + ...item, + loading: true, + loadingText: `Loading ${item.text}`, + })), + }); + + expect(wrapper.findToggleButtonById('like')!.getElement()).toHaveAttribute('aria-disabled', 'true'); + expect(wrapper.findToggleButtonById('copy')!.getElement()).toHaveAttribute('aria-disabled', 'true'); + expect(wrapper.findMenuById('menu')!.findTriggerButton()!.getElement()).toHaveAttribute('aria-disabled', 'true'); + + await waitFor(() => expect(document.body).toHaveTextContent('Loading Like')); + await waitFor(() => expect(document.body).toHaveTextContent('Loading Copy')); + await waitFor(() => expect(document.body).toHaveTextContent('Loading More actions')); +}); + +test('button popover is shown in all states', () => { + const { wrapper, rerender } = renderButtonGroup({ items: [copyButton] }); + + wrapper.findToggleButtonById('copy')!.click(); + expect(wrapper.findTooltip()!.getElement()).toHaveTextContent('Copied'); + + rerender({ items: [{ ...copyButton, loading: true }] }); + expect(wrapper.findTooltip()!.getElement()).toHaveTextContent('Copied'); + + rerender({ items: [{ ...copyButton, disabled: true }] }); + expect(wrapper.findTooltip()!.getElement()).toHaveTextContent('Copied'); +}); + +test.each([ + { ...likeButton, pressed: false }, + { ...likeButton, pressed: true }, +])('toggle button popover is shown in all states, pressed=$pressed', button => { + const { wrapper, rerender } = renderButtonGroup({ items: [button] }); + const expectedText = button.pressed ? (button.pressedPopoverFeedback as string) : (button.popoverFeedback as string); + + wrapper.findToggleButtonById('like')!.click(); + expect(wrapper.findTooltip()!.getElement()).toHaveTextContent(expectedText); + + rerender({ items: [{ ...button, loading: true }] }); + expect(wrapper.findTooltip()!.getElement()).toHaveTextContent(expectedText); + + rerender({ items: [{ ...button, disabled: true }] }); + expect(wrapper.findTooltip()!.getElement()).toHaveTextContent(expectedText); +}); diff --git a/src/button-group/__tests__/button-group-tooltips.test.tsx b/src/button-group/__tests__/button-group-tooltips.test.tsx new file mode 100644 index 0000000000..16679c9936 --- /dev/null +++ b/src/button-group/__tests__/button-group-tooltips.test.tsx @@ -0,0 +1,103 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { fireEvent } from '@testing-library/react'; + +import { ButtonGroupProps } from '../../../lib/components/button-group'; +import { renderButtonGroup } from './common'; + +const copyButton: ButtonGroupProps.IconButton = { + type: 'icon-button', + id: 'copy', + text: 'Copy', + iconName: 'copy', + popoverFeedback: 'Copied', +}; + +const likeButton: ButtonGroupProps.IconToggleButton = { + type: 'icon-toggle-button', + id: 'like', + pressed: false, + text: 'Like', + pressedText: 'Liked', + iconName: 'thumbs-up', + pressedIconName: 'thumbs-up-filled', + popoverFeedback: 'No longer liking', + pressedPopoverFeedback: 'You like it', +}; + +const menuButton: ButtonGroupProps.MenuDropdown = { + type: 'menu-dropdown', + id: 'menu', + text: 'More actions', + items: [{ id: 'search', text: 'Search' }], +}; + +test('tooltip not shown by default', () => { + const { wrapper } = renderButtonGroup({ items: [likeButton, copyButton, menuButton] }); + + expect(wrapper.findTooltip()).toBeNull(); +}); + +test.each([copyButton, likeButton, menuButton])( + 'shows the tooltip on pointer enter and hides on pointer leave, item id=$id', + item => { + const { wrapper } = renderButtonGroup({ items: [likeButton, copyButton, menuButton] }); + const button = wrapper.findButtonById(item.id)!; + + fireEvent.pointerEnter(button.getElement()); + expect(wrapper.findTooltip()!.getElement()).toHaveTextContent(item.text); + + fireEvent.pointerLeave(button.getElement()); + expect(wrapper.findTooltip()).toBeNull(); + } +); + +test.each([copyButton, likeButton, menuButton])('shows no tooltip in loading state, item id=$id', item => { + const { wrapper } = renderButtonGroup({ + items: [likeButton, copyButton, menuButton].map(item => ({ ...item, loading: true })), + }); + const button = wrapper.findButtonById(item.id)!; + + fireEvent.pointerEnter(button.getElement()); + expect(wrapper.findTooltip()).toBeNull(); +}); + +test.each([copyButton, likeButton, menuButton])('shows no tooltip in disabled state, item id=$id', item => { + const { wrapper } = renderButtonGroup({ + items: [likeButton, copyButton, menuButton].map(item => ({ ...item, disabled: true })), + }); + const button = wrapper.findButtonById(item.id)!; + + fireEvent.pointerEnter(button.getElement()); + expect(wrapper.findTooltip()).toBeNull(); +}); + +test.each([copyButton, likeButton])('shows popover on click if defined, item id=$id', item => { + const { wrapper } = renderButtonGroup({ items: [likeButton, copyButton, menuButton] }); + const button = wrapper.findButtonById(item.id)!; + + button.click(); + expect(wrapper.findTooltip()!.getElement()).toHaveTextContent(item.popoverFeedback as string); + + // Keeps popover if the button is clicked again + fireEvent.pointerDown(button.getElement()); + expect(wrapper.findTooltip()!.getElement()).toHaveTextContent(item.popoverFeedback as string); + + // Closes popover on click outside + fireEvent.pointerDown(document); + expect(wrapper.findTooltip()).toBeNull(); +}); + +test.each([copyButton, likeButton])('shows no popover on click if popover not defined, item id=$id', item => { + const { wrapper } = renderButtonGroup({ + items: [likeButton, copyButton, menuButton].map(item => ({ ...item, popoverFeedback: undefined })), + }); + const button = wrapper.findButtonById(item.id)!; + + fireEvent.pointerEnter(button.getElement()); + expect(wrapper.findTooltip()!.getElement()).toHaveTextContent(item.text); + + button.click(); + expect(wrapper.findTooltip()!.getElement()).toHaveTextContent(item.text); +}); diff --git a/src/button-group/__tests__/button-group.test.tsx b/src/button-group/__tests__/button-group.test.tsx deleted file mode 100644 index 99269fc0fd..0000000000 --- a/src/button-group/__tests__/button-group.test.tsx +++ /dev/null @@ -1,274 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import React from 'react'; -import { fireEvent, render } from '@testing-library/react'; - -import { KeyCode } from '@cloudscape-design/component-toolkit/internal'; -import { warnOnce } from '@cloudscape-design/component-toolkit/internal'; - -import ButtonGroup, { ButtonGroupProps } from '../../../lib/components/button-group'; -import createWrapper from '../../../lib/components/test-utils/dom'; - -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 defaultProps: ButtonGroupProps = { - variant: 'icon', - ariaLabel: 'Chat actions', - items: [], -}; - -const renderButtonGroup = (props: Partial, ref?: React.Ref) => { - const renderResult = render(); - const wrapper = createWrapper(renderResult.container).findButtonGroup()!; - const rerender = (props: Partial) => - renderResult.rerender(); - return { wrapper, rerender }; -}; - -const items: ButtonGroupProps.ItemOrGroup[] = [ - { - type: 'group', - text: 'Feedback', - items: [ - { type: 'icon-button', id: 'like', text: 'Like', iconName: 'thumbs-up', popoverFeedback: 'Liked' }, - { - type: 'icon-button', - id: 'dislike', - disabled: true, - text: 'dislike', - iconName: 'thumbs-down', - popoverFeedback: 'Disliked', - }, - ], - }, - { type: 'icon-button', id: 'copy', iconName: 'copy', text: 'Copy', popoverFeedback: 'Copied' }, - { type: 'icon-button', id: 'edit', iconName: 'edit', text: 'Edit' }, - { type: 'icon-button', id: 'search', text: 'Search' }, - { - type: 'menu-dropdown', - id: 'misc', - text: 'Misc', - items: [ - { id: 'menu-open', iconName: 'file-open', text: 'Open' }, - { id: 'menu-upload', iconName: 'upload', text: 'Upload' }, - ], - }, -]; - -const emptyGroup: ButtonGroupProps.ItemOrGroup[] = [ - { - type: 'group', - text: 'Feedback', - items: [], - }, -]; - -test('renders all items', () => { - const { wrapper } = renderButtonGroup({ items }); - - expect(wrapper.findItems()).toHaveLength(6); -}); - -test('renders stub icon when no icon specified', () => { - const { wrapper } = renderButtonGroup({ items }); - - expect(wrapper.findMenuById('search')?.findAll(`.${buttonStyles.icon}`)).toHaveLength(1); -}); - -test('handles menu click event correctly', () => { - const onItemClick = jest.fn(); - const { wrapper } = renderButtonGroup({ items, onItemClick }); - const buttonDropdown = wrapper.findMenuById('misc')!; - buttonDropdown.openDropdown(); - buttonDropdown.findItemById('menu-open')!.click(); - - expect(onItemClick).toHaveBeenCalledWith(expect.objectContaining({ detail: { id: 'menu-open' } })); - expect(buttonDropdown!.getElement().getElementsByTagName('button')[0]).toHaveFocus(); -}); - -describe('focus', () => { - test('focuses the correct item', () => { - const ref: { current: ButtonGroupProps.Ref | null } = { current: null }; - const { wrapper } = renderButtonGroup({ items }, ref); - ref.current?.focus('copy'); - - expect(wrapper.findButtonById('copy')!.getElement()).toHaveFocus(); - }); - - test('focuses on show more button', () => { - const ref: { current: ButtonGroupProps.Ref | null } = { current: null }; - const { wrapper } = renderButtonGroup({ items }, ref); - ref.current?.focus('misc'); - - expect(wrapper.findMenuById('misc')!.getElement().getElementsByTagName('button')[0]).toHaveFocus(); - }); - - test('focuses the correct item with keyboard', () => { - const ref: { current: ButtonGroupProps.Ref | null } = { current: null }; - const { wrapper } = renderButtonGroup({ items }, ref); - ref.current?.focus('copy'); - - fireEvent.keyDown(wrapper.getElement(), { keyCode: KeyCode.right }); - expect(wrapper.findButtonById('edit')!.getElement()).toHaveFocus(); - fireEvent.keyDown(wrapper.getElement(), { keyCode: KeyCode.left }); - expect(wrapper.findButtonById('copy')!.getElement()).toHaveFocus(); - fireEvent.keyDown(wrapper.getElement(), { keyCode: KeyCode.home }); - expect(wrapper.findButtonById('like')!.getElement()).toHaveFocus(); - fireEvent.keyDown(wrapper.getElement(), { keyCode: KeyCode.end }); - expect(wrapper.findMenuById('misc')!.getElement().getElementsByTagName('button')[0]).toHaveFocus(); - ref.current?.focus('like'); - fireEvent.keyDown(wrapper.getElement(), { keyCode: KeyCode.right }); - expect(wrapper.findButtonById('copy')!.getElement()).toHaveFocus(); - fireEvent.keyDown(wrapper.getElement(), { keyCode: KeyCode.space }); - expect(wrapper.findButtonById('copy')!.getElement()).toHaveFocus(); - fireEvent.keyDown(wrapper.getElement(), { keyCode: KeyCode.left, ctrlKey: true }); - expect(wrapper.findButtonById('copy')!.getElement()).toHaveFocus(); - }); -}); - -describe('tooltips', () => { - test('tooltip not shown by default', () => { - const { wrapper } = renderButtonGroup({ items }); - - expect(wrapper.findTooltip()).toBeNull(); - }); - - test('shows the tooltip on pointer enter and hides on pointer leave', () => { - const { wrapper } = renderButtonGroup({ items }); - const button = wrapper.findButtonById('copy')!; - - fireEvent.pointerEnter(button.getElement()); - expect(wrapper.findTooltip()!.getElement()).toHaveTextContent('Copy'); - - fireEvent.pointerLeave(button.getElement()); - expect(wrapper.findTooltip()).toBeNull(); - }); - - test('shows popover on click', () => { - const itemsLoading = items.map(item => (item.type === 'icon-button' ? { ...item, loading: true } : item)); - const itemsDisabled = items.map(item => (item.type === 'icon-button' ? { ...item, loading: true } : item)); - const { wrapper, rerender } = renderButtonGroup({ items }); - const button = wrapper.findButtonById('copy')!; - - button.click(); - fireEvent.pointerLeave(button.getElement()); - - expect(wrapper.findTooltip()!.getElement()).toHaveTextContent('Copied'); - - rerender({ items: itemsLoading }); - expect(wrapper.findTooltip()!.getElement()).toHaveTextContent('Copied'); - - rerender({ items: itemsDisabled }); - expect(wrapper.findTooltip()!.getElement()).toHaveTextContent('Copied'); - }); - - test('shows no popover on click if popover not defined', () => { - const { wrapper } = renderButtonGroup({ items }); - const button = wrapper.findButtonById('search')!; - - fireEvent.pointerEnter(button.getElement()); - expect(wrapper.findTooltip()!.getElement()).toHaveTextContent('Search'); - - button.click(); - expect(wrapper.findTooltip()!.getElement()).toHaveTextContent('Search'); - }); - - test('closes popover on pointer down', () => { - const { wrapper } = renderButtonGroup({ items }); - const button = wrapper.findButtonById('copy')!; - - button.click(); - expect(wrapper.findTooltip()!.getElement()).toHaveTextContent('Copied'); - - fireEvent.pointerDown(document); - expect(wrapper.findTooltip()).toBeNull(); - }); - - test('not closes popover on pointer down on the button', () => { - const { wrapper } = renderButtonGroup({ items }); - const button = wrapper.findButtonById('copy')!; - - button.click(); - expect(wrapper.findTooltip()!.getElement()).toHaveTextContent('Copied'); - - fireEvent.pointerDown(button.getElement()); - expect(wrapper.findTooltip()!.getElement()).toHaveTextContent('Copied'); - }); - - test('closes popover on esc key', () => { - const { wrapper } = renderButtonGroup({ items }); - const button = wrapper.findButtonById('copy')!; - - button.click(); - expect(wrapper.findTooltip()!.getElement()).toHaveTextContent('Copied'); - - fireEvent.keyDown(window, { key: 'Escape' }); - expect(wrapper.findTooltip()).toBeNull(); - }); - - describe.each(['loading', 'disabled'] as const)('hides tooltip for %s', property => { - test.each(['icon-button', 'menu-dropdown'])('%s', id => { - const items: ButtonGroupProps.Item[] = [ - { type: 'icon-button', id: 'icon-button', text: 'icon-button' }, - { type: 'menu-dropdown', id: 'menu-dropdown', text: 'menu-dropdown', items: [] }, - ]; - const { rerender } = renderButtonGroup({ items }); - const wrapper = createWrapper().findButtonGroup()!; - - fireEvent.pointerEnter(wrapper.findButtonById(id)!.getElement()); - expect(wrapper.findTooltip()!.getElement()).toHaveTextContent(id); - - rerender({ items: items.map(item => ({ ...item, [property]: item.id === id })) }); - expect(wrapper.findTooltip()).toBeNull(); - }); - }); - - test('menu trigger can have disabled and loading states', () => { - const { rerender } = renderButtonGroup({ - items: [{ type: 'menu-dropdown', id: 'menu-dropdown', text: 'menu-dropdown', items: [] }], - }); - const wrapper = createWrapper().findButtonGroup()!; - const trigger = wrapper.findMenuById('menu-dropdown')!.findTriggerButton()!; - expect(trigger.getElement()).not.toBeDisabled(); - expect(trigger.getElement()).not.toHaveAttribute('aria-disabled'); - - rerender({ - items: [{ type: 'menu-dropdown', id: 'menu-dropdown', text: 'menu-dropdown', items: [], disabled: true }], - }); - expect(trigger.getElement()).toBeDisabled(); - expect(trigger.getElement()).not.toHaveAttribute('aria-disabled'); - - rerender({ - items: [{ type: 'menu-dropdown', id: 'menu-dropdown', text: 'menu-dropdown', items: [], loading: true }], - }); - expect(trigger.getElement()).not.toBeDisabled(); - expect(trigger.getElement()).toHaveAttribute('aria-disabled'); - }); -}); - -describe('dev warnings', () => { - const componentName = 'ButtonGroup'; - - test('missing icon warning', () => { - renderButtonGroup({ items }); - - expect(warnOnce).toHaveBeenCalledTimes(1); - expect(warnOnce).toHaveBeenCalledWith(componentName, 'Missing icon for item with id: search'); - }); - - test('empty group warning', () => { - renderButtonGroup({ items: emptyGroup }); - - expect(warnOnce).toHaveBeenCalledTimes(1); - expect(warnOnce).toHaveBeenCalledWith(componentName, 'Empty group detected. Empty groups are not allowed.'); - }); -}); diff --git a/src/button-group/__tests__/common.tsx b/src/button-group/__tests__/common.tsx new file mode 100644 index 0000000000..0b7424708a --- /dev/null +++ b/src/button-group/__tests__/common.tsx @@ -0,0 +1,22 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; +import { render } from '@testing-library/react'; + +import ButtonGroup, { ButtonGroupProps } from '../../../lib/components/button-group'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +const defaultProps: ButtonGroupProps = { + variant: 'icon', + ariaLabel: 'Chat actions', + items: [], +}; + +export function renderButtonGroup(props: Partial, ref?: React.Ref) { + const renderResult = render(); + const wrapper = createWrapper(renderResult.container).findButtonGroup()!; + const rerender = (props: Partial) => + renderResult.rerender(); + return { wrapper, rerender }; +} diff --git a/src/button-group/icon-toggle-button-item.tsx b/src/button-group/icon-toggle-button-item.tsx new file mode 100644 index 0000000000..9379e07374 --- /dev/null +++ b/src/button-group/icon-toggle-button-item.tsx @@ -0,0 +1,87 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { forwardRef } from 'react'; +import clsx from 'clsx'; + +import { warnOnce } from '@cloudscape-design/component-toolkit/internal'; + +import { ButtonProps } from '../button/interfaces.js'; +import Tooltip from '../internal/components/tooltip/index.js'; +import { CancelableEventHandler, fireCancelableEvent } from '../internal/events/index.js'; +import InternalLiveRegion from '../live-region/internal.js'; +import { InternalToggleButton } from '../toggle-button/internal.js'; +import { ButtonGroupProps } from './interfaces.js'; + +import testUtilStyles from './test-classes/styles.css.js'; + +const IconToggleButtonItem = forwardRef( + ( + { + item, + showTooltip, + showFeedback, + onItemClick, + }: { + item: ButtonGroupProps.IconToggleButton; + showTooltip: boolean; + showFeedback: boolean; + onItemClick?: CancelableEventHandler; + }, + ref: React.Ref + ) => { + const containerRef = React.useRef(null); + const hasIcon = item.iconName || item.iconUrl || item.iconSvg; + const hasPressedIcon = item.pressedIconName || item.pressedIconUrl || item.pressedIconSvg; + + if (!hasIcon) { + warnOnce('ButtonGroup', `Missing icon for item with id: ${item.id}`); + } + if (!hasPressedIcon) { + warnOnce('ButtonGroup', `Missing pressed icon for item with id: ${item.id}`); + } + + const tooltipContent = item.pressed ? item.pressedText : item.text; + const feedbackContent = item.pressed ? item.pressedPopoverFeedback ?? item.popoverFeedback : item.popoverFeedback; + const canShowTooltip = showTooltip && !item.disabled && !item.loading; + const canShowFeedback = showTooltip && showFeedback && feedbackContent; + return ( +
+ fireCancelableEvent(onItemClick, { id: item.id, pressed: event.detail.pressed })} + ref={ref} + data-testid={item.id} + data-itemid={item.id} + className={clsx(testUtilStyles.item, testUtilStyles['button-group-item'])} + __title="" + > + {tooltipContent} + + {(canShowTooltip || canShowFeedback) && ( + {feedbackContent}) || + tooltipContent + } + className={clsx(testUtilStyles.tooltip, testUtilStyles['button-group-tooltip'])} + /> + )} +
+ ); + } +); + +export default IconToggleButtonItem; diff --git a/src/button-group/interfaces.ts b/src/button-group/interfaces.ts index ea10e3ec58..62db58392e 100644 --- a/src/button-group/interfaces.ts +++ b/src/button-group/interfaces.ts @@ -38,15 +38,33 @@ export interface ButtonGroupProps extends BaseComponentProps { * ### 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. + * * `pressedText` (string) - The name shown as a tooltip for this button in pressed state. + * * `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 * @@ -57,7 +75,7 @@ export interface ButtonGroupProps extends BaseComponentProps { * * `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. @@ -75,7 +93,7 @@ export namespace ButtonGroupProps { export type Variant = 'icon'; export type ItemOrGroup = Item | Group; - export type Item = IconButton | MenuDropdown; + export type Item = IconButton | IconToggleButton | MenuDropdown; export interface IconButton { type: 'icon-button'; @@ -91,6 +109,25 @@ export namespace ButtonGroupProps { popoverFeedback?: React.ReactNode; } + export interface IconToggleButton { + type: 'icon-toggle-button'; + id: string; + text: string; + pressed: boolean; + pressedText: string; + disabled?: boolean; + loading?: boolean; + loadingText?: string; + iconName?: IconProps.Name; + iconUrl?: string; + iconSvg?: React.ReactNode; + pressedIconName?: IconProps.Name; + pressedIconUrl?: string; + pressedIconSvg?: React.ReactNode; + popoverFeedback?: React.ReactNode; + pressedPopoverFeedback?: React.ReactNode; + } + export interface MenuDropdown { type: 'menu-dropdown'; id: string; @@ -109,6 +146,8 @@ export namespace ButtonGroupProps { export interface ItemClickDetails { id: string; + pressed?: boolean; + checked?: boolean; } export interface Ref { diff --git a/src/button-group/item-element.tsx b/src/button-group/item-element.tsx index a72bbba049..8030f35338 100644 --- a/src/button-group/item-element.tsx +++ b/src/button-group/item-element.tsx @@ -6,6 +6,7 @@ import { ButtonProps } from '../button/interfaces.js'; import { fireCancelableEvent, NonCancelableEventHandler } from '../internal/events'; import { nodeBelongs } from '../internal/utils/node-belongs'; import IconButtonItem from './icon-button-item'; +import IconToggleButtonItem from './icon-toggle-button-item.js'; import { ButtonGroupProps } from './interfaces'; import MenuDropdownItem from './menu-dropdown-item'; @@ -115,6 +116,15 @@ const ItemElement = forwardRef( showFeedback={!!tooltip?.feedback} /> )} + {item.type === 'icon-toggle-button' && ( + + )} {item.type === 'menu-dropdown' && ( { const containerRef = React.useRef(null); const onClickHandler = (event: CustomEvent) => { - fireCancelableEvent(onItemClick, { id: event.detail.id }, event); + fireCancelableEvent(onItemClick, { id: event.detail.id, checked: event.detail.checked }, event); }; return ( diff --git a/src/test-utils/dom/button-group/index.ts b/src/test-utils/dom/button-group/index.ts index 9eb386d262..c0a898bccc 100644 --- a/src/test-utils/dom/button-group/index.ts +++ b/src/test-utils/dom/button-group/index.ts @@ -5,6 +5,7 @@ import { ComponentWrapper, ElementWrapper } from '@cloudscape-design/test-utils- import ButtonWrapper from '../button/index.js'; import ButtonDropdownWrapper from '../button-dropdown/index.js'; import createWrapper from '../index.js'; +import ToggleButtonWrapper from '../toggle-button/index.js'; import testUtilStyles from '../../../button-group/test-classes/styles.selectors.js'; @@ -27,6 +28,15 @@ export default class ButtonGroupWrapper extends ComponentWrapper { return wrapper && new ButtonWrapper(wrapper.getElement()); } + /** + * Finds a toggle button item by its id. + */ + findToggleButtonById(id: string): null | ToggleButtonWrapper { + const inlineItemSelector = `.${testUtilStyles['button-group-item']}[data-testid="${id}"]`; + const wrapper = this.find(inlineItemSelector) as ElementWrapper; + return wrapper && new ToggleButtonWrapper(wrapper.getElement()); + } + /** * Finds a menu item by its id. */ diff --git a/src/toggle-button/internal.tsx b/src/toggle-button/internal.tsx index 42691e4971..eef655a76a 100644 --- a/src/toggle-button/internal.tsx +++ b/src/toggle-button/internal.tsx @@ -27,7 +27,7 @@ export const InternalToggleButton = React.forwardRef( onChange, className, ...rest - }: ToggleButtonProps, + }: ToggleButtonProps & { __title?: string }, ref: React.Ref ) => { if (isDevelopment) { From 7d26fd490c2f7e095064900a5e4ac979a0b5668e Mon Sep 17 00:00:00 2001 From: Andrei Zhaleznichenka Date: Tue, 12 Nov 2024 11:29:03 +0100 Subject: [PATCH 2/2] remove pressed text --- pages/button-group/item-permutations.page.tsx | 1 - pages/button-group/permutations.page.tsx | 2 -- pages/button-group/test.page.tsx | 15 +++++++++------ .../__snapshots__/documenter.test.ts.snap | 1 - .../__tests__/button-group-dev.test.tsx | 5 +---- .../__tests__/button-group-focus.test.tsx | 1 - .../__tests__/button-group-keyboard.test.tsx | 2 -- .../__tests__/button-group-states.test.tsx | 3 +-- .../__tests__/button-group-tooltips.test.tsx | 1 - src/button-group/icon-toggle-button-item.tsx | 8 +++----- src/button-group/interfaces.ts | 2 -- 11 files changed, 14 insertions(+), 27 deletions(-) diff --git a/pages/button-group/item-permutations.page.tsx b/pages/button-group/item-permutations.page.tsx index c5c2c69fdf..56c04957ef 100644 --- a/pages/button-group/item-permutations.page.tsx +++ b/pages/button-group/item-permutations.page.tsx @@ -49,7 +49,6 @@ const itemPermutations = createPermutations([ iconName: ['star'], pressedIconName: ['star-filled'], text: ['Add to favorites'], - pressedText: ['Added to favorites'], pressed: [false, true], }, ]); diff --git a/pages/button-group/permutations.page.tsx b/pages/button-group/permutations.page.tsx index c87e3d77c7..3579def78e 100644 --- a/pages/button-group/permutations.page.tsx +++ b/pages/button-group/permutations.page.tsx @@ -19,7 +19,6 @@ const feedbackGroup: ButtonGroupProps.Group = { iconName: 'thumbs-up', pressedIconName: 'thumbs-up-filled', text: 'Like', - pressedText: 'Like', pressed: true, }, { @@ -28,7 +27,6 @@ const feedbackGroup: ButtonGroupProps.Group = { iconName: 'thumbs-down', pressedIconName: 'thumbs-down-filled', text: 'Dislike', - pressedText: 'Dislike', pressed: false, }, ], diff --git a/pages/button-group/test.page.tsx b/pages/button-group/test.page.tsx index 1f48c26039..4d72766d0e 100644 --- a/pages/button-group/test.page.tsx +++ b/pages/button-group/test.page.tsx @@ -32,6 +32,12 @@ export default function ButtonGroupPage() { 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', @@ -41,8 +47,7 @@ export default function ButtonGroupPage() { id: 'like', iconName: 'thumbs-up', pressedIconName: 'thumbs-up-filled', - text: 'Like', - pressedText: 'Liked', + text: feedback === 'like' ? toggleTexts.like[1] : toggleTexts.like[0], pressed: feedback === 'like', }, { @@ -50,8 +55,7 @@ export default function ButtonGroupPage() { id: 'dislike', iconName: 'thumbs-down', pressedIconName: 'thumbs-down-filled', - text: 'Dislike', - pressedText: 'Disliked', + text: feedback === 'dislike' ? toggleTexts.dislike[1] : toggleTexts.dislike[0], pressed: feedback === 'dislike', }, ], @@ -66,8 +70,7 @@ export default function ButtonGroupPage() { id: 'favorite', iconName: 'star', pressedIconName: 'star-filled', - text: 'Add to favorites', - pressedText: 'Added to favorites', + text: isFavorite ? toggleTexts.favorite[1] : toggleTexts.favorite[0], loading: loadingId === 'favorite', pressed: isFavorite, }, diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index a319dfca2f..d24c7fd2ab 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -3912,7 +3912,6 @@ use the \`id\` attribute, consider setting it on a parent element instead.", * \`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. -* \`pressedText\` (string) - The name shown as a tooltip for this button in pressed state. * \`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. diff --git a/src/button-group/__tests__/button-group-dev.test.tsx b/src/button-group/__tests__/button-group-dev.test.tsx index 25075be486..3a3fed7466 100644 --- a/src/button-group/__tests__/button-group-dev.test.tsx +++ b/src/button-group/__tests__/button-group-dev.test.tsx @@ -37,7 +37,7 @@ 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', pressedText: 'Liked' }], + items: [{ type: 'icon-toggle-button', id: 'like', pressed, text: 'Like' }], }); expect(warnOnce).toHaveBeenCalledTimes(2); @@ -62,7 +62,6 @@ test('uses non-pressed popover feedback if pressed is not provided', () => { id: 'like', pressed: true, text: 'Like', - pressedText: 'Liked', popoverFeedback: 'You like it!', }, ], @@ -93,14 +92,12 @@ test('handles toggle item click', () => { id: 'like', pressed: false, text: 'Like', - pressedText: 'Liked', }, { type: 'icon-toggle-button', id: 'dislike', pressed: true, text: 'Dislike', - pressedText: 'Disliked', }, ], onItemClick, diff --git a/src/button-group/__tests__/button-group-focus.test.tsx b/src/button-group/__tests__/button-group-focus.test.tsx index 9d8a3a3980..73af711439 100644 --- a/src/button-group/__tests__/button-group-focus.test.tsx +++ b/src/button-group/__tests__/button-group-focus.test.tsx @@ -19,7 +19,6 @@ const likeButton: ButtonGroupProps.IconToggleButton = { id: 'like', pressed: false, text: 'Like', - pressedText: 'Liked', iconName: 'thumbs-up', pressedIconName: 'thumbs-up-filled', popoverFeedback: 'No longer liking', diff --git a/src/button-group/__tests__/button-group-keyboard.test.tsx b/src/button-group/__tests__/button-group-keyboard.test.tsx index 6e60a77313..6eb0b2c548 100644 --- a/src/button-group/__tests__/button-group-keyboard.test.tsx +++ b/src/button-group/__tests__/button-group-keyboard.test.tsx @@ -18,7 +18,6 @@ const items: ButtonGroupProps.ItemOrGroup[] = [ id: 'like', pressed: false, text: 'Like', - pressedText: 'Liked', iconName: 'thumbs-up', pressedIconName: 'thumbs-up-filled', }, @@ -28,7 +27,6 @@ const items: ButtonGroupProps.ItemOrGroup[] = [ pressed: false, disabled: true, text: 'Dislike', - pressedText: 'Disliked', iconName: 'thumbs-down', pressedIconName: 'thumbs-down-filled', popoverFeedback: 'Disliked!', diff --git a/src/button-group/__tests__/button-group-states.test.tsx b/src/button-group/__tests__/button-group-states.test.tsx index 0b9d0be10c..bfa8f06eb2 100644 --- a/src/button-group/__tests__/button-group-states.test.tsx +++ b/src/button-group/__tests__/button-group-states.test.tsx @@ -19,7 +19,6 @@ const likeButton: ButtonGroupProps.IconToggleButton = { id: 'like', pressed: false, text: 'Like', - pressedText: 'Liked', iconName: 'thumbs-up', pressedIconName: 'thumbs-up-filled', popoverFeedback: 'No longer liking', @@ -57,7 +56,7 @@ test('toggle button has correct pressed label', () => { rerender({ items: [{ ...likeButton, pressed: true }] }); expect(wrapper.findToggleButtonById('like')!.getElement()).toHaveAttribute('aria-pressed', 'true'); - expect(wrapper.findToggleButtonById('like')!.getElement()).toHaveAccessibleName('Liked'); + expect(wrapper.findToggleButtonById('like')!.getElement()).toHaveAccessibleName('Like'); }); test('all item types can be disabled', () => { diff --git a/src/button-group/__tests__/button-group-tooltips.test.tsx b/src/button-group/__tests__/button-group-tooltips.test.tsx index 16679c9936..45b9c3a981 100644 --- a/src/button-group/__tests__/button-group-tooltips.test.tsx +++ b/src/button-group/__tests__/button-group-tooltips.test.tsx @@ -19,7 +19,6 @@ const likeButton: ButtonGroupProps.IconToggleButton = { id: 'like', pressed: false, text: 'Like', - pressedText: 'Liked', iconName: 'thumbs-up', pressedIconName: 'thumbs-up-filled', popoverFeedback: 'No longer liking', diff --git a/src/button-group/icon-toggle-button-item.tsx b/src/button-group/icon-toggle-button-item.tsx index 9379e07374..b8379db6d0 100644 --- a/src/button-group/icon-toggle-button-item.tsx +++ b/src/button-group/icon-toggle-button-item.tsx @@ -40,7 +40,6 @@ const IconToggleButtonItem = forwardRef( warnOnce('ButtonGroup', `Missing pressed icon for item with id: ${item.id}`); } - const tooltipContent = item.pressed ? item.pressedText : item.text; const feedbackContent = item.pressed ? item.pressedPopoverFeedback ?? item.popoverFeedback : item.popoverFeedback; const canShowTooltip = showTooltip && !item.disabled && !item.loading; const canShowFeedback = showTooltip && showFeedback && feedbackContent; @@ -58,7 +57,7 @@ const IconToggleButtonItem = forwardRef( pressedIconName={hasIcon ? item.pressedIconName : 'close'} pressedIconUrl={item.pressedIconUrl} pressedIconSvg={item.pressedIconUrl} - ariaLabel={tooltipContent} + ariaLabel={item.text} onChange={event => fireCancelableEvent(onItemClick, { id: item.id, pressed: event.detail.pressed })} ref={ref} data-testid={item.id} @@ -66,15 +65,14 @@ const IconToggleButtonItem = forwardRef( className={clsx(testUtilStyles.item, testUtilStyles['button-group-item'])} __title="" > - {tooltipContent} + {item.text} {(canShowTooltip || canShowFeedback) && ( {feedbackContent}) || - tooltipContent + (showFeedback && {feedbackContent}) || item.text } className={clsx(testUtilStyles.tooltip, testUtilStyles['button-group-tooltip'])} /> diff --git a/src/button-group/interfaces.ts b/src/button-group/interfaces.ts index 62db58392e..8a41384cf5 100644 --- a/src/button-group/interfaces.ts +++ b/src/button-group/interfaces.ts @@ -53,7 +53,6 @@ export interface ButtonGroupProps extends BaseComponentProps { * * `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. - * * `pressedText` (string) - The name shown as a tooltip for this button in pressed state. * * `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. @@ -114,7 +113,6 @@ export namespace ButtonGroupProps { id: string; text: string; pressed: boolean; - pressedText: string; disabled?: boolean; loading?: boolean; loadingText?: string;