reshaped 4.2.0 → 4.3.0-canary.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.
Files changed (42) hide show
  1. package/dist/bundle.css +1 -1
  2. package/dist/bundle.js +2 -2
  3. package/dist/components/Autocomplete/Autocomplete.js +1 -1
  4. package/dist/components/ContextMenu/tests/ContextMenu.stories.d.ts +7 -0
  5. package/dist/components/ContextMenu/tests/ContextMenu.stories.js +99 -1
  6. package/dist/components/DropdownMenu/DropdownMenu.js +1 -1
  7. package/dist/components/Flyout/Flyout.module.css +1 -1
  8. package/dist/components/Flyout/Flyout.types.d.ts +1 -0
  9. package/dist/components/Flyout/FlyoutContent.js +3 -2
  10. package/dist/components/Flyout/FlyoutControlled.js +23 -4
  11. package/dist/components/Flyout/tests/Flyout.stories.d.ts +7 -0
  12. package/dist/components/Flyout/tests/Flyout.stories.js +142 -1
  13. package/dist/components/Modal/Modal.js +13 -171
  14. package/dist/components/Modal/Modal.module.css +1 -1
  15. package/dist/components/Modal/Modal.types.d.ts +2 -1
  16. package/dist/components/Modal/useModalDrag.d.ts +21 -0
  17. package/dist/components/Modal/useModalDrag.js +256 -0
  18. package/dist/components/Overlay/Overlay.js +5 -4
  19. package/dist/components/Overlay/Overlay.module.css +1 -1
  20. package/dist/components/ScrollArea/ScrollArea.js +29 -16
  21. package/dist/components/ScrollArea/ScrollArea.module.css +1 -1
  22. package/dist/components/ScrollArea/ScrollArea.types.d.ts +1 -1
  23. package/dist/components/Select/SelectCustomControlled.js +1 -1
  24. package/dist/components/Select/tests/Select.stories.js +12 -1
  25. package/dist/components/_private/Freeze/Freeze.d.ts +4 -0
  26. package/dist/components/_private/Freeze/Freeze.js +46 -0
  27. package/dist/components/_private/Freeze/Freeze.module.css +1 -0
  28. package/dist/components/_private/Freeze/Freeze.types.d.ts +14 -0
  29. package/dist/components/_private/Freeze/Freeze.types.js +1 -0
  30. package/dist/components/_private/Freeze/index.d.ts +1 -0
  31. package/dist/components/_private/Freeze/index.js +1 -0
  32. package/dist/components/_private/Freeze/tests/Freeze.stories.d.ts +7 -0
  33. package/dist/components/_private/Freeze/tests/Freeze.stories.js +88 -0
  34. package/dist/hooks/_internal/useSingletonHotkeys.js +4 -0
  35. package/dist/hooks/tests/useHotkeys.stories.d.ts +3 -0
  36. package/dist/hooks/tests/useHotkeys.stories.js +18 -1
  37. package/dist/hooks/tests/useOnClickOutside.stories.d.ts +6 -0
  38. package/dist/hooks/tests/useOnClickOutside.stories.js +39 -1
  39. package/dist/hooks/useOnClickOutside.js +12 -12
  40. package/dist/utilities/animation.d.ts +17 -0
  41. package/dist/utilities/animation.js +73 -0
  42. package/package.json +3 -3
@@ -128,7 +128,7 @@ const Autocomplete = (props) => {
128
128
  highlightedId,
129
129
  setHighlightedId,
130
130
  }), [highlightedId, handleItemClick]);
131
- return (_jsx(AutocompleteContext.Provider, { value: contextValue, children: _jsxs(DropdownMenu, { position: "bottom", width: "trigger", triggerType: "focus", trapFocusMode: false, active: isDropdownActive, onClose: handleClose, onOpen: handleOpen, onAfterOpen: onAfterOpen, onAfterClose: onAfterClose, containerRef: containerRef, fallbackAdjustLayout: fallbackAdjustLayout, fallbackMinHeight: fallbackMinHeight, contentMaxHeight: contentMaxHeight, contentZIndex: contentZIndex, disableHideAnimation: true, instanceRef: instanceRef, children: [_jsx(DropdownMenu.Trigger, { children: ({ ref, ...attributes }) => (_jsx(TextField, { ...textFieldProps, name: name, onChange: handleChange, focused: isDropdownActive, attributes: {
131
+ return (_jsx(AutocompleteContext.Provider, { value: contextValue, children: _jsxs(DropdownMenu, { position: "bottom", width: "trigger", triggerType: "focus", trapFocusMode: false, active: isDropdownActive, onClose: handleClose, onOpen: handleOpen, onAfterOpen: onAfterOpen, onAfterClose: onAfterClose, containerRef: containerRef, fallbackAdjustLayout: fallbackAdjustLayout, fallbackMinHeight: fallbackMinHeight, contentMaxHeight: contentMaxHeight, contentZIndex: contentZIndex, instanceRef: instanceRef, children: [_jsx(DropdownMenu.Trigger, { children: ({ ref, ...attributes }) => (_jsx(TextField, { ...textFieldProps, name: name, onChange: handleChange, focused: isDropdownActive, attributes: {
132
132
  ...textFieldProps.attributes,
133
133
  // Ignoring the type check since TS can't infer the correct html element type
134
134
  ref: ref,
@@ -28,4 +28,11 @@ export declare const handlers: StoryObj<{
28
28
  export declare const triggerRef: StoryObj<{
29
29
  handleOpen: ReturnType<typeof fn>;
30
30
  }>;
31
+ export declare const sameMenuRightClick: StoryObj<{
32
+ handleOpen: ReturnType<typeof fn>;
33
+ handleClose: ReturnType<typeof fn>;
34
+ }>;
35
+ export declare const insideFlyoutRightClick: StoryObj<{
36
+ handlePopoverClose: ReturnType<typeof fn>;
37
+ }>;
31
38
  export declare const menu: StoryObj;
@@ -3,10 +3,12 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.menu = exports.triggerRef = exports.handlers = exports.base = void 0;
6
+ exports.menu = exports.insideFlyoutRightClick = exports.sameMenuRightClick = exports.triggerRef = exports.handlers = exports.base = void 0;
7
7
  const react_1 = __importDefault(require("react"));
8
8
  const test_1 = require("storybook/test");
9
+ const Button_1 = __importDefault(require("../../../components/Button"));
9
10
  const ContextMenu_1 = __importDefault(require("../../../components/ContextMenu"));
11
+ const Popover_1 = __importDefault(require("../../../components/Popover"));
10
12
  const View_1 = __importDefault(require("../../../components/View"));
11
13
  const helpers_1 = require("../../../utilities/helpers");
12
14
  const storybook_1 = require("../../../utilities/storybook");
@@ -91,6 +93,102 @@ exports.triggerRef = {
91
93
  </>);
92
94
  },
93
95
  };
96
+ exports.sameMenuRightClick = {
97
+ name: "test: right click on the same context menu",
98
+ args: {
99
+ handleOpen: (0, test_1.fn)(),
100
+ handleClose: (0, test_1.fn)(),
101
+ },
102
+ render: (args) => {
103
+ const triggerRef = react_1.default.useRef(null);
104
+ return (<View_1.default gap={4}>
105
+ <ContextMenu_1.default onOpen={args.handleOpen} onClose={args.handleClose}>
106
+ <View_1.default height="60px" backgroundColor="neutral-faded" borderRadius="medium" attributes={{ "data-testid": "children" }}/>
107
+
108
+ <ContextMenu_1.default.Content>
109
+ <ContextMenu_1.default.Item>Children item</ContextMenu_1.default.Item>
110
+ </ContextMenu_1.default.Content>
111
+ </ContextMenu_1.default>
112
+
113
+ <View_1.default height="60px" backgroundColor="neutral-faded" borderRadius="medium" attributes={{ ref: triggerRef, "data-testid": "trigger-ref" }}/>
114
+
115
+ <ContextMenu_1.default triggerRef={triggerRef} onOpen={args.handleOpen} onClose={args.handleClose}>
116
+ <ContextMenu_1.default.Content>
117
+ <ContextMenu_1.default.Item>Trigger ref item</ContextMenu_1.default.Item>
118
+ </ContextMenu_1.default.Content>
119
+ </ContextMenu_1.default>
120
+ </View_1.default>);
121
+ },
122
+ play: async ({ canvasElement, args }) => {
123
+ const canvas = (0, test_1.within)(canvasElement.ownerDocument.body);
124
+ for (const [testId, text] of [
125
+ ["children", "Children item"],
126
+ ["trigger-ref", "Trigger ref item"],
127
+ ]) {
128
+ const trigger = canvas.getByTestId(testId);
129
+ args.handleOpen.mockClear();
130
+ args.handleClose.mockClear();
131
+ const { left, top } = trigger.getBoundingClientRect();
132
+ await test_1.userEvent.pointer({
133
+ keys: "[MouseRight]",
134
+ target: trigger,
135
+ coords: { clientX: left + 10, clientY: top + 10 },
136
+ });
137
+ (0, test_1.expect)(args.handleOpen).toHaveBeenCalledTimes(1);
138
+ const menu = canvas.getByRole("menu");
139
+ const initialLeft = menu.getBoundingClientRect().left;
140
+ // Wait for the open animation to finish
141
+ await (0, helpers_1.sleep)(500);
142
+ await test_1.userEvent.pointer({
143
+ keys: "[MouseRight]",
144
+ target: trigger,
145
+ coords: { clientX: left + 210, clientY: top + 10 },
146
+ });
147
+ (0, test_1.expect)(args.handleClose).toHaveBeenCalledTimes(1);
148
+ (0, test_1.expect)(args.handleClose).toHaveBeenCalledWith({ reason: "outside-click" });
149
+ (0, test_1.expect)(args.handleOpen).toHaveBeenCalledTimes(2);
150
+ await (0, helpers_1.sleep)(500);
151
+ (0, test_1.expect)(canvas.getByText(text)).toBeInTheDocument();
152
+ (0, test_1.expect)(canvas.getByRole("menu").getBoundingClientRect().left).toBe(initialLeft + 200);
153
+ await test_1.userEvent.click(trigger);
154
+ (0, test_1.expect)(args.handleClose).toHaveBeenCalledTimes(2);
155
+ await (0, test_1.waitFor)(() => {
156
+ (0, test_1.expect)(canvas.queryByText(text)).not.toBeInTheDocument();
157
+ }, {
158
+ timeout: 1000,
159
+ });
160
+ }
161
+ },
162
+ };
163
+ exports.insideFlyoutRightClick = {
164
+ name: "test: right click inside another flyout",
165
+ args: {
166
+ handlePopoverClose: (0, test_1.fn)(),
167
+ },
168
+ render: (args) => (<Popover_1.default onClose={args.handlePopoverClose}>
169
+ <Popover_1.default.Trigger>
170
+ {(attributes) => <Button_1.default attributes={attributes}>Open popover</Button_1.default>}
171
+ </Popover_1.default.Trigger>
172
+ <Popover_1.default.Content>
173
+ <ContextMenu_1.default>
174
+ <View_1.default width="200px" height="60px" backgroundColor="neutral-faded" borderRadius="medium" attributes={{ "data-testid": "popover-area" }}/>
175
+
176
+ <ContextMenu_1.default.Content>
177
+ <ContextMenu_1.default.Item>Context menu item</ContextMenu_1.default.Item>
178
+ </ContextMenu_1.default.Content>
179
+ </ContextMenu_1.default>
180
+ </Popover_1.default.Content>
181
+ </Popover_1.default>),
182
+ play: async ({ canvasElement, args }) => {
183
+ const canvas = (0, test_1.within)(canvasElement.ownerDocument.body);
184
+ await test_1.userEvent.click(canvas.getByText("Open popover"));
185
+ await (0, helpers_1.sleep)(500);
186
+ await test_1.userEvent.pointer({ keys: "[MouseRight]", target: canvas.getByTestId("popover-area") });
187
+ (0, test_1.expect)(canvas.getByText("Context menu item")).toBeInTheDocument();
188
+ (0, test_1.expect)(canvas.getByTestId("popover-area")).toBeInTheDocument();
189
+ (0, test_1.expect)(args.handlePopoverClose).not.toHaveBeenCalled();
190
+ },
191
+ };
94
192
  const menuData = [
95
193
  { label: "Action 1" },
96
194
  { label: "Action 2" },
@@ -15,7 +15,7 @@ const DropdownMenuSubContext = React.createContext(null);
15
15
  const DropdownMenuSubTriggerContext = React.createContext(false);
16
16
  const DropdownMenu = (props) => {
17
17
  const { children, position = "bottom-start", triggerType = "click", trapFocusMode = "action-menu", size, ...popoverProps } = props;
18
- return (_jsx(DropdownMenuProvider, { size: size, children: _jsx(Popover, { ...popoverProps, position: position, padding: 1, trapFocusMode: trapFocusMode, triggerType: triggerType, disableHideAnimation: triggerType !== "hover", children: children }) }));
18
+ return (_jsx(DropdownMenuProvider, { size: size, children: _jsx(Popover, { ...popoverProps, position: position, padding: 1, trapFocusMode: trapFocusMode, triggerType: triggerType, children: children }) }));
19
19
  };
20
20
  export const DropdownMenuContent = (props) => {
21
21
  const { children, attributes, className } = props;
@@ -1 +1 @@
1
- .positioner{--rs-flyout-max-h:100%;--rs-flyout-max-w:100%;--rs-flyout-z-index:var(--rs-z-index-absolute);--rs-flyout-origin-x:50%;--rs-flyout-origin-y:50%;position:absolute;isolation:isolate;z-index:var(--rs-flyout-z-index);display:flex;flex-direction:column;pointer-events:none}.positioner.--hover{pointer-events:all}.positioner.--hover-disabled,.positioner.--hover-disabled .content{pointer-events:none}.content{pointer-events:all;opacity:0;transform:scale(.92) translateY(0);transform-origin:var(--rs-flyout-origin-x) var(--rs-flyout-origin-y);outline:none;height:100%;max-width:var(--rs-flyout-max-w);max-height:var(--rs-flyout-max-h);backface-visibility:hidden;flex-grow:1;display:flex;flex-direction:column;transition:1ms var(--rs-easing-accelerate)}[data-rs-keyboard] .content:focus{box-shadow:var(--rs-outline-shadow)}.positioner.--width-trigger .content{transform:scale(1) translateY(var(--rs-unit-x2))}.positioner.--position-top,.positioner.--position-top-end,.positioner.--position-top-start{--rs-flyout-origin-y:100%}.positioner.--position-bottom,.positioner.--position-bottom-end,.positioner.--position-bottom-start{--rs-flyout-origin-y:0%}.positioner.--position-bottom-start,.positioner.--position-top-start{--rs-flyout-origin-x:0%}.positioner.--position-bottom-end,.positioner.--position-start,.positioner.--position-start-bottom,.positioner.--position-start-top,.positioner.--position-top-end{--rs-flyout-origin-x:100%}.positioner.--position-end,.positioner.--position-end-bottom,.positioner.--position-end-top{--rs-flyout-origin-x:0%}.positioner.--position-end-top,.positioner.--position-start-top{--rs-flyout-origin-y:0%}.positioner.--position-end-bottom,.positioner.--position-start-bottom{--rs-flyout-origin-y:100%}.positioner.--visible .content{opacity:1;transform:scale(1) translateY(0)}.positioner.--animated .content{transition-duration:var(--rs-duration-rapid);transition-property:opacity,transform}.positioner.--animated.--visible .content{transition-duration:var(--rs-duration-fast);transition-timing-function:var(--rs-easing-decelerate)}
1
+ .positioner{--rs-flyout-max-h:100%;--rs-flyout-max-w:100%;--rs-flyout-z-index:var(--rs-z-index-absolute);--rs-flyout-origin-x:50%;--rs-flyout-origin-y:50%;position:absolute;isolation:isolate;z-index:var(--rs-flyout-z-index);display:flex;flex-direction:column;pointer-events:none}.positioner.--hover{pointer-events:all}.positioner.--hover-disabled,.positioner.--hover-disabled .content{pointer-events:none}.content{pointer-events:all;opacity:0;transform:scale(.92) translateY(0);transform-origin:var(--rs-flyout-origin-x) var(--rs-flyout-origin-y);outline:none;height:100%;max-width:var(--rs-flyout-max-w);max-height:var(--rs-flyout-max-h);backface-visibility:hidden;flex-grow:1;display:flex;flex-direction:column;transition:1ms var(--rs-easing-accelerate)}[data-rs-keyboard] .content:focus{box-shadow:var(--rs-outline-shadow)}.positioner.--width-trigger .content{transform:scale(1) translateY(var(--rs-unit-x2))}.positioner.--position-top,.positioner.--position-top-end,.positioner.--position-top-start{--rs-flyout-origin-y:100%}.positioner.--position-bottom,.positioner.--position-bottom-end,.positioner.--position-bottom-start{--rs-flyout-origin-y:0%}.positioner.--position-bottom-start,.positioner.--position-top-start{--rs-flyout-origin-x:0%}.positioner.--position-bottom-end,.positioner.--position-start,.positioner.--position-start-bottom,.positioner.--position-start-top,.positioner.--position-top-end{--rs-flyout-origin-x:100%}.positioner.--position-end,.positioner.--position-end-bottom,.positioner.--position-end-top{--rs-flyout-origin-x:0%}.positioner.--position-end-top,.positioner.--position-start-top{--rs-flyout-origin-y:0%}.positioner.--position-end-bottom,.positioner.--position-start-bottom{--rs-flyout-origin-y:100%}.positioner.--visible .content{opacity:1;transform:scale(1) translateY(0);pointer-events:all}.positioner.--animated .content{transition-duration:var(--rs-duration-rapid);transition-property:opacity,transform}.positioner.--animated.--visible .content{transition-duration:var(--rs-duration-fast);transition-timing-function:var(--rs-easing-decelerate)}
@@ -180,6 +180,7 @@ export type ContentProps = {
180
180
  };
181
181
  export type ContextProps = {
182
182
  id: string;
183
+ active: boolean;
183
184
  flyout: UseFlyoutData;
184
185
  width?: Width;
185
186
  triggerElRef?: React.RefObject<HTMLButtonElement | null>;
@@ -2,6 +2,7 @@
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import React from "react";
4
4
  import { classNames } from "@reshaped/utilities";
5
+ import Freeze from "../../components/_private/Freeze";
5
6
  import Portal from "../../components/_private/Portal";
6
7
  import useIsomorphicLayoutEffect from "../../hooks/useIsomorphicLayoutEffect";
7
8
  import ScrollArea from "../ScrollArea";
@@ -10,7 +11,7 @@ import cooldown from "./utilities/cooldown";
10
11
  import s from "./Flyout.module.css";
11
12
  const FlyoutContent = (props) => {
12
13
  const { children, className, attributes, colorMode } = props;
13
- const { flyout, id, triggerElRef, flyoutElRef, handleTransitionEnd, triggerType, handleContentMouseEnter, handleMouseLeave, handleContentMouseDown, handleContentMouseUp, contentClassName, contentAttributes, scrollableClassName, scrollableAttributes, contentMaxHeight, contentMaxWidth, contentZIndex, trapFocusMode, disableContentHover, autoFocus, width, isSubmenu, } = useFlyoutContext();
14
+ const { active, flyout, id, triggerElRef, flyoutElRef, handleTransitionEnd, triggerType, handleContentMouseEnter, handleMouseLeave, handleContentMouseDown, handleContentMouseUp, contentClassName, contentAttributes, scrollableClassName, scrollableAttributes, contentMaxHeight, contentMaxWidth, contentZIndex, trapFocusMode, disableContentHover, autoFocus, width, isSubmenu, } = useFlyoutContext();
14
15
  const { status, position } = flyout;
15
16
  const [mounted, setMounted] = React.useState(false);
16
17
  const shadowRootRef = React.useRef(null);
@@ -48,7 +49,7 @@ const FlyoutContent = (props) => {
48
49
  "--rs-flyout-max-h": contentMaxHeight,
49
50
  "--rs-flyout-max-w": contentMaxWidth,
50
51
  "--rs-flyout-z-index": contentZIndex,
51
- }, ref: flyoutElRef, onTransitionEnd: handleTransitionEnd, onMouseEnter: triggerType === "hover" ? handleContentMouseEnter : undefined, onMouseLeave: triggerType === "hover" ? handleMouseLeave : undefined, onMouseDown: handleContentMouseDown, onTouchStart: handleContentMouseDown, onMouseUp: handleContentMouseUp, onTouchEnd: handleContentMouseUp, children: _jsx("div", { role: role, ...contentAttributes, ...attributes, id: id, tabIndex: !autoFocus ? -1 : undefined, "aria-modal": role === "dialog" ? true : undefined, style: { ...contentAttributes?.style, ...attributes?.style }, className: contentClassNames, children: _jsx(ScrollArea, { overscrollBehavior: "contain", scrollableAttributes: scrollableAttributes, scrollableClassName: scrollableClassName, children: children }) }) }) }));
52
+ }, ref: flyoutElRef, onTransitionEnd: handleTransitionEnd, onMouseEnter: triggerType === "hover" ? handleContentMouseEnter : undefined, onMouseLeave: triggerType === "hover" ? handleMouseLeave : undefined, onMouseDown: handleContentMouseDown, onTouchStart: handleContentMouseDown, onMouseUp: handleContentMouseUp, onTouchEnd: handleContentMouseUp, children: _jsx("div", { role: role, ...contentAttributes, ...attributes, id: id, tabIndex: !autoFocus ? -1 : undefined, "aria-modal": role === "dialog" ? true : undefined, style: { ...contentAttributes?.style, ...attributes?.style }, className: contentClassNames, children: _jsx(ScrollArea, { overscrollBehavior: "contain", scrollableAttributes: scrollableAttributes, scrollableClassName: scrollableClassName, children: _jsx(Freeze, { frozen: !active, children: children }) }) }) }) }));
52
53
  return (_jsx(Portal, { targetRef: shadowRootRef.current ? shadowRootRef : undefined, colorMode: colorMode, children: content }));
53
54
  };
54
55
  FlyoutContent.displayName = "Flyout.Content";
@@ -271,6 +271,25 @@ const FlyoutControlled = (props) => {
271
271
  if (status === "rendered")
272
272
  show();
273
273
  }, [status, show]);
274
+ // Content can be reopened at new coordinates without closing in between
275
+ useIsomorphicLayoutEffect(() => {
276
+ if (!originCoordinates || !isRendered)
277
+ return;
278
+ updatePosition();
279
+ // oxlint-disable-next-line react-hooks/exhaustive-deps
280
+ }, [originCoordinates?.x, originCoordinates?.y]);
281
+ /**
282
+ * Closing before the open transition starts cancels it instead of reversing,
283
+ * so there is no transitionend to wait for
284
+ */
285
+ useIsomorphicLayoutEffect(() => {
286
+ if (status !== "hidden")
287
+ return;
288
+ if (flyoutElRef.current?.firstElementChild?.getAnimations?.().length)
289
+ return;
290
+ remove();
291
+ onAfterCloseRef.current?.();
292
+ }, [status, remove, onAfterCloseRef]);
274
293
  /**
275
294
  * Handle focus trap
276
295
  *
@@ -301,9 +320,8 @@ const FlyoutControlled = (props) => {
301
320
  });
302
321
  }, [status, triggerType, trapFocusMode, autoFocus]);
303
322
  React.useEffect(() => {
304
- if (!disableHideAnimation && status !== "hidden")
305
- return;
306
- if (disableHideAnimation && isRendered)
323
+ // Release when the hide animation starts or right away when the content is removed without it
324
+ if (status !== "hidden" && isRendered)
307
325
  return;
308
326
  if (trapFocusRef.current?.trapped) {
309
327
  /* Locking the popover to not open it again on trigger focus */
@@ -318,7 +336,7 @@ const FlyoutControlled = (props) => {
318
336
  trapFocusRef.current.release({ withoutFocusReturn: !shouldReturnFocusRef.current });
319
337
  shouldReturnFocusRef.current = true;
320
338
  }
321
- }, [status, isRendered, triggerType, disableHideAnimation]);
339
+ }, [status, isRendered, triggerType]);
322
340
  /**
323
341
  * Clean up safe polygon tracking on unmount or when flyout closes
324
342
  */
@@ -362,6 +380,7 @@ const FlyoutControlled = (props) => {
362
380
  });
363
381
  return (_jsx(Provider, { value: {
364
382
  id,
383
+ active: !!active,
365
384
  flyout,
366
385
  width,
367
386
  triggerElRef,
@@ -75,6 +75,13 @@ export declare const disableHideAnimation: {
75
75
  name: string;
76
76
  render: () => React.JSX.Element;
77
77
  };
78
+ export declare const testFrozenContentOnClose: StoryObj<{
79
+ handleAfterClose: ReturnType<typeof fn>;
80
+ }>;
81
+ export declare const testCloseRightAfterOpen: StoryObj<{
82
+ handleAfterClose: ReturnType<typeof fn>;
83
+ }>;
84
+ export declare const testFocusReturnWithoutTransitions: StoryObj;
78
85
  export declare const disabled: StoryObj<{
79
86
  handleOpen: ReturnType<typeof fn>;
80
87
  }>;
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.testChangeSize = exports.testWithoutFocusable = exports.testScopedTheming = exports.testDynamicBounds = exports.testInsideModal = exports.testInsideScrollable = exports.testInsideSticky = exports.testInsideFixed = exports.contentAttributes = exports.instanceRef = exports.initialFocusRef = exports.positionRef = exports.containerRef = exports.disabled = exports.disableHideAnimation = exports.disableCloseOnOutsideClick = exports.disableContentHover = exports.contentMaxHeight = exports.contentZIndex = exports.contentShift = exports.contentGap = exports.width = exports.originCoordinates = exports.fallbackAdjustLayoutSize = exports.fallbackAdjustLayoutShift = exports.fallbackMinHeight = exports.fallbackAdjustLayout = exports.positionFallbacks = exports.modes = exports.activeFalse = exports.active = exports.defaultActive = exports.position = void 0;
6
+ exports.testChangeSize = exports.testWithoutFocusable = exports.testScopedTheming = exports.testDynamicBounds = exports.testInsideModal = exports.testInsideScrollable = exports.testInsideSticky = exports.testInsideFixed = exports.contentAttributes = exports.instanceRef = exports.initialFocusRef = exports.positionRef = exports.containerRef = exports.disabled = exports.testFocusReturnWithoutTransitions = exports.testCloseRightAfterOpen = exports.testFrozenContentOnClose = exports.disableHideAnimation = exports.disableCloseOnOutsideClick = exports.disableContentHover = exports.contentMaxHeight = exports.contentZIndex = exports.contentShift = exports.contentGap = exports.width = exports.originCoordinates = exports.fallbackAdjustLayoutSize = exports.fallbackAdjustLayoutShift = exports.fallbackMinHeight = exports.fallbackAdjustLayout = exports.positionFallbacks = exports.modes = exports.activeFalse = exports.active = exports.defaultActive = exports.position = void 0;
7
7
  const react_1 = __importDefault(require("react"));
8
8
  const test_1 = require("storybook/test");
9
9
  const Button_1 = __importDefault(require("../../../components/Button"));
@@ -448,6 +448,147 @@ exports.disableHideAnimation = {
448
448
  name: "disableHideAnimation",
449
449
  render: () => <Demo disableHideAnimation defaultActive/>,
450
450
  };
451
+ exports.testFrozenContentOnClose = {
452
+ name: "test: content doesn't change while closing",
453
+ args: {
454
+ handleAfterClose: (0, test_1.fn)(),
455
+ },
456
+ render: (args) => {
457
+ const [active, setActive] = react_1.default.useState(false);
458
+ const [value, setValue] = react_1.default.useState("First");
459
+ return (<Flyout_1.default active={active} onOpen={() => setActive(true)} onClose={() => setActive(false)} onAfterClose={args.handleAfterClose}>
460
+ <Flyout_1.default.Trigger>
461
+ {(attributes) => <Button_1.default attributes={attributes}>{value}</Button_1.default>}
462
+ </Flyout_1.default.Trigger>
463
+ <Flyout_1.default.Content>
464
+ <Content>
465
+ <div data-testid="selected-value">Selected: {value}</div>
466
+ <Button_1.default onClick={() => {
467
+ setValue("Second");
468
+ setActive(false);
469
+ }}>
470
+ Select second
471
+ </Button_1.default>
472
+ </Content>
473
+ </Flyout_1.default.Content>
474
+ </Flyout_1.default>);
475
+ },
476
+ play: async ({ canvasElement, args }) => {
477
+ const canvas = (0, test_1.within)(canvasElement.ownerDocument.body);
478
+ // Transitions are disabled for the first frames after the color mode gets applied
479
+ await (0, test_1.waitFor)(() => {
480
+ (0, test_1.expect)(document.documentElement).not.toHaveAttribute("data-rs-no-transition");
481
+ });
482
+ await test_1.userEvent.click(canvas.getByRole("button", { name: "First" }));
483
+ await (0, test_1.waitFor)(() => {
484
+ (0, test_1.expect)(canvas.getByTestId("selected-value")).toBeVisible();
485
+ });
486
+ // Record the content state after every DOM update since the hide animation might end before click resolves
487
+ const snapshots = [];
488
+ const observer = new MutationObserver(() => {
489
+ const el = canvasElement.ownerDocument.querySelector("[data-testid=selected-value]");
490
+ if (!el)
491
+ return;
492
+ snapshots.push({ text: el.textContent, visible: el.checkVisibility() });
493
+ });
494
+ observer.observe(canvasElement.ownerDocument.body, {
495
+ subtree: true,
496
+ childList: true,
497
+ attributes: true,
498
+ characterData: true,
499
+ });
500
+ await test_1.userEvent.click(canvas.getByText("Select second"));
501
+ await (0, test_1.waitFor)(() => {
502
+ (0, test_1.expect)(canvas.queryByTestId("selected-value")).not.toBeInTheDocument();
503
+ });
504
+ observer.disconnect();
505
+ // Trigger is updated right away while the content keeps its last state during the hide animation
506
+ (0, test_1.expect)(canvas.getAllByRole("button")[0]).toHaveTextContent("Second");
507
+ (0, test_1.expect)(snapshots.length).toBeGreaterThan(0);
508
+ snapshots.forEach((snapshot) => {
509
+ (0, test_1.expect)(snapshot).toEqual({ text: "Selected: First", visible: true });
510
+ });
511
+ (0, test_1.expect)(args.handleAfterClose).toHaveBeenCalledTimes(1);
512
+ },
513
+ };
514
+ exports.testCloseRightAfterOpen = {
515
+ name: "test: close right after opening",
516
+ args: {
517
+ handleAfterClose: (0, test_1.fn)(),
518
+ },
519
+ render: (args) => {
520
+ const [active, setActive] = react_1.default.useState(false);
521
+ const [closeOnOpen, setCloseOnOpen] = react_1.default.useState(false);
522
+ // Closing in the same frame, before the open transition starts
523
+ react_1.default.useEffect(() => {
524
+ if (active && closeOnOpen)
525
+ setActive(false);
526
+ }, [active, closeOnOpen]);
527
+ return (<Flyout_1.default active={active} onAfterClose={args.handleAfterClose}>
528
+ <Flyout_1.default.Trigger>
529
+ {(attributes) => (<Button_1.default attributes={attributes} onClick={() => {
530
+ setCloseOnOpen(true);
531
+ setActive(true);
532
+ }}>
533
+ Trigger
534
+ </Button_1.default>)}
535
+ </Flyout_1.default.Trigger>
536
+ <Flyout_1.default.Content>
537
+ <Content />
538
+ </Flyout_1.default.Content>
539
+ </Flyout_1.default>);
540
+ },
541
+ play: async ({ canvasElement, args }) => {
542
+ const canvas = (0, test_1.within)(canvasElement.ownerDocument.body);
543
+ // Transitions are disabled for the first frames after the color mode gets applied
544
+ await (0, test_1.waitFor)(() => {
545
+ (0, test_1.expect)(document.documentElement).not.toHaveAttribute("data-rs-no-transition");
546
+ });
547
+ await test_1.userEvent.click(canvas.getByRole("button", { name: "Trigger" }));
548
+ await (0, test_1.waitFor)(() => {
549
+ (0, test_1.expect)(canvas.queryByText("Content")).not.toBeInTheDocument();
550
+ (0, test_1.expect)(args.handleAfterClose).toHaveBeenCalledTimes(1);
551
+ });
552
+ },
553
+ };
554
+ exports.testFocusReturnWithoutTransitions = {
555
+ name: "test: focus returns to the trigger with transitions disabled",
556
+ render: () => (<Flyout_1.default>
557
+ <Flyout_1.default.Trigger>
558
+ {(attributes) => <Button_1.default attributes={attributes}>Trigger</Button_1.default>}
559
+ </Flyout_1.default.Trigger>
560
+ <Flyout_1.default.Content>
561
+ <Content>
562
+ <Button_1.default onClick={() => { }}>Inside</Button_1.default>
563
+ </Content>
564
+ </Flyout_1.default.Content>
565
+ </Flyout_1.default>),
566
+ play: async ({ canvasElement }) => {
567
+ const canvas = (0, test_1.within)(canvasElement.ownerDocument.body);
568
+ const trigger = canvas.getByRole("button", { name: "Trigger" });
569
+ // Wait for the provider to enable transitions after mount before disabling them again
570
+ await (0, test_1.waitFor)(() => {
571
+ (0, test_1.expect)(document.documentElement).not.toHaveAttribute("data-rs-no-transition");
572
+ });
573
+ // Same as prefers-reduced-motion, content is removed without the hide animation
574
+ document.documentElement.setAttribute("data-rs-no-transition", "true");
575
+ try {
576
+ trigger.focus();
577
+ await test_1.userEvent.keyboard("{Enter}");
578
+ await (0, test_1.waitFor)(() => {
579
+ (0, test_1.expect)(canvas.getByRole("dialog")).toContainElement(document.activeElement);
580
+ });
581
+ await test_1.userEvent.keyboard("{Escape}");
582
+ await (0, test_1.waitFor)(() => {
583
+ (0, test_1.expect)(canvas.queryByRole("dialog")).not.toBeInTheDocument();
584
+ });
585
+ (0, test_1.expect)(trigger).toHaveFocus();
586
+ }
587
+ finally {
588
+ document.documentElement.removeAttribute("data-rs-no-transition");
589
+ }
590
+ },
591
+ };
451
592
  exports.disabled = {
452
593
  name: "disabled",
453
594
  args: {
@@ -1,20 +1,15 @@
1
1
  "use client";
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
- import { useCallback, createContext, useContext, useEffect, useMemo, useState, useRef, } from "react";
3
+ import { createContext, useContext, useEffect, useMemo, useState, useRef } from "react";
4
4
  import { classNames } from "@reshaped/utilities";
5
- import { enableScroll, disableScroll } from "@reshaped/utilities/internal";
6
5
  import Overlay from "../../components/Overlay";
7
6
  import Text from "../../components/Text";
8
7
  import useElementId from "../../hooks/useElementId";
9
- import useHandlerRef from "../../hooks/useHandlerRef";
10
8
  import useResponsiveClientValue from "../../hooks/useResponsiveClientValue";
11
9
  import { responsiveClassNames, responsiveVariables } from "../../utilities/props";
12
10
  import { resolveMixin } from "../../styles/mixin";
11
+ import useModalDrag from "./useModalDrag";
13
12
  import s from "./Modal.module.css";
14
- const DRAG_THRESHOLD = 32;
15
- const DRAG_OPPOSITE_THRESHOLD = 100;
16
- const DRAG_EDGE_BOUNDARY = 32;
17
- const DRAG_SCROLL_LOCK_THRESHOLD = 8;
18
13
  const Context = createContext({
19
14
  id: "",
20
15
  titleMounted: false,
@@ -43,39 +38,25 @@ export const ModalSubtitle = (props) => {
43
38
  };
44
39
  const Modal = (props) => {
45
40
  const { children, onClose, onAfterClose, onAfterOpen, active, size, padding = 4, position = "center", overflow, transparentOverlay, blurredOverlay, ariaLabel, autoFocus = true, disableSwipeGesture, disableCloseOnOutsideClick, containerRef, contained, overlayClassName, className, attributes, } = props;
46
- const onCloseRef = useHandlerRef(onClose);
47
41
  const id = useElementId();
48
42
  const clientPosition = useResponsiveClientValue(position);
49
43
  const [titleMounted, setTitleMounted] = useState(false);
50
44
  const [subtitleMounted, setSubtitleMounted] = useState(false);
51
- const [dragging, setDragging] = useState(false);
52
45
  const internalRootRef = useRef(null);
53
46
  const rootRef = attributes?.ref || internalRootRef;
54
- const dragStartCoordinatesRef = useRef({ x: 0, y: 0 });
55
- const dragLastCoordinateRef = useRef(0);
56
- const dragDistanceRef = useRef(0);
57
- const dragFrameRef = useRef(null);
58
- const dragPendingDistanceRef = useRef(0);
59
- const dragSizeRef = useRef(1);
60
47
  const overlayRef = useRef(null);
61
- const dragDirectionRef = useRef(0);
62
- const dragShouldCloseRef = useRef(false);
63
48
  const mixinStyles = resolveMixin({ padding });
64
49
  const shouldBeContained = containerRef && contained !== false;
65
- const setDragStyles = useCallback((distance) => {
66
- const rootEl = rootRef.current;
67
- if (!rootEl)
68
- return;
69
- const dragOffset = Math.abs(distance) < DRAG_THRESHOLD
70
- ? 0
71
- : distance + DRAG_THRESHOLD * (clientPosition === "start" ? 1 : -1);
72
- rootEl.style.setProperty("--rs-modal-drag", `${dragOffset}px`);
73
- if (!transparentOverlay && overlayRef.current) {
74
- const progress = Math.min(1, Math.abs(distance) / dragSizeRef.current);
75
- const opacity = Math.max(0, 1 - progress * 0.5);
76
- overlayRef.current.setOpacity(opacity);
77
- }
78
- }, [clientPosition, overlayRef, transparentOverlay, rootRef]);
50
+ const { dragging, handleTouchStart, handleTransitionEnd } = useModalDrag({
51
+ position: clientPosition,
52
+ rootRef,
53
+ overlayRef,
54
+ active,
55
+ containerRef,
56
+ transparentOverlay,
57
+ disabled: disableSwipeGesture,
58
+ onClose,
59
+ });
79
60
  const value = useMemo(() => ({
80
61
  titleMounted,
81
62
  setTitleMounted,
@@ -83,147 +64,8 @@ const Modal = (props) => {
83
64
  setSubtitleMounted,
84
65
  id,
85
66
  }), [id, subtitleMounted, titleMounted]);
86
- const resetDragData = useCallback(() => {
87
- dragDistanceRef.current = 0;
88
- dragPendingDistanceRef.current = 0;
89
- dragStartCoordinatesRef.current = { x: 0, y: 0 };
90
- dragLastCoordinateRef.current = 0;
91
- dragDirectionRef.current = 0;
92
- dragShouldCloseRef.current = false;
93
- setDragStyles(0);
94
- }, [setDragStyles]);
95
- const unlockDragScroll = useCallback(() => {
96
- rootRef.current?.style.removeProperty("overflow");
97
- }, [rootRef]);
98
- const lockDragScroll = useCallback(() => {
99
- rootRef.current?.style.setProperty("overflow", "hidden");
100
- }, [rootRef]);
101
- const handleDragStart = (e) => {
102
- if (disableSwipeGesture)
103
- return;
104
- dragShouldCloseRef.current = false;
105
- // Prevent swipe to close from happening when user is working with text selection
106
- if (window.getSelection()?.toString())
107
- return;
108
- let currentEl = e.target;
109
- const rootEl = rootRef.current;
110
- while (currentEl && (currentEl === rootEl || rootEl?.contains(currentEl))) {
111
- // Prioritize scrolling over modal swiping
112
- if (currentEl.scrollTop !== 0 || currentEl.scrollLeft !== 0)
113
- return;
114
- // Start dragging only when starting on static elements
115
- if (currentEl.matches("input,textarea"))
116
- return;
117
- currentEl = currentEl ? currentEl.parentElement : null;
118
- }
119
- // Prevent the drag handling when browser is trying to navigate to a previous page
120
- if (clientPosition === "start" && e.targetTouches[0].clientX < DRAG_EDGE_BOUNDARY)
121
- return;
122
- if (rootEl) {
123
- const isInline = ["start", "end"].includes(clientPosition);
124
- dragSizeRef.current = Math.max(1, isInline ? rootEl.clientWidth : rootEl.clientHeight);
125
- }
126
- if (containerRef?.current)
127
- disableScroll();
128
- setDragging(true);
129
- };
130
- // Once modal is closed - reset its drag data
131
- const handleTransitionEnd = (e) => {
132
- if (active)
133
- return;
134
- if (e.propertyName !== "transform")
135
- return;
136
- if (e.currentTarget !== e.target)
137
- return;
138
- resetDragData();
139
- unlockDragScroll();
140
- };
141
- useEffect(() => {
142
- if (!dragging)
143
- return;
144
- const handleDragEnd = () => {
145
- const shouldClose = clientPosition === "start" ? dragDirectionRef.current < 0 : dragDirectionRef.current > 0;
146
- dragShouldCloseRef.current =
147
- Math.abs(dragDistanceRef.current) > DRAG_THRESHOLD && shouldClose;
148
- if (containerRef?.current)
149
- enableScroll();
150
- if (!dragShouldCloseRef.current)
151
- unlockDragScroll();
152
- setDragging(false);
153
- if (dragShouldCloseRef.current) {
154
- onCloseRef.current?.({ reason: "drag" });
155
- }
156
- else {
157
- resetDragData();
158
- }
159
- };
160
- const handleDrag = (e) => {
161
- if (!dragging || clientPosition === "center")
162
- return;
163
- if (rootRef.current?.scrollTop !== 0 || rootRef.current?.scrollLeft !== 0)
164
- return;
165
- const target = e.targetTouches[0];
166
- const coordinate = { x: target.clientX, y: target.clientY };
167
- const key = clientPosition === "bottom" ? "y" : "x";
168
- const oppositeKey = clientPosition === "bottom" ? "x" : "y";
169
- // Save the initial coordinates
170
- if (!dragStartCoordinatesRef.current[key]) {
171
- dragStartCoordinatesRef.current = coordinate;
172
- dragLastCoordinateRef.current = coordinate[key];
173
- }
174
- const next = Math.abs(coordinate[key] - dragStartCoordinatesRef.current[key]);
175
- const nextPerpendicular = Math.abs(coordinate[oppositeKey] - dragStartCoordinatesRef.current[oppositeKey]);
176
- // For start/end drawers - ignore the swiping
177
- // If user is scrolling vertically more than swiping
178
- if (position !== "bottom" &&
179
- (next < nextPerpendicular || nextPerpendicular > DRAG_OPPOSITE_THRESHOLD)) {
180
- dragLastCoordinateRef.current = coordinate[key];
181
- return;
182
- }
183
- dragDirectionRef.current = coordinate[key] - dragLastCoordinateRef.current;
184
- dragLastCoordinateRef.current = coordinate[key];
185
- const isClosingDirection = clientPosition === "start" ? dragDirectionRef.current < 0 : dragDirectionRef.current > 0;
186
- if (next > DRAG_SCROLL_LOCK_THRESHOLD && isClosingDirection) {
187
- lockDragScroll();
188
- }
189
- dragDistanceRef.current =
190
- clientPosition === "start"
191
- ? Math.min(0, dragDistanceRef.current + dragDirectionRef.current)
192
- : Math.max(0, dragDistanceRef.current + dragDirectionRef.current);
193
- dragPendingDistanceRef.current = dragDistanceRef.current;
194
- if (dragFrameRef.current === null) {
195
- dragFrameRef.current = window.requestAnimationFrame(() => {
196
- setDragStyles(dragPendingDistanceRef.current);
197
- dragFrameRef.current = null;
198
- });
199
- }
200
- };
201
- document.addEventListener("touchmove", handleDrag, { passive: true });
202
- document.addEventListener("touchend", handleDragEnd, { passive: true });
203
- return () => {
204
- if (dragFrameRef.current !== null) {
205
- window.cancelAnimationFrame(dragFrameRef.current);
206
- dragFrameRef.current = null;
207
- }
208
- if (!dragShouldCloseRef.current)
209
- unlockDragScroll();
210
- document.removeEventListener("touchmove", handleDrag);
211
- document.removeEventListener("touchend", handleDragEnd);
212
- };
213
- }, [
214
- dragging,
215
- clientPosition,
216
- onCloseRef,
217
- position,
218
- rootRef,
219
- containerRef,
220
- setDragStyles,
221
- resetDragData,
222
- lockDragScroll,
223
- unlockDragScroll,
224
- ]);
225
67
  return (_jsx(Overlay, { instanceRef: overlayRef, onClose: onClose, onAfterClose: onAfterClose, onAfterOpen: onAfterOpen, disableCloseOnClick: disableCloseOnOutsideClick, active: active, transparent: transparentOverlay, blurred: blurredOverlay, overflow: clientPosition === "center" ? "auto" : "hidden", className: overlayClassName, contained: contained, containerRef: containerRef, attributes: {
226
- onTouchStart: handleDragStart,
68
+ onTouchStart: handleTouchStart,
227
69
  }, children: ({ active }) => {
228
70
  const rootClassNames = classNames(s.root, className, active && s["--active"], dragging && s["--dragging"], overflow && s[`--overflow-${overflow}`], shouldBeContained && s["--contained"], responsiveClassNames(s, "--position", position), mixinStyles.classNames);
229
71
  return (_jsx(Context.Provider, { value: value, children: _jsx("div", { ...attributes, style: {