@dashforge/tw 0.2.0-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 (41) hide show
  1. package/A11Y.md +130 -0
  2. package/CHANGELOG.md +339 -82
  3. package/CONSUMER-VALIDATION.md +130 -0
  4. package/dist/index.esm.js +351 -47
  5. package/dist/src/components/AppShell/AppShell.d.ts +14 -0
  6. package/dist/src/components/AppShell/AppShell.d.ts.map +1 -1
  7. package/dist/src/components/AppShell/appShell.variants.d.ts.map +1 -1
  8. package/dist/src/components/Autocomplete/Autocomplete.d.ts.map +1 -1
  9. package/dist/src/components/Autocomplete/autocomplete.variants.d.ts.map +1 -1
  10. package/dist/src/components/Breadcrumbs/breadcrumbs.variants.d.ts.map +1 -1
  11. package/dist/src/components/Button/Button.d.ts.map +1 -1
  12. package/dist/src/components/Checkbox/Checkbox.d.ts.map +1 -1
  13. package/dist/src/components/LeftNav/leftNav.variants.d.ts.map +1 -1
  14. package/dist/src/components/NumberField/NumberField.d.ts.map +1 -1
  15. package/dist/src/components/RadioGroup/RadioGroup.d.ts.map +1 -1
  16. package/dist/src/components/Snackbar/snackbar.variants.d.ts.map +1 -1
  17. package/dist/src/components/Switch/switch.variants.d.ts.map +1 -1
  18. package/dist/src/components/TextField/TextField.d.ts.map +1 -1
  19. package/dist/src/components/TextField/textField.types.d.ts +29 -3
  20. package/dist/src/components/TextField/textField.types.d.ts.map +1 -1
  21. package/dist/src/components/TextField/textField.variants.d.ts +6 -0
  22. package/dist/src/components/TextField/textField.variants.d.ts.map +1 -1
  23. package/dist/src/index.d.ts +1 -1
  24. package/package.json +3 -3
  25. package/src/components/AppShell/AppShell.tsx +126 -1
  26. package/src/components/AppShell/appShell.variants.ts +8 -2
  27. package/src/components/Autocomplete/Autocomplete.tsx +77 -4
  28. package/src/components/Autocomplete/autocomplete.variants.ts +6 -0
  29. package/src/components/Breadcrumbs/breadcrumbs.variants.ts +5 -0
  30. package/src/components/Button/Button.tsx +11 -0
  31. package/src/components/Checkbox/Checkbox.tsx +63 -5
  32. package/src/components/LeftNav/leftNav.variants.ts +12 -1
  33. package/src/components/NumberField/NumberField.tsx +30 -2
  34. package/src/components/RadioGroup/RadioGroup.tsx +23 -1
  35. package/src/components/Snackbar/snackbar.variants.ts +4 -2
  36. package/src/components/Switch/switch.variants.ts +6 -1
  37. package/src/components/TextField/TextField.tsx +29 -0
  38. package/src/components/TextField/textField.types.ts +23 -3
  39. package/src/components/TextField/textField.variants.ts +18 -0
  40. package/src/index.ts +1 -1
  41. package/LICENSE +0 -21
@@ -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',
@@ -1,4 +1,4 @@
1
- import { useCallback, useContext, useEffect, useId, useRef } from 'react';
1
+ import { useCallback, useContext, useEffect, useId, useRef, useState } from 'react';
2
2
  import { DashFormContext, useEngineVisibility } from '@dashforge/ui-core';
3
3
  import type { DashFormBridge, FieldRegistration } from '@dashforge/ui-core';
4
4
  import { useDashFieldMeta } from '@dashforge/forms';
@@ -98,6 +98,22 @@ export function NumberField(props: NumberFieldProps) {
98
98
  const accessState = useAccessState(access);
99
99
 
100
100
  const inputId = useId();
101
+
102
+ /*
103
+ * Local state for the STANDALONE UNCONTROLLED case (no bridge, no
104
+ * `value` prop, only `defaultValue`). Mirrors OTPField. Without this,
105
+ * `resolvedDisplayValue` would be a snapshot computed ONCE from
106
+ * `defaultValue` and the controlled `<input value={...}>` would
107
+ * snap user input back on every keystroke / stepper click — same
108
+ * trap that hit Checkbox + RadioGroup in this package.
109
+ *
110
+ * In form mode the bridge owns state. In standalone CONTROLLED mode
111
+ * (consumer passes `value`) the consumer owns state. Only this
112
+ * branch needs the local hook.
113
+ */
114
+ const [uncontrolledValue, setUncontrolledValue] = useState<string>(() =>
115
+ formatForDisplay(defaultValue)
116
+ );
101
117
  const helperId = `${inputId}-help`;
102
118
 
103
119
  // StrictMode-safe unregister-on-unmount
@@ -140,7 +156,7 @@ export function NumberField(props: NumberFieldProps) {
140
156
  resolvedDisplayValue =
141
157
  userValue !== undefined
142
158
  ? formatForDisplay(userValue)
143
- : formatForDisplay(defaultValue);
159
+ : uncontrolledValue;
144
160
  }
145
161
 
146
162
  const writeToBridge = (parsed: number | null | undefined) => {
@@ -157,6 +173,13 @@ export function NumberField(props: NumberFieldProps) {
157
173
  // internal logic still sees the raw string change.
158
174
  void registration.onChange(e);
159
175
  }
176
+ // Standalone uncontrolled mode: mirror the raw input string so the
177
+ // controlled `<input value={...}>` reflects what the user typed.
178
+ // Partial states (e.g. "-", "1.") are kept verbatim — `parseFromInput`
179
+ // returns `undefined` for them so `writeToBridge` is a no-op above.
180
+ if (!isFormMode && userValue === undefined) {
181
+ setUncontrolledValue(e.target.value);
182
+ }
160
183
  userOnChange?.(e);
161
184
  };
162
185
 
@@ -176,6 +199,11 @@ export function NumberField(props: NumberFieldProps) {
176
199
  if (typeof min === 'number') next = Math.max(min, next);
177
200
  if (typeof max === 'number') next = Math.min(max, next);
178
201
  writeToBridge(next);
202
+ // Standalone uncontrolled: also persist the new value to local
203
+ // state so the visible display tracks the stepper click.
204
+ if (!isFormMode && userValue === undefined) {
205
+ setUncontrolledValue(formatForDisplay(next));
206
+ }
179
207
  };
180
208
 
181
209
  const canIncrement =
@@ -200,9 +200,31 @@ export function RadioGroup(props: RadioGroupProps) {
200
200
  </div>
201
201
  )}
202
202
 
203
+ {/*
204
+ * Radix RadioGroup mode discrimination — mirrors the same fix
205
+ * applied to <Checkbox>:
206
+ *
207
+ * - Form mode (bridge.register present): controlled — `value`
208
+ * comes from the reactive bridge snapshot, `handleValueChange`
209
+ * writes back through bridge.setValue.
210
+ * - Standalone controlled (consumer passes `value`): controlled —
211
+ * consumer owns state, `handleValueChange` forwards via
212
+ * `onValueChange`.
213
+ * - Standalone uncontrolled (only `defaultValue`): UNCONTROLLED —
214
+ * Radix owns the state. Previously this code passed
215
+ * `value={resolvedValue}` in this branch too, putting Radix
216
+ * in controlled mode with a stale snapshot that never updated,
217
+ * so user clicks fired Radix's onValueChange but the controlled
218
+ * prop never changed and the selection snapped right back.
219
+ *
220
+ * Picking exactly one of `{ value, ... }` or `{ defaultValue, ... }`
221
+ * lets Radix track its own state correctly when nobody else can.
222
+ */}
203
223
  <RadixRadioGroup.Root
204
224
  name={name}
205
- value={resolvedValue}
225
+ {...(isFormMode || explicitValue !== undefined
226
+ ? { value: resolvedValue }
227
+ : { defaultValue: defaultValue ?? undefined })}
206
228
  onValueChange={handleValueChange}
207
229
  onBlur={handleBlur}
208
230
  disabled={groupEffectiveDisabled}
@@ -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.1.0-beta';
240
+ export const VERSION = '0.3.0-beta';
package/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Dashforge
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.