first commit

This commit is contained in:
Warunee Tamkoo 2023-09-06 14:51:44 +07:00
commit eb2f504652
32490 changed files with 5731109 additions and 0 deletions

View file

@ -0,0 +1,509 @@
<template>
<div
class="v3dp__datepicker"
:style="variables($attrs.style as Record<string, string> | undefined)"
>
<div class="v3dp__input_wrapper">
<input
type="text"
ref="inputRef"
:readonly="!typeable"
v-model="input"
v-bind="$attrs"
:placeholder="placeholder"
:disabled="disabled"
:tabindex="disabled ? -1 : 0"
@keyup="keyUp"
@blur="blur"
@focus="focus"
@click="click"
/>
<div class="v3dp__clearable" v-show="clearable && modelValue">
<slot name="clear" :onClear="clearModelValue">
<i @click="clearModelValue()">x</i>
</slot>
</div>
</div>
<year-picker
v-show="viewShown === 'year'"
:pageDate="pageDate"
@update:pageDate="(v) => updatePageDate('year', v)"
:selected="modelValue"
:lowerLimit="lowerLimit"
:upperLimit="upperLimit"
@select="selectYear"
/>
<month-picker
v-show="viewShown === 'month'"
:pageDate="pageDate"
@update:pageDate="(v) => updatePageDate('month', v)"
:selected="modelValue"
@select="selectMonth"
:lowerLimit="lowerLimit"
:upperLimit="upperLimit"
:format="monthListFormat"
:locale="locale"
@back="viewShown = 'year'"
/>
<day-picker
v-show="viewShown === 'day'"
:pageDate="pageDate"
@update:pageDate="(v) => updatePageDate('day', v)"
:selected="modelValue"
:weekStartsOn="weekStartsOn"
:lowerLimit="lowerLimit"
:upperLimit="upperLimit"
:headingFormat="dayPickerHeadingFormat"
:disabledDates="disabledDates"
:locale="locale"
:weekdayFormat="weekdayFormat"
:allow-outside-interval="allowOutsideInterval"
:format="dayFormat"
@select="selectDay"
@back="viewShown = 'month'"
/>
<time-picker
v-show="viewShown === 'time'"
:pageDate="pageDate"
:visible="viewShown === 'time'"
:selected="modelValue"
:disabledTime="disabledTime"
@select="selectTime"
@back="goBackFromTimepicker"
/>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, computed, watchEffect, PropType } from 'vue'
import { parse, isValid, format, max, min } from 'date-fns'
import YearPicker from './YearPicker.vue'
import MonthPicker from './MonthPicker.vue'
import DayPicker from './DayPicker.vue'
import TimePicker from './Timepicker.vue'
const TIME_RESOLUTIONS = ['time', 'day', 'month', 'year']
const boundedDate = (
lower: Date | undefined,
upper: Date | undefined,
target: Date | undefined = undefined
) => {
let date = target || new Date()
if (lower) date = max([lower, date])
if (upper) date = min([upper, date])
return date
}
export default defineComponent({
components: {
YearPicker,
MonthPicker,
DayPicker,
TimePicker,
},
inheritAttrs: false,
props: {
placeholder: {
type: String,
default: '',
},
/**
* `v-model` for selected date
*/
modelValue: {
type: Date as PropType<Date>,
required: false,
},
/**
* Dates not available for picking
*/
disabledDates: {
type: Object as PropType<{
dates?: Date[]
predicate?: (currentDate: Date) => boolean
}>,
required: false,
},
allowOutsideInterval: {
type: Boolean,
required: false,
default: false,
},
/**
* Time not available for picking
*/
disabledTime: {
type: Object as PropType<{
dates?: Date[]
predicate?: (currentDate: Date) => boolean
}>,
required: false,
},
/**
* Upper limit for available dates for picking
*/
upperLimit: {
type: Date as PropType<Date>,
required: false,
},
/**
* Lower limit for available dates for picking
*/
lowerLimit: {
type: Date as PropType<Date>,
required: false,
},
/**
* View on which the date picker should open. Can be either `year`, `month`, `day` or `time`
*/
startingView: {
type: String as PropType<'year' | 'month' | 'day' | 'time'>,
required: false,
default: 'day',
validate: (v: unknown) =>
typeof v === 'string' && TIME_RESOLUTIONS.includes(v),
},
/**
* Date which should be the "center" of the initial view.
* When an empty datepicker opens, it focuses on the month/year
* that contains this date
*/
startingViewDate: {
type: Date as PropType<Date>,
required: false,
default: () => new Date(),
},
/**
* `date-fns`-type formatting for a month view heading
*/
dayPickerHeadingFormat: {
type: String,
required: false,
default: 'LLLL yyyy',
},
/**
* `date-fns`-type formatting for the month picker view
*/
monthListFormat: {
type: String,
required: false,
default: 'LLL',
},
/**
* `date-fns`-type formatting for a line of weekdays on day view
*/
weekdayFormat: {
type: String,
required: false,
default: 'EE',
},
/**
* `date-fns`-type formatting for the day picker view
*/
dayFormat: {
type: String,
required: false,
default: 'dd',
},
/**
* `date-fns`-type format in which the string in the input should be both
* parsed and displayed
*/
inputFormat: {
type: String,
required: false,
default: 'yyyy-MM-dd',
},
/**
* [`date-fns` locale object](https://date-fns.org/v2.16.1/docs/I18n#usage).
* Used in string formatting (see default `dayPickerHeadingFormat`)
*/
locale: {
type: Object as PropType<Locale>,
required: false,
},
/**
* Day on which the week should start.
*
* Number from 0 to 6, where 0 is Sunday and 6 is Saturday.
* Week starts with a Monday (1) by default
*/
weekStartsOn: {
type: Number as PropType<0 | 1 | 2 | 3 | 4 | 5 | 6>,
required: false,
default: 1,
validator: (value: any) => [0, 1, 2, 3, 4, 5, 6].includes(value),
},
/**
* Disables datepicker and prevents it's opening
*/
disabled: {
type: Boolean,
required: false,
default: false,
},
/**
* Clears selected date
*/
clearable: {
type: Boolean,
required: false,
default: false,
},
/*
* Allows user to input date manually
*/
typeable: {
type: Boolean,
required: false,
default: false,
},
/**
* If set, lower-level views won't show
*/
minimumView: {
type: String as PropType<'year' | 'month' | 'day' | 'time'>,
required: false,
default: 'day',
validate: (v: unknown) =>
typeof v === 'string' && TIME_RESOLUTIONS.includes(v),
},
},
emits: {
'update:modelValue': (value: Date | null | undefined) =>
value === null || value === undefined || isValid(value),
decadePageChanged: (pageDate: Date) => true,
yearPageChanged: (pageDate: Date) => true,
monthPageChanged: (pageDate: Date) => true,
opened: () => true,
closed: () => true,
},
setup(props, { emit, attrs }) {
const viewShown = ref('none' as 'year' | 'month' | 'day' | 'time' | 'none')
const pageDate = ref<Date>(props.startingViewDate)
const inputRef = ref(null as HTMLInputElement | null)
const isFocused = ref(false)
const input = ref('')
watchEffect(() => {
const parsed = parse(input.value, props.inputFormat, new Date(), {
locale: props.locale,
})
if (isValid(parsed)) {
pageDate.value = parsed
}
})
watchEffect(
() =>
(input.value =
props.modelValue && isValid(props.modelValue)
? format(props.modelValue, props.inputFormat, {
locale: props.locale,
})
: '')
)
const renderView = (view: typeof viewShown.value = 'none') => {
if (!props.disabled) {
if (view !== 'none' && viewShown.value === 'none')
pageDate.value =
props.modelValue ||
boundedDate(props.lowerLimit, props.upperLimit, pageDate.value)
viewShown.value = view
if (view !== 'none') {
emit('opened')
} else {
emit('closed')
}
}
}
watchEffect(() => {
if (props.disabled) viewShown.value = 'none'
})
const updatePageDate = (
view: 'year' | 'month' | 'day',
newPageDate: Date
) => {
// We need to emit "page changed" event and set the page date
pageDate.value = newPageDate
if (view === 'year') emit('decadePageChanged', newPageDate)
else if (view === 'month') emit('yearPageChanged', newPageDate)
else if (view === 'day') emit('monthPageChanged', newPageDate)
}
const selectYear = (date: Date) => {
pageDate.value = date
if (props.minimumView === 'year') {
renderView('none')
emit('update:modelValue', date)
} else {
viewShown.value = 'month'
}
}
const selectMonth = (date: Date) => {
pageDate.value = date
if (props.minimumView === 'month') {
renderView('none')
emit('update:modelValue', date)
} else {
viewShown.value = 'day'
}
}
const selectDay = (date: Date) => {
pageDate.value = date
if (props.minimumView === 'day') {
renderView('none')
emit('update:modelValue', date)
} else {
viewShown.value = 'time'
}
}
const selectTime = (date: Date) => {
renderView('none')
emit('update:modelValue', date)
}
const clearModelValue = () => {
if (props.clearable) {
renderView('none')
emit('update:modelValue', null)
pageDate.value = props.startingViewDate
}
}
const click = () => (isFocused.value = true)
const focus = () => renderView(initialView.value)
const blur = () => {
isFocused.value = false
renderView()
}
const keyUp = (event: KeyboardEvent) => {
const code = event.keyCode ? event.keyCode : event.which
// close calendar if escape or enter are pressed
const closeButton = [
27, // escape
13, // enter
].includes(code)
if (closeButton) {
inputRef.value!.blur()
}
if (props.typeable) {
const parsedDate = parse(
inputRef.value!.value,
props.inputFormat,
new Date(),
{ locale: props.locale }
)
// If the date is formatted back same way as it was inputted, then we're not disturbing user input
if (
isValid(parsedDate) &&
input.value ===
format(parsedDate, props.inputFormat, { locale: props.locale })
) {
input.value = inputRef.value!.value
emit('update:modelValue', parsedDate)
}
}
}
const initialView = computed(() => {
const startingViewOrder = TIME_RESOLUTIONS.indexOf(props.startingView)
const minimumViewOrder = TIME_RESOLUTIONS.indexOf(props.minimumView)
return startingViewOrder < minimumViewOrder
? props.minimumView
: props.startingView
})
const variables = (object: Record<string, string> | undefined) =>
Object.fromEntries(
Object.entries(object ?? {}).filter(([key, _]) => key.startsWith('--'))
)
const goBackFromTimepicker = () =>
props.startingView === 'time' && props.minimumView === 'time'
? null
: (viewShown.value = 'day')
return {
blur,
focus,
click,
input,
inputRef,
pageDate,
renderView,
updatePageDate,
selectYear,
selectMonth,
selectDay,
selectTime,
keyUp,
viewShown,
goBackFromTimepicker,
clearModelValue,
initialView,
log: (e: any) => console.log(e),
variables,
}
},
})
</script>
<style>
.v3dp__datepicker {
--popout-bg-color: var(--vdp-bg-color, #fff);
--box-shadow: var(
--vdp-box-shadow,
0 4px 10px 0 rgba(128, 144, 160, 0.1),
0 0 1px 0 rgba(128, 144, 160, 0.81)
);
--text-color: var(--vdp-text-color, #000000);
--border-radius: var(--vdp-border-radius, 3px);
--heading-size: var(--vdp-heading-size, 2.5em); /* 40px for 16px font */
--heading-weight: var(--vdp-heading-weight, bold);
--heading-hover-color: var(--vdp-heading-hover-color, #eeeeee);
--arrow-color: var(--vdp-arrow-color, currentColor);
--elem-color: var(--vdp-elem-color, currentColor);
--elem-disabled-color: var(--vdp-disabled-color, #d5d9e0);
--elem-hover-color: var(--vdp-hover-color, #fff);
--elem-hover-bg-color: var(--vdp-hover-bg-color, #0baf74);
--elem-selected-color: var(--vdp-selected-color, #fff);
--elem-selected-bg-color: var(--vdp-selected-bg-color, #0baf74);
--elem-current-outline-color: var(--vdp-current-date-outline-color, #888);
--elem-current-font-weight: var(--vdp-current-date-font-weight, bold);
--elem-font-size: var(--vdp-elem-font-size, 0.8em);
--elem-border-radius: var(--vdp-elem-border-radius, 3px);
--divider-color: var(--vdp-divider-color, var(--elem-disabled-color));
position: relative;
}
.v3dp__clearable {
display: inline;
position: relative;
left: -15px;
cursor: pointer;
}
</style>

View file

@ -0,0 +1,223 @@
<template>
<picker-popup
headingClickable
:leftDisabled="leftDisabled"
:rightDisabled="rightDisabled"
:items="days"
viewMode="day"
@left="previousPage"
@right="nextPage"
@heading="$emit('back')"
@elementClick="$emit('select', $event)"
>
<template #heading>{{ heading }}</template>
<template #subheading>
<span
v-for="(day, index) in weekDays"
:key="day"
:class="`v3dp__subheading__weekday__${index}`"
>
{{ day }}
</span>
</template>
</picker-popup>
</template>
<script lang="ts">
import { defineComponent, computed, ref, watchEffect, PropType } from 'vue'
import {
startOfMonth,
endOfMonth,
eachDayOfInterval,
subMonths,
addMonths,
startOfWeek,
endOfWeek,
isSameDay,
setDay,
isWithinInterval,
isBefore,
isAfter,
isSameMonth,
endOfDay,
startOfDay,
isValid,
format as formatDate,
} from 'date-fns'
import PickerPopup, { Item } from './PickerPopup.vue'
export default defineComponent({
components: {
PickerPopup,
},
emits: {
'update:pageDate': (date: Date) => isValid(date),
select: (date: Date) => isValid(date),
back: () => true,
},
props: {
selected: {
type: Date as PropType<Date>,
required: false,
},
pageDate: {
type: Date as PropType<Date>,
required: true,
},
format: {
type: String,
required: false,
default: 'dd',
},
headingFormat: {
type: String,
required: false,
default: 'LLLL yyyy',
},
weekdayFormat: {
type: String,
required: false,
default: 'EE',
},
locale: {
type: Object as PropType<Locale>,
required: false,
},
weekStartsOn: {
type: Number as PropType<1 | 2 | 3 | 4 | 5 | 6 | 0>,
required: false,
default: 1,
validator: (i: unknown): boolean =>
typeof i === 'number' && Number.isInteger(i) && i >= 0 && i <= 6,
},
lowerLimit: {
type: Date as PropType<Date>,
required: false,
},
upperLimit: {
type: Date as PropType<Date>,
required: false,
},
disabledDates: {
type: Object as PropType<{
dates?: Date[]
predicate?: (target: Date) => boolean
}>,
required: false,
},
allowOutsideInterval: {
type: Boolean,
required: false,
default: false,
},
},
setup(props, { emit }) {
const format = computed(
() => (format: string) => (value: Date | number) =>
formatDate(value, format, {
locale: props.locale,
weekStartsOn: props.weekStartsOn,
})
)
const monthStart = computed(() => startOfMonth(props.pageDate))
const monthEnd = computed(() => endOfMonth(props.pageDate))
const currentMonth = computed(() => ({
start: monthStart.value,
end: monthEnd.value,
}))
const displayedInterval = computed(() => ({
start: startOfWeek(monthStart.value, {
weekStartsOn: props.weekStartsOn,
}),
end: endOfWeek(monthEnd.value, {
weekStartsOn: props.weekStartsOn,
}),
}))
const weekDays = computed(() => {
const initial = props.weekStartsOn
const dayFormat = format.value(props.weekdayFormat)
return Array.from(Array(7))
.map((_, i) => (initial + i) % 7)
.map((v) =>
setDay(new Date(), v, {
weekStartsOn: props.weekStartsOn,
})
)
.map(dayFormat)
})
const isEnabled = (
target: Date,
lower?: Date,
upper?: Date,
disabledDates?: { dates?: Date[]; predicate?: (target: Date) => boolean }
): boolean => {
if (disabledDates?.dates?.some((date) => isSameDay(target, date)))
return false
if (disabledDates?.predicate?.(target)) return false
if (!lower && !upper) return true
if (lower && isBefore(target, startOfDay(lower))) return false
if (upper && isAfter(target, endOfDay(upper))) return false
return true
}
const days = computed(() => {
const today = new Date()
const dayFormat = format.value(props.format)
return eachDayOfInterval(displayedInterval.value).map(
(value): Item => ({
value,
display: dayFormat(value),
selected: !!props.selected && isSameDay(props.selected, value),
current: isSameDay(today, value),
disabled:
(!props.allowOutsideInterval &&
!isWithinInterval(value, currentMonth.value)) ||
!isEnabled(
value,
props.lowerLimit,
props.upperLimit,
props.disabledDates
),
key: format.value('yyyy-MM-dd')(value),
})
)
})
const heading = computed(() =>
format.value(props.headingFormat)(props.pageDate)
)
const leftDisabled = computed(
() =>
props.lowerLimit &&
(isSameMonth(props.lowerLimit, props.pageDate) ||
isBefore(props.pageDate, props.lowerLimit))
)
const rightDisabled = computed(
() =>
props.upperLimit &&
(isSameMonth(props.upperLimit, props.pageDate) ||
isAfter(props.pageDate, props.upperLimit))
)
const previousPage = () =>
emit('update:pageDate', subMonths(props.pageDate, 1))
const nextPage = () => emit('update:pageDate', addMonths(props.pageDate, 1))
return {
weekDays,
days,
heading,
leftDisabled,
rightDisabled,
previousPage,
nextPage,
}
},
})
</script>
<style></style>

View file

@ -0,0 +1,145 @@
<template>
<picker-popup
headingClickable
:columnCount="3"
:items="months"
:leftDisabled="leftDisabled"
:rightDisabled="rightDisabled"
viewMode="month"
@left="previousPage"
@right="nextPage"
@heading="$emit('back')"
@elementClick="$emit('select', $event)"
>
<template #heading>{{ heading }}</template>
</picker-popup>
</template>
<script lang="ts">
import { defineComponent, computed, ref, watchEffect, PropType } from 'vue'
import {
startOfYear,
endOfYear,
eachMonthOfInterval,
getMonth,
getYear,
subYears,
addYears,
format,
isSameMonth,
isBefore,
isAfter,
isSameYear,
startOfMonth,
endOfMonth,
isValid,
format as formatDate,
} from 'date-fns'
import PickerPopup, { Item } from './PickerPopup.vue'
export default defineComponent({
components: {
PickerPopup,
},
emits: {
'update:pageDate': (date: Date) => isValid(date),
select: (date: Date) => isValid(date),
back: () => true,
},
props: {
/**
* Currently selected date, needed for highlighting
*/
selected: {
type: Date as PropType<Date>,
required: false,
},
pageDate: {
type: Date as PropType<Date>,
required: true,
},
format: {
type: String,
required: false,
default: 'LLL',
},
locale: {
type: Object as PropType<Locale>,
required: false,
},
lowerLimit: {
type: Date as PropType<Date>,
required: false,
},
upperLimit: {
type: Date as PropType<Date>,
required: false,
},
},
setup(props, { emit }) {
const from = computed(() => startOfYear(props.pageDate))
const to = computed(() => endOfYear(props.pageDate))
const format = computed(
() => (value: Date | number) =>
formatDate(value, props.format, {
locale: props.locale,
})
)
const isEnabled = (
target: Date,
lower: Date | undefined,
upper: Date | undefined
): boolean => {
if (!lower && !upper) return true
if (lower && isBefore(target, startOfMonth(lower))) return false
if (upper && isAfter(target, endOfMonth(upper))) return false
return true
}
const months = computed(() =>
eachMonthOfInterval({
start: from.value,
end: to.value,
}).map(
(value): Item => ({
value,
display: format.value(value),
key: format.value(value),
selected: !!props.selected && isSameMonth(props.selected, value),
disabled: !isEnabled(value, props.lowerLimit, props.upperLimit),
})
)
)
const heading = computed(() => getYear(from.value))
const leftDisabled = computed(
() =>
props.lowerLimit &&
(isSameYear(props.lowerLimit, props.pageDate) ||
isBefore(props.pageDate, props.lowerLimit))
)
const rightDisabled = computed(
() =>
props.upperLimit &&
(isSameYear(props.upperLimit, props.pageDate) ||
isAfter(props.pageDate, props.upperLimit))
)
const previousPage = () =>
emit('update:pageDate', subYears(props.pageDate, 1))
const nextPage = () => emit('update:pageDate', addYears(props.pageDate, 1))
return {
months,
heading,
leftDisabled,
rightDisabled,
previousPage,
nextPage,
}
},
})
</script>

View file

@ -0,0 +1,255 @@
<template>
<div
class="v3dp__popout"
:class="`v3dp__popout-${viewMode}`"
:style="{ ['--popout-column-definition' as any]: `repeat(${columnCount}, 1fr)` }"
@mousedown.prevent
>
<div class="v3dp__heading">
<button
class="v3dp__heading__button v3dp__heading__button__left"
:disabled="leftDisabled"
@click.stop.prevent="$emit('left')"
>
<slot name="arrow-left">
<svg
class="v3dp__heading__icon"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 6 8"
>
<g fill="none" fill-rule="evenodd">
<path stroke="none" d="M-9 16V-8h24v24z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M5 0L1 4l4 4"
/>
</g>
</svg>
</slot>
</button>
<component
:is="headingClickable ? 'button' : 'span'"
class="v3dp__heading__center"
@click.stop.prevent="$emit('heading')"
>
<slot name="heading" />
</component>
<button
class="v3dp__heading__button v3dp__heading__button__right"
:disabled="rightDisabled"
@click.stop.prevent="$emit('right')"
>
<slot name="arrow-right">
<svg
class="v3dp__heading__icon"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 6 8"
>
<g fill="none" fill-rule="evenodd">
<path stroke="none" d="M15-8v24H-9V-8z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M1 8l4-4-4-4"
/>
</g>
</svg>
</slot>
</button>
</div>
<div class="v3dp__body">
<template v-if="'subheading' in $slots">
<div class="v3dp__subheading">
<slot name="subheading" />
</div>
<hr class="v3dp__divider" />
</template>
<div class="v3dp__elements">
<slot name="body">
<button
v-for="item in items"
:key="item.key"
:disabled="item.disabled"
:class="[
{
selected: item.selected,
current: item.current,
},
`v3dp__element__button__${viewMode}`,
]"
@click.stop.prevent="$emit('elementClick', item.value)"
>
<span>{{ item.display }}</span>
</button>
</slot>
</div>
</div>
</div>
</template>
<script lang="ts">
import { isValid } from 'date-fns'
import { defineComponent, PropType, computed } from 'vue'
export interface Item {
key: string
value: Date
display: number | string
disabled: boolean
selected: boolean
current?: boolean
}
export type ViewMode = 'year' | 'month' | 'day' | 'time' | 'custom'
const VIEW_MODES = ['year', 'month', 'day', 'time', 'custom']
export default defineComponent({
emits: {
elementClick: (value: Date) => isValid(value),
left: () => true,
right: () => true,
heading: () => true,
},
props: {
headingClickable: {
type: Boolean,
default: false,
},
leftDisabled: {
type: Boolean,
default: false,
},
rightDisabled: {
type: Boolean,
default: false,
},
columnCount: {
type: Number,
default: 7,
},
items: {
type: Array as PropType<Item[]>,
default: (): Item[] => [],
},
viewMode: {
type: String as PropType<ViewMode>,
required: true,
validate: (x: unknown) => typeof x === 'string' && VIEW_MODES.includes(x),
},
},
})
</script>
<style scoped>
.v3dp__popout {
z-index: 10;
position: absolute;
/* bottom: 0; */
text-align: center;
width: 17.5em;
background-color: var(--popout-bg-color);
box-shadow: var(--box-shadow);
border-radius: var(--border-radius);
padding: 8px 0 1em;
color: var(--text-color);
}
.v3dp__popout * {
color: inherit;
font-size: inherit;
font-weight: inherit;
}
.v3dp__popout :deep(button) {
background: none;
border: none;
outline: none;
}
.v3dp__popout :deep(button:not(:disabled)) {
cursor: pointer;
}
.v3dp__heading {
width: 100%;
display: flex;
height: var(--heading-size);
line-height: var(--heading-size);
font-weight: var(--heading-weight);
}
.v3dp__heading__button {
background: none;
border: none;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
width: var(--heading-size);
}
button.v3dp__heading__center:hover,
.v3dp__heading__button:not(:disabled):hover {
background-color: var(--heading-hover-color);
}
.v3dp__heading__center {
flex: 1;
}
.v3dp__heading__icon {
height: 12px;
stroke: var(--arrow-color);
}
.v3dp__heading__button:disabled .v3dp__heading__icon {
stroke: var(--elem-disabled-color);
}
.v3dp__subheading,
.v3dp__elements {
display: grid;
grid-template-columns: var(--popout-column-definition);
font-size: var(--elem-font-size);
}
.v3dp__subheading {
margin-top: 1em;
}
.v3dp__divider {
border: 1px solid var(--divider-color);
border-radius: 3px;
}
.v3dp__elements :deep(button:disabled) {
color: var(--elem-disabled-color);
}
.v3dp__elements :deep(button) {
padding: 0.3em 0.6em;
}
.v3dp__elements :deep(button span) {
display: block;
line-height: 1.9em;
height: 1.8em;
border-radius: var(--elem-border-radius);
}
.v3dp__elements :deep(button:not(:disabled):hover span) {
background-color: var(--elem-hover-bg-color);
color: var(--elem-hover-color);
}
.v3dp__elements :deep(button.selected span) {
background-color: var(--elem-selected-bg-color);
color: var(--elem-selected-color);
}
.v3dp__elements :deep(button.current span) {
font-weight: var(--elem-current-font-weight);
outline: 1px solid var(--elem-current-outline-color);
}
</style>

View file

@ -0,0 +1,233 @@
<template>
<picker-popup
headingClickable
:columnCount="2"
:leftDisabled="true"
:rightDisabled="true"
viewMode="time"
@heading="$emit('back')"
>
<template #heading
>{{ padStartZero(hours) }}:{{ padStartZero(minutes) }}</template
>
<template #body>
<div ref="hoursListRef" class="v3dp__column">
<button
v-for="item in hoursList"
:key="item.value"
:ref="item.ref"
:class="[{ selected: item.selected }, 'v3dp__element_button__hour']"
:disabled="!isEnabled(item.date)"
@click.stop.prevent="hours = item.value"
>
<span>{{ padStartZero(item.value) }}</span>
</button>
</div>
<div ref="minutesListRef" class="v3dp__column">
<button
v-for="item in minutesList"
:key="item.value"
:ref="item.ref"
:class="[{ selected: item.selected }, 'v3dp__element_button__minute']"
:disabled="!isEnabled(item.date)"
@click.stop.prevent="selectMinutes(item)"
>
<span>{{ padStartZero(item.value) }}</span>
</button>
</div>
</template>
</picker-popup>
</template>
<script lang="ts">
import {
defineComponent,
computed,
ref,
watch,
nextTick,
ComputedRef,
Ref,
PropType,
} from 'vue'
import { isSameHour, isSameMinute, isValid, set } from 'date-fns'
import PickerPopup from './PickerPopup.vue'
interface Item {
value: number
date: Date
selected: boolean | undefined
ref: Ref<null | HTMLElement>
}
function scrollParentToChild(parent: HTMLElement, child: HTMLElement) {
const parentRect = parent.getBoundingClientRect()
const parentViewableArea = {
height: parent.clientHeight,
width: parent.clientWidth,
}
const childRect = child.getBoundingClientRect()
const isViewable =
childRect.top >= parentRect.top &&
childRect.bottom <= parentRect.top + parentViewableArea.height
if (!isViewable) {
const scrollTop = childRect.top - parentRect.top
const scrollBot = childRect.bottom - parentRect.bottom
if (Math.abs(scrollTop) < Math.abs(scrollBot)) {
parent.scrollTop += scrollTop
} else {
parent.scrollTop += scrollBot
}
}
}
export default defineComponent({
components: {
PickerPopup,
},
emits: {
select: (date: Date) => isValid(date),
back: () => true,
},
props: {
selected: {
type: Date as PropType<Date>,
required: false,
},
pageDate: {
type: Date as PropType<Date>,
required: true,
},
visible: {
type: Boolean,
required: true,
},
disabledTime: {
type: Object as PropType<{
dates?: Date[]
predicate?: (target: Date) => boolean
}>,
required: false,
},
},
setup(props, { emit }) {
const hoursListRef = ref(null as HTMLElement | null)
const minutesListRef = ref(null as HTMLElement | null)
const currentDate = computed(() => props.pageDate ?? props.selected)
const hours = ref(currentDate.value.getHours())
const minutes = ref(currentDate.value.getMinutes())
watch(
() => props.selected,
(value: Date | undefined) => {
let newHours = 0
let newMinutes = 0
if (value) {
newHours = value.getHours()
newMinutes = value.getMinutes()
}
hours.value = newHours
minutes.value = newMinutes
}
)
const hoursList: ComputedRef<Item[]> = computed(() =>
[...Array(24).keys()].map(
(value): Item => ({
value,
date: set(new Date(currentDate.value.getTime()), {
hours: value,
minutes: minutes.value,
seconds: 0,
}),
selected: hours.value === value,
ref: ref(null),
})
)
)
const minutesList: ComputedRef<Item[]> = computed(() =>
[...Array(60).keys()].map((value) => ({
value,
date: set(new Date(currentDate.value.getTime()), {
hours: hours.value,
minutes: value,
seconds: 0,
}),
selected: minutes.value === value,
ref: ref(null),
}))
)
const selectMinutes = (item: Item) => {
minutes.value = item.value
emit('select', item.date)
}
const scroll = () => {
const currentHour = hoursList.value.find(
(item) => item.ref.value?.classList?.contains('selected') ?? false
)
const currentMinute = minutesList.value.find(
(item) => item.ref.value?.classList?.contains('selected') ?? false
)
if (currentHour && currentMinute) {
scrollParentToChild(hoursListRef.value!, currentHour.ref.value!)
scrollParentToChild(minutesListRef.value!, currentMinute.ref.value!)
}
}
watch(
() => props.visible,
(visible) => {
if (visible) {
nextTick(scroll)
}
}
)
const isEnabled = (target: Date): boolean => {
if (
props.disabledTime?.dates?.some(
(date) => isSameHour(target, date) && isSameMinute(target, date)
)
) {
return false
}
if (props.disabledTime?.predicate?.(target)) return false
return true
}
const padStartZero = (item: number): string => `0${item}`.substr(-2)
return {
hoursListRef,
minutesListRef,
hours,
minutes,
hoursList,
minutesList,
padStartZero,
selectMinutes,
isEnabled,
scroll,
}
},
})
</script>
<style scoped>
.v3dp__column {
display: flex;
flex-direction: column;
overflow-y: auto;
height: 190px;
}
</style>

View file

@ -0,0 +1,123 @@
<template>
<picker-popup
:columnCount="3"
:leftDisabled="leftDisabled"
:rightDisabled="rightDisabled"
:items="years"
viewMode="year"
@left="previousPage"
@right="nextPage"
@elementClick="$emit('select', $event)"
>
<template #heading>{{ heading }}</template>
</picker-popup>
</template>
<script lang="ts">
import { defineComponent, computed, ref, watchEffect, PropType } from 'vue'
import {
startOfDecade,
endOfDecade,
eachYearOfInterval,
getYear,
subYears,
addYears,
isAfter,
isBefore,
getDecade,
isValid,
} from 'date-fns'
import PickerPopup, { Item } from './PickerPopup.vue'
export default defineComponent({
components: {
PickerPopup,
},
emits: {
'update:pageDate': (date: Date) => isValid(date),
select: (date: Date) => isValid(date),
},
props: {
selected: {
type: Date as PropType<Date>,
required: false,
},
pageDate: {
type: Date as PropType<Date>,
required: true,
},
lowerLimit: {
type: Date as PropType<Date>,
required: false,
},
upperLimit: {
type: Date as PropType<Date>,
required: false,
},
},
setup(props, { emit }) {
const from = computed(() => startOfDecade(props.pageDate))
const to = computed(() => endOfDecade(props.pageDate))
const isEnabled = (
target: Date,
lower: Date | undefined,
upper: Date | undefined
): boolean => {
if (!lower && !upper) return true
if (lower && getYear(target) < getYear(lower)) return false
if (upper && getYear(target) > getYear(upper)) return false
return true
}
const years = computed(() =>
eachYearOfInterval({
start: from.value,
end: to.value,
}).map(
(value): Item => ({
value,
key: String(getYear(value)),
display: getYear(value),
selected:
!!props.selected && getYear(value) === getYear(props.selected),
disabled: !isEnabled(value, props.lowerLimit, props.upperLimit),
})
)
)
const heading = computed(() => {
const start = getYear(from.value)
const end = getYear(to.value)
return `${start} - ${end}`
})
const leftDisabled = computed(
() =>
props.lowerLimit &&
(getDecade(props.lowerLimit) === getDecade(props.pageDate) ||
isBefore(props.pageDate, props.lowerLimit))
)
const rightDisabled = computed(
() =>
props.upperLimit &&
(getDecade(props.upperLimit) === getDecade(props.pageDate) ||
isAfter(props.pageDate, props.upperLimit))
)
const previousPage = () =>
emit('update:pageDate', subYears(props.pageDate, 10))
const nextPage = () => emit('update:pageDate', addYears(props.pageDate, 10))
return {
years,
heading,
leftDisabled,
rightDisabled,
previousPage,
nextPage,
}
},
})
</script>