@simplybusiness/mobius 10.4.1 → 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.
- package/CHANGELOG.md +14 -0
- package/dist/cjs/components/Popover/Arrow.js +43 -0
- package/dist/cjs/components/Popover/Arrow.js.map +7 -0
- package/dist/cjs/components/Popover/Popover.js +258 -83
- package/dist/cjs/components/Popover/Popover.js.map +4 -4
- package/dist/cjs/components/Popover/index.js +258 -83
- package/dist/cjs/components/Popover/index.js.map +4 -4
- package/dist/cjs/components/Popover/useAutoUpdate.js +53 -0
- package/dist/cjs/components/Popover/useAutoUpdate.js.map +7 -0
- package/dist/cjs/components/Popover/useFloatingPosition.js +128 -0
- package/dist/cjs/components/Popover/useFloatingPosition.js.map +7 -0
- package/dist/cjs/components/Popover/useOutsidePress.js +46 -0
- package/dist/cjs/components/Popover/useOutsidePress.js.map +7 -0
- package/dist/cjs/components/index.js +422 -245
- package/dist/cjs/components/index.js.map +4 -4
- package/dist/cjs/index.js +422 -245
- package/dist/cjs/index.js.map +4 -4
- package/dist/cjs/meta.json +316 -32
- package/dist/esm/chunk-26KZYRE6.js +108 -0
- package/dist/esm/chunk-26KZYRE6.js.map +7 -0
- package/dist/esm/chunk-CAL44W47.js +23 -0
- package/dist/esm/chunk-CAL44W47.js.map +7 -0
- package/dist/esm/{chunk-PEEQNAIN.js → chunk-DMYDWKKA.js} +4 -4
- package/dist/esm/chunk-K3ECDAUR.js +33 -0
- package/dist/esm/chunk-K3ECDAUR.js.map +7 -0
- package/dist/esm/{chunk-GJBH37DH.js → chunk-KFHPI67N.js} +4 -4
- package/dist/esm/{chunk-F5ELD54X.js → chunk-LGZWQZLS.js} +2 -2
- package/dist/esm/{chunk-OAG5T7NC.js → chunk-NEFRXIFY.js} +4 -4
- package/dist/esm/chunk-VZ3IWSK6.js +158 -0
- package/dist/esm/chunk-VZ3IWSK6.js.map +7 -0
- package/dist/esm/chunk-WYJP7HVL.js +26 -0
- package/dist/esm/chunk-WYJP7HVL.js.map +7 -0
- package/dist/esm/components/AddressLookup/AddressLookup.js +4 -4
- package/dist/esm/components/AddressLookup/index.js +6 -6
- package/dist/esm/components/Breadcrumbs/index.js +3 -3
- package/dist/esm/components/Checkbox/index.js +1 -1
- package/dist/esm/components/Combobox/Combobox.js +3 -3
- package/dist/esm/components/Combobox/index.js +3 -3
- package/dist/esm/components/Drawer/index.js +3 -3
- package/dist/esm/components/Modal/index.js +3 -3
- package/dist/esm/components/Popover/Arrow.js +8 -0
- package/dist/esm/components/Popover/Arrow.js.map +7 -0
- package/dist/esm/components/Popover/Popover.js +5 -1
- package/dist/esm/components/Popover/index.js +5 -1
- package/dist/esm/components/Popover/useAutoUpdate.js +8 -0
- package/dist/esm/components/Popover/useAutoUpdate.js.map +7 -0
- package/dist/esm/components/Popover/useFloatingPosition.js +8 -0
- package/dist/esm/components/Popover/useFloatingPosition.js.map +7 -0
- package/dist/esm/components/Popover/useOutsidePress.js +8 -0
- package/dist/esm/components/Popover/useOutsidePress.js.map +7 -0
- package/dist/esm/components/index.js +77 -73
- package/dist/esm/index.js +77 -73
- package/dist/esm/meta.json +3737 -3401
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/dist/types/components/Popover/Arrow.d.ts +9 -0
- package/dist/types/components/Popover/useAutoUpdate.d.ts +9 -0
- package/dist/types/components/Popover/useFloatingPosition.d.ts +17 -0
- package/dist/types/components/Popover/useOutsidePress.d.ts +9 -0
- package/package.json +2 -3
- package/src/components/Popover/Arrow.tsx +25 -0
- package/src/components/Popover/Popover.characterization.test.tsx +269 -0
- package/src/components/Popover/Popover.stories.tsx +40 -3
- package/src/components/Popover/Popover.test.tsx +6 -2
- package/src/components/Popover/Popover.tsx +87 -81
- package/src/components/Popover/useAutoUpdate.ts +43 -0
- package/src/components/Popover/useFloatingPosition.ts +177 -0
- package/src/components/Popover/useOutsidePress.ts +31 -0
- package/dist/esm/chunk-O5YEU5TG.js +0 -155
- package/dist/esm/chunk-O5YEU5TG.js.map +0 -7
- /package/dist/esm/{chunk-PEEQNAIN.js.map → chunk-DMYDWKKA.js.map} +0 -0
- /package/dist/esm/{chunk-GJBH37DH.js.map → chunk-KFHPI67N.js.map} +0 -0
- /package/dist/esm/{chunk-F5ELD54X.js.map → chunk-LGZWQZLS.js.map} +0 -0
- /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.
|
|
4
|
+
"version": "10.4.3",
|
|
5
5
|
"description": "Core library of Mobius react components",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -96,10 +96,9 @@
|
|
|
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
|
-
"@simplybusiness/mobius-hooks": "^0.
|
|
101
|
+
"@simplybusiness/mobius-hooks": "^0.2.0",
|
|
103
102
|
"classnames": "^2.5.1",
|
|
104
103
|
"dialog-polyfill": "^0.5.6",
|
|
105
104
|
"react-accessible-dropdown-menu-hook": "^4.0.1",
|
|
@@ -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,
|
|
5
|
+
import { Button, Flex, Icon, Modal, Popover } from "..";
|
|
5
6
|
import { excludeControls } from "../../utils";
|
|
6
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
41
|
-
const
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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 =
|
|
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:
|
|
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
|
-
{
|
|
136
|
-
|
|
137
|
-
|
|
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
|
};
|