rn-grid-date-time-picker 1.2.1 → 1.3.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 (96) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/lib/commonjs/components/DateTimePicker.js +48 -20
  3. package/lib/commonjs/components/DateTimePicker.js.map +1 -1
  4. package/lib/commonjs/components/container/PickerContainer.js +2 -1
  5. package/lib/commonjs/components/container/PickerContainer.js.map +1 -1
  6. package/lib/commonjs/components/container/PickerLayout.js +5 -1
  7. package/lib/commonjs/components/container/PickerLayout.js.map +1 -1
  8. package/lib/commonjs/components/field/KeyboardAccessoryBar.js +38 -0
  9. package/lib/commonjs/components/field/KeyboardAccessoryBar.js.map +1 -0
  10. package/lib/commonjs/components/field/SegmentedInput.js +11 -6
  11. package/lib/commonjs/components/field/SegmentedInput.js.map +1 -1
  12. package/lib/commonjs/components/hooks/useScrollToTime.js +85 -0
  13. package/lib/commonjs/components/hooks/useScrollToTime.js.map +1 -0
  14. package/lib/commonjs/components/theme/defaultStrings.js.map +1 -1
  15. package/lib/commonjs/components/time/TypedTime.js +34 -10
  16. package/lib/commonjs/components/time/TypedTime.js.map +1 -1
  17. package/lib/commonjs/components/types.js +2 -0
  18. package/lib/commonjs/components/types.js.map +1 -1
  19. package/lib/commonjs/core/draft.js +2 -1
  20. package/lib/commonjs/core/draft.js.map +1 -1
  21. package/lib/module/components/DateTimePicker.js +48 -20
  22. package/lib/module/components/DateTimePicker.js.map +1 -1
  23. package/lib/module/components/container/PickerContainer.js +2 -1
  24. package/lib/module/components/container/PickerContainer.js.map +1 -1
  25. package/lib/module/components/container/PickerLayout.js +5 -1
  26. package/lib/module/components/container/PickerLayout.js.map +1 -1
  27. package/lib/module/components/field/KeyboardAccessoryBar.js +33 -0
  28. package/lib/module/components/field/KeyboardAccessoryBar.js.map +1 -0
  29. package/lib/module/components/field/SegmentedInput.js +11 -7
  30. package/lib/module/components/field/SegmentedInput.js.map +1 -1
  31. package/lib/module/components/hooks/useScrollToTime.js +79 -0
  32. package/lib/module/components/hooks/useScrollToTime.js.map +1 -0
  33. package/lib/module/components/theme/defaultStrings.js.map +1 -1
  34. package/lib/module/components/time/TypedTime.js +33 -11
  35. package/lib/module/components/time/TypedTime.js.map +1 -1
  36. package/lib/module/components/types.js +2 -0
  37. package/lib/module/components/types.js.map +1 -1
  38. package/lib/module/core/draft.js +2 -1
  39. package/lib/module/core/draft.js.map +1 -1
  40. package/lib/typescript/commonjs/src/components/DateTimePicker.d.ts.map +1 -1
  41. package/lib/typescript/commonjs/src/components/container/PickerContainer.d.ts.map +1 -1
  42. package/lib/typescript/commonjs/src/components/container/PickerLayout.d.ts +7 -2
  43. package/lib/typescript/commonjs/src/components/container/PickerLayout.d.ts.map +1 -1
  44. package/lib/typescript/commonjs/src/components/field/KeyboardAccessoryBar.d.ts +15 -0
  45. package/lib/typescript/commonjs/src/components/field/KeyboardAccessoryBar.d.ts.map +1 -0
  46. package/lib/typescript/commonjs/src/components/field/SegmentedInput.d.ts +2 -2
  47. package/lib/typescript/commonjs/src/components/field/SegmentedInput.d.ts.map +1 -1
  48. package/lib/typescript/commonjs/src/components/hooks/useScrollToTime.d.ts +26 -0
  49. package/lib/typescript/commonjs/src/components/hooks/useScrollToTime.d.ts.map +1 -0
  50. package/lib/typescript/commonjs/src/components/nativeTypes.d.ts +2 -1
  51. package/lib/typescript/commonjs/src/components/nativeTypes.d.ts.map +1 -1
  52. package/lib/typescript/commonjs/src/components/theme/defaultStrings.d.ts +1 -0
  53. package/lib/typescript/commonjs/src/components/theme/defaultStrings.d.ts.map +1 -1
  54. package/lib/typescript/commonjs/src/components/time/TypedTime.d.ts +1 -1
  55. package/lib/typescript/commonjs/src/components/time/TypedTime.d.ts.map +1 -1
  56. package/lib/typescript/commonjs/src/components/types.d.ts +41 -3
  57. package/lib/typescript/commonjs/src/components/types.d.ts.map +1 -1
  58. package/lib/typescript/commonjs/src/core/draft.d.ts +2 -1
  59. package/lib/typescript/commonjs/src/core/draft.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/src/index.d.ts +1 -1
  61. package/lib/typescript/commonjs/src/index.d.ts.map +1 -1
  62. package/lib/typescript/module/src/components/DateTimePicker.d.ts.map +1 -1
  63. package/lib/typescript/module/src/components/container/PickerContainer.d.ts.map +1 -1
  64. package/lib/typescript/module/src/components/container/PickerLayout.d.ts +7 -2
  65. package/lib/typescript/module/src/components/container/PickerLayout.d.ts.map +1 -1
  66. package/lib/typescript/module/src/components/field/KeyboardAccessoryBar.d.ts +15 -0
  67. package/lib/typescript/module/src/components/field/KeyboardAccessoryBar.d.ts.map +1 -0
  68. package/lib/typescript/module/src/components/field/SegmentedInput.d.ts +2 -2
  69. package/lib/typescript/module/src/components/field/SegmentedInput.d.ts.map +1 -1
  70. package/lib/typescript/module/src/components/hooks/useScrollToTime.d.ts +26 -0
  71. package/lib/typescript/module/src/components/hooks/useScrollToTime.d.ts.map +1 -0
  72. package/lib/typescript/module/src/components/nativeTypes.d.ts +2 -1
  73. package/lib/typescript/module/src/components/nativeTypes.d.ts.map +1 -1
  74. package/lib/typescript/module/src/components/theme/defaultStrings.d.ts +1 -0
  75. package/lib/typescript/module/src/components/theme/defaultStrings.d.ts.map +1 -1
  76. package/lib/typescript/module/src/components/time/TypedTime.d.ts +1 -1
  77. package/lib/typescript/module/src/components/time/TypedTime.d.ts.map +1 -1
  78. package/lib/typescript/module/src/components/types.d.ts +41 -3
  79. package/lib/typescript/module/src/components/types.d.ts.map +1 -1
  80. package/lib/typescript/module/src/core/draft.d.ts +2 -1
  81. package/lib/typescript/module/src/core/draft.d.ts.map +1 -1
  82. package/lib/typescript/module/src/index.d.ts +1 -1
  83. package/lib/typescript/module/src/index.d.ts.map +1 -1
  84. package/package.json +1 -1
  85. package/src/components/DateTimePicker.tsx +67 -20
  86. package/src/components/container/PickerContainer.tsx +1 -0
  87. package/src/components/container/PickerLayout.tsx +10 -1
  88. package/src/components/field/KeyboardAccessoryBar.tsx +38 -0
  89. package/src/components/field/SegmentedInput.tsx +12 -7
  90. package/src/components/hooks/useScrollToTime.ts +99 -0
  91. package/src/components/nativeTypes.ts +2 -1
  92. package/src/components/theme/defaultStrings.ts +1 -0
  93. package/src/components/time/TypedTime.tsx +31 -10
  94. package/src/components/types.ts +45 -3
  95. package/src/core/draft.ts +5 -2
  96. package/src/index.tsx +1 -0
@@ -70,6 +70,7 @@ import RangeFields, { type RangeEnd } from './field/RangeFields';
70
70
  import ValueField from './field/ValueField';
71
71
  import { useKeyboardFold } from './hooks/useKeyboardFold';
72
72
  import { useOpenPicker } from './hooks/useOpenPicker';
73
+ import { useScrollToTime } from './hooks/useScrollToTime';
73
74
  import { usePickerDraft } from './hooks/usePickerDraft';
74
75
  import { useTodayTick } from './hooks/useTodayTick';
75
76
  import TimeRows, { type PendingTimes } from './time/TimeRows';
@@ -138,6 +139,7 @@ const DateTimePicker: React.FC<DateTimePickerProps> = (props) => {
138
139
  multiple,
139
140
  range,
140
141
  presentation = 'sheet',
142
+ scrollToTimeRef,
141
143
  visible,
142
144
  onOpen,
143
145
  onClose,
@@ -188,6 +190,7 @@ const DateTimePicker: React.FC<DateTimePickerProps> = (props) => {
188
190
  calendarSwitch,
189
191
  onCalendarSystemChange,
190
192
  dualDefaultTab = 'gregory',
193
+ fieldCalendar = 'picked',
191
194
  hijriLocale = 'ar-SA',
192
195
  renderCalendarSwitch,
193
196
  showToday = false,
@@ -432,12 +435,13 @@ const DateTimePicker: React.FC<DateTimePickerProps> = (props) => {
432
435
 
433
436
  const rangeLayout = range?.layout ?? 'singleField';
434
437
  const multipleTimeMode = multiple?.timeMode ?? 'shared';
438
+ const startsEmpty = newDayTime === 'empty';
435
439
  const draftOptions = useMemo((): DraftOptions => {
436
440
  return {
437
441
  type,
438
442
  zone,
439
443
  adapter,
440
- defaultTime: DEFAULT_TIME,
444
+ defaultTime: startsEmpty ? null : DEFAULT_TIME,
441
445
  multiple: {
442
446
  timeMode: multiple?.timeMode,
443
447
  maxSelections: multiple?.maxSelections,
@@ -453,6 +457,7 @@ const DateTimePicker: React.FC<DateTimePickerProps> = (props) => {
453
457
  type,
454
458
  zone,
455
459
  adapter,
460
+ startsEmpty,
456
461
  multiple?.timeMode,
457
462
  multiple?.maxSelections,
458
463
  range?.allowSameDay,
@@ -672,6 +677,28 @@ const DateTimePicker: React.FC<DateTimePickerProps> = (props) => {
672
677
  const previewIssue = validateDraft(preview, draftOptions);
673
678
 
674
679
  const d = draft.draft;
680
+ // Once a calendar tap picks the date (both ends of a range), the sheet (or
681
+ // the app's ScrollView around an inline picker) scrolls the time section
682
+ // into view.
683
+ const datePicked =
684
+ d.mode === 'single'
685
+ ? !!d.slot.value
686
+ : d.mode === 'range'
687
+ ? !!d.from.value && !!d.to.value
688
+ : d.slots.length > 0;
689
+ const {
690
+ scrollRef,
691
+ timeRef,
692
+ request: revealTime,
693
+ } = useScrollToTime(
694
+ type !== 'datetime'
695
+ ? false
696
+ : isInline
697
+ ? !!scrollToTimeRef
698
+ : (sheetOptions?.scrollToTime ?? true),
699
+ datePicked,
700
+ isInline ? scrollToTimeRef : undefined
701
+ );
675
702
  /** The values a draft would save, with a shared time applied. */
676
703
  const valuesOf = (dr: Draft): (DateTimeValue | null)[] => {
677
704
  if (dr.mode === 'single') return [dr.slot.value];
@@ -928,7 +955,7 @@ const DateTimePicker: React.FC<DateTimePickerProps> = (props) => {
928
955
  }, [closePicker]);
929
956
 
930
957
  // A typed date with no time yet gets one the way a tapped day does: the fixed
931
- // newDayTime, else a time already typed in the time row, else now.
958
+ // newDayTime, else a time already typed in the time row, else now (or none).
932
959
  const withFilledTime = (
933
960
  parsed: ParsedSegments,
934
961
  key: 'single' | RangeEnd
@@ -937,7 +964,7 @@ const DateTimePicker: React.FC<DateTimePickerProps> = (props) => {
937
964
  (parsed.dateOnly
938
965
  ? {
939
966
  date: parsed.dateOnly,
940
- time: fixedTime ?? pending[key] ?? nowTime(),
967
+ time: fixedTime ?? pending[key] ?? newTime(),
941
968
  }
942
969
  : null);
943
970
 
@@ -967,7 +994,7 @@ const DateTimePicker: React.FC<DateTimePickerProps> = (props) => {
967
994
  };
968
995
 
969
996
  // A fixed time for every newly picked day (wins over a typed time and over "now").
970
- const fixedTime = newDayTime ?? null;
997
+ const fixedTime = typeof newDayTime === 'object' ? newDayTime : null;
971
998
 
972
999
  // A day picked on an empty date-time starts at the current time, moved up to
973
1000
  // the next allowed minute step. An existing time is kept.
@@ -978,6 +1005,11 @@ const DateTimePicker: React.FC<DateTimePickerProps> = (props) => {
978
1005
  const capped = Math.min(minutes, 23 * 60 + 60 - step);
979
1006
  return { hour: Math.floor(capped / 60), minute: capped % 60 };
980
1007
  }, [now, zone, minuteStep]);
1008
+ /** A new day's time when nothing fixed or typed: now, or none with newDayTime 'empty'. */
1009
+ const newTime = useCallback(
1010
+ (): Time | null => (startsEmpty ? null : nowTime()),
1011
+ [startsEmpty, nowTime]
1012
+ );
981
1013
 
982
1014
  // A range end picked on the start's day starts an hour after the start (at
983
1015
  // most the day's latest allowed time, or its last step), so the range isn't
@@ -987,10 +1019,10 @@ const DateTimePicker: React.FC<DateTimePickerProps> = (props) => {
987
1019
  from: DateTimeValue | null,
988
1020
  day: CalendarDate,
989
1021
  typed: Time | null | undefined
990
- ): Time => {
1022
+ ): Time | null => {
991
1023
  if (!fixedTime && typed) return typed;
992
- const time = fixedTime ?? nowTime();
993
- if (!from?.time || !isSameDay(from.date, day)) return time;
1024
+ const time = fixedTime ?? newTime();
1025
+ if (!time || !from?.time || !isSameDay(from.date, day)) return time;
994
1026
  const start = from.time.hour * 60 + from.time.minute;
995
1027
  if (time.hour * 60 + time.minute > start) return time;
996
1028
  // Within the day's allowed hours when it has them.
@@ -1018,6 +1050,8 @@ const DateTimePicker: React.FC<DateTimePickerProps> = (props) => {
1018
1050
  draft.clear();
1019
1051
  resetSelectionUi();
1020
1052
  }
1053
+ // A multiple picker scrolls for its first date only, so more can be picked.
1054
+ if (cur.mode !== 'multiple' || cur.slots.length === 0) revealTime();
1021
1055
  if (cur.mode === 'range') {
1022
1056
  if (rangeLayout === 'fromTo') {
1023
1057
  setEndPicked(false);
@@ -1032,7 +1066,7 @@ const DateTimePicker: React.FC<DateTimePickerProps> = (props) => {
1032
1066
  const end = moveNearest ? nearest : 'from';
1033
1067
  setTypingBlock('from', false);
1034
1068
  setTypingBlock('to', false);
1035
- draft.selectDay(day, fixedTime ?? times[end] ?? nowTime());
1069
+ draft.selectDay(day, fixedTime ?? times[end] ?? newTime());
1036
1070
  setActiveEnd(moveNearest ? nearest : 'to');
1037
1071
  return;
1038
1072
  }
@@ -1051,7 +1085,7 @@ const DateTimePicker: React.FC<DateTimePickerProps> = (props) => {
1051
1085
  end1,
1052
1086
  end1 === 'to'
1053
1087
  ? endTimeFor(cur.from.value, day, times.to)
1054
- : (fixedTime ?? times.from ?? nowTime())
1088
+ : (fixedTime ?? times.from ?? newTime())
1055
1089
  );
1056
1090
  // The start tapped again where a range can't be one day deselects
1057
1091
  // it, so the next tap picks the start again.
@@ -1073,7 +1107,7 @@ const DateTimePicker: React.FC<DateTimePickerProps> = (props) => {
1073
1107
  day,
1074
1108
  end === 'to' && !cur.to.value
1075
1109
  ? endTimeFor(cur.from.value, day, times.to)
1076
- : (fixedTime ?? times[end] ?? nowTime())
1110
+ : (fixedTime ?? times[end] ?? newTime())
1077
1111
  );
1078
1112
  return;
1079
1113
  }
@@ -1087,18 +1121,18 @@ const DateTimePicker: React.FC<DateTimePickerProps> = (props) => {
1087
1121
  setActiveDay(day);
1088
1122
  return;
1089
1123
  }
1090
- draft.selectDay(day, fixedTime ?? nowTime());
1124
+ draft.selectDay(day, fixedTime ?? newTime());
1091
1125
  if (perDate) setActiveDay(picked ? null : day);
1092
1126
  return;
1093
1127
  }
1094
1128
  setTypingBlock('single', false);
1095
- draft.selectDay(day, fixedTime ?? times.single ?? nowTime());
1129
+ draft.selectDay(day, fixedTime ?? times.single ?? newTime());
1096
1130
  },
1097
1131
  // eslint-disable-next-line react-hooks/exhaustive-deps -- valuesOf reads only `d` and props listed here
1098
1132
  [
1099
1133
  d,
1100
1134
  draft,
1101
- nowTime,
1135
+ newTime,
1102
1136
  rangeLayout,
1103
1137
  activeEnd,
1104
1138
  activeDay,
@@ -1115,6 +1149,7 @@ const DateTimePicker: React.FC<DateTimePickerProps> = (props) => {
1115
1149
  draftOptions,
1116
1150
  minuteStep,
1117
1151
  constraints,
1152
+ revealTime,
1118
1153
  ]
1119
1154
  );
1120
1155
 
@@ -1271,9 +1306,11 @@ const DateTimePicker: React.FC<DateTimePickerProps> = (props) => {
1271
1306
  ? (v) =>
1272
1307
  hijriDateText(v.date, hijriLocale) ?? formatDateText(v, dateFormat)
1273
1308
  : undefined;
1274
- const savedDateText = dateTextFor(savedSystem);
1309
+ // The calendar the on-screen field writes the saved value in.
1310
+ const fieldSystem = fieldCalendar === 'gregory' ? 'gregory' : savedSystem;
1311
+ const savedDateText = dateTextFor(fieldSystem);
1275
1312
  const draftDateText = dateTextFor(draftSystem);
1276
- const savedStrings = stringsFor(savedSystem);
1313
+ const savedStrings = stringsFor(fieldSystem);
1277
1314
  const draftStrings = stringsFor(draftSystem);
1278
1315
  // Error messages inside the picker follow the tab on screen; the on-screen
1279
1316
  // field's keep `strings`.
@@ -1353,7 +1390,7 @@ const DateTimePicker: React.FC<DateTimePickerProps> = (props) => {
1353
1390
  type,
1354
1391
  mode,
1355
1392
  value,
1356
- displayText: digitsFor(savedSystem)(
1393
+ displayText: digitsFor(fieldSystem)(
1357
1394
  savedText ??
1358
1395
  formatValueText(
1359
1396
  savedSingle,
@@ -1698,9 +1735,18 @@ const DateTimePicker: React.FC<DateTimePickerProps> = (props) => {
1698
1735
  <NumeralsContext.Provider value={digitsFor(shownSystem)}>
1699
1736
  {calendar}
1700
1737
  </NumeralsContext.Provider>
1701
- <NumeralsContext.Provider value={digitsFor(shownSystem)}>
1702
- {mirrored(timeRow)}
1703
- </NumeralsContext.Provider>
1738
+ {timeRow == null ? null : (
1739
+ // Measured for the scroll to the time (sheet.scrollToTime).
1740
+ <View
1741
+ ref={timeRef}
1742
+ collapsable={false}
1743
+ testID={testID ? `${testID}-time-section` : undefined}
1744
+ >
1745
+ <NumeralsContext.Provider value={digitsFor(shownSystem)}>
1746
+ {mirrored(timeRow)}
1747
+ </NumeralsContext.Provider>
1748
+ </View>
1749
+ )}
1704
1750
  {validationMessage ? (
1705
1751
  <FieldMessages
1706
1752
  error={validationMessage}
@@ -1716,7 +1762,7 @@ const DateTimePicker: React.FC<DateTimePickerProps> = (props) => {
1716
1762
  );
1717
1763
 
1718
1764
  return (
1719
- <NumeralsContext.Provider value={digitsFor(savedSystem)}>
1765
+ <NumeralsContext.Provider value={digitsFor(fieldSystem)}>
1720
1766
  {trigger}
1721
1767
  <PickerContainer
1722
1768
  presentation={presentation}
@@ -1724,6 +1770,7 @@ const DateTimePicker: React.FC<DateTimePickerProps> = (props) => {
1724
1770
  onRequestClose={close}
1725
1771
  renderSheet={renderSheet}
1726
1772
  sheet={sheetOptions}
1773
+ scrollRef={scrollRef}
1727
1774
  state={state}
1728
1775
  header={header}
1729
1776
  title={title ?? (isInline ? undefined : strings.titleDate)}
@@ -36,6 +36,7 @@ const PickerContainer: React.FC<PickerContainerProps> = ({
36
36
  fixedHeader: sheet?.fixedHeader,
37
37
  fixedFooter: sheet?.fixedFooter,
38
38
  fill: !renderSheet && sheet?.height !== undefined,
39
+ scrollProps: sheet?.scrollProps,
39
40
  });
40
41
 
41
42
  if (presentation === 'inline') {
@@ -1,6 +1,7 @@
1
1
  import React from 'react';
2
2
  import { Pressable, ScrollView, Text, View } from 'react-native';
3
- import type { PickerState, Slot } from '../types';
3
+ import type { ScrollViewRef } from '../nativeTypes';
4
+ import type { PickerState, SheetScrollProps, Slot } from '../types';
4
5
  import type { PickerStyles } from '../theme/styleKeys';
5
6
  import { buildContainerStyles } from './containerStyles';
6
7
 
@@ -27,6 +28,10 @@ export interface PickerLayoutProps {
27
28
  fixedFooter?: boolean;
28
29
  /** The sheet has a fixed height: the body fills it. */
29
30
  fill?: boolean;
31
+ /** Sheet only: passed to the body's ScrollView over its defaults. */
32
+ scrollProps?: SheetScrollProps;
33
+ /** Sheet only: the body's ScrollView. */
34
+ scrollRef?: React.Ref<ScrollViewRef>;
30
35
  }
31
36
 
32
37
  const renderSlot = (
@@ -58,6 +63,8 @@ export const buildPickerLayout = ({
58
63
  fixedHeader = true,
59
64
  fixedFooter = true,
60
65
  fill,
66
+ scrollProps,
67
+ scrollRef,
61
68
  }: PickerLayoutProps): { body: React.ReactNode; footer: React.ReactNode } => {
62
69
  const s = buildContainerStyles(state.tokens);
63
70
 
@@ -129,12 +136,14 @@ export const buildPickerLayout = ({
129
136
  {headerScrolls ? null : headerNode}
130
137
  {scrollBody ? (
131
138
  <ScrollView
139
+ ref={scrollRef}
132
140
  testID={testID ? `${testID}-scroll` : undefined}
133
141
  style={s.scroll}
134
142
  keyboardShouldPersistTaps="handled"
135
143
  nestedScrollEnabled
136
144
  bounces={false}
137
145
  showsVerticalScrollIndicator={false}
146
+ {...scrollProps}
138
147
  >
139
148
  {headerScrolls ? headerNode : null}
140
149
  {content}
@@ -0,0 +1,38 @@
1
+ import React from 'react';
2
+ import { InputAccessoryView, Platform, StyleSheet, View } from 'react-native';
3
+ import type { ThemeTokens } from '../theme/tokens';
4
+
5
+ interface Props {
6
+ /** The `inputAccessoryViewID` of the one box it serves. */
7
+ nativeID: string;
8
+ tokens: ThemeTokens;
9
+ }
10
+
11
+ /**
12
+ * iOS only: an empty solid bar above a box's number pad. Without it React
13
+ * Native adds its own Done bar, which is see-through on newer iOS, so a sheet
14
+ * resting on the keyboard showed the screen behind it.
15
+ */
16
+ const KeyboardAccessoryBar: React.FC<Props> = ({ nativeID, tokens }) => {
17
+ if (Platform.OS !== 'ios') return null;
18
+ return (
19
+ <InputAccessoryView nativeID={nativeID}>
20
+ <View
21
+ style={[
22
+ styles.bar,
23
+ {
24
+ height: tokens.touchTarget,
25
+ backgroundColor: tokens.surface,
26
+ borderTopColor: tokens.border,
27
+ },
28
+ ]}
29
+ />
30
+ </InputAccessoryView>
31
+ );
32
+ };
33
+
34
+ const styles = StyleSheet.create({
35
+ bar: { borderTopWidth: StyleSheet.hairlineWidth },
36
+ });
37
+
38
+ export default KeyboardAccessoryBar;
@@ -1,4 +1,4 @@
1
- import React, { useEffect, useMemo, useRef } from 'react';
1
+ import React, { useEffect, useId, useMemo, useRef } from 'react';
2
2
  import {
3
3
  Pressable,
4
4
  StyleSheet,
@@ -20,6 +20,7 @@ import { MAX_FONT_SCALE } from '../theme/a11y';
20
20
  import { useNumerals } from '../theme/numerals';
21
21
  import type { ThemeTokens } from '../theme/tokens';
22
22
  import { buildFieldStyles } from './fieldStyles';
23
+ import KeyboardAccessoryBar from './KeyboardAccessoryBar';
23
24
 
24
25
  interface Props {
25
26
  state: SegmentEditorState;
@@ -93,8 +94,8 @@ export const changeToActions = (
93
94
 
94
95
  /**
95
96
  * The typed field: one box per segment in locale order, plus an AM/PM switch for
96
- * 12-hour date-times. Number pad, with a "Done" bar on iOS (its number pad has no
97
- * return key).
97
+ * 12-hour date-times. Number pad, with an empty solid bar above it on iOS (so
98
+ * React Native adds no see-through Done bar).
98
99
  */
99
100
  const SegmentedInput: React.FC<Props> = ({
100
101
  state,
@@ -108,6 +109,7 @@ const SegmentedInput: React.FC<Props> = ({
108
109
  testID,
109
110
  }) => {
110
111
  const digits = useNumerals();
112
+ const barId = useId();
111
113
  // Each box's text as last rendered, and as it last reported. Fast input (a
112
114
  // hardware keyboard, autofill) sends the next character before React has
113
115
  // re-rendered, and the native box may ignore React's reset while it's
@@ -197,10 +199,9 @@ const SegmentedInput: React.FC<Props> = ({
197
199
  keyboardType="number-pad"
198
200
  editable={!disabled}
199
201
  contextMenuHidden
200
- // iOS adds a Done button above the number pad for every box; it
201
- // closes the keyboard (and blurs the box).
202
- returnKeyType="done"
203
- inputAccessoryViewButtonLabel={strings.done}
202
+ // iOS: this box's own solid bar (below), so React Native adds no
203
+ // see-through Done bar.
204
+ inputAccessoryViewID={`${barId}-${spec.key}`}
204
205
  onFocus={() => dispatch({ type: 'focus', key: spec.key })}
205
206
  onBlur={() => {
206
207
  if ((getState?.() ?? state).focused === spec.key)
@@ -228,6 +229,10 @@ const SegmentedInput: React.FC<Props> = ({
228
229
  isFocused && [s.segmentFocused, styles?.fieldSegmentFocused],
229
230
  ]}
230
231
  />
232
+ <KeyboardAccessoryBar
233
+ nativeID={`${barId}-${spec.key}`}
234
+ tokens={tokens}
235
+ />
231
236
  </React.Fragment>
232
237
  );
233
238
  };
@@ -0,0 +1,99 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useRef,
5
+ useState,
6
+ type RefObject,
7
+ } from 'react';
8
+ import type { ScrollViewRef, ViewRef } from '../nativeTypes';
9
+ import { useReduceMotion } from './useReduceMotion';
10
+
11
+ export interface ScrollToTime {
12
+ /** The sheet body's ScrollView. */
13
+ scrollRef: RefObject<ScrollViewRef | null>;
14
+ /** The time section. */
15
+ timeRef: RefObject<ViewRef | null>;
16
+ /** Called from the calendar tap that picks the date. */
17
+ request: () => void;
18
+ }
19
+
20
+ /** Rounding in the measured positions, in points. */
21
+ const SLACK = 1;
22
+
23
+ /**
24
+ * Scrolls `scroll` so `section` shows: just far enough to show all of it (its
25
+ * top, when it's taller than the view), or to the end of the content with
26
+ * `toEnd`. Nothing moves while it already shows in full.
27
+ */
28
+ export const scrollSectionIntoView = (
29
+ scroll: ScrollViewRef,
30
+ section: ViewRef,
31
+ toEnd: boolean,
32
+ animated: boolean
33
+ ): void => {
34
+ scroll.measureInWindow((_vx, viewTop, _vw, viewHeight) => {
35
+ section.measureInWindow((_x, top, _w, height) => {
36
+ // Not laid out, or folded away.
37
+ if (!viewHeight || !height) return;
38
+ const fromTop = top - viewTop;
39
+ if (fromTop >= -SLACK && fromTop + height <= viewHeight + SLACK) return;
40
+ if (toEnd) {
41
+ scroll.scrollToEnd({ animated });
42
+ return;
43
+ }
44
+ // Where the section sits in the scrolled content.
45
+ const content = scroll.getInnerViewRef?.();
46
+ if (!content) return;
47
+ section.measureLayout(
48
+ content,
49
+ (_cx, y) => {
50
+ const target =
51
+ fromTop < 0 || height > viewHeight ? y : y + height - viewHeight;
52
+ scroll.scrollTo({ y: Math.max(0, target), animated });
53
+ },
54
+ () => {}
55
+ );
56
+ });
57
+ });
58
+ };
59
+
60
+ /**
61
+ * The sheet's scroll to the time section once a calendar tap picks the date.
62
+ * `request()` from the tap; the scroll runs after that render, when `ready`
63
+ * (the picked date is complete) holds for it. Refs go on the body's ScrollView
64
+ * and the time section; `outerRef` (an inline picker's own ScrollView, from
65
+ * the app) is scrolled instead when given. 'end' scrolls to the end of the
66
+ * body; false never scrolls.
67
+ */
68
+ export const useScrollToTime = (
69
+ mode: boolean | 'end',
70
+ ready: boolean,
71
+ outerRef?: RefObject<ScrollViewRef | null>
72
+ ): ScrollToTime => {
73
+ const scrollRef = useRef<ScrollViewRef>(null);
74
+ const timeRef = useRef<ViewRef>(null);
75
+ const reduceMotion = useReduceMotion();
76
+ const [tapped, setTapped] = useState(0);
77
+ const on = useRef(mode !== false);
78
+ on.current = mode !== false;
79
+ const request = useCallback(() => {
80
+ if (on.current) setTapped((n) => n + 1);
81
+ }, []);
82
+ useEffect(() => {
83
+ if (!tapped || !ready || mode === false) return;
84
+ // A frame after the tap, on iOS and Android alike: the old architecture
85
+ // lays the tap's changes out after this effect, so measuring now could
86
+ // read the layout before them; and Android runs a scroll sent in the frame
87
+ // that grows the content (a per-date row showing) before it grows, so the
88
+ // scroll would stop short.
89
+ const frame = requestAnimationFrame(() => {
90
+ const scroll = outerRef?.current ?? scrollRef.current;
91
+ const section = timeRef.current;
92
+ if (scroll && section)
93
+ scrollSectionIntoView(scroll, section, mode === 'end', !reduceMotion);
94
+ });
95
+ return () => cancelAnimationFrame(frame);
96
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- once per tap
97
+ }, [tapped]);
98
+ return { scrollRef, timeRef, request };
99
+ };
@@ -1,5 +1,5 @@
1
1
  import type { ComponentRef } from 'react';
2
- import type { ScrollView, TextInput, TextInputProps } from 'react-native';
2
+ import type { ScrollView, TextInput, TextInputProps, View } from 'react-native';
3
3
 
4
4
  /**
5
5
  * React Native types spelled so every supported version has them: 0.88 added
@@ -8,6 +8,7 @@ import type { ScrollView, TextInput, TextInputProps } from 'react-native';
8
8
  */
9
9
  export type TextInputRef = ComponentRef<typeof TextInput>;
10
10
  export type ScrollViewRef = ComponentRef<typeof ScrollView>;
11
+ export type ViewRef = ComponentRef<typeof View>;
11
12
  export type KeyPressEvent = Parameters<
12
13
  NonNullable<TextInputProps['onKeyPress']>
13
14
  >[0];
@@ -8,6 +8,7 @@ export interface PickerStrings {
8
8
  reset: string;
9
9
  clear: string;
10
10
  today: string;
11
+ /** No longer shown (the iOS number-pad bar is empty); kept so existing `strings` objects still type-check. */
11
12
  done: string;
12
13
  time: string;
13
14
  startTime: string;
@@ -1,5 +1,6 @@
1
- import React from 'react';
2
- import { Pressable, Text, TextInput, View } from 'react-native';
1
+ import React, { useId, useRef } from 'react';
2
+ import { Platform, Pressable, Text, TextInput, View } from 'react-native';
3
+ import KeyboardAccessoryBar from '../field/KeyboardAccessoryBar';
3
4
  import type { TextInputRef } from '../nativeTypes';
4
5
  import type { TimeBounds } from '../../core/constraints';
5
6
  import type { Period } from '../../core/segments';
@@ -39,7 +40,7 @@ const PERIODS: Period[] = ['AM', 'PM'];
39
40
 
40
41
  /**
41
42
  * `[hh] : [mm] [AM | PM]`, or `[HH] : [mm]` in 24-hour. The boxes are plain
42
- * TextInputs; the row (TimeRow) owns their values. Number pad, with a Done bar
43
+ * TextInputs; the row (TimeRow) owns their values. Number pad, with a solid bar
43
44
  * on iOS. The AM/PM side that can't hold an allowed time is disabled.
44
45
  */
45
46
  const TypedTime: React.FC<Props> = ({
@@ -61,10 +62,27 @@ const TypedTime: React.FC<Props> = ({
61
62
  testID,
62
63
  }) => {
63
64
  const digits = useNumerals();
65
+ const barId = useId();
64
66
  const s = buildTimeStyles(tokens);
67
+ const inputs = useRef<Partial<Record<TimeKey, TextInputRef | null>>>({});
68
+
69
+ // Android: a TextInput keeps any drag that starts on it, so the sheet can't
70
+ // scroll from a box. The boxes ignore touches and their wrapper takes them:
71
+ // a drag scrolls the sheet, a tap focuses the box (and brings the keyboard
72
+ // back). None of this changes with focus: flipping a box's touch handling
73
+ // as it gained focus made Android pass focus on to the next box, round and
74
+ // round. The wrapper is never focusable itself.
75
+ const passTouches = Platform.OS === 'android' && !disabled;
65
76
 
66
77
  const box = (key: TimeKey) => (
67
- <View style={s.boxWrap}>
78
+ <Pressable
79
+ style={s.boxWrap}
80
+ accessible={false}
81
+ focusable={false}
82
+ disabled={!passTouches}
83
+ onPress={() => inputs.current[key]?.focus()}
84
+ testID={testID ? `${testID}-${key}-wrap` : undefined}
85
+ >
68
86
  <View
69
87
  style={[
70
88
  s.boxContainer,
@@ -73,9 +91,12 @@ const TypedTime: React.FC<Props> = ({
73
91
  hasError && [s.boxError, styles?.timeBoxError],
74
92
  ]}
75
93
  >
76
- <View style={s.boxInner}>
94
+ <View style={s.boxInner} pointerEvents={passTouches ? 'none' : 'auto'}>
77
95
  <TextInput
78
- ref={(r) => setRef(key, r)}
96
+ ref={(r) => {
97
+ inputs.current[key] = r;
98
+ setRef(key, r);
99
+ }}
79
100
  testID={testID ? `${testID}-${key}` : undefined}
80
101
  accessibilityLabel={
81
102
  key === 'hour' ? strings.a11yHour : strings.a11yMinute
@@ -99,9 +120,8 @@ const TypedTime: React.FC<Props> = ({
99
120
  contextMenuHidden
100
121
  editable={!disabled}
101
122
  underlineColorAndroid="transparent"
102
- // iOS adds a Done button above the number pad for each box.
103
- returnKeyType="done"
104
- inputAccessoryViewButtonLabel={strings.done}
123
+ // iOS: this box's own solid bar (below).
124
+ inputAccessoryViewID={`${barId}-${key}`}
105
125
  onChangeText={(text) => onChangeBox(key, text)}
106
126
  onFocus={() => onFocusBox(key)}
107
127
  onBlur={() => onBlurBox(key)}
@@ -110,9 +130,10 @@ const TypedTime: React.FC<Props> = ({
110
130
  }}
111
131
  style={[s.boxText, styles?.timeBoxText, s.boxInput]}
112
132
  />
133
+ <KeyboardAccessoryBar nativeID={`${barId}-${key}`} tokens={tokens} />
113
134
  </View>
114
135
  </View>
115
- </View>
136
+ </Pressable>
116
137
  );
117
138
 
118
139
  return (