Skip to content
33 changes: 32 additions & 1 deletion packages/vuetify/src/components/VWindow/VWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { makeThemeProps, provideTheme } from '@/composables/theme'
import vTouch from '@/directives/touch'

// Utilities
import { computed, nextTick, provide, ref, shallowRef, toRef, watch } from 'vue'
import { computed, nextTick, onScopeDispose, provide, ref, shallowRef, toRef, watch } from 'vue'
import { convertToUnit, genericComponent, IN_BROWSER, PREFERS_REDUCED_MOTION, propsFactory, useRender } from '@/util'
import { getScrollParent } from '@/util/getScrollParent'

Expand Down Expand Up @@ -67,6 +67,7 @@ export const makeVWindowProps = propsFactory({
validator: (v: any) => typeof v === 'boolean' || v === 'hover',
},
verticalArrows: [Boolean, String] as PropType<boolean | 'left' | 'right'>,
wheel: Boolean,
touch: {
type: [Object, Boolean] as PropType<boolean | TouchHandlers>,
default: undefined,
Expand Down Expand Up @@ -205,6 +206,35 @@ export const VWindow = genericComponent<new <T>(
canMoveForward.value && group.next()
}

let wheelTimeout = -1

function onWheel (e: WheelEvent) {
if (!props.wheel) return

const delta = props.direction === 'vertical'
? (e.shiftKey ? 0 : e.deltaY)
: e.deltaX || (e.shiftKey ? e.deltaY : 0)
if (!delta) return

const canMove = delta > 0 ? canMoveForward.value : canMoveBack.value
if (canMove) e.preventDefault()

const isWheelActive = wheelTimeout >= 0

window.clearTimeout(wheelTimeout)
wheelTimeout = window.setTimeout(() => {
wheelTimeout = -1
}, 150)

if (!isWheelActive && canMove) {
delta > 0 ? next() : prev()
}
}

onScopeDispose(() => {
if (IN_BROWSER) window.clearTimeout(wheelTimeout)
})

const arrows = computed(() => {
const arrows = []

Expand Down Expand Up @@ -311,6 +341,7 @@ export const VWindow = genericComponent<new <T>(
},
]}
v-touch={ touchOptions.value }
onWheel={ onWheel }
>
<div
class="v-window__container"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { VWindow } from '../VWindow'
import { VWindowItem } from '../VWindowItem'

// Utilities
import { commands, page, render, screen, showcase, userEvent } from '@test'
import { commands, page, render, screen, showcase, userEvent, wait, wheel } from '@test'
import { ref } from 'vue'

const stories = {
Expand Down Expand Up @@ -248,6 +248,72 @@ describe('VWindow', () => {
expect(screen.getByCSS('.v-window-item--active h1')).toHaveTextContent('1. foo')
})

it('should navigate horizontal windows with horizontal or shift+wheel input', async () => {
const model = ref(1)

render(() => (
<VWindow v-model={ model.value } wheel>
<VWindowItem value={ 1 }>
<div class="bg-grey d-flex justify-center align-center"><h1>1. foo</h1></div>
</VWindowItem>
<VWindowItem value={ 2 }>
<div class="bg-grey d-flex justify-center align-center"><h1>2. bar</h1></div>
</VWindowItem>
</VWindow>
))

await commands.waitStable('.v-window')
const windowEl = screen.getByCSS('.v-window')

// vertical wheel is ignored on a horizontal window
expect(wheel(windowEl, { deltaY: 100 })).toBe(true)
expect(model.value).toBe(1)

// horizontal wheel navigates and prevents default
expect(wheel(windowEl, { deltaX: 100 })).toBe(false)
await commands.waitStable('.v-window')
expect(model.value).toBe(2)

// a second wheel within the debounce window is swallowed
wheel(windowEl, { deltaX: -100 })
await commands.waitStable('.v-window')
expect(model.value).toBe(2)

// once the debounce window passes, shift+wheel navigates back
await wait(160)
expect(wheel(windowEl, { deltaY: -100, shiftKey: true })).toBe(false)
await commands.waitStable('.v-window')
expect(model.value).toBe(1)
})

it('should navigate vertical windows only with vertical wheel input', async () => {
const model = ref(1)

render(() => (
<VWindow v-model={ model.value } wheel direction="vertical">
<VWindowItem value={ 1 }>
<div class="bg-grey d-flex justify-center align-center"><h1>1. foo</h1></div>
</VWindowItem>
<VWindowItem value={ 2 }>
<div class="bg-grey d-flex justify-center align-center"><h1>2. bar</h1></div>
</VWindowItem>
</VWindow>
))

await commands.waitStable('.v-window')
const windowEl = screen.getByCSS('.v-window')

// horizontal and shift+wheel (horizontal intent) are ignored on a vertical window
expect(wheel(windowEl, { deltaX: 100 })).toBe(true)
expect(wheel(windowEl, { deltaY: 100, shiftKey: true })).toBe(true)
expect(model.value).toBe(1)

// vertical wheel navigates and prevents default
expect(wheel(windowEl, { deltaY: 100 })).toBe(false)
await commands.waitStable('.v-window')
expect(model.value).toBe(2)
})

describe('keyboard controls', () => {
it('should support horizontal keyboard navigation', async () => {
const model = ref(1)
Expand Down
5 changes: 5 additions & 0 deletions packages/vuetify/test/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ export function touch (element: Element) {
}
}

export const wheel = (element: Element, options: WheelEventInit) => {
// replace with userEvent.wheel (vitest@4.1.x) unless it would make tests flaky
return element.dispatchEvent(new WheelEvent('wheel', { bubbles: true, cancelable: true, ...options }))
}

export const wait = (timeout?: number) => {
return new Promise(resolve => setTimeout(resolve, timeout))
}
Expand Down
Loading