@lotics/ui 29.2.0 → 29.3.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/docs/catalog.md CHANGED
@@ -1328,7 +1328,13 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1328
1328
 
1329
1329
  - **`mime`** — pure MIME predicates: `isImageMimeType` / `isVideoMimeType` /
1330
1330
  `isAudioMimeType` / `isPdfMimeType` / `isExcelMimeType` / `isCsvMimeType` /
1331
- `isDocxMimeType` / `isPreviewableMimeType`.
1331
+ `isDocxMimeType` / `isPreviewableMimeType`. Plus
1332
+ **`resolveMimeType(declaredType, filename)`** — what to TREAT a file as. An
1333
+ honestly-declared type is returned untouched; an *uninformative* one (`""`,
1334
+ `application/octet-stream` and friends — what an OS clipboard or drag hands
1335
+ over) is resolved from the filename extension instead, falling back to the
1336
+ declaration when the extension is unknown too. It returns the same answer the
1337
+ server stores, so a surface that filters on it agrees with what gets persisted.
1332
1338
  - **`download`** — `downloadFileFromUrl(url, filename, { credentials? })`: fetch+blob+anchor
1333
1339
  download that works inside sandboxed iframes (where `window.open` is silently dropped);
1334
1340
  `credentials` defaults to `same-origin` — pass `"include"` only for auth-gated same-site
@@ -581,6 +581,19 @@ intake paths, all wired to ONE handler:
581
581
  modal dialog with no wrapper). Scope it with `enabled` ("while this record/dialog is open");
582
582
  a paste with no acceptable file is left alone, so Ctrl+V in a text field still pastes text.
583
583
 
584
+ **What `accept` is matched against, and the one thing a paste cannot do.** A pattern is an
585
+ extension (`.pdf`), a wildcard type (`image/*`) or an exact MIME. A file is matched on
586
+ `resolveMimeType(file.type, file.name)` — its own declared type when that type says something,
587
+ and what its EXTENSION says when it doesn't. That fallback is not a nicety: a file copied in
588
+ Finder/Explorer and pasted arrives with real bytes and a real filename but an uninformative
589
+ type — `""`, or just as often `application/octet-stream`, the same non-answer wearing the shape
590
+ of one. Judging it on that reads silence as "not a PDF", the file is discarded, and since an
591
+ unacceptable paste is deliberately left alone, the user sees nothing happen at all. Matching on
592
+ the resolved type also means the surface agrees with what the server will store. Separately,
593
+ some clipboards carry only a file *reference* (a path/URL, no bytes): there is nothing to
594
+ upload and no API that can go read it, so those pastes cannot work by any means — dragging the
595
+ file is the path that always does.
596
+
584
597
  **Paste routing (focus, then stack).** When two file targets are enabled on the same layer,
585
598
  the paste goes to the TOP-MOST one whose `region` contains focus (`document.activeElement`) —
586
599
  so two peer file sections each win while the user is working in them, never "last-mounted
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "29.2.0",
3
+ "version": "29.3.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -324,6 +324,7 @@
324
324
  "test": "vitest run"
325
325
  },
326
326
  "devDependencies": {
327
+ "@lotics/shared": "*",
327
328
  "@types/react-dom": "~19.2.2",
328
329
  "@vitejs/plugin-react": "^4.3.4",
329
330
  "lucide-react": "^0.562.0",
@@ -13,11 +13,13 @@
13
13
 
14
14
  import type { ReactNode } from "react";
15
15
  import type { StyleProp, ViewStyle } from "react-native";
16
+ import { resolveMimeType } from "./mime";
16
17
 
17
18
  /** The filter every intake path applies to what arrived. */
18
19
  export interface FileIntakeFilter {
19
20
  /** Native `accept` filter (e.g. `"application/pdf,image/*"`) — matched against
20
- * the file's MIME type, or its extension for a `.ext` pattern. */
21
+ * the file's MIME type (falling back to what its extension says when the file
22
+ * declares none), or against its filename for a `.ext` pattern. */
21
23
  accept?: string;
22
24
  /** Keep more than one file. Default true. */
23
25
  multiple?: boolean;
@@ -79,15 +81,28 @@ export interface FileDropTargetProps extends FileIntakeFilter {
79
81
  * Does a file pass a native `accept` list? Each comma-separated pattern is an
80
82
  * extension (`.pdf`), a wildcard type (`image/*`) or an exact MIME
81
83
  * (`application/pdf`); an empty/absent list accepts everything.
84
+ *
85
+ * A file whose declared type says NOTHING is judged by its extension instead.
86
+ * The OS clipboard is the case that forces this: copy a file in
87
+ * Finder/Explorer and paste it, and the browser hands over real bytes and a
88
+ * real filename while declaring `""` — or, just as often,
89
+ * `application/octet-stream`, the same non-answer wearing the shape of one.
90
+ * Reading either as a MIME mismatch asserts "this is not a PDF" from data that
91
+ * never said so, and the file is dropped; because a paste carrying nothing
92
+ * acceptable is deliberately left alone, that drop is invisible.
93
+ *
94
+ * `resolveMimeType` owns which declarations count as uninformative, so this
95
+ * matches on the same type the SERVER will store for the file.
82
96
  */
83
97
  export function matchesAccept(file: File, accept: string | undefined): boolean {
84
98
  if (!accept) return true;
85
99
  const patterns = accept.split(",").map((p) => p.trim().toLowerCase()).filter(Boolean);
86
100
  if (patterns.length === 0) return true;
87
- const mime = file.type.toLowerCase();
88
101
  const name = file.name.toLowerCase();
102
+ const mime = resolveMimeType(file.type, name);
89
103
  return patterns.some((p) => {
90
104
  if (p.startsWith(".")) return name.endsWith(p);
105
+ if (!mime) return false;
91
106
  if (p.endsWith("/*")) return mime.startsWith(p.slice(0, -1));
92
107
  return mime === p;
93
108
  });
package/src/mime.ts CHANGED
@@ -52,3 +52,79 @@ export function isPreviewableMimeType(mimeType: string): boolean {
52
52
  isDocxMimeType(mimeType)
53
53
  );
54
54
  }
55
+
56
+ /** A MIME type without its parameters (`text/csv; charset=utf-8` → `text/csv`). */
57
+ function normalizeMimeType(mimeType: string): string {
58
+ return mimeType.split(";")[0]!.trim().toLowerCase();
59
+ }
60
+
61
+ /**
62
+ * Types that carry no information — a sender that declined to classify the
63
+ * bytes. The empty string is only the most obvious one: an OS clipboard or drag
64
+ * routinely hands a file over as `application/octet-stream` instead, which is
65
+ * the same statement made in a way that LOOKS like an answer.
66
+ */
67
+ const GENERIC_MIME_TYPES: ReadonlySet<string> = new Set([
68
+ "",
69
+ "application/octet-stream",
70
+ "binary/octet-stream",
71
+ "application/binary",
72
+ "application/unknown",
73
+ ]);
74
+
75
+ /**
76
+ * Extension → MIME, for the document and image formats an intake surface names.
77
+ * A `Map`, not an object literal, so a file named `x.constructor` looks up a
78
+ * miss rather than `Object.prototype`'s member.
79
+ */
80
+ const MIME_BY_EXTENSION: ReadonlyMap<string, string> = new Map([
81
+ ["pdf", "application/pdf"],
82
+ ["png", "image/png"],
83
+ ["jpg", "image/jpeg"],
84
+ ["jpeg", "image/jpeg"],
85
+ ["gif", "image/gif"],
86
+ ["webp", "image/webp"],
87
+ ["avif", "image/avif"],
88
+ ["bmp", "image/bmp"],
89
+ ["svg", "image/svg+xml"],
90
+ ["tif", "image/tiff"],
91
+ ["tiff", "image/tiff"],
92
+ ["heic", "image/heic"],
93
+ ["heif", "image/heif"],
94
+ ["csv", "text/csv"],
95
+ ["tsv", "text/tab-separated-values"],
96
+ ["txt", "text/plain"],
97
+ ["xml", "application/xml"],
98
+ ["json", "application/json"],
99
+ ["zip", "application/zip"],
100
+ ["xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
101
+ ["xls", "application/vnd.ms-excel"],
102
+ ["docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
103
+ ["doc", "application/msword"],
104
+ ]);
105
+
106
+ /** What a filename's extension says the file is, or `""` when it says nothing. */
107
+ function mimeTypeFromFileName(name: string): string {
108
+ const dot = name.lastIndexOf(".");
109
+ // `dot <= 0` also rejects a dotfile (`.pdf` is a NAME, not an extension);
110
+ // `length - 1` rejects a trailing dot.
111
+ if (dot <= 0 || dot === name.length - 1) return "";
112
+ return MIME_BY_EXTENSION.get(name.slice(dot + 1).toLowerCase()) ?? "";
113
+ }
114
+
115
+ /**
116
+ * The type to TREAT content as, given what its sender declared and what it is
117
+ * called. An honestly-declared type is never second-guessed — only an
118
+ * uninformative one is resolved, and only from the filename, falling back to
119
+ * the declaration when the extension is unrecognized too.
120
+ *
121
+ * Mirrors `resolveMimeType` in `@lotics/shared/file_type`, which is what the
122
+ * server stores. The kit cannot import it (`primitives_purity.test.ts` keeps
123
+ * `src/` free of `@lotics/shared`), so `mime_parity.test.ts` asserts the two
124
+ * agree instead of trusting that they will.
125
+ */
126
+ export function resolveMimeType(declaredMimeType: string, filename: string): string {
127
+ const declared = normalizeMimeType(declaredMimeType);
128
+ if (!GENERIC_MIME_TYPES.has(declared)) return declared;
129
+ return mimeTypeFromFileName(filename) || declared;
130
+ }