diff --git a/app/components/malio/time/composables/useInfiniteWheel.test.ts b/app/components/malio/time/composables/useInfiniteWheel.test.ts index 94d78ad..60b5c2d 100644 --- a/app/components/malio/time/composables/useInfiniteWheel.test.ts +++ b/app/components/malio/time/composables/useInfiniteWheel.test.ts @@ -1,9 +1,12 @@ import {describe, expect, it} from 'vitest' +import {defineComponent, nextTick, ref} from 'vue' +import {mount} from '@vue/test-utils' import { CENTER_OFFSET, VISIBLE_ROWS, loopCorrection, scrollTopForValueIndex, + useInfiniteWheel, valueIndexFromScroll, } from './useInfiniteWheel' @@ -43,3 +46,48 @@ describe('useInfiniteWheel — math pure', () => { expect(loopCorrection(drifted, H, LEN)).toBe(drifted - LEN * H) }) }) + +function mountWheelHarness(initialIndex: number, onChange: (i: number) => void) { + let api!: ReturnType + const Harness = defineComponent({ + setup() { + const container = ref(null) + api = useInfiniteWheel(container, { + length: 24, + itemHeight: 40, + initialIndex: () => initialIndex, + onChange, + }) + return {container} + }, + template: '
', + }) + 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) + }) +}) diff --git a/app/components/malio/time/composables/useInfiniteWheel.ts b/app/components/malio/time/composables/useInfiniteWheel.ts index 0951e3e..ecc48bb 100644 --- a/app/components/malio/time/composables/useInfiniteWheel.ts +++ b/app/components/malio/time/composables/useInfiniteWheel.ts @@ -1,3 +1,5 @@ +import {onBeforeUnmount, onMounted, ref, type Ref} from 'vue' + export const VISIBLE_ROWS = 5 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 return scrollTop } + +export interface UseInfiniteWheelOptions { + length: number + itemHeight: number + initialIndex: () => number + onChange: (index: number) => void +} + +export function useInfiniteWheel( + containerRef: Ref, + options: UseInfiniteWheelOptions, +) { + const centeredIndex = ref(options.initialIndex()) + let programmatic = false + let scrollEndTimer: ReturnType | 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} +}