@simplybusiness/mobius 10.4.2 → 10.4.3

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 (73) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/cjs/components/Popover/Arrow.js +43 -0
  3. package/dist/cjs/components/Popover/Arrow.js.map +7 -0
  4. package/dist/cjs/components/Popover/Popover.js +258 -83
  5. package/dist/cjs/components/Popover/Popover.js.map +4 -4
  6. package/dist/cjs/components/Popover/index.js +258 -83
  7. package/dist/cjs/components/Popover/index.js.map +4 -4
  8. package/dist/cjs/components/Popover/useAutoUpdate.js +53 -0
  9. package/dist/cjs/components/Popover/useAutoUpdate.js.map +7 -0
  10. package/dist/cjs/components/Popover/useFloatingPosition.js +128 -0
  11. package/dist/cjs/components/Popover/useFloatingPosition.js.map +7 -0
  12. package/dist/cjs/components/Popover/useOutsidePress.js +46 -0
  13. package/dist/cjs/components/Popover/useOutsidePress.js.map +7 -0
  14. package/dist/cjs/components/index.js +422 -245
  15. package/dist/cjs/components/index.js.map +4 -4
  16. package/dist/cjs/index.js +422 -245
  17. package/dist/cjs/index.js.map +4 -4
  18. package/dist/cjs/meta.json +316 -32
  19. package/dist/esm/chunk-26KZYRE6.js +108 -0
  20. package/dist/esm/chunk-26KZYRE6.js.map +7 -0
  21. package/dist/esm/chunk-CAL44W47.js +23 -0
  22. package/dist/esm/chunk-CAL44W47.js.map +7 -0
  23. package/dist/esm/{chunk-PEEQNAIN.js → chunk-DMYDWKKA.js} +4 -4
  24. package/dist/esm/chunk-K3ECDAUR.js +33 -0
  25. package/dist/esm/chunk-K3ECDAUR.js.map +7 -0
  26. package/dist/esm/{chunk-GJBH37DH.js → chunk-KFHPI67N.js} +4 -4
  27. package/dist/esm/{chunk-F5ELD54X.js → chunk-LGZWQZLS.js} +2 -2
  28. package/dist/esm/{chunk-OAG5T7NC.js → chunk-NEFRXIFY.js} +4 -4
  29. package/dist/esm/chunk-VZ3IWSK6.js +158 -0
  30. package/dist/esm/chunk-VZ3IWSK6.js.map +7 -0
  31. package/dist/esm/chunk-WYJP7HVL.js +26 -0
  32. package/dist/esm/chunk-WYJP7HVL.js.map +7 -0
  33. package/dist/esm/components/AddressLookup/AddressLookup.js +4 -4
  34. package/dist/esm/components/AddressLookup/index.js +6 -6
  35. package/dist/esm/components/Breadcrumbs/index.js +3 -3
  36. package/dist/esm/components/Checkbox/index.js +1 -1
  37. package/dist/esm/components/Combobox/Combobox.js +3 -3
  38. package/dist/esm/components/Combobox/index.js +3 -3
  39. package/dist/esm/components/Drawer/index.js +3 -3
  40. package/dist/esm/components/Modal/index.js +3 -3
  41. package/dist/esm/components/Popover/Arrow.js +8 -0
  42. package/dist/esm/components/Popover/Arrow.js.map +7 -0
  43. package/dist/esm/components/Popover/Popover.js +5 -1
  44. package/dist/esm/components/Popover/index.js +5 -1
  45. package/dist/esm/components/Popover/useAutoUpdate.js +8 -0
  46. package/dist/esm/components/Popover/useAutoUpdate.js.map +7 -0
  47. package/dist/esm/components/Popover/useFloatingPosition.js +8 -0
  48. package/dist/esm/components/Popover/useFloatingPosition.js.map +7 -0
  49. package/dist/esm/components/Popover/useOutsidePress.js +8 -0
  50. package/dist/esm/components/Popover/useOutsidePress.js.map +7 -0
  51. package/dist/esm/components/index.js +77 -73
  52. package/dist/esm/index.js +77 -73
  53. package/dist/esm/meta.json +3737 -3401
  54. package/dist/tsconfig.build.tsbuildinfo +1 -1
  55. package/dist/types/components/Popover/Arrow.d.ts +9 -0
  56. package/dist/types/components/Popover/useAutoUpdate.d.ts +9 -0
  57. package/dist/types/components/Popover/useFloatingPosition.d.ts +17 -0
  58. package/dist/types/components/Popover/useOutsidePress.d.ts +9 -0
  59. package/package.json +1 -2
  60. package/src/components/Popover/Arrow.tsx +25 -0
  61. package/src/components/Popover/Popover.characterization.test.tsx +269 -0
  62. package/src/components/Popover/Popover.stories.tsx +40 -3
  63. package/src/components/Popover/Popover.test.tsx +6 -2
  64. package/src/components/Popover/Popover.tsx +87 -81
  65. package/src/components/Popover/useAutoUpdate.ts +43 -0
  66. package/src/components/Popover/useFloatingPosition.ts +177 -0
  67. package/src/components/Popover/useOutsidePress.ts +31 -0
  68. package/dist/esm/chunk-O5YEU5TG.js +0 -155
  69. package/dist/esm/chunk-O5YEU5TG.js.map +0 -7
  70. /package/dist/esm/{chunk-PEEQNAIN.js.map → chunk-DMYDWKKA.js.map} +0 -0
  71. /package/dist/esm/{chunk-GJBH37DH.js.map → chunk-KFHPI67N.js.map} +0 -0
  72. /package/dist/esm/{chunk-F5ELD54X.js.map → chunk-LGZWQZLS.js.map} +0 -0
  73. /package/dist/esm/{chunk-OAG5T7NC.js.map → chunk-NEFRXIFY.js.map} +0 -0
@@ -0,0 +1,9 @@
1
+ import type { CSSProperties, Ref } from "react";
2
+ interface ArrowProps {
3
+ ref?: Ref<SVGSVGElement>;
4
+ width?: number;
5
+ className?: string;
6
+ style?: CSSProperties;
7
+ }
8
+ export declare const Arrow: ({ ref, width, className, style }: ArrowProps) => import("react/jsx-runtime").JSX.Element;
9
+ export {};
@@ -0,0 +1,9 @@
1
+ import type { RefObject } from "react";
2
+ interface UseAutoUpdateArgs {
3
+ referenceRef: RefObject<HTMLElement | null>;
4
+ floatingRef: RefObject<HTMLDivElement | null>;
5
+ onUpdate: () => void;
6
+ enabled: boolean;
7
+ }
8
+ export declare const useAutoUpdate: ({ referenceRef, floatingRef, onUpdate, enabled, }: UseAutoUpdateArgs) => void;
9
+ export {};
@@ -0,0 +1,17 @@
1
+ import type { CSSProperties, RefObject } from "react";
2
+ interface UseFloatingPositionArgs {
3
+ referenceRef: RefObject<HTMLElement | null>;
4
+ floatingRef: RefObject<HTMLDivElement | null>;
5
+ arrowRef: RefObject<SVGSVGElement | null>;
6
+ isOpen: boolean;
7
+ offsetPx: number;
8
+ arrowWidth: number;
9
+ useFixedStrategy: boolean;
10
+ }
11
+ interface UseFloatingPositionResult {
12
+ initialFloatingStyles: CSSProperties;
13
+ initialArrowStyles: CSSProperties;
14
+ update: () => void;
15
+ }
16
+ export declare const useFloatingPosition: ({ referenceRef, floatingRef, arrowRef, isOpen, offsetPx, arrowWidth, useFixedStrategy, }: UseFloatingPositionArgs) => UseFloatingPositionResult;
17
+ export {};
@@ -0,0 +1,9 @@
1
+ import type { RefObject } from "react";
2
+ interface UseOutsidePressArgs {
3
+ referenceRef: RefObject<HTMLElement | null>;
4
+ floatingRef: RefObject<HTMLDivElement | null>;
5
+ enabled: boolean;
6
+ onOutsidePress: (event: PointerEvent) => void;
7
+ }
8
+ export declare const useOutsidePress: ({ referenceRef, floatingRef, enabled, onOutsidePress, }: UseOutsidePressArgs) => void;
9
+ export {};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@simplybusiness/mobius",
3
3
  "license": "UNLICENSED",
4
- "version": "10.4.2",
4
+ "version": "10.4.3",
5
5
  "description": "Core library of Mobius react components",
6
6
  "repository": {
7
7
  "type": "git",
@@ -96,7 +96,6 @@
96
96
  "react-dom": "^19.2.0"
97
97
  },
98
98
  "dependencies": {
99
- "@floating-ui/react": "^0.27.19",
100
99
  "@loadable/component": "^5.16.7",
101
100
  "@simplybusiness/icons": "^5.1.0",
102
101
  "@simplybusiness/mobius-hooks": "^0.2.0",
@@ -0,0 +1,25 @@
1
+ import type { CSSProperties, Ref } from "react";
2
+
3
+ interface ArrowProps {
4
+ ref?: Ref<SVGSVGElement>;
5
+ width?: number;
6
+ className?: string;
7
+ style?: CSSProperties;
8
+ }
9
+
10
+ export const Arrow = ({ ref, width = 20, className, style }: ArrowProps) => {
11
+ const height = width / 2;
12
+ return (
13
+ <svg
14
+ ref={ref}
15
+ className={className}
16
+ aria-hidden="true"
17
+ width={width}
18
+ height={height}
19
+ viewBox={`0 0 ${width} ${height}`}
20
+ style={style}
21
+ >
22
+ <path d={`M0,0 H${width} L${width / 2},${height} Z`} />
23
+ </svg>
24
+ );
25
+ };
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Characterization ("golden") tests — freeze the observable API surface of
3
+ * Popover. Added to guard the swap from @floating-ui/react to a local
4
+ * implementation; retained as a long-lived contract test.
5
+ *
6
+ * Pixel math (flip/shift/offset values) is NOT asserted here — jsdom has no
7
+ * layout. Those are verified manually in Storybook.
8
+ */
9
+ import { fireEvent, render, screen } from "@testing-library/react";
10
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
11
+ import { Popover } from ".";
12
+
13
+ describe("Popover — characterization (API contract)", () => {
14
+ describe("trigger element", () => {
15
+ it("renders the trigger as the cloned child with merged class names", () => {
16
+ render(
17
+ <Popover
18
+ trigger={
19
+ <button type="button" className="my-trigger">
20
+ Toggle
21
+ </button>
22
+ }
23
+ >
24
+ Body
25
+ </Popover>,
26
+ );
27
+
28
+ const trigger = screen.getByText("Toggle");
29
+ expect(trigger.tagName).toBe("BUTTON");
30
+ expect(trigger).toHaveClass("my-trigger", "mobius-popover__toggle");
31
+ });
32
+
33
+ it("does not wrap the trigger in an extra element", () => {
34
+ const { container } = render(
35
+ <Popover trigger={<button type="button">Toggle</button>}>Body</Popover>,
36
+ );
37
+ expect(container.firstElementChild?.tagName).toBe("BUTTON");
38
+ });
39
+ });
40
+
41
+ describe("floating container (when open)", () => {
42
+ const open = () => {
43
+ render(
44
+ <Popover trigger={<button type="button">Toggle</button>}>Body</Popover>,
45
+ );
46
+ fireEvent.click(screen.getByText("Toggle"));
47
+ return document.querySelector(
48
+ ".mobius-popover__container",
49
+ ) as HTMLDivElement;
50
+ };
51
+
52
+ it("is not in the DOM when closed", () => {
53
+ render(
54
+ <Popover trigger={<button type="button">Toggle</button>}>Body</Popover>,
55
+ );
56
+ expect(document.querySelector(".mobius-popover__container")).toBeNull();
57
+ });
58
+
59
+ it("is portalled to document.body when open", () => {
60
+ const floating = open();
61
+ expect(floating).toHaveClass("mobius", "mobius-popover__container");
62
+ expect(floating.parentElement).toBe(document.body);
63
+ });
64
+
65
+ it("uses a viewport-anchored positioning strategy", () => {
66
+ // Local implementation uses position: absolute with document-relative
67
+ // top/left (viewport rect + window.scrollY/X). Because the popover is
68
+ // portalled to document.body, the initial containing block is the
69
+ // document, so absolute coords pin it to the trigger and scroll
70
+ // natively with the page.
71
+ const floating = open();
72
+ expect(["fixed", "absolute"]).toContain(floating.style.position);
73
+ });
74
+
75
+ it("has inline top/left styles applied", () => {
76
+ const floating = open();
77
+ expect(floating).not.toHaveStyle({ top: "" });
78
+ expect(floating).not.toHaveStyle({ left: "" });
79
+ });
80
+ });
81
+
82
+ describe("arrow element", () => {
83
+ it("renders an SVG with the arrow class and aria-hidden", () => {
84
+ render(
85
+ <Popover trigger={<button type="button">Toggle</button>}>Body</Popover>,
86
+ );
87
+ fireEvent.click(screen.getByText("Toggle"));
88
+ const arrow = document.querySelector("svg.mobius-popover__arrow-icon");
89
+ expect(arrow).not.toBeNull();
90
+ expect(arrow).toHaveAttribute("aria-hidden", "true");
91
+ expect(arrow).toHaveStyle({ position: "absolute" });
92
+ });
93
+ });
94
+ });
95
+
96
+ describe("Popover — characterization (dismiss semantics)", () => {
97
+ it("closes and fires onClose when pressing outside via pointerdown", () => {
98
+ const onClose = vi.fn();
99
+ render(
100
+ <div>
101
+ <Popover
102
+ trigger={<button type="button">Toggle</button>}
103
+ onClose={onClose}
104
+ >
105
+ Body
106
+ </Popover>
107
+ <button type="button" data-testid="outside">
108
+ Outside
109
+ </button>
110
+ </div>,
111
+ );
112
+
113
+ fireEvent.click(screen.getByText("Toggle"));
114
+ expect(screen.getByText("Body")).toBeInTheDocument();
115
+
116
+ fireEvent.pointerDown(screen.getByTestId("outside"));
117
+
118
+ expect(screen.queryByText("Body")).not.toBeInTheDocument();
119
+ expect(onClose).toHaveBeenCalledTimes(1);
120
+ });
121
+
122
+ it("does not fire onClose when pointerdown lands on the toggle itself", () => {
123
+ const onClose = vi.fn();
124
+ render(
125
+ <Popover
126
+ trigger={<button type="button">Toggle</button>}
127
+ onClose={onClose}
128
+ >
129
+ Body
130
+ </Popover>,
131
+ );
132
+
133
+ fireEvent.click(screen.getByText("Toggle"));
134
+ expect(screen.getByText("Body")).toBeInTheDocument();
135
+
136
+ fireEvent.pointerDown(screen.getByText("Toggle"));
137
+ fireEvent.click(screen.getByText("Toggle"));
138
+
139
+ expect(screen.queryByText("Body")).not.toBeInTheDocument();
140
+ expect(onClose).toHaveBeenCalledTimes(1);
141
+ });
142
+
143
+ it("does not close when pointerdown lands inside the floating container", () => {
144
+ const onClose = vi.fn();
145
+ render(
146
+ <Popover
147
+ trigger={<button type="button">Toggle</button>}
148
+ onClose={onClose}
149
+ >
150
+ <span>Body</span>
151
+ </Popover>,
152
+ );
153
+
154
+ fireEvent.click(screen.getByText("Toggle"));
155
+ fireEvent.pointerDown(screen.getByText("Body"));
156
+
157
+ expect(screen.getByText("Body")).toBeInTheDocument();
158
+ expect(onClose).not.toHaveBeenCalled();
159
+ });
160
+
161
+ it("closes on Escape and fires onClose", () => {
162
+ const onClose = vi.fn();
163
+ render(
164
+ <Popover
165
+ trigger={<button type="button">Toggle</button>}
166
+ onClose={onClose}
167
+ >
168
+ Body
169
+ </Popover>,
170
+ );
171
+
172
+ fireEvent.click(screen.getByText("Toggle"));
173
+ expect(screen.getByText("Body")).toBeInTheDocument();
174
+
175
+ fireEvent.keyDown(window, { key: "Escape" });
176
+
177
+ expect(screen.queryByText("Body")).not.toBeInTheDocument();
178
+ expect(onClose).toHaveBeenCalledTimes(1);
179
+ });
180
+ });
181
+
182
+ describe("Popover — characterization (auto-update subscriptions)", () => {
183
+ let windowAddSpy: ReturnType<typeof vi.spyOn>;
184
+ let windowRemoveSpy: ReturnType<typeof vi.spyOn>;
185
+ let resizeObserverInstances: Array<{
186
+ observe: ReturnType<typeof vi.fn>;
187
+ unobserve: ReturnType<typeof vi.fn>;
188
+ disconnect: ReturnType<typeof vi.fn>;
189
+ }>;
190
+
191
+ beforeEach(() => {
192
+ resizeObserverInstances = [];
193
+ class MockResizeObserver {
194
+ observe = vi.fn();
195
+ unobserve = vi.fn();
196
+ disconnect = vi.fn();
197
+ constructor() {
198
+ resizeObserverInstances.push(this);
199
+ }
200
+ }
201
+ vi.stubGlobal("ResizeObserver", MockResizeObserver);
202
+ windowAddSpy = vi.spyOn(window, "addEventListener");
203
+ windowRemoveSpy = vi.spyOn(window, "removeEventListener");
204
+ });
205
+
206
+ afterEach(() => {
207
+ vi.unstubAllGlobals();
208
+ windowAddSpy.mockRestore();
209
+ windowRemoveSpy.mockRestore();
210
+ });
211
+
212
+ const scrollListeners = () =>
213
+ windowAddSpy.mock.calls.filter((c: unknown[]) => c[0] === "scroll");
214
+ const resizeListeners = () =>
215
+ windowAddSpy.mock.calls.filter((c: unknown[]) => c[0] === "resize");
216
+
217
+ it("subscribes to window scroll when opened", () => {
218
+ render(
219
+ <Popover trigger={<button type="button">Toggle</button>}>Body</Popover>,
220
+ );
221
+ expect(scrollListeners().length).toBe(0);
222
+
223
+ fireEvent.click(screen.getByText("Toggle"));
224
+
225
+ expect(scrollListeners().length).toBeGreaterThan(0);
226
+ });
227
+
228
+ it("subscribes to window resize when opened", () => {
229
+ render(
230
+ <Popover trigger={<button type="button">Toggle</button>}>Body</Popover>,
231
+ );
232
+ fireEvent.click(screen.getByText("Toggle"));
233
+ expect(resizeListeners().length).toBeGreaterThan(0);
234
+ });
235
+
236
+ it("creates a ResizeObserver that observes reference + floating elements", () => {
237
+ render(
238
+ <Popover trigger={<button type="button">Toggle</button>}>Body</Popover>,
239
+ );
240
+ fireEvent.click(screen.getByText("Toggle"));
241
+
242
+ const totalObserves = resizeObserverInstances.reduce(
243
+ (n, ro) => n + ro.observe.mock.calls.length,
244
+ 0,
245
+ );
246
+ expect(totalObserves).toBeGreaterThanOrEqual(2);
247
+ });
248
+
249
+ it("removes scroll/resize listeners when closed", () => {
250
+ render(
251
+ <Popover trigger={<button type="button">Toggle</button>}>Body</Popover>,
252
+ );
253
+ fireEvent.click(screen.getByText("Toggle"));
254
+ const scrollAdded: number = scrollListeners().length;
255
+ const resizeAdded: number = resizeListeners().length;
256
+
257
+ fireEvent.click(screen.getByLabelText("Close"));
258
+
259
+ const scrollRemoved: number = windowRemoveSpy.mock.calls.filter(
260
+ (c: unknown[]) => c[0] === "scroll",
261
+ ).length;
262
+ const resizeRemoved: number = windowRemoveSpy.mock.calls.filter(
263
+ (c: unknown[]) => c[0] === "resize",
264
+ ).length;
265
+
266
+ expect(scrollRemoved).toBeGreaterThanOrEqual(scrollAdded);
267
+ expect(resizeRemoved).toBeGreaterThanOrEqual(resizeAdded);
268
+ });
269
+ });
@@ -1,9 +1,10 @@
1
- import type { Meta, StoryObj } from "@storybook/react";
2
1
  import { circleQuestion } from "@simplybusiness/icons";
2
+ import type { Meta, StoryObj } from "@storybook/react";
3
+ import { useState } from "react";
3
4
  import type { PopoverProps } from "..";
4
- import { Button, Flex, Popover, Icon } from "..";
5
+ import { Button, Flex, Icon, Modal, Popover } from "..";
5
6
  import { excludeControls } from "../../utils";
6
- // eslint-disable-next-line no-restricted-imports -- story-only styles
7
+
7
8
  import "./Popover.story.styles.css";
8
9
 
9
10
  type StoryType = StoryObj<typeof Popover>;
@@ -46,4 +47,40 @@ export const CustomTrigger: StoryType = {
46
47
  ),
47
48
  };
48
49
 
50
+ export const NearBottomEdge: StoryType = {
51
+ render: () => (
52
+ <div style={{ marginTop: "90vh" }}>
53
+ <Popover trigger={<Button variant="primary">Open — flips to top</Button>}>
54
+ Near the bottom edge: the popover flips above the trigger so it stays in
55
+ the viewport.
56
+ </Popover>
57
+ </div>
58
+ ),
59
+ };
60
+
61
+ const InsideModalDemo = () => {
62
+ const [open, setOpen] = useState(false);
63
+ return (
64
+ <>
65
+ <Button onClick={() => setOpen(true)}>Open modal</Button>
66
+ <Modal isOpen={open} onClose={() => setOpen(false)} closeLabel="Close">
67
+ <Modal.Header>Popover inside a modal</Modal.Header>
68
+ <Modal.Content>
69
+ <p>
70
+ The popover must render above the modal backdrop. Click the trigger
71
+ below.
72
+ </p>
73
+ <Popover trigger={<Button variant="primary">Open popover</Button>}>
74
+ Visible above the modal backdrop.
75
+ </Popover>
76
+ </Modal.Content>
77
+ </Modal>
78
+ </>
79
+ );
80
+ };
81
+
82
+ export const InsideModal: StoryType = {
83
+ render: () => <InsideModalDemo />,
84
+ };
85
+
49
86
  export default meta;
@@ -62,7 +62,9 @@ describe("Popover", () => {
62
62
 
63
63
  fireEvent.click(button);
64
64
 
65
- const popoverContainer = button.nextSibling;
65
+ const popoverContainer = document.querySelector(
66
+ `.${CONTAINER_CLASS_NAME}`,
67
+ );
66
68
 
67
69
  await waitFor(() => {
68
70
  expect(popoverContainer).toHaveClass("mobius");
@@ -97,7 +99,9 @@ describe("Popover", () => {
97
99
 
98
100
  fireEvent.click(button);
99
101
 
100
- const popoverContainer = button.nextSibling;
102
+ const popoverContainer = document.querySelector(
103
+ `.${CONTAINER_CLASS_NAME}`,
104
+ );
101
105
 
102
106
  await waitFor(() => {
103
107
  expect(popoverContainer).toHaveClass(customClassName);
@@ -1,22 +1,16 @@
1
- import {
2
- FloatingArrow,
3
- arrow,
4
- autoUpdate,
5
- flip,
6
- offset,
7
- shift,
8
- useDismiss,
9
- useFloating,
10
- useInteractions,
11
- } from "@floating-ui/react";
12
1
  import { cross } from "@simplybusiness/icons";
13
2
  import classNames from "classnames/dedupe";
14
3
  import type { ReactElement, ReactNode, RefAttributes } from "react";
15
4
  import { cloneElement, useCallback, useEffect, useRef, useState } from "react";
5
+ import { createPortal } from "react-dom";
16
6
  import { useWindowEvent } from "@simplybusiness/mobius-hooks";
17
7
  import type { DOMProps } from "../../types";
18
8
  import { Button } from "../Button";
19
9
  import { Icon } from "../Icon";
10
+ import { Arrow } from "./Arrow";
11
+ import { useAutoUpdate } from "./useAutoUpdate";
12
+ import { useFloatingPosition } from "./useFloatingPosition";
13
+ import { useOutsidePress } from "./useOutsidePress";
20
14
  import "./Popover.css";
21
15
 
22
16
  export type PopoverElementType = HTMLDivElement;
@@ -34,38 +28,46 @@ export interface PopoverProps
34
28
  }
35
29
 
36
30
  const OFFSET_FROM_CONTENT_DEFAULT = 10;
31
+ const ARROW_WIDTH = 20;
37
32
 
38
33
  export const Popover = (props: PopoverProps) => {
39
34
  const { trigger, children, onOpen, onClose, className } = props;
40
- const arrowRef = useRef(null);
41
- const floatingContainerRef = useRef<HTMLDivElement | null>(null);
35
+ const referenceRef = useRef<HTMLElement | null>(null);
36
+ const floatingRef = useRef<HTMLDivElement | null>(null);
37
+ const arrowRef = useRef<SVGSVGElement | null>(null);
42
38
  const [isOpen, setIsOpen] = useState(false);
43
- const { refs, floatingStyles, context } = useFloating({
44
- open: isOpen,
45
- onOpenChange: setIsOpen,
46
- whileElementsMounted: autoUpdate,
47
- middleware: [
48
- arrow({
49
- element: arrowRef,
50
- }),
51
- offset(OFFSET_FROM_CONTENT_DEFAULT),
52
- shift(),
53
- flip(),
54
- ],
39
+ const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null);
40
+
41
+ const isInsideDialog =
42
+ portalTarget !== null && portalTarget.tagName === "DIALOG";
43
+
44
+ const { initialFloatingStyles, initialArrowStyles, update } =
45
+ useFloatingPosition({
46
+ referenceRef,
47
+ floatingRef,
48
+ arrowRef,
49
+ isOpen,
50
+ offsetPx: OFFSET_FROM_CONTENT_DEFAULT,
51
+ arrowWidth: ARROW_WIDTH,
52
+ useFixedStrategy: isInsideDialog,
53
+ });
54
+
55
+ useAutoUpdate({
56
+ referenceRef,
57
+ floatingRef,
58
+ onUpdate: update,
59
+ enabled: isOpen,
55
60
  });
56
- const dismiss = useDismiss(context, {
57
- bubbles: true,
58
- outsidePress: (event: MouseEvent) => {
59
- // Prevent 'onClose' from firing when clicking the toggle to close
60
- const toggle = refs.reference.current as HTMLElement;
61
- const isToggleClick = !toggle?.contains(event.target as HTMLElement);
62
- if (isToggleClick) {
63
- onClose?.();
64
- }
65
- return true;
61
+
62
+ useOutsidePress({
63
+ referenceRef,
64
+ floatingRef,
65
+ enabled: isOpen,
66
+ onOutsidePress: () => {
67
+ onClose?.();
68
+ setIsOpen(false);
66
69
  },
67
70
  });
68
- const { getReferenceProps, getFloatingProps } = useInteractions([dismiss]);
69
71
 
70
72
  const containerClasses = classNames(
71
73
  "mobius",
@@ -73,19 +75,11 @@ export const Popover = (props: PopoverProps) => {
73
75
  className,
74
76
  );
75
77
 
76
- const setFloatingRef = useCallback(
77
- (node: HTMLDivElement | null) => {
78
- refs.setFloating(node);
79
- floatingContainerRef.current = node;
80
- },
81
- [refs],
82
- );
83
-
84
78
  // Native listener to prevent clicks inside the popover from activating
85
79
  // interactive ancestors. Must be native because React's onClick fires
86
80
  // too late via delegation.
87
81
  useEffect(() => {
88
- const el = floatingContainerRef.current;
82
+ const el = floatingRef.current;
89
83
  if (!el) return;
90
84
 
91
85
  const preventLabelActivation = (e: Event) => {
@@ -107,64 +101,76 @@ export const Popover = (props: PopoverProps) => {
107
101
  return;
108
102
  }
109
103
 
104
+ // Portal into the nearest open <dialog> ancestor so the popover renders
105
+ // in the same top-layer as the dialog. document.body falls outside that
106
+ // top-layer and paints beneath the modal backdrop.
107
+ const dialog = referenceRef.current?.closest("dialog");
108
+ setPortalTarget(dialog ?? document.body);
110
109
  setIsOpen(true);
111
110
  onOpen?.();
112
111
  };
113
112
 
113
+ const setReferenceRef = useCallback((node: HTMLElement | null) => {
114
+ referenceRef.current = node;
115
+ }, []);
116
+
114
117
  const triggerComponent = cloneElement(trigger, {
115
- ref: refs.setReference,
118
+ ref: setReferenceRef,
116
119
  className: classNames(
117
120
  (trigger.props as { className?: string }).className,
118
121
  "mobius-popover__toggle",
119
122
  ),
120
123
  onClick: toggleVisibility,
121
- ...getReferenceProps(),
122
124
  } as Record<string, unknown>);
123
125
 
124
126
  useWindowEvent("keydown", e => {
125
- if (e.key === "Escape") {
127
+ if (e.key === "Escape" && isOpen) {
128
+ setIsOpen(false);
126
129
  onClose?.();
127
130
  e.preventDefault();
128
131
  e.stopPropagation();
129
132
  }
130
133
  });
131
134
 
135
+ const floatingElement = isOpen ? (
136
+ <div
137
+ className={containerClasses}
138
+ ref={floatingRef}
139
+ style={initialFloatingStyles}
140
+ >
141
+ <div className="mobius-popover">
142
+ <header className="mobius-popover__header">
143
+ <Button
144
+ type="button"
145
+ className="mobius-popover__close-button"
146
+ onClick={toggleVisibility}
147
+ aria-label="Close"
148
+ variant="ghost"
149
+ >
150
+ <Icon
151
+ icon={cross}
152
+ size="md"
153
+ className="mobius-popover__close-icon"
154
+ />
155
+ </Button>
156
+ </header>
157
+ <div className="mobius-popover__body">{children}</div>
158
+ </div>
159
+ <Arrow
160
+ ref={arrowRef}
161
+ style={initialArrowStyles}
162
+ className="mobius-popover__arrow-icon"
163
+ width={ARROW_WIDTH}
164
+ />
165
+ </div>
166
+ ) : null;
167
+
132
168
  return (
133
169
  <>
134
170
  {triggerComponent}
135
- {isOpen && (
136
- <div
137
- className={containerClasses}
138
- ref={setFloatingRef}
139
- style={floatingStyles}
140
- {...getFloatingProps()}
141
- >
142
- <div className="mobius-popover">
143
- <header className="mobius-popover__header">
144
- <Button
145
- type="button"
146
- className="mobius-popover__close-button"
147
- onClick={toggleVisibility}
148
- aria-label="Close"
149
- variant="ghost"
150
- >
151
- <Icon
152
- icon={cross}
153
- size="md"
154
- className="mobius-popover__close-icon"
155
- />
156
- </Button>
157
- </header>
158
- <div className="mobius-popover__body">{children}</div>
159
- </div>
160
- <FloatingArrow
161
- ref={arrowRef}
162
- context={context}
163
- width={20}
164
- className="mobius-popover__arrow-icon"
165
- />
166
- </div>
167
- )}
171
+ {floatingElement && portalTarget
172
+ ? createPortal(floatingElement, portalTarget)
173
+ : null}
168
174
  </>
169
175
  );
170
176
  };