@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
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { CircleAlert } from 'lucide-vue-next';
|
|
3
3
|
import { computed, inject, onMounted, provide, ref } from 'vue';
|
|
4
|
-
import { HEADING_LEVEL, provideFormStore } from '../composables/context';
|
|
4
|
+
import { FUSED_GROUP, HEADING_LEVEL, provideFormStore } from '../composables/context';
|
|
5
5
|
import { REGISTRY } from '../composables/registry';
|
|
6
|
+
import { useUnsavedChangesAlert } from '../composables/useUnsavedChangesAlert';
|
|
6
7
|
import { useXqForm } from '../composables/useXqForm';
|
|
7
8
|
import type { FormStore } from '../core/store';
|
|
8
9
|
import { cn } from '../lib/utils';
|
|
@@ -22,7 +23,7 @@ const props = withDefaults(
|
|
|
22
23
|
headingLevel?: 2 | 3 | 4 | 5 | 6;
|
|
23
24
|
class?: string;
|
|
24
25
|
}>(),
|
|
25
|
-
{ method: 'post',
|
|
26
|
+
{ method: 'post', headingLevel: 2, preserveState: 'errors' },
|
|
26
27
|
);
|
|
27
28
|
const emit = defineEmits<{
|
|
28
29
|
submit: [store: FormStore];
|
|
@@ -41,15 +42,36 @@ const errorCount = computed(() => Object.keys(store.errors).length);
|
|
|
41
42
|
|
|
42
43
|
provideFormStore(store);
|
|
43
44
|
provide(HEADING_LEVEL, props.headingLevel);
|
|
45
|
+
// A modal opened from inside a fused group shows its fields' errors as usual.
|
|
46
|
+
provide(FUSED_GROUP, false);
|
|
44
47
|
provide(REGISTRY, inject(REGISTRY, null) ?? createRegistry());
|
|
45
48
|
|
|
49
|
+
useUnsavedChangesAlert(store);
|
|
50
|
+
|
|
46
51
|
const root = ref<HTMLFormElement | null>(null);
|
|
47
52
|
|
|
48
53
|
onMounted(() => {
|
|
49
54
|
store.root.value = root.value;
|
|
55
|
+
|
|
56
|
+
const first = [...store.nodes.values()].find(
|
|
57
|
+
(node) => node.autofocus && node.path && store.isVisible(node),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
if (first) {
|
|
61
|
+
void store.focusPath(first.path!);
|
|
62
|
+
}
|
|
50
63
|
});
|
|
51
64
|
|
|
65
|
+
const unfinishedStepper = computed(() => [...store.steppers].find((item) => item.hasNext()));
|
|
66
|
+
|
|
52
67
|
function submit(): void {
|
|
68
|
+
// Enter in a field of an unfinished wizard means "next", not "save".
|
|
69
|
+
if (unfinishedStepper.value) {
|
|
70
|
+
void unfinishedStepper.value.next();
|
|
71
|
+
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
53
75
|
if (!props.action) {
|
|
54
76
|
emit('submit', store);
|
|
55
77
|
|
|
@@ -78,7 +100,11 @@ function submit(): void {
|
|
|
78
100
|
</div>
|
|
79
101
|
<p role="status" class="sr-only">
|
|
80
102
|
<template v-if="errorCount">
|
|
81
|
-
{{
|
|
103
|
+
{{
|
|
104
|
+
errorCount === 1
|
|
105
|
+
? store.t('1 field needs attention.')
|
|
106
|
+
: store.t(':count fields need attention.', { count: errorCount })
|
|
107
|
+
}}
|
|
82
108
|
</template>
|
|
83
109
|
</p>
|
|
84
110
|
<div
|
|
@@ -105,7 +131,7 @@ function submit(): void {
|
|
|
105
131
|
:disabled="syncing"
|
|
106
132
|
@click="store.queue.retry()"
|
|
107
133
|
>
|
|
108
|
-
Try again
|
|
134
|
+
{{ store.t('Try again') }}
|
|
109
135
|
</XqButton>
|
|
110
136
|
</div>
|
|
111
137
|
<slot
|
|
@@ -114,13 +140,14 @@ function submit(): void {
|
|
|
114
140
|
:processing="processing"
|
|
115
141
|
:uploading="uploading"
|
|
116
142
|
:submit="submit"
|
|
143
|
+
:has-next-step="!!unfinishedStepper"
|
|
117
144
|
>
|
|
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
|
-
|
|
121
|
-
>
|
|
145
|
+
<div v-if="action && !unfinishedStepper" class="gap-3 flex items-center justify-end">
|
|
146
|
+
<span v-if="uploading" class="text-xs text-xq-muted-foreground">{{
|
|
147
|
+
store.t('Waiting for uploads…')
|
|
148
|
+
}}</span>
|
|
122
149
|
<XqButton type="submit" :disabled="processing">
|
|
123
|
-
{{ processing ? 'Saving…' : submitLabel }}
|
|
150
|
+
{{ processing ? store.t('Saving…') : (submitLabel ?? store.t('Save')) }}
|
|
124
151
|
</XqButton>
|
|
125
152
|
</div>
|
|
126
153
|
</slot>
|
|
@@ -15,7 +15,14 @@ const component = computed(() => registry?.get(props.node.type) ?? null);
|
|
|
15
15
|
</script>
|
|
16
16
|
|
|
17
17
|
<template>
|
|
18
|
-
<div
|
|
18
|
+
<div
|
|
19
|
+
v-if="visible"
|
|
20
|
+
v-bind="node.extraAttributes"
|
|
21
|
+
class="xq-cell"
|
|
22
|
+
:style="spanStyle(node.span)"
|
|
23
|
+
:data-xq-key="node.key"
|
|
24
|
+
:data-xq-grow="node.grow === false ? 'false' : undefined"
|
|
25
|
+
>
|
|
19
26
|
<component :is="component" v-if="component" :node="node" />
|
|
20
27
|
<XqUnknownNode v-else :node="node" />
|
|
21
28
|
</div>
|
|
@@ -10,6 +10,7 @@ defineProps<{ node: SchemaNode }>();
|
|
|
10
10
|
>
|
|
11
11
|
No component is registered for type
|
|
12
12
|
<code class="font-mono font-semibold">{{ node.type }}</code> ({{ node.key }}). Register one
|
|
13
|
-
with <code class="font-mono">createXelqunForms({ components })</code
|
|
13
|
+
with <code class="font-mono">createXelqunForms({ components })</code>, or install the plugin
|
|
14
|
+
that provides it.
|
|
14
15
|
</div>
|
|
15
16
|
</template>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { toComponent } from '../composables/registry';
|
|
2
2
|
import type { ComponentRegistry, ComponentResolver } from '../composables/registry';
|
|
3
|
+
import XqAction from './XqAction.vue';
|
|
3
4
|
import XqCheckboxField from './fields/XqCheckboxField.vue';
|
|
4
5
|
import XqRadioField from './fields/XqRadioField.vue';
|
|
5
6
|
import XqRepeaterField from './fields/XqRepeaterField.vue';
|
|
@@ -7,10 +8,21 @@ import XqSelectField from './fields/XqSelectField.vue';
|
|
|
7
8
|
import XqTextareaField from './fields/XqTextareaField.vue';
|
|
8
9
|
import XqTextInputField from './fields/XqTextInputField.vue';
|
|
9
10
|
import XqToggleField from './fields/XqToggleField.vue';
|
|
11
|
+
import XqActions from './layouts/XqActions.vue';
|
|
12
|
+
import XqBlock from './layouts/XqBlock.vue';
|
|
13
|
+
import XqCallout from './layouts/XqCallout.vue';
|
|
14
|
+
import XqEmptyState from './layouts/XqEmptyState.vue';
|
|
10
15
|
import XqFieldset from './layouts/XqFieldset.vue';
|
|
16
|
+
import XqFlex from './layouts/XqFlex.vue';
|
|
17
|
+
import XqFusedGroup from './layouts/XqFusedGroup.vue';
|
|
11
18
|
import XqGrid from './layouts/XqGrid.vue';
|
|
19
|
+
import XqIconNode from './layouts/XqIconNode.vue';
|
|
20
|
+
import XqImageNode from './layouts/XqImageNode.vue';
|
|
12
21
|
import XqSection from './layouts/XqSection.vue';
|
|
13
22
|
import XqTabsLayout from './layouts/XqTabsLayout.vue';
|
|
23
|
+
import XqTextNode from './layouts/XqTextNode.vue';
|
|
24
|
+
import XqUnorderedList from './layouts/XqUnorderedList.vue';
|
|
25
|
+
import XqWizard from './layouts/XqWizard.vue';
|
|
14
26
|
|
|
15
27
|
/**
|
|
16
28
|
* Built-in renderers keyed by schema node type. Heavier fields are code-split.
|
|
@@ -24,12 +36,36 @@ export function defaultComponents(): Record<string, ComponentResolver> {
|
|
|
24
36
|
toggle: XqToggleField,
|
|
25
37
|
radio: XqRadioField,
|
|
26
38
|
'date-picker': () => import('./fields/XqDatePickerField.vue'),
|
|
39
|
+
'date-time-picker': () => import('./fields/XqDatePickerField.vue'),
|
|
40
|
+
'time-picker': () => import('./fields/XqDatePickerField.vue'),
|
|
27
41
|
'file-upload': () => import('./fields/XqFileUploadField.vue'),
|
|
42
|
+
'checkbox-list': () => import('./fields/XqCheckboxListField.vue'),
|
|
43
|
+
'toggle-buttons': () => import('./fields/XqToggleButtonsField.vue'),
|
|
44
|
+
'tags-input': () => import('./fields/XqTagsInputField.vue'),
|
|
45
|
+
slider: () => import('./fields/XqSliderField.vue'),
|
|
46
|
+
'key-value': () => import('./fields/XqKeyValueField.vue'),
|
|
47
|
+
'color-picker': () => import('./fields/XqColorPickerField.vue'),
|
|
48
|
+
'rich-editor': () => import('./fields/XqRichEditorField.vue'),
|
|
49
|
+
'markdown-editor': () => import('./fields/XqMarkdownEditorField.vue'),
|
|
50
|
+
'code-editor': () => import('./fields/XqCodeEditorField.vue'),
|
|
28
51
|
repeater: XqRepeaterField,
|
|
52
|
+
builder: XqRepeaterField,
|
|
29
53
|
section: XqSection,
|
|
30
54
|
grid: XqGrid,
|
|
31
55
|
fieldset: XqFieldset,
|
|
32
56
|
tabs: XqTabsLayout,
|
|
57
|
+
action: XqAction,
|
|
58
|
+
actions: XqActions,
|
|
59
|
+
wizard: XqWizard,
|
|
60
|
+
block: XqBlock,
|
|
61
|
+
flex: XqFlex,
|
|
62
|
+
'fused-group': XqFusedGroup,
|
|
63
|
+
callout: XqCallout,
|
|
64
|
+
'empty-state': XqEmptyState,
|
|
65
|
+
text: XqTextNode,
|
|
66
|
+
icon: XqIconNode,
|
|
67
|
+
image: XqImageNode,
|
|
68
|
+
'unordered-list': XqUnorderedList,
|
|
33
69
|
};
|
|
34
70
|
}
|
|
35
71
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { Plus } from 'lucide-vue-next';
|
|
3
|
+
import { PopoverContent, PopoverPortal, PopoverRoot, PopoverTrigger } from 'reka-ui';
|
|
4
|
+
import { computed, ref } from 'vue';
|
|
5
|
+
import { cn } from '../../lib/utils';
|
|
6
|
+
import type { BlockOption } from '../../schema/types';
|
|
7
|
+
import { groupLabelClass, popoverClass } from '../../ui/classes';
|
|
8
|
+
import XqButton from '../../ui/XqButton.vue';
|
|
9
|
+
import XqIcon from '../../ui/XqIcon.vue';
|
|
10
|
+
|
|
11
|
+
const props = defineProps<{
|
|
12
|
+
blocks: BlockOption[];
|
|
13
|
+
label: string;
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
used: Record<string, number>;
|
|
16
|
+
}>();
|
|
17
|
+
const emit = defineEmits<{ pick: [block: string] }>();
|
|
18
|
+
const open = ref(false);
|
|
19
|
+
|
|
20
|
+
const groups = computed(() => {
|
|
21
|
+
const found = new Map<string | null, BlockOption[]>();
|
|
22
|
+
|
|
23
|
+
for (const block of props.blocks) {
|
|
24
|
+
found.set(block.group, [...(found.get(block.group) ?? []), block]);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return [...found.entries()].sort(([a], [b]) => Number(a !== null) - Number(b !== null));
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function isFull(block: BlockOption): boolean {
|
|
31
|
+
return block.maxItems !== null && (props.used[block.name] ?? 0) >= block.maxItems;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function pick(block: BlockOption): void {
|
|
35
|
+
open.value = false;
|
|
36
|
+
emit('pick', block.name);
|
|
37
|
+
}
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<template>
|
|
41
|
+
<PopoverRoot v-model:open="open">
|
|
42
|
+
<PopoverTrigger as-child>
|
|
43
|
+
<XqButton type="button" variant="outline" size="sm" :disabled="disabled" data-xq-add>
|
|
44
|
+
<Plus />
|
|
45
|
+
{{ label }}
|
|
46
|
+
</XqButton>
|
|
47
|
+
</PopoverTrigger>
|
|
48
|
+
<PopoverPortal>
|
|
49
|
+
<PopoverContent align="start" :side-offset="4" :class="cn(popoverClass, 'w-56 p-1')">
|
|
50
|
+
<div v-for="[group, items] in groups" :key="group ?? ''">
|
|
51
|
+
<p v-if="group" :class="groupLabelClass">{{ group }}</p>
|
|
52
|
+
<button
|
|
53
|
+
v-for="block in items"
|
|
54
|
+
:key="block.name"
|
|
55
|
+
type="button"
|
|
56
|
+
class="gap-2 px-2 py-1.5 text-sm flex w-full cursor-pointer items-center rounded-xq-sm hover:bg-xq-accent disabled:cursor-not-allowed disabled:opacity-50"
|
|
57
|
+
:disabled="isFull(block)"
|
|
58
|
+
:data-xq-block="block.name"
|
|
59
|
+
@click="pick(block)"
|
|
60
|
+
>
|
|
61
|
+
<XqIcon v-if="block.icon" :name="block.icon" />
|
|
62
|
+
{{ block.label }}
|
|
63
|
+
</button>
|
|
64
|
+
</div>
|
|
65
|
+
</PopoverContent>
|
|
66
|
+
</PopoverPortal>
|
|
67
|
+
</PopoverRoot>
|
|
68
|
+
</template>
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { LoaderCircle } from 'lucide-vue-next';
|
|
3
|
-
import { computed } from 'vue';
|
|
3
|
+
import { computed, inject } from 'vue';
|
|
4
|
+
import XqFieldSlot from '../XqFieldSlot.vue';
|
|
5
|
+
import { FUSED_GROUP } from '../../composables/context';
|
|
4
6
|
import { useField } from '../../composables/useField';
|
|
7
|
+
import { cn } from '../../lib/utils';
|
|
5
8
|
import type { SchemaNode } from '../../schema/types';
|
|
6
9
|
import XqCheckbox from '../../ui/XqCheckbox.vue';
|
|
7
10
|
import XqLabel from '../../ui/XqLabel.vue';
|
|
8
11
|
|
|
9
12
|
const props = defineProps<{ node: SchemaNode }>();
|
|
10
|
-
const { value, error, loading, required, disabled, id } =
|
|
11
|
-
|
|
13
|
+
const { store, value, error, label, hint, loading, showLoading, required, disabled, id } =
|
|
14
|
+
useField<boolean>(props.node);
|
|
15
|
+
const fused = inject(FUSED_GROUP, false);
|
|
16
|
+
const hasHint = computed(() => !!(props.node.props?.description || hint.value));
|
|
12
17
|
const describedBy = computed(() =>
|
|
13
18
|
error.value ? `${id}-error` : hasHint.value ? `${id}-hint` : undefined,
|
|
14
19
|
);
|
|
@@ -16,7 +21,10 @@ const describedBy = computed(() =>
|
|
|
16
21
|
|
|
17
22
|
<template>
|
|
18
23
|
<div class="gap-2 grid" :data-xq-path="node.path" :aria-busy="loading || undefined">
|
|
24
|
+
<XqFieldSlot :node="node" name="aboveContent" />
|
|
19
25
|
<div class="gap-3 flex items-start">
|
|
26
|
+
<XqFieldSlot :node="node" name="beforeContent" />
|
|
27
|
+
<XqFieldSlot :node="node" name="prefixAction" />
|
|
20
28
|
<XqCheckbox
|
|
21
29
|
:id="id"
|
|
22
30
|
v-model="value"
|
|
@@ -26,23 +34,41 @@ const describedBy = computed(() =>
|
|
|
26
34
|
class="mt-0.5"
|
|
27
35
|
/>
|
|
28
36
|
<div class="gap-1.5 grid">
|
|
29
|
-
<
|
|
30
|
-
|
|
37
|
+
<XqFieldSlot :node="node" name="aboveLabel" />
|
|
38
|
+
<XqFieldSlot :node="node" name="beforeLabel" />
|
|
39
|
+
<XqLabel
|
|
40
|
+
:for="id"
|
|
41
|
+
:class="
|
|
42
|
+
cn('gap-0.5 leading-snug cursor-pointer', node.hiddenLabel && 'sr-only')
|
|
43
|
+
"
|
|
44
|
+
>
|
|
45
|
+
{{ label }}
|
|
31
46
|
<span v-if="required" class="text-xq-destructive" aria-hidden="true">*</span>
|
|
32
|
-
<span v-if="required" class="sr-only">(required)</span>
|
|
47
|
+
<span v-if="required" class="sr-only">{{ store.t('(required)') }}</span>
|
|
33
48
|
<LoaderCircle
|
|
34
|
-
v-if="
|
|
49
|
+
v-if="showLoading"
|
|
35
50
|
class="ml-1 size-3 motion-safe:animate-spin text-xq-muted-foreground"
|
|
36
51
|
aria-hidden="true"
|
|
37
52
|
/>
|
|
38
53
|
</XqLabel>
|
|
54
|
+
<XqFieldSlot :node="node" name="afterLabel" />
|
|
55
|
+
<XqFieldSlot :node="node" name="belowLabel" />
|
|
39
56
|
<p v-if="hasHint" :id="`${id}-hint`" class="text-xs text-xq-muted-foreground">
|
|
40
|
-
{{ node.props?.description ??
|
|
57
|
+
{{ node.props?.description ?? hint }}
|
|
41
58
|
</p>
|
|
42
59
|
</div>
|
|
60
|
+
<XqFieldSlot class="ms-auto" :node="node" name="suffixAction" />
|
|
61
|
+
<XqFieldSlot :node="node" name="afterContent" />
|
|
43
62
|
</div>
|
|
44
|
-
<
|
|
63
|
+
<XqFieldSlot :node="node" name="belowContent" />
|
|
64
|
+
<XqFieldSlot :node="node" name="aboveErrorMessage" />
|
|
65
|
+
<p
|
|
66
|
+
v-if="error && !fused"
|
|
67
|
+
:id="`${id}-error`"
|
|
68
|
+
class="text-xs font-medium text-xq-destructive"
|
|
69
|
+
>
|
|
45
70
|
{{ error }}
|
|
46
71
|
</p>
|
|
72
|
+
<XqFieldSlot :node="node" name="belowErrorMessage" />
|
|
47
73
|
</div>
|
|
48
74
|
</template>
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref } from 'vue';
|
|
3
|
+
import { gridStyle } from '../../composables/layout';
|
|
4
|
+
import { useField } from '../../composables/useField';
|
|
5
|
+
import type { SchemaNode, SelectOption } from '../../schema/types';
|
|
6
|
+
import XqButton from '../../ui/XqButton.vue';
|
|
7
|
+
import XqCheckbox from '../../ui/XqCheckbox.vue';
|
|
8
|
+
import XqInput from '../../ui/XqInput.vue';
|
|
9
|
+
import XqLabel from '../../ui/XqLabel.vue';
|
|
10
|
+
import XqFieldWrapper from '../XqFieldWrapper.vue';
|
|
11
|
+
import { useTranslator } from '../../composables/context';
|
|
12
|
+
|
|
13
|
+
const t = useTranslator();
|
|
14
|
+
type Value = string | number;
|
|
15
|
+
|
|
16
|
+
const props = defineProps<{ node: SchemaNode }>();
|
|
17
|
+
const { value, error, label, describedBy, required, disabled, id } = useField<Value[]>(props.node);
|
|
18
|
+
const options = computed<SelectOption[]>(() => props.node.props?.options ?? []);
|
|
19
|
+
const search = ref('');
|
|
20
|
+
const shown = computed(() => {
|
|
21
|
+
const needle = search.value.trim().toLowerCase();
|
|
22
|
+
|
|
23
|
+
return needle
|
|
24
|
+
? options.value.filter((option) => option.label.toLowerCase().includes(needle))
|
|
25
|
+
: options.value;
|
|
26
|
+
});
|
|
27
|
+
const selected = computed(() => new Set((value.value ?? []).map(String)));
|
|
28
|
+
|
|
29
|
+
function isChecked(option: SelectOption): boolean {
|
|
30
|
+
return selected.value.has(String(option.value));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function toggle(option: SelectOption, checked: boolean): void {
|
|
34
|
+
const others = (value.value ?? []).filter((item) => String(item) !== String(option.value));
|
|
35
|
+
|
|
36
|
+
value.value = checked ? [...others, option.value] : others;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function setAll(checked: boolean): void {
|
|
40
|
+
const enabled = options.value.filter((option) => !option.disabled);
|
|
41
|
+
const others = (value.value ?? []).filter(
|
|
42
|
+
(item) => !enabled.some((option) => String(option.value) === String(item)),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
value.value = checked ? [...others, ...enabled.map((option) => option.value)] : others;
|
|
46
|
+
}
|
|
47
|
+
</script>
|
|
48
|
+
|
|
49
|
+
<template>
|
|
50
|
+
<XqFieldWrapper :node="node" :id="id" :label-for="null" :error="error" :required="required">
|
|
51
|
+
<div class="gap-3 grid">
|
|
52
|
+
<div v-if="node.props?.searchable || node.props?.bulkToggleable" class="gap-2 flex">
|
|
53
|
+
<XqInput
|
|
54
|
+
v-if="node.props?.searchable"
|
|
55
|
+
v-model="search"
|
|
56
|
+
type="search"
|
|
57
|
+
placeholder="Search…"
|
|
58
|
+
:aria-label="t('Search :label', { label: label ?? '' })"
|
|
59
|
+
/>
|
|
60
|
+
<template v-if="node.props?.bulkToggleable">
|
|
61
|
+
<XqButton
|
|
62
|
+
type="button"
|
|
63
|
+
variant="outline"
|
|
64
|
+
size="sm"
|
|
65
|
+
:disabled="disabled"
|
|
66
|
+
@click="setAll(true)"
|
|
67
|
+
>
|
|
68
|
+
{{ t('Select all') }}
|
|
69
|
+
</XqButton>
|
|
70
|
+
<XqButton
|
|
71
|
+
type="button"
|
|
72
|
+
variant="ghost"
|
|
73
|
+
size="sm"
|
|
74
|
+
:disabled="disabled"
|
|
75
|
+
@click="setAll(false)"
|
|
76
|
+
>
|
|
77
|
+
{{ t('Deselect all') }}
|
|
78
|
+
</XqButton>
|
|
79
|
+
</template>
|
|
80
|
+
</div>
|
|
81
|
+
<div
|
|
82
|
+
role="group"
|
|
83
|
+
class="xq-grid gap-2"
|
|
84
|
+
:style="gridStyle(node.props?.columns)"
|
|
85
|
+
:aria-labelledby="label ? `${id}-label` : undefined"
|
|
86
|
+
:aria-describedby="describedBy"
|
|
87
|
+
>
|
|
88
|
+
<div
|
|
89
|
+
v-for="(option, index) in shown"
|
|
90
|
+
:key="String(option.value)"
|
|
91
|
+
class="gap-2.5 flex items-start"
|
|
92
|
+
>
|
|
93
|
+
<XqCheckbox
|
|
94
|
+
:id="`${id}-${index}`"
|
|
95
|
+
:model-value="isChecked(option)"
|
|
96
|
+
:disabled="disabled || option.disabled"
|
|
97
|
+
class="mt-0.5"
|
|
98
|
+
@update:model-value="toggle(option, !!$event)"
|
|
99
|
+
/>
|
|
100
|
+
<div class="gap-1 grid">
|
|
101
|
+
<XqLabel
|
|
102
|
+
:for="`${id}-${index}`"
|
|
103
|
+
class="leading-snug font-normal cursor-pointer"
|
|
104
|
+
>{{ option.label }}</XqLabel
|
|
105
|
+
>
|
|
106
|
+
<p v-if="option.description" class="text-xs text-xq-muted-foreground">
|
|
107
|
+
{{ option.description }}
|
|
108
|
+
</p>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
<p v-if="shown.length === 0" class="text-sm text-xq-muted-foreground">No results</p>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
</XqFieldWrapper>
|
|
115
|
+
</template>
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { EditorState } from '@codemirror/state';
|
|
3
|
+
import type { Extension } from '@codemirror/state';
|
|
4
|
+
import { EditorView } from '@codemirror/view';
|
|
5
|
+
import { basicSetup } from 'codemirror';
|
|
6
|
+
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
|
7
|
+
import { useField } from '../../composables/useField';
|
|
8
|
+
import { cn } from '../../lib/utils';
|
|
9
|
+
import type { SchemaNode } from '../../schema/types';
|
|
10
|
+
import { controlClass } from '../../ui/classes';
|
|
11
|
+
import XqFieldWrapper from '../XqFieldWrapper.vue';
|
|
12
|
+
|
|
13
|
+
const props = defineProps<{ node: SchemaNode }>();
|
|
14
|
+
const { value, label, error, describedBy, required, disabled, id, onBlur } = useField<
|
|
15
|
+
string | null
|
|
16
|
+
>(props.node);
|
|
17
|
+
const options = computed(() => props.node.props ?? {});
|
|
18
|
+
const host = ref<HTMLElement | null>(null);
|
|
19
|
+
let view: EditorView | null = null;
|
|
20
|
+
|
|
21
|
+
/** Each language is its own chunk, so a form only downloads the one it shows. */
|
|
22
|
+
const languages: Record<string, () => Promise<Extension>> = {
|
|
23
|
+
cpp: async () => (await import('@codemirror/lang-cpp')).cpp(),
|
|
24
|
+
css: async () => (await import('@codemirror/lang-css')).css(),
|
|
25
|
+
go: async () => (await import('@codemirror/lang-go')).go(),
|
|
26
|
+
html: async () => (await import('@codemirror/lang-html')).html(),
|
|
27
|
+
java: async () => (await import('@codemirror/lang-java')).java(),
|
|
28
|
+
javascript: async () => (await import('@codemirror/lang-javascript')).javascript(),
|
|
29
|
+
json: async () => (await import('@codemirror/lang-json')).json(),
|
|
30
|
+
markdown: async () => (await import('@codemirror/lang-markdown')).markdown(),
|
|
31
|
+
php: async () => (await import('@codemirror/lang-php')).php(),
|
|
32
|
+
python: async () => (await import('@codemirror/lang-python')).python(),
|
|
33
|
+
sql: async () => (await import('@codemirror/lang-sql')).sql(),
|
|
34
|
+
xml: async () => (await import('@codemirror/lang-xml')).xml(),
|
|
35
|
+
yaml: async () => (await import('@codemirror/lang-yaml')).yaml(),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
onMounted(async () => {
|
|
39
|
+
const language = await languages[options.value.language]?.();
|
|
40
|
+
|
|
41
|
+
if (!host.value) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
view = new EditorView({
|
|
46
|
+
parent: host.value,
|
|
47
|
+
state: EditorState.create({
|
|
48
|
+
doc: value.value ?? '',
|
|
49
|
+
extensions: [
|
|
50
|
+
basicSetup,
|
|
51
|
+
language ?? [],
|
|
52
|
+
options.value.wrap ? EditorView.lineWrapping : [],
|
|
53
|
+
EditorState.readOnly.of(disabled.value),
|
|
54
|
+
EditorView.editable.of(!disabled.value),
|
|
55
|
+
EditorView.contentAttributes.of({
|
|
56
|
+
'aria-label': label.value ?? '',
|
|
57
|
+
'aria-describedby': describedBy.value ?? '',
|
|
58
|
+
id,
|
|
59
|
+
}),
|
|
60
|
+
EditorView.domEventHandlers({ blur: () => onBlur() }),
|
|
61
|
+
EditorView.updateListener.of((update) => {
|
|
62
|
+
if (update.docChanged) {
|
|
63
|
+
const text = update.state.doc.toString();
|
|
64
|
+
|
|
65
|
+
value.value = text === '' ? null : text;
|
|
66
|
+
}
|
|
67
|
+
}),
|
|
68
|
+
],
|
|
69
|
+
}),
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
watch(value, (next) => {
|
|
74
|
+
const text = next ?? '';
|
|
75
|
+
|
|
76
|
+
if (view && view.state.doc.toString() !== text) {
|
|
77
|
+
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text } });
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
onBeforeUnmount(() => view?.destroy());
|
|
82
|
+
</script>
|
|
83
|
+
|
|
84
|
+
<template>
|
|
85
|
+
<XqFieldWrapper :node="node" :id="id" :label-for="null" :error="error" :required="required">
|
|
86
|
+
<div
|
|
87
|
+
ref="host"
|
|
88
|
+
:class="cn(controlClass, 'overflow-hidden', disabled && 'opacity-60')"
|
|
89
|
+
:style="{ minHeight: options.minHeight ?? undefined }"
|
|
90
|
+
/>
|
|
91
|
+
</XqFieldWrapper>
|
|
92
|
+
</template>
|