@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.
@@ -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
- static properties = {
121
- open: { type: Boolean, reflect: true },
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
- static properties = {
345
- variant: { type: String, reflect: true },
346
- inset: { type: Boolean },
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
- static properties = {
473
- open: { type: Boolean, reflect: true },
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
- _scheduleCloseHandler = (): void => this._scheduleClose();
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
- static properties = {
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
- declare openDelay: number;
65
- declare closeDelay: number;
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
- queueMicrotask(() => this._syncContent());
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
- _onEnter = (): void => (this.closest('ui-hover-card') as UiHoverCard | null)?.show();
155
- _onLeave = (): void => (this.closest('ui-hover-card') as UiHoverCard | null)?.hide();
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
- _onEnter = (): void => (this.closest('ui-hover-card') as UiHoverCard | null)?.show();
179
- _onLeave = (): void => (this.closest('ui-hover-card') as UiHoverCard | null)?.hide();
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
- const toaster: { add(t: ToastItem): void; remove(id: string | number): void } = {
65
- add() {},
66
- remove() {},
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
- toaster.add(item);
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
- toaster.remove(id);
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
- static properties = {
139
- position: { type: String, reflect: true },
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
- toaster.add = (t) => this._add(t);
155
- toaster.remove = (id) => this._remove(id);
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 singleton
161
- * `toaster.add` routing (which always points to the last-mounted
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