[#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>
This commit was merged in pull request #55.
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,95 @@
|
||||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
class="malio-wheel relative h-[160px] 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-8 w-full snap-center items-center justify-center leading-none outline-none transition-all"
|
||||
:class="itemClass(item.flat)"
|
||||
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 = 32
|
||||
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 buffer = computed(() =>
|
||||
[0, 1, 2].flatMap((copy) =>
|
||||
props.values.map((value, i) => {
|
||||
const flat = copy * props.values.length + i
|
||||
return {value, flat, key: flat}
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
// Taille décroissante avec la distance au centre (effet molette iOS).
|
||||
const itemClass = (flat: number) => {
|
||||
const distance = Math.abs(flat - (props.values.length + centeredIndex.value))
|
||||
if (distance === 0) return 'text-[16px] font-medium text-black'
|
||||
if (distance === 1) return 'text-[14px] text-m-muted'
|
||||
return 'text-[12px] text-m-muted'
|
||||
}
|
||||
|
||||
const onItemClick = (value: number) => scrollToIndex(indexOfValue(value))
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
if (props.values[centeredIndex.value] !== value) scrollToIndex(indexOfValue(value))
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.malio-wheel {
|
||||
scrollbar-width: none;
|
||||
/* Estompe les valeurs en haut et en bas (effet molette iOS) pour qu'elles ne
|
||||
débordent pas visuellement du cadre. */
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 30%, #000 70%, transparent 100%);
|
||||
mask-image: linear-gradient(to bottom, transparent 0%, #000 30%, #000 70%, transparent 100%);
|
||||
}
|
||||
.malio-wheel::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,48 @@
|
||||
import {describe, expect, it} from 'vitest'
|
||||
import {mount} from '@vue/test-utils'
|
||||
import TimeWheels from './TimeWheels.vue'
|
||||
import TimeWheel from './TimeWheel.vue'
|
||||
|
||||
const mountWheels = (modelValue = '09:30') =>
|
||||
mount(TimeWheels, {props: {modelValue}, attachTo: document.body})
|
||||
|
||||
describe('MalioTimeWheels', () => {
|
||||
it('rend deux molettes (heures + minutes) et un séparateur', () => {
|
||||
const wrapper = mountWheels('09:30')
|
||||
const wheels = wrapper.findAllComponents(TimeWheel)
|
||||
expect(wheels).toHaveLength(2)
|
||||
expect(wheels[0].props('ariaLabel')).toBe('Heures')
|
||||
expect(wheels[1].props('ariaLabel')).toBe('Minutes')
|
||||
expect(wrapper.text()).toContain(':')
|
||||
})
|
||||
|
||||
it('splitte modelValue vers les bonnes molettes', () => {
|
||||
const wrapper = mountWheels('09:30')
|
||||
const wheels = wrapper.findAllComponents(TimeWheel)
|
||||
expect(wheels[0].props('modelValue')).toBe(9)
|
||||
expect(wheels[1].props('modelValue')).toBe(30)
|
||||
})
|
||||
|
||||
it('recompose et émet HH:MM quand l\'heure change', async () => {
|
||||
const wrapper = mountWheels('09:30')
|
||||
const wheels = wrapper.findAllComponents(TimeWheel)
|
||||
wheels[0].vm.$emit('update:modelValue', 14)
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['14:30'])
|
||||
})
|
||||
|
||||
it('recompose et émet HH:MM quand la minute change', async () => {
|
||||
const wrapper = mountWheels('09:30')
|
||||
const wheels = wrapper.findAllComponents(TimeWheel)
|
||||
wheels[1].vm.$emit('update:modelValue', 5)
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['09:05'])
|
||||
})
|
||||
|
||||
it('par défaut 00:00 quand modelValue est vide', () => {
|
||||
const wrapper = mountWheels('')
|
||||
const wheels = wrapper.findAllComponents(TimeWheel)
|
||||
expect(wheels[0].props('modelValue')).toBe(0)
|
||||
expect(wheels[1].props('modelValue')).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div
|
||||
data-test="time-wheels"
|
||||
class="relative flex items-center justify-center gap-3 py-2"
|
||||
>
|
||||
<!-- bande centrale (overlay, traverse les 2 colonnes) -->
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-2 top-1/2 z-0 h-8 mx-3 -translate-y-1/2 rounded-lg bg-m-primary-light"
|
||||
/>
|
||||
|
||||
<MalioTimeWheel
|
||||
:model-value="hours"
|
||||
:values="HOURS"
|
||||
aria-label="Heures"
|
||||
class="relative z-10"
|
||||
@update:model-value="onHours"
|
||||
/>
|
||||
|
||||
<span class="relative z-10 text-[14px] font-bold text-black">:</span>
|
||||
|
||||
<MalioTimeWheel
|
||||
:model-value="minutes"
|
||||
:values="MINUTES"
|
||||
aria-label="Minutes"
|
||||
class="relative z-10"
|
||||
@update:model-value="onMinutes"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed} from 'vue'
|
||||
import MalioTimeWheel from './TimeWheel.vue'
|
||||
import {formatTime, parseTime} from '../composables/timeFormat'
|
||||
|
||||
defineOptions({name: 'MalioTimeWheels', inheritAttrs: false})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{modelValue?: string | null}>(),
|
||||
{modelValue: ''},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{(e: 'update:modelValue', value: string): void}>()
|
||||
|
||||
const HOURS = Array.from({length: 24}, (_, i) => i)
|
||||
const MINUTES = Array.from({length: 60}, (_, i) => i)
|
||||
|
||||
const parts = computed(() => parseTime(props.modelValue) ?? {hours: 0, minutes: 0})
|
||||
const hours = computed(() => parts.value.hours)
|
||||
const minutes = computed(() => parts.value.minutes)
|
||||
|
||||
const onHours = (value: number) => emit('update:modelValue', formatTime(value, minutes.value))
|
||||
const onMinutes = (value: number) => emit('update:modelValue', formatTime(hours.value, value))
|
||||
</script>
|
||||
Reference in New Issue
Block a user