[#MUI-33] Développer le composant Datepicker (#50)
| Numéro du ticket | Titre du ticket | |------------------|-----------------| | | | ## Description de la PR ## Modification du .env ## Check list - [x] Pas de régression - [x] TU/TI/TF rédigée - [x] TU/TI/TF OK - [x] CHANGELOG modifié Reviewed-on: #50 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #50.
This commit is contained in:
239
app/components/malio/date/internal/CalendarField.vue
Normal file
239
app/components/malio/date/internal/CalendarField.vue
Normal file
@@ -0,0 +1,239 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
ref="root"
|
||||
:class="mergedGroupClass"
|
||||
>
|
||||
<input
|
||||
:id="inputId"
|
||||
:name="name"
|
||||
data-test="date-input"
|
||||
readonly
|
||||
autocomplete="off"
|
||||
:class="mergedInputClass"
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
:value="displayValue"
|
||||
:aria-invalid="!!error"
|
||||
:aria-describedby="describedBy"
|
||||
:aria-expanded="isOpen"
|
||||
aria-haspopup="dialog"
|
||||
v-bind="attrs"
|
||||
placeholder="_"
|
||||
type="text"
|
||||
@click="onFieldClick"
|
||||
>
|
||||
|
||||
<label
|
||||
v-if="label"
|
||||
:for="inputId"
|
||||
:class="mergedLabelClass"
|
||||
>
|
||||
{{ label }}
|
||||
</label>
|
||||
|
||||
<div class="absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-1">
|
||||
<button
|
||||
v-if="showClear"
|
||||
type="button"
|
||||
data-test="clear"
|
||||
class="text-m-muted hover:text-m-primary"
|
||||
aria-label="Effacer la date"
|
||||
@click.stop="emit('clear')"
|
||||
>
|
||||
<Icon
|
||||
icon="mdi:close"
|
||||
:width="16"
|
||||
:height="16"
|
||||
/>
|
||||
</button>
|
||||
<Icon
|
||||
data-test="calendar-icon"
|
||||
icon="mdi:calendar-blank"
|
||||
:width="24"
|
||||
:height="24"
|
||||
:class="iconStateClass"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isOpen"
|
||||
data-test="popover"
|
||||
role="dialog"
|
||||
class="absolute left-0 right-0 top-full z-20 box-border w-full rounded-b-md bg-white p-[10px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||
>
|
||||
<CalendarHeader
|
||||
:view-mode="viewMode"
|
||||
:current-month="currentMonth"
|
||||
:current-year="currentYear"
|
||||
@prev="goToPrev"
|
||||
@next="goToNext"
|
||||
@toggle-view="toggleView"
|
||||
/>
|
||||
<slot
|
||||
v-if="viewMode === 'days'"
|
||||
:current-month="currentMonth"
|
||||
:current-year="currentYear"
|
||||
:close="closePopover"
|
||||
/>
|
||||
<MonthPicker
|
||||
v-else
|
||||
:selected-month="currentMonth"
|
||||
@select="onSelectMonth"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="hint || hasError || hasSuccess"
|
||||
:id="`${inputId}-describedby`"
|
||||
:class="[
|
||||
hasError ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted',
|
||||
'mt-1 ml-[2px] text-xs',
|
||||
]"
|
||||
>
|
||||
{{ error || success || hint }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, useAttrs, useId, watch} from 'vue'
|
||||
import {Icon} from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import CalendarHeader from './CalendarHeader.vue'
|
||||
import MonthPicker from './MonthPicker.vue'
|
||||
import {useCalendarPopover} from '../composables/useCalendarPopover'
|
||||
import {useCalendarView} from '../composables/useCalendarView'
|
||||
|
||||
defineOptions({name: 'MalioCalendarField', inheritAttrs: false})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
displayValue: string
|
||||
syncTo: string | null
|
||||
id?: string
|
||||
name?: string
|
||||
label?: string
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
hint?: string
|
||||
error?: string
|
||||
success?: string
|
||||
clearable?: boolean
|
||||
inputClass?: string
|
||||
labelClass?: string
|
||||
groupClass?: string
|
||||
}>(),
|
||||
{
|
||||
id: '',
|
||||
name: '',
|
||||
label: '',
|
||||
placeholder: 'JJ/MM/AAAA',
|
||||
required: false,
|
||||
disabled: false,
|
||||
readonly: false,
|
||||
hint: '',
|
||||
error: '',
|
||||
success: '',
|
||||
clearable: true,
|
||||
inputClass: '',
|
||||
labelClass: '',
|
||||
groupClass: '',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{(e: 'clear' | 'close'): void}>()
|
||||
|
||||
const attrs = useAttrs()
|
||||
const generatedId = useId()
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
|
||||
const {isOpen, viewMode, open, close: closePopover, toggleView} = useCalendarPopover(root)
|
||||
const {currentMonth, currentYear, goToPrev, goToNext, selectMonth, syncToIso} = useCalendarView(viewMode)
|
||||
|
||||
const inputId = computed(() => props.id?.toString() || `malio-date-${generatedId}`)
|
||||
const hasError = computed(() => !!props.error)
|
||||
const hasSuccess = computed(() => !!props.success && !hasError.value)
|
||||
const isFilled = computed(() => props.displayValue.length > 0)
|
||||
const showClear = computed(() =>
|
||||
props.clearable && isFilled.value && !props.disabled && !props.readonly,
|
||||
)
|
||||
const describedBy = computed(() =>
|
||||
(props.hint || hasError.value || hasSuccess.value) ? `${inputId.value}-describedby` : undefined,
|
||||
)
|
||||
|
||||
watch(isOpen, (value) => {
|
||||
if (!value) emit('close')
|
||||
})
|
||||
|
||||
const onFieldClick = () => {
|
||||
if (props.disabled || props.readonly) return
|
||||
if (isOpen.value) {
|
||||
closePopover()
|
||||
return
|
||||
}
|
||||
syncToIso(props.syncTo)
|
||||
open()
|
||||
}
|
||||
|
||||
watch(() => props.syncTo, (value) => {
|
||||
if (isOpen.value) syncToIso(value)
|
||||
})
|
||||
|
||||
const onSelectMonth = (m: number) => {
|
||||
selectMonth(m)
|
||||
toggleView()
|
||||
}
|
||||
|
||||
const mergedGroupClass = computed(() =>
|
||||
twMerge('relative flex h-12 w-full items-center', props.groupClass),
|
||||
)
|
||||
|
||||
const mergedInputClass = computed(() =>
|
||||
twMerge(
|
||||
'floating-input peer min-h-[40px] w-full cursor-pointer rounded-md border bg-white py-1 pl-3 pr-10 text-lg outline-none transition-[padding] duration-150 placeholder:text-transparent',
|
||||
isFilled.value ? 'border-black' : 'border-m-muted',
|
||||
props.disabled ? 'cursor-not-allowed text-black/60 border-m-muted' : '',
|
||||
hasError.value
|
||||
? 'border-m-danger'
|
||||
: hasSuccess.value
|
||||
? 'border-m-success'
|
||||
: 'focus:border-m-primary',
|
||||
isOpen.value ? 'border-m-primary !py-[9px] !rounded-b-none' : '',
|
||||
props.inputClass,
|
||||
),
|
||||
)
|
||||
|
||||
const mergedLabelClass = computed(() =>
|
||||
twMerge(
|
||||
'floating-label absolute left-3 top-2 mt-[5px] inline-block origin-left font-medium text-sm transition-transform duration-150',
|
||||
(isFilled.value || isOpen.value) ? '-translate-y-[1.25rem] scale-90' : '',
|
||||
hasError.value
|
||||
? 'text-m-danger'
|
||||
: hasSuccess.value
|
||||
? 'text-m-success'
|
||||
: isOpen.value
|
||||
? 'text-m-primary'
|
||||
: 'peer-placeholder-shown:text-m-muted text-black',
|
||||
props.labelClass,
|
||||
),
|
||||
)
|
||||
|
||||
const iconStateClass = computed(() => {
|
||||
if (hasError.value) return 'text-m-danger'
|
||||
if (hasSuccess.value) return 'text-m-success'
|
||||
if (isOpen.value) return 'text-m-primary'
|
||||
if (isFilled.value) return 'text-black'
|
||||
return 'text-m-muted'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.floating-label {
|
||||
background: white;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
</style>
|
||||
70
app/components/malio/date/internal/CalendarHeader.vue
Normal file
70
app/components/malio/date/internal/CalendarHeader.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div class="flex h-[36px] justify-between border-b border-black/60 mb-3">
|
||||
<button
|
||||
type="button"
|
||||
data-test="header-prev"
|
||||
class="ml-2 flex self-start rounded"
|
||||
:aria-label="viewMode === 'days' ? 'Mois précédent' : 'Année précédente'"
|
||||
@click="emit('prev')"
|
||||
>
|
||||
<Icon
|
||||
icon="mdi:chevron-left"
|
||||
:width="25"
|
||||
:height="25"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-test="header-toggle"
|
||||
class="flex gap-1 rounded text-base font-medium"
|
||||
@click="emit('toggle-view')"
|
||||
>
|
||||
<span class="mt-[2px]">{{ label }}</span>
|
||||
<Icon
|
||||
icon="mdi:chevron-down"
|
||||
:width="25"
|
||||
:height="25"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-test="header-next"
|
||||
class="mr-2 flex self-start rounded"
|
||||
:aria-label="viewMode === 'days' ? 'Mois suivant' : 'Année suivante'"
|
||||
@click="emit('next')"
|
||||
>
|
||||
<Icon
|
||||
icon="mdi:chevron-right"
|
||||
:width="25"
|
||||
:height="25"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed} from 'vue'
|
||||
import {Icon} from '@iconify/vue'
|
||||
|
||||
defineOptions({name: 'MalioDateCalendarHeader'})
|
||||
|
||||
const props = defineProps<{
|
||||
viewMode: 'days' | 'months'
|
||||
currentMonth: number
|
||||
currentYear: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'prev' | 'next' | 'toggle-view'): void
|
||||
}>()
|
||||
|
||||
const monthsLong = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin',
|
||||
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre']
|
||||
|
||||
const label = computed(() => {
|
||||
const name = monthsLong[props.currentMonth]
|
||||
return `${name.charAt(0).toUpperCase()}${name.slice(1)} ${props.currentYear}`
|
||||
})
|
||||
</script>
|
||||
178
app/components/malio/date/internal/MonthGrid.vue
Normal file
178
app/components/malio/date/internal/MonthGrid.vue
Normal file
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<div
|
||||
data-test="month-grid"
|
||||
@mouseleave="emit('hover', null)"
|
||||
>
|
||||
<div class="grid grid-cols-[auto_repeat(7,minmax(0,1fr))]">
|
||||
<div class="mr-[12px] flex h-8 w-[35px] items-center justify-center text-[14px] font-medium opacity-[60%]">
|
||||
S
|
||||
</div>
|
||||
<div
|
||||
v-for="d in dayLabels"
|
||||
:key="d"
|
||||
class="flex h-8 items-center justify-center text-[14px] font-medium opacity-[60%]"
|
||||
>
|
||||
{{ d }}
|
||||
</div>
|
||||
|
||||
<template
|
||||
v-for="(week, wIndex) in weeks"
|
||||
:key="week.days[0].isoDate"
|
||||
>
|
||||
<component
|
||||
:is="interactiveWeekNumber ? 'button' : 'div'"
|
||||
data-test="week-number"
|
||||
:data-week-start="week.days[0].isoDate"
|
||||
:data-marked="markedWeekStart === week.days[0].isoDate"
|
||||
:type="interactiveWeekNumber ? 'button' : undefined"
|
||||
:disabled="interactiveWeekNumber ? !weekSelectable(week) : undefined"
|
||||
class="mr-[12px] flex h-[45px] w-[35px] shrink-0 items-center justify-center p-[10px] text-sm"
|
||||
:class="[
|
||||
weekNumberClass(week),
|
||||
wIndex === 0 ? 'rounded-t-md' : '',
|
||||
wIndex === weeks.length - 1 ? 'rounded-b-md' : '',
|
||||
]"
|
||||
@click="onWeekNumberClick(week)"
|
||||
@mouseenter="onWeekNumberHover(week)"
|
||||
>
|
||||
{{ week.weekNumber }}
|
||||
</component>
|
||||
<button
|
||||
v-for="cell in week.days"
|
||||
:key="cell.isoDate"
|
||||
type="button"
|
||||
data-test="day"
|
||||
:data-iso="cell.isoDate"
|
||||
:data-range-role="roleOf(cell)"
|
||||
:disabled="!inRange(cell.isoDate)"
|
||||
:aria-label="ariaLabel(cell)"
|
||||
:aria-disabled="!inRange(cell.isoDate)"
|
||||
class="relative flex h-[45px] w-full items-center justify-center"
|
||||
:class="inRange(cell.isoDate) ? 'cursor-pointer' : 'cursor-not-allowed'"
|
||||
@click="onSelect(cell.isoDate)"
|
||||
@mouseenter="emit('hover', cell.isoDate)"
|
||||
>
|
||||
<span
|
||||
v-if="roleOf(cell) === 'in-range'"
|
||||
class="absolute inset-x-0 top-1/2 h-10 -translate-y-1/2 bg-m-primary-light"
|
||||
/>
|
||||
<span
|
||||
v-else-if="roleOf(cell) === 'start'"
|
||||
class="absolute inset-x-0 top-1/2 h-10 -translate-y-1/2 rounded-l-full bg-m-primary-light"
|
||||
/>
|
||||
<span
|
||||
v-else-if="roleOf(cell) === 'end'"
|
||||
class="absolute inset-x-0 top-1/2 h-10 -translate-y-1/2 rounded-r-full bg-m-primary-light"
|
||||
/>
|
||||
<span
|
||||
class="relative flex h-10 w-10 items-center justify-center rounded-full text-sm font-medium transition-colors duration-100"
|
||||
:class="cellClass(cell)"
|
||||
>
|
||||
{{ cell.day }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, toRef} from 'vue'
|
||||
import {useMonthMatrix, type DayCell, type WeekRow} from '../composables/useMonthMatrix'
|
||||
import {isDateInRange} from '../composables/dateFormat'
|
||||
import {dayRangeRole, resolveRangeBounds, type DayRangeRole} from '../composables/dateRange'
|
||||
|
||||
defineOptions({name: 'MalioDateMonthGrid'})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
month: number
|
||||
year: number
|
||||
selectedDate?: string | null
|
||||
rangeStart?: string | null
|
||||
rangeEnd?: string | null
|
||||
previewDate?: string | null
|
||||
interactiveWeekNumber?: boolean
|
||||
markedWeekStart?: string | null
|
||||
min?: string
|
||||
max?: string
|
||||
}>(),
|
||||
{
|
||||
selectedDate: null,
|
||||
rangeStart: undefined,
|
||||
rangeEnd: undefined,
|
||||
previewDate: undefined,
|
||||
interactiveWeekNumber: false,
|
||||
markedWeekStart: null,
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', iso: string): void
|
||||
(e: 'hover', iso: string | null): void
|
||||
}>()
|
||||
|
||||
const dayLabels = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim']
|
||||
const monthsLong = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin',
|
||||
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre']
|
||||
|
||||
const {weeks} = useMonthMatrix(toRef(props, 'month'), toRef(props, 'year'))
|
||||
|
||||
const inRange = (iso: string) => isDateInRange(iso, props.min, props.max)
|
||||
|
||||
const weekSelectable = (week: WeekRow) => week.days.some(d => inRange(d.isoDate))
|
||||
|
||||
const weekNumberClass = (week: WeekRow) => {
|
||||
if (props.markedWeekStart === week.days[0].isoDate) return 'bg-m-primary text-white'
|
||||
const parts = ['bg-m-primary-light']
|
||||
parts.push(week.days.some(d => d.isToday) ? 'text-black' : 'text-black/60')
|
||||
if (props.interactiveWeekNumber && weekSelectable(week)) parts.push('cursor-pointer')
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
const onWeekNumberClick = (week: WeekRow) => {
|
||||
if (!props.interactiveWeekNumber || !weekSelectable(week)) return
|
||||
emit('select', week.days[0].isoDate)
|
||||
}
|
||||
|
||||
const onWeekNumberHover = (week: WeekRow) => {
|
||||
if (!props.interactiveWeekNumber || !weekSelectable(week)) return
|
||||
emit('hover', week.days[0].isoDate)
|
||||
}
|
||||
|
||||
const isRangeMode = computed(() => props.rangeStart !== undefined)
|
||||
const bounds = computed(() =>
|
||||
isRangeMode.value
|
||||
? resolveRangeBounds(props.rangeStart ?? null, props.rangeEnd ?? null, props.previewDate ?? null)
|
||||
: null,
|
||||
)
|
||||
|
||||
const roleOf = (cell: DayCell): DayRangeRole => {
|
||||
if (isRangeMode.value) return dayRangeRole(cell.isoDate, bounds.value)
|
||||
return props.selectedDate === cell.isoDate ? 'single' : 'none'
|
||||
}
|
||||
|
||||
const ariaLabel = (cell: DayCell) => {
|
||||
const [, m, d] = cell.isoDate.split('-')
|
||||
return `${Number(d)} ${monthsLong[Number(m) - 1]} ${cell.isoDate.slice(0, 4)}`
|
||||
}
|
||||
|
||||
const cellClass = (cell: DayCell) => {
|
||||
if (!inRange(cell.isoDate)) return 'text-m-muted/30'
|
||||
const role = roleOf(cell)
|
||||
if (role === 'start' || role === 'end' || role === 'single') return 'bg-m-primary text-white'
|
||||
if (role === 'in-range') return 'text-black'
|
||||
const parts = ['hover:bg-m-primary/10']
|
||||
if (cell.isToday) parts.push('border border-m-primary text-m-primary')
|
||||
else if (cell.isCurrentMonth) parts.push('text-black')
|
||||
else parts.push('opacity-[60%]')
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
const onSelect = (iso: string) => {
|
||||
if (!inRange(iso)) return
|
||||
emit('select', iso)
|
||||
}
|
||||
</script>
|
||||
36
app/components/malio/date/internal/MonthPicker.vue
Normal file
36
app/components/malio/date/internal/MonthPicker.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<div
|
||||
data-test="month-picker"
|
||||
class="grid grid-cols-3 gap-3"
|
||||
>
|
||||
<button
|
||||
v-for="(name, index) in months"
|
||||
:key="name"
|
||||
type="button"
|
||||
data-test="month"
|
||||
:data-month="index"
|
||||
class="flex h-[45px] w-full items-center justify-center"
|
||||
@click="emit('select', index)"
|
||||
>
|
||||
<span
|
||||
class="flex h-[30px] w-full items-center justify-center rounded text-sm transition-colors duration-100"
|
||||
:class="index === selectedMonth
|
||||
? 'bg-m-primary text-white'
|
||||
: 'text-black hover:bg-m-primary/10'"
|
||||
>
|
||||
{{ name }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({name: 'MalioDateMonthPicker'})
|
||||
|
||||
defineProps<{selectedMonth?: number}>()
|
||||
|
||||
const emit = defineEmits<{(e: 'select', month: number): void}>()
|
||||
|
||||
const months = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
|
||||
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']
|
||||
</script>
|
||||
Reference in New Issue
Block a user