@webjsdev/ui 0.3.5 → 0.3.7
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/README.md +20 -2
- package/package.json +3 -2
- package/packages/registry/components/alert-dialog.ts +61 -34
- package/packages/registry/components/alert.ts +5 -0
- package/packages/registry/components/avatar.ts +5 -0
- package/packages/registry/components/badge.ts +4 -0
- package/packages/registry/components/breadcrumb.ts +5 -0
- package/packages/registry/components/button.ts +6 -0
- package/packages/registry/components/dialog.ts +63 -24
- package/packages/registry/components/dropdown-menu.ts +89 -30
- package/packages/registry/components/hover-card.ts +119 -17
- package/packages/registry/components/pagination.ts +5 -0
- package/packages/registry/components/progress.ts +5 -0
- package/packages/registry/components/separator.ts +5 -0
- package/packages/registry/components/skeleton.ts +5 -0
- package/packages/registry/components/sonner.ts +57 -17
- package/packages/registry/components/table.ts +7 -2
- package/packages/registry/components/tabs.ts +59 -30
- package/packages/registry/components/toggle-group.ts +70 -28
- package/packages/registry/components/toggle.ts +7 -13
- package/packages/registry/components/tooltip.ts +46 -11
- package/packages/registry/lib/utils.ts +43 -0
- package/packages/registry/registry.json +14 -1
|
@@ -55,6 +55,9 @@
|
|
|
55
55
|
* `type`: omit (default) | "checkbox" | "radio".
|
|
56
56
|
* `checked`: boolean. Applies to checkbox / radio items.
|
|
57
57
|
* `value`: string. Identifier for radio items.
|
|
58
|
+
* `data-disabled`: boolean. Skips keyboard focus and activation, dims the
|
|
59
|
+
* item, and sets aria-disabled. Same attribute on a
|
|
60
|
+
* <ui-dropdown-menu-sub-trigger> disables the submenu.
|
|
58
61
|
*
|
|
59
62
|
* Events:
|
|
60
63
|
* `ui-open-change` on <ui-dropdown-menu>: `{ detail: { open } }` after a transition.
|
|
@@ -74,7 +77,8 @@
|
|
|
74
77
|
* Design tokens used: --popover, --popover-foreground, --accent,
|
|
75
78
|
* --accent-foreground, --destructive, --muted-foreground, --border.
|
|
76
79
|
*/
|
|
77
|
-
import { WebComponent, html, unsafeHTML, signal } from '@webjsdev/core';
|
|
80
|
+
import { WebComponent, html, unsafeHTML, signal, prop } from '@webjsdev/core';
|
|
81
|
+
import { ensureId, onBeforeCache } from '../lib/utils.ts';
|
|
78
82
|
import { positionFloating, type PopoverSide, type PopoverAlign } from './popover.ts';
|
|
79
83
|
|
|
80
84
|
// --------------------------------------------------------------------------
|
|
@@ -116,12 +120,9 @@ const SUB_CLOSE_DELAY = 200;
|
|
|
116
120
|
// <ui-dropdown-menu>
|
|
117
121
|
// --------------------------------------------------------------------------
|
|
118
122
|
|
|
119
|
-
export class UiDropdownMenu extends WebComponent
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
};
|
|
123
|
-
declare open: boolean;
|
|
124
|
-
|
|
123
|
+
export class UiDropdownMenu extends WebComponent({
|
|
124
|
+
open: prop(Boolean, { reflect: true }),
|
|
125
|
+
}) {
|
|
125
126
|
_typeBuffer = '';
|
|
126
127
|
_typeBufferTimer: number | undefined;
|
|
127
128
|
|
|
@@ -134,8 +135,11 @@ export class UiDropdownMenu extends WebComponent {
|
|
|
134
135
|
this.open = false;
|
|
135
136
|
}
|
|
136
137
|
|
|
138
|
+
_disposeBeforeCache?: () => void;
|
|
139
|
+
|
|
137
140
|
disconnectedCallback(): void {
|
|
138
141
|
if (this.open) this._teardown();
|
|
142
|
+
this._disposeBeforeCache?.();
|
|
139
143
|
super.disconnectedCallback?.();
|
|
140
144
|
}
|
|
141
145
|
|
|
@@ -156,15 +160,61 @@ export class UiDropdownMenu extends WebComponent {
|
|
|
156
160
|
queueMicrotask(() => this._afterRender());
|
|
157
161
|
}
|
|
158
162
|
|
|
163
|
+
connectedCallback(): void {
|
|
164
|
+
super.connectedCallback?.();
|
|
165
|
+
// webjs projects slotted light-DOM children in a pass after the first
|
|
166
|
+
// render, so the trigger button and the menu are not in place at
|
|
167
|
+
// connect. Defer to the next frame, when the projection has run.
|
|
168
|
+
if (typeof requestAnimationFrame === 'function') {
|
|
169
|
+
requestAnimationFrame(() => this._wireAria());
|
|
170
|
+
}
|
|
171
|
+
// Close (and tear down) before a back/forward snapshot so the menu does not
|
|
172
|
+
// restore open (#766). Closing the root also hides any open submenu.
|
|
173
|
+
this._disposeBeforeCache = onBeforeCache(() => { this.open = false; });
|
|
174
|
+
}
|
|
175
|
+
|
|
159
176
|
_afterRender(): void {
|
|
160
177
|
const content = this._content();
|
|
161
178
|
if (content) {
|
|
162
179
|
this._syncContentPopover(content);
|
|
163
180
|
}
|
|
181
|
+
this._wireAria();
|
|
164
182
|
if (this.open) this._setup();
|
|
165
183
|
else this._teardown();
|
|
166
184
|
}
|
|
167
185
|
|
|
186
|
+
// The trigger wraps an author-supplied control (usually a <button>). Expose
|
|
187
|
+
// the menu relationship on that focusable control: aria-haspopup announces
|
|
188
|
+
// it opens a menu, aria-expanded tracks open state, aria-controls points at
|
|
189
|
+
// the menu, and the menu is labelled back by the trigger. Done at runtime
|
|
190
|
+
// because the menu is JS-driven (never shown without script).
|
|
191
|
+
_triggerControl(): HTMLElement | null {
|
|
192
|
+
const trigger = this.querySelector('ui-dropdown-menu-trigger');
|
|
193
|
+
if (!trigger) return null;
|
|
194
|
+
return (
|
|
195
|
+
trigger.querySelector<HTMLElement>('button, [role="button"], a[href], [tabindex]') ??
|
|
196
|
+
(trigger as HTMLElement)
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
_menuEl(): HTMLElement | null {
|
|
201
|
+
return this.querySelector('ui-dropdown-menu-content [role="menu"]');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
_wireAria(): void {
|
|
205
|
+
const control = this._triggerControl();
|
|
206
|
+
if (!control) return;
|
|
207
|
+
control.setAttribute('aria-haspopup', 'menu');
|
|
208
|
+
control.setAttribute('aria-expanded', String(this.open));
|
|
209
|
+
const menu = this._menuEl();
|
|
210
|
+
if (!menu) return;
|
|
211
|
+
const menuId = ensureId(menu, 'ui-menu');
|
|
212
|
+
control.setAttribute('aria-controls', menuId);
|
|
213
|
+
if (!menu.hasAttribute('aria-label') && !menu.hasAttribute('aria-labelledby')) {
|
|
214
|
+
menu.setAttribute('aria-labelledby', ensureId(control, 'ui-menu-trigger'));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
168
218
|
_content(): HTMLElement | null {
|
|
169
219
|
return this.querySelector('ui-dropdown-menu-content [popover]');
|
|
170
220
|
}
|
|
@@ -329,6 +379,7 @@ export class UiDropdownMenuContent extends WebComponent {
|
|
|
329
379
|
return html`<div
|
|
330
380
|
data-slot="dropdown-menu-content"
|
|
331
381
|
role="menu"
|
|
382
|
+
aria-orientation="vertical"
|
|
332
383
|
popover="manual"
|
|
333
384
|
class=${dropdownMenuContentClass()}
|
|
334
385
|
><slot></slot></div>`;
|
|
@@ -340,14 +391,10 @@ UiDropdownMenuContent.register('ui-dropdown-menu-content');
|
|
|
340
391
|
// <ui-dropdown-menu-item>
|
|
341
392
|
// --------------------------------------------------------------------------
|
|
342
393
|
|
|
343
|
-
export class UiDropdownMenuItem extends WebComponent
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
};
|
|
348
|
-
declare variant: 'default' | 'destructive';
|
|
349
|
-
declare inset: boolean;
|
|
350
|
-
|
|
394
|
+
export class UiDropdownMenuItem extends WebComponent({
|
|
395
|
+
variant: prop<'default' | 'destructive'>(String, { reflect: true }),
|
|
396
|
+
inset: Boolean,
|
|
397
|
+
}) {
|
|
351
398
|
// Keyboard / pointer highlight state for the own-rendered menuitem. A
|
|
352
399
|
// local signal bound with ?data-highlighted keeps the highlight in the
|
|
353
400
|
// declarative template instead of an imperative setAttribute on
|
|
@@ -361,12 +408,19 @@ export class UiDropdownMenuItem extends WebComponent {
|
|
|
361
408
|
}
|
|
362
409
|
|
|
363
410
|
render() {
|
|
411
|
+
// `data-disabled` on the host is the historical disabled marker (focus
|
|
412
|
+
// skips it, the click / pointer handlers bail on it). Mirror it onto the
|
|
413
|
+
// inner menuitem as both data-disabled (CSS) and aria-disabled, so the
|
|
414
|
+
// state also reaches assistive tech.
|
|
415
|
+
const disabled = typeof this.hasAttribute === 'function' && this.hasAttribute('data-disabled');
|
|
364
416
|
return html`<div
|
|
365
417
|
data-slot="dropdown-menu-item"
|
|
366
418
|
role="menuitem"
|
|
367
419
|
tabindex="-1"
|
|
368
420
|
data-variant=${this.variant}
|
|
369
421
|
?data-inset=${this.inset}
|
|
422
|
+
?data-disabled=${disabled}
|
|
423
|
+
aria-disabled=${disabled ? 'true' : 'false'}
|
|
370
424
|
?data-highlighted=${this.#highlighted.get()}
|
|
371
425
|
class=${dropdownMenuItemClass()}
|
|
372
426
|
@click=${this._onClick}
|
|
@@ -402,10 +456,7 @@ UiDropdownMenuItem.register('ui-dropdown-menu-item');
|
|
|
402
456
|
// <ui-dropdown-menu-label>
|
|
403
457
|
// --------------------------------------------------------------------------
|
|
404
458
|
|
|
405
|
-
export class UiDropdownMenuLabel extends WebComponent {
|
|
406
|
-
static properties = { inset: { type: Boolean } };
|
|
407
|
-
declare inset: boolean;
|
|
408
|
-
|
|
459
|
+
export class UiDropdownMenuLabel extends WebComponent({ inset: Boolean }) {
|
|
409
460
|
constructor() {
|
|
410
461
|
super();
|
|
411
462
|
this.inset = false;
|
|
@@ -468,12 +519,9 @@ UiDropdownMenuGroup.register('ui-dropdown-menu-group');
|
|
|
468
519
|
// Submenu: Sub / SubTrigger / SubContent
|
|
469
520
|
// --------------------------------------------------------------------------
|
|
470
521
|
|
|
471
|
-
export class UiDropdownMenuSub extends WebComponent
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
};
|
|
475
|
-
declare open: boolean;
|
|
476
|
-
|
|
522
|
+
export class UiDropdownMenuSub extends WebComponent({
|
|
523
|
+
open: prop(Boolean, { reflect: true }),
|
|
524
|
+
}) {
|
|
477
525
|
_closeTimer: number | undefined;
|
|
478
526
|
|
|
479
527
|
constructor() {
|
|
@@ -522,7 +570,13 @@ export class UiDropdownMenuSub extends WebComponent {
|
|
|
522
570
|
}
|
|
523
571
|
|
|
524
572
|
_cancelCloseHandler = (): void => this._cancelClose();
|
|
525
|
-
|
|
573
|
+
// Hover-close is a mouse affordance. On touch, lifting the finger fires
|
|
574
|
+
// pointerleave; without this guard a tap-opened submenu would close itself
|
|
575
|
+
// ~200ms after the tap. On touch the submenu stays open until tapped again.
|
|
576
|
+
_scheduleCloseHandler = (e: Event): void => {
|
|
577
|
+
if ((e as PointerEvent).pointerType === 'touch') return;
|
|
578
|
+
this._scheduleClose();
|
|
579
|
+
};
|
|
526
580
|
|
|
527
581
|
_scheduleClose(): void {
|
|
528
582
|
this._cancelClose();
|
|
@@ -555,10 +609,7 @@ export class UiDropdownMenuSub extends WebComponent {
|
|
|
555
609
|
}
|
|
556
610
|
UiDropdownMenuSub.register('ui-dropdown-menu-sub');
|
|
557
611
|
|
|
558
|
-
export class UiDropdownMenuSubTrigger extends WebComponent {
|
|
559
|
-
static properties = { inset: { type: Boolean } };
|
|
560
|
-
declare inset: boolean;
|
|
561
|
-
|
|
612
|
+
export class UiDropdownMenuSubTrigger extends WebComponent({ inset: Boolean }) {
|
|
562
613
|
constructor() {
|
|
563
614
|
super();
|
|
564
615
|
this.inset = false;
|
|
@@ -572,14 +623,17 @@ export class UiDropdownMenuSubTrigger extends WebComponent {
|
|
|
572
623
|
|
|
573
624
|
render() {
|
|
574
625
|
const open = !!this._sub()?.open;
|
|
626
|
+
const disabled = typeof this.hasAttribute === 'function' && this.hasAttribute('data-disabled');
|
|
575
627
|
return html`<div
|
|
576
628
|
data-slot="dropdown-menu-sub-trigger"
|
|
577
629
|
role="menuitem"
|
|
578
630
|
tabindex="-1"
|
|
579
631
|
aria-haspopup="menu"
|
|
580
632
|
aria-expanded=${String(open)}
|
|
633
|
+
aria-disabled=${disabled ? 'true' : 'false'}
|
|
581
634
|
data-state=${open ? 'open' : 'closed'}
|
|
582
635
|
?data-inset=${this.inset}
|
|
636
|
+
?data-disabled=${disabled}
|
|
583
637
|
class=${dropdownMenuSubTriggerClass()}
|
|
584
638
|
@click=${this._onClick}
|
|
585
639
|
@pointerenter=${this._onPointerEnter}
|
|
@@ -593,6 +647,10 @@ export class UiDropdownMenuSubTrigger extends WebComponent {
|
|
|
593
647
|
};
|
|
594
648
|
|
|
595
649
|
_onPointerEnter = (e: Event): void => {
|
|
650
|
+
// Hover-open is a mouse affordance. On touch there is no hover: a tap
|
|
651
|
+
// fires pointerenter on finger-down, which would open the submenu only for
|
|
652
|
+
// the following click to toggle it shut. On touch, @click is the opener.
|
|
653
|
+
if ((e as PointerEvent).pointerType === 'touch') return;
|
|
596
654
|
const el = e.currentTarget as HTMLElement;
|
|
597
655
|
if (el.hasAttribute('data-disabled')) return;
|
|
598
656
|
el.focus();
|
|
@@ -606,6 +664,7 @@ export class UiDropdownMenuSubContent extends WebComponent {
|
|
|
606
664
|
return html`<div
|
|
607
665
|
data-slot="dropdown-menu-sub-content"
|
|
608
666
|
role="menu"
|
|
667
|
+
aria-orientation="vertical"
|
|
609
668
|
popover="manual"
|
|
610
669
|
class=${dropdownMenuSubContentClass()}
|
|
611
670
|
><slot></slot></div>`;
|
|
@@ -38,7 +38,8 @@
|
|
|
38
38
|
*
|
|
39
39
|
* Design tokens used: --popover, --popover-foreground, --border.
|
|
40
40
|
*/
|
|
41
|
-
import { WebComponent, html } from '@webjsdev/core';
|
|
41
|
+
import { WebComponent, html, prop } from '@webjsdev/core';
|
|
42
|
+
import { ensureId, onBeforeCache } from '../lib/utils.ts';
|
|
42
43
|
import { positionFloating, type PopoverSide, type PopoverAlign } from './popover.ts';
|
|
43
44
|
|
|
44
45
|
// `fixed m-0` opts out of the UA `[popover]` auto-centering margin so
|
|
@@ -52,18 +53,13 @@ export const hoverCardContentClass = (): string =>
|
|
|
52
53
|
// <ui-hover-card>
|
|
53
54
|
// --------------------------------------------------------------------------
|
|
54
55
|
|
|
55
|
-
export class UiHoverCard extends WebComponent
|
|
56
|
-
|
|
57
|
-
open: { type: Boolean, reflect: true },
|
|
58
|
-
openDelay: { type: Number },
|
|
59
|
-
closeDelay: { type: Number },
|
|
60
|
-
};
|
|
61
|
-
declare open: boolean;
|
|
56
|
+
export class UiHoverCard extends WebComponent({
|
|
57
|
+
open: prop(Boolean, { reflect: true }),
|
|
62
58
|
// `openDelay` / `closeDelay` ride the `open-delay` / `close-delay`
|
|
63
59
|
// attributes (shadcn parity), read as typed props.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
60
|
+
openDelay: Number,
|
|
61
|
+
closeDelay: Number,
|
|
62
|
+
}) {
|
|
67
63
|
_showTimer: number | undefined;
|
|
68
64
|
_hideTimer: number | undefined;
|
|
69
65
|
|
|
@@ -74,6 +70,47 @@ export class UiHoverCard extends WebComponent {
|
|
|
74
70
|
this.closeDelay = 300;
|
|
75
71
|
}
|
|
76
72
|
|
|
73
|
+
_disposeBeforeCache?: () => void;
|
|
74
|
+
|
|
75
|
+
connectedCallback(): void {
|
|
76
|
+
super.connectedCallback?.();
|
|
77
|
+
// webjs projects slotted light-DOM children after the first render, so
|
|
78
|
+
// the trigger control is not in place at connect. Defer to the next
|
|
79
|
+
// frame, when the projection has run.
|
|
80
|
+
if (typeof requestAnimationFrame === 'function') {
|
|
81
|
+
requestAnimationFrame(() => this._wireAria());
|
|
82
|
+
}
|
|
83
|
+
// Close before the page is cached for back/forward so a restored snapshot
|
|
84
|
+
// does not come back frozen open (#766).
|
|
85
|
+
this._disposeBeforeCache = onBeforeCache(() => { this.open = false; });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
disconnectedCallback(): void {
|
|
89
|
+
this._disposeBeforeCache?.();
|
|
90
|
+
super.disconnectedCallback?.();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// The trigger also opens on focus (see the @focusin handler), so it is
|
|
94
|
+
// keyboard-reachable: expose the popup relationship on the focusable
|
|
95
|
+
// control. aria-expanded is refreshed on every open transition.
|
|
96
|
+
_control(): HTMLElement | null {
|
|
97
|
+
const t = this.querySelector('ui-hover-card-trigger');
|
|
98
|
+
if (!t) return null;
|
|
99
|
+
return (
|
|
100
|
+
t.querySelector<HTMLElement>('a[href], button, [tabindex], [role="button"]') ??
|
|
101
|
+
(t as HTMLElement)
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
_wireAria(): void {
|
|
106
|
+
const control = this._control();
|
|
107
|
+
if (!control) return;
|
|
108
|
+
control.setAttribute('aria-haspopup', 'dialog');
|
|
109
|
+
control.setAttribute('aria-expanded', String(this.open));
|
|
110
|
+
const content = this.querySelector<HTMLElement>('ui-hover-card-content [role="dialog"]');
|
|
111
|
+
if (content) control.setAttribute('aria-controls', ensureId(content, 'ui-hovercard'));
|
|
112
|
+
}
|
|
113
|
+
|
|
77
114
|
// Back-compat getter.
|
|
78
115
|
get isOpen(): boolean { return this.open; }
|
|
79
116
|
|
|
@@ -87,6 +124,25 @@ export class UiHoverCard extends WebComponent {
|
|
|
87
124
|
this._hideTimer = window.setTimeout(() => { this.open = false; }, this.closeDelay);
|
|
88
125
|
}
|
|
89
126
|
|
|
127
|
+
// Touch open: there is no hover delay and no mouseleave to close it, so open
|
|
128
|
+
// immediately and arm a one-shot outside-tap dismiss (a tap anywhere outside
|
|
129
|
+
// this card closes it). Deferred a tick so the opening tap itself does not
|
|
130
|
+
// immediately dismiss it.
|
|
131
|
+
openByTouch(): void {
|
|
132
|
+
clearTimeout(this._showTimer);
|
|
133
|
+
clearTimeout(this._hideTimer);
|
|
134
|
+
this.open = true;
|
|
135
|
+
const onOutside = (ev: Event): void => {
|
|
136
|
+
// Close on an outside tap; also self-remove if the card was already
|
|
137
|
+
// closed by other means (a re-tap toggle), so the listener never lingers.
|
|
138
|
+
if (!this.open || !this.contains(ev.target as Node)) {
|
|
139
|
+
this.open = false;
|
|
140
|
+
document.removeEventListener('pointerdown', onOutside, true);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
setTimeout(() => document.addEventListener('pointerdown', onOutside, true), 0);
|
|
144
|
+
}
|
|
145
|
+
|
|
90
146
|
render() {
|
|
91
147
|
return html`<div
|
|
92
148
|
data-slot="hover-card"
|
|
@@ -98,8 +154,12 @@ export class UiHoverCard extends WebComponent {
|
|
|
98
154
|
if (!changedProperties.has('open')) return;
|
|
99
155
|
if (changedProperties.get('open') === undefined) return;
|
|
100
156
|
// Wait one microtask for <ui-hover-card-content>'s inner [popover]
|
|
101
|
-
// element to commit; we drive its showPopover() / hidePopover()
|
|
102
|
-
|
|
157
|
+
// element to commit; we drive its showPopover() / hidePopover() and
|
|
158
|
+
// refresh the trigger's aria-expanded.
|
|
159
|
+
queueMicrotask(() => {
|
|
160
|
+
this._wireAria();
|
|
161
|
+
this._syncContent();
|
|
162
|
+
});
|
|
103
163
|
}
|
|
104
164
|
|
|
105
165
|
_syncContent(): void {
|
|
@@ -148,11 +208,41 @@ export class UiHoverCardTrigger extends WebComponent {
|
|
|
148
208
|
@mouseleave=${this._onLeave}
|
|
149
209
|
@focusin=${this._onEnter}
|
|
150
210
|
@focusout=${this._onLeave}
|
|
211
|
+
@click=${this._onClick}
|
|
151
212
|
><slot></slot></div>`;
|
|
152
213
|
}
|
|
153
214
|
|
|
154
|
-
|
|
155
|
-
|
|
215
|
+
// Hover/focus open + close are MOUSE affordances. On a no-hover (touch)
|
|
216
|
+
// device, iOS Safari still fires SYNTHETIC mouseenter/mouseleave (and
|
|
217
|
+
// focusin/focusout from the inner link) around a tap, which would otherwise
|
|
218
|
+
// immediately re-close a tap-opened card. Gate the hover handlers to pointer
|
|
219
|
+
// devices so on touch the card is driven only by the tap path (#745).
|
|
220
|
+
_noHover = (): boolean => !!window.matchMedia?.('(hover: none)').matches;
|
|
221
|
+
_onEnter = (): void => {
|
|
222
|
+
if (this._noHover()) return;
|
|
223
|
+
(this.closest('ui-hover-card') as UiHoverCard | null)?.show();
|
|
224
|
+
};
|
|
225
|
+
_onLeave = (): void => {
|
|
226
|
+
if (this._noHover()) return;
|
|
227
|
+
(this.closest('ui-hover-card') as UiHoverCard | null)?.hide();
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
// Touch path. A touch device has no `mouseenter`, so a tap would fall through
|
|
231
|
+
// to the inner `<a href>` and navigate. On a no-hover device the trigger tap
|
|
232
|
+
// TOGGLES the card and NEVER navigates (the real link is reachable inside the
|
|
233
|
+
// opened card). It must ALWAYS preventDefault, including while open: the
|
|
234
|
+
// client router pushState()s on any bubble-phase `<a>` click that is not
|
|
235
|
+
// defaultPrevented, so a re-tap that fell through would push a history entry
|
|
236
|
+
// every time and Back would need N presses (#745).
|
|
237
|
+
_onClick = (e: Event): void => {
|
|
238
|
+
if (!window.matchMedia?.('(hover: none)').matches) return;
|
|
239
|
+
const card = this.closest('ui-hover-card') as UiHoverCard | null;
|
|
240
|
+
if (!card) return;
|
|
241
|
+
e.preventDefault();
|
|
242
|
+
e.stopPropagation();
|
|
243
|
+
if (card.open) card.open = false;
|
|
244
|
+
else card.openByTouch();
|
|
245
|
+
};
|
|
156
246
|
}
|
|
157
247
|
UiHoverCardTrigger.register('ui-hover-card-trigger');
|
|
158
248
|
|
|
@@ -175,7 +265,19 @@ export class UiHoverCardContent extends WebComponent {
|
|
|
175
265
|
><slot></slot></div>`;
|
|
176
266
|
}
|
|
177
267
|
|
|
178
|
-
|
|
179
|
-
|
|
268
|
+
// Hover/focus open + close are MOUSE affordances. On a no-hover (touch)
|
|
269
|
+
// device, iOS Safari still fires SYNTHETIC mouseenter/mouseleave (and
|
|
270
|
+
// focusin/focusout from the inner link) around a tap, which would otherwise
|
|
271
|
+
// immediately re-close a tap-opened card. Gate the hover handlers to pointer
|
|
272
|
+
// devices so on touch the card is driven only by the tap path (#745).
|
|
273
|
+
_noHover = (): boolean => !!window.matchMedia?.('(hover: none)').matches;
|
|
274
|
+
_onEnter = (): void => {
|
|
275
|
+
if (this._noHover()) return;
|
|
276
|
+
(this.closest('ui-hover-card') as UiHoverCard | null)?.show();
|
|
277
|
+
};
|
|
278
|
+
_onLeave = (): void => {
|
|
279
|
+
if (this._noHover()) return;
|
|
280
|
+
(this.closest('ui-hover-card') as UiHoverCard | null)?.hide();
|
|
281
|
+
};
|
|
180
282
|
}
|
|
181
283
|
UiHoverCardContent.register('ui-hover-card-content');
|
|
@@ -23,6 +23,11 @@
|
|
|
23
23
|
* </ul>
|
|
24
24
|
* </nav>
|
|
25
25
|
*
|
|
26
|
+
* A11y (required for accessible output): wrap the list in <nav
|
|
27
|
+
* aria-label="pagination">, set aria-current="page" on the active page
|
|
28
|
+
* link, give an icon-only Previous / Next control an aria-label, and mark
|
|
29
|
+
* the ellipsis aria-hidden="true". The class helpers emit none of these.
|
|
30
|
+
*
|
|
26
31
|
* Design tokens used: inherited from buttonClass.
|
|
27
32
|
*/
|
|
28
33
|
import { cn } from '../lib/utils.ts';
|
|
@@ -16,6 +16,11 @@
|
|
|
16
16
|
* <!-- Indeterminate (no `value` attribute): -->
|
|
17
17
|
* <progress class=${progressClass()}></progress>
|
|
18
18
|
*
|
|
19
|
+
* A11y (required for accessible output): give the <progress> an accessible
|
|
20
|
+
* name with aria-label (or a <label for>), e.g. aria-label="Upload
|
|
21
|
+
* progress". The native element supplies the role and value; only the name
|
|
22
|
+
* is the author's responsibility.
|
|
23
|
+
*
|
|
19
24
|
* Design tokens used: --primary.
|
|
20
25
|
*/
|
|
21
26
|
|
|
@@ -13,6 +13,11 @@
|
|
|
13
13
|
* class=${separatorClass({ orientation: 'vertical' })}
|
|
14
14
|
* data-orientation="vertical"></div>
|
|
15
15
|
*
|
|
16
|
+
* A11y (required for accessible output): a meaningful divider needs
|
|
17
|
+
* role="separator" plus aria-orientation. A purely decorative one needs
|
|
18
|
+
* role="none" (or aria-hidden="true") so assistive tech does not announce
|
|
19
|
+
* an empty separator.
|
|
20
|
+
*
|
|
16
21
|
* Design tokens used: --border.
|
|
17
22
|
*/
|
|
18
23
|
import { cn } from '../lib/utils.ts';
|
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
* <div class=${cn(skeletonClass(), 'h-4 w-32')}></div>
|
|
11
11
|
* <div class=${cn(skeletonClass(), 'h-12 w-12 rounded-full')}></div>
|
|
12
12
|
*
|
|
13
|
+
* A11y (required for accessible output): a skeleton is a decorative
|
|
14
|
+
* placeholder, so hide it from assistive tech with aria-hidden="true" (or
|
|
15
|
+
* mark the loading region aria-busy="true"). Announce the real content
|
|
16
|
+
* once it replaces the skeleton.
|
|
17
|
+
*
|
|
13
18
|
* Design tokens used: --accent.
|
|
14
19
|
*/
|
|
15
20
|
|
|
@@ -43,7 +43,8 @@
|
|
|
43
43
|
*
|
|
44
44
|
* Design tokens used: --popover, --popover-foreground, --border, --radius.
|
|
45
45
|
*/
|
|
46
|
-
import { WebComponent, html, repeat, unsafeHTML, signal } from '@webjsdev/core';
|
|
46
|
+
import { WebComponent, html, repeat, unsafeHTML, signal, prop } from '@webjsdev/core';
|
|
47
|
+
import { onBeforeCache } from '../lib/utils.ts';
|
|
47
48
|
|
|
48
49
|
type ToastType = 'default' | 'success' | 'error' | 'info' | 'warning' | 'loading';
|
|
49
50
|
|
|
@@ -61,13 +62,28 @@ interface ToastItem extends ToastOptions {
|
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
let nextId = 1;
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
|
|
66
|
+
interface ToastBus {
|
|
67
|
+
add(t: ToastItem): void;
|
|
68
|
+
remove(id: string | number): void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The toast bus lives on `globalThis`, NOT in module scope, so the global
|
|
72
|
+
// `toast()` still reaches a mounted <ui-sonner> when the module is loaded
|
|
73
|
+
// under two different URLs. The app registers the element from the
|
|
74
|
+
// version-hashed `sonner.ts?v=<hash>` (asset-hash, #243), while a caller may
|
|
75
|
+
// do a bare `import('/components/ui/sonner.ts')`: those are distinct module
|
|
76
|
+
// instances with distinct module-scope state, but they share one `window`.
|
|
77
|
+
// Last-mounted viewport wins (it overwrites `bus().add`), matching the prior
|
|
78
|
+
// singleton semantics. Without this, toasts published from a different module
|
|
79
|
+
// instance silently went to a no-op bus (#745).
|
|
80
|
+
function bus(): ToastBus {
|
|
81
|
+
const g = globalThis as unknown as { __webjsSonnerBus?: ToastBus };
|
|
82
|
+
return (g.__webjsSonnerBus ??= { add() {}, remove() {} });
|
|
83
|
+
}
|
|
68
84
|
|
|
69
85
|
function publish(item: ToastItem): string | number {
|
|
70
|
-
|
|
86
|
+
bus().add(item);
|
|
71
87
|
return item.id;
|
|
72
88
|
}
|
|
73
89
|
|
|
@@ -92,7 +108,7 @@ toast.warning = (msg: string, o?: ToastOptions) => makeToast(msg, o, 'warning');
|
|
|
92
108
|
toast.loading = (msg: string, o?: ToastOptions) => makeToast(msg, o, 'loading');
|
|
93
109
|
toast.dismiss = (id?: string | number) => {
|
|
94
110
|
if (id == null) return;
|
|
95
|
-
|
|
111
|
+
bus().remove(id);
|
|
96
112
|
};
|
|
97
113
|
toast.promise = <T,>(p: Promise<T>, opts: { loading: string; success: string; error: string }) => {
|
|
98
114
|
const id = toast.loading(opts.loading);
|
|
@@ -134,31 +150,45 @@ const TYPE_ICON_COLOR: Record<ToastType, string> = {
|
|
|
134
150
|
loading: 'text-muted-foreground',
|
|
135
151
|
};
|
|
136
152
|
|
|
137
|
-
export class UiSonner extends WebComponent
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
};
|
|
141
|
-
declare position: SonnerPosition;
|
|
153
|
+
export class UiSonner extends WebComponent({
|
|
154
|
+
position: prop<SonnerPosition>(String, { reflect: true }),
|
|
155
|
+
}) {
|
|
142
156
|
items = signal<ToastItem[]>([]);
|
|
143
157
|
|
|
158
|
+
_disposeBeforeCache?: () => void;
|
|
159
|
+
|
|
144
160
|
constructor() {
|
|
145
161
|
super();
|
|
146
162
|
this.position = 'bottom-right';
|
|
147
163
|
}
|
|
148
164
|
|
|
165
|
+
connectedCallback(): void {
|
|
166
|
+
super.connectedCallback?.();
|
|
167
|
+
// Clear pending toasts before a back/forward snapshot so a restored page
|
|
168
|
+
// does not show stale toasts that should have auto-dismissed (#766).
|
|
169
|
+
this._disposeBeforeCache = onBeforeCache(() => this.items.set([]));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
disconnectedCallback(): void {
|
|
173
|
+
this._disposeBeforeCache?.();
|
|
174
|
+
super.disconnectedCallback?.();
|
|
175
|
+
}
|
|
176
|
+
|
|
149
177
|
// Routing the global toast() function to this viewport. Runs in
|
|
150
178
|
// firstUpdated rather than the constructor because tests can mount
|
|
151
179
|
// multiple <ui-sonner> instances and the most recently mounted wins
|
|
152
|
-
// (matches the existing semantics).
|
|
180
|
+
// (matches the existing semantics). The bus lives on globalThis so this
|
|
181
|
+
// routing is visible to a toast() called from another module instance.
|
|
153
182
|
firstUpdated(): void {
|
|
154
|
-
|
|
155
|
-
|
|
183
|
+
const b = bus();
|
|
184
|
+
b.add = (t) => this._add(t);
|
|
185
|
+
b.remove = (id) => this._remove(id);
|
|
156
186
|
}
|
|
157
187
|
|
|
158
188
|
/**
|
|
159
189
|
* Publish a toast directly to THIS viewport. Use when you have a
|
|
160
|
-
* specific <ui-sonner> reference and want to bypass the
|
|
161
|
-
* `
|
|
190
|
+
* specific <ui-sonner> reference and want to bypass the global
|
|
191
|
+
* `bus().add` routing (which always points to the last-mounted
|
|
162
192
|
* viewport). Primary use case: docs demos that mount one viewport
|
|
163
193
|
* per position and want each demo button to fire into its own
|
|
164
194
|
* viewport. App code should normally call the global `toast()` /
|
|
@@ -190,8 +220,18 @@ export class UiSonner extends WebComponent {
|
|
|
190
220
|
|
|
191
221
|
render() {
|
|
192
222
|
const pos = POSITIONS[this.position] ?? POSITIONS['bottom-right'];
|
|
223
|
+
// The container is a persistent live region: it is in the DOM from the
|
|
224
|
+
// first render (even with zero toasts), so a screen reader announces
|
|
225
|
+
// each toast as it is inserted. It defaults to polite; an `error` toast
|
|
226
|
+
// carries its own role="alert" (assertive) and that innermost live
|
|
227
|
+
// region wins for that item.
|
|
193
228
|
return html`<div
|
|
194
229
|
data-slot="sonner"
|
|
230
|
+
role="region"
|
|
231
|
+
aria-label="Notifications"
|
|
232
|
+
aria-live="polite"
|
|
233
|
+
aria-relevant="additions text"
|
|
234
|
+
aria-atomic="false"
|
|
195
235
|
class=${`pointer-events-none fixed z-[100] flex flex-col gap-2 ${pos}`}
|
|
196
236
|
>
|
|
197
237
|
${repeat(
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
* <table class=${tableClass()}>
|
|
19
19
|
* <thead class=${tableHeaderClass()}>
|
|
20
20
|
* <tr class=${tableRowClass()}>
|
|
21
|
-
* <th class=${tableHeadClass()}>Name</th>
|
|
22
|
-
* <th class=${tableHeadClass()}>Status</th>
|
|
21
|
+
* <th scope="col" class=${tableHeadClass()}>Name</th>
|
|
22
|
+
* <th scope="col" class=${tableHeadClass()}>Status</th>
|
|
23
23
|
* </tr>
|
|
24
24
|
* </thead>
|
|
25
25
|
* <tbody class=${tableBodyClass()}>
|
|
@@ -32,6 +32,11 @@
|
|
|
32
32
|
* </table>
|
|
33
33
|
* </div>
|
|
34
34
|
*
|
|
35
|
+
* A11y (required for accessible output): every header cell needs a scope
|
|
36
|
+
* (scope="col" on a column header, scope="row" on a row header) so screen
|
|
37
|
+
* readers map cells to their headers. Add a <caption> naming the table's
|
|
38
|
+
* purpose (it can be visually hidden if a heading already names it).
|
|
39
|
+
*
|
|
35
40
|
* Design tokens used: --muted, --muted-foreground, --foreground.
|
|
36
41
|
*/
|
|
37
42
|
|