@lotics/ui 1.21.0 → 1.23.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,12 +1,13 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "1.21.0",
3
+ "version": "1.23.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
7
7
  "./colors": "./src/colors.ts",
8
8
  "./mime": "./src/mime.ts",
9
9
  "./download": "./src/download.ts",
10
+ "./comments_thread": "./src/comments_thread.tsx",
10
11
  "./file_badge": "./src/file_badge.tsx",
11
12
  "./file_thumbnail": "./src/file_thumbnail.tsx",
12
13
  "./file_preview": {
@@ -150,9 +151,8 @@
150
151
  "./grid/data_grid": "./src/grid/data_grid.tsx",
151
152
  "./grid/data_grid_context": "./src/grid/data_grid_context.ts",
152
153
  "./grid/search_highlight": "./src/grid/search_highlight.ts",
153
- "./grid/sortable_header_cell": "./src/grid/sortable_header_cell.tsx",
154
- "./grid/column_filter": "./src/grid/column_filter.tsx",
155
- "./grid/data_grid_picker": "./src/grid/data_grid_picker.tsx"
154
+ "./column_filter": "./src/column_filter.tsx",
155
+ "./table_picker": "./src/table_picker.tsx"
156
156
  },
157
157
  "files": [
158
158
  "src"
@@ -1,13 +1,13 @@
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";
1
+ import { View, StyleSheet } from "react-native";
2
+ import { Text } from "./text";
3
+ import { Icon } from "./icon";
4
+ import { colors } from "./colors";
5
+ import { TextInputField } from "./text_input_field";
6
+ import { NumberInput } from "./number_input";
7
+ import { PickerMenu } from "./picker_menu";
8
+ import { PillButton } from "./pill_button";
9
+ import { Popover, PopoverTrigger, PopoverContent } from "./popover";
10
+ import type { PickerOption } from "./picker";
11
11
 
12
12
  /** A column the picker can filter on. `type` selects the control + operators. */
13
13
  export interface FilterableColumn {
@@ -72,18 +72,39 @@ export function isColumnFilterActive(value: ColumnFilterValue | undefined): bool
72
72
  return value.selected.length > 0;
73
73
  }
74
74
 
75
+ /** A short human summary of an active value, for the pill label ("Vàng, Đỏ", "≥ 10"). */
76
+ export function columnFilterSummary(
77
+ column: FilterableColumn,
78
+ value: ColumnFilterValue | undefined,
79
+ ): string {
80
+ if (!value) return "";
81
+ if (value.kind === "text") return value.query.trim();
82
+ if (value.kind === "number") {
83
+ const { min, max } = value;
84
+ if (min != null && max != null) return `${min}–${max}`;
85
+ if (min != null) return `≥ ${min}`;
86
+ if (max != null) return `≤ ${max}`;
87
+ return "";
88
+ }
89
+ return value.selected
90
+ .map((k) => column.options?.find((o) => o.value === k)?.label ?? k)
91
+ .join(", ");
92
+ }
93
+
75
94
  export interface ColumnFilterProps {
76
95
  column: FilterableColumn;
77
96
  value: ColumnFilterValue | undefined;
78
97
  onChange: (value: ColumnFilterValue | undefined) => void;
79
- /** Accessible name for the clear control. Pass a translated string. Default "Clear". */
98
+ /** Accessible name for the clear (X) control. Pass a translated string. Default "Clear". */
80
99
  clearLabel?: string;
81
100
  }
82
101
 
83
102
  /**
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
103
+ * A toolbar-style filter pill: the same `PillButton` the table toolbar uses, so
104
+ * a picker's filters read identically. Inactive "Label ⌄"; active
105
+ * "Label: summary" with an X to clear. Pressing the pill opens a type-aware
106
+ * editor (text contains / number range / multi-select). Controlled — the
107
+ * consumer holds the `ColumnFilterValue` and maps it to query conditions via
87
108
  * `columnFilterToConditions`. Pure UI; no data layer.
88
109
  */
89
110
  export function ColumnFilter(props: ColumnFilterProps) {
@@ -93,17 +114,15 @@ export function ColumnFilter(props: ColumnFilterProps) {
93
114
  return (
94
115
  <Popover side="bottom" align="start">
95
116
  <PopoverTrigger>
96
- <PressableHighlight
97
- accessibilityRole="button"
98
- accessibilityLabel={column.label}
99
- style={[styles.pill, active && styles.pillActive]}
117
+ <PillButton
118
+ onDismiss={active ? () => onChange(undefined) : undefined}
119
+ dismissTooltip={clearLabel}
100
120
  >
101
- <Icon name="list-filter" size={13} color={active ? colors.zinc["950"] : colors.zinc["500"]} />
102
121
  <Text size="sm" weight="medium" color={active ? "zinc-900" : "zinc-700"} numberOfLines={1}>
103
- {column.label}
122
+ {active ? `${column.label}: ${columnFilterSummary(column, value)}` : column.label}
104
123
  </Text>
105
- <Icon name="chevron-down" size={14} color={colors.zinc["400"]} />
106
- </PressableHighlight>
124
+ {!active ? <Icon name="chevron-down" size={14} color={colors.zinc["400"]} /> : null}
125
+ </PillButton>
107
126
  </PopoverTrigger>
108
127
  <PopoverContent style={styles.content}>
109
128
  {column.type === "text" ? (
@@ -142,40 +161,12 @@ export function ColumnFilter(props: ColumnFilterProps) {
142
161
  onValueChange={(selected) => onChange({ kind: "select", selected })}
143
162
  />
144
163
  )}
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
164
  </PopoverContent>
159
165
  </Popover>
160
166
  );
161
167
  }
162
168
 
163
169
  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
170
  content: {
180
171
  minWidth: 240,
181
172
  gap: 8,
@@ -185,11 +176,4 @@ const styles = StyleSheet.create({
185
176
  alignItems: "center",
186
177
  gap: 8,
187
178
  },
188
- clear: {
189
- flexDirection: "row",
190
- alignItems: "center",
191
- gap: 6,
192
- paddingVertical: 6,
193
- paddingHorizontal: 4,
194
- },
195
179
  });
@@ -0,0 +1,378 @@
1
+ import { useMemo, useState, type ReactNode } from "react";
2
+ import { View } from "react-native";
3
+ import { Text } from "./text";
4
+ import { Spacer } from "./spacer";
5
+ import { Stack } from "./stack";
6
+ import { Avatar } from "./avatar";
7
+ import { Button } from "./button";
8
+ import { IconButton } from "./icon_button";
9
+ import { MenuButton } from "./menu_button";
10
+ import { FileBadge } from "./file_badge";
11
+ import { TextInputField } from "./text_input_field";
12
+ import { Popover, PopoverTrigger, PopoverContent } from "./popover";
13
+
14
+ /**
15
+ * Presentational record-comments primitives — the view, edit, and delete of a
16
+ * comment thread, with every data, identity, file, and string concern injected.
17
+ * This is the one implementation shared by the product (`frontend`) and
18
+ * custom-code apps; neither owns a private copy of the per-comment rendering.
19
+ *
20
+ * Pure by the package contract: no data fetching, no i18n (strings come in via
21
+ * `labels`), no domain-type imports, no analytics. The consumer wires those —
22
+ * the product through its live-query + member directory, an app through
23
+ * `useComments` + `useMembers` from `@lotics/app-sdk`.
24
+ */
25
+
26
+ export interface ThreadMember {
27
+ id: string;
28
+ name: string | null;
29
+ image?: string | null;
30
+ email?: string | null;
31
+ }
32
+
33
+ export interface ThreadFile {
34
+ id: string;
35
+ filename: string;
36
+ mime_type: string;
37
+ url?: string;
38
+ thumbnail_url?: string;
39
+ }
40
+
41
+ export interface ThreadComment {
42
+ id: string;
43
+ member_id: string;
44
+ content: string;
45
+ files?: ThreadFile[] | null;
46
+ created_at: string;
47
+ updated_at: string;
48
+ }
49
+
50
+ export interface CommentListLabels {
51
+ empty: string;
52
+ edit: string;
53
+ delete: string;
54
+ edited: string;
55
+ save: string;
56
+ cancel: string;
57
+ /** Accessible name for the per-comment actions trigger. */
58
+ actions: string;
59
+ /** Shown when a comment's author can't be resolved. */
60
+ unknownMember: string;
61
+ editPlaceholder: string;
62
+ }
63
+
64
+ const DEFAULT_LABELS: CommentListLabels = {
65
+ empty: "No comments yet.",
66
+ edit: "Edit",
67
+ delete: "Delete",
68
+ edited: "edited",
69
+ save: "Save",
70
+ cancel: "Cancel",
71
+ actions: "Comment actions",
72
+ unknownMember: "Unknown member",
73
+ editPlaceholder: "Edit comment…",
74
+ };
75
+
76
+ /** Render-prop the consumer can supply to drive its own edit UI (e.g. with file editing). */
77
+ export interface CommentEditFormProps {
78
+ comment: ThreadComment;
79
+ onSave: (content: string, files?: ThreadFile[] | null) => void;
80
+ onCancel: () => void;
81
+ submitting: boolean;
82
+ labels: CommentListLabels;
83
+ }
84
+
85
+ export interface CommentListProps {
86
+ comments: ThreadComment[];
87
+ /** The viewer's member id — drives the author-only edit/delete affordance. Null hides it. */
88
+ currentMemberId: string | null;
89
+ resolveMember: (memberId: string) => ThreadMember | null;
90
+ /** Called on inline-edit save. Omit to make comments read-only (no edit affordance). */
91
+ onEdit?: (id: string, content: string, files?: ThreadFile[] | null) => void | Promise<void>;
92
+ /** Called on delete. Omit to hide the delete affordance. */
93
+ onDelete?: (id: string) => void | Promise<void>;
94
+ /** Render a comment's attachments. Defaults to a row of file badges + names. */
95
+ renderFiles?: (files: ThreadFile[]) => ReactNode;
96
+ /** Render the inline edit form. Defaults to a text-only editor (preserves existing files). */
97
+ renderEditForm?: (props: CommentEditFormProps) => ReactNode;
98
+ /** Format a comment's `created_at`. Defaults to the locale date-time string. */
99
+ formatTimestamp?: (iso: string) => string;
100
+ /** Shown when there are no comments. Defaults to the `empty` label. */
101
+ emptyState?: ReactNode;
102
+ labels?: Partial<CommentListLabels>;
103
+ }
104
+
105
+ function defaultFormatTimestamp(iso: string): string {
106
+ const d = new Date(iso);
107
+ return isNaN(d.getTime()) ? iso : d.toLocaleString();
108
+ }
109
+
110
+ function DefaultFiles({ files }: { files: ThreadFile[] }) {
111
+ return (
112
+ <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
113
+ {files.map((f) => (
114
+ <View key={f.id} style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
115
+ <FileBadge mimeType={f.mime_type} />
116
+ <Text size="sm" numberOfLines={1}>
117
+ {f.filename}
118
+ </Text>
119
+ </View>
120
+ ))}
121
+ </View>
122
+ );
123
+ }
124
+
125
+ function DefaultEditForm(props: CommentEditFormProps) {
126
+ const { comment, onSave, onCancel, submitting, labels } = props;
127
+ const [content, setContent] = useState(comment.content);
128
+ const canSave = content.trim().length > 0 || (comment.files?.length ?? 0) > 0;
129
+ return (
130
+ <View>
131
+ <TextInputField
132
+ autoFocus
133
+ value={content}
134
+ onChangeText={setContent}
135
+ multiline
136
+ numberOfLines={3}
137
+ placeholder={labels.editPlaceholder}
138
+ />
139
+ <Spacer size={8} />
140
+ <View style={{ flexDirection: "row", gap: 8, justifyContent: "flex-end" }}>
141
+ <Button title={labels.cancel} color="secondary" disabled={submitting} onPress={onCancel} />
142
+ <Button
143
+ title={labels.save}
144
+ color="primary"
145
+ disabled={!canSave || submitting}
146
+ loading={submitting}
147
+ // Preserve the comment's current files — a text edit never drops them.
148
+ onPress={() => onSave(content.trim(), comment.files)}
149
+ />
150
+ </View>
151
+ </View>
152
+ );
153
+ }
154
+
155
+ interface CommentRowProps {
156
+ comment: ThreadComment;
157
+ member: ThreadMember | null;
158
+ isOwn: boolean;
159
+ onEdit?: CommentListProps["onEdit"];
160
+ onDelete?: CommentListProps["onDelete"];
161
+ renderFiles: (files: ThreadFile[]) => ReactNode;
162
+ renderEditForm: (props: CommentEditFormProps) => ReactNode;
163
+ formatTimestamp: (iso: string) => string;
164
+ labels: CommentListLabels;
165
+ }
166
+
167
+ function CommentRow(props: CommentRowProps) {
168
+ const { comment, member, isOwn, onEdit, onDelete, renderFiles, renderEditForm } = props;
169
+ const { formatTimestamp, labels } = props;
170
+ const [isEditing, setIsEditing] = useState(false);
171
+ const [submitting, setSubmitting] = useState(false);
172
+ const [menuOpen, setMenuOpen] = useState(false);
173
+
174
+ const displayName = member?.name || member?.email || labels.unknownMember;
175
+ const imageSrc = member?.image ? { uri: member.image } : undefined;
176
+ const files = comment.files ?? [];
177
+ const showActions = isOwn && !isEditing && (onEdit != null || onDelete != null);
178
+
179
+ const handleSave = async (content: string, savedFiles?: ThreadFile[] | null) => {
180
+ if (!onEdit) return;
181
+ setSubmitting(true);
182
+ try {
183
+ await onEdit(comment.id, content, savedFiles);
184
+ setIsEditing(false);
185
+ } catch {
186
+ // The data layer owns rollback + surfacing (the optimistic edit reverts);
187
+ // keep the form open for retry and don't let the rejection go unhandled.
188
+ } finally {
189
+ setSubmitting(false);
190
+ }
191
+ };
192
+
193
+ const handleDelete = async () => {
194
+ if (!onDelete || submitting) return;
195
+ setMenuOpen(false);
196
+ setSubmitting(true);
197
+ try {
198
+ await onDelete(comment.id);
199
+ } catch {
200
+ // Rollback re-shows the row (the visible feedback); swallow the rejection.
201
+ } finally {
202
+ setSubmitting(false);
203
+ }
204
+ };
205
+
206
+ return (
207
+ <View style={{ flexDirection: "row", gap: 8 }}>
208
+ <View style={{ flex: 1, flexDirection: "row", gap: 8 }}>
209
+ <View style={{ paddingTop: 4 }}>
210
+ <Avatar source={imageSrc} name={displayName} />
211
+ </View>
212
+ <View style={{ flex: 1 }}>
213
+ <Text weight="medium">{displayName}</Text>
214
+ <Text size="sm" color="zinc-500">
215
+ {formatTimestamp(comment.created_at)}
216
+ {comment.updated_at !== comment.created_at ? ` · ${labels.edited}` : ""}
217
+ </Text>
218
+ <Spacer size={8} />
219
+ {isEditing ? (
220
+ renderEditForm({
221
+ comment,
222
+ onSave: handleSave,
223
+ onCancel: () => setIsEditing(false),
224
+ submitting,
225
+ labels,
226
+ })
227
+ ) : (
228
+ <View>
229
+ {comment.content ? <Text>{comment.content}</Text> : null}
230
+ {files.length > 0 ? (
231
+ <>
232
+ {comment.content ? <Spacer size={8} /> : null}
233
+ {renderFiles(files)}
234
+ </>
235
+ ) : null}
236
+ </View>
237
+ )}
238
+ </View>
239
+ </View>
240
+ {showActions ? (
241
+ <Popover open={menuOpen} onOpenChange={setMenuOpen}>
242
+ <PopoverTrigger>
243
+ <IconButton icon="ellipsis" tooltip={labels.actions} />
244
+ </PopoverTrigger>
245
+ <PopoverContent>
246
+ <Stack>
247
+ {onEdit ? (
248
+ <MenuButton
249
+ icon="pencil"
250
+ title={labels.edit}
251
+ onPress={() => {
252
+ setMenuOpen(false);
253
+ setIsEditing(true);
254
+ }}
255
+ />
256
+ ) : null}
257
+ {onDelete ? (
258
+ <MenuButton
259
+ icon="trash"
260
+ title={labels.delete}
261
+ danger
262
+ disabled={submitting}
263
+ onPress={handleDelete}
264
+ />
265
+ ) : null}
266
+ </Stack>
267
+ </PopoverContent>
268
+ </Popover>
269
+ ) : null}
270
+ </View>
271
+ );
272
+ }
273
+
274
+ /**
275
+ * The comment list — one author/avatar/timestamp/content/files row per comment,
276
+ * oldest first, with an author-only edit/delete menu. Pair it with a composer
277
+ * (`CommentComposer`, or a richer consumer-owned one) for the full thread.
278
+ */
279
+ export function CommentList(props: CommentListProps) {
280
+ const labels = { ...DEFAULT_LABELS, ...props.labels };
281
+ const renderFiles = props.renderFiles ?? ((files: ThreadFile[]) => <DefaultFiles files={files} />);
282
+ const renderEditForm = props.renderEditForm ?? ((p: CommentEditFormProps) => <DefaultEditForm {...p} />);
283
+ const formatTimestamp = props.formatTimestamp ?? defaultFormatTimestamp;
284
+
285
+ const sorted = useMemo(
286
+ () =>
287
+ [...props.comments].sort(
288
+ (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
289
+ ),
290
+ [props.comments],
291
+ );
292
+
293
+ if (sorted.length === 0) {
294
+ return <>{props.emptyState ?? <Text color="zinc-500">{labels.empty}</Text>}</>;
295
+ }
296
+
297
+ return (
298
+ <View>
299
+ {sorted.map((comment, i) => (
300
+ <View key={comment.id}>
301
+ {i > 0 ? <Spacer size={16} /> : null}
302
+ <CommentRow
303
+ comment={comment}
304
+ member={props.resolveMember(comment.member_id)}
305
+ isOwn={props.currentMemberId != null && props.currentMemberId === comment.member_id}
306
+ onEdit={props.onEdit}
307
+ onDelete={props.onDelete}
308
+ renderFiles={renderFiles}
309
+ renderEditForm={renderEditForm}
310
+ formatTimestamp={formatTimestamp}
311
+ labels={labels}
312
+ />
313
+ </View>
314
+ ))}
315
+ </View>
316
+ );
317
+ }
318
+
319
+ export interface CommentComposerProps {
320
+ /** Submit a new comment. Cleared on success; left intact on throw so the draft survives. */
321
+ onSubmit: (content: string) => void | Promise<void>;
322
+ placeholder: string;
323
+ sendLabel: string;
324
+ /** Disable input + send (e.g. comments unavailable for an anonymous viewer). */
325
+ disabled?: boolean;
326
+ /** Extra controls left of the send button — e.g. an attach-file button. */
327
+ actions?: ReactNode;
328
+ }
329
+
330
+ /**
331
+ * A minimal text comment composer for consumers without a richer one of their
332
+ * own (the product uses its file-capable chat composer instead). Multiline
333
+ * input + a send button; attachments are opt-in via the `actions` slot.
334
+ */
335
+ export function CommentComposer(props: CommentComposerProps) {
336
+ const { onSubmit, placeholder, sendLabel, disabled, actions } = props;
337
+ const [content, setContent] = useState("");
338
+ const [sending, setSending] = useState(false);
339
+
340
+ const send = async () => {
341
+ const text = content.trim();
342
+ if (!text || sending || disabled) return;
343
+ setSending(true);
344
+ try {
345
+ await onSubmit(text);
346
+ setContent("");
347
+ } catch {
348
+ // Keep the draft so the user can retry; the data layer surfaces the
349
+ // failure (the optimistic comment rolls back). Don't propagate.
350
+ } finally {
351
+ setSending(false);
352
+ }
353
+ };
354
+
355
+ return (
356
+ <View>
357
+ <TextInputField
358
+ value={content}
359
+ onChangeText={setContent}
360
+ placeholder={placeholder}
361
+ editable={!disabled}
362
+ multiline
363
+ numberOfLines={2}
364
+ />
365
+ <Spacer size={8} />
366
+ <View style={{ flexDirection: "row", gap: 8, justifyContent: "flex-end", alignItems: "center" }}>
367
+ {actions}
368
+ <Button
369
+ title={sendLabel}
370
+ color="primary"
371
+ disabled={disabled || !content.trim() || sending}
372
+ loading={sending}
373
+ onPress={send}
374
+ />
375
+ </View>
376
+ </View>
377
+ );
378
+ }
package/src/table.tsx CHANGED
@@ -16,7 +16,7 @@ const stickyHeader = [0];
16
16
  const CHEVRON_W = 44;
17
17
 
18
18
  export function Table<TRow extends Record<string, unknown>>(props: TableProps<TRow>) {
19
- const { columns, rows, rowKey, rowStyle, sort, onSortChange, renderDetail, expandedKeys, onToggleRow } = props;
19
+ const { columns, rows, rowKey, rowStyle, sort, onSortChange, renderDetail, expandedKeys, onToggleRow, onRowPress } = props;
20
20
  const [internal, setInternal] = useState<Set<string>>(() => new Set());
21
21
  const expanded = expandedKeys ?? internal;
22
22
  const expandable = !!renderDetail;
@@ -89,6 +89,14 @@ export function Table<TRow extends Record<string, unknown>>(props: TableProps<TR
89
89
  <Icon name={isOpen ? "chevron-up" : "chevron-down"} size={18} color={colors.zinc[400]} />
90
90
  </View>
91
91
  </Pressable>
92
+ ) : onRowPress ? (
93
+ <Pressable
94
+ accessibilityRole="button"
95
+ onPress={() => onRowPress(row)}
96
+ style={({ pressed }) => [styles.bodyRow, pressed ? styles.rowPressed : null, extra]}
97
+ >
98
+ {cells}
99
+ </Pressable>
92
100
  ) : (
93
101
  <View style={[styles.bodyRow, extra]}>{cells}</View>
94
102
  )}
package/src/table.web.tsx CHANGED
@@ -19,11 +19,14 @@ function colWidth<TRow extends Record<string, unknown>>(col: Column<TRow>): CSSP
19
19
  }
20
20
 
21
21
  export function Table<TRow extends Record<string, unknown>>(props: TableProps<TRow>) {
22
- const { columns, rows, rowKey, rowStyle, sort, onSortChange, renderDetail, expandedKeys, onToggleRow } = props;
22
+ const { columns, rows, rowKey, rowStyle, sort, onSortChange, renderDetail, expandedKeys, onToggleRow, onRowPress } = props;
23
23
  const [internal, setInternal] = useState<Set<string>>(() => new Set());
24
24
  const [hoverKey, setHoverKey] = useState<string | null>(null);
25
25
  const expanded = expandedKeys ?? internal;
26
26
  const expandable = !!renderDetail;
27
+ // A row reacts to clicks for one of two reasons: expansion (renderDetail) or
28
+ // selection (onRowPress). Expansion wins if both are set.
29
+ const pressable = expandable || !!onRowPress;
27
30
 
28
31
  const toggle = (key: string, row: TRow) => {
29
32
  onToggleRow?.(key, row);
@@ -97,8 +100,8 @@ export function Table<TRow extends Record<string, unknown>>(props: TableProps<TR
97
100
  const extra = (rowStyle?.(row) as CSSProperties | undefined) ?? undefined;
98
101
  const rowStyleFinal: CSSProperties = {
99
102
  ...bodyRowStyle,
100
- cursor: expandable ? "pointer" : "default",
101
- background: hoverKey === key && expandable ? colors.zinc[50] : colors.white,
103
+ cursor: pressable ? "pointer" : "default",
104
+ background: hoverKey === key && pressable ? colors.zinc[50] : colors.white,
102
105
  ...extra,
103
106
  };
104
107
  return (
@@ -106,18 +109,19 @@ export function Table<TRow extends Record<string, unknown>>(props: TableProps<TR
106
109
  <div
107
110
  role="row"
108
111
  onClick={
109
- expandable
112
+ pressable
110
113
  ? (e: React.MouseEvent) => {
111
- // Whole-row click expands, EXCEPT clicks inside an interactive cell
112
- // (pickers/buttons) — they keep their own behaviour. The disclosure
113
- // chevron is the keyboard/AT affordance.
114
+ // Whole-row click acts (expand or select), EXCEPT clicks inside an
115
+ // interactive cell (pickers/buttons) — they keep their own behaviour.
116
+ // The disclosure chevron is the keyboard/AT affordance for expansion.
114
117
  if ((e.target as HTMLElement).closest("[data-interactive]")) return;
115
- toggle(key, row);
118
+ if (expandable) toggle(key, row);
119
+ else onRowPress?.(row);
116
120
  }
117
121
  : undefined
118
122
  }
119
- onMouseEnter={expandable ? () => setHoverKey(key) : undefined}
120
- onMouseLeave={expandable ? () => setHoverKey((k) => (k === key ? null : k)) : undefined}
123
+ onMouseEnter={pressable ? () => setHoverKey(key) : undefined}
124
+ onMouseLeave={pressable ? () => setHoverKey((k) => (k === key ? null : k)) : undefined}
121
125
  style={rowStyleFinal}
122
126
  >
123
127
  {columns.map((col) => (
@@ -165,7 +169,10 @@ export function Table<TRow extends Record<string, unknown>>(props: TableProps<TR
165
169
  );
166
170
  }
167
171
 
168
- const containerStyle: CSSProperties = { width: "100%", overflow: "auto" };
172
+ // `maxHeight: 100%` is a no-op in a content-sized parent (a card grows to its
173
+ // rows) but caps the table to a bounded parent (a modal's flex region), so
174
+ // `overflow: auto` then scrolls and the sticky header engages.
175
+ const containerStyle: CSSProperties = { width: "100%", maxHeight: "100%", overflow: "auto" };
169
176
  const headerRowStyle: CSSProperties = {
170
177
  display: "flex",
171
178
  position: "sticky",
@@ -1,50 +1,46 @@
1
1
  import { useCallback, useMemo } from "react";
2
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";
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 { Table } from "@lotics/ui/table";
9
+ import type { Column, TableSort } from "@lotics/ui/table_types";
10
+ import { Pagination } from "./pagination";
11
+ import { ColumnFilter, type FilterableColumn, type ColumnFilterValue } from "./column_filter";
16
12
 
17
- /** A grid column the picker displays. Sorting/filtering are opt-in per column. */
18
- export interface PickerColumn<TRow> {
13
+ /** A grid column the picker displays. Sorting is opt-in per column. */
14
+ export interface PickerColumn<TRow extends Record<string, unknown>> {
19
15
  key: string;
20
16
  label: string;
21
- /** Fixed pixel width. Omit to let the grid size it. */
17
+ /** Fixed pixel width. Omit to flex. */
22
18
  width?: number;
19
+ align?: "left" | "right";
23
20
  /** Show a sortable header for this column. Requires `sort`/`onSortChange`. */
24
21
  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
- */
22
+ /** Cell content. Default: the row's value at `key` rendered as text. */
29
23
  renderCell?: (row: TRow) => React.ReactNode;
30
24
  }
31
25
 
32
26
  /** Single-column sort state — the picker sorts by one column at a time. */
33
- export interface DataGridPickerSort {
27
+ export interface TablePickerSort {
34
28
  key: string;
35
- order: SortOrder;
29
+ order: "asc" | "desc";
36
30
  }
37
31
 
38
- export interface DataGridPickerProps<TRow extends Record<string, unknown>> {
32
+ export interface TablePickerProps<TRow extends Record<string, unknown>> {
39
33
  open: boolean;
40
34
  onOpenChange: (open: boolean) => void;
41
35
  /** Heading shown above the search box. */
42
36
  title?: string;
43
37
 
44
38
  columns: PickerColumn<TRow>[];
39
+ /** Rows of the current page (the consumer paginates). */
45
40
  rows: TRow[];
46
- /** Stable identity for a row the value returned by selection. */
47
- rowIdGetter: (row: TRow) => string;
41
+ /** The field holding each row's stable id returned by selection and used to
42
+ * highlight the picked row (e.g. `"__source_record_id"`). */
43
+ rowKey: keyof TRow & string;
48
44
 
49
45
  /** Currently selected row id, or null. Highlights the matching row. */
50
46
  value: string | null;
@@ -59,16 +55,15 @@ export interface DataGridPickerProps<TRow extends Record<string, unknown>> {
59
55
  searchPlaceholder?: string;
60
56
 
61
57
  /**
62
- * Controlled single-column sort. Omit `onSortChange` to disable sorting
63
- * entirely (headers render static). Toggling a column cycles asc → desc → off.
58
+ * Controlled single-column sort. Omit `onSortChange` to disable sorting.
59
+ * Toggling a column cycles asc → desc → off.
64
60
  */
65
- sort?: DataGridPickerSort | null;
66
- onSortChange?: (sort: DataGridPickerSort | null) => void;
61
+ sort?: TablePickerSort | null;
62
+ onSortChange?: (sort: TablePickerSort | null) => void;
67
63
 
68
64
  /**
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`.
65
+ * Per-column filter pills. The consumer holds `filterValues` (keyed by column
66
+ * key) and maps them to query conditions via `columnFilterToConditions`.
72
67
  */
73
68
  filters?: FilterableColumn[];
74
69
  filterValues?: Record<string, ColumnFilterValue>;
@@ -76,15 +71,18 @@ export interface DataGridPickerProps<TRow extends Record<string, unknown>> {
76
71
  /** Accessible name for the per-filter clear control. Pass a translated string. */
77
72
  clearLabel?: string;
78
73
 
79
- /** Called when the grid scrolls near the end load the next page. */
80
- onEndReached?: () => void;
74
+ /** Pagination (page-model the consumer owns the page cursor). */
75
+ page: number;
76
+ pageSize: number;
77
+ /** Total rows across all pages, when known — drives "Page 1 of N". */
78
+ total?: number;
79
+ hasMore: boolean;
80
+ onPageChange: (page: number) => void;
81
81
 
82
- /** A request is in flight. Shows a spinner (overlay when rows exist). */
82
+ /** A request is in flight. */
83
83
  loading?: boolean;
84
84
  /** Message when there are no rows and nothing is loading. */
85
85
  emptyLabel?: string;
86
-
87
- rowHeight?: number;
88
86
  testID?: string;
89
87
  }
90
88
 
@@ -103,22 +101,20 @@ function defaultCellText(value: unknown): string {
103
101
  return "";
104
102
  }
105
103
 
106
- const DEFAULT_ROW_HEIGHT = 48;
107
-
108
104
  /**
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.
105
+ * A data-agnostic record-style picker: a modal holding a table the user can
106
+ * browse (numbered pages), search, sort, and filter, then pick one row. It knows
107
+ * nothing about records/tables/fields — the consumer supplies `columns` + `rows`
108
+ * (one page) and owns the search/sort/filter/page state (so the same component
109
+ * drives a server query, an in-memory list, or any other source). Compose it
110
+ * with a domain hook (e.g. an app's `usePaginatedQuery`) to build a record
111
+ * picker, or with any other row source for a different purpose.
116
112
  *
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.
113
+ * Single-select: clicking a row picks its id; the selected row is highlighted;
114
+ * `closeOnSelect` (default) dismisses the modal on pick.
119
115
  */
120
- export function DataGridPicker<TRow extends Record<string, unknown>>(
121
- props: DataGridPickerProps<TRow>,
116
+ export function TablePicker<TRow extends Record<string, unknown>>(
117
+ props: TablePickerProps<TRow>,
122
118
  ) {
123
119
  const {
124
120
  open,
@@ -126,7 +122,7 @@ export function DataGridPicker<TRow extends Record<string, unknown>>(
126
122
  title,
127
123
  columns,
128
124
  rows,
129
- rowIdGetter,
125
+ rowKey,
130
126
  value,
131
127
  onValueChange,
132
128
  closeOnSelect = true,
@@ -139,21 +135,25 @@ export function DataGridPicker<TRow extends Record<string, unknown>>(
139
135
  filterValues,
140
136
  onFilterChange,
141
137
  clearLabel,
142
- onEndReached,
138
+ page,
139
+ pageSize,
140
+ total,
141
+ hasMore,
142
+ onPageChange,
143
143
  loading = false,
144
144
  emptyLabel,
145
- rowHeight = DEFAULT_ROW_HEIGHT,
146
145
  testID,
147
146
  } = props;
148
147
 
149
148
  const handleSelect = useCallback(
150
- (id: string) => {
151
- onValueChange(id);
149
+ (row: TRow) => {
150
+ onValueChange(String(row[rowKey]));
152
151
  if (closeOnSelect) onOpenChange(false);
153
152
  },
154
- [onValueChange, closeOnSelect, onOpenChange],
153
+ [onValueChange, rowKey, closeOnSelect, onOpenChange],
155
154
  );
156
155
 
156
+ // Toggle this column's sort: asc → desc → off. The consumer holds the state.
157
157
  const toggleSort = useCallback(
158
158
  (key: string) => {
159
159
  if (!onSortChange) return;
@@ -164,51 +164,35 @@ export function DataGridPicker<TRow extends Record<string, unknown>>(
164
164
  [sort, onSortChange],
165
165
  );
166
166
 
167
- const gridColumns = useMemo<DataGridColumn<TRow>[]>(
167
+ const tableColumns = useMemo<Column<TRow>[]>(
168
168
  () =>
169
169
  columns.map((col) => ({
170
170
  key: col.key,
171
- name: col.label,
171
+ label: col.label,
172
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
- ),
173
+ align: col.align,
174
+ sortable: col.sortable && !!onSortChange,
175
+ renderCell: ({ row }: { row: TRow }) =>
176
+ col.renderCell ? (
177
+ col.renderCell(row)
178
+ ) : (
179
+ <Text size="sm" color="zinc-900" numberOfLines={1}>
180
+ {defaultCellText(row[col.key])}
181
+ </Text>
182
+ ),
199
183
  })),
200
- [columns, sort, onSortChange, toggleSort, handleSelect, rowIdGetter],
184
+ [columns, onSortChange],
201
185
  );
202
186
 
203
- const groups = useMemo<DataGridGroup<TRow>[]>(
204
- () => (rows.length > 0 ? [{ value: null, columnKey: "", rows }] : []),
205
- [rows],
187
+ const tableSort = useMemo<TableSort<TRow> | null>(
188
+ () => (sort ? { key: sort.key, dir: sort.order } : null),
189
+ [sort],
206
190
  );
207
191
 
208
- const rowColorGetter = useCallback(
192
+ const rowStyle = useCallback(
209
193
  (row: TRow) =>
210
- value != null && rowIdGetter(row) === value ? colors.blue["50"] : undefined,
211
- [value, rowIdGetter],
194
+ value != null && String(row[rowKey]) === value ? { backgroundColor: colors.blue["50"] } : undefined,
195
+ [value, rowKey],
212
196
  );
213
197
 
214
198
  return (
@@ -221,7 +205,7 @@ export function DataGridPicker<TRow extends Record<string, unknown>>(
221
205
  ) : null}
222
206
 
223
207
  <SearchInput
224
- testID="data-grid-picker-search"
208
+ testID="table-picker-search"
225
209
  value={searchQuery}
226
210
  onChangeText={onSearchChange}
227
211
  placeholder={searchPlaceholder}
@@ -243,16 +227,15 @@ export function DataGridPicker<TRow extends Record<string, unknown>>(
243
227
  ) : null}
244
228
 
245
229
  <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}
230
+ {rows.length > 0 ? (
231
+ <Table<TRow>
232
+ columns={tableColumns}
233
+ rows={rows}
234
+ rowKey={rowKey}
235
+ sort={tableSort}
236
+ onSortChange={onSortChange ? (key) => toggleSort(key as string) : undefined}
237
+ onRowPress={handleSelect}
238
+ rowStyle={rowStyle}
256
239
  />
257
240
  ) : (
258
241
  <View style={styles.center}>
@@ -265,12 +248,17 @@ export function DataGridPicker<TRow extends Record<string, unknown>>(
265
248
  )}
266
249
  </View>
267
250
  )}
268
- {loading && groups.length > 0 ? (
269
- <View style={styles.loadingOverlay} pointerEvents="none">
270
- <ActivityIndicator />
271
- </View>
272
- ) : null}
273
251
  </View>
252
+
253
+ <Pagination
254
+ page={page}
255
+ pageSize={pageSize}
256
+ rowCount={rows.length}
257
+ hasMore={hasMore}
258
+ total={total}
259
+ loading={loading}
260
+ onPageChange={onPageChange}
261
+ />
274
262
  </View>
275
263
  </Dialog>
276
264
  );
@@ -297,26 +285,16 @@ const styles = StyleSheet.create({
297
285
  },
298
286
  gridArea: {
299
287
  flex: 1,
288
+ minHeight: 0,
300
289
  borderWidth: 1,
301
290
  borderColor: colors.border,
302
291
  borderRadius: 12,
303
292
  overflow: "hidden",
304
293
  },
305
- cell: {
306
- flex: 1,
307
- height: "100%",
308
- justifyContent: "center",
309
- paddingHorizontal: 8,
310
- },
311
294
  center: {
312
295
  flex: 1,
313
296
  alignItems: "center",
314
297
  justifyContent: "center",
315
298
  padding: 24,
316
299
  },
317
- loadingOverlay: {
318
- position: "absolute",
319
- top: 8,
320
- right: 8,
321
- },
322
300
  });
@@ -35,6 +35,11 @@ export interface TableProps<TRow extends Record<string, unknown>> {
35
35
  * parent owns the actual sorting of `rows` — this only drives the indicator. */
36
36
  sort?: TableSort<TRow> | null;
37
37
  onSortChange?: (key: keyof TRow) => void;
38
+ /** Click-to-select: pressing a row (except `interactive` cells) calls this —
39
+ * e.g. a picker that returns the chosen row. Mutually exclusive with
40
+ * `renderDetail` (a row either expands or selects); `renderDetail` wins if
41
+ * both are set. Pair with `rowStyle` to highlight the selected row. */
42
+ onRowPress?: (row: TRow) => void;
38
43
  /** Render an inline detail panel, full-width below the row. When set, the
39
44
  * whole row (except `interactive` cells) is click-to-expand. */
40
45
  renderDetail?: (row: TRow) => ReactNode;
@@ -1,58 +0,0 @@
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
- });