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:
parent
12c30c1501
commit
d767d9578f
3 changed files with 301 additions and 0 deletions
170
src/lib/components/ui/BasePane.svelte
Normal file
170
src/lib/components/ui/BasePane.svelte
Normal 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>
|
||||
99
src/lib/components/ui/Pane.svelte
Normal file
99
src/lib/components/ui/Pane.svelte
Normal 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>
|
||||
32
src/lib/stores/pane-manager.ts
Normal file
32
src/lib/stores/pane-manager.ts
Normal 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()
|
||||
Loading…
Reference in a new issue