@dashforge/tw 0.2.1-beta → 0.3.0-beta

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.
Files changed (33) hide show
  1. package/CHANGELOG.md +134 -0
  2. package/CONSUMER-VALIDATION.md +130 -0
  3. package/dist/index.esm.js +309 -41
  4. package/dist/src/components/AppShell/AppShell.d.ts +14 -0
  5. package/dist/src/components/AppShell/AppShell.d.ts.map +1 -1
  6. package/dist/src/components/AppShell/appShell.variants.d.ts.map +1 -1
  7. package/dist/src/components/Autocomplete/Autocomplete.d.ts.map +1 -1
  8. package/dist/src/components/Autocomplete/autocomplete.variants.d.ts.map +1 -1
  9. package/dist/src/components/Breadcrumbs/breadcrumbs.variants.d.ts.map +1 -1
  10. package/dist/src/components/Checkbox/Checkbox.d.ts.map +1 -1
  11. package/dist/src/components/LeftNav/leftNav.variants.d.ts.map +1 -1
  12. package/dist/src/components/Snackbar/snackbar.variants.d.ts.map +1 -1
  13. package/dist/src/components/Switch/switch.variants.d.ts.map +1 -1
  14. package/dist/src/components/TextField/TextField.d.ts.map +1 -1
  15. package/dist/src/components/TextField/textField.types.d.ts +29 -3
  16. package/dist/src/components/TextField/textField.types.d.ts.map +1 -1
  17. package/dist/src/components/TextField/textField.variants.d.ts +6 -0
  18. package/dist/src/components/TextField/textField.variants.d.ts.map +1 -1
  19. package/dist/src/index.d.ts +1 -1
  20. package/package.json +3 -3
  21. package/src/components/AppShell/AppShell.tsx +126 -1
  22. package/src/components/AppShell/appShell.variants.ts +8 -2
  23. package/src/components/Autocomplete/Autocomplete.tsx +77 -4
  24. package/src/components/Autocomplete/autocomplete.variants.ts +6 -0
  25. package/src/components/Breadcrumbs/breadcrumbs.variants.ts +5 -0
  26. package/src/components/Checkbox/Checkbox.tsx +43 -9
  27. package/src/components/LeftNav/leftNav.variants.ts +12 -1
  28. package/src/components/Snackbar/snackbar.variants.ts +4 -2
  29. package/src/components/Switch/switch.variants.ts +6 -1
  30. package/src/components/TextField/TextField.tsx +29 -0
  31. package/src/components/TextField/textField.types.ts +23 -3
  32. package/src/components/TextField/textField.variants.ts +18 -0
  33. package/src/index.ts +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"textField.types.d.ts","sourceRoot":"","sources":["../../../../src/components/TextField/textField.types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAC5D,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACzD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAC;AACjD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAEjE;;;;;;GAMG;AACH,MAAM,WAAW,kBAAkB;IACjC,IAAI,CAAC,EAAE;QAAE,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAC9B,KAAK,CAAC,EAAE;QAAE,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAC/B,YAAY,CAAC,EAAE;QAAE,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACtC,YAAY,CAAC,EAAE;QAAE,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACtC,KAAK,CAAC,EAAE;QAAE,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAC/B,UAAU,CAAC,EAAE;QAAE,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACpC,SAAS,CAAC,EAAE;QAAE,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CACpC;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,WAAW,cACf,SAAQ,IAAI,CAAC,mBAAmB,CAAC,gBAAgB,CAAC,EAAE,MAAM,GAAG,WAAW,CAAC,EACvE,IAAI,CAAC,iBAAiB,EAAE,MAAM,GAAG,QAAQ,GAAG,WAAW,CAAC;IAC1D,wEAAwE;IACxE,IAAI,EAAE,MAAM,CAAC;IAEb,qBAAqB;IACrB,KAAK,CAAC,EAAE,SAAS,CAAC;IAElB,uEAAuE;IACvE,KAAK,CAAC,EAAE,OAAO,CAAC;IAEhB,2DAA2D;IAC3D,WAAW,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC;IAE1C,mCAAmC;IACnC,UAAU,CAAC,EAAE,SAAS,CAAC;IAEvB,6DAA6D;IAC7D,KAAK,CAAC,EAAE,OAAO,CAAC;IAEhB,iEAAiE;IACjE,QAAQ,CAAC,EAAE,OAAO,CAAC;IAEnB,wBAAwB;IACxB,MAAM,CAAC,EAAE,iBAAiB,CAAC;IAE3B,2EAA2E;IAC3E,EAAE,CAAC,EAAE,MAAM,CAAC;IAEZ,qDAAqD;IACrD,SAAS,CAAC,EAAE,kBAAkB,CAAC;CAChC"}
1
+ {"version":3,"file":"textField.types.d.ts","sourceRoot":"","sources":["../../../../src/components/TextField/textField.types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAC5D,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACzD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAC;AACjD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAEjE;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,MAAM,WAAW,kBAAkB;IACjC,IAAI,CAAC,EAAE;QAAE,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAC9B,KAAK,CAAC,EAAE;QAAE,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAC/B,YAAY,CAAC,EAAE;QAAE,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACtC,YAAY,CAAC,EAAE;QAAE,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACtC,KAAK,CAAC,EAAE;QAAE,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAC/B,UAAU,CAAC,EAAE;QAAE,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACpC,SAAS,CAAC,EAAE;QAAE,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACnC,MAAM,CAAC,EAAE;QAAE,QAAQ,CAAC,EAAE,SAAS,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACtD,MAAM,CAAC,EAAE;QAAE,QAAQ,CAAC,EAAE,SAAS,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CACvD;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,WAAW,cACf,SAAQ,IAAI,CAAC,mBAAmB,CAAC,gBAAgB,CAAC,EAAE,MAAM,GAAG,WAAW,CAAC,EACvE,IAAI,CAAC,iBAAiB,EAAE,MAAM,GAAG,QAAQ,GAAG,WAAW,CAAC;IAC1D,wEAAwE;IACxE,IAAI,EAAE,MAAM,CAAC;IAEb,qBAAqB;IACrB,KAAK,CAAC,EAAE,SAAS,CAAC;IAElB,uEAAuE;IACvE,KAAK,CAAC,EAAE,OAAO,CAAC;IAEhB,2DAA2D;IAC3D,WAAW,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC;IAE1C,mCAAmC;IACnC,UAAU,CAAC,EAAE,SAAS,CAAC;IAEvB,6DAA6D;IAC7D,KAAK,CAAC,EAAE,OAAO,CAAC;IAEhB,iEAAiE;IACjE,QAAQ,CAAC,EAAE,OAAO,CAAC;IAEnB,wBAAwB;IACxB,MAAM,CAAC,EAAE,iBAAiB,CAAC;IAE3B,2EAA2E;IAC3E,EAAE,CAAC,EAAE,MAAM,CAAC;IAEZ,qDAAqD;IACrD,SAAS,CAAC,EAAE,kBAAkB,CAAC;CAChC"}
@@ -59,6 +59,8 @@ export declare const textFieldVariants: import("tailwind-variants").TVReturnType
59
59
  input: string[];
60
60
  helperText: string;
61
61
  errorText: string;
62
+ prefix: string[];
63
+ suffix: string[];
62
64
  }, undefined, {
63
65
  size: {
64
66
  sm: {
@@ -107,6 +109,8 @@ export declare const textFieldVariants: import("tailwind-variants").TVReturnType
107
109
  input: string[];
108
110
  helperText: string;
109
111
  errorText: string;
112
+ prefix: string[];
113
+ suffix: string[];
110
114
  }, import("tailwind-variants").TVReturnType<{
111
115
  size: {
112
116
  sm: {
@@ -155,6 +159,8 @@ export declare const textFieldVariants: import("tailwind-variants").TVReturnType
155
159
  input: string[];
156
160
  helperText: string;
157
161
  errorText: string;
162
+ prefix: string[];
163
+ suffix: string[];
158
164
  }, undefined, unknown, unknown, undefined>>;
159
165
  export type TextFieldVariants = VariantProps<typeof textFieldVariants>;
160
166
  //# sourceMappingURL=textField.variants.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"textField.variants.d.ts","sourceRoot":"","sources":["../../../../src/components/TextField/textField.variants.ts"],"names":[],"mappings":"AAAA,OAAO,EAAM,KAAK,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAE1D;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;2CAsE5B,CAAC;AAEH,MAAM,MAAM,iBAAiB,GAAG,YAAY,CAAC,OAAO,iBAAiB,CAAC,CAAC"}
1
+ {"version":3,"file":"textField.variants.d.ts","sourceRoot":"","sources":["../../../../src/components/TextField/textField.variants.ts"],"names":[],"mappings":"AAAA,OAAO,EAAM,KAAK,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAE1D;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;2CAwF5B,CAAC;AAEH,MAAM,MAAM,iBAAiB,GAAG,YAAY,CAAC,OAAO,iBAAiB,CAAC,CAAC"}
@@ -100,5 +100,5 @@ export type { VariantProps } from 'tailwind-variants';
100
100
  /**
101
101
  * Package version (synced with `package.json` at publish time).
102
102
  */
103
- export declare const VERSION = "0.2.1-beta";
103
+ export declare const VERSION = "0.3.0-beta";
104
104
  //# sourceMappingURL=index.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dashforge/tw",
3
- "version": "0.2.1-beta",
3
+ "version": "0.3.0-beta",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./dist/index.esm.js",
@@ -26,8 +26,8 @@
26
26
  "tailwind-variants": "^3.1.1",
27
27
  "vitest": "*",
28
28
  "@dashforge/forms": "0.2.3-beta",
29
- "@dashforge/rbac": "0.2.3-beta",
30
- "@dashforge/ui-core": "0.2.3-beta"
29
+ "@dashforge/ui-core": "0.2.3-beta",
30
+ "@dashforge/rbac": "0.2.3-beta"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "react": "^18.0.0 || ^19.0.0",
@@ -1,8 +1,32 @@
1
- import { useEffect } from 'react';
1
+ import { useEffect, useRef } from 'react';
2
2
  import { cn } from '../../utils/cn.js';
3
3
  import { appShellVariants } from './appShell.variants.js';
4
4
  import type { AppShellProps } from './appShell.types.js';
5
5
 
6
+ /**
7
+ * Query selector for natively-focusable elements + anything with an
8
+ * explicit positive (or implicit `0`) tabindex. Used by the focus
9
+ * trap to enumerate the drawer's interactive content.
10
+ *
11
+ * Notes:
12
+ * - Excludes `[tabindex="-1"]` (intentionally non-tabbable).
13
+ * - Excludes `:disabled` — the browser already skips them in tab
14
+ * order; including them here would route focus to dead elements.
15
+ * - `details > summary` is the standard way to get a focusable
16
+ * `<details>` toggle.
17
+ *
18
+ * @internal
19
+ */
20
+ const FOCUSABLE_SELECTOR = [
21
+ 'a[href]',
22
+ 'button:not(:disabled)',
23
+ 'input:not(:disabled):not([type="hidden"])',
24
+ 'select:not(:disabled)',
25
+ 'textarea:not(:disabled)',
26
+ '[tabindex]:not([tabindex="-1"]):not(:disabled)',
27
+ 'details > summary:first-of-type',
28
+ ].join(',');
29
+
6
30
  /**
7
31
  * Dashforge TW AppShell — top-level layout orchestrator.
8
32
  *
@@ -31,6 +55,20 @@ import type { AppShellProps } from './appShell.types.js';
31
55
  * - The mobile drawer + backdrop participate in the standard
32
56
  * "click outside to close" pattern — `Escape` closes the drawer
33
57
  * too (added via global keydown).
58
+ * - **Focus trap** (WCAG 2.4.3) — when the mobile drawer opens:
59
+ * 1. The previously-focused element is captured.
60
+ * 2. Focus moves to the first focusable element inside the drawer.
61
+ * 3. Tab / Shift+Tab wrap inside the drawer (cycle from
62
+ * last → first and first → last) — Tab can never escape
63
+ * to the page underneath while the drawer is open.
64
+ * 4. On close, focus returns to the captured element (typically
65
+ * the hamburger toggle the user pressed to open the drawer).
66
+ * This pattern matches WAI-ARIA APG's modal dialog guidance even
67
+ * though the drawer isn't strictly a dialog — same focus
68
+ * management makes keyboard users' experience predictable.
69
+ * - **`role="dialog"` + `aria-modal="true"`** are applied to the
70
+ * drawer when open so screen readers announce it as a modal
71
+ * overlay rather than just an aside.
34
72
  */
35
73
  export function AppShell(props: AppShellProps) {
36
74
  const {
@@ -66,6 +104,86 @@ export function AppShell(props: AppShellProps) {
66
104
  return () => document.removeEventListener('keydown', handler);
67
105
  }, [navOpen, onNavOpenChange]);
68
106
 
107
+ /*
108
+ * Focus trap for the mobile drawer.
109
+ *
110
+ * Implementation:
111
+ * - On open: capture the currently-focused element (`document.activeElement`)
112
+ * so we can restore it on close. Then move focus to the first
113
+ * focusable element inside the drawer.
114
+ * - While open: a Tab/Shift+Tab keydown listener wraps focus inside
115
+ * the drawer subtree (last → first on Tab from end, first → last
116
+ * on Shift+Tab from start).
117
+ * - On close: restore focus to the captured element.
118
+ *
119
+ * Hand-rolled (no `focus-trap-react` dep) — the logic is ~20 LOC and
120
+ * scoped to a single drawer; adding a runtime dep felt like overkill.
121
+ * If we ever need more sophisticated trap semantics (nested traps,
122
+ * sentinel nodes, etc.) the dep is the right call.
123
+ */
124
+ const drawerRef = useRef<HTMLDivElement | null>(null);
125
+ const restoreFocusRef = useRef<HTMLElement | null>(null);
126
+
127
+ useEffect(() => {
128
+ if (!navOpen) return;
129
+ // 1) Capture the element that had focus before the drawer opened.
130
+ restoreFocusRef.current = document.activeElement as HTMLElement | null;
131
+
132
+ // 2) Move focus to the first focusable element inside the drawer.
133
+ // Defer via rAF so the drawer DOM (slide-in animation start) has
134
+ // settled and the elements are actually visible/focusable.
135
+ const raf = requestAnimationFrame(() => {
136
+ const drawer = drawerRef.current;
137
+ if (!drawer) return;
138
+ const first = drawer.querySelector<HTMLElement>(FOCUSABLE_SELECTOR);
139
+ first?.focus();
140
+ });
141
+
142
+ // 3) Trap Tab / Shift+Tab inside the drawer.
143
+ const onKeyDown = (event: KeyboardEvent) => {
144
+ if (event.key !== 'Tab') return;
145
+ const drawer = drawerRef.current;
146
+ if (!drawer) return;
147
+ const focusables = Array.from(
148
+ drawer.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)
149
+ );
150
+ if (focusables.length === 0) {
151
+ event.preventDefault();
152
+ return;
153
+ }
154
+ const first = focusables[0];
155
+ const last = focusables[focusables.length - 1];
156
+ const active = document.activeElement as HTMLElement | null;
157
+ if (event.shiftKey) {
158
+ // Shift+Tab on first → wrap to last.
159
+ if (active === first || !drawer.contains(active)) {
160
+ event.preventDefault();
161
+ last.focus();
162
+ }
163
+ } else {
164
+ // Tab on last → wrap to first.
165
+ if (active === last || !drawer.contains(active)) {
166
+ event.preventDefault();
167
+ first.focus();
168
+ }
169
+ }
170
+ };
171
+ document.addEventListener('keydown', onKeyDown);
172
+
173
+ return () => {
174
+ cancelAnimationFrame(raf);
175
+ document.removeEventListener('keydown', onKeyDown);
176
+ // 4) Restore focus to the previously-focused element on close.
177
+ // Guard against the element being removed from the DOM (rare
178
+ // but possible if the consumer re-renders the page while the
179
+ // drawer is open).
180
+ const restore = restoreFocusRef.current;
181
+ if (restore && document.body.contains(restore)) {
182
+ restore.focus();
183
+ }
184
+ };
185
+ }, [navOpen]);
186
+
69
187
  return (
70
188
  <div className={cn(v.root(), sx, slotProps?.root?.className)}>
71
189
  {header && (
@@ -81,8 +199,15 @@ export function AppShell(props: AppShellProps) {
81
199
  {nav}
82
200
  </aside>
83
201
  <aside
202
+ ref={drawerRef}
84
203
  className={cn(v.navMobile(), slotProps?.navMobile?.className)}
85
204
  aria-hidden={!navOpen}
205
+ // When open, the drawer is a modal overlay — `dialog` +
206
+ // `aria-modal="true"` so screen readers announce it as
207
+ // such. When closed, drop both attributes (the aria-
208
+ // hidden=true above already removes it from the AT tree).
209
+ role={navOpen ? 'dialog' : undefined}
210
+ aria-modal={navOpen ? true : undefined}
86
211
  // The drawer is the SAME nav content rendered twice — once
87
212
  // inline (desktop) and once as a slide-in (mobile). We
88
213
  // mark the inactive copy hidden from AT to avoid double
@@ -21,14 +21,20 @@ export const appShellVariants = tv({
21
21
  nav: 'hidden md:flex shrink-0',
22
22
  navMobile: [
23
23
  'flex md:hidden fixed inset-y-0 left-0 z-40',
24
- 'transition-transform duration-200 ease-out',
24
+ // Drawer slide-in is the most prominent motion in AppShell and
25
+ // a clear WCAG 2.3.3 candidate. Gate the transition on
26
+ // `prefers-reduced-motion: no-preference`; the data-driven
27
+ // `-translate-x-full` / `translate-x-0` still applies, just
28
+ // without the smooth slide.
29
+ 'transition-transform duration-200 ease-out motion-reduce:transition-none motion-reduce:duration-0',
25
30
  '-translate-x-full',
26
31
  ],
27
32
  main: 'flex-1 min-w-0 overflow-y-auto',
28
33
  footer: 'shrink-0',
29
34
  backdrop: [
30
35
  'md:hidden fixed inset-0 z-30 bg-black/40',
31
- 'transition-opacity duration-200',
36
+ // Backdrop opacity fade — micro motion, gated for consistency.
37
+ 'transition-opacity duration-200 motion-reduce:transition-none motion-reduce:duration-0',
32
38
  'opacity-0 pointer-events-none',
33
39
  ],
34
40
  },
@@ -23,6 +23,61 @@ import type {
23
23
  AutocompleteValue,
24
24
  } from './autocomplete.types.js';
25
25
 
26
+ /**
27
+ * Inline SVG icons used by the Autocomplete chrome (chip remove, clear,
28
+ * dropdown caret). Stroke uses `currentColor` so the parent's `text-*`
29
+ * propagates — same pattern as Checkbox's CheckIcon.
30
+ *
31
+ * Why inline SVG (not lucide / heroicons / unicode glyphs):
32
+ * - Zero icon-library dependency: keeps `@dashforge/tw` self-contained.
33
+ * - Crisp at every size (the unicode `×` / `▾` glyphs we shipped pre-
34
+ * 0.2.2 rendered as chunky font characters that looked unpolished
35
+ * next to the rest of the design system).
36
+ * - SVG scales with parent font-size cleanly via `width="1em" height="1em"`.
37
+ *
38
+ * @internal
39
+ */
40
+ function CloseIcon({ className }: { className?: string }) {
41
+ return (
42
+ <svg
43
+ aria-hidden="true"
44
+ viewBox="0 0 16 16"
45
+ width="1em"
46
+ height="1em"
47
+ fill="none"
48
+ className={className}
49
+ >
50
+ <path
51
+ d="M4 4l8 8M12 4l-8 8"
52
+ stroke="currentColor"
53
+ strokeWidth="1.75"
54
+ strokeLinecap="round"
55
+ />
56
+ </svg>
57
+ );
58
+ }
59
+
60
+ function ChevronDownIcon({ className }: { className?: string }) {
61
+ return (
62
+ <svg
63
+ aria-hidden="true"
64
+ viewBox="0 0 16 16"
65
+ width="1em"
66
+ height="1em"
67
+ fill="none"
68
+ className={className}
69
+ >
70
+ <path
71
+ d="M4 6l4 4 4-4"
72
+ stroke="currentColor"
73
+ strokeWidth="1.75"
74
+ strokeLinecap="round"
75
+ strokeLinejoin="round"
76
+ />
77
+ </svg>
78
+ );
79
+ }
80
+
26
81
  /**
27
82
  * Coerce a raw value (from bridge / props) into a canonical shape:
28
83
  *
@@ -402,7 +457,17 @@ export function Autocomplete<TOption = AutocompleteOption>(
402
457
  if (next == null) {
403
458
  setInputValue('');
404
459
  } else {
405
- const found = options.find((opt) => getOptionValue(opt) === next);
460
+ // Look up the label in the EFFECTIVE option pool (static
461
+ // `options` + async `asyncOptions` when `loadOptions` is
462
+ // configured). Looking only at the static `options` here
463
+ // means async-loaded picks never update the input — the
464
+ // user sees their search query stick instead of the
465
+ // selected label after click. Same lookup logic mirrored in
466
+ // `displayInputValue` so the two paths can't drift.
467
+ const pool = loadOptions && asyncOptions !== null
468
+ ? asyncOptions
469
+ : options;
470
+ const found = pool.find((opt) => getOptionValue(opt) === next);
406
471
  const found_label = found ? labelAsString(found) : undefined;
407
472
  if (found_label !== undefined) {
408
473
  setInputValue(found_label);
@@ -434,6 +499,8 @@ export function Autocomplete<TOption = AutocompleteOption>(
434
499
  // eslint-disable-next-line react-hooks/exhaustive-deps
435
500
  [
436
501
  options,
502
+ asyncOptions,
503
+ loadOptions,
437
504
  isFormMode,
438
505
  bridge,
439
506
  name,
@@ -837,7 +904,7 @@ export function Autocomplete<TOption = AutocompleteOption>(
837
904
  slotProps?.chipRemove?.className
838
905
  )}
839
906
  >
840
- ×
907
+ <CloseIcon />
841
908
  </button>
842
909
  )}
843
910
  </span>
@@ -893,7 +960,7 @@ export function Autocomplete<TOption = AutocompleteOption>(
893
960
  tabIndex={-1}
894
961
  className={cn(v.clearButton(), slotProps?.clearButton?.className)}
895
962
  >
896
- ×
963
+ <CloseIcon />
897
964
  </button>
898
965
  )}
899
966
 
@@ -908,7 +975,13 @@ export function Autocomplete<TOption = AutocompleteOption>(
908
975
  disabled={effectiveDisabled}
909
976
  className={cn(v.trigger(), slotProps?.trigger?.className)}
910
977
  >
911
- ▾
978
+ {/*
979
+ * Chevron flips up when popover is open (CSS-only, driven by
980
+ * the `aria-expanded` attribute selector on the parent
981
+ * `<button>`). See autocomplete.variants.ts → `trigger` slot
982
+ * for the `aria-expanded:rotate-180` rule.
983
+ */}
984
+ <ChevronDownIcon />
912
985
  </button>
913
986
 
914
987
  {isOpen && (
@@ -44,6 +44,12 @@ export const autocompleteVariants = tv({
44
44
  'text-neutral-600 hover:text-neutral-900',
45
45
  'disabled:cursor-not-allowed disabled:opacity-40',
46
46
  'transition-colors',
47
+ // Chevron flip on open — targets the SVG child via the aria-
48
+ // expanded state on the button itself (set by React). Smooth
49
+ // rotate gated on prefers-reduced-motion (WCAG 2.3.3); the
50
+ // 180° state still applies, just without the animated tween.
51
+ '[&[aria-expanded=true]>svg]:rotate-180',
52
+ '[&>svg]:transition-transform [&>svg]:duration-150 motion-reduce:[&>svg]:transition-none',
47
53
  ],
48
54
  clearButton: [
49
55
  'flex items-center justify-center shrink-0 px-2',
@@ -23,6 +23,11 @@ export const breadcrumbsVariants = tv({
23
23
  'rounded-sm outline-none',
24
24
  'focus-visible:ring-2 focus-visible:ring-primary-500/50',
25
25
  'transition-colors',
26
+ // Defensive `no-underline` — see the matching comment in
27
+ // LeftNav itemLink slot. The Breadcrumbs root cause covers
28
+ // TopBar too because Breadcrumbs is typically rendered in
29
+ // TopBar's center slot.
30
+ 'no-underline hover:no-underline',
26
31
  ],
27
32
  current: [
28
33
  'inline-flex items-center gap-1 truncate',
@@ -10,8 +10,8 @@ import { checkboxVariants } from './checkbox.variants.js';
10
10
  import type { CheckboxProps } from './checkbox.types.js';
11
11
 
12
12
  /**
13
- * Inline checkmark SVG used by `Radix.Indicator`. Stroke uses
14
- * `currentColor` so the parent's `text-white` propagates correctly.
13
+ * Inline checkmark SVG used by `Radix.Indicator` when fully checked.
14
+ * Stroke uses `currentColor` so the parent's `text-white` propagates.
15
15
  *
16
16
  * @internal
17
17
  */
@@ -34,6 +34,37 @@ function CheckIcon({ className }: { className?: string }) {
34
34
  );
35
35
  }
36
36
 
37
+ /**
38
+ * Inline dash SVG used by `Radix.Indicator` when the checkbox is in
39
+ * the `indeterminate` tri-state ("some, but not all, children
40
+ * selected" — the canonical "select all" partial state).
41
+ *
42
+ * Pre-0.2.2-beta the indicator rendered the CheckIcon for BOTH
43
+ * checked and indeterminate (Radix mounts the Indicator for either
44
+ * state). We now discriminate via the `data-state` attribute Radix
45
+ * sets on the Indicator element itself — see the parent `Indicator`
46
+ * rendering below for the CSS toggle.
47
+ *
48
+ * @internal
49
+ */
50
+ function DashIcon({ className }: { className?: string }) {
51
+ return (
52
+ <svg
53
+ aria-hidden="true"
54
+ viewBox="0 0 16 16"
55
+ fill="none"
56
+ className={className}
57
+ >
58
+ <path
59
+ d="M3 8h10"
60
+ stroke="currentColor"
61
+ strokeWidth="2.5"
62
+ strokeLinecap="round"
63
+ />
64
+ </svg>
65
+ );
66
+ }
67
+
37
68
  /**
38
69
  * Dashforge TW Checkbox — bridge-integrated form control.
39
70
  *
@@ -212,16 +243,19 @@ export function Checkbox(props: CheckboxProps) {
212
243
  * modes — controlled, uncontrolled, bridge). Indicator mounts
213
244
  * exactly when the checkbox is checked OR indeterminate.
214
245
  *
215
- * Indeterminate caveat: this renders the check glyph for
216
- * BOTH checked AND indeterminate states. Previously
217
- * indeterminate rendered nothing inside the blue square
218
- * (same level of broken). A future improvement would render
219
- * a dash for indeterminate — out of scope here.
246
+ * Per-state glyph (Sprint 2 P4): when Indicator is mounted we
247
+ * render BOTH glyphs and toggle visibility via the Indicator's
248
+ * own `data-state` attribute. The `group` class on Indicator
249
+ * lets the SVG children target the parent's state with
250
+ * `group-data-[state=checked]:hidden` / `…:indeterminate:hidden`.
251
+ * No React state required — Radix's data-state attribute is
252
+ * the single source of truth.
220
253
  */}
221
254
  <RadixCheckbox.Indicator
222
- className={cn(v.indicator(), slotProps?.indicator?.className)}
255
+ className={cn(v.indicator(), 'group', slotProps?.indicator?.className)}
223
256
  >
224
- <CheckIcon className="h-full w-full" />
257
+ <CheckIcon className="h-full w-full group-data-[state=indeterminate]:hidden" />
258
+ <DashIcon className="h-full w-full group-data-[state=checked]:hidden" />
225
259
  </RadixCheckbox.Indicator>
226
260
  </RadixCheckbox.Root>
227
261
 
@@ -24,7 +24,10 @@ export const leftNavVariants = tv({
24
24
  root: [
25
25
  'flex flex-col h-full',
26
26
  'bg-neutral-50 border-r border-neutral-200',
27
- 'transition-[width] duration-200',
27
+ // Width transition (rail-mode toggle) — gated on
28
+ // prefers-reduced-motion (WCAG 2.3.3). The new width still
29
+ // applies, just without the animated tween.
30
+ 'transition-[width] duration-200 motion-reduce:transition-none motion-reduce:duration-0',
28
31
  ],
29
32
  brand: [
30
33
  'flex items-center gap-2 px-3 h-14 shrink-0',
@@ -39,6 +42,14 @@ export const leftNavVariants = tv({
39
42
  'transition-colors w-full',
40
43
  'aria-disabled:opacity-50 aria-disabled:cursor-not-allowed',
41
44
  'aria-disabled:hover:bg-transparent',
45
+ // Defensive `no-underline` — Tailwind's preflight removes the
46
+ // default browser anchor underline globally, but environments
47
+ // that DISABLE preflight (e.g. our docs-lab, where the tw
48
+ // section coexists with MUI's chrome) get raw browser defaults
49
+ // back. Without this, `<a>` items in the nav render underlined
50
+ // in those contexts. Explicit `no-underline` + `hover:no-underline`
51
+ // keeps the appearance consistent regardless of preflight state.
52
+ 'no-underline hover:no-underline',
42
53
  ],
43
54
  itemActive: 'bg-primary-100 text-primary-900 font-medium',
44
55
  itemIcon: 'shrink-0 w-5 h-5 flex items-center justify-center',
@@ -23,8 +23,10 @@ export const snackbarVariants = tv({
23
23
  'rounded-lg border shadow-lg',
24
24
  'text-sm bg-neutral-50 text-neutral-900',
25
25
  // Subtle enter transition — opacity + translate, kept short so a
26
- // burst of snackbars feels snappy.
27
- 'transition-all duration-200',
26
+ // burst of snackbars feels snappy. Gated on motion-reduce
27
+ // (WCAG 2.3.3) — users who request reduced motion see snackbars
28
+ // pop in instantly without the slide animation.
29
+ 'transition-all duration-200 motion-reduce:transition-none motion-reduce:duration-0',
28
30
  'data-[state=entered]:opacity-100 data-[state=exited]:opacity-0',
29
31
  ],
30
32
  icon: 'shrink-0 mt-0.5 w-5 h-5 inline-flex items-center justify-center',
@@ -25,7 +25,12 @@ export const switchVariants = tv({
25
25
  ],
26
26
  thumb: [
27
27
  'pointer-events-none inline-block rounded-full bg-white shadow ring-0',
28
- 'transition-transform',
28
+ // Slide animation gated on `prefers-reduced-motion: no-preference`
29
+ // (WCAG 2.3.3). The thumb still moves between positions instantly
30
+ // for users who request reduced motion — the data-state change
31
+ // applies the translate-x rule unconditionally, only the smooth
32
+ // transition between the two is suppressed.
33
+ 'transition-transform motion-reduce:transition-none',
29
34
  'data-[state=unchecked]:translate-x-0',
30
35
  ],
31
36
  label: 'select-none cursor-pointer text-neutral-900',
@@ -178,6 +178,22 @@ export function TextField(props: TextFieldProps) {
178
178
  )}
179
179
 
180
180
  <div className={cn(v.inputWrapper(), slotProps?.inputWrapper?.className)}>
181
+ {/*
182
+ * Prefix slot (Sprint 2 P4.1) — inline adornment rendered
183
+ * BEFORE the input. Mounts only when `slotProps.prefix.children`
184
+ * is provided so empty configs don't add layout cost. Common
185
+ * use: currency symbols (`$`, `€`), units (`@`, `#`), status
186
+ * icons. The slot wrapper carries `pointer-events-none` to
187
+ * avoid stealing focus from the input on click.
188
+ */}
189
+ {slotProps?.prefix?.children !== undefined && (
190
+ <span
191
+ aria-hidden="true"
192
+ className={cn(v.prefix(), slotProps.prefix.className)}
193
+ >
194
+ {slotProps.prefix.children}
195
+ </span>
196
+ )}
181
197
  <input
182
198
  {...rest}
183
199
  id={inputId}
@@ -196,6 +212,19 @@ export function TextField(props: TextFieldProps) {
196
212
  ref={inputRef}
197
213
  className={cn(v.input(), slotProps?.input?.className)}
198
214
  />
215
+ {/*
216
+ * Suffix slot (Sprint 2 P4.1) — same pattern as prefix,
217
+ * rendered AFTER the input. Typical use: unit labels (`USD`,
218
+ * `kg`, `%`), trailing icons, length counters.
219
+ */}
220
+ {slotProps?.suffix?.children !== undefined && (
221
+ <span
222
+ aria-hidden="true"
223
+ className={cn(v.suffix(), slotProps.suffix.className)}
224
+ >
225
+ {slotProps.suffix.children}
226
+ </span>
227
+ )}
199
228
  </div>
200
229
 
201
230
  {resolvedHelperText && (
@@ -6,9 +6,27 @@ import type { TextFieldVariants } from './textField.variants.js';
6
6
  /**
7
7
  * Per-slot overrides for `<TextField>`.
8
8
  *
9
- * Each slot accepts `{ className: string }` so the override path is
10
- * extensible (style / aria-* / data-* can be added without a breaking
11
- * change). Mirrors the MUI-side `slotProps` shape.
9
+ * Each slot accepts `{ className: string }` (and, for `prefix` /
10
+ * `suffix`, a `children` node) so the override path is extensible —
11
+ * extra fields like `style`, `aria-*`, `data-*` can be added without
12
+ * a breaking change. Mirrors the MUI-side `slotProps` shape.
13
+ *
14
+ * **`prefix` / `suffix`** are inline adornments rendered INSIDE the
15
+ * `inputWrapper`, before / after the `<input>` element. Typical use:
16
+ * currency symbols, units, status icons. Passing only `className`
17
+ * (no `children`) is allowed but renders an empty slot — usually
18
+ * you'll always pair the two.
19
+ *
20
+ * ```tsx
21
+ * <TextField
22
+ * name="price"
23
+ * type="number"
24
+ * slotProps={{
25
+ * prefix: { children: '$' },
26
+ * suffix: { children: 'USD' },
27
+ * }}
28
+ * />
29
+ * ```
12
30
  */
13
31
  export interface TextFieldSlotProps {
14
32
  root?: { className?: string };
@@ -18,6 +36,8 @@ export interface TextFieldSlotProps {
18
36
  input?: { className?: string };
19
37
  helperText?: { className?: string };
20
38
  errorText?: { className?: string };
39
+ prefix?: { children?: ReactNode; className?: string };
40
+ suffix?: { children?: ReactNode; className?: string };
21
41
  }
22
42
 
23
43
  /**
@@ -34,6 +34,24 @@ export const textFieldVariants = tv({
34
34
  ],
35
35
  helperText: 'mt-1 text-sm text-neutral-600',
36
36
  errorText: 'mt-1 text-sm text-danger-600',
37
+ /*
38
+ * `prefix` / `suffix` slot — inline adornment rendered before /
39
+ * after the input INSIDE the inputWrapper. `shrink-0` keeps the
40
+ * adornment width fixed so it doesn't compete for space with the
41
+ * input; `select-none` + `pointer-events-none` avoids
42
+ * accidentally stealing focus from the input on click (the
43
+ * inputWrapper handles focus via `focus-within`).
44
+ */
45
+ prefix: [
46
+ 'shrink-0 inline-flex items-center select-none pointer-events-none',
47
+ 'text-neutral-500',
48
+ 'mr-2',
49
+ ],
50
+ suffix: [
51
+ 'shrink-0 inline-flex items-center select-none pointer-events-none',
52
+ 'text-neutral-500',
53
+ 'ml-2',
54
+ ],
37
55
  },
38
56
  variants: {
39
57
  size: {
package/src/index.ts CHANGED
@@ -237,4 +237,4 @@ export type { VariantProps } from 'tailwind-variants';
237
237
  /**
238
238
  * Package version (synced with `package.json` at publish time).
239
239
  */
240
- export const VERSION = '0.2.1-beta';
240
+ export const VERSION = '0.3.0-beta';