@charcuterie/ui 2.15.0 → 2.16.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.
@@ -1 +1 @@
1
- {"version":3,"file":"useAnchoredOverlay.d.ts","sourceRoot":"","sources":["../../src/Overlay/useAnchoredOverlay.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAA;AAYnD,OAAO,KAAK,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAGpD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AACH,MAAM,MAAM,mBAAmB,GAC3B,QAAQ,GACR,SAAS,GACT,MAAM,CAAA;AAEV,MAAM,MAAM,yBAAyB,GAAG;IACtC;;;;;;;OAOG;IACH,SAAS,CAAC,EAAE,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,CAAA;IACzC;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,OAAO,CAAA;IAC7B,yBAAyB,CAAC,EAAE,OAAO,CAAA;IACnC,+DAA+D;IAC/D,qBAAqB,CAAC,EAAE,OAAO,CAAA;IAC/B,SAAS,EAAE,OAAO,CAAA;IAClB,iEAAiE;IACjE,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,0CAA0C;IAC1C,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,SAAS,EAAE,MAAM,IAAI,CAAA;IACrB,SAAS,CAAC,EAAE,SAAS,CAAA;IACrB,IAAI,EAAE,mBAAmB,CAAA;IACzB;;;OAGG;IACH,OAAO,CAAC,EAAE,YAAY,CAAA;CACvB,CAAA;AAQD,eAAO,MAAM,kBAAkB,GAAI,6KAahC,yBAAyB;;;;;;;;;;;;;;;;;;;;;;;;CAoI3B,CAAA"}
1
+ {"version":3,"file":"useAnchoredOverlay.d.ts","sourceRoot":"","sources":["../../src/Overlay/useAnchoredOverlay.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAA;AAYnD,OAAO,KAAK,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAGpD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AACH,MAAM,MAAM,mBAAmB,GAC3B,QAAQ,GACR,SAAS,GACT,MAAM,CAAA;AAEV,MAAM,MAAM,yBAAyB,GAAG;IACtC;;;;;;;OAOG;IACH,SAAS,CAAC,EAAE,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,CAAA;IACzC;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,OAAO,CAAA;IAC7B,yBAAyB,CAAC,EAAE,OAAO,CAAA;IACnC,+DAA+D;IAC/D,qBAAqB,CAAC,EAAE,OAAO,CAAA;IAC/B,SAAS,EAAE,OAAO,CAAA;IAClB,iEAAiE;IACjE,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,0CAA0C;IAC1C,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,SAAS,EAAE,MAAM,IAAI,CAAA;IACrB,SAAS,CAAC,EAAE,SAAS,CAAA;IACrB,IAAI,EAAE,mBAAmB,CAAA;IACzB;;;OAGG;IACH,OAAO,CAAC,EAAE,YAAY,CAAA;CACvB,CAAA;AAQD,eAAO,MAAM,kBAAkB,GAAI,6KAahC,yBAAyB;;;;;;;;;;;;;;;;;;;;;;;;CAoK3B,CAAA"}
@@ -75,7 +75,36 @@ export const useAnchoredOverlay = ({ anchorRef, isEscapeDismissable = true, isOu
75
75
  // a `listbox` panel needs, since a bare `role="listbox"` is an ARIA
76
76
  // input field and must be named.
77
77
  const generatedTriggerId = useUniqueId();
78
- const triggerId = referenceProps.id ??
78
+ /**
79
+ * The trigger's OWN id wins over both.
80
+ *
81
+ * This hook used to mint an id and clone it over whatever the
82
+ * trigger already had, on the reasoning that the panel needs a
83
+ * stable target for `aria-labelledby`. It does — but any id serves
84
+ * that purpose, including the caller's, and overwriting had a
85
+ * consequence nobody traced: `Field` clones a `controlId` onto its
86
+ * child and renders `<label htmlFor={controlId}>`, so for every
87
+ * `Picker`/`Listbox`/`Combobox`/`Menu` inside a `Field` the label
88
+ * pointed at an element that did not exist. Measured in a real
89
+ * browser: zero nodes carried the id the `<label>` named.
90
+ *
91
+ * That is the exact defect `Field`'s docstring calls "precisely the
92
+ * defect this component was built to make impossible" — arriving by
93
+ * a different route, and silent, because a dangling `htmlFor`
94
+ * throws nothing and shows nothing.
95
+ *
96
+ * Preferring the child's id fixes the label without weakening the
97
+ * panel: `aria-labelledby` now points at the caller's id instead of
98
+ * a generated one, which is the same element either way.
99
+ *
100
+ * Two rediscoveries of the overwrite are already in the fleet —
101
+ * queuepilot and mux-magic both moved their e2e handles to
102
+ * `data-testid` because `id` "did not survive". `data-testid` is
103
+ * still the more robust handle, but `id` is no longer a trap.
104
+ */
105
+ const triggerOwnId = trigger?.props?.id;
106
+ const triggerId = triggerOwnId ??
107
+ referenceProps.id ??
79
108
  generatedTriggerId;
80
109
  // In anchor mode floating-ui's reference is the consumer's element, set
81
110
  // imperatively — there is no cloned trigger to carry `refs.setReference`.
@@ -1 +1 @@
1
- {"version":3,"file":"useAnchoredOverlay.js","sourceRoot":"","sources":["../../src/Overlay/useAnchoredOverlay.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,cAAc,EACd,WAAW,GACZ,MAAM,oBAAoB,CAAA;AAE3B,OAAO,EACL,UAAU,EACV,IAAI,EACJ,MAAM,EACN,KAAK,EACL,IAAI,EACJ,UAAU,EACV,WAAW,EACX,eAAe,EACf,OAAO,GACR,MAAM,oBAAoB,CAAA;AAE3B,OAAO,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAgGhD,uEAAuE;AACvE,0EAA0E;AAC1E,oEAAoE;AACpE,8DAA8D;AAC9D,MAAM,mBAAmB,GAAG,aAAa,CAAC,MAAM,CAAC,CAAA;AAEjD,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAAC,EACjC,SAAS,EACT,mBAAmB,GAAG,IAAI,EAC1B,yBAAyB,GAAG,IAAI,EAChC,qBAAqB,GAAG,KAAK,EAC7B,SAAS,EACT,WAAW,EACX,UAAU,EACV,WAAW,GAAG,CAAC,EACf,SAAS,EACT,SAAS,GAAG,cAAc,EAC1B,IAAI,EACJ,OAAO,GACmB,EAAE,EAAE;IAC9B,MAAM,UAAU,GAAG;QACjB,MAAM,CAAC,WAAW,CAAC;QACnB,IAAI,EAAE;QACN,KAAK,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,CAAC;KACtB,CAAA;IAED,IACE,qBAAqB;QACrB,WAAW,KAAK,SAAS;QACzB,UAAU,KAAK,SAAS,EACxB,CAAC;QACD,UAAU,CAAC,IAAI,CACb,IAAI,CAAC;YACH,OAAO,EAAE,CAAC;YACV,KAAK,CAAC,EACJ,eAAe,EACf,cAAc,EACd,QAAQ,EACR,KAAK,GACN;gBACC,MAAM,SAAS,GACb,WAAW,KAAK,SAAS;oBACvB,CAAC,CAAC,eAAe;oBACjB,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,WAAW,EAAE,eAAe,CAAC,CAAA;gBAE5C,QAAQ,CAAC,QAAQ,CAAC,KAAK,CAAC,SAAS,GAAG,GAAG,SAAS,IAAI,CAAA;gBAEpD,IAAI,UAAU,KAAK,SAAS,EAAE,CAAC;oBAC7B,MAAM,QAAQ,GAAG,IAAI,CAAC,GAAG,CACvB,UAAU,EACV,cAAc,CACf,CAAA;oBAED,QAAQ,CAAC,QAAQ,CAAC,KAAK,CAAC,QAAQ,GAAG,GAAG,QAAQ,IAAI,CAAA;gBACpD,CAAC;gBAED,IAAI,qBAAqB,EAAE,CAAC;oBAC1B,QAAQ,CAAC,QAAQ,CAAC,KAAK,CAAC,KAAK,GAAG,GAAG,KAAK,CAAC,SAAS,CAAC,KAAK,IAAI,CAAA;gBAC9D,CAAC;YACH,CAAC;SACF,CAAC,CACH,CAAA;IACH,CAAC;IAED,MAAM,UAAU,GAAG,SAAS,KAAK,SAAS,CAAA;IAE1C,MAAM,EAAE,OAAO,EAAE,cAAc,EAAE,IAAI,EAAE,GAAG,WAAW,CAAC;QACpD,gEAAgE;QAChE,0CAA0C;QAC1C,IAAI,EAAE,SAAS;QACf,YAAY,EAAE,CAAC,aAAa,EAAE,EAAE;YAC9B,IAAI,CAAC,aAAa,EAAE,CAAC;gBACnB,SAAS,EAAE,CAAA;YACb,CAAC;QACH,CAAC;QACD,SAAS;QACT,UAAU;QACV,QAAQ,EAAE,OAAO;QACjB,oBAAoB,EAAE,UAAU;KACjC,CAAC,CAAA;IAEF,mEAAmE;IACnE,kEAAkE;IAClE,mEAAmE;IACnE,6DAA6D;IAC7D,oCAAoC;IACpC,MAAM,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,GAC3C,eAAe,CAAC;QACd,UAAU,CAAC,OAAO,EAAE;YAClB,SAAS,EAAE,mBAAmB;YAC9B,YAAY,EAAE,yBAAyB;SACxC,CAAC;QACF,OAAO,CAAC,OAAO,EAAE,EAAE,IAAI,EAAE,CAAC;KAC3B,CAAC,CAAA;IAEJ,mEAAmE;IACnE,oEAAoE;IACpE,mEAAmE;IACnE,+DAA+D;IAC/D,yDAAyD;IACzD,uEAAuE;IACvE,qEAAqE;IACrE,kDAAkD;IAClD,MAAM,EAAE,IAAI,EAAE,qBAAqB,EAAE,GAAG,cAAc,EAAE,GACtD,iBAAiB,EAAE,CAAA;IAErB,qEAAqE;IACrE,qEAAqE;IACrE,kEAAkE;IAClE,sEAAsE;IACtE,oEAAoE;IACpE,iCAAiC;IACjC,MAAM,kBAAkB,GAAG,WAAW,EAAE,CAAA;IAExC,MAAM,SAAS,GACZ,cAAc,CAAC,EAAyB;QACzC,kBAAkB,CAAA;IAEpB,wEAAwE;IACxE,0EAA0E;IAC1E,yEAAyE;IACzE,yDAAyD;IACzD,SAAS,CAAC,GAAG,EAAE;QACb,IAAI,CAAC,UAAU,EAAE,CAAC;YAChB,OAAM;QACR,CAAC;QAED,IAAI,CAAC,YAAY,CAAC,SAAS,CAAC,OAAO,CAAC,CAAA;IACtC,CAAC,EAAE,CAAC,UAAU,EAAE,SAAS,EAAE,IAAI,CAAC,CAAC,CAAA;IAEjC,MAAM,WAAW,GAAG,cAAc,CAChC,OAAO,IAAI,mBAAmB,EAC9B;QACE,GAAG,cAAc;QACjB,EAAE,EAAE,SAAS;QACb,4DAA4D;QAC5D,gEAAgE;QAChE,GAAG,CAAC,UAAU,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,IAAI,CAAC,YAAY,EAAE,CAAC;KAClD,CACF,CAAA;IAED,MAAM,aAAa,GAAG,UAAU,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,WAAW,CAAA;IAErD,OAAO;QACL,aAAa;QACb,OAAO;QACP,cAAc;QACd,gBAAgB;QAChB,WAAW,EAAE,IAAI,CAAC,WAAW;QAC7B,SAAS;KACV,CAAA;AACH,CAAC,CAAA"}
1
+ {"version":3,"file":"useAnchoredOverlay.js","sourceRoot":"","sources":["../../src/Overlay/useAnchoredOverlay.ts"],"names":[],"mappings":"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"}
@@ -70,14 +70,20 @@ export type PickerProps = Omit<ComponentPropsWithRef<"button">, "disabled" | "on
70
70
  *
71
71
  * Query it as `getByRole("button", { name: /^Language: / })`.
72
72
  *
73
- * ### `id` does not survive; `data-testid` does
73
+ * ### `id` survives — it did not always
74
74
  *
75
- * `useAnchoredOverlay` overwrites the trigger's `id` with a generated
76
- * one, so the portalled listbox can point `aria-labelledby` across at
77
- * it. An `id` passed here is therefore silently replaced — queuepilot
78
- * found this the hard way and switched its e2e handles to
79
- * `data-testid`, which nothing injects. Everything else on the button
80
- * (`data-*`, `onClick`, `form`, …) passes straight through.
75
+ * `useAnchoredOverlay` used to overwrite the trigger's `id` with a
76
+ * generated one so the portalled listbox could point `aria-labelledby`
77
+ * across at it. That silently broke `Field`, which renders
78
+ * `<label htmlFor>` against an id it clones onto its child: the label
79
+ * pointed at nothing for every overlay control in a `Field`. The hook
80
+ * now prefers the trigger's own id and mints one only when absent, so
81
+ * both the label and the panel name the same element.
82
+ *
83
+ * `data-testid` remains the sturdier e2e handle — queuepilot and
84
+ * mux-magic both moved to it while `id` was still being replaced — but
85
+ * `id` is no longer a trap. Everything else on the button (`data-*`,
86
+ * `onClick`, `form`, …) passes straight through.
81
87
  */
82
88
  export declare const Picker: ({ appearance, className, iconEnd, intent, isDisabled, label, onChange, options, placement, placeholder, size, value, ...buttonProps }: PickerProps) => ReactNode;
83
89
  //# sourceMappingURL=Picker.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"Picker.d.ts","sourceRoot":"","sources":["../../src/Picker/Picker.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,WAAW,EACX,UAAU,EACX,MAAM,qBAAqB,CAAA;AAC5B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAA;AACnD,OAAO,KAAK,EACV,qBAAqB,EACrB,SAAS,EACV,MAAM,OAAO,CAAA;AAGd,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AAC1D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAA;AAGzD,MAAM,MAAM,YAAY,GAAG,WAAW,CAAA;AAwBtC,MAAM,MAAM,WAAW,GAAG,IAAI,CAC5B,qBAAqB,CAAC,QAAQ,CAAC,EAC/B,UAAU,GAAG,UAAU,GAAG,OAAO,CAClC,GAAG;IACF,UAAU,CAAC,EAAE,gBAAgB,CAAA;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,MAAM,CAAC,EAAE,UAAU,CAAA;IACnB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB;;;OAGG;IACH,KAAK,EAAE,MAAM,CAAA;IACb;;;;;;OAMG;IACH,OAAO,CAAC,EAAE,SAAS,CAAA;IACnB,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;IACjC,OAAO,EAAE,SAAS,YAAY,EAAE,CAAA;IAChC,SAAS,CAAC,EAAE,SAAS,CAAA;IACrB,0DAA0D;IAC1D,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,IAAI,CAAC,EAAE,WAAW,CAAA;IAClB,KAAK,CAAC,EAAE,MAAM,CAAA;CACf,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AACH,eAAO,MAAM,MAAM,GAAI,uIAcpB,WAAW,KAAG,SAoDhB,CAAA"}
1
+ {"version":3,"file":"Picker.d.ts","sourceRoot":"","sources":["../../src/Picker/Picker.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,WAAW,EACX,UAAU,EACX,MAAM,qBAAqB,CAAA;AAC5B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAA;AACnD,OAAO,KAAK,EACV,qBAAqB,EACrB,SAAS,EACV,MAAM,OAAO,CAAA;AAGd,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AAC1D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAA;AAGzD,MAAM,MAAM,YAAY,GAAG,WAAW,CAAA;AAwBtC,MAAM,MAAM,WAAW,GAAG,IAAI,CAC5B,qBAAqB,CAAC,QAAQ,CAAC,EAC/B,UAAU,GAAG,UAAU,GAAG,OAAO,CAClC,GAAG;IACF,UAAU,CAAC,EAAE,gBAAgB,CAAA;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,MAAM,CAAC,EAAE,UAAU,CAAA;IACnB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB;;;OAGG;IACH,KAAK,EAAE,MAAM,CAAA;IACb;;;;;;OAMG;IACH,OAAO,CAAC,EAAE,SAAS,CAAA;IACnB,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;IACjC,OAAO,EAAE,SAAS,YAAY,EAAE,CAAA;IAChC,SAAS,CAAC,EAAE,SAAS,CAAA;IACrB,0DAA0D;IAC1D,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,IAAI,CAAC,EAAE,WAAW,CAAA;IAClB,KAAK,CAAC,EAAE,MAAM,CAAA;CACf,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsDG;AACH,eAAO,MAAM,MAAM,GAAI,uIAcpB,WAAW,KAAG,SAoDhB,CAAA"}
@@ -48,14 +48,20 @@ const CHEVRON_DOWN = (_jsx("svg", { "aria-hidden": "true", className: "size-4",
48
48
  *
49
49
  * Query it as `getByRole("button", { name: /^Language: / })`.
50
50
  *
51
- * ### `id` does not survive; `data-testid` does
51
+ * ### `id` survives — it did not always
52
52
  *
53
- * `useAnchoredOverlay` overwrites the trigger's `id` with a generated
54
- * one, so the portalled listbox can point `aria-labelledby` across at
55
- * it. An `id` passed here is therefore silently replaced — queuepilot
56
- * found this the hard way and switched its e2e handles to
57
- * `data-testid`, which nothing injects. Everything else on the button
58
- * (`data-*`, `onClick`, `form`, …) passes straight through.
53
+ * `useAnchoredOverlay` used to overwrite the trigger's `id` with a
54
+ * generated one so the portalled listbox could point `aria-labelledby`
55
+ * across at it. That silently broke `Field`, which renders
56
+ * `<label htmlFor>` against an id it clones onto its child: the label
57
+ * pointed at nothing for every overlay control in a `Field`. The hook
58
+ * now prefers the trigger's own id and mints one only when absent, so
59
+ * both the label and the panel name the same element.
60
+ *
61
+ * `data-testid` remains the sturdier e2e handle — queuepilot and
62
+ * mux-magic both moved to it while `id` was still being replaced — but
63
+ * `id` is no longer a trap. Everything else on the button (`data-*`,
64
+ * `onClick`, `form`, …) passes straight through.
59
65
  */
60
66
  export const Picker = ({ appearance = "outline", className, iconEnd = CHEVRON_DOWN, intent = "neutral", isDisabled = false, label, onChange, options, placement, placeholder, size, value, ...buttonProps }) => {
61
67
  const { hide, isVisible, toggle } = useVisibility();
@@ -1 +1 @@
1
- {"version":3,"file":"Picker.js","sourceRoot":"","sources":["../../src/Picker/Picker.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAWlD,OAAO,EAAE,MAAM,EAAE,MAAM,qBAAsB,CAAA;AAG7C,OAAO,EAAE,OAAO,EAAE,MAAM,uBAAwB,CAAA;AAIhD;;;;GAIG;AACH,MAAM,YAAY,GAAG,CACnB,6BACc,MAAM,EAClB,SAAS,EAAC,QAAQ,EAClB,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,CACP,CAAA;AAgCD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AACH,MAAM,CAAC,MAAM,MAAM,GAAG,CAAC,EACrB,UAAU,GAAG,SAAS,EACtB,SAAS,EACT,OAAO,GAAG,YAAY,EACtB,MAAM,GAAG,SAAS,EAClB,UAAU,GAAG,KAAK,EAClB,KAAK,EACL,QAAQ,EACR,OAAO,EACP,SAAS,EACT,WAAW,EACX,IAAI,EACJ,KAAK,EACL,GAAG,WAAW,EACF,EAAa,EAAE;IAC3B,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,MAAM,EAAE,GAAG,aAAa,EAAE,CAAA;IAEnD,MAAM,aAAa,GAAG,OAAO,CAAC,IAAI,CAChC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,KAAK,KAAK,KAAK,CACnC,CAAA;IAED,MAAM,WAAW,GACf,aAAa,KAAK,SAAS;QACzB,CAAC,CAAC,WAAW;QACb,CAAC,CAAC,CAAC,aAAa,CAAC,SAAS;YACxB,CAAC,OAAO,aAAa,CAAC,KAAK,KAAK,QAAQ;gBACtC,CAAC,CAAC,aAAa,CAAC,KAAK;gBACrB,CAAC,CAAC,SAAS,CAAC,CAAC,CAAA;IAErB,MAAM,WAAW,GAAG,WAAW,IAAI,WAAW,IAAI,EAAE,CAAA;IAEpD,OAAO,CACL,KAAC,OAAO;IACN,8DAA8D;IAC9D,kEAAkE;IAClE,2DAA2D;;QAF3D,8DAA8D;QAC9D,kEAAkE;QAClE,2DAA2D;QAC3D,SAAS,EAAE,SAAS,IAAI,CAAC,UAAU,EACnC,SAAS,EAAE,IAAI,EACf,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE,OAAO,EAChB,SAAS,EAAE,SAAS,EACpB,aAAa,EAAE,KAAK,EACpB,OAAO,EACL,KAAC,MAAM,OACD,WAAW,EACf,UAAU,EAAE,UAAU,gBACV,GAAG,KAAK,KAAK,WAAW,EAAE,EACtC,SAAS,EAAE,SAAS,EACpB,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,UAAU,EACtB,OAAO,EAAE,CAAC,UAAU,EAAE,EAAE;gBACtB,WAAW,CAAC,OAAO,EAAE,CAAC,UAAU,CAAC,CAAA;gBAEjC,MAAM,EAAE,CAAA;YACV,CAAC,EACD,IAAI,EAAE,IAAI,EACV,IAAI,EAAC,QAAQ,YAEZ,aAAa,KAAK,SAAS;gBAC1B,CAAC,CAAC,WAAW;gBACb,CAAC,CAAC,aAAa,CAAC,KAAK,GAChB,GAEX,CACH,CAAA;AACH,CAAC,CAAA"}
1
+ {"version":3,"file":"Picker.js","sourceRoot":"","sources":["../../src/Picker/Picker.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAWlD,OAAO,EAAE,MAAM,EAAE,MAAM,qBAAsB,CAAA;AAG7C,OAAO,EAAE,OAAO,EAAE,MAAM,uBAAwB,CAAA;AAIhD;;;;GAIG;AACH,MAAM,YAAY,GAAG,CACnB,6BACc,MAAM,EAClB,SAAS,EAAC,QAAQ,EAClB,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,CACP,CAAA;AAgCD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsDG;AACH,MAAM,CAAC,MAAM,MAAM,GAAG,CAAC,EACrB,UAAU,GAAG,SAAS,EACtB,SAAS,EACT,OAAO,GAAG,YAAY,EACtB,MAAM,GAAG,SAAS,EAClB,UAAU,GAAG,KAAK,EAClB,KAAK,EACL,QAAQ,EACR,OAAO,EACP,SAAS,EACT,WAAW,EACX,IAAI,EACJ,KAAK,EACL,GAAG,WAAW,EACF,EAAa,EAAE;IAC3B,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,MAAM,EAAE,GAAG,aAAa,EAAE,CAAA;IAEnD,MAAM,aAAa,GAAG,OAAO,CAAC,IAAI,CAChC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,KAAK,KAAK,KAAK,CACnC,CAAA;IAED,MAAM,WAAW,GACf,aAAa,KAAK,SAAS;QACzB,CAAC,CAAC,WAAW;QACb,CAAC,CAAC,CAAC,aAAa,CAAC,SAAS;YACxB,CAAC,OAAO,aAAa,CAAC,KAAK,KAAK,QAAQ;gBACtC,CAAC,CAAC,aAAa,CAAC,KAAK;gBACrB,CAAC,CAAC,SAAS,CAAC,CAAC,CAAA;IAErB,MAAM,WAAW,GAAG,WAAW,IAAI,WAAW,IAAI,EAAE,CAAA;IAEpD,OAAO,CACL,KAAC,OAAO;IACN,8DAA8D;IAC9D,kEAAkE;IAClE,2DAA2D;;QAF3D,8DAA8D;QAC9D,kEAAkE;QAClE,2DAA2D;QAC3D,SAAS,EAAE,SAAS,IAAI,CAAC,UAAU,EACnC,SAAS,EAAE,IAAI,EACf,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE,OAAO,EAChB,SAAS,EAAE,SAAS,EACpB,aAAa,EAAE,KAAK,EACpB,OAAO,EACL,KAAC,MAAM,OACD,WAAW,EACf,UAAU,EAAE,UAAU,gBACV,GAAG,KAAK,KAAK,WAAW,EAAE,EACtC,SAAS,EAAE,SAAS,EACpB,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,UAAU,EACtB,OAAO,EAAE,CAAC,UAAU,EAAE,EAAE;gBACtB,WAAW,CAAC,OAAO,EAAE,CAAC,UAAU,CAAC,CAAA;gBAEjC,MAAM,EAAE,CAAA;YACV,CAAC,EACD,IAAI,EAAE,IAAI,EACV,IAAI,EAAC,QAAQ,YAEZ,aAAa,KAAK,SAAS;gBAC1B,CAAC,CAAC,WAAW;gBACb,CAAC,CAAC,aAAa,CAAC,KAAK,GAChB,GAEX,CACH,CAAA;AACH,CAAC,CAAA"}
@@ -1,12 +1,52 @@
1
1
  /**
2
- * Filter-and-join. Deliberately not `clsx`.
3
- *
4
- * Every call site in this package passes a flat list of strings and
5
- * conditionals, which is four lines of work — and a published
6
- * component library's dependency list is a thing consumers inherit,
7
- * so it earns its entries. `clsx` also accepts objects and nested
8
- * arrays, both of which make a className harder for the Tailwind
9
- * scanner and for `tailwindCandidates.test.ts` to read statically.
2
+ * Join a class list, and let a later class beat an earlier one that
3
+ * targets the same CSS property.
4
+ *
5
+ * ### Why this is not a plain join any more
6
+ *
7
+ * It was, and that was a bug. `getControlClassName` assembles
8
+ * `toClassName(CONTROL_BASE_CLASS, …, className)`, and
9
+ * `CONTROL_BASE_CLASS` opens with `inline-flex`. A caller writing
10
+ *
11
+ * ```tsx
12
+ * <Button className="hidden lg:inline-flex" />
13
+ * ```
14
+ *
15
+ * emitted `class="inline-flex … hidden lg:inline-flex"`. Both are
16
+ * display utilities at equal specificity, so the winner is decided by
17
+ * **source order in the generated stylesheet**, not by order in the
18
+ * class attribute — and the caller has no way to win. `className` is a
19
+ * documented escape hatch, and it did not work for the most common
20
+ * thing an escape hatch is used for.
21
+ *
22
+ * It failed *silently and invisibly*. mail-sifter's header used exactly
23
+ * that class to hide a duplicate button below `lg`; it never hid, and
24
+ * nobody noticed for weeks because at 390px the header happened to fit
25
+ * anyway. It only surfaced when an unrelated type-ramp change pushed the
26
+ * width to 427px and scrolled the page sideways. Two more repos hit the
27
+ * same shape independently.
28
+ *
29
+ * ### Why `tailwind-merge` and not `clsx`
30
+ *
31
+ * The old docblock argued against taking a dependency, on the grounds
32
+ * that a published component library's dependency list is inherited by
33
+ * consumers and that `clsx`'s object and nested-array forms make a
34
+ * className harder for the Tailwind scanner and for
35
+ * `tailwindCandidates.test.ts` to read statically.
36
+ *
37
+ * The first half still stands and this entry earns its place; the
38
+ * second half does not transfer. `tailwind-merge` does not change the
39
+ * *input* shape at all — this still takes a flat list of strings and
40
+ * conditionals, and every call site is still statically scannable. It
41
+ * changes only which of two conflicting classes survives.
42
+ *
43
+ * ### It is a no-op on everything this package emits
44
+ *
45
+ * Merging could in principle drop one of our own classes. It does not:
46
+ * the test file checks all 358 class-string literals in `src` and all
47
+ * 288 strings `getControlClassName` composes, and the merge changes
48
+ * none of them. So this only ever acts on a genuine caller conflict —
49
+ * which is the entire point.
10
50
  */
11
51
  export declare const toClassName: (...parts: (false | string | null | undefined)[]) => string;
12
52
  //# sourceMappingURL=toClassName.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"toClassName.d.ts","sourceRoot":"","sources":["../src/toClassName.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,eAAO,MAAM,WAAW,GACtB,GAAG,OAAO,CAAC,KAAK,GAAG,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC,EAAE,WACb,CAAA"}
1
+ {"version":3,"file":"toClassName.d.ts","sourceRoot":"","sources":["../src/toClassName.ts"],"names":[],"mappings":"AAqBA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AACH,eAAO,MAAM,WAAW,GACtB,GAAG,OAAO,CAAC,KAAK,GAAG,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC,EAAE,WACI,CAAA"}
@@ -1,12 +1,71 @@
1
+ import { extendTailwindMerge } from "tailwind-merge";
1
2
  /**
2
- * Filter-and-join. Deliberately not `clsx`.
3
- *
4
- * Every call site in this package passes a flat list of strings and
5
- * conditionals, which is four lines of work — and a published
6
- * component library's dependency list is a thing consumers inherit,
7
- * so it earns its entries. `clsx` also accepts objects and nested
8
- * arrays, both of which make a className harder for the Tailwind
9
- * scanner and for `tailwindCandidates.test.ts` to read statically.
3
+ * `ease-standard` is ours, from the motion tokens — Tailwind ships
4
+ * `ease-linear`/`ease-in`/`ease-out`/`ease-in-out` and knows nothing
5
+ * about it. Without this it lands in no class group, so a caller
6
+ * passing `ease-linear` would get BOTH and the winner would be decided
7
+ * by stylesheet source order — exactly the bug this file now exists to
8
+ * prevent, one utility further along.
9
+ *
10
+ * Nothing else needs registering. Verified empirically rather than
11
+ * assumed: `toClassName.test.ts` asserts the merge is a no-op across
12
+ * every class string in this package and every string
13
+ * `getControlClassName` composes.
14
+ */
15
+ const mergeClassNames = extendTailwindMerge({
16
+ extend: {
17
+ classGroups: { ease: [{ ease: ["standard"] }] },
18
+ },
19
+ });
20
+ /**
21
+ * Join a class list, and let a later class beat an earlier one that
22
+ * targets the same CSS property.
23
+ *
24
+ * ### Why this is not a plain join any more
25
+ *
26
+ * It was, and that was a bug. `getControlClassName` assembles
27
+ * `toClassName(CONTROL_BASE_CLASS, …, className)`, and
28
+ * `CONTROL_BASE_CLASS` opens with `inline-flex`. A caller writing
29
+ *
30
+ * ```tsx
31
+ * <Button className="hidden lg:inline-flex" />
32
+ * ```
33
+ *
34
+ * emitted `class="inline-flex … hidden lg:inline-flex"`. Both are
35
+ * display utilities at equal specificity, so the winner is decided by
36
+ * **source order in the generated stylesheet**, not by order in the
37
+ * class attribute — and the caller has no way to win. `className` is a
38
+ * documented escape hatch, and it did not work for the most common
39
+ * thing an escape hatch is used for.
40
+ *
41
+ * It failed *silently and invisibly*. mail-sifter's header used exactly
42
+ * that class to hide a duplicate button below `lg`; it never hid, and
43
+ * nobody noticed for weeks because at 390px the header happened to fit
44
+ * anyway. It only surfaced when an unrelated type-ramp change pushed the
45
+ * width to 427px and scrolled the page sideways. Two more repos hit the
46
+ * same shape independently.
47
+ *
48
+ * ### Why `tailwind-merge` and not `clsx`
49
+ *
50
+ * The old docblock argued against taking a dependency, on the grounds
51
+ * that a published component library's dependency list is inherited by
52
+ * consumers and that `clsx`'s object and nested-array forms make a
53
+ * className harder for the Tailwind scanner and for
54
+ * `tailwindCandidates.test.ts` to read statically.
55
+ *
56
+ * The first half still stands and this entry earns its place; the
57
+ * second half does not transfer. `tailwind-merge` does not change the
58
+ * *input* shape at all — this still takes a flat list of strings and
59
+ * conditionals, and every call site is still statically scannable. It
60
+ * changes only which of two conflicting classes survives.
61
+ *
62
+ * ### It is a no-op on everything this package emits
63
+ *
64
+ * Merging could in principle drop one of our own classes. It does not:
65
+ * the test file checks all 358 class-string literals in `src` and all
66
+ * 288 strings `getControlClassName` composes, and the merge changes
67
+ * none of them. So this only ever acts on a genuine caller conflict —
68
+ * which is the entire point.
10
69
  */
11
- export const toClassName = (...parts) => parts.filter(Boolean).join(" ");
70
+ export const toClassName = (...parts) => mergeClassNames(parts.filter(Boolean).join(" "));
12
71
  //# sourceMappingURL=toClassName.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"toClassName.js","sourceRoot":"","sources":["../src/toClassName.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,CACzB,GAAG,KAA4C,EAC/C,EAAE,CAAC,KAAK,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA"}
1
+ {"version":3,"file":"toClassName.js","sourceRoot":"","sources":["../src/toClassName.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAA;AAEpD;;;;;;;;;;;;GAYG;AACH,MAAM,eAAe,GAAG,mBAAmB,CAAC;IAC1C,MAAM,EAAE;QACN,WAAW,EAAE,EAAE,IAAI,EAAE,CAAC,EAAE,IAAI,EAAE,CAAC,UAAU,CAAC,EAAE,CAAC,EAAE;KAChD;CACF,CAAC,CAAA;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,CACzB,GAAG,KAA4C,EAC/C,EAAE,CAAC,eAAe,CAAC,KAAK,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@charcuterie/ui",
3
- "version": "2.15.0",
3
+ "version": "2.16.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",
@@ -48,10 +48,11 @@
48
48
  "typecheck": "tsc --noEmit"
49
49
  },
50
50
  "dependencies": {
51
- "@charcuterie/logic": "1.5.0",
51
+ "@charcuterie/logic": "2.0.0",
52
52
  "@charcuterie/tokens": "1.5.0",
53
53
  "@floating-ui/react": "^0.27.20",
54
- "@tanstack/react-virtual": "^3.13.12"
54
+ "@tanstack/react-virtual": "^3.13.12",
55
+ "tailwind-merge": "^3.6.0"
55
56
  },
56
57
  "peerDependencies": {
57
58
  "react": ">=19",
@@ -4,6 +4,7 @@ import {
4
4
  StoryCell,
5
5
  StoryGrid,
6
6
  } from "../board.storyHelpers.tsx"
7
+ import { Picker } from "../Picker/Picker.tsx"
7
8
  import { Select } from "../Select/Select.tsx"
8
9
  import { Tooltip } from "../Tooltip/Tooltip.tsx"
9
10
  import { Field } from "./Field.tsx"
@@ -55,6 +56,33 @@ export const AdoptsChildId: Story = {
55
56
  },
56
57
  }
57
58
 
59
+ /**
60
+ * An overlay-triggered control — `Picker`, and by the same route
61
+ * `Listbox`, `Combobox` and `Menu` — is the case that used to break.
62
+ *
63
+ * `useAnchoredOverlay` minted its own trigger id and cloned it over
64
+ * whatever was there, so the `<label htmlFor>` above pointed at an
65
+ * element that did not exist. It failed silently: a dangling `htmlFor`
66
+ * throws nothing, renders nothing, and only shows up if you go looking
67
+ * for the id in the DOM. The hook now prefers the trigger's own id.
68
+ */
69
+ export const AdoptsOverlayTriggerId: Story = {
70
+ args: {
71
+ children: (
72
+ <Picker
73
+ label="Language"
74
+ onChange={() => {}}
75
+ options={[
76
+ { label: "English", value: "en" },
77
+ { label: "Japanese", value: "ja" },
78
+ ]}
79
+ value="en"
80
+ />
81
+ ),
82
+ label: "Language",
83
+ },
84
+ }
85
+
58
86
  /**
59
87
  * The control is a slot, not a hardcoded `<input>` — `Select` goes
60
88
  * in the same hole, and so does an app's own control. The `id`,
@@ -9,6 +9,7 @@ import * as stories from "./Field.stories.tsx"
9
9
 
10
10
  const {
11
11
  AdoptsChildId,
12
+ AdoptsOverlayTriggerId,
12
13
  AllStates,
13
14
  AllVariants,
14
15
  Default,
@@ -38,6 +39,43 @@ test("a control's own id is adopted, not overwritten", async () => {
38
39
  await expectNoAxeViolations(canvasElement)
39
40
  })
40
41
 
42
+ test("an overlay trigger keeps the id the label points at", async () => {
43
+ const { canvas, canvasElement } = await mountStory(
44
+ AdoptsOverlayTriggerId,
45
+ )
46
+
47
+ // The regression this exists for: `useAnchoredOverlay` minted its own
48
+ // trigger id and cloned it over the Field's, so the `<label htmlFor>`
49
+ // named an element that was not in the document. Nothing threw and
50
+ // nothing looked wrong — the only way to see it was to go looking for
51
+ // the id.
52
+ const label = canvasElement.querySelector("label")
53
+
54
+ await expect(label).not.toBeNull()
55
+
56
+ const htmlFor = label?.getAttribute("for")
57
+
58
+ await expect(htmlFor).toBeTruthy()
59
+
60
+ // The assertion that fails on the old behaviour: the id the label
61
+ // names must actually exist, and must BE the trigger.
62
+ const target = canvasElement.ownerDocument.getElementById(
63
+ htmlFor as string,
64
+ )
65
+
66
+ await expect(target).not.toBeNull()
67
+ await expect(target?.tagName).toBe("BUTTON")
68
+
69
+ // And the round trip callers actually rely on: findable by the
70
+ // label's own text.
71
+ expectAgentDrivable(canvas, {
72
+ name: /^Language: /,
73
+ role: "button",
74
+ })
75
+
76
+ await expectNoAxeViolations(canvasElement)
77
+ })
78
+
41
79
  test("the label names the control it points at", async () => {
42
80
  const { canvas, canvasElement } =
43
81
  await mountStory(Default)
@@ -225,7 +225,39 @@ export const useAnchoredOverlay = ({
225
225
  // input field and must be named.
226
226
  const generatedTriggerId = useUniqueId()
227
227
 
228
+ /**
229
+ * The trigger's OWN id wins over both.
230
+ *
231
+ * This hook used to mint an id and clone it over whatever the
232
+ * trigger already had, on the reasoning that the panel needs a
233
+ * stable target for `aria-labelledby`. It does — but any id serves
234
+ * that purpose, including the caller's, and overwriting had a
235
+ * consequence nobody traced: `Field` clones a `controlId` onto its
236
+ * child and renders `<label htmlFor={controlId}>`, so for every
237
+ * `Picker`/`Listbox`/`Combobox`/`Menu` inside a `Field` the label
238
+ * pointed at an element that did not exist. Measured in a real
239
+ * browser: zero nodes carried the id the `<label>` named.
240
+ *
241
+ * That is the exact defect `Field`'s docstring calls "precisely the
242
+ * defect this component was built to make impossible" — arriving by
243
+ * a different route, and silent, because a dangling `htmlFor`
244
+ * throws nothing and shows nothing.
245
+ *
246
+ * Preferring the child's id fixes the label without weakening the
247
+ * panel: `aria-labelledby` now points at the caller's id instead of
248
+ * a generated one, which is the same element either way.
249
+ *
250
+ * Two rediscoveries of the overwrite are already in the fleet —
251
+ * queuepilot and mux-magic both moved their e2e handles to
252
+ * `data-testid` because `id` "did not survive". `data-testid` is
253
+ * still the more robust handle, but `id` is no longer a trap.
254
+ */
255
+ const triggerOwnId = (
256
+ trigger?.props as { id?: string } | undefined
257
+ )?.id
258
+
228
259
  const triggerId =
260
+ triggerOwnId ??
229
261
  (referenceProps.id as string | undefined) ??
230
262
  generatedTriggerId
231
263
 
@@ -108,14 +108,20 @@ export type PickerProps = Omit<
108
108
  *
109
109
  * Query it as `getByRole("button", { name: /^Language: / })`.
110
110
  *
111
- * ### `id` does not survive; `data-testid` does
111
+ * ### `id` survives — it did not always
112
112
  *
113
- * `useAnchoredOverlay` overwrites the trigger's `id` with a generated
114
- * one, so the portalled listbox can point `aria-labelledby` across at
115
- * it. An `id` passed here is therefore silently replaced — queuepilot
116
- * found this the hard way and switched its e2e handles to
117
- * `data-testid`, which nothing injects. Everything else on the button
118
- * (`data-*`, `onClick`, `form`, …) passes straight through.
113
+ * `useAnchoredOverlay` used to overwrite the trigger's `id` with a
114
+ * generated one so the portalled listbox could point `aria-labelledby`
115
+ * across at it. That silently broke `Field`, which renders
116
+ * `<label htmlFor>` against an id it clones onto its child: the label
117
+ * pointed at nothing for every overlay control in a `Field`. The hook
118
+ * now prefers the trigger's own id and mints one only when absent, so
119
+ * both the label and the panel name the same element.
120
+ *
121
+ * `data-testid` remains the sturdier e2e handle — queuepilot and
122
+ * mux-magic both moved to it while `id` was still being replaced — but
123
+ * `id` is no longer a trap. Everything else on the button (`data-*`,
124
+ * `onClick`, `form`, …) passes straight through.
119
125
  */
120
126
  export const Picker = ({
121
127
  appearance = "outline",
@@ -463,17 +463,22 @@ const ENTRY_POINT_RUNTIMES: Record<
463
463
  "./core": [],
464
464
  "./jotai": ["jotai"],
465
465
  "./preact": ["preact", "preact/hooks"],
466
- // The request/response data layer. It reaches react-query, the
467
- // two openapi-* primitives it re-exports, and `react` (the
468
- // provider's `useState`) — all optional peers, so an app that
469
- // never imports `./query` resolves none of them. `react/jsx-runtime`
470
- // is added only at emit, so it is not a source specifier here.
471
- "./query": [
472
- "@tanstack/react-query",
473
- "openapi-fetch",
474
- "openapi-react-query",
475
- "react",
476
- ],
466
+ // The typed OpenAPI seam, split out of `./query` so that only an
467
+ // app whose backend publishes a spec resolves these two. This
468
+ // table is the thing that makes that split legible: what the row
469
+ // costs is the whole argument for it being its own entry point.
470
+ // See the [split
471
+ // decision](../../../docs/decisions/2026-08-13-the-openapi-seam-is-its-own-subpath-not-part-of-query.md).
472
+ "./openapi": ["openapi-fetch", "openapi-react-query"],
473
+ // The request/response data layer: react-query and `react` (the
474
+ // provider's `useState`), both optional peers, so an app that
475
+ // never imports `./query` resolves neither. It deliberately does
476
+ // NOT reach the openapi-* packages — when it did (via the
477
+ // re-export this row used to list), the type-only edge above was
478
+ // enough to force every non-OpenAPI consumer to install both.
479
+ // `react/jsx-runtime` is added only at emit, so it is not a
480
+ // source specifier here.
481
+ "./query": ["@tanstack/react-query", "react"],
477
482
  "./signals": ["@preact/signals-core"],
478
483
  },
479
484
  tokens: {
@@ -494,6 +499,16 @@ const ENTRY_POINT_RUNTIMES: Record<
494
499
  // tree-shakeable, ~4 KB gz.
495
500
  "@tanstack/react-virtual",
496
501
  "react",
502
+ // `toClassName`'s conflict resolution — the third runtime
503
+ // dependency this package has ever taken. MIT, German origin
504
+ // (Dany Castillo), ~7 KB gz, no transitive deps.
505
+ //
506
+ // It earns the entry: without it `className` is not an escape
507
+ // hatch. A caller's `hidden` and the base `inline-flex` are
508
+ // display utilities at equal specificity, so the winner came
509
+ // from stylesheet source order and the caller could not win —
510
+ // silently, which is how it shipped broken in three repos.
511
+ "tailwind-merge",
497
512
  ],
498
513
  // The router seam's one shipped adapter, and the reason it is a
499
514
  // subpath rather than part of the barrel: `react-router` appears
@@ -0,0 +1,229 @@
1
+ import { readdirSync, readFileSync } from "node:fs"
2
+ import { join } from "node:path"
3
+ import { describe, expect, test } from "vitest"
4
+
5
+ import {
6
+ type BadgeSize,
7
+ getControlClassName,
8
+ } from "./controlStyles.ts"
9
+ import { INTENT_APPEARANCE_CLASS } from "./intentStyles.ts"
10
+ import { toClassName } from "./toClassName.ts"
11
+
12
+ describe("a caller's class beats the base class", () => {
13
+ // The bug this file exists for. `CONTROL_BASE_CLASS` opens with
14
+ // `inline-flex`; both it and `hidden` are display utilities at equal
15
+ // specificity, so before the merge the winner was decided by
16
+ // stylesheet source order and the caller could not win.
17
+ test("display: `hidden` removes `inline-flex`", () => {
18
+ expect(
19
+ toClassName("inline-flex items-center", "hidden"),
20
+ ).toBe("items-center hidden")
21
+ })
22
+
23
+ test("the responsive form that shipped broken still works", () => {
24
+ // mail-sifter's header, verbatim: hide below `lg`, show above.
25
+ // The unprefixed `hidden` must beat the base `inline-flex` while
26
+ // the `lg:` variant is left alone — they are different breakpoints
27
+ // and must not cancel each other.
28
+ const result = toClassName(
29
+ "inline-flex cursor-pointer items-center",
30
+ "hidden lg:inline-flex",
31
+ )
32
+
33
+ expect(result).toContain("hidden")
34
+ expect(result).toContain("lg:inline-flex")
35
+ expect(result.split(" ")).not.toContain("inline-flex")
36
+ })
37
+
38
+ test.each([
39
+ ["rounded-md", "rounded-full"],
40
+ ["border-border-default", "border-transparent"],
41
+ ["bg-surface-base", "bg-surface-raised"],
42
+ ["whitespace-nowrap", "whitespace-normal"],
43
+ ["font-medium", "font-bold"],
44
+ ["h-(--control-height-md)", "h-(--control-height-lg)"],
45
+ ["px-(--control-padding-inline-md)", "px-0"],
46
+ ["text-sm", "text-lg"],
47
+ // Ours, not Tailwind's — registered explicitly in the merge
48
+ // config, and it would silently fail to merge without that.
49
+ ["ease-standard", "ease-linear"],
50
+ ])("%s is overridden by %s", (base, override) => {
51
+ expect(toClassName(base, override)).toBe(override)
52
+ })
53
+ })
54
+
55
+ describe("it does not over-merge", () => {
56
+ // The dangerous direction, and the reason this was measured rather
57
+ // than assumed: `text-md` is OUR font-size (theme.css bridges
58
+ // `--text-*` onto `--font-size-*`), and `text-content-primary` is a
59
+ // colour. A merge that classified both as `text-*` would silently
60
+ // drop the colour from every control in the package.
61
+ test("a custom font size does not eat a custom text colour", () => {
62
+ const result = toClassName(
63
+ "text-md",
64
+ "text-content-primary",
65
+ )
66
+
67
+ expect(result).toContain("text-md")
68
+ expect(result).toContain("text-content-primary")
69
+ })
70
+
71
+ test("a custom easing does not eat a duration", () => {
72
+ const result = toClassName(
73
+ "ease-standard duration-(--duration-fast)",
74
+ )
75
+
76
+ expect(result).toContain("ease-standard")
77
+ expect(result).toContain("duration-(--duration-fast)")
78
+ })
79
+
80
+ test("falsy parts are still dropped", () => {
81
+ expect(
82
+ toClassName(
83
+ "a-class",
84
+ false,
85
+ null,
86
+ undefined,
87
+ "b-class",
88
+ ),
89
+ ).toBe("a-class b-class")
90
+ })
91
+ })
92
+
93
+ /**
94
+ * Adopting a merge could in principle drop one of the package's OWN
95
+ * classes — a conflict we shipped on purpose, or one it misreads.
96
+ * These two tests are the evidence that it does not, and they are the
97
+ * reason the change is safe to make globally rather than only where a
98
+ * consumer `className` is folded in.
99
+ *
100
+ * They also fail loudly if a future class introduces a real internal
101
+ * conflict, which is worth knowing about on its own.
102
+ */
103
+ describe("merging is a no-op on what this package emits", () => {
104
+ const walk = (dir: string): string[] =>
105
+ readdirSync(dir, { withFileTypes: true }).flatMap(
106
+ (entry) => {
107
+ const path = join(dir, entry.name)
108
+
109
+ if (entry.isDirectory()) {
110
+ return entry.name === "node_modules"
111
+ ? []
112
+ : walk(path)
113
+ }
114
+
115
+ return /\.tsx?$/.test(path) &&
116
+ !/\.test\./.test(path)
117
+ ? [path]
118
+ : []
119
+ },
120
+ )
121
+
122
+ const looksLikeClassList = (value: string) =>
123
+ value.includes(" ") &&
124
+ value
125
+ .split(" ")
126
+ .every((token) =>
127
+ /^[a-z0-9:[\]()\-_/.&>*+#!,%'"$@=~^|?]+$/i.test(
128
+ token,
129
+ ),
130
+ ) &&
131
+ /(flex|grid|text-|bg-|border|rounded|p[xytblr]?-|m[xytblr]?-|h-|w-|size-|gap-|items-|justify-|absolute|relative|hidden|block|inline|font-|shadow|outline|transition|duration|ease-|select-|cursor-|overflow|z-|opacity|ring|space-|min-|max-|whitespace|truncate|leading|tracking)/.test(
132
+ value,
133
+ )
134
+
135
+ test("every class-string literal in src survives untouched", () => {
136
+ const damaged: {
137
+ after: string
138
+ before: string
139
+ file: string
140
+ }[] = []
141
+
142
+ let checked = 0
143
+
144
+ for (const file of walk(
145
+ join(import.meta.dirname ?? "src"),
146
+ )) {
147
+ const source = readFileSync(file, "utf8")
148
+
149
+ for (const match of source.matchAll(
150
+ /"([a-z0-9][^"\n]*?)"/g,
151
+ )) {
152
+ const value = match[1]
153
+
154
+ if (!looksLikeClassList(value)) {
155
+ continue
156
+ }
157
+
158
+ checked++
159
+
160
+ const merged = toClassName(value)
161
+
162
+ if (merged !== value) {
163
+ damaged.push({
164
+ after: merged,
165
+ before: value,
166
+ file,
167
+ })
168
+ }
169
+ }
170
+ }
171
+
172
+ // A guard on the guard: if the heuristic stops matching, this
173
+ // test would pass by checking nothing.
174
+ expect(checked).toBeGreaterThan(300)
175
+ expect(damaged).toEqual([])
176
+ })
177
+
178
+ test("every composed control class string survives untouched", () => {
179
+ const intents = Object.keys(
180
+ INTENT_APPEARANCE_CLASS,
181
+ ) as (keyof typeof INTENT_APPEARANCE_CLASS)[]
182
+
183
+ const appearances = Object.keys(
184
+ INTENT_APPEARANCE_CLASS[intents[0]],
185
+ ) as (keyof (typeof INTENT_APPEARANCE_CLASS)[(typeof intents)[0]])[]
186
+
187
+ const damaged: string[] = []
188
+
189
+ let composed = 0
190
+
191
+ for (const intent of intents) {
192
+ for (const appearance of appearances) {
193
+ for (const size of [
194
+ "sm",
195
+ "md",
196
+ "lg",
197
+ ] as BadgeSize[] & string[]) {
198
+ for (const sizing of [
199
+ "control",
200
+ "icon",
201
+ ] as const) {
202
+ for (const isFullWidth of [false, true]) {
203
+ const value = getControlClassName({
204
+ appearance,
205
+ className: undefined,
206
+ disabledClass: "disabled:opacity-50",
207
+ intent,
208
+ isFullWidth,
209
+ size,
210
+ sizing,
211
+ })
212
+
213
+ composed++
214
+
215
+ // `getControlClassName` already routes through
216
+ // `toClassName`, so re-merging must be idempotent.
217
+ if (toClassName(value) !== value) {
218
+ damaged.push(value)
219
+ }
220
+ }
221
+ }
222
+ }
223
+ }
224
+ }
225
+
226
+ expect(composed).toBeGreaterThan(200)
227
+ expect(damaged).toEqual([])
228
+ })
229
+ })
@@ -1,13 +1,74 @@
1
+ import { extendTailwindMerge } from "tailwind-merge"
2
+
1
3
  /**
2
- * Filter-and-join. Deliberately not `clsx`.
3
- *
4
- * Every call site in this package passes a flat list of strings and
5
- * conditionals, which is four lines of work — and a published
6
- * component library's dependency list is a thing consumers inherit,
7
- * so it earns its entries. `clsx` also accepts objects and nested
8
- * arrays, both of which make a className harder for the Tailwind
9
- * scanner and for `tailwindCandidates.test.ts` to read statically.
4
+ * `ease-standard` is ours, from the motion tokens — Tailwind ships
5
+ * `ease-linear`/`ease-in`/`ease-out`/`ease-in-out` and knows nothing
6
+ * about it. Without this it lands in no class group, so a caller
7
+ * passing `ease-linear` would get BOTH and the winner would be decided
8
+ * by stylesheet source order — exactly the bug this file now exists to
9
+ * prevent, one utility further along.
10
+ *
11
+ * Nothing else needs registering. Verified empirically rather than
12
+ * assumed: `toClassName.test.ts` asserts the merge is a no-op across
13
+ * every class string in this package and every string
14
+ * `getControlClassName` composes.
15
+ */
16
+ const mergeClassNames = extendTailwindMerge({
17
+ extend: {
18
+ classGroups: { ease: [{ ease: ["standard"] }] },
19
+ },
20
+ })
21
+
22
+ /**
23
+ * Join a class list, and let a later class beat an earlier one that
24
+ * targets the same CSS property.
25
+ *
26
+ * ### Why this is not a plain join any more
27
+ *
28
+ * It was, and that was a bug. `getControlClassName` assembles
29
+ * `toClassName(CONTROL_BASE_CLASS, …, className)`, and
30
+ * `CONTROL_BASE_CLASS` opens with `inline-flex`. A caller writing
31
+ *
32
+ * ```tsx
33
+ * <Button className="hidden lg:inline-flex" />
34
+ * ```
35
+ *
36
+ * emitted `class="inline-flex … hidden lg:inline-flex"`. Both are
37
+ * display utilities at equal specificity, so the winner is decided by
38
+ * **source order in the generated stylesheet**, not by order in the
39
+ * class attribute — and the caller has no way to win. `className` is a
40
+ * documented escape hatch, and it did not work for the most common
41
+ * thing an escape hatch is used for.
42
+ *
43
+ * It failed *silently and invisibly*. mail-sifter's header used exactly
44
+ * that class to hide a duplicate button below `lg`; it never hid, and
45
+ * nobody noticed for weeks because at 390px the header happened to fit
46
+ * anyway. It only surfaced when an unrelated type-ramp change pushed the
47
+ * width to 427px and scrolled the page sideways. Two more repos hit the
48
+ * same shape independently.
49
+ *
50
+ * ### Why `tailwind-merge` and not `clsx`
51
+ *
52
+ * The old docblock argued against taking a dependency, on the grounds
53
+ * that a published component library's dependency list is inherited by
54
+ * consumers and that `clsx`'s object and nested-array forms make a
55
+ * className harder for the Tailwind scanner and for
56
+ * `tailwindCandidates.test.ts` to read statically.
57
+ *
58
+ * The first half still stands and this entry earns its place; the
59
+ * second half does not transfer. `tailwind-merge` does not change the
60
+ * *input* shape at all — this still takes a flat list of strings and
61
+ * conditionals, and every call site is still statically scannable. It
62
+ * changes only which of two conflicting classes survives.
63
+ *
64
+ * ### It is a no-op on everything this package emits
65
+ *
66
+ * Merging could in principle drop one of our own classes. It does not:
67
+ * the test file checks all 358 class-string literals in `src` and all
68
+ * 288 strings `getControlClassName` composes, and the merge changes
69
+ * none of them. So this only ever acts on a genuine caller conflict —
70
+ * which is the entire point.
10
71
  */
11
72
  export const toClassName = (
12
73
  ...parts: (false | string | null | undefined)[]
13
- ) => parts.filter(Boolean).join(" ")
74
+ ) => mergeClassNames(parts.filter(Boolean).join(" "))