@ankhorage/zora 5.0.0 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +14 -10
  3. package/dist/components/button/resolveButtonThemeRecipe.d.ts +1 -1
  4. package/dist/components/date-picker/DatePicker.d.ts.map +1 -1
  5. package/dist/components/date-picker/DatePicker.js +92 -86
  6. package/dist/components/date-picker/DatePicker.js.map +1 -1
  7. package/dist/components/date-picker/meta.d.ts +1 -1
  8. package/dist/components/date-picker/meta.js +1 -1
  9. package/dist/components/date-picker/meta.js.map +1 -1
  10. package/dist/components/heading/resolveHeadingThemeRecipe.d.ts +1 -1
  11. package/dist/components/image/Image.d.ts +1 -5
  12. package/dist/components/image/Image.d.ts.map +1 -1
  13. package/dist/components/image/Image.js +79 -8
  14. package/dist/components/image/Image.js.map +1 -1
  15. package/dist/components/image/types.d.ts +4 -2
  16. package/dist/components/image/types.d.ts.map +1 -1
  17. package/dist/components/image/types.js.map +1 -1
  18. package/dist/components/text/resolveTextThemeRecipe.d.ts +1 -1
  19. package/dist/components/time-picker/TimePicker.d.ts +1 -3
  20. package/dist/components/time-picker/TimePicker.d.ts.map +1 -1
  21. package/dist/components/time-picker/TimePicker.js +55 -32
  22. package/dist/components/time-picker/TimePicker.js.map +1 -1
  23. package/dist/components/time-picker/meta.d.ts +1 -1
  24. package/dist/components/time-picker/meta.js +1 -1
  25. package/dist/components/time-picker/meta.js.map +1 -1
  26. package/dist/features/bottom-sheet/public.d.ts +3 -0
  27. package/dist/features/bottom-sheet/public.d.ts.map +1 -0
  28. package/dist/features/bottom-sheet/public.js +2 -0
  29. package/dist/features/bottom-sheet/public.js.map +1 -0
  30. package/dist/features/uploader/adapters/inbound/Uploader.d.ts +5 -0
  31. package/dist/features/uploader/adapters/inbound/Uploader.d.ts.map +1 -0
  32. package/dist/features/uploader/adapters/inbound/Uploader.js +274 -0
  33. package/dist/features/uploader/adapters/inbound/Uploader.js.map +1 -0
  34. package/dist/features/uploader/adapters/outbound/pickDocumentOrFileAsync.d.ts +5 -0
  35. package/dist/features/uploader/adapters/outbound/pickDocumentOrFileAsync.d.ts.map +1 -0
  36. package/dist/features/uploader/adapters/outbound/pickDocumentOrFileAsync.js +38 -0
  37. package/dist/features/uploader/adapters/outbound/pickDocumentOrFileAsync.js.map +1 -0
  38. package/dist/features/uploader/adapters/outbound/pickImageOrVideoAsync.d.ts +5 -0
  39. package/dist/features/uploader/adapters/outbound/pickImageOrVideoAsync.d.ts.map +1 -0
  40. package/dist/features/uploader/adapters/outbound/pickImageOrVideoAsync.js +24 -0
  41. package/dist/features/uploader/adapters/outbound/pickImageOrVideoAsync.js.map +1 -0
  42. package/dist/features/uploader/application/ports/outbound/UploadPickerPort.d.ts +9 -0
  43. package/dist/features/uploader/application/ports/outbound/UploadPickerPort.d.ts.map +1 -0
  44. package/dist/features/uploader/application/ports/outbound/UploadPickerPort.js +2 -0
  45. package/dist/features/uploader/application/ports/outbound/UploadPickerPort.js.map +1 -0
  46. package/dist/features/uploader/application/use-cases/pickUploadAssetAsync.d.ts +5 -0
  47. package/dist/features/uploader/application/use-cases/pickUploadAssetAsync.d.ts.map +1 -0
  48. package/dist/features/uploader/application/use-cases/pickUploadAssetAsync.js +6 -0
  49. package/dist/features/uploader/application/use-cases/pickUploadAssetAsync.js.map +1 -0
  50. package/dist/features/uploader/application/use-cases/validateUploadAsset.d.ts +4 -0
  51. package/dist/features/uploader/application/use-cases/validateUploadAsset.d.ts.map +1 -0
  52. package/dist/features/uploader/application/use-cases/validateUploadAsset.js +77 -0
  53. package/dist/features/uploader/application/use-cases/validateUploadAsset.js.map +1 -0
  54. package/dist/features/uploader/composition/createUploadPicker.d.ts +4 -0
  55. package/dist/features/uploader/composition/createUploadPicker.d.ts.map +1 -0
  56. package/dist/features/uploader/composition/createUploadPicker.js +12 -0
  57. package/dist/features/uploader/composition/createUploadPicker.js.map +1 -0
  58. package/dist/features/uploader/meta.d.ts +10 -0
  59. package/dist/features/uploader/meta.d.ts.map +1 -0
  60. package/dist/features/uploader/meta.js +10 -0
  61. package/dist/features/uploader/meta.js.map +1 -0
  62. package/dist/features/uploader/public.d.ts +4 -0
  63. package/dist/features/uploader/public.d.ts.map +1 -0
  64. package/dist/features/uploader/public.js +3 -0
  65. package/dist/features/uploader/public.js.map +1 -0
  66. package/dist/index.d.ts +2 -6
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +1 -3
  69. package/dist/index.js.map +1 -1
  70. package/dist/metadata/componentMeta.d.ts.map +1 -1
  71. package/dist/metadata/componentMeta.js +2 -7
  72. package/dist/metadata/componentMeta.js.map +1 -1
  73. package/dist/registry.d.ts.map +1 -1
  74. package/dist/registry.js +2 -7
  75. package/dist/registry.js.map +1 -1
  76. package/dist/theme/ZoraProvider.d.ts +1 -1
  77. package/dist/theme/ZoraProvider.d.ts.map +1 -1
  78. package/dist/theme/ZoraProvider.js +3 -2
  79. package/dist/theme/ZoraProvider.js.map +1 -1
  80. package/dist/theme/useZoraTheme.d.ts +1 -1
  81. package/dist/types/upload.d.ts +53 -0
  82. package/dist/types/upload.d.ts.map +1 -0
  83. package/dist/types/upload.js +2 -0
  84. package/dist/types/upload.js.map +1 -0
  85. package/package.json +25 -3
  86. package/src/components/date-picker/DatePicker.tsx +197 -129
  87. package/src/components/date-picker/meta.ts +1 -1
  88. package/src/components/image/Image.tsx +136 -8
  89. package/src/components/image/types.ts +6 -2
  90. package/src/components/time-picker/TimePicker.tsx +147 -60
  91. package/src/components/time-picker/meta.ts +1 -1
  92. package/src/expo57Migration.test.ts +56 -0
  93. package/src/features/bottom-sheet/public.test.ts +39 -0
  94. package/src/features/bottom-sheet/public.ts +5 -0
  95. package/src/features/uploader/adapters/inbound/Uploader.tsx +403 -0
  96. package/src/features/uploader/adapters/outbound/pickDocumentOrFileAsync.ts +50 -0
  97. package/src/features/uploader/adapters/outbound/pickImageOrVideoAsync.ts +30 -0
  98. package/src/features/uploader/application/ports/outbound/UploadPickerPort.ts +10 -0
  99. package/src/features/uploader/application/use-cases/pickUploadAssetAsync.test.ts +48 -0
  100. package/src/features/uploader/application/use-cases/pickUploadAssetAsync.ts +14 -0
  101. package/src/features/uploader/application/use-cases/validateUploadAsset.test.ts +51 -0
  102. package/src/features/uploader/application/use-cases/validateUploadAsset.ts +111 -0
  103. package/src/features/uploader/composition/createUploadPicker.ts +14 -0
  104. package/src/features/uploader/meta.ts +11 -0
  105. package/src/features/uploader/public.test.ts +34 -0
  106. package/src/features/uploader/public.ts +10 -0
  107. package/src/index.ts +9 -14
  108. package/src/metadata/componentMeta.ts +2 -7
  109. package/src/platformAcceptance.test.ts +1 -1
  110. package/src/registry.test.ts +1 -1
  111. package/src/registry.ts +2 -7
  112. package/src/showcaseCoverage.test.ts +1 -2
  113. package/src/theme/ZoraProvider.tsx +3 -2
  114. package/src/types/upload.ts +62 -0
  115. package/dist/components/action-sheet/ActionSheet.d.ts +0 -7
  116. package/dist/components/action-sheet/ActionSheet.d.ts.map +0 -1
  117. package/dist/components/action-sheet/ActionSheet.js +0 -11
  118. package/dist/components/action-sheet/ActionSheet.js.map +0 -1
  119. package/dist/components/action-sheet/ActionSheetItem.d.ts +0 -7
  120. package/dist/components/action-sheet/ActionSheetItem.d.ts.map +0 -1
  121. package/dist/components/action-sheet/ActionSheetItem.js +0 -17
  122. package/dist/components/action-sheet/ActionSheetItem.js.map +0 -1
  123. package/dist/components/action-sheet/index.d.ts +0 -4
  124. package/dist/components/action-sheet/index.d.ts.map +0 -1
  125. package/dist/components/action-sheet/index.js +0 -3
  126. package/dist/components/action-sheet/index.js.map +0 -1
  127. package/dist/components/action-sheet/meta.d.ts +0 -17
  128. package/dist/components/action-sheet/meta.d.ts.map +0 -1
  129. package/dist/components/action-sheet/meta.js +0 -17
  130. package/dist/components/action-sheet/meta.js.map +0 -1
  131. package/dist/components/action-sheet/types.d.ts +0 -13
  132. package/dist/components/action-sheet/types.d.ts.map +0 -1
  133. package/dist/components/action-sheet/types.js +0 -2
  134. package/dist/components/action-sheet/types.js.map +0 -1
  135. package/dist/patterns/image-preview/ImagePreview.d.ts +0 -9
  136. package/dist/patterns/image-preview/ImagePreview.d.ts.map +0 -1
  137. package/dist/patterns/image-preview/ImagePreview.js +0 -46
  138. package/dist/patterns/image-preview/ImagePreview.js.map +0 -1
  139. package/dist/patterns/image-preview/index.d.ts +0 -3
  140. package/dist/patterns/image-preview/index.d.ts.map +0 -1
  141. package/dist/patterns/image-preview/index.js +0 -2
  142. package/dist/patterns/image-preview/index.js.map +0 -1
  143. package/dist/patterns/image-preview/meta.d.ts +0 -9
  144. package/dist/patterns/image-preview/meta.d.ts.map +0 -1
  145. package/dist/patterns/image-preview/meta.js +0 -9
  146. package/dist/patterns/image-preview/meta.js.map +0 -1
  147. package/dist/patterns/image-preview/types.d.ts +0 -36
  148. package/dist/patterns/image-preview/types.d.ts.map +0 -1
  149. package/dist/patterns/image-preview/types.js +0 -2
  150. package/dist/patterns/image-preview/types.js.map +0 -1
  151. package/dist/patterns/image-upload-field/ImageUploadField.d.ts +0 -9
  152. package/dist/patterns/image-upload-field/ImageUploadField.d.ts.map +0 -1
  153. package/dist/patterns/image-upload-field/ImageUploadField.js +0 -222
  154. package/dist/patterns/image-upload-field/ImageUploadField.js.map +0 -1
  155. package/dist/patterns/image-upload-field/index.d.ts +0 -3
  156. package/dist/patterns/image-upload-field/index.d.ts.map +0 -1
  157. package/dist/patterns/image-upload-field/index.js +0 -2
  158. package/dist/patterns/image-upload-field/index.js.map +0 -1
  159. package/dist/patterns/image-upload-field/meta.d.ts +0 -9
  160. package/dist/patterns/image-upload-field/meta.d.ts.map +0 -1
  161. package/dist/patterns/image-upload-field/meta.js +0 -9
  162. package/dist/patterns/image-upload-field/meta.js.map +0 -1
  163. package/dist/patterns/image-upload-field/types.d.ts +0 -35
  164. package/dist/patterns/image-upload-field/types.d.ts.map +0 -1
  165. package/dist/patterns/image-upload-field/types.js +0 -2
  166. package/dist/patterns/image-upload-field/types.js.map +0 -1
  167. package/dist/patterns/image-upload-field/uploadFlow.d.ts +0 -18
  168. package/dist/patterns/image-upload-field/uploadFlow.d.ts.map +0 -1
  169. package/dist/patterns/image-upload-field/uploadFlow.js +0 -106
  170. package/dist/patterns/image-upload-field/uploadFlow.js.map +0 -1
  171. package/src/components/action-sheet/ActionSheet.tsx +0 -14
  172. package/src/components/action-sheet/ActionSheetItem.tsx +0 -30
  173. package/src/components/action-sheet/index.ts +0 -3
  174. package/src/components/action-sheet/meta.ts +0 -19
  175. package/src/components/action-sheet/types.ts +0 -23
  176. package/src/patterns/image-preview/ImagePreview.tsx +0 -82
  177. package/src/patterns/image-preview/index.ts +0 -2
  178. package/src/patterns/image-preview/meta.ts +0 -10
  179. package/src/patterns/image-preview/types.ts +0 -41
  180. package/src/patterns/image-upload-field/ImageUploadField.tsx +0 -319
  181. package/src/patterns/image-upload-field/index.ts +0 -2
  182. package/src/patterns/image-upload-field/meta.ts +0 -10
  183. package/src/patterns/image-upload-field/types.ts +0 -41
  184. package/src/patterns/image-upload-field/uploadFlow.test.ts +0 -117
  185. package/src/patterns/image-upload-field/uploadFlow.ts +0 -145
@@ -1,9 +1,9 @@
1
1
  import { Field } from '@ankhorage/surface';
2
2
  import React from 'react';
3
3
 
4
+ import { useBottomSheet } from '../../features/bottom-sheet/public';
4
5
  import { Box, Stack } from '../../foundation';
5
6
  import { withZoraThemeScope } from '../../theme/withZoraThemeScope';
6
- import { ActionSheet } from '../action-sheet';
7
7
  import { Button } from '../button';
8
8
  import { Text } from '../text';
9
9
  import type { DatePickerProps } from './types';
@@ -11,6 +11,7 @@ import type { DatePickerProps } from './types';
11
11
  const WEEKDAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const;
12
12
  const DAY_CELL_WIDTH = 42;
13
13
 
14
+ /*** Render the field label and optional supporting description. */
14
15
  function renderLabel(label: React.ReactNode, description: React.ReactNode | undefined) {
15
16
  return (
16
17
  <Stack gap="xs">
@@ -26,75 +27,70 @@ function renderLabel(label: React.ReactNode, description: React.ReactNode | unde
26
27
  );
27
28
  }
28
29
 
30
+ /*** Normalize a date to the start of its local calendar day. */
29
31
  function startOfLocalDay(value: Date): Date {
30
32
  return new Date(value.getFullYear(), value.getMonth(), value.getDate());
31
33
  }
32
34
 
35
+ /*** Compare two values by local calendar day. */
33
36
  function isSameLocalDay(left: Date | null, right: Date): boolean {
34
- if (!left) {
35
- return false;
36
- }
37
-
37
+ if (!left) return false;
38
38
  return startOfLocalDay(left).getTime() === startOfLocalDay(right).getTime();
39
39
  }
40
40
 
41
+ /*** Report whether one local calendar day precedes another. */
41
42
  function isBeforeLocalDay(left: Date, right: Date): boolean {
42
43
  return startOfLocalDay(left).getTime() < startOfLocalDay(right).getTime();
43
44
  }
44
45
 
46
+ /*** Report whether one local calendar day follows another. */
45
47
  function isAfterLocalDay(left: Date, right: Date): boolean {
46
48
  return startOfLocalDay(left).getTime() > startOfLocalDay(right).getTime();
47
49
  }
48
50
 
51
+ /*** Resolve whether a date falls outside the configured picker range. */
49
52
  function isDateDisabled(
50
53
  value: Date,
51
54
  minDate: Date | undefined,
52
55
  maxDate: Date | undefined,
53
56
  ): boolean {
54
- if (minDate && isBeforeLocalDay(value, minDate)) {
55
- return true;
56
- }
57
-
58
- if (maxDate && isAfterLocalDay(value, maxDate)) {
59
- return true;
60
- }
61
-
57
+ if (minDate && isBeforeLocalDay(value, minDate)) return true;
58
+ if (maxDate && isAfterLocalDay(value, maxDate)) return true;
62
59
  return false;
63
60
  }
64
61
 
62
+ /*** Format the current calendar month for the picker heading. */
65
63
  function formatMonthLabel(value: Date): string {
66
64
  return value.toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
67
65
  }
68
66
 
67
+ /*** Format a selected date with the platform locale defaults. */
69
68
  function formatDefaultDate(value: Date): string {
70
69
  return value.toLocaleDateString();
71
70
  }
72
71
 
72
+ /*** Build the padded calendar grid for one month. */
73
73
  function createMonthDays(month: Date): (Date | null)[] {
74
74
  const firstDay = new Date(month.getFullYear(), month.getMonth(), 1);
75
75
  const dayCount = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate();
76
76
  const days: (Date | null)[] = [];
77
77
 
78
- for (let offset = 0; offset < firstDay.getDay(); offset += 1) {
79
- days.push(null);
80
- }
81
-
78
+ for (let offset = 0; offset < firstDay.getDay(); offset += 1) days.push(null);
82
79
  for (let day = 1; day <= dayCount; day += 1) {
83
80
  days.push(new Date(month.getFullYear(), month.getMonth(), day));
84
81
  }
85
-
86
- while (days.length % 7 !== 0) {
87
- days.push(null);
88
- }
82
+ while (days.length % 7 !== 0) days.push(null);
89
83
 
90
84
  return days;
91
85
  }
92
86
 
87
+ /*** Resolve the month initially shown by the calendar sheet. */
93
88
  function resolveInitialMonth(value: Date | null, minDate: Date | undefined): Date {
94
89
  const base = value ?? minDate ?? new Date();
95
90
  return new Date(base.getFullYear(), base.getMonth(), 1);
96
91
  }
97
92
 
93
+ /*** Report whether the configured range intersects a candidate month. */
98
94
  function canNavigateToMonth(
99
95
  month: Date,
100
96
  minDate: Date | undefined,
@@ -103,14 +99,8 @@ function canNavigateToMonth(
103
99
  const firstDay = new Date(month.getFullYear(), month.getMonth(), 1);
104
100
  const lastDay = new Date(month.getFullYear(), month.getMonth() + 1, 0);
105
101
 
106
- if (maxDate && isAfterLocalDay(firstDay, maxDate)) {
107
- return false;
108
- }
109
-
110
- if (minDate && isBeforeLocalDay(lastDay, minDate)) {
111
- return false;
112
- }
113
-
102
+ if (maxDate && isAfterLocalDay(firstDay, maxDate)) return false;
103
+ if (minDate && isBeforeLocalDay(lastDay, minDate)) return false;
114
104
  return true;
115
105
  }
116
106
 
@@ -131,35 +121,35 @@ function DatePickerInner({
131
121
  testID,
132
122
  interactionPolicy,
133
123
  }: DatePickerProps) {
124
+ const { dismiss, present } = useBottomSheet();
134
125
  const passive = interactionPolicy === 'passive';
135
- const [visible, setVisible] = React.useState(false);
136
- const [fallbackMonth] = React.useState(() => resolveInitialMonth(null, undefined));
137
- const initialMonth = value || minDate ? resolveInitialMonth(value, minDate) : fallbackMonth;
138
- const initialMonthKey = initialMonth.getTime();
139
- const [displayMonthState, setDisplayMonthState] = React.useState(() => ({
140
- sourceMonthKey: initialMonthKey,
141
- month: initialMonth,
142
- }));
143
- const displayMonth =
144
- displayMonthState.sourceMonthKey === initialMonthKey ? displayMonthState.month : initialMonth;
145
- const monthDays = React.useMemo(() => createMonthDays(displayMonth), [displayMonth]);
146
- const previousMonth = new Date(displayMonth.getFullYear(), displayMonth.getMonth() - 1, 1);
147
- const nextMonth = new Date(displayMonth.getFullYear(), displayMonth.getMonth() + 1, 1);
148
126
  const displayValue = value
149
127
  ? formatDate
150
128
  ? formatDate(value)
151
129
  : formatDefaultDate(value)
152
130
  : placeholder;
153
131
 
154
- function setDisplayMonth(month: Date) {
155
- setDisplayMonthState({ sourceMonthKey: initialMonthKey, month });
156
- }
157
-
132
+ /*** Present the calendar content through the shared BottomSheet controller. */
158
133
  function openPicker() {
159
134
  if (passive) return;
160
-
161
- setDisplayMonthState({ sourceMonthKey: initialMonthKey, month: initialMonth });
162
- setVisible(true);
135
+ present({
136
+ content: (
137
+ <DatePickerSheet
138
+ description={description}
139
+ interactionPolicy={interactionPolicy}
140
+ label={label}
141
+ maxDate={maxDate}
142
+ minDate={minDate}
143
+ onDismiss={dismiss}
144
+ onSelect={(date) => {
145
+ onValueChange?.(startOfLocalDay(date));
146
+ dismiss();
147
+ }}
148
+ testID={testID}
149
+ value={value}
150
+ />
151
+ ),
152
+ });
163
153
  }
164
154
 
165
155
  return (
@@ -181,95 +171,173 @@ function DatePickerInner({
181
171
  >
182
172
  {displayValue}
183
173
  </Button>
184
- <ActionSheet
185
- description={description}
174
+ </Field>
175
+ );
176
+ }
177
+
178
+ interface DatePickerSheetProps extends Pick<
179
+ DatePickerProps,
180
+ 'description' | 'interactionPolicy' | 'label' | 'maxDate' | 'minDate' | 'testID' | 'value'
181
+ > {
182
+ onDismiss: () => void;
183
+ onSelect: (value: Date) => void;
184
+ }
185
+
186
+ /*** Render the calendar-specific content inside the shared BottomSheet host. */
187
+ function DatePickerSheet({
188
+ description,
189
+ interactionPolicy,
190
+ label,
191
+ maxDate,
192
+ minDate,
193
+ onDismiss,
194
+ onSelect,
195
+ testID,
196
+ value,
197
+ }: DatePickerSheetProps) {
198
+ const [displayMonth, setDisplayMonth] = React.useState(() => resolveInitialMonth(value, minDate));
199
+ const monthDays = React.useMemo(() => createMonthDays(displayMonth), [displayMonth]);
200
+
201
+ return (
202
+ <Stack gap="m" p="m" testID={testID ? `${testID}-sheet` : undefined}>
203
+ <PickerSheetHeader description={description} title={label ?? 'Choose date'} />
204
+ <DatePickerMonthNavigation
205
+ displayMonth={displayMonth}
186
206
  interactionPolicy={interactionPolicy}
187
- onDismiss={() => {
188
- if (passive) return;
207
+ maxDate={maxDate}
208
+ minDate={minDate}
209
+ onMonthChange={setDisplayMonth}
210
+ />
211
+ <DatePickerWeekdays />
212
+ <DatePickerCalendar
213
+ interactionPolicy={interactionPolicy}
214
+ maxDate={maxDate}
215
+ minDate={minDate}
216
+ monthDays={monthDays}
217
+ onSelect={onSelect}
218
+ testID={testID}
219
+ value={value}
220
+ />
221
+ <Button fullWidth interactionPolicy={interactionPolicy} onPress={onDismiss} variant="ghost">
222
+ Cancel
223
+ </Button>
224
+ </Stack>
225
+ );
226
+ }
227
+
228
+ /*** Render the shared title block used by the calendar sheet. */
229
+ function PickerSheetHeader({
230
+ description,
231
+ title,
232
+ }: {
233
+ description: React.ReactNode | undefined;
234
+ title: React.ReactNode;
235
+ }) {
236
+ return (
237
+ <Stack gap="xxs">
238
+ <Text align="center" variant="label" weight="semiBold">
239
+ {title}
240
+ </Text>
241
+ {description ? (
242
+ <Text align="center" emphasis="muted" variant="bodySmall">
243
+ {description}
244
+ </Text>
245
+ ) : null}
246
+ </Stack>
247
+ );
248
+ }
249
+
250
+ /*** Render previous/next month controls for the calendar sheet. */
251
+ function DatePickerMonthNavigation({
252
+ displayMonth,
253
+ interactionPolicy,
254
+ maxDate,
255
+ minDate,
256
+ onMonthChange,
257
+ }: Pick<DatePickerSheetProps, 'interactionPolicy' | 'maxDate' | 'minDate'> & {
258
+ displayMonth: Date;
259
+ onMonthChange: (month: Date) => void;
260
+ }) {
261
+ const previousMonth = new Date(displayMonth.getFullYear(), displayMonth.getMonth() - 1, 1);
262
+ const nextMonth = new Date(displayMonth.getFullYear(), displayMonth.getMonth() + 1, 1);
189
263
 
190
- setVisible(false);
191
- }}
192
- testID={testID ? `${testID}-sheet` : undefined}
193
- title={label ?? 'Choose date'}
194
- visible={visible}
264
+ return (
265
+ <Stack align="center" direction="row" justify="space-between">
266
+ <Button
267
+ disabled={!canNavigateToMonth(previousMonth, minDate, maxDate)}
268
+ interactionPolicy={interactionPolicy}
269
+ onPress={() => onMonthChange(previousMonth)}
270
+ size="s"
271
+ variant="ghost"
195
272
  >
196
- <Stack gap="m">
197
- <Stack align="center" direction="row" justify="space-between">
198
- <Button
199
- disabled={!canNavigateToMonth(previousMonth, minDate, maxDate)}
200
- interactionPolicy={interactionPolicy}
201
- onPress={() => {
202
- if (passive) return;
273
+ Previous
274
+ </Button>
275
+ <Text align="center" variant="label" weight="semiBold">
276
+ {formatMonthLabel(displayMonth)}
277
+ </Text>
278
+ <Button
279
+ disabled={!canNavigateToMonth(nextMonth, minDate, maxDate)}
280
+ interactionPolicy={interactionPolicy}
281
+ onPress={() => onMonthChange(nextMonth)}
282
+ size="s"
283
+ variant="ghost"
284
+ >
285
+ Next
286
+ </Button>
287
+ </Stack>
288
+ );
289
+ }
203
290
 
204
- setDisplayMonth(previousMonth);
205
- }}
206
- size="s"
207
- variant="ghost"
208
- >
209
- Previous
210
- </Button>
211
- <Text align="center" variant="label" weight="semiBold">
212
- {formatMonthLabel(displayMonth)}
213
- </Text>
291
+ /*** Render weekday headings for the calendar grid. */
292
+ function DatePickerWeekdays() {
293
+ return (
294
+ <Stack direction="row" justify="space-between">
295
+ {WEEKDAY_LABELS.map((weekday) => (
296
+ <Box key={weekday} width={DAY_CELL_WIDTH}>
297
+ <Text align="center" emphasis="muted" variant="caption" weight="semiBold">
298
+ {weekday}
299
+ </Text>
300
+ </Box>
301
+ ))}
302
+ </Stack>
303
+ );
304
+ }
305
+
306
+ /*** Render selectable day cells for the current calendar month. */
307
+ function DatePickerCalendar({
308
+ interactionPolicy,
309
+ maxDate,
310
+ minDate,
311
+ monthDays,
312
+ onSelect,
313
+ testID,
314
+ value,
315
+ }: Pick<DatePickerSheetProps, 'interactionPolicy' | 'maxDate' | 'minDate' | 'testID' | 'value'> & {
316
+ monthDays: readonly (Date | null)[];
317
+ onSelect: (value: Date) => void;
318
+ }) {
319
+ return (
320
+ <Stack direction="row" gap="xs" wrap="wrap">
321
+ {monthDays.map((day, index) => {
322
+ if (!day) return <Box key={`empty-${index}`} width={DAY_CELL_WIDTH} />;
323
+ const selected = isSameLocalDay(value, day);
324
+ return (
325
+ <Box key={day.toISOString()} width={DAY_CELL_WIDTH}>
214
326
  <Button
215
- disabled={!canNavigateToMonth(nextMonth, minDate, maxDate)}
327
+ color={selected ? 'primary' : 'neutral'}
328
+ disabled={isDateDisabled(day, minDate, maxDate)}
216
329
  interactionPolicy={interactionPolicy}
217
- onPress={() => {
218
- if (passive) return;
219
-
220
- setDisplayMonth(nextMonth);
221
- }}
330
+ onPress={() => onSelect(day)}
222
331
  size="s"
223
- variant="ghost"
332
+ testID={testID ? `${testID}-day-${day.getDate()}` : undefined}
333
+ variant={selected ? 'solid' : 'ghost'}
224
334
  >
225
- Next
335
+ {day.getDate()}
226
336
  </Button>
227
- </Stack>
228
-
229
- <Stack direction="row" justify="space-between">
230
- {WEEKDAY_LABELS.map((weekday) => (
231
- <Box key={weekday} style={{ width: DAY_CELL_WIDTH }}>
232
- <Text align="center" emphasis="muted" variant="caption" weight="semiBold">
233
- {weekday}
234
- </Text>
235
- </Box>
236
- ))}
237
- </Stack>
238
-
239
- <Stack direction="row" gap="xs" style={{ flexWrap: 'wrap' }}>
240
- {monthDays.map((day, index) => {
241
- if (!day) {
242
- return <Box key={`empty-${index}`} style={{ width: DAY_CELL_WIDTH }} />;
243
- }
244
-
245
- const selected = isSameLocalDay(value, day);
246
- const dayDisabled = isDateDisabled(day, minDate, maxDate);
247
-
248
- return (
249
- <Box key={day.toISOString()} style={{ width: DAY_CELL_WIDTH }}>
250
- <Button
251
- color={selected ? 'primary' : 'neutral'}
252
- disabled={dayDisabled}
253
- interactionPolicy={interactionPolicy}
254
- onPress={() => {
255
- if (passive) return;
256
-
257
- onValueChange?.(startOfLocalDay(day));
258
- setVisible(false);
259
- }}
260
- size="s"
261
- testID={testID ? `${testID}-day-${day.getDate()}` : undefined}
262
- variant={selected ? 'solid' : 'ghost'}
263
- >
264
- {day.getDate()}
265
- </Button>
266
- </Box>
267
- );
268
- })}
269
- </Stack>
270
- </Stack>
271
- </ActionSheet>
272
- </Field>
337
+ </Box>
338
+ );
339
+ })}
340
+ </Stack>
273
341
  );
274
342
  }
275
343
 
@@ -5,6 +5,6 @@ export const datePickerMeta = {
5
5
  category: 'component',
6
6
  directManifestNode: false,
7
7
  allowedChildren: [],
8
- note: 'Code-facing ActionSheet-backed date picker; not represented as a manifest node in v1.',
8
+ note: 'Code-facing BottomSheet-backed date picker; not represented as a manifest node in v1.',
9
9
  props: {},
10
10
  } as const satisfies ZoraComponentMeta;
@@ -1,21 +1,149 @@
1
- import { Image as SurfaceImage } from '@ankhorage/surface';
1
+ import { Image as SurfaceImage, type SurfaceImageSource } from '@ankhorage/surface';
2
2
  import React from 'react';
3
3
 
4
+ import { Box } from '../../foundation';
5
+ import { useZoraTheme } from '../../theme/useZoraTheme';
4
6
  import { withZoraThemeScope } from '../../theme/withZoraThemeScope';
7
+ import type { UploadAsset } from '../../types/upload';
8
+ import { Icon } from '../icon';
5
9
  import type { ImageProps } from './types';
6
10
 
11
+ /*** Displays an image asset and owns its missing, unrenderable, and failed presentation states. */
12
+ export const Image = withZoraThemeScope(ImageInner);
13
+
14
+ /*** Renders the resolved image source or the canonical ZORA image placeholder. */
7
15
  function ImageInner({
8
16
  themeId: _themeId,
9
17
  mode: _mode,
10
18
  interactionPolicy: _interactionPolicy,
19
+ source,
20
+ fallbackSource,
21
+ alt,
22
+ accessibilityLabel,
23
+ width,
24
+ height,
25
+ aspectRatio,
26
+ radius,
27
+ style,
28
+ testID,
29
+ onError,
11
30
  ...props
12
31
  }: ImageProps) {
13
- return <SurfaceImage {...props} />;
32
+ const { theme } = useZoraTheme();
33
+ const resolvedSource = resolveImageSource(source);
34
+ const sourceKey = resolveImageSourceKey(source);
35
+ const firstFailureKeyRef = React.useRef<string | null>(null);
36
+ const [failedSourceKey, setFailedSourceKey] = React.useState<string | null>(null);
37
+ const showPlaceholder = resolvedSource === null || failedSourceKey === sourceKey;
38
+ const resolvedAlt = alt ?? (isUploadAsset(source) ? source.alt : undefined);
39
+ const placeholderLabel = accessibilityLabel ?? resolvedAlt;
40
+
41
+ if (showPlaceholder) {
42
+ return (
43
+ <Box
44
+ accessibilityLabel={placeholderLabel}
45
+ accessibilityRole={placeholderLabel ? 'image' : undefined}
46
+ accessible={Boolean(placeholderLabel)}
47
+ bg={theme.semantics.neutral.surface}
48
+ borderColor={theme.semantics.neutral.divider}
49
+ borderWidth={1}
50
+ height={height}
51
+ overflow="hidden"
52
+ radius={radius}
53
+ testID={testID}
54
+ width={width}
55
+ style={[
56
+ {
57
+ alignItems: 'center',
58
+ aspectRatio: aspectRatio ?? 1,
59
+ justifyContent: 'center',
60
+ },
61
+ style,
62
+ ]}
63
+ >
64
+ <Icon color={theme.semantics.content.muted} name="image-outline" size={24} />
65
+ </Box>
66
+ );
67
+ }
68
+
69
+ return (
70
+ <SurfaceImage
71
+ {...props}
72
+ accessibilityLabel={accessibilityLabel}
73
+ alt={resolvedAlt}
74
+ aspectRatio={aspectRatio}
75
+ fallbackSource={fallbackSource}
76
+ height={height}
77
+ onError={(event) => {
78
+ onError?.(event);
79
+
80
+ if (!sourceKey) {
81
+ return;
82
+ }
83
+
84
+ if (fallbackSource && firstFailureKeyRef.current !== sourceKey) {
85
+ firstFailureKeyRef.current = sourceKey;
86
+ return;
87
+ }
88
+
89
+ setFailedSourceKey(sourceKey);
90
+ }}
91
+ radius={radius}
92
+ source={resolvedSource}
93
+ style={style}
94
+ testID={testID}
95
+ width={width}
96
+ />
97
+ );
14
98
  }
15
99
 
16
- /***
17
- * Displays an image with cross-platform source and fit handling.
18
- *
19
-
20
- */
21
- export const Image = withZoraThemeScope(ImageInner);
100
+ /*** Resolves a generic upload asset into a source understood by the Surface Image primitive. */
101
+ function resolveImageSource(source: ImageProps['source']): SurfaceImageSource | null | undefined {
102
+ if (!isUploadAsset(source)) {
103
+ return source;
104
+ }
105
+
106
+ switch (source.kind) {
107
+ case 'local':
108
+ return source.uri;
109
+ case 'url':
110
+ return source.url;
111
+ case 'storage':
112
+ return source.publicUrl ?? null;
113
+ }
114
+ }
115
+
116
+ /*** Produces a stable-enough key for tracking image failures across source changes. */
117
+ function resolveImageSourceKey(source: ImageProps['source']): string | null {
118
+ if (source === null || source === undefined) {
119
+ return null;
120
+ }
121
+
122
+ if (isUploadAsset(source)) {
123
+ switch (source.kind) {
124
+ case 'local':
125
+ return `local:${source.uri}`;
126
+ case 'url':
127
+ return `url:${source.url}`;
128
+ case 'storage':
129
+ return `storage:${source.bucket}:${source.path}:${source.publicUrl ?? ''}`;
130
+ }
131
+ }
132
+
133
+ if (typeof source === 'string' || typeof source === 'number') {
134
+ return `source:${String(source)}`;
135
+ }
136
+
137
+ if (!Array.isArray(source) && 'uri' in source && typeof source.uri === 'string') {
138
+ return `source:${source.uri}`;
139
+ }
140
+
141
+ return 'source:opaque';
142
+ }
143
+
144
+ /*** Identifies the generic ZORA upload asset shape without leaking picker-specific types. */
145
+ function isUploadAsset(source: ImageProps['source']): source is UploadAsset {
146
+ return (
147
+ typeof source === 'object' && source !== null && !Array.isArray(source) && 'kind' in source
148
+ );
149
+ }
@@ -1,7 +1,11 @@
1
- import type { ImageProps as SurfaceImageProps } from '@ankhorage/surface';
1
+ import type { ImageProps as SurfaceImageProps, SurfaceImageSource } from '@ankhorage/surface';
2
2
 
3
3
  import type { ZoraBaseProps } from '../../theme/ZoraBaseProps';
4
+ import type { UploadAsset } from '../../types/upload';
4
5
 
5
6
  export type { ImageFit, SurfaceImageSource } from '@ankhorage/surface';
6
7
 
7
- export interface ImageProps extends ZoraBaseProps, Omit<SurfaceImageProps, 'mode' | 'themeId'> {}
8
+ export interface ImageProps
9
+ extends ZoraBaseProps, Omit<SurfaceImageProps, 'mode' | 'source' | 'themeId'> {
10
+ source?: SurfaceImageSource | UploadAsset | null;
11
+ }