add pane stack store and component for ios-style navigation
This commit is contained in:
parent
e3cc2df45c
commit
84e1fb4a8a
2 changed files with 403 additions and 0 deletions
206
src/lib/components/ui/PaneStack.svelte
Normal file
206
src/lib/components/ui/PaneStack.svelte
Normal 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>
|
||||||
197
src/lib/stores/paneStack.svelte.ts
Normal file
197
src/lib/stores/paneStack.svelte.ts
Normal 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()
|
||||||
Loading…
Reference in a new issue