integrate pane stack into sidebar store

sidebar now uses PaneStackStore internally - openWithComponent
creates root pane, and child components can push/pop via context.
simplified Sidebar.svelte to render PaneStack when stack has items.
This commit is contained in:
Justin Edmund 2025-12-03 15:55:47 -08:00
parent 096214bc52
commit d907e32d12
5 changed files with 205 additions and 176 deletions

View file

@ -1,30 +1,30 @@
<svelte:options runes={true} />
<script lang="ts">
import type { PaneConfig } from '$lib/stores/paneStack.svelte'
import {
type PaneConfig,
type PaneStackStore,
setPaneStackContext
} from '$lib/stores/paneStack.svelte'
import SidebarHeader from './SidebarHeader.svelte'
import Button from './Button.svelte'
interface Props {
/** Array of panes to render */
panes: PaneConfig[]
/** Whether an animation is in progress */
isAnimating?: boolean
/** Direction of animation */
animationDirection?: 'push' | 'pop' | null
/** Callback to pop the current pane */
onPop?: () => void
/** The pane stack store to use */
stack: PaneStackStore
/** Callback to close the entire sidebar (for root pane close button) */
onClose?: () => void
}
const {
panes,
isAnimating = false,
animationDirection = null,
onPop,
onClose
}: Props = $props()
const { stack, onClose }: Props = $props()
// Set context so child components can access the pane stack
setPaneStackContext(stack)
// Derive values from the stack
const panes = $derived(stack.panes)
const isAnimating = $derived(stack.isAnimating)
const animationDirection = $derived(stack.animationDirection)
function handleBack(pane: PaneConfig, index: number) {
if (index === 0 && pane.onback) {
@ -33,9 +33,9 @@
} else if (index === 0 && onClose) {
// Root pane, close sidebar
onClose()
} else if (onPop) {
} else {
// Non-root pane, pop from stack
onPop()
stack.pop()
}
}
@ -93,7 +93,8 @@
</SidebarHeader>
<div class="pane-content">
<svelte:component this={pane.component} {...pane.props ?? {}} />
{@const PaneComponent = pane.component}
<PaneComponent {...pane.props ?? {}} />
</div>
</div>
{/each}

View file

@ -1,80 +1,33 @@
<svelte:options runes={true} />
<script lang="ts">
import SidebarHeader from './SidebarHeader.svelte'
import Button from './Button.svelte'
import { SIDEBAR_WIDTH } from '$lib/stores/sidebar.svelte'
import PaneStack from './PaneStack.svelte'
import type { PaneStackStore } from '$lib/stores/paneStack.svelte'
import type { Snippet } from 'svelte'
interface Props {
/** Whether the sidebar is open */
open?: boolean
/** Title for the sidebar header */
title?: string
/** Callback when close is requested (camelCase preferred) */
/** The pane stack to render */
stack?: PaneStackStore
/** Callback when close is requested */
onClose?: () => void
/** Callback when close is requested (lowercase, deprecated - use onClose) */
onclose?: () => void
/** Callback when back is requested (shows arrow instead of X) */
onback?: () => void
/** Callback when save/done is requested */
onsave?: () => void
/** Label for the save button */
saveLabel?: string
/** Element for styling the save button */
element?: 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light'
/** Content to render in the sidebar */
/** Legacy: Content to render in the sidebar (when not using pane stack) */
children?: Snippet
/** Whether the sidebar content should scroll. Default true. */
scrollable?: boolean
}
const { open = false, title, onClose, onclose, onback, onsave, saveLabel = 'Done', element, children, scrollable = true }: Props = $props()
// Support both onClose (camelCase) and onclose (lowercase) for backward compatibility
const handleClose = $derived(onClose ?? onclose)
const { open = false, stack, onClose, children }: Props = $props()
</script>
{#snippet leftAccessory()}
{#if onback}
<Button
variant="ghost"
size="small"
iconOnly
icon="arrow-left"
onclick={onback}
aria-label="Go back"
/>
{:else if handleClose}
<Button
variant="ghost"
size="small"
iconOnly
icon="close"
onclick={handleClose}
aria-label="Close sidebar"
/>
{/if}
{/snippet}
{#snippet rightAccessory()}
{#if onsave}
<Button variant="ghost" size="small" {element} elementStyle={!!element} onclick={onsave}>
{saveLabel}
</Button>
{/if}
{/snippet}
<aside class="sidebar" class:open style:--sidebar-width={SIDEBAR_WIDTH}>
{#if title}
<SidebarHeader {title} {leftAccessory} {rightAccessory} />
{/if}
<div class="sidebar-content" class:scrollable>
{#if children}
{#if stack && !stack.isEmpty}
<PaneStack {stack} {onClose} />
{:else if children}
<div class="sidebar-content scrollable">
{@render children()}
{/if}
</div>
</div>
{/if}
</aside>
<style lang="scss">

View file

@ -1,4 +1,5 @@
import type { Component } from 'svelte'
import { getContext, setContext } from 'svelte'
/**
* Pane Stack Store
@ -9,6 +10,9 @@ import type { Component } from 'svelte'
export type ElementType = 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light'
/** Context key for pane stack */
const PANE_STACK_CONTEXT_KEY = Symbol('pane-stack')
export interface PaneConfig {
/** Unique identifier for this pane */
id: string
@ -39,7 +43,7 @@ interface PaneStackState {
animationDirection: 'push' | 'pop' | null
}
class PaneStackStore {
export class PaneStackStore {
state = $state<PaneStackState>({
panes: [],
isAnimating: false,
@ -195,3 +199,36 @@ export function createPaneStack() {
// Default global pane stack for sidebar
export const paneStack = new PaneStackStore()
// ============================================
// Context API for child components
// ============================================
/**
* Set the pane stack in context for child components
* Call this in the parent component that owns the pane stack
*/
export function setPaneStackContext(stack: PaneStackStore) {
setContext(PANE_STACK_CONTEXT_KEY, stack)
}
/**
* Get the pane stack from context
* Call this in child components that need to push/pop panes
* Returns undefined if no pane stack is in context
*/
export function getPaneStackContext(): PaneStackStore | undefined {
return getContext<PaneStackStore | undefined>(PANE_STACK_CONTEXT_KEY)
}
/**
* Get the pane stack from context, throwing if not found
* Use this when you know the pane stack should be available
*/
export function usePaneStack(): PaneStackStore {
const stack = getPaneStackContext()
if (!stack) {
throw new Error('usePaneStack must be used within a PaneStack context')
}
return stack
}

View file

@ -1,54 +1,56 @@
import type { Snippet, Component } from 'svelte'
import {
PaneStackStore,
type PaneConfig,
type ElementType
} from '$lib/stores/paneStack.svelte'
// Standard sidebar width
export const SIDEBAR_WIDTH = '420px'
interface SidebarState {
open: boolean
title: string | undefined
content: Snippet | undefined
component: Component<any, any, any> | undefined
componentProps: Record<string, any> | undefined
scrollable: boolean
activeItemId: string | undefined
onsave: (() => void) | undefined
saveLabel: string | undefined
element: 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light' | undefined
onback: (() => void) | undefined
}
interface OpenWithComponentOptions {
scrollable?: boolean
onsave?: () => void
saveLabel?: string
element?: 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light'
element?: ElementType
onback?: () => void
}
interface SidebarState {
open: boolean
activeItemId: string | undefined
}
/**
* SidebarStore
*
* Manages the sidebar open/close state and its pane stack.
* The sidebar always uses a pane stack internally - even a "single pane"
* is just a stack with one item.
*/
class SidebarStore {
state = $state<SidebarState>({
open: false,
title: undefined,
content: undefined,
component: undefined,
componentProps: undefined,
scrollable: true,
activeItemId: undefined,
onsave: undefined,
saveLabel: undefined,
element: undefined,
onback: undefined
activeItemId: undefined
})
/** The pane stack for sidebar navigation */
paneStack = new PaneStackStore()
/**
* Open the sidebar with a snippet content (legacy API)
*/
open(title?: string, content?: Snippet, scrollable = true) {
// For snippet content, we don't use the pane stack
// This is for backwards compatibility
this.state.open = true
this.state.title = title
this.state.content = content
this.state.component = undefined
this.state.componentProps = undefined
this.state.scrollable = scrollable
// Clear any existing panes
this.paneStack.clear()
}
/**
* Open the sidebar with a component as the root pane
*/
openWithComponent(
title: string,
component: Component<any, any, any>,
@ -59,106 +61,153 @@ class SidebarStore {
const opts: OpenWithComponentOptions =
typeof options === 'boolean' ? { scrollable: options } : options ?? {}
// Build the pane config
const paneConfig: PaneConfig = {
id: crypto.randomUUID(),
title,
component,
props,
onback: opts.onback,
scrollable: opts.scrollable ?? true,
action:
opts.onsave ?
{
label: opts.saveLabel ?? 'Done',
handler: opts.onsave,
element: opts.element
}
: undefined
}
// Reset the pane stack with this as the root pane
this.paneStack.reset(paneConfig)
this.state.open = true
this.state.title = title
this.state.component = component
this.state.componentProps = props
this.state.content = undefined
this.state.scrollable = opts.scrollable ?? true
this.state.onsave = opts.onsave
this.state.saveLabel = opts.saveLabel
this.state.element = opts.element
this.state.onback = opts.onback
// Extract and store the item ID if it's a details sidebar
if (props?.item?.id) {
this.state.activeItemId = String(props.item.id)
}
}
/**
* Push a new pane onto the sidebar's pane stack
*/
push(config: PaneConfig) {
this.paneStack.push(config)
}
/**
* Pop the current pane from the sidebar's pane stack
*/
pop(): boolean {
return this.paneStack.pop()
}
/**
* Close the sidebar
*/
close() {
this.state.open = false
this.state.activeItemId = undefined
// Clear content after animation
// Clear pane stack after animation
setTimeout(() => {
this.state.title = undefined
this.state.content = undefined
this.state.component = undefined
this.state.componentProps = undefined
this.state.onsave = undefined
this.state.saveLabel = undefined
this.state.element = undefined
this.state.onback = undefined
this.paneStack.clear()
}, 300)
}
/**
* Toggle the sidebar open/close state
*/
toggle() {
if (this.state.open) {
this.close()
} else {
this.open()
this.state.open = true
}
}
/** Update the right accessory action button dynamically */
/**
* Update the action button for the current pane
*/
setAction(
onsave: (() => void) | undefined,
saveLabel?: string,
element?: 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light'
element?: ElementType
) {
this.state.onsave = onsave
this.state.saveLabel = saveLabel
this.state.element = element
const currentPane = this.paneStack.currentPane
if (currentPane) {
this.paneStack.updateCurrentProps({})
// Update action on current pane
const panes = this.paneStack.panes
const currentIndex = panes.length - 1
if (currentIndex >= 0 && panes[currentIndex]) {
panes[currentIndex] = {
...panes[currentIndex],
action:
onsave ?
{
label: saveLabel ?? 'Done',
handler: onsave,
element
}
: undefined
}
}
}
}
/** Clear the right accessory action button */
/**
* Clear the action button for the current pane
*/
clearAction() {
this.state.onsave = undefined
this.state.saveLabel = undefined
this.state.element = undefined
this.setAction(undefined)
}
// Getters for reactive access
get isOpen() {
return this.state.open
}
get title() {
return this.state.title
}
get content() {
return this.state.content
}
get component() {
return this.state.component
}
get componentProps() {
return this.state.componentProps
}
get scrollable() {
return this.state.scrollable ?? true
}
get activeItemId() {
return this.state.activeItemId
}
// Backwards compatibility getters (delegate to pane stack)
get title() {
return this.paneStack.currentPane?.title
}
get component() {
return this.paneStack.currentPane?.component
}
get componentProps() {
return this.paneStack.currentPane?.props
}
get scrollable() {
return this.paneStack.currentPane?.scrollable ?? true
}
get onsave() {
return this.state.onsave
return this.paneStack.currentPane?.action?.handler
}
get saveLabel() {
return this.state.saveLabel
return this.paneStack.currentPane?.action?.label
}
get element() {
return this.state.element
return this.paneStack.currentPane?.action?.element
}
get onback() {
return this.state.onback
return this.paneStack.currentPane?.onback
}
// Legacy getter for content (not used with pane stack)
get content(): Snippet | undefined {
return undefined
}
}

View file

@ -112,20 +112,9 @@
<Sidebar
open={sidebar.isOpen}
title={sidebar.title}
onclose={() => sidebar.close()}
scrollable={sidebar.scrollable}
onsave={sidebar.onsave}
saveLabel={sidebar.saveLabel}
element={sidebar.element}
onback={sidebar.onback}
>
{#if sidebar.component}
<svelte:component this={sidebar.component} {...sidebar.componentProps} />
{:else if sidebar.content}
{@render sidebar.content()}
{/if}
</Sidebar>
stack={sidebar.paneStack}
onClose={() => sidebar.close()}
/>
</div>
</Tooltip.Provider>