@charcuterie/ui 2.15.1 → 2.17.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/MediaTile/MediaTile.d.ts +9 -1
- package/dist/MediaTile/MediaTile.d.ts.map +1 -1
- package/dist/MediaTile/MediaTile.js +16 -4
- package/dist/MediaTile/MediaTile.js.map +1 -1
- 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 +3 -2
- package/src/Field/Field.mdx +6 -0
- package/src/Field/Field.stories.tsx +28 -0
- package/src/Field/Field.test.tsx +38 -0
- package/src/MediaTile/MediaTile.mdx +9 -4
- package/src/MediaTile/MediaTile.stories.tsx +14 -0
- package/src/MediaTile/MediaTile.test.tsx +17 -1
- package/src/MediaTile/MediaTile.tsx +56 -15
- package/src/Overlay/useAnchoredOverlay.ts +32 -0
- package/src/Picker/Picker.mdx +31 -0
- package/src/Picker/Picker.stories.tsx +74 -0
- package/src/Picker/Picker.test.tsx +13 -1
- package/src/Picker/Picker.tsx +13 -7
- package/src/sourceRules.test.ts +10 -0
- package/src/toClassName.test.ts +229 -0
- package/src/toClassName.ts +70 -9
|
@@ -27,11 +27,27 @@ export type MediaTileProps = Omit<
|
|
|
27
27
|
/** Shown in place of the image when it fails. */
|
|
28
28
|
fallback?: ReactNode
|
|
29
29
|
href?: string
|
|
30
|
+
/**
|
|
31
|
+
* Makes the tile a button. Hover, focus-visible and
|
|
32
|
+
* `cursor-pointer` come with it — wrapping `MediaTile` in a
|
|
33
|
+
* bare `<button>` is how a poster ends up with a text cursor
|
|
34
|
+
* and no hover at all. `href` wins if both are set.
|
|
35
|
+
*/
|
|
36
|
+
isDisabled?: boolean
|
|
37
|
+
onClick?: ComponentPropsWithRef<"button">["onClick"]
|
|
30
38
|
src?: string
|
|
31
39
|
subtitle?: ReactNode
|
|
32
40
|
title: string
|
|
33
41
|
}
|
|
34
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Shared by the link and the button. A wrapped `<button
|
|
45
|
+
* className="block">` does not get these, which is why a
|
|
46
|
+
* Collection thumbnail looked inert under the pointer.
|
|
47
|
+
*/
|
|
48
|
+
const INTERACTIVE_CLASS =
|
|
49
|
+
"group flex cursor-pointer flex-col rounded-md outline-offset-2 hover:opacity-90 focus-visible:outline-solid focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-focus-ring"
|
|
50
|
+
|
|
35
51
|
const RATIO_CLASS: Record<MediaTileRatio, string> = {
|
|
36
52
|
// 2:3 is the standard poster trim, which is what Plex, Kavita, and
|
|
37
53
|
// every physical sleeve in the collection actually are.
|
|
@@ -72,6 +88,8 @@ export const MediaTile = ({
|
|
|
72
88
|
className,
|
|
73
89
|
fallback,
|
|
74
90
|
href,
|
|
91
|
+
isDisabled = false,
|
|
92
|
+
onClick,
|
|
75
93
|
src,
|
|
76
94
|
subtitle,
|
|
77
95
|
title,
|
|
@@ -144,7 +162,7 @@ export const MediaTile = ({
|
|
|
144
162
|
) : null}
|
|
145
163
|
|
|
146
164
|
<img
|
|
147
|
-
alt={href ? "" : alt}
|
|
165
|
+
alt={href || onClick ? "" : alt}
|
|
148
166
|
className={toClassName(
|
|
149
167
|
"size-full object-cover transition-opacity duration-(--duration-normal) ease-standard",
|
|
150
168
|
status === "loaded"
|
|
@@ -177,19 +195,27 @@ export const MediaTile = ({
|
|
|
177
195
|
</div>
|
|
178
196
|
)
|
|
179
197
|
|
|
180
|
-
const caption =
|
|
181
|
-
|
|
182
|
-
<
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
{subtitle ? (
|
|
187
|
-
<span className="truncate text-content-muted text-xs">
|
|
188
|
-
{subtitle}
|
|
198
|
+
const caption =
|
|
199
|
+
title === "" ? null : (
|
|
200
|
+
<figcaption className="flex flex-col gap-0.5 pt-2">
|
|
201
|
+
<span className="truncate font-medium text-content-primary text-sm cq-sm:text-md">
|
|
202
|
+
{title}
|
|
189
203
|
</span>
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
204
|
+
|
|
205
|
+
{subtitle ? (
|
|
206
|
+
<span className="truncate text-content-muted text-xs">
|
|
207
|
+
{subtitle}
|
|
208
|
+
</span>
|
|
209
|
+
) : null}
|
|
210
|
+
</figcaption>
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
// An empty `title` is how a tile sits next to a name the
|
|
214
|
+
// parent already printed. The control still needs a name, so
|
|
215
|
+
// the alt is the fallback — wrapping it in a nameless
|
|
216
|
+
// `<button>` was how Collection's cover thumbnail became
|
|
217
|
+
// unfindable as well as un-hoverable.
|
|
218
|
+
const accessibleName = title === "" ? alt : title
|
|
193
219
|
|
|
194
220
|
return (
|
|
195
221
|
<figure
|
|
@@ -207,14 +233,29 @@ export const MediaTile = ({
|
|
|
207
233
|
// The `<img>` inside goes `alt=""` for the same reason: two
|
|
208
234
|
// names for one link is a screen reader reading it twice.
|
|
209
235
|
<a
|
|
210
|
-
aria-label={
|
|
211
|
-
className=
|
|
236
|
+
aria-label={accessibleName}
|
|
237
|
+
className={INTERACTIVE_CLASS}
|
|
212
238
|
href={href}
|
|
213
239
|
>
|
|
214
240
|
{media}
|
|
215
241
|
|
|
216
242
|
{caption}
|
|
217
243
|
</a>
|
|
244
|
+
) : onClick ? (
|
|
245
|
+
<button
|
|
246
|
+
aria-label={accessibleName}
|
|
247
|
+
className={toClassName(
|
|
248
|
+
INTERACTIVE_CLASS,
|
|
249
|
+
"w-full border-0 bg-transparent p-0 text-start disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:opacity-50",
|
|
250
|
+
)}
|
|
251
|
+
disabled={isDisabled}
|
|
252
|
+
onClick={onClick}
|
|
253
|
+
type="button"
|
|
254
|
+
>
|
|
255
|
+
{media}
|
|
256
|
+
|
|
257
|
+
{caption}
|
|
258
|
+
</button>
|
|
218
259
|
) : (
|
|
219
260
|
<>
|
|
220
261
|
{media}
|
|
@@ -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.mdx
CHANGED
|
@@ -46,6 +46,37 @@ text of the whole node.
|
|
|
46
46
|
|
|
47
47
|
<Canvas of={PickerStories.AllStates} />
|
|
48
48
|
|
|
49
|
+
## The trigger is flush — the parent owns the gap
|
|
50
|
+
|
|
51
|
+
`Picker` is a `Button`. It has **no outer margin**. Next to a text
|
|
52
|
+
label it sits hard against the last word, which is how queuepilot's
|
|
53
|
+
"Chapters queued per turn" landed flush on the `[2 Default]` trigger,
|
|
54
|
+
and why that same app already had a one-off `#setmodal .fieldselect {
|
|
55
|
+
margin-left: 10px }` for the *other* inline pickers.
|
|
56
|
+
|
|
57
|
+
Do not put the gutter on `Picker`. A control that ships its own
|
|
58
|
+
margin is a control that fights `Field` (`flex flex-col gap-1.5`) and
|
|
59
|
+
every toolbar row. The parent that decided to sit text next to a
|
|
60
|
+
trigger also owns the space:
|
|
61
|
+
|
|
62
|
+
```tsx
|
|
63
|
+
{/* stacked — this is Field's job */}
|
|
64
|
+
<Field label="Chapters queued per turn" description="…">
|
|
65
|
+
<Picker label="Chapters queued per turn" … />
|
|
66
|
+
</Field>
|
|
67
|
+
|
|
68
|
+
{/* inline — the row owns the gap */}
|
|
69
|
+
<span className="inline-flex items-center gap-2.5">
|
|
70
|
+
Chapters queued per turn
|
|
71
|
+
<Picker label="Chapters queued per turn" … />
|
|
72
|
+
</span>
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The same rule is `Button`, `Select`, and a `Listbox` trigger. They are
|
|
76
|
+
all flush; they are all the same trap.
|
|
77
|
+
|
|
78
|
+
<Canvas of={PickerStories.NextToText} />
|
|
79
|
+
|
|
49
80
|
## `id` does not survive; `data-testid` does
|
|
50
81
|
|
|
51
82
|
`useAnchoredOverlay` overwrites the trigger's `id` with a generated one so the portalled
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
StoryRow,
|
|
13
13
|
StorySection,
|
|
14
14
|
} from "../board.storyHelpers.tsx"
|
|
15
|
+
import { Field } from "../Field/Field.tsx"
|
|
15
16
|
import type { PickerOption } from "./Picker.tsx"
|
|
16
17
|
import { Picker } from "./Picker.tsx"
|
|
17
18
|
|
|
@@ -182,3 +183,76 @@ export const Interactive: Story = {
|
|
|
182
183
|
/>
|
|
183
184
|
),
|
|
184
185
|
}
|
|
186
|
+
|
|
187
|
+
const COUNTS: PickerOption[] = [
|
|
188
|
+
{ label: "1", value: "1" },
|
|
189
|
+
{
|
|
190
|
+
label: (
|
|
191
|
+
<span className="flex items-center gap-2">
|
|
192
|
+
<span>2</span>
|
|
193
|
+
<Badge intent="neutral" size="sm">
|
|
194
|
+
Default
|
|
195
|
+
</Badge>
|
|
196
|
+
</span>
|
|
197
|
+
),
|
|
198
|
+
textValue: "2 Default",
|
|
199
|
+
value: "2",
|
|
200
|
+
},
|
|
201
|
+
{ label: "Custom…", value: "custom" },
|
|
202
|
+
]
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The trigger has no outer margin. Sitting it next to a label without
|
|
206
|
+
* a parent gap is the flush-against-the-word bug; `Field` or an
|
|
207
|
+
* `inline-flex gap-*` row is the parent owning the gutter.
|
|
208
|
+
*/
|
|
209
|
+
export const NextToText: Story = {
|
|
210
|
+
render: () => (
|
|
211
|
+
<div className="flex flex-col gap-8">
|
|
212
|
+
<StorySection title="Flush — the parent gave it no gap">
|
|
213
|
+
<StoryRow>
|
|
214
|
+
<span>
|
|
215
|
+
Chapters queued per turn{" "}
|
|
216
|
+
<Picker
|
|
217
|
+
label="Flush count"
|
|
218
|
+
onChange={() => undefined}
|
|
219
|
+
options={COUNTS}
|
|
220
|
+
size="sm"
|
|
221
|
+
value="2"
|
|
222
|
+
/>
|
|
223
|
+
</span>
|
|
224
|
+
</StoryRow>
|
|
225
|
+
</StorySection>
|
|
226
|
+
<StorySection title="Inline — the row owns the gap">
|
|
227
|
+
<StoryRow>
|
|
228
|
+
<span className="inline-flex items-center gap-2.5">
|
|
229
|
+
Chapters queued per turn
|
|
230
|
+
<Picker
|
|
231
|
+
label="Inline count"
|
|
232
|
+
onChange={() => undefined}
|
|
233
|
+
options={COUNTS}
|
|
234
|
+
size="sm"
|
|
235
|
+
value="2"
|
|
236
|
+
/>
|
|
237
|
+
</span>
|
|
238
|
+
</StoryRow>
|
|
239
|
+
</StorySection>
|
|
240
|
+
<StorySection title="Stacked — Field owns the gap">
|
|
241
|
+
<StoryRow>
|
|
242
|
+
<Field
|
|
243
|
+
description="How long this entry’s turn is when the queue reaches it."
|
|
244
|
+
label="Chapters queued per turn"
|
|
245
|
+
>
|
|
246
|
+
<Picker
|
|
247
|
+
label="Stacked count"
|
|
248
|
+
onChange={() => undefined}
|
|
249
|
+
options={COUNTS}
|
|
250
|
+
size="sm"
|
|
251
|
+
value="2"
|
|
252
|
+
/>
|
|
253
|
+
</Field>
|
|
254
|
+
</StoryRow>
|
|
255
|
+
</StorySection>
|
|
256
|
+
</div>
|
|
257
|
+
),
|
|
258
|
+
}
|
|
@@ -7,7 +7,7 @@ import { mountStory } from "../mountStory.testHelpers.ts"
|
|
|
7
7
|
import { expectAgentDrivable } from "../testing/index.ts"
|
|
8
8
|
import * as stories from "./Picker.stories.tsx"
|
|
9
9
|
|
|
10
|
-
const { AllStates, Default, Interactive } =
|
|
10
|
+
const { AllStates, Default, Interactive, NextToText } =
|
|
11
11
|
composeStories(stories)
|
|
12
12
|
|
|
13
13
|
test("the trigger's accessible name carries the current value", async () => {
|
|
@@ -83,6 +83,18 @@ test("a disabled picker does not open", async () => {
|
|
|
83
83
|
)
|
|
84
84
|
})
|
|
85
85
|
|
|
86
|
+
test("an inline picker next to a label is still named and has no axe issues", async () => {
|
|
87
|
+
const { canvas, canvasElement } =
|
|
88
|
+
await mountStory(NextToText)
|
|
89
|
+
|
|
90
|
+
expectAgentDrivable(canvas, {
|
|
91
|
+
name: "Inline count: 2 Default",
|
|
92
|
+
role: "button",
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
await expectNoAxeViolations(canvasElement)
|
|
96
|
+
})
|
|
97
|
+
|
|
86
98
|
test("a rich option label keeps a plain-string trigger via textValue", async () => {
|
|
87
99
|
const { canvas, canvasElement } =
|
|
88
100
|
await mountStory(AllStates)
|
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
|
@@ -499,6 +499,16 @@ const ENTRY_POINT_RUNTIMES: Record<
|
|
|
499
499
|
// tree-shakeable, ~4 KB gz.
|
|
500
500
|
"@tanstack/react-virtual",
|
|
501
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",
|
|
502
512
|
],
|
|
503
513
|
// The router seam's one shipped adapter, and the reason it is a
|
|
504
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(" "))
|