@lotics/ui 1.23.0 → 1.25.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.23.0",
3
+ "version": "1.25.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",
@@ -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/index.css CHANGED
@@ -337,10 +337,12 @@ html {
337
337
  }
338
338
 
339
339
  /* Keyboard focus ring. `:focus-visible` only matches keyboard-driven focus,
340
- so pointer/touch interactions stay visually unchanged. */
340
+ so pointer/touch interactions stay visually unchanged. The ring sits flush
341
+ against the element's border (offset 0, no gap) and, being an outline, never
342
+ reflows layout. */
341
343
  :focus-visible {
342
344
  outline: 2px solid var(--color-zinc-900);
343
- outline-offset: 2px;
345
+ outline-offset: 0;
344
346
  }
345
347
  :focus:not(:focus-visible) {
346
348
  outline: none;
@@ -4,11 +4,15 @@ import { Text } from "./text";
4
4
  import { colors } from "./colors";
5
5
  import { Icon, IconName } from "./icon";
6
6
  import { PressableHighlight } from "./pressable_highlight";
7
+ import type { TextColor } from "./text_utils";
7
8
 
8
9
  export interface MenuListItemProps {
9
10
  ref?: Ref<View>;
10
11
  icon?: IconName | React.ReactNode;
11
12
  title: string;
13
+ /** Title color. Default ink — set it to mark the row as an action (e.g. a
14
+ * leading "Browse all" tinted to match its icon). */
15
+ titleColor?: TextColor;
12
16
  description?: string;
13
17
  right?: React.ReactNode;
14
18
  onPress?: () => void;
@@ -32,6 +36,7 @@ export function MenuListItem(props: MenuListItemProps) {
32
36
  ref,
33
37
  icon,
34
38
  title,
39
+ titleColor,
35
40
  description,
36
41
  right,
37
42
  onPress,
@@ -52,7 +57,7 @@ export function MenuListItem(props: MenuListItemProps) {
52
57
  <>
53
58
  {resolvedIcon}
54
59
  <View style={styles.textContainer}>
55
- <Text weight="medium" numberOfLines={1} userSelect="none">
60
+ <Text weight="medium" color={titleColor} numberOfLines={1} userSelect="none">
56
61
  {title}
57
62
  </Text>
58
63
  {!!description && (
@@ -14,14 +14,29 @@ import { MenuListItem } from "./menu_list_item";
14
14
  import { ActivityIndicator } from "./activity_indicator";
15
15
  import { Popover, PopoverContent } from "./popover";
16
16
  import type { PickerOption } from "./picker";
17
+ import type { TextColor } from "./text_utils";
17
18
  import { useDebouncedCallback } from "./use_debounced_callback";
18
19
  import { useListKeyboardNav } from "./use_list_keyboard_nav";
19
20
 
21
+ /** A pinned action row in the dropdown — not a search result, an action. */
22
+ export interface SearchSelectAction {
23
+ key: string;
24
+ label: string;
25
+ icon?: ReactNode;
26
+ /** Tints the label so it reads as an action (match it to the icon's color). */
27
+ color?: TextColor;
28
+ onPress: () => void;
29
+ }
30
+
20
31
  export interface SearchSelectProps<T extends string = string, D = unknown> {
21
32
  /** Result options for the current query. The consumer refreshes these in
22
33
  * response to `onSearchChange` — search runs server-side, so the whole table
23
34
  * is never shipped to the client. */
24
35
  options: PickerOption<T, D>[];
36
+ /** Pinned action rows shown at the very top of the dropdown (above results and
37
+ * recents), always available while open and keyboard-navigable alongside the
38
+ * options — e.g. a "Browse all" that opens a full picker. */
39
+ leadingActions?: SearchSelectAction[];
25
40
  /** Debounced; fires as the user types. Drive a server re-query from it. */
26
41
  onSearchChange: (query: string) => void;
27
42
  /** Fires when the user picks a result or a recent. */
@@ -78,6 +93,7 @@ export function SearchSelect<T extends string = string, D = unknown>(
78
93
  ) {
79
94
  const {
80
95
  options,
96
+ leadingActions = [],
81
97
  onSearchChange,
82
98
  onValueChange,
83
99
  recentOptions,
@@ -108,16 +124,30 @@ export function SearchSelect<T extends string = string, D = unknown>(
108
124
 
109
125
  const searching = query.trim().length > 0;
110
126
  const list = searching ? options : (recentOptions ?? []);
127
+ // The navigable set is the leading actions followed by the option list, so
128
+ // arrow keys + aria-activedescendant span both. `nLead` is the boundary.
129
+ const nLead = leadingActions.length;
111
130
 
112
131
  const debouncedSearch = useDebouncedCallback(onSearchChange, searchDebounceMs);
113
132
 
114
- const scrollToIndex = useCallback((index: number) => {
115
- scrollRef.current?.scrollTo({ y: Math.max(0, index * OPTION_HEIGHT - 80), animated: false });
116
- }, []);
133
+ // Only the option list scrolls (actions are pinned above it), so map the
134
+ // combined index back into the list before scrolling.
135
+ const scrollToIndex = useCallback(
136
+ (index: number) => {
137
+ if (index < nLead) return;
138
+ scrollRef.current?.scrollTo({ y: Math.max(0, (index - nLead) * OPTION_HEIGHT - 80), animated: false });
139
+ },
140
+ [nLead],
141
+ );
117
142
 
118
143
  const handleSelect = useCallback(
119
144
  (index: number) => {
120
- const opt = list[index];
145
+ if (index < nLead) {
146
+ leadingActions[index].onPress();
147
+ setOpen(false);
148
+ return;
149
+ }
150
+ const opt = list[index - nLead];
121
151
  if (!opt || opt.disabled) return;
122
152
  onValueChange(opt);
123
153
  // The trailing search is irrelevant once a pick is made. Cancel it and
@@ -131,12 +161,12 @@ export function SearchSelect<T extends string = string, D = unknown>(
131
161
  // search again without re-clicking.
132
162
  inputRef.current?.focus();
133
163
  },
134
- [list, onValueChange, onSearchChange, debouncedSearch],
164
+ [list, leadingActions, nLead, onValueChange, onSearchChange, debouncedSearch],
135
165
  );
136
166
 
137
167
  const { activeIndex, setActiveIndex, handleKey } = useListKeyboardNav({
138
- count: list.length,
139
- isDisabled: (i) => list[i]?.disabled ?? false,
168
+ count: nLead + list.length,
169
+ isDisabled: (i) => (i >= nLead ? (list[i - nLead]?.disabled ?? false) : false),
140
170
  onSelect: handleSelect,
141
171
  onClose: () => setOpen(false),
142
172
  onActiveChange: scrollToIndex,
@@ -168,10 +198,11 @@ export function SearchSelect<T extends string = string, D = unknown>(
168
198
  [open, handleKey],
169
199
  );
170
200
 
171
- // Nothing to show ⇒ don't open an empty popover (focused box, no recents).
172
- const showList = open && !disabled && (searching || list.length > 0);
201
+ // Nothing to show ⇒ don't open an empty popover. Leading actions (e.g.
202
+ // "Browse all") always give the popover something to show on focus.
203
+ const showList = open && !disabled && (nLead > 0 || searching || list.length > 0);
173
204
  const activeOptionId =
174
- activeIndex >= 0 && activeIndex < list.length ? optionId(activeIndex) : undefined;
205
+ activeIndex >= 0 && activeIndex < nLead + list.length ? optionId(activeIndex) : undefined;
175
206
  const listLabel = searching ? accessibilityLabel : recentsLabel;
176
207
 
177
208
  return (
@@ -217,6 +248,22 @@ export function SearchSelect<T extends string = string, D = unknown>(
217
248
  testID={testID ? `${testID}-popover` : undefined}
218
249
  >
219
250
  <View style={styles.menu}>
251
+ {nLead > 0
252
+ ? leadingActions.map((action, i) => (
253
+ <MenuListItem
254
+ key={action.key}
255
+ nativeID={optionId(i)}
256
+ testID={`search-action-${action.key}`}
257
+ role="option"
258
+ icon={action.icon}
259
+ title={action.label}
260
+ titleColor={action.color}
261
+ focused={i === activeIndex}
262
+ onPress={() => handleSelect(i)}
263
+ onHoverIn={() => setActiveIndex(i)}
264
+ />
265
+ ))
266
+ : null}
220
267
  {!searching && list.length > 0 ? (
221
268
  <View style={styles.sectionHeader}>
222
269
  <Text size="xs" weight="medium" color="zinc-500">
@@ -239,27 +286,31 @@ export function SearchSelect<T extends string = string, D = unknown>(
239
286
  <ActivityIndicator />
240
287
  </View>
241
288
  ) : list.length === 0 ? (
242
- <View style={styles.statusRow}>
243
- <Text size="sm" color="zinc-500">
244
- {emptyText}
245
- </Text>
246
- </View>
289
+ // Only "no results" while searching — an empty recents list (no
290
+ // query) just shows the leading actions above, nothing more.
291
+ searching ? (
292
+ <View style={styles.statusRow}>
293
+ <Text size="sm" color="zinc-500">
294
+ {emptyText}
295
+ </Text>
296
+ </View>
297
+ ) : null
247
298
  ) : (
248
299
  list.map((opt, i) => (
249
300
  <MenuListItem
250
301
  key={opt.value}
251
- nativeID={optionId(i)}
302
+ nativeID={optionId(nLead + i)}
252
303
  testID={`search-option-${opt.value}`}
253
304
  role="option"
254
305
  icon={renderOptionIcon?.(opt)}
255
306
  title={opt.label ?? opt.value}
256
307
  description={getOptionDescription?.(opt)}
257
308
  right={renderOptionRight?.(opt)}
258
- focused={i === activeIndex}
309
+ focused={nLead + i === activeIndex}
259
310
  selected={opt.value === selectedValue}
260
311
  disabled={opt.disabled}
261
- onPress={() => handleSelect(i)}
262
- onHoverIn={() => setActiveIndex(i)}
312
+ onPress={() => handleSelect(nLead + i)}
313
+ onHoverIn={() => setActiveIndex(nLead + i)}
263
314
  />
264
315
  ))
265
316
  )}
@@ -275,6 +326,8 @@ const styles = StyleSheet.create({
275
326
  input: {
276
327
  backgroundColor: colors.background,
277
328
  boxShadow: colors.border_shadow,
329
+ // A pill, matching the search input inside the picker dialog.
330
+ borderRadius: 999,
278
331
  },
279
332
  menu: {
280
333
  gap: 2,
package/src/table.tsx CHANGED
@@ -48,9 +48,11 @@ export function Table<TRow extends Record<string, unknown>>(props: TableProps<TR
48
48
  {col.label}
49
49
  </Text>
50
50
  {sortable ? (
51
- <Text size="xs" color="muted" userSelect="none">
52
- {active ? (sort?.dir === "asc" ? "" : "") : ""}
53
- </Text>
51
+ <Icon
52
+ name={active ? (sort?.dir === "asc" ? "chevron-up" : "chevron-down") : "chevrons-up-down"}
53
+ size={14}
54
+ color={active ? colors.zinc[700] : colors.zinc[400]}
55
+ />
54
56
  ) : null}
55
57
  </View>
56
58
  );
package/src/table.web.tsx CHANGED
@@ -61,9 +61,11 @@ export function Table<TRow extends Record<string, unknown>>(props: TableProps<TR
61
61
  {col.label}
62
62
  </Text>
63
63
  {sortable ? (
64
- <Text size="xs" color="muted" userSelect="none">
65
- {active ? (sort?.dir === "asc" ? "" : "") : ""}
66
- </Text>
64
+ <Icon
65
+ name={active ? (sort?.dir === "asc" ? "chevron-up" : "chevron-down") : "chevrons-up-down"}
66
+ size={14}
67
+ color={active ? colors.zinc[700] : colors.zinc[400]}
68
+ />
67
69
  ) : null}
68
70
  </>
69
71
  );
@@ -83,6 +83,9 @@ export interface TablePickerProps<TRow extends Record<string, unknown>> {
83
83
  loading?: boolean;
84
84
  /** Message when there are no rows and nothing is loading. */
85
85
  emptyLabel?: string;
86
+ /** Max dialog width — a browse table needs more room than a form dialog.
87
+ * Default 1080. */
88
+ maxWidth?: number | `${number}%`;
86
89
  testID?: string;
87
90
  }
88
91
 
@@ -142,6 +145,7 @@ export function TablePicker<TRow extends Record<string, unknown>>(
142
145
  onPageChange,
143
146
  loading = false,
144
147
  emptyLabel,
148
+ maxWidth = 1080,
145
149
  testID,
146
150
  } = props;
147
151
 
@@ -196,7 +200,7 @@ export function TablePicker<TRow extends Record<string, unknown>>(
196
200
  );
197
201
 
198
202
  return (
199
- <Dialog open={open} onOpenChange={onOpenChange} height="90%" testID={testID}>
203
+ <Dialog open={open} onOpenChange={onOpenChange} height="90%" maxWidth={maxWidth} testID={testID}>
200
204
  <View style={styles.root}>
201
205
  {title ? (
202
206
  <Text size="lg" weight="semibold" color="zinc-900" style={styles.title}>
@@ -269,11 +273,12 @@ const styles = StyleSheet.create({
269
273
  flex: 1,
270
274
  width: "100%",
271
275
  gap: 12,
272
- paddingHorizontal: 4,
273
- },
274
- title: {
275
- paddingHorizontal: 4,
276
+ // Match the Dialog's own horizontal inset (the close button sits at 24) so
277
+ // the content isn't flush against the dialog edges.
278
+ paddingHorizontal: 24,
279
+ paddingBottom: 8,
276
280
  },
281
+ title: {},
277
282
  search: {
278
283
  width: "100%",
279
284
  },