@lotics/ui 1.16.0 → 1.18.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.18.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",
@@ -143,7 +144,10 @@
143
144
  "./grid/skeleton_row": "./src/grid/skeleton_row.tsx",
144
145
  "./grid/data_grid": "./src/grid/data_grid.tsx",
145
146
  "./grid/data_grid_context": "./src/grid/data_grid_context.ts",
146
- "./grid/search_highlight": "./src/grid/search_highlight.ts"
147
+ "./grid/search_highlight": "./src/grid/search_highlight.ts",
148
+ "./grid/sortable_header_cell": "./src/grid/sortable_header_cell.tsx",
149
+ "./grid/column_filter": "./src/grid/column_filter.tsx",
150
+ "./grid/data_grid_picker": "./src/grid/data_grid_picker.tsx"
147
151
  },
148
152
  "files": [
149
153
  "src"
@@ -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
+ }
@@ -0,0 +1,195 @@
1
+ import { StyleSheet, View } from "react-native";
2
+ import { colors } from "../colors";
3
+ import { Text } from "../text";
4
+ import { Icon } from "../icon";
5
+ import { PressableHighlight } from "../pressable_highlight";
6
+ import { TextInputField } from "../text_input_field";
7
+ import { NumberInput } from "../number_input";
8
+ import { PickerMenu } from "../picker_menu";
9
+ import { Popover, PopoverTrigger, PopoverContent } from "../popover";
10
+ import type { PickerOption } from "../picker";
11
+
12
+ /** A column the picker can filter on. `type` selects the control + operators. */
13
+ export interface FilterableColumn {
14
+ key: string;
15
+ label: string;
16
+ type: "text" | "number" | "select";
17
+ /** Options for `type: "select"`. */
18
+ options?: PickerOption[];
19
+ }
20
+
21
+ /** Controlled value of one column's filter — the canonical per-type input. */
22
+ export type ColumnFilterValue =
23
+ | { kind: "text"; query: string }
24
+ | { kind: "number"; min: number | null; max: number | null }
25
+ | { kind: "select"; selected: string[] };
26
+
27
+ /** A `TableRecordFilters` condition node (the shape the app query RPC accepts). */
28
+ export interface FilterConditionNode {
29
+ node_type: "condition";
30
+ field_key: string;
31
+ type: "text" | "number" | "select";
32
+ operator: string;
33
+ value: unknown;
34
+ }
35
+
36
+ /**
37
+ * Map one column's filter value to query conditions (pure; reusable wherever a
38
+ * column filter must become a `TableRecordFilters` predicate). Returns 0+ nodes
39
+ * — a number range yields two, an empty filter yields none.
40
+ */
41
+ export function columnFilterToConditions(
42
+ column: FilterableColumn,
43
+ value: ColumnFilterValue | undefined,
44
+ ): FilterConditionNode[] {
45
+ if (!value) return [];
46
+ if (value.kind === "text") {
47
+ const q = value.query.trim();
48
+ return q
49
+ ? [{ node_type: "condition", field_key: column.key, type: "text", operator: "contains", value: q }]
50
+ : [];
51
+ }
52
+ if (value.kind === "number") {
53
+ const nodes: FilterConditionNode[] = [];
54
+ if (value.min != null) {
55
+ nodes.push({ node_type: "condition", field_key: column.key, type: "number", operator: "greater_than_or_equal_to", value: value.min });
56
+ }
57
+ if (value.max != null) {
58
+ nodes.push({ node_type: "condition", field_key: column.key, type: "number", operator: "less_than_or_equal_to", value: value.max });
59
+ }
60
+ return nodes;
61
+ }
62
+ return value.selected.length > 0
63
+ ? [{ node_type: "condition", field_key: column.key, type: "select", operator: "has_any_of", value: value.selected }]
64
+ : [];
65
+ }
66
+
67
+ /** Whether a value represents an active (non-empty) filter — drives the pill state. */
68
+ export function isColumnFilterActive(value: ColumnFilterValue | undefined): boolean {
69
+ if (!value) return false;
70
+ if (value.kind === "text") return value.query.trim().length > 0;
71
+ if (value.kind === "number") return value.min != null || value.max != null;
72
+ return value.selected.length > 0;
73
+ }
74
+
75
+ export interface ColumnFilterProps {
76
+ column: FilterableColumn;
77
+ value: ColumnFilterValue | undefined;
78
+ onChange: (value: ColumnFilterValue | undefined) => void;
79
+ /** Accessible name for the clear control. Pass a translated string. Default "Clear". */
80
+ clearLabel?: string;
81
+ }
82
+
83
+ /**
84
+ * A reusable per-column filter: a pill that opens a type-aware editor (text
85
+ * contains / number range / multi-select). Controlled — the consumer holds the
86
+ * `ColumnFilterValue` and maps it to query conditions via
87
+ * `columnFilterToConditions`. Pure UI; no data layer.
88
+ */
89
+ export function ColumnFilter(props: ColumnFilterProps) {
90
+ const { column, value, onChange, clearLabel = "Clear" } = props;
91
+ const active = isColumnFilterActive(value);
92
+
93
+ return (
94
+ <Popover side="bottom" align="start">
95
+ <PopoverTrigger>
96
+ <PressableHighlight
97
+ accessibilityRole="button"
98
+ accessibilityLabel={column.label}
99
+ style={[styles.pill, active && styles.pillActive]}
100
+ >
101
+ <Icon name="list-filter" size={13} color={active ? colors.zinc["950"] : colors.zinc["500"]} />
102
+ <Text size="sm" weight="medium" color={active ? "zinc-900" : "zinc-700"} numberOfLines={1}>
103
+ {column.label}
104
+ </Text>
105
+ <Icon name="chevron-down" size={14} color={colors.zinc["400"]} />
106
+ </PressableHighlight>
107
+ </PopoverTrigger>
108
+ <PopoverContent style={styles.content}>
109
+ {column.type === "text" ? (
110
+ <TextInputField
111
+ autoFocus
112
+ value={value?.kind === "text" ? value.query : ""}
113
+ onChangeText={(query) => onChange({ kind: "text", query })}
114
+ placeholder={column.label}
115
+ />
116
+ ) : column.type === "number" ? (
117
+ <View style={styles.range}>
118
+ <NumberInput
119
+ accessibilityLabel={`${column.label} min`}
120
+ value={value?.kind === "number" ? value.min : null}
121
+ onValueChange={(min) =>
122
+ onChange({ kind: "number", min, max: value?.kind === "number" ? value.max : null })
123
+ }
124
+ />
125
+ <Text size="sm" color="zinc-500">
126
+
127
+ </Text>
128
+ <NumberInput
129
+ accessibilityLabel={`${column.label} max`}
130
+ value={value?.kind === "number" ? value.max : null}
131
+ onValueChange={(max) =>
132
+ onChange({ kind: "number", min: value?.kind === "number" ? value.min : null, max })
133
+ }
134
+ />
135
+ </View>
136
+ ) : (
137
+ <PickerMenu
138
+ multi
139
+ enableSearch
140
+ options={column.options ?? []}
141
+ value={value?.kind === "select" ? value.selected : []}
142
+ onValueChange={(selected) => onChange({ kind: "select", selected })}
143
+ />
144
+ )}
145
+ {active ? (
146
+ <PressableHighlight
147
+ accessibilityRole="button"
148
+ accessibilityLabel={clearLabel}
149
+ onPress={() => onChange(undefined)}
150
+ style={styles.clear}
151
+ >
152
+ <Icon name="x" size={14} color={colors.zinc["500"]} />
153
+ <Text size="sm" color="zinc-500">
154
+ {clearLabel}
155
+ </Text>
156
+ </PressableHighlight>
157
+ ) : null}
158
+ </PopoverContent>
159
+ </Popover>
160
+ );
161
+ }
162
+
163
+ const styles = StyleSheet.create({
164
+ pill: {
165
+ flexDirection: "row",
166
+ alignItems: "center",
167
+ gap: 5,
168
+ height: 32,
169
+ paddingHorizontal: 10,
170
+ borderRadius: 8,
171
+ borderWidth: 1,
172
+ borderColor: colors.border,
173
+ backgroundColor: colors.background,
174
+ },
175
+ pillActive: {
176
+ borderColor: colors.zinc["400"],
177
+ backgroundColor: colors.zinc["100"],
178
+ },
179
+ content: {
180
+ minWidth: 240,
181
+ gap: 8,
182
+ },
183
+ range: {
184
+ flexDirection: "row",
185
+ alignItems: "center",
186
+ gap: 8,
187
+ },
188
+ clear: {
189
+ flexDirection: "row",
190
+ alignItems: "center",
191
+ gap: 6,
192
+ paddingVertical: 6,
193
+ paddingHorizontal: 4,
194
+ },
195
+ });
@@ -0,0 +1,322 @@
1
+ import { useCallback, useMemo } from "react";
2
+ import { StyleSheet, View } from "react-native";
3
+ import { colors } from "../colors";
4
+ import { Text } from "../text";
5
+ import { SearchInput } from "../search_input";
6
+ import { ActivityIndicator } from "../activity_indicator";
7
+ import { Dialog } from "../dialog";
8
+ import { PressableHighlight } from "../pressable_highlight";
9
+ import { DataGrid, type DataGridColumn, type DataGridGroup } from "./data_grid";
10
+ import { SortableHeaderCell, type SortOrder } from "./sortable_header_cell";
11
+ import {
12
+ ColumnFilter,
13
+ type FilterableColumn,
14
+ type ColumnFilterValue,
15
+ } from "./column_filter";
16
+
17
+ /** A grid column the picker displays. Sorting/filtering are opt-in per column. */
18
+ export interface PickerColumn<TRow> {
19
+ key: string;
20
+ label: string;
21
+ /** Fixed pixel width. Omit to let the grid size it. */
22
+ width?: number;
23
+ /** Show a sortable header for this column. Requires `sort`/`onSortChange`. */
24
+ sortable?: boolean;
25
+ /**
26
+ * Cell content for this column. Default: a single-line text of the row's
27
+ * value at `key` (objects render their `label`/`display`/`name`).
28
+ */
29
+ renderCell?: (row: TRow) => React.ReactNode;
30
+ }
31
+
32
+ /** Single-column sort state — the picker sorts by one column at a time. */
33
+ export interface DataGridPickerSort {
34
+ key: string;
35
+ order: SortOrder;
36
+ }
37
+
38
+ export interface DataGridPickerProps<TRow extends Record<string, unknown>> {
39
+ open: boolean;
40
+ onOpenChange: (open: boolean) => void;
41
+ /** Heading shown above the search box. */
42
+ title?: string;
43
+
44
+ columns: PickerColumn<TRow>[];
45
+ rows: TRow[];
46
+ /** Stable identity for a row — the value returned by selection. */
47
+ rowIdGetter: (row: TRow) => string;
48
+
49
+ /** Currently selected row id, or null. Highlights the matching row. */
50
+ value: string | null;
51
+ /** Called with the picked row id. */
52
+ onValueChange: (id: string) => void;
53
+ /** Close the dialog after a pick. Default `true`. */
54
+ closeOnSelect?: boolean;
55
+
56
+ /** Controlled search term. The consumer owns it and re-queries. */
57
+ searchQuery: string;
58
+ onSearchChange: (query: string) => void;
59
+ searchPlaceholder?: string;
60
+
61
+ /**
62
+ * Controlled single-column sort. Omit `onSortChange` to disable sorting
63
+ * entirely (headers render static). Toggling a column cycles asc → desc → off.
64
+ */
65
+ sort?: DataGridPickerSort | null;
66
+ onSortChange?: (sort: DataGridPickerSort | null) => void;
67
+
68
+ /**
69
+ * Per-column filters shown as a pill row. The consumer holds `filterValues`
70
+ * (keyed by column key) and maps them to query conditions via
71
+ * `columnFilterToConditions`.
72
+ */
73
+ filters?: FilterableColumn[];
74
+ filterValues?: Record<string, ColumnFilterValue>;
75
+ onFilterChange?: (key: string, value: ColumnFilterValue | undefined) => void;
76
+ /** Accessible name for the per-filter clear control. Pass a translated string. */
77
+ clearLabel?: string;
78
+
79
+ /** Called when the grid scrolls near the end — load the next page. */
80
+ onEndReached?: () => void;
81
+
82
+ /** A request is in flight. Shows a spinner (overlay when rows exist). */
83
+ loading?: boolean;
84
+ /** Message when there are no rows and nothing is loading. */
85
+ emptyLabel?: string;
86
+
87
+ rowHeight?: number;
88
+ testID?: string;
89
+ }
90
+
91
+ /** Render an arbitrary cell value as a single line when no `renderCell` is given. */
92
+ function defaultCellText(value: unknown): string {
93
+ if (value == null) return "";
94
+ if (typeof value === "string") return value;
95
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
96
+ if (Array.isArray(value)) return value.map(defaultCellText).filter(Boolean).join(", ");
97
+ if (typeof value === "object") {
98
+ const o = value as { label?: unknown; display?: unknown; name?: unknown };
99
+ for (const k of [o.label, o.display, o.name]) {
100
+ if (typeof k === "string") return k;
101
+ }
102
+ }
103
+ return "";
104
+ }
105
+
106
+ const DEFAULT_ROW_HEIGHT = 48;
107
+
108
+ /**
109
+ * A data-agnostic record-style picker: a modal holding a virtualized grid the
110
+ * user can browse, search, sort, and filter, then pick one row. It knows nothing
111
+ * about records/tables/fields — the consumer supplies `columns` + `rows` and
112
+ * owns the search/sort/filter/pagination state (so the same component drives a
113
+ * server-side query, an in-memory list, or any other source). Compose it with a
114
+ * domain hook (e.g. an app's `useRecordSearch`) to build a record picker, or
115
+ * with any other row source for a different purpose.
116
+ *
117
+ * Single-select: clicking any cell in a row picks that row's id. The selected
118
+ * row is highlighted; `closeOnSelect` (default) dismisses the modal on pick.
119
+ */
120
+ export function DataGridPicker<TRow extends Record<string, unknown>>(
121
+ props: DataGridPickerProps<TRow>,
122
+ ) {
123
+ const {
124
+ open,
125
+ onOpenChange,
126
+ title,
127
+ columns,
128
+ rows,
129
+ rowIdGetter,
130
+ value,
131
+ onValueChange,
132
+ closeOnSelect = true,
133
+ searchQuery,
134
+ onSearchChange,
135
+ searchPlaceholder,
136
+ sort = null,
137
+ onSortChange,
138
+ filters,
139
+ filterValues,
140
+ onFilterChange,
141
+ clearLabel,
142
+ onEndReached,
143
+ loading = false,
144
+ emptyLabel,
145
+ rowHeight = DEFAULT_ROW_HEIGHT,
146
+ testID,
147
+ } = props;
148
+
149
+ const handleSelect = useCallback(
150
+ (id: string) => {
151
+ onValueChange(id);
152
+ if (closeOnSelect) onOpenChange(false);
153
+ },
154
+ [onValueChange, closeOnSelect, onOpenChange],
155
+ );
156
+
157
+ const toggleSort = useCallback(
158
+ (key: string) => {
159
+ if (!onSortChange) return;
160
+ if (!sort || sort.key !== key) onSortChange({ key, order: "asc" });
161
+ else if (sort.order === "asc") onSortChange({ key, order: "desc" });
162
+ else onSortChange(null);
163
+ },
164
+ [sort, onSortChange],
165
+ );
166
+
167
+ const gridColumns = useMemo<DataGridColumn<TRow>[]>(
168
+ () =>
169
+ columns.map((col) => ({
170
+ key: col.key,
171
+ name: col.label,
172
+ width: col.width,
173
+ renderHeaderCell:
174
+ col.sortable && onSortChange
175
+ ? () => (
176
+ <SortableHeaderCell
177
+ label={col.label}
178
+ order={sort?.key === col.key ? sort.order : null}
179
+ onToggle={() => toggleSort(col.key)}
180
+ testID={`picker-sort-${col.key}`}
181
+ />
182
+ )
183
+ : undefined,
184
+ renderCell: ({ row }: { row: TRow }) => (
185
+ <PressableHighlight
186
+ accessibilityRole="button"
187
+ onPress={() => handleSelect(rowIdGetter(row))}
188
+ style={styles.cell}
189
+ >
190
+ {col.renderCell ? (
191
+ col.renderCell(row)
192
+ ) : (
193
+ <Text size="sm" color="zinc-900" numberOfLines={1}>
194
+ {defaultCellText(row[col.key])}
195
+ </Text>
196
+ )}
197
+ </PressableHighlight>
198
+ ),
199
+ })),
200
+ [columns, sort, onSortChange, toggleSort, handleSelect, rowIdGetter],
201
+ );
202
+
203
+ const groups = useMemo<DataGridGroup<TRow>[]>(
204
+ () => (rows.length > 0 ? [{ value: null, columnKey: "", rows }] : []),
205
+ [rows],
206
+ );
207
+
208
+ const rowColorGetter = useCallback(
209
+ (row: TRow) =>
210
+ value != null && rowIdGetter(row) === value ? colors.blue["50"] : undefined,
211
+ [value, rowIdGetter],
212
+ );
213
+
214
+ return (
215
+ <Dialog open={open} onOpenChange={onOpenChange} height="90%" testID={testID}>
216
+ <View style={styles.root}>
217
+ {title ? (
218
+ <Text size="lg" weight="semibold" color="zinc-900" style={styles.title}>
219
+ {title}
220
+ </Text>
221
+ ) : null}
222
+
223
+ <SearchInput
224
+ testID="data-grid-picker-search"
225
+ value={searchQuery}
226
+ onChangeText={onSearchChange}
227
+ placeholder={searchPlaceholder}
228
+ style={styles.search}
229
+ />
230
+
231
+ {filters && filters.length > 0 ? (
232
+ <View style={styles.filterRow}>
233
+ {filters.map((f) => (
234
+ <ColumnFilter
235
+ key={f.key}
236
+ column={f}
237
+ value={filterValues?.[f.key]}
238
+ onChange={(v) => onFilterChange?.(f.key, v)}
239
+ clearLabel={clearLabel}
240
+ />
241
+ ))}
242
+ </View>
243
+ ) : null}
244
+
245
+ <View style={styles.gridArea}>
246
+ {groups.length > 0 ? (
247
+ <DataGrid<TRow>
248
+ rowIdGetter={rowIdGetter}
249
+ rowHeight={rowHeight}
250
+ groups={groups}
251
+ columns={gridColumns}
252
+ rowColorGetter={rowColorGetter}
253
+ frozenColumnCount={0}
254
+ enableReordering={false}
255
+ onEndReached={onEndReached}
256
+ />
257
+ ) : (
258
+ <View style={styles.center}>
259
+ {loading ? (
260
+ <ActivityIndicator />
261
+ ) : (
262
+ <Text size="sm" color="zinc-500">
263
+ {emptyLabel ?? "No results"}
264
+ </Text>
265
+ )}
266
+ </View>
267
+ )}
268
+ {loading && groups.length > 0 ? (
269
+ <View style={styles.loadingOverlay} pointerEvents="none">
270
+ <ActivityIndicator />
271
+ </View>
272
+ ) : null}
273
+ </View>
274
+ </View>
275
+ </Dialog>
276
+ );
277
+ }
278
+
279
+ const styles = StyleSheet.create({
280
+ root: {
281
+ flex: 1,
282
+ width: "100%",
283
+ gap: 12,
284
+ paddingHorizontal: 4,
285
+ },
286
+ title: {
287
+ paddingHorizontal: 4,
288
+ },
289
+ search: {
290
+ width: "100%",
291
+ },
292
+ filterRow: {
293
+ flexDirection: "row",
294
+ flexWrap: "wrap",
295
+ alignItems: "center",
296
+ gap: 8,
297
+ },
298
+ gridArea: {
299
+ flex: 1,
300
+ borderWidth: 1,
301
+ borderColor: colors.border,
302
+ borderRadius: 12,
303
+ overflow: "hidden",
304
+ },
305
+ cell: {
306
+ flex: 1,
307
+ height: "100%",
308
+ justifyContent: "center",
309
+ paddingHorizontal: 8,
310
+ },
311
+ center: {
312
+ flex: 1,
313
+ alignItems: "center",
314
+ justifyContent: "center",
315
+ padding: 24,
316
+ },
317
+ loadingOverlay: {
318
+ position: "absolute",
319
+ top: 8,
320
+ right: 8,
321
+ },
322
+ });
@@ -0,0 +1,58 @@
1
+ import { StyleSheet } from "react-native";
2
+ import { colors } from "../colors";
3
+ import { Text } from "../text";
4
+ import { Icon } from "../icon";
5
+ import { PressableHighlight } from "../pressable_highlight";
6
+
7
+ export type SortOrder = "asc" | "desc";
8
+
9
+ export interface SortableHeaderCellProps {
10
+ label: string;
11
+ /** Current sort order for THIS column, or null when another (or no) column
12
+ * is the sort key. */
13
+ order: SortOrder | null;
14
+ /** Toggle this column's sort. The consumer owns sort state and re-queries. */
15
+ onToggle: () => void;
16
+ testID?: string;
17
+ }
18
+
19
+ /**
20
+ * A clickable column header that cycles/toggles sort and shows the direction —
21
+ * drop it into any `DataGrid` column's `renderHeaderCell`. Pure presentation:
22
+ * the consumer holds the sort state and reorders/re-queries the rows. Reusable
23
+ * outside the picker (any sortable grid).
24
+ */
25
+ export function SortableHeaderCell(props: SortableHeaderCellProps) {
26
+ const { label, order, onToggle, testID } = props;
27
+ const active = order !== null;
28
+ return (
29
+ <PressableHighlight
30
+ testID={testID}
31
+ onPress={onToggle}
32
+ accessibilityRole="button"
33
+ accessibilityLabel={label}
34
+ style={styles.cell}
35
+ >
36
+ <Text size="sm" weight="medium" color={active ? "zinc-900" : "zinc-500"} numberOfLines={1}>
37
+ {label}
38
+ </Text>
39
+ <Icon
40
+ name={order === "asc" ? "chevron-up" : order === "desc" ? "chevron-down" : "chevrons-up-down"}
41
+ size={14}
42
+ color={active ? colors.zinc["700"] : colors.zinc["400"]}
43
+ />
44
+ </PressableHighlight>
45
+ );
46
+ }
47
+
48
+ const styles = StyleSheet.create({
49
+ cell: {
50
+ flexDirection: "row",
51
+ alignItems: "center",
52
+ justifyContent: "space-between",
53
+ gap: 4,
54
+ flex: 1,
55
+ height: "100%",
56
+ paddingHorizontal: 8,
57
+ },
58
+ });