diff --git a/src/components/Button/Button.test.tsx b/src/components/Button/Button.test.tsx index eec7cd4..66539e1 100644 --- a/src/components/Button/Button.test.tsx +++ b/src/components/Button/Button.test.tsx @@ -599,4 +599,27 @@ describe('Button', () => { warn.mockRestore(); }); + + it('applies id and fires onFocus on both the button and the link', async () => { + const user = userEvent.setup(); + const onFocus = vi.fn(); + const {rerender} = render( + , + ); + + const button = screen.getByRole('button', {name: 'Save'}); + expect(button).toHaveAttribute('id', 'save'); + + await user.tab(); + expect(button).toHaveFocus(); + expect(onFocus).toHaveBeenCalledOnce(); + + rerender(); + + const link = screen.getByRole('link', {name: 'Save'}); + expect(link).toHaveAttribute('id', 'save'); + + link.focus(); + expect(onFocus).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index b2b1baa..ac649ad 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -2,6 +2,7 @@ import type { CSSProperties, + FocusEventHandler, JSX, KeyboardEvent, MouseEvent, @@ -113,6 +114,10 @@ interface ButtonBaseProps { * renders as a link element. */ href?: string; + /** + * HTML `id` attribute applied to the root element. + */ + id?: string; /** * Whether the button is disabled. Prevents interaction and applies disabled * styling. @@ -136,6 +141,10 @@ interface ButtonBaseProps { * Click event handler. */ onClick?: MouseEventHandler; + /** + * Focus event handler for the root element. + */ + onFocus?: FocusEventHandler; /** * Keyboard event handler for the root element. */ @@ -218,6 +227,25 @@ export type ButtonProps = isIconOnly?: false; }); +/** + * The identity, description, and interaction props every button-like component + * in the library forwards to the button it renders. + */ +export type ButtonPassthroughProps = Pick< + ButtonProps, + | 'aria-controls' + | 'aria-describedby' + | 'aria-details' + | 'aria-expanded' + | 'aria-haspopup' + | 'aria-keyshortcuts' + | 'aria-labelledby' + | 'form' + | 'id' + | 'onFocus' + | 'onKeyDown' +>; + export function Button({ label, 'aria-controls': ariaControls, @@ -252,8 +280,10 @@ export function Button({ startContent, tooltip, onClick, + onFocus, onKeyDown, form, + id, name, value, width, @@ -379,12 +409,14 @@ export function Button({ data-testid={dataTestId} form={form} href={renderAsLink ? href : undefined} + id={id} isDisabled={ !renderAsLink && !useAriaDisabled ? buttonDisabled : undefined } isLink={renderAsLink} name={name} onClick={renderAsLink ? handleLinkClick : handleButtonClick} + onFocus={onFocus} onKeyDown={renderAsLink ? handleLinkKeyDown : handleButtonKeyDown} ref={ref} rel={renderAsLink ? linkRel : undefined} diff --git a/src/components/Button/index.ts b/src/components/Button/index.ts index 3f7a913..0f5201d 100644 --- a/src/components/Button/index.ts +++ b/src/components/Button/index.ts @@ -1,5 +1,6 @@ export { Button, + type ButtonPassthroughProps, type ButtonProps, type ButtonSize, } from 'components/Button/Button'; diff --git a/src/components/Chat/Chat.test.tsx b/src/components/Chat/Chat.test.tsx index dcaa9c4..defcead 100644 --- a/src/components/Chat/Chat.test.tsx +++ b/src/components/Chat/Chat.test.tsx @@ -288,4 +288,29 @@ describe('ChatScrollButton', () => { screen.getByTestId('scroll-button').firstElementChild?.className, ).not.toBe(hiddenPill); }); + + it('forwards the shared button passthrough props to the button', () => { + render( + <> + Jump to the latest message + {}} + /> + >, + ); + + const button = screen.getByRole('button', {name: 'Scroll to bottom'}); + expect(button).toHaveAttribute('id', 'scroll-to-bottom'); + // The icon-only tooltip appends its own id, so the consumer's survives + // alongside it rather than replacing it. + expect(button).toHaveAttribute( + 'aria-describedby', + expect.stringContaining('scroll-hint'), + ); + expect(button).toHaveAttribute('aria-keyshortcuts', 'Meta+ArrowDown'); + }); }); diff --git a/src/components/Chat/ChatComposer.test.tsx b/src/components/Chat/ChatComposer.test.tsx index 52e4db6..87e459c 100644 --- a/src/components/Chat/ChatComposer.test.tsx +++ b/src/components/Chat/ChatComposer.test.tsx @@ -312,4 +312,41 @@ describe('ChatSendButton', () => { expect(onSend).toHaveBeenCalledOnce(); expect(contextSubmit).not.toHaveBeenCalled(); }); + + it('forwards the shared button passthrough props', async () => { + const user = userEvent.setup(); + const onFocus = vi.fn(); + const onKeyDown = vi.fn(); + render( + <> + Press to send + + >, + ); + + const button = screen.getByRole('button', {name: 'Send'}); + expect(button).toHaveAttribute('id', 'send'); + // The icon-only tooltip appends its own id, so the consumer's survives + // alongside it rather than replacing it. + expect(button).toHaveAttribute( + 'aria-describedby', + expect.stringContaining('send-hint'), + ); + expect(button).toHaveAttribute('aria-keyshortcuts', 'Meta+Enter'); + expect(button).toHaveAttribute('form', 'composer-form'); + + await user.click(button); + expect(onFocus).toHaveBeenCalledOnce(); + + await user.keyboard('a'); + expect(onKeyDown).toHaveBeenCalledOnce(); + }); }); diff --git a/src/components/Chat/ChatScrollButton.tsx b/src/components/Chat/ChatScrollButton.tsx index f542d01..c5e9ca7 100644 --- a/src/components/Chat/ChatScrollButton.tsx +++ b/src/components/Chat/ChatScrollButton.tsx @@ -1,15 +1,12 @@ 'use client'; import {ChevronDown} from 'lucide-react'; -import type {ComponentPropsWithoutRef, CSSProperties, Ref} from 'react'; -import {Button} from 'components/Button'; +import type {CSSProperties, Ref} from 'react'; +import {Button, type ButtonPassthroughProps} from 'components/Button'; import {chatScrollButtonRecipe} from 'components/Chat/ChatScrollButton.recipe'; import {cx} from 'utils/cx'; -export interface ChatScrollButtonProps extends Omit< - ComponentPropsWithoutRef<'div'>, - 'onClick' -> { +export interface ChatScrollButtonProps extends ButtonPassthroughProps { /** * Additional CSS class names applied to the root element. */ @@ -54,7 +51,7 @@ export function ChatScrollButton({ onClick, ref, style, - ...rest + ...passthrough }: ChatScrollButtonProps): React.JSX.Element { const classes = chatScrollButtonRecipe({ isExpanded: label != null, @@ -63,13 +60,13 @@ export function ChatScrollButton({ return ( { await user.click(copyButton); expect(writeText).not.toHaveBeenCalled(); }); + + it('forwards the shared button passthrough props', () => { + render( + <> + Copies the snippet + + >, + ); + + const copyButton = screen.getByTestId('copy-button'); + expect(copyButton).toHaveAttribute('id', 'copy'); + // The icon-only tooltip appends its own id, so the consumer's survives + // alongside it rather than replacing it. + expect(copyButton).toHaveAttribute( + 'aria-describedby', + expect.stringContaining('copy-hint'), + ); + expect(copyButton).toHaveAttribute('aria-keyshortcuts', 'Meta+C'); + }); }); diff --git a/src/components/CopyButton/CopyButton.tsx b/src/components/CopyButton/CopyButton.tsx index 231391c..7524ba4 100644 --- a/src/components/CopyButton/CopyButton.tsx +++ b/src/components/CopyButton/CopyButton.tsx @@ -2,19 +2,26 @@ import {Check, Copy} from 'lucide-react'; import {useCallback, useEffect, useRef, useState} from 'react'; -import {Button, type ButtonProps} from 'components/Button'; +import { + Button, + type ButtonPassthroughProps, + type ButtonProps, +} from 'components/Button'; import useAnnounce from 'hooks/useAnnounce'; -export interface CopyButtonProps extends Pick< - ButtonProps, - | 'className' - | 'data-testid' - | 'isDisabled' - | 'ref' - | 'size' - | 'style' - | 'variant' -> { +export interface CopyButtonProps + extends + ButtonPassthroughProps, + Pick< + ButtonProps, + | 'className' + | 'data-testid' + | 'isDisabled' + | 'ref' + | 'size' + | 'style' + | 'variant' + > { /** * Label and tooltip shown after a successful copy. * @default 'Copied' @@ -67,6 +74,7 @@ export function CopyButton({ style, value, variant = 'ghost', + ...passthrough }: CopyButtonProps): React.JSX.Element { const [isCopied, setIsCopied] = useState(false); const resetTimeoutRef = useRef(null); @@ -121,6 +129,7 @@ export function CopyButton({ return ( <> { 'silver-h_component.lg', ); }); + + it('forwards the shared button passthrough props to the primary action', () => { + render( + <> + Saves the document + + >, + ); + + const primary = screen.getByRole('button', {name: 'Save'}); + expect(primary).toHaveAttribute('id', 'save'); + expect(primary).toHaveAttribute('aria-describedby', 'save-hint'); + expect(primary).toHaveAttribute('aria-keyshortcuts', 'Meta+S'); + expect( + screen.getByRole('button', {name: 'More actions'}), + ).not.toHaveAttribute('id'); + }); }); diff --git a/src/components/SplitButton/SplitButton.tsx b/src/components/SplitButton/SplitButton.tsx index d6b820d..4f2d274 100644 --- a/src/components/SplitButton/SplitButton.tsx +++ b/src/components/SplitButton/SplitButton.tsx @@ -2,7 +2,12 @@ import {ChevronDown} from 'lucide-react'; import type {CSSProperties, ReactNode, Ref} from 'react'; -import {Button, type ButtonProps, type ButtonSize} from 'components/Button'; +import { + Button, + type ButtonPassthroughProps, + type ButtonProps, + type ButtonSize, +} from 'components/Button'; import {ButtonGroup} from 'components/ButtonGroup'; import {DropdownMenu, type DropdownMenuOption} from 'components/DropdownMenu'; import type {IconComponent} from 'components/Icon'; @@ -13,10 +18,10 @@ import {useResolvedSize} from 'internal/SizeContext'; * that opens a menu of related actions. A thin composition of `ButtonGroup`, * `Button`, and `DropdownMenu`. */ -export interface SplitButtonProps extends Pick< - ButtonProps, - 'endContent' | 'isLoading' | 'onClick' | 'startContent' -> { +export interface SplitButtonProps + extends + ButtonPassthroughProps, + Pick { /** * Compound menu content (``), an alternative to `items`. */ @@ -113,6 +118,7 @@ export function SplitButton({ startContent, style, variant = 'secondary', + ...passthrough }: SplitButtonProps): React.JSX.Element { const size = useResolvedSize(sizeProp); @@ -124,6 +130,7 @@ export function SplitButton({ size={size} style={style}> { expect(screen.getByTestId('btn-a')).toHaveClass('silver-h_component.sm'); expect(screen.getByTestId('btn-b')).toHaveClass('silver-h_component.lg'); }); + + it('forwards the shared button passthrough props', () => { + render( + <> + Bolds the selection + + >, + ); + + const button = screen.getByTestId('bold'); + expect(button).toHaveAttribute('id', 'bold'); + expect(button).toHaveAttribute('aria-describedby', 'bold-hint'); + expect(button).toHaveAttribute('aria-keyshortcuts', 'Meta+B'); + // The component still owns the toggle contract it sets itself. + expect(button).toHaveAttribute('aria-pressed', 'false'); + }); }); diff --git a/src/components/ToggleButton/ToggleButton.tsx b/src/components/ToggleButton/ToggleButton.tsx index a3ea498..0b14f13 100644 --- a/src/components/ToggleButton/ToggleButton.tsx +++ b/src/components/ToggleButton/ToggleButton.tsx @@ -1,7 +1,7 @@ 'use client'; import type {CSSProperties, MouseEvent, Ref} from 'react'; -import type {ButtonSize} from 'components/Button'; +import type {ButtonPassthroughProps, ButtonSize} from 'components/Button'; import {buttonRecipe} from 'components/Button/Button.recipe'; import {Icon, type IconComponent} from 'components/Icon'; import {Spinner} from 'components/Spinner'; @@ -11,7 +11,7 @@ import {Tooltip} from 'components/Tooltip'; import {useResolvedSize} from 'internal/SizeContext'; import {cx} from 'utils/cx'; -export interface ToggleButtonProps { +export interface ToggleButtonProps extends ButtonPassthroughProps { /** * Additional CSS class names applied to the button root. */ @@ -101,6 +101,7 @@ export function ToggleButton({ style, tooltip, value, + ...passthrough }: ToggleButtonProps): React.JSX.Element { const group = useToggleButtonGroup(); @@ -137,6 +138,7 @@ export function ToggleButton({ const button = ( ; + onFocus?: FocusEventHandler; onKeyDown?: KeyboardEventHandler; ref?: Ref; rel?: string;