add pane stack store and component for ios-style navigation

This commit is contained in:
Justin Edmund 2025-12-03 15:47:34 -08:00
parent e3cc2df45c
commit 84e1fb4a8a
2 changed files with 403 additions and 0 deletions

View file

@ -0,0 +1,206 @@
<svelte:options runes={true} />
<script lang="ts">
import type { PaneConfig } 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
/** Callback to close the entire sidebar (for root pane close button) */
onClose?: () => void
}
const {
panes,
isAnimating = false,
animationDirection = null,
onPop,
onClose
}: Props = $props()
function handleBack(pane: PaneConfig, index: number) {
if (index === 0 && pane.onback) {
// Root pane with custom back handler
pane.onback()
} else if (index === 0 && onClose) {
// Root pane, close sidebar
onClose()
} else if (onPop) {
// Non-root pane, pop from stack
onPop()
}
}
// Determine if a pane is the one being pushed (for entry animation)
function isPushing(index: number): boolean {
return isAnimating && animationDirection === 'push' && index === panes.length - 1
}
// Determine if a pane is the one being popped (for exit animation)
function isPopping(index: number): boolean {
return isAnimating && animationDirection === 'pop' && index === panes.length - 1
}
</script>
<div class="pane-stack">
{#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}
<div
class="pane"
class:is-active={isActive && !isPopping(index)}
class:is-behind={isBehind || isPopping(index)}
class:is-pushing={isPushing(index)}
class:scrollable={pane.scrollable !== false}
>
<SidebarHeader title={pane.title}>
{#snippet leftAccessory()}
{#if showBackButton}
<Button
variant="ghost"
size="small"
iconOnly
icon={index === 0 && !pane.onback ? 'close' : 'arrow-left'}
onclick={() => handleBack(pane, index)}
aria-label={index === 0 && !pane.onback ? 'Close' : 'Go back'}
/>
{/if}
{/snippet}
{#snippet rightAccessory()}
{#if pane.action}
<Button
variant="ghost"
size="small"
element={pane.action.element}
elementStyle={!!pane.action.element}
onclick={pane.action.handler}
>
{pane.action.label}
</Button>
{/if}
{/snippet}
</SidebarHeader>
<div class="pane-content">
<svelte:component this={pane.component} {...pane.props ?? {}} />
</div>
</div>
{/each}
</div>
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/effects' as *;
.pane-stack {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
.pane {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
background: var(--sidebar-bg);
transition:
transform $duration-slide ease-out,
opacity $duration-slide ease-out;
will-change: transform, opacity;
// Active pane (top of stack, fully visible)
&.is-active {
transform: translateX(0) scale(1);
z-index: 10;
.pane-content {
opacity: 1;
transition: opacity $duration-slide ease-out;
}
}
// Behind pane (scaled down and shifted left)
&.is-behind {
transform: translateX(-20px) scale(0.9);
z-index: 1;
.pane-content {
opacity: 0;
pointer-events: none;
transition: opacity $duration-slide ease-out;
}
}
// Pushing animation (new pane entering from right)
&.is-pushing {
animation: pane-enter $duration-slide ease-out forwards;
}
}
@keyframes pane-enter {
from {
transform: translateX(100%);
}
to {
transform: translateX(0) scale(1);
}
}
.pane-content {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
// Scrollable pane content
.pane.scrollable & {
overflow-y: auto;
overflow-x: hidden;
scroll-behavior: smooth;
overflow-y: overlay;
// Thin, minimal scrollbar styling
&::-webkit-scrollbar {
width: 10px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 10px;
border: 2px solid transparent;
background-clip: padding-box;
&:hover {
background: rgba(0, 0, 0, 0.4);
background-clip: padding-box;
}
}
// Firefox scrollbar styling
scrollbar-width: thin;
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
// Improve mobile scrolling performance
@media (max-width: 768px) {
-webkit-overflow-scrolling: touch;
}
}
}
</style>

View file

@ -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<any, any, any>
/** Props to pass to the component */
props?: Record<string, any>
/** 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<PaneStackState>({
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<string, any>) {
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()