@pipelex/mthds-form 0.10.1 → 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;
@@ -464,6 +491,14 @@ function FileField({
464
491
  };
465
492
  const urlValue = value?.url ?? "";
466
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`;
467
502
  return /* @__PURE__ */ jsxs(
468
503
  FieldShell,
469
504
  {
@@ -497,7 +532,19 @@ function FileField({
497
532
  // load, which is the one thing a refusal must not look like.
498
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" }) })
499
534
  ))
500
- ] }) : (
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
+ ) : (
501
548
  // `role="presentation"` and no tab stop: this div is the drop TARGET and
502
549
  // the visual affordance, and the control inside it is a real
503
550
  // `<input type="file">`, which already has the right role and now has a
@@ -539,39 +586,43 @@ function FileField({
539
586
  }
540
587
  )
541
588
  ),
542
- 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: [
543
590
  /* @__PURE__ */ jsx("span", { className: "font-mono", children: rejected }),
544
591
  " \u2014 ",
545
592
  s.unsupportedFileType(hint)
546
- ] }),
547
- !showUrl ? /* @__PURE__ */ jsxs(
548
- "button",
549
- {
550
- type: "button",
551
- disabled: busy,
552
- onClick: () => setShowUrl(true),
553
- className: "flex w-fit items-center gap-1 font-mono text-[11px] text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50",
554
- children: [
555
- /* @__PURE__ */ jsx(Link2, { className: "h-3 w-3" }),
556
- s.pasteUrlInstead
557
- ]
558
- }
559
- ) : /* @__PURE__ */ jsx(
593
+ ] }) : failure && /* @__PURE__ */ jsx("p", { role: "alert", className: "text-[12px] text-destructive", children: failure })),
594
+ linkOpen ? /* @__PURE__ */ jsx(
560
595
  "input",
561
596
  {
562
597
  type: "text",
563
- autoFocus: true,
598
+ id: canUpload ? void 0 : domId,
599
+ autoFocus: showUrl,
564
600
  value: urlText,
565
601
  disabled: busy,
566
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,
567
605
  placeholder: s.urlPlaceholder,
568
606
  onChange: (e) => {
569
607
  setLocal(null);
608
+ dismissUploadError();
570
609
  setTypedUrl(e.target.value);
571
610
  onChange(e.target.value ? { url: e.target.value } : void 0);
572
611
  },
573
612
  className: cn(fieldControlClass, "h-9 px-3 font-mono text-[12px]")
574
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
+ }
575
626
  )
576
627
  ]
577
628
  }
@@ -1111,6 +1162,7 @@ function ListField({ field, value, onChange, id, error, env }) {
1111
1162
  const isApp = presentation === "app";
1112
1163
  const items = value ?? [];
1113
1164
  const [rowKeys, setRowKeys] = useRowKeys(items.length);
1165
+ const [staleErrors, setStaleErrors] = useState(NO_ERRORS);
1114
1166
  const setItem = (index, itemValue) => {
1115
1167
  const next = items.slice();
1116
1168
  next[index] = itemValue;
@@ -1118,10 +1170,40 @@ function ListField({ field, value, onChange, id, error, env }) {
1118
1170
  };
1119
1171
  const removeItem = (index) => {
1120
1172
  setRowKeys((prev) => prev.filter((_, i) => i !== index));
1173
+ setStaleErrors((prev) => withRenumberedErrors(prev, id, index, env?.uploadErrors));
1121
1174
  onChange(items.filter((_, i) => i !== index));
1122
1175
  };
1123
1176
  const addItem = () => onChange([...items, emptyValue(field.item)]);
1124
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]);
1125
1207
  const isFull = field.maxItemCount !== void 0 && items.length >= field.maxItemCount;
1126
1208
  return /* @__PURE__ */ jsxs("div", { className: "space-y-2", children: [
1127
1209
  /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-baseline gap-x-2 gap-y-1", children: [
@@ -1155,7 +1237,7 @@ function ListField({ field, value, onChange, id, error, env }) {
1155
1237
  value: item,
1156
1238
  onChange: (v) => setItem(index, v),
1157
1239
  id: `${id}.${index}`,
1158
- env
1240
+ env: rowEnv
1159
1241
  }
1160
1242
  ) }),
1161
1243
  /* @__PURE__ */ jsx(
@@ -1199,6 +1281,18 @@ function listIsBusy(id, uploadingIds) {
1199
1281
  for (const uploading of uploadingIds) if (uploading.startsWith(prefix)) return true;
1200
1282
  return false;
1201
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
+ }
1202
1296
  function useRowKeys(length) {
1203
1297
  const [state, setState] = useState(() => ({
1204
1298
  keys: Array.from({ length }, (_, i) => i),
@@ -1265,6 +1359,7 @@ function UnknownField({ field, value, onChange, id, error, disabled }) {
1265
1359
  }
1266
1360
  function FieldRenderer({ field, value, onChange, id, error, env }) {
1267
1361
  const disabled = env?.disabled;
1362
+ const drop = env?.onDropFile;
1268
1363
  switch (field.kind) {
1269
1364
  case "text":
1270
1365
  return /* @__PURE__ */ jsx(
@@ -1344,8 +1439,10 @@ function FieldRenderer({ field, value, onChange, id, error, env }) {
1344
1439
  field,
1345
1440
  value,
1346
1441
  onChange,
1347
- onDropFile: (file) => env?.onDropFile?.(id, file),
1442
+ onDropFile: drop ? (file) => drop(id, file) : void 0,
1443
+ allowUrl: env?.allowUrl,
1348
1444
  uploading: env?.uploadingIds?.has(id),
1445
+ uploadError: env?.uploadErrors?.get(id),
1349
1446
  resolveUrl: env?.resolveUrl,
1350
1447
  id,
1351
1448
  error,
@@ -1359,8 +1456,10 @@ function FieldRenderer({ field, value, onChange, id, error, env }) {
1359
1456
  field,
1360
1457
  value,
1361
1458
  onChange,
1362
- onDropFile: (file) => env?.onDropFile?.(id, file),
1459
+ onDropFile: drop ? (file) => drop(id, file) : void 0,
1460
+ allowUrl: env?.allowUrl,
1363
1461
  uploading: env?.uploadingIds?.has(id),
1462
+ uploadError: env?.uploadErrors?.get(id),
1364
1463
  resolveUrl: env?.resolveUrl,
1365
1464
  id,
1366
1465
  error,
@@ -2840,5 +2939,5 @@ function StuffViewer({
2840
2939
  }
2841
2940
 
2842
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 };
2843
- //# sourceMappingURL=chunk-W56WIIVC.js.map
2844
- //# sourceMappingURL=chunk-W56WIIVC.js.map
2942
+ //# sourceMappingURL=chunk-E2IVBAW3.js.map
2943
+ //# sourceMappingURL=chunk-E2IVBAW3.js.map