@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.
Files changed (102) 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/InputParts/index.cjs +3 -1
  8. package/dist/cjs/InputParts/index.native.js +3 -1
  9. package/dist/cjs/InputParts/index.native.js.map +1 -1
  10. package/dist/cjs/fields/Combobox/index.cjs +39 -48
  11. package/dist/cjs/fields/Combobox/index.native.js +43 -51
  12. package/dist/cjs/fields/Combobox/index.native.js.map +1 -1
  13. package/dist/cjs/fields/Select/index.cjs +11 -7
  14. package/dist/cjs/fields/Select/index.native.js +13 -19
  15. package/dist/cjs/fields/Select/index.native.js.map +1 -1
  16. package/dist/cjs/shared/pickerTriggerContract.cjs +38 -0
  17. package/dist/cjs/shared/pickerTriggerContract.native.js +41 -0
  18. package/dist/cjs/shared/pickerTriggerContract.native.js.map +1 -0
  19. package/dist/cjs/shared/pressSlopProps.cjs +68 -0
  20. package/dist/cjs/shared/pressSlopProps.native.js +72 -0
  21. package/dist/cjs/shared/pressSlopProps.native.js.map +1 -0
  22. package/dist/esm/Button/index.mjs +1 -31
  23. package/dist/esm/Button/index.mjs.map +1 -1
  24. package/dist/esm/Button/index.native.js +1 -31
  25. package/dist/esm/Button/index.native.js.map +1 -1
  26. package/dist/esm/ContextualSaveBar/index.mjs +17 -10
  27. package/dist/esm/ContextualSaveBar/index.mjs.map +1 -1
  28. package/dist/esm/ContextualSaveBar/index.native.js +18 -10
  29. package/dist/esm/ContextualSaveBar/index.native.js.map +1 -1
  30. package/dist/esm/InputParts/index.mjs +3 -1
  31. package/dist/esm/InputParts/index.mjs.map +1 -1
  32. package/dist/esm/InputParts/index.native.js +3 -1
  33. package/dist/esm/InputParts/index.native.js.map +1 -1
  34. package/dist/esm/fields/Combobox/index.mjs +39 -48
  35. package/dist/esm/fields/Combobox/index.mjs.map +1 -1
  36. package/dist/esm/fields/Combobox/index.native.js +43 -51
  37. package/dist/esm/fields/Combobox/index.native.js.map +1 -1
  38. package/dist/esm/fields/Select/index.mjs +12 -8
  39. package/dist/esm/fields/Select/index.mjs.map +1 -1
  40. package/dist/esm/fields/Select/index.native.js +14 -20
  41. package/dist/esm/fields/Select/index.native.js.map +1 -1
  42. package/dist/esm/shared/pickerTriggerContract.mjs +13 -0
  43. package/dist/esm/shared/pickerTriggerContract.mjs.map +1 -0
  44. package/dist/esm/shared/pickerTriggerContract.native.js +13 -0
  45. package/dist/esm/shared/pickerTriggerContract.native.js.map +1 -0
  46. package/dist/esm/shared/pressSlopProps.mjs +43 -0
  47. package/dist/esm/shared/pressSlopProps.mjs.map +1 -0
  48. package/dist/esm/shared/pressSlopProps.native.js +44 -0
  49. package/dist/esm/shared/pressSlopProps.native.js.map +1 -0
  50. package/dist/jsx/Button/index.mjs +1 -31
  51. package/dist/jsx/Button/index.mjs.map +1 -1
  52. package/dist/jsx/Button/index.native.js +3 -33
  53. package/dist/jsx/Button/index.native.js.map +1 -1
  54. package/dist/jsx/ContextualSaveBar/index.mjs +17 -10
  55. package/dist/jsx/ContextualSaveBar/index.mjs.map +1 -1
  56. package/dist/jsx/ContextualSaveBar/index.native.js +18 -10
  57. package/dist/jsx/ContextualSaveBar/index.native.js.map +1 -1
  58. package/dist/jsx/InputParts/index.mjs +3 -1
  59. package/dist/jsx/InputParts/index.mjs.map +1 -1
  60. package/dist/jsx/InputParts/index.native.js +3 -1
  61. package/dist/jsx/InputParts/index.native.js.map +1 -1
  62. package/dist/jsx/fields/Combobox/index.mjs +39 -48
  63. package/dist/jsx/fields/Combobox/index.mjs.map +1 -1
  64. package/dist/jsx/fields/Combobox/index.native.js +43 -51
  65. package/dist/jsx/fields/Combobox/index.native.js.map +1 -1
  66. package/dist/jsx/fields/Select/index.mjs +12 -8
  67. package/dist/jsx/fields/Select/index.mjs.map +1 -1
  68. package/dist/jsx/fields/Select/index.native.js +13 -19
  69. package/dist/jsx/fields/Select/index.native.js.map +1 -1
  70. package/dist/jsx/shared/pickerTriggerContract.mjs +13 -0
  71. package/dist/jsx/shared/pickerTriggerContract.mjs.map +1 -0
  72. package/dist/jsx/shared/pickerTriggerContract.native.js +41 -0
  73. package/dist/jsx/shared/pickerTriggerContract.native.js.map +1 -0
  74. package/dist/jsx/shared/pressSlopProps.mjs +43 -0
  75. package/dist/jsx/shared/pressSlopProps.mjs.map +1 -0
  76. package/dist/jsx/shared/pressSlopProps.native.js +72 -0
  77. package/dist/jsx/shared/pressSlopProps.native.js.map +1 -0
  78. package/package.json +7 -7
  79. package/src/Button/index.tsx +1 -35
  80. package/src/ContextualSaveBar/ContextualSaveBar.spec.tsx +33 -0
  81. package/src/ContextualSaveBar/ContextualSaveBar.stories.tsx +19 -0
  82. package/src/ContextualSaveBar/index.tsx +12 -8
  83. package/src/ContextualSaveBar/nativeFlow.spec.tsx +20 -0
  84. package/src/InputParts/index.tsx +5 -1
  85. package/src/fields/Combobox/index.tsx +40 -44
  86. package/src/fields/Select/index.tsx +13 -18
  87. package/src/fields/pickerActivation.spec.tsx +105 -0
  88. package/src/fields/pickerDensity.spec.tsx +86 -0
  89. package/src/fields/pickerRows.spec.tsx +66 -0
  90. package/src/shared/pickerTriggerContract.ts +13 -0
  91. package/src/shared/pressSlopProps.spec.ts +39 -0
  92. package/src/shared/pressSlopProps.ts +52 -0
  93. package/types/Button/index.d.ts.map +1 -1
  94. package/types/ContextualSaveBar/index.d.ts +1 -1
  95. package/types/ContextualSaveBar/index.d.ts.map +1 -1
  96. package/types/InputParts/index.d.ts.map +1 -1
  97. package/types/fields/Combobox/index.d.ts.map +1 -1
  98. package/types/fields/Select/index.d.ts.map +1 -1
  99. package/types/shared/pickerTriggerContract.d.ts +18 -0
  100. package/types/shared/pickerTriggerContract.d.ts.map +1 -0
  101. package/types/shared/pressSlopProps.d.ts +8 -0
  102. 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
+ });
@@ -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,
@@ -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
- size={ctx.knobProps.sizeToken}
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
- // LC-54 PRESS-FLOOR: the size knob may shrink chrome (compact → $3
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 (the floor above still applies).
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.sizeToken}
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
- minHeight={MIN_PRESS_TARGET}
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
- // LC-54 PRESS-FLOOR: ≥44px trigger target at every size (matches
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
- // Web: trigger owns toggle. Native: FloatingPanel.native owns the
1555
- // open gesture (pointerEvents="none" on the trigger) — a second
1556
- // setOpen(prev => !prev) here double-toggles and the sheet never
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
+ });