@xelqun/forms 0.0.0-stage → 0.1.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 (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +116 -2
  3. package/package.json +56 -5
  4. package/resources/css/xelqun.css +162 -0
  5. package/resources/js/components/XqFieldWrapper.vue +59 -0
  6. package/resources/js/components/XqForm.vue +128 -0
  7. package/resources/js/components/XqNode.vue +22 -0
  8. package/resources/js/components/XqUnknownNode.vue +15 -0
  9. package/resources/js/components/defaults.ts +44 -0
  10. package/resources/js/components/fields/XqCheckboxField.vue +48 -0
  11. package/resources/js/components/fields/XqDatePickerField.vue +96 -0
  12. package/resources/js/components/fields/XqFileUploadField.vue +247 -0
  13. package/resources/js/components/fields/XqRadioField.vue +45 -0
  14. package/resources/js/components/fields/XqRepeaterField.vue +166 -0
  15. package/resources/js/components/fields/XqSelectField.vue +151 -0
  16. package/resources/js/components/fields/XqTextInputField.vue +100 -0
  17. package/resources/js/components/fields/XqTextareaField.vue +28 -0
  18. package/resources/js/components/fields/XqToggleField.vue +47 -0
  19. package/resources/js/components/layouts/XqFieldset.vue +16 -0
  20. package/resources/js/components/layouts/XqGrid.vue +13 -0
  21. package/resources/js/components/layouts/XqSection.vue +62 -0
  22. package/resources/js/components/layouts/XqTabsLayout.vue +64 -0
  23. package/resources/js/composables/context.ts +22 -0
  24. package/resources/js/composables/layout.ts +55 -0
  25. package/resources/js/composables/registry.ts +25 -0
  26. package/resources/js/composables/useField.ts +41 -0
  27. package/resources/js/composables/useXqForm.ts +16 -0
  28. package/resources/js/core/conditions.ts +52 -0
  29. package/resources/js/core/expressions.ts +131 -0
  30. package/resources/js/core/http.ts +101 -0
  31. package/resources/js/core/log.ts +93 -0
  32. package/resources/js/core/patch.ts +65 -0
  33. package/resources/js/core/paths.ts +122 -0
  34. package/resources/js/core/queue.ts +190 -0
  35. package/resources/js/core/rows.ts +26 -0
  36. package/resources/js/core/store.ts +560 -0
  37. package/resources/js/core/values.ts +161 -0
  38. package/resources/js/index.ts +24 -0
  39. package/resources/js/lib/utils.ts +18 -0
  40. package/resources/js/plugin.ts +17 -0
  41. package/resources/js/schema/types.ts +99 -0
  42. package/resources/js/ui/XqBadge.vue +33 -0
  43. package/resources/js/ui/XqButton.vue +59 -0
  44. package/resources/js/ui/XqCalendar.vue +90 -0
  45. package/resources/js/ui/XqCard.vue +20 -0
  46. package/resources/js/ui/XqCheckbox.vue +28 -0
  47. package/resources/js/ui/XqCombobox.vue +184 -0
  48. package/resources/js/ui/XqInput.vue +16 -0
  49. package/resources/js/ui/XqLabel.vue +17 -0
  50. package/resources/js/ui/XqRadioGroup.vue +24 -0
  51. package/resources/js/ui/XqRadioGroupItem.vue +31 -0
  52. package/resources/js/ui/XqSelect.vue +79 -0
  53. package/resources/js/ui/XqSwitch.vue +27 -0
  54. package/resources/js/ui/XqTabs.vue +19 -0
  55. package/resources/js/ui/XqTabsContent.vue +17 -0
  56. package/resources/js/ui/XqTabsList.vue +21 -0
  57. package/resources/js/ui/XqTabsTrigger.vue +27 -0
  58. package/resources/js/ui/XqTextarea.vue +16 -0
  59. package/resources/js/ui/classes.ts +12 -0
  60. package/resources/js/ui/index.ts +17 -0
@@ -0,0 +1,151 @@
1
+ <script setup lang="ts">
2
+ import { X } from 'lucide-vue-next';
3
+ import { computed, onBeforeUnmount, ref, watch } from 'vue';
4
+ import { useField } from '../../composables/useField';
5
+ import type { SchemaNode, SelectOption } from '../../schema/types';
6
+ import XqCombobox from '../../ui/XqCombobox.vue';
7
+ import XqSelect from '../../ui/XqSelect.vue';
8
+ import XqFieldWrapper from '../XqFieldWrapper.vue';
9
+
10
+ const props = defineProps<{ node: SchemaNode }>();
11
+ const { store, path, value, error, describedBy, required, disabled, id, onBlur } = useField(
12
+ props.node,
13
+ );
14
+ const options = computed<SelectOption[]>(() => props.node.props?.options ?? []);
15
+ const useCombobox = computed(() => !!(props.node.props?.searchable || props.node.props?.multiple));
16
+ const isRemote = computed(() => !!props.node.props?.async);
17
+
18
+ const remoteOptions = ref<SelectOption[]>([]);
19
+ const loading = ref(false);
20
+ const searchFailed = ref(false);
21
+ const debounce = computed<number>(() => props.node.props?.searchDebounce ?? 250);
22
+ const cache = new Map<string, SelectOption[]>();
23
+ let lastTerm = '';
24
+ let timer: ReturnType<typeof setTimeout> | null = null;
25
+ let controller: AbortController | null = null;
26
+
27
+ watch(options, (next) => (remoteOptions.value = mergeOptions(next, remoteOptions.value)), {
28
+ immediate: true,
29
+ });
30
+
31
+ function mergeOptions(first: SelectOption[], second: SelectOption[]): SelectOption[] {
32
+ const seen = new Set(first.map((option) => String(option.value)));
33
+
34
+ return [...first, ...second.filter((option) => !seen.has(String(option.value)))];
35
+ }
36
+
37
+ function cancelPendingSearch(): void {
38
+ if (timer) {
39
+ clearTimeout(timer);
40
+ timer = null;
41
+ }
42
+
43
+ controller?.abort();
44
+ loading.value = false;
45
+ }
46
+
47
+ /**
48
+ * Called only when the user types (not when the input text is reset after a
49
+ * selection, a clear or a blur). Waits for a pause in typing, skips empty and
50
+ * repeated terms, and reuses results it has already fetched.
51
+ */
52
+ function onSearch(input: string): void {
53
+ if (!isRemote.value) {
54
+ return;
55
+ }
56
+
57
+ const term = input.trim();
58
+
59
+ cancelPendingSearch();
60
+ searchFailed.value = false;
61
+
62
+ if (term === '') {
63
+ lastTerm = '';
64
+ remoteOptions.value = options.value;
65
+
66
+ return;
67
+ }
68
+
69
+ if (term === lastTerm) {
70
+ return;
71
+ }
72
+
73
+ const cached = cache.get(term.toLowerCase());
74
+
75
+ if (cached) {
76
+ lastTerm = term;
77
+ remoteOptions.value = mergeOptions(cached, options.value);
78
+
79
+ return;
80
+ }
81
+
82
+ timer = setTimeout(async () => {
83
+ timer = null;
84
+ controller = new AbortController();
85
+ loading.value = true;
86
+
87
+ try {
88
+ const results = await store.search(path, term, controller.signal);
89
+
90
+ cache.set(term.toLowerCase(), results);
91
+ lastTerm = term;
92
+ remoteOptions.value = mergeOptions(results, options.value);
93
+ } catch (searchError) {
94
+ if (!controller.signal.aborted) {
95
+ searchFailed.value = true;
96
+ console.error('[xelqun] Search failed.', searchError);
97
+ }
98
+ } finally {
99
+ loading.value = false;
100
+ }
101
+ }, debounce.value);
102
+ }
103
+
104
+ onBeforeUnmount(cancelPendingSearch);
105
+
106
+ const hasValue = computed(
107
+ () => value.value !== null && value.value !== undefined && value.value !== '',
108
+ );
109
+ </script>
110
+
111
+ <template>
112
+ <XqFieldWrapper :node="node" :id="id" :error="error" :required="required">
113
+ <XqCombobox
114
+ v-if="useCombobox"
115
+ :id="id"
116
+ v-model="value"
117
+ :options="isRemote ? remoteOptions : options"
118
+ :multiple="!!node.props?.multiple"
119
+ :remote="isRemote"
120
+ :loading="loading"
121
+ :placeholder="node.placeholder"
122
+ :disabled="disabled"
123
+ :invalid="!!error"
124
+ :described-by="describedBy"
125
+ :search-failed="searchFailed"
126
+ @search="onSearch"
127
+ @blur="onBlur"
128
+ />
129
+ <div v-else class="relative">
130
+ <XqSelect
131
+ :id="id"
132
+ v-model="value"
133
+ :options="options"
134
+ :placeholder="node.placeholder"
135
+ :disabled="disabled"
136
+ :invalid="!!error"
137
+ :described-by="describedBy"
138
+ :class="!required && hasValue ? '[&>span:first-child]:pr-6' : undefined"
139
+ />
140
+ <button
141
+ v-if="!required && hasValue && !disabled"
142
+ type="button"
143
+ class="right-9 size-5 rounded-sm absolute top-1/2 flex -translate-y-1/2 cursor-pointer items-center justify-center text-xq-muted-foreground hover:text-xq-foreground"
144
+ aria-label="Clear selection"
145
+ @click="value = null"
146
+ >
147
+ <X class="size-3.5" />
148
+ </button>
149
+ </div>
150
+ </XqFieldWrapper>
151
+ </template>
@@ -0,0 +1,100 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue';
3
+ import { useField } from '../../composables/useField';
4
+ import { isNumeric, toNumber } from '../../core/values';
5
+ import { cn } from '../../lib/utils';
6
+ import type { SchemaNode } from '../../schema/types';
7
+ import { controlClass } from '../../ui/classes';
8
+ import XqFieldWrapper from '../XqFieldWrapper.vue';
9
+
10
+ const props = defineProps<{ node: SchemaNode }>();
11
+ const { path, value, error, describedBy, required, disabled, id, onBlur } = useField(props.node);
12
+ const options = computed(() => props.node.props ?? {});
13
+
14
+ function format(next: unknown): string {
15
+ return next === null || next === undefined ? '' : String(next);
16
+ }
17
+
18
+ /**
19
+ * Keep the typed text locally so "1." or "-" can be typed into numeric fields
20
+ * while the state already holds a number.
21
+ */
22
+ const text = ref(format(value.value));
23
+
24
+ watch(value, (next) => {
25
+ const matchesText = options.value.numeric
26
+ ? text.value.trim() !== '' && isNumeric(text.value) && toNumber(text.value) === next
27
+ : text.value === next;
28
+
29
+ if (!matchesText) {
30
+ text.value = format(next);
31
+ }
32
+ });
33
+
34
+ function onInput(event: Event): void {
35
+ text.value = (event.target as HTMLInputElement).value;
36
+
37
+ if (!options.value.numeric) {
38
+ value.value = text.value;
39
+
40
+ return;
41
+ }
42
+
43
+ const trimmed = text.value.trim();
44
+ value.value = trimmed === '' ? null : isNumeric(trimmed) ? toNumber(trimmed) : trimmed;
45
+ }
46
+
47
+ const inputType = computed(() =>
48
+ options.value.numeric ? 'text' : (options.value.inputType ?? 'text'),
49
+ );
50
+ </script>
51
+
52
+ <template>
53
+ <XqFieldWrapper :node="node" :id="id" :error="error" :required="required">
54
+ <div
55
+ :class="
56
+ cn(
57
+ controlClass,
58
+ 'h-9 flex items-center overflow-hidden focus-within:border-xq-ring focus-within:ring-[3px] focus-within:ring-xq-ring/25',
59
+ error && 'border-xq-destructive',
60
+ disabled && 'bg-xq-muted/60',
61
+ )
62
+ "
63
+ >
64
+ <span
65
+ v-if="options.prefix"
66
+ class="px-3 text-sm flex h-full items-center border-r border-xq-input bg-xq-muted text-xq-muted-foreground"
67
+ >
68
+ {{ options.prefix }}
69
+ </span>
70
+ <input
71
+ :id="id"
72
+ :name="path"
73
+ :value="text"
74
+ :type="inputType"
75
+ :inputmode="options.numeric ? 'decimal' : undefined"
76
+ :placeholder="node.placeholder ?? undefined"
77
+ :disabled="disabled"
78
+ :required="required"
79
+ :maxlength="options.maxLength ?? undefined"
80
+ :autocomplete="options.autocomplete ?? undefined"
81
+ :aria-invalid="error ? true : undefined"
82
+ :aria-describedby="describedBy"
83
+ :class="
84
+ cn(
85
+ 'min-w-0 px-3 h-full flex-1 bg-transparent outline-none placeholder:text-xq-muted-foreground disabled:cursor-not-allowed',
86
+ options.numeric && 'tabular-nums',
87
+ )
88
+ "
89
+ @input="onInput"
90
+ @blur="onBlur"
91
+ />
92
+ <span
93
+ v-if="options.suffix"
94
+ class="px-3 text-sm flex h-full items-center border-l border-xq-input bg-xq-muted text-xq-muted-foreground"
95
+ >
96
+ {{ options.suffix }}
97
+ </span>
98
+ </div>
99
+ </XqFieldWrapper>
100
+ </template>
@@ -0,0 +1,28 @@
1
+ <script setup lang="ts">
2
+ import { useField } from '../../composables/useField';
3
+ import type { SchemaNode } from '../../schema/types';
4
+ import XqTextarea from '../../ui/XqTextarea.vue';
5
+ import XqFieldWrapper from '../XqFieldWrapper.vue';
6
+
7
+ const props = defineProps<{ node: SchemaNode }>();
8
+ const { path, value, error, describedBy, required, disabled, id, onBlur } = useField<string | null>(
9
+ props.node,
10
+ );
11
+ </script>
12
+
13
+ <template>
14
+ <XqFieldWrapper :node="node" :id="id" :error="error" :required="required">
15
+ <XqTextarea
16
+ :id="id"
17
+ v-model="value"
18
+ :name="path"
19
+ :rows="node.props?.rows ?? 3"
20
+ :maxlength="node.props?.maxLength ?? undefined"
21
+ :placeholder="node.placeholder ?? undefined"
22
+ :disabled="disabled"
23
+ :aria-invalid="error ? true : undefined"
24
+ :aria-describedby="describedBy"
25
+ @blur="onBlur"
26
+ />
27
+ </XqFieldWrapper>
28
+ </template>
@@ -0,0 +1,47 @@
1
+ <script setup lang="ts">
2
+ import { LoaderCircle } from 'lucide-vue-next';
3
+ import { computed } from 'vue';
4
+ import { useField } from '../../composables/useField';
5
+ import type { SchemaNode } from '../../schema/types';
6
+ import XqLabel from '../../ui/XqLabel.vue';
7
+ import XqSwitch from '../../ui/XqSwitch.vue';
8
+
9
+ 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));
12
+ const describedBy = computed(() =>
13
+ error.value ? `${id}-error` : hasHint.value ? `${id}-hint` : undefined,
14
+ );
15
+ </script>
16
+
17
+ <template>
18
+ <div class="gap-2 grid" :data-xq-path="node.path" :aria-busy="loading || undefined">
19
+ <div class="gap-3 flex items-start">
20
+ <XqSwitch
21
+ :id="id"
22
+ v-model="value"
23
+ :disabled="disabled"
24
+ :aria-invalid="error ? true : undefined"
25
+ :aria-describedby="describedBy"
26
+ />
27
+ <div class="gap-1.5 grid">
28
+ <XqLabel :for="id" class="gap-0.5 leading-5 cursor-pointer">
29
+ {{ node.label }}
30
+ <span v-if="required" class="text-xq-destructive" aria-hidden="true">*</span>
31
+ <span v-if="required" class="sr-only">(required)</span>
32
+ <LoaderCircle
33
+ v-if="loading"
34
+ class="ml-1 size-3 motion-safe:animate-spin text-xq-muted-foreground"
35
+ aria-hidden="true"
36
+ />
37
+ </XqLabel>
38
+ <p v-if="hasHint" :id="`${id}-hint`" class="text-xs text-xq-muted-foreground">
39
+ {{ node.props?.description ?? node.hint }}
40
+ </p>
41
+ </div>
42
+ </div>
43
+ <p v-if="error" :id="`${id}-error`" class="text-xs font-medium text-xq-destructive">
44
+ {{ error }}
45
+ </p>
46
+ </div>
47
+ </template>
@@ -0,0 +1,16 @@
1
+ <script setup lang="ts">
2
+ import { gridStyle } from '../../composables/layout';
3
+ import type { SchemaNode } from '../../schema/types';
4
+ import XqNode from '../XqNode.vue';
5
+
6
+ defineProps<{ node: SchemaNode }>();
7
+ </script>
8
+
9
+ <template>
10
+ <fieldset class="px-4 pt-2 pb-4 rounded-xq-lg border border-xq-border">
11
+ <legend v-if="node.label" class="px-1.5 text-sm font-medium">{{ node.label }}</legend>
12
+ <div class="xq-grid mt-2" :style="gridStyle(node.props?.columns)">
13
+ <XqNode v-for="child in node.children ?? []" :key="child.key" :node="child" />
14
+ </div>
15
+ </fieldset>
16
+ </template>
@@ -0,0 +1,13 @@
1
+ <script setup lang="ts">
2
+ import { gridStyle } from '../../composables/layout';
3
+ import type { SchemaNode } from '../../schema/types';
4
+ import XqNode from '../XqNode.vue';
5
+
6
+ defineProps<{ node: SchemaNode }>();
7
+ </script>
8
+
9
+ <template>
10
+ <div class="xq-grid" :style="gridStyle(node.props?.columns)">
11
+ <XqNode v-for="child in node.children ?? []" :key="child.key" :node="child" />
12
+ </div>
13
+ </template>
@@ -0,0 +1,62 @@
1
+ <script setup lang="ts">
2
+ import { ChevronDown } from 'lucide-vue-next';
3
+ import { computed, inject, provide, ref } from 'vue';
4
+ import { HEADING_LEVEL } from '../../composables/context';
5
+ import { gridStyle } from '../../composables/layout';
6
+ import { cn } from '../../lib/utils';
7
+ import type { SchemaNode } from '../../schema/types';
8
+ import XqNode from '../XqNode.vue';
9
+
10
+ const props = defineProps<{ node: SchemaNode }>();
11
+ const options = computed(() => props.node.props ?? {});
12
+ const collapsed = ref<boolean>(!!props.node.props?.collapsed);
13
+ const hasHeader = computed(() => !!(props.node.label || options.value.description));
14
+ const headingLevel = inject(HEADING_LEVEL, 2);
15
+
16
+ provide(HEADING_LEVEL, Math.min(headingLevel + 1, 6));
17
+ </script>
18
+
19
+ <template>
20
+ <section
21
+ class="shadow-xs rounded-xq-lg border border-xq-border bg-xq-card text-xq-card-foreground"
22
+ >
23
+ <header
24
+ v-if="hasHeader"
25
+ :class="
26
+ cn(
27
+ 'gap-4 px-5 py-4 flex items-start justify-between',
28
+ !collapsed && 'border-b border-xq-border',
29
+ )
30
+ "
31
+ >
32
+ <div class="gap-1 grid">
33
+ <component :is="`h${headingLevel}`" v-if="node.label" class="text-sm font-semibold">
34
+ {{ node.label }}
35
+ </component>
36
+ <p v-if="options.description" class="text-sm text-xq-muted-foreground">
37
+ {{ options.description }}
38
+ </p>
39
+ </div>
40
+ <button
41
+ v-if="options.collapsible"
42
+ type="button"
43
+ class="size-7 flex cursor-pointer items-center justify-center rounded-xq-md text-xq-muted-foreground hover:bg-xq-accent"
44
+ :aria-expanded="!collapsed"
45
+ :aria-label="collapsed ? 'Expand section' : 'Collapse section'"
46
+ @click="collapsed = !collapsed"
47
+ >
48
+ <ChevronDown
49
+ :class="
50
+ cn(
51
+ 'size-4 transition-transform motion-reduce:transition-none',
52
+ collapsed && '-rotate-90',
53
+ )
54
+ "
55
+ />
56
+ </button>
57
+ </header>
58
+ <div v-show="!collapsed" class="xq-grid p-5" :style="gridStyle(options.columns)">
59
+ <XqNode v-for="child in node.children ?? []" :key="child.key" :node="child" />
60
+ </div>
61
+ </section>
62
+ </template>
@@ -0,0 +1,64 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue';
3
+ import { useFormStore } from '../../composables/context';
4
+ import { gridStyle } from '../../composables/layout';
5
+ import { eachNode } from '../../core/patch';
6
+ import type { SchemaNode } from '../../schema/types';
7
+ import XqTabs from '../../ui/XqTabs.vue';
8
+ import XqTabsContent from '../../ui/XqTabsContent.vue';
9
+ import XqTabsList from '../../ui/XqTabsList.vue';
10
+ import XqTabsTrigger from '../../ui/XqTabsTrigger.vue';
11
+ import XqNode from '../XqNode.vue';
12
+
13
+ const props = defineProps<{ node: SchemaNode }>();
14
+ const store = useFormStore();
15
+ const tabs = computed(() => (props.node.children ?? []).filter((tab) => store.isVisible(tab)));
16
+ const active = ref<string>(tabs.value[0]?.key ?? '');
17
+
18
+ watch(tabs, (visible) => {
19
+ if (!visible.some((tab) => tab.key === active.value)) {
20
+ active.value = visible[0]?.key ?? '';
21
+ }
22
+ });
23
+
24
+ function pathsIn(tab: SchemaNode): string[] {
25
+ const paths: string[] = [];
26
+ eachNode(tab.children ?? [], (child) => child.path && paths.push(child.path));
27
+
28
+ return paths;
29
+ }
30
+
31
+ function errorCount(tab: SchemaNode): number {
32
+ return pathsIn(tab).filter((path) => store.errors[path]).length;
33
+ }
34
+
35
+ watch(store.focusRequest, (request) => {
36
+ const tab =
37
+ request && tabs.value.find((candidate) => pathsIn(candidate).includes(request.path));
38
+
39
+ if (tab) {
40
+ active.value = tab.key;
41
+ }
42
+ });
43
+ </script>
44
+
45
+ <template>
46
+ <XqTabs v-model="active">
47
+ <XqTabsList>
48
+ <XqTabsTrigger v-for="tab in tabs" :key="tab.key" :value="tab.key">
49
+ {{ tab.label }}
50
+ <span
51
+ v-if="errorCount(tab)"
52
+ class="size-4 font-semibold inline-flex items-center justify-center rounded-full bg-xq-destructive text-[0.65rem] text-xq-destructive-foreground"
53
+ >
54
+ {{ errorCount(tab) }}
55
+ </span>
56
+ </XqTabsTrigger>
57
+ </XqTabsList>
58
+ <XqTabsContent v-for="tab in tabs" :key="tab.key" :value="tab.key">
59
+ <div class="xq-grid" :style="gridStyle(tab.props?.columns)">
60
+ <XqNode v-for="child in tab.children ?? []" :key="child.key" :node="child" />
61
+ </div>
62
+ </XqTabsContent>
63
+ </XqTabs>
64
+ </template>
@@ -0,0 +1,22 @@
1
+ import { inject, provide } from 'vue';
2
+ import type { InjectionKey } from 'vue';
3
+ import type { FormStore } from '../core/store';
4
+
5
+ export const FORM_STORE: InjectionKey<FormStore> = Symbol('xelqun-form-store');
6
+
7
+ /** Heading level for the next section title. Nested sections go one level deeper. */
8
+ export const HEADING_LEVEL: InjectionKey<number> = Symbol('xelqun-heading-level');
9
+
10
+ export function provideFormStore(store: FormStore): void {
11
+ provide(FORM_STORE, store);
12
+ }
13
+
14
+ export function useFormStore(): FormStore {
15
+ const store = inject(FORM_STORE, null);
16
+
17
+ if (!store) {
18
+ throw new Error('[xelqun] useFormStore() must be used inside <XqForm>.');
19
+ }
20
+
21
+ return store;
22
+ }
@@ -0,0 +1,55 @@
1
+ import type { ColumnSpan } from '../schema/types';
2
+
3
+ const BREAKPOINTS = ['default', 'sm', 'md', 'lg', 'xl'] as const;
4
+
5
+ function suffix(breakpoint: string): string {
6
+ return breakpoint === 'default' ? '' : `-${breakpoint}`;
7
+ }
8
+
9
+ /**
10
+ * CSS variables for a responsive grid, read by the .xq-grid class.
11
+ */
12
+ export function gridStyle(
13
+ columns: Partial<Record<string, number>> | null | undefined,
14
+ ): Record<string, string> {
15
+ const style: Record<string, string> = {};
16
+
17
+ for (const breakpoint of BREAKPOINTS) {
18
+ const count = columns?.[breakpoint];
19
+
20
+ if (count) {
21
+ style[`--xq-cols${suffix(breakpoint)}`] = String(count);
22
+ }
23
+ }
24
+
25
+ return style;
26
+ }
27
+
28
+ /**
29
+ * CSS variables for a cell's column span, read by the .xq-cell class.
30
+ * A plain number applies from the "md" breakpoint, where grids gain columns.
31
+ */
32
+ export function spanStyle(span: ColumnSpan | undefined): Record<string, string> {
33
+ if (span === null || span === undefined) {
34
+ return {};
35
+ }
36
+
37
+ const spans =
38
+ typeof span === 'object'
39
+ ? span
40
+ : span === 'full'
41
+ ? { default: 'full' as const }
42
+ : { md: span };
43
+ const style: Record<string, string> = {};
44
+
45
+ for (const breakpoint of BREAKPOINTS) {
46
+ const value = spans[breakpoint];
47
+
48
+ if (value !== undefined) {
49
+ style[`--xq-span${suffix(breakpoint)}`] =
50
+ value === 'full' ? '1 / -1' : `span ${value} / span ${value}`;
51
+ }
52
+ }
53
+
54
+ return style;
55
+ }
@@ -0,0 +1,25 @@
1
+ import { defineAsyncComponent, inject } from 'vue';
2
+ import type { Component, InjectionKey } from 'vue';
3
+
4
+ export type ComponentLoader = () => Promise<Component | { default: Component }>;
5
+
6
+ export type ComponentResolver = Component | ComponentLoader;
7
+
8
+ export type ComponentRegistry = Map<string, Component>;
9
+
10
+ export const REGISTRY: InjectionKey<ComponentRegistry> = Symbol('xelqun-registry');
11
+
12
+ /**
13
+ * A zero-argument function is treated as a lazy loader, e.g. () => import('./Rating.vue').
14
+ */
15
+ export function toComponent(resolver: ComponentResolver): Component {
16
+ if (typeof resolver === 'function' && resolver.length === 0 && !('render' in resolver)) {
17
+ return defineAsyncComponent(resolver as ComponentLoader);
18
+ }
19
+
20
+ return resolver as Component;
21
+ }
22
+
23
+ export function useRegistry(): ComponentRegistry | null {
24
+ return inject(REGISTRY, null);
25
+ }
@@ -0,0 +1,41 @@
1
+ import { computed } from 'vue';
2
+ import type { SchemaNode } from '../schema/types';
3
+ import { useFormStore } from './context';
4
+
5
+ /**
6
+ * Everything a field component needs: a writable value bound to the form
7
+ * state, its error, and the evaluated required/disabled rules.
8
+ */
9
+ export function useField<T = any>(nodeOrPath: SchemaNode | string) {
10
+ const store = useFormStore();
11
+ const node = typeof nodeOrPath === 'string' ? store.nodes.get(nodeOrPath)! : nodeOrPath;
12
+ const path = node.path ?? node.key;
13
+ const id = `xq-${store.id}-${path}`.replace(/[^\w-]/g, '-');
14
+ const error = computed(() => store.errors[path] ?? null);
15
+
16
+ const value = computed<T>({
17
+ get: () => store.get(path),
18
+ set: (next) => store.set(path, next, 'user'),
19
+ });
20
+
21
+ return {
22
+ store,
23
+ node,
24
+ path,
25
+ id,
26
+ value,
27
+ error,
28
+ /** The id for aria-describedby: the error when there is one, otherwise the hint. */
29
+ describedBy: computed(() => {
30
+ if (error.value) {
31
+ return `${id}-error`;
32
+ }
33
+
34
+ return node.hint ? `${id}-hint` : undefined;
35
+ }),
36
+ loading: computed(() => store.isLoading(node)),
37
+ required: computed(() => store.isRequired(node)),
38
+ disabled: computed(() => store.isDisabled(node) || store.processing.value),
39
+ onBlur: () => store.blur(path),
40
+ };
41
+ }
@@ -0,0 +1,16 @@
1
+ import { onScopeDispose } from 'vue';
2
+ import { createFormStore } from '../core/store';
3
+ import type { FormStore, FormStoreOptions } from '../core/store';
4
+ import type { FormSchema } from '../schema/types';
5
+
6
+ /**
7
+ * Create a form store from the schema prop sent by the server.
8
+ * Pass the result to <XqForm :form="form">.
9
+ */
10
+ export function useXqForm(schema: FormSchema, options: FormStoreOptions = {}): FormStore {
11
+ const store = createFormStore(schema, options);
12
+
13
+ onScopeDispose(() => store.dispose());
14
+
15
+ return store;
16
+ }