diff --git a/package.json b/package.json index 737d913bde..6174efed20 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,8 @@ "rc-util": "^5.38.1", "react-fast-compare": "^3.2.2", "react-is": "^18.2.0", + "react-virtualized-auto-sizer": "^1.0.24", + "react-window": "^1.8.10", "runes2": "^1.1.2", "staged-components": "^1.1.3", "tslib": "^2.5.0", @@ -106,6 +108,7 @@ "@types/react-helmet": "^6.1.6", "@types/react-is": "^17.0.3", "@types/react-virtualized": "^9.21.21", + "@types/react-window": "^1.8.8", "@types/resize-observer-browser": "^0.1.7", "@types/testing-library__jest-dom": "^5.14.5", "@types/use-sync-external-store": "^0.0.3", diff --git a/src/components/calendar-picker-view/calendar-picker-view.less b/src/components/calendar-picker-view/calendar-picker-view.less index 510089885d..8da58b4717 100644 --- a/src/components/calendar-picker-view/calendar-picker-view.less +++ b/src/components/calendar-picker-view/calendar-picker-view.less @@ -144,4 +144,11 @@ text-align: center; } } + + &-no-scrollbar { + scrollbar-width: none; /* firefox */ + -ms-overflow-style: none; /* IE 10+ */ + overflow-x: hidden; + overflow-y: auto; + } } diff --git a/src/components/calendar-picker-view/calendar-picker-view.tsx b/src/components/calendar-picker-view/calendar-picker-view.tsx index e3eda2bc82..c557a8a22e 100644 --- a/src/components/calendar-picker-view/calendar-picker-view.tsx +++ b/src/components/calendar-picker-view/calendar-picker-view.tsx @@ -1,10 +1,10 @@ import classNames from 'classnames' import dayjs from 'dayjs' -import isoWeek from 'dayjs/plugin/isoWeek' import isSameOrBefore from 'dayjs/plugin/isSameOrBefore' +import isoWeek from 'dayjs/plugin/isoWeek' import React, { - forwardRef, ReactNode, + forwardRef, useContext, useEffect, useImperativeHandle, @@ -12,15 +12,17 @@ import React, { useRef, useState, } from 'react' +import AutoSizer from 'react-virtualized-auto-sizer' +import { FixedSizeList as List } from 'react-window' import { NativeProps, withNativeProps } from '../../utils/native-props' import { usePropsValue } from '../../utils/use-props-value' import { mergeProps } from '../../utils/with-default-props' import { useConfig } from '../config-provider' import { - convertPageToDayjs, - convertValueToRange, DateRange, Page, + convertPageToDayjs, + convertValueToRange, } from './convert' import useSyncScroll from './useSyncScroll' @@ -84,6 +86,21 @@ const defaultProps = { selectionMode: 'single', } +type RowProps = { + index: number + style: React.CSSProperties +} + +type Cell = { + year: number + month: number + daysInMonth: number + monthIterator: dayjs.Dayjs +} + +// default 每个月的高度是 344px +const defaultMonthHeight = 344 + export const CalendarPickerView = forwardRef< CalendarPickerViewRef, CalendarPickerViewProps @@ -93,6 +110,12 @@ export const CalendarPickerView = forwardRef< const props = mergeProps(defaultProps, p) const { locale } = useConfig() + // 修改为虚拟滚动后不能固定高度, 所以需要创建用户自定义日历后的月份高度来确定虚拟滚动 List 的高度 + const [monthPlaceholderHeight, setMonthPlaceholderHeight] = + useState(defaultMonthHeight) + const [isPlaceholderRendered, setIsPlaceholderRendered] = useState(true) + const monthPlaceholderRef = useRef(null) + const markItems = [...locale.Calendar.markItems] if (props.weekStartsOn === 'Sunday') { const item = markItems.pop() @@ -152,6 +175,17 @@ export const CalendarPickerView = forwardRef< } }, [dateRange]) + // 用来获取计算用户自定义日历的月份高度 获取到后隐藏掉这个基础款月份 dom + useEffect(() => { + if (monthPlaceholderRef.current) { + const calculatedHeight = monthPlaceholderRef.current.offsetHeight + if (calculatedHeight) { + setMonthPlaceholderHeight(calculatedHeight) + } + setIsPlaceholderRendered(false) + } + }, []) + const maxDay = useMemo( () => (props.max ? dayjs(props.max) : defaultMax), [props.max, defaultMax] @@ -194,194 +228,269 @@ export const CalendarPickerView = forwardRef< ) + // =============================== Render Month Body =============================== + + const renderMonthTitle = (year: number, month: number) => { + const renderMap = { year, month } + return locale.Calendar.yearAndMonth?.replace( + /\${(.*?)}/g, + (_, variable: keyof typeof renderMap) => { + return renderMap[variable]?.toString() + } + ) + } + + const getPresetEmptyCells = (monthIterator: dayjs.Dayjs) => { + const presetEmptyCellCount = + props.weekStartsOn === 'Monday' + ? monthIterator.date(1).isoWeekday() - 1 + : monthIterator.date(1).isoWeekday() + + return presetEmptyCellCount === 7 + ? null + : Array.from({ length: presetEmptyCellCount }).map((_, index) => ( +
+ )) + } + + const getDateStatus = (d: dayjs.Dayjs, cells: Cell[]) => { + let isSelect = false + let isBegin = false + let isEnd = false + let isSelectRowBegin = false + let isSelectRowEnd = false + if (dateRange) { + const [begin, end] = dateRange + isBegin = d.isSame(begin, 'day') + isEnd = d.isSame(end, 'day') + isSelect = + isBegin || isEnd || (d.isAfter(begin, 'day') && d.isBefore(end, 'day')) + if (isSelect) { + isSelectRowBegin = + (cells.length % 7 === 0 || d.isSame(d.startOf('month'), 'day')) && + !isBegin + isSelectRowEnd = + (cells.length % 7 === 6 || d.isSame(d.endOf('month'), 'day')) && + !isEnd + } + } + const disabled = props.shouldDisableDate + ? props.shouldDisableDate(d.toDate()) + : (maxDay && d.isAfter(maxDay, 'day')) || + (minDay && d.isBefore(minDay, 'day')) + + return { + isSelect, + isBegin, + isEnd, + isSelectRowBegin, + isSelectRowEnd, + disabled, + } + } + + const handleDateClick = (d: dayjs.Dayjs, disabled: boolean) => { + if (!props.selectionMode) return + if (disabled) return + const date = d.toDate() + function shouldClear() { + if (!props.allowClear) return false + if (!dateRange) return false + const [begin, end] = dateRange + return d.isSame(begin, 'date') && d.isSame(end, 'day') + } + if (props.selectionMode === 'single') { + if (props.allowClear && shouldClear()) { + onDateChange(null) + return + } + onDateChange([date, date]) + } else if (props.selectionMode === 'range') { + if (!dateRange) { + onDateChange([date, date]) + setIntermediate(true) + return + } + if (shouldClear()) { + onDateChange(null) + setIntermediate(false) + return + } + if (intermediate) { + const another = dateRange[0] + onDateChange(another > date ? [date, another] : [another, date]) + setIntermediate(false) + } else { + onDateChange([date, date]) + setIntermediate(true) + } + } + } + + const renderDays = ( + monthIterator: dayjs.Dayjs, + daysInMonth: number, + cells: Cell[] + ) => { + return Array.from({ length: daysInMonth }).map((_, index) => { + const d = monthIterator.date(index + 1) + + const { + isSelect, + isBegin, + isEnd, + isSelectRowBegin, + isSelectRowEnd, + disabled, + } = getDateStatus(d, cells) + + const renderTop = () => { + if (props.renderTop === false) return null + + const contentWrapper = (content: ReactNode) => ( +
{content}
+ ) + + const top = props.renderTop?.(d.toDate()) + + if (top) { + return contentWrapper(top) + } + + if (props.selectionMode === 'range') { + if (isBegin) { + return contentWrapper(locale.Calendar.start) + } + + if (isEnd) { + return contentWrapper(locale.Calendar.end) + } + } + + if (d.isSame(today, 'day') && !isSelect) { + return contentWrapper(locale.Calendar.today) + } + + return contentWrapper(null) + } + + const renderBottom = () => { + if (props.renderBottom === false) return null + + return ( +
+ {props.renderBottom?.(d.toDate())} +
+ ) + } + + return ( +
{ + handleDateClick(d, disabled) + }} + > + {renderTop()} +
+ {props.renderDate ? props.renderDate(d.toDate()) : d.date()} +
+ {renderBottom()} +
+ ) + }) + } + + // 获取用户自定义后的月份面板高度 + const renderPlaceholderMonths = () => { + const monthIterator = minDay.clone() + const year = monthIterator.year() + const month = monthIterator.month() + 1 + const daysInMonth = monthIterator.daysInMonth() + const presetEmptyCells = getPresetEmptyCells(monthIterator) + + return ( +
+
+ {renderMonthTitle(year, month)} +
+
+ {/* 空格填充 */} + {presetEmptyCells} + {/* 遍历每月 */} + {renderDays(monthIterator, daysInMonth, [])} +
+
+ ) + } + function renderBody() { - const cells: ReactNode[] = [] - let monthIterator = minDay - // 遍历月份 + const totalMonths = Math.ceil(maxDay.diff(minDay, 'months', true)) + + const cells: Cell[] = [] + let monthIterator = minDay.clone() + while (monthIterator.isSameOrBefore(maxDay, 'month')) { const year = monthIterator.year() const month = monthIterator.month() + 1 + const daysInMonth = monthIterator.daysInMonth() - const renderMap = { - year, - month, - } + cells.push({ year, month, daysInMonth, monthIterator }) + monthIterator = monthIterator.add(1, 'month') + } + + const Row = ({ index, style }: RowProps) => { + const { year, month, daysInMonth, monthIterator } = cells[index] const yearMonth = `${year}-${month}` // 获取需要预先填充的空格,如果是 7 天则不需要填充 - const presetEmptyCellCount = - props.weekStartsOn === 'Monday' - ? monthIterator.date(1).isoWeekday() - 1 - : monthIterator.date(1).isoWeekday() - const presetEmptyCells = - presetEmptyCellCount == 7 - ? null - : Array(presetEmptyCellCount) - .fill(null) - .map((_, index) => ( -
- )) - - cells.push( -
+ const presetEmptyCells = getPresetEmptyCells(monthIterator) + + return ( +
- {locale.Calendar.yearAndMonth?.replace( - /\${(.*?)}/g, - (_, variable: keyof typeof renderMap) => { - return renderMap[variable]?.toString() - } - )} + {renderMonthTitle(year, month)}
{/* 空格填充 */} {presetEmptyCells} {/* 遍历每月 */} - {Array(monthIterator.daysInMonth()) - .fill(null) - .map((_, index) => { - const d = monthIterator.date(index + 1) - let isSelect = false - let isBegin = false - let isEnd = false - let isSelectRowBegin = false - let isSelectRowEnd = false - if (dateRange) { - const [begin, end] = dateRange - isBegin = d.isSame(begin, 'day') - isEnd = d.isSame(end, 'day') - isSelect = - isBegin || - isEnd || - (d.isAfter(begin, 'day') && d.isBefore(end, 'day')) - if (isSelect) { - isSelectRowBegin = - (cells.length % 7 === 0 || - d.isSame(d.startOf('month'), 'day')) && - !isBegin - isSelectRowEnd = - (cells.length % 7 === 6 || - d.isSame(d.endOf('month'), 'day')) && - !isEnd - } - } - const disabled = props.shouldDisableDate - ? props.shouldDisableDate(d.toDate()) - : (maxDay && d.isAfter(maxDay, 'day')) || - (minDay && d.isBefore(minDay, 'day')) - - const renderTop = () => { - if (props.renderTop === false) return null - - const contentWrapper = (content: ReactNode) => ( -
{content}
- ) - - const top = props.renderTop?.(d.toDate()) - - if (top) { - return contentWrapper(top) - } - - if (props.selectionMode === 'range') { - if (isBegin) { - return contentWrapper(locale.Calendar.start) - } - - if (isEnd) { - return contentWrapper(locale.Calendar.end) - } - } - - if (d.isSame(today, 'day') && !isSelect) { - return contentWrapper(locale.Calendar.today) - } - - return contentWrapper(null) - } - - const renderBottom = () => { - if (props.renderBottom === false) return null - - return ( -
- {props.renderBottom?.(d.toDate())} -
- ) - } - - return ( -
{ - if (!props.selectionMode) return - if (disabled) return - const date = d.toDate() - function shouldClear() { - if (!props.allowClear) return false - if (!dateRange) return false - const [begin, end] = dateRange - return d.isSame(begin, 'date') && d.isSame(end, 'day') - } - if (props.selectionMode === 'single') { - if (props.allowClear && shouldClear()) { - onDateChange(null) - return - } - onDateChange([date, date]) - } else if (props.selectionMode === 'range') { - if (!dateRange) { - onDateChange([date, date]) - setIntermediate(true) - return - } - if (shouldClear()) { - onDateChange(null) - setIntermediate(false) - return - } - if (intermediate) { - const another = dateRange[0] - onDateChange( - another > date ? [date, another] : [another, date] - ) - setIntermediate(false) - } else { - onDateChange([date, date]) - setIntermediate(true) - } - } - }} - > - {renderTop()} -
- {props.renderDate - ? props.renderDate(d.toDate()) - : d.date()} -
- {renderBottom()} -
- ) - })} + {renderDays(monthIterator, daysInMonth, cells)}
) - - monthIterator = monthIterator.add(1, 'month') } - return cells + return ( + <> + {isPlaceholderRendered && renderPlaceholderMonths()} + + {({ height, width }) => ( + + {Row} + + )} + + + ) } + const body = (
{renderBody()}