@classic-homes/theme-svelte 0.1.56 → 1.0.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>