Init AxoSearchField and AxoPasswordField

Co-authored-by: Jamie <113370520+jamiebuilds-signal@users.noreply.github.com>
This commit is contained in:
automated-signal
2026-07-30 00:09:11 +00:00
committed by GitHub
co-authored by Jamie
parent 611767845d
commit 61109e722a
18 changed files with 1271 additions and 684 deletions
+4
View File
@@ -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"
-627
View File
@@ -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';
}
+1
View File
@@ -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}+`,
};
+14
View File
@@ -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"
/>
);
}
+77
View File
@@ -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"
/>
);
}
+46
View File
@@ -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;
}>;
+243
View File
@@ -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';
}
+788
View File
@@ -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';
}
+1 -1
View File
@@ -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>
+2 -1
View File
@@ -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 (