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