77 lines
3.1 KiB
TypeScript
77 lines
3.1 KiB
TypeScript
|
|
// In-app browser-history integration so Back/Forward step through a module's sub-views and
|
||
|
|
// overlays before leaving the page — not straight back to the previous module.
|
||
|
|
//
|
||
|
|
// Two primitives share the existing page-history (App stamps `sfPage` in history.state):
|
||
|
|
// - useHistorySubview: a module's active sub-view rides in history.state._sub, so Back inside
|
||
|
|
// a module (e.g. Messages thread → list) returns to the parent view first.
|
||
|
|
// - useBackToClose: while an overlay/modal is mounted it occupies one history entry; Back
|
||
|
|
// closes the topmost one. A module-level stack keeps nesting correct — closing one modal by
|
||
|
|
// its own button pops exactly its entry without tripping the modals underneath.
|
||
|
|
//
|
||
|
|
// setPage() pushes a clean { sfPage } entry (dropping _sub/_ov), so each page starts at its root.
|
||
|
|
import { useEffect, useRef, useState } from "react";
|
||
|
|
|
||
|
|
// --- Sub-views (value carried in history.state._sub) ---------------------------------------
|
||
|
|
export function useHistorySubview<T>(root: T): {
|
||
|
|
view: T;
|
||
|
|
open: (next: T) => void;
|
||
|
|
back: () => void;
|
||
|
|
} {
|
||
|
|
const [view, setView] = useState<T>(() => (window.history.state?._sub as T) ?? root);
|
||
|
|
useEffect(() => {
|
||
|
|
const onPop = () => setView((window.history.state?._sub as T) ?? root);
|
||
|
|
window.addEventListener("popstate", onPop);
|
||
|
|
return () => window.removeEventListener("popstate", onPop);
|
||
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
|
|
}, []);
|
||
|
|
return {
|
||
|
|
view,
|
||
|
|
open: (next: T) => {
|
||
|
|
setView(next);
|
||
|
|
window.history.pushState({ ...window.history.state, _sub: next }, "");
|
||
|
|
},
|
||
|
|
back: () => window.history.back(),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- Overlays / modals (one history entry each, nesting-safe) -------------------------------
|
||
|
|
const overlayStack: number[] = [];
|
||
|
|
let overlayCounter = 0;
|
||
|
|
// Set while we pop our OWN entry programmatically (button/ESC close) so the other overlays'
|
||
|
|
// popstate handlers don't mistake it for a user Back and close themselves too.
|
||
|
|
let suppressPop = false;
|
||
|
|
|
||
|
|
/** While the calling component is mounted, Back (or the browser/mouse back button) closes it via
|
||
|
|
* `onClose` instead of navigating away. Mount the component only while the overlay is open. */
|
||
|
|
export function useBackToClose(onClose: () => void): void {
|
||
|
|
const cb = useRef(onClose);
|
||
|
|
cb.current = onClose;
|
||
|
|
useEffect(() => {
|
||
|
|
const token = ++overlayCounter;
|
||
|
|
overlayStack.push(token);
|
||
|
|
window.history.pushState({ ...window.history.state, _ov: token }, "");
|
||
|
|
const onPop = () => {
|
||
|
|
if (suppressPop) {
|
||
|
|
suppressPop = false;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (overlayStack[overlayStack.length - 1] === token) {
|
||
|
|
overlayStack.pop();
|
||
|
|
cb.current();
|
||
|
|
}
|
||
|
|
};
|
||
|
|
window.addEventListener("popstate", onPop);
|
||
|
|
return () => {
|
||
|
|
window.removeEventListener("popstate", onPop);
|
||
|
|
const idx = overlayStack.lastIndexOf(token);
|
||
|
|
if (idx !== -1) {
|
||
|
|
// Closed programmatically (not via Back): drop our own history entry, and tell the
|
||
|
|
// remaining overlays' handlers to ignore the resulting popstate.
|
||
|
|
overlayStack.splice(idx, 1);
|
||
|
|
suppressPop = true;
|
||
|
|
window.history.back();
|
||
|
|
}
|
||
|
|
};
|
||
|
|
}, []);
|
||
|
|
}
|