@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
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
502
|
+
layoutViewportWidth(),
|
|
480
503
|
window.innerHeight
|
|
481
504
|
);
|
|
482
505
|
}
|
|
@@ -517,7 +540,7 @@
|
|
|
517
540
|
pinnedSize,
|
|
518
541
|
detachedSize,
|
|
519
542
|
edge,
|
|
520
|
-
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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',
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
>
|