@xelqun/forms 0.4.0 → 0.6.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 CHANGED
@@ -41,7 +41,8 @@ class OrderForm extends Form
41
41
  - **Secure by default.** Forms rebuild from an encrypted token, and every endpoint re-runs `authorize()` and validation.
42
42
  - **Repeaters and uploads included.** Nested rows map to validation errors, and files upload as soon as they're picked.
43
43
  - **Every field you'd expect.** Select with relationships, date and time pickers, tags, sliders, colors, a block `Builder`, and rich text, Markdown and code editors.
44
- - **Wizards and actions.** Multi-step forms check each step on the server, and buttons can run PHP through a modal with its own fields.
44
+ - **Wizards and actions.** Multi-step forms check each step on the server, and buttons can run PHP through a modal with its own fields. Group them in a menu with `ActionGroup`.
45
+ - **Notifications.** Send one from an action, a hook or a controller with `Notification::make()`, and it reaches the page by itself. Add buttons with `NotificationAction`.
45
46
 
46
47
  ## Installation
47
48
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xelqun/forms",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Vue renderer for Xelqun Forms: PHP-defined, schema-driven forms for Laravel + Inertia.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -8,6 +8,8 @@
8
8
  @theme inline {
9
9
  --animate-xq-fade-in: xq-fade-in 150ms ease-out;
10
10
  --animate-xq-fade-out: xq-fade-out 100ms ease-in forwards;
11
+ --animate-xq-toast-in: xq-toast-in 200ms cubic-bezier(0.16, 1, 0.3, 1);
12
+ --animate-xq-toast-out: xq-toast-out 150ms ease-in forwards;
11
13
  --animate-xq-dialog-in: xq-dialog-in 180ms cubic-bezier(0.16, 1, 0.3, 1);
12
14
  --animate-xq-dialog-out: xq-dialog-out 100ms ease-in forwards;
13
15
  --color-xq-background: var(--xq-background);
@@ -298,6 +300,20 @@
298
300
  }
299
301
  }
300
302
 
303
+ @keyframes xq-toast-in {
304
+ from {
305
+ opacity: 0;
306
+ translate: 0 -0.5rem;
307
+ }
308
+ }
309
+
310
+ @keyframes xq-toast-out {
311
+ to {
312
+ opacity: 0;
313
+ translate: 1rem 0;
314
+ }
315
+ }
316
+
301
317
  @keyframes xq-dialog-in {
302
318
  from {
303
319
  opacity: 0;
@@ -13,8 +13,9 @@ import { LoaderCircle } from 'lucide-vue-next';
13
13
  import { computed, onScopeDispose, ref, shallowRef, watch } from 'vue';
14
14
  import { useFormStore } from '../composables/context';
15
15
  import { createFormStore, LOADING_INDICATOR_DELAY } from '../core/store';
16
- import { cn } from '../lib/utils';
16
+ import { cn, isOwnUrl } from '../lib/utils';
17
17
  import type { SchemaNode } from '../schema/types';
18
+ import { actionVariants } from '../ui/classes';
18
19
  import XqButton from '../ui/XqButton.vue';
19
20
  import XqConfirmDialog from '../ui/XqConfirmDialog.vue';
20
21
  import XqDialog from '../ui/XqDialog.vue';
@@ -22,7 +23,13 @@ import XqDialogFooter from '../ui/XqDialogFooter.vue';
22
23
  import XqIcon from '../ui/XqIcon.vue';
23
24
  import XqForm from './XqForm.vue';
24
25
 
25
- const props = defineProps<{ node: SchemaNode; runner?: ActionRunner }>();
26
+ const props = defineProps<{
27
+ node: SchemaNode;
28
+ runner?: ActionRunner;
29
+ /** Keep the dialogs and leave the button out, for a menu that has its own. */
30
+ headless?: boolean;
31
+ }>();
32
+ const emit = defineEmits<{ spinner: [showsSpinner: boolean] }>();
26
33
  const store = props.runner ?? useFormStore();
27
34
  const options = computed(() => props.node.props ?? {});
28
35
  const running = ref(false);
@@ -31,26 +38,14 @@ const confirming = ref(false);
31
38
  const modalOpen = ref(false);
32
39
  const modal = shallowRef<FormStore | null>(null);
33
40
 
34
- const variants: Record<string, 'default' | 'destructive' | 'outline'> = {
35
- primary: 'default',
36
- danger: 'destructive',
37
- gray: 'outline',
38
- };
39
41
  const variant = computed(() =>
40
42
  options.value.style === 'link'
41
43
  ? 'link'
42
44
  : options.value.style === 'icon'
43
45
  ? 'ghost'
44
- : (variants[options.value.color] ?? 'outline'),
46
+ : (actionVariants[options.value.color] ?? 'outline'),
45
47
  );
46
48
 
47
- // Inertia can only visit its own app.
48
- function isOwnUrl(url: string): boolean {
49
- return typeof window === 'undefined'
50
- ? url.startsWith('/')
51
- : new URL(url, window.location.href).origin === window.location.origin;
52
- }
53
-
54
49
  // A new tab isn't a visit.
55
50
  const isVisit = computed(() => !options.value.openInNewTab && isOwnUrl(options.value.url));
56
51
 
@@ -130,6 +125,10 @@ watch(modalOpen, (isOpen) => {
130
125
  }
131
126
  });
132
127
 
128
+ watch(showsSpinner, (shows) => emit('spinner', shows));
129
+
130
+ defineExpose({ open });
131
+
133
132
  onScopeDispose(() => {
134
133
  clearTimeout(spinnerTimer);
135
134
  closeModal();
@@ -138,46 +137,48 @@ onScopeDispose(() => {
138
137
 
139
138
  <template>
140
139
  <div class="inline-flex" data-xq-action>
141
- <XqButton
142
- v-if="options.url"
143
- as-child
144
- :variant="variant"
145
- :size="options.style === 'icon' ? 'icon-sm' : 'sm'"
146
- :title="options.tooltip"
147
- :aria-label="options.style === 'icon' ? options.label : undefined"
148
- >
149
- <component
150
- :is="isVisit ? Link : 'a'"
151
- :href="options.url"
152
- :target="options.openInNewTab ? '_blank' : undefined"
153
- :rel="options.openInNewTab ? 'noopener noreferrer' : undefined"
140
+ <template v-if="!headless">
141
+ <XqButton
142
+ v-if="options.url"
143
+ as-child
144
+ :variant="variant"
145
+ :size="options.style === 'icon' ? 'icon-sm' : 'sm'"
146
+ :title="options.tooltip"
147
+ :aria-label="options.style === 'icon' ? options.label : undefined"
154
148
  >
155
- <XqIcon v-if="options.icon" :name="options.icon" />
156
- <template v-if="options.style !== 'icon'">{{ options.label }}</template>
157
- </component>
158
- </XqButton>
159
- <XqButton
160
- v-else
161
- type="button"
162
- :variant="variant"
163
- :size="options.style === 'icon' ? 'icon-sm' : 'sm'"
164
- :class="showsSpinner && 'pointer-events-none'"
165
- :disabled="!!options.disabled || store.processing.value"
166
- :title="options.tooltip"
167
- :aria-label="options.style === 'icon' ? options.label : undefined"
168
- :aria-busy="running || undefined"
169
- @click="open"
170
- >
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
- />
180
- </XqButton>
149
+ <component
150
+ :is="isVisit ? Link : 'a'"
151
+ :href="options.url"
152
+ :target="options.openInNewTab ? '_blank' : undefined"
153
+ :rel="options.openInNewTab ? 'noopener noreferrer' : undefined"
154
+ >
155
+ <XqIcon v-if="options.icon" :name="options.icon" />
156
+ <template v-if="options.style !== 'icon'">{{ options.label }}</template>
157
+ </component>
158
+ </XqButton>
159
+ <XqButton
160
+ v-else
161
+ type="button"
162
+ :variant="variant"
163
+ :size="options.style === 'icon' ? 'icon-sm' : 'sm'"
164
+ :class="showsSpinner && 'pointer-events-none'"
165
+ :disabled="!!options.disabled || store.processing.value"
166
+ :title="options.tooltip"
167
+ :aria-label="options.style === 'icon' ? options.label : undefined"
168
+ :aria-busy="running || undefined"
169
+ @click="open"
170
+ >
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
+ />
180
+ </XqButton>
181
+ </template>
181
182
  <XqConfirmDialog
182
183
  v-if="options.confirmation && !options.hasForm"
183
184
  v-model:open="confirming"
@@ -189,7 +190,9 @@ onScopeDispose(() => {
189
190
  :footer-alignment="options.footerAlignment"
190
191
  :confirm-label="options.submitLabel ?? store.t('Confirm')"
191
192
  :cancel-label="options.cancelLabel"
192
- :confirm-variant="variants[options.color] === 'destructive' ? 'destructive' : 'default'"
193
+ :confirm-variant="
194
+ actionVariants[options.color] === 'destructive' ? 'destructive' : 'default'
195
+ "
193
196
  @confirm="run"
194
197
  />
195
198
  <XqDialog
@@ -0,0 +1,145 @@
1
+ <script setup lang="ts">
2
+ import { Link } from '@inertiajs/vue3';
3
+ import { LoaderCircle } from 'lucide-vue-next';
4
+ import {
5
+ DropdownMenuContent,
6
+ DropdownMenuItem,
7
+ DropdownMenuPortal,
8
+ DropdownMenuRoot,
9
+ DropdownMenuTrigger,
10
+ } from 'reka-ui';
11
+ import { computed, ref } from 'vue';
12
+ import type { ComponentPublicInstance } from 'vue';
13
+ import { useFormStore } from '../composables/context';
14
+ import { cn, isOwnUrl } from '../lib/utils';
15
+ import type { SchemaNode } from '../schema/types';
16
+ import { actionVariants, optionClass, popoverClass } from '../ui/classes';
17
+ import XqButton from '../ui/XqButton.vue';
18
+ import XqIcon from '../ui/XqIcon.vue';
19
+ import XqAction from './XqAction.vue';
20
+ import type { ActionRunner } from './XqAction.vue';
21
+
22
+ /**
23
+ * A menu of actions. Closing a menu destroys what's in it, so each item only
24
+ * asks an XqAction outside the menu to open, and the dialogs live there.
25
+ */
26
+ const props = defineProps<{ node: SchemaNode; runner?: ActionRunner }>();
27
+ const form = props.runner ? null : useFormStore();
28
+ const store = props.runner ?? form!;
29
+ const options = computed(() => props.node.props ?? {});
30
+ const items = computed(() =>
31
+ (props.node.children ?? []).filter((item) => form?.isVisible(item) ?? !item.hidden),
32
+ );
33
+ const headlessActions = computed(() => items.value.filter((item) => !item.props?.url));
34
+ const hasLabel = computed(() => !!options.value.label);
35
+ const showsSpinner = ref(false);
36
+ const actions = new Map<string, { open: () => Promise<void> }>();
37
+ let selected: string | null = null;
38
+
39
+ function remember(key: string, action: Element | ComponentPublicInstance | null): void {
40
+ if (action) {
41
+ actions.set(key, action as unknown as { open: () => Promise<void> });
42
+ } else {
43
+ actions.delete(key);
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Waits for the menu to give focus back to its button, so that a dialog, which
49
+ * returns focus to wherever it found it, comes back to the button too.
50
+ */
51
+ function openSelected(): void {
52
+ if (selected) {
53
+ void actions.get(selected)?.open();
54
+ selected = null;
55
+ }
56
+ }
57
+ </script>
58
+
59
+ <template>
60
+ <div class="inline-flex" data-xq-action-group>
61
+ <DropdownMenuRoot v-if="items.length > 0" :modal="false">
62
+ <DropdownMenuTrigger as-child>
63
+ <XqButton
64
+ type="button"
65
+ :variant="hasLabel ? (actionVariants[options.color] ?? 'outline') : 'ghost'"
66
+ :size="hasLabel ? 'sm' : 'icon-sm'"
67
+ :class="showsSpinner && 'pointer-events-none'"
68
+ :disabled="store.processing.value"
69
+ :title="options.tooltip"
70
+ :aria-label="hasLabel ? undefined : store.t('Actions')"
71
+ :aria-busy="showsSpinner || undefined"
72
+ >
73
+ <span
74
+ :class="cn('gap-1.5 inline-flex items-center', showsSpinner && 'invisible')"
75
+ >
76
+ <XqIcon :name="options.icon ?? 'ellipsis-vertical'" />
77
+ <template v-if="hasLabel">{{ options.label }}</template>
78
+ </span>
79
+ <LoaderCircle
80
+ v-if="showsSpinner"
81
+ class="motion-safe:animate-spin absolute"
82
+ aria-hidden="true"
83
+ />
84
+ </XqButton>
85
+ </DropdownMenuTrigger>
86
+ <DropdownMenuPortal>
87
+ <DropdownMenuContent
88
+ align="end"
89
+ :side-offset="4"
90
+ :class="cn(popoverClass, 'p-1 min-w-40')"
91
+ @close-auto-focus="openSelected"
92
+ >
93
+ <template v-for="item in items" :key="item.key">
94
+ <DropdownMenuItem
95
+ v-if="item.props?.url"
96
+ as-child
97
+ :class="cn(optionClass, 'pr-2')"
98
+ >
99
+ <component
100
+ :is="
101
+ !item.props.openInNewTab && isOwnUrl(item.props.url)
102
+ ? Link
103
+ : 'a'
104
+ "
105
+ :href="item.props.url"
106
+ :target="item.props.openInNewTab ? '_blank' : undefined"
107
+ :rel="item.props.openInNewTab ? 'noopener noreferrer' : undefined"
108
+ >
109
+ <XqIcon v-if="item.props.icon" :name="item.props.icon" />
110
+ {{ item.props.label }}
111
+ </component>
112
+ </DropdownMenuItem>
113
+ <DropdownMenuItem
114
+ v-else
115
+ :disabled="!!item.props?.disabled"
116
+ :class="
117
+ cn(
118
+ optionClass,
119
+ 'pr-2',
120
+ item.props?.color === 'danger' &&
121
+ 'text-xq-destructive data-[highlighted]:text-xq-destructive',
122
+ )
123
+ "
124
+ @select="selected = item.key"
125
+ >
126
+ <XqIcon v-if="item.props?.icon" :name="item.props.icon" />
127
+ {{ item.props?.label }}
128
+ </DropdownMenuItem>
129
+ </template>
130
+ </DropdownMenuContent>
131
+ </DropdownMenuPortal>
132
+ </DropdownMenuRoot>
133
+ <div hidden>
134
+ <XqAction
135
+ v-for="item in headlessActions"
136
+ :key="item.key"
137
+ :ref="(action) => remember(item.key, action)"
138
+ :node="item"
139
+ :runner="store"
140
+ headless
141
+ @spinner="showsSpinner = $event"
142
+ />
143
+ </div>
144
+ </div>
145
+ </template>
@@ -5,6 +5,7 @@ import { computed, onScopeDispose, shallowRef, watch } from 'vue';
5
5
  import { createFormStore } from '../core/store';
6
6
  import type { FormSchema } from '../schema/types';
7
7
  import XqAction from './XqAction.vue';
8
+ import XqActionGroup from './XqActionGroup.vue';
8
9
  import type { ActionRunner } from './XqAction.vue';
9
10
 
10
11
  /**
@@ -65,7 +66,13 @@ onScopeDispose(() => store.value.dispose());
65
66
 
66
67
  <template>
67
68
  <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
+ <component
70
+ :is="node.type === 'action-group' ? XqActionGroup : XqAction"
71
+ v-for="node in visibleActions"
72
+ :key="node.key"
73
+ :node="node"
74
+ :runner="runner"
75
+ />
69
76
  <p
70
77
  v-if="failure"
71
78
  role="alert"
@@ -135,6 +135,7 @@ function submit(): void {
135
135
  </XqButton>
136
136
  </div>
137
137
  <slot
138
+ v-if="!store.isFormDisabled"
138
139
  name="actions"
139
140
  :store="store"
140
141
  :processing="processing"
@@ -1,6 +1,7 @@
1
1
  import { toComponent } from '../composables/registry';
2
2
  import type { ComponentRegistry, ComponentResolver } from '../composables/registry';
3
3
  import XqAction from './XqAction.vue';
4
+ import XqActionGroup from './XqActionGroup.vue';
4
5
  import XqCheckboxField from './fields/XqCheckboxField.vue';
5
6
  import XqRadioField from './fields/XqRadioField.vue';
6
7
  import XqRepeaterField from './fields/XqRepeaterField.vue';
@@ -55,6 +56,7 @@ export function defaultComponents(): Record<string, ComponentResolver> {
55
56
  fieldset: XqFieldset,
56
57
  tabs: XqTabsLayout,
57
58
  action: XqAction,
59
+ 'action-group': XqActionGroup,
58
60
  actions: XqActions,
59
61
  wizard: XqWizard,
60
62
  block: XqBlock,
@@ -0,0 +1,64 @@
1
+ import { router } from '@inertiajs/vue3';
2
+ import { createToastManager } from 'reka-ui';
3
+ import { isOwnUrl } from '../lib/utils';
4
+ import type { ActionResult, XqNotification, XqNotificationAction } from '../schema/types';
5
+ import { HttpError, postJson } from './http';
6
+
7
+ /** Where the server puts notifications for the next page. See NotificationQueue. */
8
+ export const FLASH_KEY = 'xelqunNotifications';
9
+
10
+ export const notificationManager =
11
+ createToastManager<Pick<XqNotification, 'icon' | 'closeLabel' | 'actions'>>();
12
+
13
+ /**
14
+ * Show a notification.
15
+ */
16
+ export function notify(notification: XqNotification): void {
17
+ const { title, body, status, icon, duration, persistent, actions, closeLabel } = notification;
18
+
19
+ notificationManager.add({
20
+ title: title ?? undefined,
21
+ description: body ?? undefined,
22
+ status: status ?? undefined,
23
+ type: status === 'danger' || status === 'warning' ? 'foreground' : 'background',
24
+ duration: persistent ? Number.POSITIVE_INFINITY : (duration ?? undefined),
25
+ data: { icon, closeLabel, actions },
26
+ });
27
+ }
28
+
29
+ /**
30
+ * Press a button that runs an action, then do what any action does afterwards.
31
+ * Says whether it worked, and a failure shows as a notification of its own.
32
+ */
33
+ export async function runNotificationAction(action: XqNotificationAction): Promise<boolean> {
34
+ const { endpoint, token, action: key } = action.run!;
35
+
36
+ try {
37
+ const result = await postJson<ActionResult>(endpoint, {
38
+ token,
39
+ action: key,
40
+ phase: 'run',
41
+ state: { data: {} },
42
+ deps: {},
43
+ });
44
+
45
+ result.notifications?.forEach(notify);
46
+
47
+ if (!result.redirect) {
48
+ router.reload();
49
+ } else if (isOwnUrl(result.redirect)) {
50
+ router.visit(result.redirect);
51
+ } else {
52
+ window.location.assign(result.redirect);
53
+ }
54
+
55
+ return true;
56
+ } catch (failure) {
57
+ const [message] =
58
+ failure instanceof HttpError ? Object.values(failure.validationErrors).flat() : [];
59
+
60
+ notify({ title: message ?? action.failureMessage, status: 'danger' });
61
+
62
+ return false;
63
+ }
64
+ }
@@ -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,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 XqActionGroup } from './components/XqActionGroup.vue';
7
8
  export { default as XqActions } from './components/XqActions.vue';
8
9
  export type { ActionRunner } from './components/XqAction.vue';
9
10
  export { default as XqFieldWrapper } from './components/XqFieldWrapper.vue';
@@ -20,6 +21,7 @@ export { createFormStore, defaultLiveConfig, reportRequestFailure } from './core
20
21
  export type { ActionModal, FormStore, FormStoreOptions, SubmitOptions } from './core/store';
21
22
  export type { RequestKind, RequestLogEntry } from './core/log';
22
23
  export { HttpError, postJson } from './core/http';
24
+ export { notify } from './core/notifications';
23
25
  export { translate } from './core/translate';
24
26
  export { evaluateCondition } from './core/conditions';
25
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,28 @@ 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
+ // An app can type its flash data, and this key is ours, not part of that type.
111
+ const flash = event.detail.flash as Record<string, unknown>;
112
+
113
+ (flash[FLASH_KEY] as XqNotification[] | undefined)?.forEach(notify);
114
+ });
115
+
116
+ toaster.mount(host);
117
+
118
+ return () => {
119
+ stopListening();
120
+ toaster.unmount();
121
+ host.remove();
122
+ };
123
+ }
124
+
96
125
  export function createXelqunForms(options: XelqunFormsOptions = {}): Plugin {
97
126
  return {
98
127
  install(app) {
@@ -109,6 +138,10 @@ export function createXelqunForms(options: XelqunFormsOptions = {}): Plugin {
109
138
 
110
139
  plugins.forEach((plugin) => plugin.setup?.({ app }));
111
140
  app.provide(REGISTRY, registry);
141
+
142
+ if (typeof document !== 'undefined') {
143
+ app.onUnmount(mountNotifications(options.notificationsLabel));
144
+ }
112
145
  },
113
146
  };
114
147
  }
@@ -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,6 +170,32 @@ 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 {
@@ -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 =
@@ -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,