@multiplatform.one/forms 7.7.4 → 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/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/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/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/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/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
|
+
});
|
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,
|
|
@@ -12,7 +12,6 @@ import {
|
|
|
12
12
|
} from "@floating-ui/react";
|
|
13
13
|
import type { AnyFormApi } from "../../types";
|
|
14
14
|
import {
|
|
15
|
-
MIN_PRESS_TARGET,
|
|
16
15
|
ensureKeyboardModalityTracking,
|
|
17
16
|
keyboardFocusRingProps,
|
|
18
17
|
menuRowFrame,
|
|
@@ -31,6 +30,7 @@ import * as ReactNative from "react-native";
|
|
|
31
30
|
import { Platform, Pressable as RNPressable } from "react-native";
|
|
32
31
|
import { Paragraph, Text, View, YStack, isWeb, styled, useTheme } from "tamagui";
|
|
33
32
|
import { Chip, type ChipProps } from "../../Chip";
|
|
33
|
+
import { pickerTriggerContract } from "../../shared/pickerTriggerContract";
|
|
34
34
|
import { Skeleton } from "../../Skeleton";
|
|
35
35
|
import { Field, FieldLayout, useFieldA11y, useFieldDescribedBy } from "../../fieldLayout";
|
|
36
36
|
import { Input as InputParts, FocusContext } from "../../InputParts";
|
|
@@ -421,7 +421,7 @@ const SelectItem = memo(function SelectItem({
|
|
|
421
421
|
active={isActive}
|
|
422
422
|
aria-selected={isSelected}
|
|
423
423
|
data-state={isSelected ? "active" : "inactive"}
|
|
424
|
-
|
|
424
|
+
height={ctx.knobProps.control.height}
|
|
425
425
|
// LC-18 KNOB-TOTALITY (UX-021): menu rows re-rhythm with the space knob —
|
|
426
426
|
// horizontal inset from the panelPadding token (SP-EDGE, sidebar-row
|
|
427
427
|
// precedent) over menuRowFrame's static $3; in-row gap rides the gap
|
|
@@ -1005,13 +1005,9 @@ function FloatingSelectCore({
|
|
|
1005
1005
|
// must not paint — it would ghost around the panel's corners.
|
|
1006
1006
|
suppressFocusRing={open}
|
|
1007
1007
|
size={knobProps.sizeToken}
|
|
1008
|
-
|
|
1009
|
-
// = 36px) but the trigger's press target never drops below 44 —
|
|
1010
|
-
// same minHeight floor the house Button holds. Without it a compact
|
|
1011
|
-
// Select sat 36px beside 44px Inputs/Buttons in one control row.
|
|
1012
|
-
minHeight={MIN_PRESS_TARGET}
|
|
1008
|
+
{...pickerTriggerContract(knobProps.control.height)}
|
|
1013
1009
|
{...(inTableCell
|
|
1014
|
-
? // Chromeless in cells
|
|
1010
|
+
? // Chromeless in cells.
|
|
1015
1011
|
{}
|
|
1016
1012
|
: {
|
|
1017
1013
|
borderRadius: knobProps.borderRadius.borderRadius,
|
|
@@ -1054,6 +1050,7 @@ function FloatingSelectCore({
|
|
|
1054
1050
|
</View>
|
|
1055
1051
|
) : (
|
|
1056
1052
|
<Text
|
|
1053
|
+
{...knobProps.controlType}
|
|
1057
1054
|
fontFamily={knobProps.body.fontFamily}
|
|
1058
1055
|
fontWeight={knobProps.body.fontWeight}
|
|
1059
1056
|
color={selectedOption ? textColor : formCommonColors.muted}
|
|
@@ -1370,7 +1367,7 @@ function NativeSelectCore({
|
|
|
1370
1367
|
// menu rows — panelPadding inset + gap fragment.
|
|
1371
1368
|
{...knobProps.gap}
|
|
1372
1369
|
paddingHorizontal={knobProps.panelPadding.padding}
|
|
1373
|
-
height={knobProps.
|
|
1370
|
+
height={knobProps.control.height}
|
|
1374
1371
|
onPress={isWeb ? () => handleSelect(option.value) : undefined}
|
|
1375
1372
|
onMouseEnter={() => setActiveIndex(i)}
|
|
1376
1373
|
onPointerDown={isWeb ? () => setKbNav(false) : undefined}
|
|
@@ -1467,7 +1464,7 @@ function NativeSelectCore({
|
|
|
1467
1464
|
<InputParts.Box
|
|
1468
1465
|
disabled={disabled}
|
|
1469
1466
|
size={knobProps.sizeToken}
|
|
1470
|
-
|
|
1467
|
+
{...pickerTriggerContract(knobProps.control.height)}
|
|
1471
1468
|
pointerEvents="none"
|
|
1472
1469
|
{...(inTableCell
|
|
1473
1470
|
? {}
|
|
@@ -1486,6 +1483,7 @@ function NativeSelectCore({
|
|
|
1486
1483
|
alignItems="flex-start"
|
|
1487
1484
|
>
|
|
1488
1485
|
<Text
|
|
1486
|
+
{...knobProps.controlType}
|
|
1489
1487
|
fontFamily={knobProps.body.fontFamily}
|
|
1490
1488
|
fontWeight={knobProps.body.fontWeight}
|
|
1491
1489
|
color={selectedOption ? formCommonColors.text : formCommonColors.muted}
|
|
@@ -1519,9 +1517,7 @@ function NativeSelectCore({
|
|
|
1519
1517
|
<InputParts.Box
|
|
1520
1518
|
disabled={disabled}
|
|
1521
1519
|
size={knobProps.sizeToken}
|
|
1522
|
-
|
|
1523
|
-
// the web trigger branch above).
|
|
1524
|
-
minHeight={MIN_PRESS_TARGET}
|
|
1520
|
+
{...pickerTriggerContract(knobProps.control.height)}
|
|
1525
1521
|
{...(inTableCell
|
|
1526
1522
|
? {}
|
|
1527
1523
|
: {
|
|
@@ -1551,11 +1547,9 @@ function NativeSelectCore({
|
|
|
1551
1547
|
tabIndex: disabled ? -1 : 0,
|
|
1552
1548
|
}
|
|
1553
1549
|
: { importantForAccessibility: "no" as const })}
|
|
1554
|
-
//
|
|
1555
|
-
//
|
|
1556
|
-
|
|
1557
|
-
// appears (batched true then !prev → false).
|
|
1558
|
-
onPress={isWeb && !disabled ? () => setOpen((prev) => !prev) : undefined}
|
|
1550
|
+
// Sheet capture opens first on web. Keep this opener idempotent;
|
|
1551
|
+
// native delegates the gesture to FloatingPanel's Pressable.
|
|
1552
|
+
onPress={isWeb && !disabled ? () => setOpen(true) : undefined}
|
|
1559
1553
|
onFocus={isWeb ? onTriggerFocus : undefined}
|
|
1560
1554
|
onBlur={isWeb ? onTriggerBlur : undefined}
|
|
1561
1555
|
onPointerDown={isWeb ? onTriggerPointerDown : undefined}
|
|
@@ -1606,6 +1600,7 @@ function NativeSelectCore({
|
|
|
1606
1600
|
</View>
|
|
1607
1601
|
) : (
|
|
1608
1602
|
<Text
|
|
1603
|
+
{...knobProps.controlType}
|
|
1609
1604
|
fontFamily={knobProps.body.fontFamily}
|
|
1610
1605
|
fontWeight={knobProps.body.fontWeight}
|
|
1611
1606
|
color={selectedOption ? textColor : formCommonColors.muted}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { fireEvent, waitFor } from "@testing-library/react";
|
|
3
|
+
import { renderWithProviders } from "@multiplatform.one/test-utils";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { Select } from "./Select";
|
|
6
|
+
import { Combobox } from "./Combobox";
|
|
7
|
+
|
|
8
|
+
vi.mock("@multiplatform.one/theme", async (importOriginal) => {
|
|
9
|
+
const actual = await importOriginal<typeof import("@multiplatform.one/theme")>();
|
|
10
|
+
return {
|
|
11
|
+
...actual,
|
|
12
|
+
useResolvedKnobs: (...args: Parameters<typeof actual.useResolvedKnobs>) => {
|
|
13
|
+
const resolved = actual.useResolvedKnobs(...args);
|
|
14
|
+
return {
|
|
15
|
+
...resolved,
|
|
16
|
+
knobProps: {
|
|
17
|
+
...resolved.knobProps,
|
|
18
|
+
control: { ...resolved.knobProps.control, height: 37 },
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const options = [
|
|
26
|
+
{ value: "alpha", label: "Alpha" },
|
|
27
|
+
{ value: "beta", label: "Beta" },
|
|
28
|
+
];
|
|
29
|
+
const originalWidth = window.innerWidth;
|
|
30
|
+
beforeEach(() => Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 }));
|
|
31
|
+
afterEach(() =>
|
|
32
|
+
Object.defineProperty(window, "innerWidth", { configurable: true, value: originalWidth }),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
function ClearHarness() {
|
|
36
|
+
const [value, setValue] = useState("alpha");
|
|
37
|
+
return (
|
|
38
|
+
<>
|
|
39
|
+
<Combobox
|
|
40
|
+
aria-label="Choice"
|
|
41
|
+
options={options}
|
|
42
|
+
value={value}
|
|
43
|
+
onValueChange={(next) => {
|
|
44
|
+
if (typeof next === "string") setValue(next);
|
|
45
|
+
}}
|
|
46
|
+
clearable
|
|
47
|
+
/>
|
|
48
|
+
<span>Selected:{value || "none"}</span>
|
|
49
|
+
</>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("sheet picker pointer activation", () => {
|
|
54
|
+
for (const [name, Picker] of [
|
|
55
|
+
["Select", Select],
|
|
56
|
+
["Combobox", Combobox],
|
|
57
|
+
] as const) {
|
|
58
|
+
it(`${name} opens once when the real trigger is clicked`, async () => {
|
|
59
|
+
const result = renderWithProviders(<Picker aria-label="Choice" options={options} />);
|
|
60
|
+
const trigger = result.getByRole("combobox", { name: "Choice" });
|
|
61
|
+
fireEvent.click(trigger, { detail: 1 });
|
|
62
|
+
await waitFor(() => expect(trigger.getAttribute("aria-expanded")).toBe("true"));
|
|
63
|
+
});
|
|
64
|
+
it(`${name} keeps keyboard opening and ignores disabled clicks`, () => {
|
|
65
|
+
const result = renderWithProviders(<Picker aria-label="Choice" options={options} />);
|
|
66
|
+
fireEvent.keyDown(result.getByRole("combobox"), { key: " " });
|
|
67
|
+
expect(result.getByRole("combobox").getAttribute("aria-expanded")).toBe("true");
|
|
68
|
+
result.unmount();
|
|
69
|
+
const disabled = renderWithProviders(
|
|
70
|
+
<Picker aria-label="Choice" options={options} disabled />,
|
|
71
|
+
);
|
|
72
|
+
fireEvent.click(disabled.getByRole("combobox"), { detail: 1 });
|
|
73
|
+
expect(disabled.getByRole("combobox").getAttribute("aria-expanded")).toBe("false");
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
it("clear changes the value without opening the sheet", async () => {
|
|
77
|
+
const result = renderWithProviders(<ClearHarness />);
|
|
78
|
+
const clear = result.getByRole("button", { name: "Clear" });
|
|
79
|
+
expect(clear.className).toContain("_miw-44px");
|
|
80
|
+
expect(clear.className).toContain("_mih-44px");
|
|
81
|
+
fireEvent.click(clear, { detail: 1 });
|
|
82
|
+
await waitFor(() => expect(result.getByText("Selected:none")).toBeTruthy());
|
|
83
|
+
expect(result.getByRole("combobox", { name: "Choice" }).getAttribute("aria-expanded")).toBe(
|
|
84
|
+
"false",
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("desktop clear retains compact geometry and never opens on pointer down or click", async () => {
|
|
90
|
+
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1200 });
|
|
91
|
+
const result = renderWithProviders(<ClearHarness />);
|
|
92
|
+
const trigger = result.getByRole("combobox", { name: "Choice" });
|
|
93
|
+
const clear = result.getByRole("button", { name: "Clear" });
|
|
94
|
+
expect(trigger.className).toContain("_h-37px");
|
|
95
|
+
expect(clear.className).toContain("_h-37px");
|
|
96
|
+
expect(clear.className).toContain("_miw-44px");
|
|
97
|
+
expect(clear.getAttribute("data-mp-press-slop")).toBe("4");
|
|
98
|
+
expect(clear.getAttribute("data-mp-press-axis")).toBe("vertical");
|
|
99
|
+
fireEvent.mouseDown(clear, { detail: 1 });
|
|
100
|
+
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
|
101
|
+
fireEvent.mouseUp(clear, { detail: 1 });
|
|
102
|
+
fireEvent.click(clear, { detail: 1 });
|
|
103
|
+
await waitFor(() => expect(result.getByText("Selected:none")).toBeTruthy());
|
|
104
|
+
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
|
105
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { renderWithProviders } from "@multiplatform.one/test-utils";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { sizeRecipeForToken } from "@multiplatform.one/theme";
|
|
4
|
+
import { Select } from "./Select";
|
|
5
|
+
import { Combobox } from "./Combobox";
|
|
6
|
+
|
|
7
|
+
const mode = vi.hoisted(() => ({ height: 37, fontSize: 14, table: false }));
|
|
8
|
+
vi.mock("../shared/tableCellContext", async (importOriginal) => ({
|
|
9
|
+
...(await importOriginal<typeof import("../shared/tableCellContext")>()),
|
|
10
|
+
useIsInTableCell: () => mode.table,
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
vi.mock("@multiplatform.one/theme", async (importOriginal) => {
|
|
14
|
+
const actual = await importOriginal<typeof import("@multiplatform.one/theme")>();
|
|
15
|
+
return {
|
|
16
|
+
...actual,
|
|
17
|
+
useResolvedKnobs: (...args: Parameters<typeof actual.useResolvedKnobs>) => {
|
|
18
|
+
const resolved = actual.useResolvedKnobs(...args);
|
|
19
|
+
return {
|
|
20
|
+
...resolved,
|
|
21
|
+
knobProps: {
|
|
22
|
+
...resolved.knobProps,
|
|
23
|
+
control: { ...resolved.knobProps.control, height: mode.height },
|
|
24
|
+
controlType: { ...resolved.knobProps.controlType, fontSize: mode.fontSize },
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("picker desktop recipe sizing", () => {
|
|
32
|
+
for (const [name, Picker] of [
|
|
33
|
+
["Select", Select],
|
|
34
|
+
["Combobox", Combobox],
|
|
35
|
+
] as const) {
|
|
36
|
+
for (const table of [false, true]) {
|
|
37
|
+
it(`${name} table=${table} honors the resolved height and label font without a desktop floor`, () => {
|
|
38
|
+
mode.table = table;
|
|
39
|
+
mode.height = 37;
|
|
40
|
+
mode.fontSize = 14;
|
|
41
|
+
const result = renderWithProviders(
|
|
42
|
+
<Picker
|
|
43
|
+
options={[
|
|
44
|
+
{ value: "one", label: "First choice" },
|
|
45
|
+
{ value: "two", label: "Second choice" },
|
|
46
|
+
]}
|
|
47
|
+
value="one"
|
|
48
|
+
/>,
|
|
49
|
+
);
|
|
50
|
+
const trigger = result.container.querySelector('[data-testid$="-trigger"]')!;
|
|
51
|
+
expect(trigger).toBeTruthy();
|
|
52
|
+
expect(trigger.className).not.toContain("_mih-44px");
|
|
53
|
+
expect(trigger.className).toContain("_h-37px");
|
|
54
|
+
expect(trigger.getAttribute("data-visual-height")).toBe("37");
|
|
55
|
+
expect(trigger.getAttribute("data-mp-press-slop")).toBe("4");
|
|
56
|
+
expect(trigger.getAttribute("data-mp-press-axis")).toBe("vertical");
|
|
57
|
+
expect(trigger.className).toContain("_miw-44px");
|
|
58
|
+
expect(trigger.className).toContain("_oy-visible");
|
|
59
|
+
const targetCss = document.getElementById("mp-button-press-slop")?.textContent;
|
|
60
|
+
expect(targetCss).toContain("isolation:isolate");
|
|
61
|
+
expect(targetCss).toContain("z-index:-1");
|
|
62
|
+
const label = result.getByText("First choice");
|
|
63
|
+
expect(label.className).toContain("_fos-14px");
|
|
64
|
+
});
|
|
65
|
+
it(`${name} table=${table} retains the shared touch recipe floor`, () => {
|
|
66
|
+
const recipe = sizeRecipeForToken("$3", { touch: true });
|
|
67
|
+
mode.height = recipe.height;
|
|
68
|
+
mode.fontSize = recipe.fontSize;
|
|
69
|
+
mode.table = table;
|
|
70
|
+
expect(recipe.height).toBeGreaterThanOrEqual(44);
|
|
71
|
+
const result = renderWithProviders(
|
|
72
|
+
<Picker
|
|
73
|
+
options={[
|
|
74
|
+
{ value: "one", label: "First choice" },
|
|
75
|
+
{ value: "two", label: "Second choice" },
|
|
76
|
+
]}
|
|
77
|
+
value="one"
|
|
78
|
+
/>,
|
|
79
|
+
);
|
|
80
|
+
const trigger = result.container.querySelector('[data-testid$="-trigger"]')!;
|
|
81
|
+
expect(trigger.className).toContain(`_h-${recipe.height}px`);
|
|
82
|
+
expect(trigger.getAttribute("data-press-floor")).toBe("box");
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
});
|