@webjsdev/ui 0.3.6 → 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/package.json +3 -2
- package/packages/registry/components/alert-dialog.ts +21 -4
- package/packages/registry/components/dialog.ts +28 -4
- package/packages/registry/components/dropdown-menu.ts +18 -2
- package/packages/registry/components/hover-card.ts +76 -5
- package/packages/registry/components/sonner.ts +43 -11
- package/packages/registry/components/tabs.ts +10 -0
- package/packages/registry/components/tooltip.ts +10 -1
- package/packages/registry/lib/utils.ts +16 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/ui",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "An AI-first component library - class-helper functions for visuals, custom elements only where state matters. Source-copied into your repo, you own it. Works with any Tailwind v4 project.",
|
|
6
6
|
"bin": {
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
"README.md"
|
|
20
20
|
],
|
|
21
21
|
"scripts": {
|
|
22
|
-
"test": "node --test test/*.test.js"
|
|
22
|
+
"test": "node --test test/*.test.js",
|
|
23
|
+
"test:e2e:touch": "node test/e2e/touch.e2e.mjs"
|
|
23
24
|
},
|
|
24
25
|
"dependencies": {
|
|
25
26
|
"commander": "^14.0.0",
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
*/
|
|
66
66
|
import { WebComponent, html, prop } from '@webjsdev/core';
|
|
67
67
|
import { ref, createRef } from '@webjsdev/core/directives';
|
|
68
|
-
import { ensureId } from '../lib/utils.ts';
|
|
68
|
+
import { ensureId, onBeforeCache } from '../lib/utils.ts';
|
|
69
69
|
import { buttonClass, type ButtonVariant, type ButtonSize } from './button.ts';
|
|
70
70
|
|
|
71
71
|
export const alertDialogContentClass = (): string =>
|
|
@@ -87,7 +87,10 @@ ui-alert-dialog:not([open]) ui-alert-dialog-content { display: none !important;
|
|
|
87
87
|
ui-alert-dialog-content { display: grid; }
|
|
88
88
|
`;
|
|
89
89
|
|
|
90
|
-
|
|
90
|
+
// Fills the viewport (fixed inset-0), NOT 0x0 (#730): WebKit makes a
|
|
91
|
+
// top-layer <dialog> the containing block for its position:fixed descendants,
|
|
92
|
+
// so a 0x0 host collapsed the fixed content panel to 0x0 (invisible on iOS).
|
|
93
|
+
const NATIVE_DIALOG_CLASS = 'fixed inset-0 border-0 bg-transparent p-0 m-0 max-w-none max-h-none overflow-visible text-inherit backdrop:bg-black/50';
|
|
91
94
|
|
|
92
95
|
function installStyles(): void {
|
|
93
96
|
if (typeof document === 'undefined') return;
|
|
@@ -140,16 +143,22 @@ export class UiAlertDialog extends WebComponent({
|
|
|
140
143
|
this.open = false;
|
|
141
144
|
}
|
|
142
145
|
|
|
146
|
+
_disposeBeforeCache?: () => void;
|
|
147
|
+
|
|
143
148
|
connectedCallback(): void {
|
|
144
149
|
installStyles();
|
|
145
150
|
// Legacy <ui-alert-dialog-overlay> isn't supported anymore; the native
|
|
146
151
|
// ::backdrop pseudo replaces it. Strip it if a stale doc uses it.
|
|
147
152
|
this.querySelector<HTMLElement>(':scope > ui-alert-dialog-overlay')?.remove();
|
|
148
153
|
super.connectedCallback?.();
|
|
154
|
+
// Close before a back/forward snapshot: a modal restored half-open (the
|
|
155
|
+
// `open` attr without the native showModal top-layer state) is broken (#766).
|
|
156
|
+
this._disposeBeforeCache = onBeforeCache(() => { this.open = false; });
|
|
149
157
|
}
|
|
150
158
|
|
|
151
159
|
disconnectedCallback(): void {
|
|
152
160
|
if (this.open) this._teardown();
|
|
161
|
+
this._disposeBeforeCache?.();
|
|
153
162
|
super.disconnectedCallback?.();
|
|
154
163
|
}
|
|
155
164
|
|
|
@@ -234,9 +243,17 @@ export class UiAlertDialogContent extends WebComponent({
|
|
|
234
243
|
this.size = 'default';
|
|
235
244
|
}
|
|
236
245
|
|
|
246
|
+
// Query the native <dialog> rather than the #dialog ref: the ref's `.value`
|
|
247
|
+
// came back null on iOS WebKit (the binding did not populate through SSR
|
|
248
|
+
// hydration), so showModal() never ran (#730). querySelector is robust on
|
|
249
|
+
// every engine.
|
|
250
|
+
_native(): HTMLDialogElement | null {
|
|
251
|
+
return this.querySelector<HTMLDialogElement>('dialog[data-slot="alert-dialog-native"]');
|
|
252
|
+
}
|
|
253
|
+
|
|
237
254
|
showModal(): void {
|
|
238
255
|
this._wireLabels();
|
|
239
|
-
const native = this
|
|
256
|
+
const native = this._native();
|
|
240
257
|
if (native && !native.open) native.showModal();
|
|
241
258
|
}
|
|
242
259
|
|
|
@@ -263,7 +280,7 @@ export class UiAlertDialogContent extends WebComponent({
|
|
|
263
280
|
}
|
|
264
281
|
|
|
265
282
|
close(): void {
|
|
266
|
-
const native = this
|
|
283
|
+
const native = this._native();
|
|
267
284
|
if (native?.open) native.close();
|
|
268
285
|
}
|
|
269
286
|
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
*/
|
|
66
66
|
import { WebComponent, html, unsafeHTML, prop } from '@webjsdev/core';
|
|
67
67
|
import { ref, createRef } from '@webjsdev/core/directives';
|
|
68
|
-
import { ensureId } from '../lib/utils.ts';
|
|
68
|
+
import { ensureId, onBeforeCache } from '../lib/utils.ts';
|
|
69
69
|
import { buttonClass } from './button.ts';
|
|
70
70
|
|
|
71
71
|
// Wires a dialog panel's accessible name + description to its title /
|
|
@@ -133,7 +133,16 @@ ui-dialog-content { display: grid; }
|
|
|
133
133
|
// Clears the UA defaults on <dialog> so it becomes an invisible top-layer
|
|
134
134
|
// host. The visible box is rendered by <ui-dialog-content>. The
|
|
135
135
|
// backdrop: variant styles the ::backdrop pseudo-element.
|
|
136
|
-
|
|
136
|
+
//
|
|
137
|
+
// The host FILLS THE VIEWPORT (fixed inset-0) rather than collapsing to
|
|
138
|
+
// 0x0 (#730): WebKit makes a top-layer <dialog> the containing block for
|
|
139
|
+
// its position:fixed descendants, so a 0x0 host collapsed the fixed content
|
|
140
|
+
// panel (w-full) to 0x0 and the dialog was invisible on iOS (Blink resolves
|
|
141
|
+
// the same fixed panel against the viewport, so it worked on Android). A
|
|
142
|
+
// viewport-sized transparent host gives the panel a correct containing block
|
|
143
|
+
// on both engines; the panel still centers itself and the ::backdrop covers
|
|
144
|
+
// the screen.
|
|
145
|
+
const NATIVE_DIALOG_CLASS = 'fixed inset-0 border-0 bg-transparent p-0 m-0 max-w-none max-h-none overflow-visible text-inherit backdrop:bg-black/50';
|
|
137
146
|
|
|
138
147
|
function installStyles(): void {
|
|
139
148
|
if (typeof document === 'undefined') return;
|
|
@@ -188,16 +197,22 @@ export class UiDialog extends WebComponent({
|
|
|
188
197
|
this.open = false;
|
|
189
198
|
}
|
|
190
199
|
|
|
200
|
+
_disposeBeforeCache?: () => void;
|
|
201
|
+
|
|
191
202
|
connectedCallback(): void {
|
|
192
203
|
installStyles();
|
|
193
204
|
// Legacy <ui-dialog-overlay> isn't supported anymore; the native
|
|
194
205
|
// ::backdrop pseudo replaces it. Strip it if a stale doc uses it.
|
|
195
206
|
this.querySelector<HTMLElement>(':scope > ui-dialog-overlay')?.remove();
|
|
196
207
|
super.connectedCallback?.();
|
|
208
|
+
// Close before a back/forward snapshot: a modal restored half-open (the
|
|
209
|
+
// `open` attr without the native showModal top-layer state) is broken (#766).
|
|
210
|
+
this._disposeBeforeCache = onBeforeCache(() => { this.open = false; });
|
|
197
211
|
}
|
|
198
212
|
|
|
199
213
|
disconnectedCallback(): void {
|
|
200
214
|
if (this.open) this._teardown();
|
|
215
|
+
this._disposeBeforeCache?.();
|
|
201
216
|
super.disconnectedCallback?.();
|
|
202
217
|
}
|
|
203
218
|
|
|
@@ -290,14 +305,23 @@ export class UiDialogContent extends WebComponent({
|
|
|
290
305
|
this.showCloseButton = 'true';
|
|
291
306
|
}
|
|
292
307
|
|
|
308
|
+
// Resolve the native <dialog> by query, NOT the #dialog ref: on iOS WebKit
|
|
309
|
+
// the ref's `.value` came back null (the ref binding did not populate through
|
|
310
|
+
// SSR hydration), so showModal() was never called and the dialog never opened
|
|
311
|
+
// (#730). A direct querySelector is robust on every engine, and matches what
|
|
312
|
+
// the tooltip / popover components already do.
|
|
313
|
+
_native(): HTMLDialogElement | null {
|
|
314
|
+
return this.querySelector<HTMLDialogElement>('dialog[data-slot="dialog-native"]');
|
|
315
|
+
}
|
|
316
|
+
|
|
293
317
|
showModal(): void {
|
|
294
318
|
wireDialogLabels(this, '[data-slot="dialog-content"]');
|
|
295
|
-
const native = this
|
|
319
|
+
const native = this._native();
|
|
296
320
|
if (native && !native.open) native.showModal();
|
|
297
321
|
}
|
|
298
322
|
|
|
299
323
|
close(): void {
|
|
300
|
-
const native = this
|
|
324
|
+
const native = this._native();
|
|
301
325
|
if (native?.open) native.close();
|
|
302
326
|
}
|
|
303
327
|
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
* --accent-foreground, --destructive, --muted-foreground, --border.
|
|
79
79
|
*/
|
|
80
80
|
import { WebComponent, html, unsafeHTML, signal, prop } from '@webjsdev/core';
|
|
81
|
-
import { ensureId } from '../lib/utils.ts';
|
|
81
|
+
import { ensureId, onBeforeCache } from '../lib/utils.ts';
|
|
82
82
|
import { positionFloating, type PopoverSide, type PopoverAlign } from './popover.ts';
|
|
83
83
|
|
|
84
84
|
// --------------------------------------------------------------------------
|
|
@@ -135,8 +135,11 @@ export class UiDropdownMenu extends WebComponent({
|
|
|
135
135
|
this.open = false;
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
_disposeBeforeCache?: () => void;
|
|
139
|
+
|
|
138
140
|
disconnectedCallback(): void {
|
|
139
141
|
if (this.open) this._teardown();
|
|
142
|
+
this._disposeBeforeCache?.();
|
|
140
143
|
super.disconnectedCallback?.();
|
|
141
144
|
}
|
|
142
145
|
|
|
@@ -165,6 +168,9 @@ export class UiDropdownMenu extends WebComponent({
|
|
|
165
168
|
if (typeof requestAnimationFrame === 'function') {
|
|
166
169
|
requestAnimationFrame(() => this._wireAria());
|
|
167
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; });
|
|
168
174
|
}
|
|
169
175
|
|
|
170
176
|
_afterRender(): void {
|
|
@@ -564,7 +570,13 @@ export class UiDropdownMenuSub extends WebComponent({
|
|
|
564
570
|
}
|
|
565
571
|
|
|
566
572
|
_cancelCloseHandler = (): void => this._cancelClose();
|
|
567
|
-
|
|
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
|
+
};
|
|
568
580
|
|
|
569
581
|
_scheduleClose(): void {
|
|
570
582
|
this._cancelClose();
|
|
@@ -635,6 +647,10 @@ export class UiDropdownMenuSubTrigger extends WebComponent({ inset: Boolean }) {
|
|
|
635
647
|
};
|
|
636
648
|
|
|
637
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;
|
|
638
654
|
const el = e.currentTarget as HTMLElement;
|
|
639
655
|
if (el.hasAttribute('data-disabled')) return;
|
|
640
656
|
el.focus();
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
* Design tokens used: --popover, --popover-foreground, --border.
|
|
40
40
|
*/
|
|
41
41
|
import { WebComponent, html, prop } from '@webjsdev/core';
|
|
42
|
-
import { ensureId } from '../lib/utils.ts';
|
|
42
|
+
import { ensureId, onBeforeCache } from '../lib/utils.ts';
|
|
43
43
|
import { positionFloating, type PopoverSide, type PopoverAlign } from './popover.ts';
|
|
44
44
|
|
|
45
45
|
// `fixed m-0` opts out of the UA `[popover]` auto-centering margin so
|
|
@@ -70,6 +70,8 @@ export class UiHoverCard extends WebComponent({
|
|
|
70
70
|
this.closeDelay = 300;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
_disposeBeforeCache?: () => void;
|
|
74
|
+
|
|
73
75
|
connectedCallback(): void {
|
|
74
76
|
super.connectedCallback?.();
|
|
75
77
|
// webjs projects slotted light-DOM children after the first render, so
|
|
@@ -78,6 +80,14 @@ export class UiHoverCard extends WebComponent({
|
|
|
78
80
|
if (typeof requestAnimationFrame === 'function') {
|
|
79
81
|
requestAnimationFrame(() => this._wireAria());
|
|
80
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?.();
|
|
81
91
|
}
|
|
82
92
|
|
|
83
93
|
// The trigger also opens on focus (see the @focusin handler), so it is
|
|
@@ -114,6 +124,25 @@ export class UiHoverCard extends WebComponent({
|
|
|
114
124
|
this._hideTimer = window.setTimeout(() => { this.open = false; }, this.closeDelay);
|
|
115
125
|
}
|
|
116
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
|
+
|
|
117
146
|
render() {
|
|
118
147
|
return html`<div
|
|
119
148
|
data-slot="hover-card"
|
|
@@ -179,11 +208,41 @@ export class UiHoverCardTrigger extends WebComponent {
|
|
|
179
208
|
@mouseleave=${this._onLeave}
|
|
180
209
|
@focusin=${this._onEnter}
|
|
181
210
|
@focusout=${this._onLeave}
|
|
211
|
+
@click=${this._onClick}
|
|
182
212
|
><slot></slot></div>`;
|
|
183
213
|
}
|
|
184
214
|
|
|
185
|
-
|
|
186
|
-
|
|
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
|
+
};
|
|
187
246
|
}
|
|
188
247
|
UiHoverCardTrigger.register('ui-hover-card-trigger');
|
|
189
248
|
|
|
@@ -206,7 +265,19 @@ export class UiHoverCardContent extends WebComponent {
|
|
|
206
265
|
><slot></slot></div>`;
|
|
207
266
|
}
|
|
208
267
|
|
|
209
|
-
|
|
210
|
-
|
|
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
|
+
};
|
|
211
282
|
}
|
|
212
283
|
UiHoverCardContent.register('ui-hover-card-content');
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
* Design tokens used: --popover, --popover-foreground, --border, --radius.
|
|
45
45
|
*/
|
|
46
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);
|
|
@@ -139,24 +155,40 @@ export class UiSonner extends WebComponent({
|
|
|
139
155
|
}) {
|
|
140
156
|
items = signal<ToastItem[]>([]);
|
|
141
157
|
|
|
158
|
+
_disposeBeforeCache?: () => void;
|
|
159
|
+
|
|
142
160
|
constructor() {
|
|
143
161
|
super();
|
|
144
162
|
this.position = 'bottom-right';
|
|
145
163
|
}
|
|
146
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
|
+
|
|
147
177
|
// Routing the global toast() function to this viewport. Runs in
|
|
148
178
|
// firstUpdated rather than the constructor because tests can mount
|
|
149
179
|
// multiple <ui-sonner> instances and the most recently mounted wins
|
|
150
|
-
// (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.
|
|
151
182
|
firstUpdated(): void {
|
|
152
|
-
|
|
153
|
-
|
|
183
|
+
const b = bus();
|
|
184
|
+
b.add = (t) => this._add(t);
|
|
185
|
+
b.remove = (id) => this._remove(id);
|
|
154
186
|
}
|
|
155
187
|
|
|
156
188
|
/**
|
|
157
189
|
* Publish a toast directly to THIS viewport. Use when you have a
|
|
158
|
-
* specific <ui-sonner> reference and want to bypass the
|
|
159
|
-
* `
|
|
190
|
+
* specific <ui-sonner> reference and want to bypass the global
|
|
191
|
+
* `bus().add` routing (which always points to the last-mounted
|
|
160
192
|
* viewport). Primary use case: docs demos that mount one viewport
|
|
161
193
|
* per position and want each demo button to fire into its own
|
|
162
194
|
* viewport. App code should normally call the global `toast()` /
|
|
@@ -105,6 +105,16 @@ export class UiTabs extends WebComponent({
|
|
|
105
105
|
this.orientation = 'horizontal';
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
// Assign the scope id BEFORE render so it is serialized onto this host's
|
|
109
|
+
// opening tag at SSR (willUpdate now runs server-side). Without this, the id
|
|
110
|
+
// was minted lazily during a CHILD trigger's render, AFTER this host's tag
|
|
111
|
+
// was already serialized without an id, so the host shipped no id; on
|
|
112
|
+
// hydration the client re-minted a DIFFERENT counter id and the trigger /
|
|
113
|
+
// panel ids mismatched SSR vs client, breaking hydration (#730).
|
|
114
|
+
willUpdate(): void {
|
|
115
|
+
ensureId(this, 'ui-tabs');
|
|
116
|
+
}
|
|
117
|
+
|
|
108
118
|
// Stable per-instance scope so a trigger and its panel agree on the
|
|
109
119
|
// shared id stem (`<scope>-trigger-<value>` / `<scope>-panel-<value>`)
|
|
110
120
|
// even when several tab sets reuse the same `value` names on one page.
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
* Design tokens used: --foreground, --background.
|
|
42
42
|
*/
|
|
43
43
|
import { WebComponent, html, prop } from '@webjsdev/core';
|
|
44
|
-
import { ensureId } from '../lib/utils.ts';
|
|
44
|
+
import { ensureId, onBeforeCache } from '../lib/utils.ts';
|
|
45
45
|
import { positionFloating, type PopoverSide, type PopoverAlign } from './popover.ts';
|
|
46
46
|
|
|
47
47
|
// UA `[popover]` defaults paint a bordered, padded panel centered with
|
|
@@ -79,6 +79,8 @@ export class UiTooltip extends WebComponent({
|
|
|
79
79
|
this.skipDelayDuration = 300;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
_disposeBeforeCache?: () => void;
|
|
83
|
+
|
|
82
84
|
connectedCallback(): void {
|
|
83
85
|
super.connectedCallback?.();
|
|
84
86
|
// webjs projects slotted light-DOM children in a pass after the first
|
|
@@ -88,6 +90,13 @@ export class UiTooltip extends WebComponent({
|
|
|
88
90
|
if (typeof requestAnimationFrame === 'function') {
|
|
89
91
|
requestAnimationFrame(() => this._wireAria());
|
|
90
92
|
}
|
|
93
|
+
// Close before a back/forward snapshot so it does not restore open (#766).
|
|
94
|
+
this._disposeBeforeCache = onBeforeCache(() => { this.open = false; });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
disconnectedCallback(): void {
|
|
98
|
+
this._disposeBeforeCache?.();
|
|
99
|
+
super.disconnectedCallback?.();
|
|
91
100
|
}
|
|
92
101
|
|
|
93
102
|
// APG tooltip wiring: the focusable trigger references the tip via
|
|
@@ -266,3 +266,19 @@ export const helpClass = () => 'text-xs text-muted-foreground';
|
|
|
266
266
|
|
|
267
267
|
/** Validation error text: replaces hint when the field is invalid. */
|
|
268
268
|
export const errorClass = () => 'text-sm font-medium text-destructive';
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Run `reset` just before the webjs client router snapshots the page into its
|
|
272
|
+
* back/forward cache (the `webjs:before-cache` event). Transient overlays use
|
|
273
|
+
* this to close themselves so a restored snapshot is clean rather than, say, a
|
|
274
|
+
* hover-card frozen open after Back then Forward (#766). Wire it in
|
|
275
|
+
* `connectedCallback` and call the returned disposer in `disconnectedCallback`
|
|
276
|
+
* so the listener never leaks across soft navigations.
|
|
277
|
+
*
|
|
278
|
+
* SSR-safe: a no-op when there is no `document` (server / no client router).
|
|
279
|
+
*/
|
|
280
|
+
export function onBeforeCache(reset: () => void): () => void {
|
|
281
|
+
if (typeof document === 'undefined') return () => {};
|
|
282
|
+
document.addEventListener('webjs:before-cache', reset);
|
|
283
|
+
return () => document.removeEventListener('webjs:before-cache', reset);
|
|
284
|
+
}
|