@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.
- package/dist/cjs/Button/index.cjs +3 -33
- package/dist/cjs/Button/index.native.js +3 -33
- package/dist/cjs/Button/index.native.js.map +1 -1
- package/dist/cjs/ContextualSaveBar/index.cjs +17 -10
- package/dist/cjs/ContextualSaveBar/index.native.js +18 -10
- package/dist/cjs/ContextualSaveBar/index.native.js.map +1 -1
- package/dist/cjs/FloatingPanel/index.native.js +51 -28
- package/dist/cjs/FloatingPanel/index.native.js.map +1 -1
- package/dist/cjs/InputParts/index.cjs +3 -1
- package/dist/cjs/InputParts/index.native.js +3 -1
- package/dist/cjs/InputParts/index.native.js.map +1 -1
- package/dist/cjs/fields/Combobox/index.cjs +39 -48
- package/dist/cjs/fields/Combobox/index.native.js +43 -51
- package/dist/cjs/fields/Combobox/index.native.js.map +1 -1
- package/dist/cjs/fields/Select/index.cjs +11 -7
- package/dist/cjs/fields/Select/index.native.js +13 -19
- package/dist/cjs/fields/Select/index.native.js.map +1 -1
- package/dist/cjs/shared/pickerTriggerContract.cjs +38 -0
- package/dist/cjs/shared/pickerTriggerContract.native.js +41 -0
- package/dist/cjs/shared/pickerTriggerContract.native.js.map +1 -0
- package/dist/cjs/shared/pressSlopProps.cjs +68 -0
- package/dist/cjs/shared/pressSlopProps.native.js +72 -0
- package/dist/cjs/shared/pressSlopProps.native.js.map +1 -0
- package/dist/esm/Button/index.mjs +1 -31
- package/dist/esm/Button/index.mjs.map +1 -1
- package/dist/esm/Button/index.native.js +1 -31
- package/dist/esm/Button/index.native.js.map +1 -1
- package/dist/esm/ContextualSaveBar/index.mjs +17 -10
- package/dist/esm/ContextualSaveBar/index.mjs.map +1 -1
- package/dist/esm/ContextualSaveBar/index.native.js +18 -10
- package/dist/esm/ContextualSaveBar/index.native.js.map +1 -1
- package/dist/esm/FloatingPanel/index.native.js +51 -28
- package/dist/esm/FloatingPanel/index.native.js.map +1 -1
- package/dist/esm/InputParts/index.mjs +3 -1
- package/dist/esm/InputParts/index.mjs.map +1 -1
- package/dist/esm/InputParts/index.native.js +3 -1
- package/dist/esm/InputParts/index.native.js.map +1 -1
- package/dist/esm/fields/Combobox/index.mjs +39 -48
- package/dist/esm/fields/Combobox/index.mjs.map +1 -1
- package/dist/esm/fields/Combobox/index.native.js +43 -51
- package/dist/esm/fields/Combobox/index.native.js.map +1 -1
- package/dist/esm/fields/Select/index.mjs +12 -8
- package/dist/esm/fields/Select/index.mjs.map +1 -1
- package/dist/esm/fields/Select/index.native.js +14 -20
- package/dist/esm/fields/Select/index.native.js.map +1 -1
- package/dist/esm/shared/pickerTriggerContract.mjs +13 -0
- package/dist/esm/shared/pickerTriggerContract.mjs.map +1 -0
- package/dist/esm/shared/pickerTriggerContract.native.js +13 -0
- package/dist/esm/shared/pickerTriggerContract.native.js.map +1 -0
- package/dist/esm/shared/pressSlopProps.mjs +43 -0
- package/dist/esm/shared/pressSlopProps.mjs.map +1 -0
- package/dist/esm/shared/pressSlopProps.native.js +44 -0
- package/dist/esm/shared/pressSlopProps.native.js.map +1 -0
- package/dist/jsx/Button/index.mjs +1 -31
- package/dist/jsx/Button/index.mjs.map +1 -1
- package/dist/jsx/Button/index.native.js +3 -33
- package/dist/jsx/Button/index.native.js.map +1 -1
- package/dist/jsx/ContextualSaveBar/index.mjs +17 -10
- package/dist/jsx/ContextualSaveBar/index.mjs.map +1 -1
- package/dist/jsx/ContextualSaveBar/index.native.js +18 -10
- package/dist/jsx/ContextualSaveBar/index.native.js.map +1 -1
- package/dist/jsx/FloatingPanel/index.native.js +51 -28
- package/dist/jsx/FloatingPanel/index.native.js.map +1 -1
- package/dist/jsx/InputParts/index.mjs +3 -1
- package/dist/jsx/InputParts/index.mjs.map +1 -1
- package/dist/jsx/InputParts/index.native.js +3 -1
- package/dist/jsx/InputParts/index.native.js.map +1 -1
- package/dist/jsx/fields/Combobox/index.mjs +39 -48
- package/dist/jsx/fields/Combobox/index.mjs.map +1 -1
- package/dist/jsx/fields/Combobox/index.native.js +43 -51
- package/dist/jsx/fields/Combobox/index.native.js.map +1 -1
- package/dist/jsx/fields/Select/index.mjs +12 -8
- package/dist/jsx/fields/Select/index.mjs.map +1 -1
- package/dist/jsx/fields/Select/index.native.js +13 -19
- package/dist/jsx/fields/Select/index.native.js.map +1 -1
- package/dist/jsx/shared/pickerTriggerContract.mjs +13 -0
- package/dist/jsx/shared/pickerTriggerContract.mjs.map +1 -0
- package/dist/jsx/shared/pickerTriggerContract.native.js +41 -0
- package/dist/jsx/shared/pickerTriggerContract.native.js.map +1 -0
- package/dist/jsx/shared/pressSlopProps.mjs +43 -0
- package/dist/jsx/shared/pressSlopProps.mjs.map +1 -0
- package/dist/jsx/shared/pressSlopProps.native.js +72 -0
- package/dist/jsx/shared/pressSlopProps.native.js.map +1 -0
- package/package.json +7 -7
- package/src/Button/index.tsx +1 -35
- package/src/ContextualSaveBar/ContextualSaveBar.spec.tsx +33 -0
- package/src/ContextualSaveBar/ContextualSaveBar.stories.tsx +19 -0
- package/src/ContextualSaveBar/index.tsx +12 -8
- package/src/ContextualSaveBar/nativeFlow.spec.tsx +20 -0
- package/src/FloatingPanel/FloatingPanel.native.spec.tsx +138 -0
- package/src/FloatingPanel/index.native.tsx +76 -31
- package/src/InputParts/index.tsx +5 -1
- package/src/fields/Combobox/index.tsx +40 -44
- package/src/fields/Select/index.tsx +13 -18
- package/src/fields/pickerActivation.spec.tsx +105 -0
- package/src/fields/pickerDensity.spec.tsx +86 -0
- package/src/fields/pickerRows.spec.tsx +66 -0
- package/src/shared/pickerTriggerContract.ts +13 -0
- package/src/shared/pressSlopProps.spec.ts +39 -0
- package/src/shared/pressSlopProps.ts +52 -0
- package/types/Button/index.d.ts.map +1 -1
- package/types/ContextualSaveBar/index.d.ts +1 -1
- package/types/ContextualSaveBar/index.d.ts.map +1 -1
- package/types/FloatingPanel/index.native.d.ts +16 -1
- package/types/FloatingPanel/index.native.d.ts.map +1 -1
- package/types/InputParts/index.d.ts.map +1 -1
- package/types/fields/Combobox/index.d.ts.map +1 -1
- package/types/fields/Select/index.d.ts.map +1 -1
- package/types/shared/pickerTriggerContract.d.ts +18 -0
- package/types/shared/pickerTriggerContract.d.ts.map +1 -0
- package/types/shared/pressSlopProps.d.ts +8 -0
- 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
|
|
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
|
-
|
|
240
|
-
|
|
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
|
-
|
|
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
|
-
|
|
277
|
-
|
|
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
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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(() =>
|
|
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:
|
|
307
|
+
duration: enterDuration,
|
|
292
308
|
easing: Easing.out(Easing.cubic),
|
|
293
|
-
useNativeDriver:
|
|
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={
|
|
394
|
+
onShow={enter}
|
|
350
395
|
>
|
|
351
396
|
{/*
|
|
352
397
|
A plain padded column, not KeyboardAvoidingView: inside a Modal the
|
package/src/InputParts/index.tsx
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
1378
|
-
{
|
|
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
|
-
<
|
|
1439
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
2173
|
-
: { height: knobProps.
|
|
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.
|
|
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.
|
|
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
|
|
2400
|
-
{
|
|
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
|
-
//
|
|
2428
|
-
//
|
|
2429
|
-
onPress={isWeb && !disabled ? () => handleOpenChange(
|
|
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,
|