Files
malio-layer-ui/app/components/malio/time/composables/useInfiniteWheel.ts
T
tristan e6a46a9d60 [#MUI-39] Création d'un sélecteur d'heure à molettes (MalioTimePicker) ; DateTime rebranché dessus (remplace l'input time natif intérimaire) (#55)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #55
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-27 12:01:29 +00:00

118 lines
4.1 KiB
TypeScript

import {onBeforeUnmount, onMounted, ref, type Ref} from 'vue'
export const VISIBLE_ROWS = 5
export const CENTER_OFFSET = (VISIBLE_ROWS - 1) / 2 // 2
/** Index de valeur logique (0..length-1) centré pour un scrollTop donné. */
export function valueIndexFromScroll(scrollTop: number, itemHeight: number, length: number): number {
const flat = Math.round(scrollTop / itemHeight) + CENTER_OFFSET
return ((flat % length) + length) % length
}
/** scrollTop qui centre l'index donné dans la copie du milieu (buffer à 3 copies). */
export function scrollTopForValueIndex(valueIndex: number, itemHeight: number, length: number): number {
const flat = length + valueIndex - CENTER_OFFSET
return flat * itemHeight
}
/** Recentre le scrollTop dans la copie du milieu [length, 2*length) si on a dérivé. */
export function loopCorrection(scrollTop: number, itemHeight: number, length: number): number {
const block = length * itemHeight
const centeredFlat = Math.round(scrollTop / itemHeight) + CENTER_OFFSET
if (centeredFlat < length) return scrollTop + block
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<HTMLElement | null>,
options: UseInfiniteWheelOptions,
) {
const centeredIndex = ref(options.initialIndex())
let scrollEndTimer: ReturnType<typeof setTimeout> | null = null
// Fenêtre de suppression : ignore les évènements scroll provoqués par NOS
// repositionnements programmatiques (et les réajustements de scroll-snap), qui
// arrivent en rafale. Un booléen one-shot n'en absorberait qu'un seul : les
// suivants seraient pris pour du scroll utilisateur → settle() → onChange en
// boucle (re-render ré-entrant qui corrompt le patch DOM dans le navigateur).
let suppressed = false
let suppressTimer: ReturnType<typeof setTimeout> | null = null
// Scroll programmatique INSTANTANÉ : pas de 'smooth', dont l'animation multi-frames
// émettrait justement la rafale d'évènements scroll problématique.
function applyScroll(top: number) {
const el = containerRef.value
if (!el) return
suppressed = true
if (suppressTimer) clearTimeout(suppressTimer)
suppressTimer = setTimeout(() => { suppressed = false }, 100)
el.scrollTop = top
}
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
readCentered()
options.onChange(centeredIndex.value)
const corrected = loopCorrection(el.scrollTop, options.itemHeight, options.length)
if (corrected !== el.scrollTop) applyScroll(corrected)
}
function onScroll() {
if (suppressed) return
readCentered()
if (scrollEndTimer) clearTimeout(scrollEndTimer)
scrollEndTimer = setTimeout(settle, 120)
}
function scrollToIndex(index: number) {
centeredIndex.value = index
applyScroll(scrollTopForValueIndex(index, options.itemHeight, options.length))
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})
applyScroll(scrollTopForValueIndex(options.initialIndex(), options.itemHeight, options.length))
})
onBeforeUnmount(() => {
containerRef.value?.removeEventListener('scroll', onScroll)
if (scrollEndTimer) clearTimeout(scrollEndTimer)
if (suppressTimer) clearTimeout(suppressTimer)
})
return {centeredIndex, scrollToIndex, step, onKeydown}
}