@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,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
- <template v-if="multiple">
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
- class="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"
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="`Remove ${labelFor(value)}`"
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
- </template>
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="selectedValues.length && multiple ? '' : (placeholder ?? 'Search…')"
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
- loading
160
- ? 'Searching…'
161
- : searchTerm || !remote
162
- ? 'No results'
163
- : 'Type to search'
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
- <ComboboxItem
167
- v-for="option in options"
168
- :key="String(option.value)"
169
- :value="option.value"
170
- :text-value="option.label"
171
- :class="optionClass"
172
- >
173
- <span class="truncate">{{ option.label }}</span>
174
- <ComboboxItemIndicator
175
- class="right-2 size-3.5 absolute flex items-center justify-center"
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
- <Check class="size-4" />
178
- </ComboboxItemIndicator>
179
- </ComboboxItem>
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
- <ChevronDown class="size-4 shrink-0 opacity-60" />
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
- <SelectItem
57
- v-for="option in options"
58
- :key="String(option.value)"
59
- :value="option.value"
60
- :class="optionClass"
61
- >
62
- <SelectItemText>{{ option.label }}</SelectItemText>
63
- <SelectItemIndicator
64
- class="right-2 size-3.5 absolute flex items-center justify-center"
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
- <Check class="size-4" />
67
- </SelectItemIndicator>
68
- </SelectItem>
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
- No options
89
+ {{
90
+ loading
91
+ ? (messages?.loading ?? t('Loading…'))
92
+ : (messages?.noOptions ?? t('No options'))
93
+ }}
74
94
  </div>
75
95
  </SelectViewport>
76
96
  </SelectContent>
@@ -11,7 +11,7 @@ const model = defineModel<string | number>();
11
11
  <TabsRoot
12
12
  v-model="model"
13
13
  data-slot="xq-tabs"
14
- :unmount-on-hide="unmountOnHide ?? true"
14
+ :unmount-on-hide="unmountOnHide"
15
15
  :class="cn('gap-3 flex flex-col', props.class)"
16
16
  >
17
17
  <slot />
@@ -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
+ }