From 5a9574bec93349e26f9ee407cb77cf36d4060c39 Mon Sep 17 00:00:00 2001 From: Elijah Duffy Date: Mon, 1 Sep 2025 12:29:16 -0700 Subject: [PATCH] add navigation manager to streamline titles and breadcrumbs --- src/lib/index.ts | 6 + src/lib/navigation.svelte.ts | 235 +++++++++++++++++++++++++++++++++++ src/lib/util.ts | 11 ++ 3 files changed, 252 insertions(+) create mode 100644 src/lib/navigation.svelte.ts diff --git a/src/lib/index.ts b/src/lib/index.ts index 2ffd3b2..e90afb2 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -44,3 +44,9 @@ export { Toolbar } from './Toolbar'; export { type GraphError, type RawError, ErrorMessage } from './error'; +export { + NavigationItem, + NavigationManager, + getNavigationManager, + type NavigationItemOpts +} from './navigation.svelte'; diff --git a/src/lib/navigation.svelte.ts b/src/lib/navigation.svelte.ts new file mode 100644 index 0000000..e38dc7c --- /dev/null +++ b/src/lib/navigation.svelte.ts @@ -0,0 +1,235 @@ +import { page } from '$app/state'; +import { getContext, onDestroy, onMount, setContext } from 'svelte'; +import { generateIdentifier, resolveGetter, type MaybeGetter } from './util'; + +/** + * Options for creating or updating a navigation item. + */ +export type NavigationItemOpts = { + title: MaybeGetter; + href: MaybeGetter; + state?: Record; + depth?: number; +}; + +/** + * A single navigation item in the stack. + */ +export class NavigationItem { + id: string; + title: string = ''; + href: string = ''; + depth: number = 0; + state: Record = {}; + provisional: 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 = { + items: 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({ items: [], 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); + + if ( + this.state.items.length === 0 || + this.state.items[this.state.items.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) + ); + 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]; + // 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.items = this.state.items.slice(0, lastValid + 1); + + // Push new item to end of items + this.state.items.push(item); + + console.debug( + 'added to navigation stack', + 'item', + item, + 'new stack', + $state.snapshot(this.state.items) + ); + return item; + } + + /** + * 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) + ); + } + + /** + * 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(() => { + console.debug('removing item with onDestroy', 'id', assignedID); + 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.items.findIndex((i) => i.id === id); + if (index === -1) return; + + const initial = this.state.items[index]; + const safeOpts: NavigationItemOpts = { + ...initial, + ...opts + }; + + this.state.items.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) + ); + } + + /** + * 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(); + if (this.state.appName) flattened.push(this.state.appName); + return flattened.join(' - '); + } + + /** + * Returns the current navigation items. Note: Cannot be directly manipulated. + */ + get items() { + return this.state.items; + } +} + +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; +}; diff --git a/src/lib/util.ts b/src/lib/util.ts index 94528ff..89ce10e 100644 --- a/src/lib/util.ts +++ b/src/lib/util.ts @@ -24,6 +24,17 @@ export const defaultIconProps: IconComponentProps = { */ export type MaybeGetter = T | (() => T); +/** + * ResolveGetter returns the underlying value stored by a MaybeGetter type. + * @returns Raw value T or function return T. + */ +export const resolveGetter = (getter: MaybeGetter): T => { + if (typeof getter === 'function') { + return (getter as () => T)(); + } + return getter; +}; + /** * Generates a unique identifier string unless an identifier is provided. * If a prefix is provided, it will be prepended to the identifier.