@classic-homes/theme-svelte 1.15.1 → 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
+ });
@@ -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);
@@ -268,6 +272,14 @@
268
272
  const edge = $derived(propEdge ?? internalEdge);
269
273
  const isExpanded = $derived(propExpanded ?? internalExpanded);
270
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
+
271
283
  // Whether the panel is currently presented as a modal dialog (detached, or an
272
284
  // expanded mobile card). In that state we trap focus for a11y parity with React.
273
285
  // A desktop card is a non-modal floating sheet with no scrim, so it must not
@@ -599,11 +611,13 @@
599
611
  if (isCardVerticalDragging) {
600
612
  const deltaY = cardDragStartY - currentPos.y; // Positive when dragging up
601
613
 
602
- // 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.
603
616
  const startHeight = cardDragStartExpanded ? cardExpandedHeight : CARD_HEADER_HEIGHT;
617
+ const maxHeight = isMobileCard ? cardExpandedHeight : viewportHeight * 0.95;
604
618
  cardDragCurrentHeight = Math.max(
605
619
  CARD_HEADER_HEIGHT,
606
- Math.min(viewportHeight * 0.95, startHeight + deltaY)
620
+ Math.min(maxHeight, startHeight + deltaY)
607
621
  );
608
622
  }
609
623
 
@@ -676,6 +690,33 @@
676
690
  }
677
691
  }
678
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
+
679
720
  // Resize handlers
680
721
  function handleResizeStart(e: PointerEvent, handle: ResizeHandle) {
681
722
  if (disableResize) return;
@@ -1056,6 +1097,17 @@
1056
1097
  if (isCardWidthResizing) handleCardWidthResizeEnd();
1057
1098
  }
1058
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
+
1059
1111
  // Add/remove global listeners
1060
1112
  $effect(() => {
1061
1113
  if (
@@ -1068,13 +1120,11 @@
1068
1120
  ) {
1069
1121
  window.addEventListener('pointermove', handlePointerMove);
1070
1122
  window.addEventListener('pointerup', handlePointerUp);
1071
- // Treat a canceled pointer (OS gesture, scroll) like pointerup so we don't
1072
- // get stuck mid-interaction with body styles applied.
1073
- window.addEventListener('pointercancel', handlePointerUp);
1123
+ window.addEventListener('pointercancel', handlePointerCancel);
1074
1124
  return () => {
1075
1125
  window.removeEventListener('pointermove', handlePointerMove);
1076
1126
  window.removeEventListener('pointerup', handlePointerUp);
1077
- window.removeEventListener('pointercancel', handlePointerUp);
1127
+ window.removeEventListener('pointercancel', handlePointerCancel);
1078
1128
  };
1079
1129
  }
1080
1130
  });
@@ -1099,6 +1149,25 @@
1099
1149
  }
1100
1150
  });
1101
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
+
1102
1171
  // Calculate panel styles
1103
1172
  const panelStyles = $derived.by(() => {
1104
1173
  if (variant === 'card') {
@@ -1120,6 +1189,23 @@
1120
1189
  height = isExpanded ? cardExpandedHeight : CARD_HEADER_HEIGHT;
1121
1190
  }
1122
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
+
1123
1209
  return {
1124
1210
  bottom: '0',
1125
1211
  left: `${defaultLeftOffset + cardHorizontalPosition}px`,
@@ -1170,7 +1256,13 @@
1170
1256
  });
1171
1257
 
1172
1258
  // Whether to show content (collapsed card only shows header)
1173
- const showContent = $derived(variant !== 'card' || isExpanded || isCardVerticalDragging);
1259
+ const showContent = $derived(
1260
+ variant !== 'card' ||
1261
+ isExpanded ||
1262
+ isCardVerticalDragging ||
1263
+ isCardHeightTransitioning ||
1264
+ isCardContentLingering
1265
+ );
1174
1266
 
1175
1267
  // Rotation for the header collapse chevron (base points up). For the full
1176
1268
  // variant it points toward the edge the panel collapses to — the pinned edge,
@@ -1223,7 +1315,8 @@
1223
1315
  {#if variant === 'card' && isExpanded && isMobile}
1224
1316
  <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
1225
1317
  <div
1226
- 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 }}
1227
1320
  onclick={handleToggleExpand}
1228
1321
  aria-hidden="true"
1229
1322
  ></div>
@@ -1245,7 +1338,7 @@
1245
1338
  !isCardVerticalDragging &&
1246
1339
  !isResizing &&
1247
1340
  !isCardWidthResizing &&
1248
- 'transition-[height,left,width,transform] duration-300 ease-out',
1341
+ 'transition-[height,left,width,transform,translate] duration-300 ease-out',
1249
1342
  animationClass,
1250
1343
  className
1251
1344
  )}
@@ -1274,7 +1367,10 @@
1274
1367
  !disableDrag &&
1275
1368
  (mode === 'detached' || (mode === 'pinned' && detachThreshold > 0)) &&
1276
1369
  'cursor-grab',
1277
- 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'
1278
1374
  )}
1279
1375
  onpointerdown={variant === 'card' ? handleCardHeaderPointerDown : undefined}
1280
1376
  >
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classic-homes/theme-svelte",
3
- "version": "1.15.1",
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",