@ankhorage/zora 13.0.0 → 14.0.1

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 (229) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +182 -6
  3. package/dist/features/accordion/adapters/inbound/Accordion.js +4 -4
  4. package/dist/features/accordion/adapters/inbound/Accordion.js.map +1 -1
  5. package/dist/features/accordion/adapters/inbound/AccordionItem.js +1 -1
  6. package/dist/features/accordion/adapters/inbound/AccordionItem.js.map +1 -1
  7. package/dist/features/app-bar/adapters/inbound/AppBar.d.ts +5 -0
  8. package/dist/features/app-bar/adapters/inbound/AppBar.d.ts.map +1 -0
  9. package/dist/features/app-bar/adapters/inbound/AppBar.js +91 -0
  10. package/dist/features/app-bar/adapters/inbound/AppBar.js.map +1 -0
  11. package/dist/{components/app-bar/meta.d.ts → features/app-bar/appBarMeta.d.ts} +1 -1
  12. package/dist/features/app-bar/appBarMeta.d.ts.map +1 -0
  13. package/dist/{components/app-bar/meta.js → features/app-bar/appBarMeta.js} +1 -1
  14. package/dist/features/app-bar/appBarMeta.js.map +1 -0
  15. package/dist/features/app-bar/public.d.ts +3 -0
  16. package/dist/features/app-bar/public.d.ts.map +1 -0
  17. package/dist/features/app-bar/public.js +2 -0
  18. package/dist/features/app-bar/public.js.map +1 -0
  19. package/dist/features/date-picker/adapters/inbound/DatePicker.d.ts +1 -6
  20. package/dist/features/date-picker/adapters/inbound/DatePicker.d.ts.map +1 -1
  21. package/dist/features/date-picker/adapters/inbound/DatePicker.js +1 -175
  22. package/dist/features/date-picker/adapters/inbound/DatePicker.js.map +1 -1
  23. package/dist/features/date-picker/adapters/inbound/DatePicker.native.d.ts +5 -0
  24. package/dist/features/date-picker/adapters/inbound/DatePicker.native.d.ts.map +1 -0
  25. package/dist/features/date-picker/adapters/inbound/DatePicker.native.js +27 -0
  26. package/dist/features/date-picker/adapters/inbound/DatePicker.native.js.map +1 -0
  27. package/dist/features/date-picker/adapters/inbound/DatePicker.web.d.ts +5 -0
  28. package/dist/features/date-picker/adapters/inbound/DatePicker.web.d.ts.map +1 -0
  29. package/dist/features/date-picker/adapters/inbound/DatePicker.web.js +25 -0
  30. package/dist/features/date-picker/adapters/inbound/DatePicker.web.js.map +1 -0
  31. package/dist/features/date-picker/composition/DatePickerContent.d.ts +10 -0
  32. package/dist/features/date-picker/composition/DatePickerContent.d.ts.map +1 -0
  33. package/dist/features/date-picker/composition/DatePickerContent.js +129 -0
  34. package/dist/features/date-picker/composition/DatePickerContent.js.map +1 -0
  35. package/dist/features/date-picker/composition/DatePickerField.d.ts +9 -0
  36. package/dist/features/date-picker/composition/DatePickerField.d.ts.map +1 -0
  37. package/dist/features/date-picker/composition/DatePickerField.js +21 -0
  38. package/dist/features/date-picker/composition/DatePickerField.js.map +1 -0
  39. package/dist/features/date-picker/composition/DatePickerTrigger.d.ts +9 -0
  40. package/dist/features/date-picker/composition/DatePickerTrigger.d.ts.map +1 -0
  41. package/dist/features/date-picker/composition/DatePickerTrigger.js +18 -0
  42. package/dist/features/date-picker/composition/DatePickerTrigger.js.map +1 -0
  43. package/dist/features/time-picker/adapters/inbound/TimePicker.d.ts +1 -6
  44. package/dist/features/time-picker/adapters/inbound/TimePicker.d.ts.map +1 -1
  45. package/dist/features/time-picker/adapters/inbound/TimePicker.js +1 -116
  46. package/dist/features/time-picker/adapters/inbound/TimePicker.js.map +1 -1
  47. package/dist/features/time-picker/adapters/inbound/TimePicker.native.d.ts +5 -0
  48. package/dist/features/time-picker/adapters/inbound/TimePicker.native.d.ts.map +1 -0
  49. package/dist/features/time-picker/adapters/inbound/TimePicker.native.js +26 -0
  50. package/dist/features/time-picker/adapters/inbound/TimePicker.native.js.map +1 -0
  51. package/dist/features/time-picker/adapters/inbound/TimePicker.web.d.ts +5 -0
  52. package/dist/features/time-picker/adapters/inbound/TimePicker.web.d.ts.map +1 -0
  53. package/dist/features/time-picker/adapters/inbound/TimePicker.web.js +24 -0
  54. package/dist/features/time-picker/adapters/inbound/TimePicker.web.js.map +1 -0
  55. package/dist/features/time-picker/composition/TimePickerContent.d.ts +10 -0
  56. package/dist/features/time-picker/composition/TimePickerContent.d.ts.map +1 -0
  57. package/dist/features/time-picker/composition/TimePickerContent.js +78 -0
  58. package/dist/features/time-picker/composition/TimePickerContent.js.map +1 -0
  59. package/dist/features/time-picker/composition/TimePickerField.d.ts +9 -0
  60. package/dist/features/time-picker/composition/TimePickerField.d.ts.map +1 -0
  61. package/dist/features/time-picker/composition/TimePickerField.js +21 -0
  62. package/dist/features/time-picker/composition/TimePickerField.js.map +1 -0
  63. package/dist/features/time-picker/composition/TimePickerTrigger.d.ts +8 -0
  64. package/dist/features/time-picker/composition/TimePickerTrigger.d.ts.map +1 -0
  65. package/dist/features/time-picker/composition/TimePickerTrigger.js +9 -0
  66. package/dist/features/time-picker/composition/TimePickerTrigger.js.map +1 -0
  67. package/dist/features/toast/adapters/inbound/Toast.d.ts +5 -0
  68. package/dist/features/toast/adapters/inbound/Toast.d.ts.map +1 -0
  69. package/dist/features/toast/adapters/inbound/Toast.js +10 -0
  70. package/dist/features/toast/adapters/inbound/Toast.js.map +1 -0
  71. package/dist/features/toast/adapters/inbound/ToastProvider.d.ts +5 -0
  72. package/dist/features/toast/adapters/inbound/ToastProvider.d.ts.map +1 -0
  73. package/dist/features/toast/adapters/inbound/ToastProvider.js +7 -0
  74. package/dist/features/toast/adapters/inbound/ToastProvider.js.map +1 -0
  75. package/dist/features/toast/public.d.ts +5 -0
  76. package/dist/features/toast/public.d.ts.map +1 -0
  77. package/dist/features/toast/public.js +4 -0
  78. package/dist/features/toast/public.js.map +1 -0
  79. package/dist/features/toast/toastMeta.d.ts +9 -0
  80. package/dist/features/toast/toastMeta.d.ts.map +1 -0
  81. package/dist/features/toast/toastMeta.js +9 -0
  82. package/dist/features/toast/toastMeta.js.map +1 -0
  83. package/dist/features/toast/toastProviderMeta.d.ts +9 -0
  84. package/dist/features/toast/toastProviderMeta.d.ts.map +1 -0
  85. package/dist/features/toast/toastProviderMeta.js +9 -0
  86. package/dist/features/toast/toastProviderMeta.js.map +1 -0
  87. package/dist/index.d.ts +4 -4
  88. package/dist/index.d.ts.map +1 -1
  89. package/dist/index.js +2 -2
  90. package/dist/index.js.map +1 -1
  91. package/dist/layout/settings-layout/SettingsLayout.d.ts +0 -2
  92. package/dist/layout/settings-layout/SettingsLayout.d.ts.map +1 -1
  93. package/dist/layout/settings-layout/SettingsLayout.js +1 -3
  94. package/dist/layout/settings-layout/SettingsLayout.js.map +1 -1
  95. package/dist/metadata/componentMeta.d.ts.map +1 -1
  96. package/dist/metadata/componentMeta.js +3 -2
  97. package/dist/metadata/componentMeta.js.map +1 -1
  98. package/dist/patterns/reader/ReaderSurface.js +1 -1
  99. package/dist/patterns/reader/ReaderSurface.js.map +1 -1
  100. package/dist/patterns/selection/SelectionProvider.d.ts +0 -4
  101. package/dist/patterns/selection/SelectionProvider.d.ts.map +1 -1
  102. package/dist/patterns/selection/SelectionProvider.js +4 -6
  103. package/dist/patterns/selection/SelectionProvider.js.map +1 -1
  104. package/dist/registry.js +2 -2
  105. package/dist/registry.js.map +1 -1
  106. package/dist/theme/ZoraProvider.d.ts +4 -10
  107. package/dist/theme/ZoraProvider.d.ts.map +1 -1
  108. package/dist/theme/ZoraProvider.js +8 -6
  109. package/dist/theme/ZoraProvider.js.map +1 -1
  110. package/dist/theme/ZoraRuntimeCapabilities.d.ts +2 -0
  111. package/dist/theme/ZoraRuntimeCapabilities.d.ts.map +1 -0
  112. package/dist/theme/ZoraRuntimeCapabilities.js +2 -0
  113. package/dist/theme/ZoraRuntimeCapabilities.js.map +1 -0
  114. package/dist/theme/ZoraRuntimeCapabilities.native.d.ts +4 -0
  115. package/dist/theme/ZoraRuntimeCapabilities.native.d.ts.map +1 -0
  116. package/dist/theme/ZoraRuntimeCapabilities.native.js +8 -0
  117. package/dist/theme/ZoraRuntimeCapabilities.native.js.map +1 -0
  118. package/dist/theme/ZoraRuntimeCapabilities.web.d.ts +4 -0
  119. package/dist/theme/ZoraRuntimeCapabilities.web.d.ts.map +1 -0
  120. package/dist/theme/ZoraRuntimeCapabilities.web.js +8 -0
  121. package/dist/theme/ZoraRuntimeCapabilities.web.js.map +1 -0
  122. package/dist/theme/ZoraThemeRuntimeContext.d.ts +1 -0
  123. package/dist/theme/ZoraThemeRuntimeContext.d.ts.map +1 -1
  124. package/dist/theme/ZoraThemeRuntimeContext.js +3 -2
  125. package/dist/theme/ZoraThemeRuntimeContext.js.map +1 -1
  126. package/dist/theme/ZoraThemeScope.d.ts +1 -0
  127. package/dist/theme/ZoraThemeScope.d.ts.map +1 -1
  128. package/dist/theme/ZoraThemeScope.js +12 -10
  129. package/dist/theme/ZoraThemeScope.js.map +1 -1
  130. package/dist/theme/index.d.ts +1 -1
  131. package/dist/theme/index.d.ts.map +1 -1
  132. package/dist/theme/index.js.map +1 -1
  133. package/dist/{components/app-bar/types.d.ts → types/app-bar.d.ts} +2 -2
  134. package/dist/types/app-bar.d.ts.map +1 -0
  135. package/dist/types/app-bar.js +2 -0
  136. package/dist/types/app-bar.js.map +1 -0
  137. package/dist/types/provider.d.ts +19 -0
  138. package/dist/types/provider.d.ts.map +1 -0
  139. package/dist/types/provider.js +2 -0
  140. package/dist/types/provider.js.map +1 -0
  141. package/dist/types/toast.d.ts +9 -0
  142. package/dist/types/toast.d.ts.map +1 -0
  143. package/dist/types/toast.js +2 -0
  144. package/dist/types/toast.js.map +1 -0
  145. package/package.json +2 -2
  146. package/src/features/accordion/adapters/inbound/Accordion.tsx +4 -4
  147. package/src/features/accordion/adapters/inbound/AccordionItem.tsx +1 -1
  148. package/src/features/app-bar/adapters/inbound/AppBar.tsx +176 -0
  149. package/src/features/app-bar/public.ts +2 -0
  150. package/src/features/bottom-sheet/public.test.ts +15 -12
  151. package/src/features/date-picker/adapters/inbound/DatePicker.native.tsx +46 -0
  152. package/src/features/date-picker/adapters/inbound/DatePicker.tsx +1 -356
  153. package/src/features/date-picker/adapters/inbound/DatePicker.web.tsx +48 -0
  154. package/src/features/date-picker/composition/DatePickerContent.tsx +256 -0
  155. package/src/features/date-picker/composition/DatePickerField.tsx +53 -0
  156. package/src/features/date-picker/composition/DatePickerTrigger.tsx +47 -0
  157. package/src/features/date-picker/public.test.ts +14 -4
  158. package/src/features/time-picker/adapters/inbound/TimePicker.native.tsx +47 -0
  159. package/src/features/time-picker/adapters/inbound/TimePicker.tsx +1 -258
  160. package/src/features/time-picker/adapters/inbound/TimePicker.web.tsx +49 -0
  161. package/src/features/time-picker/composition/TimePickerContent.tsx +164 -0
  162. package/src/features/time-picker/composition/TimePickerField.tsx +53 -0
  163. package/src/features/time-picker/composition/TimePickerTrigger.tsx +35 -0
  164. package/src/features/time-picker/public.test.ts +14 -3
  165. package/src/features/toast/adapters/inbound/Toast.tsx +13 -0
  166. package/src/features/toast/adapters/inbound/ToastProvider.tsx +9 -0
  167. package/src/features/toast/public.test.ts +19 -0
  168. package/src/features/toast/public.ts +10 -0
  169. package/src/features/toast/toastMeta.ts +10 -0
  170. package/src/features/toast/toastProviderMeta.ts +10 -0
  171. package/src/index.ts +10 -4
  172. package/src/interactionPolicy.test.ts +7 -3
  173. package/src/layout/settings-layout/SettingsLayout.tsx +1 -3
  174. package/src/metadata/componentMeta.ts +3 -2
  175. package/src/patterns/reader/ReaderSurface.tsx +1 -1
  176. package/src/patterns/selection/SelectionProvider.tsx +4 -6
  177. package/src/registry.ts +2 -2
  178. package/src/theme/ZoraProvider.tsx +11 -13
  179. package/src/theme/ZoraRuntimeCapabilities.native.tsx +19 -0
  180. package/src/theme/ZoraRuntimeCapabilities.tsx +1 -0
  181. package/src/theme/ZoraRuntimeCapabilities.web.tsx +10 -0
  182. package/src/theme/ZoraThemeRuntimeContext.tsx +3 -2
  183. package/src/theme/ZoraThemeScope.tsx +14 -12
  184. package/src/theme/index.ts +1 -1
  185. package/src/theme/themeConfigScope.test.ts +4 -1
  186. package/src/theme/themeScopeStructure.test.ts +26 -18
  187. package/src/{components/app-bar/types.ts → types/app-bar.ts} +1 -1
  188. package/src/types/provider.ts +22 -0
  189. package/src/{components/toast/types.ts → types/toast.ts} +5 -7
  190. package/dist/components/app-bar/AppBar.d.ts +0 -7
  191. package/dist/components/app-bar/AppBar.d.ts.map +0 -1
  192. package/dist/components/app-bar/AppBar.js +0 -67
  193. package/dist/components/app-bar/AppBar.js.map +0 -1
  194. package/dist/components/app-bar/index.d.ts +0 -3
  195. package/dist/components/app-bar/index.d.ts.map +0 -1
  196. package/dist/components/app-bar/index.js +0 -3
  197. package/dist/components/app-bar/index.js.map +0 -1
  198. package/dist/components/app-bar/meta.d.ts.map +0 -1
  199. package/dist/components/app-bar/meta.js.map +0 -1
  200. package/dist/components/app-bar/types.d.ts.map +0 -1
  201. package/dist/components/app-bar/types.js +0 -2
  202. package/dist/components/app-bar/types.js.map +0 -1
  203. package/dist/components/toast/Toast.d.ts +0 -9
  204. package/dist/components/toast/Toast.d.ts.map +0 -1
  205. package/dist/components/toast/Toast.js +0 -13
  206. package/dist/components/toast/Toast.js.map +0 -1
  207. package/dist/components/toast/ToastProvider.d.ts +0 -9
  208. package/dist/components/toast/ToastProvider.d.ts.map +0 -1
  209. package/dist/components/toast/ToastProvider.js +0 -14
  210. package/dist/components/toast/ToastProvider.js.map +0 -1
  211. package/dist/components/toast/index.d.ts +0 -5
  212. package/dist/components/toast/index.d.ts.map +0 -1
  213. package/dist/components/toast/index.js +0 -4
  214. package/dist/components/toast/index.js.map +0 -1
  215. package/dist/components/toast/meta.d.ts +0 -17
  216. package/dist/components/toast/meta.d.ts.map +0 -1
  217. package/dist/components/toast/meta.js +0 -17
  218. package/dist/components/toast/meta.js.map +0 -1
  219. package/dist/components/toast/types.d.ts +0 -12
  220. package/dist/components/toast/types.d.ts.map +0 -1
  221. package/dist/components/toast/types.js +0 -2
  222. package/dist/components/toast/types.js.map +0 -1
  223. package/src/components/app-bar/AppBar.tsx +0 -140
  224. package/src/components/app-bar/index.ts +0 -2
  225. package/src/components/toast/Toast.tsx +0 -16
  226. package/src/components/toast/ToastProvider.tsx +0 -17
  227. package/src/components/toast/index.ts +0 -4
  228. package/src/components/toast/meta.ts +0 -19
  229. /package/src/{components/app-bar/meta.ts → features/app-bar/appBarMeta.ts} +0 -0
@@ -0,0 +1,256 @@
1
+ import React from 'react';
2
+
3
+ import type { DatePickerProps } from '../../../types/date-picker';
4
+ import { Button } from '../../button/public';
5
+ import { Box, Stack } from '../../layout/public';
6
+ import { Text } from '../../typography/public';
7
+ import { formatLocalDate, parseLocalDate } from '../utils/localDate';
8
+
9
+ const WEEKDAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const;
10
+ const DAY_CELL_WIDTH = 42;
11
+
12
+ /*** Renders the shared calendar content used by native BottomSheet and web Popover hosts. */
13
+ export function DatePickerContent({
14
+ description,
15
+ interactionPolicy,
16
+ label,
17
+ maxDate,
18
+ minDate,
19
+ onDismiss,
20
+ onSelect,
21
+ testID,
22
+ value,
23
+ }: DatePickerContentProps) {
24
+ const [displayMonth, setDisplayMonth] = React.useState(() => resolveInitialMonth(value, minDate));
25
+ const monthDays = React.useMemo(() => createMonthDays(displayMonth), [displayMonth]);
26
+ const resolvedMaxDate = React.useMemo(() => parseLocalDate(maxDate), [maxDate]);
27
+ const resolvedMinDate = React.useMemo(() => parseLocalDate(minDate), [minDate]);
28
+
29
+ return (
30
+ <Stack gap="m" p="m" testID={testID ? `${testID}-content` : undefined}>
31
+ <PickerHeader description={description} title={label ?? 'Choose date'} />
32
+ <DatePickerMonthNavigation
33
+ displayMonth={displayMonth}
34
+ interactionPolicy={interactionPolicy}
35
+ maxDate={resolvedMaxDate}
36
+ minDate={resolvedMinDate}
37
+ onMonthChange={setDisplayMonth}
38
+ />
39
+ <DatePickerWeekdays />
40
+ <DatePickerCalendar
41
+ interactionPolicy={interactionPolicy}
42
+ maxDate={resolvedMaxDate}
43
+ minDate={resolvedMinDate}
44
+ monthDays={monthDays}
45
+ onSelect={onSelect}
46
+ testID={testID}
47
+ value={value}
48
+ />
49
+ <Button fullWidth interactionPolicy={interactionPolicy} onPress={onDismiss} variant="ghost">
50
+ Cancel
51
+ </Button>
52
+ </Stack>
53
+ );
54
+ }
55
+
56
+ interface DatePickerContentProps extends Pick<
57
+ DatePickerProps,
58
+ 'description' | 'interactionPolicy' | 'label' | 'maxDate' | 'minDate' | 'testID' | 'value'
59
+ > {
60
+ onDismiss: () => void;
61
+ onSelect: (value: Date) => void;
62
+ }
63
+
64
+ /*** Renders the shared title block used by picker overlays. */
65
+ function PickerHeader({
66
+ description,
67
+ title,
68
+ }: {
69
+ description: React.ReactNode | undefined;
70
+ title: React.ReactNode;
71
+ }) {
72
+ return (
73
+ <Stack gap="xxs">
74
+ <Text align="center" variant="label" weight="semiBold">
75
+ {title}
76
+ </Text>
77
+ {description ? (
78
+ <Text align="center" emphasis="muted" variant="bodySmall">
79
+ {description}
80
+ </Text>
81
+ ) : null}
82
+ </Stack>
83
+ );
84
+ }
85
+
86
+ /*** Renders previous and next month controls for the calendar. */
87
+ function DatePickerMonthNavigation({
88
+ displayMonth,
89
+ interactionPolicy,
90
+ maxDate,
91
+ minDate,
92
+ onMonthChange,
93
+ }: {
94
+ displayMonth: Date;
95
+ interactionPolicy: DatePickerProps['interactionPolicy'];
96
+ maxDate: Date | undefined;
97
+ minDate: Date | undefined;
98
+ onMonthChange: (month: Date) => void;
99
+ }) {
100
+ const previousMonth = new Date(displayMonth.getFullYear(), displayMonth.getMonth() - 1, 1);
101
+ const nextMonth = new Date(displayMonth.getFullYear(), displayMonth.getMonth() + 1, 1);
102
+
103
+ return (
104
+ <Stack align="center" direction="row" justify="space-between">
105
+ <Button
106
+ disabled={!canNavigateToMonth(previousMonth, minDate, maxDate)}
107
+ interactionPolicy={interactionPolicy}
108
+ onPress={() => onMonthChange(previousMonth)}
109
+ size="s"
110
+ variant="ghost"
111
+ >
112
+ Previous
113
+ </Button>
114
+ <Text align="center" variant="label" weight="semiBold">
115
+ {formatMonthLabel(displayMonth)}
116
+ </Text>
117
+ <Button
118
+ disabled={!canNavigateToMonth(nextMonth, minDate, maxDate)}
119
+ interactionPolicy={interactionPolicy}
120
+ onPress={() => onMonthChange(nextMonth)}
121
+ size="s"
122
+ variant="ghost"
123
+ >
124
+ Next
125
+ </Button>
126
+ </Stack>
127
+ );
128
+ }
129
+
130
+ /*** Renders weekday headings for the calendar grid. */
131
+ function DatePickerWeekdays() {
132
+ return (
133
+ <Stack direction="row" justify="space-between">
134
+ {WEEKDAY_LABELS.map((weekday) => (
135
+ <Box key={weekday} width={DAY_CELL_WIDTH}>
136
+ <Text align="center" emphasis="muted" variant="caption" weight="semiBold">
137
+ {weekday}
138
+ </Text>
139
+ </Box>
140
+ ))}
141
+ </Stack>
142
+ );
143
+ }
144
+
145
+ /*** Renders selectable day cells for the current calendar month. */
146
+ function DatePickerCalendar({
147
+ interactionPolicy,
148
+ maxDate,
149
+ minDate,
150
+ monthDays,
151
+ onSelect,
152
+ testID,
153
+ value,
154
+ }: {
155
+ interactionPolicy: DatePickerProps['interactionPolicy'];
156
+ maxDate: Date | undefined;
157
+ minDate: Date | undefined;
158
+ monthDays: readonly (Date | null)[];
159
+ onSelect: (value: Date) => void;
160
+ testID: string | undefined;
161
+ value: DatePickerProps['value'];
162
+ }) {
163
+ return (
164
+ <Stack direction="row" gap="xs" wrap="wrap">
165
+ {monthDays.map((day, index) => {
166
+ if (!day) return <Box key={`empty-${index}`} width={DAY_CELL_WIDTH} />;
167
+ const selected = isSameLocalDay(value, day);
168
+ return (
169
+ <Box key={day.toISOString()} width={DAY_CELL_WIDTH}>
170
+ <Button
171
+ color={selected ? 'primary' : 'neutral'}
172
+ disabled={isDateDisabled(day, minDate, maxDate)}
173
+ interactionPolicy={interactionPolicy}
174
+ onPress={() => onSelect(day)}
175
+ size="s"
176
+ testID={testID ? `${testID}-day-${day.getDate()}` : undefined}
177
+ variant={selected ? 'solid' : 'ghost'}
178
+ >
179
+ {day.getDate()}
180
+ </Button>
181
+ </Box>
182
+ );
183
+ })}
184
+ </Stack>
185
+ );
186
+ }
187
+
188
+ /*** Compares a serialized date to one local calendar day. */
189
+ function isSameLocalDay(left: string | null, right: Date): boolean {
190
+ return left === formatLocalDate(right);
191
+ }
192
+
193
+ /*** Normalizes a Date to the start of its local calendar day. */
194
+ function startOfLocalDay(value: Date): Date {
195
+ return new Date(value.getFullYear(), value.getMonth(), value.getDate());
196
+ }
197
+
198
+ /*** Reports whether one local calendar day precedes another. */
199
+ function isBeforeLocalDay(left: Date, right: Date): boolean {
200
+ return startOfLocalDay(left).getTime() < startOfLocalDay(right).getTime();
201
+ }
202
+
203
+ /*** Reports whether one local calendar day follows another. */
204
+ function isAfterLocalDay(left: Date, right: Date): boolean {
205
+ return startOfLocalDay(left).getTime() > startOfLocalDay(right).getTime();
206
+ }
207
+
208
+ /*** Reports whether a date falls outside the configured picker range. */
209
+ function isDateDisabled(
210
+ value: Date,
211
+ minDate: Date | undefined,
212
+ maxDate: Date | undefined,
213
+ ): boolean {
214
+ if (minDate && isBeforeLocalDay(value, minDate)) return true;
215
+ if (maxDate && isAfterLocalDay(value, maxDate)) return true;
216
+ return false;
217
+ }
218
+
219
+ /*** Formats the current calendar month for the picker heading. */
220
+ function formatMonthLabel(value: Date): string {
221
+ return value.toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
222
+ }
223
+
224
+ /*** Builds the padded calendar grid for one month. */
225
+ function createMonthDays(month: Date): (Date | null)[] {
226
+ const firstDay = new Date(month.getFullYear(), month.getMonth(), 1);
227
+ const dayCount = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate();
228
+ const populatedLength = firstDay.getDay() + dayCount;
229
+ const paddedLength = Math.ceil(populatedLength / 7) * 7;
230
+
231
+ return Array.from({ length: paddedLength }, (_, index) =>
232
+ index < firstDay.getDay()
233
+ ? null
234
+ : new Date(month.getFullYear(), month.getMonth(), index - firstDay.getDay() + 1),
235
+ );
236
+ }
237
+
238
+ /*** Resolves the month initially shown by the calendar. */
239
+ function resolveInitialMonth(value: string | null, minDate: string | undefined): Date {
240
+ const base = parseLocalDate(value) ?? parseLocalDate(minDate) ?? new Date();
241
+ return new Date(base.getFullYear(), base.getMonth(), 1);
242
+ }
243
+
244
+ /*** Reports whether the configured range intersects a candidate month. */
245
+ function canNavigateToMonth(
246
+ month: Date,
247
+ minDate: Date | undefined,
248
+ maxDate: Date | undefined,
249
+ ): boolean {
250
+ const firstDay = new Date(month.getFullYear(), month.getMonth(), 1);
251
+ const lastDay = new Date(month.getFullYear(), month.getMonth() + 1, 0);
252
+
253
+ if (maxDate && isAfterLocalDay(firstDay, maxDate)) return false;
254
+ if (minDate && isBeforeLocalDay(lastDay, minDate)) return false;
255
+ return true;
256
+ }
@@ -0,0 +1,53 @@
1
+ import { Field } from '@ankhorage/surface';
2
+ import type React from 'react';
3
+
4
+ import type { DatePickerProps } from '../../../types/date-picker';
5
+ import { Stack } from '../../layout/public';
6
+ import { Text } from '../../typography/public';
7
+
8
+ /*** Renders the shared DatePicker field frame around a platform-specific trigger. */
9
+ export function DatePickerField({
10
+ children,
11
+ description,
12
+ disabled,
13
+ error,
14
+ label,
15
+ required,
16
+ testID,
17
+ }: DatePickerFieldProps) {
18
+ return (
19
+ <Field
20
+ disabled={disabled}
21
+ errorText={error}
22
+ invalid={Boolean(error)}
23
+ label={label ? renderLabel(label, description) : undefined}
24
+ required={required}
25
+ testID={testID}
26
+ >
27
+ {children}
28
+ </Field>
29
+ );
30
+ }
31
+
32
+ interface DatePickerFieldProps extends Pick<
33
+ DatePickerProps,
34
+ 'description' | 'disabled' | 'error' | 'label' | 'required' | 'testID'
35
+ > {
36
+ children: React.ReactNode;
37
+ }
38
+
39
+ /*** Renders the picker label and optional supporting description. */
40
+ function renderLabel(label: React.ReactNode, description: React.ReactNode | undefined) {
41
+ return (
42
+ <Stack gap="xs">
43
+ <Text variant="label" weight="semiBold">
44
+ {label}
45
+ </Text>
46
+ {description ? (
47
+ <Text emphasis="muted" variant="bodySmall">
48
+ {description}
49
+ </Text>
50
+ ) : null}
51
+ </Stack>
52
+ );
53
+ }
@@ -0,0 +1,47 @@
1
+ import type React from 'react';
2
+
3
+ import type { DatePickerProps } from '../../../types/date-picker';
4
+ import { Button } from '../../button/public';
5
+ import { parseLocalDate } from '../utils/localDate';
6
+
7
+ /*** Renders the shared DatePicker trigger button for native and web hosts. */
8
+ export function DatePickerTrigger({
9
+ disabled,
10
+ formatDate,
11
+ interactionPolicy,
12
+ onPress,
13
+ placeholder = 'Choose date',
14
+ testID,
15
+ value,
16
+ }: DatePickerTriggerProps) {
17
+ const displayValue = value
18
+ ? formatDate
19
+ ? formatDate(value)
20
+ : formatDefaultDate(value)
21
+ : placeholder;
22
+
23
+ return (
24
+ <Button
25
+ disabled={disabled}
26
+ interactionPolicy={interactionPolicy}
27
+ onPress={onPress}
28
+ testID={testID ? `${testID}-trigger` : undefined}
29
+ trailingIcon={{ name: 'calendar-outline' }}
30
+ variant="outline"
31
+ >
32
+ {displayValue}
33
+ </Button>
34
+ );
35
+ }
36
+
37
+ interface DatePickerTriggerProps extends Pick<
38
+ DatePickerProps,
39
+ 'disabled' | 'formatDate' | 'interactionPolicy' | 'placeholder' | 'testID' | 'value'
40
+ > {
41
+ onPress: () => void;
42
+ }
43
+
44
+ /*** Formats a date using the current platform locale when no formatter is supplied. */
45
+ function formatDefaultDate(value: string): React.ReactNode {
46
+ return parseLocalDate(value)?.toLocaleDateString() ?? value;
47
+ }
@@ -2,14 +2,24 @@ import { expect, test } from 'bun:test';
2
2
 
3
3
  import { ZORA_COMPONENT_META } from '../../metadata/componentMeta';
4
4
 
5
- test('DatePicker exposes a serializable manifest contract through the shared BottomSheet', async () => {
6
- const source = await Bun.file('src/features/date-picker/adapters/inbound/DatePicker.tsx').text();
5
+ test('DatePicker keeps one manifest contract across native and web presentation hosts', async () => {
6
+ const nativeSource = await Bun.file(
7
+ 'src/features/date-picker/adapters/inbound/DatePicker.native.tsx',
8
+ ).text();
9
+ const webSource = await Bun.file(
10
+ 'src/features/date-picker/adapters/inbound/DatePicker.web.tsx',
11
+ ).text();
12
+ const contentSource = await Bun.file(
13
+ 'src/features/date-picker/composition/DatePickerContent.tsx',
14
+ ).text();
7
15
  const meta = ZORA_COMPONENT_META.DatePicker;
8
16
 
9
17
  expect(meta.directManifestNode).toBe(true);
10
18
  expect(meta.props.value?.type).toBe('string');
11
19
  expect(meta.events?.valueChange?.eventType).toBe('datePicker.valueChange');
12
20
  expect(meta.bindings?.props?.value?.value.type).toBe('string');
13
- expect(source).toContain('useBottomSheet()');
14
- expect(source).toContain('formatLocalDate(date)');
21
+ expect(nativeSource).toContain('useBottomSheet()');
22
+ expect(webSource).toContain('<Popover');
23
+ expect(webSource).not.toContain('useBottomSheet');
24
+ expect(contentSource).toContain('DatePickerCalendar');
15
25
  });
@@ -0,0 +1,47 @@
1
+ import React from 'react';
2
+
3
+ import { withZoraThemeScope } from '../../../../theme/withZoraThemeScope';
4
+ import type { TimePickerProps } from '../../../../types/time-picker';
5
+ import { useBottomSheet } from '../../../bottom-sheet/public';
6
+ import { TimePickerContent } from '../../composition/TimePickerContent';
7
+ import { TimePickerField } from '../../composition/TimePickerField';
8
+ import { TimePickerTrigger } from '../../composition/TimePickerTrigger';
9
+
10
+ /*** Renders TimePicker with native BottomSheet presentation. */
11
+ export const TimePicker = withZoraThemeScope(TimePickerInner);
12
+
13
+ /*** Connects the shared TimePicker field and content to the native BottomSheet host. */
14
+ function TimePickerInner({ themeId: _themeId, mode: _mode, ...props }: TimePickerProps) {
15
+ const { dismiss, present } = useBottomSheet();
16
+
17
+ const openPicker = React.useCallback(() => {
18
+ if (props.interactionPolicy === 'passive') return;
19
+
20
+ present({
21
+ content: (
22
+ <TimePickerContent
23
+ description={props.description}
24
+ formatTime={props.formatTime}
25
+ interactionPolicy={props.interactionPolicy}
26
+ label={props.label}
27
+ maxTime={props.maxTime}
28
+ minTime={props.minTime}
29
+ onDismiss={dismiss}
30
+ onSelect={(time) => {
31
+ props.onValueChange?.(time);
32
+ dismiss();
33
+ }}
34
+ stepMinutes={props.stepMinutes}
35
+ testID={props.testID}
36
+ value={props.value}
37
+ />
38
+ ),
39
+ });
40
+ }, [dismiss, present, props]);
41
+
42
+ return (
43
+ <TimePickerField {...props}>
44
+ <TimePickerTrigger {...props} onPress={openPicker} />
45
+ </TimePickerField>
46
+ );
47
+ }
@@ -1,258 +1 @@
1
- import { Field } from '@ankhorage/surface';
2
- import React from 'react';
3
- import { ScrollView, StyleSheet } from 'react-native';
4
-
5
- import { withZoraThemeScope } from '../../../../theme/withZoraThemeScope';
6
- import type { TimePickerProps } from '../../../../types/time-picker';
7
- import { useBottomSheet } from '../../../bottom-sheet/public';
8
- import { Button } from '../../../button/public';
9
- import { Stack } from '../../../layout/public';
10
- import { Text } from '../../../typography/public';
11
-
12
- const MINUTES_PER_DAY = 24 * 60;
13
- const DEFAULT_STEP_MINUTES = 30;
14
-
15
- /*** Render the field label and optional supporting description. */
16
- function renderLabel(label: React.ReactNode, description: React.ReactNode | undefined) {
17
- return (
18
- <Stack gap="xs">
19
- <Text variant="label" weight="semiBold">
20
- {label}
21
- </Text>
22
- {description ? (
23
- <Text emphasis="muted" variant="bodySmall">
24
- {description}
25
- </Text>
26
- ) : null}
27
- </Stack>
28
- );
29
- }
30
-
31
- /*** Parse one HH:mm value to minutes since midnight. */
32
- function parseTimeToMinutes(value: string | undefined): number | undefined {
33
- if (!value) return undefined;
34
- const match = /^(\d{2}):(\d{2})$/.exec(value);
35
- if (!match) return undefined;
36
-
37
- const hours = Number(match[1]);
38
- const minutes = Number(match[2]);
39
- if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return undefined;
40
- return hours * 60 + minutes;
41
- }
42
-
43
- /*** Format minutes since midnight as HH:mm. */
44
- function formatMinutes(value: number): string {
45
- const hours = Math.floor(value / 60);
46
- const minutes = value % 60;
47
- return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
48
- }
49
-
50
- /*** Normalize the configured time step to a positive whole minute. */
51
- function resolveStepMinutes(stepMinutes: number | undefined): number {
52
- if (stepMinutes === undefined || !Number.isFinite(stepMinutes) || stepMinutes <= 0) {
53
- return DEFAULT_STEP_MINUTES;
54
- }
55
- return Math.max(1, Math.floor(stepMinutes));
56
- }
57
-
58
- /*** Generate selectable HH:mm values within the configured range. */
59
- function generateTimeOptions({
60
- minTime,
61
- maxTime,
62
- stepMinutes,
63
- }: Pick<TimePickerProps, 'maxTime' | 'minTime' | 'stepMinutes'>): string[] {
64
- const step = resolveStepMinutes(stepMinutes);
65
- const min = parseTimeToMinutes(minTime) ?? 0;
66
- const max = parseTimeToMinutes(maxTime) ?? MINUTES_PER_DAY - 1;
67
- return Array.from({ length: Math.ceil(MINUTES_PER_DAY / step) }, (_, index) => index * step)
68
- .filter((minute) => minute >= min && minute <= max)
69
- .map(formatMinutes);
70
- }
71
-
72
- function TimePickerInner({
73
- themeId: _themeId,
74
- mode: _mode,
75
- value,
76
- onValueChange,
77
- label,
78
- description,
79
- error,
80
- placeholder = 'Choose time',
81
- minTime,
82
- maxTime,
83
- stepMinutes,
84
- disabled = false,
85
- required = false,
86
- formatTime,
87
- testID,
88
- interactionPolicy,
89
- }: TimePickerProps) {
90
- const { dismiss, present } = useBottomSheet();
91
- const passive = interactionPolicy === 'passive';
92
- const displayValue = value ? (formatTime ? formatTime(value) : value) : placeholder;
93
-
94
- /*** Present the time options through the shared BottomSheet controller. */
95
- function openPicker() {
96
- if (passive) return;
97
- present({
98
- content: (
99
- <TimePickerSheet
100
- description={description}
101
- formatTime={formatTime}
102
- interactionPolicy={interactionPolicy}
103
- label={label}
104
- maxTime={maxTime}
105
- minTime={minTime}
106
- onDismiss={dismiss}
107
- onSelect={(time) => {
108
- onValueChange?.(time);
109
- dismiss();
110
- }}
111
- stepMinutes={stepMinutes}
112
- testID={testID}
113
- value={value}
114
- />
115
- ),
116
- });
117
- }
118
-
119
- return (
120
- <Field
121
- disabled={disabled}
122
- errorText={error}
123
- invalid={Boolean(error)}
124
- label={label ? renderLabel(label, description) : undefined}
125
- required={required}
126
- testID={testID}
127
- >
128
- <Button
129
- disabled={disabled}
130
- interactionPolicy={interactionPolicy}
131
- onPress={openPicker}
132
- testID={testID ? `${testID}-trigger` : undefined}
133
- trailingIcon={{ name: 'chevron-down-outline' }}
134
- variant="outline"
135
- >
136
- {displayValue}
137
- </Button>
138
- </Field>
139
- );
140
- }
141
-
142
- interface TimePickerSheetProps extends Pick<
143
- TimePickerProps,
144
- | 'description'
145
- | 'formatTime'
146
- | 'interactionPolicy'
147
- | 'label'
148
- | 'maxTime'
149
- | 'minTime'
150
- | 'stepMinutes'
151
- | 'testID'
152
- | 'value'
153
- > {
154
- onDismiss: () => void;
155
- onSelect: (value: string) => void;
156
- }
157
-
158
- /*** Render time selection content inside the shared BottomSheet host. */
159
- function TimePickerSheet({
160
- description,
161
- formatTime,
162
- interactionPolicy,
163
- label,
164
- maxTime,
165
- minTime,
166
- onDismiss,
167
- onSelect,
168
- stepMinutes,
169
- testID,
170
- value,
171
- }: TimePickerSheetProps) {
172
- const options = React.useMemo(
173
- () => generateTimeOptions({ maxTime, minTime, stepMinutes }),
174
- [maxTime, minTime, stepMinutes],
175
- );
176
-
177
- return (
178
- <Stack gap="m" p="m" testID={testID ? `${testID}-sheet` : undefined}>
179
- <PickerSheetHeader description={description} title={label ?? 'Choose time'} />
180
- <ScrollView style={styles.options}>
181
- <Stack gap="xxs">
182
- {options.map((option) => (
183
- <TimePickerOption
184
- formatTime={formatTime}
185
- interactionPolicy={interactionPolicy}
186
- key={option}
187
- onSelect={onSelect}
188
- option={option}
189
- selected={option === value}
190
- testID={testID}
191
- />
192
- ))}
193
- </Stack>
194
- </ScrollView>
195
- <Button fullWidth interactionPolicy={interactionPolicy} onPress={onDismiss} variant="ghost">
196
- Cancel
197
- </Button>
198
- </Stack>
199
- );
200
- }
201
-
202
- /*** Render the shared title block used by the time sheet. */
203
- function PickerSheetHeader({
204
- description,
205
- title,
206
- }: {
207
- description: React.ReactNode | undefined;
208
- title: React.ReactNode;
209
- }) {
210
- return (
211
- <Stack gap="xxs">
212
- <Text align="center" variant="label" weight="semiBold">
213
- {title}
214
- </Text>
215
- {description ? (
216
- <Text align="center" emphasis="muted" variant="bodySmall">
217
- {description}
218
- </Text>
219
- ) : null}
220
- </Stack>
221
- );
222
- }
223
-
224
- /*** Render one accessible selectable time option. */
225
- function TimePickerOption({
226
- formatTime,
227
- interactionPolicy,
228
- onSelect,
229
- option,
230
- selected,
231
- testID,
232
- }: Pick<TimePickerSheetProps, 'formatTime' | 'interactionPolicy' | 'testID'> & {
233
- onSelect: (value: string) => void;
234
- option: string;
235
- selected: boolean;
236
- }) {
237
- return (
238
- <Button
239
- accessibilityState={{ selected }}
240
- fullWidth
241
- interactionPolicy={interactionPolicy}
242
- onPress={() => onSelect(option)}
243
- testID={testID ? `${testID}-option-${option}` : undefined}
244
- variant={selected ? 'soft' : 'ghost'}
245
- >
246
- {formatTime ? formatTime(option) : option}
247
- </Button>
248
- );
249
- }
250
-
251
- const styles = StyleSheet.create({
252
- options: { maxHeight: 360 },
253
- });
254
-
255
- /***
256
- * Time input control with bottom-sheet selection and formatted display value.
257
- */
258
- export const TimePicker = withZoraThemeScope(TimePickerInner);
1
+ export { TimePicker } from './TimePicker.web';