Skip to content

Commit f404d9e

Browse files
Barsnesmimarz
authored andcommitted
feat(RovingFocus): add support for up/down arrows and home/end buttons (#2206)
1 parent 9503524 commit f404d9e

File tree

5 files changed

+132
-67
lines changed

5 files changed

+132
-67
lines changed

.changeset/bright-knives-remain.md

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@digdir/designsystemet-react": patch
3+
---
4+
5+
RovingFocus: add `orientation` to support for different arrow directions, and add support home/end buttons
6+
- Affects `ToggleGroup`, where up and down arrows can now be used
7+
- Affects `ToggleGroup`, where home and end can now be used
8+
- Affects `Tabs`, where home and end can now be used

packages/react/src/components/ToggleGroup/ToggleGroupRoot.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ export const ToggleGroupRoot = forwardRef<HTMLDivElement, ToggleGroupProps>(
8787
value={value}
8888
/>
8989
)}
90-
<RovingFocusRoot asChild activeValue={value}>
90+
<RovingFocusRoot asChild activeValue={value} orientation='ambiguous'>
9191
<div className='ds-togglegroup__content' role='radiogroup'>
9292
{children}
9393
</div>

packages/react/src/utilities/RovingFocus/RovingFocusItem.tsx

+38-6
Original file line numberDiff line numberDiff line change
@@ -44,23 +44,55 @@ export const RovingFocusItem = forwardRef<HTMLElement, RovingFocusItemProps>(
4444
const focusValue =
4545
value ?? (typeof rest.children == 'string' ? rest.children : '');
4646

47-
const { getOrderedItems, getRovingProps } = useRovingFocus(focusValue);
47+
const { getOrderedItems, getRovingProps, orientation } =
48+
useRovingFocus(focusValue);
4849

4950
const rovingProps = getRovingProps<HTMLElement>({
5051
onKeyDown: (e) => {
5152
rest?.onKeyDown?.(e);
5253
const items = getOrderedItems();
5354
let nextItem: RovingFocusElement | undefined;
5455

55-
if (e.key === 'ArrowRight') {
56-
nextItem = getNextFocusableValue(items, focusValue);
56+
switch (orientation) {
57+
case 'horizontal':
58+
if (e.key === 'ArrowRight') {
59+
nextItem = getNextFocusableValue(items, focusValue);
60+
}
61+
62+
if (e.key === 'ArrowLeft') {
63+
nextItem = getPrevFocusableValue(items, focusValue);
64+
}
65+
break;
66+
case 'vertical':
67+
if (e.key === 'ArrowDown') {
68+
nextItem = getNextFocusableValue(items, focusValue);
69+
}
70+
71+
if (e.key === 'ArrowUp') {
72+
nextItem = getPrevFocusableValue(items, focusValue);
73+
}
74+
break;
75+
case 'ambiguous':
76+
if (['ArrowRight', 'ArrowDown'].includes(e.key)) {
77+
nextItem = getNextFocusableValue(items, focusValue);
78+
}
79+
80+
if (['ArrowLeft', 'ArrowUp'].includes(e.key)) {
81+
nextItem = getPrevFocusableValue(items, focusValue);
82+
}
5783
}
5884

59-
if (e.key === 'ArrowLeft') {
60-
nextItem = getPrevFocusableValue(items, focusValue);
85+
if (e.key === 'Home') {
86+
nextItem = items[0];
87+
}
88+
if (e.key === 'End') {
89+
nextItem = items[items.length - 1];
6190
}
6291

63-
nextItem?.element.focus();
92+
if (nextItem) {
93+
e.preventDefault();
94+
nextItem.element.focus();
95+
}
6496
},
6597
});
6698

packages/react/src/utilities/RovingFocus/RovingFocusRoot.tsx

+83-60
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@ type RovingFocusRootBaseProps = {
2121
* @default false
2222
*/
2323
asChild?: boolean;
24+
/**
25+
* Changes what arrow keys are used to navigate the roving focus.
26+
* Sets correct `aria-orientation` attribute, if `vertical` or `horizontal`.
27+
*
28+
* @default 'horizontal'
29+
*/
30+
orientation?: 'vertical' | 'horizontal' | 'ambiguous';
2431
} & HTMLAttributes<HTMLElement>;
2532

2633
export type RovingFocusElement = {
@@ -34,6 +41,7 @@ export type RovingFocusProps = {
3441
setFocusableValue: (value: string) => void;
3542
focusableValue: string | null;
3643
onShiftTab: () => void;
44+
orientation: 'vertical' | 'horizontal' | 'ambiguous';
3745
};
3846

3947
export const RovingFocusContext = createContext<RovingFocusProps>({
@@ -46,76 +54,91 @@ export const RovingFocusContext = createContext<RovingFocusProps>({
4654
/* intentionally empty */
4755
},
4856
focusableValue: null,
57+
orientation: 'horizontal',
4958
});
5059

5160
export const RovingFocusRoot = forwardRef<
5261
HTMLElement,
5362
RovingFocusRootBaseProps
54-
>(({ activeValue, asChild, onBlur, onFocus, ...rest }, ref) => {
55-
const Component = asChild ? Slot : 'div';
63+
>(
64+
(
65+
{
66+
activeValue,
67+
asChild,
68+
orientation = 'horizontal',
69+
onBlur,
70+
onFocus,
71+
...rest
72+
},
73+
ref,
74+
) => {
75+
const Component = asChild ? Slot : 'div';
5676

57-
const [focusableValue, setFocusableValue] = useState<string | null>(null);
58-
const [isShiftTabbing, setIsShiftTabbing] = useState(false);
59-
const elements = useRef(new Map<string, HTMLElement>());
60-
const myRef = useRef<HTMLElement>();
77+
const [focusableValue, setFocusableValue] = useState<string | null>(null);
78+
const [isShiftTabbing, setIsShiftTabbing] = useState(false);
79+
const elements = useRef(new Map<string, HTMLElement>());
80+
const myRef = useRef<HTMLElement>();
6181

62-
const refs = useMergeRefs([ref, myRef]);
82+
const refs = useMergeRefs([ref, myRef]);
6383

64-
const getOrderedItems = (): RovingFocusElement[] => {
65-
if (!myRef.current) return [];
66-
const elementsFromDOM = Array.from(
67-
myRef.current.querySelectorAll<HTMLElement>(
68-
'[data-roving-tabindex-item]',
69-
),
70-
);
84+
const getOrderedItems = (): RovingFocusElement[] => {
85+
if (!myRef.current) return [];
86+
const elementsFromDOM = Array.from(
87+
myRef.current.querySelectorAll<HTMLElement>(
88+
'[data-roving-tabindex-item]',
89+
),
90+
);
7191

72-
return Array.from(elements.current)
73-
.sort(
74-
(a, b) => elementsFromDOM.indexOf(a[1]) - elementsFromDOM.indexOf(b[1]),
75-
)
76-
.map(([value, element]) => ({ value, element }));
77-
};
92+
return Array.from(elements.current)
93+
.sort(
94+
(a, b) =>
95+
elementsFromDOM.indexOf(a[1]) - elementsFromDOM.indexOf(b[1]),
96+
)
97+
.map(([value, element]) => ({ value, element }));
98+
};
7899

79-
useEffect(() => {
80-
setFocusableValue(activeValue ?? null);
81-
}, [activeValue]);
100+
useEffect(() => {
101+
setFocusableValue(activeValue ?? null);
102+
}, [activeValue]);
82103

83-
return (
84-
<RovingFocusContext.Provider
85-
value={{
86-
elements,
87-
getOrderedItems,
88-
focusableValue,
89-
setFocusableValue,
90-
onShiftTab: () => {
91-
setIsShiftTabbing(true);
92-
},
93-
}}
94-
>
95-
<Component
96-
{...rest}
97-
tabIndex={isShiftTabbing ? -1 : 0}
98-
onBlur={(e: FocusEvent<HTMLElement>) => {
99-
onBlur?.(e);
100-
setIsShiftTabbing(false);
101-
setFocusableValue(activeValue ?? null);
104+
return (
105+
<RovingFocusContext.Provider
106+
value={{
107+
elements,
108+
getOrderedItems,
109+
focusableValue,
110+
setFocusableValue,
111+
onShiftTab: () => {
112+
setIsShiftTabbing(true);
113+
},
114+
orientation,
102115
}}
103-
onFocus={(e: FocusEvent<HTMLElement>) => {
104-
onFocus?.(e);
105-
if (e.target !== e.currentTarget) return;
106-
const orderedItems = getOrderedItems();
107-
if (orderedItems.length === 0) return;
116+
>
117+
<Component
118+
{...rest}
119+
tabIndex={isShiftTabbing ? -1 : 0}
120+
onBlur={(e: FocusEvent<HTMLElement>) => {
121+
onBlur?.(e);
122+
setIsShiftTabbing(false);
123+
setFocusableValue(activeValue ?? null);
124+
}}
125+
onFocus={(e: FocusEvent<HTMLElement>) => {
126+
onFocus?.(e);
127+
if (e.target !== e.currentTarget) return;
128+
const orderedItems = getOrderedItems();
129+
if (orderedItems.length === 0) return;
108130

109-
if (focusableValue != null) {
110-
elements.current.get(focusableValue)?.focus();
111-
} else if (activeValue != null) {
112-
elements.current.get(activeValue)?.focus();
113-
} else {
114-
orderedItems.at(0)?.element.focus();
115-
}
116-
}}
117-
ref={refs}
118-
/>
119-
</RovingFocusContext.Provider>
120-
);
121-
});
131+
if (focusableValue != null) {
132+
elements.current.get(focusableValue)?.focus();
133+
} else if (activeValue != null) {
134+
elements.current.get(activeValue)?.focus();
135+
} else {
136+
orderedItems.at(0)?.element.focus();
137+
}
138+
}}
139+
ref={refs}
140+
/>
141+
</RovingFocusContext.Provider>
142+
);
143+
},
144+
);

packages/react/src/utilities/RovingFocus/useRovingFocus.ts

+2
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@ export const useRovingFocus = (value: string) => {
1414
setFocusableValue,
1515
focusableValue,
1616
onShiftTab,
17+
orientation,
1718
} = useContext(RovingFocusContext);
1819

1920
return {
2021
getOrderedItems,
2122
isFocusable: focusableValue === value,
23+
orientation,
2224
getRovingProps: <T extends HTMLElement>(props: HTMLAttributes<T>) => ({
2325
...props,
2426
ref: (element: HTMLElement | null) => {

0 commit comments

Comments
 (0)