@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
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: [
@@ -501,7 +510,24 @@ function _object_without_properties_loose(source, excluded) {
501
510
  'read-only:cursor-default'
502
511
  ],
503
512
  helperText: 'mt-1 text-sm text-neutral-600',
504
- errorText: 'mt-1 text-sm text-danger-600'
513
+ errorText: 'mt-1 text-sm text-danger-600',
514
+ /*
515
+ * `prefix` / `suffix` slot — inline adornment rendered before /
516
+ * after the input INSIDE the inputWrapper. `shrink-0` keeps the
517
+ * adornment width fixed so it doesn't compete for space with the
518
+ * input; `select-none` + `pointer-events-none` avoids
519
+ * accidentally stealing focus from the input on click (the
520
+ * inputWrapper handles focus via `focus-within`).
521
+ */ prefix: [
522
+ 'shrink-0 inline-flex items-center select-none pointer-events-none',
523
+ 'text-neutral-500',
524
+ 'mr-2'
525
+ ],
526
+ suffix: [
527
+ 'shrink-0 inline-flex items-center select-none pointer-events-none',
528
+ 'text-neutral-500',
529
+ 'ml-2'
530
+ ]
505
531
  },
506
532
  variants: {
507
533
  size: {
@@ -578,7 +604,7 @@ function _object_without_properties_loose(source, excluded) {
578
604
  * - `aria-describedby` links the input to the helper/error text node.
579
605
  * - Required fields get the native `required` attribute + a visual `*`.
580
606
  */ function TextField(props) {
581
- var _slotProps_root, _slotProps_label, _slotProps_requiredMark, _slotProps_inputWrapper, _slotProps_input, _slotProps_errorText, _slotProps_helperText;
607
+ var _slotProps_root, _slotProps_label, _slotProps_requiredMark, _slotProps_inputWrapper, _slotProps_prefix, _slotProps_input, _slotProps_suffix, _slotProps_errorText, _slotProps_helperText;
582
608
  const { name, rules, visibleWhen, layout = 'stacked', size, label, helperText, required, error, disabled, fullWidth, access, sx, slotProps, placeholder, type, onChange: userOnChange, onBlur: userOnBlur, value: userValue, defaultValue } = props, rest = _object_without_properties_loose(props, [
583
609
  "name",
584
610
  "rules",
@@ -708,25 +734,37 @@ function _object_without_properties_loose(source, excluded) {
708
734
  })
709
735
  ]
710
736
  }),
711
- jsx("div", {
737
+ jsxs("div", {
712
738
  className: cn(v.inputWrapper(), slotProps == null ? void 0 : (_slotProps_inputWrapper = slotProps.inputWrapper) == null ? void 0 : _slotProps_inputWrapper.className),
713
- children: jsx("input", _extends({}, rest, {
714
- id: inputId,
715
- name: name,
716
- type: type != null ? type : 'text',
717
- placeholder: placeholder,
718
- value: isFormMode ? resolvedValue != null ? resolvedValue : '' : userValue,
719
- defaultValue: !isFormMode && userValue === undefined ? defaultValue : undefined,
720
- disabled: effectiveDisabled,
721
- readOnly: effectiveReadOnly,
722
- required: required,
723
- "aria-invalid": resolvedError || undefined,
724
- "aria-describedby": resolvedHelperText ? helperId : undefined,
725
- onChange: handleChange,
726
- onBlur: handleBlur,
727
- ref: inputRef,
728
- className: cn(v.input(), slotProps == null ? void 0 : (_slotProps_input = slotProps.input) == null ? void 0 : _slotProps_input.className)
729
- }))
739
+ children: [
740
+ (slotProps == null ? void 0 : (_slotProps_prefix = slotProps.prefix) == null ? void 0 : _slotProps_prefix.children) !== undefined && jsx("span", {
741
+ "aria-hidden": "true",
742
+ className: cn(v.prefix(), slotProps.prefix.className),
743
+ children: slotProps.prefix.children
744
+ }),
745
+ jsx("input", _extends({}, rest, {
746
+ id: inputId,
747
+ name: name,
748
+ type: type != null ? type : 'text',
749
+ placeholder: placeholder,
750
+ value: isFormMode ? resolvedValue != null ? resolvedValue : '' : userValue,
751
+ defaultValue: !isFormMode && userValue === undefined ? defaultValue : undefined,
752
+ disabled: effectiveDisabled,
753
+ readOnly: effectiveReadOnly,
754
+ required: required,
755
+ "aria-invalid": resolvedError || undefined,
756
+ "aria-describedby": resolvedHelperText ? helperId : undefined,
757
+ onChange: handleChange,
758
+ onBlur: handleBlur,
759
+ ref: inputRef,
760
+ className: cn(v.input(), slotProps == null ? void 0 : (_slotProps_input = slotProps.input) == null ? void 0 : _slotProps_input.className)
761
+ })),
762
+ (slotProps == null ? void 0 : (_slotProps_suffix = slotProps.suffix) == null ? void 0 : _slotProps_suffix.children) !== undefined && jsx("span", {
763
+ "aria-hidden": "true",
764
+ className: cn(v.suffix(), slotProps.suffix.className),
765
+ children: slotProps.suffix.children
766
+ })
767
+ ]
730
768
  }),
731
769
  resolvedHelperText && jsx("p", {
732
770
  id: helperId,
@@ -802,8 +840,8 @@ function _object_without_properties_loose(source, excluded) {
802
840
  });
803
841
 
804
842
  /**
805
- * Inline checkmark SVG used by `Radix.Indicator`. Stroke uses
806
- * `currentColor` so the parent's `text-white` propagates correctly.
843
+ * Inline checkmark SVG used by `Radix.Indicator` when fully checked.
844
+ * Stroke uses `currentColor` so the parent's `text-white` propagates.
807
845
  *
808
846
  * @internal
809
847
  */ function CheckIcon({ className }) {
@@ -821,6 +859,32 @@ function _object_without_properties_loose(source, excluded) {
821
859
  })
822
860
  });
823
861
  }
862
+ /**
863
+ * Inline dash SVG used by `Radix.Indicator` when the checkbox is in
864
+ * the `indeterminate` tri-state ("some, but not all, children
865
+ * selected" — the canonical "select all" partial state).
866
+ *
867
+ * Pre-0.2.2-beta the indicator rendered the CheckIcon for BOTH
868
+ * checked and indeterminate (Radix mounts the Indicator for either
869
+ * state). We now discriminate via the `data-state` attribute Radix
870
+ * sets on the Indicator element itself — see the parent `Indicator`
871
+ * rendering below for the CSS toggle.
872
+ *
873
+ * @internal
874
+ */ function DashIcon({ className }) {
875
+ return jsx("svg", {
876
+ "aria-hidden": "true",
877
+ viewBox: "0 0 16 16",
878
+ fill: "none",
879
+ className: className,
880
+ children: jsx("path", {
881
+ d: "M3 8h10",
882
+ stroke: "currentColor",
883
+ strokeWidth: "2.5",
884
+ strokeLinecap: "round"
885
+ })
886
+ });
887
+ }
824
888
  /**
825
889
  * Dashforge TW Checkbox — bridge-integrated form control.
826
890
  *
@@ -966,12 +1030,16 @@ function _object_without_properties_loose(source, excluded) {
966
1030
  onBlur: handleBlur,
967
1031
  ref: registration == null ? void 0 : registration.ref,
968
1032
  className: cn(v.control(), slotProps == null ? void 0 : (_slotProps_control = slotProps.control) == null ? void 0 : _slotProps_control.className),
969
- children: jsx(RadixCheckbox.Indicator, {
970
- className: cn(v.indicator(), slotProps == null ? void 0 : (_slotProps_indicator = slotProps.indicator) == null ? void 0 : _slotProps_indicator.className),
971
- forceMount: true,
972
- children: resolvedChecked === true ? jsx(CheckIcon, {
973
- className: "h-full w-full"
974
- }) : null
1033
+ children: jsxs(RadixCheckbox.Indicator, {
1034
+ className: cn(v.indicator(), 'group', slotProps == null ? void 0 : (_slotProps_indicator = slotProps.indicator) == null ? void 0 : _slotProps_indicator.className),
1035
+ children: [
1036
+ jsx(CheckIcon, {
1037
+ className: "h-full w-full group-data-[state=indeterminate]:hidden"
1038
+ }),
1039
+ jsx(DashIcon, {
1040
+ className: "h-full w-full group-data-[state=checked]:hidden"
1041
+ })
1042
+ ]
975
1043
  })
976
1044
  })),
977
1045
  jsxs("div", {
@@ -1016,7 +1084,12 @@ function _object_without_properties_loose(source, excluded) {
1016
1084
  ],
1017
1085
  thumb: [
1018
1086
  'pointer-events-none inline-block rounded-full bg-white shadow ring-0',
1019
- 'transition-transform',
1087
+ // Slide animation gated on `prefers-reduced-motion: no-preference`
1088
+ // (WCAG 2.3.3). The thumb still moves between positions instantly
1089
+ // for users who request reduced motion — the data-state change
1090
+ // applies the translate-x rule unconditionally, only the smooth
1091
+ // transition between the two is suppressed.
1092
+ 'transition-transform motion-reduce:transition-none',
1020
1093
  'data-[state=unchecked]:translate-x-0'
1021
1094
  ],
1022
1095
  label: 'select-none cursor-pointer text-neutral-900',
@@ -1430,9 +1503,13 @@ function _object_without_properties_loose(source, excluded) {
1430
1503
  })
1431
1504
  ]
1432
1505
  }),
1433
- jsx(RadixRadioGroup.Root, {
1434
- name: name,
1435
- value: resolvedValue,
1506
+ jsx(RadixRadioGroup.Root, _extends({
1507
+ name: name
1508
+ }, isFormMode || explicitValue !== undefined ? {
1509
+ value: resolvedValue
1510
+ } : {
1511
+ defaultValue: defaultValue != null ? defaultValue : undefined
1512
+ }, {
1436
1513
  onValueChange: handleValueChange,
1437
1514
  onBlur: handleBlur,
1438
1515
  disabled: groupEffectiveDisabled,
@@ -1463,7 +1540,7 @@ function _object_without_properties_loose(source, excluded) {
1463
1540
  ]
1464
1541
  }, option.value);
1465
1542
  })
1466
- }),
1543
+ })),
1467
1544
  resolvedHelperText && jsx("p", {
1468
1545
  id: helperId,
1469
1546
  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 +1982,18 @@ function _object_without_properties_loose(source, excluded) {
1905
1982
  useDashFieldMeta(name);
1906
1983
  const accessState = useAccessState(access);
1907
1984
  const inputId = useId();
1985
+ /*
1986
+ * Local state for the STANDALONE UNCONTROLLED case (no bridge, no
1987
+ * `value` prop, only `defaultValue`). Mirrors OTPField. Without this,
1988
+ * `resolvedDisplayValue` would be a snapshot computed ONCE from
1989
+ * `defaultValue` and the controlled `<input value={...}>` would
1990
+ * snap user input back on every keystroke / stepper click — same
1991
+ * trap that hit Checkbox + RadioGroup in this package.
1992
+ *
1993
+ * In form mode the bridge owns state. In standalone CONTROLLED mode
1994
+ * (consumer passes `value`) the consumer owns state. Only this
1995
+ * branch needs the local hook.
1996
+ */ const [uncontrolledValue, setUncontrolledValue] = useState(()=>formatForDisplay(defaultValue));
1908
1997
  const helperId = `${inputId}-help`;
1909
1998
  // StrictMode-safe unregister-on-unmount
1910
1999
  const unregisterRef = useRef({
@@ -1942,7 +2031,7 @@ function _object_without_properties_loose(source, excluded) {
1942
2031
  resolvedHelperText = validation.helperText;
1943
2032
  resolvedDisplayValue = userValue !== undefined ? formatForDisplay(userValue) : formatForDisplay(bridge.getValue(name));
1944
2033
  } else {
1945
- resolvedDisplayValue = userValue !== undefined ? formatForDisplay(userValue) : formatForDisplay(defaultValue);
2034
+ resolvedDisplayValue = userValue !== undefined ? formatForDisplay(userValue) : uncontrolledValue;
1946
2035
  }
1947
2036
  const writeToBridge = (parsed)=>{
1948
2037
  if (!isFormMode || !bridge) return;
@@ -1957,6 +2046,13 @@ function _object_without_properties_loose(source, excluded) {
1957
2046
  // internal logic still sees the raw string change.
1958
2047
  void registration.onChange(e);
1959
2048
  }
2049
+ // Standalone uncontrolled mode: mirror the raw input string so the
2050
+ // controlled `<input value={...}>` reflects what the user typed.
2051
+ // Partial states (e.g. "-", "1.") are kept verbatim — `parseFromInput`
2052
+ // returns `undefined` for them so `writeToBridge` is a no-op above.
2053
+ if (!isFormMode && userValue === undefined) {
2054
+ setUncontrolledValue(e.target.value);
2055
+ }
1960
2056
  userOnChange == null ? void 0 : userOnChange(e);
1961
2057
  };
1962
2058
  const handleBlur = (e)=>{
@@ -1972,6 +2068,11 @@ function _object_without_properties_loose(source, excluded) {
1972
2068
  if (typeof min === 'number') next = Math.max(min, next);
1973
2069
  if (typeof max === 'number') next = Math.min(max, next);
1974
2070
  writeToBridge(next);
2071
+ // Standalone uncontrolled: also persist the new value to local
2072
+ // state so the visible display tracks the stepper click.
2073
+ if (!isFormMode && userValue === undefined) {
2074
+ setUncontrolledValue(formatForDisplay(next));
2075
+ }
1975
2076
  };
1976
2077
  const canIncrement = !effectiveDisabled && (typeof max !== 'number' || ((_parseFromInput = parseFromInput(resolvedDisplayValue)) != null ? _parseFromInput : -Infinity) < max);
1977
2078
  const canDecrement = !effectiveDisabled && (typeof min !== 'number' || ((_parseFromInput1 = parseFromInput(resolvedDisplayValue)) != null ? _parseFromInput1 : Infinity) > min);
@@ -2378,7 +2479,13 @@ function _object_without_properties_loose(source, excluded) {
2378
2479
  'flex items-center justify-center shrink-0 px-2',
2379
2480
  'text-neutral-600 hover:text-neutral-900',
2380
2481
  'disabled:cursor-not-allowed disabled:opacity-40',
2381
- 'transition-colors'
2482
+ 'transition-colors',
2483
+ // Chevron flip on open — targets the SVG child via the aria-
2484
+ // expanded state on the button itself (set by React). Smooth
2485
+ // rotate gated on prefers-reduced-motion (WCAG 2.3.3); the
2486
+ // 180° state still applies, just without the animated tween.
2487
+ '[&[aria-expanded=true]>svg]:rotate-180',
2488
+ '[&>svg]:transition-transform [&>svg]:duration-150 motion-reduce:[&>svg]:transition-none'
2382
2489
  ],
2383
2490
  clearButton: [
2384
2491
  'flex items-center justify-center shrink-0 px-2',
@@ -2471,6 +2578,52 @@ function _object_without_properties_loose(source, excluded) {
2471
2578
  }
2472
2579
  });
2473
2580
 
2581
+ /**
2582
+ * Inline SVG icons used by the Autocomplete chrome (chip remove, clear,
2583
+ * dropdown caret). Stroke uses `currentColor` so the parent's `text-*`
2584
+ * propagates — same pattern as Checkbox's CheckIcon.
2585
+ *
2586
+ * Why inline SVG (not lucide / heroicons / unicode glyphs):
2587
+ * - Zero icon-library dependency: keeps `@dashforge/tw` self-contained.
2588
+ * - Crisp at every size (the unicode `×` / `▾` glyphs we shipped pre-
2589
+ * 0.2.2 rendered as chunky font characters that looked unpolished
2590
+ * next to the rest of the design system).
2591
+ * - SVG scales with parent font-size cleanly via `width="1em" height="1em"`.
2592
+ *
2593
+ * @internal
2594
+ */ function CloseIcon({ className }) {
2595
+ return jsx("svg", {
2596
+ "aria-hidden": "true",
2597
+ viewBox: "0 0 16 16",
2598
+ width: "1em",
2599
+ height: "1em",
2600
+ fill: "none",
2601
+ className: className,
2602
+ children: jsx("path", {
2603
+ d: "M4 4l8 8M12 4l-8 8",
2604
+ stroke: "currentColor",
2605
+ strokeWidth: "1.75",
2606
+ strokeLinecap: "round"
2607
+ })
2608
+ });
2609
+ }
2610
+ function ChevronDownIcon({ className }) {
2611
+ return jsx("svg", {
2612
+ "aria-hidden": "true",
2613
+ viewBox: "0 0 16 16",
2614
+ width: "1em",
2615
+ height: "1em",
2616
+ fill: "none",
2617
+ className: className,
2618
+ children: jsx("path", {
2619
+ d: "M4 6l4 4 4-4",
2620
+ stroke: "currentColor",
2621
+ strokeWidth: "1.75",
2622
+ strokeLinecap: "round",
2623
+ strokeLinejoin: "round"
2624
+ })
2625
+ });
2626
+ }
2474
2627
  /**
2475
2628
  * Coerce a raw value (from bridge / props) into a canonical shape:
2476
2629
  *
@@ -2777,7 +2930,15 @@ const defaultGetOptionDisabled = (option)=>Boolean(option.disabled);
2777
2930
  if (next == null) {
2778
2931
  setInputValue('');
2779
2932
  } else {
2780
- const found = options.find((opt)=>getOptionValue(opt) === next);
2933
+ // Look up the label in the EFFECTIVE option pool (static
2934
+ // `options` + async `asyncOptions` when `loadOptions` is
2935
+ // configured). Looking only at the static `options` here
2936
+ // means async-loaded picks never update the input — the
2937
+ // user sees their search query stick instead of the
2938
+ // selected label after click. Same lookup logic mirrored in
2939
+ // `displayInputValue` so the two paths can't drift.
2940
+ const pool = loadOptions && asyncOptions !== null ? asyncOptions : options;
2941
+ const found = pool.find((opt)=>getOptionValue(opt) === next);
2781
2942
  const found_label = found ? labelAsString(found) : undefined;
2782
2943
  if (found_label !== undefined) {
2783
2944
  setInputValue(found_label);
@@ -2807,6 +2968,8 @@ const defaultGetOptionDisabled = (option)=>Boolean(option.disabled);
2807
2968
  // eslint-disable-next-line react-hooks/exhaustive-deps
2808
2969
  [
2809
2970
  options,
2971
+ asyncOptions,
2972
+ loadOptions,
2810
2973
  isFormMode,
2811
2974
  bridge,
2812
2975
  name,
@@ -3153,7 +3316,7 @@ const defaultGetOptionDisabled = (option)=>Boolean(option.disabled);
3153
3316
  "aria-label": `Remove ${labelText}`,
3154
3317
  tabIndex: -1,
3155
3318
  className: cn(v.chipRemove(), slotProps == null ? void 0 : (_slotProps_chipRemove = slotProps.chipRemove) == null ? void 0 : _slotProps_chipRemove.className),
3156
- children: "\u00D7"
3319
+ children: jsx(CloseIcon, {})
3157
3320
  })
3158
3321
  ]
3159
3322
  }, key);
@@ -3197,7 +3360,7 @@ const defaultGetOptionDisabled = (option)=>Boolean(option.disabled);
3197
3360
  "aria-label": "Clear selection",
3198
3361
  tabIndex: -1,
3199
3362
  className: cn(v.clearButton(), slotProps == null ? void 0 : (_slotProps_clearButton = slotProps.clearButton) == null ? void 0 : _slotProps_clearButton.className),
3200
- children: "\u00D7"
3363
+ children: jsx(CloseIcon, {})
3201
3364
  }),
3202
3365
  jsx("button", {
3203
3366
  type: "button",
@@ -3209,7 +3372,7 @@ const defaultGetOptionDisabled = (option)=>Boolean(option.disabled);
3209
3372
  tabIndex: -1,
3210
3373
  disabled: effectiveDisabled,
3211
3374
  className: cn(v.trigger(), slotProps == null ? void 0 : (_slotProps_trigger = slotProps.trigger) == null ? void 0 : _slotProps_trigger.className),
3212
- children: "\u25BE"
3375
+ children: jsx(ChevronDownIcon, {})
3213
3376
  }),
3214
3377
  isOpen && jsx("ul", {
3215
3378
  ref: listboxRef,
@@ -3607,7 +3770,12 @@ const defaultGetOptionDisabled = (option)=>Boolean(option.disabled);
3607
3770
  'text-neutral-600 hover:text-primary-700',
3608
3771
  'rounded-sm outline-none',
3609
3772
  'focus-visible:ring-2 focus-visible:ring-primary-500/50',
3610
- 'transition-colors'
3773
+ 'transition-colors',
3774
+ // Defensive `no-underline` — see the matching comment in
3775
+ // LeftNav itemLink slot. The Breadcrumbs root cause covers
3776
+ // TopBar too because Breadcrumbs is typically rendered in
3777
+ // TopBar's center slot.
3778
+ 'no-underline hover:no-underline'
3611
3779
  ],
3612
3780
  current: [
3613
3781
  'inline-flex items-center gap-1 truncate',
@@ -3801,7 +3969,10 @@ const defaultGetOptionDisabled = (option)=>Boolean(option.disabled);
3801
3969
  root: [
3802
3970
  'flex flex-col h-full',
3803
3971
  'bg-neutral-50 border-r border-neutral-200',
3804
- 'transition-[width] duration-200'
3972
+ // Width transition (rail-mode toggle) — gated on
3973
+ // prefers-reduced-motion (WCAG 2.3.3). The new width still
3974
+ // applies, just without the animated tween.
3975
+ 'transition-[width] duration-200 motion-reduce:transition-none motion-reduce:duration-0'
3805
3976
  ],
3806
3977
  brand: [
3807
3978
  'flex items-center gap-2 px-3 h-14 shrink-0',
@@ -3815,7 +3986,15 @@ const defaultGetOptionDisabled = (option)=>Boolean(option.disabled);
3815
3986
  'outline-none focus-visible:ring-2 focus-visible:ring-primary-500/50',
3816
3987
  'transition-colors w-full',
3817
3988
  'aria-disabled:opacity-50 aria-disabled:cursor-not-allowed',
3818
- 'aria-disabled:hover:bg-transparent'
3989
+ 'aria-disabled:hover:bg-transparent',
3990
+ // Defensive `no-underline` — Tailwind's preflight removes the
3991
+ // default browser anchor underline globally, but environments
3992
+ // that DISABLE preflight (e.g. our docs-lab, where the tw
3993
+ // section coexists with MUI's chrome) get raw browser defaults
3994
+ // back. Without this, `<a>` items in the nav render underlined
3995
+ // in those contexts. Explicit `no-underline` + `hover:no-underline`
3996
+ // keeps the appearance consistent regardless of preflight state.
3997
+ 'no-underline hover:no-underline'
3819
3998
  ],
3820
3999
  itemActive: 'bg-primary-100 text-primary-900 font-medium',
3821
4000
  itemIcon: 'shrink-0 w-5 h-5 flex items-center justify-center',
@@ -4223,14 +4402,20 @@ const defaultGetOptionDisabled = (option)=>Boolean(option.disabled);
4223
4402
  nav: 'hidden md:flex shrink-0',
4224
4403
  navMobile: [
4225
4404
  'flex md:hidden fixed inset-y-0 left-0 z-40',
4226
- 'transition-transform duration-200 ease-out',
4405
+ // Drawer slide-in is the most prominent motion in AppShell and
4406
+ // a clear WCAG 2.3.3 candidate. Gate the transition on
4407
+ // `prefers-reduced-motion: no-preference`; the data-driven
4408
+ // `-translate-x-full` / `translate-x-0` still applies, just
4409
+ // without the smooth slide.
4410
+ 'transition-transform duration-200 ease-out motion-reduce:transition-none motion-reduce:duration-0',
4227
4411
  '-translate-x-full'
4228
4412
  ],
4229
4413
  main: 'flex-1 min-w-0 overflow-y-auto',
4230
4414
  footer: 'shrink-0',
4231
4415
  backdrop: [
4232
4416
  'md:hidden fixed inset-0 z-30 bg-black/40',
4233
- 'transition-opacity duration-200',
4417
+ // Backdrop opacity fade — micro motion, gated for consistency.
4418
+ 'transition-opacity duration-200 motion-reduce:transition-none motion-reduce:duration-0',
4234
4419
  'opacity-0 pointer-events-none'
4235
4420
  ]
4236
4421
  },
@@ -4247,6 +4432,28 @@ const defaultGetOptionDisabled = (option)=>Boolean(option.disabled);
4247
4432
  }
4248
4433
  });
4249
4434
 
4435
+ /**
4436
+ * Query selector for natively-focusable elements + anything with an
4437
+ * explicit positive (or implicit `0`) tabindex. Used by the focus
4438
+ * trap to enumerate the drawer's interactive content.
4439
+ *
4440
+ * Notes:
4441
+ * - Excludes `[tabindex="-1"]` (intentionally non-tabbable).
4442
+ * - Excludes `:disabled` — the browser already skips them in tab
4443
+ * order; including them here would route focus to dead elements.
4444
+ * - `details > summary` is the standard way to get a focusable
4445
+ * `<details>` toggle.
4446
+ *
4447
+ * @internal
4448
+ */ const FOCUSABLE_SELECTOR = [
4449
+ 'a[href]',
4450
+ 'button:not(:disabled)',
4451
+ 'input:not(:disabled):not([type="hidden"])',
4452
+ 'select:not(:disabled)',
4453
+ 'textarea:not(:disabled)',
4454
+ '[tabindex]:not([tabindex="-1"]):not(:disabled)',
4455
+ 'details > summary:first-of-type'
4456
+ ].join(',');
4250
4457
  /**
4251
4458
  * Dashforge TW AppShell — top-level layout orchestrator.
4252
4459
  *
@@ -4275,6 +4482,20 @@ const defaultGetOptionDisabled = (option)=>Boolean(option.disabled);
4275
4482
  * - The mobile drawer + backdrop participate in the standard
4276
4483
  * "click outside to close" pattern — `Escape` closes the drawer
4277
4484
  * too (added via global keydown).
4485
+ * - **Focus trap** (WCAG 2.4.3) — when the mobile drawer opens:
4486
+ * 1. The previously-focused element is captured.
4487
+ * 2. Focus moves to the first focusable element inside the drawer.
4488
+ * 3. Tab / Shift+Tab wrap inside the drawer (cycle from
4489
+ * last → first and first → last) — Tab can never escape
4490
+ * to the page underneath while the drawer is open.
4491
+ * 4. On close, focus returns to the captured element (typically
4492
+ * the hamburger toggle the user pressed to open the drawer).
4493
+ * This pattern matches WAI-ARIA APG's modal dialog guidance even
4494
+ * though the drawer isn't strictly a dialog — same focus
4495
+ * management makes keyboard users' experience predictable.
4496
+ * - **`role="dialog"` + `aria-modal="true"`** are applied to the
4497
+ * drawer when open so screen readers announce it as a modal
4498
+ * overlay rather than just an aside.
4278
4499
  */ function AppShell(props) {
4279
4500
  var _slotProps_root, _slotProps_header, _slotProps_nav, _slotProps_navMobile, _slotProps_backdrop, _slotProps_main, _slotProps_footer;
4280
4501
  const { header, nav, footer, children, navOpen = false, onNavOpenChange, sx, slotProps } = props;
@@ -4304,6 +4525,80 @@ const defaultGetOptionDisabled = (option)=>Boolean(option.disabled);
4304
4525
  navOpen,
4305
4526
  onNavOpenChange
4306
4527
  ]);
4528
+ /*
4529
+ * Focus trap for the mobile drawer.
4530
+ *
4531
+ * Implementation:
4532
+ * - On open: capture the currently-focused element (`document.activeElement`)
4533
+ * so we can restore it on close. Then move focus to the first
4534
+ * focusable element inside the drawer.
4535
+ * - While open: a Tab/Shift+Tab keydown listener wraps focus inside
4536
+ * the drawer subtree (last → first on Tab from end, first → last
4537
+ * on Shift+Tab from start).
4538
+ * - On close: restore focus to the captured element.
4539
+ *
4540
+ * Hand-rolled (no `focus-trap-react` dep) — the logic is ~20 LOC and
4541
+ * scoped to a single drawer; adding a runtime dep felt like overkill.
4542
+ * If we ever need more sophisticated trap semantics (nested traps,
4543
+ * sentinel nodes, etc.) the dep is the right call.
4544
+ */ const drawerRef = useRef(null);
4545
+ const restoreFocusRef = useRef(null);
4546
+ useEffect(()=>{
4547
+ if (!navOpen) return;
4548
+ // 1) Capture the element that had focus before the drawer opened.
4549
+ restoreFocusRef.current = document.activeElement;
4550
+ // 2) Move focus to the first focusable element inside the drawer.
4551
+ // Defer via rAF so the drawer DOM (slide-in animation start) has
4552
+ // settled and the elements are actually visible/focusable.
4553
+ const raf = requestAnimationFrame(()=>{
4554
+ const drawer = drawerRef.current;
4555
+ if (!drawer) return;
4556
+ const first = drawer.querySelector(FOCUSABLE_SELECTOR);
4557
+ first == null ? void 0 : first.focus();
4558
+ });
4559
+ // 3) Trap Tab / Shift+Tab inside the drawer.
4560
+ const onKeyDown = (event)=>{
4561
+ if (event.key !== 'Tab') return;
4562
+ const drawer = drawerRef.current;
4563
+ if (!drawer) return;
4564
+ const focusables = Array.from(drawer.querySelectorAll(FOCUSABLE_SELECTOR));
4565
+ if (focusables.length === 0) {
4566
+ event.preventDefault();
4567
+ return;
4568
+ }
4569
+ const first = focusables[0];
4570
+ const last = focusables[focusables.length - 1];
4571
+ const active = document.activeElement;
4572
+ if (event.shiftKey) {
4573
+ // Shift+Tab on first → wrap to last.
4574
+ if (active === first || !drawer.contains(active)) {
4575
+ event.preventDefault();
4576
+ last.focus();
4577
+ }
4578
+ } else {
4579
+ // Tab on last → wrap to first.
4580
+ if (active === last || !drawer.contains(active)) {
4581
+ event.preventDefault();
4582
+ first.focus();
4583
+ }
4584
+ }
4585
+ };
4586
+ document.addEventListener('keydown', onKeyDown);
4587
+ return ()=>{
4588
+ cancelAnimationFrame(raf);
4589
+ document.removeEventListener('keydown', onKeyDown);
4590
+ // 4) Restore focus to the previously-focused element on close.
4591
+ // Guard against the element being removed from the DOM (rare
4592
+ // but possible if the consumer re-renders the page while the
4593
+ // drawer is open).
4594
+ const restore = restoreFocusRef.current;
4595
+ if (restore && document.body.contains(restore)) {
4596
+ restore.focus();
4597
+ }
4598
+ };
4599
+ }, [
4600
+ navOpen
4601
+ ]);
4307
4602
  return jsxs("div", {
4308
4603
  className: cn(v.root(), sx, slotProps == null ? void 0 : (_slotProps_root = slotProps.root) == null ? void 0 : _slotProps_root.className),
4309
4604
  children: [
@@ -4321,8 +4616,15 @@ const defaultGetOptionDisabled = (option)=>Boolean(option.disabled);
4321
4616
  children: nav
4322
4617
  }),
4323
4618
  jsx("aside", {
4619
+ ref: drawerRef,
4324
4620
  className: cn(v.navMobile(), slotProps == null ? void 0 : (_slotProps_navMobile = slotProps.navMobile) == null ? void 0 : _slotProps_navMobile.className),
4325
4621
  "aria-hidden": !navOpen,
4622
+ // When open, the drawer is a modal overlay — `dialog` +
4623
+ // `aria-modal="true"` so screen readers announce it as
4624
+ // such. When closed, drop both attributes (the aria-
4625
+ // hidden=true above already removes it from the AT tree).
4626
+ role: navOpen ? 'dialog' : undefined,
4627
+ "aria-modal": navOpen ? true : undefined,
4326
4628
  children: nav
4327
4629
  }),
4328
4630
  jsx("div", {
@@ -4627,8 +4929,10 @@ const ConfirmContext = /*#__PURE__*/ createContext(null);
4627
4929
  'rounded-lg border shadow-lg',
4628
4930
  'text-sm bg-neutral-50 text-neutral-900',
4629
4931
  // Subtle enter transition — opacity + translate, kept short so a
4630
- // burst of snackbars feels snappy.
4631
- 'transition-all duration-200',
4932
+ // burst of snackbars feels snappy. Gated on motion-reduce
4933
+ // (WCAG 2.3.3) — users who request reduced motion see snackbars
4934
+ // pop in instantly without the slide animation.
4935
+ 'transition-all duration-200 motion-reduce:transition-none motion-reduce:duration-0',
4632
4936
  'data-[state=entered]:opacity-100 data-[state=exited]:opacity-0'
4633
4937
  ],
4634
4938
  icon: 'shrink-0 mt-0.5 w-5 h-5 inline-flex items-center justify-center',
@@ -6514,6 +6818,6 @@ VisuallyHidden.displayName = 'VisuallyHidden';
6514
6818
  */ // ───── Components ─────
6515
6819
  /**
6516
6820
  * Package version (synced with `package.json` at publish time).
6517
- */ const VERSION = '0.1.0-beta';
6821
+ */ const VERSION = '0.3.0-beta';
6518
6822
 
6519
6823
  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 };
@@ -27,6 +27,20 @@ import type { AppShellProps } from './appShell.types.js';
27
27
  * - The mobile drawer + backdrop participate in the standard
28
28
  * "click outside to close" pattern — `Escape` closes the drawer
29
29
  * too (added via global keydown).
30
+ * - **Focus trap** (WCAG 2.4.3) — when the mobile drawer opens:
31
+ * 1. The previously-focused element is captured.
32
+ * 2. Focus moves to the first focusable element inside the drawer.
33
+ * 3. Tab / Shift+Tab wrap inside the drawer (cycle from
34
+ * last → first and first → last) — Tab can never escape
35
+ * to the page underneath while the drawer is open.
36
+ * 4. On close, focus returns to the captured element (typically
37
+ * the hamburger toggle the user pressed to open the drawer).
38
+ * This pattern matches WAI-ARIA APG's modal dialog guidance even
39
+ * though the drawer isn't strictly a dialog — same focus
40
+ * management makes keyboard users' experience predictable.
41
+ * - **`role="dialog"` + `aria-modal="true"`** are applied to the
42
+ * drawer when open so screen readers announce it as a modal
43
+ * overlay rather than just an aside.
30
44
  */
31
45
  export declare function AppShell(props: AppShellProps): import("react/jsx-runtime").JSX.Element;
32
46
  //# sourceMappingURL=AppShell.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"AppShell.d.ts","sourceRoot":"","sources":["../../../../src/components/AppShell/AppShell.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEzD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,aAAa,2CA+E5C"}
1
+ {"version":3,"file":"AppShell.d.ts","sourceRoot":"","sources":["../../../../src/components/AppShell/AppShell.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AA0BzD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,aAAa,2CAsK5C"}
@@ -1 +1 @@
1
- {"version":3,"file":"appShell.variants.d.ts","sourceRoot":"","sources":["../../../../src/components/AppShell/appShell.variants.ts"],"names":[],"mappings":"AAAA,OAAO,EAAM,KAAK,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAE1D;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;2CA8B3B,CAAC;AAEH,MAAM,MAAM,gBAAgB,GAAG,YAAY,CAAC,OAAO,gBAAgB,CAAC,CAAC"}
1
+ {"version":3,"file":"appShell.variants.d.ts","sourceRoot":"","sources":["../../../../src/components/AppShell/appShell.variants.ts"],"names":[],"mappings":"AAAA,OAAO,EAAM,KAAK,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAE1D;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;2CAoC3B,CAAC;AAEH,MAAM,MAAM,gBAAgB,GAAG,YAAY,CAAC,OAAO,gBAAgB,CAAC,CAAC"}