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 @@ + + + + + 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, + } +}