@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.
@@ -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
- // Handle escape key to close
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
- // Constrain size and position
636
- const constrainedSize = constrainSize(result.size, resolvedConstraints);
637
- const constrainedPosition = constrainPosition(
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
- constrainedSize,
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 {title} {edge} onclick={handleTabClick} />
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
- <!-- Overlay for expanded card on mobile -->
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
- onkeydown={(e) => e.key === 'Enter' && handleToggleExpand()}
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={mode === 'detached' || (variant === 'card' && isExpanded) ? 'true' : undefined}
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
- class="w-4 h-4 text-muted-foreground"
1057
- fill="none"
1058
- viewBox="0 0 24 24"
1059
- stroke="currentColor"
1060
- >
1061
- <path
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={handleToggleExpand}
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
- {#if isExpanded}
1082
- <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1083
- <path
1084
- stroke-linecap="round"
1085
- stroke-linejoin="round"
1086
- stroke-width="2"
1087
- d="M19 9l-7 7-7-7"
1088
- />
1089
- </svg>
1090
- {:else}
1091
- <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1092
- <path
1093
- stroke-linecap="round"
1094
- stroke-linejoin="round"
1095
- stroke-width="2"
1096
- d="M5 15l7-7 7 7"
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
- <div
1153
- class="resize-handle resize-handle--horizontal resize-handle--top"
1154
- role="separator"
1155
- aria-orientation="horizontal"
1156
- onpointerdown={(e) => handleResizeStart(e, 'top')}
1157
- ></div>
1158
- <div
1159
- class="resize-handle resize-handle--horizontal resize-handle--bottom"
1160
- role="separator"
1161
- aria-orientation="horizontal"
1162
- onpointerdown={(e) => handleResizeStart(e, 'bottom')}
1163
- ></div>
1164
- <div
1165
- class="resize-handle resize-handle--vertical resize-handle--left"
1166
- role="separator"
1167
- aria-orientation="vertical"
1168
- onpointerdown={(e) => handleResizeStart(e, 'left')}
1169
- ></div>
1170
- <div
1171
- class="resize-handle resize-handle--vertical resize-handle--right"
1172
- role="separator"
1173
- aria-orientation="vertical"
1174
- onpointerdown={(e) => handleResizeStart(e, 'right')}
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="separator"
1209
- aria-orientation={isHorizontalEdge(edge) ? 'vertical' : 'horizontal'}
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="separator"
1221
- aria-orientation="horizontal"
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="separator"
1228
- aria-orientation="vertical"
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="separator"
1235
- aria-orientation="vertical"
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 - horizontal (top/bottom) */
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
- background: hsl(var(--border));
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
- /** Click handler to expand panel */
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 { title = 'Panel', edge, onclick, class: className }: Props = $props();
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
- left: 'left-0 top-1/2 -translate-y-1/2 rounded-r-md border-l-0',
22
- right: 'right-0 top-1/2 -translate-y-1/2 rounded-l-md border-r-0',
23
- top: 'top-0 left-1/2 -translate-x-1/2 rounded-b-md border-t-0',
24
- bottom: 'bottom-0 left-1/2 -translate-x-1/2 rounded-t-md border-b-0',
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
- // Text direction for vertical edges
29
- const isVertical = $derived(edge === 'left' || edge === 'right');
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
- <button
33
- type="button"
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 items-center justify-center gap-2 border border-border bg-background px-3 py-2 text-sm font-medium shadow-md hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
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
- {onclick}
41
- aria-label={`Expand ${title} panel`}
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
- <!-- Expand icon -->
44
- <svg
45
- xmlns="http://www.w3.org/2000/svg"
46
- width="14"
47
- height="14"
48
- viewBox="0 0 24 24"
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
- <path d="m18 15-6-6-6 6" />
63
- </svg>
64
- <span class={cn(isVertical && 'rotate-180')}>{title}</span>
65
- </button>
66
-
67
- <style>
68
- .writing-mode-vertical {
69
- writing-mode: vertical-rl;
70
- text-orientation: mixed;
71
- }
72
- </style>
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
- /** Click handler to expand panel */
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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classic-homes/theme-svelte",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "description": "Svelte components for the Classic theme system",
5
5
  "type": "module",
6
6
  "svelte": "./dist/lib/index.js",