feat : câblage scroll/clavier de la molette infinie (MUI-39)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,12 @@
|
|||||||
import {describe, expect, it} from 'vitest'
|
import {describe, expect, it} from 'vitest'
|
||||||
|
import {defineComponent, nextTick, ref} from 'vue'
|
||||||
|
import {mount} from '@vue/test-utils'
|
||||||
import {
|
import {
|
||||||
CENTER_OFFSET,
|
CENTER_OFFSET,
|
||||||
VISIBLE_ROWS,
|
VISIBLE_ROWS,
|
||||||
loopCorrection,
|
loopCorrection,
|
||||||
scrollTopForValueIndex,
|
scrollTopForValueIndex,
|
||||||
|
useInfiniteWheel,
|
||||||
valueIndexFromScroll,
|
valueIndexFromScroll,
|
||||||
} from './useInfiniteWheel'
|
} from './useInfiniteWheel'
|
||||||
|
|
||||||
@@ -43,3 +46,48 @@ describe('useInfiniteWheel — math pure', () => {
|
|||||||
expect(loopCorrection(drifted, H, LEN)).toBe(drifted - LEN * H)
|
expect(loopCorrection(drifted, H, LEN)).toBe(drifted - LEN * H)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function mountWheelHarness(initialIndex: number, onChange: (i: number) => void) {
|
||||||
|
let api!: ReturnType<typeof useInfiniteWheel>
|
||||||
|
const Harness = defineComponent({
|
||||||
|
setup() {
|
||||||
|
const container = ref<HTMLElement | null>(null)
|
||||||
|
api = useInfiniteWheel(container, {
|
||||||
|
length: 24,
|
||||||
|
itemHeight: 40,
|
||||||
|
initialIndex: () => initialIndex,
|
||||||
|
onChange,
|
||||||
|
})
|
||||||
|
return {container}
|
||||||
|
},
|
||||||
|
template: '<div ref="container" style="height:200px;overflow:auto"><div style="height:2880px" /></div>',
|
||||||
|
})
|
||||||
|
const wrapper = mount(Harness, {attachTo: document.body})
|
||||||
|
return {wrapper, api: () => api}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useInfiniteWheel — composable', () => {
|
||||||
|
it('step(+1) émet l\'index suivant', async () => {
|
||||||
|
const changes: number[] = []
|
||||||
|
const {api} = mountWheelHarness(9, (i) => changes.push(i))
|
||||||
|
await nextTick()
|
||||||
|
api().step(1)
|
||||||
|
expect(changes.at(-1)).toBe(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('step boucle de 23 à 0', async () => {
|
||||||
|
const changes: number[] = []
|
||||||
|
const {api} = mountWheelHarness(23, (i) => changes.push(i))
|
||||||
|
await nextTick()
|
||||||
|
api().step(1)
|
||||||
|
expect(changes.at(-1)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('onKeydown ArrowUp décrémente (avec wrap)', async () => {
|
||||||
|
const changes: number[] = []
|
||||||
|
const {api} = mountWheelHarness(0, (i) => changes.push(i))
|
||||||
|
await nextTick()
|
||||||
|
api().onKeydown(new KeyboardEvent('keydown', {key: 'ArrowUp'}))
|
||||||
|
expect(changes.at(-1)).toBe(23)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import {onBeforeUnmount, onMounted, ref, type Ref} from 'vue'
|
||||||
|
|
||||||
export const VISIBLE_ROWS = 5
|
export const VISIBLE_ROWS = 5
|
||||||
export const CENTER_OFFSET = (VISIBLE_ROWS - 1) / 2 // 2
|
export const CENTER_OFFSET = (VISIBLE_ROWS - 1) / 2 // 2
|
||||||
|
|
||||||
@@ -21,3 +23,94 @@ export function loopCorrection(scrollTop: number, itemHeight: number, length: nu
|
|||||||
if (centeredFlat >= 2 * length) return scrollTop - block
|
if (centeredFlat >= 2 * length) return scrollTop - block
|
||||||
return scrollTop
|
return scrollTop
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UseInfiniteWheelOptions {
|
||||||
|
length: number
|
||||||
|
itemHeight: number
|
||||||
|
initialIndex: () => number
|
||||||
|
onChange: (index: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInfiniteWheel(
|
||||||
|
containerRef: Ref<HTMLElement | null>,
|
||||||
|
options: UseInfiniteWheelOptions,
|
||||||
|
) {
|
||||||
|
const centeredIndex = ref(options.initialIndex())
|
||||||
|
let programmatic = false
|
||||||
|
let scrollEndTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
function readCentered() {
|
||||||
|
const el = containerRef.value
|
||||||
|
if (!el) return
|
||||||
|
centeredIndex.value = valueIndexFromScroll(el.scrollTop, options.itemHeight, options.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
function settle() {
|
||||||
|
const el = containerRef.value
|
||||||
|
if (!el) return
|
||||||
|
const corrected = loopCorrection(el.scrollTop, options.itemHeight, options.length)
|
||||||
|
if (corrected !== el.scrollTop) {
|
||||||
|
programmatic = true
|
||||||
|
el.scrollTop = corrected
|
||||||
|
}
|
||||||
|
readCentered()
|
||||||
|
options.onChange(centeredIndex.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onScroll() {
|
||||||
|
if (programmatic) {
|
||||||
|
programmatic = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
readCentered()
|
||||||
|
if (scrollEndTimer) clearTimeout(scrollEndTimer)
|
||||||
|
scrollEndTimer = setTimeout(settle, 120)
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToIndex(index: number, smooth = true) {
|
||||||
|
const el = containerRef.value
|
||||||
|
centeredIndex.value = index
|
||||||
|
if (el) {
|
||||||
|
programmatic = true
|
||||||
|
const top = scrollTopForValueIndex(index, options.itemHeight, options.length)
|
||||||
|
if (smooth && typeof el.scrollTo === 'function') {
|
||||||
|
el.scrollTo({top, behavior: 'smooth'})
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
el.scrollTop = top
|
||||||
|
}
|
||||||
|
}
|
||||||
|
options.onChange(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
function step(delta: number) {
|
||||||
|
const next = (((centeredIndex.value + delta) % options.length) + options.length) % options.length
|
||||||
|
scrollToIndex(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault()
|
||||||
|
step(-1)
|
||||||
|
}
|
||||||
|
else if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault()
|
||||||
|
step(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const el = containerRef.value
|
||||||
|
if (!el) return
|
||||||
|
el.addEventListener('scroll', onScroll, {passive: true})
|
||||||
|
programmatic = true
|
||||||
|
el.scrollTop = scrollTopForValueIndex(options.initialIndex(), options.itemHeight, options.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
containerRef.value?.removeEventListener('scroll', onScroll)
|
||||||
|
if (scrollEndTimer) clearTimeout(scrollEndTimer)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {centeredIndex, scrollToIndex, step, onKeydown}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user