From d1c4e71d5102696282976a7a43887ba44fb3f585 Mon Sep 17 00:00:00 2001 From: Arthurk12 Date: Tue, 14 Jul 2026 10:11:16 -0300 Subject: [PATCH] feat(BBButton): add transient click feedback Adds showFeedback/feedbackContent/feedbackDuration props so a button can briefly swap its label/icon for a confirmation (e.g. "Copied") after being clicked, mirroring the sent-feedback affordance already used by BBBInput. Documents the new props in the README and Storybook. --- src/components/Button/README.md | 3 + src/components/Button/component.stories.tsx | 35 +++++++++- src/components/Button/component.tsx | 71 ++++++++++++++++----- src/components/Button/styles.ts | 7 ++ src/components/Button/type.ts | 13 ++++ 5 files changed, 113 insertions(+), 16 deletions(-) diff --git a/src/components/Button/README.md b/src/components/Button/README.md index 50b6299..9e6914e 100644 --- a/src/components/Button/README.md +++ b/src/components/Button/README.md @@ -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) => 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` | `` | 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. | diff --git a/src/components/Button/component.stories.tsx b/src/components/Button/component.stories.tsx index d393508..04796a3 100644 --- a/src/components/Button/component.stories.tsx +++ b/src/components/Button/component.stories.tsx @@ -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, @@ -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: '' } }, + }, + 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: { @@ -353,3 +370,19 @@ export const WithStartAndEndIcons: Story = { ), }; + +/** 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: , + showFeedback: true, + feedbackContent: ( + + Copied + + ), + }, +}; diff --git a/src/components/Button/component.tsx b/src/components/Button/component.tsx index b4d6f36..f0fd766 100644 --- a/src/components/Button/component.tsx +++ b/src/components/Button/component.tsx @@ -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, @@ -39,8 +41,28 @@ function Button(props: ButtonProps): JSX.Element { layout = DEFAULT_LAYOUT, disabled = false, children, + showFeedback = false, + feedbackContent = , + feedbackDuration = 2000, } = props; + const feedbackTimeoutRef = useRef | null>(null); + const [isFeedbackVisible, setFeedbackVisible] = useState(false); + + useEffect(() => () => { + if (feedbackTimeoutRef.current) clearTimeout(feedbackTimeoutRef.current); + }, []); + + const handleClick: React.MouseEventHandler = 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; @@ -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; @@ -71,7 +94,7 @@ function Button(props: ButtonProps): JSX.Element { - {icon} + {isFeedbackVisible ? ( + + {feedbackContent} + + ) : icon} ); } @@ -98,7 +125,7 @@ function Button(props: ButtonProps): JSX.Element { {helperIcon} )} - {icon && {icon}} + {isFeedbackVisible ? ( + + + {feedbackContent} + + + ) : (icon && {icon})} {label} @@ -142,7 +175,7 @@ function Button(props: ButtonProps): JSX.Element { - {iconStart && iconStart} - {label && {label}} - {children} - {iconEnd && iconEnd} + {isFeedbackVisible ? ( + + {feedbackContent} + + ) : ( + <> + {iconStart && iconStart} + {label && {label}} + {children} + {iconEnd && iconEnd} + + )} ); })(); diff --git a/src/components/Button/styles.ts b/src/components/Button/styles.ts index 5683fba..c3f478e 100644 --- a/src/components/Button/styles.ts +++ b/src/components/Button/styles.ts @@ -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}; diff --git a/src/components/Button/type.ts b/src/components/Button/type.ts index 29857ef..d5960d1 100644 --- a/src/components/Button/type.ts +++ b/src/components/Button/type.ts @@ -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 */ + feedbackContent?: React.ReactNode; + + /** How long, in milliseconds, the click feedback stays visible. @default 2000 */ + feedbackDuration?: number; } type DefaultLayoutProps = BaseButtonProps & {