diff --git a/packages/api-generator/src/locale/en/VAlert.json b/packages/api-generator/src/locale/en/VAlert.json
index 65235ce29e6..c1ba0951640 100644
--- a/packages/api-generator/src/locale/en/VAlert.json
+++ b/packages/api-generator/src/locale/en/VAlert.json
@@ -14,6 +14,7 @@
"modelValue": "Controls whether the component is visible or hidden.",
"prominent": "Displays a larger vertically centered icon to draw more attention.",
"tile": "Removes the component's border-radius.",
+ "timeout": "Time (in milliseconds) to wait until the alert is automatically dismissed. Use `-1` to keep the alert visible indefinitely. Changes to this property will reset the countdown.",
"type": "Create a specialized alert that uses a contextual color and has a pre-defined icon.",
"width": "Sets the width for the component."
},
diff --git a/packages/docs/src/examples/v-alert/prop-timeout.vue b/packages/docs/src/examples/v-alert/prop-timeout.vue
new file mode 100644
index 00000000000..ff451880f86
--- /dev/null
+++ b/packages/docs/src/examples/v-alert/prop-timeout.vue
@@ -0,0 +1,37 @@
+
+
+
+ This alert dismisses itself 3 seconds after it appears. The countdown pauses while you hover or focus it.
+
+
+
+
+ Reset
+
+
+
+
+
+
+
+
diff --git a/packages/docs/src/pages/en/components/alerts.md b/packages/docs/src/pages/en/components/alerts.md
index 97701b717a5..30244220757 100644
--- a/packages/docs/src/pages/en/components/alerts.md
+++ b/packages/docs/src/pages/en/components/alerts.md
@@ -137,6 +137,12 @@ The close icon automatically applies a default `aria-label` and is configurable
For more information on how to global modify your locale settings, navigate to the [Internationalization page](/features/internationalization).
:::
+#### Timeout
+
+The **timeout** property automatically dismisses the alert after the specified time in milliseconds. Use `-1` (the default) to keep the alert visible indefinitely. The countdown pauses while the alert is hovered or focused, so it stays visible while being read or interacted with.
+
+
+
## Additional Examples
The following is a collection of `v-alert` examples that demonstrate how different the properties work in an application.
diff --git a/packages/vuetify/src/components/VAlert/VAlert.tsx b/packages/vuetify/src/components/VAlert/VAlert.tsx
index 42672ddc0e0..96161416d8d 100644
--- a/packages/vuetify/src/components/VAlert/VAlert.tsx
+++ b/packages/vuetify/src/components/VAlert/VAlert.tsx
@@ -8,6 +8,7 @@ import { VDefaultsProvider } from '@/components/VDefaultsProvider'
import { VIcon } from '@/components/VIcon'
// Composables
+import { useAutoDismiss } from '@/composables/autoDismiss'
import { useTextColor } from '@/composables/color'
import { makeComponentProps } from '@/composables/component'
import { makeDensityProps, useDensity } from '@/composables/density'
@@ -57,6 +58,10 @@ export const makeVAlertProps = propsFactory({
type: String,
default: '$vuetify.close',
},
+ timeout: {
+ type: [Number, String],
+ default: -1,
+ },
icon: {
type: [Boolean, String, Function, Object] as PropType,
default: null,
@@ -107,6 +112,14 @@ export const VAlert = genericComponent()({
setup (props, { emit, slots }) {
const isActive = useProxiedModel(props, 'modelValue')
+
+ const {
+ onPointerenter,
+ onPointerleave,
+ onFocusin,
+ onFocusout,
+ } = useAutoDismiss(isActive, () => Number(props.timeout))
+
const icon = toRef(() => {
if (props.icon === false) return undefined
if (!props.type) return props.icon
@@ -179,6 +192,10 @@ export const VAlert = genericComponent()({
props.style,
]}
role="alert"
+ onPointerenter={ onPointerenter }
+ onPointerleave={ onPointerleave }
+ onFocusin={ onFocusin }
+ onFocusout={ onFocusout }
>
{ genOverlays(false, 'v-alert') }
diff --git a/packages/vuetify/src/components/VAlert/__tests__/VAlert.spec.browser.tsx b/packages/vuetify/src/components/VAlert/__tests__/VAlert.spec.browser.tsx
index b14ed3eda95..834db070ee5 100644
--- a/packages/vuetify/src/components/VAlert/__tests__/VAlert.spec.browser.tsx
+++ b/packages/vuetify/src/components/VAlert/__tests__/VAlert.spec.browser.tsx
@@ -1,7 +1,7 @@
import { VAlert } from '..'
// Utilities
-import { render, screen, showcase } from '@test'
+import { render, screen, showcase, userEvent, wait } from '@test'
const defaultColors = ['success', 'info', 'warning', 'error', 'invalid']
@@ -40,5 +40,59 @@ describe('VAlert', () => {
})
})
+ describe('timeout', () => {
+ it('keeps the alert visible until the timeout elapses, then dismisses it', async () => {
+ // Offset from the top-left corner so the resting headless-CI cursor doesn't
+ // hover the full-width alert and pause its auto-dismiss timer.
+ render(() =>
)
+
+ await screen.findByCSS('.v-alert')
+
+ // should not be dismissed before the timeout elapses
+ await wait(100)
+ expect(document.querySelector('.v-alert')).not.toBeNull()
+
+ // should be dismissed after the timeout
+ await expect.poll(() => document.querySelector('.v-alert'), { timeout: 5000 }).toBeNull()
+ })
+
+ it('emits update:modelValue when auto-dismissed', async () => {
+ const onUpdate = vi.fn()
+
+ render(() => (
+
+
+
+ ))
+
+ await expect.poll(() => onUpdate.mock.calls.length, { timeout: 5000 }).toBeGreaterThan(0)
+ expect(onUpdate).toHaveBeenLastCalledWith(false)
+ })
+
+ it('pauses the timer while hovered and resumes on leave', async () => {
+ render(() => )
+
+ const alert = await screen.findByCSS('.v-alert')
+
+ await userEvent.hover(alert)
+ // stays visible past its timeout while hovered
+ await wait(700)
+ expect(document.querySelector('.v-alert')).not.toBeNull()
+
+ // resumes and dismisses once the pointer leaves
+ await userEvent.unhover(alert)
+ await expect.poll(() => document.querySelector('.v-alert'), { timeout: 5000 }).toBeNull()
+ })
+
+ it('does not auto-dismiss by default', async () => {
+ render(() => )
+
+ await screen.findByCSS('.v-alert')
+ await wait(300)
+
+ expect(document.querySelector('.v-alert')).not.toBeNull()
+ })
+ })
+
showcase({ stories, props, component: VAlert })
})
diff --git a/packages/vuetify/src/components/VSnackbar/VSnackbar.tsx b/packages/vuetify/src/components/VSnackbar/VSnackbar.tsx
index ea3c953a80c..0d30f7cef35 100644
--- a/packages/vuetify/src/components/VSnackbar/VSnackbar.tsx
+++ b/packages/vuetify/src/components/VSnackbar/VSnackbar.tsx
@@ -13,6 +13,7 @@ import { useSnackbarItem } from '@/components/VSnackbarQueue/queue'
// Composables
import { useLayout } from '@/composables'
+import { useAutoDismiss } from '@/composables/autoDismiss'
import { forwardRefs } from '@/composables/forwardRefs'
import { IconValue } from '@/composables/icons'
import { VuetifyLayoutKey } from '@/composables/layout'
@@ -26,7 +27,7 @@ import { useToggleScope } from '@/composables/toggleScope'
import { genOverlays, makeVariantProps, useVariant } from '@/composables/variant'
// Utilities
-import { computed, inject, mergeProps, nextTick, onMounted, onScopeDispose, ref, shallowRef, watch, watchEffect } from 'vue'
+import { computed, inject, mergeProps, nextTick, onScopeDispose, ref, shallowRef, watchEffect } from 'vue'
import { convertToUnit, genericComponent, noop, omit, propsFactory, refElement, useRender } from '@/util'
// Types
@@ -147,8 +148,6 @@ export const VSnackbar = genericComponent()({
let _lastOffset: string
const timerRef = ref()
- const isHovering = shallowRef(false)
- const isFocused = shallowRef(false)
const startY = shallowRef(0)
const mainStyles = ref()
const hasLayout = inject(VuetifyLayoutKey, undefined)
@@ -161,61 +160,19 @@ export const VSnackbar = genericComponent()({
})
})
- watch(isActive, startTimeout)
- watch(() => props.timeout, startTimeout)
-
- onMounted(() => {
- if (isActive.value) startTimeout()
+ const {
+ isHovering,
+ isFocused,
+ onPointerenter,
+ onPointerleave,
+ onFocusin,
+ onFocusout,
+ } = useAutoDismiss(isActive, () => Number(props.timeout), {
+ getContentEl: () => overlay.value?.contentEl,
+ onClear: () => countdown.reset(),
+ onStart: () => nextTick(() => countdown.start(refElement(timerRef.value))),
})
- let activeTimeout = -1
- function startTimeout () {
- countdown.clear()
- window.clearTimeout(activeTimeout)
- const timeout = Number(props.timeout)
-
- if (!isActive.value || timeout === -1) return
-
- countdown.reset()
-
- const element = refElement(timerRef.value)
-
- nextTick(() => countdown.start(element))
-
- activeTimeout = window.setTimeout(() => {
- isActive.value = false
- }, timeout)
- }
-
- function clearTimeout () {
- countdown.reset()
- window.clearTimeout(activeTimeout)
- }
-
- function onPointerenter () {
- isHovering.value = true
- clearTimeout()
- }
-
- function onPointerleave () {
- isHovering.value = false
- if (!isFocused.value) startTimeout()
- }
-
- function onFocusin () {
- isFocused.value = true
- clearTimeout()
- }
-
- function onFocusout (event: FocusEvent) {
- const contentEl = overlay.value?.contentEl
- if (contentEl?.contains(event.relatedTarget as Node)) {
- return
- }
- isFocused.value = false
- if (!isHovering.value) startTimeout()
- }
-
function onTouchstart (event: TouchEvent) {
startY.value = event.touches[0].clientY
}
diff --git a/packages/vuetify/src/components/VSnackbar/__tests__/VSnackbar.spec.browser.tsx b/packages/vuetify/src/components/VSnackbar/__tests__/VSnackbar.spec.browser.tsx
new file mode 100644
index 00000000000..ab2c2ca8837
--- /dev/null
+++ b/packages/vuetify/src/components/VSnackbar/__tests__/VSnackbar.spec.browser.tsx
@@ -0,0 +1,47 @@
+import { VSnackbar } from '..'
+
+// Utilities
+import { render, screen, userEvent, wait } from '@test'
+
+const CONTENT = '.v-snackbar .v-overlay__content'
+
+describe('VSnackbar', () => {
+ describe('timeout', () => {
+ it('auto-dismisses after the timeout elapses', async () => {
+ render(() => )
+
+ await expect.poll(() => screen.queryByCSS(CONTENT)).toBeVisible()
+
+ // should still be visible shortly before the timeout elapses
+ await wait(100)
+ expect(screen.queryByCSS(CONTENT)).toBeVisible()
+
+ // should be dismissed after the timeout
+ await expect.poll(() => screen.queryByCSS(CONTENT), { timeout: 5000 }).toBeNull()
+ })
+
+ it('pauses the timer while hovered and resumes on leave', async () => {
+ render(() => )
+
+ const content = await screen.findByCSS(CONTENT)
+
+ await userEvent.hover(content)
+ // stays visible past its timeout while hovered
+ await wait(700)
+ expect(screen.queryByCSS(CONTENT)).toBeVisible()
+
+ // resumes and dismisses once the pointer leaves
+ await userEvent.unhover(content)
+ await expect.poll(() => screen.queryByCSS(CONTENT), { timeout: 5000 }).toBeNull()
+ })
+
+ it('stays open indefinitely when timeout is -1', async () => {
+ render(() => )
+
+ await expect.poll(() => screen.queryByCSS(CONTENT)).toBeVisible()
+ await wait(300)
+
+ expect(screen.queryByCSS(CONTENT)).toBeVisible()
+ })
+ })
+})
diff --git a/packages/vuetify/src/composables/autoDismiss.ts b/packages/vuetify/src/composables/autoDismiss.ts
new file mode 100644
index 00000000000..74d7d0c5f22
--- /dev/null
+++ b/packages/vuetify/src/composables/autoDismiss.ts
@@ -0,0 +1,94 @@
+// Utilities
+import { onMounted, onScopeDispose, shallowRef, watch } from 'vue'
+
+// Types
+import type { Ref } from 'vue'
+
+export interface AutoDismissOptions {
+ /** Called when the timeout (re)starts */
+ onStart?: () => void
+ /** Called when the pending timeout is cleared */
+ onClear?: () => void
+ /** Element scoping the focusout containment check, defaults to the event's currentTarget */
+ getContentEl?: () => HTMLElement | null | undefined
+}
+
+/**
+ * Toggles `isActive` off after `timeout` ms, pausing while hovered or focused.
+ * A negative `timeout` never dismisses. Shared by VSnackbar and VAlert.
+ */
+export function useAutoDismiss (
+ isActive: Ref,
+ timeout: () => number,
+ options: AutoDismissOptions = {},
+) {
+ const isHovering = shallowRef(false)
+ const isFocused = shallowRef(false)
+
+ let activeTimeout = -1
+
+ function startTimeout () {
+ options.onClear?.()
+ window.clearTimeout(activeTimeout)
+
+ const value = timeout()
+
+ if (!isActive.value || value < 0 || isHovering.value || isFocused.value) return
+
+ options.onStart?.()
+
+ activeTimeout = window.setTimeout(() => {
+ isActive.value = false
+ }, value)
+ }
+
+ function clearTimeout () {
+ options.onClear?.()
+ window.clearTimeout(activeTimeout)
+ }
+
+ function onPointerenter () {
+ isHovering.value = true
+ clearTimeout()
+ }
+
+ function onPointerleave () {
+ isHovering.value = false
+ if (!isFocused.value) startTimeout()
+ }
+
+ function onFocusin () {
+ isFocused.value = true
+ clearTimeout()
+ }
+
+ function onFocusout (e: FocusEvent) {
+ const contentEl = options.getContentEl?.() ?? (e.currentTarget as HTMLElement | null)
+ if (contentEl?.contains(e.relatedTarget as Node)) return
+
+ isFocused.value = false
+ if (!isHovering.value) startTimeout()
+ }
+
+ watch(isActive, startTimeout)
+ watch(timeout, startTimeout)
+
+ onMounted(() => {
+ if (isActive.value) startTimeout()
+ })
+
+ onScopeDispose(() => {
+ window.clearTimeout(activeTimeout)
+ })
+
+ return {
+ isHovering,
+ isFocused,
+ startTimeout,
+ clearTimeout,
+ onPointerenter,
+ onPointerleave,
+ onFocusin,
+ onFocusout,
+ }
+}