@multiplatform.one/forms 7.57.0 → 7.59.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/AdaptivePopup/index.cjs +5 -1
- package/dist/cjs/AdaptivePopup/index.native.js +5 -1
- package/dist/cjs/AdaptivePopup/index.native.js.map +1 -1
- package/dist/cjs/Button/index.cjs +2 -1
- package/dist/cjs/Button/index.native.js +2 -1
- package/dist/cjs/Button/index.native.js.map +1 -1
- package/dist/cjs/FloatingPanel/compactFace.cjs +38 -0
- package/dist/cjs/FloatingPanel/compactFace.native.js +42 -0
- package/dist/cjs/FloatingPanel/compactFace.native.js.map +1 -0
- package/dist/cjs/FloatingPanel/index.cjs +38 -9
- package/dist/cjs/FloatingPanel/index.native.js +160 -6
- package/dist/cjs/FloatingPanel/index.native.js.map +1 -1
- package/dist/cjs/FloatingPanel/pointAnchor.cjs +22 -0
- package/dist/cjs/FloatingPanel/pointAnchor.native.js +23 -0
- package/dist/cjs/FloatingPanel/pointAnchor.native.js.map +1 -1
- package/dist/cjs/InputParts/index.cjs +13 -7
- package/dist/cjs/InputParts/index.native.js +13 -7
- package/dist/cjs/InputParts/index.native.js.map +1 -1
- package/dist/cjs/fields/Checkbox/Checkbox.cjs +24 -14
- package/dist/cjs/fields/Checkbox/Checkbox.native.js +38 -22
- package/dist/cjs/fields/Checkbox/Checkbox.native.js.map +1 -1
- package/dist/cjs/fields/RadioGroup/index.cjs +6 -1
- package/dist/cjs/fields/RadioGroup/index.native.js +6 -1
- package/dist/cjs/fields/RadioGroup/index.native.js.map +1 -1
- package/dist/cjs/fields/Select/InlineChoices.cjs +148 -0
- package/dist/cjs/fields/Select/InlineChoices.native.js +163 -0
- package/dist/cjs/fields/Select/InlineChoices.native.js.map +1 -0
- package/dist/cjs/fields/Select/index.cjs +79 -17
- package/dist/cjs/fields/Select/index.native.js +92 -17
- package/dist/cjs/fields/Select/index.native.js.map +1 -1
- package/dist/cjs/fields/Select/shouldInlineChoices.cjs +37 -0
- package/dist/cjs/fields/Select/shouldInlineChoices.native.js +43 -0
- package/dist/cjs/fields/Select/shouldInlineChoices.native.js.map +1 -0
- package/dist/cjs/fields/Switch/index.cjs +22 -10
- package/dist/cjs/fields/Switch/index.native.js +28 -14
- package/dist/cjs/fields/Switch/index.native.js.map +1 -1
- package/dist/cjs/shared/choiceRowPress.cjs +46 -0
- package/dist/cjs/shared/choiceRowPress.native.js +50 -0
- package/dist/cjs/shared/choiceRowPress.native.js.map +1 -0
- package/dist/cjs/shared/overlayStack.cjs +4 -0
- package/dist/cjs/shared/overlayStack.native.js +4 -0
- package/dist/cjs/shared/overlayStack.native.js.map +1 -1
- package/dist/esm/AdaptivePopup/index.mjs +6 -2
- package/dist/esm/AdaptivePopup/index.mjs.map +1 -1
- package/dist/esm/AdaptivePopup/index.native.js +6 -2
- package/dist/esm/AdaptivePopup/index.native.js.map +1 -1
- package/dist/esm/Button/index.mjs +3 -2
- package/dist/esm/Button/index.mjs.map +1 -1
- package/dist/esm/Button/index.native.js +3 -2
- package/dist/esm/Button/index.native.js.map +1 -1
- package/dist/esm/FloatingPanel/compactFace.mjs +11 -0
- package/dist/esm/FloatingPanel/compactFace.mjs.map +1 -0
- package/dist/esm/FloatingPanel/compactFace.native.js +12 -0
- package/dist/esm/FloatingPanel/compactFace.native.js.map +1 -0
- package/dist/esm/FloatingPanel/index.mjs +40 -11
- package/dist/esm/FloatingPanel/index.mjs.map +1 -1
- package/dist/esm/FloatingPanel/index.native.js +162 -8
- package/dist/esm/FloatingPanel/index.native.js.map +1 -1
- package/dist/esm/FloatingPanel/pointAnchor.mjs +22 -1
- package/dist/esm/FloatingPanel/pointAnchor.mjs.map +1 -1
- package/dist/esm/FloatingPanel/pointAnchor.native.js +23 -1
- package/dist/esm/FloatingPanel/pointAnchor.native.js.map +1 -1
- package/dist/esm/InputParts/index.mjs +13 -7
- package/dist/esm/InputParts/index.mjs.map +1 -1
- package/dist/esm/InputParts/index.native.js +13 -7
- package/dist/esm/InputParts/index.native.js.map +1 -1
- package/dist/esm/fields/Checkbox/Checkbox.mjs +24 -14
- package/dist/esm/fields/Checkbox/Checkbox.mjs.map +1 -1
- package/dist/esm/fields/Checkbox/Checkbox.native.js +38 -22
- package/dist/esm/fields/Checkbox/Checkbox.native.js.map +1 -1
- package/dist/esm/fields/RadioGroup/index.mjs +6 -1
- package/dist/esm/fields/RadioGroup/index.mjs.map +1 -1
- package/dist/esm/fields/RadioGroup/index.native.js +6 -1
- package/dist/esm/fields/RadioGroup/index.native.js.map +1 -1
- package/dist/esm/fields/Select/InlineChoices.mjs +123 -0
- package/dist/esm/fields/Select/InlineChoices.mjs.map +1 -0
- package/dist/esm/fields/Select/InlineChoices.native.js +135 -0
- package/dist/esm/fields/Select/InlineChoices.native.js.map +1 -0
- package/dist/esm/fields/Select/index.mjs +82 -20
- package/dist/esm/fields/Select/index.mjs.map +1 -1
- package/dist/esm/fields/Select/index.native.js +95 -20
- package/dist/esm/fields/Select/index.native.js.map +1 -1
- package/dist/esm/fields/Select/shouldInlineChoices.mjs +11 -0
- package/dist/esm/fields/Select/shouldInlineChoices.mjs.map +1 -0
- package/dist/esm/fields/Select/shouldInlineChoices.native.js +14 -0
- package/dist/esm/fields/Select/shouldInlineChoices.native.js.map +1 -0
- package/dist/esm/fields/Switch/index.mjs +22 -10
- package/dist/esm/fields/Switch/index.mjs.map +1 -1
- package/dist/esm/fields/Switch/index.native.js +28 -14
- package/dist/esm/fields/Switch/index.native.js.map +1 -1
- package/dist/esm/shared/choiceRowPress.mjs +20 -0
- package/dist/esm/shared/choiceRowPress.mjs.map +1 -0
- package/dist/esm/shared/choiceRowPress.native.js +21 -0
- package/dist/esm/shared/choiceRowPress.native.js.map +1 -0
- package/dist/esm/shared/overlayStack.mjs +4 -1
- package/dist/esm/shared/overlayStack.mjs.map +1 -1
- package/dist/esm/shared/overlayStack.native.js +4 -1
- package/dist/esm/shared/overlayStack.native.js.map +1 -1
- package/dist/jsx/AdaptivePopup/index.mjs +6 -2
- package/dist/jsx/AdaptivePopup/index.mjs.map +1 -1
- package/dist/jsx/AdaptivePopup/index.native.js +5 -1
- package/dist/jsx/AdaptivePopup/index.native.js.map +1 -1
- package/dist/jsx/Button/index.mjs +3 -2
- package/dist/jsx/Button/index.mjs.map +1 -1
- package/dist/jsx/Button/index.native.js +2 -1
- package/dist/jsx/Button/index.native.js.map +1 -1
- package/dist/jsx/FloatingPanel/compactFace.mjs +11 -0
- package/dist/jsx/FloatingPanel/compactFace.mjs.map +1 -0
- package/dist/jsx/FloatingPanel/compactFace.native.js +42 -0
- package/dist/jsx/FloatingPanel/compactFace.native.js.map +1 -0
- package/dist/jsx/FloatingPanel/index.mjs +40 -11
- package/dist/jsx/FloatingPanel/index.mjs.map +1 -1
- package/dist/jsx/FloatingPanel/index.native.js +160 -6
- package/dist/jsx/FloatingPanel/index.native.js.map +1 -1
- package/dist/jsx/FloatingPanel/pointAnchor.mjs +22 -1
- package/dist/jsx/FloatingPanel/pointAnchor.mjs.map +1 -1
- package/dist/jsx/FloatingPanel/pointAnchor.native.js +23 -0
- package/dist/jsx/FloatingPanel/pointAnchor.native.js.map +1 -1
- package/dist/jsx/InputParts/index.mjs +13 -7
- package/dist/jsx/InputParts/index.mjs.map +1 -1
- package/dist/jsx/InputParts/index.native.js +13 -7
- package/dist/jsx/InputParts/index.native.js.map +1 -1
- package/dist/jsx/fields/Checkbox/Checkbox.mjs +24 -14
- package/dist/jsx/fields/Checkbox/Checkbox.mjs.map +1 -1
- package/dist/jsx/fields/Checkbox/Checkbox.native.js +38 -22
- package/dist/jsx/fields/Checkbox/Checkbox.native.js.map +1 -1
- package/dist/jsx/fields/RadioGroup/index.mjs +6 -1
- package/dist/jsx/fields/RadioGroup/index.mjs.map +1 -1
- package/dist/jsx/fields/RadioGroup/index.native.js +6 -1
- package/dist/jsx/fields/RadioGroup/index.native.js.map +1 -1
- package/dist/jsx/fields/Select/InlineChoices.mjs +123 -0
- package/dist/jsx/fields/Select/InlineChoices.mjs.map +1 -0
- package/dist/jsx/fields/Select/InlineChoices.native.js +163 -0
- package/dist/jsx/fields/Select/InlineChoices.native.js.map +1 -0
- package/dist/jsx/fields/Select/index.mjs +82 -20
- package/dist/jsx/fields/Select/index.mjs.map +1 -1
- package/dist/jsx/fields/Select/index.native.js +92 -17
- package/dist/jsx/fields/Select/index.native.js.map +1 -1
- package/dist/jsx/fields/Select/shouldInlineChoices.mjs +11 -0
- package/dist/jsx/fields/Select/shouldInlineChoices.mjs.map +1 -0
- package/dist/jsx/fields/Select/shouldInlineChoices.native.js +43 -0
- package/dist/jsx/fields/Select/shouldInlineChoices.native.js.map +1 -0
- package/dist/jsx/fields/Switch/index.mjs +22 -10
- package/dist/jsx/fields/Switch/index.mjs.map +1 -1
- package/dist/jsx/fields/Switch/index.native.js +28 -14
- package/dist/jsx/fields/Switch/index.native.js.map +1 -1
- package/dist/jsx/shared/choiceRowPress.mjs +20 -0
- package/dist/jsx/shared/choiceRowPress.mjs.map +1 -0
- package/dist/jsx/shared/choiceRowPress.native.js +50 -0
- package/dist/jsx/shared/choiceRowPress.native.js.map +1 -0
- package/dist/jsx/shared/overlayStack.mjs +4 -1
- package/dist/jsx/shared/overlayStack.mjs.map +1 -1
- package/dist/jsx/shared/overlayStack.native.js +4 -0
- package/dist/jsx/shared/overlayStack.native.js.map +1 -1
- package/package.json +8 -8
- package/src/AdaptivePopup/index.tsx +6 -1
- package/src/Button/Button.controlTiers.spec.tsx +29 -0
- package/src/Button/index.tsx +3 -1
- package/src/FloatingPanel/FloatingPanel.compactFace.spec.tsx +187 -0
- package/src/FloatingPanel/FloatingPanel.menuFace.native.spec.tsx +195 -0
- package/src/FloatingPanel/compactFace.spec.ts +40 -0
- package/src/FloatingPanel/compactFace.ts +32 -0
- package/src/FloatingPanel/index.native.tsx +178 -4
- package/src/FloatingPanel/index.tsx +55 -11
- package/src/FloatingPanel/pointAnchor.spec.ts +80 -0
- package/src/FloatingPanel/pointAnchor.ts +52 -0
- package/src/InputParts/index.tsx +18 -7
- package/src/fields/Checkbox/Checkbox.native.spec.tsx +44 -0
- package/src/fields/Checkbox/Checkbox.tsx +31 -18
- package/src/fields/DatePicker/columnFit.spec.tsx +85 -0
- package/src/fields/RadioGroup/RadioGroup.native.spec.tsx +65 -0
- package/src/fields/RadioGroup/RadioGroup.stories.tsx +49 -0
- package/src/fields/RadioGroup/index.tsx +14 -2
- package/src/fields/Select/InlineChoices.tsx +156 -0
- package/src/fields/Select/index.tsx +100 -45
- package/src/fields/Select/shouldInlineChoices.spec.ts +25 -0
- package/src/fields/Select/shouldInlineChoices.ts +22 -0
- package/src/fields/Switch/Switch.rowPress.native.spec.tsx +69 -0
- package/src/fields/Switch/index.tsx +29 -11
- package/src/fields/pickerRows.spec.tsx +2 -0
- package/src/overlayGround.spec.ts +181 -0
- package/src/shared/choiceRowPress.ts +40 -0
- package/src/shared/overlayStack.tsx +5 -0
- package/types/AdaptivePopup/index.d.ts.map +1 -1
- package/types/Button/index.d.ts.map +1 -1
- package/types/FloatingPanel/compactFace.d.ts +21 -0
- package/types/FloatingPanel/compactFace.d.ts.map +1 -0
- package/types/FloatingPanel/index.d.ts +10 -0
- package/types/FloatingPanel/index.d.ts.map +1 -1
- package/types/FloatingPanel/index.native.d.ts +8 -1
- package/types/FloatingPanel/index.native.d.ts.map +1 -1
- package/types/FloatingPanel/pointAnchor.d.ts +29 -0
- package/types/FloatingPanel/pointAnchor.d.ts.map +1 -1
- package/types/InputParts/index.d.ts.map +1 -1
- package/types/fields/Checkbox/Checkbox.d.ts.map +1 -1
- package/types/fields/Checkbox/index.d.ts +3 -3
- package/types/fields/RadioGroup/index.d.ts.map +1 -1
- package/types/fields/Select/InlineChoices.d.ts +29 -0
- package/types/fields/Select/InlineChoices.d.ts.map +1 -0
- package/types/fields/Select/index.d.ts +6 -0
- package/types/fields/Select/index.d.ts.map +1 -1
- package/types/fields/Select/shouldInlineChoices.d.ts +16 -0
- package/types/fields/Select/shouldInlineChoices.d.ts.map +1 -0
- package/types/fields/Switch/index.d.ts.map +1 -1
- package/types/shared/choiceRowPress.d.ts +40 -0
- package/types/shared/choiceRowPress.d.ts.map +1 -0
- package/types/shared/overlayStack.d.ts +2 -0
- package/types/shared/overlayStack.d.ts.map +1 -1
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FZ-7830: on a phone-width web a menu opened inside another overlay opens
|
|
3
|
+
* over its trigger, never as a second bottom sheet; at page level it keeps the
|
|
4
|
+
* sheet. Inside an overlay a collision slides the menu back on screen instead
|
|
5
|
+
* of taking LC-139's collision sheet.
|
|
6
|
+
*/
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
8
|
+
import { fireEvent, waitFor } from "@testing-library/react";
|
|
9
|
+
import { renderWithProviders } from "@multiplatform.one/test-utils";
|
|
10
|
+
import { useState } from "react";
|
|
11
|
+
import { Button } from "../Button";
|
|
12
|
+
import { OverlayStackProvider } from "../shared/overlayStack";
|
|
13
|
+
import type { CompactFace } from "./compactFace";
|
|
14
|
+
import { FloatingPanel } from "./index";
|
|
15
|
+
import { panelViewportPadding } from "./useFloatingPanel";
|
|
16
|
+
|
|
17
|
+
const phone = { width: 390, height: 844 };
|
|
18
|
+
const triggerBounds = {
|
|
19
|
+
x: 20,
|
|
20
|
+
y: 240,
|
|
21
|
+
top: 240,
|
|
22
|
+
bottom: 284,
|
|
23
|
+
left: 20,
|
|
24
|
+
right: 140,
|
|
25
|
+
width: 120,
|
|
26
|
+
height: 44,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
let originalWidth = 0;
|
|
30
|
+
let originalHeight = 0;
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
originalWidth = window.innerWidth;
|
|
33
|
+
originalHeight = window.innerHeight;
|
|
34
|
+
Object.defineProperty(window, "innerWidth", {
|
|
35
|
+
configurable: true,
|
|
36
|
+
writable: true,
|
|
37
|
+
value: phone.width,
|
|
38
|
+
});
|
|
39
|
+
Object.defineProperty(window, "innerHeight", {
|
|
40
|
+
configurable: true,
|
|
41
|
+
writable: true,
|
|
42
|
+
value: phone.height,
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
vi.restoreAllMocks();
|
|
47
|
+
Object.defineProperty(window, "innerWidth", {
|
|
48
|
+
configurable: true,
|
|
49
|
+
writable: true,
|
|
50
|
+
value: originalWidth,
|
|
51
|
+
});
|
|
52
|
+
Object.defineProperty(window, "innerHeight", {
|
|
53
|
+
configurable: true,
|
|
54
|
+
writable: true,
|
|
55
|
+
value: originalHeight,
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
function measure(bounds = triggerBounds, panelWidth = 200, panelHeight = 180) {
|
|
60
|
+
const original = HTMLElement.prototype.getBoundingClientRect;
|
|
61
|
+
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
|
|
62
|
+
function (this: HTMLElement) {
|
|
63
|
+
if (this.hasAttribute("data-fp-reference")) return { ...bounds, toJSON: () => ({}) };
|
|
64
|
+
return original.call(this);
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockImplementation(
|
|
68
|
+
function (this: HTMLElement) {
|
|
69
|
+
return this.hasAttribute("data-fp-panel") ? panelWidth : 0;
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockImplementation(
|
|
73
|
+
function (this: HTMLElement) {
|
|
74
|
+
return this.hasAttribute("data-fp-panel") ? panelHeight : 0;
|
|
75
|
+
},
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function Menu({ face }: { face?: CompactFace }) {
|
|
80
|
+
const [open, setOpen] = useState(false);
|
|
81
|
+
return (
|
|
82
|
+
<FloatingPanel
|
|
83
|
+
open={open}
|
|
84
|
+
onOpenChange={setOpen}
|
|
85
|
+
compactFace={face}
|
|
86
|
+
triggerSizing="content"
|
|
87
|
+
trigger={<Button aria-label="Stack actions">⋯</Button>}
|
|
88
|
+
>
|
|
89
|
+
<Button>Stop</Button>
|
|
90
|
+
<Button>Restart</Button>
|
|
91
|
+
</FloatingPanel>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** What a SheetModal, Dialog or AdaptivePopup puts around its content. */
|
|
96
|
+
function InSheet({ children }: { children: React.ReactNode }) {
|
|
97
|
+
return <OverlayStackProvider zIndex={100_000}>{children}</OverlayStackProvider>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function open(result: ReturnType<typeof renderWithProviders>) {
|
|
101
|
+
fireEvent.click(result.getByRole("button", { name: "Stack actions" }), { detail: 0 });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
describe("FloatingPanel compactFace on a 390pt web viewport (FZ-7830)", () => {
|
|
105
|
+
it("keeps the bottom sheet at page level for the auto face", async () => {
|
|
106
|
+
measure();
|
|
107
|
+
const result = renderWithProviders(<Menu face="auto" />);
|
|
108
|
+
open(result);
|
|
109
|
+
await result.findByTestId("floating-panel-sheet");
|
|
110
|
+
expect(result.queryByTestId("floating-panel-viewport")).toBeNull();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("opens over the trigger inside an overlay for the auto face, never a second sheet", async () => {
|
|
114
|
+
measure();
|
|
115
|
+
const result = renderWithProviders(
|
|
116
|
+
<InSheet>
|
|
117
|
+
<Menu face="auto" />
|
|
118
|
+
</InSheet>,
|
|
119
|
+
);
|
|
120
|
+
open(result);
|
|
121
|
+
const panel = await result.findByTestId("floating-panel-viewport");
|
|
122
|
+
expect(result.queryByTestId("floating-panel-sheet")).toBeNull();
|
|
123
|
+
await waitFor(() => expect(panel.style.top).toBe("240px"));
|
|
124
|
+
expect(panel.style.left).toBe("20px");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("opens over the trigger at page level for the menu face", async () => {
|
|
128
|
+
measure();
|
|
129
|
+
const result = renderWithProviders(<Menu face="menu" />);
|
|
130
|
+
open(result);
|
|
131
|
+
const panel = await result.findByTestId("floating-panel-viewport");
|
|
132
|
+
expect(result.queryByTestId("floating-panel-sheet")).toBeNull();
|
|
133
|
+
await waitFor(() => expect(panel.style.top).toBe("240px"));
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("leaves a panel without a face as a sheet, even inside an overlay", async () => {
|
|
137
|
+
measure();
|
|
138
|
+
const result = renderWithProviders(
|
|
139
|
+
<InSheet>
|
|
140
|
+
<Menu />
|
|
141
|
+
</InSheet>,
|
|
142
|
+
);
|
|
143
|
+
open(result);
|
|
144
|
+
await result.findByTestId("floating-panel-sheet");
|
|
145
|
+
expect(result.queryByTestId("floating-panel-viewport")).toBeNull();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("slides a trailing ⋯ menu back inside the end edge instead of taking a collision sheet", async () => {
|
|
149
|
+
measure({ ...triggerBounds, x: 330, left: 330, right: 374, width: 44 });
|
|
150
|
+
const result = renderWithProviders(
|
|
151
|
+
<InSheet>
|
|
152
|
+
<Menu face="auto" />
|
|
153
|
+
</InSheet>,
|
|
154
|
+
);
|
|
155
|
+
open(result);
|
|
156
|
+
const panel = await result.findByTestId("floating-panel-viewport");
|
|
157
|
+
expect(result.queryByTestId("floating-panel-sheet")).toBeNull();
|
|
158
|
+
await waitFor(() => expect(parseFloat(panel.style.left)).toBeLessThan(330));
|
|
159
|
+
expect(parseFloat(panel.style.left) + 200).toBeLessThanOrEqual(
|
|
160
|
+
phone.width - panelViewportPadding,
|
|
161
|
+
);
|
|
162
|
+
expect(panel.style.top).toBe("240px");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("slides up from the bottom edge instead of taking a collision sheet", async () => {
|
|
166
|
+
measure({ ...triggerBounds, y: 800, top: 800, bottom: 844 });
|
|
167
|
+
const result = renderWithProviders(
|
|
168
|
+
<InSheet>
|
|
169
|
+
<Menu face="auto" />
|
|
170
|
+
</InSheet>,
|
|
171
|
+
);
|
|
172
|
+
open(result);
|
|
173
|
+
const panel = await result.findByTestId("floating-panel-viewport");
|
|
174
|
+
expect(result.queryByTestId("floating-panel-sheet")).toBeNull();
|
|
175
|
+
await waitFor(() => expect(parseFloat(panel.style.top)).toBeLessThan(800));
|
|
176
|
+
expect(parseFloat(panel.style.top) + 180).toBeLessThanOrEqual(
|
|
177
|
+
phone.height - panelViewportPadding,
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("still takes LC-139's collision sheet at page level", async () => {
|
|
182
|
+
measure({ ...triggerBounds, y: 800, top: 800, bottom: 844 });
|
|
183
|
+
const result = renderWithProviders(<Menu face="menu" />);
|
|
184
|
+
open(result);
|
|
185
|
+
await result.findByTestId("floating-panel-sheet");
|
|
186
|
+
});
|
|
187
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FZ-7830: on native a menu-faced FloatingPanel opens over its trigger (LC-112,
|
|
3
|
+
* the UIMenu pull-down) instead of as a bottom sheet, and an `auto` panel does
|
|
4
|
+
* so only inside another overlay, so a sheet never stacks on a sheet.
|
|
5
|
+
*/
|
|
6
|
+
import { renderWithProviders } from "@multiplatform.one/test-utils";
|
|
7
|
+
import { act } from "@testing-library/react";
|
|
8
|
+
import { useState, type ReactNode } from "react";
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
10
|
+
import { Text } from "tamagui";
|
|
11
|
+
import { OverlayStackProvider } from "../shared/overlayStack";
|
|
12
|
+
import type { CompactFace } from "./compactFace";
|
|
13
|
+
import { FloatingPanel } from "./index.native";
|
|
14
|
+
import { panelViewportPadding } from "./useFloatingPanel";
|
|
15
|
+
|
|
16
|
+
vi.mock("react-native-web", async (importOriginal) => {
|
|
17
|
+
const actual = await importOriginal<typeof import("react-native")>();
|
|
18
|
+
return {
|
|
19
|
+
...actual,
|
|
20
|
+
ActionSheetIOS: undefined,
|
|
21
|
+
Modal: ({ children, visible }: { children: ReactNode; visible: boolean }) =>
|
|
22
|
+
visible ? children : null,
|
|
23
|
+
useWindowDimensions: () => ({ width: 390, height: 844, scale: 3, fontScale: 1 }),
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
let trigger = { x: 20, y: 240, width: 120, height: 44 };
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
vi.useFakeTimers();
|
|
30
|
+
trigger = { x: 20, y: 240, width: 120, height: 44 };
|
|
31
|
+
// RN-web's measureInWindow reads the node's client rect on a timer.
|
|
32
|
+
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(() => ({
|
|
33
|
+
x: trigger.x,
|
|
34
|
+
y: trigger.y,
|
|
35
|
+
left: trigger.x,
|
|
36
|
+
top: trigger.y,
|
|
37
|
+
width: trigger.width,
|
|
38
|
+
height: trigger.height,
|
|
39
|
+
right: trigger.x + trigger.width,
|
|
40
|
+
bottom: trigger.y + trigger.height,
|
|
41
|
+
toJSON: () => ({}),
|
|
42
|
+
}));
|
|
43
|
+
});
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
vi.restoreAllMocks();
|
|
46
|
+
vi.useRealTimers();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
function Menu({ face, attach = false }: { face?: CompactFace; attach?: boolean }) {
|
|
50
|
+
const [open, setOpen] = useState(false);
|
|
51
|
+
return (
|
|
52
|
+
<FloatingPanel
|
|
53
|
+
open={open}
|
|
54
|
+
onOpenChange={setOpen}
|
|
55
|
+
compactFace={face}
|
|
56
|
+
{...(attach ? { anchorMode: "attach" as const, triggerEcho: false } : null)}
|
|
57
|
+
triggerSizing="content"
|
|
58
|
+
contentPadding="none"
|
|
59
|
+
trigger={<Text>Stack actions</Text>}
|
|
60
|
+
triggerA11y={{ label: "Stack actions" }}
|
|
61
|
+
>
|
|
62
|
+
<Text>Stop</Text>
|
|
63
|
+
<Text>Restart</Text>
|
|
64
|
+
</FloatingPanel>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** What a native SheetModal's OverlayWindow puts around its content. */
|
|
69
|
+
function InSheet({ children }: { children: ReactNode }) {
|
|
70
|
+
return <OverlayStackProvider zIndex={100_000}>{children}</OverlayStackProvider>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function tap(target: Element) {
|
|
74
|
+
act(() => {
|
|
75
|
+
target.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 }));
|
|
76
|
+
target.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, button: 0 }));
|
|
77
|
+
target.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0 }));
|
|
78
|
+
});
|
|
79
|
+
// measureInWindow answers on the next tick.
|
|
80
|
+
act(() => {
|
|
81
|
+
vi.advanceTimersByTime(1);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** RN-web parks onLayout on the node for a ResizeObserver happy-dom never fires. */
|
|
86
|
+
function layout(node: Element, width: number, height: number) {
|
|
87
|
+
const onLayout = (node as unknown as { __reactLayoutHandler?: (event: unknown) => void })
|
|
88
|
+
.__reactLayoutHandler;
|
|
89
|
+
expect(onLayout).toBeTypeOf("function");
|
|
90
|
+
act(() => {
|
|
91
|
+
onLayout?.({ nativeEvent: { layout: { x: 0, y: 0, width, height } } });
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const menu = () => document.querySelector<HTMLElement>('[data-testid="floating-panel-menu"]');
|
|
96
|
+
const sheet = () => document.querySelector('[data-testid="floating-panel-sheet"]');
|
|
97
|
+
const echo = () => document.querySelector('[data-testid="floating-panel-menu-echo"]');
|
|
98
|
+
|
|
99
|
+
describe("FloatingPanel native menu face (FZ-7830)", () => {
|
|
100
|
+
it("opens the menu face over the trigger at page level, never a sheet", () => {
|
|
101
|
+
const result = renderWithProviders(<Menu face="menu" />);
|
|
102
|
+
tap(result.getByRole("button", { name: "Stack actions" }));
|
|
103
|
+
const panel = menu();
|
|
104
|
+
expect(panel).not.toBeNull();
|
|
105
|
+
expect(sheet()).toBeNull();
|
|
106
|
+
expect(panel?.style.opacity).toBe("0");
|
|
107
|
+
layout(panel as HTMLElement, 200, 180);
|
|
108
|
+
expect(panel?.style.left).toBe("20px");
|
|
109
|
+
expect(panel?.style.top).toBe("240px");
|
|
110
|
+
expect(panel?.style.opacity).toBe("1");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("repeats the trigger as the first row, in the trigger's own box", () => {
|
|
114
|
+
const result = renderWithProviders(<Menu face="menu" />);
|
|
115
|
+
tap(result.getByRole("button", { name: "Stack actions" }));
|
|
116
|
+
const row = echo() as HTMLElement;
|
|
117
|
+
expect(row).not.toBeNull();
|
|
118
|
+
expect(row.textContent).toBe("Stack actions");
|
|
119
|
+
expect(row.style.height).toBe("44px");
|
|
120
|
+
expect(row.getAttribute("aria-expanded")).toBe("true");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("closes when the repeated trigger row is pressed", () => {
|
|
124
|
+
const result = renderWithProviders(<Menu face="menu" />);
|
|
125
|
+
tap(result.getByRole("button", { name: "Stack actions" }));
|
|
126
|
+
act(() => {
|
|
127
|
+
vi.advanceTimersByTime(1000);
|
|
128
|
+
});
|
|
129
|
+
tap(echo() as HTMLElement);
|
|
130
|
+
expect(menu()).toBeNull();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("slides a trailing ⋯ menu back inside the padded window", () => {
|
|
134
|
+
trigger = { x: 330, y: 240, width: 44, height: 44 };
|
|
135
|
+
const result = renderWithProviders(<Menu face="menu" />);
|
|
136
|
+
tap(result.getByRole("button", { name: "Stack actions" }));
|
|
137
|
+
const panel = menu() as HTMLElement;
|
|
138
|
+
layout(panel, 200, 180);
|
|
139
|
+
expect(panel.style.left).toBe(`${390 - panelViewportPadding - 200}px`);
|
|
140
|
+
expect(panel.style.top).toBe("240px");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("slides up from the bottom edge instead of running under it", () => {
|
|
144
|
+
trigger = { x: 20, y: 800, width: 120, height: 44 };
|
|
145
|
+
const result = renderWithProviders(<Menu face="menu" />);
|
|
146
|
+
tap(result.getByRole("button", { name: "Stack actions" }));
|
|
147
|
+
const panel = menu() as HTMLElement;
|
|
148
|
+
layout(panel, 200, 180);
|
|
149
|
+
const top = parseFloat(panel.style.top);
|
|
150
|
+
expect(top + 180).toBeLessThanOrEqual(844 - panelViewportPadding);
|
|
151
|
+
expect(top).toBeLessThan(800);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("keeps the sheet at page level for the auto face", () => {
|
|
155
|
+
const result = renderWithProviders(<Menu face="auto" />);
|
|
156
|
+
tap(result.getByRole("button", { name: "Stack actions" }));
|
|
157
|
+
expect(sheet()).not.toBeNull();
|
|
158
|
+
expect(menu()).toBeNull();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("opens the auto face over the trigger inside another overlay, never a second sheet", () => {
|
|
162
|
+
const result = renderWithProviders(
|
|
163
|
+
<InSheet>
|
|
164
|
+
<Menu face="auto" />
|
|
165
|
+
</InSheet>,
|
|
166
|
+
);
|
|
167
|
+
tap(result.getByRole("button", { name: "Stack actions" }));
|
|
168
|
+
expect(sheet()).toBeNull();
|
|
169
|
+
const panel = menu() as HTMLElement;
|
|
170
|
+
layout(panel, 200, 180);
|
|
171
|
+
expect(panel.style.top).toBe("240px");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("leaves a panel without a face as a sheet, even inside an overlay", () => {
|
|
175
|
+
const result = renderWithProviders(
|
|
176
|
+
<InSheet>
|
|
177
|
+
<Menu />
|
|
178
|
+
</InSheet>,
|
|
179
|
+
);
|
|
180
|
+
tap(result.getByRole("button", { name: "Stack actions" }));
|
|
181
|
+
expect(sheet()).not.toBeNull();
|
|
182
|
+
expect(menu()).toBeNull();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("opens an attached menu (an icon trigger) under its trigger, with no echo row (LC-262)", () => {
|
|
186
|
+
const result = renderWithProviders(<Menu face="menu" attach />);
|
|
187
|
+
tap(result.getByRole("button", { name: "Stack actions" }));
|
|
188
|
+
const panel = menu();
|
|
189
|
+
expect(panel).not.toBeNull();
|
|
190
|
+
layout(panel as HTMLElement, 200, 180);
|
|
191
|
+
expect(panel?.style.top).toBe(`${trigger.y + trigger.height}px`);
|
|
192
|
+
expect(echo()).toBeNull();
|
|
193
|
+
expect(sheet()).toBeNull();
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { pickerCompactFace, resolveCompactFace, SHORT_LIST_MAX } from "./compactFace";
|
|
3
|
+
|
|
4
|
+
describe("resolveCompactFace (FZ-7830)", () => {
|
|
5
|
+
it("keeps the sheet by default, at page level and inside an overlay", () => {
|
|
6
|
+
expect(resolveCompactFace(undefined, false)).toBe("sheet");
|
|
7
|
+
expect(resolveCompactFace(undefined, true)).toBe("sheet");
|
|
8
|
+
expect(resolveCompactFace("sheet", true)).toBe("sheet");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("opens a menu everywhere for the menu face", () => {
|
|
12
|
+
expect(resolveCompactFace("menu", false)).toBe("menu");
|
|
13
|
+
expect(resolveCompactFace("menu", true)).toBe("menu");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("opens a menu only inside another overlay for the auto face", () => {
|
|
17
|
+
expect(resolveCompactFace("auto", false)).toBe("sheet");
|
|
18
|
+
expect(resolveCompactFace("auto", true)).toBe("menu");
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("pickerCompactFace (FZ-7830)", () => {
|
|
23
|
+
it("opens a short list as a menu at every level", () => {
|
|
24
|
+
expect(SHORT_LIST_MAX).toBe(7);
|
|
25
|
+
expect(pickerCompactFace(1)).toBe("menu");
|
|
26
|
+
expect(pickerCompactFace(7)).toBe("menu");
|
|
27
|
+
expect(resolveCompactFace(pickerCompactFace(7), false)).toBe("menu");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("keeps a long list's sheet at page level and opens it as a menu inside an overlay", () => {
|
|
31
|
+
expect(pickerCompactFace(8)).toBe("auto");
|
|
32
|
+
expect(resolveCompactFace(pickerCompactFace(8), false)).toBe("sheet");
|
|
33
|
+
expect(resolveCompactFace(pickerCompactFace(8), true)).toBe("menu");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("keeps a searchable list's sheet at page level, however short", () => {
|
|
37
|
+
expect(pickerCompactFace(3, true)).toBe("auto");
|
|
38
|
+
expect(resolveCompactFace(pickerCompactFace(3, true), false)).toBe("sheet");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a FloatingPanel becomes where it would otherwise be a bottom sheet:
|
|
3
|
+
* on native, and on the web at or below OVERLAY_BREAKPOINT.
|
|
4
|
+
*
|
|
5
|
+
* - `"sheet"`: the bottom sheet, wherever it opens.
|
|
6
|
+
* - `"menu"`: a menu over its trigger (LC-112) wherever it opens.
|
|
7
|
+
* - `"auto"`: the sheet at page level, the menu inside another overlay. A
|
|
8
|
+
* sheet over a sheet is the one thing a phone must never show (FZ-7830).
|
|
9
|
+
*/
|
|
10
|
+
export type CompactFace = "sheet" | "menu" | "auto";
|
|
11
|
+
|
|
12
|
+
export function resolveCompactFace(
|
|
13
|
+
face: CompactFace | undefined,
|
|
14
|
+
insideOverlay: boolean,
|
|
15
|
+
): "sheet" | "menu" {
|
|
16
|
+
if (face === "menu") return "menu";
|
|
17
|
+
if (face === "auto") return insideOverlay ? "menu" : "sheet";
|
|
18
|
+
return "sheet";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The longest list a picker opens as a menu over its trigger (FZ-7830). */
|
|
22
|
+
export const SHORT_LIST_MAX = 7;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A picker's face (FZ-7830, LC-112): a short list with no search opens as a
|
|
26
|
+
* menu over its trigger at every level, the iOS 14+ pull-down picker. A long or
|
|
27
|
+
* searchable list keeps the sheet at page level and opens as a scrolling menu
|
|
28
|
+
* inside another overlay.
|
|
29
|
+
*/
|
|
30
|
+
export function pickerCompactFace(optionCount: number, searchable = false): CompactFace {
|
|
31
|
+
return !searchable && optionCount <= SHORT_LIST_MAX ? "menu" : "auto";
|
|
32
|
+
}
|
|
@@ -44,6 +44,13 @@
|
|
|
44
44
|
* - Opening a Modal under the same finger can synthesize a backdrop press;
|
|
45
45
|
* backdrop dismiss is armed a short delay after the present begins.
|
|
46
46
|
*
|
|
47
|
+
* Menu face (`compactFace`, FZ-7830): instead of the sheet, a menu opens
|
|
48
|
+
* over the trigger (LC-112), its top start corner on the trigger's, the
|
|
49
|
+
* trigger echoed as its first row, sliding back inside the safe area where it
|
|
50
|
+
* would run off screen. DropdownMenu always opens this way (a UIMenu
|
|
51
|
+
* pull-down); Select opens this way for a short list, and for any list inside
|
|
52
|
+
* another overlay, so a sheet never stacks on a sheet.
|
|
53
|
+
*
|
|
47
54
|
* ContextMenu (`openOn="contextmenu"`): long-press opens it. On a window wider
|
|
48
55
|
* than OVERLAY_BREAKPOINT (a tablet) it opens at the long-press point, the
|
|
49
56
|
* same free overlay the web draws at the pointer; a phone keeps the sheet,
|
|
@@ -83,12 +90,19 @@ import {
|
|
|
83
90
|
motionDurationMs,
|
|
84
91
|
} from "@multiplatform.one/theme";
|
|
85
92
|
import { View, YStack } from "tamagui";
|
|
86
|
-
import { useOverlayZIndex } from "../shared/overlayStack";
|
|
93
|
+
import { useInsideOverlay, useOverlayZIndex } from "../shared/overlayStack";
|
|
87
94
|
import { OverlayWindow } from "../shared/OverlayWindow";
|
|
88
95
|
import { t } from "../shared/t";
|
|
89
96
|
import { useKeyboardInset } from "../shared/useKeyboardInset";
|
|
90
97
|
import type { FloatingPanelProps } from "./index";
|
|
91
|
-
import {
|
|
98
|
+
import { resolveCompactFace } from "./compactFace";
|
|
99
|
+
import {
|
|
100
|
+
placeAtPoint,
|
|
101
|
+
placeOverTrigger,
|
|
102
|
+
type PanelBounds,
|
|
103
|
+
type PanelPoint,
|
|
104
|
+
type TriggerRect,
|
|
105
|
+
} from "./pointAnchor";
|
|
92
106
|
import { panelViewportPadding } from "./useFloatingPanel";
|
|
93
107
|
import { TrailingActionOverlay, TriggerWithTrailingAction } from "./trailingAction";
|
|
94
108
|
|
|
@@ -142,11 +156,19 @@ export function FloatingPanel({
|
|
|
142
156
|
triggerA11y,
|
|
143
157
|
sheetFill,
|
|
144
158
|
openOn = "press",
|
|
159
|
+
compactFace,
|
|
160
|
+
triggerRow,
|
|
161
|
+
triggerEcho = true,
|
|
162
|
+
anchorMode,
|
|
145
163
|
}: FloatingPanelProps) {
|
|
146
164
|
// The panel floats over the page: it never inherits a painted ancestor.
|
|
147
165
|
const { knobProps } = useResolvedKnobs({ painted: false });
|
|
148
166
|
const windowZIndex = useOverlayZIndex("sheet");
|
|
149
|
-
const children =
|
|
167
|
+
const children = (
|
|
168
|
+
<Surface painted={false} elevated>
|
|
169
|
+
{panelChildren}
|
|
170
|
+
</Surface>
|
|
171
|
+
);
|
|
150
172
|
const slideTransition = knobProps.motion.slide.transition;
|
|
151
173
|
const enterDuration = motionDurationMs(slideTransition);
|
|
152
174
|
const scrimDuration = motionDurationMs(knobProps.motion.scrim.transition);
|
|
@@ -181,6 +203,34 @@ export function FloatingPanel({
|
|
|
181
203
|
);
|
|
182
204
|
const isContextMenu = openOn === "contextmenu";
|
|
183
205
|
const atPoint = isContextMenu && pressPoint !== null && windowWidth > OVERLAY_BREAKPOINT;
|
|
206
|
+
const insideOverlay = useInsideOverlay();
|
|
207
|
+
const asMenu = !isContextMenu && resolveCompactFace(compactFace, insideOverlay) === "menu";
|
|
208
|
+
const triggerRef = useRef<RNView | null>(null);
|
|
209
|
+
const [anchor, setAnchor] = useState<TriggerRect | null>(null);
|
|
210
|
+
// The trigger's window rect, read each time the menu opens: it may have
|
|
211
|
+
// moved (a scrolled sheet) since the last open.
|
|
212
|
+
useLayoutEffect(() => {
|
|
213
|
+
if (!asMenu || !open) {
|
|
214
|
+
setAnchor(null);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const node = triggerRef.current as
|
|
218
|
+
| (RNView & {
|
|
219
|
+
measureInWindow?: (cb: (x: number, y: number, w: number, h: number) => void) => void;
|
|
220
|
+
})
|
|
221
|
+
| null;
|
|
222
|
+
let live = true;
|
|
223
|
+
if (typeof node?.measureInWindow === "function") {
|
|
224
|
+
node.measureInWindow((x, y, width, height) => {
|
|
225
|
+
if (live) setAnchor({ x, y, width, height });
|
|
226
|
+
});
|
|
227
|
+
} else {
|
|
228
|
+
setAnchor({ x: 0, y: 0, width: 0, height: 0 });
|
|
229
|
+
}
|
|
230
|
+
return () => {
|
|
231
|
+
live = false;
|
|
232
|
+
};
|
|
233
|
+
}, [asMenu, open]);
|
|
184
234
|
|
|
185
235
|
const handleBackdropPress = useCallback(() => {
|
|
186
236
|
if (backdropArmed) close();
|
|
@@ -473,6 +523,8 @@ export function FloatingPanel({
|
|
|
473
523
|
return (
|
|
474
524
|
<>
|
|
475
525
|
<RNView
|
|
526
|
+
ref={triggerRef}
|
|
527
|
+
collapsable={false}
|
|
476
528
|
style={{
|
|
477
529
|
flexDirection: "row",
|
|
478
530
|
alignItems: "center",
|
|
@@ -526,7 +578,35 @@ export function FloatingPanel({
|
|
|
526
578
|
}}
|
|
527
579
|
>
|
|
528
580
|
<OverlayWindow zIndex={windowZIndex}>
|
|
529
|
-
{
|
|
581
|
+
{asMenu ? (
|
|
582
|
+
<TriggerMenuPanel
|
|
583
|
+
// An attached menu (an icon trigger, no echo row) opens under its
|
|
584
|
+
// trigger instead of over it, the UIMenu pull-down (LC-262).
|
|
585
|
+
anchor={
|
|
586
|
+
anchor && anchorMode === "attach"
|
|
587
|
+
? { ...anchor, y: anchor.y + anchor.height }
|
|
588
|
+
: anchor
|
|
589
|
+
}
|
|
590
|
+
bounds={{
|
|
591
|
+
top: topInset + panelViewportPadding,
|
|
592
|
+
left: (safeArea?.left ?? 0) + panelViewportPadding,
|
|
593
|
+
right: windowWidth - (safeArea?.right ?? 0) - panelViewportPadding,
|
|
594
|
+
bottom:
|
|
595
|
+
windowHeight -
|
|
596
|
+
Math.max(safeArea?.bottom ?? 0, keyboardInset) -
|
|
597
|
+
panelViewportPadding,
|
|
598
|
+
}}
|
|
599
|
+
echo={triggerEcho ? (triggerRow ?? trigger) : null}
|
|
600
|
+
echoLabel={triggerA11y?.label}
|
|
601
|
+
framePadding={framePadding}
|
|
602
|
+
frameGap={frameGap}
|
|
603
|
+
onBackdropPress={handleBackdropPress}
|
|
604
|
+
onClose={close}
|
|
605
|
+
>
|
|
606
|
+
{header}
|
|
607
|
+
{children}
|
|
608
|
+
</TriggerMenuPanel>
|
|
609
|
+
) : atPoint && pressPoint ? (
|
|
530
610
|
<PointPanel
|
|
531
611
|
key={`${pressPoint.x},${pressPoint.y}`}
|
|
532
612
|
point={pressPoint}
|
|
@@ -644,6 +724,100 @@ export function FloatingPanel({
|
|
|
644
724
|
);
|
|
645
725
|
}
|
|
646
726
|
|
|
727
|
+
/**
|
|
728
|
+
* The menu over its trigger (LC-112): measured once per open at its natural
|
|
729
|
+
* size, then placed on the trigger's window rect. The first row repeats the
|
|
730
|
+
* trigger in the trigger's own box, and pressing it closes the menu, as the
|
|
731
|
+
* web echo row does.
|
|
732
|
+
*/
|
|
733
|
+
function TriggerMenuPanel({
|
|
734
|
+
anchor,
|
|
735
|
+
bounds,
|
|
736
|
+
echo,
|
|
737
|
+
echoLabel,
|
|
738
|
+
framePadding,
|
|
739
|
+
frameGap,
|
|
740
|
+
onBackdropPress,
|
|
741
|
+
onClose,
|
|
742
|
+
children,
|
|
743
|
+
}: {
|
|
744
|
+
anchor: TriggerRect | null;
|
|
745
|
+
bounds: PanelBounds;
|
|
746
|
+
echo: ReactNode;
|
|
747
|
+
echoLabel?: string;
|
|
748
|
+
framePadding: string;
|
|
749
|
+
frameGap: string | undefined;
|
|
750
|
+
onBackdropPress: () => void;
|
|
751
|
+
onClose: () => void;
|
|
752
|
+
children: ReactNode;
|
|
753
|
+
}) {
|
|
754
|
+
const { knobProps } = useResolvedKnobs({ painted: false });
|
|
755
|
+
const [size, setSize] = useState<{ width: number; height: number } | null>(null);
|
|
756
|
+
const placement =
|
|
757
|
+
anchor && size
|
|
758
|
+
? placeOverTrigger({
|
|
759
|
+
trigger: anchor,
|
|
760
|
+
width: size.width,
|
|
761
|
+
height: size.height,
|
|
762
|
+
bounds,
|
|
763
|
+
rtl: I18nManager.isRTL,
|
|
764
|
+
})
|
|
765
|
+
: null;
|
|
766
|
+
return (
|
|
767
|
+
<RNView style={StyleSheet.absoluteFill}>
|
|
768
|
+
<Pressable
|
|
769
|
+
style={StyleSheet.absoluteFill}
|
|
770
|
+
onPress={onBackdropPress}
|
|
771
|
+
accessibilityRole="button"
|
|
772
|
+
accessibilityLabel={t("Close")}
|
|
773
|
+
/>
|
|
774
|
+
<RNView
|
|
775
|
+
testID="floating-panel-menu"
|
|
776
|
+
onLayout={(e) => {
|
|
777
|
+
if (size) return;
|
|
778
|
+
const { width, height } = e.nativeEvent.layout;
|
|
779
|
+
if (width > 0 && height > 0) setSize({ width, height });
|
|
780
|
+
}}
|
|
781
|
+
style={{
|
|
782
|
+
position: "absolute",
|
|
783
|
+
left: placement?.left ?? bounds.left,
|
|
784
|
+
top: placement?.top ?? bounds.top,
|
|
785
|
+
minWidth: anchor?.width ?? 0,
|
|
786
|
+
maxWidth: bounds.right - bounds.left,
|
|
787
|
+
maxHeight: placement?.maxHeight ?? bounds.bottom - bounds.top,
|
|
788
|
+
opacity: placement ? 1 : 0,
|
|
789
|
+
}}
|
|
790
|
+
>
|
|
791
|
+
<YStack
|
|
792
|
+
{...knobProps.elevatedSurface}
|
|
793
|
+
flexShrink={1}
|
|
794
|
+
minHeight={0}
|
|
795
|
+
overflow="hidden"
|
|
796
|
+
onAccessibilityEscape={onClose}
|
|
797
|
+
>
|
|
798
|
+
{echo != null && anchor && anchor.height > 0 ? (
|
|
799
|
+
<Pressable
|
|
800
|
+
testID="floating-panel-menu-echo"
|
|
801
|
+
onPress={onClose}
|
|
802
|
+
accessibilityRole="button"
|
|
803
|
+
{...(echoLabel ? { accessibilityLabel: echoLabel } : undefined)}
|
|
804
|
+
aria-expanded
|
|
805
|
+
style={{ height: anchor.height, minWidth: anchor.width, justifyContent: "center" }}
|
|
806
|
+
>
|
|
807
|
+
<RNView pointerEvents="none">{echo}</RNView>
|
|
808
|
+
</Pressable>
|
|
809
|
+
) : null}
|
|
810
|
+
<RNScrollView style={{ flexGrow: 0, flexShrink: 1 }} keyboardShouldPersistTaps="handled">
|
|
811
|
+
<YStack padding={framePadding} gap={frameGap}>
|
|
812
|
+
{children}
|
|
813
|
+
</YStack>
|
|
814
|
+
</RNScrollView>
|
|
815
|
+
</YStack>
|
|
816
|
+
</RNView>
|
|
817
|
+
</RNView>
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
|
|
647
821
|
/**
|
|
648
822
|
* The menu at the long-press point: measured once per open at its natural
|
|
649
823
|
* size, then placed by the same rule as the web pointer menu. Later layouts
|