datenel-vue3/src/components/SingleDatePicker.vue
2025-02-26 11:27:27 +11:00

328 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
/**
* @name SingleDatePicker
* @description A panel that allows users to select a date.
* @component
* @see {@link https://datenel.js.org/guide/vue3/components/SingleDatePicker.html}
*/
export default {
name: 'SingleDatePicker',
}
export interface SingleDatePickerPropsColorScheme {
mainColor: string
accentColor: string
borderColor: string
hoverColor: string
reversedColor: string
}
export type SingleDatePickerPropsAvailableDates = [Date | null, Date | null]
</script>
<script setup lang="ts">
import { ref, defineProps, watch, onMounted, toRefs, getCurrentInstance, PropType } from 'vue'
import { generateUniqueId, applyColor, getL10Weekday, getCalendarDates } from '../utils'
const props = defineProps({
/**
* The color scheme of the component
*
* @description Customize the color scheme of the component.
*
* The object should contain the following properties:
*
* - `mainColor`: The main color of the panel, including the text color and the border color.
* - `accentColor`: The accent color of the panel, including the background color of the selected date.
* - `borderColor`: The border color of the panel, including the divider color between the header and the body.
* - `hoverColor`: The hover color of the panel, including the hover background color of the date.
* - `reversedColor`: The reversed color of the panel, including the text color of the selected date.
*
* @see {@link https://datenel.js.org/guide/vue3/components/SingleDatePicker.html#colorscheme}
*
* @default { mainColor: '#000000', accentColor: '#000000', borderColor: '#e0e0e0', hoverColor: '#00000017', reversedColor: '#ffffff' }
*/
colorScheme: {
type: Object as () => SingleDatePickerPropsColorScheme,
default: () => ({
mainColor: '#000000',
accentColor: '#000000',
borderColor: '#e0e0e0',
hoverColor: '#00000017',
reversedColor: '#ffffff',
}),
required: false,
},
/**
* Localization
* @description The language code that will be used to localize the panel.
*
* Accept standard ISO 639-1 language code, such as 'zh-CN', 'en-US', 'ja-JP', etc. Note
* that it will not effect to the screen reader, but the screen reader will still read the
* date in the users language.
*
* @see {@link https://datenel.js.org/guide/vue3/components/SingleDatePicker.html#localization}
* @default navigator.language
*/
localization: {
type: String,
required: false,
},
/**
* Value of the selected date.
*
* @description Control the selected
* date programmatically, including situations like provide a default value or control the selected
* date by parent component.
*
* @see {@link https://datenel.js.org/guide/vue3/components/SingleDatePicker.html#modelvalue-a-k-a-v-model}
*
* @example new Date(2025, 0, 1)
* @default undefined (the panel will be in read-only mode)
*/
modelValue: {
type: Date,
required: false,
},
/**
* Available range of dates
*
* @description Limit a range of dates that can be selected. It should be an array of two dates, which the first
* one is the available range start date, and the second one is the available range end date.
*
* The parameter will be ignored if the array length is not 2.
*
* @see {@link https://datenel.js.org/guide/vue3/components/SingleDatePicker.html#availablerange}
*
* @example [new Date(2025, 0, 1), new Date(2025, 11, 31)]
* @example [new Date(2025, 0, 1), null]
* @example [null, new Date(2025, 11, 31)]
* @example [new Date(2025, 11, 31), new Date(2025, 0, 1)]
* @default undefined
*/
availableRange: {
type: Array as unknown as PropType<SingleDatePickerPropsAvailableDates>,
required: false,
},
/**
* Event handler when the panel is closed.
* @description User requires to close the panel without select a specific date. Note
* that the close button is not visible, but can be read by screen reader. The close
* button for the screen reader is only available when this prop is not `undefined`.
*
* @see {@link https://datenel.js.org/guide/vue3/components/SingleDatePicker.html#close-void}
*
* @default undefined
*/
close: {
type: Function,
required: false,
}
})
const emit = defineEmits(['update:modelValue', 'close'])
const selectMonth = ref(false)
const uniqueId = generateUniqueId()
const currentMonth = ref(new Date().getMonth())
const currentYear = ref(new Date().getFullYear())
const l10nDays = ref<string[]>([])
const dates = ref<Date[]>([])
const hasCloseListener = getCurrentInstance()?.vnode?.props?.onClose !== undefined
const availableRangeStart = ref<Date | null>(null)
const availableRangeEnd = ref<Date | null>(null)
const { colorScheme, localization, availableRange } = toRefs(props)
watch(colorScheme, newVal => {
applyColor(uniqueId, newVal)
})
watch([currentMonth, currentYear], () => {
dates.value = getCalendarDates(currentMonth.value, currentYear.value)
})
watch([availableRange], () => {
calculateAvailableRange()
})
onMounted(() => {
applyColor(uniqueId, colorScheme.value)
l10nDays.value = getL10Weekday(localization?.value || navigator.languages[0])
if (props.modelValue) {
currentMonth.value = props.modelValue.getMonth()
currentYear.value = props.modelValue.getFullYear()
} else {
currentMonth.value = new Date().getMonth()
currentYear.value = new Date().getFullYear()
}
dates.value = getCalendarDates(currentMonth.value, currentYear.value)
calculateAvailableRange()
})
function goToLastMonth() {
if (currentMonth.value === 0) {
currentMonth.value = 11
currentYear.value -= 1
} else {
currentMonth.value -= 1
}
}
function goToNextMonth() {
if (currentMonth.value === 11) {
currentMonth.value = 0
currentYear.value += 1
} else {
currentMonth.value += 1
}
}
function notAvailable(date: Date): boolean {
return currentMonth.value !== date.getMonth() || (availableRangeStart.value !== null && date < availableRangeStart.value) || (availableRangeEnd.value !== null && date > availableRangeEnd.value)
}
function selectDate(date: Date) {
emit('update:modelValue', date)
}
function changeYear(event: Event) {
const target = event.target as HTMLInputElement
const year = parseInt(target.value)
if (year) currentYear.value = year
}
function adjustYear() {
if (currentYear.value < 100) currentYear.value = parseInt(`20${currentYear.value}`)
}
function monthNotAvailable(month: number): boolean {
// When the last day of a month not inside the range of available dates
const lastDayOfMonth = new Date(currentYear.value, month + 1, 0)
if (availableRangeStart.value && lastDayOfMonth < availableRangeStart.value) return true
// When the first day of a month not inside the range of available dates
const firstDayOfMonth = new Date(currentYear.value, month, 1)
if (availableRangeEnd.value && firstDayOfMonth > availableRangeEnd.value) return true
return false
}
function closePanel() {
emit('close')
}
function calculateAvailableRange() {
if (!props.availableRange) {
availableRangeStart.value = null
availableRangeEnd.value = null
return
}
if (props.availableRange.length !== 2) {
console.warn('Invalid availableRange: The length of the array should be 2. The parameter will be ignored.')
availableRangeStart.value = null
availableRangeEnd.value = null
return
}
const [start, end] = props.availableRange
if (start && end) {
if (start > end) {
availableRangeStart.value = end
availableRangeEnd.value = start
} else {
availableRangeStart.value = start
availableRangeEnd.value = end
}
} else if (start && !end) {
availableRangeStart.value = start
availableRangeEnd.value = null
} else if (!start && end) {
availableRangeStart.value = null
availableRangeEnd.value = end
} else {
availableRangeStart.value = null
availableRangeEnd.value = null
}
}
</script>
<template>
<div :id="`__datenel-${uniqueId}`" class='datenel-component'>
<div role="dialog" aria-label="Date selection panel, you are now at month and year quick-select" v-if="selectMonth">
<div class='__datenel_header'>
<button class='__datenel_stepper' @click="() => {
if (currentYear <= 100) return
currentYear -= 1
}" :aria-label="`Go to last year, ${currentYear - 1}, you are now at year ${currentYear}`"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M10.8284 12.0007L15.7782 16.9504L14.364 18.3646L8 12.0007L14.364 5.63672L15.7782 7.05093L10.8284 12.0007Z"></path></svg></button>
<input class='__datenel_indicator'
v-model="currentYear"
@change="changeYear"
@blur="adjustYear"
aria-label="Year input, type a year to go to that year"
/>
<button class='__datenel_stepper' @click="() => {
if (currentYear >= 9999) return
currentYear += 1
}" :aria-label="`Go to next year, ${currentYear + 1}, you are now at year ${currentYear}`"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M13.1717 12.0007L8.22192 7.05093L9.63614 5.63672L16.0001 12.0007L9.63614 18.3646L8.22192 16.9504L13.1717 12.0007Z"></path></svg></button>
</div>
<div class='__datenel_body'>
<div class='__datenel_month-selector-body'>
<button
v-for="(_, index) in 12"
:class="`__datenel_item ${monthNotAvailable(index) && '__datenel_not-available'} ${currentMonth === index && '__datenel_active'}`"
key={index}
@click="() => {
currentMonth = index
selectMonth = false
}"
:aria-label="`Go to ${new Date(currentYear, index).toLocaleString(localization, { month: 'long' })} of the year ${currentYear}`"
:disabled="monthNotAvailable(index)"
:aria-hidden="monthNotAvailable(index)"
>
{{new Date(currentYear, index).toLocaleString(localization, { month: 'long' })}}
</button>
</div>
</div>
</div>
<div role="dialog" aria-label="Date selection panel" v-if="!selectMonth">
<div class='__datenel_header'>
<button class='__datenel_stepper' @click="goToLastMonth()" :aria-label="`Go to last month, ${new Date(currentYear, currentMonth - 1).toLocaleString('default', { month: 'long', year: 'numeric' })}`"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M10.8284 12.0007L15.7782 16.9504L14.364 18.3646L8 12.0007L14.364 5.63672L15.7782 7.05093L10.8284 12.0007Z"></path></svg></button>
<button class='__datenel_indicator' @click="selectMonth = true" :aria-label="`You are now at ${new Date(currentYear, currentMonth).toLocaleString('default', { month: 'long', year: 'numeric' })}. Click here to quick-select month or year.`">
{{ new Date(currentYear, currentMonth).toLocaleString('default', { month: 'long', year: 'numeric' }) }}
</button>
<button class='__datenel_stepper' @click="goToNextMonth()" :aria-label="`Go to next month, ${new Date(currentYear, currentMonth + 1).toLocaleString('default', { month: 'long', year: 'numeric' })}`"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M13.1717 12.0007L8.22192 7.05093L9.63614 5.63672L16.0001 12.0007L9.63614 18.3646L8.22192 16.9504L13.1717 12.0007Z"></path></svg></button>
</div>
<div class='__datenel_body'>
<div class='__datenel_calendar-view-body __datenel_grid' aria-live="polite">
<div class='__datenel_item __datenel_day-indicator' v-for="(day, index) in l10nDays" :key="index">{{ day }}</div>
<!-- -->
<button
v-for="date in dates"
:class="`__datenel_item __datenel_date ${notAvailable(date) && '__datenel_not-available'} ${modelValue?.toDateString() === date.toDateString() && '__datenel_active'}`"
:key="date.toISOString()"
@click="selectDate(date)"
:aria-label="`${date.toLocaleString(localization, { dateStyle: 'full' })}${date.toDateString() === new Date().toDateString() ? ', this is today' : ''}, click to select this date`"
:tabIndex="currentMonth !== date.getMonth() ? -1 : 0"
:aria-hidden="notAvailable(date)"
:disabled="notAvailable(date)"
>
{{date.getDate()}}
<svg v-if="date.toDateString() === new Date().toDateString()" xmlns="http://www.w3.org/2000/svg" class='__datenel_today-indicator' viewBox="0 0 24 24" fill="currentColor"><path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z"></path></svg>
</button>
</div>
</div>
</div>
<button class='__datenel_sr-only' @click="closePanel" v-if="hasCloseListener">Close the panel</button>
</div>
</template>
<style lang="scss" scoped>
@use '../style.scss' as *;
</style>