-
Notifications
You must be signed in to change notification settings - Fork 358
feat(button): improve a11y support according to WCAG 2.1 #3884
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from 1 commit
9f93871
e7f09a4
65ac5f5
f0e2a84
2f01d52
1a9e168
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
|
@@ -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]); | ||
|
|
||
|
|
@@ -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>); | ||
|
||
| } | ||
| if (e.key === ' ') { | ||
| e.preventDefault(); | ||
| } | ||
| } | ||
| }; | ||
|
|
||
| const handleKeyUp = (e: React.KeyboardEvent) => { | ||
| if (!disabled && !loading && e.key === ' ') { | ||
| onClick?.(e as unknown as React.MouseEvent<HTMLDivElement>); | ||
|
||
| } | ||
| }; | ||
|
|
||
| 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, | ||
| [ | ||
|
|
@@ -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} | ||
|
|
||
| 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); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.