108 lines
3.4 KiB
TypeScript
108 lines
3.4 KiB
TypeScript
/**
|
|
* ViewHistory — lightweight in-app navigation stack for rApps.
|
|
*
|
|
* Each rApp with hierarchical views instantiates one, calls push()
|
|
* on forward navigation, and back() from the back button. Replaces
|
|
* hardcoded data-back targets with a proper history stack.
|
|
*
|
|
* Browser history integration (opt-in):
|
|
* Pass `moduleId` to the constructor to sync push/back with the
|
|
* browser's history.pushState / popstate. TabCache's popstate handler
|
|
* dispatches `rspace-view-popstate` events for same-module navigation,
|
|
* which this class catches and calls `back()` automatically, then
|
|
* re-dispatches as `rspace-view-restored` for the component to re-render.
|
|
*/
|
|
|
|
export interface ViewEntry<V extends string> {
|
|
view: V;
|
|
context?: Record<string, unknown>;
|
|
}
|
|
|
|
const MAX_DEPTH = 20;
|
|
|
|
export class ViewHistory<V extends string> {
|
|
private stack: ViewEntry<V>[] = [];
|
|
private root: V;
|
|
private moduleId: string | null;
|
|
private _skipNextPush = false;
|
|
|
|
/**
|
|
* @param rootView Default view when stack is empty.
|
|
* @param moduleId If provided, push/back sync with browser history.
|
|
*/
|
|
constructor(rootView: V, moduleId?: string) {
|
|
this.root = rootView;
|
|
this.moduleId = moduleId ?? null;
|
|
|
|
if (this.moduleId) {
|
|
window.addEventListener('rspace-view-popstate', this._onPopstate as EventListener);
|
|
}
|
|
}
|
|
|
|
/** Record a forward navigation. Skips if top of stack is same view+context. */
|
|
push(view: V, context?: Record<string, unknown>): void {
|
|
const top = this.stack[this.stack.length - 1];
|
|
if (top && top.view === view) return; // skip duplicate
|
|
this.stack.push({ view, context });
|
|
if (this.stack.length > MAX_DEPTH) this.stack.shift();
|
|
|
|
// Sync with browser history
|
|
if (this.moduleId && !this._skipNextPush) {
|
|
const existing = history.state || {};
|
|
history.pushState(
|
|
{ ...existing, moduleId: existing.moduleId, spaceSlug: existing.spaceSlug, _view: view, _viewCtx: context },
|
|
'',
|
|
);
|
|
}
|
|
this._skipNextPush = false;
|
|
}
|
|
|
|
/** Pop and return the previous entry, or null if at root. */
|
|
back(): ViewEntry<V> | null {
|
|
if (this.stack.length <= 1) {
|
|
this.stack = [];
|
|
return { view: this.root };
|
|
}
|
|
this.stack.pop(); // remove current
|
|
return this.stack[this.stack.length - 1] ?? { view: this.root };
|
|
}
|
|
|
|
/** True when there's history to go back to. */
|
|
get canGoBack(): boolean {
|
|
return this.stack.length > 1;
|
|
}
|
|
|
|
/** Peek at the previous entry without popping. */
|
|
peekBack(): ViewEntry<V> | null {
|
|
if (this.stack.length <= 1) return { view: this.root };
|
|
return this.stack[this.stack.length - 2] ?? null;
|
|
}
|
|
|
|
/** Clear the stack and reset to a root view (e.g. on space switch). */
|
|
reset(rootView?: V): void {
|
|
if (rootView !== undefined) this.root = rootView;
|
|
this.stack = [];
|
|
}
|
|
|
|
/** Clean up the popstate listener. Call when the component disconnects. */
|
|
destroy(): void {
|
|
if (this.moduleId) {
|
|
window.removeEventListener('rspace-view-popstate', this._onPopstate as EventListener);
|
|
}
|
|
}
|
|
|
|
/** Handle popstate events forwarded by TabCache for same-module nav. */
|
|
private _onPopstate = (e: CustomEvent) => {
|
|
if (e.detail?.moduleId !== this.moduleId) return;
|
|
|
|
// Pop our stack without triggering another pushState
|
|
const prev = this.back();
|
|
if (!prev) return;
|
|
|
|
// Tell the component to re-render with the restored view
|
|
window.dispatchEvent(new CustomEvent('rspace-view-restored', {
|
|
detail: { moduleId: this.moduleId, view: prev.view, context: prev.context },
|
|
}));
|
|
};
|
|
}
|