vant/src/calendar/Calendar.tsx
neverland bd609e1df0
perf: add truthProp util (#8522)
* perf: add TruthyProp util

* chore: rename

* chore: upd
2021-04-14 10:26:21 +08:00

535 lines
13 KiB
TypeScript

import {
ref,
watch,
reactive,
computed,
PropType,
TeleportProps,
defineComponent,
} from 'vue';
// Utils
import {
pick,
isDate,
truthProp,
getScrollTop,
ComponentInstance,
} from '../utils';
import {
t,
bem,
name,
cloneDate,
cloneDates,
getPrevDay,
getNextDay,
compareDay,
calcDateNum,
compareMonth,
getDayByOffset,
} from './utils';
// Composables
import { raf, useRect, onMountedOrActivated } from '@vant/use';
import { useRefs } from '../composables/use-refs';
import { useExpose } from '../composables/use-expose';
// Components
import { Popup, PopupPosition } from '../popup';
import { Button } from '../button';
import { Toast } from '../toast';
import CalendarMonth, { CalendarType } from './CalendarMonth';
import CalendarHeader from './CalendarHeader';
// Types
import type { CalendarDayItem } from './CalendarDay';
export default defineComponent({
name,
props: {
show: Boolean,
title: String,
color: String,
round: truthProp,
readonly: Boolean,
poppable: truthProp,
teleport: [String, Object] as PropType<TeleportProps['to']>,
showMark: truthProp,
showTitle: truthProp,
formatter: Function as PropType<(item: CalendarDayItem) => CalendarDayItem>,
rowHeight: [Number, String],
confirmText: String,
rangePrompt: String,
lazyRender: truthProp,
showConfirm: truthProp,
// TODO: remove any
// see: https://github.com/vuejs/vue-next/issues/2668
defaultDate: [Date, Array] as any,
allowSameDay: Boolean,
showSubtitle: truthProp,
closeOnPopstate: truthProp,
confirmDisabledText: String,
closeOnClickOverlay: truthProp,
safeAreaInsetBottom: truthProp,
type: {
type: String as PropType<CalendarType>,
default: 'single',
},
position: {
type: String as PropType<PopupPosition>,
default: 'bottom',
},
maxRange: {
type: [Number, String],
default: null,
},
minDate: {
type: Date,
validator: isDate,
default: () => new Date(),
},
maxDate: {
type: Date,
validator: isDate,
default: () => {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth() + 6, now.getDate());
},
},
firstDayOfWeek: {
type: [Number, String],
default: 0,
validator: (val: number) => val >= 0 && val <= 6,
},
},
emits: ['select', 'confirm', 'unselect', 'month-show', 'update:show'],
setup(props, { emit, slots }) {
const limitDateRange = (
date: Date,
minDate = props.minDate,
maxDate = props.maxDate
) => {
if (compareDay(date, minDate) === -1) {
return minDate;
}
if (compareDay(date, maxDate) === 1) {
return maxDate;
}
return date;
};
const getInitialDate = (defaultDate = props.defaultDate) => {
const { type, minDate, maxDate } = props;
if (defaultDate === null) {
return defaultDate;
}
const now = new Date();
if (type === 'range') {
if (!Array.isArray(defaultDate)) {
defaultDate = [];
}
const start = limitDateRange(
defaultDate[0] || now,
minDate,
getPrevDay(maxDate)
);
const end = limitDateRange(defaultDate[1] || now, getNextDay(minDate));
return [start, end];
}
if (type === 'multiple') {
if (Array.isArray(defaultDate)) {
return defaultDate.map((date) => limitDateRange(date));
}
return [limitDateRange(now)];
}
if (!defaultDate || Array.isArray(defaultDate)) {
defaultDate = now;
}
return limitDateRange(defaultDate);
};
let bodyHeight: number;
const bodyRef = ref<HTMLElement>();
const state = reactive({
subtitle: '',
currentDate: getInitialDate(),
});
const [monthRefs, setMonthRefs] = useRefs<ComponentInstance>();
const dayOffset = computed(() =>
props.firstDayOfWeek ? +props.firstDayOfWeek % 7 : 0
);
const months = computed(() => {
const months = [];
const cursor = new Date(props.minDate);
cursor.setDate(1);
do {
months.push(new Date(cursor));
cursor.setMonth(cursor.getMonth() + 1);
} while (compareMonth(cursor, props.maxDate) !== 1);
return months;
});
const buttonDisabled = computed(() => {
const { currentDate } = state;
if (currentDate) {
if (props.type === 'range') {
return !(currentDate as Date[])[0] || !(currentDate as Date[])[1];
}
if (props.type === 'multiple') {
return !(currentDate as Date[]).length;
}
}
return !currentDate;
});
// calculate the position of the elements
// and find the elements that needs to be rendered
const onScroll = () => {
const top = getScrollTop(bodyRef.value!);
const bottom = top + bodyHeight;
const heights = months.value.map((item, index) =>
monthRefs.value[index].getHeight()
);
const heightSum = heights.reduce((a, b) => a + b, 0);
// iOS scroll bounce may exceed the range
if (bottom > heightSum && top > 0) {
return;
}
let height = 0;
let currentMonth;
const visibleRange = [-1, -1];
for (let i = 0; i < months.value.length; i++) {
const month = monthRefs.value[i];
const visible = height <= bottom && height + heights[i] >= top;
if (visible) {
visibleRange[1] = i;
if (!currentMonth) {
currentMonth = month;
visibleRange[0] = i;
}
if (!monthRefs.value[i].showed) {
monthRefs.value[i].showed = true;
emit('month-show', {
date: month.date,
title: month.title,
});
}
}
height += heights[i];
}
months.value.forEach((month, index) => {
const visible =
index >= visibleRange[0] - 1 && index <= visibleRange[1] + 1;
monthRefs.value[index].setVisible(visible);
});
/* istanbul ignore else */
if (currentMonth) {
state.subtitle = currentMonth.getTitle();
}
};
const scrollToDate = (targetDate: Date) => {
raf(() => {
months.value.some((month, index) => {
if (compareMonth(month, targetDate) === 0) {
monthRefs.value[index].scrollIntoView(bodyRef.value);
return true;
}
return false;
});
onScroll();
});
};
// scroll to current month
const scrollIntoView = () => {
if (props.poppable && !props.show) {
return;
}
const { currentDate } = state;
if (currentDate) {
const targetDate =
props.type === 'single' ? currentDate : (currentDate as Date[])[0];
scrollToDate(targetDate);
} else {
raf(onScroll);
}
};
const init = () => {
if (props.poppable && !props.show) {
return;
}
raf(() => {
// add Math.floor to avoid decimal height issues
// https://github.com/youzan/vant/issues/5640
bodyHeight = Math.floor(useRect(bodyRef).height);
scrollIntoView();
});
};
const reset = (date = getInitialDate()) => {
state.currentDate = date;
scrollIntoView();
};
const checkRange = (date: [Date, Date]) => {
const { maxRange, rangePrompt } = props;
if (maxRange && calcDateNum(date) > maxRange) {
Toast(rangePrompt || t('rangePrompt', maxRange));
return false;
}
return true;
};
const onConfirm = () => emit('confirm', cloneDates(state.currentDate));
const select = (date: Date | Date[], complete?: boolean) => {
const setCurrentDate = (date: Date | Date[]) => {
state.currentDate = date;
emit('select', cloneDates(state.currentDate));
};
if (complete && props.type === 'range') {
const valid = checkRange(date as [Date, Date]);
if (!valid) {
// auto selected to max range if showConfirm
if (props.showConfirm) {
setCurrentDate([
(date as Date[])[0],
getDayByOffset((date as Date[])[0], +props.maxRange - 1),
]);
} else {
setCurrentDate(date);
}
return;
}
}
setCurrentDate(date);
if (complete && !props.showConfirm) {
onConfirm();
}
};
const onClickDay = (item: CalendarDayItem) => {
if (props.readonly || !item.date) {
return;
}
const { date } = item;
const { type } = props;
const { currentDate } = state;
if (type === 'range') {
if (!currentDate) {
select([date]);
return;
}
const [startDay, endDay] = currentDate;
if (startDay && !endDay) {
const compareToStart = compareDay(date, startDay);
if (compareToStart === 1) {
select([startDay, date], true);
} else if (compareToStart === -1) {
select([date]);
} else if (props.allowSameDay) {
select([date, date], true);
}
} else {
select([date]);
}
} else if (type === 'multiple') {
if (!currentDate) {
select([date]);
return;
}
let selectedIndex;
const selected = state.currentDate.some(
(dateItem: Date, index: number) => {
const equal = compareDay(dateItem, date) === 0;
if (equal) {
selectedIndex = index;
}
return equal;
}
);
if (selected) {
const [unselectedDate] = currentDate.splice(selectedIndex, 1);
emit('unselect', cloneDate(unselectedDate));
} else if (props.maxRange && currentDate.length >= props.maxRange) {
Toast(props.rangePrompt || t('rangePrompt', props.maxRange));
} else {
select([...currentDate, date]);
}
} else {
select(date, true);
}
};
const updateShow = (value: boolean) => emit('update:show', value);
const renderMonth = (date: Date, index: number) => {
const showMonthTitle = index !== 0 || !props.showSubtitle;
return (
<CalendarMonth
ref={setMonthRefs(index)}
date={date}
currentDate={state.currentDate}
showMonthTitle={showMonthTitle}
firstDayOfWeek={dayOffset.value}
{...pick(props, [
'type',
'color',
'minDate',
'maxDate',
'showMark',
'formatter',
'rowHeight',
'lazyRender',
'showSubtitle',
'allowSameDay',
])}
onClick={onClickDay}
/>
);
};
const renderFooterButton = () => {
if (slots.footer) {
return slots.footer();
}
if (props.showConfirm) {
const text = buttonDisabled.value
? props.confirmDisabledText
: props.confirmText;
return (
<Button
round
block
type="danger"
color={props.color}
class={bem('confirm')}
disabled={buttonDisabled.value}
nativeType="button"
onClick={onConfirm}
>
{text || t('confirm')}
</Button>
);
}
};
const renderFooter = () => (
<div class={bem('footer', { unfit: !props.safeAreaInsetBottom })}>
{renderFooterButton()}
</div>
);
const renderCalendar = () => (
<div class={bem()}>
<CalendarHeader
v-slots={{ title: slots.title }}
title={props.title}
showTitle={props.showTitle}
subtitle={state.subtitle}
showSubtitle={props.showSubtitle}
firstDayOfWeek={dayOffset.value}
/>
<div ref={bodyRef} class={bem('body')} onScroll={onScroll}>
{months.value.map(renderMonth)}
</div>
{renderFooter()}
</div>
);
watch(() => props.show, init);
watch(
() => [props.type, props.minDate, props.maxDate],
() => {
reset(getInitialDate(state.currentDate));
}
);
watch(
() => props.defaultDate,
(value) => {
state.currentDate = value;
scrollIntoView();
}
);
useExpose({
reset,
scrollToDate,
});
onMountedOrActivated(init);
return () => {
if (props.poppable) {
return (
<Popup
show={props.show}
class={bem('popup')}
round={props.round}
position={props.position}
closeable={props.showTitle || props.showSubtitle}
teleport={props.teleport}
closeOnPopstate={props.closeOnPopstate}
closeOnClickOverlay={props.closeOnClickOverlay}
{...{ 'onUpdate:show': updateShow }}
>
{renderCalendar()}
</Popup>
);
}
return renderCalendar();
};
},
});