Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
54 changes: 42 additions & 12 deletions packages/components/button/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ import { TdButtonProps } from './type';
import { buttonDefaultProps } from './defaultProps';
import parseTNode from '../_util/parseTNode';
import useDefaultProps from '../hooks/useDefaultProps';
import composeRefs from '../_util/composeRefs';

export interface ButtonProps
extends TdButtonProps,
Omit<React.AllHTMLAttributes<HTMLElement>, 'content' | 'shape' | 'size' | 'type'> {}

const Button = forwardRef((originProps: ButtonProps, ref: React.RefObject<HTMLElement>) => {
const Button = forwardRef<HTMLElement, ButtonProps>((originProps, ref) => {
const props = useDefaultProps(originProps, buttonDefaultProps);

const {
type,
theme,
Expand All @@ -37,20 +39,20 @@ const Button = forwardRef((originProps: ButtonProps, ref: React.RefObject<HTMLEl
} = props;

const { classPrefix } = useConfig();

const [btnDom, setRefCurrent] = useDomRefCallback();
useRipple(ref?.current || btnDom);
useRipple(btnDom);

const renderChildren = content ?? children;

let iconNode = icon;
if (loading) iconNode = <Loading loading={loading} inheritColor={true} />;
if (loading) {
iconNode = <Loading loading={loading} inheritColor />;
} else if (icon && React.isValidElement(icon)) {
iconNode = React.cloneElement(icon as React.ReactElement<any>, { 'aria-hidden': true });
}

const renderTheme = useMemo(() => {
if (!theme) {
if (variant === 'base') return 'primary';
return 'default';
}
if (!theme) return variant === 'base' ? 'primary' : 'default';
return theme;
}, [theme, variant]);

Expand All @@ -60,14 +62,40 @@ const Button = forwardRef((originProps: ButtonProps, ref: React.RefObject<HTMLEl
return tag || 'button';
}, [tag, href, disabled]);

const a11yProps: Record<string, any> = {
role: renderTag === 'div' ? 'button' : undefined,
'aria-disabled': renderTag === 'div' && disabled ? true : undefined,
'aria-busy': loading ? true : undefined,
'aria-label': !renderChildren && icon && !('aria-label' in buttonProps) ? 'button' : undefined,
tabIndex: renderTag === 'div' && !disabled ? 0 : undefined,
};

const handleKeyDown = (e: React.KeyboardEvent) => {
if (!disabled && !loading) {
if (e.key === 'Enter') {
onClick?.(e as unknown as React.MouseEvent<HTMLDivElement>);
Copy link

Copilot AI Oct 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type casting e as unknown as React.MouseEvent<HTMLDivElement> is problematic because it forces a KeyboardEvent to be treated as a MouseEvent. Consider creating a separate handler or using a union type that accepts both event types.

Copilot uses AI. Check for mistakes.
}
if (e.key === ' ') {
e.preventDefault();
}
}
};

const handleKeyUp = (e: React.KeyboardEvent) => {
if (!disabled && !loading && e.key === ' ') {
onClick?.(e as unknown as React.MouseEvent<HTMLDivElement>);
Copy link

Copilot AI Oct 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same type casting issue as line 76. The KeyboardEvent should not be forced into a MouseEvent type, as this could lead to runtime issues when the onClick handler expects MouseEvent-specific properties.

Copilot uses AI. Check for mistakes.
}
};

return React.createElement(
renderTag,
{
...buttonProps,
href,
type,
ref: ref || setRefCurrent,
disabled: disabled || loading,
...a11yProps,
href: renderTag === 'a' ? href : undefined,
type: renderTag === 'button' ? type : undefined,
ref: composeRefs(ref, (node) => setRefCurrent(node as HTMLElement)),
disabled: renderTag === 'button' ? disabled || loading : undefined,
className: classNames(
className,
[
Expand All @@ -86,6 +114,8 @@ const Button = forwardRef((originProps: ButtonProps, ref: React.RefObject<HTMLEl
},
),
onClick: !disabled && !loading ? onClick : undefined,
onKeyDown: renderTag === 'div' ? handleKeyDown : undefined,
onKeyUp: renderTag === 'div' ? handleKeyUp : undefined,
},
<>
{iconNode}
Expand Down
123 changes: 123 additions & 0 deletions packages/components/button/__tests__/button.a11y.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import React from 'react';
import { render, fireEvent, vi } from '@test/utils';
import { axe } from 'jest-axe';
import { Button } from '..';

describe('Button Accessibility', () => {
it('renders a basic button without any accessibility violations (WCAG 2.1: All relevant rules)', async () => {
const { container } = render(<Button>Submit</Button>);
const results = await axe(container);
expect(results.violations.length).toBe(0);
});

it('hides decorative icons from screen readers (WCAG 2.1: 1.3.1 Info and Relationships)', () => {
const { container } = render(<Button icon={<svg />} />);
const svg = container.querySelector('svg');
expect(svg).toHaveAttribute('aria-hidden', 'true');
});

it('adds aria-label when button has no visible text (WCAG 2.1: 4.1.2 Name, Role, Value)', () => {
const { container } = render(<Button icon={<svg />} />);
expect(container.firstChild).toHaveAttribute('aria-label', 'button');
});

it('uses visible text as accessible name when present (WCAG 2.1: 4.1.2 Name, Role, Value)', () => {
const { container } = render(<Button>Submit</Button>);
const el = container.querySelector('button');
expect(el).not.toHaveAttribute('aria-label');
expect(el?.textContent).toBe('Submit');
});

it('assigns role="button" when a div is used (WCAG 2.1: 4.1.2 Name, Role, Value)', () => {
const { container } = render(<Button tag="div">Click</Button>);
const el = container.querySelector('div');
expect(el).toHaveAttribute('role', 'button');
});

it('renders div role button when disabled and no tag specified (WCAG 2.1: 4.1.2 Name, Role, Value)', () => {
const { container } = render(<Button disabled>Disabled</Button>);
const el = container.querySelector('div');
expect(el).toBeTruthy();
expect(el).toHaveAttribute('role', 'button');
expect(el).toHaveAttribute('aria-disabled', 'true');
});

it('renders anchor with href when not disabled and href provided (WCAG 2.1: 4.1.2 Name, Role, Value)', () => {
const { container } = render(
<Button href="https://tdesign.tencent.com/" theme="primary" variant="base">
Visit
</Button>,
);
const anchor = container.querySelector('a');
expect(anchor).toBeTruthy();
expect(anchor).toHaveAttribute('href', 'https://tdesign.tencent.com/');
expect(anchor).not.toHaveAttribute('role');
expect(anchor).not.toHaveAttribute('aria-disabled');
});

it('applies aria-busy when loading (WCAG 2.1: 4.1.3 Status Messages)', () => {
const { container } = render(<Button loading>Loading</Button>);
expect(container.firstChild).toHaveAttribute('aria-busy', 'true');
});

it('applies aria-disabled on div buttons when disabled (WCAG 2.1: 4.1.2 Name, Role, Value)', () => {
const { container } = render(<Button tag="div" disabled />);
const el = container.querySelector('div');
expect(el).toHaveAttribute('aria-disabled', 'true');
});

it('sets disabled attribute on button elements when disabled or loading (WCAG 2.1: 4.1.2 Name, Role, Value)', () => {
const { container: c1 } = render(
<Button tag="button" disabled>
Disabled
</Button>,
);
expect(c1.querySelector('button')).toHaveAttribute('disabled');

const { container: c2 } = render(
<Button tag="button" loading>
Loading
</Button>,
);
expect(c2.querySelector('button')).toHaveAttribute('disabled');
});

it('suppresses click when disabled or loading (WCAG 2.1: 2.1.1 Keyboard)', () => {
const fn1 = vi.fn();
const { container: c1 } = render(
<Button onClick={fn1} disabled>
Disabled
</Button>,
);
fireEvent.click(c1.firstChild as Element);
expect(fn1).not.toHaveBeenCalled();

const fn2 = vi.fn();
const { container: c2 } = render(
<Button onClick={fn2} loading>
Loading
</Button>,
);
fireEvent.click(c2.firstChild as Element);
expect(fn2).not.toHaveBeenCalled();
});

it('div role button should be keyboard operable (Enter triggers click) (WCAG 2.1: 2.1.1 Keyboard)', () => {
const fn = vi.fn();
const { container } = render(
<Button tag="div" onClick={fn}>
Keyboard Trigger
</Button>,
);
const el = container.querySelector('div') as HTMLElement;
fireEvent.keyDown(el, { key: 'Enter', code: 'Enter', charCode: 13 });
expect(fn).toHaveBeenCalled();
});

it('div role button should be focusable via keyboard (WCAG 2.1: 2.1.1 Keyboard)', () => {
const { container } = render(<Button tag="div">Focusable</Button>);
const el = container.querySelector('div') as HTMLElement;
el.focus();
expect(document.activeElement).toBe(el);
});
});
Loading