@via-profit/ui-kit 0.7.0 → 0.8.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  Все заметные изменения пакета `@via-profit/ui-kit`. Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/), версии следуют [семантическому версионированию](https://semver.org/lang/ru/).
4
4
 
5
+ ## 0.8.0 — 2026-10-13
6
+
7
+ Drawer: закрытие свайпом.
8
+
9
+ ### Добавлено
10
+
11
+ - **Modal**: свойство `swipeToClose` у `drawer` — панель закрывается свайпом к своему краю. Она следует за пальцем, короткий свайп возвращает её на место, прокручиваемое содержимое сначала прокручивается. На стороне, обращённой к странице, появляется полоска-ручка, за которую панель перетаскивается мышью.
12
+
5
13
  ## 0.7.0 — 2026-10-09
6
14
 
7
15
  Swiper: дробный `slidesPerView` и `centered`, вертикальная прокрутка, затухание и бегущая лента.
@@ -13,6 +13,12 @@ export type DrawerInnerProps = ModalInnerProps & React.RefAttributes<HTMLDivElem
13
13
  * **Default:** `bottom`
14
14
  */
15
15
  readonly anchor: AnchorVariant;
16
+ /**
17
+ * Close the drawer by a swipe towards its edge\
18
+ * \
19
+ * **Default:** `false`
20
+ */
21
+ readonly swipeToClose?: boolean;
16
22
  };
17
23
  declare const _default: React.ForwardRefExoticComponent<Omit<DrawerInnerProps, "ref"> & React.RefAttributes<HTMLDivElement>>;
18
24
  export default _default;
@@ -18,6 +18,84 @@ const react_1 = __importDefault(require("react"));
18
18
  const styled_1 = __importDefault(require("@emotion/styled"));
19
19
  const react_2 = require("@emotion/react");
20
20
  const ModalInner_1 = __importDefault(require("../BaseModal/ModalInner"));
21
+ const useSwipeToClose_1 = __importDefault(require("./useSwipeToClose"));
22
+ /**
23
+ * The grip on the side facing the page: shows that the drawer can be swiped
24
+ * and lets a mouse drag it (elsewhere the mouse selects the text)
25
+ */
26
+ const DrawerHandle = styled_1.default.div `
27
+ position: absolute;
28
+ z-index: 1;
29
+ display: flex;
30
+ align-items: center;
31
+ justify-content: center;
32
+ cursor: grab;
33
+ touch-action: none;
34
+
35
+ &:active {
36
+ cursor: grabbing;
37
+ }
38
+
39
+ &::before {
40
+ content: '';
41
+ border-radius: 1em;
42
+ background-color: ${({ theme }) => theme.color.textPrimary.alpha(0.24).toString()};
43
+ }
44
+
45
+ ${({ $anchor }) => {
46
+ switch ($anchor) {
47
+ case 'top':
48
+ return (0, react_2.css) `
49
+ bottom: 0;
50
+ left: 50%;
51
+ transform: translateX(-50%);
52
+ width: 4em;
53
+ height: 1.25em;
54
+ &::before {
55
+ width: 2.5em;
56
+ height: 0.25em;
57
+ }
58
+ `;
59
+ case 'left':
60
+ return (0, react_2.css) `
61
+ right: 0;
62
+ top: 50%;
63
+ transform: translateY(-50%);
64
+ width: 1.25em;
65
+ height: 4em;
66
+ &::before {
67
+ width: 0.25em;
68
+ height: 2.5em;
69
+ }
70
+ `;
71
+ case 'right':
72
+ return (0, react_2.css) `
73
+ left: 0;
74
+ top: 50%;
75
+ transform: translateY(-50%);
76
+ width: 1.25em;
77
+ height: 4em;
78
+ &::before {
79
+ width: 0.25em;
80
+ height: 2.5em;
81
+ }
82
+ `;
83
+ case 'bottom':
84
+ default:
85
+ return (0, react_2.css) `
86
+ top: 0;
87
+ left: 50%;
88
+ transform: translateX(-50%);
89
+ width: 4em;
90
+ height: 1.25em;
91
+ &::before {
92
+ width: 2.5em;
93
+ height: 0.25em;
94
+ }
95
+ `;
96
+ }
97
+ }}
98
+ `;
21
99
  const StyledDrawerBottomInner = (0, styled_1.default)(ModalInner_1.default) `
22
100
  position: fixed;
23
101
  margin: 0;
@@ -131,7 +209,16 @@ const StyledDrawerLeftInner = (0, styled_1.default)(ModalInner_1.default) `
131
209
  `}
132
210
  `;
133
211
  const DrawerInner = (props, ref) => {
134
- const { children, isOpen, anchor, onRequestClose, dialogID } = props, nativeProps = __rest(props, ["children", "isOpen", "anchor", "onRequestClose", "dialogID"]);
212
+ const { children, isOpen, anchor, onRequestClose, dialogID, swipeToClose } = props, nativeProps = __rest(props, ["children", "isOpen", "anchor", "onRequestClose", "dialogID", "swipeToClose"]);
213
+ const elementRef = react_1.default.useRef(null);
214
+ react_1.default.useImperativeHandle(ref, () => elementRef.current);
215
+ const swipeHandlers = (0, useSwipeToClose_1.default)({
216
+ elementRef,
217
+ enabled: Boolean(swipeToClose),
218
+ isOpen,
219
+ anchor,
220
+ onClose: onRequestClose,
221
+ });
135
222
  const Element = react_1.default.useMemo(() => {
136
223
  switch (anchor) {
137
224
  case 'top':
@@ -145,6 +232,8 @@ const DrawerInner = (props, ref) => {
145
232
  return StyledDrawerBottomInner;
146
233
  }
147
234
  }, [anchor]);
148
- return (react_1.default.createElement(Element, Object.assign({ role: "dialog", "aria-modal": "true", isOpen: isOpen, id: `${dialogID}-description`, onRequestClose: onRequestClose }, nativeProps, { ref: ref }), children));
235
+ return (react_1.default.createElement(Element, Object.assign({ role: "dialog", "aria-modal": "true", isOpen: isOpen, id: `${dialogID}-description`, onRequestClose: onRequestClose }, nativeProps, swipeHandlers, { ref: elementRef }),
236
+ swipeToClose && react_1.default.createElement(DrawerHandle, { "$anchor": anchor, "data-drawer-handle": true, "aria-hidden": "true" }),
237
+ children));
149
238
  };
150
239
  exports.default = react_1.default.forwardRef(DrawerInner);
@@ -41,6 +41,14 @@ export interface DrawerProps extends Omit<BaseModalProps, 'overrides'> {
41
41
  * **Default**: `'Close'`
42
42
  */
43
43
  readonly closeButtonLabel?: string;
44
+ /**
45
+ * Close the drawer by a swipe towards its edge: down for `bottom`, left for `left` and so on.
46
+ * A short swipe returns the drawer back. The content scrolls first, the drawer follows
47
+ * the finger when the content is at its edge. A mouse drags the drawer by the handle\
48
+ * \
49
+ * **Default:** `false`
50
+ */
51
+ readonly swipeToClose?: boolean;
44
52
  /**
45
53
  * Overridable components map
46
54
  */
@@ -57,7 +57,7 @@ const DrawerFooter_1 = __importDefault(require("./DrawerFooter"));
57
57
  const ModalOverlay_1 = __importDefault(require("../BaseModal/ModalOverlay"));
58
58
  const useDialogID_1 = __importDefault(require("../BaseModal/useDialogID"));
59
59
  const Drawer = props => {
60
- const { children, onRequestClose, showCloseButton, header, closeButtonLabel, anchor, toolbar, footer, overrides } = props, otherProps = __rest(props, ["children", "onRequestClose", "showCloseButton", "header", "closeButtonLabel", "anchor", "toolbar", "footer", "overrides"]);
60
+ const { children, onRequestClose, showCloseButton, header, closeButtonLabel, anchor, toolbar, footer, overrides, swipeToClose } = props, otherProps = __rest(props, ["children", "onRequestClose", "showCloseButton", "header", "closeButtonLabel", "anchor", "toolbar", "footer", "overrides", "swipeToClose"]);
61
61
  const hasFooter = React.useMemo(() => typeof footer !== 'undefined' && footer !== null, [footer]);
62
62
  const hasHeader = React.useMemo(() => typeof header !== 'undefined' ||
63
63
  typeof toolbar !== 'undefined' ||
@@ -67,8 +67,8 @@ const Drawer = props => {
67
67
  // Created once: a component created during the render is a new type on every render,
68
68
  // so React would remount the whole drawer content (inputs lose the focus and the state)
69
69
  const Inner = React.useMemo(() => React.forwardRef(function Inner(innerProps, ref) {
70
- return (React.createElement(DrawerInner_1.default, Object.assign({ anchor: anchor, dialogID: dialogID, "aria-labelledby": hasTitle ? `${dialogID}-title` : undefined, ref: ref }, innerProps)));
71
- }), [anchor, dialogID, hasTitle]);
70
+ return (React.createElement(DrawerInner_1.default, Object.assign({ anchor: anchor, dialogID: dialogID, swipeToClose: swipeToClose, "aria-labelledby": hasTitle ? `${dialogID}-title` : undefined, ref: ref }, innerProps)));
71
+ }), [anchor, dialogID, hasTitle, swipeToClose]);
72
72
  const overridesMap = React.useMemo(() => ({
73
73
  Container: (overrides === null || overrides === void 0 ? void 0 : overrides.Container) || DrawerContainer_1.default,
74
74
  Header: (overrides === null || overrides === void 0 ? void 0 : overrides.Header) || DrawerHeader_1.default,
@@ -0,0 +1,30 @@
1
+ import React from 'react';
2
+ import type { AnchorVariant } from './DrawerInner';
3
+ export type UseSwipeToCloseOptions = {
4
+ /**
5
+ * The drawer element: it follows the finger
6
+ */
7
+ readonly elementRef: React.RefObject<HTMLDivElement | null>;
8
+ readonly enabled: boolean;
9
+ readonly isOpen: boolean;
10
+ readonly anchor: AnchorVariant;
11
+ readonly onClose: (event: React.PointerEvent<HTMLDivElement>) => void;
12
+ };
13
+ /**
14
+ * Closes the drawer by a swipe towards its edge.
15
+ *
16
+ * The pointer events move the drawer. A touch would be taken by the browser for the scroll
17
+ * (and the pointer cancelled), so the native `touchmove` is prevented while the drawer is dragged
18
+ */
19
+ declare const useSwipeToClose: (options: UseSwipeToCloseOptions) => {
20
+ onPointerDown?: undefined;
21
+ onPointerMove?: undefined;
22
+ onPointerUp?: undefined;
23
+ onPointerCancel?: undefined;
24
+ } | {
25
+ onPointerDown: (event: React.PointerEvent<HTMLDivElement>) => void;
26
+ onPointerMove: (event: React.PointerEvent<HTMLDivElement>) => void;
27
+ onPointerUp: (event: React.PointerEvent<HTMLDivElement>) => void;
28
+ onPointerCancel: (event: React.PointerEvent<HTMLDivElement>) => void;
29
+ };
30
+ export default useSwipeToClose;
@@ -0,0 +1,238 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const react_1 = __importDefault(require("react"));
7
+ // The finger moves this far before the gesture is recognized as a drag or a scroll
8
+ const SLOP = 6;
9
+ // The drawer is closed when dragged by this part of its size or thrown faster than this
10
+ const CLOSE_RATIO = 0.25;
11
+ const CLOSE_VELOCITY = 0.5; // px per ms
12
+ const RETURN_TRANSITION = 'transform 240ms ease-out';
13
+ const initialState = {
14
+ mode: 'idle',
15
+ pointerId: -1,
16
+ startX: 0,
17
+ startY: 0,
18
+ offset: 0,
19
+ lastOffset: 0,
20
+ lastTime: 0,
21
+ velocity: 0,
22
+ };
23
+ const isVertical = (anchor) => anchor === 'bottom' || anchor === 'top';
24
+ /**
25
+ * The movement of the finger towards the edge of the drawer: positive closes it
26
+ */
27
+ const getClosingDelta = (anchor, dx, dy) => {
28
+ switch (anchor) {
29
+ case 'top':
30
+ return -dy;
31
+ case 'left':
32
+ return -dx;
33
+ case 'right':
34
+ return dx;
35
+ case 'bottom':
36
+ default:
37
+ return dy;
38
+ }
39
+ };
40
+ const getTransform = (anchor, offset) => {
41
+ switch (anchor) {
42
+ case 'top':
43
+ return `translateY(${-offset}px)`;
44
+ case 'left':
45
+ return `translateX(${-offset}px)`;
46
+ case 'right':
47
+ return `translateX(${offset}px)`;
48
+ case 'bottom':
49
+ default:
50
+ return `translateY(${offset}px)`;
51
+ }
52
+ };
53
+ /**
54
+ * Whether an element between the target and the drawer can scroll the way the finger moves.
55
+ * Then the finger scrolls the content: the drawer is dragged only when the content is at its edge
56
+ */
57
+ const canScrollWithGesture = (target, root, anchor) => {
58
+ let element = target instanceof Element ? target : null;
59
+ while (element && element !== root) {
60
+ if (element instanceof HTMLElement) {
61
+ const style = window.getComputedStyle(element);
62
+ const overflow = isVertical(anchor) ? style.overflowY : style.overflowX;
63
+ const scrollable = overflow === 'auto' || overflow === 'scroll';
64
+ if (scrollable) {
65
+ const { scrollTop, scrollLeft, scrollHeight, scrollWidth, clientHeight, clientWidth } = element;
66
+ switch (anchor) {
67
+ case 'bottom':
68
+ if (scrollTop > 0) {
69
+ return true;
70
+ }
71
+ break;
72
+ case 'top':
73
+ if (scrollTop + clientHeight < scrollHeight - 1) {
74
+ return true;
75
+ }
76
+ break;
77
+ case 'left':
78
+ if (scrollLeft + clientWidth < scrollWidth - 1) {
79
+ return true;
80
+ }
81
+ break;
82
+ case 'right':
83
+ if (scrollLeft > 0) {
84
+ return true;
85
+ }
86
+ break;
87
+ default:
88
+ break;
89
+ }
90
+ }
91
+ }
92
+ element = element.parentElement;
93
+ }
94
+ return false;
95
+ };
96
+ /**
97
+ * Closes the drawer by a swipe towards its edge.
98
+ *
99
+ * The pointer events move the drawer. A touch would be taken by the browser for the scroll
100
+ * (and the pointer cancelled), so the native `touchmove` is prevented while the drawer is dragged
101
+ */
102
+ const useSwipeToClose = (options) => {
103
+ const { elementRef, enabled, isOpen, anchor, onClose } = options;
104
+ const stateRef = react_1.default.useRef(Object.assign({}, initialState));
105
+ const setOffset = react_1.default.useCallback((offset, transition) => {
106
+ const element = elementRef.current;
107
+ if (element) {
108
+ element.style.transition = transition;
109
+ element.style.transform = offset > 0 ? getTransform(anchor, offset) : '';
110
+ }
111
+ }, [anchor, elementRef]);
112
+ const restore = react_1.default.useCallback(() => {
113
+ const element = elementRef.current;
114
+ if (element) {
115
+ element.style.transition = '';
116
+ element.style.transform = '';
117
+ }
118
+ }, [elementRef]);
119
+ // A drawer closed by a swipe keeps its offset while it slides out: reset it for the next opening
120
+ react_1.default.useEffect(() => {
121
+ if (isOpen) {
122
+ stateRef.current = Object.assign({}, initialState);
123
+ restore();
124
+ }
125
+ }, [isOpen, restore]);
126
+ /**
127
+ * Decides on the first noticeable movement whether it drags the drawer or scrolls the content
128
+ */
129
+ const update = react_1.default.useCallback((clientX, clientY, target) => {
130
+ const state = stateRef.current;
131
+ const element = elementRef.current;
132
+ if (!element || (state.mode !== 'pending' && state.mode !== 'drag')) {
133
+ return;
134
+ }
135
+ const dx = clientX - state.startX;
136
+ const dy = clientY - state.startY;
137
+ const closing = getClosingDelta(anchor, dx, dy);
138
+ if (state.mode === 'pending') {
139
+ const main = isVertical(anchor) ? Math.abs(dy) : Math.abs(dx);
140
+ const cross = isVertical(anchor) ? Math.abs(dx) : Math.abs(dy);
141
+ if (main < SLOP && cross < SLOP) {
142
+ return;
143
+ }
144
+ const isDrag = closing > 0 && main >= cross && !canScrollWithGesture(target, element, anchor);
145
+ state.mode = isDrag ? 'drag' : 'scroll';
146
+ if (!isDrag) {
147
+ return;
148
+ }
149
+ // The drag starts from here, without a jump by the slop
150
+ state.startX = clientX;
151
+ state.startY = clientY;
152
+ state.lastTime = performance.now();
153
+ }
154
+ const offset = Math.max(0, getClosingDelta(anchor, clientX - state.startX, clientY - state.startY));
155
+ const now = performance.now();
156
+ const elapsed = now - state.lastTime;
157
+ if (elapsed > 0) {
158
+ state.velocity = (offset - state.lastOffset) / elapsed;
159
+ }
160
+ state.lastOffset = offset;
161
+ state.lastTime = now;
162
+ state.offset = offset;
163
+ setOffset(offset, 'none');
164
+ }, [anchor, elementRef, setOffset]);
165
+ // A non-passive listener: only it may stop the browser from scrolling under the drag
166
+ react_1.default.useEffect(() => {
167
+ const element = elementRef.current;
168
+ if (!enabled || !element) {
169
+ return undefined;
170
+ }
171
+ const handleTouchMove = (event) => {
172
+ const touch = event.touches[0];
173
+ if (touch) {
174
+ update(touch.clientX, touch.clientY, event.target);
175
+ }
176
+ if (stateRef.current.mode === 'drag' && event.cancelable) {
177
+ event.preventDefault();
178
+ }
179
+ };
180
+ element.addEventListener('touchmove', handleTouchMove, { passive: false });
181
+ return () => {
182
+ element.removeEventListener('touchmove', handleTouchMove);
183
+ };
184
+ }, [enabled, elementRef, update]);
185
+ const onPointerDown = react_1.default.useCallback((event) => {
186
+ if (!event.isPrimary || event.button !== 0) {
187
+ return;
188
+ }
189
+ // A mouse drags only by the handle: everywhere else it selects the text
190
+ const target = event.target instanceof Element ? event.target : null;
191
+ if (event.pointerType === 'mouse') {
192
+ if (!(target === null || target === void 0 ? void 0 : target.closest('[data-drawer-handle]'))) {
193
+ return;
194
+ }
195
+ event.preventDefault();
196
+ event.currentTarget.setPointerCapture(event.pointerId);
197
+ }
198
+ stateRef.current = Object.assign(Object.assign({}, initialState), { mode: 'pending', pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, lastTime: performance.now() });
199
+ }, []);
200
+ const onPointerMove = react_1.default.useCallback((event) => {
201
+ if (event.pointerId === stateRef.current.pointerId) {
202
+ update(event.clientX, event.clientY, event.target);
203
+ }
204
+ }, [update]);
205
+ const finish = react_1.default.useCallback((event, cancelled) => {
206
+ const state = stateRef.current;
207
+ if (event.pointerId !== state.pointerId) {
208
+ return;
209
+ }
210
+ const wasDragged = state.mode === 'drag';
211
+ const element = elementRef.current;
212
+ const size = element ? (isVertical(anchor) ? element.offsetHeight : element.offsetWidth) : 0;
213
+ const shouldClose = wasDragged &&
214
+ !cancelled &&
215
+ (state.offset > size * CLOSE_RATIO || state.velocity > CLOSE_VELOCITY);
216
+ stateRef.current = Object.assign({}, initialState);
217
+ if (!wasDragged) {
218
+ return;
219
+ }
220
+ if (shouldClose) {
221
+ // The offset is kept: the drawer slides out from where the finger left it.
222
+ // The own transitions of the drawer are back, the drag turned them off
223
+ if (element) {
224
+ element.style.transition = '';
225
+ }
226
+ onClose(event);
227
+ return;
228
+ }
229
+ setOffset(0, RETURN_TRANSITION);
230
+ }, [anchor, elementRef, onClose, setOffset]);
231
+ const onPointerUp = react_1.default.useCallback((event) => finish(event, false), [finish]);
232
+ const onPointerCancel = react_1.default.useCallback((event) => finish(event, true), [finish]);
233
+ if (!enabled) {
234
+ return {};
235
+ }
236
+ return { onPointerDown, onPointerMove, onPointerUp, onPointerCancel };
237
+ };
238
+ exports.default = useSwipeToClose;
@@ -13,6 +13,84 @@ import React from 'react';
13
13
  import styled from '@emotion/styled';
14
14
  import { css } from '@emotion/react';
15
15
  import ModalInner from '../BaseModal/ModalInner';
16
+ import useSwipeToClose from './useSwipeToClose';
17
+ /**
18
+ * The grip on the side facing the page: shows that the drawer can be swiped
19
+ * and lets a mouse drag it (elsewhere the mouse selects the text)
20
+ */
21
+ const DrawerHandle = styled.div `
22
+ position: absolute;
23
+ z-index: 1;
24
+ display: flex;
25
+ align-items: center;
26
+ justify-content: center;
27
+ cursor: grab;
28
+ touch-action: none;
29
+
30
+ &:active {
31
+ cursor: grabbing;
32
+ }
33
+
34
+ &::before {
35
+ content: '';
36
+ border-radius: 1em;
37
+ background-color: ${({ theme }) => theme.color.textPrimary.alpha(0.24).toString()};
38
+ }
39
+
40
+ ${({ $anchor }) => {
41
+ switch ($anchor) {
42
+ case 'top':
43
+ return css `
44
+ bottom: 0;
45
+ left: 50%;
46
+ transform: translateX(-50%);
47
+ width: 4em;
48
+ height: 1.25em;
49
+ &::before {
50
+ width: 2.5em;
51
+ height: 0.25em;
52
+ }
53
+ `;
54
+ case 'left':
55
+ return css `
56
+ right: 0;
57
+ top: 50%;
58
+ transform: translateY(-50%);
59
+ width: 1.25em;
60
+ height: 4em;
61
+ &::before {
62
+ width: 0.25em;
63
+ height: 2.5em;
64
+ }
65
+ `;
66
+ case 'right':
67
+ return css `
68
+ left: 0;
69
+ top: 50%;
70
+ transform: translateY(-50%);
71
+ width: 1.25em;
72
+ height: 4em;
73
+ &::before {
74
+ width: 0.25em;
75
+ height: 2.5em;
76
+ }
77
+ `;
78
+ case 'bottom':
79
+ default:
80
+ return css `
81
+ top: 0;
82
+ left: 50%;
83
+ transform: translateX(-50%);
84
+ width: 4em;
85
+ height: 1.25em;
86
+ &::before {
87
+ width: 2.5em;
88
+ height: 0.25em;
89
+ }
90
+ `;
91
+ }
92
+ }}
93
+ `;
16
94
  const StyledDrawerBottomInner = styled(ModalInner) `
17
95
  position: fixed;
18
96
  margin: 0;
@@ -126,7 +204,16 @@ const StyledDrawerLeftInner = styled(ModalInner) `
126
204
  `}
127
205
  `;
128
206
  const DrawerInner = (props, ref) => {
129
- const { children, isOpen, anchor, onRequestClose, dialogID } = props, nativeProps = __rest(props, ["children", "isOpen", "anchor", "onRequestClose", "dialogID"]);
207
+ const { children, isOpen, anchor, onRequestClose, dialogID, swipeToClose } = props, nativeProps = __rest(props, ["children", "isOpen", "anchor", "onRequestClose", "dialogID", "swipeToClose"]);
208
+ const elementRef = React.useRef(null);
209
+ React.useImperativeHandle(ref, () => elementRef.current);
210
+ const swipeHandlers = useSwipeToClose({
211
+ elementRef,
212
+ enabled: Boolean(swipeToClose),
213
+ isOpen,
214
+ anchor,
215
+ onClose: onRequestClose,
216
+ });
130
217
  const Element = React.useMemo(() => {
131
218
  switch (anchor) {
132
219
  case 'top':
@@ -140,6 +227,8 @@ const DrawerInner = (props, ref) => {
140
227
  return StyledDrawerBottomInner;
141
228
  }
142
229
  }, [anchor]);
143
- return (React.createElement(Element, Object.assign({ role: "dialog", "aria-modal": "true", isOpen: isOpen, id: `${dialogID}-description`, onRequestClose: onRequestClose }, nativeProps, { ref: ref }), children));
230
+ return (React.createElement(Element, Object.assign({ role: "dialog", "aria-modal": "true", isOpen: isOpen, id: `${dialogID}-description`, onRequestClose: onRequestClose }, nativeProps, swipeHandlers, { ref: elementRef }),
231
+ swipeToClose && React.createElement(DrawerHandle, { "$anchor": anchor, "data-drawer-handle": true, "aria-hidden": "true" }),
232
+ children));
144
233
  };
145
234
  export default React.forwardRef(DrawerInner);
@@ -19,7 +19,7 @@ import Footer from './DrawerFooter';
19
19
  import Overlay from '../BaseModal/ModalOverlay';
20
20
  import useDialogID from '../BaseModal/useDialogID';
21
21
  const Drawer = props => {
22
- const { children, onRequestClose, showCloseButton, header, closeButtonLabel, anchor, toolbar, footer, overrides } = props, otherProps = __rest(props, ["children", "onRequestClose", "showCloseButton", "header", "closeButtonLabel", "anchor", "toolbar", "footer", "overrides"]);
22
+ const { children, onRequestClose, showCloseButton, header, closeButtonLabel, anchor, toolbar, footer, overrides, swipeToClose } = props, otherProps = __rest(props, ["children", "onRequestClose", "showCloseButton", "header", "closeButtonLabel", "anchor", "toolbar", "footer", "overrides", "swipeToClose"]);
23
23
  const hasFooter = React.useMemo(() => typeof footer !== 'undefined' && footer !== null, [footer]);
24
24
  const hasHeader = React.useMemo(() => typeof header !== 'undefined' ||
25
25
  typeof toolbar !== 'undefined' ||
@@ -29,8 +29,8 @@ const Drawer = props => {
29
29
  // Created once: a component created during the render is a new type on every render,
30
30
  // so React would remount the whole drawer content (inputs lose the focus and the state)
31
31
  const Inner = React.useMemo(() => React.forwardRef(function Inner(innerProps, ref) {
32
- return (React.createElement(DrawerInner, Object.assign({ anchor: anchor, dialogID: dialogID, "aria-labelledby": hasTitle ? `${dialogID}-title` : undefined, ref: ref }, innerProps)));
33
- }), [anchor, dialogID, hasTitle]);
32
+ return (React.createElement(DrawerInner, Object.assign({ anchor: anchor, dialogID: dialogID, swipeToClose: swipeToClose, "aria-labelledby": hasTitle ? `${dialogID}-title` : undefined, ref: ref }, innerProps)));
33
+ }), [anchor, dialogID, hasTitle, swipeToClose]);
34
34
  const overridesMap = React.useMemo(() => ({
35
35
  Container: (overrides === null || overrides === void 0 ? void 0 : overrides.Container) || Container,
36
36
  Header: (overrides === null || overrides === void 0 ? void 0 : overrides.Header) || Header,
@@ -0,0 +1,233 @@
1
+ import React from 'react';
2
+ // The finger moves this far before the gesture is recognized as a drag or a scroll
3
+ const SLOP = 6;
4
+ // The drawer is closed when dragged by this part of its size or thrown faster than this
5
+ const CLOSE_RATIO = 0.25;
6
+ const CLOSE_VELOCITY = 0.5; // px per ms
7
+ const RETURN_TRANSITION = 'transform 240ms ease-out';
8
+ const initialState = {
9
+ mode: 'idle',
10
+ pointerId: -1,
11
+ startX: 0,
12
+ startY: 0,
13
+ offset: 0,
14
+ lastOffset: 0,
15
+ lastTime: 0,
16
+ velocity: 0,
17
+ };
18
+ const isVertical = (anchor) => anchor === 'bottom' || anchor === 'top';
19
+ /**
20
+ * The movement of the finger towards the edge of the drawer: positive closes it
21
+ */
22
+ const getClosingDelta = (anchor, dx, dy) => {
23
+ switch (anchor) {
24
+ case 'top':
25
+ return -dy;
26
+ case 'left':
27
+ return -dx;
28
+ case 'right':
29
+ return dx;
30
+ case 'bottom':
31
+ default:
32
+ return dy;
33
+ }
34
+ };
35
+ const getTransform = (anchor, offset) => {
36
+ switch (anchor) {
37
+ case 'top':
38
+ return `translateY(${-offset}px)`;
39
+ case 'left':
40
+ return `translateX(${-offset}px)`;
41
+ case 'right':
42
+ return `translateX(${offset}px)`;
43
+ case 'bottom':
44
+ default:
45
+ return `translateY(${offset}px)`;
46
+ }
47
+ };
48
+ /**
49
+ * Whether an element between the target and the drawer can scroll the way the finger moves.
50
+ * Then the finger scrolls the content: the drawer is dragged only when the content is at its edge
51
+ */
52
+ const canScrollWithGesture = (target, root, anchor) => {
53
+ let element = target instanceof Element ? target : null;
54
+ while (element && element !== root) {
55
+ if (element instanceof HTMLElement) {
56
+ const style = window.getComputedStyle(element);
57
+ const overflow = isVertical(anchor) ? style.overflowY : style.overflowX;
58
+ const scrollable = overflow === 'auto' || overflow === 'scroll';
59
+ if (scrollable) {
60
+ const { scrollTop, scrollLeft, scrollHeight, scrollWidth, clientHeight, clientWidth } = element;
61
+ switch (anchor) {
62
+ case 'bottom':
63
+ if (scrollTop > 0) {
64
+ return true;
65
+ }
66
+ break;
67
+ case 'top':
68
+ if (scrollTop + clientHeight < scrollHeight - 1) {
69
+ return true;
70
+ }
71
+ break;
72
+ case 'left':
73
+ if (scrollLeft + clientWidth < scrollWidth - 1) {
74
+ return true;
75
+ }
76
+ break;
77
+ case 'right':
78
+ if (scrollLeft > 0) {
79
+ return true;
80
+ }
81
+ break;
82
+ default:
83
+ break;
84
+ }
85
+ }
86
+ }
87
+ element = element.parentElement;
88
+ }
89
+ return false;
90
+ };
91
+ /**
92
+ * Closes the drawer by a swipe towards its edge.
93
+ *
94
+ * The pointer events move the drawer. A touch would be taken by the browser for the scroll
95
+ * (and the pointer cancelled), so the native `touchmove` is prevented while the drawer is dragged
96
+ */
97
+ const useSwipeToClose = (options) => {
98
+ const { elementRef, enabled, isOpen, anchor, onClose } = options;
99
+ const stateRef = React.useRef(Object.assign({}, initialState));
100
+ const setOffset = React.useCallback((offset, transition) => {
101
+ const element = elementRef.current;
102
+ if (element) {
103
+ element.style.transition = transition;
104
+ element.style.transform = offset > 0 ? getTransform(anchor, offset) : '';
105
+ }
106
+ }, [anchor, elementRef]);
107
+ const restore = React.useCallback(() => {
108
+ const element = elementRef.current;
109
+ if (element) {
110
+ element.style.transition = '';
111
+ element.style.transform = '';
112
+ }
113
+ }, [elementRef]);
114
+ // A drawer closed by a swipe keeps its offset while it slides out: reset it for the next opening
115
+ React.useEffect(() => {
116
+ if (isOpen) {
117
+ stateRef.current = Object.assign({}, initialState);
118
+ restore();
119
+ }
120
+ }, [isOpen, restore]);
121
+ /**
122
+ * Decides on the first noticeable movement whether it drags the drawer or scrolls the content
123
+ */
124
+ const update = React.useCallback((clientX, clientY, target) => {
125
+ const state = stateRef.current;
126
+ const element = elementRef.current;
127
+ if (!element || (state.mode !== 'pending' && state.mode !== 'drag')) {
128
+ return;
129
+ }
130
+ const dx = clientX - state.startX;
131
+ const dy = clientY - state.startY;
132
+ const closing = getClosingDelta(anchor, dx, dy);
133
+ if (state.mode === 'pending') {
134
+ const main = isVertical(anchor) ? Math.abs(dy) : Math.abs(dx);
135
+ const cross = isVertical(anchor) ? Math.abs(dx) : Math.abs(dy);
136
+ if (main < SLOP && cross < SLOP) {
137
+ return;
138
+ }
139
+ const isDrag = closing > 0 && main >= cross && !canScrollWithGesture(target, element, anchor);
140
+ state.mode = isDrag ? 'drag' : 'scroll';
141
+ if (!isDrag) {
142
+ return;
143
+ }
144
+ // The drag starts from here, without a jump by the slop
145
+ state.startX = clientX;
146
+ state.startY = clientY;
147
+ state.lastTime = performance.now();
148
+ }
149
+ const offset = Math.max(0, getClosingDelta(anchor, clientX - state.startX, clientY - state.startY));
150
+ const now = performance.now();
151
+ const elapsed = now - state.lastTime;
152
+ if (elapsed > 0) {
153
+ state.velocity = (offset - state.lastOffset) / elapsed;
154
+ }
155
+ state.lastOffset = offset;
156
+ state.lastTime = now;
157
+ state.offset = offset;
158
+ setOffset(offset, 'none');
159
+ }, [anchor, elementRef, setOffset]);
160
+ // A non-passive listener: only it may stop the browser from scrolling under the drag
161
+ React.useEffect(() => {
162
+ const element = elementRef.current;
163
+ if (!enabled || !element) {
164
+ return undefined;
165
+ }
166
+ const handleTouchMove = (event) => {
167
+ const touch = event.touches[0];
168
+ if (touch) {
169
+ update(touch.clientX, touch.clientY, event.target);
170
+ }
171
+ if (stateRef.current.mode === 'drag' && event.cancelable) {
172
+ event.preventDefault();
173
+ }
174
+ };
175
+ element.addEventListener('touchmove', handleTouchMove, { passive: false });
176
+ return () => {
177
+ element.removeEventListener('touchmove', handleTouchMove);
178
+ };
179
+ }, [enabled, elementRef, update]);
180
+ const onPointerDown = React.useCallback((event) => {
181
+ if (!event.isPrimary || event.button !== 0) {
182
+ return;
183
+ }
184
+ // A mouse drags only by the handle: everywhere else it selects the text
185
+ const target = event.target instanceof Element ? event.target : null;
186
+ if (event.pointerType === 'mouse') {
187
+ if (!(target === null || target === void 0 ? void 0 : target.closest('[data-drawer-handle]'))) {
188
+ return;
189
+ }
190
+ event.preventDefault();
191
+ event.currentTarget.setPointerCapture(event.pointerId);
192
+ }
193
+ stateRef.current = Object.assign(Object.assign({}, initialState), { mode: 'pending', pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, lastTime: performance.now() });
194
+ }, []);
195
+ const onPointerMove = React.useCallback((event) => {
196
+ if (event.pointerId === stateRef.current.pointerId) {
197
+ update(event.clientX, event.clientY, event.target);
198
+ }
199
+ }, [update]);
200
+ const finish = React.useCallback((event, cancelled) => {
201
+ const state = stateRef.current;
202
+ if (event.pointerId !== state.pointerId) {
203
+ return;
204
+ }
205
+ const wasDragged = state.mode === 'drag';
206
+ const element = elementRef.current;
207
+ const size = element ? (isVertical(anchor) ? element.offsetHeight : element.offsetWidth) : 0;
208
+ const shouldClose = wasDragged &&
209
+ !cancelled &&
210
+ (state.offset > size * CLOSE_RATIO || state.velocity > CLOSE_VELOCITY);
211
+ stateRef.current = Object.assign({}, initialState);
212
+ if (!wasDragged) {
213
+ return;
214
+ }
215
+ if (shouldClose) {
216
+ // The offset is kept: the drawer slides out from where the finger left it.
217
+ // The own transitions of the drawer are back, the drag turned them off
218
+ if (element) {
219
+ element.style.transition = '';
220
+ }
221
+ onClose(event);
222
+ return;
223
+ }
224
+ setOffset(0, RETURN_TRANSITION);
225
+ }, [anchor, elementRef, onClose, setOffset]);
226
+ const onPointerUp = React.useCallback((event) => finish(event, false), [finish]);
227
+ const onPointerCancel = React.useCallback((event) => finish(event, true), [finish]);
228
+ if (!enabled) {
229
+ return {};
230
+ }
231
+ return { onPointerDown, onPointerMove, onPointerUp, onPointerCancel };
232
+ };
233
+ export default useSwipeToClose;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@via-profit/ui-kit",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "UI Kit",
5
5
  "repository": {
6
6
  "type": "git",
@@ -770,6 +770,11 @@
770
770
  "import": "./esm/Modal/Drawer/index.js",
771
771
  "require": "./Modal/Drawer/index.js"
772
772
  },
773
+ "./Modal/Drawer/useSwipeToClose": {
774
+ "types": "./Modal/Drawer/useSwipeToClose.d.ts",
775
+ "import": "./esm/Modal/Drawer/useSwipeToClose.js",
776
+ "require": "./Modal/Drawer/useSwipeToClose.js"
777
+ },
773
778
  "./Modal/MessageBox/MessageBoxContainer": {
774
779
  "types": "./Modal/MessageBox/MessageBoxContainer.d.ts",
775
780
  "import": "./esm/Modal/MessageBox/MessageBoxContainer.js",