@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
@@ -0,0 +1,144 @@
1
+ /**
2
+ * LC-266: on iOS the Color field opens the system colour picker
3
+ * (UIColorPickerViewController) through @expo/ui's SwiftUI ColorPicker.
4
+ *
5
+ * @expo/ui is native code, so an OTA can carry this JS to a binary built
6
+ * without it. `requireOptionalNativeModule("ExpoUI")` answers null there and
7
+ * the field keeps the MPO picker. The probe runs before `@expo/ui` is
8
+ * required: its views call `requireNativeView` at module load, which throws
9
+ * on a binary that lacks them.
10
+ */
11
+ import type { ComponentType, ReactNode } from "react";
12
+ import { Platform } from "react-native";
13
+ import type { SystemColorWellProps } from "./systemColorWell";
14
+
15
+ export type { SystemColorWellProps } from "./systemColorWell";
16
+
17
+ export const expoUiNativeModule = "ExpoUI";
18
+
19
+ /**
20
+ * The SwiftUI well is a 28pt circle and only the well opens the picker, so it
21
+ * is drawn nearly transparent and scaled past the field's bounds: the whole
22
+ * field becomes the well's hit area while the MPO swatch and hex stay visible.
23
+ * Below 0.01 alpha UIKit stops hit-testing a view.
24
+ */
25
+ export const wellPoints = 28;
26
+ export const wellOpacity = 0.02;
27
+
28
+ export function wellScale(width: number, height: number): number {
29
+ return Math.max(1, Math.ceil((Math.max(width, height) * 2) / wellPoints));
30
+ }
31
+
32
+ type Modifier = unknown;
33
+
34
+ export interface SwiftUiColorModule {
35
+ Host: ComponentType<{
36
+ children?: ReactNode;
37
+ colorScheme?: "light" | "dark";
38
+ style?: SystemColorWellProps["style"];
39
+ }>;
40
+ ColorPicker: ComponentType<{
41
+ selection: string | null;
42
+ label?: string;
43
+ supportsOpacity?: boolean;
44
+ onSelectionChange?: (value: string) => void;
45
+ modifiers?: Modifier[];
46
+ }>;
47
+ }
48
+
49
+ export interface SwiftUiModifiers {
50
+ labelsHidden: () => Modifier;
51
+ scaleEffect: (scale: number) => Modifier;
52
+ opacity: (value: number) => Modifier;
53
+ }
54
+
55
+ export interface SystemColorWellSources {
56
+ core: () => { requireOptionalNativeModule?: (name: string) => unknown };
57
+ ui: () => Partial<SwiftUiColorModule>;
58
+ modifiers: () => Partial<SwiftUiModifiers>;
59
+ }
60
+
61
+ /**
62
+ * Each `require` sits in its own `try` so Metro bundles an app without
63
+ * @expo/ui (an optional peer) instead of failing the build (FZ-7357).
64
+ */
65
+ const appSources: SystemColorWellSources = {
66
+ core: () => {
67
+ try {
68
+ return require("expo-modules-core");
69
+ } catch {
70
+ return {};
71
+ }
72
+ },
73
+ ui: () => {
74
+ try {
75
+ return require("@expo/ui/swift-ui");
76
+ } catch {
77
+ return {};
78
+ }
79
+ },
80
+ modifiers: () => {
81
+ try {
82
+ return require("@expo/ui/swift-ui/modifiers");
83
+ } catch {
84
+ return {};
85
+ }
86
+ },
87
+ };
88
+
89
+ export function makeSystemColorWell(
90
+ ui: SwiftUiColorModule,
91
+ modifiers: SwiftUiModifiers,
92
+ ): ComponentType<SystemColorWellProps> {
93
+ return function SystemColorWell({
94
+ selection,
95
+ supportsOpacity,
96
+ label,
97
+ colorScheme,
98
+ width,
99
+ height,
100
+ onSelectionChange,
101
+ style,
102
+ }: SystemColorWellProps) {
103
+ return (
104
+ <ui.Host colorScheme={colorScheme} style={style}>
105
+ <ui.ColorPicker
106
+ label={label}
107
+ selection={selection || null}
108
+ supportsOpacity={supportsOpacity}
109
+ onSelectionChange={onSelectionChange}
110
+ modifiers={[
111
+ modifiers.labelsHidden(),
112
+ modifiers.scaleEffect(wellScale(width, height)),
113
+ modifiers.opacity(wellOpacity),
114
+ ]}
115
+ />
116
+ </ui.Host>
117
+ );
118
+ };
119
+ }
120
+
121
+ /** The system colour well, or null when this platform or binary cannot draw it. */
122
+ export function resolveSystemColorWell(
123
+ sources: SystemColorWellSources,
124
+ os: string,
125
+ ): ComponentType<SystemColorWellProps> | null {
126
+ if (os !== "ios") return null;
127
+ try {
128
+ if (sources.core().requireOptionalNativeModule?.(expoUiNativeModule) == null) return null;
129
+ const ui = sources.ui();
130
+ const modifiers = sources.modifiers();
131
+ if (!ui.Host || !ui.ColorPicker) return null;
132
+ if (!modifiers.labelsHidden || !modifiers.scaleEffect || !modifiers.opacity) return null;
133
+ return makeSystemColorWell(ui as SwiftUiColorModule, modifiers as SwiftUiModifiers);
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+
139
+ let cached: ComponentType<SystemColorWellProps> | null | undefined;
140
+
141
+ export function useSystemColorWell(): ComponentType<SystemColorWellProps> | null {
142
+ if (cached === undefined) cached = resolveSystemColorWell(appSources, Platform.OS);
143
+ return cached;
144
+ }
@@ -0,0 +1,21 @@
1
+ import type { ComponentType } from "react";
2
+ import type { StyleProp, ViewStyle } from "react-native";
3
+
4
+ export interface SystemColorWellProps {
5
+ /** `#RRGGBB`, or `#RRGGBBAA` with opacity; empty for no colour. */
6
+ selection: string;
7
+ supportsOpacity: boolean;
8
+ /** Spoken name; the visible label stays the field's. */
9
+ label: string;
10
+ colorScheme: "light" | "dark";
11
+ /** The area the well covers, in points. */
12
+ width: number;
13
+ height: number;
14
+ onSelectionChange: (hex: string) => void;
15
+ style?: StyleProp<ViewStyle>;
16
+ }
17
+
18
+ /** Web: the MPO picker is the platform form (LC-266). */
19
+ export function useSystemColorWell(): ComponentType<SystemColorWellProps> | null {
20
+ return null;
21
+ }
@@ -13,7 +13,8 @@ import { DatetimePicker } from "./DatetimePicker";
13
13
  import { MonthPicker } from "./MonthPicker";
14
14
  import { MultiDatePicker } from "./MultiDatePicker";
15
15
  import { DatePicker } from "./index";
16
- import { Text } from "@multiplatform.one/theme";
16
+ import { formatCalendarDate, Text } from "@multiplatform.one/theme";
17
+ import { TimePicker } from "../TimePicker";
17
18
 
18
19
  const meta: Meta<typeof DatePicker> = {
19
20
  title: "Forms/DatePicker",
@@ -128,6 +129,31 @@ export const StateContract: Story = {
128
129
  },
129
130
  };
130
131
 
132
+ /**
133
+ * LC-266 (FZ-8114): on a coarse-pointer browser each field opens the
134
+ * browser's own picker; a fine pointer keeps the MPO calendar and wheels. The
135
+ * lines below show the committed value, which is the same on both paths.
136
+ */
137
+ export const TouchWeb: Story = {
138
+ render: () => {
139
+ const [date, setDate] = useState<Date | null>(new Date(2026, 2, 5));
140
+ const [time, setTime] = useState("14:30");
141
+ const [datetime, setDatetime] = useState<Date | null>(new Date(2026, 2, 5, 14, 30));
142
+ return (
143
+ <YStack gap="$4" maxWidth={400}>
144
+ <DatePicker label="Date" value={date} onChange={setDate} />
145
+ <TimePicker label="Time" value={time} onChange={setTime} />
146
+ <DatetimePicker label="Date and time" value={datetime} onChange={setDatetime} />
147
+ <YStack gap="$1">
148
+ <Text data-testid="touch-web-date">{`Date value: ${formatCalendarDate(date, "date") || "empty"}`}</Text>
149
+ <Text data-testid="touch-web-time">{`Time value: ${time || "empty"}`}</Text>
150
+ <Text data-testid="touch-web-datetime">{`Date and time value: ${formatCalendarDate(datetime, "datetime") || "empty"}`}</Text>
151
+ </YStack>
152
+ </YStack>
153
+ );
154
+ },
155
+ };
156
+
131
157
  export const Basic: Story = {
132
158
  render: (args) => <DatePicker {...args} />,
133
159
  args: {
@@ -19,6 +19,12 @@ import type { FormFieldProps } from "../../fieldLayout";
19
19
  import { Input as InputParts } from "../../InputParts";
20
20
  import { Skeleton } from "../../Skeleton";
21
21
  import type { FieldComponentProps, Validator } from "../../types";
22
+ import {
23
+ fromNativeDateValue,
24
+ toDatetimeLocalValue,
25
+ TouchWebPickerInput,
26
+ useCoarsePointer,
27
+ } from "../../shared/touchWebPicker";
22
28
 
23
29
  import {
24
30
  CalendarDays,
@@ -49,7 +55,9 @@ export type DatetimePickerProps<
49
55
  TData extends DeepValue<TParentData, TName> = DeepValue<TParentData, TName>,
50
56
  > = Omit<
51
57
  FormFieldProps<TParentData, TName, TFieldValidator, TFormValidator, TData>,
52
- "children" | "field"
58
+ // FormFieldProps carries YStack's DOM ChangeEventHandler; the field's own
59
+ // value callback replaces it, same as DatePicker.
60
+ "children" | "field" | "onChange"
53
61
  > &
54
62
  Partial<
55
63
  Omit<
@@ -378,6 +386,7 @@ export function DatetimePicker<
378
386
 
379
387
  const { resolvedForm, knobProps, id } = useFormField({ form, id: idProp });
380
388
  const resolvedValidators = useResolvedValidators(required, validators, label, name);
389
+ const touchWeb = useCoarsePointer();
381
390
 
382
391
  const [open, setOpen] = useState(false);
383
392
  const [selectedDatetime, setSelectedDatetime] = useState<Date | null>(() =>
@@ -461,6 +470,40 @@ export function DatetimePicker<
461
470
  />
462
471
  );
463
472
 
473
+ const renderTouchTrigger = (
474
+ hasError?: boolean,
475
+ blurHandler?: (...args: any[]) => void,
476
+ fieldChange?: (value: any) => void,
477
+ ) => (
478
+ <DatePickerInputTrigger
479
+ value={displayValue}
480
+ placeholder={placeholder}
481
+ disabled={disabled}
482
+ onReset={() => handleClearDatetime(fieldChange)}
483
+ onButtonPress={() => {}}
484
+ touchInput={
485
+ <TouchWebPickerInput
486
+ kind="datetime-local"
487
+ id={id}
488
+ value={toDatetimeLocalValue(selectedDatetime, showSeconds)}
489
+ min={toDatetimeLocalValue(minDate, showSeconds)}
490
+ max={toDatetimeLocalValue(maxDate, showSeconds)}
491
+ showSeconds={showSeconds}
492
+ disabled={disabled}
493
+ required={required}
494
+ invalid={hasError}
495
+ aria-label={label ? undefined : t("Date and time")}
496
+ onValue={(next) => {
497
+ const date = fromNativeDateValue(next);
498
+ if (date) handleDatetimeSelect(date, fieldChange);
499
+ else handleClearDatetime(fieldChange);
500
+ }}
501
+ onBlur={blurHandler}
502
+ />
503
+ }
504
+ />
505
+ );
506
+
464
507
  const renderPopover = (
465
508
  hasError?: boolean,
466
509
  blurHandler?: (...args: any[]) => void,
@@ -478,6 +521,7 @@ export function DatetimePicker<
478
521
  />
479
522
  );
480
523
  }
524
+ if (touchWeb) return renderTouchTrigger(hasError, blurHandler, fieldChange);
481
525
  const handleOpenChange = (isOpen: boolean) => {
482
526
  // Re-seed the visible month from the current value on every open.
483
527
  if (isOpen && selectedDatetimeRef.current) setDisplayDate(selectedDatetimeRef.current);
@@ -16,6 +16,12 @@ import type { FormFieldProps } from "../../fieldLayout";
16
16
  import { Input as InputParts } from "../../InputParts";
17
17
  import { Skeleton } from "../../Skeleton";
18
18
  import type { FieldComponentProps, Validator } from "../../types";
19
+ import {
20
+ fromNativeDateValue,
21
+ toDateInputValue,
22
+ TouchWebPickerInput,
23
+ useCoarsePointer,
24
+ } from "../../shared/touchWebPicker";
19
25
 
20
26
  import {
21
27
  CalendarDays,
@@ -211,6 +217,7 @@ export function DatePicker<
211
217
  const { resolvedForm, knobProps, id } = useFormField({ form, id: idProp });
212
218
  const resolvedValidators = useResolvedValidators(required, validators, label, name);
213
219
  const hasLabel = Boolean(label);
220
+ const touchWeb = useCoarsePointer();
214
221
 
215
222
  const [open, setOpen] = useState(false);
216
223
  const [selectedDate, setSelectedDate] = useState<Date | null>(() =>
@@ -293,6 +300,46 @@ export function DatePicker<
293
300
  );
294
301
  }
295
302
 
303
+ const describedBy =
304
+ [
305
+ helperText && id ? `${id}-description` : undefined,
306
+ hasError && id ? `${id}-error` : undefined,
307
+ ]
308
+ .filter(Boolean)
309
+ .join(" ") || undefined;
310
+
311
+ if (touchWeb) {
312
+ return (
313
+ <DatePickerInputTrigger
314
+ value={formatDateDisplay(selectedDate)}
315
+ placeholder={placeholder}
316
+ disabled={disabled}
317
+ onReset={() => handleClearDate(fieldChange)}
318
+ onButtonPress={() => {}}
319
+ touchInput={
320
+ <TouchWebPickerInput
321
+ kind="date"
322
+ id={id}
323
+ value={toDateInputValue(selectedDate)}
324
+ min={toDateInputValue(minDate)}
325
+ max={toDateInputValue(maxDate)}
326
+ disabled={disabled}
327
+ required={required}
328
+ invalid={hasError}
329
+ aria-label={hasLabel ? undefined : t("Date")}
330
+ aria-describedby={describedBy}
331
+ onValue={(next) => {
332
+ const date = fromNativeDateValue(next);
333
+ if (date) handleDateSelect(date, fieldChange);
334
+ else handleClearDate(fieldChange);
335
+ }}
336
+ onBlur={blurHandler}
337
+ />
338
+ }
339
+ />
340
+ );
341
+ }
342
+
296
343
  const handleOpenChange = (isOpen: boolean) => {
297
344
  // Re-seed the visible month from the current value on every open —
298
345
  // the value may have changed (externally or by a pick) since mount.
@@ -336,14 +383,7 @@ export function DatePicker<
336
383
  // Labelled: FieldLayout's Label wires `aria-labelledby` onto this
337
384
  // id itself — naming it here too doubles the spoken name.
338
385
  aria-label={hasLabel ? undefined : t("Date")}
339
- aria-describedby={
340
- [
341
- helperText && id ? `${id}-description` : undefined,
342
- hasError && id ? `${id}-error` : undefined,
343
- ]
344
- .filter(Boolean)
345
- .join(" ") || undefined
346
- }
386
+ aria-describedby={describedBy}
347
387
  aria-required={required || undefined}
348
388
  aria-invalid={hasError || undefined}
349
389
  />
@@ -861,6 +861,11 @@ interface DatePickerInputTriggerProps {
861
861
  "aria-describedby"?: string;
862
862
  "aria-required"?: boolean;
863
863
  "aria-invalid"?: boolean;
864
+ /**
865
+ * Touch web (LC-266): the browser's own picker input, laid over the box.
866
+ * The input then owns the tap, the focus and the field's a11y name.
867
+ */
868
+ touchInput?: ReactNode;
864
869
  }
865
870
 
866
871
  export function DatePickerClearButton({
@@ -900,6 +905,7 @@ export function DatePickerInputTrigger({
900
905
  "aria-describedby": ariaDescribedBy,
901
906
  "aria-required": ariaRequired,
902
907
  "aria-invalid": ariaInvalid,
908
+ touchInput,
903
909
  }: DatePickerInputTriggerProps) {
904
910
  const handleTriggerKey = (
905
911
  e: PanelActionEvent & { key?: string; code?: string; preventDefault?: () => void },
@@ -942,10 +948,10 @@ export function DatePickerInputTrigger({
942
948
  // on the closed text-field trigger.
943
949
  elevation={undefined}
944
950
  data-testid="date-picker-trigger"
945
- id={id}
946
- onPress={isWeb && !disabled ? onButtonPress : undefined}
951
+ id={touchInput ? undefined : id}
952
+ onPress={isWeb && !disabled && !touchInput ? onButtonPress : undefined}
947
953
  {...(disabled ? { opacity: 0.5, pointerEvents: "none" } : undefined)}
948
- {...(isWeb
954
+ {...(isWeb && !touchInput
949
955
  ? {
950
956
  role: "combobox" as const,
951
957
  "aria-haspopup": "dialog",
@@ -960,6 +966,7 @@ export function DatePickerInputTrigger({
960
966
  }
961
967
  : undefined)}
962
968
  >
969
+ {touchInput}
963
970
  <InputParts.Section data-testid="date-picker-glyph">
964
971
  <InputParts.Icon adornment="leading" color={formCommonColors.muted}>
965
972
  <Glyph />
@@ -970,6 +977,7 @@ export function DatePickerInputTrigger({
970
977
  readOnly
971
978
  pointerEvents="none"
972
979
  tabIndex={-1}
980
+ aria-hidden={touchInput ? true : undefined}
973
981
  value={value}
974
982
  placeholder={placeholder}
975
983
  disabled={disabled}
@@ -979,7 +987,14 @@ export function DatePickerInputTrigger({
979
987
  </InputParts.Section>
980
988
  {value && isWeb ? (
981
989
  <InputParts.Section>
982
- <DatePickerClearButton onReset={onReset} disabled={disabled} />
990
+ {touchInput ? (
991
+ // Above the touch input, so Clear stays reachable on the box.
992
+ <View zIndex={2}>
993
+ <DatePickerClearButton onReset={onReset} disabled={disabled} />
994
+ </View>
995
+ ) : (
996
+ <DatePickerClearButton onReset={onReset} disabled={disabled} />
997
+ )}
983
998
  </InputParts.Section>
984
999
  ) : null}
985
1000
  </InputParts.Box>
@@ -0,0 +1,245 @@
1
+ /**
2
+ * FZ-8114 (LC-266): Date, Time and Datetime on a coarse-pointer web viewport
3
+ * hand the pick to the browser's own control, and a fine pointer keeps the
4
+ * MPO calendar. Both paths must commit the same value.
5
+ */
6
+ import { formatCalendarDate } from "@multiplatform.one/theme";
7
+ import { useForm } from "@tanstack/react-form";
8
+ import { act, fireEvent, waitFor } from "@testing-library/react";
9
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
10
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
11
+ import { Button } from "../../Button";
12
+ import { Form } from "../../Form";
13
+ import { TimePicker } from "../TimePicker";
14
+ import { DatePicker, DatetimePicker } from "./index";
15
+
16
+ let coarse = false;
17
+ const originalMatchMedia = window.matchMedia;
18
+
19
+ beforeEach(() => {
20
+ Object.defineProperty(window, "matchMedia", {
21
+ configurable: true,
22
+ writable: true,
23
+ value: (media: string) => ({
24
+ media,
25
+ matches: media === "(pointer: coarse)" ? coarse : false,
26
+ onchange: null,
27
+ addEventListener: () => {},
28
+ removeEventListener: () => {},
29
+ addListener: () => {},
30
+ removeListener: () => {},
31
+ dispatchEvent: () => false,
32
+ }),
33
+ });
34
+ });
35
+
36
+ afterEach(() => {
37
+ coarse = false;
38
+ Object.defineProperty(window, "matchMedia", {
39
+ configurable: true,
40
+ writable: true,
41
+ value: originalMatchMedia,
42
+ });
43
+ });
44
+
45
+ function touchInput(root: ParentNode, type: string): HTMLInputElement {
46
+ const input = root.querySelector(`input[type="${type}"]`) as HTMLInputElement | null;
47
+ expect(input).not.toBeNull();
48
+ return input as HTMLInputElement;
49
+ }
50
+
51
+ function dayButton(root: ParentNode, day: string): HTMLElement {
52
+ const match = Array.from(root.querySelectorAll('button, [role="button"]')).find(
53
+ (el) => el.textContent?.trim() === day && el.getAttribute("aria-disabled") !== "true",
54
+ );
55
+ expect(match).toBeTruthy();
56
+ return match as HTMLElement;
57
+ }
58
+
59
+ describe("Date on touch web (FZ-8114)", () => {
60
+ it("a coarse pointer renders the browser's date input, labelled by the field", () => {
61
+ coarse = true;
62
+ const result = renderWithProviders(<DatePicker label="Due" name="due" />);
63
+ const input = touchInput(result.container, "date");
64
+ const label = result.container.querySelector("label");
65
+ expect(label?.getAttribute("for")).toBe(input.id);
66
+ expect(result.container.querySelector('[role="combobox"]')).toBeNull();
67
+ });
68
+
69
+ it("a fine pointer keeps the MPO combobox and no date input", () => {
70
+ const result = renderWithProviders(<DatePicker label="Due" name="due" />);
71
+ expect(result.container.querySelector('input[type="date"]')).toBeNull();
72
+ expect(result.container.querySelector('[role="combobox"]')).not.toBeNull();
73
+ });
74
+
75
+ it("the picked string shows in the MPO box and emits the calendar's Date", async () => {
76
+ coarse = true;
77
+ const onChange = vi.fn();
78
+ const result = renderWithProviders(<DatePicker label="Due" name="due" onChange={onChange} />);
79
+ await act(async () => {
80
+ fireEvent.change(touchInput(result.container, "date"), { target: { value: "2026-03-05" } });
81
+ });
82
+ expect(onChange).toHaveBeenCalledTimes(1);
83
+ const picked = onChange.mock.calls[0][0] as Date;
84
+ expect(picked.getTime()).toBe(new Date(2026, 2, 5).getTime());
85
+ expect(result.container.querySelector('input[value="Mar 5, 2026"]')).not.toBeNull();
86
+ expect(touchInput(result.container, "date").value).toBe("2026-03-05");
87
+ });
88
+
89
+ it("both paths commit the same value for the same day", async () => {
90
+ const fine = vi.fn();
91
+ const desktop = renderWithProviders(
92
+ <DatePicker label="Due" name="due" value="2026-03-01" onChange={fine} />,
93
+ );
94
+ await act(async () => {
95
+ fireEvent.click(desktop.container.querySelector('[role="combobox"]') as HTMLElement);
96
+ });
97
+ await waitFor(() => dayButton(document.body, "5"));
98
+ await act(async () => {
99
+ fireEvent.click(dayButton(document.body, "5"));
100
+ });
101
+ desktop.unmount();
102
+
103
+ coarse = true;
104
+ const touch = vi.fn();
105
+ const phone = renderWithProviders(
106
+ <DatePicker label="Due" name="due" value="2026-03-01" onChange={touch} />,
107
+ );
108
+ expect(touchInput(phone.container, "date").value).toBe("2026-03-01");
109
+ await act(async () => {
110
+ fireEvent.change(touchInput(phone.container, "date"), { target: { value: "2026-03-05" } });
111
+ });
112
+
113
+ expect(fine).toHaveBeenCalledTimes(1);
114
+ expect(touch).toHaveBeenCalledTimes(1);
115
+ expect((touch.mock.calls[0][0] as Date).getTime()).toBe(
116
+ (fine.mock.calls[0][0] as Date).getTime(),
117
+ );
118
+ });
119
+
120
+ it("clearing in the browser's control clears the field", async () => {
121
+ coarse = true;
122
+ const onChange = vi.fn();
123
+ const result = renderWithProviders(
124
+ <DatePicker label="Due" name="due" value="2026-03-05" onChange={onChange} />,
125
+ );
126
+ await act(async () => {
127
+ fireEvent.change(touchInput(result.container, "date"), { target: { value: "" } });
128
+ });
129
+ expect(onChange).toHaveBeenLastCalledWith(null);
130
+ });
131
+
132
+ it("min and max ride the input as local calendar days", () => {
133
+ coarse = true;
134
+ const result = renderWithProviders(
135
+ <DatePicker
136
+ label="Due"
137
+ name="due"
138
+ minDate={new Date(2026, 0, 2)}
139
+ maxDate={new Date(2026, 11, 30)}
140
+ />,
141
+ );
142
+ const input = touchInput(result.container, "date");
143
+ expect(input.min).toBe("2026-01-02");
144
+ expect(input.max).toBe("2026-12-30");
145
+ });
146
+
147
+ it("commits into form state", async () => {
148
+ coarse = true;
149
+ const onSubmit = vi.fn();
150
+ const Fixture = () => {
151
+ const form = useForm({
152
+ defaultValues: { due: null as Date | null },
153
+ onSubmit: async ({ value }) => onSubmit(value),
154
+ });
155
+ return (
156
+ <Form form={form}>
157
+ <DatePicker label="Due" name="due" />
158
+ <Button action="submit">Submit</Button>
159
+ </Form>
160
+ );
161
+ };
162
+ const result = renderWithProviders(<Fixture />);
163
+ await act(async () => {
164
+ fireEvent.change(touchInput(result.container, "date"), { target: { value: "2026-08-15" } });
165
+ });
166
+ await act(async () => {
167
+ fireEvent.click(result.getSubmitButton() as HTMLElement);
168
+ });
169
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
170
+ expect(formatCalendarDate(onSubmit.mock.calls[0][0].due, "date")).toBe("2026-08-15");
171
+ });
172
+ });
173
+
174
+ describe("Datetime on touch web (FZ-8114)", () => {
175
+ it("a coarse pointer renders datetime-local and emits the local wall clock", async () => {
176
+ coarse = true;
177
+ const onChange = vi.fn();
178
+ const result = renderWithProviders(
179
+ <DatetimePicker label="Starts" name="starts" onChange={onChange} />,
180
+ );
181
+ await act(async () => {
182
+ fireEvent.change(touchInput(result.container, "datetime-local"), {
183
+ target: { value: "2026-03-05T14:30" },
184
+ });
185
+ });
186
+ const picked = onChange.mock.calls[0][0] as Date;
187
+ expect(formatCalendarDate(picked, "datetime")).toBe("2026-03-05 14:30:00");
188
+ expect(touchInput(result.container, "datetime-local").value).toBe("2026-03-05T14:30");
189
+ });
190
+
191
+ it("seconds widen the input's step and survive the round trip", () => {
192
+ coarse = true;
193
+ const result = renderWithProviders(
194
+ <DatetimePicker
195
+ label="Starts"
196
+ name="starts"
197
+ showSeconds
198
+ value={new Date(2026, 2, 5, 14, 30, 15)}
199
+ />,
200
+ );
201
+ const input = touchInput(result.container, "datetime-local");
202
+ expect(input.step).toBe("1");
203
+ expect(input.value).toBe("2026-03-05T14:30:15");
204
+ });
205
+
206
+ it("a fine pointer keeps the MPO panel trigger", () => {
207
+ const result = renderWithProviders(<DatetimePicker label="Starts" name="starts" />);
208
+ expect(result.container.querySelector('input[type="datetime-local"]')).toBeNull();
209
+ expect(result.container.querySelector('[role="combobox"]')).not.toBeNull();
210
+ });
211
+ });
212
+
213
+ describe("Time on touch web (FZ-8114)", () => {
214
+ it("a coarse pointer renders the time input and emits TimePicker's string", async () => {
215
+ coarse = true;
216
+ const onChange = vi.fn();
217
+ const result = renderWithProviders(
218
+ <TimePicker label="At" name="at" value="09:15" onChange={onChange} />,
219
+ );
220
+ const input = touchInput(result.container, "time");
221
+ expect(input.value).toBe("09:15");
222
+ await act(async () => {
223
+ fireEvent.change(input, { target: { value: "14:30" } });
224
+ });
225
+ expect(onChange).toHaveBeenLastCalledWith("14:30");
226
+ expect(result.container.querySelector('input[value="2:30 PM"]')).not.toBeNull();
227
+ });
228
+
229
+ it("showSeconds emits HH:mm:ss even when the browser drops :00", async () => {
230
+ coarse = true;
231
+ const onChange = vi.fn();
232
+ const result = renderWithProviders(
233
+ <TimePicker label="At" name="at" showSeconds onChange={onChange} />,
234
+ );
235
+ await act(async () => {
236
+ fireEvent.change(touchInput(result.container, "time"), { target: { value: "14:30" } });
237
+ });
238
+ expect(onChange).toHaveBeenLastCalledWith("14:30:00");
239
+ });
240
+
241
+ it("a fine pointer keeps the MPO wheels trigger", () => {
242
+ const result = renderWithProviders(<TimePicker label="At" name="at" />);
243
+ expect(result.container.querySelector('input[type="time"]')).toBeNull();
244
+ });
245
+ });