@lotics/ui 15.0.0 → 15.1.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 +10 -1
- package/MIGRATION.md +24 -2
- package/docs/catalog.md +34 -4
- package/docs/data_entry.md +53 -2
- package/docs/templates.md +15 -2
- package/examples/tpl_item_list.tsx +146 -48
- package/examples/tpl_record.tsx +37 -12
- package/package.json +10 -1
- package/src/dom_region.ts +14 -0
- package/src/dom_region.web.ts +13 -0
- package/src/file_drop_target.tsx +16 -0
- package/src/file_drop_target.web.tsx +118 -0
- package/src/file_dropzone.tsx +51 -96
- package/src/file_intake.test.ts +124 -0
- package/src/file_intake.ts +148 -0
- package/src/locale.tsx +2 -2
- package/src/use_paste_files.ts +14 -0
- package/src/use_paste_files.web.ts +101 -0
package/examples/tpl_record.tsx
CHANGED
|
@@ -54,6 +54,7 @@ import { FileGalleryModal } from "@lotics/ui/file_gallery_modal";
|
|
|
54
54
|
import { DangerZone } from "@lotics/ui/danger_zone";
|
|
55
55
|
import { FileRow } from "@lotics/ui/file_row";
|
|
56
56
|
import { pickFiles } from "@lotics/ui/file_picker";
|
|
57
|
+
import { FileDropTarget } from "@lotics/ui/file_drop_target";
|
|
57
58
|
import { Table, TableRow, TableCell, type TableColumn } from "@lotics/ui/table";
|
|
58
59
|
import { cycleSort, sortBy, type SortState } from "@lotics/ui/sort_header";
|
|
59
60
|
import { Finding, FindingComparison } from "@lotics/ui/finding";
|
|
@@ -881,6 +882,20 @@ export function TplRecord() {
|
|
|
881
882
|
const [fieldDecisions, setFieldDecisions] = useState<Record<string, "kept" | "dropped">>({});
|
|
882
883
|
|
|
883
884
|
const openAi = () => { setPicked(files.filter((f) => sel.has(f.id))); setUploadFlow(false); setAiOpen(true); };
|
|
885
|
+
// THE standard files intake — one handler, three ways in: the Documents
|
|
886
|
+
// section's Add-files CTA, a drag anywhere onto the record, and Ctrl/Cmd+V.
|
|
887
|
+
// The last two ride the WHOLE-RECORD `<FileDropTarget paste>` that wraps the
|
|
888
|
+
// section stack (see the render) — a drop/paste ANYWHERE on the surface, not
|
|
889
|
+
// only the Documents section, lands here. Every record surface should carry
|
|
890
|
+
// all three; the user never hunts for a dropzone. All three land in the same
|
|
891
|
+
// PENDING fork dialog — saving stays a decision. The target is `disabled`
|
|
892
|
+
// while the dialog is open so a paste can't start a SECOND one.
|
|
893
|
+
const intakeFiles = (chosen: File[]) => {
|
|
894
|
+
if (chosen.length === 0) return;
|
|
895
|
+
setPicked(chosen.map((f, i) => ({ id: `added-${f.name}-${i}`, name: f.name, mimeType: f.type || "application/pdf", kind: (f.type || "application/pdf").split("/")[1]?.toUpperCase().slice(0, 4) ?? "FILE", sizeKB: Math.round(f.size / 1024), added: "just now", addedAt: 999 })));
|
|
896
|
+
setUploadFlow(true);
|
|
897
|
+
setAiOpen(true);
|
|
898
|
+
};
|
|
884
899
|
// The upload path's save: pending picked files land on the record only here.
|
|
885
900
|
const commitUpload = () => setFiles((fs) => [...fs, ...picked.filter((p2) => !fs.some((f) => f.id === p2.id))]);
|
|
886
901
|
const closeAi = () => {
|
|
@@ -1405,6 +1420,16 @@ export function TplRecord() {
|
|
|
1405
1420
|
</View>
|
|
1406
1421
|
) : (
|
|
1407
1422
|
<>
|
|
1423
|
+
{/* WHOLE-SURFACE INTAKE — the standard this template DEMONSTRATES: the
|
|
1424
|
+
ENTIRE record body is one `FileDropTarget`, so a file dropped or a
|
|
1425
|
+
Ctrl/Cmd+V pasted ANYWHERE on the record (not just the Documents
|
|
1426
|
+
section) is captured and routed to `intakeFiles` — the pending fork
|
|
1427
|
+
dialog opens (extract vs save, a decision). `flex:1, minWidth:0`
|
|
1428
|
+
preserves the reading column's flex/scroll; `disabled` while the
|
|
1429
|
+
dialog is open so a paste can't start a SECOND one; the accent ring
|
|
1430
|
+
frames the whole column on drag ("drop anywhere here"). A real app
|
|
1431
|
+
author opts in per-app — the section stack is the reference shape. */}
|
|
1432
|
+
<FileDropTarget onFiles={intakeFiles} accept="application/pdf,image/*" paste disabled={aiOpen} style={{ flex: 1, minWidth: 0 }}>
|
|
1408
1433
|
{/* the page column is a SectionStack — it owns the 56px beat + the
|
|
1409
1434
|
hairline BETWEEN top-level blocks (a conditional block that renders
|
|
1410
1435
|
null never leaves a stray divider) */}
|
|
@@ -1868,11 +1893,16 @@ export function TplRecord() {
|
|
|
1868
1893
|
</View>
|
|
1869
1894
|
|
|
1870
1895
|
{/* DOCUMENTS — the document desk: the register Table (search · Create
|
|
1871
|
-
documents · Add files) whose selection feeds the Use-AI fork below.
|
|
1896
|
+
documents · Add files) whose selection feeds the Use-AI fork below.
|
|
1897
|
+
No FileDropTarget of its OWN — the WHOLE-RECORD target above already
|
|
1898
|
+
captures a drop/paste here (and everywhere else on the surface); this
|
|
1899
|
+
section keeps only its explicit Add-files CTA and the affordance line. */}
|
|
1872
1900
|
<View onLayout={nav.register("documents")}>
|
|
1873
1901
|
<Section>
|
|
1874
1902
|
<SectionHeading>
|
|
1875
|
-
|
|
1903
|
+
{/* The standard files-section affordance line — drag / paste / click
|
|
1904
|
+
stay discoverable even though the drop target is the whole record. */}
|
|
1905
|
+
<SectionHeadingTitle description="Drag, paste, or click to add files">Documents</SectionHeadingTitle>
|
|
1876
1906
|
</SectionHeading>
|
|
1877
1907
|
{/* toolbar — search LEFT, the CTAs RIGHT, one row (the register band). */}
|
|
1878
1908
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
@@ -1887,20 +1917,14 @@ export function TplRecord() {
|
|
|
1887
1917
|
<View style={{ flex: 1 }} />
|
|
1888
1918
|
{/* no Create here — GENERATION lives in the Document set section
|
|
1889
1919
|
below (this desk = intake) */}
|
|
1920
|
+
{/* PENDING until the user chooses: saving is a decision the dialog
|
|
1921
|
+
asks for (save only / run a task), never a side effect of
|
|
1922
|
+
picking — closing the dialog discards. */}
|
|
1890
1923
|
<Button
|
|
1891
1924
|
title="Add files"
|
|
1892
1925
|
color="secondary"
|
|
1893
1926
|
onPress={() => {
|
|
1894
|
-
void pickFiles({ accept: "application/pdf,image/*", multiple: true }).then(
|
|
1895
|
-
if (chosen.length === 0) return;
|
|
1896
|
-
const added = chosen.map((f, i) => ({ id: `added-${f.name}-${i}`, name: f.name, mimeType: f.type || "application/pdf", kind: (f.type || "application/pdf").split("/")[1]?.toUpperCase().slice(0, 4) ?? "FILE", sizeKB: Math.round(f.size / 1024), added: "just now", addedAt: 999 }));
|
|
1897
|
-
// PENDING until the user chooses: saving is a decision the
|
|
1898
|
-
// dialog asks for (save only / run a task), never a side
|
|
1899
|
-
// effect of picking — closing the dialog discards.
|
|
1900
|
-
setPicked(added);
|
|
1901
|
-
setUploadFlow(true);
|
|
1902
|
-
setAiOpen(true);
|
|
1903
|
-
});
|
|
1927
|
+
void pickFiles({ accept: "application/pdf,image/*", multiple: true }).then(intakeFiles);
|
|
1904
1928
|
}}
|
|
1905
1929
|
/>
|
|
1906
1930
|
</View>
|
|
@@ -2457,6 +2481,7 @@ export function TplRecord() {
|
|
|
2457
2481
|
</Section>
|
|
2458
2482
|
</View>
|
|
2459
2483
|
</SectionStack>
|
|
2484
|
+
</FileDropTarget>
|
|
2460
2485
|
</>
|
|
2461
2486
|
)}
|
|
2462
2487
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "15.
|
|
3
|
+
"version": "15.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./vite": {
|
|
@@ -21,6 +21,15 @@
|
|
|
21
21
|
"./comments_thread": "./src/comments_thread.tsx",
|
|
22
22
|
"./file_badge": "./src/file_badge.tsx",
|
|
23
23
|
"./file_dropzone": "./src/file_dropzone.tsx",
|
|
24
|
+
"./file_intake": "./src/file_intake.ts",
|
|
25
|
+
"./file_drop_target": {
|
|
26
|
+
"react-native": "./src/file_drop_target.tsx",
|
|
27
|
+
"default": "./src/file_drop_target.web.tsx"
|
|
28
|
+
},
|
|
29
|
+
"./use_paste_files": {
|
|
30
|
+
"react-native": "./src/use_paste_files.ts",
|
|
31
|
+
"default": "./src/use_paste_files.web.ts"
|
|
32
|
+
},
|
|
24
33
|
"./file_thumbnail": "./src/file_thumbnail.tsx",
|
|
25
34
|
"./file_row": "./src/file_row.tsx",
|
|
26
35
|
"./file_rows": "./src/file_rows.tsx",
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { RegionRef } from "./file_intake";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Native stand-in for the region → DOM-node resolver.
|
|
5
|
+
*
|
|
6
|
+
* There is no DOM on React Native, so there is no node to resolve — the drag
|
|
7
|
+
* and focus-routing paths this backs are web-only. The file exists purely as
|
|
8
|
+
* the typecheck-resolution base for the `.web` variant (the `.web` file is the
|
|
9
|
+
* only importer at runtime; Metro never reaches this on native, but the shared
|
|
10
|
+
* signature lives here).
|
|
11
|
+
*/
|
|
12
|
+
export function resolveRegionNode(_region: RegionRef | undefined): HTMLElement | null {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { RegionRef } from "./file_intake";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A region ref → its DOM node. RN-web exposes a `View`'s underlying
|
|
5
|
+
* `HTMLElement` straight through the ref's `.current`, so this is the SINGLE
|
|
6
|
+
* place that unwraps it — shared by `FileDropTarget`'s drag listeners and
|
|
7
|
+
* `usePasteFiles`'s focus routing, so the two can never resolve the region two
|
|
8
|
+
* different ways. Returns null when the ref is empty or not yet an element.
|
|
9
|
+
*/
|
|
10
|
+
export function resolveRegionNode(region: RegionRef | undefined): HTMLElement | null {
|
|
11
|
+
const node = region?.current;
|
|
12
|
+
return node instanceof HTMLElement ? node : null;
|
|
13
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { View } from "react-native";
|
|
2
|
+
import type { FileDropTargetProps } from "./file_intake";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Native stand-in for the drop region.
|
|
6
|
+
*
|
|
7
|
+
* React Native has no HTML5 drag-and-drop, so the region is simply its children
|
|
8
|
+
* and the surface's own Add-file CTA stays the intake path. The wrapper `View`
|
|
9
|
+
* is kept (and `style` still applies) so the tree and layout match web exactly.
|
|
10
|
+
*
|
|
11
|
+
* Web targets resolve `file_drop_target.web.tsx` instead (Metro's `.web`
|
|
12
|
+
* extension resolution; the package's `react-native` export condition).
|
|
13
|
+
*/
|
|
14
|
+
export function FileDropTarget({ children, style }: FileDropTargetProps) {
|
|
15
|
+
return <View style={style}>{typeof children === "function" ? children(false) : children}</View>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { StyleSheet, View } from "react-native";
|
|
3
|
+
import { colors } from "./colors";
|
|
4
|
+
import { filesFromTransfer, type FileDropTargetProps } from "./file_intake";
|
|
5
|
+
import { resolveRegionNode } from "./dom_region";
|
|
6
|
+
import { usePasteFiles } from "./use_paste_files";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Makes ANY region accept a file drag-drop — the region-wrapper half of file
|
|
10
|
+
* intake (`FileDropzone` stays the dedicated empty-state well; `usePasteFiles`
|
|
11
|
+
* is the clipboard half). Wrap a record's Files section, a card, a whole panel:
|
|
12
|
+
* a file dragged anywhere over it lands, so nobody has to hunt for a dropzone.
|
|
13
|
+
* Pass `paste` to ALSO take Ctrl/Cmd+V scoped to this region's focus.
|
|
14
|
+
*
|
|
15
|
+
* While a drag hovers, the region wears the kit's accent affordance — a blue
|
|
16
|
+
* ring + wash, the same tokens `FileDropzone` lights up with, painted as a
|
|
17
|
+
* box-shadow so nothing reflows. Pass a FUNCTION child to draw the drag state
|
|
18
|
+
* yourself instead (the target then paints nothing).
|
|
19
|
+
*/
|
|
20
|
+
export function FileDropTarget(props: FileDropTargetProps) {
|
|
21
|
+
const { onFiles, accept, multiple = true, paste = false, disabled = false, children, style } = props;
|
|
22
|
+
|
|
23
|
+
const regionRef = useRef<View>(null);
|
|
24
|
+
// dragenter/dragleave fire per descendant element, so a plain boolean flickers
|
|
25
|
+
// as the pointer crosses children — count depth and clear only at zero.
|
|
26
|
+
const dragDepth = useRef(0);
|
|
27
|
+
const [dragging, setDragging] = useState(false);
|
|
28
|
+
|
|
29
|
+
// The clipboard half, scoped to THIS region — the paste routes to whichever
|
|
30
|
+
// enabled target holds focus (see usePasteFiles). Region-scoped so two peer
|
|
31
|
+
// sections each win when focus is in them; a no-op unless `paste` is set.
|
|
32
|
+
usePasteFiles({ onFiles, accept, multiple, enabled: paste && !disabled, region: regionRef });
|
|
33
|
+
|
|
34
|
+
// The drop settings are read through a ref so the listeners subscribe ONCE.
|
|
35
|
+
// Keying the effect on `onFiles` instead would re-bind on every parent render
|
|
36
|
+
// — routine with an inline callback — and tear an in-progress drag's state
|
|
37
|
+
// down mid-hover.
|
|
38
|
+
const latest = useRef({ onFiles, accept, multiple });
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
latest.current = { onFiles, accept, multiple };
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// RN-web exposes the View's underlying HTMLElement through the ref — attach the
|
|
44
|
+
// DOM drag events there (resolved the same way the paste focus check is).
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (disabled) {
|
|
47
|
+
// Disabled mid-drag: drop the affordance with the listeners.
|
|
48
|
+
dragDepth.current = 0;
|
|
49
|
+
setDragging(false);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const node = resolveRegionNode(regionRef);
|
|
53
|
+
if (!node) return;
|
|
54
|
+
|
|
55
|
+
// Scope every drag/drop event to the INNERMOST target — stopPropagation so a
|
|
56
|
+
// FileDropTarget nested in another (e.g. a FileDropzone empty-state inside a
|
|
57
|
+
// Files-section drop target — a FileDropzone IS a FileDropTarget) fires only
|
|
58
|
+
// once: without it one drop bubbles to both handlers and adds the file twice,
|
|
59
|
+
// and both regions light their drag ring.
|
|
60
|
+
const onDragEnter = (e: DragEvent) => {
|
|
61
|
+
e.preventDefault();
|
|
62
|
+
e.stopPropagation();
|
|
63
|
+
dragDepth.current += 1;
|
|
64
|
+
setDragging(true);
|
|
65
|
+
};
|
|
66
|
+
// preventDefault is what makes the element a legal drop target.
|
|
67
|
+
const onDragOver = (e: DragEvent) => {
|
|
68
|
+
e.preventDefault();
|
|
69
|
+
e.stopPropagation();
|
|
70
|
+
};
|
|
71
|
+
const onDragLeave = (e: DragEvent) => {
|
|
72
|
+
e.stopPropagation();
|
|
73
|
+
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
|
74
|
+
if (dragDepth.current === 0) setDragging(false);
|
|
75
|
+
};
|
|
76
|
+
const onDrop = (e: DragEvent) => {
|
|
77
|
+
e.preventDefault();
|
|
78
|
+
e.stopPropagation();
|
|
79
|
+
dragDepth.current = 0;
|
|
80
|
+
setDragging(false);
|
|
81
|
+
const settings = latest.current;
|
|
82
|
+
const dropped = filesFromTransfer(e.dataTransfer, settings);
|
|
83
|
+
if (dropped.length === 0) return;
|
|
84
|
+
settings.onFiles(dropped);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
node.addEventListener("dragenter", onDragEnter);
|
|
88
|
+
node.addEventListener("dragover", onDragOver);
|
|
89
|
+
node.addEventListener("dragleave", onDragLeave);
|
|
90
|
+
node.addEventListener("drop", onDrop);
|
|
91
|
+
return () => {
|
|
92
|
+
node.removeEventListener("dragenter", onDragEnter);
|
|
93
|
+
node.removeEventListener("dragover", onDragOver);
|
|
94
|
+
node.removeEventListener("dragleave", onDragLeave);
|
|
95
|
+
node.removeEventListener("drop", onDrop);
|
|
96
|
+
};
|
|
97
|
+
}, [disabled]);
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<View
|
|
101
|
+
ref={regionRef}
|
|
102
|
+
style={[style, dragging && typeof children !== "function" ? styles.dragging : null]}
|
|
103
|
+
>
|
|
104
|
+
{typeof children === "function" ? children(dragging) : children}
|
|
105
|
+
</View>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const styles = StyleSheet.create({
|
|
110
|
+
// The drop invitation, restrained: the accent ring reads as the edge of the
|
|
111
|
+
// landing area and the wash tints the region behind its content. Both are
|
|
112
|
+
// box-shadow/background — zero layout impact, so nothing shifts mid-drag.
|
|
113
|
+
dragging: {
|
|
114
|
+
borderRadius: 12,
|
|
115
|
+
backgroundColor: colors.blue[50],
|
|
116
|
+
boxShadow: `0 0 0 1.5px ${colors.blue[500]}, 0 0 0 6px ${colors.blue[50]}`,
|
|
117
|
+
},
|
|
118
|
+
});
|
package/src/file_dropzone.tsx
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useState } from "react";
|
|
2
2
|
import { Pressable, StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
|
|
3
3
|
import { colors } from "./colors";
|
|
4
4
|
import { Icon } from "./icon";
|
|
5
5
|
import { Text } from "./text";
|
|
6
6
|
import { pickFiles } from "./file_picker";
|
|
7
|
+
import { FileDropTarget } from "./file_drop_target";
|
|
7
8
|
import { FOCUS_RING } from "./control_surface";
|
|
8
9
|
import { useFocusRing } from "./use_focus_ring";
|
|
9
10
|
import { useLoticsLocale } from "./locale";
|
|
@@ -34,25 +35,18 @@ export interface FileDropzoneProps {
|
|
|
34
35
|
style?: StyleProp<ViewStyle>;
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
function matchesAccept(file: File, accept: string | undefined): boolean {
|
|
38
|
-
if (!accept) return true;
|
|
39
|
-
const patterns = accept.split(",").map((p) => p.trim().toLowerCase()).filter(Boolean);
|
|
40
|
-
if (patterns.length === 0) return true;
|
|
41
|
-
const mime = file.type.toLowerCase();
|
|
42
|
-
const name = file.name.toLowerCase();
|
|
43
|
-
return patterns.some((p) => {
|
|
44
|
-
if (p.startsWith(".")) return name.endsWith(p);
|
|
45
|
-
if (p.endsWith("/*")) return mime.startsWith(p.slice(0, -1));
|
|
46
|
-
return mime === p;
|
|
47
|
-
});
|
|
48
|
-
}
|
|
49
|
-
|
|
50
38
|
/**
|
|
51
|
-
* Drag-and-drop upload zone — the capture
|
|
52
|
-
* FileThumbnailGrid/FilePreview, picking = pickFiles, which also
|
|
53
|
-
* zone's click). A dashed well with an icon + invitation; while a
|
|
54
|
-
* hovers it, the zone lights up in the accent and the label flips to
|
|
55
|
-
* `dropLabel`.
|
|
39
|
+
* Drag-and-drop upload zone — the dedicated capture well of the file story
|
|
40
|
+
* (display = FileThumbnailGrid/FilePreview, picking = pickFiles, which also
|
|
41
|
+
* backs this zone's click). A dashed well with an icon + invitation; while a
|
|
42
|
+
* drag hovers it, the zone lights up in the accent and the label flips to
|
|
43
|
+
* `dropLabel`. It is ALSO a paste sink while mounted — Ctrl/Cmd+V drops a
|
|
44
|
+
* copied file or a screenshot straight in, scoped to the zone's own region so
|
|
45
|
+
* it wins while it holds focus (or as the top surface in a dialog). On native
|
|
46
|
+
* (no drag events, no clipboard event) it degrades to press-to-pick.
|
|
47
|
+
*
|
|
48
|
+
* For a region that is not a well — a record's Files section, a card, a panel —
|
|
49
|
+
* use `FileDropTarget`, which this composes.
|
|
56
50
|
*/
|
|
57
51
|
export function FileDropzone(props: FileDropzoneProps) {
|
|
58
52
|
const locale = useLoticsLocale();
|
|
@@ -69,93 +63,54 @@ export function FileDropzone(props: FileDropzoneProps) {
|
|
|
69
63
|
style,
|
|
70
64
|
} = props;
|
|
71
65
|
|
|
72
|
-
const zoneRef = useRef<View>(null);
|
|
73
|
-
const dragDepth = useRef(0);
|
|
74
|
-
const [dragging, setDragging] = useState(false);
|
|
75
66
|
const [hovered, setHovered] = useState(false);
|
|
76
67
|
const { focusVisible, focusProps } = useFocusRing();
|
|
77
68
|
|
|
78
|
-
// RN-web exposes the View's underlying HTMLElement through the ref — attach
|
|
79
|
-
// the DOM drag events there. Native platforms skip this entirely (press-to-
|
|
80
|
-
// pick still works).
|
|
81
|
-
useEffect(() => {
|
|
82
|
-
if (disabled) return;
|
|
83
|
-
if (typeof HTMLElement === "undefined") return;
|
|
84
|
-
const node = zoneRef.current;
|
|
85
|
-
if (!(node instanceof HTMLElement)) return;
|
|
86
|
-
|
|
87
|
-
const onDragEnter = (e: DragEvent) => {
|
|
88
|
-
e.preventDefault();
|
|
89
|
-
dragDepth.current += 1;
|
|
90
|
-
setDragging(true);
|
|
91
|
-
};
|
|
92
|
-
const onDragOver = (e: DragEvent) => {
|
|
93
|
-
// preventDefault is what makes the element a legal drop target.
|
|
94
|
-
e.preventDefault();
|
|
95
|
-
};
|
|
96
|
-
const onDragLeave = () => {
|
|
97
|
-
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
|
98
|
-
if (dragDepth.current === 0) setDragging(false);
|
|
99
|
-
};
|
|
100
|
-
const onDrop = (e: DragEvent) => {
|
|
101
|
-
e.preventDefault();
|
|
102
|
-
dragDepth.current = 0;
|
|
103
|
-
setDragging(false);
|
|
104
|
-
const dropped = Array.from(e.dataTransfer?.files ?? []).filter((f) => matchesAccept(f, accept));
|
|
105
|
-
if (dropped.length === 0) return;
|
|
106
|
-
onFiles(multiple ? dropped : dropped.slice(0, 1));
|
|
107
|
-
};
|
|
108
|
-
|
|
109
|
-
node.addEventListener("dragenter", onDragEnter);
|
|
110
|
-
node.addEventListener("dragover", onDragOver);
|
|
111
|
-
node.addEventListener("dragleave", onDragLeave);
|
|
112
|
-
node.addEventListener("drop", onDrop);
|
|
113
|
-
return () => {
|
|
114
|
-
node.removeEventListener("dragenter", onDragEnter);
|
|
115
|
-
node.removeEventListener("dragover", onDragOver);
|
|
116
|
-
node.removeEventListener("dragleave", onDragLeave);
|
|
117
|
-
node.removeEventListener("drop", onDrop);
|
|
118
|
-
};
|
|
119
|
-
}, [accept, disabled, multiple, onFiles]);
|
|
120
|
-
|
|
121
69
|
const handlePick = async () => {
|
|
122
70
|
if (disabled) return;
|
|
123
71
|
const picked = await pickFiles({ accept, multiple });
|
|
124
72
|
if (picked.length > 0) onFiles(picked);
|
|
125
73
|
};
|
|
126
74
|
|
|
75
|
+
// The well draws its OWN drag state (the dashed border turns accent), so the
|
|
76
|
+
// target paints nothing — hence the function child. `paste` scopes the
|
|
77
|
+
// clipboard sink to THIS well's region, so a well inside a dialog takes the
|
|
78
|
+
// paste while it holds focus and peer wells don't fight over it.
|
|
127
79
|
return (
|
|
128
|
-
<
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
80
|
+
<FileDropTarget onFiles={onFiles} accept={accept} multiple={multiple} disabled={disabled} paste>
|
|
81
|
+
{(dragging) => (
|
|
82
|
+
<Pressable
|
|
83
|
+
accessibilityRole="button"
|
|
84
|
+
accessibilityLabel={accessibilityLabel ?? label}
|
|
85
|
+
disabled={disabled}
|
|
86
|
+
onPress={handlePick}
|
|
87
|
+
{...focusProps}
|
|
88
|
+
onHoverIn={() => setHovered(true)}
|
|
89
|
+
onHoverOut={() => setHovered(false)}
|
|
90
|
+
style={[
|
|
91
|
+
styles.zone,
|
|
92
|
+
{ minHeight: height },
|
|
93
|
+
hovered && !dragging ? styles.zoneHovered : null,
|
|
94
|
+
dragging ? styles.zoneDragging : null,
|
|
95
|
+
disabled ? styles.zoneDisabled : null,
|
|
96
|
+
style,
|
|
97
|
+
focusVisible && { boxShadow: FOCUS_RING },
|
|
98
|
+
]}
|
|
99
|
+
>
|
|
100
|
+
<View style={[styles.iconWell, dragging ? styles.iconWellDragging : null]}>
|
|
101
|
+
<Icon name="upload" size={20} color={dragging ? colors.blue[600] : colors.zinc[500]} />
|
|
102
|
+
</View>
|
|
103
|
+
<Text size="sm" weight="medium" style={dragging ? { color: colors.blue[700] } : undefined}>
|
|
104
|
+
{dragging ? dropLabel : label}
|
|
105
|
+
</Text>
|
|
106
|
+
{!dragging && hint ? (
|
|
107
|
+
<Text size="xs" color="muted">
|
|
108
|
+
{hint}
|
|
109
|
+
</Text>
|
|
110
|
+
) : null}
|
|
111
|
+
</Pressable>
|
|
112
|
+
)}
|
|
113
|
+
</FileDropTarget>
|
|
159
114
|
);
|
|
160
115
|
}
|
|
161
116
|
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { matchesAccept, filesFromTransfer, selectPasteSink, type FileTransferLike, type PasteSinkEntry } from "./file_intake";
|
|
3
|
+
|
|
4
|
+
const file = (name: string, type: string) => new File([new Uint8Array([1])], name, { type });
|
|
5
|
+
|
|
6
|
+
/** A clipboard/drag payload that surfaces its files only through `items` —
|
|
7
|
+
* what a pasted screenshot looks like on the engines that do that. */
|
|
8
|
+
const itemsOnly = (files: File[]): FileTransferLike => ({
|
|
9
|
+
files: [],
|
|
10
|
+
items: files.map((f) => ({ kind: "file" as const, getAsFile: () => f })),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe("matchesAccept", () => {
|
|
14
|
+
it("accepts everything when the list is absent or empty", () => {
|
|
15
|
+
expect(matchesAccept(file("a.pdf", "application/pdf"), undefined)).toBe(true);
|
|
16
|
+
expect(matchesAccept(file("a.pdf", "application/pdf"), "")).toBe(true);
|
|
17
|
+
expect(matchesAccept(file("a.pdf", "application/pdf"), " , ")).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("matches an exact MIME, case-insensitively", () => {
|
|
21
|
+
expect(matchesAccept(file("a.pdf", "APPLICATION/PDF"), "application/pdf")).toBe(true);
|
|
22
|
+
expect(matchesAccept(file("a.png", "image/png"), "application/pdf")).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("matches a wildcard type", () => {
|
|
26
|
+
expect(matchesAccept(file("a.png", "image/png"), "image/*")).toBe(true);
|
|
27
|
+
expect(matchesAccept(file("a.pdf", "application/pdf"), "image/*")).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("matches an extension pattern by filename when the MIME is unknown", () => {
|
|
31
|
+
expect(matchesAccept(file("Report.CSV", ""), ".csv")).toBe(true);
|
|
32
|
+
expect(matchesAccept(file("report.csvx", ""), ".csv")).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("accepts a file matching ANY pattern in the list", () => {
|
|
36
|
+
expect(matchesAccept(file("a.png", "image/png"), "application/pdf, image/*")).toBe(true);
|
|
37
|
+
expect(matchesAccept(file("a.zip", "application/zip"), "application/pdf, image/*")).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("filesFromTransfer", () => {
|
|
42
|
+
it("returns [] for a missing transfer", () => {
|
|
43
|
+
expect(filesFromTransfer(null)).toEqual([]);
|
|
44
|
+
expect(filesFromTransfer(undefined)).toEqual([]);
|
|
45
|
+
expect(filesFromTransfer({})).toEqual([]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("reads the direct file list", () => {
|
|
49
|
+
const a = file("a.pdf", "application/pdf");
|
|
50
|
+
expect(filesFromTransfer({ files: [a] })).toEqual([a]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("falls back to clipboard items when the file list is empty", () => {
|
|
54
|
+
const shot = file("image.png", "image/png");
|
|
55
|
+
expect(filesFromTransfer(itemsOnly([shot]))).toEqual([shot]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("never doubles a file present in BOTH files and items", () => {
|
|
59
|
+
const a = file("a.png", "image/png");
|
|
60
|
+
expect(filesFromTransfer({ files: [a], items: [{ kind: "file", getAsFile: () => a }] })).toEqual([a]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("skips non-file clipboard items and null getAsFile results", () => {
|
|
64
|
+
const a = file("a.png", "image/png");
|
|
65
|
+
const transfer: FileTransferLike = {
|
|
66
|
+
items: [
|
|
67
|
+
{ kind: "string", getAsFile: () => null },
|
|
68
|
+
{ kind: "file", getAsFile: () => null },
|
|
69
|
+
{ kind: "file", getAsFile: () => a },
|
|
70
|
+
],
|
|
71
|
+
};
|
|
72
|
+
expect(filesFromTransfer(transfer)).toEqual([a]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("filters by accept", () => {
|
|
76
|
+
const pdf = file("a.pdf", "application/pdf");
|
|
77
|
+
const zip = file("a.zip", "application/zip");
|
|
78
|
+
expect(filesFromTransfer({ files: [pdf, zip] }, { accept: "application/pdf" })).toEqual([pdf]);
|
|
79
|
+
expect(filesFromTransfer(itemsOnly([pdf, zip]), { accept: "application/pdf" })).toEqual([pdf]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("keeps every file by default and caps at one when multiple is false", () => {
|
|
83
|
+
const a = file("a.pdf", "application/pdf");
|
|
84
|
+
const b = file("b.pdf", "application/pdf");
|
|
85
|
+
expect(filesFromTransfer({ files: [a, b] })).toEqual([a, b]);
|
|
86
|
+
expect(filesFromTransfer({ files: [a, b] }, { multiple: false })).toEqual([a]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("caps AFTER filtering, so a rejected first file does not eat the slot", () => {
|
|
90
|
+
const zip = file("a.zip", "application/zip");
|
|
91
|
+
const pdf = file("b.pdf", "application/pdf");
|
|
92
|
+
expect(filesFromTransfer({ files: [zip, pdf] }, { accept: "application/pdf", multiple: false })).toEqual([pdf]);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("selectPasteSink", () => {
|
|
97
|
+
const entry = (sink: string, focused: boolean): PasteSinkEntry<string> => ({ sink, focused });
|
|
98
|
+
|
|
99
|
+
it("returns undefined for an empty stack", () => {
|
|
100
|
+
expect(selectPasteSink<string>([])).toBeUndefined();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("falls back to the TOP of the stack when nothing is focused", () => {
|
|
104
|
+
expect(selectPasteSink([entry("a", false), entry("b", false), entry("c", false)])).toBe("c");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("routes to a focused region over the (later-mounted) top of the stack", () => {
|
|
108
|
+
// b holds focus; c is on top but unfocused — b wins.
|
|
109
|
+
expect(selectPasteSink([entry("a", false), entry("b", true), entry("c", false)])).toBe("b");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("picks the TOP-MOST focused region when several are focused", () => {
|
|
113
|
+
// Nested regions can both contain activeElement; the innermost/last wins.
|
|
114
|
+
expect(selectPasteSink([entry("a", true), entry("b", true), entry("c", false)])).toBe("b");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("a single region-less sink still gets the paste (byte-for-byte old behavior)", () => {
|
|
118
|
+
expect(selectPasteSink([entry("only", false)])).toBe("only");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("a focused region beats an unfocused one mounted after it", () => {
|
|
122
|
+
expect(selectPasteSink([entry("focused", true), entry("later", false)])).toBe("focused");
|
|
123
|
+
});
|
|
124
|
+
});
|