Skip to content

Commit 3a9ef46

Browse files
authored
feat(dx): configurable loop behavior to focus-managed components (rad-ui#1934)
1 parent 0e2497c commit 3a9ef46

16 files changed

Lines changed: 215 additions & 12 deletions

File tree

.changeset/green-pigs-repeat.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@radui/ui": patch
3+
---
4+
5+
Add configurable `loop` focus-wrapping options for Tabs, Disclosure, Tree, and NavigationMenu, including NavigationMenu content panels.

docs/app/docs/components/disclosure/docs/component_api/root.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ const data = {
88
],
99
data: [
1010
{ prop: { name: "items", info_tooltips: "Array of { title, content } for the convenience wrapper." }, type: "{ title: string, content: ReactNode }[]", default: "--" },
11-
{ prop: { name: "className", info_tooltips: "Additional CSS classes." }, type: "string", default: '""' }
11+
{ prop: { name: "className", info_tooltips: "Additional CSS classes." }, type: "string", default: '""' },
12+
{ prop: { name: "loop", info_tooltips: "Whether arrow key navigation wraps between disclosure triggers." }, type: "boolean", default: "true" }
1213
]
1314
}
1415

docs/app/docs/components/navigation-menu/docs/component_api/root.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ const data = {
88
],
99
data: [
1010
{ prop: { name: "value", info_tooltips: "Controlled open item value." }, type: "string", default: "--" },
11+
{ prop: { name: "loop", info_tooltips: "Whether arrow key navigation wraps between top-level triggers." }, type: "boolean", default: "true" },
12+
{ prop: { name: "contentLoop", info_tooltips: "Default wrap behavior for roving focus inside opened content panels." }, type: "boolean", default: "true" },
1113
{ prop: { name: "orientation", info_tooltips: "Layout orientation of the menu." }, type: "enum", enum_values: ["horizontal", "vertical"], default: "horizontal" },
1214
{ prop: { name: "onValueChange", info_tooltips: "Callback when the active item changes." }, type: "function", default: "--" }
1315
]

docs/app/docs/components/tabs/docs/component_api/root.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,14 @@ const data = {
9090
enum_values : ['ltr', 'rtl'],
9191
default : "ltr",
9292
},
93+
{
94+
prop : {
95+
name : "loop",
96+
info_tooltips : "Whether arrow key navigation wraps from the last trigger to the first, and from the first to the last."
97+
},
98+
type : "boolean",
99+
default : "true",
100+
},
93101
{
94102
prop : {
95103
name : "activationMode",

docs/app/docs/components/tree/docs/codeUsage.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { getSourceCodeFromPath } from '@/utils/parseSourceCode';
2+
import root_api from './component_api/root.tsx';
23
import item_api from './component_api/item.tsx';
34

45
const example_1_SourceCode = await getSourceCodeFromPath('docs/app/docs/components/tree/docs/example_1.tsx');
@@ -13,6 +14,7 @@ export const code = {
1314
export const anatomy = { code: anatomy_SourceCode };
1415

1516
export const api_documentation = {
17+
root: root_api,
1618
item: item_api
1719
};
1820

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
const data = {
2+
name: "Root",
3+
description: "The root Tree component.",
4+
columns: [
5+
{ name: "Prop", id: "prop" },
6+
{ name: "Type", id: "type" },
7+
{ name: "Default", id: "default" }
8+
],
9+
data: [
10+
{ prop: { name: "aria-label", info_tooltips: "Accessible label for the tree when no visible label is present." }, type: "string", default: "--" },
11+
{ prop: { name: "aria-labelledby", info_tooltips: "ID of an element that labels the tree." }, type: "string", default: "--" },
12+
{ prop: { name: "loop", info_tooltips: "Whether arrow key navigation wraps from the last visible item to the first, and from the first to the last." }, type: "boolean", default: "true" }
13+
]
14+
}
15+
16+
export default data

src/components/ui/Disclosure/fragments/DisclosureRoot.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@ const COMPONENT_NAME = 'Disclosure';
1010
export type DisclosureRootProps = React.ComponentPropsWithoutRef<'div'> & {
1111
customRootClass?: string;
1212
defaultOpen?: number | null;
13+
loop?: boolean;
1314
};
1415

15-
const DisclosureRoot = React.forwardRef<React.ElementRef<'div'>, DisclosureRootProps>(({ children, customRootClass, 'aria-label': ariaLabel, ...props }, forwardedRef) => {
16+
const DisclosureRoot = React.forwardRef<React.ElementRef<'div'>, DisclosureRootProps>(({ children, customRootClass, 'aria-label': ariaLabel, loop = true, ...props }, forwardedRef) => {
1617
const disclosureRef = useRef<React.ElementRef<'div'> | null>(null);
1718
const rootClass = useComponentClass(customRootClass, COMPONENT_NAME);
1819

@@ -37,7 +38,7 @@ const DisclosureRoot = React.forwardRef<React.ElementRef<'div'>, DisclosureRootP
3738
disclosureRef
3839

3940
}}>
40-
<RovingFocusGroup.Root>
41+
<RovingFocusGroup.Root loop={loop}>
4142
<RovingFocusGroup.Group className={clsx(rootClass && `${rootClass}-root`)}>
4243
<div
4344
{...props}

src/components/ui/Disclosure/tests/Disclosure.test.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React from 'react';
22
import { fireEvent, render, screen } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
34
import Disclosure from '../Disclosure';
45

56
const items = [
@@ -55,6 +56,38 @@ describe('Disclosure', () => {
5556
expect(screen.getByText('Content 1')).toBeInTheDocument();
5657
});
5758

59+
test('loop={false} stops focus wrap between triggers', async() => {
60+
const user = userEvent.setup();
61+
62+
render(
63+
<Disclosure.Root aria-label="test" loop={false}>
64+
<Disclosure.Item value={0}>
65+
<Disclosure.Trigger>Item 1</Disclosure.Trigger>
66+
<Disclosure.Content>Content 1</Disclosure.Content>
67+
</Disclosure.Item>
68+
<Disclosure.Item value={1}>
69+
<Disclosure.Trigger>Item 2</Disclosure.Trigger>
70+
<Disclosure.Content>Content 2</Disclosure.Content>
71+
</Disclosure.Item>
72+
</Disclosure.Root>
73+
);
74+
75+
const item1 = screen.getByText('Item 1');
76+
const item2 = screen.getByText('Item 2');
77+
78+
await user.tab();
79+
expect(item1).toHaveFocus();
80+
81+
await user.keyboard('{ArrowLeft}');
82+
expect(item1).toHaveFocus();
83+
84+
await user.keyboard('{ArrowRight}');
85+
expect(item2).toHaveFocus();
86+
87+
await user.keyboard('{ArrowRight}');
88+
expect(item2).toHaveFocus();
89+
});
90+
5891
test('hides content when the same item is clicked again', () => {
5992
render(<Disclosure items={items} aria-label="test" />);
6093
const button = screen.getByText('Item 1');

src/components/ui/NavigationMenu/contexts/NavigationMenuRootContext.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@ export interface NavigationMenuRootContextProps {
44
isOpen: string;
55
setIsOpen: React.Dispatch<React.SetStateAction<string>>;
66
rootClass: string;
7+
contentLoop: boolean;
78
}
89

910
const NavigationMenuRootContext = React.createContext<NavigationMenuRootContextProps>({
1011
isOpen: '',
1112
setIsOpen: () => {},
12-
rootClass: ''
13+
rootClass: '',
14+
contentLoop: true
1315
});
1416

1517
export default NavigationMenuRootContext;

src/components/ui/NavigationMenu/fragments/NavigationMenuContent.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@ export type NavigationMenuContentElement = React.ElementRef<'div'>;
88

99
export interface NavigationMenuContentProps extends React.ComponentPropsWithoutRef<'div'> {
1010
children: React.ReactNode;
11+
loop?: boolean;
1112
}
1213

1314
const NavigationMenuContent = React.forwardRef<NavigationMenuContentElement, NavigationMenuContentProps>(
14-
({ children, className, ...props }, ref) => {
15+
({ children, className, loop, ...props }, ref) => {
1516
const { itemOpen } = React.useContext(NavigationMenuItemContext);
16-
const { rootClass } = React.useContext(NavigationMenuRootContext);
17+
const { rootClass, contentLoop } = React.useContext(NavigationMenuRootContext);
1718
const contentRef = React.useRef<HTMLDivElement>(null);
19+
const resolvedLoop = loop ?? contentLoop;
1820

1921
React.useImperativeHandle(ref, () => contentRef.current as HTMLDivElement);
2022

@@ -30,7 +32,7 @@ const NavigationMenuContent = React.forwardRef<NavigationMenuContentElement, Nav
3032
data-state={itemOpen ? 'open' : 'closed'}
3133
{...props}
3234
>
33-
<RovingFocusGroup.Root>
35+
<RovingFocusGroup.Root loop={resolvedLoop}>
3436
<RovingFocusGroup.Group>{children}</RovingFocusGroup.Group>
3537
</RovingFocusGroup.Root>
3638
</div>

0 commit comments

Comments
 (0)