@multiplatform.one/forms 7.57.0 → 7.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/dist/cjs/AdaptivePopup/index.cjs +5 -1
  2. package/dist/cjs/AdaptivePopup/index.native.js +5 -1
  3. package/dist/cjs/AdaptivePopup/index.native.js.map +1 -1
  4. package/dist/cjs/Button/index.cjs +2 -1
  5. package/dist/cjs/Button/index.native.js +2 -1
  6. package/dist/cjs/Button/index.native.js.map +1 -1
  7. package/dist/cjs/FloatingPanel/compactFace.cjs +38 -0
  8. package/dist/cjs/FloatingPanel/compactFace.native.js +42 -0
  9. package/dist/cjs/FloatingPanel/compactFace.native.js.map +1 -0
  10. package/dist/cjs/FloatingPanel/index.cjs +38 -9
  11. package/dist/cjs/FloatingPanel/index.native.js +160 -6
  12. package/dist/cjs/FloatingPanel/index.native.js.map +1 -1
  13. package/dist/cjs/FloatingPanel/pointAnchor.cjs +22 -0
  14. package/dist/cjs/FloatingPanel/pointAnchor.native.js +23 -0
  15. package/dist/cjs/FloatingPanel/pointAnchor.native.js.map +1 -1
  16. package/dist/cjs/InputParts/index.cjs +13 -7
  17. package/dist/cjs/InputParts/index.native.js +13 -7
  18. package/dist/cjs/InputParts/index.native.js.map +1 -1
  19. package/dist/cjs/fields/Checkbox/Checkbox.cjs +24 -14
  20. package/dist/cjs/fields/Checkbox/Checkbox.native.js +38 -22
  21. package/dist/cjs/fields/Checkbox/Checkbox.native.js.map +1 -1
  22. package/dist/cjs/fields/RadioGroup/index.cjs +6 -1
  23. package/dist/cjs/fields/RadioGroup/index.native.js +6 -1
  24. package/dist/cjs/fields/RadioGroup/index.native.js.map +1 -1
  25. package/dist/cjs/fields/Select/InlineChoices.cjs +148 -0
  26. package/dist/cjs/fields/Select/InlineChoices.native.js +163 -0
  27. package/dist/cjs/fields/Select/InlineChoices.native.js.map +1 -0
  28. package/dist/cjs/fields/Select/index.cjs +79 -17
  29. package/dist/cjs/fields/Select/index.native.js +92 -17
  30. package/dist/cjs/fields/Select/index.native.js.map +1 -1
  31. package/dist/cjs/fields/Select/shouldInlineChoices.cjs +37 -0
  32. package/dist/cjs/fields/Select/shouldInlineChoices.native.js +43 -0
  33. package/dist/cjs/fields/Select/shouldInlineChoices.native.js.map +1 -0
  34. package/dist/cjs/fields/Switch/index.cjs +22 -10
  35. package/dist/cjs/fields/Switch/index.native.js +28 -14
  36. package/dist/cjs/fields/Switch/index.native.js.map +1 -1
  37. package/dist/cjs/shared/choiceRowPress.cjs +46 -0
  38. package/dist/cjs/shared/choiceRowPress.native.js +50 -0
  39. package/dist/cjs/shared/choiceRowPress.native.js.map +1 -0
  40. package/dist/cjs/shared/overlayStack.cjs +4 -0
  41. package/dist/cjs/shared/overlayStack.native.js +4 -0
  42. package/dist/cjs/shared/overlayStack.native.js.map +1 -1
  43. package/dist/esm/AdaptivePopup/index.mjs +6 -2
  44. package/dist/esm/AdaptivePopup/index.mjs.map +1 -1
  45. package/dist/esm/AdaptivePopup/index.native.js +6 -2
  46. package/dist/esm/AdaptivePopup/index.native.js.map +1 -1
  47. package/dist/esm/Button/index.mjs +3 -2
  48. package/dist/esm/Button/index.mjs.map +1 -1
  49. package/dist/esm/Button/index.native.js +3 -2
  50. package/dist/esm/Button/index.native.js.map +1 -1
  51. package/dist/esm/FloatingPanel/compactFace.mjs +11 -0
  52. package/dist/esm/FloatingPanel/compactFace.mjs.map +1 -0
  53. package/dist/esm/FloatingPanel/compactFace.native.js +12 -0
  54. package/dist/esm/FloatingPanel/compactFace.native.js.map +1 -0
  55. package/dist/esm/FloatingPanel/index.mjs +40 -11
  56. package/dist/esm/FloatingPanel/index.mjs.map +1 -1
  57. package/dist/esm/FloatingPanel/index.native.js +162 -8
  58. package/dist/esm/FloatingPanel/index.native.js.map +1 -1
  59. package/dist/esm/FloatingPanel/pointAnchor.mjs +22 -1
  60. package/dist/esm/FloatingPanel/pointAnchor.mjs.map +1 -1
  61. package/dist/esm/FloatingPanel/pointAnchor.native.js +23 -1
  62. package/dist/esm/FloatingPanel/pointAnchor.native.js.map +1 -1
  63. package/dist/esm/InputParts/index.mjs +13 -7
  64. package/dist/esm/InputParts/index.mjs.map +1 -1
  65. package/dist/esm/InputParts/index.native.js +13 -7
  66. package/dist/esm/InputParts/index.native.js.map +1 -1
  67. package/dist/esm/fields/Checkbox/Checkbox.mjs +24 -14
  68. package/dist/esm/fields/Checkbox/Checkbox.mjs.map +1 -1
  69. package/dist/esm/fields/Checkbox/Checkbox.native.js +38 -22
  70. package/dist/esm/fields/Checkbox/Checkbox.native.js.map +1 -1
  71. package/dist/esm/fields/RadioGroup/index.mjs +6 -1
  72. package/dist/esm/fields/RadioGroup/index.mjs.map +1 -1
  73. package/dist/esm/fields/RadioGroup/index.native.js +6 -1
  74. package/dist/esm/fields/RadioGroup/index.native.js.map +1 -1
  75. package/dist/esm/fields/Select/InlineChoices.mjs +123 -0
  76. package/dist/esm/fields/Select/InlineChoices.mjs.map +1 -0
  77. package/dist/esm/fields/Select/InlineChoices.native.js +135 -0
  78. package/dist/esm/fields/Select/InlineChoices.native.js.map +1 -0
  79. package/dist/esm/fields/Select/index.mjs +82 -20
  80. package/dist/esm/fields/Select/index.mjs.map +1 -1
  81. package/dist/esm/fields/Select/index.native.js +95 -20
  82. package/dist/esm/fields/Select/index.native.js.map +1 -1
  83. package/dist/esm/fields/Select/shouldInlineChoices.mjs +11 -0
  84. package/dist/esm/fields/Select/shouldInlineChoices.mjs.map +1 -0
  85. package/dist/esm/fields/Select/shouldInlineChoices.native.js +14 -0
  86. package/dist/esm/fields/Select/shouldInlineChoices.native.js.map +1 -0
  87. package/dist/esm/fields/Switch/index.mjs +22 -10
  88. package/dist/esm/fields/Switch/index.mjs.map +1 -1
  89. package/dist/esm/fields/Switch/index.native.js +28 -14
  90. package/dist/esm/fields/Switch/index.native.js.map +1 -1
  91. package/dist/esm/shared/choiceRowPress.mjs +20 -0
  92. package/dist/esm/shared/choiceRowPress.mjs.map +1 -0
  93. package/dist/esm/shared/choiceRowPress.native.js +21 -0
  94. package/dist/esm/shared/choiceRowPress.native.js.map +1 -0
  95. package/dist/esm/shared/overlayStack.mjs +4 -1
  96. package/dist/esm/shared/overlayStack.mjs.map +1 -1
  97. package/dist/esm/shared/overlayStack.native.js +4 -1
  98. package/dist/esm/shared/overlayStack.native.js.map +1 -1
  99. package/dist/jsx/AdaptivePopup/index.mjs +6 -2
  100. package/dist/jsx/AdaptivePopup/index.mjs.map +1 -1
  101. package/dist/jsx/AdaptivePopup/index.native.js +5 -1
  102. package/dist/jsx/AdaptivePopup/index.native.js.map +1 -1
  103. package/dist/jsx/Button/index.mjs +3 -2
  104. package/dist/jsx/Button/index.mjs.map +1 -1
  105. package/dist/jsx/Button/index.native.js +2 -1
  106. package/dist/jsx/Button/index.native.js.map +1 -1
  107. package/dist/jsx/FloatingPanel/compactFace.mjs +11 -0
  108. package/dist/jsx/FloatingPanel/compactFace.mjs.map +1 -0
  109. package/dist/jsx/FloatingPanel/compactFace.native.js +42 -0
  110. package/dist/jsx/FloatingPanel/compactFace.native.js.map +1 -0
  111. package/dist/jsx/FloatingPanel/index.mjs +40 -11
  112. package/dist/jsx/FloatingPanel/index.mjs.map +1 -1
  113. package/dist/jsx/FloatingPanel/index.native.js +160 -6
  114. package/dist/jsx/FloatingPanel/index.native.js.map +1 -1
  115. package/dist/jsx/FloatingPanel/pointAnchor.mjs +22 -1
  116. package/dist/jsx/FloatingPanel/pointAnchor.mjs.map +1 -1
  117. package/dist/jsx/FloatingPanel/pointAnchor.native.js +23 -0
  118. package/dist/jsx/FloatingPanel/pointAnchor.native.js.map +1 -1
  119. package/dist/jsx/InputParts/index.mjs +13 -7
  120. package/dist/jsx/InputParts/index.mjs.map +1 -1
  121. package/dist/jsx/InputParts/index.native.js +13 -7
  122. package/dist/jsx/InputParts/index.native.js.map +1 -1
  123. package/dist/jsx/fields/Checkbox/Checkbox.mjs +24 -14
  124. package/dist/jsx/fields/Checkbox/Checkbox.mjs.map +1 -1
  125. package/dist/jsx/fields/Checkbox/Checkbox.native.js +38 -22
  126. package/dist/jsx/fields/Checkbox/Checkbox.native.js.map +1 -1
  127. package/dist/jsx/fields/RadioGroup/index.mjs +6 -1
  128. package/dist/jsx/fields/RadioGroup/index.mjs.map +1 -1
  129. package/dist/jsx/fields/RadioGroup/index.native.js +6 -1
  130. package/dist/jsx/fields/RadioGroup/index.native.js.map +1 -1
  131. package/dist/jsx/fields/Select/InlineChoices.mjs +123 -0
  132. package/dist/jsx/fields/Select/InlineChoices.mjs.map +1 -0
  133. package/dist/jsx/fields/Select/InlineChoices.native.js +163 -0
  134. package/dist/jsx/fields/Select/InlineChoices.native.js.map +1 -0
  135. package/dist/jsx/fields/Select/index.mjs +82 -20
  136. package/dist/jsx/fields/Select/index.mjs.map +1 -1
  137. package/dist/jsx/fields/Select/index.native.js +92 -17
  138. package/dist/jsx/fields/Select/index.native.js.map +1 -1
  139. package/dist/jsx/fields/Select/shouldInlineChoices.mjs +11 -0
  140. package/dist/jsx/fields/Select/shouldInlineChoices.mjs.map +1 -0
  141. package/dist/jsx/fields/Select/shouldInlineChoices.native.js +43 -0
  142. package/dist/jsx/fields/Select/shouldInlineChoices.native.js.map +1 -0
  143. package/dist/jsx/fields/Switch/index.mjs +22 -10
  144. package/dist/jsx/fields/Switch/index.mjs.map +1 -1
  145. package/dist/jsx/fields/Switch/index.native.js +28 -14
  146. package/dist/jsx/fields/Switch/index.native.js.map +1 -1
  147. package/dist/jsx/shared/choiceRowPress.mjs +20 -0
  148. package/dist/jsx/shared/choiceRowPress.mjs.map +1 -0
  149. package/dist/jsx/shared/choiceRowPress.native.js +50 -0
  150. package/dist/jsx/shared/choiceRowPress.native.js.map +1 -0
  151. package/dist/jsx/shared/overlayStack.mjs +4 -1
  152. package/dist/jsx/shared/overlayStack.mjs.map +1 -1
  153. package/dist/jsx/shared/overlayStack.native.js +4 -0
  154. package/dist/jsx/shared/overlayStack.native.js.map +1 -1
  155. package/package.json +8 -8
  156. package/src/AdaptivePopup/index.tsx +6 -1
  157. package/src/Button/Button.controlTiers.spec.tsx +29 -0
  158. package/src/Button/index.tsx +3 -1
  159. package/src/FloatingPanel/FloatingPanel.compactFace.spec.tsx +187 -0
  160. package/src/FloatingPanel/FloatingPanel.menuFace.native.spec.tsx +195 -0
  161. package/src/FloatingPanel/compactFace.spec.ts +40 -0
  162. package/src/FloatingPanel/compactFace.ts +32 -0
  163. package/src/FloatingPanel/index.native.tsx +178 -4
  164. package/src/FloatingPanel/index.tsx +55 -11
  165. package/src/FloatingPanel/pointAnchor.spec.ts +80 -0
  166. package/src/FloatingPanel/pointAnchor.ts +52 -0
  167. package/src/InputParts/index.tsx +18 -7
  168. package/src/fields/Checkbox/Checkbox.native.spec.tsx +44 -0
  169. package/src/fields/Checkbox/Checkbox.tsx +31 -18
  170. package/src/fields/DatePicker/columnFit.spec.tsx +85 -0
  171. package/src/fields/RadioGroup/RadioGroup.native.spec.tsx +65 -0
  172. package/src/fields/RadioGroup/RadioGroup.stories.tsx +49 -0
  173. package/src/fields/RadioGroup/index.tsx +14 -2
  174. package/src/fields/Select/InlineChoices.tsx +156 -0
  175. package/src/fields/Select/index.tsx +100 -45
  176. package/src/fields/Select/shouldInlineChoices.spec.ts +25 -0
  177. package/src/fields/Select/shouldInlineChoices.ts +22 -0
  178. package/src/fields/Switch/Switch.rowPress.native.spec.tsx +69 -0
  179. package/src/fields/Switch/index.tsx +29 -11
  180. package/src/fields/pickerRows.spec.tsx +2 -0
  181. package/src/overlayGround.spec.ts +181 -0
  182. package/src/shared/choiceRowPress.ts +40 -0
  183. package/src/shared/overlayStack.tsx +5 -0
  184. package/types/AdaptivePopup/index.d.ts.map +1 -1
  185. package/types/Button/index.d.ts.map +1 -1
  186. package/types/FloatingPanel/compactFace.d.ts +21 -0
  187. package/types/FloatingPanel/compactFace.d.ts.map +1 -0
  188. package/types/FloatingPanel/index.d.ts +10 -0
  189. package/types/FloatingPanel/index.d.ts.map +1 -1
  190. package/types/FloatingPanel/index.native.d.ts +8 -1
  191. package/types/FloatingPanel/index.native.d.ts.map +1 -1
  192. package/types/FloatingPanel/pointAnchor.d.ts +29 -0
  193. package/types/FloatingPanel/pointAnchor.d.ts.map +1 -1
  194. package/types/InputParts/index.d.ts.map +1 -1
  195. package/types/fields/Checkbox/Checkbox.d.ts.map +1 -1
  196. package/types/fields/Checkbox/index.d.ts +3 -3
  197. package/types/fields/RadioGroup/index.d.ts.map +1 -1
  198. package/types/fields/Select/InlineChoices.d.ts +29 -0
  199. package/types/fields/Select/InlineChoices.d.ts.map +1 -0
  200. package/types/fields/Select/index.d.ts +6 -0
  201. package/types/fields/Select/index.d.ts.map +1 -1
  202. package/types/fields/Select/shouldInlineChoices.d.ts +16 -0
  203. package/types/fields/Select/shouldInlineChoices.d.ts.map +1 -0
  204. package/types/fields/Switch/index.d.ts.map +1 -1
  205. package/types/shared/choiceRowPress.d.ts +40 -0
  206. package/types/shared/choiceRowPress.d.ts.map +1 -0
  207. package/types/shared/overlayStack.d.ts +2 -0
  208. package/types/shared/overlayStack.d.ts.map +1 -1
@@ -6,8 +6,11 @@ import { useState } from "react";
6
6
  import { RadioGroup as TamaguiRadioGroup, XStack, YStack } from "tamagui";
7
7
  import { Label } from "../../Label";
8
8
  import { Preset, Text, Paragraph } from "@multiplatform.one/theme";
9
+ import { AdaptivePopup } from "../../AdaptivePopup";
9
10
  import { Button } from "../../Button";
10
11
  import { Form } from "../../Form";
12
+ import { Checkbox } from "../Checkbox";
13
+ import { Switch } from "../Switch";
11
14
  import { RadioGroup } from "./index";
12
15
 
13
16
  const meta: Meta<typeof RadioGroup> = {
@@ -374,3 +377,49 @@ export const InTable: Story = {
374
377
  );
375
378
  },
376
379
  };
380
+
381
+ /**
382
+ * FZ-7849: on a phone the whole row of every labelled choice selects, the label
383
+ * included, inside a sheet as on the page. The SHC console's Catalog filters
384
+ * found the label dead in a sheet.
385
+ */
386
+ export const RowPressInSheet: Story = {
387
+ name: "Row press in a sheet",
388
+ render: () => {
389
+ const RowPressExample = () => {
390
+ const [open, setOpen] = useState(false);
391
+ const [kind, setKind] = useState("app");
392
+ const [installed, setInstalled] = useState(false);
393
+ const [updates, setUpdates] = useState(false);
394
+ const summary = `Kind: ${kind} · Installed: ${installed ? "yes" : "no"} · Updates: ${updates ? "on" : "off"}`;
395
+ return (
396
+ <YStack gap="$3">
397
+ <Text>{summary}</Text>
398
+ <Button onPress={() => setOpen(true)}>Filter</Button>
399
+ <AdaptivePopup open={open} onOpenChange={setOpen} title="Filter">
400
+ <YStack gap="$4">
401
+ <RadioGroup
402
+ label="Kind"
403
+ options={[
404
+ { value: "app", label: "app" },
405
+ { value: "vapp", label: "vapp" },
406
+ { value: "addon", label: "addon" },
407
+ ]}
408
+ value={kind}
409
+ onChange={setKind}
410
+ />
411
+ <Checkbox
412
+ label="Installed only"
413
+ checked={installed}
414
+ onChange={(next) => setInstalled(next === true)}
415
+ />
416
+ <Switch label="Auto updates" checked={updates} onChange={setUpdates} />
417
+ <Text>{summary}</Text>
418
+ </YStack>
419
+ </AdaptivePopup>
420
+ </YStack>
421
+ );
422
+ };
423
+ return <RowPressExample />;
424
+ },
425
+ };
@@ -31,6 +31,7 @@ import { Skeleton } from "../../Skeleton";
31
31
  import { Field, FieldLayout } from "../../fieldLayout";
32
32
  import { formControlColors, formSelectedColors } from "../../shared/colorRamps";
33
33
  import { choiceItemA11y, composeA11yName } from "../../shared/nativeA11y";
34
+ import { choiceLabelPressProps, choiceRowPressProps } from "../../shared/choiceRowPress";
34
35
  import {
35
36
  getElevationWrapperProps,
36
37
  getFieldError,
@@ -582,8 +583,19 @@ export function RadioGroup({
582
583
  <Container gap="$2">
583
584
  {options.map((option, index) => {
584
585
  const itemId = `${id}-${index}-${option.value}`;
586
+ const select =
587
+ disabled || option.disabled || readOnly
588
+ ? undefined
589
+ : () => {
590
+ if (currentValue !== option.value) emit(option.value);
591
+ };
585
592
  return (
586
- <XStack key={`${index}-${option.value}`} gap="$2" alignItems="center">
593
+ <XStack
594
+ key={`${index}-${option.value}`}
595
+ gap="$2"
596
+ alignItems="center"
597
+ {...choiceRowPressProps(select)}
598
+ >
587
599
  {renderRadioItem(
588
600
  option,
589
601
  itemId,
@@ -592,7 +604,7 @@ export function RadioGroup({
592
604
  index === tabStopIndex,
593
605
  )}
594
606
  <Label
595
- htmlFor={itemId}
607
+ {...choiceLabelPressProps(itemId, select)}
596
608
  // MPO-283: on native the radio itself now carries this text as
597
609
  // its accessible name, so leaving the Label in the tree reads
598
610
  // every option twice. Web keeps the label/control association.
@@ -0,0 +1,156 @@
1
+ import { hairline, IconSlot, keyboardFocusRingProps } from "@multiplatform.one/theme";
2
+ import { CheckIcon } from "@phosphor-icons/react";
3
+ import { useRef, type KeyboardEvent, type ReactNode } from "react";
4
+ import { Pressable } from "react-native";
5
+ import { isWeb, Text, View, XStack, YStack } from "tamagui";
6
+ import { useFieldDescribedBy } from "../../fieldLayout";
7
+ import { formCommonColors, formInputColors } from "../../shared/colorRamps";
8
+ import { choiceItemA11y } from "../../shared/nativeA11y";
9
+
10
+ export interface InlineChoice {
11
+ value: string;
12
+ label: string;
13
+ icon?: ReactNode;
14
+ }
15
+
16
+ export interface InlineChoicesProps {
17
+ options: InlineChoice[];
18
+ value?: string;
19
+ onValueChange: (value: string) => void;
20
+ onBlur?: (...args: any[]) => void;
21
+ disabled?: boolean;
22
+ /** The field id: FieldLayout's label points at it. */
23
+ id?: string;
24
+ labelledBy?: string;
25
+ "aria-label"?: string;
26
+ knobProps: any;
27
+ /** The LC-05 selected mark, already resolved to a paint. */
28
+ markColor: string;
29
+ }
30
+
31
+ const nextKeys = new Set(["ArrowDown", "ArrowRight"]);
32
+ const previousKeys = new Set(["ArrowUp", "ArrowLeft"]);
33
+
34
+ /**
35
+ * A short Select inside a sheet laid out in place (LC-262 DIRECT-ACTIONS): one
36
+ * grouped list of radio rows, each with its glyph and label, the chosen row
37
+ * marked by the accent check alone. No trigger and no popover. On the web it
38
+ * is one tab stop, arrows move and choose, Space and Enter choose (APG radio
39
+ * group).
40
+ */
41
+ export function InlineChoices({
42
+ options,
43
+ value,
44
+ onValueChange,
45
+ onBlur,
46
+ disabled,
47
+ id,
48
+ labelledBy,
49
+ "aria-label": ariaLabel,
50
+ knobProps,
51
+ markColor,
52
+ }: InlineChoicesProps) {
53
+ const describedBy = useFieldDescribedBy();
54
+ const rowsRef = useRef<Array<HTMLElement | null>>([]);
55
+ const textColor = knobProps.textAccentColor;
56
+ const selectedIndex = options.findIndex((option) => option.value === value);
57
+ const tabStop = selectedIndex >= 0 ? selectedIndex : 0;
58
+
59
+ const choose = (index: number) => {
60
+ const option = options[index];
61
+ if (disabled || !option || option.value === value) return;
62
+ onValueChange(option.value);
63
+ };
64
+
65
+ const onKeyDown = (index: number) => (event: KeyboardEvent) => {
66
+ const step = nextKeys.has(event.key) ? 1 : previousKeys.has(event.key) ? -1 : 0;
67
+ if (step) {
68
+ event.preventDefault();
69
+ const next = (index + step + options.length) % options.length;
70
+ rowsRef.current[next]?.focus();
71
+ choose(next);
72
+ } else if (event.key === " " || event.key === "Enter") {
73
+ event.preventDefault();
74
+ choose(index);
75
+ }
76
+ };
77
+
78
+ const rowContent = (option: InlineChoice, index: number, selected: boolean) => (
79
+ <XStack
80
+ alignItems="center"
81
+ height={knobProps.menuRow.minHeight}
82
+ paddingHorizontal={knobProps.panelPadding.padding}
83
+ {...knobProps.gap}
84
+ {...(index === 0 ? undefined : hairline.top)}
85
+ borderColor={formCommonColors.divider}
86
+ pointerEvents="none"
87
+ >
88
+ <View flexShrink={0} alignItems="center" justifyContent="center" minWidth={18}>
89
+ <IconSlot icon={option.icon} color={textColor} />
90
+ </View>
91
+ <Text
92
+ fontFamily={knobProps.body.fontFamily}
93
+ fontWeight={knobProps.body.fontWeight}
94
+ color={textColor}
95
+ numberOfLines={1}
96
+ flex={1}
97
+ >
98
+ {option.label}
99
+ </Text>
100
+ {selected ? <CheckIcon size={14} weight="bold" color={markColor} /> : null}
101
+ </XStack>
102
+ );
103
+
104
+ return (
105
+ <YStack
106
+ id={id}
107
+ role="radiogroup"
108
+ aria-labelledby={labelledBy}
109
+ aria-label={labelledBy ? undefined : ariaLabel}
110
+ aria-describedby={describedBy}
111
+ aria-disabled={disabled || undefined}
112
+ data-testid="select-inline-choices"
113
+ {...knobProps.borderRadius}
114
+ borderColor={formInputColors.border.base}
115
+ backgroundColor={formInputColors.background.base}
116
+ overflow="hidden"
117
+ width="100%"
118
+ >
119
+ {options.map((option, index) => {
120
+ const selected = option.value === value;
121
+ if (isWeb) {
122
+ return (
123
+ <View
124
+ key={option.value}
125
+ ref={(node: any) => {
126
+ rowsRef.current[index] = node;
127
+ }}
128
+ role="radio"
129
+ aria-checked={selected}
130
+ aria-disabled={disabled || undefined}
131
+ tabIndex={!disabled && index === tabStop ? 0 : -1}
132
+ cursor={disabled ? "default" : "pointer"}
133
+ onPress={() => choose(index)}
134
+ onKeyDown={onKeyDown(index)}
135
+ onBlur={onBlur}
136
+ outlineWidth={0}
137
+ focusVisibleStyle={keyboardFocusRingProps}
138
+ >
139
+ {rowContent(option, index, selected)}
140
+ </View>
141
+ );
142
+ }
143
+ return (
144
+ <Pressable
145
+ key={option.value}
146
+ {...choiceItemA11y({ name: option.label, selected, disabled: !!disabled })}
147
+ disabled={disabled}
148
+ onPress={() => choose(index)}
149
+ >
150
+ {rowContent(option, index, selected)}
151
+ </Pressable>
152
+ );
153
+ })}
154
+ </YStack>
155
+ );
156
+ }
@@ -1,6 +1,15 @@
1
- import { useTouchSurface } from "@multiplatform.one/theme";
1
+ import { IconSlot, useTouchSurface } from "@multiplatform.one/theme";
2
2
  import type { KeyboardEvent, MouseEvent, ReactNode, RefObject, UIEvent } from "react";
3
- import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, memo } from "react";
3
+ import {
4
+ useCallback,
5
+ useEffect,
6
+ useLayoutEffect,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ useSyncExternalStore,
11
+ memo,
12
+ } from "react";
4
13
  import { CaretDownIcon, CheckIcon } from "@phosphor-icons/react";
5
14
  import {
6
15
  useClick,
@@ -54,13 +63,16 @@ import {
54
63
  panelViewportPadding,
55
64
  useViewportGtSm,
56
65
  } from "../../FloatingPanel";
66
+ import { pickerCompactFace, resolveCompactFace } from "../../FloatingPanel/compactFace";
57
67
  import { PanelPortal } from "../../FloatingPanel/PanelPortal";
58
- import { useOverlayZIndex } from "../../shared/overlayStack";
68
+ import { useInsideOverlay, useOverlayZIndex } from "../../shared/overlayStack";
59
69
  import { useDragScroll } from "../../shared/useDragScroll";
60
70
  import { useIsInTableCell } from "../../shared/tableCellContext";
61
71
  import { t } from "../../shared/t";
62
72
  import { orderOptionsBySelected, type SelectedOrder } from "../selectedOrder";
63
73
  import { shouldUseIosNativeSelect } from "../nativeSelect";
74
+ import { InlineChoices } from "./InlineChoices";
75
+ import { shouldInlineChoices } from "./shouldInlineChoices";
64
76
 
65
77
  /** Undefined on web (RNW ships no ActionSheetIOS); real on iOS. */
66
78
  const actionSheetIOS = (ReactNative as { ActionSheetIOS?: typeof ReactNative.ActionSheetIOS })
@@ -73,6 +85,12 @@ export interface SelectOption {
73
85
  label: string;
74
86
  /** Optional group label for grouping options */
75
87
  group?: string;
88
+ /**
89
+ * Leading glyph on the option's row (LC-112: every menu row carries one when
90
+ * the options have them). Inside a sheet, five or fewer options that all
91
+ * carry one render inline as an icon choice list (LC-262).
92
+ */
93
+ icon?: ReactNode;
76
94
  }
77
95
 
78
96
  export interface SelectProps {
@@ -150,8 +168,9 @@ const SelectRow = styled(View, {
150
168
  // Do not put outlineWidth here — Tamagui maps these onto mouse :focus and
151
169
  // they beat instance ring props. Rows paint the ring via selectItemRingProps.
152
170
  variants: {
171
+ // LC-262: the accent check alone marks the chosen row, never a grey fill.
153
172
  selected: {
154
- true: { backgroundColor: formInputColors.background.base, borderRadius: 0 },
173
+ true: { backgroundColor: "transparent", borderRadius: 0 },
155
174
  false: { backgroundColor: "transparent" },
156
175
  },
157
176
  active: {
@@ -164,6 +183,15 @@ const SelectRow = styled(View, {
164
183
  } as const,
165
184
  });
166
185
 
186
+ /** An option's leading glyph, in the row's ink. */
187
+ function OptionGlyph({ icon, color }: { icon: ReactNode; color?: string }) {
188
+ return (
189
+ <View flexShrink={0} alignItems="center" justifyContent="center" minWidth={18}>
190
+ <IconSlot icon={icon} color={color} />
191
+ </View>
192
+ );
193
+ }
194
+
167
195
  function GroupHeader(props: HouseTextProps) {
168
196
  return (
169
197
  <HouseText
@@ -432,7 +460,9 @@ const SelectItem = memo(function SelectItem({
432
460
  role="option"
433
461
  tabIndex={-1}
434
462
  selected={isSelected}
435
- active={isActive}
463
+ // The open-time cursor sits on the chosen row; it only paints while the
464
+ // keyboard drives it, so the chosen row never opens grey (LC-262).
465
+ active={Boolean(isActive) && ctx.kbNav}
436
466
  aria-selected={isSelected}
437
467
  data-state={isSelected ? "active" : "inactive"}
438
468
  height={ctx.knobProps.menuRow.minHeight}
@@ -446,22 +476,15 @@ const SelectItem = memo(function SelectItem({
446
476
  {...(suppressHover
447
477
  ? {
448
478
  hoverStyle: {
449
- backgroundColor: isActive
450
- ? formInputColors.background.focus
451
- : isSelected
452
- ? formInputColors.background.base
453
- : "transparent",
479
+ backgroundColor: isActive ? formInputColors.background.focus : "transparent",
454
480
  },
455
481
  }
456
482
  : {
457
- hoverStyle: {
458
- backgroundColor: isSelected
459
- ? formInputColors.background.base
460
- : formInputColors.background.focus,
461
- },
483
+ hoverStyle: { backgroundColor: formInputColors.background.focus },
462
484
  })}
463
485
  {...transitionProps(ctx.knobProps.motion.control)}
464
486
  >
487
+ {option.icon ? <OptionGlyph icon={option.icon} color={ctx.textColor} /> : null}
465
488
  <Text
466
489
  fontFamily={ctx.knobProps.body.fontFamily}
467
490
  fontWeight={ctx.knobProps.body.fontWeight}
@@ -1247,6 +1270,9 @@ function NativeSelectCore({
1247
1270
  dismissible,
1248
1271
  native,
1249
1272
  }: FloatingSelectCoreProps) {
1273
+ const insideOverlay = useInsideOverlay();
1274
+ const compactFace = pickerCompactFace(options.length);
1275
+ const asMenu = resolveCompactFace(compactFace, insideOverlay) === "menu";
1250
1276
  const hydrationTouch = useTouchSurface();
1251
1277
  const describedBy = useFieldDescribedBy();
1252
1278
  const fieldA11y = useFieldA11y();
@@ -1422,30 +1448,16 @@ function NativeSelectCore({
1422
1448
  {...(isWeb ? selectItemRingProps(active, kbNav) : undefined)}
1423
1449
  hoverStyle={
1424
1450
  activeIndex != null
1425
- ? {
1426
- backgroundColor: active
1427
- ? formInputColors.background.focus
1428
- : selected
1429
- ? formInputColors.background.base
1430
- : "transparent",
1431
- }
1432
- : {
1433
- backgroundColor: selected
1434
- ? formInputColors.background.base
1435
- : formInputColors.background.focus,
1436
- }
1437
- }
1438
- backgroundColor={
1439
- active
1440
- ? formInputColors.background.focus
1441
- : selected
1442
- ? formInputColors.background.base
1443
- : "transparent"
1451
+ ? { backgroundColor: active ? formInputColors.background.focus : "transparent" }
1452
+ : { backgroundColor: formInputColors.background.focus }
1444
1453
  }
1454
+ // LC-262: the accent check alone marks the chosen row.
1455
+ backgroundColor={active && kbNav ? formInputColors.background.focus : "transparent"}
1445
1456
  pressStyle={{ backgroundColor: formInputColors.background.focus }}
1446
1457
  // Native: parent RNPressable owns the hit; disable child stealing.
1447
1458
  pointerEvents={isWeb ? undefined : "none"}
1448
1459
  >
1460
+ {option.icon ? <OptionGlyph icon={option.icon} color={textColor} /> : null}
1449
1461
  <Text
1450
1462
  fontFamily={knobProps.body.fontFamily}
1451
1463
  fontWeight={knobProps.body.fontWeight}
@@ -1481,7 +1493,9 @@ function NativeSelectCore({
1481
1493
  : placeholder
1482
1494
  : (selectedOption?.label ?? placeholder);
1483
1495
 
1484
- if (shouldUseIosNativeSelect({ native, multiple, isWeb, os: Platform.OS })) {
1496
+ // The iOS action sheet is a sheet too: a short list, or any list inside an
1497
+ // overlay, opens as a menu over the trigger instead (FZ-7830).
1498
+ if (!asMenu && shouldUseIosNativeSelect({ native, multiple, isWeb, os: Platform.OS })) {
1485
1499
  const openNative = () => {
1486
1500
  if (disabled) return;
1487
1501
  const labels = options.map((o) => o.label);
@@ -1556,6 +1570,9 @@ function NativeSelectCore({
1556
1570
  if (!val) onBlur?.();
1557
1571
  }}
1558
1572
  sheet
1573
+ // A short list opens as a menu over its trigger; a long one keeps its
1574
+ // sheet at page level and opens as a menu inside another overlay (FZ-7830).
1575
+ compactFace={compactFace}
1559
1576
  triggerA11y={{
1560
1577
  label: ariaLabel ?? fieldA11y?.label,
1561
1578
  value: triggerValueText,
@@ -1676,6 +1693,10 @@ function NativeSelectCore({
1676
1693
 
1677
1694
  // ── Select (public API) ──────────────────────────────────────
1678
1695
 
1696
+ const subscribeNothing = () => () => {};
1697
+ const clientSnapshot = () => true;
1698
+ const serverSnapshot = () => false;
1699
+
1679
1700
  export function Select({
1680
1701
  name,
1681
1702
  form: formProp,
@@ -1725,18 +1746,32 @@ export function Select({
1725
1746
  const isControlled = value !== undefined;
1726
1747
  const hasLabel = Boolean(label);
1727
1748
 
1728
- // DG-CTRL-01 / DG-CTRL-05 — short single-select lists prefer RadioGroup.
1729
- warnSelectTooFewOptions({
1730
- count: options?.length ?? 0,
1731
- multiple,
1732
- component: "Select",
1733
- id,
1734
- });
1735
1749
  const viewportGtSm = useViewportGtSm();
1736
- // Sheet on native and below `$sm` (viewport < 640, OVERLAY_BREAKPOINT).
1737
- // Floating from `$sm` up.
1750
+ const insideOverlay = useInsideOverlay();
1751
+ // Sheet core on native and below `$sm` (viewport < 640, OVERLAY_BREAKPOINT).
1752
+ // Floating from `$sm` up, and on a phone-width web for a short list or
1753
+ // inside another overlay, where a sheet would stack on a sheet (FZ-7830).
1754
+ // Native keeps the sheet core, whose FloatingPanel opens the same menu.
1755
+ // SSR and the hydration render keep the sheet core, as the viewport does.
1738
1756
  const effectiveGtSm = typeof window !== "undefined" ? viewportGtSm : false;
1739
- const shouldUseSheet = !isWeb || !effectiveGtSm;
1757
+ const hydrated = useSyncExternalStore(subscribeNothing, clientSnapshot, serverSnapshot);
1758
+ const compactMenu =
1759
+ hydrated &&
1760
+ resolveCompactFace(pickerCompactFace(options?.length ?? 0), insideOverlay) === "menu";
1761
+ const shouldUseSheet = !isWeb || (!effectiveGtSm && !compactMenu);
1762
+ const inTableCell = useIsInTableCell();
1763
+ const inline = !inTableCell && shouldInlineChoices({ insideOverlay, multiple, options });
1764
+ // DG-CTRL-01 / DG-CTRL-05 — short single-select lists prefer RadioGroup,
1765
+ // which the inline face already is.
1766
+ if (!inline) {
1767
+ warnSelectTooFewOptions({
1768
+ count: options?.length ?? 0,
1769
+ multiple,
1770
+ component: "Select",
1771
+ id,
1772
+ });
1773
+ }
1774
+ const markColor = useSelectedMarkColor();
1740
1775
 
1741
1776
  // Render skeleton placeholder
1742
1777
  if (skeleton) {
@@ -1801,6 +1836,26 @@ export function Select({
1801
1836
  handleBlur?: (...args: any[]) => void,
1802
1837
  ) => {
1803
1838
  if (readOnly) return renderReadOnly(selectValue);
1839
+ if (inline) {
1840
+ return (
1841
+ <InlineChoices
1842
+ options={options}
1843
+ value={typeof selectValue === "string" ? selectValue : undefined}
1844
+ onValueChange={(val) => {
1845
+ if (disabled) return;
1846
+ handleValueChange?.(val);
1847
+ emitValueChange(val);
1848
+ }}
1849
+ onBlur={handleBlur}
1850
+ disabled={disabled}
1851
+ id={id}
1852
+ labelledBy={hasLabel ? `${id}-label` : undefined}
1853
+ aria-label={ariaLabel}
1854
+ knobProps={knobProps}
1855
+ markColor={markColor}
1856
+ />
1857
+ );
1858
+ }
1804
1859
  const SelectCore = shouldUseSheet ? NativeSelectCore : FloatingSelectCore;
1805
1860
  return (
1806
1861
  <SelectCore
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { INLINE_CHOICES_MAX, shouldInlineChoices } from "./shouldInlineChoices";
3
+
4
+ const glyph = "•";
5
+ const withIcons = (n: number) => Array.from({ length: n }, () => ({ icon: glyph }));
6
+
7
+ describe("shouldInlineChoices (LC-262, FZ-7830)", () => {
8
+ it("lays out five or fewer glyphed options inline inside an overlay", () => {
9
+ expect(INLINE_CHOICES_MAX).toBe(5);
10
+ expect(shouldInlineChoices({ insideOverlay: true, options: withIcons(2) })).toBe(true);
11
+ expect(shouldInlineChoices({ insideOverlay: true, options: withIcons(5) })).toBe(true);
12
+ });
13
+
14
+ it("keeps the trigger past five, without a glyph on every option, at page level, or for multiple", () => {
15
+ expect(shouldInlineChoices({ insideOverlay: true, options: withIcons(6) })).toBe(false);
16
+ expect(shouldInlineChoices({ insideOverlay: true, options: [...withIcons(3), {}] })).toBe(
17
+ false,
18
+ );
19
+ expect(shouldInlineChoices({ insideOverlay: false, options: withIcons(3) })).toBe(false);
20
+ expect(
21
+ shouldInlineChoices({ insideOverlay: true, multiple: true, options: withIcons(3) }),
22
+ ).toBe(false);
23
+ expect(shouldInlineChoices({ insideOverlay: true, options: [] })).toBe(false);
24
+ });
25
+ });
@@ -0,0 +1,22 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ /** The most options a Select inside a sheet lays out inline (LC-262). */
4
+ export const INLINE_CHOICES_MAX = 5;
5
+
6
+ /**
7
+ * A single-choice Select inside a sheet, dialog or popover with five or fewer
8
+ * options that all carry a glyph renders inline as an icon choice list: no
9
+ * trigger and no popover, the choices sit in the sheet (LC-262, FZ-7830).
10
+ */
11
+ export function shouldInlineChoices({
12
+ insideOverlay,
13
+ multiple,
14
+ options,
15
+ }: {
16
+ insideOverlay: boolean;
17
+ multiple?: boolean;
18
+ options: ReadonlyArray<{ icon?: ReactNode }> | undefined;
19
+ }): boolean {
20
+ if (!insideOverlay || multiple || !options?.length) return false;
21
+ return options.length <= INLINE_CHOICES_MAX && options.every((option) => option.icon != null);
22
+ }
@@ -0,0 +1,69 @@
1
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
2
+ import { useForm } from "@tanstack/react-form";
3
+ import { cleanup, fireEvent } from "@testing-library/react";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { Form } from "../../Form";
6
+ import { Switch } from "./index";
7
+ import { muteBubbleInputs } from "../../../tests/muteBubbleInputs";
8
+
9
+ vi.mock("tamagui", async (importOriginal) => ({
10
+ ...(await importOriginal<typeof import("tamagui")>()),
11
+ isWeb: false,
12
+ }));
13
+
14
+ afterEach(cleanup);
15
+
16
+ function FormSwitch({ onChange }: { onChange: (checked: boolean) => void }) {
17
+ const form = useForm({ defaultValues: { alerts: false } });
18
+ return (
19
+ <Form form={form}>
20
+ <Switch name="alerts" label="Alerts" onChange={onChange} />
21
+ </Form>
22
+ );
23
+ }
24
+
25
+ // FZ-7849. Same contract as RadioGroup's row press: the row and the label flip
26
+ // the switch directly on native instead of through tamagui's focusable
27
+ // registry, and each press flips it exactly once.
28
+ describe("native Switch row press", () => {
29
+ muteBubbleInputs();
30
+
31
+ const rowOf = (label: HTMLElement) => label.parentElement as HTMLElement;
32
+
33
+ it("flips once from the row and once from the label, standalone", () => {
34
+ const onChange = vi.fn();
35
+ const result = renderWithProviders(<Switch label="Dark mode" onChange={onChange} />);
36
+ const label = result.getByText("Dark mode");
37
+ expect(label.hasAttribute("for")).toBe(false);
38
+ fireEvent.click(rowOf(label));
39
+ expect(onChange).toHaveBeenCalledExactlyOnceWith(true);
40
+ fireEvent.click(label);
41
+ expect(onChange).toHaveBeenCalledTimes(2);
42
+ expect(onChange).toHaveBeenLastCalledWith(false);
43
+ });
44
+
45
+ it("flips a form field once from its label", () => {
46
+ const onChange = vi.fn();
47
+ const result = renderWithProviders(<FormSwitch onChange={onChange} />);
48
+ fireEvent.click(result.getByText("Alerts"));
49
+ expect(onChange).toHaveBeenCalledExactlyOnceWith(true);
50
+ fireEvent.click(rowOf(result.getByText("Alerts")));
51
+ expect(onChange).toHaveBeenLastCalledWith(false);
52
+ });
53
+
54
+ it("leaves a disabled or read-only switch's row and label dead", () => {
55
+ const onChange = vi.fn();
56
+ const result = renderWithProviders(
57
+ <>
58
+ <Switch label="Off limits" onChange={onChange} disabled />
59
+ <Switch label="Look only" onChange={onChange} readOnly />
60
+ </>,
61
+ );
62
+ for (const text of ["Off limits", "Look only"]) {
63
+ const label = result.getByText(text);
64
+ fireEvent.click(rowOf(label));
65
+ fireEvent.click(label);
66
+ }
67
+ expect(onChange).not.toHaveBeenCalled();
68
+ });
69
+ });