feat: Ajout des composants modal, accordeon, datetime avec selecteur d'heure à la molette (#56)
Release / release (push) Successful in 2m38s
Release / release (push) Successful in 2m38s
| 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é --------- Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr> Co-authored-by: matthieu <matthieu@yuno.malio.fr> Reviewed-on: #56 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #56.
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import {describe, expect, it} from 'vitest'
|
||||
import {clampHours, clampMinutes, formatTime, padSegment, parseTime} from './timeFormat'
|
||||
|
||||
describe('timeFormat', () => {
|
||||
it('parse une chaîne HH:MM valide', () => {
|
||||
expect(parseTime('09:05')).toEqual({hours: 9, minutes: 5})
|
||||
})
|
||||
|
||||
it('renvoie null pour vide ou invalide', () => {
|
||||
expect(parseTime('')).toBeNull()
|
||||
expect(parseTime(null)).toBeNull()
|
||||
expect(parseTime('abc')).toBeNull()
|
||||
expect(parseTime('12')).toBeNull()
|
||||
})
|
||||
|
||||
it('clamp les valeurs hors bornes au parsing', () => {
|
||||
expect(parseTime('99:88')).toEqual({hours: 23, minutes: 59})
|
||||
})
|
||||
|
||||
it('formate avec zéro-padding', () => {
|
||||
expect(formatTime(9, 5)).toBe('09:05')
|
||||
expect(formatTime(0, 0)).toBe('00:00')
|
||||
})
|
||||
|
||||
it('clamp et pad les helpers', () => {
|
||||
expect(clampHours(30)).toBe(23)
|
||||
expect(clampHours(-2)).toBe(0)
|
||||
expect(clampMinutes(75)).toBe(59)
|
||||
expect(padSegment(7)).toBe('07')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
export interface TimeParts {
|
||||
hours: number
|
||||
minutes: number
|
||||
}
|
||||
|
||||
export function clampHours(value: number): number {
|
||||
if (Number.isNaN(value)) return 0
|
||||
return Math.min(23, Math.max(0, Math.trunc(value)))
|
||||
}
|
||||
|
||||
export function clampMinutes(value: number): number {
|
||||
if (Number.isNaN(value)) return 0
|
||||
return Math.min(59, Math.max(0, Math.trunc(value)))
|
||||
}
|
||||
|
||||
export function padSegment(value: number): string {
|
||||
return value.toString().padStart(2, '0')
|
||||
}
|
||||
|
||||
export function parseTime(value: string | null | undefined): TimeParts | null {
|
||||
if (!value) return null
|
||||
const match = /^(\d{1,2}):(\d{1,2})$/.exec(value.trim())
|
||||
if (!match) return null
|
||||
return {
|
||||
hours: clampHours(Number.parseInt(match[1], 10)),
|
||||
minutes: clampMinutes(Number.parseInt(match[2], 10)),
|
||||
}
|
||||
}
|
||||
|
||||
export function formatTime(hours: number, minutes: number): string {
|
||||
return `${padSegment(clampHours(hours))}:${padSegment(clampMinutes(minutes))}`
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import {describe, expect, it, vi} 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'
|
||||
|
||||
const H = 40 // itemHeight
|
||||
const LEN = 24 // ex. heures
|
||||
|
||||
describe('useInfiniteWheel — math pure', () => {
|
||||
it('expose 5 lignes visibles et un offset central de 2', () => {
|
||||
expect(VISIBLE_ROWS).toBe(5)
|
||||
expect(CENTER_OFFSET).toBe(2)
|
||||
})
|
||||
|
||||
it('scrollTopForValueIndex et valueIndexFromScroll font un aller-retour', () => {
|
||||
for (const index of [0, 1, 9, 23]) {
|
||||
const top = scrollTopForValueIndex(index, H, LEN)
|
||||
expect(valueIndexFromScroll(top, H, LEN)).toBe(index)
|
||||
}
|
||||
})
|
||||
|
||||
it('valueIndexFromScroll boucle en modulo', () => {
|
||||
const top = scrollTopForValueIndex(0, H, LEN)
|
||||
expect(valueIndexFromScroll(top + LEN * H, H, LEN)).toBe(0)
|
||||
})
|
||||
|
||||
it('loopCorrection laisse le scroll de la copie du milieu inchangé', () => {
|
||||
const top = scrollTopForValueIndex(12, H, LEN)
|
||||
expect(loopCorrection(top, H, LEN)).toBe(top)
|
||||
})
|
||||
|
||||
it('loopCorrection ramène vers le milieu quand on dérive vers le haut', () => {
|
||||
const drifted = scrollTopForValueIndex(0, H, LEN) - LEN * H
|
||||
expect(loopCorrection(drifted, H, LEN)).toBe(drifted + LEN * H)
|
||||
})
|
||||
|
||||
it('loopCorrection ramène vers le milieu quand on dérive vers le bas', () => {
|
||||
const drifted = scrollTopForValueIndex(0, H, LEN) + 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)
|
||||
})
|
||||
|
||||
// Anti-boucle navigateur : un scroll programmatique déclenche une rafale d'évènements
|
||||
// scroll (animation/snap). Ils ne doivent PAS être pris pour du scroll utilisateur,
|
||||
// sinon settle() ré-émet en boucle et corrompt le patch DOM de Vue.
|
||||
it('n\'émet pas en double quand un scroll programmatique déclenche une rafale de scroll', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const changes: number[] = []
|
||||
const {wrapper, api} = mountWheelHarness(9, (i) => changes.push(i))
|
||||
await nextTick()
|
||||
const el = wrapper.element as HTMLElement
|
||||
changes.length = 0
|
||||
|
||||
api().scrollToIndex(12)
|
||||
|
||||
el.dispatchEvent(new Event('scroll'))
|
||||
el.dispatchEvent(new Event('scroll'))
|
||||
el.dispatchEvent(new Event('scroll'))
|
||||
|
||||
vi.advanceTimersByTime(300)
|
||||
|
||||
expect(changes).toEqual([12])
|
||||
}
|
||||
finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
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}
|
||||
}
|
||||
Reference in New Issue
Block a user