@charcuterie/ui 2.1.0 → 2.3.0

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 (45) hide show
  1. package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.d.ts +1 -1
  2. package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.d.ts.map +1 -1
  3. package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.js.map +1 -1
  4. package/dist/ColorSchemeToggle/ColorSchemeToggle.d.ts +11 -2
  5. package/dist/ColorSchemeToggle/ColorSchemeToggle.d.ts.map +1 -1
  6. package/dist/ColorSchemeToggle/ColorSchemeToggle.js +2 -2
  7. package/dist/ColorSchemeToggle/ColorSchemeToggle.js.map +1 -1
  8. package/dist/Combobox/Combobox.d.ts +22 -4
  9. package/dist/Combobox/Combobox.d.ts.map +1 -1
  10. package/dist/Combobox/Combobox.js +142 -24
  11. package/dist/Combobox/Combobox.js.map +1 -1
  12. package/dist/Combobox/ComboboxOption.d.ts.map +1 -1
  13. package/dist/Combobox/ComboboxOption.js +22 -1
  14. package/dist/Combobox/ComboboxOption.js.map +1 -1
  15. package/dist/Listbox/ListboxOption.d.ts.map +1 -1
  16. package/dist/Listbox/ListboxOption.js +12 -2
  17. package/dist/Listbox/ListboxOption.js.map +1 -1
  18. package/dist/Overlay/useAnchoredOverlay.d.ts +17 -5
  19. package/dist/Overlay/useAnchoredOverlay.d.ts.map +1 -1
  20. package/dist/Overlay/useAnchoredOverlay.js +23 -3
  21. package/dist/Overlay/useAnchoredOverlay.js.map +1 -1
  22. package/dist/Select/Select.d.ts.map +1 -1
  23. package/dist/Select/Select.js +8 -2
  24. package/dist/Select/Select.js.map +1 -1
  25. package/dist/controlStyles.d.ts +8 -0
  26. package/dist/controlStyles.d.ts.map +1 -1
  27. package/dist/controlStyles.js +8 -0
  28. package/dist/controlStyles.js.map +1 -1
  29. package/package.json +1 -1
  30. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.mdx +6 -0
  31. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.test.tsx +12 -0
  32. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.tsx +1 -0
  33. package/src/ColorSchemeToggle/ColorSchemeToggle.mdx +12 -0
  34. package/src/ColorSchemeToggle/ColorSchemeToggle.stories.tsx +39 -1
  35. package/src/ColorSchemeToggle/ColorSchemeToggle.test.tsx +39 -3
  36. package/src/ColorSchemeToggle/ColorSchemeToggle.tsx +15 -1
  37. package/src/Combobox/Combobox.mdx +30 -0
  38. package/src/Combobox/Combobox.stories.tsx +150 -5
  39. package/src/Combobox/Combobox.test.tsx +120 -1
  40. package/src/Combobox/Combobox.tsx +266 -72
  41. package/src/Combobox/ComboboxOption.tsx +22 -3
  42. package/src/Listbox/ListboxOption.tsx +11 -3
  43. package/src/Overlay/useAnchoredOverlay.ts +49 -8
  44. package/src/Select/Select.tsx +6 -5
  45. package/src/controlStyles.ts +8 -0
@@ -1 +1 @@
1
- {"version":3,"file":"useAnchoredOverlay.js","sourceRoot":"","sources":["../../src/Overlay/useAnchoredOverlay.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"useAnchoredOverlay.js","sourceRoot":"","sources":["../../src/Overlay/useAnchoredOverlay.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"Select.d.ts","sourceRoot":"","sources":["../../src/Select/Select.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AACtD,OAAO,KAAK,EAEV,qBAAqB,EACrB,SAAS,EACV,MAAM,OAAO,CAAA;AAYd,MAAM,MAAM,YAAY,GAAG;IACzB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;CACd,CAAA;AAED,MAAM,MAAM,iBAAiB,GAAG;IAC9B,KAAK,EAAE,MAAM,CAAA;IACb,OAAO,EAAE,SAAS,YAAY,EAAE,CAAA;CACjC,CAAA;AAED,MAAM,MAAM,UAAU,GAAG,YAAY,GAAG,iBAAiB,CAAA;AAEzD;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,WAAW,GAAG,IAAI,CAC5B,qBAAqB,CAAC,QAAQ,CAAC,EAC/B,UAAU,GAAG,UAAU,GAAG,MAAM,GAAG,OAAO,CAC3C,GAAG;IACF,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;IAClC;;;;;;;OAOG;IACH,OAAO,EAAE,SAAS,UAAU,EAAE,CAAA;IAC9B;;;;;OAKG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,IAAI,CAAC,EAAE,WAAW,CAAA;IAClB,qEAAqE;IACrE,KAAK,CAAC,EAAE,MAAM,CAAA;CACf,CAAA;AAMD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,eAAO,MAAM,MAAM,GAAI,+FAUpB,WAAW,KAAG,SA8EhB,CAAA"}
1
+ {"version":3,"file":"Select.d.ts","sourceRoot":"","sources":["../../src/Select/Select.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AACtD,OAAO,KAAK,EAEV,qBAAqB,EACrB,SAAS,EACV,MAAM,OAAO,CAAA;AASd,MAAM,MAAM,YAAY,GAAG;IACzB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;CACd,CAAA;AAED,MAAM,MAAM,iBAAiB,GAAG;IAC9B,KAAK,EAAE,MAAM,CAAA;IACb,OAAO,EAAE,SAAS,YAAY,EAAE,CAAA;CACjC,CAAA;AAED,MAAM,MAAM,UAAU,GAAG,YAAY,GAAG,iBAAiB,CAAA;AAEzD;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,WAAW,GAAG,IAAI,CAC5B,qBAAqB,CAAC,QAAQ,CAAC,EAC/B,UAAU,GAAG,UAAU,GAAG,MAAM,GAAG,OAAO,CAC3C,GAAG;IACF,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;IAClC;;;;;;;OAOG;IACH,OAAO,EAAE,SAAS,UAAU,EAAE,CAAA;IAC9B;;;;;OAKG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,IAAI,CAAC,EAAE,WAAW,CAAA;IAClB,qEAAqE;IACrE,KAAK,CAAC,EAAE,MAAM,CAAA;CACf,CAAA;AAMD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,eAAO,MAAM,MAAM,GAAI,+FAUpB,WAAW,KAAG,SAkFhB,CAAA"}
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { CONTROL_SIZE_CLASS, MIN_TOUCH_TARGET_CLASS, } from "../controlStyles.js";
2
+ import { CONTROL_SIZE_CLASS } from "../controlStyles.js";
3
3
  import { DISABLED_CLASS, FOCUS_RING_CLASS, } from "../intentStyles.js";
4
4
  import { toClassName } from "../toClassName.js";
5
5
  const getIsGroup = (item) => "options" in item;
@@ -44,7 +44,13 @@ export const Select = ({ className, isDisabled = false, label, onChange, options
44
44
  // cell. A `<select>`'s own arrow is what `appearance-none`
45
45
  // removes, and forgetting to put one back leaves a control
46
46
  // that looks exactly like a text input.
47
- "pe-9", CONTROL_SIZE_CLASS[size], MIN_TOUCH_TARGET_CLASS, "hover:border-border-strong", FOCUS_RING_CLASS, DISABLED_CLASS, className), defaultValue: value ??
47
+ "pe-9",
48
+ // Height comes from the shared control-size/density system ONLY —
49
+ // no per-component touch-target floor. Touch is the density axis's
50
+ // job (`[data-density]` scales `--control-height-*` past 44px on a
51
+ // kiosk), so forcing 44px here made Select taller than Button et al.
52
+ // at desktop density. See the controls-share-one-height decision.
53
+ CONTROL_SIZE_CLASS[size], "hover:border-border-strong", FOCUS_RING_CLASS, DISABLED_CLASS, className), defaultValue: value ??
48
54
  (placeholder === undefined ? undefined : ""), disabled: isDisabled, onChange: (changeEvent) => {
49
55
  onChange?.(changeEvent.currentTarget.value);
50
56
  }, children: [placeholder === undefined ? null : (_jsx("option", { disabled: true, value: "", children: placeholder })), options.map((item) => getIsGroup(item) ? (_jsx("optgroup", { label: item.label, children: item.options.map((option) => (_jsx("option", { disabled: option.isDisabled, value: option.value, children: option.label }, option.value))) }, item.label)) : (_jsx("option", { disabled: item.isDisabled, value: item.value, children: item.label }, item.value)))] }), _jsx("svg", { "aria-hidden": "true", className: "pointer-events-none absolute end-3 size-4 text-content-secondary", fill: "none", focusable: false, stroke: "currentColor", strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 1.75, viewBox: "0 0 24 24", xmlns: "http://www.w3.org/2000/svg", children: _jsx("path", { d: "m6 9 6 6 6-6" }) })] }));
@@ -1 +1 @@
1
- {"version":3,"file":"Select.js","sourceRoot":"","sources":["../../src/Select/Select.tsx"],"names":[],"mappings":";AAOA,OAAO,EACL,kBAAkB,EAClB,sBAAsB,GACvB,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EACL,cAAc,EACd,gBAAgB,GACjB,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAgE/C,MAAM,UAAU,GAAG,CACjB,IAAgB,EACW,EAAE,CAAC,SAAS,IAAI,IAAI,CAAA;AAEjD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,MAAM,CAAC,MAAM,MAAM,GAAG,CAAC,EACrB,SAAS,EACT,UAAU,GAAG,KAAK,EAClB,KAAK,EACL,QAAQ,EACR,OAAO,EACP,WAAW,EACX,IAAI,GAAG,IAAI,EACX,KAAK,EACL,GAAG,WAAW,EACF,EAAa,EAAE,CAAC,CAC5B,eAAK,SAAS,EAAC,0CAA0C,aACvD,qBACM,WAAW,gBACH,KAAK,EACjB,SAAS,EAAE,WAAW,CACpB,iLAAiL;YACjL,0DAA0D;YAC1D,2DAA2D;YAC3D,2DAA2D;YAC3D,wCAAwC;YACxC,MAAM,EACN,kBAAkB,CAAC,IAAI,CAAC,EACxB,sBAAsB,EACtB,4BAA4B,EAC5B,gBAAgB,EAChB,cAAc,EACd,SAAS,CACV,EACD,YAAY,EACV,KAAK;gBACL,CAAC,WAAW,KAAK,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,EAE9C,QAAQ,EAAE,UAAU,EACpB,QAAQ,EAAE,CACR,WAA2C,EAC3C,EAAE;gBACF,QAAQ,EAAE,CAAC,WAAW,CAAC,aAAa,CAAC,KAAK,CAAC,CAAA;YAC7C,CAAC,aAEA,WAAW,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAClC,iBAAQ,QAAQ,QAAC,KAAK,EAAC,EAAE,YACtB,WAAW,GACL,CACV,EAEA,OAAO,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CACpB,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CACjB,mBAA2B,KAAK,EAAE,IAAI,CAAC,KAAK,YACzC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAC5B,iBACE,QAAQ,EAAE,MAAM,CAAC,UAAU,EAE3B,KAAK,EAAE,MAAM,CAAC,KAAK,YAElB,MAAM,CAAC,KAAK,IAHR,MAAM,CAAC,KAAK,CAIV,CACV,CAAC,IATW,IAAI,CAAC,KAAK,CAUd,CACZ,CAAC,CAAC,CAAC,CACF,iBACE,QAAQ,EAAE,IAAI,CAAC,UAAU,EAEzB,KAAK,EAAE,IAAI,CAAC,KAAK,YAEhB,IAAI,CAAC,KAAK,IAHN,IAAI,CAAC,KAAK,CAIR,CACV,CACF,IACM,EAET,6BAGc,MAAM,EAClB,SAAS,EAAC,kEAAkE,EAC5E,IAAI,EAAC,MAAM,EACX,SAAS,EAAE,KAAK,EAChB,MAAM,EAAC,cAAc,EACrB,aAAa,EAAC,OAAO,EACrB,cAAc,EAAC,OAAO,EACtB,WAAW,EAAE,IAAI,EACjB,OAAO,EAAC,WAAW,EACnB,KAAK,EAAC,4BAA4B,YAElC,eAAM,CAAC,EAAC,cAAc,GAAG,GACrB,IACF,CACP,CAAA"}
1
+ {"version":3,"file":"Select.js","sourceRoot":"","sources":["../../src/Select/Select.tsx"],"names":[],"mappings":";AAOA,OAAO,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAA;AACxD,OAAO,EACL,cAAc,EACd,gBAAgB,GACjB,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAgE/C,MAAM,UAAU,GAAG,CACjB,IAAgB,EACW,EAAE,CAAC,SAAS,IAAI,IAAI,CAAA;AAEjD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,MAAM,CAAC,MAAM,MAAM,GAAG,CAAC,EACrB,SAAS,EACT,UAAU,GAAG,KAAK,EAClB,KAAK,EACL,QAAQ,EACR,OAAO,EACP,WAAW,EACX,IAAI,GAAG,IAAI,EACX,KAAK,EACL,GAAG,WAAW,EACF,EAAa,EAAE,CAAC,CAC5B,eAAK,SAAS,EAAC,0CAA0C,aACvD,qBACM,WAAW,gBACH,KAAK,EACjB,SAAS,EAAE,WAAW,CACpB,iLAAiL;YACjL,0DAA0D;YAC1D,2DAA2D;YAC3D,2DAA2D;YAC3D,wCAAwC;YACxC,MAAM;YACN,kEAAkE;YAClE,mEAAmE;YACnE,mEAAmE;YACnE,qEAAqE;YACrE,kEAAkE;YAClE,kBAAkB,CAAC,IAAI,CAAC,EACxB,4BAA4B,EAC5B,gBAAgB,EAChB,cAAc,EACd,SAAS,CACV,EACD,YAAY,EACV,KAAK;gBACL,CAAC,WAAW,KAAK,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,EAE9C,QAAQ,EAAE,UAAU,EACpB,QAAQ,EAAE,CACR,WAA2C,EAC3C,EAAE;gBACF,QAAQ,EAAE,CAAC,WAAW,CAAC,aAAa,CAAC,KAAK,CAAC,CAAA;YAC7C,CAAC,aAEA,WAAW,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAClC,iBAAQ,QAAQ,QAAC,KAAK,EAAC,EAAE,YACtB,WAAW,GACL,CACV,EAEA,OAAO,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CACpB,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CACjB,mBAA2B,KAAK,EAAE,IAAI,CAAC,KAAK,YACzC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAC5B,iBACE,QAAQ,EAAE,MAAM,CAAC,UAAU,EAE3B,KAAK,EAAE,MAAM,CAAC,KAAK,YAElB,MAAM,CAAC,KAAK,IAHR,MAAM,CAAC,KAAK,CAIV,CACV,CAAC,IATW,IAAI,CAAC,KAAK,CAUd,CACZ,CAAC,CAAC,CAAC,CACF,iBACE,QAAQ,EAAE,IAAI,CAAC,UAAU,EAEzB,KAAK,EAAE,IAAI,CAAC,KAAK,YAEhB,IAAI,CAAC,KAAK,IAHN,IAAI,CAAC,KAAK,CAIR,CACV,CACF,IACM,EAET,6BAGc,MAAM,EAClB,SAAS,EAAC,kEAAkE,EAC5E,IAAI,EAAC,MAAM,EACX,SAAS,EAAE,KAAK,EAChB,MAAM,EAAC,cAAc,EACrB,aAAa,EAAC,OAAO,EACrB,cAAc,EAAC,OAAO,EACtB,WAAW,EAAE,IAAI,EACjB,OAAO,EAAC,WAAW,EACnB,KAAK,EAAC,4BAA4B,YAElC,eAAM,CAAC,EAAC,cAAc,GAAG,GACrB,IACF,CACP,CAAA"}
@@ -28,6 +28,14 @@ export declare const ICON_CONTROL_SIZE_CLASS: Record<ControlSize, string>;
28
28
  * density — a finger does not get smaller because the list is
29
29
  * dense — so this is opt-in per control rather than baked into the
30
30
  * size classes, which would make `compact` a lie.
31
+ *
32
+ * **Do NOT add this to a control that shares a form row** (`Button`,
33
+ * `Select`, a `Listbox`/`Combobox` trigger, `SegmentedControl`, …). It
34
+ * overrides the shared `CONTROL_SIZE_CLASS` height and makes that one
35
+ * control taller than its neighbours at desktop density — touch sizing is
36
+ * the density axis's job. Reserve this for a genuinely standalone tap
37
+ * target. See
38
+ * `docs/decisions/2026-08-05-controls-share-one-height-no-per-component-touch-floor.md`.
31
39
  */
32
40
  export declare const MIN_TOUCH_TARGET_CLASS = "min-h-(--control-min-touch-target) min-w-(--control-min-touch-target)";
33
41
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"controlStyles.d.ts","sourceRoot":"","sources":["../src/controlStyles.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AAEtD,eAAO,MAAM,kBAAkB,EAAE,MAAM,CACrC,WAAW,EACX,MAAM,CAKP,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,EAAE,MAAM,CAC1C,WAAW,EACX,MAAM,CAKP,CAAA;AAED;;;;;GAKG;AACH,eAAO,MAAM,sBAAsB,0EACsC,CAAA;AAEzE;;;;;GAKG;AACH,eAAO,MAAM,gBAAgB;;;CAGnB,CAAA;AAEV,MAAM,MAAM,SAAS,GAAG,MAAM,OAAO,gBAAgB,CAAA;AAErD,eAAO,MAAM,cAAc;;;;CAIjB,CAAA;AAEV,eAAO,MAAM,kBAAkB,EAAE,MAAM,CACrC,WAAW,EACX,MAAM,CAKP,CAAA"}
1
+ {"version":3,"file":"controlStyles.d.ts","sourceRoot":"","sources":["../src/controlStyles.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AAEtD,eAAO,MAAM,kBAAkB,EAAE,MAAM,CACrC,WAAW,EACX,MAAM,CAKP,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,EAAE,MAAM,CAC1C,WAAW,EACX,MAAM,CAKP,CAAA;AAED;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,sBAAsB,0EACsC,CAAA;AAEzE;;;;;GAKG;AACH,eAAO,MAAM,gBAAgB;;;CAGnB,CAAA;AAEV,MAAM,MAAM,SAAS,GAAG,MAAM,OAAO,gBAAgB,CAAA;AAErD,eAAO,MAAM,cAAc;;;;CAIjB,CAAA;AAEV,eAAO,MAAM,kBAAkB,EAAE,MAAM,CACrC,WAAW,EACX,MAAM,CAKP,CAAA"}
@@ -35,6 +35,14 @@ export const ICON_CONTROL_SIZE_CLASS = {
35
35
  * density — a finger does not get smaller because the list is
36
36
  * dense — so this is opt-in per control rather than baked into the
37
37
  * size classes, which would make `compact` a lie.
38
+ *
39
+ * **Do NOT add this to a control that shares a form row** (`Button`,
40
+ * `Select`, a `Listbox`/`Combobox` trigger, `SegmentedControl`, …). It
41
+ * overrides the shared `CONTROL_SIZE_CLASS` height and makes that one
42
+ * control taller than its neighbours at desktop density — touch sizing is
43
+ * the density axis's job. Reserve this for a genuinely standalone tap
44
+ * target. See
45
+ * `docs/decisions/2026-08-05-controls-share-one-height-no-per-component-touch-floor.md`.
38
46
  */
39
47
  export const MIN_TOUCH_TARGET_CLASS = "min-h-(--control-min-touch-target) min-w-(--control-min-touch-target)";
40
48
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"controlStyles.js","sourceRoot":"","sources":["../src/controlStyles.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAIH,MAAM,CAAC,MAAM,kBAAkB,GAG3B;IACF,EAAE,EAAE,yFAAyF;IAC7F,EAAE,EAAE,yFAAyF;IAC7F,EAAE,EAAE,yFAAyF;CAC9F,CAAA;AAED;;;;GAIG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAGhC;IACF,EAAE,EAAE,yCAAyC;IAC7C,EAAE,EAAE,yCAAyC;IAC7C,EAAE,EAAE,yCAAyC;CAC9C,CAAA;AAED;;;;;GAKG;AACH,MAAM,CAAC,MAAM,sBAAsB,GACjC,uEAAuE,CAAA;AAEzE;;;;;GAKG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG;IAC9B,EAAE,EAAE,2BAA2B;IAC/B,EAAE,EAAE,6BAA6B;CACzB,CAAA;AAIV,MAAM,CAAC,MAAM,cAAc,GAAG;IAC5B,EAAE,EAAE,UAAU;IACd,EAAE,EAAE,QAAQ;IACZ,EAAE,EAAE,UAAU;CACN,CAAA;AAEV,MAAM,CAAC,MAAM,kBAAkB,GAG3B;IACF,EAAE,EAAE,mBAAmB;IACvB,EAAE,EAAE,iBAAiB;IACrB,EAAE,EAAE,iBAAiB;CACtB,CAAA"}
1
+ {"version":3,"file":"controlStyles.js","sourceRoot":"","sources":["../src/controlStyles.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAIH,MAAM,CAAC,MAAM,kBAAkB,GAG3B;IACF,EAAE,EAAE,yFAAyF;IAC7F,EAAE,EAAE,yFAAyF;IAC7F,EAAE,EAAE,yFAAyF;CAC9F,CAAA;AAED;;;;GAIG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAGhC;IACF,EAAE,EAAE,yCAAyC;IAC7C,EAAE,EAAE,yCAAyC;IAC7C,EAAE,EAAE,yCAAyC;CAC9C,CAAA;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,CAAC,MAAM,sBAAsB,GACjC,uEAAuE,CAAA;AAEzE;;;;;GAKG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG;IAC9B,EAAE,EAAE,2BAA2B;IAC/B,EAAE,EAAE,6BAA6B;CACzB,CAAA;AAIV,MAAM,CAAC,MAAM,cAAc,GAAG;IAC5B,EAAE,EAAE,UAAU;IACd,EAAE,EAAE,QAAQ;IACZ,EAAE,EAAE,UAAU;CACN,CAAA;AAEV,MAAM,CAAC,MAAM,kBAAkB,GAG3B;IACF,EAAE,EAAE,mBAAmB;IACvB,EAAE,EAAE,iBAAiB;IACrB,EAAE,EAAE,iBAAiB;CACtB,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@charcuterie/ui",
3
- "version": "2.1.0",
3
+ "version": "2.3.0",
4
4
  "description": "The Charcuterie components. Tokens for look, @charcuterie/logic for state, ARIA that agents can actually drive.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -18,6 +18,12 @@ follows the OS, remembers the user's choice, and re-themes with zero React re-re
18
18
 
19
19
  <Controls />
20
20
 
21
+ ## Tone is neutral chrome
22
+
23
+ Like `ColorSchemeToggle`, this forwards `intent="neutral"` to the button by default — a
24
+ scheme switcher is chrome, not an accent action, so its ghost hover is the neutral surface
25
+ rather than accent-violet. Pass `intent` (any tone `Button` accepts) to change it.
26
+
21
27
  ## Every seam is overridable — that is the point
22
28
 
23
29
  `resolver`, `persistence`, and `apply` all default to the browser implementations but can be
@@ -51,3 +51,15 @@ test("cycling to a concrete mode re-applies the scheme", async () => {
51
51
 
52
52
  await expectNoAxeViolations(canvasElement)
53
53
  })
54
+
55
+ test("the connected switcher forwards the neutral intent by default", async () => {
56
+ const { canvasElement } = await mountStory(Default)
57
+
58
+ const button = canvasElement.querySelector("button")
59
+
60
+ // Chrome, not an accent action — the hover is the neutral surface.
61
+ expect(button?.className).toContain(
62
+ "hover:bg-intent-neutral-surface",
63
+ )
64
+ expect(button?.className).not.toContain("intent-accent")
65
+ })
@@ -22,6 +22,7 @@ export type ColorSchemeSwitcherProps = Pick<
22
22
  ColorSchemeToggleProps,
23
23
  | "appearance"
24
24
  | "className"
25
+ | "intent"
25
26
  | "isDisabled"
26
27
  | "label"
27
28
  | "order"
@@ -39,6 +39,18 @@ rather than a nameless button.
39
39
 
40
40
  <Canvas of={ColorSchemeToggleStories.AllModes} />
41
41
 
42
+ ## Intent is `neutral`, not accent
43
+
44
+ `intent` is the same tone union `Button`/`IconButton` accept, and it **defaults to
45
+ `neutral`** here on purpose. A scheme switcher is toolbar chrome, so its ghost hover reads
46
+ as `hover:bg-intent-neutral-surface` and its icon as `text-intent-neutral-content` —
47
+ chrome, not a violet accent action. `IconButton`'s own default is `accent`, which on real
48
+ app chrome made this control look like a call-to-action; forwarding `neutral` is the fix,
49
+ and it means a consumer no longer needs an `!important` className to tone it down. Pass
50
+ `intent="accent"` (or any tone) to opt back in.
51
+
52
+ <Canvas of={ColorSchemeToggleStories.Intents} />
53
+
42
54
  ## Cycling is the only form, for now
43
55
 
44
56
  A segmented, all-three-visible `ColorSchemePicker` was considered and deferred (owner chose
@@ -2,7 +2,10 @@ import type { ColorSchemeMode } from "@charcuterie/logic"
2
2
  import type { Meta, StoryObj } from "@storybook/react"
3
3
  import { useState } from "react"
4
4
 
5
- import { controlSizeArgType } from "../argTypes.storyHelpers.ts"
5
+ import {
6
+ controlSizeArgType,
7
+ intentArgType,
8
+ } from "../argTypes.storyHelpers.ts"
6
9
  import {
7
10
  ContainerBoard,
8
11
  StoryCell,
@@ -34,6 +37,7 @@ const meta = {
34
37
  component: ColorSchemeToggle,
35
38
  parameters: { layout: "padded" },
36
39
  argTypes: {
40
+ intent: intentArgType,
37
41
  // `mode` is `ColorSchemeMode` from @charcuterie/logic — a bare
38
42
  // specifier docgen cannot enumerate, so it gets an explicit
39
43
  // radio rather than falling through to the object control.
@@ -46,6 +50,7 @@ const meta = {
46
50
  args: {
47
51
  appearance: "ghost",
48
52
  icons: ICONS,
53
+ intent: "neutral",
49
54
  isDisabled: false,
50
55
  mode: "system",
51
56
  onCycle: () => {},
@@ -117,6 +122,39 @@ export const AllModes: Story = {
117
122
  ),
118
123
  }
119
124
 
125
+ /**
126
+ * The tone is **neutral by default** — a scheme switcher is toolbar
127
+ * chrome, so its ghost hover is `hover:bg-intent-neutral-surface` and
128
+ * its icon `text-intent-neutral-content`, which read as chrome on a
129
+ * normal surface rather than as an accent action. A consumer that
130
+ * wants it to draw attention passes `intent="accent"` (or any tone).
131
+ */
132
+ export const Intents: Story = {
133
+ render: (toggleProps) => (
134
+ <StorySection title="neutral (default) vs an explicit accent override.">
135
+ <StoryGrid columns={2}>
136
+ <StoryCell label="intent=neutral (default)">
137
+ <ColorSchemeToggle
138
+ {...toggleProps}
139
+ intent="neutral"
140
+ mode="system"
141
+ onCycle={() => {}}
142
+ />
143
+ </StoryCell>
144
+
145
+ <StoryCell label="intent=accent">
146
+ <ColorSchemeToggle
147
+ {...toggleProps}
148
+ intent="accent"
149
+ mode="system"
150
+ onCycle={() => {}}
151
+ />
152
+ </StoryCell>
153
+ </StoryGrid>
154
+ </StorySection>
155
+ ),
156
+ }
157
+
120
158
  export const Sizes: Story = {
121
159
  render: (toggleProps) => (
122
160
  <StorySection title="Square on the control height, so it lines up with the buttons beside it in a toolbar.">
@@ -1,11 +1,14 @@
1
- import { composeStories } from "@storybook/react"
2
- import { userEvent } from "storybook/test"
1
+ import {
2
+ composeStories,
3
+ composeStory,
4
+ } from "@storybook/react"
5
+ import { expect, userEvent } from "storybook/test"
3
6
  import { test } from "vitest"
4
7
 
5
8
  import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
6
9
  import { mountStory } from "../mountStory.testHelpers.ts"
7
10
  import { expectAgentDrivable } from "../testing/index.ts"
8
- import * as stories from "./ColorSchemeToggle.stories.tsx"
11
+ import meta, * as stories from "./ColorSchemeToggle.stories.tsx"
9
12
 
10
13
  const { AllModes, Default } = composeStories(stories)
11
14
 
@@ -78,3 +81,36 @@ test("each mode renders its own control", async () => {
78
81
  role: "button",
79
82
  })
80
83
  })
84
+
85
+ /**
86
+ * The fix: the switcher is chrome, so it forwards `neutral` to the
87
+ * button by default — a neutral ghost hover
88
+ * (`hover:bg-intent-neutral-surface`) and a neutral icon colour,
89
+ * rather than `IconButton`'s accent default that read as a violet
90
+ * action on real app chrome.
91
+ */
92
+ test("defaults to the neutral intent, not accent", async () => {
93
+ const { canvasElement } = await mountStory(Default)
94
+
95
+ const button = canvasElement.querySelector("button")
96
+
97
+ expect(button?.className).toContain(
98
+ "hover:bg-intent-neutral-surface",
99
+ )
100
+ expect(button?.className).not.toContain("intent-accent")
101
+ })
102
+
103
+ test("an explicit intent overrides the default", async () => {
104
+ const AccentToggle = composeStory(
105
+ { args: { intent: "accent" } },
106
+ meta,
107
+ )
108
+
109
+ const { canvasElement } = await mountStory(AccentToggle)
110
+
111
+ const button = canvasElement.querySelector("button")
112
+
113
+ expect(button?.className).toContain(
114
+ "hover:bg-intent-accent-surface",
115
+ )
116
+ })
@@ -3,7 +3,10 @@ import {
3
3
  DEFAULT_COLOR_SCHEME_ORDER,
4
4
  nextColorSchemeMode,
5
5
  } from "@charcuterie/logic"
6
- import type { ControlSize } from "@charcuterie/tokens"
6
+ import type {
7
+ ControlSize,
8
+ IntentName,
9
+ } from "@charcuterie/tokens"
7
10
  import type { ReactNode } from "react"
8
11
 
9
12
  import { IconButton } from "../IconButton/IconButton.tsx"
@@ -25,6 +28,15 @@ export type ColorSchemeToggleProps = {
25
28
  appearance?: IntentAppearance
26
29
  className?: string
27
30
  icons: ColorSchemeIcons
31
+ /**
32
+ * The intent (tone) forwarded to the underlying `IconButton` — the
33
+ * same union `Button`/`IconButton` accept. **Defaults to `neutral`**,
34
+ * not `accent`: a scheme switcher is toolbar chrome, so its ghost
35
+ * hover reads as `hover:bg-intent-neutral-surface` and its icon as
36
+ * `text-intent-neutral-content` rather than accent-violet. Pass
37
+ * `intent="accent"` (or any tone) to make it an accent action.
38
+ */
39
+ intent?: IntentName
28
40
  isDisabled?: boolean
29
41
  /**
30
42
  * Builds the accessible name from the current and next mode.
@@ -81,6 +93,7 @@ export const ColorSchemeToggle = ({
81
93
  appearance = "ghost",
82
94
  className,
83
95
  icons,
96
+ intent = "neutral",
84
97
  isDisabled = false,
85
98
  label = defaultLabel,
86
99
  mode,
@@ -94,6 +107,7 @@ export const ColorSchemeToggle = ({
94
107
  <IconButton
95
108
  appearance={appearance}
96
109
  className={className}
110
+ intent={intent}
97
111
  isDisabled={isDisabled}
98
112
  label={label(mode, nextMode)}
99
113
  onClick={() => {
@@ -58,6 +58,36 @@ removable chips rather than filling the input, and keeps the popup open.
58
58
 
59
59
  <Canvas of={ComboboxStories.AllStates} />
60
60
 
61
+ ## Attached input & drill-down
62
+
63
+ Supply `inputRef` and Combobox stops rendering its own input: it anchors a **list-only**
64
+ popup to the consumer's `<input>`, mirrors the combobox ARIA (`role`, `aria-controls`,
65
+ `aria-activedescendant`) onto it, and drives arrows/Enter/Tab/Escape off it while focus
66
+ stays put. Use it when the field is **both the value and the query** — a path field whose
67
+ text you type *and* filter by, the shape `PathPicker` needs.
68
+
69
+ The consumer owns the input's `value`/`onChange` (wire them to the controlled `query`,
70
+ which is then required) **and** `isVisible`. Because of that, **a select does not
71
+ auto-dismiss** in this mode: `onSelect` fires and the consumer decides what happens next —
72
+ append the folder and leave `isVisible` on to drill deeper (the popup re-queries from the
73
+ new path and stays open), or call its own `close()` to finish. Enter and Tab both accept
74
+ the active option; Escape closes outright. Single-select only (`isMultiple` is classic-mode
75
+ only, and it stays the only mode that keeps the popup open on a normal select).
76
+
77
+ ```tsx
78
+ <input ref={inputRef} value={path} onChange={(e) => setPath(e.target.value)} />
79
+ <Combobox
80
+ inputRef={inputRef}
81
+ isVisible={isVisible}
82
+ query={path}
83
+ options={folders} // pre-filtered by the trailing segment
84
+ onSelect={(name) => setPath(`${parentPath}${name}/`)} // drills; stays open
85
+ onDismiss={() => setIsVisible(false)}
86
+ />
87
+ ```
88
+
89
+ <Canvas of={ComboboxStories.AttachedInputDrillDown} />
90
+
61
91
  ## Virtualization, and why `aria-setsize` is not optional
62
92
 
63
93
  Above ~100 options (or with `isVirtualized`), the list is windowed with
@@ -1,7 +1,7 @@
1
1
  import { useVisibility } from "@charcuterie/logic"
2
2
  import type { Meta, StoryObj } from "@storybook/react"
3
3
  import type { ReactNode } from "react"
4
- import { useState } from "react"
4
+ import { useRef, useState } from "react"
5
5
 
6
6
  import { Button } from "../Button/Button.tsx"
7
7
  import type { ListboxItem } from "../Listbox/Listbox.tsx"
@@ -153,10 +153,11 @@ export const AllStates: Story = {
153
153
  }
154
154
 
155
155
  /**
156
- * Multi-select renders the chosen values as removable chips rather
157
- * than filling the input, with a sticky footer hint — the
158
- * `LanguageCodeField` and `LinkPicker` shapes. `isCreatable` lets Enter
159
- * commit the raw query.
156
+ * Multi-select renders the chosen values as an always-visible row of
157
+ * removable chips **above the trigger** — they stay on screen after the
158
+ * popup closes, and each carries its option's human label (not its raw
159
+ * value) and an ✕ to remove it. The `LanguageCodeField` and `LinkPicker`
160
+ * shapes. `isCreatable` lets Enter commit the raw query.
160
161
  */
161
162
  export const AllVariants: Story = {
162
163
  args: {
@@ -227,6 +228,150 @@ export const Interactive: Story = {
227
228
  * first option to the first enabled one — and Enter must never commit the
228
229
  * disabled value.
229
230
  */
231
+ // A tiny in-memory directory tree for the attached-input demo: keys are
232
+ // parent paths (with trailing slash), values their child folders.
233
+ const TREE: Record<string, string[]> = {
234
+ "/": ["apps", "config", "media"],
235
+ "/apps/": ["mux-magic", "charcuterie"],
236
+ "/config/": ["hosts", "secrets"],
237
+ "/media/": ["movies", "music"],
238
+ }
239
+
240
+ const AttachedInputHarness = (): ReactNode => {
241
+ const inputRef = useRef<HTMLInputElement>(null)
242
+
243
+ const [value, setValue] = useState("/")
244
+
245
+ const [isVisible, setIsVisible] = useState(true)
246
+
247
+ const lastSlash = value.lastIndexOf("/")
248
+
249
+ const parentPath = value.slice(0, lastSlash + 1) || "/"
250
+
251
+ const tail = value.slice(lastSlash + 1)
252
+
253
+ const options: ListboxItem[] = (TREE[parentPath] ?? [])
254
+ .filter((name) => name.startsWith(tail))
255
+ .map((name) => ({
256
+ label: `📁 ${name}`,
257
+ textValue: name,
258
+ value: name,
259
+ }))
260
+
261
+ return (
262
+ <>
263
+ <input
264
+ aria-label="Path"
265
+ className="w-80 rounded-md border border-border-default bg-surface-raised px-2 py-1.5 font-mono text-content-primary text-sm outline-none"
266
+ onChange={(changeEvent) => {
267
+ setValue(changeEvent.target.value)
268
+
269
+ setIsVisible(true)
270
+ }}
271
+ ref={inputRef}
272
+ value={value}
273
+ />
274
+
275
+ <Combobox
276
+ emptyLabel="No matching entries."
277
+ inputRef={inputRef}
278
+ isVisible={isVisible}
279
+ onDismiss={() => {
280
+ setIsVisible(false)
281
+ }}
282
+ onSelect={(name) => {
283
+ // Drill in: append the picked folder + separator. The changed
284
+ // query refetches the new directory; the popup stays open
285
+ // because we never flip `isVisible`.
286
+ setValue(`${parentPath}${name}/`)
287
+ }}
288
+ options={options}
289
+ query={value}
290
+ />
291
+ </>
292
+ )
293
+ }
294
+
295
+ /**
296
+ * Attached-input mode: the consumer renders its own `<input>` (both the
297
+ * committed value and the query) and Combobox anchors a list-only popup
298
+ * to it. Type a path; picking a folder appends it and re-queries that
299
+ * folder's children **without closing** — a shell-style drill-down. This
300
+ * is `PathPicker`'s shape.
301
+ */
302
+ export const AttachedInputDrillDown: Story = {
303
+ args: {
304
+ inputRef: { current: null },
305
+ isVisible: true,
306
+ onDismiss: () => {},
307
+ onSelect: () => {},
308
+ options: [],
309
+ query: "/",
310
+ },
311
+ render: () => <AttachedInputHarness />,
312
+ }
313
+
314
+ // Long, space-separated names — the real-world case (a media library of
315
+ // long folder names) the short demo tree does not show.
316
+ const LONG_OPTIONS: ListboxItem[] = [
317
+ "Armored Trooper Votoms - Brilliantly Shining Heresy [anidb-7910]",
318
+ "Armored Trooper Votoms - Case;Irvine [anidb-7911]",
319
+ "Armored Trooper Votoms - Chirico's Return [anidb-1234]",
320
+ "Armored Trooper Votoms - Deadworld Sunsa [anidb-5678]",
321
+ "Armored Trooper Votoms - Pailsen Files - The Movie [anidb-9012]",
322
+ ].map((name) => ({
323
+ label: `📁 ${name}`,
324
+ textValue: name,
325
+ value: name,
326
+ }))
327
+
328
+ const LongOptionsHarness = (): ReactNode => {
329
+ const inputRef = useRef<HTMLInputElement>(null)
330
+
331
+ const [isVisible, setIsVisible] = useState(true)
332
+
333
+ return (
334
+ <>
335
+ <input
336
+ aria-label="Path"
337
+ className="w-80 rounded-md border border-border-default bg-surface-raised px-2 py-1.5 font-mono text-content-primary text-sm outline-none"
338
+ readOnly
339
+ ref={inputRef}
340
+ value="/media/Media-Storage/Anime/"
341
+ />
342
+
343
+ <Combobox
344
+ emptyLabel="No matching entries."
345
+ inputRef={inputRef}
346
+ isVisible={isVisible}
347
+ onDismiss={() => {
348
+ setIsVisible(false)
349
+ }}
350
+ onSelect={() => {}}
351
+ options={LONG_OPTIONS}
352
+ query="/media/Media-Storage/Anime/"
353
+ />
354
+ </>
355
+ )
356
+ }
357
+
358
+ /**
359
+ * How the attached-input popup handles **long option labels** — a media
360
+ * library of long folder names, the case the short drill-down demo hides.
361
+ * Shows how the panel sizes and how each option row treats overflow.
362
+ */
363
+ export const AttachedInputLongOptions: Story = {
364
+ args: {
365
+ inputRef: { current: null },
366
+ isVisible: true,
367
+ onDismiss: () => {},
368
+ onSelect: () => {},
369
+ options: LONG_OPTIONS,
370
+ query: "/media/Media-Storage/Anime/",
371
+ },
372
+ render: () => <LongOptionsHarness />,
373
+ }
374
+
230
375
  export const DisabledFirstOption: Story = {
231
376
  args: {
232
377
  isVisible: false,