@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.
@@ -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
- <SectionHeadingTitle>Documents</SectionHeadingTitle>
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((chosen) => {
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.0.0",
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
+ });
@@ -1,9 +1,10 @@
1
- import { useEffect, useRef, useState } from "react";
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 half of the file story (display =
52
- * FileThumbnailGrid/FilePreview, picking = pickFiles, which also backs this
53
- * zone's click). A dashed well with an icon + invitation; while a drag
54
- * hovers it, the zone lights up in the accent and the label flips to
55
- * `dropLabel`. On native (no drag events) it degrades to press-to-pick.
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
- <Pressable
129
- ref={zoneRef}
130
- accessibilityRole="button"
131
- accessibilityLabel={accessibilityLabel ?? label}
132
- disabled={disabled}
133
- onPress={handlePick}
134
- {...focusProps}
135
- onHoverIn={() => setHovered(true)}
136
- onHoverOut={() => setHovered(false)}
137
- style={[
138
- styles.zone,
139
- { minHeight: height },
140
- hovered && !dragging ? styles.zoneHovered : null,
141
- dragging ? styles.zoneDragging : null,
142
- disabled ? styles.zoneDisabled : null,
143
- style,
144
- focusVisible && { boxShadow: FOCUS_RING },
145
- ]}
146
- >
147
- <View style={[styles.iconWell, dragging ? styles.iconWellDragging : null]}>
148
- <Icon name="upload" size={20} color={dragging ? colors.blue[600] : colors.zinc[500]} />
149
- </View>
150
- <Text size="sm" weight="medium" style={dragging ? { color: colors.blue[700] } : undefined}>
151
- {dragging ? dropLabel : label}
152
- </Text>
153
- {!dragging && hint ? (
154
- <Text size="xs" color="muted">
155
- {hint}
156
- </Text>
157
- ) : null}
158
- </Pressable>
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
+ });