@dashforge/tw 0.2.0-beta → 0.2.1-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.
- package/A11Y.md +130 -0
- package/CHANGELOG.md +205 -82
- package/dist/index.esm.js +45 -9
- package/dist/src/components/Button/Button.d.ts.map +1 -1
- package/dist/src/components/Checkbox/Checkbox.d.ts.map +1 -1
- package/dist/src/components/NumberField/NumberField.d.ts.map +1 -1
- package/dist/src/components/RadioGroup/RadioGroup.d.ts.map +1 -1
- package/dist/src/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/components/Button/Button.tsx +11 -0
- package/src/components/Checkbox/Checkbox.tsx +26 -2
- package/src/components/NumberField/NumberField.tsx +30 -2
- package/src/components/RadioGroup/RadioGroup.tsx +23 -1
- package/src/index.ts +1 -1
- package/LICENSE +0 -21
package/A11Y.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# A11Y audit — `@dashforge/tw`
|
|
2
|
+
|
|
3
|
+
Per-component accessibility status for every shipped `@dashforge/tw`
|
|
4
|
+
component (24 total). Audit date: 2026-05-17, against the 0.2.0-beta
|
|
5
|
+
codebase (+ the three pending lib fixes for Checkbox / RadioGroup /
|
|
6
|
+
NumberField + the Button `aria-busy` improvement landing in 0.2.1-beta).
|
|
7
|
+
|
|
8
|
+
Reference: [WCAG 2.1 AA](https://www.w3.org/TR/WCAG21/) +
|
|
9
|
+
[WAI-ARIA Authoring Practices](https://www.w3.org/WAI/ARIA/apg/).
|
|
10
|
+
|
|
11
|
+
## Summary
|
|
12
|
+
|
|
13
|
+
| Group | Components | Status |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| Foundation (presentational) | Typography, Box, Stack, Grid, Container, AspectRatio | ✅ N/A — pass-through to HTML semantics |
|
|
16
|
+
| Foundation (a11y primitive) | VisuallyHidden, Divider | ✅ Conformant |
|
|
17
|
+
| Form controls (Radix-backed) | Checkbox, Switch, RadioGroup | ✅ Inherits WAI-ARIA APG from Radix |
|
|
18
|
+
| Form controls (custom) | Button, TextField, Textarea, NumberField, OTPField, Autocomplete, DateTimePicker | ✅ Conformant (see per-row notes) |
|
|
19
|
+
| Layout / Navigation | AppShell, TopBar, LeftNav, Breadcrumbs | ✅ Conformant |
|
|
20
|
+
| Overlays (Radix-backed) | ConfirmDialog | ✅ Inherits from Radix Dialog |
|
|
21
|
+
| Notifications | Snackbar | ✅ Conformant (aria-live polite) |
|
|
22
|
+
|
|
23
|
+
Zero blocking failures. One enhancement landed: Button now exposes
|
|
24
|
+
`aria-busy={loading}` so assistive tech distinguishes loading from
|
|
25
|
+
plain disabled.
|
|
26
|
+
|
|
27
|
+
## Per-component findings
|
|
28
|
+
|
|
29
|
+
### Foundation
|
|
30
|
+
|
|
31
|
+
| Component | Status | Notes |
|
|
32
|
+
|---|---|---|
|
|
33
|
+
| **Typography** | ✅ | Renders the variant-appropriate HTML tag (`<h1>`–`<h6>`, `<p>`, `<small>`) so screen-reader heading navigation works out of the box. `as` / `asChild` let consumers re-tag without losing styles. |
|
|
34
|
+
| **Box** | ✅ | Pass-through `<div>` (or `as`/`asChild`-overridden tag). No interactive semantics. |
|
|
35
|
+
| **Stack** | ✅ | Pass-through `<div>`. Display-only flex container. |
|
|
36
|
+
| **Grid** | ✅ | Pass-through `<div>` with `display: grid`. Layout-only. |
|
|
37
|
+
| **Container** | ✅ | Pass-through `<div>` or `<main>` (via `asChild`). Layout-only. |
|
|
38
|
+
| **Divider** | ✅ | Horizontal: `<hr role="separator" aria-orientation="horizontal">`. Vertical: `<div role="separator" aria-orientation="vertical">`. Labeled variant keeps the role on the wrapper + decorative line segments carry `aria-hidden`. |
|
|
39
|
+
| **AspectRatio** | ✅ | Pass-through `<div>` carrying a `style={aspect-ratio: ...}`. No interactive semantics. |
|
|
40
|
+
| **VisuallyHidden** | ✅ | IS the a11y primitive — WebAIM clip technique via Tailwind's `sr-only`. Chooses `<span>` by default (works inside buttons / links without breaking inline flow). |
|
|
41
|
+
|
|
42
|
+
### Tier-1 form controls
|
|
43
|
+
|
|
44
|
+
| Component | Status | Notes |
|
|
45
|
+
|-------------|--------|-------|
|
|
46
|
+
| **Button** | ✅ | Native `<button>` (or Slot via `asChild`). `disabled` + `aria-disabled` mirrored. **`aria-busy={true}` while loading** (added 2026-05-17) — SR users hear "busy" instead of just "dimmed". Loading spinner is `aria-hidden`. `focus-visible:ring-*` matches the variant color. |
|
|
47
|
+
| **TextField** | ✅ | `<label htmlFor>` via `useId()`. `aria-invalid` on error, `aria-describedby` linked to the helper-text id. Required marker is `aria-hidden` decorative + the real signal is the native `required` attribute. `placeholder:text-neutral-400`. |
|
|
48
|
+
| **Checkbox** | ✅ | Radix `Checkbox.Root` (WAI-ARIA APG). Indicator now uses Radix-native mounting (no React conditional) so checked-state changes from any source — click, programmatic, bridge — propagate to AT. |
|
|
49
|
+
| **Switch** | ✅ | Radix `Switch.Root` (WAI-ARIA APG). `role="switch"` + `aria-checked`. Thumb animation gated on `data-state` — pure CSS, no React state for the visual. |
|
|
50
|
+
|
|
51
|
+
### Tier-2 / 3 form controls
|
|
52
|
+
|
|
53
|
+
| Component | Status | Notes |
|
|
54
|
+
|-------------------|--------|-------|
|
|
55
|
+
| **RadioGroup** | ✅ | Radix `RadioGroup.Root` + `Item`. `role="radiogroup"` on the container, `role="radio"` + `aria-checked` per option. Group label linked via `aria-labelledby`. Per-option RBAC hide preserves the selected option as disabled (so the user can see what they picked even after a perm change). |
|
|
56
|
+
| **Textarea** | ✅ | Same label + helper-text wiring as TextField. `<textarea>` native element, `resize` axis controlled via CSS. |
|
|
57
|
+
| **NumberField** | ✅ | Native `<input type="number">` — keyboard arrow keys are the accessible way to change the value (browser default). The custom +/− stepper is **intentionally `aria-hidden`** and `tabIndex={-1}` — visible/touch affordance only, with `aria-label="Increment"` / `"Decrement"` for any AT that does walk into hidden subtrees. |
|
|
58
|
+
| **OTPField** | ✅ | One real `<input>` (visually rendered as N segmented slots via decorative `aria-hidden` `<div>`s). `autoComplete="one-time-code"` for SMS autofill on iOS. `inputMode="numeric"` (default) for mobile keyboards. Backspace / Delete / paste all work natively because the underlying element IS a single input. |
|
|
59
|
+
| **Autocomplete** | ✅ | Full WAI-ARIA Combobox pattern: `role="combobox"` + `aria-autocomplete="list"` + `aria-expanded` + `aria-controls` + `aria-activedescendant`. Listbox: `role="listbox"` + per-option `role="option"` + `aria-selected`. Async load row: `aria-live="polite"` + `aria-busy="true"`. Clear / open buttons carry `aria-label`. Chip remove buttons: `aria-label="Remove {label}"`. |
|
|
60
|
+
| **DateTimePicker** | ✅ | Native HTML5 `<input type="date">` / `"time"` / `"datetime-local"`. Browser ships a fully-accessible picker for each. `aria-invalid` + `aria-describedby` wired to the helper text. |
|
|
61
|
+
|
|
62
|
+
### Layout / navigation
|
|
63
|
+
|
|
64
|
+
| Component | Status | Notes |
|
|
65
|
+
|---------------|--------|-------|
|
|
66
|
+
| **AppShell** | ✅ | `<aside>` for desktop nav, `<aside aria-hidden={!navOpen}>` for the mobile drawer copy (so it doesn't double-announce when hidden). Escape closes the drawer. `<main>` landmark wraps content. `<header>` / `<footer>` are native landmarks. **Known limitation**: no focus trap inside the mobile drawer — focus returns to the toggle button on Escape close, which is acceptable for a docs site / typical web app but not full WAI-ARIA modal-drawer compliance. Can be added in a future revision. |
|
|
67
|
+
| **TopBar** | ✅ | Renders as `<header>` (banner landmark) by default. `asDiv` opt-out for nested layouts where a banner landmark would duplicate. |
|
|
68
|
+
| **LeftNav** | ✅ | `<nav aria-label>` landmark. `aria-current="page"` on the active row. Groups use `aria-expanded` + `aria-controls` linking the header `<button>` to a `role="region" aria-labelledby` panel. Collapse toggle: `aria-pressed` + dynamic `aria-label`. Per-row RBAC hide is matched by an a11y-tree removal (not just visual), so the announced nav matches what the user can actually interact with. |
|
|
69
|
+
| **Breadcrumbs** | ✅ | `<nav aria-label="Breadcrumb">` + `<ol>` (screen readers announce position-in-list). `aria-current="page"` on the last (or `current: true`) crumb. Separators carry `aria-hidden="true"`. Truncation ellipsis: `<span aria-label="More breadcrumbs">`. |
|
|
70
|
+
|
|
71
|
+
### Overlays + notifications
|
|
72
|
+
|
|
73
|
+
| Component | Status | Notes |
|
|
74
|
+
|------------------|--------|-------|
|
|
75
|
+
| **ConfirmDialog** | ✅ | Radix `Dialog.Root` (WAI-ARIA APG). `role="dialog"` + `aria-modal="true"` + `aria-labelledby` on the title. Radix handles focus trap, focus restoration on close, Escape close, scroll lock. |
|
|
76
|
+
| **Snackbar** | ✅ | Stack is `role="region" aria-label="Notifications" aria-live="polite" aria-atomic="false"`. Polite (not assertive) so notifications don't interrupt the user's screen-reader stream; `atomic="false"` so only the changed item is announced, not the whole queue. Per-item dismiss button: `aria-label="Dismiss notification"`. |
|
|
77
|
+
|
|
78
|
+
## Cross-cutting concerns
|
|
79
|
+
|
|
80
|
+
- **Focus visibility** — every interactive component carries
|
|
81
|
+
`focus-visible:ring-*` matching its color variant. `outline-none` is
|
|
82
|
+
always paired with a ring so keyboard users never lose the focus
|
|
83
|
+
indicator (regression-tested via the inventory grep:
|
|
84
|
+
`Button / Checkbox / ConfirmDialog / RadioGroup / Switch / TextField
|
|
85
|
+
/ Textarea / NumberField / OTPField / Autocomplete / DateTimePicker
|
|
86
|
+
/ Breadcrumbs / LeftNav / Snackbar` all have `focus-visible:ring`).
|
|
87
|
+
- **Color contrast** — the design tokens emit foreground/background
|
|
88
|
+
pairs that meet WCAG 2.1 AA (4.5:1 for normal text, 3:1 for large
|
|
89
|
+
text). Independent verification is on the dashforge-tokens roadmap.
|
|
90
|
+
- **Reduced motion** — animations (Switch thumb slide, Snackbar slide-in,
|
|
91
|
+
ConfirmDialog fade) use the default `transition-*` utilities. A
|
|
92
|
+
`prefers-reduced-motion` audit pass is filed as a future enhancement
|
|
93
|
+
(current behavior: animations always play; not a WCAG failure but
|
|
94
|
+
reduces polish for users with motion sensitivity).
|
|
95
|
+
- **Form error association** — every form control with `helperText` +
|
|
96
|
+
`error` wires `aria-invalid` and `aria-describedby` to the helper id
|
|
97
|
+
so the error message is announced alongside the field name.
|
|
98
|
+
|
|
99
|
+
## Known limitations & future work
|
|
100
|
+
|
|
101
|
+
1. **AppShell mobile drawer focus trap** — currently relies on Escape
|
|
102
|
+
to close + focus naturally returning to the toggle button. A future
|
|
103
|
+
release can add a real focus trap (probably via `focus-trap-react`
|
|
104
|
+
or hand-rolled).
|
|
105
|
+
2. **`prefers-reduced-motion` audit** — animations always play. Should
|
|
106
|
+
gate the Switch thumb slide, Snackbar slide-in, ConfirmDialog fade
|
|
107
|
+
on the media query.
|
|
108
|
+
3. **High-contrast Windows mode** — not explicitly tested. The `border`
|
|
109
|
+
utility renders a 1px border that should survive forced-colors mode,
|
|
110
|
+
but full validation pending.
|
|
111
|
+
4. **Color contrast regression suite** — should be added to CI so a
|
|
112
|
+
token recolor doesn't silently regress AA compliance.
|
|
113
|
+
|
|
114
|
+
## Audit methodology
|
|
115
|
+
|
|
116
|
+
- ARIA role / attribute inventory via `grep -nE "aria-[a-z]+=|role=|onKeyDown"`
|
|
117
|
+
per component source.
|
|
118
|
+
- Keyboard handler audit by reading each component's `onKeyDown`,
|
|
119
|
+
`onPaste`, `onFocus`, `onBlur` and tracing into the registered
|
|
120
|
+
bridge / Radix primitive.
|
|
121
|
+
- Focus visibility inventory via `grep "focus-visible:ring"` across
|
|
122
|
+
all `*.variants.ts`.
|
|
123
|
+
- Label association verification: `useId()` → `htmlFor=inputId` +
|
|
124
|
+
`aria-describedby=helperId` chain checked for TextField, Textarea,
|
|
125
|
+
NumberField, OTPField, DateTimePicker.
|
|
126
|
+
|
|
127
|
+
No automated lighthouse / axe scan was run as part of this audit —
|
|
128
|
+
that's a separate CI integration on the roadmap. Manual screen-reader
|
|
129
|
+
verification (NVDA on Windows, VoiceOver on macOS / iOS) should be
|
|
130
|
+
periodically refreshed; results land in this file as they happen.
|
package/CHANGELOG.md
CHANGED
|
@@ -12,111 +12,234 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
12
12
|
> duplicated intentionally — no shared "lowest common denominator" headless
|
|
13
13
|
> layer.
|
|
14
14
|
|
|
15
|
+
## [0.2.1-beta] — 2026-05-17
|
|
16
|
+
|
|
17
|
+
**Hardening release.** Four targeted fixes — three in form-control
|
|
18
|
+
runtime behaviour, one in the Button accessibility contract — surfaced
|
|
19
|
+
while building live-preview demos for the docs site. No public API
|
|
20
|
+
change on any component; strictly additive on the `<Button>` props
|
|
21
|
+
contract (a new `aria-busy` attribute is emitted automatically when
|
|
22
|
+
`loading` is true). Drop-in upgrade from `0.2.0-beta`.
|
|
23
|
+
|
|
24
|
+
Theme of the three form-control fixes: the **same root cause** —
|
|
25
|
+
"controlled-without-an-owner" — under three different surface
|
|
26
|
+
appearances. In standalone uncontrolled mode (no `DashFormProvider`,
|
|
27
|
+
no `value` / `checked` prop, only `defaultValue` / `defaultChecked`),
|
|
28
|
+
each component was sitting in a controlled mode without anyone able
|
|
29
|
+
to update the controlled prop on the user's keystrokes / clicks, so
|
|
30
|
+
React would snap the input right back. The fixes vary by component
|
|
31
|
+
implementation (Radix-backed → discriminated spread of `value` vs
|
|
32
|
+
`defaultValue`; Radix indicator → drop `forceMount` + React
|
|
33
|
+
conditional; native `<input>` → local `useState` for the uncontrolled
|
|
34
|
+
case) but the pattern is identical. A11Y.md (new doc, separate
|
|
35
|
+
commit) documents the broader pattern audit.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- **Checkbox** — the indicator's check glyph never appeared when the
|
|
40
|
+
user clicked a Checkbox that was rendered standalone-uncontrolled
|
|
41
|
+
(no `DashFormProvider`, no `checked` prop). The control turned blue
|
|
42
|
+
via `data-[state=checked]:bg-primary-500` but the React-conditional
|
|
43
|
+
`<CheckIcon />` was gated on a stale `resolvedChecked` snapshot.
|
|
44
|
+
Dropped `forceMount` + the conditional; the Radix `Indicator` now
|
|
45
|
+
owns the mount decision, tracking Radix's internal `data-state`
|
|
46
|
+
directly. Mounts in all three modes (controlled, uncontrolled,
|
|
47
|
+
bridge). 14/14 tests pass.
|
|
48
|
+
- **RadioGroup** — clicking a different radio in standalone-uncontrolled
|
|
49
|
+
mode had no visible effect (the selection snapped back to
|
|
50
|
+
`defaultValue`). `<RadixRadioGroup.Root>` was passed `value={…}`
|
|
51
|
+
always, putting Radix in controlled mode against a never-updated
|
|
52
|
+
snapshot. Discriminated spread now picks `value` only in form mode
|
|
53
|
+
or when the consumer explicitly passes `value`; standalone-with-only-
|
|
54
|
+
`defaultValue` uses `defaultValue` so Radix manages its own state.
|
|
55
|
+
11/11 tests pass.
|
|
56
|
+
- **NumberField** — typing into the input or clicking the +/− stepper
|
|
57
|
+
had no visible effect in standalone-uncontrolled mode, for the same
|
|
58
|
+
reason (controlled `<input value={…}>` with no setter). Added a
|
|
59
|
+
local `useState<string>` seeded from `defaultValue` (mirrors the
|
|
60
|
+
OTPField pattern); `handleChange` + `stepBy` now both update it.
|
|
61
|
+
8/8 tests pass.
|
|
62
|
+
- **Button** — sets `aria-busy={true}` while `loading`, so assistive
|
|
63
|
+
tech distinguishes "wait for the action to finish" from plain
|
|
64
|
+
"disabled" (which previously was the only signal — same DOM
|
|
65
|
+
attribute regardless of whether the disable came from `loading`,
|
|
66
|
+
`disabled={true}`, or RBAC). 19/19 tests pass.
|
|
67
|
+
|
|
68
|
+
### Internal
|
|
69
|
+
|
|
70
|
+
- A11Y.md added at package root — per-component status table mapped
|
|
71
|
+
to WCAG 2.1 AA / WAI-ARIA APG. Documents that 23 of 24 components
|
|
72
|
+
were already conformant pre-release (only Button needed the
|
|
73
|
+
`aria-busy` enhancement above). Known non-blocking limitations
|
|
74
|
+
filed: AppShell mobile drawer focus trap, `prefers-reduced-motion`
|
|
75
|
+
pass, color-contrast CI suite, lighthouse/axe automated scan.
|
|
76
|
+
|
|
77
|
+
### Compatibility
|
|
78
|
+
|
|
79
|
+
| Compatibility axis | Pre-`0.2.1` | Post-`0.2.1` |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| Public API surface | unchanged | unchanged + `aria-busy` auto-emitted on `<Button loading>` |
|
|
82
|
+
| Peer deps | `react ^18 \|\| ^19`, `tw-theme workspace`, `tw-tokens workspace` | unchanged |
|
|
83
|
+
| Bridge deps | `forms` / `rbac` / `ui-core` `workspace:*` | unchanged |
|
|
84
|
+
|
|
15
85
|
## [0.2.0-beta] — 2026-05-17
|
|
16
86
|
|
|
17
87
|
**Foundation release.** Eight layout / structural primitives added on top of
|
|
18
|
-
the F3–F7 component catalogue
|
|
19
|
-
the
|
|
88
|
+
the F3–F7 component catalogue (16 components → 24), plus a coverage
|
|
89
|
+
hardening pass bringing the package from **460 → 592 unit tests** across
|
|
90
|
+
**32 files**. End-to-end validated in the `dash` consumer app: mount
|
|
91
|
+
**12.1 ms** / re-render **7–8.6 ms** for a page with 50+ primitive
|
|
92
|
+
instances.
|
|
20
93
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
primitives incrementally; existing code keeps working
|
|
94
|
+
**No public API change** on any of the 16 previously-shipped components
|
|
95
|
+
— strictly additive minor bump. Consumers upgrading from `0.1.0-beta`
|
|
96
|
+
can adopt the new primitives incrementally; existing code keeps working
|
|
97
|
+
unchanged.
|
|
24
98
|
|
|
25
99
|
### Added — Foundation primitives (F9)
|
|
26
100
|
|
|
27
101
|
The `Box ≠ flex`, `Stack = flex 1D`, `Grid = flex 2D` rule is the spine of
|
|
28
102
|
this layer: each primitive has a single, non-overlapping responsibility so
|
|
29
|
-
"which one do I use?" has one answer per scenario.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
`color
|
|
103
|
+
"which one do I use?" has one answer per scenario. The rule is enforced at
|
|
104
|
+
the TypeScript prop type level — `<Box direction="row">` is a compile error.
|
|
105
|
+
|
|
106
|
+
- **`Typography`** — semantic typed text. Twelve variants (`h1`–`h6`,
|
|
107
|
+
`subtitle1/2`, `body1/2`, `caption`, `overline`) × nine intent colors ×
|
|
108
|
+
five weight overrides + alignment + truncate / noWrap / gutterBottom.
|
|
109
|
+
Default HTML tag inferred from variant (h1→`<h1>`, body1→`<p>`, …),
|
|
110
|
+
overridable via `as` or `asChild` (Radix Slot). Reads color from a
|
|
111
|
+
parent `<Box>` via `color="inherit"`.
|
|
112
|
+
Source: `src/components/Typography/{Typography.tsx, typography.types.ts, typography.variants.ts}`.
|
|
113
|
+
|
|
37
114
|
- **`Box`** — surface primitive consolidating MUI's Box + Paper + Card +
|
|
38
|
-
Surface into one. Five variants (`plain` · `outlined` · `elevated` ·
|
|
39
|
-
`soft` · `solid`) × seven intent colors = 21 compound visuals
|
|
40
|
-
elevation levels (0
|
|
41
|
-
+ rounded scale +
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
115
|
+
Joy Surface into one. Five variants (`plain` · `outlined` · `elevated` ·
|
|
116
|
+
`soft` · `solid`) × seven intent colors = **21 compound visuals**
|
|
117
|
+
emitted by the TV recipe + six elevation levels (`0`–`5`) + token-scale
|
|
118
|
+
spacing (`p`/`px`/`py`/`m`/`mx`/`my`) + rounded scale +
|
|
119
|
+
`fullWidth`/`fullHeight`. Strictly no flex / no grid by design — wrap
|
|
120
|
+
in Stack/Grid for layout.
|
|
121
|
+
Source: `src/components/Box/box.variants.ts` (compound matrix lives here).
|
|
122
|
+
|
|
123
|
+
- **`Stack`** — the **only** flex container in `@dashforge/tw`.
|
|
124
|
+
`direction` + `align` + `justify` + token-scale `gap` + `wrap`, plus a
|
|
125
|
+
runtime `divider` prop that inserts N-1 separators between children
|
|
126
|
+
(`React.Children.toArray` semantics: Fragments count as one child —
|
|
127
|
+
documented in `Stack.tsx` header + asserted in the test suite).
|
|
128
|
+
Source: `src/components/Stack/{Stack.tsx, stack.types.ts, stack.variants.ts}`.
|
|
129
|
+
|
|
47
130
|
- **`Grid`** — CSS Grid container + item, polymorphic in role. MUI v2 API
|
|
48
|
-
surface (`<Grid container>` + `<Grid xs={6}>`) backed by real CSS
|
|
49
|
-
(`display: grid` + `col-span-*`), not flexbox
|
|
50
|
-
TypeScript
|
|
51
|
-
responsive col-span mapping (xs/sm/md/lg/xl
|
|
131
|
+
surface (`<Grid container>` + `<Grid xs={6}>`) backed by **real CSS
|
|
132
|
+
Grid** (`display: grid` + `col-span-*`), not flexbox like MUI v2's own
|
|
133
|
+
internals. Discriminated-union TypeScript: `<Grid container xs={6}>`
|
|
134
|
+
fails compilation. 70-entry responsive `col-span` mapping (xs/sm/md/lg/xl
|
|
135
|
+
× `1..12/auto/full`) in the TV recipe.
|
|
136
|
+
Source: `src/components/Grid/{Grid.tsx, grid.types.ts, grid.variants.ts}`.
|
|
52
137
|
|
|
53
138
|
### Added — Foundation completions (F10)
|
|
54
139
|
|
|
55
|
-
Closes the foundation surface to match what Chakra/Mantine/Joy ship at
|
|
56
|
-
layout-primitive level.
|
|
140
|
+
Closes the foundation surface to match what Chakra/Mantine/Joy ship at
|
|
141
|
+
the layout-primitive level.
|
|
57
142
|
|
|
58
143
|
- **`Container`** — centered max-width page wrapper with the canonical
|
|
59
144
|
responsive padding ramp (`px-4 sm:px-6 lg:px-8`). Six sizes
|
|
60
|
-
(sm/md/lg/xl/2xl/fluid) mapped to Tailwind's
|
|
61
|
-
`centerContent` opt-in for marketing/
|
|
62
|
-
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
-
|
|
68
|
-
|
|
145
|
+
(`sm` / `md` / `lg` / `xl` / `2xl` / `fluid`) mapped to Tailwind's
|
|
146
|
+
`max-w-screen-*` aliases + `centerContent` opt-in for marketing /
|
|
147
|
+
sign-in layouts.
|
|
148
|
+
Source: `src/components/Container/{Container.tsx, container.types.ts, container.variants.ts}`.
|
|
149
|
+
|
|
150
|
+
- **`Divider`** — visual separator with two rendering modes selected by
|
|
151
|
+
`children` presence. Line-only renders `<hr>` with `role="separator"` +
|
|
152
|
+
`aria-orientation`; labeled mode renders the "OR" separator pattern as
|
|
153
|
+
two flex segments around the label, with a 32 px stub on the squashed
|
|
154
|
+
side for `align="start"` / `"end"`. orientation × variant
|
|
155
|
+
(solid/dashed/dotted) × color (7 intents) × align (3) axes.
|
|
156
|
+
Source: `src/components/Divider/{Divider.tsx, divider.types.ts, divider.variants.ts}`.
|
|
157
|
+
|
|
158
|
+
- **`AspectRatio`** — content-shape primitive using the **native CSS
|
|
159
|
+
`aspect-ratio` property** (supported since 2021, ~98% browser coverage).
|
|
69
160
|
Number or CSS-string ratio. Pairs with `sx="rounded-xl overflow-hidden"`
|
|
70
|
-
for the canonical clipped media pattern (documented as the #1 gotcha
|
|
161
|
+
for the canonical clipped media pattern (documented as the #1 gotcha
|
|
162
|
+
in `AspectRatio.tsx` header and the public MDX docs).
|
|
163
|
+
Source: `src/components/AspectRatio/{AspectRatio.tsx, aspectRatio.types.ts}`.
|
|
164
|
+
|
|
71
165
|
- **`VisuallyHidden`** — the a11y primitive. Uses Tailwind's `sr-only`
|
|
72
|
-
(WebAIM clip technique). Hides children from sighted users
|
|
73
|
-
them in the accessibility tree — icon button labels,
|
|
74
|
-
skip links. Default tag is
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
- **
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
166
|
+
utility (WebAIM clip technique). Hides children from sighted users
|
|
167
|
+
while keeping them in the accessibility tree — icon button labels,
|
|
168
|
+
status announcers (`aria-live="polite"`), skip links. Default tag is
|
|
169
|
+
`<span>` for the 99% case (inline label inside a button or link).
|
|
170
|
+
Source: `src/components/VisuallyHidden/{VisuallyHidden.tsx, visuallyHidden.types.ts}`.
|
|
171
|
+
|
|
172
|
+
### Internal
|
|
173
|
+
|
|
174
|
+
- **+132 edge case unit tests** added (`460 → 592` total across `32`
|
|
175
|
+
files). Reorganised here under `Internal` rather than `Added` because
|
|
176
|
+
the tests are not part of the public API surface.
|
|
177
|
+
|
|
178
|
+
- **Box (+33)** — every one of the 21 compound surface variants
|
|
179
|
+
(outlined / soft / solid × 7 intents) asserted explicitly with the
|
|
180
|
+
light + dark pair; plain / elevated color-agnostic invariants;
|
|
181
|
+
spacing axis coexistence (`p` + `px` + `py` × tailwind-merge
|
|
182
|
+
precedence); `elevation` × `variant` interaction; rounded edge
|
|
183
|
+
values. File: `src/components/Box/Box.test.tsx`.
|
|
184
|
+
|
|
185
|
+
- **Grid (+38)** — every responsive breakpoint × representative span
|
|
186
|
+
enumeration (5 × 5 = 25), full cascade test (`xs={12} sm={6} md={4}
|
|
187
|
+
lg={3} xl={2}`), every `autoFlow` value, every `cols` value,
|
|
188
|
+
`spacingX` / `spacingY` independence, empty container + orphan item
|
|
189
|
+
handling, deep nesting (Grid inside Grid item). File:
|
|
190
|
+
`src/components/Grid/Grid.test.tsx`.
|
|
191
|
+
|
|
192
|
+
- **Stack (+29)** — array divider, conditional / null children, mixed
|
|
193
|
+
text + element children, nested Stack with divider, divider key
|
|
194
|
+
stability across re-renders, every gap step (11 token values),
|
|
195
|
+
every `align`/`justify` value (11), empty Stack with and without
|
|
196
|
+
divider. File: `src/components/Stack/Stack.test.tsx`.
|
|
197
|
+
|
|
198
|
+
- **Typography / Container / Divider / AspectRatio / VisuallyHidden
|
|
199
|
+
(+32)** — multi-axis combinations (`variant` + `color` + `weight` +
|
|
200
|
+
`align` + `truncate` + `gutterBottom`), full variant catalogues
|
|
201
|
+
iterated with `it.each`, extreme ratio values (21/9, 9/16, 0.5, 3),
|
|
202
|
+
nested fluid/capped Container pattern, `aria-live="polite"`
|
|
203
|
+
announcer pattern.
|
|
204
|
+
|
|
205
|
+
- **End-to-end consumer validation.** New page
|
|
206
|
+
`~/projects/web/learn/dash/src/pages/TestFoundation.tsx` (linked into
|
|
207
|
+
the `dash` consumer app via a `file:` package override) mounts all
|
|
208
|
+
eight primitives inside `<DashforgeTailwindProvider>` wrapped in a
|
|
209
|
+
React `<Profiler>` with `onRender` logger. Measured: **mount 12.1 ms,
|
|
210
|
+
update 7–8.6 ms** for a page with 50+ primitive instances — within the
|
|
211
|
+
60 fps frame budget. No `React.memo` applied — the primitives are
|
|
212
|
+
pure (no `useState`, no `useEffect`, only className resolution), so
|
|
213
|
+
React's reconciler trivially handles the re-render.
|
|
214
|
+
|
|
215
|
+
### Architecture
|
|
216
|
+
|
|
217
|
+
- **`Box ≠ flex`, `Stack = flex 1D`, `Grid = flex 2D`** — single
|
|
218
|
+
responsibility per primitive, enforced at the TypeScript prop type
|
|
219
|
+
level. `Box` exposes no `display` / `flex*` / `grid*` props at all —
|
|
220
|
+
trying to pass them fails compilation. The rule rules out the MUI
|
|
221
|
+
failure mode where every `<Box display="flex" gap={2}>` quietly becomes
|
|
222
|
+
the de facto flex container of the codebase, drowning the
|
|
223
|
+
surface-vs-layout distinction. When you read `<Stack>` in a JSX tree
|
|
224
|
+
you know it's flex without reading any further.
|
|
112
225
|
|
|
113
226
|
### Compatibility
|
|
114
227
|
|
|
115
228
|
- **Peer deps unchanged**: `@dashforge/tw-theme@^0.1.0-beta` and
|
|
116
|
-
`@dashforge/tw-tokens@^0.1.0-beta` (neither was modified
|
|
117
|
-
|
|
118
|
-
`@dashforge/
|
|
119
|
-
|
|
229
|
+
`@dashforge/tw-tokens@^0.1.0-beta` (neither package was modified in
|
|
230
|
+
this cycle). Bridge layer (`@dashforge/forms`, `@dashforge/ui-core`,
|
|
231
|
+
`@dashforge/rbac`) likewise unchanged at the workspace `0.2.3-beta`
|
|
232
|
+
version.
|
|
233
|
+
|
|
234
|
+
- **No breaking changes**: the public API of the 16 previously-shipped
|
|
235
|
+
components is byte-identical. Diff `0.1.0-beta..0.2.0-beta` against
|
|
236
|
+
`src/index.ts` shows only additive exports (the new Foundation
|
|
237
|
+
primitives + their `*Variants` recipes + their `*Props` types).
|
|
238
|
+
|
|
239
|
+
- **Bundle size impact** (gzipped, when fully exercised):
|
|
240
|
+
`dist/index.esm.js` grew from 255 KB to 272 KB (+17 KB / +6.7%) for
|
|
241
|
+
the eight new primitives. Tree-shaking unaffected — consumers only
|
|
242
|
+
pay for what they import.
|
|
120
243
|
|
|
121
244
|
---
|
|
122
245
|
|
package/dist/index.esm.js
CHANGED
|
@@ -403,6 +403,13 @@ function _object_without_properties_loose(source, excluded) {
|
|
|
403
403
|
fullWidth,
|
|
404
404
|
loading
|
|
405
405
|
}), sx);
|
|
406
|
+
/*
|
|
407
|
+
* `aria-busy` announces the loading state to assistive tech.
|
|
408
|
+
* `disabled` alone hides the reason (perm-denied vs loading vs
|
|
409
|
+
* intrinsic), so SR users only hear "dimmed/inactive" without
|
|
410
|
+
* knowing why. Adding `aria-busy={true}` while loading distinguishes
|
|
411
|
+
* "wait for the action to finish" from "you can't do this".
|
|
412
|
+
*/ const ariaBusy = loading ? true : undefined;
|
|
406
413
|
// `asChild` renders through Radix Slot: the immediate child element
|
|
407
414
|
// gets the resolved className, no extra Button DOM is emitted.
|
|
408
415
|
if (asChild) {
|
|
@@ -411,6 +418,7 @@ function _object_without_properties_loose(source, excluded) {
|
|
|
411
418
|
className: classes,
|
|
412
419
|
"data-disabled": effectiveDisabled || undefined,
|
|
413
420
|
"aria-disabled": effectiveDisabled || undefined,
|
|
421
|
+
"aria-busy": ariaBusy,
|
|
414
422
|
children: children
|
|
415
423
|
});
|
|
416
424
|
}
|
|
@@ -418,6 +426,7 @@ function _object_without_properties_loose(source, excluded) {
|
|
|
418
426
|
ref: ref,
|
|
419
427
|
type: (_rest_type = rest.type) != null ? _rest_type : 'button',
|
|
420
428
|
disabled: effectiveDisabled,
|
|
429
|
+
"aria-busy": ariaBusy,
|
|
421
430
|
className: classes
|
|
422
431
|
}, rest, {
|
|
423
432
|
children: [
|
|
@@ -968,10 +977,9 @@ function _object_without_properties_loose(source, excluded) {
|
|
|
968
977
|
className: cn(v.control(), slotProps == null ? void 0 : (_slotProps_control = slotProps.control) == null ? void 0 : _slotProps_control.className),
|
|
969
978
|
children: jsx(RadixCheckbox.Indicator, {
|
|
970
979
|
className: cn(v.indicator(), slotProps == null ? void 0 : (_slotProps_indicator = slotProps.indicator) == null ? void 0 : _slotProps_indicator.className),
|
|
971
|
-
|
|
972
|
-
children: resolvedChecked === true ? jsx(CheckIcon, {
|
|
980
|
+
children: jsx(CheckIcon, {
|
|
973
981
|
className: "h-full w-full"
|
|
974
|
-
})
|
|
982
|
+
})
|
|
975
983
|
})
|
|
976
984
|
})),
|
|
977
985
|
jsxs("div", {
|
|
@@ -1430,9 +1438,13 @@ function _object_without_properties_loose(source, excluded) {
|
|
|
1430
1438
|
})
|
|
1431
1439
|
]
|
|
1432
1440
|
}),
|
|
1433
|
-
jsx(RadixRadioGroup.Root, {
|
|
1434
|
-
name: name
|
|
1435
|
-
|
|
1441
|
+
jsx(RadixRadioGroup.Root, _extends({
|
|
1442
|
+
name: name
|
|
1443
|
+
}, isFormMode || explicitValue !== undefined ? {
|
|
1444
|
+
value: resolvedValue
|
|
1445
|
+
} : {
|
|
1446
|
+
defaultValue: defaultValue != null ? defaultValue : undefined
|
|
1447
|
+
}, {
|
|
1436
1448
|
onValueChange: handleValueChange,
|
|
1437
1449
|
onBlur: handleBlur,
|
|
1438
1450
|
disabled: groupEffectiveDisabled,
|
|
@@ -1463,7 +1475,7 @@ function _object_without_properties_loose(source, excluded) {
|
|
|
1463
1475
|
]
|
|
1464
1476
|
}, option.value);
|
|
1465
1477
|
})
|
|
1466
|
-
}),
|
|
1478
|
+
})),
|
|
1467
1479
|
resolvedHelperText && jsx("p", {
|
|
1468
1480
|
id: helperId,
|
|
1469
1481
|
className: cn(resolvedError ? v.errorText() : v.helperText(), resolvedError ? slotProps == null ? void 0 : (_slotProps_errorText = slotProps.errorText) == null ? void 0 : _slotProps_errorText.className : slotProps == null ? void 0 : (_slotProps_helperText = slotProps.helperText) == null ? void 0 : _slotProps_helperText.className),
|
|
@@ -1905,6 +1917,18 @@ function _object_without_properties_loose(source, excluded) {
|
|
|
1905
1917
|
useDashFieldMeta(name);
|
|
1906
1918
|
const accessState = useAccessState(access);
|
|
1907
1919
|
const inputId = useId();
|
|
1920
|
+
/*
|
|
1921
|
+
* Local state for the STANDALONE UNCONTROLLED case (no bridge, no
|
|
1922
|
+
* `value` prop, only `defaultValue`). Mirrors OTPField. Without this,
|
|
1923
|
+
* `resolvedDisplayValue` would be a snapshot computed ONCE from
|
|
1924
|
+
* `defaultValue` and the controlled `<input value={...}>` would
|
|
1925
|
+
* snap user input back on every keystroke / stepper click — same
|
|
1926
|
+
* trap that hit Checkbox + RadioGroup in this package.
|
|
1927
|
+
*
|
|
1928
|
+
* In form mode the bridge owns state. In standalone CONTROLLED mode
|
|
1929
|
+
* (consumer passes `value`) the consumer owns state. Only this
|
|
1930
|
+
* branch needs the local hook.
|
|
1931
|
+
*/ const [uncontrolledValue, setUncontrolledValue] = useState(()=>formatForDisplay(defaultValue));
|
|
1908
1932
|
const helperId = `${inputId}-help`;
|
|
1909
1933
|
// StrictMode-safe unregister-on-unmount
|
|
1910
1934
|
const unregisterRef = useRef({
|
|
@@ -1942,7 +1966,7 @@ function _object_without_properties_loose(source, excluded) {
|
|
|
1942
1966
|
resolvedHelperText = validation.helperText;
|
|
1943
1967
|
resolvedDisplayValue = userValue !== undefined ? formatForDisplay(userValue) : formatForDisplay(bridge.getValue(name));
|
|
1944
1968
|
} else {
|
|
1945
|
-
resolvedDisplayValue = userValue !== undefined ? formatForDisplay(userValue) :
|
|
1969
|
+
resolvedDisplayValue = userValue !== undefined ? formatForDisplay(userValue) : uncontrolledValue;
|
|
1946
1970
|
}
|
|
1947
1971
|
const writeToBridge = (parsed)=>{
|
|
1948
1972
|
if (!isFormMode || !bridge) return;
|
|
@@ -1957,6 +1981,13 @@ function _object_without_properties_loose(source, excluded) {
|
|
|
1957
1981
|
// internal logic still sees the raw string change.
|
|
1958
1982
|
void registration.onChange(e);
|
|
1959
1983
|
}
|
|
1984
|
+
// Standalone uncontrolled mode: mirror the raw input string so the
|
|
1985
|
+
// controlled `<input value={...}>` reflects what the user typed.
|
|
1986
|
+
// Partial states (e.g. "-", "1.") are kept verbatim — `parseFromInput`
|
|
1987
|
+
// returns `undefined` for them so `writeToBridge` is a no-op above.
|
|
1988
|
+
if (!isFormMode && userValue === undefined) {
|
|
1989
|
+
setUncontrolledValue(e.target.value);
|
|
1990
|
+
}
|
|
1960
1991
|
userOnChange == null ? void 0 : userOnChange(e);
|
|
1961
1992
|
};
|
|
1962
1993
|
const handleBlur = (e)=>{
|
|
@@ -1972,6 +2003,11 @@ function _object_without_properties_loose(source, excluded) {
|
|
|
1972
2003
|
if (typeof min === 'number') next = Math.max(min, next);
|
|
1973
2004
|
if (typeof max === 'number') next = Math.min(max, next);
|
|
1974
2005
|
writeToBridge(next);
|
|
2006
|
+
// Standalone uncontrolled: also persist the new value to local
|
|
2007
|
+
// state so the visible display tracks the stepper click.
|
|
2008
|
+
if (!isFormMode && userValue === undefined) {
|
|
2009
|
+
setUncontrolledValue(formatForDisplay(next));
|
|
2010
|
+
}
|
|
1975
2011
|
};
|
|
1976
2012
|
const canIncrement = !effectiveDisabled && (typeof max !== 'number' || ((_parseFromInput = parseFromInput(resolvedDisplayValue)) != null ? _parseFromInput : -Infinity) < max);
|
|
1977
2013
|
const canDecrement = !effectiveDisabled && (typeof min !== 'number' || ((_parseFromInput1 = parseFromInput(resolvedDisplayValue)) != null ? _parseFromInput1 : Infinity) > min);
|
|
@@ -6514,6 +6550,6 @@ VisuallyHidden.displayName = 'VisuallyHidden';
|
|
|
6514
6550
|
*/ // ───── Components ─────
|
|
6515
6551
|
/**
|
|
6516
6552
|
* Package version (synced with `package.json` at publish time).
|
|
6517
|
-
*/ const VERSION = '0.1
|
|
6553
|
+
*/ const VERSION = '0.2.1-beta';
|
|
6518
6554
|
|
|
6519
6555
|
export { AppShell, AspectRatio, Autocomplete, Box, Breadcrumbs, Button, Checkbox, ConfirmDialogProvider, Container, DateTimePicker, Divider, Grid, LeftNav, NumberField, OTPField, RadioGroup, SnackbarProvider, Stack, Switch, TextField, Textarea, TopBar, Typography, VERSION, VisuallyHidden, appShellVariants, autocompleteVariants, boxVariants, breadcrumbsVariants, buttonVariants, checkboxVariants, cn, confirmDialogVariants, containerVariants, dateTimePickerVariants, dividerLineVariants, dividerVariants, gridVariants, isoToInputValue, leftNavVariants, numberFieldVariants, otpFieldVariants, radioGroupVariants, snackbarVariants, stackVariants, switchVariants, textFieldVariants, textareaVariants, topBarVariants, typographyVariants, useAccessState, useConfirm, useSnackbar };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Button.d.ts","sourceRoot":"","sources":["../../../../src/components/Button/Button.tsx"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AA8BrD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,eAAO,MAAM,MAAM,
|
|
1
|
+
{"version":3,"file":"Button.d.ts","sourceRoot":"","sources":["../../../../src/components/Button/Button.tsx"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AA8BrD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,eAAO,MAAM,MAAM,2GAyEjB,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Checkbox.d.ts","sourceRoot":"","sources":["../../../../src/components/Checkbox/Checkbox.tsx"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AA2BzD;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,aAAa,
|
|
1
|
+
{"version":3,"file":"Checkbox.d.ts","sourceRoot":"","sources":["../../../../src/components/Checkbox/Checkbox.tsx"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AA2BzD;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,aAAa,kDA2L5C"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"NumberField.d.ts","sourceRoot":"","sources":["../../../../src/components/NumberField/NumberField.tsx"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAuC/D;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,gBAAgB,
|
|
1
|
+
{"version":3,"file":"NumberField.d.ts","sourceRoot":"","sources":["../../../../src/components/NumberField/NumberField.tsx"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAuC/D;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,gBAAgB,kDAkQlD"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"RadioGroup.d.ts","sourceRoot":"","sources":["../../../../src/components/RadioGroup/RadioGroup.tsx"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAE7D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,eAAe,
|
|
1
|
+
{"version":3,"file":"RadioGroup.d.ts","sourceRoot":"","sources":["../../../../src/components/RadioGroup/RadioGroup.tsx"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAE7D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,eAAe,kDA+OhD"}
|
package/dist/src/index.d.ts
CHANGED
|
@@ -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.1
|
|
103
|
+
export declare const VERSION = "0.2.1-beta";
|
|
104
104
|
//# sourceMappingURL=index.d.ts.map
|
package/package.json
CHANGED
|
@@ -109,6 +109,15 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
|
|
109
109
|
sx
|
|
110
110
|
);
|
|
111
111
|
|
|
112
|
+
/*
|
|
113
|
+
* `aria-busy` announces the loading state to assistive tech.
|
|
114
|
+
* `disabled` alone hides the reason (perm-denied vs loading vs
|
|
115
|
+
* intrinsic), so SR users only hear "dimmed/inactive" without
|
|
116
|
+
* knowing why. Adding `aria-busy={true}` while loading distinguishes
|
|
117
|
+
* "wait for the action to finish" from "you can't do this".
|
|
118
|
+
*/
|
|
119
|
+
const ariaBusy = loading ? true : undefined;
|
|
120
|
+
|
|
112
121
|
// `asChild` renders through Radix Slot: the immediate child element
|
|
113
122
|
// gets the resolved className, no extra Button DOM is emitted.
|
|
114
123
|
if (asChild) {
|
|
@@ -118,6 +127,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
|
|
118
127
|
className={classes}
|
|
119
128
|
data-disabled={effectiveDisabled || undefined}
|
|
120
129
|
aria-disabled={effectiveDisabled || undefined}
|
|
130
|
+
aria-busy={ariaBusy}
|
|
121
131
|
>
|
|
122
132
|
{children}
|
|
123
133
|
</Slot>
|
|
@@ -129,6 +139,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
|
|
129
139
|
ref={ref}
|
|
130
140
|
type={rest.type ?? 'button'}
|
|
131
141
|
disabled={effectiveDisabled}
|
|
142
|
+
aria-busy={ariaBusy}
|
|
132
143
|
className={classes}
|
|
133
144
|
{...rest}
|
|
134
145
|
>
|
|
@@ -193,11 +193,35 @@ export function Checkbox(props: CheckboxProps) {
|
|
|
193
193
|
ref={registration?.ref as React.Ref<HTMLButtonElement> | undefined}
|
|
194
194
|
className={cn(v.control(), slotProps?.control?.className)}
|
|
195
195
|
>
|
|
196
|
+
{/*
|
|
197
|
+
* Radix.Indicator natively mounts only when `data-state` is
|
|
198
|
+
* `checked` or `indeterminate` — i.e. it tracks Radix's
|
|
199
|
+
* internal state directly, no React state dependency.
|
|
200
|
+
*
|
|
201
|
+
* The previous implementation used `forceMount` + a React
|
|
202
|
+
* conditional `{resolvedChecked === true ? <CheckIcon /> : null}`,
|
|
203
|
+
* which broke standalone uncontrolled mode: Radix would flip
|
|
204
|
+
* its internal `data-state` on click (turning the control blue
|
|
205
|
+
* via `data-[state=checked]:bg-primary-500`) but the React
|
|
206
|
+
* snapshot for `resolvedChecked` stayed stale, so the
|
|
207
|
+
* `<CheckIcon />` never mounted. Result: blue box with no
|
|
208
|
+
* tick after user interaction.
|
|
209
|
+
*
|
|
210
|
+
* Dropping forceMount + the conditional defers the mount
|
|
211
|
+
* decision to Radix (the single source of truth in all three
|
|
212
|
+
* modes — controlled, uncontrolled, bridge). Indicator mounts
|
|
213
|
+
* exactly when the checkbox is checked OR indeterminate.
|
|
214
|
+
*
|
|
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.
|
|
220
|
+
*/}
|
|
196
221
|
<RadixCheckbox.Indicator
|
|
197
222
|
className={cn(v.indicator(), slotProps?.indicator?.className)}
|
|
198
|
-
forceMount
|
|
199
223
|
>
|
|
200
|
-
|
|
224
|
+
<CheckIcon className="h-full w-full" />
|
|
201
225
|
</RadixCheckbox.Indicator>
|
|
202
226
|
</RadixCheckbox.Root>
|
|
203
227
|
|
|
@@ -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
|
-
:
|
|
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
|
-
|
|
225
|
+
{...(isFormMode || explicitValue !== undefined
|
|
226
|
+
? { value: resolvedValue }
|
|
227
|
+
: { defaultValue: defaultValue ?? undefined })}
|
|
206
228
|
onValueChange={handleValueChange}
|
|
207
229
|
onBlur={handleBlur}
|
|
208
230
|
disabled={groupEffectiveDisabled}
|
package/src/index.ts
CHANGED
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.
|