@riducms/admin 0.1.5 → 0.2.1

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.
Files changed (66) hide show
  1. package/package.json +11 -9
  2. package/src/app.css +63 -4
  3. package/src/app.svelte +12 -14
  4. package/src/components/ui/date-value-control/date-value-control-loader.svelte +15 -4
  5. package/src/components/ui/date-value-control/date-value-control.svelte +17 -17
  6. package/src/components/ui/select/select-trigger.svelte +1 -1
  7. package/src/core/forms/embedded-fields.ts +132 -0
  8. package/src/core/forms/embedded-schema-draft.svelte.ts +335 -0
  9. package/src/core/forms/field-editor-authoring.ts +47 -0
  10. package/src/core/forms/field-editor-binding.ts +228 -0
  11. package/src/core/forms/field-issue-focus.ts +42 -0
  12. package/src/core/forms/field-occurrence.ts +122 -0
  13. package/src/core/forms/form-controller.svelte.ts +475 -29
  14. package/src/core/forms/form-issue-correlation.ts +159 -0
  15. package/src/core/forms/form-schema.ts +211 -17
  16. package/src/core/forms/form-validation.ts +183 -17
  17. package/src/core/forms/live-validation.svelte.ts +317 -0
  18. package/src/core/forms/plugin-field-authoring.ts +160 -0
  19. package/src/core/forms/plugin-field-binding.ts +258 -0
  20. package/src/core/forms/plugin-field-write.ts +57 -0
  21. package/src/core/i18n/admin-i18n.svelte.ts +8 -1
  22. package/src/core/i18n/localized-schema.ts +33 -10
  23. package/src/core/plugins/field-registry.ts +29 -49
  24. package/src/core/plugins/row-label-registry.ts +25 -5
  25. package/src/core/runtime/admin-runtime.svelte.ts +25 -13
  26. package/src/features/auth/account-route.svelte +10 -5
  27. package/src/features/auth/account-security-route.svelte +1 -1
  28. package/src/features/collections/collection-list-bulk-editor.svelte +6 -6
  29. package/src/features/collections/collection-list-controller.svelte.ts +5 -5
  30. package/src/features/collections/collection-list-filter-popover.svelte +6 -6
  31. package/src/features/collections/collection-list-route.svelte +13 -14
  32. package/src/features/collections/list-workspace.ts +22 -8
  33. package/src/features/documents/document-controller.svelte.ts +8 -2
  34. package/src/features/documents/document-route.svelte +36 -43
  35. package/src/features/reference-browser/reference-browser-workflow.svelte.ts +30 -15
  36. package/src/features/reference-browser/reference-browser.svelte +215 -189
  37. package/src/features/uploads/upload-metadata-fields.svelte +6 -6
  38. package/src/features/versions/version-history-route.svelte +1 -1
  39. package/src/fields/embedded-schema-draft-editor.svelte +74 -0
  40. package/src/fields/embedded-schema-fields.svelte +44 -0
  41. package/src/fields/field-clipboard.ts +76 -16
  42. package/src/fields/field-collapsible.svelte +49 -0
  43. package/src/fields/field-layout.svelte +2 -18
  44. package/src/fields/field-messages.svelte +13 -18
  45. package/src/fields/field-renderer.svelte +53 -14
  46. package/src/fields/join/join-field.svelte +8 -1
  47. package/src/fields/live-validation-feedback.svelte +21 -0
  48. package/src/fields/local-field-editor.svelte +52 -0
  49. package/src/fields/nested/nested-field.svelte +159 -91
  50. package/src/fields/nested/scoped-field.ts +7 -9
  51. package/src/fields/plugin-field.svelte +156 -0
  52. package/src/fields/point/point-field.svelte +12 -2
  53. package/src/fields/primitive-list/primitive-list-field.svelte +170 -0
  54. package/src/fields/primitive-list/primitive-number-input.ts +6 -0
  55. package/src/fields/radio/radio-field.svelte +13 -9
  56. package/src/fields/relationship/relationship-field.svelte +36 -12
  57. package/src/fields/relationship/relationship-query.ts +4 -2
  58. package/src/fields/relationship/relationship-quick-picker.svelte +38 -10
  59. package/src/fields/scalar/date-control-value.ts +5 -5
  60. package/src/fields/scalar/date-value.ts +5 -5
  61. package/src/fields/scalar/scalar-field.svelte +47 -41
  62. package/src/fields/select/select-field.svelte +24 -24
  63. package/src/fields/select/select-value.ts +3 -3
  64. package/src/fields/text/slug-field.svelte +24 -8
  65. package/src/fields/text/text-field.svelte +8 -6
  66. package/src/index.ts +4 -12
@@ -1,5 +1,7 @@
1
1
  <script lang="ts">
2
+ import LiveValidationFeedback from "@admin/fields/live-validation-feedback.svelte";
2
3
  import type { FieldReferenceBrowserProps } from "@riducms/plugin";
4
+ import { fieldControlARIA } from "@riducms/ui";
3
5
  import { tick } from "svelte";
4
6
  import ArrowLeftIcon from "~icons/lucide/arrow-left";
5
7
  import ChevronLeftIcon from "~icons/lucide/chevron-left";
@@ -62,8 +64,9 @@
62
64
  const lowerSingularLabel = $derived(singularLabel.toLocaleLowerCase(runtime.i18n.language));
63
65
  const lowerPluralLabel = $derived(pluralLabel.toLocaleLowerCase(runtime.i18n.language));
64
66
  let contentElement = $state<HTMLElement | null>(null);
67
+ let searchElement = $state<HTMLInputElement | null>(null);
65
68
  // The field mounts a fresh browser for each drawer session, so workflow inputs other than the
66
- // controlled open state intentionally describe that session rather than later prop replacements.
69
+ // controlled open/editability state intentionally describe that session rather than later prop replacements.
67
70
  // svelte-ignore state_referenced_locally
68
71
  const controller = new ReferenceBrowserWorkflow({
69
72
  runtime,
@@ -72,7 +75,9 @@
72
75
  collection,
73
76
  hasMany,
74
77
  selectedIDs,
75
- readOnly,
78
+ get readOnly() {
79
+ return readOnly;
80
+ },
76
81
  initialDocument,
77
82
  initialDocumentID,
78
83
  optionFilter,
@@ -137,6 +142,12 @@
137
142
  );
138
143
  const visibleResultIdentity = $derived(docs.map((document) => document.id).join("\u001f"));
139
144
 
145
+ function focusBrowserSearch(event: Event) {
146
+ if (screen !== "list" || searchElement === null) return;
147
+ event.preventDefault();
148
+ searchElement.focus();
149
+ }
150
+
140
151
  async function handleSave(event: Event) {
141
152
  event.preventDefault();
142
153
  if (await saveEditor()) return;
@@ -318,6 +329,7 @@
318
329
  class="border-control-border bg-background-surface p-0"
319
330
  style="width: min(760px, 96vw); max-width: 760px"
320
331
  showCloseButton={false}
332
+ onOpenAutoFocus={focusBrowserSearch}
321
333
  aria-label={screen === "list"
322
334
  ? runtime.i18n.t("reference:selectLabel", { label: fieldLabel })
323
335
  : editorTitle}
@@ -379,6 +391,7 @@
379
391
  {runtime.i18n.t("reference:search", { label: pluralLabel })}
380
392
  </label>
381
393
  <Input
394
+ bind:ref={searchElement}
382
395
  id="{field.id}-relationship-search"
383
396
  type="search"
384
397
  disabled={committing}
@@ -611,207 +624,220 @@
611
624
  </div>
612
625
  {:else}
613
626
  <form class="mx-auto max-w-[620px] px-6 py-8" onsubmit={handleSave}>
614
- {#if readOnly}
615
- <Banner class="mb-6" tone="warning">
616
- {runtime.i18n.t("reference:readOnlyDescription")}
617
- </Banner>
618
- {/if}
619
- {#if editorError !== undefined}
620
- <Banner class="mb-6" tone="destructive">{editorError}</Banner>
621
- {/if}
622
- {#if !readOnly && form.access !== undefined && !form.access.operations[creating ? "create" : "update"]}
623
- <Banner class="mb-6" tone="warning">
624
- {runtime.i18n.t(creating ? "reference:cannotCreate" : "reference:cannotUpdate", {
625
- label: lowerSingularLabel,
626
- })}
627
- </Banner>
628
- {/if}
627
+ <fieldset class="contents" disabled={form.submitting}>
628
+ {#if readOnly}
629
+ <Banner class="mb-6" tone="warning">
630
+ {runtime.i18n.t("reference:readOnlyDescription")}
631
+ </Banner>
632
+ {/if}
633
+ {#if editorError !== undefined}
634
+ <Banner class="mb-6" tone="destructive">{editorError}</Banner>
635
+ {/if}
636
+ {#if !readOnly && form.access !== undefined && !form.access.operations[creating ? "create" : "update"]}
637
+ <Banner class="mb-6" tone="warning">
638
+ {runtime.i18n.t(
639
+ creating ? "reference:cannotCreate" : "reference:cannotUpdate",
640
+ {
641
+ label: lowerSingularLabel,
642
+ }
643
+ )}
644
+ </Banner>
645
+ {/if}
629
646
 
630
- {#if collection.capabilities.upload && editorDocument !== undefined}
631
- <div
632
- class="mb-7 overflow-hidden rounded-[4px] border border-control-border bg-control"
633
- >
634
- <div class="grid min-h-48 place-items-center bg-background-layer">
635
- {#if isImage(editorDocument) && mediaURL(editorDocument) !== undefined}
636
- <img
637
- class="max-h-72 w-full object-contain"
638
- src={mediaURL(editorDocument)}
639
- alt={typeof editorDocument.alt === "string" ? editorDocument.alt : ""}
640
- />
641
- {:else}
642
- <FileIcon class="size-9 text-foreground-faint" />
643
- {/if}
644
- </div>
645
- <dl
646
- class="grid grid-cols-[90px_1fr] gap-x-4 gap-y-2 border-t border-control-border px-4 py-3 text-[11.5px]"
647
+ {#if collection.capabilities.upload && editorDocument !== undefined}
648
+ <div
649
+ class="mb-7 overflow-hidden rounded-[4px] border border-control-border bg-control"
647
650
  >
648
- <dt class="text-foreground-faint">{runtime.i18n.t("uploads:filename")}</dt>
649
- <dd class="min-w-0 truncate text-foreground-muted">
650
- {String(editorDocument.filename ?? "—")}
651
- </dd>
652
- <dt class="text-foreground-faint">{runtime.i18n.t("uploads:type")}</dt>
653
- <dd class="font-mono text-[10.5px] text-foreground-muted">
654
- {String(editorDocument.mimeType ?? "—")}
655
- </dd>
656
- <dt class="text-foreground-faint">{runtime.i18n.t("uploads:size")}</dt>
657
- <dd class="font-mono text-[10.5px] text-foreground-muted">
658
- {formatBytes(editorDocument.filesize) || "—"}
659
- </dd>
660
- <dt class="text-foreground-faint">
661
- {runtime.i18n.t("uploads:dimensions")}
662
- </dt>
663
- <dd class="font-mono text-[10.5px] text-foreground-muted">
664
- {typeof editorDocument.width === "number" &&
665
- typeof editorDocument.height === "number"
666
- ? `${runtime.i18n.formatNumber(editorDocument.width)} × ${runtime.i18n.formatNumber(editorDocument.height)}`
667
- : "—"}
668
- </dd>
669
- </dl>
670
- </div>
671
- {/if}
651
+ <div class="grid min-h-48 place-items-center bg-background-layer">
652
+ {#if isImage(editorDocument) && mediaURL(editorDocument) !== undefined}
653
+ <img
654
+ class="max-h-72 w-full object-contain"
655
+ src={mediaURL(editorDocument)}
656
+ alt={typeof editorDocument.alt === "string" ? editorDocument.alt : ""}
657
+ />
658
+ {:else}
659
+ <FileIcon class="size-9 text-foreground-faint" />
660
+ {/if}
661
+ </div>
662
+ <dl
663
+ class="grid grid-cols-[90px_1fr] gap-x-4 gap-y-2 border-t border-control-border px-4 py-3 text-[11.5px]"
664
+ >
665
+ <dt class="text-foreground-faint">{runtime.i18n.t("uploads:filename")}</dt>
666
+ <dd class="min-w-0 truncate text-foreground-muted">
667
+ {String(editorDocument.filename ?? "—")}
668
+ </dd>
669
+ <dt class="text-foreground-faint">{runtime.i18n.t("uploads:type")}</dt>
670
+ <dd class="font-mono text-[10.5px] text-foreground-muted">
671
+ {String(editorDocument.mimeType ?? "—")}
672
+ </dd>
673
+ <dt class="text-foreground-faint">{runtime.i18n.t("uploads:size")}</dt>
674
+ <dd class="font-mono text-[10.5px] text-foreground-muted">
675
+ {formatBytes(editorDocument.filesize) || "—"}
676
+ </dd>
677
+ <dt class="text-foreground-faint">
678
+ {runtime.i18n.t("uploads:dimensions")}
679
+ </dt>
680
+ <dd class="font-mono text-[10.5px] text-foreground-muted">
681
+ {typeof editorDocument.width === "number" &&
682
+ typeof editorDocument.height === "number"
683
+ ? `${runtime.i18n.formatNumber(editorDocument.width)} × ${runtime.i18n.formatNumber(editorDocument.height)}`
684
+ : "—"}
685
+ </dd>
686
+ </dl>
687
+ </div>
688
+ {/if}
672
689
 
673
- {#if creating && collection.capabilities.upload}
674
- <label
675
- class="mb-7 grid min-h-44 cursor-pointer place-items-center rounded-[4px] border border-dashed border-control-border-hover bg-control px-6 py-8 text-center transition-colors hover:border-primary/45 hover:bg-primary/[0.025]"
676
- >
677
- <input
678
- class="sr-only"
679
- type="file"
680
- accept={collection.uploadSettings?.mimeTypes.join(",")}
681
- required
682
- disabled={form.submitting || form.access?.operations.create !== true}
683
- bind:files={controller.selectedFiles}
684
- />
685
- <span>
686
- <FileUpIcon class="mx-auto size-6 text-foreground-faint" />
687
- <span class="mt-3 block text-[13.5px] font-medium text-foreground-muted">
688
- {selectedFile?.name ?? runtime.i18n.t("uploads:chooseFileToUpload")}
689
- </span>
690
- <span class="font-mono mt-1.5 block text-[9.5px] text-foreground-faint">
691
- {runtime.i18n.formatList(collection.uploadSettings?.mimeTypes ?? [], {
692
- style: "short",
693
- type: "disjunction",
694
- })}
690
+ {#if creating && collection.capabilities.upload}
691
+ <label
692
+ class="mb-7 grid min-h-44 cursor-pointer place-items-center rounded-[4px] border border-dashed border-control-border-hover bg-control px-6 py-8 text-center transition-colors hover:border-primary/45 hover:bg-primary/[0.025]"
693
+ >
694
+ <input
695
+ class="sr-only"
696
+ type="file"
697
+ accept={collection.uploadSettings?.mimeTypes.join(",")}
698
+ required
699
+ disabled={form.submitting || form.access?.operations.create !== true}
700
+ bind:files={controller.selectedFiles}
701
+ />
702
+ <span>
703
+ <FileUpIcon class="mx-auto size-6 text-foreground-faint" />
704
+ <span class="mt-3 block text-[13.5px] font-medium text-foreground-muted">
705
+ {selectedFile?.name ?? runtime.i18n.t("uploads:chooseFileToUpload")}
706
+ </span>
707
+ <span class="font-mono mt-1.5 block text-[9.5px] text-foreground-faint">
708
+ {runtime.i18n.formatList(collection.uploadSettings?.mimeTypes ?? [], {
709
+ style: "short",
710
+ type: "disjunction",
711
+ })}
712
+ </span>
695
713
  </span>
696
- </span>
697
- </label>
698
- {/if}
714
+ </label>
715
+ {/if}
699
716
 
700
- {#if controller.creatingAuthUser}
701
- <fieldset
702
- class="mb-7 grid gap-4 rounded-[4px] border border-control-border bg-control p-4.5"
703
- >
704
- <legend class="px-1 text-[13px] font-semibold text-foreground">
705
- {runtime.i18n.t("reference:credentials")}
706
- </legend>
707
- <div class="grid gap-4 sm:grid-cols-2">
708
- <label class="grid gap-2" for="ridu-reference-new-user-password">
709
- <span class="ridu-field-label">{runtime.i18n.t("reference:password")}</span>
710
- <input
711
- id="ridu-reference-new-user-password"
712
- type="password"
713
- autocomplete="new-password"
714
- required
715
- disabled={form.submitting || form.access?.operations.create !== true}
716
- aria-invalid={controller.credentialIssue !== undefined}
717
- aria-describedby="ridu-reference-new-user-password-help"
718
- bind:value={controller.newUserPassword}
719
- oninput={() => (controller.credentialIssue = undefined)}
720
- />
717
+ {#if controller.creatingAuthUser}
718
+ <fieldset
719
+ class="mb-7 grid gap-4 rounded-[4px] border border-control-border bg-control p-4.5"
720
+ >
721
+ <legend class="px-1 text-[13px] font-semibold text-foreground">
722
+ {runtime.i18n.t("reference:credentials")}
723
+ </legend>
724
+ <div class="grid gap-4 sm:grid-cols-2">
725
+ <label class="grid gap-2" for="ridu-reference-new-user-password">
726
+ <span class="ridu-field-label">{runtime.i18n.t("reference:password")}</span>
727
+ <input
728
+ id="ridu-reference-new-user-password"
729
+ type="password"
730
+ autocomplete="new-password"
731
+ required
732
+ disabled={form.submitting || form.access?.operations.create !== true}
733
+ aria-invalid={controller.credentialIssue !== undefined}
734
+ aria-describedby="ridu-reference-new-user-password-help"
735
+ bind:value={controller.newUserPassword}
736
+ oninput={() => (controller.credentialIssue = undefined)}
737
+ />
738
+ </label>
739
+ <label class="grid gap-2" for="ridu-reference-new-user-password-confirmation">
740
+ <span class="ridu-field-label">
741
+ {runtime.i18n.t("reference:confirmPassword")}
742
+ </span>
743
+ <input
744
+ id="ridu-reference-new-user-password-confirmation"
745
+ type="password"
746
+ autocomplete="new-password"
747
+ required
748
+ disabled={form.submitting || form.access?.operations.create !== true}
749
+ aria-invalid={controller.credentialIssue !== undefined}
750
+ aria-describedby="ridu-reference-new-user-password-help"
751
+ bind:value={controller.newUserPasswordConfirmation}
752
+ oninput={() => (controller.credentialIssue = undefined)}
753
+ />
754
+ </label>
755
+ </div>
756
+ <p
757
+ id="ridu-reference-new-user-password-help"
758
+ class={controller.credentialIssue === undefined
759
+ ? "ridu-field-help"
760
+ : "ridu-field-error"}
761
+ role={controller.credentialIssue === undefined ? undefined : "alert"}
762
+ >
763
+ {controller.credentialIssue ??
764
+ runtime.i18n.t("reference:passwordMinimum", {
765
+ minimum: runtime.i18n.formatNumber(
766
+ collection.authSettings?.passwordMinLength ?? 8
767
+ ),
768
+ })}
769
+ </p>
770
+ </fieldset>
771
+ {/if}
772
+
773
+ <p class="font-mono text-[9.5px] tracking-[0.13em] text-foreground-faint uppercase">
774
+ {creating
775
+ ? runtime.i18n.t("reference:newMetadata", { label: singularLabel })
776
+ : `${singularLabel} · ${editorDocument?.id ?? ""}`}
777
+ </p>
778
+ {#if headingField !== undefined}
779
+ <div data-field-path={headingField.path}>
780
+ <label class="sr-only" for={headingField.id}>
781
+ {runtime.i18n.text(
782
+ headingField.admin.label,
783
+ headingField.admin.labelTranslations
784
+ )}
721
785
  </label>
722
- <label class="grid gap-2" for="ridu-reference-new-user-password-confirmation">
723
- <span class="ridu-field-label">
724
- {runtime.i18n.t("reference:confirmPassword")}
725
- </span>
786
+ <FieldMessages
787
+ controlID={headingField.id}
788
+ issues={headingIssues}
789
+ description={headingField.admin.description}
790
+ class="mt-3"
791
+ >
726
792
  <input
727
- id="ridu-reference-new-user-password-confirmation"
728
- type="password"
729
- autocomplete="new-password"
730
- required
731
- disabled={form.submitting || form.access?.operations.create !== true}
732
- aria-invalid={controller.credentialIssue !== undefined}
733
- aria-describedby="ridu-reference-new-user-password-help"
734
- bind:value={controller.newUserPasswordConfirmation}
735
- oninput={() => (controller.credentialIssue = undefined)}
793
+ id={headingField.id}
794
+ class="font-serif w-full border-x-0 border-t-0 border-b border-b-transparent bg-transparent p-0 pb-1 text-[34px] leading-[1.08] text-foreground caret-primary outline-none placeholder:text-foreground-soft aria-invalid:!border-b-destructive/65"
795
+ value={String(form.get(headingField.path) ?? "")}
796
+ placeholder={runtime.i18n.t("reference:untitledLabel", {
797
+ label: lowerSingularLabel,
798
+ })}
799
+ required={headingField.required &&
800
+ (!headingField.dynamicDefault ||
801
+ form.get(headingField.path) !== undefined)}
802
+ readonly={readOnly || !form.canWrite(headingField.path)}
803
+ {...fieldControlARIA(
804
+ headingField.id,
805
+ headingField.admin.description !== undefined,
806
+ headingIssues.length > 0
807
+ )}
808
+ oninput={(event) => form.set(headingField.path, event.currentTarget.value)}
809
+ onblur={() => form.liveValidation.flush(headingField.path)}
736
810
  />
737
- </label>
811
+ </FieldMessages>
812
+ <LiveValidationFeedback
813
+ feedback={form.liveValidation.forField(headingField.path)}
814
+ i18n={runtime.i18n}
815
+ path={headingField.path}
816
+ />
738
817
  </div>
739
- <p
740
- id="ridu-reference-new-user-password-help"
741
- class={controller.credentialIssue === undefined
742
- ? "ridu-field-help"
743
- : "ridu-field-error"}
744
- role={controller.credentialIssue === undefined ? undefined : "alert"}
745
- >
746
- {controller.credentialIssue ??
747
- runtime.i18n.t("reference:passwordMinimum", {
748
- minimum: runtime.i18n.formatNumber(
749
- collection.authSettings?.passwordMinLength ?? 8
750
- ),
751
- })}
752
- </p>
753
- </fieldset>
754
- {/if}
818
+ {/if}
755
819
 
756
- <p class="font-mono text-[9.5px] tracking-[0.13em] text-foreground-faint uppercase">
757
- {creating
758
- ? runtime.i18n.t("reference:newMetadata", { label: singularLabel })
759
- : `${singularLabel} · ${editorDocument?.id ?? ""}`}
760
- </p>
761
- {#if headingField !== undefined}
762
- <div data-field-path={headingField.path}>
763
- <label class="sr-only" for={headingField.id}>
764
- {runtime.i18n.text(
765
- headingField.admin.label,
766
- headingField.admin.labelTranslations
820
+ <div class="mt-7">
821
+ <FieldLayout
822
+ fields={editorFields.map((candidate) =>
823
+ readOnly
824
+ ? { ...candidate, admin: { ...candidate.admin, readOnly: true } }
825
+ : candidate
767
826
  )}
768
- </label>
769
- <input
770
- id={headingField.id}
771
- class="font-serif mt-3 w-full border-x-0 border-t-0 border-b border-b-transparent bg-transparent p-0 pb-1 text-[34px] leading-[1.08] text-foreground caret-primary outline-none placeholder:text-foreground-soft aria-invalid:border-b-destructive/65"
772
- value={String(form.get(headingField.path) ?? "")}
773
- placeholder={runtime.i18n.t("reference:untitledLabel", {
774
- label: lowerSingularLabel,
775
- })}
776
- required={headingField.required}
777
- readonly={readOnly || form.submitting || !form.canWrite(headingField.path)}
778
- aria-invalid={headingIssues.length > 0}
779
- aria-describedby={headingIssues.length > 0
780
- ? `${headingField.id}-message`
781
- : undefined}
782
- aria-errormessage={headingIssues.length > 0
783
- ? `${headingField.id}-message`
784
- : undefined}
785
- oninput={(event) => form.set(headingField.path, event.currentTarget.value)}
786
- />
787
- <FieldMessages
788
- id="{headingField.id}-message"
789
- issues={headingIssues}
790
- class="mt-2"
827
+ {form}
791
828
  />
792
829
  </div>
793
- {/if}
794
830
 
795
- <div class="mt-7">
796
- <FieldLayout
797
- fields={editorFields.map((candidate) =>
798
- readOnly || form.submitting
799
- ? { ...candidate, admin: { ...candidate.admin, readOnly: true } }
800
- : candidate
801
- )}
802
- {form}
803
- />
804
- </div>
805
-
806
- {#if !readOnly}
807
- <div class="mt-8 flex justify-end">
808
- <Button type="submit" disabled={!canSave}>
809
- {creating
810
- ? runtime.i18n.t("reference:createLabel", { label: lowerSingularLabel })
811
- : runtime.i18n.t("reference:saveLabel", { label: lowerSingularLabel })}
812
- </Button>
813
- </div>
814
- {/if}
831
+ {#if !readOnly}
832
+ <div class="mt-8 flex justify-end">
833
+ <Button type="submit" disabled={!canSave}>
834
+ {creating
835
+ ? runtime.i18n.t("reference:createLabel", { label: lowerSingularLabel })
836
+ : runtime.i18n.t("reference:saveLabel", { label: lowerSingularLabel })}
837
+ </Button>
838
+ </div>
839
+ {/if}
840
+ </fieldset>
815
841
  </form>
816
842
  {/if}
817
843
  </div>
@@ -42,10 +42,10 @@
42
42
  );
43
43
 
44
44
  function selectedLabel(field: SchemaField, value: string) {
45
- const choice = field.select?.choices.find((candidate) => candidate.value === value);
46
- return choice === undefined
45
+ const option = field.select?.options.find((candidate) => candidate.value === value);
46
+ return option === undefined
47
47
  ? i18n.t("uploads:chooseValue")
48
- : i18n.text(choice.label, choice.labelTranslations);
48
+ : i18n.text(option.label, option.labelTranslations);
49
49
  }
50
50
 
51
51
  function controlLabel(field: SchemaField) {
@@ -83,10 +83,10 @@
83
83
  </SelectTrigger>
84
84
  <SelectContent>
85
85
  <SelectItem value="" label={i18n.t("uploads:chooseValue")} />
86
- {#each entry.field.select?.choices ?? [] as choice (choice.value)}
86
+ {#each entry.field.select?.options ?? [] as option (option.value)}
87
87
  <SelectItem
88
- value={choice.value}
89
- label={i18n.text(choice.label, choice.labelTranslations)}
88
+ value={option.value}
89
+ label={i18n.text(option.label, option.labelTranslations)}
90
90
  />
91
91
  {/each}
92
92
  </SelectContent>
@@ -480,7 +480,7 @@
480
480
  {runtime.i18n.t("versions:publishDateTime")}
481
481
  <DateValueControl
482
482
  id="scheduled-publish-at"
483
- appearance="dayAndTime"
483
+ appearance="date-time"
484
484
  value={scheduleAt}
485
485
  label={runtime.i18n.t("versions:publishDateTime")}
486
486
  onValueChange={(next) => (scheduleAt = next)}
@@ -0,0 +1,74 @@
1
+ <script lang="ts">
2
+ import { getAdminI18n, type EmbeddedSchemaDraftEditorProps } from "@riducms/plugin";
3
+ import type { HostedSchemaDraft } from "@admin/core/forms/embedded-schema-draft.svelte";
4
+ import { Sheet, SheetContent, SheetTitle, SheetDescription } from "@admin/components/ui/sheet";
5
+ import { Button } from "@admin/components/ui/button";
6
+ import { focusFieldIssue } from "@admin/core/forms/field-issue-focus";
7
+ import FieldLayout from "@admin/fields/field-layout.svelte";
8
+ import { onDestroy } from "svelte";
9
+
10
+ let {
11
+ session,
12
+ options,
13
+ }: { session: HostedSchemaDraft; options: EmbeddedSchemaDraftEditorProps } = $props();
14
+ // A mounted drawer owns exactly one detached edit and releases its parent blocker.
15
+ // svelte-ignore state_referenced_locally
16
+ const draft = session.draft;
17
+ const i18n = getAdminI18n();
18
+ onDestroy(() => draft.discard());
19
+
20
+ function cancel() {
21
+ draft.discard();
22
+ options.onCancel();
23
+ }
24
+ async function apply() {
25
+ if (!draft.validate()) {
26
+ const issue = draft.issues[0];
27
+ if (issue !== undefined) await focusFieldIssue(issue.path);
28
+ return;
29
+ }
30
+ options.onApply(draft.payload());
31
+ draft.discard();
32
+ }
33
+ </script>
34
+
35
+ <Sheet
36
+ open
37
+ onOpenChange={(open) => {
38
+ if (!open) cancel();
39
+ }}
40
+ >
41
+ <SheetContent
42
+ class="w-full sm:max-w-2xl"
43
+ showCloseButton={false}
44
+ onOpenAutoFocus={async (event) => {
45
+ const issue = draft.issues[0];
46
+ if (issue !== undefined) {
47
+ event.preventDefault();
48
+ await focusFieldIssue(issue.path);
49
+ }
50
+ }}
51
+ >
52
+ <div class="border-b border-border px-6 py-4">
53
+ <SheetTitle>{options.title ?? i18n.t("fields:embeddedEditTitle")}</SheetTitle>
54
+ <SheetDescription>{i18n.t("fields:embeddedEditDescription")}</SheetDescription>
55
+ </div>
56
+ <div class="min-h-0 flex-1 overflow-y-auto px-6 py-5" data-schema-draft={draft.id}>
57
+ {#if draft.stale}
58
+ <p role="alert" class="mb-4 text-destructive">
59
+ {i18n.t("errors:staleEmbeddedEdit")}
60
+ </p>
61
+ {/if}
62
+ {#if draft.issues.length > 0}
63
+ <div role="alert" class="mb-4 text-destructive">
64
+ {#each draft.issues as issue}<p>{issue.message}</p>{/each}
65
+ </div>
66
+ {/if}
67
+ {#if !draft.stale}<FieldLayout fields={session.fields} form={session.form} />{/if}
68
+ </div>
69
+ <div class="flex justify-end gap-2 border-t border-border px-6 py-4">
70
+ <Button variant="outline" onclick={cancel}>{i18n.t("general:cancel")}</Button>
71
+ <Button onclick={apply} disabled={draft.stale}>{i18n.t("general:apply")}</Button>
72
+ </div>
73
+ </SheetContent>
74
+ </Sheet>
@@ -0,0 +1,44 @@
1
+ <script lang="ts">
2
+ import type { EmbeddedSchemaFormScope } from "@riducms/plugin";
3
+ import type { SchemaField, ValidationIssue } from "@riducms/protocol";
4
+ import type { FormController } from "@admin/core/forms/form-controller.svelte";
5
+ import type { EmbeddedOccurrence } from "@admin/core/forms/embedded-fields";
6
+ import { fieldAccessPath, scopeRepeatedRowField } from "@admin/fields/nested/scoped-field";
7
+ import FieldLayout from "@admin/fields/field-layout.svelte";
8
+
9
+ let {
10
+ field,
11
+ form,
12
+ scope,
13
+ occurrence,
14
+ issues,
15
+ }: {
16
+ field: SchemaField;
17
+ form: FormController;
18
+ scope: EmbeddedSchemaFormScope;
19
+ occurrence: EmbeddedOccurrence | undefined;
20
+ issues: readonly ValidationIssue[];
21
+ } = $props();
22
+ const children = $derived(
23
+ occurrence?.block.fields.map((child) =>
24
+ scopeRepeatedRowField(
25
+ child,
26
+ occurrence.path,
27
+ `${field.id}-${scope.treeKey}-${scope.identity}`,
28
+ scope.readOnly === true ||
29
+ field.admin.readOnly === true ||
30
+ !form.canWrite(field.path, fieldAccessPath(field))
31
+ )
32
+ ) ?? []
33
+ );
34
+ </script>
35
+
36
+ {#if issues.length > 0}
37
+ <p role="alert">{issues[0]?.message}</p>
38
+ {:else if occurrence === undefined}
39
+ <p role="alert">
40
+ The embedded field occurrence is no longer available. Close this editor and reopen it.
41
+ </p>
42
+ {:else}
43
+ <FieldLayout fields={children} {form} />
44
+ {/if}