@kennycorrea/ui 1.8.0 → 1.8.2

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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.8.2 — 2026-10-30
4
+
5
+ A live validation status on `FormField`.
6
+
7
+ - **`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.
8
+ - **`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.
9
+ - Tests in `test/FormField-status.test.tsx`; demo in `examples/UsernameField.tsx`.
10
+
11
+ ## 1.8.1 — 2026-10-30
12
+
13
+ TabBar label contrast.
14
+
15
+ - **`TabBar` is opaque by default.** Over coloured content the translucent bar left its 10px labels below WCAG AA (gray-11 at 3.3:1 in light). The see-through material is now opt-in with `translucent`, which switches labels to step 12 (≥ 6:1 over any backdrop). The active label uses `accent-12` in both modes, because `accent-11` on the canvas fails for eleven accents in light; the icon keeps the brand colour. The active item is also bolder and has an indicator pill, so colour is not the only difference. Checked against saturated backdrops in `test/TabBar-contrast.test.tsx`; demo in `examples/TabBars.tsx`.
16
+
3
17
  ## 1.8.0 — 2026-10-27
4
18
 
5
19
  An `Accordion` organism.
@@ -26,16 +26,22 @@ export interface TabBarProps {
26
26
  onValueChange?: (value: string) => void;
27
27
  /** Breakpoint (Radix Themes' scale) from which the bar is hidden. Default `md`. */
28
28
  hideFrom?: TabBarBreakpoint;
29
+ /**
30
+ * Blurred see-through material instead of the default opaque bar. Labels switch to step 12 so
31
+ * they stay AA over whatever scrolls beneath.
32
+ */
33
+ translucent?: boolean;
29
34
  'aria-label'?: string;
30
35
  className?: string;
31
36
  }
32
37
  /**
33
38
  * A fixed bottom navigation bar for small screens. 3–5 items; the active one is in the brand
34
- * colour and carries `aria-current="page"`. Height is `--tabbar-height` (49px + `--safe-bottom`);
39
+ * colour, bolder and on an indicator pill, and carries `aria-current="page"`. Opaque unless
40
+ * `translucent`. Height is `--tabbar-height` (49px + `--safe-bottom`);
35
41
  * render a {@link TabBarSpacer} (or pad by that variable) so content is not covered.
36
42
  * Press feedback is `:active`; nothing depends on hover.
37
43
  */
38
- export declare function TabBar({ items, value, onValueChange, hideFrom, className, 'aria-label': ariaLabel, }: TabBarProps): import("react").JSX.Element;
44
+ export declare function TabBar({ items, value, onValueChange, hideFrom, translucent, className, 'aria-label': ariaLabel, }: TabBarProps): import("react").JSX.Element;
39
45
  /** Reserves the bar's height in the flow, hidden at the same breakpoint as the bar. */
40
46
  export declare function TabBarSpacer({ hideFrom }: {
41
47
  hideFrom?: TabBarBreakpoint;
@@ -8,12 +8,13 @@ function badgeText(badge) {
8
8
  }
9
9
  /**
10
10
  * A fixed bottom navigation bar for small screens. 3–5 items; the active one is in the brand
11
- * colour and carries `aria-current="page"`. Height is `--tabbar-height` (49px + `--safe-bottom`);
11
+ * colour, bolder and on an indicator pill, and carries `aria-current="page"`. Opaque unless
12
+ * `translucent`. Height is `--tabbar-height` (49px + `--safe-bottom`);
12
13
  * render a {@link TabBarSpacer} (or pad by that variable) so content is not covered.
13
14
  * Press feedback is `:active`; nothing depends on hover.
14
15
  */
15
- export function TabBar({ items, value, onValueChange, hideFrom = 'md', className, 'aria-label': ariaLabel, }) {
16
- return (_jsx("nav", { className: ['ui-tabbar', className].filter(Boolean).join(' '), "data-hide-from": hideFrom, "aria-label": ariaLabel, children: items.map((item) => {
16
+ export function TabBar({ items, value, onValueChange, hideFrom = 'md', translucent = false, className, 'aria-label': ariaLabel, }) {
17
+ return (_jsx("nav", { className: ['ui-tabbar', className].filter(Boolean).join(' '), "data-hide-from": hideFrom, "data-material": translucent ? 'translucent' : 'opaque', "aria-label": ariaLabel, children: items.map((item) => {
17
18
  const active = item.value === value;
18
19
  const text = badgeText(item.badge);
19
20
  const children = (_jsxs(_Fragment, { children: [_jsxs("span", { className: "ui-tabbar__icon", "aria-hidden": "true", children: [active && item.activeIcon ? item.activeIcon : item.icon, text !== null && (_jsx("span", { className: "ui-tabbar__badge", "data-dot": text === '' ? 'true' : undefined, children: text }))] }), _jsx("span", { className: "ui-tabbar__label", children: item.label })] }));
@@ -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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kennycorrea/ui",
3
- "version": "1.8.0",
3
+ "version": "1.8.2",
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
@@ -796,18 +796,32 @@
796
796
  :root { --tabbar-height: calc(49px + var(--safe-bottom, 0px)); }
797
797
  .ui-tabbar { position: fixed; inset: auto 0 0 0; z-index: 30; display: flex; box-sizing: border-box;
798
798
  height: var(--tabbar-height); padding: 0 var(--safe-right, 0px) var(--safe-bottom, 0px) var(--safe-left, 0px);
799
- background-color: var(--material-bar); -webkit-backdrop-filter: var(--material-bar-filter); backdrop-filter: var(--material-bar-filter);
799
+ background-color: var(--canvas-grouped, var(--color-panel-solid, #fff));
800
800
  box-shadow: inset 0 0.5px 0 var(--color-hairline); }
801
+ /* Opaque by default: gray-11 on the canvas is AA whatever scrolls underneath. `translucent` brings
802
+ the material back; 80% canvas over saturated content leaves gray-11 at 3.3:1 and accent-11 at
803
+ 2.5:1 in light, so the labels step up to 12 there (≥ 6:1 over any backdrop). Numbers in
804
+ `test/TabBar-contrast.test.tsx`. */
805
+ .ui-tabbar[data-material="translucent"] { background-color: var(--material-bar);
806
+ -webkit-backdrop-filter: var(--material-bar-filter); backdrop-filter: var(--material-bar-filter); }
807
+ .ui-tabbar[data-material="translucent"] .ui-tabbar__item { color: var(--gray-12); }
808
+ .ui-tabbar[data-material="translucent"] .ui-tabbar__item[data-active="true"] { color: var(--accent-12); }
801
809
  .ui-tabbar-spacer { height: var(--tabbar-height); flex: none; }
802
810
  .ui-tabbar__cell { flex: 1 1 0; min-width: 0; display: flex; }
803
811
  .ui-tabbar__item { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
804
812
  min-width: 44px; padding: 0; border: 0; background: none; font: inherit; color: var(--gray-11); text-decoration: none;
805
813
  cursor: pointer; -webkit-tap-highlight-color: transparent; touch-action: manipulation; }
806
814
  .ui-tabbar__item[data-active="true"] { color: var(--accent-11); }
815
+ /* Not by colour alone: the active item is bolder and its icon sits on an indicator pill. The icon
816
+ keeps the brand step; the 10px label takes 12, since accent-11 on gray-3 is under 4.5:1 for
817
+ eleven of the accents in light. */
818
+ .ui-tabbar__item[data-active="true"] .ui-tabbar__label { font-weight: 700; color: var(--accent-12); }
819
+ .ui-tabbar__item[data-active="true"] .ui-tabbar__icon::before { content: ''; position: absolute; inset: -2px -12px; z-index: -1;
820
+ border-radius: 999px; background: var(--accent-a4); }
807
821
  .ui-tabbar__item { border-radius: 8px; transition: transform var(--press-duration) ease-out, background-color var(--press-duration) ease-out; }
808
822
  .ui-tabbar__item:active { background: var(--gray-a3); transform: scale(var(--press-scale)); }
809
823
  .ui-tabbar__item:focus-visible { outline: 2px solid var(--focus-8); outline-offset: -2px; border-radius: 8px; }
810
- .ui-tabbar__icon { position: relative; display: inline-flex; font-size: 24px; line-height: 1; }
824
+ .ui-tabbar__icon { position: relative; isolation: isolate; display: inline-flex; font-size: 24px; line-height: 1; }
811
825
  .ui-tabbar__label { font-size: 10px; line-height: 12px; font-weight: 500; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
812
826
  .ui-tabbar__badge { position: absolute; top: -4px; left: calc(100% - 8px); min-width: 16px; height: 16px; padding: 0 4px; box-sizing: border-box;
813
827
  border-radius: 8px; background: var(--red-9); color: white; font-size: 11px; line-height: 16px; font-weight: 600; text-align: center; }
@@ -1260,6 +1274,10 @@
1260
1274
  .ui-field__hint { margin: 0; font-size: 13px; color: var(--color-ink-muted); }
1261
1275
  .ui-field__error { display: flex; align-items: flex-start; gap: 6px; margin: 0; font-size: 13px; font-weight: 500; color: var(--red-11); }
1262
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; }
1263
1281
  .ui-fieldset { display: flex; flex-direction: column; gap: 8px; min-inline-size: 0; margin: 0; padding: 0; border: 0; }
1264
1282
  .ui-fieldset__legend { padding: 0; margin-block-end: 2px; font-size: 14px; font-weight: 500; color: var(--color-ink); }
1265
1283