@leavepulse/ui 0.74.2 → 0.76.0

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 (52) hide show
  1. package/README.md +44 -0
  2. package/dist/assets/{LpShortcutsDialog-BUp_BX4s.css → LpShortcutsDialog-C6uzPeMf.css} +7 -7
  3. package/dist/assets/{LpTable-DHvorDiU.css → LpTable-Bv-EcQs7.css} +19 -19
  4. package/dist/assets/LpTabs-CJ0rYQbx.css +18 -0
  5. package/dist/chunks/{LpAddressInput-Al3HT5ey.js → LpAddressInput-De_LTYM7.js} +1 -1
  6. package/dist/chunks/{LpAppShell-CpSSm5WT.js → LpAppShell-Cpy_t1U_.js} +1 -1
  7. package/dist/chunks/{LpConfirmDialog-DY2VjDji.js → LpConfirmDialog-CphJR6Q9.js} +4 -2
  8. package/dist/chunks/{LpConfirmHost-DvSZTJ4N.js → LpConfirmHost-QSJ0zl63.js} +1 -1
  9. package/dist/chunks/{LpDrawer-RRamxbLV.js → LpDrawer-DQZixOqJ.js} +13 -0
  10. package/dist/chunks/{LpModal-BnjZ2iem.js → LpModal-C0tGg_D_.js} +13 -0
  11. package/dist/chunks/{LpModalNav-DQpV2kdn.js → LpModalNav-iDDtT9c1.js} +2 -2
  12. package/dist/chunks/{LpSidebar-BoNLKgh-.js → LpSidebar-D6zO8vLm.js} +1 -1
  13. package/dist/components/LpAddressInput.vue.js +1 -1
  14. package/dist/components/LpAppShell.vue.js +1 -1
  15. package/dist/components/LpConfirmDialog.vue.d.ts +4 -0
  16. package/dist/components/LpConfirmDialog.vue.js +1 -1
  17. package/dist/components/LpConfirmHost.vue.js +1 -1
  18. package/dist/components/LpDrawer.vue.d.ts +16 -0
  19. package/dist/components/LpDrawer.vue.js +1 -1
  20. package/dist/components/LpModal.vue.d.ts +9 -0
  21. package/dist/components/LpModal.vue.js +1 -1
  22. package/dist/components/LpModalNav.vue.js +1 -1
  23. package/dist/components/LpShortcutsDialog.vue.d.ts +4 -0
  24. package/dist/components/LpShortcutsDialog.vue.js +6 -4
  25. package/dist/components/LpSidebar.vue.js +1 -1
  26. package/dist/components/LpTable.vue.d.ts +37 -0
  27. package/dist/components/LpTable.vue.js +34 -15
  28. package/dist/components/LpTabs.vue.d.ts +9 -0
  29. package/dist/components/LpTabs.vue.js +18 -4
  30. package/dist/components/tableFilter.d.ts +28 -0
  31. package/dist/components/tableFilter.js +43 -0
  32. package/dist/composables/overlayHistory.d.ts +33 -0
  33. package/dist/composables/overlayHistory.js +237 -0
  34. package/dist/composables/useOverlayHistory.d.ts +45 -0
  35. package/dist/composables/useOverlayHistory.js +84 -0
  36. package/dist/composables/useTableFilterChoice.d.ts +16 -0
  37. package/dist/composables/useTableFilterChoice.js +34 -0
  38. package/dist/index.d.ts +6 -0
  39. package/dist/index.js +18 -8
  40. package/package.json +2 -1
  41. package/src/components/LpConfirmDialog.vue +5 -1
  42. package/src/components/LpDrawer.vue +21 -0
  43. package/src/components/LpModal.vue +21 -1
  44. package/src/components/LpShortcutsDialog.vue +5 -1
  45. package/src/components/LpTable.vue +86 -23
  46. package/src/components/LpTabs.vue +26 -2
  47. package/src/components/tableFilter.ts +66 -0
  48. package/src/composables/overlayHistory.ts +381 -0
  49. package/src/composables/useOverlayHistory.ts +173 -0
  50. package/src/composables/useTableFilterChoice.ts +51 -0
  51. package/src/index.ts +6 -0
  52. 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
@@ -27,6 +27,72 @@ export function unhideValues(hidden: ReadonlySet<unknown>, values: readonly unkn
27
27
  return next
28
28
  }
29
29
 
30
+ /**
31
+ * A table's header filters as a consumer holds them (v-model:filters on
32
+ * LpTable): per column key, the values hidden. A column that is absent or has
33
+ * an empty list is not filtered. Hidden rather than shown for the same reason
34
+ * as inside the table: a value nobody has seen before shows up.
35
+ */
36
+ export type TableFilters = Record<string, unknown[]>
37
+
38
+ /** The hidden list that leaves only `keep` of `values` showing. */
39
+ export function showOnly(values: readonly unknown[], keep: readonly unknown[]): unknown[] {
40
+ return [...onlyValues(values, keep)]
41
+ }
42
+
43
+ /** Which of `values` a column's filter still shows. */
44
+ export function shownValues(values: readonly unknown[], hidden: readonly unknown[] | undefined): unknown[] {
45
+ if (!hidden?.length) return [...values]
46
+ const out = new Set(hidden)
47
+ return values.filter((v) => !out.has(v))
48
+ }
49
+
50
+ /**
51
+ * The rows a set of header filters leaves, for the same data drawn somewhere
52
+ * the table is not (cards on a phone, a CSV built by the page).
53
+ */
54
+ export function filterRows<Row extends Record<string, unknown>>(rows: readonly Row[], filters: TableFilters): Row[] {
55
+ const active = Object.entries(filters).filter(([, hidden]) => hidden.length).map(([key, hidden]) => [key, new Set(hidden)] as const)
56
+ if (!active.length) return [...rows]
57
+ return rows.filter((row) => active.every(([key, hidden]) => !hidden.has(row[key])))
58
+ }
59
+
60
+ /** Sets per column, from the plain lists a consumer writes. */
61
+ export function toHiddenSets(filters: TableFilters): Record<string, Set<unknown>> {
62
+ return Object.fromEntries(Object.entries(filters).map(([key, values]) => [key, new Set(values)]))
63
+ }
64
+
65
+ /** The plain lists a consumer reads, empty columns left out. */
66
+ export function fromHiddenSets(hidden: Record<string, ReadonlySet<unknown>>): TableFilters {
67
+ return Object.fromEntries(Object.entries(hidden).filter(([, set]) => set.size).map(([key, set]) => [key, [...set]]))
68
+ }
69
+
70
+ /** Whether two filter states hide the same values, empty columns counting as absent. */
71
+ export function sameHidden(a: Record<string, ReadonlySet<unknown>>, b: Record<string, ReadonlySet<unknown>>): boolean {
72
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)])
73
+ for (const key of keys) {
74
+ const x = a[key]
75
+ const y = b[key]
76
+ if ((x?.size ?? 0) !== (y?.size ?? 0)) return false
77
+ if (x && y) for (const v of x) if (!y.has(v)) return false
78
+ }
79
+ return true
80
+ }
81
+
82
+ /**
83
+ * Persisted filters meeting the ones a parent already set: the parent's
84
+ * explicit state (a deep link, a control above the table) wins per column,
85
+ * and what was remembered fills only the columns it leaves alone.
86
+ */
87
+ export function mergeRestored(
88
+ current: Record<string, ReadonlySet<unknown>>,
89
+ restored: Record<string, unknown[]>,
90
+ ): Record<string, Set<unknown>> {
91
+ const out: Record<string, Set<unknown>> = Object.fromEntries(Object.entries(current).map(([k, v]) => [k, new Set(v)]))
92
+ for (const [key, values] of Object.entries(restored)) if (!out[key]?.size) out[key] = new Set(values)
93
+ return out
94
+ }
95
+
30
96
  /** Lower-cased and stripped of accents, so "cafe" finds "Café". */
31
97
  export function foldText(text: string): string {
32
98
  return text.normalize("NFKD").replace(/\p{M}/gu, "").toLocaleLowerCase()
@@ -0,0 +1,381 @@
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
+ */
35
+
36
+ const KEY = "__lpOverlay"
37
+ const SETTLE_MS = 300
38
+
39
+ export interface HistoryEntry {
40
+ /** Back left this entry: undo what it recorded (close the overlay, restore the tab). */
41
+ back: () => void
42
+ /** Forward came back to it: redo it, and say whether that happened. */
43
+ forward?: () => boolean
44
+ /** The page moved on to another path while this entry was live. */
45
+ navigated?: () => void
46
+ /** The component that pushed it is still mounted. */
47
+ alive: boolean
48
+ }
49
+
50
+ interface Mark {
51
+ token: string
52
+ depth: number
53
+ }
54
+
55
+ interface Tracked {
56
+ entry: HistoryEntry
57
+ token: string
58
+ depth: number
59
+ }
60
+
61
+ /**
62
+ * What the kit needs of a router: vue-router's `listening` switch, or the same
63
+ * flag on any router that skips its popstate handling while it is false.
64
+ */
65
+ export interface HistoryRouter {
66
+ listening: boolean
67
+ }
68
+
69
+ interface EngineState {
70
+ router: HistoryRouter | null
71
+ replaying: boolean
72
+ /** The pop being dispatched: whether the router was muted for it, and the address before it. */
73
+ popping: { muted: boolean; before: string } | null
74
+ installed: boolean
75
+ live: Tracked[]
76
+ /** Entries Back closed, by token, for Forward to reopen. */
77
+ closed: Map<string, Tracked>
78
+ /** Entries whose owner closed or left while the page sat elsewhere. */
79
+ dead: Map<string, Tracked>
80
+ /** Pops the kit started itself and swallows on arrival. */
81
+ silent: number
82
+ waiting: Array<() => void>
83
+ href: string
84
+ serial: number
85
+ internal: boolean
86
+ settle: number
87
+ }
88
+
89
+ const STATE_KEY = Symbol.for("@leavepulse/ui:overlay-history")
90
+ type GlobalWithEngine = typeof globalThis & { [STATE_KEY]?: EngineState }
91
+ const g = globalThis as GlobalWithEngine
92
+ const fresh: EngineState = {
93
+ router: null,
94
+ replaying: false,
95
+ popping: null,
96
+ installed: false,
97
+ live: [],
98
+ closed: new Map(),
99
+ dead: new Map(),
100
+ silent: 0,
101
+ waiting: [],
102
+ href: "",
103
+ serial: 0,
104
+ internal: false,
105
+ settle: 0,
106
+ }
107
+ const s = (g[STATE_KEY] ??= fresh)
108
+
109
+ function markOf(state: unknown): Mark | null {
110
+ if (!state || typeof state !== "object") return null
111
+ const mark = (state as Record<string, unknown>)[KEY]
112
+ if (!mark || typeof mark !== "object") return null
113
+ const { token, depth } = mark as Partial<Mark>
114
+ return typeof token === "string" && typeof depth === "number" ? { token, depth } : null
115
+ }
116
+
117
+ function pathOf(href: string): string {
118
+ try {
119
+ return new URL(href, location.href).pathname
120
+ } catch {
121
+ return href
122
+ }
123
+ }
124
+
125
+ function own<T>(run: () => T): T {
126
+ s.internal = true
127
+ try {
128
+ return run()
129
+ } finally {
130
+ s.internal = false
131
+ }
132
+ }
133
+
134
+ function silentGo(delta: number) {
135
+ if (delta === 0) return
136
+ s.silent++
137
+ mute()
138
+ own(() => history.go(delta))
139
+ }
140
+
141
+ /* Pushes wait for a pop the kit started: a push between `go` and its arrival would land on the wrong entry. */
142
+ function whenSettled(run: () => void) {
143
+ if (s.silent > 0) s.waiting.push(run)
144
+ else run()
145
+ }
146
+
147
+ function flushWaiting() {
148
+ if (s.silent > 0) return
149
+ const queued = s.waiting.splice(0)
150
+ for (const run of queued) run()
151
+ }
152
+
153
+ function current(): Mark | null {
154
+ return markOf(history.state)
155
+ }
156
+
157
+ function onTop(): boolean {
158
+ const top = s.live[s.live.length - 1]
159
+ return !!top && current()?.token === top.token
160
+ }
161
+
162
+ /** Everything live closes because the page went elsewhere. */
163
+ function leavePage() {
164
+ const gone = s.live.splice(0)
165
+ for (const item of gone.reverse()) {
166
+ bury(item)
167
+ item.entry.navigated?.()
168
+ }
169
+ s.closed.clear()
170
+ }
171
+
172
+ function handlePop(event: PopStateEvent) {
173
+ const before = s.href
174
+ s.href = location.href
175
+ if (s.silent > 0) {
176
+ s.silent--
177
+ if (before === location.href) event.stopImmediatePropagation()
178
+ flushWaiting()
179
+ return
180
+ }
181
+ window.clearTimeout(s.settle)
182
+ const sameAddress = before === location.href
183
+ const mark = markOf(event.state)
184
+ const at = mark ? s.live.findIndex((item) => item.token === mark.token) : -1
185
+
186
+ if (at >= 0) {
187
+ closeAbove(at)
188
+ if (sameAddress) event.stopImmediatePropagation()
189
+ return
190
+ }
191
+
192
+ const reopen = mark ? s.closed.get(mark.token) : undefined
193
+ if (reopen && sameAddress) {
194
+ event.stopImmediatePropagation()
195
+ if (reopen.entry.alive && reopen.entry.forward?.()) {
196
+ s.closed.delete(reopen.token)
197
+ s.live.push(reopen)
198
+ return
199
+ }
200
+ // Not something that can come back: undo the step so the entry is not left dead.
201
+ silentGo(-1)
202
+ return
203
+ }
204
+
205
+ if (mark && (s.dead.has(mark.token) || reopen)) {
206
+ if (sameAddress) event.stopImmediatePropagation()
207
+ if (!sameAddress) {
208
+ // Back from a later page onto entries buried under it: step past them to the page itself.
209
+ silentGo(-mark.depth)
210
+ return
211
+ }
212
+ // Forward onto a buried entry: carry on to whatever comes after it, or return if nothing does.
213
+ const depth = mark.depth
214
+ own(() => history.forward())
215
+ s.settle = window.setTimeout(() => {
216
+ if (markOf(history.state)?.token === mark.token) silentGo(-depth)
217
+ else mute()
218
+ }, SETTLE_MS)
219
+ return
220
+ }
221
+
222
+ if (s.live.length === 0) return
223
+ if (sameAddress) {
224
+ // The entry under the first overlay: close them all.
225
+ closeAbove(-1)
226
+ event.stopImmediatePropagation()
227
+ } else if (pathOf(before) !== pathOf(location.href)) {
228
+ leavePage()
229
+ }
230
+ }
231
+
232
+ /* Buried entries are kept by token; the oldest go first, history this deep is never walked back. */
233
+ const DEAD_LIMIT = 200
234
+ function bury(item: Tracked) {
235
+ s.dead.set(item.token, item)
236
+ if (s.dead.size > DEAD_LIMIT) s.dead.delete(s.dead.keys().next().value as string)
237
+ }
238
+
239
+ function closeAbove(index: number) {
240
+ const popped = s.live.splice(index + 1)
241
+ for (const item of popped.reverse()) {
242
+ s.closed.set(item.token, item)
243
+ if (item.entry.alive) item.entry.back()
244
+ }
245
+ }
246
+
247
+ function onPop(event: PopStateEvent) {
248
+ if (s.replaying) return
249
+ s.popping = { muted: !!s.router && !s.router.listening, before: s.href }
250
+ handlePop(event)
251
+ // A pop the kit stopped never reaches `afterPop`; it stayed on one address, so there is nothing to replay.
252
+ if (event.cancelBubble) {
253
+ s.popping = null
254
+ mute()
255
+ }
256
+ }
257
+
258
+ /*
259
+ * Runs after every other listener of the same pop, whichever order the engine
260
+ * calls capture and bubble listeners on `window` in (Chromium by registration,
261
+ * WebKit capture first): the router has seen the pop by now, with the switch
262
+ * as it stood before it, and only now is the switch set for the next one.
263
+ */
264
+ function afterPop(event: PopStateEvent) {
265
+ if (s.replaying || !s.popping) return
266
+ const { muted, before } = s.popping
267
+ s.popping = null
268
+ if (muted && before !== location.href) replay(event.state)
269
+ mute()
270
+ }
271
+
272
+ /*
273
+ * The router was told to look away (the page sat on a kit entry) and the
274
+ * step left the address after all, a jump of several entries from the Back
275
+ * menu: hand it the same pop again, now listening, so it navigates.
276
+ */
277
+ function replay(state: unknown) {
278
+ if (!s.router) return
279
+ s.router.listening = true
280
+ s.replaying = true
281
+ try {
282
+ window.dispatchEvent(new PopStateEvent("popstate", { state }))
283
+ } finally {
284
+ s.replaying = false
285
+ }
286
+ }
287
+
288
+ /*
289
+ * A router registers its popstate listener when it is created, before the kit
290
+ * is asked to do anything, so stopping the event cannot reach it. Its own
291
+ * switch can: while the page sits on a kit entry, or a step the kit started is
292
+ * on its way, the router does not listen.
293
+ */
294
+ function mute() {
295
+ if (s.popping) return
296
+ if (s.router) s.router.listening = !(current() || s.silent > 0)
297
+ }
298
+
299
+ /** The app's router, told not to listen while a step only concerns the kit. */
300
+ export function setHistoryRouter(router: HistoryRouter | null): void {
301
+ if (s.router && s.router !== router) s.router.listening = true
302
+ s.router = router
303
+ if (typeof window === "undefined") return
304
+ // After the router's own listener, which it added when it was created.
305
+ window.removeEventListener("popstate", afterPop)
306
+ window.addEventListener("popstate", afterPop)
307
+ mute()
308
+ }
309
+
310
+ function install() {
311
+ if (s.installed || typeof window === "undefined") return
312
+ s.installed = true
313
+ s.href = location.href
314
+ window.addEventListener("popstate", onPop, { capture: true })
315
+ window.removeEventListener("popstate", afterPop)
316
+ window.addEventListener("popstate", afterPop)
317
+
318
+ const push = history.pushState.bind(history)
319
+ const replace = history.replaceState.bind(history)
320
+ history.pushState = function (data: unknown, unused: string, url?: string | URL | null) {
321
+ const before = location.href
322
+ push(data, unused, url)
323
+ s.href = location.href
324
+ if (s.internal) return
325
+ s.closed.clear()
326
+ if (pathOf(before) !== pathOf(location.href)) leavePage()
327
+ mute()
328
+ }
329
+ history.replaceState = function (data: unknown, unused: string, url?: string | URL | null) {
330
+ const mark = s.internal ? null : current()
331
+ const keep = mark && data && typeof data === "object" && !markOf(data) ? { ...(data as object), [KEY]: mark } : data
332
+ replace(keep, unused, url)
333
+ s.href = location.href
334
+ }
335
+ }
336
+
337
+ /** Record an entry on top of the history, under the current address. */
338
+ export function pushEntry(entry: HistoryEntry): void {
339
+ if (typeof window === "undefined") return
340
+ install()
341
+ whenSettled(() => {
342
+ if (!entry.alive) return
343
+ const token = `${Date.now().toString(36)}-${(s.serial++).toString(36)}`
344
+ const depth = (current()?.depth ?? 0) + 1
345
+ const base = history.state && typeof history.state === "object" ? history.state : {}
346
+ own(() => history.pushState({ ...base, [KEY]: { token, depth } }, "", location.href))
347
+ s.href = location.href
348
+ s.closed.clear()
349
+ s.live.push({ entry, token, depth })
350
+ mute()
351
+ })
352
+ }
353
+
354
+ /**
355
+ * The thing behind an entry ended without Back (a close button, Escape, the
356
+ * app setting `open` to false, an unmount): take the entry and everything
357
+ * pushed above it out of history, so Back is never spent on a dead step.
358
+ * When the page is not sitting on the kit's entries (another push came on top),
359
+ * the entry stays in place and is skipped when Back or Forward lands on it.
360
+ */
361
+ export function dropEntry(entry: HistoryEntry): void {
362
+ const at = s.live.findIndex((item) => item.entry === entry)
363
+ if (at === -1) return
364
+ const positioned = onTop()
365
+ const removed = s.live.splice(at)
366
+ if (positioned) {
367
+ silentGo(-removed.length)
368
+ return
369
+ }
370
+ for (const item of removed) bury(item)
371
+ }
372
+
373
+ /** Whether the entry is live: pushed and not yet popped or dropped. */
374
+ export function isLive(entry: HistoryEntry): boolean {
375
+ return s.live.some((item) => item.entry === entry)
376
+ }
377
+
378
+ /** Live entries, bottom first. For tests and devtools. */
379
+ export function liveEntries(): number {
380
+ return s.live.length
381
+ }
@@ -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
+ }