diff --git a/package-lock.json b/package-lock.json index 606b09bd..c7a917c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tdesign-web-components", - "version": "1.2.2", + "version": "1.2.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tdesign-web-components", - "version": "1.2.2", + "version": "1.2.7", "license": "MIT", "dependencies": { "@babel/runtime": "^7.24.7", @@ -15,6 +15,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "copy-to-clipboard": "^3.3.3", + "dayjs": "^1.11.19", "fast-json-patch": "3.1.1", "htm": "^3.1.1", "immer": "^10.1.1", @@ -6720,9 +6721,9 @@ } }, "node_modules/dayjs": { - "version": "1.11.18", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz", - "integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==", + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", "license": "MIT" }, "node_modules/debug": { diff --git a/package.json b/package.json index e68a5ca5..f1ed55a9 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "copy-to-clipboard": "^3.3.3", + "dayjs": "^1.11.19", "fast-json-patch": "3.1.1", "htm": "^3.1.1", "immer": "^10.1.1", diff --git a/site/sidebar.config.ts b/site/sidebar.config.ts index 157779a5..76a57f59 100644 --- a/site/sidebar.config.ts +++ b/site/sidebar.config.ts @@ -217,6 +217,12 @@ export default [ path: '/webcomponents/components/checkbox', component: () => import('tdesign-web-components/checkbox/README.md'), }, + { + title: 'DatePicker 日期选择器', + name: 'date-picker', + path: '/webcomponents/components/date-picker', + component: () => import('tdesign-web-components/date-picker/README.md'), + }, { title: 'Input 输入框', name: 'input', diff --git a/src/date-picker/DatePicker.tsx b/src/date-picker/DatePicker.tsx new file mode 100644 index 00000000..3260eaee --- /dev/null +++ b/src/date-picker/DatePicker.tsx @@ -0,0 +1,451 @@ +import '../select-input'; +import './panel/SinglePanel'; +import 'tdesign-icons-web-components/esm/components/calendar'; + +import dayjs from 'dayjs'; +import { classNames, Component, OmiProps, signal, tag } from 'omi'; + +import { formatDate, getDefaultFormat, isValidDate, parseToDayjs } from '../_common/js/date-picker/format'; +import { addMonth, covertToDate, subtractMonth } from '../_common/js/date-picker/utils'; +import { getClassPrefix } from '../_util/classname'; +import { setExportparts } from '../_util/dom'; +import useControlled from '../_util/useControlled'; +import { StyledProps } from '../common'; +import { datePickerDefaultProps } from './defaultProps'; +import { DateValue, PresetDate, TdDatePickerProps } from './type'; + +export interface DatePickerProps extends Omit, Omit { + style?: TdDatePickerProps['style']; +} + +@tag('t-date-picker') +export default class DatePicker extends Component { + static defaultProps = datePickerDefaultProps; + + static propTypes = { + value: [String, Number, Object, Array], + defaultValue: [String, Number, Object, Array], + popupVisible: Boolean, + defaultPopupVisible: Boolean, + format: String, + mode: String, + enableTimePicker: Boolean, + disabled: Boolean, + presets: Object, + presetsPlacement: String, + placeholder: String, + tips: [String, Object, Function], + status: String, + borderless: Boolean, + onChange: Function, + onPick: Function, + onClear: Function, + onVisibleChange: Function, + onPresetClick: Function, + }; + + private classPrefix = getClassPrefix(); + + private formatInfo = getDefaultFormat({ mode: 'date' }); + + private valueState: DateValue; + + private setValueState: (value: DateValue, context?: any) => void; + + private popupVisibleState = false; + + private setPopupVisibleState: (visible: boolean, context?: any) => void; + + private inputValueSignal = signal(''); + + private cacheValueSignal = signal(''); + + private placeholderSignal = signal(''); + + private yearSignal = signal(dayjs().year()); + + private monthSignal = signal(dayjs().month()); + + install() { + this.initializeControlled(this.props as DatePickerProps); + } + + ready() { + setExportparts(this); + } + + receiveProps(nextProps: OmiProps) { + this.initializeControlled(nextProps as DatePickerProps); + } + + /** + * 初始化受控状态 + * 设置值和弹窗的受控逻辑,同步派生状态 + */ + private initializeControlled(props: DatePickerProps) { + this.setupValueControl(props); + this.setupPopupControl(props); + this.formatInfo = getDefaultFormat({ + mode: props.mode, + format: props.format, + valueType: props.valueType, + enableTimePicker: false, + }); + this.syncDerivedState(this.valueState, props); + } + + /** + * 管理value的受控/非受控状态 + */ + private setupValueControl(props: DatePickerProps) { + const [value, setValue] = useControlled(props, 'value', (val, context) => props.onChange?.(val, context), { + defaultValue: props.defaultValue, + activeComponent: this, + }); + this.valueState = value as DateValue; + this.setValueState = (nextValue, context) => { + setValue(nextValue, context); + this.valueState = nextValue; + this.syncDerivedState(nextValue, this.props as DatePickerProps); + }; + } + + /** + * 管理popupVisible的受控/非受控状态 + */ + private setupPopupControl(props: DatePickerProps) { + const [visible, setVisible] = useControlled( + props, + 'popupVisible', + (val, context) => props.onVisibleChange?.(val, context), + { + defaultPopupVisible: props.defaultPopupVisible, + activeComponent: this, + }, + ); + this.popupVisibleState = Boolean(visible); + this.setPopupVisibleState = (nextVisible, context) => { + setVisible(nextVisible, context); + this.popupVisibleState = nextVisible; + }; + } + + /** + * 将不同类型的日期值标准化为Date对象 + */ + private normalizeValue(value: DateValue | undefined, props: DatePickerProps) { + if (!value) return value; + if (value instanceof Date) return value; + const { valueType, format } = this.formatInfo; + if (['week', 'quarter'].includes(props.mode)) { + if (valueType === 'time-stamp') { + return new Date(Number(value)); + } + if (valueType === 'Date') { + return value; + } + const dayjsValue = parseToDayjs(value as DateValue, valueType || format); + if (dayjsValue?.isValid?.()) { + return dayjsValue.toDate(); + } + return value; + } + return covertToDate(value as string, valueType); + } + + /** + * 同步派生状态 + * 根据当前值更新输入框显示值、缓存值、年月信号 + */ + private syncDerivedState(value: DateValue | undefined, props: DatePickerProps) { + const { format } = this.formatInfo; + const normalized = this.normalizeValue(value, props); + const formatted = formatDate(normalized, { format }) || ''; + + this.inputValueSignal.value = formatted; + this.cacheValueSignal.value = formatted; + + const baseDate = + (normalized && parseToDayjs(normalized, format)) || (formatted && parseToDayjs(formatted, format)) || dayjs(); + if (baseDate?.isValid?.()) { + this.yearSignal.value = baseDate.year(); + this.monthSignal.value = baseDate.month(); + } + } + + /** + * 处理弹窗visible change + * 弹窗打开时同步状态,关闭时根据输入的值是否有效来决定值的更新 + */ + private handlePopupVisibleChange = (visible: boolean, context: any) => { + if (!this.setPopupVisibleState) return; + this.setPopupVisibleState(visible, context); + if (!visible) { + this.triggerConfirmOnClose(); + } else { + this.syncDerivedState(this.valueState, this.props as DatePickerProps); + } + }; + + /** + * 弹窗关闭时确认是否存在有效的输入更新 + * 如果输入值有效且与当前值不同,则更新值 + */ + private triggerConfirmOnClose = () => { + const { format, valueType } = this.formatInfo; + const inputValue = this.inputValueSignal.value; + + if (inputValue && isValidDate(inputValue, format)) { + const currentValue = formatDate(this.valueState, { format }) || ''; + if (currentValue !== inputValue) { + const nextValue = formatDate(inputValue, { format, targetFormat: valueType }) as DateValue; + this.setValueState?.(nextValue, { + dayjsValue: parseToDayjs(inputValue, format), + trigger: 'confirm', + }); + return; + } + } + + this.inputValueSignal.value = this.cacheValueSignal.value; + }; + + /** + * 清空值并关闭弹窗 + */ + private handleClear = (context: CustomEvent) => { + const detail = context?.detail ?? {}; + this.cacheValueSignal.value = ''; + this.inputValueSignal.value = ''; + this.setValueState?.(undefined, { trigger: 'clear', dayjsValue: dayjs() }); + this.setPopupVisibleState?.(false, { trigger: 'clear', ...detail }); + this.props.onClear?.(detail); + }; + + /** + * 处理日期单元格点击 + * 更新选中值、同步年月面板、触发回调并关闭弹窗 + */ + private handleCellClick = (date: Date) => { + const { format, valueType } = this.formatInfo; + const mode = (this.props as DatePickerProps)?.mode ?? 'date'; + const formatted = formatDate(date, { format }) || ''; + const nextValue = formatDate(date, { format, targetFormat: valueType }) as DateValue; + + this.cacheValueSignal.value = formatted; + this.inputValueSignal.value = formatted; + + if (mode === 'month') { + this.yearSignal.value = date.getFullYear(); + this.monthSignal.value = date.getMonth(); + } else if (mode === 'quarter') { + this.yearSignal.value = date.getFullYear(); + this.monthSignal.value = date.getMonth(); + } else if (mode === 'year') { + this.yearSignal.value = date.getFullYear(); + } else if (mode === 'week' || mode === 'date') { + this.yearSignal.value = date.getFullYear(); + this.monthSignal.value = date.getMonth(); + } + + const dayjsValue = parseToDayjs(date, format); + this.setValueState?.(nextValue, { + dayjsValue, + trigger: 'pick', + }); + this.props.onPick?.(date, { dayjsValue, trigger: 'pick' }); + this.setPopupVisibleState?.(false, { trigger: 'pick' }); + }; + + /** + * 日期单元格mouse enter + * 更新placeholder + */ + private handleCellMouseEnter = (date: Date) => { + const { format } = this.formatInfo; + this.placeholderSignal.value = formatDate(date, { format }) || ''; + }; + + /** + * 日期单元格mouse leave + * placeholder + */ + private handleCellMouseLeave = () => { + this.placeholderSignal.value = ''; + }; + + /** + * 处理输入框内容变化 + * 验证输入格式,有效时同步更新值和年月面板 + */ + private handleInputChange = (value: string) => { + this.inputValueSignal.value = value; + + const { format } = this.formatInfo; + + if (!isValidDate(value, format)) return; + + this.cacheValueSignal.value = value; + const parsed = parseToDayjs(value, format); + const newMonth = parsed.month(); + const newYear = parsed.year(); + if (!Number.isNaN(newYear)) { + this.yearSignal.value = newYear; + } + if (!Number.isNaN(newMonth)) { + this.monthSignal.value = newMonth; + } + }; + + /** + * 处理year, month面板快速切换 + * 根据mode计算跳转的月数,更新year, month signal + */ + private handlePanelJumperClick = ({ trigger }: { trigger: 'prev' | 'next' | 'current' }) => { + const { mode = 'date' } = (this.props as DatePickerProps) ?? {}; + const monthCountMap: Record = { + date: 1, + week: 1, + month: 12, + quarter: 12, + year: 120, + }; + const monthCount = monthCountMap[mode] ?? 0; + + const current = new Date(this.yearSignal.value, this.monthSignal.value); + let target = current; + + if (trigger === 'prev') { + target = subtractMonth(current, monthCount); + } else if (trigger === 'next') { + target = addMonth(current, monthCount); + } else if (trigger === 'current') { + target = new Date(); + } + + this.yearSignal.value = target.getFullYear(); + this.monthSignal.value = target.getMonth(); + }; + + private handlePanelMonthChange = (month: number) => { + this.monthSignal.value = month; + }; + + private handlePanelYearChange = (year: number) => { + this.yearSignal.value = year; + }; + + /** + * 处理预设按钮点击 + * 解析预设值,更新选中值和面板状态 + */ + private handlePresetClick = ( + preset: DateValue | (() => DateValue), + context: { preset: PresetDate; e: MouseEvent }, + ) => { + const { format, valueType } = this.formatInfo; + const presetValue: DateValue = typeof preset === 'function' ? preset() : preset; + + const formattedPreset = formatDate(presetValue, { format, targetFormat: valueType }) as DateValue; + const formattedInput = formatDate(presetValue, { format }) || ''; + + this.inputValueSignal.value = formattedInput; + this.cacheValueSignal.value = formattedInput; + + const dayjsValue = parseToDayjs(presetValue, format); + if (dayjsValue?.isValid?.()) { + this.yearSignal.value = dayjsValue.year(); + this.monthSignal.value = dayjsValue.month(); + } + + this.setPopupVisibleState?.(false, { trigger: 'preset' }); + this.setValueState?.(formattedPreset, { + dayjsValue, + trigger: 'preset', + }); + this.props.onPresetClick?.(context); + }; + + private renderPanel(props: DatePickerProps) { + const { format } = this.formatInfo; + return ( + this.handleCellClick(value)} + onCellMouseEnter={this.handleCellMouseEnter} + onCellMouseLeave={this.handleCellMouseLeave} + onJumperClick={this.handlePanelJumperClick} + onMonthChange={this.handlePanelMonthChange} + onYearChange={this.handlePanelYearChange} + onPresetClick={this.handlePresetClick} + /> + ); + } + + render(props: OmiProps) { + const { + disabled, + status, + tips, + borderless, + label, + clearable, + placeholder, + allowInput, + popupProps, + inputProps, + tagInputProps, + prefixIcon, + suffixIcon, + } = props; + + const cls = classNames(`${this.classPrefix}-date-picker`); + const visible = props.popupVisible ?? this.popupVisibleState; + const panelVNode = this.renderPanel(props as DatePickerProps); + + const prefixIconNode = prefixIcon; + const suffixIconNode = suffixIcon ?? ; + + const displayPlaceholder = this.placeholderSignal.value || placeholder; + + return ( +
+ +
+ ); + } +} diff --git a/src/date-picker/DateRangePicker.tsx b/src/date-picker/DateRangePicker.tsx new file mode 100644 index 00000000..2cfdee3d --- /dev/null +++ b/src/date-picker/DateRangePicker.tsx @@ -0,0 +1,589 @@ +import '../range-input'; +import './panel/RangePanel'; +import 'tdesign-icons-web-components/esm/components/calendar'; + +import dayjs from 'dayjs'; +import { classNames, Component, OmiProps, signal, tag } from 'omi'; + +import { formatDate, getDefaultFormat, isValidDate, parseToDayjs } from '../_common/js/date-picker/format'; +import { addMonth, subtractMonth } from '../_common/js/date-picker/utils'; +import { getClassPrefix } from '../_util/classname'; +import { setExportparts } from '../_util/dom'; +import useControlled from '../_util/useControlled'; +import { StyledProps } from '../common'; +import { dateRangePickerDefaultProps } from './defaultProps'; +import { DateRangeValue, DateValue, PresetRange, TdDateRangePickerProps } from './type'; + +export interface DateRangePickerProps extends Omit, Omit { + style?: TdDateRangePickerProps['style']; +} + +@tag('t-date-range-picker') +export default class DateRangePicker extends Component { + static defaultProps = dateRangePickerDefaultProps; + + static propTypes = { + value: Array, + defaultValue: Array, + popupVisible: Boolean, + defaultPopupVisible: Boolean, + format: String, + mode: String, + disabled: Boolean, + presets: Object, + presetsPlacement: String, + placeholder: [String, Array], + tips: [String, Object, Function], + status: String, + clearable: Boolean, + cancelRangeSelectLimit: Boolean, + panelPreselection: Boolean, + separator: [String, Object, Function], + onChange: Function, + onPick: Function, + onClear: Function, + onVisibleChange: Function, + onPresetClick: Function, + }; + + private classPrefix = getClassPrefix(); + + private formatInfo = getDefaultFormat({ mode: 'date' }); + + private valueState: DateRangeValue = []; + + private setValueState: (value: DateRangeValue, context?: any) => void; + + private popupVisibleState = false; + + private setPopupVisibleState: (visible: boolean, context?: any) => void; + + private inputValueSignal = signal(['', '']); + + private cacheValueSignal = signal(['', '']); + + private placeholderSignal = signal(['', '']); + + private yearSignal = signal([dayjs().year(), dayjs().year()]); + + private monthSignal = signal([dayjs().month(), dayjs().month() + 1 > 11 ? 0 : dayjs().month() + 1]); + + private activeIndexSignal = signal(0); + + private isHoverCellSignal = signal(false); + + private isFirstValueSelectedSignal = signal(false); + + private hoverValueSignal = signal(['', '']); + + install() { + this.initializeControlled(this.props as DateRangePickerProps); + } + + ready() { + setExportparts(this); + } + + receiveProps(nextProps: OmiProps) { + this.initializeControlled(nextProps as DateRangePickerProps); + } + + /** + * 初始化受控状态,设置值和弹窗的受控逻辑 + */ + private initializeControlled(props: DateRangePickerProps) { + this.setupValueControl(props); + this.setupPopupControl(props); + this.formatInfo = getDefaultFormat({ + mode: props.mode, + format: props.format, + valueType: props.valueType, + enableTimePicker: false, + }); + this.syncDerivedState(this.valueState); + } + + /** + * 设置值的受控逻辑 + */ + private setupValueControl(props: DateRangePickerProps) { + const [value, setValue] = useControlled(props, 'value', (val, context) => props.onChange?.(val, context), { + defaultValue: props.defaultValue, + activeComponent: this, + }); + this.valueState = (value as DateRangeValue) || []; + this.setValueState = (nextValue, context) => { + setValue(nextValue, context); + this.valueState = nextValue; + this.syncDerivedState(nextValue); + }; + } + + /** + * 设置弹窗的受控逻辑 + */ + private setupPopupControl(props: DateRangePickerProps) { + const [visible, setVisible] = useControlled( + props, + 'popupVisible', + (val, context) => props.onVisibleChange?.(val, context), + { + defaultPopupVisible: props.defaultPopupVisible, + activeComponent: this, + }, + ); + this.popupVisibleState = Boolean(visible); + this.setPopupVisibleState = (nextVisible, context) => { + setVisible(nextVisible, context); + this.popupVisibleState = nextVisible; + }; + } + + /** + * 同步派生状态 + * 根据当前值更新输入框、缓存值、年月面板 + */ + private syncDerivedState(value: DateRangeValue | undefined) { + const { format } = this.formatInfo; + const formatted = formatDate(value || [], { format }) as string[]; + + this.inputValueSignal.value = formatted; + this.cacheValueSignal.value = formatted; + + if (formatted.length === 2 && formatted[0] && formatted[1]) { + const startDate = parseToDayjs(formatted[0], format); + const endDate = parseToDayjs(formatted[1], format); + if (startDate?.isValid?.() && endDate?.isValid?.()) { + const startYear = startDate.year(); + let startMonth = startDate.month(); + let endYear = endDate.year(); + let endMonth = endDate.month(); + + // 两个面板显示不同月份 + if (startYear === endYear && startMonth === endMonth) { + if (startMonth === 11) { + startMonth -= 1; + } else { + endMonth += 1; + if (endMonth > 11) { + endMonth = 0; + endYear += 1; + } + } + } + + this.yearSignal.value = [startYear, endYear]; + this.monthSignal.value = [startMonth, endMonth]; + } + } else { + // 空值时设置默认的年月 + const now = dayjs(); + let endMonth = now.month() + 1; + let endYear = now.year(); + if (endMonth > 11) { + endMonth = 0; + endYear += 1; + } + this.yearSignal.value = [now.year(), endYear]; + this.monthSignal.value = [now.month(), endMonth]; + } + } + + /** + * 处理弹窗显隐变化 + * 打开时重置选择状态,关闭时恢复缓存值并清空placeholder + */ + private handlePopupVisibleChange = (visible: boolean, context: any) => { + if (!this.setPopupVisibleState) return; + this.setPopupVisibleState(visible, context); + + if (visible) { + // 面板展开时重置状态 + this.activeIndexSignal.value = 0; + this.isHoverCellSignal.value = false; + this.isFirstValueSelectedSignal.value = false; + this.syncDerivedState(this.valueState); + } else { + // 面板关闭时恢复缓存值并清空预览 + this.inputValueSignal.value = this.cacheValueSignal.value; + this.hoverValueSignal.value = ['', '']; + this.placeholderSignal.value = ['', '']; + } + }; + + /** + * 处理清空按钮点击 + * 清空所有值并关闭弹窗 + */ + private handleClear = (context?: { e?: any }) => { + const detail = context ?? {}; + this.cacheValueSignal.value = ['', '']; + this.inputValueSignal.value = ['', '']; + this.hoverValueSignal.value = ['', '']; + this.setValueState?.([], { trigger: 'clear', dayjsValue: [dayjs(), dayjs()] }); + this.setPopupVisibleState?.(false, { trigger: 'clear', ...detail }); + this.props.onClear?.(detail); + }; + + /** + * 日期范围选择逻辑 + * 首次点击:设置一端日期,清空另一端,切换到另一端的选择 + * 第二次点击:设置另一端日期,自动交换顺序 + */ + private handleCellClick = (date: Date, { partial }: { e: MouseEvent; partial: 'start' | 'end' }) => { + const { format, valueType } = this.formatInfo; + const activeIndex = this.activeIndexSignal.value; + const isFirstClick = !this.isFirstValueSelectedSignal.value; + + this.isHoverCellSignal.value = false; + + const nextValue = [...this.inputValueSignal.value]; + nextValue[activeIndex] = formatDate(date, { format }) || ''; + + // 如果是popup打开后的第一次点击,清空另一端的值,开始新的选择流程 + if (isFirstClick) { + nextValue[activeIndex ? 0 : 1] = ''; + } + + this.cacheValueSignal.value = nextValue; + this.inputValueSignal.value = nextValue; + this.hoverValueSignal.value = ['', '']; + + // 检查是否都是有效值 + const notValidIndex = nextValue.findIndex((v) => !v || !isValidDate(v, format)); + + // 当两端都有有效值时更改value只有在第二次点击后才会满足) + if (notValidIndex === -1 && nextValue.length === 2) { + // 检查是否需要交换顺序 + if (parseToDayjs(nextValue[0], format).isAfter(parseToDayjs(nextValue[1], format))) { + const formattedValue = formatDate([nextValue[1], nextValue[0]], { format, targetFormat: valueType }); + this.setValueState?.(formattedValue as DateRangeValue, { + dayjsValue: [parseToDayjs(nextValue[1], format), parseToDayjs(nextValue[0], format)], + trigger: 'pick', + }); + } else { + const formattedValue = formatDate(nextValue, { format, targetFormat: valueType }); + this.setValueState?.(formattedValue as DateRangeValue, { + dayjsValue: nextValue.map((v) => parseToDayjs(v, format)), + trigger: 'pick', + }); + } + } + + const dayjsValue = parseToDayjs(date, format); + this.props.onPick?.(date, { dayjsValue, trigger: 'pick', partial }); + + // 首次点击不关闭,第二次点击(两端都有值)后关闭 + if (isFirstClick) { + // 第一次点击:切换到另一端,标记为已选择第一个值 + this.activeIndexSignal.value = activeIndex ? 0 : 1; + this.isFirstValueSelectedSignal.value = true; + } else if (notValidIndex === -1) { + // 第二次点击且两端都有值则关闭 + this.setPopupVisibleState?.(false, { trigger: 'pick' }); + } + + this.update(); + }; + + /** + * 日期单元格mouse enter + * 更新placeholder + */ + private handleCellMouseEnter = (date: Date) => { + const { format } = this.formatInfo; + this.isHoverCellSignal.value = true; + + const nextValue = [...this.placeholderSignal.value]; + nextValue[this.activeIndexSignal.value] = formatDate(date, { format }) || ''; + this.placeholderSignal.value = nextValue; + + // 面板高亮 + const hoverValue = [...this.inputValueSignal.value]; + hoverValue[this.activeIndexSignal.value] = formatDate(date, { format }) || ''; + this.hoverValueSignal.value = hoverValue; + }; + + /** + * 日期单元格鼠标mouse leave + * 清空placeholder + */ + private handleCellMouseLeave = () => { + this.isHoverCellSignal.value = false; + this.hoverValueSignal.value = ['', '']; + this.placeholderSignal.value = ['', '']; + }; + + /** + * 确保左侧面板的年月不大于右侧面板 + */ + private dateCorrection(partialIndex: number, nextYear: number[], nextMonth: number[], onlyYearSelect: boolean) { + if (nextYear[0] > nextYear[1]) { + if (partialIndex === 0) { + [nextYear[1]] = [nextYear[0]]; + } else { + [nextYear[0]] = [nextYear[1]]; + } + } + + if (onlyYearSelect) { + return { nextYear, nextMonth }; + } + + if (nextYear[0] === nextYear[1] && nextMonth[0] >= nextMonth[1]) { + if (partialIndex === 0) { + nextMonth[1] = nextMonth[0] + 1; + if (nextMonth[1] > 11) { + nextMonth[1] = 0; + nextYear[1] += 1; + } + } else { + nextMonth[0] = nextMonth[1] - 1; + if (nextMonth[0] < 0) { + nextMonth[0] = 11; + nextYear[0] -= 1; + } + } + } + + return { nextYear, nextMonth }; + } + + /** + * 处理年月面板快速切换(上一页/下一页/今天) + * 根据mode计算跳转月数,进行日期纠正 + */ + private handlePanelJumperClick = ({ + trigger, + partial, + }: { + trigger: 'prev' | 'next' | 'current'; + partial: 'start' | 'end'; + }) => { + const { mode = 'date' } = (this.props as DateRangePickerProps) ?? {}; + const partialIndex = partial === 'start' ? 0 : 1; + const monthCountMap: Record = { + date: 1, + week: 1, + month: 12, + quarter: 12, + year: 120, + }; + const monthCount = monthCountMap[mode] ?? 0; + + const current = new Date(this.yearSignal.value[partialIndex], this.monthSignal.value[partialIndex]); + let target = current; + + if (trigger === 'prev') { + target = subtractMonth(current, monthCount); + } else if (trigger === 'next') { + target = addMonth(current, monthCount); + } else if (trigger === 'current') { + target = new Date(); + } + + const nextYear = [...this.yearSignal.value]; + const nextMonth = [...this.monthSignal.value]; + nextYear[partialIndex] = target.getFullYear(); + nextMonth[partialIndex] = target.getMonth(); + + const onlyYearSelect = ['year', 'quarter', 'month'].includes(mode); + const corrected = this.dateCorrection(partialIndex, nextYear, nextMonth, onlyYearSelect); + + this.yearSignal.value = corrected.nextYear; + this.monthSignal.value = corrected.nextMonth; + this.update(); + }; + + /** 处理月份选择器变化,进行日期纠正 */ + private handlePanelMonthChange = (month: number, { partial }: { partial: 'start' | 'end' }) => { + const partialIndex = partial === 'start' ? 0 : 1; + const nextMonth = [...this.monthSignal.value]; + nextMonth[partialIndex] = month; + + // 保证左侧时间不大于右侧 + if (this.yearSignal.value[0] === this.yearSignal.value[1]) { + if (partialIndex === 0 && nextMonth[1] <= nextMonth[0]) { + nextMonth[1] = nextMonth[0] + 1; + if (nextMonth[1] > 11) { + nextMonth[1] = 0; + this.yearSignal.value = [this.yearSignal.value[0], this.yearSignal.value[1] + 1]; + } + } + if (partialIndex === 1 && nextMonth[0] >= nextMonth[1]) { + nextMonth[0] = nextMonth[1] - 1; + if (nextMonth[0] < 0) { + nextMonth[0] = 11; + this.yearSignal.value = [this.yearSignal.value[0] - 1, this.yearSignal.value[1]]; + } + } + } + + this.monthSignal.value = nextMonth; + this.update(); + }; + + /** 处理年份选择器变化,进行日期纠正 */ + private handlePanelYearChange = (year: number, { partial }: { partial: 'start' | 'end' }) => { + const { mode = 'date' } = (this.props as DateRangePickerProps) ?? {}; + const partialIndex = partial === 'start' ? 0 : 1; + const nextYear = [...this.yearSignal.value]; + const nextMonth = [...this.monthSignal.value]; + nextYear[partialIndex] = year; + + const onlyYearSelect = ['year', 'quarter', 'month'].includes(mode); + const corrected = this.dateCorrection(partialIndex, nextYear, nextMonth, onlyYearSelect); + + this.yearSignal.value = corrected.nextYear; + if (!onlyYearSelect) { + this.monthSignal.value = corrected.nextMonth; + } + this.update(); + }; + + /** + * 处理预设按钮点击 + * 解析预设值数组,更新选中值和面板状态 + */ + private handlePresetClick = ( + preset: DateValue[] | (() => DateValue[]), + context: { preset: PresetRange; e: MouseEvent }, + ) => { + const { format, valueType } = this.formatInfo; + let presetValue = preset; + if (typeof preset === 'function') { + presetValue = preset(); + } + + if (!Array.isArray(presetValue)) { + console.error('DateRangePicker', `preset: ${preset} must be Array!`); + return; + } + + const formattedPreset = formatDate(presetValue, { format, targetFormat: valueType }) as string[]; + this.inputValueSignal.value = formattedPreset; + this.cacheValueSignal.value = formattedPreset; + + this.isFirstValueSelectedSignal.value = true; + + // 同步面板 + if (formattedPreset.length === 2 && formattedPreset[0] && formattedPreset[1]) { + const nextMonth = formattedPreset.map((v: string) => parseToDayjs(v, format).month()); + const nextYear = formattedPreset.map((v: string) => parseToDayjs(v, format).year()); + if (nextYear[0] === nextYear[1] && nextMonth[0] === nextMonth[1]) { + nextMonth[0] === 11 ? (nextMonth[0] -= 1) : (nextMonth[1] += 1); + } + this.monthSignal.value = nextMonth; + this.yearSignal.value = nextYear; + } + + this.setPopupVisibleState?.(false, { trigger: 'preset' }); + this.setValueState?.(formattedPreset as DateRangeValue, { + dayjsValue: formattedPreset.map((p) => parseToDayjs(p, format)), + trigger: 'preset', + }); + this.props.onPresetClick?.(context); + }; + + /** 处理范围输入框变化,检测清空操作 */ + private handleRangeInputChange = (nextValue: string[], context?: { trigger?: string }) => { + // 检测清空操作 + if (context?.trigger === 'clear') { + this.handleClear({ e: context }); + return; + } + if (Array.isArray(nextValue)) { + this.inputValueSignal.value = nextValue; + } + }; + + private renderPanel(props: DateRangePickerProps) { + const { format } = this.formatInfo; + const isHoverCell = this.isHoverCellSignal.value; + + return ( + + ); + } + + render(props: OmiProps) { + const { + disabled, + status, + tips, + label, + clearable, + placeholder, + separator, + popupProps, + rangeInputProps, + prefixIcon, + suffixIcon, + } = props; + + const cls = classNames(`${this.classPrefix}-date-range-picker`); + const visible = props.popupVisible ?? this.popupVisibleState; + const panelVNode = this.renderPanel(props as DateRangePickerProps); + + const suffixIconNode = suffixIcon ?? ; + + const placeholderArr = Array.isArray(placeholder) + ? placeholder + : [placeholder || '开始日期', placeholder || '结束日期']; + + const displayPlaceholder = [ + this.placeholderSignal.value[0] || placeholderArr[0], + this.placeholderSignal.value[1] || placeholderArr[1], + ]; + + return ( +
+ +
+ ); + } +} diff --git a/src/date-picker/README.md b/src/date-picker/README.md new file mode 100644 index 00000000..5f0211e9 --- /dev/null +++ b/src/date-picker/README.md @@ -0,0 +1,128 @@ +--- +title: DatePicker 日期选择器 +description: 用于选择某一具体日期或某一段日期区间。 +isComponent: true +usage: { title: '', description: '' } +spline: base +--- + +### 基础示例 + +{{ base }} + +### 周选择器 + +{{ week }} + +### 月份选择器 + +用于月份的选择。用户仅需输入月份信息时使用。 + +{{ month }} + +### 年份选择器 + +用于年份的选择。用户仅需输入年份信息时使用,常用于如年账单等按年记录数据的查询场景。 + +{{ year }} + +### 季度选择器 + +用于季度的选择。用户仅需输入季度信息时使用。 + +{{ quarter }} + +### 带快捷标签的日期选择器 + +具有可提前设置的时间标签。当日期信息具有规律性,需要点击标签快捷输入时。 + +{{ presets }} + +### 可禁用日期的选择器 + +可将不支持用户选择的日期禁止点击。 + +{{ limit }} + +### 可输入日期的选择器 + +可输入指定的日期,会自动判断日期是否合法。 + +{{ input }} + + +## API + +### DatePicker Props + +名称 | 类型 | 默认值 | 说明 | 必传 +-- | -- | -- | -- | -- +className | String | - | 类名 | N +style | Object | - | 样式 | N +value | String / Number / Date | - | 选中值。TS 类型:`DateValue`。`type DateValue = string \| number \| Date` | N +defaultValue | String / Number / Date | - | 选中值,非受控属性。TS 类型:`DateValue` | N +format | String | 'YYYY-MM-DD' | 用于格式化日期显示的格式 | N +valueType | String | - | 用于格式化日期值的类型,对比 format 只用于展示 | N +mode | String | 'date' | 选择器模式。可选项:date/month/year/week/quarter | N +firstDayOfWeek | Number | 1 | 一周的起始天(0-6) | N +disableDate | Object / Array / Function | - | 禁用日期。TS 类型:`DisableDate`。`type DisableDate = DateValue[] \| DisableDateObject \| ((date: DateValue) => boolean)` | N +minDate | String / Number / Date | - | 最小可选日期 | N +maxDate | String / Number / Date | - | 最大可选日期 | N +presets | Object | - | 预设快捷日期选择。TS 类型:`PresetDate`。`interface PresetDate { [name: string]: DateValue \| (() => DateValue) }` | N +presetsPlacement | String | 'bottom' | 预设面板展示区域。可选项:left/top/right/bottom | N +placeholder | String | - | 占位符 | N +tips | TNode | - | 输入框下方提示 | N +status | String | 'default' | 输入框状态。可选项:default/success/warning/error | N +borderless | Boolean | false | 是否无边框 | N +disabled | Boolean | false | 是否禁用组件 | N +clearable | Boolean | false | 是否显示清除按钮 | N +allowInput | Boolean | false | 是否允许输入日期 | N +label | TNode | - | 左侧文本内容 | N +prefixIcon | TNode | - | 自定义前缀图标 | N +suffixIcon | TNode | - | 自定义后缀图标 | N +popupVisible | Boolean | - | 设置面板是否可见(受控) | N +defaultPopupVisible | Boolean | false | 默认面板显示状态(非受控) | N +inputProps | Object | - | 透传给输入框组件的属性 | N +popupProps | Object | - | 透传给 popup 组件的参数 | N +onChange | Function | - | 选中值变化时触发。`(value: DateValue, context?: any) => void` | N +onVisibleChange | Function | - | 面板显示/隐藏切换时触发。`(visible: boolean, context?: any) => void` | N +onPick | Function | - | 用户选择日期时触发。`(value: Date, context?: any) => void` | N +onPresetClick | Function | - | 点击预设按钮后触发。`(context?: any) => void` | N +onClear | Function | - | 点击清除按钮时触发。`(context?: any) => void` | N + +### DateRangePicker Props + +名称 | 类型 | 默认值 | 说明 | 必传 +-- | -- | -- | -- | -- +className | String | - | 类名 | N +style | Object | - | 样式 | N +value | Array | - | 选中值。TS 类型:`DateRangeValue`。`type DateRangeValue = Array` | N +defaultValue | Array | - | 选中值,非受控属性。TS 类型:`DateRangeValue` | N +format | String | 'YYYY-MM-DD' | 用于格式化日期显示的格式 | N +valueType | String | - | 用于格式化日期值的类型,对比 format 只用于展示 | N +mode | String | 'date' | 选择器模式。可选项:date/month/year/week/quarter | N +firstDayOfWeek | Number | 1 | 一周的起始天(0-6) | N +disableDate | Object / Array / Function | - | 禁用日期。TS 类型:`DisableDate` | N +cancelRangeSelectLimit | Boolean | false | 是否允许取消选中范围选择限制,设置为 true 将不再限制结束日期必须大于开始日期 | N +panelPreselection | Boolean | true | 是否在选中日期时预选高亮 | N +presets | Object | - | 预设快捷日期选择。TS 类型:`PresetRange`。`interface PresetRange { [name: string]: DateRangeValue \| (() => DateRangeValue) }` | N +presetsPlacement | String | 'bottom' | 预设面板展示区域。可选项:left/top/right/bottom | N +placeholder | String / Array | - | 占位符。TS 类型:`string \| string[]` | N +tips | TNode | - | 输入框下方提示 | N +status | String | 'default' | 输入框状态。可选项:default/success/warning/error | N +disabled | Boolean | false | 是否禁用组件 | N +clearable | Boolean | false | 是否显示清除按钮 | N +allowInput | Boolean | false | 是否允许输入日期 | N +label | TNode | - | 左侧文本内容 | N +separator | TNode | '-' | 范围分隔符 | N +prefixIcon | TNode | - | 自定义前缀图标 | N +suffixIcon | TNode | - | 自定义后缀图标 | N +popupVisible | Boolean | - | 设置面板是否可见(受控) | N +defaultPopupVisible | Boolean | false | 默认面板显示状态(非受控) | N +rangeInputProps | Object | - | 透传给输入框组件的属性 | N +popupProps | Object | - | 透传给 popup 组件的参数 | N +onChange | Function | - | 选中值变化时触发。`(value: DateRangeValue, context?: any) => void` | N +onVisibleChange | Function | - | 面板显示/隐藏切换时触发。`(visible: boolean, context?: any) => void` | N +onPick | Function | - | 用户选择日期时触发。`(value: Date, context?: any) => void` | N +onPresetClick | Function | - | 点击预设按钮后触发。`(context?: any) => void` | N +onClear | Function | - | 点击清除按钮时触发。`(context?: any) => void` | N \ No newline at end of file diff --git a/src/date-picker/_example/base.tsx b/src/date-picker/_example/base.tsx new file mode 100644 index 00000000..23dd9143 --- /dev/null +++ b/src/date-picker/_example/base.tsx @@ -0,0 +1,30 @@ +import 'tdesign-web-components/date-picker'; +import 'tdesign-web-components/space'; + +import { Component } from 'omi'; + +export default class DatePickerBaseDemo extends Component { + state = { + dateValue: '', + rangeValue: ['', ''], + }; + + handleDateChange = (value: string) => { + this.state.dateValue = value; + this.update(); + }; + + handleRangeChange = (value: string[]) => { + this.state.rangeValue = value; + this.update(); + }; + + render() { + return ( + + + + + ); + } +} diff --git a/src/date-picker/_example/input.tsx b/src/date-picker/_example/input.tsx new file mode 100644 index 00000000..2f75bb05 --- /dev/null +++ b/src/date-picker/_example/input.tsx @@ -0,0 +1,18 @@ +import 'tdesign-web-components/date-picker'; + +import { Component } from 'omi'; + +export default class DatePickerBaseDemo extends Component { + state = { + dateValue: '', + }; + + handleDateChange = (value: string) => { + this.state.dateValue = value; + this.update(); + }; + + render() { + return ; + } +} diff --git a/src/date-picker/_example/limit.tsx b/src/date-picker/_example/limit.tsx new file mode 100644 index 00000000..07c93e03 --- /dev/null +++ b/src/date-picker/_example/limit.tsx @@ -0,0 +1,41 @@ +import 'tdesign-web-components/date-picker'; +import 'tdesign-web-components/space'; + +import dayjs from 'dayjs'; +import { Component } from 'omi'; + +const minDate = dayjs().subtract(7, 'day'); +const maxDate = dayjs().add(14, 'day'); + +const disableDate = (value: string | number | Date) => { + const current = dayjs(value); + if (!current.isValid()) return false; + const outOfRange = current.isBefore(minDate, 'day') || current.isAfter(maxDate, 'day'); + const isWeekend = current.day() === 0 || current.day() === 6; + return outOfRange || isWeekend; +}; + +export default class DatePickerLimitDemo extends Component { + state = { + rangeValue: ['', ''], + }; + + handleRangeChange = (value: string[]) => { + this.state.rangeValue = value; + this.update(); + }; + + render() { + return ( + +
示例:周末与范围外日期不可选
+ +
+ ); + } +} diff --git a/src/date-picker/_example/month.tsx b/src/date-picker/_example/month.tsx new file mode 100644 index 00000000..0884a3d6 --- /dev/null +++ b/src/date-picker/_example/month.tsx @@ -0,0 +1,35 @@ +import 'tdesign-web-components/date-picker'; +import 'tdesign-web-components/space'; + +import { Component } from 'omi'; + +export default class DatePickerMonthDemo extends Component { + state = { + value: '', + rangeValue: ['', ''], + }; + + handleChange = (value: string) => { + this.state.value = value; + this.update(); + }; + + handleRangeChange = (value: string[]) => { + this.state.rangeValue = value; + this.update(); + }; + + render() { + return ( + + + + + ); + } +} diff --git a/src/date-picker/_example/presets.tsx b/src/date-picker/_example/presets.tsx new file mode 100644 index 00000000..d8a6604b --- /dev/null +++ b/src/date-picker/_example/presets.tsx @@ -0,0 +1,67 @@ +import 'tdesign-web-components/date-picker'; +import 'tdesign-web-components/space'; + +import dayjs from 'dayjs'; +import { Component } from 'omi'; + +const formatDate = (value: dayjs.Dayjs) => value.format('YYYY-MM-DD'); +const today = dayjs(); + +const datePresets = { + 今天: formatDate(dayjs()), + 明天: () => formatDate(dayjs().add(1, 'day')), + 上周五: () => formatDate(dayjs().subtract(1, 'week').day(5)), +}; + +const dateRangePresets = { + 今天: [formatDate(today), formatDate(today)], + 昨天: [formatDate(today.subtract(1, 'day')), formatDate(dayjs())], + 近7天: () => [formatDate(dayjs().subtract(6, 'day')), formatDate(dayjs())], + 本月: () => [formatDate(dayjs().startOf('month')), formatDate(dayjs().endOf('month'))], +}; + +export default class DatePickerPresetsDemo extends Component { + state = { + value1: formatDate(dayjs()), + value2: [formatDate(dayjs().subtract(6, 'day')), formatDate(dayjs())], + value3: ['', ''], + }; + + handleChange1 = (value: string) => { + this.state.value1 = value; + this.update(); + }; + + handleChange2 = (value: string[]) => { + this.state.value2 = value; + this.update(); + }; + + handleChange3 = (value: string[]) => { + this.state.value3 = value; + this.update(); + }; + + render() { + return ( + + + + + 自定义连缀符号和预设面板的位置 + + + ); + } +} diff --git a/src/date-picker/_example/quarter.tsx b/src/date-picker/_example/quarter.tsx new file mode 100644 index 00000000..cfcceab2 --- /dev/null +++ b/src/date-picker/_example/quarter.tsx @@ -0,0 +1,35 @@ +import 'tdesign-web-components/date-picker'; +import 'tdesign-web-components/space'; + +import { Component } from 'omi'; + +export default class DatePickerQuarterDemo extends Component { + state = { + value: '', + rangeValue: ['', ''], + }; + + handleChange = (value: string) => { + this.state.value = value; + this.update(); + }; + + handleRangeChange = (value: string[]) => { + this.state.rangeValue = value; + this.update(); + }; + + render() { + return ( + + + + + ); + } +} diff --git a/src/date-picker/_example/week.tsx b/src/date-picker/_example/week.tsx new file mode 100644 index 00000000..c97a5ae7 --- /dev/null +++ b/src/date-picker/_example/week.tsx @@ -0,0 +1,35 @@ +import 'tdesign-web-components/date-picker'; +import 'tdesign-web-components/space'; + +import { Component } from 'omi'; + +export default class DatePickerWeekDemo extends Component { + state = { + value: '', + rangeValue: ['', ''], + }; + + handleChange = (value: string) => { + this.state.value = value; + this.update(); + }; + + handleRangeChange = (value: string[]) => { + this.state.rangeValue = value; + this.update(); + }; + + render() { + return ( + + + + + ); + } +} diff --git a/src/date-picker/_example/year.tsx b/src/date-picker/_example/year.tsx new file mode 100644 index 00000000..94e6e41a --- /dev/null +++ b/src/date-picker/_example/year.tsx @@ -0,0 +1,35 @@ +import 'tdesign-web-components/date-picker'; +import 'tdesign-web-components/space'; + +import { Component } from 'omi'; + +export default class DatePickerYearDemo extends Component { + state = { + value: '', + rangeValue: ['', ''], + }; + + handleChange = (value: string) => { + this.state.value = value; + this.update(); + }; + + handleRangeChange = (value: string[]) => { + this.state.rangeValue = value; + this.update(); + }; + + render() { + return ( + + + + + ); + } +} diff --git a/src/date-picker/defaultProps.ts b/src/date-picker/defaultProps.ts new file mode 100644 index 00000000..2fda46c1 --- /dev/null +++ b/src/date-picker/defaultProps.ts @@ -0,0 +1,18 @@ +import { DatePickerProps } from './DatePicker'; +import { DateRangePickerProps } from './DateRangePicker'; + +export const datePickerDefaultProps: Partial = { + mode: 'date', + allowInput: false, + clearable: false, + firstDayOfWeek: 1, + placeholder: '请选择日期', +}; + +export const dateRangePickerDefaultProps: Partial = { + mode: 'date', + allowInput: false, + clearable: false, + firstDayOfWeek: 1, + placeholder: ['请选择日期', '请选择日期'], +}; diff --git a/src/date-picker/index.ts b/src/date-picker/index.ts new file mode 100644 index 00000000..7f16e131 --- /dev/null +++ b/src/date-picker/index.ts @@ -0,0 +1,14 @@ +import './style/index.js'; + +import _DatePicker from './DatePicker'; +import _DateRangePicker from './DateRangePicker'; + +export type { DatePickerProps } from './DatePicker'; +export type { DateRangePickerProps } from './DateRangePicker'; + +export const DatePicker = _DatePicker; +export const DateRangePicker = _DateRangePicker; + +export default DatePicker; + +export * from './type'; diff --git a/src/date-picker/panel/RangePanel.tsx b/src/date-picker/panel/RangePanel.tsx new file mode 100644 index 00000000..b6cc7bd3 --- /dev/null +++ b/src/date-picker/panel/RangePanel.tsx @@ -0,0 +1,979 @@ +import 'tdesign-icons-web-components/esm/components/chevron-down'; +import 'tdesign-icons-web-components/esm/components/chevron-left'; +import 'tdesign-icons-web-components/esm/components/chevron-right'; +import 'tdesign-web-components/select-input'; +import 'dayjs/locale/zh-cn'; + +import classNames from 'classnames'; +import dayjs from 'dayjs'; +import isoWeek from 'dayjs/plugin/isoWeek'; +import { Component, createRef, OmiProps, tag } from 'omi'; + +import { parseToDayjs } from '../../_common/js/date-picker/format'; +import { + extractTimeObj, + flagActive, + getMonths, + getQuarters, + getWeeks, + getYears, + isSame, +} from '../../_common/js/date-picker/utils'; +import { getClassPrefix } from '../../_util/classname'; +import { PaginationMini } from '../../pagination/PaginationMini'; +import { TdPaginationMiniProps } from '../../pagination/type'; +import { DateValue, DisableDate, PresetRange, TdDateRangePickerProps } from '../type'; + +dayjs.locale('zh-cn'); +dayjs.extend(isoWeek); + +const SELECT_WIDTH = '80px'; +const LONG_SELECT_WIDTH = '130px'; + +interface YearOption { + label: string; + value: number; +} + +type ScrollAnchor = 'default' | 'top' | 'bottom'; + +interface WeekInfo { + year: number; + week: number; +} + +export interface DatePickerTableCell { + text: string | number; + value: Date; + time?: string; + active?: boolean; + highlight?: boolean; + hoverHighlight?: boolean; + disabled?: boolean; + additional?: boolean; + now?: boolean; + firstDayOfMonth?: boolean; + lastDayOfMonth?: boolean; + weekOfYear?: boolean; + startOfRange?: boolean; + endOfRange?: boolean; + hoverStartOfRange?: boolean; + hoverEndOfRange?: boolean; + dayjsObj?: any; +} + +export interface RangePanelProps + extends Pick< + TdDateRangePickerProps, + 'mode' | 'firstDayOfWeek' | 'disableDate' | 'presets' | 'presetsPlacement' | 'panelPreselection' + > { + format: string; + value?: string[]; + hoverValue?: string[]; + year: number[]; + month: number[]; + activeIndex?: number; + isFirstValueSelected?: boolean; + cancelRangeSelectLimit?: boolean; + onCellClick?: (value: Date, context: { e: MouseEvent; partial: 'start' | 'end' }) => void; + onCellMouseEnter?: (value: Date, context: { partial: 'start' | 'end' }) => void; + onCellMouseLeave?: (context: { e: MouseEvent }) => void; + onJumperClick?: (context: { trigger: 'prev' | 'next' | 'current'; partial: 'start' | 'end' }) => void; + onMonthChange?: (month: number, context: { partial: 'start' | 'end' }) => void; + onYearChange?: (year: number, context: { partial: 'start' | 'end' }) => void; + onPresetClick?: (preset: DateValue[] | (() => DateValue[]), context: { preset: PresetRange; e: MouseEvent }) => void; + class?: string; + style?: Record | string; +} + +@tag('t-date-range-picker-panel') +export default class RangePanel extends Component { + static defaultProps: Partial = { + mode: 'date', + firstDayOfWeek: 1, + panelPreselection: true, + presetsPlacement: 'bottom', + }; + + private classPrefix = getClassPrefix(); + + private startMonthPopupVisible = false; + + private startYearPopupVisible = false; + + private endMonthPopupVisible = false; + + private endYearPopupVisible = false; + + private startYearOptions: YearOption[] = []; + + private endYearOptions: YearOption[] = []; + + private scrollAnchor: ScrollAnchor = 'default'; + + private startYearPanelRef = createRef(); + + private endYearPanelRef = createRef(); + + private cachedPopupAttach: HTMLElement | null = null; + + install() { + const { year = [dayjs().year(), dayjs().year()], mode = 'date' } = this.props; + this.startYearOptions = this.initYearOptions(year[0], mode); + this.endYearOptions = this.initYearOptions(year[1], mode); + } + + receiveProps(nextProps: RangePanelProps, oldProps: RangePanelProps): void { + const nextYear = nextProps.year ?? [dayjs().year(), dayjs().year()]; + const prevYear = oldProps?.year ?? [dayjs().year(), dayjs().year()]; + if (nextYear[0] !== prevYear[0] || nextProps.mode !== oldProps.mode) { + this.startYearOptions = this.initYearOptions(nextYear[0], nextProps.mode ?? 'date'); + } + if (nextYear[1] !== prevYear[1] || nextProps.mode !== oldProps.mode) { + this.endYearOptions = this.initYearOptions(nextYear[1], nextProps.mode ?? 'date'); + } + } + + private convertToDate(value?: DateValue, format?: string) { + if (value === null || value === undefined || value === '') return null; + if (value instanceof Date) return value; + const parsed = parseToDayjs(value, format); + if (!parsed || !parsed.isValid()) return null; + return parsed.toDate(); + } + + private resolveDisableDate(disableDate: DisableDate | undefined, format: string, mode: string) { + if (!disableDate) return undefined; + if (typeof disableDate === 'function') { + return (date: Date) => (disableDate as (d: DateValue) => boolean)(date); + } + if (Array.isArray(disableDate)) { + const cached = disableDate.map((item) => this.convertToDate(item, format)).filter(Boolean) as Date[]; + return (date: Date) => cached.some((item) => isSame(item, date, mode)); + } + const { before, after, from, to } = disableDate; + const beforeDate = this.convertToDate(before, format); + const afterDate = this.convertToDate(after, format); + const fromDate = this.convertToDate(from, format); + const toDate = this.convertToDate(to, format); + + return (date: Date) => { + if (beforeDate && dayjs(date).isBefore(beforeDate, 'day')) return true; + if (afterDate && dayjs(date).isAfter(afterDate, 'day')) return true; + if (fromDate && dayjs(date).isBefore(fromDate, 'day')) return true; + if (toDate && dayjs(date).isAfter(toDate, 'day')) return true; + return false; + }; + } + + private buildWeekdays(mode: RangePanelProps['mode'], firstDayOfWeek: number) { + if (mode !== 'date' && mode !== 'week') return []; + const localeData = dayjs.localeData(); + const weekdays = localeData.weekdaysMin(); + const start = ((firstDayOfWeek % 7) + 7) % 7; + const result: string[] = []; + let index = start; + for (let i = 0; i < 7; i += 1) { + result.push(weekdays[index]); + index = (index + 1) % 7; + } + if (mode === 'week') { + const locale = dayjs.locale() || 'zh-cn'; + const weekLabel = locale.startsWith('zh') ? '周' : 'Week'; + result.unshift(weekLabel); + } + return result; + } + + private getQuarterLabels(): string[] { + const locale = dayjs.locale(); + if (locale && locale.startsWith('zh')) { + return ['一季度', '二季度', '三季度', '四季度']; + } + return ['Q1', 'Q2', 'Q3', 'Q4']; + } + + /** + * 根据mode生成对应的日期/周/月/季度/年数据,并标记选中、悬浮高亮、范围等状态 + */ + private buildTableData( + props: RangePanelProps, + year: number, + month: number, + partial: 'start' | 'end', + ): DatePickerTableCell[][] { + const { + format, + mode = 'date', + firstDayOfWeek = 1, + disableDate, + value = [], + hoverValue = [], + panelPreselection, + cancelRangeSelectLimit, + } = props; + + const disableHandler = this.resolveDisableDate(disableDate, format, mode) || (() => false); + + const dayjsLocale = dayjs.locale(); + const localeData = dayjs.localeData(); + const monthLocal = (localeData.monthsShort && localeData.monthsShort()) || localeData.months(); + const quarterLocal = this.getQuarterLabels(); + + const options = { + firstDayOfWeek, + disableDate: disableHandler, + // 一个日期对象相对于该纪元,最多可以表示±8,640,000,000,000毫秒 + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date + minDate: new Date(-8.64e15), + maxDate: new Date(8.64e15), + monthLocal, + quarterLocal, + dayjsLocale, + cancelRangeSelectLimit: Boolean(cancelRangeSelectLimit), + showWeekOfYear: mode === 'week', + }; + + let tableSource: any[][] = []; + + if (mode === 'date' || mode === 'week') { + tableSource = getWeeks({ year, month }, options); + } else if (mode === 'month') { + tableSource = getMonths(year, options); + } else if (mode === 'quarter') { + tableSource = getQuarters(year, options); + } else if (mode === 'year') { + const displayYear = partial === 'end' && props.year[1] - props.year[0] <= 9 ? year + 9 : year; + tableSource = getYears(displayYear, options); + } + + const startDate = value[0] ? this.convertToDate(value[0], format) : undefined; + const endDate = value[1] ? this.convertToDate(value[1], format) : undefined; + + const hidePreselection = !panelPreselection && value.length === 2; + const hoverStart = !hidePreselection && hoverValue[0] ? this.convertToDate(hoverValue[0], format) : undefined; + const hoverEnd = !hidePreselection && hoverValue[1] ? this.convertToDate(hoverValue[1], format) : undefined; + + const flagged = flagActive(tableSource, { + start: startDate ?? undefined, + end: endDate ?? undefined, + hoverStart: hoverStart ?? undefined, + hoverEnd: hoverEnd ?? undefined, + type: mode, + isRange: true, + value, + multiple: false, + }); + + return flagged.map((row) => + row.map((cell, cellIndex) => { + const isWeekMode = mode === 'week'; + return { + text: cell.text, + value: cell.value, + time: cell.time, + disabled: cell.disabled, + now: cell.now, + additional: cell.additional, + firstDayOfMonth: cell.firstDayOfMonth, + lastDayOfMonth: cell.lastDayOfMonth, + weekOfYear: isWeekMode && cellIndex === 0, + active: cell.active, + highlight: cell.highlight, + hoverHighlight: cell.hoverHighlight, + startOfRange: cell.startOfRange, + endOfRange: cell.endOfRange, + hoverStartOfRange: cell.hoverStartOfRange, + hoverEndOfRange: cell.hoverEndOfRange, + dayjsObj: cell.dayjsObj, + } as DatePickerTableCell; + }), + ); + } + + private getMonthOptions() { + return Array.from({ length: 12 }, (_, index) => ({ label: `${index + 1} 月`, value: index })); + } + + private initYearOptions(year: number, mode: RangePanelProps['mode'] = 'date'): YearOption[] { + const options: YearOption[] = []; + if (mode === 'year') { + const extraYear = year % 10; + const minYear = year - extraYear - 100; + const maxYear = year - extraYear + 100; + for (let i = minYear; i <= maxYear; i += 10) { + options.push({ label: `${i} - ${i + 9}`, value: i + 9 }); + } + return options; + } + + for (let i = 3; i >= 1; i -= 1) { + options.push({ label: `${year - i}`, value: year - i }); + } + options.push({ label: `${year}`, value: year }); + options.push({ label: `${year + 1}`, value: year + 1 }); + return options; + } + + private loadMoreYear(year: number, type: 'add' | 'reduce', mode: RangePanelProps['mode'] = 'date'): YearOption[] { + const options: YearOption[] = []; + + if (mode === 'year') { + const extraYear = year % 10; + if (type === 'add') { + for (let i = year - extraYear + 10; i <= year - extraYear + 50; i += 10) { + options.push({ label: `${i} - ${i + 9}`, value: i }); + } + } else { + for (let i = year - extraYear - 1; i > year - extraYear - 50; i -= 10) { + options.unshift({ label: `${i - 9} - ${i}`, value: i }); + } + } + return options; + } + + if (type === 'add') { + for (let i = year + 1; i <= year + 5; i += 1) { + options.push({ label: `${i}`, value: i }); + } + return options; + } + + for (let i = year - 1; i >= year - 5; i -= 1) { + options.unshift({ label: `${i}`, value: i }); + } + return options; + } + + private adjustYearScrollPosition(container: HTMLElement | null) { + if (!container) return; + this.updateScrollPosition(container); + this.scrollAnchor = 'default'; + } + + private handleMonthPopupVisibleChange(visible: boolean, partial: 'start' | 'end') { + if (partial === 'start') { + if (this.startMonthPopupVisible === visible) return; + this.startMonthPopupVisible = visible; + } else { + if (this.endMonthPopupVisible === visible) return; + this.endMonthPopupVisible = visible; + } + this.update(); + } + + private handleYearPopupVisibleChange(visible: boolean, partial: 'start' | 'end') { + if (partial === 'start') { + if (this.startYearPopupVisible === visible) return; + this.startYearPopupVisible = visible; + if (!visible) { + this.scrollAnchor = 'default'; + } + this.update(); + if (visible) { + setTimeout(() => this.adjustYearScrollPosition(this.startYearPanelRef.current?.parentElement as HTMLElement)); + } + } else { + if (this.endYearPopupVisible === visible) return; + this.endYearPopupVisible = visible; + if (!visible) { + this.scrollAnchor = 'default'; + } + this.update(); + if (visible) { + setTimeout(() => this.adjustYearScrollPosition(this.endYearPanelRef.current?.parentElement as HTMLElement)); + } + } + } + + private handleMonthOptionClick(value: number, partial: 'start' | 'end') { + this.props.onMonthChange?.(value, { partial }); + if (partial === 'start') { + this.startMonthPopupVisible = false; + } else { + this.endMonthPopupVisible = false; + } + this.update(); + } + + private handleYearOptionClick(value: number, partial: 'start' | 'end') { + this.props.onYearChange?.(value, { partial }); + if (partial === 'start') { + this.startYearPopupVisible = false; + } else { + this.endYearPopupVisible = false; + } + this.update(); + } + + private handlePanelTopClick(partial: 'start' | 'end', e?: MouseEvent) { + e?.stopPropagation?.(); + e?.preventDefault?.(); + const yearOptions = partial === 'start' ? this.startYearOptions : this.endYearOptions; + if (!yearOptions.length) return; + const options = this.loadMoreYear(yearOptions[0].value, 'reduce', this.props.mode); + if (!options.length) return; + if (partial === 'start') { + this.startYearOptions = [...options, ...this.startYearOptions]; + } else { + this.endYearOptions = [...options, ...this.endYearOptions]; + } + this.scrollAnchor = 'top'; + this.update(); + setTimeout(() => { + const panelRef = partial === 'start' ? this.startYearPanelRef : this.endYearPanelRef; + this.adjustYearScrollPosition(panelRef.current?.parentElement as HTMLElement); + }); + } + + private handlePanelBottomClick(partial: 'start' | 'end', e?: MouseEvent) { + e?.stopPropagation?.(); + e?.preventDefault?.(); + const yearOptions = partial === 'start' ? this.startYearOptions : this.endYearOptions; + if (!yearOptions.length) return; + const options = this.loadMoreYear(yearOptions.slice(-1)[0].value, 'add', this.props.mode); + if (!options.length) return; + if (partial === 'start') { + this.startYearOptions = [...this.startYearOptions, ...options]; + } else { + this.endYearOptions = [...this.endYearOptions, ...options]; + } + this.scrollAnchor = 'bottom'; + this.update(); + setTimeout(() => { + const panelRef = partial === 'start' ? this.startYearPanelRef : this.endYearPanelRef; + this.adjustYearScrollPosition(panelRef.current?.parentElement as HTMLElement); + }); + } + + private handleYearPanelScroll = + (partial: 'start' | 'end') => + ({ e }: { e: WheelEvent }) => { + const target = e.target as HTMLElement; + if (!target) return; + if (target.scrollTop === 0) { + this.handlePanelTopClick(partial); + this.scrollAnchor = 'top'; + } else if (Math.ceil(target.scrollTop + target.clientHeight) >= target.scrollHeight) { + this.handlePanelBottomClick(partial); + this.scrollAnchor = 'bottom'; + } + }; + + private handlePaginationChange = + (partial: 'start' | 'end'): NonNullable => + ({ trigger }) => { + this.props.onJumperClick?.({ trigger, partial }); + }; + + private getMonthLabel(month: number | undefined): string { + if (typeof month !== 'number') return ''; + const options = this.getMonthOptions(); + return options.find((item) => item.value === month)?.label ?? `${month + 1}`; + } + + private getNearestYearValue(year: number, yearOptions: YearOption[], mode: RangePanelProps['mode'] = 'date'): number { + if (mode !== 'year') return year; + const matched = yearOptions.find((option) => option.value - year <= 9 && option.value - year >= 0); + return matched?.value ?? year; + } + + private getYearLabel(year: number, yearOptions: YearOption[], mode: RangePanelProps['mode'] = 'date'): string { + if (mode === 'year') { + const option = yearOptions.find((item) => item.value === year); + if (option) return option.label; + const decadeStart = year - (year % 10); + return `${decadeStart} - ${decadeStart + 9}`; + } + return `${year}`; + } + + private getPaginationTips(mode: RangePanelProps['mode']) { + const tipsMap: Record = { + year: { prev: '上一年代', current: '当前', next: '下一年代' }, + month: { prev: '上一年', current: '当前', next: '下一年' }, + date: { prev: '上一月', current: '今天', next: '下一月' }, + }; + if (!mode) return undefined; + return tipsMap[mode]; + } + + private handleUpdateScrollTop = (content: HTMLElement) => { + this.updateScrollPosition(content); + }; + + private updateScrollPosition(container: HTMLElement) { + if (this.scrollAnchor === 'top') { + container.scrollTop = 30 * 10; + return; + } + if (this.scrollAnchor === 'bottom') { + container.scrollTop = container.scrollHeight - 30 * 10; + return; + } + const selectedNode = container.querySelector(`.${this.classPrefix}-is-selected`); + if (selectedNode instanceof HTMLElement) { + const { offsetTop, clientHeight } = selectedNode; + const offset = offsetTop - (container.clientHeight - clientHeight) / 2; + container.scrollTop = offset < 0 ? 0 : offset; + } + } + + private findPopupContainer(triggerElement?: HTMLElement | null): HTMLElement | null { + if (!triggerElement) return null; + + const popupContentClass = `${this.classPrefix}-popup__content`; + const popupWrapperClass = `${this.classPrefix}-popup`; + + let current: Node | null = triggerElement; + const visited = new Set(); + + while (current && !visited.has(current)) { + visited.add(current); + + if (current instanceof HTMLElement) { + if (current.classList.contains(popupContentClass) || current.classList.contains(popupWrapperClass)) { + return current; + } + + if (current.parentElement) { + current = current.parentElement; + continue; + } + } + + const rootNode = (current as Element | ShadowRoot)?.getRootNode?.(); + if (rootNode instanceof ShadowRoot) { + current = rootNode.host; + continue; + } + + current = null; + } + + return null; + } + + private resolvePopupAttach(triggerElement?: HTMLElement | null): HTMLElement { + const popupClass = `${this.classPrefix}-popup`; + const popupContentClass = `${this.classPrefix}-popup__content`; + + const normalizeContainer = (container: HTMLElement | null) => { + if (!container) return null; + if (!document.contains(container)) return null; + if (container.classList.contains(popupContentClass)) { + const wrapper = container.parentElement; + if (wrapper instanceof HTMLElement && wrapper.classList.contains(popupClass)) { + return wrapper; + } + } + return container; + }; + + if (this.cachedPopupAttach && document.contains(this.cachedPopupAttach)) { + return this.cachedPopupAttach; + } + + let popupContainer = this.findPopupContainer(triggerElement); + if (!popupContainer && this.rootElement instanceof HTMLElement) { + popupContainer = this.findPopupContainer(this.rootElement); + } + + const normalized = normalizeContainer(popupContainer); + if (normalized) { + this.cachedPopupAttach = normalized; + return normalized; + } + + if (this.rootElement instanceof HTMLElement && document.contains(this.rootElement)) { + this.cachedPopupAttach = this.rootElement; + return this.rootElement; + } + + const parentElement = triggerElement?.parentElement; + if (parentElement && !(parentElement instanceof HTMLButtonElement)) { + this.cachedPopupAttach = parentElement; + return parentElement; + } + + const rootNode = triggerElement?.getRootNode?.(); + if (rootNode instanceof ShadowRoot && rootNode.host instanceof HTMLElement) { + this.cachedPopupAttach = rootNode.host; + return rootNode.host; + } + + return document.body; + } + + private getCellValueWithTime(cell: DatePickerTableCell) { + if (!cell.time) return cell.value; + const next = new Date(cell.value); + const { hours, minutes, seconds, milliseconds, meridiem } = extractTimeObj(cell.time); + let nextHours = hours; + if (/am/i.test(meridiem) && nextHours === 12) nextHours -= 12; + if (/pm/i.test(meridiem) && nextHours < 12) nextHours += 12; + next.setHours(nextHours); + next.setMinutes(minutes); + next.setSeconds(seconds); + next.setMilliseconds(milliseconds); + return next; + } + + private renderTableCell( + cell: DatePickerTableCell, + cellIndex: number, + partial: 'start' | 'end', + onCellClick?: (value: Date, context: { e: MouseEvent; partial: 'start' | 'end' }) => void, + onCellMouseEnter?: (value: Date, context: { partial: 'start' | 'end' }) => void, + ) { + const cellClass = classNames(`${this.classPrefix}-date-picker__cell`, { + [`${this.classPrefix}-date-picker__cell--now`]: cell.now, + [`${this.classPrefix}-date-picker__cell--active`]: cell.active, + [`${this.classPrefix}-date-picker__cell--disabled`]: cell.disabled, + [`${this.classPrefix}-date-picker__cell--highlight`]: cell.highlight, + [`${this.classPrefix}-date-picker__cell--hover-highlight`]: cell.hoverHighlight, + [`${this.classPrefix}-date-picker__cell--active-start`]: cell.startOfRange, + [`${this.classPrefix}-date-picker__cell--active-end`]: cell.endOfRange, + [`${this.classPrefix}-date-picker__cell--hover-start`]: cell.hoverStartOfRange, + [`${this.classPrefix}-date-picker__cell--hover-end`]: cell.hoverEndOfRange, + [`${this.classPrefix}-date-picker__cell--additional`]: cell.additional, + [`${this.classPrefix}-date-picker__cell--first-day-of-month`]: cell.firstDayOfMonth, + [`${this.classPrefix}-date-picker__cell--last-day-of-month`]: cell.lastDayOfMonth, + [`${this.classPrefix}-date-picker__cell--week-of-year`]: cell.weekOfYear, + }); + + const handleClick = (e: MouseEvent) => { + if (cell.disabled) return; + onCellClick?.(this.getCellValueWithTime(cell), { e, partial }); + }; + + const handleMouseEnter = () => { + if (cell.disabled) return; + onCellMouseEnter?.(this.getCellValueWithTime(cell), { partial }); + }; + + return ( + +
{cell.text}
+ + ); + } + + private renderTable( + data: DatePickerTableCell[][], + weekdays: string[], + partial: 'start' | 'end', + props: RangePanelProps, + ) { + const { + mode = 'date', + onCellClick, + onCellMouseEnter, + onCellMouseLeave, + value, + hoverValue, + format, + panelPreselection, + } = props; + const showThead = mode === 'date' || mode === 'week'; + + const getWeekInfo = (val?: string): WeekInfo | null => { + if (!val || !format) return null; + const parsed = parseToDayjs(val, format); + if (!parsed || !parsed.isValid()) return null; + const localized = parsed.locale(dayjs.locale()); + return { year: localized.isoWeekYear(), week: localized.isoWeek() }; + }; + + const getRowWeekInfo = (row: DatePickerTableCell[]): WeekInfo | null => { + const targetCell = row.find((cell) => !cell.weekOfYear) ?? row[0]; + if (!targetCell) return null; + const targetDayjs = targetCell.dayjsObj ? dayjs(targetCell.dayjsObj) : dayjs(targetCell.value); + if (!targetDayjs?.isValid?.()) return null; + return { year: targetDayjs.isoWeekYear(), week: targetDayjs.isoWeek() }; + }; + + const isSameWeek = (a: WeekInfo | null | undefined, b: WeekInfo | null | undefined) => + Boolean(a && b && a.year === b.year && a.week === b.week); + + const toComparable = (info: WeekInfo | null | undefined) => (info ? info.year * 100 + info.week : Number.NaN); + + const isBetweenWeeks = (target: WeekInfo | null, start: WeekInfo | null, end: WeekInfo | null) => { + if (!target || !start || !end) return false; + const targetKey = toComparable(target); + const startKey = toComparable(start); + const endKey = toComparable(end); + if (Number.isNaN(targetKey) || Number.isNaN(startKey) || Number.isNaN(endKey)) return false; + const min = Math.min(startKey, endKey); + const max = Math.max(startKey, endKey); + return targetKey > min && targetKey < max; + }; + + const hidePreselection = !panelPreselection && (value?.length ?? 0) === 2; + const valueWeekInfos = (value || []).map((item) => getWeekInfo(item)).filter(Boolean) as WeekInfo[]; + const hoverWeekInfos = hidePreselection + ? [] + : ((hoverValue || []).map((item) => getWeekInfo(item)).filter(Boolean) as WeekInfo[]); + + const buildWeekRowClass = (row: DatePickerTableCell[]) => { + if (mode !== 'week') return undefined; + const baseClass = `${this.classPrefix}-date-picker__table-${mode}-row`; + const targetInfo = getRowWeekInfo(row); + if (!targetInfo) return undefined; + + const valueStart = valueWeekInfos[0] ?? null; + const valueEnd = valueWeekInfos[1] ?? null; + const hoverStart = hoverWeekInfos[0] ?? null; + const hoverEnd = hoverWeekInfos[1] ?? null; + const hasValueRange = Boolean(valueStart && valueEnd); + + const isValueActive = valueWeekInfos.some((info) => isSameWeek(info, targetInfo)); + const isValueRange = isBetweenWeeks(targetInfo, valueStart, valueEnd); + + const isHoverActive = hoverWeekInfos.some((info) => isSameWeek(info, targetInfo)); + const isHoverRange = isBetweenWeeks(targetInfo, hoverStart, hoverEnd); + + return { + [`${baseClass}--active`]: isValueActive || (!hasValueRange && !isValueRange && isHoverActive), + [`${baseClass}--range`]: isValueRange || (!hasValueRange && isHoverRange), + }; + }; + + return ( +
onCellMouseLeave?.({ e })}> + + {showThead && ( + + + {weekdays.map((label, index) => ( + + ))} + + + )} + + {data.map((row, rowIndex) => ( + + {row.map((cell, cellIndex) => + this.renderTableCell(cell, cellIndex, partial, onCellClick, onCellMouseEnter), + )} + + ))} + +
{label}
+
+ ); + } + + private renderMonthPanel(month: number | undefined, partial: 'start' | 'end') { + const optionClass = `${this.classPrefix}-select-option`; + const selectedClass = `${this.classPrefix}-is-selected`; + + return ( +
    + {this.getMonthOptions().map((item) => ( +
  • this.handleMonthOptionClick(item.value, partial)} + > + {item.label} +
  • + ))} +
+ ); + } + + private renderYearPanel(headerClassName: string, value: number, partial: 'start' | 'end') { + const optionClass = `${this.classPrefix}-select-option`; + const selectedClass = `${this.classPrefix}-is-selected`; + const yearOptions = partial === 'start' ? this.startYearOptions : this.endYearOptions; + const yearPanelRef = partial === 'start' ? this.startYearPanelRef : this.endYearPanelRef; + + return ( +
+
this.handlePanelTopClick(partial, e)} + > + ... +
+
    + {yearOptions.map((item) => ( +
  • this.handleYearOptionClick(item.value, partial)} + > + {item.label} +
  • + ))} +
+
this.handlePanelBottomClick(partial, e)} + > + ... +
+
+ ); + } + + private renderPanelContent( + props: RangePanelProps, + partial: 'start' | 'end', + year: number, + month: number, + yearOptions: YearOption[], + monthPopupVisible: boolean, + yearPopupVisible: boolean, + ) { + const { mode = 'date', firstDayOfWeek = 1 } = props; + + const tableData = this.buildTableData(props, year, month, partial); + const weekdays = this.buildWeekdays(mode, firstDayOfWeek); + const panelName = `${this.classPrefix}-date-picker__panel`; + const headerClassName = `${this.classPrefix}-date-picker__header`; + const showMonthPicker = mode === 'date' || mode === 'week'; + const monthLabel = this.getMonthLabel(month); + const currentYearValue = this.getNearestYearValue(year, yearOptions, mode); + const yearLabel = this.getYearLabel(currentYearValue, yearOptions, mode); + const monthSelectWidth = SELECT_WIDTH; + const yearSelectWidth = mode === 'year' ? LONG_SELECT_WIDTH : SELECT_WIDTH; + + return ( +
+
+
+
+ {showMonthPicker && ( + } + valueDisplay={{monthLabel}} + panel={this.renderMonthPanel(month, partial)} + popupProps={{ + trigger: 'click', + attach: (triggerElement: HTMLElement) => this.resolvePopupAttach(triggerElement), + overlayClassName: `${headerClassName}-controller-month-popup`, + overlayInnerStyle: { width: monthSelectWidth }, + }} + popupMatchWidth={false} + onPopupVisibleChange={(visible: boolean) => this.handleMonthPopupVisibleChange(visible, partial)} + /> + )} + } + valueDisplay={{yearLabel}} + panel={this.renderYearPanel(headerClassName, currentYearValue, partial)} + popupProps={{ + trigger: 'click', + attach: (triggerElement: HTMLElement) => this.resolvePopupAttach(triggerElement), + overlayClassName: `${headerClassName}-controller-year-popup`, + onScroll: this.handleYearPanelScroll(partial), + updateScrollTop: this.handleUpdateScrollTop, + overlayInnerStyle: { width: yearSelectWidth }, + }} + popupMatchWidth={false} + onPopupVisibleChange={(visible: boolean) => this.handleYearPopupVisibleChange(visible, partial)} + /> +
+ +
+ {this.renderTable(tableData, weekdays, partial, props)} +
+
+ ); + } + + /** + * 根据presetsPlacement决定预设区域是垂直或水平布局 + */ + private renderPresets(presets: PresetRange | undefined, presetsPlacement: string) { + if (!presets || Object.keys(presets).length === 0) return null; + + const isVertical = ['left', 'right'].includes(presetsPlacement); + + return ( +
+ {Object.entries(presets).map(([name, preset]) => ( +
{ + this.props.onPresetClick?.(preset as DateValue[] | (() => DateValue[]), { + preset: presets, + e: e as unknown as MouseEvent, + }); + }} + > + {name} +
+ ))} +
+ ); + } + + render(props: OmiProps) { + const { year = [dayjs().year(), dayjs().year()], month = [dayjs().month(), dayjs().month()] } = props; + const [startYear, endYear] = year; + const [startMonth, endMonth] = month; + + const panelName = `${this.classPrefix}-date-range-picker__panel`; + const panelClass = classNames(panelName, props.class, { + [`${panelName}--direction-row`]: ['left', 'right'].includes(props.presetsPlacement || 'bottom'), + }); + + const presetsTop = ['top', 'left'].includes(props.presetsPlacement || 'bottom'); + const presetsBottom = ['bottom', 'right'].includes(props.presetsPlacement || 'bottom'); + + return ( +
+ {presetsTop && this.renderPresets(props.presets, props.presetsPlacement || 'bottom')} +
+ {this.renderPanelContent( + props, + 'start', + startYear, + startMonth, + this.startYearOptions, + this.startMonthPopupVisible, + this.startYearPopupVisible, + )} + {this.renderPanelContent( + props, + 'end', + endYear, + endMonth, + this.endYearOptions, + this.endMonthPopupVisible, + this.endYearPopupVisible, + )} +
+ {presetsBottom && this.renderPresets(props.presets, props.presetsPlacement || 'bottom')} +
+ ); + } +} diff --git a/src/date-picker/panel/SinglePanel.tsx b/src/date-picker/panel/SinglePanel.tsx new file mode 100644 index 00000000..7ed2ad6e --- /dev/null +++ b/src/date-picker/panel/SinglePanel.tsx @@ -0,0 +1,885 @@ +import 'tdesign-icons-web-components/esm/components/chevron-down'; +import 'tdesign-icons-web-components/esm/components/chevron-left'; +import 'tdesign-icons-web-components/esm/components/chevron-right'; +import 'tdesign-web-components/select-input'; +import 'dayjs/locale/zh-cn'; + +import classNames from 'classnames'; +import dayjs from 'dayjs'; +import isoWeek from 'dayjs/plugin/isoWeek'; +import { Component, createRef, OmiProps, tag } from 'omi'; + +import { parseToDayjs } from '../../_common/js/date-picker/format'; +import { + extractTimeObj, + flagActive, + getMonths, + getQuarters, + getWeeks, + getYears, + isSame, +} from '../../_common/js/date-picker/utils'; +import { getClassPrefix } from '../../_util/classname'; +import { PaginationMini } from '../../pagination/PaginationMini'; +import { TdPaginationMiniProps } from '../../pagination/type'; +import { DateValue, DisableDate, PresetDate, TdDatePickerProps } from '../type'; + +dayjs.locale('zh-cn'); +dayjs.extend(isoWeek); + +const SELECT_WIDTH = '80px'; +const LONG_SELECT_WIDTH = '130px'; + +interface YearOption { + label: string; + value: number; +} + +type ScrollAnchor = 'default' | 'top' | 'bottom'; + +export interface DatePickerTableCell { + text: string | number; + value: Date; + time?: string; + active?: boolean; + highlight?: boolean; + hoverHighlight?: boolean; + disabled?: boolean; + additional?: boolean; + now?: boolean; + firstDayOfMonth?: boolean; + lastDayOfMonth?: boolean; + weekOfYear?: boolean; + startOfRange?: boolean; + endOfRange?: boolean; + hoverStartOfRange?: boolean; + hoverEndOfRange?: boolean; + dayjsObj?: any; +} + +export interface SinglePanelProps + extends Pick { + format: string; + value?: DateValue | DateValue[]; + formattedValue?: string; + year: number; + month: number; + multiple?: boolean; + presets?: PresetDate; + presetsPlacement?: 'left' | 'top' | 'right' | 'bottom'; + onCellClick?: (value: Date, context: { e: MouseEvent }) => void; + onCellMouseEnter?: (value: Date) => void; + onCellMouseLeave?: (context: { e: MouseEvent }) => void; + onJumperClick?: (context: { trigger: 'prev' | 'next' | 'current' }) => void; + onMonthChange?: (month: number) => void; + onYearChange?: (year: number) => void; + onPresetClick?: (preset: DateValue | (() => DateValue), context: { preset: PresetDate; e: MouseEvent }) => void; + class?: string; + style?: Record | string; +} + +@tag('t-date-picker-panel') +export default class DatePickerPanel extends Component { + static defaultProps: Partial = { + mode: 'date', + firstDayOfWeek: 1, + }; + + private classPrefix = getClassPrefix(); + + private monthPopupVisible = false; + + private yearPopupVisible = false; + + private yearOptions: YearOption[] = []; + + private scrollAnchor: ScrollAnchor = 'default'; + + private yearPanelRef = createRef(); + + private cachedPopupAttach: HTMLElement | null = null; + + install() { + const { year = dayjs().year(), mode = 'date' } = this.props; + this.yearOptions = this.initYearOptions(year, mode); + } + + receiveProps(nextProps: SinglePanelProps, oldProps: SinglePanelProps): void { + const nextYear = nextProps.year ?? dayjs().year(); + const prevYear = oldProps?.year ?? dayjs().year(); + if (nextYear !== prevYear || nextProps.mode !== oldProps.mode) { + this.yearOptions = this.initYearOptions(nextYear, nextProps.mode ?? 'date'); + } + } + + private convertToDate(value?: DateValue, format?: string) { + if (value === null || value === undefined || value === '') return null; + if (value instanceof Date) return value; + const parsed = parseToDayjs(value, format); + if (!parsed || !parsed.isValid()) return null; + return parsed.toDate(); + } + + private resolveDisableDate(disableDate: DisableDate | undefined, format: string, mode: string) { + if (!disableDate) return undefined; + if (typeof disableDate === 'function') { + return (date: Date) => (disableDate as (d: DateValue) => boolean)(date); + } + if (Array.isArray(disableDate)) { + const cached = disableDate.map((item) => this.convertToDate(item, format)).filter(Boolean) as Date[]; + return (date: Date) => cached.some((item) => isSame(item, date, mode)); + } + const { before, after, from, to } = disableDate; + const beforeDate = this.convertToDate(before, format); + const afterDate = this.convertToDate(after, format); + const fromDate = this.convertToDate(from, format); + const toDate = this.convertToDate(to, format); + + return (date: Date) => { + if (beforeDate && dayjs(date).isBefore(beforeDate, 'day')) return true; + if (afterDate && dayjs(date).isAfter(afterDate, 'day')) return true; + if (fromDate && dayjs(date).isBefore(fromDate, 'day')) return true; + if (toDate && dayjs(date).isAfter(toDate, 'day')) return true; + return false; + }; + } + + /** + * 根据firstDayOfWeek调整星期顺序,week mode额外添加"周"列 + */ + private buildWeekdays(mode: SinglePanelProps['mode'], firstDayOfWeek: number) { + if (mode !== 'date' && mode !== 'week') return []; + const localeData = dayjs.localeData(); + const weekdays = localeData.weekdaysMin(); + const start = ((firstDayOfWeek % 7) + 7) % 7; + const result: string[] = []; + let index = start; + for (let i = 0; i < 7; i += 1) { + result.push(weekdays[index]); + index = (index + 1) % 7; + } + if (mode === 'week') { + const locale = dayjs.locale() || 'zh-cn'; + const weekLabel = locale.startsWith('zh') ? '周' : 'Week'; + result.unshift(weekLabel); + } + return result; + } + + private getQuarterLabels(): string[] { + const locale = dayjs.locale(); + if (locale && locale.startsWith('zh')) { + return ['一季度', '二季度', '三季度', '四季度']; + } + return ['Q1', 'Q2', 'Q3', 'Q4']; + } + + /** + * 根据mode生成对应的日期/周/月/季度/年数据,并标记选中、禁用等状态 + */ + private buildTableData(props: SinglePanelProps): DatePickerTableCell[][] { + const { format, mode = 'date', firstDayOfWeek = 1, disableDate, minDate, maxDate } = props; + const selectedValue = + this.convertToDate(props.formattedValue, format) || this.convertToDate(props.value as DateValue, props.format); + const disableHandler = this.resolveDisableDate(disableDate, format, mode) || (() => false); + + const minBoundary = this.convertToDate(minDate, format) || new Date(-8.64e15); + const maxBoundary = this.convertToDate(maxDate, format) || new Date(8.64e15); + + const dayjsLocale = dayjs.locale(); + const localeData = dayjs.localeData(); + const monthLocal = (localeData.monthsShort && localeData.monthsShort()) || localeData.months(); + const quarterLocal = this.getQuarterLabels(); + + const options = { + firstDayOfWeek, + disableDate: disableHandler, + minDate: minBoundary, + maxDate: maxBoundary, + monthLocal, + quarterLocal, + dayjsLocale, + cancelRangeSelectLimit: true, + showWeekOfYear: mode === 'week', + }; + + let tableSource: any[][] = []; + + if (mode === 'date' || mode === 'week') { + tableSource = getWeeks({ year: props.year, month: props.month }, options); + } else if (mode === 'month') { + tableSource = getMonths(props.year, options); + } else if (mode === 'quarter') { + tableSource = getQuarters(props.year, options); + } else if (mode === 'year') { + tableSource = getYears(props.year, options); + } + + const flagged = flagActive(tableSource, { + start: selectedValue ?? undefined, + end: undefined, + hoverStart: undefined, + hoverEnd: undefined, + type: mode, + isRange: false, + value: props.value, + multiple: Boolean(props.multiple), + }); + + return flagged.map((row) => + row.map((cell, cellIndex) => { + const isWeekMode = mode === 'week'; + return { + text: cell.text, + value: cell.value, + time: cell.time, + disabled: cell.disabled, + now: cell.now, + additional: cell.additional, + firstDayOfMonth: cell.firstDayOfMonth, + lastDayOfMonth: cell.lastDayOfMonth, + weekOfYear: isWeekMode && cellIndex === 0, + active: cell.active, + highlight: cell.highlight, + hoverHighlight: cell.hoverHighlight, + startOfRange: cell.startOfRange, + endOfRange: cell.endOfRange, + hoverStartOfRange: cell.hoverStartOfRange, + hoverEndOfRange: cell.hoverEndOfRange, + dayjsObj: cell.dayjsObj, + } as DatePickerTableCell; + }), + ); + } + + private getMonthOptions() { + return Array.from({ length: 12 }, (_, index) => ({ label: `${index + 1} 月`, value: index })); + } + + private initYearOptions(year: number, mode: SinglePanelProps['mode'] = 'date'): YearOption[] { + const options: YearOption[] = []; + if (mode === 'year') { + const extraYear = year % 10; + const minYear = year - extraYear - 100; + const maxYear = year - extraYear + 100; + for (let i = minYear; i <= maxYear; i += 10) { + options.push({ label: `${i} - ${i + 9}`, value: i + 9 }); + } + return options; + } + + for (let i = 3; i >= 1; i -= 1) { + options.push({ label: `${year - i}`, value: year - i }); + } + options.push({ label: `${year}`, value: year }); + options.push({ label: `${year + 1}`, value: year + 1 }); + return options; + } + + private loadMoreYear(year: number, type: 'add' | 'reduce'): YearOption[] { + const { mode = 'date' } = this.props; + const options: YearOption[] = []; + + if (mode === 'year') { + const extraYear = year % 10; + if (type === 'add') { + for (let i = year - extraYear + 10; i <= year - extraYear + 50; i += 10) { + options.push({ label: `${i} - ${i + 9}`, value: i }); + } + } else { + for (let i = year - extraYear - 1; i > year - extraYear - 50; i -= 10) { + options.unshift({ label: `${i - 9} - ${i}`, value: i }); + } + } + return options; + } + + if (type === 'add') { + for (let i = year + 1; i <= year + 5; i += 1) { + options.push({ label: `${i}`, value: i }); + } + return options; + } + + for (let i = year - 1; i >= year - 5; i -= 1) { + options.unshift({ label: `${i}`, value: i }); + } + return options; + } + + private adjustYearScrollPosition() { + const container = this.getYearScrollContainer(); + if (!container) return; + + this.updateScrollPosition(container); + this.scrollAnchor = 'default'; + } + + private getYearScrollContainer(): HTMLElement | null { + const panel = this.yearPanelRef.current; + if (!panel) return null; + return panel.parentElement as HTMLElement; + } + + private handleMonthPopupVisibleChange(visible: boolean) { + if (this.monthPopupVisible === visible) return; + this.monthPopupVisible = visible; + this.update(); + } + + private handleYearPopupVisibleChange(visible: boolean) { + if (this.yearPopupVisible === visible) return; + this.yearPopupVisible = visible; + if (!visible) { + this.scrollAnchor = 'default'; + } + this.update(); + if (visible) { + setTimeout(() => this.adjustYearScrollPosition()); + } + } + + private handleMonthOptionClick(value: number) { + this.props.onMonthChange?.(value); + this.monthPopupVisible = false; + this.update(); + } + + private handleYearOptionClick(value: number) { + this.props.onYearChange?.(value); + this.yearPopupVisible = false; + this.update(); + } + + private handlePanelTopClick(e?: MouseEvent) { + e?.stopPropagation?.(); + e?.preventDefault?.(); + if (!this.yearOptions.length) return; + const options = this.loadMoreYear(this.yearOptions[0].value, 'reduce'); + if (!options.length) return; + this.yearOptions = [...options, ...this.yearOptions]; + this.scrollAnchor = 'top'; + this.update(); + setTimeout(() => this.adjustYearScrollPosition()); + } + + private handlePanelBottomClick(e?: MouseEvent) { + e?.stopPropagation?.(); + e?.preventDefault?.(); + if (!this.yearOptions.length) return; + const options = this.loadMoreYear(this.yearOptions.slice(-1)[0].value, 'add'); + if (!options.length) return; + this.yearOptions = [...this.yearOptions, ...options]; + this.scrollAnchor = 'bottom'; + this.update(); + setTimeout(() => this.adjustYearScrollPosition()); + } + + private handleYearPanelScroll = ({ e }: { e: WheelEvent }) => { + const target = e.target as HTMLElement; + if (!target) return; + if (target.scrollTop === 0) { + this.handlePanelTopClick(); + this.scrollAnchor = 'top'; + } else if (Math.ceil(target.scrollTop + target.clientHeight) >= target.scrollHeight) { + this.handlePanelBottomClick(); + this.scrollAnchor = 'bottom'; + } + }; + + private handlePaginationChange: NonNullable = ({ trigger }) => { + this.props.onJumperClick?.({ trigger }); + }; + + private getMonthLabel(month: number | undefined): string { + if (typeof month !== 'number') return ''; + const options = this.getMonthOptions(); + return options.find((item) => item.value === month)?.label ?? `${month + 1}`; + } + + private getNearestYearValue(year: number, mode: SinglePanelProps['mode'] = 'date'): number { + if (mode !== 'year') return year; + const { ...rest } = this.props; + const { partial, internalYear = [] } = (rest as { partial?: 'start' | 'end'; internalYear?: number[] }) || {}; + const extraYear = partial === 'end' && internalYear.length > 1 && internalYear[1] - internalYear[0] <= 9 ? 9 : 0; + const targetYear = year + extraYear; + const matched = this.yearOptions.find((option) => option.value - targetYear <= 9 && option.value - targetYear >= 0); + return matched?.value ?? targetYear; + } + + private getYearLabel(year: number, mode: SinglePanelProps['mode'] = 'date'): string { + if (mode === 'year') { + const option = this.yearOptions.find((item) => item.value === year); + if (option) return option.label; + const decadeStart = year - (year % 10); + return `${decadeStart} - ${decadeStart + 9}`; + } + return `${year}`; + } + + private renderMonthPanel(month: number | undefined) { + const optionClass = `${this.classPrefix}-select-option`; + const selectedClass = `${this.classPrefix}-is-selected`; + + return ( +
    + {this.getMonthOptions().map((item) => ( +
  • this.handleMonthOptionClick(item.value)} + > + {item.label} +
  • + ))} +
+ ); + } + + private renderYearPanel(headerClassName: string, value: number) { + const optionClass = `${this.classPrefix}-select-option`; + const selectedClass = `${this.classPrefix}-is-selected`; + + return ( +
+
this.handlePanelTopClick(e)} + > + ... +
+
    + {this.yearOptions.map((item) => ( +
  • this.handleYearOptionClick(item.value)} + > + {item.label} +
  • + ))} +
+
this.handlePanelBottomClick(e)} + > + ... +
+
+ ); + } + + private getPaginationTips(mode: SinglePanelProps['mode']) { + const tipsMap: Record = { + year: { prev: '上一年代', current: '当前', next: '下一年代' }, + month: { prev: '上一年', current: '当前', next: '下一年' }, + date: { prev: '上一月', current: '今天', next: '下一月' }, + }; + if (!mode) return undefined; + return tipsMap[mode]; + } + + private handleUpdateScrollTop = (content: HTMLElement) => { + this.updateScrollPosition(content); + }; + + private updateScrollPosition(container: HTMLElement) { + if (this.scrollAnchor === 'top') { + container.scrollTop = 30 * 10; + return; + } + if (this.scrollAnchor === 'bottom') { + container.scrollTop = container.scrollHeight - 30 * 10; + return; + } + const selectedNode = container.querySelector(`.${this.classPrefix}-is-selected`); + if (selectedNode instanceof HTMLElement) { + const { offsetTop, clientHeight } = selectedNode; + const offset = offsetTop - (container.clientHeight - clientHeight) / 2; + container.scrollTop = offset < 0 ? 0 : offset; + } + } + + private findPopupContainer(triggerElement?: HTMLElement | null): HTMLElement | null { + if (!triggerElement) return null; + + const popupContentClass = `${this.classPrefix}-popup__content`; + const popupWrapperClass = `${this.classPrefix}-popup`; + + let current: Node | null = triggerElement; + const visited = new Set(); + + while (current && !visited.has(current)) { + visited.add(current); + + if (current instanceof HTMLElement) { + if (current.classList.contains(popupContentClass) || current.classList.contains(popupWrapperClass)) { + return current; + } + + if (current.parentElement) { + current = current.parentElement; + continue; + } + } + + const rootNode = (current as Element | ShadowRoot)?.getRootNode?.(); + if (rootNode instanceof ShadowRoot) { + current = rootNode.host; + continue; + } + + current = null; + } + + return null; + } + + private resolvePopupAttach(triggerElement?: HTMLElement | null): HTMLElement { + const popupClass = `${this.classPrefix}-popup`; + const popupContentClass = `${this.classPrefix}-popup__content`; + + const normalizeContainer = (container: HTMLElement | null) => { + if (!container) return null; + if (!document.contains(container)) return null; + if (container.classList.contains(popupContentClass)) { + const wrapper = container.parentElement; + if (wrapper instanceof HTMLElement && wrapper.classList.contains(popupClass)) { + return wrapper; + } + } + return container; + }; + + if (this.cachedPopupAttach && document.contains(this.cachedPopupAttach)) { + return this.cachedPopupAttach; + } + + let popupContainer = this.findPopupContainer(triggerElement); + if (!popupContainer && this.rootElement instanceof HTMLElement) { + popupContainer = this.findPopupContainer(this.rootElement); + } + + const normalized = normalizeContainer(popupContainer); + if (normalized) { + this.cachedPopupAttach = normalized; + return normalized; + } + + if (this.rootElement instanceof HTMLElement && document.contains(this.rootElement)) { + this.cachedPopupAttach = this.rootElement; + return this.rootElement; + } + + const parentElement = triggerElement?.parentElement; + if (parentElement && !(parentElement instanceof HTMLButtonElement)) { + this.cachedPopupAttach = parentElement; + return parentElement; + } + + const rootNode = triggerElement?.getRootNode?.(); + if (rootNode instanceof ShadowRoot && rootNode.host instanceof HTMLElement) { + this.cachedPopupAttach = rootNode.host; + return rootNode.host; + } + + return document.body; + } + + private getCellValueWithTime(cell: DatePickerTableCell) { + if (!cell.time) return cell.value; + const next = new Date(cell.value); + const { hours, minutes, seconds, milliseconds, meridiem } = extractTimeObj(cell.time); + let nextHours = hours; + if (/am/i.test(meridiem) && nextHours === 12) nextHours -= 12; + if (/pm/i.test(meridiem) && nextHours < 12) nextHours += 12; + next.setHours(nextHours); + next.setMinutes(minutes); + next.setSeconds(seconds); + next.setMilliseconds(milliseconds); + return next; + } + + private renderTableCell( + cell: DatePickerTableCell, + cellIndex: number, + onCellClick?: (value: Date, context: { e: MouseEvent }) => void, + onCellMouseEnter?: (value: Date) => void, + ) { + const cellClass = classNames(`${this.classPrefix}-date-picker__cell`, { + [`${this.classPrefix}-date-picker__cell--now`]: cell.now, + [`${this.classPrefix}-date-picker__cell--active`]: cell.active, + [`${this.classPrefix}-date-picker__cell--disabled`]: cell.disabled, + [`${this.classPrefix}-date-picker__cell--highlight`]: cell.highlight, + [`${this.classPrefix}-date-picker__cell--hover-highlight`]: cell.hoverHighlight, + [`${this.classPrefix}-date-picker__cell--active-start`]: cell.startOfRange, + [`${this.classPrefix}-date-picker__cell--active-end`]: cell.endOfRange, + [`${this.classPrefix}-date-picker__cell--hover-start`]: cell.hoverStartOfRange, + [`${this.classPrefix}-date-picker__cell--hover-end`]: cell.hoverEndOfRange, + [`${this.classPrefix}-date-picker__cell--additional`]: cell.additional, + [`${this.classPrefix}-date-picker__cell--first-day-of-month`]: cell.firstDayOfMonth, + [`${this.classPrefix}-date-picker__cell--last-day-of-month`]: cell.lastDayOfMonth, + [`${this.classPrefix}-date-picker__cell--week-of-year`]: cell.weekOfYear, + }); + + const handleClick = (e: MouseEvent) => { + if (cell.disabled) return; + onCellClick?.(this.getCellValueWithTime(cell), { e }); + }; + + const handleMouseEnter = () => { + if (cell.disabled) return; + onCellMouseEnter?.(this.getCellValueWithTime(cell)); + }; + + return ( + +
{cell.text}
+ + ); + } + + private renderTable(props: OmiProps, data: DatePickerTableCell[][], weekdays: string[]) { + const { mode = 'date', onCellClick, onCellMouseEnter, onCellMouseLeave, value, format, multiple } = props; + const showThead = mode === 'date' || mode === 'week'; + + const getWeekInfo = (val: DateValue | undefined) => { + if (!val) return null; + const parsed = parseToDayjs(val, format); + if (!parsed || !parsed.isValid()) return null; + const local = parsed.locale(dayjs.locale()); + return { year: local.isoWeekYear(), week: local.isoWeek() }; + }; + + const multipleWeekSet = + mode === 'week' && multiple && Array.isArray(value) + ? new Set( + value + .map((item) => { + const info = getWeekInfo(item); + if (!info) return ''; + return `${info.year}-${info.week}`; + }) + .filter(Boolean), + ) + : null; + + const rangeWeekInfo = + mode === 'week' && !multiple && Array.isArray(value) && value.length + ? value.map((item) => getWeekInfo(item)) + : []; + + const singleWeekInfo = mode === 'week' && value && !Array.isArray(value) ? getWeekInfo(value) : null; + + const buildWeekRowClass = (row: DatePickerTableCell[]) => { + if (mode !== 'week' || !value) return {}; + const baseClass = `${this.classPrefix}-date-picker__table-${mode}-row`; + const targetCell = row.find((cell) => !cell.weekOfYear) ?? row[0]; + const targetDayjs = targetCell?.dayjsObj ? dayjs(targetCell.dayjsObj) : dayjs(targetCell?.value); + if (!targetDayjs.isValid()) return {}; + + const targetYear = targetDayjs.isoWeekYear(); + const targetWeek = targetDayjs.isoWeek(); + + if (multipleWeekSet) { + const key = `${targetYear}-${targetWeek}`; + return { + [`${baseClass}--active`]: multipleWeekSet.has(key), + }; + } + + if (Array.isArray(rangeWeekInfo) && rangeWeekInfo.length) { + const [start, end] = rangeWeekInfo; + if (!start) return {}; + + const isActive = + (start && start.year === targetYear && start.week === targetWeek) || + (end && end.year === targetYear && end.week === targetWeek); + + const isRange = Boolean( + start && + end && + (targetYear > start.year || (targetYear === start.year && targetWeek > start.week)) && + (targetYear < end.year || (targetYear === end.year && targetWeek < end.week)), + ); + + return { + [`${baseClass}--active`]: isActive, + [`${baseClass}--range`]: isRange, + }; + } + + if (singleWeekInfo) { + return { + [`${baseClass}--active`]: singleWeekInfo.year === targetYear && singleWeekInfo.week === targetWeek, + }; + } + + return {}; + }; + + return ( +
onCellMouseLeave?.({ e })}> + + {showThead && ( + + + {weekdays.map((label, index) => ( + + ))} + + + )} + + {data.map((row, rowIndex) => ( + + {row.map((cell, cellIndex) => this.renderTableCell(cell, cellIndex, onCellClick, onCellMouseEnter))} + + ))} + +
{label}
+
+ ); + } + + /** + * 根据presetsPlacement决定预设区域的位置样式 + */ + private renderPresets(presets: PresetDate | undefined, presetsPlacement: string) { + if (!presets || Object.keys(presets).length === 0) return null; + + return ( +
+
+ {Object.entries(presets).map(([name, preset]) => ( +
{ + this.props.onPresetClick?.(preset as DateValue | (() => DateValue), { + preset: presets, + e: e as unknown as MouseEvent, + }); + }} + > + {name} +
+ ))} +
+
+ ); + } + + render(props: OmiProps) { + const { + year, + month, + firstDayOfWeek = 1, + presets, + presetsPlacement = 'bottom', + onCellClick, + onCellMouseEnter, + onCellMouseLeave, + onJumperClick, + onMonthChange, + onYearChange, + } = props; + + const panelName = `${this.classPrefix}-date-picker__panel`; + const panelClass = classNames(panelName, props.class, { + [`${panelName}--direction-row`]: ['left', 'right'].includes(presetsPlacement), + }); + const tableData = this.buildTableData(props); + const weekdays = this.buildWeekdays(props.mode, firstDayOfWeek); + const mode = props.mode ?? 'date'; + + const headerClassName = `${this.classPrefix}-date-picker__header`; + const showMonthPicker = mode === 'date' || mode === 'week'; + const monthLabel = this.getMonthLabel(month); + const currentYearValue = this.getNearestYearValue(year, mode); + const yearLabel = this.getYearLabel(currentYearValue, mode); + const monthSelectWidth = SELECT_WIDTH; + const yearSelectWidth = mode === 'year' ? LONG_SELECT_WIDTH : SELECT_WIDTH; + + const presetsTop = ['top', 'left'].includes(presetsPlacement); + const presetsBottom = ['bottom', 'right'].includes(presetsPlacement); + + return ( +
+ {presetsTop && this.renderPresets(presets, presetsPlacement)} +
+
+
+
+ {showMonthPicker && ( + } + valueDisplay={{monthLabel}} + panel={this.renderMonthPanel(month)} + popupProps={{ + trigger: 'click', + attach: (triggerElement: HTMLElement) => this.resolvePopupAttach(triggerElement), + overlayClassName: `${headerClassName}-controller-month-popup`, + overlayInnerStyle: { width: monthSelectWidth }, + }} + popupMatchWidth={false} + onPopupVisibleChange={(visible: boolean) => this.handleMonthPopupVisibleChange(visible)} + /> + )} + } + valueDisplay={{yearLabel}} + panel={this.renderYearPanel(headerClassName, currentYearValue)} + popupProps={{ + trigger: 'click', + attach: (triggerElement: HTMLElement) => this.resolvePopupAttach(triggerElement), + overlayClassName: `${headerClassName}-controller-year-popup`, + onScroll: this.handleYearPanelScroll, + updateScrollTop: this.handleUpdateScrollTop, + overlayInnerStyle: { width: yearSelectWidth }, + }} + popupMatchWidth={false} + onPopupVisibleChange={(visible: boolean) => this.handleYearPopupVisibleChange(visible)} + /> +
+ +
+ {this.renderTable( + { + ...props, + onCellClick, + onCellMouseEnter, + onCellMouseLeave, + onJumperClick, + onMonthChange, + onYearChange, + } as OmiProps, + tableData, + weekdays, + )} +
+
+ {presetsBottom && this.renderPresets(presets, presetsPlacement)} +
+ ); + } +} diff --git a/src/date-picker/style/index.js b/src/date-picker/style/index.js new file mode 100644 index 00000000..4ecf9769 --- /dev/null +++ b/src/date-picker/style/index.js @@ -0,0 +1,11 @@ +import { css, globalCSS } from 'omi'; + +import styles from '../../_common/style/web/components/date-picker/_index.less'; +import baseStyles from './index.less' + +export const styleSheet = css` + ${styles} + ${baseStyles} +`; + +globalCSS(styleSheet); diff --git a/src/date-picker/style/index.less b/src/date-picker/style/index.less new file mode 100644 index 00000000..a6f29079 --- /dev/null +++ b/src/date-picker/style/index.less @@ -0,0 +1,168 @@ +@import '../../_common/style/web/base.less'; + +// :host的样式直接应用到t-date-picker和t-date-range-picker标签本身,而非仅影响shadow dom内部的元素 +:host { + display: inline-block; +} + +// DatePicker样式 +.t-date-picker__header { + position: relative; +} + +.t-date-picker__header > .t-portal-wrapper { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 0; +} + +.t-date-picker-header__panel-list { + display: flex; + flex-direction: column; + padding: 0; + margin: 0; + gap: 2px; + list-style: none; +} + +.t-date-picker-header__panel-item { + display: block; + border-radius: 3px; + line-height: 22px; + cursor: pointer; + padding: 3px 8px; + color: var(--td-text-color-primary); + transition: background-color 0.2s linear; + white-space: nowrap; + word-wrap: normal; + overflow: hidden; + text-overflow: ellipsis; + + &:hover { + background-color: var(--td-bg-color-container-hover); + } +} + +.t-date-picker__header-controller-month-popup, +.t-date-picker__header-controller-year-popup { + > .t-popup__content, + > .t-popup__content--arrow { + // 下拉框长度 + max-height: 160px; + overflow-y: auto; + padding: 6px; + } +} + +.t-popup__content, +.t-popup__content--arrow { + margin-top: 8px !important; + padding: 0; +} + +.t-date-picker__panel { + &--direction-row { + display: flex; + flex-direction: row; + } +} + +.t-date-picker__footer { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + border-top: 1px solid var(--td-component-stroke); + + &--top { + border-top: none; + border-bottom: 1px solid var(--td-component-stroke); + } + + &--left { + flex-direction: column; + border-top: none; + border-right: 1px solid var(--td-component-stroke); + padding: 12px; + } + + &--right { + flex-direction: column; + border-top: none; + border-left: 1px solid var(--td-component-stroke); + padding: 12px; + } +} + +.t-date-picker__presets { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.t-date-picker__presets-item { + padding: 4px 8px; + font-size: var(--td-font-size-body-small); + color: var(--td-text-color-primary); + cursor: pointer; + border-radius: var(--td-radius-default); + transition: + background-color 0.2s linear, + color 0.2s linear; + white-space: nowrap; + + &:hover { + background-color: var(--td-bg-color-container-hover); + color: var(--td-brand-color); + } +} + +// DateRangePicker 样式 +.t-date-range-picker__panel { + display: flex; + flex-direction: column; + background-color: var(--td-bg-color-container); + border-radius: var(--td-radius-medium); + box-shadow: var(--td-shadow-2); + + &--direction-row { + flex-direction: row; + } +} + +.t-date-range-picker__panel-content-wrapper { + display: flex; + flex-direction: row; +} + +.t-date-range-picker__presets { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 8px 12px; + border-top: 1px solid var(--td-component-stroke); + + &--vertical { + flex-direction: column; + border-top: none; + border-left: 1px solid var(--td-component-stroke); + padding: 12px; + } +} + +.t-date-range-picker__presets-item { + padding: 4px 8px; + font-size: var(--td-font-size-body-small); + color: var(--td-text-color-primary); + cursor: pointer; + border-radius: var(--td-radius-default); + transition: background-color 0.2s linear; + white-space: nowrap; + + &:hover { + background-color: var(--td-bg-color-container-hover); + color: var(--td-brand-color); + } +} diff --git a/src/date-picker/type.ts b/src/date-picker/type.ts new file mode 100644 index 00000000..2859f569 --- /dev/null +++ b/src/date-picker/type.ts @@ -0,0 +1,164 @@ +import { TNode } from '../common'; + +export type DateValue = string | number | Date; + +export type DateRangeValue = Array; + +export interface DisableDateObject { + before?: DateValue; + after?: DateValue; + from?: DateValue; + to?: DateValue; +} + +export type DisableDate = DateValue[] | DisableDateObject | ((date: DateValue) => boolean); + +export interface PresetDate { + [name: string]: DateValue | (() => DateValue); +} + +export interface PresetRange { + [name: string]: DateRangeValue | (() => DateRangeValue); +} + +export interface TdDatePickerProps { + /** 选中值 */ + value?: DateValue; + /** 选中值,非受控属性 */ + defaultValue?: DateValue; + /** 用于格式化日期显示的格式 */ + format?: string; + /** 用于格式化日期值的类型,对比 format 只用于展示 */ + valueType?: string; + /** 选择器模式 */ + mode?: 'date' | 'month' | 'year' | 'week' | 'quarter'; + /** 一周的起始天(0-6) */ + firstDayOfWeek?: number; + /** 禁用日期 */ + disableDate?: DisableDate; + /** 最小可选日期 */ + minDate?: DateValue; + /** 最大可选日期 */ + maxDate?: DateValue; + /** 预设快捷日期选择 */ + presets?: PresetDate; + /** 预设面板展示区域(左侧/下侧) */ + presetsPlacement?: 'left' | 'top' | 'right' | 'bottom'; + /** 是否需要点击确认按钮 */ + needConfirm?: boolean; + /** 占位符 */ + placeholder?: string; + /** 输入框下方提示 */ + tips?: any; + /** 输入框状态 */ + status?: 'default' | 'success' | 'warning' | 'error'; + /** 是否无边框 */ + borderless?: boolean; + /** 是否禁用组件 */ + disabled?: boolean; + /** 是否显示清除按钮 */ + clearable?: boolean; + /** 是否允许输入日期 */ + allowInput?: boolean; + /** 左侧文本内容 */ + label?: any; + /** 自定义前缀图标 */ + prefixIcon?: TNode; + /** 自定义后缀图标 */ + suffixIcon?: TNode; + /** 自定义样式 */ + style?: Record | string; + /** 自定义 class 类名 */ + class?: string; + /** 设置面板是否可见(受控) */ + popupVisible?: boolean; + /** 默认面板显示状态(非受控) */ + defaultPopupVisible?: boolean; + /** 透传给输入框组件的属性 */ + inputProps?: any; + /** 透传给 popup 组件的参数 */ + popupProps?: any; + /** 透传给 tagInput 组件的参数 */ + tagInputProps?: any; + /** 选中值变化时触发 */ + onChange?: (value: DateValue, context?: any) => void; + /** 面板显示/隐藏切换时触发 */ + onVisibleChange?: (visible: boolean, context?: any) => void; + /** 用户选择日期时触发 */ + onPick?: (value: Date, context?: any) => void; + /** 点击预设按钮后触发 */ + onPresetClick?: (context?: any) => void; + /** 点击清除按钮时触发 */ + onClear?: (context?: any) => void; + /** 输入框获得焦点时触发 */ + onFocus?: (context?: any) => void; + /** 输入框失去焦点时触发 */ + onBlur?: (context?: any) => void; +} + +export interface TdDateRangePickerProps { + /** 选中值 */ + value?: DateRangeValue; + /** 选中值,非受控属性 */ + defaultValue?: DateRangeValue; + /** 用于格式化日期显示的格式 */ + format?: string; + /** 用于格式化日期值的类型,对比 format 只用于展示 */ + valueType?: string; + /** 选择器模式 */ + mode?: 'date' | 'month' | 'year' | 'week' | 'quarter'; + /** 一周的起始天(0-6) */ + firstDayOfWeek?: number; + /** 禁用日期 */ + disableDate?: DisableDate; + /** 是否允许取消选中范围选择限制,设置为 true 将不再限制结束日期必须大于开始日期 */ + cancelRangeSelectLimit?: boolean; + /** 是否在选中日期时预选高亮 */ + panelPreselection?: boolean; + /** 预设快捷日期选择 */ + presets?: PresetRange; + /** 预设面板展示区域(左侧/下侧) */ + presetsPlacement?: 'left' | 'top' | 'right' | 'bottom'; + /** 占位符 */ + placeholder?: string | string[]; + /** 输入框下方提示 */ + tips?: any; + /** 输入框状态 */ + status?: 'default' | 'success' | 'warning' | 'error'; + /** 是否禁用组件 */ + disabled?: boolean; + /** 是否显示清除按钮 */ + clearable?: boolean; + /** 是否允许输入日期 */ + allowInput?: boolean; + /** 左侧文本内容 */ + label?: any; + /** 范围分隔符 */ + separator?: TNode; + /** 自定义前缀图标 */ + prefixIcon?: TNode; + /** 自定义后缀图标 */ + suffixIcon?: TNode; + /** 自定义样式 */ + style?: Record | string; + /** 自定义 class 类名 */ + class?: string; + /** 设置面板是否可见(受控) */ + popupVisible?: boolean; + /** 默认面板显示状态(非受控) */ + defaultPopupVisible?: boolean; + /** 透传给输入框组件的属性 */ + rangeInputProps?: any; + /** 透传给 popup 组件的参数 */ + popupProps?: any; + /** 选中值变化时触发 */ + onChange?: (value: DateRangeValue, context?: any) => void; + /** 面板显示/隐藏切换时触发 */ + onVisibleChange?: (visible: boolean, context?: any) => void; + /** 用户选择日期时触发 */ + onPick?: (value: Date, context?: any) => void; + /** 点击预设按钮后触发 */ + onPresetClick?: (context?: any) => void; + /** 点击清除按钮时触发 */ + onClear?: (context?: any) => void; +} diff --git a/src/index.ts b/src/index.ts index dcbf3a4d..d9cfcc50 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ export * from './checkbox'; export * from './collapse'; export * from './comment'; export * from './common'; +export * from './date-picker'; export * from './dialog'; export * from './divider'; export * from './dropdown'; diff --git a/src/pagination/PaginationMini.tsx b/src/pagination/PaginationMini.tsx new file mode 100644 index 00000000..2df43b08 --- /dev/null +++ b/src/pagination/PaginationMini.tsx @@ -0,0 +1,129 @@ +import 'tdesign-icons-web-components/esm/components/chevron-down'; +import 'tdesign-icons-web-components/esm/components/chevron-left'; +import 'tdesign-icons-web-components/esm/components/chevron-right'; +import 'tdesign-icons-web-components/esm/components/chevron-up'; +import 'tdesign-icons-web-components/esm/components/round'; +import '../button'; + +import classNames from 'classnames'; + +import { getClassPrefix } from '../_util/classname'; +import { StyledProps } from '../common'; +import { paginationMiniDefaultProps } from './defaultProps'; +import { JumperDisabledConfig, JumperTipsConfig, TdPaginationMiniProps } from './type'; + +const PAGINATION_ICON_SIZE_MAP = { + small: '14px', + medium: '16px', + large: '18px', +} as const; + +const DEFAULT_TIPS: Required = { + prev: '上一页', + current: '当前', + next: '下一页', +}; + +export interface PaginationMiniProps extends TdPaginationMiniProps, StyledProps { + class?: string; +} + +function normalizeDisabled(disabled?: boolean | JumperDisabledConfig): Required { + if (typeof disabled === 'boolean') { + return { + prev: disabled, + current: disabled, + next: disabled, + }; + } + return { + prev: disabled?.prev ?? false, + current: disabled?.current ?? false, + next: disabled?.next ?? false, + }; +} + +function normalizeTips(tips?: boolean | JumperTipsConfig): JumperTipsConfig { + if (tips === true) return { ...DEFAULT_TIPS }; + if (!tips) return {}; + return tips; +} + +export const PaginationMini = (props: PaginationMiniProps) => { + const classPrefix = getClassPrefix(); + const { + layout = paginationMiniDefaultProps.layout, + showCurrent = paginationMiniDefaultProps.showCurrent, + size = paginationMiniDefaultProps.size, + variant = paginationMiniDefaultProps.variant, + disabled, + tips, + onChange, + class: className, + style, + innerClass, + } = props; + + const disabledConfig = normalizeDisabled(disabled); + const tipConfig = normalizeTips(tips); + + const rootClassName = classNames(`${classPrefix}-pagination-mini`, className, innerClass, { + [`${classPrefix}-pagination-mini--outline`]: variant === 'outline', + }); + + const handleClick = (trigger: 'prev' | 'current' | 'next') => (e: MouseEvent) => { + const isDisabled = disabledConfig[trigger]; + if (isDisabled) return; + onChange?.({ e, trigger }); + }; + + const horizontal = layout !== 'vertical'; + + const iconSize = PAGINATION_ICON_SIZE_MAP[size ?? 'small'] || PAGINATION_ICON_SIZE_MAP.small; + const iconStyle = { + fontSize: iconSize, + width: iconSize, + height: iconSize, + } as const; + + return ( +
+ : } + onClick={handleClick('prev')} + /> + + {showCurrent && ( + } + onClick={handleClick('current')} + /> + )} + + : } + onClick={handleClick('next')} + /> +
+ ); +}; + +export default PaginationMini; diff --git a/src/pagination/defaultProps.ts b/src/pagination/defaultProps.ts new file mode 100644 index 00000000..bfa4eb59 --- /dev/null +++ b/src/pagination/defaultProps.ts @@ -0,0 +1,8 @@ +import { TdPaginationMiniProps } from './type'; + +export const paginationMiniDefaultProps: TdPaginationMiniProps = { + layout: 'horizontal', + showCurrent: true, + size: 'medium', + variant: 'text', +}; diff --git a/src/pagination/type.ts b/src/pagination/type.ts new file mode 100644 index 00000000..b01e2c17 --- /dev/null +++ b/src/pagination/type.ts @@ -0,0 +1,25 @@ +import { SizeEnum } from '../common'; + +export type JumperTrigger = 'prev' | 'current' | 'next'; + +export interface JumperDisabledConfig { + prev?: boolean; + current?: boolean; + next?: boolean; +} + +export interface JumperTipsConfig { + prev?: string; + current?: string; + next?: string; +} + +export interface TdPaginationMiniProps { + disabled?: boolean | JumperDisabledConfig; + layout?: 'horizontal' | 'vertical'; + showCurrent?: boolean; + size?: SizeEnum; + tips?: boolean | JumperTipsConfig; + variant?: 'text' | 'outline'; + onChange?: (context: { e: MouseEvent; trigger: JumperTrigger }) => void; +} diff --git a/src/range-input/RangeInput.tsx b/src/range-input/RangeInput.tsx index 87f1e150..6d65adb1 100644 --- a/src/range-input/RangeInput.tsx +++ b/src/range-input/RangeInput.tsx @@ -5,6 +5,8 @@ import './RangeInputInner'; import { bind, classNames, Component, signal, tag } from 'omi'; import { getClassPrefix } from '../_util/classname'; +import { setExportparts } from '../_util/dom'; +import parseTNode from '../_util/parseTNode'; import { StyledProps } from '../common'; import { RangeInputPosition, RangeInputValue, TdRangeInputProps } from './type'; @@ -27,6 +29,11 @@ export default class RangeInput extends Component { .${getClassPrefix()}-range-input__suffix-clear.${getClassPrefix()}-icon { display: flex; } + + .${getClassPrefix()}-range-input.${getClassPrefix()}-is-focused .${getClassPrefix()}-range-input__suffix .t-icon-time, + .${getClassPrefix()}-range-input.${getClassPrefix()}-is-focused .${getClassPrefix()}-range-input__suffix .t-icon-calendar { + color: var(--td-brand-color); + } `; static defaultProps: TdRangeInputProps = { @@ -70,6 +77,10 @@ export default class RangeInput extends Component { this.addEventListener('mouseleave', this.handleMouseLeave); } + ready() { + setExportparts(this); + } + uninstalled() { this.removeEventListener('mouseenter', this.handleMouseEnter); this.removeEventListener('mouseleave', this.handleMouseLeave); @@ -150,6 +161,7 @@ export default class RangeInput extends Component { readonly, format, clearable, + suffixIcon, innerClass, innerStyle, value = this.innerValue, @@ -161,16 +173,22 @@ export default class RangeInput extends Component { const [firstFormat, secondFormat] = calcArrayValue(format); const showClearIcon = clearable && value?.length && !disabled && this.isHover.value; - const suffixIconContent = showClearIcon ? ( - { - this.handleChange(undefined, 'clear', ''); - }} - > - - - ) : null; + let suffixIconContent = null; + if (showClearIcon) { + suffixIconContent = ( + { + this.handleChange(undefined, 'clear', ''); + }} + > + + + ); + } else if (suffixIcon) { + const iconNode = parseTNode(suffixIcon); + suffixIconContent = iconNode ? {iconNode} : null; + } return (
{ this.innerInputValue = inputValue !== undefined ? inputValue : defaultInputValue; } + ready() { + setExportparts(this); + } + receiveProps(nextProps: { inputValue: any; visible?: boolean }) { // 受控场景下,同步外部值到内部缓存 if (nextProps.inputValue !== undefined) { @@ -63,6 +68,9 @@ export default class RangeInputPopup extends Component { className, style, innerStyle, + autoWidth, + clearable, + suffixIcon, } = props; const { tOverlayInnerStyle, innerPopupVisible, onInnerPopupVisibleChange } = useOverlayInnerStyle( @@ -84,16 +92,19 @@ export default class RangeInputPopup extends Component { // 计算 panel 宽度,支持自定义或和输入框宽度保持一致 const overlayInnerStyle = (triggerEl: HTMLElement, popupEl: HTMLElement) => { - if (!this.cachedOverlayWidth && triggerEl) { - const { width } = triggerEl.getBoundingClientRect(); - if (Number.isFinite(width) && width > 0) { - this.cachedOverlayWidth = `${Math.round(width)}px`; + // 如果设置了autoWidth,则不强制宽度与输入框一致 + if (!autoWidth) { + if (!this.cachedOverlayWidth && triggerEl) { + const { width } = triggerEl.getBoundingClientRect(); + if (Number.isFinite(width) && width > 0) { + this.cachedOverlayWidth = `${Math.round(width)}px`; + } } - } - if (triggerEl && !this.lockedTriggerElement) { - this.lockedTriggerElement = triggerEl; - this.lockedTriggerOriginalWidth = triggerEl.style.width; + if (triggerEl && !this.lockedTriggerElement) { + this.lockedTriggerElement = triggerEl; + this.lockedTriggerOriginalWidth = triggerEl.style.width; + } } const baseStyle = tOverlayInnerStyle?.() || {}; @@ -109,11 +120,11 @@ export default class RangeInputPopup extends Component { marginTop: '16px', } as Record; - if (this.cachedOverlayWidth) { + if (!autoWidth && this.cachedOverlayWidth) { merged.width = this.cachedOverlayWidth; } - if (this.lockedTriggerElement && this.cachedOverlayWidth) { + if (!autoWidth && this.lockedTriggerElement && this.cachedOverlayWidth) { this.lockedTriggerElement.style.width = this.cachedOverlayWidth; } @@ -150,6 +161,8 @@ export default class RangeInputPopup extends Component { value={value} onChange={this.handleRangeInputChange} readonly={readonly} + clearable={clearable} + suffixIcon={suffixIcon} {...rangeInputConfig} /> diff --git a/src/range-input/type.ts b/src/range-input/type.ts index 7e706aa1..dfe3a55a 100644 --- a/src/range-input/type.ts +++ b/src/range-input/type.ts @@ -30,6 +30,10 @@ export interface TdRangeInputProps { * @default false */ readonly?: boolean; + /** + * 后缀图标 + */ + suffixIcon?: TNode; /** * 范围分隔符 * @default '-' @@ -120,6 +124,11 @@ export interface TdRangeInputPopupProps { * @default false */ autoWidth?: boolean; + /** + * 是否可清空 + * @default false + */ + clearable?: boolean; /** * 是否禁用范围输入框,值为数组表示可分别控制某一个输入框是否禁用 */ @@ -162,6 +171,10 @@ export interface TdRangeInputPopupProps { * @default default */ status?: 'default' | 'success' | 'warning' | 'error'; + /** + * 后缀图标 + */ + suffixIcon?: TNode; /** * 输入框下方提示文本,会根据不同的 `status` 呈现不同的样式 */ diff --git a/src/select-input/SelectInput.tsx b/src/select-input/SelectInput.tsx index e2051a00..e4f67560 100644 --- a/src/select-input/SelectInput.tsx +++ b/src/select-input/SelectInput.tsx @@ -8,6 +8,7 @@ import { pick } from 'lodash-es'; import { Component, createRef, OmiProps, tag } from 'omi'; import { getClassPrefix } from '../_util/classname'; +import { setExportparts } from '../_util/dom'; import { StyledProps } from '../common'; import { PopupVisibleChangeContext } from '../popup'; import { selectInputDefaultProps } from './defaultProps'; @@ -139,6 +140,10 @@ class SelectInput extends Component { } } + ready() { + setExportparts(this); + } + updateValue = (val, key: 'multipleInputValue' | 'singleInputValue') => { this[key] = val; };