@multiplatform.one/forms 7.7.3 → 7.7.6

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 (112) hide show
  1. package/dist/cjs/Button/index.cjs +3 -33
  2. package/dist/cjs/Button/index.native.js +3 -33
  3. package/dist/cjs/Button/index.native.js.map +1 -1
  4. package/dist/cjs/ContextualSaveBar/index.cjs +17 -10
  5. package/dist/cjs/ContextualSaveBar/index.native.js +18 -10
  6. package/dist/cjs/ContextualSaveBar/index.native.js.map +1 -1
  7. package/dist/cjs/FloatingPanel/index.native.js +51 -28
  8. package/dist/cjs/FloatingPanel/index.native.js.map +1 -1
  9. package/dist/cjs/InputParts/index.cjs +3 -1
  10. package/dist/cjs/InputParts/index.native.js +3 -1
  11. package/dist/cjs/InputParts/index.native.js.map +1 -1
  12. package/dist/cjs/fields/Combobox/index.cjs +39 -48
  13. package/dist/cjs/fields/Combobox/index.native.js +43 -51
  14. package/dist/cjs/fields/Combobox/index.native.js.map +1 -1
  15. package/dist/cjs/fields/Select/index.cjs +11 -7
  16. package/dist/cjs/fields/Select/index.native.js +13 -19
  17. package/dist/cjs/fields/Select/index.native.js.map +1 -1
  18. package/dist/cjs/shared/pickerTriggerContract.cjs +38 -0
  19. package/dist/cjs/shared/pickerTriggerContract.native.js +41 -0
  20. package/dist/cjs/shared/pickerTriggerContract.native.js.map +1 -0
  21. package/dist/cjs/shared/pressSlopProps.cjs +68 -0
  22. package/dist/cjs/shared/pressSlopProps.native.js +72 -0
  23. package/dist/cjs/shared/pressSlopProps.native.js.map +1 -0
  24. package/dist/esm/Button/index.mjs +1 -31
  25. package/dist/esm/Button/index.mjs.map +1 -1
  26. package/dist/esm/Button/index.native.js +1 -31
  27. package/dist/esm/Button/index.native.js.map +1 -1
  28. package/dist/esm/ContextualSaveBar/index.mjs +17 -10
  29. package/dist/esm/ContextualSaveBar/index.mjs.map +1 -1
  30. package/dist/esm/ContextualSaveBar/index.native.js +18 -10
  31. package/dist/esm/ContextualSaveBar/index.native.js.map +1 -1
  32. package/dist/esm/FloatingPanel/index.native.js +51 -28
  33. package/dist/esm/FloatingPanel/index.native.js.map +1 -1
  34. package/dist/esm/InputParts/index.mjs +3 -1
  35. package/dist/esm/InputParts/index.mjs.map +1 -1
  36. package/dist/esm/InputParts/index.native.js +3 -1
  37. package/dist/esm/InputParts/index.native.js.map +1 -1
  38. package/dist/esm/fields/Combobox/index.mjs +39 -48
  39. package/dist/esm/fields/Combobox/index.mjs.map +1 -1
  40. package/dist/esm/fields/Combobox/index.native.js +43 -51
  41. package/dist/esm/fields/Combobox/index.native.js.map +1 -1
  42. package/dist/esm/fields/Select/index.mjs +12 -8
  43. package/dist/esm/fields/Select/index.mjs.map +1 -1
  44. package/dist/esm/fields/Select/index.native.js +14 -20
  45. package/dist/esm/fields/Select/index.native.js.map +1 -1
  46. package/dist/esm/shared/pickerTriggerContract.mjs +13 -0
  47. package/dist/esm/shared/pickerTriggerContract.mjs.map +1 -0
  48. package/dist/esm/shared/pickerTriggerContract.native.js +13 -0
  49. package/dist/esm/shared/pickerTriggerContract.native.js.map +1 -0
  50. package/dist/esm/shared/pressSlopProps.mjs +43 -0
  51. package/dist/esm/shared/pressSlopProps.mjs.map +1 -0
  52. package/dist/esm/shared/pressSlopProps.native.js +44 -0
  53. package/dist/esm/shared/pressSlopProps.native.js.map +1 -0
  54. package/dist/jsx/Button/index.mjs +1 -31
  55. package/dist/jsx/Button/index.mjs.map +1 -1
  56. package/dist/jsx/Button/index.native.js +3 -33
  57. package/dist/jsx/Button/index.native.js.map +1 -1
  58. package/dist/jsx/ContextualSaveBar/index.mjs +17 -10
  59. package/dist/jsx/ContextualSaveBar/index.mjs.map +1 -1
  60. package/dist/jsx/ContextualSaveBar/index.native.js +18 -10
  61. package/dist/jsx/ContextualSaveBar/index.native.js.map +1 -1
  62. package/dist/jsx/FloatingPanel/index.native.js +51 -28
  63. package/dist/jsx/FloatingPanel/index.native.js.map +1 -1
  64. package/dist/jsx/InputParts/index.mjs +3 -1
  65. package/dist/jsx/InputParts/index.mjs.map +1 -1
  66. package/dist/jsx/InputParts/index.native.js +3 -1
  67. package/dist/jsx/InputParts/index.native.js.map +1 -1
  68. package/dist/jsx/fields/Combobox/index.mjs +39 -48
  69. package/dist/jsx/fields/Combobox/index.mjs.map +1 -1
  70. package/dist/jsx/fields/Combobox/index.native.js +43 -51
  71. package/dist/jsx/fields/Combobox/index.native.js.map +1 -1
  72. package/dist/jsx/fields/Select/index.mjs +12 -8
  73. package/dist/jsx/fields/Select/index.mjs.map +1 -1
  74. package/dist/jsx/fields/Select/index.native.js +13 -19
  75. package/dist/jsx/fields/Select/index.native.js.map +1 -1
  76. package/dist/jsx/shared/pickerTriggerContract.mjs +13 -0
  77. package/dist/jsx/shared/pickerTriggerContract.mjs.map +1 -0
  78. package/dist/jsx/shared/pickerTriggerContract.native.js +41 -0
  79. package/dist/jsx/shared/pickerTriggerContract.native.js.map +1 -0
  80. package/dist/jsx/shared/pressSlopProps.mjs +43 -0
  81. package/dist/jsx/shared/pressSlopProps.mjs.map +1 -0
  82. package/dist/jsx/shared/pressSlopProps.native.js +72 -0
  83. package/dist/jsx/shared/pressSlopProps.native.js.map +1 -0
  84. package/package.json +7 -7
  85. package/src/Button/index.tsx +1 -35
  86. package/src/ContextualSaveBar/ContextualSaveBar.spec.tsx +33 -0
  87. package/src/ContextualSaveBar/ContextualSaveBar.stories.tsx +19 -0
  88. package/src/ContextualSaveBar/index.tsx +12 -8
  89. package/src/ContextualSaveBar/nativeFlow.spec.tsx +20 -0
  90. package/src/FloatingPanel/FloatingPanel.native.spec.tsx +138 -0
  91. package/src/FloatingPanel/index.native.tsx +76 -31
  92. package/src/InputParts/index.tsx +5 -1
  93. package/src/fields/Combobox/index.tsx +40 -44
  94. package/src/fields/Select/index.tsx +13 -18
  95. package/src/fields/pickerActivation.spec.tsx +105 -0
  96. package/src/fields/pickerDensity.spec.tsx +86 -0
  97. package/src/fields/pickerRows.spec.tsx +66 -0
  98. package/src/shared/pickerTriggerContract.ts +13 -0
  99. package/src/shared/pressSlopProps.spec.ts +39 -0
  100. package/src/shared/pressSlopProps.ts +52 -0
  101. package/types/Button/index.d.ts.map +1 -1
  102. package/types/ContextualSaveBar/index.d.ts +1 -1
  103. package/types/ContextualSaveBar/index.d.ts.map +1 -1
  104. package/types/FloatingPanel/index.native.d.ts +16 -1
  105. package/types/FloatingPanel/index.native.d.ts.map +1 -1
  106. package/types/InputParts/index.d.ts.map +1 -1
  107. package/types/fields/Combobox/index.d.ts.map +1 -1
  108. package/types/fields/Select/index.d.ts.map +1 -1
  109. package/types/shared/pickerTriggerContract.d.ts +18 -0
  110. package/types/shared/pickerTriggerContract.d.ts.map +1 -0
  111. package/types/shared/pressSlopProps.d.ts +8 -0
  112. package/types/shared/pressSlopProps.d.ts.map +1 -0
@@ -39,7 +39,7 @@ export interface ContextualSaveBarProps {
39
39
  * Form reads this; the bar itself does not.
40
40
  */
41
41
  onAutoCommit?: (name: string, value: unknown) => void;
42
- /** Override the in-bar status line. */
42
+ /** Override normal dirty-state copy; saving and failures retain their status. */
43
43
  message?: string;
44
44
  }
45
45
 
@@ -83,7 +83,6 @@ function statusCopy(args: {
83
83
  dirty: number;
84
84
  override?: string;
85
85
  }): { text: string; failed: boolean; qualifier?: string } {
86
- if (args.override) return { text: args.override, failed: false };
87
86
  if (args.isSubmitting) return { text: t("Saving…"), failed: false };
88
87
  if (args.saveError) {
89
88
  return {
@@ -101,6 +100,7 @@ function statusCopy(args: {
101
100
  failed: true,
102
101
  };
103
102
  }
103
+ if (args.override) return { text: args.override, failed: false };
104
104
  const qualifier =
105
105
  args.dirty > 0
106
106
  ? args.dirty === 1
@@ -236,11 +236,13 @@ export function ContextualSaveBar({
236
236
  data-mp-savebar-bottom-radius="0"
237
237
  role="region"
238
238
  aria-label={t("Unsaved changes")}
239
- position="sticky"
240
- bottom={0}
239
+ // Tamagui maps sticky to absolute on native; keep the dock in flow there.
240
+ position={isWeb ? "sticky" : "relative"}
241
+ bottom={isWeb ? 0 : undefined}
241
242
  width="100%"
242
243
  zIndex={zIndex.sheet}
243
- alignItems="center"
244
+ flexDirection={copy.failed ? "column" : "row"}
245
+ alignItems={copy.failed ? "stretch" : "center"}
244
246
  paddingTop="$2.5"
245
247
  paddingHorizontal={padX as never}
246
248
  paddingBottom="$2.5"
@@ -266,15 +268,17 @@ export function ContextualSaveBar({
266
268
  ...(elevationProps.style as object | undefined),
267
269
  }}
268
270
  >
269
- <XStack flex={1} minWidth={0} alignItems="baseline" gap="$2">
271
+ <XStack flex={copy.failed ? undefined : 1} minWidth={0} alignItems="baseline" gap="$2">
270
272
  <Text
271
273
  role="status"
272
274
  {...knobProps.body}
273
275
  fontSize="$4"
274
276
  lineHeight="$4"
275
277
  color={copy.failed ? formCommonColors.error : "$color"}
276
- numberOfLines={1}
277
- ellipsizeMode="tail"
278
+ flexShrink={1}
279
+ numberOfLines={copy.failed ? undefined : 1}
280
+ ellipsizeMode={copy.failed ? undefined : "tail"}
281
+ {...(isWeb && copy.failed ? { style: { overflowWrap: "anywhere" } } : {})}
278
282
  >
279
283
  {copy.text}
280
284
  </Text>
@@ -0,0 +1,20 @@
1
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
2
+ import { expect, it, vi } from "vitest";
3
+ import { Form } from "../Form";
4
+ import { ContextualSaveBar } from "./index";
5
+
6
+ vi.mock("tamagui", async (importOriginal) => ({
7
+ ...(await importOriginal<typeof import("tamagui")>()),
8
+ isWeb: false,
9
+ }));
10
+
11
+ it("reserves native layout space instead of mapping a sticky dock to absolute", () => {
12
+ const view = renderWithProviders(
13
+ <Form formOptions={{ defaultValues: { name: "example" } }}>
14
+ <ContextualSaveBar forceVisible />
15
+ </Form>,
16
+ );
17
+ const bar = view.container.querySelector("[data-mp-contextual-savebar]")!;
18
+ expect(getComputedStyle(bar).position).toBe("relative");
19
+ expect(getComputedStyle(bar).bottom).not.toBe("0px");
20
+ });
@@ -0,0 +1,138 @@
1
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
2
+ import { act } from "@testing-library/react";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { Animated, PanResponder } from "react-native";
5
+ import { Text } from "tamagui";
6
+ import { FloatingPanel } from "./index.native";
7
+
8
+ const modal = vi.hoisted(() => ({ onShow: undefined as undefined | (() => void) }));
9
+ vi.mock("react-native-web", async (importOriginal) => ({
10
+ ...(await importOriginal<typeof import("react-native")>()),
11
+ ActionSheetIOS: undefined,
12
+ Modal: ({ children, onShow }: { children: import("react").ReactNode; onShow?: () => void }) => {
13
+ modal.onShow = onShow;
14
+ return children;
15
+ },
16
+ }));
17
+
18
+ const onOpenChange = vi.fn();
19
+ let dismissCompletion: (result: { finished: boolean }) => void;
20
+ let release: () => void;
21
+ let completions: Array<(result: { finished: boolean }) => void>;
22
+ beforeEach(() => {
23
+ vi.useFakeTimers();
24
+ completions = [];
25
+ modal.onShow = undefined;
26
+ onOpenChange.mockReset();
27
+ vi.spyOn(PanResponder, "create").mockImplementation((config) => {
28
+ release = () => config.onPanResponderRelease?.({} as never, { dy: 1000, vy: 2 } as never);
29
+ return { panHandlers: {} };
30
+ });
31
+ vi.spyOn(Animated, "spring").mockImplementation(() => ({
32
+ start: (callback) => {
33
+ if (callback) dismissCompletion = callback;
34
+ },
35
+ stop: vi.fn(),
36
+ reset: vi.fn(),
37
+ }));
38
+ vi.spyOn(Animated, "timing").mockImplementation(() => ({
39
+ start: (callback) => {
40
+ if (callback) completions.push(callback);
41
+ },
42
+ stop: vi.fn(),
43
+ reset: vi.fn(),
44
+ }));
45
+ });
46
+ afterEach(() => {
47
+ vi.restoreAllMocks();
48
+ vi.useRealTimers();
49
+ });
50
+
51
+ function panel(open: boolean) {
52
+ return (
53
+ <FloatingPanel open={open} onOpenChange={onOpenChange} trigger={<Text>Pick one</Text>}>
54
+ <Text>row</Text>
55
+ </FloatingPanel>
56
+ );
57
+ }
58
+
59
+ describe("FloatingPanel native presentation lifecycle", () => {
60
+ it("enters without onShow and ignores onShow for the same presentation", () => {
61
+ renderWithProviders(panel(true));
62
+ expect(Animated.timing).toHaveBeenCalledTimes(1);
63
+ expect(modal.onShow).toBeTypeOf("function");
64
+ act(() => modal.onShow?.());
65
+ expect(Animated.timing).toHaveBeenCalledTimes(1);
66
+ });
67
+ it("ignores onShow after the presentation closes", () => {
68
+ const { rerender } = renderWithProviders(panel(true));
69
+ const lateShow = modal.onShow;
70
+ rerender(panel(false));
71
+ const count = vi.mocked(Animated.timing).mock.calls.length;
72
+ act(() => lateShow?.());
73
+ expect(Animated.timing).toHaveBeenCalledTimes(count);
74
+ expect(vi.getTimerCount()).toBe(0);
75
+ });
76
+ it("ignores a completed entrance from an earlier presentation", () => {
77
+ const { rerender } = renderWithProviders(panel(true));
78
+ const oldCompletion = completions[0];
79
+ rerender(panel(false));
80
+ rerender(panel(true));
81
+ const writes = vi.spyOn(Animated.Value.prototype, "setValue");
82
+ act(() => oldCompletion({ finished: true }));
83
+ expect(writes).not.toHaveBeenCalled();
84
+ act(() => completions[1]({ finished: true }));
85
+ expect(writes).toHaveBeenCalledWith(0);
86
+ });
87
+ it("ignores callbacks after unmount and clears its timer", () => {
88
+ const { unmount } = renderWithProviders(panel(true));
89
+ const lateShow = modal.onShow;
90
+ const completion = completions[0];
91
+ const stop = vi.spyOn(Animated.Value.prototype, "stopAnimation");
92
+ unmount();
93
+ expect(stop).toHaveBeenCalled();
94
+ const writes = vi.spyOn(Animated.Value.prototype, "setValue");
95
+ const count = vi.mocked(Animated.timing).mock.calls.length;
96
+ act(() => {
97
+ lateShow?.();
98
+ completion({ finished: true });
99
+ });
100
+ expect(writes).not.toHaveBeenCalled();
101
+ expect(Animated.timing).toHaveBeenCalledTimes(count);
102
+ expect(vi.getTimerCount()).toBe(0);
103
+ });
104
+ it("does not let an earlier dismiss close a reopened presentation", () => {
105
+ const { rerender } = renderWithProviders(panel(true));
106
+ act(() => release());
107
+ const oldDismiss = dismissCompletion;
108
+ rerender(panel(false));
109
+ rerender(panel(true));
110
+ act(() => oldDismiss({ finished: true }));
111
+ expect(onOpenChange).not.toHaveBeenCalled();
112
+ });
113
+ it("closes only after a successful current dismiss", () => {
114
+ renderWithProviders(panel(true));
115
+ act(() => release());
116
+ act(() => dismissCompletion({ finished: false }));
117
+ expect(onOpenChange).not.toHaveBeenCalled();
118
+ act(() => release());
119
+ act(() => dismissCompletion({ finished: true }));
120
+ expect(onOpenChange).toHaveBeenCalledExactlyOnceWith(false);
121
+ });
122
+ it("owns only the current StrictMode effect presentation", () => {
123
+ renderWithProviders(panel(true), { reactStrictMode: true });
124
+ expect(completions.length).toBeGreaterThanOrEqual(2);
125
+ const writes = vi.spyOn(Animated.Value.prototype, "setValue");
126
+ act(() => completions[0]({ finished: true }));
127
+ expect(writes).not.toHaveBeenCalled();
128
+ act(() => completions[completions.length - 1]({ finished: true }));
129
+ expect(writes).toHaveBeenCalledWith(0);
130
+ });
131
+ it("keeps the measured JS-driver workaround", () => {
132
+ renderWithProviders(panel(true));
133
+ expect(Animated.timing).toHaveBeenCalledWith(
134
+ expect.anything(),
135
+ expect.objectContaining({ useNativeDriver: false }),
136
+ );
137
+ });
138
+ });
@@ -19,6 +19,21 @@
19
19
  * closes the panel
20
20
  * - backdrop tap and the Android back button dismiss WITHOUT committing
21
21
  *
22
+ * Entrance / driver notes (MPO-256):
23
+ * - The slide-in is driven by an EFFECT on `open`, not by `Modal.onShow`
24
+ * alone. `onShow` still fires it (it is the better moment when it arrives)
25
+ * but it is one of two triggers, guarded to run once per present. A sheet
26
+ * whose only path onto the screen is a native presentation callback is a
27
+ * sheet that is off-screen whenever that callback does not arrive.
28
+ * - Every animation inside the Modal uses the JS driver. On the New
29
+ * Architecture a native-driven Animated node inside a core RN `Modal`
30
+ * never advances: `setValue` still paints (the node is updated through the
31
+ * view's props), but `Animated.timing(...).start()` moves nothing. That
32
+ * left the sheet parked at `travel` — exactly one sheet-height BELOW its
33
+ * resting place, every row present in the a11y tree and none of them on
34
+ * screen. The drag path already used JS `setValue`, so this also stops the
35
+ * drag and the settle from fighting over two drivers.
36
+ *
22
37
  * Hit-testing / open-race notes:
23
38
  * - Fabric + Tamagui XGroup steal the responder without firing onPress, so
24
39
  * this Pressable owns the open gesture and forces pointerEvents="none" on
@@ -27,7 +42,7 @@
27
42
  * gesture, React batches setOpen(true) then setOpen(prev => !prev) → false
28
43
  * and the sheet never appears. Fields should not also toggle open on native.
29
44
  * - Opening a Modal under the same finger can synthesize a backdrop press;
30
- * backdrop dismiss is armed only after Modal.onShow + a short delay.
45
+ * backdrop dismiss is armed a short delay after the present begins.
31
46
  */
32
47
 
33
48
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -82,6 +97,8 @@ const minSheetHeight = 180;
82
97
  const mediumDetent = 0.55;
83
98
  // Released more than this far below the smallest detent, the sheet dismisses.
84
99
  const dismissThreshold = 56;
100
+ // Slide-in, and the delay before a backdrop tap can dismiss.
101
+ const enterDuration = 280;
85
102
 
86
103
  export function FloatingPanel({
87
104
  open,
@@ -117,19 +134,6 @@ export function FloatingPanel({
117
134
  if (backdropArmed) close();
118
135
  }, [backdropArmed, close]);
119
136
 
120
- useEffect(() => {
121
- if (!open) {
122
- setBackdropArmed(false);
123
- if (armTimerRef.current) {
124
- clearTimeout(armTimerRef.current);
125
- armTimerRef.current = null;
126
- }
127
- }
128
- return () => {
129
- if (armTimerRef.current) clearTimeout(armTimerRef.current);
130
- };
131
- }, [open]);
132
-
133
137
  const framePadding = contentPadding === "none" ? "$0" : knobProps.panelPadding.padding;
134
138
  const frameGap = contentPadding === "none" ? undefined : knobProps.gapLg.gap;
135
139
  /*
@@ -191,14 +195,9 @@ export function FloatingPanel({
191
195
  travelRef.current = travel;
192
196
  detentsRef.current = detents;
193
197
 
194
- useEffect(() => {
195
- if (open) return;
196
- // Park off-screen before the next present so the first painted frame of
197
- // `visible={true}` is not the resting sheet (open → closed → open).
198
- const parked = travelRef.current || 10000;
199
- translateY.setValue(parked);
200
- restRef.current = parked;
201
- }, [open, translateY]);
198
+ // Async callbacks belong to one committed presentation, never its successor.
199
+ const presentationRef = useRef(0);
200
+ const activeRef = useRef(false);
202
201
 
203
202
  const settleTo = useCallback(
204
203
  (toValue: number, velocity = 0) => {
@@ -210,7 +209,9 @@ export function FloatingPanel({
210
209
  stiffness: 320,
211
210
  mass: 0.9,
212
211
  overshootClamping: true,
213
- useNativeDriver: true,
212
+ // JS driver: see the header. A native-driven node inside this Modal
213
+ // does not advance, and the drag already writes the value from JS.
214
+ useNativeDriver: false,
214
215
  }).start();
215
216
  },
216
217
  [translateY],
@@ -218,6 +219,8 @@ export function FloatingPanel({
218
219
 
219
220
  const dismiss = useCallback(
220
221
  (velocity = 0) => {
222
+ if (!activeRef.current) return;
223
+ const presentation = presentationRef.current;
221
224
  restRef.current = travelRef.current;
222
225
  Animated.spring(translateY, {
223
226
  toValue: travelRef.current,
@@ -226,9 +229,9 @@ export function FloatingPanel({
226
229
  stiffness: 400,
227
230
  mass: 0.9,
228
231
  overshootClamping: true,
229
- useNativeDriver: true,
232
+ useNativeDriver: false,
230
233
  }).start(({ finished }) => {
231
- if (finished) close();
234
+ if (finished && activeRef.current && presentation === presentationRef.current) close();
232
235
  });
233
236
  },
234
237
  [close, translateY],
@@ -278,22 +281,64 @@ export function FloatingPanel({
278
281
  [dismiss, settleTo, translateY],
279
282
  );
280
283
 
281
- const handleModalShow = useCallback(() => {
284
+ // One entrance per present, whichever trigger gets here first: the effect
285
+ // below (React knows `open` flipped) or Modal.onShow (the platform says the
286
+ // presentation finished). The ref makes the second one a no-op instead of a
287
+ // second slide, and is cleared when the sheet closes.
288
+ const enteredRef = useRef(false);
289
+ const enter = useCallback(() => {
290
+ if (!activeRef.current || enteredRef.current) return;
291
+ const presentation = presentationRef.current;
292
+ enteredRef.current = true;
282
293
  if (armTimerRef.current) clearTimeout(armTimerRef.current);
283
294
  // One frame + small delay so the opening tap cannot dismiss immediately.
284
- armTimerRef.current = setTimeout(() => setBackdropArmed(true), 280);
295
+ armTimerRef.current = setTimeout(() => {
296
+ if (activeRef.current && presentation === presentationRef.current) {
297
+ armTimerRef.current = null;
298
+ setBackdropArmed(true);
299
+ }
300
+ }, enterDuration);
285
301
  // MPO-62: the modal itself fades (so the backdrop dims IN PLACE) and only
286
302
  // the sheet slides, on this transform.
287
303
  translateY.setValue(travelRef.current);
288
304
  restRef.current = 0;
289
305
  Animated.timing(translateY, {
290
306
  toValue: 0,
291
- duration: 280,
307
+ duration: enterDuration,
292
308
  easing: Easing.out(Easing.cubic),
293
- useNativeDriver: true,
294
- }).start();
309
+ useNativeDriver: false,
310
+ }).start(({ finished }) => {
311
+ // An interrupted entrance (a drag caught it mid-slide) leaves the pan
312
+ // responder owning the value; only a completed one asserts the rest.
313
+ if (finished && activeRef.current && presentation === presentationRef.current)
314
+ translateY.setValue(0);
315
+ });
295
316
  }, [translateY]);
296
317
 
318
+ useEffect(() => {
319
+ presentationRef.current += 1;
320
+ activeRef.current = open;
321
+ if (open) {
322
+ enter();
323
+ } else {
324
+ setBackdropArmed(false);
325
+ const parked = travelRef.current || 10000;
326
+ translateY.setValue(parked);
327
+ restRef.current = parked;
328
+ }
329
+ return () => {
330
+ // Invalidate first: stopping an animation can synchronously finish it.
331
+ activeRef.current = false;
332
+ presentationRef.current += 1;
333
+ enteredRef.current = false;
334
+ if (armTimerRef.current) {
335
+ clearTimeout(armTimerRef.current);
336
+ armTimerRef.current = null;
337
+ }
338
+ translateY.stopAnimation();
339
+ };
340
+ }, [open, enter, translateY]);
341
+
297
342
  const content = scrollable ? (
298
343
  <RNScrollView
299
344
  // sheetFill: the list scrollport fills the fixed-height sheet; default:
@@ -346,7 +391,7 @@ export function FloatingPanel({
346
391
  animationType="fade"
347
392
  statusBarTranslucent
348
393
  onRequestClose={close}
349
- onShow={handleModalShow}
394
+ onShow={enter}
350
395
  >
351
396
  {/*
352
397
  A plain padded column, not KeyboardAvoidingView: inside a Modal the
@@ -443,7 +443,10 @@ const InputGroupImpl = InputGroupFrame.styleable<{
443
443
  // paints outside overflow:hidden, so the ring clip can stay.
444
444
  const paintsRing = !grouped && !inTableCell;
445
445
  const hasElevation = knobProps.elevation != null;
446
- const boxOverflow: BoxOverflow = paintsRing && !(hasElevation && !isWeb) ? "hidden" : "visible";
446
+ const hasWebPressSlop =
447
+ isWeb && (props as { "data-mp-press-slop"?: string })["data-mp-press-slop"] != null;
448
+ const boxOverflow: BoxOverflow =
449
+ paintsRing && !(hasElevation && !isWeb) && !hasWebPressSlop ? "hidden" : "visible";
447
450
  const ringMode = boxRingMode(boxOverflow, paintsRing);
448
451
  if (isWeb) {
449
452
  ensureCompositeFocusRing();
@@ -536,6 +539,7 @@ const InputGroupImpl = InputGroupFrame.styleable<{
536
539
  minWidth={0}
537
540
  elevation={undefined}
538
541
  {...props}
542
+ {...controlFragment}
539
543
  {...passthroughSizeProps}
540
544
  {...insetPadProps}
541
545
  {...sizeRecipeEscapeOverride}
@@ -37,7 +37,9 @@ import {
37
37
  import type { LabelProps, SizeTokens } from "tamagui";
38
38
  import { Pressable as RNPressable } from "react-native";
39
39
  import { Input as TInput, Text, View, XStack, YStack, isWeb, styled, useTheme } from "tamagui";
40
+ import { Button } from "../../Button";
40
41
  import { Chip, type ChipProps } from "../../Chip";
42
+ import { pickerTriggerContract } from "../../shared/pickerTriggerContract";
41
43
  import { Skeleton } from "../../Skeleton";
42
44
  import { Field, FieldLayout, useFieldA11y, useFieldDescribedBy } from "../../fieldLayout";
43
45
  import { Input as InputParts } from "../../InputParts";
@@ -367,7 +369,7 @@ const ComboboxItem = memo(function ComboboxItem({
367
369
  active={isActive}
368
370
  aria-selected={isSelected}
369
371
  data-state={isSelected ? "active" : "inactive"}
370
- size={ctx.knobProps.sizeToken}
372
+ height={ctx.knobProps.control.height}
371
373
  {...listItemRingProps(Boolean(keyboardRing))}
372
374
  {...(suppressHover
373
375
  ? {
@@ -1373,17 +1375,13 @@ function FloatingComboboxCore({
1373
1375
  // attaches below instead (MPO-38), leaving the trigger on screen and
1374
1376
  // focusable, so it keeps its ring.
1375
1377
  suppressFocusRing={open && !multiple}
1378
+ {...pickerTriggerContract(knobProps.control.height)}
1376
1379
  {...(inTableCell
1377
- ? // Chromeless in cells, but keep a ≥44px pressable target (SP-FIXED).
1378
- { minHeight: 44 }
1380
+ ? // Chromeless in cells.
1381
+ {}
1379
1382
  : {
1380
1383
  borderRadius: knobProps.borderRadius.borderRadius,
1381
1384
  borderWidth: knobProps.borderRadius.borderWidth,
1382
- // LC-54 PRESS-FLOOR: compact/small size tokens render a sub-44
1383
- // trigger (the 36px "Allocated To" quick filter breaking the
1384
- // list's 44px control row) — same floor the house Select holds
1385
- // on both its trigger variants.
1386
- minHeight: 44,
1387
1385
  })}
1388
1386
  >
1389
1387
  <SyncFocusToOpen open={open} />
@@ -1420,6 +1418,7 @@ function FloatingComboboxCore({
1420
1418
  </View>
1421
1419
  ) : (
1422
1420
  <Text
1421
+ {...knobProps.controlType}
1423
1422
  fontFamily={knobProps.body.fontFamily}
1424
1423
  fontWeight={knobProps.body.fontWeight}
1425
1424
  color={
@@ -1435,11 +1434,13 @@ function FloatingComboboxCore({
1435
1434
  )}
1436
1435
  </View>
1437
1436
  {clearable && !multiple && !disabled && typeof value === "string" && value !== "" && (
1438
- <View
1439
- paddingInlineEnd="$1"
1437
+ <Button
1438
+ size={knobProps.control.height}
1439
+ chromeless
1440
+ {...pickerTriggerContract(knobProps.control.height)}
1441
+ icon={<XIcon size={14} />}
1440
1442
  alignSelf="center"
1441
1443
  cursor="pointer"
1442
- role="button"
1443
1444
  tabIndex={-1}
1444
1445
  aria-label={t("Clear")}
1445
1446
  opacity={0.6}
@@ -1454,9 +1455,7 @@ function FloatingComboboxCore({
1454
1455
  e.stopPropagation?.();
1455
1456
  onValueChange?.("");
1456
1457
  }}
1457
- >
1458
- <XIcon size={14} />
1459
- </View>
1458
+ />
1460
1459
  )}
1461
1460
  <View paddingInlineEnd="$2" alignSelf="center">
1462
1461
  <CaretDownIcon size={16} color={glyphColor} />
@@ -1607,7 +1606,7 @@ function FloatingComboboxCore({
1607
1606
  id={comboboxOptionId(id, createOptionIndex)}
1608
1607
  selected={false}
1609
1608
  active={createOptionIndex === activeIndex}
1610
- size={knobProps.sizeToken}
1609
+ height={knobProps.control.height}
1611
1610
  data-combobox-index={createOptionIndex}
1612
1611
  data-active={createOptionIndex === activeIndex}
1613
1612
  {...listItemRingProps(createOptionIndex === activeIndex && listKeyboardRing)}
@@ -1649,7 +1648,7 @@ function FloatingComboboxCore({
1649
1648
  id={comboboxOptionId(id, createOptionIndex)}
1650
1649
  selected={false}
1651
1650
  active={createOptionIndex === activeIndex}
1652
- size={knobProps.sizeToken}
1651
+ height={knobProps.control.height}
1653
1652
  data-combobox-index={createOptionIndex}
1654
1653
  data-active={createOptionIndex === activeIndex}
1655
1654
  {...listItemRingProps(createOptionIndex === activeIndex && listKeyboardRing)}
@@ -1736,7 +1735,7 @@ const comboboxSearchHeader = (
1736
1735
  alignItems="center"
1737
1736
  gap="$2"
1738
1737
  paddingHorizontal="$3"
1739
- height={knobProps.sizeToken}
1738
+ height={knobProps.control.height}
1740
1739
  borderBottomWidth={1}
1741
1740
  borderBottomColor={formCommonColors.divider}
1742
1741
  >
@@ -2169,8 +2168,8 @@ function NativeComboboxCore({
2169
2168
  data-active={active}
2170
2169
  {...listItemRingProps(active && listKeyboardRing)}
2171
2170
  {...(option.description
2172
- ? { minHeight: knobProps.sizeToken, paddingVertical: "$2" }
2173
- : { height: knobProps.sizeToken })}
2171
+ ? { minHeight: knobProps.control.height, paddingVertical: "$2" }
2172
+ : { height: knobProps.control.height })}
2174
2173
  onPress={isWeb ? () => handleSelect(option) : undefined}
2175
2174
  onMouseEnter={() => {
2176
2175
  setKeyboardNav(false);
@@ -2262,7 +2261,7 @@ function NativeComboboxCore({
2262
2261
  alignItems="center"
2263
2262
  gap="$2"
2264
2263
  paddingHorizontal="$3"
2265
- height={knobProps.sizeToken}
2264
+ height={knobProps.control.height}
2266
2265
  role="option"
2267
2266
  id={comboboxOptionId(id, createOptionIndex)}
2268
2267
  data-combobox-index={createOptionIndex}
@@ -2307,7 +2306,7 @@ function NativeComboboxCore({
2307
2306
  alignItems="center"
2308
2307
  gap="$2"
2309
2308
  paddingHorizontal="$3"
2310
- height={knobProps.sizeToken}
2309
+ height={knobProps.control.height}
2311
2310
  role="option"
2312
2311
  id={comboboxOptionId(id, createOptionIndex)}
2313
2312
  data-combobox-index={createOptionIndex}
@@ -2395,9 +2394,10 @@ function NativeComboboxCore({
2395
2394
  // paint — it would ghost around the panel's corners. A multi-select
2396
2395
  // panel attaches below instead (MPO-38), so it keeps its ring.
2397
2396
  suppressFocusRing={open && !multiple}
2397
+ {...pickerTriggerContract(knobProps.control.height)}
2398
2398
  {...(inTableCell
2399
- ? // Chromeless in cells, but keep a ≥44px pressable target (SP-FIXED).
2400
- { minHeight: 44 }
2399
+ ? // Chromeless in cells.
2400
+ {}
2401
2401
  : {
2402
2402
  borderRadius: knobProps.borderRadius.borderRadius,
2403
2403
  borderWidth: knobProps.borderRadius.borderWidth,
@@ -2424,9 +2424,9 @@ function NativeComboboxCore({
2424
2424
  tabIndex: disabled ? -1 : 0,
2425
2425
  }
2426
2426
  : { importantForAccessibility: "no" as const })}
2427
- // Web: trigger owns toggle. Native: FloatingPanel.native owns open
2428
- // (avoid double-toggle that batches open then immediately closed).
2429
- onPress={isWeb && !disabled ? () => handleOpenChange(!open) : undefined}
2427
+ // Sheet capture opens first on web, so this opener is idempotent.
2428
+ // Native delegates the gesture to FloatingPanel.
2429
+ onPress={isWeb && !disabled ? () => handleOpenChange(true) : undefined}
2430
2430
  onKeyDown={(e: React.KeyboardEvent) => {
2431
2431
  if (
2432
2432
  ["Enter", " ", "ArrowDown", "ArrowUp", "j", "k"].includes(e.key) ||
@@ -2471,6 +2471,7 @@ function NativeComboboxCore({
2471
2471
  </View>
2472
2472
  ) : (
2473
2473
  <Text
2474
+ {...knobProps.controlType}
2474
2475
  fontFamily={knobProps.body.fontFamily}
2475
2476
  fontWeight={knobProps.body.fontWeight}
2476
2477
  color={
@@ -2484,24 +2485,6 @@ function NativeComboboxCore({
2484
2485
  </Text>
2485
2486
  )}
2486
2487
  </View>
2487
- {clearable && !multiple && !disabled && typeof value === "string" && value !== "" && (
2488
- <View
2489
- paddingInlineEnd="$1"
2490
- alignSelf="center"
2491
- cursor="pointer"
2492
- role="button"
2493
- tabIndex={-1}
2494
- aria-label={t("Clear")}
2495
- opacity={0.6}
2496
- hoverStyle={{ opacity: 1 }}
2497
- onPress={(e: any) => {
2498
- e.stopPropagation?.();
2499
- onValueChange?.("");
2500
- }}
2501
- >
2502
- <XIcon size={14} />
2503
- </View>
2504
- )}
2505
2488
  <View paddingInlineEnd="$2" alignSelf="center">
2506
2489
  <CaretDownIcon size={16} color={glyphColor} />
2507
2490
  </View>
@@ -2523,6 +2506,19 @@ function NativeComboboxCore({
2523
2506
  // the user types, so a content-fit sheet would open as a sliver.
2524
2507
  sheetFill={Boolean(onSearch)}
2525
2508
  trigger={triggerContent}
2509
+ triggerEnd={
2510
+ clearable && !multiple && !disabled && typeof value === "string" && value !== "" ? (
2511
+ <Button
2512
+ size={knobProps.sizeToken}
2513
+ chromeless
2514
+ minWidth={44}
2515
+ minHeight={44}
2516
+ aria-label={t("Clear")}
2517
+ icon={<XIcon size={14} />}
2518
+ onPress={() => onValueChange?.("")}
2519
+ />
2520
+ ) : undefined
2521
+ }
2526
2522
  triggerA11y={{
2527
2523
  label: ariaLabel ?? fieldA11y?.label,
2528
2524
  value: triggerValueText,