reshaped 3.1.4 → 3.1.6

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 (80) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/bundle.css +1 -1
  3. package/dist/bundle.d.ts +2 -0
  4. package/dist/bundle.js +11 -12
  5. package/dist/components/Actionable/Actionable.d.ts +1 -1
  6. package/dist/components/Actionable/Actionable.js +2 -2
  7. package/dist/components/Actionable/Actionable.module.css +1 -1
  8. package/dist/components/Actionable/Actionable.types.d.ts +1 -0
  9. package/dist/components/Autocomplete/Autocomplete.js +10 -4
  10. package/dist/components/Button/Button.js +1 -1
  11. package/dist/components/Card/Card.d.ts +1 -1
  12. package/dist/components/Card/tests/Card.stories.d.ts +1 -1
  13. package/dist/components/DropdownMenu/DropdownMenu.types.d.ts +1 -1
  14. package/dist/components/FormControl/FormControl.context.d.ts +2 -1
  15. package/dist/components/Grid/Grid.d.ts +6 -0
  16. package/dist/components/Grid/Grid.js +46 -0
  17. package/dist/components/Grid/Grid.module.css +1 -0
  18. package/dist/components/Grid/Grid.types.d.ts +31 -0
  19. package/dist/components/Grid/Grid.types.js +1 -0
  20. package/dist/components/Grid/index.d.ts +2 -0
  21. package/dist/components/Grid/index.js +1 -0
  22. package/dist/components/Grid/tests/Grid.stories.d.ts +18 -0
  23. package/dist/components/Grid/tests/Grid.stories.js +170 -0
  24. package/dist/components/Icon/Icon.module.css +1 -1
  25. package/dist/components/Link/Link.d.ts +1 -1
  26. package/dist/components/Loader/Loader.module.css +1 -1
  27. package/dist/components/Loader/Loader.types.d.ts +1 -1
  28. package/dist/components/Loader/tests/Loader.stories.js +5 -3
  29. package/dist/components/Overlay/tests/Overlay.stories.js +1 -1
  30. package/dist/components/Popover/Popover.js +2 -4
  31. package/dist/components/Popover/Popover.types.d.ts +1 -1
  32. package/dist/components/Select/Select.js +1 -1
  33. package/dist/components/Table/Table.js +6 -4
  34. package/dist/components/Table/Table.types.d.ts +6 -1
  35. package/dist/components/Tabs/Tabs.d.ts +1 -1
  36. package/dist/components/Tabs/Tabs.module.css +1 -1
  37. package/dist/components/Tabs/TabsItem.d.ts +1 -1
  38. package/dist/components/Tabs/TabsItem.js +2 -3
  39. package/dist/components/Tabs/tests/Tabs.stories.d.ts +15 -13
  40. package/dist/components/Tabs/tests/Tabs.stories.js +71 -8
  41. package/dist/components/Tooltip/Tooltip.js +1 -1
  42. package/dist/components/View/View.js +7 -3
  43. package/dist/components/View/View.module.css +1 -1
  44. package/dist/components/View/View.types.d.ts +2 -2
  45. package/dist/components/_private/Expandable/Expandable.js +9 -5
  46. package/dist/components/_private/Flyout/Flyout.module.css +1 -1
  47. package/dist/components/_private/Flyout/Flyout.types.d.ts +11 -2
  48. package/dist/components/_private/Flyout/FlyoutControlled.js +33 -18
  49. package/dist/components/_private/Flyout/tests/Flyout.stories.d.ts +1 -0
  50. package/dist/components/_private/Flyout/tests/Flyout.stories.js +28 -18
  51. package/dist/components/_private/Flyout/useFlyout.d.ts +2 -1
  52. package/dist/components/_private/Flyout/useFlyout.js +46 -57
  53. package/dist/components/_private/Flyout/utilities/calculatePosition.js +16 -11
  54. package/dist/components/_private/Flyout/utilities/cooldown.d.ts +1 -1
  55. package/dist/components/_private/Flyout/utilities/cooldown.js +17 -5
  56. package/dist/components/_private/Flyout/utilities/getPositionFallbacks.d.ts +3 -0
  57. package/dist/components/_private/Flyout/utilities/getPositionFallbacks.js +39 -0
  58. package/dist/config/tailwind.d.ts +1 -1
  59. package/dist/hooks/_private/useOnClickOutside.js +3 -2
  60. package/dist/hooks/_private/useSingletonHotkeys.js +16 -13
  61. package/dist/hooks/tests/useHotkeys.stories.js +6 -0
  62. package/dist/index.d.ts +2 -0
  63. package/dist/index.js +1 -0
  64. package/dist/styles/align/align.module.css +1 -0
  65. package/dist/styles/align/index.d.ts +3 -0
  66. package/dist/styles/align/index.js +10 -0
  67. package/dist/styles/justify/index.d.ts +3 -0
  68. package/dist/styles/justify/index.js +10 -0
  69. package/dist/styles/justify/justify.module.css +1 -0
  70. package/dist/styles/types.d.ts +2 -0
  71. package/dist/tests/ShadowDOM.stories.d.ts +6 -0
  72. package/dist/tests/ShadowDOM.stories.js +110 -0
  73. package/dist/themes/_generator/tests/themes.stories.js +1 -1
  74. package/dist/utilities/a11y/TrapFocus.d.ts +1 -1
  75. package/dist/utilities/a11y/TrapFocus.js +14 -5
  76. package/dist/utilities/a11y/focus.d.ts +1 -1
  77. package/dist/utilities/a11y/focus.js +10 -5
  78. package/dist/utilities/dom.d.ts +2 -1
  79. package/dist/utilities/dom.js +12 -2
  80. package/package.json +31 -29
@@ -16,7 +16,8 @@ import cooldown from "./utilities/cooldown.js";
16
16
  import { Provider, useFlyoutTriggerContext, useFlyoutContext, useFlyoutContentContext, } from "./Flyout.context.js";
17
17
  import useHandlerRef from "../../../hooks/useHandlerRef.js";
18
18
  const FlyoutRoot = (props) => {
19
- const { triggerType = "click", onOpen, onClose, children, disabled, forcePosition, trapFocusMode, width, disableHideAnimation, disableContentHover, disableCloseOnOutsideClick, contentGap, contentClassName, contentAttributes, position: passedPosition, active: passedActive, id: passedId, instanceRef, containerRef, } = props;
19
+ const { triggerType = "click", groupTimeouts, onOpen, onClose, children, disabled, forcePosition, trapFocusMode, width, disableHideAnimation, disableContentHover, disableCloseOnOutsideClick, contentGap = 2, contentClassName, contentAttributes, position: passedPosition, active: passedActive, id: passedId, instanceRef, containerRef, initialFocusRef, } = props;
20
+ const fallbackPositions = props.fallbackPositions === false || forcePosition ? [] : props.fallbackPositions;
20
21
  const onOpenRef = useHandlerRef(onOpen);
21
22
  const onCloseRef = useHandlerRef(onClose);
22
23
  const resolvedActive = disabled === true ? false : passedActive;
@@ -33,7 +34,7 @@ const FlyoutRoot = (props) => {
33
34
  */
34
35
  const triggerElRef = (!parentFlyoutContentContext && parentFlyoutTriggerContext?.triggerElRef) ||
35
36
  internalTriggerElRef;
36
- const triggerBoundsRef = React.useRef();
37
+ const triggerBoundsRef = React.useRef(null);
37
38
  const flyoutElRef = React.useRef(null);
38
39
  const id = useElementId(passedId);
39
40
  const timerRef = React.useRef();
@@ -57,11 +58,13 @@ const FlyoutRoot = (props) => {
57
58
  position: passedPosition,
58
59
  defaultActive: resolvedActive,
59
60
  container: containerRef?.current,
60
- forcePosition,
61
+ fallbackPositions,
62
+ contentGap,
61
63
  });
62
64
  const { status, updatePosition, render, hide, remove, show } = flyout;
65
+ const isRendered = status !== "idle";
63
66
  // Don't create dismissible queue for hover flyout because they close all together on mouseout
64
- const isDismissible = useIsDismissible(triggerType !== "hover" && status !== "idle", flyoutElRef, triggerElRef);
67
+ const isDismissible = useIsDismissible(triggerType !== "hover" && isRendered, flyoutElRef, triggerElRef);
65
68
  const clearTimer = React.useCallback(() => {
66
69
  if (timerRef.current)
67
70
  clearTimeout(timerRef.current);
@@ -71,20 +74,20 @@ const FlyoutRoot = (props) => {
71
74
  * Called from the internal actions
72
75
  */
73
76
  const handleOpen = React.useCallback(() => {
74
- const canOpen = !lockedRef.current && status === "idle";
77
+ const canOpen = !lockedRef.current && !isRendered;
75
78
  if (!canOpen)
76
79
  return;
77
80
  onOpenRef.current?.();
78
- }, [status, onOpenRef]);
81
+ }, [isRendered, onOpenRef]);
79
82
  const handleClose = React.useCallback((options) => {
80
83
  const isLocked = triggerType === "click" && !isDismissible();
81
- const canClose = !isLocked && (status !== "idle" || disabled);
84
+ const canClose = !isLocked && (isRendered || disabled);
82
85
  if (!canClose)
83
86
  return;
84
87
  onCloseRef.current?.();
85
88
  if (options?.closeParents)
86
89
  parentFlyoutContext?.handleClose?.();
87
- }, [status, isDismissible, triggerType, onCloseRef, disabled, parentFlyoutContext]);
90
+ }, [isRendered, isDismissible, triggerType, onCloseRef, disabled, parentFlyoutContext]);
88
91
  /**
89
92
  * Trigger event handlers
90
93
  */
@@ -118,24 +121,28 @@ const FlyoutRoot = (props) => {
118
121
  hoverTriggeredWithTouchEventRef.current = false;
119
122
  }
120
123
  else {
121
- timerRef.current = setTimeout(handleOpen, cooldown.timer || isSubmenu ? timeouts.mouseEnterShort : timeouts.mouseEnter);
122
- if (!isSubmenu && triggerType === "hover")
124
+ if (groupTimeouts)
123
125
  cooldown.warm();
126
+ timerRef.current = setTimeout(() => {
127
+ handleOpen();
128
+ }, groupTimeouts && cooldown.status === "warming"
129
+ ? timeouts.mouseEnter
130
+ : timeouts.mouseEnterShort);
124
131
  }
125
- }, [clearTimer, timerRef, handleOpen, isSubmenu, triggerType]);
132
+ }, [clearTimer, timerRef, handleOpen, groupTimeouts]);
126
133
  const handleMouseLeave = React.useCallback(() => {
127
134
  cooldown.cool();
128
135
  clearTimer();
129
136
  timerRef.current = setTimeout(() => handleClose(), timeouts.mouseLeave);
130
137
  }, [clearTimer, timerRef, handleClose]);
131
138
  const handleTriggerClick = React.useCallback(() => {
132
- if (status === "idle") {
139
+ if (!isRendered) {
133
140
  handleOpen();
134
141
  }
135
142
  else {
136
143
  handleClose();
137
144
  }
138
- }, [status, handleOpen, handleClose]);
145
+ }, [isRendered, handleOpen, handleClose]);
139
146
  const handleTriggerMouseDown = React.useCallback(() => {
140
147
  const rect = triggerElRef.current?.getBoundingClientRect();
141
148
  triggerBoundsRef.current = rect;
@@ -153,6 +160,11 @@ const FlyoutRoot = (props) => {
153
160
  if (flyoutElRef.current !== e.currentTarget || e.propertyName !== "transform")
154
161
  return;
155
162
  transitionStartedRef.current = true;
163
+ /**
164
+ * After animation has started, we're sure about the correct bounds
165
+ * so drop the cache to make flyout work when trigger moves around
166
+ */
167
+ triggerBoundsRef.current = null;
156
168
  }, [resolvedActive]);
157
169
  const handleTransitionEnd = React.useCallback((e) => {
158
170
  if (flyoutElRef.current !== e.currentTarget || e.propertyName !== "transform")
@@ -180,14 +192,14 @@ const FlyoutRoot = (props) => {
180
192
  if (checkTransitions() &&
181
193
  !disableHideAnimation &&
182
194
  transitionStartedRef.current &&
183
- (cooldown.status !== "warm" || triggerType !== "hover")) {
195
+ (cooldown.status === "cooling" || !groupTimeouts)) {
184
196
  hide();
185
197
  }
186
198
  else {
187
199
  // In case transitions are disabled globally - remove from the DOM immediately
188
200
  remove();
189
201
  }
190
- }, [resolvedActive, render, hide, remove, disableHideAnimation, disabled]);
202
+ }, [resolvedActive, render, hide, remove, disableHideAnimation, disabled, groupTimeouts]);
191
203
  React.useEffect(() => {
192
204
  // Wait after positioning before show is triggered to animate flyout from the right side
193
205
  if (status === "positioned")
@@ -210,6 +222,7 @@ const FlyoutRoot = (props) => {
210
222
  trapFocusRef.current = new TrapFocus(flyoutElRef.current);
211
223
  trapFocusRef.current.trap({
212
224
  mode: trapFocusMode,
225
+ initialFocusEl: initialFocusRef?.current,
213
226
  includeTrigger: triggerType === "hover" && trapFocusMode !== "dialog" && !isSubmenu,
214
227
  onNavigateOutside: () => {
215
228
  handleClose();
@@ -219,7 +232,7 @@ const FlyoutRoot = (props) => {
219
232
  React.useEffect(() => {
220
233
  if (!disableHideAnimation && status !== "hidden")
221
234
  return;
222
- if (disableHideAnimation && status !== "idle")
235
+ if (disableHideAnimation && isRendered)
223
236
  return;
224
237
  if (trapFocusRef.current?.trapped) {
225
238
  /* Locking the popover to not open it again on trigger focus */
@@ -232,7 +245,7 @@ const FlyoutRoot = (props) => {
232
245
  trapFocusRef.current.release({ withoutFocusReturn: !shouldReturnFocusRef.current });
233
246
  shouldReturnFocusRef.current = true;
234
247
  }
235
- }, [status, triggerType, disableHideAnimation]);
248
+ }, [status, isRendered, triggerType, disableHideAnimation]);
236
249
  /**
237
250
  * Release focus trapping on unmount
238
251
  */
@@ -243,12 +256,14 @@ const FlyoutRoot = (props) => {
243
256
  * Update position on resize or RTL
244
257
  */
245
258
  React.useEffect(() => {
259
+ if (!isRendered)
260
+ return;
246
261
  const resizeObserver = new ResizeObserver(() => updatePosition({ sync: true }));
247
262
  resizeObserver.observe(document.body);
248
263
  if (triggerElRef.current)
249
264
  resizeObserver.observe(triggerElRef.current);
250
265
  return () => resizeObserver.disconnect();
251
- }, [updatePosition, triggerElRef]);
266
+ }, [updatePosition, triggerElRef, isRendered]);
252
267
  React.useEffect(() => {
253
268
  updatePosition();
254
269
  }, [isRTL, updatePosition]);
@@ -7,6 +7,7 @@ export declare const positions: () => React.JSX.Element;
7
7
  export declare const dynamicPosition: () => React.JSX.Element;
8
8
  export declare const modes: () => React.JSX.Element;
9
9
  export declare const disableFlags: () => React.JSX.Element;
10
+ export declare const initialFocus: () => React.JSX.Element;
10
11
  export declare const customPortalTarget: () => React.JSX.Element;
11
12
  export declare const testWidthOverflowOnMobile: () => React.JSX.Element;
12
13
  export declare const testInsideFixed: () => React.JSX.Element;
@@ -6,6 +6,7 @@ import View from "../../../View/index.js";
6
6
  import Theme from "../../../Theme/index.js";
7
7
  import Button from "../../../Button/index.js";
8
8
  import Flyout from "../index.js";
9
+ import TextField from "../../../TextField/index.js";
9
10
  export default { title: "Utilities/Internal/Flyout" };
10
11
  const Demo = (props) => {
11
12
  const { position = "bottom-start", children, ...rest } = props;
@@ -17,7 +18,7 @@ const Demo = (props) => {
17
18
  <div style={{
18
19
  background: "var(--rs-color-background-elevation-overlay)",
19
20
  padding: "var(--rs-unit-x4)",
20
- height: 100,
21
+ height: 150,
21
22
  width: 160,
22
23
  borderRadius: "var(--rs-radius-medium)",
23
24
  border: "1px solid var(--rs-color-border-neutral-faded)",
@@ -120,6 +121,19 @@ export const disableFlags = () => (<Example>
120
121
  <Demo disableHideAnimation>Content</Demo>
121
122
  </Example.Item>
122
123
  </Example>);
124
+ export const initialFocus = () => {
125
+ const initialFocusRef = React.useRef(null);
126
+ return (<Example>
127
+ <Example.Item title="focuses input on open">
128
+ <Demo initialFocusRef={initialFocusRef}>
129
+ <View gap={4}>
130
+ <Button onClick={() => { }}>Click me</Button>
131
+ <TextField name="foo" inputAttributes={{ ref: initialFocusRef }}/>
132
+ </View>
133
+ </Demo>
134
+ </Example.Item>
135
+ </Example>);
136
+ };
123
137
  class CustomElement extends window.HTMLElement {
124
138
  constructor() {
125
139
  super();
@@ -199,36 +213,32 @@ export const testInsideFixed = () => (<Example>
199
213
  </Example.Item>
200
214
  </Example>);
201
215
  export const testDynamicBounds = () => {
202
- const [left, setLeft] = React.useState("50%");
216
+ const [left, setLeft] = React.useState(50);
217
+ const [top, setTop] = React.useState(50);
203
218
  const [size, setSize] = React.useState("medium");
204
219
  const flyoutRef = React.useRef();
205
220
  React.useEffect(() => {
206
221
  flyoutRef.current?.updatePosition();
207
- }, [left]);
222
+ }, [left, top]);
208
223
  return (<View gap={4}>
209
224
  <View direction="row" gap={2}>
225
+ <Button onClick={() => setLeft((prev) => prev - 10)}>Left</Button>
226
+ <Button onClick={() => setLeft((prev) => prev + 10)}>Right</Button>
227
+ <Button onClick={() => setTop((prev) => prev - 10)}>Up</Button>
228
+ <Button onClick={() => setTop((prev) => prev + 10)}>Down</Button>
210
229
  <Button onClick={() => {
211
- setLeft("20%");
212
- }}>
213
- Left
214
- </Button>
215
- <Button onClick={() => {
216
- setLeft("50%");
230
+ setLeft(50);
231
+ setTop(50);
217
232
  }}>
218
233
  Center
219
234
  </Button>
220
- <Button onClick={() => {
221
- setLeft("70%");
222
- }}>
223
- Right
224
- </Button>
225
235
  <Button onClick={() => setSize("large")}>Large button</Button>
226
236
  <Button onClick={() => setSize("medium")}>Small button</Button>
227
237
  </View>
228
238
  <View height={100}>
229
- <Flyout position="bottom" active instanceRef={flyoutRef}>
239
+ <Flyout position="bottom" instanceRef={flyoutRef} disableCloseOnOutsideClick fallbackPositions={["top", "bottom"]}>
230
240
  <Flyout.Trigger>
231
- {(attributes) => (<div style={{ position: "absolute", left, top: "50%" }}>
241
+ {(attributes) => (<div style={{ position: "absolute", left: `${left}%`, top: `${top}%` }}>
232
242
  <Button color="primary" attributes={attributes} size={size}>
233
243
  Open
234
244
  </Button>
@@ -239,12 +249,12 @@ export const testDynamicBounds = () => {
239
249
  background: "var(--rs-color-background-elevation-overlay)",
240
250
  padding: "var(--rs-unit-x4)",
241
251
  height: 100,
242
- width: 160,
252
+ minWidth: 160,
243
253
  borderRadius: "var(--rs-radius-medium)",
244
254
  border: "1px solid var(--rs-color-border-neutral-faded)",
245
255
  boxSizing: "border-box",
246
256
  }}>
247
- {"Content"}
257
+ Content
248
258
  </div>
249
259
  </Flyout.Content>
250
260
  </Flyout>
@@ -8,13 +8,14 @@ type PassedFlyoutOptions = {
8
8
  width?: T.Width;
9
9
  position?: T.Position;
10
10
  defaultActive?: boolean;
11
- forcePosition?: boolean;
11
+ fallbackPositions?: T.Position[];
12
12
  container?: HTMLElement | null;
13
13
  };
14
14
  type UseFlyout = (args: PassedFlyoutOptions & {
15
15
  triggerElRef: ElementRef;
16
16
  flyoutElRef: ElementRef;
17
17
  triggerBoundsRef: React.RefObject<DOMRect | undefined>;
18
+ contentGap?: number;
18
19
  }) => Pick<T.State, "styles" | "position" | "status"> & {
19
20
  updatePosition: (options?: {
20
21
  sync?: boolean;
@@ -1,25 +1,8 @@
1
1
  import React from "react";
2
2
  import useRTL from "../../../hooks/useRTL.js";
3
- import { getClosestFlyoutTarget } from "../../../utilities/dom.js";
3
+ import { getClosestFlyoutTarget, getShadowRoot } from "../../../utilities/dom.js";
4
4
  import calculatePosition from "./utilities/calculatePosition.js";
5
- const topPos = ["top-start", "top", "top-end"];
6
- const bottomPos = ["bottom-start", "bottom", "bottom-end"];
7
- const startPos = ["start", "start-bottom", "start-top"];
8
- const endPos = ["end", "end-bottom", "end-top"];
9
- const order = {
10
- top: [...topPos, ...bottomPos, ...endPos, ...startPos],
11
- bottom: [...bottomPos, ...topPos, ...endPos, ...startPos],
12
- start: [...startPos, ...endPos, ...topPos, ...bottomPos],
13
- end: [...endPos, ...startPos, ...topPos, ...bottomPos],
14
- };
15
- /**
16
- * Get an order of positions to try to fit popover on the screen based on its starting position
17
- */
18
- const getPositionOrder = (position) => {
19
- const types = ["top", "bottom", "start", "end"];
20
- const type = types.find((type) => position.startsWith(type)) || "bottom";
21
- return order[type];
22
- };
5
+ import getPositionFallbacks from "./utilities/getPositionFallbacks.js";
23
6
  /**
24
7
  * Check if element visually fits on the screen
25
8
  */
@@ -60,12 +43,13 @@ const resetStyles = {
60
43
  * Set position of the target element to fit on the screen
61
44
  */
62
45
  const flyout = (args) => {
63
- const { triggerEl, flyoutEl, triggerBounds: passedTriggerBounds, ...options } = args;
64
- const { position, forcePosition, width, container } = options;
46
+ const { triggerEl, flyoutEl, triggerBounds: passedTriggerBounds, contentGap = 0, ...options } = args;
47
+ const { position, fallbackPositions, width, container, lastUsedFallback, onFallback } = options;
65
48
  const targetClone = flyoutEl.cloneNode(true);
66
49
  const triggerBounds = passedTriggerBounds || triggerEl.getBoundingClientRect();
50
+ const contentGapModifier = parseInt(getComputedStyle(flyoutEl).getPropertyValue("--rs-unit-x1"));
67
51
  // Reset all styles applied on the previous hook execution
68
- targetClone.style = "";
52
+ targetClone.style.cssText = "";
69
53
  Object.keys(resetStyles).forEach((key) => {
70
54
  const value = resetStyles[key];
71
55
  targetClone.style[key] = value.toString();
@@ -78,8 +62,7 @@ const flyout = (args) => {
78
62
  targetClone.style.width = width;
79
63
  }
80
64
  }
81
- const rootNode = triggerEl?.getRootNode();
82
- const shadowRoot = rootNode instanceof ShadowRoot ? rootNode : null;
65
+ const shadowRoot = getShadowRoot(triggerEl);
83
66
  // Insert inside shadow root if possible to make sure styles are applied correctly
84
67
  (shadowRoot || document.body).appendChild(targetClone);
85
68
  const flyoutBounds = targetClone.getBoundingClientRect();
@@ -89,37 +72,30 @@ const flyout = (args) => {
89
72
  top: containerBounds.top + document.documentElement.scrollTop - containerParent.scrollTop,
90
73
  left: containerBounds.left + document.documentElement.scrollLeft - containerParent.scrollLeft,
91
74
  };
92
- let calculated = calculatePosition({ triggerBounds, flyoutBounds, scopeOffset, ...options });
93
- if (!fullyVisible(calculated) && !forcePosition) {
94
- const order = getPositionOrder(position);
95
- const mobileOrder = order.filter((position) => position === "top" || position === "bottom");
96
- const test = (testOrder, extraOptions = {}) => {
97
- const { fullWidth } = extraOptions;
98
- testOrder.some((currentPosition) => {
99
- const calculateOptions = {
100
- ...options,
101
- width: fullWidth ? "full" : options.width,
102
- position: currentPosition,
103
- };
104
- const tested = calculatePosition({
105
- triggerBounds,
106
- flyoutBounds,
107
- scopeOffset,
108
- ...calculateOptions,
109
- });
110
- if (fullyVisible(tested)) {
111
- calculated = tested;
112
- return true;
113
- }
114
- return false;
115
- });
116
- };
117
- test(order);
118
- if (!fullyVisible(calculated)) {
119
- test(mobileOrder, { fullWidth: true });
75
+ let calculated = null;
76
+ const testOrder = getPositionFallbacks(position, fallbackPositions);
77
+ testOrder.some((currentPosition, index) => {
78
+ const tested = calculatePosition({
79
+ ...options,
80
+ triggerBounds,
81
+ flyoutBounds,
82
+ scopeOffset,
83
+ position: currentPosition,
84
+ contentGap: contentGap * contentGapModifier,
85
+ });
86
+ const visible = fullyVisible(tested);
87
+ const validPosition = visible || fallbackPositions?.length === 0;
88
+ // Saving first try in case non of the options work
89
+ if (validPosition || lastUsedFallback === currentPosition) {
90
+ calculated = tested;
91
+ onFallback(currentPosition);
120
92
  }
93
+ return validPosition;
94
+ });
95
+ if (!calculated) {
96
+ throw new Error(`Reshaped: Can't calculate styles for the ${position} position`);
121
97
  }
122
- targetClone.parentNode.removeChild(targetClone);
98
+ targetClone.parentNode?.removeChild(targetClone);
123
99
  return calculated;
124
100
  };
125
101
  const flyoutReducer = (state, action) => {
@@ -157,8 +133,13 @@ const flyoutReducer = (state, action) => {
157
133
  }
158
134
  };
159
135
  const useFlyout = (args) => {
160
- const { triggerElRef, flyoutElRef, triggerBoundsRef, ...options } = args;
161
- const { position: defaultPosition = "bottom", forcePosition, width, container } = options;
136
+ const { triggerElRef, flyoutElRef, triggerBoundsRef, contentGap, ...options } = args;
137
+ const { position: defaultPosition = "bottom", fallbackPositions, width, container } = options;
138
+ const lastUsedFallbackRef = React.useRef(defaultPosition);
139
+ // Memo the array internally to avoid new arrays triggering useCallback
140
+ const cachedFallbackPositions = React.useMemo(() => fallbackPositions,
141
+ // eslint-disable-next-line react-hooks/exhaustive-deps
142
+ [fallbackPositions?.join(" ")]);
162
143
  const [isRTL] = useRTL();
163
144
  const [state, dispatch] = React.useReducer(flyoutReducer, {
164
145
  position: defaultPosition,
@@ -177,6 +158,9 @@ const useFlyout = (args) => {
177
158
  const remove = React.useCallback(() => {
178
159
  dispatch({ type: "remove" });
179
160
  }, []);
161
+ const handleFallback = React.useCallback((position) => {
162
+ lastUsedFallbackRef.current = position;
163
+ }, []);
180
164
  const updatePosition = React.useCallback((options) => {
181
165
  if (!triggerElRef.current || !flyoutElRef.current)
182
166
  return;
@@ -186,21 +170,26 @@ const useFlyout = (args) => {
186
170
  triggerBounds: triggerBoundsRef.current,
187
171
  width,
188
172
  position: defaultPosition,
189
- forcePosition,
173
+ fallbackPositions: cachedFallbackPositions,
174
+ lastUsedFallback: lastUsedFallbackRef.current,
175
+ onFallback: handleFallback,
190
176
  rtl: isRTL,
191
177
  container,
178
+ contentGap,
192
179
  });
193
180
  if (nextFlyoutData)
194
181
  dispatch({ type: "position", payload: { ...nextFlyoutData, sync: options?.sync } });
195
182
  }, [
196
183
  container,
197
184
  defaultPosition,
198
- forcePosition,
185
+ cachedFallbackPositions,
199
186
  isRTL,
200
187
  flyoutElRef,
201
188
  triggerElRef,
202
189
  triggerBoundsRef,
203
190
  width,
191
+ contentGap,
192
+ handleFallback,
204
193
  ]);
205
194
  React.useEffect(() => {
206
195
  if (state.status === "rendered")
@@ -16,24 +16,29 @@ const centerBySize = (originSize, targetSize) => {
16
16
  * Calculate styles for the current position
17
17
  */
18
18
  const calculatePosition = (args) => {
19
- const { triggerBounds, flyoutBounds, scopeOffset, position: passedPosition, rtl, width } = args;
19
+ const { triggerBounds, flyoutBounds, scopeOffset, position: passedPosition, rtl, width, contentGap = 0, } = args;
20
+ const isFullWidth = width === "full" || width === "100%";
20
21
  let left = 0;
21
22
  let top = 0;
22
23
  let position = passedPosition;
23
24
  if (rtl)
24
25
  position = getRTLPosition(position);
25
- if (width === "full" || width === "trigger") {
26
+ if (isFullWidth || width === "trigger") {
26
27
  position = position.includes("top") ? "top" : "bottom";
27
28
  }
29
+ const isHorizontalPosition = position.match(/^(start|end)/);
30
+ const isVerticalPosition = position.match(/^(top|bottom)/);
31
+ const flyoutWidth = flyoutBounds.width + (isHorizontalPosition ? contentGap : 0);
32
+ const flyoutHeight = flyoutBounds.height + (isVerticalPosition ? contentGap : 0);
28
33
  switch (position) {
29
34
  case "bottom":
30
35
  case "top":
31
- left = centerBySize(triggerBounds.width, flyoutBounds.width) + triggerBounds.left;
36
+ left = centerBySize(triggerBounds.width, flyoutWidth) + triggerBounds.left;
32
37
  break;
33
38
  case "start":
34
39
  case "start-top":
35
40
  case "start-bottom":
36
- left = triggerBounds.left - flyoutBounds.width;
41
+ left = triggerBounds.left - flyoutWidth;
37
42
  break;
38
43
  case "end":
39
44
  case "end-top":
@@ -46,7 +51,7 @@ const calculatePosition = (args) => {
46
51
  break;
47
52
  case "top-end":
48
53
  case "bottom-end":
49
- left = triggerBounds.right - flyoutBounds.width;
54
+ left = triggerBounds.right - flyoutWidth;
50
55
  break;
51
56
  default:
52
57
  break;
@@ -55,7 +60,7 @@ const calculatePosition = (args) => {
55
60
  case "top":
56
61
  case "top-start":
57
62
  case "top-end":
58
- top = triggerBounds.top - flyoutBounds.height;
63
+ top = triggerBounds.top - flyoutHeight;
59
64
  break;
60
65
  case "bottom":
61
66
  case "bottom-start":
@@ -64,7 +69,7 @@ const calculatePosition = (args) => {
64
69
  break;
65
70
  case "start":
66
71
  case "end":
67
- top = centerBySize(triggerBounds.height, flyoutBounds.height) + triggerBounds.top;
72
+ top = centerBySize(triggerBounds.height, flyoutHeight) + triggerBounds.top;
68
73
  break;
69
74
  case "start-top":
70
75
  case "end-top":
@@ -72,7 +77,7 @@ const calculatePosition = (args) => {
72
77
  break;
73
78
  case "start-bottom":
74
79
  case "end-bottom":
75
- top = triggerBounds.bottom - flyoutBounds.height;
80
+ top = triggerBounds.bottom - flyoutHeight;
76
81
  break;
77
82
  default:
78
83
  break;
@@ -82,9 +87,9 @@ const calculatePosition = (args) => {
82
87
  }
83
88
  top = Math.round(top + (window.scrollY || 0) - scopeOffset.top);
84
89
  left = Math.round(left + (window.scrollX || 0) - scopeOffset.left);
85
- let widthStyle = Math.ceil(flyoutBounds.width);
86
- const height = Math.ceil(flyoutBounds.height);
87
- if (width === "full") {
90
+ let widthStyle = Math.ceil(flyoutWidth);
91
+ const height = Math.ceil(flyoutHeight);
92
+ if (isFullWidth) {
88
93
  left = SCREEN_OFFSET;
89
94
  widthStyle = window.innerWidth - SCREEN_OFFSET * 2;
90
95
  }
@@ -1,5 +1,5 @@
1
1
  declare class Cooldown {
2
- status: "cold" | "warm" | "cooling";
2
+ status: "warming" | "warm" | "cooling" | "cold";
3
3
  timer?: ReturnType<typeof setTimeout>;
4
4
  warm: () => void;
5
5
  cool: () => void;
@@ -1,18 +1,30 @@
1
+ import * as timeouts from "../Flyout.constants.js";
1
2
  class Cooldown {
2
3
  status = "cold";
3
4
  timer;
4
5
  warm = () => {
5
6
  clearTimeout(this.timer);
6
- this.status = "warm";
7
+ if (this.status === "cooling") {
8
+ this.status = "warm";
9
+ return;
10
+ }
11
+ this.status = "warming";
12
+ this.timer = setTimeout(() => {
13
+ this.status = "warm";
14
+ this.timer = undefined;
15
+ }, timeouts.mouseEnterShort);
7
16
  };
8
17
  cool = () => {
18
+ clearTimeout(this.timer);
19
+ if (this.status === "warming") {
20
+ this.status = "cold";
21
+ return;
22
+ }
9
23
  this.status = "cooling";
10
- const currentTimer = setTimeout(() => {
24
+ this.timer = setTimeout(() => {
11
25
  this.status = "cold";
12
- if (currentTimer === this.timer)
13
- this.timer = undefined;
26
+ this.timer = undefined;
14
27
  }, 500);
15
- this.timer = currentTimer;
16
28
  };
17
29
  }
18
30
  export default new Cooldown();
@@ -0,0 +1,3 @@
1
+ import type * as T from "../Flyout.types";
2
+ declare const getPositionFallbacks: (position: T.Position, availableFallbacks?: T.Position[]) => T.Position[];
3
+ export default getPositionFallbacks;
@@ -0,0 +1,39 @@
1
+ // All available positions for each side
2
+ const positions = {
3
+ top: ["top-start", "top-end", "top"],
4
+ bottom: ["bottom-start", "bottom-end", "bottom"],
5
+ start: ["start-top", "start-bottom", "start"],
6
+ end: ["end-top", "end-bottom", "end"],
7
+ };
8
+ // Order of sides to try depending on the starting side
9
+ const fallbackOrder = {
10
+ top: ["bottom", "start", "end"],
11
+ bottom: ["top", "end", "start"],
12
+ start: ["end", "top", "bottom"],
13
+ end: ["start", "bottom", "top"],
14
+ };
15
+ // Get an order of positions to try to fit flyout on the screen based on its starting position
16
+ const getPositionFallbacks = (position, availableFallbacks) => {
17
+ const result = [];
18
+ const chunks = position.split("-");
19
+ const [firstChunk] = chunks;
20
+ const passedPositionOrder = positions[firstChunk];
21
+ const startingFallbackIndex = passedPositionOrder.indexOf(position);
22
+ const fallbackIndexOrder = [startingFallbackIndex];
23
+ passedPositionOrder.forEach((_, index) => {
24
+ if (index === startingFallbackIndex)
25
+ return;
26
+ fallbackIndexOrder.push(index);
27
+ });
28
+ [firstChunk, ...fallbackOrder[firstChunk]].forEach((fallbackSide) => {
29
+ const fallbackOrder = positions[fallbackSide];
30
+ fallbackIndexOrder.forEach((index) => {
31
+ const position = fallbackOrder[index];
32
+ if (availableFallbacks?.indexOf(position) === -1)
33
+ return;
34
+ result.push(position);
35
+ });
36
+ });
37
+ return result;
38
+ };
39
+ export default getPositionFallbacks;
@@ -1,2 +1,2 @@
1
1
  import type { ThemeDefinition } from "../themes/_generator/tokens/types";
2
- export declare const getTheme: (theme?: ThemeDefinition) => Record<"borderRadius" | "backgroundColor" | "borderColor" | "spacing" | "boxShadow" | "textColor" | "colors" | "screens", Record<string, string>>;
2
+ export declare const getTheme: (theme?: ThemeDefinition) => Record<"borderRadius" | "backgroundColor" | "borderColor" | "boxShadow" | "textColor" | "colors" | "spacing" | "screens", Record<string, string>>;
@@ -7,10 +7,11 @@ const useOnClickOutside = (refs, handler) => {
7
7
  return;
8
8
  const handleClick = (event) => {
9
9
  let isInside = false;
10
+ const clickedEl = event.composedPath()[0];
10
11
  refs.forEach((elRef) => {
11
12
  if (!elRef.current ||
12
- elRef.current === event.target ||
13
- elRef.current.contains(event.target)) {
13
+ elRef.current === clickedEl ||
14
+ elRef.current.contains(clickedEl)) {
14
15
  isInside = true;
15
16
  }
16
17
  });