@xelqun/forms 0.3.1 → 0.4.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/package.json +1 -1
- package/resources/css/xelqun.css +30 -0
- package/resources/js/components/XqAction.vue +76 -22
- package/resources/js/components/XqActions.vue +78 -0
- package/resources/js/index.ts +1 -0
- package/resources/js/schema/types.ts +2 -0
- package/resources/js/ui/XqConfirmDialog.vue +52 -17
- package/resources/js/ui/XqDialog.vue +64 -13
- package/resources/js/ui/XqDialogFooter.vue +31 -0
- package/resources/js/ui/XqDialogHeader.vue +38 -0
- package/resources/js/ui/classes.ts +16 -0
package/package.json
CHANGED
package/resources/css/xelqun.css
CHANGED
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
@source "../js";
|
|
7
7
|
|
|
8
8
|
@theme inline {
|
|
9
|
+
--animate-xq-fade-in: xq-fade-in 150ms ease-out;
|
|
10
|
+
--animate-xq-fade-out: xq-fade-out 100ms ease-in forwards;
|
|
11
|
+
--animate-xq-dialog-in: xq-dialog-in 180ms cubic-bezier(0.16, 1, 0.3, 1);
|
|
12
|
+
--animate-xq-dialog-out: xq-dialog-out 100ms ease-in forwards;
|
|
9
13
|
--color-xq-background: var(--xq-background);
|
|
10
14
|
--color-xq-foreground: var(--xq-foreground);
|
|
11
15
|
--color-xq-card: var(--xq-card);
|
|
@@ -281,3 +285,29 @@
|
|
|
281
285
|
max-width: 100%;
|
|
282
286
|
height: auto;
|
|
283
287
|
}
|
|
288
|
+
|
|
289
|
+
@keyframes xq-fade-in {
|
|
290
|
+
from {
|
|
291
|
+
opacity: 0;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
@keyframes xq-fade-out {
|
|
296
|
+
to {
|
|
297
|
+
opacity: 0;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
@keyframes xq-dialog-in {
|
|
302
|
+
from {
|
|
303
|
+
opacity: 0;
|
|
304
|
+
scale: 0.96;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
@keyframes xq-dialog-out {
|
|
309
|
+
to {
|
|
310
|
+
opacity: 0;
|
|
311
|
+
scale: 0.98;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
@@ -8,15 +8,17 @@ export type ActionRunner = Pick<FormStore, 'mountAction' | 'runAction' | 'proces
|
|
|
8
8
|
</script>
|
|
9
9
|
|
|
10
10
|
<script setup lang="ts">
|
|
11
|
-
import { Link } from '@inertiajs/vue3';
|
|
11
|
+
import { Link, router } from '@inertiajs/vue3';
|
|
12
12
|
import { LoaderCircle } from 'lucide-vue-next';
|
|
13
13
|
import { computed, onScopeDispose, ref, shallowRef, watch } from 'vue';
|
|
14
14
|
import { useFormStore } from '../composables/context';
|
|
15
|
-
import { createFormStore } from '../core/store';
|
|
15
|
+
import { createFormStore, LOADING_INDICATOR_DELAY } from '../core/store';
|
|
16
|
+
import { cn } from '../lib/utils';
|
|
16
17
|
import type { SchemaNode } from '../schema/types';
|
|
17
18
|
import XqButton from '../ui/XqButton.vue';
|
|
18
19
|
import XqConfirmDialog from '../ui/XqConfirmDialog.vue';
|
|
19
20
|
import XqDialog from '../ui/XqDialog.vue';
|
|
21
|
+
import XqDialogFooter from '../ui/XqDialogFooter.vue';
|
|
20
22
|
import XqIcon from '../ui/XqIcon.vue';
|
|
21
23
|
import XqForm from './XqForm.vue';
|
|
22
24
|
|
|
@@ -24,6 +26,7 @@ const props = defineProps<{ node: SchemaNode; runner?: ActionRunner }>();
|
|
|
24
26
|
const store = props.runner ?? useFormStore();
|
|
25
27
|
const options = computed(() => props.node.props ?? {});
|
|
26
28
|
const running = ref(false);
|
|
29
|
+
const showsSpinner = ref(false);
|
|
27
30
|
const confirming = ref(false);
|
|
28
31
|
const modalOpen = ref(false);
|
|
29
32
|
const modal = shallowRef<FormStore | null>(null);
|
|
@@ -41,17 +44,26 @@ const variant = computed(() =>
|
|
|
41
44
|
: (variants[options.value.color] ?? 'outline'),
|
|
42
45
|
);
|
|
43
46
|
|
|
44
|
-
// Inertia can only visit its own app
|
|
45
|
-
|
|
46
|
-
if (options.value.openInNewTab) {
|
|
47
|
-
return false;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const url: string = options.value.url;
|
|
51
|
-
|
|
47
|
+
// Inertia can only visit its own app.
|
|
48
|
+
function isOwnUrl(url: string): boolean {
|
|
52
49
|
return typeof window === 'undefined'
|
|
53
50
|
? url.startsWith('/')
|
|
54
51
|
: new URL(url, window.location.href).origin === window.location.origin;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A new tab isn't a visit.
|
|
55
|
+
const isVisit = computed(() => !options.value.openInNewTab && isOwnUrl(options.value.url));
|
|
56
|
+
|
|
57
|
+
let spinnerTimer: ReturnType<typeof setTimeout> | undefined;
|
|
58
|
+
|
|
59
|
+
// A quick action changes nothing on screen until it's done, so it can't flicker.
|
|
60
|
+
watch(running, (isRunning) => {
|
|
61
|
+
clearTimeout(spinnerTimer);
|
|
62
|
+
showsSpinner.value = false;
|
|
63
|
+
|
|
64
|
+
if (isRunning) {
|
|
65
|
+
spinnerTimer = setTimeout(() => (showsSpinner.value = true), LOADING_INDICATOR_DELAY);
|
|
66
|
+
}
|
|
55
67
|
});
|
|
56
68
|
|
|
57
69
|
async function run(): Promise<void> {
|
|
@@ -62,14 +74,29 @@ async function run(): Promise<void> {
|
|
|
62
74
|
|
|
63
75
|
running.value = true;
|
|
64
76
|
|
|
65
|
-
|
|
77
|
+
const result = await store.runAction(props.node, modal.value ?? undefined);
|
|
78
|
+
|
|
79
|
+
if (result) {
|
|
66
80
|
modalOpen.value = false;
|
|
67
81
|
}
|
|
68
82
|
|
|
83
|
+
if (result?.redirect) {
|
|
84
|
+
if (isOwnUrl(result.redirect)) {
|
|
85
|
+
router.visit(result.redirect);
|
|
86
|
+
} else {
|
|
87
|
+
window.location.assign(result.redirect);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
69
91
|
running.value = false;
|
|
70
92
|
}
|
|
71
93
|
|
|
72
94
|
async function open(): Promise<void> {
|
|
95
|
+
// Disabling the button would dim it and drop keyboard focus, so a second click lands here.
|
|
96
|
+
if (running.value) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
73
100
|
if (!options.value.hasForm) {
|
|
74
101
|
if (options.value.confirmation) {
|
|
75
102
|
confirming.value = true;
|
|
@@ -103,7 +130,10 @@ watch(modalOpen, (isOpen) => {
|
|
|
103
130
|
}
|
|
104
131
|
});
|
|
105
132
|
|
|
106
|
-
onScopeDispose(
|
|
133
|
+
onScopeDispose(() => {
|
|
134
|
+
clearTimeout(spinnerTimer);
|
|
135
|
+
closeModal();
|
|
136
|
+
});
|
|
107
137
|
</script>
|
|
108
138
|
|
|
109
139
|
<template>
|
|
@@ -131,20 +161,32 @@ onScopeDispose(closeModal);
|
|
|
131
161
|
type="button"
|
|
132
162
|
:variant="variant"
|
|
133
163
|
:size="options.style === 'icon' ? 'icon-sm' : 'sm'"
|
|
134
|
-
:
|
|
164
|
+
:class="showsSpinner && 'pointer-events-none'"
|
|
165
|
+
:disabled="!!options.disabled || store.processing.value"
|
|
135
166
|
:title="options.tooltip"
|
|
136
167
|
:aria-label="options.style === 'icon' ? options.label : undefined"
|
|
168
|
+
:aria-busy="running || undefined"
|
|
137
169
|
@click="open"
|
|
138
170
|
>
|
|
139
|
-
<
|
|
140
|
-
|
|
141
|
-
|
|
171
|
+
<span :class="cn('gap-1.5 inline-flex items-center', showsSpinner && 'invisible')">
|
|
172
|
+
<XqIcon v-if="options.icon" :name="options.icon" />
|
|
173
|
+
<template v-if="options.style !== 'icon'">{{ options.label }}</template>
|
|
174
|
+
</span>
|
|
175
|
+
<LoaderCircle
|
|
176
|
+
v-if="showsSpinner"
|
|
177
|
+
class="motion-safe:animate-spin absolute"
|
|
178
|
+
aria-hidden="true"
|
|
179
|
+
/>
|
|
142
180
|
</XqButton>
|
|
143
181
|
<XqConfirmDialog
|
|
144
182
|
v-if="options.confirmation && !options.hasForm"
|
|
145
183
|
v-model:open="confirming"
|
|
146
184
|
:title="options.heading"
|
|
147
185
|
:message="options.description ?? store.t('Are you sure?')"
|
|
186
|
+
:icon="options.modalIcon"
|
|
187
|
+
:icon-color="options.modalIconColor ?? options.color"
|
|
188
|
+
:alignment="options.alignment"
|
|
189
|
+
:footer-alignment="options.footerAlignment"
|
|
148
190
|
:confirm-label="options.submitLabel ?? store.t('Confirm')"
|
|
149
191
|
:cancel-label="options.cancelLabel"
|
|
150
192
|
:confirm-variant="variants[options.color] === 'destructive' ? 'destructive' : 'default'"
|
|
@@ -156,10 +198,15 @@ onScopeDispose(closeModal);
|
|
|
156
198
|
:title="options.heading"
|
|
157
199
|
:description="options.description"
|
|
158
200
|
:width="options.width"
|
|
201
|
+
:icon="options.modalIcon"
|
|
202
|
+
:icon-color="options.modalIconColor"
|
|
203
|
+
:alignment="options.alignment"
|
|
204
|
+
:close-button="options.closeButton !== false"
|
|
205
|
+
:close-on-click-away="options.closeOnClickAway !== false"
|
|
159
206
|
>
|
|
160
207
|
<XqForm :form="modal" @submit="run">
|
|
161
208
|
<template #actions>
|
|
162
|
-
<
|
|
209
|
+
<XqDialogFooter sticky :alignment="options.footerAlignment">
|
|
163
210
|
<XqButton
|
|
164
211
|
type="button"
|
|
165
212
|
variant="outline"
|
|
@@ -168,15 +215,22 @@ onScopeDispose(closeModal);
|
|
|
168
215
|
>
|
|
169
216
|
{{ options.cancelLabel ?? store.t('Cancel') }}
|
|
170
217
|
</XqButton>
|
|
171
|
-
<XqButton
|
|
218
|
+
<XqButton
|
|
219
|
+
type="submit"
|
|
220
|
+
size="sm"
|
|
221
|
+
:class="showsSpinner && 'pointer-events-none'"
|
|
222
|
+
:aria-busy="running || undefined"
|
|
223
|
+
>
|
|
224
|
+
<span :class="showsSpinner && 'invisible'">
|
|
225
|
+
{{ options.submitLabel ?? store.t('Save') }}
|
|
226
|
+
</span>
|
|
172
227
|
<LoaderCircle
|
|
173
|
-
v-if="
|
|
174
|
-
class="motion-safe:animate-spin"
|
|
228
|
+
v-if="showsSpinner"
|
|
229
|
+
class="motion-safe:animate-spin absolute"
|
|
175
230
|
aria-hidden="true"
|
|
176
231
|
/>
|
|
177
|
-
{{ options.submitLabel ?? store.t('Save') }}
|
|
178
232
|
</XqButton>
|
|
179
|
-
</
|
|
233
|
+
</XqDialogFooter>
|
|
180
234
|
</template>
|
|
181
235
|
</XqForm>
|
|
182
236
|
</XqDialog>
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { router } from '@inertiajs/vue3';
|
|
3
|
+
import { CircleAlert } from 'lucide-vue-next';
|
|
4
|
+
import { computed, onScopeDispose, shallowRef, watch } from 'vue';
|
|
5
|
+
import { createFormStore } from '../core/store';
|
|
6
|
+
import type { FormSchema } from '../schema/types';
|
|
7
|
+
import XqAction from './XqAction.vue';
|
|
8
|
+
import type { ActionRunner } from './XqAction.vue';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The buttons of an ActionSet, with no form around them.
|
|
12
|
+
*/
|
|
13
|
+
const props = defineProps<{ actions: FormSchema }>();
|
|
14
|
+
const store = shallowRef(createFormStore(props.actions));
|
|
15
|
+
const visibleActions = computed(() => props.actions.components.filter((node) => !node.hidden));
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolves once the page has the new data, so the button stays busy until then.
|
|
19
|
+
*
|
|
20
|
+
* shortcut: a global "before" listener that cancels this leaves the button busy.
|
|
21
|
+
* Forms' own unsaved changes alert ignores reloads, so only an app's own could.
|
|
22
|
+
*/
|
|
23
|
+
function reloadPage(): Promise<void> {
|
|
24
|
+
return new Promise((resolve) => router.reload({ onFinish: () => resolve() }));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// One runner for good: a new one, or a new key, would rebuild the buttons and lose focus.
|
|
28
|
+
const runner: ActionRunner = {
|
|
29
|
+
get processing() {
|
|
30
|
+
return store.value.processing;
|
|
31
|
+
},
|
|
32
|
+
t: (...args) => store.value.t(...args),
|
|
33
|
+
mountAction: (node) => store.value.mountAction(node),
|
|
34
|
+
async runAction(node, modal) {
|
|
35
|
+
const result = await store.value.runAction(node, modal);
|
|
36
|
+
|
|
37
|
+
if (result && !result.redirect) {
|
|
38
|
+
await reloadPage();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return result;
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// With no fields to land on, a failure has to show here.
|
|
46
|
+
const failure = computed(() => {
|
|
47
|
+
const { errors, fatalError, liveError, t } = store.value;
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
fatalError.value ??
|
|
51
|
+
(liveError.value ? t('That action could not run.') : Object.values(errors)[0])
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
watch(
|
|
56
|
+
() => props.actions,
|
|
57
|
+
(next) => {
|
|
58
|
+
store.value.dispose();
|
|
59
|
+
store.value = createFormStore(next);
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
onScopeDispose(() => store.value.dispose());
|
|
64
|
+
</script>
|
|
65
|
+
|
|
66
|
+
<template>
|
|
67
|
+
<div class="gap-2 flex flex-wrap items-center" data-xq-actions>
|
|
68
|
+
<XqAction v-for="node in visibleActions" :key="node.key" :node="node" :runner="runner" />
|
|
69
|
+
<p
|
|
70
|
+
v-if="failure"
|
|
71
|
+
role="alert"
|
|
72
|
+
class="gap-2 text-sm flex basis-full items-start text-xq-destructive"
|
|
73
|
+
>
|
|
74
|
+
<CircleAlert class="mt-0.5 size-4 shrink-0" aria-hidden="true" />
|
|
75
|
+
{{ failure }}
|
|
76
|
+
</p>
|
|
77
|
+
</div>
|
|
78
|
+
</template>
|
package/resources/js/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ export type { XelqunFormsOptions, XelqunPlugin, XelqunPluginContext } from './pl
|
|
|
4
4
|
export { default as XqForm } from './components/XqForm.vue';
|
|
5
5
|
export { default as XqNode } from './components/XqNode.vue';
|
|
6
6
|
export { default as XqAction } from './components/XqAction.vue';
|
|
7
|
+
export { default as XqActions } from './components/XqActions.vue';
|
|
7
8
|
export type { ActionRunner } from './components/XqAction.vue';
|
|
8
9
|
export { default as XqFieldWrapper } from './components/XqFieldWrapper.vue';
|
|
9
10
|
export { createRegistry, defaultComponents } from './components/defaults';
|
|
@@ -173,6 +173,8 @@ export interface UpdatePatch {
|
|
|
173
173
|
|
|
174
174
|
export interface ActionResult extends UpdatePatch {
|
|
175
175
|
result?: unknown;
|
|
176
|
+
/** Where the action sends the user, in place of staying on the page. */
|
|
177
|
+
redirect?: string | null;
|
|
176
178
|
}
|
|
177
179
|
|
|
178
180
|
export interface SelectOption {
|
|
@@ -9,17 +9,38 @@ import {
|
|
|
9
9
|
AlertDialogRoot,
|
|
10
10
|
AlertDialogTitle,
|
|
11
11
|
} from 'reka-ui';
|
|
12
|
-
import XqButton from './XqButton.vue';
|
|
13
12
|
import { useTranslator } from '../composables/context';
|
|
13
|
+
import { cn } from '../lib/utils';
|
|
14
|
+
import { dialogContentClass, dialogOverlayClass } from './classes';
|
|
15
|
+
import XqButton from './XqButton.vue';
|
|
16
|
+
import XqDialogFooter from './XqDialogFooter.vue';
|
|
17
|
+
import XqDialogHeader from './XqDialogHeader.vue';
|
|
14
18
|
|
|
15
19
|
const t = useTranslator();
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
|
|
21
|
+
withDefaults(
|
|
22
|
+
defineProps<{
|
|
23
|
+
title: string;
|
|
24
|
+
message?: string | null;
|
|
25
|
+
confirmLabel?: string | null;
|
|
26
|
+
cancelLabel?: string | null;
|
|
27
|
+
confirmVariant?: 'default' | 'destructive';
|
|
28
|
+
icon?: string | null;
|
|
29
|
+
iconColor?: string | null;
|
|
30
|
+
alignment?: 'start' | 'center' | null;
|
|
31
|
+
footerAlignment?: 'start' | 'center' | 'end' | 'full' | null;
|
|
32
|
+
}>(),
|
|
33
|
+
{
|
|
34
|
+
message: undefined,
|
|
35
|
+
confirmLabel: undefined,
|
|
36
|
+
cancelLabel: undefined,
|
|
37
|
+
confirmVariant: 'destructive',
|
|
38
|
+
icon: 'triangle-alert',
|
|
39
|
+
iconColor: undefined,
|
|
40
|
+
alignment: 'center',
|
|
41
|
+
footerAlignment: 'full',
|
|
42
|
+
},
|
|
43
|
+
);
|
|
23
44
|
defineEmits<{ confirm: [] }>();
|
|
24
45
|
const open = defineModel<boolean>('open', { default: false });
|
|
25
46
|
</script>
|
|
@@ -27,15 +48,29 @@ const open = defineModel<boolean>('open', { default: false });
|
|
|
27
48
|
<template>
|
|
28
49
|
<AlertDialogRoot v-model:open="open">
|
|
29
50
|
<AlertDialogPortal>
|
|
30
|
-
<AlertDialogOverlay class="
|
|
51
|
+
<AlertDialogOverlay :class="dialogOverlayClass" />
|
|
31
52
|
<AlertDialogContent
|
|
32
|
-
|
|
53
|
+
v-bind="message ? {} : { 'aria-describedby': undefined }"
|
|
54
|
+
:class="cn(dialogContentClass, 'max-w-sm p-6 gap-5 overflow-y-auto')"
|
|
33
55
|
>
|
|
34
|
-
<
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
56
|
+
<XqDialogHeader
|
|
57
|
+
:icon="icon"
|
|
58
|
+
:icon-color="
|
|
59
|
+
iconColor ?? (confirmVariant === 'destructive' ? 'danger' : 'primary')
|
|
60
|
+
"
|
|
61
|
+
:alignment="alignment"
|
|
62
|
+
>
|
|
63
|
+
<AlertDialogTitle class="text-base font-semibold text-balance">
|
|
64
|
+
{{ title }}
|
|
65
|
+
</AlertDialogTitle>
|
|
66
|
+
<AlertDialogDescription
|
|
67
|
+
v-if="message"
|
|
68
|
+
class="text-sm text-pretty text-xq-muted-foreground"
|
|
69
|
+
>
|
|
70
|
+
{{ message }}
|
|
71
|
+
</AlertDialogDescription>
|
|
72
|
+
</XqDialogHeader>
|
|
73
|
+
<XqDialogFooter :alignment="footerAlignment">
|
|
39
74
|
<AlertDialogCancel as-child>
|
|
40
75
|
<XqButton type="button" variant="outline" size="sm">
|
|
41
76
|
{{ cancelLabel ?? t('Cancel') }}
|
|
@@ -44,14 +79,14 @@ const open = defineModel<boolean>('open', { default: false });
|
|
|
44
79
|
<AlertDialogAction as-child>
|
|
45
80
|
<XqButton
|
|
46
81
|
type="button"
|
|
47
|
-
:variant="confirmVariant
|
|
82
|
+
:variant="confirmVariant"
|
|
48
83
|
size="sm"
|
|
49
84
|
@click="$emit('confirm')"
|
|
50
85
|
>
|
|
51
86
|
{{ confirmLabel ?? t('Delete') }}
|
|
52
87
|
</XqButton>
|
|
53
88
|
</AlertDialogAction>
|
|
54
|
-
</
|
|
89
|
+
</XqDialogFooter>
|
|
55
90
|
</AlertDialogContent>
|
|
56
91
|
</AlertDialogPortal>
|
|
57
92
|
</AlertDialogRoot>
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import { X } from 'lucide-vue-next';
|
|
2
3
|
import {
|
|
4
|
+
DialogClose,
|
|
3
5
|
DialogContent,
|
|
4
6
|
DialogDescription,
|
|
5
7
|
DialogOverlay,
|
|
@@ -7,9 +9,26 @@ import {
|
|
|
7
9
|
DialogRoot,
|
|
8
10
|
DialogTitle,
|
|
9
11
|
} from 'reka-ui';
|
|
12
|
+
import { useTranslator } from '../composables/context';
|
|
10
13
|
import { cn } from '../lib/utils';
|
|
14
|
+
import { dialogContentClass, dialogOverlayClass } from './classes';
|
|
15
|
+
import XqButton from './XqButton.vue';
|
|
16
|
+
import XqDialogHeader from './XqDialogHeader.vue';
|
|
11
17
|
|
|
12
|
-
const
|
|
18
|
+
const t = useTranslator();
|
|
19
|
+
const props = withDefaults(
|
|
20
|
+
defineProps<{
|
|
21
|
+
title: string;
|
|
22
|
+
description?: string | null;
|
|
23
|
+
width?: string | null;
|
|
24
|
+
icon?: string | null;
|
|
25
|
+
iconColor?: string | null;
|
|
26
|
+
alignment?: 'start' | 'center' | null;
|
|
27
|
+
closeButton?: boolean;
|
|
28
|
+
closeOnClickAway?: boolean;
|
|
29
|
+
}>(),
|
|
30
|
+
{ closeButton: true, closeOnClickAway: true },
|
|
31
|
+
);
|
|
13
32
|
const open = defineModel<boolean>('open', { default: false });
|
|
14
33
|
|
|
15
34
|
const widths: Record<string, string> = {
|
|
@@ -18,28 +37,60 @@ const widths: Record<string, string> = {
|
|
|
18
37
|
lg: 'max-w-lg',
|
|
19
38
|
xl: 'max-w-xl',
|
|
20
39
|
'2xl': 'max-w-2xl',
|
|
40
|
+
'3xl': 'max-w-3xl',
|
|
41
|
+
'4xl': 'max-w-4xl',
|
|
42
|
+
'5xl': 'max-w-5xl',
|
|
21
43
|
};
|
|
44
|
+
|
|
45
|
+
function keepOpenUnlessAllowed(event: Event): void {
|
|
46
|
+
if (!props.closeOnClickAway) {
|
|
47
|
+
event.preventDefault();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
22
50
|
</script>
|
|
23
51
|
|
|
24
52
|
<template>
|
|
25
53
|
<DialogRoot v-model:open="open">
|
|
26
54
|
<DialogPortal>
|
|
27
|
-
<DialogOverlay class="
|
|
55
|
+
<DialogOverlay :class="dialogOverlayClass" />
|
|
28
56
|
<DialogContent
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
widths[props.width ?? 'md'],
|
|
33
|
-
)
|
|
34
|
-
"
|
|
57
|
+
v-bind="description ? {} : { 'aria-describedby': undefined }"
|
|
58
|
+
:class="cn(dialogContentClass, widths[props.width ?? 'md'])"
|
|
59
|
+
@interact-outside="keepOpenUnlessAllowed"
|
|
35
60
|
>
|
|
36
|
-
<
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
61
|
+
<XqDialogHeader
|
|
62
|
+
:icon="icon"
|
|
63
|
+
:icon-color="iconColor"
|
|
64
|
+
:alignment="alignment"
|
|
65
|
+
class="px-6 py-4 min-h-0 overflow-y-auto border-b border-xq-border"
|
|
66
|
+
:class="closeButton && 'pr-12'"
|
|
67
|
+
>
|
|
68
|
+
<DialogTitle class="text-base font-semibold text-balance">
|
|
69
|
+
{{ title }}
|
|
70
|
+
</DialogTitle>
|
|
71
|
+
<DialogDescription
|
|
72
|
+
v-if="description"
|
|
73
|
+
class="text-sm text-pretty text-xq-muted-foreground"
|
|
74
|
+
>
|
|
75
|
+
{{ description }}
|
|
40
76
|
</DialogDescription>
|
|
77
|
+
</XqDialogHeader>
|
|
78
|
+
<!-- A sticky footer can't leave its parent, so it brings its own space. -->
|
|
79
|
+
<div class="px-6 pt-5 pb-6 has-data-xq-dialog-footer:pb-0 min-h-0 overflow-y-auto">
|
|
80
|
+
<slot />
|
|
41
81
|
</div>
|
|
42
|
-
|
|
82
|
+
<!-- Last, so a modal's first field gets the focus and not this. -->
|
|
83
|
+
<DialogClose v-if="closeButton" as-child>
|
|
84
|
+
<XqButton
|
|
85
|
+
type="button"
|
|
86
|
+
variant="ghost"
|
|
87
|
+
size="icon-sm"
|
|
88
|
+
class="top-3.5 right-3.5 absolute text-xq-muted-foreground"
|
|
89
|
+
:aria-label="t('Close')"
|
|
90
|
+
>
|
|
91
|
+
<X />
|
|
92
|
+
</XqButton>
|
|
93
|
+
</DialogClose>
|
|
43
94
|
</DialogContent>
|
|
44
95
|
</DialogPortal>
|
|
45
96
|
</DialogRoot>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { cn } from '../lib/utils';
|
|
3
|
+
|
|
4
|
+
defineProps<{
|
|
5
|
+
alignment?: 'start' | 'center' | 'end' | 'full' | null;
|
|
6
|
+
/** Keeps the buttons in reach while a long form scrolls behind them. */
|
|
7
|
+
sticky?: boolean;
|
|
8
|
+
}>();
|
|
9
|
+
|
|
10
|
+
const alignments: Record<string, string> = {
|
|
11
|
+
start: 'justify-start',
|
|
12
|
+
center: 'justify-center',
|
|
13
|
+
end: 'justify-end',
|
|
14
|
+
full: '*:flex-1',
|
|
15
|
+
};
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<template>
|
|
19
|
+
<div
|
|
20
|
+
data-xq-dialog-footer
|
|
21
|
+
:class="
|
|
22
|
+
cn(
|
|
23
|
+
'gap-2 flex',
|
|
24
|
+
alignments[alignment ?? 'end'] ?? alignments.end,
|
|
25
|
+
sticky && 'bottom-0 -mx-6 px-6 py-4 sticky border-t border-xq-border bg-xq-popover',
|
|
26
|
+
)
|
|
27
|
+
"
|
|
28
|
+
>
|
|
29
|
+
<slot />
|
|
30
|
+
</div>
|
|
31
|
+
</template>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { cn } from '../lib/utils';
|
|
3
|
+
import { dialogIconClasses } from './classes';
|
|
4
|
+
import XqIcon from './XqIcon.vue';
|
|
5
|
+
|
|
6
|
+
defineProps<{
|
|
7
|
+
icon?: string | null;
|
|
8
|
+
iconColor?: string | null;
|
|
9
|
+
alignment?: 'start' | 'center' | null;
|
|
10
|
+
}>();
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<template>
|
|
14
|
+
<div
|
|
15
|
+
:class="
|
|
16
|
+
cn(
|
|
17
|
+
'gap-4 flex',
|
|
18
|
+
alignment === 'center' ? 'flex-col items-center text-center' : 'items-start',
|
|
19
|
+
)
|
|
20
|
+
"
|
|
21
|
+
>
|
|
22
|
+
<span
|
|
23
|
+
v-if="icon"
|
|
24
|
+
data-xq-dialog-icon
|
|
25
|
+
:class="
|
|
26
|
+
cn(
|
|
27
|
+
'size-10 flex shrink-0 items-center justify-center rounded-full',
|
|
28
|
+
dialogIconClasses[iconColor ?? 'primary'] ?? dialogIconClasses.primary,
|
|
29
|
+
)
|
|
30
|
+
"
|
|
31
|
+
>
|
|
32
|
+
<XqIcon :name="icon" class="size-5" />
|
|
33
|
+
</span>
|
|
34
|
+
<div class="gap-1 min-w-0 grid">
|
|
35
|
+
<slot />
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
</template>
|
|
@@ -15,6 +15,22 @@ export const groupLabelClass = 'px-2 py-1.5 text-xs font-medium text-xq-muted-fo
|
|
|
15
15
|
export const optionClass =
|
|
16
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
17
|
|
|
18
|
+
export const dialogOverlayClass =
|
|
19
|
+
'inset-0 bg-black/50 fixed z-50 backdrop-blur-[2px] motion-safe:data-[state=open]:animate-xq-fade-in motion-safe:data-[state=closed]:animate-xq-fade-out';
|
|
20
|
+
|
|
21
|
+
export const dialogContentClass =
|
|
22
|
+
'shadow-xl fixed top-1/2 left-1/2 z-50 flex max-h-[85vh] w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-xq-xl border border-xq-border bg-xq-popover text-xq-popover-foreground outline-none motion-safe:data-[state=open]:animate-xq-dialog-in motion-safe:data-[state=closed]:animate-xq-dialog-out';
|
|
23
|
+
|
|
24
|
+
/** The tinted circle behind a dialog's icon. */
|
|
25
|
+
export const dialogIconClasses: Record<string, string> = {
|
|
26
|
+
primary: 'bg-xq-primary/10 text-xq-primary',
|
|
27
|
+
danger: 'bg-xq-destructive/10 text-xq-destructive',
|
|
28
|
+
info: 'bg-xq-info/10 text-xq-info',
|
|
29
|
+
success: 'bg-xq-success/10 text-xq-success',
|
|
30
|
+
warning: 'bg-xq-warning/10 text-xq-warning',
|
|
31
|
+
gray: 'bg-xq-muted text-xq-muted-foreground',
|
|
32
|
+
};
|
|
33
|
+
|
|
18
34
|
/** Text colors the PHP side can name. */
|
|
19
35
|
export const toneClasses: Record<string, string> = {
|
|
20
36
|
neutral: 'text-xq-foreground',
|