@xelqun/forms 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
@@ -0,0 +1,156 @@
1
+ <script setup lang="ts">
2
+ import {
3
+ ColorAreaArea,
4
+ ColorAreaRoot,
5
+ ColorAreaThumb,
6
+ ColorSliderRoot,
7
+ ColorSliderThumb,
8
+ ColorSliderTrack,
9
+ colorToString,
10
+ convertToHsb,
11
+ isValidColor,
12
+ parseColor,
13
+ PopoverContent,
14
+ PopoverPortal,
15
+ PopoverRoot,
16
+ PopoverTrigger,
17
+ } from 'reka-ui';
18
+ import type { Color } from 'reka-ui';
19
+ import { computed, ref, watch } from 'vue';
20
+ import { useField } from '../../composables/useField';
21
+ import { cn } from '../../lib/utils';
22
+ import type { SchemaNode } from '../../schema/types';
23
+ import { controlClass, popoverClass } from '../../ui/classes';
24
+ import XqInput from '../../ui/XqInput.vue';
25
+ import XqFieldWrapper from '../XqFieldWrapper.vue';
26
+ import { useTranslator } from '../../composables/context';
27
+
28
+ const t = useTranslator();
29
+ const props = defineProps<{ node: SchemaNode }>();
30
+ const { value, error, placeholder, describedBy, required, disabled, id, onBlur } = useField<
31
+ string | null
32
+ >(props.node);
33
+ const format = computed<string>(() => props.node.props?.format ?? 'hex');
34
+
35
+ function toHsb(input: string | null | undefined): Color {
36
+ return convertToHsb(parseColor(input && isValidColor(input) ? input : '#000000'));
37
+ }
38
+
39
+ function toText(color: Color): string {
40
+ if (format.value === 'rgba') {
41
+ return colorToString({ ...color, alpha: Math.min(color.alpha, 0.999) }, 'rgb').replace(
42
+ /, 0\.999\)$/,
43
+ ', 1)',
44
+ );
45
+ }
46
+
47
+ const opaque = { ...color, alpha: 1 };
48
+
49
+ return colorToString(opaque, format.value === 'hex' ? 'hex' : (format.value as 'rgb' | 'hsl'));
50
+ }
51
+
52
+ /** Kept as HSB, because hex and rgb forget the hue of a black or grey color. */
53
+ const color = ref<Color>(toHsb(value.value));
54
+
55
+ watch(value, (next) => {
56
+ if (next !== toText(color.value)) {
57
+ color.value = toHsb(next);
58
+ }
59
+ });
60
+
61
+ function pick(next: Color): void {
62
+ color.value = next;
63
+ value.value = toText(next);
64
+ }
65
+
66
+ function type(text: string | number | null | undefined): void {
67
+ value.value = text ? String(text) : null;
68
+ }
69
+ </script>
70
+
71
+ <template>
72
+ <XqFieldWrapper :node="node" :id="id" :error="error" :required="required">
73
+ <PopoverRoot @update:open="(open) => !open && onBlur()">
74
+ <div class="gap-2 flex items-center">
75
+ <PopoverTrigger as-child>
76
+ <button
77
+ type="button"
78
+ :disabled="disabled"
79
+ :aria-label="
80
+ t('Choose a color, currently :value', { value: value ?? t('none') })
81
+ "
82
+ :class="
83
+ cn(
84
+ controlClass,
85
+ 'size-9 shrink-0 cursor-pointer',
86
+ error && 'border-xq-destructive',
87
+ )
88
+ "
89
+ :style="{ backgroundColor: value ?? undefined }"
90
+ />
91
+ </PopoverTrigger>
92
+ <XqInput
93
+ :id="id"
94
+ :model-value="value"
95
+ :placeholder="placeholder ?? undefined"
96
+ :disabled="disabled"
97
+ :aria-invalid="error ? true : undefined"
98
+ :aria-describedby="describedBy"
99
+ class="font-mono"
100
+ @update:model-value="type"
101
+ @blur="onBlur"
102
+ />
103
+ </div>
104
+ <PopoverPortal>
105
+ <PopoverContent
106
+ align="start"
107
+ :side-offset="4"
108
+ :class="cn(popoverClass, 'w-60 gap-3 p-3 grid')"
109
+ >
110
+ <ColorAreaRoot
111
+ v-slot="{ style }"
112
+ :model-value="color"
113
+ color-space="hsb"
114
+ x-channel="saturation"
115
+ y-channel="brightness"
116
+ @update:color="pick"
117
+ >
118
+ <ColorAreaArea
119
+ :style="style"
120
+ class="h-36 relative w-full touch-none rounded-xq-md"
121
+ >
122
+ <ColorAreaThumb
123
+ class="size-4 border-white shadow-md rounded-full border-2 outline-none focus-visible:ring-[3px] focus-visible:ring-xq-ring/40"
124
+ />
125
+ </ColorAreaArea>
126
+ </ColorAreaRoot>
127
+ <ColorSliderRoot
128
+ :model-value="color"
129
+ color-space="hsb"
130
+ channel="hue"
131
+ class="h-4 relative flex w-full touch-none items-center"
132
+ @update:color="pick"
133
+ >
134
+ <ColorSliderTrack class="h-2.5 relative w-full rounded-full" />
135
+ <ColorSliderThumb
136
+ class="size-4 border-white shadow-md block rounded-full border-2 outline-none focus-visible:ring-[3px] focus-visible:ring-xq-ring/40"
137
+ />
138
+ </ColorSliderRoot>
139
+ <ColorSliderRoot
140
+ v-if="format === 'rgba'"
141
+ :model-value="color"
142
+ color-space="hsb"
143
+ channel="alpha"
144
+ class="h-4 relative flex w-full touch-none items-center"
145
+ @update:color="pick"
146
+ >
147
+ <ColorSliderTrack class="h-2.5 relative w-full rounded-full" />
148
+ <ColorSliderThumb
149
+ class="size-4 border-white shadow-md block rounded-full border-2 outline-none focus-visible:ring-[3px] focus-visible:ring-xq-ring/40"
150
+ />
151
+ </ColorSliderRoot>
152
+ </PopoverContent>
153
+ </PopoverPortal>
154
+ </PopoverRoot>
155
+ </XqFieldWrapper>
156
+ </template>
@@ -1,21 +1,69 @@
1
1
  <script setup lang="ts">
2
2
  import { getLocalTimeZone, parseDate } from '@internationalized/date';
3
3
  import type { DateValue } from '@internationalized/date';
4
- import { CalendarDays, X } from 'lucide-vue-next';
4
+ import { CalendarDays, LoaderCircle, X } from 'lucide-vue-next';
5
5
  import { PopoverContent, PopoverPortal, PopoverRoot, PopoverTrigger } from 'reka-ui';
6
6
  import { computed, ref } from 'vue';
7
7
  import { useField } from '../../composables/useField';
8
+ import { formatPhpDate } from '../../core/dates';
8
9
  import { cn } from '../../lib/utils';
9
10
  import type { SchemaNode } from '../../schema/types';
10
- import { controlClass, popoverClass } from '../../ui/classes';
11
+ import { controlClass, popoverClass, spinnerClass } from '../../ui/classes';
11
12
  import XqCalendar from '../../ui/XqCalendar.vue';
13
+ import XqTimeField from '../../ui/XqTimeField.vue';
12
14
  import XqFieldWrapper from '../XqFieldWrapper.vue';
15
+ import { useTranslator } from '../../composables/context';
13
16
 
17
+ const t = useTranslator();
14
18
  const props = defineProps<{ node: SchemaNode }>();
15
- const { value, error, describedBy, required, disabled, id, onBlur } = useField<string | null>(
16
- props.node,
17
- );
19
+ const {
20
+ store,
21
+ value,
22
+ error,
23
+ label: fieldLabel,
24
+ placeholder,
25
+ describedBy,
26
+ showLoading,
27
+ required,
28
+ disabled,
29
+ id,
30
+ onBlur,
31
+ } = useField<string | null>(props.node);
18
32
  const open = ref(false);
33
+ const options = computed(() => props.node.props ?? {});
34
+ const hasTime = computed(() => !!options.value.time);
35
+ const hasDate = computed(() => !options.value.timeOnly);
36
+
37
+ /** A time picked before its date has nowhere to go yet, so it waits here. */
38
+ const pendingTime = ref<string | null>(null);
39
+
40
+ const timeText = computed<string | null>({
41
+ get() {
42
+ if (!value.value) {
43
+ return pendingTime.value;
44
+ }
45
+
46
+ return hasDate.value ? value.value.slice(11) || null : value.value;
47
+ },
48
+ set(next) {
49
+ if (!hasDate.value) {
50
+ value.value = next;
51
+ } else if (value.value) {
52
+ // A half-typed time comes through as null, and the value should wait for the rest.
53
+ if (next) {
54
+ value.value = `${value.value.slice(0, 10)} ${next}`;
55
+ }
56
+ } else {
57
+ pendingTime.value = next;
58
+ }
59
+
60
+ onBlur();
61
+ },
62
+ });
63
+
64
+ function midnight(): string {
65
+ return options.value.seconds ? '00:00:00' : '00:00';
66
+ }
19
67
 
20
68
  function parse(input: unknown): DateValue | undefined {
21
69
  if (typeof input !== 'string' || input === '') {
@@ -32,65 +80,113 @@ function parse(input: unknown): DateValue | undefined {
32
80
  const date = computed<DateValue | undefined>({
33
81
  get: () => parse(value.value),
34
82
  set: (next) => {
35
- value.value = next ? next.toString() : null;
83
+ if (next) {
84
+ value.value = hasTime.value
85
+ ? `${next.toString()} ${timeText.value ?? midnight()}`
86
+ : next.toString();
87
+ } else {
88
+ value.value = null;
89
+ }
90
+
36
91
  open.value = false;
37
92
  onBlur();
38
93
  },
39
94
  });
40
95
 
41
- const label = computed(() =>
42
- date.value
43
- ? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(
44
- date.value.toDate(getLocalTimeZone()),
45
- )
46
- : null,
47
- );
96
+ const disabledDates = computed(() => new Set<string>(options.value.disabledDates ?? []));
97
+
98
+ const label = computed(() => {
99
+ if (!date.value) {
100
+ return null;
101
+ }
102
+
103
+ const shown = date.value.toDate(getLocalTimeZone());
104
+
105
+ if (!options.value.displayFormat) {
106
+ return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(shown);
107
+ }
108
+
109
+ const [hours = 0, minutes = 0, seconds = 0] = (timeText.value ?? '').split(':').map(Number);
110
+
111
+ shown.setHours(hours, minutes, seconds);
112
+
113
+ return formatPhpDate(shown, options.value.displayFormat, store.locale);
114
+ });
48
115
  </script>
49
116
 
50
117
  <template>
51
- <XqFieldWrapper :node="node" :id="id" :error="error" :required="required">
52
- <PopoverRoot v-model:open="open">
53
- <div class="relative">
54
- <PopoverTrigger as-child>
118
+ <XqFieldWrapper :node="node" :id="id" :error="error" :required="required" control-shows-loading>
119
+ <div :class="cn('gap-2 grid', hasDate && hasTime && 'sm:grid-cols-[2fr_1fr]')">
120
+ <PopoverRoot v-if="hasDate" v-model:open="open">
121
+ <div class="relative">
122
+ <PopoverTrigger as-child>
123
+ <button
124
+ :id="id"
125
+ type="button"
126
+ :disabled="disabled"
127
+ :aria-invalid="error ? true : undefined"
128
+ :aria-describedby="describedBy"
129
+ :class="
130
+ cn(
131
+ controlClass,
132
+ 'h-9 gap-2 px-3 flex cursor-pointer items-center text-left',
133
+ !label && 'text-xq-muted-foreground',
134
+ )
135
+ "
136
+ >
137
+ <LoaderCircle
138
+ v-if="showLoading"
139
+ :class="spinnerClass"
140
+ aria-hidden="true"
141
+ />
142
+ <CalendarDays v-else class="size-4 shrink-0 opacity-60" />
143
+ <span class="truncate">{{
144
+ label ?? placeholder ?? t('Pick a date')
145
+ }}</span>
146
+ </button>
147
+ </PopoverTrigger>
55
148
  <button
56
- :id="id"
149
+ v-if="label && !required && !disabled"
57
150
  type="button"
58
- :disabled="disabled"
59
- :aria-invalid="error ? true : undefined"
60
- :aria-describedby="describedBy"
61
- :class="
62
- cn(
63
- controlClass,
64
- 'h-9 gap-2 px-3 flex cursor-pointer items-center text-left',
65
- !label && 'text-xq-muted-foreground',
66
- )
67
- "
151
+ class="right-2 size-6 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"
152
+ :aria-label="t('Clear date')"
153
+ @click="date = undefined"
68
154
  >
69
- <CalendarDays class="size-4 shrink-0 opacity-60" />
70
- <span class="truncate">{{
71
- label ?? node.placeholder ?? 'Pick a date'
72
- }}</span>
155
+ <X class="size-3.5" />
73
156
  </button>
74
- </PopoverTrigger>
75
- <button
76
- v-if="label && !required && !disabled"
77
- type="button"
78
- class="right-2 size-6 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"
79
- aria-label="Clear date"
80
- @click="date = undefined"
81
- >
82
- <X class="size-3.5" />
83
- </button>
84
- </div>
85
- <PopoverPortal>
86
- <PopoverContent align="start" :side-offset="4" :class="cn(popoverClass, 'w-auto')">
87
- <XqCalendar
88
- v-model="date"
89
- :min-value="parse(node.props?.min)"
90
- :max-value="parse(node.props?.max)"
91
- />
92
- </PopoverContent>
93
- </PopoverPortal>
94
- </PopoverRoot>
157
+ </div>
158
+ <PopoverPortal>
159
+ <PopoverContent
160
+ align="start"
161
+ :side-offset="4"
162
+ :class="cn(popoverClass, 'w-auto')"
163
+ >
164
+ <XqCalendar
165
+ :locale="store.locale"
166
+ v-model="date"
167
+ :min-value="parse(node.props?.min)"
168
+ :max-value="parse(node.props?.max)"
169
+ :is-date-unavailable="
170
+ disabledDates.size
171
+ ? (day) => disabledDates.has(day.toString())
172
+ : undefined
173
+ "
174
+ />
175
+ </PopoverContent>
176
+ </PopoverPortal>
177
+ </PopoverRoot>
178
+ <XqTimeField
179
+ v-if="hasTime"
180
+ :id="hasDate ? `${id}-time` : id"
181
+ v-model="timeText"
182
+ :locale="store.locale"
183
+ :seconds="!!options.seconds"
184
+ :steps="options.steps"
185
+ :disabled="disabled"
186
+ :invalid="!!error"
187
+ :described-by="describedBy"
188
+ :label="fieldLabel"
189
+ />
190
+ </div>
95
191
  </XqFieldWrapper>
96
192
  </template>