@poodle64/ui 2026.8.11 → 2026.8.13
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.
- package/README.md +164 -20
- package/dist/components/ui/collection-detail/collection-detail.svelte +169 -0
- package/dist/components/ui/collection-detail/collection-detail.svelte.d.ts +47 -0
- package/dist/components/ui/collection-detail/index.d.ts +3 -0
- package/dist/components/ui/collection-detail/index.js +2 -0
- package/dist/components/ui/document-detail/document-detail.svelte +184 -0
- package/dist/components/ui/document-detail/document-detail.svelte.d.ts +34 -0
- package/dist/components/ui/document-detail/index.d.ts +3 -0
- package/dist/components/ui/document-detail/index.js +2 -0
- package/dist/components/ui/library-browse/document-table.svelte +101 -0
- package/dist/components/ui/library-browse/document-table.svelte.d.ts +10 -0
- package/dist/components/ui/library-browse/facet-rail.svelte +62 -0
- package/dist/components/ui/library-browse/facet-rail.svelte.d.ts +8 -0
- package/dist/components/ui/library-browse/index.d.ts +3 -0
- package/dist/components/ui/library-browse/index.js +2 -0
- package/dist/components/ui/library-browse/library-browse.svelte +235 -0
- package/dist/components/ui/library-browse/library-browse.svelte.d.ts +44 -0
- package/dist/components/ui/library-browse/types.d.ts +101 -0
- package/dist/components/ui/library-browse/types.js +1 -0
- package/dist/components/ui/schema-form/context.d.ts +31 -0
- package/dist/components/ui/schema-form/context.js +10 -0
- package/dist/components/ui/schema-form/data.d.ts +42 -0
- package/dist/components/ui/schema-form/data.js +97 -0
- package/dist/components/ui/schema-form/dispatch.d.ts +35 -0
- package/dist/components/ui/schema-form/dispatch.js +139 -0
- package/dist/components/ui/schema-form/index.d.ts +5 -0
- package/dist/components/ui/schema-form/index.js +5 -0
- package/dist/components/ui/schema-form/schema-form-control.svelte +146 -0
- package/dist/components/ui/schema-form/schema-form-control.svelte.d.ts +7 -0
- package/dist/components/ui/schema-form/schema-form-element.svelte +105 -0
- package/dist/components/ui/schema-form/schema-form-element.svelte.d.ts +7 -0
- package/dist/components/ui/schema-form/schema-form-widget.svelte +169 -0
- package/dist/components/ui/schema-form/schema-form-widget.svelte.d.ts +18 -0
- package/dist/components/ui/schema-form/schema-form.svelte +153 -0
- package/dist/components/ui/schema-form/schema-form.svelte.d.ts +4 -0
- package/dist/components/ui/schema-form/types.d.ts +61 -0
- package/dist/components/ui/schema-form/types.js +21 -0
- package/dist/components/ui/schema-form/widgets/radio-field.svelte +48 -0
- package/dist/components/ui/schema-form/widgets/radio-field.svelte.d.ts +15 -0
- package/dist/components/ui/schema-form/widgets/slider-field.svelte +51 -0
- package/dist/components/ui/schema-form/widgets/slider-field.svelte.d.ts +13 -0
- package/dist/components/ui/schema-form/widgets/tags-field.svelte +98 -0
- package/dist/components/ui/schema-form/widgets/tags-field.svelte.d.ts +12 -0
- package/dist/components/ui/schema-form/widgets/unknown-field.svelte +123 -0
- package/dist/components/ui/schema-form/widgets/unknown-field.svelte.d.ts +20 -0
- package/dist/components/ui/search-results/index.d.ts +3 -0
- package/dist/components/ui/search-results/index.js +2 -0
- package/dist/components/ui/search-results/search-results.svelte +187 -0
- package/dist/components/ui/search-results/search-results.svelte.d.ts +44 -0
- package/package.json +2 -1
- package/registry/component-map.json +52 -3
- package/registry/component-map.md +20 -1
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type JsonSchema, type UISchemaElement } from '@jsonforms/core';
|
|
2
|
+
import type { UnknownReason } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Reading and writing a value at a dot path, and the walk that decides which
|
|
5
|
+
* schema properties the layout never mentioned.
|
|
6
|
+
*
|
|
7
|
+
* Dot paths are JSON Forms' own addressing (`toDataPath('#/properties/a/properties/b')`
|
|
8
|
+
* → `a.b`), so a property name containing a literal `.` is not addressable —
|
|
9
|
+
* the same limitation the wire format itself carries.
|
|
10
|
+
*/
|
|
11
|
+
export declare const scopeToPath: (scope: string) => string;
|
|
12
|
+
/** Read the value at a dot path, or `undefined`. */
|
|
13
|
+
export declare function getAt(data: unknown, path: string): unknown;
|
|
14
|
+
/**
|
|
15
|
+
* Return a copy of `data` with `path` set to `value`. Every object along the
|
|
16
|
+
* way is copied, so a consumer holding the previous value still holds the
|
|
17
|
+
* previous value — a `$state` consumer and an immutable store both work.
|
|
18
|
+
*/
|
|
19
|
+
export declare function setAt(data: Record<string, unknown>, path: string, value: unknown): Record<string, unknown>;
|
|
20
|
+
/** Every UI schema element in the tree, depth first. */
|
|
21
|
+
export declare function walkElements(element: UISchemaElement | undefined): UISchemaElement[];
|
|
22
|
+
/** The dot paths every Control in the tree addresses. */
|
|
23
|
+
export declare function addressedPaths(uischema: UISchemaElement | undefined): Set<string>;
|
|
24
|
+
export interface UnmappedField {
|
|
25
|
+
/** Dot path of the property nothing in the layout addresses. */
|
|
26
|
+
path: string;
|
|
27
|
+
/** JSON Pointer scope, so the fix is a copy-paste into the UI schema. */
|
|
28
|
+
scope: string;
|
|
29
|
+
schema: JsonSchema;
|
|
30
|
+
reason: UnknownReason;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Every schema property no Control addresses.
|
|
34
|
+
*
|
|
35
|
+
* This is the check that would have caught the estate's live defect: three
|
|
36
|
+
* whole top-level config groups never rendered because their names were absent
|
|
37
|
+
* from a hardcoded GROUP_ORDER, and a missing group produced no output and no
|
|
38
|
+
* error. Here a group nothing addresses is reported ONCE, at the group — the
|
|
39
|
+
* walk does not descend into a subtree the layout ignores entirely, so an
|
|
40
|
+
* unmentioned group is one loud entry rather than forty.
|
|
41
|
+
*/
|
|
42
|
+
export declare function unmappedFields(root: JsonSchema, addressed: Set<string>, node?: JsonSchema, scope?: string, path?: string): UnmappedField[];
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { resolveSchema, toDataPath } from '@jsonforms/core';
|
|
2
|
+
/**
|
|
3
|
+
* Reading and writing a value at a dot path, and the walk that decides which
|
|
4
|
+
* schema properties the layout never mentioned.
|
|
5
|
+
*
|
|
6
|
+
* Dot paths are JSON Forms' own addressing (`toDataPath('#/properties/a/properties/b')`
|
|
7
|
+
* → `a.b`), so a property name containing a literal `.` is not addressable —
|
|
8
|
+
* the same limitation the wire format itself carries.
|
|
9
|
+
*/
|
|
10
|
+
export const scopeToPath = (scope) => toDataPath(scope);
|
|
11
|
+
/** Read the value at a dot path, or `undefined`. */
|
|
12
|
+
export function getAt(data, path) {
|
|
13
|
+
if (!path)
|
|
14
|
+
return data;
|
|
15
|
+
let node = data;
|
|
16
|
+
for (const key of path.split('.')) {
|
|
17
|
+
if (node === null || typeof node !== 'object')
|
|
18
|
+
return undefined;
|
|
19
|
+
node = node[key];
|
|
20
|
+
}
|
|
21
|
+
return node;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Return a copy of `data` with `path` set to `value`. Every object along the
|
|
25
|
+
* way is copied, so a consumer holding the previous value still holds the
|
|
26
|
+
* previous value — a `$state` consumer and an immutable store both work.
|
|
27
|
+
*/
|
|
28
|
+
export function setAt(data, path, value) {
|
|
29
|
+
const keys = path.split('.');
|
|
30
|
+
const root = { ...data };
|
|
31
|
+
let node = root;
|
|
32
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
33
|
+
const key = keys[i];
|
|
34
|
+
const child = node[key];
|
|
35
|
+
node[key] = child && typeof child === 'object' && !Array.isArray(child)
|
|
36
|
+
? { ...child }
|
|
37
|
+
: {};
|
|
38
|
+
node = node[key];
|
|
39
|
+
}
|
|
40
|
+
node[keys[keys.length - 1]] = value;
|
|
41
|
+
return root;
|
|
42
|
+
}
|
|
43
|
+
/** Every UI schema element in the tree, depth first. */
|
|
44
|
+
export function walkElements(element) {
|
|
45
|
+
if (!element)
|
|
46
|
+
return [];
|
|
47
|
+
const children = element.elements ?? [];
|
|
48
|
+
return [element, ...children.flatMap(walkElements)];
|
|
49
|
+
}
|
|
50
|
+
/** The dot paths every Control in the tree addresses. */
|
|
51
|
+
export function addressedPaths(uischema) {
|
|
52
|
+
const paths = new Set();
|
|
53
|
+
for (const element of walkElements(uischema)) {
|
|
54
|
+
const scope = element.scope;
|
|
55
|
+
if (element.type === 'Control' && typeof scope === 'string' && scope.startsWith('#')) {
|
|
56
|
+
paths.add(scopeToPath(scope));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return paths;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Every schema property no Control addresses.
|
|
63
|
+
*
|
|
64
|
+
* This is the check that would have caught the estate's live defect: three
|
|
65
|
+
* whole top-level config groups never rendered because their names were absent
|
|
66
|
+
* from a hardcoded GROUP_ORDER, and a missing group produced no output and no
|
|
67
|
+
* error. Here a group nothing addresses is reported ONCE, at the group — the
|
|
68
|
+
* walk does not descend into a subtree the layout ignores entirely, so an
|
|
69
|
+
* unmentioned group is one loud entry rather than forty.
|
|
70
|
+
*/
|
|
71
|
+
export function unmappedFields(root, addressed, node = root, scope = '#', path = '') {
|
|
72
|
+
const properties = node.properties;
|
|
73
|
+
if (!properties)
|
|
74
|
+
return [];
|
|
75
|
+
const found = [];
|
|
76
|
+
for (const [key, raw] of Object.entries(properties)) {
|
|
77
|
+
const childPath = path ? `${path}.${key}` : key;
|
|
78
|
+
const childScope = `${scope}/properties/${key}`;
|
|
79
|
+
// `resolveSchema` follows any $ref, so a property behind a definition is
|
|
80
|
+
// judged on the schema it actually resolves to.
|
|
81
|
+
const child = resolveSchema(root, childScope, root) ?? raw;
|
|
82
|
+
// Addressed BY ITSELF: a Control points at this property. Whatever it does
|
|
83
|
+
// with it is that Control's business — do not descend and second-guess it.
|
|
84
|
+
if (addressed.has(childPath))
|
|
85
|
+
continue;
|
|
86
|
+
// Addressed BELOW: some Control points inside it, so the layout knows about
|
|
87
|
+
// this subtree and only some of its leaves are missing.
|
|
88
|
+
if ([...addressed].some((addressedPath) => addressedPath.startsWith(`${childPath}.`))) {
|
|
89
|
+
found.push(...unmappedFields(root, addressed, child, childScope, childPath));
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
// Addressed nowhere: report it here, once, and do not descend — an
|
|
93
|
+
// unmentioned group is one loud entry, not forty.
|
|
94
|
+
found.push({ path: childPath, scope: childScope, schema: child, reason: 'not-in-layout' });
|
|
95
|
+
}
|
|
96
|
+
return found;
|
|
97
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { JsonSchema } from '@jsonforms/core';
|
|
2
|
+
import { type UnknownReason, type WidgetKind } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Control → widget resolution, as a pure function so the table can be asserted
|
|
5
|
+
* without rendering anything.
|
|
6
|
+
*
|
|
7
|
+
* THE RULE THIS FILE EXISTS FOR: every branch that cannot produce a real widget
|
|
8
|
+
* returns `{ widget: 'unknown', reason }`. There is no branch that returns
|
|
9
|
+
* nothing, and no branch that quietly substitutes a default for a hint it did
|
|
10
|
+
* not understand. The estate's previous home-grown renderer had both, and a
|
|
11
|
+
* `widget: "dropdown"` hint therefore rendered no input at all for months
|
|
12
|
+
* without anyone noticing.
|
|
13
|
+
*/
|
|
14
|
+
export interface WidgetChoice {
|
|
15
|
+
widget: WidgetKind;
|
|
16
|
+
/** Present exactly when `widget === 'unknown'`. */
|
|
17
|
+
reason?: UnknownReason;
|
|
18
|
+
/** Human-readable specifics for the flagged render, e.g. the rejected hint. */
|
|
19
|
+
detail?: string;
|
|
20
|
+
}
|
|
21
|
+
/** The first `type` a subschema declares (JSON Schema allows an array of them). */
|
|
22
|
+
export declare function schemaType(schema: JsonSchema | undefined): string | undefined;
|
|
23
|
+
/** Does this subschema carry a closed set of values a select can list? */
|
|
24
|
+
export declare function enumOptions(schema: JsonSchema | undefined): {
|
|
25
|
+
value: unknown;
|
|
26
|
+
label: string;
|
|
27
|
+
}[] | null;
|
|
28
|
+
/**
|
|
29
|
+
* Resolve a Control to a widget.
|
|
30
|
+
*
|
|
31
|
+
* @param schema the subschema the Control's scope resolved to, or `undefined`
|
|
32
|
+
* if the scope resolved to nothing at all.
|
|
33
|
+
* @param options the Control's `options` object from the UI schema.
|
|
34
|
+
*/
|
|
35
|
+
export declare function pickWidget(schema: JsonSchema | undefined, options?: Record<string, unknown>): WidgetChoice;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { WIDGET_KINDS } from './types.js';
|
|
2
|
+
const KNOWN = new Set(WIDGET_KINDS);
|
|
3
|
+
/** The primitive JSON Schema types a `tags` widget can carry as array items. */
|
|
4
|
+
const TAGGABLE = new Set(['string', 'number', 'integer']);
|
|
5
|
+
/** JSON Schema `format` → the input type that renders it. */
|
|
6
|
+
const FORMAT_WIDGETS = {
|
|
7
|
+
password: 'password',
|
|
8
|
+
date: 'date',
|
|
9
|
+
time: 'time',
|
|
10
|
+
'date-time': 'datetime'
|
|
11
|
+
};
|
|
12
|
+
/** The first `type` a subschema declares (JSON Schema allows an array of them). */
|
|
13
|
+
export function schemaType(schema) {
|
|
14
|
+
const t = schema?.type;
|
|
15
|
+
if (Array.isArray(t))
|
|
16
|
+
return t.find((entry) => entry !== 'null') ?? t[0];
|
|
17
|
+
return t;
|
|
18
|
+
}
|
|
19
|
+
/** Does this subschema carry a closed set of values a select can list? */
|
|
20
|
+
export function enumOptions(schema) {
|
|
21
|
+
if (!schema)
|
|
22
|
+
return null;
|
|
23
|
+
const withEnum = schema;
|
|
24
|
+
if (Array.isArray(withEnum.enum)) {
|
|
25
|
+
return withEnum.enum.map((value) => ({ value, label: String(value) }));
|
|
26
|
+
}
|
|
27
|
+
// The `oneOf: [{ const, title }]` spelling — how a server labels its enum.
|
|
28
|
+
if (Array.isArray(withEnum.oneOf) && withEnum.oneOf.every((entry) => 'const' in entry)) {
|
|
29
|
+
return withEnum.oneOf.map((entry) => ({
|
|
30
|
+
value: entry.const,
|
|
31
|
+
label: entry.title ?? String(entry.const)
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Resolve a Control to a widget.
|
|
38
|
+
*
|
|
39
|
+
* @param schema the subschema the Control's scope resolved to, or `undefined`
|
|
40
|
+
* if the scope resolved to nothing at all.
|
|
41
|
+
* @param options the Control's `options` object from the UI schema.
|
|
42
|
+
*/
|
|
43
|
+
export function pickWidget(schema, options = {}) {
|
|
44
|
+
// 1. A scope that resolves to nothing is the most fundamental defect there is
|
|
45
|
+
// — flag it before anything else, hint or no hint.
|
|
46
|
+
if (!schema) {
|
|
47
|
+
return {
|
|
48
|
+
widget: 'unknown',
|
|
49
|
+
reason: 'unresolved-scope',
|
|
50
|
+
detail: 'no such property in the JSON Schema'
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
// 2. An explicit hint wins — and an unrecognised one is LOUD, never a default.
|
|
54
|
+
// `format` is the JSON Forms spelling; `widget` is accepted because the
|
|
55
|
+
// estate's outgoing x-ui vocabulary used it, so a migrating server does
|
|
56
|
+
// not have to change both documents at once.
|
|
57
|
+
const hint = options.format ?? options.widget;
|
|
58
|
+
if (typeof hint === 'string') {
|
|
59
|
+
if (KNOWN.has(hint)) {
|
|
60
|
+
// A select or a radio group over nothing renders as an empty box — which
|
|
61
|
+
// is the vanishing act this whole component exists to stop, one level
|
|
62
|
+
// down from an unrecognised hint.
|
|
63
|
+
if ((hint === 'select' || hint === 'radio') && !enumOptions(schema)?.length) {
|
|
64
|
+
return {
|
|
65
|
+
widget: 'unknown',
|
|
66
|
+
reason: 'no-options',
|
|
67
|
+
detail: `the "${hint}" widget needs a closed value set, and this subschema declares no enum or oneOf`
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return { widget: hint };
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
widget: 'unknown',
|
|
74
|
+
reason: 'unknown-widget',
|
|
75
|
+
detail: `no widget is registered for "${hint}"`
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
if (hint !== undefined) {
|
|
79
|
+
return {
|
|
80
|
+
widget: 'unknown',
|
|
81
|
+
reason: 'unknown-widget',
|
|
82
|
+
detail: `widget hint must be a string, got ${typeof hint}`
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
// 3. The two boolean options JSON Forms itself defines.
|
|
86
|
+
const type = schemaType(schema);
|
|
87
|
+
if (options.multi === true && type === 'string')
|
|
88
|
+
return { widget: 'textarea' };
|
|
89
|
+
if (options.slider === true && (type === 'number' || type === 'integer'))
|
|
90
|
+
return { widget: 'slider' };
|
|
91
|
+
// 4. Derive from the schema.
|
|
92
|
+
const closedSet = enumOptions(schema);
|
|
93
|
+
if (closedSet) {
|
|
94
|
+
if (!closedSet.length) {
|
|
95
|
+
return {
|
|
96
|
+
widget: 'unknown',
|
|
97
|
+
reason: 'no-options',
|
|
98
|
+
detail: 'the subschema declares an empty enum, so there is nothing to choose from'
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return { widget: 'select' };
|
|
102
|
+
}
|
|
103
|
+
switch (type) {
|
|
104
|
+
case 'boolean':
|
|
105
|
+
return { widget: 'switch' };
|
|
106
|
+
case 'integer':
|
|
107
|
+
case 'number':
|
|
108
|
+
return { widget: 'number' };
|
|
109
|
+
case 'string': {
|
|
110
|
+
const format = schema.format;
|
|
111
|
+
if (format && FORMAT_WIDGETS[format])
|
|
112
|
+
return { widget: FORMAT_WIDGETS[format] };
|
|
113
|
+
return { widget: 'text' };
|
|
114
|
+
}
|
|
115
|
+
case 'array': {
|
|
116
|
+
const items = schema.items;
|
|
117
|
+
const itemType = Array.isArray(items) ? undefined : schemaType(items);
|
|
118
|
+
if (itemType && TAGGABLE.has(itemType))
|
|
119
|
+
return { widget: 'tags' };
|
|
120
|
+
return {
|
|
121
|
+
widget: 'unknown',
|
|
122
|
+
reason: 'unsupported-array',
|
|
123
|
+
detail: `array items are ${itemType ? `"${itemType}"` : 'unspecified'}; only string, number and integer items render as tags`
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
case 'object':
|
|
127
|
+
return {
|
|
128
|
+
widget: 'unknown',
|
|
129
|
+
reason: 'object-control',
|
|
130
|
+
detail: 'an object needs a layout with Controls for its properties, not one Control'
|
|
131
|
+
};
|
|
132
|
+
default:
|
|
133
|
+
return {
|
|
134
|
+
widget: 'unknown',
|
|
135
|
+
reason: 'unsupported-type',
|
|
136
|
+
detail: type ? `no widget renders type "${type}"` : 'the subschema declares no type'
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { default as SchemaForm } from './schema-form.svelte';
|
|
2
|
+
export { default } from './schema-form.svelte';
|
|
3
|
+
export { pickWidget, enumOptions, schemaType, type WidgetChoice } from './dispatch.js';
|
|
4
|
+
export { addressedPaths, unmappedFields, scopeToPath, getAt, setAt } from './data.js';
|
|
5
|
+
export { WIDGET_KINDS, type WidgetKind, type UnknownReason, type SchemaFormChange, type SchemaFormProps, type JsonSchema, type UISchemaElement } from './types.js';
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { default as SchemaForm } from './schema-form.svelte';
|
|
2
|
+
export { default } from './schema-form.svelte';
|
|
3
|
+
export { pickWidget, enumOptions, schemaType } from './dispatch.js';
|
|
4
|
+
export { addressedPaths, unmappedFields, scopeToPath, getAt, setAt } from './data.js';
|
|
5
|
+
export { WIDGET_KINDS } from './types.js';
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { isEnabled, resolveSchema, type ControlElement } from '@jsonforms/core';
|
|
3
|
+
import Label from '../label/label.svelte';
|
|
4
|
+
import SchemaFormWidget from './schema-form-widget.svelte';
|
|
5
|
+
import UnknownField from './widgets/unknown-field.svelte';
|
|
6
|
+
import { getSchemaFormContext } from './context.js';
|
|
7
|
+
import { pickWidget } from './dispatch.js';
|
|
8
|
+
import { getAt, scopeToPath } from './data.js';
|
|
9
|
+
import type { JsonSchema } from './types.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* One Control: resolve its scope against the JSON Schema, choose a widget,
|
|
13
|
+
* and frame it with its label, description and validation message.
|
|
14
|
+
*
|
|
15
|
+
* Every way this can fail — no scope, a scope that resolves to nothing, a
|
|
16
|
+
* widget hint nobody registered — leaves through `<UnknownField>`, never
|
|
17
|
+
* through a branch that renders nothing.
|
|
18
|
+
*/
|
|
19
|
+
let { element }: { element: ControlElement } = $props();
|
|
20
|
+
|
|
21
|
+
const form = getSchemaFormContext();
|
|
22
|
+
|
|
23
|
+
const scope = $derived(typeof element.scope === 'string' ? element.scope : '');
|
|
24
|
+
const path = $derived(scope ? scopeToPath(scope) : '');
|
|
25
|
+
const subschema = $derived(
|
|
26
|
+
scope ? (resolveSchema(form.schema, scope, form.schema) as JsonSchema | undefined) : undefined
|
|
27
|
+
);
|
|
28
|
+
const options = $derived((element.options ?? {}) as Record<string, unknown>);
|
|
29
|
+
const choice = $derived(pickWidget(subschema, options));
|
|
30
|
+
|
|
31
|
+
const value = $derived(path ? getAt(form.data, path) : undefined);
|
|
32
|
+
const id = $derived(`${form.idPrefix}-${(path || 'unscoped').replace(/[^a-zA-Z0-9_-]/g, '-')}`);
|
|
33
|
+
|
|
34
|
+
/** `maxDepth` / `max_depth` → `Max depth`, when nothing supplied a title. */
|
|
35
|
+
const humanise = (key: string) =>
|
|
36
|
+
key
|
|
37
|
+
.replace(/[_-]+/g, ' ')
|
|
38
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
39
|
+
.replace(/^./, (first) => first.toUpperCase());
|
|
40
|
+
|
|
41
|
+
const label = $derived(
|
|
42
|
+
(typeof element.label === 'string' && element.label) ||
|
|
43
|
+
(subschema as { title?: string } | undefined)?.title ||
|
|
44
|
+
humanise(path.split('.').pop() || scope || 'Field')
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const description = $derived((subschema as { description?: string } | undefined)?.description);
|
|
48
|
+
|
|
49
|
+
// `required` lives on the PARENT object schema, so read it from there rather
|
|
50
|
+
// than guessing from the field.
|
|
51
|
+
const required = $derived.by(() => {
|
|
52
|
+
const parentScope = scope.replace(/\/properties\/[^/]+$/, '');
|
|
53
|
+
const key = scope.split('/').pop() ?? '';
|
|
54
|
+
const parent =
|
|
55
|
+
parentScope === '#' || parentScope === ''
|
|
56
|
+
? form.schema
|
|
57
|
+
: (resolveSchema(form.schema, parentScope, form.schema) as JsonSchema | undefined);
|
|
58
|
+
return ((parent as { required?: string[] } | undefined)?.required ?? []).includes(key);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const errors = $derived(form.errors[path] ?? []);
|
|
62
|
+
const disabled = $derived(
|
|
63
|
+
form.disabled ||
|
|
64
|
+
options.readonly === true ||
|
|
65
|
+
!isEnabled(element, form.data, '', form.ajv, undefined)
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const describedBy = $derived(
|
|
69
|
+
[description ? `${id}-description` : null, errors.length ? `${id}-error` : null]
|
|
70
|
+
.filter(Boolean)
|
|
71
|
+
.join(' ') || undefined
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
// A toggle reads as "control, then what it toggles"; everything else reads as
|
|
75
|
+
// "label, then the field". Both are still one labelled field.
|
|
76
|
+
const inline = $derived(choice.widget === 'switch' || choice.widget === 'checkbox');
|
|
77
|
+
|
|
78
|
+
const commit = (next: unknown) => form.change(path, next);
|
|
79
|
+
</script>
|
|
80
|
+
|
|
81
|
+
{#if !scope}
|
|
82
|
+
<UnknownField
|
|
83
|
+
{id}
|
|
84
|
+
label={typeof element.label === 'string' ? element.label : 'Control'}
|
|
85
|
+
scope="(none)"
|
|
86
|
+
path=""
|
|
87
|
+
value={undefined}
|
|
88
|
+
reason="missing-scope"
|
|
89
|
+
detail="this Control carries no scope, so there is no property to edit"
|
|
90
|
+
/>
|
|
91
|
+
{:else if choice.widget === 'unknown'}
|
|
92
|
+
<UnknownField
|
|
93
|
+
{id}
|
|
94
|
+
{label}
|
|
95
|
+
{scope}
|
|
96
|
+
{path}
|
|
97
|
+
{value}
|
|
98
|
+
{disabled}
|
|
99
|
+
schema={subschema}
|
|
100
|
+
reason={choice.reason ?? 'unsupported-type'}
|
|
101
|
+
detail={choice.detail ?? 'this control could not be rendered'}
|
|
102
|
+
onchange={commit}
|
|
103
|
+
/>
|
|
104
|
+
{:else}
|
|
105
|
+
<div class="grid gap-1.5" data-schema-form-field={path}>
|
|
106
|
+
{#snippet widget()}
|
|
107
|
+
<SchemaFormWidget
|
|
108
|
+
{choice}
|
|
109
|
+
schema={subschema as JsonSchema}
|
|
110
|
+
{value}
|
|
111
|
+
{id}
|
|
112
|
+
{label}
|
|
113
|
+
{scope}
|
|
114
|
+
{path}
|
|
115
|
+
{disabled}
|
|
116
|
+
{describedBy}
|
|
117
|
+
name={path}
|
|
118
|
+
onchange={commit}
|
|
119
|
+
/>
|
|
120
|
+
{/snippet}
|
|
121
|
+
{#snippet fieldLabel()}
|
|
122
|
+
<Label for={id} id="{id}-label" class={disabled ? 'opacity-50' : undefined}>
|
|
123
|
+
{label}{#if required}<span class="text-status-error" aria-hidden="true"> *</span
|
|
124
|
+
><span class="sr-only"> (required)</span>{/if}
|
|
125
|
+
</Label>
|
|
126
|
+
{/snippet}
|
|
127
|
+
|
|
128
|
+
{#if inline}
|
|
129
|
+
<!-- A toggle reads as "control, then what it toggles". -->
|
|
130
|
+
<div class="flex items-center gap-2.5">
|
|
131
|
+
{@render widget()}
|
|
132
|
+
{@render fieldLabel()}
|
|
133
|
+
</div>
|
|
134
|
+
{:else}
|
|
135
|
+
{@render fieldLabel()}
|
|
136
|
+
{@render widget()}
|
|
137
|
+
{/if}
|
|
138
|
+
|
|
139
|
+
{#if description}
|
|
140
|
+
<p id="{id}-description" class="text-muted-foreground text-xs">{description}</p>
|
|
141
|
+
{/if}
|
|
142
|
+
{#if errors.length}
|
|
143
|
+
<p id="{id}-error" class="text-status-error text-xs">{errors.join('. ')}</p>
|
|
144
|
+
{/if}
|
|
145
|
+
</div>
|
|
146
|
+
{/if}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type ControlElement } from '@jsonforms/core';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
element: ControlElement;
|
|
4
|
+
};
|
|
5
|
+
declare const SchemaFormControl: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
6
|
+
type SchemaFormControl = ReturnType<typeof SchemaFormControl>;
|
|
7
|
+
export default SchemaFormControl;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { isVisible, type ControlElement, type UISchemaElement } from '@jsonforms/core';
|
|
3
|
+
import Panel from '../panel/panel.svelte';
|
|
4
|
+
import * as Tabs from '../tabs/index.js';
|
|
5
|
+
import SchemaFormControl from './schema-form-control.svelte';
|
|
6
|
+
import UnknownField from './widgets/unknown-field.svelte';
|
|
7
|
+
import Self from './schema-form-element.svelte';
|
|
8
|
+
import { getSchemaFormContext } from './context.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* One node of the UI Schema tree, recursively.
|
|
12
|
+
*
|
|
13
|
+
* The vocabulary walked here is JSON Forms' own: VerticalLayout,
|
|
14
|
+
* HorizontalLayout, Group, Categorization/Category, Control and Label. An
|
|
15
|
+
* element type outside it renders as a flagged fallback — the previous
|
|
16
|
+
* renderers treated an unknown node as nothing to do, which is how a nested
|
|
17
|
+
* hint became inert without any signal.
|
|
18
|
+
*
|
|
19
|
+
* SHOW/HIDE is evaluated here rather than inside the control, so a rule on a
|
|
20
|
+
* Group hides the whole group, which is what an author writing one means.
|
|
21
|
+
*/
|
|
22
|
+
let { element }: { element: UISchemaElement } = $props();
|
|
23
|
+
|
|
24
|
+
const form = getSchemaFormContext();
|
|
25
|
+
|
|
26
|
+
const visible = $derived(isVisible(element, form.data, '', form.ajv, undefined));
|
|
27
|
+
const children = $derived(((element as { elements?: UISchemaElement[] }).elements ?? []));
|
|
28
|
+
const label = $derived(
|
|
29
|
+
typeof (element as { label?: unknown }).label === 'string'
|
|
30
|
+
? ((element as { label: string }).label)
|
|
31
|
+
: undefined
|
|
32
|
+
);
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
{#if visible}
|
|
36
|
+
{#if element.type === 'VerticalLayout'}
|
|
37
|
+
<div class="grid gap-4" data-schema-form-layout="vertical">
|
|
38
|
+
{#each children as child, index (index)}
|
|
39
|
+
<Self element={child} />
|
|
40
|
+
{/each}
|
|
41
|
+
</div>
|
|
42
|
+
{:else if element.type === 'HorizontalLayout'}
|
|
43
|
+
<!-- Columns of equal width on a wide viewport, one stack below it. The
|
|
44
|
+
column count follows the element count, so a layout never needs a
|
|
45
|
+
breakpoint written per form. -->
|
|
46
|
+
<div class="grid gap-4 sm:auto-cols-fr sm:grid-flow-col" data-schema-form-layout="horizontal">
|
|
47
|
+
{#each children as child, index (index)}
|
|
48
|
+
<Self element={child} />
|
|
49
|
+
{/each}
|
|
50
|
+
</div>
|
|
51
|
+
{:else if element.type === 'Group'}
|
|
52
|
+
<Panel title={label} data-schema-form-layout="group">
|
|
53
|
+
<div class="grid gap-4">
|
|
54
|
+
{#each children as child, index (index)}
|
|
55
|
+
<Self element={child} />
|
|
56
|
+
{/each}
|
|
57
|
+
</div>
|
|
58
|
+
</Panel>
|
|
59
|
+
{:else if element.type === 'Categorization'}
|
|
60
|
+
{@const categories = children.filter((child) => isVisible(child, form.data, '', form.ajv, undefined))}
|
|
61
|
+
{#if categories.length}
|
|
62
|
+
<Tabs.Root value="category-0" data-schema-form-layout="categorization">
|
|
63
|
+
<Tabs.List>
|
|
64
|
+
{#each categories as category, index (index)}
|
|
65
|
+
<Tabs.Trigger value="category-{index}">
|
|
66
|
+
{(category as { label?: string }).label ?? `Section ${index + 1}`}
|
|
67
|
+
</Tabs.Trigger>
|
|
68
|
+
{/each}
|
|
69
|
+
</Tabs.List>
|
|
70
|
+
{#each categories as category, index (index)}
|
|
71
|
+
<Tabs.Content value="category-{index}" class="pt-4">
|
|
72
|
+
<div class="grid gap-4">
|
|
73
|
+
{#each (category as { elements?: UISchemaElement[] }).elements ?? [] as child, position (position)}
|
|
74
|
+
<Self element={child} />
|
|
75
|
+
{/each}
|
|
76
|
+
</div>
|
|
77
|
+
</Tabs.Content>
|
|
78
|
+
{/each}
|
|
79
|
+
</Tabs.Root>
|
|
80
|
+
{/if}
|
|
81
|
+
{:else if element.type === 'Category'}
|
|
82
|
+
<!-- A Category outside a Categorization is still a section of fields. -->
|
|
83
|
+
<div class="grid gap-4" data-schema-form-layout="category">
|
|
84
|
+
{#each children as child, index (index)}
|
|
85
|
+
<Self element={child} />
|
|
86
|
+
{/each}
|
|
87
|
+
</div>
|
|
88
|
+
{:else if element.type === 'Control'}
|
|
89
|
+
<SchemaFormControl element={element as ControlElement} />
|
|
90
|
+
{:else if element.type === 'Label'}
|
|
91
|
+
<p class="text-2xs text-muted-foreground font-semibold tracking-wide uppercase">
|
|
92
|
+
{(element as { text?: string }).text ?? label ?? ''}
|
|
93
|
+
</p>
|
|
94
|
+
{:else}
|
|
95
|
+
<UnknownField
|
|
96
|
+
id="unknown-element"
|
|
97
|
+
label={label ?? element.type}
|
|
98
|
+
scope="(ui schema element)"
|
|
99
|
+
path=""
|
|
100
|
+
reason="unknown-element"
|
|
101
|
+
detail={`"${element.type}" is not a UI schema element this renderer knows`}
|
|
102
|
+
value={element}
|
|
103
|
+
/>
|
|
104
|
+
{/if}
|
|
105
|
+
{/if}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type UISchemaElement } from '@jsonforms/core';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
element: UISchemaElement;
|
|
4
|
+
};
|
|
5
|
+
declare const SchemaFormElement: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
6
|
+
type SchemaFormElement = ReturnType<typeof SchemaFormElement>;
|
|
7
|
+
export default SchemaFormElement;
|