@multiplatform.one/forms 7.57.0 → 7.59.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/AdaptivePopup/index.cjs +5 -1
- package/dist/cjs/AdaptivePopup/index.native.js +5 -1
- package/dist/cjs/AdaptivePopup/index.native.js.map +1 -1
- package/dist/cjs/Button/index.cjs +2 -1
- package/dist/cjs/Button/index.native.js +2 -1
- package/dist/cjs/Button/index.native.js.map +1 -1
- package/dist/cjs/FloatingPanel/compactFace.cjs +38 -0
- package/dist/cjs/FloatingPanel/compactFace.native.js +42 -0
- package/dist/cjs/FloatingPanel/compactFace.native.js.map +1 -0
- package/dist/cjs/FloatingPanel/index.cjs +38 -9
- package/dist/cjs/FloatingPanel/index.native.js +160 -6
- package/dist/cjs/FloatingPanel/index.native.js.map +1 -1
- package/dist/cjs/FloatingPanel/pointAnchor.cjs +22 -0
- package/dist/cjs/FloatingPanel/pointAnchor.native.js +23 -0
- package/dist/cjs/FloatingPanel/pointAnchor.native.js.map +1 -1
- package/dist/cjs/InputParts/index.cjs +13 -7
- package/dist/cjs/InputParts/index.native.js +13 -7
- package/dist/cjs/InputParts/index.native.js.map +1 -1
- package/dist/cjs/fields/Checkbox/Checkbox.cjs +24 -14
- package/dist/cjs/fields/Checkbox/Checkbox.native.js +38 -22
- package/dist/cjs/fields/Checkbox/Checkbox.native.js.map +1 -1
- package/dist/cjs/fields/RadioGroup/index.cjs +6 -1
- package/dist/cjs/fields/RadioGroup/index.native.js +6 -1
- package/dist/cjs/fields/RadioGroup/index.native.js.map +1 -1
- package/dist/cjs/fields/Select/InlineChoices.cjs +148 -0
- package/dist/cjs/fields/Select/InlineChoices.native.js +163 -0
- package/dist/cjs/fields/Select/InlineChoices.native.js.map +1 -0
- package/dist/cjs/fields/Select/index.cjs +79 -17
- package/dist/cjs/fields/Select/index.native.js +92 -17
- package/dist/cjs/fields/Select/index.native.js.map +1 -1
- package/dist/cjs/fields/Select/shouldInlineChoices.cjs +37 -0
- package/dist/cjs/fields/Select/shouldInlineChoices.native.js +43 -0
- package/dist/cjs/fields/Select/shouldInlineChoices.native.js.map +1 -0
- package/dist/cjs/fields/Switch/index.cjs +22 -10
- package/dist/cjs/fields/Switch/index.native.js +28 -14
- package/dist/cjs/fields/Switch/index.native.js.map +1 -1
- package/dist/cjs/shared/choiceRowPress.cjs +46 -0
- package/dist/cjs/shared/choiceRowPress.native.js +50 -0
- package/dist/cjs/shared/choiceRowPress.native.js.map +1 -0
- package/dist/cjs/shared/overlayStack.cjs +4 -0
- package/dist/cjs/shared/overlayStack.native.js +4 -0
- package/dist/cjs/shared/overlayStack.native.js.map +1 -1
- package/dist/esm/AdaptivePopup/index.mjs +6 -2
- package/dist/esm/AdaptivePopup/index.mjs.map +1 -1
- package/dist/esm/AdaptivePopup/index.native.js +6 -2
- package/dist/esm/AdaptivePopup/index.native.js.map +1 -1
- package/dist/esm/Button/index.mjs +3 -2
- package/dist/esm/Button/index.mjs.map +1 -1
- package/dist/esm/Button/index.native.js +3 -2
- package/dist/esm/Button/index.native.js.map +1 -1
- package/dist/esm/FloatingPanel/compactFace.mjs +11 -0
- package/dist/esm/FloatingPanel/compactFace.mjs.map +1 -0
- package/dist/esm/FloatingPanel/compactFace.native.js +12 -0
- package/dist/esm/FloatingPanel/compactFace.native.js.map +1 -0
- package/dist/esm/FloatingPanel/index.mjs +40 -11
- package/dist/esm/FloatingPanel/index.mjs.map +1 -1
- package/dist/esm/FloatingPanel/index.native.js +162 -8
- package/dist/esm/FloatingPanel/index.native.js.map +1 -1
- package/dist/esm/FloatingPanel/pointAnchor.mjs +22 -1
- package/dist/esm/FloatingPanel/pointAnchor.mjs.map +1 -1
- package/dist/esm/FloatingPanel/pointAnchor.native.js +23 -1
- package/dist/esm/FloatingPanel/pointAnchor.native.js.map +1 -1
- package/dist/esm/InputParts/index.mjs +13 -7
- package/dist/esm/InputParts/index.mjs.map +1 -1
- package/dist/esm/InputParts/index.native.js +13 -7
- package/dist/esm/InputParts/index.native.js.map +1 -1
- package/dist/esm/fields/Checkbox/Checkbox.mjs +24 -14
- package/dist/esm/fields/Checkbox/Checkbox.mjs.map +1 -1
- package/dist/esm/fields/Checkbox/Checkbox.native.js +38 -22
- package/dist/esm/fields/Checkbox/Checkbox.native.js.map +1 -1
- package/dist/esm/fields/RadioGroup/index.mjs +6 -1
- package/dist/esm/fields/RadioGroup/index.mjs.map +1 -1
- package/dist/esm/fields/RadioGroup/index.native.js +6 -1
- package/dist/esm/fields/RadioGroup/index.native.js.map +1 -1
- package/dist/esm/fields/Select/InlineChoices.mjs +123 -0
- package/dist/esm/fields/Select/InlineChoices.mjs.map +1 -0
- package/dist/esm/fields/Select/InlineChoices.native.js +135 -0
- package/dist/esm/fields/Select/InlineChoices.native.js.map +1 -0
- package/dist/esm/fields/Select/index.mjs +82 -20
- package/dist/esm/fields/Select/index.mjs.map +1 -1
- package/dist/esm/fields/Select/index.native.js +95 -20
- package/dist/esm/fields/Select/index.native.js.map +1 -1
- package/dist/esm/fields/Select/shouldInlineChoices.mjs +11 -0
- package/dist/esm/fields/Select/shouldInlineChoices.mjs.map +1 -0
- package/dist/esm/fields/Select/shouldInlineChoices.native.js +14 -0
- package/dist/esm/fields/Select/shouldInlineChoices.native.js.map +1 -0
- package/dist/esm/fields/Switch/index.mjs +22 -10
- package/dist/esm/fields/Switch/index.mjs.map +1 -1
- package/dist/esm/fields/Switch/index.native.js +28 -14
- package/dist/esm/fields/Switch/index.native.js.map +1 -1
- package/dist/esm/shared/choiceRowPress.mjs +20 -0
- package/dist/esm/shared/choiceRowPress.mjs.map +1 -0
- package/dist/esm/shared/choiceRowPress.native.js +21 -0
- package/dist/esm/shared/choiceRowPress.native.js.map +1 -0
- package/dist/esm/shared/overlayStack.mjs +4 -1
- package/dist/esm/shared/overlayStack.mjs.map +1 -1
- package/dist/esm/shared/overlayStack.native.js +4 -1
- package/dist/esm/shared/overlayStack.native.js.map +1 -1
- package/dist/jsx/AdaptivePopup/index.mjs +6 -2
- package/dist/jsx/AdaptivePopup/index.mjs.map +1 -1
- package/dist/jsx/AdaptivePopup/index.native.js +5 -1
- package/dist/jsx/AdaptivePopup/index.native.js.map +1 -1
- package/dist/jsx/Button/index.mjs +3 -2
- package/dist/jsx/Button/index.mjs.map +1 -1
- package/dist/jsx/Button/index.native.js +2 -1
- package/dist/jsx/Button/index.native.js.map +1 -1
- package/dist/jsx/FloatingPanel/compactFace.mjs +11 -0
- package/dist/jsx/FloatingPanel/compactFace.mjs.map +1 -0
- package/dist/jsx/FloatingPanel/compactFace.native.js +42 -0
- package/dist/jsx/FloatingPanel/compactFace.native.js.map +1 -0
- package/dist/jsx/FloatingPanel/index.mjs +40 -11
- package/dist/jsx/FloatingPanel/index.mjs.map +1 -1
- package/dist/jsx/FloatingPanel/index.native.js +160 -6
- package/dist/jsx/FloatingPanel/index.native.js.map +1 -1
- package/dist/jsx/FloatingPanel/pointAnchor.mjs +22 -1
- package/dist/jsx/FloatingPanel/pointAnchor.mjs.map +1 -1
- package/dist/jsx/FloatingPanel/pointAnchor.native.js +23 -0
- package/dist/jsx/FloatingPanel/pointAnchor.native.js.map +1 -1
- package/dist/jsx/InputParts/index.mjs +13 -7
- package/dist/jsx/InputParts/index.mjs.map +1 -1
- package/dist/jsx/InputParts/index.native.js +13 -7
- package/dist/jsx/InputParts/index.native.js.map +1 -1
- package/dist/jsx/fields/Checkbox/Checkbox.mjs +24 -14
- package/dist/jsx/fields/Checkbox/Checkbox.mjs.map +1 -1
- package/dist/jsx/fields/Checkbox/Checkbox.native.js +38 -22
- package/dist/jsx/fields/Checkbox/Checkbox.native.js.map +1 -1
- package/dist/jsx/fields/RadioGroup/index.mjs +6 -1
- package/dist/jsx/fields/RadioGroup/index.mjs.map +1 -1
- package/dist/jsx/fields/RadioGroup/index.native.js +6 -1
- package/dist/jsx/fields/RadioGroup/index.native.js.map +1 -1
- package/dist/jsx/fields/Select/InlineChoices.mjs +123 -0
- package/dist/jsx/fields/Select/InlineChoices.mjs.map +1 -0
- package/dist/jsx/fields/Select/InlineChoices.native.js +163 -0
- package/dist/jsx/fields/Select/InlineChoices.native.js.map +1 -0
- package/dist/jsx/fields/Select/index.mjs +82 -20
- package/dist/jsx/fields/Select/index.mjs.map +1 -1
- package/dist/jsx/fields/Select/index.native.js +92 -17
- package/dist/jsx/fields/Select/index.native.js.map +1 -1
- package/dist/jsx/fields/Select/shouldInlineChoices.mjs +11 -0
- package/dist/jsx/fields/Select/shouldInlineChoices.mjs.map +1 -0
- package/dist/jsx/fields/Select/shouldInlineChoices.native.js +43 -0
- package/dist/jsx/fields/Select/shouldInlineChoices.native.js.map +1 -0
- package/dist/jsx/fields/Switch/index.mjs +22 -10
- package/dist/jsx/fields/Switch/index.mjs.map +1 -1
- package/dist/jsx/fields/Switch/index.native.js +28 -14
- package/dist/jsx/fields/Switch/index.native.js.map +1 -1
- package/dist/jsx/shared/choiceRowPress.mjs +20 -0
- package/dist/jsx/shared/choiceRowPress.mjs.map +1 -0
- package/dist/jsx/shared/choiceRowPress.native.js +50 -0
- package/dist/jsx/shared/choiceRowPress.native.js.map +1 -0
- package/dist/jsx/shared/overlayStack.mjs +4 -1
- package/dist/jsx/shared/overlayStack.mjs.map +1 -1
- package/dist/jsx/shared/overlayStack.native.js +4 -0
- package/dist/jsx/shared/overlayStack.native.js.map +1 -1
- package/package.json +8 -8
- package/src/AdaptivePopup/index.tsx +6 -1
- package/src/Button/Button.controlTiers.spec.tsx +29 -0
- package/src/Button/index.tsx +3 -1
- package/src/FloatingPanel/FloatingPanel.compactFace.spec.tsx +187 -0
- package/src/FloatingPanel/FloatingPanel.menuFace.native.spec.tsx +195 -0
- package/src/FloatingPanel/compactFace.spec.ts +40 -0
- package/src/FloatingPanel/compactFace.ts +32 -0
- package/src/FloatingPanel/index.native.tsx +178 -4
- package/src/FloatingPanel/index.tsx +55 -11
- package/src/FloatingPanel/pointAnchor.spec.ts +80 -0
- package/src/FloatingPanel/pointAnchor.ts +52 -0
- package/src/InputParts/index.tsx +18 -7
- package/src/fields/Checkbox/Checkbox.native.spec.tsx +44 -0
- package/src/fields/Checkbox/Checkbox.tsx +31 -18
- package/src/fields/DatePicker/columnFit.spec.tsx +85 -0
- package/src/fields/RadioGroup/RadioGroup.native.spec.tsx +65 -0
- package/src/fields/RadioGroup/RadioGroup.stories.tsx +49 -0
- package/src/fields/RadioGroup/index.tsx +14 -2
- package/src/fields/Select/InlineChoices.tsx +156 -0
- package/src/fields/Select/index.tsx +100 -45
- package/src/fields/Select/shouldInlineChoices.spec.ts +25 -0
- package/src/fields/Select/shouldInlineChoices.ts +22 -0
- package/src/fields/Switch/Switch.rowPress.native.spec.tsx +69 -0
- package/src/fields/Switch/index.tsx +29 -11
- package/src/fields/pickerRows.spec.tsx +2 -0
- package/src/overlayGround.spec.ts +181 -0
- package/src/shared/choiceRowPress.ts +40 -0
- package/src/shared/overlayStack.tsx +5 -0
- package/types/AdaptivePopup/index.d.ts.map +1 -1
- package/types/Button/index.d.ts.map +1 -1
- package/types/FloatingPanel/compactFace.d.ts +21 -0
- package/types/FloatingPanel/compactFace.d.ts.map +1 -0
- package/types/FloatingPanel/index.d.ts +10 -0
- package/types/FloatingPanel/index.d.ts.map +1 -1
- package/types/FloatingPanel/index.native.d.ts +8 -1
- package/types/FloatingPanel/index.native.d.ts.map +1 -1
- package/types/FloatingPanel/pointAnchor.d.ts +29 -0
- package/types/FloatingPanel/pointAnchor.d.ts.map +1 -1
- package/types/InputParts/index.d.ts.map +1 -1
- package/types/fields/Checkbox/Checkbox.d.ts.map +1 -1
- package/types/fields/Checkbox/index.d.ts +3 -3
- package/types/fields/RadioGroup/index.d.ts.map +1 -1
- package/types/fields/Select/InlineChoices.d.ts +29 -0
- package/types/fields/Select/InlineChoices.d.ts.map +1 -0
- package/types/fields/Select/index.d.ts +6 -0
- package/types/fields/Select/index.d.ts.map +1 -1
- package/types/fields/Select/shouldInlineChoices.d.ts +16 -0
- package/types/fields/Select/shouldInlineChoices.d.ts.map +1 -0
- package/types/fields/Switch/index.d.ts.map +1 -1
- package/types/shared/choiceRowPress.d.ts +40 -0
- package/types/shared/choiceRowPress.d.ts.map +1 -0
- package/types/shared/overlayStack.d.ts +2 -0
- package/types/shared/overlayStack.d.ts.map +1 -1
|
@@ -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:
|
|
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
|
-
|
|
797
|
-
|
|
798
|
-
|
|
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 =
|
|
801
|
-
const cssLeft =
|
|
802
|
-
const maxHeight = Math.max(0, viewportBottom - panelViewportPadding -
|
|
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
|
+
}
|
package/src/InputParts/index.tsx
CHANGED
|
@@ -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
|
|
776
|
-
// input must be allowed to shrink
|
|
777
|
-
// (min-width:auto floors flex items
|
|
778
|
-
// adopt the flexed width instead of
|
|
779
|
-
// consumer widths (OTP digit boxes
|
|
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
|
-
:
|
|
313
|
-
? {
|
|
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={
|
|
321
|
-
if (disabled || readOnly) return;
|
|
322
|
-
setUncontrolledChecked(val);
|
|
323
|
-
handleValueChange(val);
|
|
324
|
-
}}
|
|
333
|
+
onCheckedChange={commitChecked}
|
|
325
334
|
>
|
|
326
|
-
{renderControlContent(
|
|
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
|
-
|
|
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={
|
|
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
|
-
|
|
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
|
+
});
|