@multiplatform.one/forms 7.63.4 → 7.64.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 (185) hide show
  1. package/dist/cjs/AdaptivePopup/index.cjs +3 -5
  2. package/dist/cjs/AdaptivePopup/index.native.js +3 -5
  3. package/dist/cjs/AdaptivePopup/index.native.js.map +1 -1
  4. package/dist/cjs/Button/index.cjs +1 -0
  5. package/dist/cjs/Button/index.native.js +1 -0
  6. package/dist/cjs/Button/index.native.js.map +1 -1
  7. package/dist/cjs/fields/ColorPicker/SystemColorField.cjs +106 -0
  8. package/dist/cjs/fields/ColorPicker/SystemColorField.native.js +110 -0
  9. package/dist/cjs/fields/ColorPicker/SystemColorField.native.js.map +1 -0
  10. package/dist/cjs/fields/ColorPicker/index.cjs +36 -4
  11. package/dist/cjs/fields/ColorPicker/index.native.js +36 -6
  12. package/dist/cjs/fields/ColorPicker/index.native.js.map +1 -1
  13. package/dist/cjs/fields/ColorPicker/systemColorWell.cjs +30 -0
  14. package/dist/cjs/fields/ColorPicker/systemColorWell.gnome.cjs +30 -0
  15. package/dist/cjs/fields/ColorPicker/systemColorWell.gnome.native.js +33 -0
  16. package/dist/cjs/fields/ColorPicker/systemColorWell.gnome.native.js.map +1 -0
  17. package/dist/cjs/fields/ColorPicker/systemColorWell.native.js +111 -0
  18. package/dist/cjs/fields/ColorPicker/systemColorWell.native.js.map +1 -0
  19. package/dist/cjs/fields/DatePicker/DatetimePicker.catalog.cjs +27 -0
  20. package/dist/cjs/fields/DatePicker/DatetimePicker.catalog.native.js +31 -0
  21. package/dist/cjs/fields/DatePicker/DatetimePicker.catalog.native.js.map +1 -1
  22. package/dist/cjs/fields/DatePicker/catalog.cjs +30 -1
  23. package/dist/cjs/fields/DatePicker/catalog.native.js +32 -1
  24. package/dist/cjs/fields/DatePicker/catalog.native.js.map +1 -1
  25. package/dist/cjs/fields/DatePicker/parts.cjs +17 -6
  26. package/dist/cjs/fields/DatePicker/parts.native.js +17 -6
  27. package/dist/cjs/fields/DatePicker/parts.native.js.map +1 -1
  28. package/dist/cjs/fields/TimePicker/catalog.cjs +24 -0
  29. package/dist/cjs/fields/TimePicker/catalog.native.js +28 -0
  30. package/dist/cjs/fields/TimePicker/catalog.native.js.map +1 -1
  31. package/dist/cjs/fields/TimePicker/parts.cjs +20 -8
  32. package/dist/cjs/fields/TimePicker/parts.native.js +20 -8
  33. package/dist/cjs/fields/TimePicker/parts.native.js.map +1 -1
  34. package/dist/cjs/shared/focusManagement.cjs +1 -5
  35. package/dist/cjs/shared/focusManagement.native.js +1 -5
  36. package/dist/cjs/shared/focusManagement.native.js.map +1 -1
  37. package/dist/cjs/shared/touchWebPicker.cjs +136 -0
  38. package/dist/cjs/shared/touchWebPicker.native.js +154 -0
  39. package/dist/cjs/shared/touchWebPicker.native.js.map +1 -0
  40. package/dist/esm/AdaptivePopup/index.mjs +4 -6
  41. package/dist/esm/AdaptivePopup/index.mjs.map +1 -1
  42. package/dist/esm/AdaptivePopup/index.native.js +4 -6
  43. package/dist/esm/AdaptivePopup/index.native.js.map +1 -1
  44. package/dist/esm/Button/index.mjs +2 -1
  45. package/dist/esm/Button/index.mjs.map +1 -1
  46. package/dist/esm/Button/index.native.js +2 -1
  47. package/dist/esm/Button/index.native.js.map +1 -1
  48. package/dist/esm/fields/ColorPicker/SystemColorField.mjs +81 -0
  49. package/dist/esm/fields/ColorPicker/SystemColorField.mjs.map +1 -0
  50. package/dist/esm/fields/ColorPicker/SystemColorField.native.js +82 -0
  51. package/dist/esm/fields/ColorPicker/SystemColorField.native.js.map +1 -0
  52. package/dist/esm/fields/ColorPicker/index.mjs +36 -4
  53. package/dist/esm/fields/ColorPicker/index.mjs.map +1 -1
  54. package/dist/esm/fields/ColorPicker/index.native.js +36 -6
  55. package/dist/esm/fields/ColorPicker/index.native.js.map +1 -1
  56. package/dist/esm/fields/ColorPicker/systemColorWell.gnome.mjs +5 -0
  57. package/dist/esm/fields/ColorPicker/systemColorWell.gnome.mjs.map +1 -0
  58. package/dist/esm/fields/ColorPicker/systemColorWell.gnome.native.js +5 -0
  59. package/dist/esm/fields/ColorPicker/systemColorWell.gnome.native.js.map +1 -0
  60. package/dist/esm/fields/ColorPicker/systemColorWell.mjs +5 -0
  61. package/dist/esm/fields/ColorPicker/systemColorWell.mjs.map +1 -0
  62. package/dist/esm/fields/ColorPicker/systemColorWell.native.js +77 -0
  63. package/dist/esm/fields/ColorPicker/systemColorWell.native.js.map +1 -0
  64. package/dist/esm/fields/DatePicker/DatetimePicker.catalog.mjs +27 -0
  65. package/dist/esm/fields/DatePicker/DatetimePicker.catalog.mjs.map +1 -1
  66. package/dist/esm/fields/DatePicker/DatetimePicker.catalog.native.js +31 -0
  67. package/dist/esm/fields/DatePicker/DatetimePicker.catalog.native.js.map +1 -1
  68. package/dist/esm/fields/DatePicker/catalog.mjs +30 -1
  69. package/dist/esm/fields/DatePicker/catalog.mjs.map +1 -1
  70. package/dist/esm/fields/DatePicker/catalog.native.js +32 -1
  71. package/dist/esm/fields/DatePicker/catalog.native.js.map +1 -1
  72. package/dist/esm/fields/DatePicker/parts.mjs +17 -6
  73. package/dist/esm/fields/DatePicker/parts.mjs.map +1 -1
  74. package/dist/esm/fields/DatePicker/parts.native.js +17 -6
  75. package/dist/esm/fields/DatePicker/parts.native.js.map +1 -1
  76. package/dist/esm/fields/TimePicker/catalog.mjs +24 -0
  77. package/dist/esm/fields/TimePicker/catalog.mjs.map +1 -1
  78. package/dist/esm/fields/TimePicker/catalog.native.js +28 -0
  79. package/dist/esm/fields/TimePicker/catalog.native.js.map +1 -1
  80. package/dist/esm/fields/TimePicker/parts.mjs +20 -8
  81. package/dist/esm/fields/TimePicker/parts.mjs.map +1 -1
  82. package/dist/esm/fields/TimePicker/parts.native.js +20 -8
  83. package/dist/esm/fields/TimePicker/parts.native.js.map +1 -1
  84. package/dist/esm/shared/focusManagement.mjs +2 -6
  85. package/dist/esm/shared/focusManagement.mjs.map +1 -1
  86. package/dist/esm/shared/focusManagement.native.js +2 -6
  87. package/dist/esm/shared/focusManagement.native.js.map +1 -1
  88. package/dist/esm/shared/touchWebPicker.mjs +105 -0
  89. package/dist/esm/shared/touchWebPicker.mjs.map +1 -0
  90. package/dist/esm/shared/touchWebPicker.native.js +120 -0
  91. package/dist/esm/shared/touchWebPicker.native.js.map +1 -0
  92. package/dist/jsx/AdaptivePopup/index.mjs +4 -6
  93. package/dist/jsx/AdaptivePopup/index.mjs.map +1 -1
  94. package/dist/jsx/AdaptivePopup/index.native.js +3 -5
  95. package/dist/jsx/AdaptivePopup/index.native.js.map +1 -1
  96. package/dist/jsx/Button/index.mjs +2 -1
  97. package/dist/jsx/Button/index.mjs.map +1 -1
  98. package/dist/jsx/Button/index.native.js +1 -0
  99. package/dist/jsx/Button/index.native.js.map +1 -1
  100. package/dist/jsx/fields/ColorPicker/SystemColorField.mjs +81 -0
  101. package/dist/jsx/fields/ColorPicker/SystemColorField.mjs.map +1 -0
  102. package/dist/jsx/fields/ColorPicker/SystemColorField.native.js +110 -0
  103. package/dist/jsx/fields/ColorPicker/SystemColorField.native.js.map +1 -0
  104. package/dist/jsx/fields/ColorPicker/index.mjs +36 -4
  105. package/dist/jsx/fields/ColorPicker/index.mjs.map +1 -1
  106. package/dist/jsx/fields/ColorPicker/index.native.js +36 -6
  107. package/dist/jsx/fields/ColorPicker/index.native.js.map +1 -1
  108. package/dist/jsx/fields/ColorPicker/systemColorWell.gnome.mjs +5 -0
  109. package/dist/jsx/fields/ColorPicker/systemColorWell.gnome.mjs.map +1 -0
  110. package/dist/jsx/fields/ColorPicker/systemColorWell.gnome.native.js +33 -0
  111. package/dist/jsx/fields/ColorPicker/systemColorWell.gnome.native.js.map +1 -0
  112. package/dist/jsx/fields/ColorPicker/systemColorWell.mjs +5 -0
  113. package/dist/jsx/fields/ColorPicker/systemColorWell.mjs.map +1 -0
  114. package/dist/jsx/fields/ColorPicker/systemColorWell.native.js +111 -0
  115. package/dist/jsx/fields/ColorPicker/systemColorWell.native.js.map +1 -0
  116. package/dist/jsx/fields/DatePicker/DatetimePicker.catalog.mjs +27 -0
  117. package/dist/jsx/fields/DatePicker/DatetimePicker.catalog.mjs.map +1 -1
  118. package/dist/jsx/fields/DatePicker/DatetimePicker.catalog.native.js +31 -0
  119. package/dist/jsx/fields/DatePicker/DatetimePicker.catalog.native.js.map +1 -1
  120. package/dist/jsx/fields/DatePicker/catalog.mjs +30 -1
  121. package/dist/jsx/fields/DatePicker/catalog.mjs.map +1 -1
  122. package/dist/jsx/fields/DatePicker/catalog.native.js +32 -1
  123. package/dist/jsx/fields/DatePicker/catalog.native.js.map +1 -1
  124. package/dist/jsx/fields/DatePicker/parts.mjs +17 -6
  125. package/dist/jsx/fields/DatePicker/parts.mjs.map +1 -1
  126. package/dist/jsx/fields/DatePicker/parts.native.js +17 -6
  127. package/dist/jsx/fields/DatePicker/parts.native.js.map +1 -1
  128. package/dist/jsx/fields/TimePicker/catalog.mjs +24 -0
  129. package/dist/jsx/fields/TimePicker/catalog.mjs.map +1 -1
  130. package/dist/jsx/fields/TimePicker/catalog.native.js +28 -0
  131. package/dist/jsx/fields/TimePicker/catalog.native.js.map +1 -1
  132. package/dist/jsx/fields/TimePicker/parts.mjs +20 -8
  133. package/dist/jsx/fields/TimePicker/parts.mjs.map +1 -1
  134. package/dist/jsx/fields/TimePicker/parts.native.js +20 -8
  135. package/dist/jsx/fields/TimePicker/parts.native.js.map +1 -1
  136. package/dist/jsx/shared/focusManagement.mjs +2 -6
  137. package/dist/jsx/shared/focusManagement.mjs.map +1 -1
  138. package/dist/jsx/shared/focusManagement.native.js +1 -5
  139. package/dist/jsx/shared/focusManagement.native.js.map +1 -1
  140. package/dist/jsx/shared/touchWebPicker.mjs +105 -0
  141. package/dist/jsx/shared/touchWebPicker.mjs.map +1 -0
  142. package/dist/jsx/shared/touchWebPicker.native.js +154 -0
  143. package/dist/jsx/shared/touchWebPicker.native.js.map +1 -0
  144. package/package.json +13 -8
  145. package/src/AdaptivePopup/index.tsx +4 -3
  146. package/src/Button/index.tsx +4 -0
  147. package/src/fields/ColorPicker/SystemColorField.tsx +101 -0
  148. package/src/fields/ColorPicker/index.tsx +48 -13
  149. package/src/fields/ColorPicker/systemColorWell.gnome.ts +7 -0
  150. package/src/fields/ColorPicker/systemColorWell.native.spec.tsx +266 -0
  151. package/src/fields/ColorPicker/systemColorWell.native.tsx +144 -0
  152. package/src/fields/ColorPicker/systemColorWell.ts +21 -0
  153. package/src/fields/DatePicker/DatePicker.stories.tsx +27 -1
  154. package/src/fields/DatePicker/DatetimePicker.catalog.tsx +45 -1
  155. package/src/fields/DatePicker/catalog.tsx +48 -8
  156. package/src/fields/DatePicker/parts.tsx +19 -4
  157. package/src/fields/DatePicker/touchWeb.spec.tsx +245 -0
  158. package/src/fields/TimePicker/catalog.tsx +33 -0
  159. package/src/fields/TimePicker/parts.tsx +19 -6
  160. package/src/overlayGround.spec.ts +50 -0
  161. package/src/shared/focusManagement.ts +7 -3
  162. package/src/shared/touchWebPicker.spec.ts +98 -0
  163. package/src/shared/touchWebPicker.tsx +158 -0
  164. package/types/AdaptivePopup/index.d.ts.map +1 -1
  165. package/types/Button/index.d.ts.map +1 -1
  166. package/types/fields/ColorPicker/SystemColorField.d.ts +22 -0
  167. package/types/fields/ColorPicker/SystemColorField.d.ts.map +1 -0
  168. package/types/fields/ColorPicker/index.d.ts.map +1 -1
  169. package/types/fields/ColorPicker/systemColorWell.d.ts +18 -0
  170. package/types/fields/ColorPicker/systemColorWell.d.ts.map +1 -0
  171. package/types/fields/ColorPicker/systemColorWell.gnome.d.ts +5 -0
  172. package/types/fields/ColorPicker/systemColorWell.gnome.d.ts.map +1 -0
  173. package/types/fields/ColorPicker/systemColorWell.native.d.ts +55 -0
  174. package/types/fields/ColorPicker/systemColorWell.native.d.ts.map +1 -0
  175. package/types/fields/DatePicker/DatetimePicker.catalog.d.ts +1 -1
  176. package/types/fields/DatePicker/DatetimePicker.catalog.d.ts.map +1 -1
  177. package/types/fields/DatePicker/catalog.d.ts.map +1 -1
  178. package/types/fields/DatePicker/parts.d.ts +6 -1
  179. package/types/fields/DatePicker/parts.d.ts.map +1 -1
  180. package/types/fields/TimePicker/catalog.d.ts.map +1 -1
  181. package/types/fields/TimePicker/parts.d.ts +3 -1
  182. package/types/fields/TimePicker/parts.d.ts.map +1 -1
  183. package/types/shared/focusManagement.d.ts.map +1 -1
  184. package/types/shared/touchWebPicker.d.ts +40 -0
  185. package/types/shared/touchWebPicker.d.ts.map +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplatform.one/forms",
3
- "version": "7.63.4",
3
+ "version": "7.64.0",
4
4
  "description": "Cross-platform form components with URL state management",
5
5
  "keywords": [
6
6
  "cross-platform",
@@ -57,10 +57,10 @@
57
57
  },
58
58
  "dependencies": {
59
59
  "@floating-ui/react": "^0.27.20",
60
- "@multiplatform.one/platform": "^7.63.4",
61
- "@multiplatform.one/router": "^7.63.4",
62
- "@multiplatform.one/table-primitives": "^7.63.4",
63
- "@multiplatform.one/theme": "^7.63.4",
60
+ "@multiplatform.one/platform": "^7.64.0",
61
+ "@multiplatform.one/router": "^7.64.0",
62
+ "@multiplatform.one/table-primitives": "^7.64.0",
63
+ "@multiplatform.one/theme": "^7.64.0",
64
64
  "@phosphor-icons/react": "^2.1.10",
65
65
  "@tamagui/get-font-sized": "2.7.6",
66
66
  "@tamagui/get-token": "2.7.6",
@@ -79,9 +79,10 @@
79
79
  "tamagui": "2.7.6"
80
80
  },
81
81
  "devDependencies": {
82
- "@multiplatform.one/config": "7.63.4",
83
- "@multiplatform.one/storybook": "7.63.4",
84
- "@multiplatform.one/test-utils": "7.63.4",
82
+ "@expo/ui": "~55.0.17",
83
+ "@multiplatform.one/config": "7.64.0",
84
+ "@multiplatform.one/storybook": "7.64.0",
85
+ "@multiplatform.one/test-utils": "7.64.0",
85
86
  "@tamagui/build": "2.7.6",
86
87
  "@tamagui/config": "2.7.6",
87
88
  "@tamagui/vite-plugin": "2.7.6",
@@ -103,6 +104,7 @@
103
104
  "vitest": "^4.1.5"
104
105
  },
105
106
  "peerDependencies": {
107
+ "@expo/ui": "*",
106
108
  "@react-native-community/datetimepicker": ">=8.0.1",
107
109
  "expo-document-picker": "*",
108
110
  "expo-haptics": "55.0.17",
@@ -117,6 +119,9 @@
117
119
  "react-native-safe-area-context": "*"
118
120
  },
119
121
  "peerDependenciesMeta": {
122
+ "@expo/ui": {
123
+ "optional": true
124
+ },
120
125
  "@react-native-community/datetimepicker": {
121
126
  "optional": true
122
127
  },
@@ -12,6 +12,7 @@ import {
12
12
  OVERLAY_BREAKPOINT,
13
13
  Surface,
14
14
  ensureFocusVisibleRing,
15
+ focusOnOpen,
15
16
  useResolvedKnobs,
16
17
  } from "@multiplatform.one/theme";
17
18
  import {
@@ -192,9 +193,9 @@ function focusFirstField(event: Event) {
192
193
  const field = root?.querySelector<HTMLElement>(
193
194
  "input, textarea, select, [contenteditable='true']",
194
195
  );
195
- if (!field) return;
196
+ if (!root || !field) return;
196
197
  event.preventDefault();
197
- field.focus({ preventScroll: true });
198
+ focusOnOpen(field, root);
198
199
  }
199
200
 
200
201
  export function AdaptivePopup({
@@ -401,7 +402,7 @@ export function AdaptivePopup({
401
402
  borderTopLeftRadius: free,
402
403
  borderBottomLeftRadius: free,
403
404
  };
404
- const slideFromEnd = rtl ? -24 : 24;
405
+ const slideFromEnd = rtl ? -16 : 16;
405
406
  return (
406
407
  <Dialog modal open={open} onOpenChange={onOpenChange}>
407
408
  {trigger && (
@@ -30,6 +30,7 @@ import {
30
30
  solidButtonIntents,
31
31
  solidPaletteThemes,
32
32
  useAccentOnSurface,
33
+ ensureKeyboardModalityTracking,
33
34
  useBarSurface,
34
35
  useElevatedSurface,
35
36
  useIdentityInkOnSurface,
@@ -487,6 +488,9 @@ export function Button({
487
488
  const grouped = useControlGrouped();
488
489
  // LC-153 CONTROL-TIERS: a bar paints its neutral buttons plain; content
489
490
  // paints them tinted. The scheme picks the wash step.
491
+ // A Button is most overlays' trigger: the modality must be known before its
492
+ // opening key press, or the open focus cannot tell keyboard from pointer.
493
+ if (isWeb) ensureKeyboardModalityTracking();
490
494
  const barSurface = useBarSurface();
491
495
  const elevatedGround = useElevatedSurface();
492
496
  const darkScheme = isDarkThemeName(useThemeName());
@@ -0,0 +1,101 @@
1
+ import { isDarkThemeName, useResolvedKnobs } from "@multiplatform.one/theme";
2
+ import { type ComponentType, type ReactNode, useState } from "react";
3
+ import { ScrollView, View, YStack, useThemeName } from "tamagui";
4
+
5
+ import { useIsInTableCell } from "../../shared/tableCellContext";
6
+ import { t } from "../../shared/t";
7
+ import { PresetSwatches, hexAlphaByte, parseHex, rgbToHex } from "./parts";
8
+ import type { SystemColorWellProps } from "./systemColorWell";
9
+
10
+ export interface SystemColorFieldProps {
11
+ Well: ComponentType<SystemColorWellProps>;
12
+ /** The MPO trigger row: swatch, hex and clear button. */
13
+ trigger: ReactNode;
14
+ selectedColor: string;
15
+ alpha: number;
16
+ showAlpha: boolean;
17
+ presetColors: string[];
18
+ disabled?: boolean;
19
+ a11yLabel?: string;
20
+ onPick: (hex: string, alpha?: number) => void;
21
+ onSelectPreset: (hex: string) => void;
22
+ }
23
+
24
+ /**
25
+ * LC-266 on iOS: the field opens the system colour picker; the theme swatches
26
+ * stay one tap away in a row under it, because the system picker cannot show
27
+ * them. With a value, the trailing clear button stays outside the well.
28
+ */
29
+ export function SystemColorField({
30
+ Well,
31
+ trigger,
32
+ selectedColor,
33
+ alpha,
34
+ showAlpha,
35
+ presetColors,
36
+ disabled,
37
+ a11yLabel,
38
+ onPick,
39
+ onSelectPreset,
40
+ }: SystemColorFieldProps) {
41
+ const { knobProps } = useResolvedKnobs();
42
+ const inTableCell = useIsInTableCell();
43
+ const scheme = isDarkThemeName(useThemeName()) ? "dark" : "light";
44
+ const [size, setSize] = useState({ width: 0, height: 0 });
45
+ const clearInset = selectedColor ? size.height : 0;
46
+
47
+ return (
48
+ <YStack {...knobProps.gap}>
49
+ <View
50
+ position="relative"
51
+ testID="color-picker-system-field"
52
+ onLayout={(e) => {
53
+ const { width, height } = e.nativeEvent.layout;
54
+ if (width !== size.width || height !== size.height) setSize({ width, height });
55
+ }}
56
+ >
57
+ {trigger}
58
+ {!disabled ? (
59
+ <Well
60
+ selection={
61
+ selectedColor && showAlpha ? `${selectedColor}${hexAlphaByte(alpha)}` : selectedColor
62
+ }
63
+ supportsOpacity={showAlpha}
64
+ label={a11yLabel || t("Color")}
65
+ colorScheme={scheme}
66
+ width={Math.max(0, size.width - clearInset)}
67
+ height={size.height}
68
+ onSelectionChange={(picked) => {
69
+ const parsed = parseHex(picked);
70
+ if (!parsed) return;
71
+ onPick(rgbToHex(parsed.r, parsed.g, parsed.b), showAlpha ? parsed.alpha : undefined);
72
+ }}
73
+ style={{
74
+ position: "absolute",
75
+ top: 0,
76
+ bottom: 0,
77
+ left: 0,
78
+ right: clearInset,
79
+ overflow: "hidden",
80
+ }}
81
+ />
82
+ ) : null}
83
+ </View>
84
+ {!inTableCell && presetColors.length > 0 ? (
85
+ <ScrollView
86
+ horizontal
87
+ showsHorizontalScrollIndicator={false}
88
+ testID="color-picker-system-presets"
89
+ >
90
+ <PresetSwatches
91
+ presetColors={presetColors}
92
+ selectedColor={selectedColor}
93
+ onSelect={(color) => {
94
+ if (color !== null && !disabled) onSelectPreset(color);
95
+ }}
96
+ />
97
+ </ScrollView>
98
+ ) : null}
99
+ </YStack>
100
+ );
101
+ }
@@ -40,6 +40,8 @@ import {
40
40
  parseHex,
41
41
  rgbToHex,
42
42
  } from "./parts";
43
+ import { SystemColorField } from "./SystemColorField";
44
+ import { useSystemColorWell } from "./systemColorWell";
43
45
 
44
46
  // ---------------------------------------------------------------------------
45
47
  // Types
@@ -475,6 +477,7 @@ function ColorPickerControl({
475
477
  onBlurControl,
476
478
  }: ColorPickerControlProps) {
477
479
  const { knobProps } = useResolvedKnobs();
480
+ const SystemWell = useSystemColorWell();
478
481
  const [open, setOpen] = useState(false);
479
482
  const parsedInitial = parseColorValue(value, format);
480
483
  const initialColor = parsedInitial.hex;
@@ -620,6 +623,50 @@ function ColorPickerControl({
620
623
  );
621
624
  }
622
625
 
626
+ const triggerAria = {
627
+ // Labelled: the sibling Label wires `aria-labelledby` onto this id
628
+ // itself — naming it here too doubles the spoken name.
629
+ "aria-label": hasLabel ? undefined : t("Color"),
630
+ "aria-describedby":
631
+ [
632
+ helperText && id ? `${id}-description` : undefined,
633
+ hasError && id ? `${id}-error` : undefined,
634
+ ]
635
+ .filter(Boolean)
636
+ .join(" ") || undefined,
637
+ "aria-required": required || undefined,
638
+ "aria-invalid": hasError || undefined,
639
+ };
640
+
641
+ if (SystemWell) {
642
+ return (
643
+ <SystemColorField
644
+ Well={SystemWell}
645
+ selectedColor={selectedColor}
646
+ alpha={alpha}
647
+ showAlpha={showAlpha}
648
+ presetColors={presetColors}
649
+ disabled={disabled}
650
+ a11yLabel={a11yLabel}
651
+ onPick={handleHexInput}
652
+ onSelectPreset={handleSelectPreset}
653
+ trigger={
654
+ <ColorPickerInputTrigger
655
+ value={value || selectedColor}
656
+ placeholder={placeholder}
657
+ disabled={disabled}
658
+ onReset={handleClear}
659
+ onButtonPress={() => {}}
660
+ error={hasError}
661
+ id={id}
662
+ {...triggerAria}
663
+ inputProps={inputProps}
664
+ />
665
+ }
666
+ />
667
+ );
668
+ }
669
+
623
670
  return (
624
671
  <ColorPickerPopoverShell
625
672
  open={open}
@@ -652,19 +699,7 @@ function ColorPickerControl({
652
699
  }}
653
700
  error={hasError}
654
701
  id={id}
655
- // Labelled: the sibling Label wires `aria-labelledby` onto this id
656
- // itself — naming it here too doubles the spoken name.
657
- aria-label={hasLabel ? undefined : t("Color")}
658
- aria-describedby={
659
- [
660
- helperText && id ? `${id}-description` : undefined,
661
- hasError && id ? `${id}-error` : undefined,
662
- ]
663
- .filter(Boolean)
664
- .join(" ") || undefined
665
- }
666
- aria-required={required || undefined}
667
- aria-invalid={hasError || undefined}
702
+ {...triggerAria}
668
703
  inputProps={inputProps}
669
704
  />
670
705
  }
@@ -0,0 +1,7 @@
1
+ import type { ComponentType } from "react";
2
+ import type { SystemColorWellProps } from "./systemColorWell";
3
+
4
+ /** GNOME: the MPO picker is the platform form (LC-266). */
5
+ export function useSystemColorWell(): ComponentType<SystemColorWellProps> | null {
6
+ return null;
7
+ }
@@ -0,0 +1,266 @@
1
+ /**
2
+ * LC-266 (FZ-8121): on iOS the Color field opens the system colour picker
3
+ * when the binary carries @expo/ui, and keeps the MPO picker when it does not,
4
+ * so an OTA on an older binary never crashes.
5
+ */
6
+ import { act, cleanup, fireEvent, render, within } from "@testing-library/react";
7
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
8
+ import { createElement } from "react";
9
+ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
10
+ import type { SystemColorWellProps } from "./systemColorWell";
11
+ import {
12
+ type SwiftUiColorModule,
13
+ type SwiftUiModifiers,
14
+ type SystemColorWellSources,
15
+ expoUiNativeModule,
16
+ makeSystemColorWell,
17
+ resolveSystemColorWell,
18
+ wellOpacity,
19
+ wellScale,
20
+ } from "./systemColorWell.native";
21
+
22
+ vi.mock("@tamagui/lucide-icons-2", () => ({
23
+ Palette: () => null,
24
+ X: () => null,
25
+ Check: () => null,
26
+ Pipette: () => null,
27
+ }));
28
+
29
+ const wellState = vi.hoisted(() => ({
30
+ present: false,
31
+ last: undefined as SystemColorWellProps | undefined,
32
+ }));
33
+
34
+ vi.mock("./systemColorWell", () => ({
35
+ useSystemColorWell: () =>
36
+ wellState.present
37
+ ? (props: SystemColorWellProps) => {
38
+ wellState.last = props;
39
+ return createElement("div", {
40
+ "data-testid": "system-color-well",
41
+ "data-selection": props.selection,
42
+ "data-scheme": props.colorScheme,
43
+ });
44
+ }
45
+ : null,
46
+ }));
47
+
48
+ afterEach(() => {
49
+ cleanup();
50
+ wellState.present = false;
51
+ wellState.last = undefined;
52
+ });
53
+
54
+ const FakeHost: SwiftUiColorModule["Host"] = ({ children, colorScheme }) =>
55
+ createElement("div", { "data-testid": "host", "data-scheme": colorScheme }, children);
56
+
57
+ const FakePicker: SwiftUiColorModule["ColorPicker"] = (props) =>
58
+ createElement("button", {
59
+ "data-testid": "swiftui-color-picker",
60
+ "aria-label": props.label,
61
+ "data-selection": props.selection ?? "",
62
+ "data-modifiers": JSON.stringify(props.modifiers),
63
+ onClick: () => props.onSelectionChange?.("#3366FF"),
64
+ });
65
+
66
+ const fakeModifiers: SwiftUiModifiers = {
67
+ labelsHidden: () => ({ $type: "labelsHidden" }),
68
+ scaleEffect: (scale) => ({ $type: "scaleEffect", x: scale, y: scale }),
69
+ opacity: (value) => ({ $type: "opacity", value }),
70
+ };
71
+
72
+ function sources(
73
+ nativeModule: unknown,
74
+ ui: SystemColorWellSources["ui"] = () => ({ Host: FakeHost, ColorPicker: FakePicker }),
75
+ ): SystemColorWellSources {
76
+ return {
77
+ core: () => ({ requireOptionalNativeModule: () => nativeModule }),
78
+ ui,
79
+ modifiers: () => fakeModifiers,
80
+ };
81
+ }
82
+
83
+ describe("system colour well detection", () => {
84
+ it("draws @expo/ui's ColorPicker on iOS when the binary registers ExpoUI", () => {
85
+ const probe = vi.fn((name: string) => (name === expoUiNativeModule ? {} : null));
86
+ const well = resolveSystemColorWell(
87
+ {
88
+ core: () => ({ requireOptionalNativeModule: probe }),
89
+ ui: () => ({ Host: FakeHost, ColorPicker: FakePicker }),
90
+ modifiers: () => fakeModifiers,
91
+ },
92
+ "ios",
93
+ );
94
+ expect(well).toBeTypeOf("function");
95
+ expect(probe).toHaveBeenCalledWith("ExpoUI");
96
+ });
97
+
98
+ it("never requires @expo/ui on a binary without ExpoUI (the OTA case)", () => {
99
+ const ui = vi.fn(() => ({ Host: FakeHost, ColorPicker: FakePicker }));
100
+ expect(resolveSystemColorWell(sources(null, ui), "ios")).toBeNull();
101
+ expect(ui).not.toHaveBeenCalled();
102
+ });
103
+
104
+ it("keeps the MPO picker on Android and web", () => {
105
+ expect(resolveSystemColorWell(sources({}), "android")).toBeNull();
106
+ expect(resolveSystemColorWell(sources({}), "web")).toBeNull();
107
+ });
108
+
109
+ it("falls back when the probe or the require throws, or an export is missing", () => {
110
+ const throwing = () => {
111
+ throw new Error("no native view");
112
+ };
113
+ expect(
114
+ resolveSystemColorWell(
115
+ { core: throwing, ui: () => ({}), modifiers: () => fakeModifiers },
116
+ "ios",
117
+ ),
118
+ ).toBeNull();
119
+ expect(resolveSystemColorWell(sources({}, throwing), "ios")).toBeNull();
120
+ expect(
121
+ resolveSystemColorWell(
122
+ sources({}, () => ({ Host: FakeHost })),
123
+ "ios",
124
+ ),
125
+ ).toBeNull();
126
+ });
127
+
128
+ it("covers the field with a near-transparent well scaled past its bounds", () => {
129
+ const Well = makeSystemColorWell({ Host: FakeHost, ColorPicker: FakePicker }, fakeModifiers);
130
+ const onSelectionChange = vi.fn();
131
+ const result = render(
132
+ createElement(Well, {
133
+ selection: "#EF4444",
134
+ supportsOpacity: false,
135
+ label: "Accent",
136
+ colorScheme: "dark",
137
+ width: 358,
138
+ height: 44,
139
+ onSelectionChange,
140
+ }),
141
+ );
142
+ expect(result.getByTestId("host").getAttribute("data-scheme")).toBe("dark");
143
+ const picker = result.getByTestId("swiftui-color-picker");
144
+ expect(picker.getAttribute("aria-label")).toBe("Accent");
145
+ expect(picker.getAttribute("data-selection")).toBe("#EF4444");
146
+ const modifiers = JSON.parse(picker.getAttribute("data-modifiers") ?? "[]");
147
+ expect(modifiers).toEqual([
148
+ { $type: "labelsHidden" },
149
+ { $type: "scaleEffect", x: wellScale(358, 44), y: wellScale(358, 44) },
150
+ { $type: "opacity", value: wellOpacity },
151
+ ]);
152
+ expect(wellScale(358, 44) * 28).toBeGreaterThanOrEqual(358 * 2);
153
+ expect(wellScale(0, 0)).toBe(1);
154
+ expect(wellOpacity).toBeGreaterThan(0.01);
155
+ fireEvent.click(picker);
156
+ expect(onSelectionChange).toHaveBeenCalledWith("#3366FF");
157
+ });
158
+ });
159
+
160
+ async function loadColorPicker() {
161
+ return (await import("./index")).ColorPicker;
162
+ }
163
+
164
+ beforeAll(async () => {
165
+ const ColorPicker = await loadColorPicker();
166
+ wellState.present = true;
167
+ renderWithProviders(<ColorPicker label="Accent" value="#EF4444" />);
168
+ cleanup();
169
+ wellState.present = false;
170
+ wellState.last = undefined;
171
+ }, 120_000);
172
+
173
+ describe("ColorPicker with the system colour well (iOS)", () => {
174
+ it("renders the system well over the field and the theme swatches under it", async () => {
175
+ wellState.present = true;
176
+ const ColorPicker = await loadColorPicker();
177
+ const result = renderWithProviders(
178
+ <ColorPicker label="Accent" value="#EF4444" presetColors={["#EF4444", "#22C55E"]} />,
179
+ );
180
+ const field = result.getByTestId("color-picker-system-field");
181
+ expect(field.querySelector('[data-testid="system-color-well"]')).toBeTruthy();
182
+ expect(field.querySelector('[data-testid="color-picker-trigger-swatch"]')).toBeTruthy();
183
+ expect(result.getByTestId("color-picker-system-presets")).toBeTruthy();
184
+ expect(document.body.querySelector('[data-testid="color-picker-panel"]')).toBeNull();
185
+ });
186
+
187
+ it("writes the hex the system picker returns", async () => {
188
+ wellState.present = true;
189
+ const ColorPicker = await loadColorPicker();
190
+ const onChange = vi.fn();
191
+ const result = renderWithProviders(
192
+ <ColorPicker label="Accent" value="#EF4444" onChange={onChange} />,
193
+ );
194
+ expect(result.container.querySelectorAll('[data-testid="system-color-well"]')).toHaveLength(1);
195
+ const well = wellState.last;
196
+ expect(well?.selection).toBe("#EF4444");
197
+ expect(well?.label).toBe("Accent");
198
+ expect(well?.supportsOpacity).toBe(false);
199
+ await act(async () => well?.onSelectionChange("#3366ff"));
200
+ expect(onChange).toHaveBeenLastCalledWith("#3366FF");
201
+ });
202
+
203
+ it("carries opacity through when showAlpha is on", async () => {
204
+ wellState.present = true;
205
+ const ColorPicker = await loadColorPicker();
206
+ const onChange = vi.fn();
207
+ renderWithProviders(
208
+ <ColorPicker label="Accent" value="#EF444480" showAlpha onChange={onChange} />,
209
+ );
210
+ expect(wellState.last?.supportsOpacity).toBe(true);
211
+ expect(wellState.last?.selection).toBe("#EF444480");
212
+ await act(async () => wellState.last?.onSelectionChange("#3366FF40"));
213
+ expect(onChange).toHaveBeenLastCalledWith("#3366FF40");
214
+ });
215
+
216
+ it("picks a theme swatch from the row under the field", async () => {
217
+ wellState.present = true;
218
+ const ColorPicker = await loadColorPicker();
219
+ const onChange = vi.fn();
220
+ const result = renderWithProviders(
221
+ <ColorPicker
222
+ label="Accent"
223
+ value="#EF4444"
224
+ presetColors={["#EF4444", "#22C55E"]}
225
+ onChange={onChange}
226
+ />,
227
+ );
228
+ await act(async () => {
229
+ fireEvent.click(within(result.container).getByRole("radio", { name: "Color #22C55E" }));
230
+ });
231
+ expect(onChange).toHaveBeenLastCalledWith("#22C55E");
232
+ });
233
+
234
+ it("keeps the clear button outside the well", async () => {
235
+ wellState.present = true;
236
+ const ColorPicker = await loadColorPicker();
237
+ const onChange = vi.fn();
238
+ const result = renderWithProviders(
239
+ <ColorPicker label="Accent" value="#EF4444" onChange={onChange} />,
240
+ );
241
+ await act(async () => {
242
+ fireEvent.click(within(result.container).getByRole("button", { name: "Clear color" }));
243
+ });
244
+ expect(onChange).toHaveBeenLastCalledWith("");
245
+ });
246
+
247
+ it("draws no well while disabled", async () => {
248
+ wellState.present = true;
249
+ const ColorPicker = await loadColorPicker();
250
+ renderWithProviders(<ColorPicker label="Accent" value="#EF4444" disabled />);
251
+ expect(wellState.last).toBeUndefined();
252
+ });
253
+ });
254
+
255
+ describe("ColorPicker without @expo/ui (older binary)", () => {
256
+ it("opens the MPO picker and does not crash", async () => {
257
+ const ColorPicker = await loadColorPicker();
258
+ const result = renderWithProviders(<ColorPicker label="Accent" value="#EF4444" />);
259
+ expect(result.queryByTestId("color-picker-system-field")).toBeNull();
260
+ const input = result.container.querySelector("input");
261
+ await act(async () => {
262
+ if (input) fireEvent.click(input);
263
+ });
264
+ expect(document.body.querySelector('[data-testid="color-picker-panel"]')).toBeTruthy();
265
+ });
266
+ });