From 421b59343ee2f012c09597f1d68c86fd5c07fa7f Mon Sep 17 00:00:00 2001 From: Andrei Zhaleznichenka Date: Mon, 21 Oct 2024 16:10:13 +0200 Subject: [PATCH] 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 | 26 ++++-- .../__snapshots__/documenter.test.ts.snap | 33 +++++-- .../__tests__/button-group.test.tsx | 36 ++++++-- src/button-group/icon-toggle-button-item.tsx | 87 +++++++++++++++++++ src/button-group/interfaces.ts | 50 +++++++++-- src/button-group/item-element.tsx | 10 +++ src/toggle-button/internal.tsx | 2 +- 9 files changed, 236 insertions(+), 30 deletions(-) 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..51cce640cd 100644 --- a/pages/button-group/test.page.tsx +++ b/pages/button-group/test.page.tsx @@ -36,16 +36,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 +61,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, }, ], }; @@ -191,9 +199,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': diff --git a/src/__tests__/__snapshots__/documenter.test.ts.snap b/src/__tests__/__snapshots__/documenter.test.ts.snap index 8d0686428a..84089b7cb6 100644 --- a/src/__tests__/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/__snapshots__/documenter.test.ts.snap @@ -3795,6 +3795,11 @@ exports[`Documenter definition for button-group matches the snapshot: button-gro "optional": false, "type": "string", }, + { + "name": "pressed", + "optional": true, + "type": "false | true", + }, ], "type": "object", }, @@ -3861,15 +3866,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 @@ -3880,7 +3903,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/__tests__/button-group.test.tsx b/src/button-group/__tests__/button-group.test.tsx index 99269fc0fd..06b6be3c3a 100644 --- a/src/button-group/__tests__/button-group.test.tsx +++ b/src/button-group/__tests__/button-group.test.tsx @@ -39,14 +39,23 @@ const items: ButtonGroupProps.ItemOrGroup[] = [ type: 'group', text: 'Feedback', items: [ - { type: 'icon-button', id: 'like', text: 'Like', iconName: 'thumbs-up', popoverFeedback: 'Liked' }, { - type: 'icon-button', + 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', - disabled: true, - text: 'dislike', + pressed: false, + text: 'Dislike', + pressedText: 'Disliked', iconName: 'thumbs-down', - popoverFeedback: 'Disliked', + pressedIconName: 'thumbs-down-filled', }, ], }, @@ -64,6 +73,18 @@ const items: ButtonGroupProps.ItemOrGroup[] = [ }, ]; +function traverseItems( + items: readonly ButtonGroupProps.ItemOrGroup[], + onItem: (item: ButtonGroupProps.Item) => ButtonGroupProps.Item +): readonly ButtonGroupProps.ItemOrGroup[] { + return items.map(itemOrGroup => { + if (itemOrGroup.type === 'group') { + return { ...itemOrGroup, items: traverseItems(itemOrGroup.items, onItem) } as ButtonGroupProps.Group; + } + return onItem(itemOrGroup); + }); +} + const emptyGroup: ButtonGroupProps.ItemOrGroup[] = [ { type: 'group', @@ -114,7 +135,10 @@ describe('focus', () => { test('focuses the correct item with keyboard', () => { const ref: { current: ButtonGroupProps.Ref | null } = { current: null }; - const { wrapper } = renderButtonGroup({ items }, ref); + const itemsWithDisabledDislike = traverseItems(items, item => + item.id === 'dislike' ? { ...item, disabled: true } : item + ); + const { wrapper } = renderButtonGroup({ items: itemsWithDisabledDislike }, ref); ref.current?.focus('copy'); fireEvent.keyDown(wrapper.getElement(), { keyCode: KeyCode.right }); 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..66c5aedaf1 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,7 @@ export namespace ButtonGroupProps { export interface ItemClickDetails { id: string; + pressed?: 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' && ( ) => { if (isDevelopment) {