Skip to content
Open
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
1 change: 1 addition & 0 deletions scripts/RELEASED_COMPONENTS.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const RELEASED_COMPONENTS = [
'Tabs',
'Toggle',
'ToggleGroup',
'Toolbar',
'Tooltip',
'VisuallyHidden',
// Released but not documented officially
Expand Down
29 changes: 29 additions & 0 deletions src/components/ui/Toolbar/Toolbar.tsx
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;
10 changes: 10 additions & 0 deletions src/components/ui/Toolbar/context/ToolbarRootContext.tsx
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;
26 changes: 26 additions & 0 deletions src/components/ui/Toolbar/fragments/ToolbarButton.tsx
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;
26 changes: 26 additions & 0 deletions src/components/ui/Toolbar/fragments/ToolbarLink.tsx
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>
Comment on lines +16 to +19
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

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:

-      <RovingFocusGroup.Item role="link">
+      <RovingFocusGroup.Item>
         <Link ref={ref} className={clsx(`${rootClass}-link`, className)} {...props} />
       </RovingFocusGroup.Item>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return (
<RovingFocusGroup.Item role="link">
<Link ref={ref} className={clsx(`${rootClass}-link`, className)} {...props} />
</RovingFocusGroup.Item>
return (
<RovingFocusGroup.Item>
<Link ref={ref} className={clsx(`${rootClass}-link`, className)} {...props} />
</RovingFocusGroup.Item>
🤖 Prompt for AI Agents
In src/components/ui/Toolbar/fragments/ToolbarLink.tsx around lines 16 to 19,
the RovingFocusGroup.Item is given a duplicate interactive role ("link") while
the inner Link already renders a native anchor; remove the role prop from
RovingFocusGroup.Item to avoid nested/duplicated semantics and let the inner
Link/a provide the accessible role — simply delete the role="link" attribute (or
set it to undefined) on RovingFocusGroup.Item so focus handling remains but no
duplicate role is exposed.

);
}
);

ToolbarLink.displayName = COMPONENT_NAME;

export default ToolbarLink;
58 changes: 58 additions & 0 deletions src/components/ui/Toolbar/fragments/ToolbarRoot.tsx
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
Copy link
Contributor

Choose a reason for hiding this comment

The 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, aria-orientation should be set; also mirror dir on the DOM element for correct bidirectional behavior.

             <div
               ref={ref}
               role="toolbar"
+              dir={dir}
+              aria-orientation={orientation}
               className={clsx(rootClass, className)}
               {...dataAttributes}
               {...props}
             >
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div
ref={ref}
role="toolbar"
className={clsx(rootClass, className)}
{...dataAttributes}
{...props}
>
{children}
<div
ref={ref}
role="toolbar"
dir={dir}
aria-orientation={orientation}
className={clsx(rootClass, className)}
{...dataAttributes}
{...props}
>
{children}
🤖 Prompt for AI Agents
In src/components/ui/Toolbar/fragments/ToolbarRoot.tsx around lines 40 to 47,
the toolbar container is missing required ARIA and direction attributes: add
aria-orientation when the toolbar is vertical (e.g.,
aria-orientation={orientation === 'vertical' ? 'vertical' : undefined}) and set
the DOM dir attribute to mirror the current direction (e.g., dir={dir ||
undefined} or derive from context/props) so the element reflects bidi behavior;
ensure these attributes are passed on the root div alongside existing props and
do not override consumers' explicit props.

</div>
</RovingFocusGroup.Group>
</RovingFocusGroup.Root>
</ToolbarRootContext.Provider>
);
}
);

ToolbarRoot.displayName = COMPONENT_NAME;

export default ToolbarRoot;
30 changes: 30 additions & 0 deletions src/components/ui/Toolbar/fragments/ToolbarSeparator.tsx
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
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Prevent consumers from overriding decorative (enforce presentational separator).

Spreading ...props last allows decorative={false} to override the intended presentational behavior. Move the spread before fixed props to make decorative non-overridable.

       <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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<Separator
ref={ref}
decorative
orientation={separatorOrientation}
className={clsx(`${rootClass}-separator`, className)}
{...props}
/>
<Separator
ref={ref}
{...props}
decorative
orientation={separatorOrientation}
className={clsx(`${rootClass}-separator`, className)}
/>
🤖 Prompt for AI Agents
In src/components/ui/Toolbar/fragments/ToolbarSeparator.tsx around lines 17 to
23, the component spreads {...props} after fixed props which allows callers to
override decorative; move the {...props} spread before the fixed props (ref,
decorative, orientation, className) so decorative remains enforced as
presentational and cannot be overridden by consumer-supplied props.

);
}
);

ToolbarSeparator.displayName = COMPONENT_NAME;

export default ToolbarSeparator;
26 changes: 26 additions & 0 deletions src/components/ui/Toolbar/stories/Toolbar.stories.tsx
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: {}
};
41 changes: 41 additions & 0 deletions src/components/ui/Toolbar/tests/Toolbar.behavior.test.tsx
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);
});
});
44 changes: 44 additions & 0 deletions styles/themes/components/toolbar.scss
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
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.rad-ui-toolbar-link {
text-decoration: none;
}
.styles/themes/components/toolbar.scss
.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;
}
}
// Removed redundant standalone link rule:
// .rad-ui-toolbar-link {
// text-decoration: none;
// }
🧰 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
In styles/themes/components/toolbar.scss around lines 28 to 30, the
.rad-ui-toolbar-link rule sets text-decoration after more specific
:focus-visible selectors causing stylelint no-descending-specificity; move the
link-specific text-decoration declaration into the shared .rad-ui-toolbar-item
(or whatever shared item rule immediately precedes :focus-visible) so the
text-decoration appears before the :focus-visible selector (i.e., remove the
separate .rad-ui-toolbar-link block and add text-decoration: none; to the shared
item rule above).


.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;
}
}
1 change: 1 addition & 0 deletions styles/themes/default.scss
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
@use "components/context-menu";
@use "components/menubar";
@use "components/splitter";
@use "components/toolbar";

// import .css file
@use "../cssTokens/base.tokens";
Expand Down
Loading