summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorTobias Järvelöv <tobias.jarvelov@mullvad.net>2025-10-07 11:41:42 +0200
committerTobias Järvelöv <tobias.jarvelov@mullvad.net>2025-10-07 11:41:42 +0200
commitcc8a363014eb2f424a3f31d4499b1cc5b28d7191 (patch)
tree82bbb41edcc517f880be11dbbbd1110a18826e24
parent93c2133fe37d18a5114b0cb5fef60c6dc8a9465f (diff)
parentf29ef10f4802c3c8f67c2660a4374035cffcfe57 (diff)
downloadmullvadvpn-cc8a363014eb2f424a3f31d4499b1cc5b28d7191.tar.xz
mullvadvpn-cc8a363014eb2f424a3f31d4499b1cc5b28d7191.zip
Merge branch 'fix-split-listbox-option-focus'
-rw-r--r--desktop/packages/mullvad-vpn/locales/messages.pot4
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/input-option/components/input-option-trigger/InputOptionTrigger.tsx1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/split-option/SplitOption.tsx23
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/split-option/components/split-option-item/SplitOptionItem.tsx2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/split-option/components/split-option-navigate-button/SplitOptionNavigateButton.tsx3
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/wireguard-settings/components/obfuscation-settings/ObfuscationSettings.tsx12
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-option/components/listbox-option-trigger/ListboxOptionTrigger.tsx1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/ListboxOptions.tsx43
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/hooks/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/hooks/useFocusOptionByIndex.ts17
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/hooks/useGetInitialFocusIndex.ts19
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/hooks/useHandleKeyboardNavigation.ts41
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/utils/get-options.ts11
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/hooks/index.ts2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/hooks/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/hooks/use-handle-options-keyboard-navigation/hooks/index.ts2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/hooks/use-handle-options-keyboard-navigation/hooks/use-focus-option-by-index.ts23
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/hooks/use-handle-options-keyboard-navigation/hooks/use-get-initial-focus-index.ts25
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/hooks/use-handle-options-keyboard-navigation/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/hooks/use-handle-options-keyboard-navigation/use-handle-options-keyboard-navigation.ts59
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/use-roving-focus.ts61
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/utils/get-initial-option.ts (renamed from desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/utils/get-initial-option.ts)0
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/utils/get-is-option-selected.ts (renamed from desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/utils/get-is-option-selected.ts)0
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/utils/get-options.ts9
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/utils/get-selected-option-index.ts (renamed from desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/utils/get-selected-option-index.ts)0
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/utils/get-selected-option.ts (renamed from desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/utils/get-selected-option.ts)0
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/hooks/use-roving-focus/utils/index.ts (renamed from desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/utils/index.ts)3
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';