-
-
Notifications
You must be signed in to change notification settings - Fork 53
feat: add accessible toolbar component #1545
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: main
Are you sure you want to change the base?
Changes from all commits
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 |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| import React from 'react'; | ||
| import ToolbarRoot from './fragments/ToolbarRoot'; | ||
| import ToolbarButton from './fragments/ToolbarButton'; | ||
| import ToolbarSeparator from './fragments/ToolbarSeparator'; | ||
| import ToolbarLink from './fragments/ToolbarLink'; | ||
|
|
||
| type ToolbarElement = React.ElementRef<'div'>; | ||
| type ToolbarProps = React.ComponentPropsWithoutRef<'div'>; | ||
|
|
||
| type ToolbarComponent = React.ForwardRefExoticComponent<ToolbarProps & React.RefAttributes<ToolbarElement>> & { | ||
| Root: typeof ToolbarRoot; | ||
| Button: typeof ToolbarButton; | ||
| Separator: typeof ToolbarSeparator; | ||
| Link: typeof ToolbarLink; | ||
| }; | ||
|
|
||
| const Toolbar = React.forwardRef<ToolbarElement, ToolbarProps>((_props, _ref) => { | ||
| console.warn('Direct usage of Toolbar is not supported. Please use Toolbar.Root, Toolbar.Button, etc. instead.'); | ||
| return null; | ||
| }) as ToolbarComponent; | ||
|
|
||
| Toolbar.displayName = 'Toolbar'; | ||
|
|
||
| Toolbar.Root = ToolbarRoot; | ||
| Toolbar.Button = ToolbarButton; | ||
| Toolbar.Separator = ToolbarSeparator; | ||
| Toolbar.Link = ToolbarLink; | ||
|
|
||
| export default Toolbar; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| import { createContext } from 'react'; | ||
|
|
||
| export type ToolbarRootContextType = { | ||
| rootClass: string; | ||
| orientation: 'horizontal' | 'vertical'; | ||
| } | null; | ||
|
|
||
| const ToolbarRootContext = createContext<ToolbarRootContextType>(null); | ||
|
|
||
| export default ToolbarRootContext; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| 'use client'; | ||
| import React from 'react'; | ||
| import { clsx } from 'clsx'; | ||
| import RovingFocusGroup from '~/core/utils/RovingFocusGroup'; | ||
| import Button, { ButtonProps } from '~/components/ui/Button/Button'; | ||
| import ToolbarRootContext from '../context/ToolbarRootContext'; | ||
|
|
||
| const COMPONENT_NAME = 'ToolbarButton'; | ||
|
|
||
| const ToolbarButton = React.forwardRef<React.ElementRef<typeof Button>, ButtonProps>( | ||
| ({ className = '', ...props }, ref) => { | ||
| const context = React.useContext(ToolbarRootContext); | ||
| if (!context) throw new Error('Toolbar.Button must be used within Toolbar.Root'); | ||
| const { rootClass } = context; | ||
|
|
||
| return ( | ||
| <RovingFocusGroup.Item> | ||
| <Button ref={ref} className={clsx(`${rootClass}-button`, className)} {...props} /> | ||
| </RovingFocusGroup.Item> | ||
| ); | ||
| } | ||
| ); | ||
|
|
||
| ToolbarButton.displayName = COMPONENT_NAME; | ||
|
|
||
| export default ToolbarButton; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| 'use client'; | ||
| import React from 'react'; | ||
| import { clsx } from 'clsx'; | ||
| import RovingFocusGroup from '~/core/utils/RovingFocusGroup'; | ||
| import Link, { LinkProps } from '~/components/ui/Link/Link'; | ||
| import ToolbarRootContext from '../context/ToolbarRootContext'; | ||
|
|
||
| const COMPONENT_NAME = 'ToolbarLink'; | ||
|
|
||
| const ToolbarLink = React.forwardRef<React.ElementRef<typeof Link>, LinkProps>( | ||
| ({ className = '', ...props }, ref) => { | ||
| const context = React.useContext(ToolbarRootContext); | ||
| if (!context) throw new Error('Toolbar.Link must be used within Toolbar.Root'); | ||
| const { rootClass } = context; | ||
|
|
||
| return ( | ||
| <RovingFocusGroup.Item role="link"> | ||
| <Link ref={ref} className={clsx(`${rootClass}-link`, className)} {...props} /> | ||
| </RovingFocusGroup.Item> | ||
| ); | ||
| } | ||
| ); | ||
|
|
||
| ToolbarLink.displayName = COMPONENT_NAME; | ||
|
|
||
| export default ToolbarLink; | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,58 @@ | ||||||||||||||||||||||||||||||||||||||
| 'use client'; | ||||||||||||||||||||||||||||||||||||||
| import React from 'react'; | ||||||||||||||||||||||||||||||||||||||
| import { clsx } from 'clsx'; | ||||||||||||||||||||||||||||||||||||||
| import { customClassSwitcher } from '~/core'; | ||||||||||||||||||||||||||||||||||||||
| import RovingFocusGroup from '~/core/utils/RovingFocusGroup'; | ||||||||||||||||||||||||||||||||||||||
| import ToolbarRootContext from '../context/ToolbarRootContext'; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| const COMPONENT_NAME = 'Toolbar'; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| export type ToolbarRootProps = React.ComponentPropsWithoutRef<'div'> & { | ||||||||||||||||||||||||||||||||||||||
| orientation?: 'horizontal' | 'vertical'; | ||||||||||||||||||||||||||||||||||||||
| loop?: boolean; | ||||||||||||||||||||||||||||||||||||||
| dir?: 'ltr' | 'rtl'; | ||||||||||||||||||||||||||||||||||||||
| customRootClass?: string; | ||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| const ToolbarRoot = React.forwardRef<HTMLDivElement, ToolbarRootProps>( | ||||||||||||||||||||||||||||||||||||||
| ( | ||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||
| orientation = 'horizontal', | ||||||||||||||||||||||||||||||||||||||
| loop = false, | ||||||||||||||||||||||||||||||||||||||
| dir = 'ltr', | ||||||||||||||||||||||||||||||||||||||
| className = '', | ||||||||||||||||||||||||||||||||||||||
| customRootClass = '', | ||||||||||||||||||||||||||||||||||||||
| children, | ||||||||||||||||||||||||||||||||||||||
| ...props | ||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||
| ref | ||||||||||||||||||||||||||||||||||||||
| ) => { | ||||||||||||||||||||||||||||||||||||||
| const rootClass = customClassSwitcher(customRootClass, COMPONENT_NAME); | ||||||||||||||||||||||||||||||||||||||
| const context = React.useMemo(() => ({ rootClass, orientation }), [rootClass, orientation]); | ||||||||||||||||||||||||||||||||||||||
| const dataAttributes: Record<string, string> = { | ||||||||||||||||||||||||||||||||||||||
| 'data-orientation': orientation as string | ||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||||||||||||
| <ToolbarRootContext.Provider value={context}> | ||||||||||||||||||||||||||||||||||||||
| <RovingFocusGroup.Root orientation={orientation} loop={loop} dir={dir}> | ||||||||||||||||||||||||||||||||||||||
| <RovingFocusGroup.Group> | ||||||||||||||||||||||||||||||||||||||
| <div | ||||||||||||||||||||||||||||||||||||||
| ref={ref} | ||||||||||||||||||||||||||||||||||||||
| role="toolbar" | ||||||||||||||||||||||||||||||||||||||
| className={clsx(rootClass, className)} | ||||||||||||||||||||||||||||||||||||||
| {...dataAttributes} | ||||||||||||||||||||||||||||||||||||||
| {...props} | ||||||||||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||||||||||
| {children} | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+40
to
+47
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion Add required ARIA attributes to the toolbar container. For vertical toolbars, <div
ref={ref}
role="toolbar"
+ dir={dir}
+ aria-orientation={orientation}
className={clsx(rootClass, className)}
{...dataAttributes}
{...props}
>📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||
| </RovingFocusGroup.Group> | ||||||||||||||||||||||||||||||||||||||
| </RovingFocusGroup.Root> | ||||||||||||||||||||||||||||||||||||||
| </ToolbarRootContext.Provider> | ||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| ToolbarRoot.displayName = COMPONENT_NAME; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| export default ToolbarRoot; | ||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,30 @@ | ||||||||||||||||||||||||||||||
| 'use client'; | ||||||||||||||||||||||||||||||
| import React from 'react'; | ||||||||||||||||||||||||||||||
| import { clsx } from 'clsx'; | ||||||||||||||||||||||||||||||
| import Separator, { SeparatorProps } from '~/components/ui/Separator/Separator'; | ||||||||||||||||||||||||||||||
| import ToolbarRootContext from '../context/ToolbarRootContext'; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| const COMPONENT_NAME = 'ToolbarSeparator'; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| const ToolbarSeparator = React.forwardRef<React.ElementRef<typeof Separator>, SeparatorProps>( | ||||||||||||||||||||||||||||||
| ({ className = '', orientation, ...props }, ref) => { | ||||||||||||||||||||||||||||||
| const context = React.useContext(ToolbarRootContext); | ||||||||||||||||||||||||||||||
| if (!context) throw new Error('Toolbar.Separator must be used within Toolbar.Root'); | ||||||||||||||||||||||||||||||
| const { rootClass, orientation: rootOrientation } = context; | ||||||||||||||||||||||||||||||
| const separatorOrientation = orientation ?? (rootOrientation === 'horizontal' ? 'vertical' : 'horizontal'); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||||
| <Separator | ||||||||||||||||||||||||||||||
| ref={ref} | ||||||||||||||||||||||||||||||
| decorative | ||||||||||||||||||||||||||||||
| orientation={separatorOrientation} | ||||||||||||||||||||||||||||||
| className={clsx(`${rootClass}-separator`, className)} | ||||||||||||||||||||||||||||||
| {...props} | ||||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||||
|
Comment on lines
+17
to
+23
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion Prevent consumers from overriding decorative (enforce presentational separator). Spreading <Separator
- ref={ref}
- decorative
- orientation={separatorOrientation}
- className={clsx(`${rootClass}-separator`, className)}
- {...props}
+ ref={ref}
+ {...props}
+ decorative
+ orientation={separatorOrientation}
+ className={clsx(`${rootClass}-separator`, className)}
/>📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| ToolbarSeparator.displayName = COMPONENT_NAME; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| export default ToolbarSeparator; | ||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| import React from 'react'; | ||
| import Toolbar from '../Toolbar'; | ||
| import SandboxEditor from '~/components/tools/SandboxEditor/SandboxEditor'; | ||
|
|
||
| export default { | ||
| title: 'Components/Toolbar', | ||
| component: Toolbar, | ||
| render: (args: React.JSX.IntrinsicAttributes) => <Template {...args} /> | ||
| }; | ||
|
|
||
| const Template = (_args: any) => { | ||
| return ( | ||
| <SandboxEditor className="space-y-4 pt-4"> | ||
| <Toolbar.Root aria-label="Formatting options"> | ||
| <Toolbar.Button>Bold</Toolbar.Button> | ||
| <Toolbar.Button>Italic</Toolbar.Button> | ||
| <Toolbar.Separator /> | ||
| <Toolbar.Link href="#">Link</Toolbar.Link> | ||
| </Toolbar.Root> | ||
| </SandboxEditor> | ||
| ); | ||
| }; | ||
|
|
||
| export const Default = { | ||
| args: {} | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| import React from 'react'; | ||
| import { render, screen } from '@testing-library/react'; | ||
| import '@testing-library/jest-dom'; | ||
| import Toolbar from '../Toolbar'; | ||
| import { axe, keyboard } from 'test-utils'; | ||
|
|
||
| describe('Toolbar keyboard navigation and a11y', () => { | ||
| test('arrow keys move focus between items', async () => { | ||
| render( | ||
| <Toolbar.Root aria-label="Editor toolbar"> | ||
| <Toolbar.Button>Bold</Toolbar.Button> | ||
| <Toolbar.Button>Italic</Toolbar.Button> | ||
| <Toolbar.Link href="#">Link</Toolbar.Link> | ||
| </Toolbar.Root> | ||
| ); | ||
|
|
||
| const user = keyboard(); | ||
| await user.tab(); | ||
| const buttons = screen.getAllByRole('button'); | ||
| const link = screen.getByRole('link'); | ||
| expect(buttons[0]).toHaveFocus(); | ||
| await user.keyboard('{ArrowRight}'); | ||
| expect(buttons[1]).toHaveFocus(); | ||
| await user.keyboard('{ArrowRight}'); | ||
| expect(link).toHaveFocus(); | ||
| await user.keyboard('{ArrowLeft}'); | ||
| expect(buttons[1]).toHaveFocus(); | ||
| }); | ||
|
|
||
| test('axe: no accessibility violations', async () => { | ||
| const { container } = render( | ||
| <Toolbar.Root aria-label="Editor toolbar"> | ||
| <Toolbar.Button>Bold</Toolbar.Button> | ||
| <Toolbar.Button>Italic</Toolbar.Button> | ||
| <Toolbar.Link href="#">Link</Toolbar.Link> | ||
| </Toolbar.Root> | ||
| ); | ||
| const results = await axe(container); | ||
| expect(results.violations).toHaveLength(0); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,44 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .rad-ui-toolbar { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| display: inline-flex; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| align-items: center; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| gap: 4px; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| &[data-orientation='vertical'] { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| flex-direction: column; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .rad-ui-toolbar-button, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .rad-ui-toolbar-link { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| all: unset; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| display: inline-flex; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| align-items: center; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| justify-content: center; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| padding: 4px 8px; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| border-radius: 4px; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| background-color: var(--rad-ui-color-gray-50); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| color: var(--rad-ui-color-slate-900); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cursor: pointer; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| &:focus-visible { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| outline: 2px solid var(--rad-ui-color-accent-900); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| outline-offset: 2px; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .rad-ui-toolbar-link { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| text-decoration: none; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+28
to
+30
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fix stylelint no-descending-specificity by inlining link rule. Move link-specific text-decoration into the shared item rule so it appears before the :focus-visible selector. Apply this diff: .rad-ui-toolbar-button,
.rad-ui-toolbar-link {
all: unset;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px 8px;
border-radius: 4px;
background-color: var(--rad-ui-color-gray-50);
color: var(--rad-ui-color-slate-900);
cursor: pointer;
+ /* Link-specific reset must come after `all: unset` to stick */
+ a& { text-decoration: none; }
&:focus-visible {
outline: 2px solid var(--rad-ui-color-accent-900);
outline-offset: 2px;
}
}
-
- .rad-ui-toolbar-link {
- text-decoration: none;
- }📝 Committable suggestion
Suggested change
🧰 Tools🪛 GitHub Actions: Lint[error] 28-28: stylelint: no-descending-specificity: Expected selector '.rad-ui-toolbar-link' to come before selector '.rad-ui-toolbar .rad-ui-toolbar-link:focus-visible'. 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .rad-ui-toolbar-separator { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| background-color: var(--rad-ui-color-gray-600); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| width: 1px; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| height: 24px; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| margin: 0 4px; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| &[data-orientation='vertical'] .rad-ui-toolbar-separator { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| width: 100%; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| height: 1px; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| margin: 4px 0; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Remove duplicate interactive role to avoid nested roles.
RovingFocusGroup.Item wrapping a native already inside should not also expose role="link"; it risks duplicate semantics and confused focus/AT output.
Apply this diff:
📝 Committable suggestion
🤖 Prompt for AI Agents