@expofp/ui 3.29.0 → 3.31.0

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.
@@ -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
- const Autocomplete = ({ placeholder, options, value, showClear = false, onChange, renderTarget = document.body, noOptionsLabel, ariaLabel, clearLabel, getOptionKey = (option) => option.value, }) => {
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 [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 });
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 && showOptionsDropdown) {
34
- const rect = refAutocomplete.current.getBoundingClientRect();
35
- setDropdownPosition({
36
- top: rect.bottom + window.scrollY + 4,
37
- left: rect.left + window.scrollX,
38
- width: rect.width,
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
- }, [showOptionsDropdown]);
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: [_jsx("div", { ref: refAutocomplete, className: classNames('efp-autocomplete', {
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
- ? getOptionId(focusOptionIndex)
172
- : 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) => {
173
- e.preventDefault();
174
- clearValue(true);
175
- } }))] }) }), showOptionsDropdown &&
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 { useDragGesture } from '../../shared/hooks/useDragGesture';
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
- const [panelHeight, setPanelHeight] = useState(() => sizes[size]);
32
- const [overlayHeight, setOverlayHeight] = useState(() => sizes[size]);
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
- // Mirror frequently-changing values into refs so the drag callbacks can stay
42
- // identity-stable and the listener effect subscribes only when mobile/disableDrag change.
43
- const sizesRef = useRef(sizes);
44
- const panelHeightRef = useRef(panelHeight);
45
- const overlayHeightRef = useRef(overlayHeight);
46
- const draggingRef = useRef(dragging);
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
- useEffect(() => {
49
- sizesRef.current = sizes;
50
- panelHeightRef.current = panelHeight;
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
- const height = sizesRef.current[newSize];
57
- setPanelHeight(height);
58
- setOverlayHeight(height);
59
- onChangeSize?.(newSize);
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
- height: open || isAnimating ? overlayHeight : 0,
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
- }, [mobile, open, overlayHeight, isAnimating, forceFull]);
74
- const setSizeByValue = useCallback((heightVal) => {
75
- const sizes = sizesRef.current;
76
- const sizesArray = [sizes.small, sizes.medium, sizes.full].sort();
77
- if (heightVal >= sizes.full) {
78
- setSize('full');
79
- return;
80
- }
81
- if (heightVal <= sizes.small) {
82
- setSize('small');
83
- return;
84
- }
85
- const currentHeight = panelHeightRef.current || sizes.medium;
86
- const targetSize = heightVal > currentHeight
87
- ? (sizesArray.find((val) => val + GAP >= heightVal) ?? sizes.full)
88
- : (sizesArray.reverse().find((val) => val - GAP <= heightVal) ?? sizes.small);
89
- if (targetSize === sizes.full) {
90
- setSize('full');
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
- if (draggingRef.current)
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 { touchHandlers, mouseHandlers } = useDragGesture({
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
- onDragMove: handleDragMove,
147
- onDragEnd: handleDragEnd,
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
- useEffect(() => {
164
- if (!mobile || disableDrag)
165
- return;
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(size);
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
- setOverlayHeight(0);
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, size, setSize, mobile]);
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--full': size === 'full',
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", ref: triggerRef }), _jsxs("div", { className: "efp-overlay__scroll", ref: scrollRef, onScroll: () => {
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@expofp/ui",
3
- "version": "3.29.0",
3
+ "version": "3.31.0",
4
4
  "type": "module",
5
5
  "description": "ExpoFP SDK internal: shared pure React UI components",
6
6
  "homepage": "https://developer.expofp.com/",
@@ -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
- };