diff --git a/packages/ui/src/components/input/index.ts b/packages/ui/src/components/input/index.ts
index 04f79060d..54aa5b8fc 100644
--- a/packages/ui/src/components/input/index.ts
+++ b/packages/ui/src/components/input/index.ts
@@ -1,2 +1,3 @@
export * from './input';
export * from './types';
+export { FIELD_STATES, type FieldState } from '@ui/shared/types/field';
diff --git a/packages/ui/src/components/input/input-root.module.css b/packages/ui/src/components/input/input-root.module.css
index 75971ce0f..2f8e06498 100644
--- a/packages/ui/src/components/input/input-root.module.css
+++ b/packages/ui/src/components/input/input-root.module.css
@@ -3,33 +3,77 @@
display: flex;
align-items: center;
cursor: text;
+ background-color: var(--ax-public-input-root-background-color);
+ border: var(--ax-public-input-root-border-size) solid var(--ax-public-input-root-border-color);
+ color: var(--ax-public-input-root-icon-color);
- color: var(--ax-public-input-root-color);
+ &[data-state='default']:not([data-disabled]):not(:focus-within):hover {
+ border-color: var(--ax-public-input-root-border-color-hover);
+ }
- border: var(--ax-public-input-root-border-size) solid var(--ax-public-input-root-border-color);
+ &[data-state='critical']:not([data-disabled]) {
+ background-color: var(--ax-public-input-root-background-color-critical);
+ border-color: var(--ax-public-input-root-border-color-critical);
+ }
+
+ &[data-state='success']:not([data-disabled]) {
+ background-color: var(--ax-public-input-root-background-color-success);
+ border-color: var(--ax-public-input-root-border-color-success);
+ }
- &:not(:hover) {
- transition: all var(--ax-public-transition);
+ &[data-state='read-only']:not([data-disabled]),
+ &[data-disabled] {
+ background-color: var(--ax-public-input-root-background-color-disabled);
+ border-color: var(--ax-public-input-root-border-color);
}
- &:has(input:focus-visible) {
+ &[data-disabled] {
+ color: var(--ax-public-input-root-color-disabled);
+ cursor: not-allowed;
+ }
+
+ &:not([data-disabled]):focus-within {
border-color: var(--ax-public-input-root-border-color-focus);
}
+ }
- &:global(.base--error) {
- background-color: var(--ax-public-input-root-background-color-error);
- border-color: var(--ax-public-input-root-border-color-error);
- color: var(--ax-public-input-root-color-error);
+ .icon,
+ .clear {
+ display: inline-flex;
+ flex: 0 0 auto;
+ align-items: center;
+ justify-content: center;
+ }
+
+ .icon {
+ pointer-events: none;
+ }
+
+ .icon svg,
+ .clear svg {
+ width: var(--wb-size-200);
+ height: var(--wb-size-200);
+ }
+
+ .clear {
+ padding: 0;
+ border: none;
+ background: transparent;
+ color: inherit;
+ cursor: pointer;
+
+ &:hover:not(:disabled) {
+ color: var(--ax-public-input-color);
}
- &:global(.base--disabled) {
- color: var(--ax-public-input-root-color-disabled);
+ &:focus-visible {
+ border-radius: var(--wb-radius-25);
+ outline: var(--wb-size-12) solid var(--wb-ui-focus-ring);
+ outline-offset: var(--wb-size-25);
}
- svg {
- width: 1rem;
- height: 1rem;
- min-width: 1rem;
+ &:disabled {
+ cursor: not-allowed;
}
}
}
diff --git a/packages/ui/src/components/input/input.module.css b/packages/ui/src/components/input/input.module.css
index 32ba45b29..9f2977407 100644
--- a/packages/ui/src/components/input/input.module.css
+++ b/packages/ui/src/components/input/input.module.css
@@ -1,10 +1,12 @@
@layer ui.component {
.input {
+ min-width: 0;
+ width: 100%;
+ flex: 1;
border: none;
background: transparent;
padding: 0;
- color: unset;
- width: 100%;
+ color: var(--ax-public-input-color);
white-space: nowrap;
overflow: hidden;
@@ -16,11 +18,12 @@
&:disabled {
color: var(--ax-public-input-color-disabled);
+ cursor: not-allowed;
}
&::placeholder {
- color: unset;
- opacity: 0.5;
+ color: var(--ax-public-input-placeholder-color);
+ opacity: 1;
}
}
}
diff --git a/packages/ui/src/components/input/input.tsx b/packages/ui/src/components/input/input.tsx
index be57699ce..ae744b68a 100644
--- a/packages/ui/src/components/input/input.tsx
+++ b/packages/ui/src/components/input/input.tsx
@@ -1,43 +1,89 @@
import { Input as InputBase } from '@base-ui/react/input';
+import { X } from '@phosphor-icons/react';
+import { Field } from '@ui/shared/components/field/field';
import clsx from 'clsx';
+import { forwardRef } from 'react';
import inputRootStyles from './input-root.module.css';
import inputStyles from './input.module.css';
import './variables.css';
+import inputSizeStyles from '@ui/shared/styles/field-control-size.module.css';
import inputFontStyles from '@ui/shared/styles/input-font-size.module.css';
-import inputSizeStyles from '@ui/shared/styles/input-size.module.css';
import type { InputProps } from './types';
-export function Input({
- size = 'medium',
- startAdornment,
- endAdornment,
- error = false,
- className,
- ...props
-}: InputProps) {
+export const Input = forwardRef
(function Input(
+ {
+ size = 'm',
+ state = 'default',
+ prefixIcon,
+ suffixIcon,
+ onClear,
+ label,
+ helperText,
+ isRequired,
+ className,
+ disabled,
+ readOnly,
+ id,
+ required,
+ 'aria-describedby': ariaDescribedBy,
+ 'aria-invalid': ariaInvalid,
+ ...props
+ },
+ ref,
+) {
+ const isReadOnly = !disabled && (state === 'read-only' || readOnly === true);
+ const fieldState = disabled && state === 'read-only' ? 'default' : isReadOnly ? 'read-only' : state;
+ const isNativeRequired = required || isRequired;
+
return (
- {
- if (event.target !== event.currentTarget) return;
- event.preventDefault();
- event.currentTarget.querySelector('input')?.focus();
- }}
+
- {startAdornment}
-
- {endAdornment}
-
+ {({ controlId, describedBy }) => (
+ {
+ if (event.target !== event.currentTarget) return;
+ event.preventDefault();
+ event.currentTarget.querySelector('input')?.focus();
+ }}
+ >
+ {prefixIcon && {prefixIcon} }
+
+ {suffixIcon && {suffixIcon} }
+ {onClear && (
+ event.preventDefault()}
+ onClick={onClear}
+ >
+
+
+ )}
+
+ )}
+
);
-}
+});
diff --git a/packages/ui/src/components/input/types.ts b/packages/ui/src/components/input/types.ts
index 03520df37..381d4e272 100644
--- a/packages/ui/src/components/input/types.ts
+++ b/packages/ui/src/components/input/types.ts
@@ -1,27 +1,4 @@
-import type { ItemSize } from '@ui/shared/types/item-size';
-import type { InputHTMLAttributes, ReactNode } from 'react';
+import type { FieldControlProps } from '@ui/shared/types/field';
+import type { InputHTMLAttributes } from 'react';
-export type InputProps = Omit, 'size'> & {
- /**
- * Specifies the size of the input field.
- * Can be 'small', 'medium', or 'large'.
- * @default 'medium'
- */
- size?: ItemSize;
-
- /**
- * Element displayed at the end of the input field.
- */
- endAdornment?: ReactNode;
-
- /**
- * Element displayed at the start of the input field.
- */
- startAdornment?: ReactNode;
-
- /**
- * Renders the input in an error state.
- * @default false
- */
- error?: boolean;
-};
+export type InputProps = Omit, 'size'> & FieldControlProps;
diff --git a/packages/ui/src/components/input/variables.css b/packages/ui/src/components/input/variables.css
index d93c6d8fe..e1ecfdc8e 100644
--- a/packages/ui/src/components/input/variables.css
+++ b/packages/ui/src/components/input/variables.css
@@ -1,14 +1,22 @@
:root {
+ --ax-public-input-color: var(--wb-ui-text-default);
--ax-public-input-color-disabled: var(--wb-ui-text-ghost-default);
+ --ax-public-input-placeholder-color: var(--wb-ui-text-ghost-default);
- --ax-public-input-root-color: var(--wb-ui-text-subtle-default);
+ --ax-public-input-root-background-color: var(--wb-ui-bg-base);
+ --ax-public-input-root-background-color-disabled: var(--wb-ui-bg-fill-disabled);
+ --ax-public-input-root-background-color-critical: var(--wb-components-text-field-bg-primary-critical);
+ --ax-public-input-root-background-color-success: var(--wb-components-text-field-bg-primary-success);
--ax-public-input-root-border-color: var(--wb-components-text-field-stroke-default);
+ --ax-public-input-root-border-color-hover: var(--wb-ui-stroke-default);
--ax-public-input-root-border-color-focus: var(--wb-components-text-field-stroke-focus);
- --ax-public-input-root-border-size: 0.0625rem;
-
+ --ax-public-input-root-border-color-critical: var(--wb-components-text-field-stroke-critical);
+ --ax-public-input-root-border-color-success: var(--wb-components-text-field-stroke-success);
+ --ax-public-input-root-border-size: var(--wb-size-12);
+ --ax-public-input-root-icon-color: var(--wb-ui-text-subtle-default);
+ --ax-public-input-root-color-disabled: var(--wb-ui-text-ghost-default);
+ --ax-public-input-root-color: var(--wb-ui-text-subtle-default);
--ax-public-input-root-background-color-error: var(--wb-components-text-field-bg-primary-critical);
--ax-public-input-root-border-color-error: var(--wb-components-text-field-stroke-critical);
--ax-public-input-root-color-error: var(--wb-ui-text-critical-default);
-
- --ax-public-input-root-color-disabled: var(--wb-ui-text-ghost-default);
}
diff --git a/packages/ui/src/components/number-field/index.ts b/packages/ui/src/components/number-field/index.ts
new file mode 100644
index 000000000..6f85ee689
--- /dev/null
+++ b/packages/ui/src/components/number-field/index.ts
@@ -0,0 +1,3 @@
+export * from './number-field';
+export * from './types';
+export { FIELD_STATES, type FieldState } from '@ui/shared/types/field';
diff --git a/packages/ui/src/components/number-field/number-field.module.css b/packages/ui/src/components/number-field/number-field.module.css
new file mode 100644
index 000000000..a52c46e54
--- /dev/null
+++ b/packages/ui/src/components/number-field/number-field.module.css
@@ -0,0 +1,170 @@
+@layer ui.component {
+ .number-field {
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ overflow: hidden;
+ background-color: var(--ax-public-number-field-root-background-color);
+ border: var(--ax-public-number-field-root-border-size) solid var(--ax-public-number-field-root-border-color);
+ color: var(--ax-public-number-field-root-icon-color);
+ cursor: text;
+
+ &[data-field-state='default']:not([data-field-disabled]):not(:focus-within):hover {
+ border-color: var(--ax-public-number-field-root-border-color-hover);
+ }
+
+ &[data-field-state='critical']:not([data-field-disabled]) {
+ background-color: var(--ax-public-number-field-root-background-color-critical);
+ border-color: var(--ax-public-number-field-root-border-color-critical);
+ }
+
+ &[data-field-state='success']:not([data-field-disabled]) {
+ background-color: var(--ax-public-number-field-root-background-color-success);
+ border-color: var(--ax-public-number-field-root-border-color-success);
+ }
+
+ &[data-field-state='read-only']:not([data-field-disabled]),
+ &[data-field-disabled] {
+ background-color: var(--ax-public-number-field-root-background-color-disabled);
+ border-color: var(--ax-public-number-field-root-border-color);
+ }
+
+ &[data-field-disabled] {
+ color: var(--ax-public-number-field-color-disabled);
+ cursor: not-allowed;
+ }
+
+ &:not([data-field-disabled]):focus-within {
+ border-color: var(--ax-public-number-field-root-border-color-focus);
+ }
+ }
+
+ .l {
+ --ax-private-number-field-padding-block: var(--wb-space-137);
+ }
+
+ .m {
+ --ax-private-number-field-padding-block: var(--wb-space-125);
+ }
+
+ .s {
+ --ax-private-number-field-padding-block: var(--wb-space-100);
+ }
+
+ .xs {
+ --ax-private-number-field-padding-block: var(--wb-space-75);
+ }
+
+ .input {
+ min-width: 0;
+ width: 100%;
+ flex: 1;
+ border: none;
+ background: transparent;
+ padding: 0;
+ color: var(--ax-public-number-field-color);
+
+ &:focus-visible {
+ outline: none;
+ }
+
+ &:disabled {
+ color: var(--ax-public-number-field-color-disabled);
+ cursor: not-allowed;
+ }
+
+ &::placeholder {
+ color: var(--ax-public-number-field-placeholder-color);
+ opacity: 1;
+ }
+ }
+
+ .icon,
+ .clear {
+ display: inline-flex;
+ flex: 0 0 auto;
+ align-items: center;
+ justify-content: center;
+ }
+
+ .icon {
+ pointer-events: none;
+ }
+
+ .icon svg,
+ .clear svg {
+ width: var(--wb-size-200);
+ height: var(--wb-size-200);
+ }
+
+ .clear {
+ padding: 0;
+ border: none;
+ background: transparent;
+ color: inherit;
+ cursor: pointer;
+
+ &:hover:not(:disabled) {
+ color: var(--ax-public-number-field-color);
+ }
+
+ &:focus-visible {
+ border-radius: var(--wb-radius-25);
+ outline: var(--wb-size-12) solid var(--wb-ui-focus-ring);
+ outline-offset: var(--wb-size-25);
+ }
+
+ &:disabled {
+ cursor: not-allowed;
+ }
+ }
+
+ .stepper {
+ display: flex;
+ width: var(--wb-size-300);
+ align-self: stretch;
+ flex: 0 0 var(--wb-size-300);
+ flex-direction: column;
+ margin-block: calc(var(--ax-private-number-field-padding-block) * -1);
+ margin-inline-end: calc(var(--wb-space-150) * -1);
+ }
+
+ .stepper-button {
+ display: flex;
+ width: 100%;
+ min-height: 0;
+ flex: 1;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ border: none;
+ border-left: var(--wb-size-12) solid var(--ax-public-number-field-stepper-border-color);
+ background-color: var(--ax-public-number-field-stepper-background-color);
+ color: var(--ax-public-number-field-root-icon-color);
+ cursor: pointer;
+
+ &:hover:not(:disabled, [data-disabled]) {
+ background-color: var(--ax-public-number-field-stepper-background-color-hover);
+ }
+
+ &:active:not(:disabled, [data-disabled]) {
+ background-color: var(--ax-public-number-field-stepper-background-color-active);
+ }
+
+ &:disabled,
+ &[data-disabled] {
+ background-color: var(--ax-public-number-field-stepper-background-color-disabled);
+ color: var(--ax-public-number-field-color-disabled);
+ cursor: not-allowed;
+ }
+
+ svg {
+ width: var(--wb-size-150);
+ height: var(--wb-size-150);
+ }
+ }
+
+ .decrement {
+ border-top: var(--wb-size-12) solid var(--ax-public-number-field-stepper-border-color);
+ }
+}
diff --git a/packages/ui/src/components/number-field/number-field.spec.tsx b/packages/ui/src/components/number-field/number-field.spec.tsx
new file mode 100644
index 000000000..d52b1e665
--- /dev/null
+++ b/packages/ui/src/components/number-field/number-field.spec.tsx
@@ -0,0 +1,323 @@
+import { act, createRef } from 'react';
+import { type Root, createRoot } from 'react-dom/client';
+
+import { NumberField } from './number-field';
+
+(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+let container: HTMLDivElement;
+let root: Root;
+
+beforeEach(() => {
+ container = document.createElement('div');
+ document.body.append(container);
+ root = createRoot(container);
+});
+
+afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+});
+
+function getVisibleInput() {
+ const input = container.querySelector('input[type="text"]');
+ expect(input).not.toBeNull();
+ return input as HTMLInputElement;
+}
+
+function changeInputValue(input: HTMLInputElement, value: string) {
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
+ act(() => {
+ setter?.call(input, value);
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ });
+}
+
+function pressKey(input: HTMLInputElement, key: string) {
+ act(() => input.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true })));
+}
+
+function paste(input: HTMLInputElement, text: string) {
+ const event = new Event('paste', { bubbles: true, cancelable: true });
+ Object.defineProperty(event, 'clipboardData', {
+ value: { getData: (type: string) => (type === 'text/plain' ? text : '') },
+ });
+ act(() => input.dispatchEvent(event));
+ return event;
+}
+
+it('steps with arrow keys while respecting the bounds', () => {
+ const values: Array = [];
+
+ act(() => {
+ root.render(
+ values.push(value)}
+ />,
+ );
+ });
+
+ const input = getVisibleInput();
+
+ pressKey(input, 'ArrowUp');
+ pressKey(input, 'ArrowUp');
+
+ expect(values).toEqual([2]);
+
+ pressKey(input, 'ArrowDown');
+
+ expect(values).toEqual([2, 1]);
+});
+
+it('keeps disabled steppers out of the tab order', () => {
+ act(() => root.render( ));
+
+ const buttons = container.querySelectorAll(
+ 'button[aria-label="Increment value"], button[aria-label="Decrement value"]',
+ );
+ expect(buttons).toHaveLength(2);
+
+ for (const button of buttons) {
+ expect(button.disabled).toBe(true);
+ expect(button.tabIndex).toBe(-1);
+ }
+});
+
+it('keeps the read-only input focusable and copyable', () => {
+ const ref = createRef();
+
+ act(() => root.render( ));
+
+ expect(ref.current?.readOnly).toBe(true);
+ expect(ref.current?.disabled).toBe(false);
+ expect(ref.current?.tabIndex).toBe(0);
+});
+
+it('lets disabled behavior take precedence over read-only state', () => {
+ act(() => root.render( ));
+
+ const input = getVisibleInput();
+ expect(input.disabled).toBe(true);
+ expect(input.readOnly).toBe(false);
+ expect(input.closest('[data-field-state]')?.getAttribute('data-field-state')).toBe('default');
+});
+
+it('renders an accessible clear affordance', () => {
+ const onClear = vi.fn();
+
+ act(() => root.render( ));
+
+ const clearButton = container.querySelector('button[aria-label="Clear number field"]');
+ expect(clearButton).not.toBeNull();
+
+ act(() => clearButton?.click());
+
+ expect(onClear).toHaveBeenCalledOnce();
+});
+
+it('reports clearing as a nullable value with change details', () => {
+ const changes: Array<{ value: number | null; reason: string }> = [];
+ act(() =>
+ root.render(
+ changes.push({ value, reason: details.reason })}
+ />,
+ ),
+ );
+
+ changeInputValue(getVisibleInput(), '');
+
+ expect(changes).toEqual([{ value: null, reason: 'input-clear' }]);
+});
+
+it('emits a clamped typed value only once after blur', () => {
+ const values: Array = [];
+ act(() =>
+ root.render(
+ values.push(value)} />,
+ ),
+ );
+
+ const input = getVisibleInput();
+ act(() => input.focus());
+ changeInputValue(input, '11');
+ act(() => input.blur());
+
+ expect(values).toEqual([10]);
+});
+
+it('resynchronizes a controlled value when its parent refuses an update', () => {
+ const values: Array = [];
+ act(() => root.render( values.push(value)} />));
+
+ const input = getVisibleInput();
+ act(() => input.focus());
+ changeInputValue(input, '2');
+
+ expect(getVisibleInput().value).toBe('1');
+
+ pressKey(getVisibleInput(), 'ArrowUp');
+
+ expect(values).toEqual([2, 2]);
+ expect(getVisibleInput().value).toBe('1');
+});
+
+it('honors a controlled cancellation without leaving dirty text', () => {
+ const reasons: string[] = [];
+ act(() =>
+ root.render(
+ {
+ reasons.push(details.reason);
+ details.cancel();
+ }}
+ />,
+ ),
+ );
+
+ const input = getVisibleInput();
+ act(() => input.focus());
+ changeInputValue(input, '2');
+
+ expect(reasons).toEqual(['input-change']);
+ expect(getVisibleInput().value).toBe('1');
+ expect(document.activeElement).toBe(getVisibleInput());
+});
+
+it('keeps an off-grid clamped boundary valid for form submission', () => {
+ const values: Array = [];
+ act(() =>
+ root.render(
+ ,
+ ),
+ );
+
+ const increment = container.querySelector('button[aria-label="Increment value"]');
+ act(() => increment?.click());
+
+ const form = container.querySelector('form');
+ const nativeInput = container.querySelector('input[type="number"]');
+ expect(values).toEqual([10]);
+ expect(nativeInput?.value).toBe('10');
+ expect(nativeInput?.step).toBe('any');
+ expect(nativeInput?.validity.stepMismatch).toBe(false);
+ expect(form?.checkValidity()).toBe(true);
+});
+
+it('normalizes non-finite numeric props and never emits NaN', () => {
+ const values: Array = [];
+ act(() =>
+ root.render(
+ values.push(value)}
+ />,
+ ),
+ );
+
+ pressKey(getVisibleInput(), 'ArrowUp');
+
+ expect(values).toEqual([0]);
+ expect(values.every((value) => value === null || Number.isFinite(value))).toBe(true);
+
+ act(() =>
+ root.render(
+ values.push(value)}
+ />,
+ ),
+ );
+ pressKey(getVisibleInput(), 'ArrowUp');
+
+ expect(values).toEqual([0, 0]);
+});
+
+it.each([0, -2])('normalizes an invalid numeric step of %s', (step) => {
+ const values: Array = [];
+ act(() =>
+ root.render(
+ values.push(value)} />,
+ ),
+ );
+
+ pressKey(getVisibleInput(), 'ArrowUp');
+
+ expect(values).toEqual([3]);
+});
+
+it('supports step="any" while retaining interactive stepping', () => {
+ const values: Array = [];
+ act(() =>
+ root.render(
+ values.push(value)} />,
+ ),
+ );
+
+ pressKey(getVisibleInput(), 'ArrowUp');
+
+ expect(values).toEqual([2]);
+ expect(container.querySelector('input[type="number"]')?.step).toBe('any');
+});
+
+it('rejects malformed mixed-content paste instead of reinterpreting it', () => {
+ const onValueChange = vi.fn();
+ act(() => root.render( ));
+
+ const input = getVisibleInput();
+ input.setSelectionRange(0, 0);
+ const mixedTextEvent = paste(input, '12abc');
+ const repeatedDecimalEvent = paste(input, '1.2.3');
+
+ expect(mixedTextEvent.defaultPrevented).toBe(true);
+ expect(repeatedDecimalEvent.defaultPrevented).toBe(true);
+ expect(input.value).toBe('');
+ expect(onValueChange).not.toHaveBeenCalled();
+});
+
+it('associates its label and critical helper with the input', () => {
+ act(() =>
+ root.render(
+ ,
+ ),
+ );
+
+ const input = getVisibleInput();
+ const label = container.querySelector('label');
+ const helper = container.querySelector('[id$="-helper"]');
+ expect(input.id).not.toBe('');
+ expect(label?.htmlFor).toBe(input.id);
+ expect(label?.textContent).toBe('Quantity*');
+ expect(input.required).toBe(true);
+ expect(input.getAttribute('aria-invalid')).toBe('true');
+ expect(input.getAttribute('aria-describedby')?.split(' ')).toEqual(['external-description', helper?.id]);
+});
diff --git a/packages/ui/src/components/number-field/number-field.tsx b/packages/ui/src/components/number-field/number-field.tsx
new file mode 100644
index 000000000..1cd498f13
--- /dev/null
+++ b/packages/ui/src/components/number-field/number-field.tsx
@@ -0,0 +1,263 @@
+import { NumberField as NumberFieldBase } from '@base-ui/react/number-field';
+import { CaretDown, CaretUp, X } from '@phosphor-icons/react';
+import { Field } from '@ui/shared/components/field/field';
+import clsx from 'clsx';
+import {
+ type ClipboardEvent,
+ type ForwardedRef,
+ forwardRef,
+ useCallback,
+ useLayoutEffect,
+ useRef,
+ useState,
+} from 'react';
+
+import styles from './number-field.module.css';
+import './variables.css';
+import inputSizeStyles from '@ui/shared/styles/field-control-size.module.css';
+import inputFontStyles from '@ui/shared/styles/input-font-size.module.css';
+
+import type { NumberFieldProps } from './types';
+
+const NUMERAL_PATTERN = /^[\p{N}零〇一二三四五六七八九]+$/u;
+const SIGN_PATTERN = /[++﹢\-−-‒–—﹣]/u;
+
+function normalizeFinite(value: number | undefined) {
+ return value === undefined || !Number.isFinite(value) ? undefined : value;
+}
+
+function normalizeStep(step: number | 'any') {
+ return step === 'any' || (Number.isFinite(step) && step > 0) ? step : 1;
+}
+
+function isValidPastedNumber(value: string) {
+ let input = value.trim();
+ if (!input) return false;
+
+ const parts = new Intl.NumberFormat().formatToParts(12_345.6);
+ const decimal = parts.find((part) => part.type === 'decimal')?.value ?? '.';
+ const group = parts.find((part) => part.type === 'group')?.value;
+ const characters = [...input];
+ const firstIsSign = SIGN_PATTERN.test(characters[0] ?? '');
+ const lastIsSign = SIGN_PATTERN.test(characters.at(-1) ?? '');
+
+ if (firstIsSign && lastIsSign && characters.length > 1) return false;
+ if (firstIsSign) input = characters.slice(1).join('');
+ if (lastIsSign) input = characters.slice(0, -1).join('');
+ if (SIGN_PATTERN.test(input)) return false;
+
+ const decimalParts = input.split(decimal);
+ if (decimalParts.length > 2) return false;
+
+ const [integer, fraction] = decimalParts;
+ if (fraction !== undefined && fraction !== '' && !NUMERAL_PATTERN.test(fraction)) return false;
+ if (!integer && !fraction) return false;
+
+ if (!integer) return true;
+ if (!group || !integer.includes(group)) return NUMERAL_PATTERN.test(integer);
+
+ const groups = integer.split(group);
+ return (
+ groups.length > 1 &&
+ groups[0].length > 0 &&
+ groups[0].length <= 3 &&
+ groups.every((part, index) => NUMERAL_PATTERN.test(part) && (index === 0 || part.length === 3))
+ );
+}
+
+function setRef(ref: ForwardedRef, value: HTMLInputElement | null) {
+ if (typeof ref === 'function') ref(value);
+ else if (ref) ref.current = value;
+}
+
+export const NumberField = forwardRef(function NumberField(
+ {
+ value,
+ defaultValue,
+ onValueChange,
+ min,
+ max,
+ step = 1,
+ size = 'm',
+ state = 'default',
+ prefixIcon,
+ suffixIcon,
+ onClear,
+ label,
+ helperText,
+ isRequired,
+ className,
+ disabled,
+ readOnly,
+ id,
+ name,
+ form,
+ required,
+ onPaste,
+ 'aria-describedby': ariaDescribedBy,
+ 'aria-invalid': ariaInvalid,
+ ...props
+ },
+ ref,
+) {
+ const normalizedValue = value == null || Number.isFinite(value) ? value : null;
+ const normalizedDefaultValue = defaultValue == null ? undefined : normalizeFinite(defaultValue);
+ const normalizedMin = normalizeFinite(min);
+ const normalizedMax = normalizeFinite(max);
+ const normalizedStep = normalizeStep(step);
+ const isReadOnly = !disabled && (state === 'read-only' || readOnly === true);
+ const fieldState = disabled && state === 'read-only' ? 'default' : isReadOnly ? 'read-only' : state;
+ const isNativeRequired = required || isRequired;
+ const visibleInputRef = useRef(null);
+ const restoreFocusRef = useRef(false);
+ const lastReportedValueRef = useRef(undefined);
+ const pendingControlledValueRef = useRef(undefined);
+ const [resetVersion, setResetVersion] = useState(0);
+ const [proposalVersion, setProposalVersion] = useState(0);
+ const isControlled = value !== undefined;
+ const maxIsOffStep =
+ typeof normalizedStep === 'number' &&
+ normalizedMax !== undefined &&
+ Math.abs(
+ (normalizedMax - (normalizedMin ?? 0)) / normalizedStep -
+ Math.round((normalizedMax - (normalizedMin ?? 0)) / normalizedStep),
+ ) > 1e-10;
+ const setVisibleInputRef = useCallback(
+ (element: HTMLInputElement | null) => {
+ visibleInputRef.current = element;
+ setRef(ref, element);
+ },
+ [ref],
+ );
+ const setHiddenInputRef = useCallback(
+ (element: HTMLInputElement | null) => {
+ if (element) element.step = maxIsOffStep || normalizedStep === 'any' ? 'any' : String(normalizedStep);
+ },
+ [maxIsOffStep, normalizedStep],
+ );
+
+ useLayoutEffect(() => {
+ if (pendingControlledValueRef.current === undefined) return;
+
+ const proposedValue = pendingControlledValueRef.current;
+ pendingControlledValueRef.current = undefined;
+ if (!Object.is(normalizedValue, proposedValue)) setResetVersion((current) => current + 1);
+ }, [normalizedValue, proposalVersion]);
+
+ useLayoutEffect(() => {
+ if (!restoreFocusRef.current) return;
+
+ restoreFocusRef.current = false;
+ visibleInputRef.current?.focus();
+ }, [resetVersion]);
+
+ const handlePaste = (event: ClipboardEvent) => {
+ onPaste?.(event);
+ if (event.defaultPrevented) return;
+
+ const pastedText = event.clipboardData.getData('text/plain');
+ const selectionStart = event.currentTarget.selectionStart ?? event.currentTarget.value.length;
+ const selectionEnd = event.currentTarget.selectionEnd ?? selectionStart;
+ const nextText =
+ event.currentTarget.value.slice(0, selectionStart) + pastedText + event.currentTarget.value.slice(selectionEnd);
+
+ if (!isValidPastedNumber(nextText)) event.preventDefault();
+ };
+
+ return (
+
+ {({ controlId, describedBy }) => (
+ {
+ if (nextValue !== null && !Number.isFinite(nextValue)) {
+ details.cancel();
+ return;
+ }
+ if (details.reason === 'input-blur' && Object.is(lastReportedValueRef.current, nextValue)) return;
+
+ onValueChange?.(nextValue, details);
+ if (details.isCanceled) {
+ if (isControlled) {
+ restoreFocusRef.current = document.activeElement === visibleInputRef.current;
+ setResetVersion((current) => current + 1);
+ }
+ return;
+ }
+
+ lastReportedValueRef.current = nextValue;
+ if (isControlled) {
+ pendingControlledValueRef.current = nextValue;
+ restoreFocusRef.current = document.activeElement === visibleInputRef.current;
+ setProposalVersion((current) => current + 1);
+ }
+ }}
+ >
+ {
+ if (event.target !== event.currentTarget) return;
+ event.preventDefault();
+ event.currentTarget.querySelector('input')?.focus();
+ }}
+ >
+ {prefixIcon && {prefixIcon} }
+
+ {suffixIcon && {suffixIcon} }
+ {onClear && (
+ event.preventDefault()}
+ onClick={onClear}
+ >
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ );
+});
diff --git a/packages/ui/src/components/number-field/types.ts b/packages/ui/src/components/number-field/types.ts
new file mode 100644
index 000000000..4b59599ea
--- /dev/null
+++ b/packages/ui/src/components/number-field/types.ts
@@ -0,0 +1,19 @@
+import type { NumberFieldRootChangeEventDetails } from '@base-ui/react/number-field';
+import type { FieldControlProps } from '@ui/shared/types/field';
+import type { InputHTMLAttributes } from 'react';
+
+export type NumberFieldProps = Omit<
+ InputHTMLAttributes,
+ 'defaultValue' | 'max' | 'min' | 'size' | 'step' | 'type' | 'value'
+> &
+ FieldControlProps & {
+ value?: number | null;
+ defaultValue?: number | null;
+ onValueChange?: (value: number | null, details: NumberFieldRootChangeEventDetails) => void;
+ min?: number;
+ max?: number;
+ /**
+ * @default 1
+ */
+ step?: number | 'any';
+ };
diff --git a/packages/ui/src/components/number-field/variables.css b/packages/ui/src/components/number-field/variables.css
new file mode 100644
index 000000000..e50ea3ff2
--- /dev/null
+++ b/packages/ui/src/components/number-field/variables.css
@@ -0,0 +1,21 @@
+:root {
+ --ax-public-number-field-color: var(--wb-ui-text-default);
+ --ax-public-number-field-color-disabled: var(--wb-ui-text-ghost-default);
+ --ax-public-number-field-placeholder-color: var(--wb-ui-text-ghost-default);
+ --ax-public-number-field-root-background-color: var(--wb-ui-bg-base);
+ --ax-public-number-field-root-background-color-disabled: var(--wb-ui-bg-fill-disabled);
+ --ax-public-number-field-root-background-color-critical: var(--wb-components-text-field-bg-primary-critical);
+ --ax-public-number-field-root-background-color-success: var(--wb-components-text-field-bg-primary-success);
+ --ax-public-number-field-root-border-color: var(--wb-components-text-field-stroke-default);
+ --ax-public-number-field-root-border-color-hover: var(--wb-ui-stroke-default);
+ --ax-public-number-field-root-border-color-focus: var(--wb-components-text-field-stroke-focus);
+ --ax-public-number-field-root-border-color-critical: var(--wb-components-text-field-stroke-critical);
+ --ax-public-number-field-root-border-color-success: var(--wb-components-text-field-stroke-success);
+ --ax-public-number-field-root-border-size: var(--wb-size-12);
+ --ax-public-number-field-root-icon-color: var(--wb-ui-text-subtle-default);
+ --ax-public-number-field-stepper-background-color: var(--wb-ui-bg-base);
+ --ax-public-number-field-stepper-background-color-hover: var(--wb-ui-bg-fill-hover);
+ --ax-public-number-field-stepper-background-color-active: var(--wb-ui-bg-fill-active);
+ --ax-public-number-field-stepper-background-color-disabled: var(--wb-ui-bg-fill-disabled);
+ --ax-public-number-field-stepper-border-color: var(--wb-ui-stroke-default);
+}
diff --git a/packages/ui/src/components/text-area/index.ts b/packages/ui/src/components/text-area/index.ts
index e18b3258a..c1332d7c1 100644
--- a/packages/ui/src/components/text-area/index.ts
+++ b/packages/ui/src/components/text-area/index.ts
@@ -1 +1,2 @@
export * from './text-area';
+export { FIELD_STATES, type FieldState } from '@ui/shared/types/field';
diff --git a/packages/ui/src/components/text-area/text-area.module.css b/packages/ui/src/components/text-area/text-area.module.css
index f36042be3..0ad674a2f 100644
--- a/packages/ui/src/components/text-area/text-area.module.css
+++ b/packages/ui/src/components/text-area/text-area.module.css
@@ -1,56 +1,87 @@
:root {
+ --ax-public-textarea-color: var(--wb-ui-text-default);
--ax-public-textarea-color-disabled: var(--wb-ui-text-ghost-default);
- --ax-public-textarea-root-color: var(--wb-ui-text-subtle-default);
+ --ax-public-textarea-placeholder-color: var(--wb-ui-text-ghost-default);
+ --ax-public-textarea-root-background-color: var(--wb-ui-bg-base);
+ --ax-public-textarea-root-background-color-disabled: var(--wb-ui-bg-fill-disabled);
+ --ax-public-textarea-root-background-color-critical: var(--wb-components-text-field-bg-primary-critical);
+ --ax-public-textarea-root-background-color-success: var(--wb-components-text-field-bg-primary-success);
--ax-public-textarea-root-border-color: var(--wb-components-text-field-stroke-default);
+ --ax-public-textarea-root-border-color-hover: var(--wb-ui-stroke-default);
--ax-public-textarea-root-border-color-focus: var(--wb-components-text-field-stroke-focus);
- --ax-public-textarea-root-border-size: 0.0625rem;
- --ax-public-textarea-root-background-color-error: var(--wb-components-text-field-bg-primary-critical);
- --ax-public-textarea-root-border-color-error: var(--wb-components-text-field-stroke-critical);
- --ax-public-textarea-root-color-error: var(--wb-ui-text-critical-default);
+ --ax-public-textarea-root-border-color-critical: var(--wb-components-text-field-stroke-critical);
+ --ax-public-textarea-root-border-color-success: var(--wb-components-text-field-stroke-success);
+ --ax-public-textarea-root-border-size: var(--wb-size-12);
+ --ax-public-textarea-root-icon-color: var(--wb-ui-text-subtle-default);
--ax-public-textarea-root-color-disabled: var(--wb-ui-text-ghost-default);
- --ax-public-textarea-root-background-color-disabled: transparent;
}
@layer ui.component {
.text-area-container {
display: flex;
- min-width: 100px;
+ min-width: 0;
+ width: 100%;
+ height: auto;
align-items: flex-start;
- gap: 0.5rem;
- background-color: transparent;
- color: var(--ax-public-textarea-root-color);
+ background-color: var(--ax-public-textarea-root-background-color);
+ color: var(--ax-public-textarea-root-icon-color);
border: var(--ax-public-textarea-root-border-size) solid var(--ax-public-textarea-root-border-color);
- &:not(:hover) {
- transition: all var(--ax-public-transition);
+ &[data-state='default']:not([data-disabled]):not(:focus-within):hover {
+ border-color: var(--ax-public-textarea-root-border-color-hover);
}
- &:has(.text-area:focus-visible) {
- border-color: var(--ax-public-textarea-root-border-color-focus);
+ &[data-state='critical']:not([data-disabled]) {
+ background-color: var(--ax-public-textarea-root-background-color-critical);
+ border-color: var(--ax-public-textarea-root-border-color-critical);
}
- &:global(.base--error) {
- background-color: var(--ax-public-textarea-root-background-color-error);
- border-color: var(--ax-public-textarea-root-border-color-error);
- color: var(--ax-public-textarea-root-color-error);
+ &[data-state='success']:not([data-disabled]) {
+ background-color: var(--ax-public-textarea-root-background-color-success);
+ border-color: var(--ax-public-textarea-root-border-color-success);
}
- &:global(.base--disabled) {
- color: var(--ax-public-textarea-root-color-disabled);
+ &[data-state='read-only']:not([data-disabled]),
+ &[data-disabled] {
background-color: var(--ax-public-textarea-root-background-color-disabled);
+ border-color: var(--ax-public-textarea-root-border-color);
+ }
+
+ &[data-disabled] {
+ color: var(--ax-public-textarea-root-color-disabled);
cursor: not-allowed;
}
+
+ &:not([data-disabled]):focus-within {
+ border-color: var(--ax-public-textarea-root-border-color-focus);
+ }
+ }
+
+ .text-area-container.text-area-container {
+ height: auto;
+ }
+
+ .size-l {
+ min-height: calc(var(--wb-size-800) + var(--wb-size-700));
+ }
+
+ .size-m {
+ min-height: calc(var(--wb-size-1000) + var(--wb-space-250));
+ }
+
+ .size-s {
+ min-height: var(--wb-size-1000);
}
.text-area {
+ min-width: 0;
+ flex: 1;
border: none;
background: transparent;
padding: 0;
- color: unset;
+ color: var(--ax-public-textarea-color);
width: 100%;
resize: none;
- /* Not the `font` shorthand - it would also set the size, overriding the
- ax-public-* typography class the component is given. */
font-family: inherit;
&:focus-visible {
@@ -61,5 +92,50 @@
color: var(--ax-public-textarea-color-disabled);
cursor: not-allowed;
}
+
+ &::placeholder {
+ color: var(--ax-public-textarea-placeholder-color);
+ opacity: 1;
+ }
+ }
+
+ .icon,
+ .clear {
+ display: inline-flex;
+ flex: 0 0 auto;
+ align-items: center;
+ justify-content: center;
+ }
+
+ .icon {
+ pointer-events: none;
+ }
+
+ .icon svg,
+ .clear svg {
+ width: var(--wb-size-200);
+ height: var(--wb-size-200);
+ }
+
+ .clear {
+ padding: 0;
+ border: none;
+ background: transparent;
+ color: inherit;
+ cursor: pointer;
+
+ &:hover:not(:disabled) {
+ color: var(--ax-public-textarea-color);
+ }
+
+ &:focus-visible {
+ border-radius: var(--wb-radius-25);
+ outline: var(--wb-size-12) solid var(--wb-ui-focus-ring);
+ outline-offset: var(--wb-size-25);
+ }
+
+ &:disabled {
+ cursor: not-allowed;
+ }
}
}
diff --git a/packages/ui/src/components/text-area/text-area.tsx b/packages/ui/src/components/text-area/text-area.tsx
index 93c4f14d0..f0250341c 100644
--- a/packages/ui/src/components/text-area/text-area.tsx
+++ b/packages/ui/src/components/text-area/text-area.tsx
@@ -1,125 +1,97 @@
+import { X } from '@phosphor-icons/react';
+import { Field } from '@ui/shared/components/field/field';
+import type { FieldControlProps } from '@ui/shared/types/field';
import clsx from 'clsx';
-import type React from 'react';
+import { type ComponentPropsWithoutRef, type TextareaHTMLAttributes, forwardRef } from 'react';
import TextareaAutosize from 'react-textarea-autosize';
import styles from './text-area.module.css';
+import inputSizeStyles from '@ui/shared/styles/field-control-size.module.css';
import inputFontStyles from '@ui/shared/styles/input-font-size.module.css';
-import inputSizeStyles from '@ui/shared/styles/input-size.module.css';
-import type { ItemSize } from '../../shared/types/item-size';
+export type TextAreaProps = Omit, 'size' | 'style'> &
+ FieldControlProps & {
+ maxRows?: number;
+ minRows?: number;
+ style?: ComponentPropsWithoutRef['style'];
+ };
-export type TextAreaProps = {
- /**
- * Controlled value of the textarea
- */
- value?: string;
- /**
- * Initial value of the textarea
- */
- defaultValue?: string;
- /**
- * Placeholder text for the textarea
- */
- placeholder?: string;
- /**
- * Size of the textarea
- * @default 'medium'
- */
- size?: ItemSize;
- /**
- * Maximum number of rows the textarea can expand to
- */
- maxRows?: number;
- /**
- * Minimum number of rows the textarea can expand to
- */
- minRows?: number;
- /**
- * Whether the textarea is disabled
- */
- disabled?: boolean;
- /**
- * Whether the textarea has an error
- */
- error?: boolean;
- /**
- * Callback function to handle change in textarea value
- */
- onChange?: (event: React.ChangeEvent) => void;
- /**
- * Callback function to handle click
- */
- onClick?: (event: React.MouseEvent) => void;
- /**
- * Function called when the input loses focus.
- * The event parameter may be undefined.
- */
- onBlur?: (event?: React.FocusEvent) => void;
- /**
- * Custom class name for the textarea
- */
- className?: string;
- /**
- * Enables or disables browser spell checking
- */
- spellCheck?: boolean;
-};
-
-/**
- * Component for displaying a textarea with customizable size, rows, and error state
- */
-export function TextArea({
- value,
- defaultValue,
- placeholder,
- size = 'medium',
- maxRows,
- minRows,
- disabled,
- error,
- onChange,
- onClick,
- onBlur,
- className,
- spellCheck,
- ...props
-}: TextAreaProps) {
- const containerClasses = clsx(
- styles['text-area-container'],
- inputSizeStyles[size],
- {
- 'base--error': error,
- 'base--disabled': disabled,
- },
+export const TextArea = forwardRef(function TextArea(
+ {
+ size = 'm',
+ state = 'default',
+ prefixIcon,
+ suffixIcon,
+ onClear,
+ label,
+ helperText,
+ isRequired,
+ maxRows,
+ minRows,
+ disabled,
+ readOnly,
className,
- );
-
- const textareaClasses = clsx(styles['text-area'], inputFontStyles[size]);
+ id,
+ required,
+ 'aria-describedby': ariaDescribedBy,
+ 'aria-invalid': ariaInvalid,
+ ...props
+ },
+ ref,
+) {
+ const isReadOnly = !disabled && (state === 'read-only' || readOnly === true);
+ const fieldState = disabled && state === 'read-only' ? 'default' : isReadOnly ? 'read-only' : state;
+ const isNativeRequired = required || isRequired;
return (
- {
- if (event.target !== event.currentTarget) return;
- event.preventDefault();
- event.currentTarget.querySelector('textarea')?.focus();
- }}
+
-
-
+ {({ controlId, describedBy }) => (
+ {
+ if (event.target !== event.currentTarget) return;
+ event.preventDefault();
+ event.currentTarget.querySelector('textarea')?.focus();
+ }}
+ >
+ {prefixIcon && {prefixIcon} }
+
+ {suffixIcon && {suffixIcon} }
+ {onClear && (
+ event.preventDefault()}
+ onClick={onClear}
+ >
+
+
+ )}
+
+ )}
+
);
-}
+});
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index d21caad50..5e40367af 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -15,6 +15,7 @@ export * from './components/input';
export * from './components/menu';
export * from './components/modal';
export * from './components/node';
+export * from './components/number-field';
export * from './components/radio-button';
export * from './components/segment-picker';
export * from './components/select';
@@ -29,3 +30,4 @@ export * from './components/tooltip';
export * from './shared/types/size';
export * from './shared/types/item-size';
export * from './shared/types/with-icon';
+export * from './shared/types/field';
diff --git a/packages/ui/src/shared/components/field/field.module.css b/packages/ui/src/shared/components/field/field.module.css
new file mode 100644
index 000000000..e50199b48
--- /dev/null
+++ b/packages/ui/src/shared/components/field/field.module.css
@@ -0,0 +1,35 @@
+@layer ui.component {
+ .field {
+ display: flex;
+ min-width: 0;
+ width: 100%;
+ flex-direction: column;
+ gap: var(--wb-space-75);
+ }
+
+ .label,
+ .helper {
+ display: flex;
+ height: var(--wb-size-200);
+ align-items: center;
+ font-size: var(--wb-font-size-150);
+ font-weight: 400;
+ line-height: var(--wb-size-200);
+ }
+
+ .label {
+ color: var(--wb-ui-text-default);
+ }
+
+ .required {
+ color: var(--wb-components-text-field-stroke-critical);
+ }
+
+ .helper {
+ color: var(--wb-ui-text-subtle-default);
+ }
+
+ .field[data-state='critical'] .helper {
+ color: var(--wb-components-text-field-stroke-critical);
+ }
+}
diff --git a/packages/ui/src/shared/components/field/field.tsx b/packages/ui/src/shared/components/field/field.tsx
new file mode 100644
index 000000000..fc6aa46e7
--- /dev/null
+++ b/packages/ui/src/shared/components/field/field.tsx
@@ -0,0 +1,54 @@
+import { type ReactNode, useId } from 'react';
+
+import styles from './field.module.css';
+
+import type { FieldState } from '../../types/field';
+
+type FieldRenderProps = {
+ controlId: string | undefined;
+ describedBy: string | undefined;
+};
+
+type FieldProps = {
+ ariaDescribedBy?: string;
+ children: (props: FieldRenderProps) => ReactNode;
+ helperText?: ReactNode;
+ id?: string;
+ isRequired?: boolean;
+ label?: ReactNode;
+ state: FieldState;
+};
+
+export function Field({ ariaDescribedBy, children, helperText, id, isRequired, label, state }: FieldProps) {
+ const generatedId = useId();
+ const hasLabel = label !== undefined && label !== null;
+ const hasHelper = helperText !== undefined && helperText !== null;
+ const isComposed = hasLabel || hasHelper;
+ const controlId = id ?? (isComposed ? generatedId : undefined);
+ const helperId = hasHelper ? `${controlId ?? generatedId}-helper` : undefined;
+ const describedBy = [ariaDescribedBy, helperId].filter(Boolean).join(' ') || undefined;
+ const control = children({ controlId, describedBy });
+
+ if (!isComposed) return control;
+
+ return (
+
+ {hasLabel && (
+
+ {label}
+ {isRequired && (
+
+ *
+
+ )}
+
+ )}
+ {control}
+ {hasHelper && (
+
+ {helperText}
+
+ )}
+
+ );
+}
diff --git a/packages/ui/src/shared/styles/field-control-size.module.css b/packages/ui/src/shared/styles/field-control-size.module.css
new file mode 100644
index 000000000..59f0cefa3
--- /dev/null
+++ b/packages/ui/src/shared/styles/field-control-size.module.css
@@ -0,0 +1,51 @@
+:root {
+ --ax-public-input-height-l: calc(var(--wb-size-500) + var(--wb-size-25));
+ --ax-public-input-height-m: calc(var(--wb-size-400) + var(--wb-size-50));
+ --ax-public-input-height-s: var(--wb-size-400);
+ --ax-public-input-height-xs: calc(var(--wb-size-300) + var(--wb-size-50));
+
+ --ax-public-input-padding-l: var(--wb-space-137) var(--wb-space-150);
+ --ax-public-input-padding-m: var(--wb-space-125) var(--wb-space-150);
+ --ax-public-input-padding-s: var(--wb-space-100) var(--wb-space-150);
+ --ax-public-input-padding-xs: var(--wb-space-75) var(--wb-space-150);
+
+ --ax-public-input-border-radius-l: var(--wb-radius-100);
+ --ax-public-input-border-radius-m: var(--wb-radius-75);
+ --ax-public-input-border-radius-s: var(--wb-radius-75);
+ --ax-public-input-border-radius-xs: var(--wb-radius-75);
+
+ --ax-public-input-gap-l: var(--wb-space-100);
+ --ax-public-input-gap-m: var(--wb-space-100);
+ --ax-public-input-gap-s: var(--wb-space-100);
+ --ax-public-input-gap-xs: var(--wb-space-100);
+}
+
+@layer ui.component {
+ .l {
+ height: var(--ax-public-input-height-l);
+ padding: var(--ax-public-input-padding-l);
+ border-radius: var(--ax-public-input-border-radius-l);
+ gap: var(--ax-public-input-gap-l);
+ }
+
+ .m {
+ height: var(--ax-public-input-height-m);
+ padding: var(--ax-public-input-padding-m);
+ border-radius: var(--ax-public-input-border-radius-m);
+ gap: var(--ax-public-input-gap-m);
+ }
+
+ .s {
+ height: var(--ax-public-input-height-s);
+ padding: var(--ax-public-input-padding-s);
+ border-radius: var(--ax-public-input-border-radius-s);
+ gap: var(--ax-public-input-gap-s);
+ }
+
+ .xs {
+ height: var(--ax-public-input-height-xs);
+ padding: var(--ax-public-input-padding-xs);
+ border-radius: var(--ax-public-input-border-radius-xs);
+ gap: var(--ax-public-input-gap-xs);
+ }
+}
diff --git a/packages/ui/src/shared/styles/input-font-size.module.css b/packages/ui/src/shared/styles/input-font-size.module.css
index 93cb8746e..341811578 100644
--- a/packages/ui/src/shared/styles/input-font-size.module.css
+++ b/packages/ui/src/shared/styles/input-font-size.module.css
@@ -1,4 +1,14 @@
@layer ui.component {
+ .l {
+ composes: wb-text-label-l from global;
+ }
+
+ .m,
+ .s,
+ .xs {
+ composes: wb-text-label-m from global;
+ }
+
.large {
composes: ax-public-p9 from global;
}
diff --git a/packages/ui/src/shared/types/field.ts b/packages/ui/src/shared/types/field.ts
new file mode 100644
index 000000000..6ad70754b
--- /dev/null
+++ b/packages/ui/src/shared/types/field.ts
@@ -0,0 +1,26 @@
+import type { ReactNode } from 'react';
+
+export const FIELD_STATES = ['default', 'critical', 'success', 'read-only'] as const;
+export const FIELD_SIZES = ['l', 'm', 's', 'xs'] as const;
+
+export type FieldState = (typeof FIELD_STATES)[number];
+export type FieldSize = (typeof FIELD_SIZES)[number];
+
+export type FieldControlProps = {
+ /**
+ * Disabled controls take precedence over the read-only state.
+ * @default 'default'
+ */
+ state?: FieldState;
+ /**
+ * @default 'm'
+ */
+ size?: FieldSize;
+ prefixIcon?: ReactNode;
+ suffixIcon?: ReactNode;
+ /** Renders a clear affordance that invokes this callback. */
+ onClear?: () => void;
+ label?: ReactNode;
+ helperText?: ReactNode;
+ isRequired?: boolean;
+};
diff --git a/packages/ui/vite.config.mts b/packages/ui/vite.config.mts
index c736f77e0..986f4c474 100644
--- a/packages/ui/vite.config.mts
+++ b/packages/ui/vite.config.mts
@@ -27,6 +27,7 @@ const componentEntries = [
'menu',
'modal',
'node',
+ 'number-field',
'radio-button',
'segment-picker',
'select',