@lotics/ui 1.17.0 → 1.19.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.17.0",
3
+ "version": "1.19.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -105,6 +105,7 @@
105
105
  "./pager_view": "./src/pager_view.tsx",
106
106
  "./date_picker": "./src/date_picker.tsx",
107
107
  "./date_filter": "./src/date_filter.tsx",
108
+ "./date_range_filter_field": "./src/date_range_filter_field.tsx",
108
109
  "./time_picker": "./src/time_picker.tsx",
109
110
  "./time_field": "./src/time_field.tsx",
110
111
  "./date_calendar": "./src/date_calendar.tsx",
@@ -144,7 +145,10 @@
144
145
  "./grid/skeleton_row": "./src/grid/skeleton_row.tsx",
145
146
  "./grid/data_grid": "./src/grid/data_grid.tsx",
146
147
  "./grid/data_grid_context": "./src/grid/data_grid_context.ts",
147
- "./grid/search_highlight": "./src/grid/search_highlight.ts"
148
+ "./grid/search_highlight": "./src/grid/search_highlight.ts",
149
+ "./grid/sortable_header_cell": "./src/grid/sortable_header_cell.tsx",
150
+ "./grid/column_filter": "./src/grid/column_filter.tsx",
151
+ "./grid/data_grid_picker": "./src/grid/data_grid_picker.tsx"
148
152
  },
149
153
  "files": [
150
154
  "src"
@@ -0,0 +1,116 @@
1
+ import React, { useMemo, useState } from "react";
2
+ import { View } from "react-native";
3
+ import { Text } from "./text";
4
+ import { colors } from "./colors";
5
+ import { Icon } from "./icon";
6
+ import { Button } from "./button";
7
+ import { PressableHighlight } from "./pressable_highlight";
8
+ import { Popover, PopoverTrigger, PopoverContent, PopoverFooter } from "./popover";
9
+ import { DateFilter, DateFilterValue, DateFilterLabels } from "./date_filter";
10
+
11
+ // =============================================================================
12
+ // DateRangeFilterField — the common filter composition over DateFilter:
13
+ // a trigger button showing the selected range, a popover holding the DateFilter
14
+ // preset panel, and a Clear/Done footer. Use this for a date-range filter on a
15
+ // toolbar/form; use the bare `DateFilter` panel when the host owns its own
16
+ // trigger + footer (e.g. a grid column header).
17
+ // =============================================================================
18
+
19
+ export interface DateRangeFilterFieldLabels extends DateFilterLabels {
20
+ /** Footer: reset to no range. */
21
+ clear: string;
22
+ /** Footer: close the popover. */
23
+ done: string;
24
+ /** Trigger text when no range is selected. */
25
+ placeholder: string;
26
+ }
27
+
28
+ const DEFAULT_FIELD_LABELS = { clear: "Clear", done: "Done", placeholder: "All time" };
29
+
30
+ export interface DateRangeFilterFieldProps {
31
+ value: DateFilterValue;
32
+ onValueChange: (value: DateFilterValue) => void;
33
+ includeTime?: boolean;
34
+ /** Translated labels (presets + footer + placeholder). Defaults to English. */
35
+ labels?: Partial<DateRangeFilterFieldLabels>;
36
+ /** BCP-47 locale for the calendar + trigger date display. Defaults to "en-US". */
37
+ locale?: string;
38
+ testID?: string;
39
+ }
40
+
41
+ const EMPTY_VALUE: DateFilterValue = {
42
+ start: { date: null, time: null },
43
+ end: { date: null, time: null },
44
+ };
45
+
46
+ function formatDate(date: Date | null, locale: string | undefined): string {
47
+ if (!date) return "";
48
+ try {
49
+ return new Intl.DateTimeFormat(locale, {
50
+ day: "2-digit",
51
+ month: "2-digit",
52
+ year: "numeric",
53
+ }).format(date);
54
+ } catch {
55
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
56
+ }
57
+ }
58
+
59
+ export function DateRangeFilterField(props: DateRangeFilterFieldProps) {
60
+ const { value, onValueChange, includeTime, locale, testID } = props;
61
+ const labels = useMemo(() => ({ ...DEFAULT_FIELD_LABELS, ...props.labels }), [props.labels]);
62
+ const [open, setOpen] = useState(false);
63
+
64
+ const hasValue = Boolean(value.start.date || value.end.date);
65
+ const display = hasValue
66
+ ? `${formatDate(value.start.date, locale)} – ${formatDate(value.end.date, locale)}`
67
+ : labels.placeholder;
68
+
69
+ return (
70
+ <Popover open={open} onOpenChange={setOpen} side="bottom" align="start">
71
+ <PopoverTrigger>
72
+ <PressableHighlight
73
+ testID={testID}
74
+ accessibilityRole="button"
75
+ accessibilityLabel={labels.selectDateRange}
76
+ style={{
77
+ flexDirection: "row",
78
+ alignItems: "center",
79
+ gap: 8,
80
+ paddingVertical: 9,
81
+ paddingHorizontal: 12,
82
+ borderWidth: 1,
83
+ borderColor: colors.zinc[300],
84
+ borderRadius: 8,
85
+ backgroundColor: colors.white,
86
+ }}
87
+ >
88
+ <Icon name="calendar" size={16} color={hasValue ? colors.zinc[700] : colors.zinc[400]} />
89
+ <Text size="sm" color={hasValue ? "default" : "muted"} numberOfLines={1} style={{ flex: 1 }}>
90
+ {display}
91
+ </Text>
92
+ <Icon name="chevron-down" size={14} color={colors.zinc[400]} />
93
+ </PressableHighlight>
94
+ </PopoverTrigger>
95
+ <PopoverContent disableBodyScroll>
96
+ <DateFilter
97
+ value={value}
98
+ onValueChange={onValueChange}
99
+ includeTime={includeTime}
100
+ labels={props.labels}
101
+ locale={locale}
102
+ />
103
+ <PopoverFooter>
104
+ <View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between" }}>
105
+ {hasValue ? (
106
+ <Button title={labels.clear} onPress={() => onValueChange(EMPTY_VALUE)} />
107
+ ) : (
108
+ <View />
109
+ )}
110
+ <Button title={labels.done} color="secondary" onPress={() => setOpen(false)} />
111
+ </View>
112
+ </PopoverFooter>
113
+ </PopoverContent>
114
+ </Popover>
115
+ );
116
+ }
@@ -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
+ });