diff --git a/docs/next.config.mjs b/docs/next.config.mjs
index 26c3b8e4d..ec3ddf1fc 100644
--- a/docs/next.config.mjs
+++ b/docs/next.config.mjs
@@ -111,14 +111,6 @@ const nextConfig = {
webpack: (config) => {
config.resolve.alias = {
...config.resolve.alias,
- '@radui/ui/Breadcrumb': path.resolve(__dirname, '../src/components/ui/Breadcrumb/Breadcrumb.tsx'),
- '@radui/ui/Fieldset': path.resolve(__dirname, '../src/components/ui/Fieldset/Fieldset.tsx'),
- '@radui/ui/LiveRegion': path.resolve(__dirname, '../src/components/ui/LiveRegion/LiveRegion.tsx'),
- '@radui/ui/Popover': path.resolve(__dirname, '../src/components/ui/Popover/Popover.tsx'),
- '@radui/ui/TextField': path.resolve(__dirname, '../src/components/ui/TextField/TextField.tsx'),
- '@radui/ui/Toast': path.resolve(__dirname, '../src/components/ui/Toast/Toast.tsx'),
- '@radui/ui/themes/default.css': path.resolve(__dirname, '../src/design-systems/clarity/default.scss'),
- '@radui/ui/themes/baremetal.css': path.resolve(__dirname, '../src/design-systems/baremetal/default.scss'),
'~': path.resolve(__dirname, '../src'),
}
config.resolve.modules = [
diff --git a/src/components/ui/CheckboxCards/tests/CheckboxCards.test.tsx b/src/components/ui/CheckboxCards/tests/CheckboxCards.test.tsx
index 449f2fa25..92fb03bc3 100644
--- a/src/components/ui/CheckboxCards/tests/CheckboxCards.test.tsx
+++ b/src/components/ui/CheckboxCards/tests/CheckboxCards.test.tsx
@@ -3,6 +3,22 @@ import { render, screen, fireEvent } from '@testing-library/react';
import CheckboxCards from '../CheckboxCards';
describe('CheckboxCards', () => {
+ it('renders multiple items without invalid asChild warnings', () => {
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
+
+ render(
+
+ One
+ Two
+
+ );
+
+ expect(warnSpy).not.toHaveBeenCalledWith(
+ expect.stringContaining('asChild prop requires exactly one valid child element')
+ );
+ warnSpy.mockRestore();
+ });
+
it('renders items and content, and toggles checked state (uncontrolled)', () => {
render(
diff --git a/src/components/ui/CheckboxGroup/tests/CheckboxGroup.test.tsx b/src/components/ui/CheckboxGroup/tests/CheckboxGroup.test.tsx
index 38d5391f1..5a9b42473 100644
--- a/src/components/ui/CheckboxGroup/tests/CheckboxGroup.test.tsx
+++ b/src/components/ui/CheckboxGroup/tests/CheckboxGroup.test.tsx
@@ -3,6 +3,28 @@ import { render, screen, fireEvent } from '@testing-library/react';
import CheckboxGroup from '../CheckboxGroup';
describe('CheckboxGroup', () => {
+ it('renders multiple items without invalid asChild warnings', () => {
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
+
+ render(
+
+
+
+ One
+
+
+
+ Two
+
+
+ );
+
+ expect(warnSpy).not.toHaveBeenCalledWith(
+ expect.stringContaining('asChild prop requires exactly one valid child element')
+ );
+ warnSpy.mockRestore();
+ });
+
it('exposes stable anatomy data slots', () => {
render(
diff --git a/src/components/ui/Command/fragments/CommandInput.tsx b/src/components/ui/Command/fragments/CommandInput.tsx
index 980975a86..2685cd9fb 100644
--- a/src/components/ui/Command/fragments/CommandInput.tsx
+++ b/src/components/ui/Command/fragments/CommandInput.tsx
@@ -27,6 +27,7 @@ const CommandInput = React.forwardRef(({
inputId,
search,
setSearch,
+ activeItemId,
moveActive,
moveToBoundary,
selectActiveItem
@@ -44,6 +45,7 @@ const CommandInput = React.forwardRef(({
role="combobox"
aria-autocomplete="list"
aria-controls={listId}
+ aria-activedescendant={activeItemId || undefined}
aria-expanded="true"
aria-label={label}
value={currentValue}
diff --git a/src/components/ui/Command/fragments/CommandItem.tsx b/src/components/ui/Command/fragments/CommandItem.tsx
index 6c7536f3f..0a79f4acd 100644
--- a/src/components/ui/Command/fragments/CommandItem.tsx
+++ b/src/components/ui/Command/fragments/CommandItem.tsx
@@ -75,6 +75,7 @@ const CommandItem = React.forwardRef(({
return (
(({
role="option"
aria-disabled={disabled || undefined}
aria-selected={selected}
- tabIndex={disabled ? -1 : 0}
+ tabIndex={-1}
hidden={!visible}
onMouseMove={(event: React.MouseEvent) => {
if (!disabled) {
diff --git a/src/components/ui/Command/fragments/CommandRoot.tsx b/src/components/ui/Command/fragments/CommandRoot.tsx
index aff277c6a..f88009ec8 100644
--- a/src/components/ui/Command/fragments/CommandRoot.tsx
+++ b/src/components/ui/Command/fragments/CommandRoot.tsx
@@ -52,6 +52,7 @@ const CommandRoot = React.forwardRef(({
const [separators, setSeparators] = React.useState([]);
const [activeItemId, setActiveItemId] = React.useState(null);
const orderRef = React.useRef(0);
+ const hasInitializedActiveItemRef = React.useRef(false);
const listId = React.useId();
const inputId = React.useId();
@@ -152,6 +153,11 @@ const CommandRoot = React.forwardRef(({
return;
}
+ if (!hasInitializedActiveItemRef.current) {
+ hasInitializedActiveItemRef.current = true;
+ return;
+ }
+
const activeItem = items.find((item) => item.id === activeItemId);
const element = activeItem?.ref.current;
diff --git a/src/components/ui/Command/tests/Command.keyboard.test.tsx b/src/components/ui/Command/tests/Command.keyboard.test.tsx
index a639df498..0167142c3 100644
--- a/src/components/ui/Command/tests/Command.keyboard.test.tsx
+++ b/src/components/ui/Command/tests/Command.keyboard.test.tsx
@@ -43,4 +43,29 @@ describe('Command keyboard paths', () => {
fireEvent.keyDown(input, { key: 'Home' });
expect(screen.getByText('Apple')).toHaveAttribute('data-selected', '');
});
+
+ test('keeps focus on the combobox and exposes the active option', () => {
+ render(
+
+
+
+ Apple
+ Banana
+
+
+ );
+
+ const input = screen.getByRole('combobox');
+ const apple = screen.getByText('Apple');
+ const banana = screen.getByText('Banana');
+
+ input.focus();
+ expect(input).toHaveAttribute('aria-activedescendant', apple.id);
+ expect(apple).toHaveAttribute('tabindex', '-1');
+ expect(banana).toHaveAttribute('tabindex', '-1');
+
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
+ expect(input).toHaveFocus();
+ expect(input).toHaveAttribute('aria-activedescendant', banana.id);
+ });
});
diff --git a/src/components/ui/Command/tests/Command.test.tsx b/src/components/ui/Command/tests/Command.test.tsx
index eb3abb755..c5a171cbe 100644
--- a/src/components/ui/Command/tests/Command.test.tsx
+++ b/src/components/ui/Command/tests/Command.test.tsx
@@ -4,6 +4,26 @@ import '@testing-library/jest-dom';
import Command from '../Command';
describe('Command', () => {
+ it('does not scroll the page to the initial active item', () => {
+ const scrollIntoView = jest.fn();
+ Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
+ configurable: true,
+ value: scrollIntoView
+ });
+
+ render(
+
+
+
+ Home
+ Settings
+
+
+ );
+
+ expect(scrollIntoView).not.toHaveBeenCalled();
+ });
+
it('filters items, hides empty groups, and renders empty state', () => {
render(
diff --git a/src/components/ui/DataList/fragments/DataListRoot.tsx b/src/components/ui/DataList/fragments/DataListRoot.tsx
index 978e9f477..8bd3ff883 100644
--- a/src/components/ui/DataList/fragments/DataListRoot.tsx
+++ b/src/components/ui/DataList/fragments/DataListRoot.tsx
@@ -6,8 +6,8 @@ import clsx from 'clsx';
const COMPONENT_NAME = 'DataList';
-type DataListRootElement = ElementRef<'div'>;
-export interface DataListRootProps extends ComponentPropsWithoutRef<'div'> {
+type DataListRootElement = ElementRef<'dl'>;
+export interface DataListRootProps extends ComponentPropsWithoutRef<'dl'> {
customRootClass?: string;
size?: string;
}
@@ -19,7 +19,7 @@ const DataListRoot = forwardRef(({ child
value={{
rootClass
}}>
- {children}
+ {children}
;
});
diff --git a/src/components/ui/DataList/tests/DataList.test.tsx b/src/components/ui/DataList/tests/DataList.test.tsx
index 8c279a1e8..484aedc5f 100644
--- a/src/components/ui/DataList/tests/DataList.test.tsx
+++ b/src/components/ui/DataList/tests/DataList.test.tsx
@@ -90,7 +90,7 @@ describe('DataList Component', () => {
});
test('should forward refs to underlying elements', () => {
- const rootRef = React.createRef();
+ const rootRef = React.createRef();
const itemRef = React.createRef();
const labelRef = React.createRef();
const valueRef = React.createRef();
@@ -104,7 +104,7 @@ describe('DataList Component', () => {
);
- expect(rootRef.current).toBeInstanceOf(HTMLDivElement);
+ expect(rootRef.current).toBeInstanceOf(HTMLDListElement);
expect(itemRef.current).toBeInstanceOf(HTMLDivElement);
expect(labelRef.current?.tagName).toBe('DT');
expect(valueRef.current?.tagName).toBe('DD');
diff --git a/src/components/ui/Dialog/tests/Dialog.lazyMount.test.tsx b/src/components/ui/Dialog/tests/Dialog.lazyMount.test.tsx
index ce5ca5b5d..be08333f0 100644
--- a/src/components/ui/Dialog/tests/Dialog.lazyMount.test.tsx
+++ b/src/components/ui/Dialog/tests/Dialog.lazyMount.test.tsx
@@ -49,4 +49,21 @@ describe('Dialog lazy mount behavior', () => {
expect(screen.getByText('Mounted dialog')).toBeInTheDocument();
expect(screen.getByText('Mounted dialog')).toHaveAttribute('data-state', 'closed');
});
+
+ test('forceMount does not focus the trigger on initial closed mount', () => {
+ render(
+
+ Before dialog
+
+ Open
+
+ Mounted dialog
+
+
+
+ );
+
+ expect(screen.getByText('Open')).not.toHaveFocus();
+ expect(document.body).toHaveFocus();
+ });
});
diff --git a/src/components/ui/Disclosure/contexts/DisclosureItemContext.tsx b/src/components/ui/Disclosure/contexts/DisclosureItemContext.tsx
index bee677a8c..74088969d 100644
--- a/src/components/ui/Disclosure/contexts/DisclosureItemContext.tsx
+++ b/src/components/ui/Disclosure/contexts/DisclosureItemContext.tsx
@@ -3,6 +3,7 @@ import { createContext } from 'react';
export type DisclosureItemContextType = {
itemValue: number;
setItemValue: (value: number) => void;
+ triggerId: string;
}
export const DisclosureItemContext = createContext({} as DisclosureItemContextType);
diff --git a/src/components/ui/Disclosure/disclosure.clarity.scss b/src/components/ui/Disclosure/disclosure.clarity.scss
index cd16a5bce..0734aa767 100644
--- a/src/components/ui/Disclosure/disclosure.clarity.scss
+++ b/src/components/ui/Disclosure/disclosure.clarity.scss
@@ -7,12 +7,6 @@
margin: 0;
border-bottom: 1px solid var(--rad-ui-border-soft);
- &:focus{
- outline: none;
- border: none;
- color: var(--rad-ui-text-primary);
- }
-
&:focus-within{
color: var(--rad-ui-text-primary);
}
@@ -26,10 +20,10 @@
color: var(--rad-ui-text-primary);
background-color: transparent;
- &:focus{
+ &:focus-visible{
outline: none;
- box-shadow: var(--rad-ui-focus-ring-shadow-inset);
-
+ border-radius: var(--rad-ui-radius-sm);
+ box-shadow: var(--rad-ui-focus-ring-shadow-offset);
}
&:hover {
diff --git a/src/components/ui/Disclosure/fragments/DisclosureContent.tsx b/src/components/ui/Disclosure/fragments/DisclosureContent.tsx
index ae6cc2110..a68199674 100644
--- a/src/components/ui/Disclosure/fragments/DisclosureContent.tsx
+++ b/src/components/ui/Disclosure/fragments/DisclosureContent.tsx
@@ -8,7 +8,7 @@ export type DisclosureContentProps = React.ComponentPropsWithoutRef<'div'>;
const DisclosureContent = React.forwardRef, DisclosureContentProps>(({ children, className = '', ...props }, forwardedRef) => {
const { activeItem, rootClass } = useContext(DisclosureContext);
- const { itemValue } = useContext(DisclosureItemContext);
+ const { itemValue, triggerId } = useContext(DisclosureItemContext);
return (
itemValue !== activeItem
? null
@@ -20,6 +20,7 @@ const DisclosureContent = React.forwardRef, DisclosureCo
data-state="open"
data-slot="disclosure-content"
role="region"
+ aria-labelledby={triggerId}
aria-hidden={activeItem !== itemValue}
>
{children}
diff --git a/src/components/ui/Disclosure/fragments/DisclosureItem.tsx b/src/components/ui/Disclosure/fragments/DisclosureItem.tsx
index f55219dba..c16211553 100644
--- a/src/components/ui/Disclosure/fragments/DisclosureItem.tsx
+++ b/src/components/ui/Disclosure/fragments/DisclosureItem.tsx
@@ -19,12 +19,14 @@ const DisclosureItem = React.forwardRef, DisclosureItemP
}, [activeItem, itemValue]);
const id = useId();
+ const triggerId = `disclosure-trigger-${id}`;
return (
, DisclosureItemP
data-state={isOpen ? 'open' : 'closed'}
data-slot="disclosure-item"
id={`disclosure-data-item-${id}`}
- role="region"
- aria-labelledby={`disclosure-trigger-${id}`}
>
{children}
diff --git a/src/components/ui/Disclosure/fragments/DisclosureRoot.tsx b/src/components/ui/Disclosure/fragments/DisclosureRoot.tsx
index 3e2de341f..4ab5037c3 100644
--- a/src/components/ui/Disclosure/fragments/DisclosureRoot.tsx
+++ b/src/components/ui/Disclosure/fragments/DisclosureRoot.tsx
@@ -39,13 +39,14 @@ const DisclosureRoot = React.forwardRef, DisclosureRootP
}}>
-
+
diff --git a/src/components/ui/Disclosure/fragments/DisclosureTrigger.tsx b/src/components/ui/Disclosure/fragments/DisclosureTrigger.tsx
index e1f1f10c9..969cccce6 100644
--- a/src/components/ui/Disclosure/fragments/DisclosureTrigger.tsx
+++ b/src/components/ui/Disclosure/fragments/DisclosureTrigger.tsx
@@ -9,7 +9,7 @@ export type DisclosureTriggerProps = React.ComponentPropsWithoutRef<'button'>;
const DisclosureTrigger = React.forwardRef
, DisclosureTriggerProps>(({ children, className, onClick, ...props }, forwardedRef) => {
const { activeItem, setActiveItem, rootClass } = useContext(DisclosureContext);
- const { itemValue } = useContext(DisclosureItemContext);
+ const { itemValue, triggerId } = useContext(DisclosureItemContext);
const onClickHandler = (e: React.MouseEvent) => {
if (activeItem === itemValue) {
@@ -21,18 +21,18 @@ const DisclosureTrigger = React.forwardRef, Disclosur
};
return (
-
+
{children}
diff --git a/src/components/ui/Disclosure/tests/Disclosure.test.tsx b/src/components/ui/Disclosure/tests/Disclosure.test.tsx
index ba433bc50..364748df2 100644
--- a/src/components/ui/Disclosure/tests/Disclosure.test.tsx
+++ b/src/components/ui/Disclosure/tests/Disclosure.test.tsx
@@ -1,6 +1,8 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
+import fs from 'node:fs';
+import path from 'node:path';
import Disclosure from '../Disclosure';
const items = [
@@ -137,6 +139,10 @@ describe('Disclosure', () => {
fireEvent.click(trigger);
const content = screen.getByText('Content 1');
expect(content).toHaveAttribute('aria-hidden', 'false');
+ expect(content).toHaveAttribute('aria-labelledby', trigger.id);
+ expect(root).toHaveAttribute('role', 'group');
+ expect(root).toHaveAccessibleName('accordion');
+ expect(trigger).not.toHaveAttribute('aria-haspopup');
});
test('renders without warnings', () => {
@@ -145,4 +151,13 @@ describe('Disclosure', () => {
expect(spy).not.toHaveBeenCalled();
spy.mockRestore();
});
+
+ test('only shows the centralized focus ring for keyboard focus', () => {
+ const stylesheet = fs.readFileSync(path.resolve(__dirname, '../disclosure.clarity.scss'), 'utf8');
+
+ expect(stylesheet).toContain('&:focus-visible{');
+ expect(stylesheet).toContain('box-shadow: var(--rad-ui-focus-ring-shadow-offset);');
+ expect(stylesheet).not.toContain('&:focus{');
+ expect(stylesheet).not.toContain('box-shadow: var(--rad-ui-focus-ring-shadow-inset);');
+ });
});
diff --git a/src/components/ui/Menubar/contexts/MenubarContext.tsx b/src/components/ui/Menubar/contexts/MenubarContext.tsx
index 0748ed6a9..7d2159ef7 100644
--- a/src/components/ui/Menubar/contexts/MenubarContext.tsx
+++ b/src/components/ui/Menubar/contexts/MenubarContext.tsx
@@ -13,6 +13,7 @@ export interface MenubarContextProps {
items: MenubarItem[];
updateItemState: (id: string, state: 'open' | 'closed') => void;
updateItemTrigger: (id: string, trigger: HTMLButtonElement | null) => void;
+ focusItemTrigger: (id: string) => void;
navigateMenu: (delta: 1 | -1) => void;
contentInitialFocus?: number;
}
diff --git a/src/components/ui/Menubar/fragments/MenubarContent.tsx b/src/components/ui/Menubar/fragments/MenubarContent.tsx
index 936695d50..774d9ffc9 100644
--- a/src/components/ui/Menubar/fragments/MenubarContent.tsx
+++ b/src/components/ui/Menubar/fragments/MenubarContent.tsx
@@ -1,6 +1,7 @@
import React, { forwardRef, ElementRef, ComponentPropsWithoutRef } from 'react';
import MenuPrimitive from '~/core/primitives/Menu/MenuPrimitive';
import MenubarContext from '../contexts/MenubarContext';
+import MenubarMenuContext from '../contexts/MenubarMenuContext';
import clsx from 'clsx';
export type MenubarContentElement = ElementRef;
@@ -16,6 +17,7 @@ const MenubarContent = forwardRef(({
return null;
}
const { rootClass, navigateMenu, contentInitialFocus } = context;
+ const isOpen = React.useContext(MenubarMenuContext)?.isOpen ?? false;
const { onKeyDown, ...restProps } = props;
const setContentRef = React.useCallback((node: HTMLDivElement | null) => {
@@ -27,6 +29,8 @@ const MenubarContent = forwardRef(({
}, [ref]);
React.useEffect(() => {
+ if (!isOpen) return;
+
const handleKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented) return;
@@ -47,7 +51,7 @@ const MenubarContent = forwardRef(({
return () => {
document.removeEventListener('keydown', handleKeyDown, true);
};
- }, [navigateMenu]);
+ }, [isOpen, navigateMenu]);
const handleKeyDown = (event: React.KeyboardEvent) => {
onKeyDown?.(event);
diff --git a/src/components/ui/Menubar/fragments/MenubarMenu.tsx b/src/components/ui/Menubar/fragments/MenubarMenu.tsx
index 86fb1001b..8e3d28943 100644
--- a/src/components/ui/Menubar/fragments/MenubarMenu.tsx
+++ b/src/components/ui/Menubar/fragments/MenubarMenu.tsx
@@ -21,7 +21,7 @@ const MenubarMenu = forwardRef(({ children
console.warn('MenubarMenu should be used in the MenubarRoot');
return null;
}
- const { rootClass, registerItem, items, updateItemState } = context;
+ const { rootClass, registerItem, items, updateItemState, focusItemTrigger } = context;
React.useEffect(() => {
if (id) {
@@ -30,6 +30,16 @@ const MenubarMenu = forwardRef(({ children
}, [id]);
const isOpen = items.find((item: MenubarItem) => item.id === id)?.state === 'open';
+ const wasOpenRef = React.useRef(false);
+
+ React.useLayoutEffect(() => {
+ const anotherMenuIsOpen = items.some((item) => item.id !== id && item.state === 'open');
+ if (wasOpenRef.current && !isOpen && !anotherMenuIsOpen) {
+ focusItemTrigger(id);
+ }
+ wasOpenRef.current = isOpen;
+ }, [focusItemTrigger, id, isOpen, items]);
+
return (
(({ children
}
}, []);
+ const focusItemTrigger = React.useCallback((id: string) => {
+ triggersRef.current[id]?.focus();
+ }, []);
+
const handleOnNavigate = React.useCallback((newIndex: number) => {
const prevItem = items[activeIndex];
const nextItem = items[newIndex];
@@ -95,9 +99,10 @@ const MenubarRoot = forwardRef(({ children
items,
updateItemState,
updateItemTrigger,
+ focusItemTrigger,
navigateMenu,
contentInitialFocus
- }), [rootClass, registerItem, items, updateItemState, updateItemTrigger, navigateMenu, contentInitialFocus]);
+ }), [rootClass, registerItem, items, updateItemState, updateItemTrigger, focusItemTrigger, navigateMenu, contentInitialFocus]);
return (
diff --git a/src/components/ui/Menubar/tests/Menubar.keyboard.test.tsx b/src/components/ui/Menubar/tests/Menubar.keyboard.test.tsx
index 001b85259..7d31d77f6 100644
--- a/src/components/ui/Menubar/tests/Menubar.keyboard.test.tsx
+++ b/src/components/ui/Menubar/tests/Menubar.keyboard.test.tsx
@@ -19,6 +19,43 @@ const mockMatchMedia = () => {
describe('Menubar keyboard paths', () => {
beforeEach(() => mockMatchMedia());
+ test('does not react to arrow keys while menus are closed', async() => {
+ const user = userEvent.setup();
+
+ render(
+
+ Before menubar
+
+
+ File
+
+
+ New
+
+
+
+
+ Edit
+
+
+ Cut
+
+
+
+
+
+ );
+
+ const before = screen.getByText('Before menubar');
+ const edit = screen.getByText('Edit');
+
+ before.focus();
+ await user.keyboard('{ArrowRight}');
+
+ expect(before).toHaveFocus();
+ expect(edit).not.toHaveFocus();
+ });
+
test('arrow keys move between triggers and open menus', async() => {
const user = userEvent.setup();
@@ -58,4 +95,30 @@ describe('Menubar keyboard paths', () => {
await user.keyboard('{ArrowLeft}');
expect(file).toHaveFocus();
});
+
+ test('returns focus to the trigger after selecting an item', async() => {
+ const user = userEvent.setup();
+
+ render(
+
+
+
+ File
+
+
+ New
+
+
+
+
+
+ );
+
+ const trigger = screen.getByText('File');
+ await user.click(trigger);
+ await user.click(screen.getByText('New'));
+
+ await waitFor(() => expect(trigger).toHaveFocus());
+ expect(screen.queryByText('New')).not.toBeInTheDocument();
+ });
});
diff --git a/src/components/ui/NavigationMenu/fragments/NavigationMenuContent.tsx b/src/components/ui/NavigationMenu/fragments/NavigationMenuContent.tsx
index f62fbc878..162216a78 100644
--- a/src/components/ui/NavigationMenu/fragments/NavigationMenuContent.tsx
+++ b/src/components/ui/NavigationMenu/fragments/NavigationMenuContent.tsx
@@ -33,7 +33,9 @@ const NavigationMenuContent = React.forwardRef
- {children}
+
+ {children}
+
);
diff --git a/src/components/ui/NavigationMenu/fragments/NavigationMenuRoot.tsx b/src/components/ui/NavigationMenu/fragments/NavigationMenuRoot.tsx
index 533842496..7db4f98d4 100644
--- a/src/components/ui/NavigationMenu/fragments/NavigationMenuRoot.tsx
+++ b/src/components/ui/NavigationMenu/fragments/NavigationMenuRoot.tsx
@@ -41,8 +41,10 @@ const NavigationMenuRoot = React.forwardRef
-
- {children}
+
+
+ {children}
+
diff --git a/src/components/ui/NavigationMenu/tests/NavigationMenu.test.tsx b/src/components/ui/NavigationMenu/tests/NavigationMenu.test.tsx
index d58d84d69..fba6a0bbe 100644
--- a/src/components/ui/NavigationMenu/tests/NavigationMenu.test.tsx
+++ b/src/components/ui/NavigationMenu/tests/NavigationMenu.test.tsx
@@ -186,15 +186,23 @@ describe('NavigationMenu component', () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
render(
-
+
Open
+
+ One
+ Two
+
+
+
+ Three
);
expect(errorSpy).not.toHaveBeenCalled();
expect(warnSpy).not.toHaveBeenCalled();
+ expect(document.querySelector('a[href="#three"]')).toHaveAttribute('role', 'link');
errorSpy.mockRestore();
warnSpy.mockRestore();
diff --git a/src/components/ui/NumberField/fragments/NumberFieldDecrement.tsx b/src/components/ui/NumberField/fragments/NumberFieldDecrement.tsx
index 609dd691c..154a16890 100644
--- a/src/components/ui/NumberField/fragments/NumberFieldDecrement.tsx
+++ b/src/components/ui/NumberField/fragments/NumberFieldDecrement.tsx
@@ -5,7 +5,7 @@ import clsx from 'clsx';
export type NumberFieldDecrementElement = ElementRef<'button'>;
export type NumberFieldDecrementProps = ComponentPropsWithoutRef<'button'>;
-const NumberFieldDecrement = forwardRef(({ children, className, onMouseDown, ...props }, ref) => {
+const NumberFieldDecrement = forwardRef(({ children, className, onClick, onMouseDown, 'aria-label': ariaLabel = 'Decrease value', ...props }, ref) => {
const context = useContext(NumberFieldContext);
if (!context) {
console.error('NumberFieldDecrement must be used within a NumberField');
@@ -15,7 +15,13 @@ const NumberFieldDecrement = forwardRef handleStep({ direction: 'decrement', type: 'small' })}
+ aria-label={ariaLabel}
+ onClick={(event) => {
+ onClick?.(event);
+ if (!event.defaultPrevented) {
+ handleStep({ direction: 'decrement', type: 'small' });
+ }
+ }}
className={clsx(rootClass && `${rootClass}-decrement`, className)}
disabled={disabled || readOnly}
type="button"
diff --git a/src/components/ui/NumberField/fragments/NumberFieldIncrement.tsx b/src/components/ui/NumberField/fragments/NumberFieldIncrement.tsx
index 94acfe471..3fd6099bb 100644
--- a/src/components/ui/NumberField/fragments/NumberFieldIncrement.tsx
+++ b/src/components/ui/NumberField/fragments/NumberFieldIncrement.tsx
@@ -5,7 +5,7 @@ import clsx from 'clsx';
export type NumberFieldIncrementElement = ElementRef<'button'>;
export type NumberFieldIncrementProps = ComponentPropsWithoutRef<'button'>;
-const NumberFieldIncrement = forwardRef(({ children, className, onMouseDown, ...props }, ref) => {
+const NumberFieldIncrement = forwardRef(({ children, className, onClick, onMouseDown, 'aria-label': ariaLabel = 'Increase value', ...props }, ref) => {
const context = useContext(NumberFieldContext);
if (!context) {
console.error('NumberFieldIncrement must be used within a NumberField');
@@ -16,7 +16,13 @@ const NumberFieldIncrement = forwardRef handleStep({ direction: 'increment', type: 'small' })}
+ aria-label={ariaLabel}
+ onClick={(event) => {
+ onClick?.(event);
+ if (!event.defaultPrevented) {
+ handleStep({ direction: 'increment', type: 'small' });
+ }
+ }}
className={clsx(rootClass && `${rootClass}-increment`, className)}
disabled={disabled || readOnly}
type="button"
diff --git a/src/components/ui/NumberField/fragments/NumberFieldRoot.tsx b/src/components/ui/NumberField/fragments/NumberFieldRoot.tsx
index 055739461..73235a20c 100644
--- a/src/components/ui/NumberField/fragments/NumberFieldRoot.tsx
+++ b/src/components/ui/NumberField/fragments/NumberFieldRoot.tsx
@@ -22,7 +22,7 @@ export type NumberFieldRootProps = {
required?: boolean
} & ComponentPropsWithoutRef<'div'>;
-const NumberFieldRoot = forwardRef(({ children, customRootClass = '', name, defaultValue = '', value, onValueChange, largeStep, step, min, max, disabled, readOnly, required, id, className = '', ...props }, ref) => {
+const NumberFieldRoot = forwardRef(({ children, customRootClass = '', name, defaultValue = '', value, onValueChange, largeStep = 10, step = 1, min, max, disabled, readOnly, required, id, className = '', ...props }, ref) => {
const rootClass = useComponentClass(customRootClass, COMPONENT_NAME);
const [inputValue, setInputValue] = useControllableState(
value,
diff --git a/src/components/ui/NumberField/tests/NumberField.controlledSwitch.test.tsx b/src/components/ui/NumberField/tests/NumberField.controlledSwitch.test.tsx
index b7475114b..26f00c027 100644
--- a/src/components/ui/NumberField/tests/NumberField.controlledSwitch.test.tsx
+++ b/src/components/ui/NumberField/tests/NumberField.controlledSwitch.test.tsx
@@ -22,7 +22,7 @@ describe('NumberField controlled switch', () => {
expect(screen.getByRole('spinbutton')).toHaveValue(5);
- fireEvent.click(screen.getByRole('button', { name: '+' }));
+ fireEvent.click(screen.getByRole('button', { name: 'Increase value' }));
expect(onValueChange).toHaveBeenCalled();
});
diff --git a/src/components/ui/NumberField/tests/NumberField.test.tsx b/src/components/ui/NumberField/tests/NumberField.test.tsx
index 05bcee162..f6a8d3239 100644
--- a/src/components/ui/NumberField/tests/NumberField.test.tsx
+++ b/src/components/ui/NumberField/tests/NumberField.test.tsx
@@ -33,7 +33,8 @@ describe('NumberField', () => {
);
expect(screen.getByLabelText('Quantity')).toBeInTheDocument();
- expect(screen.getByRole('button', { name: '+' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Increase value' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Decrease value' })).toBeInTheDocument();
});
test('keeps stepper buttons out of the tab order while preserving pointer clicks', () => {
@@ -46,8 +47,8 @@ describe('NumberField', () => {
);
const input = screen.getByLabelText('value') as HTMLInputElement;
- const increment = screen.getByRole('button', { name: '+' });
- const decrement = screen.getByRole('button', { name: '-' });
+ const increment = screen.getByRole('button', { name: 'Increase value' });
+ const decrement = screen.getByRole('button', { name: 'Decrease value' });
expect(input).not.toHaveAttribute('tabindex');
expect(increment).toHaveAttribute('tabindex', '-1');
@@ -60,6 +61,28 @@ describe('NumberField', () => {
expect(input).toHaveValue(3);
});
+ test('composes consumer click handlers with stepping', () => {
+ const onIncrement = jest.fn();
+ const onDecrement = jest.fn();
+
+ render(
+
+ -
+
+ +
+
+ );
+
+ const input = screen.getByLabelText('value');
+ fireEvent.click(screen.getByRole('button', { name: 'Increase value' }));
+ expect(onIncrement).toHaveBeenCalledTimes(1);
+ expect(input).toHaveValue(4);
+
+ fireEvent.click(screen.getByRole('button', { name: 'Decrease value' }));
+ expect(onDecrement).toHaveBeenCalledTimes(1);
+ expect(input).toHaveValue(3);
+ });
+
test('renders without warnings', () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
const error = jest.spyOn(console, 'error').mockImplementation(() => {});
diff --git a/src/components/ui/Progress/fragments/ProgressRoot.tsx b/src/components/ui/Progress/fragments/ProgressRoot.tsx
index ff2569615..31b98b281 100644
--- a/src/components/ui/Progress/fragments/ProgressRoot.tsx
+++ b/src/components/ui/Progress/fragments/ProgressRoot.tsx
@@ -46,12 +46,16 @@ const ProgressRoot = forwardRef(
customRootClass,
getValueLabel,
className,
+ 'aria-label': ariaLabelProp,
+ 'aria-valuetext': ariaValueTextProp,
...props
},
ref
) => {
const rootClass = useComponentClass(customRootClass, COMPONENT_NAME);
- const ariaLabel = getValueLabel?.(value ?? 0, minValue, maxValue) ?? '';
+ const valueLabel = getValueLabel?.(value ?? 0, minValue, maxValue);
+ const ariaLabel = ariaLabelProp ?? valueLabel ?? 'Progress';
+ const ariaValueText = ariaValueTextProp ?? valueLabel;
const state = getProgressState(value, maxValue);
const isIndeterminate = value === null;
@@ -72,7 +76,7 @@ const ProgressRoot = forwardRef(
{
const progressBars = screen.getAllByRole('progressbar');
const root = progressBars[0];
- expect(root).toHaveAttribute('aria-label', '');
- expect(root).toHaveAttribute('aria-valuetext', '');
+ expect(root).toHaveAttribute('aria-label', 'Progress');
+ expect(root).not.toHaveAttribute('aria-valuetext');
+ });
+
+ test('preserves consumer accessible name and value text', () => {
+ render(
+
+
+
+ );
+
+ const root = screen.getAllByRole('progressbar')[0];
+ expect(root).toHaveAttribute('aria-label', 'Upload progress');
+ expect(root).toHaveAttribute('aria-valuetext', 'Half complete');
});
test('getValueLabel with custom min/max values', () => {
diff --git a/src/components/ui/Radio/tests/Radio.test.tsx b/src/components/ui/Radio/tests/Radio.test.tsx
index 7ee732bae..c9d905b38 100644
--- a/src/components/ui/Radio/tests/Radio.test.tsx
+++ b/src/components/ui/Radio/tests/Radio.test.tsx
@@ -16,6 +16,7 @@ describe('Radio', () => {
expect(radio).toHaveAttribute('name', 'test-radio');
expect(radio).toHaveAttribute('value', 'option1');
expect(radio).toHaveAttribute('id', 'radio1');
+ expect((radio as HTMLInputElement).tabIndex).toBe(0);
});
it('applies checked, required, and disabled props', () => {
diff --git a/src/components/ui/RadioGroup/tests/RadioGroup.behavior.test.tsx b/src/components/ui/RadioGroup/tests/RadioGroup.behavior.test.tsx
index 448c996a8..adf436e40 100644
--- a/src/components/ui/RadioGroup/tests/RadioGroup.behavior.test.tsx
+++ b/src/components/ui/RadioGroup/tests/RadioGroup.behavior.test.tsx
@@ -8,6 +8,22 @@ import RadioGroup from '../RadioGroup';
const ACCESSIBILITY_TEST_TAGS = ['wcag2a', 'wcag2aa'];
describe('RadioGroup behavior', () => {
+ test('renders multiple items without invalid asChild warnings', () => {
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
+
+ render(
+
+
+
+
+ );
+
+ expect(warnSpy).not.toHaveBeenCalledWith(
+ expect.stringContaining('asChild prop requires exactly one valid child element')
+ );
+ warnSpy.mockRestore();
+ });
+
test('arrow keys move focus/selection and Home/End jump to first/last', async() => {
render(
diff --git a/src/components/ui/ScrollArea/fragments/ScrollAreaRoot.tsx b/src/components/ui/ScrollArea/fragments/ScrollAreaRoot.tsx
index 2751d7537..eb9eca342 100644
--- a/src/components/ui/ScrollArea/fragments/ScrollAreaRoot.tsx
+++ b/src/components/ui/ScrollArea/fragments/ScrollAreaRoot.tsx
@@ -1,6 +1,6 @@
'use client';
-import React, { useEffect, useLayoutEffect, useRef, forwardRef, ElementRef, ComponentPropsWithoutRef } from 'react';
+import React, { useEffect, useRef, forwardRef, ElementRef, ComponentPropsWithoutRef } from 'react';
import clsx from 'clsx';
import { useComponentClass } from '~/components/ui/Theme/useComponentClass';
@@ -15,10 +15,6 @@ export type ScrollAreaRootProps = ComponentPropsWithoutRef<'div'> & {
customRootClass?: string;
/** Controls scrollbar and thumb visibility: always, on scroll (1s fade), on hover + scroll, or when overflowing (auto). */
type?: ScrollAreaScrollbarType;
- /** When set to manual, resets the viewport scroll position on mount and whenever restoreKey changes. */
- scrollRestoration?: 'auto' | 'manual';
- /** Key used to reset manual scroll restoration, such as a route pathname. */
- restoreKey?: React.Key;
};
const ScrollAreaRoot = forwardRef(({
@@ -26,8 +22,6 @@ const ScrollAreaRoot = forwardRef(({
className = '',
customRootClass = '',
type = 'hover',
- scrollRestoration = 'auto',
- restoreKey,
...props
}, ref) => {
const rootClass = useComponentClass(customRootClass, COMPONENT_NAME);
@@ -81,17 +75,9 @@ const ScrollAreaRoot = forwardRef(({
&& (mutation.addedNodes.length > 0 || mutation.removedNodes.length > 0)
);
- if (directChildSwap) {
- viewport.scrollTop = 0;
- viewport.scrollLeft = 0;
- syncResizeObservers();
- }
+ if (directChildSwap) syncResizeObservers();
handleResize();
-
- if (directChildSwap) {
- handleScroll();
- }
});
mutationObserver.observe(viewport, {
@@ -184,21 +170,6 @@ const ScrollAreaRoot = forwardRef(({
}
};
- const resetViewportScroll = () => {
- const viewport = scrollAreaViewportRef.current;
- if (!viewport) return;
-
- viewport.scrollTop = 0;
- viewport.scrollLeft = 0;
- handleScroll();
- };
-
- useLayoutEffect(() => {
- if (scrollRestoration !== 'manual') return;
-
- resetViewportScroll();
- }, [scrollRestoration, restoreKey]);
-
const fastScrollTo = (target: { top?: number; left?: number }) => {
if (!scrollAreaViewportRef.current) return;
const viewport = scrollAreaViewportRef.current;
diff --git a/src/components/ui/ScrollArea/fragments/ScrollAreaViewport.tsx b/src/components/ui/ScrollArea/fragments/ScrollAreaViewport.tsx
index 23a0352b7..0717333a8 100644
--- a/src/components/ui/ScrollArea/fragments/ScrollAreaViewport.tsx
+++ b/src/components/ui/ScrollArea/fragments/ScrollAreaViewport.tsx
@@ -6,7 +6,7 @@ import clsx from 'clsx';
type ScrollAreaViewportElement = ElementRef<'div'>;
export type ScrollAreaViewportProps = ComponentPropsWithoutRef<'div'>;
-const ScrollAreaViewport = forwardRef(({ children, className = '', ...props }, ref) => {
+const ScrollAreaViewport = forwardRef(({ children, className = '', tabIndex = 0, ...props }, ref) => {
const { rootClass, scrollAreaViewportRef, handleScroll } = useContext(ScrollAreaContext);
const setRef = (node: ScrollAreaViewportElement | null) => {
@@ -20,7 +20,7 @@ const ScrollAreaViewport = forwardRef{children} ;
});
ScrollAreaViewport.displayName = 'ScrollAreaViewport';
diff --git a/src/components/ui/ScrollArea/tests/ScrollArea.test.tsx b/src/components/ui/ScrollArea/tests/ScrollArea.test.tsx
index be9d98058..7869e68ab 100644
--- a/src/components/ui/ScrollArea/tests/ScrollArea.test.tsx
+++ b/src/components/ui/ScrollArea/tests/ScrollArea.test.tsx
@@ -37,6 +37,7 @@ describe('ScrollArea', () => {
expect(rootRef.current).toBeInstanceOf(HTMLDivElement);
expect(viewportRef.current).toBeInstanceOf(HTMLDivElement);
+ expect(viewportRef.current).toHaveAttribute('tabindex', '0');
expect(scrollbarRef.current).toBeInstanceOf(HTMLDivElement);
expect(thumbRef.current).toBeInstanceOf(HTMLDivElement);
expect(cornerRef.current).toBeInstanceOf(HTMLDivElement);
@@ -48,6 +49,18 @@ describe('ScrollArea', () => {
errorSpy.mockRestore();
});
+ test('allows consumers to remove the viewport from the tab order', () => {
+ render(
+
+ );
+
+ expect(screen.getByTestId('viewport')).toHaveAttribute('tabindex', '-1');
+ });
+
test('does not emit generated part classes without a namespace', () => {
render(