feat : update modal style
This commit is contained in:
@@ -14,7 +14,12 @@
|
|||||||
"Bash(mv InputSelect.story.vue selectCheckbox.story.vue select/)",
|
"Bash(mv InputSelect.story.vue selectCheckbox.story.vue select/)",
|
||||||
"Bash(mv inputCheckbox.story.vue checkbox/)",
|
"Bash(mv inputCheckbox.story.vue checkbox/)",
|
||||||
"Bash(npx eslint *)",
|
"Bash(npx eslint *)",
|
||||||
"Bash(echo \"LINT EXIT: $?\")"
|
"Bash(echo \"LINT EXIT: $?\")",
|
||||||
|
"Bash(git commit *)",
|
||||||
|
"mcp__chrome__navigate_page",
|
||||||
|
"mcp__chrome__take_snapshot",
|
||||||
|
"mcp__chrome__click",
|
||||||
|
"mcp__chrome__evaluate_script"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const mountDateTime = (props: DateTimeProps = {}) =>
|
|||||||
describe('MalioDateTime', () => {
|
describe('MalioDateTime', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026
|
vi.setSystemTime(new Date(2026, 4, 19, 9, 5, 0)) // 19 mai 2026, 09:05
|
||||||
})
|
})
|
||||||
afterEach(() => vi.useRealTimers())
|
afterEach(() => vi.useRealTimers())
|
||||||
|
|
||||||
@@ -60,11 +60,12 @@ describe('MalioDateTime', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('sélection', () => {
|
describe('sélection', () => {
|
||||||
it('émet le jour à 00:00 et garde le popover ouvert', async () => {
|
it('émet le jour à l\'heure actuelle (si aucune heure choisie) et garde le popover ouvert', async () => {
|
||||||
const wrapper = mountDateTime()
|
const wrapper = mountDateTime()
|
||||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||||
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
|
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
|
||||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19T00:00:00'])
|
// heure système figée à 09:05
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19T09:05:00'])
|
||||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
|
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -28,10 +28,12 @@
|
|||||||
:max="max?.slice(0, 10)"
|
:max="max?.slice(0, 10)"
|
||||||
@select="onSelectDay"
|
@select="onSelectDay"
|
||||||
/>
|
/>
|
||||||
<div class="mt-[26px]">
|
<div class="mt-4">
|
||||||
<MalioTimePicker
|
<MalioTimePicker
|
||||||
:model-value="timeValue || null"
|
:model-value="timeValue || null"
|
||||||
|
label="Heure"
|
||||||
:clearable="false"
|
:clearable="false"
|
||||||
|
static-popover
|
||||||
@update:model-value="onTimeChange"
|
@update:model-value="onTimeChange"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -44,6 +46,7 @@ import {computed, ref, watch} from 'vue'
|
|||||||
import CalendarField from './internal/CalendarField.vue'
|
import CalendarField from './internal/CalendarField.vue'
|
||||||
import MonthGrid from './internal/MonthGrid.vue'
|
import MonthGrid from './internal/MonthGrid.vue'
|
||||||
import MalioTimePicker from '../time/TimePicker.vue'
|
import MalioTimePicker from '../time/TimePicker.vue'
|
||||||
|
import {formatTime} from '../time/composables/timeFormat'
|
||||||
import {composeDateTime, formatIsoDateTimeToDisplay, isValidIsoDateTime, splitDateTime} from './composables/datetimeFormat'
|
import {composeDateTime, formatIsoDateTimeToDisplay, isValidIsoDateTime, splitDateTime} from './composables/datetimeFormat'
|
||||||
|
|
||||||
defineOptions({name: 'MalioDateTime', inheritAttrs: false})
|
defineOptions({name: 'MalioDateTime', inheritAttrs: false})
|
||||||
@@ -100,7 +103,10 @@ const displayValue = computed(() => formatIsoDateTimeToDisplay(props.modelValue
|
|||||||
const timeValue = computed(() => parts.value.time || pendingTime.value)
|
const timeValue = computed(() => parts.value.time || pendingTime.value)
|
||||||
|
|
||||||
function onSelectDay(iso: string) {
|
function onSelectDay(iso: string) {
|
||||||
const time = parts.value.time || pendingTime.value || '00:00'
|
// Si aucune heure n'a été choisie, on prend l'heure actuelle (pas 00:00).
|
||||||
|
// (heure courante au moment du clic)
|
||||||
|
const now = new Date()
|
||||||
|
const time = parts.value.time || pendingTime.value || formatTime(now.getHours(), now.getMinutes())
|
||||||
emit('update:modelValue', composeDateTime(iso, time))
|
emit('update:modelValue', composeDateTime(iso, time))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div ref="root">
|
||||||
<div
|
<div :class="mergedGroupClass">
|
||||||
ref="root"
|
|
||||||
:class="mergedGroupClass"
|
|
||||||
>
|
|
||||||
<input
|
<input
|
||||||
:id="inputId"
|
:id="inputId"
|
||||||
:name="name"
|
:name="name"
|
||||||
@@ -52,11 +49,12 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Mode overlay (par défaut) : popover absolu au-dessus du contenu suivant. -->
|
||||||
<div
|
<div
|
||||||
v-if="isOpen"
|
v-if="isOpen && !staticPopover"
|
||||||
data-test="popover"
|
data-test="popover"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
class="absolute left-0 right-0 top-full z-20 box-border w-full bg-white p-[10px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
class="absolute left-0 right-0 top-full z-20 box-border w-full bg-white shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||||
>
|
>
|
||||||
<TimeWheels
|
<TimeWheels
|
||||||
:model-value="wheelsValue"
|
:model-value="wheelsValue"
|
||||||
@@ -65,6 +63,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Mode statique : molette en flux (hors du groupe à hauteur fixe) → le
|
||||||
|
conteneur parent (ex. popover du DateTime) grandit pour l'englober. -->
|
||||||
|
<div
|
||||||
|
v-if="isOpen && staticPopover"
|
||||||
|
data-test="popover"
|
||||||
|
role="dialog"
|
||||||
|
class="relative mt-4 w-full bg-white shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||||
|
>
|
||||||
|
<TimeWheels
|
||||||
|
:model-value="wheelsValue"
|
||||||
|
@update:model-value="onWheelChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
v-if="hint || hasError || hasSuccess"
|
v-if="hint || hasError || hasSuccess"
|
||||||
:id="`${inputId}-describedby`"
|
:id="`${inputId}-describedby`"
|
||||||
@@ -100,6 +112,7 @@ const props = withDefaults(
|
|||||||
error?: string
|
error?: string
|
||||||
success?: string
|
success?: string
|
||||||
clearable?: boolean
|
clearable?: boolean
|
||||||
|
staticPopover?: boolean
|
||||||
inputClass?: string
|
inputClass?: string
|
||||||
labelClass?: string
|
labelClass?: string
|
||||||
groupClass?: string
|
groupClass?: string
|
||||||
@@ -117,6 +130,7 @@ const props = withDefaults(
|
|||||||
error: '',
|
error: '',
|
||||||
success: '',
|
success: '',
|
||||||
clearable: true,
|
clearable: true,
|
||||||
|
staticPopover: false,
|
||||||
inputClass: '',
|
inputClass: '',
|
||||||
labelClass: '',
|
labelClass: '',
|
||||||
groupClass: '',
|
groupClass: '',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {describe, expect, it} from 'vitest'
|
import {describe, expect, it, vi} from 'vitest'
|
||||||
import {defineComponent, nextTick, ref} from 'vue'
|
import {defineComponent, nextTick, ref} from 'vue'
|
||||||
import {mount} from '@vue/test-utils'
|
import {mount} from '@vue/test-utils'
|
||||||
import {
|
import {
|
||||||
@@ -90,4 +90,31 @@ describe('useInfiniteWheel — composable', () => {
|
|||||||
api().onKeydown(new KeyboardEvent('keydown', {key: 'ArrowUp'}))
|
api().onKeydown(new KeyboardEvent('keydown', {key: 'ArrowUp'}))
|
||||||
expect(changes.at(-1)).toBe(23)
|
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()
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -36,8 +36,25 @@ export function useInfiniteWheel(
|
|||||||
options: UseInfiniteWheelOptions,
|
options: UseInfiniteWheelOptions,
|
||||||
) {
|
) {
|
||||||
const centeredIndex = ref(options.initialIndex())
|
const centeredIndex = ref(options.initialIndex())
|
||||||
let programmatic = false
|
|
||||||
let scrollEndTimer: ReturnType<typeof setTimeout> | null = null
|
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() {
|
function readCentered() {
|
||||||
const el = containerRef.value
|
const el = containerRef.value
|
||||||
@@ -48,38 +65,22 @@ export function useInfiniteWheel(
|
|||||||
function settle() {
|
function settle() {
|
||||||
const el = containerRef.value
|
const el = containerRef.value
|
||||||
if (!el) return
|
if (!el) return
|
||||||
const corrected = loopCorrection(el.scrollTop, options.itemHeight, options.length)
|
|
||||||
if (corrected !== el.scrollTop) {
|
|
||||||
programmatic = true
|
|
||||||
el.scrollTop = corrected
|
|
||||||
}
|
|
||||||
readCentered()
|
readCentered()
|
||||||
options.onChange(centeredIndex.value)
|
options.onChange(centeredIndex.value)
|
||||||
|
const corrected = loopCorrection(el.scrollTop, options.itemHeight, options.length)
|
||||||
|
if (corrected !== el.scrollTop) applyScroll(corrected)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onScroll() {
|
function onScroll() {
|
||||||
if (programmatic) {
|
if (suppressed) return
|
||||||
programmatic = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
readCentered()
|
readCentered()
|
||||||
if (scrollEndTimer) clearTimeout(scrollEndTimer)
|
if (scrollEndTimer) clearTimeout(scrollEndTimer)
|
||||||
scrollEndTimer = setTimeout(settle, 120)
|
scrollEndTimer = setTimeout(settle, 120)
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToIndex(index: number, smooth = true) {
|
function scrollToIndex(index: number) {
|
||||||
const el = containerRef.value
|
|
||||||
centeredIndex.value = index
|
centeredIndex.value = index
|
||||||
if (el) {
|
applyScroll(scrollTopForValueIndex(index, options.itemHeight, options.length))
|
||||||
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)
|
options.onChange(index)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,13 +104,13 @@ export function useInfiniteWheel(
|
|||||||
const el = containerRef.value
|
const el = containerRef.value
|
||||||
if (!el) return
|
if (!el) return
|
||||||
el.addEventListener('scroll', onScroll, {passive: true})
|
el.addEventListener('scroll', onScroll, {passive: true})
|
||||||
programmatic = true
|
applyScroll(scrollTopForValueIndex(options.initialIndex(), options.itemHeight, options.length))
|
||||||
el.scrollTop = scrollTopForValueIndex(options.initialIndex(), options.itemHeight, options.length)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
containerRef.value?.removeEventListener('scroll', onScroll)
|
containerRef.value?.removeEventListener('scroll', onScroll)
|
||||||
if (scrollEndTimer) clearTimeout(scrollEndTimer)
|
if (scrollEndTimer) clearTimeout(scrollEndTimer)
|
||||||
|
if (suppressTimer) clearTimeout(suppressTimer)
|
||||||
})
|
})
|
||||||
|
|
||||||
return {centeredIndex, scrollToIndex, step, onKeydown}
|
return {centeredIndex, scrollToIndex, step, onKeydown}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
ref="container"
|
ref="container"
|
||||||
class="malio-wheel relative h-[200px] w-14 snap-y snap-mandatory overflow-y-scroll"
|
class="malio-wheel relative h-[160px] w-14 snap-y snap-mandatory overflow-y-scroll"
|
||||||
role="spinbutton"
|
role="spinbutton"
|
||||||
:tabindex="0"
|
:tabindex="0"
|
||||||
:aria-label="ariaLabel"
|
:aria-label="ariaLabel"
|
||||||
@@ -16,8 +16,8 @@
|
|||||||
:key="item.key"
|
:key="item.key"
|
||||||
type="button"
|
type="button"
|
||||||
data-test="wheel-item"
|
data-test="wheel-item"
|
||||||
class="flex h-10 w-full snap-center items-center justify-center text-lg outline-none transition-colors"
|
class="flex h-8 w-full snap-center items-center justify-center leading-none outline-none transition-all"
|
||||||
:class="item.value === centeredValue ? 'font-bold text-black' : 'text-m-muted'"
|
:class="itemClass(item.flat)"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
@click="onItemClick(item.value)"
|
@click="onItemClick(item.value)"
|
||||||
>
|
>
|
||||||
@@ -41,7 +41,7 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{(e: 'update:modelValue', value: number): void}>()
|
const emit = defineEmits<{(e: 'update:modelValue', value: number): void}>()
|
||||||
|
|
||||||
const ITEM_HEIGHT = 40
|
const ITEM_HEIGHT = 32
|
||||||
const container = ref<HTMLElement | null>(null)
|
const container = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
const pad = (value: number) => padSegment(value)
|
const pad = (value: number) => padSegment(value)
|
||||||
@@ -54,20 +54,29 @@ const {centeredIndex, scrollToIndex, onKeydown} = useInfiniteWheel(container, {
|
|||||||
onChange: (index) => emit('update:modelValue', props.values[index]),
|
onChange: (index) => emit('update:modelValue', props.values[index]),
|
||||||
})
|
})
|
||||||
|
|
||||||
const centeredValue = computed(() => props.values[centeredIndex.value])
|
|
||||||
|
|
||||||
const buffer = computed(() =>
|
const buffer = computed(() =>
|
||||||
[0, 1, 2].flatMap((copy) =>
|
[0, 1, 2].flatMap((copy) =>
|
||||||
props.values.map((value) => ({value, key: copy * props.values.length + value})),
|
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))
|
const onItemClick = (value: number) => scrollToIndex(indexOfValue(value))
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
(value) => {
|
(value) => {
|
||||||
if (props.values[centeredIndex.value] !== value) scrollToIndex(indexOfValue(value), false)
|
if (props.values[centeredIndex.value] !== value) scrollToIndex(indexOfValue(value))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
@@ -75,6 +84,10 @@ watch(
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.malio-wheel {
|
.malio-wheel {
|
||||||
scrollbar-width: none;
|
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 {
|
.malio-wheel::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
>
|
>
|
||||||
<!-- bande centrale (overlay, traverse les 2 colonnes) -->
|
<!-- bande centrale (overlay, traverse les 2 colonnes) -->
|
||||||
<div
|
<div
|
||||||
class="pointer-events-none absolute inset-x-2 top-1/2 z-0 h-10 -translate-y-1/2 rounded-lg bg-m-primary/10"
|
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
|
<MalioTimeWheel
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
@update:model-value="onHours"
|
@update:model-value="onHours"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<span class="relative z-10 text-lg font-bold text-black">:</span>
|
<span class="relative z-10 text-[14px] font-bold text-black">:</span>
|
||||||
|
|
||||||
<MalioTimeWheel
|
<MalioTimeWheel
|
||||||
:model-value="minutes"
|
:model-value="minutes"
|
||||||
|
|||||||
Reference in New Issue
Block a user