Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/components/Button/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,6 @@ import { MdPlayArrow } from 'react-icons/md';
| `helperIcon` | `React.ReactNode` | | The auxiliary icon to be displayed. Used for 'stacked' layout. |
| `hideHelperIcon` | `boolean` | `false` | If `true`, the auxiliary icon will be hidden. Used for 'stacked' layout.|
| `helperOnClick` | `(event: React.MouseEvent<HTMLDivElement>) => void` | | The function to be called when the auxiliary icon is clicked. |
| `showFeedback` | `boolean` | `false` | If `true`, temporarily shows `feedbackContent` in place of the button's own label/icon for `feedbackDuration` after `onClick` fires. |
| `feedbackContent` | `React.ReactNode` | `<MdCheckCircle />` | Content rendered while the click feedback is visible (e.g. a checkmark icon and "Copied"). |
| `feedbackDuration` | `number` | `2000` | How long, in milliseconds, the click feedback stays visible. |
35 changes: 34 additions & 1 deletion src/components/Button/component.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import React from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test';
import BBButton from './component';
import { MdFavorite, MdMoreVert, MdSettings } from 'react-icons/md';
import {
MdFavorite, MdMoreVert, MdSettings, MdCheckCircle, MdContentCopy,
} from 'react-icons/md';
import {
VARIANT_VALUES,
SIZE_VALUES,
Expand Down Expand Up @@ -102,6 +104,21 @@ const meta = {
description: `Keyboard keydown event handler. Useful to handle custom keyboard interactions; receives the native keyboard event. Works across all layouts.`,
table: { defaultValue: { summary: 'null' } },
},
showFeedback: {
control: 'boolean',
description: 'When `true`, temporarily shows `feedbackContent` in place of the button\'s own label/icon for `feedbackDuration` after `onClick` fires.',
table: { defaultValue: { summary: 'false' } },
},
feedbackContent: {
control: false,
description: 'Content rendered while the click feedback is visible (e.g. a checkmark icon and "Copied"). Only relevant when `showFeedback` is `true`.',
table: { defaultValue: { summary: '<MdCheckCircle />' } },
},
feedbackDuration: {
control: { type: 'number', min: 0, step: 100 },
description: 'How long, in milliseconds, the click feedback stays visible. Only relevant when `showFeedback` is `true`.',
table: { defaultValue: { summary: '2000' } },
},
},

args: {
Expand Down Expand Up @@ -353,3 +370,19 @@ export const WithStartAndEndIcons: Story = {
</div>
),
};

/** Shows a transient "Copied" feedback in place of the label after clicking. */
export const WithFeedback: Story = {
name: 'With Click Feedback',
args: {
label: 'Copy Link',
variant: 'primary',
iconStart: <MdContentCopy size="1.25rem" />,
showFeedback: true,
feedbackContent: (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }}>
<MdCheckCircle /> Copied
</span>
),
},
};
71 changes: 56 additions & 15 deletions src/components/Button/component.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import React, { JSX, useId } from 'react';
import { MdSettings } from 'react-icons/md';
import React, {
JSX, useCallback, useEffect, useId, useRef, useState,
} from 'react';
import { MdSettings, MdCheckCircle } from 'react-icons/md';
import { ButtonProps } from './type';
import {
DEFAULT_VARIANT,
Expand Down Expand Up @@ -39,8 +41,28 @@ function Button(props: ButtonProps): JSX.Element {
layout = DEFAULT_LAYOUT,
disabled = false,
children,
showFeedback = false,
feedbackContent = <MdCheckCircle fontSize="small" />,
feedbackDuration = 2000,
} = props;

const feedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [isFeedbackVisible, setFeedbackVisible] = useState(false);

useEffect(() => () => {
if (feedbackTimeoutRef.current) clearTimeout(feedbackTimeoutRef.current);
}, []);

const handleClick: React.MouseEventHandler<HTMLButtonElement> = useCallback((event) => {
onClick(event);

if (showFeedback) {
setFeedbackVisible(true);
if (feedbackTimeoutRef.current) clearTimeout(feedbackTimeoutRef.current);
feedbackTimeoutRef.current = setTimeout(() => setFeedbackVisible(false), feedbackDuration);
}
}, [onClick, showFeedback, feedbackDuration]);

const accessibilityProps: {
'aria-label'?: string;
'aria-labelledby'?: string;
Expand All @@ -49,11 +71,12 @@ function Button(props: ButtonProps): JSX.Element {
const generatedId = useId();
const defaultLabelId = `${id || generatedId}-label`;
// Only default aria-labelledby to defaultLabelId when an element with that id is
// actually rendered (stacked always renders ButtonText; default only when labeled).
// circle never renders a label element, so it must rely on aria-label instead —
// otherwise aria-labelledby would point at a non-existent id.
// actually rendered (stacked always renders ButtonText; default when labeled, or
// when the feedback content is taking the label's place). circle never renders a
// label element, so it must rely on aria-label instead — otherwise aria-labelledby
// would point at a non-existent id.
const hasRenderedLabelElement = layout === LAYOUTS.STACKED
|| (layout === LAYOUTS.DEFAULT && Boolean(label));
|| (layout === LAYOUTS.DEFAULT && (Boolean(label) || isFeedbackVisible));
accessibilityProps['aria-label'] = ariaLabel || label;
if (ariaLabelledBy) {
accessibilityProps['aria-labelledby'] = ariaLabelledBy;
Expand All @@ -71,7 +94,7 @@ function Button(props: ButtonProps): JSX.Element {
<Styled.Button
id={id}
data-test={testId}
onClick={onClick}
onClick={handleClick}
onKeyDown={onKeyDown}
{...accessibilityProps}
$color={color}
Expand All @@ -80,7 +103,11 @@ function Button(props: ButtonProps): JSX.Element {
$layout={layout}
disabled={disabled}
>
{icon}
{isFeedbackVisible ? (
<Styled.FeedbackContent role="status" aria-live="polite">
{feedbackContent}
</Styled.FeedbackContent>
) : icon}
</Styled.Button>
);
}
Expand All @@ -98,7 +125,7 @@ function Button(props: ButtonProps): JSX.Element {
<Styled.ButtonWrapper data-test={testId} $layout={layout}>
<Styled.Button
id={id}
onClick={onClick}
onClick={handleClick}
onKeyDown={onKeyDown}
{...accessibilityProps}
$color={color}
Expand Down Expand Up @@ -128,7 +155,13 @@ function Button(props: ButtonProps): JSX.Element {
<Styled.HelperIcon>{helperIcon}</Styled.HelperIcon>
</Styled.HelperIconContainer>
)}
{icon && <Styled.IconWrapper>{icon}</Styled.IconWrapper>}
{isFeedbackVisible ? (
<Styled.IconWrapper>
<Styled.FeedbackContent role="status" aria-live="polite">
{feedbackContent}
</Styled.FeedbackContent>
</Styled.IconWrapper>
) : (icon && <Styled.IconWrapper>{icon}</Styled.IconWrapper>)}
</Styled.Button>
<Styled.ButtonText id={defaultLabelId}>{label}</Styled.ButtonText>
</Styled.ButtonWrapper>
Expand All @@ -142,7 +175,7 @@ function Button(props: ButtonProps): JSX.Element {
<Styled.Button
id={id}
data-test={testId}
onClick={onClick}
onClick={handleClick}
onKeyDown={onKeyDown}
{...accessibilityProps}
$color={color}
Expand All @@ -151,10 +184,18 @@ function Button(props: ButtonProps): JSX.Element {
$layout={layout}
disabled={disabled}
>
{iconStart && iconStart}
{label && <span id={defaultLabelId}>{label}</span>}
{children}
{iconEnd && iconEnd}
{isFeedbackVisible ? (
<Styled.FeedbackContent id={defaultLabelId} role="status" aria-live="polite">
{feedbackContent}
</Styled.FeedbackContent>
) : (
<>
{iconStart && iconStart}
{label && <span id={defaultLabelId}>{label}</span>}
{children}
{iconEnd && iconEnd}
</>
)}
</Styled.Button>
);
})();
Expand Down
7 changes: 7 additions & 0 deletions src/components/Button/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,13 @@ export const IconWrapper = styled.div`
}
`;

export const FeedbackContent = styled.span`
display: inline-flex;
align-items: center;
justify-content: center;
gap: ${spacingSmall};
`;

export const ButtonText = styled.div`
color: ${colorTextDefault};
font-size: ${fontSizeSmall};
Expand Down
13 changes: 13 additions & 0 deletions src/components/Button/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,19 @@ type BaseButtonProps = {
size?: SizeType;
disabled?: boolean;
children?: React.ReactNode;

/**
* When `true`, temporarily shows `feedbackContent` in place of the button's own
* label/icon for `feedbackDuration` after `onClick` fires.
* @default false
*/
showFeedback?: boolean;

/** Content rendered while the click feedback is visible (e.g. a checkmark icon and "Copied"). @default <MdCheckCircle /> */
feedbackContent?: React.ReactNode;

/** How long, in milliseconds, the click feedback stays visible. @default 2000 */
feedbackDuration?: number;
}

type DefaultLayoutProps = BaseButtonProps & {
Expand Down
Loading