Archipelago — open-source initial import
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
// 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 */ }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user