@multiplatform.one/forms 7.8.0 → 7.11.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 (232) hide show
  1. package/dist/cjs/Button/index.cjs +1 -1
  2. package/dist/cjs/Button/index.native.js +5 -3
  3. package/dist/cjs/Button/index.native.js.map +1 -1
  4. package/dist/cjs/InputParts/index.cjs +11 -2
  5. package/dist/cjs/InputParts/index.native.js +11 -2
  6. package/dist/cjs/InputParts/index.native.js.map +1 -1
  7. package/dist/cjs/fields/Barcode/qr.native.js +34 -34
  8. package/dist/cjs/fields/FileUpload/index.cjs +6 -0
  9. package/dist/cjs/fields/FileUpload/index.native.js +6 -0
  10. package/dist/cjs/fields/FileUpload/index.native.js.map +1 -1
  11. package/dist/cjs/fields/Icon/drawnSet.cjs +3 -1
  12. package/dist/cjs/fields/Icon/drawnSet.native.js +3 -1
  13. package/dist/cjs/fields/Icon/drawnSet.native.js.map +1 -1
  14. package/dist/cjs/fields/RadioGroup/index.cjs +34 -5
  15. package/dist/cjs/fields/RadioGroup/index.native.js +49 -7
  16. package/dist/cjs/fields/RadioGroup/index.native.js.map +1 -1
  17. package/dist/cjs/fields/ToggleGroup/index.cjs +35 -2
  18. package/dist/cjs/fields/ToggleGroup/index.native.js +42 -2
  19. package/dist/cjs/fields/ToggleGroup/index.native.js.map +1 -1
  20. package/dist/cjs/shared/nativeA11y.cjs +57 -0
  21. package/dist/cjs/shared/nativeA11y.native.js +61 -0
  22. package/dist/cjs/shared/nativeA11y.native.js.map +1 -0
  23. package/dist/esm/AdaptivePopup/index.native.js.map +1 -1
  24. package/dist/esm/Button/index.mjs +1 -1
  25. package/dist/esm/Button/index.mjs.map +1 -1
  26. package/dist/esm/Button/index.native.js +5 -3
  27. package/dist/esm/Button/index.native.js.map +1 -1
  28. package/dist/esm/Chip/index.native.js.map +1 -1
  29. package/dist/esm/ContextualSaveBar/UnsavedChangesDialog.native.js.map +1 -1
  30. package/dist/esm/ContextualSaveBar/clientKnownIdentity.native.js.map +1 -1
  31. package/dist/esm/ContextualSaveBar/commitImmediateField.native.js.map +1 -1
  32. package/dist/esm/ContextualSaveBar/index.native.js.map +1 -1
  33. package/dist/esm/ContextualSaveBar/useUnsavedChangesGuard.native.js.map +1 -1
  34. package/dist/esm/ControlGroup/index.native.js.map +1 -1
  35. package/dist/esm/DescriptionList/index.native.js.map +1 -1
  36. package/dist/esm/ErrorSummary.native.js.map +1 -1
  37. package/dist/esm/FieldDisplay/editMode.native.js.map +1 -1
  38. package/dist/esm/FieldDisplay/formatters.native.js.map +1 -1
  39. package/dist/esm/FieldDisplay/index.native.js.map +1 -1
  40. package/dist/esm/FieldGroup.native.js.map +1 -1
  41. package/dist/esm/FloatingPanel/nestedAction.native.js.map +1 -1
  42. package/dist/esm/FloatingPanel/useFloatingPanel.native.js.map +1 -1
  43. package/dist/esm/Form/index.native.js.map +1 -1
  44. package/dist/esm/Form/validateOn.native.js.map +1 -1
  45. package/dist/esm/FormGrid.native.js.map +1 -1
  46. package/dist/esm/FormSection.native.js.map +1 -1
  47. package/dist/esm/InputParts/index.mjs +11 -2
  48. package/dist/esm/InputParts/index.mjs.map +1 -1
  49. package/dist/esm/InputParts/index.native.js +11 -2
  50. package/dist/esm/InputParts/index.native.js.map +1 -1
  51. package/dist/esm/Label/index.native.js.map +1 -1
  52. package/dist/esm/Skeleton/index.native.js.map +1 -1
  53. package/dist/esm/Spinner/SpinnerGlyph.native.js.map +1 -1
  54. package/dist/esm/Spinner/spinnerSize.native.js.map +1 -1
  55. package/dist/esm/Table/index.native.js.map +1 -1
  56. package/dist/esm/Wheel/haptic.native.js.map +1 -1
  57. package/dist/esm/Wheel/index.native.js.map +1 -1
  58. package/dist/esm/actionLabel.native.js.map +1 -1
  59. package/dist/esm/fieldDefaults.native.js.map +1 -1
  60. package/dist/esm/fieldLayout.native.js.map +1 -1
  61. package/dist/esm/fields/Barcode/BarcodePreview.native.js.map +1 -1
  62. package/dist/esm/fields/Barcode/code128.native.js.map +1 -1
  63. package/dist/esm/fields/Barcode/index.native.js.map +1 -1
  64. package/dist/esm/fields/Barcode/qr.native.js +34 -34
  65. package/dist/esm/fields/Barcode/qr.native.js.map +1 -1
  66. package/dist/esm/fields/Barcode/scannerTypes.native.js.map +1 -1
  67. package/dist/esm/fields/Checkbox/Checkbox.native.js.map +1 -1
  68. package/dist/esm/fields/Checkbox/CheckboxBox.native.js.map +1 -1
  69. package/dist/esm/fields/Checkbox/CheckboxCard.native.js.map +1 -1
  70. package/dist/esm/fields/Checkbox/CheckboxFieldFrame.native.js.map +1 -1
  71. package/dist/esm/fields/Checkbox/CheckboxGroup.native.js.map +1 -1
  72. package/dist/esm/fields/Checkbox/checkboxGroupContext.native.js.map +1 -1
  73. package/dist/esm/fields/Checkbox/index.native.js.map +1 -1
  74. package/dist/esm/fields/ColorPicker/index.native.js.map +1 -1
  75. package/dist/esm/fields/ColorPicker/parts.native.js.map +1 -1
  76. package/dist/esm/fields/Combobox/authorOptions.native.js.map +1 -1
  77. package/dist/esm/fields/Combobox/index.native.js.map +1 -1
  78. package/dist/esm/fields/DatePicker/Calendar.native.js.map +1 -1
  79. package/dist/esm/fields/DatePicker/DateRangePicker.native.js.map +1 -1
  80. package/dist/esm/fields/DatePicker/DatetimePicker.catalog.native.js.map +1 -1
  81. package/dist/esm/fields/DatePicker/MonthPicker.native.js.map +1 -1
  82. package/dist/esm/fields/DatePicker/MultiDatePicker.native.js.map +1 -1
  83. package/dist/esm/fields/DatePicker/catalog.native.js.map +1 -1
  84. package/dist/esm/fields/DatePicker/fieldValueSync.native.js.map +1 -1
  85. package/dist/esm/fields/DatePicker/parts.native.js.map +1 -1
  86. package/dist/esm/fields/DatePicker/utils.native.js.map +1 -1
  87. package/dist/esm/fields/Duration/durationMath.native.js.map +1 -1
  88. package/dist/esm/fields/Duration/index.native.js.map +1 -1
  89. package/dist/esm/fields/FileUpload/filePickerTypes.native.js.map +1 -1
  90. package/dist/esm/fields/FileUpload/index.mjs +6 -0
  91. package/dist/esm/fields/FileUpload/index.mjs.map +1 -1
  92. package/dist/esm/fields/FileUpload/index.native.js +6 -0
  93. package/dist/esm/fields/FileUpload/index.native.js.map +1 -1
  94. package/dist/esm/fields/FileUpload/nativePickerLoader.native.js.map +1 -1
  95. package/dist/esm/fields/Geolocation/expoMapsLoader.native.js.map +1 -1
  96. package/dist/esm/fields/Geolocation/geoValue.native.js.map +1 -1
  97. package/dist/esm/fields/Geolocation/geocoder.native.js.map +1 -1
  98. package/dist/esm/fields/Geolocation/index.native.js.map +1 -1
  99. package/dist/esm/fields/Geolocation/mapMath.native.js.map +1 -1
  100. package/dist/esm/fields/Geolocation/mapTypes.native.js.map +1 -1
  101. package/dist/esm/fields/Geolocation/nativeSearchFocus.native.js.map +1 -1
  102. package/dist/esm/fields/Icon/drawnSet.mjs +3 -1
  103. package/dist/esm/fields/Icon/drawnSet.mjs.map +1 -1
  104. package/dist/esm/fields/Icon/drawnSet.native.js +3 -1
  105. package/dist/esm/fields/Icon/drawnSet.native.js.map +1 -1
  106. package/dist/esm/fields/Icon/index.native.js.map +1 -1
  107. package/dist/esm/fields/ImageField/index.native.js.map +1 -1
  108. package/dist/esm/fields/Input/index.native.js.map +1 -1
  109. package/dist/esm/fields/MentionInput/index.native.js.map +1 -1
  110. package/dist/esm/fields/OTPInput/index.native.js.map +1 -1
  111. package/dist/esm/fields/OTPInput/otpCellCss.native.js.map +1 -1
  112. package/dist/esm/fields/PhoneInput/dialCodes.native.js.map +1 -1
  113. package/dist/esm/fields/PhoneInput/index.native.js.map +1 -1
  114. package/dist/esm/fields/PhoneInput/phone.native.js.map +1 -1
  115. package/dist/esm/fields/Progress/index.native.js.map +1 -1
  116. package/dist/esm/fields/RadioGroup/index.mjs +34 -5
  117. package/dist/esm/fields/RadioGroup/index.mjs.map +1 -1
  118. package/dist/esm/fields/RadioGroup/index.native.js +49 -7
  119. package/dist/esm/fields/RadioGroup/index.native.js.map +1 -1
  120. package/dist/esm/fields/Rating/index.native.js.map +1 -1
  121. package/dist/esm/fields/Rating/ratingMath.native.js.map +1 -1
  122. package/dist/esm/fields/Rating/types.native.js.map +1 -1
  123. package/dist/esm/fields/SearchInput/index.native.js.map +1 -1
  124. package/dist/esm/fields/Select/index.native.js.map +1 -1
  125. package/dist/esm/fields/Signature/index.native.js.map +1 -1
  126. package/dist/esm/fields/Slider/index.native.js.map +1 -1
  127. package/dist/esm/fields/Slider/jumpMotion.native.js.map +1 -1
  128. package/dist/esm/fields/Stepper/index.native.js.map +1 -1
  129. package/dist/esm/fields/Stepper/resolveStepperPlacement.native.js.map +1 -1
  130. package/dist/esm/fields/Switch/index.native.js.map +1 -1
  131. package/dist/esm/fields/TextArea/index.native.js.map +1 -1
  132. package/dist/esm/fields/TimePicker/catalog.native.js.map +1 -1
  133. package/dist/esm/fields/TimePicker/parts.native.js.map +1 -1
  134. package/dist/esm/fields/TimeRangeScrubber/geometry.native.js.map +1 -1
  135. package/dist/esm/fields/TimeRangeScrubber/index.native.js.map +1 -1
  136. package/dist/esm/fields/ToggleGroup/index.mjs +35 -2
  137. package/dist/esm/fields/ToggleGroup/index.mjs.map +1 -1
  138. package/dist/esm/fields/ToggleGroup/index.native.js +42 -2
  139. package/dist/esm/fields/ToggleGroup/index.native.js.map +1 -1
  140. package/dist/esm/fields/nativeDateTime/OsDateTimeSheet.native.js.map +1 -1
  141. package/dist/esm/fields/nativeDateTime/osDateTimeIosDisplay.native.js.map +1 -1
  142. package/dist/esm/fields/nativeSelect.native.js.map +1 -1
  143. package/dist/esm/fields/selectedOrder.native.js.map +1 -1
  144. package/dist/esm/formSpacing.native.js.map +1 -1
  145. package/dist/esm/hooks/useFocusManagement.native.js.map +1 -1
  146. package/dist/esm/index.native.js.map +1 -1
  147. package/dist/esm/requiredMarking.native.js.map +1 -1
  148. package/dist/esm/shared/TimeColumn.native.js.map +1 -1
  149. package/dist/esm/shared/capabilityPreflight.native.js.map +1 -1
  150. package/dist/esm/shared/colorRamps.native.js.map +1 -1
  151. package/dist/esm/shared/devWarn.native.js.map +1 -1
  152. package/dist/esm/shared/floatingList.native.js.map +1 -1
  153. package/dist/esm/shared/focusManagement.native.js.map +1 -1
  154. package/dist/esm/shared/groupContext.native.js.map +1 -1
  155. package/dist/esm/shared/i18n.native.js.map +1 -1
  156. package/dist/esm/shared/nativeA11y.mjs +31 -0
  157. package/dist/esm/shared/nativeA11y.mjs.map +1 -0
  158. package/dist/esm/shared/nativeA11y.native.js +32 -0
  159. package/dist/esm/shared/nativeA11y.native.js.map +1 -0
  160. package/dist/esm/shared/pickerTriggerContract.native.js.map +1 -1
  161. package/dist/esm/shared/pressSlopProps.native.js.map +1 -1
  162. package/dist/esm/shared/t.native.js.map +1 -1
  163. package/dist/esm/shared/tableCellContext.native.js.map +1 -1
  164. package/dist/esm/shared/useDragScroll.native.js.map +1 -1
  165. package/dist/esm/shared/useKeyboardInset.native.js.map +1 -1
  166. package/dist/esm/shared/useSheetEscapeDismiss.native.js.map +1 -1
  167. package/dist/esm/shared/useWheelIncrement.native.js.map +1 -1
  168. package/dist/esm/shared/utils.native.js.map +1 -1
  169. package/dist/esm/shared/zIndex.native.js.map +1 -1
  170. package/dist/esm/themedPrimitives.native.js.map +1 -1
  171. package/dist/esm/types.native.js.map +1 -1
  172. package/dist/esm/utils/LinkCell.native.js.map +1 -1
  173. package/dist/jsx/Button/index.mjs +1 -1
  174. package/dist/jsx/Button/index.mjs.map +1 -1
  175. package/dist/jsx/Button/index.native.js +5 -3
  176. package/dist/jsx/Button/index.native.js.map +1 -1
  177. package/dist/jsx/InputParts/index.mjs +11 -2
  178. package/dist/jsx/InputParts/index.mjs.map +1 -1
  179. package/dist/jsx/InputParts/index.native.js +11 -2
  180. package/dist/jsx/InputParts/index.native.js.map +1 -1
  181. package/dist/jsx/fields/Barcode/qr.native.js +34 -34
  182. package/dist/jsx/fields/FileUpload/index.mjs +6 -0
  183. package/dist/jsx/fields/FileUpload/index.mjs.map +1 -1
  184. package/dist/jsx/fields/FileUpload/index.native.js +6 -0
  185. package/dist/jsx/fields/FileUpload/index.native.js.map +1 -1
  186. package/dist/jsx/fields/Icon/drawnSet.mjs +3 -1
  187. package/dist/jsx/fields/Icon/drawnSet.mjs.map +1 -1
  188. package/dist/jsx/fields/Icon/drawnSet.native.js +3 -1
  189. package/dist/jsx/fields/Icon/drawnSet.native.js.map +1 -1
  190. package/dist/jsx/fields/RadioGroup/index.mjs +34 -5
  191. package/dist/jsx/fields/RadioGroup/index.mjs.map +1 -1
  192. package/dist/jsx/fields/RadioGroup/index.native.js +49 -7
  193. package/dist/jsx/fields/RadioGroup/index.native.js.map +1 -1
  194. package/dist/jsx/fields/ToggleGroup/index.mjs +35 -2
  195. package/dist/jsx/fields/ToggleGroup/index.mjs.map +1 -1
  196. package/dist/jsx/fields/ToggleGroup/index.native.js +42 -2
  197. package/dist/jsx/fields/ToggleGroup/index.native.js.map +1 -1
  198. package/dist/jsx/shared/nativeA11y.mjs +31 -0
  199. package/dist/jsx/shared/nativeA11y.mjs.map +1 -0
  200. package/dist/jsx/shared/nativeA11y.native.js +61 -0
  201. package/dist/jsx/shared/nativeA11y.native.js.map +1 -0
  202. package/package.json +11 -10
  203. package/src/Button/Button.fontWeight.spec.tsx +75 -0
  204. package/src/Button/index.tsx +5 -3
  205. package/src/FieldGroup.stories.tsx +3 -7
  206. package/src/FieldLayout.stories.tsx +34 -12
  207. package/src/Fieldset.stories.tsx +3 -7
  208. package/src/FormGrid.stories.tsx +5 -11
  209. package/src/InputParts/InputButton.spec.tsx +16 -0
  210. package/src/InputParts/index.tsx +19 -2
  211. package/src/fieldFamilyStories.radius.spec.tsx +118 -0
  212. package/src/fields/Barcode/Barcode.spec.tsx +2 -0
  213. package/src/fields/FileUpload/FileUpload.spec.tsx +29 -0
  214. package/src/fields/FileUpload/index.tsx +6 -0
  215. package/src/fields/Icon/drawnSet.tsx +7 -2
  216. package/src/fields/RadioGroup/RadioGroup.native.spec.tsx +67 -0
  217. package/src/fields/RadioGroup/index.tsx +82 -8
  218. package/src/fields/ToggleGroup/ToggleGroup.native.spec.tsx +73 -0
  219. package/src/fields/ToggleGroup/ToggleGroup.spec.tsx +49 -0
  220. package/src/fields/ToggleGroup/index.tsx +76 -1
  221. package/src/shared/nativeA11y.spec.ts +64 -0
  222. package/src/shared/nativeA11y.ts +80 -0
  223. package/types/Button/index.d.ts.map +1 -1
  224. package/types/InputParts/index.d.ts.map +1 -1
  225. package/types/fields/FileUpload/index.d.ts.map +1 -1
  226. package/types/fields/Icon/drawnSet.d.ts.map +1 -1
  227. package/types/fields/RadioGroup/index.d.ts +28 -1
  228. package/types/fields/RadioGroup/index.d.ts.map +1 -1
  229. package/types/fields/ToggleGroup/index.d.ts +30 -1
  230. package/types/fields/ToggleGroup/index.d.ts.map +1 -1
  231. package/types/shared/nativeA11y.d.ts +61 -0
  232. package/types/shared/nativeA11y.d.ts.map +1 -0
@@ -0,0 +1,75 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ *
4
+ * MPO-125: the `fontWeight` knob must reach the Button LABEL. A literal
5
+ * `fontWeight="400"` sat after the `knobProps.body` spread and pinned the
6
+ * label at 400 in both schemes, while Input and Select moved to 700 off the
7
+ * same knob. `docs/design/knob-laws.md` § Family rules already says
8
+ * `button | ... | fonts honour`, so the render was the thing that was wrong.
9
+ *
10
+ * The weight is read off the TEXT NODE, not the frame — the frame reported
11
+ * 400 either way, which is how the defect survived. jsdom cannot cascade
12
+ * Tamagui CSS, so the rendered value is the atomic class the text node emits
13
+ * (`_fow-700`); it exists only when the prop survived the merge, which is the
14
+ * thing under test. Same measurement Button.labelInk.spec.tsx uses.
15
+ */
16
+
17
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
18
+ import { Preset } from "@multiplatform.one/theme";
19
+ import { cleanup, screen } from "@testing-library/react";
20
+ import { Theme } from "tamagui";
21
+ import { afterEach, describe, expect, it } from "vitest";
22
+ import { Button } from "./index";
23
+
24
+ afterEach(cleanup);
25
+
26
+ const SCHEMES = ["light", "dark"] as const;
27
+
28
+ function inkWeight(el: HTMLElement): string {
29
+ const inline = el.style.fontWeight;
30
+ if (inline) return String(inline);
31
+ for (const cls of String(el.className ?? "").split(/\s+/)) {
32
+ const match = cls.match(/^_fow-(\d+)$/);
33
+ if (match) return match[1];
34
+ }
35
+ return "";
36
+ }
37
+
38
+ function labelNode(
39
+ fontWeight: "regular" | "bold",
40
+ scheme: (typeof SCHEMES)[number],
41
+ label: string,
42
+ ): HTMLElement {
43
+ renderWithProviders(
44
+ <Theme name={scheme}>
45
+ <Preset overrides={{ fontWeight }}>
46
+ <Button>{label}</Button>
47
+ </Preset>
48
+ </Theme>,
49
+ );
50
+ const node = screen.getByText(label) as HTMLElement;
51
+ expect(node.tagName).not.toBe("BUTTON");
52
+ return node;
53
+ }
54
+
55
+ describe("Button MPO-125 fontWeight knob", () => {
56
+ it.each(SCHEMES)("%s: bold takes the label to 700", (scheme) => {
57
+ const node = labelNode("bold", scheme, `bold-${scheme}`);
58
+ expect(inkWeight(node)).toBe("700");
59
+ expect(node.className).not.toMatch(/_fow-400\b/);
60
+ });
61
+
62
+ it.each(SCHEMES)("%s: regular keeps the label at 400", (scheme) => {
63
+ const node = labelNode("regular", scheme, `regular-${scheme}`);
64
+ expect(inkWeight(node)).toBe("400");
65
+ expect(node.className).not.toMatch(/_fow-700\b/);
66
+ });
67
+
68
+ it.each(SCHEMES)("%s: the size variant never leaks 500/600 at either stop", (scheme) => {
69
+ for (const stop of ["regular", "bold"] as const) {
70
+ const node = labelNode(stop, scheme, `leak-${stop}-${scheme}`);
71
+ expect(node.className).not.toMatch(/_fow-500\b/);
72
+ expect(node.className).not.toMatch(/_fow-600\b/);
73
+ }
74
+ });
75
+ });
@@ -545,9 +545,11 @@ export function Button({
545
545
  // LC-40: on the washed disabled chrome the label leaves the on-fill
546
546
  // foreground and pins to the lowest AA tier (keepLabel keeps >=4.5:1).
547
547
  {...(isDisabled ? disabledState.textKnobProps : undefined)}
548
- // Boards measure ink.weight on the TEXT NODE, never the frame.
549
- // Regular 400 — SizableText size variants otherwise leak 500/600.
550
- fontWeight="400"
548
+ // Boards measure ink.weight on the TEXT NODE, never the frame. The
549
+ // prop is restated after the spreads because SizableText's size
550
+ // variant otherwise leaks 500/600 over it (MPO-125: a literal 400
551
+ // here pinned the label and swallowed the fontWeight knob).
552
+ fontWeight={knobProps.body.fontWeight}
551
553
  transition={knobProps.transition}
552
554
  >
553
555
  {label}
@@ -1,8 +1,8 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-native-web-vite";
2
- import { Input as TamaguiInput, YStack } from "tamagui";
2
+ import { YStack } from "tamagui";
3
3
  import { FieldGroup } from "./FieldGroup";
4
4
  import { FormSection, FormSectionStack } from "./FormSection";
5
- import { FieldLayout } from "./fieldLayout";
5
+ import { Input } from "./fields/Input";
6
6
 
7
7
  const meta: Meta<typeof FieldGroup> = {
8
8
  title: "Forms/FieldGroup",
@@ -18,11 +18,7 @@ export default meta;
18
18
  type Story = StoryObj<typeof FieldGroup>;
19
19
 
20
20
  function Field({ id, label }: { id: string; label: string }) {
21
- return (
22
- <FieldLayout id={id} label={label}>
23
- <TamaguiInput id={id} placeholder={label} />
24
- </FieldLayout>
25
- );
21
+ return <Input id={id} label={label} placeholder={label} />;
26
22
  }
27
23
 
28
24
  export const Basic: Story = {
@@ -3,13 +3,35 @@ import type { Meta, StoryObj } from "@storybook/react-native-web-vite";
3
3
  import { useForm } from "@tanstack/react-form";
4
4
  import { useState } from "react";
5
5
  import { Preset } from "@multiplatform.one/theme";
6
- import { Input as TamaguiInput, Paragraph, YStack, XStack } from "tamagui";
6
+ import { Paragraph, YStack, XStack } from "tamagui";
7
7
  import { Button } from "./Button";
8
8
  import { Form } from "./Form";
9
9
  import { Field, FieldLayout } from "./fieldLayout";
10
10
  import { Input } from "./fields/Input";
11
+ import { Input as InputParts } from "./InputParts";
11
12
  import { Checkbox } from "./fields/Checkbox";
12
13
 
14
+ /**
15
+ * FieldLayout wraps a CONTROL, so these stories cannot mount the house `Input`
16
+ * field — it brings its own FieldLayout. `InputParts.Box` is the house chrome
17
+ * underneath that field, and it is the part that consumes the knobs.
18
+ */
19
+ function Control(props: {
20
+ id?: string;
21
+ placeholder?: string;
22
+ value?: string;
23
+ onChangeText?: (text: string) => void;
24
+ secureTextEntry?: boolean;
25
+ }) {
26
+ return (
27
+ <InputParts>
28
+ <InputParts.Box>
29
+ <InputParts.Area {...props} />
30
+ </InputParts.Box>
31
+ </InputParts>
32
+ );
33
+ }
34
+
13
35
  const meta: Meta<typeof FieldLayout> = {
14
36
  title: "Forms/FieldLayout",
15
37
  component: FieldLayout,
@@ -37,7 +59,7 @@ export const Basic: Story = {
37
59
  render: (args) => (
38
60
  <YStack maxWidth={360}>
39
61
  <FieldLayout {...args}>
40
- <TamaguiInput id={args.id} placeholder="you@example.com" />
62
+ <Control id={args.id} placeholder="you@example.com" />
41
63
  </FieldLayout>
42
64
  </YStack>
43
65
  ),
@@ -90,7 +112,7 @@ export const ErrorStates: Story = {
90
112
  // DG-COPY-02: error copy states the rule broken — never "invalid"/"please"/"error".
91
113
  error="Value must use letters only"
92
114
  >
93
- <TamaguiInput id="fl-err-string" />
115
+ <Control id="fl-err-string" />
94
116
  </FieldLayout>
95
117
  <FieldLayout
96
118
  id="fl-err-bool"
@@ -98,7 +120,7 @@ export const ErrorStates: Story = {
98
120
  helperText="Helper should remain visible"
99
121
  error={true}
100
122
  >
101
- <TamaguiInput id="fl-err-bool" />
123
+ <Control id="fl-err-bool" />
102
124
  </FieldLayout>
103
125
  <FieldLayout
104
126
  id="fl-err-empty"
@@ -106,7 +128,7 @@ export const ErrorStates: Story = {
106
128
  helperText="Empty string should act as no message"
107
129
  error=""
108
130
  >
109
- <TamaguiInput id="fl-err-empty" />
131
+ <Control id="fl-err-empty" />
110
132
  </FieldLayout>
111
133
  </YStack>
112
134
  ),
@@ -116,13 +138,13 @@ export const AnatomyEdgeCases: Story = {
116
138
  render: () => (
117
139
  <YStack gap="$6" maxWidth={360}>
118
140
  <FieldLayout id="fl-required" label="Required field" required>
119
- <TamaguiInput id="fl-required" />
141
+ <Control id="fl-required" />
120
142
  </FieldLayout>
121
143
  <FieldLayout id="fl-nolabel" helperText="No label means compact $1 gap">
122
- <TamaguiInput id="fl-nolabel" />
144
+ <Control id="fl-nolabel" />
123
145
  </FieldLayout>
124
146
  <FieldLayout id="fl-compact" label="Compact spacing" compactSpacing helperText="Compact gap">
125
- <TamaguiInput id="fl-compact" />
147
+ <Control id="fl-compact" />
126
148
  </FieldLayout>
127
149
  <FieldLayout
128
150
  id="fl-node-label"
@@ -133,14 +155,14 @@ export const AnatomyEdgeCases: Story = {
133
155
  </XStack>
134
156
  }
135
157
  >
136
- <TamaguiInput id="fl-node-label" />
158
+ <Control id="fl-node-label" />
137
159
  </FieldLayout>
138
160
  <FieldLayout
139
161
  id="fl-long"
140
162
  label="Long messages"
141
163
  error="This validation message is extremely long and keeps going to make sure long validation copy wraps correctly under the control without pushing the layout sideways or clipping any of its content at narrow widths"
142
164
  >
143
- <TamaguiInput id="fl-long" />
165
+ <Control id="fl-long" />
144
166
  </FieldLayout>
145
167
  </YStack>
146
168
  ),
@@ -166,7 +188,7 @@ export const FieldWrapper: Story = {
166
188
  helperText="TanStack Field wrapper"
167
189
  field={field as any}
168
190
  >
169
- <TamaguiInput
191
+ <Control
170
192
  id="field-username"
171
193
  value={String(field.state.value ?? "")}
172
194
  onChangeText={(text) => field.handleChange(text)}
@@ -208,7 +230,7 @@ export const FieldRequiredValidator: Story = {
208
230
  >
209
231
  {(field) => (
210
232
  <FieldLayout id="field-nickname" label="Nickname" required field={field as any}>
211
- <TamaguiInput
233
+ <Control
212
234
  id="field-nickname"
213
235
  value={String(field.state.value ?? "")}
214
236
  onChangeText={(text) => field.handleChange(text)}
@@ -1,14 +1,10 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-native-web-vite";
2
- import { Input as TamaguiInput, YStack } from "tamagui";
3
- import { FieldLayout } from "./fieldLayout";
2
+ import { YStack } from "tamagui";
4
3
  import { Fieldset, FormSectionStack } from "./FormSection";
4
+ import { Input } from "./fields/Input";
5
5
 
6
6
  function Field({ id, label, ...rest }: { id: string; label: string; secureTextEntry?: boolean }) {
7
- return (
8
- <FieldLayout id={id} label={label}>
9
- <TamaguiInput id={id} placeholder={label} {...rest} />
10
- </FieldLayout>
11
- );
7
+ return <Input id={id} label={label} placeholder={label} {...rest} />;
12
8
  }
13
9
 
14
10
  const meta: Meta<typeof Fieldset> = {
@@ -1,9 +1,9 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-native-web-vite";
2
- import { Input as TamaguiInput, Paragraph, YStack } from "tamagui";
2
+ import { Paragraph, YStack } from "tamagui";
3
3
  import { FieldGroup } from "./FieldGroup";
4
4
  import { FormGrid } from "./FormGrid";
5
5
  import { FormSection, FormSectionStack } from "./FormSection";
6
- import { FieldLayout } from "./fieldLayout";
6
+ import { Input } from "./fields/Input";
7
7
 
8
8
  const meta: Meta<typeof FormGrid> = {
9
9
  title: "Forms/FormGrid",
@@ -24,11 +24,7 @@ export default meta;
24
24
  type Story = StoryObj<typeof FormGrid>;
25
25
 
26
26
  function GridField({ id, label }: { id: string; label: string }) {
27
- return (
28
- <FieldLayout id={id} label={label}>
29
- <TamaguiInput id={id} placeholder={label} />
30
- </FieldLayout>
31
- );
27
+ return <Input id={id} label={label} placeholder={label} />;
32
28
  }
33
29
 
34
30
  export const Basic: Story = {
@@ -99,13 +95,11 @@ export const LongErrors: Story = {
99
95
  render: () => (
100
96
  <YStack maxWidth={640}>
101
97
  <FormGrid columns={2}>
102
- <FieldLayout
98
+ <Input
103
99
  id="fge-a"
104
100
  label="With long error"
105
101
  error="This validation message is extremely long and should wrap inside its own grid cell without breaking the neighbouring column alignment or overflowing the grid container"
106
- >
107
- <TamaguiInput id="fge-a" />
108
- </FieldLayout>
102
+ />
109
103
  <GridField id="fge-b" label="Neighbour" />
110
104
  </FormGrid>
111
105
  </YStack>
@@ -40,6 +40,22 @@ describe("Input.Button", () => {
40
40
  expect(btn.contains(ring)).toBe(true);
41
41
  });
42
42
 
43
+ it("a bare string child is a LABEL, not an icon type to createElement", () => {
44
+ const result = renderWithProviders(
45
+ <Input size="$4">
46
+ <Input.Box size="$4">
47
+ <Input.Button size="$4" aria-label="cap">
48
+ Scan
49
+ </Input.Button>
50
+ </Input.Box>
51
+ </Input>,
52
+ );
53
+ const btn = result.container.querySelector("[aria-label='cap']") as HTMLElement;
54
+ expect(btn).not.toBeNull();
55
+ expect(btn.textContent).toContain("Scan");
56
+ expect(result.container.querySelector("scan")).toBeNull();
57
+ });
58
+
43
59
  it("end-cap square is generated from the size recipe at every token", () => {
44
60
  for (const token of SIZE_RECIPE_TOKENS) {
45
61
  if (token === "$true") continue;
@@ -1035,6 +1035,23 @@ const InputButtonImpl = InputButton.styleable<{ glyphRing?: boolean }>((buttonPr
1035
1035
  size: knobProps.controlIcon.width,
1036
1036
  color: paint as never,
1037
1037
  });
1038
+ // A string/number child is a LABEL, not an icon. `getThemedIcon` treats an
1039
+ // un-elemented child as a component TYPE and `createElement`s it, so "Scan"
1040
+ // becomes an invented `<scan>` element that paints nothing (MPO-208); a bare
1041
+ // string under the glyphRing View is the same category error on native.
1042
+ const textChild =
1043
+ typeof children === "string" || typeof children === "number" ? (
1044
+ <Text
1045
+ {...knobProps.controlType}
1046
+ fontFamily={knobProps.body.fontFamily}
1047
+ fontWeight={knobProps.body.fontWeight}
1048
+ color={paint as never}
1049
+ numberOfLines={1}
1050
+ userSelect="none"
1051
+ >
1052
+ {children}
1053
+ </Text>
1054
+ ) : null;
1038
1055
  const decorativeRef = useCallback(
1039
1056
  (node: TamaguiElement | null) => {
1040
1057
  // Tamagui 2.7.6 Button stamps tabIndex=0 after consumer props.
@@ -1078,7 +1095,7 @@ const InputButtonImpl = InputButton.styleable<{ glyphRing?: boolean }>((buttonPr
1078
1095
  alignItems="center"
1079
1096
  justifyContent="center"
1080
1097
  >
1081
- {children}
1098
+ {textChild ?? children}
1082
1099
  </View>
1083
1100
  </InputGlyphButton>
1084
1101
  );
@@ -1101,7 +1118,7 @@ const InputButtonImpl = InputButton.styleable<{ glyphRing?: boolean }>((buttonPr
1101
1118
  data-end-cap={square}
1102
1119
  {...props}
1103
1120
  >
1104
- {getThemedIcon(children)}
1121
+ {textChild ?? getThemedIcon(children)}
1105
1122
  </InputButton>
1106
1123
  );
1107
1124
  });
@@ -0,0 +1,118 @@
1
+ /**
2
+ * The four form LAYOUT stories must mount house field chrome, not a raw
3
+ * tamagui primitive.
4
+ *
5
+ * FieldLayout, FieldGroup, Fieldset and FormGrid paint no radius of their own —
6
+ * they are stacks, a grid and a legend. Whatever radius a probe reads off one
7
+ * of their stories belongs to the CONTROL the story chose to mount. A raw
8
+ * `Input` from "tamagui" consumes no knobs, so it pins one radius at every
9
+ * stop and the axis reads dead against a component that never had a say.
10
+ *
11
+ * The measurement here is therefore the STORY's, not the component's: walk the
12
+ * borderRadius knob and assert the control inside each story tracks it.
13
+ *
14
+ * Under jsdom the providers inject no CSS and the radius atom keeps its token
15
+ * reference (`_btlr-t-radius-4`) rather than resolving to `9px`, so this file
16
+ * gates the token ladder. The pixel ladder (0/5/9/16/50) and the dark scheme
17
+ * are measured for real by scripts/knob-axis-probe.mjs against a browser.
18
+ */
19
+
20
+ import { cleanup } from "@testing-library/react";
21
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
22
+ import { Preset, type BorderRadius } from "@multiplatform.one/theme";
23
+ import type { ReactElement } from "react";
24
+ import { afterEach, describe, expect, it } from "vitest";
25
+
26
+ import * as fieldGroupStories from "./FieldGroup.stories";
27
+ import * as fieldLayoutStories from "./FieldLayout.stories";
28
+ import * as fieldsetStories from "./Fieldset.stories";
29
+ import * as formGridStories from "./FormGrid.stories";
30
+
31
+ afterEach(cleanup);
32
+
33
+ /** Knob stop -> radius token index, the `$N` the resolver hands the frame. */
34
+ const ladder: Record<BorderRadius, string> = {
35
+ none: "0",
36
+ small: "2",
37
+ medium: "4",
38
+ large: "6",
39
+ full: "12",
40
+ };
41
+ const stops = Object.keys(ladder) as BorderRadius[];
42
+
43
+ type StoryLike = { render?: (args: Record<string, unknown>) => ReactElement };
44
+
45
+ /** Every radius token present in the subtree, deduped — the probe's reading. */
46
+ function radiusTokens(container: HTMLElement): string[] {
47
+ const found = new Set<string>();
48
+ for (const el of container.querySelectorAll<HTMLElement>("*")) {
49
+ for (const cls of String(el.className || "").split(/\s+/)) {
50
+ const match = /^_btlr-t-radius-(\d+)$/.exec(cls);
51
+ if (match) found.add(match[1]);
52
+ }
53
+ }
54
+ return [...found].sort();
55
+ }
56
+
57
+ function mountAt(story: StoryLike, meta: { args?: unknown }, stop: BorderRadius) {
58
+ if (!story.render) throw new Error("story has no render");
59
+ return renderWithProviders(
60
+ <Preset overrides={{ borderRadius: stop, elevation: "none" }}>
61
+ {story.render({ ...((meta.args ?? {}) as Record<string, unknown>) })}
62
+ </Preset>,
63
+ );
64
+ }
65
+
66
+ const family = [
67
+ {
68
+ name: "Forms/FieldLayout",
69
+ story: fieldLayoutStories.Basic as StoryLike,
70
+ meta: fieldLayoutStories.default,
71
+ },
72
+ {
73
+ name: "Forms/FieldGroup",
74
+ story: fieldGroupStories.Basic as StoryLike,
75
+ meta: fieldGroupStories.default,
76
+ },
77
+ {
78
+ name: "Forms/Fieldset",
79
+ story: fieldsetStories.Default as StoryLike,
80
+ meta: fieldsetStories.default,
81
+ },
82
+ {
83
+ name: "Forms/FormGrid",
84
+ story: formGridStories.Basic as StoryLike,
85
+ meta: formGridStories.default,
86
+ },
87
+ ];
88
+
89
+ describe("form layout stories mount knob-consuming field chrome", () => {
90
+ for (const { name, story, meta } of family) {
91
+ describe(name, () => {
92
+ // A story that fails to render measures flat on every axis, which reads
93
+ // exactly like a dead knob. Prove there is a control before believing
94
+ // anything measured off it.
95
+ it("renders a text control at all", () => {
96
+ const { container } = mountAt(story, meta as { args?: unknown }, "medium");
97
+ expect(container.querySelectorAll("input").length).toBeGreaterThan(0);
98
+ });
99
+
100
+ for (const stop of stops) {
101
+ it(`takes radius token $${ladder[stop]} at borderRadius:${stop}`, () => {
102
+ const { container } = mountAt(story, meta as { args?: unknown }, stop);
103
+ expect(radiusTokens(container)).toContain(ladder[stop]);
104
+ });
105
+ }
106
+
107
+ it("moves across all five stops instead of pinning one radius", () => {
108
+ const signatures = stops.map((stop) => {
109
+ const result = mountAt(story, meta as { args?: unknown }, stop);
110
+ const tokens = JSON.stringify(radiusTokens(result.container));
111
+ cleanup();
112
+ return tokens;
113
+ });
114
+ expect(new Set(signatures).size).toBe(stops.length);
115
+ });
116
+ });
117
+ }
118
+ });
@@ -102,6 +102,8 @@ describe("Barcode", () => {
102
102
  const button = result.container.querySelector("[aria-label='Scan barcode']");
103
103
  expect(button).not.toBeNull();
104
104
  expect(button?.textContent).toContain("Scan");
105
+ expect(button?.querySelector("scan")).toBeNull();
106
+ expect(result.container.querySelector("scan")).toBeNull();
105
107
  });
106
108
 
107
109
  it("renders readOnly — hides scan button", () => {
@@ -45,6 +45,14 @@ function getZone(container: Element): HTMLElement {
45
45
  return zone as HTMLElement;
46
46
  }
47
47
 
48
+ function borderWidthAtomPx(el: HTMLElement, prefix: string): number {
49
+ const atom = el.className.split(/\s+/).find((cls) => cls.startsWith(prefix));
50
+ // No atom means the side declares no width and falls through to the UA
51
+ // initial, which is exactly the defect — report it as such, not as 0.
52
+ if (!atom) return Number.NaN;
53
+ return Number.parseFloat(atom.slice(prefix.length));
54
+ }
55
+
48
56
  async function pickFile(container: Element, file: File) {
49
57
  const input = container.querySelector("input[type='file']") as HTMLInputElement | null;
50
58
  expect(input).toBeTruthy();
@@ -317,6 +325,27 @@ describe("FileUpload bento-derived dropzone (MPO-57)", () => {
317
325
  expect(zone.textContent).toContain("Choose File");
318
326
  });
319
327
 
328
+ it("hangs the file list off ONE top hairline — bottom/left/right are 0, not the UA 3px initial (MPO-124)", async () => {
329
+ const { container } = renderWithHouseThemes(
330
+ <FileUpload label="Attachment" name="attachment" />,
331
+ );
332
+ await pickFile(container, new File(["hello"], "hello.txt", { type: "text/plain" }));
333
+
334
+ const zone = getZone(container);
335
+ const flist = zone.querySelector('[data-file-upload="files"]') as HTMLElement;
336
+ expect(flist).toBeTruthy();
337
+
338
+ // The frame declares `borderStyle: dashed` for all four sides. A side that
339
+ // declares no width therefore does not render nothing — it renders the CSS
340
+ // initial `medium` (3px) in `currentColor` ($color12), which is a heavy
341
+ // dashed BOX around the picked files instead of the one hairline the
342
+ // divider contract asks for.
343
+ expect(borderWidthAtomPx(flist, "_btw-")).toBe(1);
344
+ for (const prefix of ["_bbw-", "_blw-", "_brw-"] as const) {
345
+ expect(borderWidthAtomPx(flist, prefix)).toBe(0);
346
+ }
347
+ });
348
+
320
349
  it("keeps compact a space-only dial — CTA stays 44 and remove stays nested 32 (LC-76)", async () => {
321
350
  const { container } = renderWithHouseThemes(
322
351
  <FileUpload label="Attachment" name="attachment" compact />,
@@ -170,6 +170,12 @@ const DropOverlayFrame = styled(YStack, {
170
170
  const UploadFileListFrame = styled(YStack, {
171
171
  width: "100%",
172
172
  borderTopWidth: 1,
173
+ // A side with a declared style and no declared width is NOT a side with no
174
+ // border: it takes the CSS initial `medium` (3px) in `currentColor`, which
175
+ // boxed the picked files in a heavy $color12 dash (MPO-124).
176
+ borderBottomWidth: 0,
177
+ borderLeftWidth: 0,
178
+ borderRightWidth: 0,
173
179
  borderStyle: "dashed",
174
180
  borderTopColor: formDropzoneColors.border,
175
181
  });
@@ -213,13 +213,18 @@ export function filterDrawnIcons(
213
213
  */
214
214
  const drawnIconInkToken = "color11";
215
215
 
216
- function useDrawnInk(color?: string): string | undefined {
216
+ function useDrawnInk(color?: string): string {
217
217
  const theme = useTheme() as unknown as Record<string, { val?: unknown } | undefined>;
218
218
  const raw = !color || color === "currentColor" ? `$${drawnIconInkToken}` : color;
219
219
  const val = raw.startsWith("$") ? theme[raw.slice(1)]?.val : raw;
220
220
  // An unknown consumer token falls back to the active theme's readable ink.
221
221
  const fallback = theme[drawnIconInkToken]?.val ?? theme.color?.val;
222
- return typeof val === "string" ? val : typeof fallback === "string" ? fallback : undefined;
222
+ if (typeof val === "string") return val;
223
+ if (typeof fallback === "string") return fallback;
224
+ // Outside a theme both lookups miss. Returning undefined is what MPO-72 was:
225
+ // react-native-svg takes it as nil and Core Graphics paints with whatever
226
+ // stroke was last in the context. A mid neutral is visible on both schemes.
227
+ return "#808080";
223
228
  }
224
229
 
225
230
  export function DrawnIcon({
@@ -0,0 +1,67 @@
1
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { RadioGroup } from "./index";
4
+
5
+ // MPO-283. Mock tamagui's isWeb so the native branch runs on the test host
6
+ // renderer, then read the React Native prop names back off the DOM. The state
7
+ // object itself is pinned in shared/nativeA11y.spec.ts — the host stringifies
8
+ // it — so what this file proves is that the component reaches the helper with
9
+ // the right role and the right name.
10
+ vi.mock("tamagui", async (importOriginal) => ({
11
+ ...(await importOriginal<typeof import("tamagui")>()),
12
+ isWeb: false,
13
+ }));
14
+
15
+ const phase = [
16
+ { label: "CREDITS", value: "CREDITS" },
17
+ { label: "DEBITS", value: "DEBITS" },
18
+ { label: "DISCRETIONARY", value: "DISCRETIONARY", disabled: true },
19
+ ];
20
+
21
+ const names = (container: HTMLElement) =>
22
+ Array.from(container.querySelectorAll("[accessibilityrole]")).map((el) => [
23
+ el.getAttribute("accessibilityrole"),
24
+ el.getAttribute("accessibilitylabel"),
25
+ ]);
26
+
27
+ describe("native RadioGroup accessibility contract", () => {
28
+ it("makes every option a named control instead of bare StaticText", () => {
29
+ const { container } = renderWithProviders(
30
+ <RadioGroup label="Phase" options={phase} value="DEBITS" />,
31
+ );
32
+ expect(names(container)).toEqual([
33
+ ["button", "CREDITS"],
34
+ ["button", "DEBITS"],
35
+ ["button", "DISCRETIONARY"],
36
+ ]);
37
+ });
38
+
39
+ it("prefixes a group accessibilityLabel onto each option, because iOS never reads a container", () => {
40
+ const { container } = renderWithProviders(
41
+ <RadioGroup accessibilityLabel="Phase" options={phase} value="DEBITS" />,
42
+ );
43
+ expect(names(container)).toContainEqual(["button", "Phase, DEBITS"]);
44
+ });
45
+
46
+ it("takes aria-label as an alias, since tamagui never maps it onto native", () => {
47
+ const { container } = renderWithProviders(
48
+ <RadioGroup aria-label="Phase" options={phase} value="DEBITS" />,
49
+ );
50
+ expect(names(container)).toContainEqual(["button", "Phase, DEBITS"]);
51
+ });
52
+
53
+ it("lets an option override its own name", () => {
54
+ const { container } = renderWithProviders(
55
+ <RadioGroup
56
+ options={[{ label: "▲", value: "up", accessibilityLabel: "Increasing" }]}
57
+ value="up"
58
+ />,
59
+ );
60
+ expect(names(container)).toEqual([["button", "Increasing"]]);
61
+ });
62
+
63
+ // The sibling Label is also told to stay out of the native tree so an option
64
+ // is not read twice. It cannot be asserted here — the web renderer drops the
65
+ // React Native accessibility props off Label entirely — so that one is
66
+ // measured on device instead.
67
+ });