@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 +3 -1
- package/src/file_thumbnail_grid.tsx +165 -0
- package/src/image_gallery.tsx +123 -0
- package/src/index.css +4 -2
- package/src/menu_list_item.tsx +6 -1
- package/src/search_select.tsx +72 -19
- package/src/table.tsx +5 -3
- package/src/table.web.tsx +5 -3
- package/src/table_picker.tsx +10 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "1.
|
|
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:
|
|
345
|
+
outline-offset: 0;
|
|
344
346
|
}
|
|
345
347
|
:focus:not(:focus-visible) {
|
|
346
348
|
outline: none;
|
package/src/menu_list_item.tsx
CHANGED
|
@@ -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 && (
|
package/src/search_select.tsx
CHANGED
|
@@ -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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
|
172
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
<
|
|
52
|
-
{active ? (sort?.dir === "asc" ? "
|
|
53
|
-
|
|
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
|
-
<
|
|
65
|
-
{active ? (sort?.dir === "asc" ? "
|
|
66
|
-
|
|
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
|
);
|
package/src/table_picker.tsx
CHANGED
|
@@ -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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
},
|