@kennycorrea/ui 1.8.1 → 1.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.8.4 — 2026-10-30
4
+
5
+ A `MentionTextarea` molecule.
6
+
7
+ - **`MentionTextarea`**: a `Textarea` that is an ARIA combobox for inline tokens. `triggers` maps a character to a kind (`{ '$': 'ticker', '@': 'member' }`); a trigger at the start or after whitespace, up to the caret, is the token. `onQuery(kind, q)` returns `{ value, label, detail? }` options, sync or async (an answer to an older query is dropped). `onPick` defaults to replacing the token with `<trigger><value> ` and putting the caret after it.
8
+ - The listbox sits under the field (`role="listbox"`/`option` on divs, `aria-activedescendant`, focus stays in the textarea). Arrow keys move, Enter or Tab picks, Esc closes; options pick on mousedown without blurring. Radix tokens only, so light and dark follow the theme.
9
+ - **New UI string `suggestions`** names the listbox. `findMentionToken` is exported too. Tests in `test/MentionTextarea.test.tsx`.
10
+
11
+ ## 1.8.2 — 2026-10-30
12
+
13
+ A live validation status on `FormField`.
14
+
15
+ - **`status` and `statusMessage` on `FormField`**, for checks run as the user types, such as a username being looked up. `checking` shows a spinner and muted text; `valid` shows a check icon and text in `--color-good-ink` (jade-11, at least 4.5:1 on the panel in light and dark); `invalid` looks and behaves like `error`. The icon and the words carry the meaning, not the colour. While shown, the message replaces the hint and is linked through `aria-describedby`. `error` wins when both are set.
16
+ - **`announce`** repeats the message in a polite `role="status"` region. The region starts empty, so nothing is announced on first render, only changes. Off by default.
17
+ - Tests in `test/FormField-status.test.tsx`; demo in `examples/UsernameField.tsx`.
18
+
3
19
  ## 1.8.1 — 2026-10-30
4
20
 
5
21
  TabBar label contrast.
package/README.md CHANGED
@@ -284,6 +284,22 @@ Full example with 500 rows: [examples/admin/Orders.tsx](examples/admin/Orders.ts
284
284
  - **`FilterPopover`**: a "Filters (n)" button opening a popover, or a bottom sheet below `sm`.
285
285
  With `onApply` the controls are a draft. **`FilterChips`** is a ChipGroup plus clear-all.
286
286
  **`FilterBar`** lays out search, filters and actions, with active filters as removable pills.
287
+ - **`MentionTextarea`**: a Textarea with inline tokens such as `@ana` or `$AAPL`, as an ARIA
288
+ combobox. Typing a trigger opens a listbox under the field; arrows move, Enter/Tab picks,
289
+ Esc closes, and a click picks without taking focus from the textarea.
290
+
291
+ ```tsx
292
+ <MentionTextarea
293
+ value={note}
294
+ onValueChange={setNote}
295
+ triggers={{ $: 'ticker', '@': 'member' }}
296
+ onQuery={(kind, q) => (kind === 'member' ? searchMembers(q) : searchTickers(q))}
297
+ />
298
+ ```
299
+
300
+ Options are `{ value, label, detail? }`, sync or async. `onPick(option, token, text)` returns
301
+ `{ value, caret }`; the default replaces the token with `<trigger><value> `. The listbox is
302
+ named by the `suggestions` UI string.
287
303
  - **`SettingsPage`**: section list beside the content from 768px; a select above it when
288
304
  narrower.
289
305
 
@@ -12,6 +12,19 @@ export interface FormFieldProps {
12
12
  hint?: ReactNode;
13
13
  /** The validation message. Marks the control invalid and is linked with `aria-describedby`. */
14
14
  error?: ReactNode;
15
+ /**
16
+ * A live check's state — a username looked up as it is typed. `checking` shows a spinner,
17
+ * `valid` a check, `invalid` the same red state as `error`. `error` wins when both are set.
18
+ * While shown, `statusMessage` replaces the hint.
19
+ */
20
+ status?: FormFieldStatus;
21
+ /** The words for `status`. The icon and these carry the meaning, never the colour alone. */
22
+ statusMessage?: ReactNode;
23
+ /**
24
+ * Repeat `statusMessage` in a polite live region (`role="status"`). Only changes are
25
+ * announced, never what was there on first render.
26
+ */
27
+ announce?: boolean;
15
28
  required?: boolean;
16
29
  /** Defaults to a generated id. */
17
30
  id?: string;
@@ -28,6 +41,7 @@ export interface FormFieldProps {
28
41
  children: ReactElement | ((control: FormFieldControlProps) => ReactNode);
29
42
  className?: string;
30
43
  }
44
+ export type FormFieldStatus = 'checking' | 'valid' | 'invalid';
31
45
  /**
32
46
  * A label, a control, a hint and an error, wired together: the label names the control, the
33
47
  * hint and error describe it, and the error marks it invalid.
@@ -35,7 +49,7 @@ export interface FormFieldProps {
35
49
  * The error is text with an icon, never colour alone. It is not a live region: announcing each
36
50
  * field's error as it appears is noisy, and `ErrorSummary` says it once, on submit.
37
51
  */
38
- export declare function FormField({ label, hint, error, required, id: idProp, layout, children, className, }: FormFieldProps): import("react").JSX.Element;
52
+ export declare function FormField({ label, hint, error, status, statusMessage, announce, required, id: idProp, layout, children, className, }: FormFieldProps): import("react").JSX.Element;
39
53
  export interface FormFieldsetProps {
40
54
  /** Names the group: "Notifications", "Plan". */
41
55
  legend: ReactNode;
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
3
- import { cloneElement, isValidElement, useId } from 'react';
2
+ import { CheckCircledIcon, ExclamationTriangleIcon } from '@radix-ui/react-icons';
3
+ import { Spinner as ThemedSpinner } from '@radix-ui/themes';
4
+ import { cloneElement, isValidElement, useEffect, useId, useRef, useState, } from 'react';
4
5
  import { Input } from '../../atoms/Input/Input.js';
5
6
  import { Label } from '../../atoms/Label/Label.js';
6
7
  import { Select } from '../../atoms/Select/Select.js';
@@ -14,16 +15,29 @@ const INVALID_PROP = new Set([Input, Textarea, Select, PasswordInput]);
14
15
  * The error is text with an icon, never colour alone. It is not a live region: announcing each
15
16
  * field's error as it appears is noisy, and `ErrorSummary` says it once, on submit.
16
17
  */
17
- export function FormField({ label, hint, error, required, id: idProp, layout = 'stacked', children, className, }) {
18
+ export function FormField({ label, hint, error, status, statusMessage, announce, required, id: idProp, layout = 'stacked', children, className, }) {
18
19
  const auto = useId();
19
20
  const id = idProp ?? auto;
20
21
  const hintId = `${id}-hint`;
21
22
  const errorId = `${id}-error`;
22
- const describedBy = [hint ? hintId : null, error ? errorId : null].filter(Boolean).join(' ');
23
+ const statusId = `${id}-status`;
24
+ // The error wins; a status with words stands in for the hint while it is shown.
25
+ const shownStatus = !error && status ? status : undefined;
26
+ const showStatus = !!shownStatus && statusMessage != null && statusMessage !== false;
27
+ const showHint = !!hint && !showStatus;
28
+ const invalid = !!error || shownStatus === 'invalid';
29
+ const describedBy = [
30
+ showHint ? hintId : null,
31
+ showStatus ? statusId : null,
32
+ error ? errorId : null,
33
+ ]
34
+ .filter(Boolean)
35
+ .join(' ');
36
+ const announced = useAnnouncement(showStatus ? statusMessage : null, shownStatus, statusMessage);
23
37
  const control = {
24
38
  id,
25
39
  'aria-describedby': describedBy || undefined,
26
- 'aria-invalid': error ? true : undefined,
40
+ 'aria-invalid': invalid ? true : undefined,
27
41
  required: required || undefined,
28
42
  };
29
43
  let rendered;
@@ -35,7 +49,7 @@ export function FormField({ label, hint, error, required, id: idProp, layout = '
35
49
  // Passing the key at all, even as undefined, would override theirs, so it is left out.
36
50
  const { 'aria-invalid': ariaInvalid, ...rest } = control;
37
51
  const props = INVALID_PROP.has(children.type)
38
- ? { ...rest, invalid: !!error }
52
+ ? { ...rest, invalid }
39
53
  : { ...rest, ...(ariaInvalid ? { 'aria-invalid': ariaInvalid } : {}) };
40
54
  rendered = cloneElement(children, props);
41
55
  }
@@ -43,13 +57,40 @@ export function FormField({ label, hint, error, required, id: idProp, layout = '
43
57
  rendered = children;
44
58
  }
45
59
  const labelEl = (_jsx(Label, { htmlFor: id, required: required, children: label }));
46
- const hintEl = hint ? (_jsx("p", { id: hintId, className: "ui-field__hint", children: hint })) : null;
47
- const errorEl = error ? _jsx(FieldError, { id: errorId, children: error }) : null;
48
- return (_jsx("div", { className: ['ui-field', className].filter(Boolean).join(' '), "data-layout": layout, children: layout === 'inline' ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "ui-field__control", children: rendered }), _jsxs("span", { className: "ui-field__text", children: [labelEl, hintEl, errorEl] })] })) : (_jsxs(_Fragment, { children: [labelEl, hintEl, rendered, errorEl] })) }));
60
+ const hintEl = showHint ? (_jsx("p", { id: hintId, className: "ui-field__hint", children: hint })) : null;
61
+ const errorEl = error ? (_jsx(FieldError, { id: errorId, children: error })) : showStatus && shownStatus ? (_jsx(FieldStatus, { id: statusId, status: shownStatus, children: statusMessage })) : null;
62
+ // Always mounted while opted in, so a change is a change to an existing region.
63
+ const liveEl = announce ? (_jsx("span", { role: "status", className: "ui-sr-only", children: announced })) : null;
64
+ return (_jsx("div", { className: ['ui-field', className].filter(Boolean).join(' '), "data-layout": layout, children: layout === 'inline' ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "ui-field__control", children: rendered }), _jsxs("span", { className: "ui-field__text", children: [labelEl, hintEl, errorEl] }), liveEl] })) : (_jsxs(_Fragment, { children: [labelEl, hintEl, rendered, errorEl, liveEl] })) }));
49
65
  }
50
66
  function FieldError({ id, children }) {
51
67
  return (_jsxs("p", { id: id, className: "ui-field__error", children: [_jsx(ExclamationTriangleIcon, { "aria-hidden": "true" }), _jsx("span", { children: children })] }));
52
68
  }
69
+ function FieldStatus({ id, status, children, }) {
70
+ if (status === 'invalid')
71
+ return _jsx(FieldError, { id: id, children: children });
72
+ return (_jsxs("p", { id: id, className: "ui-field__status", "data-status": status, children: [status === 'checking' ? (_jsx("span", { "aria-hidden": "true", className: "ui-field__status-icon", children: _jsx(ThemedSpinner, { size: "1" }) })) : (_jsx(CheckCircledIcon, { "aria-hidden": "true" })), _jsx("span", { children: children })] }));
73
+ }
74
+ /**
75
+ * What the live region holds: empty on first render, then the message whenever the status or
76
+ * message changes. A region that mounts with text is announced by some screen readers and not
77
+ * others, so it mounts empty and only ever receives changes.
78
+ */
79
+ function useAnnouncement(message, status, source) {
80
+ const [text, setText] = useState(null);
81
+ const first = useRef(true);
82
+ const latest = useRef(message);
83
+ latest.current = message;
84
+ // biome-ignore lint/correctness/useExhaustiveDependencies: keyed on what changed, read through the ref.
85
+ useEffect(() => {
86
+ if (first.current) {
87
+ first.current = false;
88
+ return;
89
+ }
90
+ setText(latest.current);
91
+ }, [status, source]);
92
+ return text;
93
+ }
53
94
  /**
54
95
  * A `fieldset` for controls that answer one question together. The legend names the group;
55
96
  * hint and error describe it.
@@ -0,0 +1,46 @@
1
+ import { type ReactNode } from 'react';
2
+ import { type TextareaProps } from '../../atoms/Textarea/Textarea.js';
3
+ export interface MentionOption {
4
+ value: string;
5
+ label: ReactNode;
6
+ /** Secondary text, shown muted beside the label. */
7
+ detail?: ReactNode;
8
+ }
9
+ /** The token under the caret: its trigger, the kind it maps to, the query and where it sits. */
10
+ export interface MentionToken {
11
+ trigger: string;
12
+ kind: string;
13
+ query: string;
14
+ /** Index of the trigger character. */
15
+ start: number;
16
+ /** Index just past the token (the caret). */
17
+ end: number;
18
+ }
19
+ export interface MentionTextareaProps extends Omit<TextareaProps, 'value' | 'defaultValue' | 'onChange' | 'role'> {
20
+ value?: string;
21
+ defaultValue?: string;
22
+ onValueChange?: (value: string) => void;
23
+ /** Trigger character → kind, e.g. `{ '$': 'ticker', '@': 'member' }`. */
24
+ triggers: Record<string, string>;
25
+ /** The options for a token. May be async; an answer to an older query is dropped. */
26
+ onQuery: (kind: string, query: string) => MentionOption[] | Promise<MentionOption[]>;
27
+ /**
28
+ * Returns the new text and caret, or nothing to leave the text alone.
29
+ * Default: replace the token with `<trigger><value> `.
30
+ */
31
+ onPick?: (option: MentionOption, token: MentionToken, text: string) => {
32
+ value: string;
33
+ caret: number;
34
+ } | undefined;
35
+ }
36
+ /** The token ending at `caret`: a trigger at the start or after whitespace, then no whitespace. */
37
+ export declare function findMentionToken(text: string, caret: number, triggers: Record<string, string>): MentionToken | null;
38
+ /**
39
+ * A Textarea that is also an ARIA combobox for inline tokens such as `@ana` or `$AAPL`.
40
+ *
41
+ * Typing a trigger opens a listbox under the field. Arrow keys move the active option, announced
42
+ * through `aria-activedescendant` so focus never leaves the textarea; Enter or Tab picks it and
43
+ * Escape closes the list. Options are picked on mousedown with the default prevented, so the
44
+ * textarea keeps focus and the caret. Colours are Radix tokens, so the list follows light and dark.
45
+ */
46
+ export declare const MentionTextarea: import("react").ForwardRefExoticComponent<MentionTextareaProps & import("react").RefAttributes<HTMLTextAreaElement>>;
@@ -0,0 +1,158 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useRef, useState, } from 'react';
3
+ import { Textarea } from '../../atoms/Textarea/Textarea.js';
4
+ import { useUiStrings } from '../../strings.js';
5
+ /** The token ending at `caret`: a trigger at the start or after whitespace, then no whitespace. */
6
+ export function findMentionToken(text, caret, triggers) {
7
+ for (let i = caret - 1; i >= 0; i--) {
8
+ const ch = text[i];
9
+ if (/\s/.test(ch))
10
+ return null;
11
+ const kind = triggers[ch];
12
+ if (kind !== undefined && (i === 0 || /\s/.test(text[i - 1]))) {
13
+ return { trigger: ch, kind, query: text.slice(i + 1, caret), start: i, end: caret };
14
+ }
15
+ }
16
+ return null;
17
+ }
18
+ function defaultPick(option, token, text) {
19
+ const insert = `${token.trigger}${option.value} `;
20
+ return {
21
+ value: text.slice(0, token.start) + insert + text.slice(token.end),
22
+ caret: token.start + insert.length,
23
+ };
24
+ }
25
+ /**
26
+ * A Textarea that is also an ARIA combobox for inline tokens such as `@ana` or `$AAPL`.
27
+ *
28
+ * Typing a trigger opens a listbox under the field. Arrow keys move the active option, announced
29
+ * through `aria-activedescendant` so focus never leaves the textarea; Enter or Tab picks it and
30
+ * Escape closes the list. Options are picked on mousedown with the default prevented, so the
31
+ * textarea keeps focus and the caret. Colours are Radix tokens, so the list follows light and dark.
32
+ */
33
+ export const MentionTextarea = forwardRef(function MentionTextarea({ value, defaultValue = '', onValueChange, triggers, onQuery, onPick = defaultPick, onKeyDown, onBlur, onSelect, id, ...props }, ref) {
34
+ const strings = useUiStrings();
35
+ const autoId = useId();
36
+ const listId = `${id ?? autoId}-listbox`;
37
+ const optionId = (i) => `${listId}-${i}`;
38
+ const area = useRef(null);
39
+ useImperativeHandle(ref, () => area.current);
40
+ const [text, setText] = useState(value ?? defaultValue);
41
+ const [token, setToken] = useState(null);
42
+ const [options, setOptions] = useState([]);
43
+ const [active, setActive] = useState(0);
44
+ const request = useRef(0);
45
+ const pendingCaret = useRef(null);
46
+ useEffect(() => {
47
+ if (value !== undefined)
48
+ setText(value);
49
+ }, [value]);
50
+ // After a pick re-renders the text, put the caret after the inserted token.
51
+ useEffect(() => {
52
+ if (pendingCaret.current === null || !area.current)
53
+ return;
54
+ area.current.setSelectionRange(pendingCaret.current, pendingCaret.current);
55
+ pendingCaret.current = null;
56
+ });
57
+ const close = useCallback(() => {
58
+ request.current++;
59
+ setToken(null);
60
+ setOptions([]);
61
+ }, []);
62
+ const detect = useCallback((nextText, caret) => {
63
+ const found = findMentionToken(nextText, caret, triggers);
64
+ if (!found) {
65
+ close();
66
+ return;
67
+ }
68
+ setToken((prev) => prev && prev.start === found.start && prev.query === found.query ? prev : found);
69
+ const ticket = ++request.current;
70
+ Promise.resolve(onQuery(found.kind, found.query)).then((result) => {
71
+ if (ticket !== request.current)
72
+ return;
73
+ setOptions(result);
74
+ setActive(0);
75
+ });
76
+ }, [triggers, onQuery, close]);
77
+ const commit = (next) => {
78
+ if (value === undefined)
79
+ setText(next);
80
+ onValueChange?.(next);
81
+ };
82
+ const pick = (option) => {
83
+ if (!token)
84
+ return;
85
+ const result = onPick(option, token, text);
86
+ close();
87
+ if (!result)
88
+ return;
89
+ pendingCaret.current = result.caret;
90
+ commit(result.value);
91
+ };
92
+ const open = token !== null && options.length > 0;
93
+ const handleKeyDown = (event) => {
94
+ if (open) {
95
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
96
+ event.preventDefault();
97
+ const step = event.key === 'ArrowDown' ? 1 : -1;
98
+ setActive((a) => (a + step + options.length) % options.length);
99
+ return;
100
+ }
101
+ if ((event.key === 'Enter' && !event.shiftKey) || event.key === 'Tab') {
102
+ event.preventDefault();
103
+ pick(options[active]);
104
+ return;
105
+ }
106
+ if (event.key === 'Escape') {
107
+ event.preventDefault();
108
+ event.stopPropagation();
109
+ close();
110
+ return;
111
+ }
112
+ }
113
+ onKeyDown?.(event);
114
+ };
115
+ return (_jsxs("div", { style: { position: 'relative' }, children: [_jsx(Textarea, { ...props, id: id, ref: area, value: text, role: "combobox", "aria-autocomplete": "list", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listId : undefined, "aria-activedescendant": open ? optionId(active) : undefined, onChange: (event) => {
116
+ const next = event.target.value;
117
+ commit(next);
118
+ detect(next, event.target.selectionStart ?? next.length);
119
+ }, onSelect: (event) => {
120
+ const el = event.currentTarget;
121
+ if (el.selectionStart === el.selectionEnd)
122
+ detect(el.value, el.selectionStart);
123
+ else
124
+ close();
125
+ onSelect?.(event);
126
+ }, onKeyDown: handleKeyDown, onBlur: (event) => {
127
+ close();
128
+ onBlur?.(event);
129
+ } }), open && (_jsx("div", { id: listId, role: "listbox", "aria-label": strings.suggestions, className: "ui-mention-listbox", style: {
130
+ position: 'absolute',
131
+ insetInlineStart: 0,
132
+ insetInlineEnd: 0,
133
+ top: '100%',
134
+ marginTop: 4,
135
+ zIndex: 50,
136
+ maxHeight: 240,
137
+ overflowY: 'auto',
138
+ padding: 4,
139
+ background: 'var(--color-panel-solid)',
140
+ color: 'var(--gray-12)',
141
+ border: '1px solid var(--gray-a6)',
142
+ borderRadius: 'var(--radius-3)',
143
+ boxShadow: 'var(--shadow-4)',
144
+ }, children: options.map((option, i) => (_jsxs("div", { id: optionId(i), role: "option", tabIndex: -1, "aria-selected": i === active, onMouseDown: (event) => {
145
+ event.preventDefault();
146
+ pick(option);
147
+ }, onMouseMove: () => setActive(i), style: {
148
+ display: 'flex',
149
+ gap: 8,
150
+ alignItems: 'baseline',
151
+ padding: '6px 8px',
152
+ minHeight: 32,
153
+ borderRadius: 'var(--radius-2)',
154
+ cursor: 'pointer',
155
+ background: i === active ? 'var(--accent-a4)' : undefined,
156
+ color: i === active ? 'var(--accent-12)' : undefined,
157
+ }, children: [_jsx("span", { children: option.label }), option.detail != null && (_jsx("span", { style: { color: 'var(--gray-11)', fontSize: 'var(--font-size-1)' }, children: option.detail }))] }, option.value))) }))] }));
158
+ });
@@ -0,0 +1 @@
1
+ export * from './MentionTextarea.js';
@@ -0,0 +1 @@
1
+ export * from './MentionTextarea.js';
@@ -12,6 +12,7 @@ export * from './FormField/index.js';
12
12
  export * from './FormSection/index.js';
13
13
  export * from './KeyValueList/index.js';
14
14
  export * from './LoadingState/index.js';
15
+ export * from './MentionTextarea/index.js';
15
16
  export * from './NavItem/index.js';
16
17
  export * from './Pagination/index.js';
17
18
  export * from './PasswordInput/index.js';
@@ -12,6 +12,7 @@ export * from './FormField/index.js';
12
12
  export * from './FormSection/index.js';
13
13
  export * from './KeyValueList/index.js';
14
14
  export * from './LoadingState/index.js';
15
+ export * from './MentionTextarea/index.js';
15
16
  export * from './NavItem/index.js';
16
17
  export * from './Pagination/index.js';
17
18
  export * from './PasswordInput/index.js';
package/dist/strings.d.ts CHANGED
@@ -128,6 +128,8 @@ export interface UiStrings {
128
128
  viewAsChart: string;
129
129
  /** PasswordInput: the visibility toggle's name. It is a toggle (`aria-pressed`), so one name. */
130
130
  showPassword: string;
131
+ /** MentionTextarea: the name of the listbox of suggestions for the token at the caret. */
132
+ suggestions: string;
131
133
  }
132
134
  /**
133
135
  * Feeds the components their strings, once, near the root.
package/dist/strings.js CHANGED
@@ -72,6 +72,7 @@ const DEFAULTS = {
72
72
  viewAsTable: 'View as table',
73
73
  viewAsChart: 'View as chart',
74
74
  showPassword: 'Show password',
75
+ suggestions: 'Suggestions',
75
76
  };
76
77
  const StringsContext = createContext(DEFAULTS);
77
78
  const LocaleContext = createContext(undefined);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kennycorrea/ui",
3
- "version": "1.8.1",
3
+ "version": "1.8.4",
4
4
  "description": "A React + TypeScript component library built on Radix Themes: atoms, a Tailwind token bridge and theming.",
5
5
  "private": false,
6
6
  "type": "module",
package/tokens.css CHANGED
@@ -1274,6 +1274,10 @@
1274
1274
  .ui-field__hint { margin: 0; font-size: 13px; color: var(--color-ink-muted); }
1275
1275
  .ui-field__error { display: flex; align-items: flex-start; gap: 6px; margin: 0; font-size: 13px; font-weight: 500; color: var(--red-11); }
1276
1276
  .ui-field__error svg { flex: none; margin-block-start: 2px; }
1277
+ .ui-field__status { display: flex; align-items: flex-start; gap: 6px; margin: 0; font-size: 13px; color: var(--color-ink-muted); }
1278
+ .ui-field__status[data-status="valid"] { font-weight: 500; color: var(--color-good-ink); }
1279
+ .ui-field__status svg, .ui-field__status-icon { flex: none; margin-block-start: 2px; }
1280
+ .ui-field__status-icon { display: inline-flex; }
1277
1281
  .ui-fieldset { display: flex; flex-direction: column; gap: 8px; min-inline-size: 0; margin: 0; padding: 0; border: 0; }
1278
1282
  .ui-fieldset__legend { padding: 0; margin-block-end: 2px; font-size: 14px; font-weight: 500; color: var(--color-ink); }
1279
1283