@multiplatform.one/forms 7.8.0 → 7.10.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 (229) 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/FileUpload/index.cjs +6 -0
  8. package/dist/cjs/fields/FileUpload/index.native.js +6 -0
  9. package/dist/cjs/fields/FileUpload/index.native.js.map +1 -1
  10. package/dist/cjs/fields/Icon/drawnSet.cjs +3 -1
  11. package/dist/cjs/fields/Icon/drawnSet.native.js +3 -1
  12. package/dist/cjs/fields/Icon/drawnSet.native.js.map +1 -1
  13. package/dist/cjs/fields/RadioGroup/index.cjs +34 -5
  14. package/dist/cjs/fields/RadioGroup/index.native.js +49 -7
  15. package/dist/cjs/fields/RadioGroup/index.native.js.map +1 -1
  16. package/dist/cjs/fields/ToggleGroup/index.cjs +35 -2
  17. package/dist/cjs/fields/ToggleGroup/index.native.js +42 -2
  18. package/dist/cjs/fields/ToggleGroup/index.native.js.map +1 -1
  19. package/dist/cjs/shared/nativeA11y.cjs +57 -0
  20. package/dist/cjs/shared/nativeA11y.native.js +61 -0
  21. package/dist/cjs/shared/nativeA11y.native.js.map +1 -0
  22. package/dist/esm/AdaptivePopup/index.native.js.map +1 -1
  23. package/dist/esm/Button/index.mjs +1 -1
  24. package/dist/esm/Button/index.mjs.map +1 -1
  25. package/dist/esm/Button/index.native.js +5 -3
  26. package/dist/esm/Button/index.native.js.map +1 -1
  27. package/dist/esm/Chip/index.native.js.map +1 -1
  28. package/dist/esm/ContextualSaveBar/UnsavedChangesDialog.native.js.map +1 -1
  29. package/dist/esm/ContextualSaveBar/clientKnownIdentity.native.js.map +1 -1
  30. package/dist/esm/ContextualSaveBar/commitImmediateField.native.js.map +1 -1
  31. package/dist/esm/ContextualSaveBar/index.native.js.map +1 -1
  32. package/dist/esm/ContextualSaveBar/useUnsavedChangesGuard.native.js.map +1 -1
  33. package/dist/esm/ControlGroup/index.native.js.map +1 -1
  34. package/dist/esm/DescriptionList/index.native.js.map +1 -1
  35. package/dist/esm/ErrorSummary.native.js.map +1 -1
  36. package/dist/esm/FieldDisplay/editMode.native.js.map +1 -1
  37. package/dist/esm/FieldDisplay/formatters.native.js.map +1 -1
  38. package/dist/esm/FieldDisplay/index.native.js.map +1 -1
  39. package/dist/esm/FieldGroup.native.js.map +1 -1
  40. package/dist/esm/FloatingPanel/nestedAction.native.js.map +1 -1
  41. package/dist/esm/FloatingPanel/useFloatingPanel.native.js.map +1 -1
  42. package/dist/esm/Form/index.native.js.map +1 -1
  43. package/dist/esm/Form/validateOn.native.js.map +1 -1
  44. package/dist/esm/FormGrid.native.js.map +1 -1
  45. package/dist/esm/FormSection.native.js.map +1 -1
  46. package/dist/esm/InputParts/index.mjs +11 -2
  47. package/dist/esm/InputParts/index.mjs.map +1 -1
  48. package/dist/esm/InputParts/index.native.js +11 -2
  49. package/dist/esm/InputParts/index.native.js.map +1 -1
  50. package/dist/esm/Label/index.native.js.map +1 -1
  51. package/dist/esm/Skeleton/index.native.js.map +1 -1
  52. package/dist/esm/Spinner/SpinnerGlyph.native.js.map +1 -1
  53. package/dist/esm/Spinner/spinnerSize.native.js.map +1 -1
  54. package/dist/esm/Table/index.native.js.map +1 -1
  55. package/dist/esm/Wheel/haptic.native.js.map +1 -1
  56. package/dist/esm/Wheel/index.native.js.map +1 -1
  57. package/dist/esm/actionLabel.native.js.map +1 -1
  58. package/dist/esm/fieldDefaults.native.js.map +1 -1
  59. package/dist/esm/fieldLayout.native.js.map +1 -1
  60. package/dist/esm/fields/Barcode/BarcodePreview.native.js.map +1 -1
  61. package/dist/esm/fields/Barcode/code128.native.js.map +1 -1
  62. package/dist/esm/fields/Barcode/index.native.js.map +1 -1
  63. package/dist/esm/fields/Barcode/qr.native.js.map +1 -1
  64. package/dist/esm/fields/Barcode/scannerTypes.native.js.map +1 -1
  65. package/dist/esm/fields/Checkbox/Checkbox.native.js.map +1 -1
  66. package/dist/esm/fields/Checkbox/CheckboxBox.native.js.map +1 -1
  67. package/dist/esm/fields/Checkbox/CheckboxCard.native.js.map +1 -1
  68. package/dist/esm/fields/Checkbox/CheckboxFieldFrame.native.js.map +1 -1
  69. package/dist/esm/fields/Checkbox/CheckboxGroup.native.js.map +1 -1
  70. package/dist/esm/fields/Checkbox/checkboxGroupContext.native.js.map +1 -1
  71. package/dist/esm/fields/Checkbox/index.native.js.map +1 -1
  72. package/dist/esm/fields/ColorPicker/index.native.js.map +1 -1
  73. package/dist/esm/fields/ColorPicker/parts.native.js.map +1 -1
  74. package/dist/esm/fields/Combobox/authorOptions.native.js.map +1 -1
  75. package/dist/esm/fields/Combobox/index.native.js.map +1 -1
  76. package/dist/esm/fields/DatePicker/Calendar.native.js.map +1 -1
  77. package/dist/esm/fields/DatePicker/DateRangePicker.native.js.map +1 -1
  78. package/dist/esm/fields/DatePicker/DatetimePicker.catalog.native.js.map +1 -1
  79. package/dist/esm/fields/DatePicker/MonthPicker.native.js.map +1 -1
  80. package/dist/esm/fields/DatePicker/MultiDatePicker.native.js.map +1 -1
  81. package/dist/esm/fields/DatePicker/catalog.native.js.map +1 -1
  82. package/dist/esm/fields/DatePicker/fieldValueSync.native.js.map +1 -1
  83. package/dist/esm/fields/DatePicker/parts.native.js.map +1 -1
  84. package/dist/esm/fields/DatePicker/utils.native.js.map +1 -1
  85. package/dist/esm/fields/Duration/durationMath.native.js.map +1 -1
  86. package/dist/esm/fields/Duration/index.native.js.map +1 -1
  87. package/dist/esm/fields/FileUpload/filePickerTypes.native.js.map +1 -1
  88. package/dist/esm/fields/FileUpload/index.mjs +6 -0
  89. package/dist/esm/fields/FileUpload/index.mjs.map +1 -1
  90. package/dist/esm/fields/FileUpload/index.native.js +6 -0
  91. package/dist/esm/fields/FileUpload/index.native.js.map +1 -1
  92. package/dist/esm/fields/FileUpload/nativePickerLoader.native.js.map +1 -1
  93. package/dist/esm/fields/Geolocation/expoMapsLoader.native.js.map +1 -1
  94. package/dist/esm/fields/Geolocation/geoValue.native.js.map +1 -1
  95. package/dist/esm/fields/Geolocation/geocoder.native.js.map +1 -1
  96. package/dist/esm/fields/Geolocation/index.native.js.map +1 -1
  97. package/dist/esm/fields/Geolocation/mapMath.native.js.map +1 -1
  98. package/dist/esm/fields/Geolocation/mapTypes.native.js.map +1 -1
  99. package/dist/esm/fields/Geolocation/nativeSearchFocus.native.js.map +1 -1
  100. package/dist/esm/fields/Icon/drawnSet.mjs +3 -1
  101. package/dist/esm/fields/Icon/drawnSet.mjs.map +1 -1
  102. package/dist/esm/fields/Icon/drawnSet.native.js +3 -1
  103. package/dist/esm/fields/Icon/drawnSet.native.js.map +1 -1
  104. package/dist/esm/fields/Icon/index.native.js.map +1 -1
  105. package/dist/esm/fields/ImageField/index.native.js.map +1 -1
  106. package/dist/esm/fields/Input/index.native.js.map +1 -1
  107. package/dist/esm/fields/MentionInput/index.native.js.map +1 -1
  108. package/dist/esm/fields/OTPInput/index.native.js.map +1 -1
  109. package/dist/esm/fields/OTPInput/otpCellCss.native.js.map +1 -1
  110. package/dist/esm/fields/PhoneInput/dialCodes.native.js.map +1 -1
  111. package/dist/esm/fields/PhoneInput/index.native.js.map +1 -1
  112. package/dist/esm/fields/PhoneInput/phone.native.js.map +1 -1
  113. package/dist/esm/fields/Progress/index.native.js.map +1 -1
  114. package/dist/esm/fields/RadioGroup/index.mjs +34 -5
  115. package/dist/esm/fields/RadioGroup/index.mjs.map +1 -1
  116. package/dist/esm/fields/RadioGroup/index.native.js +49 -7
  117. package/dist/esm/fields/RadioGroup/index.native.js.map +1 -1
  118. package/dist/esm/fields/Rating/index.native.js.map +1 -1
  119. package/dist/esm/fields/Rating/ratingMath.native.js.map +1 -1
  120. package/dist/esm/fields/Rating/types.native.js.map +1 -1
  121. package/dist/esm/fields/SearchInput/index.native.js.map +1 -1
  122. package/dist/esm/fields/Select/index.native.js.map +1 -1
  123. package/dist/esm/fields/Signature/index.native.js.map +1 -1
  124. package/dist/esm/fields/Slider/index.native.js.map +1 -1
  125. package/dist/esm/fields/Slider/jumpMotion.native.js.map +1 -1
  126. package/dist/esm/fields/Stepper/index.native.js.map +1 -1
  127. package/dist/esm/fields/Stepper/resolveStepperPlacement.native.js.map +1 -1
  128. package/dist/esm/fields/Switch/index.native.js.map +1 -1
  129. package/dist/esm/fields/TextArea/index.native.js.map +1 -1
  130. package/dist/esm/fields/TimePicker/catalog.native.js.map +1 -1
  131. package/dist/esm/fields/TimePicker/parts.native.js.map +1 -1
  132. package/dist/esm/fields/TimeRangeScrubber/geometry.native.js.map +1 -1
  133. package/dist/esm/fields/TimeRangeScrubber/index.native.js.map +1 -1
  134. package/dist/esm/fields/ToggleGroup/index.mjs +35 -2
  135. package/dist/esm/fields/ToggleGroup/index.mjs.map +1 -1
  136. package/dist/esm/fields/ToggleGroup/index.native.js +42 -2
  137. package/dist/esm/fields/ToggleGroup/index.native.js.map +1 -1
  138. package/dist/esm/fields/nativeDateTime/OsDateTimeSheet.native.js.map +1 -1
  139. package/dist/esm/fields/nativeDateTime/osDateTimeIosDisplay.native.js.map +1 -1
  140. package/dist/esm/fields/nativeSelect.native.js.map +1 -1
  141. package/dist/esm/fields/selectedOrder.native.js.map +1 -1
  142. package/dist/esm/formSpacing.native.js.map +1 -1
  143. package/dist/esm/hooks/useFocusManagement.native.js.map +1 -1
  144. package/dist/esm/index.native.js.map +1 -1
  145. package/dist/esm/requiredMarking.native.js.map +1 -1
  146. package/dist/esm/shared/TimeColumn.native.js.map +1 -1
  147. package/dist/esm/shared/capabilityPreflight.native.js.map +1 -1
  148. package/dist/esm/shared/colorRamps.native.js.map +1 -1
  149. package/dist/esm/shared/devWarn.native.js.map +1 -1
  150. package/dist/esm/shared/floatingList.native.js.map +1 -1
  151. package/dist/esm/shared/focusManagement.native.js.map +1 -1
  152. package/dist/esm/shared/groupContext.native.js.map +1 -1
  153. package/dist/esm/shared/i18n.native.js.map +1 -1
  154. package/dist/esm/shared/nativeA11y.mjs +31 -0
  155. package/dist/esm/shared/nativeA11y.mjs.map +1 -0
  156. package/dist/esm/shared/nativeA11y.native.js +32 -0
  157. package/dist/esm/shared/nativeA11y.native.js.map +1 -0
  158. package/dist/esm/shared/pickerTriggerContract.native.js.map +1 -1
  159. package/dist/esm/shared/pressSlopProps.native.js.map +1 -1
  160. package/dist/esm/shared/t.native.js.map +1 -1
  161. package/dist/esm/shared/tableCellContext.native.js.map +1 -1
  162. package/dist/esm/shared/useDragScroll.native.js.map +1 -1
  163. package/dist/esm/shared/useKeyboardInset.native.js.map +1 -1
  164. package/dist/esm/shared/useSheetEscapeDismiss.native.js.map +1 -1
  165. package/dist/esm/shared/useWheelIncrement.native.js.map +1 -1
  166. package/dist/esm/shared/utils.native.js.map +1 -1
  167. package/dist/esm/shared/zIndex.native.js.map +1 -1
  168. package/dist/esm/themedPrimitives.native.js.map +1 -1
  169. package/dist/esm/types.native.js.map +1 -1
  170. package/dist/esm/utils/LinkCell.native.js.map +1 -1
  171. package/dist/jsx/Button/index.mjs +1 -1
  172. package/dist/jsx/Button/index.mjs.map +1 -1
  173. package/dist/jsx/Button/index.native.js +5 -3
  174. package/dist/jsx/Button/index.native.js.map +1 -1
  175. package/dist/jsx/InputParts/index.mjs +11 -2
  176. package/dist/jsx/InputParts/index.mjs.map +1 -1
  177. package/dist/jsx/InputParts/index.native.js +11 -2
  178. package/dist/jsx/InputParts/index.native.js.map +1 -1
  179. package/dist/jsx/fields/FileUpload/index.mjs +6 -0
  180. package/dist/jsx/fields/FileUpload/index.mjs.map +1 -1
  181. package/dist/jsx/fields/FileUpload/index.native.js +6 -0
  182. package/dist/jsx/fields/FileUpload/index.native.js.map +1 -1
  183. package/dist/jsx/fields/Icon/drawnSet.mjs +3 -1
  184. package/dist/jsx/fields/Icon/drawnSet.mjs.map +1 -1
  185. package/dist/jsx/fields/Icon/drawnSet.native.js +3 -1
  186. package/dist/jsx/fields/Icon/drawnSet.native.js.map +1 -1
  187. package/dist/jsx/fields/RadioGroup/index.mjs +34 -5
  188. package/dist/jsx/fields/RadioGroup/index.mjs.map +1 -1
  189. package/dist/jsx/fields/RadioGroup/index.native.js +49 -7
  190. package/dist/jsx/fields/RadioGroup/index.native.js.map +1 -1
  191. package/dist/jsx/fields/ToggleGroup/index.mjs +35 -2
  192. package/dist/jsx/fields/ToggleGroup/index.mjs.map +1 -1
  193. package/dist/jsx/fields/ToggleGroup/index.native.js +42 -2
  194. package/dist/jsx/fields/ToggleGroup/index.native.js.map +1 -1
  195. package/dist/jsx/shared/nativeA11y.mjs +31 -0
  196. package/dist/jsx/shared/nativeA11y.mjs.map +1 -0
  197. package/dist/jsx/shared/nativeA11y.native.js +61 -0
  198. package/dist/jsx/shared/nativeA11y.native.js.map +1 -0
  199. package/package.json +9 -8
  200. package/src/Button/Button.fontWeight.spec.tsx +75 -0
  201. package/src/Button/index.tsx +5 -3
  202. package/src/FieldGroup.stories.tsx +3 -7
  203. package/src/FieldLayout.stories.tsx +34 -12
  204. package/src/Fieldset.stories.tsx +3 -7
  205. package/src/FormGrid.stories.tsx +5 -11
  206. package/src/InputParts/InputButton.spec.tsx +16 -0
  207. package/src/InputParts/index.tsx +19 -2
  208. package/src/fieldFamilyStories.radius.spec.tsx +118 -0
  209. package/src/fields/Barcode/Barcode.spec.tsx +2 -0
  210. package/src/fields/FileUpload/FileUpload.spec.tsx +29 -0
  211. package/src/fields/FileUpload/index.tsx +6 -0
  212. package/src/fields/Icon/drawnSet.tsx +7 -2
  213. package/src/fields/RadioGroup/RadioGroup.native.spec.tsx +67 -0
  214. package/src/fields/RadioGroup/index.tsx +82 -8
  215. package/src/fields/ToggleGroup/ToggleGroup.native.spec.tsx +73 -0
  216. package/src/fields/ToggleGroup/ToggleGroup.spec.tsx +49 -0
  217. package/src/fields/ToggleGroup/index.tsx +76 -1
  218. package/src/shared/nativeA11y.spec.ts +64 -0
  219. package/src/shared/nativeA11y.ts +80 -0
  220. package/types/Button/index.d.ts.map +1 -1
  221. package/types/InputParts/index.d.ts.map +1 -1
  222. package/types/fields/FileUpload/index.d.ts.map +1 -1
  223. package/types/fields/Icon/drawnSet.d.ts.map +1 -1
  224. package/types/fields/RadioGroup/index.d.ts +28 -1
  225. package/types/fields/RadioGroup/index.d.ts.map +1 -1
  226. package/types/fields/ToggleGroup/index.d.ts +30 -1
  227. package/types/fields/ToggleGroup/index.d.ts.map +1 -1
  228. package/types/shared/nativeA11y.d.ts +61 -0
  229. package/types/shared/nativeA11y.d.ts.map +1 -0
@@ -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
+ });
@@ -28,6 +28,7 @@ import { getSize } from "@tamagui/get-token";
28
28
  import { Skeleton } from "../../Skeleton";
29
29
  import { Field, FieldLayout } from "../../fieldLayout";
30
30
  import { formControlColors, formSelectedColors } from "../../shared/colorRamps";
31
+ import { choiceItemA11y, composeA11yName } from "../../shared/nativeA11y";
31
32
  import {
32
33
  getElevationWrapperProps,
33
34
  getFieldError,
@@ -131,6 +132,14 @@ export interface RadioGroupOption {
131
132
  disabled?: boolean;
132
133
  /** Muted secondary line under the label; rendered in `card` mode. */
133
134
  description?: string;
135
+ /**
136
+ * MPO-283 — accessible name for THIS option, overriding `label`. Use it
137
+ * when the option carries meaning the label text does not: a glyph, or a
138
+ * state encoded in fill/weight/position rather than words.
139
+ */
140
+ accessibilityLabel?: string;
141
+ /** Extra spoken context for this option (native `accessibilityHint`). */
142
+ accessibilityHint?: string;
134
143
  }
135
144
 
136
145
  export interface RadioGroupProps {
@@ -179,11 +188,33 @@ export interface RadioGroupProps {
179
188
  * primitives (same anatomy as `CheckboxGroup card`).
180
189
  */
181
190
  card?: boolean;
191
+ /**
192
+ * MPO-283 — accessible name for the whole group.
193
+ *
194
+ * tamagui does not map `aria-label` onto React Native's
195
+ * `accessibilityLabel`, so the web attribute is inert on native; this prop
196
+ * is the native channel and `aria-label` is accepted as an alias.
197
+ *
198
+ * iOS speaks elements, never non-element containers, so a name set here is
199
+ * PREFIXED onto each option's accessible name ("Phase, CREDITS") instead of
200
+ * sitting on the frame where nothing reads it. Options stay individually
201
+ * reachable and keep their own checked state.
202
+ */
203
+ accessibilityLabel?: string;
204
+ /** Alias for `accessibilityLabel`; also emitted as the web attribute. */
205
+ "aria-label"?: string;
206
+ /** Web-only name reference. Native has no id-based equivalent. */
207
+ "aria-labelledby"?: string;
208
+ /** Extra spoken context for the group (native `accessibilityHint`). */
209
+ accessibilityHint?: string;
182
210
  }
183
211
 
184
212
  interface RadioOptionItemProps {
185
213
  option: RadioGroupOption;
186
214
  itemId: string;
215
+ /** MPO-283 — the resolved native accessible name for this option. */
216
+ nativeLabel: string;
217
+ nativeHint?: string;
187
218
  a11y?: { "aria-labelledby"?: string; "aria-describedby"?: string };
188
219
  selected: boolean;
189
220
  isTabStop?: boolean;
@@ -203,6 +234,8 @@ interface RadioOptionItemProps {
203
234
  function RadioOptionItem({
204
235
  option,
205
236
  itemId,
237
+ nativeLabel,
238
+ nativeHint,
206
239
  a11y,
207
240
  selected,
208
241
  isTabStop,
@@ -277,15 +310,22 @@ function RadioOptionItem({
277
310
  {...(groupDisabled || option.disabled ? disabledChrome : undefined)}
278
311
  pointerEvents={readOnly ? "none" : undefined}
279
312
  {...a11y}
280
- // RN only exposes the radio to the a11y tree when `accessible` is set;
281
- // web keeps Tamagui's role=radio + label association untouched.
313
+ // MPO-283. RN only exposes the radio when `accessible` is set, and the
314
+ // aria-* spellings this block used to carry never reached iOS at all:
315
+ // tamagui does not map them onto React Native's accessibility props, so
316
+ // options came back as bare StaticText with no role and no checked
317
+ // state. The RN prop names are the ones iOS actually reads.
282
318
  {...(!isWeb
283
- ? {
284
- accessible: true,
285
- "aria-label": typeof option.label === "string" ? option.label : String(option.value),
286
- "aria-checked": selected,
287
- }
288
- : {})}
319
+ ? choiceItemA11y({
320
+ kind: "radio",
321
+ name: nativeLabel,
322
+ selected,
323
+ disabled: Boolean(groupDisabled || option.disabled),
324
+ hint: nativeHint,
325
+ })
326
+ : option.accessibilityLabel
327
+ ? { "aria-label": option.accessibilityLabel }
328
+ : {})}
289
329
  {...(hoverProps && {
290
330
  hoverStyle: {
291
331
  ...hoverProps,
@@ -344,6 +384,10 @@ export function RadioGroup({
344
384
  skeleton,
345
385
  compact,
346
386
  card,
387
+ accessibilityLabel,
388
+ accessibilityHint,
389
+ "aria-label": ariaLabel,
390
+ "aria-labelledby": ariaLabelledBy,
347
391
  }: RadioGroupProps) {
348
392
  // Canonical `onChange` and the alias `onValueChange` both fire from the
349
393
  // single emit site below (same reconciliation as Switch).
@@ -351,6 +395,17 @@ export function RadioGroup({
351
395
  onChange?.(val);
352
396
  onValueChange?.(val);
353
397
  };
398
+ // MPO-283: one name, two channels — tamagui drops `aria-label` on native, so
399
+ // the prop and the attribute are reconciled here.
400
+ const groupA11yName = accessibilityLabel ?? ariaLabel;
401
+ // iOS reads elements, not containers, so an explicit group name rides in
402
+ // front of each option's own name rather than sitting on the frame.
403
+ const optionA11yName = (option: RadioGroupOption) =>
404
+ composeA11yName(
405
+ groupA11yName,
406
+ option.accessibilityLabel ??
407
+ (typeof option.label === "string" ? option.label : String(option.value)),
408
+ );
354
409
  const { resolvedForm, knobProps, control, text, elevation, disabledState, id } = useFormField({
355
410
  form: formProp,
356
411
  id: idProp,
@@ -440,6 +495,8 @@ export function RadioGroup({
440
495
  <RadioOptionItem
441
496
  option={option}
442
497
  itemId={itemId}
498
+ nativeLabel={optionA11yName(option)}
499
+ nativeHint={option.accessibilityHint ?? accessibilityHint}
443
500
  a11y={a11y}
444
501
  selected={!!selected}
445
502
  isTabStop={isTabStop}
@@ -541,6 +598,15 @@ export function RadioGroup({
541
598
  )}
542
599
  <Label
543
600
  htmlFor={itemId}
601
+ // MPO-283: on native the radio itself now carries this text as
602
+ // its accessible name, so leaving the Label in the tree reads
603
+ // every option twice. Web keeps the label/control association.
604
+ {...(!isWeb
605
+ ? {
606
+ accessibilityElementsHidden: true,
607
+ importantForAccessibility: "no-hide-descendants" as const,
608
+ }
609
+ : undefined)}
544
610
  size={(size || knobProps.sizeToken) as FontSizeTokens}
545
611
  {...knobProps.body}
546
612
  color={knobProps.textAccentColor}
@@ -615,6 +681,14 @@ export function RadioGroup({
615
681
  aria-required={required || undefined}
616
682
  aria-invalid={hasError || undefined}
617
683
  aria-readonly={readOnly || undefined}
684
+ // MPO-283: web reads the attribute, Android reads the RN prop off the
685
+ // container. iOS reads neither, which is why the name is also prefixed
686
+ // onto every option above.
687
+ {...(groupA11yName ? { "aria-label": groupA11yName } : undefined)}
688
+ {...(ariaLabelledBy ? { "aria-labelledby": ariaLabelledBy } : undefined)}
689
+ {...(!isWeb && groupA11yName
690
+ ? { accessibilityLabel: groupA11yName, accessibilityRole: "radiogroup" as const }
691
+ : undefined)}
618
692
  >
619
693
  {renderItems(radioValue, emit)}
620
694
  </TamaguiRadioGroup>
@@ -0,0 +1,73 @@
1
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { ToggleGroup } from "./index";
4
+
5
+ // MPO-283. Before this the whole segmented control was absent from the iOS
6
+ // tree. See RadioGroup.native.spec.tsx for why the state object is pinned in
7
+ // shared/nativeA11y.spec.ts rather than here.
8
+ vi.mock("tamagui", async (importOriginal) => ({
9
+ ...(await importOriginal<typeof import("tamagui")>()),
10
+ isWeb: false,
11
+ }));
12
+
13
+ const severity = [
14
+ { label: "error", value: "error" },
15
+ { label: "warning", value: "warning" },
16
+ { label: "info", value: "info", disabled: true },
17
+ ];
18
+
19
+ const names = (container: HTMLElement) =>
20
+ Array.from(container.querySelectorAll("[accessibilityrole]")).map((el) => [
21
+ el.getAttribute("accessibilityrole"),
22
+ el.getAttribute("accessibilitylabel"),
23
+ ]);
24
+
25
+ describe("native ToggleGroup accessibility contract", () => {
26
+ it("puts every segment in the tree as a named control", () => {
27
+ const { container } = renderWithProviders(
28
+ <ToggleGroup label="Severity" options={severity} value="warning" />,
29
+ );
30
+ expect(names(container)).toEqual([
31
+ ["button", "error"],
32
+ ["button", "warning"],
33
+ ["button", "info"],
34
+ ]);
35
+ });
36
+
37
+ // The platform role is `button` for both kinds, measured: a radio/checkbox
38
+ // accessibilityRole takes the element out of the iOS tree entirely.
39
+ it("keeps every segment in the tree when the group takes several values", () => {
40
+ const { container } = renderWithProviders(
41
+ <ToggleGroup type="multiple" options={severity} value={["error"]} />,
42
+ );
43
+ expect(names(container)).toEqual([
44
+ ["button", "error"],
45
+ ["button", "warning"],
46
+ ["button", "info"],
47
+ ]);
48
+ });
49
+
50
+ it("prefixes a group accessibilityLabel onto each segment, because iOS never reads a container", () => {
51
+ const { container } = renderWithProviders(
52
+ <ToggleGroup accessibilityLabel="Severity" options={severity} value="warning" />,
53
+ );
54
+ expect(names(container)).toContainEqual(["button", "Severity, warning"]);
55
+ });
56
+
57
+ it("takes aria-label as an alias, since tamagui never maps it onto native", () => {
58
+ const { container } = renderWithProviders(
59
+ <ToggleGroup aria-label="Severity" options={severity} value="warning" />,
60
+ );
61
+ expect(names(container)).toContainEqual(["button", "Severity, warning"]);
62
+ });
63
+
64
+ it("lets a segment override its own name", () => {
65
+ const { container } = renderWithProviders(
66
+ <ToggleGroup
67
+ options={[{ label: "●", value: "on", accessibilityLabel: "Filled" }]}
68
+ value="on"
69
+ />,
70
+ );
71
+ expect(names(container)).toEqual([["button", "Filled"]]);
72
+ });
73
+ });
@@ -294,6 +294,55 @@ describe("ToggleGroup", () => {
294
294
  });
295
295
  });
296
296
 
297
+ describe("per-segment elevation wrapper (MPO-133)", () => {
298
+ function elevationWrapperFor(segment: HTMLElement): HTMLElement {
299
+ const wrapper = segment.parentElement as HTMLElement;
300
+ expect(wrapper?.getAttribute("style") ?? "").toMatch(/box-shadow/);
301
+ return wrapper;
302
+ }
303
+
304
+ it("lays the wrapper out as a row so the segment's zero flex-basis sizes width, not height", () => {
305
+ const { container } = renderWithHouseThemes(
306
+ <ToggleGroup label="Alignment" options={options} defaultValue="left" />,
307
+ );
308
+ const segments = [...container.querySelectorAll("[data-state]")] as HTMLElement[];
309
+ expect(segments).toHaveLength(3);
310
+ for (const segment of segments) {
311
+ expect(segment.className).toContain("_fb-0px");
312
+ const wrapper = elevationWrapperFor(segment);
313
+ expect(wrapper.className).toContain("_fd-row");
314
+ expect(wrapper.className).toContain("_fg-1");
315
+ }
316
+ });
317
+
318
+ it("keeps the row wrapper in the vertical orientation", () => {
319
+ const { container } = renderWithHouseThemes(
320
+ <ToggleGroup
321
+ label="Alignment"
322
+ options={options}
323
+ defaultValue="left"
324
+ orientation="vertical"
325
+ />,
326
+ );
327
+ const segments = [...container.querySelectorAll("[data-state]")] as HTMLElement[];
328
+ expect(segments).toHaveLength(3);
329
+ for (const segment of segments) {
330
+ expect(segment.className).toContain("_fb-0px");
331
+ expect(elevationWrapperFor(segment).className).toContain("_fd-row");
332
+ }
333
+ });
334
+
335
+ it("leaves the group frame flat while the segments carry the shadow (E-FLAT)", () => {
336
+ const { container } = renderWithHouseThemes(
337
+ <ToggleGroup label="Alignment" options={options} defaultValue="left" />,
338
+ );
339
+ const group = container.querySelector('[role="group"]') as HTMLElement;
340
+ expect(group).toBeTruthy();
341
+ expect(group.className).not.toMatch(/_bxsh-/);
342
+ expect(group.getAttribute("style") ?? "").not.toMatch(/box-shadow/);
343
+ });
344
+ });
345
+
297
346
  describe("one tab stop (LC-49 / SB-M-217)", () => {
298
347
  it("exposes exactly one tab stop among the segments", () => {
299
348
  const { container } = renderWithHouseThemes(