@classic-homes/theme-svelte 0.1.50 → 0.1.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1399 @@
1
+ <script lang="ts">
2
+ import { cn } from '../utils.js';
3
+ import { onMount } from 'svelte';
4
+ import type { Snippet } from 'svelte';
5
+ import type {
6
+ PanelEdge,
7
+ PanelMode,
8
+ PanelVariant,
9
+ CardConfig,
10
+ Position,
11
+ Size,
12
+ PanelConstraints,
13
+ DataPanelAction,
14
+ ResizeHandle,
15
+ } from '@classic-homes/data-panel-core';
16
+ import {
17
+ DEFAULT_PANEL_STATE,
18
+ DEFAULT_CONSTRAINTS,
19
+ DEFAULT_CARD_CONFIG,
20
+ CARD_HEADER_HEIGHT,
21
+ CARD_EXPANDED_RATIO,
22
+ CARD_MIN_WIDTH,
23
+ loadPanelState,
24
+ savePanelState,
25
+ getPointerPosition,
26
+ calculateDragPosition,
27
+ calculateResize,
28
+ getResizeCursor,
29
+ getPinnedResizeHandle,
30
+ constrainSize,
31
+ constrainPosition,
32
+ constrainPinnedWidth,
33
+ constrainPinnedHeight,
34
+ detectEdgeSnap,
35
+ getPinnedPositionStyles,
36
+ getDetachedPositionStyles,
37
+ getInitialDetachedPosition,
38
+ isHorizontalEdge,
39
+ // Pinned drag-to-detach utilities
40
+ calculateDetachDistance,
41
+ calculatePullOffset,
42
+ calculateDetachedPositionFromPinned,
43
+ getPinnedPullTransform,
44
+ } from '@classic-homes/data-panel-core';
45
+
46
+ import DataPanelHeader from './DataPanelHeader.svelte';
47
+ import DataPanelContent from './DataPanelContent.svelte';
48
+ import DataPanelFooter from './DataPanelFooter.svelte';
49
+ import DataPanelTab from './DataPanelTab.svelte';
50
+
51
+ interface Props {
52
+ /** Panel visibility */
53
+ open?: boolean;
54
+ /** Callback when open state changes */
55
+ onOpenChange?: (open: boolean) => void;
56
+ /** Panel mode - pinned to edge or detached floating */
57
+ mode?: PanelMode;
58
+ /** Callback when mode changes */
59
+ onModeChange?: (mode: PanelMode) => void;
60
+ /** Panel variant - 'full' for traditional sidebar, 'card' for mobile bottom sheet */
61
+ variant?: PanelVariant;
62
+ /** Callback when variant changes */
63
+ onVariantChange?: (variant: PanelVariant) => void;
64
+ /** Edge to pin panel to (when mode is 'pinned') */
65
+ edge?: PanelEdge;
66
+ /** Callback when edge changes */
67
+ onEdgeChange?: (edge: PanelEdge) => void;
68
+ /** Whether panel is expanded or collapsed */
69
+ expanded?: boolean;
70
+ /** Callback when expanded state changes */
71
+ onExpandedChange?: (expanded: boolean) => void;
72
+ /** Panel title */
73
+ title?: string;
74
+ /** Panel subtitle */
75
+ subtitle?: string;
76
+ /** Content inside the panel */
77
+ children: Snippet;
78
+ /** Footer action buttons */
79
+ actions?: DataPanelAction[];
80
+ /** Size constraints */
81
+ constraints?: PanelConstraints;
82
+ /** Card mode configuration */
83
+ cardConfig?: CardConfig;
84
+ /** Disable close button */
85
+ disableClose?: boolean;
86
+ /** Disable resize functionality */
87
+ disableResize?: boolean;
88
+ /** Disable drag functionality (detached mode) */
89
+ disableDrag?: boolean;
90
+ /** Disable mode switching */
91
+ disableModeSwitch?: boolean;
92
+ /** localStorage key for state persistence */
93
+ persistKey?: string;
94
+ /** Distance in pixels for edge snap detection */
95
+ snapThreshold?: number;
96
+ /** Distance in pixels to drag before detaching from pinned mode (0 to disable) */
97
+ detachThreshold?: number;
98
+ /** Custom header content (replaces title/subtitle) */
99
+ header?: Snippet;
100
+ /** Custom header actions */
101
+ headerActions?: Snippet;
102
+ /** Custom footer content */
103
+ footer?: Snippet;
104
+ /** Custom footer actions */
105
+ footerActions?: Snippet;
106
+ /** Additional class */
107
+ class?: string;
108
+ }
109
+
110
+ let {
111
+ open = $bindable(true),
112
+ onOpenChange,
113
+ mode: propMode,
114
+ onModeChange,
115
+ variant: propVariant,
116
+ onVariantChange,
117
+ edge: propEdge,
118
+ onEdgeChange,
119
+ expanded: propExpanded,
120
+ onExpandedChange,
121
+ title,
122
+ subtitle,
123
+ children,
124
+ actions = [],
125
+ constraints = {},
126
+ cardConfig = {},
127
+ disableClose = false,
128
+ disableResize = false,
129
+ disableDrag = false,
130
+ disableModeSwitch = false,
131
+ persistKey,
132
+ snapThreshold = 20,
133
+ detachThreshold = 40,
134
+ header,
135
+ headerActions,
136
+ footer,
137
+ footerActions,
138
+ class: className,
139
+ }: Props = $props();
140
+
141
+ // Resolve constraints and card config (use $derived to react to prop changes)
142
+ const resolvedConstraints = $derived({ ...DEFAULT_CONSTRAINTS, ...constraints });
143
+ const resolvedCardConfig = $derived({ ...DEFAULT_CARD_CONFIG, ...cardConfig });
144
+
145
+ // Internal state - initialized from props or persistence
146
+ let internalMode = $state<PanelMode>(DEFAULT_PANEL_STATE.mode);
147
+ let internalVariant = $state<PanelVariant>(DEFAULT_PANEL_STATE.variant);
148
+ let internalEdge = $state<PanelEdge>(DEFAULT_PANEL_STATE.edge);
149
+ let internalExpanded = $state<boolean>(DEFAULT_PANEL_STATE.isExpanded);
150
+ let detachedPosition = $state<Position>(DEFAULT_PANEL_STATE.detachedPosition);
151
+ let detachedSize = $state<Size>(DEFAULT_PANEL_STATE.detachedSize);
152
+ let pinnedSize = $state<number>(DEFAULT_PANEL_STATE.pinnedSize);
153
+
154
+ // Card position state (horizontal position for card mode)
155
+ let cardHorizontalPosition = $state<number>(0);
156
+ let cardExpandedHeight = $state<number>(0);
157
+ // Card width state (for desktop width resizing)
158
+ let cardWidth = $state<number | null>(null); // null means use default calculated width
159
+
160
+ // Drag/resize state
161
+ let isDragging = $state(false);
162
+ let isResizing = $state(false);
163
+ let isCardVerticalDragging = $state(false);
164
+ let isCardHorizontalDragging = $state(false);
165
+ let dragStart = $state<Position>({ x: 0, y: 0 });
166
+ let dragPanelStart = $state<Position>({ x: 0, y: 0 });
167
+ let resizeHandle = $state<ResizeHandle | null>(null);
168
+ let resizeStart = $state<Position>({ x: 0, y: 0 });
169
+ let resizePanelStart = $state<Position>({ x: 0, y: 0 });
170
+ let resizeSizeStart = $state<Size>({ width: 0, height: 0 });
171
+
172
+ // Card drag state
173
+ let cardDragStartY = $state(0);
174
+ let cardDragStartX = $state(0);
175
+ let cardDragStartTime = $state(0);
176
+ let cardDragCurrentHeight = $state(CARD_HEADER_HEIGHT);
177
+ let cardDragStartExpanded = $state(false);
178
+ let cardDragStartHorizontalPosition = $state(0);
179
+ // Flag to indicate we're animating after a drag (keeps using cardDragCurrentHeight for height)
180
+ let isCardHeightTransitioning = $state(false);
181
+
182
+ // Card width resize state
183
+ let isCardWidthResizing = $state(false);
184
+ let cardWidthResizeHandle = $state<'left' | 'right' | null>(null);
185
+ let cardWidthResizeStartX = $state(0);
186
+ let cardWidthResizeStartWidth = $state(0);
187
+ let cardWidthResizeStartLeft = $state(0);
188
+
189
+ // Snap preview state
190
+ let snapPreview = $state<PanelEdge | null>(null);
191
+
192
+ // Pinned drag-to-detach state
193
+ let isPinnedDragging = $state(false);
194
+ let pullOffset = $state(0);
195
+ let pinnedDragStartPosition = $state<Position>({ x: 0, y: 0 });
196
+ let pinnedDragHasDetached = $state(false);
197
+
198
+ // Panel element ref
199
+ let panelRef = $state<HTMLDivElement | null>(null);
200
+
201
+ // Viewport dimensions
202
+ let viewportWidth = $state(typeof window !== 'undefined' ? window.innerWidth : 1024);
203
+ let viewportHeight = $state(typeof window !== 'undefined' ? window.innerHeight : 768);
204
+
205
+ // Detect mobile
206
+ let isMobile = $derived(viewportWidth < 640);
207
+
208
+ // Derived state - use props if provided, otherwise internal state
209
+ const mode = $derived(propMode ?? internalMode);
210
+ const variant = $derived(propVariant ?? internalVariant);
211
+ const edge = $derived(propEdge ?? internalEdge);
212
+ const isExpanded = $derived(propExpanded ?? internalExpanded);
213
+
214
+ // Save timeout for debounced persistence
215
+ let saveTimeout: ReturnType<typeof setTimeout> | null = null;
216
+
217
+ function debouncedSave() {
218
+ if (!persistKey) return;
219
+
220
+ if (saveTimeout) {
221
+ clearTimeout(saveTimeout);
222
+ }
223
+
224
+ saveTimeout = setTimeout(() => {
225
+ savePanelState(persistKey, {
226
+ mode: internalMode,
227
+ variant: internalVariant,
228
+ edge: internalEdge,
229
+ isExpanded: internalExpanded,
230
+ detachedPosition,
231
+ detachedSize,
232
+ pinnedSize,
233
+ cardSnapIndex: 0, // Legacy field
234
+ });
235
+ saveTimeout = null;
236
+ }, 300);
237
+ }
238
+
239
+ // Initialize from persistence on mount
240
+ onMount(() => {
241
+ // Update viewport dimensions
242
+ viewportWidth = window.innerWidth;
243
+ viewportHeight = window.innerHeight;
244
+
245
+ // Initialize card expanded height
246
+ cardExpandedHeight = viewportHeight * CARD_EXPANDED_RATIO;
247
+
248
+ // Listen for resize
249
+ const handleResize = () => {
250
+ viewportWidth = window.innerWidth;
251
+ viewportHeight = window.innerHeight;
252
+ cardExpandedHeight = viewportHeight * CARD_EXPANDED_RATIO;
253
+ };
254
+ window.addEventListener('resize', handleResize);
255
+
256
+ if (persistKey) {
257
+ const persisted = loadPanelState(persistKey);
258
+ if (persisted) {
259
+ if (propMode === undefined && persisted.mode) internalMode = persisted.mode;
260
+ if (propVariant === undefined && persisted.variant) internalVariant = persisted.variant;
261
+ if (propEdge === undefined && persisted.edge) internalEdge = persisted.edge;
262
+ if (propExpanded === undefined && persisted.isExpanded !== undefined)
263
+ internalExpanded = persisted.isExpanded;
264
+ if (persisted.detachedPosition) detachedPosition = persisted.detachedPosition;
265
+ if (persisted.detachedSize) detachedSize = persisted.detachedSize;
266
+ if (persisted.pinnedSize) pinnedSize = persisted.pinnedSize;
267
+ }
268
+ }
269
+
270
+ // Center detached panel on first show if at default position
271
+ if (mode === 'detached') {
272
+ if (
273
+ detachedPosition.x === DEFAULT_PANEL_STATE.detachedPosition.x &&
274
+ detachedPosition.y === DEFAULT_PANEL_STATE.detachedPosition.y
275
+ ) {
276
+ detachedPosition = getInitialDetachedPosition(
277
+ detachedSize,
278
+ window.innerWidth,
279
+ window.innerHeight
280
+ );
281
+ }
282
+ }
283
+
284
+ // Handle escape key to close
285
+ const handleKeyDown = (e: KeyboardEvent) => {
286
+ if (e.key === 'Escape' && !disableClose) {
287
+ handleClose();
288
+ }
289
+ };
290
+ window.addEventListener('keydown', handleKeyDown);
291
+
292
+ return () => {
293
+ window.removeEventListener('resize', handleResize);
294
+ window.removeEventListener('keydown', handleKeyDown);
295
+ };
296
+ });
297
+
298
+ // State update handlers
299
+ function handleModeChange(newMode: PanelMode) {
300
+ if (propMode === undefined) {
301
+ internalMode = newMode;
302
+ debouncedSave();
303
+ }
304
+ onModeChange?.(newMode);
305
+ }
306
+
307
+ function handleVariantChange(newVariant: PanelVariant) {
308
+ if (propVariant === undefined) {
309
+ internalVariant = newVariant;
310
+ debouncedSave();
311
+ }
312
+ onVariantChange?.(newVariant);
313
+ }
314
+
315
+ function handleEdgeChange(newEdge: PanelEdge) {
316
+ if (propEdge === undefined) {
317
+ internalEdge = newEdge;
318
+ debouncedSave();
319
+ }
320
+ onEdgeChange?.(newEdge);
321
+ }
322
+
323
+ function handleExpandedChange(newExpanded: boolean) {
324
+ if (propExpanded === undefined) {
325
+ internalExpanded = newExpanded;
326
+ debouncedSave();
327
+ }
328
+ onExpandedChange?.(newExpanded);
329
+ }
330
+
331
+ function handleClose() {
332
+ open = false;
333
+ onOpenChange?.(false);
334
+ }
335
+
336
+ function handleTabClick() {
337
+ handleExpandedChange(true);
338
+ }
339
+
340
+ function handleToggleExpand() {
341
+ handleExpandedChange(!isExpanded);
342
+ }
343
+
344
+ // Drag handlers for detached mode
345
+ function handleDragStart(e: PointerEvent) {
346
+ if (disableDrag) return;
347
+
348
+ // Only drag from header
349
+ const target = e.target as HTMLElement;
350
+ if (!target.closest('[data-panel-header]')) return;
351
+
352
+ const pos = getPointerPosition(e);
353
+
354
+ if (mode === 'detached') {
355
+ // Standard detached drag
356
+ isDragging = true;
357
+ dragStart = pos;
358
+ dragPanelStart = { ...detachedPosition };
359
+ document.body.style.cursor = 'grabbing';
360
+ document.body.style.userSelect = 'none';
361
+ } else if (mode === 'pinned' && variant === 'full' && detachThreshold > 0) {
362
+ // Start pinned drag-to-detach
363
+ isPinnedDragging = true;
364
+ pinnedDragStartPosition = pos;
365
+ pinnedDragHasDetached = false;
366
+ pullOffset = 0;
367
+ document.body.style.cursor = 'grab';
368
+ document.body.style.userSelect = 'none';
369
+ }
370
+ }
371
+
372
+ function handleDragMove(e: PointerEvent) {
373
+ if (!isDragging) return;
374
+
375
+ const currentPos = getPointerPosition(e);
376
+ const newPosition = calculateDragPosition(
377
+ {
378
+ isDragging: true,
379
+ startPosition: dragStart,
380
+ startPanelPosition: dragPanelStart,
381
+ currentPosition: currentPos,
382
+ },
383
+ currentPos
384
+ );
385
+
386
+ // Check for edge snap
387
+ const snap = detectEdgeSnap(
388
+ newPosition,
389
+ detachedSize,
390
+ window.innerWidth,
391
+ window.innerHeight,
392
+ snapThreshold
393
+ );
394
+
395
+ snapPreview = snap.shouldSnap ? snap.edge : null;
396
+
397
+ // Constrain position to viewport
398
+ detachedPosition = constrainPosition(
399
+ newPosition,
400
+ detachedSize,
401
+ window.innerWidth,
402
+ window.innerHeight
403
+ );
404
+ }
405
+
406
+ function handleDragEnd() {
407
+ if (!isDragging) return;
408
+
409
+ isDragging = false;
410
+ document.body.style.cursor = '';
411
+ document.body.style.userSelect = '';
412
+
413
+ // Apply snap if active
414
+ if (snapPreview) {
415
+ handleModeChange('pinned');
416
+ handleEdgeChange(snapPreview);
417
+ snapPreview = null;
418
+ } else {
419
+ debouncedSave();
420
+ }
421
+ }
422
+
423
+ // Pinned drag-to-detach handlers
424
+ function handlePinnedDragMove(e: PointerEvent) {
425
+ if (!isPinnedDragging) return;
426
+
427
+ const currentPos = getPointerPosition(e);
428
+ const distance = calculateDetachDistance(pinnedDragStartPosition, currentPos, edge);
429
+
430
+ if (distance >= detachThreshold && !pinnedDragHasDetached) {
431
+ // Threshold exceeded - detach the panel
432
+ pinnedDragHasDetached = true;
433
+ isPinnedDragging = false;
434
+ pullOffset = 0;
435
+
436
+ // Calculate where to position the detached panel
437
+ const newPosition = calculateDetachedPositionFromPinned(
438
+ currentPos,
439
+ pinnedSize,
440
+ detachedSize,
441
+ edge,
442
+ window.innerWidth,
443
+ window.innerHeight
444
+ );
445
+
446
+ // Switch to detached mode and start regular drag
447
+ detachedPosition = newPosition;
448
+ handleModeChange('detached');
449
+
450
+ // Immediately start detached dragging from current position
451
+ isDragging = true;
452
+ dragStart = currentPos;
453
+ dragPanelStart = newPosition;
454
+ document.body.style.cursor = 'grabbing';
455
+ } else if (!pinnedDragHasDetached) {
456
+ // Still within threshold - show pull effect
457
+ pullOffset = calculatePullOffset(distance, detachThreshold);
458
+ }
459
+ }
460
+
461
+ function handlePinnedDragEnd() {
462
+ if (!isPinnedDragging) return;
463
+
464
+ isPinnedDragging = false;
465
+ pullOffset = 0;
466
+ document.body.style.cursor = '';
467
+ document.body.style.userSelect = '';
468
+ }
469
+
470
+ // Card mode handlers
471
+ function handleCardHeaderPointerDown(e: PointerEvent) {
472
+ if (variant !== 'card') return;
473
+
474
+ const target = e.target as HTMLElement;
475
+
476
+ // Check for drag handle FIRST (before button check, since drag handle is a button)
477
+ const isDragHandle = target.closest('[data-card-drag-handle]');
478
+
479
+ // Don't start drag if clicking on other buttons (not the drag handle)
480
+ if (!isDragHandle && target.closest('button')) return;
481
+
482
+ const pos = getPointerPosition(e);
483
+ cardDragStartY = pos.y;
484
+ cardDragStartX = pos.x;
485
+ cardDragStartTime = Date.now();
486
+ cardDragStartExpanded = isExpanded;
487
+ cardDragCurrentHeight = isExpanded ? cardExpandedHeight : CARD_HEADER_HEIGHT;
488
+ cardDragStartHorizontalPosition = cardHorizontalPosition;
489
+
490
+ // On mobile: vertical drag to expand/collapse
491
+ // On desktop: horizontal drag to reposition (via drag handle), click header to expand/collapse
492
+ if (isMobile) {
493
+ isCardVerticalDragging = true;
494
+ } else {
495
+ if (isDragHandle) {
496
+ isCardHorizontalDragging = true;
497
+ } else {
498
+ // Clicking elsewhere on header toggles expand
499
+ isCardVerticalDragging = true;
500
+ }
501
+ }
502
+
503
+ document.body.style.userSelect = 'none';
504
+ (e.target as HTMLElement).setPointerCapture(e.pointerId);
505
+ }
506
+
507
+ function handleCardPointerMove(e: PointerEvent) {
508
+ const currentPos = getPointerPosition(e);
509
+
510
+ if (isCardVerticalDragging) {
511
+ const deltaY = cardDragStartY - currentPos.y; // Positive when dragging up
512
+
513
+ // Both mobile and desktop: change height directly during drag
514
+ const startHeight = cardDragStartExpanded ? cardExpandedHeight : CARD_HEADER_HEIGHT;
515
+ cardDragCurrentHeight = Math.max(
516
+ CARD_HEADER_HEIGHT,
517
+ Math.min(viewportHeight * 0.95, startHeight + deltaY)
518
+ );
519
+ }
520
+
521
+ if (isCardHorizontalDragging && !isMobile) {
522
+ const deltaX = currentPos.x - cardDragStartX;
523
+ // Calculate card width and default offset (centered position)
524
+ const cardWidth = Math.min(
525
+ viewportWidth - resolvedCardConfig.horizontalMargin * 2,
526
+ resolvedCardConfig.maxWidth
527
+ );
528
+ const defaultLeftOffset = Math.max(
529
+ resolvedCardConfig.horizontalMargin,
530
+ (viewportWidth - cardWidth) / 2
531
+ );
532
+ // Calculate bounds: can move from left margin to right margin
533
+ const minOffset = -(defaultLeftOffset - resolvedCardConfig.horizontalMargin);
534
+ const maxOffset =
535
+ viewportWidth - cardWidth - defaultLeftOffset - resolvedCardConfig.horizontalMargin;
536
+ // Apply delta to starting position and clamp to bounds
537
+ const newPosition = cardDragStartHorizontalPosition + deltaX;
538
+ cardHorizontalPosition = Math.max(minOffset, Math.min(maxOffset, newPosition));
539
+ }
540
+ }
541
+
542
+ function handleCardPointerUp(e: PointerEvent) {
543
+ const endTime = Date.now();
544
+ const currentPos = getPointerPosition(e);
545
+ const deltaTime = endTime - cardDragStartTime;
546
+ const deltaY = cardDragStartY - currentPos.y;
547
+ const deltaX = currentPos.x - cardDragStartX;
548
+
549
+ document.body.style.userSelect = '';
550
+ (e.target as HTMLElement).releasePointerCapture(e.pointerId);
551
+
552
+ if (isCardVerticalDragging) {
553
+ // Tap detection - toggle and exit early
554
+ if (deltaTime < 200 && Math.abs(deltaY) < 10) {
555
+ isCardVerticalDragging = false;
556
+ handleToggleExpand();
557
+ return;
558
+ }
559
+
560
+ // Determine final state based on velocity or position
561
+ const velocity = deltaY / deltaTime;
562
+ let newExpanded: boolean;
563
+
564
+ if (Math.abs(velocity) > 0.5) {
565
+ // Velocity-based: fast swipe snaps in direction of swipe
566
+ newExpanded = velocity > 0;
567
+ } else {
568
+ // Position-based: snap to whichever state the card is closer to
569
+ const midpoint = (CARD_HEADER_HEIGHT + cardExpandedHeight) / 2;
570
+ newExpanded = cardDragCurrentHeight > midpoint;
571
+ }
572
+
573
+ const targetHeight = newExpanded ? cardExpandedHeight : CARD_HEADER_HEIGHT;
574
+
575
+ // Step 1: Stop dragging (enables CSS transitions) but keep current height
576
+ isCardVerticalDragging = false;
577
+ isCardHeightTransitioning = true;
578
+ handleExpandedChange(newExpanded);
579
+
580
+ // Step 2: Next frame, update height to trigger the animation
581
+ // This ensures transitions are enabled before the height changes
582
+ requestAnimationFrame(() => {
583
+ cardDragCurrentHeight = targetHeight;
584
+ });
585
+ }
586
+
587
+ if (isCardHorizontalDragging) {
588
+ isCardHorizontalDragging = false;
589
+ debouncedSave();
590
+ }
591
+ }
592
+
593
+ // Resize handlers
594
+ function handleResizeStart(e: PointerEvent, handle: ResizeHandle) {
595
+ if (disableResize) return;
596
+
597
+ e.preventDefault();
598
+ e.stopPropagation();
599
+
600
+ isResizing = true;
601
+ resizeHandle = handle;
602
+ const pos = getPointerPosition(e);
603
+ resizeStart = pos;
604
+ resizePanelStart = { ...detachedPosition };
605
+ resizeSizeStart =
606
+ mode === 'detached' ? { ...detachedSize } : { width: pinnedSize, height: pinnedSize };
607
+
608
+ document.body.style.cursor = getResizeCursor(handle);
609
+ document.body.style.userSelect = 'none';
610
+ }
611
+
612
+ function handleResizeMove(e: PointerEvent) {
613
+ if (!isResizing || !resizeHandle) return;
614
+
615
+ const currentPos = getPointerPosition(e);
616
+
617
+ if (mode === 'detached') {
618
+ const result = calculateResize(
619
+ {
620
+ isResizing: true,
621
+ handle: resizeHandle,
622
+ startPosition: resizeStart,
623
+ startSize: resizeSizeStart,
624
+ startPanelPosition: resizePanelStart,
625
+ },
626
+ currentPos
627
+ );
628
+
629
+ // Constrain size and position
630
+ const constrainedSize = constrainSize(result.size, resolvedConstraints);
631
+ const constrainedPosition = constrainPosition(
632
+ result.position,
633
+ constrainedSize,
634
+ window.innerWidth,
635
+ window.innerHeight
636
+ );
637
+
638
+ detachedSize = constrainedSize;
639
+ detachedPosition = constrainedPosition;
640
+ } else {
641
+ // Pinned mode - resize perpendicular to edge
642
+ const delta = isHorizontalEdge(edge)
643
+ ? currentPos.x - resizeStart.x
644
+ : currentPos.y - resizeStart.y;
645
+
646
+ const direction = edge === 'left' || edge === 'top' ? 1 : -1;
647
+ const newSize = resizeSizeStart.width + delta * direction;
648
+
649
+ if (isHorizontalEdge(edge)) {
650
+ pinnedSize = constrainPinnedWidth(newSize, resolvedConstraints, window.innerWidth);
651
+ } else {
652
+ pinnedSize = constrainPinnedHeight(newSize, resolvedConstraints, window.innerHeight);
653
+ }
654
+ }
655
+ }
656
+
657
+ function handleResizeEnd() {
658
+ if (!isResizing) return;
659
+
660
+ isResizing = false;
661
+ resizeHandle = null;
662
+ document.body.style.cursor = '';
663
+ document.body.style.userSelect = '';
664
+ debouncedSave();
665
+ }
666
+
667
+ // Card resize (top edge only when expanded)
668
+ function handleCardResizeStart(e: PointerEvent) {
669
+ if (variant !== 'card' || !isExpanded || disableResize) return;
670
+
671
+ e.preventDefault();
672
+ e.stopPropagation();
673
+
674
+ isResizing = true;
675
+ const pos = getPointerPosition(e);
676
+ resizeStart = pos;
677
+ resizeSizeStart = { width: 0, height: cardExpandedHeight };
678
+
679
+ document.body.style.cursor = 'ns-resize';
680
+ document.body.style.userSelect = 'none';
681
+ }
682
+
683
+ function handleCardResizeMove(e: PointerEvent) {
684
+ if (!isResizing || variant !== 'card') return;
685
+
686
+ const currentPos = getPointerPosition(e);
687
+ const deltaY = resizeStart.y - currentPos.y;
688
+ const newHeight = Math.max(
689
+ CARD_HEADER_HEIGHT * 2,
690
+ Math.min(viewportHeight * 0.95, resizeSizeStart.height + deltaY)
691
+ );
692
+ cardExpandedHeight = newHeight;
693
+ }
694
+
695
+ // Card width resize handlers (desktop only)
696
+ function handleCardWidthResizeStart(e: PointerEvent, handle: 'left' | 'right') {
697
+ if (variant !== 'card' || !isExpanded || disableResize || isMobile) return;
698
+
699
+ e.preventDefault();
700
+ e.stopPropagation();
701
+
702
+ isCardWidthResizing = true;
703
+ cardWidthResizeHandle = handle;
704
+ const pos = getPointerPosition(e);
705
+ cardWidthResizeStartX = pos.x;
706
+
707
+ // Calculate current width
708
+ const defaultCardWidth = Math.min(
709
+ viewportWidth - resolvedCardConfig.horizontalMargin * 2,
710
+ resolvedCardConfig.maxWidth
711
+ );
712
+ const currentWidth = cardWidth ?? defaultCardWidth;
713
+ cardWidthResizeStartWidth = currentWidth;
714
+
715
+ // Calculate current left position
716
+ const defaultLeftOffset = Math.max(
717
+ resolvedCardConfig.horizontalMargin,
718
+ (viewportWidth - defaultCardWidth) / 2
719
+ );
720
+ cardWidthResizeStartLeft = defaultLeftOffset + cardHorizontalPosition;
721
+
722
+ document.body.style.cursor = 'ew-resize';
723
+ document.body.style.userSelect = 'none';
724
+ }
725
+
726
+ function handleCardWidthResizeMove(e: PointerEvent) {
727
+ if (!isCardWidthResizing || !cardWidthResizeHandle) return;
728
+
729
+ const currentPos = getPointerPosition(e);
730
+ const deltaX = currentPos.x - cardWidthResizeStartX;
731
+
732
+ if (cardWidthResizeHandle === 'right') {
733
+ // Right handle: only adjust width
734
+ const newWidth = Math.max(
735
+ CARD_MIN_WIDTH,
736
+ Math.min(
737
+ resolvedCardConfig.maxWidth,
738
+ viewportWidth - cardWidthResizeStartLeft - resolvedCardConfig.horizontalMargin,
739
+ cardWidthResizeStartWidth + deltaX
740
+ )
741
+ );
742
+ cardWidth = newWidth;
743
+ } else {
744
+ // Left handle: adjust both left position and width
745
+ const newLeft = cardWidthResizeStartLeft + deltaX;
746
+ const newWidth = cardWidthResizeStartWidth - deltaX;
747
+
748
+ // Constrain: left can't go below margin, width can't go below min or above max
749
+ if (newLeft >= resolvedCardConfig.horizontalMargin && newWidth >= CARD_MIN_WIDTH) {
750
+ const constrainedWidth = Math.min(newWidth, resolvedCardConfig.maxWidth);
751
+ cardWidth = constrainedWidth;
752
+
753
+ // Calculate new horizontal position offset from default centered position
754
+ const defaultCardWidth = Math.min(
755
+ viewportWidth - resolvedCardConfig.horizontalMargin * 2,
756
+ resolvedCardConfig.maxWidth
757
+ );
758
+ const defaultLeftOffset = Math.max(
759
+ resolvedCardConfig.horizontalMargin,
760
+ (viewportWidth - defaultCardWidth) / 2
761
+ );
762
+ cardHorizontalPosition = newLeft - defaultLeftOffset;
763
+ }
764
+ }
765
+ }
766
+
767
+ function handleCardWidthResizeEnd() {
768
+ if (!isCardWidthResizing) return;
769
+
770
+ isCardWidthResizing = false;
771
+ cardWidthResizeHandle = null;
772
+ document.body.style.cursor = '';
773
+ document.body.style.userSelect = '';
774
+ debouncedSave();
775
+ }
776
+
777
+ // Global event listeners
778
+ function handlePointerMove(e: PointerEvent) {
779
+ if (isDragging) handleDragMove(e);
780
+ if (isPinnedDragging) handlePinnedDragMove(e);
781
+ if (isResizing && variant !== 'card') handleResizeMove(e);
782
+ if (isResizing && variant === 'card') handleCardResizeMove(e);
783
+ if (isCardVerticalDragging || isCardHorizontalDragging) handleCardPointerMove(e);
784
+ if (isCardWidthResizing) handleCardWidthResizeMove(e);
785
+ }
786
+
787
+ function handlePointerUp(e: PointerEvent) {
788
+ if (isDragging) handleDragEnd();
789
+ if (isPinnedDragging) handlePinnedDragEnd();
790
+ if (isResizing) handleResizeEnd();
791
+ if (isCardVerticalDragging || isCardHorizontalDragging) handleCardPointerUp(e);
792
+ if (isCardWidthResizing) handleCardWidthResizeEnd();
793
+ }
794
+
795
+ // Add/remove global listeners
796
+ $effect(() => {
797
+ if (
798
+ isDragging ||
799
+ isPinnedDragging ||
800
+ isResizing ||
801
+ isCardVerticalDragging ||
802
+ isCardHorizontalDragging ||
803
+ isCardWidthResizing
804
+ ) {
805
+ window.addEventListener('pointermove', handlePointerMove);
806
+ window.addEventListener('pointerup', handlePointerUp);
807
+ return () => {
808
+ window.removeEventListener('pointermove', handlePointerMove);
809
+ window.removeEventListener('pointerup', handlePointerUp);
810
+ };
811
+ }
812
+ });
813
+
814
+ // Reset card position and width when switching to mobile
815
+ $effect(() => {
816
+ if (isMobile && variant === 'card') {
817
+ // Reset to centered, full-width on mobile
818
+ cardHorizontalPosition = 0;
819
+ cardWidth = null;
820
+ }
821
+ });
822
+
823
+ // Clear height transition flag after CSS transition completes
824
+ // This returns control to the normal isExpanded-based height calculation
825
+ $effect(() => {
826
+ if (isCardHeightTransitioning) {
827
+ const timeout = setTimeout(() => {
828
+ isCardHeightTransitioning = false;
829
+ }, 350); // Slightly longer than CSS transition (300ms) to ensure completion
830
+ return () => clearTimeout(timeout);
831
+ }
832
+ });
833
+
834
+ // Calculate panel styles
835
+ const panelStyles = $derived(() => {
836
+ if (variant === 'card') {
837
+ // Card mode - bottom sheet style
838
+ const defaultCardWidth = Math.min(
839
+ viewportWidth - resolvedCardConfig.horizontalMargin * 2,
840
+ resolvedCardConfig.maxWidth
841
+ );
842
+ // Use custom cardWidth if set, otherwise use default
843
+ const actualCardWidth = cardWidth ?? defaultCardWidth;
844
+ const defaultLeftOffset = Math.max(
845
+ resolvedCardConfig.horizontalMargin,
846
+ (viewportWidth - defaultCardWidth) / 2
847
+ );
848
+
849
+ // Height based on expanded state or drag
850
+ let height: number;
851
+ if (isCardVerticalDragging || isCardHeightTransitioning) {
852
+ // During drag or animated transition, use cardDragCurrentHeight
853
+ height = cardDragCurrentHeight;
854
+ } else {
855
+ height = isExpanded ? cardExpandedHeight : CARD_HEADER_HEIGHT;
856
+ }
857
+
858
+ return {
859
+ bottom: '0',
860
+ left: `${defaultLeftOffset + cardHorizontalPosition}px`,
861
+ width: `${actualCardWidth}px`,
862
+ height: `${height}px`,
863
+ maxHeight: `${viewportHeight * 0.95}px`,
864
+ borderRadius: `${resolvedCardConfig.borderRadius}px ${resolvedCardConfig.borderRadius}px 0 0`,
865
+ };
866
+ }
867
+ if (mode === 'pinned') {
868
+ const baseStyles = getPinnedPositionStyles(edge, pinnedSize);
869
+ // Add pull transform when dragging from pinned
870
+ if (pullOffset > 0) {
871
+ const transform = getPinnedPullTransform(pullOffset, edge);
872
+ return { ...baseStyles, transform };
873
+ }
874
+ return baseStyles;
875
+ }
876
+ return getDetachedPositionStyles(detachedPosition, detachedSize);
877
+ });
878
+
879
+ // Panel position classes for full pinned mode
880
+ const pinnedClasses = $derived(
881
+ mode === 'pinned' && variant === 'full'
882
+ ? {
883
+ left: 'left-0 top-0 bottom-0 border-r',
884
+ right: 'right-0 top-0 bottom-0 border-l',
885
+ top: 'top-0 left-0 right-0 border-b',
886
+ bottom: 'bottom-0 left-0 right-0 border-t',
887
+ }[edge]
888
+ : ''
889
+ );
890
+
891
+ // Animation classes
892
+ const animationClass = $derived(() => {
893
+ if (variant === 'card') {
894
+ return 'animate-panel-slide-in-bottom';
895
+ }
896
+ if (mode === 'pinned') {
897
+ return {
898
+ left: 'animate-slide-right',
899
+ right: 'animate-slide-left',
900
+ top: 'animate-slide-down',
901
+ bottom: 'animate-slide-up',
902
+ }[edge];
903
+ }
904
+ return 'animate-scale-in';
905
+ });
906
+
907
+ // Whether to show content (collapsed card only shows header)
908
+ const showContent = $derived(variant !== 'card' || isExpanded || isCardVerticalDragging);
909
+ </script>
910
+
911
+ {#if open}
912
+ {#if variant === 'full' && !isExpanded && mode === 'pinned'}
913
+ <!-- Collapsed tab (full variant only) -->
914
+ <DataPanelTab {title} {edge} onclick={handleTabClick} />
915
+ {:else}
916
+ <!-- Snap preview indicator (detached mode only) -->
917
+ {#if snapPreview}
918
+ <div
919
+ class={cn(
920
+ 'fixed z-40 bg-primary/20 border-2 border-primary border-dashed transition-all',
921
+ snapPreview === 'left' && 'left-0 top-0 bottom-0 w-80',
922
+ snapPreview === 'right' && 'right-0 top-0 bottom-0 w-80',
923
+ snapPreview === 'top' && 'top-0 left-0 right-0 h-64',
924
+ snapPreview === 'bottom' && 'bottom-0 left-0 right-0 h-64'
925
+ )}
926
+ ></div>
927
+ {/if}
928
+
929
+ <!-- Overlay for expanded card on mobile -->
930
+ {#if variant === 'card' && isExpanded && isMobile}
931
+ <div
932
+ class="fixed inset-0 z-40 bg-black/50 transition-opacity"
933
+ onclick={handleToggleExpand}
934
+ onkeydown={(e) => e.key === 'Enter' && handleToggleExpand()}
935
+ role="button"
936
+ tabindex="-1"
937
+ aria-label="Close panel"
938
+ ></div>
939
+ {/if}
940
+
941
+ <!-- Panel -->
942
+ <div
943
+ bind:this={panelRef}
944
+ class={cn(
945
+ 'fixed z-50 flex flex-col bg-background border border-border shadow-lg overflow-hidden',
946
+ variant === 'full' && mode === 'pinned' && pinnedClasses,
947
+ variant === 'full' && mode === 'detached' && 'rounded-lg',
948
+ variant === 'card' && 'rounded-t-lg border-b-0',
949
+ isPinnedDragging && pullOffset > 0 && 'shadow-2xl',
950
+ !isDragging &&
951
+ !isPinnedDragging &&
952
+ !isCardHorizontalDragging &&
953
+ !isCardVerticalDragging &&
954
+ !isResizing &&
955
+ !isCardWidthResizing &&
956
+ 'transition-[height,left,width,transform] duration-300 ease-out',
957
+ animationClass(),
958
+ className
959
+ )}
960
+ style={Object.entries(panelStyles())
961
+ .map(([k, v]) => `${k}:${v}`)
962
+ .join(';')}
963
+ role={mode === 'detached' || variant === 'card' ? 'dialog' : 'complementary'}
964
+ aria-modal={mode === 'detached' || (variant === 'card' && isExpanded) ? 'true' : undefined}
965
+ aria-labelledby={title ? 'data-panel-title' : undefined}
966
+ aria-expanded={variant === 'card' ? isExpanded : undefined}
967
+ tabindex="-1"
968
+ onpointerdown={variant === 'full' &&
969
+ (mode === 'detached' || (mode === 'pinned' && detachThreshold > 0))
970
+ ? handleDragStart
971
+ : undefined}
972
+ >
973
+ <!-- Header -->
974
+ <div
975
+ data-panel-header
976
+ class={cn(
977
+ 'relative flex items-center justify-between gap-2 border-b border-border bg-muted/30 px-4',
978
+ variant === 'full' && 'py-3',
979
+ variant === 'card' && 'pt-5 pb-3',
980
+ variant === 'full' &&
981
+ !disableDrag &&
982
+ (mode === 'detached' || (mode === 'pinned' && detachThreshold > 0)) &&
983
+ 'cursor-grab',
984
+ variant === 'card' && 'cursor-pointer'
985
+ )}
986
+ onpointerdown={variant === 'card' ? handleCardHeaderPointerDown : undefined}
987
+ >
988
+ <!-- Card drag handle / puller (overlays header) -->
989
+ {#if variant === 'card' && resolvedCardConfig.showDragHandle}
990
+ <div
991
+ class="absolute top-1.5 left-1/2 -translate-x-1/2 w-10 h-1.5 rounded-full bg-muted-foreground/30 cursor-pointer touch-none"
992
+ onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && handleToggleExpand()}
993
+ onpointerdown={handleCardHeaderPointerDown}
994
+ role="button"
995
+ tabindex="0"
996
+ aria-label={isExpanded ? 'Collapse panel' : 'Expand panel'}
997
+ aria-expanded={isExpanded}
998
+ ></div>
999
+ {/if}
1000
+ {#if variant === 'card'}
1001
+ <div
1002
+ class="flex-1 min-w-0"
1003
+ onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && handleToggleExpand()}
1004
+ role="button"
1005
+ tabindex="0"
1006
+ >
1007
+ {#if header}
1008
+ {@render header()}
1009
+ {:else}
1010
+ {#if subtitle}
1011
+ <p class="text-sm text-muted-foreground truncate">{subtitle}</p>
1012
+ {/if}
1013
+ {#if title}
1014
+ <h3 id="data-panel-title" class="text-lg font-semibold truncate">{title}</h3>
1015
+ {/if}
1016
+ {/if}
1017
+ </div>
1018
+ {:else}
1019
+ <div class="flex-1 min-w-0">
1020
+ {#if header}
1021
+ {@render header()}
1022
+ {:else}
1023
+ {#if subtitle}
1024
+ <p class="text-sm text-muted-foreground truncate">{subtitle}</p>
1025
+ {/if}
1026
+ {#if title}
1027
+ <h3 id="data-panel-title" class="text-lg font-semibold truncate">{title}</h3>
1028
+ {/if}
1029
+ {/if}
1030
+ </div>
1031
+ {/if}
1032
+
1033
+ <div class="flex items-center gap-1">
1034
+ {#if headerActions}
1035
+ {@render headerActions()}
1036
+ {/if}
1037
+
1038
+ <!-- Horizontal drag handle (desktop card only) -->
1039
+ {#if variant === 'card' && !isMobile}
1040
+ <button
1041
+ data-card-drag-handle
1042
+ type="button"
1043
+ class="p-1.5 rounded hover:bg-muted cursor-move touch-none"
1044
+ aria-label="Drag to reposition panel"
1045
+ title="Drag to move"
1046
+ onpointerdown={handleCardHeaderPointerDown}
1047
+ >
1048
+ <svg
1049
+ class="w-4 h-4 text-muted-foreground"
1050
+ fill="none"
1051
+ viewBox="0 0 24 24"
1052
+ stroke="currentColor"
1053
+ >
1054
+ <path
1055
+ stroke-linecap="round"
1056
+ stroke-linejoin="round"
1057
+ stroke-width="2"
1058
+ d="M4 8h16M4 16h16"
1059
+ />
1060
+ </svg>
1061
+ </button>
1062
+ {/if}
1063
+
1064
+ <!-- Expand/collapse toggle -->
1065
+ {#if variant === 'card' || (variant === 'full' && mode === 'pinned')}
1066
+ <button
1067
+ type="button"
1068
+ class="p-1.5 rounded hover:bg-muted"
1069
+ onclick={handleToggleExpand}
1070
+ aria-expanded={isExpanded}
1071
+ aria-label={isExpanded ? 'Collapse panel' : 'Expand panel'}
1072
+ title={isExpanded ? 'Collapse' : 'Expand'}
1073
+ >
1074
+ {#if isExpanded}
1075
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1076
+ <path
1077
+ stroke-linecap="round"
1078
+ stroke-linejoin="round"
1079
+ stroke-width="2"
1080
+ d="M19 9l-7 7-7-7"
1081
+ />
1082
+ </svg>
1083
+ {:else}
1084
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1085
+ <path
1086
+ stroke-linecap="round"
1087
+ stroke-linejoin="round"
1088
+ stroke-width="2"
1089
+ d="M5 15l7-7 7 7"
1090
+ />
1091
+ </svg>
1092
+ {/if}
1093
+ </button>
1094
+ {/if}
1095
+
1096
+ <!-- Mode menu (full variant only) -->
1097
+ {#if variant === 'full' && !disableModeSwitch}
1098
+ <DataPanelHeader
1099
+ {mode}
1100
+ {edge}
1101
+ isExpanded={true}
1102
+ disableModeSwitch={false}
1103
+ onModeChange={handleModeChange}
1104
+ onEdgeChange={handleEdgeChange}
1105
+ showModeMenuOnly
1106
+ />
1107
+ {/if}
1108
+
1109
+ <!-- Close button -->
1110
+ {#if !disableClose}
1111
+ <button
1112
+ type="button"
1113
+ class="p-1.5 rounded hover:bg-muted"
1114
+ onclick={handleClose}
1115
+ aria-label="Close panel"
1116
+ title="Close"
1117
+ >
1118
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1119
+ <path
1120
+ stroke-linecap="round"
1121
+ stroke-linejoin="round"
1122
+ stroke-width="2"
1123
+ d="M6 18L18 6M6 6l12 12"
1124
+ />
1125
+ </svg>
1126
+ </button>
1127
+ {/if}
1128
+ </div>
1129
+ </div>
1130
+
1131
+ <!-- Content (hidden when card is collapsed) -->
1132
+ {#if showContent}
1133
+ <DataPanelContent>
1134
+ {@render children()}
1135
+ </DataPanelContent>
1136
+
1137
+ <!-- Footer -->
1138
+ <DataPanelFooter {actions} {footer} {footerActions} />
1139
+ {/if}
1140
+
1141
+ <!-- Resize handles (full variant only) -->
1142
+ {#if variant === 'full' && !disableResize}
1143
+ {#if mode === 'detached'}
1144
+ <!-- Edge handles -->
1145
+ <div
1146
+ class="resize-handle resize-handle--horizontal resize-handle--top"
1147
+ onpointerdown={(e) => handleResizeStart(e, 'top')}
1148
+ ></div>
1149
+ <div
1150
+ class="resize-handle resize-handle--horizontal resize-handle--bottom"
1151
+ onpointerdown={(e) => handleResizeStart(e, 'bottom')}
1152
+ ></div>
1153
+ <div
1154
+ class="resize-handle resize-handle--vertical resize-handle--left"
1155
+ onpointerdown={(e) => handleResizeStart(e, 'left')}
1156
+ ></div>
1157
+ <div
1158
+ class="resize-handle resize-handle--vertical resize-handle--right"
1159
+ onpointerdown={(e) => handleResizeStart(e, 'right')}
1160
+ ></div>
1161
+ <!-- Corner handles (6px radius = 8px panel radius minus 2px offset) -->
1162
+ <div
1163
+ class="resize-handle resize-handle--corner resize-handle--top-left"
1164
+ onpointerdown={(e) => handleResizeStart(e, 'top-left')}
1165
+ ></div>
1166
+ <div
1167
+ class="resize-handle resize-handle--corner resize-handle--top-right"
1168
+ onpointerdown={(e) => handleResizeStart(e, 'top-right')}
1169
+ ></div>
1170
+ <div
1171
+ class="resize-handle resize-handle--corner resize-handle--bottom-left"
1172
+ onpointerdown={(e) => handleResizeStart(e, 'bottom-left')}
1173
+ ></div>
1174
+ <div
1175
+ class="resize-handle resize-handle--corner resize-handle--bottom-right"
1176
+ onpointerdown={(e) => handleResizeStart(e, 'bottom-right')}
1177
+ ></div>
1178
+ {:else}
1179
+ <!-- Single resize handle for pinned mode -->
1180
+ {@const pinnedHandle = getPinnedResizeHandle(edge)}
1181
+ <div
1182
+ class={cn(
1183
+ 'resize-handle',
1184
+ edge === 'left' && 'resize-handle--vertical resize-handle--right',
1185
+ edge === 'right' && 'resize-handle--vertical resize-handle--left',
1186
+ edge === 'top' && 'resize-handle--horizontal resize-handle--bottom',
1187
+ edge === 'bottom' && 'resize-handle--horizontal resize-handle--top'
1188
+ )}
1189
+ onpointerdown={(e) => handleResizeStart(e, pinnedHandle)}
1190
+ ></div>
1191
+ {/if}
1192
+ {/if}
1193
+
1194
+ <!-- Card resize handles (top edge for height, left/right for width - desktop only when expanded) -->
1195
+ {#if variant === 'card' && isExpanded && !disableResize && !isMobile}
1196
+ <!-- Top edge for height resize -->
1197
+ <div
1198
+ class="resize-handle resize-handle--horizontal resize-handle--top"
1199
+ onpointerdown={handleCardResizeStart}
1200
+ ></div>
1201
+ <!-- Left edge for width resize -->
1202
+ <div
1203
+ class="resize-handle resize-handle--vertical resize-handle--left resize-handle--card-edge"
1204
+ onpointerdown={(e) => handleCardWidthResizeStart(e, 'left')}
1205
+ ></div>
1206
+ <!-- Right edge for width resize -->
1207
+ <div
1208
+ class="resize-handle resize-handle--vertical resize-handle--right resize-handle--card-edge"
1209
+ onpointerdown={(e) => handleCardWidthResizeStart(e, 'right')}
1210
+ ></div>
1211
+ {/if}
1212
+ </div>
1213
+ {/if}
1214
+ {/if}
1215
+
1216
+ <style>
1217
+ /* Base resize handle - invisible hit area */
1218
+ .resize-handle {
1219
+ position: absolute;
1220
+ z-index: 10;
1221
+ touch-action: none;
1222
+ background: transparent;
1223
+ }
1224
+
1225
+ /* Edge handles - horizontal (top/bottom) */
1226
+ .resize-handle--horizontal {
1227
+ left: 14px;
1228
+ right: 14px;
1229
+ height: 8px;
1230
+ cursor: ns-resize;
1231
+ }
1232
+
1233
+ .resize-handle--horizontal::after {
1234
+ content: '';
1235
+ position: absolute;
1236
+ left: 0;
1237
+ right: 0;
1238
+ height: 2px;
1239
+ background: transparent;
1240
+ transition: background-color 0.15s ease;
1241
+ }
1242
+
1243
+ .resize-handle--top {
1244
+ top: 2px;
1245
+ }
1246
+
1247
+ .resize-handle--top::after {
1248
+ top: 0;
1249
+ }
1250
+
1251
+ .resize-handle--bottom {
1252
+ bottom: 2px;
1253
+ }
1254
+
1255
+ .resize-handle--bottom::after {
1256
+ bottom: 0;
1257
+ }
1258
+
1259
+ /* Edge handles - vertical (left/right) */
1260
+ .resize-handle--vertical {
1261
+ top: 14px;
1262
+ bottom: 14px;
1263
+ width: 8px;
1264
+ cursor: ew-resize;
1265
+ }
1266
+
1267
+ .resize-handle--vertical::after {
1268
+ content: '';
1269
+ position: absolute;
1270
+ top: 0;
1271
+ bottom: 0;
1272
+ width: 2px;
1273
+ background: transparent;
1274
+ transition: background-color 0.15s ease;
1275
+ }
1276
+
1277
+ .resize-handle--left {
1278
+ left: 2px;
1279
+ }
1280
+
1281
+ .resize-handle--left::after {
1282
+ left: 0;
1283
+ }
1284
+
1285
+ .resize-handle--right {
1286
+ right: 2px;
1287
+ }
1288
+
1289
+ .resize-handle--right::after {
1290
+ right: 0;
1291
+ }
1292
+
1293
+ /* Hover state for edge handles */
1294
+ .resize-handle--horizontal:hover::after,
1295
+ .resize-handle--vertical:hover::after {
1296
+ background: hsl(var(--border));
1297
+ }
1298
+
1299
+ /* Card edge handles - extend to bottom edge (no bottom corners) */
1300
+ .resize-handle--card-edge {
1301
+ top: 14px;
1302
+ bottom: 2px;
1303
+ }
1304
+
1305
+ /* Corner handles - quarter-circle arcs matching panel border-radius */
1306
+ .resize-handle--corner {
1307
+ width: 20px;
1308
+ height: 20px;
1309
+ background: transparent;
1310
+ }
1311
+
1312
+ .resize-handle--corner::after {
1313
+ content: '';
1314
+ position: absolute;
1315
+ /* Element size slightly larger than radius for good hover target */
1316
+ width: 10px;
1317
+ height: 10px;
1318
+ /* !important needed to override Tailwind preflight reset (border-width: 0) */
1319
+ border: 2px solid transparent !important;
1320
+ transition:
1321
+ border-top-color 0.15s ease,
1322
+ border-right-color 0.15s ease,
1323
+ border-bottom-color 0.15s ease,
1324
+ border-left-color 0.15s ease;
1325
+ }
1326
+
1327
+ /* Corner positions */
1328
+ .resize-handle--top-left {
1329
+ top: 2px;
1330
+ left: 2px;
1331
+ cursor: nwse-resize;
1332
+ }
1333
+
1334
+ .resize-handle--top-left::after {
1335
+ top: 0;
1336
+ left: 0;
1337
+ /* Match panel's 8px border-radius for smooth visual continuity */
1338
+ border-top-left-radius: 8px;
1339
+ }
1340
+
1341
+ .resize-handle--top-left:hover::after {
1342
+ border-top-color: hsl(var(--border)) !important;
1343
+ border-left-color: hsl(var(--border)) !important;
1344
+ }
1345
+
1346
+ .resize-handle--top-right {
1347
+ top: 2px;
1348
+ right: 2px;
1349
+ cursor: nesw-resize;
1350
+ }
1351
+
1352
+ .resize-handle--top-right::after {
1353
+ top: 0;
1354
+ right: 0;
1355
+ /* Match panel's 8px border-radius for smooth visual continuity */
1356
+ border-top-right-radius: 8px;
1357
+ }
1358
+
1359
+ .resize-handle--top-right:hover::after {
1360
+ border-top-color: hsl(var(--border)) !important;
1361
+ border-right-color: hsl(var(--border)) !important;
1362
+ }
1363
+
1364
+ .resize-handle--bottom-left {
1365
+ bottom: 2px;
1366
+ left: 2px;
1367
+ cursor: nesw-resize;
1368
+ }
1369
+
1370
+ .resize-handle--bottom-left::after {
1371
+ bottom: 0;
1372
+ left: 0;
1373
+ /* Match panel's 8px border-radius for smooth visual continuity */
1374
+ border-bottom-left-radius: 8px;
1375
+ }
1376
+
1377
+ .resize-handle--bottom-left:hover::after {
1378
+ border-bottom-color: hsl(var(--border)) !important;
1379
+ border-left-color: hsl(var(--border)) !important;
1380
+ }
1381
+
1382
+ .resize-handle--bottom-right {
1383
+ bottom: 2px;
1384
+ right: 2px;
1385
+ cursor: nwse-resize;
1386
+ }
1387
+
1388
+ .resize-handle--bottom-right::after {
1389
+ bottom: 0;
1390
+ right: 0;
1391
+ /* Match panel's 8px border-radius for smooth visual continuity */
1392
+ border-bottom-right-radius: 8px;
1393
+ }
1394
+
1395
+ .resize-handle--bottom-right:hover::after {
1396
+ border-bottom-color: hsl(var(--border)) !important;
1397
+ border-right-color: hsl(var(--border)) !important;
1398
+ }
1399
+ </style>