@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
@@ -28,11 +28,13 @@ import { useReturnFocusOnClose, useSheetModalFocus } from "../shared/focusManage
28
28
  import { useSheetEscapeDismiss } from "../shared/useSheetEscapeDismiss";
29
29
  import { useFloatingPanel, panelViewportPadding } from "./useFloatingPanel";
30
30
  import type { PanelWidthMode } from "./useFloatingPanel";
31
- import { OverlayStackProvider, useOverlayZIndex } from "../shared/overlayStack";
31
+ import { OverlayStackProvider, useInsideOverlay, useOverlayZIndex } from "../shared/overlayStack";
32
+ import { resolveCompactFace, type CompactFace } from "./compactFace";
32
33
  import { isNestedPanelAction, type PanelActionEvent } from "./nestedAction";
33
34
  import { PanelPortal } from "./PanelPortal";
34
35
  import {
35
36
  contextMenuPoint,
37
+ placeOverTrigger,
36
38
  pointOpensUp,
37
39
  pointPanelLeft,
38
40
  referenceCornerPoint,
@@ -99,6 +101,15 @@ export interface FloatingPanelProps {
99
101
  * OVERLAY_BREAKPOINT), floating panel from `$sm` up.
100
102
  */
101
103
  sheet?: boolean;
104
+ /**
105
+ * What the panel becomes where it would be a bottom sheet (native, and web
106
+ * at or below 640). `"sheet"` (default) keeps the sheet; `"menu"` opens a
107
+ * menu over the trigger; `"auto"` keeps the sheet at page level and opens
108
+ * the menu inside another overlay, so a sheet never stacks on a sheet
109
+ * (FZ-7830). A menu inside an overlay slides back on screen instead of
110
+ * taking LC-139's collision sheet. An explicit `sheet` wins on the web.
111
+ */
112
+ compactFace?: CompactFace;
102
113
  /**
103
114
  * Sizing mode for the web floating panel.
104
115
  * - `"grow"` (default): opens from the trigger to the nearest viewport
@@ -435,19 +446,25 @@ export function FloatingPanel(props: FloatingPanelProps) {
435
446
  // the panel is already classified as keyboard-origin when focus moves in.
436
447
  if (isWeb) ensureKeyboardModalityTracking();
437
448
  const viewportGtSm = useViewportGtSm();
449
+ const insideOverlay = useInsideOverlay();
450
+ const compactMenu = resolveCompactFace(props.compactFace, insideOverlay) === "menu";
438
451
  // SSR and the hydration render use the same sheet structure. The viewport
439
452
  // snapshot switches to floating after hydration when the browser is wide.
440
- const shouldUseSheet = props.sheet ?? (!isWeb || !viewportGtSm);
453
+ const shouldUseSheet = props.sheet ?? (!isWeb || (!viewportGtSm && !compactMenu));
441
454
  // The panel floats over the page: its content never sits on a painted
442
455
  // ancestor, so a Card inside it paints as top-level.
443
456
  const panelProps = {
444
457
  ...props,
445
458
  cover: props.anchorMode ? props.anchorMode === "cover-trigger" : props.cover,
446
- children: <Surface painted={false}>{props.children}</Surface>,
459
+ children: (
460
+ <Surface painted={false} elevated>
461
+ {props.children}
462
+ </Surface>
463
+ ),
447
464
  };
448
465
 
449
466
  if (shouldUseSheet) return <FloatingPanelSheet {...panelProps} />;
450
- return <FloatingPanelWeb {...panelProps} />;
467
+ return <FloatingPanelWeb {...panelProps} clampCollisions={compactMenu && insideOverlay} />;
451
468
  }
452
469
 
453
470
  // ── Sheet (mobile / native / forced) ──────────────────────────
@@ -644,8 +661,9 @@ function FloatingPanelWeb({
644
661
  triggerEcho = true,
645
662
  triggerRow,
646
663
  anchorRect,
664
+ clampCollisions = false,
647
665
  ...sheetProps
648
- }: FloatingPanelProps) {
666
+ }: FloatingPanelProps & { clampCollisions?: boolean }) {
649
667
  const panelZIndex = useOverlayZIndex("dropdown");
650
668
  const [collisionSheet, setCollisionSheet] = useState(false);
651
669
  const isFill = sizing === "fill";
@@ -783,6 +801,8 @@ function FloatingPanelWeb({
783
801
  measured.height + firstActionHeight + bodyPadding + totalBorderWidth,
784
802
  )
785
803
  : totalContentHeight;
804
+ let placedTop = measured.top;
805
+ let placedLeft = left;
786
806
  if (
787
807
  measured.width > 0 &&
788
808
  measured.height > 0 &&
@@ -793,13 +813,36 @@ function FloatingPanelWeb({
793
813
  measured.top + Math.max(neededHeight, measured.height) >
794
814
  viewportBottom - panelViewportPadding)
795
815
  ) {
796
- dropdownElement.style.visibility = "hidden";
797
- setCollisionSheet(true);
798
- return;
816
+ if (!clampCollisions) {
817
+ dropdownElement.style.visibility = "hidden";
818
+ setCollisionSheet(true);
819
+ return;
820
+ }
821
+ // FZ-7830: inside another overlay the collision sheet would stack a
822
+ // sheet on a sheet, so the menu slides back on screen instead.
823
+ const placed = placeOverTrigger({
824
+ trigger: {
825
+ x: measured.left,
826
+ y: measured.top,
827
+ width: measured.width,
828
+ height: measured.height,
829
+ },
830
+ width,
831
+ height: neededHeight,
832
+ bounds: {
833
+ top: viewportTop + panelViewportPadding,
834
+ left: viewportLeft + panelViewportPadding,
835
+ right: viewportRight - panelViewportPadding,
836
+ bottom: viewportBottom - panelViewportPadding,
837
+ },
838
+ rtl,
839
+ });
840
+ placedTop = placed.top;
841
+ placedLeft = placed.left;
799
842
  }
800
- const top = measured.top - containingBlock.top;
801
- const cssLeft = left - containingBlock.left;
802
- const maxHeight = Math.max(0, viewportBottom - panelViewportPadding - measured.top);
843
+ const top = placedTop - containingBlock.top;
844
+ const cssLeft = placedLeft - containingBlock.left;
845
+ const maxHeight = Math.max(0, viewportBottom - panelViewportPadding - placedTop);
803
846
  dropdownElement.style.top = `${top}px`;
804
847
  dropdownElement.style.left = `${cssLeft}px`;
805
848
  dropdownElement.style.maxHeight = `${maxHeight}px`;
@@ -966,6 +1009,7 @@ function FloatingPanelWeb({
966
1009
  isContextMenu,
967
1010
  cover,
968
1011
  anchorRect,
1012
+ clampCollisions,
969
1013
  sheetProps.scrollable,
970
1014
  panel.placement,
971
1015
  panel.refs,
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
3
3
  import {
4
4
  contextMenuPoint,
5
5
  placeAtPoint,
6
+ placeOverTrigger,
6
7
  pointOpensUp,
7
8
  pointPanelLeft,
8
9
  type PanelBounds,
@@ -139,3 +140,82 @@ describe("contextMenuPoint", () => {
139
140
  expect(contextMenuPoint({ clientX: 0, clientY: 0, button: 0 }, null)).toBeNull();
140
141
  });
141
142
  });
143
+
144
+ describe("placeOverTrigger (FZ-7830, LC-112 where no sheet may take it)", () => {
145
+ // A 390 x 844 phone inside a 10pt viewport padding.
146
+ const phone: PanelBounds = { top: 57, left: 10, right: 380, bottom: 800 };
147
+ const trigger = { x: 300, y: 200, width: 44, height: 44 };
148
+
149
+ it("puts the menu's top start corner on the trigger's when it fits", () => {
150
+ const placed = placeOverTrigger({
151
+ trigger: { x: 20, y: 200, width: 120, height: 44 },
152
+ width: 200,
153
+ height: 180,
154
+ bounds: phone,
155
+ });
156
+ expect(placed).toEqual({ left: 20, top: 200, width: 200, maxHeight: 600, shifted: false });
157
+ });
158
+
159
+ it("is at least the trigger's width", () => {
160
+ const placed = placeOverTrigger({
161
+ trigger: { x: 20, y: 200, width: 300, height: 44 },
162
+ width: 160,
163
+ height: 180,
164
+ bounds: phone,
165
+ });
166
+ expect(placed.width).toBe(300);
167
+ });
168
+
169
+ it("slides back inside the end edge for a trailing ⋯ trigger", () => {
170
+ const placed = placeOverTrigger({ trigger, width: 200, height: 180, bounds: phone });
171
+ expect(placed.left).toBe(180);
172
+ expect(placed.top).toBe(200);
173
+ expect(placed.shifted).toBe(true);
174
+ });
175
+
176
+ it("lines its end edge up with the trigger's in RTL", () => {
177
+ const placed = placeOverTrigger({
178
+ trigger: { x: 200, y: 200, width: 120, height: 44 },
179
+ width: 200,
180
+ height: 180,
181
+ bounds: phone,
182
+ rtl: true,
183
+ });
184
+ expect(placed.left).toBe(120);
185
+ expect(placed.shifted).toBe(false);
186
+ });
187
+
188
+ it("slides up rather than running under the bottom edge", () => {
189
+ const placed = placeOverTrigger({
190
+ trigger: { x: 20, y: 760, width: 120, height: 44 },
191
+ width: 200,
192
+ height: 180,
193
+ bounds: phone,
194
+ });
195
+ expect(placed.top).toBe(620);
196
+ expect(placed.maxHeight).toBe(180);
197
+ expect(placed.shifted).toBe(true);
198
+ });
199
+
200
+ it("caps a menu taller than the screen at the bounds, so it scrolls", () => {
201
+ const placed = placeOverTrigger({
202
+ trigger: { x: 20, y: 400, width: 120, height: 44 },
203
+ width: 200,
204
+ height: 2000,
205
+ bounds: phone,
206
+ });
207
+ expect(placed.top).toBe(57);
208
+ expect(placed.maxHeight).toBe(743);
209
+ });
210
+
211
+ it("never grows wider than the bounds", () => {
212
+ const placed = placeOverTrigger({
213
+ trigger: { x: 20, y: 200, width: 120, height: 44 },
214
+ width: 900,
215
+ height: 180,
216
+ bounds: phone,
217
+ });
218
+ expect(placed.width).toBe(370);
219
+ expect(placed.left).toBe(10);
220
+ });
221
+ });
@@ -120,3 +120,55 @@ export function referenceCornerPoint(reference: PointReference, rtl = false): Pa
120
120
  const rect = reference.getBoundingClientRect();
121
121
  return { x: rtl ? rect.right : rect.left, y: rect.bottom };
122
122
  }
123
+
124
+ /** A trigger's box in the same coordinates as the bounds (window or viewport). */
125
+ export interface TriggerRect {
126
+ x: number;
127
+ y: number;
128
+ width: number;
129
+ height: number;
130
+ }
131
+
132
+ export interface TriggerPlacement {
133
+ left: number;
134
+ top: number;
135
+ width: number;
136
+ maxHeight: number;
137
+ /** The menu moved off the trigger's top start corner to stay on screen. */
138
+ shifted: boolean;
139
+ }
140
+
141
+ /**
142
+ * A menu opened over its trigger (LC-112) where no bottom sheet may take it:
143
+ * on native, and inside another overlay (FZ-7830). Its top start corner sits
144
+ * on the trigger's, it is at least the trigger's width, and where it would
145
+ * run off screen it slides back inside the bounds the way a UIMenu does,
146
+ * scrolling whatever still does not fit.
147
+ */
148
+ export function placeOverTrigger({
149
+ trigger,
150
+ width,
151
+ height,
152
+ bounds,
153
+ rtl = false,
154
+ }: {
155
+ trigger: TriggerRect;
156
+ width: number;
157
+ height: number;
158
+ bounds: PanelBounds;
159
+ rtl?: boolean;
160
+ }): TriggerPlacement {
161
+ const room = Math.max(0, bounds.right - bounds.left);
162
+ const w = Math.min(Math.max(width, trigger.width), room);
163
+ const wantLeft = rtl ? trigger.x + trigger.width - w : trigger.x;
164
+ const left = Math.max(bounds.left, Math.min(wantLeft, bounds.right - w));
165
+ const h = Math.min(height, Math.max(0, bounds.bottom - bounds.top));
166
+ const top = Math.max(bounds.top, Math.min(trigger.y, bounds.bottom - h));
167
+ return {
168
+ left,
169
+ top,
170
+ width: w,
171
+ maxHeight: Math.max(0, bounds.bottom - top),
172
+ shifted: left !== wantLeft || top !== trigger.y,
173
+ };
174
+ }
@@ -485,6 +485,15 @@ const InputGroupImpl = InputGroupFrame.styleable<{
485
485
  />
486
486
  ) : null;
487
487
  const trailingPadProps = trailingOverlay ? { paddingInlineEnd: 0 } : undefined;
488
+ // FZ-7853 (LC-10): a Box sized by its content (a picker trigger in a row)
489
+ // is capped at its column, so a phone-width form never pushes the field
490
+ // past the card inset. A cap, not flexShrink: shrink would also act on the
491
+ // height axis in a column parent. A Box with its own width (an OTP digit)
492
+ // keeps it.
493
+ const columnFitProps =
494
+ (props as { width?: unknown }).width == null
495
+ ? { minWidth: 0, maxWidth: "100%" as const }
496
+ : undefined;
488
497
 
489
498
  // Chromeless rendering inside table cells
490
499
  if (inTableCell) {
@@ -581,6 +590,7 @@ const InputGroupImpl = InputGroupFrame.styleable<{
581
590
  }}
582
591
  focusStyle={FIELD_FOCUS_STYLE}
583
592
  focusVisibleStyle={FIELD_FOCUS_STYLE}
593
+ {...columnFitProps}
584
594
  {...props}
585
595
  {...passthroughSizeProps}
586
596
  {...insetPadProps}
@@ -772,11 +782,12 @@ const InputAreaImpl = InputFrame.styleable<{
772
782
  const hugWidth = flexProp === 0 || typeof widthProp === "number";
773
783
 
774
784
  return (
775
- // LC-06/LC-10 (UX-P12): in a table cell the flex wrapper and the raw
776
- // input must be allowed to shrink below the <input>'s intrinsic width
777
- // (min-width:auto floors flex items at it); width 100% makes the input
778
- // adopt the flexed width instead of its size-attribute default. Explicit
779
- // consumer widths (OTP digit boxes) still win via the later spread.
785
+ // LC-06/LC-10 (UX-P12, FZ-7853): in a table cell and in a form column
786
+ // alike, the flex wrapper and the raw input must be allowed to shrink
787
+ // below the <input>'s intrinsic width (min-width:auto floors flex items
788
+ // at it); width 100% makes the input adopt the flexed width instead of
789
+ // its size-attribute default. Explicit consumer widths (OTP digit boxes,
790
+ // Duration) still win via the later spread.
780
791
  <View
781
792
  flex={hugWidth ? 0 : 1}
782
793
  {...(hugWidth && typeof widthProp === "number"
@@ -789,7 +800,7 @@ const InputAreaImpl = InputFrame.styleable<{
789
800
  {...(heightProp != null
790
801
  ? { height: heightProp, minHeight: minHeightProp ?? 0, maxHeight: maxHeightProp }
791
802
  : undefined)}
792
- {...(inTableCell ? { minWidth: 0 } : undefined)}
803
+ {...(inTableCell || !hugWidth ? { minWidth: 0 } : undefined)}
793
804
  >
794
805
  <InputFrame
795
806
  touch={hydrationTouch}
@@ -800,7 +811,7 @@ const InputAreaImpl = InputFrame.styleable<{
800
811
  {...knobProps.controlType}
801
812
  color={knobProps.textAccentColor}
802
813
  placeholderTextColor={theme.placeholderColor?.val ?? "$placeholderColor"}
803
- {...(inTableCell ? { width: "100%" as const, minWidth: 0 } : undefined)}
814
+ {...(inTableCell || !hugWidth ? { width: "100%" as const, minWidth: 0 } : undefined)}
804
815
  {...knobProps.body}
805
816
  type={secureTextEntry ? "password" : props.type}
806
817
  padding={0}
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import { Form } from "../../Form";
6
6
  import { Checkbox } from "./Checkbox";
7
7
  import { Checkboxes } from "./index";
8
+ import { muteBubbleInputs } from "../../../tests/muteBubbleInputs";
8
9
 
9
10
  vi.mock("tamagui", async (importOriginal) => ({
10
11
  ...(await importOriginal<typeof import("tamagui")>()),
@@ -90,3 +91,46 @@ describe("native Checkbox square tap", () => {
90
91
  expect(handlersOnLastFrame()).toEqual([]);
91
92
  });
92
93
  });
94
+
95
+ // FZ-7849. Same contract as RadioGroup's row press: the row and the label toggle
96
+ // directly on native instead of through tamagui's focusable registry. A second
97
+ // toggle would undo the first, so every case counts the calls.
98
+ describe("native Checkbox row press", () => {
99
+ muteBubbleInputs();
100
+
101
+ const rowOf = (label: HTMLElement) => label.parentElement as HTMLElement;
102
+
103
+ it("toggles once from the row and once from the label, standalone", () => {
104
+ const onChange = vi.fn();
105
+ const result = renderWithProviders(<Checkbox label="Accept terms" onChange={onChange} />);
106
+ const label = result.getByText("Accept terms");
107
+ expect(label.hasAttribute("for")).toBe(false);
108
+ fireEvent.click(rowOf(label));
109
+ expect(onChange).toHaveBeenCalledExactlyOnceWith(true);
110
+ expect(result.getByRole("checkbox").getAttribute("aria-checked")).toBe("true");
111
+ fireEvent.click(label);
112
+ expect(onChange).toHaveBeenCalledTimes(2);
113
+ expect(onChange).toHaveBeenLastCalledWith(false);
114
+ expect(result.getByRole("checkbox").getAttribute("aria-checked")).toBe("false");
115
+ });
116
+
117
+ it("toggles a form field once from its label", () => {
118
+ const onChange = vi.fn();
119
+ const result = renderWithProviders(<FormCheckbox onChange={onChange} />);
120
+ fireEvent.click(result.getByText("Accept terms"));
121
+ expect(onChange).toHaveBeenCalledExactlyOnceWith(true);
122
+ fireEvent.click(rowOf(result.getByText("Accept terms")));
123
+ expect(onChange).toHaveBeenLastCalledWith(false);
124
+ });
125
+
126
+ it("leaves a disabled checkbox's row and label dead", () => {
127
+ const onChange = vi.fn();
128
+ const result = renderWithProviders(
129
+ <Checkbox label="Accept terms" onChange={onChange} disabled />,
130
+ );
131
+ const label = result.getByText("Accept terms");
132
+ fireEvent.click(rowOf(label));
133
+ fireEvent.click(label);
134
+ expect(onChange).not.toHaveBeenCalled();
135
+ });
136
+ });
@@ -42,6 +42,7 @@ import {
42
42
  getCheckboxIconSize,
43
43
  } from "./CheckboxBox";
44
44
  import { CheckboxFieldFrame } from "./CheckboxFieldFrame";
45
+ import { choiceLabelPressProps, choiceRowPressProps } from "../../shared/choiceRowPress";
45
46
 
46
47
  export interface CheckboxProps {
47
48
  name?: string;
@@ -285,6 +286,14 @@ export function Checkbox({
285
286
  if (!resolvedForm || !name) {
286
287
  const isControlled = checked !== undefined;
287
288
  const needsFieldLayout = !!(error || helperText);
289
+ const currentChecked = checked ?? uncontrolledChecked;
290
+ const commitChecked = (val: CheckedState) => {
291
+ if (disabled || readOnly) return;
292
+ setUncontrolledChecked(val);
293
+ handleValueChange(val);
294
+ };
295
+ // Indeterminate toggles to checked, as tamagui's own press does.
296
+ const toggle = disabled || readOnly ? undefined : () => commitChecked(currentChecked !== true);
288
297
 
289
298
  const checkboxEl = (
290
299
  <CheckboxFieldFrame
@@ -307,23 +316,23 @@ export function Checkbox({
307
316
  // whole assembly via FieldLayout instead.
308
317
  {...(disabled ? disabledState.chromeKnobProps : undefined)}
309
318
  id={id}
319
+ // Native rows toggle from outside the frame (FZ-7849), so the frame
320
+ // follows our state there instead of keeping its own.
310
321
  {...(isControlled
311
322
  ? { checked }
312
- : defaultValue !== undefined
313
- ? { defaultChecked: defaultValue as CheckedState }
314
- : undefined)}
323
+ : !isWeb
324
+ ? { checked: uncontrolledChecked ?? false }
325
+ : defaultValue !== undefined
326
+ ? { defaultChecked: defaultValue as CheckedState }
327
+ : undefined)}
315
328
  {...(readOnly ? { pointerEvents: "none" as const } : undefined)}
316
329
  {...(readOnly ? { "aria-readonly": true } : undefined)}
317
330
  aria-required={required || undefined}
318
331
  aria-invalid={!!error || undefined}
319
332
  {...nativeCheckboxA11y(typeof error === "string" && error ? error : undefined)}
320
- onCheckedChange={(val) => {
321
- if (disabled || readOnly) return;
322
- setUncontrolledChecked(val);
323
- handleValueChange(val);
324
- }}
333
+ onCheckedChange={commitChecked}
325
334
  >
326
- {renderControlContent(checked ?? uncontrolledChecked)}
335
+ {renderControlContent(currentChecked)}
327
336
  </CheckboxFieldFrame>
328
337
  );
329
338
 
@@ -340,11 +349,12 @@ export function Checkbox({
340
349
  {...(disabled && !needsFieldLayout
341
350
  ? { "data-disabled-dimmed": disabledState.style === "dimWhole" ? "true" : "false" }
342
351
  : undefined)}
352
+ {...(label ? choiceRowPressProps(toggle) : undefined)}
343
353
  >
344
354
  {checkboxEl}
345
355
  {label && (
346
356
  <Label
347
- htmlFor={id}
357
+ {...choiceLabelPressProps(id, toggle)}
348
358
  size={(size || "$true") as FontSizeTokens}
349
359
  {...knobProps.textWeight}
350
360
  color={baseLabelColor}
@@ -410,6 +420,14 @@ export function Checkbox({
410
420
  color: resolvedLabelColor,
411
421
  ...text.focusVisibleKnobProps,
412
422
  };
423
+ const commitChecked = (newChecked: CheckedState) => {
424
+ if (disabled || readOnly) return;
425
+ const fieldValue = newChecked === "indeterminate" ? false : newChecked === true;
426
+ field.handleChange(fieldValue as any);
427
+ handleValueChange(newChecked);
428
+ };
429
+ const toggle =
430
+ disabled || readOnly ? undefined : () => commitChecked(resolvedChecked !== true);
413
431
 
414
432
  return (
415
433
  <FieldLayout
@@ -425,6 +443,7 @@ export function Checkbox({
425
443
  gap={inlineGap}
426
444
  alignItems="center"
427
445
  {...(elevationWrapperProps.elevation && { overflow: "visible" as const })}
446
+ {...(label ? choiceRowPressProps(toggle) : undefined)}
428
447
  >
429
448
  {(() => {
430
449
  const checkboxElement = (
@@ -458,13 +477,7 @@ export function Checkbox({
458
477
  ? resolvedError
459
478
  : undefined,
460
479
  )}
461
- onCheckedChange={(newChecked) => {
462
- if (disabled || readOnly) return;
463
- const fieldValue =
464
- newChecked === "indeterminate" ? false : newChecked === true;
465
- field.handleChange(fieldValue as any);
466
- handleValueChange(newChecked);
467
- }}
480
+ onCheckedChange={commitChecked}
468
481
  >
469
482
  {renderControlContent(resolvedChecked)}
470
483
  </CheckboxFieldFrame>
@@ -473,7 +486,7 @@ export function Checkbox({
473
486
  })()}
474
487
  {label && (
475
488
  <Label
476
- htmlFor={id}
489
+ {...choiceLabelPressProps(id, toggle)}
477
490
  size={(size || "$true") as FontSizeTokens}
478
491
  {...knobProps.textWeight}
479
492
  color={resolvedLabelColor}
@@ -0,0 +1,85 @@
1
+ import type { ReactElement } from "react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
4
+ import { YStack } from "tamagui";
5
+ import { Input as InputParts } from "../../InputParts";
6
+ import { Input } from "../Input";
7
+ import { SearchInput } from "../SearchInput";
8
+ import { TimePicker } from "../TimePicker";
9
+ import { DatePicker, DateRangePicker, DatetimePicker, MonthPicker } from "./index";
10
+
11
+ vi.mock("@tamagui/lucide-icons-2", () => ({
12
+ Calendar: () => null,
13
+ ChevronLeft: () => null,
14
+ ChevronRight: () => null,
15
+ Clock: () => null,
16
+ X: () => null,
17
+ }));
18
+
19
+ // FZ-7853 / LC-10 FRAME-CONTAINMENT. happy-dom has no layout, so this pins the
20
+ // CSS that makes the browser fit: every field Box is capped at its column
21
+ // (max-width 100%, min-width 0), and the raw input inside takes the flexed
22
+ // width (width 100%, min-width 0) instead of its size-attribute width, which
23
+ // floored a clearable date field at 338px in a 324px column. The browser arm
24
+ // (lc-verify LC-10 fieldColumn, 320 and 390 viewports) measures the real box.
25
+ const column = 300;
26
+
27
+ function inColumn(node: ReactElement) {
28
+ return renderWithProviders(<YStack width={column}>{node}</YStack>);
29
+ }
30
+
31
+ function expectFits(root: HTMLElement) {
32
+ const boxes = [...root.querySelectorAll<HTMLElement>("[data-mp-input-box]")];
33
+ expect(boxes.length).toBeGreaterThan(0);
34
+ for (const box of boxes) {
35
+ const cs = getComputedStyle(box);
36
+ expect(cs.maxWidth).toBe("100%");
37
+ expect(cs.minWidth).toBe("0px");
38
+ for (const input of box.querySelectorAll<HTMLElement>("input[data-mp-input-area]")) {
39
+ expect(getComputedStyle(input).width).toBe("100%");
40
+ expect(getComputedStyle(input).minWidth).toBe("0px");
41
+ expect(getComputedStyle(input.parentElement as HTMLElement).minWidth).toBe("0px");
42
+ }
43
+ }
44
+ return boxes;
45
+ }
46
+
47
+ describe("FZ-7853 field boxes fit a phone-width column", () => {
48
+ it("a clearable DatePicker in a 300px column: box capped, input shrinks, clear inside the box", () => {
49
+ const result = inColumn(<DatePicker label="Due Date" value="2026-10-31" onChange={() => {}} />);
50
+ const [box] = expectFits(result.container);
51
+ const clear = result.container.querySelector("[data-testid='date-picker-clear']");
52
+ expect(clear).toBeTruthy();
53
+ expect(box.contains(clear)).toBe(true);
54
+ });
55
+
56
+ it.each([
57
+ ["DatePicker (empty)", <DatePicker key="d" label="Date" />],
58
+ [
59
+ "DatetimePicker",
60
+ <DatetimePicker key="dt" label="When" value={new Date("2026-10-31T09:30:00")} />,
61
+ ],
62
+ ["TimePicker", <TimePicker key="t" label="Time" value="09:30" onChange={() => {}} />],
63
+ ["MonthPicker", <MonthPicker key="m" label="Month" />],
64
+ ["DateRangePicker", <DateRangePicker key="r" label="Range" />],
65
+ ["Input", <Input key="i" label="Name" defaultValue="A long enough value to fill" />],
66
+ ["SearchInput", <SearchInput key="s" value="query" onChange={() => {}} />],
67
+ ])("%s takes the same column-fit shell", (_name, node) => {
68
+ expectFits(inColumn(node).container);
69
+ });
70
+
71
+ it("an Area with its own width keeps it (Duration and OTP digits)", () => {
72
+ const result = renderWithProviders(
73
+ <InputParts>
74
+ <InputParts.Box width={48}>
75
+ <InputParts.Area width={32} />
76
+ </InputParts.Box>
77
+ </InputParts>,
78
+ );
79
+ const box = result.container.querySelector<HTMLElement>("[data-mp-input-box]");
80
+ const input = result.container.querySelector<HTMLElement>("input[data-mp-input-area]");
81
+ expect(getComputedStyle(box as HTMLElement).width).toBe("48px");
82
+ expect(getComputedStyle(box as HTMLElement).maxWidth).not.toBe("100%");
83
+ expect(getComputedStyle(input as HTMLElement).width).toBe("32px");
84
+ });
85
+ });
@@ -1,6 +1,8 @@
1
1
  import { renderWithProviders } from "@multiplatform.one/test-utils";
2
+ import { fireEvent } from "@testing-library/react";
2
3
  import { describe, expect, it, vi } from "vitest";
3
4
  import { RadioGroup } from "./index";
5
+ import { muteBubbleInputs } from "../../../tests/muteBubbleInputs";
4
6
 
5
7
  // MPO-283. Mock tamagui's isWeb so the native branch runs on the test host
6
8
  // renderer, then read the React Native prop names back off the DOM. The state
@@ -65,3 +67,66 @@ describe("native RadioGroup accessibility contract", () => {
65
67
  // React Native accessibility props off Label entirely — so that one is
66
68
  // measured on device instead.
67
69
  });
70
+
71
+ // FZ-7849. On a device a label's press reached the radio only through tamagui's
72
+ // global focusable registry (Label `htmlFor` -> focusFocusable), which can go
73
+ // dead while the radio still works, and the row around the label was no target
74
+ // at all. The row and the label now select directly. This renderer bubbles
75
+ // clicks where React Native gives a tap to the innermost handler alone, so the
76
+ // label consumes its press and the row is clicked on its own.
77
+ describe("native RadioGroup row press", () => {
78
+ muteBubbleInputs();
79
+
80
+ const rowOf = (label: HTMLElement) => label.parentElement as HTMLElement;
81
+
82
+ it("selects an option from a press anywhere on its row", () => {
83
+ const onChange = vi.fn();
84
+ const result = renderWithProviders(
85
+ <RadioGroup label="Phase" options={phase} value="DEBITS" onChange={onChange} />,
86
+ );
87
+ fireEvent.click(rowOf(result.getByText("CREDITS")));
88
+ expect(onChange).toHaveBeenCalledExactlyOnceWith("CREDITS");
89
+ });
90
+
91
+ it("selects from the label without the id registry", () => {
92
+ const onChange = vi.fn();
93
+ const result = renderWithProviders(
94
+ <RadioGroup label="Phase" options={phase} defaultValue="DEBITS" onChange={onChange} />,
95
+ );
96
+ const label = result.getByText("CREDITS");
97
+ expect(label.hasAttribute("for")).toBe(false);
98
+ fireEvent.click(label);
99
+ expect(onChange).toHaveBeenCalledExactlyOnceWith("CREDITS");
100
+ });
101
+
102
+ it("does not re-emit the option that is already selected", () => {
103
+ const onChange = vi.fn();
104
+ const result = renderWithProviders(
105
+ <RadioGroup label="Phase" options={phase} value="DEBITS" onChange={onChange} />,
106
+ );
107
+ fireEvent.click(rowOf(result.getByText("DEBITS")));
108
+ fireEvent.click(result.getByText("DEBITS"));
109
+ expect(onChange).not.toHaveBeenCalled();
110
+ });
111
+
112
+ it("leaves a disabled option's row and label dead", () => {
113
+ const onChange = vi.fn();
114
+ const result = renderWithProviders(
115
+ <RadioGroup label="Phase" options={phase} value="DEBITS" onChange={onChange} />,
116
+ );
117
+ const label = result.getByText("DISCRETIONARY");
118
+ fireEvent.click(rowOf(label));
119
+ fireEvent.click(label);
120
+ expect(onChange).not.toHaveBeenCalled();
121
+ });
122
+
123
+ it("leaves a read-only group's rows dead", () => {
124
+ const onChange = vi.fn();
125
+ const result = renderWithProviders(
126
+ <RadioGroup label="Phase" options={phase} value="DEBITS" onChange={onChange} readOnly />,
127
+ );
128
+ fireEvent.click(rowOf(result.getByText("CREDITS")));
129
+ fireEvent.click(result.getByText("CREDITS"));
130
+ expect(onChange).not.toHaveBeenCalled();
131
+ });
132
+ });