@lotics/ui 1.22.0 → 1.24.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.22.0",
3
+ "version": "1.24.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -16,6 +16,8 @@
16
16
  },
17
17
  "./file_preview_types": "./src/file_preview_types.ts",
18
18
  "./file_gallery_modal": "./src/file_gallery_modal.tsx",
19
+ "./image_gallery": "./src/image_gallery.tsx",
20
+ "./file_thumbnail_grid": "./src/file_thumbnail_grid.tsx",
19
21
  "./pagination": "./src/pagination.tsx",
20
22
  "./bar_chart": "./src/bar_chart.tsx",
21
23
  "./line_chart": "./src/line_chart.tsx",
@@ -151,9 +153,8 @@
151
153
  "./grid/data_grid": "./src/grid/data_grid.tsx",
152
154
  "./grid/data_grid_context": "./src/grid/data_grid_context.ts",
153
155
  "./grid/search_highlight": "./src/grid/search_highlight.ts",
154
- "./grid/sortable_header_cell": "./src/grid/sortable_header_cell.tsx",
155
- "./grid/column_filter": "./src/grid/column_filter.tsx",
156
- "./grid/data_grid_picker": "./src/grid/data_grid_picker.tsx"
156
+ "./column_filter": "./src/column_filter.tsx",
157
+ "./table_picker": "./src/table_picker.tsx"
157
158
  },
158
159
  "files": [
159
160
  "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,165 @@
1
+ // Pure, reusable thumbnail-grid layout + a file-specific specialization.
2
+ //
3
+ // `ThumbnailGrid<T>` is the layout engine (three mutually-exclusive modes:
4
+ // compact / fixed-columns / auto-columns, with `maxVisible` overflow). It is
5
+ // item-agnostic — pass `renderItem`. `FileThumbnailGrid` is the common case:
6
+ // a grid of `DisplayFile`s rendered with `FileThumbnail` (any file type —
7
+ // image/video/audio/doc — via FileThumbnail's own type handling).
8
+ //
9
+ // The Lotics-coupled file grid (`@lotics/ui-internal/file_grid`, which mixes in
10
+ // upload-progress thumbnails) composes `ThumbnailGrid` with its own renderItem,
11
+ // so the layout lives in exactly one place.
12
+ import React, { useState } from "react";
13
+ import { View, StyleSheet, type ViewStyle } from "react-native";
14
+ import { Text } from "./text";
15
+ import { FileThumbnail, type DisplayFile, THUMBNAIL_SIZE } from "./file_thumbnail";
16
+
17
+ export interface ThumbnailGridProps<T> {
18
+ items: T[];
19
+ keyExtractor: (item: T) => string;
20
+ /** Render one item. `size` is passed in compact mode (fixed px); omitted in
21
+ * column modes, where the item fills its flex cell. */
22
+ renderItem: (item: T, size?: number) => React.ReactNode;
23
+ /** Fixed pixel size per item → flat horizontal wrap. Takes precedence. */
24
+ itemSize?: number;
25
+ /** Fixed column count → items fill 1/N of the row width. */
26
+ columns?: number;
27
+ /** Minimum item width for auto column count when neither itemSize nor columns
28
+ * is set. Default 96. */
29
+ minItemWidth?: number;
30
+ /** Gap between items. Default 8. */
31
+ gap?: number;
32
+ /** Max items shown before a "+N" overflow indicator. */
33
+ maxVisible?: number;
34
+ /** Alignment of a partial last row. Default "start". */
35
+ partialRowAlign?: "start" | "end";
36
+ style?: ViewStyle;
37
+ /** Disable press/click interactions (sets pointerEvents none). */
38
+ disablePress?: boolean;
39
+ /** Rendered inside the container after the grid (e.g. a retry-all button). */
40
+ footer?: React.ReactNode;
41
+ }
42
+
43
+ export function ThumbnailGrid<T>(props: ThumbnailGridProps<T>) {
44
+ const {
45
+ items,
46
+ keyExtractor,
47
+ renderItem,
48
+ itemSize,
49
+ columns,
50
+ minItemWidth = THUMBNAIL_SIZE,
51
+ gap = 8,
52
+ maxVisible,
53
+ partialRowAlign = "start",
54
+ style,
55
+ disablePress,
56
+ footer,
57
+ } = props;
58
+
59
+ const [containerWidth, setContainerWidth] = useState(0);
60
+
61
+ const isCompact = itemSize !== undefined;
62
+ const dynamicColumns =
63
+ !isCompact && columns === undefined && containerWidth > 0
64
+ ? Math.max(1, Math.floor((containerWidth + gap) / (minItemWidth + gap)))
65
+ : undefined;
66
+ const effectiveColumns = columns ?? dynamicColumns;
67
+
68
+ const visible = maxVisible ? items.slice(0, maxVisible) : items;
69
+ const overflow = maxVisible ? Math.max(0, items.length - maxVisible) : 0;
70
+ const canRender = isCompact || columns !== undefined || containerWidth > 0;
71
+
72
+ if (items.length === 0) return null;
73
+
74
+ const renderRows = (rowItems: T[], cols: number) => {
75
+ const rows: T[][] = [];
76
+ for (let i = 0; i < rowItems.length; i += cols) rows.push(rowItems.slice(i, i + cols));
77
+ return rows.map((row, rowIndex) => {
78
+ const spacers =
79
+ row.length < cols
80
+ ? Array.from({ length: cols - row.length }, (_, i) => (
81
+ <View key={`spacer-${i}`} style={styles.flexItem} />
82
+ ))
83
+ : null;
84
+ return (
85
+ <View key={rowIndex} style={[styles.row, { gap }]}>
86
+ {partialRowAlign === "end" && spacers}
87
+ {row.map((item) => (
88
+ <View key={keyExtractor(item)} style={styles.flexItem}>
89
+ {renderItem(item)}
90
+ </View>
91
+ ))}
92
+ {partialRowAlign === "start" && spacers}
93
+ </View>
94
+ );
95
+ });
96
+ };
97
+
98
+ return (
99
+ <View
100
+ style={[isCompact && styles.compactContainer, { gap }, style]}
101
+ pointerEvents={disablePress ? "none" : "auto"}
102
+ onLayout={(e) => setContainerWidth(e.nativeEvent.layout.width)}
103
+ >
104
+ {canRender &&
105
+ (isCompact
106
+ ? visible.map((item) => (
107
+ <React.Fragment key={keyExtractor(item)}>{renderItem(item, itemSize)}</React.Fragment>
108
+ ))
109
+ : effectiveColumns !== undefined
110
+ ? renderRows(visible, effectiveColumns)
111
+ : null)}
112
+
113
+ {overflow > 0 && (
114
+ <Text size="sm" color="muted" userSelect="none">
115
+ +{overflow}
116
+ </Text>
117
+ )}
118
+
119
+ {footer}
120
+ </View>
121
+ );
122
+ }
123
+
124
+ export interface FileThumbnailGridProps {
125
+ files: DisplayFile[];
126
+ itemSize?: number;
127
+ columns?: number;
128
+ minItemWidth?: number;
129
+ gap?: number;
130
+ maxVisible?: number;
131
+ partialRowAlign?: "start" | "end";
132
+ style?: ViewStyle;
133
+ disablePress?: boolean;
134
+ /** Selected file IDs — renders a selection overlay on those thumbnails. */
135
+ selectedIds?: ReadonlySet<string>;
136
+ onFilePress?: (file: DisplayFile) => void;
137
+ onRemove?: (id: string) => void;
138
+ }
139
+
140
+ export function FileThumbnailGrid(props: FileThumbnailGridProps) {
141
+ const { files, selectedIds, onFilePress, onRemove, disablePress, ...layout } = props;
142
+ return (
143
+ <ThumbnailGrid
144
+ {...layout}
145
+ items={files}
146
+ keyExtractor={(f) => f.id}
147
+ disablePress={disablePress}
148
+ renderItem={(file, size) => (
149
+ <FileThumbnail
150
+ file={file}
151
+ size={size}
152
+ onPress={disablePress || !onFilePress ? undefined : () => onFilePress(file)}
153
+ onRemove={onRemove ? () => onRemove(file.id) : undefined}
154
+ selected={selectedIds?.has(file.id)}
155
+ />
156
+ )}
157
+ />
158
+ );
159
+ }
160
+
161
+ const styles = StyleSheet.create({
162
+ compactContainer: { flexDirection: "row", flexWrap: "wrap", userSelect: "none" },
163
+ row: { flexDirection: "row" },
164
+ flexItem: { flex: 1 },
165
+ });
@@ -0,0 +1,123 @@
1
+ // Ecommerce-style image gallery: a large main image plus a thumbnail grid.
2
+ // Click a thumbnail to swap the main image; click the main image to zoom it
3
+ // full-screen via FileGalleryModal. Source-agnostic — takes DisplayFile[], so
4
+ // any caller (record photos, uploads, chat attachments) can use it.
5
+ //
6
+ // Width is measured with onLayout (like FileThumbnailGrid) and the columns are
7
+ // sized from it, so the gallery adapts to the available space instead of
8
+ // relying on percentage/flex-basis resolution (which collapses inside an
9
+ // auto-width parent).
10
+ import { useState } from "react";
11
+ import { View, Image, Pressable } from "react-native";
12
+ import { Text } from "./text";
13
+ import { colors } from "./colors";
14
+ import { ActivityIndicator } from "./activity_indicator";
15
+ import { FileThumbnail, type DisplayFile } from "./file_thumbnail";
16
+ import { FileGalleryModal } from "./file_gallery_modal";
17
+
18
+ const GAP = 12;
19
+
20
+ export interface ImageGalleryProps {
21
+ images: DisplayFile[];
22
+ loading?: boolean;
23
+ /** Shown when there are no images and not loading. Pass a translated string. */
24
+ emptyText?: string;
25
+ /** Optional max width. Omit to fill the container. */
26
+ maxWidth?: number;
27
+ /** Hint shown in the full-screen zoom modal. Pass a translated string. */
28
+ captionHint?: string;
29
+ /** "bottom" stacks thumbnails under the main image; "right" puts the main
30
+ * image (~60% of the width) and the thumbnail grid side by side. */
31
+ thumbnailPosition?: "bottom" | "right";
32
+ /** Aspect ratio (width / height) of the main image box. Default 4/3. A
33
+ * smaller value (e.g. 1) makes a taller, larger preview. */
34
+ mainAspectRatio?: number;
35
+ /** Main image's fraction of the width in "right" mode (0–1). Default 0.6. */
36
+ mainFraction?: number;
37
+ }
38
+
39
+ export function ImageGallery({
40
+ images,
41
+ loading = false,
42
+ emptyText = "No images.",
43
+ maxWidth,
44
+ captionHint = "ESC to close · ←/→ to navigate",
45
+ thumbnailPosition = "bottom",
46
+ mainAspectRatio = 4 / 3,
47
+ mainFraction = 0.6,
48
+ }: ImageGalleryProps) {
49
+ const [selected, setSelected] = useState(0);
50
+ const [zoomIdx, setZoomIdx] = useState<number | null>(null);
51
+ const [containerWidth, setContainerWidth] = useState(0);
52
+
53
+ if (loading) return <ActivityIndicator />;
54
+ if (images.length === 0) return <Text size="sm" color="muted">{emptyText}</Text>;
55
+
56
+ const idx = Math.min(selected, images.length - 1);
57
+ const main = images[idx];
58
+ const right = thumbnailPosition === "right";
59
+ const width = maxWidth ? Math.min(containerWidth, maxWidth) : containerWidth;
60
+
61
+ const mainImage = (
62
+ <Pressable
63
+ onPress={() => setZoomIdx(idx)}
64
+ accessibilityRole="button"
65
+ accessibilityLabel="Zoom image"
66
+ style={{
67
+ width: "100%",
68
+ aspectRatio: mainAspectRatio,
69
+ borderRadius: 8,
70
+ borderWidth: 1,
71
+ borderColor: colors.border,
72
+ backgroundColor: colors.zinc["100"],
73
+ overflow: "hidden",
74
+ cursor: "pointer",
75
+ }}
76
+ >
77
+ <Image source={{ uri: main.url }} resizeMode="contain" style={{ width: "100%", height: "100%" }} />
78
+ </Pressable>
79
+ );
80
+
81
+ const thumbnails = images.length > 1 ? (
82
+ <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 6 }}>
83
+ {images.map((img, i) => (
84
+ // Border radius = FileThumbnail's own radius (8) + the 2px border, so
85
+ // the active highlight is concentric with the thumbnail's corners.
86
+ <View
87
+ key={img.id}
88
+ style={{
89
+ borderRadius: 10,
90
+ borderWidth: 2,
91
+ borderColor: i === idx ? colors.zinc["900"] : colors.zinc["200"],
92
+ }}
93
+ >
94
+ <FileThumbnail file={img} size={56} onPress={() => setSelected(i)} />
95
+ </View>
96
+ ))}
97
+ </View>
98
+ ) : null;
99
+
100
+ return (
101
+ <View onLayout={(e) => setContainerWidth(e.nativeEvent.layout.width)}>
102
+ {width > 0 &&
103
+ (right && thumbnails ? (
104
+ <View style={{ flexDirection: "row", gap: GAP }}>
105
+ <View style={{ width: (width - GAP) * mainFraction }}>{mainImage}</View>
106
+ <View style={{ width: (width - GAP) * (1 - mainFraction) }}>{thumbnails}</View>
107
+ </View>
108
+ ) : (
109
+ <View style={{ gap: 8, width }}>
110
+ {mainImage}
111
+ {thumbnails}
112
+ </View>
113
+ ))}
114
+
115
+ <FileGalleryModal
116
+ files={images}
117
+ activeIndex={zoomIdx}
118
+ onIndexChange={setZoomIdx}
119
+ captionHint={captionHint}
120
+ />
121
+ </View>
122
+ );
123
+ }
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
- });