@lotics/ui 5.9.0 → 5.10.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/AGENTS.md +127 -44
- package/examples/tpl_approvals.tsx +3 -3
- package/examples/tpl_dossier.tsx +5 -4
- package/examples/tpl_pipeline.tsx +400 -0
- package/examples/tpl_record.tsx +5 -4
- package/examples/tpl_shifts.tsx +7 -5
- package/examples/tpl_task_board.tsx +473 -0
- package/examples/tpl_tasks.tsx +440 -0
- package/package.json +10 -5
- package/src/accordion.tsx +1 -1
- package/src/agent_progress.tsx +1 -1
- package/src/agent_run.tsx +1 -1
- package/src/breakdown.tsx +2 -2
- package/src/button.tsx +12 -3
- package/src/calendar/calendar_view.tsx +10 -10
- package/src/calendar/month_view.tsx +30 -11
- package/src/calendar/time_grid_view.tsx +25 -7
- package/src/card_select_item.tsx +11 -13
- package/src/check_circle.tsx +88 -0
- package/src/checkbox_input.tsx +5 -1
- package/src/chip.tsx +1 -1
- package/src/chip_group.tsx +1 -1
- package/src/column_filter.tsx +3 -2
- package/src/combobox.tsx +84 -219
- package/src/confidence.tsx +14 -3
- package/src/control_surface.ts +22 -7
- package/src/data_grid.tsx +159 -0
- package/src/date_calendar.tsx +46 -23
- package/src/date_field.tsx +10 -2
- package/src/date_filter.tsx +5 -9
- package/src/date_picker.tsx +78 -26
- package/src/date_picker_value.ts +11 -0
- package/src/date_range_filter_field.tsx +6 -4
- package/src/drawer.tsx +1 -1
- package/src/file_dropzone.tsx +5 -0
- package/src/file_gallery_modal.tsx +43 -42
- package/src/file_preview.web.tsx +25 -11
- package/src/file_preview_types.ts +9 -0
- package/src/file_row.tsx +5 -1
- package/src/file_rows.tsx +6 -2
- package/src/file_thumbnail.tsx +13 -4
- package/src/file_thumbnail_grid.tsx +5 -0
- package/src/files_editor.tsx +232 -0
- package/src/filter_chip.tsx +25 -12
- package/src/focus_ring_pressable.tsx +34 -0
- package/src/form_picker.tsx +3 -3
- package/src/gantt/gantt_view.tsx +25 -7
- package/src/heatmap.tsx +5 -1
- package/src/icon_button.tsx +2 -2
- package/src/image_gallery.tsx +8 -10
- package/src/index.css +8 -12
- package/src/inline_date_picker.tsx +11 -11
- package/src/inline_edit.tsx +22 -6
- package/src/inline_member_select.tsx +1 -1
- package/src/inline_select.tsx +9 -8
- package/src/inline_text_input.tsx +4 -1
- package/src/link.tsx +11 -1
- package/src/link_button.tsx +1 -1
- package/src/list_item.tsx +2 -2
- package/src/member_select.tsx +9 -4
- package/src/menu_button.tsx +2 -2
- package/src/menu_list_item.tsx +2 -2
- package/src/number_input.tsx +11 -4
- package/src/option_list.tsx +211 -0
- package/src/peek.tsx +1 -1
- package/src/picker.tsx +24 -213
- package/src/pressable_highlight.tsx +42 -23
- package/src/radio_picker.tsx +2 -2
- package/src/range_slider.tsx +35 -7
- package/src/react_native.d.ts +3 -0
- package/src/scroll_to_bottom.tsx +1 -1
- package/src/section.tsx +7 -2
- package/src/segmented_control.tsx +2 -2
- package/src/select.tsx +299 -0
- package/src/sort_header.tsx +1 -1
- package/src/sources.tsx +1 -1
- package/src/spreadsheet_view.tsx +43 -17
- package/src/status_grid.tsx +4 -3
- package/src/stepper.tsx +1 -1
- package/src/switch.tsx +5 -1
- package/src/switch_button.tsx +1 -1
- package/src/switcher.tsx +1 -1
- package/src/table.tsx +5 -1
- package/src/tabs.tsx +2 -2
- package/src/text_input_field.tsx +15 -4
- package/src/time_picker.tsx +11 -4
- package/src/timeline.tsx +1 -1
- package/src/use_focus_ring.ts +80 -0
- package/src/use_hover.ts +26 -0
- package/src/use_list_keyboard_nav.test.ts +71 -0
- package/src/use_list_keyboard_nav.ts +52 -5
- package/src/use_option_list.test.ts +193 -0
- package/src/use_option_list.ts +354 -0
- package/src/command_menu.tsx +0 -205
- package/src/picker_menu.tsx +0 -355
- package/src/tag_input.tsx +0 -203
- package/src/time_field.tsx +0 -297
|
@@ -5,15 +5,14 @@
|
|
|
5
5
|
// (pdfjs-dist, @lotics/docx, @lotics/xlsx) are optional peer deps loaded lazily —
|
|
6
6
|
// so a consumer that only shows images pays nothing extra.
|
|
7
7
|
|
|
8
|
-
import { useCallback, useEffect,
|
|
9
|
-
import { Linking, Modal, Platform,
|
|
8
|
+
import { useCallback, useEffect, type ReactNode } from "react";
|
|
9
|
+
import { Linking, Modal, Platform, StyleSheet, View } from "react-native";
|
|
10
10
|
import { Text } from "./text";
|
|
11
11
|
import { Icon } from "./icon";
|
|
12
12
|
import { IconButton } from "./icon_button";
|
|
13
|
+
import { Button } from "./button";
|
|
14
|
+
import { FocusRingPressable } from "./focus_ring_pressable";
|
|
13
15
|
import { Alert } from "./alert";
|
|
14
|
-
import { MenuButton } from "./menu_button";
|
|
15
|
-
import { Popover, PopoverTrigger, PopoverContent } from "./popover";
|
|
16
|
-
import { useScreenSize } from "./use_screen_size";
|
|
17
16
|
import { colors } from "./colors";
|
|
18
17
|
import type { DisplayFile } from "./file_thumbnail";
|
|
19
18
|
import { FilePreview } from "./file_preview";
|
|
@@ -65,6 +64,13 @@ export interface FileGalleryModalProps {
|
|
|
65
64
|
* forwarded so the override can honor the rotate controls for images.
|
|
66
65
|
*/
|
|
67
66
|
renderPreview?: (file: DisplayFile, opts: { rotation: number }) => ReactNode;
|
|
67
|
+
/**
|
|
68
|
+
* Credentials mode for the built-in `FilePreview`'s byte fetches. The host
|
|
69
|
+
* frontend passes `"include"` so the session cookie rides the cross-origin
|
|
70
|
+
* fetch to its auth-gated proxy; custom-code apps omit it (presigned URLs).
|
|
71
|
+
* Ignored when `renderPreview` is set (the override owns its own fetching).
|
|
72
|
+
*/
|
|
73
|
+
credentials?: RequestCredentials;
|
|
68
74
|
/**
|
|
69
75
|
* Persist the active image's current view-rotation as a new stored file. When
|
|
70
76
|
* provided and the active image is rotated, a confirm (✓) control appears next
|
|
@@ -93,12 +99,11 @@ export function FileGalleryModal(props: FileGalleryModalProps) {
|
|
|
93
99
|
rotation,
|
|
94
100
|
rotatable = true,
|
|
95
101
|
renderPreview,
|
|
102
|
+
credentials,
|
|
96
103
|
onPersistRotation,
|
|
97
104
|
persisting,
|
|
98
105
|
} = props;
|
|
99
106
|
const visible = activeIndex !== null;
|
|
100
|
-
const [actionsOpen, setActionsOpen] = useState(false);
|
|
101
|
-
const { small } = useScreenSize();
|
|
102
107
|
// Own the rotation when the host doesn't share one (standalone consumers).
|
|
103
108
|
const internalRotation = useImageRotation();
|
|
104
109
|
const rot = rotation ?? internalRotation;
|
|
@@ -147,26 +152,24 @@ export function FileGalleryModal(props: FileGalleryModalProps) {
|
|
|
147
152
|
const rotated = rot.rotationFor(file.id) !== 0;
|
|
148
153
|
|
|
149
154
|
const handleDownload = () => {
|
|
150
|
-
setActionsOpen(false);
|
|
151
155
|
if (onDownload) {
|
|
152
156
|
onDownload(file);
|
|
153
157
|
return;
|
|
154
158
|
}
|
|
155
|
-
// Default: the web fetch+save path
|
|
156
|
-
//
|
|
159
|
+
// Default: the web fetch+save path. `credentials` rides the host's auth-gated
|
|
160
|
+
// proxy URLs (presigned app URLs are same-origin and omit it). On native the
|
|
161
|
+
// web primitive can't run, so open the URL externally.
|
|
157
162
|
if (Platform.OS === "web") {
|
|
158
|
-
void downloadFileFromUrl(file.url, file.filename);
|
|
163
|
+
void downloadFileFromUrl(file.url, file.filename, { credentials });
|
|
159
164
|
} else {
|
|
160
165
|
void Linking.openURL(file.url);
|
|
161
166
|
}
|
|
162
167
|
};
|
|
163
168
|
const handleOpenExternal = () => {
|
|
164
|
-
setActionsOpen(false);
|
|
165
169
|
onOpenExternal?.(file);
|
|
166
170
|
};
|
|
167
171
|
const handleRemove = () => {
|
|
168
172
|
if (!onRemove) return;
|
|
169
|
-
setActionsOpen(false);
|
|
170
173
|
Alert.alert(l.removeConfirmTitle, l.removeConfirmMessage(file.filename), [
|
|
171
174
|
{ text: l.cancel, style: "cancel" },
|
|
172
175
|
{
|
|
@@ -231,26 +234,28 @@ export function FileGalleryModal(props: FileGalleryModalProps) {
|
|
|
231
234
|
</>
|
|
232
235
|
) : null}
|
|
233
236
|
|
|
234
|
-
<
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
237
|
+
<Button
|
|
238
|
+
icon="download"
|
|
239
|
+
title={l.download}
|
|
240
|
+
color="secondary"
|
|
241
|
+
onPress={handleDownload}
|
|
242
|
+
/>
|
|
243
|
+
{onOpenExternal ? (
|
|
244
|
+
<Button
|
|
245
|
+
icon="external-link"
|
|
246
|
+
title={l.openExternal}
|
|
247
|
+
color="secondary"
|
|
248
|
+
onPress={handleOpenExternal}
|
|
249
|
+
/>
|
|
250
|
+
) : null}
|
|
251
|
+
{onRemove ? (
|
|
252
|
+
<Button
|
|
253
|
+
icon="trash"
|
|
254
|
+
title={l.remove}
|
|
255
|
+
color="danger-secondary"
|
|
256
|
+
onPress={handleRemove}
|
|
257
|
+
/>
|
|
258
|
+
) : null}
|
|
254
259
|
|
|
255
260
|
<IconButton size="lg" icon="x" tooltip={l.close} color="secondary" onPress={close} />
|
|
256
261
|
</View>
|
|
@@ -264,29 +269,28 @@ export function FileGalleryModal(props: FileGalleryModalProps) {
|
|
|
264
269
|
labels={labels}
|
|
265
270
|
onError={onError}
|
|
266
271
|
rotation={rot.rotationFor(file.id)}
|
|
272
|
+
credentials={credentials}
|
|
267
273
|
/>
|
|
268
274
|
)}
|
|
269
275
|
|
|
270
276
|
{activeIndex > 0 ? (
|
|
271
|
-
<
|
|
277
|
+
<FocusRingPressable accessibilityRole="button"
|
|
272
278
|
onPress={prev}
|
|
273
|
-
accessibilityRole="button"
|
|
274
279
|
accessibilityLabel={l.previous}
|
|
275
280
|
style={[styles.nav, styles.navLeft]}
|
|
276
281
|
>
|
|
277
282
|
<Icon name="chevron-left" size={28} color={colors.white} />
|
|
278
|
-
</
|
|
283
|
+
</FocusRingPressable>
|
|
279
284
|
) : null}
|
|
280
285
|
|
|
281
286
|
{activeIndex < total - 1 ? (
|
|
282
|
-
<
|
|
287
|
+
<FocusRingPressable accessibilityRole="button"
|
|
283
288
|
onPress={next}
|
|
284
|
-
accessibilityRole="button"
|
|
285
289
|
accessibilityLabel={l.next}
|
|
286
290
|
style={[styles.nav, styles.navRight]}
|
|
287
291
|
>
|
|
288
292
|
<Icon name="chevron-right" size={28} color={colors.white} />
|
|
289
|
-
</
|
|
293
|
+
</FocusRingPressable>
|
|
290
294
|
) : null}
|
|
291
295
|
</View>
|
|
292
296
|
</View>
|
|
@@ -318,9 +322,6 @@ const styles = StyleSheet.create({
|
|
|
318
322
|
spacer: {
|
|
319
323
|
flex: 1,
|
|
320
324
|
},
|
|
321
|
-
menu: {
|
|
322
|
-
minWidth: 180,
|
|
323
|
-
},
|
|
324
325
|
previewArea: {
|
|
325
326
|
flex: 1,
|
|
326
327
|
justifyContent: "center",
|
package/src/file_preview.web.tsx
CHANGED
|
@@ -33,7 +33,7 @@ import { colors } from "./colors";
|
|
|
33
33
|
* Pure: i18n via `labels` props, errors via `onError` — no lingui/analytics
|
|
34
34
|
* (purity test). Lifted from the frontend gallery so both share one renderer.
|
|
35
35
|
*/
|
|
36
|
-
export function FilePreview({ file, labels, onError, rotation }: FilePreviewProps) {
|
|
36
|
+
export function FilePreview({ file, labels, onError, rotation, credentials }: FilePreviewProps) {
|
|
37
37
|
const l = resolveLabels(labels);
|
|
38
38
|
|
|
39
39
|
if (isImageMimeType(file.mimeType)) {
|
|
@@ -47,10 +47,10 @@ export function FilePreview({ file, labels, onError, rotation }: FilePreviewProp
|
|
|
47
47
|
);
|
|
48
48
|
}
|
|
49
49
|
if (isPdfMimeType(file.mimeType)) {
|
|
50
|
-
return <PdfPreview file={file} labels={l} onError={onError} />;
|
|
50
|
+
return <PdfPreview file={file} labels={l} onError={onError} credentials={credentials} />;
|
|
51
51
|
}
|
|
52
52
|
if (isDocxMimeType(file.mimeType)) {
|
|
53
|
-
return <WordPreview file={file} labels={l} onError={onError} />;
|
|
53
|
+
return <WordPreview file={file} labels={l} onError={onError} credentials={credentials} />;
|
|
54
54
|
}
|
|
55
55
|
if (isVideoMimeType(file.mimeType)) {
|
|
56
56
|
return <video controls preload="metadata" src={file.url} style={mediaElementStyle} />;
|
|
@@ -64,7 +64,7 @@ export function FilePreview({ file, labels, onError, rotation }: FilePreviewProp
|
|
|
64
64
|
);
|
|
65
65
|
}
|
|
66
66
|
if (isPreviewableMimeType(file.mimeType)) {
|
|
67
|
-
return <SpreadsheetView file={file} labels={l} onError={onError} />;
|
|
67
|
+
return <SpreadsheetView file={file} labels={l} onError={onError} credentials={credentials} />;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
return (
|
|
@@ -89,10 +89,12 @@ function WordPreview({
|
|
|
89
89
|
file,
|
|
90
90
|
labels,
|
|
91
91
|
onError,
|
|
92
|
+
credentials,
|
|
92
93
|
}: {
|
|
93
94
|
file: FilePreviewProps["file"];
|
|
94
95
|
labels: PreviewLabels;
|
|
95
96
|
onError: FilePreviewProps["onError"];
|
|
97
|
+
credentials: FilePreviewProps["credentials"];
|
|
96
98
|
}) {
|
|
97
99
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
98
100
|
const [state, setState] = useState<{ loading: boolean; error: string | undefined }>({
|
|
@@ -106,7 +108,7 @@ function WordPreview({
|
|
|
106
108
|
setState({ loading: true, error: undefined });
|
|
107
109
|
|
|
108
110
|
const run = async () => {
|
|
109
|
-
const response = await fetch(file.url, { signal: controller.signal });
|
|
111
|
+
const response = await fetch(file.url, { signal: controller.signal, credentials });
|
|
110
112
|
if (!response.ok) throw new Error(`Document fetch failed (${response.status})`);
|
|
111
113
|
const arrayBuffer = await response.arrayBuffer();
|
|
112
114
|
const bytes = new Uint8Array(arrayBuffer);
|
|
@@ -136,7 +138,7 @@ function WordPreview({
|
|
|
136
138
|
controller.abort();
|
|
137
139
|
loadedView?.destroy();
|
|
138
140
|
};
|
|
139
|
-
}, [file.url, file.id, file.mimeType, labels.loadFailed, onError]);
|
|
141
|
+
}, [file.url, file.id, file.mimeType, labels.loadFailed, onError, credentials]);
|
|
140
142
|
|
|
141
143
|
return (
|
|
142
144
|
<div style={wordContainerStyle}>
|
|
@@ -157,7 +159,7 @@ function WordPreview({
|
|
|
157
159
|
icon="download"
|
|
158
160
|
title={labels.download}
|
|
159
161
|
color="secondary"
|
|
160
|
-
onPress={() => void downloadFileFromUrl(file.url, file.filename)}
|
|
162
|
+
onPress={() => void downloadFileFromUrl(file.url, file.filename, { credentials })}
|
|
161
163
|
/>
|
|
162
164
|
</View>
|
|
163
165
|
</div>
|
|
@@ -246,14 +248,15 @@ async function renderPdf(opts: {
|
|
|
246
248
|
scroll: HTMLDivElement;
|
|
247
249
|
pages: HTMLDivElement;
|
|
248
250
|
signal: AbortSignal;
|
|
251
|
+
credentials: RequestCredentials | undefined;
|
|
249
252
|
/** Reports a per-page render failure (the page degrades to a blank slot, but
|
|
250
253
|
* the error is never swallowed). */
|
|
251
254
|
reportError: (err: unknown) => void;
|
|
252
255
|
}): Promise<RenderedPdf> {
|
|
253
|
-
const { url, scroll, pages, signal, reportError } = opts;
|
|
256
|
+
const { url, scroll, pages, signal, credentials, reportError } = opts;
|
|
254
257
|
const pdfjs = await loadPdfjs();
|
|
255
258
|
|
|
256
|
-
const response = await fetch(url, { signal });
|
|
259
|
+
const response = await fetch(url, { signal, credentials });
|
|
257
260
|
if (!response.ok) throw new Error(`PDF fetch failed (${response.status})`);
|
|
258
261
|
const data = new Uint8Array(await response.arrayBuffer());
|
|
259
262
|
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
|
|
@@ -367,10 +370,12 @@ function PdfPreview({
|
|
|
367
370
|
file,
|
|
368
371
|
labels,
|
|
369
372
|
onError,
|
|
373
|
+
credentials,
|
|
370
374
|
}: {
|
|
371
375
|
file: FilePreviewProps["file"];
|
|
372
376
|
labels: PreviewLabels;
|
|
373
377
|
onError: FilePreviewProps["onError"];
|
|
378
|
+
credentials: FilePreviewProps["credentials"];
|
|
374
379
|
}) {
|
|
375
380
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
376
381
|
const pagesRef = useRef<HTMLDivElement>(null);
|
|
@@ -394,6 +399,7 @@ function PdfPreview({
|
|
|
394
399
|
scroll,
|
|
395
400
|
pages,
|
|
396
401
|
signal: controller.signal,
|
|
402
|
+
credentials,
|
|
397
403
|
reportError: (err) => onError?.(err, { fileId: file.id, mimeType: file.mimeType }),
|
|
398
404
|
}).then(
|
|
399
405
|
(result) => {
|
|
@@ -420,7 +426,15 @@ function PdfPreview({
|
|
|
420
426
|
controller.abort();
|
|
421
427
|
rendered?.destroy();
|
|
422
428
|
};
|
|
423
|
-
}, [
|
|
429
|
+
}, [
|
|
430
|
+
file.url,
|
|
431
|
+
file.id,
|
|
432
|
+
file.mimeType,
|
|
433
|
+
labels.loadFailed,
|
|
434
|
+
labels.passwordProtected,
|
|
435
|
+
onError,
|
|
436
|
+
credentials,
|
|
437
|
+
]);
|
|
424
438
|
|
|
425
439
|
return (
|
|
426
440
|
<div style={pdfContainerStyle}>
|
|
@@ -444,7 +458,7 @@ function PdfPreview({
|
|
|
444
458
|
icon="download"
|
|
445
459
|
title={labels.download}
|
|
446
460
|
color="secondary"
|
|
447
|
-
onPress={() => void downloadFileFromUrl(file.url, file.filename)}
|
|
461
|
+
onPress={() => void downloadFileFromUrl(file.url, file.filename, { credentials })}
|
|
448
462
|
/>
|
|
449
463
|
</View>
|
|
450
464
|
</div>
|
|
@@ -30,6 +30,14 @@ export interface FilePreviewProps {
|
|
|
30
30
|
onError?: (error: unknown, meta: { fileId: string; mimeType: string }) => void;
|
|
31
31
|
/** View rotation in degrees (0/90/180/270) — applied to image previews only. */
|
|
32
32
|
rotation?: number;
|
|
33
|
+
/**
|
|
34
|
+
* Credentials mode for the byte fetches the PDF / Word / spreadsheet previews
|
|
35
|
+
* make. The host frontend serves files from the auth-gated proxy on a sibling
|
|
36
|
+
* subdomain (`api.lotics.ai`); the session cookie only rides that cross-origin
|
|
37
|
+
* fetch with `"include"`. Custom-code apps fetch presigned URLs — a credentialed
|
|
38
|
+
* request would trip the bucket's CORS — so they omit this and take the default.
|
|
39
|
+
*/
|
|
40
|
+
credentials?: RequestCredentials;
|
|
33
41
|
}
|
|
34
42
|
|
|
35
43
|
export function resolveLabels(labels: Partial<PreviewLabels> | undefined): PreviewLabels {
|
|
@@ -45,6 +53,7 @@ export interface GalleryLabels extends PreviewLabels {
|
|
|
45
53
|
close: string;
|
|
46
54
|
previous: string;
|
|
47
55
|
next: string;
|
|
56
|
+
/** Accessibility label for the per-row ⋯ menu in `FileRows`. */
|
|
48
57
|
actions: string;
|
|
49
58
|
openExternal: string;
|
|
50
59
|
remove: string;
|
package/src/file_row.tsx
CHANGED
|
@@ -3,6 +3,8 @@ import { Pressable, StyleSheet, View } from "react-native";
|
|
|
3
3
|
import { Text } from "./text";
|
|
4
4
|
import { colors } from "./colors";
|
|
5
5
|
import { FileBadge } from "./file_badge";
|
|
6
|
+
import { FOCUS_RING } from "./control_surface";
|
|
7
|
+
import { useFocusRing } from "./use_focus_ring";
|
|
6
8
|
|
|
7
9
|
export interface FileRowProps {
|
|
8
10
|
/** The file / document name — the primary line. */
|
|
@@ -38,6 +40,7 @@ export interface FileRowProps {
|
|
|
38
40
|
export function FileRow({ name, meta, mimeType, placeholder, isTemplate, onPress, trailing }: FileRowProps) {
|
|
39
41
|
const [hovered, setHovered] = useState(false);
|
|
40
42
|
const [pressed, setPressed] = useState(false);
|
|
43
|
+
const { focusVisible, focusProps } = useFocusRing();
|
|
41
44
|
// Hover via the DOM (not Pressability): RN-Web hands a parent's hover to the
|
|
42
45
|
// innermost nested pressable, which would stop the wash short of `trailing`.
|
|
43
46
|
const mouseProps = {
|
|
@@ -77,11 +80,12 @@ export function FileRow({ name, meta, mimeType, placeholder, isTemplate, onPress
|
|
|
77
80
|
>
|
|
78
81
|
<Pressable
|
|
79
82
|
onPress={onPress}
|
|
83
|
+
{...focusProps}
|
|
80
84
|
onPressIn={() => setPressed(true)}
|
|
81
85
|
onPressOut={() => setPressed(false)}
|
|
82
86
|
accessibilityRole="button"
|
|
83
87
|
accessibilityLabel={`Open ${name}`}
|
|
84
|
-
style={styles.door}
|
|
88
|
+
style={[styles.door, focusVisible && { boxShadow: FOCUS_RING }]}
|
|
85
89
|
>
|
|
86
90
|
{content}
|
|
87
91
|
</Pressable>
|
package/src/file_rows.tsx
CHANGED
|
@@ -34,6 +34,9 @@ export interface FileRowsProps {
|
|
|
34
34
|
labels?: Partial<GalleryLabels>;
|
|
35
35
|
/** Gap between rows. Default 8. */
|
|
36
36
|
gap?: number;
|
|
37
|
+
/** Credentials mode for the gallery's preview fetches — `"include"` for the
|
|
38
|
+
* host's auth-gated proxy URLs, omitted for an app's presigned URLs. */
|
|
39
|
+
credentials?: RequestCredentials;
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
/**
|
|
@@ -42,7 +45,7 @@ export interface FileRowsProps {
|
|
|
42
45
|
* record's documents, a message's files. For an ADD/upload surface use `FileGrid`;
|
|
43
46
|
* for bare square tiles use `FileThumbnailGrid`.
|
|
44
47
|
*/
|
|
45
|
-
export function FileRows({ files, meta, onRemove, onOpenExternal, onDownload, onError, labels, gap = 8 }: FileRowsProps) {
|
|
48
|
+
export function FileRows({ files, meta, onRemove, onOpenExternal, onDownload, onError, labels, gap = 8, credentials }: FileRowsProps) {
|
|
46
49
|
const [activeIndex, setActiveIndex] = useState<number | null>(null);
|
|
47
50
|
const l = resolveGalleryLabels(labels);
|
|
48
51
|
|
|
@@ -53,7 +56,7 @@ export function FileRows({ files, meta, onRemove, onOpenExternal, onDownload, on
|
|
|
53
56
|
onDownload(file);
|
|
54
57
|
return;
|
|
55
58
|
}
|
|
56
|
-
void downloadFileFromUrl(file.url, file.filename);
|
|
59
|
+
void downloadFileFromUrl(file.url, file.filename, { credentials });
|
|
57
60
|
};
|
|
58
61
|
|
|
59
62
|
const confirmRemove = (file: DisplayFile) => {
|
|
@@ -93,6 +96,7 @@ export function FileRows({ files, meta, onRemove, onOpenExternal, onDownload, on
|
|
|
93
96
|
onIndexChange={setActiveIndex}
|
|
94
97
|
onDownload={onDownload}
|
|
95
98
|
onOpenExternal={onOpenExternal}
|
|
99
|
+
credentials={credentials}
|
|
96
100
|
onRemove={onRemove ? (fileId) => {
|
|
97
101
|
const file = files.find((f) => f.id === fileId);
|
|
98
102
|
if (file) onRemove(file);
|
package/src/file_thumbnail.tsx
CHANGED
|
@@ -6,6 +6,8 @@ import { IconButton } from "./icon_button";
|
|
|
6
6
|
import { Checkbox } from "./checkbox";
|
|
7
7
|
import { ActivityIndicator } from "./activity_indicator";
|
|
8
8
|
import { colors } from "./colors";
|
|
9
|
+
import { FOCUS_RING } from "./control_surface";
|
|
10
|
+
import { useFocusRing } from "./use_focus_ring";
|
|
9
11
|
import { useCallback, useState } from "react";
|
|
10
12
|
import {
|
|
11
13
|
GestureResponderEvent,
|
|
@@ -183,9 +185,10 @@ interface DocumentBadgeProps {
|
|
|
183
185
|
*/
|
|
184
186
|
export function DocumentBadge(props: DocumentBadgeProps) {
|
|
185
187
|
const { mimeType, isTemplate, onPress, onLongPress } = props;
|
|
188
|
+
const { focusVisible, focusProps } = useFocusRing();
|
|
186
189
|
|
|
187
190
|
return (
|
|
188
|
-
<Pressable onPress={onPress} onLongPress={onLongPress}>
|
|
191
|
+
<Pressable onPress={onPress} onLongPress={onLongPress} {...focusProps} style={focusVisible ? { boxShadow: FOCUS_RING, borderRadius: 6 } : undefined}>
|
|
189
192
|
<FileBadge mimeType={mimeType} isTemplate={isTemplate} />
|
|
190
193
|
</Pressable>
|
|
191
194
|
);
|
|
@@ -217,6 +220,7 @@ export function DocumentCard(props: DocumentCardProps) {
|
|
|
217
220
|
|
|
218
221
|
// Badge takes ~40% of the tile, clamped so tiny/huge tiles stay tasteful.
|
|
219
222
|
const badgeSize = size !== undefined ? Math.max(30, Math.min(60, Math.round(size * 0.4))) : 40;
|
|
223
|
+
const { focusVisible, focusProps } = useFocusRing();
|
|
220
224
|
|
|
221
225
|
return (
|
|
222
226
|
<Pressable
|
|
@@ -225,9 +229,11 @@ export function DocumentCard(props: DocumentCardProps) {
|
|
|
225
229
|
sizeStyle,
|
|
226
230
|
(state as { hovered?: boolean }).hovered && styles.tileHovered,
|
|
227
231
|
state.pressed && styles.tilePressed,
|
|
232
|
+
focusVisible && { boxShadow: FOCUS_RING },
|
|
228
233
|
]}
|
|
229
234
|
onPress={onPress}
|
|
230
235
|
onLongPress={onLongPress}
|
|
236
|
+
{...focusProps}
|
|
231
237
|
>
|
|
232
238
|
<FileBadge mimeType={mimeType} size={badgeSize} isTemplate={isTemplate} />
|
|
233
239
|
<Text size="xs" numberOfLines={1} color="zinc-500" style={styles.documentTileName} userSelect="none">
|
|
@@ -264,14 +270,16 @@ export function MediaCard(props: MediaCardProps) {
|
|
|
264
270
|
: { width: "100%" as const, height: "100%" as const };
|
|
265
271
|
|
|
266
272
|
const isCompact = size !== undefined && size <= COMPACT_THUMBNAIL_SIZE;
|
|
273
|
+
const { focusVisible, focusProps } = useFocusRing();
|
|
267
274
|
|
|
268
275
|
if (isCompact) {
|
|
269
276
|
const iconSize = Math.max(12, Math.round(size * 0.55));
|
|
270
277
|
return (
|
|
271
278
|
<Pressable
|
|
272
|
-
style={({ pressed }) => [styles.mediaCardCompact, sizeStyle, { backgroundColor: color }, pressed && styles.pressedDim]}
|
|
279
|
+
style={({ pressed }) => [styles.mediaCardCompact, sizeStyle, { backgroundColor: color }, pressed && styles.pressedDim, focusVisible && { boxShadow: FOCUS_RING }]}
|
|
273
280
|
onPress={onPress}
|
|
274
281
|
onLongPress={onLongPress}
|
|
282
|
+
{...focusProps}
|
|
275
283
|
>
|
|
276
284
|
<Icon name={icon} size={iconSize} color={colors.white} />
|
|
277
285
|
{overlay}
|
|
@@ -283,7 +291,7 @@ export function MediaCard(props: MediaCardProps) {
|
|
|
283
291
|
const backdrop = iconSize + 20;
|
|
284
292
|
|
|
285
293
|
return (
|
|
286
|
-
<Pressable style={[styles.documentCard, sizeStyle]} onPress={onPress} onLongPress={onLongPress}>
|
|
294
|
+
<Pressable style={[styles.documentCard, sizeStyle, focusVisible && { boxShadow: FOCUS_RING }]} onPress={onPress} onLongPress={onLongPress} {...focusProps}>
|
|
287
295
|
{(state) => (
|
|
288
296
|
<>
|
|
289
297
|
<View style={[styles.mediaCardBody, { backgroundColor: color }]}>
|
|
@@ -323,6 +331,7 @@ interface ImageThumbnailProps {
|
|
|
323
331
|
function ImageThumbnail(props: ImageThumbnailProps) {
|
|
324
332
|
const { file, size, onPress, onLongPress } = props;
|
|
325
333
|
const [useFallback, setUseFallback] = useState(false);
|
|
334
|
+
const { focusVisible, focusProps } = useFocusRing();
|
|
326
335
|
|
|
327
336
|
// Use thumbnail if available, fallback to full URL
|
|
328
337
|
const url = !useFallback && file.thumbnailUrl ? file.thumbnailUrl : file.url;
|
|
@@ -333,7 +342,7 @@ function ImageThumbnail(props: ImageThumbnailProps) {
|
|
|
333
342
|
: { width: "100%" as const, height: "100%" as const };
|
|
334
343
|
|
|
335
344
|
return (
|
|
336
|
-
<Pressable onPress={onPress} onLongPress={onLongPress} style={[styles.imageThumbnail, sizeStyle]}>
|
|
345
|
+
<Pressable onPress={onPress} onLongPress={onLongPress} {...focusProps} style={[styles.imageThumbnail, sizeStyle, focusVisible && { boxShadow: FOCUS_RING }]}>
|
|
337
346
|
{(state) => (
|
|
338
347
|
<>
|
|
339
348
|
<Image
|
|
@@ -15,6 +15,8 @@ import { View, Pressable, StyleSheet, type ViewStyle } from "react-native";
|
|
|
15
15
|
import { Text } from "./text";
|
|
16
16
|
import { colors } from "./colors";
|
|
17
17
|
import { FileThumbnail, type DisplayFile, THUMBNAIL_SIZE, COMPACT_THUMBNAIL_SIZE } from "./file_thumbnail";
|
|
18
|
+
import { FOCUS_RING } from "./control_surface";
|
|
19
|
+
import { useFocusRing } from "./use_focus_ring";
|
|
18
20
|
|
|
19
21
|
export interface ThumbnailGridProps<T> {
|
|
20
22
|
items: T[];
|
|
@@ -199,6 +201,7 @@ export function FileThumbnailGrid(props: FileThumbnailGridProps) {
|
|
|
199
201
|
function OverflowTile({ size, count, onPress }: { size: number; count: number; onPress?: () => void }) {
|
|
200
202
|
const compact = size <= COMPACT_THUMBNAIL_SIZE;
|
|
201
203
|
const box = { width: size, height: size, borderRadius: compact ? 7 : 10 };
|
|
204
|
+
const { focusVisible, focusProps } = useFocusRing();
|
|
202
205
|
const label = (
|
|
203
206
|
<Text size={compact ? "xs" : "sm"} weight="medium" color="zinc-700" userSelect="none">
|
|
204
207
|
+{count}
|
|
@@ -210,6 +213,7 @@ function OverflowTile({ size, count, onPress }: { size: number; count: number; o
|
|
|
210
213
|
return (
|
|
211
214
|
<Pressable
|
|
212
215
|
onPress={onPress}
|
|
216
|
+
{...focusProps}
|
|
213
217
|
accessibilityRole="button"
|
|
214
218
|
accessibilityLabel={`+${count}`}
|
|
215
219
|
style={(state) => [
|
|
@@ -217,6 +221,7 @@ function OverflowTile({ size, count, onPress }: { size: number; count: number; o
|
|
|
217
221
|
box,
|
|
218
222
|
(state as { hovered?: boolean }).hovered && styles.overflowHovered,
|
|
219
223
|
state.pressed && styles.overflowPressed,
|
|
224
|
+
focusVisible && { boxShadow: FOCUS_RING },
|
|
220
225
|
]}
|
|
221
226
|
>
|
|
222
227
|
{label}
|