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

View file

@ -1,80 +1,33 @@
<svelte:options runes={true} /> <svelte:options runes={true} />
<script lang="ts"> <script lang="ts">
import SidebarHeader from './SidebarHeader.svelte'
import Button from './Button.svelte'
import { SIDEBAR_WIDTH } from '$lib/stores/sidebar.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' import type { Snippet } from 'svelte'
interface Props { interface Props {
/** Whether the sidebar is open */ /** Whether the sidebar is open */
open?: boolean open?: boolean
/** Title for the sidebar header */ /** The pane stack to render */
title?: string stack?: PaneStackStore
/** Callback when close is requested (camelCase preferred) */ /** Callback when close is requested */
onClose?: () => void onClose?: () => void
/** Callback when close is requested (lowercase, deprecated - use onClose) */ /** Legacy: Content to render in the sidebar (when not using pane stack) */
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 */
children?: Snippet 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() const { open = false, stack, onClose, children }: Props = $props()
// Support both onClose (camelCase) and onclose (lowercase) for backward compatibility
const handleClose = $derived(onClose ?? onclose)
</script> </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}> <aside class="sidebar" class:open style:--sidebar-width={SIDEBAR_WIDTH}>
{#if title} {#if stack && !stack.isEmpty}
<SidebarHeader {title} {leftAccessory} {rightAccessory} /> <PaneStack {stack} {onClose} />
{/if} {:else if children}
<div class="sidebar-content scrollable">
<div class="sidebar-content" class:scrollable>
{#if children}
{@render children()} {@render children()}
{/if} </div>
</div> {/if}
</aside> </aside>
<style lang="scss"> <style lang="scss">

View file

@ -1,4 +1,5 @@
import type { Component } from 'svelte' import type { Component } from 'svelte'
import { getContext, setContext } from 'svelte'
/** /**
* Pane Stack Store * Pane Stack Store
@ -9,6 +10,9 @@ import type { Component } from 'svelte'
export type ElementType = 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light' 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 { export interface PaneConfig {
/** Unique identifier for this pane */ /** Unique identifier for this pane */
id: string id: string
@ -39,7 +43,7 @@ interface PaneStackState {
animationDirection: 'push' | 'pop' | null animationDirection: 'push' | 'pop' | null
} }
class PaneStackStore { export class PaneStackStore {
state = $state<PaneStackState>({ state = $state<PaneStackState>({
panes: [], panes: [],
isAnimating: false, isAnimating: false,
@ -195,3 +199,36 @@ export function createPaneStack() {
// Default global pane stack for sidebar // Default global pane stack for sidebar
export const paneStack = new PaneStackStore() 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 type { Snippet, Component } from 'svelte'
import {
PaneStackStore,
type PaneConfig,
type ElementType
} from '$lib/stores/paneStack.svelte'
// Standard sidebar width // Standard sidebar width
export const SIDEBAR_WIDTH = '420px' 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 { interface OpenWithComponentOptions {
scrollable?: boolean scrollable?: boolean
onsave?: () => void onsave?: () => void
saveLabel?: string saveLabel?: string
element?: 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light' element?: ElementType
onback?: () => void 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 { class SidebarStore {
state = $state<SidebarState>({ state = $state<SidebarState>({
open: false, open: false,
title: undefined, activeItemId: undefined
content: undefined,
component: undefined,
componentProps: undefined,
scrollable: true,
activeItemId: undefined,
onsave: undefined,
saveLabel: undefined,
element: undefined,
onback: 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) { 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.open = true
this.state.title = title // Clear any existing panes
this.state.content = content this.paneStack.clear()
this.state.component = undefined
this.state.componentProps = undefined
this.state.scrollable = scrollable
} }
/**
* Open the sidebar with a component as the root pane
*/
openWithComponent( openWithComponent(
title: string, title: string,
component: Component<any, any, any>, component: Component<any, any, any>,
@ -59,106 +61,153 @@ class SidebarStore {
const opts: OpenWithComponentOptions = const opts: OpenWithComponentOptions =
typeof options === 'boolean' ? { scrollable: options } : options ?? {} 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.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 // Extract and store the item ID if it's a details sidebar
if (props?.item?.id) { if (props?.item?.id) {
this.state.activeItemId = String(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() { close() {
this.state.open = false this.state.open = false
this.state.activeItemId = undefined this.state.activeItemId = undefined
// Clear content after animation // Clear pane stack after animation
setTimeout(() => { setTimeout(() => {
this.state.title = undefined this.paneStack.clear()
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
}, 300) }, 300)
} }
/**
* Toggle the sidebar open/close state
*/
toggle() { toggle() {
if (this.state.open) { if (this.state.open) {
this.close() this.close()
} else { } else {
this.open() this.state.open = true
} }
} }
/** Update the right accessory action button dynamically */ /**
* Update the action button for the current pane
*/
setAction( setAction(
onsave: (() => void) | undefined, onsave: (() => void) | undefined,
saveLabel?: string, saveLabel?: string,
element?: 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light' element?: ElementType
) { ) {
this.state.onsave = onsave const currentPane = this.paneStack.currentPane
this.state.saveLabel = saveLabel if (currentPane) {
this.state.element = element 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() { clearAction() {
this.state.onsave = undefined this.setAction(undefined)
this.state.saveLabel = undefined
this.state.element = undefined
} }
// Getters for reactive access
get isOpen() { get isOpen() {
return this.state.open 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() { get activeItemId() {
return this.state.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() { get onsave() {
return this.state.onsave return this.paneStack.currentPane?.action?.handler
} }
get saveLabel() { get saveLabel() {
return this.state.saveLabel return this.paneStack.currentPane?.action?.label
} }
get element() { get element() {
return this.state.element return this.paneStack.currentPane?.action?.element
} }
get onback() { 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 <Sidebar
open={sidebar.isOpen} open={sidebar.isOpen}
title={sidebar.title} stack={sidebar.paneStack}
onclose={() => sidebar.close()} 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>
</div> </div>
</Tooltip.Provider> </Tooltip.Provider>