summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorOliver <oliver@mohlin.dev>2025-09-02 06:47:17 +0200
committerTobias Järvelöv <tobias.jarvelov@mullvad.net>2025-09-22 12:35:43 +0200
commita59f1975dda0afeccc289b2e271c6577fa7a5dc9 (patch)
tree9d56fe1842340478d6972d2f762848b45c30cb09
parentc9465d3403f3acbcaafdab9db83b2a4bdd86ceb7 (diff)
downloadmullvadvpn-a59f1975dda0afeccc289b2e271c6577fa7a5dc9.tar.xz
mullvadvpn-a59f1975dda0afeccc289b2e271c6577fa7a5dc9.zip
Add InputListboxOption component
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/input-listbox-option/InputListboxOption.tsx35
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/input-listbox-option/components/index.ts4
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/input-listbox-option/components/input-listbox-option-context/InputListboxOptionContext.tsx33
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/input-listbox-option/components/input-listbox-option-context/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/input-listbox-option/components/input-listbox-option-input/InputListboxOptionInput.tsx74
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/input-listbox-option/components/input-listbox-option-input/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/input-listbox-option/components/input-listbox-option-label/InputListboxOptionLabel.tsx16
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/input-listbox-option/components/input-listbox-option-label/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/input-listbox-option/components/input-listbox-option-trigger/InputListboxOptionTrigger.tsx64
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/input-listbox-option/components/input-listbox-option-trigger/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/input-listbox-option/index.ts1
11 files changed, 231 insertions, 0 deletions
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/input-listbox-option/InputListboxOption.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/input-listbox-option/InputListboxOption.tsx
new file mode 100644
index 0000000000..68899fec46
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/input-listbox-option/InputListboxOption.tsx
@@ -0,0 +1,35 @@
+import React from 'react';
+
+import { ListboxOptionProps } from '../../lib/components/listbox/components';
+import { Listbox } from '../../lib/components/listbox/Listbox';
+import {
+ InputListboxOptionInput,
+ InputListboxOptionLabel,
+ InputListboxOptionProvider,
+ InputListboxOptionTrigger,
+} from './components';
+
+export type InputListboxOptionProps<T> = ListboxOptionProps<T>;
+
+function InputListboxOption<T>({ children, ...props }: InputListboxOptionProps<T>) {
+ const inputRef = React.useRef<HTMLInputElement>(null);
+ const labelId = React.useId();
+ return (
+ <InputListboxOptionProvider inputRef={inputRef} labelId={labelId}>
+ <Listbox.Option level={1} {...props}>
+ <InputListboxOptionTrigger>
+ <Listbox.Option.Item>
+ <Listbox.Option.Content>{children}</Listbox.Option.Content>
+ </Listbox.Option.Item>
+ </InputListboxOptionTrigger>
+ </Listbox.Option>
+ </InputListboxOptionProvider>
+ );
+}
+
+const InputListboxOptionNamespace = Object.assign(InputListboxOption, {
+ Label: InputListboxOptionLabel,
+ Input: InputListboxOptionInput,
+});
+
+export { InputListboxOptionNamespace as InputListboxOption };
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/input-listbox-option/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/input-listbox-option/components/index.ts
new file mode 100644
index 0000000000..0dd9c17d78
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/input-listbox-option/components/index.ts
@@ -0,0 +1,4 @@
+export * from './input-listbox-option-input';
+export * from './input-listbox-option-label';
+export * from './input-listbox-option-context';
+export * from './input-listbox-option-trigger';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/input-listbox-option/components/input-listbox-option-context/InputListboxOptionContext.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/input-listbox-option/components/input-listbox-option-context/InputListboxOptionContext.tsx
new file mode 100644
index 0000000000..dd3c5f5760
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/input-listbox-option/components/input-listbox-option-context/InputListboxOptionContext.tsx
@@ -0,0 +1,33 @@
+import React, { createContext, ReactNode, useContext } from 'react';
+
+type InputListboxOptionContextType = {
+ inputRef: React.RefObject<HTMLInputElement | null>;
+ labelId: string | undefined;
+};
+
+const InputListboxOptionContextContext = createContext<InputListboxOptionContextType | undefined>(
+ undefined,
+);
+
+type InputListboxOptionProviderProps = {
+ children: ReactNode;
+} & InputListboxOptionContextType;
+
+export const InputListboxOptionProvider = ({
+ children,
+ ...props
+}: InputListboxOptionProviderProps) => {
+ return (
+ <InputListboxOptionContextContext.Provider value={props}>
+ {children}
+ </InputListboxOptionContextContext.Provider>
+ );
+};
+
+export const useInputListboxOption = (): InputListboxOptionContextType => {
+ const context = useContext(InputListboxOptionContextContext);
+ if (!context) {
+ throw new Error('useInputListboxOption must be used within a InputListboxOptionProvider');
+ }
+ return context;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/input-listbox-option/components/input-listbox-option-context/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/input-listbox-option/components/input-listbox-option-context/index.ts
new file mode 100644
index 0000000000..f59a35a516
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/input-listbox-option/components/input-listbox-option-context/index.ts
@@ -0,0 +1 @@
+export * from './InputListboxOptionContext';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/input-listbox-option/components/input-listbox-option-input/InputListboxOptionInput.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/input-listbox-option/components/input-listbox-option-input/InputListboxOptionInput.tsx
new file mode 100644
index 0000000000..1ccd2ff078
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/input-listbox-option/components/input-listbox-option-input/InputListboxOptionInput.tsx
@@ -0,0 +1,74 @@
+import React from 'react';
+
+import { ListItem } from '../../../../lib/components/list-item';
+import { ListItemTextFieldInputProps } from '../../../../lib/components/list-item/components/list-item-text-field/list-item-text-field-input';
+import { useListboxContext } from '../../../../lib/components/listbox/components';
+import { useTextField } from '../../../../lib/components/text-field';
+import { useInputListboxOption } from '../input-listbox-option-context';
+
+type InputListboxOptionInputProps = {
+ initialValue?: string;
+ validate?: (value: string) => boolean;
+ format?: (value: string) => string;
+} & ListItemTextFieldInputProps;
+
+export function InputListboxOptionInput({
+ initialValue,
+ validate,
+ format,
+ ...props
+}: InputListboxOptionInputProps) {
+ const { onValueChange: listBoxOnValueChange, value: listBoxValue } = useListboxContext<
+ string | undefined
+ >();
+
+ const { inputRef, labelId } = useInputListboxOption();
+
+ const { value, invalid, dirty, blur, handleChange, reset } = useTextField({
+ inputRef,
+ defaultValue: initialValue,
+ validate,
+ format,
+ });
+
+ React.useEffect(() => {
+ if (listBoxValue !== 'custom') {
+ reset();
+ }
+ }, [listBoxValue, reset]);
+
+ const handleBlur = React.useCallback(async () => {
+ if (listBoxOnValueChange && !invalid && dirty) {
+ await listBoxOnValueChange(value);
+ }
+ if (invalid) {
+ reset();
+ }
+ }, [dirty, invalid, listBoxOnValueChange, reset, value]);
+
+ const handleSubmit = React.useCallback(
+ async (event: React.FormEvent) => {
+ event.preventDefault();
+ if (listBoxOnValueChange && !invalid) {
+ await listBoxOnValueChange?.(value);
+ blur();
+ }
+ },
+ [blur, invalid, listBoxOnValueChange, value],
+ );
+
+ return (
+ <ListItem.TextField invalid={invalid} onSubmit={handleSubmit}>
+ <ListItem.TextField.Input
+ ref={inputRef}
+ value={value}
+ aria-labelledby={labelId}
+ tabIndex={-1}
+ inputMode="numeric"
+ onBlur={handleBlur}
+ onChange={handleChange}
+ {...props}
+ />
+ </ListItem.TextField>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/input-listbox-option/components/input-listbox-option-input/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/input-listbox-option/components/input-listbox-option-input/index.ts
new file mode 100644
index 0000000000..fcb33ef37f
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/input-listbox-option/components/input-listbox-option-input/index.ts
@@ -0,0 +1 @@
+export * from './InputListboxOptionInput';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/input-listbox-option/components/input-listbox-option-label/InputListboxOptionLabel.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/input-listbox-option/components/input-listbox-option-label/InputListboxOptionLabel.tsx
new file mode 100644
index 0000000000..14e480762d
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/input-listbox-option/components/input-listbox-option-label/InputListboxOptionLabel.tsx
@@ -0,0 +1,16 @@
+import { Listbox } from '../../../../lib/components/listbox/Listbox';
+import { useInputListboxOption } from '../input-listbox-option-context';
+
+export type InputListboxOptionLabelProps = {
+ children: React.ReactNode;
+};
+
+export function InputListboxOptionLabel({ children }: InputListboxOptionLabelProps) {
+ const { labelId } = useInputListboxOption();
+ return (
+ <Listbox.Option.Group>
+ <Listbox.Option.Icon icon="checkmark" />
+ <Listbox.Option.Label id={labelId}>{children}</Listbox.Option.Label>
+ </Listbox.Option.Group>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/input-listbox-option/components/input-listbox-option-label/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/input-listbox-option/components/input-listbox-option-label/index.ts
new file mode 100644
index 0000000000..31b3f095e7
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/input-listbox-option/components/input-listbox-option-label/index.ts
@@ -0,0 +1 @@
+export * from './InputListboxOptionLabel';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/input-listbox-option/components/input-listbox-option-trigger/InputListboxOptionTrigger.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/input-listbox-option/components/input-listbox-option-trigger/InputListboxOptionTrigger.tsx
new file mode 100644
index 0000000000..574d707ee4
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/input-listbox-option/components/input-listbox-option-trigger/InputListboxOptionTrigger.tsx
@@ -0,0 +1,64 @@
+import React from 'react';
+import styled from 'styled-components';
+
+import { useListboxContext } from '../../../../lib/components/listbox/components';
+import { ListboxOptionTriggerProps } from '../../../../lib/components/listbox/components/listbox-option/components';
+import { useListboxOptionContext } from '../../../../lib/components/listbox/components/listbox-option/components/listbox-option-context/ListboxOptionContext';
+import { StyledListItemOptionItem } from '../../../../lib/components/listbox/components/listbox-option/components/listbox-option-item/ListboxOptionItem';
+import { colors } from '../../../../lib/foundations';
+import { useInputListboxOption } from '../input-listbox-option-context';
+
+export type InputListboxOptionTriggerProps = ListboxOptionTriggerProps;
+
+export const StyledListItemOptionTrigger = styled.li`
+ &&[aria-selected='true'] {
+ &:hover {
+ ${StyledListItemOptionItem} {
+ background-color: ${colors.green};
+ }
+ }
+ &:active {
+ ${StyledListItemOptionItem} {
+ background-color: ${colors.green};
+ }
+ }
+ }
+`;
+
+export const InputListboxOptionTrigger = ({
+ children,
+ ...props
+}: InputListboxOptionTriggerProps) => {
+ const { value } = useListboxOptionContext();
+ const { inputRef } = useInputListboxOption();
+
+ const { value: selectedValue, focusedValue, setFocusedValue } = useListboxContext();
+ const selected = value === selectedValue;
+ const focused = value === focusedValue;
+
+ const tabIndex = selected && focusedValue === undefined ? 0 : -1;
+
+ React.useEffect(() => {
+ if (focused && inputRef.current) {
+ inputRef.current.focus();
+ }
+ }, [value, focused, inputRef]);
+
+ const handleFocus = React.useCallback(() => {
+ if (!focused) {
+ setFocusedValue(value);
+ inputRef.current?.focus();
+ }
+ }, [focused, inputRef, setFocusedValue, value]);
+
+ return (
+ <StyledListItemOptionTrigger
+ role="option"
+ aria-selected={selected}
+ tabIndex={tabIndex}
+ onFocus={handleFocus}
+ {...props}>
+ {children}
+ </StyledListItemOptionTrigger>
+ );
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/input-listbox-option/components/input-listbox-option-trigger/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/input-listbox-option/components/input-listbox-option-trigger/index.ts
new file mode 100644
index 0000000000..1785479636
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/input-listbox-option/components/input-listbox-option-trigger/index.ts
@@ -0,0 +1 @@
+export * from './InputListboxOptionTrigger';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/input-listbox-option/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/input-listbox-option/index.ts
new file mode 100644
index 0000000000..e7ed819a25
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/input-listbox-option/index.ts
@@ -0,0 +1 @@
+export * from './InputListboxOption';