@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/ui",
3
- "version": "0.3.6",
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
- const NATIVE_DIALOG_CLASS = 'border-0 bg-transparent p-0 m-0 w-0 h-0 max-w-none max-h-none overflow-visible text-inherit backdrop:bg-black/50';
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.#dialog.value;
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.#dialog.value;
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
- const NATIVE_DIALOG_CLASS = 'border-0 bg-transparent p-0 m-0 w-0 h-0 max-w-none max-h-none overflow-visible text-inherit backdrop:bg-black/50';
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.#dialog.value;
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.#dialog.value;
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
- _scheduleCloseHandler = (): void => this._scheduleClose();
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
- _onEnter = (): void => (this.closest('ui-hover-card') as UiHoverCard | null)?.show();
186
- _onLeave = (): void => (this.closest('ui-hover-card') as UiHoverCard | null)?.hide();
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
- _onEnter = (): void => (this.closest('ui-hover-card') as UiHoverCard | null)?.show();
210
- _onLeave = (): void => (this.closest('ui-hover-card') as UiHoverCard | null)?.hide();
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
- 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);
@@ -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
- toaster.add = (t) => this._add(t);
153
- toaster.remove = (id) => this._remove(id);
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 singleton
159
- * `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
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
- // Custom-element base: SSR-safe. In the browser `Base = HTMLElement`. On
132
- // the server (Node, during SSR) `HTMLElement` is undefined; we substitute
133
- // a stub class so that `class Foo extends Base { … }` doesn't throw at
134
- // module-load time. The stub's methods are never actually called server-side
135
- // because connectedCallback/lifecycle hooks only run when the element is
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"
@@ -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'` import to the path
80
- * that resolves correctly from the file's target location to the user's
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 the user's
85
- * components/ui/<x>.ts, the literal `'../lib/utils.ts'` resolves to
86
- * `components/lib/utils.ts`, which doesn't exist. We compute the actual
87
- * relative path from the target directory to `config.resolvedPaths.utils`
88
- * (an absolute path the user has already configured via components.json's
89
- * aliases.utils) and substitute it in.
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 rel = relPath(dirname(target), utilsAbs).split(/[\\/]/).join('/');
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("'../lib/utils.ts'", `'${rel}'`)
103
- .replaceAll('"../lib/utils.ts"', `"${rel}"`);
122
+ .replaceAll(`'${spec}'`, `'${rel}'`)
123
+ .replaceAll(`"${spec}"`, `"${rel}"`);
104
124
  }
105
125
 
106
126
  function resolveTarget(cwd, config, item, file) {
@@ -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
- // `utils` alias points at e.g. "lib/utils" → we write to lib/utils.ts
83
- const target = join(cwd, utilsAlias.replace(/^@\//, '') + '.ts');
84
- ensureDir(dirname(target));
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-utils from registry (${e.message}). You may need to write lib/utils.ts manually.`);
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
- tailwindCss: 'app/globals.css',
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':