diff options
| author | Oliver <oliver@mohlin.dev> | 2025-09-02 06:47:17 +0200 |
|---|---|---|
| committer | Tobias Järvelöv <tobias.jarvelov@mullvad.net> | 2025-09-22 12:35:43 +0200 |
| commit | a59f1975dda0afeccc289b2e271c6577fa7a5dc9 (patch) | |
| tree | 9d56fe1842340478d6972d2f762848b45c30cb09 | |
| parent | c9465d3403f3acbcaafdab9db83b2a4bdd86ceb7 (diff) | |
| download | mullvadvpn-a59f1975dda0afeccc289b2e271c6577fa7a5dc9.tar.xz mullvadvpn-a59f1975dda0afeccc289b2e271c6577fa7a5dc9.zip | |
Add InputListboxOption component
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'; |
