@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 +3 -2
- package/packages/registry/components/alert-dialog.ts +19 -7
- package/packages/registry/components/dialog.ts +18 -7
- package/packages/registry/components/native-select.ts +17 -59
- package/packages/registry/components/sonner.ts +51 -17
- package/packages/registry/lib/utils.ts +138 -109
- package/packages/registry/themes/index.css +13 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/ui",
|
|
3
|
-
"version": "0.3.
|
|
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
|
|
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-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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-
|
|
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
|
|
12
|
+
* for explicit overrides, since the theme
|
|
13
13
|
* stylesheet sets Canvas / CanvasText on
|
|
14
14
|
* every <option> automatically)
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
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
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
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
|
|
36
|
-
*
|
|
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
|
|
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
|
|
251
|
-
//
|
|
252
|
-
//
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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
|
|
320
|
-
//
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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)]
|
|
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,
|
|
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]
|
|
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-]*)
|
|
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
|
|
307
|
-
//
|
|
308
|
-
//
|
|
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
|
|
311
|
-
// deduping against its own property.
|
|
312
|
-
|
|
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 === '[')
|
|
317
|
-
else if (c === ']')
|
|
318
|
-
else if (c === '
|
|
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
|
}
|