@pipelex/mthds-form 0.10.0 → 0.11.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.
@@ -203,6 +203,7 @@ var DEFAULT_FIELD_STRINGS = {
203
203
  dropToUpload: "Drop to upload",
204
204
  dropOrBrowse: "Drop a file or click to browse",
205
205
  pasteUrlInstead: "paste a URL instead",
206
+ uploadUnavailable: "Files cannot be uploaded here. Paste a link to the file instead.",
206
207
  urlPlaceholder: "https://\u2026",
207
208
  fileUrlAria: (label) => label ? `Link to the file for ${label}` : "Link to the file",
208
209
  uploadedFile: "Attached file",
@@ -278,7 +279,16 @@ function FieldShell({
278
279
  !required && /* @__PURE__ */ jsx("span", { className: "ml-auto font-mono text-[10px] uppercase tracking-wider text-muted-foreground", children: s.optionalBadge })
279
280
  ] }),
280
281
  description && /* @__PURE__ */ jsx("p", { className: "text-[12px] leading-relaxed text-muted-foreground", children: description }),
281
- isApp && error ? /* @__PURE__ */ jsx("div", { className: "rounded-lg ring-1 ring-destructive/70 ring-offset-4 ring-offset-background", children }) : children,
282
+ /* @__PURE__ */ jsx(
283
+ "div",
284
+ {
285
+ className: cn(
286
+ "space-y-2",
287
+ isApp && error && "rounded-lg ring-1 ring-destructive/70 ring-offset-4 ring-offset-background"
288
+ ),
289
+ children
290
+ }
291
+ ),
282
292
  error && /* @__PURE__ */ jsx("p", { className: "flex items-center gap-1 text-[11px] text-destructive", role: "alert", children: error })
283
293
  ] });
284
294
  }
@@ -348,9 +358,11 @@ function FileField({
348
358
  field,
349
359
  value,
350
360
  onDropFile,
361
+ allowUrl = true,
351
362
  onChange,
352
363
  id,
353
364
  uploading,
365
+ uploadError,
354
366
  resolveUrl,
355
367
  error,
356
368
  disabled,
@@ -372,16 +384,28 @@ function FileField({
372
384
  }, []);
373
385
  useEffect(() => () => setLocal(null), [setLocal]);
374
386
  const [rejected, setRejected] = useState(null);
387
+ const [dismissedUploadError, setDismissedUploadError] = useState(null);
388
+ useEffect(() => {
389
+ if (uploadError === void 0) setDismissedUploadError(null);
390
+ else setRejected(null);
391
+ }, [uploadError]);
392
+ const dismissUploadError = useCallback(() => {
393
+ setDismissedUploadError(uploadError ?? null);
394
+ }, [uploadError]);
395
+ const failure = uploadError !== void 0 && uploadError !== dismissedUploadError ? uploadError : null;
375
396
  const formats = field.formats;
376
397
  const hint = acceptLabel(formats);
377
398
  const accept = useMemo(() => acceptMap(formats), [formats]);
399
+ const canUpload = onDropFile !== void 0;
378
400
  const handleFile = useCallback(
379
401
  (file) => {
402
+ if (!onDropFile) return;
380
403
  if (!isAcceptedFile(formats, file)) {
381
404
  setRejected(file.name);
382
405
  return;
383
406
  }
384
407
  setRejected(null);
408
+ setDismissedUploadError(null);
385
409
  setLocal({ objectUrl: URL.createObjectURL(file), type: file.type });
386
410
  onDropFile(file);
387
411
  },
@@ -391,7 +415,9 @@ function FileField({
391
415
  const { getRootProps, getInputProps, isDragActive } = useDropzone({
392
416
  maxFiles: 1,
393
417
  multiple: false,
394
- disabled: busy,
418
+ // Disabled, not merely unrendered, when there is no upload path: the hook
419
+ // cannot be skipped, and nothing may open a picker through it.
420
+ disabled: busy || !canUpload,
395
421
  // Filters the OS picker so a wrong file is hard to choose in the first
396
422
  // place. It is NOT the enforcement: a drag-and-drop bypasses the picker
397
423
  // entirely, and a browser reporting an empty `File.type` slips through
@@ -421,8 +447,9 @@ function FileField({
421
447
  setResolved(null);
422
448
  setPreviewOpen(false);
423
449
  setRejected(null);
450
+ dismissUploadError();
424
451
  onChange(void 0);
425
- }, [setLocal, onChange]);
452
+ }, [setLocal, onChange, dismissUploadError]);
426
453
  const localIsCurrent = !!localPreview && (localPreview.boundUrl === void 0 || localPreview.boundUrl === value?.url);
427
454
  useEffect(() => {
428
455
  if (!localPreview) return;
@@ -447,19 +474,31 @@ function FileField({
447
474
  if (!previewOpen || !storageUri || localIsCurrent || !resolveUrl) return;
448
475
  let cancelled = false;
449
476
  void resolveUrl(storageUri).then((src) => {
450
- if (!cancelled) setResolved(src ? { uri: storageUri, src } : null);
477
+ if (!cancelled) setResolved({ uri: storageUri, src: src || null });
451
478
  }).catch(() => {
452
- if (!cancelled) setResolved(null);
479
+ if (!cancelled) setResolved({ uri: storageUri, src: null });
453
480
  });
454
481
  return () => {
455
482
  cancelled = true;
456
483
  };
457
484
  }, [previewOpen, storageUri, localIsCurrent, resolveUrl]);
458
- const resolvedSrc = resolved && resolved.uri === storageUri ? viewableUrl(resolved.src) : void 0;
485
+ const resolvedSrc = resolved && resolved.uri === storageUri ? viewableUrl(resolved.src ?? void 0) : void 0;
459
486
  const previewSrc = (localIsCurrent ? localPreview?.objectUrl : void 0) ?? directSrc ?? resolvedSrc;
460
- const previewPending = !previewSrc && !!storageUri && resolved?.uri !== storageUri;
487
+ const previewPending = !previewSrc && !!storageUri && !!resolveUrl && resolved?.uri !== storageUri;
488
+ const togglePreview = () => {
489
+ if (!previewOpen) setResolved((r) => r?.src === null ? null : r);
490
+ setPreviewOpen((v) => !v);
491
+ };
461
492
  const urlValue = value?.url ?? "";
462
493
  const urlText = presentation === "studio" || isReadableLink(urlValue) || urlValue === typedUrl ? urlValue : "";
494
+ if (!canUpload && !allowUrl) {
495
+ throw new Error(
496
+ `The file field at "${id}" has no way in: the form's environment supplies no onDropFile and sets allowUrl to false. Supply onDropFile so the file can be uploaded, or leave allowUrl unset so a link can be pasted.`
497
+ );
498
+ }
499
+ const linkOpen = allowUrl && (!canUpload || showUrl);
500
+ const noUploadLineShown = !canUpload && !hasFile && !uploading;
501
+ const noUploadLineId = `${domId}-no-upload`;
463
502
  return /* @__PURE__ */ jsxs(
464
503
  FieldShell,
465
504
  {
@@ -483,7 +522,7 @@ function FileField({
483
522
  onClear: clear,
484
523
  canPreview,
485
524
  previewOpen,
486
- onTogglePreview: () => setPreviewOpen((v) => !v)
525
+ onTogglePreview: togglePreview
487
526
  }
488
527
  ),
489
528
  previewOpen && (previewSrc && isImage ? /* @__PURE__ */ jsx(ImagePreview, { src: previewSrc, filename: value?.filename }) : previewSrc && isPdf ? /* @__PURE__ */ jsx(PdfPreview, { src: previewSrc }) : previewPending ? /* @__PURE__ */ jsx("div", { className: "flex h-24 items-center justify-center rounded-md border border-border bg-muted", children: /* @__PURE__ */ jsx(Loader2, { className: "h-4 w-4 animate-spin text-muted-foreground" }) }) : (
@@ -493,7 +532,19 @@ function FileField({
493
532
  // load, which is the one thing a refusal must not look like.
494
533
  /* @__PURE__ */ jsx("div", { className: "flex h-24 items-center justify-center rounded-md border border-border bg-muted", children: /* @__PURE__ */ jsx(ImageOff, { className: "h-5 w-5 text-muted-foreground" }) })
495
534
  ))
496
- ] }) : (
535
+ ] }) : !canUpload ? (
536
+ // No upload path, so no dropzone and no picker: a control that takes a
537
+ // file with nowhere to send it drops the file without a word. The line
538
+ // says why the link input below is the way in, and the hint says what
539
+ // that link has to point at.
540
+ /* @__PURE__ */ jsx("div", { className: "space-y-0.5", children: uploading ? /* @__PURE__ */ jsxs("p", { className: "flex items-center gap-1.5 text-[12px] text-muted-foreground", children: [
541
+ /* @__PURE__ */ jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin" }),
542
+ s.uploading
543
+ ] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
544
+ /* @__PURE__ */ jsx("p", { id: noUploadLineId, className: "text-[12px] text-muted-foreground", children: s.uploadUnavailable }),
545
+ hint && /* @__PURE__ */ jsx("p", { className: "font-mono text-[10.5px] text-muted-foreground", children: hint })
546
+ ] }) })
547
+ ) : (
497
548
  // `role="presentation"` and no tab stop: this div is the drop TARGET and
498
549
  // the visual affordance, and the control inside it is a real
499
550
  // `<input type="file">`, which already has the right role and now has a
@@ -535,39 +586,43 @@ function FileField({
535
586
  }
536
587
  )
537
588
  ),
538
- rejected && !busy && /* @__PURE__ */ jsxs("p", { role: "alert", className: "text-[12px] text-destructive", children: [
589
+ !busy && (rejected ? /* @__PURE__ */ jsxs("p", { role: "alert", className: "text-[12px] text-destructive", children: [
539
590
  /* @__PURE__ */ jsx("span", { className: "font-mono", children: rejected }),
540
591
  " \u2014 ",
541
592
  s.unsupportedFileType(hint)
542
- ] }),
543
- !showUrl ? /* @__PURE__ */ jsxs(
544
- "button",
545
- {
546
- type: "button",
547
- disabled: busy,
548
- onClick: () => setShowUrl(true),
549
- className: "flex w-fit items-center gap-1 font-mono text-[11px] text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50",
550
- children: [
551
- /* @__PURE__ */ jsx(Link2, { className: "h-3 w-3" }),
552
- s.pasteUrlInstead
553
- ]
554
- }
555
- ) : /* @__PURE__ */ jsx(
593
+ ] }) : failure && /* @__PURE__ */ jsx("p", { role: "alert", className: "text-[12px] text-destructive", children: failure })),
594
+ linkOpen ? /* @__PURE__ */ jsx(
556
595
  "input",
557
596
  {
558
597
  type: "text",
559
- autoFocus: true,
598
+ id: canUpload ? void 0 : domId,
599
+ autoFocus: showUrl,
560
600
  value: urlText,
561
601
  disabled: busy,
562
602
  "aria-label": s.fileUrlAria(fieldLabel(field.title, field.name, presentation)),
603
+ "aria-describedby": noUploadLineShown ? noUploadLineId : void 0,
604
+ "aria-invalid": canUpload ? void 0 : !!error,
563
605
  placeholder: s.urlPlaceholder,
564
606
  onChange: (e) => {
565
607
  setLocal(null);
608
+ dismissUploadError();
566
609
  setTypedUrl(e.target.value);
567
610
  onChange(e.target.value ? { url: e.target.value } : void 0);
568
611
  },
569
612
  className: cn(fieldControlClass, "h-9 px-3 font-mono text-[12px]")
570
613
  }
614
+ ) : allowUrl && /* @__PURE__ */ jsxs(
615
+ "button",
616
+ {
617
+ type: "button",
618
+ disabled: busy,
619
+ onClick: () => setShowUrl(true),
620
+ className: "flex w-fit items-center gap-1 font-mono text-[11px] text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50",
621
+ children: [
622
+ /* @__PURE__ */ jsx(Link2, { className: "h-3 w-3" }),
623
+ s.pasteUrlInstead
624
+ ]
625
+ }
571
626
  )
572
627
  ]
573
628
  }
@@ -1107,6 +1162,7 @@ function ListField({ field, value, onChange, id, error, env }) {
1107
1162
  const isApp = presentation === "app";
1108
1163
  const items = value ?? [];
1109
1164
  const [rowKeys, setRowKeys] = useRowKeys(items.length);
1165
+ const [staleErrors, setStaleErrors] = useState(NO_ERRORS);
1110
1166
  const setItem = (index, itemValue) => {
1111
1167
  const next = items.slice();
1112
1168
  next[index] = itemValue;
@@ -1114,10 +1170,40 @@ function ListField({ field, value, onChange, id, error, env }) {
1114
1170
  };
1115
1171
  const removeItem = (index) => {
1116
1172
  setRowKeys((prev) => prev.filter((_, i) => i !== index));
1173
+ setStaleErrors((prev) => withRenumberedErrors(prev, id, index, env?.uploadErrors));
1117
1174
  onChange(items.filter((_, i) => i !== index));
1118
1175
  };
1119
1176
  const addItem = () => onChange([...items, emptyValue(field.item)]);
1120
1177
  const busy = listIsBusy(id, env?.uploadingIds);
1178
+ const hostErrors = env?.uploadErrors;
1179
+ useEffect(() => {
1180
+ setStaleErrors((prev) => {
1181
+ if (prev.size === 0) return prev;
1182
+ const next = new Map([...prev].filter(([key, message]) => hostErrors?.get(key) === message));
1183
+ return next.size === prev.size ? prev : next;
1184
+ });
1185
+ }, [hostErrors]);
1186
+ const rowEnv = useMemo(() => {
1187
+ if (!env || staleErrors.size === 0) return env;
1188
+ const drop = env.onDropFile;
1189
+ return {
1190
+ ...env,
1191
+ uploadErrors: new Map(
1192
+ [...env.uploadErrors ?? []].filter(([key, message]) => staleErrors.get(key) !== message)
1193
+ ),
1194
+ // A new file at a position is a new attempt there, so what the host says
1195
+ // about that position from now on is about the file that is there.
1196
+ onDropFile: drop ? (dropId, file) => {
1197
+ setStaleErrors((prev) => {
1198
+ if (!prev.has(dropId)) return prev;
1199
+ const next = new Map(prev);
1200
+ next.delete(dropId);
1201
+ return next;
1202
+ });
1203
+ drop(dropId, file);
1204
+ } : void 0
1205
+ };
1206
+ }, [env, staleErrors]);
1121
1207
  const isFull = field.maxItemCount !== void 0 && items.length >= field.maxItemCount;
1122
1208
  return /* @__PURE__ */ jsxs("div", { className: "space-y-2", children: [
1123
1209
  /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-baseline gap-x-2 gap-y-1", children: [
@@ -1151,7 +1237,7 @@ function ListField({ field, value, onChange, id, error, env }) {
1151
1237
  value: item,
1152
1238
  onChange: (v) => setItem(index, v),
1153
1239
  id: `${id}.${index}`,
1154
- env
1240
+ env: rowEnv
1155
1241
  }
1156
1242
  ) }),
1157
1243
  /* @__PURE__ */ jsx(
@@ -1195,6 +1281,18 @@ function listIsBusy(id, uploadingIds) {
1195
1281
  for (const uploading of uploadingIds) if (uploading.startsWith(prefix)) return true;
1196
1282
  return false;
1197
1283
  }
1284
+ var NO_ERRORS = /* @__PURE__ */ new Map();
1285
+ function withRenumberedErrors(stale, listId, removed, hostErrors) {
1286
+ if (!hostErrors?.size) return stale;
1287
+ const prefix = `${listId}.`;
1288
+ const next = new Map(stale);
1289
+ for (const [key, message] of hostErrors) {
1290
+ if (!key.startsWith(prefix)) continue;
1291
+ const row = key.slice(prefix.length).split(".")[0] ?? "";
1292
+ if (/^\d+$/.test(row) && Number(row) >= removed) next.set(key, message);
1293
+ }
1294
+ return next.size === stale.size && [...next].every(([k, m]) => stale.get(k) === m) ? stale : next;
1295
+ }
1198
1296
  function useRowKeys(length) {
1199
1297
  const [state, setState] = useState(() => ({
1200
1298
  keys: Array.from({ length }, (_, i) => i),
@@ -1261,6 +1359,7 @@ function UnknownField({ field, value, onChange, id, error, disabled }) {
1261
1359
  }
1262
1360
  function FieldRenderer({ field, value, onChange, id, error, env }) {
1263
1361
  const disabled = env?.disabled;
1362
+ const drop = env?.onDropFile;
1264
1363
  switch (field.kind) {
1265
1364
  case "text":
1266
1365
  return /* @__PURE__ */ jsx(
@@ -1340,8 +1439,10 @@ function FieldRenderer({ field, value, onChange, id, error, env }) {
1340
1439
  field,
1341
1440
  value,
1342
1441
  onChange,
1343
- onDropFile: (file) => env?.onDropFile?.(id, file),
1442
+ onDropFile: drop ? (file) => drop(id, file) : void 0,
1443
+ allowUrl: env?.allowUrl,
1344
1444
  uploading: env?.uploadingIds?.has(id),
1445
+ uploadError: env?.uploadErrors?.get(id),
1345
1446
  resolveUrl: env?.resolveUrl,
1346
1447
  id,
1347
1448
  error,
@@ -1355,8 +1456,10 @@ function FieldRenderer({ field, value, onChange, id, error, env }) {
1355
1456
  field,
1356
1457
  value,
1357
1458
  onChange,
1358
- onDropFile: (file) => env?.onDropFile?.(id, file),
1459
+ onDropFile: drop ? (file) => drop(id, file) : void 0,
1460
+ allowUrl: env?.allowUrl,
1359
1461
  uploading: env?.uploadingIds?.has(id),
1462
+ uploadError: env?.uploadErrors?.get(id),
1360
1463
  resolveUrl: env?.resolveUrl,
1361
1464
  id,
1362
1465
  error,
@@ -2836,5 +2939,5 @@ function StuffViewer({
2836
2939
  }
2837
2940
 
2838
2941
  export { BooleanField, ConceptPill, DEFAULT_FIELD_STRINGS, DateField, DocumentField, EnumField, FieldDomIdProvider, FieldPresentationProvider, FieldRenderer, FieldShell, FieldStringsProvider, HtmlPreview, ImageField, JsonView, ListField, Markdown, NumberField, ObjectField, OptionalToggle, ProseField, ResultEnvProvider, ResultField, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, StuffViewer, Switch, TextField, ToggleGroup, ToggleGroupItem, UnknownField, cn, downloadStuff, fieldControlClass, humanizeFieldName, useFieldDomId, useFieldPresentation, useFieldStrings, useProseImages, useResolveShareUrl, useResolveUrl, useResolvedUrl };
2839
- //# sourceMappingURL=chunk-G6BWZUGV.js.map
2840
- //# sourceMappingURL=chunk-G6BWZUGV.js.map
2942
+ //# sourceMappingURL=chunk-E2IVBAW3.js.map
2943
+ //# sourceMappingURL=chunk-E2IVBAW3.js.map