@terreno/ui 0.7.1 → 0.8.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.
- package/dist/BooleanField.js +23 -23
- package/dist/BooleanField.js.map +1 -1
- package/dist/ConsentFormScreen.d.ts +14 -0
- package/dist/ConsentFormScreen.js +93 -0
- package/dist/ConsentFormScreen.js.map +1 -0
- package/dist/ConsentHistory.d.ts +8 -0
- package/dist/ConsentHistory.js +70 -0
- package/dist/ConsentHistory.js.map +1 -0
- package/dist/ConsentNavigator.d.ts +9 -0
- package/dist/ConsentNavigator.js +72 -0
- package/dist/ConsentNavigator.js.map +1 -0
- package/dist/DataTable.js +1 -1
- package/dist/DataTable.js.map +1 -1
- package/dist/DateTimeActionSheet.js +24 -8
- package/dist/DateTimeActionSheet.js.map +1 -1
- package/dist/DateTimeField.d.ts +22 -0
- package/dist/DateTimeField.js +187 -67
- package/dist/DateTimeField.js.map +1 -1
- package/dist/DraggableList.d.ts +66 -0
- package/dist/DraggableList.js +241 -0
- package/dist/DraggableList.js.map +1 -0
- package/dist/Link.js +1 -1
- package/dist/Link.js.map +1 -1
- package/dist/MarkdownEditor.d.ts +12 -0
- package/dist/MarkdownEditor.js +12 -0
- package/dist/MarkdownEditor.js.map +1 -0
- package/dist/MarkdownEditorField.d.ts +1 -0
- package/dist/MarkdownEditorField.js +16 -16
- package/dist/MarkdownEditorField.js.map +1 -1
- package/dist/Modal.js +11 -1
- package/dist/Modal.js.map +1 -1
- package/dist/PickerSelect.js +10 -0
- package/dist/PickerSelect.js.map +1 -1
- package/dist/Slider.js +2 -8
- package/dist/Slider.js.map +1 -1
- package/dist/TerrenoProvider.js +10 -1
- package/dist/TerrenoProvider.js.map +1 -1
- package/dist/UpgradeRequiredScreen.d.ts +8 -0
- package/dist/UpgradeRequiredScreen.js +10 -0
- package/dist/UpgradeRequiredScreen.js.map +1 -0
- package/dist/generateConsentHistoryPdf.d.ts +2 -0
- package/dist/generateConsentHistoryPdf.js +185 -0
- package/dist/generateConsentHistoryPdf.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/dist/useConsentForms.d.ts +29 -0
- package/dist/useConsentForms.js +50 -0
- package/dist/useConsentForms.js.map +1 -0
- package/dist/useConsentHistory.d.ts +31 -0
- package/dist/useConsentHistory.js +17 -0
- package/dist/useConsentHistory.js.map +1 -0
- package/dist/useSubmitConsent.d.ts +12 -0
- package/dist/useSubmitConsent.js +23 -0
- package/dist/useSubmitConsent.js.map +1 -0
- package/package.json +4 -2
- package/src/BooleanField.test.tsx +3 -5
- package/src/BooleanField.tsx +33 -31
- package/src/ConsentFormScreen.tsx +216 -0
- package/src/ConsentHistory.tsx +249 -0
- package/src/ConsentNavigator.test.tsx +111 -0
- package/src/ConsentNavigator.tsx +128 -0
- package/src/DataTable.tsx +1 -1
- package/src/DateTimeActionSheet.tsx +21 -8
- package/src/DateTimeField.tsx +416 -133
- package/src/DraggableList.tsx +424 -0
- package/src/Link.tsx +1 -1
- package/src/MarkdownEditor.tsx +66 -0
- package/src/MarkdownEditorField.tsx +32 -28
- package/src/Modal.tsx +19 -1
- package/src/PickerSelect.tsx +11 -0
- package/src/Slider.tsx +2 -1
- package/src/TerrenoProvider.tsx +10 -1
- package/src/TimezonePicker.test.tsx +9 -1
- package/src/UpgradeRequiredScreen.tsx +52 -0
- package/src/__snapshots__/BooleanField.test.tsx.snap +167 -203
- package/src/__snapshots__/DataTable.test.tsx.snap +0 -114
- package/src/__snapshots__/Field.test.tsx.snap +53 -69
- package/src/__snapshots__/Link.test.tsx.snap +14 -21
- package/src/__snapshots__/Slider.test.tsx.snap +0 -7
- package/src/__snapshots__/TimezonePicker.test.tsx.snap +0 -4710
- package/src/generateConsentHistoryPdf.ts +211 -0
- package/src/index.tsx +9 -1
- package/src/useConsentForms.ts +70 -0
- package/src/useConsentHistory.ts +40 -0
- package/src/useSubmitConsent.ts +35 -0
package/src/DateTimeField.tsx
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import {FontAwesome6} from "@expo/vector-icons";
|
|
1
2
|
import {DateTime} from "luxon";
|
|
2
|
-
import React, {type FC, useCallback, useEffect, useRef, useState} from "react";
|
|
3
|
-
import {TextInput, View} from "react-native";
|
|
3
|
+
import React, {type FC, useCallback, useEffect, useMemo, useRef, useState} from "react";
|
|
4
|
+
import {Pressable, TextInput, View} from "react-native";
|
|
4
5
|
|
|
5
6
|
import {Box} from "./Box";
|
|
6
|
-
import type {DateTimeFieldProps
|
|
7
|
+
import type {DateTimeFieldProps} from "./Common";
|
|
7
8
|
import {DateTimeActionSheet} from "./DateTimeActionSheet";
|
|
8
9
|
import {FieldError, FieldHelperText, FieldTitle} from "./fieldElements";
|
|
9
10
|
import {IconButton} from "./IconButton";
|
|
@@ -17,6 +18,10 @@ interface SeparatorProps {
|
|
|
17
18
|
type: "date" | "time";
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Visual separator rendered between date or time input segments.
|
|
23
|
+
* Displays "/" for date fields (MM/DD/YYYY) and ":" for time fields (hh:mm).
|
|
24
|
+
*/
|
|
20
25
|
const Separator: FC<SeparatorProps> = ({type}) => {
|
|
21
26
|
return (
|
|
22
27
|
<View>
|
|
@@ -36,6 +41,12 @@ interface DateTimeSegmentProps {
|
|
|
36
41
|
error?: string;
|
|
37
42
|
}
|
|
38
43
|
|
|
44
|
+
/**
|
|
45
|
+
* A single numeric input segment within a date or time field.
|
|
46
|
+
* Each segment represents one part of the value (e.g. month, day, year, hour, minute).
|
|
47
|
+
* Renders a fixed-width TextInput with centered text and numeric keyboard.
|
|
48
|
+
* Used on desktop; on mobile, segments are replaced by {@link MobileTimeDisplay}.
|
|
49
|
+
*/
|
|
39
50
|
const DateTimeSegment: FC<DateTimeSegmentProps> = ({
|
|
40
51
|
disabled,
|
|
41
52
|
getFieldValue,
|
|
@@ -90,6 +101,11 @@ interface DateTimeProps extends Omit<DateTimeSegmentProps, "index" | "config"> {
|
|
|
90
101
|
fieldErrors?: Record<number, string | undefined>;
|
|
91
102
|
}
|
|
92
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Groups three {@link DateTimeSegment} inputs for month, day, and year (MM/DD/YYYY).
|
|
106
|
+
* Renders as a fixed-width (130px) row with "/" separators between segments.
|
|
107
|
+
* Used in both date-only and datetime field types.
|
|
108
|
+
*/
|
|
93
109
|
const DateField: FC<DateTimeProps> = ({fieldErrors, ...segmentProps}) => {
|
|
94
110
|
return (
|
|
95
111
|
<View
|
|
@@ -124,6 +140,15 @@ const DateField: FC<DateTimeProps> = ({fieldErrors, ...segmentProps}) => {
|
|
|
124
140
|
);
|
|
125
141
|
};
|
|
126
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Groups two {@link DateTimeSegment} inputs for hour and minute (hh:mm).
|
|
145
|
+
* Only used on desktop; on mobile, time is rendered as a read-only
|
|
146
|
+
* {@link MobileTimeDisplay} that opens the {@link DateTimeActionSheet} on tap.
|
|
147
|
+
*
|
|
148
|
+
* The hour/minute field indices depend on the parent type:
|
|
149
|
+
* - type="time": indices 0 (hour) and 1 (minute)
|
|
150
|
+
* - type="datetime": indices 3 (hour) and 4 (minute), after the date segments
|
|
151
|
+
*/
|
|
127
152
|
const TimeField: FC<DateTimeProps> = ({type, onBlur, fieldErrors, ...segmentProps}) => {
|
|
128
153
|
const hourIndex = type === "time" ? 0 : 3;
|
|
129
154
|
const minuteIndex = type === "time" ? 1 : 4;
|
|
@@ -148,12 +173,239 @@ const TimeField: FC<DateTimeProps> = ({type, onBlur, fieldErrors, ...segmentProp
|
|
|
148
173
|
);
|
|
149
174
|
};
|
|
150
175
|
|
|
176
|
+
/**
|
|
177
|
+
* @param borderColor - Border color from the parent's computed border state (error, disabled, default).
|
|
178
|
+
* Only applied when {@link showBorder} is true.
|
|
179
|
+
* @param showBorder - When true (default), renders as a standalone bordered field (for type="time").
|
|
180
|
+
* When false, renders borderless to embed inside the datetime container which provides its own border.
|
|
181
|
+
*/
|
|
182
|
+
interface MobileTimeDisplayProps {
|
|
183
|
+
borderColor?: string;
|
|
184
|
+
disabled?: boolean;
|
|
185
|
+
displayText: string;
|
|
186
|
+
onPress: () => void;
|
|
187
|
+
placeholder: string;
|
|
188
|
+
showBorder?: boolean;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Read-only tappable time display used on mobile devices.
|
|
193
|
+
* Shows the formatted time (e.g. "02:30 PM CDT") with a clock icon, matching the
|
|
194
|
+
* Figma design system's mobile time field pattern.
|
|
195
|
+
*
|
|
196
|
+
* Tapping opens the {@link DateTimeActionSheet} for time selection via native pickers.
|
|
197
|
+
*
|
|
198
|
+
* Used in two contexts:
|
|
199
|
+
* - **Standalone** (type="time"): Renders with its own border as the full field container.
|
|
200
|
+
* - **Embedded** (type="datetime"): Renders borderless inside the datetime container,
|
|
201
|
+
* below the date row. The parent container provides the border.
|
|
202
|
+
*/
|
|
203
|
+
const MobileTimeDisplay: FC<MobileTimeDisplayProps> = ({
|
|
204
|
+
borderColor,
|
|
205
|
+
disabled,
|
|
206
|
+
displayText,
|
|
207
|
+
onPress,
|
|
208
|
+
placeholder,
|
|
209
|
+
showBorder = true,
|
|
210
|
+
}): React.ReactElement => {
|
|
211
|
+
const {theme} = useTheme();
|
|
212
|
+
const isPlaceholder = !displayText;
|
|
213
|
+
|
|
214
|
+
return (
|
|
215
|
+
<Pressable
|
|
216
|
+
accessibilityHint="Tap to select a time"
|
|
217
|
+
accessibilityLabel="Time picker"
|
|
218
|
+
disabled={disabled}
|
|
219
|
+
onPress={onPress}
|
|
220
|
+
style={{
|
|
221
|
+
alignItems: "center",
|
|
222
|
+
flexDirection: "row",
|
|
223
|
+
gap: 10,
|
|
224
|
+
minHeight: 40,
|
|
225
|
+
paddingHorizontal: showBorder ? 12 : 10,
|
|
226
|
+
paddingVertical: showBorder ? 8 : 4,
|
|
227
|
+
...(showBorder
|
|
228
|
+
? {
|
|
229
|
+
backgroundColor: disabled ? theme.surface.disabled : theme.surface.base,
|
|
230
|
+
borderColor,
|
|
231
|
+
borderRadius: 4,
|
|
232
|
+
borderWidth: 1,
|
|
233
|
+
maxWidth: 250,
|
|
234
|
+
}
|
|
235
|
+
: {}),
|
|
236
|
+
}}
|
|
237
|
+
>
|
|
238
|
+
<Text color={isPlaceholder ? "secondaryLight" : "primary"} numberOfLines={1} size="md">
|
|
239
|
+
{displayText || placeholder}
|
|
240
|
+
</Text>
|
|
241
|
+
<Box flex="grow" />
|
|
242
|
+
<FontAwesome6
|
|
243
|
+
color={disabled ? theme.text.secondaryLight : theme.text.primary}
|
|
244
|
+
name="clock"
|
|
245
|
+
size={16}
|
|
246
|
+
/>
|
|
247
|
+
</Pressable>
|
|
248
|
+
);
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
interface DateRowWithIconProps {
|
|
252
|
+
disabled?: boolean;
|
|
253
|
+
isMobile: boolean;
|
|
254
|
+
isMobileDatetime: boolean;
|
|
255
|
+
onOpenActionSheet: () => void;
|
|
256
|
+
segmentProps: Omit<DateTimeProps, "type">;
|
|
257
|
+
type: "date" | "datetime" | "time";
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Date section row showing MM/DD/YYYY {@link DateField} segments with a calendar icon.
|
|
262
|
+
* On mobile, renders a non-interactive row (via pointerEvents) with a plain dark calendar icon
|
|
263
|
+
* aligned to the right. On desktop date-only, renders an interactive {@link IconButton}.
|
|
264
|
+
* For desktop datetime, no icon is shown here — it appears in {@link DesktopTimeSection} instead.
|
|
265
|
+
*/
|
|
266
|
+
const DateRowWithIcon: FC<DateRowWithIconProps> = ({
|
|
267
|
+
disabled,
|
|
268
|
+
isMobile,
|
|
269
|
+
isMobileDatetime,
|
|
270
|
+
onOpenActionSheet,
|
|
271
|
+
segmentProps,
|
|
272
|
+
type,
|
|
273
|
+
}): React.ReactElement => {
|
|
274
|
+
const {theme} = useTheme();
|
|
275
|
+
|
|
276
|
+
return (
|
|
277
|
+
<View
|
|
278
|
+
pointerEvents={isMobileDatetime ? "none" : "auto"}
|
|
279
|
+
style={{alignItems: "center", flexDirection: "row"}}
|
|
280
|
+
>
|
|
281
|
+
<DateField {...segmentProps} type={type} />
|
|
282
|
+
{isMobile && <Box flex="grow" />}
|
|
283
|
+
{!disabled && isMobile && (
|
|
284
|
+
<Pressable
|
|
285
|
+
accessibilityHint="Opens the calendar to select a date"
|
|
286
|
+
accessibilityLabel="Show calendar"
|
|
287
|
+
hitSlop={10}
|
|
288
|
+
onPress={onOpenActionSheet}
|
|
289
|
+
style={{paddingHorizontal: 10, paddingVertical: 8}}
|
|
290
|
+
>
|
|
291
|
+
<FontAwesome6 color={theme.text.primary} name="calendar" size={16} />
|
|
292
|
+
</Pressable>
|
|
293
|
+
)}
|
|
294
|
+
{!disabled && !isMobile && type === "date" && (
|
|
295
|
+
<IconButton
|
|
296
|
+
accessibilityHint="Opens the calendar to select a date"
|
|
297
|
+
accessibilityLabel="Show calendar"
|
|
298
|
+
iconName="calendar"
|
|
299
|
+
onClick={onOpenActionSheet}
|
|
300
|
+
variant="navigation"
|
|
301
|
+
/>
|
|
302
|
+
)}
|
|
303
|
+
</View>
|
|
304
|
+
);
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
interface DesktopTimeSectionProps {
|
|
308
|
+
amPm: "am" | "pm";
|
|
309
|
+
disabled?: boolean;
|
|
310
|
+
isMobile: boolean;
|
|
311
|
+
onAmPmChange: (amPm: "am" | "pm") => void;
|
|
312
|
+
onOpenActionSheet: () => void;
|
|
313
|
+
onTimezoneChange: (tz: string) => void;
|
|
314
|
+
segmentProps: Omit<DateTimeProps, "type">;
|
|
315
|
+
timezone: string;
|
|
316
|
+
type: "date" | "datetime" | "time";
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Desktop time editing controls rendered in a horizontal row.
|
|
321
|
+
* Contains editable {@link TimeField} segments (hh:mm), an AM/PM {@link SelectField},
|
|
322
|
+
* a {@link TimezonePicker}, and for datetime type, an {@link IconButton} that opens
|
|
323
|
+
* the {@link DateTimeActionSheet}.
|
|
324
|
+
*
|
|
325
|
+
* Only rendered on desktop; on mobile, time is shown via {@link MobileTimeDisplay}.
|
|
326
|
+
*/
|
|
327
|
+
const DesktopTimeSection: FC<DesktopTimeSectionProps> = ({
|
|
328
|
+
amPm,
|
|
329
|
+
disabled,
|
|
330
|
+
isMobile,
|
|
331
|
+
onAmPmChange,
|
|
332
|
+
onOpenActionSheet,
|
|
333
|
+
onTimezoneChange,
|
|
334
|
+
segmentProps,
|
|
335
|
+
timezone,
|
|
336
|
+
type,
|
|
337
|
+
}): React.ReactElement => {
|
|
338
|
+
return (
|
|
339
|
+
<View style={{alignItems: "center", flexDirection: "row"}}>
|
|
340
|
+
<TimeField {...segmentProps} type={type} />
|
|
341
|
+
<Box direction="column" marginLeft={2} marginRight={2} width={60}>
|
|
342
|
+
<SelectField
|
|
343
|
+
disabled={disabled}
|
|
344
|
+
onChange={(result) => onAmPmChange(result as "am" | "pm")}
|
|
345
|
+
options={[
|
|
346
|
+
{label: "am", value: "am"},
|
|
347
|
+
{label: "pm", value: "pm"},
|
|
348
|
+
]}
|
|
349
|
+
requireValue
|
|
350
|
+
value={amPm}
|
|
351
|
+
/>
|
|
352
|
+
</Box>
|
|
353
|
+
<Box direction="column" width={70}>
|
|
354
|
+
<TimezonePicker
|
|
355
|
+
disabled={disabled}
|
|
356
|
+
hideTitle
|
|
357
|
+
onChange={onTimezoneChange}
|
|
358
|
+
shortTimezone
|
|
359
|
+
timezone={timezone}
|
|
360
|
+
/>
|
|
361
|
+
</Box>
|
|
362
|
+
{!disabled && type === "datetime" && !isMobile && (
|
|
363
|
+
<Box marginLeft={2}>
|
|
364
|
+
<IconButton
|
|
365
|
+
accessibilityHint="Opens the calendar to select a date and time"
|
|
366
|
+
accessibilityLabel="Show calendar"
|
|
367
|
+
iconName="calendar"
|
|
368
|
+
onClick={onOpenActionSheet}
|
|
369
|
+
variant="navigation"
|
|
370
|
+
/>
|
|
371
|
+
</Box>
|
|
372
|
+
)}
|
|
373
|
+
</View>
|
|
374
|
+
);
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
/** Configuration for a single {@link DateTimeSegment} input. */
|
|
151
378
|
interface FieldConfig {
|
|
379
|
+
/** Maximum character length (e.g. 2 for MM/DD/hh/mm, 4 for YYYY). */
|
|
152
380
|
maxLength: number;
|
|
381
|
+
/** Placeholder text shown when the segment is empty (e.g. "MM", "DD", "YYYY", "hh", "mm"). */
|
|
153
382
|
placeholder: string;
|
|
383
|
+
/** Fixed pixel width of the segment container. */
|
|
154
384
|
width: number;
|
|
155
385
|
}
|
|
156
386
|
|
|
387
|
+
/**
|
|
388
|
+
* A versatile date/time input field that adapts its rendering based on device type and field mode.
|
|
389
|
+
*
|
|
390
|
+
* Supports three modes via the `type` prop:
|
|
391
|
+
* - **"date"**: Date-only input (MM/DD/YYYY). Values stored as UTC midnight ISO strings.
|
|
392
|
+
* - **"time"**: Time-only input (hh:mm AM/PM + timezone). Values stored as UTC ISO strings.
|
|
393
|
+
* - **"datetime"**: Combined date and time input with timezone support.
|
|
394
|
+
*
|
|
395
|
+
* ## Desktop Behavior
|
|
396
|
+
* Renders editable {@link DateTimeSegment} inputs for each part of the date/time, with
|
|
397
|
+
* AM/PM {@link SelectField}, {@link TimezonePicker}, and a calendar/clock {@link IconButton}
|
|
398
|
+
* that opens the {@link DateTimeActionSheet}.
|
|
399
|
+
*
|
|
400
|
+
* ## Mobile Behavior (type="datetime")
|
|
401
|
+
* On mobile when `type="datetime"`, date segments remain visible but non-editable. Time
|
|
402
|
+
* segments are replaced with a {@link MobileTimeDisplay} showing the formatted time
|
|
403
|
+
* (e.g. "02:30 PM CDT"). Tapping anywhere on the field opens the
|
|
404
|
+
* {@link DateTimeActionSheet} with native picker wheels for selection. This follows the
|
|
405
|
+
* Figma design system's mobile pattern.
|
|
406
|
+
*
|
|
407
|
+
* All values are emitted as UTC ISO 8601 strings via the `onChange` callback.
|
|
408
|
+
*/
|
|
157
409
|
export const DateTimeField: FC<DateTimeFieldProps> = ({
|
|
158
410
|
type,
|
|
159
411
|
title,
|
|
@@ -211,19 +463,8 @@ export const DateTimeField: FC<DateTimeFieldProps> = ({
|
|
|
211
463
|
|
|
212
464
|
// Use provided timezone if available, otherwise use local
|
|
213
465
|
const timezone = providedTimezone ?? localTimezone;
|
|
214
|
-
const lastTimezoneRef = useRef(timezone);
|
|
215
|
-
|
|
216
466
|
const inputRefs = useRef<(TextInput | null)[]>([]);
|
|
217
467
|
|
|
218
|
-
let iconName: IconName | undefined;
|
|
219
|
-
if (disabled) {
|
|
220
|
-
iconName = undefined;
|
|
221
|
-
} else if (type === "time") {
|
|
222
|
-
iconName = "clock";
|
|
223
|
-
} else {
|
|
224
|
-
iconName = "calendar";
|
|
225
|
-
}
|
|
226
|
-
|
|
227
468
|
let borderColor = theme.border.dark;
|
|
228
469
|
if (disabled) {
|
|
229
470
|
borderColor = theme.border.activeNeutral;
|
|
@@ -231,6 +472,7 @@ export const DateTimeField: FC<DateTimeFieldProps> = ({
|
|
|
231
472
|
borderColor = theme.border.error;
|
|
232
473
|
}
|
|
233
474
|
|
|
475
|
+
/** Builds the ordered array of {@link FieldConfig} for each input segment based on the field type. */
|
|
234
476
|
const getFieldConfigs = useCallback((): FieldConfig[] => {
|
|
235
477
|
const configs: FieldConfig[] = [];
|
|
236
478
|
if (type === "date" || type === "datetime") {
|
|
@@ -255,6 +497,7 @@ export const DateTimeField: FC<DateTimeFieldProps> = ({
|
|
|
255
497
|
inputRefs.current = configs.map(() => null);
|
|
256
498
|
}, [getFieldConfigs]);
|
|
257
499
|
|
|
500
|
+
/** Validates a single segment value and returns an error message if invalid, or undefined if valid. */
|
|
258
501
|
const validateField = useCallback(
|
|
259
502
|
(fieldIndex: number, fieldValue: string): string | undefined => {
|
|
260
503
|
if (!fieldValue) return undefined;
|
|
@@ -302,6 +545,12 @@ export const DateTimeField: FC<DateTimeFieldProps> = ({
|
|
|
302
545
|
[type]
|
|
303
546
|
);
|
|
304
547
|
|
|
548
|
+
/**
|
|
549
|
+
* Assembles the current segment state (month, day, year, hour, minute, amPm, timezone)
|
|
550
|
+
* into a UTC ISO 8601 string. Accepts optional overrides for fields that haven't
|
|
551
|
+
* been committed to state yet (e.g. during mid-edit or AM/PM toggle).
|
|
552
|
+
* Returns undefined if required fields are missing.
|
|
553
|
+
*/
|
|
305
554
|
const getISOFromFields = useCallback(
|
|
306
555
|
(override?: {
|
|
307
556
|
amPm?: "am" | "pm";
|
|
@@ -393,6 +642,12 @@ export const DateTimeField: FC<DateTimeFieldProps> = ({
|
|
|
393
642
|
[amPm, month, day, year, hour, minute, timezone, type]
|
|
394
643
|
);
|
|
395
644
|
|
|
645
|
+
/**
|
|
646
|
+
* Handles text changes in any {@link DateTimeSegment} input.
|
|
647
|
+
* Strips non-numeric characters, validates the value, updates local state,
|
|
648
|
+
* and emits the ISO value via onChange when all required fields are complete.
|
|
649
|
+
* Auto-advances focus to the next segment when the current one is full.
|
|
650
|
+
*/
|
|
396
651
|
const handleFieldChange = useCallback(
|
|
397
652
|
(index: number, text: string, config: FieldConfig) => {
|
|
398
653
|
const numericValue = text.replace(/[^0-9]/g, "");
|
|
@@ -503,6 +758,11 @@ export const DateTimeField: FC<DateTimeFieldProps> = ({
|
|
|
503
758
|
[type, getFieldConfigs, getISOFromFields, onChange, value, validateField, month, day, year]
|
|
504
759
|
);
|
|
505
760
|
|
|
761
|
+
/**
|
|
762
|
+
* Callback from the {@link DateTimeActionSheet}. Parses the selected ISO value,
|
|
763
|
+
* syncs it into the local segment state (month, day, year, hour, minute, amPm),
|
|
764
|
+
* normalizes it to UTC, and emits via onChange. An empty string clears the field.
|
|
765
|
+
*/
|
|
506
766
|
const onActionSheetChange = useCallback(
|
|
507
767
|
(inputDate: string) => {
|
|
508
768
|
// Handle clear case - empty string should clear the field
|
|
@@ -520,9 +780,12 @@ export const DateTimeField: FC<DateTimeFieldProps> = ({
|
|
|
520
780
|
setAmPm(parsedDate.hour >= 12 ? "pm" : "am");
|
|
521
781
|
|
|
522
782
|
if (type === "date" || type === "datetime") {
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
783
|
+
// type="date" values are UTC midnight — parse in UTC to extract the correct calendar date.
|
|
784
|
+
const displayDate =
|
|
785
|
+
type === "date" ? DateTime.fromISO(inputDate, {zone: "UTC"}) : parsedDate;
|
|
786
|
+
setMonth(displayDate.month.toString().padStart(2, "0"));
|
|
787
|
+
setDay(displayDate.day.toString().padStart(2, "0"));
|
|
788
|
+
setYear(displayDate.year.toString());
|
|
526
789
|
}
|
|
527
790
|
|
|
528
791
|
if (type === "time" || type === "datetime") {
|
|
@@ -535,11 +798,9 @@ export const DateTimeField: FC<DateTimeFieldProps> = ({
|
|
|
535
798
|
// Normalize emitted value to ISO (UTC for date-only)
|
|
536
799
|
const normalized =
|
|
537
800
|
type === "date"
|
|
538
|
-
?
|
|
539
|
-
.setZone("UTC")
|
|
801
|
+
? DateTime.fromISO(inputDate, {zone: "UTC"})
|
|
540
802
|
.startOf("day")
|
|
541
803
|
.set({millisecond: 0, second: 0})
|
|
542
|
-
.toUTC()
|
|
543
804
|
.toISO()
|
|
544
805
|
: parsedDate.set({millisecond: 0, second: 0}).toUTC().toISO();
|
|
545
806
|
if (!normalized) {
|
|
@@ -552,7 +813,10 @@ export const DateTimeField: FC<DateTimeFieldProps> = ({
|
|
|
552
813
|
[onChange, type]
|
|
553
814
|
);
|
|
554
815
|
|
|
555
|
-
|
|
816
|
+
/**
|
|
817
|
+
* Called when a segment input loses focus. Assembles the current field state
|
|
818
|
+
* (plus any pending overrides) into an ISO string and emits it if changed.
|
|
819
|
+
*/
|
|
556
820
|
const onBlur = useCallback(
|
|
557
821
|
(override?: {amPm?: "am" | "pm"}) => {
|
|
558
822
|
const iso = getISOFromFields({...override, ...pendingValueRef.current});
|
|
@@ -580,18 +844,6 @@ export const DateTimeField: FC<DateTimeFieldProps> = ({
|
|
|
580
844
|
return;
|
|
581
845
|
}
|
|
582
846
|
|
|
583
|
-
// // If only timezone changed, don't recalculate fields
|
|
584
|
-
const isOnlyTimezoneChange =
|
|
585
|
-
lastTimezoneRef.current !== timezone &&
|
|
586
|
-
DateTime.fromISO(value).toUTC().toISO() ===
|
|
587
|
-
DateTime.fromISO(value).setZone(timezone).toUTC().toISO();
|
|
588
|
-
|
|
589
|
-
lastTimezoneRef.current = timezone;
|
|
590
|
-
|
|
591
|
-
if (isOnlyTimezoneChange) {
|
|
592
|
-
return;
|
|
593
|
-
}
|
|
594
|
-
|
|
595
847
|
// Handle dates which should have 00:00:00.000Z as the time component, ignore timezones.
|
|
596
848
|
let parsedDate = DateTime.fromISO(value);
|
|
597
849
|
if (type === "date") {
|
|
@@ -619,9 +871,7 @@ export const DateTimeField: FC<DateTimeFieldProps> = ({
|
|
|
619
871
|
}
|
|
620
872
|
}, [value, type, timezone]);
|
|
621
873
|
|
|
622
|
-
|
|
623
|
-
// We update the value of the date according to the zone and then this get triggered
|
|
624
|
-
// and we update the value of the date according to the zone again
|
|
874
|
+
/** Returns the current display string for a given segment index from local state. */
|
|
625
875
|
const getFieldValue = useCallback(
|
|
626
876
|
(index: number): string => {
|
|
627
877
|
if (type === "date" || type === "datetime") {
|
|
@@ -657,108 +907,141 @@ export const DateTimeField: FC<DateTimeFieldProps> = ({
|
|
|
657
907
|
onRef: (el: TextInput | null, i: number) => (inputRefs.current[i] = el),
|
|
658
908
|
};
|
|
659
909
|
|
|
910
|
+
const isMobile = isMobileDevice();
|
|
911
|
+
const isMobileTimeOnly = isMobile && type === "time";
|
|
912
|
+
const isMobileDatetime = isMobile && type === "datetime";
|
|
913
|
+
const showDateSection = type === "date" || type === "datetime";
|
|
914
|
+
const showDesktopTime = !isMobile && (type === "time" || type === "datetime");
|
|
915
|
+
const timezoneAbbr = useMemo((): string => {
|
|
916
|
+
if (value) {
|
|
917
|
+
const parsed = DateTime.fromISO(value);
|
|
918
|
+
if (parsed.isValid) {
|
|
919
|
+
return parsed.setZone(timezone).offsetNameShort ?? "";
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
const iso = getISOFromFields();
|
|
923
|
+
if (iso) {
|
|
924
|
+
const parsed = DateTime.fromISO(iso);
|
|
925
|
+
if (parsed.isValid) {
|
|
926
|
+
return parsed.setZone(timezone).offsetNameShort ?? "";
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
return DateTime.now().setZone(timezone).offsetNameShort ?? "";
|
|
930
|
+
}, [value, getISOFromFields, timezone]);
|
|
931
|
+
const mobileTimeDisplayText =
|
|
932
|
+
hour && minute ? `${hour}:${minute} ${amPm.toUpperCase()} ${timezoneAbbr}` : "";
|
|
933
|
+
const mobileTimePlaceholder = `12:00 PM ${timezoneAbbr}`;
|
|
934
|
+
|
|
935
|
+
/** Handles AM/PM toggle from the SelectField, recomputes and emits the ISO value. */
|
|
936
|
+
const handleAmPmChange = useCallback(
|
|
937
|
+
(newAmPm: "am" | "pm"): void => {
|
|
938
|
+
setAmPm(newAmPm);
|
|
939
|
+
const iso = getISOFromFields({amPm: newAmPm});
|
|
940
|
+
const currentValueUTC = value ? DateTime.fromISO(value).toUTC().toISO() : undefined;
|
|
941
|
+
if (iso && iso !== currentValueUTC) {
|
|
942
|
+
onChange(iso);
|
|
943
|
+
}
|
|
944
|
+
},
|
|
945
|
+
[getISOFromFields, value, onChange]
|
|
946
|
+
);
|
|
947
|
+
|
|
948
|
+
/** Handles timezone changes from the TimezonePicker, recomputes and emits the ISO value. */
|
|
949
|
+
const handleTimezoneChange = useCallback(
|
|
950
|
+
(tz: string): void => {
|
|
951
|
+
if (onTimezoneChange) {
|
|
952
|
+
onTimezoneChange(tz);
|
|
953
|
+
} else {
|
|
954
|
+
setLocalTimezone(tz);
|
|
955
|
+
}
|
|
956
|
+
const iso = getISOFromFields({timezone: tz});
|
|
957
|
+
const currentValueUTC = value ? DateTime.fromISO(value).toUTC().toISO() : undefined;
|
|
958
|
+
if (iso && iso !== currentValueUTC) {
|
|
959
|
+
onChange(iso);
|
|
960
|
+
}
|
|
961
|
+
},
|
|
962
|
+
[getISOFromFields, value, onChange, onTimezoneChange]
|
|
963
|
+
);
|
|
964
|
+
|
|
965
|
+
const openActionSheet = useCallback((): void => {
|
|
966
|
+
setShowDate(true);
|
|
967
|
+
}, []);
|
|
968
|
+
|
|
660
969
|
return (
|
|
661
970
|
<>
|
|
662
|
-
{Boolean(title) && <FieldTitle text={title
|
|
663
|
-
{Boolean(errorText) && <FieldError text={errorText
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
{
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
// Compare in UTC to avoid timezone issues
|
|
708
|
-
const currentValueUTC = value
|
|
709
|
-
? DateTime.fromISO(value).toUTC().toISO()
|
|
710
|
-
: undefined;
|
|
711
|
-
if (iso && iso !== currentValueUTC) {
|
|
712
|
-
onChange(iso);
|
|
713
|
-
}
|
|
714
|
-
}}
|
|
715
|
-
options={[
|
|
716
|
-
{label: "am", value: "am"},
|
|
717
|
-
{label: "pm", value: "pm"},
|
|
718
|
-
]}
|
|
719
|
-
requireValue
|
|
720
|
-
value={amPm}
|
|
721
|
-
/>
|
|
722
|
-
</Box>
|
|
723
|
-
<Box direction="column" width={70}>
|
|
724
|
-
<TimezonePicker
|
|
725
|
-
disabled={disabled}
|
|
726
|
-
hideTitle
|
|
727
|
-
onChange={(t) => {
|
|
728
|
-
if (onTimezoneChange) {
|
|
729
|
-
onTimezoneChange(t);
|
|
730
|
-
} else {
|
|
731
|
-
setLocalTimezone(t);
|
|
732
|
-
}
|
|
733
|
-
const iso = getISOFromFields({timezone: t});
|
|
734
|
-
// Compare in UTC to avoid timezone issues
|
|
735
|
-
const currentValueUTC = value
|
|
736
|
-
? DateTime.fromISO(value).toUTC().toISO()
|
|
737
|
-
: undefined;
|
|
738
|
-
if (iso && iso !== currentValueUTC) {
|
|
739
|
-
onChange(iso);
|
|
740
|
-
}
|
|
741
|
-
}}
|
|
742
|
-
shortTimezone
|
|
743
|
-
timezone={timezone}
|
|
744
|
-
/>
|
|
745
|
-
</Box>
|
|
746
|
-
</>
|
|
971
|
+
{Boolean(title) && <FieldTitle text={title as string} />}
|
|
972
|
+
{Boolean(errorText) && <FieldError text={errorText as string} />}
|
|
973
|
+
|
|
974
|
+
{isMobileTimeOnly && (
|
|
975
|
+
<MobileTimeDisplay
|
|
976
|
+
borderColor={borderColor}
|
|
977
|
+
disabled={disabled}
|
|
978
|
+
displayText={mobileTimeDisplayText}
|
|
979
|
+
onPress={openActionSheet}
|
|
980
|
+
placeholder={mobileTimePlaceholder}
|
|
981
|
+
/>
|
|
982
|
+
)}
|
|
983
|
+
|
|
984
|
+
{!isMobileTimeOnly && (
|
|
985
|
+
<Pressable
|
|
986
|
+
{...(isMobileDatetime && {
|
|
987
|
+
accessibilityHint: "Opens date and time picker",
|
|
988
|
+
accessibilityLabel: "Date and time picker",
|
|
989
|
+
accessibilityRole: "button" as const,
|
|
990
|
+
})}
|
|
991
|
+
disabled={!isMobileDatetime || disabled}
|
|
992
|
+
onLayout={(e) => setParentWidth(e.nativeEvent.layout.width)}
|
|
993
|
+
onPress={openActionSheet}
|
|
994
|
+
style={{
|
|
995
|
+
alignItems: parentIsLessThanBreakpointOrIsMobile ? "stretch" : "center",
|
|
996
|
+
backgroundColor: theme.surface.base,
|
|
997
|
+
borderColor,
|
|
998
|
+
borderRadius: 4,
|
|
999
|
+
borderWidth: 1,
|
|
1000
|
+
flexDirection: parentIsLessThanBreakpointOrIsMobile ? "column" : "row",
|
|
1001
|
+
maxWidth: isMobileDatetime ? 250 : maximumWidth,
|
|
1002
|
+
minWidth: isMobileDatetime ? 200 : minimumWidth,
|
|
1003
|
+
paddingHorizontal: 6,
|
|
1004
|
+
paddingVertical: 2,
|
|
1005
|
+
}}
|
|
1006
|
+
>
|
|
1007
|
+
{showDateSection && (
|
|
1008
|
+
<DateRowWithIcon
|
|
1009
|
+
disabled={disabled}
|
|
1010
|
+
isMobile={parentIsLessThanBreakpointOrIsMobile}
|
|
1011
|
+
isMobileDatetime={isMobileDatetime}
|
|
1012
|
+
onOpenActionSheet={openActionSheet}
|
|
1013
|
+
segmentProps={segmentProps}
|
|
1014
|
+
type={type}
|
|
1015
|
+
/>
|
|
747
1016
|
)}
|
|
748
1017
|
|
|
749
|
-
{
|
|
750
|
-
<
|
|
751
|
-
<
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
1018
|
+
{isMobileDatetime && (
|
|
1019
|
+
<View pointerEvents="none">
|
|
1020
|
+
<MobileTimeDisplay
|
|
1021
|
+
disabled={disabled}
|
|
1022
|
+
displayText={mobileTimeDisplayText}
|
|
1023
|
+
onPress={openActionSheet}
|
|
1024
|
+
placeholder={mobileTimePlaceholder}
|
|
1025
|
+
showBorder={false}
|
|
757
1026
|
/>
|
|
758
|
-
</
|
|
1027
|
+
</View>
|
|
759
1028
|
)}
|
|
760
|
-
|
|
761
|
-
|
|
1029
|
+
|
|
1030
|
+
{showDesktopTime && (
|
|
1031
|
+
<DesktopTimeSection
|
|
1032
|
+
amPm={amPm}
|
|
1033
|
+
disabled={disabled}
|
|
1034
|
+
isMobile={parentIsLessThanBreakpointOrIsMobile}
|
|
1035
|
+
onAmPmChange={handleAmPmChange}
|
|
1036
|
+
onOpenActionSheet={openActionSheet}
|
|
1037
|
+
onTimezoneChange={handleTimezoneChange}
|
|
1038
|
+
segmentProps={segmentProps}
|
|
1039
|
+
timezone={timezone}
|
|
1040
|
+
type={type}
|
|
1041
|
+
/>
|
|
1042
|
+
)}
|
|
1043
|
+
</Pressable>
|
|
1044
|
+
)}
|
|
762
1045
|
|
|
763
1046
|
{!disabled && (
|
|
764
1047
|
<DateTimeActionSheet
|
|
@@ -771,7 +1054,7 @@ export const DateTimeField: FC<DateTimeFieldProps> = ({
|
|
|
771
1054
|
visible={showDate}
|
|
772
1055
|
/>
|
|
773
1056
|
)}
|
|
774
|
-
{Boolean(helperText) && <FieldHelperText text={helperText
|
|
1057
|
+
{Boolean(helperText) && <FieldHelperText text={helperText as string} />}
|
|
775
1058
|
</>
|
|
776
1059
|
);
|
|
777
1060
|
};
|