Skip to content

Commit 34e7b80

Browse files
committed
feat: add disappear countdown for toast and posotion toast based on the #app container padding (to prevent toast appearance above agent plugin)
AdminForth/1808/add-timer-line-and-stop-count-
1 parent dddd6ba commit 34e7b80

2 files changed

Lines changed: 123 additions & 25 deletions

File tree

adminforth/spa/src/App.vue

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@
118118
</div>
119119
</div>
120120
<AcceptModal />
121-
<div v-if="toastStore.toasts.length>0" class="fixed bottom-5 right-5 flex gap-1 flex-col-reverse z-[100]">
121+
<div v-if="toastStore.toasts.length>0" class="fixed bottom-5 flex gap-1 flex-col-reverse z-[100]" :style="{ right: `calc(${appPaddingRight}px + 1.25rem)` }">
122122
<transition-group
123123
name="fade"
124124
tag="div"
@@ -185,7 +185,7 @@
185185
</style>
186186

187187
<script setup lang="ts">
188-
import { computed, nextTick, onMounted, ref, watch, onBeforeMount } from 'vue';
188+
import { computed, nextTick, onMounted, onUnmounted, ref, watch, onBeforeMount } from 'vue';
189189
import { RouterView } from 'vue-router';
190190
import { Dropdown } from 'flowbite'
191191
import './index.scss'
@@ -296,6 +296,20 @@ const title = computed(() => {
296296
return `${coreStore.config?.title || coreStore.config?.brandName || 'Adminforth'} | ${ resourceTitle || route.meta.title || ' '}`;
297297
});
298298
299+
const appPaddingRight = ref(0);
300+
let appPaddingObserver: MutationObserver | null = null;
301+
let appPaddingResizeObserver: ResizeObserver | null = null;
302+
303+
function updateAppPaddingRight() {
304+
const app = document.getElementById('app');
305+
if (!app) {
306+
appPaddingRight.value = 0;
307+
return;
308+
}
309+
const style = window.getComputedStyle(app);
310+
appPaddingRight.value = parseInt(style.paddingRight, 10) || 0;
311+
}
312+
299313
// TODO - useHead not working in this way
300314
// useHead({
301315
// title: title.value
@@ -349,6 +363,22 @@ onMounted(async () => {
349363
350364
window.addEventListener('online', () => coreStore.isInternetError = false);
351365
window.addEventListener('offline', () => coreStore.isInternetError = true);
366+
367+
const app = document.getElementById('app');
368+
updateAppPaddingRight();
369+
if (app) {
370+
// padding-right changes come in via inline style / class updates on #app
371+
appPaddingObserver = new MutationObserver(updateAppPaddingRight);
372+
appPaddingObserver.observe(app, { attributes: true, attributeFilter: ['style', 'class'] });
373+
// and via viewport/box size changes that affect resolved padding
374+
appPaddingResizeObserver = new ResizeObserver(updateAppPaddingRight);
375+
appPaddingResizeObserver.observe(app);
376+
}
377+
})
378+
379+
onUnmounted(() => {
380+
appPaddingObserver?.disconnect();
381+
appPaddingResizeObserver?.disconnect();
352382
})
353383
354384
onBeforeMount(()=>{

adminforth/spa/src/components/Toast.vue

Lines changed: 91 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,30 @@
22

33

44
<div class="afcl-toast flex items-center w-full p-4 rounded-lg shadow-lg dark:text-darkToastText dark:bg-darkToastBackground bg-lightToastBackground text-lightToastText border-l-4"
5-
:class="toast.variant == 'info' ? 'border-lightPrimary dark:border-darkPrimary' : toast.variant == 'danger' ? 'border-red-500 dark:border-red-800' : toast.variant == 'warning' ? 'border-orange-500 dark:border-orange-700' : 'border-green-500 dark:border-green-800'"
5+
:class="variantConfig.borderClass"
66
role="alert"
7+
@mouseenter="pauseTimer"
8+
@mouseleave="resumeTimer"
79
>
8-
<div v-if="toast.variant == 'info'" class="af-toast-icon inline-flex items-center justify-center flex-shrink-0 w-8 h-8 text-lightPrimary dark:text-darkPrimary bg-lightPrimaryOpacity rounded-lg dark:bg-darkPrimary dark:!text-blue-100">
9-
<IconInfoCircleSolid class="w-5 h-5" aria-hidden="true" />
10-
</div>
11-
<div v-else-if="toast.variant == 'danger'" class="af-toast-icon inline-flex items-center justify-center flex-shrink-0 w-8 h-8 text-red-500 bg-red-100 rounded-lg dark:bg-red-800 dark:text-red-200">
12-
<IconCloseCircleSolid class="w-5 h-5" aria-hidden="true" />
13-
</div>
14-
<div v-else-if="toast.variant == 'warning'" class="af-toast-icon inline-flex items-center justify-center flex-shrink-0 w-8 h-8 text-orange-500 bg-orange-100 rounded-lg dark:bg-orange-700 dark:text-orange-200">
15-
<IconExclamationCircleSolid class="w-5 h-5" aria-hidden="true" />
16-
17-
</div>
18-
<div v-else class="af-toast-icon inline-flex items-center justify-center flex-shrink-0 w-8 h-8 text-green-500 bg-green-100 rounded-lg dark:bg-green-800 dark:text-green-200">
19-
<IconCheckCircleSolid class="w-5 h-5" aria-hidden="true" />
10+
<div class="relative w-8 h-8 flex items-center justify-center">
11+
<div class="absolute af-toast-icon inline-flex items-center justify-center flex-shrink-0 rounded-lg w-full h-full" :class="variantConfig.iconClass">
12+
<component :is="variantConfig.icon" class="w-5 h-5" aria-hidden="true" />
13+
</div>
14+
<svg v-if="hasTimer" class="absolute inset-0 w-full h-full rotate-180">
15+
<rect
16+
x="1"
17+
y="1"
18+
width="calc(100% - 2px)"
19+
height="calc(100% - 2px)"
20+
:class="variantConfig.strokeClass"
21+
rx="8"
22+
fill="none"
23+
stroke-width="2"
24+
pathLength="100"
25+
:stroke-dasharray="`100`"
26+
:stroke-dashoffset="dashOffset"
27+
/>
28+
</svg>
2029
</div>
2130
<div class="flex flex-col items-center justify-center break-all overflow-hidden [display:-webkit-box] [-webkit-box-orient:vertical] [-webkit-line-clamp:70]">
2231
<div class="ms-3 text-sm font-normal max-w-xs pr-2" v-if="toast.messageHtml" v-html="toast.messageHtml"></div>
@@ -36,14 +45,13 @@
3645
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
3746
</svg>
3847
</button>
39-
<!-- <div class="h-full ml-3 w-1 rounded-r-lg" :class="toast.variant == 'info' ? 'bg-lightPrimary dark:bg-darkPrimary' : toast.variant == 'danger' ? 'bg-red-500 dark:bg-red-800' : toast.variant == 'warning' ? 'bg-orange-500 dark:bg-orange-700' : 'bg-green-500 dark:bg-green-800'"></div> -->
4048
</div>
4149

4250

4351
</template>
4452

4553
<script setup lang="ts">
46-
import { onMounted } from 'vue';
54+
import { onMounted, onUnmounted, computed, ref } from 'vue';
4755
import { useToastStore } from '@/stores/toast';
4856
import { IconInfoCircleSolid, IconCloseCircleSolid, IconExclamationCircleSolid, IconCheckCircleSolid } from '@iconify-prerendered/vue-flowbite';
4957
@@ -59,6 +67,69 @@ const props = defineProps<{
5967
buttons?: { value: any; label: string }[];
6068
}
6169
}>();
70+
71+
const VARIANT_CONFIGS = {
72+
info: {
73+
icon: IconInfoCircleSolid,
74+
borderClass: 'border-lightPrimary dark:border-darkPrimary',
75+
iconClass: 'text-lightPrimary dark:text-darkPrimary bg-lightPrimaryOpacity dark:bg-darkPrimary dark:!text-blue-100',
76+
strokeClass: 'stroke-lightPrimary dark:stroke-darkPrimary',
77+
},
78+
danger: {
79+
icon: IconCloseCircleSolid,
80+
borderClass: 'border-red-500 dark:border-red-800',
81+
iconClass: 'text-red-500 bg-red-100 dark:bg-red-800 dark:text-red-200',
82+
strokeClass: 'stroke-red-500 dark:stroke-red-800',
83+
},
84+
warning: {
85+
icon: IconExclamationCircleSolid,
86+
borderClass: 'border-orange-500 dark:border-orange-700',
87+
iconClass: 'text-orange-500 bg-orange-100 dark:bg-orange-700 dark:text-orange-200',
88+
strokeClass: 'stroke-orange-500 dark:stroke-orange-700',
89+
},
90+
success: {
91+
icon: IconCheckCircleSolid,
92+
borderClass: 'border-green-500 dark:border-green-800',
93+
iconClass: 'text-green-500 bg-green-100 dark:bg-green-800 dark:text-green-200',
94+
strokeClass: 'stroke-green-500 dark:stroke-green-800',
95+
},
96+
} as const;
97+
98+
const variantConfig = computed(() => VARIANT_CONFIGS[props.toast.variant as keyof typeof VARIANT_CONFIGS] ?? VARIANT_CONFIGS.success);
99+
100+
const hasTimer = computed(() => props.toast.timeout !== 'unlimited');
101+
const totalMs = (typeof props.toast.timeout === 'number' ? props.toast.timeout : 10) * 1e3;
102+
const dashOffset = ref(0);
103+
let rafId: number | null = null;
104+
let remainingMs = totalMs;
105+
let startedAt = 0;
106+
107+
function frame(now: number) {
108+
const leftMs = Math.max(0, remainingMs - (now - startedAt));
109+
dashOffset.value = (1 - leftMs / totalMs) * 100;
110+
if (leftMs <= 0) {
111+
rafId = null;
112+
// resolve with undefined on auto-timeout
113+
toastStore.resolveToast(props.toast.id);
114+
emit('close');
115+
return;
116+
}
117+
rafId = requestAnimationFrame(frame);
118+
}
119+
120+
function pauseTimer() {
121+
if (rafId === null) return;
122+
cancelAnimationFrame(rafId);
123+
rafId = null;
124+
remainingMs = Math.max(0, remainingMs - (performance.now() - startedAt));
125+
}
126+
127+
function resumeTimer() {
128+
if (!hasTimer.value || rafId !== null || remainingMs <= 0) return;
129+
startedAt = performance.now();
130+
rafId = requestAnimationFrame(frame);
131+
}
132+
62133
function closeToast() {
63134
// resolve with undefined on close (X button)
64135
toastStore.resolveToast(props.toast.id);
@@ -71,14 +142,11 @@ function onButtonClick(value: any) {
71142
}
72143
73144
onMounted(() => {
74-
if (props.toast.timeout === 'unlimited') return;
75-
else {
76-
setTimeout(() => {
77-
// resolve with undefined on auto-timeout
78-
toastStore.resolveToast(props.toast.id);
79-
emit('close');
80-
}, (props.toast.timeout || 10) * 1e3 );
81-
}
145+
resumeTimer();
146+
});
147+
148+
onUnmounted(() => {
149+
if (rafId !== null) cancelAnimationFrame(rafId);
82150
});
83151
84152
</script>

0 commit comments

Comments
 (0)