@kitnai/chat 0.7.0 → 0.8.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.
- package/README.md +9 -9
- package/dist/custom-elements.json +1626 -883
- package/dist/kitn-chat.es.js +36 -36
- package/dist/llms/llms-full.txt +303 -142
- package/dist/llms/llms.txt +18 -18
- package/dist/schemas/card-envelope.schema.json +14 -0
- package/dist/schemas/card-event.schema.json +12 -0
- package/dist/schemas/confirm.schema.json +65 -0
- package/dist/schemas/embed.schema.json +65 -0
- package/dist/schemas/form.result.schema.json +7 -0
- package/dist/schemas/form.schema.json +33 -0
- package/dist/schemas/link.schema.json +56 -0
- package/dist/schemas/task-list.result.schema.json +16 -0
- package/dist/schemas/task-list.schema.json +78 -0
- package/dist/theme.tokens.css +65 -65
- package/dist/tsx-B8rCNbgL.js +1 -0
- package/dist/typescript-RycA9KXf.js +1 -0
- package/frameworks/react/index.tsx +356 -189
- package/frameworks/react/runtime.tsx +2 -2
- package/llms-full.txt +303 -142
- package/llms.txt +18 -18
- package/package.json +5 -2
- package/src/components/artifact.stories.tsx +138 -0
- package/src/components/artifact.tsx +581 -0
- package/src/components/attachments.stories.tsx +7 -8
- package/src/components/attachments.tsx +2 -2
- package/src/components/card.tsx +110 -0
- package/src/components/chain-of-thought.stories.tsx +7 -8
- package/src/components/chat-container.stories.tsx +7 -8
- package/src/components/chat-container.tsx +4 -0
- package/src/components/checkpoint.stories.tsx +7 -8
- package/src/components/code-block.stories.tsx +8 -9
- package/src/components/component-meta.json +3411 -0
- package/src/components/confirm-card.stories.tsx +74 -0
- package/src/components/confirm-card.tsx +299 -0
- package/src/components/context.stories.tsx +7 -8
- package/src/components/conversation-item.stories.tsx +7 -8
- package/src/components/conversation-item.tsx +2 -2
- package/src/components/conversation-list.stories.tsx +7 -8
- package/src/components/conversation-list.tsx +1 -1
- package/src/components/embed.tsx +196 -0
- package/src/components/empty.stories.tsx +8 -9
- package/src/components/feedback-bar.stories.tsx +7 -8
- package/src/components/file-tree.stories.tsx +73 -0
- package/src/components/file-tree.tsx +383 -0
- package/src/components/file-upload.stories.tsx +7 -8
- package/src/components/form-widgets.tsx +461 -0
- package/src/components/form.tsx +796 -0
- package/src/components/image.stories.tsx +7 -8
- package/src/components/link-card.tsx +194 -0
- package/src/components/loader.stories.tsx +7 -8
- package/src/components/markdown.stories.tsx +7 -8
- package/src/components/message-narrow.stories.tsx +12 -13
- package/src/components/message-skills.stories.tsx +16 -17
- package/src/components/message.stories.tsx +17 -18
- package/src/components/model-switcher.stories.tsx +7 -8
- package/src/components/prompt-input.stories.tsx +8 -9
- package/src/components/prompt-suggestion.stories.tsx +7 -8
- package/src/components/prompt-suggestion.tsx +3 -3
- package/src/components/reasoning.stories.tsx +7 -8
- package/src/components/scroll-button.stories.tsx +7 -8
- package/src/components/slash-command.stories.tsx +8 -9
- package/src/components/slash-command.tsx +2 -2
- package/src/components/source.stories.tsx +7 -8
- package/src/components/source.tsx +1 -1
- package/src/components/task-list-card.stories.tsx +78 -0
- package/src/components/task-list-card.tsx +388 -0
- package/src/components/text-shimmer.stories.tsx +7 -8
- package/src/components/thinking-bar.stories.tsx +7 -8
- package/src/components/tool.stories.tsx +7 -8
- package/src/components/tool.tsx +2 -2
- package/src/components/voice-input.stories.tsx +7 -8
- package/src/elements/artifact.stories.tsx +291 -0
- package/src/elements/artifact.tsx +72 -0
- package/src/elements/{kitn-attachments.stories.tsx → attachments.stories.tsx} +11 -20
- package/src/elements/attachments.tsx +4 -4
- package/src/elements/card.stories.tsx +118 -0
- package/src/elements/card.tsx +40 -0
- package/src/elements/catalog.stories.tsx +491 -0
- package/src/elements/{kitn-chain-of-thought.stories.tsx → chain-of-thought.stories.tsx} +13 -22
- package/src/elements/chain-of-thought.tsx +3 -3
- package/src/elements/{kitn-chat-scope-picker.stories.tsx → chat-scope-picker.stories.tsx} +10 -19
- package/src/elements/chat-scope-picker.tsx +4 -4
- package/src/elements/{kitn-chat-workspace.stories.tsx → chat-workspace.stories.tsx} +15 -23
- package/src/elements/chat-workspace.tsx +2 -2
- package/src/elements/{kitn-chat.stories.tsx → chat.stories.tsx} +12 -20
- package/src/elements/chat.tsx +2 -2
- package/src/elements/{kitn-checkpoint.stories.tsx → checkpoint.stories.tsx} +11 -20
- package/src/elements/checkpoint.tsx +4 -4
- package/src/elements/{kitn-code-block.stories.tsx → code-block.stories.tsx} +10 -19
- package/src/elements/code-block.tsx +3 -3
- package/src/elements/compiled.css +1 -1
- package/src/elements/composed-shell.stories.tsx +316 -0
- package/src/elements/confirm-card.stories.tsx +186 -0
- package/src/elements/confirm-card.tsx +45 -0
- package/src/elements/{kitn-context-meter.stories.tsx → context-meter.stories.tsx} +10 -19
- package/src/elements/context-meter.tsx +3 -3
- package/src/elements/{kitn-conversation-list.stories.tsx → conversation-list.stories.tsx} +12 -20
- package/src/elements/conversation-list.tsx +2 -2
- package/src/elements/css.ts +1 -1
- package/src/elements/define.tsx +10 -10
- package/src/elements/element-meta.json +1379 -733
- package/src/elements/element-types.d.ts +251 -125
- package/src/elements/embed.stories.tsx +197 -0
- package/src/elements/embed.tsx +35 -0
- package/src/elements/{kitn-empty.stories.tsx → empty.stories.tsx} +12 -21
- package/src/elements/empty.tsx +3 -3
- package/src/elements/{kitn-feedback-bar.stories.tsx → feedback-bar.stories.tsx} +11 -20
- package/src/elements/feedback-bar.tsx +4 -4
- package/src/elements/file-tree.stories.tsx +133 -0
- package/src/elements/file-tree.tsx +52 -0
- package/src/elements/{kitn-file-upload.stories.tsx → file-upload.stories.tsx} +12 -21
- package/src/elements/file-upload.tsx +4 -4
- package/src/elements/form.stories.tsx +204 -0
- package/src/elements/form.tsx +37 -0
- package/src/elements/{kitn-image.stories.tsx → image.stories.tsx} +10 -19
- package/src/elements/image.tsx +3 -3
- package/src/elements/link-card.stories.tsx +193 -0
- package/src/elements/link-card.tsx +34 -0
- package/src/elements/{kitn-loader.stories.tsx → loader.stories.tsx} +11 -20
- package/src/elements/loader.tsx +3 -3
- package/src/elements/{kitn-markdown.stories.tsx → markdown.stories.tsx} +10 -19
- package/src/elements/markdown.tsx +3 -3
- package/src/elements/{kitn-message-skills.stories.tsx → message-skills.stories.tsx} +10 -19
- package/src/elements/message-skills.tsx +3 -3
- package/src/elements/{kitn-message.stories.tsx → message.stories.tsx} +12 -21
- package/src/elements/message.tsx +5 -5
- package/src/elements/{kitn-model-switcher.stories.tsx → model-switcher.stories.tsx} +10 -19
- package/src/elements/model-switcher.tsx +5 -5
- package/src/elements/{kitn-prompt-input.stories.tsx → prompt-input.stories.tsx} +14 -22
- package/src/elements/prompt-input.tsx +3 -3
- package/src/elements/{kitn-prompt-suggestions.stories.tsx → prompt-suggestions.stories.tsx} +13 -22
- package/src/elements/prompt-suggestions.tsx +4 -4
- package/src/elements/{kitn-reasoning.stories.tsx → reasoning.stories.tsx} +10 -19
- package/src/elements/reasoning.tsx +4 -4
- package/src/elements/register.ts +11 -1
- package/src/elements/resizable.stories.tsx +200 -0
- package/src/elements/resizable.tsx +264 -0
- package/src/elements/{kitn-response-stream.stories.tsx → response-stream.stories.tsx} +10 -19
- package/src/elements/response-stream.tsx +4 -4
- package/src/elements/{kitn-source-list.stories.tsx → source-list.stories.tsx} +11 -20
- package/src/elements/{kitn-source.stories.tsx → source.stories.tsx} +12 -21
- package/src/elements/source.tsx +5 -5
- package/src/elements/styles.css +140 -1
- package/src/elements/task-list-card.stories.tsx +194 -0
- package/src/elements/task-list-card.tsx +40 -0
- package/src/elements/{kitn-text-shimmer.stories.tsx → text-shimmer.stories.tsx} +10 -19
- package/src/elements/text-shimmer.tsx +3 -3
- package/src/elements/{kitn-thinking-bar.stories.tsx → thinking-bar.stories.tsx} +11 -20
- package/src/elements/thinking-bar.tsx +5 -5
- package/src/elements/{kitn-tool.stories.tsx → tool.stories.tsx} +10 -19
- package/src/elements/tool.tsx +3 -3
- package/src/elements/{kitn-voice-input.stories.tsx → voice-input.stories.tsx} +10 -19
- package/src/elements/voice-input.tsx +4 -4
- package/src/index.ts +94 -2
- package/src/primitives/card-contract.ts +60 -0
- package/src/primitives/card-host.tsx +35 -0
- package/src/primitives/card-routing.ts +79 -0
- package/src/primitives/card-schemas/card-envelope.schema.json +14 -0
- package/src/primitives/card-schemas/card-event.schema.json +12 -0
- package/src/primitives/card-schemas/confirm.schema.json +65 -0
- package/src/primitives/card-schemas/embed.schema.json +65 -0
- package/src/primitives/card-schemas/form.result.schema.json +7 -0
- package/src/primitives/card-schemas/form.schema.json +33 -0
- package/src/primitives/card-schemas/link.schema.json +56 -0
- package/src/primitives/card-schemas/task-list.result.schema.json +16 -0
- package/src/primitives/card-schemas/task-list.schema.json +78 -0
- package/src/primitives/card-validate.ts +95 -0
- package/src/primitives/embed-providers.ts +254 -0
- package/src/primitives/highlighter.ts +4 -0
- package/src/primitives/link-preview.ts +87 -0
- package/src/primitives/pdf-preview.ts +121 -0
- package/src/stories/chat-panel-layout.stories.tsx +2 -1
- package/src/stories/chat-scene.tsx +22 -21
- package/src/stories/checkpoint-restore.stories.tsx +10 -10
- package/src/stories/conversation-with-reasoning.stories.tsx +4 -4
- package/src/stories/conversation-with-sources.stories.tsx +7 -7
- package/src/stories/docs/Accessibility.mdx +2 -2
- package/src/stories/docs/ForAIAgents.mdx +3 -3
- package/src/stories/docs/GettingStarted.mdx +2 -2
- package/src/stories/docs/Installation.mdx +2 -2
- package/src/stories/docs/Integrations.mdx +29 -29
- package/src/stories/docs/Introduction.mdx +3 -3
- package/src/stories/docs/Theming.mdx +2 -2
- package/src/stories/docs/element-controls.ts +32 -0
- package/src/stories/docs/theme-editor/theme-editor.tsx +1 -0
- package/src/stories/examples/ChoosingComponents.mdx +94 -0
- package/src/stories/examples/sample-data.ts +79 -0
- package/src/stories/message-actions.stories.tsx +13 -13
- package/src/stories/pattern-centered-conversation.stories.tsx +3 -3
- package/src/stories/pattern-docked-widget.stories.tsx +1 -1
- package/src/stories/pattern-empty-state.stories.tsx +3 -3
- package/src/stories/prompt-input-variants.stories.tsx +13 -13
- package/src/stories/streaming-response.stories.tsx +3 -3
- package/src/stories/typography.stories.tsx +4 -4
- package/src/ui/avatar.stories.tsx +7 -8
- package/src/ui/badge.stories.tsx +7 -8
- package/src/ui/button.stories.tsx +8 -9
- package/src/ui/button.tsx +1 -0
- package/src/ui/collapsible.stories.tsx +6 -7
- package/src/ui/dropdown.stories.tsx +6 -7
- package/src/ui/hover-card.stories.tsx +6 -7
- package/src/ui/resizable.stories.tsx +74 -9
- package/src/ui/resizable.tsx +351 -71
- package/src/ui/scroll-area.stories.tsx +6 -7
- package/src/ui/scroll-area.tsx +3 -1
- package/src/ui/separator.stories.tsx +7 -8
- package/src/ui/skeleton.stories.tsx +7 -8
- package/src/ui/textarea.stories.tsx +6 -7
- package/src/ui/tooltip.stories.tsx +8 -9
- package/theme.css +65 -65
- package/src/stories/docs/element-spec.tsx +0 -86
|
@@ -0,0 +1,796 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type JSX,
|
|
3
|
+
For,
|
|
4
|
+
Show,
|
|
5
|
+
Index,
|
|
6
|
+
splitProps,
|
|
7
|
+
mergeProps,
|
|
8
|
+
createSignal,
|
|
9
|
+
createMemo,
|
|
10
|
+
createEffect,
|
|
11
|
+
on,
|
|
12
|
+
ErrorBoundary,
|
|
13
|
+
} from 'solid-js';
|
|
14
|
+
import { createStore, produce, unwrap } from 'solid-js/store';
|
|
15
|
+
import { cn } from '../utils/cn';
|
|
16
|
+
import { Button } from '../ui/button';
|
|
17
|
+
import { Card } from './card';
|
|
18
|
+
import {
|
|
19
|
+
validateAgainstSchema,
|
|
20
|
+
type JsonSchema,
|
|
21
|
+
} from '../primitives/card-validate';
|
|
22
|
+
import type { CardEnvelope, CardEvent, CardHost } from '../primitives/card-contract';
|
|
23
|
+
import { emitCardEvent } from '../primitives/card-routing';
|
|
24
|
+
import { useCardHost } from '../primitives/card-host';
|
|
25
|
+
import {
|
|
26
|
+
TextWidget,
|
|
27
|
+
TextareaWidget,
|
|
28
|
+
NumberWidget,
|
|
29
|
+
SliderWidget,
|
|
30
|
+
RatingWidget,
|
|
31
|
+
SwitchWidget,
|
|
32
|
+
CheckboxWidget,
|
|
33
|
+
RadioGroupWidget,
|
|
34
|
+
SelectWidget,
|
|
35
|
+
CheckboxGroupWidget,
|
|
36
|
+
MultiSelectWidget,
|
|
37
|
+
TagListWidget,
|
|
38
|
+
} from './form-widgets';
|
|
39
|
+
|
|
40
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
41
|
+
// Types (the JSON-Schema subset kc-form renders) — see form.schema.json.
|
|
42
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/** A field definition (the JSON Schema subset kc-form renders). */
|
|
45
|
+
export interface FormField {
|
|
46
|
+
type: 'string' | 'number' | 'integer' | 'boolean' | 'array' | 'object';
|
|
47
|
+
title?: string;
|
|
48
|
+
description?: string;
|
|
49
|
+
default?: unknown;
|
|
50
|
+
enum?: unknown[];
|
|
51
|
+
format?: 'email' | 'uri' | 'url' | 'date' | 'date-time' | 'time';
|
|
52
|
+
minimum?: number;
|
|
53
|
+
maximum?: number;
|
|
54
|
+
minLength?: number;
|
|
55
|
+
maxLength?: number;
|
|
56
|
+
pattern?: string;
|
|
57
|
+
minItems?: number;
|
|
58
|
+
maxItems?: number;
|
|
59
|
+
items?: FormField | { enum: unknown[] };
|
|
60
|
+
properties?: Record<string, FormField>;
|
|
61
|
+
required?: string[];
|
|
62
|
+
readOnly?: boolean;
|
|
63
|
+
'x-kc-widget'?:
|
|
64
|
+
| 'textarea'
|
|
65
|
+
| 'slider'
|
|
66
|
+
| 'rating'
|
|
67
|
+
| 'radio'
|
|
68
|
+
| 'select'
|
|
69
|
+
| 'checkbox'
|
|
70
|
+
| 'password'
|
|
71
|
+
| 'switch';
|
|
72
|
+
'x-kc-placeholder'?: string;
|
|
73
|
+
'x-kc-step'?: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The form definition = CardEnvelope.data for type:'form'. */
|
|
77
|
+
export interface FormDefinition {
|
|
78
|
+
type: 'object';
|
|
79
|
+
title?: string;
|
|
80
|
+
description?: string;
|
|
81
|
+
required?: string[];
|
|
82
|
+
properties: Record<string, FormField>;
|
|
83
|
+
'x-kc-order'?: string[];
|
|
84
|
+
'x-kc-inlineMax'?: number;
|
|
85
|
+
'x-kc-submitLabel'?: string;
|
|
86
|
+
'x-kc-dismissible'?: boolean;
|
|
87
|
+
'x-kc-actions'?: { id: string; label: string; variant?: 'default' | 'ghost' | 'outline' }[];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type FormCardEnvelope = CardEnvelope<'form', FormDefinition>;
|
|
91
|
+
|
|
92
|
+
/** The internal widget identifiers `widgetFor` resolves to. */
|
|
93
|
+
export type WidgetKind =
|
|
94
|
+
| 'text'
|
|
95
|
+
| 'textarea'
|
|
96
|
+
| 'password'
|
|
97
|
+
| 'email'
|
|
98
|
+
| 'url'
|
|
99
|
+
| 'date'
|
|
100
|
+
| 'datetime'
|
|
101
|
+
| 'time'
|
|
102
|
+
| 'number'
|
|
103
|
+
| 'slider'
|
|
104
|
+
| 'rating'
|
|
105
|
+
| 'switch'
|
|
106
|
+
| 'checkbox'
|
|
107
|
+
| 'radio'
|
|
108
|
+
| 'select'
|
|
109
|
+
| 'checkbox-group'
|
|
110
|
+
| 'multiselect'
|
|
111
|
+
| 'repeater'
|
|
112
|
+
| 'taglist'
|
|
113
|
+
| 'fieldset'
|
|
114
|
+
| 'unsupported';
|
|
115
|
+
|
|
116
|
+
export const DEFAULT_INLINE_MAX = 4;
|
|
117
|
+
|
|
118
|
+
const VALID_HINTS = new Set([
|
|
119
|
+
'textarea',
|
|
120
|
+
'slider',
|
|
121
|
+
'rating',
|
|
122
|
+
'radio',
|
|
123
|
+
'select',
|
|
124
|
+
'checkbox',
|
|
125
|
+
'password',
|
|
126
|
+
'switch',
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
130
|
+
// Pure mapping / validation / coercion helpers (unit-tested in isolation).
|
|
131
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
/** Resolve the widget for a field. An explicit valid `x-kc-widget` always wins;
|
|
134
|
+
* otherwise the type/format/enum/constraint combination selects the widget. */
|
|
135
|
+
export function widgetFor(field: FormField, inlineMax: number): WidgetKind {
|
|
136
|
+
const hint = field['x-kc-widget'];
|
|
137
|
+
if (hint && VALID_HINTS.has(hint)) {
|
|
138
|
+
switch (hint) {
|
|
139
|
+
case 'textarea':
|
|
140
|
+
return 'textarea';
|
|
141
|
+
case 'slider':
|
|
142
|
+
return 'slider';
|
|
143
|
+
case 'rating':
|
|
144
|
+
return 'rating';
|
|
145
|
+
case 'radio':
|
|
146
|
+
return 'radio';
|
|
147
|
+
case 'select':
|
|
148
|
+
return 'select';
|
|
149
|
+
case 'checkbox':
|
|
150
|
+
return 'checkbox';
|
|
151
|
+
case 'password':
|
|
152
|
+
return 'password';
|
|
153
|
+
case 'switch':
|
|
154
|
+
return 'switch';
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
switch (field.type) {
|
|
159
|
+
case 'string': {
|
|
160
|
+
if (Array.isArray(field.enum)) {
|
|
161
|
+
return field.enum.length <= inlineMax ? 'radio' : 'select';
|
|
162
|
+
}
|
|
163
|
+
switch (field.format) {
|
|
164
|
+
case 'email':
|
|
165
|
+
return 'email';
|
|
166
|
+
case 'uri':
|
|
167
|
+
case 'url':
|
|
168
|
+
return 'url';
|
|
169
|
+
case 'date':
|
|
170
|
+
return 'date';
|
|
171
|
+
case 'date-time':
|
|
172
|
+
return 'datetime';
|
|
173
|
+
case 'time':
|
|
174
|
+
return 'time';
|
|
175
|
+
}
|
|
176
|
+
if (field.maxLength !== undefined && field.maxLength > 120) return 'textarea';
|
|
177
|
+
return 'text';
|
|
178
|
+
}
|
|
179
|
+
case 'number':
|
|
180
|
+
case 'integer':
|
|
181
|
+
return 'number';
|
|
182
|
+
case 'boolean':
|
|
183
|
+
return 'switch';
|
|
184
|
+
case 'array': {
|
|
185
|
+
const items = field.items;
|
|
186
|
+
if (items && 'enum' in items && Array.isArray(items.enum)) {
|
|
187
|
+
return items.enum.length <= inlineMax ? 'checkbox-group' : 'multiselect';
|
|
188
|
+
}
|
|
189
|
+
if (items && 'type' in items && (items as FormField).type === 'object') return 'repeater';
|
|
190
|
+
if (items && 'type' in items && (items as FormField).type === 'string') return 'taglist';
|
|
191
|
+
return 'taglist';
|
|
192
|
+
}
|
|
193
|
+
case 'object':
|
|
194
|
+
return 'fieldset';
|
|
195
|
+
default:
|
|
196
|
+
return 'unsupported';
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Humanize a camelCase / snake_case property key into a label. */
|
|
201
|
+
export function humanize(key: string): string {
|
|
202
|
+
const spaced = key
|
|
203
|
+
.replace(/[_-]+/g, ' ')
|
|
204
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
205
|
+
.trim();
|
|
206
|
+
return spaced.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Field order: `x-kc-order` (filtered to known keys, missing appended) if
|
|
210
|
+
* present, else `required` first then schema declaration order. */
|
|
211
|
+
export function orderedKeys(def: FormDefinition): string[] {
|
|
212
|
+
const all = Object.keys(def.properties ?? {});
|
|
213
|
+
const order = def['x-kc-order'];
|
|
214
|
+
if (Array.isArray(order)) {
|
|
215
|
+
const known = order.filter((k) => all.includes(k));
|
|
216
|
+
const rest = all.filter((k) => !known.includes(k));
|
|
217
|
+
return [...known, ...rest];
|
|
218
|
+
}
|
|
219
|
+
const required = (def.required ?? []).filter((k) => all.includes(k));
|
|
220
|
+
const rest = all.filter((k) => !required.includes(k));
|
|
221
|
+
return [...required, ...rest];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Coerce a raw control value to the field's JSON type. Empty number string →
|
|
225
|
+
* undefined; number/integer → Number; boolean → real boolean. */
|
|
226
|
+
export function coerceValue(field: FormField, raw: unknown): unknown {
|
|
227
|
+
if (field.type === 'number' || field.type === 'integer') {
|
|
228
|
+
if (raw === '' || raw === null || raw === undefined) return undefined;
|
|
229
|
+
const n = typeof raw === 'number' ? raw : Number(raw);
|
|
230
|
+
return Number.isNaN(n) ? raw : n;
|
|
231
|
+
}
|
|
232
|
+
if (field.type === 'boolean') return Boolean(raw);
|
|
233
|
+
return raw;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const EMAIL_RE = '^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$';
|
|
237
|
+
|
|
238
|
+
/** Translate a FormField into the lean-validator JsonSchema (incl. format→pattern). */
|
|
239
|
+
function toJsonSchema(field: FormField): JsonSchema {
|
|
240
|
+
const s: JsonSchema = { type: field.type };
|
|
241
|
+
if (field.enum) s.enum = field.enum;
|
|
242
|
+
if (field.minimum !== undefined) s.minimum = field.minimum;
|
|
243
|
+
if (field.maximum !== undefined) s.maximum = field.maximum;
|
|
244
|
+
if (field.minLength !== undefined) s.minLength = field.minLength;
|
|
245
|
+
if (field.maxLength !== undefined) s.maxLength = field.maxLength;
|
|
246
|
+
if (field.minItems !== undefined) s.minItems = field.minItems;
|
|
247
|
+
if (field.maxItems !== undefined) s.maxItems = field.maxItems;
|
|
248
|
+
if (field.pattern !== undefined) s.pattern = field.pattern;
|
|
249
|
+
else if (field.format === 'email') s.pattern = EMAIL_RE;
|
|
250
|
+
return s;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export interface FormValidation {
|
|
254
|
+
valid: boolean;
|
|
255
|
+
fieldErrors: Record<string, string>;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function isEmpty(v: unknown): boolean {
|
|
259
|
+
return v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Full client-side validation of `values` against the form definition. Returns a
|
|
263
|
+
* per-field error map (the contract validator subset, applied field-by-field so
|
|
264
|
+
* each field can show its own inline message). */
|
|
265
|
+
export function validateForm(def: FormDefinition, values: Record<string, unknown>): FormValidation {
|
|
266
|
+
const fieldErrors: Record<string, string> = {};
|
|
267
|
+
const required = new Set(def.required ?? []);
|
|
268
|
+
|
|
269
|
+
for (const [key, field] of Object.entries(def.properties ?? {})) {
|
|
270
|
+
const v = values[key];
|
|
271
|
+
if (required.has(key) && isEmpty(v)) {
|
|
272
|
+
fieldErrors[key] = `${field.title ?? humanize(key)} is required.`;
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (isEmpty(v)) continue; // optional + empty → skip per-field checks
|
|
276
|
+
const result = validateAgainstSchema(toJsonSchema(field), v);
|
|
277
|
+
if (!result.valid) {
|
|
278
|
+
fieldErrors[key] = friendlyError(field, key, result.errors[0]);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return { valid: Object.keys(fieldErrors).length === 0, fieldErrors };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function friendlyError(field: FormField, key: string, raw?: string): string {
|
|
286
|
+
const label = field.title ?? humanize(key);
|
|
287
|
+
if (!raw) return `${label} is invalid.`;
|
|
288
|
+
if (raw.includes('minimum')) return `${label} must be at least ${field.minimum}.`;
|
|
289
|
+
if (raw.includes('maximum')) return `${label} must be at most ${field.maximum}.`;
|
|
290
|
+
if (raw.includes('minLength')) return `${label} must be at least ${field.minLength} characters.`;
|
|
291
|
+
if (raw.includes('maxLength')) return `${label} must be at most ${field.maxLength} characters.`;
|
|
292
|
+
if (raw.includes('pattern')) {
|
|
293
|
+
return field.format === 'email'
|
|
294
|
+
? `${label} must be a valid email address.`
|
|
295
|
+
: `${label} is not in the expected format.`;
|
|
296
|
+
}
|
|
297
|
+
if (raw.includes('one of')) return `${label} must be one of the allowed options.`;
|
|
298
|
+
if (raw.includes('expected integer')) return `${label} must be a whole number.`;
|
|
299
|
+
if (raw.includes('expected')) return `${label} is invalid.`;
|
|
300
|
+
if (raw.includes('minItems')) return `${label}: choose at least ${field.minItems}.`;
|
|
301
|
+
if (raw.includes('maxItems')) return `${label}: choose at most ${field.maxItems}.`;
|
|
302
|
+
return `${label} is invalid.`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Build the result object: coerced values with empty optional fields omitted.
|
|
306
|
+
* `false` and `0` are kept (they are real values, not "empty"). */
|
|
307
|
+
export function buildResult(
|
|
308
|
+
def: FormDefinition,
|
|
309
|
+
values: Record<string, unknown>,
|
|
310
|
+
): Record<string, unknown> {
|
|
311
|
+
const out: Record<string, unknown> = {};
|
|
312
|
+
for (const key of Object.keys(def.properties ?? {})) {
|
|
313
|
+
const field = def.properties[key];
|
|
314
|
+
const coerced = coerceValue(field, values[key]);
|
|
315
|
+
if (coerced === undefined || coerced === '' ) continue;
|
|
316
|
+
if (Array.isArray(coerced) && coerced.length === 0) continue;
|
|
317
|
+
out[key] = coerced;
|
|
318
|
+
}
|
|
319
|
+
return out;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
323
|
+
// The <Form> component.
|
|
324
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
325
|
+
|
|
326
|
+
export interface FormProps {
|
|
327
|
+
/** The form definition (CardEnvelope.data). */
|
|
328
|
+
data?: FormDefinition;
|
|
329
|
+
/** The card id used to correlate every emitted CardEvent. */
|
|
330
|
+
cardId?: string;
|
|
331
|
+
/** The envelope title rendered in the card chrome. */
|
|
332
|
+
heading?: string;
|
|
333
|
+
/** Optional explicit CardHost (otherwise read from a CardProvider, otherwise the
|
|
334
|
+
* bubbling `kc-card` CustomEvent off `hostElement`). */
|
|
335
|
+
host?: CardHost;
|
|
336
|
+
/** The custom-element host node, for the bubbling `kc-card` fallback emit. */
|
|
337
|
+
hostElement?: HTMLElement;
|
|
338
|
+
class?: string;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const DEFAULT_FORM: FormDefinition = { type: 'object', properties: {} };
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* `Form` — renders a JSON-Schema form definition into themed, accessible widgets
|
|
345
|
+
* inside `Card` chrome, validates input against that schema, and emits the
|
|
346
|
+
* collected, coerced, validated object up the Card contract as `submit-data`.
|
|
347
|
+
* Reads context/emits via a `CardProvider` when present, else the bubbling
|
|
348
|
+
* `kc-card` CustomEvent.
|
|
349
|
+
*/
|
|
350
|
+
export function Form(props: FormProps): JSX.Element {
|
|
351
|
+
const merged = mergeProps({ cardId: 'kc-form' }, props);
|
|
352
|
+
const [local] = splitProps(merged, ['data', 'cardId', 'heading', 'host', 'hostElement', 'class']);
|
|
353
|
+
|
|
354
|
+
const ctxHost = useCardHost();
|
|
355
|
+
|
|
356
|
+
const emit = (event: CardEvent): void => {
|
|
357
|
+
const h = local.host ?? ctxHost;
|
|
358
|
+
if (h) h.emit(event);
|
|
359
|
+
else if (local.hostElement) emitCardEvent(local.hostElement, event);
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
// Validate the incoming definition against form.schema.json's shape (the lean
|
|
363
|
+
// subset). A malformed definition → inline error + an `error` event.
|
|
364
|
+
const envelopeValid = createMemo(() => {
|
|
365
|
+
const d = local.data;
|
|
366
|
+
if (!d) return { ok: false, message: 'No form definition provided.' };
|
|
367
|
+
if (d.type !== 'object' || typeof d.properties !== 'object' || d.properties === null) {
|
|
368
|
+
return { ok: false, message: "This form couldn't be displayed." };
|
|
369
|
+
}
|
|
370
|
+
return { ok: true as const, message: '' };
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
const def = createMemo<FormDefinition>(() => (envelopeValid().ok ? local.data ?? DEFAULT_FORM : DEFAULT_FORM));
|
|
374
|
+
const inlineMax = () => def()['x-kc-inlineMax'] ?? DEFAULT_INLINE_MAX;
|
|
375
|
+
const keys = createMemo(() => orderedKeys(def()));
|
|
376
|
+
|
|
377
|
+
// The reactive values store, seeded from each field's `default`.
|
|
378
|
+
const [values, setValues] = createStore<Record<string, unknown>>({});
|
|
379
|
+
const [errors, setErrors] = createStore<Record<string, string>>({});
|
|
380
|
+
const [submitted, setSubmitted] = createSignal(false);
|
|
381
|
+
|
|
382
|
+
const seed = (d: FormDefinition): void => {
|
|
383
|
+
const next: Record<string, unknown> = {};
|
|
384
|
+
for (const [key, field] of Object.entries(d.properties ?? {})) {
|
|
385
|
+
if (field.default !== undefined) next[key] = field.default;
|
|
386
|
+
else if (field.type === 'array') next[key] = [];
|
|
387
|
+
}
|
|
388
|
+
setValues(produce((s) => {
|
|
389
|
+
for (const k of Object.keys(s)) delete s[k];
|
|
390
|
+
Object.assign(s, next);
|
|
391
|
+
}));
|
|
392
|
+
setErrors(produce((s) => {
|
|
393
|
+
for (const k of Object.keys(s)) delete s[k];
|
|
394
|
+
}));
|
|
395
|
+
setSubmitted(false);
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
// Reseed whenever a NEW valid definition arrives.
|
|
399
|
+
createEffect(on(() => local.data, () => { if (envelopeValid().ok) seed(def()); }));
|
|
400
|
+
|
|
401
|
+
// ready + error lifecycle emits.
|
|
402
|
+
createEffect(on(envelopeValid, (state) => {
|
|
403
|
+
if (state.ok) emit({ kind: 'ready', cardId: local.cardId });
|
|
404
|
+
else emit({ kind: 'error', cardId: local.cardId, message: state.message });
|
|
405
|
+
}));
|
|
406
|
+
|
|
407
|
+
const setField = (key: string, raw: unknown): void => {
|
|
408
|
+
setValues(key, raw);
|
|
409
|
+
if (errors[key]) setErrors(key, undefined as unknown as string);
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
const validateField = (key: string): void => {
|
|
413
|
+
const field = def().properties[key];
|
|
414
|
+
if (!field) return;
|
|
415
|
+
const single = validateForm(
|
|
416
|
+
{ type: 'object', required: def().required, properties: { [key]: field } },
|
|
417
|
+
{ [key]: values[key] },
|
|
418
|
+
);
|
|
419
|
+
setErrors(key, single.fieldErrors[key]);
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
const onSubmit = (e: Event): void => {
|
|
423
|
+
e.preventDefault();
|
|
424
|
+
// Capture the <form> synchronously — `e.currentTarget` is nulled out once the
|
|
425
|
+
// event has finished dispatching (so it can't be read in a later microtask).
|
|
426
|
+
const formEl = e.currentTarget as HTMLElement | null;
|
|
427
|
+
const snapshot = unwrap(values);
|
|
428
|
+
const result = validateForm(def(), snapshot as Record<string, unknown>);
|
|
429
|
+
setErrors(produce((s) => {
|
|
430
|
+
for (const k of Object.keys(s)) delete s[k];
|
|
431
|
+
Object.assign(s, result.fieldErrors);
|
|
432
|
+
}));
|
|
433
|
+
if (!result.valid) {
|
|
434
|
+
const firstBad = keys().find((k) => result.fieldErrors[k]);
|
|
435
|
+
if (firstBad && local.hostElement) {
|
|
436
|
+
// Focus the first invalid control (light-DOM query inside shadow root).
|
|
437
|
+
queueMicrotask(() => {
|
|
438
|
+
const root: ParentNode = formEl?.closest('form') ?? formEl ?? document;
|
|
439
|
+
root.querySelector<HTMLElement>(`[data-field="${cssEscape(firstBad)}"] [data-control]`)?.focus();
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
const out = buildResult(def(), snapshot as Record<string, unknown>);
|
|
445
|
+
emit({ kind: 'submit-data', cardId: local.cardId, data: out });
|
|
446
|
+
setSubmitted(true);
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
const actions = createMemo(() => def()['x-kc-actions'] ?? []);
|
|
450
|
+
const submitLabel = () => def()['x-kc-submitLabel'] ?? 'Submit';
|
|
451
|
+
const dismissible = () => def()['x-kc-dismissible'] === true;
|
|
452
|
+
|
|
453
|
+
return (
|
|
454
|
+
<Show
|
|
455
|
+
when={envelopeValid().ok}
|
|
456
|
+
fallback={<Card heading={local.heading} errorMessage={envelopeValid().message} />}
|
|
457
|
+
>
|
|
458
|
+
<ErrorBoundary
|
|
459
|
+
fallback={() => {
|
|
460
|
+
emit({ kind: 'error', cardId: local.cardId, message: 'The form failed to render.' });
|
|
461
|
+
return <Card heading={local.heading} errorMessage="The form failed to render." />;
|
|
462
|
+
}}
|
|
463
|
+
>
|
|
464
|
+
<Card
|
|
465
|
+
heading={local.heading ?? def().title}
|
|
466
|
+
description={def().description}
|
|
467
|
+
actions={
|
|
468
|
+
<div class="flex w-full flex-wrap items-center justify-between gap-2">
|
|
469
|
+
<Show when={dismissible()}>
|
|
470
|
+
<Button
|
|
471
|
+
type="button"
|
|
472
|
+
variant="ghost"
|
|
473
|
+
onClick={() => emit({ kind: 'dismiss', cardId: local.cardId })}
|
|
474
|
+
>
|
|
475
|
+
Dismiss
|
|
476
|
+
</Button>
|
|
477
|
+
</Show>
|
|
478
|
+
<div class="ml-auto flex flex-wrap items-center gap-2">
|
|
479
|
+
<For each={actions()}>
|
|
480
|
+
{(action) => (
|
|
481
|
+
<Button
|
|
482
|
+
type="button"
|
|
483
|
+
variant={action.variant ?? 'ghost'}
|
|
484
|
+
onClick={() => emit({ kind: 'action', cardId: local.cardId, action: action.id })}
|
|
485
|
+
>
|
|
486
|
+
{action.label}
|
|
487
|
+
</Button>
|
|
488
|
+
)}
|
|
489
|
+
</For>
|
|
490
|
+
<Button type="submit" form={formId()} disabled={submitted()}>
|
|
491
|
+
{submitLabel()}
|
|
492
|
+
</Button>
|
|
493
|
+
</div>
|
|
494
|
+
</div>
|
|
495
|
+
}
|
|
496
|
+
>
|
|
497
|
+
<form
|
|
498
|
+
id={formId()}
|
|
499
|
+
class={cn('flex flex-col gap-3', local.class)}
|
|
500
|
+
novalidate
|
|
501
|
+
onSubmit={onSubmit}
|
|
502
|
+
>
|
|
503
|
+
<For each={keys()}>
|
|
504
|
+
{(key) => (
|
|
505
|
+
<FieldRow
|
|
506
|
+
fieldKey={key}
|
|
507
|
+
field={def().properties[key]}
|
|
508
|
+
required={(def().required ?? []).includes(key)}
|
|
509
|
+
inlineMax={inlineMax()}
|
|
510
|
+
value={() => values[key]}
|
|
511
|
+
error={() => errors[key]}
|
|
512
|
+
disabled={submitted()}
|
|
513
|
+
onInput={(v) => setField(key, v)}
|
|
514
|
+
onBlur={() => validateField(key)}
|
|
515
|
+
/>
|
|
516
|
+
)}
|
|
517
|
+
</For>
|
|
518
|
+
|
|
519
|
+
<Show when={submitted()}>
|
|
520
|
+
<p role="status" class="text-sm text-muted-foreground">
|
|
521
|
+
Submitted. Thank you.
|
|
522
|
+
</p>
|
|
523
|
+
</Show>
|
|
524
|
+
</form>
|
|
525
|
+
</Card>
|
|
526
|
+
</ErrorBoundary>
|
|
527
|
+
</Show>
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// A stable per-instance form id so the footer submit button can target the form.
|
|
532
|
+
let formIdCounter = 0;
|
|
533
|
+
const formIdValue = `kc-form-${++formIdCounter}`;
|
|
534
|
+
function formId(): string {
|
|
535
|
+
return formIdValue;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
539
|
+
// Per-field row: label + control + help + error, dispatching to the right widget.
|
|
540
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
541
|
+
|
|
542
|
+
interface FieldRowProps {
|
|
543
|
+
fieldKey: string;
|
|
544
|
+
field: FormField;
|
|
545
|
+
required: boolean;
|
|
546
|
+
inlineMax: number;
|
|
547
|
+
value: () => unknown;
|
|
548
|
+
error: () => string | undefined;
|
|
549
|
+
disabled: boolean;
|
|
550
|
+
onInput: (value: unknown) => void;
|
|
551
|
+
onBlur: () => void;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function FieldRow(props: FieldRowProps): JSX.Element {
|
|
555
|
+
const id = `f-${props.fieldKey}-${Math.random().toString(36).slice(2, 8)}`;
|
|
556
|
+
const errorId = `${id}-err`;
|
|
557
|
+
const descId = `${id}-desc`;
|
|
558
|
+
const label = () => props.field.title ?? humanize(props.fieldKey);
|
|
559
|
+
const widget = createMemo(() => widgetFor(props.field, props.inlineMax));
|
|
560
|
+
const placeholder = () => props.field['x-kc-placeholder'];
|
|
561
|
+
const describedBy = () =>
|
|
562
|
+
[props.field.description ? descId : '', props.error() ? errorId : '']
|
|
563
|
+
.filter(Boolean)
|
|
564
|
+
.join(' ') || undefined;
|
|
565
|
+
|
|
566
|
+
const common = () => ({
|
|
567
|
+
id,
|
|
568
|
+
value: props.value(),
|
|
569
|
+
field: props.field,
|
|
570
|
+
disabled: props.disabled || props.field.readOnly === true,
|
|
571
|
+
placeholder: placeholder(),
|
|
572
|
+
required: props.required,
|
|
573
|
+
invalid: Boolean(props.error()),
|
|
574
|
+
describedBy: describedBy(),
|
|
575
|
+
label: label(),
|
|
576
|
+
onInput: props.onInput,
|
|
577
|
+
onBlur: props.onBlur,
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
// A nested fieldset / repeater / checkbox-group provide their own grouping
|
|
581
|
+
// label, so the row's <label> is only rendered for simple single controls.
|
|
582
|
+
const isGrouped = () =>
|
|
583
|
+
['fieldset', 'repeater', 'checkbox-group', 'multiselect', 'radio', 'taglist'].includes(widget());
|
|
584
|
+
|
|
585
|
+
return (
|
|
586
|
+
<div class="flex flex-col gap-2 rounded-xl bg-muted/40 p-3.5" data-field={props.fieldKey}>
|
|
587
|
+
<Show when={!isGrouped()}>
|
|
588
|
+
<label for={id} class="text-sm font-medium text-foreground">
|
|
589
|
+
{label()}
|
|
590
|
+
<Show when={props.required}>
|
|
591
|
+
<span class="text-destructive dark:text-red-400" aria-hidden="true">{' *'}</span>
|
|
592
|
+
</Show>
|
|
593
|
+
</label>
|
|
594
|
+
</Show>
|
|
595
|
+
|
|
596
|
+
<Show when={props.field.description}>
|
|
597
|
+
<p id={descId} class="text-xs text-muted-foreground">
|
|
598
|
+
{props.field.description}
|
|
599
|
+
</p>
|
|
600
|
+
</Show>
|
|
601
|
+
|
|
602
|
+
<WidgetSwitch widget={widget()} common={common()} fieldKey={props.fieldKey} />
|
|
603
|
+
|
|
604
|
+
<Show when={props.error()}>
|
|
605
|
+
<p id={errorId} role="alert" class="text-xs text-destructive dark:text-red-400">
|
|
606
|
+
{props.error()}
|
|
607
|
+
</p>
|
|
608
|
+
</Show>
|
|
609
|
+
</div>
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
interface WidgetSwitchProps {
|
|
614
|
+
widget: WidgetKind;
|
|
615
|
+
fieldKey: string;
|
|
616
|
+
common: ReturnType<FieldRowCommon>;
|
|
617
|
+
}
|
|
618
|
+
type FieldRowCommon = () => {
|
|
619
|
+
id: string;
|
|
620
|
+
value: unknown;
|
|
621
|
+
field: FormField;
|
|
622
|
+
disabled: boolean;
|
|
623
|
+
placeholder?: string;
|
|
624
|
+
required: boolean;
|
|
625
|
+
invalid: boolean;
|
|
626
|
+
describedBy?: string;
|
|
627
|
+
label: string;
|
|
628
|
+
onInput: (v: unknown) => void;
|
|
629
|
+
onBlur: () => void;
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
/** Dispatch to the concrete widget for `widget`. */
|
|
633
|
+
function WidgetSwitch(props: WidgetSwitchProps): JSX.Element {
|
|
634
|
+
const w = () => props.widget;
|
|
635
|
+
const c = () => props.common;
|
|
636
|
+
return (
|
|
637
|
+
<>
|
|
638
|
+
<Show when={w() === 'text' || w() === 'email' || w() === 'url' || w() === 'date' || w() === 'datetime' || w() === 'time' || w() === 'password'}>
|
|
639
|
+
<TextWidget {...c()} variant={w() as 'text' | 'email' | 'url' | 'date' | 'datetime' | 'time' | 'password'} />
|
|
640
|
+
</Show>
|
|
641
|
+
<Show when={w() === 'textarea'}>
|
|
642
|
+
<TextareaWidget {...c()} />
|
|
643
|
+
</Show>
|
|
644
|
+
<Show when={w() === 'number'}>
|
|
645
|
+
<NumberWidget {...c()} />
|
|
646
|
+
</Show>
|
|
647
|
+
<Show when={w() === 'slider'}>
|
|
648
|
+
<SliderWidget {...c()} />
|
|
649
|
+
</Show>
|
|
650
|
+
<Show when={w() === 'rating'}>
|
|
651
|
+
<RatingWidget {...c()} />
|
|
652
|
+
</Show>
|
|
653
|
+
<Show when={w() === 'switch'}>
|
|
654
|
+
<SwitchWidget {...c()} />
|
|
655
|
+
</Show>
|
|
656
|
+
<Show when={w() === 'checkbox'}>
|
|
657
|
+
<CheckboxWidget {...c()} />
|
|
658
|
+
</Show>
|
|
659
|
+
<Show when={w() === 'radio'}>
|
|
660
|
+
<RadioGroupWidget {...c()} />
|
|
661
|
+
</Show>
|
|
662
|
+
<Show when={w() === 'select'}>
|
|
663
|
+
<SelectWidget {...c()} />
|
|
664
|
+
</Show>
|
|
665
|
+
<Show when={w() === 'checkbox-group'}>
|
|
666
|
+
<CheckboxGroupWidget {...c()} />
|
|
667
|
+
</Show>
|
|
668
|
+
<Show when={w() === 'multiselect'}>
|
|
669
|
+
<MultiSelectWidget {...c()} />
|
|
670
|
+
</Show>
|
|
671
|
+
<Show when={w() === 'taglist'}>
|
|
672
|
+
<TagListWidget {...c()} />
|
|
673
|
+
</Show>
|
|
674
|
+
<Show when={w() === 'repeater'}>
|
|
675
|
+
<RepeaterWidget {...c()} inlineMax={DEFAULT_INLINE_MAX} />
|
|
676
|
+
</Show>
|
|
677
|
+
<Show when={w() === 'fieldset'}>
|
|
678
|
+
<FieldsetWidget {...c()} inlineMax={DEFAULT_INLINE_MAX} />
|
|
679
|
+
</Show>
|
|
680
|
+
<Show when={w() === 'unsupported'}>
|
|
681
|
+
<p class="rounded-md border border-dashed border-border p-2 text-xs text-muted-foreground">
|
|
682
|
+
Unsupported field "{props.fieldKey}".
|
|
683
|
+
</p>
|
|
684
|
+
</Show>
|
|
685
|
+
</>
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
690
|
+
// Composite widgets that need the FormField recursion (fieldset + repeater).
|
|
691
|
+
// They live here (not form-widgets) to reuse FieldRow/Switch + the helpers.
|
|
692
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
693
|
+
|
|
694
|
+
interface CompositeProps {
|
|
695
|
+
id: string;
|
|
696
|
+
value: unknown;
|
|
697
|
+
field: FormField;
|
|
698
|
+
disabled: boolean;
|
|
699
|
+
required: boolean;
|
|
700
|
+
invalid: boolean;
|
|
701
|
+
describedBy?: string;
|
|
702
|
+
label: string;
|
|
703
|
+
inlineMax: number;
|
|
704
|
+
onInput: (v: unknown) => void;
|
|
705
|
+
onBlur: () => void;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function FieldsetWidget(props: CompositeProps): JSX.Element {
|
|
709
|
+
const subProps = () => props.field.properties ?? {};
|
|
710
|
+
const obj = () => (props.value && typeof props.value === 'object' ? (props.value as Record<string, unknown>) : {});
|
|
711
|
+
const setKey = (k: string, v: unknown): void => {
|
|
712
|
+
props.onInput({ ...obj(), [k]: v });
|
|
713
|
+
};
|
|
714
|
+
return (
|
|
715
|
+
<fieldset class="flex flex-col gap-3 rounded-lg border border-border p-3">
|
|
716
|
+
<legend class="px-1 text-sm font-medium text-foreground">{props.label}</legend>
|
|
717
|
+
<For each={Object.keys(subProps())}>
|
|
718
|
+
{(k) => (
|
|
719
|
+
<FieldRow
|
|
720
|
+
fieldKey={k}
|
|
721
|
+
field={subProps()[k]}
|
|
722
|
+
required={(props.field.required ?? []).includes(k)}
|
|
723
|
+
inlineMax={props.inlineMax}
|
|
724
|
+
value={() => obj()[k]}
|
|
725
|
+
error={() => undefined}
|
|
726
|
+
disabled={props.disabled}
|
|
727
|
+
onInput={(v) => setKey(k, v)}
|
|
728
|
+
onBlur={props.onBlur}
|
|
729
|
+
/>
|
|
730
|
+
)}
|
|
731
|
+
</For>
|
|
732
|
+
</fieldset>
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function RepeaterWidget(props: CompositeProps): JSX.Element {
|
|
737
|
+
const itemSchema = () => (props.field.items as FormField) ?? { type: 'object', properties: {} };
|
|
738
|
+
const rows = () => (Array.isArray(props.value) ? (props.value as unknown[]) : []);
|
|
739
|
+
const setRows = (next: unknown[]): void => props.onInput(next);
|
|
740
|
+
const addRow = (): void => setRows([...rows(), {}]);
|
|
741
|
+
const removeRow = (i: number): void => setRows(rows().filter((_, idx) => idx !== i));
|
|
742
|
+
const setRowKey = (i: number, k: string, v: unknown): void => {
|
|
743
|
+
const next = rows().slice();
|
|
744
|
+
next[i] = { ...(next[i] as Record<string, unknown>), [k]: v };
|
|
745
|
+
setRows(next);
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
return (
|
|
749
|
+
<fieldset class="flex flex-col gap-3 rounded-lg border border-border p-3" data-control>
|
|
750
|
+
<legend class="px-1 text-sm font-medium text-foreground">{props.label}</legend>
|
|
751
|
+
<Index each={rows()}>
|
|
752
|
+
{(row, i) => (
|
|
753
|
+
<div class="flex flex-col gap-2 rounded-md border border-border/60 p-2">
|
|
754
|
+
<div class="flex items-center justify-between">
|
|
755
|
+
<span class="text-xs text-muted-foreground">Item {i + 1}</span>
|
|
756
|
+
<Button
|
|
757
|
+
type="button"
|
|
758
|
+
size="icon-sm"
|
|
759
|
+
variant="ghost"
|
|
760
|
+
aria-label={`Remove row ${i + 1}`}
|
|
761
|
+
disabled={props.disabled}
|
|
762
|
+
onClick={() => removeRow(i)}
|
|
763
|
+
>
|
|
764
|
+
✕
|
|
765
|
+
</Button>
|
|
766
|
+
</div>
|
|
767
|
+
<For each={Object.keys(itemSchema().properties ?? {})}>
|
|
768
|
+
{(k) => (
|
|
769
|
+
<FieldRow
|
|
770
|
+
fieldKey={k}
|
|
771
|
+
field={itemSchema().properties![k]}
|
|
772
|
+
required={(itemSchema().required ?? []).includes(k)}
|
|
773
|
+
inlineMax={props.inlineMax}
|
|
774
|
+
value={() => (row() as Record<string, unknown>)?.[k]}
|
|
775
|
+
error={() => undefined}
|
|
776
|
+
disabled={props.disabled}
|
|
777
|
+
onInput={(v) => setRowKey(i, k, v)}
|
|
778
|
+
onBlur={props.onBlur}
|
|
779
|
+
/>
|
|
780
|
+
)}
|
|
781
|
+
</For>
|
|
782
|
+
</div>
|
|
783
|
+
)}
|
|
784
|
+
</Index>
|
|
785
|
+
<Button type="button" variant="outline" size="sm" disabled={props.disabled} onClick={addRow}>
|
|
786
|
+
Add item
|
|
787
|
+
</Button>
|
|
788
|
+
</fieldset>
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/** Minimal CSS.escape fallback for attribute-selector building. */
|
|
793
|
+
function cssEscape(s: string): string {
|
|
794
|
+
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') return CSS.escape(s);
|
|
795
|
+
return s.replace(/["\\]/g, '\\$&');
|
|
796
|
+
}
|