@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.
- package/LICENSE +21 -0
- package/README.md +40 -0
- package/dist/index.cjs +5785 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1253 -0
- package/dist/index.d.ts +1253 -0
- package/dist/index.js +5683 -0
- package/dist/index.js.map +1 -0
- package/package.json +88 -0
- package/src/Accordion.tsx +11 -0
- package/src/Affix.tsx +20 -0
- package/src/Alert.tsx +102 -0
- package/src/Anchor.tsx +58 -0
- package/src/AutoComplete.tsx +122 -0
- package/src/Avatar.tsx +58 -0
- package/src/BackTop.tsx +71 -0
- package/src/Backdrop.tsx +32 -0
- package/src/Badge.tsx +87 -0
- package/src/Box.tsx +67 -0
- package/src/Breadcrumb.tsx +46 -0
- package/src/Button.test.tsx +39 -0
- package/src/Button.tsx +127 -0
- package/src/ButtonGroup.tsx +74 -0
- package/src/Calendar.tsx +165 -0
- package/src/Card.tsx +69 -0
- package/src/Carousel.tsx +99 -0
- package/src/Cascader.tsx +160 -0
- package/src/Checkbox.tsx +85 -0
- package/src/ChipInput.tsx +130 -0
- package/src/Collapse.tsx +120 -0
- package/src/ColorPicker.tsx +114 -0
- package/src/Container.tsx +22 -0
- package/src/DataGrid.tsx +170 -0
- package/src/DatePicker.tsx +195 -0
- package/src/DateRangePicker.tsx +249 -0
- package/src/Descriptions.tsx +98 -0
- package/src/Divider.tsx +32 -0
- package/src/Drawer.tsx +103 -0
- package/src/Dropdown.tsx +15 -0
- package/src/ElvoraProvider.tsx +31 -0
- package/src/Empty.tsx +34 -0
- package/src/FloatButton.tsx +78 -0
- package/src/Form.tsx +119 -0
- package/src/Grid.tsx +68 -0
- package/src/Icon.tsx +49 -0
- package/src/IconButton.tsx +28 -0
- package/src/Image.tsx +68 -0
- package/src/ImageList.tsx +58 -0
- package/src/Input.tsx +87 -0
- package/src/Label.tsx +46 -0
- package/src/List.tsx +82 -0
- package/src/Mentions.tsx +148 -0
- package/src/Menu.tsx +77 -0
- package/src/Modal.tsx +114 -0
- package/src/NumberInput.tsx +156 -0
- package/src/Pagination.tsx +148 -0
- package/src/PaginationVariants.tsx +64 -0
- package/src/Popover.tsx +74 -0
- package/src/ProForm.tsx +219 -0
- package/src/ProLayout.tsx +151 -0
- package/src/ProTable.tsx +91 -0
- package/src/Progress.tsx +92 -0
- package/src/QRCode.tsx +65 -0
- package/src/Radio.tsx +98 -0
- package/src/Rate.tsx +66 -0
- package/src/Result.tsx +64 -0
- package/src/Segmented.tsx +75 -0
- package/src/Select.tsx +146 -0
- package/src/Skeleton.tsx +49 -0
- package/src/Slider.tsx +122 -0
- package/src/SpeedDial.tsx +87 -0
- package/src/Spinner.tsx +29 -0
- package/src/Splitter.tsx +91 -0
- package/src/Stack.tsx +38 -0
- package/src/Statistic.tsx +60 -0
- package/src/Stepper.tsx +113 -0
- package/src/Steps.tsx +146 -0
- package/src/Switch.tsx +52 -0
- package/src/Table.tsx +178 -0
- package/src/Tabs.tsx +122 -0
- package/src/Tag.tsx +83 -0
- package/src/Textarea.tsx +22 -0
- package/src/TimePicker.tsx +187 -0
- package/src/Timeline.tsx +92 -0
- package/src/Toast.tsx +140 -0
- package/src/ToggleButton.tsx +66 -0
- package/src/Tooltip.tsx +56 -0
- package/src/Tour.tsx +118 -0
- package/src/Transfer.tsx +219 -0
- package/src/Tree.tsx +144 -0
- package/src/TreeSelect.tsx +221 -0
- package/src/Upload.tsx +109 -0
- package/src/Watermark.tsx +76 -0
- package/src/index.ts +221 -0
- package/src/smoke.test.tsx +113 -0
- package/src/test/react-native-stub.tsx +413 -0
- package/src/test/react-native-svg-stub.tsx +33 -0
- package/src/test/setup.ts +7 -0
package/src/DataGrid.tsx
ADDED
|
@@ -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
|
+
}
|