@lotics/ui 1.15.0 → 1.17.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.
@@ -0,0 +1,100 @@
1
+ import type { DateFilterValue } from "./date_filter";
2
+
3
+ // Pure preset-range math for DateFilter. Kept free of React/RN so it can be
4
+ // unit-tested deterministically: every range is computed from an injected
5
+ // `now`, never an ambient clock.
6
+
7
+ export type PresetId =
8
+ | "custom"
9
+ | "today"
10
+ | "yesterday"
11
+ | "tomorrow"
12
+ | "this_week"
13
+ | "this_month"
14
+ | "last_month";
15
+
16
+ /** Display order. "custom" carries no range — it clears the value. */
17
+ export const PRESET_IDS: PresetId[] = [
18
+ "today",
19
+ "yesterday",
20
+ "tomorrow",
21
+ "this_week",
22
+ "this_month",
23
+ "last_month",
24
+ "custom",
25
+ ];
26
+
27
+ function startOfDay(date: Date): Date {
28
+ const d = new Date(date);
29
+ d.setHours(0, 0, 0, 0);
30
+ return d;
31
+ }
32
+
33
+ function endOfDay(date: Date): Date {
34
+ const d = new Date(date);
35
+ d.setHours(23, 59, 59, 999);
36
+ return d;
37
+ }
38
+
39
+ function startOfWeek(date: Date): Date {
40
+ const d = new Date(date);
41
+ const day = d.getDay();
42
+ const diff = d.getDate() - day + (day === 0 ? -6 : 1); // Monday is first day
43
+ d.setDate(diff);
44
+ d.setHours(0, 0, 0, 0);
45
+ return d;
46
+ }
47
+
48
+ function endOfWeek(date: Date): Date {
49
+ const start = startOfWeek(date);
50
+ const end = new Date(start);
51
+ end.setDate(end.getDate() + 6);
52
+ end.setHours(23, 59, 59, 999);
53
+ return end;
54
+ }
55
+
56
+ function startOfMonth(date: Date): Date {
57
+ return new Date(date.getFullYear(), date.getMonth(), 1);
58
+ }
59
+
60
+ function endOfMonth(date: Date): Date {
61
+ return new Date(date.getFullYear(), date.getMonth() + 1, 0, 23, 59, 59, 999);
62
+ }
63
+
64
+ function range(start: Date, end: Date): DateFilterValue {
65
+ return { start: { date: start, time: null }, end: { date: end, time: null } };
66
+ }
67
+
68
+ /**
69
+ * Resolve a preset to a concrete date range relative to `now`. Returns `null`
70
+ * for "custom" (no range — the caller clears the value so the user picks
71
+ * manually). The boundary math is identical to the view-page filter so a
72
+ * resolved range round-trips back to the same preset.
73
+ */
74
+ export function getPresetValue(id: PresetId, now: Date): DateFilterValue | null {
75
+ switch (id) {
76
+ case "today":
77
+ return range(startOfDay(now), endOfDay(now));
78
+ case "yesterday": {
79
+ const d = new Date(now);
80
+ d.setDate(d.getDate() - 1);
81
+ return range(startOfDay(d), endOfDay(d));
82
+ }
83
+ case "tomorrow": {
84
+ const d = new Date(now);
85
+ d.setDate(d.getDate() + 1);
86
+ return range(startOfDay(d), endOfDay(d));
87
+ }
88
+ case "this_week":
89
+ return range(startOfWeek(now), endOfWeek(now));
90
+ case "this_month":
91
+ return range(startOfMonth(now), endOfMonth(now));
92
+ case "last_month": {
93
+ const d = new Date(now);
94
+ d.setMonth(d.getMonth() - 1);
95
+ return range(startOfMonth(d), endOfMonth(d));
96
+ }
97
+ case "custom":
98
+ return null;
99
+ }
100
+ }
@@ -1,12 +1,11 @@
1
- // FileGalleryModal — pure fullscreen image preview with prev/next nav + ESC close.
2
- // Pure primitive: takes DisplayFile[] only, no Lotics domain coupling. Frontend
3
- // has a richer modal in `frontend/components/file_gallery.tsx` that handles
4
- // xlsx/docx/pdf inline rendering leave that one for Lotics-specific previews
5
- // and use this primitive when only images need previewing.
1
+ // FileGalleryModal — fullscreen file preview with prev/next nav + ESC close.
2
+ // Pure primitive: takes DisplayFile[] only, no Lotics domain coupling. Renders
3
+ // every previewable type (image/PDF/video/audio/Word/Excel/CSV) via FilePreview,
4
+ // whose document engines (@lotics/docx, @lotics/xlsx) are optional peer deps
5
+ // loaded lazily so a consumer that only shows images pays nothing extra.
6
6
 
7
7
  import { useCallback, useEffect, useRef } from "react";
8
8
  import {
9
- Image,
10
9
  Modal,
11
10
  Pressable,
12
11
  StyleSheet,
@@ -15,8 +14,9 @@ import {
15
14
  import { Text } from "./text";
16
15
  import { Icon } from "./icon";
17
16
  import { colors } from "./colors";
18
- import { isImageMimeType } from "./mime";
19
17
  import type { DisplayFile } from "./file_thumbnail";
18
+ import { FilePreview } from "./file_preview";
19
+ import type { PreviewLabels } from "./file_preview_types";
20
20
 
21
21
  export interface FileGalleryModalProps {
22
22
  files: DisplayFile[];
@@ -24,19 +24,22 @@ export interface FileGalleryModalProps {
24
24
  activeIndex: number | null;
25
25
  onIndexChange: (next: number | null) => void;
26
26
  /**
27
- * Optional caption suffix shown under the image. Useful for hint text like
27
+ * Optional caption suffix shown under the file. Useful for hint text like
28
28
  * "ESC để đóng · ←/→ để chuyển". When omitted, just shows filename + count.
29
29
  */
30
30
  captionHint?: string;
31
+ /** Translated preview strings; English fallback when omitted. */
32
+ labels?: Partial<PreviewLabels>;
33
+ /** Reported when a file fails to render (host wires to its logger). */
34
+ onError?: (error: unknown, meta: { fileId: string; mimeType: string }) => void;
31
35
  }
32
36
 
33
37
  /**
34
- * Image-focused fullscreen preview. Non-image MIME types render as a simple
35
- * "{filename} · cannot preview" card callers needing rich preview should use
36
- * a Lotics-specific gallery instead.
38
+ * Fullscreen file preview gallery. Renders any previewable MIME type inline
39
+ * via FilePreview; unknown types show a download placeholder.
37
40
  */
38
41
  export function FileGalleryModal(props: FileGalleryModalProps) {
39
- const { files, activeIndex, onIndexChange, captionHint } = props;
42
+ const { files, activeIndex, onIndexChange, captionHint, labels, onError } = props;
40
43
  const visible = activeIndex !== null;
41
44
  const overlayRef = useRef<View | null>(null);
42
45
 
@@ -81,22 +84,9 @@ export function FileGalleryModal(props: FileGalleryModalProps) {
81
84
  style={StyleSheet.absoluteFill}
82
85
  />
83
86
 
84
- {isImageMimeType(file.mimeType) ? (
85
- <Image
86
- source={{ uri: file.url }}
87
- style={styles.image}
88
- resizeMode="contain"
89
- />
90
- ) : (
91
- <View style={styles.unsupportedCard}>
92
- <Text size="md" style={styles.unsupportedText}>
93
- {file.filename}
94
- </Text>
95
- <Text size="sm" color="muted">
96
- Cannot preview this file type in the gallery.
97
- </Text>
98
- </View>
99
- )}
87
+ <View style={styles.previewPanel}>
88
+ <FilePreview file={file} labels={labels} onError={onError} />
89
+ </View>
100
90
 
101
91
  {activeIndex > 0 ? (
102
92
  <Pressable
@@ -137,20 +127,12 @@ const styles = StyleSheet.create({
137
127
  justifyContent: "center",
138
128
  alignItems: "center",
139
129
  },
140
- image: {
130
+ previewPanel: {
141
131
  width: "92%",
142
- height: "85%",
143
- },
144
- unsupportedCard: {
145
- backgroundColor: colors.white,
132
+ height: "86%",
133
+ backgroundColor: colors.background,
146
134
  borderRadius: 8,
147
- padding: 24,
148
- maxWidth: 480,
149
- gap: 8,
150
- alignItems: "center",
151
- },
152
- unsupportedText: {
153
- fontWeight: "600",
135
+ overflow: "hidden",
154
136
  },
155
137
  navButton: {
156
138
  position: "absolute",
@@ -0,0 +1,38 @@
1
+ import { View, Image, StyleSheet } from "react-native";
2
+ import { Text } from "./text";
3
+ import { DocumentCard } from "./file_thumbnail";
4
+ import { isImageMimeType } from "./mime";
5
+ import { type FilePreviewProps, resolveLabels } from "./file_preview_types";
6
+
7
+ /**
8
+ * Native (mobile) fallback: images render full-bleed; everything else shows a
9
+ * document placeholder. Rich document rendering (docx/xlsx/pdf) is web-only —
10
+ * the `.web.tsx` variant carries it; native consumers download instead.
11
+ */
12
+ export function FilePreview({ file, labels }: FilePreviewProps) {
13
+ const l = resolveLabels(labels);
14
+ if (isImageMimeType(file.mimeType)) {
15
+ return (
16
+ <Image
17
+ source={{ uri: file.url }}
18
+ resizeMode="contain"
19
+ style={styles.image}
20
+ accessibilityIgnoresInvertColors
21
+ />
22
+ );
23
+ }
24
+ return (
25
+ <View style={styles.placeholder}>
26
+ <DocumentCard mimeType={file.mimeType} filename={file.filename} size={120} />
27
+ <Text size="sm" color="muted" style={styles.placeholderText}>
28
+ {l.notAvailable}
29
+ </Text>
30
+ </View>
31
+ );
32
+ }
33
+
34
+ const styles = StyleSheet.create({
35
+ image: { flex: 1, width: "100%" },
36
+ placeholder: { flex: 1, justifyContent: "center", alignItems: "center", gap: 16 },
37
+ placeholderText: { marginTop: 8 },
38
+ });
@@ -0,0 +1,198 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { View, StyleSheet } from "react-native";
3
+ import { Text } from "./text";
4
+ import { Button } from "./button";
5
+ import { DocumentCard, MediaCard } from "./file_thumbnail";
6
+ import {
7
+ isImageMimeType,
8
+ isPdfMimeType,
9
+ isDocxMimeType,
10
+ isVideoMimeType,
11
+ isAudioMimeType,
12
+ isPreviewableMimeType,
13
+ } from "./mime";
14
+ import { type FilePreviewProps, resolveLabels, type PreviewLabels } from "./file_preview_types";
15
+ import { SpreadsheetView } from "./spreadsheet_view";
16
+ import { downloadFileFromUrl } from "./download";
17
+
18
+ /**
19
+ * Universal inline file preview (web). Renders by MIME type: images, PDF, video
20
+ * and audio use browser-native elements; Word via the lazy-loaded `@lotics/docx`
21
+ * renderer; Excel/CSV via the read-only `@lotics/xlsx` canvas. Heavy engines are
22
+ * dynamic-imported so non-document files never pay their bundle cost.
23
+ *
24
+ * Pure: i18n via `labels` props, errors via `onError` — no lingui/analytics
25
+ * (purity test). Lifted from the frontend gallery so both share one renderer.
26
+ */
27
+ export function FilePreview({ file, labels, onError }: FilePreviewProps) {
28
+ const l = resolveLabels(labels);
29
+
30
+ if (isImageMimeType(file.mimeType)) {
31
+ return <img alt={file.filename} src={file.url} style={imageStyle} />;
32
+ }
33
+ if (isPdfMimeType(file.mimeType)) {
34
+ return <iframe src={file.url} title={file.filename} style={fullFrameStyle} />;
35
+ }
36
+ if (isDocxMimeType(file.mimeType)) {
37
+ return <WordPreview file={file} labels={l} onError={onError} />;
38
+ }
39
+ if (isVideoMimeType(file.mimeType)) {
40
+ return <video controls preload="metadata" src={file.url} style={mediaElementStyle} />;
41
+ }
42
+ if (isAudioMimeType(file.mimeType)) {
43
+ return (
44
+ <div style={audioContainerStyle}>
45
+ <MediaCard mimeType={file.mimeType} filename={file.filename} icon="music" size={160} />
46
+ <audio controls preload="metadata" src={file.url} style={audioElementStyle} />
47
+ </div>
48
+ );
49
+ }
50
+ if (isPreviewableMimeType(file.mimeType)) {
51
+ return <SpreadsheetView file={file} labels={l} onError={onError} />;
52
+ }
53
+
54
+ return (
55
+ <View style={styles.placeholder}>
56
+ <DocumentCard mimeType={file.mimeType} filename={file.filename} size={120} />
57
+ <Text size="sm" color="muted" style={styles.placeholderText}>
58
+ {l.notAvailable}
59
+ </Text>
60
+ </View>
61
+ );
62
+ }
63
+
64
+ /** Lazy bridge — pulls jszip + prosemirror only when a .docx is actually opened. */
65
+ async function lazyLoadDocx(
66
+ ...args: Parameters<typeof import("@lotics/docx/load").loadDocxIntoElement>
67
+ ): ReturnType<typeof import("@lotics/docx/load").loadDocxIntoElement> {
68
+ const mod = await import("@lotics/docx/load");
69
+ return mod.loadDocxIntoElement(...args);
70
+ }
71
+
72
+ function WordPreview({
73
+ file,
74
+ labels,
75
+ onError,
76
+ }: {
77
+ file: FilePreviewProps["file"];
78
+ labels: PreviewLabels;
79
+ onError: FilePreviewProps["onError"];
80
+ }) {
81
+ const containerRef = useRef<HTMLDivElement>(null);
82
+ const [state, setState] = useState<{ loading: boolean; error: string | undefined }>({
83
+ loading: true,
84
+ error: undefined,
85
+ });
86
+
87
+ useEffect(() => {
88
+ const controller = new AbortController();
89
+ let loadedView: { destroy: () => void } | null = null;
90
+ setState({ loading: true, error: undefined });
91
+
92
+ const run = async () => {
93
+ const response = await fetch(file.url, { signal: controller.signal });
94
+ if (!response.ok) throw new Error(`Document fetch failed (${response.status})`);
95
+ const arrayBuffer = await response.arrayBuffer();
96
+ const bytes = new Uint8Array(arrayBuffer);
97
+ if (controller.signal.aborted || !containerRef.current) return;
98
+ containerRef.current.innerHTML = "";
99
+ const loaded = await lazyLoadDocx(containerRef.current, bytes);
100
+ if (controller.signal.aborted) {
101
+ loaded.view.destroy();
102
+ return;
103
+ }
104
+ loadedView = loaded.view;
105
+ };
106
+
107
+ run().then(
108
+ () => {
109
+ if (!controller.signal.aborted) setState({ loading: false, error: undefined });
110
+ },
111
+ (err: unknown) => {
112
+ if (controller.signal.aborted) return;
113
+ if (err instanceof DOMException && err.name === "AbortError") return;
114
+ onError?.(err, { fileId: file.id, mimeType: file.mimeType });
115
+ setState({ loading: false, error: labels.loadFailed });
116
+ },
117
+ );
118
+
119
+ return () => {
120
+ controller.abort();
121
+ loadedView?.destroy();
122
+ };
123
+ }, [file.url, file.id, file.mimeType, labels.loadFailed, onError]);
124
+
125
+ return (
126
+ <div style={wordContainerStyle}>
127
+ {state.loading && (
128
+ <div style={centerStyle}>
129
+ <Text size="sm" color="muted">
130
+
131
+ </Text>
132
+ </div>
133
+ )}
134
+ {state.error !== undefined && (
135
+ <div style={centerStyle}>
136
+ <Text size="sm" color="muted">
137
+ {state.error}
138
+ </Text>
139
+ <View style={{ marginTop: 12 }}>
140
+ <Button
141
+ icon="download"
142
+ title={labels.download}
143
+ color="secondary"
144
+ onPress={() => void downloadFileFromUrl(file.url, file.filename)}
145
+ />
146
+ </View>
147
+ </div>
148
+ )}
149
+ <div ref={containerRef} style={state.loading ? hiddenStyle : wordContentStyle} />
150
+ </div>
151
+ );
152
+ }
153
+
154
+ const styles = StyleSheet.create({
155
+ placeholder: { flex: 1, justifyContent: "center", alignItems: "center", gap: 16 },
156
+ placeholderText: { marginTop: 8 },
157
+ });
158
+
159
+ const imageStyle: React.CSSProperties = {
160
+ width: "100%",
161
+ height: "100%",
162
+ objectFit: "contain",
163
+ backgroundColor: "rgba(10, 10, 10, 0.8)",
164
+ };
165
+ const fullFrameStyle: React.CSSProperties = { width: "100%", height: "100%", border: "none" };
166
+ const mediaElementStyle: React.CSSProperties = {
167
+ width: "100%",
168
+ height: "100%",
169
+ backgroundColor: "rgba(10, 10, 10, 0.8)",
170
+ objectFit: "contain",
171
+ };
172
+ const audioContainerStyle: React.CSSProperties = {
173
+ display: "flex",
174
+ flexDirection: "column",
175
+ alignItems: "center",
176
+ justifyContent: "center",
177
+ flex: 1,
178
+ gap: 24,
179
+ padding: 48,
180
+ };
181
+ const audioElementStyle: React.CSSProperties = { width: "min(480px, 100%)" };
182
+ const wordContainerStyle: React.CSSProperties = {
183
+ display: "flex",
184
+ flexDirection: "column",
185
+ flex: 1,
186
+ overflow: "auto",
187
+ backgroundColor: "#f4f4f5",
188
+ };
189
+ const wordContentStyle: React.CSSProperties = { display: "block", minHeight: "100%" };
190
+ const hiddenStyle: React.CSSProperties = { display: "none" };
191
+ const centerStyle: React.CSSProperties = {
192
+ display: "flex",
193
+ flexDirection: "column",
194
+ alignItems: "center",
195
+ justifyContent: "center",
196
+ flex: 1,
197
+ padding: 48,
198
+ };
@@ -0,0 +1,35 @@
1
+ import type { DisplayFile } from "./file_thumbnail";
2
+
3
+ /**
4
+ * Caption/error strings shown inside the preview. `@lotics/ui` is i18n-free
5
+ * (purity test forbids lingui), so the host passes translated strings; English
6
+ * is the fallback.
7
+ */
8
+ export interface PreviewLabels {
9
+ notAvailable: string;
10
+ loadFailed: string;
11
+ download: string;
12
+ passwordProtected: string;
13
+ }
14
+
15
+ export const defaultPreviewLabels: PreviewLabels = {
16
+ notAvailable: "Preview not available",
17
+ loadFailed: "Failed to load preview",
18
+ download: "Download file",
19
+ passwordProtected: "This file is password-protected and cannot be previewed",
20
+ };
21
+
22
+ export interface FilePreviewProps {
23
+ file: DisplayFile;
24
+ /** Override any subset of the English default labels. */
25
+ labels?: Partial<PreviewLabels>;
26
+ /**
27
+ * Reported on a render/parse failure. Logging/analytics live in the host (the
28
+ * package stays analytics-free), so the host wires this to its logger.
29
+ */
30
+ onError?: (error: unknown, meta: { fileId: string; mimeType: string }) => void;
31
+ }
32
+
33
+ export function resolveLabels(labels: Partial<PreviewLabels> | undefined): PreviewLabels {
34
+ return labels ? { ...defaultPreviewLabels, ...labels } : defaultPreviewLabels;
35
+ }
package/src/mime.ts CHANGED
@@ -13,3 +13,49 @@ export function isVideoMimeType(mimeType: string): boolean {
13
13
  export function isAudioMimeType(mimeType: string): boolean {
14
14
  return mimeType.toLowerCase().startsWith("audio/");
15
15
  }
16
+
17
+ const EXCEL_MIME_TYPES = [
18
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
19
+ "application/wps-office.xlsx",
20
+ "application/wps-office.xls",
21
+ "application/vnd.ms-excel",
22
+ ];
23
+
24
+ const LEGACY_EXCEL_MIME_TYPES = ["application/vnd.ms-excel", "application/wps-office.xls"];
25
+
26
+ const DOCX_MIME_TYPES = [
27
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
28
+ "application/wps-office.docx",
29
+ ];
30
+
31
+ export function isPdfMimeType(mimeType: string): boolean {
32
+ return mimeType.toLowerCase() === "application/pdf";
33
+ }
34
+
35
+ export function isExcelMimeType(mimeType: string): boolean {
36
+ return EXCEL_MIME_TYPES.includes(mimeType.toLowerCase());
37
+ }
38
+
39
+ /** Legacy .xls (BIFF) — not parseable in-app; excluded from previewable. */
40
+ export function isLegacyExcelMimeType(mimeType: string): boolean {
41
+ return LEGACY_EXCEL_MIME_TYPES.includes(mimeType.toLowerCase());
42
+ }
43
+
44
+ export function isCsvMimeType(mimeType: string): boolean {
45
+ const mt = mimeType.toLowerCase();
46
+ return mt === "text/csv" || mt === "text/tab-separated-values";
47
+ }
48
+
49
+ export function isDocxMimeType(mimeType: string): boolean {
50
+ return DOCX_MIME_TYPES.includes(mimeType.toLowerCase());
51
+ }
52
+
53
+ /** PDF, modern Excel (.xlsx), Word (.docx), or CSV — types FilePreview renders inline. */
54
+ export function isPreviewableMimeType(mimeType: string): boolean {
55
+ return (
56
+ (isExcelMimeType(mimeType) && !isLegacyExcelMimeType(mimeType)) ||
57
+ isCsvMimeType(mimeType) ||
58
+ isPdfMimeType(mimeType) ||
59
+ isDocxMimeType(mimeType)
60
+ );
61
+ }
@@ -10,6 +10,7 @@ import { MenuButton } from "./menu_button";
10
10
  import { ActivityIndicator } from "./activity_indicator";
11
11
  import { PickerOption, PickerValue, PickerOnValueChange, PickerOnClose } from "./picker";
12
12
  import { useScreenSize } from "./use_screen_size";
13
+ import { customOptionFor } from "./custom_option";
13
14
 
14
15
  export interface PickerMenuProps<T extends string = string, MULTI extends boolean = false> {
15
16
  testID?: string;
@@ -22,6 +23,14 @@ export interface PickerMenuProps<T extends string = string, MULTI extends boolea
22
23
  renderOptionContent?: (option: PickerOption<T>) => React.ReactNode;
23
24
  /** Enable search input to filter options */
24
25
  enableSearch?: boolean;
26
+ /** Single-select + search only: when the typed query matches no option, append
27
+ * a trailing row that selects the raw query as a custom value (free entry).
28
+ * `onValueChange` then receives the typed string. */
29
+ allowCustom?: boolean;
30
+ /** Label for the free-entry row (default: the raw query). Return `null` to
31
+ * suppress the row for a given query — e.g. while it is still incomplete or
32
+ * invalid — so "add" only appears when it is a meaningful thing to add. */
33
+ customOptionLabel?: (query: string) => string | null;
25
34
  enableSelectAll?: boolean;
26
35
  includeEmptyOption?: boolean;
27
36
  /** Called whenever the search text changes. Provide it to drive server-side
@@ -61,6 +70,8 @@ export function PickerMenu<T extends string, MULTI extends boolean = false>(
61
70
  onRequestClose,
62
71
  enableSelectAll,
63
72
  enableSearch,
73
+ allowCustom,
74
+ customOptionLabel,
64
75
  includeEmptyOption,
65
76
  onSearchChange,
66
77
  serverFiltered,
@@ -92,6 +103,13 @@ export function PickerMenu<T extends string, MULTI extends boolean = false>(
92
103
 
93
104
  const [searchQuery, setSearchQuery] = useState("");
94
105
 
106
+ // Free entry: a trailing selectable row carrying the raw query as its value
107
+ // (recognised below for distinct styling). See `customOptionFor` for the rules.
108
+ const customOpt = useMemo(
109
+ () => customOptionFor({ allowCustom, multi, query: searchQuery, options, customOptionLabel }),
110
+ [allowCustom, multi, searchQuery, options, customOptionLabel],
111
+ );
112
+
95
113
  const filteredOptions = useMemo(() => {
96
114
  let result = options;
97
115
  // Skip the local filter in server-driven mode — `options` already reflect
@@ -107,8 +125,11 @@ export function PickerMenu<T extends string, MULTI extends boolean = false>(
107
125
  };
108
126
  result = [emptyOption, ...result];
109
127
  }
128
+ if (customOpt) {
129
+ result = [...result, customOpt];
130
+ }
110
131
  return result;
111
- }, [options, enableSearch, searchQuery, includeEmptyOption, serverFiltered]);
132
+ }, [options, enableSearch, searchQuery, includeEmptyOption, serverFiltered, customOpt]);
112
133
 
113
134
  const [focusedIndex, setFocusedIndex] = useState<number>(() => {
114
135
  const selectedIndex = filteredOptions.findIndex((opt) => {
@@ -297,23 +318,26 @@ export function PickerMenu<T extends string, MULTI extends boolean = false>(
297
318
  ? Array.isArray(multiValue) && multiValue.includes(item.value)
298
319
  : item.value === singleValue;
299
320
  const isFocused = index === focusedIndex;
321
+ const isCustom = !!customOpt && item.value === customOpt.value;
300
322
 
301
323
  return (
302
324
  <MenuButton
303
325
  key={item.value}
304
- testID={item.testID || `picker-option-${item.value}`}
326
+ testID={isCustom ? "picker-custom-option" : item.testID || `picker-option-${item.value}`}
305
327
  icon={multi ? <Checkbox checked={isSelected} /> : undefined}
306
328
  title={
307
- renderOptionContent ? (
329
+ renderOptionContent && !isCustom ? (
308
330
  renderOptionContent(item)
309
331
  ) : (
310
- <Text userSelect="none" numberOfLines={1}>
332
+ <Text userSelect="none" numberOfLines={1} color={isCustom ? "zinc-500" : undefined}>
311
333
  {item.label}
312
334
  </Text>
313
335
  )
314
336
  }
315
337
  right={
316
- !multi && isSelected ? (
338
+ isCustom ? (
339
+ <Icon name="plus" size={16} color={colors.zinc["400"]} />
340
+ ) : !multi && isSelected ? (
317
341
  <Icon name="check" size={18} color={colors.zinc["950"]} />
318
342
  ) : undefined
319
343
  }