@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.
Files changed (208) hide show
  1. package/dist/cjs/AdaptivePopup/index.cjs +5 -1
  2. package/dist/cjs/AdaptivePopup/index.native.js +5 -1
  3. package/dist/cjs/AdaptivePopup/index.native.js.map +1 -1
  4. package/dist/cjs/Button/index.cjs +2 -1
  5. package/dist/cjs/Button/index.native.js +2 -1
  6. package/dist/cjs/Button/index.native.js.map +1 -1
  7. package/dist/cjs/FloatingPanel/compactFace.cjs +38 -0
  8. package/dist/cjs/FloatingPanel/compactFace.native.js +42 -0
  9. package/dist/cjs/FloatingPanel/compactFace.native.js.map +1 -0
  10. package/dist/cjs/FloatingPanel/index.cjs +38 -9
  11. package/dist/cjs/FloatingPanel/index.native.js +160 -6
  12. package/dist/cjs/FloatingPanel/index.native.js.map +1 -1
  13. package/dist/cjs/FloatingPanel/pointAnchor.cjs +22 -0
  14. package/dist/cjs/FloatingPanel/pointAnchor.native.js +23 -0
  15. package/dist/cjs/FloatingPanel/pointAnchor.native.js.map +1 -1
  16. package/dist/cjs/InputParts/index.cjs +13 -7
  17. package/dist/cjs/InputParts/index.native.js +13 -7
  18. package/dist/cjs/InputParts/index.native.js.map +1 -1
  19. package/dist/cjs/fields/Checkbox/Checkbox.cjs +24 -14
  20. package/dist/cjs/fields/Checkbox/Checkbox.native.js +38 -22
  21. package/dist/cjs/fields/Checkbox/Checkbox.native.js.map +1 -1
  22. package/dist/cjs/fields/RadioGroup/index.cjs +6 -1
  23. package/dist/cjs/fields/RadioGroup/index.native.js +6 -1
  24. package/dist/cjs/fields/RadioGroup/index.native.js.map +1 -1
  25. package/dist/cjs/fields/Select/InlineChoices.cjs +148 -0
  26. package/dist/cjs/fields/Select/InlineChoices.native.js +163 -0
  27. package/dist/cjs/fields/Select/InlineChoices.native.js.map +1 -0
  28. package/dist/cjs/fields/Select/index.cjs +79 -17
  29. package/dist/cjs/fields/Select/index.native.js +92 -17
  30. package/dist/cjs/fields/Select/index.native.js.map +1 -1
  31. package/dist/cjs/fields/Select/shouldInlineChoices.cjs +37 -0
  32. package/dist/cjs/fields/Select/shouldInlineChoices.native.js +43 -0
  33. package/dist/cjs/fields/Select/shouldInlineChoices.native.js.map +1 -0
  34. package/dist/cjs/fields/Switch/index.cjs +22 -10
  35. package/dist/cjs/fields/Switch/index.native.js +28 -14
  36. package/dist/cjs/fields/Switch/index.native.js.map +1 -1
  37. package/dist/cjs/shared/choiceRowPress.cjs +46 -0
  38. package/dist/cjs/shared/choiceRowPress.native.js +50 -0
  39. package/dist/cjs/shared/choiceRowPress.native.js.map +1 -0
  40. package/dist/cjs/shared/overlayStack.cjs +4 -0
  41. package/dist/cjs/shared/overlayStack.native.js +4 -0
  42. package/dist/cjs/shared/overlayStack.native.js.map +1 -1
  43. package/dist/esm/AdaptivePopup/index.mjs +6 -2
  44. package/dist/esm/AdaptivePopup/index.mjs.map +1 -1
  45. package/dist/esm/AdaptivePopup/index.native.js +6 -2
  46. package/dist/esm/AdaptivePopup/index.native.js.map +1 -1
  47. package/dist/esm/Button/index.mjs +3 -2
  48. package/dist/esm/Button/index.mjs.map +1 -1
  49. package/dist/esm/Button/index.native.js +3 -2
  50. package/dist/esm/Button/index.native.js.map +1 -1
  51. package/dist/esm/FloatingPanel/compactFace.mjs +11 -0
  52. package/dist/esm/FloatingPanel/compactFace.mjs.map +1 -0
  53. package/dist/esm/FloatingPanel/compactFace.native.js +12 -0
  54. package/dist/esm/FloatingPanel/compactFace.native.js.map +1 -0
  55. package/dist/esm/FloatingPanel/index.mjs +40 -11
  56. package/dist/esm/FloatingPanel/index.mjs.map +1 -1
  57. package/dist/esm/FloatingPanel/index.native.js +162 -8
  58. package/dist/esm/FloatingPanel/index.native.js.map +1 -1
  59. package/dist/esm/FloatingPanel/pointAnchor.mjs +22 -1
  60. package/dist/esm/FloatingPanel/pointAnchor.mjs.map +1 -1
  61. package/dist/esm/FloatingPanel/pointAnchor.native.js +23 -1
  62. package/dist/esm/FloatingPanel/pointAnchor.native.js.map +1 -1
  63. package/dist/esm/InputParts/index.mjs +13 -7
  64. package/dist/esm/InputParts/index.mjs.map +1 -1
  65. package/dist/esm/InputParts/index.native.js +13 -7
  66. package/dist/esm/InputParts/index.native.js.map +1 -1
  67. package/dist/esm/fields/Checkbox/Checkbox.mjs +24 -14
  68. package/dist/esm/fields/Checkbox/Checkbox.mjs.map +1 -1
  69. package/dist/esm/fields/Checkbox/Checkbox.native.js +38 -22
  70. package/dist/esm/fields/Checkbox/Checkbox.native.js.map +1 -1
  71. package/dist/esm/fields/RadioGroup/index.mjs +6 -1
  72. package/dist/esm/fields/RadioGroup/index.mjs.map +1 -1
  73. package/dist/esm/fields/RadioGroup/index.native.js +6 -1
  74. package/dist/esm/fields/RadioGroup/index.native.js.map +1 -1
  75. package/dist/esm/fields/Select/InlineChoices.mjs +123 -0
  76. package/dist/esm/fields/Select/InlineChoices.mjs.map +1 -0
  77. package/dist/esm/fields/Select/InlineChoices.native.js +135 -0
  78. package/dist/esm/fields/Select/InlineChoices.native.js.map +1 -0
  79. package/dist/esm/fields/Select/index.mjs +82 -20
  80. package/dist/esm/fields/Select/index.mjs.map +1 -1
  81. package/dist/esm/fields/Select/index.native.js +95 -20
  82. package/dist/esm/fields/Select/index.native.js.map +1 -1
  83. package/dist/esm/fields/Select/shouldInlineChoices.mjs +11 -0
  84. package/dist/esm/fields/Select/shouldInlineChoices.mjs.map +1 -0
  85. package/dist/esm/fields/Select/shouldInlineChoices.native.js +14 -0
  86. package/dist/esm/fields/Select/shouldInlineChoices.native.js.map +1 -0
  87. package/dist/esm/fields/Switch/index.mjs +22 -10
  88. package/dist/esm/fields/Switch/index.mjs.map +1 -1
  89. package/dist/esm/fields/Switch/index.native.js +28 -14
  90. package/dist/esm/fields/Switch/index.native.js.map +1 -1
  91. package/dist/esm/shared/choiceRowPress.mjs +20 -0
  92. package/dist/esm/shared/choiceRowPress.mjs.map +1 -0
  93. package/dist/esm/shared/choiceRowPress.native.js +21 -0
  94. package/dist/esm/shared/choiceRowPress.native.js.map +1 -0
  95. package/dist/esm/shared/overlayStack.mjs +4 -1
  96. package/dist/esm/shared/overlayStack.mjs.map +1 -1
  97. package/dist/esm/shared/overlayStack.native.js +4 -1
  98. package/dist/esm/shared/overlayStack.native.js.map +1 -1
  99. package/dist/jsx/AdaptivePopup/index.mjs +6 -2
  100. package/dist/jsx/AdaptivePopup/index.mjs.map +1 -1
  101. package/dist/jsx/AdaptivePopup/index.native.js +5 -1
  102. package/dist/jsx/AdaptivePopup/index.native.js.map +1 -1
  103. package/dist/jsx/Button/index.mjs +3 -2
  104. package/dist/jsx/Button/index.mjs.map +1 -1
  105. package/dist/jsx/Button/index.native.js +2 -1
  106. package/dist/jsx/Button/index.native.js.map +1 -1
  107. package/dist/jsx/FloatingPanel/compactFace.mjs +11 -0
  108. package/dist/jsx/FloatingPanel/compactFace.mjs.map +1 -0
  109. package/dist/jsx/FloatingPanel/compactFace.native.js +42 -0
  110. package/dist/jsx/FloatingPanel/compactFace.native.js.map +1 -0
  111. package/dist/jsx/FloatingPanel/index.mjs +40 -11
  112. package/dist/jsx/FloatingPanel/index.mjs.map +1 -1
  113. package/dist/jsx/FloatingPanel/index.native.js +160 -6
  114. package/dist/jsx/FloatingPanel/index.native.js.map +1 -1
  115. package/dist/jsx/FloatingPanel/pointAnchor.mjs +22 -1
  116. package/dist/jsx/FloatingPanel/pointAnchor.mjs.map +1 -1
  117. package/dist/jsx/FloatingPanel/pointAnchor.native.js +23 -0
  118. package/dist/jsx/FloatingPanel/pointAnchor.native.js.map +1 -1
  119. package/dist/jsx/InputParts/index.mjs +13 -7
  120. package/dist/jsx/InputParts/index.mjs.map +1 -1
  121. package/dist/jsx/InputParts/index.native.js +13 -7
  122. package/dist/jsx/InputParts/index.native.js.map +1 -1
  123. package/dist/jsx/fields/Checkbox/Checkbox.mjs +24 -14
  124. package/dist/jsx/fields/Checkbox/Checkbox.mjs.map +1 -1
  125. package/dist/jsx/fields/Checkbox/Checkbox.native.js +38 -22
  126. package/dist/jsx/fields/Checkbox/Checkbox.native.js.map +1 -1
  127. package/dist/jsx/fields/RadioGroup/index.mjs +6 -1
  128. package/dist/jsx/fields/RadioGroup/index.mjs.map +1 -1
  129. package/dist/jsx/fields/RadioGroup/index.native.js +6 -1
  130. package/dist/jsx/fields/RadioGroup/index.native.js.map +1 -1
  131. package/dist/jsx/fields/Select/InlineChoices.mjs +123 -0
  132. package/dist/jsx/fields/Select/InlineChoices.mjs.map +1 -0
  133. package/dist/jsx/fields/Select/InlineChoices.native.js +163 -0
  134. package/dist/jsx/fields/Select/InlineChoices.native.js.map +1 -0
  135. package/dist/jsx/fields/Select/index.mjs +82 -20
  136. package/dist/jsx/fields/Select/index.mjs.map +1 -1
  137. package/dist/jsx/fields/Select/index.native.js +92 -17
  138. package/dist/jsx/fields/Select/index.native.js.map +1 -1
  139. package/dist/jsx/fields/Select/shouldInlineChoices.mjs +11 -0
  140. package/dist/jsx/fields/Select/shouldInlineChoices.mjs.map +1 -0
  141. package/dist/jsx/fields/Select/shouldInlineChoices.native.js +43 -0
  142. package/dist/jsx/fields/Select/shouldInlineChoices.native.js.map +1 -0
  143. package/dist/jsx/fields/Switch/index.mjs +22 -10
  144. package/dist/jsx/fields/Switch/index.mjs.map +1 -1
  145. package/dist/jsx/fields/Switch/index.native.js +28 -14
  146. package/dist/jsx/fields/Switch/index.native.js.map +1 -1
  147. package/dist/jsx/shared/choiceRowPress.mjs +20 -0
  148. package/dist/jsx/shared/choiceRowPress.mjs.map +1 -0
  149. package/dist/jsx/shared/choiceRowPress.native.js +50 -0
  150. package/dist/jsx/shared/choiceRowPress.native.js.map +1 -0
  151. package/dist/jsx/shared/overlayStack.mjs +4 -1
  152. package/dist/jsx/shared/overlayStack.mjs.map +1 -1
  153. package/dist/jsx/shared/overlayStack.native.js +4 -0
  154. package/dist/jsx/shared/overlayStack.native.js.map +1 -1
  155. package/package.json +8 -8
  156. package/src/AdaptivePopup/index.tsx +6 -1
  157. package/src/Button/Button.controlTiers.spec.tsx +29 -0
  158. package/src/Button/index.tsx +3 -1
  159. package/src/FloatingPanel/FloatingPanel.compactFace.spec.tsx +187 -0
  160. package/src/FloatingPanel/FloatingPanel.menuFace.native.spec.tsx +195 -0
  161. package/src/FloatingPanel/compactFace.spec.ts +40 -0
  162. package/src/FloatingPanel/compactFace.ts +32 -0
  163. package/src/FloatingPanel/index.native.tsx +178 -4
  164. package/src/FloatingPanel/index.tsx +55 -11
  165. package/src/FloatingPanel/pointAnchor.spec.ts +80 -0
  166. package/src/FloatingPanel/pointAnchor.ts +52 -0
  167. package/src/InputParts/index.tsx +18 -7
  168. package/src/fields/Checkbox/Checkbox.native.spec.tsx +44 -0
  169. package/src/fields/Checkbox/Checkbox.tsx +31 -18
  170. package/src/fields/DatePicker/columnFit.spec.tsx +85 -0
  171. package/src/fields/RadioGroup/RadioGroup.native.spec.tsx +65 -0
  172. package/src/fields/RadioGroup/RadioGroup.stories.tsx +49 -0
  173. package/src/fields/RadioGroup/index.tsx +14 -2
  174. package/src/fields/Select/InlineChoices.tsx +156 -0
  175. package/src/fields/Select/index.tsx +100 -45
  176. package/src/fields/Select/shouldInlineChoices.spec.ts +25 -0
  177. package/src/fields/Select/shouldInlineChoices.ts +22 -0
  178. package/src/fields/Switch/Switch.rowPress.native.spec.tsx +69 -0
  179. package/src/fields/Switch/index.tsx +29 -11
  180. package/src/fields/pickerRows.spec.tsx +2 -0
  181. package/src/overlayGround.spec.ts +181 -0
  182. package/src/shared/choiceRowPress.ts +40 -0
  183. package/src/shared/overlayStack.tsx +5 -0
  184. package/types/AdaptivePopup/index.d.ts.map +1 -1
  185. package/types/Button/index.d.ts.map +1 -1
  186. package/types/FloatingPanel/compactFace.d.ts +21 -0
  187. package/types/FloatingPanel/compactFace.d.ts.map +1 -0
  188. package/types/FloatingPanel/index.d.ts +10 -0
  189. package/types/FloatingPanel/index.d.ts.map +1 -1
  190. package/types/FloatingPanel/index.native.d.ts +8 -1
  191. package/types/FloatingPanel/index.native.d.ts.map +1 -1
  192. package/types/FloatingPanel/pointAnchor.d.ts +29 -0
  193. package/types/FloatingPanel/pointAnchor.d.ts.map +1 -1
  194. package/types/InputParts/index.d.ts.map +1 -1
  195. package/types/fields/Checkbox/Checkbox.d.ts.map +1 -1
  196. package/types/fields/Checkbox/index.d.ts +3 -3
  197. package/types/fields/RadioGroup/index.d.ts.map +1 -1
  198. package/types/fields/Select/InlineChoices.d.ts +29 -0
  199. package/types/fields/Select/InlineChoices.d.ts.map +1 -0
  200. package/types/fields/Select/index.d.ts +6 -0
  201. package/types/fields/Select/index.d.ts.map +1 -1
  202. package/types/fields/Select/shouldInlineChoices.d.ts +16 -0
  203. package/types/fields/Select/shouldInlineChoices.d.ts.map +1 -0
  204. package/types/fields/Switch/index.d.ts.map +1 -1
  205. package/types/shared/choiceRowPress.d.ts +40 -0
  206. package/types/shared/choiceRowPress.d.ts.map +1 -0
  207. package/types/shared/overlayStack.d.ts +2 -0
  208. 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 { placeAtPoint, type PanelBounds, type PanelPoint } from "./pointAnchor";
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 = <Surface painted={false}>{panelChildren}</Surface>;
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
- {atPoint && pressPoint ? (
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