@webjsdev/ui 0.3.6 → 0.3.8
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 -3
- package/packages/registry/components/dialog.ts +28 -3
- package/packages/registry/components/dropdown-menu.ts +18 -1
- package/packages/registry/components/hover-card.ts +76 -4
- package/packages/registry/components/sonner.ts +43 -11
- package/packages/registry/components/tabs.ts +10 -0
- package/packages/registry/components/tooltip.ts +10 -0
- package/packages/registry/lib/dom.ts +18 -0
- package/packages/registry/lib/utils.ts +6 -89
- package/packages/registry/registry.json +25 -5
- package/src/commands/add.js +33 -13
- package/src/commands/init.js +30 -6
- package/src/utils/detect-project.js +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/ui",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.8",
|
|
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",
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
import { WebComponent, html, prop } from '@webjsdev/core';
|
|
67
67
|
import { ref, createRef } from '@webjsdev/core/directives';
|
|
68
68
|
import { ensureId } from '../lib/utils.ts';
|
|
69
|
+
import { onBeforeCache } from '../lib/dom.ts';
|
|
69
70
|
import { buttonClass, type ButtonVariant, type ButtonSize } from './button.ts';
|
|
70
71
|
|
|
71
72
|
export const alertDialogContentClass = (): string =>
|
|
@@ -87,7 +88,10 @@ ui-alert-dialog:not([open]) ui-alert-dialog-content { display: none !important;
|
|
|
87
88
|
ui-alert-dialog-content { display: grid; }
|
|
88
89
|
`;
|
|
89
90
|
|
|
90
|
-
|
|
91
|
+
// Fills the viewport (fixed inset-0), NOT 0x0 (#730): WebKit makes a
|
|
92
|
+
// top-layer <dialog> the containing block for its position:fixed descendants,
|
|
93
|
+
// so a 0x0 host collapsed the fixed content panel to 0x0 (invisible on iOS).
|
|
94
|
+
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
95
|
|
|
92
96
|
function installStyles(): void {
|
|
93
97
|
if (typeof document === 'undefined') return;
|
|
@@ -140,16 +144,22 @@ export class UiAlertDialog extends WebComponent({
|
|
|
140
144
|
this.open = false;
|
|
141
145
|
}
|
|
142
146
|
|
|
147
|
+
_disposeBeforeCache?: () => void;
|
|
148
|
+
|
|
143
149
|
connectedCallback(): void {
|
|
144
150
|
installStyles();
|
|
145
151
|
// Legacy <ui-alert-dialog-overlay> isn't supported anymore; the native
|
|
146
152
|
// ::backdrop pseudo replaces it. Strip it if a stale doc uses it.
|
|
147
153
|
this.querySelector<HTMLElement>(':scope > ui-alert-dialog-overlay')?.remove();
|
|
148
154
|
super.connectedCallback?.();
|
|
155
|
+
// Close before a back/forward snapshot: a modal restored half-open (the
|
|
156
|
+
// `open` attr without the native showModal top-layer state) is broken (#766).
|
|
157
|
+
this._disposeBeforeCache = onBeforeCache(() => { this.open = false; });
|
|
149
158
|
}
|
|
150
159
|
|
|
151
160
|
disconnectedCallback(): void {
|
|
152
161
|
if (this.open) this._teardown();
|
|
162
|
+
this._disposeBeforeCache?.();
|
|
153
163
|
super.disconnectedCallback?.();
|
|
154
164
|
}
|
|
155
165
|
|
|
@@ -234,9 +244,17 @@ export class UiAlertDialogContent extends WebComponent({
|
|
|
234
244
|
this.size = 'default';
|
|
235
245
|
}
|
|
236
246
|
|
|
247
|
+
// Query the native <dialog> rather than the #dialog ref: the ref's `.value`
|
|
248
|
+
// came back null on iOS WebKit (the binding did not populate through SSR
|
|
249
|
+
// hydration), so showModal() never ran (#730). querySelector is robust on
|
|
250
|
+
// every engine.
|
|
251
|
+
_native(): HTMLDialogElement | null {
|
|
252
|
+
return this.querySelector<HTMLDialogElement>('dialog[data-slot="alert-dialog-native"]');
|
|
253
|
+
}
|
|
254
|
+
|
|
237
255
|
showModal(): void {
|
|
238
256
|
this._wireLabels();
|
|
239
|
-
const native = this
|
|
257
|
+
const native = this._native();
|
|
240
258
|
if (native && !native.open) native.showModal();
|
|
241
259
|
}
|
|
242
260
|
|
|
@@ -263,7 +281,7 @@ export class UiAlertDialogContent extends WebComponent({
|
|
|
263
281
|
}
|
|
264
282
|
|
|
265
283
|
close(): void {
|
|
266
|
-
const native = this
|
|
284
|
+
const native = this._native();
|
|
267
285
|
if (native?.open) native.close();
|
|
268
286
|
}
|
|
269
287
|
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
import { WebComponent, html, unsafeHTML, prop } from '@webjsdev/core';
|
|
67
67
|
import { ref, createRef } from '@webjsdev/core/directives';
|
|
68
68
|
import { ensureId } from '../lib/utils.ts';
|
|
69
|
+
import { onBeforeCache } from '../lib/dom.ts';
|
|
69
70
|
import { buttonClass } from './button.ts';
|
|
70
71
|
|
|
71
72
|
// Wires a dialog panel's accessible name + description to its title /
|
|
@@ -133,7 +134,16 @@ ui-dialog-content { display: grid; }
|
|
|
133
134
|
// Clears the UA defaults on <dialog> so it becomes an invisible top-layer
|
|
134
135
|
// host. The visible box is rendered by <ui-dialog-content>. The
|
|
135
136
|
// backdrop: variant styles the ::backdrop pseudo-element.
|
|
136
|
-
|
|
137
|
+
//
|
|
138
|
+
// The host FILLS THE VIEWPORT (fixed inset-0) rather than collapsing to
|
|
139
|
+
// 0x0 (#730): WebKit makes a top-layer <dialog> the containing block for
|
|
140
|
+
// its position:fixed descendants, so a 0x0 host collapsed the fixed content
|
|
141
|
+
// panel (w-full) to 0x0 and the dialog was invisible on iOS (Blink resolves
|
|
142
|
+
// the same fixed panel against the viewport, so it worked on Android). A
|
|
143
|
+
// viewport-sized transparent host gives the panel a correct containing block
|
|
144
|
+
// on both engines; the panel still centers itself and the ::backdrop covers
|
|
145
|
+
// the screen.
|
|
146
|
+
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
147
|
|
|
138
148
|
function installStyles(): void {
|
|
139
149
|
if (typeof document === 'undefined') return;
|
|
@@ -188,16 +198,22 @@ export class UiDialog extends WebComponent({
|
|
|
188
198
|
this.open = false;
|
|
189
199
|
}
|
|
190
200
|
|
|
201
|
+
_disposeBeforeCache?: () => void;
|
|
202
|
+
|
|
191
203
|
connectedCallback(): void {
|
|
192
204
|
installStyles();
|
|
193
205
|
// Legacy <ui-dialog-overlay> isn't supported anymore; the native
|
|
194
206
|
// ::backdrop pseudo replaces it. Strip it if a stale doc uses it.
|
|
195
207
|
this.querySelector<HTMLElement>(':scope > ui-dialog-overlay')?.remove();
|
|
196
208
|
super.connectedCallback?.();
|
|
209
|
+
// Close before a back/forward snapshot: a modal restored half-open (the
|
|
210
|
+
// `open` attr without the native showModal top-layer state) is broken (#766).
|
|
211
|
+
this._disposeBeforeCache = onBeforeCache(() => { this.open = false; });
|
|
197
212
|
}
|
|
198
213
|
|
|
199
214
|
disconnectedCallback(): void {
|
|
200
215
|
if (this.open) this._teardown();
|
|
216
|
+
this._disposeBeforeCache?.();
|
|
201
217
|
super.disconnectedCallback?.();
|
|
202
218
|
}
|
|
203
219
|
|
|
@@ -290,14 +306,23 @@ export class UiDialogContent extends WebComponent({
|
|
|
290
306
|
this.showCloseButton = 'true';
|
|
291
307
|
}
|
|
292
308
|
|
|
309
|
+
// Resolve the native <dialog> by query, NOT the #dialog ref: on iOS WebKit
|
|
310
|
+
// the ref's `.value` came back null (the ref binding did not populate through
|
|
311
|
+
// SSR hydration), so showModal() was never called and the dialog never opened
|
|
312
|
+
// (#730). A direct querySelector is robust on every engine, and matches what
|
|
313
|
+
// the tooltip / popover components already do.
|
|
314
|
+
_native(): HTMLDialogElement | null {
|
|
315
|
+
return this.querySelector<HTMLDialogElement>('dialog[data-slot="dialog-native"]');
|
|
316
|
+
}
|
|
317
|
+
|
|
293
318
|
showModal(): void {
|
|
294
319
|
wireDialogLabels(this, '[data-slot="dialog-content"]');
|
|
295
|
-
const native = this
|
|
320
|
+
const native = this._native();
|
|
296
321
|
if (native && !native.open) native.showModal();
|
|
297
322
|
}
|
|
298
323
|
|
|
299
324
|
close(): void {
|
|
300
|
-
const native = this
|
|
325
|
+
const native = this._native();
|
|
301
326
|
if (native?.open) native.close();
|
|
302
327
|
}
|
|
303
328
|
|
|
@@ -79,6 +79,7 @@
|
|
|
79
79
|
*/
|
|
80
80
|
import { WebComponent, html, unsafeHTML, signal, prop } from '@webjsdev/core';
|
|
81
81
|
import { ensureId } from '../lib/utils.ts';
|
|
82
|
+
import { onBeforeCache } from '../lib/dom.ts';
|
|
82
83
|
import { positionFloating, type PopoverSide, type PopoverAlign } from './popover.ts';
|
|
83
84
|
|
|
84
85
|
// --------------------------------------------------------------------------
|
|
@@ -135,8 +136,11 @@ export class UiDropdownMenu extends WebComponent({
|
|
|
135
136
|
this.open = false;
|
|
136
137
|
}
|
|
137
138
|
|
|
139
|
+
_disposeBeforeCache?: () => void;
|
|
140
|
+
|
|
138
141
|
disconnectedCallback(): void {
|
|
139
142
|
if (this.open) this._teardown();
|
|
143
|
+
this._disposeBeforeCache?.();
|
|
140
144
|
super.disconnectedCallback?.();
|
|
141
145
|
}
|
|
142
146
|
|
|
@@ -165,6 +169,9 @@ export class UiDropdownMenu extends WebComponent({
|
|
|
165
169
|
if (typeof requestAnimationFrame === 'function') {
|
|
166
170
|
requestAnimationFrame(() => this._wireAria());
|
|
167
171
|
}
|
|
172
|
+
// Close (and tear down) before a back/forward snapshot so the menu does not
|
|
173
|
+
// restore open (#766). Closing the root also hides any open submenu.
|
|
174
|
+
this._disposeBeforeCache = onBeforeCache(() => { this.open = false; });
|
|
168
175
|
}
|
|
169
176
|
|
|
170
177
|
_afterRender(): void {
|
|
@@ -564,7 +571,13 @@ export class UiDropdownMenuSub extends WebComponent({
|
|
|
564
571
|
}
|
|
565
572
|
|
|
566
573
|
_cancelCloseHandler = (): void => this._cancelClose();
|
|
567
|
-
|
|
574
|
+
// Hover-close is a mouse affordance. On touch, lifting the finger fires
|
|
575
|
+
// pointerleave; without this guard a tap-opened submenu would close itself
|
|
576
|
+
// ~200ms after the tap. On touch the submenu stays open until tapped again.
|
|
577
|
+
_scheduleCloseHandler = (e: Event): void => {
|
|
578
|
+
if ((e as PointerEvent).pointerType === 'touch') return;
|
|
579
|
+
this._scheduleClose();
|
|
580
|
+
};
|
|
568
581
|
|
|
569
582
|
_scheduleClose(): void {
|
|
570
583
|
this._cancelClose();
|
|
@@ -635,6 +648,10 @@ export class UiDropdownMenuSubTrigger extends WebComponent({ inset: Boolean }) {
|
|
|
635
648
|
};
|
|
636
649
|
|
|
637
650
|
_onPointerEnter = (e: Event): void => {
|
|
651
|
+
// Hover-open is a mouse affordance. On touch there is no hover: a tap
|
|
652
|
+
// fires pointerenter on finger-down, which would open the submenu only for
|
|
653
|
+
// the following click to toggle it shut. On touch, @click is the opener.
|
|
654
|
+
if ((e as PointerEvent).pointerType === 'touch') return;
|
|
638
655
|
const el = e.currentTarget as HTMLElement;
|
|
639
656
|
if (el.hasAttribute('data-disabled')) return;
|
|
640
657
|
el.focus();
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
*/
|
|
41
41
|
import { WebComponent, html, prop } from '@webjsdev/core';
|
|
42
42
|
import { ensureId } from '../lib/utils.ts';
|
|
43
|
+
import { onBeforeCache } from '../lib/dom.ts';
|
|
43
44
|
import { positionFloating, type PopoverSide, type PopoverAlign } from './popover.ts';
|
|
44
45
|
|
|
45
46
|
// `fixed m-0` opts out of the UA `[popover]` auto-centering margin so
|
|
@@ -70,6 +71,8 @@ export class UiHoverCard extends WebComponent({
|
|
|
70
71
|
this.closeDelay = 300;
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
_disposeBeforeCache?: () => void;
|
|
75
|
+
|
|
73
76
|
connectedCallback(): void {
|
|
74
77
|
super.connectedCallback?.();
|
|
75
78
|
// webjs projects slotted light-DOM children after the first render, so
|
|
@@ -78,6 +81,14 @@ export class UiHoverCard extends WebComponent({
|
|
|
78
81
|
if (typeof requestAnimationFrame === 'function') {
|
|
79
82
|
requestAnimationFrame(() => this._wireAria());
|
|
80
83
|
}
|
|
84
|
+
// Close before the page is cached for back/forward so a restored snapshot
|
|
85
|
+
// does not come back frozen open (#766).
|
|
86
|
+
this._disposeBeforeCache = onBeforeCache(() => { this.open = false; });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
disconnectedCallback(): void {
|
|
90
|
+
this._disposeBeforeCache?.();
|
|
91
|
+
super.disconnectedCallback?.();
|
|
81
92
|
}
|
|
82
93
|
|
|
83
94
|
// The trigger also opens on focus (see the @focusin handler), so it is
|
|
@@ -114,6 +125,25 @@ export class UiHoverCard extends WebComponent({
|
|
|
114
125
|
this._hideTimer = window.setTimeout(() => { this.open = false; }, this.closeDelay);
|
|
115
126
|
}
|
|
116
127
|
|
|
128
|
+
// Touch open: there is no hover delay and no mouseleave to close it, so open
|
|
129
|
+
// immediately and arm a one-shot outside-tap dismiss (a tap anywhere outside
|
|
130
|
+
// this card closes it). Deferred a tick so the opening tap itself does not
|
|
131
|
+
// immediately dismiss it.
|
|
132
|
+
openByTouch(): void {
|
|
133
|
+
clearTimeout(this._showTimer);
|
|
134
|
+
clearTimeout(this._hideTimer);
|
|
135
|
+
this.open = true;
|
|
136
|
+
const onOutside = (ev: Event): void => {
|
|
137
|
+
// Close on an outside tap; also self-remove if the card was already
|
|
138
|
+
// closed by other means (a re-tap toggle), so the listener never lingers.
|
|
139
|
+
if (!this.open || !this.contains(ev.target as Node)) {
|
|
140
|
+
this.open = false;
|
|
141
|
+
document.removeEventListener('pointerdown', onOutside, true);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
setTimeout(() => document.addEventListener('pointerdown', onOutside, true), 0);
|
|
145
|
+
}
|
|
146
|
+
|
|
117
147
|
render() {
|
|
118
148
|
return html`<div
|
|
119
149
|
data-slot="hover-card"
|
|
@@ -179,11 +209,41 @@ export class UiHoverCardTrigger extends WebComponent {
|
|
|
179
209
|
@mouseleave=${this._onLeave}
|
|
180
210
|
@focusin=${this._onEnter}
|
|
181
211
|
@focusout=${this._onLeave}
|
|
212
|
+
@click=${this._onClick}
|
|
182
213
|
><slot></slot></div>`;
|
|
183
214
|
}
|
|
184
215
|
|
|
185
|
-
|
|
186
|
-
|
|
216
|
+
// Hover/focus open + close are MOUSE affordances. On a no-hover (touch)
|
|
217
|
+
// device, iOS Safari still fires SYNTHETIC mouseenter/mouseleave (and
|
|
218
|
+
// focusin/focusout from the inner link) around a tap, which would otherwise
|
|
219
|
+
// immediately re-close a tap-opened card. Gate the hover handlers to pointer
|
|
220
|
+
// devices so on touch the card is driven only by the tap path (#745).
|
|
221
|
+
_noHover = (): boolean => !!window.matchMedia?.('(hover: none)').matches;
|
|
222
|
+
_onEnter = (): void => {
|
|
223
|
+
if (this._noHover()) return;
|
|
224
|
+
(this.closest('ui-hover-card') as UiHoverCard | null)?.show();
|
|
225
|
+
};
|
|
226
|
+
_onLeave = (): void => {
|
|
227
|
+
if (this._noHover()) return;
|
|
228
|
+
(this.closest('ui-hover-card') as UiHoverCard | null)?.hide();
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
// Touch path. A touch device has no `mouseenter`, so a tap would fall through
|
|
232
|
+
// to the inner `<a href>` and navigate. On a no-hover device the trigger tap
|
|
233
|
+
// TOGGLES the card and NEVER navigates (the real link is reachable inside the
|
|
234
|
+
// opened card). It must ALWAYS preventDefault, including while open: the
|
|
235
|
+
// client router pushState()s on any bubble-phase `<a>` click that is not
|
|
236
|
+
// defaultPrevented, so a re-tap that fell through would push a history entry
|
|
237
|
+
// every time and Back would need N presses (#745).
|
|
238
|
+
_onClick = (e: Event): void => {
|
|
239
|
+
if (!window.matchMedia?.('(hover: none)').matches) return;
|
|
240
|
+
const card = this.closest('ui-hover-card') as UiHoverCard | null;
|
|
241
|
+
if (!card) return;
|
|
242
|
+
e.preventDefault();
|
|
243
|
+
e.stopPropagation();
|
|
244
|
+
if (card.open) card.open = false;
|
|
245
|
+
else card.openByTouch();
|
|
246
|
+
};
|
|
187
247
|
}
|
|
188
248
|
UiHoverCardTrigger.register('ui-hover-card-trigger');
|
|
189
249
|
|
|
@@ -206,7 +266,19 @@ export class UiHoverCardContent extends WebComponent {
|
|
|
206
266
|
><slot></slot></div>`;
|
|
207
267
|
}
|
|
208
268
|
|
|
209
|
-
|
|
210
|
-
|
|
269
|
+
// Hover/focus open + close are MOUSE affordances. On a no-hover (touch)
|
|
270
|
+
// device, iOS Safari still fires SYNTHETIC mouseenter/mouseleave (and
|
|
271
|
+
// focusin/focusout from the inner link) around a tap, which would otherwise
|
|
272
|
+
// immediately re-close a tap-opened card. Gate the hover handlers to pointer
|
|
273
|
+
// devices so on touch the card is driven only by the tap path (#745).
|
|
274
|
+
_noHover = (): boolean => !!window.matchMedia?.('(hover: none)').matches;
|
|
275
|
+
_onEnter = (): void => {
|
|
276
|
+
if (this._noHover()) return;
|
|
277
|
+
(this.closest('ui-hover-card') as UiHoverCard | null)?.show();
|
|
278
|
+
};
|
|
279
|
+
_onLeave = (): void => {
|
|
280
|
+
if (this._noHover()) return;
|
|
281
|
+
(this.closest('ui-hover-card') as UiHoverCard | null)?.hide();
|
|
282
|
+
};
|
|
211
283
|
}
|
|
212
284
|
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/dom.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.
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
*/
|
|
43
43
|
import { WebComponent, html, prop } from '@webjsdev/core';
|
|
44
44
|
import { ensureId } from '../lib/utils.ts';
|
|
45
|
+
import { onBeforeCache } from '../lib/dom.ts';
|
|
45
46
|
import { positionFloating, type PopoverSide, type PopoverAlign } from './popover.ts';
|
|
46
47
|
|
|
47
48
|
// UA `[popover]` defaults paint a bordered, padded panel centered with
|
|
@@ -79,6 +80,8 @@ export class UiTooltip extends WebComponent({
|
|
|
79
80
|
this.skipDelayDuration = 300;
|
|
80
81
|
}
|
|
81
82
|
|
|
83
|
+
_disposeBeforeCache?: () => void;
|
|
84
|
+
|
|
82
85
|
connectedCallback(): void {
|
|
83
86
|
super.connectedCallback?.();
|
|
84
87
|
// webjs projects slotted light-DOM children in a pass after the first
|
|
@@ -88,6 +91,13 @@ export class UiTooltip extends WebComponent({
|
|
|
88
91
|
if (typeof requestAnimationFrame === 'function') {
|
|
89
92
|
requestAnimationFrame(() => this._wireAria());
|
|
90
93
|
}
|
|
94
|
+
// Close before a back/forward snapshot so it does not restore open (#766).
|
|
95
|
+
this._disposeBeforeCache = onBeforeCache(() => { this.open = false; });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
disconnectedCallback(): void {
|
|
99
|
+
this._disposeBeforeCache?.();
|
|
100
|
+
super.disconnectedCallback?.();
|
|
91
101
|
}
|
|
92
102
|
|
|
93
103
|
// APG tooltip wiring: the focusable trigger references the tip via
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Client-only DOM helpers. Kept SEPARATE from lib/utils.ts because these
|
|
2
|
+
// reference `document`: any module that references a client global is treated
|
|
3
|
+
// as client-effecting by the elision analyzer, so bundling this with the pure
|
|
4
|
+
// `cn()` would pin every page that imports `cn` to the browser (#819). Import
|
|
5
|
+
// these ONLY from components (which ship anyway), never from a page/layout.
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Run `reset` just before the webjs client router snapshots the page into its
|
|
9
|
+
* back/forward cache (the `webjs:before-cache` event). Transient overlays use
|
|
10
|
+
* this to close themselves so a restored snapshot is clean. SSR-safe (a no-op
|
|
11
|
+
* when there is no `document`). Wire it in `connectedCallback` and call the
|
|
12
|
+
* returned disposer in `disconnectedCallback` so it never leaks across navs.
|
|
13
|
+
*/
|
|
14
|
+
export function onBeforeCache(reset: () => void): () => void {
|
|
15
|
+
if (typeof document === 'undefined') return () => {};
|
|
16
|
+
document.addEventListener('webjs:before-cache', reset);
|
|
17
|
+
return () => document.removeEventListener('webjs:before-cache', reset);
|
|
18
|
+
}
|
|
@@ -127,95 +127,12 @@ export function ensureId(el: { id: string }, prefix = 'ui'): string {
|
|
|
127
127
|
return el.id;
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
// inserted into a live DOM, which doesn't happen during webjs SSR.
|
|
137
|
-
// ---------------------------------------------------------------------------
|
|
138
|
-
|
|
139
|
-
const HasHTMLElement = typeof HTMLElement !== 'undefined';
|
|
140
|
-
|
|
141
|
-
class ServerHTMLElementStub {
|
|
142
|
-
// Minimal surface so attribute reads/writes inside synchronous code paths
|
|
143
|
-
// that DO execute during SSR (e.g. attribute reflection) don't throw.
|
|
144
|
-
_ssrAttrs: Record<string, string> = {};
|
|
145
|
-
getAttribute(name: string): string | null {
|
|
146
|
-
return this._ssrAttrs[name] ?? null;
|
|
147
|
-
}
|
|
148
|
-
setAttribute(name: string, value: string): void {
|
|
149
|
-
this._ssrAttrs[name] = String(value);
|
|
150
|
-
}
|
|
151
|
-
hasAttribute(name: string): boolean {
|
|
152
|
-
return name in this._ssrAttrs;
|
|
153
|
-
}
|
|
154
|
-
removeAttribute(name: string): void {
|
|
155
|
-
delete this._ssrAttrs[name];
|
|
156
|
-
}
|
|
157
|
-
toggleAttribute(name: string, force?: boolean): boolean {
|
|
158
|
-
const want = force === undefined ? !this.hasAttribute(name) : force;
|
|
159
|
-
if (want) this.setAttribute(name, '');
|
|
160
|
-
else this.removeAttribute(name);
|
|
161
|
-
return want;
|
|
162
|
-
}
|
|
163
|
-
addEventListener(): void {}
|
|
164
|
-
removeEventListener(): void {}
|
|
165
|
-
dispatchEvent(): boolean {
|
|
166
|
-
return true;
|
|
167
|
-
}
|
|
168
|
-
// Tree-walk APIs no-op into null: components that call them server-side
|
|
169
|
-
// simply see "no children / no siblings", which is acceptable for SSR.
|
|
170
|
-
closest(): null {
|
|
171
|
-
return null;
|
|
172
|
-
}
|
|
173
|
-
querySelector(): null {
|
|
174
|
-
return null;
|
|
175
|
-
}
|
|
176
|
-
querySelectorAll(): never[] {
|
|
177
|
-
return [];
|
|
178
|
-
}
|
|
179
|
-
focus(): void {}
|
|
180
|
-
blur(): void {}
|
|
181
|
-
contains(): boolean {
|
|
182
|
-
return false;
|
|
183
|
-
}
|
|
184
|
-
insertBefore<T>(node: T): T {
|
|
185
|
-
return node;
|
|
186
|
-
}
|
|
187
|
-
appendChild<T>(node: T): T {
|
|
188
|
-
return node;
|
|
189
|
-
}
|
|
190
|
-
replaceChildren(): void {}
|
|
191
|
-
get firstChild(): null {
|
|
192
|
-
return null;
|
|
193
|
-
}
|
|
194
|
-
get classList() {
|
|
195
|
-
return { add: () => {}, remove: () => {}, toggle: () => false, contains: () => false };
|
|
196
|
-
}
|
|
197
|
-
get className(): string {
|
|
198
|
-
return this._ssrAttrs.class ?? '';
|
|
199
|
-
}
|
|
200
|
-
set className(v: string) {
|
|
201
|
-
this._ssrAttrs.class = v;
|
|
202
|
-
}
|
|
203
|
-
get style(): Record<string, string> {
|
|
204
|
-
return {};
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/** SSR-safe base class: `HTMLElement` in browser, a thin stub in Node. */
|
|
209
|
-
export const Base: typeof HTMLElement = (HasHTMLElement
|
|
210
|
-
? HTMLElement
|
|
211
|
-
: (ServerHTMLElementStub as unknown as typeof HTMLElement)) as typeof HTMLElement;
|
|
212
|
-
|
|
213
|
-
/** Register a custom element. No-op on server (no `customElements` global). */
|
|
214
|
-
export function defineElement(name: string, cls: CustomElementConstructor): void {
|
|
215
|
-
if (typeof customElements === 'undefined') return;
|
|
216
|
-
if (customElements.get(name)) return; // already defined (HMR / double-import)
|
|
217
|
-
customElements.define(name, cls);
|
|
218
|
-
}
|
|
130
|
+
// NOTE: the old `HTMLElement`-era `Base` / `defineElement` / SSR-stub helpers
|
|
131
|
+
// were removed. The ui registry components extend `WebComponent` from
|
|
132
|
+
// `@webjsdev/core` now, so those helpers were dead code, and referencing
|
|
133
|
+
// `HTMLElement` / `customElements` at module scope marked this util (and any
|
|
134
|
+
// page that imports `cn`) as client-effecting. Everything below is pure or
|
|
135
|
+
// SSR-safe, so importing `cn` no longer pins a page to the browser (#819).
|
|
219
136
|
|
|
220
137
|
// ---------------------------------------------------------------------------
|
|
221
138
|
// Layout helpers: encode the design-system rhythm (spacing between label /
|
|
@@ -15,6 +15,18 @@
|
|
|
15
15
|
}
|
|
16
16
|
]
|
|
17
17
|
},
|
|
18
|
+
{
|
|
19
|
+
"name": "lib-dom",
|
|
20
|
+
"type": "registry:lib",
|
|
21
|
+
"description": "onBeforeCache() client-router cache hook",
|
|
22
|
+
"files": [
|
|
23
|
+
{
|
|
24
|
+
"path": "lib/dom.ts",
|
|
25
|
+
"type": "registry:lib",
|
|
26
|
+
"target": "lib/dom.ts"
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
},
|
|
18
30
|
{
|
|
19
31
|
"name": "theme-neutral",
|
|
20
32
|
"type": "registry:theme",
|
|
@@ -59,7 +71,8 @@
|
|
|
59
71
|
"type": "registry:ui",
|
|
60
72
|
"registryDependencies": [
|
|
61
73
|
"button",
|
|
62
|
-
"dialog"
|
|
74
|
+
"dialog",
|
|
75
|
+
"lib-dom"
|
|
63
76
|
],
|
|
64
77
|
"dependencies": [
|
|
65
78
|
"@webjsdev/core"
|
|
@@ -189,7 +202,8 @@
|
|
|
189
202
|
"type": "registry:ui",
|
|
190
203
|
"registryDependencies": [
|
|
191
204
|
"lib-utils",
|
|
192
|
-
"button"
|
|
205
|
+
"button",
|
|
206
|
+
"lib-dom"
|
|
193
207
|
],
|
|
194
208
|
"dependencies": [
|
|
195
209
|
"@webjsdev/core"
|
|
@@ -205,7 +219,8 @@
|
|
|
205
219
|
"name": "dropdown-menu",
|
|
206
220
|
"type": "registry:ui",
|
|
207
221
|
"registryDependencies": [
|
|
208
|
-
"popover"
|
|
222
|
+
"popover",
|
|
223
|
+
"lib-dom"
|
|
209
224
|
],
|
|
210
225
|
"dependencies": [
|
|
211
226
|
"@webjsdev/core"
|
|
@@ -221,7 +236,8 @@
|
|
|
221
236
|
"name": "hover-card",
|
|
222
237
|
"type": "registry:ui",
|
|
223
238
|
"registryDependencies": [
|
|
224
|
-
"popover"
|
|
239
|
+
"popover",
|
|
240
|
+
"lib-dom"
|
|
225
241
|
],
|
|
226
242
|
"dependencies": [
|
|
227
243
|
"@webjsdev/core"
|
|
@@ -381,6 +397,9 @@
|
|
|
381
397
|
{
|
|
382
398
|
"name": "sonner",
|
|
383
399
|
"type": "registry:ui",
|
|
400
|
+
"registryDependencies": [
|
|
401
|
+
"lib-dom"
|
|
402
|
+
],
|
|
384
403
|
"dependencies": [
|
|
385
404
|
"@webjsdev/core"
|
|
386
405
|
],
|
|
@@ -476,7 +495,8 @@
|
|
|
476
495
|
"name": "tooltip",
|
|
477
496
|
"type": "registry:ui",
|
|
478
497
|
"registryDependencies": [
|
|
479
|
-
"popover"
|
|
498
|
+
"popover",
|
|
499
|
+
"lib-dom"
|
|
480
500
|
],
|
|
481
501
|
"dependencies": [
|
|
482
502
|
"@webjsdev/core"
|
package/src/commands/add.js
CHANGED
|
@@ -76,31 +76,51 @@ async function writeRegistryFile(cwd, config, item, file, opts) {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
/**
|
|
79
|
-
* Rewrite the registry-relative `'../lib/utils.ts'`
|
|
80
|
-
* that
|
|
81
|
-
* cn() helper.
|
|
79
|
+
* Rewrite the registry-relative `'../lib/utils.ts'` and `'../lib/dom.ts'`
|
|
80
|
+
* imports to the paths that resolve correctly from the file's target
|
|
81
|
+
* location to the user's cn() helper and onBeforeCache() DOM helper.
|
|
82
82
|
*
|
|
83
83
|
* The registry source assumes its own layout (`<registry>/components/<x>.ts`
|
|
84
|
-
* imports `'../lib/utils.ts'`). When that file lands in
|
|
85
|
-
* components/ui/<x>.ts, the literal `'../lib/utils.ts'` resolves
|
|
86
|
-
* `components/lib/utils.ts`, which doesn't exist. We compute the actual
|
|
87
|
-
* relative path from the target directory to
|
|
88
|
-
* (
|
|
89
|
-
*
|
|
84
|
+
* imports `'../lib/utils.ts'` / `'../lib/dom.ts'`). When that file lands in
|
|
85
|
+
* the user's components/ui/<x>.ts, the literal `'../lib/utils.ts'` resolves
|
|
86
|
+
* to `components/lib/utils.ts`, which doesn't exist. We compute the actual
|
|
87
|
+
* relative path from the target directory to the user's configured helper
|
|
88
|
+
* (`config.resolvedPaths.utils`, from components.json's aliases.utils) and
|
|
89
|
+
* substitute it in. The DOM helper is written as a SIBLING of the utils
|
|
90
|
+
* file (the scaffold puts it at `lib/utils/dom.ts`, next to `cn.ts`), so its
|
|
91
|
+
* absolute target is `dom.ts` in the utils file's directory. sonner imports
|
|
92
|
+
* ONLY the DOM helper, so both specifiers are handled independently and
|
|
93
|
+
* neither presence gates the other.
|
|
90
94
|
*
|
|
91
95
|
* @param {string} content raw file content from the registry
|
|
92
96
|
* @param {string} target absolute path where the file will be written
|
|
93
97
|
* @param {{ resolvedPaths: { utils: string } }} config parsed components.json
|
|
94
98
|
*/
|
|
95
99
|
export function rewriteUtilsImport(content, target, config) {
|
|
96
|
-
if (!content.includes('../lib/utils.ts')) return content;
|
|
97
100
|
const utilsAbs = config?.resolvedPaths?.utils;
|
|
98
101
|
if (!utilsAbs) return content;
|
|
99
|
-
let
|
|
102
|
+
let out = replaceImportSpecifier(content, '../lib/utils.ts', utilsAbs, target);
|
|
103
|
+
const domAbs = join(dirname(utilsAbs), 'dom.ts');
|
|
104
|
+
out = replaceImportSpecifier(out, '../lib/dom.ts', domAbs, target);
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Replace a single registry-relative import specifier with the relative path
|
|
110
|
+
* from `target`'s directory to `destAbs`. No-op when `spec` is absent.
|
|
111
|
+
*
|
|
112
|
+
* @param {string} content
|
|
113
|
+
* @param {string} spec the registry-relative specifier to replace
|
|
114
|
+
* @param {string} destAbs absolute path of the destination file
|
|
115
|
+
* @param {string} target absolute path where the importing file is written
|
|
116
|
+
*/
|
|
117
|
+
function replaceImportSpecifier(content, spec, destAbs, target) {
|
|
118
|
+
if (!content.includes(spec)) return content;
|
|
119
|
+
let rel = relPath(dirname(target), destAbs).split(/[\\/]/).join('/');
|
|
100
120
|
if (!rel.startsWith('.')) rel = './' + rel;
|
|
101
121
|
return content
|
|
102
|
-
.replaceAll(
|
|
103
|
-
.replaceAll(
|
|
122
|
+
.replaceAll(`'${spec}'`, `'${rel}'`)
|
|
123
|
+
.replaceAll(`"${spec}"`, `"${rel}"`);
|
|
104
124
|
}
|
|
105
125
|
|
|
106
126
|
function resolveTarget(cwd, config, item, file) {
|
package/src/commands/init.js
CHANGED
|
@@ -75,21 +75,45 @@ export const init = new Command()
|
|
|
75
75
|
});
|
|
76
76
|
|
|
77
77
|
async function writeLibUtils(cwd, utilsAlias, registryUrl) {
|
|
78
|
+
// `utils` alias points at e.g. "lib/utils" so we write to lib/utils.ts
|
|
79
|
+
const utilsRel = utilsAlias.replace(/^@\//, '') + '.ts';
|
|
80
|
+
const utilsTarget = join(cwd, utilsRel);
|
|
78
81
|
try {
|
|
79
82
|
const item = await fetchRegistryItem('lib-utils', registryUrl);
|
|
83
|
+
if (item.files) {
|
|
84
|
+
for (const f of item.files) {
|
|
85
|
+
ensureDir(dirname(utilsTarget));
|
|
86
|
+
writeFileSync(utilsTarget, f.content || '', 'utf8');
|
|
87
|
+
logger.success(`Wrote ${utilsAlias}.ts`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
} catch (e) {
|
|
91
|
+
logger.warn(`Could not fetch lib-utils from registry (${e.message}). You may need to write lib/utils.ts manually.`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// The onBeforeCache() DOM helper lives in a SEPARATE module (#819) because
|
|
95
|
+
// it references `document`, so keeping it out of cn()'s file prevents the
|
|
96
|
+
// elision analyzer pinning every page that imports cn to the browser.
|
|
97
|
+
// Overlay components import it from `../lib/dom.ts`, so write it as a
|
|
98
|
+
// sibling of the utils file (e.g. lib/utils/dom.ts next to cn.ts).
|
|
99
|
+
const domTarget = join(dirname(utilsTarget), 'dom.ts');
|
|
100
|
+
try {
|
|
101
|
+
const item = await fetchRegistryItem('lib-dom', registryUrl);
|
|
80
102
|
if (!item.files) return;
|
|
81
103
|
for (const f of item.files) {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
writeFileSync(target, f.content || '', 'utf8');
|
|
86
|
-
logger.success(`Wrote ${utilsAlias}.ts`);
|
|
104
|
+
ensureDir(dirname(domTarget));
|
|
105
|
+
writeFileSync(domTarget, f.content || '', 'utf8');
|
|
106
|
+
logger.success(`Wrote ${relative(cwd, domTarget)}`);
|
|
87
107
|
}
|
|
88
108
|
} catch (e) {
|
|
89
|
-
logger.warn(`Could not fetch lib-
|
|
109
|
+
logger.warn(`Could not fetch lib-dom from registry (${e.message}). You may need to write lib/dom.ts manually.`);
|
|
90
110
|
}
|
|
91
111
|
}
|
|
92
112
|
|
|
113
|
+
function relative(cwd, p) {
|
|
114
|
+
return p.startsWith(cwd) ? p.slice(cwd.length + 1) : p;
|
|
115
|
+
}
|
|
116
|
+
|
|
93
117
|
async function writeTheme(cwd, baseColor, cssPath, registryUrl) {
|
|
94
118
|
try {
|
|
95
119
|
const item = await fetchRegistryItem(`theme-${baseColor}`, registryUrl);
|
|
@@ -34,7 +34,9 @@ export function defaultsForProject(cwd = process.cwd()) {
|
|
|
34
34
|
switch (type) {
|
|
35
35
|
case 'webjs':
|
|
36
36
|
return {
|
|
37
|
-
|
|
37
|
+
// styles/globals.css, NOT app/globals.css: in webjs, app/ is routing-only,
|
|
38
|
+
// so a non-routing stylesheet lives outside it.
|
|
39
|
+
tailwindCss: 'styles/globals.css',
|
|
38
40
|
aliases: { components: 'components', utils: 'lib/utils', ui: 'components/ui', lib: 'lib' },
|
|
39
41
|
};
|
|
40
42
|
case 'next':
|