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/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..f14cf9244b 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-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..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 a72bbba049..1fff38cb1f 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';
@@ -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);
@@ -84,6 +86,8 @@ const ItemElement = forwardRef(
if (hasPopoverFeedback) {
setTooltip({ item: item.id, feedback: true });
+ } else if (tooltipToggle) {
+ setTooltip(tooltip ? null : { item: item.id, feedback: false });
}
fireCancelableEvent(onItemClick, event.detail, event);
@@ -115,6 +119,15 @@ const ItemElement = forwardRef(
showFeedback={!!tooltip?.feedback}
/>
)}
+ {item.type === 'icon-toggle-button' && (
+
+ )}
{item.type === 'menu-dropdown' && (
) => {
if (isDevelopment) {