@multiplatform.one/forms 8.3.0 → 8.4.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 (265) hide show
  1. package/dist/cjs/ControlGroup/index.cjs +2 -1
  2. package/dist/cjs/ControlGroup/index.native.js +2 -1
  3. package/dist/cjs/ControlGroup/index.native.js.map +1 -1
  4. package/dist/cjs/FloatingPanel/index.native.js +20 -2
  5. package/dist/cjs/FloatingPanel/index.native.js.map +1 -1
  6. package/dist/cjs/InputParts/index.cjs +1 -0
  7. package/dist/cjs/InputParts/index.native.js +1 -0
  8. package/dist/cjs/InputParts/index.native.js.map +1 -1
  9. package/dist/cjs/Wheel/index.cjs +22 -6
  10. package/dist/cjs/Wheel/index.native.js +26 -8
  11. package/dist/cjs/Wheel/index.native.js.map +1 -1
  12. package/dist/cjs/fields/DatePicker/parts.cjs +33 -30
  13. package/dist/cjs/fields/DatePicker/parts.native.js +34 -30
  14. package/dist/cjs/fields/DatePicker/parts.native.js.map +1 -1
  15. package/dist/cjs/fields/FileUpload/index.cjs +32 -15
  16. package/dist/cjs/fields/FileUpload/index.native.js +40 -19
  17. package/dist/cjs/fields/FileUpload/index.native.js.map +1 -1
  18. package/dist/cjs/fields/ImageField/index.cjs +35 -20
  19. package/dist/cjs/fields/ImageField/index.native.js +37 -20
  20. package/dist/cjs/fields/ImageField/index.native.js.map +1 -1
  21. package/dist/cjs/fields/Progress/index.cjs +5 -0
  22. package/dist/cjs/fields/Progress/index.native.js +5 -0
  23. package/dist/cjs/fields/Progress/index.native.js.map +1 -1
  24. package/dist/cjs/fields/Slider/index.cjs +38 -12
  25. package/dist/cjs/fields/Slider/index.native.js +39 -11
  26. package/dist/cjs/fields/Slider/index.native.js.map +1 -1
  27. package/dist/cjs/fields/Switch/index.cjs +10 -6
  28. package/dist/cjs/fields/Switch/index.native.js +10 -6
  29. package/dist/cjs/fields/Switch/index.native.js.map +1 -1
  30. package/dist/cjs/fields/TimePicker/parts.cjs +9 -10
  31. package/dist/cjs/fields/TimePicker/parts.native.js +9 -10
  32. package/dist/cjs/fields/TimePicker/parts.native.js.map +1 -1
  33. package/dist/cjs/fields/ToggleGroup/index.cjs +1 -0
  34. package/dist/cjs/fields/ToggleGroup/index.native.js +1 -0
  35. package/dist/cjs/fields/ToggleGroup/index.native.js.map +1 -1
  36. package/dist/cjs/fields/pickerHandle.cjs +18 -0
  37. package/dist/cjs/fields/pickerHandle.native.js +21 -0
  38. package/dist/cjs/fields/pickerHandle.native.js.map +1 -0
  39. package/dist/cjs/hooks/useHoldCue.cjs +54 -0
  40. package/dist/cjs/hooks/useHoldCue.native.js +62 -0
  41. package/dist/cjs/hooks/useHoldCue.native.js.map +1 -0
  42. package/dist/cjs/index.cjs +4 -0
  43. package/dist/cjs/index.gnome.cjs +4 -0
  44. package/dist/cjs/index.gnome.native.js +4 -0
  45. package/dist/cjs/index.gnome.native.js.map +1 -1
  46. package/dist/cjs/index.native.js +4 -0
  47. package/dist/cjs/index.native.js.map +1 -1
  48. package/dist/cjs/shared/colorRamps.cjs +9 -7
  49. package/dist/cjs/shared/colorRamps.native.js +9 -7
  50. package/dist/cjs/shared/colorRamps.native.js.map +1 -1
  51. package/dist/cjs/shared/controlTrack.cjs +32 -0
  52. package/dist/cjs/shared/controlTrack.native.js +35 -0
  53. package/dist/cjs/shared/controlTrack.native.js.map +1 -0
  54. package/dist/esm/ControlGroup/index.mjs +2 -1
  55. package/dist/esm/ControlGroup/index.mjs.map +1 -1
  56. package/dist/esm/ControlGroup/index.native.js +2 -1
  57. package/dist/esm/ControlGroup/index.native.js.map +1 -1
  58. package/dist/esm/FloatingPanel/index.native.js +20 -2
  59. package/dist/esm/FloatingPanel/index.native.js.map +1 -1
  60. package/dist/esm/InputParts/index.mjs +1 -0
  61. package/dist/esm/InputParts/index.mjs.map +1 -1
  62. package/dist/esm/InputParts/index.native.js +1 -0
  63. package/dist/esm/InputParts/index.native.js.map +1 -1
  64. package/dist/esm/Wheel/index.mjs +23 -7
  65. package/dist/esm/Wheel/index.mjs.map +1 -1
  66. package/dist/esm/Wheel/index.native.js +27 -9
  67. package/dist/esm/Wheel/index.native.js.map +1 -1
  68. package/dist/esm/fields/DatePicker/parts.mjs +35 -32
  69. package/dist/esm/fields/DatePicker/parts.mjs.map +1 -1
  70. package/dist/esm/fields/DatePicker/parts.native.js +36 -32
  71. package/dist/esm/fields/DatePicker/parts.native.js.map +1 -1
  72. package/dist/esm/fields/FileUpload/index.mjs +33 -16
  73. package/dist/esm/fields/FileUpload/index.mjs.map +1 -1
  74. package/dist/esm/fields/FileUpload/index.native.js +41 -20
  75. package/dist/esm/fields/FileUpload/index.native.js.map +1 -1
  76. package/dist/esm/fields/ImageField/index.mjs +36 -21
  77. package/dist/esm/fields/ImageField/index.mjs.map +1 -1
  78. package/dist/esm/fields/ImageField/index.native.js +38 -21
  79. package/dist/esm/fields/ImageField/index.native.js.map +1 -1
  80. package/dist/esm/fields/Progress/index.mjs +5 -0
  81. package/dist/esm/fields/Progress/index.mjs.map +1 -1
  82. package/dist/esm/fields/Progress/index.native.js +5 -0
  83. package/dist/esm/fields/Progress/index.native.js.map +1 -1
  84. package/dist/esm/fields/Slider/index.mjs +38 -12
  85. package/dist/esm/fields/Slider/index.mjs.map +1 -1
  86. package/dist/esm/fields/Slider/index.native.js +39 -11
  87. package/dist/esm/fields/Slider/index.native.js.map +1 -1
  88. package/dist/esm/fields/Switch/index.mjs +10 -6
  89. package/dist/esm/fields/Switch/index.mjs.map +1 -1
  90. package/dist/esm/fields/Switch/index.native.js +10 -6
  91. package/dist/esm/fields/Switch/index.native.js.map +1 -1
  92. package/dist/esm/fields/TimePicker/parts.mjs +9 -10
  93. package/dist/esm/fields/TimePicker/parts.mjs.map +1 -1
  94. package/dist/esm/fields/TimePicker/parts.native.js +9 -10
  95. package/dist/esm/fields/TimePicker/parts.native.js.map +1 -1
  96. package/dist/esm/fields/ToggleGroup/index.mjs +2 -1
  97. package/dist/esm/fields/ToggleGroup/index.mjs.map +1 -1
  98. package/dist/esm/fields/ToggleGroup/index.native.js +2 -1
  99. package/dist/esm/fields/ToggleGroup/index.native.js.map +1 -1
  100. package/dist/esm/fields/pickerHandle.mjs +2 -0
  101. package/dist/esm/fields/pickerHandle.mjs.map +1 -0
  102. package/dist/esm/fields/pickerHandle.native.js +2 -0
  103. package/dist/esm/fields/pickerHandle.native.js.map +1 -0
  104. package/dist/esm/hooks/useHoldCue.mjs +27 -0
  105. package/dist/esm/hooks/useHoldCue.mjs.map +1 -0
  106. package/dist/esm/hooks/useHoldCue.native.js +32 -0
  107. package/dist/esm/hooks/useHoldCue.native.js.map +1 -0
  108. package/dist/esm/index.gnome.mjs +2 -1
  109. package/dist/esm/index.gnome.mjs.map +1 -1
  110. package/dist/esm/index.gnome.native.js +2 -1
  111. package/dist/esm/index.gnome.native.js.map +1 -1
  112. package/dist/esm/index.mjs +2 -1
  113. package/dist/esm/index.mjs.map +1 -1
  114. package/dist/esm/index.native.js +2 -1
  115. package/dist/esm/index.native.js.map +1 -1
  116. package/dist/esm/shared/colorRamps.mjs +9 -7
  117. package/dist/esm/shared/colorRamps.mjs.map +1 -1
  118. package/dist/esm/shared/colorRamps.native.js +9 -7
  119. package/dist/esm/shared/colorRamps.native.js.map +1 -1
  120. package/dist/esm/shared/controlTrack.mjs +7 -0
  121. package/dist/esm/shared/controlTrack.mjs.map +1 -0
  122. package/dist/esm/shared/controlTrack.native.js +7 -0
  123. package/dist/esm/shared/controlTrack.native.js.map +1 -0
  124. package/dist/esm/shared/nativeOverlayWindow.mjs.map +1 -1
  125. package/dist/esm/shared/systemControls.mjs.map +1 -1
  126. package/dist/esm/shared/systemControls.native.js.map +1 -1
  127. package/dist/jsx/ControlGroup/index.mjs +2 -1
  128. package/dist/jsx/ControlGroup/index.mjs.map +1 -1
  129. package/dist/jsx/ControlGroup/index.native.js +2 -1
  130. package/dist/jsx/ControlGroup/index.native.js.map +1 -1
  131. package/dist/jsx/FloatingPanel/index.native.js +20 -2
  132. package/dist/jsx/FloatingPanel/index.native.js.map +1 -1
  133. package/dist/jsx/InputParts/index.mjs +1 -0
  134. package/dist/jsx/InputParts/index.mjs.map +1 -1
  135. package/dist/jsx/InputParts/index.native.js +1 -0
  136. package/dist/jsx/InputParts/index.native.js.map +1 -1
  137. package/dist/jsx/Wheel/index.mjs +23 -7
  138. package/dist/jsx/Wheel/index.mjs.map +1 -1
  139. package/dist/jsx/Wheel/index.native.js +26 -8
  140. package/dist/jsx/Wheel/index.native.js.map +1 -1
  141. package/dist/jsx/fields/DatePicker/parts.mjs +35 -32
  142. package/dist/jsx/fields/DatePicker/parts.mjs.map +1 -1
  143. package/dist/jsx/fields/DatePicker/parts.native.js +34 -30
  144. package/dist/jsx/fields/DatePicker/parts.native.js.map +1 -1
  145. package/dist/jsx/fields/FileUpload/index.mjs +33 -16
  146. package/dist/jsx/fields/FileUpload/index.mjs.map +1 -1
  147. package/dist/jsx/fields/FileUpload/index.native.js +40 -19
  148. package/dist/jsx/fields/FileUpload/index.native.js.map +1 -1
  149. package/dist/jsx/fields/ImageField/index.mjs +36 -21
  150. package/dist/jsx/fields/ImageField/index.mjs.map +1 -1
  151. package/dist/jsx/fields/ImageField/index.native.js +37 -20
  152. package/dist/jsx/fields/ImageField/index.native.js.map +1 -1
  153. package/dist/jsx/fields/Progress/index.mjs +5 -0
  154. package/dist/jsx/fields/Progress/index.mjs.map +1 -1
  155. package/dist/jsx/fields/Progress/index.native.js +5 -0
  156. package/dist/jsx/fields/Progress/index.native.js.map +1 -1
  157. package/dist/jsx/fields/Slider/index.mjs +38 -12
  158. package/dist/jsx/fields/Slider/index.mjs.map +1 -1
  159. package/dist/jsx/fields/Slider/index.native.js +39 -11
  160. package/dist/jsx/fields/Slider/index.native.js.map +1 -1
  161. package/dist/jsx/fields/Switch/index.mjs +10 -6
  162. package/dist/jsx/fields/Switch/index.mjs.map +1 -1
  163. package/dist/jsx/fields/Switch/index.native.js +10 -6
  164. package/dist/jsx/fields/Switch/index.native.js.map +1 -1
  165. package/dist/jsx/fields/TimePicker/parts.mjs +9 -10
  166. package/dist/jsx/fields/TimePicker/parts.mjs.map +1 -1
  167. package/dist/jsx/fields/TimePicker/parts.native.js +9 -10
  168. package/dist/jsx/fields/TimePicker/parts.native.js.map +1 -1
  169. package/dist/jsx/fields/ToggleGroup/index.mjs +2 -1
  170. package/dist/jsx/fields/ToggleGroup/index.mjs.map +1 -1
  171. package/dist/jsx/fields/ToggleGroup/index.native.js +1 -0
  172. package/dist/jsx/fields/ToggleGroup/index.native.js.map +1 -1
  173. package/dist/jsx/fields/pickerHandle.mjs +2 -0
  174. package/dist/jsx/fields/pickerHandle.mjs.map +1 -0
  175. package/dist/jsx/fields/pickerHandle.native.js +21 -0
  176. package/dist/jsx/fields/pickerHandle.native.js.map +1 -0
  177. package/dist/jsx/hooks/useHoldCue.mjs +27 -0
  178. package/dist/jsx/hooks/useHoldCue.mjs.map +1 -0
  179. package/dist/jsx/hooks/useHoldCue.native.js +62 -0
  180. package/dist/jsx/hooks/useHoldCue.native.js.map +1 -0
  181. package/dist/jsx/index.gnome.mjs +2 -1
  182. package/dist/jsx/index.gnome.mjs.map +1 -1
  183. package/dist/jsx/index.gnome.native.js +4 -0
  184. package/dist/jsx/index.gnome.native.js.map +1 -1
  185. package/dist/jsx/index.js +2 -1
  186. package/dist/jsx/index.js.map +1 -1
  187. package/dist/jsx/index.mjs +2 -1
  188. package/dist/jsx/index.mjs.map +1 -1
  189. package/dist/jsx/index.native.js +4 -0
  190. package/dist/jsx/index.native.js.map +1 -1
  191. package/dist/jsx/shared/colorRamps.mjs +9 -7
  192. package/dist/jsx/shared/colorRamps.mjs.map +1 -1
  193. package/dist/jsx/shared/colorRamps.native.js +9 -7
  194. package/dist/jsx/shared/colorRamps.native.js.map +1 -1
  195. package/dist/jsx/shared/controlTrack.mjs +7 -0
  196. package/dist/jsx/shared/controlTrack.mjs.map +1 -0
  197. package/dist/jsx/shared/controlTrack.native.js +35 -0
  198. package/dist/jsx/shared/controlTrack.native.js.map +1 -0
  199. package/dist/jsx/shared/nativeOverlayWindow.mjs.map +1 -1
  200. package/dist/jsx/shared/nativeOverlayWindow.native.js.map +1 -1
  201. package/dist/jsx/shared/systemControls.mjs.map +1 -1
  202. package/package.json +11 -10
  203. package/src/Button/Button.controlTiers.spec.tsx +2 -2
  204. package/src/ControlGroup/index.tsx +2 -1
  205. package/src/FloatingPanel/FloatingPanel.holdCue.native.spec.tsx +72 -0
  206. package/src/FloatingPanel/index.native.tsx +9 -1
  207. package/src/InputParts/index.tsx +1 -0
  208. package/src/Wheel/Wheel.edgeFade.spec.tsx +74 -0
  209. package/src/Wheel/index.tsx +25 -11
  210. package/src/fields/DatePicker/dateFieldMarks.spec.tsx +154 -0
  211. package/src/fields/DatePicker/parts.tsx +33 -30
  212. package/src/fields/FileUpload/index.tsx +45 -16
  213. package/src/fields/ImageField/index.tsx +42 -19
  214. package/src/fields/Progress/index.tsx +3 -0
  215. package/src/fields/Slider/Slider.system.native.spec.tsx +44 -0
  216. package/src/fields/Slider/index.tsx +40 -12
  217. package/src/fields/Stepper/Stepper.stories.tsx +42 -0
  218. package/src/fields/Switch/Switch.spec.tsx +14 -10
  219. package/src/fields/Switch/index.tsx +11 -7
  220. package/src/fields/TimePicker/parts.tsx +8 -8
  221. package/src/fields/ToggleGroup/index.tsx +7 -1
  222. package/src/fields/fieldTriggerSlot.spec.tsx +89 -0
  223. package/src/fields/pickerHandle.ts +13 -0
  224. package/src/hooks/useHoldCue.ts +26 -0
  225. package/src/index.gnome.ts +1 -0
  226. package/src/index.ts +2 -0
  227. package/src/overlayGround.spec.ts +5 -3
  228. package/src/shared/OverlayWindow.tsx +2 -2
  229. package/src/shared/colorRamps.ts +9 -6
  230. package/src/shared/controlTrack.ts +7 -0
  231. package/src/shared/nativeOverlayWindow.tsx +1 -1
  232. package/src/shared/systemControls.ts +10 -2
  233. package/types/ControlGroup/index.d.ts.map +1 -1
  234. package/types/FloatingPanel/index.native.d.ts.map +1 -1
  235. package/types/InputParts/index.d.ts +4 -4
  236. package/types/InputParts/index.d.ts.map +1 -1
  237. package/types/Wheel/index.d.ts.map +1 -1
  238. package/types/fields/DatePicker/parts.d.ts +4 -3
  239. package/types/fields/DatePicker/parts.d.ts.map +1 -1
  240. package/types/fields/FileUpload/index.d.ts +6 -1
  241. package/types/fields/FileUpload/index.d.ts.map +1 -1
  242. package/types/fields/ImageField/index.d.ts +7 -2
  243. package/types/fields/ImageField/index.d.ts.map +1 -1
  244. package/types/fields/Progress/index.d.ts.map +1 -1
  245. package/types/fields/Slider/index.d.ts.map +1 -1
  246. package/types/fields/Switch/index.d.ts.map +1 -1
  247. package/types/fields/ToggleGroup/index.d.ts.map +1 -1
  248. package/types/fields/pickerHandle.d.ts +12 -0
  249. package/types/fields/pickerHandle.d.ts.map +1 -0
  250. package/types/hooks/useHoldCue.d.ts +10 -0
  251. package/types/hooks/useHoldCue.d.ts.map +1 -0
  252. package/types/index.d.ts +2 -0
  253. package/types/index.d.ts.map +1 -1
  254. package/types/index.gnome.d.ts +1 -0
  255. package/types/index.gnome.d.ts.map +1 -1
  256. package/types/shared/OverlayWindow.d.ts +2 -2
  257. package/types/shared/OverlayWindow.d.ts.map +1 -1
  258. package/types/shared/colorRamps.d.ts +8 -6
  259. package/types/shared/colorRamps.d.ts.map +1 -1
  260. package/types/shared/controlTrack.d.ts +3 -0
  261. package/types/shared/controlTrack.d.ts.map +1 -0
  262. package/types/shared/nativeOverlayWindow.d.ts +1 -1
  263. package/types/shared/nativeOverlayWindow.d.ts.map +1 -1
  264. package/types/shared/systemControls.d.ts +2 -1
  265. package/types/shared/systemControls.d.ts.map +1 -1
@@ -2,7 +2,16 @@ import { transitionProps } from "@multiplatform.one/theme";
2
2
  import { useTouchSurface } from "@multiplatform.one/theme";
3
3
  import { FileIcon, UploadIcon, XIcon } from "@phosphor-icons/react";
4
4
  import type { DeepKeys, DeepValue } from "@tanstack/form-core";
5
- import { type DragEvent, type ReactNode, useCallback, useEffect, useRef, useState } from "react";
5
+ import {
6
+ type DragEvent,
7
+ type ReactNode,
8
+ type Ref,
9
+ useCallback,
10
+ useEffect,
11
+ useImperativeHandle,
12
+ useRef,
13
+ useState,
14
+ } from "react";
6
15
  import { useMedia, useProps, type SizeTokens } from "tamagui";
7
16
  import { Image, isWeb, Text, XStack, YStack, styled } from "tamagui";
8
17
  import { Button, IconButton, IconButtonGroup } from "../../Button";
@@ -26,6 +35,7 @@ import {
26
35
  import { formCommonColors, formDropzoneColors, formInputColors } from "../../shared/colorRamps";
27
36
  import { t } from "../../shared/t";
28
37
  import type { FieldComponentProps, Validator } from "../../types";
38
+ import type { FieldPickerHandle, FieldPickerTrigger } from "../pickerHandle";
29
39
  import type { NativePickedFile, NativePickerMode } from "./filePickerTypes";
30
40
  // Platform-split: the web twin is inert, the `.native` twin opens
31
41
  // expo-document-picker / expo-image-picker (Geolocation map-surface pattern).
@@ -114,6 +124,10 @@ export type FileUploadProps<
114
124
  existing?: { src: string; name?: string; isImage?: boolean } | null;
115
125
  /** Removes the existing file; given, its tile carries a remove disc. */
116
126
  onExistingRemove?: () => void;
127
+ /** LC-290: `open()` runs the picker from the app's own control. */
128
+ ref?: Ref<FieldPickerHandle>;
129
+ /** LC-290: the app's own control, drawn in place of the dropzone; call `open` from its press. */
130
+ trigger?: FieldPickerTrigger;
117
131
  };
118
132
 
119
133
  const UploadAreaFrame = styled(YStack, {
@@ -203,6 +217,7 @@ export function FileUpload<
203
217
  >(props: FileUploadProps<TParentData, TName, TFieldValidator, TFormValidator, TData>) {
204
218
  const hydrationTouch = useTouchSurface();
205
219
  const media = useMedia();
220
+ const { ref: pickerRef, trigger } = props;
206
221
  const {
207
222
  defaultValue,
208
223
  form,
@@ -230,6 +245,8 @@ export function FileUpload<
230
245
  existing,
231
246
  onExistingRemove,
232
247
  id: idProp,
248
+ ref: _ref,
249
+ trigger: _trigger,
233
250
  ...fieldProps
234
251
  } = useProps(props);
235
252
 
@@ -615,6 +632,9 @@ export function FileUpload<
615
632
  fileInputRef.current?.click();
616
633
  }, [disabled, readOnly]);
617
634
 
635
+ const requestFilesRef = useRef<() => void>(openFileDialog);
636
+ useImperativeHandle(pickerRef, () => ({ open: () => requestFilesRef.current() }), []);
637
+
618
638
  // Native has no <input type="file">. The same press opens the OS picker and
619
639
  // routes its result through the identical validate → setState → field path,
620
640
  // so `accept`, `maxSize`, `multiple` and the removal list behave as on web.
@@ -912,6 +932,29 @@ export function FileUpload<
912
932
  : () => {
913
933
  void openNativePicker(onValueChange, onBlurHandler);
914
934
  };
935
+ requestFilesRef.current = requestFiles;
936
+ const hiddenInput = isWeb ? (
937
+ <input
938
+ ref={fileInputRef}
939
+ type="file"
940
+ accept={accept}
941
+ multiple={multiple}
942
+ tabIndex={-1}
943
+ onChange={(e) => {
944
+ handleFileSelect(e.target.files, onValueChange, e.target.files);
945
+ onBlurHandler?.();
946
+ }}
947
+ style={{ display: "none" }}
948
+ disabled={!canInteract}
949
+ />
950
+ ) : null;
951
+ if (trigger)
952
+ return (
953
+ <YStack data-file-upload="trigger" alignItems="flex-start">
954
+ {trigger(requestFiles)}
955
+ {hiddenInput}
956
+ </YStack>
957
+ );
915
958
 
916
959
  // Drag-and-drop, keyboard activation and the focus ring are DOM concerns;
917
960
  // native gets a plain press target on the same surface instead.
@@ -1150,21 +1193,7 @@ export function FileUpload<
1150
1193
  </Text>
1151
1194
  )}
1152
1195
 
1153
- {isWeb ? (
1154
- <input
1155
- ref={fileInputRef}
1156
- type="file"
1157
- accept={accept}
1158
- multiple={multiple}
1159
- tabIndex={-1}
1160
- onChange={(e) => {
1161
- handleFileSelect(e.target.files, onValueChange, e.target.files);
1162
- onBlurHandler?.();
1163
- }}
1164
- style={{ display: "none" }}
1165
- disabled={!canInteract}
1166
- />
1167
- ) : null}
1196
+ {hiddenInput}
1168
1197
  </YStack>
1169
1198
 
1170
1199
  {((multiple && hasRows) || transfers.length > 0) && (
@@ -18,8 +18,9 @@ import {
18
18
  type DisabledSurfaceProps,
19
19
  type KnobProps,
20
20
  } from "@multiplatform.one/theme";
21
- import type { DragEvent, ReactNode } from "react";
22
- import { useCallback, useEffect, useRef, useState } from "react";
21
+ import type { DragEvent, ReactNode, Ref } from "react";
22
+ import { useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
23
+ import type { FieldPickerHandle, FieldPickerTrigger } from "../pickerHandle";
23
24
  import type { LabelProps, SizeTokens } from "tamagui";
24
25
  import { Image, Text as TamaguiText, View, YStack, isWeb, styled } from "tamagui";
25
26
  import { IconButton } from "../../Button";
@@ -78,6 +79,10 @@ export interface ImageFieldProps {
78
79
  objectFit?: "cover" | "contain" | "fill" | "none";
79
80
  /** When true, renders a skeleton placeholder instead of the image field */
80
81
  skeleton?: boolean;
82
+ /** LC-290: `open()` runs the picker from the app's own control. */
83
+ ref?: Ref<FieldPickerHandle>;
84
+ /** LC-290: the app's own control, drawn in place of the image frame; call `open` from its press. */
85
+ trigger?: FieldPickerTrigger;
81
86
  }
82
87
 
83
88
  const ImageFrame = styled(View, {
@@ -112,6 +117,8 @@ type ImageControlProps = {
112
117
  onChangeProp?: (val: string | File | null) => void;
113
118
  onInvalid?: (message: string | undefined) => void;
114
119
  blurHandler?: (...args: any[]) => void;
120
+ pickerRef?: Ref<FieldPickerHandle>;
121
+ trigger?: FieldPickerTrigger;
115
122
  };
116
123
 
117
124
  function isImageFile(file: File, accept: string): boolean {
@@ -148,6 +155,8 @@ function ImageControl({
148
155
  onChangeProp,
149
156
  onInvalid,
150
157
  blurHandler,
158
+ pickerRef,
159
+ trigger,
151
160
  }: ImageControlProps) {
152
161
  const describedBy = useFieldDescribedBy();
153
162
  const fieldA11y = useFieldA11y();
@@ -230,6 +239,7 @@ function ImageControl({
230
239
  if (!canInteract) return;
231
240
  fileInputRef.current?.click();
232
241
  }, [canInteract]);
242
+ useImperativeHandle(pickerRef, () => ({ open: openFileDialog }), [openFileDialog]);
233
243
 
234
244
  const isInteractiveChild = useCallback((e: { target: unknown; currentTarget: unknown }) => {
235
245
  const target = e.target as HTMLElement | null;
@@ -313,6 +323,31 @@ function ImageControl({
313
323
  const emptyName =
314
324
  typeof label === "string" && label ? t("{{label}}: add image", { label }) : t("Add image");
315
325
 
326
+ const hiddenInput = isWeb ? (
327
+ <input
328
+ ref={fileInputRef}
329
+ type="file"
330
+ accept={accept}
331
+ tabIndex={-1}
332
+ aria-hidden={true}
333
+ onChange={(e) => {
334
+ const files = e.target.files;
335
+ e.target.value = "";
336
+ if (!files || files.length === 0) return;
337
+ applyFile(files[0]);
338
+ }}
339
+ style={{ display: "none" }}
340
+ disabled={!canInteract}
341
+ />
342
+ ) : null;
343
+ if (trigger)
344
+ return (
345
+ <YStack alignItems="flex-start" data-image-field="trigger">
346
+ {trigger(openFileDialog)}
347
+ {hiddenInput}
348
+ </YStack>
349
+ );
350
+
316
351
  return (
317
352
  <YStack alignItems="flex-start" {...knobProps.gap}>
318
353
  <ImageFrame
@@ -398,23 +433,7 @@ function ImageControl({
398
433
  </View>
399
434
  )}
400
435
 
401
- {isWeb && (
402
- <input
403
- ref={fileInputRef}
404
- type="file"
405
- accept={accept}
406
- tabIndex={-1}
407
- aria-hidden={true}
408
- onChange={(e) => {
409
- const files = e.target.files;
410
- e.target.value = "";
411
- if (!files || files.length === 0) return;
412
- applyFile(files[0]);
413
- }}
414
- style={{ display: "none" }}
415
- disabled={!canInteract}
416
- />
417
- )}
436
+ {hiddenInput}
418
437
  </ImageFrame>
419
438
 
420
439
  {showRemove && hasValue && canInteract && !inTableCell && (
@@ -453,6 +472,8 @@ export function ImageField({
453
472
  placeholder,
454
473
  objectFit = "cover",
455
474
  skeleton,
475
+ ref,
476
+ trigger,
456
477
  }: ImageFieldProps) {
457
478
  const { resolvedForm, knobProps, disabledState, id } = useFormField({
458
479
  form: formProp,
@@ -486,6 +507,8 @@ export function ImageField({
486
507
  disabledSurface: disabled ? disabledState.surfaceKnobProps : undefined,
487
508
  onChangeProp,
488
509
  onInvalid: setValidationError,
510
+ pickerRef: ref,
511
+ trigger,
489
512
  };
490
513
 
491
514
  if (!resolvedForm || !name) {
@@ -25,6 +25,7 @@ import {
25
25
  import { Field, FieldLayout, useFieldDescribedBy } from "../../fieldLayout";
26
26
  import { Skeleton } from "../../Skeleton";
27
27
  import { formControlColors, formSelectedColors } from "../../shared/colorRamps";
28
+ import { useControlTrackFill } from "../../shared/controlTrack";
28
29
  import { useIsInTableCell } from "../../shared/tableCellContext";
29
30
  import { bidiIsolate, t } from "../../shared/t";
30
31
  import {
@@ -186,6 +187,7 @@ function ProgressTrack({
186
187
  blurHandler,
187
188
  }: ProgressTrackProps) {
188
189
  const describedBy = useFieldDescribedBy();
190
+ const trackFill = useControlTrackFill();
189
191
  const trackSize = size ?? sizeToken;
190
192
  const percent = Math.round((progressValue / max) * 100);
191
193
  const motion = indicatorMotion(knobProps.motion.slide);
@@ -197,6 +199,7 @@ function ProgressTrack({
197
199
  size={trackSize}
198
200
  overflow="hidden"
199
201
  outlined={knobProps.outlined}
202
+ {...(knobProps.outlined ? undefined : { backgroundColor: trackFill })}
200
203
  {...knobProps.borderRadius}
201
204
  {...(knobProps.outlined ? knobProps.inputSurface : { borderWidth: 0 })}
202
205
  {...(knobProps.outlined ? { borderColor: formControlColors.boundary } : undefined)}
@@ -25,6 +25,17 @@ const SystemSliderProbe = vi.hoisted(
25
25
  data-rail={props.maximumTrackTintColor}
26
26
  data-thumb={props.thumbTintColor ?? ""}
27
27
  data-label={props.accessibilityLabel ?? ""}
28
+ data-accessible={String(props.accessible ?? "")}
29
+ data-role={props.accessibilityRole ?? ""}
30
+ data-a11y-value={JSON.stringify(props.accessibilityValue ?? null)}
31
+ data-actions={(props.accessibilityActions ?? [])
32
+ .map((a: { name: string }) => a.name)
33
+ .join(",")}
34
+ onKeyDown={(event) => {
35
+ const actionName =
36
+ event.key === "ArrowUp" ? "increment" : event.key === "ArrowDown" ? "decrement" : "";
37
+ if (actionName) props.onAccessibilityAction?.({ nativeEvent: { actionName } });
38
+ }}
28
39
  data-min={props.minimumValue}
29
40
  data-max={props.maximumValue}
30
41
  data-step={props.step}
@@ -101,6 +112,39 @@ describe("Slider on iOS and Android", () => {
101
112
  },
102
113
  );
103
114
 
115
+ it("is an adjustable element named by its label, valued, stepping on VoiceOver swipes (FZ-8905)", () => {
116
+ const onChange = vi.fn();
117
+ const result = renderWithProviders(
118
+ <Slider
119
+ label="Brightness"
120
+ defaultValue={[80]}
121
+ min={0}
122
+ max={100}
123
+ step={1}
124
+ onChange={onChange}
125
+ />,
126
+ );
127
+ const view = result.getByTestId("system-slider");
128
+ expect(view.dataset).toMatchObject({
129
+ accessible: "true",
130
+ role: "adjustable",
131
+ label: "Brightness",
132
+ actions: "increment,decrement",
133
+ });
134
+ expect(JSON.parse(view.dataset.a11yValue ?? "null")).toEqual({
135
+ min: 0,
136
+ max: 100,
137
+ now: 80,
138
+ text: "80",
139
+ });
140
+ fireEvent.keyDown(view, { key: "ArrowUp" });
141
+ expect(onChange).toHaveBeenLastCalledWith([81]);
142
+ expect(JSON.parse(view.dataset.a11yValue ?? "null")).toMatchObject({ now: 81 });
143
+ fireEvent.keyDown(view, { key: "ArrowDown" });
144
+ fireEvent.keyDown(view, { key: "ArrowDown" });
145
+ expect(onChange).toHaveBeenLastCalledWith([79]);
146
+ });
147
+
104
148
  it("tints the Android thumb with the fill", () => {
105
149
  system.os = "android";
106
150
  const result = renderInScheme(<Slider label="Volume" />, "light");
@@ -34,6 +34,7 @@ import {
34
34
  import { Skeleton } from "../../Skeleton";
35
35
  import { Field, FieldLayout, useFieldA11y, useFieldDescribedBy } from "../../fieldLayout";
36
36
  import { formCommonColors, formControlColors, formSelectedColors } from "../../shared/colorRamps";
37
+ import { useControlTrackFill } from "../../shared/controlTrack";
37
38
  import { loadSystemSlider } from "../../shared/systemControlModules";
38
39
  import {
39
40
  type SystemPlatform,
@@ -74,12 +75,16 @@ function radiusStopFromToken(token: number | string): BorderRadius {
74
75
  }
75
76
 
76
77
  // Track styling helper - returns props to pass to TamaguiSlider.Track
77
- const getTrackProps = (outlined: boolean | undefined, borderRadius: number) => ({
78
+ const getTrackProps = (
79
+ outlined: boolean | undefined,
80
+ borderRadius: number,
81
+ trackFill: string = formControlColors.background,
82
+ ) => ({
78
83
  unstyled: false,
79
84
  height: TRACK_HEIGHT,
80
85
  width: "100%",
81
86
  alignSelf: "center" as const,
82
- backgroundColor: outlined ? "transparent" : formControlColors.background,
87
+ backgroundColor: outlined ? "transparent" : trackFill,
83
88
  borderWidth: outlined ? 1 : 0,
84
89
  borderColor: outlined ? formControlColors.border : undefined,
85
90
  borderRadius,
@@ -270,9 +275,26 @@ function SystemSliderControl({
270
275
  onBlur?: (...args: any[]) => void;
271
276
  }) {
272
277
  const literal = useThemeLiteral();
278
+ const trackFill = useControlTrackFill();
273
279
  const [displayValue, setDisplayValue] = useState(value);
274
280
  useEffect(() => setDisplayValue(value), [value]);
275
281
  const fill = literal(hasError ? formCommonColors.error : formSelectedColors.mark);
282
+ // A Fabric view is an accessibility element only when `accessible` says so:
283
+ // without it UIKit drops the UISlider from the tree (FZ-8905). It takes the
284
+ // MPO thumb's VoiceOver contract: adjustable, named by its field, valued,
285
+ // and one step per swipe.
286
+ const fieldA11y = useFieldA11y();
287
+ const move = (next: number) => {
288
+ setDisplayValue(next);
289
+ if (disabled || readOnly) return;
290
+ onChangeValue(next);
291
+ };
292
+ const adjust = (direction: 1 | -1) => {
293
+ if (disabled || readOnly) return;
294
+ const next = snapSliderValue(displayValue + direction * step, min, max, step);
295
+ if (next !== displayValue) move(next);
296
+ };
297
+ const readout = formatSliderOutput([displayValue], step);
276
298
  return (
277
299
  <XStack height={height} alignItems="center" width="100%" gap="$3">
278
300
  <View
@@ -291,15 +313,20 @@ function SystemSliderControl({
291
313
  step={step}
292
314
  disabled={disabled}
293
315
  minimumTrackTintColor={fill}
294
- maximumTrackTintColor={literal(formControlColors.background)}
316
+ maximumTrackTintColor={literal(trackFill)}
295
317
  {...(platform === "android" && { thumbTintColor: fill })}
296
- accessibilityLabel={accessibilityLabel}
297
- accessibilityHint={accessibilityHint}
298
- onValueChange={(next) => {
299
- setDisplayValue(next);
300
- if (disabled || readOnly) return;
301
- onChangeValue(next);
318
+ accessible
319
+ accessibilityRole="adjustable"
320
+ accessibilityLabel={accessibilityLabel ?? fieldA11y?.label ?? t("Slider")}
321
+ accessibilityHint={accessibilityHint ?? fieldA11y?.description}
322
+ accessibilityValue={{ min, max, now: displayValue, text: readout }}
323
+ accessibilityState={{ disabled: Boolean(disabled) }}
324
+ accessibilityActions={A11Y_ADJUSTABLE_ACTIONS}
325
+ onAccessibilityAction={(event: { nativeEvent: { actionName: string } }) => {
326
+ if (event.nativeEvent.actionName === "increment") adjust(1);
327
+ else if (event.nativeEvent.actionName === "decrement") adjust(-1);
302
328
  }}
329
+ onValueChange={move}
303
330
  onSlidingComplete={() => onBlur?.()}
304
331
  />
305
332
  </View>
@@ -310,7 +337,7 @@ function SystemSliderControl({
310
337
  textAlign="right"
311
338
  aria-hidden
312
339
  >
313
- {formatSliderOutput([displayValue], step)}
340
+ {readout}
314
341
  </Paragraph>
315
342
  </XStack>
316
343
  );
@@ -383,6 +410,7 @@ function SliderControl({
383
410
  // with their thumbs (RULINGS-2026-08-28 §R2).
384
411
  const radiusStop = radiusStopFromToken(borderRadius);
385
412
  const railRadius = resolveRadiusClass("BINARY", radiusStop, { heightPx: TRACK_HEIGHT });
413
+ const trackFill = useControlTrackFill();
386
414
 
387
415
  // Native jump tween rides transform (FLIP) — the RN driver cannot animate
388
416
  // the thumb's `left` percent (see ./jumpMotion.ts). Web keeps the css
@@ -634,12 +662,12 @@ function SliderControl({
634
662
  height={TRACK_HEIGHT}
635
663
  borderRadius={railRadius}
636
664
  >
637
- <TamaguiSlider.Track {...getTrackProps(outlined, railRadius)}>
665
+ <TamaguiSlider.Track {...getTrackProps(outlined, railRadius, trackFill)}>
638
666
  {trackActive}
639
667
  </TamaguiSlider.Track>
640
668
  </View>
641
669
  ) : (
642
- <TamaguiSlider.Track {...getTrackProps(outlined, railRadius)}>
670
+ <TamaguiSlider.Track {...getTrackProps(outlined, railRadius, trackFill)}>
643
671
  {trackActive}
644
672
  </TamaguiSlider.Track>
645
673
  )}
@@ -188,3 +188,45 @@ export const InTable: Story = {
188
188
  );
189
189
  },
190
190
  };
191
+
192
+ /** LC-114: a buttonless Stepper with `hug` is the number field. It is as wide as its value plus the currency glyph or unit, and a long signed value with decimals still fits (FZ-7598). */
193
+ export const HuggingCurrency: Story = {
194
+ render: () => (
195
+ <YStack gap="$4" width="100%">
196
+ <Stepper
197
+ label="Daily loss cap"
198
+ aria-label="Daily loss cap"
199
+ currency="USD"
200
+ hug
201
+ showButtons={false}
202
+ min={0}
203
+ max={50000}
204
+ precision={2}
205
+ defaultValue={2500}
206
+ />
207
+ <Stepper
208
+ label="Position limit"
209
+ aria-label="Position limit"
210
+ unit="%"
211
+ hug
212
+ showButtons={false}
213
+ min={0}
214
+ max={100}
215
+ precision={1}
216
+ defaultValue={12.5}
217
+ />
218
+ <Stepper
219
+ label="Adjustment"
220
+ aria-label="Adjustment"
221
+ currency="USD"
222
+ hug
223
+ showButtons={false}
224
+ min={-9999999}
225
+ max={9999999}
226
+ precision={2}
227
+ defaultValue={-1234567.89}
228
+ />
229
+ <Stepper label="Contracts" aria-label="Contracts" hug min={1} max={500} defaultValue={12} />
230
+ </YStack>
231
+ ),
232
+ };
@@ -499,16 +499,20 @@ describe("Switch thumb and pointer paint", () => {
499
499
  expect(paint(track, "_bg-")).toBe("var(--switchTrackOff)");
500
500
  });
501
501
 
502
- it.each([false, true])("grown=%s changes only the outlined ring on hover", (grown) => {
503
- const { frame, track } = mount({ grown, outlined: true });
504
- expect(track.className).toContain("_bg-transparent");
505
- expect(paint(track, "_btc-")).toBe("var(--switchTrackOff)");
506
- fireEvent.mouseEnter(frame);
507
- expect(track.className).toContain("_bg-transparent");
508
- expect(paint(track, "_btc-")).toBe("var(--switchTrackOffHover)");
509
- fireEvent.mouseLeave(frame);
510
- expect(paint(track, "_btc-")).toBe("var(--switchTrackOff)");
511
- });
502
+ it.each([false, true])(
503
+ "grown=%s keeps the outlined ring on its 3:1 edge and washes the track on hover (LC-299)",
504
+ (grown) => {
505
+ const { frame, track } = mount({ grown, outlined: true });
506
+ expect(track.className).toContain("_bg-transparent");
507
+ expect(paint(track, "_btc-")).toBe("var(--switchTrackOffBorder)");
508
+ fireEvent.mouseEnter(frame);
509
+ expect(paint(track, "_bg-")).toBe("var(--switchTrackOffHover)");
510
+ expect(paint(track, "_btc-")).toBe("var(--switchTrackOffBorder)");
511
+ fireEvent.mouseLeave(frame);
512
+ expect(track.className).toContain("_bg-transparent");
513
+ expect(paint(track, "_btc-")).toBe("var(--switchTrackOffBorder)");
514
+ },
515
+ );
512
516
 
513
517
  it("composes the caller's mouse handlers once", () => {
514
518
  const onMouseEnter = vi.fn();
@@ -198,7 +198,7 @@ const SwitchFrameStyled = styled(View, {
198
198
  borderRadius: 1000,
199
199
  backgroundColor: formSwitchColors.track.off,
200
200
  borderWidth: 0,
201
- borderColor: formSwitchColors.track.off,
201
+ borderColor: formSwitchColors.track.offBorder,
202
202
  outlineWidth: 0,
203
203
  focusStyle: { outlineWidth: 0 },
204
204
  // LC-71: ring is painted from keyboard modality on the track, never
@@ -214,7 +214,7 @@ const SwitchFrameStyled = styled(View, {
214
214
  },
215
215
  false: {
216
216
  backgroundColor: formSwitchColors.track.off,
217
- borderColor: formSwitchColors.track.off,
217
+ borderColor: formSwitchColors.track.offBorder,
218
218
  },
219
219
  },
220
220
 
@@ -406,6 +406,8 @@ function SwitchControl({
406
406
  : hovered
407
407
  ? formSwitchColors.track.offHover
408
408
  : formSwitchColors.track.off;
409
+ const offFill = outlined && !hovered && !pressed ? "transparent" : offPaint;
410
+ const offRing = outlined ? undefined : knobProps.trackRing;
409
411
  const { thumbInset, thumbSize, travel: dragDistance } = footprint;
410
412
  const motion = resolveSwitchMotion(transition);
411
413
  const [dragX, setDragX] = useState<number | null>(null);
@@ -562,8 +564,9 @@ function SwitchControl({
562
564
  borderColor: formSwitchColors.track.on,
563
565
  }
564
566
  : {
565
- backgroundColor: outlined ? "transparent" : offPaint,
566
- borderColor: offPaint,
567
+ backgroundColor: offFill,
568
+ borderColor: formSwitchColors.track.offBorder,
569
+ ...offRing,
567
570
  })}
568
571
  {...(kbFocus ? { "data-kb-focus": "true" } : undefined)}
569
572
  {...(frameClassName ? { className: frameClassName } : undefined)}
@@ -653,10 +656,11 @@ function SwitchControl({
653
656
  borderRadius={cellTrack.borderRadius}
654
657
  {...radiusResolution}
655
658
  borderWidth={cellTrack.borderWidth}
656
- borderColor={displayChecked ? formSwitchColors.track.on : offPaint}
657
- backgroundColor={
658
- displayChecked ? formSwitchColors.track.on : outlined ? "transparent" : offPaint
659
+ borderColor={
660
+ displayChecked ? formSwitchColors.track.on : formSwitchColors.track.offBorder
659
661
  }
662
+ backgroundColor={displayChecked ? formSwitchColors.track.on : offFill}
663
+ {...(displayChecked ? undefined : offRing)}
660
664
  {...transitionProps({ ...knobProps.motion.control, transition: motion }, ["borderWidth"])}
661
665
  pointerEvents="none"
662
666
  className={kbFocus ? SWITCH_KB_RING_CLASS : undefined}
@@ -160,11 +160,6 @@ export function TimePickerInputTrigger({
160
160
  onPress={isWeb && interactive ? onOpen : undefined}
161
161
  {...(disabled ? { opacity: 0.5, pointerEvents: "none" as const } : undefined)}
162
162
  >
163
- <InputParts.Section data-testid="time-picker-glyph">
164
- <InputParts.Icon adornment="leading" color={formCommonColors.muted}>
165
- {clockIcon ?? <ClockIcon />}
166
- </InputParts.Icon>
167
- </InputParts.Section>
168
163
  <InputParts.Section>
169
164
  <InputParts.Area
170
165
  readOnly
@@ -174,7 +169,6 @@ export function TimePickerInputTrigger({
174
169
  placeholder={placeholder}
175
170
  disabled={disabled}
176
171
  color={formCommonColors.text}
177
- placeholderTextColor={formCommonColors.muted}
178
172
  aria-label={ariaLabel}
179
173
  aria-required={ariaRequired}
180
174
  aria-invalid={ariaInvalid}
@@ -196,11 +190,17 @@ export function TimePickerInputTrigger({
196
190
  }
197
191
  />
198
192
  </InputParts.Section>
199
- {value && isWeb && !readOnly ? (
193
+ {value && isWeb && !readOnly && !disabled ? (
200
194
  <InputParts.Section>
201
195
  <TimePickerClearButton onClear={onClear} disabled={disabled} />
202
196
  </InputParts.Section>
203
- ) : null}
197
+ ) : (
198
+ <InputParts.Section data-testid="time-picker-glyph">
199
+ <InputParts.Icon adornment="trailing" color={formCommonColors.muted}>
200
+ {clockIcon ?? <ClockIcon />}
201
+ </InputParts.Icon>
202
+ </InputParts.Section>
203
+ )}
204
204
  </InputParts.Box>
205
205
  </View>
206
206
  );
@@ -25,7 +25,12 @@ import {
25
25
  } from "tamagui";
26
26
  import { Skeleton } from "../../Skeleton";
27
27
  import { Field, FieldLayout } from "../../fieldLayout";
28
- import { formButtonColors, formCommonColors, formInputColors } from "../../shared/colorRamps";
28
+ import {
29
+ formButtonColors,
30
+ formCommonColors,
31
+ formControlColors,
32
+ formInputColors,
33
+ } from "../../shared/colorRamps";
29
34
  import { warnBareDisabled } from "../../shared/devWarn";
30
35
  import { choiceItemA11y, composeA11yName } from "../../shared/nativeA11y";
31
36
  import { loadSystemSegmentedControl } from "../../shared/systemControlModules";
@@ -693,6 +698,7 @@ export function ToggleGroup({
693
698
  {...toggleProps}
694
699
  {...knobProps.borderRadius}
695
700
  {...knobProps.inputSurface}
701
+ borderColor={formControlColors.edge}
696
702
  overflow="hidden"
697
703
  backgroundColor={knobProps.outlined ? "transparent" : formInputColors.background.base}
698
704
  {...(isWeb