@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(
|
|
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
|
-
|
|
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',
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
>
|