mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-08-14 17:02:37 +01:00
Init AxoSearchField and AxoPasswordField
Co-authored-by: Jamie <113370520+jamiebuilds-signal@users.noreply.github.com>
This commit is contained in:
co-authored by
Jamie
parent
611767845d
commit
61109e722a
@@ -27,6 +27,10 @@
|
||||
"messageformat": "Close",
|
||||
"description": "Axo Design System > Dialog Component > Close Button > Generic accessibility label"
|
||||
},
|
||||
"icu:AxoPasswordField.Reveal": {
|
||||
"messageformat": "Show Password",
|
||||
"description": "Axo Design System > Password Field Component > Reveal Button > Generic accessibility label"
|
||||
},
|
||||
"icu:AxoTextField.Clear": {
|
||||
"messageformat": "Clear",
|
||||
"description": "Axo Design System > Text Field Component > Clear Button > Generic accessibility label"
|
||||
|
||||
@@ -1,627 +0,0 @@
|
||||
// Copyright 2026 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import { memo, useCallback, useId, useMemo, useRef } from 'react';
|
||||
import type { FC, InputEvent, MouseEvent, ReactNode, RefObject } from 'react';
|
||||
import { mergeRefs } from '@react-aria/utils';
|
||||
import { AxoSymbol } from './AxoSymbol.dom.tsx';
|
||||
import { tw } from './tw.dom.tsx';
|
||||
import { assert } from './_internal/assert.std.tsx';
|
||||
import { utf8 } from './_internal/utf8.std.ts';
|
||||
import {
|
||||
createStrictContext,
|
||||
useStrictContext,
|
||||
} from './_internal/StrictContext.dom.tsx';
|
||||
import { useAxoIntl } from './_internal/AxoIntl.dom.tsx';
|
||||
import { variants } from './_internal/variants.dom.tsx';
|
||||
|
||||
/**
|
||||
* A single-line text input with optional icons, action buttons, and
|
||||
* character/byte limiting.
|
||||
*
|
||||
* @example Anatomy
|
||||
* ```tsx
|
||||
* <AxoTextField.Root>
|
||||
* <AxoTextField.Input />
|
||||
* <AxoTextField.Separator />
|
||||
* <AxoTextField.Input />
|
||||
* <AxoTextField.Action />
|
||||
* </AxoTextField.Root>
|
||||
* ```
|
||||
* @see {@link https://w3c.github.io/aria/#textbox | `textbox` role - WAI-ARIA 1.3}
|
||||
* @see {@link https://w3c.github.io/aria/#group | `group` role - WAI-ARIA 1.3}
|
||||
*/
|
||||
export namespace AxoTextField {
|
||||
/**
|
||||
* <AxoTextField.Root>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/** @internal */
|
||||
type RootContextType = Readonly<{
|
||||
disabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
}>;
|
||||
|
||||
/** @internal */
|
||||
const RootContext = createStrictContext<RootContextType>('AxoTextField.Root');
|
||||
|
||||
/**
|
||||
* The preferred width of the text field.
|
||||
*
|
||||
* TODO(jamie): Get real sizes from design
|
||||
*
|
||||
* - `xs` – 200px
|
||||
* - `sm` – 300px
|
||||
* - `md` – 400px
|
||||
* - `lg` – 500px
|
||||
* - `xl` – 600px
|
||||
* - `full` – stretches to fill the container (default)
|
||||
*
|
||||
* All sizes shrink to fit the container if it is narrower than the minimum.
|
||||
*/
|
||||
export type Width = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||
|
||||
export type RootProps = Readonly<{
|
||||
/** Leading icon displayed before the input. */
|
||||
symbol?: AxoSymbol.IconName;
|
||||
/** Controls the width of the entire field. Defaults to `full`. */
|
||||
width?: Width;
|
||||
/** Disables all inputs and actions within the field. */
|
||||
disabled?: boolean;
|
||||
/** Makes all inputs within the field read-only. */
|
||||
readOnly?: boolean;
|
||||
/** Should be `Input`, `Action`, and/or `Separator` elements. */
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Container for the text field. Provides shared `disabled`/`readOnly` state
|
||||
* to child inputs and actions.
|
||||
*
|
||||
* @example Basic usage
|
||||
* ```tsx
|
||||
* <AxoTextField.Root>
|
||||
* <AxoTextField.Input
|
||||
* placeholder="First name"
|
||||
* value={value}
|
||||
* onValueChange={setValue}
|
||||
* maxGraphemes={26}
|
||||
* maxBytes={128}
|
||||
* showCount
|
||||
* showClear
|
||||
* />
|
||||
* </AxoTextField.Root>
|
||||
* ```
|
||||
*
|
||||
* @example Segmented field with icon and action
|
||||
* ```tsx
|
||||
* <AxoTextField.Root symbol="at">
|
||||
* <AxoTextField.Input placeholder="Username" sizing="grow" ... />
|
||||
* <AxoTextField.Separator />
|
||||
* <AxoTextField.Input placeholder="00" sizing="fit" ... />
|
||||
* <AxoTextField.Action label="Insert emoji" symbol="emoji" onClick={openEmojiPicker} />
|
||||
* </AxoTextField.Root>
|
||||
* ```
|
||||
*/
|
||||
export const Root: FC<RootProps> = memo(props => {
|
||||
const { disabled, readOnly } = props;
|
||||
|
||||
const context = useMemo((): RootContextType => {
|
||||
return { disabled, readOnly };
|
||||
}, [disabled, readOnly]);
|
||||
|
||||
return (
|
||||
<RootContext.Provider value={context}>
|
||||
<Group width={props.width ?? 'full'}>
|
||||
{props.symbol != null && <Icon symbol={props.symbol} />}
|
||||
{props.children}
|
||||
</Group>
|
||||
</RootContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
Root.displayName = 'AxoTextField.Root';
|
||||
|
||||
/**
|
||||
* <AxoTextField.Group>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const GroupWidthStyles = variants<Width>('AxoTextField.Width', {
|
||||
xs: tw('w-[calc-size(fit-content,min(max(200px,size),100%))]'),
|
||||
sm: tw('w-[calc-size(fit-content,min(max(300px,size),100%))]'),
|
||||
md: tw('w-[calc-size(fit-content,min(max(400px,size),100%))]'),
|
||||
lg: tw('w-[calc-size(fit-content,min(max(500px,size),100%))]'),
|
||||
xl: tw('w-[calc-size(fit-content,min(max(600px,size),100%))]'),
|
||||
full: tw('w-full'),
|
||||
});
|
||||
|
||||
/** @internal */
|
||||
type GroupProps = Readonly<{
|
||||
width: Width;
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/** @internal */
|
||||
const Group: FC<GroupProps> = memo(props => {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
className={tw(
|
||||
'group flex items-stretch',
|
||||
'overflow-hidden',
|
||||
GroupWidthStyles.get(props.width),
|
||||
'curved-lg bg-control',
|
||||
'border-[0.5px] border-primary',
|
||||
'shadow-elevation-0 shadow-no-outline',
|
||||
'placeholder:text-placeholder',
|
||||
'not-forced-colors:has-[input:focus]:axo-focus-ring',
|
||||
'forced-colors:border-[ButtonBorder] forced-colors:bg-[ButtonFace] forced-colors:text-[ButtonText]'
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Group.displayName = 'AxoTextField.Group';
|
||||
|
||||
/**
|
||||
* <AxoTextField.Input>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* How an `Input` sizes itself within the field group.
|
||||
* - `fixed`: Takes up all remaining space (default).
|
||||
* - `grow`: Expands with typed content, up to available space.
|
||||
* - `fit`: Shrinks to fit typed content, useful for segmented fields.
|
||||
*/
|
||||
export type Sizing = 'fixed' | 'grow' | 'fit';
|
||||
|
||||
export type InputProps = Readonly<{
|
||||
/** Ref to the underlying `<input>` element. */
|
||||
ref?: RefObject<HTMLInputElement | null>;
|
||||
/** Provide your own id for the `<input>` to target with a `<label>`. Auto-generated if omitted. */
|
||||
id?: string;
|
||||
/** Form field name for native form submissions. */
|
||||
name?: string;
|
||||
/** Placeholder text shown when the input is empty. */
|
||||
placeholder: string;
|
||||
/** How the input sizes itself within the field group. Defaults to `fixed`. */
|
||||
sizing?: Sizing;
|
||||
/** Controlled value of the input. */
|
||||
value: string;
|
||||
/** Called with the new value on every change. */
|
||||
onValueChange: (value: string) => void;
|
||||
/** Maximum number of Unicode grapheme clusters allowed. */
|
||||
maxGraphemes: number;
|
||||
/** Maximum number of UTF-8 bytes allowed. Should be ~4x the number of `maxGraphemes`. */
|
||||
maxBytes: number;
|
||||
/** Shows a remaining-character counter that appears as the limit is approached. */
|
||||
showCount?: boolean;
|
||||
/** Shows a clear button when the input has a value. */
|
||||
showClear?: boolean;
|
||||
/** Marks the input as required for form validation. */
|
||||
required?: boolean;
|
||||
/** Disables this input. Also disabled if `Root` has `disabled` set. */
|
||||
disabled?: boolean;
|
||||
/** Makes this input read-only. Also read-only if `Root` has `readOnly` set. */
|
||||
readOnly?: boolean;
|
||||
/** Focuses the input on mount. */
|
||||
autoFocus?: boolean;
|
||||
/** Enables or disables browser spell checking. */
|
||||
spellCheck?: boolean;
|
||||
/** Default is 'text', a normal text box. Can be used to input into a password field. */
|
||||
type?: 'text' | 'password';
|
||||
}>;
|
||||
|
||||
/** The text input field. Must be placed inside `Root`. */
|
||||
export const Input: FC<InputProps> = memo(props => {
|
||||
const { onValueChange, maxBytes, maxGraphemes } = props;
|
||||
const context = useStrictContext(RootContext);
|
||||
|
||||
const disabled = context.disabled === true || props.disabled === true;
|
||||
const readOnly = context.readOnly === true || props.readOnly === true;
|
||||
|
||||
const sizing = props.sizing ?? 'fixed';
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const mergedRef = mergeRefs(inputRef, props.ref);
|
||||
|
||||
const fallbackId = useId();
|
||||
const inputId = props.id ?? fallbackId;
|
||||
|
||||
const handleBeforeInput = useCallback(
|
||||
(event: InputEvent<HTMLInputElement>) => {
|
||||
const input = event.currentTarget;
|
||||
const current = input.value;
|
||||
|
||||
const start = input.selectionStart ?? current.length;
|
||||
const end = input.selectionEnd ?? start;
|
||||
|
||||
const prefix = current.substring(0, start);
|
||||
const suffix = current.substring(end);
|
||||
const inserted = event.data;
|
||||
|
||||
const updated = `${prefix}${inserted}${suffix}`;
|
||||
const updatedBytes = utf8.getByteLength(updated);
|
||||
const updatedGraphemes = utf8.getGraphemeCount(updated);
|
||||
|
||||
if (updatedBytes <= maxBytes && updatedGraphemes <= maxGraphemes) {
|
||||
return;
|
||||
}
|
||||
|
||||
const base = `${prefix}${suffix}`;
|
||||
const baseBytes = utf8.getByteLength(base);
|
||||
const baseGraphemes = utf8.getGraphemeCount(base);
|
||||
|
||||
let result = '';
|
||||
result += prefix;
|
||||
|
||||
const remainingBytes = maxBytes - baseBytes;
|
||||
const remainingChars = maxGraphemes - baseGraphemes;
|
||||
result += utf8.truncateBytesAndGraphemes(
|
||||
inserted,
|
||||
remainingBytes,
|
||||
remainingChars
|
||||
);
|
||||
|
||||
result += suffix;
|
||||
|
||||
// Simulate the input as if we had just enough room
|
||||
// for exactly the bytes we want to let through
|
||||
input.maxLength = result.length;
|
||||
requestAnimationFrame(() => {
|
||||
input.removeAttribute('maxlength'); // reset
|
||||
});
|
||||
},
|
||||
[maxBytes, maxGraphemes]
|
||||
);
|
||||
|
||||
const handleInput = useCallback(
|
||||
(event: InputEvent<HTMLInputElement>) => {
|
||||
onValueChange(
|
||||
utf8.truncateBytesAndGraphemes(
|
||||
event.currentTarget.value,
|
||||
maxBytes,
|
||||
maxGraphemes
|
||||
)
|
||||
);
|
||||
},
|
||||
[onValueChange, maxBytes, maxGraphemes]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={tw(
|
||||
'peer z-0 flex min-w-0 first:ps-2.5 last:pe-2.5',
|
||||
sizing !== 'fit' && 'grow',
|
||||
// prevent overlapping text-selection
|
||||
'peer-has-[input]:overflow-hidden'
|
||||
)}
|
||||
>
|
||||
{/* FIXME */}
|
||||
{/* oxlint-disable-next-line jsx-a11y/control-has-associated-label */}
|
||||
<input
|
||||
ref={mergedRef}
|
||||
id={inputId}
|
||||
type={props.type ?? 'text'}
|
||||
value={props.value}
|
||||
placeholder={props.placeholder ?? ''}
|
||||
required={props.required}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
onInput={handleInput}
|
||||
onBeforeInput={handleBeforeInput}
|
||||
autoFocus={props.autoFocus}
|
||||
spellCheck={props.spellCheck}
|
||||
className={tw(
|
||||
'min-w-0 grow',
|
||||
sizing === 'grow' && 'field-sizing-content',
|
||||
sizing === 'fit' && 'field-sizing-content shrink',
|
||||
|
||||
// allow text selection in full box
|
||||
'-ms-20 ps-20',
|
||||
'-mx-20 pe-20',
|
||||
|
||||
'py-1.5',
|
||||
'indent-1',
|
||||
'text-primary',
|
||||
'not-forced-colors:outline-none',
|
||||
'disabled:text-disabled'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{props.showCount && (
|
||||
<Count
|
||||
value={props.value}
|
||||
maxBytes={props.maxBytes}
|
||||
maxGraphemes={props.maxGraphemes}
|
||||
/>
|
||||
)}
|
||||
{props.showClear && (
|
||||
<Clear
|
||||
inputRef={inputRef}
|
||||
inputId={inputId}
|
||||
value={props.value}
|
||||
onValueChange={onValueChange}
|
||||
disabled={disabled || readOnly}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
Input.displayName = 'AxoTextField.Input';
|
||||
|
||||
/**
|
||||
* <AxoTextField.Icon>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/** @internal */
|
||||
type IconProps = Readonly<{
|
||||
symbol: AxoSymbol.IconName;
|
||||
}>;
|
||||
|
||||
/** @internal */
|
||||
const Icon: FC<IconProps> = memo(props => {
|
||||
return (
|
||||
<span
|
||||
className={tw(
|
||||
'pointer-events-none z-10 flex items-center justify-center text-secondary',
|
||||
'px-1 first:ps-2.5 last:pe-2.5'
|
||||
)}
|
||||
>
|
||||
<AxoSymbol.Icon size={16} symbol={props.symbol} label={null} />
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
Icon.displayName = 'AxoTextField.Icon';
|
||||
|
||||
/**
|
||||
* <AxoTextField.Count>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const SHOW_REMAINING_COUNT_THRESHOLD = 0.5;
|
||||
const WARN_REMAINING_COUNT_THRESHOLD = 0.25;
|
||||
|
||||
/** @internal */
|
||||
type CountProps = Readonly<{
|
||||
value: string;
|
||||
maxBytes: number;
|
||||
maxGraphemes: number;
|
||||
}>;
|
||||
|
||||
/** @internal */
|
||||
const Count: FC<CountProps> = memo(props => {
|
||||
const { value, maxBytes, maxGraphemes } = props;
|
||||
|
||||
const remainingCount = useMemo(() => {
|
||||
if (value.length === 0) {
|
||||
return maxGraphemes;
|
||||
}
|
||||
|
||||
const totalBytes = utf8.getByteLength(value);
|
||||
const totalGraphemes = utf8.getGraphemeCount(value);
|
||||
|
||||
const remainingBytes = maxBytes - totalBytes;
|
||||
const remainingChars = maxGraphemes - totalGraphemes;
|
||||
|
||||
if (remainingBytes > remainingChars) {
|
||||
return remainingChars;
|
||||
}
|
||||
|
||||
return remainingBytes;
|
||||
}, [value, maxBytes, maxGraphemes]);
|
||||
|
||||
const showRemainingCount = useMemo(() => {
|
||||
return remainingCount <= maxGraphemes * SHOW_REMAINING_COUNT_THRESHOLD;
|
||||
}, [maxGraphemes, remainingCount]);
|
||||
|
||||
const warnRemainingCount = useMemo(() => {
|
||||
return remainingCount <= maxGraphemes * WARN_REMAINING_COUNT_THRESHOLD;
|
||||
}, [maxGraphemes, remainingCount]);
|
||||
|
||||
if (!showRemainingCount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={tw(
|
||||
'pointer-events-none z-10 flex items-center',
|
||||
'px-1 first:ps-2.5 last:pe-2.5',
|
||||
'type-body-small tabular-nums',
|
||||
warnRemainingCount ? 'text-destructive' : 'text-secondary'
|
||||
)}
|
||||
>
|
||||
{remainingCount}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
Count.displayName = 'AxoTextField.Count';
|
||||
|
||||
/**
|
||||
* <AxoTextField.Clear>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/** @internal */
|
||||
type ClearProps = Readonly<{
|
||||
inputRef: RefObject<HTMLInputElement | null>;
|
||||
inputId: string;
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
disabled: boolean;
|
||||
}>;
|
||||
|
||||
/** @internal */
|
||||
const Clear: FC<ClearProps> = memo(props => {
|
||||
const { inputRef, value, onValueChange } = props;
|
||||
const intl = useAxoIntl();
|
||||
|
||||
const handleClear = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
onValueChange('');
|
||||
assert(inputRef.current).focus();
|
||||
},
|
||||
[inputRef, onValueChange]
|
||||
);
|
||||
|
||||
if (value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={intl.get('AxoTextField.Clear')}
|
||||
aria-controls={props.inputId}
|
||||
className={tw(
|
||||
'z-10',
|
||||
'px-0.5 first:ps-1.5 last:pe-1.5',
|
||||
'group/clear group-has-[input:placeholder-shown]:hidden',
|
||||
'outline-none'
|
||||
)}
|
||||
onClick={handleClear}
|
||||
disabled={props.disabled}
|
||||
>
|
||||
<span
|
||||
className={tw(
|
||||
'flex items-center justify-center',
|
||||
'p-0.5',
|
||||
'rounded-full',
|
||||
'text-secondary',
|
||||
'group-enabled/clear:group-hover/clear:text-primary',
|
||||
'group-enabled/clear:group-hover/clear:bg-surface-secondary',
|
||||
'group-focus-visible/clear:axo-focus-ring'
|
||||
)}
|
||||
>
|
||||
<AxoSymbol.Icon size={16} symbol="x" label={null} />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
Clear.displayName = 'AxoTextField.Clear';
|
||||
|
||||
/**
|
||||
* <AxoTextField.Action>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type ActionProps = Readonly<{
|
||||
/** Accessible label for the button describing the action to be taken, not the icon. */
|
||||
label: string;
|
||||
/** Icon to display inside the button. */
|
||||
symbol: AxoSymbol.IconName;
|
||||
/** Called when the button is clicked. */
|
||||
onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
|
||||
/** Overrides the `disabled` state from `Root` for this button only. */
|
||||
disabled?: boolean;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* An icon button placed inside a `Root`, typically used for supplementary
|
||||
* actions like inserting an emoji or opening a menu.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <AxoTextField.Root>
|
||||
* <AxoTextField.Input ... />
|
||||
* <AxoTextField.Action label="Insert emoji" symbol="emoji" onClick={openEmojiPicker} />
|
||||
* </AxoTextField.Root>
|
||||
* ```
|
||||
*/
|
||||
export const Action: FC<ActionProps> = memo(props => {
|
||||
const { onClick } = props;
|
||||
const context = useStrictContext(RootContext);
|
||||
|
||||
const disabled =
|
||||
context.disabled === true ||
|
||||
context.readOnly === true ||
|
||||
props.disabled === true;
|
||||
|
||||
const handleClick = useCallback(
|
||||
(event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
if (disabled) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
onClick?.(event);
|
||||
},
|
||||
[disabled, onClick]
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={props.label}
|
||||
aria-disabled={disabled}
|
||||
className={tw(
|
||||
'group/action z-10 outline-none',
|
||||
'first:ps-1 last:pe-1',
|
||||
'aria-disabled:cursor-default'
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<span
|
||||
className={tw(
|
||||
'flex items-center justify-center rounded-full p-1',
|
||||
'text-secondary',
|
||||
'group-not-aria-disabled/action:group-hover/action:text-primary',
|
||||
'group-not-aria-disabled/action:group-hover/action:bg-surface-secondary',
|
||||
'group-focus-visible/action:axo-focus-ring'
|
||||
)}
|
||||
>
|
||||
<AxoSymbol.Icon size={18} symbol={props.symbol} label={null} />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
Action.displayName = 'AxoTextField.Action';
|
||||
|
||||
/**
|
||||
* <AxoTextField.Separator>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* A vertical divider between segments in a multi-input field.
|
||||
*
|
||||
* @example Username + discriminator
|
||||
* ```tsx
|
||||
* <AxoTextField.Root symbol="at">
|
||||
* <AxoTextField.Input placeholder="Username" sizing="grow" ... />
|
||||
* <AxoTextField.Separator />
|
||||
* <AxoTextField.Input placeholder="00" sizing="fit" ... />
|
||||
* </AxoTextField.Root>
|
||||
* ```
|
||||
*/
|
||||
export const Separator: FC = memo(() => {
|
||||
return (
|
||||
<span className={tw('flex py-2 ps-3 pe-2')}>
|
||||
<span
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
className={tw(
|
||||
'w-px rounded-xs',
|
||||
// oxlint-disable-next-line better-tailwindcss/no-restricted-classes
|
||||
'bg-[#000]/12 dark:bg-[#FFF]/20' // should be "separator"
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
Separator.displayName = 'AxoTextField.Separator';
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export namespace AxoIntl {
|
||||
'AxoDialog.Back': 'Back',
|
||||
'AxoDialog.Close': 'Close',
|
||||
'AxoTextField.Clear': 'Clear',
|
||||
'AxoPasswordField.Reveal': 'Show Password',
|
||||
'AxoBadge.MaxOverflow': (max: number) => `${max}+`,
|
||||
};
|
||||
|
||||
|
||||
@@ -42,3 +42,17 @@ export function useStrictContext<T>(
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Similar to `useStrictContext()` but will return null if not wrapped.
|
||||
* Useful when some components require strict context but others don't.
|
||||
*/
|
||||
export function useStrictContextNullable<T>(
|
||||
context: StrictContext<T>
|
||||
): T | null {
|
||||
const value = useContext(context);
|
||||
if (value === EMPTY) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// Copyright 2026 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import type { Meta } from '@storybook/react';
|
||||
import { AxoPasswordField } from './AxoPasswordField.dom.tsx';
|
||||
|
||||
export default {
|
||||
title: 'Axo/Fields/AxoPasswordField',
|
||||
} satisfies Meta;
|
||||
|
||||
export function Basic(): ReactNode {
|
||||
const [value, setValue] = useState('');
|
||||
return (
|
||||
<AxoPasswordField.Root
|
||||
width="lg"
|
||||
placeholder="Password"
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
maxBytes={64}
|
||||
maxGraphemes={64}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Copyright 2026 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import type { FC, RefObject } from 'react';
|
||||
import { memo, useState } from 'react';
|
||||
import { AxoBaseField } from './_AxoBaseField.dom.tsx';
|
||||
import { useAxoIntl } from '../_internal/AxoIntl.dom.tsx';
|
||||
|
||||
export namespace AxoPasswordField {
|
||||
/**
|
||||
* <AxoPasswordField.Root>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type AutoComplete = 'current-password' | 'new-password';
|
||||
|
||||
export type RootProps = Readonly<{
|
||||
/** Ref to the underlying `<input>` element. */
|
||||
ref?: RefObject<HTMLInputElement | null>;
|
||||
/** Controls the width of the entire field. Defaults to `full`. */
|
||||
width?: AxoBaseField.Width;
|
||||
/** Controlled value of the input. */
|
||||
value: string;
|
||||
/** Called with the new value on every change. */
|
||||
onValueChange: (value: string) => void;
|
||||
/** Maximum number of Unicode grapheme clusters allowed. */
|
||||
maxGraphemes: number;
|
||||
/** Maximum number of UTF-8 bytes allowed. Should be ~4x the number of `maxGraphemes`. */
|
||||
maxBytes: number;
|
||||
/** Placeholder text shown when the input is empty. */
|
||||
placeholder: string;
|
||||
/** Hint for form autofill feature. */
|
||||
autoComplete: AutoComplete;
|
||||
/** Focuses the input on mount. */
|
||||
autoFocus?: boolean;
|
||||
/** Disables this input. */
|
||||
disabled?: boolean;
|
||||
/** Hide the reveal button. */
|
||||
hideReveal?: boolean;
|
||||
}>;
|
||||
|
||||
export const Root: FC<RootProps> = memo(props => {
|
||||
const intl = useAxoIntl();
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
return (
|
||||
<AxoBaseField.Container variant="text" width={props.width}>
|
||||
<AxoBaseField.Segment
|
||||
value={props.value}
|
||||
onValueChange={props.onValueChange}
|
||||
maxGraphemes={props.maxGraphemes}
|
||||
maxBytes={props.maxBytes}
|
||||
disabled={props.disabled}
|
||||
>
|
||||
<AxoBaseField.Input
|
||||
ref={props.ref}
|
||||
type={revealed ? 'text' : 'password'}
|
||||
inputMode="text"
|
||||
autoComplete={props.autoComplete}
|
||||
placeholder={props.placeholder}
|
||||
autoFocus={props.autoFocus}
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</AxoBaseField.Segment>
|
||||
{!props.hideReveal && (
|
||||
<AxoBaseField.Reveal
|
||||
label={intl.get('AxoPasswordField.Reveal')}
|
||||
revealed={revealed}
|
||||
onRevealedChange={setRevealed}
|
||||
/>
|
||||
)}
|
||||
</AxoBaseField.Container>
|
||||
);
|
||||
});
|
||||
|
||||
Root.displayName = 'AxoPasswordField.Root';
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright 2026 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import type { Meta } from '@storybook/react';
|
||||
import { AxoSearchField } from './AxoSearchField.dom.tsx';
|
||||
|
||||
export default {
|
||||
title: 'Axo/Fields/AxoSearchField',
|
||||
} satisfies Meta;
|
||||
|
||||
export function Basic(): ReactNode {
|
||||
const [value, setValue] = useState('');
|
||||
return (
|
||||
<AxoSearchField.Root
|
||||
width="lg"
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
placeholder="Search"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Copyright 2026 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import type { FC } from 'react';
|
||||
import { memo } from 'react';
|
||||
import { AxoBaseField } from './_AxoBaseField.dom.tsx';
|
||||
import { UnitBytes } from '@signalapp/types';
|
||||
|
||||
export namespace AxoSearchField {
|
||||
/**
|
||||
* <AxoSearchField.Root>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type RootProps = Readonly<{
|
||||
/** Controls the width of the entire field. Defaults to `full`. */
|
||||
width?: AxoBaseField.Width;
|
||||
/** Disables this input. */
|
||||
disabled?: boolean;
|
||||
/** Placeholder text shown when the input is empty. */
|
||||
placeholder: string;
|
||||
/** Controlled value of the input. */
|
||||
value: string;
|
||||
/** Called with the new value on every change. */
|
||||
onValueChange: (value: string) => void;
|
||||
}>;
|
||||
|
||||
export const Root: FC<RootProps> = memo(props => {
|
||||
return (
|
||||
<AxoBaseField.Container variant="search" width={props.width}>
|
||||
<AxoBaseField.Icon symbol="search" />
|
||||
<AxoBaseField.Segment
|
||||
value={props.value}
|
||||
onValueChange={props.onValueChange}
|
||||
disabled={props.disabled}
|
||||
maxBytes={UnitBytes.KILOBYTE}
|
||||
maxGraphemes={UnitBytes.KILOBYTE}
|
||||
>
|
||||
<AxoBaseField.Input type="search" placeholder={props.placeholder} />
|
||||
<AxoBaseField.Clear />
|
||||
</AxoBaseField.Segment>
|
||||
</AxoBaseField.Container>
|
||||
);
|
||||
});
|
||||
|
||||
Root.displayName = 'AxoSearchField.Root';
|
||||
}
|
||||
@@ -4,12 +4,12 @@ import type { ReactNode } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { Meta } from '@storybook/react';
|
||||
import { AxoTextField } from './AxoTextField.dom.tsx';
|
||||
import { tw } from './tw.dom.tsx';
|
||||
import type { AxoSymbol } from './AxoSymbol.dom.tsx';
|
||||
import { assert } from './_internal/assert.std.tsx';
|
||||
import { tw } from '../tw.dom.tsx';
|
||||
import type { AxoSymbol } from '../AxoSymbol.dom.tsx';
|
||||
import { assert } from '../_internal/assert.std.tsx';
|
||||
|
||||
export default {
|
||||
title: 'Axo/AxoTextField',
|
||||
title: 'Axo/Fields/AxoTextField',
|
||||
} satisfies Meta;
|
||||
|
||||
function Stack(props: { children: ReactNode }) {
|
||||
@@ -75,7 +75,7 @@ type TemplateInputProps = Readonly<{
|
||||
placeholder?: string;
|
||||
showCount?: boolean;
|
||||
showClear?: boolean;
|
||||
sizing?: AxoTextField.Sizing;
|
||||
sizing?: AxoTextField.InputSizing;
|
||||
disabled?: boolean;
|
||||
}>;
|
||||
|
||||
@@ -111,7 +111,7 @@ type TemplateProps = Readonly<{
|
||||
showClear?: boolean;
|
||||
leading?: ReactNode;
|
||||
trailing?: ReactNode;
|
||||
sizing?: AxoTextField.Sizing;
|
||||
sizing?: AxoTextField.InputSizing;
|
||||
disabled?: boolean;
|
||||
}>;
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
// Copyright 2026 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import { memo } from 'react';
|
||||
import type { FC, MouseEvent, ReactNode, RefObject } from 'react';
|
||||
import type { AxoSymbol } from '../AxoSymbol.dom.tsx';
|
||||
import { AxoBaseField } from './_AxoBaseField.dom.tsx';
|
||||
|
||||
/**
|
||||
* A single-line text input with optional icons, action buttons, and
|
||||
* character/byte limiting.
|
||||
*
|
||||
* @example Anatomy
|
||||
* ```tsx
|
||||
* <AxoTextField.Root>
|
||||
* <AxoTextField.Input />
|
||||
* <AxoTextField.Separator />
|
||||
* <AxoTextField.Input />
|
||||
* <AxoTextField.Action />
|
||||
* </AxoTextField.Root>
|
||||
* ```
|
||||
* @see {@link https://w3c.github.io/aria/#textbox | `textbox` role - WAI-ARIA 1.3}
|
||||
* @see {@link https://w3c.github.io/aria/#group | `group` role - WAI-ARIA 1.3}
|
||||
*/
|
||||
export namespace AxoTextField {
|
||||
/**
|
||||
* <AxoTextField.Root>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* The preferred width of the text field.
|
||||
*
|
||||
* TODO(jamie): Get real sizes from design
|
||||
*
|
||||
* - `xs` – 200px
|
||||
* - `sm` – 300px
|
||||
* - `md` – 400px
|
||||
* - `lg` – 500px
|
||||
* - `xl` – 600px
|
||||
* - `full` – stretches to fill the container (default)
|
||||
*
|
||||
* All sizes shrink to fit the container if it is narrower than the minimum.
|
||||
*/
|
||||
export type Width = AxoBaseField.Width;
|
||||
|
||||
export type RootProps = Readonly<{
|
||||
/** Leading icon displayed before the input. */
|
||||
symbol?: AxoSymbol.IconName;
|
||||
/** Controls the width of the entire field. Defaults to `full`. */
|
||||
width?: AxoBaseField.Width;
|
||||
/** Disables all inputs and actions within the field. */
|
||||
disabled?: boolean;
|
||||
/** Makes all inputs within the field read-only. */
|
||||
readOnly?: boolean;
|
||||
/** Should be `Input`, `Action`, and/or `Separator` elements. */
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Container for the text field. Provides shared `disabled`/`readOnly` state
|
||||
* to child inputs and actions.
|
||||
*
|
||||
* @example Basic usage
|
||||
* ```tsx
|
||||
* <AxoTextField.Root>
|
||||
* <AxoTextField.Input
|
||||
* placeholder="First name"
|
||||
* value={value}
|
||||
* onValueChange={setValue}
|
||||
* maxGraphemes={26}
|
||||
* maxBytes={128}
|
||||
* showCount
|
||||
* showClear
|
||||
* />
|
||||
* </AxoTextField.Root>
|
||||
* ```
|
||||
*
|
||||
* @example Segmented field with icon and action
|
||||
* ```tsx
|
||||
* <AxoTextField.Root symbol="at">
|
||||
* <AxoTextField.Input placeholder="Username" sizing="grow" ... />
|
||||
* <AxoTextField.Separator />
|
||||
* <AxoTextField.Input placeholder="00" sizing="fit" ... />
|
||||
* <AxoTextField.Action label="Insert emoji" symbol="emoji" onClick={openEmojiPicker} />
|
||||
* </AxoTextField.Root>
|
||||
* ```
|
||||
*/
|
||||
export const Root: FC<RootProps> = memo(props => {
|
||||
return (
|
||||
<AxoBaseField.Group disabled={props.disabled} readOnly={props.readOnly}>
|
||||
<AxoBaseField.Container variant="text" width={props.width}>
|
||||
{props.symbol != null && <AxoBaseField.Icon symbol={props.symbol} />}
|
||||
{props.children}
|
||||
</AxoBaseField.Container>
|
||||
</AxoBaseField.Group>
|
||||
);
|
||||
});
|
||||
|
||||
Root.displayName = 'AxoTextField.Root';
|
||||
|
||||
/**
|
||||
* <AxoTextField.Input>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type InputSizing = AxoBaseField.InputSizing;
|
||||
|
||||
export type InputProps = Readonly<{
|
||||
/** Ref to the underlying `<input>` element. */
|
||||
ref?: RefObject<HTMLInputElement | null>;
|
||||
/** Provide your own id for the `<input>` to target with a `<label>`. Auto-generated if omitted. */
|
||||
id?: string;
|
||||
/** Form field name for native form submissions. */
|
||||
name?: string;
|
||||
/** Placeholder text shown when the input is empty. */
|
||||
placeholder: string;
|
||||
/** How the input sizes itself within the field group. Defaults to `fixed`. */
|
||||
sizing?: InputSizing;
|
||||
/** Controlled value of the input. */
|
||||
value: string;
|
||||
/** Called with the new value on every change. */
|
||||
onValueChange: (value: string) => void;
|
||||
/** Maximum number of Unicode grapheme clusters allowed. */
|
||||
maxGraphemes: number;
|
||||
/** Maximum number of UTF-8 bytes allowed. Should be ~4x the number of `maxGraphemes`. */
|
||||
maxBytes: number;
|
||||
/** Shows a remaining-character counter that appears as the limit is approached. */
|
||||
showCount?: boolean;
|
||||
/** Shows a clear button when the input has a value. */
|
||||
showClear?: boolean;
|
||||
/** Marks the input as required for form validation. */
|
||||
required?: boolean;
|
||||
/** Disables this input. Also disabled if `Root` has `disabled` set. */
|
||||
disabled?: boolean;
|
||||
/** Makes this input read-only. Also read-only if `Root` has `readOnly` set. */
|
||||
readOnly?: boolean;
|
||||
/** Focuses the input on mount. */
|
||||
autoFocus?: boolean;
|
||||
/** Enables or disables browser spell checking. */
|
||||
spellCheck?: boolean;
|
||||
/** Prefer using the specific axo component for the input type (See: <AxoPasswordField> or <AxoSearchField>) */
|
||||
type?: never;
|
||||
}>;
|
||||
|
||||
/** The text input field. Must be placed inside `Root`. */
|
||||
export const Input: FC<InputProps> = memo(props => {
|
||||
return (
|
||||
<AxoBaseField.Segment
|
||||
id={props.id}
|
||||
value={props.value}
|
||||
onValueChange={props.onValueChange}
|
||||
maxGraphemes={props.maxGraphemes}
|
||||
maxBytes={props.maxBytes}
|
||||
disabled={props.disabled}
|
||||
readOnly={props.readOnly}
|
||||
>
|
||||
<AxoBaseField.Input
|
||||
type="text" // Note: Do not customize here, prefer creating more specific axo components
|
||||
ref={props.ref}
|
||||
name={props.name}
|
||||
placeholder={props.placeholder}
|
||||
sizing={props.sizing}
|
||||
required={props.required}
|
||||
autoFocus={props.autoFocus}
|
||||
spellCheck={props.spellCheck}
|
||||
/>
|
||||
{props.showCount && (
|
||||
<AxoBaseField.RemainingCount
|
||||
maxGraphemes={props.maxGraphemes}
|
||||
maxBytes={props.maxBytes}
|
||||
/>
|
||||
)}
|
||||
{props.showClear && <AxoBaseField.Clear />}
|
||||
</AxoBaseField.Segment>
|
||||
);
|
||||
});
|
||||
|
||||
Input.displayName = 'AxoTextField.Input';
|
||||
|
||||
/**
|
||||
* <AxoTextField.Action>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type ActionProps = Readonly<{
|
||||
/** Accessible label for the button describing the action to be taken, not the icon. */
|
||||
label: string;
|
||||
/** Icon to display inside the button. */
|
||||
symbol: AxoSymbol.IconName;
|
||||
/** Called when the button is clicked. */
|
||||
onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
|
||||
/** Overrides the `disabled` state from `Root` for this button only. */
|
||||
disabled?: boolean;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* An icon button placed inside a `Root`, typically used for supplementary
|
||||
* actions like inserting an emoji or opening a menu.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <AxoTextField.Root>
|
||||
* <AxoTextField.Input ... />
|
||||
* <AxoTextField.Action label="Insert emoji" symbol="emoji" onClick={openEmojiPicker} />
|
||||
* </AxoTextField.Root>
|
||||
* ```
|
||||
*/
|
||||
export const Action: FC<ActionProps> = memo(props => {
|
||||
return (
|
||||
<AxoBaseField.Action
|
||||
label={props.label}
|
||||
symbol={props.symbol}
|
||||
onClick={props.onClick}
|
||||
disabled={props.disabled}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
Action.displayName = 'AxoTextField.Action';
|
||||
|
||||
/**
|
||||
* <AxoTextField.Separator>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* A vertical divider between segments in a multi-input field.
|
||||
*
|
||||
* @example Username + discriminator
|
||||
* ```tsx
|
||||
* <AxoTextField.Root symbol="at">
|
||||
* <AxoTextField.Input placeholder="Username" sizing="grow" ... />
|
||||
* <AxoTextField.Separator />
|
||||
* <AxoTextField.Input placeholder="00" sizing="fit" ... />
|
||||
* </AxoTextField.Root>
|
||||
* ```
|
||||
*/
|
||||
export const Separator: FC = memo(() => {
|
||||
return <AxoBaseField.Separator />;
|
||||
});
|
||||
|
||||
Separator.displayName = 'AxoTextField.Separator';
|
||||
}
|
||||
@@ -0,0 +1,788 @@
|
||||
// Copyright 2026 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import { memo, useCallback, useId, useMemo, useRef } from 'react';
|
||||
import type { FC, InputEvent, MouseEvent, ReactNode, RefObject } from 'react';
|
||||
import { mergeRefs } from '@react-aria/utils';
|
||||
import { AxoSymbol } from '../AxoSymbol.dom.tsx';
|
||||
import { tw } from '../tw.dom.tsx';
|
||||
import { assert } from '../_internal/assert.std.tsx';
|
||||
import { utf8 } from '../_internal/utf8.std.ts';
|
||||
import {
|
||||
createStrictContext,
|
||||
useStrictContext,
|
||||
useStrictContextNullable,
|
||||
} from '../_internal/StrictContext.dom.tsx';
|
||||
import { useAxoIntl } from '../_internal/AxoIntl.dom.tsx';
|
||||
import { variants } from '../_internal/variants.dom.tsx';
|
||||
|
||||
export namespace AxoBaseField {
|
||||
/**
|
||||
* The type of input control to render.
|
||||
* Note: Only include `type`'s relevant to text inputs.
|
||||
*/
|
||||
export type Type =
|
||||
| 'email'
|
||||
| 'number'
|
||||
| 'password'
|
||||
| 'search'
|
||||
| 'tel'
|
||||
| 'text'
|
||||
| 'url';
|
||||
|
||||
/**
|
||||
* Specifies what type of virtual keyboard to use.
|
||||
* Note: Only include `inputMode`'s relevant to text inputs.
|
||||
*/
|
||||
export type InputMode =
|
||||
| 'none'
|
||||
| 'text'
|
||||
| 'tel'
|
||||
| 'url'
|
||||
| 'email'
|
||||
| 'numeric'
|
||||
| 'decimal'
|
||||
| 'search';
|
||||
|
||||
/**
|
||||
* Hint for form autofill feature.
|
||||
*/
|
||||
export type AutoComplete = AutoFill;
|
||||
|
||||
/**
|
||||
* Toggle auto-correction of spelling and punctuation errors.
|
||||
*/
|
||||
export type AutoCorrect = 'on' | 'off';
|
||||
|
||||
/**
|
||||
* Toggle whether inputted text is automatically captialized, and if so, in what manner.
|
||||
*/
|
||||
export type AutoCapitalize =
|
||||
| 'on'
|
||||
| 'off'
|
||||
| 'sentences'
|
||||
| 'words'
|
||||
| 'characters'
|
||||
| 'none';
|
||||
|
||||
/**
|
||||
* Define what action label (or icon) to present for the enter key on virtual keyboards.
|
||||
*/
|
||||
export type EnterKeyHint =
|
||||
| 'enter'
|
||||
| 'done'
|
||||
| 'go'
|
||||
| 'next'
|
||||
| 'previous'
|
||||
| 'search'
|
||||
| 'send';
|
||||
|
||||
export type KeyboardInputAttrs = Readonly<{
|
||||
/** Specifies what type of virtual keyboard to use. */
|
||||
inputMode?: InputMode;
|
||||
/** Hint for form autofill feature. */
|
||||
autoComplete?: AutoComplete;
|
||||
/** Toggle auto-correction of spelling and punctuation errors. */
|
||||
autoCorrect?: AutoCorrect;
|
||||
/** Toggle whether inputted text is automatically captialized, and if so, in what manner. */
|
||||
autoCapitalize?: AutoCapitalize;
|
||||
/** Define what action label (or icon) to present for the enter key on virtual keyboards. */
|
||||
enterKeyHint?: EnterKeyHint;
|
||||
/** Enables or disables browser spell checking. */
|
||||
spellCheck?: boolean;
|
||||
}>;
|
||||
|
||||
export type TextValidationInputAttrs = Readonly<{
|
||||
/** Min string length (in UTF-16 code units) that the user can input. */
|
||||
minLength?: number;
|
||||
/** Max string length (in UTF-16 code units) that the user can input. */
|
||||
maxLength?: number;
|
||||
/** A regex that the input's value must match. */
|
||||
pattern?: string;
|
||||
/**
|
||||
* The default width of the input based on character size.
|
||||
* A useful visual hint for the expected length of an input.
|
||||
*/
|
||||
size?: number;
|
||||
}>;
|
||||
|
||||
export type NumberValidationInputAttrs = Readonly<{
|
||||
/** Min number in the range of permitted values */
|
||||
min?: number;
|
||||
/** Max number in the range of permitted values */
|
||||
max?: number;
|
||||
/** Specifies the granularity that the value must adhere to. */
|
||||
step?: number;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* <AxoBaseField.Group>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
type GroupContextType = Readonly<{
|
||||
disabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
}>;
|
||||
|
||||
const GroupContext =
|
||||
createStrictContext<GroupContextType>('AxoBaseField.Group');
|
||||
|
||||
export type GroupProps = Readonly<{
|
||||
/** Disables all inputs and actions within the field. */
|
||||
disabled?: boolean;
|
||||
/** Makes all inputs within the field read-only. */
|
||||
readOnly?: boolean;
|
||||
/** Should be `Segment`, `Action`, and/or `Separator` elements. */
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
export const Group: FC<GroupProps> = memo(props => {
|
||||
const { disabled, readOnly } = props;
|
||||
|
||||
const context = useMemo((): GroupContextType => {
|
||||
return { disabled, readOnly };
|
||||
}, [disabled, readOnly]);
|
||||
|
||||
return (
|
||||
<GroupContext.Provider value={context}>
|
||||
{props.children}
|
||||
</GroupContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
Group.displayName = 'AxoBaseField.Group';
|
||||
|
||||
/**
|
||||
* <AxoBaseField.Container>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Visual style of the field.
|
||||
*/
|
||||
export type Variant = 'text' | 'search';
|
||||
|
||||
const ContainerVariants = variants<Variant>('AxoBaseField.Variant', {
|
||||
text: tw(
|
||||
'curved-lg bg-control',
|
||||
'border-[0.5px] border-primary',
|
||||
'shadow-elevation-0 shadow-no-outline'
|
||||
),
|
||||
search: tw('rounded-full bg-primary'),
|
||||
});
|
||||
|
||||
/**
|
||||
* The preferred width of the text field.
|
||||
*
|
||||
* TODO(jamie): Get real sizes from design
|
||||
*
|
||||
* - `xs` – 200px
|
||||
* - `sm` – 300px
|
||||
* - `md` – 400px
|
||||
* - `lg` – 500px
|
||||
* - `xl` – 600px
|
||||
* - `full` – stretches to fill the container (default)
|
||||
*
|
||||
* All sizes shrink to fit the container if it is narrower than the minimum.
|
||||
*/
|
||||
export type Width = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||
|
||||
export const ContainerWidths = variants<Width>('AxoBaseField.Width', {
|
||||
xs: tw('w-[calc-size(fit-content,min(max(200px,size),100%))]'),
|
||||
sm: tw('w-[calc-size(fit-content,min(max(300px,size),100%))]'),
|
||||
md: tw('w-[calc-size(fit-content,min(max(400px,size),100%))]'),
|
||||
lg: tw('w-[calc-size(fit-content,min(max(500px,size),100%))]'),
|
||||
xl: tw('w-[calc-size(fit-content,min(max(600px,size),100%))]'),
|
||||
full: tw('w-full'),
|
||||
});
|
||||
|
||||
type ContainerContextType = Readonly<{
|
||||
variant: Variant;
|
||||
}>;
|
||||
|
||||
const ContainerContext = createStrictContext<ContainerContextType>(
|
||||
'AxoBaseField.Container'
|
||||
);
|
||||
|
||||
export type ContainerProps = Readonly<{
|
||||
/** Visual style of the field. */
|
||||
variant: Variant;
|
||||
/** Controls the width of the entire field. Defaults to `full`. */
|
||||
width?: Width;
|
||||
/** Should be `Group`, `Icon`, `Segment`, `Separator`, and/or `Action` elements. */
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
export const Container: FC<ContainerProps> = memo(props => {
|
||||
const { variant } = props;
|
||||
const width = props.width ?? 'full';
|
||||
|
||||
const context = useMemo((): ContainerContextType => {
|
||||
return { variant };
|
||||
}, [variant]);
|
||||
|
||||
return (
|
||||
<ContainerContext value={context}>
|
||||
<div
|
||||
role="group"
|
||||
className={tw(
|
||||
'group flex items-stretch',
|
||||
'overflow-hidden',
|
||||
ContainerWidths.get(width),
|
||||
ContainerVariants.get(props.variant),
|
||||
'placeholder:text-placeholder',
|
||||
'not-forced-colors:has-[input:focus]:axo-focus-ring',
|
||||
'forced-colors:border-[ButtonBorder] forced-colors:bg-[ButtonFace] forced-colors:text-[ButtonText]'
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</ContainerContext>
|
||||
);
|
||||
});
|
||||
|
||||
Container.displayName = 'AxoBaseField.Container';
|
||||
|
||||
/**
|
||||
* <AxoBaseField.Icon>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type IconProps = Readonly<{
|
||||
symbol: AxoSymbol.IconName;
|
||||
}>;
|
||||
|
||||
export const Icon: FC<IconProps> = memo(props => {
|
||||
return (
|
||||
<span
|
||||
className={tw(
|
||||
'pointer-events-none z-10 flex items-center justify-center text-secondary',
|
||||
'px-1 first:ps-2.5 last:pe-2.5'
|
||||
)}
|
||||
>
|
||||
<AxoSymbol.Icon size={16} symbol={props.symbol} label={null} />
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
Icon.displayName = 'AxoBaseField.Icon';
|
||||
|
||||
/**
|
||||
* <AxoBaseField.InputProvider>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
type SegmentContextType = Readonly<{
|
||||
ref: RefObject<HTMLInputElement | null>;
|
||||
id: string;
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
maxGraphemes: number;
|
||||
maxBytes: number;
|
||||
disabled: boolean;
|
||||
readOnly: boolean;
|
||||
}>;
|
||||
|
||||
const SegmentContext = createStrictContext<SegmentContextType>(
|
||||
'AxoBaseField.Segment'
|
||||
);
|
||||
|
||||
export type SegmentProps = Readonly<{
|
||||
/** Provide your own id for the `<input>` to target with a `<label>`. Auto-generated if omitted. */
|
||||
id?: string;
|
||||
/** Controlled value of the input. */
|
||||
value: string;
|
||||
/** Called with the new value on every change. */
|
||||
onValueChange: (value: string) => void;
|
||||
/** Maximum number of Unicode grapheme clusters allowed. */
|
||||
maxGraphemes: number;
|
||||
/** Maximum number of UTF-8 bytes allowed. Should be ~4x the number of `maxGraphemes`. */
|
||||
maxBytes: number;
|
||||
/** Disables this input. Also disabled if `Root` has `disabled` set. */
|
||||
disabled?: boolean;
|
||||
/** Makes this input read-only. Also read-only if `Root` has `readOnly` set. */
|
||||
readOnly?: boolean;
|
||||
/** Should be `Input` and `Clear` elements */
|
||||
children?: ReactNode;
|
||||
}>;
|
||||
|
||||
export const Segment: FC<SegmentProps> = memo(props => {
|
||||
const { value, onValueChange, maxGraphemes, maxBytes } = props;
|
||||
const groupContext = useStrictContextNullable(GroupContext);
|
||||
|
||||
const disabled = groupContext?.disabled === true || props.disabled === true;
|
||||
const readOnly = groupContext?.readOnly === true || props.readOnly === true;
|
||||
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
const fallbackId = useId();
|
||||
const id = props.id ?? fallbackId;
|
||||
|
||||
const inputContext = useMemo((): SegmentContextType => {
|
||||
return {
|
||||
ref,
|
||||
id,
|
||||
value,
|
||||
onValueChange,
|
||||
maxGraphemes,
|
||||
maxBytes,
|
||||
disabled,
|
||||
readOnly,
|
||||
};
|
||||
}, [
|
||||
ref,
|
||||
id,
|
||||
value,
|
||||
onValueChange,
|
||||
maxGraphemes,
|
||||
maxBytes,
|
||||
disabled,
|
||||
readOnly,
|
||||
]);
|
||||
|
||||
return (
|
||||
<SegmentContext value={inputContext}>{props.children}</SegmentContext>
|
||||
);
|
||||
});
|
||||
|
||||
Segment.displayName = 'AxoBaseField.Segment';
|
||||
|
||||
/**
|
||||
* <AxoBaseField.Input>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* How an `Input` sizes itself within the field group.
|
||||
* - `fixed`: Takes up all remaining space (default).
|
||||
* - `grow`: Expands with typed content, up to available space.
|
||||
* - `fit`: Shrinks to fit typed content, useful for segmented fields.
|
||||
*/
|
||||
export type InputSizing = 'fixed' | 'grow' | 'fit';
|
||||
|
||||
export type InputProps = Readonly<
|
||||
{
|
||||
/** Ref to the underlying `<input>` element. */
|
||||
ref?: RefObject<HTMLInputElement | null>;
|
||||
/** The type of the input */
|
||||
type: Type;
|
||||
/** Form field name for native form submissions. */
|
||||
name?: string;
|
||||
/** Placeholder text shown when the input is empty. */
|
||||
placeholder: string;
|
||||
/** How the input sizes itself within the field group. Defaults to `fixed`. */
|
||||
sizing?: InputSizing;
|
||||
/** Marks the input as required for form validation. */
|
||||
required?: boolean;
|
||||
/** Focuses the input on mount. */
|
||||
autoFocus?: boolean;
|
||||
} & KeyboardInputAttrs &
|
||||
TextValidationInputAttrs &
|
||||
NumberValidationInputAttrs
|
||||
>;
|
||||
|
||||
/** The text input field. Must be placed inside `Root`. */
|
||||
export const Input: FC<InputProps> = memo(props => {
|
||||
const segmentContext = useStrictContext(SegmentContext);
|
||||
const sizing = props.sizing ?? 'fixed';
|
||||
const mergedRef = mergeRefs(segmentContext.ref, props.ref);
|
||||
|
||||
const { maxGraphemes, maxBytes, onValueChange } = segmentContext;
|
||||
|
||||
const handleBeforeInput = useCallback(
|
||||
(event: InputEvent<HTMLInputElement>) => {
|
||||
const input = event.currentTarget;
|
||||
const current = input.value;
|
||||
|
||||
const start = input.selectionStart ?? current.length;
|
||||
const end = input.selectionEnd ?? start;
|
||||
|
||||
const prefix = current.substring(0, start);
|
||||
const suffix = current.substring(end);
|
||||
const inserted = event.data;
|
||||
|
||||
const updated = `${prefix}${inserted}${suffix}`;
|
||||
const updatedBytes = utf8.getByteLength(updated);
|
||||
const updatedGraphemes = utf8.getGraphemeCount(updated);
|
||||
|
||||
if (updatedBytes <= maxBytes && updatedGraphemes <= maxGraphemes) {
|
||||
return;
|
||||
}
|
||||
|
||||
const base = `${prefix}${suffix}`;
|
||||
const baseBytes = utf8.getByteLength(base);
|
||||
const baseGraphemes = utf8.getGraphemeCount(base);
|
||||
|
||||
let result = '';
|
||||
result += prefix;
|
||||
|
||||
const remainingBytes = maxBytes - baseBytes;
|
||||
const remainingChars = maxGraphemes - baseGraphemes;
|
||||
result += utf8.truncateBytesAndGraphemes(
|
||||
inserted,
|
||||
remainingBytes,
|
||||
remainingChars
|
||||
);
|
||||
|
||||
result += suffix;
|
||||
|
||||
// Simulate the input as if we had just enough room
|
||||
// for exactly the bytes we want to let through
|
||||
const prevMaxLength = input.getAttribute('maxlength');
|
||||
input.maxLength = result.length;
|
||||
requestAnimationFrame(() => {
|
||||
if (input.maxLength !== result.length) {
|
||||
return; // changed elsewhere
|
||||
}
|
||||
if (prevMaxLength == null) {
|
||||
input.removeAttribute('maxlength');
|
||||
} else {
|
||||
input.setAttribute('maxlength', prevMaxLength);
|
||||
}
|
||||
});
|
||||
},
|
||||
[maxGraphemes, maxBytes]
|
||||
);
|
||||
|
||||
const handleInput = useCallback(
|
||||
(event: InputEvent<HTMLInputElement>) => {
|
||||
const input = event.currentTarget;
|
||||
const current = input.value;
|
||||
|
||||
const truncated = utf8.truncateBytesAndGraphemes(
|
||||
current,
|
||||
maxBytes,
|
||||
maxGraphemes
|
||||
);
|
||||
|
||||
onValueChange(truncated);
|
||||
},
|
||||
[maxGraphemes, maxBytes, onValueChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={tw(
|
||||
'peer z-0 flex min-w-0 first:ps-2.5 last:pe-2.5',
|
||||
sizing !== 'fit' && 'grow',
|
||||
// prevent overlapping text-selection
|
||||
'peer-has-[input]:overflow-hidden'
|
||||
)}
|
||||
>
|
||||
{/* FIXME */}
|
||||
{/* oxlint-disable-next-line jsx-a11y/control-has-associated-label */}
|
||||
<input
|
||||
ref={mergedRef}
|
||||
id={segmentContext.id}
|
||||
type={props.type}
|
||||
value={segmentContext.value}
|
||||
placeholder={props.placeholder}
|
||||
required={props.required}
|
||||
disabled={segmentContext.disabled}
|
||||
readOnly={segmentContext.readOnly}
|
||||
onInput={handleInput}
|
||||
onBeforeInput={handleBeforeInput}
|
||||
autoFocus={props.autoFocus}
|
||||
className={tw(
|
||||
'min-w-0 grow',
|
||||
sizing === 'grow' && 'field-sizing-content',
|
||||
sizing === 'fit' && 'field-sizing-content shrink',
|
||||
|
||||
// allow text selection in full box
|
||||
'-ms-20 ps-20',
|
||||
'-mx-20 pe-20',
|
||||
|
||||
'py-1.5',
|
||||
'indent-1',
|
||||
'text-primary',
|
||||
'not-forced-colors:outline-none',
|
||||
'disabled:text-disabled',
|
||||
|
||||
'[&::-webkit-search-cancel-button]:appearance-none'
|
||||
)}
|
||||
// KeyboardInputAttrs
|
||||
inputMode={props.inputMode}
|
||||
autoComplete={props.autoComplete}
|
||||
autoCorrect={props.autoCorrect}
|
||||
autoCapitalize={props.autoCapitalize}
|
||||
enterKeyHint={props.enterKeyHint}
|
||||
spellCheck={props.spellCheck}
|
||||
// TextValidationInputAttrs
|
||||
minLength={props.minLength}
|
||||
maxLength={props.maxLength}
|
||||
pattern={props.pattern}
|
||||
size={props.size}
|
||||
// NumberValidationInputAttrs
|
||||
min={props.min}
|
||||
max={props.max}
|
||||
step={props.step}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Input.displayName = 'AxoBaseField.Input';
|
||||
|
||||
/**
|
||||
* <AxoBaseField.RemainingCount>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const SHOW_REMAINING_COUNT_THRESHOLD = 0.5;
|
||||
const WARN_REMAINING_COUNT_THRESHOLD = 0.25;
|
||||
|
||||
export type RemainingCountProps = Readonly<{
|
||||
maxGraphemes: number;
|
||||
maxBytes: number;
|
||||
}>;
|
||||
|
||||
export const RemainingCount: FC<RemainingCountProps> = memo(props => {
|
||||
const { maxBytes, maxGraphemes } = props;
|
||||
const segmentContext = useStrictContext(SegmentContext);
|
||||
const { value } = segmentContext;
|
||||
|
||||
const remainingCount = useMemo(() => {
|
||||
if (value.length === 0) {
|
||||
return maxGraphemes;
|
||||
}
|
||||
|
||||
const totalBytes = utf8.getByteLength(value);
|
||||
const totalGraphemes = utf8.getGraphemeCount(value);
|
||||
|
||||
const remainingBytes = maxBytes - totalBytes;
|
||||
const remainingChars = maxGraphemes - totalGraphemes;
|
||||
|
||||
if (remainingBytes > remainingChars) {
|
||||
return remainingChars;
|
||||
}
|
||||
|
||||
return remainingBytes;
|
||||
}, [value, maxBytes, maxGraphemes]);
|
||||
|
||||
const showRemainingCount = useMemo(() => {
|
||||
return remainingCount <= maxGraphemes * SHOW_REMAINING_COUNT_THRESHOLD;
|
||||
}, [maxGraphemes, remainingCount]);
|
||||
|
||||
const warnRemainingCount = useMemo(() => {
|
||||
return remainingCount <= maxGraphemes * WARN_REMAINING_COUNT_THRESHOLD;
|
||||
}, [maxGraphemes, remainingCount]);
|
||||
|
||||
if (!showRemainingCount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={tw(
|
||||
'pointer-events-none z-10 flex items-center',
|
||||
'px-1 first:ps-2.5 last:pe-2.5',
|
||||
'type-body-small tabular-nums',
|
||||
warnRemainingCount ? 'text-destructive' : 'text-secondary'
|
||||
)}
|
||||
>
|
||||
{remainingCount}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
RemainingCount.displayName = 'AxoBaseField.RemainingCount';
|
||||
|
||||
/**
|
||||
* <AxoBaseField.Clear>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const ClearVariants = variants<Variant>('AxoBaseField.Variant', {
|
||||
text: tw('group-enabled/clear:group-hover/clear:bg-surface-secondary'),
|
||||
search: tw('group-enabled/clear:group-hover/clear:bg-primary'),
|
||||
});
|
||||
|
||||
export const Clear: FC = memo(() => {
|
||||
const segmentContext = useStrictContext(SegmentContext);
|
||||
const containerContext = useStrictContext(ContainerContext);
|
||||
const { ref, value, onValueChange } = segmentContext;
|
||||
const intl = useAxoIntl();
|
||||
|
||||
const handleClear = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
onValueChange('');
|
||||
assert(ref.current).focus();
|
||||
},
|
||||
[ref, onValueChange]
|
||||
);
|
||||
|
||||
if (value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={intl.get('AxoTextField.Clear')}
|
||||
aria-controls={segmentContext.id}
|
||||
className={tw(
|
||||
'z-10',
|
||||
'px-0.5 first:ps-1.5 last:pe-1.5',
|
||||
'group/clear group-has-[input:placeholder-shown]:hidden',
|
||||
'outline-none'
|
||||
)}
|
||||
onClick={handleClear}
|
||||
disabled={segmentContext.disabled}
|
||||
>
|
||||
<span
|
||||
className={tw(
|
||||
'flex items-center justify-center',
|
||||
'p-0.5',
|
||||
'rounded-full',
|
||||
'text-secondary',
|
||||
'group-enabled/clear:group-hover/clear:text-primary',
|
||||
ClearVariants.get(containerContext.variant),
|
||||
'group-focus-visible/clear:axo-focus-ring'
|
||||
)}
|
||||
>
|
||||
<AxoSymbol.Icon size={16} symbol="x" label={null} />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
Clear.displayName = 'AxoBaseField.Clear';
|
||||
|
||||
/**
|
||||
* <AxoBaseField.Action>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const ActionVariants = variants<Variant>('AxoBaseField.Variant', {
|
||||
text: tw(
|
||||
'group-not-aria-disabled/action:group-hover/action:bg-surface-secondary'
|
||||
),
|
||||
search: tw('group-not-aria-disabled/action:group-hover/action:bg-primary'),
|
||||
});
|
||||
|
||||
export type ActionProps = Readonly<{
|
||||
/** Accessible label for the button describing the action to be taken, not the icon. */
|
||||
label: string;
|
||||
/** Icon to display inside the button. */
|
||||
symbol: AxoSymbol.IconName;
|
||||
/** Called when the button is clicked. */
|
||||
onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
|
||||
/** Overrides the `disabled` state from `Root` for this button only. */
|
||||
disabled?: boolean;
|
||||
/** When set, the button behaves as a toggle with `aria-pressed` semantics. */
|
||||
pressed?: boolean;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* An icon button placed inside a `Root`, typically used for supplementary
|
||||
* actions like inserting an emoji or opening a menu.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <AxoTextField.Root>
|
||||
* <AxoTextField.Input ... />
|
||||
* <AxoTextField.Action label="Insert emoji" symbol="emoji" onClick={openEmojiPicker} />
|
||||
* </AxoTextField.Root>
|
||||
* ```
|
||||
*/
|
||||
export const Action: FC<ActionProps> = memo(props => {
|
||||
const { onClick } = props;
|
||||
const groupContext = useStrictContextNullable(GroupContext);
|
||||
const containerContext = useStrictContext(ContainerContext);
|
||||
|
||||
const disabled =
|
||||
groupContext?.disabled === true ||
|
||||
groupContext?.readOnly === true ||
|
||||
props.disabled === true;
|
||||
|
||||
const handleClick = useCallback(
|
||||
(event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
if (disabled) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
onClick?.(event);
|
||||
},
|
||||
[disabled, onClick]
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={props.label}
|
||||
aria-disabled={disabled}
|
||||
aria-pressed={props.pressed}
|
||||
className={tw(
|
||||
'group/action z-10 outline-none',
|
||||
'first:ps-1 last:pe-1',
|
||||
'aria-disabled:cursor-default'
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<span
|
||||
className={tw(
|
||||
'flex items-center justify-center rounded-full p-1',
|
||||
'text-secondary',
|
||||
'group-not-aria-disabled/action:group-hover/action:text-primary',
|
||||
ActionVariants.get(containerContext.variant),
|
||||
'group-focus-visible/action:axo-focus-ring'
|
||||
)}
|
||||
>
|
||||
<AxoSymbol.Icon size={18} symbol={props.symbol} label={null} />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
Action.displayName = 'AxoBaseField.Action';
|
||||
|
||||
/**
|
||||
* <AxoBaseField.Separator>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export const Separator: FC = memo(() => {
|
||||
return (
|
||||
<span className={tw('flex py-2 ps-3 pe-2')}>
|
||||
<span
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
className={tw('rounded-xs border-l border-secondary')}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
Separator.displayName = 'AxoBaseField.Separator';
|
||||
|
||||
/**
|
||||
* <AxoBaseField.Reveal>
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type RevealProps = Readonly<{
|
||||
label: string;
|
||||
revealed: boolean;
|
||||
onRevealedChange: (revealed: boolean) => void;
|
||||
}>;
|
||||
|
||||
export const Reveal: FC<RevealProps> = memo(props => {
|
||||
const { revealed, onRevealedChange } = props;
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
onRevealedChange(!revealed);
|
||||
}, [revealed, onRevealedChange]);
|
||||
|
||||
return (
|
||||
<Action
|
||||
label={props.label}
|
||||
symbol={props.revealed ? 'visible-slash' : 'visible'}
|
||||
pressed={revealed}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
Reveal.displayName = 'AxoBaseField.Reveal';
|
||||
}
|
||||
@@ -50,7 +50,7 @@ import { useConfirmDiscard } from '../hooks/useConfirmDiscard.dom.tsx';
|
||||
import { AxoButton } from '../axo/AxoButton.dom.tsx';
|
||||
import { normalizeProfileName } from '../util/normalizeProfileName.std.ts';
|
||||
import { Emoji } from '../axo/emoji.std.ts';
|
||||
import { AxoTextField } from '../axo/AxoTextField.dom.tsx';
|
||||
import { AxoTextField } from '../axo/fields/AxoTextField.dom.tsx';
|
||||
import { tw } from '../axo/tw.dom.tsx';
|
||||
import { AxoConfirmDialog } from '../axo/AxoConfirmDialog.dom.tsx';
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { AxoConfirmDialog } from '../../../axo/AxoConfirmDialog.dom.tsx';
|
||||
import { AxoAlertDialog } from '../../../axo/AxoAlertDialog.dom.tsx';
|
||||
import { AxoButton } from '../../../axo/AxoButton.dom.tsx';
|
||||
import { AxoDropdownMenu } from '../../../axo/AxoDropdownMenu.dom.tsx';
|
||||
import { AxoTextField } from '../../../axo/AxoTextField.dom.tsx';
|
||||
import { AxoPasswordField } from '../../../axo/fields/AxoPasswordField.dom.tsx';
|
||||
import {
|
||||
Buttons,
|
||||
Container,
|
||||
@@ -126,19 +126,17 @@ export function CreatePINScreen({
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<AxoTextField.Root width="lg">
|
||||
<AxoTextField.Input
|
||||
autoFocus
|
||||
maxBytes={10}
|
||||
maxGraphemes={10}
|
||||
onValueChange={onChangePIN}
|
||||
placeholder={i18n(
|
||||
'icu:StandaloneRegistration--CreatePIN--placeholder'
|
||||
)}
|
||||
type="password"
|
||||
value={pin}
|
||||
/>
|
||||
</AxoTextField.Root>
|
||||
<AxoPasswordField.Root
|
||||
autoFocus
|
||||
maxBytes={10}
|
||||
maxGraphemes={10}
|
||||
onValueChange={onChangePIN}
|
||||
placeholder={i18n(
|
||||
'icu:StandaloneRegistration--CreatePIN--placeholder'
|
||||
)}
|
||||
value={pin}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</InputContainer>
|
||||
<Spacer className={tw('grow')} />
|
||||
<Buttons>
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { JSX } from 'react';
|
||||
|
||||
import { tw } from '../../../axo/tw.dom.tsx';
|
||||
import { AxoButton } from '../../../axo/AxoButton.dom.tsx';
|
||||
import { AxoTextField } from '../../../axo/AxoTextField.dom.tsx';
|
||||
import { AxoPasswordField } from '../../../axo/fields/AxoPasswordField.dom.tsx';
|
||||
import {
|
||||
Buttons,
|
||||
Container,
|
||||
@@ -63,19 +63,18 @@ export function CreatePINConfirmScreen({
|
||||
</Description>
|
||||
<Spacer className={tw('h-8')} />
|
||||
<InputContainer className={tw('w-81')}>
|
||||
<AxoTextField.Root width="md" disabled={pending}>
|
||||
<AxoTextField.Input
|
||||
autoFocus
|
||||
maxBytes={10}
|
||||
maxGraphemes={10}
|
||||
onValueChange={onChangePIN}
|
||||
placeholder={i18n(
|
||||
'icu:StandaloneRegistration--CreatePIN--confirming--placeholder'
|
||||
)}
|
||||
type="password"
|
||||
value={pin}
|
||||
/>
|
||||
</AxoTextField.Root>
|
||||
<AxoPasswordField.Root
|
||||
disabled={pending}
|
||||
autoFocus
|
||||
maxBytes={10}
|
||||
maxGraphemes={10}
|
||||
onValueChange={onChangePIN}
|
||||
placeholder={i18n(
|
||||
'icu:StandaloneRegistration--CreatePIN--confirming--placeholder'
|
||||
)}
|
||||
value={pin}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</InputContainer>
|
||||
<Spacer className={tw('h-8 grow')} />
|
||||
<Buttons>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { tw } from '../../../axo/tw.dom.tsx';
|
||||
import { AxoButton } from '../../../axo/AxoButton.dom.tsx';
|
||||
import { ConfirmPhoneNumberDialog } from '../util/ConfirmPhoneNumberDialog.dom.tsx';
|
||||
import { ChooseCountryCodeModal } from '../../CountryCodeSelect.dom.tsx';
|
||||
import { AxoTextField } from '../../../axo/AxoTextField.dom.tsx';
|
||||
import { AxoTextField } from '../../../axo/fields/AxoTextField.dom.tsx';
|
||||
import {
|
||||
Buttons,
|
||||
Container,
|
||||
@@ -101,7 +101,7 @@ export function PhoneNumberScreen({
|
||||
</Description>
|
||||
<Spacer className={tw('h-9')} />
|
||||
<InputContainer className={tw('w-81')}>
|
||||
<AxoTextField.Root width="lg">
|
||||
<AxoTextField.Root>
|
||||
{regionCode ? (
|
||||
<div className={tw('p-1.5 ps-3 type-body-large text-primary')}>
|
||||
{codeByRegion.get(regionCode)}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { AxoSymbol } from '../../../axo/AxoSymbol.dom.tsx';
|
||||
import { AxoButton } from '../../../axo/AxoButton.dom.tsx';
|
||||
import { AxoDialog } from '../../../axo/AxoDialog.dom.tsx';
|
||||
import { AxoRadioGroup } from '../../../axo/AxoRadioGroup.dom.tsx';
|
||||
import { AxoTextField } from '../../../axo/AxoTextField.dom.tsx';
|
||||
import { AxoTextField } from '../../../axo/fields/AxoTextField.dom.tsx';
|
||||
|
||||
import type { LocalizerType } from '../../../types/I18N.std.ts';
|
||||
import type { ActionCreator } from '../../../state/types.std.ts';
|
||||
@@ -138,7 +138,7 @@ export function ProfileEntryScreen({
|
||||
</AxoButton.Root>
|
||||
<Spacer className={tw('h-7')} />
|
||||
<InputContainer className={tw('w-100')}>
|
||||
<AxoTextField.Root width="md" disabled={pending}>
|
||||
<AxoTextField.Root disabled={pending}>
|
||||
<AxoTextField.Input
|
||||
placeholder={i18n(
|
||||
'icu:StandaloneRegistration--ProfileEntry--first-name'
|
||||
@@ -152,7 +152,7 @@ export function ProfileEntryScreen({
|
||||
</InputContainer>
|
||||
<Spacer />
|
||||
<InputContainer className={tw('w-100')}>
|
||||
<AxoTextField.Root width="md" disabled={pending}>
|
||||
<AxoTextField.Root disabled={pending}>
|
||||
<AxoTextField.Input
|
||||
placeholder={i18n(
|
||||
'icu:StandaloneRegistration--ProfileEntry--last-name'
|
||||
|
||||
@@ -27,7 +27,7 @@ import type {
|
||||
goToCreatePINStage as doGoToCreatePINStage,
|
||||
verifyPIN as doVerifyPIN,
|
||||
} from '../../../state/ducks/standaloneInstaller.preload.ts';
|
||||
import { AxoTextField } from '../../../axo/AxoTextField.dom.tsx';
|
||||
import { AxoPasswordField } from '../../../axo/fields/AxoPasswordField.dom.tsx';
|
||||
import { AxoAlertDialog } from '../../../axo/AxoAlertDialog.dom.tsx';
|
||||
import { openLinkInWebBrowser } from '../../../util/openLinkInWebBrowser.dom.ts';
|
||||
import { CONTACT_SUPPORT_URL } from '../../../util/contactSupport.dom.tsx';
|
||||
@@ -243,21 +243,19 @@ export function VerifyPINScreen({
|
||||
</Description>
|
||||
<Spacer className={tw('h-10')} />
|
||||
<InputContainer className={tw('w-81')} helperElement={helperElement}>
|
||||
<AxoTextField.Root width="lg">
|
||||
<AxoTextField.Input
|
||||
autoFocus
|
||||
maxBytes={10}
|
||||
maxGraphemes={10}
|
||||
onValueChange={onChangePIN}
|
||||
disabled={pending}
|
||||
placeholder={i18n(
|
||||
'icu:StandaloneRegistration--VerifyPIN--placeholder'
|
||||
)}
|
||||
ref={inputRef}
|
||||
type="password"
|
||||
value={pin}
|
||||
/>
|
||||
</AxoTextField.Root>
|
||||
<AxoPasswordField.Root
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
maxBytes={10}
|
||||
maxGraphemes={10}
|
||||
onValueChange={onChangePIN}
|
||||
disabled={pending}
|
||||
placeholder={i18n(
|
||||
'icu:StandaloneRegistration--VerifyPIN--placeholder'
|
||||
)}
|
||||
value={pin}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</InputContainer>
|
||||
<Spacer className={tw('grow')} />
|
||||
<Buttons>
|
||||
|
||||
@@ -16,11 +16,12 @@ export const AppProvider: FC<AppProviderProps> = memo(
|
||||
|
||||
const messages: AxoProviderProps['messages'] = {
|
||||
'AxoAlertDialog.Cancel': i18n('icu:AxoAlertDialog.Cancel'),
|
||||
'AxoBadge.MaxOverflow': max => i18n('icu:AxoBadge.MaxOverflow', { max }),
|
||||
'AxoButton.Pending': i18n('icu:AxoButton.Pending'),
|
||||
'AxoDialog.Back': i18n('icu:AxoDialog.Back'),
|
||||
'AxoDialog.Close': i18n('icu:AxoDialog.Close'),
|
||||
'AxoPasswordField.Reveal': i18n('icu:AxoPasswordField.Reveal'),
|
||||
'AxoTextField.Clear': i18n('icu:AxoTextField.Clear'),
|
||||
'AxoBadge.MaxOverflow': max => i18n('icu:AxoBadge.MaxOverflow', { max }),
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user