@dragcraft/form-generator 0.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-PRESENT hackycy <https://github.com/hackycy>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,357 @@
1
+ import { Component, ComputedRef, InjectionKey, PropType, Ref, VNodeChild } from "vue";
2
+ import { I18nInstance } from "@dragcraft/i18n";
3
+ //#region src/types.d.ts
4
+ /**
5
+ * Context object passed to visible/disabled predicates.
6
+ * Provides access to the current form values so fields can react to each other.
7
+ */
8
+ interface FormContext {
9
+ /** Current flat key-value map of all form field values */
10
+ values: Record<string, unknown>;
11
+ }
12
+ /**
13
+ * Validation rule for a single field.
14
+ */
15
+ interface ValidationRule {
16
+ /** If true (or predicate returns true), the field must have a non-empty/non-null value */
17
+ required?: boolean | ((ctx: FormContext) => boolean);
18
+ /** Error message to display when validation fails */
19
+ message?: string;
20
+ /** Custom validator returning true (pass) or a string (error message) */
21
+ validator?: (value: unknown, ctx: FormContext) => boolean | string;
22
+ /** Minimum value (number only) */
23
+ min?: number;
24
+ /** Maximum value (number only) */
25
+ max?: number;
26
+ /** Minimum string length */
27
+ minLength?: number;
28
+ /** Maximum string length */
29
+ maxLength?: number;
30
+ /** Regex pattern the string must match */
31
+ pattern?: RegExp;
32
+ /** Value must be one of these */
33
+ enum?: unknown[];
34
+ }
35
+ /**
36
+ * Validation error associated with a specific field.
37
+ */
38
+ interface ValidationError {
39
+ key: string;
40
+ message: string;
41
+ }
42
+ /**
43
+ * Describes a single form field.
44
+ */
45
+ type FieldComponentProps<Props extends object = Record<string, unknown>> = Props | ((ctx: FormContext) => Props);
46
+ /**
47
+ * Reactive form state available to an inline field render factory.
48
+ */
49
+ interface FieldRenderContext {
50
+ /** Dependency-resolved field schema. */
51
+ field: ComputedRef<FieldSchema>;
52
+ /** Reactive flat key-value map of all form field values. */
53
+ values: Record<string, unknown>;
54
+ /** Current formatted field value. */
55
+ value: ComputedRef<unknown>;
56
+ /** Resolved global and field-level disabled state. */
57
+ disabled: ComputedRef<boolean>;
58
+ /** Resolved static or dynamic component props, including i18n transforms. */
59
+ componentProps: ComputedRef<Record<string, unknown>>;
60
+ /** Translate a message key, with an optional fallback. */
61
+ t: (key: string, fallback?: string) => string;
62
+ /** Write a value through the field parse, change, and validation pipeline. */
63
+ setValue: (value: unknown) => void;
64
+ /** Validate the field against its current resolved schema. */
65
+ validate: () => void;
66
+ }
67
+ /**
68
+ * Creates inline field content from reactive form state.
69
+ *
70
+ * Any function assigned to FieldSchema.component is treated as a render
71
+ * factory. Reusable Vue functional components must be registered under a
72
+ * string key in FieldComponentMap.
73
+ */
74
+ type FieldRenderFactory = (ctx: FieldRenderContext) => () => VNodeChild;
75
+ type FieldBindingScope = 'node' | 'schema' | 'globalConfig' | 'container';
76
+ interface FieldBindingTarget {
77
+ /** The document area this field writes to. Defaults depend on the host form. */
78
+ scope?: FieldBindingScope;
79
+ /** Dot path inside the selected scope. */
80
+ path: string;
81
+ }
82
+ interface FieldSchema<ComponentType extends string | FieldRenderFactory = string | FieldRenderFactory, ComponentProps extends object = Record<string, unknown>> {
83
+ /** Property key in the values object (e.g., 'text', 'fontSize') */
84
+ key: string;
85
+ /** Human-readable label */
86
+ label: string;
87
+ /** i18n message key for label; overrides `label` when i18n is active */
88
+ labelKey?: string;
89
+ /** i18n message key for placeholder; overrides field.componentProps.placeholder when i18n is active */
90
+ placeholderKey?: string;
91
+ /**
92
+ * i18n key prefix for select/radio option labels.
93
+ * Each option label is resolved as `${optionKeyPrefix}.${option.value}`.
94
+ * Falls back to the static `option.label` when the key is missing.
95
+ */
96
+ optionKeyPrefix?: string;
97
+ /** Registered field component name or an inline field render factory. */
98
+ component: ComponentType;
99
+ /** Optional DSL path binding used by host applications such as the designer. */
100
+ bindTo?: string | FieldBindingTarget;
101
+ /** Props forwarded to the registered UI component. Static or dynamic. */
102
+ componentProps?: FieldComponentProps<ComponentProps>;
103
+ /** Declares which other fields this field depends on, and how to react. */
104
+ dependencies?: FieldDependencies<FieldSchema<ComponentType, ComponentProps>>;
105
+ /** If false (or predicate returns false), field is hidden via CSS (preserves DOM state). */
106
+ show?: boolean | ((ctx: FormContext) => boolean);
107
+ /** If false (or predicate returns false), field is removed from DOM entirely. */
108
+ ifShow?: boolean | ((ctx: FormContext) => boolean);
109
+ /** Transform value before writing to form model (input -> model). */
110
+ parseValue?: (value: unknown, ctx: FormContext) => unknown;
111
+ /** Transform value before passing to component (model -> component). */
112
+ valueFormat?: (value: unknown, ctx: FormContext) => unknown;
113
+ /** Default value used when the actual value is undefined */
114
+ defaultValue?: unknown;
115
+ /** Dynamic visibility predicate */
116
+ visible?: (ctx: FormContext) => boolean;
117
+ /** Dynamic disabled predicate */
118
+ disabled?: (ctx: FormContext) => boolean;
119
+ /** Validation rules */
120
+ rules?: ValidationRule[];
121
+ /** Optional tooltip / help text */
122
+ tooltip?: string;
123
+ /** Number of grid columns this field spans (requires section.columns > 1) */
124
+ span?: number;
125
+ }
126
+ /**
127
+ * Declares dependencies on other fields and how to react when they change.
128
+ */
129
+ interface FieldDependencies<Field extends FieldSchema<string | FieldRenderFactory, object> = FieldSchema> {
130
+ /** Field keys this field depends on. */
131
+ fields: string[];
132
+ /**
133
+ * Called when any dependency field changes.
134
+ * Returns partial FieldSchema overrides.
135
+ * Cannot override key, component, or dependencies (to prevent cycles).
136
+ */
137
+ handler: (form: Record<string, unknown>, fieldValue: unknown) => Partial<Omit<Field, 'key' | 'component' | 'dependencies'>>;
138
+ }
139
+ /**
140
+ * A titled group of fields.
141
+ */
142
+ interface SectionSchema<Field extends FieldSchema<string | FieldRenderFactory, object> = FieldSchema> {
143
+ title: string;
144
+ /** i18n message key for title; overrides `title` when i18n is active */
145
+ titleKey?: string;
146
+ /** Whether the section starts collapsed. Defaults to false. */
147
+ collapsed?: boolean;
148
+ fields: Field[];
149
+ /** Number of grid columns. Defaults to 1 (vertical stack). */
150
+ columns?: number;
151
+ }
152
+ /**
153
+ * Top-level schema that FormGenerator accepts.
154
+ */
155
+ interface FormSchema<Field extends FieldSchema<string | FieldRenderFactory, object> = FieldSchema> {
156
+ sections: Array<SectionSchema<Field>>;
157
+ }
158
+ type TypedFieldSchema<PropsMap extends object> = { [ComponentType in Extract<keyof PropsMap, string>]: PropsMap[ComponentType] extends object ? FieldSchema<ComponentType, PropsMap[ComponentType]> : never; }[Extract<keyof PropsMap, string>] | FieldSchema<FieldRenderFactory>;
159
+ type TypedSectionSchema<PropsMap extends object> = SectionSchema<TypedFieldSchema<PropsMap>>;
160
+ type TypedFormSchema<PropsMap extends object> = FormSchema<TypedFieldSchema<PropsMap>>;
161
+ interface FieldComponentTransformContext {
162
+ field: FieldSchema;
163
+ values: Record<string, unknown>;
164
+ }
165
+ /**
166
+ * Describes how a schema field binds to a concrete Vue UI component.
167
+ */
168
+ interface FieldComponentDefinition {
169
+ /** Vue component rendered for this field type. */
170
+ component: Component;
171
+ /** Prop name used by the UI component as its model value. Defaults to `modelValue`. */
172
+ modelPropName?: string;
173
+ /** Event prop used to receive model updates. Defaults to `onUpdate:modelValue`. */
174
+ updateEventName?: string;
175
+ /** Default UI component props merged before field.componentProps. */
176
+ defaultProps?: Record<string, unknown>;
177
+ /** Transform model value before passing it to the UI component. */
178
+ formatValue?: (value: unknown, ctx: FieldComponentTransformContext) => unknown;
179
+ /** Transform UI component value before writing it to the form model. */
180
+ normalizeValue?: (value: unknown, ctx: FieldComponentTransformContext) => unknown;
181
+ }
182
+ /**
183
+ * Maps field component names (e.g., 'Input', 'Select') to component adapters.
184
+ */
185
+ type FieldComponentMap = Record<string, FieldComponentDefinition>;
186
+ /**
187
+ * Props accepted by the FormGenerator root component.
188
+ */
189
+ interface FormGeneratorProps {
190
+ /** Schema describing sections and fields to render */
191
+ schema: FormSchema;
192
+ /** Current values keyed by field key */
193
+ values: Record<string, unknown>;
194
+ /** Globally disable all fields */
195
+ disabled?: boolean;
196
+ /** Field components keyed by schema component name */
197
+ fieldComponentMap?: FieldComponentMap;
198
+ }
199
+ /**
200
+ * Change event payload emitted by FormGenerator.
201
+ */
202
+ interface FieldChangePayload {
203
+ /** Which field changed */
204
+ key: string;
205
+ /** New value */
206
+ value: unknown;
207
+ }
208
+ /**
209
+ * Section toggle event payload.
210
+ */
211
+ interface SectionTogglePayload {
212
+ /** Index of the section in the schema */
213
+ index: number;
214
+ /** Title of the section */
215
+ title: string;
216
+ /** Whether the section is now collapsed */
217
+ collapsed: boolean;
218
+ }
219
+ /**
220
+ * Internal context provided to all FormGenerator descendants via provide/inject.
221
+ */
222
+ interface FormGeneratorContext {
223
+ /** Field component map available to nested fields */
224
+ fieldComponentMap: FieldComponentMap;
225
+ /** Callback invoked when any field value changes */
226
+ onFieldChange: (key: string, value: unknown) => void;
227
+ /** Read a specific field value from the current model */
228
+ getFieldValue: (key: string) => unknown;
229
+ /** Get all current values as a snapshot */
230
+ getFormValues: () => Record<string, unknown>;
231
+ /** Reactive values — use for fine-grained dependency tracking in computed/watch */
232
+ values: Record<string, unknown>;
233
+ /** Global disabled ref */
234
+ disabled: Ref<boolean>;
235
+ /** Map of field key -> validation error messages (reactive) */
236
+ fieldErrors: Ref<Record<string, string | undefined>>;
237
+ /** Validate a specific field, optionally with a resolved field for dependency-driven rules */
238
+ validateField: (key: string, resolvedField?: FieldSchema) => string | undefined;
239
+ }
240
+ /**
241
+ * Injection key for the form generator context.
242
+ */
243
+ declare const FORM_GENERATOR_CONTEXT_KEY: InjectionKey<FormGeneratorContext>;
244
+ //#endregion
245
+ //#region src/components/FormGenerator.d.ts
246
+ declare const _default: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
247
+ schema: {
248
+ type: PropType<FormSchema>;
249
+ required: true;
250
+ };
251
+ values: {
252
+ type: PropType<Record<string, unknown>>;
253
+ required: true;
254
+ };
255
+ disabled: {
256
+ type: BooleanConstructor;
257
+ default: boolean;
258
+ };
259
+ fieldComponentMap: {
260
+ type: PropType<FieldComponentMap>;
261
+ default: undefined;
262
+ };
263
+ }>, () => import("vue").VNode<import("vue").RendererNode, import("vue").RendererElement, {
264
+ [key: string]: any;
265
+ }>, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
266
+ change: (_payload: FieldChangePayload) => true;
267
+ 'section:toggle': (_payload: SectionTogglePayload) => true;
268
+ submit: (_values: Record<string, unknown>) => true;
269
+ }, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
270
+ schema: {
271
+ type: PropType<FormSchema>;
272
+ required: true;
273
+ };
274
+ values: {
275
+ type: PropType<Record<string, unknown>>;
276
+ required: true;
277
+ };
278
+ disabled: {
279
+ type: BooleanConstructor;
280
+ default: boolean;
281
+ };
282
+ fieldComponentMap: {
283
+ type: PropType<FieldComponentMap>;
284
+ default: undefined;
285
+ };
286
+ }>> & Readonly<{
287
+ onChange?: ((_payload: FieldChangePayload) => any) | undefined;
288
+ "onSection:toggle"?: ((_payload: SectionTogglePayload) => any) | undefined;
289
+ onSubmit?: ((_values: Record<string, unknown>) => any) | undefined;
290
+ }>, {
291
+ disabled: boolean;
292
+ fieldComponentMap: FieldComponentMap;
293
+ }, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
294
+ //#endregion
295
+ //#region src/composables/useFieldDependencies.d.ts
296
+ interface FieldDependenciesResult {
297
+ resolvedField: ComputedRef<FieldSchema>;
298
+ }
299
+ type FieldSource = FieldSchema | (() => FieldSchema);
300
+ /**
301
+ * Resolves dynamic field schema overrides driven by explicit field dependencies.
302
+ * When a field declares `dependencies`, this composable watches those fields
303
+ * and merges the handler's return into a resolved FieldSchema.
304
+ */
305
+ declare function useFieldDependencies(field: FieldSource, ctx: FormGeneratorContext): FieldDependenciesResult;
306
+ //#endregion
307
+ //#region src/composables/useFieldState.d.ts
308
+ /**
309
+ * Reactive interaction state computed for a single field.
310
+ */
311
+ interface FieldState {
312
+ /** Whether the field should be rendered in the DOM (ifShow / visible). */
313
+ isVisible: ComputedRef<boolean>;
314
+ /** Whether the field should be visually displayed (show -- CSS visibility). */
315
+ isShown: ComputedRef<boolean>;
316
+ /** Whether the field is disabled. */
317
+ isDisabled: ComputedRef<boolean>;
318
+ }
319
+ /**
320
+ * Computes reactive visibility, display, and disabled state for a given field.
321
+ * Evaluates the field's predicates against the current form values.
322
+ */
323
+ declare function useFieldState(getField: () => FieldSchema, ctx: FormGeneratorContext): FieldState;
324
+ //#endregion
325
+ //#region src/composables/useFormValidation.d.ts
326
+ /**
327
+ * Form-level validation interface.
328
+ */
329
+ interface FormValidation {
330
+ /** Reactive map of field key -> error message (undefined = no error) */
331
+ fieldErrors: Ref<Record<string, string | undefined>>;
332
+ /** Validate a single field by key. Optionally pass a resolved field for dependency-driven rules. */
333
+ validateField: (key: string, resolvedField?: FieldSchema) => string | undefined;
334
+ /** Validate all fields. Returns array of errors (empty = all valid). */
335
+ validateAll: () => ValidationError[];
336
+ /** Clear all validation errors */
337
+ clearErrors: () => void;
338
+ }
339
+ type FormSchemaSource = FormSchema | (() => FormSchema);
340
+ declare function findFieldSchema(schema: FormSchema, key: string): FieldSchema | undefined;
341
+ /**
342
+ * Creates a form-level validation system.
343
+ * Validates fields against their rules and tracks errors reactively.
344
+ */
345
+ declare function useFormValidation(schema: FormSchemaSource, getValues: () => Record<string, unknown>, resolveField?: (key: string) => FieldSchema | undefined): FormValidation;
346
+ //#endregion
347
+ //#region src/context.d.ts
348
+ /**
349
+ * Injects the FormGeneratorContext from the nearest ancestor FormGenerator.
350
+ * Throws if called outside a FormGenerator tree.
351
+ */
352
+ declare function useFormGeneratorContext(): FormGeneratorContext;
353
+ //#endregion
354
+ //#region src/utils.d.ts
355
+ declare function resolveFieldComponentProps(field: FieldSchema, formCtx: FormContext, t: I18nInstance['t']): Record<string, unknown>;
356
+ //#endregion
357
+ export { FORM_GENERATOR_CONTEXT_KEY, type FieldBindingScope, type FieldBindingTarget, type FieldChangePayload, type FieldComponentDefinition, type FieldComponentMap, type FieldComponentProps, type FieldComponentTransformContext, type FieldDependencies, type FieldDependenciesResult, type FieldRenderContext, type FieldRenderFactory, type FieldSchema, type FieldState, type FormContext, _default as FormGenerator, type FormGeneratorContext, type FormGeneratorProps, type FormSchema, type FormValidation, type SectionSchema, type SectionTogglePayload, type TypedFieldSchema, type TypedFormSchema, type TypedSectionSchema, type ValidationError, type ValidationRule, findFieldSchema, resolveFieldComponentProps, useFieldDependencies, useFieldState, useFormGeneratorContext, useFormValidation };
package/dist/index.mjs ADDED
@@ -0,0 +1,491 @@
1
+ import { computed, defineComponent, h, inject, provide, reactive, ref, watch } from "vue";
2
+ import { useI18n } from "@dragcraft/i18n";
3
+ import { IconChevronDown } from "@dragcraft/icons";
4
+ //#region src/utils.ts
5
+ function createFormContext(values) {
6
+ return { values };
7
+ }
8
+ function copyFormValues(values) {
9
+ return { ...values };
10
+ }
11
+ function resolveFieldModelValue(field, values) {
12
+ const value = values[field.key];
13
+ return value === void 0 ? field.defaultValue : value;
14
+ }
15
+ function syncReactiveRecord(target, source) {
16
+ for (const key of Object.keys(target)) if (!(key in source)) delete target[key];
17
+ for (const [key, value] of Object.entries(source)) target[key] = value;
18
+ }
19
+ function evaluateBoolean(value, ctx, defaultValue) {
20
+ if (value === void 0) return defaultValue;
21
+ return typeof value === "function" ? value(ctx) : value;
22
+ }
23
+ function resolveFieldDependencies(field, values, fieldValue) {
24
+ if (!field.dependencies) return field;
25
+ const overrides = field.dependencies.handler(copyFormValues(values), fieldValue);
26
+ return {
27
+ ...field,
28
+ ...overrides,
29
+ key: field.key,
30
+ component: field.component,
31
+ dependencies: field.dependencies
32
+ };
33
+ }
34
+ function resolveFieldComponentProps(field, formCtx, t) {
35
+ const resolvedProps = { ...typeof field.componentProps === "function" ? field.componentProps(formCtx) : field.componentProps ?? {} };
36
+ if (field.placeholderKey) resolvedProps.placeholder = t(field.placeholderKey, String(resolvedProps.placeholder ?? ""));
37
+ if (field.optionKeyPrefix && Array.isArray(resolvedProps.options)) resolvedProps.options = resolvedProps.options.map((option) => {
38
+ if (!option || typeof option !== "object" || !("value" in option)) return option;
39
+ const value = option.value;
40
+ const label = "label" in option ? String(option.label ?? "") : "";
41
+ return {
42
+ ...option,
43
+ label: t(`${field.optionKeyPrefix}.${String(value)}`, label)
44
+ };
45
+ });
46
+ return resolvedProps;
47
+ }
48
+ //#endregion
49
+ //#region src/composables/useFormValidation.ts
50
+ function resolveSchema(schema) {
51
+ return typeof schema === "function" ? schema() : schema;
52
+ }
53
+ function createFieldIndex(schema) {
54
+ const index = /* @__PURE__ */ new Map();
55
+ for (const section of schema.sections) for (const field of section.fields) {
56
+ if (index.has(field.key)) console.warn(`[dragcraft/form-generator] Duplicate field key "${field.key}" found in schema.`);
57
+ index.set(field.key, field);
58
+ }
59
+ return index;
60
+ }
61
+ function findFieldSchema(schema, key) {
62
+ for (const section of schema.sections) for (const field of section.fields) if (field.key === key) return field;
63
+ }
64
+ function isEmptyValue(value) {
65
+ return value === null || value === void 0 || value === "";
66
+ }
67
+ function shouldValidateField(field, formCtx) {
68
+ return evaluateBoolean(field.ifShow !== void 0 ? field.ifShow : field.visible, formCtx, true) && evaluateBoolean(field.show, formCtx, true);
69
+ }
70
+ function runFieldValidation(field, value, formCtx) {
71
+ if (!field.rules || field.rules.length === 0) return void 0;
72
+ for (const rule of field.rules) {
73
+ if ((typeof rule.required === "function" ? rule.required(formCtx) : rule.required) && isEmptyValue(value)) return rule.message ?? "This field is required";
74
+ if (rule.min !== void 0 && typeof value === "number" && value < rule.min) return rule.message ?? `Value must be at least ${rule.min}`;
75
+ if (rule.max !== void 0 && typeof value === "number" && value > rule.max) return rule.message ?? `Value must be at most ${rule.max}`;
76
+ if (rule.minLength !== void 0 && typeof value === "string" && value.length < rule.minLength) return rule.message ?? `Must be at least ${rule.minLength} characters`;
77
+ if (rule.maxLength !== void 0 && typeof value === "string" && value.length > rule.maxLength) return rule.message ?? `Must be at most ${rule.maxLength} characters`;
78
+ if (rule.pattern && typeof value === "string") {
79
+ rule.pattern.lastIndex = 0;
80
+ if (!rule.pattern.test(value)) return rule.message ?? "Invalid format";
81
+ }
82
+ if (rule.enum && !rule.enum.includes(value)) return rule.message ?? `Must be one of: ${rule.enum.join(", ")}`;
83
+ if (rule.validator) {
84
+ const result = rule.validator(value, formCtx);
85
+ if (typeof result === "string") return result;
86
+ if (result === false) return rule.message ?? "Validation failed";
87
+ }
88
+ }
89
+ }
90
+ /**
91
+ * Creates a form-level validation system.
92
+ * Validates fields against their rules and tracks errors reactively.
93
+ */
94
+ function useFormValidation(schema, getValues, resolveField) {
95
+ const fieldErrors = ref({});
96
+ const currentSchema = () => resolveSchema(schema);
97
+ let cachedSchema;
98
+ let cachedFieldIndex = /* @__PURE__ */ new Map();
99
+ const getFieldIndex = () => {
100
+ const schema = currentSchema();
101
+ if (schema !== cachedSchema) {
102
+ cachedSchema = schema;
103
+ cachedFieldIndex = createFieldIndex(schema);
104
+ }
105
+ return cachedFieldIndex;
106
+ };
107
+ const validateField = (key, resolvedField) => {
108
+ const field = resolvedField ?? getFieldIndex().get(key);
109
+ if (!field) return void 0;
110
+ const values = getValues();
111
+ const value = resolveFieldModelValue(field, values);
112
+ const formCtx = createFormContext(values);
113
+ if (!shouldValidateField(field, formCtx)) {
114
+ fieldErrors.value = {
115
+ ...fieldErrors.value,
116
+ [key]: void 0
117
+ };
118
+ return;
119
+ }
120
+ const error = runFieldValidation(field, value, formCtx);
121
+ fieldErrors.value = {
122
+ ...fieldErrors.value,
123
+ [key]: error
124
+ };
125
+ return error;
126
+ };
127
+ const validateAll = () => {
128
+ const errors = [];
129
+ const values = getValues();
130
+ const formCtx = createFormContext(values);
131
+ const newFieldErrors = {};
132
+ for (const section of currentSchema().sections) for (const field of section.fields) {
133
+ const resolved = resolveField?.(field.key) ?? field;
134
+ const value = resolveFieldModelValue(resolved, values);
135
+ if (!shouldValidateField(resolved, formCtx)) {
136
+ newFieldErrors[resolved.key] = void 0;
137
+ continue;
138
+ }
139
+ const error = runFieldValidation(resolved, value, formCtx);
140
+ newFieldErrors[resolved.key] = error;
141
+ if (error) errors.push({
142
+ key: resolved.key,
143
+ message: error
144
+ });
145
+ }
146
+ fieldErrors.value = newFieldErrors;
147
+ return errors;
148
+ };
149
+ const clearErrors = () => {
150
+ fieldErrors.value = {};
151
+ };
152
+ return {
153
+ fieldErrors,
154
+ validateField,
155
+ validateAll,
156
+ clearErrors
157
+ };
158
+ }
159
+ //#endregion
160
+ //#region src/types.ts
161
+ /**
162
+ * Injection key for the form generator context.
163
+ */
164
+ const FORM_GENERATOR_CONTEXT_KEY = Symbol("dc-form-generator");
165
+ //#endregion
166
+ //#region src/composables/useFieldDependencies.ts
167
+ function resolveFieldSource(field) {
168
+ return typeof field === "function" ? field() : field;
169
+ }
170
+ function createDependencySnapshot(field, values) {
171
+ const snapshot = {};
172
+ for (const key of field.dependencies?.fields ?? []) snapshot[key] = values[key];
173
+ return snapshot;
174
+ }
175
+ /**
176
+ * Resolves dynamic field schema overrides driven by explicit field dependencies.
177
+ * When a field declares `dependencies`, this composable watches those fields
178
+ * and merges the handler's return into a resolved FieldSchema.
179
+ */
180
+ function useFieldDependencies(field, ctx) {
181
+ return { resolvedField: computed(() => {
182
+ const currentField = resolveFieldSource(field);
183
+ if (!currentField.dependencies) return currentField;
184
+ return resolveFieldDependencies(currentField, createDependencySnapshot(currentField, ctx.values), ctx.getFieldValue(currentField.key));
185
+ }) };
186
+ }
187
+ //#endregion
188
+ //#region src/composables/useFieldState.ts
189
+ /**
190
+ * Computes reactive visibility, display, and disabled state for a given field.
191
+ * Evaluates the field's predicates against the current form values.
192
+ */
193
+ function useFieldState(getField, ctx) {
194
+ const getFormContext = () => createFormContext(ctx.values);
195
+ return {
196
+ isVisible: computed(() => {
197
+ const field = getField();
198
+ return evaluateBoolean(field.ifShow !== void 0 ? field.ifShow : field.visible, getFormContext(), true);
199
+ }),
200
+ isShown: computed(() => {
201
+ return evaluateBoolean(getField().show, getFormContext(), true);
202
+ }),
203
+ isDisabled: computed(() => {
204
+ const field = getField();
205
+ if (ctx.disabled.value) return true;
206
+ if (!field.disabled) return false;
207
+ return evaluateBoolean(field.disabled, getFormContext(), false);
208
+ })
209
+ };
210
+ }
211
+ //#endregion
212
+ //#region src/context.ts
213
+ /**
214
+ * Injects the FormGeneratorContext from the nearest ancestor FormGenerator.
215
+ * Throws if called outside a FormGenerator tree.
216
+ */
217
+ function useFormGeneratorContext() {
218
+ const ctx = inject(FORM_GENERATOR_CONTEXT_KEY);
219
+ if (!ctx) throw new Error("[dragcraft/form-generator] FormGeneratorContext not found. Ensure this component is a descendant of FormGenerator.");
220
+ return ctx;
221
+ }
222
+ //#endregion
223
+ //#region src/components/FormField.ts
224
+ var FormField_default = defineComponent({
225
+ name: "DcFormField",
226
+ props: { field: {
227
+ type: Object,
228
+ required: true
229
+ } },
230
+ setup(props) {
231
+ const ctx = useFormGeneratorContext();
232
+ const { t } = useI18n();
233
+ const { resolvedField } = useFieldDependencies(() => props.field, ctx);
234
+ const { isVisible, isShown, isDisabled } = useFieldState(() => resolvedField.value, ctx);
235
+ const value = computed(() => {
236
+ const field = resolvedField.value;
237
+ const modelValue = resolveFieldModelValue(field, ctx.values);
238
+ return field.valueFormat?.(modelValue, createFormContext(ctx.values)) ?? modelValue;
239
+ });
240
+ const componentProps = computed(() => resolveFieldComponentProps(resolvedField.value, createFormContext(ctx.values), t));
241
+ const validate = () => {
242
+ const field = resolvedField.value;
243
+ ctx.validateField(field.key, field);
244
+ };
245
+ const setValue = (value) => {
246
+ const field = resolvedField.value;
247
+ const transformed = field.parseValue?.(value, createFormContext(ctx.values)) ?? value;
248
+ ctx.onFieldChange(field.key, transformed);
249
+ validate();
250
+ };
251
+ const fieldRender = typeof props.field.component === "function" ? props.field.component({
252
+ field: resolvedField,
253
+ values: ctx.values,
254
+ value,
255
+ disabled: isDisabled,
256
+ componentProps,
257
+ t,
258
+ setValue,
259
+ validate
260
+ }) : void 0;
261
+ const renderRegisteredField = (field, disabled) => {
262
+ const definition = typeof field.component === "string" ? ctx.fieldComponentMap[field.component] : void 0;
263
+ const transformCtx = {
264
+ field,
265
+ values: ctx.values
266
+ };
267
+ const currentValue = definition?.formatValue?.(value.value, transformCtx) ?? value.value;
268
+ const FieldComponent = definition?.component;
269
+ const fieldContent = definition && FieldComponent ? h(FieldComponent, {
270
+ ...definition.defaultProps,
271
+ ...componentProps.value,
272
+ disabled,
273
+ [definition.modelPropName ?? "modelValue"]: currentValue,
274
+ [definition.updateEventName ?? "onUpdate:modelValue"]: (value) => {
275
+ const normalized = definition.normalizeValue?.(value, transformCtx) ?? value;
276
+ setValue(normalized);
277
+ }
278
+ }) : h("div", {
279
+ "class": "dc-field-unknown",
280
+ "data-dc-part": "unknown"
281
+ }, `Unknown field: ${String(field.component)}`);
282
+ return [h("label", {
283
+ "class": "dc-form-field__label",
284
+ "data-dc-part": "label"
285
+ }, field.labelKey ? t(field.labelKey, field.label) : field.label), h("div", {
286
+ "class": "dc-form-field__control",
287
+ "data-dc-part": "control"
288
+ }, [fieldContent])];
289
+ };
290
+ return () => {
291
+ if (!isVisible.value) return null;
292
+ const field = resolvedField.value;
293
+ const errorMsg = ctx.fieldErrors.value[field.key];
294
+ const disabled = isDisabled.value;
295
+ const children = fieldRender ? [fieldRender()] : renderRegisteredField(field, disabled);
296
+ if (field.tooltip) children.push(h("div", {
297
+ "class": "dc-form-field__tooltip",
298
+ "data-dc-part": "tooltip"
299
+ }, field.tooltip));
300
+ if (errorMsg) children.push(h("div", {
301
+ "class": "dc-form-field__error",
302
+ "data-dc-part": "error"
303
+ }, errorMsg));
304
+ const span = field.span ?? 1;
305
+ const wrapperClass = ["dc-form-field", {
306
+ "dc-form-field--disabled": disabled,
307
+ "dc-form-field--error": !!errorMsg
308
+ }];
309
+ const wrapperStyle = {};
310
+ if (span > 1) {
311
+ wrapperClass.push(`dc-form-field--span-${span}`);
312
+ wrapperStyle["--_dc-span"] = String(span);
313
+ }
314
+ if (!isShown.value) wrapperStyle.display = "none";
315
+ return h("div", {
316
+ "class": wrapperClass,
317
+ "style": wrapperStyle,
318
+ "data-dc-component": "form-field",
319
+ "data-dc-state": [disabled ? "disabled" : null, errorMsg ? "error" : null].filter(Boolean).join(" ") || void 0
320
+ }, children);
321
+ };
322
+ }
323
+ });
324
+ //#endregion
325
+ //#region src/components/FormSection.ts
326
+ var FormSection_default = defineComponent({
327
+ name: "DcFormSection",
328
+ props: {
329
+ section: {
330
+ type: Object,
331
+ required: true
332
+ },
333
+ onToggle: {
334
+ type: Function,
335
+ default: void 0
336
+ }
337
+ },
338
+ setup(props) {
339
+ const { t } = useI18n();
340
+ const collapsed = ref(props.section.collapsed ?? false);
341
+ watch(() => props.section.collapsed, (val) => {
342
+ if (val !== void 0) collapsed.value = val;
343
+ });
344
+ const toggleCollapse = () => {
345
+ collapsed.value = !collapsed.value;
346
+ props.onToggle?.(collapsed.value);
347
+ };
348
+ return () => {
349
+ const section = props.section;
350
+ const columns = section.columns ?? 1;
351
+ const header = h("button", {
352
+ "type": "button",
353
+ "class": "dc-form-section__header",
354
+ "data-dc-part": "header",
355
+ "aria-expanded": !collapsed.value,
356
+ "onClick": toggleCollapse
357
+ }, [h("span", {
358
+ "class": "dc-form-section__title",
359
+ "data-dc-part": "title"
360
+ }, section.titleKey ? t(section.titleKey, section.title) : section.title), h("span", {
361
+ "class": collapsed.value ? "dc-form-section__toggle dc-form-section__toggle--collapsed" : "dc-form-section__toggle",
362
+ "data-dc-part": "toggle"
363
+ }, [h(IconChevronDown, { size: 15 })])]);
364
+ if (collapsed.value) return h("div", {
365
+ "class": "dc-form-section",
366
+ "data-dc-component": "form-section",
367
+ "data-dc-state": "collapsed"
368
+ }, [header]);
369
+ const bodyClass = ["dc-form-section__body"];
370
+ const bodyStyle = {};
371
+ if (columns > 1) {
372
+ bodyClass.push("dc-form-section--grid");
373
+ bodyStyle["--_dc-columns"] = String(columns);
374
+ }
375
+ return h("div", {
376
+ "class": "dc-form-section",
377
+ "data-dc-component": "form-section",
378
+ "data-dc-state": "expanded"
379
+ }, [header, h("div", {
380
+ "class": bodyClass,
381
+ "style": bodyStyle,
382
+ "data-dc-part": "body"
383
+ }, section.fields.map((field) => h(FormField_default, {
384
+ key: field.key,
385
+ field
386
+ })))]);
387
+ };
388
+ }
389
+ });
390
+ //#endregion
391
+ //#region src/components/FormGenerator.ts
392
+ function createDependencyIndex(schema) {
393
+ const index = /* @__PURE__ */ new Map();
394
+ for (const section of schema.sections) for (const field of section.fields) for (const dependencyKey of field.dependencies?.fields ?? []) {
395
+ const dependentKeys = index.get(dependencyKey) ?? [];
396
+ dependentKeys.push(field.key);
397
+ index.set(dependencyKey, dependentKeys);
398
+ }
399
+ return index;
400
+ }
401
+ var FormGenerator_default = defineComponent({
402
+ name: "DcFormGenerator",
403
+ props: {
404
+ schema: {
405
+ type: Object,
406
+ required: true
407
+ },
408
+ values: {
409
+ type: Object,
410
+ required: true
411
+ },
412
+ disabled: {
413
+ type: Boolean,
414
+ default: false
415
+ },
416
+ fieldComponentMap: {
417
+ type: Object,
418
+ default: void 0
419
+ }
420
+ },
421
+ emits: {
422
+ "change": (_payload) => true,
423
+ "section:toggle": (_payload) => true,
424
+ "submit": (_values) => true
425
+ },
426
+ setup(props, { emit, expose }) {
427
+ const localValues = reactive(copyFormValues(props.values));
428
+ watch(() => props.values, (newValues) => {
429
+ syncReactiveRecord(localValues, newValues);
430
+ }, { deep: true });
431
+ const fieldComponentMapRef = computed(() => props.fieldComponentMap ?? {});
432
+ const dependencyIndex = computed(() => createDependencyIndex(props.schema));
433
+ const getFormValues = () => copyFormValues(localValues);
434
+ const resolveField = (key) => {
435
+ const field = findFieldSchema(props.schema, key);
436
+ return field ? resolveFieldDependencies(field, localValues, localValues[key]) : void 0;
437
+ };
438
+ const { fieldErrors, validateField, validateAll, clearErrors } = useFormValidation(() => props.schema, getFormValues, resolveField);
439
+ const validateResolvedField = (key, resolvedField) => {
440
+ const field = resolvedField ?? resolveField(key);
441
+ if (field) validateField(key, field);
442
+ };
443
+ const onFieldChange = (key, value) => {
444
+ localValues[key] = value;
445
+ emit("change", {
446
+ key,
447
+ value
448
+ });
449
+ for (const dependentKey of dependencyIndex.value.get(key) ?? []) validateResolvedField(dependentKey);
450
+ };
451
+ provide(FORM_GENERATOR_CONTEXT_KEY, {
452
+ get fieldComponentMap() {
453
+ return fieldComponentMapRef.value;
454
+ },
455
+ onFieldChange,
456
+ getFieldValue: (key) => localValues[key],
457
+ getFormValues,
458
+ values: localValues,
459
+ disabled: computed(() => props.disabled),
460
+ fieldErrors,
461
+ validateField
462
+ });
463
+ const submit = () => {
464
+ emit("submit", getFormValues());
465
+ };
466
+ expose({
467
+ validate: validateAll,
468
+ clearErrors,
469
+ submit
470
+ });
471
+ return () => {
472
+ const schema = props.schema;
473
+ return h("div", {
474
+ "class": "dc-form-generator",
475
+ "data-dc-component": "form-generator"
476
+ }, schema.sections.map((section, i) => h(FormSection_default, {
477
+ key: `${section.title}-${i}`,
478
+ section,
479
+ onToggle: (collapsed) => {
480
+ emit("section:toggle", {
481
+ index: i,
482
+ title: section.title,
483
+ collapsed
484
+ });
485
+ }
486
+ })));
487
+ };
488
+ }
489
+ });
490
+ //#endregion
491
+ export { FORM_GENERATOR_CONTEXT_KEY, FormGenerator_default as FormGenerator, findFieldSchema, resolveFieldComponentProps, useFieldDependencies, useFieldState, useFormGeneratorContext, useFormValidation };
@@ -0,0 +1,69 @@
1
+ .dc-form-generator, .dc-form-section, .dc-form-section__header, .dc-form-section__body, .dc-form-field, .dc-form-field__control, .dc-field-unknown {
2
+ box-sizing: border-box;
3
+ }
4
+
5
+ .dc-field-unknown {
6
+ border: 1px dashed #0000;
7
+ }
8
+
9
+ .dc-form-generator {
10
+ padding: 0 12px;
11
+ }
12
+
13
+ .dc-form-section {
14
+ margin-bottom: 4px;
15
+ }
16
+
17
+ .dc-form-section__header {
18
+ text-align: left;
19
+ cursor: pointer;
20
+ user-select: none;
21
+ border: 0;
22
+ justify-content: space-between;
23
+ align-items: center;
24
+ width: 100%;
25
+ min-height: 34px;
26
+ padding: 0 6px;
27
+ display: flex;
28
+ }
29
+
30
+ .dc-form-section__toggle {
31
+ flex: none;
32
+ display: inline-flex;
33
+ }
34
+
35
+ .dc-form-section__toggle--collapsed {
36
+ transform: rotate(-90deg);
37
+ }
38
+
39
+ .dc-form-section__body {
40
+ padding-bottom: 8px;
41
+ }
42
+
43
+ .dc-form-section--grid {
44
+ grid-template-columns: repeat(var(--_dc-columns), minmax(0, 1fr));
45
+ gap: 10px 12px;
46
+ display: grid;
47
+ }
48
+
49
+ .dc-form-field {
50
+ flex-direction: column;
51
+ gap: 4px;
52
+ margin-bottom: 10px;
53
+ display: flex;
54
+ }
55
+
56
+ .dc-form-section--grid > .dc-form-field {
57
+ grid-column: span min(var(--_dc-span, 1), var(--_dc-columns));
58
+ min-width: 0;
59
+ margin-bottom: 0;
60
+ }
61
+
62
+ .dc-form-field__control {
63
+ width: 100%;
64
+ min-width: 0;
65
+ }
66
+
67
+ .dc-field-unknown {
68
+ padding: 6px 8px;
69
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@dragcraft/form-generator",
3
+ "type": "module",
4
+ "version": "0.0.1",
5
+ "description": "@dragcraft/form-generator",
6
+ "author": "hackycy <hackycy@outlook.com>",
7
+ "license": "MIT",
8
+ "homepage": "https://github.com/hackycy/dragcraft#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/hackycy/dragcraft.git"
12
+ },
13
+ "bugs": "https://github.com/hackycy/dragcraft/issues",
14
+ "keywords": [],
15
+ "sideEffects": [
16
+ "**/*.css"
17
+ ],
18
+ "exports": {
19
+ ".": "./dist/index.mjs",
20
+ "./package.json": "./package.json",
21
+ "./structure.css": "./dist/structure.css"
22
+ },
23
+ "main": "./dist/index.mjs",
24
+ "module": "./dist/index.mjs",
25
+ "types": "./dist/index.d.mts",
26
+ "files": [
27
+ "dist"
28
+ ],
29
+ "peerDependencies": {
30
+ "vue": ">=3.0.0"
31
+ },
32
+ "dependencies": {
33
+ "@dragcraft/i18n": "0.0.1",
34
+ "@dragcraft/icons": "0.0.1"
35
+ },
36
+ "devDependencies": {
37
+ "happy-dom": "^20.10.6",
38
+ "vitest": "^4.0.7",
39
+ "vue": "^3.5.27"
40
+ },
41
+ "scripts": {
42
+ "build": "tsdown",
43
+ "dev": "tsdown --watch",
44
+ "lint": "eslint",
45
+ "release": "bumpp",
46
+ "start": "tsx src/index.ts",
47
+ "test": "vitest",
48
+ "typecheck": "tsc"
49
+ }
50
+ }