Skip to content

Commit ddfea8d

Browse files
committed
feat(VAlert): add duration prop to auto-dismiss alerts
Add a `duration` prop that automatically dismisses the alert after the given time in milliseconds. A value of `0` (default) preserves the current behavior and never auto-dismisses. The timer restarts when the alert becomes visible or the duration changes, pauses while the alert is hovered or focused, and is cleared on manual close and on unmount — mirroring VSnackbar's timeout handling. resolves #20409
1 parent 9a3ec28 commit ddfea8d

3 files changed

Lines changed: 111 additions & 2 deletions

File tree

packages/api-generator/src/locale/en/VAlert.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"closable": "Adds a close icon that can hide the alert.",
77
"closeIcon": "Change the default icon used for **closable** alerts.",
88
"closeLabel": "Text used for *aria-label* on **closable** alerts. Can also be customized globally in [Internationalization](/customization/internationalization).",
9+
"duration": "Time (in milliseconds) to wait until the alert is automatically dismissed. A value of `0` keeps the alert visible.",
910
"height": "Sets the height for the component.",
1011
"maxHeight": "Sets the maximum height for the component.",
1112
"maxWidth": "Sets the maximum width for the component.",

packages/vuetify/src/components/VAlert/VAlert.tsx

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { makeThemeProps, provideTheme } from '@/composables/theme'
2525
import { genOverlays, makeVariantProps, useVariant } from '@/composables/variant'
2626

2727
// Utilities
28-
import { toRef } from 'vue'
28+
import { onMounted, onScopeDispose, shallowRef, toRef, watch } from 'vue'
2929
import { genericComponent, propsFactory } from '@/util'
3030

3131
// Types
@@ -57,6 +57,10 @@ export const makeVAlertProps = propsFactory({
5757
type: String,
5858
default: '$vuetify.close',
5959
},
60+
duration: {
61+
type: [Number, String],
62+
default: 0,
63+
},
6064
icon: {
6165
type: [Boolean, String, Function, Object] as PropType<false | IconValue>,
6266
default: null,
@@ -107,6 +111,58 @@ export const VAlert = genericComponent<VAlertSlots>()({
107111

108112
setup (props, { emit, slots }) {
109113
const isActive = useProxiedModel(props, 'modelValue')
114+
const isHovering = shallowRef(false)
115+
const isFocused = shallowRef(false)
116+
117+
let activeTimeout = -1
118+
function startTimeout () {
119+
window.clearTimeout(activeTimeout)
120+
const duration = Number(props.duration)
121+
122+
if (!isActive.value || !duration || duration < 0 || isHovering.value || isFocused.value) return
123+
124+
activeTimeout = window.setTimeout(() => {
125+
isActive.value = false
126+
}, duration)
127+
}
128+
129+
function clearTimeout () {
130+
window.clearTimeout(activeTimeout)
131+
}
132+
133+
function onPointerenter () {
134+
isHovering.value = true
135+
clearTimeout()
136+
}
137+
138+
function onPointerleave () {
139+
isHovering.value = false
140+
startTimeout()
141+
}
142+
143+
function onFocusin () {
144+
isFocused.value = true
145+
clearTimeout()
146+
}
147+
148+
function onFocusout (e: FocusEvent) {
149+
if (e.relatedTarget && (e.currentTarget as HTMLElement | null)?.contains(e.relatedTarget as Node)) return
150+
151+
isFocused.value = false
152+
startTimeout()
153+
}
154+
155+
watch(isActive, startTimeout)
156+
watch(() => props.duration, startTimeout)
157+
158+
onMounted(() => {
159+
if (isActive.value) startTimeout()
160+
})
161+
162+
onScopeDispose(() => {
163+
window.clearTimeout(activeTimeout)
164+
})
165+
110166
const icon = toRef(() => {
111167
if (props.icon === false) return undefined
112168
if (!props.type) return props.icon
@@ -179,6 +235,10 @@ export const VAlert = genericComponent<VAlertSlots>()({
179235
props.style,
180236
]}
181237
role="alert"
238+
onPointerenter={ onPointerenter }
239+
onPointerleave={ onPointerleave }
240+
onFocusin={ onFocusin }
241+
onFocusout={ onFocusout }
182242
>
183243
{ genOverlays(false, 'v-alert') }
184244

packages/vuetify/src/components/VAlert/__tests__/VAlert.spec.browser.tsx

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { VAlert } from '..'
22

33
// Utilities
4-
import { render, screen, showcase } from '@test'
4+
import { render, screen, showcase, userEvent, wait } from '@test'
55

66
const defaultColors = ['success', 'info', 'warning', 'error', 'invalid']
77

@@ -40,5 +40,53 @@ describe('VAlert', () => {
4040
})
4141
})
4242

43+
describe('duration', () => {
44+
it('keeps the alert visible until the duration elapses, then dismisses it', async () => {
45+
render(() => <VAlert text="auto dismiss" duration={ 300 } />)
46+
47+
await screen.findByCSS('.v-alert')
48+
49+
// should not be dismissed before the duration elapses
50+
await wait(100)
51+
expect(document.querySelector('.v-alert')).not.toBeNull()
52+
53+
// should be dismissed after the duration
54+
await expect.poll(() => document.querySelector('.v-alert'), { timeout: 2000 }).toBeNull()
55+
})
56+
57+
it('emits update:modelValue when auto-dismissed', async () => {
58+
const onUpdate = vi.fn()
59+
60+
render(() => <VAlert text="auto dismiss" duration={ 150 } { ...{ 'onUpdate:modelValue': onUpdate } } />)
61+
62+
await expect.poll(() => onUpdate.mock.calls.length, { timeout: 2000 }).toBeGreaterThan(0)
63+
expect(onUpdate).toHaveBeenLastCalledWith(false)
64+
})
65+
66+
it('pauses the timer while hovered and resumes on leave', async () => {
67+
render(() => <VAlert text="hover" duration={ 500 } />)
68+
69+
const alert = await screen.findByCSS('.v-alert')
70+
71+
await userEvent.hover(alert)
72+
// stays visible past its duration while hovered
73+
await wait(700)
74+
expect(document.querySelector('.v-alert')).not.toBeNull()
75+
76+
// resumes and dismisses once the pointer leaves
77+
await userEvent.unhover(alert)
78+
await expect.poll(() => document.querySelector('.v-alert'), { timeout: 2000 }).toBeNull()
79+
})
80+
81+
it('does not auto-dismiss by default', async () => {
82+
render(() => <VAlert text="persistent" />)
83+
84+
await screen.findByCSS('.v-alert')
85+
await wait(300)
86+
87+
expect(document.querySelector('.v-alert')).not.toBeNull()
88+
})
89+
})
90+
4391
showcase({ stories, props, component: VAlert })
4492
})

0 commit comments

Comments
 (0)