navigation: add historical state restore
This commit is contained in:
@@ -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];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user