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
23 changes: 23 additions & 0 deletions src/components/Button/Button.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<Button id="save" label="Save" onFocus={onFocus} />,
);

const button = screen.getByRole('button', {name: 'Save'});
expect(button).toHaveAttribute('id', 'save');

await user.tab();
expect(button).toHaveFocus();
expect(onFocus).toHaveBeenCalledOnce();

rerender(<Button href="/save" id="save" label="Save" onFocus={onFocus} />);

const link = screen.getByRole('link', {name: 'Save'});
expect(link).toHaveAttribute('id', 'save');

link.focus();
expect(onFocus).toHaveBeenCalledTimes(2);
});
});
32 changes: 32 additions & 0 deletions src/components/Button/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import type {
CSSProperties,
FocusEventHandler,
JSX,
KeyboardEvent,
MouseEvent,
Expand Down Expand Up @@ -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.
Expand All @@ -136,6 +141,10 @@ interface ButtonBaseProps {
* Click event handler.
*/
onClick?: MouseEventHandler<HTMLElement>;
/**
* Focus event handler for the root element.
*/
onFocus?: FocusEventHandler<HTMLElement>;
/**
* Keyboard event handler for the root element.
*/
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -252,8 +280,10 @@ export function Button({
startContent,
tooltip,
onClick,
onFocus,
onKeyDown,
form,
id,
name,
value,
width,
Expand Down Expand Up @@ -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}
Expand Down
1 change: 1 addition & 0 deletions src/components/Button/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export {
Button,
type ButtonPassthroughProps,
type ButtonProps,
type ButtonSize,
} from 'components/Button/Button';
25 changes: 25 additions & 0 deletions src/components/Chat/Chat.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<>
<span id="scroll-hint">Jump to the latest message</span>
<ChatScrollButton
aria-describedby="scroll-hint"
aria-keyshortcuts="Meta+ArrowDown"
id="scroll-to-bottom"
isVisible
onClick={() => {}}
/>
</>,
);

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');
});
});
37 changes: 37 additions & 0 deletions src/components/Chat/ChatComposer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<>
<span id="send-hint">Press to send</span>
<ChatSendButton
aria-describedby="send-hint"
aria-keyshortcuts="Meta+Enter"
form="composer-form"
id="send"
isDisabled={false}
onFocus={onFocus}
onKeyDown={onKeyDown}
/>
</>,
);

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();
});
});
13 changes: 5 additions & 8 deletions src/components/Chat/ChatScrollButton.tsx
Original file line number Diff line number Diff line change
@@ -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.
*/
Expand Down Expand Up @@ -54,7 +51,7 @@ export function ChatScrollButton({
onClick,
ref,
style,
...rest
...passthrough
}: ChatScrollButtonProps): React.JSX.Element {
const classes = chatScrollButtonRecipe({
isExpanded: label != null,
Expand All @@ -63,13 +60,13 @@ export function ChatScrollButton({

return (
<div
{...rest}
className={cx(classes.wrapper, className)}
data-testid={dataTestId}
ref={ref}
style={style}>
<div className={classes.pill}>
<Button
{...passthrough}
className={classes.button}
icon={ChevronDown}
isIconOnly={label == null}
Expand Down
6 changes: 4 additions & 2 deletions src/components/Chat/ChatSendButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import {ArrowUp, Square} from 'lucide-react';
import type {CSSProperties, Ref} from 'react';
import {Button} from 'components/Button';
import {Button, type ButtonPassthroughProps} from 'components/Button';
import {useChatComposerContext} from 'components/Chat/ChatContext';
import type {IconComponent} from 'components/Icon';
import {css} from 'styled-system/css';
Expand All @@ -13,7 +13,7 @@ const sendButtonStyle = css({
flexShrink: 0,
});

export interface ChatSendButtonProps {
export interface ChatSendButtonProps extends ButtonPassthroughProps {
/**
* Additional CSS class names applied to the button.
*/
Expand Down Expand Up @@ -85,13 +85,15 @@ export function ChatSendButton({
size = 'md',
stopIcon = Square,
style,
...passthrough
}: ChatSendButtonProps): React.JSX.Element {
const composer = useChatComposerContext();
const showStop = isStopShown ?? composer?.isStopShown ?? false;
const sendDisabled = isDisabled ?? !(composer?.canSend ?? false);

return (
<Button
{...passthrough}
className={cx(sendButtonStyle, className)}
data-testid={dataTestId}
icon={showStop ? stopIcon : sendIcon}
Expand Down
25 changes: 25 additions & 0 deletions src/components/CopyButton/CopyButton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -249,4 +249,29 @@ describe('CopyButton', () => {
await user.click(copyButton);
expect(writeText).not.toHaveBeenCalled();
});

it('forwards the shared button passthrough props', () => {
render(
<>
<span id="copy-hint">Copies the snippet</span>
<CopyButton
aria-describedby="copy-hint"
aria-keyshortcuts="Meta+C"
data-testid="copy-button"
id="copy"
value="copy me"
/>
</>,
);

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');
});
});
31 changes: 20 additions & 11 deletions src/components/CopyButton/CopyButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -67,6 +74,7 @@ export function CopyButton({
style,
value,
variant = 'ghost',
...passthrough
}: CopyButtonProps): React.JSX.Element {
const [isCopied, setIsCopied] = useState(false);
const resetTimeoutRef = useRef<number | null>(null);
Expand Down Expand Up @@ -121,6 +129,7 @@ export function CopyButton({
return (
<>
<Button
{...passthrough}
className={className}
data-testid={dataTestId}
icon={isCopied ? Check : Copy}
Expand Down
23 changes: 23 additions & 0 deletions src/components/SplitButton/SplitButton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,4 +119,27 @@ describe('SplitButton', () => {
'silver-h_component.lg',
);
});

it('forwards the shared button passthrough props to the primary action', () => {
render(
<>
<span id="save-hint">Saves the document</span>
<SplitButton
aria-describedby="save-hint"
aria-keyshortcuts="Meta+S"
id="save"
items={[{label: 'Save a copy'}]}
label="Save"
/>
</>,
);

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');
});
});
Loading