@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 +16 -1
- package/src/combobox.tsx +13 -1
- package/src/custom_option.test.ts +50 -0
- package/src/custom_option.ts +30 -0
- package/src/file_gallery_modal.tsx +22 -40
- package/src/file_preview.tsx +38 -0
- package/src/file_preview.web.tsx +198 -0
- package/src/file_preview_types.ts +35 -0
- package/src/menu_list_item.tsx +43 -3
- package/src/mime.ts +46 -0
- package/src/picker_menu.tsx +29 -5
- package/src/search_select.tsx +47 -47
- package/src/spreadsheet_view.tsx +304 -0
- package/src/table.tsx +105 -36
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "1.
|
|
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 —
|
|
2
|
-
// Pure primitive: takes DisplayFile[] only, no Lotics domain coupling.
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
|
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
|
-
*
|
|
35
|
-
*
|
|
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
|
-
{
|
|
85
|
-
<
|
|
86
|
-
|
|
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
|
-
|
|
130
|
+
previewPanel: {
|
|
141
131
|
width: "92%",
|
|
142
|
-
height: "
|
|
143
|
-
|
|
144
|
-
unsupportedCard: {
|
|
145
|
-
backgroundColor: colors.white,
|
|
132
|
+
height: "86%",
|
|
133
|
+
backgroundColor: colors.background,
|
|
146
134
|
borderRadius: 8,
|
|
147
|
-
|
|
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/menu_list_item.tsx
CHANGED
|
@@ -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 {
|
|
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 = [
|
|
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
|
-
|
|
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
|
});
|