Skip to content

Commit b8a8f38

Browse files
committed
refactor(VAlert): rename duration to timeout, share dismiss logic
Addresses review feedback on vuetifyjs#22947: - Rename the `VAlert` `duration` prop to `timeout` so it matches `VSnackbar` and avoids the cognitive load of two differently-named props with the same behavior. - Extract the auto-dismiss timeout and pause-on-hover/focus handling that was duplicated between `VSnackbar` and `VAlert` into a shared `useAutoDismiss` composable. `VSnackbar` keeps driving its visual countdown timer through the composable's `onStart`/`onClear` hooks. A `timeout` of `0` or less keeps the alert visible (default), so the behavior remains backwards compatible.
1 parent ddfea8d commit b8a8f38

5 files changed

Lines changed: 125 additions & 117 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
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.",
109
"height": "Sets the height for the component.",
1110
"maxHeight": "Sets the maximum height for the component.",
1211
"maxWidth": "Sets the maximum width for the component.",
@@ -15,6 +14,7 @@
1514
"modelValue": "Controls whether the component is visible or hidden.",
1615
"prominent": "Displays a larger vertically centered icon to draw more attention.",
1716
"tile": "Removes the component's border-radius.",
17+
"timeout": "Time (in milliseconds) to wait until the alert is automatically dismissed. A value of `0` or less keeps the alert visible. Changes to this property will reset the countdown.",
1818
"type": "Create a specialized alert that uses a contextual color and has a pre-defined icon.",
1919
"width": "Sets the width for the component."
2020
},

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

Lines changed: 9 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { VDefaultsProvider } from '@/components/VDefaultsProvider'
88
import { VIcon } from '@/components/VIcon'
99

1010
// Composables
11+
import { useAutoDismiss } from '@/composables/autoDismiss'
1112
import { useTextColor } from '@/composables/color'
1213
import { makeComponentProps } from '@/composables/component'
1314
import { makeDensityProps, useDensity } from '@/composables/density'
@@ -25,7 +26,7 @@ import { makeThemeProps, provideTheme } from '@/composables/theme'
2526
import { genOverlays, makeVariantProps, useVariant } from '@/composables/variant'
2627

2728
// Utilities
28-
import { onMounted, onScopeDispose, shallowRef, toRef, watch } from 'vue'
29+
import { toRef } from 'vue'
2930
import { genericComponent, propsFactory } from '@/util'
3031

3132
// Types
@@ -57,7 +58,7 @@ export const makeVAlertProps = propsFactory({
5758
type: String,
5859
default: '$vuetify.close',
5960
},
60-
duration: {
61+
timeout: {
6162
type: [Number, String],
6263
default: 0,
6364
},
@@ -111,57 +112,13 @@ export const VAlert = genericComponent<VAlertSlots>()({
111112

112113
setup (props, { emit, slots }) {
113114
const isActive = useProxiedModel(props, 'modelValue')
114-
const isHovering = shallowRef(false)
115-
const isFocused = shallowRef(false)
116115

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-
})
116+
const {
117+
onPointerenter,
118+
onPointerleave,
119+
onFocusin,
120+
onFocusout,
121+
} = useAutoDismiss(isActive, () => Number(props.timeout))
165122

166123
const icon = toRef(() => {
167124
if (props.icon === false) return undefined

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

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,36 +40,36 @@ 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 } />)
43+
describe('timeout', () => {
44+
it('keeps the alert visible until the timeout elapses, then dismisses it', async () => {
45+
render(() => <VAlert text="auto dismiss" timeout={ 300 } />)
4646

4747
await screen.findByCSS('.v-alert')
4848

49-
// should not be dismissed before the duration elapses
49+
// should not be dismissed before the timeout elapses
5050
await wait(100)
5151
expect(document.querySelector('.v-alert')).not.toBeNull()
5252

53-
// should be dismissed after the duration
53+
// should be dismissed after the timeout
5454
await expect.poll(() => document.querySelector('.v-alert'), { timeout: 2000 }).toBeNull()
5555
})
5656

5757
it('emits update:modelValue when auto-dismissed', async () => {
5858
const onUpdate = vi.fn()
5959

60-
render(() => <VAlert text="auto dismiss" duration={ 150 } { ...{ 'onUpdate:modelValue': onUpdate } } />)
60+
render(() => <VAlert text="auto dismiss" timeout={ 150 } { ...{ 'onUpdate:modelValue': onUpdate } } />)
6161

6262
await expect.poll(() => onUpdate.mock.calls.length, { timeout: 2000 }).toBeGreaterThan(0)
6363
expect(onUpdate).toHaveBeenLastCalledWith(false)
6464
})
6565

6666
it('pauses the timer while hovered and resumes on leave', async () => {
67-
render(() => <VAlert text="hover" duration={ 500 } />)
67+
render(() => <VAlert text="hover" timeout={ 500 } />)
6868

6969
const alert = await screen.findByCSS('.v-alert')
7070

7171
await userEvent.hover(alert)
72-
// stays visible past its duration while hovered
72+
// stays visible past its timeout while hovered
7373
await wait(700)
7474
expect(document.querySelector('.v-alert')).not.toBeNull()
7575

packages/vuetify/src/components/VSnackbar/VSnackbar.tsx

Lines changed: 13 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { useSnackbarItem } from '@/components/VSnackbarQueue/queue'
1313

1414
// Composables
1515
import { useLayout } from '@/composables'
16+
import { useAutoDismiss } from '@/composables/autoDismiss'
1617
import { forwardRefs } from '@/composables/forwardRefs'
1718
import { IconValue } from '@/composables/icons'
1819
import { VuetifyLayoutKey } from '@/composables/layout'
@@ -26,7 +27,7 @@ import { useToggleScope } from '@/composables/toggleScope'
2627
import { genOverlays, makeVariantProps, useVariant } from '@/composables/variant'
2728

2829
// Utilities
29-
import { computed, inject, mergeProps, nextTick, onMounted, onScopeDispose, ref, shallowRef, watch, watchEffect } from 'vue'
30+
import { computed, inject, mergeProps, nextTick, onScopeDispose, ref, shallowRef, watchEffect } from 'vue'
3031
import { convertToUnit, genericComponent, noop, omit, propsFactory, refElement, useRender } from '@/util'
3132

3233
// Types
@@ -147,8 +148,6 @@ export const VSnackbar = genericComponent<VSnackbarSlots>()({
147148
let _lastOffset: string
148149

149150
const timerRef = ref<VProgressLinear>()
150-
const isHovering = shallowRef(false)
151-
const isFocused = shallowRef(false)
152151
const startY = shallowRef(0)
153152
const mainStyles = ref()
154153
const hasLayout = inject(VuetifyLayoutKey, undefined)
@@ -161,61 +160,19 @@ export const VSnackbar = genericComponent<VSnackbarSlots>()({
161160
})
162161
})
163162

164-
watch(isActive, startTimeout)
165-
watch(() => props.timeout, startTimeout)
166-
167-
onMounted(() => {
168-
if (isActive.value) startTimeout()
163+
const {
164+
isHovering,
165+
isFocused,
166+
onPointerenter,
167+
onPointerleave,
168+
onFocusin,
169+
onFocusout,
170+
} = useAutoDismiss(isActive, () => Number(props.timeout), {
171+
getContentEl: () => overlay.value?.contentEl,
172+
onClear: () => countdown.reset(),
173+
onStart: () => nextTick(() => countdown.start(refElement(timerRef.value))),
169174
})
170175

171-
let activeTimeout = -1
172-
function startTimeout () {
173-
countdown.clear()
174-
window.clearTimeout(activeTimeout)
175-
const timeout = Number(props.timeout)
176-
177-
if (!isActive.value || timeout === -1) return
178-
179-
countdown.reset()
180-
181-
const element = refElement(timerRef.value)
182-
183-
nextTick(() => countdown.start(element))
184-
185-
activeTimeout = window.setTimeout(() => {
186-
isActive.value = false
187-
}, timeout)
188-
}
189-
190-
function clearTimeout () {
191-
countdown.reset()
192-
window.clearTimeout(activeTimeout)
193-
}
194-
195-
function onPointerenter () {
196-
isHovering.value = true
197-
clearTimeout()
198-
}
199-
200-
function onPointerleave () {
201-
isHovering.value = false
202-
if (!isFocused.value) startTimeout()
203-
}
204-
205-
function onFocusin () {
206-
isFocused.value = true
207-
clearTimeout()
208-
}
209-
210-
function onFocusout (event: FocusEvent) {
211-
const contentEl = overlay.value?.contentEl
212-
if (contentEl?.contains(event.relatedTarget as Node)) {
213-
return
214-
}
215-
isFocused.value = false
216-
if (!isHovering.value) startTimeout()
217-
}
218-
219176
function onTouchstart (event: TouchEvent) {
220177
startY.value = event.touches[0].clientY
221178
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// Utilities
2+
import { onMounted, onScopeDispose, shallowRef, watch } from 'vue'
3+
4+
// Types
5+
import type { Ref } from 'vue'
6+
7+
export interface AutoDismissOptions {
8+
/** Called when the timeout (re)starts */
9+
onStart?: () => void
10+
/** Called when the pending timeout is cleared */
11+
onClear?: () => void
12+
/** Element scoping the focusout containment check, defaults to the event's currentTarget */
13+
getContentEl?: () => HTMLElement | null | undefined
14+
}
15+
16+
/**
17+
* Toggles `isActive` off after `timeout` ms, pausing while hovered or focused.
18+
* A `timeout` of `0` or less never dismisses. Shared by VSnackbar and VAlert.
19+
*/
20+
export function useAutoDismiss (
21+
isActive: Ref<boolean>,
22+
timeout: () => number,
23+
options: AutoDismissOptions = {},
24+
) {
25+
const isHovering = shallowRef(false)
26+
const isFocused = shallowRef(false)
27+
28+
let activeTimeout = -1
29+
30+
function startTimeout () {
31+
options.onClear?.()
32+
window.clearTimeout(activeTimeout)
33+
34+
const value = timeout()
35+
36+
if (!isActive.value || value <= 0 || isHovering.value || isFocused.value) return
37+
38+
options.onStart?.()
39+
40+
activeTimeout = window.setTimeout(() => {
41+
isActive.value = false
42+
}, value)
43+
}
44+
45+
function clearTimeout () {
46+
options.onClear?.()
47+
window.clearTimeout(activeTimeout)
48+
}
49+
50+
function onPointerenter () {
51+
isHovering.value = true
52+
clearTimeout()
53+
}
54+
55+
function onPointerleave () {
56+
isHovering.value = false
57+
if (!isFocused.value) startTimeout()
58+
}
59+
60+
function onFocusin () {
61+
isFocused.value = true
62+
clearTimeout()
63+
}
64+
65+
function onFocusout (e: FocusEvent) {
66+
const contentEl = options.getContentEl?.() ?? (e.currentTarget as HTMLElement | null)
67+
if (contentEl?.contains(e.relatedTarget as Node)) return
68+
69+
isFocused.value = false
70+
if (!isHovering.value) startTimeout()
71+
}
72+
73+
watch(isActive, startTimeout)
74+
watch(timeout, startTimeout)
75+
76+
onMounted(() => {
77+
if (isActive.value) startTimeout()
78+
})
79+
80+
onScopeDispose(() => {
81+
window.clearTimeout(activeTimeout)
82+
})
83+
84+
return {
85+
isHovering,
86+
isFocused,
87+
startTimeout,
88+
clearTimeout,
89+
onPointerenter,
90+
onPointerleave,
91+
onFocusin,
92+
onFocusout,
93+
}
94+
}

0 commit comments

Comments
 (0)