260 lines
6.8 KiB
Vue
260 lines
6.8 KiB
Vue
<template>
|
|
<div class="native-date-strip" :class="fieldClass" :aria-label="ariaLabel">
|
|
<select
|
|
:value="parts.year"
|
|
class="native-date-part native-date-year"
|
|
:aria-label="`${ariaLabel}年`"
|
|
@change="updatePart('year', ($event.target as HTMLSelectElement).value)"
|
|
>
|
|
<option v-for="year in years" :key="year" :value="year">{{ year }}</option>
|
|
</select>
|
|
<span class="native-date-separator">/</span>
|
|
<select
|
|
:value="parts.month"
|
|
class="native-date-part native-date-month"
|
|
:aria-label="`${ariaLabel}月`"
|
|
@change="updatePart('month', ($event.target as HTMLSelectElement).value)"
|
|
>
|
|
<option v-for="month in months" :key="month" :value="month">
|
|
{{ month.toString().padStart(2, '0') }}
|
|
</option>
|
|
</select>
|
|
<span class="native-date-separator">/</span>
|
|
<select
|
|
:value="parts.day"
|
|
class="native-date-part native-date-day"
|
|
:aria-label="`${ariaLabel}日`"
|
|
@change="updatePart('day', ($event.target as HTMLSelectElement).value)"
|
|
>
|
|
<option v-for="day in days" :key="day" :value="day">
|
|
{{ day.toString().padStart(2, '0') }}
|
|
</option>
|
|
</select>
|
|
<template v-if="includeTime">
|
|
<span class="native-date-time-spacer" />
|
|
<select
|
|
:value="parts.hour"
|
|
class="native-date-part native-date-time"
|
|
:aria-label="`${ariaLabel}时`"
|
|
@change="updatePart('hour', ($event.target as HTMLSelectElement).value)"
|
|
>
|
|
<option v-for="hour in hours" :key="hour" :value="hour">
|
|
{{ hour.toString().padStart(2, '0') }}
|
|
</option>
|
|
</select>
|
|
<span class="native-date-separator">:</span>
|
|
<select
|
|
:value="parts.minute"
|
|
class="native-date-part native-date-time"
|
|
:aria-label="`${ariaLabel}分`"
|
|
@change="updatePart('minute', ($event.target as HTMLSelectElement).value)"
|
|
>
|
|
<option v-for="minute in minutes" :key="minute" :value="minute">
|
|
{{ minute.toString().padStart(2, '0') }}
|
|
</option>
|
|
</select>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed } from 'vue'
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
modelValue: string
|
|
fieldClass?: string | string[]
|
|
ariaLabel?: string
|
|
minYear?: number
|
|
maxYear?: number
|
|
includeTime?: boolean
|
|
}>(),
|
|
{
|
|
fieldClass: '',
|
|
ariaLabel: '日期',
|
|
minYear: 1900,
|
|
maxYear: 2100,
|
|
includeTime: false
|
|
}
|
|
)
|
|
|
|
const emit = defineEmits<{
|
|
'update:modelValue': [value: string]
|
|
change: []
|
|
}>()
|
|
|
|
type DatePart = 'year' | 'month' | 'day' | 'hour' | 'minute'
|
|
|
|
const pad = (value: number) => String(value).padStart(2, '0')
|
|
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value))
|
|
const getDaysInMonth = (year: number, month: number) => new Date(year, month, 0).getDate()
|
|
|
|
const years = computed(() =>
|
|
Array.from({ length: props.maxYear - props.minYear + 1 }, (_, index) => props.minYear + index)
|
|
)
|
|
const months = Array.from({ length: 12 }, (_, index) => index + 1)
|
|
const hours = Array.from({ length: 24 }, (_, index) => index)
|
|
const minutes = Array.from({ length: 60 }, (_, index) => index)
|
|
|
|
const parts = computed(() => {
|
|
const [rawDate = '', rawTime = ''] = props.modelValue.split('T')
|
|
const [rawYear, rawMonth, rawDay] = rawDate.split('-').map(Number)
|
|
const [rawHour, rawMinute] = rawTime.split(':').map(Number)
|
|
const year = clamp(Number.isFinite(rawYear) ? rawYear : new Date().getFullYear(), props.minYear, props.maxYear)
|
|
const month = clamp(Number.isFinite(rawMonth) ? rawMonth : new Date().getMonth() + 1, 1, 12)
|
|
const day = clamp(
|
|
Number.isFinite(rawDay) ? rawDay : new Date().getDate(),
|
|
1,
|
|
getDaysInMonth(year, month)
|
|
)
|
|
const hour = clamp(Number.isFinite(rawHour) ? rawHour : 0, 0, 23)
|
|
const minute = clamp(Number.isFinite(rawMinute) ? rawMinute : 0, 0, 59)
|
|
return { year, month, day, hour, minute }
|
|
})
|
|
|
|
const days = computed(() =>
|
|
Array.from(
|
|
{ length: getDaysInMonth(parts.value.year, parts.value.month) },
|
|
(_, index) => index + 1
|
|
)
|
|
)
|
|
|
|
const updatePart = (part: DatePart, value: string) => {
|
|
const next = { ...parts.value, [part]: Number(value) }
|
|
next.day = clamp(next.day, 1, getDaysInMonth(next.year, next.month))
|
|
const nextDate = `${next.year}-${pad(next.month)}-${pad(next.day)}`
|
|
const nextValue = props.includeTime
|
|
? `${nextDate}T${pad(next.hour)}:${pad(next.minute)}`
|
|
: nextDate
|
|
emit('update:modelValue', nextValue)
|
|
emit('change')
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.native-date-strip {
|
|
display: inline-flex;
|
|
width: max-content;
|
|
max-width: 100%;
|
|
min-height: 2.75rem;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 0;
|
|
padding-inline: 0.65ch;
|
|
border: 1px solid transparent;
|
|
border-radius: 30px;
|
|
background: rgba(237, 244, 245, 0.52);
|
|
color: #2d4650;
|
|
font-variant-numeric: tabular-nums;
|
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.62);
|
|
transition:
|
|
background 180ms ease,
|
|
border-color 180ms ease,
|
|
box-shadow 180ms ease,
|
|
transform 180ms ease;
|
|
}
|
|
|
|
.native-date-strip:hover,
|
|
.native-date-strip:focus-within {
|
|
border-color: rgba(143, 178, 191, 0.7);
|
|
background: rgba(226, 236, 238, 0.78);
|
|
box-shadow:
|
|
inset 0 1px 0 rgba(255, 255, 255, 0.72),
|
|
0 10px 24px rgba(126, 149, 154, 0.16);
|
|
}
|
|
|
|
.native-date-strip:focus-within {
|
|
transform: translateY(-1px);
|
|
}
|
|
|
|
.native-date-part {
|
|
height: 2.65rem;
|
|
appearance: none;
|
|
border: 0;
|
|
border-radius: 0;
|
|
background: transparent;
|
|
color: inherit;
|
|
cursor: pointer;
|
|
padding: 0;
|
|
text-align: center;
|
|
font-size: 0.95rem;
|
|
font-weight: 600;
|
|
line-height: 1;
|
|
outline: none;
|
|
}
|
|
|
|
.native-date-part:focus-visible {
|
|
border-radius: 999px;
|
|
box-shadow: 0 0 0 2px rgba(143, 178, 191, 0.58);
|
|
}
|
|
|
|
.native-date-year {
|
|
width: 4.2ch;
|
|
}
|
|
|
|
.native-date-month,
|
|
.native-date-day,
|
|
.native-date-time {
|
|
width: 2.2ch;
|
|
}
|
|
|
|
.native-date-time-spacer {
|
|
width: 0.9ch;
|
|
flex: 0 0 auto;
|
|
}
|
|
|
|
.native-date-separator {
|
|
margin-inline: 0.35ch;
|
|
color: #789096;
|
|
font-size: 0.95rem;
|
|
line-height: 1;
|
|
opacity: 0.78;
|
|
pointer-events: none;
|
|
}
|
|
|
|
@media (max-width: 639px) {
|
|
.native-date-strip {
|
|
min-height: 2.35rem;
|
|
padding-inline: 0.35ch;
|
|
}
|
|
|
|
.native-date-part {
|
|
height: 2.25rem;
|
|
font-size: 0.82rem;
|
|
}
|
|
|
|
.native-date-year {
|
|
width: 3.7ch;
|
|
}
|
|
|
|
.native-date-month,
|
|
.native-date-day {
|
|
width: 1.8ch;
|
|
}
|
|
|
|
.native-date-separator {
|
|
margin-inline: 0.15ch;
|
|
font-size: 0.82rem;
|
|
}
|
|
}
|
|
|
|
.dark .native-date-strip {
|
|
background: rgba(38, 57, 65, 0.62);
|
|
color: #d8e3e5;
|
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
|
}
|
|
|
|
.dark .native-date-strip:hover,
|
|
.dark .native-date-strip:focus-within {
|
|
border-color: rgba(113, 153, 168, 0.72);
|
|
background: rgba(45, 71, 81, 0.76);
|
|
box-shadow:
|
|
inset 0 1px 0 rgba(255, 255, 255, 0.1),
|
|
0 10px 24px rgba(4, 10, 14, 0.28);
|
|
}
|
|
|
|
.dark .native-date-separator {
|
|
color: #aabdc2;
|
|
}
|
|
</style>
|