Skip to content

Commit

Permalink
feat: Support for toggle buttons in button group
Browse files Browse the repository at this point in the history
  • Loading branch information
pan-kot committed Oct 28, 2024
1 parent 7cd7cc6 commit 8f66e2d
Show file tree
Hide file tree
Showing 9 changed files with 422 additions and 14 deletions.
209 changes: 209 additions & 0 deletions pages/button-group/interaction-variants.page.tsx
Original file line number Diff line number Diff line change
@@ -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: <StatusIndicator>Copied</StatusIndicator>,
};

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 (
<ScreenshotArea disableAnimations={true}>
<SpaceBetween size="m">
<Header variant="h1">ButtonGroup interaction variants</Header>

<ExpandableSection headerText="Settings" variant="container" headingTagOverride="h2" defaultExpanded={true}>
<SpaceBetween size="s" direction="horizontal" alignItems="center">
<Checkbox
checked={tooltipToggle}
onChange={({ detail }) => setUrlParams({ tooltipToggle: detail.checked })}
>
Toggle tooltip on interaction
</Checkbox>

<Checkbox
checked={usePressedText}
onChange={({ detail }) => setUrlParams({ usePressedText: detail.checked })}
>
Use different pressed text
</Checkbox>

<SpaceBetween size="xs" direction="horizontal" alignItems="center">
<SegmentedControl
label="Popover feedback type"
options={[
{ id: 'none', text: 'None' },
{ id: 'text', text: 'Text' },
{ id: 'status', text: 'Status' },
]}
selectedId={feedbackType}
onChange={e => setUrlParams({ feedbackType: e.detail.selectedId as any })}
/>
<label htmlFor="min-size-input">Popover feedback type</label>
</SpaceBetween>
</SpaceBetween>
</ExpandableSection>

<Header variant="h2">Demo</Header>

<StatefulButtonGroup
variant="icon"
ariaLabel="Chat actions"
items={[feedbackGroup, copy, actionsGroupsWithMenu]}
tooltipToggle={tooltipToggle}
/>

<Box>
<div id="log"></div>
</Box>
</SpaceBetween>
</ScreenshotArea>
);
}

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' ? <StatusIndicator>{feedback}</StatusIndicator> : 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 (
<ButtonGroup
{...props}
items={items}
onItemClick={({ detail }) => {
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);
}
});
}
46 changes: 42 additions & 4 deletions pages/button-group/permutations.page.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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,
},
],
};
Expand Down Expand Up @@ -98,9 +105,40 @@ export default function () {
<h1>ButtonGroup permutations</h1>
<PermutationsView
permutations={buttonGroupPermutations}
render={permutation => <div>{<ButtonGroup {...permutation} />}</div>}
render={permutation => <div>{<StatefulButtonGroup {...permutation} />}</div>}
/>
</article>
</ScreenshotArea>
);
}

function StatefulButtonGroup(props: ButtonGroupProps) {
const [feedback, setFeedback] = useState<'like' | 'dislike'>('like');
return (
<ButtonGroup
{...props}
items={withFeedbackState(props.items, feedback)}
onItemClick={({ detail }) => {
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;
}
25 changes: 25 additions & 0 deletions src/__tests__/__snapshots__/documenter.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
Expand Down Expand Up @@ -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\`.
Expand All @@ -3889,6 +3909,11 @@ group
"optional": false,
"type": "ReadonlyArray<ButtonGroupProps.ItemOrGroup>",
},
{
"name": "tooltipToggle",
"optional": true,
"type": "boolean",
},
{
"description": "Determines the general styling of the button dropdown.
* \`icon\` for icon buttons.",
Expand Down
9 changes: 5 additions & 4 deletions src/button-group/icon-button-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -25,7 +25,7 @@ const IconButtonItem = forwardRef(
item: ButtonGroupProps.IconButton;
showTooltip: boolean;
showFeedback: boolean;
onItemClick?: CancelableEventHandler<ClickDetail>;
onItemClick?: CancelableEventHandler<ButtonGroupProps.ItemClickDetails>;
},
ref: React.Ref<ButtonProps.Ref>
) => {
Expand All @@ -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}
Expand Down
Loading

0 comments on commit 8f66e2d

Please sign in to comment.