@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.
- package/README.md +2 -1
- package/package.json +1 -1
- package/resources/css/xelqun.css +46 -0
- package/resources/js/components/XqAction.vue +115 -58
- package/resources/js/components/XqActionGroup.vue +145 -0
- package/resources/js/components/XqActions.vue +85 -0
- package/resources/js/components/XqForm.vue +1 -0
- package/resources/js/components/defaults.ts +2 -0
- package/resources/js/core/notifications.ts +64 -0
- package/resources/js/core/store.ts +5 -1
- package/resources/js/index.ts +3 -0
- package/resources/js/lib/utils.ts +9 -0
- package/resources/js/plugin.ts +32 -0
- package/resources/js/schema/types.ts +29 -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/XqNotificationList.vue +124 -0
- package/resources/js/ui/XqNotifications.vue +26 -0
- package/resources/js/ui/classes.ts +23 -0
- package/resources/js/ui/icons.ts +2 -0
|
@@ -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,
|
package/resources/js/index.ts
CHANGED
|
@@ -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
|
*/
|
package/resources/js/plugin.ts
CHANGED
|
@@ -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
|
-
|
|
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>
|
|
@@ -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',
|
package/resources/js/ui/icons.ts
CHANGED
|
@@ -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,
|