Skip to content

Commit e49f533

Browse files
committed
feat: confirm dialog in the Dialog look of the design system, a red icon well beside the title for dangerous confirms, actions in the footer band, confirm() takes icon and the core delete confirms pass the trash icon AdminForth/2005
1 parent 36ae084 commit e49f533

8 files changed

Lines changed: 106 additions & 90 deletions

File tree

‎adminforth/spa/src/adminforth.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ class FrontendAPI implements FrontendAPIInterface {
128128
acceptText: params.yes || 'Yes',
129129
cancelText: params.no || 'Cancel',
130130
dangerous: params.dangerous ?? false,
131+
icon: params.icon,
131132
})
132133
this.modalStore.onAcceptFunction = resolve
133134
this.modalStore.onCancelFunction = reject
Lines changed: 69 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,93 +1,86 @@
11
<template>
2-
<Teleport to="body">
3-
<Modal
4-
ref="modalRef"
5-
:beforeCloseFunction="()=>{modalStore.onAcceptFunction(false);modalStore.isOpened=false}"
6-
backgroundCustomClasses="z-[998]"
7-
modalCustomClasses="z-[999] flex items-center justify-center"
8-
>
9-
<div class="relative p-6 sm:w-[420px] rounded-[inherit] bg-lightAcceptModalBackground dark:bg-darkAcceptModalBackground" >
10-
11-
<button type="button" @click="modalStore.togleModal()" class="absolute top-3 right-3 p-1.5 rounded-default text-lightAcceptModalCloseIcon hover:text-lightAcceptModalCloseIconHover hover:bg-lightAcceptModalCloseIconHoverBackground dark:text-darkAcceptModalCloseIcon dark:hover:text-darkAcceptModalCloseIconHover dark:hover:bg-darkAcceptModalCloseIconHoverBackground transition-colors" >
12-
<IconX class="w-4 h-4" />
13-
</button>
14-
15-
<div class="text-center flex flex-col">
16-
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-xl mb-4 relative"
17-
:class="modalStore.modalContent.dangerous ? 'bg-lightDangerSoft dark:bg-darkDangerSoft' : 'bg-lightSecondaryDarken dark:bg-darkSecondaryLighten'">
18-
<IconClipboardList class="w-6 h-6"
19-
:class="modalStore.modalContent.dangerous ? 'text-lightAcceptModalWarningIcon dark:text-darkAcceptModalWarningIcon' : 'text-lightButtonsBackground dark:text-darkButtonsBackground'" />
20-
<div class="absolute -bottom-1 -right-1 rounded-full w-[18px] h-[18px] flex items-center justify-center border-2 border-lightAcceptModalBackground dark:border-darkAcceptModalBackground"
21-
:class="modalStore.modalContent.dangerous ? 'bg-lightAcceptModalConfirmButtonBackground dark:bg-darkAcceptModalConfirmButtonBackground' : 'bg-lightButtonsBackground dark:bg-darkButtonsBackground'">
22-
<span class="text-lightButtonsText dark:text-darkButtonsText text-[10px] font-medium">!</span>
23-
</div>
24-
</div>
25-
26-
<div class="flex flex-col gap-3">
27-
<h3 v-if="modalStore?.modalContent?.title" class="text-lg font-semibold tracking-tight text-lightDialogHeaderText dark:text-darkDialogHeaderText">
28-
{{ modalStore.modalContent.title }}
29-
</h3>
30-
31-
<div class="text-sm text-lightAcceptModalText dark:text-darkAcceptModalText">
32-
<div v-if="modalStore?.modalContent?.contentHTML" v-html="modalStore.modalContent.contentHTML"></div>
33-
<p v-else-if="modalStore?.modalContent?.content">{{ modalStore.modalContent.content }}</p>
34-
</div>
35-
</div>
36-
37-
<hr class="border-t border-lightDialogBreakLine dark:border-darkDialogBreakLine my-5">
2+
<Modal
3+
ref="modalRef"
4+
class="w-full sm:w-[400px]"
5+
:role="dangerous ? 'alertdialog' : 'dialog'"
6+
:aria-labelledby="titleId"
7+
:beforeCloseFunction="() => { modalStore.onAcceptFunction(false); modalStore.isOpened = false; }"
8+
backgroundCustomClasses="z-[998]"
9+
>
10+
<div class="flex flex-col gap-3 px-5 py-[18px]">
11+
<div class="flex items-start gap-3">
12+
<span
13+
v-if="dangerous"
14+
class="flex w-9 h-9 shrink-0 items-center justify-center rounded-lg bg-lightDangerSoft text-lightDanger dark:bg-darkDangerSoft dark:text-darkDanger"
15+
>
16+
<component :is="icon" class="w-5 h-5" aria-hidden="true" />
17+
</span>
18+
<div class="flex-1 min-w-0 flex flex-col gap-1" :class="dangerous ? 'pt-1.5' : ''">
19+
<h3 :id="titleId" class="text-base font-semibold tracking-[-0.01em] text-lightDialogHeaderText dark:text-darkDialogHeaderText">
20+
{{ title }}
21+
</h3>
22+
<p v-if="subtitle" class="text-sm leading-5 text-lightDialogBodyText dark:text-darkDialogBodyText">{{ subtitle }}</p>
23+
</div>
24+
<Button
25+
v-if="!dangerous"
26+
type="button"
27+
variant="ghost"
28+
size="sm"
29+
iconOnly
30+
class="-mt-0.5 -me-1.5 text-lightDialogCloseButton hover:text-lightDialogCloseButtonHover hover:bg-lightDialogCloseButtonHoverBackground dark:text-darkDialogCloseButton dark:hover:text-darkDialogCloseButtonHover dark:hover:bg-darkDialogCloseButtonHoverBackground"
31+
:aria-label="t('Close Modal')"
32+
@click="answer(false)"
33+
>
34+
<IconX aria-hidden="true" />
35+
</Button>
36+
</div>
3837

39-
<div class="flex justify-center gap-2 w-full">
40-
<Button @click="()=>{modalStore.onAcceptFunction(false);modalStore.togleModal()}" type="button" variant="secondary" class="flex-1">
41-
{{ modalStore?.modalContent?.cancelText }}
42-
</Button>
43-
44-
<Button
45-
@click="()=>{ modalStore.onAcceptFunction(true);modalStore.togleModal()}"
46-
type="button"
47-
class="flex-1"
48-
:variant="modalStore.modalContent.dangerous ? 'danger' : 'primary'">
49-
{{ modalStore?.modalContent?.acceptText }}
50-
</Button>
51-
</div>
38+
<div v-if="modalContent.contentHTML" class="text-sm text-lightDialogBodyText dark:text-darkDialogBodyText" v-html="modalContent.contentHTML"></div>
5239

53-
<div v-if="modalStore?.modalContent?.guardMessage" class="flex items-center justify-center mt-5 text-xs text-lightAcceptModalText dark:text-darkAcceptModalText gap-1.5 font-medium">
54-
<IconShieldCheck class="w-4 h-4" />
55-
<span> {{ modalStore?.modalContent?.guardMessage }}</span>
56-
</div>
40+
<p v-if="modalContent.guardMessage" class="flex items-center gap-1.5 text-xs font-medium text-lightInputIconColor dark:text-darkInputIconColor">
41+
<IconShieldCheck class="w-3.5 h-3.5 shrink-0" aria-hidden="true" />
42+
{{ modalContent.guardMessage }}
43+
</p>
44+
</div>
5745

58-
</div>
59-
</div>
60-
</Modal>
61-
</Teleport>
46+
<div :class="dialogFooterClasses">
47+
<Button type="button" variant="secondary" @click="answer(false)">{{ modalContent.cancelText }}</Button>
48+
<Button type="button" :variant="dangerous ? 'danger' : 'primary'" @click="answer(true)">{{ modalContent.acceptText }}</Button>
49+
</div>
50+
</Modal>
6251
</template>
6352

6453
<script setup lang="ts">
65-
import { watch, ref } from 'vue';
54+
import { watch, ref, computed, useId, type Component } from 'vue';
55+
import { storeToRefs } from 'pinia';
56+
import { useI18n } from 'vue-i18n';
6657
import { useModalStore } from '@/stores/modal';
6758
import { Button, Modal } from '@/afcl';
68-
import { IconClipboardList, IconShieldCheck, IconX } from '@iconify-prerendered/vue-lucide';
69-
59+
import { dialogFooterClasses } from '@/afcl/dialogStyles';
60+
import { IconShieldCheck, IconTriangleAlert, IconX } from '@iconify-prerendered/vue-lucide';
7061
62+
const { t } = useI18n();
7163
const modalRef = ref();
64+
const titleId = useId();
7265
const modalStore = useModalStore();
66+
const { modalContent } = storeToRefs(modalStore);
7367
74-
watch( () => modalStore.isOpened, (newVal) => {
75-
if (newVal) {
76-
open();
77-
} else {
78-
close();
79-
}
80-
}
81-
);
82-
83-
68+
const dangerous = computed(() => modalContent.value.dangerous);
69+
const icon = computed(() => (modalContent.value.icon ?? IconTriangleAlert) as Component);
70+
// a call with only a message shows it as the title, a call with a title shows the message under it
71+
const title = computed(() => modalContent.value.title || modalContent.value.content);
72+
const subtitle = computed(() => modalContent.value.title ? modalContent.value.content : '');
8473
85-
function open() {
86-
modalRef.value.open();
87-
}
88-
89-
function close() {
90-
modalRef.value.close();
74+
function answer(accepted: boolean) {
75+
modalStore.onAcceptFunction(accepted);
76+
modalStore.togleModal();
9177
}
9278
79+
watch(() => modalStore.isOpened, (opened) => {
80+
if (opened) {
81+
modalRef.value.open();
82+
} else {
83+
modalRef.value.close();
84+
}
85+
});
9386
</script>

‎adminforth/spa/src/components/ResourceListTable.vue‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -682,6 +682,7 @@ async function deleteRecord(row: any) {
682682
yes: t('Delete'),
683683
no: t('Cancel'),
684684
dangerous: true,
685+
icon: IconTrash2,
685686
});
686687
if (data) {
687688
try {

‎adminforth/spa/src/stores/modal.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ref } from 'vue'
1+
import { ref, markRaw } from 'vue'
22
import { defineStore } from 'pinia'
33

44
type ModalContentType = {
@@ -9,18 +9,20 @@ import { defineStore } from 'pinia'
99
acceptText?: string;
1010
cancelText?: string;
1111
dangerous?: boolean;
12+
icon?: object;
1213
}
1314

1415

1516
export const useModalStore = defineStore('modal', () => {
16-
const modalContent = ref({
17+
const modalContent = ref<ModalContentType>({
1718
title: 'title',
1819
guardMessage: 'guardMessage',
1920
content: '',
2021
contentHTML: '',
2122
acceptText: 'acceptText',
2223
cancelText: 'cancelText',
2324
dangerous: false,
25+
icon: undefined,
2426
});
2527
const isOpened = ref(false);
2628
const onAcceptFunction: any = ref(()=>{});
@@ -43,6 +45,7 @@ export const useModalStore = defineStore('modal', () => {
4345
acceptText: content.acceptText || 'acceptText',
4446
cancelText: content.cancelText || 'cancelText',
4547
dangerous: content.dangerous ?? false,
48+
icon: content.icon && markRaw(content.icon),
4649
};
4750
}
4851
function resetmodalState() {
@@ -55,6 +58,7 @@ export const useModalStore = defineStore('modal', () => {
5558
acceptText: 'acceptText',
5659
cancelText: 'cancelText',
5760
dangerous: false,
61+
icon: undefined,
5862
};
5963
setOnAcceptFunction(()=>{});
6064

‎adminforth/spa/src/utils/utils.ts‎

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -707,29 +707,25 @@ export function generateMessageHtmlForRecordChange(changedFields: Record<string,
707707
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;');
708708
};
709709

710+
const labelClass = 'font-medium text-lightDialogHeaderText dark:text-darkDialogHeaderText';
710711
const items = Object.keys(changedFields || {}).map(key => {
711712
const column = coreStore.resource?.columns?.find((c: any) => c.name === key);
712713
const label = column?.label || key;
713714
if (column?.masked) {
714-
return `<li class="truncate"><strong>${escapeHtml(label)}</strong>: <em>${escapeHtml(t('changed'))}</em></li>`;
715+
return `<li class="truncate"><strong class="${labelClass}">${escapeHtml(label)}</strong>: <em>${escapeHtml(t('changed'))}</em></li>`;
715716
}
716717
const oldV = escapeHtml(changedFields[key].oldValue);
717718
const newV = escapeHtml(changedFields[key].newValue);
718-
return `<li class="truncate"><strong>${escapeHtml(label)}</strong>: <span class="af-old-value text-muted">${oldV}</span> &#8594; <span class="af-new-value">${newV}</span></li>`;
719+
return `<li class="truncate"><strong class="${labelClass}">${escapeHtml(label)}</strong>: <span class="af-old-value text-lightInputIconColor dark:text-darkInputIconColor">${oldV}</span> &#8594; <span class="af-new-value">${newV}</span></li>`;
719720
}).join('');
720721

721-
const listHtml = items ? `<ul class="mt-2 list-disc list-inside">${items}</ul>` : '';
722-
const messageHtml = `
723-
<div class="flex flex-col gap-y-2 text-center">
724-
<div class="text-gray-500 dark:text-gray-400">
725-
${listHtml}
726-
</div>
727-
<p class="font-medium text-gray-900 dark:text-white mt-4">
728-
${escapeHtml(t('Are you sure you want to leave this page?'))}
729-
</p>
722+
const listHtml = items ? `<ul class="flex flex-col gap-1">${items}</ul>` : '';
723+
return `
724+
<div class="flex flex-col gap-3">
725+
${listHtml}
726+
<p class="${labelClass}">${escapeHtml(t('Are you sure you want to leave this page?'))}</p>
730727
</div>
731728
`;
732-
return messageHtml;
733729
}
734730

735731
export function getTimeAgoString(date: Date): string {

‎adminforth/spa/src/views/ShowView.vue‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,7 @@ async function deleteRecord() {
398398
yes: t('Delete'),
399399
no: t('Cancel'),
400400
dangerous: true,
401+
icon: IconTrash2,
401402
});
402403
if (data) {
403404
try {

‎adminforth/types/FrontendAPI.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,11 @@ export type ConfirmParams = {
178178
*/
179179
dangerous?: boolean;
180180

181+
/**
182+
* Vue component of the icon shown beside the title of a dangerous dialog, e.g. IconTrash2 from `@iconify-prerendered/vue-lucide`.
183+
* Without it a dangerous dialog shows an alert triangle. Not used when dangerous is false.
184+
*/
185+
icon?: object;
181186
}
182187

183188
export type AlertParams = {

‎documentation/docs/tutorial/03-Customization/07-alert.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,21 @@ const { confirm } = useAdminforth();
7575
const isConfirmed = await confirm({message: 'Are you sure?', yes: 'Yes', no: 'No'})
7676
```
7777

78+
For a destructive action pass `dangerous: true`: the dialog gets a red icon beside the title and a red main button. `title` is shown above `message`, `guardMessage` is a short muted line with a shield icon under it. The icon is an alert triangle, pass a Vue component in `icon` for a more specific one:
79+
80+
```ts
81+
import { IconTrash2 } from '@iconify-prerendered/vue-lucide';
82+
83+
const isConfirmed = await confirm({
84+
title: 'Delete this car?',
85+
message: 'Photos stored in S3 are removed too.',
86+
yes: 'Delete',
87+
no: 'Cancel',
88+
dangerous: true,
89+
icon: IconTrash2,
90+
})
91+
```
92+
7893
## Usage example
7994

8095
Create a Vue component in the custom directory of your project, e.g. `Alerts.vue`:

0 commit comments

Comments
 (0)