@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,169 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import Input from '../input/input.svelte';
|
|
3
|
+
import Textarea from '../textarea/textarea.svelte';
|
|
4
|
+
import Switch from '../switch/switch.svelte';
|
|
5
|
+
import Checkbox from '../checkbox/checkbox.svelte';
|
|
6
|
+
import * as Select from '../select/index.js';
|
|
7
|
+
import SliderField from './widgets/slider-field.svelte';
|
|
8
|
+
import TagsField from './widgets/tags-field.svelte';
|
|
9
|
+
import RadioField from './widgets/radio-field.svelte';
|
|
10
|
+
import UnknownField from './widgets/unknown-field.svelte';
|
|
11
|
+
import { enumOptions, schemaType, type WidgetChoice } from './dispatch.js';
|
|
12
|
+
import type { JsonSchema } from './types.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The widget dispatch table: one branch per `WidgetKind`, and a final `:else`
|
|
16
|
+
* that is itself the loud fallback rather than nothing. There is deliberately
|
|
17
|
+
* no silent arm in this file — that is the whole contract.
|
|
18
|
+
*/
|
|
19
|
+
let {
|
|
20
|
+
choice,
|
|
21
|
+
schema,
|
|
22
|
+
value,
|
|
23
|
+
id,
|
|
24
|
+
name,
|
|
25
|
+
label,
|
|
26
|
+
scope,
|
|
27
|
+
path,
|
|
28
|
+
disabled = false,
|
|
29
|
+
describedBy,
|
|
30
|
+
onchange
|
|
31
|
+
}: {
|
|
32
|
+
choice: WidgetChoice;
|
|
33
|
+
schema: JsonSchema;
|
|
34
|
+
value: unknown;
|
|
35
|
+
id: string;
|
|
36
|
+
name: string;
|
|
37
|
+
label: string;
|
|
38
|
+
scope: string;
|
|
39
|
+
path: string;
|
|
40
|
+
disabled?: boolean;
|
|
41
|
+
describedBy?: string;
|
|
42
|
+
onchange: (next: unknown) => void;
|
|
43
|
+
} = $props();
|
|
44
|
+
|
|
45
|
+
const type = $derived(schemaType(schema));
|
|
46
|
+
const options = $derived(enumOptions(schema) ?? []);
|
|
47
|
+
const bounds = $derived(schema as { minimum?: number; maximum?: number; multipleOf?: number });
|
|
48
|
+
const items = $derived((schema as { items?: JsonSchema }).items);
|
|
49
|
+
|
|
50
|
+
const text = $derived(value === undefined || value === null ? '' : String(value));
|
|
51
|
+
/** The string key bits-ui addresses an option by; enum values may be numbers. */
|
|
52
|
+
const selected = $derived(value === undefined || value === null ? '' : String(value));
|
|
53
|
+
|
|
54
|
+
const commitNumber = (raw: string) => {
|
|
55
|
+
if (raw.trim() === '') return onchange(undefined);
|
|
56
|
+
const parsed = Number(raw);
|
|
57
|
+
onchange(Number.isFinite(parsed) ? parsed : raw);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const commitEnum = (key: string) => {
|
|
61
|
+
const match = options.find((option) => String(option.value) === key);
|
|
62
|
+
onchange(match ? match.value : key);
|
|
63
|
+
};
|
|
64
|
+
</script>
|
|
65
|
+
|
|
66
|
+
{#if choice.widget === 'text' || choice.widget === 'password' || choice.widget === 'date' || choice.widget === 'time' || choice.widget === 'datetime'}
|
|
67
|
+
<Input
|
|
68
|
+
{id}
|
|
69
|
+
{name}
|
|
70
|
+
{disabled}
|
|
71
|
+
type={choice.widget === 'datetime'
|
|
72
|
+
? 'datetime-local'
|
|
73
|
+
: choice.widget === 'text'
|
|
74
|
+
? 'text'
|
|
75
|
+
: choice.widget}
|
|
76
|
+
value={text}
|
|
77
|
+
aria-describedby={describedBy}
|
|
78
|
+
oninput={(event) => onchange((event.currentTarget as HTMLInputElement).value)}
|
|
79
|
+
/>
|
|
80
|
+
{:else if choice.widget === 'textarea'}
|
|
81
|
+
<Textarea
|
|
82
|
+
{id}
|
|
83
|
+
{name}
|
|
84
|
+
{disabled}
|
|
85
|
+
value={text}
|
|
86
|
+
aria-describedby={describedBy}
|
|
87
|
+
oninput={(event) => onchange((event.currentTarget as HTMLTextAreaElement).value)}
|
|
88
|
+
/>
|
|
89
|
+
{:else if choice.widget === 'number'}
|
|
90
|
+
<Input
|
|
91
|
+
{id}
|
|
92
|
+
{name}
|
|
93
|
+
{disabled}
|
|
94
|
+
type="number"
|
|
95
|
+
min={bounds.minimum}
|
|
96
|
+
max={bounds.maximum}
|
|
97
|
+
step={bounds.multipleOf ?? (type === 'integer' ? 1 : undefined)}
|
|
98
|
+
value={text}
|
|
99
|
+
aria-describedby={describedBy}
|
|
100
|
+
oninput={(event) => commitNumber((event.currentTarget as HTMLInputElement).value)}
|
|
101
|
+
/>
|
|
102
|
+
{:else if choice.widget === 'slider'}
|
|
103
|
+
<SliderField
|
|
104
|
+
{id}
|
|
105
|
+
{disabled}
|
|
106
|
+
{describedBy}
|
|
107
|
+
value={typeof value === 'number' ? value : undefined}
|
|
108
|
+
min={bounds.minimum ?? 0}
|
|
109
|
+
max={bounds.maximum ?? 100}
|
|
110
|
+
step={bounds.multipleOf ?? (type === 'integer' ? 1 : 0.01)}
|
|
111
|
+
onchange={(next) => onchange(next)}
|
|
112
|
+
/>
|
|
113
|
+
{:else if choice.widget === 'select'}
|
|
114
|
+
<Select.Root type="single" value={selected} onValueChange={commitEnum} {disabled} {name}>
|
|
115
|
+
<Select.Trigger {id} class="w-full" aria-describedby={describedBy}>
|
|
116
|
+
{options.find((option) => String(option.value) === selected)?.label ?? 'Select…'}
|
|
117
|
+
</Select.Trigger>
|
|
118
|
+
<Select.Content>
|
|
119
|
+
{#each options as option (String(option.value))}
|
|
120
|
+
<Select.Item value={String(option.value)} label={option.label}>{option.label}</Select.Item>
|
|
121
|
+
{/each}
|
|
122
|
+
</Select.Content>
|
|
123
|
+
</Select.Root>
|
|
124
|
+
{:else if choice.widget === 'radio'}
|
|
125
|
+
<RadioField {id} {name} {value} {options} {disabled} {describedBy} onchange={(next) => onchange(next)} />
|
|
126
|
+
{:else if choice.widget === 'switch'}
|
|
127
|
+
<Switch
|
|
128
|
+
{id}
|
|
129
|
+
{name}
|
|
130
|
+
{disabled}
|
|
131
|
+
checked={value === true}
|
|
132
|
+
aria-describedby={describedBy}
|
|
133
|
+
onCheckedChange={(next: boolean) => onchange(next)}
|
|
134
|
+
/>
|
|
135
|
+
{:else if choice.widget === 'checkbox'}
|
|
136
|
+
<Checkbox
|
|
137
|
+
{id}
|
|
138
|
+
{name}
|
|
139
|
+
{disabled}
|
|
140
|
+
checked={value === true}
|
|
141
|
+
aria-describedby={describedBy}
|
|
142
|
+
onCheckedChange={(next: boolean) => onchange(next === true)}
|
|
143
|
+
/>
|
|
144
|
+
{:else if choice.widget === 'tags'}
|
|
145
|
+
<TagsField
|
|
146
|
+
{id}
|
|
147
|
+
{disabled}
|
|
148
|
+
{describedBy}
|
|
149
|
+
value={Array.isArray(value) ? value : undefined}
|
|
150
|
+
itemType={schemaType(items) ?? 'string'}
|
|
151
|
+
onchange={(next) => onchange(next)}
|
|
152
|
+
/>
|
|
153
|
+
{:else}
|
|
154
|
+
<!-- `unknown`, and the unreachable case of a WidgetKind with no branch above.
|
|
155
|
+
Both land loudly: a widget kind this file forgot is exactly the class of
|
|
156
|
+
defect that made a field vanish in the renderers this one replaces. -->
|
|
157
|
+
<UnknownField
|
|
158
|
+
{id}
|
|
159
|
+
{label}
|
|
160
|
+
{scope}
|
|
161
|
+
{path}
|
|
162
|
+
{value}
|
|
163
|
+
{schema}
|
|
164
|
+
{disabled}
|
|
165
|
+
reason={choice.reason ?? 'unknown-widget'}
|
|
166
|
+
detail={choice.detail ?? `no branch renders the "${choice.widget}" widget`}
|
|
167
|
+
onchange={(next) => onchange(next)}
|
|
168
|
+
/>
|
|
169
|
+
{/if}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type WidgetChoice } from './dispatch.js';
|
|
2
|
+
import type { JsonSchema } from './types.js';
|
|
3
|
+
type $$ComponentProps = {
|
|
4
|
+
choice: WidgetChoice;
|
|
5
|
+
schema: JsonSchema;
|
|
6
|
+
value: unknown;
|
|
7
|
+
id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
label: string;
|
|
10
|
+
scope: string;
|
|
11
|
+
path: string;
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
describedBy?: string;
|
|
14
|
+
onchange: (next: unknown) => void;
|
|
15
|
+
};
|
|
16
|
+
declare const SchemaFormWidget: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
17
|
+
type SchemaFormWidget = ReturnType<typeof SchemaFormWidget>;
|
|
18
|
+
export default SchemaFormWidget;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { createAjv, Generate, type UISchemaElement } from '@jsonforms/core';
|
|
3
|
+
import Panel from '../panel/panel.svelte';
|
|
4
|
+
import SchemaFormElement from './schema-form-element.svelte';
|
|
5
|
+
import UnknownField from './widgets/unknown-field.svelte';
|
|
6
|
+
import { setSchemaFormContext } from './context.js';
|
|
7
|
+
import { addressedPaths, getAt, setAt, unmappedFields } from './data.js';
|
|
8
|
+
import { cn } from '../../../utils.js';
|
|
9
|
+
import type { SchemaFormProps } from './types.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Render a form the server described: a JSON Schema for what the value IS,
|
|
13
|
+
* and a JSON Forms UI Schema for how it is laid out.
|
|
14
|
+
*
|
|
15
|
+
* This component knows nothing about any application. It takes the two
|
|
16
|
+
* documents, the current value and a change handler; it performs no fetch,
|
|
17
|
+
* holds no endpoint, and names no service. That is what lets the engine
|
|
18
|
+
* behind the schema be swapped without touching a consumer — and what lets
|
|
19
|
+
* three apps that each hand-rolled this converge on one renderer.
|
|
20
|
+
*
|
|
21
|
+
* `@jsonforms/core` is used HEADLESS — for JSON-Pointer scope resolution,
|
|
22
|
+
* rule evaluation and Ajv only. The renderers are this package's own and
|
|
23
|
+
* dispatch to this package's own widgets; there is no official Svelte
|
|
24
|
+
* renderer for JSON Forms and the community one carries no external
|
|
25
|
+
* validation.
|
|
26
|
+
*
|
|
27
|
+
* THE ONE NON-NEGOTIABLE RULE: nothing renders silently as nothing. An
|
|
28
|
+
* unknown widget hint, an unknown element type, a scope that resolves to
|
|
29
|
+
* nothing, or a schema property the layout never mentions each renders as a
|
|
30
|
+
* flagged fallback carrying `data-schema-form-unknown`. The renderers this
|
|
31
|
+
* replaces failed all four ways in silence, and shipped that way for months.
|
|
32
|
+
*/
|
|
33
|
+
let {
|
|
34
|
+
schema,
|
|
35
|
+
uischema,
|
|
36
|
+
value,
|
|
37
|
+
onChange,
|
|
38
|
+
disabled = false,
|
|
39
|
+
idPrefix = 'sf',
|
|
40
|
+
class: className
|
|
41
|
+
}: SchemaFormProps = $props();
|
|
42
|
+
|
|
43
|
+
// One Ajv for the life of the form: rule conditions and validation both use
|
|
44
|
+
// it, and compiling per keystroke would be the whole cost of the component.
|
|
45
|
+
const ajv = createAjv();
|
|
46
|
+
|
|
47
|
+
// No UI schema is a legitimate mode, not an error: generating one from the
|
|
48
|
+
// schema is the only arrangement in which a field CANNOT be missing from the
|
|
49
|
+
// layout, so it is the safe default rather than a blank form.
|
|
50
|
+
const layout = $derived(uischema ?? (Generate.uiSchema(schema) as UISchemaElement));
|
|
51
|
+
|
|
52
|
+
const addressed = $derived(addressedPaths(layout));
|
|
53
|
+
const unmapped = $derived(unmappedFields(schema, addressed));
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Validation messages by dot path. Ajv is asked about a CLONE, because
|
|
57
|
+
* `createAjv` enables `useDefaults` and would otherwise write defaults into
|
|
58
|
+
* the caller's value behind its back.
|
|
59
|
+
*/
|
|
60
|
+
const errors = $derived.by(() => {
|
|
61
|
+
const byPath: Record<string, string[]> = {};
|
|
62
|
+
try {
|
|
63
|
+
const validate = ajv.compile(schema as object);
|
|
64
|
+
const probe = structuredClone($state.snapshot(value));
|
|
65
|
+
if (validate(probe)) return byPath;
|
|
66
|
+
for (const error of validate.errors ?? []) {
|
|
67
|
+
const missing = (error.params as { missingProperty?: string })?.missingProperty;
|
|
68
|
+
const instance = error.instancePath.replace(/^\//, '').split('/').filter(Boolean).join('.');
|
|
69
|
+
const path = missing ? [instance, missing].filter(Boolean).join('.') : instance;
|
|
70
|
+
if (!path) continue;
|
|
71
|
+
(byPath[path] ??= []).push(missing ? 'Required' : (error.message ?? 'is invalid'));
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
// A schema Ajv will not compile, or a value it will not clone, is still
|
|
75
|
+
// a form this renderer can lay out — it simply cannot be validated.
|
|
76
|
+
// Never let a validation problem blank the form: that is the failure
|
|
77
|
+
// mode this whole component exists to remove.
|
|
78
|
+
return byPath;
|
|
79
|
+
}
|
|
80
|
+
return byPath;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
setSchemaFormContext({
|
|
84
|
+
get schema() {
|
|
85
|
+
return schema;
|
|
86
|
+
},
|
|
87
|
+
get data() {
|
|
88
|
+
return value;
|
|
89
|
+
},
|
|
90
|
+
get disabled() {
|
|
91
|
+
return disabled;
|
|
92
|
+
},
|
|
93
|
+
get idPrefix() {
|
|
94
|
+
return idPrefix;
|
|
95
|
+
},
|
|
96
|
+
get errors() {
|
|
97
|
+
return errors;
|
|
98
|
+
},
|
|
99
|
+
ajv,
|
|
100
|
+
change(path, next) {
|
|
101
|
+
onChange(setAt($state.snapshot(value) as Record<string, unknown>, path, next), {
|
|
102
|
+
path,
|
|
103
|
+
value: next
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
</script>
|
|
108
|
+
|
|
109
|
+
<div class={cn('grid gap-4', className)} data-schema-form="">
|
|
110
|
+
<SchemaFormElement element={layout} />
|
|
111
|
+
|
|
112
|
+
{#if unmapped.length}
|
|
113
|
+
<!--
|
|
114
|
+
The defect this section exists for: in the renderer being retired, three
|
|
115
|
+
whole top-level config groups never rendered because their names were
|
|
116
|
+
absent from a hardcoded order. Nothing failed, nothing warned — the
|
|
117
|
+
groups were simply not there. A field the layout does not mention is now
|
|
118
|
+
the loudest thing on the page instead of the quietest.
|
|
119
|
+
-->
|
|
120
|
+
<Panel
|
|
121
|
+
title="Not in the layout"
|
|
122
|
+
subtitle="{unmapped.length} field{unmapped.length === 1 ? '' : 's'} no Control addresses"
|
|
123
|
+
data-schema-form-unmapped=""
|
|
124
|
+
>
|
|
125
|
+
<p class="text-muted-foreground mb-3 text-xs">
|
|
126
|
+
The JSON Schema describes {unmapped.length === 1 ? 'this field' : 'these fields'} but the UI
|
|
127
|
+
schema never {unmapped.length === 1 ? 'mentions it' : 'mentions them'}. Add a Control for
|
|
128
|
+
each; until then {unmapped.length === 1 ? 'it is' : 'they are'} edited raw here rather than
|
|
129
|
+
disappearing.
|
|
130
|
+
</p>
|
|
131
|
+
<div class="grid gap-3">
|
|
132
|
+
{#each unmapped as field (field.path)}
|
|
133
|
+
<UnknownField
|
|
134
|
+
id="{idPrefix}-unmapped-{field.path.replace(/[^a-zA-Z0-9_-]/g, '-')}"
|
|
135
|
+
label={(field.schema as { title?: string }).title ?? field.path}
|
|
136
|
+
scope={field.scope}
|
|
137
|
+
path={field.path}
|
|
138
|
+
schema={field.schema}
|
|
139
|
+
reason={field.reason}
|
|
140
|
+
{disabled}
|
|
141
|
+
detail="no Control in the UI schema addresses this property"
|
|
142
|
+
value={getAt(value, field.path)}
|
|
143
|
+
onchange={(next) =>
|
|
144
|
+
onChange(
|
|
145
|
+
setAt($state.snapshot(value) as Record<string, unknown>, field.path, next),
|
|
146
|
+
{ path: field.path, value: next }
|
|
147
|
+
)}
|
|
148
|
+
/>
|
|
149
|
+
{/each}
|
|
150
|
+
</div>
|
|
151
|
+
</Panel>
|
|
152
|
+
{/if}
|
|
153
|
+
</div>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { JsonSchema, UISchemaElement } from '@jsonforms/core';
|
|
2
|
+
export type { JsonSchema, UISchemaElement };
|
|
3
|
+
/**
|
|
4
|
+
* The widgets `<SchemaForm>` can dispatch a Control to. This list IS the
|
|
5
|
+
* contract: a hint naming anything outside it resolves to `'unknown'` and
|
|
6
|
+
* renders loudly, it does not fall back to a default control and it never
|
|
7
|
+
* renders nothing.
|
|
8
|
+
*/
|
|
9
|
+
export declare const WIDGET_KINDS: readonly ["text", "textarea", "password", "number", "slider", "date", "time", "datetime", "select", "radio", "switch", "checkbox", "tags"];
|
|
10
|
+
export type WidgetKind = (typeof WIDGET_KINDS)[number] | 'unknown';
|
|
11
|
+
/**
|
|
12
|
+
* Why something could not be rendered as a real control. Every value is
|
|
13
|
+
* surfaced in the DOM as `data-unknown-reason`, so a consuming app's own gate
|
|
14
|
+
* can assert on it, and so a screenshot of a broken form says WHY it is broken.
|
|
15
|
+
*/
|
|
16
|
+
export type UnknownReason =
|
|
17
|
+
/** `options.format`/`options.widget` named a widget this package does not ship. */
|
|
18
|
+
'unknown-widget'
|
|
19
|
+
/** The UI schema element's `type` is not in the JSON Forms vocabulary this renderer walks. */
|
|
20
|
+
| 'unknown-element'
|
|
21
|
+
/** The Control's `scope` pointer resolves to nothing in the JSON Schema. */
|
|
22
|
+
| 'unresolved-scope'
|
|
23
|
+
/** The Control has no `scope` at all. */
|
|
24
|
+
| 'missing-scope'
|
|
25
|
+
/** The resolved subschema has a `type` no widget covers. */
|
|
26
|
+
| 'unsupported-type'
|
|
27
|
+
/** A Control addressing an object — an object needs a layout, not a control. */
|
|
28
|
+
| 'object-control'
|
|
29
|
+
/** A hint asked for a closed value set, but the subschema declares none. */
|
|
30
|
+
| 'no-options'
|
|
31
|
+
/** An array whose items are not primitives; `tags` cannot represent it. */
|
|
32
|
+
| 'unsupported-array'
|
|
33
|
+
/** The JSON Schema describes this field but the UI schema never mentions it. */
|
|
34
|
+
| 'not-in-layout';
|
|
35
|
+
/** The change `<SchemaForm>` emits alongside the next whole value. */
|
|
36
|
+
export interface SchemaFormChange {
|
|
37
|
+
/** Dot path of the field that changed, e.g. `tuning.depth`. */
|
|
38
|
+
path: string;
|
|
39
|
+
/** The field's new value. */
|
|
40
|
+
value: unknown;
|
|
41
|
+
}
|
|
42
|
+
export interface SchemaFormProps {
|
|
43
|
+
/** The JSON Schema describing the object being edited. */
|
|
44
|
+
schema: JsonSchema;
|
|
45
|
+
/**
|
|
46
|
+
* The JSON Forms UI Schema describing the layout. Omit it and one is
|
|
47
|
+
* generated from the schema, which is the only mode in which no field can
|
|
48
|
+
* be missing from the layout.
|
|
49
|
+
*/
|
|
50
|
+
uischema?: UISchemaElement;
|
|
51
|
+
/** The current value. `<SchemaForm>` never mutates it. */
|
|
52
|
+
value: Record<string, unknown>;
|
|
53
|
+
/** Called with the next whole value and the single field that changed. */
|
|
54
|
+
onChange: (next: Record<string, unknown>, change: SchemaFormChange) => void;
|
|
55
|
+
/** Disable every control in the form. */
|
|
56
|
+
disabled?: boolean;
|
|
57
|
+
/** Prefix for generated element ids, when two forms share a page. */
|
|
58
|
+
idPrefix?: string;
|
|
59
|
+
/** Extra classes on the form's root element. */
|
|
60
|
+
class?: string;
|
|
61
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The widgets `<SchemaForm>` can dispatch a Control to. This list IS the
|
|
3
|
+
* contract: a hint naming anything outside it resolves to `'unknown'` and
|
|
4
|
+
* renders loudly, it does not fall back to a default control and it never
|
|
5
|
+
* renders nothing.
|
|
6
|
+
*/
|
|
7
|
+
export const WIDGET_KINDS = [
|
|
8
|
+
'text',
|
|
9
|
+
'textarea',
|
|
10
|
+
'password',
|
|
11
|
+
'number',
|
|
12
|
+
'slider',
|
|
13
|
+
'date',
|
|
14
|
+
'time',
|
|
15
|
+
'datetime',
|
|
16
|
+
'select',
|
|
17
|
+
'radio',
|
|
18
|
+
'switch',
|
|
19
|
+
'checkbox',
|
|
20
|
+
'tags'
|
|
21
|
+
];
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import Label from '../../label/label.svelte';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A closed value set as a visible row of choices, for the case where a select
|
|
6
|
+
* hides the options a user needs to compare. Composed here for the same
|
|
7
|
+
* reason as the slider: the package ships no radio group, and its only caller
|
|
8
|
+
* is an enum Control that asked for `options.format: "radio"`.
|
|
9
|
+
*
|
|
10
|
+
* Native inputs, one `name` per field, so arrow-key roving and the radiogroup
|
|
11
|
+
* role come from the platform rather than from markup that has to be right.
|
|
12
|
+
*/
|
|
13
|
+
let {
|
|
14
|
+
id,
|
|
15
|
+
name,
|
|
16
|
+
value,
|
|
17
|
+
options,
|
|
18
|
+
disabled = false,
|
|
19
|
+
describedBy,
|
|
20
|
+
onchange
|
|
21
|
+
}: {
|
|
22
|
+
id: string;
|
|
23
|
+
name: string;
|
|
24
|
+
value: unknown;
|
|
25
|
+
options: { value: unknown; label: string }[];
|
|
26
|
+
disabled?: boolean;
|
|
27
|
+
describedBy?: string;
|
|
28
|
+
onchange: (next: unknown) => void;
|
|
29
|
+
} = $props();
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
<div {id} role="radiogroup" aria-labelledby="{id}-label" aria-describedby={describedBy} class="flex flex-wrap gap-x-4 gap-y-2">
|
|
33
|
+
{#each options as option, index (String(option.value))}
|
|
34
|
+
<div class="flex items-center gap-1.5">
|
|
35
|
+
<input
|
|
36
|
+
id="{id}-{index}"
|
|
37
|
+
{name}
|
|
38
|
+
{disabled}
|
|
39
|
+
type="radio"
|
|
40
|
+
value={String(option.value)}
|
|
41
|
+
checked={option.value === value}
|
|
42
|
+
class="accent-primary focus-visible:ring-ring/50 size-4 outline-none focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50"
|
|
43
|
+
onchange={() => onchange(option.value)}
|
|
44
|
+
/>
|
|
45
|
+
<Label for="{id}-{index}" class="font-normal">{option.label}</Label>
|
|
46
|
+
</div>
|
|
47
|
+
{/each}
|
|
48
|
+
</div>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
type $$ComponentProps = {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
value: unknown;
|
|
5
|
+
options: {
|
|
6
|
+
value: unknown;
|
|
7
|
+
label: string;
|
|
8
|
+
}[];
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
describedBy?: string;
|
|
11
|
+
onchange: (next: unknown) => void;
|
|
12
|
+
};
|
|
13
|
+
declare const RadioField: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
14
|
+
type RadioField = ReturnType<typeof RadioField>;
|
|
15
|
+
export default RadioField;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* A bounded numeric field as a track plus a live readout.
|
|
4
|
+
*
|
|
5
|
+
* Composed here rather than imported: the package ships no slider, and the
|
|
6
|
+
* only caller is a Control whose subschema carries `minimum`/`maximum`. It is
|
|
7
|
+
* a native `<input type="range">` so the keyboard, ARIA and touch behaviour
|
|
8
|
+
* are the platform's; the only styling is the accent utility, which resolves
|
|
9
|
+
* through the palette, so an app that changes `--ds-color-primary` changes
|
|
10
|
+
* the track with it.
|
|
11
|
+
*/
|
|
12
|
+
let {
|
|
13
|
+
id,
|
|
14
|
+
value,
|
|
15
|
+
min,
|
|
16
|
+
max,
|
|
17
|
+
step = 1,
|
|
18
|
+
disabled = false,
|
|
19
|
+
describedBy,
|
|
20
|
+
onchange
|
|
21
|
+
}: {
|
|
22
|
+
id: string;
|
|
23
|
+
value: number | undefined;
|
|
24
|
+
min: number;
|
|
25
|
+
max: number;
|
|
26
|
+
step?: number;
|
|
27
|
+
disabled?: boolean;
|
|
28
|
+
describedBy?: string;
|
|
29
|
+
onchange: (next: number) => void;
|
|
30
|
+
} = $props();
|
|
31
|
+
|
|
32
|
+
const current = $derived(typeof value === 'number' && Number.isFinite(value) ? value : min);
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<div class="flex items-center gap-3">
|
|
36
|
+
<input
|
|
37
|
+
{id}
|
|
38
|
+
{min}
|
|
39
|
+
{max}
|
|
40
|
+
{step}
|
|
41
|
+
{disabled}
|
|
42
|
+
type="range"
|
|
43
|
+
value={current}
|
|
44
|
+
aria-describedby={describedBy}
|
|
45
|
+
class="accent-primary focus-visible:ring-ring/50 h-8 w-full cursor-pointer rounded-lg outline-none focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50"
|
|
46
|
+
oninput={(event) => onchange(Number((event.currentTarget as HTMLInputElement).value))}
|
|
47
|
+
/>
|
|
48
|
+
<output for={id} class="ds-tabular text-muted-foreground w-10 shrink-0 text-right text-xs"
|
|
49
|
+
>{current}</output
|
|
50
|
+
>
|
|
51
|
+
</div>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
type $$ComponentProps = {
|
|
2
|
+
id: string;
|
|
3
|
+
value: number | undefined;
|
|
4
|
+
min: number;
|
|
5
|
+
max: number;
|
|
6
|
+
step?: number;
|
|
7
|
+
disabled?: boolean;
|
|
8
|
+
describedBy?: string;
|
|
9
|
+
onchange: (next: number) => void;
|
|
10
|
+
};
|
|
11
|
+
declare const SliderField: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
12
|
+
type SliderField = ReturnType<typeof SliderField>;
|
|
13
|
+
export default SliderField;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import X from '@lucide/svelte/icons/x';
|
|
3
|
+
import Badge from '../../badge/badge.svelte';
|
|
4
|
+
import Input from '../../input/input.svelte';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* An array of primitives as removable chips plus one entry field.
|
|
8
|
+
*
|
|
9
|
+
* Composed here because the package ships no tags input and an array of
|
|
10
|
+
* strings is the commonest non-scalar in a server-described config (allowed
|
|
11
|
+
* hosts, enabled features, ignore globs). Enter or comma commits; Backspace
|
|
12
|
+
* on an empty field removes the last chip, which is the behaviour every tags
|
|
13
|
+
* input has and the one users try first.
|
|
14
|
+
*
|
|
15
|
+
* Item coercion follows the subschema: a `number`/`integer` item schema means
|
|
16
|
+
* the array stays numeric rather than quietly becoming strings.
|
|
17
|
+
*/
|
|
18
|
+
let {
|
|
19
|
+
id,
|
|
20
|
+
value,
|
|
21
|
+
itemType = 'string',
|
|
22
|
+
disabled = false,
|
|
23
|
+
placeholder = 'Add and press Enter',
|
|
24
|
+
describedBy,
|
|
25
|
+
onchange
|
|
26
|
+
}: {
|
|
27
|
+
id: string;
|
|
28
|
+
value: unknown[] | undefined;
|
|
29
|
+
itemType?: string;
|
|
30
|
+
disabled?: boolean;
|
|
31
|
+
placeholder?: string;
|
|
32
|
+
describedBy?: string;
|
|
33
|
+
onchange: (next: unknown[]) => void;
|
|
34
|
+
} = $props();
|
|
35
|
+
|
|
36
|
+
let draft = $state('');
|
|
37
|
+
|
|
38
|
+
const items = $derived(Array.isArray(value) ? value : []);
|
|
39
|
+
|
|
40
|
+
const coerce = (text: string): unknown => {
|
|
41
|
+
if (itemType !== 'number' && itemType !== 'integer') return text;
|
|
42
|
+
const parsed = Number(text);
|
|
43
|
+
return Number.isFinite(parsed) ? parsed : text;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function add() {
|
|
47
|
+
const text = draft.trim();
|
|
48
|
+
if (!text) return;
|
|
49
|
+
const next = coerce(text);
|
|
50
|
+
if (!items.some((item) => item === next)) onchange([...items, next]);
|
|
51
|
+
draft = '';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function removeAt(index: number) {
|
|
55
|
+
onchange(items.filter((_, position) => position !== index));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function onkeydown(event: KeyboardEvent) {
|
|
59
|
+
if (event.key === 'Enter' || event.key === ',') {
|
|
60
|
+
event.preventDefault();
|
|
61
|
+
add();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (event.key === 'Backspace' && draft === '' && items.length) removeAt(items.length - 1);
|
|
65
|
+
}
|
|
66
|
+
</script>
|
|
67
|
+
|
|
68
|
+
<div class="grid gap-1.5">
|
|
69
|
+
{#if items.length}
|
|
70
|
+
<ul class="flex flex-wrap gap-1.5" aria-label="Current values">
|
|
71
|
+
{#each items as item, index (`${String(item)}-${index}`)}
|
|
72
|
+
<li>
|
|
73
|
+
<Badge variant="secondary" class="h-6 gap-1 pr-1">
|
|
74
|
+
<span class="font-mono">{String(item)}</span>
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
{disabled}
|
|
78
|
+
aria-label="Remove {String(item)}"
|
|
79
|
+
class="hover:bg-muted hover:text-foreground text-muted-foreground focus-visible:ring-ring/50 flex size-5 items-center justify-center rounded-full outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50"
|
|
80
|
+
onclick={() => removeAt(index)}
|
|
81
|
+
>
|
|
82
|
+
<X class="size-3" aria-hidden="true" />
|
|
83
|
+
</button>
|
|
84
|
+
</Badge>
|
|
85
|
+
</li>
|
|
86
|
+
{/each}
|
|
87
|
+
</ul>
|
|
88
|
+
{/if}
|
|
89
|
+
<Input
|
|
90
|
+
{id}
|
|
91
|
+
{disabled}
|
|
92
|
+
{placeholder}
|
|
93
|
+
bind:value={draft}
|
|
94
|
+
aria-describedby={describedBy}
|
|
95
|
+
{onkeydown}
|
|
96
|
+
onblur={add}
|
|
97
|
+
/>
|
|
98
|
+
</div>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
type $$ComponentProps = {
|
|
2
|
+
id: string;
|
|
3
|
+
value: unknown[] | undefined;
|
|
4
|
+
itemType?: string;
|
|
5
|
+
disabled?: boolean;
|
|
6
|
+
placeholder?: string;
|
|
7
|
+
describedBy?: string;
|
|
8
|
+
onchange: (next: unknown[]) => void;
|
|
9
|
+
};
|
|
10
|
+
declare const TagsField: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
11
|
+
type TagsField = ReturnType<typeof TagsField>;
|
|
12
|
+
export default TagsField;
|