@xelqun/forms 0.0.0-stage → 0.1.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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +116 -2
  3. package/package.json +56 -5
  4. package/resources/css/xelqun.css +162 -0
  5. package/resources/js/components/XqFieldWrapper.vue +59 -0
  6. package/resources/js/components/XqForm.vue +128 -0
  7. package/resources/js/components/XqNode.vue +22 -0
  8. package/resources/js/components/XqUnknownNode.vue +15 -0
  9. package/resources/js/components/defaults.ts +44 -0
  10. package/resources/js/components/fields/XqCheckboxField.vue +48 -0
  11. package/resources/js/components/fields/XqDatePickerField.vue +96 -0
  12. package/resources/js/components/fields/XqFileUploadField.vue +247 -0
  13. package/resources/js/components/fields/XqRadioField.vue +45 -0
  14. package/resources/js/components/fields/XqRepeaterField.vue +166 -0
  15. package/resources/js/components/fields/XqSelectField.vue +151 -0
  16. package/resources/js/components/fields/XqTextInputField.vue +100 -0
  17. package/resources/js/components/fields/XqTextareaField.vue +28 -0
  18. package/resources/js/components/fields/XqToggleField.vue +47 -0
  19. package/resources/js/components/layouts/XqFieldset.vue +16 -0
  20. package/resources/js/components/layouts/XqGrid.vue +13 -0
  21. package/resources/js/components/layouts/XqSection.vue +62 -0
  22. package/resources/js/components/layouts/XqTabsLayout.vue +64 -0
  23. package/resources/js/composables/context.ts +22 -0
  24. package/resources/js/composables/layout.ts +55 -0
  25. package/resources/js/composables/registry.ts +25 -0
  26. package/resources/js/composables/useField.ts +41 -0
  27. package/resources/js/composables/useXqForm.ts +16 -0
  28. package/resources/js/core/conditions.ts +52 -0
  29. package/resources/js/core/expressions.ts +131 -0
  30. package/resources/js/core/http.ts +101 -0
  31. package/resources/js/core/log.ts +93 -0
  32. package/resources/js/core/patch.ts +65 -0
  33. package/resources/js/core/paths.ts +122 -0
  34. package/resources/js/core/queue.ts +190 -0
  35. package/resources/js/core/rows.ts +26 -0
  36. package/resources/js/core/store.ts +560 -0
  37. package/resources/js/core/values.ts +161 -0
  38. package/resources/js/index.ts +24 -0
  39. package/resources/js/lib/utils.ts +18 -0
  40. package/resources/js/plugin.ts +17 -0
  41. package/resources/js/schema/types.ts +99 -0
  42. package/resources/js/ui/XqBadge.vue +33 -0
  43. package/resources/js/ui/XqButton.vue +59 -0
  44. package/resources/js/ui/XqCalendar.vue +90 -0
  45. package/resources/js/ui/XqCard.vue +20 -0
  46. package/resources/js/ui/XqCheckbox.vue +28 -0
  47. package/resources/js/ui/XqCombobox.vue +184 -0
  48. package/resources/js/ui/XqInput.vue +16 -0
  49. package/resources/js/ui/XqLabel.vue +17 -0
  50. package/resources/js/ui/XqRadioGroup.vue +24 -0
  51. package/resources/js/ui/XqRadioGroupItem.vue +31 -0
  52. package/resources/js/ui/XqSelect.vue +79 -0
  53. package/resources/js/ui/XqSwitch.vue +27 -0
  54. package/resources/js/ui/XqTabs.vue +19 -0
  55. package/resources/js/ui/XqTabsContent.vue +17 -0
  56. package/resources/js/ui/XqTabsList.vue +21 -0
  57. package/resources/js/ui/XqTabsTrigger.vue +27 -0
  58. package/resources/js/ui/XqTextarea.vue +16 -0
  59. package/resources/js/ui/classes.ts +12 -0
  60. package/resources/js/ui/index.ts +17 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jay-Are Ocero
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.
package/README.md CHANGED
@@ -1,3 +1,117 @@
1
- # Temporary Holding Version
1
+ # Xelqun Forms
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ PHP-defined, schema-driven forms for **Laravel + Inertia + Vue**. You describe a form in PHP, the package turns it into a versioned JSON schema, and generic Vue components render it. No Vue to write for normal forms.
4
+
5
+ > **Status: pre-alpha.** The API will change. Built and tested against Laravel 13, Inertia 3, Vue 3.5 and Tailwind 4.
6
+
7
+ ```php
8
+ class OrderForm extends Form
9
+ {
10
+ public function schema(): array
11
+ {
12
+ return [
13
+ TextInput::make('customer')->required(),
14
+ Select::make('country_id')->relationship('country', 'name')
15
+ ->afterStateUpdated(fn (Set $set) => $set('state_id', null)),
16
+ Select::make('state_id')
17
+ ->options(fn (Get $get) => State::where('country_id', $get('country_id'))->pluck('name', 'id')),
18
+ Section::make('Business')->visibleWhen(When::eq('type', 'business'))->schema([
19
+ TextInput::make('company')->requiredWhen(When::eq('type', 'business')),
20
+ ]),
21
+ Repeater::make('items')->relationship()->schema([
22
+ TextInput::make('qty')->numeric(),
23
+ TextInput::make('price')->numeric(),
24
+ TextInput::make('subtotal')->computed('qty * price')->disabled(),
25
+ ]),
26
+ TextInput::make('total')->computed('sum(items.*.subtotal)')->disabled(),
27
+ ];
28
+ }
29
+ }
30
+ ```
31
+
32
+ ## How it works
33
+
34
+ - **Three tiers of rules.** Declarative conditions (`visibleWhen`, `requiredWhen`, `disabledWhen`) and calculations (`computed('qty * price')`) run in the browser with no server call. The server re-checks every condition, drops hidden values and recomputes every total on submit. Server closures (`options(fn (Get $get) => ...)`, `afterStateUpdated`) run only on the server.
35
+ - **Automatic dependency tracking.** Closures read state through `Get`, which records what they read. The browser calls the server only when one of those paths changes, and the response is a small patch that is merged in place, so focus and cursor position are kept.
36
+ - **Secure by default.** Forms are rebuilt on the server from an encrypted token (never a class name from the client). Every endpoint re-runs `authorize()` and validation.
37
+ - **Stable row keys.** Repeater rows are keyed by UUIDs (or `record-{id}`), so Laravel validation errors map straight onto nested fields.
38
+ - **Uploads stay out of the form state.** Files upload as soon as they are picked, and the state only holds a short token until the form is saved.
39
+
40
+ ## Installation
41
+
42
+ ```bash
43
+ composer require xelqun/forms && php artisan xelqun-forms:install
44
+ ```
45
+
46
+ The installer adds the matching version of the `@xelqun/forms` npm package with your package manager (npm, pnpm, Yarn or Bun, detected from the lockfile). It then adds the plugin to `resources/js/app.ts` and the stylesheet to `resources/css/app.css`. It's safe to run again, so run it after `composer update` to bring the npm package up to the same version.
47
+
48
+ Use `--config` to publish the config file, or `--without-node` to skip the npm step. If the installer can't edit a file safely, it leaves the file alone and prints the step to do by hand:
49
+
50
+ ```ts
51
+ // resources/js/app.ts
52
+ import { createXelqunForms } from '@xelqun/forms';
53
+
54
+ createInertiaApp({
55
+ withApp: (app) => app.use(createXelqunForms()),
56
+ });
57
+ ```
58
+
59
+ ```css
60
+ /* resources/css/app.css */
61
+ @import 'tailwindcss';
62
+ @import '@xelqun/forms/style.css';
63
+ ```
64
+
65
+ Then render a form:
66
+
67
+ ```php
68
+ // controller
69
+ return Inertia::render('Orders/Edit', ['form' => OrderForm::make($order)->toSchema()]);
70
+
71
+ public function update(Request $request, Order $order)
72
+ {
73
+ OrderForm::make($order)->save($request);
74
+
75
+ return back();
76
+ }
77
+ ```
78
+
79
+ ```vue
80
+ <script setup lang="ts">
81
+ import { XqForm, type FormSchema } from '@xelqun/forms';
82
+
83
+ defineProps<{ form: FormSchema; orderId: number }>();
84
+ </script>
85
+
86
+ <template>
87
+ <XqForm :form="form" :action="`/orders/${orderId}`" method="put" />
88
+ </template>
89
+ ```
90
+
91
+ Section titles start at `h2`. If the form sits lower in your page's heading order, set it with `<XqForm :heading-level="3" />`.
92
+
93
+ Components use their own `--xq-*` design tokens and `Xq*` names, so they work next to an app's own shadcn-vue components. Dark mode follows a `.dark` class or `[data-xq-theme=dark]`.
94
+
95
+ ## Security
96
+
97
+ - **Schedule the cleanup.** Temporary uploads stay on disk until `php artisan xelqun-forms:prune-uploads` removes them, so run it from your scheduler.
98
+ - **Authorize in `authorize()`.** `validate()`, `save()` and every internal endpoint call it, and it allows everything by default.
99
+ - **Use `hidden()` to protect a field, not `visibleWhen()`.** `visibleWhen()` runs against state the browser controls. `hidden()` also blocks the field's search, upload and hooks.
100
+ - **`Hidden` fields are not secret or read-only.** Their value comes back from the browser, so use `disabled()` for values users must not change.
101
+ - **A disabled field submits what it was filled with**, even with `dehydrated()`, because the browser can't be trusted to send it back unchanged. To save a value the server derives, set it in `mutateBeforeSave()`.
102
+ - **Limit uploads.** Without `acceptedFileTypes()` or `image()`, a field accepts any file except types a web server may run or a browser may treat as a page.
103
+
104
+ ## Development
105
+
106
+ ```bash
107
+ composer install && vendor/bin/pest # PHP tests
108
+ npm install && npx vp test # Vitest
109
+ vendor/bin/pint && vendor/bin/phpstan analyse
110
+ npx vp check && npx vue-tsc --noEmit
111
+ ```
112
+
113
+ The conformance fixtures in `tests/Fixtures/conformance` run in both Pest and Vitest, so the PHP and browser evaluators always agree. See [`docs/forms-plan.md`](docs/forms-plan.md) for the design.
114
+
115
+ ## License
116
+
117
+ MIT
package/package.json CHANGED
@@ -1,6 +1,57 @@
1
1
  {
2
- "name": "@xelqun/forms",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@xelqun/forms",
3
+ "version": "0.1.1",
4
+ "description": "Vue renderer for Xelqun Forms: PHP-defined, schema-driven forms for Laravel + Inertia.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/199ocero/xelqun-forms.git"
9
+ },
10
+ "files": [
11
+ "resources/js",
12
+ "resources/css"
13
+ ],
14
+ "type": "module",
15
+ "sideEffects": [
16
+ "*.css",
17
+ "*.vue"
18
+ ],
19
+ "exports": {
20
+ ".": "./resources/js/index.ts",
21
+ "./style.css": "./resources/css/xelqun.css"
22
+ },
23
+ "scripts": {
24
+ "test": "vp test",
25
+ "check": "vp check",
26
+ "check:fix": "vp check --fix",
27
+ "types:check": "vue-tsc --noEmit"
28
+ },
29
+ "dependencies": {
30
+ "@internationalized/date": "^3.12.4",
31
+ "class-variance-authority": "^0.7.1",
32
+ "clsx": "^2.1.1",
33
+ "lucide-vue-next": "^1.0.0",
34
+ "reka-ui": "^2.11.0",
35
+ "tailwind-merge": "^3.2.0"
36
+ },
37
+ "devDependencies": {
38
+ "@inertiajs/vue3": "^3.0.0",
39
+ "@vitejs/plugin-vue": "^6.0.0",
40
+ "@vue/test-utils": "^2.4.6",
41
+ "happy-dom": "^21.0.4",
42
+ "typescript": "^5.2.2",
43
+ "vite-plus": "1.4.0",
44
+ "vitest": "^5.0.5",
45
+ "vue": "^3.5.13",
46
+ "vue-tsc": "^3.3.13"
47
+ },
48
+ "peerDependencies": {
49
+ "@inertiajs/vue3": "^3.0.0",
50
+ "vue": "^3.5.0"
51
+ },
52
+ "overrides": {
53
+ "glob": "^14.0.2",
54
+ "minimatch": "^11.0.1",
55
+ "vite": "npm:@voidzero-dev/vite-plus-core@1.4.0"
56
+ }
57
+ }
@@ -0,0 +1,162 @@
1
+ /*
2
+ * Xelqun Forms styles. Import once in the app's Tailwind entry:
3
+ * @import '@xelqun/forms/style.css';
4
+ * The @source below makes Tailwind scan the package's components.
5
+ */
6
+ @source "../js";
7
+
8
+ @theme inline {
9
+ --color-xq-background: var(--xq-background);
10
+ --color-xq-foreground: var(--xq-foreground);
11
+ --color-xq-card: var(--xq-card);
12
+ --color-xq-card-foreground: var(--xq-card-foreground);
13
+ --color-xq-popover: var(--xq-popover);
14
+ --color-xq-popover-foreground: var(--xq-popover-foreground);
15
+ --color-xq-primary: var(--xq-primary);
16
+ --color-xq-primary-foreground: var(--xq-primary-foreground);
17
+ --color-xq-secondary: var(--xq-secondary);
18
+ --color-xq-secondary-foreground: var(--xq-secondary-foreground);
19
+ --color-xq-muted: var(--xq-muted);
20
+ --color-xq-muted-foreground: var(--xq-muted-foreground);
21
+ --color-xq-accent: var(--xq-accent);
22
+ --color-xq-accent-foreground: var(--xq-accent-foreground);
23
+ --color-xq-destructive: var(--xq-destructive);
24
+ --color-xq-destructive-foreground: var(--xq-destructive-foreground);
25
+ --color-xq-success: var(--xq-success);
26
+ --color-xq-border: var(--xq-border);
27
+ --color-xq-input: var(--xq-input);
28
+ --color-xq-ring: var(--xq-ring);
29
+ --radius-xq-sm: calc(var(--xq-radius) - 4px);
30
+ --radius-xq-md: calc(var(--xq-radius) - 2px);
31
+ --radius-xq-lg: var(--xq-radius);
32
+ --radius-xq-xl: calc(var(--xq-radius) + 4px);
33
+ }
34
+
35
+ :root {
36
+ --xq-radius: 0.625rem;
37
+ --xq-background: oklch(1 0 0);
38
+ --xq-foreground: oklch(0.2 0.01 270);
39
+ --xq-card: oklch(1 0 0);
40
+ --xq-card-foreground: oklch(0.2 0.01 270);
41
+ --xq-popover: oklch(1 0 0);
42
+ --xq-popover-foreground: oklch(0.2 0.01 270);
43
+ --xq-primary: oklch(0.5 0.2 272);
44
+ --xq-primary-foreground: oklch(0.99 0 0);
45
+ --xq-secondary: oklch(0.96 0.006 270);
46
+ --xq-secondary-foreground: oklch(0.25 0.02 270);
47
+ --xq-muted: oklch(0.965 0.004 270);
48
+ --xq-muted-foreground: oklch(0.52 0.015 270);
49
+ --xq-accent: oklch(0.955 0.012 272);
50
+ --xq-accent-foreground: oklch(0.25 0.04 272);
51
+ --xq-destructive: oklch(0.54 0.22 27);
52
+ --xq-destructive-foreground: oklch(0.99 0 0);
53
+ --xq-success: oklch(0.47 0.12 155);
54
+ --xq-border: oklch(0.915 0.006 270);
55
+ --xq-input: oklch(0.885 0.008 270);
56
+ --xq-ring: oklch(0.55 0.19 272);
57
+ }
58
+
59
+ .dark,
60
+ [data-xq-theme='dark'] {
61
+ --xq-background: oklch(0.17 0.008 270);
62
+ --xq-foreground: oklch(0.97 0.004 270);
63
+ --xq-card: oklch(0.205 0.01 270);
64
+ --xq-card-foreground: oklch(0.97 0.004 270);
65
+ --xq-popover: oklch(0.22 0.01 270);
66
+ --xq-popover-foreground: oklch(0.97 0.004 270);
67
+ --xq-primary: oklch(0.68 0.16 272);
68
+ --xq-primary-foreground: oklch(0.16 0.02 272);
69
+ --xq-secondary: oklch(0.27 0.012 270);
70
+ --xq-secondary-foreground: oklch(0.95 0.004 270);
71
+ --xq-muted: oklch(0.25 0.01 270);
72
+ --xq-muted-foreground: oklch(0.7 0.012 270);
73
+ --xq-accent: oklch(0.28 0.025 272);
74
+ --xq-accent-foreground: oklch(0.96 0.01 272);
75
+ --xq-destructive: oklch(0.68 0.19 25);
76
+ --xq-destructive-foreground: oklch(0.17 0.02 25);
77
+ --xq-success: oklch(0.72 0.15 155);
78
+ --xq-border: oklch(1 0 0 / 10%);
79
+ --xq-input: oklch(1 0 0 / 14%);
80
+ --xq-ring: oklch(0.68 0.16 272);
81
+ }
82
+
83
+ /*
84
+ * Responsive grid driven by CSS variables set from the schema
85
+ * (see gridStyle() and spanStyle() in composables/layout.ts).
86
+ */
87
+ /* Custom properties inherit, so reset them at each level to keep nested grids independent. */
88
+ .xq-grid {
89
+ --xq-span: initial;
90
+ --xq-span-sm: initial;
91
+ --xq-span-md: initial;
92
+ --xq-span-lg: initial;
93
+ --xq-span-xl: initial;
94
+ display: grid;
95
+ gap: 1.25rem 1rem;
96
+ grid-template-columns: repeat(var(--xq-cols, 1), minmax(0, 1fr));
97
+ }
98
+
99
+ .xq-cell {
100
+ --xq-cols: initial;
101
+ --xq-cols-sm: initial;
102
+ --xq-cols-md: initial;
103
+ --xq-cols-lg: initial;
104
+ --xq-cols-xl: initial;
105
+ min-width: 0;
106
+ grid-column: var(--xq-span, auto);
107
+ }
108
+
109
+ @media (min-width: 40rem) {
110
+ .xq-grid {
111
+ grid-template-columns: repeat(var(--xq-cols-sm, var(--xq-cols, 1)), minmax(0, 1fr));
112
+ }
113
+
114
+ .xq-cell {
115
+ grid-column: var(--xq-span-sm, var(--xq-span, auto));
116
+ }
117
+ }
118
+
119
+ @media (min-width: 48rem) {
120
+ .xq-grid {
121
+ grid-template-columns: repeat(
122
+ var(--xq-cols-md, var(--xq-cols-sm, var(--xq-cols, 1))),
123
+ minmax(0, 1fr)
124
+ );
125
+ }
126
+
127
+ .xq-cell {
128
+ grid-column: var(--xq-span-md, var(--xq-span-sm, var(--xq-span, auto)));
129
+ }
130
+ }
131
+
132
+ @media (min-width: 64rem) {
133
+ .xq-grid {
134
+ grid-template-columns: repeat(
135
+ var(--xq-cols-lg, var(--xq-cols-md, var(--xq-cols-sm, var(--xq-cols, 1)))),
136
+ minmax(0, 1fr)
137
+ );
138
+ }
139
+
140
+ .xq-cell {
141
+ grid-column: var(--xq-span-lg, var(--xq-span-md, var(--xq-span-sm, var(--xq-span, auto))));
142
+ }
143
+ }
144
+
145
+ @media (min-width: 80rem) {
146
+ .xq-grid {
147
+ grid-template-columns: repeat(
148
+ var(
149
+ --xq-cols-xl,
150
+ var(--xq-cols-lg, var(--xq-cols-md, var(--xq-cols-sm, var(--xq-cols, 1))))
151
+ ),
152
+ minmax(0, 1fr)
153
+ );
154
+ }
155
+
156
+ .xq-cell {
157
+ grid-column: var(
158
+ --xq-span-xl,
159
+ var(--xq-span-lg, var(--xq-span-md, var(--xq-span-sm, var(--xq-span, auto))))
160
+ );
161
+ }
162
+ }
@@ -0,0 +1,59 @@
1
+ <script setup lang="ts">
2
+ import { LoaderCircle } from 'lucide-vue-next';
3
+ import { computed } from 'vue';
4
+ import { useFormStore } from '../composables/context';
5
+ import type { SchemaNode } from '../schema/types';
6
+ import XqLabel from '../ui/XqLabel.vue';
7
+
8
+ const props = defineProps<{
9
+ node: SchemaNode;
10
+ id: string;
11
+ /** The control the label points at, or null when it only names a group. */
12
+ labelFor?: string | null;
13
+ /** Announce the error as soon as it appears, for errors outside a submit. */
14
+ announceError?: boolean;
15
+ error?: string | null;
16
+ required?: boolean;
17
+ hideLabel?: boolean;
18
+ }>();
19
+
20
+ const store = useFormStore();
21
+ const loading = computed(() => store.isLoading(props.node));
22
+ </script>
23
+
24
+ <template>
25
+ <div
26
+ class="gap-2 grid"
27
+ :data-xq-path="node.path"
28
+ :data-invalid="error ? '' : undefined"
29
+ :aria-busy="loading || undefined"
30
+ >
31
+ <XqLabel
32
+ v-if="node.label && !hideLabel"
33
+ :id="`${id}-label`"
34
+ :for="labelFor === null ? undefined : (labelFor ?? id)"
35
+ class="gap-0.5"
36
+ >
37
+ {{ node.label }}
38
+ <span v-if="required" class="text-xq-destructive" aria-hidden="true">*</span>
39
+ <span v-if="required" class="sr-only">(required)</span>
40
+ <LoaderCircle
41
+ v-if="loading"
42
+ class="ml-1 size-3 motion-safe:animate-spin text-xq-muted-foreground"
43
+ aria-hidden="true"
44
+ />
45
+ </XqLabel>
46
+ <slot />
47
+ <p v-if="node.hint && !error" :id="`${id}-hint`" class="text-xs text-xq-muted-foreground">
48
+ {{ node.hint }}
49
+ </p>
50
+ <p
51
+ v-if="error"
52
+ :id="`${id}-error`"
53
+ :role="announceError ? 'alert' : undefined"
54
+ class="text-xs font-medium text-xq-destructive"
55
+ >
56
+ {{ error }}
57
+ </p>
58
+ </div>
59
+ </template>
@@ -0,0 +1,128 @@
1
+ <script setup lang="ts">
2
+ import { CircleAlert } from 'lucide-vue-next';
3
+ import { computed, inject, onMounted, provide, ref } from 'vue';
4
+ import { HEADING_LEVEL, provideFormStore } from '../composables/context';
5
+ import { REGISTRY } from '../composables/registry';
6
+ import { useXqForm } from '../composables/useXqForm';
7
+ import type { FormStore } from '../core/store';
8
+ import { cn } from '../lib/utils';
9
+ import type { FormSchema } from '../schema/types';
10
+ import XqButton from '../ui/XqButton.vue';
11
+ import { createRegistry } from './defaults';
12
+ import XqNode from './XqNode.vue';
13
+
14
+ const props = withDefaults(
15
+ defineProps<{
16
+ form: FormStore | FormSchema;
17
+ action?: string;
18
+ method?: 'post' | 'put' | 'patch';
19
+ submitLabel?: string;
20
+ preserveState?: boolean | 'errors';
21
+ /** Heading level of top-level section titles. Set it to fit the page around the form. */
22
+ headingLevel?: 2 | 3 | 4 | 5 | 6;
23
+ class?: string;
24
+ }>(),
25
+ { method: 'post', submitLabel: 'Save', headingLevel: 2, preserveState: 'errors' },
26
+ );
27
+ const emit = defineEmits<{
28
+ submit: [store: FormStore];
29
+ success: [page: unknown];
30
+ error: [errors: Record<string, string>];
31
+ }>();
32
+
33
+ function isStore(value: FormStore | FormSchema): value is FormStore {
34
+ return 'state' in value && 'submit' in value && typeof value.submit === 'function';
35
+ }
36
+
37
+ const store = isStore(props.form) ? props.form : useXqForm(props.form);
38
+ const { processing, uploading, fatalError, liveError } = store;
39
+ const syncing = store.queue.busy;
40
+ const errorCount = computed(() => Object.keys(store.errors).length);
41
+
42
+ provideFormStore(store);
43
+ provide(HEADING_LEVEL, props.headingLevel);
44
+ provide(REGISTRY, inject(REGISTRY, null) ?? createRegistry());
45
+
46
+ const root = ref<HTMLFormElement | null>(null);
47
+
48
+ onMounted(() => {
49
+ store.root.value = root.value;
50
+ });
51
+
52
+ function submit(): void {
53
+ if (!props.action) {
54
+ emit('submit', store);
55
+
56
+ return;
57
+ }
58
+
59
+ void store.submit(props.action, {
60
+ method: props.method,
61
+ preserveState: props.preserveState,
62
+ onSuccess: (page) => emit('success', page),
63
+ onError: (errors) => emit('error', errors),
64
+ });
65
+ }
66
+ </script>
67
+
68
+ <template>
69
+ <form
70
+ ref="root"
71
+ :class="cn('xq-form gap-6 grid', props.class)"
72
+ :data-xq-form="store.id"
73
+ novalidate
74
+ @submit.prevent="submit"
75
+ >
76
+ <div class="xq-grid">
77
+ <XqNode v-for="node in store.components" :key="node.key" :node="node" />
78
+ </div>
79
+ <p role="status" class="sr-only">
80
+ <template v-if="errorCount">
81
+ {{ errorCount }} {{ errorCount === 1 ? 'field needs' : 'fields need' }} attention.
82
+ </template>
83
+ </p>
84
+ <div
85
+ v-if="fatalError"
86
+ role="alert"
87
+ class="bottom-4 gap-2 px-3 py-2 text-sm shadow-lg sticky z-10 flex items-start rounded-xq-md border border-xq-destructive/40 bg-xq-card text-xq-destructive"
88
+ >
89
+ <CircleAlert class="mt-0.5 size-4 shrink-0" />
90
+ {{ fatalError }}
91
+ </div>
92
+ <div
93
+ v-if="liveError"
94
+ role="alert"
95
+ class="bottom-4 gap-3 px-3 py-2 text-sm shadow-lg sticky z-10 flex items-center justify-between rounded-xq-md border border-xq-destructive/40 bg-xq-card text-xq-destructive"
96
+ >
97
+ <span class="gap-2 flex items-start">
98
+ <CircleAlert class="mt-0.5 size-4 shrink-0" />
99
+ {{ liveError }}
100
+ </span>
101
+ <XqButton
102
+ type="button"
103
+ variant="outline"
104
+ size="sm"
105
+ :disabled="syncing"
106
+ @click="store.queue.retry()"
107
+ >
108
+ Try again
109
+ </XqButton>
110
+ </div>
111
+ <slot
112
+ name="actions"
113
+ :store="store"
114
+ :processing="processing"
115
+ :uploading="uploading"
116
+ :submit="submit"
117
+ >
118
+ <div v-if="action" class="gap-3 flex items-center justify-end">
119
+ <span v-if="uploading" class="text-xs text-xq-muted-foreground"
120
+ >Waiting for uploads…</span
121
+ >
122
+ <XqButton type="submit" :disabled="processing">
123
+ {{ processing ? 'Saving…' : submitLabel }}
124
+ </XqButton>
125
+ </div>
126
+ </slot>
127
+ </form>
128
+ </template>
@@ -0,0 +1,22 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import { useFormStore } from '../composables/context';
4
+ import { spanStyle } from '../composables/layout';
5
+ import { useRegistry } from '../composables/registry';
6
+ import type { SchemaNode } from '../schema/types';
7
+ import XqUnknownNode from './XqUnknownNode.vue';
8
+
9
+ const props = defineProps<{ node: SchemaNode }>();
10
+ const store = useFormStore();
11
+ const registry = useRegistry();
12
+
13
+ const visible = computed(() => props.node.type !== 'hidden' && store.isVisible(props.node));
14
+ const component = computed(() => registry?.get(props.node.type) ?? null);
15
+ </script>
16
+
17
+ <template>
18
+ <div v-if="visible" class="xq-cell" :style="spanStyle(node.span)" :data-xq-key="node.key">
19
+ <component :is="component" v-if="component" :node="node" />
20
+ <XqUnknownNode v-else :node="node" />
21
+ </div>
22
+ </template>
@@ -0,0 +1,15 @@
1
+ <script setup lang="ts">
2
+ import type { SchemaNode } from '../schema/types';
3
+
4
+ defineProps<{ node: SchemaNode }>();
5
+ </script>
6
+
7
+ <template>
8
+ <div
9
+ class="px-3 py-2 text-xs rounded-xq-md border border-dashed border-xq-destructive/50 bg-xq-destructive/5 text-xq-destructive"
10
+ >
11
+ No component is registered for type
12
+ <code class="font-mono font-semibold">{{ node.type }}</code> ({{ node.key }}). Register one
13
+ with <code class="font-mono">createXelqunForms({ components })</code>.
14
+ </div>
15
+ </template>
@@ -0,0 +1,44 @@
1
+ import { toComponent } from '../composables/registry';
2
+ import type { ComponentRegistry, ComponentResolver } from '../composables/registry';
3
+ import XqCheckboxField from './fields/XqCheckboxField.vue';
4
+ import XqRadioField from './fields/XqRadioField.vue';
5
+ import XqRepeaterField from './fields/XqRepeaterField.vue';
6
+ import XqSelectField from './fields/XqSelectField.vue';
7
+ import XqTextareaField from './fields/XqTextareaField.vue';
8
+ import XqTextInputField from './fields/XqTextInputField.vue';
9
+ import XqToggleField from './fields/XqToggleField.vue';
10
+ import XqFieldset from './layouts/XqFieldset.vue';
11
+ import XqGrid from './layouts/XqGrid.vue';
12
+ import XqSection from './layouts/XqSection.vue';
13
+ import XqTabsLayout from './layouts/XqTabsLayout.vue';
14
+
15
+ /**
16
+ * Built-in renderers keyed by schema node type. Heavier fields are code-split.
17
+ */
18
+ export function defaultComponents(): Record<string, ComponentResolver> {
19
+ return {
20
+ 'text-input': XqTextInputField,
21
+ textarea: XqTextareaField,
22
+ select: XqSelectField,
23
+ checkbox: XqCheckboxField,
24
+ toggle: XqToggleField,
25
+ radio: XqRadioField,
26
+ 'date-picker': () => import('./fields/XqDatePickerField.vue'),
27
+ 'file-upload': () => import('./fields/XqFileUploadField.vue'),
28
+ repeater: XqRepeaterField,
29
+ section: XqSection,
30
+ grid: XqGrid,
31
+ fieldset: XqFieldset,
32
+ tabs: XqTabsLayout,
33
+ };
34
+ }
35
+
36
+ export function createRegistry(custom: Record<string, ComponentResolver> = {}): ComponentRegistry {
37
+ const registry: ComponentRegistry = new Map();
38
+
39
+ for (const [type, resolver] of Object.entries({ ...defaultComponents(), ...custom })) {
40
+ registry.set(type, toComponent(resolver));
41
+ }
42
+
43
+ return registry;
44
+ }