@multiplatform.one/forms 7.6.0 → 7.6.2

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 (211) hide show
  1. package/dist/cjs/fields/FileUpload/index.cjs +55 -2
  2. package/dist/cjs/fields/FileUpload/index.native.js +65 -2
  3. package/dist/cjs/fields/FileUpload/index.native.js.map +1 -1
  4. package/dist/cjs/fields/Geolocation/geoValue.cjs +122 -0
  5. package/dist/cjs/fields/Geolocation/geoValue.native.js +151 -0
  6. package/dist/cjs/fields/Geolocation/geoValue.native.js.map +1 -0
  7. package/dist/cjs/fields/Geolocation/index.cjs +16 -29
  8. package/dist/cjs/fields/Geolocation/index.native.js +16 -29
  9. package/dist/cjs/fields/Geolocation/index.native.js.map +1 -1
  10. package/dist/esm/fields/FileUpload/index.mjs +55 -2
  11. package/dist/esm/fields/FileUpload/index.mjs.map +1 -1
  12. package/dist/esm/fields/FileUpload/index.native.js +65 -2
  13. package/dist/esm/fields/FileUpload/index.native.js.map +1 -1
  14. package/dist/esm/fields/Geolocation/geoValue.mjs +95 -0
  15. package/dist/esm/fields/Geolocation/geoValue.mjs.map +1 -0
  16. package/dist/esm/fields/Geolocation/geoValue.native.js +121 -0
  17. package/dist/esm/fields/Geolocation/geoValue.native.js.map +1 -0
  18. package/dist/esm/fields/Geolocation/index.mjs +15 -28
  19. package/dist/esm/fields/Geolocation/index.mjs.map +1 -1
  20. package/dist/esm/fields/Geolocation/index.native.js +15 -28
  21. package/dist/esm/fields/Geolocation/index.native.js.map +1 -1
  22. package/dist/jsx/AdaptivePopup/index.native.js +1 -1
  23. package/dist/jsx/AdaptivePopup/index.native.js.map +1 -1
  24. package/dist/jsx/Button/index.native.js +1 -1
  25. package/dist/jsx/Button/index.native.js.map +1 -1
  26. package/dist/jsx/Chip/index.native.js.map +1 -1
  27. package/dist/jsx/ContextualSaveBar/UnsavedChangesDialog.native.js +1 -1
  28. package/dist/jsx/ContextualSaveBar/UnsavedChangesDialog.native.js.map +1 -1
  29. package/dist/jsx/ContextualSaveBar/clientKnownIdentity.native.js.map +1 -1
  30. package/dist/jsx/ContextualSaveBar/commitImmediateField.native.js.map +1 -1
  31. package/dist/jsx/ContextualSaveBar/index.native.js +3 -3
  32. package/dist/jsx/ContextualSaveBar/index.native.js.map +1 -1
  33. package/dist/jsx/ContextualSaveBar/useUnsavedChangesGuard.native.js.map +1 -1
  34. package/dist/jsx/ControlGroup/index.native.js +1 -1
  35. package/dist/jsx/ControlGroup/index.native.js.map +1 -1
  36. package/dist/jsx/DescriptionList/index.native.js +2 -2
  37. package/dist/jsx/DescriptionList/index.native.js.map +1 -1
  38. package/dist/jsx/ErrorSummary.native.js.map +1 -1
  39. package/dist/jsx/FieldDisplay/editMode.native.js.map +1 -1
  40. package/dist/jsx/FieldDisplay/formatters.native.js.map +1 -1
  41. package/dist/jsx/FieldDisplay/index.native.js +1 -1
  42. package/dist/jsx/FieldDisplay/index.native.js.map +1 -1
  43. package/dist/jsx/FieldGroup.native.js.map +1 -1
  44. package/dist/jsx/FloatingPanel/useFloatingPanel.native.js.map +1 -1
  45. package/dist/jsx/Form/index.native.js +1 -1
  46. package/dist/jsx/Form/index.native.js.map +1 -1
  47. package/dist/jsx/Form/validateOn.native.js.map +1 -1
  48. package/dist/jsx/FormGrid.native.js.map +1 -1
  49. package/dist/jsx/FormSection.native.js.map +1 -1
  50. package/dist/jsx/InputParts/index.native.js.map +1 -1
  51. package/dist/jsx/Label/index.native.js.map +1 -1
  52. package/dist/jsx/Skeleton/index.native.js.map +1 -1
  53. package/dist/jsx/Spinner/SpinnerGlyph.native.js.map +1 -1
  54. package/dist/jsx/Spinner/spinnerSize.native.js.map +1 -1
  55. package/dist/jsx/Table/index.native.js.map +1 -1
  56. package/dist/jsx/Wheel/haptic.native.js.map +1 -1
  57. package/dist/jsx/Wheel/index.native.js.map +1 -1
  58. package/dist/jsx/actionLabel.native.js.map +1 -1
  59. package/dist/jsx/fieldDefaults.native.js.map +1 -1
  60. package/dist/jsx/fieldLayout.native.js +1 -1
  61. package/dist/jsx/fieldLayout.native.js.map +1 -1
  62. package/dist/jsx/fields/Barcode/BarcodePreview.native.js.map +1 -1
  63. package/dist/jsx/fields/Barcode/BarcodeScanner.native.js +1 -1
  64. package/dist/jsx/fields/Barcode/code128.native.js.map +1 -1
  65. package/dist/jsx/fields/Barcode/index.native.js +2 -2
  66. package/dist/jsx/fields/Barcode/index.native.js.map +1 -1
  67. package/dist/jsx/fields/Barcode/qr.native.js.map +1 -1
  68. package/dist/jsx/fields/Barcode/scannerTypes.native.js.map +1 -1
  69. package/dist/jsx/fields/Checkbox/Checkbox.native.js +1 -1
  70. package/dist/jsx/fields/Checkbox/Checkbox.native.js.map +1 -1
  71. package/dist/jsx/fields/Checkbox/CheckboxBox.native.js.map +1 -1
  72. package/dist/jsx/fields/Checkbox/CheckboxCard.native.js.map +1 -1
  73. package/dist/jsx/fields/Checkbox/CheckboxFieldFrame.native.js.map +1 -1
  74. package/dist/jsx/fields/Checkbox/CheckboxGroup.native.js +1 -1
  75. package/dist/jsx/fields/Checkbox/CheckboxGroup.native.js.map +1 -1
  76. package/dist/jsx/fields/Checkbox/index.native.js.map +1 -1
  77. package/dist/jsx/fields/ColorPicker/index.native.js +3 -3
  78. package/dist/jsx/fields/ColorPicker/index.native.js.map +1 -1
  79. package/dist/jsx/fields/ColorPicker/parts.native.js +2 -2
  80. package/dist/jsx/fields/ColorPicker/parts.native.js.map +1 -1
  81. package/dist/jsx/fields/Combobox/authorOptions.native.js.map +1 -1
  82. package/dist/jsx/fields/Combobox/index.native.js +5 -5
  83. package/dist/jsx/fields/Combobox/index.native.js.map +1 -1
  84. package/dist/jsx/fields/DatePicker/Calendar.native.js.map +1 -1
  85. package/dist/jsx/fields/DatePicker/DateRangePicker.native.js +2 -2
  86. package/dist/jsx/fields/DatePicker/DateRangePicker.native.js.map +1 -1
  87. package/dist/jsx/fields/DatePicker/DatetimePicker.native.js +3 -3
  88. package/dist/jsx/fields/DatePicker/DatetimePicker.native.js.map +1 -1
  89. package/dist/jsx/fields/DatePicker/MonthPicker.native.js +2 -2
  90. package/dist/jsx/fields/DatePicker/MonthPicker.native.js.map +1 -1
  91. package/dist/jsx/fields/DatePicker/MultiDatePicker.native.js +2 -2
  92. package/dist/jsx/fields/DatePicker/MultiDatePicker.native.js.map +1 -1
  93. package/dist/jsx/fields/DatePicker/fieldValueSync.native.js.map +1 -1
  94. package/dist/jsx/fields/DatePicker/index.native.js +2 -2
  95. package/dist/jsx/fields/DatePicker/index.native.js.map +1 -1
  96. package/dist/jsx/fields/DatePicker/parts.native.js +2 -2
  97. package/dist/jsx/fields/DatePicker/parts.native.js.map +1 -1
  98. package/dist/jsx/fields/DatePicker/utils.native.js.map +1 -1
  99. package/dist/jsx/fields/Duration/durationMath.native.js.map +1 -1
  100. package/dist/jsx/fields/Duration/index.native.js +3 -3
  101. package/dist/jsx/fields/Duration/index.native.js.map +1 -1
  102. package/dist/jsx/fields/FileUpload/filePickerTypes.native.js.map +1 -1
  103. package/dist/jsx/fields/FileUpload/index.mjs +55 -2
  104. package/dist/jsx/fields/FileUpload/index.mjs.map +1 -1
  105. package/dist/jsx/fields/FileUpload/index.native.js +67 -4
  106. package/dist/jsx/fields/FileUpload/index.native.js.map +1 -1
  107. package/dist/jsx/fields/FileUpload/nativePickerLoader.native.js.map +1 -1
  108. package/dist/jsx/fields/Geolocation/expoMapsLoader.native.js.map +1 -1
  109. package/dist/jsx/fields/Geolocation/geoValue.mjs +95 -0
  110. package/dist/jsx/fields/Geolocation/geoValue.mjs.map +1 -0
  111. package/dist/jsx/fields/Geolocation/geoValue.native.js +151 -0
  112. package/dist/jsx/fields/Geolocation/geoValue.native.js.map +1 -0
  113. package/dist/jsx/fields/Geolocation/index.mjs +15 -28
  114. package/dist/jsx/fields/Geolocation/index.mjs.map +1 -1
  115. package/dist/jsx/fields/Geolocation/index.native.js +21 -34
  116. package/dist/jsx/fields/Geolocation/index.native.js.map +1 -1
  117. package/dist/jsx/fields/Geolocation/mapMath.native.js.map +1 -1
  118. package/dist/jsx/fields/Geolocation/mapTypes.native.js.map +1 -1
  119. package/dist/jsx/fields/Icon/drawnSet.native.js.map +1 -1
  120. package/dist/jsx/fields/Icon/index.native.js +3 -3
  121. package/dist/jsx/fields/Icon/index.native.js.map +1 -1
  122. package/dist/jsx/fields/ImageField/index.native.js +2 -2
  123. package/dist/jsx/fields/ImageField/index.native.js.map +1 -1
  124. package/dist/jsx/fields/Input/index.native.js +2 -2
  125. package/dist/jsx/fields/Input/index.native.js.map +1 -1
  126. package/dist/jsx/fields/MentionInput/index.native.js +3 -3
  127. package/dist/jsx/fields/MentionInput/index.native.js.map +1 -1
  128. package/dist/jsx/fields/OTPInput/index.native.js +3 -3
  129. package/dist/jsx/fields/OTPInput/index.native.js.map +1 -1
  130. package/dist/jsx/fields/OTPInput/otpCellCss.native.js.map +1 -1
  131. package/dist/jsx/fields/PhoneInput/dialCodes.native.js.map +1 -1
  132. package/dist/jsx/fields/PhoneInput/index.native.js +3 -3
  133. package/dist/jsx/fields/PhoneInput/index.native.js.map +1 -1
  134. package/dist/jsx/fields/PhoneInput/phone.native.js.map +1 -1
  135. package/dist/jsx/fields/Progress/index.native.js +1 -1
  136. package/dist/jsx/fields/Progress/index.native.js.map +1 -1
  137. package/dist/jsx/fields/RadioGroup/index.native.js +1 -1
  138. package/dist/jsx/fields/RadioGroup/index.native.js.map +1 -1
  139. package/dist/jsx/fields/Rating/index.native.js +1 -1
  140. package/dist/jsx/fields/Rating/index.native.js.map +1 -1
  141. package/dist/jsx/fields/Rating/ratingMath.native.js.map +1 -1
  142. package/dist/jsx/fields/Rating/types.native.js.map +1 -1
  143. package/dist/jsx/fields/SearchInput/index.native.js +2 -2
  144. package/dist/jsx/fields/SearchInput/index.native.js.map +1 -1
  145. package/dist/jsx/fields/Select/index.native.js +4 -4
  146. package/dist/jsx/fields/Select/index.native.js.map +1 -1
  147. package/dist/jsx/fields/Signature/index.native.js +2 -2
  148. package/dist/jsx/fields/Signature/index.native.js.map +1 -1
  149. package/dist/jsx/fields/Slider/index.native.js +1 -1
  150. package/dist/jsx/fields/Slider/index.native.js.map +1 -1
  151. package/dist/jsx/fields/Slider/jumpMotion.native.js.map +1 -1
  152. package/dist/jsx/fields/Stepper/index.native.js +2 -2
  153. package/dist/jsx/fields/Stepper/index.native.js.map +1 -1
  154. package/dist/jsx/fields/Stepper/resolveStepperPlacement.native.js.map +1 -1
  155. package/dist/jsx/fields/Switch/index.native.js +2 -2
  156. package/dist/jsx/fields/Switch/index.native.js.map +1 -1
  157. package/dist/jsx/fields/TextArea/index.native.js +2 -2
  158. package/dist/jsx/fields/TextArea/index.native.js.map +1 -1
  159. package/dist/jsx/fields/TimePicker/index.native.js +3 -3
  160. package/dist/jsx/fields/TimePicker/index.native.js.map +1 -1
  161. package/dist/jsx/fields/TimePicker/parts.native.js +1 -1
  162. package/dist/jsx/fields/TimePicker/parts.native.js.map +1 -1
  163. package/dist/jsx/fields/TimeRangeScrubber/geometry.native.js.map +1 -1
  164. package/dist/jsx/fields/TimeRangeScrubber/index.native.js +2 -2
  165. package/dist/jsx/fields/TimeRangeScrubber/index.native.js.map +1 -1
  166. package/dist/jsx/fields/ToggleGroup/index.native.js +1 -1
  167. package/dist/jsx/fields/ToggleGroup/index.native.js.map +1 -1
  168. package/dist/jsx/fields/selectedOrder.native.js.map +1 -1
  169. package/dist/jsx/formSpacing.native.js.map +1 -1
  170. package/dist/jsx/hooks/useFocusManagement.native.js.map +1 -1
  171. package/dist/jsx/index.native.js +43 -43
  172. package/dist/jsx/index.native.js.map +1 -1
  173. package/dist/jsx/requiredMarking.native.js.map +1 -1
  174. package/dist/jsx/shared/TimeColumn.native.js +1 -1
  175. package/dist/jsx/shared/TimeColumn.native.js.map +1 -1
  176. package/dist/jsx/shared/capabilityPreflight.native.js.map +1 -1
  177. package/dist/jsx/shared/colorRamps.native.js.map +1 -1
  178. package/dist/jsx/shared/devWarn.native.js.map +1 -1
  179. package/dist/jsx/shared/floatingList.native.js +1 -1
  180. package/dist/jsx/shared/floatingList.native.js.map +1 -1
  181. package/dist/jsx/shared/focusManagement.native.js.map +1 -1
  182. package/dist/jsx/shared/groupContext.native.js.map +1 -1
  183. package/dist/jsx/shared/i18n.native.js.map +1 -1
  184. package/dist/jsx/shared/t.native.js.map +1 -1
  185. package/dist/jsx/shared/tableCellContext.native.js.map +1 -1
  186. package/dist/jsx/shared/useDragScroll.native.js.map +1 -1
  187. package/dist/jsx/shared/useKeyboardInset.native.js.map +1 -1
  188. package/dist/jsx/shared/useSheetEscapeDismiss.native.js.map +1 -1
  189. package/dist/jsx/shared/useWheelIncrement.native.js.map +1 -1
  190. package/dist/jsx/shared/utils.native.js +1 -1
  191. package/dist/jsx/shared/utils.native.js.map +1 -1
  192. package/dist/jsx/shared/zIndex.native.js.map +1 -1
  193. package/dist/jsx/themedPrimitives.native.js.map +1 -1
  194. package/dist/jsx/types.native.js.map +1 -1
  195. package/dist/jsx/utils/LinkCell.native.js.map +1 -1
  196. package/package.json +7 -7
  197. package/src/Button/Button.stories.tsx +90 -1
  198. package/src/fields/Barcode/BarcodePreview.spec.tsx +14 -0
  199. package/src/fields/Barcode/BarcodePreview.stories.tsx +104 -0
  200. package/src/fields/ColorPicker/PresetSwatches.spec.tsx +71 -0
  201. package/src/fields/ColorPicker/PresetSwatches.stories.tsx +94 -0
  202. package/src/fields/FileUpload/FileUpload.spec.tsx +43 -0
  203. package/src/fields/FileUpload/index.tsx +74 -2
  204. package/src/fields/Geolocation/Geolocation.spec.tsx +126 -0
  205. package/src/fields/Geolocation/geoValue.spec.ts +169 -0
  206. package/src/fields/Geolocation/geoValue.ts +149 -0
  207. package/src/fields/Geolocation/index.tsx +15 -24
  208. package/types/fields/FileUpload/index.d.ts.map +1 -1
  209. package/types/fields/Geolocation/geoValue.d.ts +45 -0
  210. package/types/fields/Geolocation/geoValue.d.ts.map +1 -0
  211. package/types/fields/Geolocation/index.d.ts.map +1 -1
@@ -0,0 +1,104 @@
1
+ /**
2
+ * BarcodePreview — the drawn face of the barcode field, not the input.
3
+ * Barcode.stories.tsx mounts the field; this file mounts the preview alone
4
+ * so Kitchen Sink can show QR and Code 128 without a scanner.
5
+ *
6
+ * QR polarity is themed ($color on $background). MPO-94 is Clay's call;
7
+ * these stories do not pin dark-on-light.
8
+ */
9
+
10
+ import type { Meta, StoryObj } from "@storybook/react-native-web-vite";
11
+ import { Text, XStack, YStack } from "tamagui";
12
+ import { BarcodePreview } from "./BarcodePreview";
13
+
14
+ const meta: Meta<typeof BarcodePreview> = {
15
+ title: "Forms/BarcodePreview",
16
+ component: BarcodePreview,
17
+ parameters: {
18
+ status: { type: "stable" },
19
+ docs: {
20
+ description: {
21
+ component:
22
+ "Canvas-free barcode/QR preview. Theme ink on theme surface in both schemes. MPO-94 (pin vs themed QR polarity) is not taken here.",
23
+ },
24
+ },
25
+ },
26
+ argTypes: {
27
+ value: { control: "text" },
28
+ format: { control: "select", options: ["code128", "qr", "qrcode"] },
29
+ height: { control: "number" },
30
+ moduleWidth: { control: "number" },
31
+ showText: { control: "boolean" },
32
+ textFallback: { control: "boolean" },
33
+ },
34
+ };
35
+
36
+ export default meta;
37
+ type Story = StoryObj<typeof BarcodePreview>;
38
+
39
+ export const Main: Story = {
40
+ name: "Main",
41
+ args: {
42
+ value: "SKU-001234",
43
+ height: 36,
44
+ showText: true,
45
+ },
46
+ };
47
+
48
+ export const Code128: Story = {
49
+ args: {
50
+ value: "SKU-001234",
51
+ format: "code128",
52
+ height: 36,
53
+ showText: true,
54
+ },
55
+ };
56
+
57
+ /**
58
+ * QR face (MPO-53 AC-9). Height 120 so a short payload draws at a whole
59
+ * pixel per module. Polarity stays themed — MPO-94 is not this lane.
60
+ */
61
+ export const Qr: Story = {
62
+ args: {
63
+ value: "SHC-42",
64
+ format: "qr",
65
+ height: 120,
66
+ showText: true,
67
+ },
68
+ };
69
+
70
+ /** Host owns the empty marker; the preview paints nothing. */
71
+ export const Empty: Story = {
72
+ render: () => (
73
+ <YStack gap="$2">
74
+ <Text fontSize="$2" color="$color10">
75
+ empty value — preview is null
76
+ </Text>
77
+ <BarcodePreview value="" format="qr" height={72} />
78
+ </YStack>
79
+ ),
80
+ };
81
+
82
+ /**
83
+ * Themed polarity specimen. Both faces paint $color on $background, so a
84
+ * scheme flip restyles them with the rest of the screen. Do not treat this
85
+ * as a pin-to-dark-on-light preview; that decision lives on MPO-94.
86
+ */
87
+ export const ThemedPolarity: Story = {
88
+ render: () => (
89
+ <XStack gap="$6" flexWrap="wrap" padding="$4" alignItems="flex-end">
90
+ <YStack gap="$2" alignItems="center">
91
+ <Text fontSize="$2" color="$color10">
92
+ QR (themed)
93
+ </Text>
94
+ <BarcodePreview value="SHC-42" format="qr" height={120} showText />
95
+ </YStack>
96
+ <YStack gap="$2" alignItems="center">
97
+ <Text fontSize="$2" color="$color10">
98
+ Code 128 (themed)
99
+ </Text>
100
+ <BarcodePreview value="SKU-001234" format="code128" height={36} showText />
101
+ </YStack>
102
+ </XStack>
103
+ ),
104
+ };
@@ -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
+ };
@@ -347,6 +347,49 @@ describe("FileUpload bento-derived dropzone (MPO-57)", () => {
347
347
  const thumb = flist.querySelector("img") as HTMLImageElement;
348
348
  expect(thumb).toBeTruthy();
349
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");
350
393
  });
351
394
  });
352
395
 
@@ -174,6 +174,14 @@ const UploadFileListFrame = styled(YStack, {
174
174
  borderTopColor: formDropzoneColors.border,
175
175
  });
176
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
+
177
185
  export function FileUpload<
178
186
  TParentData,
179
187
  TName extends DeepKeys<TParentData>,
@@ -244,6 +252,13 @@ export function FileUpload<
244
252
  // Intentional: extract the recipe icon step (16 at the 44 control) for the
245
253
  // row file glyph — same held "icon 16" exception the CTA carries.
246
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);
247
262
 
248
263
  const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
249
264
  const [transfers, setTransfers] = useState<TransferItem[]>([]);
@@ -738,6 +753,44 @@ export function FileUpload<
738
753
  </XStack>
739
754
  );
740
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
+
741
794
  const renderUploadArea = (
742
795
  errorProp?: string | boolean,
743
796
  onValueChange?: (nextValue: TData) => void,
@@ -780,6 +833,15 @@ export function FileUpload<
780
833
  : ({ role: "button", onPress: requestFiles } as any)
781
834
  : { cursor: disabled ? "not-allowed" : undefined };
782
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
+
783
845
  return (
784
846
  <UploadAreaFrame
785
847
  // The dropzone is this field's control: it carries the FieldLayout id
@@ -928,8 +990,18 @@ export function FileUpload<
928
990
  // The hairline tracks the frame edge's knob width — one dash voice.
929
991
  borderTopWidth={knobProps.borderRadius.borderWidth ?? 1}
930
992
  >
931
- {selectedFiles.map((file, index) => {
932
- 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 }) => {
933
1005
  return (
934
1006
  <XStack
935
1007
  key={`${file.name}-${index}`}
@@ -6,6 +6,52 @@ import { Geolocation, preflightGeolocation } from "./index";
6
6
 
7
7
  const locationIcon = <span data-testid="location-icon" />;
8
8
 
9
+ /**
10
+ * Recorded stock-Frappe Geolocation value: what
11
+ * `JSON.stringify(editableLayers.toGeoJSON())` writes for a circle marker at
12
+ * 37.7749,-122.4194. GeoJSON order is [lng, lat].
13
+ */
14
+ const stockFrappePoint = JSON.stringify({
15
+ type: "FeatureCollection",
16
+ features: [
17
+ {
18
+ type: "Feature",
19
+ properties: { point_type: "circle", radius: 10 },
20
+ geometry: { type: "Point", coordinates: [-122.4194, 37.7749] },
21
+ },
22
+ ],
23
+ });
24
+
25
+ /**
26
+ * Stock Frappe's geolocation.js load path: JSON.parse then L.geoJSON. Compact
27
+ * `{"lat","lng"}` parses as JSON but is not GeoJSON, so Leaflet adds nothing
28
+ * and the desk field renders empty. A FeatureCollection with a Point is what
29
+ * survives a round trip through both clients.
30
+ */
31
+ function frappeGeoJsonReads(value: string): { lat: number; lng: number } | null {
32
+ let parsed: unknown;
33
+ try {
34
+ parsed = JSON.parse(value);
35
+ } catch {
36
+ return null;
37
+ }
38
+ if (!parsed || typeof parsed !== "object") return null;
39
+ const collection = parsed as { type?: unknown; features?: unknown };
40
+ if (collection.type !== "FeatureCollection" || !Array.isArray(collection.features)) {
41
+ return null;
42
+ }
43
+ for (const feature of collection.features) {
44
+ if (!feature || typeof feature !== "object") continue;
45
+ const geometry = (feature as { geometry?: { type?: unknown; coordinates?: unknown } }).geometry;
46
+ if (!geometry || geometry.type !== "Point" || !Array.isArray(geometry.coordinates)) continue;
47
+ const [lng, lat] = geometry.coordinates as [unknown, unknown];
48
+ if (typeof lng !== "number" || typeof lat !== "number") continue;
49
+ if (!Number.isFinite(lng) || !Number.isFinite(lat)) continue;
50
+ return { lat, lng };
51
+ }
52
+ return null;
53
+ }
54
+
9
55
  describe("Geolocation", () => {
10
56
  describe("without form context", () => {
11
57
  it("renders with label", () => {
@@ -418,4 +464,84 @@ describe("Geolocation", () => {
418
464
  expect(result.findTextElement("Location")).toBeDefined();
419
465
  });
420
466
  });
467
+
468
+ describe("MPO-100 stock Frappe FeatureCollection", () => {
469
+ it("paints a recorded stock value instead of rendering empty", () => {
470
+ const result = renderWithProviders(
471
+ <Geolocation label="Location" value={stockFrappePoint} locationIcon={locationIcon} />,
472
+ );
473
+ expect(result.container.textContent).toContain("37.7749, -122.4194");
474
+ expect(result.container.textContent).not.toContain("Set location");
475
+ expect(result.container.querySelector("[data-testid='geolocation-map']")).not.toBeNull();
476
+ });
477
+
478
+ it("a compact {lat,lng} write is invisible to Frappe's parser (negative control)", () => {
479
+ expect(frappeGeoJsonReads(JSON.stringify({ lat: 37.7749, lng: -122.4194 }))).toBeNull();
480
+ expect(frappeGeoJsonReads(stockFrappePoint)).toEqual({ lat: 37.7749, lng: -122.4194 });
481
+ });
482
+
483
+ it("editing a stock value still reads in Frappe's parser and keeps properties", async () => {
484
+ const onChange = vi.fn();
485
+ const result = renderWithProviders(
486
+ <Geolocation
487
+ label="Location"
488
+ locationIcon={locationIcon}
489
+ value={stockFrappePoint}
490
+ onChange={onChange}
491
+ />,
492
+ );
493
+ fireEvent.click(result.container.querySelector("[aria-label='Edit coordinates']") as Element);
494
+ await waitFor(() => {
495
+ expect(document.querySelector("[data-testid='geolocation-coords-panel']")).not.toBeNull();
496
+ });
497
+ const latInput = document.querySelector("input[aria-label='Latitude']") as HTMLInputElement;
498
+ expect(latInput).not.toBeNull();
499
+ fireEvent.change(latInput, { target: { value: "12.34" } });
500
+ await waitFor(() => {
501
+ expect(onChange).toHaveBeenCalled();
502
+ });
503
+ const written = onChange.mock.calls[0][0] as string;
504
+ expect(JSON.parse(written).type).toBe("FeatureCollection");
505
+ expect(frappeGeoJsonReads(written)).toEqual({ lat: 12.34, lng: -122.4194 });
506
+ expect(JSON.parse(written).features[0].properties).toEqual({
507
+ point_type: "circle",
508
+ radius: 10,
509
+ });
510
+ });
511
+
512
+ it("map pick on a stock value writes FeatureCollection, not compact latlng", () => {
513
+ const onChange = vi.fn();
514
+ const result = renderWithProviders(
515
+ <Geolocation
516
+ label="Location"
517
+ value={stockFrappePoint}
518
+ onChange={onChange}
519
+ locationIcon={locationIcon}
520
+ />,
521
+ );
522
+ const pick = result.container.querySelector(
523
+ "[data-testid='geolocation-map-pick']",
524
+ ) as HTMLElement;
525
+ expect(pick).not.toBeNull();
526
+ pick.getBoundingClientRect = () =>
527
+ ({
528
+ width: 200,
529
+ height: 200,
530
+ left: 0,
531
+ top: 0,
532
+ right: 200,
533
+ bottom: 200,
534
+ x: 0,
535
+ y: 0,
536
+ toJSON() {},
537
+ }) as DOMRect;
538
+ fireEvent.click(pick, { clientX: 150, clientY: 50 });
539
+ expect(onChange).toHaveBeenCalled();
540
+ const written = onChange.mock.calls[0][0] as string;
541
+ expect(JSON.parse(written).type).toBe("FeatureCollection");
542
+ const read = frappeGeoJsonReads(written);
543
+ expect(read).not.toBeNull();
544
+ expect(read?.lat).not.toBeCloseTo(37.7749, 3);
545
+ });
546
+ });
421
547
  });