Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/api-generator/src/locale/en/VAlert.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
},
Expand Down
37 changes: 37 additions & 0 deletions packages/docs/src/examples/v-alert/prop-timeout.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<template>
<div>
<v-alert
v-model="alert"
:timeout="3000"
title="Auto-dismiss Alert"
type="success"
variant="tonal"
closable
>
This alert dismisses itself 3 seconds after it appears. The countdown pauses while you hover or focus it.
</v-alert>

<div
v-if="!alert"
class="text-center"
>
<v-btn @click="alert = true">
Reset
</v-btn>
</div>
</div>
</template>

<script setup>
import { ref } from 'vue'

const alert = ref(true)
</script>

<script>
export default {
data: () => ({
alert: true,
}),
}
</script>
6 changes: 6 additions & 0 deletions packages/docs/src/pages/en/components/alerts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<ExamplesExample file="v-alert/prop-timeout" />

## Additional Examples

The following is a collection of `v-alert` examples that demonstrate how different the properties work in an application.
Expand Down
17 changes: 17 additions & 0 deletions packages/vuetify/src/components/VAlert/VAlert.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<false | IconValue>,
default: null,
Expand Down Expand Up @@ -107,6 +112,14 @@ export const VAlert = genericComponent<VAlertSlots>()({

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
Expand Down Expand Up @@ -179,6 +192,10 @@ export const VAlert = genericComponent<VAlertSlots>()({
props.style,
]}
role="alert"
onPointerenter={ onPointerenter }
onPointerleave={ onPointerleave }
onFocusin={ onFocusin }
onFocusout={ onFocusout }
>
{ genOverlays(false, 'v-alert') }

Expand Down
Original file line number Diff line number Diff line change
@@ -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']

Expand Down Expand Up @@ -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(() => <div style={{ paddingTop: '200px' }}><VAlert text="auto dismiss" timeout={ 1000 } /></div>)

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(() => (
<div style={{ paddingTop: '200px' }}>
<VAlert text="auto dismiss" timeout={ 150 } { ...{ 'onUpdate:modelValue': onUpdate } } />
</div>
))

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(() => <VAlert text="hover" timeout={ 500 } />)

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(() => <VAlert text="persistent" />)

await screen.findByCSS('.v-alert')
await wait(300)

expect(document.querySelector('.v-alert')).not.toBeNull()
})
})

showcase({ stories, props, component: VAlert })
})
69 changes: 13 additions & 56 deletions packages/vuetify/src/components/VSnackbar/VSnackbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -147,8 +148,6 @@ export const VSnackbar = genericComponent<VSnackbarSlots>()({
let _lastOffset: string

const timerRef = ref<VProgressLinear>()
const isHovering = shallowRef(false)
const isFocused = shallowRef(false)
const startY = shallowRef(0)
const mainStyles = ref()
const hasLayout = inject(VuetifyLayoutKey, undefined)
Expand All @@ -161,61 +160,19 @@ export const VSnackbar = genericComponent<VSnackbarSlots>()({
})
})

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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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(() => <VSnackbar modelValue timeout={ 1000 } text="auto dismiss" />)

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(() => <VSnackbar modelValue timeout={ 500 } text="hover" />)

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(() => <VSnackbar modelValue timeout={ -1 } text="persistent" />)

await expect.poll(() => screen.queryByCSS(CONTENT)).toBeVisible()
await wait(300)

expect(screen.queryByCSS(CONTENT)).toBeVisible()
})
})
})
Loading
Loading