@lotics/ui 1.15.0 → 1.17.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.
- package/package.json +17 -1
- package/src/combobox.tsx +13 -1
- package/src/custom_option.test.ts +50 -0
- package/src/custom_option.ts +30 -0
- package/src/date_filter.tsx +389 -0
- package/src/date_filter_presets.test.ts +62 -0
- package/src/date_filter_presets.ts +100 -0
- package/src/file_gallery_modal.tsx +22 -40
- package/src/file_preview.tsx +38 -0
- package/src/file_preview.web.tsx +198 -0
- package/src/file_preview_types.ts +35 -0
- package/src/mime.ts +46 -0
- package/src/picker_menu.tsx +29 -5
- package/src/spreadsheet_view.tsx +304 -0
- package/src/table.tsx +105 -36
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.17.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./tokens": "./src/tokens.ts",
|
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
"./download": "./src/download.ts",
|
|
10
10
|
"./file_badge": "./src/file_badge.tsx",
|
|
11
11
|
"./file_thumbnail": "./src/file_thumbnail.tsx",
|
|
12
|
+
"./file_preview": {
|
|
13
|
+
"react-native": "./src/file_preview.tsx",
|
|
14
|
+
"default": "./src/file_preview.web.tsx"
|
|
15
|
+
},
|
|
16
|
+
"./file_preview_types": "./src/file_preview_types.ts",
|
|
12
17
|
"./file_gallery_modal": "./src/file_gallery_modal.tsx",
|
|
13
18
|
"./pagination": "./src/pagination.tsx",
|
|
14
19
|
"./bar_chart": "./src/bar_chart.tsx",
|
|
@@ -99,6 +104,7 @@
|
|
|
99
104
|
"./page_header": "./src/page_header.tsx",
|
|
100
105
|
"./pager_view": "./src/pager_view.tsx",
|
|
101
106
|
"./date_picker": "./src/date_picker.tsx",
|
|
107
|
+
"./date_filter": "./src/date_filter.tsx",
|
|
102
108
|
"./time_picker": "./src/time_picker.tsx",
|
|
103
109
|
"./time_field": "./src/time_field.tsx",
|
|
104
110
|
"./date_calendar": "./src/date_calendar.tsx",
|
|
@@ -153,6 +159,8 @@
|
|
|
153
159
|
},
|
|
154
160
|
"license": "MIT",
|
|
155
161
|
"peerDependencies": {
|
|
162
|
+
"@lotics/docx": "^0.1.0",
|
|
163
|
+
"@lotics/xlsx": "^0.1.0",
|
|
156
164
|
"@react-native-picker/picker": ">=2.0.0",
|
|
157
165
|
"expo-image": ">=3.0.0",
|
|
158
166
|
"lucide-react": ">=0.460.0",
|
|
@@ -165,6 +173,12 @@
|
|
|
165
173
|
"recharts": ">=3.0.0"
|
|
166
174
|
},
|
|
167
175
|
"peerDependenciesMeta": {
|
|
176
|
+
"@lotics/docx": {
|
|
177
|
+
"optional": true
|
|
178
|
+
},
|
|
179
|
+
"@lotics/xlsx": {
|
|
180
|
+
"optional": true
|
|
181
|
+
},
|
|
168
182
|
"expo-image": {
|
|
169
183
|
"optional": true
|
|
170
184
|
},
|
|
@@ -187,6 +201,8 @@
|
|
|
187
201
|
"test": "vitest run"
|
|
188
202
|
},
|
|
189
203
|
"devDependencies": {
|
|
204
|
+
"@lotics/docx": "^0.1.0",
|
|
205
|
+
"@lotics/xlsx": "^0.1.0",
|
|
190
206
|
"recharts": "^3.8.1"
|
|
191
207
|
}
|
|
192
208
|
}
|
package/src/combobox.tsx
CHANGED
|
@@ -29,6 +29,13 @@ export interface ComboboxProps<T extends string = string> {
|
|
|
29
29
|
searchPlaceholder?: string;
|
|
30
30
|
/** Shown when there are no results and not loading. Default: "No results". */
|
|
31
31
|
emptyText?: string;
|
|
32
|
+
/** Accept free entry: when the typed query matches no option, offer it as a
|
|
33
|
+
* custom value. `onValueChange` then receives `{ value: query, label: query }`.
|
|
34
|
+
* The consumer decides what an unknown value means (e.g. a manually-typed id). */
|
|
35
|
+
allowCustom?: boolean;
|
|
36
|
+
/** Label for the free-entry row (default: the raw query). Return `null` to
|
|
37
|
+
* suppress it for a given query (e.g. still incomplete/invalid). */
|
|
38
|
+
customOptionLabel?: (query: string) => string | null;
|
|
32
39
|
disabled?: boolean;
|
|
33
40
|
autoFocus?: boolean;
|
|
34
41
|
testID?: string;
|
|
@@ -56,6 +63,8 @@ export function Combobox<T extends string>(props: ComboboxProps<T>) {
|
|
|
56
63
|
placeholder,
|
|
57
64
|
searchPlaceholder,
|
|
58
65
|
emptyText,
|
|
66
|
+
allowCustom,
|
|
67
|
+
customOptionLabel,
|
|
59
68
|
disabled = false,
|
|
60
69
|
autoFocus = false,
|
|
61
70
|
testID,
|
|
@@ -73,9 +82,10 @@ export function Combobox<T extends string>(props: ComboboxProps<T>) {
|
|
|
73
82
|
(v: T) => {
|
|
74
83
|
const opt = options.find((o) => o.value === v);
|
|
75
84
|
if (opt) onValueChange(opt);
|
|
85
|
+
else if (allowCustom && v) onValueChange({ value: v, label: v });
|
|
76
86
|
setOpen(false);
|
|
77
87
|
},
|
|
78
|
-
[options, onValueChange],
|
|
88
|
+
[options, onValueChange, allowCustom],
|
|
79
89
|
);
|
|
80
90
|
|
|
81
91
|
return (
|
|
@@ -117,6 +127,8 @@ export function Combobox<T extends string>(props: ComboboxProps<T>) {
|
|
|
117
127
|
onValueChange={handleSelect}
|
|
118
128
|
onRequestClose={() => setOpen(false)}
|
|
119
129
|
enableSearch
|
|
130
|
+
allowCustom={allowCustom}
|
|
131
|
+
customOptionLabel={customOptionLabel}
|
|
120
132
|
onSearchChange={debouncedSearch}
|
|
121
133
|
// Local filtering is wrong when the consumer drives search server-side.
|
|
122
134
|
serverFiltered={onSearchChange !== undefined}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { customOptionFor } from "./custom_option";
|
|
3
|
+
|
|
4
|
+
const opts = [
|
|
5
|
+
{ value: "BX1N", label: "BX1N" },
|
|
6
|
+
{ value: "UX1N", label: "UX1N" },
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
describe("customOptionFor", () => {
|
|
10
|
+
it("offers the trimmed query when nothing matches", () => {
|
|
11
|
+
expect(customOptionFor({ allowCustom: true, multi: false, query: " ABCD ", options: opts }))
|
|
12
|
+
.toEqual({ value: "ABCD", label: "ABCD" });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("suppressed when free entry is off", () => {
|
|
16
|
+
expect(customOptionFor({ allowCustom: false, multi: false, query: "ABCD", options: opts })).toBeNull();
|
|
17
|
+
expect(customOptionFor({ allowCustom: undefined, multi: false, query: "ABCD", options: opts })).toBeNull();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("suppressed for multi-select", () => {
|
|
21
|
+
expect(customOptionFor({ allowCustom: true, multi: true, query: "ABCD", options: opts })).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("suppressed for an empty / whitespace query", () => {
|
|
25
|
+
expect(customOptionFor({ allowCustom: true, multi: false, query: " ", options: opts })).toBeNull();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("suppressed when the query matches an option value (case-insensitive)", () => {
|
|
29
|
+
expect(customOptionFor({ allowCustom: true, multi: false, query: "bx1n", options: opts })).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("uses customOptionLabel for the row label but keeps the raw value", () => {
|
|
33
|
+
expect(customOptionFor({
|
|
34
|
+
allowCustom: true, multi: false, query: "ABCD", options: opts,
|
|
35
|
+
customOptionLabel: (q) => `Add "${q}"`,
|
|
36
|
+
})).toEqual({ value: "ABCD", label: 'Add "ABCD"' });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("suppressed when customOptionLabel returns null", () => {
|
|
40
|
+
expect(customOptionFor({
|
|
41
|
+
allowCustom: true, multi: false, query: "AB", options: opts,
|
|
42
|
+
customOptionLabel: () => null,
|
|
43
|
+
})).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("ignores undefined / false holes in the options list", () => {
|
|
47
|
+
expect(customOptionFor({ allowCustom: true, multi: false, query: "ABCD", options: [undefined, false, ...opts] }))
|
|
48
|
+
.toEqual({ value: "ABCD", label: "ABCD" });
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { PickerOption } from "./picker";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve the free-entry ("custom") option a search picker should offer for the
|
|
5
|
+
* current query — or `null` when it should not be offered. The option's `value`
|
|
6
|
+
* stays the raw trimmed query; consumers decide what an unknown value means.
|
|
7
|
+
*
|
|
8
|
+
* Suppressed when: free entry is disabled, the picker is multi-select, the query
|
|
9
|
+
* is empty, the query already matches an existing option's value
|
|
10
|
+
* (case-insensitive), or `customOptionLabel` returns `null` for it.
|
|
11
|
+
*
|
|
12
|
+
* Pure and RN-free so it is unit-testable in isolation from `PickerMenu`.
|
|
13
|
+
*/
|
|
14
|
+
export function customOptionFor<T extends string>(args: {
|
|
15
|
+
allowCustom: boolean | undefined;
|
|
16
|
+
multi: boolean;
|
|
17
|
+
query: string;
|
|
18
|
+
options: (PickerOption<T> | undefined | false)[];
|
|
19
|
+
customOptionLabel?: (query: string) => string | null;
|
|
20
|
+
}): PickerOption<T> | null {
|
|
21
|
+
const { allowCustom, multi, query, options, customOptionLabel } = args;
|
|
22
|
+
if (!allowCustom || multi) return null;
|
|
23
|
+
const q = query.trim();
|
|
24
|
+
if (!q || options.some((o) => o && String(o.value).toLowerCase() === q.toLowerCase())) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
const label = customOptionLabel ? customOptionLabel(q) : q;
|
|
28
|
+
if (label == null) return null;
|
|
29
|
+
return { value: q as T, label };
|
|
30
|
+
}
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import React, { useCallback, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { ScrollView, StyleSheet, View } from "react-native";
|
|
3
|
+
import { Text } from "./text";
|
|
4
|
+
import { colors } from "./colors";
|
|
5
|
+
import { MenuButton } from "./menu_button";
|
|
6
|
+
import { Calendar, CalendarRangeValue, CalendarRef } from "./date_calendar";
|
|
7
|
+
import { TimeField } from "./time_field";
|
|
8
|
+
import { Switch } from "./switch";
|
|
9
|
+
import { useScreenSize } from "./use_screen_size";
|
|
10
|
+
import { SegmentLabels } from "./date_segments";
|
|
11
|
+
import { PresetId, PRESET_IDS, getPresetValue } from "./date_filter_presets";
|
|
12
|
+
|
|
13
|
+
type SelectionMode = "single" | "range";
|
|
14
|
+
|
|
15
|
+
// =============================================================================
|
|
16
|
+
// Types
|
|
17
|
+
// =============================================================================
|
|
18
|
+
|
|
19
|
+
export interface DateFilterValue {
|
|
20
|
+
start: { date: Date | null; time: string | null };
|
|
21
|
+
end: { date: Date | null; time: string | null };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Translated labels. Extends `SegmentLabels` (consumed by the time fields);
|
|
26
|
+
* the rest are the preset names and the range-display strings. The frontend
|
|
27
|
+
* feeds these from `useDateFilterLabels`; standalone apps pass literals.
|
|
28
|
+
*/
|
|
29
|
+
export interface DateFilterLabels extends SegmentLabels {
|
|
30
|
+
today: string;
|
|
31
|
+
yesterday: string;
|
|
32
|
+
tomorrow: string;
|
|
33
|
+
thisWeek: string;
|
|
34
|
+
thisMonth: string;
|
|
35
|
+
lastMonth: string;
|
|
36
|
+
custom: string;
|
|
37
|
+
from: string;
|
|
38
|
+
to: string;
|
|
39
|
+
selectDateRange: string;
|
|
40
|
+
selectDate: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const DEFAULT_LABELS: DateFilterLabels = {
|
|
44
|
+
year: "Year",
|
|
45
|
+
month: "Month",
|
|
46
|
+
day: "Day",
|
|
47
|
+
hour: "Hour",
|
|
48
|
+
minute: "Minute",
|
|
49
|
+
dayPeriod: "AM/PM",
|
|
50
|
+
today: "Today",
|
|
51
|
+
yesterday: "Yesterday",
|
|
52
|
+
tomorrow: "Tomorrow",
|
|
53
|
+
thisWeek: "This week",
|
|
54
|
+
thisMonth: "This month",
|
|
55
|
+
lastMonth: "Last month",
|
|
56
|
+
custom: "Custom",
|
|
57
|
+
from: "From",
|
|
58
|
+
to: "To",
|
|
59
|
+
selectDateRange: "Select date range",
|
|
60
|
+
selectDate: "Select date",
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export interface DateFilterProps {
|
|
64
|
+
value: DateFilterValue;
|
|
65
|
+
onValueChange: (value: DateFilterValue) => void;
|
|
66
|
+
includeTime?: boolean;
|
|
67
|
+
/** Translated labels. Defaults to English. */
|
|
68
|
+
labels?: Partial<DateFilterLabels>;
|
|
69
|
+
/** BCP-47 locale for the calendar and date display. Defaults to "en-US". */
|
|
70
|
+
locale?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// =============================================================================
|
|
74
|
+
// Helpers
|
|
75
|
+
// =============================================================================
|
|
76
|
+
|
|
77
|
+
function presetLabel(id: PresetId, labels: DateFilterLabels): string {
|
|
78
|
+
switch (id) {
|
|
79
|
+
case "today":
|
|
80
|
+
return labels.today;
|
|
81
|
+
case "yesterday":
|
|
82
|
+
return labels.yesterday;
|
|
83
|
+
case "tomorrow":
|
|
84
|
+
return labels.tomorrow;
|
|
85
|
+
case "this_week":
|
|
86
|
+
return labels.thisWeek;
|
|
87
|
+
case "this_month":
|
|
88
|
+
return labels.thisMonth;
|
|
89
|
+
case "last_month":
|
|
90
|
+
return labels.lastMonth;
|
|
91
|
+
case "custom":
|
|
92
|
+
return labels.custom;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function formatDateDisplay(date: Date | null, locale: string | undefined): string {
|
|
97
|
+
if (!date) return "";
|
|
98
|
+
const isoDate = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
|
99
|
+
try {
|
|
100
|
+
return new Intl.DateTimeFormat(locale, {
|
|
101
|
+
month: "short",
|
|
102
|
+
day: "numeric",
|
|
103
|
+
year: "numeric",
|
|
104
|
+
}).format(date);
|
|
105
|
+
} catch {
|
|
106
|
+
return isoDate;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// =============================================================================
|
|
111
|
+
// Main Component
|
|
112
|
+
// =============================================================================
|
|
113
|
+
|
|
114
|
+
export function DateFilter(props: DateFilterProps) {
|
|
115
|
+
const { value, onValueChange, includeTime = false, locale } = props;
|
|
116
|
+
const labels = useMemo<DateFilterLabels>(
|
|
117
|
+
() => ({ ...DEFAULT_LABELS, ...props.labels }),
|
|
118
|
+
[props.labels],
|
|
119
|
+
);
|
|
120
|
+
const screenSize = useScreenSize();
|
|
121
|
+
const calendarRef = useRef<CalendarRef>(null);
|
|
122
|
+
const [selectionMode, setSelectionMode] = useState<SelectionMode>(() => {
|
|
123
|
+
if (value.start.date && value.end.date) {
|
|
124
|
+
if (value.start.date.toDateString() !== value.end.date.toDateString()) {
|
|
125
|
+
return "range";
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return "single";
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const calendarValue: CalendarRangeValue = useMemo(
|
|
132
|
+
() => ({ start: value.start.date, end: value.end.date }),
|
|
133
|
+
[value.start.date, value.end.date],
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const handleCalendarChange = useCallback(
|
|
137
|
+
(newValue: CalendarRangeValue) => {
|
|
138
|
+
// In single mode, one click selects a single date immediately
|
|
139
|
+
if (selectionMode === "single" && newValue.start && !newValue.end) {
|
|
140
|
+
onValueChange({
|
|
141
|
+
start: { date: newValue.start, time: value.start.time },
|
|
142
|
+
end: { date: newValue.start, time: value.end.time },
|
|
143
|
+
});
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
onValueChange({
|
|
147
|
+
start: { date: newValue.start, time: value.start.time },
|
|
148
|
+
end: { date: newValue.end, time: value.end.time },
|
|
149
|
+
});
|
|
150
|
+
},
|
|
151
|
+
[onValueChange, value.start.time, value.end.time, selectionMode],
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const handleModeChange = useCallback(
|
|
155
|
+
(isRange: boolean) => {
|
|
156
|
+
const newMode: SelectionMode = isRange ? "range" : "single";
|
|
157
|
+
setSelectionMode(newMode);
|
|
158
|
+
|
|
159
|
+
// Switching to single: collapse range to start date
|
|
160
|
+
if (newMode === "single" && value.start.date) {
|
|
161
|
+
onValueChange({
|
|
162
|
+
start: value.start,
|
|
163
|
+
end: { date: value.start.date, time: value.start.time },
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
[value, onValueChange],
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const handleStartTimeChange = useCallback(
|
|
171
|
+
(time: string) => {
|
|
172
|
+
onValueChange({ ...value, start: { ...value.start, time: time || null } });
|
|
173
|
+
},
|
|
174
|
+
[onValueChange, value],
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const handleEndTimeChange = useCallback(
|
|
178
|
+
(time: string) => {
|
|
179
|
+
onValueChange({ ...value, end: { ...value.end, time: time || null } });
|
|
180
|
+
},
|
|
181
|
+
[onValueChange, value],
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
// Which preset (if any) the current value matches.
|
|
185
|
+
const activePresetId = useMemo((): PresetId | null => {
|
|
186
|
+
if (!value.start.date && !value.end.date) return null;
|
|
187
|
+
const now = new Date();
|
|
188
|
+
for (const id of PRESET_IDS) {
|
|
189
|
+
const presetValue = getPresetValue(id, now);
|
|
190
|
+
if (!presetValue) continue; // "custom"
|
|
191
|
+
if (
|
|
192
|
+
value.start.date?.toDateString() === presetValue.start.date?.toDateString() &&
|
|
193
|
+
value.end.date?.toDateString() === presetValue.end.date?.toDateString()
|
|
194
|
+
) {
|
|
195
|
+
return id;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return "custom";
|
|
199
|
+
}, [value]);
|
|
200
|
+
|
|
201
|
+
const handlePresetSelect = useCallback(
|
|
202
|
+
(id: PresetId) => {
|
|
203
|
+
// Re-clicking the active preset clears the filter
|
|
204
|
+
if (activePresetId === id) {
|
|
205
|
+
onValueChange({ start: { date: null, time: null }, end: { date: null, time: null } });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const presetValue = getPresetValue(id, new Date());
|
|
210
|
+
if (!presetValue) {
|
|
211
|
+
// "Custom" clears the value so the user can select manually
|
|
212
|
+
onValueChange({ start: { date: null, time: null }, end: { date: null, time: null } });
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Auto-switch to range mode for multi-day presets
|
|
217
|
+
if (presetValue.start.date && presetValue.end.date) {
|
|
218
|
+
const isSingleDay =
|
|
219
|
+
presetValue.start.date.toDateString() === presetValue.end.date.toDateString();
|
|
220
|
+
if (!isSingleDay && selectionMode === "single") setSelectionMode("range");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
onValueChange(presetValue);
|
|
224
|
+
|
|
225
|
+
if (presetValue.start.date) {
|
|
226
|
+
calendarRef.current?.navigateToMonth(
|
|
227
|
+
presetValue.start.date.getFullYear(),
|
|
228
|
+
presetValue.start.date.getMonth(),
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
[onValueChange, activePresetId, selectionMode],
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
const renderPreset = (id: PresetId) => {
|
|
236
|
+
const isActive = activePresetId === id;
|
|
237
|
+
return (
|
|
238
|
+
<MenuButton
|
|
239
|
+
key={id}
|
|
240
|
+
title={
|
|
241
|
+
<Text size="sm" color={isActive ? "inverted" : "default"}>
|
|
242
|
+
{presetLabel(id, labels)}
|
|
243
|
+
</Text>
|
|
244
|
+
}
|
|
245
|
+
onPress={() => handlePresetSelect(id)}
|
|
246
|
+
style={[styles.presetItem, isActive && styles.presetItemActive]}
|
|
247
|
+
/>
|
|
248
|
+
);
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
return (
|
|
252
|
+
<View style={[styles.container, { flexDirection: screenSize.small ? "column" : "row" }]}>
|
|
253
|
+
{/* Presets sidebar (desktop only) */}
|
|
254
|
+
{!screenSize.small && (
|
|
255
|
+
<View style={styles.presetsSidebar}>{PRESET_IDS.map(renderPreset)}</View>
|
|
256
|
+
)}
|
|
257
|
+
|
|
258
|
+
{/* Calendar and time pickers */}
|
|
259
|
+
<View style={styles.mainContent}>
|
|
260
|
+
<Calendar<"range">
|
|
261
|
+
ref={calendarRef}
|
|
262
|
+
mode="range"
|
|
263
|
+
value={calendarValue}
|
|
264
|
+
onValueChange={handleCalendarChange}
|
|
265
|
+
locale={locale}
|
|
266
|
+
/>
|
|
267
|
+
|
|
268
|
+
{selectionMode === "range" ? (
|
|
269
|
+
<View style={styles.rangeDisplay}>
|
|
270
|
+
{value.start.date ? (
|
|
271
|
+
<View style={styles.rangeItem}>
|
|
272
|
+
<Text size="xs" color="zinc-500">
|
|
273
|
+
{labels.from}
|
|
274
|
+
</Text>
|
|
275
|
+
<Text size="sm" weight="medium">
|
|
276
|
+
{formatDateDisplay(value.start.date, locale) || labels.selectDate}
|
|
277
|
+
</Text>
|
|
278
|
+
{includeTime && (
|
|
279
|
+
<View style={{ marginTop: 4 }}>
|
|
280
|
+
<TimeField
|
|
281
|
+
value={value.start.time || ""}
|
|
282
|
+
onChange={handleStartTimeChange}
|
|
283
|
+
segmentLabels={labels}
|
|
284
|
+
locale={locale}
|
|
285
|
+
/>
|
|
286
|
+
</View>
|
|
287
|
+
)}
|
|
288
|
+
</View>
|
|
289
|
+
) : (
|
|
290
|
+
<View style={styles.rangeItem} />
|
|
291
|
+
)}
|
|
292
|
+
{value.end.date ? (
|
|
293
|
+
<View style={styles.rangeItem}>
|
|
294
|
+
<Text size="xs" color="zinc-500">
|
|
295
|
+
{labels.to}
|
|
296
|
+
</Text>
|
|
297
|
+
<Text size="sm" weight="medium">
|
|
298
|
+
{formatDateDisplay(value.end.date, locale) || labels.selectDate}
|
|
299
|
+
</Text>
|
|
300
|
+
{includeTime && value.end.date && (
|
|
301
|
+
<View style={{ marginTop: 4 }}>
|
|
302
|
+
<TimeField
|
|
303
|
+
value={value.end.time || ""}
|
|
304
|
+
onChange={handleEndTimeChange}
|
|
305
|
+
segmentLabels={labels}
|
|
306
|
+
locale={locale}
|
|
307
|
+
/>
|
|
308
|
+
</View>
|
|
309
|
+
)}
|
|
310
|
+
</View>
|
|
311
|
+
) : (
|
|
312
|
+
<View style={styles.rangeItem} />
|
|
313
|
+
)}
|
|
314
|
+
</View>
|
|
315
|
+
) : null}
|
|
316
|
+
|
|
317
|
+
<View style={styles.modeToggle}>
|
|
318
|
+
<Switch value={selectionMode === "range"} onChange={handleModeChange} />
|
|
319
|
+
<Text size="sm" color="muted">
|
|
320
|
+
{labels.selectDateRange}
|
|
321
|
+
</Text>
|
|
322
|
+
</View>
|
|
323
|
+
</View>
|
|
324
|
+
|
|
325
|
+
{/* Presets (mobile only - below calendar) */}
|
|
326
|
+
{screenSize.small && (
|
|
327
|
+
<ScrollView
|
|
328
|
+
horizontal
|
|
329
|
+
showsHorizontalScrollIndicator={false}
|
|
330
|
+
style={styles.presetsScrollView}
|
|
331
|
+
contentContainerStyle={styles.presetsScrollContent}
|
|
332
|
+
>
|
|
333
|
+
{PRESET_IDS.map(renderPreset)}
|
|
334
|
+
</ScrollView>
|
|
335
|
+
)}
|
|
336
|
+
</View>
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// =============================================================================
|
|
341
|
+
// Styles
|
|
342
|
+
// =============================================================================
|
|
343
|
+
|
|
344
|
+
const styles = StyleSheet.create({
|
|
345
|
+
container: {
|
|
346
|
+
backgroundColor: colors.white,
|
|
347
|
+
borderRadius: 8,
|
|
348
|
+
gap: 16,
|
|
349
|
+
},
|
|
350
|
+
presetsSidebar: {
|
|
351
|
+
minWidth: 140,
|
|
352
|
+
},
|
|
353
|
+
presetsScrollView: {
|
|
354
|
+
borderTopWidth: 1,
|
|
355
|
+
borderTopColor: colors.zinc["200"],
|
|
356
|
+
paddingTop: 8,
|
|
357
|
+
},
|
|
358
|
+
presetsScrollContent: {
|
|
359
|
+
flexDirection: "row",
|
|
360
|
+
gap: 4,
|
|
361
|
+
},
|
|
362
|
+
presetItem: {
|
|
363
|
+
marginHorizontal: 0,
|
|
364
|
+
marginBottom: 2,
|
|
365
|
+
},
|
|
366
|
+
presetItemActive: {
|
|
367
|
+
backgroundColor: colors.zinc["800"],
|
|
368
|
+
},
|
|
369
|
+
mainContent: {
|
|
370
|
+
flex: 1,
|
|
371
|
+
},
|
|
372
|
+
modeToggle: {
|
|
373
|
+
flexDirection: "row",
|
|
374
|
+
alignItems: "center",
|
|
375
|
+
gap: 8,
|
|
376
|
+
paddingTop: 8,
|
|
377
|
+
paddingBottom: 8,
|
|
378
|
+
paddingLeft: 8,
|
|
379
|
+
},
|
|
380
|
+
rangeDisplay: {
|
|
381
|
+
flexDirection: "row",
|
|
382
|
+
gap: 16,
|
|
383
|
+
paddingTop: 8,
|
|
384
|
+
},
|
|
385
|
+
rangeItem: {
|
|
386
|
+
flex: 1,
|
|
387
|
+
paddingLeft: 8,
|
|
388
|
+
},
|
|
389
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { getPresetValue, PRESET_IDS, type PresetId } from "./date_filter_presets";
|
|
3
|
+
|
|
4
|
+
// Wednesday, 10 June 2026, 14:30 — a fixed clock so every range is deterministic.
|
|
5
|
+
const NOW = new Date(2026, 5, 10, 14, 30, 0, 0);
|
|
6
|
+
|
|
7
|
+
function ymd(d: Date | null): string {
|
|
8
|
+
if (!d) return "null";
|
|
9
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("getPresetValue", () => {
|
|
13
|
+
it("today spans the start to end of the current day", () => {
|
|
14
|
+
const v = getPresetValue("today", NOW)!;
|
|
15
|
+
expect(ymd(v.start.date)).toBe("2026-06-10");
|
|
16
|
+
expect(ymd(v.end.date)).toBe("2026-06-10");
|
|
17
|
+
expect(v.start.date!.getHours()).toBe(0);
|
|
18
|
+
expect(v.start.date!.getMilliseconds()).toBe(0);
|
|
19
|
+
expect(v.end.date!.getHours()).toBe(23);
|
|
20
|
+
expect(v.end.date!.getMinutes()).toBe(59);
|
|
21
|
+
expect(v.end.date!.getMilliseconds()).toBe(999);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("yesterday and tomorrow shift by one day", () => {
|
|
25
|
+
expect(ymd(getPresetValue("yesterday", NOW)!.start.date)).toBe("2026-06-09");
|
|
26
|
+
expect(ymd(getPresetValue("yesterday", NOW)!.end.date)).toBe("2026-06-09");
|
|
27
|
+
expect(ymd(getPresetValue("tomorrow", NOW)!.start.date)).toBe("2026-06-11");
|
|
28
|
+
expect(ymd(getPresetValue("tomorrow", NOW)!.end.date)).toBe("2026-06-11");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("this_week runs Monday → Sunday around now", () => {
|
|
32
|
+
const v = getPresetValue("this_week", NOW)!;
|
|
33
|
+
expect(ymd(v.start.date)).toBe("2026-06-08"); // Monday
|
|
34
|
+
expect(v.start.date!.getDay()).toBe(1);
|
|
35
|
+
expect(ymd(v.end.date)).toBe("2026-06-14"); // Sunday
|
|
36
|
+
expect(v.end.date!.getDay()).toBe(0);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("this_month covers the whole calendar month", () => {
|
|
40
|
+
const v = getPresetValue("this_month", NOW)!;
|
|
41
|
+
expect(ymd(v.start.date)).toBe("2026-06-01");
|
|
42
|
+
expect(ymd(v.end.date)).toBe("2026-06-30");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("last_month covers the previous calendar month", () => {
|
|
46
|
+
const v = getPresetValue("last_month", NOW)!;
|
|
47
|
+
expect(ymd(v.start.date)).toBe("2026-05-01");
|
|
48
|
+
expect(ymd(v.end.date)).toBe("2026-05-31");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("custom carries no range", () => {
|
|
52
|
+
expect(getPresetValue("custom", NOW)).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("every preset id resolves (only custom is null)", () => {
|
|
56
|
+
for (const id of PRESET_IDS) {
|
|
57
|
+
const v = getPresetValue(id as PresetId, NOW);
|
|
58
|
+
if (id === "custom") expect(v).toBeNull();
|
|
59
|
+
else expect(v).not.toBeNull();
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
});
|