@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.
- 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,12 +1,14 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { Check, ChevronsUpDown, LoaderCircle, X } from 'lucide-vue-next';
|
|
2
|
+
import { Check, ChevronLeft, ChevronRight, ChevronsUpDown, LoaderCircle, X } from 'lucide-vue-next';
|
|
3
3
|
import {
|
|
4
4
|
ComboboxAnchor,
|
|
5
5
|
ComboboxContent,
|
|
6
6
|
ComboboxEmpty,
|
|
7
|
+
ComboboxGroup,
|
|
7
8
|
ComboboxInput,
|
|
8
9
|
ComboboxItem,
|
|
9
10
|
ComboboxItemIndicator,
|
|
11
|
+
ComboboxLabel,
|
|
10
12
|
ComboboxPortal,
|
|
11
13
|
ComboboxRoot,
|
|
12
14
|
ComboboxTrigger,
|
|
@@ -14,10 +16,14 @@ import {
|
|
|
14
16
|
} from 'reka-ui';
|
|
15
17
|
import { computed, reactive, ref, watch } from 'vue';
|
|
16
18
|
import type { HTMLAttributes } from 'vue';
|
|
19
|
+
import { useSortable } from '../composables/useSortable';
|
|
17
20
|
import { cn } from '../lib/utils';
|
|
18
|
-
import type { SelectOption } from '../schema/types';
|
|
19
|
-
import { controlClass, optionClass, popoverClass } from './classes';
|
|
21
|
+
import type { SelectMessages, SelectOption } from '../schema/types';
|
|
22
|
+
import { controlClass, groupLabelClass, optionClass, popoverClass, spinnerClass } from './classes';
|
|
23
|
+
import { groupOptions, moveItem } from './options';
|
|
24
|
+
import { useTranslator } from '../composables/context';
|
|
20
25
|
|
|
26
|
+
const t = useTranslator();
|
|
21
27
|
type Value = string | number;
|
|
22
28
|
|
|
23
29
|
const props = defineProps<{
|
|
@@ -26,17 +32,21 @@ const props = defineProps<{
|
|
|
26
32
|
multiple?: boolean;
|
|
27
33
|
remote?: boolean;
|
|
28
34
|
loading?: boolean;
|
|
35
|
+
searching?: boolean;
|
|
29
36
|
placeholder?: string | null;
|
|
30
37
|
disabled?: boolean;
|
|
31
38
|
invalid?: boolean;
|
|
32
39
|
describedBy?: string;
|
|
33
40
|
searchFailed?: boolean;
|
|
41
|
+
messages?: SelectMessages;
|
|
42
|
+
reorderable?: boolean;
|
|
34
43
|
class?: HTMLAttributes['class'];
|
|
35
44
|
}>();
|
|
36
45
|
const emit = defineEmits<{ search: [term: string]; blur: [] }>();
|
|
37
46
|
const model = defineModel<Value | Value[] | null>();
|
|
38
47
|
const searchTerm = ref('');
|
|
39
48
|
const labels = reactive(new Map<string, string>());
|
|
49
|
+
const groups = computed(() => groupOptions(props.options));
|
|
40
50
|
|
|
41
51
|
watch(
|
|
42
52
|
() => props.options,
|
|
@@ -66,6 +76,18 @@ function remove(value: Value): void {
|
|
|
66
76
|
model.value = selectedValues.value.filter((item) => item !== value);
|
|
67
77
|
}
|
|
68
78
|
|
|
79
|
+
function move(value: Value, offset: number): void {
|
|
80
|
+
model.value = moveItem(selectedValues.value, value, offset);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const chipList = ref<HTMLElement | null>(null);
|
|
84
|
+
|
|
85
|
+
useSortable(chipList, {
|
|
86
|
+
draggable: '> [data-xq-chip]',
|
|
87
|
+
enabled: () => !!props.reorderable && !props.disabled && selectedValues.value.length > 1,
|
|
88
|
+
onMove: (from, to) => move(selectedValues.value[from], to - from),
|
|
89
|
+
});
|
|
90
|
+
|
|
69
91
|
function clear(): void {
|
|
70
92
|
model.value = props.multiple ? [] : null;
|
|
71
93
|
searchTerm.value = '';
|
|
@@ -93,43 +115,68 @@ function clear(): void {
|
|
|
93
115
|
)
|
|
94
116
|
"
|
|
95
117
|
>
|
|
96
|
-
<
|
|
118
|
+
<span v-if="multiple" ref="chipList" class="gap-1 flex max-w-full flex-wrap">
|
|
97
119
|
<span
|
|
98
|
-
v-for="value in selectedValues"
|
|
120
|
+
v-for="(value, index) in selectedValues"
|
|
99
121
|
:key="String(value)"
|
|
100
|
-
|
|
122
|
+
data-xq-chip
|
|
123
|
+
:class="
|
|
124
|
+
cn(
|
|
125
|
+
'gap-1 py-0.5 pr-1 pl-2 text-xs font-medium inline-flex items-center rounded-xq-sm bg-xq-secondary text-xq-secondary-foreground',
|
|
126
|
+
reorderable && 'cursor-grab touch-none',
|
|
127
|
+
)
|
|
128
|
+
"
|
|
101
129
|
>
|
|
130
|
+
<button
|
|
131
|
+
v-if="reorderable && index > 0"
|
|
132
|
+
type="button"
|
|
133
|
+
class="rounded-sm cursor-pointer opacity-60 hover:opacity-100"
|
|
134
|
+
:aria-label="t('Move :name earlier', { name: labelFor(value) })"
|
|
135
|
+
:disabled="disabled"
|
|
136
|
+
@click.stop="move(value, -1)"
|
|
137
|
+
>
|
|
138
|
+
<ChevronLeft class="size-3" />
|
|
139
|
+
</button>
|
|
102
140
|
{{ labelFor(value) }}
|
|
103
141
|
<button
|
|
142
|
+
v-if="reorderable && index < selectedValues.length - 1"
|
|
104
143
|
type="button"
|
|
105
144
|
class="rounded-sm cursor-pointer opacity-60 hover:opacity-100"
|
|
106
|
-
:aria-label="
|
|
145
|
+
:aria-label="t('Move :name later', { name: labelFor(value) })"
|
|
146
|
+
:disabled="disabled"
|
|
147
|
+
@click.stop="move(value, 1)"
|
|
148
|
+
>
|
|
149
|
+
<ChevronRight class="size-3" />
|
|
150
|
+
</button>
|
|
151
|
+
<button
|
|
152
|
+
type="button"
|
|
153
|
+
class="rounded-sm cursor-pointer opacity-60 hover:opacity-100"
|
|
154
|
+
:aria-label="t('Remove :name', { name: labelFor(value) })"
|
|
107
155
|
:disabled="disabled"
|
|
108
156
|
@click.stop="remove(value)"
|
|
109
157
|
>
|
|
110
158
|
<X class="size-3" />
|
|
111
159
|
</button>
|
|
112
160
|
</span>
|
|
113
|
-
</
|
|
161
|
+
</span>
|
|
114
162
|
<ComboboxInput
|
|
115
163
|
:id="id"
|
|
116
164
|
v-model="searchTerm"
|
|
117
165
|
@input="emit('search', ($event.target as HTMLInputElement).value)"
|
|
118
166
|
:display-value="multiple ? undefined : labelFor"
|
|
119
|
-
:placeholder="
|
|
167
|
+
:placeholder="
|
|
168
|
+
selectedValues.length && multiple ? '' : (placeholder ?? t('Search…'))
|
|
169
|
+
"
|
|
120
170
|
:aria-invalid="invalid || undefined"
|
|
121
171
|
:aria-describedby="describedBy"
|
|
122
172
|
class="h-7 min-w-16 px-1 text-sm flex-1 bg-transparent outline-none placeholder:text-xq-muted-foreground disabled:cursor-not-allowed"
|
|
123
173
|
/>
|
|
124
|
-
<LoaderCircle
|
|
125
|
-
v-if="loading"
|
|
126
|
-
class="size-4 motion-safe:animate-spin shrink-0 text-xq-muted-foreground"
|
|
127
|
-
/>
|
|
174
|
+
<LoaderCircle v-if="loading || searching" :class="spinnerClass" aria-hidden="true" />
|
|
128
175
|
<button
|
|
129
176
|
v-else-if="selectedValues.length && !disabled"
|
|
130
177
|
type="button"
|
|
131
178
|
class="size-6 rounded-sm flex cursor-pointer items-center justify-center text-xq-muted-foreground hover:text-xq-foreground"
|
|
132
|
-
aria-label="Clear selection"
|
|
179
|
+
:aria-label="t('Clear selection')"
|
|
133
180
|
@click.stop="clear"
|
|
134
181
|
>
|
|
135
182
|
<X class="size-3.5" />
|
|
@@ -156,27 +203,37 @@ function clear(): void {
|
|
|
156
203
|
</p>
|
|
157
204
|
<ComboboxEmpty class="px-2 py-6 text-sm text-center text-xq-muted-foreground">
|
|
158
205
|
{{
|
|
159
|
-
|
|
160
|
-
? 'Searching…'
|
|
161
|
-
:
|
|
162
|
-
?
|
|
163
|
-
:
|
|
206
|
+
searching
|
|
207
|
+
? (messages?.searching ?? t('Searching…'))
|
|
208
|
+
: loading
|
|
209
|
+
? (messages?.loading ?? t('Loading…'))
|
|
210
|
+
: searchTerm
|
|
211
|
+
? (messages?.noResults ?? t('No results'))
|
|
212
|
+
: remote
|
|
213
|
+
? (messages?.prompt ?? t('Type to search'))
|
|
214
|
+
: (messages?.noOptions ?? t('No results'))
|
|
164
215
|
}}
|
|
165
216
|
</ComboboxEmpty>
|
|
166
|
-
<
|
|
167
|
-
v-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
217
|
+
<ComboboxGroup v-for="group in groups" :key="group.label ?? ''">
|
|
218
|
+
<ComboboxLabel v-if="group.label" :class="groupLabelClass">{{
|
|
219
|
+
group.label
|
|
220
|
+
}}</ComboboxLabel>
|
|
221
|
+
<ComboboxItem
|
|
222
|
+
v-for="option in group.options"
|
|
223
|
+
:key="String(option.value)"
|
|
224
|
+
:value="option.value"
|
|
225
|
+
:text-value="option.label"
|
|
226
|
+
:disabled="option.disabled"
|
|
227
|
+
:class="optionClass"
|
|
176
228
|
>
|
|
177
|
-
<
|
|
178
|
-
|
|
179
|
-
|
|
229
|
+
<span class="truncate">{{ option.label }}</span>
|
|
230
|
+
<ComboboxItemIndicator
|
|
231
|
+
class="right-2 size-3.5 absolute flex items-center justify-center"
|
|
232
|
+
>
|
|
233
|
+
<Check class="size-4" />
|
|
234
|
+
</ComboboxItemIndicator>
|
|
235
|
+
</ComboboxItem>
|
|
236
|
+
</ComboboxGroup>
|
|
180
237
|
</ComboboxViewport>
|
|
181
238
|
</ComboboxContent>
|
|
182
239
|
</ComboboxPortal>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
AlertDialogAction,
|
|
4
|
+
AlertDialogCancel,
|
|
5
|
+
AlertDialogContent,
|
|
6
|
+
AlertDialogDescription,
|
|
7
|
+
AlertDialogOverlay,
|
|
8
|
+
AlertDialogPortal,
|
|
9
|
+
AlertDialogRoot,
|
|
10
|
+
AlertDialogTitle,
|
|
11
|
+
} from 'reka-ui';
|
|
12
|
+
import XqButton from './XqButton.vue';
|
|
13
|
+
import { useTranslator } from '../composables/context';
|
|
14
|
+
|
|
15
|
+
const t = useTranslator();
|
|
16
|
+
defineProps<{
|
|
17
|
+
title: string;
|
|
18
|
+
message?: string | null;
|
|
19
|
+
confirmLabel?: string | null;
|
|
20
|
+
cancelLabel?: string | null;
|
|
21
|
+
confirmVariant?: 'default' | 'destructive';
|
|
22
|
+
}>();
|
|
23
|
+
defineEmits<{ confirm: [] }>();
|
|
24
|
+
const open = defineModel<boolean>('open', { default: false });
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<template>
|
|
28
|
+
<AlertDialogRoot v-model:open="open">
|
|
29
|
+
<AlertDialogPortal>
|
|
30
|
+
<AlertDialogOverlay class="inset-0 bg-black/50 fixed z-50" />
|
|
31
|
+
<AlertDialogContent
|
|
32
|
+
class="max-w-sm p-5 shadow-lg gap-3 fixed top-1/2 left-1/2 z-50 grid w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 rounded-xq-lg border border-xq-border bg-xq-popover text-xq-popover-foreground"
|
|
33
|
+
>
|
|
34
|
+
<AlertDialogTitle class="text-sm font-semibold">{{ title }}</AlertDialogTitle>
|
|
35
|
+
<AlertDialogDescription class="text-sm text-xq-muted-foreground">
|
|
36
|
+
{{ message ?? '' }}
|
|
37
|
+
</AlertDialogDescription>
|
|
38
|
+
<div class="gap-2 flex justify-end">
|
|
39
|
+
<AlertDialogCancel as-child>
|
|
40
|
+
<XqButton type="button" variant="outline" size="sm">
|
|
41
|
+
{{ cancelLabel ?? t('Cancel') }}
|
|
42
|
+
</XqButton>
|
|
43
|
+
</AlertDialogCancel>
|
|
44
|
+
<AlertDialogAction as-child>
|
|
45
|
+
<XqButton
|
|
46
|
+
type="button"
|
|
47
|
+
:variant="confirmVariant ?? 'destructive'"
|
|
48
|
+
size="sm"
|
|
49
|
+
@click="$emit('confirm')"
|
|
50
|
+
>
|
|
51
|
+
{{ confirmLabel ?? t('Delete') }}
|
|
52
|
+
</XqButton>
|
|
53
|
+
</AlertDialogAction>
|
|
54
|
+
</div>
|
|
55
|
+
</AlertDialogContent>
|
|
56
|
+
</AlertDialogPortal>
|
|
57
|
+
</AlertDialogRoot>
|
|
58
|
+
</template>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
DialogContent,
|
|
4
|
+
DialogDescription,
|
|
5
|
+
DialogOverlay,
|
|
6
|
+
DialogPortal,
|
|
7
|
+
DialogRoot,
|
|
8
|
+
DialogTitle,
|
|
9
|
+
} from 'reka-ui';
|
|
10
|
+
import { cn } from '../lib/utils';
|
|
11
|
+
|
|
12
|
+
const props = defineProps<{ title: string; description?: string | null; width?: string | null }>();
|
|
13
|
+
const open = defineModel<boolean>('open', { default: false });
|
|
14
|
+
|
|
15
|
+
const widths: Record<string, string> = {
|
|
16
|
+
sm: 'max-w-sm',
|
|
17
|
+
md: 'max-w-md',
|
|
18
|
+
lg: 'max-w-lg',
|
|
19
|
+
xl: 'max-w-xl',
|
|
20
|
+
'2xl': 'max-w-2xl',
|
|
21
|
+
};
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<template>
|
|
25
|
+
<DialogRoot v-model:open="open">
|
|
26
|
+
<DialogPortal>
|
|
27
|
+
<DialogOverlay class="inset-0 bg-black/50 fixed z-50" />
|
|
28
|
+
<DialogContent
|
|
29
|
+
:class="
|
|
30
|
+
cn(
|
|
31
|
+
'p-5 shadow-lg gap-4 fixed top-1/2 left-1/2 z-50 grid max-h-[85vh] w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-xq-lg border border-xq-border bg-xq-popover text-xq-popover-foreground',
|
|
32
|
+
widths[props.width ?? 'md'],
|
|
33
|
+
)
|
|
34
|
+
"
|
|
35
|
+
>
|
|
36
|
+
<div class="gap-1 grid">
|
|
37
|
+
<DialogTitle class="text-sm font-semibold">{{ title }}</DialogTitle>
|
|
38
|
+
<DialogDescription class="text-sm text-xq-muted-foreground">
|
|
39
|
+
{{ description ?? '' }}
|
|
40
|
+
</DialogDescription>
|
|
41
|
+
</div>
|
|
42
|
+
<slot />
|
|
43
|
+
</DialogContent>
|
|
44
|
+
</DialogPortal>
|
|
45
|
+
</DialogRoot>
|
|
46
|
+
</template>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue';
|
|
3
|
+
import type { HTMLAttributes } from 'vue';
|
|
4
|
+
import { cn } from '../lib/utils';
|
|
5
|
+
import { resolveIcon } from './icons';
|
|
6
|
+
|
|
7
|
+
const props = defineProps<{ name: string; class?: HTMLAttributes['class'] }>();
|
|
8
|
+
const icon = computed(() => {
|
|
9
|
+
const resolved = resolveIcon(props.name);
|
|
10
|
+
|
|
11
|
+
if (!resolved && import.meta.env?.DEV) {
|
|
12
|
+
console.warn(
|
|
13
|
+
`[xelqun] No icon is registered as "${props.name}". Add it with the icons option.`,
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return resolved;
|
|
18
|
+
});
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<template>
|
|
22
|
+
<component
|
|
23
|
+
:is="icon"
|
|
24
|
+
v-if="icon"
|
|
25
|
+
:class="cn('size-4 shrink-0', props.class)"
|
|
26
|
+
aria-hidden="true"
|
|
27
|
+
/>
|
|
28
|
+
</template>
|
|
@@ -1,31 +1,40 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { Check, ChevronDown } from 'lucide-vue-next';
|
|
2
|
+
import { Check, ChevronDown, LoaderCircle } from 'lucide-vue-next';
|
|
3
3
|
import {
|
|
4
4
|
SelectContent,
|
|
5
|
+
SelectGroup,
|
|
5
6
|
SelectItem,
|
|
6
7
|
SelectItemIndicator,
|
|
7
8
|
SelectItemText,
|
|
9
|
+
SelectLabel,
|
|
8
10
|
SelectPortal,
|
|
9
11
|
SelectRoot,
|
|
10
12
|
SelectTrigger,
|
|
11
13
|
SelectValue,
|
|
12
14
|
SelectViewport,
|
|
13
15
|
} from 'reka-ui';
|
|
16
|
+
import { computed } from 'vue';
|
|
14
17
|
import type { HTMLAttributes } from 'vue';
|
|
15
18
|
import { cn } from '../lib/utils';
|
|
16
|
-
import type { SelectOption } from '../schema/types';
|
|
17
|
-
import { controlClass, optionClass, popoverClass } from './classes';
|
|
19
|
+
import type { SelectMessages, SelectOption } from '../schema/types';
|
|
20
|
+
import { controlClass, groupLabelClass, optionClass, popoverClass, spinnerClass } from './classes';
|
|
21
|
+
import { groupOptions } from './options';
|
|
22
|
+
import { useTranslator } from '../composables/context';
|
|
18
23
|
|
|
24
|
+
const t = useTranslator();
|
|
19
25
|
const props = defineProps<{
|
|
20
26
|
id?: string;
|
|
21
27
|
options: SelectOption[];
|
|
28
|
+
loading?: boolean;
|
|
22
29
|
placeholder?: string | null;
|
|
23
30
|
disabled?: boolean;
|
|
24
31
|
invalid?: boolean;
|
|
25
32
|
describedBy?: string;
|
|
33
|
+
messages?: SelectMessages;
|
|
26
34
|
class?: HTMLAttributes['class'];
|
|
27
35
|
}>();
|
|
28
36
|
const model = defineModel<string | number | null>();
|
|
37
|
+
const groups = computed(() => groupOptions(props.options));
|
|
29
38
|
</script>
|
|
30
39
|
|
|
31
40
|
<template>
|
|
@@ -43,8 +52,9 @@ const model = defineModel<string | number | null>();
|
|
|
43
52
|
)
|
|
44
53
|
"
|
|
45
54
|
>
|
|
46
|
-
<SelectValue :placeholder="placeholder ?? 'Select an option'" class="truncate" />
|
|
47
|
-
<
|
|
55
|
+
<SelectValue :placeholder="placeholder ?? t('Select an option')" class="truncate" />
|
|
56
|
+
<LoaderCircle v-if="loading" :class="spinnerClass" aria-hidden="true" />
|
|
57
|
+
<ChevronDown v-else class="size-4 shrink-0 opacity-60" />
|
|
48
58
|
</SelectTrigger>
|
|
49
59
|
<SelectPortal>
|
|
50
60
|
<SelectContent
|
|
@@ -53,24 +63,34 @@ const model = defineModel<string | number | null>();
|
|
|
53
63
|
:class="cn(popoverClass, 'max-h-72 min-w-32 w-(--reka-select-trigger-width)')"
|
|
54
64
|
>
|
|
55
65
|
<SelectViewport class="p-1">
|
|
56
|
-
<
|
|
57
|
-
v-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
66
|
+
<SelectGroup v-for="group in groups" :key="group.label ?? ''">
|
|
67
|
+
<SelectLabel v-if="group.label" :class="groupLabelClass">{{
|
|
68
|
+
group.label
|
|
69
|
+
}}</SelectLabel>
|
|
70
|
+
<SelectItem
|
|
71
|
+
v-for="option in group.options"
|
|
72
|
+
:key="String(option.value)"
|
|
73
|
+
:value="option.value"
|
|
74
|
+
:disabled="option.disabled"
|
|
75
|
+
:class="optionClass"
|
|
65
76
|
>
|
|
66
|
-
<
|
|
67
|
-
|
|
68
|
-
|
|
77
|
+
<SelectItemText>{{ option.label }}</SelectItemText>
|
|
78
|
+
<SelectItemIndicator
|
|
79
|
+
class="right-2 size-3.5 absolute flex items-center justify-center"
|
|
80
|
+
>
|
|
81
|
+
<Check class="size-4" />
|
|
82
|
+
</SelectItemIndicator>
|
|
83
|
+
</SelectItem>
|
|
84
|
+
</SelectGroup>
|
|
69
85
|
<div
|
|
70
86
|
v-if="options.length === 0"
|
|
71
87
|
class="px-2 py-6 text-sm text-center text-xq-muted-foreground"
|
|
72
88
|
>
|
|
73
|
-
|
|
89
|
+
{{
|
|
90
|
+
loading
|
|
91
|
+
? (messages?.loading ?? t('Loading…'))
|
|
92
|
+
: (messages?.noOptions ?? t('No options'))
|
|
93
|
+
}}
|
|
74
94
|
</div>
|
|
75
95
|
</SelectViewport>
|
|
76
96
|
</SelectContent>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { parseTime } from '@internationalized/date';
|
|
3
|
+
import type { Time } from '@internationalized/date';
|
|
4
|
+
import { TimeFieldInput, TimeFieldRoot } from 'reka-ui';
|
|
5
|
+
import { computed } from 'vue';
|
|
6
|
+
import type { HTMLAttributes } from 'vue';
|
|
7
|
+
import { cn } from '../lib/utils';
|
|
8
|
+
import { controlClass } from './classes';
|
|
9
|
+
|
|
10
|
+
const props = defineProps<{
|
|
11
|
+
id?: string;
|
|
12
|
+
seconds?: boolean;
|
|
13
|
+
steps?: { hour: number; minute: number; second: number };
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
invalid?: boolean;
|
|
16
|
+
describedBy?: string;
|
|
17
|
+
label?: string | null;
|
|
18
|
+
locale?: string;
|
|
19
|
+
class?: HTMLAttributes['class'];
|
|
20
|
+
}>();
|
|
21
|
+
const model = defineModel<string | null>();
|
|
22
|
+
|
|
23
|
+
const time = computed<Time | undefined>({
|
|
24
|
+
get() {
|
|
25
|
+
try {
|
|
26
|
+
return model.value ? parseTime(model.value) : undefined;
|
|
27
|
+
} catch {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
set(next) {
|
|
32
|
+
model.value = next ? next.toString().slice(0, props.seconds ? 8 : 5) : null;
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
</script>
|
|
36
|
+
|
|
37
|
+
<template>
|
|
38
|
+
<TimeFieldRoot
|
|
39
|
+
:id="id"
|
|
40
|
+
v-slot="{ segments }"
|
|
41
|
+
v-model="time"
|
|
42
|
+
:granularity="seconds ? 'second' : 'minute'"
|
|
43
|
+
:step="steps"
|
|
44
|
+
:hour-cycle="24"
|
|
45
|
+
:locale="locale"
|
|
46
|
+
:disabled="disabled"
|
|
47
|
+
:aria-invalid="invalid || undefined"
|
|
48
|
+
:aria-describedby="describedBy"
|
|
49
|
+
:aria-label="label ?? undefined"
|
|
50
|
+
data-slot="xq-time-field"
|
|
51
|
+
:class="cn(controlClass, 'h-9 px-3 flex items-center tabular-nums', props.class)"
|
|
52
|
+
>
|
|
53
|
+
<template v-for="segment in segments" :key="segment.part">
|
|
54
|
+
<TimeFieldInput v-if="segment.part === 'literal'" :part="segment.part">
|
|
55
|
+
{{ segment.value }}
|
|
56
|
+
</TimeFieldInput>
|
|
57
|
+
<TimeFieldInput
|
|
58
|
+
v-else
|
|
59
|
+
:part="segment.part"
|
|
60
|
+
class="px-0.5 rounded-sm outline-none focus:bg-xq-accent focus:text-xq-accent-foreground data-[placeholder]:text-xq-muted-foreground"
|
|
61
|
+
>
|
|
62
|
+
{{ segment.value }}
|
|
63
|
+
</TimeFieldInput>
|
|
64
|
+
</template>
|
|
65
|
+
</TimeFieldRoot>
|
|
66
|
+
</template>
|
|
@@ -5,8 +5,41 @@
|
|
|
5
5
|
export const controlClass =
|
|
6
6
|
'w-full min-w-0 rounded-xq-md border border-xq-input bg-xq-background text-sm text-xq-foreground shadow-xs transition-[color,box-shadow,border-color] outline-none placeholder:text-xq-muted-foreground focus-visible:border-xq-ring focus-visible:ring-[3px] focus-visible:ring-xq-ring/25 disabled:cursor-not-allowed disabled:opacity-60 aria-invalid:border-xq-destructive aria-invalid:ring-xq-destructive/20';
|
|
7
7
|
|
|
8
|
+
export const spinnerClass = 'size-4 shrink-0 motion-safe:animate-spin text-xq-muted-foreground';
|
|
9
|
+
|
|
8
10
|
export const popoverClass =
|
|
9
11
|
'z-50 overflow-hidden rounded-xq-md border border-xq-border bg-xq-popover text-xq-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0';
|
|
10
12
|
|
|
13
|
+
export const groupLabelClass = 'px-2 py-1.5 text-xs font-medium text-xq-muted-foreground';
|
|
14
|
+
|
|
11
15
|
export const optionClass =
|
|
12
16
|
'relative flex w-full cursor-default items-center gap-2 rounded-xq-sm py-1.5 pr-8 pl-2 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[highlighted]:bg-xq-accent data-[highlighted]:text-xq-accent-foreground';
|
|
17
|
+
|
|
18
|
+
/** Text colors the PHP side can name. */
|
|
19
|
+
export const toneClasses: Record<string, string> = {
|
|
20
|
+
neutral: 'text-xq-foreground',
|
|
21
|
+
primary: 'text-xq-primary',
|
|
22
|
+
danger: 'text-xq-destructive',
|
|
23
|
+
info: 'text-xq-info',
|
|
24
|
+
success: 'text-xq-success',
|
|
25
|
+
warning: 'text-xq-warning',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const textSizeClasses: Record<string, string> = {
|
|
29
|
+
xs: 'text-xs',
|
|
30
|
+
sm: 'text-sm',
|
|
31
|
+
md: 'text-base',
|
|
32
|
+
lg: 'text-lg',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const iconSizeClasses: Record<string, string> = {
|
|
36
|
+
xs: 'size-3',
|
|
37
|
+
sm: 'size-4',
|
|
38
|
+
md: 'size-5',
|
|
39
|
+
lg: 'size-6',
|
|
40
|
+
xl: 'size-8',
|
|
41
|
+
'2xl': 'size-10',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const toolbarButtonClass =
|
|
45
|
+
'size-7 flex cursor-pointer items-center justify-center rounded-xq-sm text-xq-muted-foreground hover:bg-xq-accent aria-pressed:bg-xq-accent aria-pressed:text-xq-accent-foreground disabled:pointer-events-none disabled:opacity-50';
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AtSign,
|
|
3
|
+
Building,
|
|
4
|
+
Calendar,
|
|
5
|
+
Check,
|
|
6
|
+
CircleAlert,
|
|
7
|
+
CircleCheck,
|
|
8
|
+
Clock,
|
|
9
|
+
CreditCard,
|
|
10
|
+
DollarSign,
|
|
11
|
+
Eye,
|
|
12
|
+
File,
|
|
13
|
+
Folder,
|
|
14
|
+
Globe,
|
|
15
|
+
Hash,
|
|
16
|
+
Heart,
|
|
17
|
+
Image,
|
|
18
|
+
Inbox,
|
|
19
|
+
Info,
|
|
20
|
+
Key,
|
|
21
|
+
Link,
|
|
22
|
+
Lock,
|
|
23
|
+
Mail,
|
|
24
|
+
MapPin,
|
|
25
|
+
Pencil,
|
|
26
|
+
Phone,
|
|
27
|
+
Plus,
|
|
28
|
+
Search,
|
|
29
|
+
Settings,
|
|
30
|
+
Star,
|
|
31
|
+
Tag,
|
|
32
|
+
ThumbsDown,
|
|
33
|
+
ThumbsUp,
|
|
34
|
+
Trash,
|
|
35
|
+
TriangleAlert,
|
|
36
|
+
User,
|
|
37
|
+
Users,
|
|
38
|
+
X,
|
|
39
|
+
} from 'lucide-vue-next';
|
|
40
|
+
import type { Component } from 'vue';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* shortcut: a fixed set, because any dynamic import of lucide would pull all of its
|
|
44
|
+
* icons into the main bundle. Apps add more with the `icons` option.
|
|
45
|
+
*/
|
|
46
|
+
const icons: Record<string, Component> = {
|
|
47
|
+
'at-sign': AtSign,
|
|
48
|
+
building: Building,
|
|
49
|
+
calendar: Calendar,
|
|
50
|
+
check: Check,
|
|
51
|
+
'circle-alert': CircleAlert,
|
|
52
|
+
'circle-check': CircleCheck,
|
|
53
|
+
clock: Clock,
|
|
54
|
+
'credit-card': CreditCard,
|
|
55
|
+
'dollar-sign': DollarSign,
|
|
56
|
+
eye: Eye,
|
|
57
|
+
file: File,
|
|
58
|
+
folder: Folder,
|
|
59
|
+
globe: Globe,
|
|
60
|
+
hash: Hash,
|
|
61
|
+
heart: Heart,
|
|
62
|
+
image: Image,
|
|
63
|
+
inbox: Inbox,
|
|
64
|
+
info: Info,
|
|
65
|
+
key: Key,
|
|
66
|
+
link: Link,
|
|
67
|
+
lock: Lock,
|
|
68
|
+
mail: Mail,
|
|
69
|
+
'map-pin': MapPin,
|
|
70
|
+
pencil: Pencil,
|
|
71
|
+
phone: Phone,
|
|
72
|
+
plus: Plus,
|
|
73
|
+
search: Search,
|
|
74
|
+
settings: Settings,
|
|
75
|
+
star: Star,
|
|
76
|
+
tag: Tag,
|
|
77
|
+
'thumbs-down': ThumbsDown,
|
|
78
|
+
'thumbs-up': ThumbsUp,
|
|
79
|
+
trash: Trash,
|
|
80
|
+
'triangle-alert': TriangleAlert,
|
|
81
|
+
user: User,
|
|
82
|
+
users: Users,
|
|
83
|
+
x: X,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Add icons that PHP can name, as { name: component }.
|
|
88
|
+
*/
|
|
89
|
+
export function registerIcons(extra: Record<string, Component>): void {
|
|
90
|
+
Object.assign(icons, extra);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function resolveIcon(name: string): Component | null {
|
|
94
|
+
return icons[name] ?? null;
|
|
95
|
+
}
|