@xelqun/forms 0.1.1 → 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.
- package/README.md +46 -61
- package/package.json +32 -2
- package/resources/css/xelqun.css +121 -0
- package/resources/js/components/XqAction.vue +143 -0
- package/resources/js/components/XqFieldSlot.vue +20 -0
- package/resources/js/components/XqFieldWrapper.vue +74 -30
- package/resources/js/components/XqForm.vue +36 -9
- package/resources/js/components/XqNode.vue +8 -1
- package/resources/js/components/XqUnknownNode.vue +2 -1
- package/resources/js/components/defaults.ts +36 -0
- package/resources/js/components/fields/XqBlockPicker.vue +68 -0
- package/resources/js/components/fields/XqCheckboxField.vue +35 -9
- package/resources/js/components/fields/XqCheckboxListField.vue +115 -0
- package/resources/js/components/fields/XqCodeEditorField.vue +92 -0
- package/resources/js/components/fields/XqColorPickerField.vue +156 -0
- package/resources/js/components/fields/XqDatePickerField.vue +149 -53
- package/resources/js/components/fields/XqFileUploadField.vue +524 -165
- package/resources/js/components/fields/XqImageEditor.vue +165 -0
- package/resources/js/components/fields/XqKeyValueField.vue +168 -0
- package/resources/js/components/fields/XqMarkdownEditorField.vue +245 -0
- package/resources/js/components/fields/XqRadioField.vue +9 -5
- package/resources/js/components/fields/XqRepeaterField.vue +314 -79
- package/resources/js/components/fields/XqRepeaterRowActions.vue +101 -0
- package/resources/js/components/fields/XqRichEditorField.vue +280 -0
- package/resources/js/components/fields/XqSelectField.vue +29 -12
- package/resources/js/components/fields/XqSliderField.vue +47 -0
- package/resources/js/components/fields/XqTagsInputField.vue +127 -0
- package/resources/js/components/fields/XqTextInputField.vue +121 -12
- package/resources/js/components/fields/XqTextareaField.vue +39 -16
- package/resources/js/components/fields/XqToggleButtonsField.vue +75 -0
- package/resources/js/components/fields/XqToggleField.vue +33 -9
- package/resources/js/components/layouts/XqActions.vue +19 -0
- package/resources/js/components/layouts/XqBlock.vue +52 -0
- package/resources/js/components/layouts/XqCallout.vue +46 -0
- package/resources/js/components/layouts/XqEmptyState.vue +37 -0
- package/resources/js/components/layouts/XqFlex.vue +23 -0
- package/resources/js/components/layouts/XqFusedGroup.vue +48 -0
- package/resources/js/components/layouts/XqIconNode.vue +32 -0
- package/resources/js/components/layouts/XqImageNode.vue +30 -0
- package/resources/js/components/layouts/XqSection.vue +3 -1
- package/resources/js/components/layouts/XqTabsLayout.vue +3 -21
- package/resources/js/components/layouts/XqTextNode.vue +49 -0
- package/resources/js/components/layouts/XqUnorderedList.vue +18 -0
- package/resources/js/components/layouts/XqWizard.vue +168 -0
- package/resources/js/composables/context.ts +14 -0
- package/resources/js/composables/useField.ts +21 -3
- package/resources/js/composables/usePanelErrors.ts +37 -0
- package/resources/js/composables/useSortable.ts +70 -0
- package/resources/js/composables/useUnsavedChangesAlert.ts +48 -0
- package/resources/js/core/dates.ts +38 -0
- package/resources/js/core/images.ts +110 -0
- package/resources/js/core/log.ts +1 -1
- package/resources/js/core/markdown.ts +61 -0
- package/resources/js/core/mask.ts +42 -0
- package/resources/js/core/patch.ts +6 -3
- package/resources/js/core/paths.ts +11 -14
- package/resources/js/core/store.ts +515 -25
- package/resources/js/core/translate.ts +17 -0
- package/resources/js/core/validation.ts +126 -0
- package/resources/js/index.ts +4 -3
- package/resources/js/lib/utils.ts +15 -0
- package/resources/js/plugin.ts +103 -6
- package/resources/js/schema/types.ts +102 -0
- package/resources/js/testing.ts +50 -0
- package/resources/js/ui/XqCalendar.vue +4 -0
- package/resources/js/ui/XqCombobox.vue +89 -32
- package/resources/js/ui/XqConfirmDialog.vue +58 -0
- package/resources/js/ui/XqDialog.vue +46 -0
- package/resources/js/ui/XqIcon.vue +28 -0
- package/resources/js/ui/XqSelect.vue +38 -18
- package/resources/js/ui/XqTabs.vue +1 -1
- package/resources/js/ui/XqTimeField.vue +66 -0
- package/resources/js/ui/classes.ts +33 -0
- package/resources/js/ui/icons.ts +95 -0
- package/resources/js/ui/index.ts +5 -0
- package/resources/js/ui/options.ts +46 -0
package/README.md
CHANGED
|
@@ -1,8 +1,18 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
36
|
+
## Features
|
|
33
37
|
|
|
34
|
-
- **
|
|
35
|
-
- **
|
|
36
|
-
- **
|
|
37
|
-
- **
|
|
38
|
-
- **
|
|
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
|
|
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
|
-
|
|
54
|
+
## Usage
|
|
66
55
|
|
|
67
56
|
```php
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
90
|
+
## Plugins
|
|
92
91
|
|
|
93
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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.
|
|
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": {
|
package/resources/css/xelqun.css
CHANGED
|
@@ -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
|
-
<
|
|
32
|
-
|
|
33
|
-
:
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
:
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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>
|