diff options
| author | Tobias Järvelöv <tobias.jarvelov@mullvad.net> | 2025-10-07 11:41:42 +0200 |
|---|---|---|
| committer | Tobias Järvelöv <tobias.jarvelov@mullvad.net> | 2025-10-07 11:41:42 +0200 |
| commit | cc8a363014eb2f424a3f31d4499b1cc5b28d7191 (patch) | |
| tree | 82bbb41edcc517f880be11dbbbd1110a18826e24 | |
| parent | 93c2133fe37d18a5114b0cb5fef60c6dc8a9465f (diff) | |
| parent | f29ef10f4802c3c8f67c2660a4374035cffcfe57 (diff) | |
| download | mullvadvpn-cc8a363014eb2f424a3f31d4499b1cc5b28d7191.tar.xz mullvadvpn-cc8a363014eb2f424a3f31d4499b1cc5b28d7191.zip | |
Merge branch 'fix-split-listbox-option-focus'
28 files changed, 234 insertions, 131 deletions
diff --git a/desktop/packages/mullvad-vpn/locales/messages.pot b/desktop/packages/mullvad-vpn/locales/messages.pot index 2b7de605ce..122c7ea117 100644 --- a/desktop/packages/mullvad-vpn/locales/messages.pot +++ b/desktop/packages/mullvad-vpn/locales/messages.pot @@ -426,6 +426,10 @@ msgctxt "accessibility" msgid "UDP-over-TCP settings" msgstr "" +msgctxt "accessibility" +msgid "Use the right arrow key to focus the settings button." +msgstr "" + #. Title label in navigation bar msgctxt "account-view" msgid "Account" diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/input-option/components/input-option-trigger/InputOptionTrigger.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/input-option/components/input-option-trigger/InputOptionTrigger.tsx index e7343ee11a..12e928876a 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/input-option/components/input-option-trigger/InputOptionTrigger.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/input-option/components/input-option-trigger/InputOptionTrigger.tsx @@ -63,6 +63,7 @@ export const InputOptionTrigger = ({ children, ...props }: InputOptionTriggerPro return ( <StyledInputOptionTrigger role="option" + data-option aria-selected={selected} tabIndex={-1} onFocus={handleFocus} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/split-option/SplitOption.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/split-option/SplitOption.tsx index 4c02f43cb0..7e7b69867e 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/split-option/SplitOption.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/split-option/SplitOption.tsx @@ -1,14 +1,33 @@ +import React from 'react'; + import { Flex } from '../../../../lib/components'; import { Listbox } from '../../../../lib/components/listbox'; import { ListboxOptionProps } from '../../../../lib/components/listbox/components'; +import { useRovingFocus } from '../../../../lib/hooks'; import { SplitOptionItem, SplitOptionNavigateButton } from './components'; export type SplitOptionProps<T> = ListboxOptionProps<T>; function SplitOption<T>({ children, ...props }: SplitOptionProps<T>) { + const optionsRef = React.useRef<HTMLDivElement>(null); + const [focusedIndex, setFocusedIndex] = React.useState<number | undefined>(undefined); + const { handleKeyboardNavigation, handleBlur, handleFocus } = useRovingFocus({ + optionsRef, + orientation: 'horizontal', + selector: '[data-split-button="true"]:not([aria-disabled="true"])', + focusedIndex, + setFocusedIndex, + }); + return ( - <Listbox.Option level={1} {...props}> - <Flex>{children}</Flex> + <Listbox.Option + level={1} + role="group" + onKeyDown={handleKeyboardNavigation} + onFocus={handleFocus} + onBlur={handleBlur} + {...props}> + <Flex ref={optionsRef}>{children}</Flex> </Listbox.Option> ); } diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/split-option/components/split-option-item/SplitOptionItem.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/split-option/components/split-option-item/SplitOptionItem.tsx index cbe431ad7e..b2804c0b64 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/split-option/components/split-option-item/SplitOptionItem.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/split-option/components/split-option-item/SplitOptionItem.tsx @@ -4,7 +4,7 @@ export type ListBoxOptionWithNavigationProps = React.ComponentPropsWithRef<'li'> export function SplitOptionItem({ children, ...props }: ListBoxOptionWithNavigationProps) { return ( - <Listbox.Option.Trigger {...props}> + <Listbox.Option.Trigger data-option data-split-button {...props}> <Listbox.Option.Item> <Listbox.Option.Content> <Listbox.Option.Group> diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/split-option/components/split-option-navigate-button/SplitOptionNavigateButton.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/split-option/components/split-option-navigate-button/SplitOptionNavigateButton.tsx index c942f159c7..8ea55d0353 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/split-option/components/split-option-navigate-button/SplitOptionNavigateButton.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/split-option/components/split-option-navigate-button/SplitOptionNavigateButton.tsx @@ -17,6 +17,7 @@ const StyledFlex = styled(Flex)` const StyledSplitOptionNavigateButton = styled.button` position: relative; + margin-bottom: 1px; &&::before { content: ''; position: absolute; @@ -54,7 +55,7 @@ export function SplitOptionNavigateButton({ }, [history, to]); return ( - <StyledSplitOptionNavigateButton onClick={navigate} {...props}> + <StyledSplitOptionNavigateButton data-split-button onClick={navigate} tabIndex={-1} {...props}> <StyledFlex $justifyContent="center" $alignItems="center" $padding={{ horizontal: 'medium' }}> <Icon icon={'chevron-right'} aria-hidden="true" /> </StyledFlex> diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/wireguard-settings/components/obfuscation-settings/ObfuscationSettings.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/wireguard-settings/components/obfuscation-settings/ObfuscationSettings.tsx index 7058548c36..c1c56aef9e 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/views/wireguard-settings/components/obfuscation-settings/ObfuscationSettings.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/wireguard-settings/components/obfuscation-settings/ObfuscationSettings.tsx @@ -69,7 +69,11 @@ export function ObfuscationSettings() { {messages.gettext('Automatic')} </SettingsListbox.BaseOption> <SettingsListbox.SplitOption value={ObfuscationType.shadowsocks}> - <SettingsListbox.SplitOption.Item> + <SettingsListbox.SplitOption.Item + aria-description={messages.pgettext( + 'accessibility', + 'Use the right arrow key to focus the settings button.', + )}> <FlexColumn> <SettingsListbox.SplitOption.Label> {messages.pgettext('wireguard-settings-view', 'Shadowsocks')} @@ -87,7 +91,11 @@ export function ObfuscationSettings() { /> </SettingsListbox.SplitOption> <SettingsListbox.SplitOption value={ObfuscationType.udp2tcp}> - <SettingsListbox.SplitOption.Item> + <SettingsListbox.SplitOption.Item + aria-description={messages.pgettext( + 'accessibility', + 'Use the right arrow key to focus the settings button.', + )}> <FlexColumn> <SettingsListbox.SplitOption.Label> {messages.pgettext('wireguard-settings-view', 'UDP-over-TCP')} diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-option/components/listbox-option-trigger/ListboxOptionTrigger.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-option/components/listbox-option-trigger/ListboxOptionTrigger.tsx index 383a0aac32..4c049ec695 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-option/components/listbox-option-trigger/ListboxOptionTrigger.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-option/components/listbox-option-trigger/ListboxOptionTrigger.tsx @@ -91,6 +91,7 @@ export const ListboxOptionTrigger = ({ children, ...props }: ListboxOptionTrigge return ( <StyledListItemOptionTrigger ref={triggerRef} + data-option role="option" aria-selected={selected} aria-disabled={disabled} diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/ListboxOptions.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/ListboxOptions.tsx index 6af21a10cb..902cd8d790 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/ListboxOptions.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/ListboxOptions.tsx @@ -1,33 +1,20 @@ import React from 'react'; +import { useRovingFocus } from '../../../../hooks'; import { useListboxContext } from '../../'; -import { useHandleKeyboardNavigation } from './hooks'; -import { getInitialOption, getOptions } from './utils'; export type ListboxOptionsProps = { children: React.ReactNode[]; }; export function ListboxOptions({ children }: ListboxOptionsProps) { - const { labelId, optionsRef, setFocusedIndex } = useListboxContext(); - const [tabIndex, setTabIndex] = React.useState<number>(0); - - const handleFocus = React.useCallback( - (event: React.FocusEvent) => { - if (!optionsRef.current?.isSameNode(event.target)) return; - - const options = getOptions(optionsRef.current); - - const initialOption = getInitialOption(options); - if (initialOption) { - setTabIndex(-1); - initialOption.focus(); - } - }, - [optionsRef], - ); - - const handleKeyboardNavigation = useHandleKeyboardNavigation(); + const { labelId, optionsRef, focusedIndex, setFocusedIndex } = useListboxContext(); + const { handleFocus, handleKeyboardNavigation, handleBlur, tabIndex } = useRovingFocus({ + focusedIndex, + optionsRef, + setFocusedIndex, + selector: '[data-option="true"]:not([aria-disabled="true"])', + }); const onKeyDown = React.useCallback( (event: React.KeyboardEvent) => { @@ -36,20 +23,6 @@ export function ListboxOptions({ children }: ListboxOptionsProps) { [handleKeyboardNavigation], ); - const handleBlur = React.useCallback( - (event: React.FocusEvent<HTMLUListElement>) => { - const container = optionsRef.current; - const nextFocus = event.relatedTarget as Node | null; - - // If focus moves outside the listbox - if (!container || !nextFocus || !container.contains(nextFocus)) { - setFocusedIndex(undefined); - setTabIndex(0); - } - }, - [optionsRef, setFocusedIndex], - ); - return ( <ul ref={optionsRef} diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/hooks/index.ts deleted file mode 100644 index 64b446899b..0000000000 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/hooks/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './useHandleKeyboardNavigation'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/hooks/useFocusOptionByIndex.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/hooks/useFocusOptionByIndex.ts deleted file mode 100644 index 8e75d70912..0000000000 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/hooks/useFocusOptionByIndex.ts +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react'; - -import { useListboxContext } from '../../../ListboxContext'; -import { getOptions } from '../utils'; - -export const useFocusOptionByIndex = () => { - const { setFocusedIndex, optionsRef } = useListboxContext(); - return React.useCallback( - (index: number) => { - const options = getOptions(optionsRef.current); - setFocusedIndex(index); - const option = options[index]; - option.focus(); - }, - [optionsRef, setFocusedIndex], - ); -}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/hooks/useGetInitialFocusIndex.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/hooks/useGetInitialFocusIndex.ts deleted file mode 100644 index 43205589cd..0000000000 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/hooks/useGetInitialFocusIndex.ts +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; - -import { useListboxContext } from '../../../ListboxContext'; -import { getOptions, getSelectedOptionIndex } from '../utils'; - -export const useGetInitialFocusIndex = () => { - const { focusedIndex, optionsRef } = useListboxContext(); - return React.useCallback(() => { - const options = getOptions(optionsRef.current); - if (focusedIndex !== undefined) { - return focusedIndex; - } - const selectedOptionIndex = getSelectedOptionIndex(options); - if (selectedOptionIndex !== -1) { - return selectedOptionIndex; - } - return 0; - }, [focusedIndex, optionsRef]); -}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/hooks/useHandleKeyboardNavigation.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/hooks/useHandleKeyboardNavigation.ts deleted file mode 100644 index ba7b61bd67..0000000000 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/hooks/useHandleKeyboardNavigation.ts +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react'; - -import { useListboxContext } from '../../../'; -import { getOptions } from '../utils'; -import { useFocusOptionByIndex } from './useFocusOptionByIndex'; -import { useGetInitialFocusIndex } from './useGetInitialFocusIndex'; - -export const useHandleKeyboardNavigation = () => { - const { optionsRef } = useListboxContext(); - const getInitialFocusIndex = useGetInitialFocusIndex(); - const focusOptionByIndex = useFocusOptionByIndex(); - - return React.useCallback( - (event: React.KeyboardEvent) => { - const options = getOptions(optionsRef.current); - - const initialFocusedIndex = getInitialFocusIndex(); - - if (event.key === 'ArrowUp') { - event.preventDefault(); - if (initialFocusedIndex > 0) { - const newFocusedIndex = initialFocusedIndex - 1; - focusOptionByIndex(newFocusedIndex); - } - } else if (event.key === 'ArrowDown') { - event.preventDefault(); - if (initialFocusedIndex < options.length - 1) { - const newFocusedIndex = initialFocusedIndex + 1; - focusOptionByIndex(newFocusedIndex); - } - } else if (event.key === 'Home') { - event.preventDefault(); - focusOptionByIndex(0); - } else if (event.key === 'End') { - event.preventDefault(); - focusOptionByIndex(options.length - 1); - } - }, - [focusOptionByIndex, getInitialFocusIndex, optionsRef], - ); -}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/utils/get-options.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/utils/get-options.ts deleted file mode 100644 index d37647936c..0000000000 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/utils/get-options.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const getOptions = (container: HTMLElement | null) => { - const options = container?.querySelectorAll<HTMLElement>( - '[role="option"]:not([aria-disabled="true"])', - ); - - if (options) { - return Array.from(options); - } - - return []; -}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/index.ts new file mode 100644 index 0000000000..e75fe95d06 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './use-exclusive-task'; +export * from './use-roving-focus'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/hooks/index.ts new file mode 100644 index 0000000000..b5c80ee105 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/hooks/index.ts @@ -0,0 +1 @@ +export * from './use-handle-options-keyboard-navigation'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/hooks/use-handle-options-keyboard-navigation/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/hooks/use-handle-options-keyboard-navigation/hooks/index.ts new file mode 100644 index 0000000000..e0ca5fa2e3 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/hooks/use-handle-options-keyboard-navigation/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './use-focus-option-by-index'; +export * from './use-get-initial-focus-index'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/hooks/use-handle-options-keyboard-navigation/hooks/use-focus-option-by-index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/hooks/use-handle-options-keyboard-navigation/hooks/use-focus-option-by-index.ts new file mode 100644 index 0000000000..6b68003df9 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/hooks/use-handle-options-keyboard-navigation/hooks/use-focus-option-by-index.ts @@ -0,0 +1,23 @@ +import React from 'react'; + +import { getOptions } from '../../../utils'; + +export const useFocusOptionByIndex = <T extends HTMLElement>({ + optionsRef, + setFocusedIndex, + selector, +}: { + optionsRef: React.RefObject<T | null>; + setFocusedIndex: (index: number) => void; + selector: string; +}) => { + return React.useCallback( + (index: number) => { + const options = getOptions(optionsRef.current, selector); + setFocusedIndex(index); + const option = options[index]; + option.focus(); + }, + [optionsRef, selector, setFocusedIndex], + ); +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/hooks/use-handle-options-keyboard-navigation/hooks/use-get-initial-focus-index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/hooks/use-handle-options-keyboard-navigation/hooks/use-get-initial-focus-index.ts new file mode 100644 index 0000000000..1c0070fba0 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/hooks/use-handle-options-keyboard-navigation/hooks/use-get-initial-focus-index.ts @@ -0,0 +1,25 @@ +import React from 'react'; + +import { getOptions, getSelectedOptionIndex } from '../../../utils'; + +export const useGetInitialFocusIndex = <T extends HTMLElement>({ + focusedIndex, + optionsRef, + selector, +}: { + focusedIndex?: number; + optionsRef: React.RefObject<T | null>; + selector: string; +}) => { + return React.useCallback(() => { + const options = getOptions(optionsRef.current, selector); + if (focusedIndex !== undefined) { + return focusedIndex; + } + const selectedOptionIndex = getSelectedOptionIndex(options); + if (selectedOptionIndex !== -1) { + return selectedOptionIndex; + } + return 0; + }, [focusedIndex, optionsRef, selector]); +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/hooks/use-handle-options-keyboard-navigation/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/hooks/use-handle-options-keyboard-navigation/index.ts new file mode 100644 index 0000000000..b5c80ee105 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/hooks/use-handle-options-keyboard-navigation/index.ts @@ -0,0 +1 @@ +export * from './use-handle-options-keyboard-navigation'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/hooks/use-handle-options-keyboard-navigation/use-handle-options-keyboard-navigation.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/hooks/use-handle-options-keyboard-navigation/use-handle-options-keyboard-navigation.ts new file mode 100644 index 0000000000..5b39b71434 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/hooks/use-handle-options-keyboard-navigation/use-handle-options-keyboard-navigation.ts @@ -0,0 +1,59 @@ +import React from 'react'; + +import { getOptions } from '../../utils'; +import { useFocusOptionByIndex, useGetInitialFocusIndex } from './hooks'; + +export type KeyboardNavigationOrientation = 'horizontal' | 'vertical'; + +export const useHandleOptionsKeyboardNavigation = <T extends HTMLElement>({ + optionsRef, + focusedIndex, + setFocusedIndex, + orientation = 'vertical', + selector, +}: { + optionsRef: React.RefObject<T | null>; + focusedIndex?: number; + setFocusedIndex: (index: number) => void; + orientation?: KeyboardNavigationOrientation; + selector: string; +}) => { + const getInitialFocusIndex = useGetInitialFocusIndex({ optionsRef, focusedIndex, selector }); + const focusOptionByIndex = useFocusOptionByIndex({ + optionsRef, + setFocusedIndex, + selector, + }); + + const nextKey = orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight'; + const previousKey = orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft'; + + return React.useCallback( + (event: React.KeyboardEvent) => { + const options = getOptions(optionsRef.current, selector); + + const initialFocusedIndex = getInitialFocusIndex(); + + if (event.key === previousKey) { + event.preventDefault(); + if (initialFocusedIndex > 0) { + const newFocusedIndex = initialFocusedIndex - 1; + focusOptionByIndex(newFocusedIndex); + } + } else if (event.key === nextKey) { + event.preventDefault(); + if (initialFocusedIndex < options.length - 1) { + const newFocusedIndex = initialFocusedIndex + 1; + focusOptionByIndex(newFocusedIndex); + } + } else if (event.key === 'Home') { + event.preventDefault(); + focusOptionByIndex(0); + } else if (event.key === 'End') { + event.preventDefault(); + focusOptionByIndex(options.length - 1); + } + }, + [focusOptionByIndex, getInitialFocusIndex, nextKey, optionsRef, previousKey, selector], + ); +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/index.ts new file mode 100644 index 0000000000..4fe1d09b41 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/index.ts @@ -0,0 +1 @@ +export * from './use-roving-focus'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/use-roving-focus.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/use-roving-focus.ts new file mode 100644 index 0000000000..b7f0b264e0 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/use-roving-focus.ts @@ -0,0 +1,61 @@ +import React from 'react'; + +import { type KeyboardNavigationOrientation, useHandleOptionsKeyboardNavigation } from './hooks'; +import { getInitialOption, getOptions } from './utils'; + +export type UseRovingFocusProps<T extends HTMLElement> = { + optionsRef: React.RefObject<T | null>; + focusedIndex?: number; + setFocusedIndex: React.Dispatch<React.SetStateAction<number | undefined>>; + selector: string; + orientation?: KeyboardNavigationOrientation; +}; + +export function useRovingFocus<T extends HTMLElement>({ + optionsRef, + focusedIndex, + setFocusedIndex, + selector, + orientation = 'vertical', +}: UseRovingFocusProps<T>) { + const [tabIndex, setTabIndex] = React.useState<number>(0); + const handleFocus = React.useCallback( + (event: React.FocusEvent) => { + if (!optionsRef.current?.isSameNode(event.target)) return; + + const options = getOptions(optionsRef.current, selector); + + const initialOption = getInitialOption(options); + if (initialOption) { + // Prevent the container from being tabbable once an option has focus + setTabIndex(-1); + initialOption.focus(); + } + }, + [optionsRef, selector], + ); + + const handleKeyboardNavigation = useHandleOptionsKeyboardNavigation({ + optionsRef, + setFocusedIndex, + focusedIndex, + selector, + orientation, + }); + + const handleBlur = React.useCallback( + (event: React.FocusEvent<T>) => { + const container = optionsRef.current; + const nextFocus = event.relatedTarget as Node | null; + + // If focus moves outside the container + if (!container || !nextFocus || !container.contains(nextFocus)) { + setFocusedIndex(undefined); + setTabIndex(0); + } + }, + [optionsRef, setFocusedIndex], + ); + + return { tabIndex, handleFocus, handleKeyboardNavigation, handleBlur, optionsRef }; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/utils/get-initial-option.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/utils/get-initial-option.ts index e2278b5237..e2278b5237 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/utils/get-initial-option.ts +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/utils/get-initial-option.ts diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/utils/get-is-option-selected.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/utils/get-is-option-selected.ts index 6433e20232..6433e20232 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/utils/get-is-option-selected.ts +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/utils/get-is-option-selected.ts diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/utils/get-options.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/utils/get-options.ts new file mode 100644 index 0000000000..0754a72631 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/utils/get-options.ts @@ -0,0 +1,9 @@ +export const getOptions = (container: HTMLElement | null, selector: string) => { + const options = container?.querySelectorAll<HTMLElement>(selector); + + if (options) { + return Array.from(options); + } + + return []; +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/utils/get-selected-option-index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/utils/get-selected-option-index.ts index 2534ef46d1..2534ef46d1 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/utils/get-selected-option-index.ts +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/utils/get-selected-option-index.ts diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/utils/get-selected-option.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/utils/get-selected-option.ts index dfed49aae7..dfed49aae7 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/utils/get-selected-option.ts +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/utils/get-selected-option.ts diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/utils/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/utils/index.ts index cbd0929620..fc4f9891f9 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/utils/index.ts +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/utils/index.ts @@ -1,4 +1,5 @@ -export * from './get-initial-option'; +export * from './get-is-option-selected'; export * from './get-options'; +export * from './get-initial-option'; export * from './get-selected-option-index'; export * from './get-selected-option'; |
