add navigation manager to streamline titles and breadcrumbs
This commit is contained in:
@@ -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';
|
||||
|
||||
235
src/lib/navigation.svelte.ts
Normal file
235
src/lib/navigation.svelte.ts
Normal file
@@ -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<string>;
|
||||
href: MaybeGetter<string>;
|
||||
state?: Record<string, unknown>;
|
||||
depth?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A single navigation item in the stack.
|
||||
*/
|
||||
export class NavigationItem {
|
||||
id: string;
|
||||
title: string = '';
|
||||
href: string = '';
|
||||
depth: number = 0;
|
||||
state: Record<string, unknown> = {};
|
||||
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<NavigationItemOpts>) {
|
||||
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<NavigationItemOpts>) {
|
||||
// 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<NavigationManager>(navigationManagerKey);
|
||||
if (saved) return saved;
|
||||
|
||||
console.debug('initializing a new session navigation manager');
|
||||
const manager = initializer();
|
||||
setContext(navigationManagerKey, manager);
|
||||
return manager;
|
||||
};
|
||||
@@ -24,6 +24,17 @@ export const defaultIconProps: IconComponentProps = {
|
||||
*/
|
||||
export type MaybeGetter<T> = T | (() => T);
|
||||
|
||||
/**
|
||||
* ResolveGetter returns the underlying value stored by a MaybeGetter type.
|
||||
* @returns Raw value T or function return T.
|
||||
*/
|
||||
export const resolveGetter = <T>(getter: MaybeGetter<T>): 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.
|
||||
|
||||
Reference in New Issue
Block a user