@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.
@@ -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
- <figcaption className="flex flex-col gap-0.5 pt-2">
182
- <span className="truncate font-medium text-content-primary text-sm cq-sm:text-md">
183
- {title}
184
- </span>
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
- ) : null}
191
- </figcaption>
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={title}
211
- className="group flex 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"
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
 
@@ -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)
@@ -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",
@@ -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
+ })
@@ -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(" "))