@leavepulse/ui 0.75.0 → 0.76.1

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.
Files changed (42) hide show
  1. package/README.md +47 -0
  2. package/dist/assets/{LpShortcutsDialog-BUp_BX4s.css → LpShortcutsDialog-C6uzPeMf.css} +7 -7
  3. package/dist/assets/LpTabs-CJ0rYQbx.css +18 -0
  4. package/dist/chunks/{LpAddressInput-Al3HT5ey.js → LpAddressInput-De_LTYM7.js} +1 -1
  5. package/dist/chunks/{LpAppShell-CpSSm5WT.js → LpAppShell-Cpy_t1U_.js} +1 -1
  6. package/dist/chunks/{LpConfirmDialog-DY2VjDji.js → LpConfirmDialog-CphJR6Q9.js} +4 -2
  7. package/dist/chunks/{LpConfirmHost-DvSZTJ4N.js → LpConfirmHost-QSJ0zl63.js} +1 -1
  8. package/dist/chunks/{LpDrawer-RRamxbLV.js → LpDrawer-DQZixOqJ.js} +13 -0
  9. package/dist/chunks/{LpModal-BnjZ2iem.js → LpModal-C0tGg_D_.js} +13 -0
  10. package/dist/chunks/{LpModalNav-DQpV2kdn.js → LpModalNav-iDDtT9c1.js} +2 -2
  11. package/dist/chunks/{LpSidebar-BoNLKgh-.js → LpSidebar-D6zO8vLm.js} +1 -1
  12. package/dist/components/LpAddressInput.vue.js +1 -1
  13. package/dist/components/LpAppShell.vue.js +1 -1
  14. package/dist/components/LpConfirmDialog.vue.d.ts +4 -0
  15. package/dist/components/LpConfirmDialog.vue.js +1 -1
  16. package/dist/components/LpConfirmHost.vue.js +1 -1
  17. package/dist/components/LpDrawer.vue.d.ts +16 -0
  18. package/dist/components/LpDrawer.vue.js +1 -1
  19. package/dist/components/LpModal.vue.d.ts +9 -0
  20. package/dist/components/LpModal.vue.js +1 -1
  21. package/dist/components/LpModalNav.vue.js +1 -1
  22. package/dist/components/LpShortcutsDialog.vue.d.ts +4 -0
  23. package/dist/components/LpShortcutsDialog.vue.js +6 -4
  24. package/dist/components/LpSidebar.vue.js +1 -1
  25. package/dist/components/LpTabs.vue.d.ts +9 -0
  26. package/dist/components/LpTabs.vue.js +18 -4
  27. package/dist/composables/overlayHistory.d.ts +33 -0
  28. package/dist/composables/overlayHistory.js +250 -0
  29. package/dist/composables/useOverlayHistory.d.ts +45 -0
  30. package/dist/composables/useOverlayHistory.js +84 -0
  31. package/dist/index.d.ts +3 -0
  32. package/dist/index.js +12 -8
  33. package/package.json +2 -1
  34. package/src/components/LpConfirmDialog.vue +5 -1
  35. package/src/components/LpDrawer.vue +21 -0
  36. package/src/components/LpModal.vue +21 -1
  37. package/src/components/LpShortcutsDialog.vue +5 -1
  38. package/src/components/LpTabs.vue +26 -2
  39. package/src/composables/overlayHistory.ts +403 -0
  40. package/src/composables/useOverlayHistory.ts +173 -0
  41. package/src/index.ts +3 -0
  42. package/dist/assets/LpTabs-CDchzoRL.css +0 -18
@@ -4,6 +4,7 @@ import { Motion } from "motion-v"
4
4
  import { TabsContent, TabsList, TabsRoot, TabsTrigger } from "reka-ui"
5
5
  import { computed, nextTick, ref, useId, watch } from "vue"
6
6
  import { usePillTransition } from "../composables/usePillTransition"
7
+ import { useStepHistory } from "../composables/useOverlayHistory"
7
8
  import { prefersReducedMotion } from "../composables/easing"
8
9
  import LpContextMenu, { type ContextMenuItemDef } from "./LpContextMenu.vue"
9
10
  import LpSkeleton from "./LpSkeleton.vue"
@@ -65,8 +66,16 @@ const props = withDefaults(
65
66
  * icon keep their label.
66
67
  */
67
68
  compact?: boolean
69
+ /**
70
+ * Record each switch as a browser history entry, so Back returns to the
71
+ * previous tab. Unset follows the overlay the tabs sit in (a modal or a
72
+ * drawer with history on, see `provideOverlayHistory`) and is off
73
+ * elsewhere: a page's own tabs belong in its URL. `false` opts out inside
74
+ * an overlay, `true` opts in on a page that keeps no URL of its own.
75
+ */
76
+ history?: boolean
68
77
  }>(),
69
- { variant: "contained", accent: false, block: false, hoverFollow: true, compact: false },
78
+ { variant: "contained", accent: false, block: false, hoverFollow: true, compact: false, history: undefined },
70
79
  )
71
80
 
72
81
  const bottom = computed(() => props.variant === "bottom")
@@ -110,11 +119,26 @@ watch(current, () => {
110
119
  })
111
120
  })
112
121
 
113
- function select(value: string) {
122
+ function show(value: string) {
114
123
  if (props.modelValue === undefined) current.value = value
115
124
  emit("update:modelValue", value)
116
125
  }
117
126
 
127
+ const { record } = useStepHistory({
128
+ mode: () => props.history,
129
+ set: show,
130
+ has: (value) => props.items.some((item) => item.value === value && !item.disabled),
131
+ })
132
+
133
+ function select(value: string) {
134
+ const from = current.value
135
+ show(value)
136
+ // Recorded once the switch took: a bound value the app refused is not a step.
137
+ void nextTick(() => {
138
+ if (from !== undefined && current.value === value) record(from, value)
139
+ })
140
+ }
141
+
118
142
  // The pill sits under the hovered tab, falling back to the active one
119
143
  // (UAProject header behaviour). motion-v shared layoutId animates the move.
120
144
  // The id must be unique per instance — a shared constant makes the pill fly
@@ -0,0 +1,403 @@
1
+ /*
2
+ * The browser history engine behind `useOverlayHistory`: one stack of entries
3
+ * the kit pushed for open overlays and for tab switches inside them, shared by
4
+ * every component and pinned to a global symbol like the modal stack, so a
5
+ * barrel import and a deep import never keep two stacks.
6
+ *
7
+ * Only `history.pushState`, `history.go` and `popstate` are used. The
8
+ * Navigation API would make this simpler but WebKit (Safari, WebKitGTK in a
9
+ * Tauri shell) does not have it.
10
+ *
11
+ * How it stays out of a router's way:
12
+ *
13
+ * - An entry copies the state object of the entry under it (vue-router keeps
14
+ * `back`, `current`, `forward`, `position`, `replaced` and `scroll` there)
15
+ * and adds one key, so a router reading it finds its own data unchanged.
16
+ * - The URL never changes. A `popstate` between two entries of the same
17
+ * address is the kit's own and is stopped where the kit sees it first. A
18
+ * router registers its listener when it is created, earlier than the kit,
19
+ * and engines differ on whether a capture listener on `window` runs before
20
+ * it (WebKit) or not (Chromium), so the router is also handed over as an
21
+ * adapter and its `listening` switch is off while the page sits on a kit
22
+ * entry: it neither navigates nor re-renders nor restores its scroll. A
23
+ * `popstate` that changes the address is always left to the router; when
24
+ * it was muted for it (a jump of several entries from the Back menu), the
25
+ * pop is dispatched to it again.
26
+ * - Someone else's `pushState` to another path (a link, `router.push`) is a
27
+ * real navigation: the open overlays close and their entries, now buried
28
+ * under the new page, are skipped when Back or Forward lands on them.
29
+ * Someone else's `pushState` on the same path (a query or hash, as an app
30
+ * does when an overlay lives in the route query) leaves them open and the
31
+ * entries keep working once Back returns past it.
32
+ * - Someone else's `replaceState` on a kit entry keeps the kit's key, so a
33
+ * router saving its scroll position does not orphan the entry.
34
+ * - An app that keeps an overlay in its query through `replace` rewrites the
35
+ * address of the kit's entry when the overlay closes, and the entry under it
36
+ * still holds the old one. Taking the entry out lands there, so the address
37
+ * the app left is put back on it and the step never reaches the router:
38
+ * handed over, it would navigate to the old query and open the overlay again.
39
+ */
40
+
41
+ const KEY = "__lpOverlay"
42
+ const SETTLE_MS = 300
43
+
44
+ export interface HistoryEntry {
45
+ /** Back left this entry: undo what it recorded (close the overlay, restore the tab). */
46
+ back: () => void
47
+ /** Forward came back to it: redo it, and say whether that happened. */
48
+ forward?: () => boolean
49
+ /** The page moved on to another path while this entry was live. */
50
+ navigated?: () => void
51
+ /** The component that pushed it is still mounted. */
52
+ alive: boolean
53
+ }
54
+
55
+ interface Mark {
56
+ token: string
57
+ depth: number
58
+ }
59
+
60
+ interface Tracked {
61
+ entry: HistoryEntry
62
+ token: string
63
+ depth: number
64
+ }
65
+
66
+ /**
67
+ * What the kit needs of a router: vue-router's `listening` switch, or the same
68
+ * flag on any router that skips its popstate handling while it is false.
69
+ */
70
+ export interface HistoryRouter {
71
+ listening: boolean
72
+ }
73
+
74
+ interface EngineState {
75
+ router: HistoryRouter | null
76
+ replaying: boolean
77
+ /** The pop being dispatched: whether the router was muted for it, and the address before it. */
78
+ popping: { muted: boolean; before: string } | null
79
+ installed: boolean
80
+ live: Tracked[]
81
+ /** Entries Back closed, by token, for Forward to reopen. */
82
+ closed: Map<string, Tracked>
83
+ /** Entries whose owner closed or left while the page sat elsewhere. */
84
+ dead: Map<string, Tracked>
85
+ /** Pops the kit started itself and swallows on arrival. */
86
+ silent: number
87
+ /** What the page showed before the kit took entries out, to be kept where the step lands. */
88
+ keep: { href: string; state: unknown } | null
89
+ waiting: Array<() => void>
90
+ href: string
91
+ serial: number
92
+ internal: boolean
93
+ settle: number
94
+ }
95
+
96
+ const STATE_KEY = Symbol.for("@leavepulse/ui:overlay-history")
97
+ type GlobalWithEngine = typeof globalThis & { [STATE_KEY]?: EngineState }
98
+ const g = globalThis as GlobalWithEngine
99
+ const fresh: EngineState = {
100
+ router: null,
101
+ replaying: false,
102
+ popping: null,
103
+ installed: false,
104
+ live: [],
105
+ closed: new Map(),
106
+ dead: new Map(),
107
+ silent: 0,
108
+ keep: null,
109
+ waiting: [],
110
+ href: "",
111
+ serial: 0,
112
+ internal: false,
113
+ settle: 0,
114
+ }
115
+ const s = (g[STATE_KEY] ??= fresh)
116
+
117
+ function markOf(state: unknown): Mark | null {
118
+ if (!state || typeof state !== "object") return null
119
+ const mark = (state as Record<string, unknown>)[KEY]
120
+ if (!mark || typeof mark !== "object") return null
121
+ const { token, depth } = mark as Partial<Mark>
122
+ return typeof token === "string" && typeof depth === "number" ? { token, depth } : null
123
+ }
124
+
125
+ function pathOf(href: string): string {
126
+ try {
127
+ return new URL(href, location.href).pathname
128
+ } catch {
129
+ return href
130
+ }
131
+ }
132
+
133
+ function own<T>(run: () => T): T {
134
+ s.internal = true
135
+ try {
136
+ return run()
137
+ } finally {
138
+ s.internal = false
139
+ }
140
+ }
141
+
142
+ function silentGo(delta: number) {
143
+ if (delta === 0) return
144
+ s.silent++
145
+ mute()
146
+ own(() => history.go(delta))
147
+ }
148
+
149
+ /* Pushes wait for a pop the kit started: a push between `go` and its arrival would land on the wrong entry. */
150
+ function whenSettled(run: () => void) {
151
+ if (s.silent > 0) s.waiting.push(run)
152
+ else run()
153
+ }
154
+
155
+ function flushWaiting() {
156
+ if (s.silent > 0) return
157
+ const queued = s.waiting.splice(0)
158
+ for (const run of queued) run()
159
+ }
160
+
161
+ function current(): Mark | null {
162
+ return markOf(history.state)
163
+ }
164
+
165
+ function onTop(): boolean {
166
+ const top = s.live[s.live.length - 1]
167
+ return !!top && current()?.token === top.token
168
+ }
169
+
170
+ /** Everything live closes because the page went elsewhere. */
171
+ function leavePage() {
172
+ const gone = s.live.splice(0)
173
+ for (const item of gone.reverse()) {
174
+ bury(item)
175
+ item.entry.navigated?.()
176
+ }
177
+ s.closed.clear()
178
+ }
179
+
180
+ function handlePop(event: PopStateEvent) {
181
+ const before = s.href
182
+ s.href = location.href
183
+ if (s.silent > 0) {
184
+ s.silent--
185
+ const keep = s.silent === 0 ? s.keep : null
186
+ if (s.silent === 0) s.keep = null
187
+ if (keep && keep.href !== location.href) restore(keep, event)
188
+ if (before === location.href) event.stopImmediatePropagation()
189
+ flushWaiting()
190
+ return
191
+ }
192
+ window.clearTimeout(s.settle)
193
+ const sameAddress = before === location.href
194
+ const mark = markOf(event.state)
195
+ const at = mark ? s.live.findIndex((item) => item.token === mark.token) : -1
196
+
197
+ if (at >= 0) {
198
+ closeAbove(at)
199
+ if (sameAddress) event.stopImmediatePropagation()
200
+ return
201
+ }
202
+
203
+ const reopen = mark ? s.closed.get(mark.token) : undefined
204
+ if (reopen && sameAddress) {
205
+ event.stopImmediatePropagation()
206
+ if (reopen.entry.alive && reopen.entry.forward?.()) {
207
+ s.closed.delete(reopen.token)
208
+ s.live.push(reopen)
209
+ return
210
+ }
211
+ // Not something that can come back: undo the step so the entry is not left dead.
212
+ silentGo(-1)
213
+ return
214
+ }
215
+
216
+ if (mark && (s.dead.has(mark.token) || reopen)) {
217
+ if (sameAddress) event.stopImmediatePropagation()
218
+ if (!sameAddress) {
219
+ // Back from a later page onto entries buried under it: step past them to the page itself.
220
+ silentGo(-mark.depth)
221
+ return
222
+ }
223
+ // Forward onto a buried entry: carry on to whatever comes after it, or return if nothing does.
224
+ const depth = mark.depth
225
+ own(() => history.forward())
226
+ s.settle = window.setTimeout(() => {
227
+ if (markOf(history.state)?.token === mark.token) silentGo(-depth)
228
+ else mute()
229
+ }, SETTLE_MS)
230
+ return
231
+ }
232
+
233
+ if (s.live.length === 0) return
234
+ if (sameAddress) {
235
+ // The entry under the first overlay: close them all.
236
+ closeAbove(-1)
237
+ event.stopImmediatePropagation()
238
+ } else if (pathOf(before) !== pathOf(location.href)) {
239
+ leavePage()
240
+ }
241
+ }
242
+
243
+ /* The address the app left on the entry the kit took out, put back where the step landed. */
244
+ function restore(keep: { href: string; state: unknown }, event: PopStateEvent) {
245
+ const mark = markOf(event.state)
246
+ const base = keep.state && typeof keep.state === "object" ? { ...(keep.state as Record<string, unknown>) } : {}
247
+ delete base[KEY]
248
+ own(() => history.replaceState(mark ? { ...base, [KEY]: mark } : base, "", keep.href))
249
+ s.href = location.href
250
+ event.stopImmediatePropagation()
251
+ }
252
+
253
+ /* Buried entries are kept by token; the oldest go first, history this deep is never walked back. */
254
+ const DEAD_LIMIT = 200
255
+ function bury(item: Tracked) {
256
+ s.dead.set(item.token, item)
257
+ if (s.dead.size > DEAD_LIMIT) s.dead.delete(s.dead.keys().next().value as string)
258
+ }
259
+
260
+ function closeAbove(index: number) {
261
+ const popped = s.live.splice(index + 1)
262
+ for (const item of popped.reverse()) {
263
+ s.closed.set(item.token, item)
264
+ if (item.entry.alive) item.entry.back()
265
+ }
266
+ }
267
+
268
+ function onPop(event: PopStateEvent) {
269
+ if (s.replaying) return
270
+ s.popping = { muted: !!s.router && !s.router.listening, before: s.href }
271
+ handlePop(event)
272
+ // A pop the kit stopped never reaches `afterPop`; it stayed on one address, so there is nothing to replay.
273
+ if (event.cancelBubble) {
274
+ s.popping = null
275
+ mute()
276
+ }
277
+ }
278
+
279
+ /*
280
+ * Runs after every other listener of the same pop, whichever order the engine
281
+ * calls capture and bubble listeners on `window` in (Chromium by registration,
282
+ * WebKit capture first): the router has seen the pop by now, with the switch
283
+ * as it stood before it, and only now is the switch set for the next one.
284
+ */
285
+ function afterPop(event: PopStateEvent) {
286
+ if (s.replaying || !s.popping) return
287
+ const { muted, before } = s.popping
288
+ s.popping = null
289
+ if (muted && before !== location.href) replay(event.state)
290
+ mute()
291
+ }
292
+
293
+ /*
294
+ * The router was told to look away (the page sat on a kit entry) and the
295
+ * step left the address after all, a jump of several entries from the Back
296
+ * menu: hand it the same pop again, now listening, so it navigates.
297
+ */
298
+ function replay(state: unknown) {
299
+ if (!s.router) return
300
+ s.router.listening = true
301
+ s.replaying = true
302
+ try {
303
+ window.dispatchEvent(new PopStateEvent("popstate", { state }))
304
+ } finally {
305
+ s.replaying = false
306
+ }
307
+ }
308
+
309
+ /*
310
+ * A router registers its popstate listener when it is created, before the kit
311
+ * is asked to do anything, so stopping the event cannot reach it. Its own
312
+ * switch can: while the page sits on a kit entry, or a step the kit started is
313
+ * on its way, the router does not listen.
314
+ */
315
+ function mute() {
316
+ if (s.popping) return
317
+ if (s.router) s.router.listening = !(current() || s.silent > 0)
318
+ }
319
+
320
+ /** The app's router, told not to listen while a step only concerns the kit. */
321
+ export function setHistoryRouter(router: HistoryRouter | null): void {
322
+ if (s.router && s.router !== router) s.router.listening = true
323
+ s.router = router
324
+ if (typeof window === "undefined") return
325
+ // After the router's own listener, which it added when it was created.
326
+ window.removeEventListener("popstate", afterPop)
327
+ window.addEventListener("popstate", afterPop)
328
+ mute()
329
+ }
330
+
331
+ function install() {
332
+ if (s.installed || typeof window === "undefined") return
333
+ s.installed = true
334
+ s.href = location.href
335
+ window.addEventListener("popstate", onPop, { capture: true })
336
+ window.removeEventListener("popstate", afterPop)
337
+ window.addEventListener("popstate", afterPop)
338
+
339
+ const push = history.pushState.bind(history)
340
+ const replace = history.replaceState.bind(history)
341
+ history.pushState = function (data: unknown, unused: string, url?: string | URL | null) {
342
+ const before = location.href
343
+ push(data, unused, url)
344
+ s.href = location.href
345
+ if (s.internal) return
346
+ s.closed.clear()
347
+ if (pathOf(before) !== pathOf(location.href)) leavePage()
348
+ mute()
349
+ }
350
+ history.replaceState = function (data: unknown, unused: string, url?: string | URL | null) {
351
+ const mark = s.internal ? null : current()
352
+ const keep = mark && data && typeof data === "object" && !markOf(data) ? { ...(data as object), [KEY]: mark } : data
353
+ replace(keep, unused, url)
354
+ s.href = location.href
355
+ }
356
+ }
357
+
358
+ /** Record an entry on top of the history, under the current address. */
359
+ export function pushEntry(entry: HistoryEntry): void {
360
+ if (typeof window === "undefined") return
361
+ install()
362
+ whenSettled(() => {
363
+ if (!entry.alive) return
364
+ const token = `${Date.now().toString(36)}-${(s.serial++).toString(36)}`
365
+ const depth = (current()?.depth ?? 0) + 1
366
+ const base = history.state && typeof history.state === "object" ? history.state : {}
367
+ own(() => history.pushState({ ...base, [KEY]: { token, depth } }, "", location.href))
368
+ s.href = location.href
369
+ s.closed.clear()
370
+ s.live.push({ entry, token, depth })
371
+ mute()
372
+ })
373
+ }
374
+
375
+ /**
376
+ * The thing behind an entry ended without Back (a close button, Escape, the
377
+ * app setting `open` to false, an unmount): take the entry and everything
378
+ * pushed above it out of history, so Back is never spent on a dead step.
379
+ * When the page is not sitting on the kit's entries (another push came on top),
380
+ * the entry stays in place and is skipped when Back or Forward lands on it.
381
+ */
382
+ export function dropEntry(entry: HistoryEntry): void {
383
+ const at = s.live.findIndex((item) => item.entry === entry)
384
+ if (at === -1) return
385
+ const positioned = onTop()
386
+ const removed = s.live.splice(at)
387
+ if (positioned) {
388
+ s.keep = { href: location.href, state: history.state }
389
+ silentGo(-removed.length)
390
+ return
391
+ }
392
+ for (const item of removed) bury(item)
393
+ }
394
+
395
+ /** Whether the entry is live: pushed and not yet popped or dropped. */
396
+ export function isLive(entry: HistoryEntry): boolean {
397
+ return s.live.some((item) => item.entry === entry)
398
+ }
399
+
400
+ /** Live entries, bottom first. For tests and devtools. */
401
+ export function liveEntries(): number {
402
+ return s.live.length
403
+ }
@@ -0,0 +1,173 @@
1
+ import {
2
+ computed,
3
+ inject,
4
+ nextTick,
5
+ onBeforeUnmount,
6
+ provide,
7
+ toValue,
8
+ watch,
9
+ type InjectionKey,
10
+ type MaybeRefOrGetter,
11
+ } from "vue"
12
+ import { dropEntry, isLive, pushEntry, setHistoryRouter, type HistoryEntry, type HistoryRouter } from "./overlayHistory"
13
+
14
+ /*
15
+ * Browser Back and Forward for overlays. With it on, opening a modal or a
16
+ * drawer adds a history entry and Back closes the topmost one instead of
17
+ * leaving the page; nested overlays close one per press, top first. Switching
18
+ * tabs inside an open overlay adds an entry too, so Back returns to the
19
+ * previous tab before it closes the overlay. Closing through the UI (the close
20
+ * button, Escape, the scrim, the app setting `open` to false) takes the entry
21
+ * back out, so Back is never spent on a step that no longer shows anything.
22
+ *
23
+ * Off unless the app turns it on, like `provideGlass`: an app that already
24
+ * keeps some overlay in its route query would otherwise get two entries for
25
+ * one dialog. `history` on a single overlay (or tab bar) still wins.
26
+ *
27
+ * - `true`: Back closes; Forward after it does nothing (the step is undone at
28
+ * once, so the Forward button does not leave a dead entry). The kit only
29
+ * sees a boolean `open`, and whatever the app put into the overlay may be
30
+ * gone by then: a record deleted, a selection changed.
31
+ * - `"reopen"`: Forward also opens it again, by emitting `update:open` true,
32
+ * while the overlay is still mounted. For overlays whose content does not
33
+ * depend on a moment that passed (settings, help, a fixed form).
34
+ * - `false`: the overlay never touches history.
35
+ */
36
+ export type OverlayHistoryMode = boolean | "reopen"
37
+
38
+ const MODE: InjectionKey<MaybeRefOrGetter<OverlayHistoryMode>> = Symbol("lp-overlay-history")
39
+
40
+ interface OverlayScope {
41
+ /** Whether a step recorded now would sit on the overlay's own entry. */
42
+ active: () => boolean
43
+ }
44
+ const SCOPE: InjectionKey<OverlayScope> = Symbol("lp-overlay-history-scope")
45
+
46
+ export interface ProvideOverlayHistoryOptions {
47
+ /**
48
+ * The app's router, so a Back or Forward that only closes an overlay does
49
+ * not reach it as a navigation (vue-router: the router instance). Its
50
+ * popstate listener runs before the kit's, so without this it sees each of
51
+ * those steps as a visit to the page it is already on: guards and
52
+ * `afterEach` run again and the route object is replaced, though nothing
53
+ * remounts. One router per page; the latest one given is used.
54
+ */
55
+ router?: HistoryRouter
56
+ }
57
+
58
+ /** Turn browser history on for every overlay below the calling component. */
59
+ export function provideOverlayHistory(
60
+ mode: MaybeRefOrGetter<OverlayHistoryMode> = true,
61
+ options: ProvideOverlayHistoryOptions = {},
62
+ ): void {
63
+ provide(MODE, mode)
64
+ if (options.router) setHistoryRouter(options.router)
65
+ }
66
+
67
+ export interface OverlayHistoryOptions {
68
+ /** The overlay's own `history` prop; `undefined` follows `provideOverlayHistory`. */
69
+ mode: () => OverlayHistoryMode | undefined
70
+ open: () => boolean
71
+ /** Back left the entry: close the way the overlay's own ways out do. */
72
+ close: () => Promise<unknown>
73
+ /** Forward under `"reopen"`: ask the app to open it again (`update:open` true). */
74
+ reopen: () => void
75
+ /** The page went to another path: close without asking, the place it belonged to is gone. */
76
+ navigated: () => void
77
+ }
78
+
79
+ /** Wire an overlay's `open` to a history entry. */
80
+ export function useOverlayHistory(options: OverlayHistoryOptions): void {
81
+ const inherited = inject(MODE, false)
82
+ const mode = computed<OverlayHistoryMode>(() => options.mode() ?? toValue(inherited))
83
+
84
+ const entry: HistoryEntry = {
85
+ alive: true,
86
+ back: () => {
87
+ // A guard, or an app that ignored `update:open`, kept it open: it still owns a step of history.
88
+ void options.close().then(() =>
89
+ nextTick(() => {
90
+ if (entry.alive && options.open() && !isLive(entry)) pushEntry(entry)
91
+ }),
92
+ )
93
+ },
94
+ forward: () => {
95
+ if (mode.value !== "reopen") return false
96
+ options.reopen()
97
+ void nextTick(() => {
98
+ if (!options.open() && isLive(entry)) dropEntry(entry)
99
+ })
100
+ return true
101
+ },
102
+ navigated: () => {
103
+ if (options.open()) options.navigated()
104
+ },
105
+ }
106
+
107
+ watch(
108
+ () => [options.open(), mode.value !== false] as const,
109
+ ([open, enabled]) => {
110
+ if (open && enabled) {
111
+ if (!isLive(entry)) pushEntry(entry)
112
+ } else if (isLive(entry)) {
113
+ dropEntry(entry)
114
+ }
115
+ },
116
+ { immediate: true },
117
+ )
118
+
119
+ onBeforeUnmount(() => {
120
+ entry.alive = false
121
+ if (isLive(entry)) dropEntry(entry)
122
+ })
123
+
124
+ provide(SCOPE, { active: () => mode.value !== false && options.open() && isLive(entry) })
125
+ }
126
+
127
+ export interface StepHistoryOptions {
128
+ /** The component's own `history` prop; `undefined` follows the overlay it sits in. */
129
+ mode: () => boolean | undefined
130
+ /** Show this value without recording it. */
131
+ set: (value: string) => void
132
+ /** Whether the value can still be shown (the tab exists and is enabled). */
133
+ has: (value: string) => boolean
134
+ }
135
+
136
+ /**
137
+ * Steps inside an overlay (a tab switch) as history entries. Outside an overlay
138
+ * it is off unless asked for: a page's own tabs belong in its URL, where the
139
+ * app's router already keeps them.
140
+ */
141
+ export function useStepHistory(options: StepHistoryOptions): { record: (from: string, to: string) => void } {
142
+ const scope = inject(SCOPE, null)
143
+ const enabled = () => {
144
+ const own = options.mode()
145
+ if (own === false) return false
146
+ if (scope) return scope.active()
147
+ return own === true
148
+ }
149
+ const entries: HistoryEntry[] = []
150
+
151
+ function record(from: string, to: string) {
152
+ if (from === to || !enabled()) return
153
+ const entry: HistoryEntry = {
154
+ alive: true,
155
+ back: () => options.set(from),
156
+ forward: () => {
157
+ if (!enabled() || !options.has(to)) return false
158
+ options.set(to)
159
+ return true
160
+ },
161
+ }
162
+ entries.splice(0, entries.length, ...entries.filter(isLive), entry)
163
+ pushEntry(entry)
164
+ }
165
+
166
+ onBeforeUnmount(() => {
167
+ for (const entry of entries) entry.alive = false
168
+ const first = entries.find(isLive)
169
+ if (first) dropEntry(first)
170
+ })
171
+
172
+ return { record }
173
+ }
package/src/index.ts CHANGED
@@ -358,6 +358,9 @@ export type { UseGamepadNavOptions } from "./composables/useGamepadNav"
358
358
  export { focusableIn, nextInDirection } from "./composables/spatialNav"
359
359
  export type { Box, Direction } from "./composables/spatialNav"
360
360
  export { provideGlass, useGlass } from "./composables/useGlass"
361
+ export { provideOverlayHistory, useOverlayHistory, useStepHistory } from "./composables/useOverlayHistory"
362
+ export type { OverlayHistoryMode, OverlayHistoryOptions, ProvideOverlayHistoryOptions, StepHistoryOptions } from "./composables/useOverlayHistory"
363
+ export type { HistoryRouter } from "./composables/overlayHistory"
361
364
  export { LP_CONFIG, lpConfig, mergeConfig, provideLpConfig, useLabels, useLinkComponent, useLpConfig } from "./composables/useLpConfig"
362
365
  export type { KitStorage, LabelScope, LpConfig } from "./composables/useLpConfig"
363
366
  export { browserStorage, useKitStorage, usePersisted } from "./composables/usePersisted"
@@ -1,18 +0,0 @@
1
-
2
- .lp-tab-badge-enter-active[data-v-9f51a497],
3
- .lp-tab-badge-leave-active[data-v-9f51a497] {
4
- transition:
5
- opacity var(--duration-fast) var(--ease-emphasized),
6
- transform var(--duration-fast) var(--ease-emphasized);
7
- }
8
- .lp-tab-badge-enter-from[data-v-9f51a497],
9
- .lp-tab-badge-leave-to[data-v-9f51a497] {
10
- opacity: 0;
11
- transform: scale(0.6);
12
- }
13
- @media (prefers-reduced-motion: reduce) {
14
- .lp-tab-badge-enter-active[data-v-9f51a497],
15
- .lp-tab-badge-leave-active[data-v-9f51a497] {
16
- transition: none;
17
- }
18
- }