diff --git a/.changeset/bright-knives-remain.md b/.changeset/bright-knives-remain.md new file mode 100644 index 0000000000..cbdd5a29bc --- /dev/null +++ b/.changeset/bright-knives-remain.md @@ -0,0 +1,8 @@ +--- +"@digdir/designsystemet-react": patch +--- + +RovingFocus: add `orientation` to support for different arrow directions, and add support home/end buttons +- Affects `ToggleGroup`, where up and down arrows can now be used +- Affects `ToggleGroup`, where home and end can now be used +- Affects `Tabs`, where home and end can now be used diff --git a/packages/react/src/components/ToggleGroup/ToggleGroupRoot.tsx b/packages/react/src/components/ToggleGroup/ToggleGroupRoot.tsx index 2f30748307..c974030281 100644 --- a/packages/react/src/components/ToggleGroup/ToggleGroupRoot.tsx +++ b/packages/react/src/components/ToggleGroup/ToggleGroupRoot.tsx @@ -87,7 +87,7 @@ export const ToggleGroupRoot = forwardRef( value={value} /> )} - +
{children}
diff --git a/packages/react/src/utilities/RovingFocus/RovingFocusItem.tsx b/packages/react/src/utilities/RovingFocus/RovingFocusItem.tsx index 5984c611a2..6bbf0f97d7 100644 --- a/packages/react/src/utilities/RovingFocus/RovingFocusItem.tsx +++ b/packages/react/src/utilities/RovingFocus/RovingFocusItem.tsx @@ -44,7 +44,8 @@ export const RovingFocusItem = forwardRef( const focusValue = value ?? (typeof rest.children == 'string' ? rest.children : ''); - const { getOrderedItems, getRovingProps } = useRovingFocus(focusValue); + const { getOrderedItems, getRovingProps, orientation } = + useRovingFocus(focusValue); const rovingProps = getRovingProps({ onKeyDown: (e) => { @@ -52,15 +53,46 @@ export const RovingFocusItem = forwardRef( const items = getOrderedItems(); let nextItem: RovingFocusElement | undefined; - if (e.key === 'ArrowRight') { - nextItem = getNextFocusableValue(items, focusValue); + switch (orientation) { + case 'horizontal': + if (e.key === 'ArrowRight') { + nextItem = getNextFocusableValue(items, focusValue); + } + + if (e.key === 'ArrowLeft') { + nextItem = getPrevFocusableValue(items, focusValue); + } + break; + case 'vertical': + if (e.key === 'ArrowDown') { + nextItem = getNextFocusableValue(items, focusValue); + } + + if (e.key === 'ArrowUp') { + nextItem = getPrevFocusableValue(items, focusValue); + } + break; + case 'ambiguous': + if (['ArrowRight', 'ArrowDown'].includes(e.key)) { + nextItem = getNextFocusableValue(items, focusValue); + } + + if (['ArrowLeft', 'ArrowUp'].includes(e.key)) { + nextItem = getPrevFocusableValue(items, focusValue); + } } - if (e.key === 'ArrowLeft') { - nextItem = getPrevFocusableValue(items, focusValue); + if (e.key === 'Home') { + nextItem = items[0]; + } + if (e.key === 'End') { + nextItem = items[items.length - 1]; } - nextItem?.element.focus(); + if (nextItem) { + e.preventDefault(); + nextItem.element.focus(); + } }, }); diff --git a/packages/react/src/utilities/RovingFocus/RovingFocusRoot.tsx b/packages/react/src/utilities/RovingFocus/RovingFocusRoot.tsx index ac131889b3..8e111302fc 100644 --- a/packages/react/src/utilities/RovingFocus/RovingFocusRoot.tsx +++ b/packages/react/src/utilities/RovingFocus/RovingFocusRoot.tsx @@ -21,6 +21,13 @@ type RovingFocusRootBaseProps = { * @default false */ asChild?: boolean; + /** + * Changes what arrow keys are used to navigate the roving focus. + * Sets correct `aria-orientation` attribute, if `vertical` or `horizontal`. + * + * @default 'horizontal' + */ + orientation?: 'vertical' | 'horizontal' | 'ambiguous'; } & HTMLAttributes; export type RovingFocusElement = { @@ -34,6 +41,7 @@ export type RovingFocusProps = { setFocusableValue: (value: string) => void; focusableValue: string | null; onShiftTab: () => void; + orientation: 'vertical' | 'horizontal' | 'ambiguous'; }; export const RovingFocusContext = createContext({ @@ -46,76 +54,91 @@ export const RovingFocusContext = createContext({ /* intentionally empty */ }, focusableValue: null, + orientation: 'horizontal', }); export const RovingFocusRoot = forwardRef< HTMLElement, RovingFocusRootBaseProps ->(({ activeValue, asChild, onBlur, onFocus, ...rest }, ref) => { - const Component = asChild ? Slot : 'div'; +>( + ( + { + activeValue, + asChild, + orientation = 'horizontal', + onBlur, + onFocus, + ...rest + }, + ref, + ) => { + const Component = asChild ? Slot : 'div'; - const [focusableValue, setFocusableValue] = useState(null); - const [isShiftTabbing, setIsShiftTabbing] = useState(false); - const elements = useRef(new Map()); - const myRef = useRef(); + const [focusableValue, setFocusableValue] = useState(null); + const [isShiftTabbing, setIsShiftTabbing] = useState(false); + const elements = useRef(new Map()); + const myRef = useRef(); - const refs = useMergeRefs([ref, myRef]); + const refs = useMergeRefs([ref, myRef]); - const getOrderedItems = (): RovingFocusElement[] => { - if (!myRef.current) return []; - const elementsFromDOM = Array.from( - myRef.current.querySelectorAll( - '[data-roving-tabindex-item]', - ), - ); + const getOrderedItems = (): RovingFocusElement[] => { + if (!myRef.current) return []; + const elementsFromDOM = Array.from( + myRef.current.querySelectorAll( + '[data-roving-tabindex-item]', + ), + ); - return Array.from(elements.current) - .sort( - (a, b) => elementsFromDOM.indexOf(a[1]) - elementsFromDOM.indexOf(b[1]), - ) - .map(([value, element]) => ({ value, element })); - }; + return Array.from(elements.current) + .sort( + (a, b) => + elementsFromDOM.indexOf(a[1]) - elementsFromDOM.indexOf(b[1]), + ) + .map(([value, element]) => ({ value, element })); + }; - useEffect(() => { - setFocusableValue(activeValue ?? null); - }, [activeValue]); + useEffect(() => { + setFocusableValue(activeValue ?? null); + }, [activeValue]); - return ( - { - setIsShiftTabbing(true); - }, - }} - > - ) => { - onBlur?.(e); - setIsShiftTabbing(false); - setFocusableValue(activeValue ?? null); + return ( + { + setIsShiftTabbing(true); + }, + orientation, }} - onFocus={(e: FocusEvent) => { - onFocus?.(e); - if (e.target !== e.currentTarget) return; - const orderedItems = getOrderedItems(); - if (orderedItems.length === 0) return; + > + ) => { + onBlur?.(e); + setIsShiftTabbing(false); + setFocusableValue(activeValue ?? null); + }} + onFocus={(e: FocusEvent) => { + onFocus?.(e); + if (e.target !== e.currentTarget) return; + const orderedItems = getOrderedItems(); + if (orderedItems.length === 0) return; - if (focusableValue != null) { - elements.current.get(focusableValue)?.focus(); - } else if (activeValue != null) { - elements.current.get(activeValue)?.focus(); - } else { - orderedItems.at(0)?.element.focus(); - } - }} - ref={refs} - /> - - ); -}); + if (focusableValue != null) { + elements.current.get(focusableValue)?.focus(); + } else if (activeValue != null) { + elements.current.get(activeValue)?.focus(); + } else { + orderedItems.at(0)?.element.focus(); + } + }} + ref={refs} + /> + + ); + }, +); diff --git a/packages/react/src/utilities/RovingFocus/useRovingFocus.ts b/packages/react/src/utilities/RovingFocus/useRovingFocus.ts index 8e66bb9b7d..9cb8424596 100644 --- a/packages/react/src/utilities/RovingFocus/useRovingFocus.ts +++ b/packages/react/src/utilities/RovingFocus/useRovingFocus.ts @@ -14,11 +14,13 @@ export const useRovingFocus = (value: string) => { setFocusableValue, focusableValue, onShiftTab, + orientation, } = useContext(RovingFocusContext); return { getOrderedItems, isFocusable: focusableValue === value, + orientation, getRovingProps: (props: HTMLAttributes) => ({ ...props, ref: (element: HTMLElement | null) => {