@lotics/ui 1.16.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "1.16.0",
3
+ "version": "1.17.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -104,6 +104,7 @@
104
104
  "./page_header": "./src/page_header.tsx",
105
105
  "./pager_view": "./src/pager_view.tsx",
106
106
  "./date_picker": "./src/date_picker.tsx",
107
+ "./date_filter": "./src/date_filter.tsx",
107
108
  "./time_picker": "./src/time_picker.tsx",
108
109
  "./time_field": "./src/time_field.tsx",
109
110
  "./date_calendar": "./src/date_calendar.tsx",
@@ -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
+ });
@@ -0,0 +1,100 @@
1
+ import type { DateFilterValue } from "./date_filter";
2
+
3
+ // Pure preset-range math for DateFilter. Kept free of React/RN so it can be
4
+ // unit-tested deterministically: every range is computed from an injected
5
+ // `now`, never an ambient clock.
6
+
7
+ export type PresetId =
8
+ | "custom"
9
+ | "today"
10
+ | "yesterday"
11
+ | "tomorrow"
12
+ | "this_week"
13
+ | "this_month"
14
+ | "last_month";
15
+
16
+ /** Display order. "custom" carries no range — it clears the value. */
17
+ export const PRESET_IDS: PresetId[] = [
18
+ "today",
19
+ "yesterday",
20
+ "tomorrow",
21
+ "this_week",
22
+ "this_month",
23
+ "last_month",
24
+ "custom",
25
+ ];
26
+
27
+ function startOfDay(date: Date): Date {
28
+ const d = new Date(date);
29
+ d.setHours(0, 0, 0, 0);
30
+ return d;
31
+ }
32
+
33
+ function endOfDay(date: Date): Date {
34
+ const d = new Date(date);
35
+ d.setHours(23, 59, 59, 999);
36
+ return d;
37
+ }
38
+
39
+ function startOfWeek(date: Date): Date {
40
+ const d = new Date(date);
41
+ const day = d.getDay();
42
+ const diff = d.getDate() - day + (day === 0 ? -6 : 1); // Monday is first day
43
+ d.setDate(diff);
44
+ d.setHours(0, 0, 0, 0);
45
+ return d;
46
+ }
47
+
48
+ function endOfWeek(date: Date): Date {
49
+ const start = startOfWeek(date);
50
+ const end = new Date(start);
51
+ end.setDate(end.getDate() + 6);
52
+ end.setHours(23, 59, 59, 999);
53
+ return end;
54
+ }
55
+
56
+ function startOfMonth(date: Date): Date {
57
+ return new Date(date.getFullYear(), date.getMonth(), 1);
58
+ }
59
+
60
+ function endOfMonth(date: Date): Date {
61
+ return new Date(date.getFullYear(), date.getMonth() + 1, 0, 23, 59, 59, 999);
62
+ }
63
+
64
+ function range(start: Date, end: Date): DateFilterValue {
65
+ return { start: { date: start, time: null }, end: { date: end, time: null } };
66
+ }
67
+
68
+ /**
69
+ * Resolve a preset to a concrete date range relative to `now`. Returns `null`
70
+ * for "custom" (no range — the caller clears the value so the user picks
71
+ * manually). The boundary math is identical to the view-page filter so a
72
+ * resolved range round-trips back to the same preset.
73
+ */
74
+ export function getPresetValue(id: PresetId, now: Date): DateFilterValue | null {
75
+ switch (id) {
76
+ case "today":
77
+ return range(startOfDay(now), endOfDay(now));
78
+ case "yesterday": {
79
+ const d = new Date(now);
80
+ d.setDate(d.getDate() - 1);
81
+ return range(startOfDay(d), endOfDay(d));
82
+ }
83
+ case "tomorrow": {
84
+ const d = new Date(now);
85
+ d.setDate(d.getDate() + 1);
86
+ return range(startOfDay(d), endOfDay(d));
87
+ }
88
+ case "this_week":
89
+ return range(startOfWeek(now), endOfWeek(now));
90
+ case "this_month":
91
+ return range(startOfMonth(now), endOfMonth(now));
92
+ case "last_month": {
93
+ const d = new Date(now);
94
+ d.setMonth(d.getMonth() - 1);
95
+ return range(startOfMonth(d), endOfMonth(d));
96
+ }
97
+ case "custom":
98
+ return null;
99
+ }
100
+ }