feat: implement reusable pane system components

- Add BasePane and Pane components for consistent panel UI
- Create pane-manager store for centralized pane state management
- Support for different pane positions and animations
- Establish foundation for unified pane behavior across the app

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Justin Edmund 2025-07-09 23:20:32 -07:00
parent 12c30c1501
commit d767d9578f
3 changed files with 301 additions and 0 deletions

View file

@ -0,0 +1,170 @@
<script lang="ts">
import { fade } from 'svelte/transition'
import type { Snippet } from 'svelte'
interface BasePaneProps {
isOpen: boolean
position?: { x: number; y: number } | null
closeOnBackdrop?: boolean
closeOnEscape?: boolean
maxWidth?: string
maxHeight?: string
onClose?: () => void
children?: Snippet
}
let {
isOpen = $bindable(false),
position = null,
closeOnBackdrop = true,
closeOnEscape = true,
maxWidth = '320px',
maxHeight = '400px',
onClose,
children
}: BasePaneProps = $props()
let paneElement: HTMLDivElement
// Handle escape key
$effect(() => {
if (!isOpen || !closeOnEscape) return
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
handleClose()
}
}
window.addEventListener('keydown', handleKeydown)
return () => window.removeEventListener('keydown', handleKeydown)
})
// Handle click outside
$effect(() => {
if (!isOpen || !closeOnBackdrop) return
function handleClickOutside(e: MouseEvent) {
if (paneElement && !paneElement.contains(e.target as Node)) {
handleClose()
}
}
// Use capture phase to ensure we catch the click before other handlers
setTimeout(() => {
document.addEventListener('click', handleClickOutside, true)
}, 0)
return () => document.removeEventListener('click', handleClickOutside, true)
})
function handleClose() {
isOpen = false
onClose?.()
}
// State to store calculated position
let calculatedPosition = $state<{ x: number; y: number } | null>(null)
// Calculate viewport-aware position
$effect(() => {
if (!position || !paneElement || !isOpen) {
calculatedPosition = null
return
}
// Get pane dimensions
const paneRect = paneElement.getBoundingClientRect()
const paneWidth = paneRect.width
const paneHeight = paneRect.height
// Get viewport dimensions
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
// Calculate position with viewport awareness
let x = position.x
let y = position.y
// Check horizontal bounds
if (x + paneWidth > viewportWidth - 20) {
// Too close to right edge, align to right
x = viewportWidth - paneWidth - 20
}
if (x < 20) {
// Too close to left edge
x = 20
}
// Check vertical bounds
if (y + paneHeight > viewportHeight - 20) {
// Too close to bottom edge, show above the cursor instead
// Assuming the cursor is at position.y, we want to show above it
y = Math.max(20, position.y - paneHeight - 8)
}
if (y < 20) {
// Too close to top edge
y = 20
}
calculatedPosition = { x, y }
})
// Calculate position styles
const positionStyles = $derived(() => {
const pos = calculatedPosition || position
if (!pos) return ''
let styles = []
// Position the pane at the calculated coordinates
styles.push(`left: ${pos.x}px`)
styles.push(`top: ${pos.y}px`)
return styles.join('; ')
})
</script>
{#if isOpen}
<div
class="base-pane"
bind:this={paneElement}
style="{positionStyles()}; max-width: {maxWidth}; max-height: {maxHeight};"
transition:fade={{ duration: 150 }}
role="dialog"
aria-modal="false"
>
{#if children}
{@render children()}
{/if}
</div>
{/if}
<style lang="scss">
@import '$styles/variables';
.base-pane {
position: fixed;
background: $white;
border: 1px solid $gray-85;
border-radius: $corner-radius;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06),
0 10px 15px -3px rgba(0, 0, 0, 0.1);
z-index: $z-index-popover;
min-width: 400px;
overflow: auto;
// Ensure pane doesn't go off-screen
&:global([style*='left']) {
transform: translateX(0);
// If it would go off the right edge, align to right instead
@media (max-width: 640px) {
left: auto !important;
right: $unit !important;
}
}
}
</style>

View file

@ -0,0 +1,99 @@
<script lang="ts">
import BasePane from './BasePane.svelte'
import X from 'lucide-svelte/icons/x'
import type { Snippet } from 'svelte'
interface PaneProps {
isOpen: boolean
position?: { x: number; y: number } | null
title?: string
showCloseButton?: boolean
closeOnBackdrop?: boolean
closeOnEscape?: boolean
maxWidth?: string
maxHeight?: string
onClose?: () => void
children?: Snippet
}
let {
isOpen = $bindable(false),
position = null,
title,
showCloseButton = true,
closeOnBackdrop = true,
closeOnEscape = true,
maxWidth = '320px',
maxHeight = '400px',
onClose,
children
}: PaneProps = $props()
function handleClose() {
isOpen = false
onClose?.()
}
</script>
<BasePane bind:isOpen {position} {closeOnBackdrop} {closeOnEscape} {maxWidth} {maxHeight} {onClose}>
{#if title || showCloseButton}
<div class="pane-header">
{#if title}
<h3 class="pane-title">{title}</h3>
{/if}
{#if showCloseButton}
<button class="pane-close" onclick={handleClose} aria-label="Close pane">
<X size={20} />
</button>
{/if}
</div>
{/if}
{#if children}
{@render children()}
{/if}
</BasePane>
<style lang="scss">
@import '$styles/variables';
.pane-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: $unit-2x;
border-bottom: 1px solid $gray-90;
}
.pane-title {
margin: 0;
font-size: $font-size;
font-weight: 600;
color: $gray-10;
}
.pane-close {
display: flex;
align-items: center;
justify-content: center;
width: $unit-4x;
height: $unit-4x;
padding: 0;
background: transparent;
border: none;
border-radius: $corner-radius-sm;
color: $gray-50;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: $gray-95;
color: $gray-30;
}
&:focus-visible {
outline: 2px solid $primary-color;
outline-offset: 2px;
}
}
</style>

View file

@ -0,0 +1,32 @@
import { writable } from 'svelte/store'
interface PaneState {
activePane: string | null
}
function createPaneManager() {
const { subscribe, set, update } = writable<PaneState>({
activePane: null
})
return {
subscribe,
open: (paneId: string) => {
update((state) => ({
...state,
activePane: paneId
}))
},
close: () => {
update((state) => ({
...state,
activePane: null
}))
},
isActive: (paneId: string, state: PaneState) => {
return state.activePane === paneId
}
}
}
export const paneManager = createPaneManager()