@classic-homes/theme-svelte 1.15.0 → 1.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,138 @@
1
+ /**
2
+ * DataPanel phone card: gestures and layout.
3
+ *
4
+ * - A cancelled pointer (iOS's swipe up from the home indicator to leave the
5
+ * app) must not expand the card. It used to be handled like pointerup, so a
6
+ * quick cancel read as a tap.
7
+ * - The phone card is laid out at its expanded height and slid with
8
+ * `translate`, padded by the safe-area inset so its collapsed header sits
9
+ * above the home indicator.
10
+ */
11
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
12
+ import { mount, unmount, flushSync, createRawSnippet } from 'svelte';
13
+ import DataPanel from '../lib/components/DataPanel.svelte';
14
+ const children = createRawSnippet(() => ({ render: () => '<p>Legend body</p>' }));
15
+ let instance = null;
16
+ let host;
17
+ function pointer(type, target, y) {
18
+ const event = new MouseEvent(type, { bubbles: true, clientX: 100, clientY: y });
19
+ Object.defineProperty(event, 'pointerId', { value: 1 });
20
+ target.dispatchEvent(event);
21
+ }
22
+ function mountCard(props = {}) {
23
+ host = document.createElement('div');
24
+ document.body.appendChild(host);
25
+ instance = mount(DataPanel, {
26
+ target: host,
27
+ props: {
28
+ title: 'Legend & Information',
29
+ variant: 'card',
30
+ disableClose: true,
31
+ mobileBreakpoint: 768,
32
+ children,
33
+ ...props,
34
+ },
35
+ });
36
+ flushSync();
37
+ return {
38
+ panel: host.querySelector('[role="dialog"]'),
39
+ header: host.querySelector('[data-panel-header]'),
40
+ };
41
+ }
42
+ beforeEach(() => {
43
+ var _a, _b, _c, _d;
44
+ vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(390);
45
+ vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800);
46
+ vi.spyOn(document.documentElement, 'clientWidth', 'get').mockReturnValue(390);
47
+ // jsdom has no pointer capture
48
+ (_a = HTMLElement.prototype).setPointerCapture ?? (_a.setPointerCapture = () => { });
49
+ (_b = HTMLElement.prototype).releasePointerCapture ?? (_b.releasePointerCapture = () => { });
50
+ (_c = HTMLElement.prototype).hasPointerCapture ?? (_c.hasPointerCapture = () => false);
51
+ // ...nor Web Animations, which the scrim's fade transition uses
52
+ (_d = Element.prototype).animate ?? (_d.animate = function () {
53
+ const animation = {
54
+ onfinish: null,
55
+ cancel() { },
56
+ finished: Promise.resolve(),
57
+ };
58
+ queueMicrotask(() => animation.onfinish?.());
59
+ return animation;
60
+ });
61
+ });
62
+ afterEach(() => {
63
+ if (instance)
64
+ unmount(instance);
65
+ instance = null;
66
+ host?.remove();
67
+ vi.useRealTimers();
68
+ vi.restoreAllMocks();
69
+ });
70
+ describe('DataPanel card pointer cancel', () => {
71
+ it('does not expand when the OS cancels the pointer', () => {
72
+ const onExpandedChange = vi.fn();
73
+ const { header } = mountCard({ expanded: false, onExpandedChange });
74
+ pointer('pointerdown', header, 790);
75
+ flushSync();
76
+ pointer('pointercancel', header, 790);
77
+ flushSync();
78
+ expect(onExpandedChange).not.toHaveBeenCalled();
79
+ });
80
+ it('does not expand on a fast upward cancel either', () => {
81
+ const onExpandedChange = vi.fn();
82
+ const { header } = mountCard({ expanded: false, onExpandedChange });
83
+ pointer('pointerdown', header, 790);
84
+ flushSync();
85
+ pointer('pointermove', header, 700);
86
+ flushSync();
87
+ pointer('pointercancel', header, 600);
88
+ flushSync();
89
+ expect(onExpandedChange).not.toHaveBeenCalled();
90
+ });
91
+ it('still expands on a tap', () => {
92
+ const onExpandedChange = vi.fn();
93
+ const { header } = mountCard({ expanded: false, onExpandedChange });
94
+ pointer('pointerdown', header, 790);
95
+ flushSync();
96
+ pointer('pointerup', header, 790);
97
+ flushSync();
98
+ expect(onExpandedChange).toHaveBeenCalledWith(true);
99
+ });
100
+ });
101
+ describe('DataPanel phone card layout', () => {
102
+ it('slides a full-height sheet instead of resizing it, clear of the home indicator', () => {
103
+ const { panel, header } = mountCard({ expanded: false });
104
+ // 0.85 of an 800px viewport
105
+ // jsdom cannot parse env() (it drops padding-bottom and garbles height),
106
+ // so the safe-area padding itself is checked in a browser
107
+ expect(panel.style.height).toContain('680px');
108
+ expect(panel.style.height).toContain('safe-area-inset-bottom');
109
+ // Collapsed: everything but the 72px header is below the fold
110
+ expect(panel.style.getPropertyValue('translate')).toBe('0 608px');
111
+ expect(header.classList.contains('touch-none')).toBe(true);
112
+ });
113
+ it('keeps content mounted while the card animates closed', () => {
114
+ vi.useFakeTimers();
115
+ // Uncontrolled: the chevron drives expanded state
116
+ const { panel } = mountCard();
117
+ const chevron = () => host.querySelector('button[title="Expand"], button[title="Collapse"]');
118
+ if (chevron().title === 'Expand')
119
+ chevron().click();
120
+ flushSync();
121
+ expect(panel.style.getPropertyValue('translate')).toBe('0 0px');
122
+ expect(host.textContent).toContain('Legend body');
123
+ chevron().click();
124
+ flushSync();
125
+ expect(panel.style.getPropertyValue('translate')).toBe('0 608px');
126
+ expect(host.textContent).toContain('Legend body');
127
+ vi.advanceTimersByTime(400);
128
+ flushSync();
129
+ expect(host.textContent).not.toContain('Legend body');
130
+ });
131
+ it('leaves the desktop card sized by height', () => {
132
+ vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1280);
133
+ vi.spyOn(document.documentElement, 'clientWidth', 'get').mockReturnValue(1280);
134
+ const { panel } = mountCard({ expanded: false });
135
+ expect(panel.style.height).toBe('72px');
136
+ expect(panel.style.getPropertyValue('translate')).toBe('');
137
+ });
138
+ });
@@ -0,0 +1,45 @@
1
+ /**
2
+ * DataPanel card sizing uses the layout viewport, not window.innerWidth.
3
+ *
4
+ * innerWidth includes a vertical scrollbar; the layout width does not. With
5
+ * a 15px scrollbar the card used to be 16px from the left edge and 1px from
6
+ * the right, because both its width and its centred offset came from the
7
+ * wider number.
8
+ */
9
+ import { describe, it, expect, vi, afterEach } from 'vitest';
10
+ import { mount, unmount, flushSync, createRawSnippet } from 'svelte';
11
+ import DataPanel from '../lib/components/DataPanel.svelte';
12
+ const children = createRawSnippet(() => ({ render: () => '<p>Legend</p>' }));
13
+ let instance = null;
14
+ afterEach(() => {
15
+ if (instance)
16
+ unmount(instance);
17
+ instance = null;
18
+ vi.restoreAllMocks();
19
+ });
20
+ describe('DataPanel card viewport width', () => {
21
+ it('insets the card equally when a scrollbar makes innerWidth wider than the layout', () => {
22
+ // 390px window with a 15px vertical scrollbar: 375px of layout
23
+ vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(390);
24
+ vi.spyOn(document.documentElement, 'clientWidth', 'get').mockReturnValue(375);
25
+ const host = document.createElement('div');
26
+ document.body.appendChild(host);
27
+ instance = mount(DataPanel, {
28
+ target: host,
29
+ props: {
30
+ title: 'Legend & Information',
31
+ variant: 'card',
32
+ expanded: false,
33
+ disableClose: true,
34
+ mobileBreakpoint: 768,
35
+ cardConfig: { maxWidth: 480, horizontalMargin: 16 },
36
+ children,
37
+ },
38
+ });
39
+ flushSync();
40
+ const panel = host.querySelector('[role="dialog"]');
41
+ expect(panel.style.left).toBe('16px');
42
+ // 375 - 2 * 16, so the right margin is 16px too
43
+ expect(panel.style.width).toBe('343px');
44
+ });
45
+ });
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { cn } from '../utils.js';
3
3
  import { onMount, onDestroy } from 'svelte';
4
+ import { fade } from 'svelte/transition';
4
5
  import type { Snippet } from 'svelte';
5
6
  import type {
6
7
  PanelEdge,
@@ -224,6 +225,9 @@
224
225
  let cardDragStartHorizontalPosition = $state(0);
225
226
  // Flag to indicate we're animating after a drag (keeps using cardDragCurrentHeight for height)
226
227
  let isCardHeightTransitioning = $state(false);
228
+ // Keeps a collapsing card's content mounted until the collapse animation ends,
229
+ // so the sheet does not go blank before it has shrunk.
230
+ let isCardContentLingering = $state(false);
227
231
 
228
232
  // Card width resize state
229
233
  let isCardWidthResizing = $state(false);
@@ -244,8 +248,19 @@
244
248
  // Panel element ref
245
249
  let panelRef = $state<HTMLDivElement | null>(null);
246
250
 
251
+ /**
252
+ * Layout viewport width. Not layoutViewportWidth(): that includes a vertical
253
+ * scrollbar, and the card sheet's width and centred left offset are
254
+ * computed from this number, so with a scrollbar showing (a desktop
255
+ * browser at phone width, device toolbars) the card came out 16px from the
256
+ * left edge and a scrollbar's width short of the right one.
257
+ */
258
+ function layoutViewportWidth(): number {
259
+ return document.documentElement.clientWidth;
260
+ }
261
+
247
262
  // Viewport dimensions
248
- let viewportWidth = $state(typeof window !== 'undefined' ? window.innerWidth : 1024);
263
+ let viewportWidth = $state(typeof document !== 'undefined' ? layoutViewportWidth() : 1024);
249
264
  let viewportHeight = $state(typeof window !== 'undefined' ? window.innerHeight : 768);
250
265
 
251
266
  // Detect mobile
@@ -257,6 +272,14 @@
257
272
  const edge = $derived(propEdge ?? internalEdge);
258
273
  const isExpanded = $derived(propExpanded ?? internalExpanded);
259
274
 
275
+ /**
276
+ * A phone bottom sheet. It is laid out once at its expanded height and slid
277
+ * with `translate`, rather than animating `height` (which re-lays-out the
278
+ * whole panel every frame and stutters on iOS), and it clears the home
279
+ * indicator with `env(safe-area-inset-bottom)`.
280
+ */
281
+ const isMobileCard = $derived(variant === 'card' && isMobile);
282
+
260
283
  // Whether the panel is currently presented as a modal dialog (detached, or an
261
284
  // expanded mobile card). In that state we trap focus for a11y parity with React.
262
285
  // A desktop card is a non-modal floating sheet with no scrim, so it must not
@@ -311,7 +334,7 @@
311
334
  // Initialize from persistence on mount
312
335
  onMount(() => {
313
336
  // Update viewport dimensions
314
- viewportWidth = window.innerWidth;
337
+ viewportWidth = layoutViewportWidth();
315
338
  viewportHeight = window.innerHeight;
316
339
 
317
340
  // Initialize card expanded height
@@ -319,7 +342,7 @@
319
342
 
320
343
  // Listen for resize
321
344
  const handleResize = () => {
322
- viewportWidth = window.innerWidth;
345
+ viewportWidth = layoutViewportWidth();
323
346
  viewportHeight = window.innerHeight;
324
347
  cardExpandedHeight = viewportHeight * resolvedCardConfig.expandedRatio;
325
348
  };
@@ -348,7 +371,7 @@
348
371
  ) {
349
372
  detachedPosition = getInitialDetachedPosition(
350
373
  detachedSize,
351
- window.innerWidth,
374
+ layoutViewportWidth(),
352
375
  window.innerHeight
353
376
  );
354
377
  }
@@ -465,7 +488,7 @@
465
488
  const snap = detectEdgeSnap(
466
489
  newPosition,
467
490
  detachedSize,
468
- window.innerWidth,
491
+ layoutViewportWidth(),
469
492
  window.innerHeight,
470
493
  snapThreshold
471
494
  );
@@ -476,7 +499,7 @@
476
499
  detachedPosition = constrainPosition(
477
500
  newPosition,
478
501
  detachedSize,
479
- window.innerWidth,
502
+ layoutViewportWidth(),
480
503
  window.innerHeight
481
504
  );
482
505
  }
@@ -517,7 +540,7 @@
517
540
  pinnedSize,
518
541
  detachedSize,
519
542
  edge,
520
- window.innerWidth,
543
+ layoutViewportWidth(),
521
544
  window.innerHeight
522
545
  );
523
546
 
@@ -588,11 +611,13 @@
588
611
  if (isCardVerticalDragging) {
589
612
  const deltaY = cardDragStartY - currentPos.y; // Positive when dragging up
590
613
 
591
- // Both mobile and desktop: change height directly during drag
614
+ // Both mobile and desktop: change height directly during drag. A phone
615
+ // sheet is only as tall as its expanded state, so it stops there.
592
616
  const startHeight = cardDragStartExpanded ? cardExpandedHeight : CARD_HEADER_HEIGHT;
617
+ const maxHeight = isMobileCard ? cardExpandedHeight : viewportHeight * 0.95;
593
618
  cardDragCurrentHeight = Math.max(
594
619
  CARD_HEADER_HEIGHT,
595
- Math.min(viewportHeight * 0.95, startHeight + deltaY)
620
+ Math.min(maxHeight, startHeight + deltaY)
596
621
  );
597
622
  }
598
623
 
@@ -665,6 +690,33 @@
665
690
  }
666
691
  }
667
692
 
693
+ /**
694
+ * The OS took the pointer: iOS's swipe up from the home indicator to leave the
695
+ * app, a system scroll, a palm. Nothing was released, so nothing is decided -
696
+ * the card settles back where the gesture started. Treating this like
697
+ * pointerup read a quick cancel as a tap and a fast one as an upward flick,
698
+ * so leaving the app opened the sheet every time.
699
+ */
700
+ function handleCardPointerCancel(e: PointerEvent) {
701
+ document.body.style.userSelect = '';
702
+ const target = e.target as HTMLElement;
703
+ if (target?.hasPointerCapture?.(e.pointerId)) target.releasePointerCapture(e.pointerId);
704
+
705
+ if (isCardVerticalDragging) {
706
+ isCardVerticalDragging = false;
707
+ isCardHeightTransitioning = true;
708
+ const restingHeight = cardDragStartExpanded ? cardExpandedHeight : CARD_HEADER_HEIGHT;
709
+ requestAnimationFrame(() => {
710
+ cardDragCurrentHeight = restingHeight;
711
+ });
712
+ }
713
+
714
+ if (isCardHorizontalDragging) {
715
+ isCardHorizontalDragging = false;
716
+ cardHorizontalPosition = cardDragStartHorizontalPosition;
717
+ }
718
+ }
719
+
668
720
  // Resize handlers
669
721
  function handleResizeStart(e: PointerEvent, handle: ResizeHandle) {
670
722
  if (disableResize) return;
@@ -708,7 +760,7 @@
708
760
  result.position,
709
761
  result.size,
710
762
  resolvedConstraints,
711
- window.innerWidth,
763
+ layoutViewportWidth(),
712
764
  window.innerHeight,
713
765
  resizeHandle
714
766
  );
@@ -725,7 +777,7 @@
725
777
  const newSize = resizeSizeStart.width + delta * direction;
726
778
 
727
779
  if (isHorizontalEdge(edge)) {
728
- pinnedSize = constrainPinnedWidth(newSize, resolvedConstraints, window.innerWidth);
780
+ pinnedSize = constrainPinnedWidth(newSize, resolvedConstraints, layoutViewportWidth());
729
781
  } else {
730
782
  pinnedSize = constrainPinnedHeight(newSize, resolvedConstraints, window.innerHeight);
731
783
  }
@@ -791,7 +843,7 @@
791
843
  candidatePosition,
792
844
  { width, height },
793
845
  resolvedConstraints,
794
- window.innerWidth,
846
+ layoutViewportWidth(),
795
847
  window.innerHeight,
796
848
  handle
797
849
  );
@@ -829,7 +881,7 @@
829
881
  if (handled) {
830
882
  e.preventDefault();
831
883
  pinnedSize = horizontal
832
- ? constrainPinnedWidth(newSize, resolvedConstraints, window.innerWidth)
884
+ ? constrainPinnedWidth(newSize, resolvedConstraints, layoutViewportWidth())
833
885
  : constrainPinnedHeight(newSize, resolvedConstraints, window.innerHeight);
834
886
  debouncedSave();
835
887
  }
@@ -1045,6 +1097,17 @@
1045
1097
  if (isCardWidthResizing) handleCardWidthResizeEnd();
1046
1098
  }
1047
1099
 
1100
+ function handlePointerCancel(e: PointerEvent) {
1101
+ // A card gesture is abandoned rather than completed (see
1102
+ // handleCardPointerCancel); every other interaction ends as on pointerup so
1103
+ // nothing is left stuck with body styles applied.
1104
+ if (isCardVerticalDragging || isCardHorizontalDragging) handleCardPointerCancel(e);
1105
+ if (isDragging) handleDragEnd();
1106
+ if (isPinnedDragging) handlePinnedDragEnd();
1107
+ if (isResizing) handleResizeEnd();
1108
+ if (isCardWidthResizing) handleCardWidthResizeEnd();
1109
+ }
1110
+
1048
1111
  // Add/remove global listeners
1049
1112
  $effect(() => {
1050
1113
  if (
@@ -1057,13 +1120,11 @@
1057
1120
  ) {
1058
1121
  window.addEventListener('pointermove', handlePointerMove);
1059
1122
  window.addEventListener('pointerup', handlePointerUp);
1060
- // Treat a canceled pointer (OS gesture, scroll) like pointerup so we don't
1061
- // get stuck mid-interaction with body styles applied.
1062
- window.addEventListener('pointercancel', handlePointerUp);
1123
+ window.addEventListener('pointercancel', handlePointerCancel);
1063
1124
  return () => {
1064
1125
  window.removeEventListener('pointermove', handlePointerMove);
1065
1126
  window.removeEventListener('pointerup', handlePointerUp);
1066
- window.removeEventListener('pointercancel', handlePointerUp);
1127
+ window.removeEventListener('pointercancel', handlePointerCancel);
1067
1128
  };
1068
1129
  }
1069
1130
  });
@@ -1088,6 +1149,25 @@
1088
1149
  }
1089
1150
  });
1090
1151
 
1152
+ // Keep a card's content mounted while it animates closed (from a tap, the
1153
+ // chevron, the scrim, Escape or a parent flipping `expanded`), matching the
1154
+ // 300ms transition.
1155
+ let wasCardExpanded = false;
1156
+ $effect(() => {
1157
+ const expandedNow = isExpanded;
1158
+ const collapsing = variant === 'card' && wasCardExpanded && !expandedNow;
1159
+ wasCardExpanded = expandedNow;
1160
+ if (!collapsing) return;
1161
+ isCardContentLingering = true;
1162
+ const timeout = setTimeout(() => {
1163
+ isCardContentLingering = false;
1164
+ }, 350);
1165
+ return () => {
1166
+ clearTimeout(timeout);
1167
+ isCardContentLingering = false;
1168
+ };
1169
+ });
1170
+
1091
1171
  // Calculate panel styles
1092
1172
  const panelStyles = $derived.by(() => {
1093
1173
  if (variant === 'card') {
@@ -1109,6 +1189,23 @@
1109
1189
  height = isExpanded ? cardExpandedHeight : CARD_HEADER_HEIGHT;
1110
1190
  }
1111
1191
 
1192
+ if (isMobileCard) {
1193
+ // Full-width phone sheet at its expanded height; `height` above is how
1194
+ // much of it shows. The part below the fold is slid off-screen with
1195
+ // `translate` (composited, and it composes with the mount keyframe's
1196
+ // `transform`). The safe-area inset is added to the sheet and padded
1197
+ // out, so the collapsed header sits above the home indicator instead
1198
+ // of inside the strip iOS claims for its swipe-to-leave gesture.
1199
+ return {
1200
+ bottom: '0',
1201
+ left: `${defaultLeftOffset}px`,
1202
+ width: `${defaultCardWidth}px`,
1203
+ height: `calc(${cardExpandedHeight}px + env(safe-area-inset-bottom, 0px))`,
1204
+ 'padding-bottom': 'env(safe-area-inset-bottom, 0px)',
1205
+ translate: `0 ${Math.max(0, cardExpandedHeight - height)}px`,
1206
+ };
1207
+ }
1208
+
1112
1209
  return {
1113
1210
  bottom: '0',
1114
1211
  left: `${defaultLeftOffset + cardHorizontalPosition}px`,
@@ -1159,7 +1256,13 @@
1159
1256
  });
1160
1257
 
1161
1258
  // Whether to show content (collapsed card only shows header)
1162
- const showContent = $derived(variant !== 'card' || isExpanded || isCardVerticalDragging);
1259
+ const showContent = $derived(
1260
+ variant !== 'card' ||
1261
+ isExpanded ||
1262
+ isCardVerticalDragging ||
1263
+ isCardHeightTransitioning ||
1264
+ isCardContentLingering
1265
+ );
1163
1266
 
1164
1267
  // Rotation for the header collapse chevron (base points up). For the full
1165
1268
  // variant it points toward the edge the panel collapses to — the pinned edge,
@@ -1212,7 +1315,8 @@
1212
1315
  {#if variant === 'card' && isExpanded && isMobile}
1213
1316
  <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
1214
1317
  <div
1215
- class="fixed inset-0 z-40 bg-black/50 transition-opacity"
1318
+ class="fixed inset-0 z-40 bg-black/50"
1319
+ transition:fade={{ duration: 300 }}
1216
1320
  onclick={handleToggleExpand}
1217
1321
  aria-hidden="true"
1218
1322
  ></div>
@@ -1234,7 +1338,7 @@
1234
1338
  !isCardVerticalDragging &&
1235
1339
  !isResizing &&
1236
1340
  !isCardWidthResizing &&
1237
- 'transition-[height,left,width,transform] duration-300 ease-out',
1341
+ 'transition-[height,left,width,transform,translate] duration-300 ease-out',
1238
1342
  animationClass,
1239
1343
  className
1240
1344
  )}
@@ -1263,7 +1367,10 @@
1263
1367
  !disableDrag &&
1264
1368
  (mode === 'detached' || (mode === 'pinned' && detachThreshold > 0)) &&
1265
1369
  'cursor-grab',
1266
- variant === 'card' && 'cursor-pointer'
1370
+ // touch-none: a drag on the header moves the sheet, not the page.
1371
+ // Without it the browser scrolled along, then cancelled the pointer
1372
+ // mid-drag and the sheet snapped.
1373
+ variant === 'card' && 'cursor-pointer touch-none'
1267
1374
  )}
1268
1375
  onpointerdown={variant === 'card' ? handleCardHeaderPointerDown : undefined}
1269
1376
  >
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classic-homes/theme-svelte",
3
- "version": "1.15.0",
3
+ "version": "1.15.2",
4
4
  "description": "Svelte components for the Classic theme system",
5
5
  "type": "module",
6
6
  "svelte": "./dist/lib/index.js",