@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xelqun/forms",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Vue renderer for Xelqun Forms: PHP-defined, schema-driven forms for Laravel + Inertia.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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, and a new tab isn't a visit.
45
- const isVisit = computed(() => {
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
- if (await store.runAction(props.node, modal.value ?? undefined)) {
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(closeModal);
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
- :disabled="!!options.disabled || running || store.processing.value"
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
- <LoaderCircle v-if="running" class="motion-safe:animate-spin" aria-hidden="true" />
140
- <XqIcon v-else-if="options.icon" :name="options.icon" />
141
- <template v-if="options.style !== 'icon'">{{ options.label }}</template>
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
- <div class="gap-2 flex justify-end">
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 type="submit" size="sm" :disabled="running">
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="running"
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
- </div>
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>
@@ -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
- defineProps<{
17
- title: string;
18
- message?: string | null;
19
- confirmLabel?: string | null;
20
- cancelLabel?: string | null;
21
- confirmVariant?: 'default' | 'destructive';
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="inset-0 bg-black/50 fixed z-50" />
51
+ <AlertDialogOverlay :class="dialogOverlayClass" />
31
52
  <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"
53
+ v-bind="message ? {} : { 'aria-describedby': undefined }"
54
+ :class="cn(dialogContentClass, 'max-w-sm p-6 gap-5 overflow-y-auto')"
33
55
  >
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">
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 ?? 'destructive'"
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
- </div>
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 props = defineProps<{ title: string; description?: string | null; width?: string | null }>();
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="inset-0 bg-black/50 fixed z-50" />
55
+ <DialogOverlay :class="dialogOverlayClass" />
28
56
  <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
- "
57
+ v-bind="description ? {} : { 'aria-describedby': undefined }"
58
+ :class="cn(dialogContentClass, widths[props.width ?? 'md'])"
59
+ @interact-outside="keepOpenUnlessAllowed"
35
60
  >
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 ?? '' }}
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
- <slot />
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',