@riducms/admin 0.1.4 → 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
@@ -0,0 +1,335 @@
1
+ import { resolveBlockTypes } from "@riducms/protocol";
2
+ import type {
3
+ AdminI18n,
4
+ EmbeddedSchemaDraft,
5
+ EmbeddedSchemaFormScope,
6
+ EmbeddedSchemaVariantScope,
7
+ } from "@riducms/plugin";
8
+ import type { SchemaField } from "@riducms/protocol";
9
+ import { FormController } from "@admin/core/forms/form-controller.svelte";
10
+ import { embeddedOccurrences } from "@admin/core/forms/embedded-fields";
11
+ import { cloneFormValue, initialFormValues } from "@admin/core/forms/form-schema";
12
+ import { validateFormValues } from "@admin/core/forms/form-validation";
13
+ import { cloneFieldForPaste } from "@admin/fields/field-clipboard";
14
+ import { fieldAccessPath, scopeRepeatedRowField } from "@admin/fields/nested/scoped-field";
15
+ import { indexFieldValues, canonicalIssueTarget } from "@admin/core/forms/form-issue-correlation";
16
+
17
+ /** Rendering state stays private; the plugin receives only the public detached edit. */
18
+ export interface HostedSchemaDraft {
19
+ draft: EmbeddedSchemaDraft;
20
+ form: FormController;
21
+ fields: SchemaField[];
22
+ }
23
+
24
+ export function embeddedVariant(field: SchemaField, scope: EmbeddedSchemaVariantScope) {
25
+ const tree = field.plugin?.embeddedTrees?.find((candidate) => candidate.key === scope.treeKey);
26
+ const branch = tree?.cases.find((candidate) => candidate.tagValue === scope.caseTag);
27
+ const block = resolveBlockTypes(branch).find((candidate) => candidate.slug === scope.variantSlug);
28
+ if (tree === undefined || branch === undefined || block === undefined)
29
+ throw new Error(
30
+ "The embedded schema is no longer available. Reload its configuration before editing."
31
+ );
32
+ return { tree, branch, block };
33
+ }
34
+
35
+ export function copyEmbeddedSchemaPayload(
36
+ field: SchemaField,
37
+ scope: EmbeddedSchemaVariantScope,
38
+ payload: Readonly<Record<string, unknown>>
39
+ ) {
40
+ const { branch, block } = embeddedVariant(field, scope);
41
+ if (payload[branch.discriminator] !== block.slug)
42
+ throw new Error("The copied payload does not match the configured embedded schema.");
43
+ const copy = cloneFormValue(payload) as Record<string, unknown>;
44
+ for (const child of block.fields) {
45
+ if (Object.hasOwn(copy, child.name))
46
+ copy[child.name] = cloneFieldForPaste(child, copy[child.name]);
47
+ }
48
+ copy[branch.identity] = crypto.randomUUID();
49
+ return copy;
50
+ }
51
+
52
+ export function createEmbeddedSchemaDraft(
53
+ parent: FormController,
54
+ getField: () => SchemaField,
55
+ scope: EmbeddedSchemaFormScope | EmbeddedSchemaVariantScope,
56
+ i18n: AdminI18n,
57
+ isActive: () => boolean = () => true
58
+ ): HostedSchemaDraft {
59
+ const field = getField();
60
+ // Rendered fields carry DOM-specific IDs. Detached validation keeps manifest field identity.
61
+ const authoredField =
62
+ indexFieldValues(parent.schemaFields, parent.values).find(
63
+ (location) => location.path === field.path
64
+ )?.schema ?? field;
65
+ if (
66
+ ("readOnly" in scope && scope.readOnly) ||
67
+ parent.submitting ||
68
+ field.admin.readOnly ||
69
+ !parent.canWrite(field.path, fieldAccessPath(field))
70
+ )
71
+ throw new Error("This field is read-only.");
72
+ const revision = parent.revision;
73
+ const locale = parent.contentLocale;
74
+ const signature = JSON.stringify(field.plugin?.embeddedTrees);
75
+ const existing = "identity" in scope;
76
+ const findOccurrence = () =>
77
+ embeddedOccurrences(getField(), parent.get(getField().path)).occurrences.find(
78
+ (occurrence) =>
79
+ occurrence.tree.key === scope.treeKey && existing && occurrence.identity === scope.identity
80
+ );
81
+ const occurrence = existing ? findOccurrence() : undefined;
82
+ if (existing && occurrence === undefined)
83
+ throw new Error("The embedded occurrence no longer exists.");
84
+ const variant =
85
+ occurrence === undefined
86
+ ? embeddedVariant(authoredField, scope as EmbeddedSchemaVariantScope)
87
+ : embeddedVariant(authoredField, {
88
+ treeKey: occurrence.tree.key,
89
+ caseTag: occurrence.case.tagValue,
90
+ variantSlug: occurrence.block.slug,
91
+ });
92
+ const { branch, block } = variant;
93
+ const accessState = () => {
94
+ const currentPath = existing ? findOccurrence()?.path : undefined;
95
+ const canonical = block.fields.map(fieldAccessPath);
96
+ const entries = Object.entries(parent.access?.fields ?? {})
97
+ .filter(
98
+ ([path]) =>
99
+ path === getField().path ||
100
+ path === fieldAccessPath(getField()) ||
101
+ (currentPath !== undefined &&
102
+ (path === currentPath || path.startsWith(`${currentPath}.`))) ||
103
+ canonical.some((prefix) => path === prefix || path.startsWith(`${prefix}.`))
104
+ )
105
+ .map(([path, capability]) => [
106
+ currentPath !== undefined && path.startsWith(currentPath)
107
+ ? "$payload" + path.slice(currentPath.length)
108
+ : path,
109
+ capability,
110
+ ])
111
+ .sort((left, right) => String(left[0]).localeCompare(String(right[0])));
112
+ return JSON.stringify({
113
+ operations: parent.access?.operations,
114
+ mode: parent.accessMode,
115
+ entries,
116
+ });
117
+ };
118
+ const initialAccess = accessState();
119
+ const id = `draft_${crypto.randomUUID().replaceAll("-", "")}`;
120
+ const identity = occurrence?.identity ?? crypto.randomUUID();
121
+ const originalPayload = occurrence === undefined ? undefined : JSON.stringify(occurrence.payload);
122
+ const payload =
123
+ occurrence === undefined
124
+ ? initialFormValues(block.fields, {
125
+ [branch.identity]: identity,
126
+ [branch.discriminator]: block.slug,
127
+ })
128
+ : (cloneFormValue(occurrence.payload) as Record<string, unknown>);
129
+ const form = new FormController({ [id]: payload }, i18n, parent);
130
+ const fields = block.fields.map((child) => scopeRepeatedRowField(child, id, id));
131
+ const container: SchemaField = {
132
+ id,
133
+ name: id,
134
+ path: id,
135
+ type: "group",
136
+ category: "nested",
137
+ required: true,
138
+ unique: false,
139
+ admin: { label: block.labels.singular },
140
+ nested: { fields: block.fields },
141
+ };
142
+ form.reset({ [id]: payload }, [container]);
143
+ form.setResource(parent.resource);
144
+ const rebase = (path: string) =>
145
+ occurrence !== undefined && (path === occurrence.path || path.startsWith(`${occurrence.path}.`))
146
+ ? id + path.slice(occurrence.path.length)
147
+ : path;
148
+ form.setAccess(
149
+ parent.access === undefined
150
+ ? undefined
151
+ : {
152
+ ...parent.access,
153
+ fields: Object.fromEntries(
154
+ Object.entries(parent.access.fields).map(([path, value]) => [rebase(path), value])
155
+ ),
156
+ },
157
+ parent.accessMode
158
+ );
159
+ form.setLocalization(
160
+ locale,
161
+ Object.fromEntries(
162
+ Object.entries(parent.localizationSources).map(([path, value]) => [rebase(path), value])
163
+ )
164
+ );
165
+ const draftTargets = new Map(
166
+ indexFieldValues([container], form.snapshot()).map((location) => [
167
+ location.path,
168
+ location.token,
169
+ ])
170
+ );
171
+ form.issues =
172
+ occurrence === undefined
173
+ ? []
174
+ : parent.issues
175
+ .filter(
176
+ (issue) =>
177
+ issue.path === occurrence.path || issue.path.startsWith(`${occurrence.path}.`)
178
+ )
179
+ .map((issue) => {
180
+ const path = rebase(issue.path);
181
+ return {
182
+ ...issue,
183
+ path,
184
+ ...(issue.target === undefined ? {} : { target: draftTargets.get(path) }),
185
+ };
186
+ });
187
+ let discarded = false;
188
+ // Revocation is synchronous; UI notification waits until any derived read has finished.
189
+ let publishedDiscard = $state(false);
190
+ let invalidated = false;
191
+ const isStale = () =>
192
+ discarded ||
193
+ invalidated ||
194
+ !isActive() ||
195
+ parent.submitting ||
196
+ parent.revision !== revision ||
197
+ parent.contentLocale !== locale ||
198
+ accessState() !== initialAccess ||
199
+ JSON.stringify(getField().plugin?.embeddedTrees) !== signature ||
200
+ getField().admin.readOnly === true ||
201
+ !parent.canWrite(getField().path, fieldAccessPath(getField())) ||
202
+ (existing && JSON.stringify(findOccurrence()?.payload) !== originalPayload);
203
+ const stale = () => {
204
+ // A getter can run inside a Svelte derived expression. Record revocation without
205
+ // mutating reactive form state; retained child capabilities consult this scope.
206
+ if (isStale()) invalidated = true;
207
+ return invalidated;
208
+ };
209
+ form.setEditorScope(() => !stale());
210
+ if (parent.liveValidation.available) {
211
+ const relative = (path: string) => {
212
+ if (!path.startsWith(`${id}.`)) throw new Error("The embedded field is outside this draft.");
213
+ return path.slice(id.length + 1);
214
+ };
215
+ form.configureLiveValidation(async (input, signal) => {
216
+ if (stale()) throw new Error("This embedded schema draft is stale.");
217
+ const nested = input.embedded ?? [];
218
+ const data = input.data[id];
219
+ if (typeof data !== "object" || data === null || Array.isArray(data))
220
+ throw new Error("The embedded payload is unavailable.");
221
+ const result = await parent.requestLiveValidation(
222
+ {
223
+ ...(parent.resource?.id === undefined ? {} : { id: parent.resource.id }),
224
+ data: parent.liveValidationData(),
225
+ fields: nested.length ? input.fields : input.fields.map(relative),
226
+ embedded: [
227
+ {
228
+ field: getField().path,
229
+ treeKey: variant.tree.key,
230
+ caseTag: branch.tagValue,
231
+ variantSlug: block.slug,
232
+ identity,
233
+ data: { ...data, [branch.identity]: identity, [branch.discriminator]: block.slug },
234
+ },
235
+ ...nested.map((scope, index) =>
236
+ index === 0 ? { ...scope, field: relative(scope.field) } : scope
237
+ ),
238
+ ],
239
+ },
240
+ signal
241
+ );
242
+ // Only the innermost host rebases payload-relative feedback into its private form.
243
+ if (nested.length) return result;
244
+ const target = (value: string | undefined) => {
245
+ if (value === undefined) return undefined;
246
+ const canonical = canonicalIssueTarget(value);
247
+ return canonical === undefined ? undefined : JSON.stringify([id, ...JSON.parse(canonical)]);
248
+ };
249
+ return {
250
+ evaluations: result.evaluations.map((evaluation) => ({
251
+ ...evaluation,
252
+ path: `${id}.${evaluation.path}`,
253
+ target: target(evaluation.target),
254
+ issues: evaluation.issues.map((issue) => ({
255
+ ...issue,
256
+ path: `${id}.${issue.path}`,
257
+ target: target(issue.target),
258
+ })),
259
+ })),
260
+ };
261
+ });
262
+ }
263
+ const stop = parent.registerPendingEdit(() =>
264
+ discarded || !isActive() || (!form.dirty && existing)
265
+ ? []
266
+ : [
267
+ {
268
+ code: "pending_embedded_edit",
269
+ path: getField().path,
270
+ message: i18n.t("errors:pendingEmbeddedEdit"),
271
+ },
272
+ ]
273
+ );
274
+ const stopObserve = parent.observe(field.path.split(".")[0]!, () => {
275
+ if (stale()) form.disposeBindings();
276
+ });
277
+ const stopChanges = parent.observeChanges(() => {
278
+ if (stale()) form.disposeBindings();
279
+ else form.refreshLiveValidation();
280
+ });
281
+ const stopLifetime = parent.registerEditorLifetime(() => draft.discard());
282
+ const draft: EmbeddedSchemaDraft = {
283
+ id,
284
+ identity,
285
+ get dirty() {
286
+ return !publishedDiscard && !discarded && (form.dirty || !existing);
287
+ },
288
+ get stale() {
289
+ return publishedDiscard || stale();
290
+ },
291
+ get issues() {
292
+ return form.issues.map((issue) => ({ ...issue }));
293
+ },
294
+ payload: () => {
295
+ if (stale()) throw new Error("This embedded schema draft is stale.");
296
+ return cloneFormValue(form.get(id)) as Record<string, unknown>;
297
+ },
298
+ validate: () => {
299
+ form.liveValidation.suspend();
300
+ if (stale()) {
301
+ form.issues = [
302
+ {
303
+ code: "stale_embedded_edit",
304
+ path: id,
305
+ message: i18n.t("errors:staleEmbeddedEdit"),
306
+ },
307
+ ];
308
+ return false;
309
+ }
310
+ form.issues = [
311
+ ...form.pendingEditIssues(),
312
+ ...validateFormValues([container], form.values, {
313
+ requireMissing: !existing,
314
+ include: (path, canonicalPath) =>
315
+ form.canRead(path, canonicalPath) && form.canWrite(path, canonicalPath),
316
+ i18n,
317
+ }),
318
+ ];
319
+ return form.issues.length === 0;
320
+ },
321
+ discard: () => {
322
+ if (discarded) return;
323
+ discarded = true;
324
+ queueMicrotask(() => {
325
+ publishedDiscard = true;
326
+ stop();
327
+ });
328
+ stopObserve();
329
+ stopChanges();
330
+ stopLifetime();
331
+ form.disposeBindings();
332
+ },
333
+ };
334
+ return { draft, form, fields };
335
+ }
@@ -0,0 +1,47 @@
1
+ import type { FieldAuthoringHost } from "@riducms/plugin";
2
+ import type { FieldEditorProps } from "@riducms/plugin/editor";
3
+ import { cloneFormValue } from "@admin/core/forms/form-schema";
4
+
5
+ type EditorAuthoring = FieldEditorProps["authoring"];
6
+
7
+ /** Stable callable identities, with invocation and async completion bound to one editor mount. */
8
+ export function guardEditorAuthoring(
9
+ authoring: () => FieldAuthoringHost,
10
+ assertActive: () => void
11
+ ): EditorAuthoring {
12
+ const referenceBrowser: EditorAuthoring["referenceBrowser"] = (...args) => {
13
+ assertActive();
14
+ return authoring().referenceBrowser(...args);
15
+ };
16
+ const findDocument: EditorAuthoring["findDocument"] = (...args) => {
17
+ assertActive();
18
+ return authoring()
19
+ .findDocument(...args)
20
+ .then((document) => {
21
+ assertActive();
22
+ return document;
23
+ });
24
+ };
25
+ return {
26
+ get collections() {
27
+ assertActive();
28
+ return cloneFormValue(authoring().collections) as EditorAuthoring["collections"];
29
+ },
30
+ get documentRevision() {
31
+ assertActive();
32
+ return authoring().documentRevision;
33
+ },
34
+ get locale() {
35
+ assertActive();
36
+ return authoring().locale;
37
+ },
38
+ get referenceBrowser() {
39
+ assertActive();
40
+ return referenceBrowser;
41
+ },
42
+ get findDocument() {
43
+ assertActive();
44
+ return findDocument;
45
+ },
46
+ };
47
+ }
@@ -0,0 +1,228 @@
1
+ import { cloneSchemaField } from "@riducms/protocol";
2
+ import type {
3
+ FieldBinding,
4
+ FieldEditorProps,
5
+ FieldEditorType,
6
+ FieldEditorValue,
7
+ } from "@riducms/plugin/editor";
8
+ import type { SchemaField } from "@riducms/protocol";
9
+ import { fieldControlARIA } from "@riducms/ui";
10
+ import type { FormController } from "@admin/core/forms/form-controller.svelte";
11
+ import { fieldAccessPath } from "@admin/fields/nested/scoped-field";
12
+ import { cloneFormValue } from "@admin/core/forms/form-schema";
13
+ import { evaluateFieldCondition } from "@admin/core/forms/field-condition";
14
+ import { captureFieldOccurrence, type FieldOccurrence } from "@admin/core/forms/field-occurrence";
15
+
16
+ /** The host owns this capability until unmount or an invalidating form transition. */
17
+ export class FieldEditorBinding<Type extends FieldEditorType> implements FieldBinding<Type> {
18
+ #form: FormController;
19
+ #schema: () => SchemaField;
20
+ #occurrence: FieldOccurrence;
21
+ #path: string;
22
+ #stale = false;
23
+ #stopPath: () => void;
24
+ #stopObserve: () => void;
25
+ #stopLifetime: () => void;
26
+ #epoch: number;
27
+ #resource: string;
28
+ #locale: string | undefined;
29
+ #type: Type;
30
+ #schemaID: string;
31
+ #schemaVersion: () => unknown;
32
+ #initialSchemaVersion: unknown;
33
+ readonly form: FieldEditorProps["form"];
34
+
35
+ constructor(
36
+ form: FormController,
37
+ schema: () => SchemaField,
38
+ type: Type,
39
+ schemaVersion: () => unknown = () => form.editorEpoch
40
+ ) {
41
+ this.#form = form;
42
+ this.#schemaID = schema().id;
43
+ this.#schemaVersion = schemaVersion;
44
+ this.#initialSchemaVersion = schemaVersion();
45
+ this.#schema = schema;
46
+ this.#type = type;
47
+ this.#epoch = form.editorEpoch;
48
+ this.#resource = JSON.stringify(form.resource);
49
+ this.#locale = form.contentLocale;
50
+ this.#path = schema().path;
51
+ this.#occurrence = captureFieldOccurrence(form, this.#path);
52
+ this.#stopPath = form.register(this.#path);
53
+ this.#stopObserve = form.observe(this.#path.split(".")[0]!, () => this.#resolve());
54
+ this.#stopLifetime = form.registerEditorLifetime(this.destroy);
55
+ const binding = this;
56
+ this.form = {
57
+ get contentLocale() {
58
+ binding.assertActive();
59
+ return binding.#locale;
60
+ },
61
+ get resource() {
62
+ binding.assertActive();
63
+ return form.resource === undefined ? undefined : { ...form.resource };
64
+ },
65
+ get: (path) => {
66
+ this.assertActive();
67
+ return cloneFormValue(form.get(path));
68
+ },
69
+ issuesFor: (path) => {
70
+ this.assertActive();
71
+ return form.issuesFor(path).map((issue) => ({ ...issue }));
72
+ },
73
+ snapshot: () => {
74
+ this.assertActive();
75
+ return form.snapshot();
76
+ },
77
+ };
78
+ }
79
+
80
+ get schema(): SchemaField & { type: Type } {
81
+ // A revoked host can render once before its keyed subtree unmounts. Metadata
82
+ // remains a detached snapshot; values and writes stay revoked immediately.
83
+ this.#resolve();
84
+ return {
85
+ ...cloneSchemaField(this.#schema()),
86
+ type: this.#type,
87
+ path: this.#path,
88
+ };
89
+ }
90
+ get stale() {
91
+ this.#resolve();
92
+ return this.#stale;
93
+ }
94
+ get value(): FieldEditorValue<Type> | null | undefined {
95
+ if (this.stale) return undefined;
96
+ const value = this.#form.get(this.#path);
97
+ assertEditorValue(this.#type, value);
98
+ return cloneFormValue(value) as FieldEditorValue<Type> | null | undefined;
99
+ }
100
+ get issues() {
101
+ return this.stale ? [] : this.#form.issuesFor(this.#path).map((issue) => ({ ...issue }));
102
+ }
103
+ get liveValidation() {
104
+ if (this.stale) return { status: "idle" as const, retry: () => {} };
105
+ const feedback = this.#form.liveValidation.forField(this.#path);
106
+ return {
107
+ status: feedback.status,
108
+ retry: () => {
109
+ this.assertActive();
110
+ if (!this.readOnly) this.#form.liveValidation.flush(this.#path);
111
+ },
112
+ };
113
+ }
114
+
115
+ get readOnly() {
116
+ if (this.stale) return true;
117
+ if (
118
+ this.#occurrence.ancestors.some(({ schema, resolve }) => {
119
+ const path = resolve();
120
+ return (
121
+ path === undefined ||
122
+ schema.admin.readOnly === true ||
123
+ schema.admin.hidden === true ||
124
+ !this.#form.canRead(path, fieldAccessPath(schema)) ||
125
+ !this.#form.canWrite(path, fieldAccessPath(schema)) ||
126
+ (schema.admin.condition !== undefined &&
127
+ !evaluateFieldCondition(schema.admin.condition, path, (other) => this.#form.get(other)))
128
+ );
129
+ })
130
+ )
131
+ return true;
132
+ const schema = this.#schema();
133
+ const accessPath = fieldAccessPath(schema);
134
+ return (
135
+ this.#form.editingBlocked ||
136
+ schema.admin.readOnly === true ||
137
+ schema.admin.hidden === true ||
138
+ !this.#form.canRead(this.#path, accessPath) ||
139
+ !this.#form.canWrite(this.#path, accessPath) ||
140
+ (schema.admin.condition !== undefined &&
141
+ !evaluateFieldCondition(schema.admin.condition, this.#path, (path) => this.#form.get(path)))
142
+ );
143
+ }
144
+ get inputProps() {
145
+ const schema = this.schema;
146
+ const invalid = this.issues.length > 0;
147
+ return {
148
+ id: schema.id,
149
+ name: schema.path,
150
+ // Native required checks one input. Boolean false and list items containing empty
151
+ // strings can satisfy their Ridu field; their container rule runs on submit.
152
+ required:
153
+ this.#type !== "checkbox" &&
154
+ this.#type !== "text-list" &&
155
+ this.#type !== "number-list" &&
156
+ schema.required &&
157
+ (!schema.dynamicDefault || this.value !== undefined),
158
+ ...fieldControlARIA(schema.id, schema.admin.description !== undefined, invalid),
159
+ };
160
+ }
161
+ set = (value: FieldEditorValue<Type> | null) => {
162
+ this.#write(value);
163
+ };
164
+ assertActive = () => {
165
+ if (this.stale)
166
+ throw new Error(
167
+ "This field editor is stale. Use the editor mounted for the current document, locale and field occurrence."
168
+ );
169
+ };
170
+ destroy = () => {
171
+ if (this.#stale) return;
172
+ this.#stale = true;
173
+ this.#stopPath();
174
+ this.#stopObserve();
175
+ this.#stopLifetime();
176
+ };
177
+ #write(value: unknown) {
178
+ this.assertActive();
179
+ if (this.readOnly) throw new Error(`Field ${this.#path} is read-only.`);
180
+ assertEditorValue(this.#type, value);
181
+ if (value === undefined) throw new Error("Use null to clear an editor value.");
182
+ this.#form.set(this.#path, cloneFormValue(value));
183
+ }
184
+ #resolve() {
185
+ if (this.#stale) return;
186
+ if (
187
+ this.#schema().id !== this.#schemaID ||
188
+ this.#schemaVersion() !== this.#initialSchemaVersion ||
189
+ !this.#form.editorScopeActive ||
190
+ this.#form.editorEpoch !== this.#epoch ||
191
+ this.#form.contentLocale !== this.#locale ||
192
+ JSON.stringify(this.#form.resource) !== this.#resource
193
+ ) {
194
+ this.destroy();
195
+ return;
196
+ }
197
+ const path = this.#occurrence.resolve();
198
+ if (path === undefined) {
199
+ this.destroy();
200
+ return;
201
+ }
202
+ if (path !== this.#path) {
203
+ this.#stopPath();
204
+ this.#path = path;
205
+ this.#stopPath = this.#form.register(path);
206
+ }
207
+ }
208
+ }
209
+
210
+ function assertEditorValue(type: FieldEditorType, value: unknown) {
211
+ if (value === null || value === undefined) return;
212
+ if (type === "text-list" || type === "number-list") {
213
+ const element = type === "text-list" ? "string" : "number";
214
+ if (
215
+ !Array.isArray(value) ||
216
+ !Array.from(value).every(
217
+ (item) => typeof item === element && (element !== "number" || Number.isFinite(item))
218
+ )
219
+ )
220
+ throw new Error(
221
+ `Editor for ${type} expected an array of ${element}s with no null or invalid items.`
222
+ );
223
+ return;
224
+ }
225
+ const expected = type === "number" ? "number" : type === "checkbox" ? "boolean" : "string";
226
+ if (typeof value !== expected || (typeof value === "number" && !Number.isFinite(value)))
227
+ throw new Error(`Editor for ${type} expected a ${expected} value or null.`);
228
+ }
@@ -0,0 +1,42 @@
1
+ import { tick } from "svelte";
2
+
3
+ export const fieldIssueRevealEvent = "ridu:reveal-field-issue";
4
+
5
+ /** Open lazy field disclosures and repeated rows before focusing the invalid control. */
6
+ export async function focusFieldIssue(path: string) {
7
+ const selector = `[data-field-path="${CSS.escape(path)}"]`;
8
+ let previous: HTMLElement | null = null;
9
+ // Each reveal can mount the next schema boundary. Bound the search by the
10
+ // path depth, and stop when a reveal no longer makes progress.
11
+ for (let depth = 0; depth <= path.split(".").length; depth++) {
12
+ const segments = path.split(".");
13
+ let container: HTMLElement | null = null;
14
+ while (segments.length > 0 && container === null) {
15
+ container = document.querySelector<HTMLElement>(
16
+ `[data-field-path="${CSS.escape(segments.join("."))}"]`
17
+ );
18
+ segments.pop();
19
+ }
20
+ if (container === null || container === previous) return;
21
+ previous = container;
22
+ for (
23
+ let ancestor = container.parentElement;
24
+ ancestor !== null;
25
+ ancestor = ancestor.parentElement
26
+ ) {
27
+ if (ancestor instanceof HTMLDetailsElement && ancestor.hasAttribute("data-field-collapsible"))
28
+ ancestor.open = true;
29
+ }
30
+ container.dispatchEvent(new Event(fieldIssueRevealEvent, { bubbles: true }));
31
+ await tick();
32
+ const revealed = document.querySelector<HTMLElement>(selector);
33
+ const control = revealed?.querySelector<HTMLElement>(
34
+ "input:not([disabled]), textarea:not([disabled]), [contenteditable='true'], button:not([disabled]), [tabindex]:not([tabindex='-1'])"
35
+ );
36
+ if (revealed !== null && control != null) {
37
+ revealed.scrollIntoView({ behavior: "instant", block: "center" });
38
+ control.focus({ preventScroll: true });
39
+ return;
40
+ }
41
+ }
42
+ }