@xelqun/forms 0.3.1 → 0.5.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.
@@ -23,6 +23,7 @@ import { HttpError, postJson, uploadFile } from './http';
23
23
  import { eachNode, mergeNode } from './patch';
24
24
  import { clone, deletePath, getPath, overlaps, replaceRow, setPath, startsWithPath } from './paths';
25
25
  import { RequestLog } from './log';
26
+ import { notify as showNotification } from './notifications';
26
27
  import { RequestQueue } from './queue';
27
28
  import { newRowKey } from './rows';
28
29
  import { translate } from './translate';
@@ -470,6 +471,8 @@ export function createFormStore(schema: FormSchema, options: FormStoreOptions =
470
471
  }
471
472
 
472
473
  function applyPatch(patch: UpdatePatch, versions: Map<string, number>): void {
474
+ patch.notifications?.forEach(showNotification);
475
+
473
476
  for (const [key, partial] of Object.entries(patch.schema ?? {})) {
474
477
  const node = nodes.get(key);
475
478
 
@@ -892,7 +895,7 @@ export function createFormStore(schema: FormSchema, options: FormStoreOptions =
892
895
  }
893
896
 
894
897
  async function submit(url: string, submitOptions: SubmitOptions = {}): Promise<void> {
895
- if (processing.value) {
898
+ if (processing.value || schema.disabled) {
896
899
  return;
897
900
  }
898
901
 
@@ -1033,6 +1036,7 @@ export function createFormStore(schema: FormSchema, options: FormStoreOptions =
1033
1036
  isVisible,
1034
1037
  isRequired,
1035
1038
  isDisabled,
1039
+ isFormDisabled: !!schema.disabled,
1036
1040
  text,
1037
1041
  isLoading,
1038
1042
  showsLoadingIndicator,
@@ -4,6 +4,8 @@ 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 XqActionGroup } from './components/XqActionGroup.vue';
8
+ export { default as XqActions } from './components/XqActions.vue';
7
9
  export type { ActionRunner } from './components/XqAction.vue';
8
10
  export { default as XqFieldWrapper } from './components/XqFieldWrapper.vue';
9
11
  export { createRegistry, defaultComponents } from './components/defaults';
@@ -19,6 +21,7 @@ export { createFormStore, defaultLiveConfig, reportRequestFailure } from './core
19
21
  export type { ActionModal, FormStore, FormStoreOptions, SubmitOptions } from './core/store';
20
22
  export type { RequestKind, RequestLogEntry } from './core/log';
21
23
  export { HttpError, postJson } from './core/http';
24
+ export { notify } from './core/notifications';
22
25
  export { translate } from './core/translate';
23
26
  export { evaluateCondition } from './core/conditions';
24
27
  export { evaluateExpression } from './core/expressions';
@@ -6,6 +6,15 @@ export function cn(...inputs: ClassValue[]): string {
6
6
  return twMerge(clsx(inputs));
7
7
  }
8
8
 
9
+ /**
10
+ * Inertia can only visit its own app.
11
+ */
12
+ export function isOwnUrl(url: string): boolean {
13
+ return typeof window === 'undefined'
14
+ ? url.startsWith('/')
15
+ : new URL(url, window.location.href).origin === window.location.origin;
16
+ }
17
+
9
18
  /**
10
19
  * Keep keyboard users in place: focus the first of these that can take it.
11
20
  */
@@ -1,8 +1,13 @@
1
+ import { router } from '@inertiajs/vue3';
2
+ import { createApp } from 'vue';
1
3
  import type { App, Component, Plugin } from 'vue';
2
4
  import { createRegistry, defaultComponents } from './components/defaults';
3
5
  import { REGISTRY, toComponent } from './composables/registry';
4
6
  import type { ComponentRegistry, ComponentResolver } from './composables/registry';
7
+ import { FLASH_KEY, notify } from './core/notifications';
8
+ import type { XqNotification } from './schema/types';
5
9
  import { registerIcons } from './ui/icons';
10
+ import XqNotifications from './ui/XqNotifications.vue';
6
11
 
7
12
  export interface XelqunPluginContext {
8
13
  app: App;
@@ -30,6 +35,8 @@ export interface XelqunFormsOptions {
30
35
  components?: Record<string, ComponentResolver>;
31
36
  /** Icons PHP can name, as { name: component }, on top of the built-in set. */
32
37
  icons?: Record<string, Component>;
38
+ /** What screen readers call the notifications region. {hotkey} is the key that jumps to it. */
39
+ notificationsLabel?: string;
33
40
  }
34
41
 
35
42
  const VENDOR_PREFIXED_TYPE = /^[a-z0-9-]+\/[a-z0-9-]+$/;
@@ -93,6 +100,27 @@ export function registerPlugins(
93
100
  }
94
101
  }
95
102
 
103
+ /**
104
+ * Put the toaster on the page and listen for flash data. Returns how to undo both.
105
+ */
106
+ function mountNotifications(label?: string): () => void {
107
+ const host = document.body.appendChild(document.createElement('div'));
108
+ const toaster = createApp(XqNotifications, { label });
109
+ const stopListening = router.on('flash', (event) => {
110
+ const { [FLASH_KEY]: notifications } = event.detail.flash;
111
+
112
+ (notifications as XqNotification[] | undefined)?.forEach(notify);
113
+ });
114
+
115
+ toaster.mount(host);
116
+
117
+ return () => {
118
+ stopListening();
119
+ toaster.unmount();
120
+ host.remove();
121
+ };
122
+ }
123
+
96
124
  export function createXelqunForms(options: XelqunFormsOptions = {}): Plugin {
97
125
  return {
98
126
  install(app) {
@@ -109,6 +137,10 @@ export function createXelqunForms(options: XelqunFormsOptions = {}): Plugin {
109
137
 
110
138
  plugins.forEach((plugin) => plugin.setup?.({ app }));
111
139
  app.provide(REGISTRY, registry);
140
+
141
+ if (typeof document !== 'undefined') {
142
+ app.onUnmount(mountNotifications(options.notificationsLabel));
143
+ }
112
144
  },
113
145
  };
114
146
  }
@@ -135,6 +135,7 @@ export interface FormSchema {
135
135
  /** Text in this locale, by its English original. Anything missing stays English. */
136
136
  messages?: Record<string, string>;
137
137
  unsavedChangesAlert?: boolean;
138
+ disabled?: boolean;
138
139
  state: { data: Record<string, any> };
139
140
  components: SchemaNode[];
140
141
  }
@@ -169,10 +170,38 @@ export interface UpdatePatch {
169
170
  set: Record<string, unknown>;
170
171
  schema: Record<string, Partial<SchemaNode>>;
171
172
  errors: Record<string, string | string[]>;
173
+ /** Left out when there are none. */
174
+ notifications?: XqNotification[];
175
+ }
176
+
177
+ export interface XqNotification {
178
+ title?: string | null;
179
+ body?: string | null;
180
+ status?: 'success' | 'danger' | 'warning' | 'info' | null;
181
+ icon?: string | null;
182
+ /** In milliseconds. */
183
+ duration?: number | null;
184
+ persistent?: boolean;
185
+ actions?: XqNotificationAction[];
186
+ closeLabel?: string;
187
+ }
188
+
189
+ export interface XqNotificationAction {
190
+ label: string;
191
+ icon?: string | null;
192
+ color?: string | null;
193
+ /** Close the notification once the button has done its work. */
194
+ close: boolean;
195
+ url?: string;
196
+ openInNewTab?: boolean;
197
+ run?: { endpoint: string; token: string; action: string };
198
+ failureMessage?: string;
172
199
  }
173
200
 
174
201
  export interface ActionResult extends UpdatePatch {
175
202
  result?: unknown;
203
+ /** Where the action sends the user, in place of staying on the page. */
204
+ redirect?: string | null;
176
205
  }
177
206
 
178
207
  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>
@@ -0,0 +1,124 @@
1
+ <script setup lang="ts">
2
+ import { router } from '@inertiajs/vue3';
3
+ import { X } from 'lucide-vue-next';
4
+ import { ToastClose, ToastDescription, ToastRoot, ToastTitle, useToastManager } from 'reka-ui';
5
+ import type { ToastObject } from 'reka-ui';
6
+ import { reactive } from 'vue';
7
+ import { runNotificationAction } from '../core/notifications';
8
+ import { cn, isOwnUrl } from '../lib/utils';
9
+ import type { XqNotificationAction } from '../schema/types';
10
+ import { actionVariants } from './classes';
11
+ import XqButton from './XqButton.vue';
12
+ import XqIcon from './XqIcon.vue';
13
+
14
+ const { toasts, close } = useToastManager();
15
+ const running = reactive(new Set<string>());
16
+
17
+ const statusIcons: Record<string, string> = {
18
+ success: 'circle-check',
19
+ danger: 'circle-alert',
20
+ warning: 'triangle-alert',
21
+ info: 'info',
22
+ };
23
+
24
+ function iconFor(toast: ToastObject): string | undefined {
25
+ return toast.data?.icon ?? statusIcons[toast.status ?? ''];
26
+ }
27
+
28
+ const iconClasses: Record<string, string> = {
29
+ success: 'text-xq-success',
30
+ danger: 'text-xq-destructive',
31
+ warning: 'text-xq-warning',
32
+ info: 'text-xq-info',
33
+ };
34
+
35
+ async function press(toast: ToastObject, action: XqNotificationAction): Promise<void> {
36
+ // A second click on "Undo" would run it twice.
37
+ if (running.has(toast.id)) {
38
+ return;
39
+ }
40
+
41
+ running.add(toast.id);
42
+
43
+ const hasWorked = !action.run || (await runNotificationAction(action));
44
+
45
+ running.delete(toast.id);
46
+
47
+ if (hasWorked && action.close) {
48
+ close(toast.id);
49
+ }
50
+ }
51
+
52
+ function follow(event: MouseEvent, toast: ToastObject, action: XqNotificationAction): void {
53
+ const opensElsewhere = event.ctrlKey || event.metaKey || event.shiftKey || action.openInNewTab;
54
+
55
+ if (action.close) {
56
+ close(toast.id);
57
+ }
58
+
59
+ if (!opensElsewhere && isOwnUrl(action.url!)) {
60
+ event.preventDefault();
61
+ router.visit(action.url!);
62
+ }
63
+ }
64
+ </script>
65
+
66
+ <template>
67
+ <ToastRoot
68
+ v-for="toast in toasts"
69
+ :key="toast.id"
70
+ :toast="toast"
71
+ class="gap-3 p-3 shadow-lg inset-x-0 top-0 ease-out data-[swipe=cancel]:translate-x-0 absolute flex [transform:translateY(calc(var(--reka-toast-offset-y)+var(--reka-toast-index)*0.5rem))] items-start rounded-xq-md border border-xq-border bg-xq-popover text-xq-popover-foreground transition-[transform,opacity] duration-200 outline-none focus-visible:ring-[3px] focus-visible:ring-xq-ring/25 data-[limited]:invisible data-[swipe=end]:translate-x-(--reka-toast-swipe-end-x) data-[swipe=move]:translate-x-(--reka-toast-swipe-move-x) data-[swipe=move]:transition-none motion-safe:data-[state=closed]:animate-xq-toast-out motion-safe:data-[state=open]:animate-xq-toast-in motion-reduce:transition-none"
72
+ >
73
+ <XqIcon
74
+ v-if="iconFor(toast)"
75
+ :name="iconFor(toast)!"
76
+ :class="cn('mt-0.5', iconClasses[toast.status ?? ''])"
77
+ />
78
+ <div class="min-w-0 flex-1 wrap-break-word">
79
+ <ToastTitle v-if="toast.title" class="text-sm font-medium">
80
+ {{ toast.title }}
81
+ </ToastTitle>
82
+ <ToastDescription v-if="toast.description" class="text-sm text-xq-muted-foreground">
83
+ {{ toast.description }}
84
+ </ToastDescription>
85
+ <div v-if="toast.data?.actions?.length" class="mt-2 gap-2 flex flex-wrap">
86
+ <template v-for="action in toast.data.actions" :key="action.label">
87
+ <XqButton
88
+ v-if="action.url"
89
+ as-child
90
+ size="sm"
91
+ :variant="actionVariants[action.color ?? ''] ?? 'outline'"
92
+ >
93
+ <a
94
+ :href="action.url"
95
+ :target="action.openInNewTab ? '_blank' : undefined"
96
+ :rel="action.openInNewTab ? 'noopener noreferrer' : undefined"
97
+ @click="follow($event, toast, action)"
98
+ >
99
+ <XqIcon v-if="action.icon" :name="action.icon" />
100
+ {{ action.label }}
101
+ </a>
102
+ </XqButton>
103
+ <XqButton
104
+ v-else
105
+ type="button"
106
+ size="sm"
107
+ :variant="actionVariants[action.color ?? ''] ?? 'outline'"
108
+ :aria-busy="running.has(toast.id) || undefined"
109
+ @click="press(toast, action)"
110
+ >
111
+ <XqIcon v-if="action.icon" :name="action.icon" />
112
+ {{ action.label }}
113
+ </XqButton>
114
+ </template>
115
+ </div>
116
+ </div>
117
+ <ToastClose
118
+ :aria-label="toast.data?.closeLabel ?? 'Close'"
119
+ class="rounded-xq-sm text-xq-muted-foreground outline-none hover:text-xq-foreground focus-visible:ring-[3px] focus-visible:ring-xq-ring/25"
120
+ >
121
+ <X class="size-4" aria-hidden="true" />
122
+ </ToastClose>
123
+ </ToastRoot>
124
+ </template>
@@ -0,0 +1,26 @@
1
+ <script setup lang="ts">
2
+ import { ToastProvider, ToastViewport } from 'reka-ui';
3
+ import { notificationManager } from '../core/notifications';
4
+ import XqNotificationList from './XqNotificationList.vue';
5
+
6
+ /**
7
+ * Shows every notification. createXelqunForms mounts it as an app of its own,
8
+ * because Reka keeps toasts in the provider, and a layout that changes between
9
+ * pages would take them with it.
10
+ *
11
+ * It sits above dialogs, and takes pointer events back from an open one, which
12
+ * turns them off for the whole page.
13
+ */
14
+ withDefaults(defineProps<{ label?: string }>(), { label: 'Notifications ({hotkey})' });
15
+ </script>
16
+
17
+ <template>
18
+ <ToastProvider :toast-manager="notificationManager" :limit="5">
19
+ <XqNotificationList />
20
+ <ToastViewport
21
+ :label="label"
22
+ data-slot="xq-notifications"
23
+ class="top-4 right-4 max-w-sm pointer-events-auto fixed z-[60] w-[calc(100%-2rem)] outline-none"
24
+ />
25
+ </ToastProvider>
26
+ </template>
@@ -5,6 +5,13 @@
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
+ /** How an action's color looks on a button. */
9
+ export const actionVariants: Record<string, 'default' | 'destructive' | 'outline'> = {
10
+ primary: 'default',
11
+ danger: 'destructive',
12
+ gray: 'outline',
13
+ };
14
+
8
15
  export const spinnerClass = 'size-4 shrink-0 motion-safe:animate-spin text-xq-muted-foreground';
9
16
 
10
17
  export const popoverClass =
@@ -15,6 +22,22 @@ export const groupLabelClass = 'px-2 py-1.5 text-xs font-medium text-xq-muted-fo
15
22
  export const optionClass =
16
23
  '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
24
 
25
+ export const dialogOverlayClass =
26
+ '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';
27
+
28
+ export const dialogContentClass =
29
+ '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';
30
+
31
+ /** The tinted circle behind a dialog's icon. */
32
+ export const dialogIconClasses: Record<string, string> = {
33
+ primary: 'bg-xq-primary/10 text-xq-primary',
34
+ danger: 'bg-xq-destructive/10 text-xq-destructive',
35
+ info: 'bg-xq-info/10 text-xq-info',
36
+ success: 'bg-xq-success/10 text-xq-success',
37
+ warning: 'bg-xq-warning/10 text-xq-warning',
38
+ gray: 'bg-xq-muted text-xq-muted-foreground',
39
+ };
40
+
18
41
  /** Text colors the PHP side can name. */
19
42
  export const toneClasses: Record<string, string> = {
20
43
  neutral: 'text-xq-foreground',
@@ -8,6 +8,7 @@ import {
8
8
  Clock,
9
9
  CreditCard,
10
10
  DollarSign,
11
+ EllipsisVertical,
11
12
  Eye,
12
13
  File,
13
14
  Folder,
@@ -53,6 +54,7 @@ const icons: Record<string, Component> = {
53
54
  clock: Clock,
54
55
  'credit-card': CreditCard,
55
56
  'dollar-sign': DollarSign,
57
+ 'ellipsis-vertical': EllipsisVertical,
56
58
  eye: Eye,
57
59
  file: File,
58
60
  folder: Folder,