@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.
- package/dist/Overlay/useAnchoredOverlay.d.ts.map +1 -1
- package/dist/Overlay/useAnchoredOverlay.js +30 -1
- package/dist/Overlay/useAnchoredOverlay.js.map +1 -1
- package/dist/Picker/Picker.d.ts +13 -7
- package/dist/Picker/Picker.d.ts.map +1 -1
- package/dist/Picker/Picker.js +13 -7
- package/dist/Picker/Picker.js.map +1 -1
- package/dist/toClassName.d.ts +48 -8
- package/dist/toClassName.d.ts.map +1 -1
- package/dist/toClassName.js +68 -9
- package/dist/toClassName.js.map +1 -1
- package/package.json +4 -3
- package/src/Field/Field.stories.tsx +28 -0
- package/src/Field/Field.test.tsx +38 -0
- package/src/Overlay/useAnchoredOverlay.ts +32 -0
- package/src/Picker/Picker.tsx +13 -7
- package/src/sourceRules.test.ts +26 -11
- package/src/toClassName.test.ts +229 -0
- package/src/toClassName.ts +70 -9
|
@@ -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;;;;;;;;;;;;;;;;;;;;;;;;
|
|
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
|
-
|
|
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":"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
|
|
1
|
+
{"version":3,"file":"useAnchoredOverlay.js","sourceRoot":"","sources":["../../src/Overlay/useAnchoredOverlay.ts"],"names":[],"mappings":"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"}
|
package/dist/Picker/Picker.d.ts
CHANGED
|
@@ -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`
|
|
73
|
+
* ### `id` survives — it did not always
|
|
74
74
|
*
|
|
75
|
-
* `useAnchoredOverlay`
|
|
76
|
-
* one
|
|
77
|
-
* it.
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
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
|
|
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"}
|
package/dist/Picker/Picker.js
CHANGED
|
@@ -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`
|
|
51
|
+
* ### `id` survives — it did not always
|
|
52
52
|
*
|
|
53
|
-
* `useAnchoredOverlay`
|
|
54
|
-
* one
|
|
55
|
-
* it.
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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
|
|
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"}
|
package/dist/toClassName.d.ts
CHANGED
|
@@ -1,12 +1,52 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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":"
|
|
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"}
|
package/dist/toClassName.js
CHANGED
|
@@ -1,12 +1,71 @@
|
|
|
1
|
+
import { extendTailwindMerge } from "tailwind-merge";
|
|
1
2
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
package/dist/toClassName.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"toClassName.js","sourceRoot":"","sources":["../src/toClassName.ts"],"names":[],"mappings":"AAAA
|
|
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.
|
|
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": "
|
|
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`,
|
package/src/Field/Field.test.tsx
CHANGED
|
@@ -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
|
|
package/src/Picker/Picker.tsx
CHANGED
|
@@ -108,14 +108,20 @@ export type PickerProps = Omit<
|
|
|
108
108
|
*
|
|
109
109
|
* Query it as `getByRole("button", { name: /^Language: / })`.
|
|
110
110
|
*
|
|
111
|
-
* ### `id`
|
|
111
|
+
* ### `id` survives — it did not always
|
|
112
112
|
*
|
|
113
|
-
* `useAnchoredOverlay`
|
|
114
|
-
* one
|
|
115
|
-
* it.
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
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",
|
package/src/sourceRules.test.ts
CHANGED
|
@@ -463,17 +463,22 @@ const ENTRY_POINT_RUNTIMES: Record<
|
|
|
463
463
|
"./core": [],
|
|
464
464
|
"./jotai": ["jotai"],
|
|
465
465
|
"./preact": ["preact", "preact/hooks"],
|
|
466
|
-
// The
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
+
})
|
package/src/toClassName.ts
CHANGED
|
@@ -1,13 +1,74 @@
|
|
|
1
|
+
import { extendTailwindMerge } from "tailwind-merge"
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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(" "))
|