navigation: add historical state restore

This commit is contained in:
Elijah Duffy
2025-09-01 17:18:49 -07:00
parent 8993b86691
commit db4f149302

View File

@@ -1,6 +1,7 @@
import { page } from '$app/state'; import { page } from '$app/state';
import { getContext, onDestroy, onMount, setContext } from 'svelte'; import { getContext, onDestroy, onMount, setContext } from 'svelte';
import { generateIdentifier, resolveGetter, type MaybeGetter } from './util'; import { generateIdentifier, resolveGetter, type MaybeGetter } from './util';
import { goto } from '$app/navigation';
/** /**
* Options for creating or updating a navigation item. * Options for creating or updating a navigation item.
@@ -8,7 +9,7 @@ import { generateIdentifier, resolveGetter, type MaybeGetter } from './util';
export type NavigationItemOpts = { export type NavigationItemOpts = {
title: MaybeGetter<string>; title: MaybeGetter<string>;
href: MaybeGetter<string>; href: MaybeGetter<string>;
state?: Record<string, unknown>; state?: Map<unknown, unknown>;
depth?: number; depth?: number;
}; };
@@ -20,8 +21,10 @@ export class NavigationItem {
title: string = ''; title: string = '';
href: string = ''; href: string = '';
depth: number = 0; depth: number = 0;
state: Record<string, unknown> = {}; // eslint-disable-next-line svelte/prefer-svelte-reactivity
state: Map<unknown, unknown> = new Map();
provisional: boolean = false; provisional: boolean = false;
restoreState: boolean = false;
manager?: NavigationManager; manager?: NavigationManager;
@@ -44,7 +47,8 @@ export class NavigationItem {
* Represents the current state of the navigation stack. * Represents the current state of the navigation stack.
*/ */
type NavigationState = { type NavigationState = {
items: NavigationItem[]; stack: NavigationItem[];
history: NavigationItem[];
appName?: string; appName?: string;
}; };
@@ -56,7 +60,7 @@ export class NavigationManager {
private state: NavigationState; private state: NavigationState;
constructor(appName?: string) { constructor(appName?: string) {
this.state = $state({ items: [], appName }); this.state = $state({ stack: [], history: [], appName });
} }
private applyItemOpts(item: NavigationItem, opts: NavigationItemOpts) { private applyItemOpts(item: NavigationItem, opts: NavigationItemOpts) {
@@ -90,25 +94,30 @@ export class NavigationManager {
const item = new NavigationItem(id, opts); const item = new NavigationItem(id, opts);
this.applyItemOpts(item, opts); this.applyItemOpts(item, opts);
// Look for an item in the history stack with a matching href
// If found, fetch the item from the stack and delete it from history
const historyItem = this.state.history.find((i) => i.href === item.href);
if (historyItem) {
this.state.history = this.state.history.filter((i) => i !== historyItem);
if (historyItem.restoreState) {
item.state = historyItem.state;
}
}
// if the navigation stack is empty or this item is deeper than the previous,
// just add it to the top of the stack without any further (and slower) checks.
if ( if (
this.state.items.length === 0 || this.state.stack.length === 0 ||
this.state.items[this.state.items.length - 1].depth < item.depth this.state.stack[this.state.stack.length - 1].depth < item.depth
) { ) {
this.state.items.push(item); this.state.stack.push(item);
console.debug(
'short added to navstack',
'item',
item,
'new stack',
$state.snapshot(this.state.items)
);
return item; return item;
} }
// Find the index of the last navigation item that will be kept // Find the index of the last navigation item that will be kept
let lastValid = 0; let lastValid = 0;
for (let i = this.state.items.length - 1; i >= 0; i--) { for (let i = this.state.stack.length - 1; i >= 0; i--) {
const focus = this.state.items[i]; const focus = this.state.stack[i];
// keep items that are shallower than the new item or provisional with similar href // keep items that are shallower than the new item or provisional with similar href
if (focus.depth >= item.depth || (focus.provisional && !item.href.includes(focus.href))) { if (focus.depth >= item.depth || (focus.provisional && !item.href.includes(focus.href))) {
continue; continue;
@@ -117,18 +126,11 @@ export class NavigationManager {
} }
// Remove all items after the last valid item // Remove all items after the last valid item
this.state.items = this.state.items.slice(0, lastValid + 1); this.state.stack = this.state.stack.slice(0, lastValid + 1);
// Push new item to end of items // Push new item to end of items
this.state.items.push(item); this.state.stack.push(item);
console.debug(
'added to navigation stack',
'item',
item,
'new stack',
$state.snapshot(this.state.items)
);
return item; return item;
} }
@@ -136,15 +138,23 @@ export class NavigationManager {
* Removes a specific item from the navigation stack by id. * Removes a specific item from the navigation stack by id.
*/ */
removeItem(id: string | undefined) { removeItem(id: string | undefined) {
if (!id) return; const item = this.getItem(id);
this.state.items = this.state.items.filter((i) => i.id !== id); if (!item) return;
console.debug(
'removed from navigation stack', // If the item has any state, save it to the history stack
'id', if (item.state.size > 0) {
id, this.state.history.push(item);
'new stack', }
$state.snapshot(this.state.items)
); this.unsafeRemoveItem(id);
}
/**
* Removes an item from the navigation stack without checking state.
*/
private unsafeRemoveItem(id: string | undefined) {
// Remove the item from the stack
this.state.stack = this.state.stack.filter((i) => i.id !== id);
} }
/** /**
@@ -159,7 +169,6 @@ export class NavigationManager {
}); });
onDestroy(() => { onDestroy(() => {
console.debug('removing item with onDestroy', 'id', assignedID);
this.removeItem(assignedID); this.removeItem(assignedID);
}); });
@@ -171,49 +180,79 @@ export class NavigationManager {
*/ */
updateItem(id: string, opts: Partial<NavigationItemOpts>) { updateItem(id: string, opts: Partial<NavigationItemOpts>) {
// Find index and remove item from the navigation stack // Find index and remove item from the navigation stack
const index = this.state.items.findIndex((i) => i.id === id); const index = this.state.stack.findIndex((i) => i.id === id);
if (index === -1) return; if (index === -1) return;
const initial = this.state.items[index]; const initial = this.state.stack[index];
const safeOpts: NavigationItemOpts = { const safeOpts: NavigationItemOpts = {
...initial, ...initial,
...opts ...opts
}; };
this.state.items.splice(index, 1); // remove old item this.state.stack.splice(index, 1); // remove old item
// Create new item // Create new item
const item = new NavigationItem(id, safeOpts); const item = new NavigationItem(id, safeOpts);
this.applyItemOpts(item, safeOpts); this.applyItemOpts(item, safeOpts);
// Insert item at the same index // Insert item at the same index
this.state.items.splice(index, 0, item); this.state.stack.splice(index, 0, item);
console.debug(
'updated navigation item',
'id',
id,
'new stack',
$state.snapshot(this.state.items)
);
} }
/** /**
* Returns the title string for the current navigation stack. * Returns the title string for the current navigation stack.
*/ */
titleString(): string { titleString(): string {
// Order according to depth const flattened = this.items.map((item) => item.title).reverse();
const ordered = [...this.state.items].sort((a, b) => a.depth - b.depth);
const flattened = ordered.map((item) => item.title).reverse();
if (this.state.appName) flattened.push(this.state.appName); if (this.state.appName) flattened.push(this.state.appName);
return flattened.join(' - '); return flattened.join(' - ');
} }
/** /**
* Returns the current navigation items. Note: Cannot be directly manipulated. * Finds a navigation item in the stack by ID.
* @returns The navigation item if found, otherwise undefined.
*/
getItem(id: string | undefined): NavigationItem | undefined {
if (!id) return undefined;
return this.state.stack.find((item) => item.id === id);
}
/**
* Navigates to a specific URL and allows state to be restored from history.
*/
navigateTo(url: string, restoreState: boolean = true) {
if (restoreState) {
const index = this.state.history.findIndex((item) => url.includes(item.href));
if (index !== -1) {
this.state.history[index].restoreState = true;
}
}
goto(url);
}
/**
* Returns the current navigation items sorted by depth. Note: Cannot be directly manipulated.
*/ */
get items() { get items() {
return this.state.items; return [...this.state.stack].sort((a, b) => a.depth - b.depth);
}
/**
* Last item in the navigation stack or undefined if stack is empty.
* Note: Cannot be directly manipulated, use updateItem instead.
*/
get currentItem(): NavigationItem | undefined {
if (this.state.stack.length === 0) return undefined;
return this.items[this.items.length - 1];
}
/**
* Previous item in the navigation stack or undefined if stack is empty or length = 1.
* Note: Cannot be directly manipulated, use updateItem instead.
*/
get previousItem(): NavigationItem | undefined {
if (this.state.stack.length < 2) return undefined;
return this.items[this.items.length - 2];
} }
} }