From db4f14930255b7041c8a9c4de6e5cb99c5c57ced Mon Sep 17 00:00:00 2001 From: Elijah Duffy Date: Mon, 1 Sep 2025 17:18:49 -0700 Subject: [PATCH] navigation: add historical state restore --- src/lib/navigation.svelte.ts | 143 ++++++++++++++++++++++------------- 1 file changed, 91 insertions(+), 52 deletions(-) diff --git a/src/lib/navigation.svelte.ts b/src/lib/navigation.svelte.ts index e38dc7c..3d873e2 100644 --- a/src/lib/navigation.svelte.ts +++ b/src/lib/navigation.svelte.ts @@ -1,6 +1,7 @@ import { page } from '$app/state'; import { getContext, onDestroy, onMount, setContext } from 'svelte'; import { generateIdentifier, resolveGetter, type MaybeGetter } from './util'; +import { goto } from '$app/navigation'; /** * Options for creating or updating a navigation item. @@ -8,7 +9,7 @@ import { generateIdentifier, resolveGetter, type MaybeGetter } from './util'; export type NavigationItemOpts = { title: MaybeGetter; href: MaybeGetter; - state?: Record; + state?: Map; depth?: number; }; @@ -20,8 +21,10 @@ export class NavigationItem { title: string = ''; href: string = ''; depth: number = 0; - state: Record = {}; + // eslint-disable-next-line svelte/prefer-svelte-reactivity + state: Map = new Map(); provisional: boolean = false; + restoreState: boolean = false; manager?: NavigationManager; @@ -44,7 +47,8 @@ export class NavigationItem { * Represents the current state of the navigation stack. */ type NavigationState = { - items: NavigationItem[]; + stack: NavigationItem[]; + history: NavigationItem[]; appName?: string; }; @@ -56,7 +60,7 @@ export class NavigationManager { private state: NavigationState; constructor(appName?: string) { - this.state = $state({ items: [], appName }); + this.state = $state({ stack: [], history: [], appName }); } private applyItemOpts(item: NavigationItem, opts: NavigationItemOpts) { @@ -90,25 +94,30 @@ export class NavigationManager { const item = new NavigationItem(id, 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 ( - this.state.items.length === 0 || - this.state.items[this.state.items.length - 1].depth < item.depth + this.state.stack.length === 0 || + this.state.stack[this.state.stack.length - 1].depth < item.depth ) { - this.state.items.push(item); - console.debug( - 'short added to navstack', - 'item', - item, - 'new stack', - $state.snapshot(this.state.items) - ); + this.state.stack.push(item); return item; } // Find the index of the last navigation item that will be kept let lastValid = 0; - for (let i = this.state.items.length - 1; i >= 0; i--) { - const focus = this.state.items[i]; + for (let i = this.state.stack.length - 1; i >= 0; i--) { + const focus = this.state.stack[i]; // 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))) { continue; @@ -117,18 +126,11 @@ export class NavigationManager { } // 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 - 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; } @@ -136,15 +138,23 @@ export class NavigationManager { * Removes a specific item from the navigation stack by id. */ removeItem(id: string | undefined) { - if (!id) return; - this.state.items = this.state.items.filter((i) => i.id !== id); - console.debug( - 'removed from navigation stack', - 'id', - id, - 'new stack', - $state.snapshot(this.state.items) - ); + const item = this.getItem(id); + if (!item) return; + + // If the item has any state, save it to the history stack + if (item.state.size > 0) { + this.state.history.push(item); + } + + 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(() => { - console.debug('removing item with onDestroy', 'id', assignedID); this.removeItem(assignedID); }); @@ -171,49 +180,79 @@ export class NavigationManager { */ updateItem(id: string, opts: Partial) { // 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; - const initial = this.state.items[index]; + const initial = this.state.stack[index]; const safeOpts: NavigationItemOpts = { ...initial, ...opts }; - this.state.items.splice(index, 1); // remove old item + this.state.stack.splice(index, 1); // remove old item // Create new item const item = new NavigationItem(id, safeOpts); this.applyItemOpts(item, safeOpts); // Insert item at the same index - this.state.items.splice(index, 0, item); - - console.debug( - 'updated navigation item', - 'id', - id, - 'new stack', - $state.snapshot(this.state.items) - ); + this.state.stack.splice(index, 0, item); } /** * Returns the title string for the current navigation stack. */ titleString(): string { - // Order according to depth - const ordered = [...this.state.items].sort((a, b) => a.depth - b.depth); - const flattened = ordered.map((item) => item.title).reverse(); + const flattened = this.items.map((item) => item.title).reverse(); if (this.state.appName) flattened.push(this.state.appName); 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() { - 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]; } }