@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
@@ -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', submitLabel: 'Save', headingLevel: 2 },
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
- {{ errorCount }} {{ errorCount === 1 ? 'field needs' : 'fields need' }} attention.
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
- >Waiting for uploads…</span
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 v-if="visible" class="xq-cell" :style="spanStyle(node.span)" :data-xq-key="node.key">
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 } = useField<boolean>(props.node);
11
- const hasHint = computed(() => !!(props.node.props?.description || props.node.hint));
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
- <XqLabel :for="id" class="gap-0.5 leading-snug cursor-pointer">
30
- {{ node.label }}
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="loading"
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 ?? node.hint }}
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
- <p v-if="error" :id="`${id}-error`" class="text-xs font-medium text-xq-destructive">
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>