@riducms/admin 0.1.5 → 0.2.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.
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,6 @@
1
1
  <script lang="ts">
2
2
  import type { SchemaField } from "@riducms/protocol";
3
+ import { fieldControlARIA } from "@riducms/ui";
3
4
 
4
5
  import { Input } from "@admin/components/ui/input";
5
6
  import type { FormController } from "@admin/core/forms/form-controller.svelte";
@@ -12,10 +13,15 @@
12
13
  Array.isArray(form.get(field.path)) ? (form.get(field.path) as number[]) : []
13
14
  );
14
15
  const issues = $derived(form.issuesFor(field.path));
16
+ const editingBlocked = $derived(field.admin.readOnly === true || form.editingBlocked);
17
+ const controlARIA = $derived(
18
+ fieldControlARIA(field.id, field.admin.description !== undefined, issues.length > 0)
19
+ );
15
20
 
16
21
  $effect(() => form.register(field.path));
17
22
 
18
23
  function setCoordinate(index: number, next: number) {
24
+ if (editingBlocked) return;
19
25
  const coordinates = [Number(value[0] ?? 0), Number(value[1] ?? 0)];
20
26
  coordinates[index] = next;
21
27
  form.set(field.path, coordinates);
@@ -27,24 +33,28 @@
27
33
  <label class="grid gap-1.5 text-[12px] text-foreground-muted">
28
34
  {runtime.i18n.t("fields:longitude")}
29
35
  <Input
36
+ id={field.id}
30
37
  type="number"
31
38
  min="-180"
32
39
  max="180"
33
40
  step="any"
34
41
  value={value[0] ?? ""}
35
- readonly={field.admin.readOnly}
42
+ readonly={editingBlocked}
43
+ {...controlARIA}
36
44
  oninput={(event) => setCoordinate(0, event.currentTarget.valueAsNumber)}
37
45
  />
38
46
  </label>
39
47
  <label class="grid gap-1.5 text-[12px] text-foreground-muted">
40
48
  {runtime.i18n.t("fields:latitude")}
41
49
  <Input
50
+ id={`${field.id}-latitude`}
42
51
  type="number"
43
52
  min="-90"
44
53
  max="90"
45
54
  step="any"
46
55
  value={value[1] ?? ""}
47
- readonly={field.admin.readOnly}
56
+ readonly={editingBlocked}
57
+ {...controlARIA}
48
58
  oninput={(event) => setCoordinate(1, event.currentTarget.valueAsNumber)}
49
59
  />
50
60
  </label>
@@ -0,0 +1,170 @@
1
+ <script lang="ts">
2
+ import { tick } from "svelte";
3
+ import type { SchemaField } from "@riducms/protocol";
4
+ import { fieldControlARIA } from "@riducms/ui";
5
+ import ArrowUpIcon from "~icons/lucide/arrow-up";
6
+ import ArrowDownIcon from "~icons/lucide/arrow-down";
7
+ import XIcon from "~icons/lucide/x";
8
+ import { Button } from "@admin/components/ui/button";
9
+ import { Input } from "@admin/components/ui/input";
10
+ import type { FormController } from "@admin/core/forms/form-controller.svelte";
11
+ import { getAdminI18n } from "@riducms/plugin";
12
+ import FieldShell from "@admin/fields/field-shell.svelte";
13
+ import { primitiveNumberInput } from "@admin/fields/primitive-list/primitive-number-input";
14
+
15
+ let { field, form }: { field: SchemaField; form: FormController } = $props();
16
+ const i18n = getAdminI18n();
17
+
18
+ $effect(() => form.register(field.path));
19
+ const values = $derived.by(() => {
20
+ const value = form.get(field.path);
21
+ return Array.isArray(value) ? value : [];
22
+ });
23
+ const issues = $derived(form.issuesFor(field.path));
24
+ const editingBlocked = $derived(field.admin.readOnly === true || form.editingBlocked);
25
+ const canAdd = $derived(
26
+ !editingBlocked && (!field.list?.maxRows || values.length < field.list.maxRows)
27
+ );
28
+ const minimum = $derived(Math.max(field.required ? 1 : 0, field.list?.minRows ?? 0));
29
+ const feedbackARIA = $derived(
30
+ fieldControlARIA(field.id, field.admin.description !== undefined, issues.length > 0)
31
+ );
32
+ const controlARIA = $derived({
33
+ ...feedbackARIA,
34
+ "aria-describedby": [`${field.id}-count`, feedbackARIA["aria-describedby"]]
35
+ .filter(Boolean)
36
+ .join(" "),
37
+ });
38
+ let container: HTMLDivElement;
39
+ let announcement = $state("");
40
+ // Only DOM identity lives here. The form owns all values, including unfinished numeric input.
41
+ let keys = $state<string[]>([]);
42
+ const itemKey = (index: number) => keys[index] ?? `initial-${index}`;
43
+
44
+ async function focusItem(index: number) {
45
+ // Insertion/removal must render before moving focus to the surviving input or Add button.
46
+ await tick();
47
+ container.querySelector<HTMLInputElement>(`[data-list-item="${index}"]`)?.focus();
48
+ if (index < 0) container.querySelector<HTMLButtonElement>("[data-list-add]")?.focus();
49
+ }
50
+ function update(next: unknown[], nextKeys = values.map((_, index) => itemKey(index))) {
51
+ if (editingBlocked) return;
52
+ keys = nextKeys;
53
+ form.set(field.path, next);
54
+ }
55
+ async function add() {
56
+ if (!canAdd) return;
57
+ const index = values.length;
58
+ update([...values, ""], [...values.map((_, index) => itemKey(index)), crypto.randomUUID()]);
59
+ announcement = i18n.t("fields:listAdded", { number: index + 1 });
60
+ await focusItem(index);
61
+ }
62
+ function edit(index: number, value: string) {
63
+ if (editingBlocked) return;
64
+ const next = [...values];
65
+ next[index] = field.type === "number-list" ? primitiveNumberInput(value) : value;
66
+ update(next);
67
+ }
68
+ async function remove(index: number) {
69
+ if (editingBlocked) return;
70
+ const next = values.filter((_, at) => at !== index);
71
+ update(
72
+ next,
73
+ values.map((_, at) => itemKey(at)).filter((_, at) => at !== index)
74
+ );
75
+ announcement = i18n.t("fields:listRemoved", { number: index + 1 });
76
+ await focusItem(Math.min(index, next.length - 1));
77
+ }
78
+ async function move(index: number, direction: -1 | 1) {
79
+ const destination = index + direction;
80
+ if (editingBlocked || destination < 0 || destination >= values.length) return;
81
+ const next = [...values];
82
+ const nextKeys = values.map((_, at) => itemKey(at));
83
+ [next[index], next[destination]] = [next[destination], next[index]];
84
+ [nextKeys[index], nextKeys[destination]] = [nextKeys[destination]!, nextKeys[index]!];
85
+ update(next, nextKeys);
86
+ announcement = i18n.t("fields:listMoved", { from: index + 1, to: destination + 1 });
87
+ await focusItem(destination);
88
+ }
89
+ function keyboard(event: KeyboardEvent, index: number) {
90
+ if (!event.altKey || (event.key !== "ArrowUp" && event.key !== "ArrowDown")) return;
91
+ event.preventDefault();
92
+ return move(index, event.key === "ArrowUp" ? -1 : 1);
93
+ }
94
+ </script>
95
+
96
+ <FieldShell {field} {issues}>
97
+ <div bind:this={container} class="grid gap-2">
98
+ <p id="{field.id}-count" class="text-xs text-foreground-muted">
99
+ {i18n.t("fields:itemCount", { count: values.length })}
100
+ {#if minimum > 0}
101
+ · {i18n.t("fields:minimum", { count: minimum })}{/if}
102
+ {#if field.list?.maxRows}
103
+ · {i18n.t("fields:maximum", { count: field.list.maxRows })}{/if}
104
+ </p>
105
+ {#if values.length === 0}<p class="text-sm text-foreground-muted">
106
+ {i18n.t("fields:listEmpty")}
107
+ </p>{/if}
108
+ <ol class="grid gap-2" aria-label={field.admin.label}>
109
+ {#each values as value, index (itemKey(index))}
110
+ <li class="flex items-center gap-1">
111
+ <Input
112
+ id={index === 0 ? field.id : `${field.id}-item-${itemKey(index)}`}
113
+ data-list-item={index}
114
+ aria-label={i18n.t("fields:listItem", {
115
+ label: field.admin.label,
116
+ number: index + 1,
117
+ })}
118
+ {...controlARIA}
119
+ inputmode={field.type === "number-list" ? "decimal" : undefined}
120
+ placeholder={field.admin.placeholder}
121
+ value={typeof value === "string" || typeof value === "number" ? String(value) : ""}
122
+ readonly={editingBlocked}
123
+ oninput={(event) => edit(index, event.currentTarget.value)}
124
+ onkeydown={(event) => keyboard(event, index)}
125
+ />
126
+ <Button
127
+ variant="ghost"
128
+ size="icon"
129
+ aria-label={i18n.t("fields:listMoveUp", { number: index + 1 })}
130
+ disabled={editingBlocked || index === 0}
131
+ onclick={() => move(index, -1)}
132
+ >
133
+ <ArrowUpIcon class="size-4" />
134
+ </Button>
135
+ <Button
136
+ variant="ghost"
137
+ size="icon"
138
+ aria-label={i18n.t("fields:listMoveDown", { number: index + 1 })}
139
+ disabled={editingBlocked || index === values.length - 1}
140
+ onclick={() => move(index, 1)}
141
+ >
142
+ <ArrowDownIcon class="size-4" />
143
+ </Button>
144
+ <Button
145
+ variant="ghost"
146
+ size="icon"
147
+ aria-label={i18n.t("fields:listRemove", { number: index + 1 })}
148
+ disabled={editingBlocked}
149
+ onclick={() => remove(index)}
150
+ >
151
+ <XIcon class="size-4" />
152
+ </Button>
153
+ </li>
154
+ {/each}
155
+ </ol>
156
+ <Button
157
+ id={values.length === 0 ? field.id : undefined}
158
+ data-list-add
159
+ aria-label={i18n.t("fields:listAdd")}
160
+ {...controlARIA}
161
+ variant="outline"
162
+ class="w-fit"
163
+ disabled={!canAdd}
164
+ onclick={add}
165
+ >
166
+ {i18n.t("fields:listAdd")}
167
+ </Button>
168
+ <p class="sr-only" role="status">{announcement}</p>
169
+ </div>
170
+ </FieldShell>
@@ -0,0 +1,6 @@
1
+ /** Keep unfinished input in the unsaved form so collapse/remount cannot discard it. */
2
+ export function primitiveNumberInput(input: string): number | string {
3
+ if (!/^[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(input)) return input;
4
+ const value = Number(input);
5
+ return Number.isFinite(value) ? value : input;
6
+ }
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { SchemaField } from "@riducms/protocol";
3
+ import { fieldControlARIA } from "@riducms/ui";
3
4
 
4
5
  import { RadioCardItem, RadioGroup } from "@admin/components/ui/radio-group";
5
6
  import type { FormController } from "@admin/core/forms/form-controller.svelte";
@@ -8,7 +9,10 @@
8
9
  let { field, form }: { field: SchemaField; form: FormController } = $props();
9
10
  const value = $derived(String(form.get(field.path) ?? ""));
10
11
  const issues = $derived(form.issuesFor(field.path));
11
- const hasMessage = $derived(issues.length > 0 || field.admin.description !== undefined);
12
+ const editingBlocked = $derived(field.admin.readOnly === true || form.editingBlocked);
13
+ const controlARIA = $derived(
14
+ fieldControlARIA(field.id, field.admin.description !== undefined, issues.length > 0)
15
+ );
12
16
 
13
17
  $effect(() => form.register(field.path));
14
18
  </script>
@@ -19,16 +23,16 @@
19
23
  class="flex flex-wrap gap-x-7 gap-y-1"
20
24
  name={field.path}
21
25
  {value}
22
- required={field.required}
23
- readonly={field.admin.readOnly}
26
+ required={field.required && (!field.dynamicDefault || form.get(field.path) !== undefined)}
27
+ readonly={editingBlocked}
24
28
  aria-labelledby={`${field.id}-label`}
25
- aria-invalid={issues.length > 0}
26
- aria-describedby={hasMessage ? `${field.id}-message` : undefined}
27
- aria-errormessage={issues.length > 0 ? `${field.id}-message` : undefined}
28
- onValueChange={(next) => form.set(field.path, next)}
29
+ {...controlARIA}
30
+ onValueChange={(next) => {
31
+ if (!editingBlocked) form.set(field.path, next);
32
+ }}
29
33
  >
30
- {#each field.select?.choices ?? [] as choice (choice.value)}
31
- <RadioCardItem value={choice.value} label={choice.label} />
34
+ {#each field.select?.options ?? [] as option (option.value)}
35
+ <RadioCardItem value={option.value} label={option.label} />
32
36
  {/each}
33
37
  </RadioGroup>
34
38
  </FieldShell>
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { FieldAuthoringHost } from "@riducms/plugin";
3
3
  import type { SchemaField } from "@riducms/protocol";
4
+ import { fieldControlARIA } from "@riducms/ui";
4
5
  import FileIcon from "~icons/lucide/file";
5
6
  import PencilIcon from "~icons/lucide/pencil";
6
7
  import PlusIcon from "~icons/lucide/plus";
@@ -59,9 +60,12 @@
59
60
  selectTarget,
60
61
  setBrowserOpen,
61
62
  } = controller;
62
- const hasMessage = $derived(issues.length > 0 || field.admin.description !== undefined);
63
+ const controlARIA = $derived(
64
+ fieldControlARIA(field.id, field.admin.description !== undefined, issues.length > 0)
65
+ );
66
+ const editingBlocked = $derived(field.admin.readOnly === true || form.editingBlocked);
63
67
  const optionFilter = $derived(
64
- relationshipOptionFilters(field, form.values, targetCollection?.slug)
68
+ relationshipOptionFilters(field, (path) => form.get(path), targetCollection?.slug)
65
69
  );
66
70
  const quickPickerTargets = $derived(
67
71
  targets.flatMap((target) => {
@@ -73,18 +77,20 @@
73
77
  : [
74
78
  {
75
79
  collection: localizeSchemaCollection(collection, runtime.i18n),
76
- filter: relationshipOptionFilters(field, form.values, collection.slug),
80
+ filter: relationshipOptionFilters(field, (path) => form.get(path), collection.slug),
77
81
  },
78
82
  ];
79
83
  })
80
84
  );
81
85
 
82
86
  function pickRelationship(target: string, id: string) {
87
+ if (editingBlocked) return;
83
88
  selectTarget(target);
84
89
  commit([id]);
85
90
  }
86
91
 
87
92
  function browseRelationships(target: string) {
93
+ if (form.editingBlocked) return;
88
94
  selectTarget(target);
89
95
  openBrowser();
90
96
  }
@@ -101,13 +107,17 @@
101
107
  {:else if field.upload !== undefined && !hasMany && selectedID !== undefined}
102
108
  {const document = documents[selectedID]}
103
109
  <div
104
- class="flex min-h-17 items-center gap-2.5 rounded-[4px] border border-control-border bg-control p-2"
110
+ class="flex min-h-17 items-center gap-2.5 rounded-[4px] border border-control-border bg-control p-2 aria-invalid:!border-destructive/65"
111
+ aria-invalid={issues.length > 0}
105
112
  >
106
113
  <button
114
+ id={field.id}
107
115
  type="button"
116
+ disabled={form.editingBlocked}
108
117
  class="group flex min-w-0 flex-1 items-center gap-2.5 rounded-[3px] text-start outline-none focus-visible:ring-2 focus-visible:ring-ring/70"
109
118
  onclick={() => openBrowser(selectedID)}
110
119
  aria-label={runtime.i18n.t("fields:edit", { label: label(selectedID) })}
120
+ {...controlARIA}
111
121
  >
112
122
  <span
113
123
  class="grid size-14 shrink-0 place-items-center overflow-hidden rounded-[3px] border border-control-border bg-background"
@@ -137,11 +147,16 @@
137
147
  </button>
138
148
  {#if !field.admin.readOnly}
139
149
  <div class="flex shrink-0 flex-wrap justify-end gap-1">
140
- <Button variant="outline" size="xs" onclick={() => openBrowser()}>
150
+ <Button
151
+ variant="outline"
152
+ size="xs"
153
+ disabled={form.editingBlocked}
154
+ onclick={() => openBrowser()}
155
+ >
141
156
  <SearchIcon class="size-3" />
142
157
  {runtime.i18n.t("general:replace")}
143
158
  </Button>
144
- <Button variant="ghost" size="xs" onclick={() => commit([])}>
159
+ <Button variant="ghost" size="xs" disabled={editingBlocked} onclick={() => commit([])}>
145
160
  <XIcon class="size-3" />
146
161
  {runtime.i18n.t("general:remove")}
147
162
  </Button>
@@ -160,27 +175,31 @@
160
175
  selectedInitials={selectedID === undefined ? undefined : initials(selectedID)}
161
176
  placeholder={field.admin.placeholder}
162
177
  readOnly={field.admin.readOnly}
178
+ blocked={form.editingBlocked}
163
179
  locale={form.contentLocale}
164
180
  upload={field.upload !== undefined}
165
181
  invalid={issues.length > 0}
166
- describedBy={hasMessage ? `${field.id}-message` : undefined}
182
+ hasDescription={field.admin.description !== undefined}
167
183
  onPick={pickRelationship}
168
- onRemove={() => commit([])}
184
+ onRemove={() => {
185
+ if (!editingBlocked) commit([]);
186
+ }}
169
187
  onBrowse={browseRelationships}
170
188
  onEdit={selectedID === undefined ? undefined : () => openBrowser(selectedID)}
171
189
  />
172
190
  {:else}
173
191
  <div
174
- class="flex min-h-10 min-w-0 rounded-[3px] border border-control-border bg-control transition-colors hover:border-control-border-hover aria-invalid:border-destructive/65"
192
+ class="flex min-h-10 min-w-0 rounded-[3px] border border-control-border bg-control transition-colors hover:border-control-border-hover aria-invalid:!border-destructive/65"
175
193
  aria-invalid={issues.length > 0}
176
194
  >
177
195
  {#if selectedIDs.length === 0}
178
196
  <button
179
197
  id={field.id}
180
198
  type="button"
199
+ disabled={form.editingBlocked}
181
200
  class="flex min-h-10 min-w-0 flex-1 items-center px-3 text-start text-[13px] text-foreground-placeholder outline-none hover:text-foreground focus-visible:text-primary"
182
201
  onclick={() => openBrowser()}
183
- aria-describedby={hasMessage ? `${field.id}-message` : undefined}
202
+ {...controlARIA}
184
203
  >
185
204
  <span class="px-1.5 text-[13px] text-foreground-placeholder">
186
205
  {field.admin.placeholder ??
@@ -197,6 +216,7 @@
197
216
  >
198
217
  <button
199
218
  type="button"
219
+ disabled={form.editingBlocked}
200
220
  class="min-w-0 flex-1 truncate text-start outline-none hover:text-foreground focus-visible:text-primary"
201
221
  onclick={() => openBrowser(id)}
202
222
  aria-label={runtime.i18n.t("fields:edit", { label: label(id) })}
@@ -207,6 +227,7 @@
207
227
  <Button
208
228
  variant="ghost"
209
229
  size="icon-xs"
230
+ disabled={editingBlocked}
210
231
  onclick={() => remove(id)}
211
232
  aria-label={runtime.i18n.t("fields:remove", { label: label(id) })}
212
233
  >
@@ -221,8 +242,9 @@
221
242
  variant="ghost"
222
243
  size="icon"
223
244
  class="h-auto min-h-10 w-10 rounded-s-none border-s border-control-border"
245
+ disabled={form.editingBlocked}
224
246
  onclick={() => openBrowser()}
225
- aria-describedby={hasMessage ? `${field.id}-message` : undefined}
247
+ {...controlARIA}
226
248
  aria-label={runtime.i18n.t("fields:browse", {
227
249
  label: targetCollection.labels.plural.toLocaleLowerCase(runtime.i18n.language),
228
250
  })}
@@ -252,7 +274,9 @@
252
274
  {initialDocumentID}
253
275
  {optionFilter}
254
276
  locale={form.contentLocale}
255
- onCommit={commit}
277
+ onCommit={(ids) => {
278
+ if (!editingBlocked) commit(ids);
279
+ }}
256
280
  onClose={() => setBrowserOpen(false)}
257
281
  />
258
282
  {/if}
@@ -33,7 +33,7 @@ export function combineRelationshipFilters(
33
33
 
34
34
  export function relationshipOptionFilters(
35
35
  field: SchemaField,
36
- values: Record<string, unknown>,
36
+ values: Record<string, unknown> | ((path: string) => unknown),
37
37
  targetCollection: string | undefined
38
38
  ): readonly FieldReferenceFilter[] | undefined {
39
39
  const reference = field.relationship ?? field.upload;
@@ -46,7 +46,9 @@ export function relationshipOptionFilters(
46
46
  candidate.value === undefined
47
47
  ? candidate.sourcePath === undefined
48
48
  ? undefined
49
- : readPath(values, candidate.sourcePath)
49
+ : typeof values === "function"
50
+ ? values(candidate.sourcePath)
51
+ : readPath(values, candidate.sourcePath)
50
52
  : relationshipFilterLiteral(candidate.value);
51
53
  if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
52
54
  return [];
@@ -7,7 +7,7 @@
7
7
  import PencilIcon from "~icons/lucide/pencil";
8
8
  import PlusIcon from "~icons/lucide/plus";
9
9
  import XIcon from "~icons/lucide/x";
10
- import { TooltipContent, TooltipRoot, TooltipTrigger } from "@riducms/ui";
10
+ import { fieldControlARIA, TooltipContent, TooltipRoot, TooltipTrigger } from "@riducms/ui";
11
11
 
12
12
  import { Skeleton } from "@admin/components/ui/skeleton";
13
13
  import { getAdminRuntime } from "@admin/core/runtime/admin-runtime.svelte";
@@ -27,6 +27,7 @@
27
27
  selectedInitials,
28
28
  placeholder,
29
29
  readOnly = false,
30
+ blocked = false,
30
31
  locale,
31
32
  onPick,
32
33
  onRemove,
@@ -34,7 +35,7 @@
34
35
  onEdit,
35
36
  upload = false,
36
37
  invalid = false,
37
- describedBy,
38
+ hasDescription = false,
38
39
  }: {
39
40
  id: string;
40
41
  label: string;
@@ -45,6 +46,7 @@
45
46
  selectedInitials?: string;
46
47
  placeholder?: string;
47
48
  readOnly?: boolean;
49
+ blocked?: boolean;
48
50
  locale?: string;
49
51
  onPick: (target: string, id: string) => void;
50
52
  onRemove: () => void;
@@ -52,7 +54,7 @@
52
54
  onEdit?: () => void;
53
55
  upload?: boolean;
54
56
  invalid?: boolean;
55
- describedBy?: string;
57
+ hasDescription?: boolean;
56
58
  } = $props();
57
59
 
58
60
  const runtime = getAdminRuntime();
@@ -77,7 +79,7 @@
77
79
  const { comboboxItems, groups, open, query, selectedValue } = $derived(controller);
78
80
  const {
79
81
  browseAll,
80
- changeOpen,
82
+ changeOpen: setOpen,
81
83
  documentLabel,
82
84
  initials,
83
85
  pick,
@@ -92,31 +94,52 @@
92
94
  const searchLabel = $derived(
93
95
  targets.length === 1 ? (selectedCollection?.labels.plural ?? label) : label
94
96
  );
97
+ const controlARIA = $derived(fieldControlARIA(id, hasDescription, invalid));
98
+ const editingBlocked = $derived(readOnly || blocked);
95
99
  let triggerElement = $state<HTMLButtonElement | null>(null);
96
100
  let searchInput = $state<HTMLInputElement | null>(null);
97
101
 
102
+ $effect(() => {
103
+ if (editingBlocked) setOpen(false);
104
+ });
105
+
106
+ function changePickerOpen(next: boolean) {
107
+ if (editingBlocked && next) return;
108
+ setOpen(next);
109
+ }
110
+
98
111
  async function togglePicker() {
112
+ if (editingBlocked) return;
99
113
  if (!toggleOpen()) return;
100
114
  await tick();
101
115
  searchInput?.focus();
102
116
  }
103
117
 
104
118
  function beginSearch(event: Event) {
119
+ if (editingBlocked) return;
105
120
  setQuery((event.currentTarget as HTMLInputElement).value);
106
121
  }
107
122
 
123
+ function choose(value: string) {
124
+ if (editingBlocked) return;
125
+ pick(value);
126
+ }
127
+
108
128
  function remove(event: MouseEvent) {
109
129
  event.stopPropagation();
130
+ if (editingBlocked) return;
110
131
  removeSelection();
111
132
  }
112
133
 
113
134
  function browse(event: MouseEvent, target?: string) {
114
135
  event.stopPropagation();
136
+ if (blocked) return;
115
137
  browseAll(target);
116
138
  }
117
139
 
118
140
  function edit(event: MouseEvent) {
119
141
  event.stopPropagation();
142
+ if (blocked) return;
120
143
  onEdit?.();
121
144
  }
122
145
  </script>
@@ -127,13 +150,13 @@
127
150
  inputValue={query}
128
151
  items={comboboxItems}
129
152
  {open}
130
- onOpenChange={changeOpen}
131
- onValueChange={pick}
153
+ onOpenChange={changePickerOpen}
154
+ onValueChange={choose}
132
155
  allowDeselect={false}
133
156
  loop
134
157
  >
135
158
  <div
136
- class="flex h-10.5 min-w-0 rounded-[3px] border border-control-border bg-control transition-[background-color,border-color] duration-150 hover:bg-background focus-within:border-primary/65 aria-invalid:border-destructive/65"
159
+ class="flex h-10.5 min-w-0 rounded-[3px] border border-control-border bg-control transition-[background-color,border-color] duration-150 hover:bg-background focus-within:border-primary/65 aria-invalid:!border-destructive/65"
137
160
  aria-invalid={invalid}
138
161
  >
139
162
  <button
@@ -141,9 +164,9 @@
141
164
  {id}
142
165
  type="button"
143
166
  class="flex min-w-0 flex-1 items-center gap-2 rounded-s-[3px] px-2.75 text-start outline-none disabled:cursor-not-allowed disabled:opacity-45"
144
- disabled={readOnly}
167
+ disabled={editingBlocked}
145
168
  aria-label={label}
146
- aria-describedby={describedBy}
169
+ {...controlARIA}
147
170
  aria-haspopup="listbox"
148
171
  aria-expanded={open}
149
172
  onclick={togglePicker}
@@ -181,7 +204,7 @@
181
204
  {...props}
182
205
  type="button"
183
206
  class="grid w-9 shrink-0 place-items-center border-s border-control-border text-foreground-sub outline-none transition-colors hover:bg-background hover:text-foreground focus-visible:text-primary disabled:cursor-not-allowed disabled:opacity-45"
184
- disabled={readOnly}
207
+ disabled={blocked}
185
208
  onpointerdown={(event) => event.preventDefault()}
186
209
  onclick={edit}
187
210
  aria-label={runtime.i18n.t("fields:edit", { label: selectedLabel ?? "" })}
@@ -201,6 +224,7 @@
201
224
  <button
202
225
  {...props}
203
226
  type="button"
227
+ disabled={editingBlocked}
204
228
  class="grid w-9 shrink-0 place-items-center border-s border-control-border text-foreground-sub outline-none transition-colors hover:bg-destructive/7 hover:text-destructive focus-visible:text-destructive"
205
229
  onpointerdown={(event) => event.preventDefault()}
206
230
  onclick={remove}
@@ -220,6 +244,7 @@
220
244
  <button
221
245
  {...props}
222
246
  type="button"
247
+ disabled={blocked}
223
248
  class="grid w-9 shrink-0 place-items-center rounded-e-[3px] border-s border-control-border text-foreground-sub outline-none transition-colors hover:bg-background hover:text-foreground focus-visible:text-primary"
224
249
  onpointerdown={(event) => event.preventDefault()}
225
250
  onclick={(event) => browse(event, selectedCollection?.slug)}
@@ -247,6 +272,7 @@
247
272
  >
248
273
  <Combobox.Input
249
274
  bind:ref={searchInput}
275
+ disabled={editingBlocked}
250
276
  aria-label={runtime.i18n.t("fields:searchLabel", { label: searchLabel })}
251
277
  placeholder={runtime.i18n.t("fields:search", {
252
278
  label: searchLabel.toLocaleLowerCase(runtime.i18n.language),
@@ -291,6 +317,7 @@
291
317
  <Combobox.Item
292
318
  value={JSON.stringify([group.collection.slug, document.id])}
293
319
  label={optionLabel}
320
+ disabled={editingBlocked}
294
321
  class="flex w-full cursor-default items-center gap-[9px] rounded-[3px] px-2 py-1.5 text-start text-[12.5px] text-foreground-muted outline-none select-none data-[highlighted]:bg-control data-[selected]:text-foreground-strong"
295
322
  >
296
323
  {#snippet children({ selected })}
@@ -310,6 +337,7 @@
310
337
  {/if}
311
338
  <button
312
339
  type="button"
340
+ disabled={editingBlocked}
313
341
  class="mt-0.5 flex w-full items-center gap-1 px-2 py-1.5 text-start text-[11.5px] text-primary outline-none hover:text-primary-hover focus-visible:text-primary-hover"
314
342
  onpointerdown={(event) => event.preventDefault()}
315
343
  onclick={(event) => browse(event, group.collection.slug)}
@@ -8,7 +8,7 @@ import {
8
8
  type DateValue,
9
9
  type Time,
10
10
  } from "@internationalized/date";
11
- import type { SchemaDatePickerAppearance } from "@riducms/protocol";
11
+ import type { SchemaDateFormat } from "@riducms/protocol";
12
12
 
13
13
  /**
14
14
  * Converts a Ridu wire value into the timezone-free value used by Bits' date
@@ -16,13 +16,13 @@ import type { SchemaDatePickerAppearance } from "@riducms/protocol";
16
16
  */
17
17
  export function datePickerValue(
18
18
  value: unknown,
19
- appearance: SchemaDatePickerAppearance | undefined,
19
+ appearance: SchemaDateFormat | undefined,
20
20
  timeZone = getLocalTimeZone()
21
21
  ): DateValue | undefined {
22
22
  const encoded = String(value ?? "");
23
23
  if (encoded === "") return undefined;
24
24
  try {
25
- if (appearance === "dayAndTime") {
25
+ if (appearance === "date-time") {
26
26
  return toCalendarDateTime(parseAbsolute(encoded, timeZone));
27
27
  }
28
28
  return parseDate(encoded.slice(0, 10));
@@ -34,11 +34,11 @@ export function datePickerValue(
34
34
  /** Converts a Bits date selection to Ridu's canonical wire value. */
35
35
  export function datePickerFormValue(
36
36
  value: DateValue | undefined,
37
- appearance: SchemaDatePickerAppearance | undefined,
37
+ appearance: SchemaDateFormat | undefined,
38
38
  timeZone = getLocalTimeZone()
39
39
  ) {
40
40
  if (value === undefined) return "";
41
- if (appearance === "dayAndTime") {
41
+ if (appearance === "date-time") {
42
42
  return toCalendarDateTime(value).toDate(timeZone).toISOString();
43
43
  }
44
44
  return toCalendarDate(value).toString();