@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.
- package/README.md +44 -0
- package/dist/assets/{LpShortcutsDialog-BUp_BX4s.css → LpShortcutsDialog-C6uzPeMf.css} +7 -7
- package/dist/assets/{LpTable-DHvorDiU.css → LpTable-Bv-EcQs7.css} +19 -19
- package/dist/assets/LpTabs-CJ0rYQbx.css +18 -0
- package/dist/chunks/{LpAddressInput-Al3HT5ey.js → LpAddressInput-De_LTYM7.js} +1 -1
- package/dist/chunks/{LpAppShell-CpSSm5WT.js → LpAppShell-Cpy_t1U_.js} +1 -1
- package/dist/chunks/{LpConfirmDialog-DY2VjDji.js → LpConfirmDialog-CphJR6Q9.js} +4 -2
- package/dist/chunks/{LpConfirmHost-DvSZTJ4N.js → LpConfirmHost-QSJ0zl63.js} +1 -1
- package/dist/chunks/{LpDrawer-RRamxbLV.js → LpDrawer-DQZixOqJ.js} +13 -0
- package/dist/chunks/{LpModal-BnjZ2iem.js → LpModal-C0tGg_D_.js} +13 -0
- package/dist/chunks/{LpModalNav-DQpV2kdn.js → LpModalNav-iDDtT9c1.js} +2 -2
- package/dist/chunks/{LpSidebar-BoNLKgh-.js → LpSidebar-D6zO8vLm.js} +1 -1
- package/dist/components/LpAddressInput.vue.js +1 -1
- package/dist/components/LpAppShell.vue.js +1 -1
- package/dist/components/LpConfirmDialog.vue.d.ts +4 -0
- package/dist/components/LpConfirmDialog.vue.js +1 -1
- package/dist/components/LpConfirmHost.vue.js +1 -1
- package/dist/components/LpDrawer.vue.d.ts +16 -0
- package/dist/components/LpDrawer.vue.js +1 -1
- package/dist/components/LpModal.vue.d.ts +9 -0
- package/dist/components/LpModal.vue.js +1 -1
- package/dist/components/LpModalNav.vue.js +1 -1
- package/dist/components/LpShortcutsDialog.vue.d.ts +4 -0
- package/dist/components/LpShortcutsDialog.vue.js +6 -4
- package/dist/components/LpSidebar.vue.js +1 -1
- package/dist/components/LpTable.vue.d.ts +37 -0
- package/dist/components/LpTable.vue.js +34 -15
- package/dist/components/LpTabs.vue.d.ts +9 -0
- package/dist/components/LpTabs.vue.js +18 -4
- package/dist/components/tableFilter.d.ts +28 -0
- package/dist/components/tableFilter.js +43 -0
- package/dist/composables/overlayHistory.d.ts +33 -0
- package/dist/composables/overlayHistory.js +237 -0
- package/dist/composables/useOverlayHistory.d.ts +45 -0
- package/dist/composables/useOverlayHistory.js +84 -0
- package/dist/composables/useTableFilterChoice.d.ts +16 -0
- package/dist/composables/useTableFilterChoice.js +34 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +18 -8
- package/package.json +2 -1
- package/src/components/LpConfirmDialog.vue +5 -1
- package/src/components/LpDrawer.vue +21 -0
- package/src/components/LpModal.vue +21 -1
- package/src/components/LpShortcutsDialog.vue +5 -1
- package/src/components/LpTable.vue +86 -23
- package/src/components/LpTabs.vue +26 -2
- package/src/components/tableFilter.ts +66 -0
- package/src/composables/overlayHistory.ts +381 -0
- package/src/composables/useOverlayHistory.ts +173 -0
- package/src/composables/useTableFilterChoice.ts +51 -0
- package/src/index.ts +6 -0
- 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
|
|
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
|
+
}
|