@lotics/ui 14.3.1 → 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.
@@ -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
+ });
@@ -0,0 +1,148 @@
1
+ // File INTAKE — the shared core behind every "a file arrived" path in the kit:
2
+ // what counts as an acceptable file, and how to read files off a drag-drop or a
3
+ // clipboard paste. Both payloads are a `DataTransfer`, so ONE extractor serves
4
+ // the `drop` and the `paste` event; `FileDropzone`, `FileDropTarget` and
5
+ // `usePasteFiles` all route through it and can never drift apart.
6
+ //
7
+ // Also the shared CONTRACT module for the two platform-split primitives
8
+ // (`use_paste_files` / `file_drop_target`): their option and prop shapes live
9
+ // here so the `.web` implementation and its native sibling declare them once.
10
+ //
11
+ // No react-native import — the components render this module's result, so the
12
+ // logic stays unit-testable (Vitest cannot parse `react-native`).
13
+
14
+ import type { ReactNode } from "react";
15
+ import type { StyleProp, ViewStyle } from "react-native";
16
+
17
+ /** The filter every intake path applies to what arrived. */
18
+ export interface FileIntakeFilter {
19
+ /** Native `accept` filter (e.g. `"application/pdf,image/*"`) — matched against
20
+ * the file's MIME type, or its extension for a `.ext` pattern. */
21
+ accept?: string;
22
+ /** Keep more than one file. Default true. */
23
+ multiple?: boolean;
24
+ }
25
+
26
+ /**
27
+ * The structural shape a `DataTransfer` satisfies — `DragEvent.dataTransfer`
28
+ * (a drop) and `ClipboardEvent.clipboardData` (a paste) are the same object.
29
+ * Declared structurally so the extractor is testable without a DOM.
30
+ */
31
+ export interface FileTransferLike {
32
+ readonly files?: ArrayLike<File> | null;
33
+ readonly items?: ArrayLike<{ readonly kind: string; getAsFile: () => File | null }> | null;
34
+ }
35
+
36
+ /** The minimal ref shape `usePasteFiles`/`FileDropTarget` need to locate a
37
+ * region — an RN `View` ref satisfies it (`useRef<View>(null)`); on web its
38
+ * `.current` IS the region's DOM node. Declared structurally (readonly, so an
39
+ * `RefObject<View | null>` assigns cleanly) to keep this module RN-free. */
40
+ export interface RegionRef {
41
+ readonly current: unknown;
42
+ }
43
+
44
+ /** Options for `usePasteFiles` (`@lotics/ui/use_paste_files`). */
45
+ export interface UsePasteFilesOptions extends FileIntakeFilter {
46
+ /** Receives the pasted files. Bytes/upload belong to the host. */
47
+ onFiles: (files: File[]) => void;
48
+ /** Subscribe only while true (default true) — scope the sink to "while this
49
+ * surface is open" so a background screen never swallows a paste. */
50
+ enabled?: boolean;
51
+ /** The region this sink belongs to. When the paste happens while focus is
52
+ * INSIDE this region, this sink wins over a later-mounted one (see
53
+ * `selectPasteSink`). Omit for a region-less surface (a modal dialog) — the
54
+ * sink then participates by stack order alone. */
55
+ region?: RegionRef;
56
+ }
57
+
58
+ /** Props for `FileDropTarget` (`@lotics/ui/file_drop_target`). */
59
+ export interface FileDropTargetProps extends FileIntakeFilter {
60
+ /** Receives the dropped files. */
61
+ onFiles: (files: File[]) => void;
62
+ /** Also accept a clipboard PASTE, scoped to this region's focus: while focus
63
+ * is inside the wrapped region, Ctrl/Cmd+V lands here. Wires `usePasteFiles`
64
+ * with this region — so two peer file sections each win when focus is in
65
+ * them, instead of the last-mounted one silently grabbing every paste. */
66
+ paste?: boolean;
67
+ /** Stop accepting drops/pastes (and drop the affordance). */
68
+ disabled?: boolean;
69
+ /**
70
+ * The region. A plain node lets the target paint the drag affordance itself;
71
+ * a FUNCTION receives the drag state and OWNS the visual (the target paints
72
+ * nothing) — how `FileDropzone` keeps its own well styling.
73
+ */
74
+ children: ReactNode | ((dragging: boolean) => ReactNode);
75
+ style?: StyleProp<ViewStyle>;
76
+ }
77
+
78
+ /**
79
+ * Does a file pass a native `accept` list? Each comma-separated pattern is an
80
+ * extension (`.pdf`), a wildcard type (`image/*`) or an exact MIME
81
+ * (`application/pdf`); an empty/absent list accepts everything.
82
+ */
83
+ export function matchesAccept(file: File, accept: string | undefined): boolean {
84
+ if (!accept) return true;
85
+ const patterns = accept.split(",").map((p) => p.trim().toLowerCase()).filter(Boolean);
86
+ if (patterns.length === 0) return true;
87
+ const mime = file.type.toLowerCase();
88
+ const name = file.name.toLowerCase();
89
+ return patterns.some((p) => {
90
+ if (p.startsWith(".")) return name.endsWith(p);
91
+ if (p.endsWith("/*")) return mime.startsWith(p.slice(0, -1));
92
+ return mime === p;
93
+ });
94
+ }
95
+
96
+ /**
97
+ * The files a drop or a paste carries, filtered by `accept` and capped by
98
+ * `multiple`. Returns a new array; never throws on a missing/empty transfer.
99
+ */
100
+ export function filesFromTransfer(
101
+ transfer: FileTransferLike | null | undefined,
102
+ filter: FileIntakeFilter = {},
103
+ ): File[] {
104
+ if (!transfer) return [];
105
+ // `files` carries a drop and most pastes. A pasted SCREENSHOT reaches some
106
+ // engines only through `items` (a `kind: "file"` entry), so fall back to it —
107
+ // never union the two, or an engine that populates both doubles every file.
108
+ const direct = transfer.files ? Array.from(transfer.files) : [];
109
+ const candidates =
110
+ direct.length > 0
111
+ ? direct
112
+ : Array.from(transfer.items ?? [])
113
+ .filter((item) => item.kind === "file")
114
+ .map((item) => item.getAsFile())
115
+ .filter((file): file is File => file !== null);
116
+
117
+ const accepted = candidates.filter((file) => matchesAccept(file, filter.accept));
118
+ return filter.multiple === false ? accepted.slice(0, 1) : accepted;
119
+ }
120
+
121
+ /** One registered paste sink for the routing decision: the payload to hand the
122
+ * paste to, and whether its region currently contains focus. */
123
+ export interface PasteSinkEntry<T> {
124
+ sink: T;
125
+ /** True when `document.activeElement` is inside this sink's region. A
126
+ * region-less sink (a modal dialog) is always false — it routes by stack
127
+ * order alone. */
128
+ focused: boolean;
129
+ }
130
+
131
+ /**
132
+ * Which sink gets the paste, given the active sinks in MOUNT order (oldest
133
+ * first, top of the stack last). The rule: the TOP-MOST focused region wins —
134
+ * so when two file targets are enabled on the same layer, the one the user is
135
+ * actually working in takes the file. With NO region focused it falls back to
136
+ * the top of the stack (the last-mounted surface), which is the right default
137
+ * for modal stacking (a dialog over a screen). Returns `undefined` for an empty
138
+ * stack.
139
+ *
140
+ * Pure so the DOM-free routing rule is unit-tested here; the `focused` flags are
141
+ * computed against live focus in the `.web` sink at dispatch time.
142
+ */
143
+ export function selectPasteSink<T>(entries: readonly PasteSinkEntry<T>[]): T | undefined {
144
+ for (let i = entries.length - 1; i >= 0; i--) {
145
+ if (entries[i].focused) return entries[i].sink;
146
+ }
147
+ return entries.length > 0 ? entries[entries.length - 1].sink : undefined;
148
+ }
package/src/locale.tsx CHANGED
@@ -129,10 +129,19 @@ export interface LoticsLocale {
129
129
  scrollToBottom: { tooltip: string };
130
130
  /** `TextInputField`: the clear-button tooltip. */
131
131
  textInputField: { clear: string };
132
- /** `AgentRun`: the reasoning disclosure's label (settled / streaming) and the
133
- * auto-built tool peek's Input / Error / Output panel titles. (Tool-step labels
134
- * and the "{n} steps" suffix stay prop-localized `labelForTool` / `stepsLabel`.) */
135
- agentRun: { thinking: string; thinkingStreaming: string; input: string; error: string; output: string };
132
+ /** `AgentRun`: the reasoning disclosure's label (settled / streaming), the
133
+ * auto-built tool peek's Input / Error / Output panel titles, the `awaiting`
134
+ * annotation on a call parked on a human decision, and the terminal error's
135
+ * `retry` action. (Tool-step labels and the "{n} steps" suffix stay
136
+ * prop-localized — `labelForCall` / `stepsLabel`.) */
137
+ agentRun: { thinking: string; thinkingStreaming: string; input: string; error: string; output: string; awaiting: string; retry: string };
138
+ /** `ApprovalPrompt`: the default prompt line (overridable per instance) and
139
+ * the Approve / Deny button labels — the surface that ANSWERS `AgentRun`'s
140
+ * read-only `awaiting` row (approve/deny = the ai-sdk approval vocabulary). */
141
+ approvalPrompt: { message: string; approve: string; deny: string };
142
+ /** `MessageActions`: the copy tooltip + its flipped "copied" confirmation, the
143
+ * regenerate and edit tooltips, and the branch pager's prev/next names. */
144
+ messageActions: { copy: string; copied: string; regenerate: string; edit: string; previousVersion: string; nextVersion: string };
136
145
  }
137
146
 
138
147
  /** The platform default — English. Every component's hardcoded default lives
@@ -221,14 +230,16 @@ export const en: LoticsLocale = {
221
230
  chart: { noData: "No data", total: "Total" },
222
231
  composer: { send: "Send", stop: "Stop" },
223
232
  overlay: { close: "Close" },
224
- fileDropzone: { label: "Drag files here", hint: "or click to browse", drop: "Drop to upload" },
233
+ fileDropzone: { label: "Drag files here", hint: "or click, or paste (⌘V)", drop: "Drop to upload" },
225
234
  fileThumbnail: { remove: "Remove" },
226
235
  imageGallery: { empty: "No images.", rotateLeft: "Rotate left", rotateRight: "Rotate right", zoom: "Zoom image" },
227
236
  infoPopover: { more: "More information" },
228
237
  matrix: { total: "Total", less: "Less", more: "More" },
229
238
  scrollToBottom: { tooltip: "Scroll to bottom" },
230
239
  textInputField: { clear: "Clear" },
231
- agentRun: { thinking: "Thinking", thinkingStreaming: "Thinking…", input: "Input", error: "Error", output: "Output" },
240
+ agentRun: { thinking: "Thinking", thinkingStreaming: "Thinking…", input: "Input", error: "Error", output: "Output", awaiting: "Awaiting", retry: "Retry" },
241
+ approvalPrompt: { message: "The assistant wants to perform an action that needs your approval.", approve: "Approve", deny: "Deny" },
242
+ messageActions: { copy: "Copy", copied: "Copied", regenerate: "Regenerate", edit: "Edit", previousVersion: "Previous version", nextVersion: "Next version" },
232
243
  };
233
244
 
234
245
  /** Vietnamese. Maintained once here so every app (and the frontend) shares one
@@ -317,14 +328,16 @@ export const vi: LoticsLocale = {
317
328
  chart: { noData: "Không có dữ liệu", total: "Tổng" },
318
329
  composer: { send: "Gửi", stop: "Dừng" },
319
330
  overlay: { close: "Đóng" },
320
- fileDropzone: { label: "Kéo tệp vào đây", hint: "hoặc bấm để chọn", drop: "Thả để tải lên" },
331
+ fileDropzone: { label: "Kéo tệp vào đây", hint: "hoặc bấm chọn, hoặc dán (Ctrl+V)", drop: "Thả để tải lên" },
321
332
  fileThumbnail: { remove: "Xóa" },
322
333
  imageGallery: { empty: "Chưa có ảnh.", rotateLeft: "Xoay trái", rotateRight: "Xoay phải", zoom: "Phóng to ảnh" },
323
334
  infoPopover: { more: "Thông tin thêm" },
324
335
  matrix: { total: "Tổng", less: "Ít", more: "Nhiều" },
325
336
  scrollToBottom: { tooltip: "Cuộn xuống cuối" },
326
337
  textInputField: { clear: "Xóa" },
327
- agentRun: { thinking: "Suy nghĩ", thinkingStreaming: "Đang suy nghĩ…", input: "Đầu vào", error: "Lỗi", output: "Kết quả" },
338
+ agentRun: { thinking: "Suy nghĩ", thinkingStreaming: "Đang suy nghĩ…", input: "Đầu vào", error: "Lỗi", output: "Kết quả", awaiting: "Chờ duyệt", retry: "Thử lại" },
339
+ approvalPrompt: { message: "Trợ lý muốn thực hiện thao tác cần bạn duyệt.", approve: "Cho phép", deny: "Từ chối" },
340
+ messageActions: { copy: "Sao chép", copied: "Đã sao chép", regenerate: "Tạo lại", edit: "Chỉnh sửa", previousVersion: "Phiên bản trước", nextVersion: "Phiên bản sau" },
328
341
  };
329
342
 
330
343
  const LoticsLocaleContext = createContext<LoticsLocale>(en);