@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/ui",
3
- "version": "0.3.6",
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
- 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';
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.#dialog.value;
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.#dialog.value;
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
- 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';
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.#dialog.value;
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.#dialog.value;
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
- _scheduleCloseHandler = (): void => this._scheduleClose();
573
+ // Hover-close is a mouse affordance. On touch, lifting the finger fires
574
+ // pointerleave; without this guard a tap-opened submenu would close itself
575
+ // ~200ms after the tap. On touch the submenu stays open until tapped again.
576
+ _scheduleCloseHandler = (e: Event): void => {
577
+ if ((e as PointerEvent).pointerType === 'touch') return;
578
+ this._scheduleClose();
579
+ };
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
- _onEnter = (): void => (this.closest('ui-hover-card') as UiHoverCard | null)?.show();
186
- _onLeave = (): void => (this.closest('ui-hover-card') as UiHoverCard | null)?.hide();
215
+ // Hover/focus open + close are MOUSE affordances. On a no-hover (touch)
216
+ // device, iOS Safari still fires SYNTHETIC mouseenter/mouseleave (and
217
+ // focusin/focusout from the inner link) around a tap, which would otherwise
218
+ // immediately re-close a tap-opened card. Gate the hover handlers to pointer
219
+ // devices so on touch the card is driven only by the tap path (#745).
220
+ _noHover = (): boolean => !!window.matchMedia?.('(hover: none)').matches;
221
+ _onEnter = (): void => {
222
+ if (this._noHover()) return;
223
+ (this.closest('ui-hover-card') as UiHoverCard | null)?.show();
224
+ };
225
+ _onLeave = (): void => {
226
+ if (this._noHover()) return;
227
+ (this.closest('ui-hover-card') as UiHoverCard | null)?.hide();
228
+ };
229
+
230
+ // Touch path. A touch device has no `mouseenter`, so a tap would fall through
231
+ // to the inner `<a href>` and navigate. On a no-hover device the trigger tap
232
+ // TOGGLES the card and NEVER navigates (the real link is reachable inside the
233
+ // opened card). It must ALWAYS preventDefault, including while open: the
234
+ // client router pushState()s on any bubble-phase `<a>` click that is not
235
+ // defaultPrevented, so a re-tap that fell through would push a history entry
236
+ // every time and Back would need N presses (#745).
237
+ _onClick = (e: Event): void => {
238
+ if (!window.matchMedia?.('(hover: none)').matches) return;
239
+ const card = this.closest('ui-hover-card') as UiHoverCard | null;
240
+ if (!card) return;
241
+ e.preventDefault();
242
+ e.stopPropagation();
243
+ if (card.open) card.open = false;
244
+ else card.openByTouch();
245
+ };
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
- _onEnter = (): void => (this.closest('ui-hover-card') as UiHoverCard | null)?.show();
210
- _onLeave = (): void => (this.closest('ui-hover-card') as UiHoverCard | null)?.hide();
268
+ // Hover/focus open + close are MOUSE affordances. On a no-hover (touch)
269
+ // device, iOS Safari still fires SYNTHETIC mouseenter/mouseleave (and
270
+ // focusin/focusout from the inner link) around a tap, which would otherwise
271
+ // immediately re-close a tap-opened card. Gate the hover handlers to pointer
272
+ // devices so on touch the card is driven only by the tap path (#745).
273
+ _noHover = (): boolean => !!window.matchMedia?.('(hover: none)').matches;
274
+ _onEnter = (): void => {
275
+ if (this._noHover()) return;
276
+ (this.closest('ui-hover-card') as UiHoverCard | null)?.show();
277
+ };
278
+ _onLeave = (): void => {
279
+ if (this._noHover()) return;
280
+ (this.closest('ui-hover-card') as UiHoverCard | null)?.hide();
281
+ };
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
- 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.
@@ -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
+ }