@webjsdev/ui 0.3.12 → 0.3.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/ui",
3
- "version": "0.3.12",
3
+ "version": "0.3.14",
4
4
  "type": "module",
5
5
  "description": "The AI-first component library for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Class-helper functions for visuals, custom elements only where state matters, source-copied into your repo so you own it.",
6
6
  "bin": {
@@ -21,7 +21,8 @@
21
21
  ],
22
22
  "scripts": {
23
23
  "test": "node --test test/*.test.js",
24
- "test:e2e:touch": "node test/e2e/touch.e2e.mjs"
24
+ "test:e2e:touch": "node test/e2e/touch.e2e.mjs",
25
+ "test:e2e:a11y-tree": "node test/e2e/a11y-tree.e2e.mjs"
25
26
  },
26
27
  "dependencies": {
27
28
  "commander": "^14.0.0",
@@ -55,6 +55,11 @@
55
55
  * Treat that as a bug in your markup rather than a feature: this dialog
56
56
  * interrupts the user to demand an explicit choice and blocks Escape, so
57
57
  * naming it "Alert dialog" tells them nothing about what they are deciding.
58
+ * `role="alertdialog"` and the name both sit on the NATIVE `<dialog>`, a
59
+ * valid ARIA-in-HTML override of that element's implicit `dialog` role, so
60
+ * exactly one dialog-family node is exposed rather than an `alertdialog`
61
+ * nested inside a `dialog`. There is no `aria-modal`: the platform already
62
+ * exposes a `showModal()`-opened dialog as modal.
58
63
  *
59
64
  * Design tokens used: --background, --border, --muted-foreground.
60
65
  *
@@ -417,18 +422,23 @@ export class UiAlertDialogContent extends WebComponent({
417
422
  panel.setAttribute('aria-describedby', authored);
418
423
  return;
419
424
  }
425
+ // Clear the previous open's value before resolving again. The panel keeps
426
+ // its attributes between opens, so a guard that skips when the attribute is
427
+ // already present can never re-resolve, and a removed description node
428
+ // leaves a stale aria-describedby pointing at a dead IDREF.
429
+ panel.removeAttribute('aria-describedby');
420
430
  const desc =
421
431
  this.querySelector('[data-slot="alert-dialog-description"]') ?? this.querySelector('p');
422
- if (desc && !panel.hasAttribute('aria-describedby')) {
432
+ if (desc) {
423
433
  panel.setAttribute('aria-describedby', ensureId(desc as HTMLElement, 'ui-alert-desc'));
424
434
  }
425
435
  }
426
436
 
427
437
  _wireLabels(): void {
428
- const panel = this.querySelector('[data-slot="alert-dialog-content"]');
438
+ const panel = this.querySelector('dialog[data-slot="alert-dialog-native"]');
429
439
  if (!panel) return;
430
440
  // A name the author put on <ui-alert-dialog-content> is where they
431
- // naturally write it, but role="alertdialog" lives on the inner panel.
441
+ // naturally write it, but role="alertdialog" lives on the native <dialog>.
432
442
  //
433
443
  // Each authored-name branch RETURNS rather than falling through to the
434
444
  // title wiring. Falling through would set aria-labelledby from the title
@@ -449,9 +459,13 @@ export class UiAlertDialogContent extends WebComponent({
449
459
  this._wireDescription(panel);
450
460
  return;
451
461
  }
462
+ // Same re-resolve rule: clear what a previous open wrote, or a removed
463
+ // title node leaves a stale aria-labelledby that points at a dead IDREF AND
464
+ // suppresses the floor below, which exists to prevent exactly that state.
465
+ panel.removeAttribute('aria-labelledby');
452
466
  const title =
453
467
  this.querySelector('[data-slot="alert-dialog-title"]') ?? this.querySelector('h1, h2, h3');
454
- if (title && !panel.hasAttribute('aria-labelledby')) {
468
+ if (title) {
455
469
  panel.setAttribute('aria-labelledby', ensureId(title as HTMLElement, 'ui-alert-title'));
456
470
  }
457
471
  this._wireDescription(panel);
@@ -472,15 +486,13 @@ export class UiAlertDialogContent extends WebComponent({
472
486
  const parentOpen = !!this._parent()?.open;
473
487
  return html`<dialog
474
488
  data-slot="alert-dialog-native"
489
+ role="alertdialog"
475
490
  class=${NATIVE_DIALOG_CLASS}
476
491
  ${ref(this.#dialog)}
477
492
  @cancel=${this._onNativeCancel}
478
493
  @close=${this._onNativeClose}
479
494
  ><div
480
495
  data-slot="alert-dialog-content"
481
- role="alertdialog"
482
- aria-modal="true"
483
- tabindex="-1"
484
496
  data-size=${this.size}
485
497
  data-state=${parentOpen ? 'open' : 'closed'}
486
498
  class=${alertDialogContentClass()}
@@ -47,6 +47,10 @@
47
47
  * floor to make an unnamed modal impossible, NOT a substitute: give the
48
48
  * dialog a real title, since "Dialog" tells a screen reader user nothing
49
49
  * about what they have been interrupted with.
50
+ * `role="dialog"` and the name both sit on the NATIVE `<dialog>`, not on the
51
+ * inner content div, so exactly one dialog-family node is exposed in the
52
+ * accessibility tree. There is no `aria-modal`: a `showModal()`-opened
53
+ * native dialog is already exposed as modal by the platform.
50
54
  *
51
55
  * Design tokens used: --background, --border, --muted-foreground.
52
56
  *
@@ -111,9 +115,14 @@ function wireDialogDescription(host: Element, panel: Element): void {
111
115
  panel.setAttribute('aria-describedby', authored);
112
116
  return;
113
117
  }
118
+ // Clear the previous open's value before resolving again. The panel keeps its
119
+ // attributes between opens, so a guard that skips when the attribute is
120
+ // already present can never re-resolve: remove the description node and
121
+ // re-open, and the stale aria-describedby survives pointing at a dead IDREF.
122
+ panel.removeAttribute('aria-describedby');
114
123
  const desc =
115
124
  host.querySelector('[data-slot="dialog-description"]') ?? host.querySelector('p');
116
- if (desc && !panel.hasAttribute('aria-describedby')) {
125
+ if (desc) {
117
126
  panel.setAttribute('aria-describedby', ensureId(desc as HTMLElement, 'ui-dialog-desc'));
118
127
  }
119
128
  }
@@ -122,7 +131,7 @@ export function wireDialogLabels(host: Element, panelSelector: string): void {
122
131
  const panel = host.querySelector(panelSelector);
123
132
  if (!panel) return;
124
133
  // A name the author put on <ui-dialog-content> is where they naturally write
125
- // it, but role="dialog" lives on the inner panel, so forward it there.
134
+ // it, but role="dialog" lives on the native <dialog>, so forward it there.
126
135
  //
127
136
  // This RETURNS once an authored name is forwarded, rather than falling
128
137
  // through to the title wiring below. Falling through would set
@@ -144,9 +153,13 @@ export function wireDialogLabels(host: Element, panelSelector: string): void {
144
153
  wireDialogDescription(host, panel);
145
154
  return;
146
155
  }
156
+ // Same re-resolve rule: clear what a previous open wrote, or a removed title
157
+ // node leaves a stale aria-labelledby that points at a dead IDREF AND
158
+ // suppresses the floor below, which exists to prevent exactly that state.
159
+ panel.removeAttribute('aria-labelledby');
147
160
  const title =
148
161
  host.querySelector('[data-slot="dialog-title"]') ?? host.querySelector('h1, h2, h3');
149
- if (title && !panel.hasAttribute('aria-labelledby')) {
162
+ if (title) {
150
163
  panel.setAttribute('aria-labelledby', ensureId(title as HTMLElement, 'ui-dialog-title'));
151
164
  }
152
165
  wireDialogDescription(host, panel);
@@ -505,7 +518,7 @@ export class UiDialogContent extends WebComponent({
505
518
  }
506
519
 
507
520
  showModal(): void {
508
- wireDialogLabels(this, '[data-slot="dialog-content"]');
521
+ wireDialogLabels(this, 'dialog[data-slot="dialog-native"]');
509
522
  const native = this._native();
510
523
  if (native && !native.open) native.showModal();
511
524
  }
@@ -520,15 +533,13 @@ export class UiDialogContent extends WebComponent({
520
533
  const parentOpen = !!this._parent()?.open;
521
534
  return html`<dialog
522
535
  data-slot="dialog-native"
536
+ role="dialog"
523
537
  class=${NATIVE_DIALOG_CLASS}
524
538
  ${ref(this.#dialog)}
525
539
  @close=${this._onNativeClose}
526
540
  @click=${this._onNativeBackdropClick}
527
541
  ><div
528
542
  data-slot="dialog-content"
529
- role="dialog"
530
- aria-modal="true"
531
- tabindex="-1"
532
543
  data-state=${parentOpen ? 'open' : 'closed'}
533
544
  class=${dialogContentClass()}
534
545
  >
@@ -9,14 +9,16 @@
9
9
  * NativeSelect wrapper → nativeSelectWrapperClass()
10
10
  * NativeSelect chevron → nativeSelectIconClass()
11
11
  * NativeSelect option → bare <option> (or nativeSelectOptionClass()
12
- * for explicit overrides; the installed
12
+ * for explicit overrides, since the theme
13
13
  * stylesheet sets Canvas / CanvasText on
14
14
  * every <option> automatically)
15
15
  *
16
- * Importing this module installs a stylesheet that forces Canvas /
17
- * CanvasText on every <option> inside the wrapper so the dropdown reads
18
- * in both light and dark themes regardless of OS preference; advanced
19
- * overrides use `nativeSelectOptionClass()` / `nativeSelectOptGroupClass()`.
16
+ * The theme stylesheet forces Canvas / CanvasText on every <option> so the
17
+ * dropdown reads in both light and dark themes regardless of OS preference.
18
+ * It arrives with the design tokens (`npx @webjsdev/ui init`, or `add`, which
19
+ * writes the block when it is missing), so it is in the first paint and works
20
+ * with JavaScript off. Advanced overrides use `nativeSelectOptionClass()` and
21
+ * `nativeSelectOptGroupClass()`.
20
22
  *
21
23
  * Design tokens used: --input, --background, --primary, --primary-foreground,
22
24
  * --muted-foreground, --ring, --destructive.
@@ -58,61 +60,17 @@ import { cn } from '../lib/utils.ts';
58
60
 
59
61
  export type NativeSelectSize = 'default' | 'sm';
60
62
 
61
- // Auto-apply Canvas/CanvasText to every <option> on the page. Without
62
- // this, an <option> with no explicit bg paints transparent on top of
63
- // the browser-popup background; in dark mode (when color-scheme: dark
64
- // is set on <html>) Chrome's popup is dark, the option's transparent
65
- // bg lets the popup colour through, and the inherited text colour
66
- // from the <select> matches that dark popup: the option disappears,
67
- // only the focused/selected one stays visible because the browser
68
- // overlays its own highlight on it.
63
+ // The <option> / <optgroup> colour rule is NOT injected from here. It lives in
64
+ // the theme stylesheet the kit installs, so it is in the first paint, works
65
+ // with JavaScript off, and does not make this module client-effecting (a
66
+ // module-scope call pins every page that imports it, #1320). See the
67
+ // `select option, select optgroup` rule in the theme block.
69
68
  //
70
- // Original selector required the option to be inside a
71
- // `.group/native-select` wrapper, on the assumption every user would
72
- // follow the documented Usage block above. But it's easy to write a
73
- // bare <select class=${nativeSelectClass()}> without the wrapper
74
- // (legitimate when you don't need the chevron icon: the popover and
75
- // hover-card docs examples both do this), in which case the rule
76
- // never matched and the dropdown reverted to invisible-options.
77
- // Broadening to `select option, select optgroup` makes the fix work
78
- // everywhere the user has imported native-select, with no required
79
- // wrapper. The Canvas/CanvasText pair is a safe default: they ARE
80
- // the system colours the browser would have painted anyway when no
81
- // rule applied; we just stop relying on inheritance to pull through.
82
- // Selector specificity is 0,0,2 (two elements), so any user who
83
- // genuinely needs custom <option> colours can override with a single
84
- // class anywhere in their cascade (e.g. `.my-select option { ... }`
85
- // at 0,1,2 wins).
86
- //
87
- // `nativeSelectOptionClass()` and `nativeSelectOptGroupClass()` stay
88
- // exported for users who want to opt into the same colours via the
89
- // class helper instead of the global rule. They emit the same
90
- // `bg-[Canvas] text-[CanvasText]` Tailwind utilities: redundant if
91
- // this stylesheet is installed, but harmless and matches the broader
92
- // shadcn convention of "every part has a class helper".
93
- const STYLES = `
94
- select option,
95
- select optgroup {
96
- background-color: Canvas;
97
- color: CanvasText;
98
- }
99
- `;
100
-
101
- let installed = false;
102
- export function installNativeSelectStyles(): void {
103
- if (installed || typeof document === 'undefined') return;
104
- if (document.getElementById('ui-native-select-styles')) {
105
- installed = true;
106
- return;
107
- }
108
- const style = document.createElement('style');
109
- style.id = 'ui-native-select-styles';
110
- style.textContent = STYLES;
111
- document.head.appendChild(style);
112
- installed = true;
113
- }
114
-
115
- if (typeof document !== 'undefined') installNativeSelectStyles();
69
+ // `nativeSelectOptionClass()` and `nativeSelectOptGroupClass()` stay exported
70
+ // for users who want to opt into the same colours via a class helper. They
71
+ // emit the same `bg-[Canvas] text-[CanvasText]` utilities: redundant when the
72
+ // theme block is present, harmless, and they match the broader shadcn
73
+ // convention that every part has a class helper.
116
74
 
117
75
  export const nativeSelectWrapperClass = (): string =>
118
76
  'group/native-select relative w-fit has-[select:disabled]:opacity-50';
@@ -32,14 +32,21 @@
32
32
  *
33
33
  * A11y (owned by the element, nothing to supply):
34
34
  * The viewport is a persistent `aria-live="polite"` region labelled
35
- * "Notifications", so a toast is announced as it is inserted; an `error`
36
- * toast carries `role="alert"` so it is announced assertively. Every toast
35
+ * "Notifications", so a toast is announced as it is inserted, and it is the
36
+ * ONLY live root an ordinary toast resolves under: a non-error toast carries
37
+ * no role of its own, because `role="status"` is itself a live region and
38
+ * nesting one inside the polite viewport bought nothing while making some
39
+ * readers announce the toast twice. An `error` toast does carry
40
+ * `role="alert"` so it is announced assertively, which is the only way to
41
+ * make one item assertive inside a polite viewport, and its second live
42
+ * root is the accepted cost of that urgency. Every toast
37
43
  * also ships a labelled close button, so a toast can always be dismissed by
38
44
  * hand rather than only by its timer or a programmatic `toast.dismiss(id)`.
39
45
  * That matters most for a toast that never auto-dismisses, which
40
46
  * `toast.loading()` is by default (`duration: 0`) and which was otherwise
41
47
  * impossible to get rid of from the UI. Each toast's icon is decorative and
42
- * aria-hidden; the meaning is in the text and the role.
48
+ * aria-hidden; the meaning is in the text, and urgency in the viewport's
49
+ * politeness plus `role="alert"` on an error toast.
43
50
  *
44
51
  * Design tokens used: --popover, --popover-foreground, --border, --radius.
45
52
  *
@@ -247,9 +254,16 @@ export class UiSonner extends WebComponent({
247
254
  const pos = POSITIONS[this.position] ?? POSITIONS['bottom-right'];
248
255
  // The container is a persistent live region: it is in the DOM from the
249
256
  // first render (even with zero toasts), so a screen reader announces
250
- // each toast as it is inserted. It defaults to polite; an `error` toast
251
- // carries its own role="alert" (assertive) and that innermost live
252
- // region wins for that item.
257
+ // each toast as it is inserted. It is polite, and it is the ONLY live
258
+ // root an ordinary toast resolves under.
259
+ //
260
+ // A non-error toast therefore carries no role of its own. It used to carry
261
+ // role="status", which is itself a live region, so the toast resolved under
262
+ // TWO nested live roots (measured over CDP, #1245) and some readers
263
+ // double-announce that. The inner role bought nothing, because the viewport
264
+ // is already polite. An `error` toast keeps role="alert", which is
265
+ // load-bearing: it is the only way to make ONE item assertive inside a
266
+ // polite viewport, and its second live root is the accepted cost.
253
267
  return html`<div
254
268
  data-slot="sonner"
255
269
  role="region"
@@ -262,12 +276,33 @@ export class UiSonner extends WebComponent({
262
276
  ${repeat(
263
277
  this.items.get(),
264
278
  (item) => item.id,
265
- (item) => html`<div
266
- data-slot="sonner-toast"
267
- class=${TOAST_ITEM_BASE}
268
- data-type=${item.type}
269
- role=${item.type === 'error' ? 'alert' : 'status'}
270
- >
279
+ // The role is BRANCHED rather than written as a nullish hole. A hole
280
+ // that evaluates to null serves role="" from the server renderer while
281
+ // the client renderer removes the attribute, which is the divergence
282
+ // test/ssr-aria.test.js exists to catch, and an empty role is worse
283
+ // than no role: it is not a role, so the toast falls back to `generic`
284
+ // in a way no attribute assertion would notice.
285
+ (item) => (item.type === 'error'
286
+ ? html`<div
287
+ data-slot="sonner-toast"
288
+ class=${TOAST_ITEM_BASE}
289
+ data-type=${item.type}
290
+ role="alert"
291
+ >${this._toastBody(item)}</div>`
292
+ : html`<div
293
+ data-slot="sonner-toast"
294
+ class=${TOAST_ITEM_BASE}
295
+ data-type=${item.type}
296
+ >${this._toastBody(item)}</div>`),
297
+ )}
298
+ </div>`;
299
+ }
300
+
301
+ // The toast's contents, shared by both arms of the role branch above.
302
+ // A METHOD, not a module-scope value: a registry module does no work at
303
+ // module scope.
304
+ _toastBody(item: ToastItem) {
305
+ return html`
271
306
  <div class=${`${TYPE_ICON_COLOR[item.type]} pt-0.5`}>${unsafeHTML(ICONS[item.type])}</div>
272
307
  <div class="flex-1">
273
308
  <div class="font-medium">${item.message}</div>
@@ -304,9 +339,7 @@ export class UiSonner extends WebComponent({
304
339
  class="shrink-0 rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
305
340
  @click=${() => this._remove(item.id)}
306
341
  >${unsafeHTML(CLOSE_SVG)}</button>
307
- </div>`,
308
- )}
309
- </div>`;
342
+ `;
310
343
  }
311
344
  }
312
345
  UiSonner.register('ui-sonner');
@@ -316,8 +349,9 @@ UiSonner.register('ui-sonner');
316
349
  const CLOSE_SVG =
317
350
  '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
318
351
 
319
- // Type glyphs, all decorative: the toast's text carries the meaning, and the
320
- // type is conveyed by role="alert" vs role="status", not by the picture.
352
+ // Type glyphs, all decorative: the toast's text carries the meaning, and
353
+ // urgency is conveyed by role="alert" on an error toast, not by the picture.
354
+ // An ordinary toast carries no role and is announced by the polite viewport.
321
355
  const ICONS: Record<ToastType, string> = {
322
356
  default: '',
323
357
  success:
@@ -50,99 +50,106 @@ function walk(value: ClassValue, out: string[]): void {
50
50
  // IMPORTANT: text-size (text-sm, text-xs, text-base, text-lg, …) and
51
51
  // text-color (text-primary, text-foreground, …) are DIFFERENT properties
52
52
  // and must be in different groups. Same for bg-size vs bg-color etc.
53
- const GROUPS: Array<[RegExp, string]> = [
54
- [/^p-/, 'p'], [/^px-/, 'px'], [/^py-/, 'py'], [/^pt-/, 'pt'], [/^pr-/, 'pr'], [/^pb-/, 'pb'], [/^pl-/, 'pl'],
55
- [/^m-/, 'm'], [/^mx-/, 'mx'], [/^my-/, 'my'], [/^mt-/, 'mt'], [/^mr-/, 'mr'], [/^mb-/, 'mb'], [/^ml-/, 'ml'],
56
- [/^w-/, 'w'], [/^h-/, 'h'], [/^size-/, 'size'],
57
- // A `bg-[url(…)]` / `bg-[linear-gradient(…)]` background image is classified
58
- // by its FUNCTION, since it carries no type hint to classify it by. The
59
- // hinted forms are handled centrally, in `hintedGroup`.
60
- [/^bg-\[(url\(|linear-gradient|radial-gradient|conic-gradient)/, 'bg-image'],
61
- [/^bg-(linear|gradient|conic|radial|none)/, 'bg-image'],
62
- [/^bg-(no-repeat|repeat|repeat-x|repeat-y|repeat-round|repeat-space)$/, 'bg-repeat'],
63
- [/^bg-(fixed|local|scroll)$/, 'bg-attach'],
64
- [/^bg-(auto|cover|contain)$/, 'bg-size'],
65
- [/^bg-size-/, 'bg-size'],
66
- // Two entries rather than one alternation: the first covers the live v4
67
- // compounds (`bg-top-left`), the second the bare keywords plus the v4.1
68
- // deprecated reversed compounds (`bg-left-top`), which tailwind-merge still
69
- // carries. An unmatched `bg-*` token falls into the colour catch-all below and
70
- // evicts a real colour, so admitting a dead spelling is the safe direction.
71
- [/^bg-(top|bottom)(-(left|right))?$/, 'bg-position'],
72
- [/^bg-(left|right|center)(-(top|bottom))?$/, 'bg-position'],
73
- [/^bg-position-/, 'bg-position'],
74
- // Clip, origin, and blend mode are three more properties under the same
75
- // prefix. Each sat in `bg-color` before, so `bg-clip-text` evicted a real
76
- // background colour (the gradient-text idiom lost its clip silently).
77
- [/^bg-clip-(border|padding|content|text)$/, 'bg-clip'],
78
- [/^bg-origin-(border|padding|content)$/, 'bg-origin'],
79
- [/^bg-blend-(normal|multiply|screen|overlay|darken|lighten|color-dodge|color-burn|hard-light|soft-light|difference|exclusion|hue|saturation|color|luminosity)$/, 'bg-blend'],
80
- [/^bg-/, 'bg-color'],
81
- // text-shadow is its own property, and its size scale and its colour are two
82
- // properties again. All three entries precede the text- patterns below, which
83
- // is what keeps a `text-shadow-*` token out of `text-size` and `text-color`.
84
- [/^text-shadow(-(2xs|xs|sm|md|lg|none))?(\/([\d.]+|\[[^\]]*\]))?$/, 'text-shadow'],
85
- [/^text-shadow-(\[(inset|-|\.|\d|var\()|\(--)/, 'text-shadow'],
86
- [/^text-shadow-/, 'text-shadow-color'],
87
- // Font size: explicit list of Tailwind size scale.
88
- [/^text-(xs|sm|base|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl|8xl|9xl)$/, 'text-size'],
89
- // Alignment, wrapping, and overflow are three more properties under the same
90
- // prefix. Each was previously excluded from text-color by a lookahead and then
91
- // matched nothing at all, so two alignments never collapsed.
92
- [/^text-(left|center|right|justify|start|end)$/, 'text-align'],
93
- [/^text-(wrap|nowrap|balance|pretty)$/, 'text-wrap'],
94
- [/^text-(ellipsis|clip)$/, 'text-overflow'],
95
- // Text color: anything else under the prefix. The specific groups above are
96
- // the whole carve-out, so no negative lookahead is needed here as well.
97
- [/^text-/, 'text-color'],
98
- // Border sub-properties that are neither a width nor a colour. These come
99
- // FIRST so the width / colour classifier below never sees them.
100
- [/^border-(collapse|separate)$/, 'border-collapse'],
101
- [/^border-spacing(-[xy])?-/, 'border-spacing'],
102
- [/^border-(solid|dashed|dotted|double|hidden|none)$/, 'border-style'],
103
- ...borderGroups(),
104
- [/^rounded(-[a-z]+)?$/, 'rounded'],
105
- [/^rounded-/, 'rounded'],
106
- [/^opacity-/, 'opacity'],
107
- [/^font-(thin|light|normal|medium|semibold|bold|black|extralight|extrabold)$/, 'font-weight'],
108
- // Box-shadow SIZE and box-shadow COLOUR are two properties (`box-shadow` and
109
- // `--tw-shadow-color`), so they need two groups. `shadow-none` is a size,
110
- // `shadow-inherit` / `shadow-initial` are colours, and Tailwind accepts an
111
- // alpha modifier on a size as well as on a colour, so `shadow-lg/25` has to
112
- // stay on the size side. An unhinted arbitrary value is a SIZE when it opens
113
- // with `inset`, a sign, a dot, or a digit (a shadow offset list) and also
114
- // when it is a bare `var()` or the `(--x)` variable shorthand: Tailwind
115
- // itself resolves an ambiguous arbitrary shadow to `box-shadow` unless the
116
- // value is provably a colour, and `shadow-[var(--shadow-glow)]` is the normal
117
- // way to write a design-token shadow. This is the one place the
118
- // `borderGroups()` convention inverts, because a bare `border-[var(--x)]` is
119
- // far more often a colour while a bare `shadow-[var(--x)]` is far more often
120
- // a shadow. The size entries must precede the colour catch-all, or every
121
- // size lands in the colour group and the bug inverts rather than being fixed.
122
- [/^shadow(-(2xs|xs|sm|md|lg|xl|2xl|inner|none))?(\/([\d.]+|\[[^\]]*\]))?$/, 'shadow'],
123
- [/^shadow-(\[(inset|-|\.|\d|var\()|\(--)/, 'shadow'],
124
- // A bare name the size scale does not list reads as a colour, because
125
- // `shadow-primary` is overwhelmingly more common than a `@theme`-extended
126
- // `--shadow-card`. A project that adds a custom shadow NAME is the residual
127
- // gap, and the docs say so rather than claiming the split is total.
128
- [/^shadow-/, 'shadow-color'],
129
- [/^z-/, 'z'],
130
- // A bare `flex` / `grid` is a DISPLAY value, not a member of the flex / grid
131
- // sub-property groups below, so it must never dedupe against them: an element
132
- // can be both a flex container and a flex child (`class="flex flex-1"`), and
133
- // collapsing the two silently drops `display:flex`. It still belongs to a
134
- // group of its own, alongside every other display keyword, so a repeated one
135
- // collapses and `cn('hidden', open && 'flex')` resolves to one display.
136
- [/^(inline-block|inline-flex|inline-grid|inline-table|inline|block|flex|grid|flow-root|contents|hidden|list-item|table-caption|table-cell|table-column-group|table-column|table-footer-group|table-header-group|table-row-group|table-row|table)$/, 'display'],
137
- // Each sub-utility below gets the group of the real CSS property it sets, so
138
- // none of them collapses against the display value or against each other.
139
- [/^flex-(row|row-reverse|col|col-reverse)$/, 'flex-direction'],
140
- [/^flex-(wrap|wrap-reverse|nowrap)$/, 'flex-wrap'],
141
- [/^flex-(\d+|auto|initial|none|\[[^\]]*\])$/, 'flex'],
142
- [/^grid-cols-/, 'grid-cols'],
143
- [/^grid-rows-/, 'grid-rows'],
144
- [/^grid-flow-/, 'grid-flow'],
145
- ];
53
+ // Built on FIRST USE, not at module load. A module-scope `...borderGroups()`
54
+ // spread is a real top-level call, so the elision analyser reads this module as
55
+ // client-effecting and every page that reaches `cn` on a component-free path
56
+ // ships whole instead of being elided (#1320).
57
+ let _groups: Array<[RegExp, string]> | undefined;
58
+ function GROUPS(): Array<[RegExp, string]> {
59
+ return (_groups ??= [
60
+ [/^p-/, 'p'], [/^px-/, 'px'], [/^py-/, 'py'], [/^pt-/, 'pt'], [/^pr-/, 'pr'], [/^pb-/, 'pb'], [/^pl-/, 'pl'],
61
+ [/^m-/, 'm'], [/^mx-/, 'mx'], [/^my-/, 'my'], [/^mt-/, 'mt'], [/^mr-/, 'mr'], [/^mb-/, 'mb'], [/^ml-/, 'ml'],
62
+ [/^w-/, 'w'], [/^h-/, 'h'], [/^size-/, 'size'],
63
+ // A `bg-[url(…)]` / `bg-[linear-gradient(…)]` background image is classified
64
+ // by its FUNCTION, since it carries no type hint to classify it by. The
65
+ // hinted forms are handled centrally, in `hintedGroup`.
66
+ [/^bg-\[(url\(|linear-gradient|radial-gradient|conic-gradient)/, 'bg-image'],
67
+ [/^bg-(linear|gradient|conic|radial|none)/, 'bg-image'],
68
+ [/^bg-(no-repeat|repeat|repeat-x|repeat-y|repeat-round|repeat-space)$/, 'bg-repeat'],
69
+ [/^bg-(fixed|local|scroll)$/, 'bg-attach'],
70
+ [/^bg-(auto|cover|contain)$/, 'bg-size'],
71
+ [/^bg-size-/, 'bg-size'],
72
+ // Two entries rather than one alternation: the first covers the live v4
73
+ // compounds (`bg-top-left`), the second the bare keywords plus the v4.1
74
+ // deprecated reversed compounds (`bg-left-top`), which tailwind-merge still
75
+ // carries. An unmatched `bg-*` token falls into the colour catch-all below and
76
+ // evicts a real colour, so admitting a dead spelling is the safe direction.
77
+ [/^bg-(top|bottom)(-(left|right))?$/, 'bg-position'],
78
+ [/^bg-(left|right|center)(-(top|bottom))?$/, 'bg-position'],
79
+ [/^bg-position-/, 'bg-position'],
80
+ // Clip, origin, and blend mode are three more properties under the same
81
+ // prefix. Each sat in `bg-color` before, so `bg-clip-text` evicted a real
82
+ // background colour (the gradient-text idiom lost its clip silently).
83
+ [/^bg-clip-(border|padding|content|text)$/, 'bg-clip'],
84
+ [/^bg-origin-(border|padding|content)$/, 'bg-origin'],
85
+ [/^bg-blend-(normal|multiply|screen|overlay|darken|lighten|color-dodge|color-burn|hard-light|soft-light|difference|exclusion|hue|saturation|color|luminosity)$/, 'bg-blend'],
86
+ [/^bg-/, 'bg-color'],
87
+ // text-shadow is its own property, and its size scale and its colour are two
88
+ // properties again. All three entries precede the text- patterns below, which
89
+ // is what keeps a `text-shadow-*` token out of `text-size` and `text-color`.
90
+ [/^text-shadow(-(2xs|xs|sm|md|lg|none))?(\/([\d.]+|\[[^\]]*\]))?$/, 'text-shadow'],
91
+ [/^text-shadow-(\[(inset|-|\.|\d|var\()|\(--)/, 'text-shadow'],
92
+ [/^text-shadow-/, 'text-shadow-color'],
93
+ // Font size: explicit list of Tailwind size scale.
94
+ [/^text-(xs|sm|base|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl|8xl|9xl)$/, 'text-size'],
95
+ // Alignment, wrapping, and overflow are three more properties under the same
96
+ // prefix. Each was previously excluded from text-color by a lookahead and then
97
+ // matched nothing at all, so two alignments never collapsed.
98
+ [/^text-(left|center|right|justify|start|end)$/, 'text-align'],
99
+ [/^text-(wrap|nowrap|balance|pretty)$/, 'text-wrap'],
100
+ [/^text-(ellipsis|clip)$/, 'text-overflow'],
101
+ // Text color: anything else under the prefix. The specific groups above are
102
+ // the whole carve-out, so no negative lookahead is needed here as well.
103
+ [/^text-/, 'text-color'],
104
+ // Border sub-properties that are neither a width nor a colour. These come
105
+ // FIRST so the width / colour classifier below never sees them.
106
+ [/^border-(collapse|separate)$/, 'border-collapse'],
107
+ [/^border-spacing(-[xy])?-/, 'border-spacing'],
108
+ [/^border-(solid|dashed|dotted|double|hidden|none)$/, 'border-style'],
109
+ ...borderGroups(),
110
+ [/^rounded(-[a-z]+)?$/, 'rounded'],
111
+ [/^rounded-/, 'rounded'],
112
+ [/^opacity-/, 'opacity'],
113
+ [/^font-(thin|light|normal|medium|semibold|bold|black|extralight|extrabold)$/, 'font-weight'],
114
+ // Box-shadow SIZE and box-shadow COLOUR are two properties (`box-shadow` and
115
+ // `--tw-shadow-color`), so they need two groups. `shadow-none` is a size,
116
+ // `shadow-inherit` / `shadow-initial` are colours, and Tailwind accepts an
117
+ // alpha modifier on a size as well as on a colour, so `shadow-lg/25` has to
118
+ // stay on the size side. An unhinted arbitrary value is a SIZE when it opens
119
+ // with `inset`, a sign, a dot, or a digit (a shadow offset list) and also
120
+ // when it is a bare `var()` or the `(--x)` variable shorthand: Tailwind
121
+ // itself resolves an ambiguous arbitrary shadow to `box-shadow` unless the
122
+ // value is provably a colour, and `shadow-[var(--shadow-glow)]` is the normal
123
+ // way to write a design-token shadow. This is the one place the
124
+ // `borderGroups()` convention inverts, because a bare `border-[var(--x)]` is
125
+ // far more often a colour while a bare `shadow-[var(--x)]` is far more often
126
+ // a shadow. The size entries must precede the colour catch-all, or every
127
+ // size lands in the colour group and the bug inverts rather than being fixed.
128
+ [/^shadow(-(2xs|xs|sm|md|lg|xl|2xl|inner|none))?(\/([\d.]+|\[[^\]]*\]))?$/, 'shadow'],
129
+ [/^shadow-(\[(inset|-|\.|\d|var\()|\(--)/, 'shadow'],
130
+ // A bare name the size scale does not list reads as a colour, because
131
+ // `shadow-primary` is overwhelmingly more common than a `@theme`-extended
132
+ // `--shadow-card`. A project that adds a custom shadow NAME is the residual
133
+ // gap, and the docs say so rather than claiming the split is total.
134
+ [/^shadow-/, 'shadow-color'],
135
+ [/^z-/, 'z'],
136
+ // A bare `flex` / `grid` is a DISPLAY value, not a member of the flex / grid
137
+ // sub-property groups below, so it must never dedupe against them: an element
138
+ // can be both a flex container and a flex child (`class="flex flex-1"`), and
139
+ // collapsing the two silently drops `display:flex`. It still belongs to a
140
+ // group of its own, alongside every other display keyword, so a repeated one
141
+ // collapses and `cn('hidden', open && 'flex')` resolves to one display.
142
+ [/^(inline-block|inline-flex|inline-grid|inline-table|inline|block|flex|grid|flow-root|contents|hidden|list-item|table-caption|table-cell|table-column-group|table-column|table-footer-group|table-header-group|table-row-group|table-row|table)$/, 'display'],
143
+ // Each sub-utility below gets the group of the real CSS property it sets, so
144
+ // none of them collapses against the display value or against each other.
145
+ [/^flex-(row|row-reverse|col|col-reverse)$/, 'flex-direction'],
146
+ [/^flex-(wrap|wrap-reverse|nowrap)$/, 'flex-wrap'],
147
+ [/^flex-(\d+|auto|initial|none|\[[^\]]*\])$/, 'flex'],
148
+ [/^grid-cols-/, 'grid-cols'],
149
+ [/^grid-rows-/, 'grid-rows'],
150
+ [/^grid-flow-/, 'grid-flow'],
151
+ ]);
152
+ }
146
153
 
147
154
  /**
148
155
  * Border WIDTH and border COLOUR share the `border-` prefix but are different
@@ -155,9 +162,11 @@ const GROUPS: Array<[RegExp, string]> = [
155
162
  * Classification, after an optional side / axis segment: a bare or numeric
156
163
  * value is a width (`border`, `border-2`, `border-t-4`), an arbitrary value
157
164
  * that opens with a digit, a dot, or `length:` is a width (`border-[3px]`,
158
- * `border-[length:var(--w)]`), and everything else is a colour
165
+ * `border-[length:var(--w)]`, and the v4 paren spelling
166
+ * `border-(length:--w)`), and everything else is a colour
159
167
  * (`border-primary`, `border-red-500/50`, `border-[#fff]`). An ambiguous
160
- * `border-[var(--x)]` falls to colour, which is the far more common intent.
168
+ * `border-[var(--x)]`, or its paren spelling `border-(--x)`, falls to colour,
169
+ * which is the far more common intent.
161
170
  *
162
171
  * Each side is its own group so a per-side utility only overrides its own side,
163
172
  * and the shorthand / axis subsumption is declared in CONFLICTS below, exactly
@@ -167,7 +176,7 @@ const GROUPS: Array<[RegExp, string]> = [
167
176
  * the writing direction, so an axis override there would be a guess.
168
177
  */
169
178
  function borderGroups(): Array<[RegExp, string]> {
170
- const width = '(\\d+(\\.\\d+)?|\\[(length:|\\d|\\.)[^\\]]*\\])';
179
+ const width = '(\\d+(\\.\\d+)?|\\[(length:|\\d|\\.)[^\\]]*\\]|\\(length:[^)]*\\))';
171
180
  const out: Array<[RegExp, string]> = [];
172
181
  // Sides before the bare form: `border-t-primary` must match the `t` colour
173
182
  // group, not be swallowed by the side-less `^border-` colour pattern.
@@ -185,7 +194,8 @@ function borderGroups(): Array<[RegExp, string]> {
185
194
 
186
195
  /**
187
196
  * An arbitrary value may carry a Tailwind TYPE HINT: `bg-[image:var(--g)]`,
188
- * `text-[length:14px]`, `shadow-[color:red]`. The hint exists precisely because
197
+ * `text-[length:14px]`, `shadow-[color:red]`, and in the v4 paren shorthand
198
+ * `bg-(image:--g)`, `shadow-(color:--x)`. The hint exists precisely because
189
199
  * the utility prefix is ambiguous, so the PREFIX cannot decide the group and
190
200
  * the hint must. Letting a hinted value fall into the prefix's default group
191
201
  * evicts a class that sets an entirely different property (`shadow-[color:red]`
@@ -204,7 +214,13 @@ function borderGroups(): Array<[RegExp, string]> {
204
214
  * while `bg-` and `text-` were correct.
205
215
  *
206
216
  * These reach the matcher at all only because `variantPrefix` splits on the
207
- * last colon OUTSIDE brackets; see the comment there.
217
+ * last colon OUTSIDE brackets AND parentheses; see the comment there.
218
+ *
219
+ * Both spellings of one hint produce the identical `<prefix>:<hint>` key, so
220
+ * the map needs no paren-specific entries. The closing delimiter is
221
+ * deliberately not validated: the hint names the CSS property, and how the
222
+ * value terminates cannot change which property that is (the bracket branch
223
+ * has never validated its own close either).
208
224
  */
209
225
  const HINTED_GROUPS: Record<string, string> = {
210
226
  'bg:color': '',
@@ -227,7 +243,7 @@ const HINTED_GROUPS: Record<string, string> = {
227
243
  * should decide it, `undefined` when the token carries no hint at all.
228
244
  */
229
245
  function hintedGroup(bare: string): string | null | undefined {
230
- const m = /^([a-z][a-z-]*)-\[([a-z][a-z-]*):/.exec(bare);
246
+ const m = /^([a-z][a-z-]*)-[\[(]([a-z][a-z-]*):/.exec(bare);
231
247
  if (!m) return undefined;
232
248
  const prefix = m[1];
233
249
  const hint = m[2];
@@ -283,7 +299,7 @@ function dedupeUtilities(input: string): string {
283
299
  const hinted = hintedGroup(bare);
284
300
  let gk: string | null = hinted ?? null;
285
301
  if (hinted === undefined || hinted === null) {
286
- for (const [re, g] of GROUPS) {
302
+ for (const [re, g] of GROUPS()) {
287
303
  if (re.test(bare)) { gk = g; break; }
288
304
  }
289
305
  }
@@ -303,19 +319,32 @@ function dedupeUtilities(input: string): string {
303
319
 
304
320
  function variantPrefix(token: string): string {
305
321
  // Capture leading variants like `hover:`, `dark:`, `md:`: overrides only
306
- // conflict within the same variant set. The split is the last colon OUTSIDE
307
- // square brackets, because an arbitrary value can contain one of its own
308
- // (`border-[length:2px]`, `bg-[url(https://x/y.png)]`, `text-[color:var(--c)]`).
322
+ // conflict within the same variant set. The split is the last colon at top
323
+ // level, meaning outside BOTH square brackets and parentheses, because an
324
+ // arbitrary value can contain a colon of its own in either delimiter
325
+ // (`border-[length:2px]`, `bg-[url(https://x/y.png)]`, `text-[color:var(--c)]`,
326
+ // and the v4 paren spelling `shadow-(color:--x)`, `bg-(image:--g)`).
309
327
  // Splitting on the last colon anywhere hands the group matcher a fragment
310
- // like `2px]`, which matches nothing, so the utility silently stops
311
- // deduping against its own property.
312
- let depth = 0;
328
+ // like `2px]` or `--x)`, which matches nothing, so the utility silently
329
+ // stops deduping against its own property.
330
+ //
331
+ // TWO counters, both required to be zero, rather than one shared counter.
332
+ // Tailwind's own top-level splitter (`segment()`, in the upstream
333
+ // tailwindlabs/tailwindcss repo at packages/tailwindcss/src/utils/segment.ts,
334
+ // NOT a path in this repo) is a MATCHED-PAIR stack, so a
335
+ // stray closer never cancels a live opener of the other kind. A single
336
+ // shared counter would let `)` cancel `[` and read `x-[y)-z:w` as having a
337
+ // top-level colon, which is the fragment bug above wearing a different hat.
338
+ let bracketDepth = 0;
339
+ let parenDepth = 0;
313
340
  let i = -1;
314
341
  for (let n = 0; n < token.length; n += 1) {
315
342
  const c = token[n];
316
- if (c === '[') depth += 1;
317
- else if (c === ']') depth -= 1;
318
- else if (c === ':' && depth === 0) i = n;
343
+ if (c === '[') bracketDepth += 1;
344
+ else if (c === ']') bracketDepth -= 1;
345
+ else if (c === '(') parenDepth += 1;
346
+ else if (c === ')') parenDepth -= 1;
347
+ else if (c === ':' && bracketDepth === 0 && parenDepth === 0) i = n;
319
348
  }
320
349
  return i === -1 ? '' : token.slice(0, i + 1);
321
350
  }
@@ -149,4 +149,17 @@
149
149
  outline: 2px solid color-mix(in oklab, var(--color-ring) 50%, transparent);
150
150
  outline-offset: 2px;
151
151
  }
152
+ /* Native <select> options paint transparent over the browser popup when no
153
+ rule applies, so in dark mode Chrome's dark popup plus the inherited text
154
+ colour makes every unselected option disappear. Canvas / CanvasText are
155
+ the system colours the browser would have painted anyway, so this stops
156
+ relying on inheritance to pull them through. Two ELEMENT selectors
157
+ (specificity 0,0,2) and no wrapper requirement on purpose: a bare
158
+ <select class=${nativeSelectClass()}> with no wrapper is legitimate, and
159
+ any single class overrides this. */
160
+ select option,
161
+ select optgroup {
162
+ background-color: Canvas;
163
+ color: CanvasText;
164
+ }
152
165
  }