@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
@@ -0,0 +1,165 @@
1
+ <script setup lang="ts">
2
+ import Cropper from 'cropperjs';
3
+ import 'cropperjs/dist/cropper.css';
4
+ import { FlipHorizontal, FlipVertical, RotateCcw, RotateCw, Undo2 } from 'lucide-vue-next';
5
+ import { computed, onBeforeUnmount, ref } from 'vue';
6
+ import { useTranslator } from '../../composables/context';
7
+ import { toolbarButtonClass } from '../../ui/classes';
8
+ import XqButton from '../../ui/XqButton.vue';
9
+ import XqDialog from '../../ui/XqDialog.vue';
10
+
11
+ const props = defineProps<{
12
+ src: string;
13
+ name: string;
14
+ aspectRatios?: Array<string | null> | null;
15
+ fillColor?: string | null;
16
+ }>();
17
+ const emit = defineEmits<{ save: [file: File]; cancel: [] }>();
18
+ const t = useTranslator();
19
+ const open = ref(true);
20
+ const failed = ref(false);
21
+ const image = ref<HTMLImageElement | null>(null);
22
+ let cropper: Cropper | null = null;
23
+ let scaleX = 1;
24
+ let scaleY = 1;
25
+
26
+ const ratios = computed(() => props.aspectRatios ?? []);
27
+
28
+ function toNumber(ratio: string | null): number {
29
+ const [width, height] = (ratio ?? '').split(':').map(Number);
30
+
31
+ return width > 0 && height > 0 ? width / height : NaN;
32
+ }
33
+
34
+ function start(): void {
35
+ cropper = new Cropper(image.value!, {
36
+ viewMode: 1,
37
+ autoCropArea: 1,
38
+ aspectRatio: ratios.value.length ? toNumber(ratios.value[0]) : NaN,
39
+ });
40
+ }
41
+
42
+ function flip(axis: 'x' | 'y'): void {
43
+ if (axis === 'x') {
44
+ scaleX = -scaleX;
45
+ cropper?.scaleX(scaleX);
46
+ } else {
47
+ scaleY = -scaleY;
48
+ cropper?.scaleY(scaleY);
49
+ }
50
+ }
51
+
52
+ function close(): void {
53
+ open.value = false;
54
+ emit('cancel');
55
+ }
56
+
57
+ function save(): void {
58
+ // A stored file on another origin taints the canvas, unless that origin allows it.
59
+ try {
60
+ cropper
61
+ ?.getCroppedCanvas({ fillColor: props.fillColor ?? 'transparent' })
62
+ .toBlob((blob) => {
63
+ if (blob) {
64
+ emit('save', new File([blob], props.name, { type: blob.type }));
65
+ } else {
66
+ failed.value = true;
67
+ }
68
+ });
69
+ } catch {
70
+ failed.value = true;
71
+ }
72
+ }
73
+
74
+ onBeforeUnmount(() => cropper?.destroy());
75
+ </script>
76
+
77
+ <template>
78
+ <XqDialog
79
+ :open="open"
80
+ :title="t('Edit image')"
81
+ :description="name"
82
+ width="2xl"
83
+ @update:open="$event || close()"
84
+ >
85
+ <div class="gap-3 grid">
86
+ <div class="max-h-[60vh] overflow-hidden">
87
+ <img
88
+ ref="image"
89
+ :src="src"
90
+ :alt="name"
91
+ crossorigin="anonymous"
92
+ class="block max-w-full"
93
+ @load="start"
94
+ @error="failed = true"
95
+ />
96
+ </div>
97
+ <p v-if="failed" role="alert" class="text-xs font-medium text-xq-destructive">
98
+ {{ t('The image could not be edited.') }}
99
+ </p>
100
+ <div class="gap-1 flex flex-wrap items-center">
101
+ <button
102
+ type="button"
103
+ :class="toolbarButtonClass"
104
+ :aria-label="t('Rotate left')"
105
+ @click="cropper?.rotate(-90)"
106
+ >
107
+ <RotateCcw class="size-4" aria-hidden="true" />
108
+ </button>
109
+ <button
110
+ type="button"
111
+ :class="toolbarButtonClass"
112
+ :aria-label="t('Rotate right')"
113
+ @click="cropper?.rotate(90)"
114
+ >
115
+ <RotateCw class="size-4" aria-hidden="true" />
116
+ </button>
117
+ <button
118
+ type="button"
119
+ :class="toolbarButtonClass"
120
+ :aria-label="t('Flip horizontally')"
121
+ @click="flip('x')"
122
+ >
123
+ <FlipHorizontal class="size-4" aria-hidden="true" />
124
+ </button>
125
+ <button
126
+ type="button"
127
+ :class="toolbarButtonClass"
128
+ :aria-label="t('Flip vertically')"
129
+ @click="flip('y')"
130
+ >
131
+ <FlipVertical class="size-4" aria-hidden="true" />
132
+ </button>
133
+ <button
134
+ type="button"
135
+ :class="toolbarButtonClass"
136
+ :aria-label="t('Reset')"
137
+ @click="cropper?.reset()"
138
+ >
139
+ <Undo2 class="size-4" aria-hidden="true" />
140
+ </button>
141
+ <div v-if="ratios.length" class="gap-1 ms-auto flex flex-wrap">
142
+ <XqButton
143
+ v-for="ratio in ratios"
144
+ :key="ratio ?? 'free'"
145
+ type="button"
146
+ variant="outline"
147
+ size="sm"
148
+ :data-xq-ratio="ratio ?? 'free'"
149
+ @click="cropper?.setAspectRatio(toNumber(ratio))"
150
+ >
151
+ {{ ratio ?? t('Free') }}
152
+ </XqButton>
153
+ </div>
154
+ </div>
155
+ <div class="gap-2 flex justify-end">
156
+ <XqButton type="button" variant="outline" size="sm" @click="close">
157
+ {{ t('Cancel') }}
158
+ </XqButton>
159
+ <XqButton type="button" size="sm" data-xq-image-save @click="save">
160
+ {{ t('Save') }}
161
+ </XqButton>
162
+ </div>
163
+ </div>
164
+ </XqDialog>
165
+ </template>
@@ -0,0 +1,168 @@
1
+ <script setup lang="ts">
2
+ import { ChevronDown, ChevronUp, GripVertical, Plus, X } from 'lucide-vue-next';
3
+ import { computed, ref, watch } from 'vue';
4
+ import { useField } from '../../composables/useField';
5
+ import { useSortable } from '../../composables/useSortable';
6
+ import type { SchemaNode } from '../../schema/types';
7
+ import { moveItem } from '../../ui/options';
8
+ import XqButton from '../../ui/XqButton.vue';
9
+ import XqInput from '../../ui/XqInput.vue';
10
+ import XqFieldWrapper from '../XqFieldWrapper.vue';
11
+ import { useTranslator } from '../../composables/context';
12
+
13
+ const t = useTranslator();
14
+ interface Row {
15
+ id: number;
16
+ key: string;
17
+ value: string;
18
+ }
19
+
20
+ const props = defineProps<{ node: SchemaNode }>();
21
+ const { value, error, describedBy, required, disabled, id } = useField<Record<string, unknown>>(
22
+ props.node,
23
+ );
24
+ const options = computed(() => props.node.props ?? {});
25
+ let nextRowId = 0;
26
+
27
+ function toRows(state: unknown): Row[] {
28
+ const map = state && !Array.isArray(state) ? (state as Record<string, unknown>) : {};
29
+
30
+ return Object.entries(map).map(([key, item]) => ({
31
+ id: nextRowId++,
32
+ key,
33
+ value: item === null || item === undefined ? '' : String(item),
34
+ }));
35
+ }
36
+
37
+ function toMap(list: Row[]): Record<string, string> {
38
+ return Object.fromEntries(
39
+ list.filter((row) => row.key.trim() !== '').map((row) => [row.key, row.value]),
40
+ );
41
+ }
42
+
43
+ /** Rows keep a blank key and a stable id while typing, which the map can't hold. */
44
+ const rows = ref<Row[]>(toRows(value.value));
45
+
46
+ function commit(): void {
47
+ value.value = toMap(rows.value);
48
+ }
49
+
50
+ watch(value, (next) => {
51
+ if (JSON.stringify(next ?? {}) !== JSON.stringify(toMap(rows.value))) {
52
+ rows.value = toRows(next);
53
+ }
54
+ });
55
+
56
+ function add(): void {
57
+ rows.value.push({ id: nextRowId++, key: '', value: '' });
58
+ }
59
+
60
+ function remove(row: Row): void {
61
+ rows.value = rows.value.filter((item) => item.id !== row.id);
62
+ commit();
63
+ }
64
+
65
+ function move(row: Row, offset: number): void {
66
+ rows.value = moveItem(rows.value, row, offset);
67
+ commit();
68
+ }
69
+
70
+ const rowList = ref<HTMLElement | null>(null);
71
+
72
+ useSortable(rowList, {
73
+ draggable: '> [data-xq-row]',
74
+ handle: '[data-xq-drag]',
75
+ enabled: () => !!options.value.reorderable && !disabled.value && rows.value.length > 1,
76
+ onMove: (from, to) => move(rows.value[from], to - from),
77
+ });
78
+ </script>
79
+
80
+ <template>
81
+ <XqFieldWrapper :node="node" :id="id" :label-for="null" :error="error" :required="required">
82
+ <div class="gap-2 grid">
83
+ <div
84
+ v-if="rows.length"
85
+ class="gap-2 text-xs font-medium grid grid-cols-2 text-xq-muted-foreground"
86
+ >
87
+ <span>{{ options.keyLabel }}</span>
88
+ <span>{{ options.valueLabel }}</span>
89
+ </div>
90
+ <div ref="rowList" class="gap-2 grid">
91
+ <div
92
+ v-for="(row, index) in rows"
93
+ :key="row.id"
94
+ data-xq-row
95
+ class="gap-2 flex items-center"
96
+ >
97
+ <span
98
+ v-if="options.reorderable && rows.length > 1"
99
+ aria-hidden="true"
100
+ data-xq-drag
101
+ class="cursor-grab touch-none text-xq-muted-foreground"
102
+ >
103
+ <GripVertical class="size-4" />
104
+ </span>
105
+ <XqInput
106
+ v-model="row.key"
107
+ :aria-label="`${options.keyLabel} ${index + 1}`"
108
+ :aria-describedby="describedBy"
109
+ :placeholder="options.keyPlaceholder ?? undefined"
110
+ :disabled="disabled || !options.editableKeys"
111
+ @update:model-value="commit"
112
+ />
113
+ <XqInput
114
+ v-model="row.value"
115
+ :aria-label="`${options.valueLabel} ${index + 1}`"
116
+ :placeholder="options.valuePlaceholder ?? undefined"
117
+ :disabled="disabled || !options.editableValues"
118
+ @update:model-value="commit"
119
+ />
120
+ <template v-if="options.reorderable">
121
+ <XqButton
122
+ type="button"
123
+ variant="ghost"
124
+ size="icon-sm"
125
+ :aria-label="t('Move row :number up', { number: index + 1 })"
126
+ :disabled="disabled || index === 0"
127
+ @click="move(row, -1)"
128
+ >
129
+ <ChevronUp />
130
+ </XqButton>
131
+ <XqButton
132
+ type="button"
133
+ variant="ghost"
134
+ size="icon-sm"
135
+ :aria-label="t('Move row :number down', { number: index + 1 })"
136
+ :disabled="disabled || index === rows.length - 1"
137
+ @click="move(row, 1)"
138
+ >
139
+ <ChevronDown />
140
+ </XqButton>
141
+ </template>
142
+ <XqButton
143
+ v-if="options.deletable"
144
+ type="button"
145
+ variant="ghost"
146
+ size="icon-sm"
147
+ :aria-label="t('Remove row :number', { number: index + 1 })"
148
+ :disabled="disabled"
149
+ @click="remove(row)"
150
+ >
151
+ <X />
152
+ </XqButton>
153
+ </div>
154
+ </div>
155
+ <XqButton
156
+ v-if="options.addable"
157
+ type="button"
158
+ variant="outline"
159
+ size="sm"
160
+ class="justify-self-start"
161
+ :disabled="disabled"
162
+ @click="add"
163
+ >
164
+ <Plus />{{ options.addLabel }}
165
+ </XqButton>
166
+ </div>
167
+ </XqFieldWrapper>
168
+ </template>
@@ -0,0 +1,245 @@
1
+ <script setup lang="ts">
2
+ import {
3
+ Bold,
4
+ Code,
5
+ Heading,
6
+ ImagePlus,
7
+ Italic,
8
+ Link as LinkIcon,
9
+ List,
10
+ ListOrdered,
11
+ LoaderCircle,
12
+ Quote,
13
+ Strikethrough,
14
+ Table,
15
+ } from 'lucide-vue-next';
16
+ import type { Component } from 'vue';
17
+ import { computed, nextTick, ref } from 'vue';
18
+ import { useField } from '../../composables/useField';
19
+ import { insertBlock, prefixLines, wrapSelection } from '../../core/markdown';
20
+ import type { TextEdit } from '../../core/markdown';
21
+ import { cn } from '../../lib/utils';
22
+ import type { SchemaNode } from '../../schema/types';
23
+ import { controlClass, spinnerClass, toolbarButtonClass } from '../../ui/classes';
24
+ import XqFieldWrapper from '../XqFieldWrapper.vue';
25
+
26
+ const props = defineProps<{ node: SchemaNode }>();
27
+ const { store, path, value, error, describedBy, required, disabled, readOnly, id, onBlur } =
28
+ useField<string | null>(props.node);
29
+ const { t } = store;
30
+ const options = computed(() => props.node.props ?? {});
31
+ const textarea = ref<HTMLTextAreaElement | null>(null);
32
+ const fileInput = ref<HTMLInputElement | null>(null);
33
+ const previewing = ref(false);
34
+ const preview = ref<string | null>(null);
35
+ const loadingPreview = ref(false);
36
+ const previewAction = computed(() =>
37
+ props.node.children?.find((child) => child.slot === 'markdownPreview'),
38
+ );
39
+
40
+ type Edit = (text: string, start: number, end: number) => TextEdit;
41
+
42
+ const table = '| Column | Column |\n| ------ | ------ |\n| Cell | Cell |';
43
+
44
+ const buttons: Record<string, { label: () => string; icon: Component; edit?: Edit }> = {
45
+ bold: {
46
+ label: () => t('Bold'),
47
+ icon: Bold,
48
+ edit: (text, start, end) => wrapSelection(text, start, end, '**', '**', t('Text')),
49
+ },
50
+ italic: {
51
+ label: () => t('Italic'),
52
+ icon: Italic,
53
+ edit: (text, start, end) => wrapSelection(text, start, end, '*', '*', t('Text')),
54
+ },
55
+ strike: {
56
+ label: () => t('Strikethrough'),
57
+ icon: Strikethrough,
58
+ edit: (text, start, end) => wrapSelection(text, start, end, '~~', '~~', t('Text')),
59
+ },
60
+ link: {
61
+ label: () => t('Link'),
62
+ icon: LinkIcon,
63
+ edit: (text, start, end) => wrapSelection(text, start, end, '[', '](https://)', t('Text')),
64
+ },
65
+ heading: {
66
+ label: () => t('Heading'),
67
+ icon: Heading,
68
+ edit: (text, start, end) => prefixLines(text, start, end, '## '),
69
+ },
70
+ blockquote: {
71
+ label: () => t('Quote'),
72
+ icon: Quote,
73
+ edit: (text, start, end) => prefixLines(text, start, end, '> '),
74
+ },
75
+ codeBlock: {
76
+ label: () => t('Code block'),
77
+ icon: Code,
78
+ edit: (text, start, end) => wrapSelection(text, start, end, '```\n', '\n```'),
79
+ },
80
+ bulletList: {
81
+ label: () => t('Bulleted list'),
82
+ icon: List,
83
+ edit: (text, start, end) => prefixLines(text, start, end, '- '),
84
+ },
85
+ orderedList: {
86
+ label: () => t('Numbered list'),
87
+ icon: ListOrdered,
88
+ edit: (text, start, end) => prefixLines(text, start, end, '#'),
89
+ },
90
+ table: {
91
+ label: () => t('Table'),
92
+ icon: Table,
93
+ edit: (text, _, end) => insertBlock(text, end, table),
94
+ },
95
+ attachFiles: { label: () => t('Attach a file'), icon: ImagePlus },
96
+ };
97
+
98
+ async function apply(edit: Edit): Promise<void> {
99
+ const element = textarea.value;
100
+
101
+ if (!element) {
102
+ return;
103
+ }
104
+
105
+ const result = edit(value.value ?? '', element.selectionStart, element.selectionEnd);
106
+
107
+ value.value = result.text;
108
+ await nextTick();
109
+ element.focus();
110
+ element.setSelectionRange(result.selectionStart, result.selectionEnd);
111
+ }
112
+
113
+ function press(name: string): void {
114
+ const edit = buttons[name]?.edit;
115
+
116
+ if (edit) {
117
+ void apply(edit);
118
+ } else if (name === 'attachFiles') {
119
+ fileInput.value?.click();
120
+ }
121
+ }
122
+
123
+ async function attach(event: Event): Promise<void> {
124
+ const input = event.target as HTMLInputElement;
125
+ const file = input.files?.[0];
126
+
127
+ input.value = '';
128
+
129
+ if (file) {
130
+ const { token } = await store.upload(path, file, () => {});
131
+
132
+ await apply((text, _, end) => insertBlock(text, end, `![${file.name}](${token})`));
133
+ }
134
+ }
135
+
136
+ async function showPreview(): Promise<void> {
137
+ previewing.value = true;
138
+
139
+ if (previewAction.value) {
140
+ loadingPreview.value = true;
141
+
142
+ const response = await store.runAction(previewAction.value);
143
+
144
+ preview.value = (response?.result as string | undefined) ?? null;
145
+ loadingPreview.value = false;
146
+ }
147
+ }
148
+ </script>
149
+
150
+ <template>
151
+ <XqFieldWrapper :node="node" :id="id" :error="error" :required="required">
152
+ <div
153
+ :class="
154
+ cn(
155
+ controlClass,
156
+ 'focus-within:border-xq-ring focus-within:ring-[3px] focus-within:ring-xq-ring/25',
157
+ )
158
+ "
159
+ >
160
+ <div class="gap-1 p-1 flex flex-wrap items-center border-b border-xq-border">
161
+ <template v-if="!disabled">
162
+ <div
163
+ v-for="(group, index) in options.toolbar ?? []"
164
+ :key="index"
165
+ class="gap-0.5 flex"
166
+ >
167
+ <template v-for="name in group" :key="name">
168
+ <button
169
+ v-if="buttons[name]"
170
+ type="button"
171
+ :class="toolbarButtonClass"
172
+ :aria-label="buttons[name].label()"
173
+ :title="buttons[name].label()"
174
+ :disabled="previewing"
175
+ :data-xq-tool="name"
176
+ @click="press(name)"
177
+ >
178
+ <component
179
+ :is="buttons[name].icon"
180
+ class="size-4"
181
+ aria-hidden="true"
182
+ />
183
+ </button>
184
+ </template>
185
+ </div>
186
+ </template>
187
+ <div v-if="previewAction && !disabled" class="gap-1 ms-auto flex">
188
+ <button
189
+ type="button"
190
+ class="px-2 py-1 text-xs cursor-pointer rounded-xq-sm hover:bg-xq-accent aria-pressed:bg-xq-accent"
191
+ :aria-pressed="!previewing"
192
+ @click="previewing = false"
193
+ >
194
+ {{ t('Write') }}
195
+ </button>
196
+ <button
197
+ type="button"
198
+ class="px-2 py-1 text-xs cursor-pointer rounded-xq-sm hover:bg-xq-accent aria-pressed:bg-xq-accent"
199
+ :aria-pressed="previewing"
200
+ data-xq-markdown-preview
201
+ @click="showPreview"
202
+ >
203
+ {{ t('Preview') }}
204
+ </button>
205
+ </div>
206
+ </div>
207
+ <textarea
208
+ v-show="!previewing"
209
+ :id="id"
210
+ ref="textarea"
211
+ :value="value ?? ''"
212
+ :readonly="readOnly"
213
+ :disabled="disabled && !readOnly"
214
+ :aria-invalid="error ? true : undefined"
215
+ :aria-describedby="describedBy"
216
+ :placeholder="store.text(node, 'placeholder') ?? undefined"
217
+ :style="{ minHeight: options.minHeight ?? '10rem', maxHeight: options.maxHeight }"
218
+ class="px-3 py-2 text-sm font-mono block w-full resize-y bg-transparent outline-none"
219
+ @input="value = ($event.target as HTMLTextAreaElement).value"
220
+ @blur="onBlur"
221
+ />
222
+ <div
223
+ v-if="previewing"
224
+ class="px-3 py-2 text-sm"
225
+ :style="{ minHeight: options.minHeight ?? '10rem' }"
226
+ >
227
+ <LoaderCircle v-if="loadingPreview" :class="spinnerClass" aria-hidden="true" />
228
+ <!-- MarkdownEditor::render() strips raw HTML and cleans the result on the server. -->
229
+ <div v-else-if="preview !== null" class="xq-rich-content" v-html="preview" />
230
+ <p v-else class="text-xq-muted-foreground">
231
+ {{ t('Could not load the preview.') }}
232
+ </p>
233
+ </div>
234
+ <input
235
+ ref="fileInput"
236
+ type="file"
237
+ class="sr-only"
238
+ tabindex="-1"
239
+ aria-hidden="true"
240
+ :accept="options.accept"
241
+ @change="attach"
242
+ />
243
+ </div>
244
+ </XqFieldWrapper>
245
+ </template>
@@ -8,9 +8,9 @@ import XqRadioGroupItem from '../../ui/XqRadioGroupItem.vue';
8
8
  import XqFieldWrapper from '../XqFieldWrapper.vue';
9
9
 
10
10
  const props = defineProps<{ node: SchemaNode }>();
11
- const { value, error, describedBy, required, disabled, id } = useField<string | number | null>(
12
- props.node,
13
- );
11
+ const { value, error, label, describedBy, required, disabled, id } = useField<
12
+ string | number | null
13
+ >(props.node);
14
14
  </script>
15
15
 
16
16
  <template>
@@ -22,7 +22,7 @@ const { value, error, describedBy, required, disabled, id } = useField<string |
22
22
  :aria-invalid="error ? true : undefined"
23
23
  :aria-required="required || undefined"
24
24
  :aria-describedby="describedBy"
25
- :aria-labelledby="node.label ? `${id}-label` : undefined"
25
+ :aria-labelledby="label ? `${id}-label` : undefined"
26
26
  :class="cn(node.props?.inline && 'gap-x-6 gap-y-2 flex flex-wrap')"
27
27
  >
28
28
  <div
@@ -30,7 +30,11 @@ const { value, error, describedBy, required, disabled, id } = useField<string |
30
30
  :key="String(option.value)"
31
31
  class="gap-2.5 flex items-start"
32
32
  >
33
- <XqRadioGroupItem :id="`${id}-${index}`" :value="option.value" />
33
+ <XqRadioGroupItem
34
+ :id="`${id}-${index}`"
35
+ :value="option.value"
36
+ :disabled="option.disabled"
37
+ />
34
38
  <div class="gap-1 grid">
35
39
  <XqLabel :for="`${id}-${index}`" class="leading-4 font-normal cursor-pointer">{{
36
40
  option.label