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. */ export type NavigationItemOpts = { title: MaybeGetter; href: MaybeGetter; state?: Map; depth?: number; }; /** * A single navigation item in the stack. */ export class NavigationItem { id: string; title: string = ''; href: string = ''; depth: number = 0; // eslint-disable-next-line svelte/prefer-svelte-reactivity state: Map = new Map(); provisional: boolean = false; restoreState: boolean = false; manager?: NavigationManager; constructor(id: string, opts: NavigationItemOpts) { this.id = id; if (opts?.state) this.state = opts.state; } /** * Updates the navigation item with the current manager. * @throws {Error} If the manager is not set. */ update(opts: Partial) { if (!this.manager) throw new Error('Navigation item is not managed'); this.manager.updateItem(this.id, opts); } } /** * Represents the current state of the navigation stack. */ type NavigationState = { stack: NavigationItem[]; history: NavigationItem[]; appName?: string; }; /** * Manages the navigation state for the application, assisting with building page * titles, breadcrumbs, and navigation to previous hierarchy levels. */ export class NavigationManager { private state: NavigationState; constructor(appName?: string) { this.state = $state({ stack: [], history: [], appName }); } private applyItemOpts(item: NavigationItem, opts: NavigationItemOpts) { item.manager = this; item.title = resolveGetter(opts.title); // normalize href (keep leading slash, strip trailing slashes) const href = resolveGetter(opts.href); item.href = href ? '/' + href.replace(/^\/+|\/+$/g, '') : '/'; // depth: either provides or compute from href segments item.depth = opts?.depth ?? Math.max(0, item.href.split('/').length - 1); // set provisional based on current browser URL at add time item.provisional = !(page.url.pathname === item.href); } /** * Adds a navigation item to the stack. New item replaces any existing items * that are lower or equal in the hierarchy. * @returns Newly created navigation item. */ addItem(opts: NavigationItemOpts): NavigationItem { return this.unsafeAddItem(generateIdentifier(), opts); } /** * Accepts a unique identifier for the navigation item. */ private unsafeAddItem(id: string, opts: NavigationItemOpts): NavigationItem { 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.stack.length === 0 || this.state.stack[this.state.stack.length - 1].depth < item.depth ) { 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.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; } lastValid = i; } // Remove all items after the last valid item this.state.stack = this.state.stack.slice(0, lastValid + 1); // Push new item to end of items this.state.stack.push(item); return item; } /** * Removes a specific item from the navigation stack by id. */ removeItem(id: string | undefined) { 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); } /** * Adds an item to the navigation stack onMount and removes it onDestroy. * @returns Navigation item ID. */ useItem(opts: NavigationItemOpts): string { const assignedID = generateIdentifier(); onMount(() => { this.unsafeAddItem(assignedID, opts); }); onDestroy(() => { this.removeItem(assignedID); }); return assignedID; } /** * Updates an item in the navigation stack. */ updateItem(id: string, opts: Partial) { // Find index and remove item from the navigation stack const index = this.state.stack.findIndex((i) => i.id === id); if (index === -1) return; const initial = this.state.stack[index]; const safeOpts: NavigationItemOpts = { ...initial, ...opts }; 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.stack.splice(index, 0, item); } /** * Returns the title string for the current navigation stack. */ titleString(): string { const flattened = this.items.map((item) => item.title).reverse(); if (this.state.appName) flattened.push(this.state.appName); return flattened.join(' - '); } /** * 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.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]; } } const navigationManagerKey = {}; /** * Returns the navigation manager for the current context. * @param initializer A function to initialize the navigation manager if none exists for this session. * @returns The navigation manager for this session. */ export const getNavigationManager = (initializer: () => NavigationManager): NavigationManager => { const saved = getContext(navigationManagerKey); if (saved) return saved; console.debug('initializing a new session navigation manager'); const manager = initializer(); setContext(navigationManagerKey, manager); return manager; };