From 84e1fb4a8abf29f1038c5a2b126d3eea8093eacb Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Wed, 3 Dec 2025 15:47:34 -0800 Subject: [PATCH] add pane stack store and component for ios-style navigation --- src/lib/components/ui/PaneStack.svelte | 206 +++++++++++++++++++++++++ src/lib/stores/paneStack.svelte.ts | 197 +++++++++++++++++++++++ 2 files changed, 403 insertions(+) create mode 100644 src/lib/components/ui/PaneStack.svelte create mode 100644 src/lib/stores/paneStack.svelte.ts diff --git a/src/lib/components/ui/PaneStack.svelte b/src/lib/components/ui/PaneStack.svelte new file mode 100644 index 00000000..f597eff0 --- /dev/null +++ b/src/lib/components/ui/PaneStack.svelte @@ -0,0 +1,206 @@ + + + + +
+ {#each panes as pane, index (pane.id)} + {@const isActive = index === panes.length - 1} + {@const isBehind = index < panes.length - 1} + {@const showBackButton = index > 0 || pane.onback || onClose} + +
+ + {#snippet leftAccessory()} + {#if showBackButton} + + {/if} + {/snippet} + + +
+ +
+
+ {/each} +
+ + diff --git a/src/lib/stores/paneStack.svelte.ts b/src/lib/stores/paneStack.svelte.ts new file mode 100644 index 00000000..bccd2619 --- /dev/null +++ b/src/lib/stores/paneStack.svelte.ts @@ -0,0 +1,197 @@ +import type { Component } from 'svelte' + +/** + * Pane Stack Store + * + * Manages a stack of panes for iOS-style navigation within the sidebar. + * Supports push/pop operations with animated transitions. + */ + +export type ElementType = 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light' + +export interface PaneConfig { + /** Unique identifier for this pane */ + id: string + /** Title displayed in the pane header */ + title: string + /** Component to render in the pane */ + component: Component + /** Props to pass to the component */ + props?: Record + /** Optional callback when back is clicked (for root pane) */ + onback?: () => void + /** Optional save/action button configuration */ + action?: { + label: string + handler: () => void + element?: ElementType + } + /** Whether this pane's content should scroll */ + scrollable?: boolean +} + +interface PaneStackState { + /** Stack of panes (last is active/visible) */ + panes: PaneConfig[] + /** Whether an animation is in progress */ + isAnimating: boolean + /** Direction of current animation */ + animationDirection: 'push' | 'pop' | null +} + +class PaneStackStore { + state = $state({ + panes: [], + isAnimating: false, + animationDirection: null + }) + + /** Animation duration in ms - should match CSS */ + private readonly ANIMATION_DURATION = 300 + + /** + * Push a new pane onto the stack + */ + push(config: PaneConfig) { + if (this.state.isAnimating) return + + this.state.isAnimating = true + this.state.animationDirection = 'push' + this.state.panes = [...this.state.panes, config] + + // Clear animation state after transition completes + setTimeout(() => { + this.state.isAnimating = false + this.state.animationDirection = null + }, this.ANIMATION_DURATION) + } + + /** + * Pop the top pane from the stack + * Returns true if a pane was popped, false if stack was empty + */ + pop(): boolean { + if (this.state.isAnimating) return false + if (this.state.panes.length <= 1) { + // If only root pane, call its onback if defined + const rootPane = this.state.panes[0] + if (rootPane?.onback) { + rootPane.onback() + } + return false + } + + this.state.isAnimating = true + this.state.animationDirection = 'pop' + + // Remove the top pane after animation starts + setTimeout(() => { + this.state.panes = this.state.panes.slice(0, -1) + this.state.isAnimating = false + this.state.animationDirection = null + }, this.ANIMATION_DURATION) + + return true + } + + /** + * Pop all panes until reaching the pane with the given id + */ + popTo(id: string) { + const index = this.state.panes.findIndex((p) => p.id === id) + if (index === -1 || index === this.state.panes.length - 1) return + + if (this.state.isAnimating) return + + this.state.isAnimating = true + this.state.animationDirection = 'pop' + + setTimeout(() => { + this.state.panes = this.state.panes.slice(0, index + 1) + this.state.isAnimating = false + this.state.animationDirection = null + }, this.ANIMATION_DURATION) + } + + /** + * Pop to the root pane + */ + popToRoot() { + if (this.state.panes.length <= 1) return + this.popTo(this.state.panes[0]?.id ?? '') + } + + /** + * Clear the entire stack + */ + clear() { + this.state.panes = [] + this.state.isAnimating = false + this.state.animationDirection = null + } + + /** + * Replace the entire stack with a new root pane + */ + reset(config: PaneConfig) { + this.state.panes = [config] + this.state.isAnimating = false + this.state.animationDirection = null + } + + /** + * Update props for the current (top) pane + */ + updateCurrentProps(props: Record) { + if (this.state.panes.length === 0) return + + const currentIndex = this.state.panes.length - 1 + const currentPane = this.state.panes[currentIndex] + if (currentPane) { + this.state.panes[currentIndex] = { + ...currentPane, + props: { ...currentPane.props, ...props } + } + } + } + + // Getters for reactive access + get panes() { + return this.state.panes + } + + get currentPane(): PaneConfig | undefined { + return this.state.panes[this.state.panes.length - 1] + } + + get isAnimating() { + return this.state.isAnimating + } + + get animationDirection() { + return this.state.animationDirection + } + + get depth() { + return this.state.panes.length + } + + get isEmpty() { + return this.state.panes.length === 0 + } + + get canGoBack() { + return this.state.panes.length > 1 || !!this.state.panes[0]?.onback + } +} + +/** + * Create a new pane stack instance + * Use this to create isolated pane stacks for different contexts + */ +export function createPaneStack() { + return new PaneStackStore() +} + +// Default global pane stack for sidebar +export const paneStack = new PaneStackStore()