@lotics/ui 1.14.0 → 1.16.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.14.0",
3
+ "version": "1.16.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -9,6 +9,11 @@
9
9
  "./download": "./src/download.ts",
10
10
  "./file_badge": "./src/file_badge.tsx",
11
11
  "./file_thumbnail": "./src/file_thumbnail.tsx",
12
+ "./file_preview": {
13
+ "react-native": "./src/file_preview.tsx",
14
+ "default": "./src/file_preview.web.tsx"
15
+ },
16
+ "./file_preview_types": "./src/file_preview_types.ts",
12
17
  "./file_gallery_modal": "./src/file_gallery_modal.tsx",
13
18
  "./pagination": "./src/pagination.tsx",
14
19
  "./bar_chart": "./src/bar_chart.tsx",
@@ -153,6 +158,8 @@
153
158
  },
154
159
  "license": "MIT",
155
160
  "peerDependencies": {
161
+ "@lotics/docx": "^0.1.0",
162
+ "@lotics/xlsx": "^0.1.0",
156
163
  "@react-native-picker/picker": ">=2.0.0",
157
164
  "expo-image": ">=3.0.0",
158
165
  "lucide-react": ">=0.460.0",
@@ -165,6 +172,12 @@
165
172
  "recharts": ">=3.0.0"
166
173
  },
167
174
  "peerDependenciesMeta": {
175
+ "@lotics/docx": {
176
+ "optional": true
177
+ },
178
+ "@lotics/xlsx": {
179
+ "optional": true
180
+ },
168
181
  "expo-image": {
169
182
  "optional": true
170
183
  },
@@ -187,6 +200,8 @@
187
200
  "test": "vitest run"
188
201
  },
189
202
  "devDependencies": {
203
+ "@lotics/docx": "^0.1.0",
204
+ "@lotics/xlsx": "^0.1.0",
190
205
  "recharts": "^3.8.1"
191
206
  }
192
207
  }
package/src/combobox.tsx CHANGED
@@ -29,6 +29,13 @@ export interface ComboboxProps<T extends string = string> {
29
29
  searchPlaceholder?: string;
30
30
  /** Shown when there are no results and not loading. Default: "No results". */
31
31
  emptyText?: string;
32
+ /** Accept free entry: when the typed query matches no option, offer it as a
33
+ * custom value. `onValueChange` then receives `{ value: query, label: query }`.
34
+ * The consumer decides what an unknown value means (e.g. a manually-typed id). */
35
+ allowCustom?: boolean;
36
+ /** Label for the free-entry row (default: the raw query). Return `null` to
37
+ * suppress it for a given query (e.g. still incomplete/invalid). */
38
+ customOptionLabel?: (query: string) => string | null;
32
39
  disabled?: boolean;
33
40
  autoFocus?: boolean;
34
41
  testID?: string;
@@ -56,6 +63,8 @@ export function Combobox<T extends string>(props: ComboboxProps<T>) {
56
63
  placeholder,
57
64
  searchPlaceholder,
58
65
  emptyText,
66
+ allowCustom,
67
+ customOptionLabel,
59
68
  disabled = false,
60
69
  autoFocus = false,
61
70
  testID,
@@ -73,9 +82,10 @@ export function Combobox<T extends string>(props: ComboboxProps<T>) {
73
82
  (v: T) => {
74
83
  const opt = options.find((o) => o.value === v);
75
84
  if (opt) onValueChange(opt);
85
+ else if (allowCustom && v) onValueChange({ value: v, label: v });
76
86
  setOpen(false);
77
87
  },
78
- [options, onValueChange],
88
+ [options, onValueChange, allowCustom],
79
89
  );
80
90
 
81
91
  return (
@@ -117,6 +127,8 @@ export function Combobox<T extends string>(props: ComboboxProps<T>) {
117
127
  onValueChange={handleSelect}
118
128
  onRequestClose={() => setOpen(false)}
119
129
  enableSearch
130
+ allowCustom={allowCustom}
131
+ customOptionLabel={customOptionLabel}
120
132
  onSearchChange={debouncedSearch}
121
133
  // Local filtering is wrong when the consumer drives search server-side.
122
134
  serverFiltered={onSearchChange !== undefined}
@@ -0,0 +1,50 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { customOptionFor } from "./custom_option";
3
+
4
+ const opts = [
5
+ { value: "BX1N", label: "BX1N" },
6
+ { value: "UX1N", label: "UX1N" },
7
+ ];
8
+
9
+ describe("customOptionFor", () => {
10
+ it("offers the trimmed query when nothing matches", () => {
11
+ expect(customOptionFor({ allowCustom: true, multi: false, query: " ABCD ", options: opts }))
12
+ .toEqual({ value: "ABCD", label: "ABCD" });
13
+ });
14
+
15
+ it("suppressed when free entry is off", () => {
16
+ expect(customOptionFor({ allowCustom: false, multi: false, query: "ABCD", options: opts })).toBeNull();
17
+ expect(customOptionFor({ allowCustom: undefined, multi: false, query: "ABCD", options: opts })).toBeNull();
18
+ });
19
+
20
+ it("suppressed for multi-select", () => {
21
+ expect(customOptionFor({ allowCustom: true, multi: true, query: "ABCD", options: opts })).toBeNull();
22
+ });
23
+
24
+ it("suppressed for an empty / whitespace query", () => {
25
+ expect(customOptionFor({ allowCustom: true, multi: false, query: " ", options: opts })).toBeNull();
26
+ });
27
+
28
+ it("suppressed when the query matches an option value (case-insensitive)", () => {
29
+ expect(customOptionFor({ allowCustom: true, multi: false, query: "bx1n", options: opts })).toBeNull();
30
+ });
31
+
32
+ it("uses customOptionLabel for the row label but keeps the raw value", () => {
33
+ expect(customOptionFor({
34
+ allowCustom: true, multi: false, query: "ABCD", options: opts,
35
+ customOptionLabel: (q) => `Add "${q}"`,
36
+ })).toEqual({ value: "ABCD", label: 'Add "ABCD"' });
37
+ });
38
+
39
+ it("suppressed when customOptionLabel returns null", () => {
40
+ expect(customOptionFor({
41
+ allowCustom: true, multi: false, query: "AB", options: opts,
42
+ customOptionLabel: () => null,
43
+ })).toBeNull();
44
+ });
45
+
46
+ it("ignores undefined / false holes in the options list", () => {
47
+ expect(customOptionFor({ allowCustom: true, multi: false, query: "ABCD", options: [undefined, false, ...opts] }))
48
+ .toEqual({ value: "ABCD", label: "ABCD" });
49
+ });
50
+ });
@@ -0,0 +1,30 @@
1
+ import type { PickerOption } from "./picker";
2
+
3
+ /**
4
+ * Resolve the free-entry ("custom") option a search picker should offer for the
5
+ * current query — or `null` when it should not be offered. The option's `value`
6
+ * stays the raw trimmed query; consumers decide what an unknown value means.
7
+ *
8
+ * Suppressed when: free entry is disabled, the picker is multi-select, the query
9
+ * is empty, the query already matches an existing option's value
10
+ * (case-insensitive), or `customOptionLabel` returns `null` for it.
11
+ *
12
+ * Pure and RN-free so it is unit-testable in isolation from `PickerMenu`.
13
+ */
14
+ export function customOptionFor<T extends string>(args: {
15
+ allowCustom: boolean | undefined;
16
+ multi: boolean;
17
+ query: string;
18
+ options: (PickerOption<T> | undefined | false)[];
19
+ customOptionLabel?: (query: string) => string | null;
20
+ }): PickerOption<T> | null {
21
+ const { allowCustom, multi, query, options, customOptionLabel } = args;
22
+ if (!allowCustom || multi) return null;
23
+ const q = query.trim();
24
+ if (!q || options.some((o) => o && String(o.value).toLowerCase() === q.toLowerCase())) {
25
+ return null;
26
+ }
27
+ const label = customOptionLabel ? customOptionLabel(q) : q;
28
+ if (label == null) return null;
29
+ return { value: q as T, label };
30
+ }
@@ -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
+ }
@@ -1,6 +1,7 @@
1
1
  import { Ref } from "react";
2
2
  import { StyleProp, StyleSheet, View, ViewStyle } from "react-native";
3
3
  import { Text } from "./text";
4
+ import { colors } from "./colors";
4
5
  import { Icon, IconName } from "./icon";
5
6
  import { PressableHighlight } from "./pressable_highlight";
6
7
 
@@ -11,13 +12,38 @@ export interface MenuListItemProps {
11
12
  description?: string;
12
13
  right?: React.ReactNode;
13
14
  onPress?: () => void;
15
+ onHoverIn?: () => void;
16
+ /** Persistent selection highlight (e.g. the current value in a listbox). */
17
+ selected?: boolean;
18
+ /** Roving keyboard highlight, driven by the controlling input via
19
+ * `aria-activedescendant`. Distinct from `selected`. */
20
+ focused?: boolean;
14
21
  disabled?: boolean;
15
22
  style?: StyleProp<ViewStyle>;
16
23
  testID?: string;
24
+ /** DOM id, referenced by a combobox input's `aria-activedescendant`. */
25
+ nativeID?: string;
26
+ /** ARIA role. Defaults to `button`; pass `option` for a listbox row. */
27
+ role?: "menuitem" | "button" | "option";
17
28
  }
18
29
 
19
30
  export function MenuListItem(props: MenuListItemProps) {
20
- const { ref, icon, title, description, right, onPress, disabled, style, testID } = props;
31
+ const {
32
+ ref,
33
+ icon,
34
+ title,
35
+ description,
36
+ right,
37
+ onPress,
38
+ onHoverIn,
39
+ selected,
40
+ focused,
41
+ disabled,
42
+ style,
43
+ testID,
44
+ nativeID,
45
+ role = "button",
46
+ } = props;
21
47
 
22
48
  const resolvedIcon =
23
49
  typeof icon === "string" ? <Icon size={20} name={icon as IconName} /> : icon;
@@ -39,18 +65,26 @@ export function MenuListItem(props: MenuListItemProps) {
39
65
  </>
40
66
  );
41
67
 
42
- const containerStyle = [styles.container, style];
68
+ const containerStyle = [
69
+ styles.container,
70
+ selected && styles.selected,
71
+ focused && !selected && styles.focused,
72
+ style,
73
+ ];
43
74
 
44
75
  if (onPress) {
45
76
  return (
46
77
  <PressableHighlight
47
78
  ref={ref}
48
79
  testID={testID}
80
+ nativeID={nativeID}
49
81
  onPress={onPress}
82
+ onHoverIn={onHoverIn}
50
83
  disabled={disabled}
51
84
  style={containerStyle}
52
- accessibilityRole="button"
85
+ role={role}
53
86
  accessibilityLabel={description ? `${title}, ${description}` : title}
87
+ aria-selected={selected || undefined}
54
88
  aria-disabled={disabled || undefined}
55
89
  >
56
90
  {inner}
@@ -79,4 +113,10 @@ const styles = StyleSheet.create({
79
113
  gap: 2,
80
114
  alignItems: "flex-start",
81
115
  },
116
+ selected: {
117
+ backgroundColor: colors.zinc["100"],
118
+ },
119
+ focused: {
120
+ backgroundColor: colors.zinc["50"],
121
+ },
82
122
  });