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:
parent
096214bc52
commit
d907e32d12
5 changed files with 205 additions and 176 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue