@expofp/ui 3.30.0 → 3.31.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.
- package/dist/components/Autocomplete/Autocomplete.d.ts +8 -1
- package/dist/components/Autocomplete/Autocomplete.js +84 -21
- package/dist/components/Overlay/Overlay.js +108 -144
- package/dist/shared/hooks/useKeyboardInset.d.ts +9 -0
- package/dist/shared/hooks/useKeyboardInset.js +84 -0
- package/dist/shared/hooks/useSheetDrag.d.ts +27 -0
- package/dist/shared/hooks/useSheetDrag.js +227 -0
- package/package.json +1 -1
- package/dist/shared/hooks/useDragGesture.d.ts +0 -19
- package/dist/shared/hooks/useDragGesture.js +0 -45
|
@@ -11,11 +11,18 @@ export interface AutocompleteProps<T extends AutocompleteOptionObject = Autocomp
|
|
|
11
11
|
showClear?: boolean;
|
|
12
12
|
onChange?: (value: T | undefined) => void;
|
|
13
13
|
renderTarget?: HTMLElement | null;
|
|
14
|
+
/**
|
|
15
|
+
* Anchor the option list to the input with CSS instead of portalling it and
|
|
16
|
+
* measuring where it should go. The bottom sheet wants this: while the
|
|
17
|
+
* on-screen keyboard pans the viewport, measured coordinates are stale by the
|
|
18
|
+
* time they are written (#1567).
|
|
19
|
+
*/
|
|
20
|
+
inline?: boolean;
|
|
14
21
|
noOptionsLabel: string;
|
|
15
22
|
ariaLabel: string;
|
|
16
23
|
clearLabel: string;
|
|
17
24
|
getOptionKey?: (option: T) => string;
|
|
18
25
|
}
|
|
19
|
-
declare const Autocomplete: <T extends AutocompleteOptionObject = AutocompleteOptionObject>({ placeholder, options, value, showClear, onChange, renderTarget, noOptionsLabel, ariaLabel, clearLabel, getOptionKey, }: AutocompleteProps<T>) => React.JSX.Element;
|
|
26
|
+
declare const Autocomplete: <T extends AutocompleteOptionObject = AutocompleteOptionObject>({ placeholder, options, value, showClear, onChange, renderTarget, inline, noOptionsLabel, ariaLabel, clearLabel, getOptionKey, }: AutocompleteProps<T>) => React.JSX.Element;
|
|
20
27
|
export default Autocomplete;
|
|
21
28
|
//# sourceMappingURL=Autocomplete.d.ts.map
|
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import './Autocomplete.scss';
|
|
3
3
|
import classNames from 'classnames';
|
|
4
|
-
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
|
4
|
+
import { useContext, useEffect, useId, useMemo, useRef, useState } from 'react';
|
|
5
5
|
import { createPortal } from 'react-dom';
|
|
6
|
+
import { ResizeObserver } from 'resize-observer';
|
|
6
7
|
import useOnClickOutside from '../../shared/hooks/useOnClickOutside';
|
|
7
|
-
|
|
8
|
+
import { OverlayScrollContext } from '../Overlay/OverlayScrollContext';
|
|
9
|
+
const DROPDOWN_GAP = 4;
|
|
10
|
+
/** Below this the list is useless, so it is allowed to overflow the visible area. */
|
|
11
|
+
const DROPDOWN_MIN_HEIGHT = 96;
|
|
12
|
+
/** Share of the visible viewport the list may take (the SCSS counterpart of a `vh` cap). */
|
|
13
|
+
const DROPDOWN_HEIGHT_RATIO = 0.45;
|
|
14
|
+
const Autocomplete = ({ placeholder, options, value, showClear = false, onChange, renderTarget = document.body, inline = false, noOptionsLabel, ariaLabel, clearLabel, getOptionKey = (option) => option.value, }) => {
|
|
8
15
|
const container = renderTarget;
|
|
16
|
+
// The scrollable body of the sheet the inline list must stay inside of.
|
|
17
|
+
const scrollContainer = useContext(OverlayScrollContext);
|
|
9
18
|
const refAutocomplete = useRef(null);
|
|
10
19
|
const refSearchInput = useRef(null);
|
|
11
20
|
const refOptions = useRef(null);
|
|
@@ -19,7 +28,8 @@ const Autocomplete = ({ placeholder, options, value, showClear = false, onChange
|
|
|
19
28
|
const [searchText, setSearchText] = useState(null);
|
|
20
29
|
const [focusOptionIndex, setFocusOptionIndex] = useState(null);
|
|
21
30
|
const [showOptionsDropdown, setShowOptionsDropdown] = useState(false);
|
|
22
|
-
const [
|
|
31
|
+
const [inlineMaxHeight, setInlineMaxHeight] = useState(null);
|
|
32
|
+
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0, maxHeight: 0 });
|
|
23
33
|
const activeOption = value ? findOptionByValue(value) : undefined;
|
|
24
34
|
const activeOptionIndex = activeOption ? findOptionIndexByValue(activeOption) : null;
|
|
25
35
|
const displayValue = searchText ?? (activeOption ? getOptionLabel(activeOption) : '');
|
|
@@ -30,26 +40,74 @@ const Autocomplete = ({ placeholder, options, value, showClear = false, onChange
|
|
|
30
40
|
return options.filter((option) => getOptionLabel(option).toLowerCase().includes(lower));
|
|
31
41
|
}, [searchText, options]);
|
|
32
42
|
const updateDropdownPosition = () => {
|
|
33
|
-
if (refAutocomplete.current
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
43
|
+
if (inline || !refAutocomplete.current || !showOptionsDropdown)
|
|
44
|
+
return;
|
|
45
|
+
const rect = refAutocomplete.current.getBoundingClientRect();
|
|
46
|
+
// The visible bounds, not the layout ones: the on-screen keyboard shrinks
|
|
47
|
+
// `visualViewport` while `innerHeight` stays put.
|
|
48
|
+
const viewport = typeof window.visualViewport !== 'undefined' ? window.visualViewport : null;
|
|
49
|
+
const visibleTop = viewport ? viewport.offsetTop : 0;
|
|
50
|
+
const visibleBottom = viewport ? viewport.offsetTop + viewport.height : window.innerHeight;
|
|
51
|
+
const spaceBelow = visibleBottom - rect.bottom - DROPDOWN_GAP;
|
|
52
|
+
const spaceAbove = rect.top - visibleTop - DROPDOWN_GAP;
|
|
53
|
+
// Flip up when the list does not fit below and above is roomier (#1567).
|
|
54
|
+
const desiredHeight = (visibleBottom - visibleTop) * DROPDOWN_HEIGHT_RATIO;
|
|
55
|
+
const openUp = spaceBelow < desiredHeight && spaceAbove > spaceBelow;
|
|
56
|
+
const space = Math.max(openUp ? spaceAbove : spaceBelow, DROPDOWN_MIN_HEIGHT);
|
|
57
|
+
const maxHeight = Math.min(desiredHeight, space);
|
|
58
|
+
// The dropdown is absolutely positioned inside the render target, so both
|
|
59
|
+
// edges are measured against it — never against the document.
|
|
60
|
+
const containerRect = container?.getBoundingClientRect();
|
|
61
|
+
const containerTop = containerRect?.top ?? 0;
|
|
62
|
+
setDropdownPosition({
|
|
63
|
+
...(openUp
|
|
64
|
+
? { bottom: (containerRect?.bottom ?? window.innerHeight) - rect.top + DROPDOWN_GAP }
|
|
65
|
+
: { top: rect.bottom - containerTop + DROPDOWN_GAP }),
|
|
66
|
+
left: rect.left - (containerRect?.left ?? 0),
|
|
67
|
+
width: rect.width,
|
|
68
|
+
maxHeight,
|
|
69
|
+
});
|
|
41
70
|
};
|
|
42
71
|
useEffect(() => {
|
|
43
|
-
if (!showOptionsDropdown)
|
|
72
|
+
if (inline || !showOptionsDropdown)
|
|
44
73
|
return;
|
|
45
74
|
updateDropdownPosition();
|
|
46
75
|
window.addEventListener('scroll', updateDropdownPosition, true);
|
|
47
76
|
window.addEventListener('resize', updateDropdownPosition);
|
|
77
|
+
const viewport = typeof window.visualViewport !== 'undefined' ? window.visualViewport : null;
|
|
78
|
+
viewport?.addEventListener('resize', updateDropdownPosition);
|
|
79
|
+
viewport?.addEventListener('scroll', updateDropdownPosition);
|
|
48
80
|
return () => {
|
|
49
81
|
window.removeEventListener('scroll', updateDropdownPosition, true);
|
|
50
82
|
window.removeEventListener('resize', updateDropdownPosition);
|
|
83
|
+
viewport?.removeEventListener('resize', updateDropdownPosition);
|
|
84
|
+
viewport?.removeEventListener('scroll', updateDropdownPosition);
|
|
85
|
+
};
|
|
86
|
+
}, [showOptionsDropdown, inline]);
|
|
87
|
+
// The inline list ends where the sheet's scroll body does. Left to overflow,
|
|
88
|
+
// it would extend that body and the tail could only be reached by scrolling
|
|
89
|
+
// the sheet, then the list. Both rects come from the same layout, so the
|
|
90
|
+
// keyboard panning the viewport does not skew the difference.
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (!inline || !showOptionsDropdown || !scrollContainer) {
|
|
93
|
+
setInlineMaxHeight(null);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const measure = () => {
|
|
97
|
+
const anchor = refAutocomplete.current;
|
|
98
|
+
if (!anchor)
|
|
99
|
+
return;
|
|
100
|
+
const room = scrollContainer.getBoundingClientRect().bottom -
|
|
101
|
+
anchor.getBoundingClientRect().bottom -
|
|
102
|
+
DROPDOWN_GAP;
|
|
103
|
+
setInlineMaxHeight(Math.max(room, DROPDOWN_MIN_HEIGHT));
|
|
51
104
|
};
|
|
52
|
-
|
|
105
|
+
measure();
|
|
106
|
+
// The sheet resizes under the list: snap points, the keyboard, a drag.
|
|
107
|
+
const observer = new ResizeObserver(measure);
|
|
108
|
+
observer.observe(scrollContainer);
|
|
109
|
+
return () => observer.disconnect();
|
|
110
|
+
}, [inline, showOptionsDropdown, scrollContainer]);
|
|
53
111
|
useEffect(() => {
|
|
54
112
|
if (focusOptionIndex === null || !refOptions.current)
|
|
55
113
|
return;
|
|
@@ -164,20 +222,25 @@ const Autocomplete = ({ placeholder, options, value, showClear = false, onChange
|
|
|
164
222
|
return (_jsx("li", { id: getOptionId(index), role: "option", "aria-selected": isActive, className: className, onMouseDown: (e) => e.preventDefault(), onClick: () => selectOption(option), children: getOptionLabel(option) }, getOptionKey(option)));
|
|
165
223
|
}) }));
|
|
166
224
|
};
|
|
167
|
-
return (_jsxs(_Fragment, { children: [
|
|
225
|
+
return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: refAutocomplete, "data-no-drag": true, className: classNames('efp-autocomplete', {
|
|
168
226
|
'is-open': showOptionsDropdown,
|
|
169
227
|
'with-clear': showOptionsDropdown && showClear,
|
|
170
|
-
}), children: _jsxs("div", { className: "efp-autocomplete__inner", children: [_jsx("input", { type: "search", className: "efp-autocomplete__input", role: "combobox", "aria-label": ariaLabel, "aria-autocomplete": "list", "aria-expanded": showOptionsDropdown, "aria-controls": showOptionsDropdown ? listboxId : undefined, "aria-activedescendant": showOptionsDropdown && focusOptionIndex !== null
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
228
|
+
}), children: [_jsxs("div", { className: "efp-autocomplete__inner", children: [_jsx("input", { type: "search", className: "efp-autocomplete__input", role: "combobox", "aria-label": ariaLabel, "aria-autocomplete": "list", "aria-expanded": showOptionsDropdown, "aria-controls": showOptionsDropdown ? listboxId : undefined, "aria-activedescendant": showOptionsDropdown && focusOptionIndex !== null
|
|
229
|
+
? getOptionId(focusOptionIndex)
|
|
230
|
+
: undefined, onChange: onInputChange, onFocus: onFocus, onBlur: onBlur, onKeyDown: onKeyDown, value: displayValue, placeholder: placeholder, ref: refSearchInput }), showClear && showOptionsDropdown && displayValue && (_jsx("button", { type: "button", className: "efp-autocomplete__clear", "aria-label": clearLabel, onMouseDown: (e) => {
|
|
231
|
+
e.preventDefault();
|
|
232
|
+
clearValue(true);
|
|
233
|
+
} }))] }), showOptionsDropdown && inline && (_jsx("div", { ref: refOptions, "data-no-drag": true, className: "efp-autocomplete-options efp-autocomplete-options--inline", style: inlineMaxHeight !== null ? { maxHeight: `${inlineMaxHeight}px` } : undefined, children: _jsx("div", { className: "efp-autocomplete-options__scroll", children: renderOptions() }) }))] }), showOptionsDropdown &&
|
|
234
|
+
!inline &&
|
|
176
235
|
container &&
|
|
177
236
|
createPortal(_jsx("div", { ref: refOptions, className: "efp-autocomplete-options", style: {
|
|
178
|
-
top: `${dropdownPosition.top}px
|
|
237
|
+
...(dropdownPosition.top !== undefined ? { top: `${dropdownPosition.top}px` } : {}),
|
|
238
|
+
...(dropdownPosition.bottom !== undefined
|
|
239
|
+
? { bottom: `${dropdownPosition.bottom}px` }
|
|
240
|
+
: {}),
|
|
179
241
|
left: `${dropdownPosition.left}px`,
|
|
180
242
|
width: `${dropdownPosition.width}px`,
|
|
243
|
+
maxHeight: `${dropdownPosition.maxHeight}px`,
|
|
181
244
|
}, children: _jsx("div", { className: "efp-autocomplete-options__scroll", children: renderOptions() }) }), container)] }));
|
|
182
245
|
};
|
|
183
246
|
export default Autocomplete;
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import './Overlay.scss';
|
|
3
3
|
import cn from 'classnames';
|
|
4
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
4
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react';
|
|
5
5
|
import debounce from '../../shared/debounce';
|
|
6
|
-
import {
|
|
6
|
+
import { useKeyboardInset } from '../../shared/hooks/useKeyboardInset';
|
|
7
|
+
import { useSheetDrag } from '../../shared/hooks/useSheetDrag';
|
|
7
8
|
import { useWindowSize } from '../../shared/hooks/useWindowSize';
|
|
8
9
|
import { OverlayScrollContext } from './OverlayScrollContext';
|
|
9
|
-
const GAP = 70;
|
|
10
10
|
const TRANSITION_DURATION = '0.3s';
|
|
11
11
|
const DEFAULT_SCROLL_THRESHOLD = 10;
|
|
12
12
|
const DEFAULT_MOBILE_BREAKPOINT = 768;
|
|
@@ -27,125 +27,121 @@ const Overlay = ({ children, open, size = 'medium', particles, className, onChan
|
|
|
27
27
|
// Pure derivations of the window size — computed in render, not stored/synced via an effect.
|
|
28
28
|
const mobile = windowWidth <= mobileBreakpoint;
|
|
29
29
|
const sizes = useMemo(() => generateSizes(windowHeight, smallSizeMultiplier, mediumSizeMultiplier, fullSizeOffset), [windowHeight, smallSizeMultiplier, mediumSizeMultiplier, fullSizeOffset]);
|
|
30
|
+
// The on-screen keyboard covers the sheet without resizing the window (#1567):
|
|
31
|
+
// lift the sheet above it and give up the height the keyboard took.
|
|
32
|
+
const keyboardInset = useKeyboardInset(mobile);
|
|
33
|
+
// What the sheet can actually occupy right now. Snap points and rendered
|
|
34
|
+
// heights must come from the same numbers, or a drag lands where the sheet is not.
|
|
35
|
+
const effectiveSizes = useMemo(() => {
|
|
36
|
+
if (keyboardInset <= 0)
|
|
37
|
+
return sizes;
|
|
38
|
+
// Never below the smallest snap point: a collapsed sheet reads as a white screen.
|
|
39
|
+
const full = Math.max(sizes.full - keyboardInset, sizes.small);
|
|
40
|
+
return {
|
|
41
|
+
small: Math.min(sizes.small, full),
|
|
42
|
+
medium: Math.min(sizes.medium, full),
|
|
43
|
+
full,
|
|
44
|
+
};
|
|
45
|
+
}, [sizes, keyboardInset]);
|
|
30
46
|
const [dragging, setDragging] = useState(false);
|
|
31
|
-
|
|
32
|
-
const [
|
|
47
|
+
// The sheet is at zero height: closed, or mounted and about to animate open.
|
|
48
|
+
const [collapsed, setCollapsed] = useState(!open);
|
|
33
49
|
const [isAnimating, setIsAnimating] = useState(false);
|
|
34
50
|
const [shouldRender, setShouldRender] = useState(open);
|
|
35
51
|
const [isScrolled, setIsScrolled] = useState(false);
|
|
36
|
-
const touchStartRef = useRef(undefined);
|
|
37
|
-
const dragOffsetRef = useRef(undefined);
|
|
38
52
|
const overlayPanelRef = useRef(null);
|
|
39
|
-
const triggerRef = useRef(null);
|
|
40
53
|
const scrollRef = useRef(null);
|
|
41
|
-
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
54
|
+
const openedAtRef = useRef(undefined);
|
|
55
|
+
// The snap point the sheet actually sits on. The prop may be undefined
|
|
56
|
+
// before the host has chosen one, and a drag needs a value to start from.
|
|
57
|
+
const [currentSnap, setCurrentSnap] = useState(size);
|
|
58
|
+
// Mirror for the identity-stable callbacks below. Written after paint, not
|
|
59
|
+
// during render, so a concurrent re-render cannot observe a torn value.
|
|
60
|
+
const snapRef = useRef(currentSnap);
|
|
47
61
|
const sizeRef = useRef(size);
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
overlayHeightRef.current = overlayHeight;
|
|
52
|
-
draggingRef.current = dragging;
|
|
62
|
+
const onChangeSizeRef = useRef(onChangeSize);
|
|
63
|
+
useLayoutEffect(() => {
|
|
64
|
+
snapRef.current = currentSnap;
|
|
53
65
|
sizeRef.current = size;
|
|
66
|
+
onChangeSizeRef.current = onChangeSize;
|
|
54
67
|
});
|
|
68
|
+
// The snap point is the state; the height follows from it every render. Storing
|
|
69
|
+
// pixels instead would freeze whatever the keyboard allowed at that moment and
|
|
70
|
+
// keep the sheet short after it closes.
|
|
55
71
|
const setSize = useCallback((newSize) => {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}, [onChangeSize]);
|
|
72
|
+
setCurrentSnap(newSize);
|
|
73
|
+
setCollapsed(false);
|
|
74
|
+
onChangeSizeRef.current?.(newSize);
|
|
75
|
+
}, []);
|
|
61
76
|
const positioningStyles = useMemo(() => {
|
|
62
77
|
if (mobile) {
|
|
78
|
+
const lift = keyboardInset > 0 ? { bottom: keyboardInset } : {};
|
|
79
|
+
const clamp = (height) => Math.min(height, effectiveSizes.full);
|
|
63
80
|
if (forceFull && open) {
|
|
64
|
-
return { height: getActualViewportHeight() };
|
|
81
|
+
return { ...lift, height: clamp(getActualViewportHeight()) };
|
|
65
82
|
}
|
|
83
|
+
// A gesture freezes the sheet at its tallest point and moves it with
|
|
84
|
+
// `translate3d`, keeping the drag off the layout path. The lift stays on.
|
|
85
|
+
if (dragging)
|
|
86
|
+
return { ...lift, height: effectiveSizes.full };
|
|
66
87
|
return {
|
|
67
|
-
|
|
88
|
+
...lift,
|
|
89
|
+
height: (open || isAnimating) && !collapsed ? effectiveSizes[currentSnap] : 0,
|
|
68
90
|
};
|
|
69
91
|
}
|
|
70
92
|
return {
|
|
71
93
|
height: open ? 'auto' : undefined,
|
|
72
94
|
};
|
|
73
|
-
}, [
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
else if (targetSize === sizes.medium) {
|
|
93
|
-
setSize('medium');
|
|
94
|
-
}
|
|
95
|
-
else {
|
|
96
|
-
setSize('small');
|
|
97
|
-
}
|
|
98
|
-
}, [setSize]);
|
|
99
|
-
const handleDragStart = useCallback((clientY) => {
|
|
100
|
-
setDragging(true);
|
|
101
|
-
touchStartRef.current = clientY;
|
|
102
|
-
}, []);
|
|
103
|
-
const handleDragMove = useCallback((clientY) => {
|
|
104
|
-
const touchStart = touchStartRef.current;
|
|
105
|
-
if (touchStart === undefined)
|
|
106
|
-
return;
|
|
107
|
-
const sizes = sizesRef.current;
|
|
108
|
-
const panelHeight = panelHeightRef.current;
|
|
109
|
-
const scrollEl = scrollRef.current;
|
|
110
|
-
if ((scrollEl?.scrollTop ?? 0) > 0 && panelHeight === sizes.full)
|
|
111
|
-
return;
|
|
112
|
-
const offset = touchStart - clientY;
|
|
113
|
-
const currentHeight = panelHeight || sizes.medium;
|
|
114
|
-
let nextHeight = currentHeight + offset;
|
|
115
|
-
if (scrollEl && panelHeight === sizes.full && nextHeight < panelHeight) {
|
|
116
|
-
scrollEl.style.overflow = 'hidden';
|
|
117
|
-
}
|
|
118
|
-
nextHeight = Math.min(Math.max(nextHeight, sizes.small), sizes.full);
|
|
119
|
-
dragOffsetRef.current = offset;
|
|
120
|
-
overlayHeightRef.current = nextHeight;
|
|
121
|
-
setOverlayHeight(nextHeight);
|
|
122
|
-
}, []);
|
|
123
|
-
const handleDragEnd = useCallback(() => {
|
|
124
|
-
setDragging(false);
|
|
125
|
-
touchStartRef.current = undefined;
|
|
126
|
-
const scrollEl = scrollRef.current;
|
|
127
|
-
if (scrollEl) {
|
|
128
|
-
scrollEl.style.removeProperty('overflow');
|
|
129
|
-
}
|
|
130
|
-
const dragOffset = dragOffsetRef.current;
|
|
131
|
-
dragOffsetRef.current = undefined;
|
|
132
|
-
const finalHeight = overlayHeightRef.current;
|
|
133
|
-
if (dragOffset !== undefined && finalHeight !== undefined) {
|
|
134
|
-
setSizeByValue(finalHeight);
|
|
135
|
-
}
|
|
136
|
-
}, [setSizeByValue]);
|
|
95
|
+
}, [
|
|
96
|
+
mobile,
|
|
97
|
+
open,
|
|
98
|
+
collapsed,
|
|
99
|
+
currentSnap,
|
|
100
|
+
isAnimating,
|
|
101
|
+
forceFull,
|
|
102
|
+
dragging,
|
|
103
|
+
effectiveSizes,
|
|
104
|
+
keyboardInset,
|
|
105
|
+
]);
|
|
106
|
+
const snapPoints = useMemo(() => [
|
|
107
|
+
{ name: 'small', height: effectiveSizes.small },
|
|
108
|
+
{ name: 'medium', height: effectiveSizes.medium },
|
|
109
|
+
{ name: 'full', height: effectiveSizes.full },
|
|
110
|
+
], [effectiveSizes]);
|
|
111
|
+
const handleDragStart = useCallback(() => setDragging(true), []);
|
|
112
|
+
const handleSettle = useCallback(() => setDragging(false), []);
|
|
137
113
|
const handleTriggerClick = useCallback(() => {
|
|
138
|
-
|
|
139
|
-
return;
|
|
140
|
-
const currentSize = sizeRef.current;
|
|
114
|
+
const currentSize = snapRef.current;
|
|
141
115
|
const nextSize = currentSize === 'small' ? 'medium' : currentSize === 'medium' ? 'full' : 'small';
|
|
142
116
|
setSize(nextSize);
|
|
143
117
|
}, [setSize]);
|
|
144
|
-
const {
|
|
118
|
+
const { onPointerDown } = useSheetDrag({
|
|
119
|
+
enabled: mobile && !disableDrag && !forceFull,
|
|
120
|
+
panelRef: overlayPanelRef,
|
|
121
|
+
scrollRef,
|
|
122
|
+
snapPoints,
|
|
123
|
+
current: currentSnap,
|
|
124
|
+
openedAtRef,
|
|
145
125
|
onDragStart: handleDragStart,
|
|
146
|
-
|
|
147
|
-
|
|
126
|
+
onSnap: setSize,
|
|
127
|
+
onSettle: handleSettle,
|
|
128
|
+
onHandleTap: handleTriggerClick,
|
|
148
129
|
});
|
|
130
|
+
// The gesture owns `transform`; React owns `height`. Clear the transform in
|
|
131
|
+
// the same paint as the height React just wrote, so the sheet does not jump.
|
|
132
|
+
useLayoutEffect(() => {
|
|
133
|
+
if (dragging)
|
|
134
|
+
return;
|
|
135
|
+
const panel = overlayPanelRef.current;
|
|
136
|
+
if (!panel)
|
|
137
|
+
return;
|
|
138
|
+
// The swap must not run through the `height` transition — it would flash
|
|
139
|
+
// the sheet to the top of the screen.
|
|
140
|
+
panel.style.transition = 'none';
|
|
141
|
+
panel.style.removeProperty('transform');
|
|
142
|
+
const frame = requestAnimationFrame(() => panel.style.removeProperty('transition'));
|
|
143
|
+
return () => cancelAnimationFrame(frame);
|
|
144
|
+
}, [dragging]);
|
|
149
145
|
const handleScroll = useCallback(() => {
|
|
150
146
|
const scrollEl = scrollRef.current;
|
|
151
147
|
if (!scrollEl)
|
|
@@ -160,63 +156,25 @@ const Overlay = ({ children, open, size = 'medium', particles, className, onChan
|
|
|
160
156
|
const debouncedScrollActivity = useMemo(() => debounce(() => {
|
|
161
157
|
onScrollActivity?.();
|
|
162
158
|
}, 250), [onScrollActivity]);
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
const overlayEl = overlayPanelRef.current;
|
|
167
|
-
const triggerEl = triggerRef.current;
|
|
168
|
-
const scrollEl = scrollRef.current;
|
|
169
|
-
const preventOverscroll = () => {
|
|
170
|
-
const panelHeight = panelHeightRef.current;
|
|
171
|
-
if (panelHeight && panelHeight < sizesRef.current.full && scrollEl) {
|
|
172
|
-
scrollEl.style.overflow = 'hidden';
|
|
173
|
-
}
|
|
174
|
-
};
|
|
175
|
-
if (overlayEl) {
|
|
176
|
-
overlayEl.addEventListener('touchstart', touchHandlers.onTouchStart);
|
|
177
|
-
overlayEl.addEventListener('touchend', touchHandlers.onTouchEnd);
|
|
178
|
-
window.addEventListener('touchmove', touchHandlers.onTouchMove);
|
|
179
|
-
overlayEl.addEventListener('mousedown', mouseHandlers.onMouseDown);
|
|
180
|
-
overlayEl.addEventListener('mouseup', mouseHandlers.onMouseUp);
|
|
181
|
-
window.addEventListener('mousemove', mouseHandlers.onMouseMove);
|
|
182
|
-
}
|
|
183
|
-
if (triggerEl) {
|
|
184
|
-
triggerEl.addEventListener('mouseup', handleTriggerClick);
|
|
185
|
-
}
|
|
186
|
-
if (scrollEl) {
|
|
187
|
-
scrollEl.addEventListener('touchstart', preventOverscroll);
|
|
188
|
-
}
|
|
189
|
-
return () => {
|
|
190
|
-
if (overlayEl) {
|
|
191
|
-
overlayEl.removeEventListener('touchstart', touchHandlers.onTouchStart);
|
|
192
|
-
overlayEl.removeEventListener('touchend', touchHandlers.onTouchEnd);
|
|
193
|
-
window.removeEventListener('touchmove', touchHandlers.onTouchMove);
|
|
194
|
-
overlayEl.removeEventListener('mousedown', mouseHandlers.onMouseDown);
|
|
195
|
-
overlayEl.removeEventListener('mouseup', mouseHandlers.onMouseUp);
|
|
196
|
-
window.removeEventListener('mousemove', mouseHandlers.onMouseMove);
|
|
197
|
-
}
|
|
198
|
-
if (triggerEl) {
|
|
199
|
-
triggerEl.removeEventListener('mouseup', handleTriggerClick);
|
|
200
|
-
}
|
|
201
|
-
if (scrollEl) {
|
|
202
|
-
scrollEl.removeEventListener('touchstart', preventOverscroll);
|
|
203
|
-
}
|
|
204
|
-
};
|
|
205
|
-
}, [mobile, disableDrag, touchHandlers, mouseHandlers, handleTriggerClick]);
|
|
159
|
+
// Runs on open/close only. A `size` prop change (or a host re-creating
|
|
160
|
+
// `onChangeSize`) is not a re-open: re-running this would re-arm the opening
|
|
161
|
+
// lock and, 10ms later, write the prop over the snap point a drag just chose.
|
|
206
162
|
useEffect(() => {
|
|
207
163
|
if (open) {
|
|
208
164
|
setShouldRender(true);
|
|
209
165
|
setIsAnimating(true);
|
|
166
|
+
openedAtRef.current = performance.now();
|
|
210
167
|
const timer = setTimeout(() => {
|
|
211
|
-
setSize(
|
|
168
|
+
setSize(sizeRef.current);
|
|
212
169
|
setIsAnimating(false);
|
|
213
170
|
}, 10);
|
|
214
171
|
return () => clearTimeout(timer);
|
|
215
172
|
}
|
|
216
173
|
else {
|
|
217
174
|
setIsAnimating(true);
|
|
175
|
+
openedAtRef.current = undefined;
|
|
218
176
|
if (mobile) {
|
|
219
|
-
|
|
177
|
+
setCollapsed(true);
|
|
220
178
|
}
|
|
221
179
|
const timer = setTimeout(() => {
|
|
222
180
|
setShouldRender(false);
|
|
@@ -225,7 +183,12 @@ const Overlay = ({ children, open, size = 'medium', particles, className, onChan
|
|
|
225
183
|
}, parseFloat(TRANSITION_DURATION) * 1000);
|
|
226
184
|
return () => clearTimeout(timer);
|
|
227
185
|
}
|
|
228
|
-
}, [open,
|
|
186
|
+
}, [open, setSize, mobile]);
|
|
187
|
+
// Host-driven size changes (details open at full, the planner at medium).
|
|
188
|
+
// The sheet's own reports come back through here too, already applied.
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
setCurrentSnap(size);
|
|
191
|
+
}, [size]);
|
|
229
192
|
useEffect(() => {
|
|
230
193
|
if (open && !isAnimating) {
|
|
231
194
|
scrollRef.current?.scrollTo({ top: 0 });
|
|
@@ -237,13 +200,14 @@ const Overlay = ({ children, open, size = 'medium', particles, className, onChan
|
|
|
237
200
|
return null;
|
|
238
201
|
return (_jsx(OverlayScrollContext, { value: scrollRef.current, children: _jsxs("div", { className: cn('efp-overlay', {
|
|
239
202
|
'efp-overlay--draggable': dragging,
|
|
240
|
-
'efp-overlay--
|
|
203
|
+
'efp-overlay--keyboard': mobile && keyboardInset > 0,
|
|
204
|
+
'efp-overlay--full': currentSnap === 'full',
|
|
241
205
|
'efp-overlay--mobile': mobile,
|
|
242
206
|
'efp-overlay--desktop': !mobile,
|
|
243
207
|
'efp-overlay--hidden': !mobile && !open,
|
|
244
208
|
'efp-overlay--scrolled': isScrolled,
|
|
245
209
|
'efp-overlay--forced-full': forceFull && mobile,
|
|
246
|
-
}, className), style: positioningStyles, ref: overlayPanelRef, children: [!disableDrag && _jsx("div", { className: "efp-overlay__draghandle",
|
|
210
|
+
}, className), style: positioningStyles, ref: overlayPanelRef, onPointerDown: onPointerDown, children: [!disableDrag && _jsx("div", { className: "efp-overlay__draghandle", "data-sheet-handle": true }), _jsxs("div", { className: "efp-overlay__scroll", ref: scrollRef, onScroll: () => {
|
|
247
211
|
handleScroll();
|
|
248
212
|
debouncedScrollActivity();
|
|
249
213
|
}, children: [particles, _jsx("div", { className: "efp-overlay__content", children: children ? children : noContentLabel })] })] }) }));
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Height in px of the on-screen keyboard covering the layout viewport's bottom,
|
|
3
|
+
* or 0. The keyboard shrinks `visualViewport` while `innerHeight` stays put
|
|
4
|
+
* (#1567). iOS additionally pans the layout viewport to reveal the focused
|
|
5
|
+
* input; tracking that pan means chasing it (iOS re-pans after every lift), so
|
|
6
|
+
* the inset is the keyboard height alone and the pan is undone instead.
|
|
7
|
+
*/
|
|
8
|
+
export declare const useKeyboardInset: (enabled: boolean) => number;
|
|
9
|
+
//# sourceMappingURL=useKeyboardInset.d.ts.map
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
/** Anything smaller is browser-chrome noise (URL bar collapse), not a keyboard. */
|
|
3
|
+
const MIN_KEYBOARD_HEIGHT = 100;
|
|
4
|
+
/** px of drift to absorb — iOS reports fractional heights while the keyboard settles. */
|
|
5
|
+
const INSET_TOLERANCE = 2;
|
|
6
|
+
/** A keyboard never covers more than this; anything larger is a geometry glitch. */
|
|
7
|
+
const MAX_INSET_RATIO = 0.6;
|
|
8
|
+
/** Unzoomed scale is not exactly 1 in every Android WebView — 1.0000001 happens. */
|
|
9
|
+
const SCALE_TOLERANCE = 0.01;
|
|
10
|
+
/**
|
|
11
|
+
* Height in px of the on-screen keyboard covering the layout viewport's bottom,
|
|
12
|
+
* or 0. The keyboard shrinks `visualViewport` while `innerHeight` stays put
|
|
13
|
+
* (#1567). iOS additionally pans the layout viewport to reveal the focused
|
|
14
|
+
* input; tracking that pan means chasing it (iOS re-pans after every lift), so
|
|
15
|
+
* the inset is the keyboard height alone and the pan is undone instead.
|
|
16
|
+
*/
|
|
17
|
+
export const useKeyboardInset = (enabled) => {
|
|
18
|
+
const [inset, setInset] = useState(0);
|
|
19
|
+
const insetRef = useRef(0);
|
|
20
|
+
const pinnedScrollRef = useRef(null);
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (!enabled) {
|
|
23
|
+
setInset(0);
|
|
24
|
+
insetRef.current = 0;
|
|
25
|
+
pinnedScrollRef.current = null;
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const viewport = typeof window.visualViewport !== 'undefined' ? window.visualViewport : null;
|
|
29
|
+
if (!viewport)
|
|
30
|
+
return;
|
|
31
|
+
const read = () => {
|
|
32
|
+
const covered = window.innerHeight - viewport.height;
|
|
33
|
+
// Pinch zoom also shrinks the visual viewport — only an unzoomed shrink counts.
|
|
34
|
+
if (Math.abs(viewport.scale - 1) >= SCALE_TOLERANCE || covered <= MIN_KEYBOARD_HEIGHT) {
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
return Math.round(Math.min(covered, window.innerHeight * MAX_INSET_RATIO));
|
|
38
|
+
};
|
|
39
|
+
// Undoing the pan scrolls the whole window — ours to do only when the SDK
|
|
40
|
+
// owns it, not embedded in a `<div>` on a scrollable host page.
|
|
41
|
+
const ownsViewport = () => document.documentElement.scrollHeight <= window.innerHeight + INSET_TOLERANCE;
|
|
42
|
+
const apply = (next) => {
|
|
43
|
+
if (Math.abs(next - insetRef.current) <= INSET_TOLERANCE)
|
|
44
|
+
return;
|
|
45
|
+
// Remember where the page stood before the keyboard pushed it around.
|
|
46
|
+
if (insetRef.current === 0) {
|
|
47
|
+
pinnedScrollRef.current = ownsViewport() ? window.scrollY : null;
|
|
48
|
+
}
|
|
49
|
+
if (next === 0)
|
|
50
|
+
pinnedScrollRef.current = null;
|
|
51
|
+
insetRef.current = next;
|
|
52
|
+
setInset(next);
|
|
53
|
+
};
|
|
54
|
+
// The keyboard animating in fires a burst of events; render the last value
|
|
55
|
+
// of each frame, or the sheet jitters (#1567).
|
|
56
|
+
let frame = 0;
|
|
57
|
+
const onResize = () => {
|
|
58
|
+
if (frame)
|
|
59
|
+
return;
|
|
60
|
+
frame = requestAnimationFrame(() => {
|
|
61
|
+
frame = 0;
|
|
62
|
+
apply(read());
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
// The lift already reveals the input, so the iOS pan is pure damage: undo it.
|
|
66
|
+
const onScroll = () => {
|
|
67
|
+
const pinned = pinnedScrollRef.current;
|
|
68
|
+
if (pinned === null || insetRef.current === 0)
|
|
69
|
+
return;
|
|
70
|
+
if (Math.abs(window.scrollY - pinned) > INSET_TOLERANCE)
|
|
71
|
+
window.scrollTo(0, pinned);
|
|
72
|
+
};
|
|
73
|
+
apply(read());
|
|
74
|
+
viewport.addEventListener('resize', onResize);
|
|
75
|
+
viewport.addEventListener('scroll', onScroll);
|
|
76
|
+
return () => {
|
|
77
|
+
if (frame)
|
|
78
|
+
cancelAnimationFrame(frame);
|
|
79
|
+
viewport.removeEventListener('resize', onResize);
|
|
80
|
+
viewport.removeEventListener('scroll', onScroll);
|
|
81
|
+
};
|
|
82
|
+
}, [enabled]);
|
|
83
|
+
return inset;
|
|
84
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type PointerEvent as ReactPointerEvent, type RefObject } from 'react';
|
|
2
|
+
export interface SnapPoint<T extends string> {
|
|
3
|
+
name: T;
|
|
4
|
+
height: number;
|
|
5
|
+
}
|
|
6
|
+
interface UseSheetDragOptions<T extends string> {
|
|
7
|
+
enabled: boolean;
|
|
8
|
+
panelRef: RefObject<HTMLDivElement | null>;
|
|
9
|
+
scrollRef: RefObject<HTMLDivElement | null>;
|
|
10
|
+
/** Ascending by height; the last one is the tallest. */
|
|
11
|
+
snapPoints: readonly SnapPoint<T>[];
|
|
12
|
+
current: T;
|
|
13
|
+
/** `performance.now()` of the last open, or undefined while the sheet is closed. */
|
|
14
|
+
openedAtRef: RefObject<number | undefined>;
|
|
15
|
+
/** Freeze the sheet at its tallest point; fires before the first drawn move. */
|
|
16
|
+
onDragStart: () => void;
|
|
17
|
+
/** Fires on release, while the sheet animates to the chosen point. */
|
|
18
|
+
onSnap: (name: T) => void;
|
|
19
|
+
/** The animation has landed; the host resumes its height-driven layout. */
|
|
20
|
+
onSettle: () => void;
|
|
21
|
+
onHandleTap: () => void;
|
|
22
|
+
}
|
|
23
|
+
export declare function useSheetDrag<T extends string>({ enabled, panelRef, scrollRef, snapPoints, current, openedAtRef, onDragStart, onSnap, onSettle, onHandleTap, }: UseSheetDragOptions<T>): {
|
|
24
|
+
onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
|
25
|
+
};
|
|
26
|
+
export {};
|
|
27
|
+
//# sourceMappingURL=useSheetDrag.d.ts.map
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Bottom-sheet drag physics ported from `vaul` v1.1.2 (not a dependency: it
|
|
4
|
+
* injects styles into `document.head`, which never reaches our shadow root).
|
|
5
|
+
* At rest the sheet is height-driven; during a gesture it is frozen at its
|
|
6
|
+
* tallest point and moved with `translate3d`, off the React/layout path.
|
|
7
|
+
*/
|
|
8
|
+
/** px/ms: above this the sheet flies to the far end regardless of distance. */
|
|
9
|
+
const FLING_VELOCITY = 2;
|
|
10
|
+
/** px/ms: above this a short drag steps exactly one snap point. */
|
|
11
|
+
const STEP_VELOCITY = 0.4;
|
|
12
|
+
const STEP_DISTANCE_RATIO = 0.4;
|
|
13
|
+
const SCROLL_LOCK_TIMEOUT = 100;
|
|
14
|
+
const OPEN_LOCK_TIMEOUT = 500;
|
|
15
|
+
const MOVE_TOLERANCE = 2;
|
|
16
|
+
/** Matches SHEET_TRANSITION, with room for a `transitionend` that may not come. */
|
|
17
|
+
const SETTLE_TIMEOUT = 550;
|
|
18
|
+
const SHEET_TRANSITION = 'transform 0.5s cubic-bezier(0.32, 0.72, 0, 1)';
|
|
19
|
+
/** Resistance past the end stops (vaul's curve). */
|
|
20
|
+
const dampen = (distance) => Math.max(8 * (Math.log(distance + 1) - 2), 0);
|
|
21
|
+
const readOffset = (panel) => {
|
|
22
|
+
if (!panel || typeof DOMMatrixReadOnly !== 'function')
|
|
23
|
+
return null;
|
|
24
|
+
const { transform } = getComputedStyle(panel);
|
|
25
|
+
if (!transform || transform === 'none')
|
|
26
|
+
return null;
|
|
27
|
+
try {
|
|
28
|
+
return new DOMMatrixReadOnly(transform).m42;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
export function useSheetDrag({ enabled, panelRef, scrollRef, snapPoints, current, openedAtRef, onDragStart, onSnap, onSettle, onHandleTap, }) {
|
|
35
|
+
const gestureRef = useRef(null);
|
|
36
|
+
const scrollBlockedAtRef = useRef(0);
|
|
37
|
+
const settleRef = useRef(null);
|
|
38
|
+
// Detaches the window listeners of a gesture still in flight, without snapping.
|
|
39
|
+
const stopGestureRef = useRef(null);
|
|
40
|
+
// Mirrored after paint so a concurrent re-render cannot observe a torn value.
|
|
41
|
+
const stateRef = useRef({ enabled, snapPoints, current });
|
|
42
|
+
useLayoutEffect(() => {
|
|
43
|
+
stateRef.current = { enabled, snapPoints, current };
|
|
44
|
+
}, [enabled, snapPoints, current]);
|
|
45
|
+
const callbacksRef = useRef({ onDragStart, onSnap, onSettle, onHandleTap });
|
|
46
|
+
useLayoutEffect(() => {
|
|
47
|
+
callbacksRef.current = { onDragStart, onSnap, onSettle, onHandleTap };
|
|
48
|
+
}, [onDragStart, onSnap, onSettle, onHandleTap]);
|
|
49
|
+
const offsetOf = useCallback((name) => {
|
|
50
|
+
const { snapPoints } = stateRef.current;
|
|
51
|
+
const tallest = snapPoints[snapPoints.length - 1].height;
|
|
52
|
+
const point = snapPoints.find((snapPoint) => snapPoint.name === name);
|
|
53
|
+
return tallest - (point?.height ?? tallest);
|
|
54
|
+
}, []);
|
|
55
|
+
const writeOffset = useCallback((offset, transition) => {
|
|
56
|
+
const panel = panelRef.current;
|
|
57
|
+
if (!panel)
|
|
58
|
+
return;
|
|
59
|
+
panel.style.transition = transition;
|
|
60
|
+
panel.style.transform = `translate3d(0, ${offset}px, 0)`;
|
|
61
|
+
}, [panelRef]);
|
|
62
|
+
/** Vaul's `shouldDrag`; runs on the first move — the answer depends on direction. */
|
|
63
|
+
const shouldDrag = useCallback((target, draggingDown) => {
|
|
64
|
+
const now = performance.now();
|
|
65
|
+
const openedAt = openedAtRef.current;
|
|
66
|
+
if (openedAt !== undefined && now - openedAt < OPEN_LOCK_TIMEOUT)
|
|
67
|
+
return false;
|
|
68
|
+
if (now - scrollBlockedAtRef.current < SCROLL_LOCK_TIMEOUT)
|
|
69
|
+
return false;
|
|
70
|
+
if (window.getSelection()?.toString())
|
|
71
|
+
return false;
|
|
72
|
+
let element = target instanceof Element ? target : null;
|
|
73
|
+
if (element?.closest('[data-no-drag]'))
|
|
74
|
+
return false;
|
|
75
|
+
const { snapPoints, current } = stateRef.current;
|
|
76
|
+
if (!draggingDown && current === snapPoints[snapPoints.length - 1].name) {
|
|
77
|
+
scrollBlockedAtRef.current = now;
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
const panel = panelRef.current;
|
|
81
|
+
while (element && panel?.contains(element)) {
|
|
82
|
+
if (element.scrollHeight > element.clientHeight && element.scrollTop !== 0) {
|
|
83
|
+
scrollBlockedAtRef.current = now;
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
element = element.parentElement;
|
|
87
|
+
}
|
|
88
|
+
return true;
|
|
89
|
+
}, [openedAtRef, panelRef]);
|
|
90
|
+
const resolveTarget = useCallback((offset, distance, velocity) => {
|
|
91
|
+
const { snapPoints, current } = stateRef.current;
|
|
92
|
+
const draggedUp = distance > 0;
|
|
93
|
+
if (velocity > FLING_VELOCITY) {
|
|
94
|
+
return draggedUp ? snapPoints[snapPoints.length - 1].name : snapPoints[0].name;
|
|
95
|
+
}
|
|
96
|
+
if (velocity > STEP_VELOCITY &&
|
|
97
|
+
Math.abs(distance) < window.innerHeight * STEP_DISTANCE_RATIO) {
|
|
98
|
+
const index = snapPoints.findIndex((snapPoint) => snapPoint.name === current);
|
|
99
|
+
const stepped = index + (draggedUp ? 1 : -1);
|
|
100
|
+
return snapPoints[Math.min(Math.max(stepped, 0), snapPoints.length - 1)].name;
|
|
101
|
+
}
|
|
102
|
+
return snapPoints.reduce((closest, snapPoint) => Math.abs(offsetOf(snapPoint.name) - offset) < Math.abs(offsetOf(closest.name) - offset)
|
|
103
|
+
? snapPoint
|
|
104
|
+
: closest).name;
|
|
105
|
+
}, [offsetOf]);
|
|
106
|
+
const flushSettle = useCallback(() => {
|
|
107
|
+
settleRef.current?.flush();
|
|
108
|
+
}, []);
|
|
109
|
+
const scheduleSettle = useCallback(() => {
|
|
110
|
+
const panel = panelRef.current;
|
|
111
|
+
const cancel = () => {
|
|
112
|
+
settleRef.current = null;
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
panel?.removeEventListener('transitionend', onTransitionEnd);
|
|
115
|
+
};
|
|
116
|
+
const flush = () => {
|
|
117
|
+
cancel();
|
|
118
|
+
callbacksRef.current.onSettle();
|
|
119
|
+
};
|
|
120
|
+
const onTransitionEnd = (event) => {
|
|
121
|
+
if (event.target === panel && event.propertyName === 'transform')
|
|
122
|
+
flush();
|
|
123
|
+
};
|
|
124
|
+
// No `transitionend` when the sheet is released where it started.
|
|
125
|
+
const timer = setTimeout(flush, SETTLE_TIMEOUT);
|
|
126
|
+
panel?.addEventListener('transitionend', onTransitionEnd);
|
|
127
|
+
settleRef.current = { flush, cancel };
|
|
128
|
+
}, [panelRef]);
|
|
129
|
+
// A sheet unmounted mid-gesture takes its timers and window listeners with
|
|
130
|
+
// it, silently — no onSnap/onSettle into a dead tree.
|
|
131
|
+
useEffect(() => () => {
|
|
132
|
+
settleRef.current?.cancel();
|
|
133
|
+
stopGestureRef.current?.();
|
|
134
|
+
}, []);
|
|
135
|
+
const onPointerDown = useCallback((event) => {
|
|
136
|
+
if (!stateRef.current.enabled || gestureRef.current)
|
|
137
|
+
return;
|
|
138
|
+
if (event.pointerType === 'mouse' && event.button !== 0)
|
|
139
|
+
return;
|
|
140
|
+
const liveOffset = settleRef.current ? readOffset(panelRef.current) : null;
|
|
141
|
+
flushSettle();
|
|
142
|
+
const startOffset = liveOffset ?? offsetOf(stateRef.current.current);
|
|
143
|
+
const gesture = {
|
|
144
|
+
pointerId: event.pointerId,
|
|
145
|
+
startY: event.clientY,
|
|
146
|
+
startTime: performance.now(),
|
|
147
|
+
startOffset,
|
|
148
|
+
offset: startOffset,
|
|
149
|
+
committed: false,
|
|
150
|
+
};
|
|
151
|
+
gestureRef.current = gesture;
|
|
152
|
+
const stop = () => {
|
|
153
|
+
window.removeEventListener('pointermove', handleMove);
|
|
154
|
+
window.removeEventListener('pointerup', finish);
|
|
155
|
+
window.removeEventListener('pointercancel', finish);
|
|
156
|
+
gestureRef.current = null;
|
|
157
|
+
stopGestureRef.current = null;
|
|
158
|
+
};
|
|
159
|
+
const finish = (endEvent) => {
|
|
160
|
+
// A second finger releasing must not end the first finger's gesture.
|
|
161
|
+
if (endEvent.pointerId !== gesture.pointerId)
|
|
162
|
+
return;
|
|
163
|
+
stop();
|
|
164
|
+
const scrollEl = scrollRef.current;
|
|
165
|
+
if (scrollEl)
|
|
166
|
+
scrollEl.style.removeProperty('overflow');
|
|
167
|
+
if (!gesture.committed) {
|
|
168
|
+
const target = event.target;
|
|
169
|
+
if (target instanceof Element && target.closest('[data-sheet-handle]')) {
|
|
170
|
+
callbacksRef.current.onHandleTap();
|
|
171
|
+
}
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const distance = gesture.startY - endEvent.clientY;
|
|
175
|
+
const elapsed = Math.max(performance.now() - gesture.startTime, 1);
|
|
176
|
+
const velocity = Math.abs(distance) / elapsed;
|
|
177
|
+
const target = resolveTarget(gesture.offset, distance, velocity);
|
|
178
|
+
writeOffset(offsetOf(target), SHEET_TRANSITION);
|
|
179
|
+
scheduleSettle();
|
|
180
|
+
callbacksRef.current.onSnap(target);
|
|
181
|
+
};
|
|
182
|
+
const handleMove = (moveEvent) => {
|
|
183
|
+
if (moveEvent.pointerId !== gesture.pointerId)
|
|
184
|
+
return;
|
|
185
|
+
const delta = moveEvent.clientY - gesture.startY;
|
|
186
|
+
if (!gesture.committed) {
|
|
187
|
+
if (Math.abs(delta) < MOVE_TOLERANCE)
|
|
188
|
+
return;
|
|
189
|
+
if (!shouldDrag(event.target, delta > 0)) {
|
|
190
|
+
stop();
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
gesture.committed = true;
|
|
194
|
+
try {
|
|
195
|
+
panelRef.current?.setPointerCapture(gesture.pointerId);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
// Throws when the pointer is already gone; the window listeners
|
|
199
|
+
// below carry the gesture either way.
|
|
200
|
+
}
|
|
201
|
+
const scrollEl = scrollRef.current;
|
|
202
|
+
if (scrollEl)
|
|
203
|
+
scrollEl.style.overflow = 'hidden';
|
|
204
|
+
callbacksRef.current.onDragStart();
|
|
205
|
+
}
|
|
206
|
+
const limit = offsetOf(stateRef.current.snapPoints[0].name);
|
|
207
|
+
const raw = gesture.startOffset + delta;
|
|
208
|
+
const offset = raw < 0 ? -dampen(-raw) : raw > limit ? limit + dampen(raw - limit) : raw;
|
|
209
|
+
gesture.offset = offset;
|
|
210
|
+
writeOffset(offset, 'none');
|
|
211
|
+
};
|
|
212
|
+
window.addEventListener('pointermove', handleMove);
|
|
213
|
+
window.addEventListener('pointerup', finish);
|
|
214
|
+
window.addEventListener('pointercancel', finish);
|
|
215
|
+
stopGestureRef.current = stop;
|
|
216
|
+
}, [
|
|
217
|
+
flushSettle,
|
|
218
|
+
offsetOf,
|
|
219
|
+
panelRef,
|
|
220
|
+
resolveTarget,
|
|
221
|
+
scheduleSettle,
|
|
222
|
+
scrollRef,
|
|
223
|
+
shouldDrag,
|
|
224
|
+
writeOffset,
|
|
225
|
+
]);
|
|
226
|
+
return { onPointerDown };
|
|
227
|
+
}
|
package/package.json
CHANGED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
interface DragGestureConfig {
|
|
2
|
-
onDragStart: (clientY: number) => void;
|
|
3
|
-
onDragMove: (clientY: number) => void;
|
|
4
|
-
onDragEnd: () => void;
|
|
5
|
-
}
|
|
6
|
-
export declare const useDragGesture: ({ onDragStart, onDragMove, onDragEnd }: DragGestureConfig) => {
|
|
7
|
-
touchHandlers: {
|
|
8
|
-
onTouchStart: (event: TouchEvent) => void;
|
|
9
|
-
onTouchMove: (event: TouchEvent) => void;
|
|
10
|
-
onTouchEnd: () => void;
|
|
11
|
-
};
|
|
12
|
-
mouseHandlers: {
|
|
13
|
-
onMouseDown: (event: MouseEvent) => void;
|
|
14
|
-
onMouseMove: (event: MouseEvent) => void;
|
|
15
|
-
onMouseUp: () => void;
|
|
16
|
-
};
|
|
17
|
-
};
|
|
18
|
-
export {};
|
|
19
|
-
//# sourceMappingURL=useDragGesture.d.ts.map
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import { useCallback, useMemo, useRef } from 'react';
|
|
2
|
-
export const useDragGesture = ({ onDragStart, onDragMove, onDragEnd }) => {
|
|
3
|
-
const isDraggingRef = useRef(false);
|
|
4
|
-
const handleTouchStart = useCallback((event) => {
|
|
5
|
-
isDraggingRef.current = true;
|
|
6
|
-
onDragStart(event.touches[0].clientY);
|
|
7
|
-
}, [onDragStart]);
|
|
8
|
-
const handleTouchMove = useCallback((event) => {
|
|
9
|
-
if (!isDraggingRef.current)
|
|
10
|
-
return;
|
|
11
|
-
onDragMove(event.touches[0].clientY);
|
|
12
|
-
}, [onDragMove]);
|
|
13
|
-
const handleTouchEnd = useCallback(() => {
|
|
14
|
-
if (!isDraggingRef.current)
|
|
15
|
-
return;
|
|
16
|
-
isDraggingRef.current = false;
|
|
17
|
-
onDragEnd();
|
|
18
|
-
}, [onDragEnd]);
|
|
19
|
-
const handleMouseStart = useCallback((event) => {
|
|
20
|
-
isDraggingRef.current = true;
|
|
21
|
-
onDragStart(event.clientY);
|
|
22
|
-
}, [onDragStart]);
|
|
23
|
-
const handleMouseMove = useCallback((event) => {
|
|
24
|
-
if (!isDraggingRef.current)
|
|
25
|
-
return;
|
|
26
|
-
onDragMove(event.clientY);
|
|
27
|
-
}, [onDragMove]);
|
|
28
|
-
const handleMouseEnd = useCallback(() => {
|
|
29
|
-
if (!isDraggingRef.current)
|
|
30
|
-
return;
|
|
31
|
-
isDraggingRef.current = false;
|
|
32
|
-
onDragEnd();
|
|
33
|
-
}, [onDragEnd]);
|
|
34
|
-
const touchHandlers = useMemo(() => ({
|
|
35
|
-
onTouchStart: handleTouchStart,
|
|
36
|
-
onTouchMove: handleTouchMove,
|
|
37
|
-
onTouchEnd: handleTouchEnd,
|
|
38
|
-
}), [handleTouchStart, handleTouchMove, handleTouchEnd]);
|
|
39
|
-
const mouseHandlers = useMemo(() => ({
|
|
40
|
-
onMouseDown: handleMouseStart,
|
|
41
|
-
onMouseMove: handleMouseMove,
|
|
42
|
-
onMouseUp: handleMouseEnd,
|
|
43
|
-
}), [handleMouseStart, handleMouseMove, handleMouseEnd]);
|
|
44
|
-
return { touchHandlers, mouseHandlers };
|
|
45
|
-
};
|