@multiplatform.one/forms 7.5.0 → 7.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. package/dist/cjs/Button/index.cjs +6 -10
  2. package/dist/cjs/Button/index.native.js +6 -10
  3. package/dist/cjs/Button/index.native.js.map +1 -1
  4. package/dist/cjs/ContextualSaveBar/UnsavedChangesDialog.cjs +0 -1
  5. package/dist/cjs/ContextualSaveBar/UnsavedChangesDialog.native.js +0 -1
  6. package/dist/cjs/ContextualSaveBar/UnsavedChangesDialog.native.js.map +1 -1
  7. package/dist/cjs/FieldDisplay/index.cjs +2 -1
  8. package/dist/cjs/FieldDisplay/index.native.js +9 -2
  9. package/dist/cjs/FieldDisplay/index.native.js.map +1 -1
  10. package/dist/cjs/FloatingPanel/index.cjs +8 -1
  11. package/dist/cjs/InputParts/index.cjs +64 -2
  12. package/dist/cjs/InputParts/index.native.js +70 -4
  13. package/dist/cjs/InputParts/index.native.js.map +1 -1
  14. package/dist/cjs/fields/Barcode/BarcodePreview.cjs +15 -9
  15. package/dist/cjs/fields/Barcode/BarcodePreview.native.js +15 -9
  16. package/dist/cjs/fields/Barcode/BarcodePreview.native.js.map +1 -1
  17. package/dist/cjs/fields/Combobox/index.cjs +14 -11
  18. package/dist/cjs/fields/Combobox/index.native.js +16 -11
  19. package/dist/cjs/fields/Combobox/index.native.js.map +1 -1
  20. package/dist/cjs/fields/FileUpload/index.cjs +66 -5
  21. package/dist/cjs/fields/FileUpload/index.native.js +85 -5
  22. package/dist/cjs/fields/FileUpload/index.native.js.map +1 -1
  23. package/dist/cjs/fields/Geolocation/geoValue.cjs +122 -0
  24. package/dist/cjs/fields/Geolocation/geoValue.native.js +151 -0
  25. package/dist/cjs/fields/Geolocation/geoValue.native.js.map +1 -0
  26. package/dist/cjs/fields/Geolocation/index.cjs +16 -29
  27. package/dist/cjs/fields/Geolocation/index.native.js +16 -29
  28. package/dist/cjs/fields/Geolocation/index.native.js.map +1 -1
  29. package/dist/cjs/fields/Icon/index.cjs +1 -0
  30. package/dist/cjs/fields/Icon/index.native.js +1 -0
  31. package/dist/cjs/fields/Icon/index.native.js.map +1 -1
  32. package/dist/cjs/fields/Input/index.cjs +175 -95
  33. package/dist/cjs/fields/Input/index.native.js +83 -4
  34. package/dist/cjs/fields/Input/index.native.js.map +1 -1
  35. package/dist/cjs/fields/MentionInput/index.cjs +6 -1
  36. package/dist/cjs/fields/MentionInput/index.native.js +6 -1
  37. package/dist/cjs/fields/MentionInput/index.native.js.map +1 -1
  38. package/dist/cjs/fields/OTPInput/index.cjs +7 -2
  39. package/dist/cjs/fields/OTPInput/index.native.js +7 -2
  40. package/dist/cjs/fields/OTPInput/index.native.js.map +1 -1
  41. package/dist/cjs/fields/PhoneInput/index.native.js +1 -0
  42. package/dist/cjs/fields/PhoneInput/index.native.js.map +1 -1
  43. package/dist/cjs/fields/SearchInput/index.native.js.map +1 -1
  44. package/dist/cjs/shared/utils.cjs +4 -14
  45. package/dist/cjs/shared/utils.native.js +6 -14
  46. package/dist/cjs/shared/utils.native.js.map +1 -1
  47. package/dist/esm/Button/index.mjs +6 -9
  48. package/dist/esm/Button/index.mjs.map +1 -1
  49. package/dist/esm/Button/index.native.js +6 -9
  50. package/dist/esm/Button/index.native.js.map +1 -1
  51. package/dist/esm/ContextualSaveBar/UnsavedChangesDialog.mjs +0 -1
  52. package/dist/esm/ContextualSaveBar/UnsavedChangesDialog.mjs.map +1 -1
  53. package/dist/esm/ContextualSaveBar/UnsavedChangesDialog.native.js +0 -1
  54. package/dist/esm/ContextualSaveBar/UnsavedChangesDialog.native.js.map +1 -1
  55. package/dist/esm/FieldDisplay/index.mjs +2 -1
  56. package/dist/esm/FieldDisplay/index.mjs.map +1 -1
  57. package/dist/esm/FieldDisplay/index.native.js +9 -2
  58. package/dist/esm/FieldDisplay/index.native.js.map +1 -1
  59. package/dist/esm/FloatingPanel/index.mjs +8 -1
  60. package/dist/esm/FloatingPanel/index.mjs.map +1 -1
  61. package/dist/esm/InputParts/index.mjs +64 -2
  62. package/dist/esm/InputParts/index.mjs.map +1 -1
  63. package/dist/esm/InputParts/index.native.js +70 -4
  64. package/dist/esm/InputParts/index.native.js.map +1 -1
  65. package/dist/esm/fields/Barcode/BarcodePreview.mjs +15 -9
  66. package/dist/esm/fields/Barcode/BarcodePreview.mjs.map +1 -1
  67. package/dist/esm/fields/Barcode/BarcodePreview.native.js +15 -9
  68. package/dist/esm/fields/Barcode/BarcodePreview.native.js.map +1 -1
  69. package/dist/esm/fields/Combobox/index.mjs +14 -11
  70. package/dist/esm/fields/Combobox/index.mjs.map +1 -1
  71. package/dist/esm/fields/Combobox/index.native.js +16 -11
  72. package/dist/esm/fields/Combobox/index.native.js.map +1 -1
  73. package/dist/esm/fields/FileUpload/index.mjs +66 -5
  74. package/dist/esm/fields/FileUpload/index.mjs.map +1 -1
  75. package/dist/esm/fields/FileUpload/index.native.js +85 -5
  76. package/dist/esm/fields/FileUpload/index.native.js.map +1 -1
  77. package/dist/esm/fields/Geolocation/geoValue.mjs +95 -0
  78. package/dist/esm/fields/Geolocation/geoValue.mjs.map +1 -0
  79. package/dist/esm/fields/Geolocation/geoValue.native.js +121 -0
  80. package/dist/esm/fields/Geolocation/geoValue.native.js.map +1 -0
  81. package/dist/esm/fields/Geolocation/index.mjs +15 -28
  82. package/dist/esm/fields/Geolocation/index.mjs.map +1 -1
  83. package/dist/esm/fields/Geolocation/index.native.js +15 -28
  84. package/dist/esm/fields/Geolocation/index.native.js.map +1 -1
  85. package/dist/esm/fields/Icon/index.mjs +1 -0
  86. package/dist/esm/fields/Icon/index.mjs.map +1 -1
  87. package/dist/esm/fields/Icon/index.native.js +1 -0
  88. package/dist/esm/fields/Icon/index.native.js.map +1 -1
  89. package/dist/esm/fields/Input/index.mjs +177 -98
  90. package/dist/esm/fields/Input/index.mjs.map +1 -1
  91. package/dist/esm/fields/Input/index.native.js +85 -7
  92. package/dist/esm/fields/Input/index.native.js.map +1 -1
  93. package/dist/esm/fields/MentionInput/index.mjs +6 -1
  94. package/dist/esm/fields/MentionInput/index.mjs.map +1 -1
  95. package/dist/esm/fields/MentionInput/index.native.js +6 -1
  96. package/dist/esm/fields/MentionInput/index.native.js.map +1 -1
  97. package/dist/esm/fields/OTPInput/index.mjs +7 -3
  98. package/dist/esm/fields/OTPInput/index.mjs.map +1 -1
  99. package/dist/esm/fields/OTPInput/index.native.js +7 -3
  100. package/dist/esm/fields/OTPInput/index.native.js.map +1 -1
  101. package/dist/esm/fields/PhoneInput/index.mjs.map +1 -1
  102. package/dist/esm/fields/PhoneInput/index.native.js +1 -0
  103. package/dist/esm/fields/PhoneInput/index.native.js.map +1 -1
  104. package/dist/esm/fields/SearchInput/index.mjs.map +1 -1
  105. package/dist/esm/fields/SearchInput/index.native.js.map +1 -1
  106. package/dist/esm/shared/utils.mjs +4 -14
  107. package/dist/esm/shared/utils.mjs.map +1 -1
  108. package/dist/esm/shared/utils.native.js +6 -14
  109. package/dist/esm/shared/utils.native.js.map +1 -1
  110. package/dist/jsx/Button/index.mjs +6 -9
  111. package/dist/jsx/Button/index.mjs.map +1 -1
  112. package/dist/jsx/Button/index.native.js +6 -10
  113. package/dist/jsx/Button/index.native.js.map +1 -1
  114. package/dist/jsx/ContextualSaveBar/UnsavedChangesDialog.mjs +0 -1
  115. package/dist/jsx/ContextualSaveBar/UnsavedChangesDialog.mjs.map +1 -1
  116. package/dist/jsx/ContextualSaveBar/UnsavedChangesDialog.native.js +0 -1
  117. package/dist/jsx/ContextualSaveBar/UnsavedChangesDialog.native.js.map +1 -1
  118. package/dist/jsx/FieldDisplay/index.mjs +2 -1
  119. package/dist/jsx/FieldDisplay/index.mjs.map +1 -1
  120. package/dist/jsx/FieldDisplay/index.native.js +9 -2
  121. package/dist/jsx/FieldDisplay/index.native.js.map +1 -1
  122. package/dist/jsx/FloatingPanel/index.mjs +8 -1
  123. package/dist/jsx/FloatingPanel/index.mjs.map +1 -1
  124. package/dist/jsx/InputParts/index.mjs +64 -2
  125. package/dist/jsx/InputParts/index.mjs.map +1 -1
  126. package/dist/jsx/InputParts/index.native.js +70 -4
  127. package/dist/jsx/InputParts/index.native.js.map +1 -1
  128. package/dist/jsx/fields/Barcode/BarcodePreview.mjs +15 -9
  129. package/dist/jsx/fields/Barcode/BarcodePreview.mjs.map +1 -1
  130. package/dist/jsx/fields/Barcode/BarcodePreview.native.js +15 -9
  131. package/dist/jsx/fields/Barcode/BarcodePreview.native.js.map +1 -1
  132. package/dist/jsx/fields/Combobox/index.mjs +14 -11
  133. package/dist/jsx/fields/Combobox/index.mjs.map +1 -1
  134. package/dist/jsx/fields/Combobox/index.native.js +16 -11
  135. package/dist/jsx/fields/Combobox/index.native.js.map +1 -1
  136. package/dist/jsx/fields/FileUpload/index.mjs +66 -5
  137. package/dist/jsx/fields/FileUpload/index.mjs.map +1 -1
  138. package/dist/jsx/fields/FileUpload/index.native.js +85 -5
  139. package/dist/jsx/fields/FileUpload/index.native.js.map +1 -1
  140. package/dist/jsx/fields/Geolocation/geoValue.mjs +95 -0
  141. package/dist/jsx/fields/Geolocation/geoValue.mjs.map +1 -0
  142. package/dist/jsx/fields/Geolocation/geoValue.native.js +151 -0
  143. package/dist/jsx/fields/Geolocation/geoValue.native.js.map +1 -0
  144. package/dist/jsx/fields/Geolocation/index.mjs +15 -28
  145. package/dist/jsx/fields/Geolocation/index.mjs.map +1 -1
  146. package/dist/jsx/fields/Geolocation/index.native.js +16 -29
  147. package/dist/jsx/fields/Geolocation/index.native.js.map +1 -1
  148. package/dist/jsx/fields/Icon/index.mjs +1 -0
  149. package/dist/jsx/fields/Icon/index.mjs.map +1 -1
  150. package/dist/jsx/fields/Icon/index.native.js +1 -0
  151. package/dist/jsx/fields/Icon/index.native.js.map +1 -1
  152. package/dist/jsx/fields/Input/index.mjs +177 -98
  153. package/dist/jsx/fields/Input/index.mjs.map +1 -1
  154. package/dist/jsx/fields/Input/index.native.js +83 -4
  155. package/dist/jsx/fields/Input/index.native.js.map +1 -1
  156. package/dist/jsx/fields/MentionInput/index.mjs +6 -1
  157. package/dist/jsx/fields/MentionInput/index.mjs.map +1 -1
  158. package/dist/jsx/fields/MentionInput/index.native.js +6 -1
  159. package/dist/jsx/fields/MentionInput/index.native.js.map +1 -1
  160. package/dist/jsx/fields/OTPInput/index.mjs +7 -3
  161. package/dist/jsx/fields/OTPInput/index.mjs.map +1 -1
  162. package/dist/jsx/fields/OTPInput/index.native.js +7 -2
  163. package/dist/jsx/fields/OTPInput/index.native.js.map +1 -1
  164. package/dist/jsx/fields/PhoneInput/index.mjs.map +1 -1
  165. package/dist/jsx/fields/PhoneInput/index.native.js +1 -0
  166. package/dist/jsx/fields/PhoneInput/index.native.js.map +1 -1
  167. package/dist/jsx/fields/SearchInput/index.mjs.map +1 -1
  168. package/dist/jsx/fields/SearchInput/index.native.js.map +1 -1
  169. package/dist/jsx/shared/utils.mjs +4 -14
  170. package/dist/jsx/shared/utils.mjs.map +1 -1
  171. package/dist/jsx/shared/utils.native.js +6 -14
  172. package/dist/jsx/shared/utils.native.js.map +1 -1
  173. package/package.json +7 -7
  174. package/src/Button/Button.density.spec.tsx +118 -32
  175. package/src/Button/Button.labelInk.spec.tsx +72 -19
  176. package/src/Button/Button.lc71.spec.tsx +10 -3
  177. package/src/Button/Button.stories.tsx +97 -7
  178. package/src/Button/index.tsx +22 -12
  179. package/src/ContextualSaveBar/UnsavedChangesDialog.tsx +0 -1
  180. package/src/FieldDisplay/FieldDisplay.spec.tsx +11 -2
  181. package/src/FieldDisplay/index.tsx +5 -0
  182. package/src/FloatingPanel/index.tsx +22 -1
  183. package/src/FloatingPanel/panelWidthContract.spec.ts +1 -0
  184. package/src/InputParts/index.tsx +100 -3
  185. package/src/fields/Barcode/BarcodePreview.spec.tsx +153 -5
  186. package/src/fields/Barcode/BarcodePreview.stories.tsx +104 -0
  187. package/src/fields/Barcode/BarcodePreview.tsx +47 -15
  188. package/src/fields/ColorPicker/PresetSwatches.spec.tsx +71 -0
  189. package/src/fields/ColorPicker/PresetSwatches.stories.tsx +94 -0
  190. package/src/fields/Combobox/Combobox.spec.tsx +64 -1
  191. package/src/fields/Combobox/index.tsx +22 -11
  192. package/src/fields/FileUpload/FileUpload.spec.tsx +71 -0
  193. package/src/fields/FileUpload/index.tsx +100 -3
  194. package/src/fields/Geolocation/Geolocation.spec.tsx +126 -0
  195. package/src/fields/Geolocation/geoValue.spec.ts +169 -0
  196. package/src/fields/Geolocation/geoValue.ts +149 -0
  197. package/src/fields/Geolocation/index.tsx +15 -24
  198. package/src/fields/Icon/Icon.stories.tsx +1 -1
  199. package/src/fields/Icon/index.tsx +1 -0
  200. package/src/fields/Input/index.tsx +180 -81
  201. package/src/fields/MentionInput/index.tsx +9 -1
  202. package/src/fields/OTPInput/OtpInput.spec.tsx +36 -4
  203. package/src/fields/OTPInput/index.tsx +18 -3
  204. package/src/fields/OTPInput/otpCellHeightTouch.spec.ts +44 -0
  205. package/src/fields/PhoneInput/index.tsx +1 -0
  206. package/src/fields/SearchInput/index.tsx +5 -3
  207. package/src/shared/utils.spec.ts +46 -0
  208. package/src/shared/utils.ts +31 -16
  209. package/types/Button/index.d.ts +8 -3
  210. package/types/Button/index.d.ts.map +1 -1
  211. package/types/ContextualSaveBar/UnsavedChangesDialog.d.ts.map +1 -1
  212. package/types/FieldDisplay/index.d.ts.map +1 -1
  213. package/types/InputParts/index.d.ts +9 -5
  214. package/types/InputParts/index.d.ts.map +1 -1
  215. package/types/fields/Barcode/BarcodePreview.d.ts +20 -9
  216. package/types/fields/Barcode/BarcodePreview.d.ts.map +1 -1
  217. package/types/fields/Checkbox/index.d.ts +3 -3
  218. package/types/fields/Combobox/index.d.ts.map +1 -1
  219. package/types/fields/FileUpload/index.d.ts.map +1 -1
  220. package/types/fields/Geolocation/geoValue.d.ts +45 -0
  221. package/types/fields/Geolocation/geoValue.d.ts.map +1 -0
  222. package/types/fields/Geolocation/index.d.ts.map +1 -1
  223. package/types/fields/Icon/index.d.ts.map +1 -1
  224. package/types/fields/Input/index.d.ts +18 -6
  225. package/types/fields/Input/index.d.ts.map +1 -1
  226. package/types/fields/MentionInput/index.d.ts.map +1 -1
  227. package/types/fields/OTPInput/index.d.ts +12 -0
  228. package/types/fields/OTPInput/index.d.ts.map +1 -1
  229. package/types/fields/PhoneInput/index.d.ts.map +1 -1
  230. package/types/fields/RadioGroup/index.d.ts +2 -2
  231. package/types/fields/SearchInput/index.d.ts.map +1 -1
  232. package/types/fields/ToggleGroup/index.d.ts +1 -1
  233. package/types/shared/utils.d.ts.map +1 -1
@@ -1,6 +1,6 @@
1
1
  import type { ComponentProps, ReactElement, ReactNode } from "react";
2
- import { useRef } from "react";
3
- import { WarningCircleIcon } from "@phosphor-icons/react";
2
+ import { useCallback, useRef } from "react";
3
+ import { ClipboardIcon, WarningCircleIcon } from "@phosphor-icons/react";
4
4
  import { MIN_PRESS_TARGET, pressTargetHitSlop } from "@multiplatform.one/theme";
5
5
  import type {
6
6
  InputKeyboardType,
@@ -9,7 +9,7 @@ import type {
9
9
  SizeTokens,
10
10
  TamaguiElement,
11
11
  } from "tamagui";
12
- import { Text } from "tamagui";
12
+ import { isWeb, Text } from "tamagui";
13
13
  import { Skeleton } from "../../Skeleton";
14
14
  import { resolveFieldInputProps } from "../../fieldDefaults";
15
15
  import { Field, FieldLayout } from "../../fieldLayout";
@@ -27,6 +27,23 @@ import { LinkCell } from "../../utils/LinkCell";
27
27
 
28
28
  type InputAreaProps = ComponentProps<typeof InputParts.Area>;
29
29
 
30
+ /** F1 / reference §10: standalone Input rings at offset 0, not the inset −2. */
31
+ const INPUT_FIELD_RING_STYLE_ID = "mp-input-field-ring-offset";
32
+
33
+ export function ensureInputFieldRingOffset(): void {
34
+ if (typeof document === "undefined") return;
35
+ if (document.getElementById(INPUT_FIELD_RING_STYLE_ID)) return;
36
+ const tag = document.createElement("style");
37
+ tag.id = INPUT_FIELD_RING_STYLE_ID;
38
+ tag.textContent = `[data-mp-input-field][data-mp-ring="inset"]:has(input:focus),
39
+ [data-mp-input-field][data-mp-ring="inset"]:has(textarea:focus),
40
+ [data-mp-input-field][data-mp-ring="inset"]:has([data-mp-input-area]:focus),
41
+ [data-mp-input-field][data-mp-ring="inset"]:focus-visible {
42
+ outline-offset: 0px !important;
43
+ }`;
44
+ document.head.appendChild(tag);
45
+ }
46
+
30
47
  export interface InputProps {
31
48
  name?: string;
32
49
  form?: AnyFormApi;
@@ -91,6 +108,25 @@ export interface InputProps {
91
108
  compact?: boolean;
92
109
  /** When true, renders without borders/background (for toolbar integration) */
93
110
  chromeless?: boolean;
111
+ /**
112
+ * Declared copy end-cap (CopyField / LC-16 second consumer of Input.Button).
113
+ * Empty omits the cap. Disabled keeps geometry and drops the tab stop.
114
+ */
115
+ copy?: boolean;
116
+ /** Called after a copy attempt. CopyField routes this through notify(). */
117
+ onCopyResult?: (ok: boolean) => void;
118
+ }
119
+
120
+ const COPY_LABEL = "Copy to clipboard";
121
+
122
+ async function writeClipboard(text: string): Promise<boolean> {
123
+ try {
124
+ if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) return false;
125
+ await navigator.clipboard.writeText(text);
126
+ return true;
127
+ } catch {
128
+ return false;
129
+ }
94
130
  }
95
131
 
96
132
  export function Input({
@@ -130,6 +166,8 @@ export function Input({
130
166
  skeleton,
131
167
  compact,
132
168
  chromeless,
169
+ copy,
170
+ onCopyResult,
133
171
  }: InputProps) {
134
172
  const { resolvedForm, knobProps, control, id } = useFormField({
135
173
  form: formProp,
@@ -138,6 +176,7 @@ export function Input({
138
176
  });
139
177
  const inputRef = useRef<TamaguiElement>(null);
140
178
  const focusTrigger = useForwardFocus(inputRef);
179
+ if (isWeb) ensureInputFieldRingOffset();
141
180
 
142
181
  const resolvedValidators = useResolvedValidators(required, validators, label, name);
143
182
 
@@ -161,6 +200,10 @@ export function Input({
161
200
  "data-visual-height": String(visualHeight),
162
201
  "data-press-floor": belowFloor ? "slop" : "box",
163
202
  "data-ring-target": "box",
203
+ "data-radius-class": "DEFAULT",
204
+ "data-radius-part": "Input",
205
+ "data-ring-offset": "0",
206
+ "data-mp-input-field": "true",
164
207
  ...(belowFloor ? { hitSlop: pressTargetHitSlop(visualHeight) } : undefined),
165
208
  } as const;
166
209
 
@@ -186,7 +229,8 @@ export function Input({
186
229
  const layoutBind = {
187
230
  id,
188
231
  label: visibleLabel,
189
- labelProps,
232
+ // R7 / §6.1: label is weight 400. Consumer labelProps still eject last.
233
+ labelProps: { fontWeight: "400" as const, ...labelProps },
190
234
  helperText,
191
235
  required,
192
236
  disabled,
@@ -201,6 +245,23 @@ export function Input({
201
245
  const areaName =
202
246
  visibleLabel || !resolvedAriaLabel ? undefined : { "aria-label": resolvedAriaLabel };
203
247
 
248
+ const handleCopy = useCallback(
249
+ async (text: string) => {
250
+ const ok = await writeClipboard(text);
251
+ onCopyResult?.(ok);
252
+ },
253
+ [onCopyResult],
254
+ );
255
+
256
+ // Identifier values (tokens, URLs) ride the mono register (LC-12).
257
+ const identifierProps = copy
258
+ ? ({
259
+ fontFamily: "$mono",
260
+ letterSpacing: 0,
261
+ "data-register": "identifier",
262
+ } as const)
263
+ : undefined;
264
+
204
265
  // Render skeleton placeholder — FieldLayout keeps label/helper anatomy
205
266
  // (LC-20); only the control chrome is the skeleton.
206
267
  if (skeleton) {
@@ -226,96 +287,134 @@ export function Input({
226
287
  handleChange?: (text: string) => void,
227
288
  handleBlur?: (...args: any[]) => void,
228
289
  hasError?: boolean,
229
- ) => (
230
- <InputParts.Box
231
- {...boxContract}
232
- {...(handleBlur ? { onBlur: handleBlur } : undefined)}
233
- // LC-40: the Box owns the disabled chrome treatment (read-only renders
234
- // its own branch below and never dims).
235
- disabled={disabled}
236
- {...chromelessProps}
237
- >
238
- {leftIcon && (
239
- <InputParts.Icon adornment="leading" {...focusTrigger}>
240
- {leftIcon}
241
- </InputParts.Icon>
242
- )}
243
- {readOnly ? (
244
- linkResolver ? (
245
- <LinkCell value={inputValue} linkResolver={linkResolver}>
246
- <Text
290
+ ) => {
291
+ const copyText = String(inputValue ?? "");
292
+ // Empty omits the cap so the field stays one tab stop (LC-49).
293
+ const showCopy = Boolean(copy && copyText);
294
+ const activateCopy = () => {
295
+ if (disabled) return;
296
+ void handleCopy(copyText);
297
+ };
298
+ return (
299
+ <InputParts.Box
300
+ {...boxContract}
301
+ {...(handleBlur ? { onBlur: handleBlur } : undefined)}
302
+ // LC-40: the Box owns the disabled chrome treatment (read-only renders
303
+ // its own branch below and never dims).
304
+ disabled={disabled}
305
+ {...(showCopy ? { paddingInlineEnd: 0 } : undefined)}
306
+ {...chromelessProps}
307
+ >
308
+ {leftIcon && (
309
+ <InputParts.Icon adornment="leading" {...focusTrigger}>
310
+ {leftIcon}
311
+ </InputParts.Icon>
312
+ )}
313
+ {readOnly ? (
314
+ linkResolver ? (
315
+ <LinkCell value={inputValue} linkResolver={linkResolver}>
316
+ <Text
317
+ id={id}
318
+ aria-readonly="true"
319
+ aria-required={required || undefined}
320
+ aria-invalid={hasError || undefined}
321
+ {...areaName}
322
+ {...identifierProps}
323
+ color="$color"
324
+ >
325
+ {inputValue ?? ""}
326
+ </Text>
327
+ </LinkCell>
328
+ ) : (
329
+ <InputParts.Area
330
+ ref={inputRef}
331
+ value={inputValue ?? ""}
332
+ readOnly
247
333
  id={id}
248
334
  aria-readonly="true"
249
335
  aria-required={required || undefined}
250
336
  aria-invalid={hasError || undefined}
337
+ {...identifierProps}
251
338
  {...areaName}
252
- color="$color"
253
- >
254
- {inputValue ?? ""}
255
- </Text>
256
- </LinkCell>
339
+ />
340
+ )
257
341
  ) : (
258
342
  <InputParts.Area
259
343
  ref={inputRef}
260
- value={inputValue ?? ""}
261
- readOnly
344
+ {...(purposeInputProps.inputMode !== undefined
345
+ ? { inputMode: purposeInputProps.inputMode }
346
+ : undefined)}
347
+ {...(purposeInputProps.keyboardType !== undefined
348
+ ? { keyboardType: purposeInputProps.keyboardType }
349
+ : undefined)}
350
+ {...(purposeInputProps.autoComplete !== undefined
351
+ ? { autoComplete: purposeInputProps.autoComplete }
352
+ : undefined)}
353
+ {...inputProps}
354
+ {...identifierProps}
355
+ {...(multiline !== undefined ? { multiline } : undefined)}
356
+ {...(maxLength !== undefined ? { maxLength } : undefined)}
357
+ {...(keyboardType !== undefined ? { keyboardType } : undefined)}
358
+ {...(textContentType !== undefined ? { textContentType } : undefined)}
359
+ {...(autoComplete !== undefined ? { autoComplete } : undefined)}
360
+ {...(secureTextEntry !== undefined ? { secureTextEntry } : undefined)}
361
+ {...(placeholder !== undefined ? { placeholder } : undefined)}
362
+ disabled={disabled}
262
363
  id={id}
263
- aria-readonly="true"
364
+ {...(handleChange ? { value: inputValue ?? "" } : { defaultValue: inputValue })}
264
365
  aria-required={required || undefined}
265
366
  aria-invalid={hasError || undefined}
266
367
  {...areaName}
368
+ onChange={onChange}
369
+ onChangeText={(text) => {
370
+ if (disabled) return;
371
+ handleChange?.(text);
372
+ onChangeText?.(text);
373
+ }}
267
374
  />
268
- )
269
- ) : (
270
- <InputParts.Area
271
- ref={inputRef}
272
- {...(purposeInputProps.inputMode !== undefined
273
- ? { inputMode: purposeInputProps.inputMode }
274
- : undefined)}
275
- {...(purposeInputProps.keyboardType !== undefined
276
- ? { keyboardType: purposeInputProps.keyboardType }
277
- : undefined)}
278
- {...(purposeInputProps.autoComplete !== undefined
279
- ? { autoComplete: purposeInputProps.autoComplete }
280
- : undefined)}
281
- {...inputProps}
282
- {...(multiline !== undefined ? { multiline } : undefined)}
283
- {...(maxLength !== undefined ? { maxLength } : undefined)}
284
- {...(keyboardType !== undefined ? { keyboardType } : undefined)}
285
- {...(textContentType !== undefined ? { textContentType } : undefined)}
286
- {...(autoComplete !== undefined ? { autoComplete } : undefined)}
287
- {...(secureTextEntry !== undefined ? { secureTextEntry } : undefined)}
288
- {...(placeholder !== undefined ? { placeholder } : undefined)}
289
- disabled={disabled}
290
- id={id}
291
- {...(handleChange ? { value: inputValue ?? "" } : { defaultValue: inputValue })}
292
- aria-required={required || undefined}
293
- aria-invalid={hasError || undefined}
294
- {...areaName}
295
- onChange={onChange}
296
- onChangeText={(text) => {
297
- if (disabled) return;
298
- handleChange?.(text);
299
- onChangeText?.(text);
300
- }}
301
- />
302
- )}
303
- {rightIcon ? (
304
- <InputParts.Icon
305
- adornment="trailing"
306
- {...(rightIconInteractive ? undefined : focusTrigger)}
307
- >
308
- {rightIcon}
309
- </InputParts.Icon>
310
- ) : hasError ? (
311
- // M3 / Polaris / Spectrum: in-field error glyph (color is not the
312
- // only channel). Omitted when the consumer owns the trailing slot.
313
- <InputParts.Icon adornment="trailing" color={formCommonColors.error}>
314
- <WarningCircleIcon aria-hidden />
315
- </InputParts.Icon>
316
- ) : null}
317
- </InputParts.Box>
318
- );
375
+ )}
376
+ {showCopy ? (
377
+ <InputParts.Button
378
+ glyphRing
379
+ size={resolvedSize}
380
+ type="button"
381
+ disabled={disabled}
382
+ tabIndex={disabled ? -1 : 0}
383
+ aria-label={COPY_LABEL}
384
+ hitSlop={belowFloor ? pressTargetHitSlop(visualHeight) : undefined}
385
+ onPress={disabled ? undefined : activateCopy}
386
+ onKeyDown={
387
+ ((e: KeyboardEvent) => {
388
+ if (disabled) return;
389
+ if (e.key === "Enter" || e.key === " ") {
390
+ e.preventDefault();
391
+ e.stopPropagation();
392
+ activateCopy();
393
+ }
394
+ }) as unknown as () => void
395
+ }
396
+ >
397
+ <InputParts.Icon aria-hidden>
398
+ <ClipboardIcon />
399
+ </InputParts.Icon>
400
+ </InputParts.Button>
401
+ ) : rightIcon ? (
402
+ <InputParts.Icon
403
+ adornment="trailing"
404
+ {...(rightIconInteractive ? undefined : focusTrigger)}
405
+ >
406
+ {rightIcon}
407
+ </InputParts.Icon>
408
+ ) : hasError ? (
409
+ // M3 / Polaris / Spectrum: in-field error glyph (color is not the
410
+ // only channel). Omitted when the consumer owns the trailing slot.
411
+ <InputParts.Icon adornment="trailing" color={formCommonColors.error}>
412
+ <WarningCircleIcon aria-hidden />
413
+ </InputParts.Icon>
414
+ ) : null}
415
+ </InputParts.Box>
416
+ );
417
+ };
319
418
 
320
419
  if (!resolvedForm || !name) {
321
420
  return (
@@ -790,11 +790,19 @@ export function MentionInput<
790
790
 
791
791
  // ── Floating dropdown ──────────────────────────────────────
792
792
 
793
+ // Same clobber the FloatingPanel web branch carried (MPO-38): the complete
794
+ // `elevatedSurface` fragment ends in a web-only `style: { boxShadow }`, and a
795
+ // second `style` prop on one element replaces rather than merges — so
796
+ // spreading it after the positioning style dropped `position: fixed`. Fold
797
+ // its style in instead of letting the two compete.
798
+ const { style: elevatedSurfaceStyle, ...elevatedSurfaceProps } = panel.knobProps.elevatedSurface;
799
+
793
800
  const floatingDropdown = panel.mounted ? (
794
801
  <YStack
795
802
  {...getFloatingProps({
796
803
  ref: panel.refs.setFloating,
797
804
  style: {
805
+ ...elevatedSurfaceStyle,
798
806
  position: panel.strategy,
799
807
  top: panel.y ?? 0,
800
808
  left: panel.x ?? 0,
@@ -810,7 +818,7 @@ export function MentionInput<
810
818
  })}
811
819
  outlineWidth={0}
812
820
  pointerEvents={panel.visible ? "auto" : "none"}
813
- {...panel.knobProps.elevatedSurface}
821
+ {...elevatedSurfaceProps}
814
822
  borderRadius={panel.dropdownRadius}
815
823
  padding={0}
816
824
  maxHeight={suggestionsMaxHeight}
@@ -2,11 +2,17 @@ import { useForm } from "@tanstack/react-form";
2
2
  import { act, fireEvent, waitFor } from "@testing-library/react";
3
3
  import { describe, expect, it, vi } from "vitest";
4
4
  import { TestProviders, renderWithProviders } from "@multiplatform.one/test-utils";
5
- import { Preset, resolveRadiusClass, type BorderRadius } from "@multiplatform.one/theme";
5
+ import { getSize } from "@tamagui/get-token";
6
+ import { getVariableValue, type SizeTokens } from "tamagui";
7
+ import {
8
+ Preset,
9
+ resolveRadiusClass,
10
+ sizeRecipeForToken,
11
+ type BorderRadius,
12
+ } from "@multiplatform.one/theme";
6
13
  import { Form } from "../../Form";
7
14
  import { Button } from "../../Button";
8
- import { getFieldHeight } from "../../shared/utils";
9
- import { OTPInput, extractOtpCode } from "./index";
15
+ import { OTPInput, extractOtpCode, otpCellHeightPx } from "./index";
10
16
 
11
17
  describe("OTPInput", () => {
12
18
  describe("basic rendering", () => {
@@ -708,7 +714,10 @@ describe("OTPInput", () => {
708
714
  }
709
715
 
710
716
  it("cell measures height/2 at full and 0 at none, riding the token scale between", () => {
711
- const heightPx = getFieldHeight("$4");
717
+ // the cell's own painted edge — `height={cellSize}` is the `$size` token,
718
+ // so the circle is that token halved. Reading the recipe here instead would
719
+ // pass on a desktop run and lie on a touch one (recipe $4 = 48 vs token 44).
720
+ const heightPx = getVariableValue(getSize("$4" as SizeTokens)) as number;
712
721
  for (const stop of RADIUS_STOPS) {
713
722
  const result = renderWithProviders(
714
723
  <Preset overrides={{ borderRadius: stop }}>
@@ -727,5 +736,28 @@ describe("OTPInput", () => {
727
736
  expect(paintedRadiusPx(cell as HTMLElement), `cell @ ${stop}`).toBe(expected);
728
737
  }
729
738
  });
739
+
740
+ it("halves the cell's painted token, not the touch-floored recipe height", () => {
741
+ for (const token of ["$2", "$3", "$4", "$6"] as const) {
742
+ const painted = getVariableValue(getSize(token as SizeTokens)) as number;
743
+ expect(otpCellHeightPx(token), `painted height @ ${token}`).toBe(painted);
744
+ // The recipe's touch column is a different number at EVERY step (44/44/48/68
745
+ // against 28/36/44/64). Halving it puts the radius past the cell's own edge,
746
+ // and the engine clamps it back — the browser clamp MPO-56 exists to kill.
747
+ const touchHeight = sizeRecipeForToken(token, { touch: true }).height;
748
+ expect(touchHeight, `touch recipe @ ${token}`).not.toBe(painted);
749
+ expect(
750
+ resolveRadiusClass("CIRCULAR-AT-FULL", "full", { heightPx: otpCellHeightPx(token) }),
751
+ `full @ ${token}`,
752
+ ).toBe(painted / 2);
753
+ }
754
+ });
755
+
756
+ it("an explicit numeric cell size is its own painted height", () => {
757
+ expect(otpCellHeightPx(52)).toBe(52);
758
+ expect(
759
+ resolveRadiusClass("CIRCULAR-AT-FULL", "full", { heightPx: otpCellHeightPx(52) }),
760
+ ).toBe(26);
761
+ });
730
762
  });
731
763
  });
@@ -15,7 +15,8 @@ import {
15
15
  useState,
16
16
  } from "react";
17
17
  import { useProps, type SizeTokens, type TamaguiElement } from "tamagui";
18
- import { Input, Text, XStack, isWeb, useDidFinishSSR } from "tamagui";
18
+ import { Input, Text, XStack, getVariableValue, isWeb, useDidFinishSSR } from "tamagui";
19
+ import { getSize } from "@tamagui/get-token";
19
20
  import { Button } from "../../Button";
20
21
  import { Field, FieldLayout } from "../../fieldLayout";
21
22
  import {
@@ -132,9 +133,23 @@ function digitFontSize(cellSize: string | number | undefined): string {
132
133
  return "$6";
133
134
  }
134
135
 
135
- function otpCellHeightPx(cellSize: string | number | undefined): number {
136
+ /**
137
+ * The height the cell PAINTS, which is what CIRCULAR-AT-FULL halves (DG-RAD-02).
138
+ *
139
+ * The cell sets `height={cellSize}`, so its painted edge is the `$size` token —
140
+ * NOT the touch-floored recipe height. On a touch surface those disagree at every
141
+ * step (`sizeRecipeForToken("$4", { touch: true }).height` is 48 against a 44pt
142
+ * token, `$2` is 44 against 28), and a resolver fed the recipe height resolves 24
143
+ * on a 44pt cell: over the edge, so the engine clamps it back to 22. That clamp is
144
+ * exactly the defect MPO-56 killed, so the radius rides the painted token instead.
145
+ * Same source as the avatar square face (`avatarTokenPx`, components/Avatar).
146
+ */
147
+ export function otpCellHeightPx(cellSize: string | number | undefined): number {
136
148
  if (typeof cellSize === "number" && Number.isFinite(cellSize) && cellSize > 0) return cellSize;
137
- return getFieldHeight((cellSize ?? "$4") as SizeTokens);
149
+ const token = (cellSize ?? "$4") as SizeTokens;
150
+ const painted = getVariableValue(getSize(token));
151
+ if (typeof painted === "number" && Number.isFinite(painted) && painted > 0) return painted;
152
+ return getFieldHeight(token);
138
153
  }
139
154
 
140
155
  /**
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ /**
4
+ * The touch arm of DG-RAD-02 for the OTP cell, in its own file because the
5
+ * surface probe is module-level state: `getFieldHeight` reads `isTouchSurface()`
6
+ * on every call, and a desktop run cannot tell the painted token from the recipe
7
+ * height (they are the same number there). Only on a touch surface do the two
8
+ * ladders separate — 28/36/44/64 painted against 44/44/48/68 recipe — which is
9
+ * where a resolver fed the wrong one hands the circle back to the engine clamp.
10
+ */
11
+ vi.mock("@multiplatform.one/theme", async () => {
12
+ const actual = await vi.importActual<typeof import("@multiplatform.one/theme")>(
13
+ "@multiplatform.one/theme",
14
+ );
15
+ return { ...actual, isTouchSurface: () => true };
16
+ });
17
+
18
+ import { getSize } from "@tamagui/get-token";
19
+ import { resolveRadiusClass } from "@multiplatform.one/theme";
20
+ import { getVariableValue, type SizeTokens } from "tamagui";
21
+ import { getFieldHeight } from "../../shared/utils";
22
+ import { otpCellHeightPx } from "./index";
23
+
24
+ const tokens = ["$2", "$3", "$4", "$6"] as const;
25
+
26
+ describe("OTP cell geometry on a touch surface (DG-RAD-02)", () => {
27
+ it("the two ladders really do separate, or this file proves nothing", () => {
28
+ for (const token of tokens) {
29
+ const painted = getVariableValue(getSize(token as SizeTokens)) as number;
30
+ expect(getFieldHeight(token as SizeTokens), `recipe height @ ${token}`).not.toBe(painted);
31
+ }
32
+ });
33
+
34
+ it("the cell resolves against the height it paints, not the recipe height", () => {
35
+ for (const token of tokens) {
36
+ const painted = getVariableValue(getSize(token as SizeTokens)) as number;
37
+ expect(otpCellHeightPx(token), `painted height @ ${token}`).toBe(painted);
38
+ expect(
39
+ resolveRadiusClass("CIRCULAR-AT-FULL", "full", { heightPx: otpCellHeightPx(token) }),
40
+ `full @ ${token}`,
41
+ ).toBe(painted / 2);
42
+ }
43
+ });
44
+ });
@@ -347,6 +347,7 @@ export function PhoneInput<
347
347
  <XStack
348
348
  alignItems="center"
349
349
  gap="$1.5"
350
+ // size-recipe-escape: zero-reset — the country trigger rides inside Input.Box, which owns the inset (LC-71)
350
351
  paddingHorizontal={0}
351
352
  height="100%"
352
353
  cursor={disabled ? "not-allowed" : "pointer"}
@@ -7,9 +7,11 @@
7
7
  * second focusable and rings its own glyph (chip-dismiss, LC-16).
8
8
  *
9
9
  * Clear is a View sized from the size-recipe square (same geometry as
10
- * Input.Button). Origin/main's TButton swallows className, so the
11
- * chip-dismiss ring classes cannot ride Input.Button until glyphRing
12
- * lands do not rewrite Input/ from this lane.
10
+ * Input.Button). It was hand-rolled because TButton swallows className, so
11
+ * the chip-dismiss ring classes could not ride Input.Button. `glyphRing`
12
+ * now exists (InputParts/index.tsx, MPO-52) and renders this exact pair, so
13
+ * this block can collapse onto `<Input.Button glyphRing>` — in SearchInput's
14
+ * own lane, not this one.
13
15
  */
14
16
  import { MagnifyingGlassIcon, XIcon } from "@phosphor-icons/react";
15
17
  import {
@@ -1,4 +1,6 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
+ import type { BorderRadius } from "@multiplatform.one/theme";
3
+ import { borderRadiusMap, resolveRadiusClass } from "@multiplatform.one/theme";
2
4
  import {
3
5
  clampRadiusForControl,
4
6
  clampRadiusForLargeComponent,
@@ -180,6 +182,50 @@ describe("clampRadiusInStateProps", () => {
180
182
  expect(result?.[key]?.borderRadius).toBe(THUMB_H / 2);
181
183
  }
182
184
  });
185
+
186
+ // LC-16 ONE-IMPLEMENTATION. The BINARY row is doctrine-GENERATED
187
+ // (radiusClassTable.generated.ts, from docs/design-guidelines.md §13).
188
+ // A state bag must resolve through THAT table, not through a copy of it
189
+ // living in forms — otherwise a DG-RAD edit reaches the base radius and
190
+ // silently misses every hover/press/focus bag. Driven off borderRadiusMap
191
+ // so a stop added to the doctrine is covered without touching this test.
192
+ const STOPS = Object.keys(borderRadiusMap) as BorderRadius[];
193
+
194
+ it.each(STOPS)("agrees with resolveRadiusClass('BINARY') at the %s stop", (stop) => {
195
+ for (const heightPx of [THUMB_H, TRACK_H, 21]) {
196
+ const expected = resolveRadiusClass("BINARY", stop, { heightPx });
197
+ for (const spelling of [stop, borderRadiusMap[stop]]) {
198
+ const result = clampRadiusInStateProps(
199
+ { borderRadius: spelling },
200
+ { radiusClass: "BINARY", heightPx },
201
+ );
202
+ expect(result?.borderRadius).toBe(expected);
203
+ }
204
+ }
205
+ });
206
+
207
+ it("reads the token/stop mapping off borderRadiusMap rather than a hand-kept copy", () => {
208
+ for (const stop of STOPS) {
209
+ const byName = clampRadiusInStateProps(
210
+ { borderRadius: stop },
211
+ { radiusClass: "BINARY", heightPx: THUMB_H },
212
+ );
213
+ const byToken = clampRadiusInStateProps(
214
+ { borderRadius: borderRadiusMap[stop] },
215
+ { radiusClass: "BINARY", heightPx: THUMB_H },
216
+ );
217
+ expect(byName?.borderRadius).toBe(byToken?.borderRadius);
218
+ }
219
+ });
220
+
221
+ it("refuses a BINARY bag with no height instead of guessing one", () => {
222
+ expect(() =>
223
+ clampRadiusInStateProps({ borderRadius: "$12" }, { radiusClass: "BINARY" }),
224
+ ).toThrow(/heightPx/);
225
+ expect(() =>
226
+ clampRadiusInStateProps({ borderRadius: "$0" }, { radiusClass: "BINARY", heightPx: 0 }),
227
+ ).toThrow(/heightPx/);
228
+ });
183
229
  });
184
230
  });
185
231
 
@@ -229,20 +229,25 @@ export interface ClampRadiusInStatePropsOptions {
229
229
 
230
230
  const STATE_STYLE_KEYS = ["hoverStyle", "pressStyle", "focusStyle", "focusVisibleStyle"] as const;
231
231
 
232
- const RADIUS_TOKEN_TO_STOP: Record<string, BorderRadius> = {
233
- $0: "none",
234
- "0": "none",
235
- none: "none",
236
- $2: "small",
237
- small: "small",
238
- $4: "medium",
239
- medium: "medium",
240
- $6: "large",
241
- large: "large",
242
- $12: "full",
243
- full: "full",
244
- };
232
+ /**
233
+ * Token/stop-name lookup DERIVED from `borderRadiusMap` — the same single
234
+ * source `otpRadiusStop` reads a few lines up. A hand-kept second copy of the
235
+ * token scale is how this file would end up with two answers for one stop, so
236
+ * there is only one (LC-16 ONE-IMPLEMENTATION).
237
+ */
238
+ const RADIUS_TOKEN_TO_STOP = Object.fromEntries(
239
+ (Object.keys(borderRadiusMap) as BorderRadius[]).flatMap((stop) => [
240
+ [stop, stop],
241
+ [borderRadiusMap[stop], stop],
242
+ ]),
243
+ ) as Record<string, BorderRadius>;
245
244
 
245
+ /**
246
+ * A state bag carries whatever the recipe put in it — a stop name, a radius
247
+ * token, or a raw px value. An unrecognised value resolves to `full`, which
248
+ * for BINARY is h/2: the safe direction, because the class has no
249
+ * intermediate value to fall back to.
250
+ */
246
251
  function radiusValueToStop(value: unknown): BorderRadius {
247
252
  if (value == null || value === 0) return "none";
248
253
  const key = String(value).trim();
@@ -251,8 +256,18 @@ function radiusValueToStop(value: unknown): BorderRadius {
251
256
  }
252
257
 
253
258
  /**
254
- * BINARY class contract (DG-RAD-03 / resolveRadiusClass("BINARY")): none is
255
- * square, every other stop is h/2. Tracks square with their thumbs (§R2).
259
+ * BINARY class contract (DG-RAD-03): none is square, every other stop is h/2,
260
+ * and tracks square with their thumbs (§R2).
261
+ *
262
+ * The table is NOT restated here. It is read from the doctrine-GENERATED
263
+ * resolver in `@multiplatform.one/theme`, so a DG-RAD edit reaches per-state
264
+ * override bags the same way it reaches the base radius — a local copy of the
265
+ * BINARY row would keep answering with the old doctrine (LC-16).
266
+ *
267
+ * The `heightPx` guard is the CALLER's contract rather than the table's: the
268
+ * doctrine only needs a height for the h/2 stops, but a part that declares
269
+ * BINARY without its height would resolve at `none` and throw at every other
270
+ * stop. Refusing up front beats working in one state and throwing in the next.
256
271
  */
257
272
  function resolveBinaryStateRadius(value: unknown, heightPx: number | undefined): number {
258
273
  if (heightPx === undefined || heightPx <= 0) {
@@ -260,7 +275,7 @@ function resolveBinaryStateRadius(value: unknown, heightPx: number | undefined):
260
275
  "clampRadiusInStateProps: BINARY resolves to 0 or h/2 and needs heightPx — the part's own painted height (shorter edge)",
261
276
  );
262
277
  }
263
- return radiusValueToStop(value) === "none" ? 0 : heightPx / 2;
278
+ return resolveRadiusClass("BINARY", radiusValueToStop(value), { heightPx });
264
279
  }
265
280
 
266
281
  function clampOneStateRadius(