@isoline-ui/react-native 0.0.0-stage → 1.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 (167) hide show
  1. package/LICENSE +22 -0
  2. package/NOTICE.md +12 -0
  3. package/README.md +104 -3
  4. package/dist/amount.d.ts +3 -0
  5. package/dist/calendar.d.ts +7 -0
  6. package/dist/components/action-chip.d.ts +9 -0
  7. package/dist/components/amount-field.d.ts +12 -0
  8. package/dist/components/avatar.d.ts +17 -0
  9. package/dist/components/back-button.d.ts +12 -0
  10. package/dist/components/back-icon.d.ts +3 -0
  11. package/dist/components/badge.d.ts +13 -0
  12. package/dist/components/banner.d.ts +14 -0
  13. package/dist/components/bottom-sheet-native.d.ts +11 -0
  14. package/dist/components/bottom-sheet-web.d.ts +18 -0
  15. package/dist/components/bottom-sheet.d.ts +3 -0
  16. package/dist/components/button-group.d.ts +26 -0
  17. package/dist/components/button.d.ts +48 -0
  18. package/dist/components/checkbox.d.ts +12 -0
  19. package/dist/components/color-picker.d.ts +21 -0
  20. package/dist/components/context-menu.d.ts +26 -0
  21. package/dist/components/date-picker.d.ts +64 -0
  22. package/dist/components/dialog-context.d.ts +2 -0
  23. package/dist/components/dialog.d.ts +20 -0
  24. package/dist/components/empty-state.d.ts +15 -0
  25. package/dist/components/expand-indicator.d.ts +27 -0
  26. package/dist/components/expressive-shape.d.ts +18 -0
  27. package/dist/components/fab-menu-legacy.d.ts +31 -0
  28. package/dist/components/fab-menu.d.ts +21 -0
  29. package/dist/components/filter-chip.d.ts +69 -0
  30. package/dist/components/floating-action-button.d.ts +15 -0
  31. package/dist/components/icon-button.d.ts +40 -0
  32. package/dist/components/icons.d.ts +18 -0
  33. package/dist/components/input.d.ts +9 -0
  34. package/dist/components/keyboard-inset-view.d.ts +15 -0
  35. package/dist/components/list-controls.d.ts +38 -0
  36. package/dist/components/loading-indicator.d.ts +9 -0
  37. package/dist/components/menu-sheet.d.ts +36 -0
  38. package/dist/components/panel-sheet.d.ts +8 -0
  39. package/dist/components/password-visibility.d.ts +5 -0
  40. package/dist/components/popover-layout.d.ts +25 -0
  41. package/dist/components/popover.d.ts +20 -0
  42. package/dist/components/pressable.d.ts +12 -0
  43. package/dist/components/radio-navigation.d.ts +11 -0
  44. package/dist/components/refresh-control.d.ts +16 -0
  45. package/dist/components/row-gap.d.ts +9 -0
  46. package/dist/components/search-field.d.ts +22 -0
  47. package/dist/components/select-field.d.ts +16 -0
  48. package/dist/components/settings-list.d.ts +98 -0
  49. package/dist/components/settings-row.d.ts +13 -0
  50. package/dist/components/sheet-frame.d.ts +27 -0
  51. package/dist/components/sheet-lifecycle.d.ts +36 -0
  52. package/dist/components/sheet-transition.d.ts +36 -0
  53. package/dist/components/slider.d.ts +21 -0
  54. package/dist/components/snackbar.d.ts +10 -0
  55. package/dist/components/split-button.d.ts +38 -0
  56. package/dist/components/squish.d.ts +20 -0
  57. package/dist/components/swipeable-row.d.ts +20 -0
  58. package/dist/components/switch.d.ts +8 -0
  59. package/dist/components/tab-bar.d.ts +20 -0
  60. package/dist/components/text-field.d.ts +31 -0
  61. package/dist/components/text.d.ts +9 -0
  62. package/dist/components/time-picker.d.ts +40 -0
  63. package/dist/components/toolbar.d.ts +77 -0
  64. package/dist/components/top-bar.d.ts +27 -0
  65. package/dist/components/undo-bar.d.ts +23 -0
  66. package/dist/components/wavy-progress.d.ts +21 -0
  67. package/dist/components/zoomable-image.d.ts +18 -0
  68. package/dist/expressive-shapes.d.ts +8 -0
  69. package/dist/index.d.ts +62 -0
  70. package/dist/keyboard.d.ts +2 -0
  71. package/dist/material.d.ts +11 -0
  72. package/dist/media.d.ts +11 -0
  73. package/dist/motion.d.ts +96 -0
  74. package/dist/platform.d.ts +3 -0
  75. package/dist/preset-tokens.d.ts +264 -0
  76. package/dist/provider.d.ts +59 -0
  77. package/dist/reduced-motion.d.ts +45 -0
  78. package/dist/selection.d.ts +2 -0
  79. package/dist/shape.d.ts +99 -0
  80. package/dist/sheet-physics.d.ts +37 -0
  81. package/dist/theme.d.ts +103 -0
  82. package/dist/tokens.d.ts +2 -0
  83. package/dist/use-keyboard-height.d.ts +10 -0
  84. package/dist/utils.d.ts +3 -0
  85. package/nativewind.cjs +68 -0
  86. package/package.json +63 -6
  87. package/src/amount.ts +53 -0
  88. package/src/calendar.ts +42 -0
  89. package/src/components/action-chip.tsx +64 -0
  90. package/src/components/amount-field.tsx +49 -0
  91. package/src/components/avatar.tsx +91 -0
  92. package/src/components/back-button.tsx +39 -0
  93. package/src/components/back-icon.tsx +25 -0
  94. package/src/components/badge.tsx +41 -0
  95. package/src/components/banner.tsx +63 -0
  96. package/src/components/bottom-sheet-native.tsx +52 -0
  97. package/src/components/bottom-sheet-web.tsx +193 -0
  98. package/src/components/bottom-sheet.tsx +5 -0
  99. package/src/components/button-group.tsx +326 -0
  100. package/src/components/button.tsx +337 -0
  101. package/src/components/checkbox.tsx +71 -0
  102. package/src/components/color-picker.tsx +115 -0
  103. package/src/components/context-menu.tsx +149 -0
  104. package/src/components/date-picker.tsx +466 -0
  105. package/src/components/dialog-context.ts +3 -0
  106. package/src/components/dialog.tsx +210 -0
  107. package/src/components/empty-state.tsx +49 -0
  108. package/src/components/expand-indicator.tsx +60 -0
  109. package/src/components/expressive-shape.tsx +37 -0
  110. package/src/components/fab-menu-legacy.tsx +181 -0
  111. package/src/components/fab-menu.tsx +90 -0
  112. package/src/components/filter-chip.tsx +136 -0
  113. package/src/components/floating-action-button.tsx +79 -0
  114. package/src/components/icon-button.tsx +127 -0
  115. package/src/components/icons.tsx +43 -0
  116. package/src/components/input.tsx +80 -0
  117. package/src/components/keyboard-inset-view.tsx +28 -0
  118. package/src/components/list-controls.tsx +134 -0
  119. package/src/components/loading-indicator.tsx +183 -0
  120. package/src/components/menu-sheet.tsx +133 -0
  121. package/src/components/panel-sheet.tsx +90 -0
  122. package/src/components/password-visibility.tsx +39 -0
  123. package/src/components/popover-layout.ts +47 -0
  124. package/src/components/popover.tsx +139 -0
  125. package/src/components/pressable.tsx +108 -0
  126. package/src/components/radio-navigation.ts +52 -0
  127. package/src/components/refresh-control.tsx +39 -0
  128. package/src/components/row-gap.tsx +13 -0
  129. package/src/components/search-field.tsx +92 -0
  130. package/src/components/select-field.tsx +105 -0
  131. package/src/components/settings-list.tsx +302 -0
  132. package/src/components/settings-row.tsx +61 -0
  133. package/src/components/sheet-frame.tsx +250 -0
  134. package/src/components/sheet-lifecycle.ts +233 -0
  135. package/src/components/sheet-transition.ts +105 -0
  136. package/src/components/slider.tsx +185 -0
  137. package/src/components/snackbar.tsx +97 -0
  138. package/src/components/split-button.tsx +212 -0
  139. package/src/components/squish.ts +41 -0
  140. package/src/components/swipeable-row.tsx +109 -0
  141. package/src/components/switch.tsx +77 -0
  142. package/src/components/tab-bar.tsx +195 -0
  143. package/src/components/text-field.tsx +264 -0
  144. package/src/components/text.tsx +58 -0
  145. package/src/components/time-picker.tsx +277 -0
  146. package/src/components/toolbar.tsx +406 -0
  147. package/src/components/top-bar.tsx +91 -0
  148. package/src/components/undo-bar.tsx +137 -0
  149. package/src/components/wavy-progress.tsx +150 -0
  150. package/src/components/zoomable-image.tsx +240 -0
  151. package/src/expressive-shapes.ts +25 -0
  152. package/src/index.ts +62 -0
  153. package/src/keyboard.ts +5 -0
  154. package/src/material.ts +16 -0
  155. package/src/media.ts +51 -0
  156. package/src/motion.ts +96 -0
  157. package/src/platform.ts +13 -0
  158. package/src/preset-tokens.ts +265 -0
  159. package/src/provider.tsx +201 -0
  160. package/src/reduced-motion.ts +50 -0
  161. package/src/selection.ts +11 -0
  162. package/src/shape.ts +217 -0
  163. package/src/sheet-physics.ts +73 -0
  164. package/src/theme.ts +180 -0
  165. package/src/tokens.ts +2 -0
  166. package/src/use-keyboard-height.ts +64 -0
  167. package/src/utils.ts +7 -0
@@ -0,0 +1,466 @@
1
+ import * as React from 'react';
2
+ import { Platform, View, ScrollView } from 'react-native';
3
+ import { Pressable } from './pressable.tsx';
4
+ import { Button } from './button.tsx';
5
+ import { Gesture, GestureDetector } from 'react-native-gesture-handler';
6
+ import { IconButton } from './icon-button.tsx';
7
+ import { X } from './icons.tsx';
8
+ import { Text } from './text.tsx';
9
+ import { BottomSheet } from './bottom-sheet.tsx';
10
+ import { useThemeColors, type MaterialOptions } from '../provider.tsx';
11
+ import { addDays, addMonths, monthDays, validDate, formatCalendarDate } from '../calendar.ts';
12
+
13
+ export interface CalendarLabels {
14
+ previousMonth: string;
15
+ nextMonth: string;
16
+ }
17
+ export interface CalendarMark {
18
+ label: string;
19
+ count?: number;
20
+ color?: string;
21
+ }
22
+ export interface MonthCalendarProps {
23
+ value: string | null;
24
+ onChange: (date: string) => void;
25
+ today: string;
26
+ locale?: string;
27
+ month?: string;
28
+ onMonthChange?: (month: string) => void;
29
+ firstDayOfWeek?: number;
30
+ minDate?: string;
31
+ maxDate?: string;
32
+ disabledDates?: readonly string[];
33
+ marks?: Record<string, CalendarMark>;
34
+ labels: CalendarLabels;
35
+ range?: { start: string; end: string } | null;
36
+ renderDayAccessory?: (date: string, selected: boolean) => React.ReactNode;
37
+ }
38
+ /** Date arithmetic and marks are independent from agenda repositories or app clocks. */
39
+ export function MonthCalendar({
40
+ value,
41
+ onChange,
42
+ today,
43
+ locale,
44
+ month: controlledMonth,
45
+ onMonthChange,
46
+ firstDayOfWeek = 1,
47
+ minDate,
48
+ maxDate,
49
+ disabledDates = [],
50
+ marks = {},
51
+ labels,
52
+ range,
53
+ renderDayAccessory,
54
+ }: MonthCalendarProps) {
55
+ const colors = useThemeColors();
56
+ const initial = validDate(value ?? '') ? value! : today;
57
+ const [localMonth, setMonth] = React.useState(initial.slice(0, 7));
58
+ const month = controlledMonth ?? localMonth;
59
+ const days = monthDays(month, firstDayOfWeek);
60
+ const refs = React.useRef<Record<string, React.ComponentRef<typeof Pressable> | null>>({});
61
+ const [pendingFocus, setPendingFocus] = React.useState<string | null>(null);
62
+ const disabled = (date: string) =>
63
+ Boolean(
64
+ (minDate && date < minDate) ||
65
+ (maxDate && date > maxDate) ||
66
+ disabledDates.includes(date),
67
+ );
68
+ const navigate = (next: string) => {
69
+ setMonth(next);
70
+ onMonthChange?.(next);
71
+ };
72
+ React.useEffect(() => {
73
+ if (pendingFocus && refs.current[pendingFocus]) {
74
+ refs.current[pendingFocus]?.focus();
75
+ setPendingFocus(null);
76
+ }
77
+ }, [month, pendingFocus]);
78
+ const focusDate =
79
+ days.find((date) => date === value && !disabled(date)) ??
80
+ days.find((date) => date === today && !disabled(date)) ??
81
+ days.find((date) => date.startsWith(month) && !disabled(date));
82
+ const content = (
83
+ <View style={{ gap: 8 }}>
84
+ <View
85
+ style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}
86
+ >
87
+ <Button
88
+ variant="ghost"
89
+ size="s"
90
+ label="‹"
91
+ accessibilityLabel={labels.previousMonth}
92
+ disabled={Boolean(minDate && addMonths(month, -1) < minDate.slice(0, 7))}
93
+ onPress={() => navigate(addMonths(month, -1))}
94
+ />
95
+ <Text
96
+ accessibilityRole="header"
97
+ accessibilityLiveRegion="polite"
98
+ style={{ fontWeight: '600' }}
99
+ >
100
+ {formatCalendarDate(`${month}-01`, locale, { month: 'long', year: 'numeric' })}
101
+ </Text>
102
+ <Button
103
+ variant="ghost"
104
+ size="s"
105
+ label="›"
106
+ accessibilityLabel={labels.nextMonth}
107
+ disabled={Boolean(maxDate && addMonths(month, 1) > maxDate.slice(0, 7))}
108
+ onPress={() => navigate(addMonths(month, 1))}
109
+ />
110
+ </View>
111
+ <View style={{ flexDirection: 'row' }}>
112
+ {days.slice(0, 7).map((date) => (
113
+ <Text
114
+ key={date}
115
+ accessible={false}
116
+ style={{ width: `${100 / 7}%`, textAlign: 'center', fontSize: 12 }}
117
+ >
118
+ {formatCalendarDate(date, locale, { weekday: 'short' })}
119
+ </Text>
120
+ ))}
121
+ </View>
122
+ <View style={{ flexDirection: 'row', flexWrap: 'wrap' }}>
123
+ {days.map((date) => {
124
+ const selected = date === value || date === range?.start || date === range?.end,
125
+ unavailable = disabled(date),
126
+ mark = marks[date];
127
+ return (
128
+ <Pressable
129
+ key={date}
130
+ ref={(node) => {
131
+ refs.current[date] = node;
132
+ }}
133
+ tabIndex={date === focusDate ? 0 : -1}
134
+ accessibilityRole="button"
135
+ accessibilityLabel={`${formatCalendarDate(date, locale, { dateStyle: 'full' })}${mark ? `, ${mark.label}` : ''}`}
136
+ accessibilityState={{ selected, disabled: unavailable }}
137
+ disabled={unavailable}
138
+ onPress={() => onChange(date)}
139
+ {...(Platform.OS === 'web'
140
+ ? {
141
+ onKeyDown: (
142
+ event: Parameters<
143
+ NonNullable<React.ComponentProps<typeof Pressable>['onKeyDown']>
144
+ >[0],
145
+ ) => {
146
+ const key = event.nativeEvent.key;
147
+ if (
148
+ ![
149
+ 'ArrowLeft',
150
+ 'ArrowRight',
151
+ 'ArrowUp',
152
+ 'ArrowDown',
153
+ 'Home',
154
+ 'End',
155
+ 'PageUp',
156
+ 'PageDown',
157
+ ].includes(key)
158
+ )
159
+ return;
160
+ event.preventDefault();
161
+ const weekday = days.indexOf(date) % 7;
162
+ const jump =
163
+ key === 'ArrowLeft'
164
+ ? -1
165
+ : key === 'ArrowRight'
166
+ ? 1
167
+ : key === 'ArrowUp'
168
+ ? -7
169
+ : key === 'ArrowDown'
170
+ ? 7
171
+ : key === 'Home'
172
+ ? -weekday
173
+ : key === 'End'
174
+ ? 6 - weekday
175
+ : key === 'PageUp'
176
+ ? -28
177
+ : 28;
178
+ let next = addDays(date, jump);
179
+ for (let count = 0; count < 366 && disabled(next); count++)
180
+ next = addDays(next, jump < 0 ? -1 : 1);
181
+ if (
182
+ disabled(next) ||
183
+ (minDate && next < minDate) ||
184
+ (maxDate && next > maxDate)
185
+ )
186
+ return;
187
+ if (!days.includes(next)) navigate(next.slice(0, 7));
188
+ setPendingFocus(next);
189
+ },
190
+ }
191
+ : {})}
192
+ style={{
193
+ width: `${100 / 7}%`,
194
+ minHeight: 44,
195
+ alignItems: 'center',
196
+ justifyContent: 'center',
197
+ gap: 2,
198
+ borderRadius: 22,
199
+ backgroundColor: selected
200
+ ? colors.primary
201
+ : range && date > range.start && date < range.end
202
+ ? colors.secondaryContainer
203
+ : 'transparent',
204
+ borderWidth: date === today ? 1 : 0,
205
+ borderColor: colors.primary,
206
+ opacity: unavailable ? 0.3 : date.startsWith(month) ? 1 : 0.5,
207
+ }}
208
+ >
209
+ <Text style={{ color: selected ? colors.onPrimary : colors.onSurface }}>
210
+ {Number(date.slice(8))}
211
+ </Text>
212
+ {renderDayAccessory ? (
213
+ renderDayAccessory(date, selected)
214
+ ) : mark ? (
215
+ <View
216
+ style={{
217
+ height: 4,
218
+ width: Math.min(20, Math.max(1, mark.count ?? 1) * 4),
219
+ backgroundColor: selected
220
+ ? colors.onPrimary
221
+ : (mark.color ?? colors.primary),
222
+ borderRadius: 2,
223
+ }}
224
+ />
225
+ ) : null}
226
+ </Pressable>
227
+ );
228
+ })}
229
+ </View>
230
+ </View>
231
+ );
232
+ const turn = (delta: number) => {
233
+ const next = addMonths(month, delta);
234
+ if ((minDate && next < minDate.slice(0, 7)) || (maxDate && next > maxDate.slice(0, 7)))
235
+ return;
236
+ navigate(next);
237
+ };
238
+ const swipe = Gesture.Race(
239
+ Gesture.Fling()
240
+ .direction(1)
241
+ .runOnJS(true)
242
+ .onEnd(() => turn(-1)),
243
+ Gesture.Fling()
244
+ .direction(2)
245
+ .runOnJS(true)
246
+ .onEnd(() => turn(1)),
247
+ );
248
+ return Platform.OS === 'web' ? (
249
+ content
250
+ ) : (
251
+ <GestureDetector gesture={swipe}>{content}</GestureDetector>
252
+ );
253
+ }
254
+ export interface WeekStripProps extends Pick<
255
+ MonthCalendarProps,
256
+ 'value' | 'onChange' | 'today' | 'locale' | 'marks' | 'minDate' | 'maxDate'
257
+ > {
258
+ startDate: string;
259
+ renderDayAccessory?: MonthCalendarProps['renderDayAccessory'];
260
+ }
261
+ export function WeekStrip({
262
+ startDate,
263
+ value,
264
+ onChange,
265
+ today,
266
+ locale,
267
+ marks = {},
268
+ minDate,
269
+ maxDate,
270
+ renderDayAccessory,
271
+ }: WeekStripProps) {
272
+ const colors = useThemeColors();
273
+ return (
274
+ <View style={{ flexDirection: 'row', gap: 2 }}>
275
+ {Array.from({ length: 7 }, (_, i) => addDays(startDate, i)).map((date) => (
276
+ <Pressable
277
+ key={date}
278
+ accessibilityRole="button"
279
+ accessibilityLabel={`${formatCalendarDate(date, locale, { dateStyle: 'full' })}${marks[date] ? `, ${marks[date].label}` : ''}`}
280
+ accessibilityState={{
281
+ selected: date === value,
282
+ disabled: Boolean((minDate && date < minDate) || (maxDate && date > maxDate)),
283
+ }}
284
+ disabled={Boolean((minDate && date < minDate) || (maxDate && date > maxDate))}
285
+ onPress={() => onChange(date)}
286
+ style={{
287
+ flex: 1,
288
+ minWidth: 0,
289
+ minHeight: 56,
290
+ borderRadius: 16,
291
+ alignItems: 'center',
292
+ justifyContent: 'center',
293
+ backgroundColor: date === value ? colors.secondaryContainer : 'transparent',
294
+ borderColor: colors.outline,
295
+ borderWidth: date === today ? 1 : 0,
296
+ }}
297
+ >
298
+ <Text style={{ fontSize: 11 }}>
299
+ {formatCalendarDate(date, locale, { weekday: 'short' })}
300
+ </Text>
301
+ <Text>{Number(date.slice(8))}</Text>
302
+ {renderDayAccessory ? (
303
+ renderDayAccessory(date, date === value)
304
+ ) : marks[date] ? (
305
+ <View
306
+ style={{ width: 4, height: 4, borderRadius: 2, backgroundColor: colors.primary }}
307
+ />
308
+ ) : null}
309
+ </Pressable>
310
+ ))}
311
+ </View>
312
+ );
313
+ }
314
+ export interface QuickDateOption {
315
+ value: string;
316
+ label: string;
317
+ disabled?: boolean;
318
+ }
319
+ export interface DayPickerProps extends MonthCalendarProps, MaterialOptions {
320
+ quickDates?: QuickDateOption[];
321
+ onClear?: () => void;
322
+ clearLabel?: string;
323
+ }
324
+ export function DayPicker({
325
+ quickDates = [],
326
+ onClear,
327
+ clearLabel,
328
+ ...calendar
329
+ }: DayPickerProps) {
330
+ return (
331
+ <View style={{ gap: 12 }}>
332
+ <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
333
+ {quickDates.map((option) => (
334
+ <Button
335
+ key={option.value}
336
+ size="s"
337
+ variant={calendar.value === option.value ? 'default' : 'tonal'}
338
+ label={option.label}
339
+ disabled={
340
+ option.disabled ||
341
+ !validDate(option.value) ||
342
+ Boolean(
343
+ (calendar.minDate && option.value < calendar.minDate) ||
344
+ (calendar.maxDate && option.value > calendar.maxDate) ||
345
+ calendar.disabledDates?.includes(option.value),
346
+ )
347
+ }
348
+ onPress={() => calendar.onChange(option.value)}
349
+ />
350
+ ))}
351
+ </View>
352
+ <MonthCalendar {...calendar} />
353
+ {onClear && clearLabel ? (
354
+ <Button variant="ghost" size="s" label={clearLabel} onPress={onClear} />
355
+ ) : null}
356
+ </View>
357
+ );
358
+ }
359
+ export interface DatePickerSheetProps extends MonthCalendarProps, MaterialOptions {
360
+ visible: boolean;
361
+ onClose: () => void;
362
+ title: string;
363
+ quickDates?: QuickDateOption[];
364
+ onClear?: () => void;
365
+ clearLabel?: string;
366
+ }
367
+ export function DatePickerSheet({
368
+ visible,
369
+ onClose,
370
+ title,
371
+ quickDates = [],
372
+ onClear,
373
+ clearLabel,
374
+ material = 'solid',
375
+ glassStyle,
376
+ tintColor,
377
+ fallbackColor,
378
+ interactive,
379
+ ...calendar
380
+ }: DatePickerSheetProps) {
381
+ const select = (date: string) => {
382
+ calendar.onChange(date);
383
+ onClose();
384
+ };
385
+ return (
386
+ <BottomSheet
387
+ visible={visible}
388
+ onClose={onClose}
389
+ material={material}
390
+ glassStyle={glassStyle}
391
+ tintColor={tintColor}
392
+ fallbackColor={fallbackColor}
393
+ interactive={interactive}
394
+ >
395
+ <ScrollView
396
+ keyboardShouldPersistTaps="handled"
397
+ style={{ flexGrow: 0 }}
398
+ contentContainerStyle={{ gap: 12 }}
399
+ >
400
+ <Text variant="heading">{title}</Text>
401
+ <DayPicker
402
+ {...calendar}
403
+ onChange={select}
404
+ quickDates={quickDates}
405
+ clearLabel={clearLabel}
406
+ onClear={
407
+ onClear
408
+ ? () => {
409
+ onClear();
410
+ onClose();
411
+ }
412
+ : undefined
413
+ }
414
+ />
415
+ </ScrollView>
416
+ </BottomSheet>
417
+ );
418
+ }
419
+ export interface DateFieldProps extends Omit<
420
+ DatePickerSheetProps,
421
+ 'visible' | 'onClose' | 'title' | 'onChange' | 'onClear'
422
+ > {
423
+ label: string;
424
+ emptyLabel: string;
425
+ onChange: (date: string | null) => void;
426
+ disabled?: boolean;
427
+ }
428
+ export function DateField({ label, emptyLabel, onChange, disabled, ...props }: DateFieldProps) {
429
+ const [open, setOpen] = React.useState(false);
430
+ React.useEffect(() => {
431
+ if (disabled) setOpen(false);
432
+ }, [disabled]);
433
+ return (
434
+ <View style={{ gap: 4 }}>
435
+ <Text variant="caption">{label}</Text>
436
+ <View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
437
+ <View style={{ flex: 1 }}>
438
+ <Button
439
+ variant="tonal"
440
+ size="s"
441
+ accessibilityLabel={label}
442
+ disabled={disabled}
443
+ label={props.value ? formatCalendarDate(props.value, props.locale) : emptyLabel}
444
+ onPress={() => setOpen(true)}
445
+ />
446
+ </View>
447
+ {props.value && props.clearLabel ? (
448
+ <IconButton
449
+ icon={X}
450
+ accessibilityLabel={props.clearLabel}
451
+ disabled={disabled}
452
+ onPress={() => onChange(null)}
453
+ />
454
+ ) : null}
455
+ </View>
456
+ <DatePickerSheet
457
+ {...props}
458
+ title={label}
459
+ visible={open}
460
+ onClose={() => setOpen(false)}
461
+ onChange={onChange}
462
+ onClear={() => onChange(null)}
463
+ />
464
+ </View>
465
+ );
466
+ }
@@ -0,0 +1,3 @@
1
+ import { createContext } from 'react';
2
+ /** Keeps iOS alert actions styled together without changing normal buttons. */
3
+ export const DialogActionContext = createContext(false);
@@ -0,0 +1,210 @@
1
+ import * as React from 'react';
2
+ import { KeyboardAvoidingView, Modal, Platform, ScrollView, View } from 'react-native';
3
+ import Animated, {
4
+ useAnimatedStyle,
5
+ useSharedValue,
6
+ withSpring,
7
+ withTiming,
8
+ } from 'react-native-reanimated';
9
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
10
+
11
+ import { DialogActionContext } from './dialog-context.ts';
12
+ import { Pressable } from './pressable.tsx';
13
+ import { effects, spatial } from '../motion.ts';
14
+ import {
15
+ Surface,
16
+ type MaterialOptions,
17
+ ThemeScope,
18
+ useThemeColors,
19
+ useIsolinePlatform,
20
+ elevation,
21
+ } from '../provider.tsx';
22
+ import { useReducedMotion } from '../reduced-motion.ts';
23
+
24
+ /** How long the scrim takes to dim — matches the bottom sheet's own fade. */
25
+ const SCRIM_FADE_MS = 120;
26
+
27
+ /**
28
+ * The open transition for a dialog: the scrim dims while the panel scales up
29
+ * from its resting point and fades in, rather than sliding from an edge —
30
+ * a dialog is anchored to the middle of the screen, so nothing here has an
31
+ * edge to travel from.
32
+ */
33
+ function useDialogTransition(visible: boolean, preserveOpacity: boolean) {
34
+ const reducedMotion = useReducedMotion();
35
+ const dim = useSharedValue(0);
36
+ const scale = useSharedValue(0.85);
37
+ const opacity = useSharedValue(0);
38
+
39
+ React.useEffect(() => {
40
+ if (visible) {
41
+ dim.value = reducedMotion ? 1 : withTiming(1, { duration: SCRIM_FADE_MS });
42
+ scale.value = reducedMotion ? 1 : withSpring(1, spatial.default);
43
+ opacity.value = reducedMotion ? 1 : withSpring(1, effects.default);
44
+ return;
45
+ }
46
+
47
+ // `Modal` unmounts its children as it hides, so there is nothing left to
48
+ // animate out — this only rearms the transition for the next open.
49
+ dim.value = 0;
50
+ scale.value = 0.85;
51
+ opacity.value = 0;
52
+ }, [dim, scale, opacity, reducedMotion, visible]);
53
+
54
+ const scrimStyle = useAnimatedStyle(() => ({ opacity: dim.value }));
55
+ const panelStyle = useAnimatedStyle(() => ({
56
+ opacity: preserveOpacity ? 1 : opacity.value,
57
+ transform: [{ scale: scale.value }],
58
+ }));
59
+
60
+ return { scrimStyle, panelStyle };
61
+ }
62
+
63
+ export interface DialogProps extends MaterialOptions {
64
+ visible: boolean;
65
+ onClose: () => void;
66
+ /** Runs once the dialog is actually on screen — for one that focuses an input. */
67
+ onShow?: () => void;
68
+ children: React.ReactNode;
69
+ }
70
+
71
+ /**
72
+ * The app's Material 3 dialog: a scrim, and a panel centred on screen that
73
+ * scales up into place. Used for a short, focused decision — naming
74
+ * something, confirming an action — where a bottom sheet's list-of-options
75
+ * shape would be the wrong read.
76
+ */
77
+ export function Dialog({
78
+ visible,
79
+ onClose,
80
+ onShow,
81
+ children,
82
+ material = 'solid',
83
+ glassStyle,
84
+ tintColor,
85
+ fallbackColor,
86
+ interactive = false,
87
+ }: DialogProps) {
88
+ const ios = useIsolinePlatform() === 'ios';
89
+ const glass = ios && material !== 'solid';
90
+ const { scrimStyle, panelStyle } = useDialogTransition(visible, glass);
91
+ const insets = useSafeAreaInsets();
92
+ const web = Platform.OS === 'web';
93
+ const Box = (web ? View : Animated.View) as typeof Animated.View;
94
+ const colors = useThemeColors();
95
+
96
+ return (
97
+ <Modal
98
+ visible={visible}
99
+ transparent
100
+ presentationStyle="overFullScreen"
101
+ animationType="none"
102
+ onRequestClose={onClose}
103
+ onShow={onShow}
104
+ >
105
+ <ThemeScope style={{ flex: 1 }}>
106
+ <KeyboardAvoidingView
107
+ style={{ flex: 1 }}
108
+ behavior="padding"
109
+ enabled={Platform.OS === 'ios'}
110
+ >
111
+ <Box
112
+ style={[
113
+ {
114
+ flex: 1,
115
+ alignItems: 'center',
116
+ justifyContent: 'center',
117
+ paddingHorizontal: 24,
118
+ paddingTop: insets.top + 24,
119
+ paddingBottom: insets.bottom + 24,
120
+ },
121
+ ]}
122
+ className="flex-1 items-center justify-center px-6"
123
+ >
124
+ <Box
125
+ pointerEvents="none"
126
+ style={[
127
+ {
128
+ position: 'absolute',
129
+ top: 0,
130
+ right: 0,
131
+ bottom: 0,
132
+ left: 0,
133
+ backgroundColor: 'rgba(0,0,0,0.6)',
134
+ },
135
+ !web && scrimStyle,
136
+ ]}
137
+ />
138
+ <Pressable
139
+ testID="isoline-dialog-backdrop"
140
+ className="absolute inset-0"
141
+ onPress={onClose}
142
+ accessible={false}
143
+ />
144
+
145
+ <Box
146
+ testID="isoline-dialog-panel"
147
+ accessibilityViewIsModal
148
+ onAccessibilityEscape={onClose}
149
+ className="w-full max-w-sm rounded-[28px] bg-popover"
150
+ style={[
151
+ !web && panelStyle,
152
+ {
153
+ width: '100%',
154
+ ...(glass
155
+ ? { backgroundColor: 'transparent' }
156
+ : web
157
+ ? { backgroundColor: colors.surfaceContainerLow }
158
+ : {}),
159
+ maxHeight: '100%',
160
+ elevation: ios ? 0 : elevation.level3,
161
+ borderRadius: ios ? 14 : 28,
162
+ maxWidth: ios ? 320 : 384,
163
+ },
164
+ ]}
165
+ >
166
+ {glass ? (
167
+ <Surface
168
+ material={material}
169
+ glassStyle={glassStyle}
170
+ tintColor={tintColor}
171
+ fallbackColor={fallbackColor}
172
+ interactive={interactive}
173
+ pointerEvents="none"
174
+ style={{
175
+ position: 'absolute',
176
+ top: 0,
177
+ right: 0,
178
+ bottom: 0,
179
+ left: 0,
180
+ borderRadius: ios ? 14 : 28,
181
+ }}
182
+ />
183
+ ) : null}
184
+ <ScrollView
185
+ style={{ flexGrow: 0 }}
186
+ contentContainerStyle={{ padding: 24, gap: 16 }}
187
+ keyboardShouldPersistTaps="handled"
188
+ keyboardDismissMode={Platform.OS === 'ios' ? 'interactive' : 'on-drag'}
189
+ >
190
+ {children}
191
+ </ScrollView>
192
+ </Box>
193
+ </Box>
194
+ </KeyboardAvoidingView>
195
+ </ThemeScope>
196
+ </Modal>
197
+ );
198
+ }
199
+
200
+ /** A dialog's row of text actions, right-aligned per M3. */
201
+ export function DialogActions({ children }: { children: React.ReactNode }) {
202
+ const ios = useIsolinePlatform() === 'ios';
203
+ return (
204
+ <DialogActionContext.Provider value={ios}>
205
+ <View className={ios ? 'flex-row justify-center' : 'flex-row justify-end gap-2'}>
206
+ {children}
207
+ </View>
208
+ </DialogActionContext.Provider>
209
+ );
210
+ }