diff --git a/packages/ui/src/components/date-picker/date-picker.module.css b/packages/ui/src/components/date-picker/date-picker.module.css
index 701b66a61..e935e9b94 100644
--- a/packages/ui/src/components/date-picker/date-picker.module.css
+++ b/packages/ui/src/components/date-picker/date-picker.module.css
@@ -11,9 +11,9 @@
}
&.container--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);
+ background-color: var(--ax-public-input-root-background-color-critical);
+ border-color: var(--ax-public-input-root-border-color-critical);
+ color: var(--ax-public-input-root-color-critical);
}
&.container--placeholder {
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..d81d6629c 100644
--- a/packages/ui/src/components/input/input-root.module.css
+++ b/packages/ui/src/components/input/input-root.module.css
@@ -3,33 +3,73 @@
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);
+ }
- &:not(:hover) {
- transition: all var(--ax-public-transition);
+ &[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);
+ }
+
+ &[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);
+ }
+
+ &[data-disabled] {
+ color: var(--ax-public-input-root-color-disabled);
+ cursor: not-allowed;
}
- &:has(input:focus-visible) {
+ &:not([data-disabled]):focus-within {
border-color: var(--ax-public-input-root-border-color-focus);
}
+ }
+
+ .icon,
+ .clear {
+ display: inline-flex;
+ flex: 0 0 auto;
+ align-items: center;
+ justify-content: center;
+ }
- &: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 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..4cfe9a52e 100644
--- a/packages/ui/src/components/input/input.tsx
+++ b/packages/ui/src/components/input/input.tsx
@@ -1,43 +1,94 @@
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 inputHeightStyles from '@ui/shared/styles/field-control-height.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 { 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,
+ clearLabel = 'Clear input',
+ 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 instanceof Element)) return;
+ const interactiveTarget = event.target.closest('button, a, input, select, textarea, [tabindex]');
+ if (interactiveTarget && event.currentTarget.contains(interactiveTarget)) 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..cf6f4b04a 100644
--- a/packages/ui/src/components/input/variables.css
+++ b/packages/ui/src/components/input/variables.css
@@ -1,14 +1,20 @@
: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-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-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-color-critical: var(--wb-ui-text-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..a1aedeebd 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,82 @@
: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%;
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);
+ }
+ }
+
+ .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 +87,46 @@
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 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..d02fd2054 100644
--- a/packages/ui/src/components/text-area/text-area.tsx
+++ b/packages/ui/src/components/text-area/text-area.tsx
@@ -1,125 +1,102 @@
+import { X } from '@phosphor-icons/react';
+import { Field } from '@ui/shared/components/field/field';
+import type { FieldControlProps, FieldSize } 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 = {
- /**
- * 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;
-};
+ size?: Exclude;
+ style?: ComponentPropsWithoutRef['style'];
+} & Omit, 'size' | 'style'> &
+ FieldControlProps;
-/**
- * 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,
+ clearLabel = 'Clear text area',
+ 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 instanceof Element)) return;
+ const interactiveTarget = event.target.closest('button, a, input, select, textarea, [tabindex]');
+ if (interactiveTarget && event.currentTarget.contains(interactiveTarget)) 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 87221e9c5..83cce0c93 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -29,3 +29,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..a98b94b35
--- /dev/null
+++ b/packages/ui/src/shared/components/field/field.module.css
@@ -0,0 +1,50 @@
+:root {
+ --ax-public-field-gap: var(--wb-space-75);
+ --ax-public-field-row-min-height: var(--wb-size-200);
+ --ax-public-field-font-size: var(--wb-font-size-150);
+ --ax-public-field-font-weight: 400;
+ --ax-public-field-line-height: var(--wb-size-200);
+ --ax-public-field-label-color: var(--wb-ui-text-default);
+ --ax-public-field-helper-color: var(--wb-ui-text-subtle-default);
+ --ax-public-field-critical-color: var(--wb-components-text-field-stroke-critical);
+ --ax-public-field-color-disabled: var(--wb-ui-text-ghost-default);
+}
+
+@layer ui.component {
+ .field {
+ display: flex;
+ min-width: 0;
+ width: 100%;
+ flex-direction: column;
+ gap: var(--ax-public-field-gap);
+ }
+
+ .label,
+ .helper {
+ display: flex;
+ min-height: var(--ax-public-field-row-min-height);
+ font-size: var(--ax-public-field-font-size);
+ font-weight: var(--ax-public-field-font-weight);
+ line-height: var(--ax-public-field-line-height);
+ }
+
+ .label {
+ color: var(--ax-public-field-label-color);
+ }
+
+ .required {
+ color: var(--ax-public-field-critical-color);
+ }
+
+ .helper {
+ color: var(--ax-public-field-helper-color);
+ }
+
+ .field[data-state='critical']:not([data-disabled]) .helper {
+ color: var(--ax-public-field-critical-color);
+ }
+
+ .field[data-disabled] :is(.label, .helper, .required) {
+ color: var(--ax-public-field-color-disabled);
+ }
+}
diff --git a/packages/ui/src/shared/components/field/field.spec.tsx b/packages/ui/src/shared/components/field/field.spec.tsx
new file mode 100644
index 000000000..824aea9d4
--- /dev/null
+++ b/packages/ui/src/shared/components/field/field.spec.tsx
@@ -0,0 +1,82 @@
+import { type ReactElement, act } from 'react';
+import { createRoot } from 'react-dom/client';
+
+import { Input } from '../../../components/input/input';
+import { TextArea } from '../../../components/text-area/text-area';
+import { Field } from './field';
+
+declare global {
+ // eslint-disable-next-line no-var
+ var IS_REACT_ACT_ENVIRONMENT: boolean;
+}
+globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+
+describe('Field', () => {
+ let container: HTMLDivElement;
+ let root: ReturnType;
+
+ beforeEach(() => {
+ container = document.createElement('div');
+ document.body.append(container);
+ root = createRoot(container);
+ });
+
+ afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ });
+
+ function renderField(label?: ReactElement | string | boolean, helperText?: ReactElement | string | boolean) {
+ act(() => {
+ root.render(
+
+ {({ controlId, describedBy }) => }
+ ,
+ );
+ });
+ }
+
+ it('preserves the control when composition content changes', () => {
+ renderField();
+ const input = container.querySelector('input')!;
+ input.focus();
+
+ renderField('Label', 'Helper');
+
+ expect(container.querySelector('input')).toBe(input);
+ expect(document.activeElement).toBe(input);
+ expect(container.querySelector('label')?.htmlFor).toBe(input.id);
+ });
+
+ it('does not render or reference boolean label and helper values', () => {
+ renderField(true, false);
+ const input = container.querySelector('input')!;
+
+ expect(container.querySelector('label')).toBeNull();
+ expect(container.querySelector('span')).toBeNull();
+ expect(input.getAttribute('aria-describedby')).toBeNull();
+ });
+});
+
+describe.each([
+ ['Input', , 'input'],
+ ['TextArea', , 'textarea'],
+] as const)('%s pointer guard', (_name, control, selector) => {
+ it('ignores interactive ancestors outside the field control', () => {
+ const host = document.createElement('div');
+ host.tabIndex = 0;
+ document.body.append(host);
+ const root = createRoot(host);
+
+ act(() => root.render(control));
+ const nativeControl = host.querySelector(selector)! as HTMLInputElement | HTMLTextAreaElement;
+ const controlRoot = nativeControl.parentElement!;
+
+ act(() => controlRoot.dispatchEvent(new Event('pointerdown', { bubbles: true })));
+
+ expect(document.activeElement).toBe(nativeControl);
+
+ act(() => root.unmount());
+ host.remove();
+ });
+});
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..2032cbaa6
--- /dev/null
+++ b/packages/ui/src/shared/components/field/field.tsx
@@ -0,0 +1,52 @@
+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;
+ disabled?: boolean;
+ helperText?: ReactNode;
+ id?: string;
+ isRequired?: boolean;
+ label?: ReactNode;
+ state: FieldState;
+};
+
+export function Field({ ariaDescribedBy, children, disabled, helperText, id, isRequired, label, state }: FieldProps) {
+ const generatedId = useId();
+ const hasLabel = label !== undefined && label !== null && typeof label !== 'boolean';
+ const hasHelper = helperText !== undefined && helperText !== null && typeof helperText !== 'boolean';
+ const controlId = id ?? generatedId;
+ const helperId = hasHelper ? `${controlId}-helper` : undefined;
+ const describedBy = [ariaDescribedBy, helperId].filter(Boolean).join(' ') || undefined;
+ const control = children({ controlId, describedBy });
+
+ return (
+
+ {hasLabel && (
+
+ {label}
+ {isRequired && (
+
+ *
+
+ )}
+
+ )}
+ {control}
+ {hasHelper && (
+
+ {helperText}
+
+ )}
+
+ );
+}
diff --git a/packages/ui/src/shared/styles/field-control-height.module.css b/packages/ui/src/shared/styles/field-control-height.module.css
new file mode 100644
index 000000000..addb56ab5
--- /dev/null
+++ b/packages/ui/src/shared/styles/field-control-height.module.css
@@ -0,0 +1,24 @@
+: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));
+}
+
+@layer ui.component {
+ .l {
+ min-height: var(--ax-public-input-height-l);
+ }
+
+ .m {
+ min-height: var(--ax-public-input-height-m);
+ }
+
+ .s {
+ min-height: var(--ax-public-input-height-s);
+ }
+
+ .xs {
+ min-height: var(--ax-public-input-height-xs);
+ }
+}
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..013d566a2
--- /dev/null
+++ b/packages/ui/src/shared/styles/field-control-size.module.css
@@ -0,0 +1,42 @@
+:root {
+ --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 {
+ padding: var(--ax-public-input-padding-l);
+ border-radius: var(--ax-public-input-border-radius-l);
+ gap: var(--ax-public-input-gap-l);
+ }
+
+ .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 {
+ padding: var(--ax-public-input-padding-s);
+ border-radius: var(--ax-public-input-border-radius-s);
+ gap: var(--ax-public-input-gap-s);
+ }
+
+ .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..428a61d17
--- /dev/null
+++ b/packages/ui/src/shared/types/field.ts
@@ -0,0 +1,28 @@
+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;
+ /** Accessible name for the clear affordance rendered when `onClear` is provided. */
+ clearLabel?: string;
+ label?: ReactNode;
+ helperText?: ReactNode;
+ isRequired?: boolean;
+};