@multiplatform.one/forms 7.80.0 → 7.81.1

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 (221) hide show
  1. package/dist/cjs/AdaptivePopup/index.cjs +2 -1
  2. package/dist/cjs/AdaptivePopup/index.native.js +2 -1
  3. package/dist/cjs/AdaptivePopup/index.native.js.map +1 -1
  4. package/dist/cjs/Button/index.cjs +12 -5
  5. package/dist/cjs/Button/index.native.js +14 -6
  6. package/dist/cjs/Button/index.native.js.map +1 -1
  7. package/dist/cjs/FloatingPanel/PanelHeader.cjs +3 -8
  8. package/dist/cjs/FloatingPanel/PanelHeader.native.js +3 -8
  9. package/dist/cjs/FloatingPanel/PanelHeader.native.js.map +1 -1
  10. package/dist/cjs/FloatingPanel/coverRect.cjs +0 -8
  11. package/dist/cjs/FloatingPanel/coverRect.native.js +0 -8
  12. package/dist/cjs/FloatingPanel/coverRect.native.js.map +1 -1
  13. package/dist/cjs/FloatingPanel/echoContext.cjs +5 -27
  14. package/dist/cjs/FloatingPanel/echoContext.native.js +5 -28
  15. package/dist/cjs/FloatingPanel/echoContext.native.js.map +1 -1
  16. package/dist/cjs/FloatingPanel/index.cjs +49 -131
  17. package/dist/cjs/FloatingPanel/index.native.js +216 -135
  18. package/dist/cjs/FloatingPanel/index.native.js.map +1 -1
  19. package/dist/cjs/fields/Combobox/index.cjs +3 -10
  20. package/dist/cjs/fields/Combobox/index.native.js +3 -10
  21. package/dist/cjs/fields/Combobox/index.native.js.map +1 -1
  22. package/dist/cjs/fields/Select/InlineChoices.cjs +4 -8
  23. package/dist/cjs/fields/Select/InlineChoices.native.js +4 -8
  24. package/dist/cjs/fields/Select/InlineChoices.native.js.map +1 -1
  25. package/dist/cjs/fields/Select/index.cjs +65 -201
  26. package/dist/cjs/fields/Select/index.native.js +74 -209
  27. package/dist/cjs/fields/Select/index.native.js.map +1 -1
  28. package/dist/cjs/fields/nativeDateTime/OsDateTimeSheet.cjs +2 -2
  29. package/dist/cjs/fields/nativeDateTime/OsDateTimeSheet.native.js +2 -2
  30. package/dist/cjs/fields/nativeDateTime/OsDateTimeSheet.native.js.map +1 -1
  31. package/dist/cjs/index.cjs +4 -0
  32. package/dist/cjs/index.native.js +4 -0
  33. package/dist/cjs/index.native.js.map +1 -1
  34. package/dist/cjs/shared/CheckGlyph.cjs +40 -0
  35. package/dist/cjs/shared/CheckGlyph.native.js +44 -0
  36. package/dist/cjs/shared/CheckGlyph.native.js.map +1 -0
  37. package/dist/cjs/shared/checkedOnField.cjs +104 -0
  38. package/dist/cjs/shared/checkedOnField.native.js +117 -0
  39. package/dist/cjs/shared/checkedOnField.native.js.map +1 -0
  40. package/dist/cjs/shared/floatingList.cjs +12 -5
  41. package/dist/cjs/shared/floatingList.native.js +15 -7
  42. package/dist/cjs/shared/floatingList.native.js.map +1 -1
  43. package/dist/esm/AdaptivePopup/index.mjs +3 -2
  44. package/dist/esm/AdaptivePopup/index.mjs.map +1 -1
  45. package/dist/esm/AdaptivePopup/index.native.js +3 -2
  46. package/dist/esm/AdaptivePopup/index.native.js.map +1 -1
  47. package/dist/esm/Button/index.mjs +12 -5
  48. package/dist/esm/Button/index.mjs.map +1 -1
  49. package/dist/esm/Button/index.native.js +14 -6
  50. package/dist/esm/Button/index.native.js.map +1 -1
  51. package/dist/esm/FloatingPanel/PanelHeader.mjs +3 -8
  52. package/dist/esm/FloatingPanel/PanelHeader.mjs.map +1 -1
  53. package/dist/esm/FloatingPanel/PanelHeader.native.js +3 -8
  54. package/dist/esm/FloatingPanel/PanelHeader.native.js.map +1 -1
  55. package/dist/esm/FloatingPanel/coverRect.mjs +1 -8
  56. package/dist/esm/FloatingPanel/coverRect.mjs.map +1 -1
  57. package/dist/esm/FloatingPanel/coverRect.native.js +1 -8
  58. package/dist/esm/FloatingPanel/coverRect.native.js.map +1 -1
  59. package/dist/esm/FloatingPanel/echoContext.mjs +4 -24
  60. package/dist/esm/FloatingPanel/echoContext.mjs.map +1 -1
  61. package/dist/esm/FloatingPanel/echoContext.native.js +4 -25
  62. package/dist/esm/FloatingPanel/echoContext.native.js.map +1 -1
  63. package/dist/esm/FloatingPanel/index.mjs +53 -135
  64. package/dist/esm/FloatingPanel/index.mjs.map +1 -1
  65. package/dist/esm/FloatingPanel/index.native.js +180 -99
  66. package/dist/esm/FloatingPanel/index.native.js.map +1 -1
  67. package/dist/esm/fields/Combobox/index.mjs +4 -11
  68. package/dist/esm/fields/Combobox/index.mjs.map +1 -1
  69. package/dist/esm/fields/Combobox/index.native.js +4 -11
  70. package/dist/esm/fields/Combobox/index.native.js.map +1 -1
  71. package/dist/esm/fields/Select/InlineChoices.mjs +2 -6
  72. package/dist/esm/fields/Select/InlineChoices.mjs.map +1 -1
  73. package/dist/esm/fields/Select/InlineChoices.native.js +2 -6
  74. package/dist/esm/fields/Select/InlineChoices.native.js.map +1 -1
  75. package/dist/esm/fields/Select/index.mjs +68 -204
  76. package/dist/esm/fields/Select/index.mjs.map +1 -1
  77. package/dist/esm/fields/Select/index.native.js +77 -212
  78. package/dist/esm/fields/Select/index.native.js.map +1 -1
  79. package/dist/esm/fields/nativeDateTime/OsDateTimeSheet.mjs +3 -3
  80. package/dist/esm/fields/nativeDateTime/OsDateTimeSheet.mjs.map +1 -1
  81. package/dist/esm/fields/nativeDateTime/OsDateTimeSheet.native.js +3 -3
  82. package/dist/esm/fields/nativeDateTime/OsDateTimeSheet.native.js.map +1 -1
  83. package/dist/esm/index.mjs +3 -1
  84. package/dist/esm/index.mjs.map +1 -1
  85. package/dist/esm/index.native.js +3 -1
  86. package/dist/esm/index.native.js.map +1 -1
  87. package/dist/esm/shared/CheckGlyph.mjs +15 -0
  88. package/dist/esm/shared/CheckGlyph.mjs.map +1 -0
  89. package/dist/esm/shared/CheckGlyph.native.js +16 -0
  90. package/dist/esm/shared/CheckGlyph.native.js.map +1 -0
  91. package/dist/esm/shared/checkedOnField.mjs +76 -0
  92. package/dist/esm/shared/checkedOnField.mjs.map +1 -0
  93. package/dist/esm/shared/checkedOnField.native.js +86 -0
  94. package/dist/esm/shared/checkedOnField.native.js.map +1 -0
  95. package/dist/esm/shared/floatingList.mjs +9 -5
  96. package/dist/esm/shared/floatingList.mjs.map +1 -1
  97. package/dist/esm/shared/floatingList.native.js +12 -7
  98. package/dist/esm/shared/floatingList.native.js.map +1 -1
  99. package/dist/jsx/AdaptivePopup/index.mjs +3 -2
  100. package/dist/jsx/AdaptivePopup/index.mjs.map +1 -1
  101. package/dist/jsx/AdaptivePopup/index.native.js +2 -1
  102. package/dist/jsx/AdaptivePopup/index.native.js.map +1 -1
  103. package/dist/jsx/Button/index.mjs +12 -5
  104. package/dist/jsx/Button/index.mjs.map +1 -1
  105. package/dist/jsx/Button/index.native.js +14 -6
  106. package/dist/jsx/Button/index.native.js.map +1 -1
  107. package/dist/jsx/FloatingPanel/PanelHeader.mjs +3 -8
  108. package/dist/jsx/FloatingPanel/PanelHeader.mjs.map +1 -1
  109. package/dist/jsx/FloatingPanel/PanelHeader.native.js +3 -8
  110. package/dist/jsx/FloatingPanel/PanelHeader.native.js.map +1 -1
  111. package/dist/jsx/FloatingPanel/coverRect.mjs +1 -8
  112. package/dist/jsx/FloatingPanel/coverRect.mjs.map +1 -1
  113. package/dist/jsx/FloatingPanel/coverRect.native.js +0 -8
  114. package/dist/jsx/FloatingPanel/coverRect.native.js.map +1 -1
  115. package/dist/jsx/FloatingPanel/echoContext.mjs +4 -24
  116. package/dist/jsx/FloatingPanel/echoContext.mjs.map +1 -1
  117. package/dist/jsx/FloatingPanel/echoContext.native.js +5 -28
  118. package/dist/jsx/FloatingPanel/echoContext.native.js.map +1 -1
  119. package/dist/jsx/FloatingPanel/index.mjs +53 -135
  120. package/dist/jsx/FloatingPanel/index.mjs.map +1 -1
  121. package/dist/jsx/FloatingPanel/index.native.js +216 -135
  122. package/dist/jsx/FloatingPanel/index.native.js.map +1 -1
  123. package/dist/jsx/fields/Combobox/index.mjs +4 -11
  124. package/dist/jsx/fields/Combobox/index.mjs.map +1 -1
  125. package/dist/jsx/fields/Combobox/index.native.js +3 -10
  126. package/dist/jsx/fields/Combobox/index.native.js.map +1 -1
  127. package/dist/jsx/fields/Select/InlineChoices.mjs +2 -6
  128. package/dist/jsx/fields/Select/InlineChoices.mjs.map +1 -1
  129. package/dist/jsx/fields/Select/InlineChoices.native.js +4 -8
  130. package/dist/jsx/fields/Select/InlineChoices.native.js.map +1 -1
  131. package/dist/jsx/fields/Select/index.mjs +68 -204
  132. package/dist/jsx/fields/Select/index.mjs.map +1 -1
  133. package/dist/jsx/fields/Select/index.native.js +74 -209
  134. package/dist/jsx/fields/Select/index.native.js.map +1 -1
  135. package/dist/jsx/fields/nativeDateTime/OsDateTimeSheet.mjs +3 -3
  136. package/dist/jsx/fields/nativeDateTime/OsDateTimeSheet.mjs.map +1 -1
  137. package/dist/jsx/fields/nativeDateTime/OsDateTimeSheet.native.js +2 -2
  138. package/dist/jsx/fields/nativeDateTime/OsDateTimeSheet.native.js.map +1 -1
  139. package/dist/jsx/index.js +3 -1
  140. package/dist/jsx/index.js.map +1 -1
  141. package/dist/jsx/index.mjs +3 -1
  142. package/dist/jsx/index.mjs.map +1 -1
  143. package/dist/jsx/index.native.js +4 -0
  144. package/dist/jsx/index.native.js.map +1 -1
  145. package/dist/jsx/shared/CheckGlyph.mjs +15 -0
  146. package/dist/jsx/shared/CheckGlyph.mjs.map +1 -0
  147. package/dist/jsx/shared/CheckGlyph.native.js +44 -0
  148. package/dist/jsx/shared/CheckGlyph.native.js.map +1 -0
  149. package/dist/jsx/shared/checkedOnField.mjs +76 -0
  150. package/dist/jsx/shared/checkedOnField.mjs.map +1 -0
  151. package/dist/jsx/shared/checkedOnField.native.js +117 -0
  152. package/dist/jsx/shared/checkedOnField.native.js.map +1 -0
  153. package/dist/jsx/shared/floatingList.mjs +9 -5
  154. package/dist/jsx/shared/floatingList.mjs.map +1 -1
  155. package/dist/jsx/shared/floatingList.native.js +15 -7
  156. package/dist/jsx/shared/floatingList.native.js.map +1 -1
  157. package/package.json +8 -8
  158. package/src/AdaptivePopup/index.tsx +6 -3
  159. package/src/Button/Button.iconButton.spec.tsx +39 -0
  160. package/src/Button/index.tsx +20 -8
  161. package/src/FloatingPanel/FloatingPanel.iconCover.spec.tsx +279 -0
  162. package/src/FloatingPanel/FloatingPanel.menuFace.native.spec.tsx +78 -69
  163. package/src/FloatingPanel/FloatingPanel.seamlessCover.spec.tsx +12 -0
  164. package/src/FloatingPanel/PanelHeader.tsx +4 -12
  165. package/src/FloatingPanel/coverRect.ts +2 -15
  166. package/src/FloatingPanel/echoContext.ts +6 -40
  167. package/src/FloatingPanel/index.native.tsx +209 -144
  168. package/src/FloatingPanel/index.tsx +71 -187
  169. package/src/FloatingPanel/pointAnchor.spec.ts +1 -1
  170. package/src/FloatingPanel/pointAnchor.ts +4 -4
  171. package/src/FloatingPanel/sheetGrabber.spec.tsx +53 -0
  172. package/src/fields/Combobox/index.tsx +4 -9
  173. package/src/fields/Select/InlineChoices.tsx +2 -2
  174. package/src/fields/Select/Select.containingBlock.spec.tsx +131 -130
  175. package/src/fields/Select/Select.popUp.native.spec.tsx +276 -0
  176. package/src/fields/Select/Select.stories.tsx +26 -0
  177. package/src/fields/Select/index.tsx +84 -255
  178. package/src/fields/nativeDateTime/OsDateTimeSheet.tsx +3 -3
  179. package/src/index.ts +2 -1
  180. package/src/shared/CheckGlyph.tsx +12 -0
  181. package/src/shared/checkedOnField.ts +116 -0
  182. package/src/shared/floatingList.spec.tsx +154 -1
  183. package/src/shared/floatingList.tsx +18 -9
  184. package/types/AdaptivePopup/index.d.ts.map +1 -1
  185. package/types/Button/index.d.ts.map +1 -1
  186. package/types/FloatingPanel/PanelHeader.d.ts +1 -6
  187. package/types/FloatingPanel/PanelHeader.d.ts.map +1 -1
  188. package/types/FloatingPanel/coverRect.d.ts +2 -11
  189. package/types/FloatingPanel/coverRect.d.ts.map +1 -1
  190. package/types/FloatingPanel/echoContext.d.ts +5 -22
  191. package/types/FloatingPanel/echoContext.d.ts.map +1 -1
  192. package/types/FloatingPanel/index.d.ts +22 -9
  193. package/types/FloatingPanel/index.d.ts.map +1 -1
  194. package/types/FloatingPanel/index.native.d.ts +8 -1
  195. package/types/FloatingPanel/index.native.d.ts.map +1 -1
  196. package/types/FloatingPanel/pointAnchor.d.ts +4 -4
  197. package/types/fields/Combobox/index.d.ts.map +1 -1
  198. package/types/fields/Select/index.d.ts.map +1 -1
  199. package/types/index.d.ts +2 -1
  200. package/types/index.d.ts.map +1 -1
  201. package/types/shared/CheckGlyph.d.ts +10 -0
  202. package/types/shared/CheckGlyph.d.ts.map +1 -0
  203. package/types/shared/checkedOnField.d.ts +54 -0
  204. package/types/shared/checkedOnField.d.ts.map +1 -0
  205. package/types/shared/floatingList.d.ts +5 -2
  206. package/types/shared/floatingList.d.ts.map +1 -1
  207. package/dist/cjs/FloatingPanel/iconEcho.cjs +0 -106
  208. package/dist/cjs/FloatingPanel/iconEcho.native.js +0 -116
  209. package/dist/cjs/FloatingPanel/iconEcho.native.js.map +0 -1
  210. package/dist/esm/FloatingPanel/iconEcho.mjs +0 -79
  211. package/dist/esm/FloatingPanel/iconEcho.mjs.map +0 -1
  212. package/dist/esm/FloatingPanel/iconEcho.native.js +0 -86
  213. package/dist/esm/FloatingPanel/iconEcho.native.js.map +0 -1
  214. package/dist/jsx/FloatingPanel/iconEcho.mjs +0 -79
  215. package/dist/jsx/FloatingPanel/iconEcho.mjs.map +0 -1
  216. package/dist/jsx/FloatingPanel/iconEcho.native.js +0 -116
  217. package/dist/jsx/FloatingPanel/iconEcho.native.js.map +0 -1
  218. package/src/FloatingPanel/FloatingPanel.iconEcho.spec.tsx +0 -357
  219. package/src/FloatingPanel/iconEcho.tsx +0 -124
  220. package/types/FloatingPanel/iconEcho.d.ts +0 -29
  221. package/types/FloatingPanel/iconEcho.d.ts.map +0 -1
@@ -54,51 +54,7 @@ describe("Select inside a transformed sheet (FZ-8438)", () => {
54
54
  });
55
55
  });
56
56
 
57
- describe("Select seamless cover (LC-112, FZ-8438)", () => {
58
- beforeEach(() => {
59
- Object.defineProperty(window, "innerWidth", {
60
- configurable: true,
61
- writable: true,
62
- value: 1200,
63
- });
64
- });
65
- afterEach(() => {
66
- vi.restoreAllMocks();
67
- });
68
-
69
- it("opens on the field's top edge with the field echoed as a hidden first row", async () => {
70
- const original = HTMLElement.prototype.getBoundingClientRect;
71
- vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
72
- function (this: HTMLElement) {
73
- const field = this.querySelector('[data-testid="select-trigger"]');
74
- if (this.dataset.testid === "select-trigger" || field) {
75
- return {
76
- x: 16,
77
- y: 200,
78
- top: 200,
79
- left: 16,
80
- right: 416,
81
- bottom: 240,
82
- width: 400,
83
- height: 40,
84
- toJSON: () => ({}),
85
- } as DOMRect;
86
- }
87
- return original.call(this);
88
- },
89
- );
90
- const result = renderWithProviders(<Select options={options} value="nbg" />);
91
- const { dropdown, top } = await openTop(result);
92
- expect(top).toBe(200);
93
- const echo = dropdown.querySelector<HTMLElement>("[data-fp-trigger-row]");
94
- expect(echo).not.toBeNull();
95
- expect(dropdown.firstElementChild).toBe(echo);
96
- expect(echo?.getAttribute("aria-hidden")).toBe("true");
97
- expect(echo?.getAttribute("role")).toBeNull();
98
- });
99
- });
100
-
101
- describe("Select cover and scroll (LC-112, FZ-8463)", () => {
57
+ describe("Select opens with its checked row on the field (LC-112, FZ-8557)", () => {
102
58
  const field = (top: number) => ({
103
59
  x: 16,
104
60
  y: top,
@@ -110,8 +66,11 @@ describe("Select cover and scroll (LC-112, FZ-8463)", () => {
110
66
  height: 40,
111
67
  toJSON: () => ({}),
112
68
  });
69
+ const rowTop = 4;
70
+ const rowHeight = 36;
113
71
  let fieldTop = 700;
114
- let contentHeight = 300;
72
+ let viewportHeight = 768;
73
+ const contentOf = (count: number) => rowTop * 2 + count * rowHeight;
115
74
  beforeEach(() => {
116
75
  Object.defineProperty(window, "innerWidth", {
117
76
  configurable: true,
@@ -120,8 +79,7 @@ describe("Select cover and scroll (LC-112, FZ-8463)", () => {
120
79
  });
121
80
  Object.defineProperty(window, "innerHeight", {
122
81
  configurable: true,
123
- writable: true,
124
- value: 768,
82
+ get: () => viewportHeight,
125
83
  });
126
84
  const original = HTMLElement.prototype.getBoundingClientRect;
127
85
  vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
@@ -135,113 +93,156 @@ describe("Select cover and scroll (LC-112, FZ-8463)", () => {
135
93
  return original.call(this);
136
94
  },
137
95
  );
96
+ const rowsOf = (el: HTMLElement) =>
97
+ Array.from(
98
+ el.closest('[data-testid="select-dropdown"]')?.querySelectorAll('[role="option"]') ?? [],
99
+ );
138
100
  vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockImplementation(
139
101
  function (this: HTMLElement) {
140
- return this.dataset.testid === "select-dropdown" ? contentHeight : 0;
102
+ return this.dataset.testid === "select-dropdown" ? contentOf(rowsOf(this).length) : 0;
103
+ },
104
+ );
105
+ vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockImplementation(
106
+ function (this: HTMLElement) {
107
+ return this.getAttribute("role") === "option" ? rowHeight : 0;
108
+ },
109
+ );
110
+ vi.spyOn(HTMLElement.prototype, "offsetTop", "get").mockImplementation(
111
+ function (this: HTMLElement) {
112
+ return this.getAttribute("role") === "option"
113
+ ? rowTop + rowsOf(this).indexOf(this) * rowHeight
114
+ : 0;
115
+ },
116
+ );
117
+ vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockImplementation(
118
+ function (this: HTMLElement) {
119
+ return this.dataset.testid === "select-dropdown" ? innerOf(this) : 0;
141
120
  },
142
121
  );
143
122
  });
144
123
  afterEach(() => {
124
+ viewportHeight = 768;
145
125
  vi.restoreAllMocks();
146
126
  });
147
127
 
148
- it("a low field's list covers the field with no echo and grows up from its bottom edge", async () => {
149
- fieldTop = 700;
150
- contentHeight = 300;
151
- const result = renderWithProviders(<Select options={options} value="hel" />);
128
+ const borders = (dropdown: HTMLElement) => {
129
+ const style = getComputedStyle(dropdown);
130
+ return {
131
+ top: parseFloat(style.borderTopWidth) || 0,
132
+ bottom: parseFloat(style.borderBottomWidth) || 0,
133
+ };
134
+ };
135
+ /** The panel's padding box, in viewport pixels. */
136
+ function innerOf(dropdown: HTMLElement) {
137
+ const border = borders(dropdown);
138
+ return (parseFloat(dropdown.style.maxHeight) || 0) - border.top - border.bottom;
139
+ }
140
+ const paddingTop = (dropdown: HTMLElement) =>
141
+ parseFloat(dropdown.style.top) + borders(dropdown).top;
142
+ /** The panel's outer edges: these meet the safe edges (`panelViewportPadding`). */
143
+ const outerTop = (dropdown: HTMLElement) => parseFloat(dropdown.style.top);
144
+ const outerBottom = (dropdown: HTMLElement) =>
145
+ outerTop(dropdown) + parseFloat(dropdown.style.maxHeight);
146
+ /**
147
+ * The checked row's value against the closed field's: both draw one line
148
+ * of text centred in their row, so the delta of the two centres is how far
149
+ * the value moves on open.
150
+ */
151
+ const valueDelta = (dropdown: HTMLElement, row: number) =>
152
+ paddingTop(dropdown) +
153
+ rowTop +
154
+ row * rowHeight -
155
+ dropdown.scrollTop +
156
+ rowHeight / 2 -
157
+ (fieldTop + 20);
158
+ const many: SelectOption[] = Array.from({ length: 40 }, (_, i) => ({
159
+ value: `c${i}`,
160
+ label: `City ${i}`,
161
+ }));
162
+
163
+ it("never echoes the field: the checked row is the value, shown once", async () => {
164
+ fieldTop = 120;
165
+ const result = renderWithProviders(<Select options={options} value="nbg" />);
152
166
  const { dropdown } = await openTop(result);
153
- await waitFor(() => expect(dropdown.querySelector("[data-fp-trigger-row]")).toBeNull());
154
- // The panel's bottom edge is the field's (740), the whole list above it.
155
- await waitFor(() =>
156
- expect(parseFloat(dropdown.style.top) + parseFloat(dropdown.style.maxHeight)).toBe(740),
157
- );
158
- expect(parseFloat(dropdown.style.top)).toBeLessThan(700);
159
- expect(parseFloat(dropdown.style.maxHeight)).toBeGreaterThanOrEqual(300);
160
- expect(dropdown.textContent?.match(/Helsinki/g)?.length ?? 0).toBe(1);
167
+ await waitFor(() => expect(Math.abs(valueDelta(dropdown, 2))).toBeLessThanOrEqual(1));
168
+ expect(dropdown.querySelector("[data-fp-trigger-row]")).toBeNull();
169
+ expect(dropdown.textContent?.match(/Nuremberg/g)?.length ?? 0).toBe(1);
170
+ expect(dropdown.scrollTop).toBe(0);
161
171
  });
162
172
 
163
- it("a low field's long list stops at the safe top edge and scrolls", async () => {
173
+ it("with no value, the first row sits on the field", async () => {
174
+ fieldTop = 120;
175
+ const result = renderWithProviders(<Select options={options} />);
176
+ const { dropdown } = await openTop(result);
177
+ await waitFor(() => expect(Math.abs(valueDelta(dropdown, 0))).toBeLessThanOrEqual(1));
178
+ expect(dropdown.querySelector("[data-fp-trigger-row]")).toBeNull();
179
+ });
180
+
181
+ it("a short list by the bottom edge shows whole, slid up the least distance", async () => {
164
182
  fieldTop = 700;
165
- contentHeight = 2000;
166
183
  const result = renderWithProviders(<Select options={options} value="hel" />);
167
184
  const { dropdown } = await openTop(result);
168
- await waitFor(() => expect(dropdown.querySelector("[data-fp-trigger-row]")).toBeNull());
169
- await waitFor(() => expect(parseFloat(dropdown.style.top)).toBe(10));
170
- expect(parseFloat(dropdown.style.maxHeight)).toBe(730);
185
+ await waitFor(() => expect(outerBottom(dropdown)).toBe(768 - 10));
186
+ expect(innerOf(dropdown)).toBe(contentOf(3));
187
+ expect(dropdown.scrollTop).toBe(0);
188
+ // Helsinki would sit on the field; the list stops at the safe edge instead.
189
+ expect(valueDelta(dropdown, 0)).toBeLessThan(0);
171
190
  });
172
191
 
173
- it("a high field's long list covers the field and grows down to the safe bottom edge", async () => {
174
- fieldTop = 120;
175
- contentHeight = 2000;
176
- const result = renderWithProviders(<Select options={options} value="hel" />);
192
+ it("a long list that fits the screen shows every row with no scrolling", async () => {
193
+ viewportHeight = 1366;
194
+ fieldTop = 700;
195
+ const result = renderWithProviders(<Select options={many.slice(0, 36)} value="c20" />);
177
196
  const { dropdown } = await openTop(result);
178
- await waitFor(() => expect(dropdown.querySelector("[data-fp-trigger-row]")).toBeNull());
179
- expect(parseFloat(dropdown.style.top)).toBe(120);
180
- await waitFor(() => expect(parseFloat(dropdown.style.maxHeight)).toBe(768 - 10 - 120));
197
+ await waitFor(() => expect(innerOf(dropdown)).toBe(contentOf(36)));
198
+ expect(dropdown.scrollTop).toBe(0);
199
+ // Cairo's 20 rows above it don't fit over the field: the list slides down
200
+ // the least distance, its top on the safe edge, and the value sits just below.
201
+ expect(outerTop(dropdown)).toBe(10);
202
+ expect(outerBottom(dropdown)).toBeLessThanOrEqual(1366 - 10);
203
+ expect(valueDelta(dropdown, 20)).toBeGreaterThan(0);
181
204
  });
182
205
 
183
- describe("with 36px rows", () => {
184
- const rowTop = 4;
185
- const rowHeight = 36;
186
- beforeEach(() => {
187
- const rowIndex = (el: HTMLElement) =>
188
- Array.from(
189
- el.closest('[data-testid="select-dropdown"]')?.querySelectorAll('[role="option"]') ?? [],
190
- ).indexOf(el);
191
- vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockImplementation(
192
- function (this: HTMLElement) {
193
- return this.getAttribute("role") === "option" ? rowHeight : 0;
194
- },
195
- );
196
- vi.spyOn(HTMLElement.prototype, "offsetTop", "get").mockImplementation(
197
- function (this: HTMLElement) {
198
- return this.getAttribute("role") === "option" ? rowTop + rowIndex(this) * rowHeight : 0;
199
- },
200
- );
201
- vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockImplementation(
202
- function (this: HTMLElement) {
203
- return this.dataset.testid === "select-dropdown"
204
- ? parseFloat(this.style.maxHeight) || 0
205
- : 0;
206
- },
207
- );
208
- });
206
+ it("a list taller than the screen keeps its value on the field and reaches both safe edges", async () => {
207
+ fieldTop = 700;
208
+ const result = renderWithProviders(<Select options={many} value="c20" />);
209
+ const { dropdown } = await openTop(result);
210
+ await waitFor(() => expect(Math.abs(valueDelta(dropdown, 20))).toBeLessThanOrEqual(1));
211
+ expect(dropdown.scrollTop).toBeGreaterThan(0);
212
+ // No whole-row trim: the clipped edges sit on the safe edges, a cut row under each band.
213
+ expect(outerTop(dropdown)).toBe(10);
214
+ expect(outerBottom(dropdown)).toBe(768 - 10);
215
+ });
209
216
 
210
- it("growing up, the row on the field is whole: the panel's bottom stays on the field", async () => {
211
- fieldTop = 700;
212
- contentHeight = 2000;
213
- const result = renderWithProviders(<Select options={options} value="hel" />);
214
- const { dropdown } = await openTop(result);
215
- // 730 of room above holds 20 whole rows and 6px of a 21st: that sliver goes.
216
- await waitFor(() => expect(parseFloat(dropdown.style.maxHeight)).toBe(724));
217
- expect(parseFloat(dropdown.style.top)).toBe(16);
218
- const visibleBottom = dropdown.scrollTop + parseFloat(dropdown.style.maxHeight);
219
- expect((visibleBottom - rowTop) % rowHeight).toBe(0);
220
- });
217
+ it("a high field's first row stays on the field and the list runs to the safe bottom edge", async () => {
218
+ fieldTop = 120;
219
+ const result = renderWithProviders(<Select options={many} value="c0" />);
220
+ const { dropdown } = await openTop(result);
221
+ await waitFor(() => expect(Math.abs(valueDelta(dropdown, 0))).toBeLessThanOrEqual(1));
222
+ expect(dropdown.scrollTop).toBe(0);
223
+ expect(outerBottom(dropdown)).toBe(768 - 10);
224
+ });
221
225
 
222
- it("growing down, no sliver of a row sits at the screen edge", async () => {
223
- fieldTop = 120;
224
- contentHeight = 2000;
225
- const result = renderWithProviders(<Select options={options} value="hel" />);
226
- const { dropdown } = await openTop(result);
227
- expect(parseFloat(dropdown.style.top)).toBe(120);
228
- await waitFor(() =>
229
- expect(parseFloat(dropdown.style.maxHeight)).toBeLessThan(768 - 10 - 120),
230
- );
231
- const visibleBottom = dropdown.scrollTop + parseFloat(dropdown.style.maxHeight);
232
- expect((visibleBottom - rowTop) % rowHeight).toBe(0);
233
- });
226
+ it("the last row checked on a low field ends the panel at the list's end", async () => {
227
+ fieldTop = 700;
228
+ const result = renderWithProviders(<Select options={many} value="c39" />);
229
+ const { dropdown } = await openTop(result);
230
+ await waitFor(() => expect(Math.abs(valueDelta(dropdown, 39))).toBeLessThanOrEqual(1));
231
+ expect(outerTop(dropdown)).toBe(10);
232
+ expect(dropdown.scrollTop + innerOf(dropdown)).toBe(contentOf(40));
234
233
  });
235
234
 
236
- it("a field with room below keeps the echo on top, opening down", async () => {
235
+ it("scrolling grows a panel that stops short of the safe top edge, the rows staying put", async () => {
237
236
  fieldTop = 120;
238
- contentHeight = 300;
239
- const result = renderWithProviders(<Select options={options} value="hel" />);
240
- const { dropdown, top } = await openTop(result);
241
- expect(top).toBe(120);
242
- const echo = dropdown.querySelector<HTMLElement>("[data-fp-trigger-row]");
243
- expect(echo).not.toBeNull();
244
- expect(dropdown.firstElementChild).toBe(echo);
237
+ const result = renderWithProviders(<Select options={many} value="c0" />);
238
+ const { dropdown } = await openTop(result);
239
+ await waitFor(() => expect(Math.abs(valueDelta(dropdown, 0))).toBeLessThanOrEqual(1));
240
+ const before = paddingTop(dropdown);
241
+ dropdown.scrollTop = 50;
242
+ fireEvent.scroll(dropdown);
243
+ await waitFor(() => expect(paddingTop(dropdown)).toBe(before - 50));
244
+ expect(dropdown.scrollTop).toBe(0);
245
+ expect(outerBottom(dropdown)).toBe(768 - 10);
245
246
  });
246
247
  });
247
248
 
@@ -0,0 +1,276 @@
1
+ /**
2
+ * FZ-8655 (LC-112, Round 40 parity): a native Select opens the way the web's
3
+ * covering Select does, a macOS pop-up. The rows are measured in the native
4
+ * FloatingPanel and placed by the placement code the web shares
5
+ * (`placeCheckedOnField`): the checked row on the field, or the first row with
6
+ * no value, and never a field echo. A list that fits shows whole, a taller one
7
+ * scrolls with its clipped edges on the safe edges. On a phone a long list
8
+ * keeps its sheet at page level.
9
+ */
10
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
11
+ import { useResolvedKnobs } from "@multiplatform.one/theme";
12
+ import { act } from "@testing-library/react";
13
+ import type { ReactNode } from "react";
14
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
15
+ import { panelViewportPadding } from "../../FloatingPanel/useFloatingPanel";
16
+ import { OverlayStackProvider } from "../../shared/overlayStack";
17
+ import { placeCheckedOnField, scrollArrowBand } from "../../shared/checkedOnField";
18
+ import { Select } from "./index";
19
+
20
+ const screen = vi.hoisted(() => ({ width: 1024, height: 1366 }));
21
+
22
+ vi.mock("tamagui", async (importOriginal) => ({
23
+ ...(await importOriginal<typeof import("tamagui")>()),
24
+ isWeb: false,
25
+ }));
26
+ vi.mock("react-native-web", async (importOriginal) => ({
27
+ ...(await importOriginal<typeof import("react-native")>()),
28
+ ActionSheetIOS: undefined,
29
+ Modal: ({ children, visible }: { children: ReactNode; visible: boolean }) =>
30
+ visible ? children : null,
31
+ useWindowDimensions: () => ({ ...screen, scale: 2, fontScale: 1 }),
32
+ }));
33
+ vi.mock("../../FloatingPanel", async (importOriginal) => {
34
+ const actual = await importOriginal<typeof import("../../FloatingPanel")>();
35
+ const native = await import("../../FloatingPanel/index.native");
36
+ return { ...actual, FloatingPanel: native.FloatingPanel };
37
+ });
38
+
39
+ const rowHeight = 44;
40
+ let band = 0;
41
+ let field = { x: 40, y: 400, width: 320, height: 44 };
42
+
43
+ beforeEach(() => {
44
+ vi.useFakeTimers();
45
+ field = { x: 40, y: 400, width: 320, height: 44 };
46
+ screen.width = 1024;
47
+ screen.height = 1366;
48
+ vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(() => ({
49
+ ...field,
50
+ left: field.x,
51
+ top: field.y,
52
+ right: field.x + field.width,
53
+ bottom: field.y + field.height,
54
+ toJSON: () => ({}),
55
+ }));
56
+ });
57
+ afterEach(() => {
58
+ vi.restoreAllMocks();
59
+ vi.useRealTimers();
60
+ });
61
+
62
+ /** The band the panel lays out for: half this preset's menu row. */
63
+ function BandProbe() {
64
+ const { knobProps } = useResolvedKnobs({ painted: false });
65
+ band = scrollArrowBand(knobProps.menuRow.minHeight);
66
+ return null;
67
+ }
68
+
69
+ const cities = (count: number) =>
70
+ Array.from({ length: count }, (_, i) => ({ value: `c${i}`, label: `City ${i}` }));
71
+
72
+ function tap(target: Element) {
73
+ act(() => {
74
+ target.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 }));
75
+ target.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, button: 0 }));
76
+ target.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0 }));
77
+ });
78
+ act(() => {
79
+ vi.advanceTimersByTime(1);
80
+ });
81
+ }
82
+
83
+ /** RN-web parks onLayout on the node for a ResizeObserver happy-dom never fires. */
84
+ function layout(node: Element, box: { y?: number; width: number; height: number }) {
85
+ const onLayout = (node as unknown as { __reactLayoutHandler?: (event: unknown) => void })
86
+ .__reactLayoutHandler;
87
+ expect(onLayout).toBeTypeOf("function");
88
+ act(() => {
89
+ onLayout?.({ nativeEvent: { layout: { x: 0, y: 0, ...box } } });
90
+ });
91
+ }
92
+
93
+ const byTestId = (id: string) => document.querySelector<HTMLElement>(`[data-testid="${id}"]`);
94
+ const menu = () => byTestId("floating-panel-menu");
95
+ const px = (value: string | undefined) => Number.parseFloat(value ?? "");
96
+
97
+ function open({
98
+ count,
99
+ checked,
100
+ inOverlay = false,
101
+ }: {
102
+ count: number;
103
+ checked?: number;
104
+ inOverlay?: boolean;
105
+ }) {
106
+ const options = cities(count);
107
+ const select = (
108
+ <Select
109
+ aria-label="City"
110
+ options={options}
111
+ value={checked === undefined ? undefined : `c${checked}`}
112
+ />
113
+ );
114
+ const view = renderWithProviders(
115
+ <>
116
+ <BandProbe />
117
+ {inOverlay ? <OverlayStackProvider zIndex={100_000}>{select}</OverlayStackProvider> : select}
118
+ </>,
119
+ );
120
+ tap(view.getByRole("button", { name: "City" }));
121
+ return { view, options, contentHeight: count * rowHeight };
122
+ }
123
+
124
+ function measureRows(
125
+ view: ReturnType<typeof renderWithProviders>,
126
+ index: number,
127
+ contentHeight: number,
128
+ ) {
129
+ const frame = menu();
130
+ expect(frame).not.toBeNull();
131
+ layout(frame as HTMLElement, { width: 320, height: Math.min(contentHeight + 2, 900) });
132
+ layout(view.getByRole("button", { name: `City ${index}` }), {
133
+ y: index * rowHeight,
134
+ width: 320,
135
+ height: rowHeight,
136
+ });
137
+ layout(byTestId("floating-panel-menu-content") as HTMLElement, {
138
+ width: 320,
139
+ height: contentHeight,
140
+ });
141
+ }
142
+
143
+ /** The padding box the shared helper asks for, from the screen under test. */
144
+ function expected(index: number, contentHeight: number, border: number) {
145
+ return placeCheckedOnField({
146
+ fieldTop: field.y,
147
+ fieldBottom: field.y + field.height,
148
+ rowTop: index * rowHeight,
149
+ rowHeight,
150
+ contentHeight,
151
+ boundsTop: panelViewportPadding + border,
152
+ boundsBottom: screen.height - panelViewportPadding - border,
153
+ band,
154
+ });
155
+ }
156
+
157
+ function border() {
158
+ const surface = byTestId("floating-panel-menu-surface") as HTMLElement;
159
+ return Math.round(px(getComputedStyle(surface).borderTopWidth)) || 0;
160
+ }
161
+
162
+ describe("Select native pop-up (FZ-8655)", () => {
163
+ it("never echoes the field, and paints nothing until its rows are measured", () => {
164
+ const { view, contentHeight } = open({ count: 5, checked: 2 });
165
+ expect(menu()?.style.opacity).toBe("0");
166
+ measureRows(view, 2, contentHeight);
167
+ expect(menu()?.style.opacity).toBe("1");
168
+ expect(byTestId("floating-panel-menu-echo")).toBeNull();
169
+ });
170
+
171
+ it("centres the checked row on the field, the rows before it running up", () => {
172
+ const { view, contentHeight } = open({ count: 5, checked: 2 });
173
+ measureRows(view, 2, contentHeight);
174
+ const b = border();
175
+ const fit = expected(2, contentHeight, b);
176
+ expect(px(menu()?.style.top)).toBe(fit.top - b);
177
+ expect(fit.top + 2 * rowHeight).toBe(field.y);
178
+ });
179
+
180
+ it("puts the first row on the field with no value", () => {
181
+ const { view, contentHeight } = open({ count: 5 });
182
+ measureRows(view, 0, contentHeight);
183
+ const b = border();
184
+ expect(px(menu()?.style.top) + b).toBe(field.y);
185
+ });
186
+
187
+ it("shows a short list by the bottom edge whole, slid inside the safe edge", () => {
188
+ field = { ...field, y: 1300 };
189
+ const { view, contentHeight } = open({ count: 7, checked: 3 });
190
+ measureRows(view, 3, contentHeight);
191
+ const b = border();
192
+ const top = px(menu()?.style.top);
193
+ expect(top + b + contentHeight).toBe(screen.height - panelViewportPadding - b);
194
+ expect(px(menu()?.style.maxHeight)).toBe(contentHeight + 2 * b);
195
+ expect(byTestId("floating-panel-scroll-up")).toBeNull();
196
+ expect(byTestId("floating-panel-scroll-down")).toBeNull();
197
+ });
198
+
199
+ it("opens at every list length at regular width, not a sheet or a regular panel", () => {
200
+ const { view, contentHeight } = open({ count: 20, checked: 10 });
201
+ measureRows(view, 10, contentHeight);
202
+ expect(byTestId("floating-panel-sheet")).toBeNull();
203
+ expect(byTestId("floating-panel-regular")).toBeNull();
204
+ expect(byTestId("floating-panel-menu-echo")).toBeNull();
205
+ });
206
+
207
+ it("scrolls a list taller than the safe viewport, its clipped edges on the safe edges", () => {
208
+ const { view, contentHeight } = open({ count: 60, checked: 30 });
209
+ measureRows(view, 30, contentHeight);
210
+ const b = border();
211
+ const fit = expected(30, contentHeight, b);
212
+ expect(fit.scrollTop).toBeGreaterThan(0);
213
+ expect(px(menu()?.style.top)).toBe(panelViewportPadding);
214
+ expect(px(menu()?.style.maxHeight)).toBe(screen.height - 2 * panelViewportPadding);
215
+ expect(byTestId("floating-panel-scroll-up")).not.toBeNull();
216
+ expect(byTestId("floating-panel-scroll-down")).not.toBeNull();
217
+ const checkedTop = fit.top - fit.scrollTop + 30 * rowHeight;
218
+ expect(checkedTop + rowHeight / 2).toBe(field.y + field.height / 2);
219
+ });
220
+
221
+ it("keeps an arrow only on the side that's clipped", () => {
222
+ const { view, contentHeight } = open({ count: 60, checked: 1 });
223
+ measureRows(view, 1, contentHeight);
224
+ expect(byTestId("floating-panel-scroll-up")).toBeNull();
225
+ expect(byTestId("floating-panel-scroll-down")).not.toBeNull();
226
+ });
227
+
228
+ it.each([
229
+ ["top", 20],
230
+ ["middle", 640],
231
+ ["bottom", 1290],
232
+ ])("keeps the checked row inside the panel, clear of its bands, at the %s", (_name, y) => {
233
+ field = { ...field, y };
234
+ for (const checked of [0, 1, 29, 58, 59]) {
235
+ const { view, contentHeight } = open({ count: 60, checked });
236
+ measureRows(view, checked, contentHeight);
237
+ const b = border();
238
+ const fit = expected(checked, contentHeight, b);
239
+ const rowTop = fit.top - fit.scrollTop + checked * rowHeight;
240
+ expect(px(menu()?.style.top)).toBe(fit.top - b);
241
+ expect(rowTop).toBeGreaterThanOrEqual(fit.top);
242
+ expect(rowTop + rowHeight).toBeLessThanOrEqual(fit.top + fit.height);
243
+ view.unmount();
244
+ }
245
+ });
246
+
247
+ it("keeps the sheet for a long list on a phone at page level", () => {
248
+ screen.width = 390;
249
+ screen.height = 844;
250
+ open({ count: 20, checked: 10 });
251
+ expect(byTestId("floating-panel-sheet")).not.toBeNull();
252
+ expect(menu()).toBeNull();
253
+ });
254
+
255
+ it("opens a long list as a pop-up on a phone inside an overlay", () => {
256
+ screen.width = 390;
257
+ screen.height = 844;
258
+ field = { ...field, y: 300 };
259
+ const { view, contentHeight } = open({ count: 20, checked: 10, inOverlay: true });
260
+ measureRows(view, 10, contentHeight);
261
+ expect(byTestId("floating-panel-sheet")).toBeNull();
262
+ expect(byTestId("floating-panel-menu-echo")).toBeNull();
263
+ const b = border();
264
+ expect(px(menu()?.style.top)).toBe(expected(10, contentHeight, b).top - b);
265
+ });
266
+
267
+ it("keeps a short list inside an overlay attached beside its field (LC-262)", () => {
268
+ screen.width = 390;
269
+ screen.height = 844;
270
+ open({ count: 4, checked: 1, inOverlay: true });
271
+ const frame = menu() as HTMLElement;
272
+ layout(frame, { width: 320, height: 178 });
273
+ expect(frame.style.opacity).toBe("1");
274
+ expect(px(frame.style.top)).toBe(field.y + field.height);
275
+ });
276
+ });
@@ -10,6 +10,7 @@ import { Select as TamaguiSelect, Sheet, useMedia, XStack, YStack } from "tamagu
10
10
  import { Button } from "../../Button";
11
11
  import { useViewportGtSm } from "../../FloatingPanel";
12
12
  import { Form } from "../../Form";
13
+ import { formReadableMaxWidth } from "../../formSpacing";
13
14
  import { Select, type SelectOption } from "./index";
14
15
 
15
16
  // tamagui v2 types Select.Content as children + scope + FocusScopeProps only,
@@ -21,6 +22,15 @@ const raiseAbovePortal: Record<string, unknown> = { zIndex: 200000 };
21
22
  const meta: Meta<typeof Select> = {
22
23
  title: "Forms/Select",
23
24
  component: Select,
25
+ // LC-115 READABLE-FORMS: a field sits in a readable form column, so an open
26
+ // list on an iPad or a desktop is the column's width, not the screen's.
27
+ decorators: [
28
+ (Story) => (
29
+ <YStack width="100%" maxWidth={formReadableMaxWidth}>
30
+ <Story />
31
+ </YStack>
32
+ ),
33
+ ],
24
34
  parameters: {
25
35
  docs: {
26
36
  description: {
@@ -613,3 +623,19 @@ export const LowOnPage: StoryObj<{ spaceAbove: number; count: number }> = {
613
623
  );
614
624
  },
615
625
  };
626
+
627
+ /**
628
+ * LC-112 (FZ-8655): a field mid-screen. A list taller than the safe viewport
629
+ * reaches both safe edges and scrolls under its bands, the checked row on the
630
+ * field.
631
+ */
632
+ export const MidOnPage: StoryObj<{ spaceAbove: number; count: number }> = {
633
+ ...LowOnPage,
634
+ args: { ...LowOnPage.args, spaceAbove: 440 },
635
+ };
636
+
637
+ /** LC-112 (FZ-8655): a short list by the bottom edge shows whole, slid inside the safe edge. */
638
+ export const ShortLowOnPage: StoryObj<{ spaceAbove: number; count: number }> = {
639
+ ...LowOnPage,
640
+ args: { ...LowOnPage.args, count: 7 },
641
+ };