diff --git a/pages/button-group/interaction-variants.page.tsx b/pages/button-group/interaction-variants.page.tsx new file mode 100644 index 0000000000..2e7293ad8f --- /dev/null +++ b/pages/button-group/interaction-variants.page.tsx @@ -0,0 +1,209 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React, { useContext, useState } from 'react'; + +import { + Box, + ButtonGroup, + ButtonGroupProps, + Checkbox, + ExpandableSection, + Header, + SegmentedControl, + SpaceBetween, + StatusIndicator, +} from '~components'; + +import AppContext, { AppContextType } from '../app/app-context'; +import ScreenshotArea from '../utils/screenshot-area'; + +type PageContext = React.Context< + AppContextType<{ + tooltipToggle: boolean; + usePressedText: boolean; + feedbackType: 'none' | 'text' | 'status'; + }> +>; + +const feedbackGroup: ButtonGroupProps.Group = { + type: 'group', + text: 'Vote', + items: [ + { + type: 'icon-toggle-button', + id: 'like', + iconName: 'thumbs-up', + pressedIconName: 'thumbs-up-filled', + text: 'Like', + pressedText: 'Liked', + pressed: false, + }, + { + type: 'icon-toggle-button', + id: 'dislike', + iconName: 'thumbs-down', + pressedIconName: 'thumbs-down-filled', + text: 'Dislike', + pressedText: 'Disliked', + pressed: false, + }, + ], +}; + +const copy: ButtonGroupProps.Item = { + type: 'icon-button', + id: 'copy', + iconName: 'copy', + text: 'Copy', + popoverFeedback: Copied, +}; + +const add: ButtonGroupProps.Item = { + type: 'icon-button', + id: 'add', + iconName: 'add-plus', + text: 'Add', +}; + +const remove: ButtonGroupProps.Item = { + type: 'icon-button', + id: 'remove', + iconName: 'remove', + text: 'Remove', +}; + +const moreActionsMenu: ButtonGroupProps.MenuDropdown = { + type: 'menu-dropdown', + id: 'more-actions', + text: 'More actions', + items: [remove], +}; + +const actionsGroupsWithMenu: ButtonGroupProps.Group = { + type: 'group', + text: 'Actions', + items: [add, moreActionsMenu], +}; + +export default function () { + const { + urlParams: { tooltipToggle = false, usePressedText = false, feedbackType = 'none' }, + setUrlParams, + } = useContext(AppContext as PageContext); + + return ( + + +
ButtonGroup interaction variants
+ + + + setUrlParams({ tooltipToggle: detail.checked })} + > + Toggle tooltip on interaction + + + setUrlParams({ usePressedText: detail.checked })} + > + Use different pressed text + + + + setUrlParams({ feedbackType: e.detail.selectedId as any })} + /> + + + + + +
Demo
+ + + + +
+
+
+
+ ); +} + +function StatefulButtonGroup(props: ButtonGroupProps) { + const [feedback, setFeedback] = useState<'none' | 'like' | 'dislike'>('none'); + const { + urlParams: { usePressedText = false, feedbackType = 'none' }, + } = useContext(AppContext as PageContext); + + const items = traverseItems(props.items, item => { + if (item.type !== 'icon-toggle-button') { + return item; + } + const pressed = item.id === feedback; + const text = usePressedText && pressed ? item.pressedText : item.text; + const renderFeedback = (feedback: string) => + feedbackType === 'status' ? {feedback} : feedback; + const popoverFeedback = feedbackType !== 'none' ? renderFeedback(item.text + 'd') : undefined; + const pressedPopoverFeedback = feedbackType !== 'none' ? renderFeedback('Removed ' + item.text) : undefined; + return { ...item, pressed, text, popoverFeedback, pressedPopoverFeedback }; + }); + + function addLog(text: string) { + const entry = document.createElement('div'); + entry.textContent = text; + document.querySelector('#log')!.append(entry); + } + + return ( + { + switch (detail.id) { + case 'like': + return setFeedback(detail.pressed ? 'like' : 'none'); + case 'dislike': + return setFeedback(detail.pressed ? 'dislike' : 'none'); + case 'copy': + return addLog('Copied'); + case 'add': + return addLog('Added'); + case 'remove': + return addLog('Removed'); + default: + // not implemented + } + }} + /> + ); +} + +function traverseItems( + source: readonly ButtonGroupProps.ItemOrGroup[], + onItem: (item: ButtonGroupProps.Item) => ButtonGroupProps.Item +): ButtonGroupProps.ItemOrGroup[] { + return source.map(itemOrGroup => { + if (itemOrGroup.type === 'group') { + return { ...itemOrGroup, items: traverseItems(itemOrGroup.items, onItem) } as ButtonGroupProps.Group; + } else { + return onItem(itemOrGroup); + } + }); +} diff --git a/pages/button-group/permutations.page.tsx b/pages/button-group/permutations.page.tsx index 129de7bae2..1dda7d2014 100644 --- a/pages/button-group/permutations.page.tsx +++ b/pages/button-group/permutations.page.tsx @@ -1,7 +1,8 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React from 'react'; +import React, { useState } from 'react'; +import cloneDeep from 'lodash/cloneDeep'; import ButtonGroup, { ButtonGroupProps } from '~components/button-group'; @@ -14,16 +15,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, }, ], }; @@ -98,9 +105,40 @@ export default function () {

ButtonGroup permutations

{}
} + render={permutation =>
{}
} /> ); } + +function StatefulButtonGroup(props: ButtonGroupProps) { + const [feedback, setFeedback] = useState<'like' | 'dislike'>('like'); + return ( + { + switch (detail.id) { + case 'like': + return setFeedback(detail.pressed ? 'like' : 'dislike'); + case 'dislike': + return setFeedback(detail.pressed ? 'dislike' : 'like'); + default: + // not implemented + } + }} + /> + ); +} + +function withFeedbackState(source: readonly ButtonGroupProps.ItemOrGroup[], feedback: 'like' | 'dislike') { + const clone = cloneDeep(source); + for (const itemOrGroup of clone) { + if (itemOrGroup.type === 'group' && itemOrGroup.text === 'Vote') { + itemOrGroup.items[0].type === 'icon-toggle-button' && (itemOrGroup.items[0].pressed = feedback === 'like'); + itemOrGroup.items[1].type === 'icon-toggle-button' && (itemOrGroup.items[1].pressed = feedback === 'dislike'); + } + } + return clone; +} diff --git a/src/__tests__/__snapshots__/documenter.test.ts.snap b/src/__tests__/__snapshots__/documenter.test.ts.snap index 3a7b87b5e9..8b01a2c20a 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", }, @@ -3871,6 +3876,21 @@ use the \`id\` attribute, consider setting it on a parent element instead.", * \`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. +* ### 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)\`. +* \`text\` (string) - The name shown as a tooltip or menu text for this button. +* \`pressed\` (boolean) - The toggle button pressed state. +* \`disabled\` (optional, boolean) - The disabled state indication for the button. +* \`loading\` (optional, boolean) - The loading state indication for the 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/). + ### menu-dropdown * \`id\` (string) - The unique identifier of the button, used as detail in \`onItemClick\`. @@ -3889,6 +3909,11 @@ group "optional": false, "type": "ReadonlyArray", }, + { + "name": "tooltipToggle", + "optional": true, + "type": "boolean", + }, { "description": "Determines the general styling of the button dropdown. * \`icon\` for icon buttons.", diff --git a/src/button-group/icon-button-item.tsx b/src/button-group/icon-button-item.tsx index cc8006bb2e..1f4a2ecfd9 100644 --- a/src/button-group/icon-button-item.tsx +++ b/src/button-group/icon-button-item.tsx @@ -8,7 +8,7 @@ import { warnOnce } from '@cloudscape-design/component-toolkit/internal'; import { ButtonProps } from '../button/interfaces.js'; import { InternalButton } from '../button/internal.js'; import Tooltip from '../internal/components/tooltip/index.js'; -import { CancelableEventHandler, ClickDetail } from '../internal/events/index.js'; +import { CancelableEventHandler, fireCancelableEvent } from '../internal/events/index.js'; import InternalLiveRegion from '../live-region/internal.js'; import { ButtonGroupProps } from './interfaces.js'; @@ -25,7 +25,7 @@ const IconButtonItem = forwardRef( item: ButtonGroupProps.IconButton; showTooltip: boolean; showFeedback: boolean; - onItemClick?: CancelableEventHandler; + onItemClick?: CancelableEventHandler; }, ref: React.Ref ) => { @@ -44,10 +44,11 @@ const IconButtonItem = forwardRef( loadingText={item.loadingText} disabled={item.disabled} iconName={hasIcon ? item.iconName : 'close'} - iconAlt={item.text} + iconUrl={item.iconUrl} iconSvg={item.iconSvg} + iconAlt={item.text} ariaLabel={item.text} - onClick={onItemClick} + onClick={event => fireCancelableEvent(onItemClick, { id: item.id }, event)} ref={ref} data-testid={item.id} data-itemid={item.id} 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..e11901c3e6 --- /dev/null +++ b/src/button-group/icon-toggle-button-item.tsx @@ -0,0 +1,83 @@ +// 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 feedbackContent = item.pressed ? item.popoverFeedback : item.pressedPopoverFeedback; + 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="" + > + {item.text} + + {showTooltip && !item.disabled && !item.loading && (!showFeedback || feedbackContent) && ( + {feedbackContent}) || item.text + } + 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..4c65c6413a 100644 --- a/src/button-group/interfaces.ts +++ b/src/button-group/interfaces.ts @@ -48,6 +48,21 @@ export interface ButtonGroupProps extends BaseComponentProps { * * `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. * + * * ### 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)`. + * * `text` (string) - The name shown as a tooltip or menu text for this button. + * * `pressed` (boolean) - The toggle button pressed state. + * * `disabled` (optional, boolean) - The disabled state indication for the button. + * * `loading` (optional, boolean) - The loading state indication for the 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/). + * * ### menu-dropdown * * * `id` (string) - The unique identifier of the button, used as detail in `onItemClick`. @@ -67,6 +82,8 @@ export interface ButtonGroupProps extends BaseComponentProps { * Called when the user clicks on an item, and the item is not disabled. The event detail object contains the id of the clicked item. */ onItemClick?: NonCancelableEventHandler; + + tooltipToggle?: boolean; } export interface InternalButtonGroupProps extends ButtonGroupProps, InternalBaseComponentProps {} @@ -75,7 +92,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 +108,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 +145,7 @@ export namespace ButtonGroupProps { export interface ItemClickDetails { id: string; + pressed?: boolean; } export interface Ref { diff --git a/src/button-group/internal.tsx b/src/button-group/internal.tsx index c645f236d8..2e1efc9222 100644 --- a/src/button-group/internal.tsx +++ b/src/button-group/internal.tsx @@ -30,6 +30,7 @@ const InternalButtonGroup = forwardRef( onItemClick, ariaLabel, dropdownExpandToViewport, + tooltipToggle = false, __internalRootRef = null, ...props }: InternalButtonGroupProps, @@ -158,6 +159,7 @@ const InternalButtonGroup = forwardRef( setTooltip={setTooltip} onItemClick={onItemClick} ref={element => (itemsRef.current[item.id] = element)} + tooltipToggle={tooltipToggle} /> ); diff --git a/src/button-group/item-element.tsx b/src/button-group/item-element.tsx index 63312ae649..1fff38cb1f 100644 --- a/src/button-group/item-element.tsx +++ b/src/button-group/item-element.tsx @@ -3,9 +3,10 @@ import React, { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'; import { ButtonProps } from '../button/interfaces.js'; -import { ClickDetail, fireCancelableEvent, NonCancelableEventHandler } from '../internal/events'; +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'; @@ -17,11 +18,12 @@ interface ItemElementProps { tooltip: null | { item: string; feedback: boolean }; setTooltip: (tooltip: null | { item: string; feedback: boolean }) => void; onItemClick?: NonCancelableEventHandler | undefined; + tooltipToggle: boolean; } const ItemElement = forwardRef( ( - { item, dropdownExpandToViewport, tooltip, setTooltip, onItemClick }: ItemElementProps, + { item, dropdownExpandToViewport, tooltip, setTooltip, onItemClick, tooltipToggle }: ItemElementProps, ref: React.Ref ) => { const containerRef = useRef(null); @@ -79,14 +81,16 @@ const ItemElement = forwardRef( setTooltip(show ? { item: item.id, feedback: false } : null); }; - const onClickHandler = (event: CustomEvent) => { + const onClickHandler = (event: CustomEvent) => { const hasPopoverFeedback = 'popoverFeedback' in item && item.popoverFeedback; if (hasPopoverFeedback) { setTooltip({ item: item.id, feedback: true }); + } else if (tooltipToggle) { + setTooltip(tooltip ? null : { item: item.id, feedback: false }); } - fireCancelableEvent(onItemClick, { id: 'id' in event.detail ? event.detail.id : item.id }, event); + fireCancelableEvent(onItemClick, event.detail, event); }; return ( @@ -115,6 +119,15 @@ const ItemElement = forwardRef( showFeedback={!!tooltip?.feedback} /> )} + {item.type === 'icon-toggle-button' && ( + + )} {item.type === 'menu-dropdown' && ( ) => { if (isDevelopment) {