@elvora/react-native 1.0.0-rc.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 (98) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +40 -0
  3. package/dist/index.cjs +5785 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +1253 -0
  6. package/dist/index.d.ts +1253 -0
  7. package/dist/index.js +5683 -0
  8. package/dist/index.js.map +1 -0
  9. package/package.json +88 -0
  10. package/src/Accordion.tsx +11 -0
  11. package/src/Affix.tsx +20 -0
  12. package/src/Alert.tsx +102 -0
  13. package/src/Anchor.tsx +58 -0
  14. package/src/AutoComplete.tsx +122 -0
  15. package/src/Avatar.tsx +58 -0
  16. package/src/BackTop.tsx +71 -0
  17. package/src/Backdrop.tsx +32 -0
  18. package/src/Badge.tsx +87 -0
  19. package/src/Box.tsx +67 -0
  20. package/src/Breadcrumb.tsx +46 -0
  21. package/src/Button.test.tsx +39 -0
  22. package/src/Button.tsx +127 -0
  23. package/src/ButtonGroup.tsx +74 -0
  24. package/src/Calendar.tsx +165 -0
  25. package/src/Card.tsx +69 -0
  26. package/src/Carousel.tsx +99 -0
  27. package/src/Cascader.tsx +160 -0
  28. package/src/Checkbox.tsx +85 -0
  29. package/src/ChipInput.tsx +130 -0
  30. package/src/Collapse.tsx +120 -0
  31. package/src/ColorPicker.tsx +114 -0
  32. package/src/Container.tsx +22 -0
  33. package/src/DataGrid.tsx +170 -0
  34. package/src/DatePicker.tsx +195 -0
  35. package/src/DateRangePicker.tsx +249 -0
  36. package/src/Descriptions.tsx +98 -0
  37. package/src/Divider.tsx +32 -0
  38. package/src/Drawer.tsx +103 -0
  39. package/src/Dropdown.tsx +15 -0
  40. package/src/ElvoraProvider.tsx +31 -0
  41. package/src/Empty.tsx +34 -0
  42. package/src/FloatButton.tsx +78 -0
  43. package/src/Form.tsx +119 -0
  44. package/src/Grid.tsx +68 -0
  45. package/src/Icon.tsx +49 -0
  46. package/src/IconButton.tsx +28 -0
  47. package/src/Image.tsx +68 -0
  48. package/src/ImageList.tsx +58 -0
  49. package/src/Input.tsx +87 -0
  50. package/src/Label.tsx +46 -0
  51. package/src/List.tsx +82 -0
  52. package/src/Mentions.tsx +148 -0
  53. package/src/Menu.tsx +77 -0
  54. package/src/Modal.tsx +114 -0
  55. package/src/NumberInput.tsx +156 -0
  56. package/src/Pagination.tsx +148 -0
  57. package/src/PaginationVariants.tsx +64 -0
  58. package/src/Popover.tsx +74 -0
  59. package/src/ProForm.tsx +219 -0
  60. package/src/ProLayout.tsx +151 -0
  61. package/src/ProTable.tsx +91 -0
  62. package/src/Progress.tsx +92 -0
  63. package/src/QRCode.tsx +65 -0
  64. package/src/Radio.tsx +98 -0
  65. package/src/Rate.tsx +66 -0
  66. package/src/Result.tsx +64 -0
  67. package/src/Segmented.tsx +75 -0
  68. package/src/Select.tsx +146 -0
  69. package/src/Skeleton.tsx +49 -0
  70. package/src/Slider.tsx +122 -0
  71. package/src/SpeedDial.tsx +87 -0
  72. package/src/Spinner.tsx +29 -0
  73. package/src/Splitter.tsx +91 -0
  74. package/src/Stack.tsx +38 -0
  75. package/src/Statistic.tsx +60 -0
  76. package/src/Stepper.tsx +113 -0
  77. package/src/Steps.tsx +146 -0
  78. package/src/Switch.tsx +52 -0
  79. package/src/Table.tsx +178 -0
  80. package/src/Tabs.tsx +122 -0
  81. package/src/Tag.tsx +83 -0
  82. package/src/Textarea.tsx +22 -0
  83. package/src/TimePicker.tsx +187 -0
  84. package/src/Timeline.tsx +92 -0
  85. package/src/Toast.tsx +140 -0
  86. package/src/ToggleButton.tsx +66 -0
  87. package/src/Tooltip.tsx +56 -0
  88. package/src/Tour.tsx +118 -0
  89. package/src/Transfer.tsx +219 -0
  90. package/src/Tree.tsx +144 -0
  91. package/src/TreeSelect.tsx +221 -0
  92. package/src/Upload.tsx +109 -0
  93. package/src/Watermark.tsx +76 -0
  94. package/src/index.ts +221 -0
  95. package/src/smoke.test.tsx +113 -0
  96. package/src/test/react-native-stub.tsx +413 -0
  97. package/src/test/react-native-svg-stub.tsx +33 -0
  98. package/src/test/setup.ts +7 -0
@@ -0,0 +1,170 @@
1
+ import { useCallback, useMemo, useState, type ReactNode } from 'react';
2
+ import { FlatList, Pressable, ScrollView, Text, View, type ViewProps } from 'react-native';
3
+ import { useTheme } from './ElvoraProvider';
4
+
5
+ export type DataGridSortOrder = 'asc' | 'desc' | null;
6
+
7
+ export interface DataGridColumn<Row = Record<string, unknown>> {
8
+ key: string;
9
+ title: ReactNode;
10
+ dataIndex?: keyof Row | string;
11
+ width?: number;
12
+ render?: (value: unknown, row: Row, index: number) => ReactNode;
13
+ sortable?: boolean;
14
+ sorter?: (a: Row, b: Row) => number;
15
+ align?: 'left' | 'center' | 'right';
16
+ }
17
+
18
+ export interface DataGridProps<Row = Record<string, unknown>> extends Omit<ViewProps, 'children'> {
19
+ columns: DataGridColumn<Row>[];
20
+ dataSource: Row[];
21
+ rowKey?: keyof Row | ((row: Row, index: number) => string | number);
22
+ rowHeight?: number;
23
+ height?: number;
24
+ empty?: ReactNode;
25
+ onRowPress?: (row: Row, index: number) => void;
26
+ }
27
+
28
+ function defaultCompare(a: unknown, b: unknown): number {
29
+ if (a == null && b == null) return 0;
30
+ if (a == null) return -1;
31
+ if (b == null) return 1;
32
+ if (typeof a === 'number' && typeof b === 'number') return a - b;
33
+ return String(a).localeCompare(String(b));
34
+ }
35
+
36
+ export function DataGrid<Row extends Record<string, unknown>>(props: DataGridProps<Row>) {
37
+ const {
38
+ columns,
39
+ dataSource,
40
+ rowKey,
41
+ rowHeight = 40,
42
+ height = 400,
43
+ empty,
44
+ onRowPress,
45
+ style,
46
+ ...rest
47
+ } = props;
48
+ const theme = useTheme();
49
+ const [sortKey, setSortKey] = useState<string | null>(null);
50
+ const [sortOrder, setSortOrder] = useState<DataGridSortOrder>(null);
51
+
52
+ const sorted = useMemo(() => {
53
+ if (!sortKey || !sortOrder) return dataSource;
54
+ const col = columns.find((c) => c.key === sortKey);
55
+ if (!col || !col.sortable) return dataSource;
56
+ const arr = [...dataSource];
57
+ const compare = col.sorter
58
+ ? col.sorter
59
+ : (a: Row, b: Row) => defaultCompare(a[col.dataIndex as keyof Row], b[col.dataIndex as keyof Row]);
60
+ arr.sort((a, b) => (sortOrder === 'asc' ? compare(a, b) : -compare(a, b)));
61
+ return arr;
62
+ }, [columns, dataSource, sortKey, sortOrder]);
63
+
64
+ const toggleSort = useCallback(
65
+ (key: string) => {
66
+ if (sortKey !== key) {
67
+ setSortKey(key);
68
+ setSortOrder('asc');
69
+ return;
70
+ }
71
+ if (sortOrder === 'asc') setSortOrder('desc');
72
+ else if (sortOrder === 'desc') {
73
+ setSortKey(null);
74
+ setSortOrder(null);
75
+ } else setSortOrder('asc');
76
+ },
77
+ [sortKey, sortOrder],
78
+ );
79
+
80
+ const getRowKey = (row: Row, idx: number): string => {
81
+ if (typeof rowKey === 'function') return String(rowKey(row, idx));
82
+ if (rowKey) return String((row as Record<string, unknown>)[rowKey as string] ?? idx);
83
+ return String(idx);
84
+ };
85
+
86
+ if (sorted.length === 0 && empty) {
87
+ return (
88
+ <View accessibilityRole="text" style={style}>
89
+ {typeof empty === 'string' ? <Text>{empty}</Text> : empty}
90
+ </View>
91
+ );
92
+ }
93
+
94
+ return (
95
+ <ScrollView horizontal showsHorizontalScrollIndicator={false} style={style} {...rest}>
96
+ <View
97
+ style={{
98
+ borderWidth: 1,
99
+ borderColor: theme.colors.border,
100
+ borderRadius: 8,
101
+ backgroundColor: theme.colors.surfaceElevated,
102
+ overflow: 'hidden',
103
+ }}
104
+ >
105
+ <View
106
+ style={{
107
+ flexDirection: 'row',
108
+ backgroundColor: theme.colors.intent.neutral.subtle,
109
+ borderBottomWidth: 1,
110
+ borderBottomColor: theme.colors.border,
111
+ }}
112
+ >
113
+ {columns.map((col) => (
114
+ <Pressable
115
+ key={col.key}
116
+ accessibilityRole={col.sortable ? 'button' : undefined}
117
+ onPress={col.sortable ? () => toggleSort(col.key) : undefined}
118
+ style={{ width: col.width ?? 120, paddingHorizontal: 12, paddingVertical: 8 }}
119
+ >
120
+ <Text style={{ color: theme.colors.fg, fontWeight: '600', fontSize: 14 }}>
121
+ {typeof col.title === 'string' ? col.title : null}
122
+ {col.sortable
123
+ ? sortKey === col.key
124
+ ? sortOrder === 'asc'
125
+ ? ' ↑'
126
+ : sortOrder === 'desc'
127
+ ? ' ↓'
128
+ : ''
129
+ : ' ⇅'
130
+ : ''}
131
+ </Text>
132
+ </Pressable>
133
+ ))}
134
+ </View>
135
+ <FlatList<Row>
136
+ data={sorted}
137
+ keyExtractor={(row, idx) => getRowKey(row, idx)}
138
+ style={{ height }}
139
+ getItemLayout={(_data, index) => ({ length: rowHeight, offset: rowHeight * index, index })}
140
+ renderItem={({ item: row, index: rowIndex }) => (
141
+ <Pressable
142
+ onPress={onRowPress ? () => onRowPress(row, rowIndex) : undefined}
143
+ style={{
144
+ flexDirection: 'row',
145
+ height: rowHeight,
146
+ borderBottomWidth: 1,
147
+ borderBottomColor: theme.colors.border,
148
+ alignItems: 'center',
149
+ }}
150
+ >
151
+ {columns.map((col) => {
152
+ const value = col.dataIndex !== undefined ? (row as Record<string, unknown>)[col.dataIndex as string] : undefined;
153
+ const content = col.render ? col.render(value, row, rowIndex) : (value as ReactNode);
154
+ return (
155
+ <View key={col.key} style={{ width: col.width ?? 120, paddingHorizontal: 12 }}>
156
+ {typeof content === 'string' || typeof content === 'number' ? (
157
+ <Text style={{ color: theme.colors.fg, fontSize: 14 }}>{String(content)}</Text>
158
+ ) : (
159
+ content
160
+ )}
161
+ </View>
162
+ );
163
+ })}
164
+ </Pressable>
165
+ )}
166
+ />
167
+ </View>
168
+ </ScrollView>
169
+ );
170
+ }
@@ -0,0 +1,195 @@
1
+ import { useMemo, useState } from 'react';
2
+ import { Modal, Pressable, View, Text, type ViewProps } from 'react-native';
3
+ import { useTheme } from './ElvoraProvider';
4
+
5
+ export type DateValue = Date | null;
6
+
7
+ export interface DatePickerProps extends Omit<ViewProps, 'onChange'> {
8
+ value?: DateValue;
9
+ defaultValue?: DateValue;
10
+ onChange?: (value: DateValue) => void;
11
+ min?: Date;
12
+ max?: Date;
13
+ placeholder?: string;
14
+ isDisabled?: boolean;
15
+ weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
16
+ format?: (d: Date) => string;
17
+ }
18
+
19
+ function pad(n: number) {
20
+ return String(n).padStart(2, '0');
21
+ }
22
+
23
+ const defaultFormat = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
24
+
25
+ function isSameDay(a: Date | null, b: Date | null): boolean {
26
+ if (!a || !b) return false;
27
+ return a.toDateString() === b.toDateString();
28
+ }
29
+
30
+ function startOfMonth(d: Date) {
31
+ return new Date(d.getFullYear(), d.getMonth(), 1);
32
+ }
33
+
34
+ function addMonths(d: Date, n: number) {
35
+ return new Date(d.getFullYear(), d.getMonth() + n, 1);
36
+ }
37
+
38
+ function buildMatrix(view: Date, weekStartsOn: number): Date[][] {
39
+ const first = startOfMonth(view);
40
+ const firstWeekday = (first.getDay() - weekStartsOn + 7) % 7;
41
+ const start = new Date(first);
42
+ start.setDate(first.getDate() - firstWeekday);
43
+ const weeks: Date[][] = [];
44
+ for (let w = 0; w < 6; w++) {
45
+ const row: Date[] = [];
46
+ for (let d = 0; d < 7; d++) {
47
+ const day = new Date(start);
48
+ day.setDate(start.getDate() + w * 7 + d);
49
+ row.push(day);
50
+ }
51
+ weeks.push(row);
52
+ }
53
+ return weeks;
54
+ }
55
+
56
+ export function DatePicker(props: DatePickerProps) {
57
+ const {
58
+ value: valueProp,
59
+ defaultValue = null,
60
+ onChange,
61
+ min,
62
+ max,
63
+ placeholder = 'YYYY-MM-DD',
64
+ isDisabled,
65
+ weekStartsOn = 0,
66
+ format = defaultFormat,
67
+ style,
68
+ ...rest
69
+ } = props;
70
+ const theme = useTheme();
71
+ const [internal, setInternal] = useState<DateValue>(defaultValue);
72
+ const value = valueProp !== undefined ? valueProp : internal;
73
+ const [isOpen, setOpen] = useState(false);
74
+ const [view, setView] = useState<Date>(value ?? new Date());
75
+
76
+ const matrix = useMemo(() => buildMatrix(view, weekStartsOn), [view, weekStartsOn]);
77
+ const weekdays = useMemo(() => {
78
+ const arr: string[] = [];
79
+ const base = new Date(2024, 0, 7);
80
+ for (let i = 0; i < 7; i++) {
81
+ const d = new Date(base);
82
+ d.setDate(base.getDate() + ((i + weekStartsOn) % 7));
83
+ arr.push(d.toLocaleString(undefined, { weekday: 'short' }));
84
+ }
85
+ return arr;
86
+ }, [weekStartsOn]);
87
+
88
+ const pick = (d: Date) => {
89
+ if (valueProp === undefined) setInternal(d);
90
+ onChange?.(d);
91
+ setOpen(false);
92
+ };
93
+
94
+ const display = value ? format(value) : '';
95
+
96
+ return (
97
+ <View style={style} {...rest}>
98
+ <Pressable
99
+ accessibilityRole="button"
100
+ disabled={isDisabled}
101
+ onPress={() => setOpen(true)}
102
+ style={({ pressed }) => ({
103
+ paddingHorizontal: 12,
104
+ paddingVertical: 8,
105
+ borderWidth: 1,
106
+ borderColor: theme.colors.border,
107
+ borderRadius: Number(theme.radii.md),
108
+ backgroundColor: pressed ? theme.colors.intent.neutral.subtle : theme.colors.surfaceElevated,
109
+ opacity: isDisabled ? 0.6 : 1,
110
+ })}
111
+ >
112
+ <Text style={{ color: display ? theme.colors.fg : theme.colors.fgMuted, fontSize: 14 }}>{display || placeholder}</Text>
113
+ </Pressable>
114
+ <Modal transparent visible={isOpen} animationType="fade" onRequestClose={() => setOpen(false)}>
115
+ <Pressable
116
+ style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: 'rgba(0,0,0,0.4)' }}
117
+ onPress={() => setOpen(false)}
118
+ >
119
+ <Pressable
120
+ onPress={() => undefined}
121
+ style={{
122
+ padding: 12,
123
+ backgroundColor: theme.colors.surfaceElevated,
124
+ borderRadius: Number(theme.radii.md),
125
+ width: 280,
126
+ }}
127
+ >
128
+ <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
129
+ <Pressable onPress={() => setView((v) => addMonths(v, -1))} style={{ padding: 6 }}>
130
+ <Text style={{ color: theme.colors.fg, fontSize: 18 }}>‹</Text>
131
+ </Pressable>
132
+ <Text style={{ fontSize: 14, fontWeight: '500', color: theme.colors.fg }}>
133
+ {view.toLocaleString(undefined, { month: 'long', year: 'numeric' })}
134
+ </Text>
135
+ <Pressable onPress={() => setView((v) => addMonths(v, 1))} style={{ padding: 6 }}>
136
+ <Text style={{ color: theme.colors.fg, fontSize: 18 }}>›</Text>
137
+ </Pressable>
138
+ </View>
139
+ <View style={{ flexDirection: 'row' }}>
140
+ {weekdays.map((wd) => (
141
+ <Text key={wd} style={{ flex: 1, textAlign: 'center', fontSize: 11, color: theme.colors.fgMuted, paddingVertical: 4 }}>
142
+ {wd}
143
+ </Text>
144
+ ))}
145
+ </View>
146
+ {matrix.map((week, wi) => (
147
+ <View key={wi} style={{ flexDirection: 'row' }}>
148
+ {week.map((d) => {
149
+ const inMonth = d.getMonth() === view.getMonth();
150
+ const selected = isSameDay(d, value);
151
+ const tooEarly = min && d < new Date(min.getFullYear(), min.getMonth(), min.getDate());
152
+ const tooLate = max && d > new Date(max.getFullYear(), max.getMonth(), max.getDate());
153
+ const disabled = !!tooEarly || !!tooLate;
154
+ return (
155
+ <Pressable
156
+ key={d.toISOString()}
157
+ disabled={disabled}
158
+ onPress={() => pick(d)}
159
+ style={({ pressed }) => ({
160
+ flex: 1,
161
+ margin: 1,
162
+ paddingVertical: 6,
163
+ alignItems: 'center',
164
+ borderRadius: Number(theme.radii.sm),
165
+ backgroundColor: selected
166
+ ? theme.colors.intent.primary.solid
167
+ : pressed
168
+ ? theme.colors.intent.neutral.subtle
169
+ : 'transparent',
170
+ })}
171
+ >
172
+ <Text
173
+ style={{
174
+ fontSize: 13,
175
+ color: selected
176
+ ? theme.colors.intent.primary.solidFg
177
+ : inMonth
178
+ ? theme.colors.fg
179
+ : theme.colors.fgSubtle,
180
+ opacity: disabled ? 0.4 : 1,
181
+ }}
182
+ >
183
+ {d.getDate()}
184
+ </Text>
185
+ </Pressable>
186
+ );
187
+ })}
188
+ </View>
189
+ ))}
190
+ </Pressable>
191
+ </Pressable>
192
+ </Modal>
193
+ </View>
194
+ );
195
+ }
@@ -0,0 +1,249 @@
1
+ import { useMemo, useState } from 'react';
2
+ import { Modal, Pressable, ScrollView, View, Text, type ViewProps } from 'react-native';
3
+ import { useTheme } from './ElvoraProvider';
4
+
5
+ export interface DateRange {
6
+ start: Date | null;
7
+ end: Date | null;
8
+ }
9
+
10
+ export interface DateRangePickerProps extends Omit<ViewProps, 'onChange'> {
11
+ value?: DateRange;
12
+ defaultValue?: DateRange;
13
+ onChange?: (value: DateRange) => void;
14
+ min?: Date;
15
+ max?: Date;
16
+ isDisabled?: boolean;
17
+ format?: (d: Date) => string;
18
+ weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
19
+ placeholder?: { start?: string; end?: string };
20
+ }
21
+
22
+ const pad = (n: number) => String(n).padStart(2, '0');
23
+ const defaultFormat = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
24
+
25
+ function startOfMonth(d: Date) {
26
+ return new Date(d.getFullYear(), d.getMonth(), 1);
27
+ }
28
+
29
+ function addMonths(d: Date, n: number) {
30
+ return new Date(d.getFullYear(), d.getMonth() + n, 1);
31
+ }
32
+
33
+ function buildMatrix(view: Date, weekStartsOn: number): Date[][] {
34
+ const first = startOfMonth(view);
35
+ const firstWeekday = (first.getDay() - weekStartsOn + 7) % 7;
36
+ const start = new Date(first);
37
+ start.setDate(first.getDate() - firstWeekday);
38
+ const weeks: Date[][] = [];
39
+ for (let w = 0; w < 6; w++) {
40
+ const row: Date[] = [];
41
+ for (let d = 0; d < 7; d++) {
42
+ const day = new Date(start);
43
+ day.setDate(start.getDate() + w * 7 + d);
44
+ row.push(day);
45
+ }
46
+ weeks.push(row);
47
+ }
48
+ return weeks;
49
+ }
50
+
51
+ function dayKey(d: Date): number {
52
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
53
+ }
54
+
55
+ function isSameDay(a: Date | null, b: Date | null): boolean {
56
+ if (!a || !b) return false;
57
+ return a.toDateString() === b.toDateString();
58
+ }
59
+
60
+ export function DateRangePicker(props: DateRangePickerProps) {
61
+ const {
62
+ value: valueProp,
63
+ defaultValue,
64
+ onChange,
65
+ min,
66
+ max,
67
+ isDisabled,
68
+ format = defaultFormat,
69
+ weekStartsOn = 0,
70
+ placeholder,
71
+ style,
72
+ ...rest
73
+ } = props;
74
+ const theme = useTheme();
75
+ const [internal, setInternal] = useState<DateRange>(defaultValue ?? { start: null, end: null });
76
+ const value = valueProp ?? internal;
77
+ const [isOpen, setOpen] = useState(false);
78
+ const [view, setView] = useState<Date>(value.start ?? new Date());
79
+ const [pendingStart, setPendingStart] = useState<Date | null>(null);
80
+
81
+ const setRange = (next: DateRange) => {
82
+ if (valueProp === undefined) setInternal(next);
83
+ onChange?.(next);
84
+ };
85
+
86
+ const pickDay = (d: Date) => {
87
+ if (!pendingStart || (value.start && value.end)) {
88
+ setPendingStart(d);
89
+ setRange({ start: d, end: null });
90
+ return;
91
+ }
92
+ const start = pendingStart;
93
+ const end = d;
94
+ if (dayKey(end) < dayKey(start)) setRange({ start: end, end: start });
95
+ else setRange({ start, end });
96
+ setPendingStart(null);
97
+ setOpen(false);
98
+ };
99
+
100
+ const weekdays = useMemo(() => {
101
+ const arr: string[] = [];
102
+ const base = new Date(2024, 0, 7);
103
+ for (let i = 0; i < 7; i++) {
104
+ const d = new Date(base);
105
+ d.setDate(base.getDate() + ((i + weekStartsOn) % 7));
106
+ arr.push(d.toLocaleString(undefined, { weekday: 'short' }));
107
+ }
108
+ return arr;
109
+ }, [weekStartsOn]);
110
+
111
+ const renderMonth = (offset: number) => {
112
+ const monthView = addMonths(view, offset);
113
+ const matrix = buildMatrix(monthView, weekStartsOn);
114
+ return (
115
+ <View key={offset} style={{ width: 240 }}>
116
+ <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
117
+ {offset === 0 ? (
118
+ <Pressable onPress={() => setView((v) => addMonths(v, -1))} style={{ padding: 4 }}>
119
+ <Text style={{ fontSize: 18, color: theme.colors.fg }}>‹</Text>
120
+ </Pressable>
121
+ ) : (
122
+ <View style={{ width: 24 }} />
123
+ )}
124
+ <Text style={{ fontSize: 14, fontWeight: '500', color: theme.colors.fg }}>
125
+ {monthView.toLocaleString(undefined, { month: 'long', year: 'numeric' })}
126
+ </Text>
127
+ {offset === 1 ? (
128
+ <Pressable onPress={() => setView((v) => addMonths(v, 1))} style={{ padding: 4 }}>
129
+ <Text style={{ fontSize: 18, color: theme.colors.fg }}>›</Text>
130
+ </Pressable>
131
+ ) : (
132
+ <View style={{ width: 24 }} />
133
+ )}
134
+ </View>
135
+ <View style={{ flexDirection: 'row' }}>
136
+ {weekdays.map((wd) => (
137
+ <Text key={wd} style={{ flex: 1, textAlign: 'center', fontSize: 11, color: theme.colors.fgMuted, paddingVertical: 4 }}>
138
+ {wd}
139
+ </Text>
140
+ ))}
141
+ </View>
142
+ {matrix.map((week, wi) => (
143
+ <View key={wi} style={{ flexDirection: 'row' }}>
144
+ {week.map((d) => {
145
+ const inMonth = d.getMonth() === monthView.getMonth();
146
+ const isStart = isSameDay(d, value.start);
147
+ const isEnd = isSameDay(d, value.end);
148
+ const inRange =
149
+ value.start && value.end && dayKey(d) > dayKey(value.start) && dayKey(d) < dayKey(value.end);
150
+ const tooEarly = min && d < new Date(min.getFullYear(), min.getMonth(), min.getDate());
151
+ const tooLate = max && d > new Date(max.getFullYear(), max.getMonth(), max.getDate());
152
+ const disabled = !!tooEarly || !!tooLate;
153
+ const isEndpoint = isStart || isEnd;
154
+ return (
155
+ <Pressable
156
+ key={d.toISOString()}
157
+ disabled={disabled}
158
+ onPress={() => pickDay(d)}
159
+ style={{
160
+ flex: 1,
161
+ margin: 1,
162
+ paddingVertical: 6,
163
+ alignItems: 'center',
164
+ borderRadius: Number(theme.radii.sm),
165
+ backgroundColor: isEndpoint
166
+ ? theme.colors.intent.primary.solid
167
+ : inRange
168
+ ? theme.colors.intent.primary.subtle
169
+ : 'transparent',
170
+ }}
171
+ >
172
+ <Text
173
+ style={{
174
+ fontSize: 13,
175
+ color: isEndpoint
176
+ ? theme.colors.intent.primary.solidFg
177
+ : inMonth
178
+ ? theme.colors.fg
179
+ : theme.colors.fgSubtle,
180
+ opacity: disabled ? 0.4 : 1,
181
+ }}
182
+ >
183
+ {d.getDate()}
184
+ </Text>
185
+ </Pressable>
186
+ );
187
+ })}
188
+ </View>
189
+ ))}
190
+ </View>
191
+ );
192
+ };
193
+
194
+ const startText = value.start ? format(value.start) : '';
195
+ const endText = value.end ? format(value.end) : '';
196
+
197
+ return (
198
+ <View style={style} {...rest}>
199
+ <Pressable
200
+ accessibilityRole="button"
201
+ disabled={isDisabled}
202
+ onPress={() => setOpen(true)}
203
+ style={({ pressed }) => ({
204
+ flexDirection: 'row',
205
+ alignItems: 'center',
206
+ paddingHorizontal: 12,
207
+ paddingVertical: 8,
208
+ borderWidth: 1,
209
+ borderColor: theme.colors.border,
210
+ borderRadius: Number(theme.radii.md),
211
+ backgroundColor: pressed ? theme.colors.intent.neutral.subtle : theme.colors.surfaceElevated,
212
+ opacity: isDisabled ? 0.6 : 1,
213
+ gap: 8,
214
+ })}
215
+ >
216
+ <Text style={{ color: startText ? theme.colors.fg : theme.colors.fgMuted, fontSize: 14, minWidth: 90 }}>
217
+ {startText || placeholder?.start || 'Start'}
218
+ </Text>
219
+ <Text style={{ color: theme.colors.fgMuted }}>→</Text>
220
+ <Text style={{ color: endText ? theme.colors.fg : theme.colors.fgMuted, fontSize: 14, minWidth: 90 }}>
221
+ {endText || placeholder?.end || 'End'}
222
+ </Text>
223
+ </Pressable>
224
+ <Modal transparent visible={isOpen} animationType="fade" onRequestClose={() => setOpen(false)}>
225
+ <Pressable
226
+ style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: 'rgba(0,0,0,0.4)' }}
227
+ onPress={() => setOpen(false)}
228
+ >
229
+ <Pressable
230
+ onPress={() => undefined}
231
+ style={{
232
+ backgroundColor: theme.colors.surfaceElevated,
233
+ borderRadius: Number(theme.radii.md),
234
+ padding: 12,
235
+ maxWidth: '95%',
236
+ }}
237
+ >
238
+ <ScrollView horizontal>
239
+ <View style={{ flexDirection: 'row', gap: 12 }}>
240
+ {renderMonth(0)}
241
+ {renderMonth(1)}
242
+ </View>
243
+ </ScrollView>
244
+ </Pressable>
245
+ </Pressable>
246
+ </Modal>
247
+ </View>
248
+ );
249
+ }