@classic-homes/theme-svelte 1.0.0 → 1.1.1
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.
- package/dist/lib/components/DataPanel.svelte +383 -114
- package/dist/lib/components/DataPanelHeader.svelte +4 -4
- package/dist/lib/components/DataPanelTab.svelte +212 -44
- package/dist/lib/components/DataPanelTab.svelte.d.ts +10 -1
- package/dist/lib/components/Icon.svelte +4 -0
- package/dist/lib/components/Icon.svelte.d.ts +1 -1
- package/dist/lib/stores/dataPanel.svelte.d.ts +2 -0
- package/dist/lib/stores/dataPanel.svelte.js +14 -0
- package/package.json +1 -1
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
getPinnedResizeHandle,
|
|
30
30
|
constrainSize,
|
|
31
31
|
constrainPosition,
|
|
32
|
+
constrainResize,
|
|
32
33
|
constrainPinnedWidth,
|
|
33
34
|
constrainPinnedHeight,
|
|
34
35
|
detectEdgeSnap,
|
|
@@ -47,6 +48,8 @@
|
|
|
47
48
|
import DataPanelContent from './DataPanelContent.svelte';
|
|
48
49
|
import DataPanelFooter from './DataPanelFooter.svelte';
|
|
49
50
|
import DataPanelTab from './DataPanelTab.svelte';
|
|
51
|
+
import { useFocusTrap } from '../composables/useFocusTrap.svelte.js';
|
|
52
|
+
import { useEscapeKey } from '../composables/useEscapeKey.svelte.js';
|
|
50
53
|
|
|
51
54
|
interface Props {
|
|
52
55
|
/** Panel visibility */
|
|
@@ -150,6 +153,8 @@
|
|
|
150
153
|
let detachedPosition = $state<Position>(DEFAULT_PANEL_STATE.detachedPosition);
|
|
151
154
|
let detachedSize = $state<Size>(DEFAULT_PANEL_STATE.detachedSize);
|
|
152
155
|
let pinnedSize = $state<number>(DEFAULT_PANEL_STATE.pinnedSize);
|
|
156
|
+
// Offset of the collapsed pinned tab along its edge
|
|
157
|
+
let internalTabOffset = $state<number>(DEFAULT_PANEL_STATE.tabOffset);
|
|
153
158
|
|
|
154
159
|
// Card position state (horizontal position for card mode)
|
|
155
160
|
let cardHorizontalPosition = $state<number>(0);
|
|
@@ -211,6 +216,18 @@
|
|
|
211
216
|
const edge = $derived(propEdge ?? internalEdge);
|
|
212
217
|
const isExpanded = $derived(propExpanded ?? internalExpanded);
|
|
213
218
|
|
|
219
|
+
// Whether the panel is currently presented as a modal dialog (detached, or an
|
|
220
|
+
// expanded mobile card). In that state we trap focus for a11y parity with React.
|
|
221
|
+
// A desktop card is a non-modal floating sheet with no scrim, so it must not
|
|
222
|
+
// trap focus — gate the card branch on isMobile.
|
|
223
|
+
const isDialog = $derived(mode === 'detached' || (variant === 'card' && isExpanded && isMobile));
|
|
224
|
+
|
|
225
|
+
// Trap focus inside the panel while it acts as a dialog; return focus on close.
|
|
226
|
+
const { action: focusTrap } = useFocusTrap({ active: () => open && isDialog });
|
|
227
|
+
|
|
228
|
+
// Close on Escape (all modes, matching the previous behavior) unless disabled.
|
|
229
|
+
useEscapeKey(() => handleClose(), { enabled: () => open && !disableClose });
|
|
230
|
+
|
|
214
231
|
// Save timeout for debounced persistence
|
|
215
232
|
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
216
233
|
|
|
@@ -233,6 +250,7 @@
|
|
|
233
250
|
detachedSize,
|
|
234
251
|
pinnedSize,
|
|
235
252
|
cardSnapIndex: 0, // Legacy field
|
|
253
|
+
tabOffset: internalTabOffset,
|
|
236
254
|
});
|
|
237
255
|
saveTimeout = null;
|
|
238
256
|
}, 300);
|
|
@@ -240,6 +258,13 @@
|
|
|
240
258
|
|
|
241
259
|
onDestroy(() => {
|
|
242
260
|
if (saveTimeout) clearTimeout(saveTimeout);
|
|
261
|
+
// If we unmount mid-drag/resize (e.g. Escape closes the panel), the *End
|
|
262
|
+
// handlers never run — reset the body styles they own so the page isn't
|
|
263
|
+
// left stuck at grabbing cursor / no user-select.
|
|
264
|
+
if (typeof document !== 'undefined') {
|
|
265
|
+
document.body.style.cursor = '';
|
|
266
|
+
document.body.style.userSelect = '';
|
|
267
|
+
}
|
|
243
268
|
});
|
|
244
269
|
|
|
245
270
|
// Initialize from persistence on mount
|
|
@@ -270,6 +295,7 @@
|
|
|
270
295
|
if (persisted.detachedPosition) detachedPosition = persisted.detachedPosition;
|
|
271
296
|
if (persisted.detachedSize) detachedSize = persisted.detachedSize;
|
|
272
297
|
if (persisted.pinnedSize) pinnedSize = persisted.pinnedSize;
|
|
298
|
+
if (persisted.tabOffset !== undefined) internalTabOffset = persisted.tabOffset;
|
|
273
299
|
}
|
|
274
300
|
}
|
|
275
301
|
|
|
@@ -287,17 +313,10 @@
|
|
|
287
313
|
}
|
|
288
314
|
}
|
|
289
315
|
|
|
290
|
-
//
|
|
291
|
-
const handleKeyDown = (e: KeyboardEvent) => {
|
|
292
|
-
if (e.key === 'Escape' && !disableClose) {
|
|
293
|
-
handleClose();
|
|
294
|
-
}
|
|
295
|
-
};
|
|
296
|
-
window.addEventListener('keydown', handleKeyDown);
|
|
316
|
+
// Escape-to-close is handled by useEscapeKey; focus trapping by useFocusTrap.
|
|
297
317
|
|
|
298
318
|
return () => {
|
|
299
319
|
window.removeEventListener('resize', handleResize);
|
|
300
|
-
window.removeEventListener('keydown', handleKeyDown);
|
|
301
320
|
};
|
|
302
321
|
});
|
|
303
322
|
|
|
@@ -347,6 +366,18 @@
|
|
|
347
366
|
handleExpandedChange(!isExpanded);
|
|
348
367
|
}
|
|
349
368
|
|
|
369
|
+
// Header collapse button. A detached full panel collapses by re-pinning to its
|
|
370
|
+
// last edge (edge always holds the previous pin, or the default) and showing the
|
|
371
|
+
// collapsed tab. Pinned/card just toggle expanded.
|
|
372
|
+
function handleCollapseToggle() {
|
|
373
|
+
if (variant === 'full' && mode === 'detached') {
|
|
374
|
+
handleModeChange('pinned');
|
|
375
|
+
handleExpandedChange(false);
|
|
376
|
+
} else {
|
|
377
|
+
handleToggleExpand();
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
350
381
|
// Drag handlers for detached mode
|
|
351
382
|
function handleDragStart(e: PointerEvent) {
|
|
352
383
|
if (disableDrag) return;
|
|
@@ -632,13 +663,16 @@
|
|
|
632
663
|
currentPos
|
|
633
664
|
);
|
|
634
665
|
|
|
635
|
-
//
|
|
636
|
-
|
|
637
|
-
|
|
666
|
+
// Route through constrainResize so a left/top/corner resize that clamps to
|
|
667
|
+
// min/max compensates the position and keeps the anchored (right/bottom)
|
|
668
|
+
// edge fixed instead of drifting.
|
|
669
|
+
const { position: constrainedPosition, size: constrainedSize } = constrainResize(
|
|
638
670
|
result.position,
|
|
639
|
-
|
|
671
|
+
result.size,
|
|
672
|
+
resolvedConstraints,
|
|
640
673
|
window.innerWidth,
|
|
641
|
-
window.innerHeight
|
|
674
|
+
window.innerHeight,
|
|
675
|
+
resizeHandle
|
|
642
676
|
);
|
|
643
677
|
|
|
644
678
|
detachedSize = constrainedSize;
|
|
@@ -670,6 +704,130 @@
|
|
|
670
704
|
debouncedSave();
|
|
671
705
|
}
|
|
672
706
|
|
|
707
|
+
// Keyboard resize (arrow keys) — parity with the React ResizeHandles slider.
|
|
708
|
+
const KEYBOARD_RESIZE_STEP = 10; // pixels per key press
|
|
709
|
+
const KEYBOARD_RESIZE_STEP_LARGE = 50; // pixels per shift+key press
|
|
710
|
+
|
|
711
|
+
function handleResizeKeyDown(e: KeyboardEvent, handle: ResizeHandle) {
|
|
712
|
+
if (disableResize) return;
|
|
713
|
+
const step = e.shiftKey ? KEYBOARD_RESIZE_STEP_LARGE : KEYBOARD_RESIZE_STEP;
|
|
714
|
+
|
|
715
|
+
if (mode === 'detached') {
|
|
716
|
+
let width = detachedSize.width;
|
|
717
|
+
let height = detachedSize.height;
|
|
718
|
+
let handled = false;
|
|
719
|
+
|
|
720
|
+
switch (e.key) {
|
|
721
|
+
case 'ArrowUp':
|
|
722
|
+
if (handle.includes('top')) ((height += step), (handled = true));
|
|
723
|
+
else if (handle.includes('bottom')) ((height -= step), (handled = true));
|
|
724
|
+
break;
|
|
725
|
+
case 'ArrowDown':
|
|
726
|
+
if (handle.includes('top')) ((height -= step), (handled = true));
|
|
727
|
+
else if (handle.includes('bottom')) ((height += step), (handled = true));
|
|
728
|
+
break;
|
|
729
|
+
case 'ArrowLeft':
|
|
730
|
+
if (handle.includes('left')) ((width += step), (handled = true));
|
|
731
|
+
else if (handle.includes('right')) ((width -= step), (handled = true));
|
|
732
|
+
break;
|
|
733
|
+
case 'ArrowRight':
|
|
734
|
+
if (handle.includes('left')) ((width -= step), (handled = true));
|
|
735
|
+
else if (handle.includes('right')) ((width += step), (handled = true));
|
|
736
|
+
break;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
if (handled) {
|
|
740
|
+
e.preventDefault();
|
|
741
|
+
// Left/top handles move the panel so the anchored (right/bottom) edge
|
|
742
|
+
// stays put as the size changes; right/bottom handles keep position.
|
|
743
|
+
// constrainResize then clamps size and compensates the position on clamp.
|
|
744
|
+
const candidatePosition = {
|
|
745
|
+
x: handle.includes('left')
|
|
746
|
+
? detachedPosition.x + (detachedSize.width - width)
|
|
747
|
+
: detachedPosition.x,
|
|
748
|
+
y: handle.includes('top')
|
|
749
|
+
? detachedPosition.y + (detachedSize.height - height)
|
|
750
|
+
: detachedPosition.y,
|
|
751
|
+
};
|
|
752
|
+
const { position, size } = constrainResize(
|
|
753
|
+
candidatePosition,
|
|
754
|
+
{ width, height },
|
|
755
|
+
resolvedConstraints,
|
|
756
|
+
window.innerWidth,
|
|
757
|
+
window.innerHeight,
|
|
758
|
+
handle
|
|
759
|
+
);
|
|
760
|
+
detachedSize = size;
|
|
761
|
+
detachedPosition = position;
|
|
762
|
+
debouncedSave();
|
|
763
|
+
}
|
|
764
|
+
} else {
|
|
765
|
+
// Pinned mode - single handle grows/shrinks perpendicular to the edge.
|
|
766
|
+
const horizontal = isHorizontalEdge(edge);
|
|
767
|
+
let newSize = pinnedSize;
|
|
768
|
+
let handled = false;
|
|
769
|
+
|
|
770
|
+
if (horizontal) {
|
|
771
|
+
if (
|
|
772
|
+
(e.key === 'ArrowLeft' && edge === 'right') ||
|
|
773
|
+
(e.key === 'ArrowRight' && edge === 'left')
|
|
774
|
+
)
|
|
775
|
+
((newSize += step), (handled = true));
|
|
776
|
+
else if (
|
|
777
|
+
(e.key === 'ArrowRight' && edge === 'right') ||
|
|
778
|
+
(e.key === 'ArrowLeft' && edge === 'left')
|
|
779
|
+
)
|
|
780
|
+
((newSize -= step), (handled = true));
|
|
781
|
+
} else {
|
|
782
|
+
if ((e.key === 'ArrowUp' && edge === 'bottom') || (e.key === 'ArrowDown' && edge === 'top'))
|
|
783
|
+
((newSize += step), (handled = true));
|
|
784
|
+
else if (
|
|
785
|
+
(e.key === 'ArrowDown' && edge === 'bottom') ||
|
|
786
|
+
(e.key === 'ArrowUp' && edge === 'top')
|
|
787
|
+
)
|
|
788
|
+
((newSize -= step), (handled = true));
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
if (handled) {
|
|
792
|
+
e.preventDefault();
|
|
793
|
+
pinnedSize = horizontal
|
|
794
|
+
? constrainPinnedWidth(newSize, resolvedConstraints, window.innerWidth)
|
|
795
|
+
: constrainPinnedHeight(newSize, resolvedConstraints, window.innerHeight);
|
|
796
|
+
debouncedSave();
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// aria-value* for the resize sliders.
|
|
802
|
+
function resizeAriaNow(handle: ResizeHandle): number {
|
|
803
|
+
const isWidth = handle.includes('left') || handle.includes('right');
|
|
804
|
+
if (mode === 'detached') return isWidth ? detachedSize.width : detachedSize.height;
|
|
805
|
+
return pinnedSize;
|
|
806
|
+
}
|
|
807
|
+
function resizeAriaMin(handle: ResizeHandle): number {
|
|
808
|
+
const isWidth = handle.includes('left') || handle.includes('right');
|
|
809
|
+
return isWidth ? resolvedConstraints.minWidth : resolvedConstraints.minHeight;
|
|
810
|
+
}
|
|
811
|
+
function resizeAriaMax(handle: ResizeHandle): number {
|
|
812
|
+
const isWidth = handle.includes('left') || handle.includes('right');
|
|
813
|
+
return isWidth ? resolvedConstraints.maxWidth : resolvedConstraints.maxHeight;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// The eight detached resize handles, driving the {#each} in the template.
|
|
817
|
+
const detachedResizeHandles: {
|
|
818
|
+
handle: ResizeHandle;
|
|
819
|
+
orientation: 'horizontal' | 'vertical' | null;
|
|
820
|
+
}[] = [
|
|
821
|
+
{ handle: 'top', orientation: 'horizontal' },
|
|
822
|
+
{ handle: 'bottom', orientation: 'horizontal' },
|
|
823
|
+
{ handle: 'left', orientation: 'vertical' },
|
|
824
|
+
{ handle: 'right', orientation: 'vertical' },
|
|
825
|
+
{ handle: 'top-left', orientation: null },
|
|
826
|
+
{ handle: 'top-right', orientation: null },
|
|
827
|
+
{ handle: 'bottom-left', orientation: null },
|
|
828
|
+
{ handle: 'bottom-right', orientation: null },
|
|
829
|
+
];
|
|
830
|
+
|
|
673
831
|
// Card resize (top edge only when expanded)
|
|
674
832
|
function handleCardResizeStart(e: PointerEvent) {
|
|
675
833
|
if (variant !== 'card' || !isExpanded || disableResize) return;
|
|
@@ -780,6 +938,63 @@
|
|
|
780
938
|
debouncedSave();
|
|
781
939
|
}
|
|
782
940
|
|
|
941
|
+
// The current desktop card width (falls back to the computed default).
|
|
942
|
+
const cardDefaultWidth = $derived(
|
|
943
|
+
Math.min(viewportWidth - resolvedCardConfig.horizontalMargin * 2, resolvedCardConfig.maxWidth)
|
|
944
|
+
);
|
|
945
|
+
const cardCurrentWidth = $derived(cardWidth ?? cardDefaultWidth);
|
|
946
|
+
const cardHeightMin = $derived(CARD_HEADER_HEIGHT * 2);
|
|
947
|
+
const cardHeightMax = $derived(Math.round(viewportHeight * 0.95));
|
|
948
|
+
|
|
949
|
+
// Keyboard resize for the desktop card handles (parity with the full-variant
|
|
950
|
+
// sliders). Top edge steps height; left/right edges step width, with the left
|
|
951
|
+
// edge shifting position so the opposite edge stays anchored.
|
|
952
|
+
function handleCardResizeKeyDown(
|
|
953
|
+
e: KeyboardEvent,
|
|
954
|
+
kind: 'height' | 'width-left' | 'width-right'
|
|
955
|
+
) {
|
|
956
|
+
if (disableResize) return;
|
|
957
|
+
const step = e.shiftKey ? KEYBOARD_RESIZE_STEP_LARGE : KEYBOARD_RESIZE_STEP;
|
|
958
|
+
|
|
959
|
+
if (kind === 'height') {
|
|
960
|
+
let delta = 0;
|
|
961
|
+
if (e.key === 'ArrowUp') delta = step;
|
|
962
|
+
else if (e.key === 'ArrowDown') delta = -step;
|
|
963
|
+
else return;
|
|
964
|
+
e.preventDefault();
|
|
965
|
+
cardExpandedHeight = Math.max(
|
|
966
|
+
cardHeightMin,
|
|
967
|
+
Math.min(cardHeightMax, cardExpandedHeight + delta)
|
|
968
|
+
);
|
|
969
|
+
debouncedSave();
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// Width handles.
|
|
974
|
+
let widthDelta = 0;
|
|
975
|
+
if (kind === 'width-right') {
|
|
976
|
+
if (e.key === 'ArrowRight') widthDelta = step;
|
|
977
|
+
else if (e.key === 'ArrowLeft') widthDelta = -step;
|
|
978
|
+
} else {
|
|
979
|
+
// Left edge: ArrowLeft grows leftward, ArrowRight shrinks.
|
|
980
|
+
if (e.key === 'ArrowLeft') widthDelta = step;
|
|
981
|
+
else if (e.key === 'ArrowRight') widthDelta = -step;
|
|
982
|
+
}
|
|
983
|
+
if (widthDelta === 0) return;
|
|
984
|
+
e.preventDefault();
|
|
985
|
+
|
|
986
|
+
const maxWidth = Math.min(
|
|
987
|
+
resolvedCardConfig.maxWidth,
|
|
988
|
+
viewportWidth - resolvedCardConfig.horizontalMargin * 2
|
|
989
|
+
);
|
|
990
|
+
const nextWidth = Math.max(CARD_MIN_WIDTH, Math.min(maxWidth, cardCurrentWidth + widthDelta));
|
|
991
|
+
const appliedDelta = nextWidth - cardCurrentWidth;
|
|
992
|
+
cardWidth = nextWidth;
|
|
993
|
+
// Left edge keeps its right edge fixed: growing width moves the panel left.
|
|
994
|
+
if (kind === 'width-left') cardHorizontalPosition -= appliedDelta;
|
|
995
|
+
debouncedSave();
|
|
996
|
+
}
|
|
997
|
+
|
|
783
998
|
// Global event listeners
|
|
784
999
|
function handlePointerMove(e: PointerEvent) {
|
|
785
1000
|
if (isDragging) handleDragMove(e);
|
|
@@ -810,9 +1025,13 @@
|
|
|
810
1025
|
) {
|
|
811
1026
|
window.addEventListener('pointermove', handlePointerMove);
|
|
812
1027
|
window.addEventListener('pointerup', handlePointerUp);
|
|
1028
|
+
// Treat a canceled pointer (OS gesture, scroll) like pointerup so we don't
|
|
1029
|
+
// get stuck mid-interaction with body styles applied.
|
|
1030
|
+
window.addEventListener('pointercancel', handlePointerUp);
|
|
813
1031
|
return () => {
|
|
814
1032
|
window.removeEventListener('pointermove', handlePointerMove);
|
|
815
1033
|
window.removeEventListener('pointerup', handlePointerUp);
|
|
1034
|
+
window.removeEventListener('pointercancel', handlePointerUp);
|
|
816
1035
|
};
|
|
817
1036
|
}
|
|
818
1037
|
});
|
|
@@ -838,7 +1057,7 @@
|
|
|
838
1057
|
});
|
|
839
1058
|
|
|
840
1059
|
// Calculate panel styles
|
|
841
|
-
const panelStyles = $derived(() => {
|
|
1060
|
+
const panelStyles = $derived.by(() => {
|
|
842
1061
|
if (variant === 'card') {
|
|
843
1062
|
// Card mode - bottom sheet style
|
|
844
1063
|
const defaultCardWidth = Math.min(
|
|
@@ -895,7 +1114,7 @@
|
|
|
895
1114
|
);
|
|
896
1115
|
|
|
897
1116
|
// Animation classes
|
|
898
|
-
const animationClass = $derived(() => {
|
|
1117
|
+
const animationClass = $derived.by(() => {
|
|
899
1118
|
if (variant === 'card') {
|
|
900
1119
|
return 'animate-panel-slide-in-bottom';
|
|
901
1120
|
}
|
|
@@ -912,12 +1131,36 @@
|
|
|
912
1131
|
|
|
913
1132
|
// Whether to show content (collapsed card only shows header)
|
|
914
1133
|
const showContent = $derived(variant !== 'card' || isExpanded || isCardVerticalDragging);
|
|
1134
|
+
|
|
1135
|
+
// Rotation for the header collapse chevron (base points up). For the full
|
|
1136
|
+
// variant it points toward the edge the panel collapses to — the pinned edge,
|
|
1137
|
+
// or, when detached, the edge it will re-pin to. The card toggles up (expand) /
|
|
1138
|
+
// down (collapse).
|
|
1139
|
+
const collapseChevronRotation = $derived<string>(
|
|
1140
|
+
variant === 'full'
|
|
1141
|
+
? { top: '', bottom: 'rotate-180', left: '-rotate-90', right: 'rotate-90' }[edge]
|
|
1142
|
+
: isExpanded
|
|
1143
|
+
? 'rotate-180'
|
|
1144
|
+
: ''
|
|
1145
|
+
);
|
|
915
1146
|
</script>
|
|
916
1147
|
|
|
917
1148
|
{#if open}
|
|
918
1149
|
{#if variant === 'full' && !isExpanded && mode === 'pinned'}
|
|
919
1150
|
<!-- Collapsed tab (full variant only) -->
|
|
920
|
-
<DataPanelTab
|
|
1151
|
+
<DataPanelTab
|
|
1152
|
+
{title}
|
|
1153
|
+
{subtitle}
|
|
1154
|
+
{edge}
|
|
1155
|
+
{disableClose}
|
|
1156
|
+
onclick={handleTabClick}
|
|
1157
|
+
onClose={handleClose}
|
|
1158
|
+
offset={internalTabOffset}
|
|
1159
|
+
onOffsetChange={(o) => {
|
|
1160
|
+
internalTabOffset = o;
|
|
1161
|
+
debouncedSave();
|
|
1162
|
+
}}
|
|
1163
|
+
/>
|
|
921
1164
|
{:else}
|
|
922
1165
|
<!-- Snap preview indicator (detached mode only) -->
|
|
923
1166
|
{#if snapPreview}
|
|
@@ -932,21 +1175,21 @@
|
|
|
932
1175
|
></div>
|
|
933
1176
|
{/if}
|
|
934
1177
|
|
|
935
|
-
<!--
|
|
1178
|
+
<!-- Presentational scrim for the expanded mobile card. Keyboard users dismiss
|
|
1179
|
+
via Escape (useEscapeKey); the backdrop is click-to-dismiss only. -->
|
|
936
1180
|
{#if variant === 'card' && isExpanded && isMobile}
|
|
1181
|
+
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
|
937
1182
|
<div
|
|
938
1183
|
class="fixed inset-0 z-40 bg-black/50 transition-opacity"
|
|
939
1184
|
onclick={handleToggleExpand}
|
|
940
|
-
|
|
941
|
-
role="button"
|
|
942
|
-
tabindex="-1"
|
|
943
|
-
aria-label="Close panel"
|
|
1185
|
+
aria-hidden="true"
|
|
944
1186
|
></div>
|
|
945
1187
|
{/if}
|
|
946
1188
|
|
|
947
1189
|
<!-- Panel -->
|
|
948
1190
|
<div
|
|
949
1191
|
bind:this={panelRef}
|
|
1192
|
+
use:focusTrap
|
|
950
1193
|
class={cn(
|
|
951
1194
|
'fixed z-50 flex flex-col bg-background border border-border shadow-lg overflow-hidden',
|
|
952
1195
|
variant === 'full' && mode === 'pinned' && pinnedClasses,
|
|
@@ -960,14 +1203,14 @@
|
|
|
960
1203
|
!isResizing &&
|
|
961
1204
|
!isCardWidthResizing &&
|
|
962
1205
|
'transition-[height,left,width,transform] duration-300 ease-out',
|
|
963
|
-
animationClass
|
|
1206
|
+
animationClass,
|
|
964
1207
|
className
|
|
965
1208
|
)}
|
|
966
|
-
style={Object.entries(panelStyles
|
|
1209
|
+
style={Object.entries(panelStyles)
|
|
967
1210
|
.map(([k, v]) => `${k}:${v}`)
|
|
968
1211
|
.join(';')}
|
|
969
1212
|
role={mode === 'detached' || variant === 'card' ? 'dialog' : 'complementary'}
|
|
970
|
-
aria-modal={
|
|
1213
|
+
aria-modal={isDialog ? 'true' : undefined}
|
|
971
1214
|
aria-labelledby={title ? 'data-panel-title' : undefined}
|
|
972
1215
|
aria-expanded={variant === 'card' ? isExpanded : undefined}
|
|
973
1216
|
tabindex="-1"
|
|
@@ -1052,18 +1295,13 @@
|
|
|
1052
1295
|
title="Drag to move"
|
|
1053
1296
|
onpointerdown={handleCardHeaderPointerDown}
|
|
1054
1297
|
>
|
|
1055
|
-
<svg
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
<
|
|
1062
|
-
stroke-linecap="round"
|
|
1063
|
-
stroke-linejoin="round"
|
|
1064
|
-
stroke-width="2"
|
|
1065
|
-
d="M4 8h16M4 16h16"
|
|
1066
|
-
/>
|
|
1298
|
+
<svg class="w-4 h-4 text-muted-foreground" viewBox="0 0 24 24" fill="currentColor">
|
|
1299
|
+
<circle cx="9" cy="6" r="1.5" />
|
|
1300
|
+
<circle cx="15" cy="6" r="1.5" />
|
|
1301
|
+
<circle cx="9" cy="12" r="1.5" />
|
|
1302
|
+
<circle cx="15" cy="12" r="1.5" />
|
|
1303
|
+
<circle cx="9" cy="18" r="1.5" />
|
|
1304
|
+
<circle cx="15" cy="18" r="1.5" />
|
|
1067
1305
|
</svg>
|
|
1068
1306
|
</button>
|
|
1069
1307
|
{/if}
|
|
@@ -1073,30 +1311,27 @@
|
|
|
1073
1311
|
<button
|
|
1074
1312
|
type="button"
|
|
1075
1313
|
class="p-1.5 rounded hover:bg-muted"
|
|
1076
|
-
onclick={
|
|
1314
|
+
onclick={handleCollapseToggle}
|
|
1077
1315
|
aria-expanded={isExpanded}
|
|
1078
1316
|
aria-label={isExpanded ? 'Collapse panel' : 'Expand panel'}
|
|
1079
1317
|
title={isExpanded ? 'Collapse' : 'Expand'}
|
|
1080
1318
|
>
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
/>
|
|
1098
|
-
</svg>
|
|
1099
|
-
{/if}
|
|
1319
|
+
<!-- Base chevron points up; rotate it so that when pinned it points
|
|
1320
|
+
toward the pinned edge (the collapse direction), and for the card
|
|
1321
|
+
it toggles the usual up (expand) / down (collapse). -->
|
|
1322
|
+
<svg
|
|
1323
|
+
class={cn('w-4 h-4 transition-transform', collapseChevronRotation)}
|
|
1324
|
+
fill="none"
|
|
1325
|
+
viewBox="0 0 24 24"
|
|
1326
|
+
stroke="currentColor"
|
|
1327
|
+
>
|
|
1328
|
+
<path
|
|
1329
|
+
stroke-linecap="round"
|
|
1330
|
+
stroke-linejoin="round"
|
|
1331
|
+
stroke-width="2"
|
|
1332
|
+
d="M5 15l7-7 7 7"
|
|
1333
|
+
/>
|
|
1334
|
+
</svg>
|
|
1100
1335
|
</button>
|
|
1101
1336
|
{/if}
|
|
1102
1337
|
|
|
@@ -1148,52 +1383,30 @@
|
|
|
1148
1383
|
<!-- Resize handles (full variant only) -->
|
|
1149
1384
|
{#if variant === 'full' && !disableResize}
|
|
1150
1385
|
{#if mode === 'detached'}
|
|
1151
|
-
<!-- Edge handles -->
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
></div>
|
|
1176
|
-
<!-- Corner handles (6px radius = 8px panel radius minus 2px offset) -->
|
|
1177
|
-
<div
|
|
1178
|
-
class="resize-handle resize-handle--corner resize-handle--top-left"
|
|
1179
|
-
role="separator"
|
|
1180
|
-
onpointerdown={(e) => handleResizeStart(e, 'top-left')}
|
|
1181
|
-
></div>
|
|
1182
|
-
<div
|
|
1183
|
-
class="resize-handle resize-handle--corner resize-handle--top-right"
|
|
1184
|
-
role="separator"
|
|
1185
|
-
onpointerdown={(e) => handleResizeStart(e, 'top-right')}
|
|
1186
|
-
></div>
|
|
1187
|
-
<div
|
|
1188
|
-
class="resize-handle resize-handle--corner resize-handle--bottom-left"
|
|
1189
|
-
role="separator"
|
|
1190
|
-
onpointerdown={(e) => handleResizeStart(e, 'bottom-left')}
|
|
1191
|
-
></div>
|
|
1192
|
-
<div
|
|
1193
|
-
class="resize-handle resize-handle--corner resize-handle--bottom-right"
|
|
1194
|
-
role="separator"
|
|
1195
|
-
onpointerdown={(e) => handleResizeStart(e, 'bottom-right')}
|
|
1196
|
-
></div>
|
|
1386
|
+
<!-- Edge + corner handles: keyboard-resizable sliders (arrow keys, shift for large steps) -->
|
|
1387
|
+
{#each detachedResizeHandles as { handle, orientation } (handle)}
|
|
1388
|
+
<div
|
|
1389
|
+
class={cn(
|
|
1390
|
+
'resize-handle',
|
|
1391
|
+
orientation === 'horizontal' && 'resize-handle--horizontal',
|
|
1392
|
+
orientation === 'vertical' && 'resize-handle--vertical',
|
|
1393
|
+
orientation === null && 'resize-handle--corner',
|
|
1394
|
+
`resize-handle--${handle}`
|
|
1395
|
+
)}
|
|
1396
|
+
role="slider"
|
|
1397
|
+
tabindex="0"
|
|
1398
|
+
aria-orientation={orientation === 'vertical' ? 'vertical' : 'horizontal'}
|
|
1399
|
+
aria-label={`Resize panel from ${handle.replace('-', ' ')} ${
|
|
1400
|
+
handle.includes('-') ? 'corner' : 'edge'
|
|
1401
|
+
}`}
|
|
1402
|
+
aria-valuenow={resizeAriaNow(handle)}
|
|
1403
|
+
aria-valuemin={resizeAriaMin(handle)}
|
|
1404
|
+
aria-valuemax={resizeAriaMax(handle)}
|
|
1405
|
+
aria-valuetext={`${resizeAriaNow(handle)} pixels`}
|
|
1406
|
+
onpointerdown={(e) => handleResizeStart(e, handle)}
|
|
1407
|
+
onkeydown={(e) => handleResizeKeyDown(e, handle)}
|
|
1408
|
+
></div>
|
|
1409
|
+
{/each}
|
|
1197
1410
|
{:else}
|
|
1198
1411
|
<!-- Single resize handle for pinned mode -->
|
|
1199
1412
|
{@const pinnedHandle = getPinnedResizeHandle(edge)}
|
|
@@ -1205,9 +1418,16 @@
|
|
|
1205
1418
|
edge === 'top' && 'resize-handle--horizontal resize-handle--bottom',
|
|
1206
1419
|
edge === 'bottom' && 'resize-handle--horizontal resize-handle--top'
|
|
1207
1420
|
)}
|
|
1208
|
-
role="
|
|
1209
|
-
|
|
1421
|
+
role="slider"
|
|
1422
|
+
tabindex="0"
|
|
1423
|
+
aria-orientation={isHorizontalEdge(edge) ? 'horizontal' : 'vertical'}
|
|
1424
|
+
aria-label={`Resize panel from ${edge} edge`}
|
|
1425
|
+
aria-valuenow={pinnedSize}
|
|
1426
|
+
aria-valuemin={resizeAriaMin(pinnedHandle)}
|
|
1427
|
+
aria-valuemax={resizeAriaMax(pinnedHandle)}
|
|
1428
|
+
aria-valuetext={`${pinnedSize} pixels`}
|
|
1210
1429
|
onpointerdown={(e) => handleResizeStart(e, pinnedHandle)}
|
|
1430
|
+
onkeydown={(e) => handleResizeKeyDown(e, pinnedHandle)}
|
|
1211
1431
|
></div>
|
|
1212
1432
|
{/if}
|
|
1213
1433
|
{/if}
|
|
@@ -1217,23 +1437,44 @@
|
|
|
1217
1437
|
<!-- Top edge for height resize -->
|
|
1218
1438
|
<div
|
|
1219
1439
|
class="resize-handle resize-handle--horizontal resize-handle--top"
|
|
1220
|
-
role="
|
|
1221
|
-
|
|
1440
|
+
role="slider"
|
|
1441
|
+
tabindex="0"
|
|
1442
|
+
aria-orientation="vertical"
|
|
1443
|
+
aria-label="Resize card height"
|
|
1444
|
+
aria-valuenow={Math.round(cardExpandedHeight)}
|
|
1445
|
+
aria-valuemin={cardHeightMin}
|
|
1446
|
+
aria-valuemax={cardHeightMax}
|
|
1447
|
+
aria-valuetext={`${Math.round(cardExpandedHeight)} pixels`}
|
|
1222
1448
|
onpointerdown={handleCardResizeStart}
|
|
1449
|
+
onkeydown={(e) => handleCardResizeKeyDown(e, 'height')}
|
|
1223
1450
|
></div>
|
|
1224
1451
|
<!-- Left edge for width resize -->
|
|
1225
1452
|
<div
|
|
1226
1453
|
class="resize-handle resize-handle--vertical resize-handle--left resize-handle--card-edge"
|
|
1227
|
-
role="
|
|
1228
|
-
|
|
1454
|
+
role="slider"
|
|
1455
|
+
tabindex="0"
|
|
1456
|
+
aria-orientation="horizontal"
|
|
1457
|
+
aria-label="Resize card width from left edge"
|
|
1458
|
+
aria-valuenow={Math.round(cardCurrentWidth)}
|
|
1459
|
+
aria-valuemin={CARD_MIN_WIDTH}
|
|
1460
|
+
aria-valuemax={resolvedCardConfig.maxWidth}
|
|
1461
|
+
aria-valuetext={`${Math.round(cardCurrentWidth)} pixels`}
|
|
1229
1462
|
onpointerdown={(e) => handleCardWidthResizeStart(e, 'left')}
|
|
1463
|
+
onkeydown={(e) => handleCardResizeKeyDown(e, 'width-left')}
|
|
1230
1464
|
></div>
|
|
1231
1465
|
<!-- Right edge for width resize -->
|
|
1232
1466
|
<div
|
|
1233
1467
|
class="resize-handle resize-handle--vertical resize-handle--right resize-handle--card-edge"
|
|
1234
|
-
role="
|
|
1235
|
-
|
|
1468
|
+
role="slider"
|
|
1469
|
+
tabindex="0"
|
|
1470
|
+
aria-orientation="horizontal"
|
|
1471
|
+
aria-label="Resize card width from right edge"
|
|
1472
|
+
aria-valuenow={Math.round(cardCurrentWidth)}
|
|
1473
|
+
aria-valuemin={CARD_MIN_WIDTH}
|
|
1474
|
+
aria-valuemax={resolvedCardConfig.maxWidth}
|
|
1475
|
+
aria-valuetext={`${Math.round(cardCurrentWidth)} pixels`}
|
|
1236
1476
|
onpointerdown={(e) => handleCardWidthResizeStart(e, 'right')}
|
|
1477
|
+
onkeydown={(e) => handleCardResizeKeyDown(e, 'width-right')}
|
|
1237
1478
|
></div>
|
|
1238
1479
|
{/if}
|
|
1239
1480
|
</div>
|
|
@@ -1249,7 +1490,8 @@
|
|
|
1249
1490
|
background: transparent;
|
|
1250
1491
|
}
|
|
1251
1492
|
|
|
1252
|
-
/* Edge handles -
|
|
1493
|
+
/* Edge handles - invisible hit strip inset from the corners, with a 2px line
|
|
1494
|
+
inset 2px from the panel edge so it aligns with the corner arcs' gap. */
|
|
1253
1495
|
.resize-handle--horizontal {
|
|
1254
1496
|
left: 14px;
|
|
1255
1497
|
right: 14px;
|
|
@@ -1317,10 +1559,16 @@
|
|
|
1317
1559
|
right: 0;
|
|
1318
1560
|
}
|
|
1319
1561
|
|
|
1320
|
-
/* Hover state for edge handles */
|
|
1562
|
+
/* Hover / keyboard-focus state for edge handles */
|
|
1321
1563
|
.resize-handle--horizontal:hover::after,
|
|
1322
|
-
.resize-handle--vertical:hover::after
|
|
1323
|
-
|
|
1564
|
+
.resize-handle--vertical:hover::after,
|
|
1565
|
+
.resize-handle--horizontal:focus-visible::after,
|
|
1566
|
+
.resize-handle--vertical:focus-visible::after {
|
|
1567
|
+
background: hsl(var(--ring));
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
.resize-handle:focus-visible {
|
|
1571
|
+
outline: none;
|
|
1324
1572
|
}
|
|
1325
1573
|
|
|
1326
1574
|
/* Card edge handles - extend to bottom edge (no bottom corners) */
|
|
@@ -1423,4 +1671,25 @@
|
|
|
1423
1671
|
border-bottom-color: hsl(var(--border)) !important;
|
|
1424
1672
|
border-right-color: hsl(var(--border)) !important;
|
|
1425
1673
|
}
|
|
1674
|
+
|
|
1675
|
+
/* Keyboard-focus state for corner handles */
|
|
1676
|
+
.resize-handle--top-left:focus-visible::after {
|
|
1677
|
+
border-top-color: hsl(var(--ring)) !important;
|
|
1678
|
+
border-left-color: hsl(var(--ring)) !important;
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
.resize-handle--top-right:focus-visible::after {
|
|
1682
|
+
border-top-color: hsl(var(--ring)) !important;
|
|
1683
|
+
border-right-color: hsl(var(--ring)) !important;
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
.resize-handle--bottom-left:focus-visible::after {
|
|
1687
|
+
border-bottom-color: hsl(var(--ring)) !important;
|
|
1688
|
+
border-left-color: hsl(var(--ring)) !important;
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
.resize-handle--bottom-right:focus-visible::after {
|
|
1692
|
+
border-bottom-color: hsl(var(--ring)) !important;
|
|
1693
|
+
border-right-color: hsl(var(--ring)) !important;
|
|
1694
|
+
}
|
|
1426
1695
|
</style>
|
|
@@ -89,7 +89,7 @@
|
|
|
89
89
|
{#snippet trigger()}
|
|
90
90
|
<button
|
|
91
91
|
type="button"
|
|
92
|
-
class="flex h-8 w-8 items-center justify-center rounded-md bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
92
|
+
class="relative flex h-8 w-8 items-center justify-center rounded-md before:absolute before:-inset-1.5 before:content-[''] bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
93
93
|
aria-label="Panel options"
|
|
94
94
|
>
|
|
95
95
|
<svg
|
|
@@ -141,7 +141,7 @@
|
|
|
141
141
|
<!-- Collapse/Expand button -->
|
|
142
142
|
<button
|
|
143
143
|
type="button"
|
|
144
|
-
class="flex h-8 w-8 items-center justify-center rounded-md bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
144
|
+
class="relative flex h-8 w-8 items-center justify-center rounded-md before:absolute before:-inset-1.5 before:content-[''] bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
145
145
|
onclick={() => onExpandedChange?.(!isExpanded)}
|
|
146
146
|
aria-label={isExpanded ? 'Collapse panel' : 'Expand panel'}
|
|
147
147
|
aria-expanded={isExpanded}
|
|
@@ -168,7 +168,7 @@
|
|
|
168
168
|
{#snippet trigger()}
|
|
169
169
|
<button
|
|
170
170
|
type="button"
|
|
171
|
-
class="flex h-8 w-8 items-center justify-center rounded-md bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
171
|
+
class="relative flex h-8 w-8 items-center justify-center rounded-md before:absolute before:-inset-1.5 before:content-[''] bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
172
172
|
aria-label="Panel options"
|
|
173
173
|
>
|
|
174
174
|
<svg
|
|
@@ -195,7 +195,7 @@
|
|
|
195
195
|
{#if !disableClose}
|
|
196
196
|
<button
|
|
197
197
|
type="button"
|
|
198
|
-
class="flex h-8 w-8 items-center justify-center rounded-md bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
198
|
+
class="relative flex h-8 w-8 items-center justify-center rounded-md before:absolute before:-inset-1.5 before:content-[''] bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
199
199
|
onclick={() => onClose?.()}
|
|
200
200
|
aria-label="Close panel"
|
|
201
201
|
>
|
|
@@ -1,72 +1,240 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { cn } from '../utils.js';
|
|
3
3
|
import type { PanelEdge } from '@classic-homes/data-panel-core';
|
|
4
|
+
import { constrainTabOffset } from '@classic-homes/data-panel-core';
|
|
4
5
|
|
|
5
6
|
interface Props {
|
|
6
7
|
/** Tab label/title */
|
|
7
8
|
title?: string;
|
|
9
|
+
/** Secondary line under the title */
|
|
10
|
+
subtitle?: string;
|
|
8
11
|
/** Current edge position */
|
|
9
12
|
edge: PanelEdge;
|
|
10
|
-
/**
|
|
13
|
+
/** Expand the panel (tap/click/keyboard on the chip body) */
|
|
11
14
|
onclick?: () => void;
|
|
15
|
+
/** Close the panel (chip close button) */
|
|
16
|
+
onClose?: () => void;
|
|
17
|
+
disableClose?: boolean;
|
|
18
|
+
/** Offset (px) of the tab along its edge from the centered position */
|
|
19
|
+
offset?: number;
|
|
20
|
+
/** Called with the new offset after the tab is dragged along its edge */
|
|
21
|
+
onOffsetChange?: (offset: number) => void;
|
|
12
22
|
/** Additional class */
|
|
13
23
|
class?: string;
|
|
14
24
|
}
|
|
15
25
|
|
|
16
|
-
let {
|
|
26
|
+
let {
|
|
27
|
+
title = 'Panel',
|
|
28
|
+
subtitle,
|
|
29
|
+
edge,
|
|
30
|
+
onclick,
|
|
31
|
+
onClose,
|
|
32
|
+
disableClose = false,
|
|
33
|
+
offset = 0,
|
|
34
|
+
onOffsetChange,
|
|
35
|
+
class: className,
|
|
36
|
+
}: Props = $props();
|
|
37
|
+
|
|
38
|
+
const isVertical = $derived(edge === 'left' || edge === 'right');
|
|
17
39
|
|
|
18
|
-
// Position classes based on edge
|
|
19
40
|
const positionClasses = $derived<string>(
|
|
20
41
|
{
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
42
|
+
// Side tabs are the whole chip rotated 90°, so they need a fixed thickness
|
|
43
|
+
// (h-14 → 56px) for a deterministic edge hug; see tabTransform below. Border
|
|
44
|
+
// classes are in the chip's pre-rotation frame: its bottom rotates to the
|
|
45
|
+
// attached (edge) side and its top corners to the viewport-facing corners,
|
|
46
|
+
// so both edges drop the bottom border and round the top.
|
|
47
|
+
left: 'left-0 top-1/2 h-14 rounded-t-lg border-b-0',
|
|
48
|
+
right: 'right-0 top-1/2 h-14 rounded-t-lg border-b-0',
|
|
49
|
+
top: 'top-0 left-1/2 rounded-b-lg border-t-0',
|
|
50
|
+
bottom: 'bottom-0 left-1/2 rounded-t-lg border-b-0',
|
|
25
51
|
}[edge]
|
|
26
52
|
);
|
|
27
53
|
|
|
28
|
-
|
|
29
|
-
|
|
54
|
+
const draggable = $derived(!!onOffsetChange);
|
|
55
|
+
|
|
56
|
+
let btn = $state<HTMLDivElement | null>(null);
|
|
57
|
+
let dragOffset = $state<number | null>(null);
|
|
58
|
+
const effectiveOffset = $derived(dragOffset ?? offset);
|
|
59
|
+
|
|
60
|
+
// Half the side-tab thickness (h-14 → 56px). Left/right tabs are the whole chip
|
|
61
|
+
// rotated 90°; we center the rotated footprint on the edge, then shift it back
|
|
62
|
+
// by half its thickness so the outer long edge sits flush.
|
|
63
|
+
const SIDE_HALF = 28;
|
|
64
|
+
const tabTransform = $derived<string>(
|
|
65
|
+
edge === 'left'
|
|
66
|
+
? `translate(calc(-50% + ${SIDE_HALF}px), calc(-50% + ${effectiveOffset}px)) rotate(90deg)`
|
|
67
|
+
: edge === 'right'
|
|
68
|
+
? `translate(calc(50% - ${SIDE_HALF}px), calc(-50% + ${effectiveOffset}px)) rotate(-90deg)`
|
|
69
|
+
: `translateX(calc(-50% + ${effectiveOffset}px))`
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
// Non-reactive drag bookkeeping.
|
|
73
|
+
let drag = { startX: 0, startY: 0, startOffset: 0, moved: false, pointerId: -1 };
|
|
74
|
+
|
|
75
|
+
function handlePointerDown(e: PointerEvent) {
|
|
76
|
+
if (!onOffsetChange) return;
|
|
77
|
+
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
|
78
|
+
// Let action buttons (close) handle their own press without starting a drag.
|
|
79
|
+
if ((e.target as HTMLElement).closest('[data-no-drag]')) return;
|
|
80
|
+
drag = {
|
|
81
|
+
startX: e.clientX,
|
|
82
|
+
startY: e.clientY,
|
|
83
|
+
startOffset: offset,
|
|
84
|
+
moved: false,
|
|
85
|
+
pointerId: e.pointerId,
|
|
86
|
+
};
|
|
87
|
+
dragOffset = offset;
|
|
88
|
+
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function handlePointerMove(e: PointerEvent) {
|
|
92
|
+
const el = e.currentTarget as HTMLElement;
|
|
93
|
+
if (drag.pointerId !== e.pointerId || !el.hasPointerCapture(e.pointerId)) return;
|
|
94
|
+
const delta = isVertical ? e.clientY - drag.startY : e.clientX - drag.startX;
|
|
95
|
+
if (Math.abs(delta) > 3) drag.moved = true;
|
|
96
|
+
const rect = btn?.getBoundingClientRect();
|
|
97
|
+
const tabSize = { width: rect?.width ?? 160, height: rect?.height ?? 48 };
|
|
98
|
+
dragOffset = constrainTabOffset(
|
|
99
|
+
drag.startOffset + delta,
|
|
100
|
+
edge,
|
|
101
|
+
tabSize,
|
|
102
|
+
window.innerWidth,
|
|
103
|
+
window.innerHeight
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function handlePointerUp(e: PointerEvent) {
|
|
108
|
+
const el = e.currentTarget as HTMLElement;
|
|
109
|
+
if (drag.pointerId !== e.pointerId) return;
|
|
110
|
+
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);
|
|
111
|
+
drag.pointerId = -1;
|
|
112
|
+
if (drag.moved) {
|
|
113
|
+
// A drag repositions.
|
|
114
|
+
onOffsetChange?.(dragOffset ?? offset);
|
|
115
|
+
} else {
|
|
116
|
+
// A tap anywhere on the chip expands. Handled here (not on the inner
|
|
117
|
+
// button's click) because the container holds pointer capture during a
|
|
118
|
+
// press, which would otherwise swallow the button click.
|
|
119
|
+
onclick?.();
|
|
120
|
+
}
|
|
121
|
+
dragOffset = null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// A canceled pointer (OS gesture, scroll) must not count as a tap-to-expand;
|
|
125
|
+
// just discard the in-flight drag and revert to the persisted offset.
|
|
126
|
+
function handlePointerCancel(e: PointerEvent) {
|
|
127
|
+
if (drag.pointerId !== e.pointerId) return;
|
|
128
|
+
const el = e.currentTarget as HTMLElement;
|
|
129
|
+
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);
|
|
130
|
+
drag.pointerId = -1;
|
|
131
|
+
drag.moved = false;
|
|
132
|
+
dragOffset = null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Keyboard activation of the expand button (pointer taps expand via the
|
|
136
|
+
// container's pointerup above).
|
|
137
|
+
function handleExpandKeyDown(e: KeyboardEvent) {
|
|
138
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
139
|
+
e.preventDefault();
|
|
140
|
+
onclick?.();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function handleClose(e: MouseEvent) {
|
|
145
|
+
e.stopPropagation();
|
|
146
|
+
onClose?.();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function clampOffset() {
|
|
150
|
+
if (!onOffsetChange) return;
|
|
151
|
+
const rect = btn?.getBoundingClientRect();
|
|
152
|
+
if (!rect) return;
|
|
153
|
+
const clamped = constrainTabOffset(
|
|
154
|
+
offset,
|
|
155
|
+
edge,
|
|
156
|
+
{ width: rect.width, height: rect.height },
|
|
157
|
+
window.innerWidth,
|
|
158
|
+
window.innerHeight
|
|
159
|
+
);
|
|
160
|
+
if (clamped !== offset) onOffsetChange(clamped);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Re-clamp the persisted offset to the current tab geometry. The tab's
|
|
164
|
+
// along-edge extent depends on its rendered size (and, for side edges, on the
|
|
165
|
+
// 90° rotation), so an offset saved under a different size/edge/viewport can
|
|
166
|
+
// land the tab off-screen. Runs on mount and whenever edge/offset/size change.
|
|
167
|
+
$effect(() => {
|
|
168
|
+
// Track the reactive inputs so this re-runs when they change.
|
|
169
|
+
void edge;
|
|
170
|
+
void offset;
|
|
171
|
+
void btn;
|
|
172
|
+
if (dragOffset !== null) return;
|
|
173
|
+
clampOffset();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
$effect(() => {
|
|
177
|
+
const onResize = () => {
|
|
178
|
+
if (dragOffset === null) clampOffset();
|
|
179
|
+
};
|
|
180
|
+
window.addEventListener('resize', onResize);
|
|
181
|
+
return () => window.removeEventListener('resize', onResize);
|
|
182
|
+
});
|
|
30
183
|
</script>
|
|
31
184
|
|
|
32
|
-
|
|
33
|
-
|
|
185
|
+
<!-- Presentational drag container: holds the expand + close buttons as siblings
|
|
186
|
+
so neither interactive control is nested inside another (valid a11y). The
|
|
187
|
+
container carries the position/transform and the pointer-drag handlers. -->
|
|
188
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
189
|
+
<div
|
|
190
|
+
bind:this={btn}
|
|
34
191
|
class={cn(
|
|
35
|
-
'fixed z-40 flex
|
|
192
|
+
'fixed z-40 flex max-w-[220px] items-center gap-2 border border-border bg-background px-2.5 py-1.5 shadow-md focus-within:ring-2 focus-within:ring-ring',
|
|
193
|
+
draggable && 'cursor-grab touch-none active:cursor-grabbing',
|
|
36
194
|
positionClasses,
|
|
37
|
-
isVertical && 'writing-mode-vertical',
|
|
38
195
|
className
|
|
39
196
|
)}
|
|
40
|
-
{
|
|
41
|
-
|
|
197
|
+
style={`transform: ${tabTransform}`}
|
|
198
|
+
onpointerdown={handlePointerDown}
|
|
199
|
+
onpointermove={draggable ? handlePointerMove : undefined}
|
|
200
|
+
onpointerup={draggable ? handlePointerUp : undefined}
|
|
201
|
+
onpointercancel={draggable ? handlePointerCancel : undefined}
|
|
42
202
|
>
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
fill="none"
|
|
50
|
-
stroke="currentColor"
|
|
51
|
-
stroke-width="2"
|
|
52
|
-
stroke-linecap="round"
|
|
53
|
-
stroke-linejoin="round"
|
|
54
|
-
class={cn(
|
|
55
|
-
'shrink-0',
|
|
56
|
-
edge === 'left' && 'rotate-90',
|
|
57
|
-
edge === 'right' && '-rotate-90',
|
|
58
|
-
edge === 'top' && 'rotate-180',
|
|
59
|
-
edge === 'bottom' && ''
|
|
60
|
-
)}
|
|
203
|
+
<button
|
|
204
|
+
type="button"
|
|
205
|
+
aria-label={`Expand ${title} panel`}
|
|
206
|
+
class="flex min-w-0 flex-1 flex-col text-left leading-tight hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
207
|
+
onclick={draggable ? undefined : onclick}
|
|
208
|
+
onkeydown={handleExpandKeyDown}
|
|
61
209
|
>
|
|
62
|
-
<
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
210
|
+
<span class="truncate text-sm font-semibold">{title}</span>
|
|
211
|
+
{#if subtitle}
|
|
212
|
+
<span class="truncate text-xs text-muted-foreground">{subtitle}</span>
|
|
213
|
+
{/if}
|
|
214
|
+
</button>
|
|
215
|
+
|
|
216
|
+
{#if !disableClose && onClose}
|
|
217
|
+
<button
|
|
218
|
+
data-no-drag
|
|
219
|
+
type="button"
|
|
220
|
+
class="relative ml-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground before:absolute before:-inset-2.5 before:content-[''] hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
221
|
+
aria-label="Close panel"
|
|
222
|
+
onclick={handleClose}
|
|
223
|
+
>
|
|
224
|
+
<svg
|
|
225
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
226
|
+
width="14"
|
|
227
|
+
height="14"
|
|
228
|
+
viewBox="0 0 24 24"
|
|
229
|
+
fill="none"
|
|
230
|
+
stroke="currentColor"
|
|
231
|
+
stroke-width="2"
|
|
232
|
+
stroke-linecap="round"
|
|
233
|
+
stroke-linejoin="round"
|
|
234
|
+
>
|
|
235
|
+
<path d="M18 6 6 18" />
|
|
236
|
+
<path d="m6 6 12 12" />
|
|
237
|
+
</svg>
|
|
238
|
+
</button>
|
|
239
|
+
{/if}
|
|
240
|
+
</div>
|
|
@@ -2,10 +2,19 @@ import type { PanelEdge } from '@classic-homes/data-panel-core';
|
|
|
2
2
|
interface Props {
|
|
3
3
|
/** Tab label/title */
|
|
4
4
|
title?: string;
|
|
5
|
+
/** Secondary line under the title */
|
|
6
|
+
subtitle?: string;
|
|
5
7
|
/** Current edge position */
|
|
6
8
|
edge: PanelEdge;
|
|
7
|
-
/**
|
|
9
|
+
/** Expand the panel (tap/click/keyboard on the chip body) */
|
|
8
10
|
onclick?: () => void;
|
|
11
|
+
/** Close the panel (chip close button) */
|
|
12
|
+
onClose?: () => void;
|
|
13
|
+
disableClose?: boolean;
|
|
14
|
+
/** Offset (px) of the tab along its edge from the centered position */
|
|
15
|
+
offset?: number;
|
|
16
|
+
/** Called with the new offset after the tab is dragged along its edge */
|
|
17
|
+
onOffsetChange?: (offset: number) => void;
|
|
9
18
|
/** Additional class */
|
|
10
19
|
class?: string;
|
|
11
20
|
}
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
| 'archive'
|
|
40
40
|
| 'power'
|
|
41
41
|
| 'zap'
|
|
42
|
+
| 'sparkles'
|
|
42
43
|
| 'pin'
|
|
43
44
|
// Navigation
|
|
44
45
|
| 'chevron-down'
|
|
@@ -496,6 +497,9 @@
|
|
|
496
497
|
archive: { path: 'M21 8v13H3V8M1 3h22v5H1zM10 12h4' },
|
|
497
498
|
power: { path: 'M18.36 6.64a9 9 0 1 1-12.73 0M12 2v10' },
|
|
498
499
|
zap: { path: 'M13 2L3 14h9l-1 8 10-12h-9l1-8z' },
|
|
500
|
+
sparkles: {
|
|
501
|
+
path: 'M12 3l-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3zM5 3v4M19 17v4M3 5h4M17 19h4',
|
|
502
|
+
},
|
|
499
503
|
pin: {
|
|
500
504
|
path: 'M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48',
|
|
501
505
|
},
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Available icon names for the Icon component.
|
|
3
3
|
* Organized by category for easier discovery.
|
|
4
4
|
*/
|
|
5
|
-
export type IconName = 'close' | 'check' | 'plus' | 'minus' | 'edit' | 'trash' | 'info' | 'alert-circle' | 'alert-triangle' | 'search' | 'settings' | 'home' | 'loading' | 'send' | 'more' | 'more-horizontal' | 'grip' | 'history' | 'copy' | 'download' | 'upload' | 'refresh' | 'save' | 'print' | 'share' | 'cut' | 'paste' | 'undo' | 'redo' | 'archive' | 'power' | 'zap' | 'pin' | 'chevron-down' | 'chevron-up' | 'chevron-left' | 'chevron-right' | 'chevrons-down' | 'chevrons-up' | 'chevrons-left' | 'chevrons-right' | 'arrow-left' | 'arrow-right' | 'arrow-up' | 'arrow-down' | 'corner-up-left' | 'corner-up-right' | 'corner-down-left' | 'corner-down-right' | 'menu' | 'external-link' | 'expand' | 'collapse' | 'fullscreen' | 'minimize' | 'user' | 'users' | 'user-plus' | 'user-minus' | 'user-check' | 'user-x' | 'shield' | 'shield-check' | 'shield-off' | 'badge' | 'id-card' | 'message' | 'chat' | 'mail' | 'phone' | 'bell' | 'bell-off' | 'inbox' | 'at-sign' | 'hash' | 'rss' | 'send' | 'message-circle' | 'message-square' | 'file' | 'file-text' | 'file-code' | 'file-plus' | 'file-minus' | 'folder' | 'folder-open' | 'folder-plus' | 'folder-minus' | 'clipboard' | 'clipboard-check' | 'clipboard-list' | 'book' | 'book-open' | 'newspaper' | 'link' | 'image' | 'play' | 'pause' | 'stop' | 'skip-forward' | 'skip-back' | 'rewind' | 'fast-forward' | 'volume' | 'volume-1' | 'volume-2' | 'volume-x' | 'video' | 'video-off' | 'mic' | 'mic-off' | 'music' | 'camera' | 'camera-off' | 'shopping-cart' | 'shopping-bag' | 'credit-card' | 'dollar-sign' | 'percent' | 'tag' | 'tags' | 'package' | 'truck' | 'receipt' | 'wallet' | 'briefcase' | 'eye' | 'eye-off' | 'lock' | 'unlock' | 'key' | 'check-circle' | 'x-circle' | 'help-circle' | 'alert-octagon' | 'circle' | 'square' | 'triangle' | 'star' | 'star-half' | 'heart' | 'bookmark' | 'flag' | 'thumbs-up' | 'thumbs-down' | 'award' | 'trophy' | 'gift' | 'grid' | 'list' | 'columns' | 'sidebar' | 'layers' | 'layout' | 'move' | 'maximize' | 'filter' | 'sort' | 'sort-asc' | 'sort-desc' | 'bar-chart' | 'bar-chart-2' | 'line-chart' | 'pie-chart' | 'trending-up' | 'trending-down' | 'activity' | 'target' | 'calendar' | 'calendar-plus' | 'calendar-minus' | 'calendar-check' | 'clock' | 'timer' | 'hourglass' | 'alarm' | 'sun' | 'moon' | 'cloud' | 'cloud-rain' | 'cloud-snow' | 'wind' | 'thermometer' | 'droplet' | 'umbrella' | 'globe' | 'map' | 'map-pin' | 'navigation' | 'compass' | 'crosshair' | 'smartphone' | 'tablet' | 'laptop' | 'monitor' | 'tv' | 'printer' | 'wifi' | 'wifi-off' | 'bluetooth' | 'battery' | 'battery-charging' | 'cpu' | 'hard-drive' | 'server' | 'database' | 'cloud-upload' | 'cloud-download' | 'terminal' | 'code' | 'code-2' | 'bug' | 'git-branch' | 'git-commit' | 'git-merge' | 'git-pull-request' | 'box' | 'package' | 'lightbulb' | 'tool' | 'wrench' | 'scissors' | 'paperclip' | 'anchor' | 'aperture' | 'loader' | 'rotate-cw' | 'rotate-ccw' | 'log-in' | 'log-out' | 'slash' | 'percent' | 'hash';
|
|
5
|
+
export type IconName = 'close' | 'check' | 'plus' | 'minus' | 'edit' | 'trash' | 'info' | 'alert-circle' | 'alert-triangle' | 'search' | 'settings' | 'home' | 'loading' | 'send' | 'more' | 'more-horizontal' | 'grip' | 'history' | 'copy' | 'download' | 'upload' | 'refresh' | 'save' | 'print' | 'share' | 'cut' | 'paste' | 'undo' | 'redo' | 'archive' | 'power' | 'zap' | 'sparkles' | 'pin' | 'chevron-down' | 'chevron-up' | 'chevron-left' | 'chevron-right' | 'chevrons-down' | 'chevrons-up' | 'chevrons-left' | 'chevrons-right' | 'arrow-left' | 'arrow-right' | 'arrow-up' | 'arrow-down' | 'corner-up-left' | 'corner-up-right' | 'corner-down-left' | 'corner-down-right' | 'menu' | 'external-link' | 'expand' | 'collapse' | 'fullscreen' | 'minimize' | 'user' | 'users' | 'user-plus' | 'user-minus' | 'user-check' | 'user-x' | 'shield' | 'shield-check' | 'shield-off' | 'badge' | 'id-card' | 'message' | 'chat' | 'mail' | 'phone' | 'bell' | 'bell-off' | 'inbox' | 'at-sign' | 'hash' | 'rss' | 'send' | 'message-circle' | 'message-square' | 'file' | 'file-text' | 'file-code' | 'file-plus' | 'file-minus' | 'folder' | 'folder-open' | 'folder-plus' | 'folder-minus' | 'clipboard' | 'clipboard-check' | 'clipboard-list' | 'book' | 'book-open' | 'newspaper' | 'link' | 'image' | 'play' | 'pause' | 'stop' | 'skip-forward' | 'skip-back' | 'rewind' | 'fast-forward' | 'volume' | 'volume-1' | 'volume-2' | 'volume-x' | 'video' | 'video-off' | 'mic' | 'mic-off' | 'music' | 'camera' | 'camera-off' | 'shopping-cart' | 'shopping-bag' | 'credit-card' | 'dollar-sign' | 'percent' | 'tag' | 'tags' | 'package' | 'truck' | 'receipt' | 'wallet' | 'briefcase' | 'eye' | 'eye-off' | 'lock' | 'unlock' | 'key' | 'check-circle' | 'x-circle' | 'help-circle' | 'alert-octagon' | 'circle' | 'square' | 'triangle' | 'star' | 'star-half' | 'heart' | 'bookmark' | 'flag' | 'thumbs-up' | 'thumbs-down' | 'award' | 'trophy' | 'gift' | 'grid' | 'list' | 'columns' | 'sidebar' | 'layers' | 'layout' | 'move' | 'maximize' | 'filter' | 'sort' | 'sort-asc' | 'sort-desc' | 'bar-chart' | 'bar-chart-2' | 'line-chart' | 'pie-chart' | 'trending-up' | 'trending-down' | 'activity' | 'target' | 'calendar' | 'calendar-plus' | 'calendar-minus' | 'calendar-check' | 'clock' | 'timer' | 'hourglass' | 'alarm' | 'sun' | 'moon' | 'cloud' | 'cloud-rain' | 'cloud-snow' | 'wind' | 'thermometer' | 'droplet' | 'umbrella' | 'globe' | 'map' | 'map-pin' | 'navigation' | 'compass' | 'crosshair' | 'smartphone' | 'tablet' | 'laptop' | 'monitor' | 'tv' | 'printer' | 'wifi' | 'wifi-off' | 'bluetooth' | 'battery' | 'battery-charging' | 'cpu' | 'hard-drive' | 'server' | 'database' | 'cloud-upload' | 'cloud-download' | 'terminal' | 'code' | 'code-2' | 'bug' | 'git-branch' | 'git-commit' | 'git-merge' | 'git-pull-request' | 'box' | 'package' | 'lightbulb' | 'tool' | 'wrench' | 'scissors' | 'paperclip' | 'anchor' | 'aperture' | 'loader' | 'rotate-cw' | 'rotate-ccw' | 'log-in' | 'log-out' | 'slash' | 'percent' | 'hash';
|
|
6
6
|
interface Props {
|
|
7
7
|
/** The name of the icon to display */
|
|
8
8
|
name: IconName;
|
|
@@ -22,6 +22,7 @@ export declare function createDataPanelStore(options?: DataPanelStoreOptions): {
|
|
|
22
22
|
readonly detachedSize: Size;
|
|
23
23
|
readonly pinnedSize: number;
|
|
24
24
|
readonly cardSnapIndex: number;
|
|
25
|
+
readonly tabOffset: number;
|
|
25
26
|
setMode: (newMode: PanelMode) => void;
|
|
26
27
|
setVariant: (newVariant: PanelVariant) => void;
|
|
27
28
|
setEdge: (newEdge: PanelEdge) => void;
|
|
@@ -31,6 +32,7 @@ export declare function createDataPanelStore(options?: DataPanelStoreOptions): {
|
|
|
31
32
|
setDetachedSize: (size: Size) => void;
|
|
32
33
|
setPinnedSize: (size: number) => void;
|
|
33
34
|
setCardSnapIndex: (index: number) => void;
|
|
35
|
+
setTabOffset: (offset: number) => void;
|
|
34
36
|
reset: () => void;
|
|
35
37
|
getState: () => DataPanelState;
|
|
36
38
|
};
|
|
@@ -22,6 +22,7 @@ export function createDataPanelStore(options = {}) {
|
|
|
22
22
|
let detachedSize = $state(merged.detachedSize);
|
|
23
23
|
let pinnedSize = $state(merged.pinnedSize);
|
|
24
24
|
let cardSnapIndex = $state(merged.cardSnapIndex);
|
|
25
|
+
let tabOffset = $state(merged.tabOffset);
|
|
25
26
|
// Resolved constraints
|
|
26
27
|
const resolvedConstraints = { ...DEFAULT_CONSTRAINTS, ...constraints };
|
|
27
28
|
// Debounced save for persistence
|
|
@@ -42,6 +43,7 @@ export function createDataPanelStore(options = {}) {
|
|
|
42
43
|
detachedSize,
|
|
43
44
|
pinnedSize,
|
|
44
45
|
cardSnapIndex,
|
|
46
|
+
tabOffset,
|
|
45
47
|
});
|
|
46
48
|
saveTimeout = null;
|
|
47
49
|
}, 300);
|
|
@@ -116,6 +118,12 @@ export function createDataPanelStore(options = {}) {
|
|
|
116
118
|
cardSnapIndex = index;
|
|
117
119
|
debouncedSave();
|
|
118
120
|
}
|
|
121
|
+
function setTabOffset(offset) {
|
|
122
|
+
if (tabOffset === offset)
|
|
123
|
+
return;
|
|
124
|
+
tabOffset = offset;
|
|
125
|
+
debouncedSave();
|
|
126
|
+
}
|
|
119
127
|
function reset() {
|
|
120
128
|
mode = DEFAULT_PANEL_STATE.mode;
|
|
121
129
|
variant = DEFAULT_PANEL_STATE.variant;
|
|
@@ -125,6 +133,7 @@ export function createDataPanelStore(options = {}) {
|
|
|
125
133
|
detachedSize = DEFAULT_PANEL_STATE.detachedSize;
|
|
126
134
|
pinnedSize = DEFAULT_PANEL_STATE.pinnedSize;
|
|
127
135
|
cardSnapIndex = DEFAULT_PANEL_STATE.cardSnapIndex;
|
|
136
|
+
tabOffset = DEFAULT_PANEL_STATE.tabOffset;
|
|
128
137
|
debouncedSave();
|
|
129
138
|
}
|
|
130
139
|
// Getters for current state (readonly)
|
|
@@ -138,6 +147,7 @@ export function createDataPanelStore(options = {}) {
|
|
|
138
147
|
detachedSize,
|
|
139
148
|
pinnedSize,
|
|
140
149
|
cardSnapIndex,
|
|
150
|
+
tabOffset,
|
|
141
151
|
};
|
|
142
152
|
}
|
|
143
153
|
return {
|
|
@@ -166,6 +176,9 @@ export function createDataPanelStore(options = {}) {
|
|
|
166
176
|
get cardSnapIndex() {
|
|
167
177
|
return cardSnapIndex;
|
|
168
178
|
},
|
|
179
|
+
get tabOffset() {
|
|
180
|
+
return tabOffset;
|
|
181
|
+
},
|
|
169
182
|
// Actions
|
|
170
183
|
setMode,
|
|
171
184
|
setVariant,
|
|
@@ -176,6 +189,7 @@ export function createDataPanelStore(options = {}) {
|
|
|
176
189
|
setDetachedSize,
|
|
177
190
|
setPinnedSize,
|
|
178
191
|
setCardSnapIndex,
|
|
192
|
+
setTabOffset,
|
|
179
193
|
reset,
|
|
180
194
|
getState,
|
|
181
195
|
};
|