83 lines
3.3 KiB
TypeScript
83 lines
3.3 KiB
TypeScript
// Back/forward integration for modals (kiosk, remote browsers, mobile).
|
|
//
|
|
// Without this, the browser's Back control (mouse side-button on kiosk,
|
|
// gesture on mobile, toolbar button in a remote browser) navigates the
|
|
// ROUTER while a modal is open — at best closing the whole screen under a
|
|
// dialog, at worst leaving the app. The native-app expectation, and what
|
|
// the companion webview already provides, is: Back closes the topmost
|
|
// dialog first.
|
|
//
|
|
// Mechanics: opening a modal pushes one history entry (same URL, a depth
|
|
// marker in state — router keys are preserved by spreading the existing
|
|
// state). A popstate that lands BELOW our depth means the user pressed
|
|
// Back over an open modal: close the topmost one. A UI-side close (X,
|
|
// backdrop, Esc) consumes its own entry with history.back() so Back never
|
|
// needs pressing twice — guarded by the depth marker so it can never eat
|
|
// a router entry. One module-level stack serves every BaseModal instance,
|
|
// so stacked modals close one per Back, top first.
|
|
import { watch, type Ref } from 'vue'
|
|
|
|
type Entry = { close: () => void }
|
|
|
|
const stack: Entry[] = []
|
|
// Set when a popstate initiated the close: the history entry is already
|
|
// gone, so the close-side cleanup must not call history.back() again.
|
|
let poppedClose = false
|
|
let listening = false
|
|
|
|
function modalDepth(state: unknown): number {
|
|
return (state as { __archyModal?: number } | null)?.__archyModal ?? 0
|
|
}
|
|
|
|
function ensureListener() {
|
|
if (listening || typeof window === 'undefined') return
|
|
listening = true
|
|
window.addEventListener('popstate', (e) => {
|
|
// Landed at a depth below the open-modal count → this Back was aimed
|
|
// at the topmost modal. One entry per Back press: close exactly one.
|
|
// (A popstate at or above our depth is someone else's navigation —
|
|
// e.g. our own cleanup back, or a forward — leave it alone.)
|
|
if (modalDepth(e.state) < stack.length) {
|
|
const top = stack[stack.length - 1]
|
|
if (top) {
|
|
poppedClose = true
|
|
top.close()
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
/** Call from a modal component with its visibility and close trigger. */
|
|
export function useModalHistory(show: Ref<boolean>, close: () => void) {
|
|
ensureListener()
|
|
const entry: Entry = { close }
|
|
watch(show, (open, was) => {
|
|
if (open === was) return
|
|
if (open) {
|
|
stack.push(entry)
|
|
try {
|
|
// Preserve vue-router's own keys in state — clobbering them breaks
|
|
// its scroll restoration and position tracking.
|
|
window.history.pushState(
|
|
{ ...(window.history.state ?? {}), __archyModal: stack.length },
|
|
'',
|
|
)
|
|
} catch { /* history can throw in exotic embeds — modal still works */ }
|
|
} else {
|
|
const wasTop = stack[stack.length - 1] === entry
|
|
const i = stack.indexOf(entry)
|
|
if (i >= 0) stack.splice(i, 1)
|
|
if (poppedClose) {
|
|
poppedClose = false
|
|
return
|
|
}
|
|
// UI-side close of the top modal: consume the entry we pushed, but
|
|
// only if it is still the current one (a route change after opening
|
|
// moves history past it — backing out then would eat a real entry).
|
|
if (wasTop && modalDepth(window.history.state) > stack.length) {
|
|
try { window.history.back() } catch { /* same guard as above */ }
|
|
}
|
|
}
|
|
})
|
|
}
|