feat : composant TimeWheel colonne molette infinie (MUI-39)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,41 @@
|
|||||||
|
import {describe, expect, it} from 'vitest'
|
||||||
|
import {mount} from '@vue/test-utils'
|
||||||
|
import TimeWheel from './TimeWheel.vue'
|
||||||
|
|
||||||
|
const HOURS = Array.from({length: 24}, (_, i) => i)
|
||||||
|
|
||||||
|
const mountWheel = (modelValue = 9) =>
|
||||||
|
mount(TimeWheel, {
|
||||||
|
props: {modelValue, values: HOURS, ariaLabel: 'Heures'},
|
||||||
|
attachTo: document.body,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('MalioTimeWheel', () => {
|
||||||
|
it('expose le rôle spinbutton et les attributs aria', () => {
|
||||||
|
const wrapper = mountWheel(9)
|
||||||
|
const el = wrapper.get('[role="spinbutton"]')
|
||||||
|
expect(el.attributes('aria-label')).toBe('Heures')
|
||||||
|
expect(el.attributes('aria-valuenow')).toBe('9')
|
||||||
|
expect(el.attributes('aria-valuemin')).toBe('0')
|
||||||
|
expect(el.attributes('aria-valuemax')).toBe('23')
|
||||||
|
expect(el.attributes('aria-valuetext')).toBe('09')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rend 3 copies des valeurs (buffer infini)', () => {
|
||||||
|
const wrapper = mountWheel()
|
||||||
|
expect(wrapper.findAll('[data-test="wheel-item"]')).toHaveLength(24 * 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('émet la nouvelle valeur au clavier ArrowDown', async () => {
|
||||||
|
const wrapper = mountWheel(9)
|
||||||
|
await wrapper.get('[role="spinbutton"]').trigger('keydown', {key: 'ArrowDown'})
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([10])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('émet la valeur cliquée', async () => {
|
||||||
|
const wrapper = mountWheel(9)
|
||||||
|
const item = wrapper.findAll('[data-test="wheel-item"]').find((w) => w.text() === '11')!
|
||||||
|
await item.trigger('click')
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([11])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="container"
|
||||||
|
class="malio-wheel relative h-[200px] w-14 snap-y snap-mandatory overflow-y-scroll"
|
||||||
|
role="spinbutton"
|
||||||
|
:tabindex="0"
|
||||||
|
:aria-label="ariaLabel"
|
||||||
|
:aria-valuenow="modelValue"
|
||||||
|
:aria-valuemin="values[0]"
|
||||||
|
:aria-valuemax="values[values.length - 1]"
|
||||||
|
:aria-valuetext="pad(modelValue)"
|
||||||
|
@keydown="onKeydown"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="item in buffer"
|
||||||
|
:key="item.key"
|
||||||
|
type="button"
|
||||||
|
data-test="wheel-item"
|
||||||
|
class="flex h-10 w-full snap-center items-center justify-center text-lg outline-none transition-colors"
|
||||||
|
:class="item.value === centeredValue ? 'font-bold text-black' : 'text-m-muted'"
|
||||||
|
tabindex="-1"
|
||||||
|
@click="onItemClick(item.value)"
|
||||||
|
>
|
||||||
|
{{ pad(item.value) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {computed, ref, watch} from 'vue'
|
||||||
|
import {useInfiniteWheel} from '../composables/useInfiniteWheel'
|
||||||
|
import {padSegment} from '../composables/timeFormat'
|
||||||
|
|
||||||
|
defineOptions({name: 'MalioTimeWheel', inheritAttrs: false})
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: number
|
||||||
|
values: number[]
|
||||||
|
ariaLabel: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{(e: 'update:modelValue', value: number): void}>()
|
||||||
|
|
||||||
|
const ITEM_HEIGHT = 40
|
||||||
|
const container = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
const pad = (value: number) => padSegment(value)
|
||||||
|
const indexOfValue = (value: number) => Math.max(0, props.values.indexOf(value))
|
||||||
|
|
||||||
|
const {centeredIndex, scrollToIndex, onKeydown} = useInfiniteWheel(container, {
|
||||||
|
length: props.values.length,
|
||||||
|
itemHeight: ITEM_HEIGHT,
|
||||||
|
initialIndex: () => indexOfValue(props.modelValue),
|
||||||
|
onChange: (index) => emit('update:modelValue', props.values[index]),
|
||||||
|
})
|
||||||
|
|
||||||
|
const centeredValue = computed(() => props.values[centeredIndex.value])
|
||||||
|
|
||||||
|
const buffer = computed(() =>
|
||||||
|
[0, 1, 2].flatMap((copy) =>
|
||||||
|
props.values.map((value) => ({value, key: copy * props.values.length + value})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const onItemClick = (value: number) => scrollToIndex(indexOfValue(value))
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(value) => {
|
||||||
|
if (props.values[centeredIndex.value] !== value) scrollToIndex(indexOfValue(value), false)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.malio-wheel {
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.malio-wheel::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user