diff options
| author | Oliver <oliver@mohlin.dev> | 2025-09-02 06:26:59 +0200 |
|---|---|---|
| committer | Tobias Järvelöv <tobias.jarvelov@mullvad.net> | 2025-09-22 12:35:43 +0200 |
| commit | 6c449dbe3d333c560650c448eabac4d64ffd584b (patch) | |
| tree | 594e03f7a8af24afa9d70470b38614bea5901ed0 | |
| parent | abdeb6f9d403c1c69ade32c09fc3bc66862e5000 (diff) | |
| download | mullvadvpn-6c449dbe3d333c560650c448eabac4d64ffd584b.tar.xz mullvadvpn-6c449dbe3d333c560650c448eabac4d64ffd584b.zip | |
Add TextField component
12 files changed, 182 insertions, 0 deletions
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/TextField.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/TextField.tsx new file mode 100644 index 0000000000..0f79deff75 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/TextField.tsx @@ -0,0 +1,26 @@ +import React from 'react'; + +import { FlexColumn } from '../flex-column'; +import { TextFieldInput, TextFieldLabel, TextFieldProvider } from './components'; + +export type TextFieldProps = React.PropsWithChildren<{ + invalid?: boolean; + value?: string; + disabled?: boolean; +}>; + +function TextField({ children, ...props }: TextFieldProps) { + const labelId = React.useId(); + return ( + <TextFieldProvider labelId={labelId} {...props}> + <FlexColumn $gap="tiny">{children}</FlexColumn> + </TextFieldProvider> + ); +} + +const TextFieldNamespace = Object.assign(TextField, { + Input: TextFieldInput, + Label: TextFieldLabel, +}); + +export { TextFieldNamespace as TextField }; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/components/index.ts new file mode 100644 index 0000000000..dd393f2b40 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/components/index.ts @@ -0,0 +1,3 @@ +export * from './text-field-context'; +export * from './text-field-input'; +export * from './text-field-label'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/components/text-field-context/TextFieldContext.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/components/text-field-context/TextFieldContext.tsx new file mode 100644 index 0000000000..abd9a8147e --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/components/text-field-context/TextFieldContext.tsx @@ -0,0 +1,25 @@ +import { createContext, ReactNode, useContext } from 'react'; + +import { TextFieldProps } from '../../TextField'; + +type TextFieldContextType = TextFieldProps & { + labelId: string; +}; + +const TextFieldContext = createContext<TextFieldContextType | undefined>(undefined); + +type TextFieldProviderProps = TextFieldContextType & { + children: ReactNode; +}; + +export const TextFieldProvider = ({ children, ...props }: TextFieldProviderProps) => { + return <TextFieldContext.Provider value={props}>{children}</TextFieldContext.Provider>; +}; + +export const useTextFieldContext = (): TextFieldContextType => { + const context = useContext(TextFieldContext); + if (!context) { + throw new Error('useTextField must be used within a TextFieldProvider'); + } + return context; +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/components/text-field-context/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/components/text-field-context/index.ts new file mode 100644 index 0000000000..f3ee72b76e --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/components/text-field-context/index.ts @@ -0,0 +1 @@ +export * from './TextFieldContext'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/components/text-field-input/TextFieldInput.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/components/text-field-input/TextFieldInput.tsx new file mode 100644 index 0000000000..293a3320fa --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/components/text-field-input/TextFieldInput.tsx @@ -0,0 +1,53 @@ +import styled, { css } from 'styled-components'; + +import { colors, Radius, spacings } from '../../../../foundations'; +import { useTextFieldContext } from '../text-field-context/TextFieldContext'; + +export type TextFieldInputProps = React.ComponentPropsWithRef<'input'>; + +export const StyledTextField = styled.input<{ $disabled?: boolean; $invalid?: boolean }>` + ${({ $invalid, $disabled }) => { + // TODO: Add color to tokens + const borderColor = $invalid ? 'rgba(235, 93, 64, 1)' : colors.chalkAlpha40; + const backgroundColor = $disabled ? colors.whiteOnDarkBlue5 : colors.blue40; + const color = $disabled ? colors.whiteAlpha20 : colors.white; + return css` + all: unset; + font-family: var(--font-family-open-sans); + background-color: ${backgroundColor}; + padding: ${spacings.small}; + border: 1px solid ${colors.whiteAlpha60}; + border-color: ${borderColor}; + font-size: 14px; + border-radius: ${Radius.radius4}; + color: ${color}; + width: 100%; + + &&::placeholder { + color: ${colors.whiteAlpha60}; + } + + &&:not(:disabled):not([aria-invalid='true']):hover { + border-color: ${colors.chalkAlpha80}; + } + &&:not(:disabled):not([aria-invalid='true']):focus-visible { + border-color: ${colors.chalk}; + } + `; + }} +`; + +export function TextFieldInput(props: TextFieldInputProps) { + const { disabled, invalid } = useTextFieldContext(); + + return ( + <StyledTextField + type="text" + $disabled={disabled} + disabled={disabled} + $invalid={invalid} + aria-invalid={invalid} + {...props} + /> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/components/text-field-input/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/components/text-field-input/index.ts new file mode 100644 index 0000000000..80197f01a4 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/components/text-field-input/index.ts @@ -0,0 +1 @@ +export * from './TextFieldInput'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/components/text-field-label/TextFieldLabel.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/components/text-field-label/TextFieldLabel.tsx new file mode 100644 index 0000000000..26ff388db0 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/components/text-field-label/TextFieldLabel.tsx @@ -0,0 +1,9 @@ +import { Text, TextProps } from '../../../typography'; +import { useTextFieldContext } from '../text-field-context'; + +export type TextFieldLabelProps = TextProps; + +export const TextFieldLabel = (props: TextFieldLabelProps) => { + const { labelId } = useTextFieldContext(); + return <Text id={labelId} variant="labelTiny" {...props} />; +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/components/text-field-label/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/components/text-field-label/index.ts new file mode 100644 index 0000000000..e8e12f5750 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/components/text-field-label/index.ts @@ -0,0 +1 @@ +export * from './TextFieldLabel'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/hooks/index.ts new file mode 100644 index 0000000000..106172db8b --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/hooks/index.ts @@ -0,0 +1 @@ +export * from './use-text-field'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/hooks/use-text-field/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/hooks/use-text-field/index.ts new file mode 100644 index 0000000000..d59cdb842f --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/hooks/use-text-field/index.ts @@ -0,0 +1 @@ +export * from './useTextField'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/hooks/use-text-field/useTextField.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/hooks/use-text-field/useTextField.tsx new file mode 100644 index 0000000000..b58fa08599 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/hooks/use-text-field/useTextField.tsx @@ -0,0 +1,59 @@ +import React from 'react'; + +export type UseTextFieldOptions = { + inputRef: React.RefObject<HTMLInputElement | null>; + defaultValue?: string; + validate?: (value: string) => boolean; + format?: (value: string) => string; +}; + +export type UseTextFieldReturn = { + value: string; + invalid: boolean; + dirty: boolean; + reset: () => void; + focus: () => void; + blur: () => void; + handleChange: (event: React.ChangeEvent<HTMLInputElement>) => void; + inputRef: React.RefObject<HTMLInputElement | null>; +}; + +export function useTextField({ + inputRef, + defaultValue, + format, + validate, +}: UseTextFieldOptions): UseTextFieldReturn { + const [value, setValue] = React.useState(defaultValue ?? ''); + const [invalid, setInvalid] = React.useState(validate ? !validate(value) : false); + const [dirty, setDirty] = React.useState(false); + + const reset = React.useCallback(() => { + setValue(defaultValue ?? ''); + setInvalid(false); + setDirty(false); + }, [defaultValue]); + + const focus = React.useCallback(() => { + inputRef.current?.focus(); + }, [inputRef]); + + const blur = React.useCallback(() => { + inputRef.current?.blur(); + }, [inputRef]); + + const handleChange = React.useCallback( + (event: React.ChangeEvent<HTMLInputElement>) => { + const newValue = event.target.value; + const formattedValue = format ? format(newValue) : newValue; + const invalid = validate ? !validate(formattedValue) : false; + setInvalid(invalid); + setValue(formattedValue); + setDirty(true); + }, + + [format, validate], + ); + + return { value, invalid, dirty, reset, blur, focus, handleChange, inputRef }; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/index.ts new file mode 100644 index 0000000000..e9364df1c1 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/index.ts @@ -0,0 +1,2 @@ +export * from './TextField'; +export * from './hooks'; |
