@terreno/ui 0.7.2 → 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.
Files changed (82) hide show
  1. package/dist/BooleanField.js +23 -23
  2. package/dist/BooleanField.js.map +1 -1
  3. package/dist/ConsentFormScreen.d.ts +14 -0
  4. package/dist/ConsentFormScreen.js +93 -0
  5. package/dist/ConsentFormScreen.js.map +1 -0
  6. package/dist/ConsentHistory.d.ts +8 -0
  7. package/dist/ConsentHistory.js +70 -0
  8. package/dist/ConsentHistory.js.map +1 -0
  9. package/dist/ConsentNavigator.d.ts +9 -0
  10. package/dist/ConsentNavigator.js +72 -0
  11. package/dist/ConsentNavigator.js.map +1 -0
  12. package/dist/DataTable.js +1 -1
  13. package/dist/DataTable.js.map +1 -1
  14. package/dist/DateTimeActionSheet.js +22 -6
  15. package/dist/DateTimeActionSheet.js.map +1 -1
  16. package/dist/DateTimeField.d.ts +22 -0
  17. package/dist/DateTimeField.js +187 -67
  18. package/dist/DateTimeField.js.map +1 -1
  19. package/dist/DraggableList.d.ts +66 -0
  20. package/dist/DraggableList.js +241 -0
  21. package/dist/DraggableList.js.map +1 -0
  22. package/dist/Link.js +1 -1
  23. package/dist/Link.js.map +1 -1
  24. package/dist/MarkdownEditor.d.ts +12 -0
  25. package/dist/MarkdownEditor.js +12 -0
  26. package/dist/MarkdownEditor.js.map +1 -0
  27. package/dist/MarkdownEditorField.d.ts +1 -0
  28. package/dist/MarkdownEditorField.js +16 -16
  29. package/dist/MarkdownEditorField.js.map +1 -1
  30. package/dist/Modal.js +11 -1
  31. package/dist/Modal.js.map +1 -1
  32. package/dist/PickerSelect.js +10 -0
  33. package/dist/PickerSelect.js.map +1 -1
  34. package/dist/TerrenoProvider.js +10 -1
  35. package/dist/TerrenoProvider.js.map +1 -1
  36. package/dist/UpgradeRequiredScreen.d.ts +8 -0
  37. package/dist/UpgradeRequiredScreen.js +10 -0
  38. package/dist/UpgradeRequiredScreen.js.map +1 -0
  39. package/dist/generateConsentHistoryPdf.d.ts +2 -0
  40. package/dist/generateConsentHistoryPdf.js +185 -0
  41. package/dist/generateConsentHistoryPdf.js.map +1 -0
  42. package/dist/index.d.ts +9 -0
  43. package/dist/index.js +9 -0
  44. package/dist/index.js.map +1 -1
  45. package/dist/useConsentForms.d.ts +29 -0
  46. package/dist/useConsentForms.js +50 -0
  47. package/dist/useConsentForms.js.map +1 -0
  48. package/dist/useConsentHistory.d.ts +31 -0
  49. package/dist/useConsentHistory.js +17 -0
  50. package/dist/useConsentHistory.js.map +1 -0
  51. package/dist/useSubmitConsent.d.ts +12 -0
  52. package/dist/useSubmitConsent.js +23 -0
  53. package/dist/useSubmitConsent.js.map +1 -0
  54. package/package.json +4 -2
  55. package/src/BooleanField.test.tsx +3 -5
  56. package/src/BooleanField.tsx +33 -31
  57. package/src/ConsentFormScreen.tsx +216 -0
  58. package/src/ConsentHistory.tsx +249 -0
  59. package/src/ConsentNavigator.test.tsx +111 -0
  60. package/src/ConsentNavigator.tsx +128 -0
  61. package/src/DataTable.tsx +1 -1
  62. package/src/DateTimeActionSheet.tsx +19 -6
  63. package/src/DateTimeField.tsx +416 -133
  64. package/src/DraggableList.tsx +424 -0
  65. package/src/Link.tsx +1 -1
  66. package/src/MarkdownEditor.tsx +66 -0
  67. package/src/MarkdownEditorField.tsx +32 -28
  68. package/src/Modal.tsx +19 -1
  69. package/src/PickerSelect.tsx +11 -0
  70. package/src/TerrenoProvider.tsx +10 -1
  71. package/src/TimezonePicker.test.tsx +9 -1
  72. package/src/UpgradeRequiredScreen.tsx +52 -0
  73. package/src/__snapshots__/BooleanField.test.tsx.snap +167 -203
  74. package/src/__snapshots__/DataTable.test.tsx.snap +0 -114
  75. package/src/__snapshots__/Field.test.tsx.snap +53 -69
  76. package/src/__snapshots__/Link.test.tsx.snap +14 -21
  77. package/src/__snapshots__/TimezonePicker.test.tsx.snap +0 -4710
  78. package/src/generateConsentHistoryPdf.ts +211 -0
  79. package/src/index.tsx +9 -1
  80. package/src/useConsentForms.ts +70 -0
  81. package/src/useConsentHistory.ts +40 -0
  82. package/src/useSubmitConsent.ts +35 -0
@@ -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, IconName} from "./Common";
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
- setMonth(parsedDate.month.toString().padStart(2, "0"));
524
- setDay(parsedDate.day.toString().padStart(2, "0"));
525
- setYear(parsedDate.year.toString());
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
- ? parsedDate
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
- // When fields change, send the value to onChange
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
- // JOSH: This is where the infinite loop is happening
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
- <View
665
- onLayout={(e) => setParentWidth(e.nativeEvent.layout.width)}
666
- style={{
667
- alignItems: "center",
668
- backgroundColor: theme.surface.base,
669
- borderColor,
670
- borderRadius: 4,
671
- borderWidth: 1,
672
- flexDirection: parentIsLessThanBreakpointOrIsMobile ? "column" : "row",
673
- maxWidth: maximumWidth,
674
- minWidth: minimumWidth,
675
- paddingHorizontal: 6,
676
- paddingVertical: 2,
677
- }}
678
- >
679
- {(type === "date" || type === "datetime") && (
680
- <View style={{alignItems: "center", flexDirection: "row"}}>
681
- <DateField {...segmentProps} type={type} />
682
- {!disabled &&
683
- (type === "date" ||
684
- (type === "datetime" && parentIsLessThanBreakpointOrIsMobile)) && (
685
- <IconButton
686
- accessibilityHint="Opens the calendar to select a date and time"
687
- accessibilityLabel="Show calendar"
688
- iconName={iconName!}
689
- onClick={() => setShowDate(true)}
690
- variant="muted"
691
- />
692
- )}
693
- </View>
694
- )}
695
-
696
- <View style={{alignItems: "center", flexDirection: "row"}}>
697
- {(type === "time" || type === "datetime") && <TimeField {...segmentProps} type={type} />}
698
- {Boolean(type === "datetime" || type === "time") && (
699
- <>
700
- <Box direction="column" marginLeft={2} marginRight={2} width={60}>
701
- <SelectField
702
- disabled={disabled}
703
- onChange={(result) => {
704
- setAmPm(result as "am" | "pm");
705
- // No onblur, so we need to manually update the value
706
- const iso = getISOFromFields({amPm: result as "am" | "pm"});
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
- {!disabled && type === "datetime" && !parentIsLessThanBreakpointOrIsMobile && (
750
- <Box marginLeft={2}>
751
- <IconButton
752
- accessibilityHint="Opens the calendar to select a date and time"
753
- accessibilityLabel="Show calendar"
754
- iconName={iconName!}
755
- onClick={() => setShowDate(true)}
756
- variant="muted"
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
- </Box>
1027
+ </View>
759
1028
  )}
760
- </View>
761
- </View>
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
  };