@xelqun/forms 0.1.0 → 0.2.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.
Files changed (76) hide show
  1. package/README.md +46 -61
  2. package/package.json +32 -2
  3. package/resources/css/xelqun.css +121 -0
  4. package/resources/js/components/XqAction.vue +143 -0
  5. package/resources/js/components/XqFieldSlot.vue +20 -0
  6. package/resources/js/components/XqFieldWrapper.vue +74 -30
  7. package/resources/js/components/XqForm.vue +36 -9
  8. package/resources/js/components/XqNode.vue +8 -1
  9. package/resources/js/components/XqUnknownNode.vue +2 -1
  10. package/resources/js/components/defaults.ts +36 -0
  11. package/resources/js/components/fields/XqBlockPicker.vue +68 -0
  12. package/resources/js/components/fields/XqCheckboxField.vue +35 -9
  13. package/resources/js/components/fields/XqCheckboxListField.vue +115 -0
  14. package/resources/js/components/fields/XqCodeEditorField.vue +92 -0
  15. package/resources/js/components/fields/XqColorPickerField.vue +156 -0
  16. package/resources/js/components/fields/XqDatePickerField.vue +149 -53
  17. package/resources/js/components/fields/XqFileUploadField.vue +524 -165
  18. package/resources/js/components/fields/XqImageEditor.vue +165 -0
  19. package/resources/js/components/fields/XqKeyValueField.vue +168 -0
  20. package/resources/js/components/fields/XqMarkdownEditorField.vue +245 -0
  21. package/resources/js/components/fields/XqRadioField.vue +9 -5
  22. package/resources/js/components/fields/XqRepeaterField.vue +314 -79
  23. package/resources/js/components/fields/XqRepeaterRowActions.vue +101 -0
  24. package/resources/js/components/fields/XqRichEditorField.vue +280 -0
  25. package/resources/js/components/fields/XqSelectField.vue +29 -12
  26. package/resources/js/components/fields/XqSliderField.vue +47 -0
  27. package/resources/js/components/fields/XqTagsInputField.vue +127 -0
  28. package/resources/js/components/fields/XqTextInputField.vue +121 -12
  29. package/resources/js/components/fields/XqTextareaField.vue +39 -16
  30. package/resources/js/components/fields/XqToggleButtonsField.vue +75 -0
  31. package/resources/js/components/fields/XqToggleField.vue +33 -9
  32. package/resources/js/components/layouts/XqActions.vue +19 -0
  33. package/resources/js/components/layouts/XqBlock.vue +52 -0
  34. package/resources/js/components/layouts/XqCallout.vue +46 -0
  35. package/resources/js/components/layouts/XqEmptyState.vue +37 -0
  36. package/resources/js/components/layouts/XqFlex.vue +23 -0
  37. package/resources/js/components/layouts/XqFusedGroup.vue +48 -0
  38. package/resources/js/components/layouts/XqIconNode.vue +32 -0
  39. package/resources/js/components/layouts/XqImageNode.vue +30 -0
  40. package/resources/js/components/layouts/XqSection.vue +3 -1
  41. package/resources/js/components/layouts/XqTabsLayout.vue +3 -21
  42. package/resources/js/components/layouts/XqTextNode.vue +49 -0
  43. package/resources/js/components/layouts/XqUnorderedList.vue +18 -0
  44. package/resources/js/components/layouts/XqWizard.vue +168 -0
  45. package/resources/js/composables/context.ts +14 -0
  46. package/resources/js/composables/useField.ts +21 -3
  47. package/resources/js/composables/usePanelErrors.ts +37 -0
  48. package/resources/js/composables/useSortable.ts +70 -0
  49. package/resources/js/composables/useUnsavedChangesAlert.ts +48 -0
  50. package/resources/js/core/dates.ts +38 -0
  51. package/resources/js/core/images.ts +110 -0
  52. package/resources/js/core/log.ts +1 -1
  53. package/resources/js/core/markdown.ts +61 -0
  54. package/resources/js/core/mask.ts +42 -0
  55. package/resources/js/core/patch.ts +6 -3
  56. package/resources/js/core/paths.ts +11 -14
  57. package/resources/js/core/store.ts +515 -25
  58. package/resources/js/core/translate.ts +17 -0
  59. package/resources/js/core/validation.ts +126 -0
  60. package/resources/js/index.ts +4 -3
  61. package/resources/js/lib/utils.ts +15 -0
  62. package/resources/js/plugin.ts +103 -6
  63. package/resources/js/schema/types.ts +102 -0
  64. package/resources/js/testing.ts +50 -0
  65. package/resources/js/ui/XqCalendar.vue +4 -0
  66. package/resources/js/ui/XqCombobox.vue +89 -32
  67. package/resources/js/ui/XqConfirmDialog.vue +58 -0
  68. package/resources/js/ui/XqDialog.vue +46 -0
  69. package/resources/js/ui/XqIcon.vue +28 -0
  70. package/resources/js/ui/XqSelect.vue +38 -18
  71. package/resources/js/ui/XqTabs.vue +1 -1
  72. package/resources/js/ui/XqTimeField.vue +66 -0
  73. package/resources/js/ui/classes.ts +33 -0
  74. package/resources/js/ui/icons.ts +95 -0
  75. package/resources/js/ui/index.ts +5 -0
  76. package/resources/js/ui/options.ts +46 -0
package/README.md CHANGED
@@ -1,8 +1,18 @@
1
- # Xelqun Forms
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/199ocero/xelqun-forms/main/.github/art/banner.png" alt="Xelqun Forms: PHP-defined forms for Laravel, Inertia and Vue" width="100%">
3
+ </p>
2
4
 
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.
5
+ <p align="center">
6
+ <a href="https://packagist.org/packages/xelqun/forms"><img src="https://img.shields.io/packagist/v/xelqun/forms?label=packagist&color=414fd2" alt="Packagist version"></a>
7
+ <a href="https://www.npmjs.com/package/@xelqun/forms"><img src="https://img.shields.io/npm/v/@xelqun/forms?label=npm&color=414fd2" alt="npm version"></a>
8
+ <a href="https://packagist.org/packages/xelqun/forms/stats"><img src="https://img.shields.io/packagist/dt/xelqun/forms?color=414fd2" alt="Total downloads"></a>
9
+ </p>
4
10
 
5
- > **Status: pre-alpha.** The API will change. Built and tested against Laravel 13, Inertia 3, Vue 3.5 and Tailwind 4.
11
+ Describe a form in PHP and generic Vue components render it. Normal forms need no Vue at all.
12
+
13
+ > **Pre-alpha:** the API will change. Requires Laravel 13, Inertia 3, Vue 3.5 and Tailwind 4.
14
+
15
+ ## Example
6
16
 
7
17
  ```php
8
18
  class OrderForm extends Form
@@ -11,13 +21,7 @@ class OrderForm extends Form
11
21
  {
12
22
  return [
13
23
  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
- ]),
24
+ Select::make('country_id')->relationship('country', 'name'),
21
25
  Repeater::make('items')->relationship()->schema([
22
26
  TextInput::make('qty')->numeric(),
23
27
  TextInput::make('price')->numeric(),
@@ -29,13 +33,15 @@ class OrderForm extends Form
29
33
  }
30
34
  ```
31
35
 
32
- ## How it works
36
+ ## Features
33
37
 
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.
38
+ - **Write PHP, not Vue.** Normal forms need no frontend code.
39
+ - **Instant browser rules.** Conditions and calculations run without a server call, and the server re-checks them on submit.
40
+ - **Small server updates.** Dependent fields update through small patches that keep focus and cursor position.
41
+ - **Secure by default.** Forms rebuild from an encrypted token, and every endpoint re-runs `authorize()` and validation.
42
+ - **Repeaters and uploads included.** Nested rows map to validation errors, and files upload as soon as they're picked.
43
+ - **Every field you'd expect.** Select with relationships, date and time pickers, tags, sliders, colors, a block `Builder`, and rich text, Markdown and code editors.
44
+ - **Wizards and actions.** Multi-step forms check each step on the server, and buttons can run PHP through a modal with its own fields.
39
45
 
40
46
  ## Installation
41
47
 
@@ -43,36 +49,29 @@ class OrderForm extends Form
43
49
  composer require xelqun/forms && php artisan xelqun-forms:install
44
50
  ```
45
51
 
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
- ```
52
+ The installer adds the matching `@xelqun/forms` npm package and sets up `resources/js/app.ts` and `resources/css/app.css`.
64
53
 
65
- Then render a form:
54
+ ## Usage
66
55
 
67
56
  ```php
68
- // controller
69
- return Inertia::render('Orders/Edit', ['form' => OrderForm::make($order)->toSchema()]);
70
-
71
- public function update(Request $request, Order $order)
57
+ class OrderController
72
58
  {
73
- OrderForm::make($order)->save($request);
59
+ // Show the form: GET /orders/{order}/edit
60
+ public function edit(Order $order)
61
+ {
62
+ return Inertia::render('Orders/Edit', [
63
+ 'form' => OrderForm::make($order)->toSchema(),
64
+ 'orderId' => $order->id,
65
+ ]);
66
+ }
74
67
 
75
- return back();
68
+ // Save the form: PUT /orders/{order}
69
+ public function update(Request $request, Order $order)
70
+ {
71
+ OrderForm::make($order)->save($request);
72
+
73
+ return back();
74
+ }
76
75
  }
77
76
  ```
78
77
 
@@ -88,29 +87,15 @@ defineProps<{ form: FormSchema; orderId: number }>();
88
87
  </template>
89
88
  ```
90
89
 
91
- Section titles start at `h2`. If the form sits lower in your page's heading order, set it with `<XqForm :heading-level="3" />`.
90
+ ## Plugins
92
91
 
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]`.
92
+ Other packages can add fields, with the same search, uploads, rows and saving as built-in ones. Install one with `php artisan xelqun-forms:plugin acme/rating`, or build one with the [plugin guide](docs/plugins.md).
94
93
 
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
- ```
94
+ ## Before going to production
112
95
 
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.
96
+ - **Add your rules to `authorize()`.** It allows everything by default.
97
+ - **Give each action its own `authorize()`.** An action does nothing without one, and the form's `authorize()` doesn't count.
98
+ - **Schedule `php artisan xelqun-forms:prune-uploads`.** Temporary uploads stay on disk until it runs.
114
99
 
115
100
  ## License
116
101
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xelqun/forms",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Vue renderer for Xelqun Forms: PHP-defined, schema-driven forms for Laravel + Inertia.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -18,7 +18,8 @@
18
18
  ],
19
19
  "exports": {
20
20
  ".": "./resources/js/index.ts",
21
- "./style.css": "./resources/css/xelqun.css"
21
+ "./style.css": "./resources/css/xelqun.css",
22
+ "./testing": "./resources/js/testing.ts"
22
23
  },
23
24
  "scripts": {
24
25
  "test": "vp test",
@@ -27,11 +28,40 @@
27
28
  "types:check": "vue-tsc --noEmit"
28
29
  },
29
30
  "dependencies": {
31
+ "@codemirror/lang-cpp": "^6.0.3",
32
+ "@codemirror/lang-css": "^6.3.1",
33
+ "@codemirror/lang-go": "^6.0.1",
34
+ "@codemirror/lang-html": "^6.4.12",
35
+ "@codemirror/lang-java": "^6.0.2",
36
+ "@codemirror/lang-javascript": "^6.2.5",
37
+ "@codemirror/lang-json": "^6.0.2",
38
+ "@codemirror/lang-markdown": "^6.5.2",
39
+ "@codemirror/lang-php": "^6.0.2",
40
+ "@codemirror/lang-python": "^6.2.1",
41
+ "@codemirror/lang-sql": "^6.10.0",
42
+ "@codemirror/lang-xml": "^6.1.0",
43
+ "@codemirror/lang-yaml": "^6.1.3",
44
+ "@codemirror/language": "^6.13.1",
45
+ "@codemirror/state": "^6.7.6",
46
+ "@codemirror/view": "^6.43.15",
30
47
  "@internationalized/date": "^3.12.4",
48
+ "@tiptap/core": "^3.33.0",
49
+ "@tiptap/extension-image": "^3.33.0",
50
+ "@tiptap/extension-subscript": "^3.33.0",
51
+ "@tiptap/extension-superscript": "^3.33.0",
52
+ "@tiptap/extension-table": "^3.33.0",
53
+ "@tiptap/extension-text-align": "^3.33.0",
54
+ "@tiptap/pm": "^3.33.0",
55
+ "@tiptap/starter-kit": "^3.33.0",
56
+ "@tiptap/vue-3": "^3.33.0",
57
+ "@types/sortablejs": "^1.15.9",
31
58
  "class-variance-authority": "^0.7.1",
32
59
  "clsx": "^2.1.1",
60
+ "codemirror": "^6.0.2",
61
+ "cropperjs": "^1.7.0",
33
62
  "lucide-vue-next": "^1.0.0",
34
63
  "reka-ui": "^2.11.0",
64
+ "sortablejs": "^1.15.7",
35
65
  "tailwind-merge": "^3.2.0"
36
66
  },
37
67
  "devDependencies": {
@@ -23,6 +23,8 @@
23
23
  --color-xq-destructive: var(--xq-destructive);
24
24
  --color-xq-destructive-foreground: var(--xq-destructive-foreground);
25
25
  --color-xq-success: var(--xq-success);
26
+ --color-xq-warning: var(--xq-warning);
27
+ --color-xq-info: var(--xq-info);
26
28
  --color-xq-border: var(--xq-border);
27
29
  --color-xq-input: var(--xq-input);
28
30
  --color-xq-ring: var(--xq-ring);
@@ -51,6 +53,8 @@
51
53
  --xq-destructive: oklch(0.54 0.22 27);
52
54
  --xq-destructive-foreground: oklch(0.99 0 0);
53
55
  --xq-success: oklch(0.47 0.12 155);
56
+ --xq-warning: oklch(0.55 0.13 75);
57
+ --xq-info: oklch(0.5 0.13 240);
54
58
  --xq-border: oklch(0.915 0.006 270);
55
59
  --xq-input: oklch(0.885 0.008 270);
56
60
  --xq-ring: oklch(0.55 0.19 272);
@@ -75,6 +79,8 @@
75
79
  --xq-destructive: oklch(0.68 0.19 25);
76
80
  --xq-destructive-foreground: oklch(0.17 0.02 25);
77
81
  --xq-success: oklch(0.72 0.15 155);
82
+ --xq-warning: oklch(0.8 0.14 85);
83
+ --xq-info: oklch(0.74 0.12 240);
78
84
  --xq-border: oklch(1 0 0 / 10%);
79
85
  --xq-input: oklch(1 0 0 / 14%);
80
86
  --xq-ring: oklch(0.68 0.16 272);
@@ -160,3 +166,118 @@
160
166
  );
161
167
  }
162
168
  }
169
+
170
+ /* Flex: children share the row. A child's span means nothing here. */
171
+ .xq-flex > .xq-cell {
172
+ flex: 1 1 0%;
173
+ }
174
+
175
+ .xq-flex > .xq-cell[data-xq-grow='false'] {
176
+ flex: none;
177
+ }
178
+
179
+ /* FusedGroup: one border around the group, and the 1px gaps show the same color as dividers. */
180
+ .xq-fused {
181
+ gap: 1px;
182
+ overflow: hidden;
183
+ border: 1px solid var(--xq-input);
184
+ border-radius: var(--radius-xq-md);
185
+ background: var(--xq-input);
186
+ }
187
+
188
+ .xq-fused > .xq-cell {
189
+ background: var(--xq-background);
190
+ }
191
+
192
+ .xq-fused [class*='rounded-xq'] {
193
+ border-radius: 0;
194
+ }
195
+
196
+ .xq-fused [class*='border-xq-input']:not(:focus-visible, [aria-invalid='true']) {
197
+ border-color: transparent;
198
+ }
199
+
200
+ /* Rich text: an app's reset takes away what these elements mean. */
201
+ .xq-rich-content {
202
+ outline: none;
203
+ }
204
+
205
+ .xq-rich-content > * + * {
206
+ margin-top: 0.75em;
207
+ }
208
+
209
+ .xq-rich-content h1 {
210
+ font-size: 1.5em;
211
+ font-weight: 600;
212
+ }
213
+
214
+ .xq-rich-content h2 {
215
+ font-size: 1.25em;
216
+ font-weight: 600;
217
+ }
218
+
219
+ .xq-rich-content h3 {
220
+ font-size: 1.1em;
221
+ font-weight: 600;
222
+ }
223
+
224
+ .xq-rich-content h4,
225
+ .xq-rich-content h5,
226
+ .xq-rich-content h6 {
227
+ font-weight: 600;
228
+ }
229
+
230
+ .xq-rich-content ul {
231
+ padding-inline-start: 1.25em;
232
+ list-style: disc;
233
+ }
234
+
235
+ .xq-rich-content ol {
236
+ padding-inline-start: 1.25em;
237
+ list-style: decimal;
238
+ }
239
+
240
+ .xq-rich-content a {
241
+ color: var(--xq-primary);
242
+ text-decoration: underline;
243
+ }
244
+
245
+ .xq-rich-content blockquote {
246
+ padding-inline-start: 0.75em;
247
+ border-inline-start: 2px solid var(--xq-border);
248
+ color: var(--xq-muted-foreground);
249
+ }
250
+
251
+ .xq-rich-content pre,
252
+ .xq-rich-content :not(pre) > code {
253
+ border-radius: var(--radius-xq-sm);
254
+ background: var(--xq-muted);
255
+ font-family: ui-monospace, monospace;
256
+ font-size: 0.9em;
257
+ }
258
+
259
+ .xq-rich-content pre {
260
+ overflow-x: auto;
261
+ padding: 0.75em;
262
+ }
263
+
264
+ .xq-rich-content :not(pre) > code {
265
+ padding: 0.1em 0.3em;
266
+ }
267
+
268
+ .xq-rich-content table {
269
+ width: 100%;
270
+ border-collapse: collapse;
271
+ }
272
+
273
+ .xq-rich-content td,
274
+ .xq-rich-content th {
275
+ padding: 0.25em 0.5em;
276
+ border: 1px solid var(--xq-border);
277
+ text-align: start;
278
+ }
279
+
280
+ .xq-rich-content img {
281
+ max-width: 100%;
282
+ height: auto;
283
+ }
@@ -0,0 +1,143 @@
1
+ <script setup lang="ts">
2
+ import { LoaderCircle } from 'lucide-vue-next';
3
+ import { computed, onScopeDispose, ref, shallowRef, watch } from 'vue';
4
+ import { useFormStore } from '../composables/context';
5
+ import { createFormStore } from '../core/store';
6
+ import type { FormStore } from '../core/store';
7
+ import type { SchemaNode } from '../schema/types';
8
+ import XqButton from '../ui/XqButton.vue';
9
+ import XqConfirmDialog from '../ui/XqConfirmDialog.vue';
10
+ import XqDialog from '../ui/XqDialog.vue';
11
+ import XqIcon from '../ui/XqIcon.vue';
12
+ import XqForm from './XqForm.vue';
13
+
14
+ const props = defineProps<{ node: SchemaNode }>();
15
+ const store = useFormStore();
16
+ const options = computed(() => props.node.props ?? {});
17
+ const running = ref(false);
18
+ const confirming = ref(false);
19
+ const modalOpen = ref(false);
20
+ const modal = shallowRef<FormStore | null>(null);
21
+
22
+ const variants: Record<string, 'default' | 'destructive' | 'outline'> = {
23
+ primary: 'default',
24
+ danger: 'destructive',
25
+ gray: 'outline',
26
+ };
27
+ const variant = computed(() =>
28
+ options.value.style === 'link'
29
+ ? 'link'
30
+ : options.value.style === 'icon'
31
+ ? 'ghost'
32
+ : (variants[options.value.color] ?? 'outline'),
33
+ );
34
+
35
+ async function run(): Promise<void> {
36
+ // Enter in a modal field reaches this without the button.
37
+ if (running.value) {
38
+ return;
39
+ }
40
+
41
+ running.value = true;
42
+
43
+ if (await store.runAction(props.node, modal.value ?? undefined)) {
44
+ modalOpen.value = false;
45
+ }
46
+
47
+ running.value = false;
48
+ }
49
+
50
+ async function open(): Promise<void> {
51
+ if (!options.value.hasForm) {
52
+ if (options.value.confirmation) {
53
+ confirming.value = true;
54
+ } else {
55
+ await run();
56
+ }
57
+
58
+ return;
59
+ }
60
+
61
+ running.value = true;
62
+
63
+ const form = await store.mountAction(props.node);
64
+
65
+ if (form) {
66
+ modal.value = createFormStore(form);
67
+ modalOpen.value = true;
68
+ }
69
+
70
+ running.value = false;
71
+ }
72
+
73
+ function closeModal(): void {
74
+ modal.value?.dispose();
75
+ modal.value = null;
76
+ }
77
+
78
+ watch(modalOpen, (isOpen) => {
79
+ if (!isOpen) {
80
+ closeModal();
81
+ }
82
+ });
83
+
84
+ onScopeDispose(closeModal);
85
+ </script>
86
+
87
+ <template>
88
+ <div class="inline-flex" data-xq-action>
89
+ <XqButton
90
+ type="button"
91
+ :variant="variant"
92
+ :size="options.style === 'icon' ? 'icon-sm' : 'sm'"
93
+ :disabled="!!options.disabled || running || store.processing.value"
94
+ :title="options.tooltip"
95
+ :aria-label="options.style === 'icon' ? options.label : undefined"
96
+ @click="open"
97
+ >
98
+ <LoaderCircle v-if="running" class="motion-safe:animate-spin" aria-hidden="true" />
99
+ <XqIcon v-else-if="options.icon" :name="options.icon" />
100
+ <template v-if="options.style !== 'icon'">{{ options.label }}</template>
101
+ </XqButton>
102
+ <XqConfirmDialog
103
+ v-if="options.confirmation && !options.hasForm"
104
+ v-model:open="confirming"
105
+ :title="options.heading"
106
+ :message="options.description ?? store.t('Are you sure?')"
107
+ :confirm-label="options.submitLabel ?? store.t('Confirm')"
108
+ :cancel-label="options.cancelLabel"
109
+ :confirm-variant="variants[options.color] === 'destructive' ? 'destructive' : 'default'"
110
+ @confirm="run"
111
+ />
112
+ <XqDialog
113
+ v-if="modal"
114
+ v-model:open="modalOpen"
115
+ :title="options.heading"
116
+ :description="options.description"
117
+ :width="options.width"
118
+ >
119
+ <XqForm :form="modal" @submit="run">
120
+ <template #actions>
121
+ <div class="gap-2 flex justify-end">
122
+ <XqButton
123
+ type="button"
124
+ variant="outline"
125
+ size="sm"
126
+ @click="modalOpen = false"
127
+ >
128
+ {{ options.cancelLabel ?? store.t('Cancel') }}
129
+ </XqButton>
130
+ <XqButton type="submit" size="sm" :disabled="running">
131
+ <LoaderCircle
132
+ v-if="running"
133
+ class="motion-safe:animate-spin"
134
+ aria-hidden="true"
135
+ />
136
+ {{ options.submitLabel ?? store.t('Save') }}
137
+ </XqButton>
138
+ </div>
139
+ </template>
140
+ </XqForm>
141
+ </XqDialog>
142
+ </div>
143
+ </template>
@@ -0,0 +1,20 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import { useFormStore } from '../composables/context';
4
+ import type { FieldSlot, SchemaNode } from '../schema/types';
5
+ import XqNode from './XqNode.vue';
6
+
7
+ const props = defineProps<{ node: SchemaNode; name: FieldSlot }>();
8
+ const store = useFormStore();
9
+ const nodes = computed(() =>
10
+ (props.node.children ?? []).filter(
11
+ (child) => child.slot === props.name && store.isVisible(child),
12
+ ),
13
+ );
14
+ </script>
15
+
16
+ <template>
17
+ <div v-if="nodes.length" class="gap-2 flex flex-wrap items-center">
18
+ <XqNode v-for="child in nodes" :key="child.key" :node="child" />
19
+ </div>
20
+ </template>
@@ -1,9 +1,11 @@
1
1
  <script setup lang="ts">
2
2
  import { LoaderCircle } from 'lucide-vue-next';
3
- import { computed } from 'vue';
4
- import { useFormStore } from '../composables/context';
3
+ import { computed, inject } from 'vue';
4
+ import { FUSED_GROUP, useFormStore } from '../composables/context';
5
+ import { cn } from '../lib/utils';
5
6
  import type { SchemaNode } from '../schema/types';
6
7
  import XqLabel from '../ui/XqLabel.vue';
8
+ import XqFieldSlot from './XqFieldSlot.vue';
7
9
 
8
10
  const props = defineProps<{
9
11
  node: SchemaNode;
@@ -15,45 +17,87 @@ const props = defineProps<{
15
17
  error?: string | null;
16
18
  required?: boolean;
17
19
  hideLabel?: boolean;
20
+ /** The control shows its own spinner, so the label doesn't need one. */
21
+ controlShowsLoading?: boolean;
18
22
  }>();
19
23
 
20
24
  const store = useFormStore();
21
25
  const loading = computed(() => store.isLoading(props.node));
26
+ const label = computed(() => store.text(props.node, 'label'));
27
+ const hint = computed(() => store.text(props.node, 'hint'));
28
+ // A hidden label takes no grid space, so two columns would squeeze the control.
29
+ const inlineLabel = computed(() => props.node.inlineLabel && !props.node.hiddenLabel);
30
+ const showLabelSpinner = computed(
31
+ () => !props.controlShowsLoading && store.showsLoadingIndicator(props.node),
32
+ );
33
+ const fused = inject(FUSED_GROUP, false);
34
+ const hasSlot = (name: string) => props.node.children?.some((child) => child.slot === name);
35
+ const hasLabelSlots = computed(() => hasSlot('beforeLabel') || hasSlot('afterLabel'));
36
+ // A wrapper around a screen-reader label would be an empty row with a gap.
37
+ const hasStackedLabel = computed(
38
+ () => hasLabelSlots.value || hasSlot('aboveLabel') || hasSlot('belowLabel'),
39
+ );
40
+ const hasContentSlots = computed(() =>
41
+ ['beforeContent', 'afterContent', 'prefixAction', 'suffixAction', 'optionAction'].some(hasSlot),
42
+ );
22
43
  </script>
23
44
 
24
45
  <template>
25
46
  <div
26
- class="gap-2 grid"
47
+ :class="cn('gap-2 grid', inlineLabel && 'sm:grid-cols-[minmax(0,1fr)_minmax(0,2fr)]')"
27
48
  :data-xq-path="node.path"
28
49
  :data-invalid="error ? '' : undefined"
29
50
  :aria-busy="loading || undefined"
30
51
  >
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>
52
+ <div :class="hasStackedLabel ? 'gap-2 grid content-start' : 'contents'">
53
+ <XqFieldSlot :node="node" name="aboveLabel" />
54
+ <div :class="hasLabelSlots ? 'gap-2 flex items-center' : 'contents'">
55
+ <XqFieldSlot :node="node" name="beforeLabel" />
56
+ <XqLabel
57
+ v-if="label && !hideLabel"
58
+ :id="`${id}-label`"
59
+ :for="labelFor === null ? undefined : (labelFor ?? id)"
60
+ :class="cn('gap-0.5', node.hiddenLabel && 'sr-only', inlineLabel && 'sm:mt-2')"
61
+ >
62
+ {{ label }}
63
+ <span v-if="required" class="text-xq-destructive" aria-hidden="true">*</span>
64
+ <span v-if="required" class="sr-only">{{ store.t('(required)') }}</span>
65
+ <LoaderCircle
66
+ v-if="showLabelSpinner"
67
+ class="ml-1 size-3 motion-safe:animate-spin text-xq-muted-foreground"
68
+ aria-hidden="true"
69
+ />
70
+ </XqLabel>
71
+ <XqFieldSlot class="ms-auto" :node="node" name="afterLabel" />
72
+ </div>
73
+ <XqFieldSlot :node="node" name="belowLabel" />
74
+ </div>
75
+ <div :class="inlineLabel ? 'gap-2 min-w-0 grid' : 'contents'">
76
+ <XqFieldSlot :node="node" name="aboveContent" />
77
+ <div :class="hasContentSlots ? 'gap-2 flex items-center' : 'contents'">
78
+ <XqFieldSlot :node="node" name="beforeContent" />
79
+ <XqFieldSlot :node="node" name="prefixAction" />
80
+ <div :class="hasContentSlots ? 'min-w-0 flex-1' : 'contents'">
81
+ <slot />
82
+ </div>
83
+ <XqFieldSlot :node="node" name="optionAction" />
84
+ <XqFieldSlot :node="node" name="suffixAction" />
85
+ <XqFieldSlot :node="node" name="afterContent" />
86
+ </div>
87
+ <XqFieldSlot :node="node" name="belowContent" />
88
+ <p v-if="hint && !error" :id="`${id}-hint`" class="text-xs text-xq-muted-foreground">
89
+ {{ hint }}
90
+ </p>
91
+ <XqFieldSlot :node="node" name="aboveErrorMessage" />
92
+ <p
93
+ v-if="error && !fused"
94
+ :id="`${id}-error`"
95
+ :role="announceError ? 'alert' : undefined"
96
+ class="text-xs font-medium text-xq-destructive"
97
+ >
98
+ {{ error }}
99
+ </p>
100
+ <XqFieldSlot :node="node" name="belowErrorMessage" />
101
+ </div>
58
102
  </div>
59
103
  </template>