@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
|
@@ -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 {
|
|
16
|
-
|
|
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
|
-
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
<
|
|
53
|
-
<
|
|
54
|
-
<
|
|
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
|
-
|
|
149
|
+
v-if="label && !required && !disabled"
|
|
57
150
|
type="button"
|
|
58
|
-
|
|
59
|
-
:aria-
|
|
60
|
-
|
|
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
|
-
<
|
|
70
|
-
<span class="truncate">{{
|
|
71
|
-
label ?? node.placeholder ?? 'Pick a date'
|
|
72
|
-
}}</span>
|
|
155
|
+
<X class="size-3.5" />
|
|
73
156
|
</button>
|
|
74
|
-
</
|
|
75
|
-
<
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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>
|