@multiplatform.one/forms 7.5.0 → 7.6.1

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 (233) hide show
  1. package/dist/cjs/Button/index.cjs +6 -10
  2. package/dist/cjs/Button/index.native.js +6 -10
  3. package/dist/cjs/Button/index.native.js.map +1 -1
  4. package/dist/cjs/ContextualSaveBar/UnsavedChangesDialog.cjs +0 -1
  5. package/dist/cjs/ContextualSaveBar/UnsavedChangesDialog.native.js +0 -1
  6. package/dist/cjs/ContextualSaveBar/UnsavedChangesDialog.native.js.map +1 -1
  7. package/dist/cjs/FieldDisplay/index.cjs +2 -1
  8. package/dist/cjs/FieldDisplay/index.native.js +9 -2
  9. package/dist/cjs/FieldDisplay/index.native.js.map +1 -1
  10. package/dist/cjs/FloatingPanel/index.cjs +8 -1
  11. package/dist/cjs/InputParts/index.cjs +64 -2
  12. package/dist/cjs/InputParts/index.native.js +70 -4
  13. package/dist/cjs/InputParts/index.native.js.map +1 -1
  14. package/dist/cjs/fields/Barcode/BarcodePreview.cjs +15 -9
  15. package/dist/cjs/fields/Barcode/BarcodePreview.native.js +15 -9
  16. package/dist/cjs/fields/Barcode/BarcodePreview.native.js.map +1 -1
  17. package/dist/cjs/fields/Combobox/index.cjs +14 -11
  18. package/dist/cjs/fields/Combobox/index.native.js +16 -11
  19. package/dist/cjs/fields/Combobox/index.native.js.map +1 -1
  20. package/dist/cjs/fields/FileUpload/index.cjs +66 -5
  21. package/dist/cjs/fields/FileUpload/index.native.js +85 -5
  22. package/dist/cjs/fields/FileUpload/index.native.js.map +1 -1
  23. package/dist/cjs/fields/Geolocation/geoValue.cjs +122 -0
  24. package/dist/cjs/fields/Geolocation/geoValue.native.js +151 -0
  25. package/dist/cjs/fields/Geolocation/geoValue.native.js.map +1 -0
  26. package/dist/cjs/fields/Geolocation/index.cjs +16 -29
  27. package/dist/cjs/fields/Geolocation/index.native.js +16 -29
  28. package/dist/cjs/fields/Geolocation/index.native.js.map +1 -1
  29. package/dist/cjs/fields/Icon/index.cjs +1 -0
  30. package/dist/cjs/fields/Icon/index.native.js +1 -0
  31. package/dist/cjs/fields/Icon/index.native.js.map +1 -1
  32. package/dist/cjs/fields/Input/index.cjs +175 -95
  33. package/dist/cjs/fields/Input/index.native.js +83 -4
  34. package/dist/cjs/fields/Input/index.native.js.map +1 -1
  35. package/dist/cjs/fields/MentionInput/index.cjs +6 -1
  36. package/dist/cjs/fields/MentionInput/index.native.js +6 -1
  37. package/dist/cjs/fields/MentionInput/index.native.js.map +1 -1
  38. package/dist/cjs/fields/OTPInput/index.cjs +7 -2
  39. package/dist/cjs/fields/OTPInput/index.native.js +7 -2
  40. package/dist/cjs/fields/OTPInput/index.native.js.map +1 -1
  41. package/dist/cjs/fields/PhoneInput/index.native.js +1 -0
  42. package/dist/cjs/fields/PhoneInput/index.native.js.map +1 -1
  43. package/dist/cjs/fields/SearchInput/index.native.js.map +1 -1
  44. package/dist/cjs/shared/utils.cjs +4 -14
  45. package/dist/cjs/shared/utils.native.js +6 -14
  46. package/dist/cjs/shared/utils.native.js.map +1 -1
  47. package/dist/esm/Button/index.mjs +6 -9
  48. package/dist/esm/Button/index.mjs.map +1 -1
  49. package/dist/esm/Button/index.native.js +6 -9
  50. package/dist/esm/Button/index.native.js.map +1 -1
  51. package/dist/esm/ContextualSaveBar/UnsavedChangesDialog.mjs +0 -1
  52. package/dist/esm/ContextualSaveBar/UnsavedChangesDialog.mjs.map +1 -1
  53. package/dist/esm/ContextualSaveBar/UnsavedChangesDialog.native.js +0 -1
  54. package/dist/esm/ContextualSaveBar/UnsavedChangesDialog.native.js.map +1 -1
  55. package/dist/esm/FieldDisplay/index.mjs +2 -1
  56. package/dist/esm/FieldDisplay/index.mjs.map +1 -1
  57. package/dist/esm/FieldDisplay/index.native.js +9 -2
  58. package/dist/esm/FieldDisplay/index.native.js.map +1 -1
  59. package/dist/esm/FloatingPanel/index.mjs +8 -1
  60. package/dist/esm/FloatingPanel/index.mjs.map +1 -1
  61. package/dist/esm/InputParts/index.mjs +64 -2
  62. package/dist/esm/InputParts/index.mjs.map +1 -1
  63. package/dist/esm/InputParts/index.native.js +70 -4
  64. package/dist/esm/InputParts/index.native.js.map +1 -1
  65. package/dist/esm/fields/Barcode/BarcodePreview.mjs +15 -9
  66. package/dist/esm/fields/Barcode/BarcodePreview.mjs.map +1 -1
  67. package/dist/esm/fields/Barcode/BarcodePreview.native.js +15 -9
  68. package/dist/esm/fields/Barcode/BarcodePreview.native.js.map +1 -1
  69. package/dist/esm/fields/Combobox/index.mjs +14 -11
  70. package/dist/esm/fields/Combobox/index.mjs.map +1 -1
  71. package/dist/esm/fields/Combobox/index.native.js +16 -11
  72. package/dist/esm/fields/Combobox/index.native.js.map +1 -1
  73. package/dist/esm/fields/FileUpload/index.mjs +66 -5
  74. package/dist/esm/fields/FileUpload/index.mjs.map +1 -1
  75. package/dist/esm/fields/FileUpload/index.native.js +85 -5
  76. package/dist/esm/fields/FileUpload/index.native.js.map +1 -1
  77. package/dist/esm/fields/Geolocation/geoValue.mjs +95 -0
  78. package/dist/esm/fields/Geolocation/geoValue.mjs.map +1 -0
  79. package/dist/esm/fields/Geolocation/geoValue.native.js +121 -0
  80. package/dist/esm/fields/Geolocation/geoValue.native.js.map +1 -0
  81. package/dist/esm/fields/Geolocation/index.mjs +15 -28
  82. package/dist/esm/fields/Geolocation/index.mjs.map +1 -1
  83. package/dist/esm/fields/Geolocation/index.native.js +15 -28
  84. package/dist/esm/fields/Geolocation/index.native.js.map +1 -1
  85. package/dist/esm/fields/Icon/index.mjs +1 -0
  86. package/dist/esm/fields/Icon/index.mjs.map +1 -1
  87. package/dist/esm/fields/Icon/index.native.js +1 -0
  88. package/dist/esm/fields/Icon/index.native.js.map +1 -1
  89. package/dist/esm/fields/Input/index.mjs +177 -98
  90. package/dist/esm/fields/Input/index.mjs.map +1 -1
  91. package/dist/esm/fields/Input/index.native.js +85 -7
  92. package/dist/esm/fields/Input/index.native.js.map +1 -1
  93. package/dist/esm/fields/MentionInput/index.mjs +6 -1
  94. package/dist/esm/fields/MentionInput/index.mjs.map +1 -1
  95. package/dist/esm/fields/MentionInput/index.native.js +6 -1
  96. package/dist/esm/fields/MentionInput/index.native.js.map +1 -1
  97. package/dist/esm/fields/OTPInput/index.mjs +7 -3
  98. package/dist/esm/fields/OTPInput/index.mjs.map +1 -1
  99. package/dist/esm/fields/OTPInput/index.native.js +7 -3
  100. package/dist/esm/fields/OTPInput/index.native.js.map +1 -1
  101. package/dist/esm/fields/PhoneInput/index.mjs.map +1 -1
  102. package/dist/esm/fields/PhoneInput/index.native.js +1 -0
  103. package/dist/esm/fields/PhoneInput/index.native.js.map +1 -1
  104. package/dist/esm/fields/SearchInput/index.mjs.map +1 -1
  105. package/dist/esm/fields/SearchInput/index.native.js.map +1 -1
  106. package/dist/esm/shared/utils.mjs +4 -14
  107. package/dist/esm/shared/utils.mjs.map +1 -1
  108. package/dist/esm/shared/utils.native.js +6 -14
  109. package/dist/esm/shared/utils.native.js.map +1 -1
  110. package/dist/jsx/Button/index.mjs +6 -9
  111. package/dist/jsx/Button/index.mjs.map +1 -1
  112. package/dist/jsx/Button/index.native.js +6 -10
  113. package/dist/jsx/Button/index.native.js.map +1 -1
  114. package/dist/jsx/ContextualSaveBar/UnsavedChangesDialog.mjs +0 -1
  115. package/dist/jsx/ContextualSaveBar/UnsavedChangesDialog.mjs.map +1 -1
  116. package/dist/jsx/ContextualSaveBar/UnsavedChangesDialog.native.js +0 -1
  117. package/dist/jsx/ContextualSaveBar/UnsavedChangesDialog.native.js.map +1 -1
  118. package/dist/jsx/FieldDisplay/index.mjs +2 -1
  119. package/dist/jsx/FieldDisplay/index.mjs.map +1 -1
  120. package/dist/jsx/FieldDisplay/index.native.js +9 -2
  121. package/dist/jsx/FieldDisplay/index.native.js.map +1 -1
  122. package/dist/jsx/FloatingPanel/index.mjs +8 -1
  123. package/dist/jsx/FloatingPanel/index.mjs.map +1 -1
  124. package/dist/jsx/InputParts/index.mjs +64 -2
  125. package/dist/jsx/InputParts/index.mjs.map +1 -1
  126. package/dist/jsx/InputParts/index.native.js +70 -4
  127. package/dist/jsx/InputParts/index.native.js.map +1 -1
  128. package/dist/jsx/fields/Barcode/BarcodePreview.mjs +15 -9
  129. package/dist/jsx/fields/Barcode/BarcodePreview.mjs.map +1 -1
  130. package/dist/jsx/fields/Barcode/BarcodePreview.native.js +15 -9
  131. package/dist/jsx/fields/Barcode/BarcodePreview.native.js.map +1 -1
  132. package/dist/jsx/fields/Combobox/index.mjs +14 -11
  133. package/dist/jsx/fields/Combobox/index.mjs.map +1 -1
  134. package/dist/jsx/fields/Combobox/index.native.js +16 -11
  135. package/dist/jsx/fields/Combobox/index.native.js.map +1 -1
  136. package/dist/jsx/fields/FileUpload/index.mjs +66 -5
  137. package/dist/jsx/fields/FileUpload/index.mjs.map +1 -1
  138. package/dist/jsx/fields/FileUpload/index.native.js +85 -5
  139. package/dist/jsx/fields/FileUpload/index.native.js.map +1 -1
  140. package/dist/jsx/fields/Geolocation/geoValue.mjs +95 -0
  141. package/dist/jsx/fields/Geolocation/geoValue.mjs.map +1 -0
  142. package/dist/jsx/fields/Geolocation/geoValue.native.js +151 -0
  143. package/dist/jsx/fields/Geolocation/geoValue.native.js.map +1 -0
  144. package/dist/jsx/fields/Geolocation/index.mjs +15 -28
  145. package/dist/jsx/fields/Geolocation/index.mjs.map +1 -1
  146. package/dist/jsx/fields/Geolocation/index.native.js +16 -29
  147. package/dist/jsx/fields/Geolocation/index.native.js.map +1 -1
  148. package/dist/jsx/fields/Icon/index.mjs +1 -0
  149. package/dist/jsx/fields/Icon/index.mjs.map +1 -1
  150. package/dist/jsx/fields/Icon/index.native.js +1 -0
  151. package/dist/jsx/fields/Icon/index.native.js.map +1 -1
  152. package/dist/jsx/fields/Input/index.mjs +177 -98
  153. package/dist/jsx/fields/Input/index.mjs.map +1 -1
  154. package/dist/jsx/fields/Input/index.native.js +83 -4
  155. package/dist/jsx/fields/Input/index.native.js.map +1 -1
  156. package/dist/jsx/fields/MentionInput/index.mjs +6 -1
  157. package/dist/jsx/fields/MentionInput/index.mjs.map +1 -1
  158. package/dist/jsx/fields/MentionInput/index.native.js +6 -1
  159. package/dist/jsx/fields/MentionInput/index.native.js.map +1 -1
  160. package/dist/jsx/fields/OTPInput/index.mjs +7 -3
  161. package/dist/jsx/fields/OTPInput/index.mjs.map +1 -1
  162. package/dist/jsx/fields/OTPInput/index.native.js +7 -2
  163. package/dist/jsx/fields/OTPInput/index.native.js.map +1 -1
  164. package/dist/jsx/fields/PhoneInput/index.mjs.map +1 -1
  165. package/dist/jsx/fields/PhoneInput/index.native.js +1 -0
  166. package/dist/jsx/fields/PhoneInput/index.native.js.map +1 -1
  167. package/dist/jsx/fields/SearchInput/index.mjs.map +1 -1
  168. package/dist/jsx/fields/SearchInput/index.native.js.map +1 -1
  169. package/dist/jsx/shared/utils.mjs +4 -14
  170. package/dist/jsx/shared/utils.mjs.map +1 -1
  171. package/dist/jsx/shared/utils.native.js +6 -14
  172. package/dist/jsx/shared/utils.native.js.map +1 -1
  173. package/package.json +7 -7
  174. package/src/Button/Button.density.spec.tsx +118 -32
  175. package/src/Button/Button.labelInk.spec.tsx +72 -19
  176. package/src/Button/Button.lc71.spec.tsx +10 -3
  177. package/src/Button/Button.stories.tsx +97 -7
  178. package/src/Button/index.tsx +22 -12
  179. package/src/ContextualSaveBar/UnsavedChangesDialog.tsx +0 -1
  180. package/src/FieldDisplay/FieldDisplay.spec.tsx +11 -2
  181. package/src/FieldDisplay/index.tsx +5 -0
  182. package/src/FloatingPanel/index.tsx +22 -1
  183. package/src/FloatingPanel/panelWidthContract.spec.ts +1 -0
  184. package/src/InputParts/index.tsx +100 -3
  185. package/src/fields/Barcode/BarcodePreview.spec.tsx +153 -5
  186. package/src/fields/Barcode/BarcodePreview.stories.tsx +104 -0
  187. package/src/fields/Barcode/BarcodePreview.tsx +47 -15
  188. package/src/fields/ColorPicker/PresetSwatches.spec.tsx +71 -0
  189. package/src/fields/ColorPicker/PresetSwatches.stories.tsx +94 -0
  190. package/src/fields/Combobox/Combobox.spec.tsx +64 -1
  191. package/src/fields/Combobox/index.tsx +22 -11
  192. package/src/fields/FileUpload/FileUpload.spec.tsx +71 -0
  193. package/src/fields/FileUpload/index.tsx +100 -3
  194. package/src/fields/Geolocation/Geolocation.spec.tsx +126 -0
  195. package/src/fields/Geolocation/geoValue.spec.ts +169 -0
  196. package/src/fields/Geolocation/geoValue.ts +149 -0
  197. package/src/fields/Geolocation/index.tsx +15 -24
  198. package/src/fields/Icon/Icon.stories.tsx +1 -1
  199. package/src/fields/Icon/index.tsx +1 -0
  200. package/src/fields/Input/index.tsx +180 -81
  201. package/src/fields/MentionInput/index.tsx +9 -1
  202. package/src/fields/OTPInput/OtpInput.spec.tsx +36 -4
  203. package/src/fields/OTPInput/index.tsx +18 -3
  204. package/src/fields/OTPInput/otpCellHeightTouch.spec.ts +44 -0
  205. package/src/fields/PhoneInput/index.tsx +1 -0
  206. package/src/fields/SearchInput/index.tsx +5 -3
  207. package/src/shared/utils.spec.ts +46 -0
  208. package/src/shared/utils.ts +31 -16
  209. package/types/Button/index.d.ts +8 -3
  210. package/types/Button/index.d.ts.map +1 -1
  211. package/types/ContextualSaveBar/UnsavedChangesDialog.d.ts.map +1 -1
  212. package/types/FieldDisplay/index.d.ts.map +1 -1
  213. package/types/InputParts/index.d.ts +9 -5
  214. package/types/InputParts/index.d.ts.map +1 -1
  215. package/types/fields/Barcode/BarcodePreview.d.ts +20 -9
  216. package/types/fields/Barcode/BarcodePreview.d.ts.map +1 -1
  217. package/types/fields/Checkbox/index.d.ts +3 -3
  218. package/types/fields/Combobox/index.d.ts.map +1 -1
  219. package/types/fields/FileUpload/index.d.ts.map +1 -1
  220. package/types/fields/Geolocation/geoValue.d.ts +45 -0
  221. package/types/fields/Geolocation/geoValue.d.ts.map +1 -0
  222. package/types/fields/Geolocation/index.d.ts.map +1 -1
  223. package/types/fields/Icon/index.d.ts.map +1 -1
  224. package/types/fields/Input/index.d.ts +18 -6
  225. package/types/fields/Input/index.d.ts.map +1 -1
  226. package/types/fields/MentionInput/index.d.ts.map +1 -1
  227. package/types/fields/OTPInput/index.d.ts +12 -0
  228. package/types/fields/OTPInput/index.d.ts.map +1 -1
  229. package/types/fields/PhoneInput/index.d.ts.map +1 -1
  230. package/types/fields/RadioGroup/index.d.ts +2 -2
  231. package/types/fields/SearchInput/index.d.ts.map +1 -1
  232. package/types/fields/ToggleGroup/index.d.ts +1 -1
  233. package/types/shared/utils.d.ts.map +1 -1
@@ -0,0 +1,71 @@
1
+ /**
2
+ * PresetSwatches spec (MPO-117). The row is a radiogroup of swatches.
3
+ * ColorPicker owns the popover; this file only covers the exported grid.
4
+ */
5
+
6
+ import { fireEvent, screen } from "@testing-library/react";
7
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
8
+ import { describe, expect, it, vi } from "vitest";
9
+ import { PresetSwatches } from "./parts";
10
+
11
+ vi.mock("@tamagui/lucide-icons-2", () => ({
12
+ Palette: () => null,
13
+ X: () => null,
14
+ Check: () => null,
15
+ Pipette: () => null,
16
+ }));
17
+
18
+ const BRAND = ["#1E40AF", "#3B82F6", "#BFDBFE"];
19
+
20
+ describe("PresetSwatches", () => {
21
+ it("renders a radiogroup of radios for each preset", () => {
22
+ renderWithProviders(
23
+ <PresetSwatches presetColors={BRAND} selectedColor="#3B82F6" onSelect={vi.fn()} />,
24
+ );
25
+ expect(screen.getByRole("radiogroup", { name: "Color presets" })).toBeTruthy();
26
+ const radios = screen.getAllByRole("radio");
27
+ expect(radios).toHaveLength(BRAND.length);
28
+ });
29
+
30
+ it("marks the selected hex aria-checked", () => {
31
+ renderWithProviders(
32
+ <PresetSwatches presetColors={BRAND} selectedColor="#3B82F6" onSelect={vi.fn()} />,
33
+ );
34
+ const selected = screen.getByRole("radio", { name: "Color #3B82F6" });
35
+ expect(selected.getAttribute("aria-checked")).toBe("true");
36
+ const other = screen.getByRole("radio", { name: "Color #1E40AF" });
37
+ expect(other.getAttribute("aria-checked")).toBe("false");
38
+ });
39
+
40
+ it("onSelect fires with the hex of the pressed swatch", () => {
41
+ const onSelect = vi.fn();
42
+ renderWithProviders(
43
+ <PresetSwatches presetColors={BRAND} selectedColor={null} onSelect={onSelect} />,
44
+ );
45
+ fireEvent.click(screen.getByRole("radio", { name: "Color #1E40AF" }));
46
+ expect(onSelect).toHaveBeenCalledWith("#1E40AF");
47
+ });
48
+
49
+ it("empty presetColors renders nothing", () => {
50
+ const { container } = renderWithProviders(
51
+ <PresetSwatches presetColors={[]} selectedColor={null} onSelect={vi.fn()} />,
52
+ );
53
+ expect(container.querySelector('[role="radiogroup"]')).toBeNull();
54
+ expect(container.textContent).toBe("");
55
+ });
56
+
57
+ it("null entry is the no-color radio and reports null", () => {
58
+ const onSelect = vi.fn();
59
+ renderWithProviders(
60
+ <PresetSwatches
61
+ presetColors={[{ name: "None", value: null }, "#3B82F6"]}
62
+ selectedColor={null}
63
+ onSelect={onSelect}
64
+ />,
65
+ );
66
+ const none = screen.getByRole("radio", { name: "None" });
67
+ expect(none.getAttribute("aria-checked")).toBe("true");
68
+ fireEvent.click(screen.getByRole("radio", { name: "Color #3B82F6" }));
69
+ expect(onSelect).toHaveBeenCalledWith("#3B82F6");
70
+ });
71
+ });
@@ -0,0 +1,94 @@
1
+ /**
2
+ * PresetSwatches — the color-preset row ColorPicker embeds. Exported from
3
+ * the forms barrel, previously invisible in Kitchen Sink because it had no
4
+ * story of its own.
5
+ */
6
+
7
+ import { action } from "@multiplatform.one/storybook";
8
+ import type { Meta, StoryObj } from "@storybook/react-native-web-vite";
9
+ import { useState } from "react";
10
+ import { Text, YStack } from "tamagui";
11
+ import { PresetSwatches } from "./parts";
12
+
13
+ const BRAND = ["#1E40AF", "#1D4ED8", "#2563EB", "#3B82F6", "#60A5FA", "#93C5FD", "#BFDBFE"];
14
+
15
+ const meta: Meta<typeof PresetSwatches> = {
16
+ title: "Forms/PresetSwatches",
17
+ component: PresetSwatches,
18
+ parameters: {
19
+ status: { type: "stable" },
20
+ docs: {
21
+ description: {
22
+ component:
23
+ "Radiogroup of preset color swatches. Named entries, hex strings, and a null 'no color' chip.",
24
+ },
25
+ },
26
+ },
27
+ };
28
+
29
+ export default meta;
30
+ type Story = StoryObj<typeof PresetSwatches>;
31
+
32
+ export const Main: Story = {
33
+ name: "Main",
34
+ render: () => {
35
+ const Example = () => {
36
+ const [selected, setSelected] = useState<string | null>(BRAND[3]);
37
+ return (
38
+ <YStack gap="$3" padding="$4" width={280}>
39
+ <PresetSwatches
40
+ presetColors={BRAND}
41
+ selectedColor={selected}
42
+ onSelect={(next) => {
43
+ setSelected(next);
44
+ action("onSelect")(next);
45
+ }}
46
+ />
47
+ <Text fontSize="$2" color="$color10">
48
+ {selected ?? "none"}
49
+ </Text>
50
+ </YStack>
51
+ );
52
+ };
53
+ return <Example />;
54
+ },
55
+ };
56
+
57
+ export const Selected: Story = {
58
+ render: () => (
59
+ <YStack padding="$4" width={280}>
60
+ <PresetSwatches presetColors={BRAND} selectedColor="#3B82F6" onSelect={action("onSelect")} />
61
+ </YStack>
62
+ ),
63
+ };
64
+
65
+ /** Null entry is the "no color" chip with the diagonal strike. */
66
+ export const NoColor: Story = {
67
+ render: () => {
68
+ const Example = () => {
69
+ const [selected, setSelected] = useState<string | null>(null);
70
+ return (
71
+ <YStack gap="$3" padding="$4" width={280}>
72
+ <PresetSwatches
73
+ presetColors={[{ name: "None", value: null }, ...BRAND]}
74
+ selectedColor={selected}
75
+ onSelect={setSelected}
76
+ />
77
+ </YStack>
78
+ );
79
+ };
80
+ return <Example />;
81
+ },
82
+ };
83
+
84
+ /** Empty list paints nothing; the host owns the empty marker. */
85
+ export const Empty: Story = {
86
+ render: () => (
87
+ <YStack padding="$4">
88
+ <Text fontSize="$2" color="$color10">
89
+ empty presetColors — row is null
90
+ </Text>
91
+ <PresetSwatches presetColors={[]} selectedColor={null} onSelect={action("onSelect")} />
92
+ </YStack>
93
+ ),
94
+ };
@@ -1,5 +1,5 @@
1
1
  import { act, fireEvent } from "@testing-library/react";
2
- import { describe, expect, it, vi } from "vitest";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
3
  import { renderWithProviders } from "@multiplatform.one/test-utils";
4
4
  import { Form } from "../../Form";
5
5
  import { Combobox } from "./index";
@@ -313,5 +313,68 @@ describe("Combobox", () => {
313
313
  expect(activeOption(result.container)?.textContent).toContain("Carrot");
314
314
  expect(activeOption(result.container)?.getAttribute("role")).toBe("option");
315
315
  });
316
+
317
+ // The sheet core (<= OVERLAY_BREAKPOINT, and every native render) used to
318
+ // build headers itself from source order, so interleaved groups emitted a
319
+ // REPEAT header and the rows stayed unclustered — two different lists from
320
+ // one set of props. Both cores now go through groupComboboxOptions.
321
+ it("the sheet renders the same headers and row order as the desktop panel", async () => {
322
+ function shot(root: HTMLElement) {
323
+ return {
324
+ headers: [...root.querySelectorAll("[data-combobox-group-header]")].map(
325
+ (el) => el.textContent,
326
+ ),
327
+ rows: [...root.querySelectorAll('[role="option"]')].map((el) => ({
328
+ label: el.textContent,
329
+ index: el.getAttribute("data-combobox-index"),
330
+ })),
331
+ };
332
+ }
333
+
334
+ window.innerWidth = 1200;
335
+ const desktop = renderWithProviders(
336
+ <Combobox label="Produce" name="produce" options={groupedOptions} multiple open />,
337
+ );
338
+ await act(async () => {});
339
+ const onDesktop = shot(document.body);
340
+ desktop.unmount();
341
+
342
+ window.innerWidth = 500;
343
+ renderWithProviders(
344
+ <Combobox label="Produce" name="produce" options={groupedOptions} multiple open />,
345
+ );
346
+ await act(async () => {});
347
+ const onSheet = shot(document.body);
348
+
349
+ expect(onDesktop.headers).toEqual(["Fruit", "Vegetable"]);
350
+ expect(onSheet.headers).toEqual(onDesktop.headers);
351
+ expect(onSheet.rows).toEqual(onDesktop.rows);
352
+ // One header per group: a repeated label is the defect this guards.
353
+ expect(new Set(onSheet.headers).size).toBe(onSheet.headers.length);
354
+ });
355
+
356
+ // The sheet's activeIndex indexes the same grouped list the sheet renders,
357
+ // so arrows land on the row the user sees rather than the source-order row.
358
+ it("sheet ArrowDown follows the rendered row order, not source order", async () => {
359
+ window.innerWidth = 500;
360
+ renderWithProviders(
361
+ <Combobox label="Produce" name="produce" options={groupedOptions} multiple open />,
362
+ );
363
+ await act(async () => {});
364
+
365
+ const rows = [...document.body.querySelectorAll('[role="option"]')];
366
+ expect(rows.map((el) => el.textContent?.trim())).toEqual(["Apple", "Banana", "Carrot"]);
367
+
368
+ const search = document.body.querySelector("input");
369
+ expect(search).toBeTruthy();
370
+ // activeIndex starts at 0 (Apple); one ArrowDown must highlight the row
371
+ // rendered second (Banana), which is sortedFiltered[2], not [1].
372
+ await act(async () => {
373
+ fireEvent.keyDown(search!, { key: "ArrowDown" });
374
+ });
375
+ const active = document.body.querySelector('[data-active="true"]');
376
+ expect(active?.textContent).toContain("Banana");
377
+ expect(active?.getAttribute("data-combobox-index")).toBe("1");
378
+ });
316
379
  });
317
380
  });
@@ -1918,6 +1918,17 @@ function NativeComboboxCore({
1918
1918
  return orderOptionsBySelected(filtered, current, selectedOrder);
1919
1919
  }, [filtered, value, filter, selectedOrder]);
1920
1920
 
1921
+ // Same grouping the floating core uses (`groupComboboxOptions`, the mechanism
1922
+ // mpo-floating-measured.md §2.3 names as ours): rows cluster by group so each
1923
+ // group gets exactly ONE header. Rendering source order here emitted a repeat
1924
+ // header for interleaved groups and disagreed with the desktop panel for
1925
+ // identical props. `activeIndex` indexes `navigableOptions`, which IS the
1926
+ // render order, so arrows still land on the row the user sees (MPO-38 AC-6).
1927
+ const { flatOptions: navigableOptions } = useMemo(
1928
+ () => groupComboboxOptions(sortedFiltered),
1929
+ [sortedFiltered],
1930
+ );
1931
+
1921
1932
  const searchCleanupRef = useRef<(() => void) | null>(null);
1922
1933
  useEffect(() => {
1923
1934
  if (!onSearch || !open) return;
@@ -1942,8 +1953,8 @@ function NativeComboboxCore({
1942
1953
  }
1943
1954
  }, [open]);
1944
1955
 
1945
- const totalItems = sortedFiltered.length + (showCreateOption ? 1 : 0);
1946
- const createOptionIndex = showCreateOption ? sortedFiltered.length : -1;
1956
+ const totalItems = navigableOptions.length + (showCreateOption ? 1 : 0);
1957
+ const createOptionIndex = showCreateOption ? navigableOptions.length : -1;
1947
1958
  const listboxId = comboboxListboxId(id);
1948
1959
  const activeDescendant =
1949
1960
  open && activeIndex != null && activeIndex >= 0 ? comboboxOptionId(id, activeIndex) : undefined;
@@ -2028,8 +2039,8 @@ function NativeComboboxCore({
2028
2039
  e.preventDefault();
2029
2040
  if (activeIndex === createOptionIndex && showCreateOption) {
2030
2041
  handleCreate();
2031
- } else if (activeIndex != null && activeIndex >= 0 && sortedFiltered[activeIndex]) {
2032
- handleSelect(sortedFiltered[activeIndex]);
2042
+ } else if (activeIndex != null && activeIndex >= 0 && navigableOptions[activeIndex]) {
2043
+ handleSelect(navigableOptions[activeIndex]);
2033
2044
  if (e.key === "Enter" || !multiple) setOpen(false);
2034
2045
  }
2035
2046
  }
@@ -2039,7 +2050,7 @@ function NativeComboboxCore({
2039
2050
  createOptionIndex,
2040
2051
  showCreateOption,
2041
2052
  activeIndex,
2042
- sortedFiltered,
2053
+ navigableOptions,
2043
2054
  handleCreate,
2044
2055
  handleSelect,
2045
2056
  multiple,
@@ -2066,8 +2077,8 @@ function NativeComboboxCore({
2066
2077
  e.stopPropagation();
2067
2078
  if (activeIndex === createOptionIndex && showCreateOption) {
2068
2079
  handleCreate();
2069
- } else if (activeIndex != null && activeIndex >= 0 && sortedFiltered[activeIndex]) {
2070
- handleSelect(sortedFiltered[activeIndex]);
2080
+ } else if (activeIndex != null && activeIndex >= 0 && navigableOptions[activeIndex]) {
2081
+ handleSelect(navigableOptions[activeIndex]);
2071
2082
  if (e.key === "Enter" || !multiple) setOpen(false);
2072
2083
  } else {
2073
2084
  setOpen(false);
@@ -2081,7 +2092,7 @@ function NativeComboboxCore({
2081
2092
  activeIndex,
2082
2093
  createOptionIndex,
2083
2094
  showCreateOption,
2084
- sortedFiltered,
2095
+ navigableOptions,
2085
2096
  handleCreate,
2086
2097
  handleSelect,
2087
2098
  multiple,
@@ -2138,12 +2149,12 @@ function NativeComboboxCore({
2138
2149
  {searchError}
2139
2150
  </Text>
2140
2151
  </View>
2141
- ) : sortedFiltered.length > 0 ? (
2152
+ ) : navigableOptions.length > 0 ? (
2142
2153
  <>
2143
- {sortedFiltered.map((option, i) => {
2154
+ {navigableOptions.map((option, i) => {
2144
2155
  const selected = isSelectedFn(option.value);
2145
2156
  const active = i === activeIndex;
2146
- const prevGroup = i > 0 ? sortedFiltered[i - 1].group : undefined;
2157
+ const prevGroup = i > 0 ? navigableOptions[i - 1].group : undefined;
2147
2158
  const showGroupHeader = option.group && option.group !== prevGroup;
2148
2159
  const optionRow = (
2149
2160
  <View
@@ -134,6 +134,34 @@ describe("FileUpload", () => {
134
134
  expect(dropzone?.getAttribute("data-kb-ring")).toBe("true");
135
135
  });
136
136
 
137
+ // MPO-57 regression. `data-kb-ring="true"` proved the STATE, never the paint.
138
+ // The frame's LC-71 kill is a `:focus-visible` rule; a keyboard-origin focus
139
+ // matches `:focus-visible` too, so the base-state ring atom lost on
140
+ // specificity and the browser computed outline-width 0px while every
141
+ // assertion passed. Measured on the Storybook harness, not read off source.
142
+ // The ring must therefore also win INSIDE the focus-visible layer.
143
+ it("beats its own LC-71 focus-visible kill — the ring wins in the layer that suppresses it", () => {
144
+ const { container } = renderWithHouseThemes(
145
+ <FileUpload label="Attachment" name="attachment" />,
146
+ );
147
+ const zone = getZone(container);
148
+ fireEvent.keyDown(document.body, { key: "Tab", code: "Tab" });
149
+ fireEvent.focus(zone);
150
+ expect(zone.getAttribute("data-kb-ring")).toBe("true");
151
+
152
+ // Base-state ring (2px solid $outlineColor at offset 0).
153
+ expect(zone.className).toMatch(/_outlineWidth-2px/);
154
+ expect(zone.className).toMatch(/_outlineStyle-solid/);
155
+ expect(zone.className).toMatch(/_outlineOffset-0px/);
156
+
157
+ // …restated in the focus-visible layer, so the kill cannot outrank it.
158
+ expect(zone.className).toMatch(/_outlineWidth-0focus-visible-2px/);
159
+ expect(zone.className).toMatch(/_outlineStyle-0focus-visible-solid/);
160
+ // …and the zeroing atoms are gone from that layer while the ring is up.
161
+ expect(zone.className).not.toMatch(/_outlineWidth-0focus-visible-0px/);
162
+ expect(zone.className).not.toMatch(/_outlineStyle-0focus-visible-none/);
163
+ });
164
+
137
165
  it("keeps Choose File out of the tab order so the dropzone is the one stop", () => {
138
166
  const result = renderWithProviders(
139
167
  <FileUpload label="Attachment" name="attachment" buttonText="Pick file" />,
@@ -319,6 +347,49 @@ describe("FileUpload bento-derived dropzone (MPO-57)", () => {
319
347
  const thumb = flist.querySelector("img") as HTMLImageElement;
320
348
  expect(thumb).toBeTruthy();
321
349
  expect(thumb.getAttribute("alt")).toBe("diagram.png");
350
+
351
+ // AC-4 image clause (Bento Image Picker): an image pick is a TILE in the
352
+ // in-zone grid, not a row — the thumb IS the tile, and the remove disc
353
+ // rides its top-right corner (absolute, negative inset) instead of
354
+ // trailing on a row. Same 32/hitSlop-44 disc the rows carry.
355
+ const grid = flist.querySelector('[data-file-upload="tiles"]') as HTMLElement;
356
+ expect(grid).toBeTruthy();
357
+ const tile = grid.querySelector('[data-file-upload="tile"]') as HTMLElement;
358
+ expect(tile).toBeTruthy();
359
+ expect(tile.contains(thumb)).toBe(true);
360
+ const remove = tile.querySelector('[aria-label="Remove diagram.png"]') as HTMLElement;
361
+ expect(remove).toBeTruthy();
362
+ expect(remove.getAttribute("data-nested-px")).toBe("32");
363
+ expect(remove.getAttribute("data-mp-press-slop")).toBe("6");
364
+ const corner = tile.querySelector('[data-file-upload="tile-remove"]') as HTMLElement;
365
+ expect(corner).toBeTruthy();
366
+ expect(corner.contains(remove)).toBe(true);
367
+ expect(corner.className).toMatch(/_pos-absolute/);
368
+ // No size line inside a tile: Bento's populated Image Picker is thumbs only.
369
+ expect(tile.textContent ?? "").not.toMatch(/Bytes|KB|MB/);
370
+ });
371
+
372
+ it("keeps non-image picks as rows while images tile — one list, two treatments", async () => {
373
+ if (typeof URL.createObjectURL !== "function") {
374
+ (URL as any).createObjectURL = () => "blob:mock";
375
+ (URL as any).revokeObjectURL = () => {};
376
+ }
377
+ const { container } = renderWithHouseThemes(
378
+ <FileUpload label="Attachment" name="attachment" multiple showPreview />,
379
+ );
380
+ await pickFile(container, new File(["fake-png-bytes"], "diagram.png", { type: "image/png" }));
381
+ await pickFile(container, new File(["hello"], "notes.txt", { type: "text/plain" }));
382
+
383
+ const zone = getZone(container);
384
+ const flist = zone.querySelector('[data-file-upload="files"]') as HTMLElement;
385
+ const tiles = flist.querySelectorAll('[data-file-upload="tile"]');
386
+ expect(tiles.length).toBe(1);
387
+ // The text file is a row with its bold size line and trailing disc, outside the grid.
388
+ const grid = flist.querySelector('[data-file-upload="tiles"]') as HTMLElement;
389
+ const rowRemove = flist.querySelector('[aria-label="Remove notes.txt"]') as HTMLElement;
390
+ expect(rowRemove).toBeTruthy();
391
+ expect(grid.contains(rowRemove)).toBe(false);
392
+ expect(flist.textContent).toContain("notes.txt");
322
393
  });
323
394
  });
324
395
 
@@ -133,6 +133,22 @@ const UploadAreaFrame = styled(YStack, {
133
133
  focusVisibleStyle: { outlineWidth: 0, outlineStyle: "none" as any },
134
134
  });
135
135
 
136
+ /**
137
+ * The house ring, applied so it survives the frame's own LC-71 kill.
138
+ *
139
+ * The kill above is what suppresses the 1px UA outline on a POINTER focus,
140
+ * and it has to stay. But it is a `:focus-visible` rule, and a keyboard-origin
141
+ * focus matches `:focus-visible` too — so spreading the ring as base props
142
+ * only loses on specificity and the zone paints no ring at all. Restating the
143
+ * same fragment in `focusStyle`/`focusVisibleStyle` beats the kill in exactly
144
+ * the state that should carry a ring, and leaves pointer focus alone (the
145
+ * caller only spreads this while `kbZoneFocus` is true).
146
+ */
147
+ function withZoneFocusRing() {
148
+ const ring = ensureFocusVisibleRing({ outlineOffset: 0 });
149
+ return { ...ring, focusStyle: ring, focusVisibleStyle: ring };
150
+ }
151
+
136
152
  const DropOverlayFrame = styled(YStack, {
137
153
  position: "absolute",
138
154
  top: 0,
@@ -158,6 +174,14 @@ const UploadFileListFrame = styled(YStack, {
158
174
  borderTopColor: formDropzoneColors.border,
159
175
  });
160
176
 
177
+ // BENTO-DERIVED (Image Picker): picked IMAGES populate a tile grid, not the
178
+ // file-row list — each tile is the thumb itself with the remove disc riding
179
+ // its top-right corner. Non-image picks and in-flight transfers keep the row.
180
+ const UploadTileGridFrame = styled(XStack, {
181
+ width: "100%",
182
+ flexWrap: "wrap",
183
+ });
184
+
161
185
  export function FileUpload<
162
186
  TParentData,
163
187
  TName extends DeepKeys<TParentData>,
@@ -228,6 +252,13 @@ export function FileUpload<
228
252
  // Intentional: extract the recipe icon step (16 at the 44 control) for the
229
253
  // row file glyph — same held "icon 16" exception the CTA carries.
230
254
  const rowGlyphSize = knobProps.controlIcon.width;
255
+ // Intentional: the image TILE edge is two control steps (88 at medium) —
256
+ // the same getFieldHeight ladder the 220 frame floor sits on, so tile and
257
+ // frame move together per size. The corner remove disc hangs a quarter of
258
+ // its own nestedControl step (8 at medium) outside the tile's top-right,
259
+ // which is the Bento Image Picker overlap read under the house tokens.
260
+ const tilePx = getFieldHeight(knobProps.sizeToken as SizeTokens, 2);
261
+ const tileRemoveInset = -Math.round(knobProps.nestedControl.px / 4);
231
262
 
232
263
  const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
233
264
  const [transfers, setTransfers] = useState<TransferItem[]>([]);
@@ -722,6 +753,44 @@ export function FileUpload<
722
753
  </XStack>
723
754
  );
724
755
 
756
+ const renderImageTile = (
757
+ file: File,
758
+ preview: string,
759
+ onValueChange?: (nextValue: TData) => void,
760
+ ) => (
761
+ <YStack
762
+ key={`${file.name}-${file.size}-${file.lastModified}`}
763
+ data-file-upload="tile"
764
+ position="relative"
765
+ width={tilePx}
766
+ height={tilePx}
767
+ >
768
+ <Image
769
+ borderRadius={cappedRadius}
770
+ // src (string), not source: the RN source object leaks
771
+ // onto the DOM <img> as source="[object Object]" on web
772
+ src={preview}
773
+ alt={file.name}
774
+ width={tilePx}
775
+ height={tilePx}
776
+ objectFit="cover"
777
+ />
778
+ {canInteract ? (
779
+ <YStack
780
+ data-file-upload="tile-remove"
781
+ position="absolute"
782
+ top={tileRemoveInset}
783
+ right={tileRemoveInset}
784
+ zIndex={1}
785
+ >
786
+ {renderRemoveDisc(t("Remove {{name}}", { name: file.name }), () =>
787
+ handleFileRemove(file, onValueChange),
788
+ )}
789
+ </YStack>
790
+ ) : null}
791
+ </YStack>
792
+ );
793
+
725
794
  const renderUploadArea = (
726
795
  errorProp?: string | boolean,
727
796
  onValueChange?: (nextValue: TData) => void,
@@ -764,6 +833,15 @@ export function FileUpload<
764
833
  : ({ role: "button", onPress: requestFiles } as any)
765
834
  : { cursor: disabled ? "not-allowed" : undefined };
766
835
 
836
+ // Committed image picks become tiles; everything else stays a row.
837
+ const imageTiles: { file: File; preview: string }[] = [];
838
+ const fileRows: { file: File; preview: string | null; index: number }[] = [];
839
+ selectedFiles.forEach((file, index) => {
840
+ const preview = showPreview ? getFilePreview(file) : null;
841
+ if (preview) imageTiles.push({ file, preview });
842
+ else fileRows.push({ file, preview, index });
843
+ });
844
+
767
845
  return (
768
846
  <UploadAreaFrame
769
847
  // The dropzone is this field's control: it carries the FieldLayout id
@@ -796,7 +874,16 @@ export function FileUpload<
796
874
  // One family ring rule (boards media-01 dz-ring): 2px $outlineColor at
797
875
  // offset 0. The old -2 inset was the F1 defect — an element's own
798
876
  // outline is never clipped by its own overflow:hidden.
799
- {...(kbZoneFocus ? ensureFocusVisibleRing({ outlineOffset: 0 }) : undefined)}
877
+ //
878
+ // The ring must ALSO be spread into focusStyle/focusVisibleStyle. The
879
+ // base props alone lose: the frame's LC-71 kill is a :focus-visible
880
+ // rule, the browser matches :focus-visible on a keyboard-origin focus,
881
+ // and a pseudo-class rule outranks the plain-class atom — the zone
882
+ // carried `_outlineWidth-2px` AND `_outlineWidth-0focus-visible-0px`
883
+ // and computed outline-width 0px. Measured on the harness, not read off
884
+ // the source: the pre-fix spec asserted the atom's PRESENCE, which was
885
+ // true while nothing painted.
886
+ {...(kbZoneFocus ? withZoneFocusRing() : undefined)}
800
887
  hoverStyle={
801
888
  canInteract
802
889
  ? {
@@ -903,8 +990,18 @@ export function FileUpload<
903
990
  // The hairline tracks the frame edge's knob width — one dash voice.
904
991
  borderTopWidth={knobProps.borderRadius.borderWidth ?? 1}
905
992
  >
906
- {selectedFiles.map((file, index) => {
907
- const preview = showPreview ? getFilePreview(file) : null;
993
+ {imageTiles.length > 0 ? (
994
+ <UploadTileGridFrame
995
+ data-file-upload="tiles"
996
+ {...knobProps.gap}
997
+ {...knobProps.panelPadding}
998
+ >
999
+ {imageTiles.map(({ file, preview }) =>
1000
+ renderImageTile(file, preview, onValueChange),
1001
+ )}
1002
+ </UploadTileGridFrame>
1003
+ ) : null}
1004
+ {fileRows.map(({ file, preview, index }) => {
908
1005
  return (
909
1006
  <XStack
910
1007
  key={`${file.name}-${index}`}