From ae36a85fab2991193c91502095e6df70d91f2fe9 Mon Sep 17 00:00:00 2001 From: wangdicoder Date: Fri, 3 Apr 2026 22:05:11 +1100 Subject: [PATCH 1/7] feat(react): calendar enhancements and date-picker range mode Calendar: range selection, multiple selection, keyboard navigation, week numbers, valid range, dot indicators, decade panel, card mode. DatePicker: range mode with date range selection and preview highlighting. --- .../calendar-datepicker-enhancements.md | 5 + .../__snapshots__/calendar.test.tsx.snap | 169 ++++++ .../src/calendar/__tests__/calendar.test.tsx | 178 +++++- packages/react/src/calendar/calendar.tsx | 557 ++++++++++++++++-- packages/react/src/calendar/demo/CardMode.tsx | 23 + .../react/src/calendar/demo/DotIndicator.tsx | 23 + .../src/calendar/demo/MultipleSelection.tsx | 22 + .../src/calendar/demo/RangeSelection.tsx | 22 + .../react/src/calendar/demo/ValidRange.tsx | 20 + .../react/src/calendar/demo/WeekNumber.tsx | 16 + packages/react/src/calendar/index.md | 144 ++++- packages/react/src/calendar/index.zh_CN.md | 144 ++++- packages/react/src/calendar/style/_index.scss | 175 +++++- packages/react/src/calendar/types.ts | 58 +- .../__tests__/date-picker.test.tsx | 23 + .../react/src/date-picker/date-picker.tsx | 127 +++- packages/react/src/date-picker/demo/Range.tsx | 22 + .../src/date-picker/demo/RangeDisabled.tsx | 20 + packages/react/src/date-picker/index.md | 31 +- packages/react/src/date-picker/index.zh_CN.md | 31 +- packages/react/src/date-picker/picker-day.tsx | 50 +- .../react/src/date-picker/style/_index.scss | 31 + packages/react/src/date-picker/types.ts | 10 +- packages/react/src/date-picker/utils.ts | 32 +- 24 files changed, 1798 insertions(+), 135 deletions(-) create mode 100644 .changeset/calendar-datepicker-enhancements.md create mode 100644 packages/react/src/calendar/demo/CardMode.tsx create mode 100644 packages/react/src/calendar/demo/DotIndicator.tsx create mode 100644 packages/react/src/calendar/demo/MultipleSelection.tsx create mode 100644 packages/react/src/calendar/demo/RangeSelection.tsx create mode 100644 packages/react/src/calendar/demo/ValidRange.tsx create mode 100644 packages/react/src/calendar/demo/WeekNumber.tsx create mode 100644 packages/react/src/date-picker/demo/Range.tsx create mode 100644 packages/react/src/date-picker/demo/RangeDisabled.tsx diff --git a/.changeset/calendar-datepicker-enhancements.md b/.changeset/calendar-datepicker-enhancements.md new file mode 100644 index 00000000..96891f0d --- /dev/null +++ b/.changeset/calendar-datepicker-enhancements.md @@ -0,0 +1,5 @@ +--- +"@tiny-design/react": minor +--- + +Calendar: add range selection, multiple selection, keyboard navigation, week numbers, valid range, dot indicators, decade panel, and card mode. DatePicker: add range mode with date range selection and preview highlighting. diff --git a/packages/react/src/calendar/__tests__/__snapshots__/calendar.test.tsx.snap b/packages/react/src/calendar/__tests__/__snapshots__/calendar.test.tsx.snap index 4b8bc3e5..1ce3d3bd 100644 --- a/packages/react/src/calendar/__tests__/__snapshots__/calendar.test.tsx.snap +++ b/packages/react/src/calendar/__tests__/__snapshots__/calendar.test.tsx.snap @@ -4,6 +4,7 @@ exports[` should match the snapshot 1`] = `
should match the snapshot 1`] = `
should match the snapshot 1`] = `
should match the snapshot 1`] = `
should match the snapshot 1`] = `
should match the snapshot 1`] = `
should match the snapshot 1`] = `
should match the snapshot 1`] = `
should match the snapshot 1`] = `
should match the snapshot 1`] = `
should match the snapshot 1`] = `
should match the snapshot 1`] = `
should match the snapshot 1`] = `
should match the snapshot 1`] = `
should match the snapshot 1`] = `
should match the snapshot 1`] = `
should match the snapshot 1`] = `
should match the snapshot 1`] = `
should match the snapshot 1`] = `
should match the snapshot 1`] = `
should match the snapshot 1`] = `
should match the snapshot 1`] = `
should match the snapshot 1`] = `
should match the snapshot 1`] = `
should match the snapshot 1`] = `
should match the snapshot 1`] = `
should match the snapshot 1`] = `
should match the snapshot 1`] = `
should match the snapshot 1`] = `
should match the snapshot 1`] = `
should match the snapshot 1`] = `
should match the snapshot 1`] = `
should match the snapshot 1`] = `
should match the snapshot 1`] = `
should match the snapshot 1`] = `
should match the snapshot 1`] = `
should match the snapshot 1`] = `
should match the snapshot 1`] = `
should match the snapshot 1`] = `
should match the snapshot 1`] = `
should match the snapshot 1`] = `
should match the snapshot 1`] = `
should match the snapshot 1`] = `
', () => { + // ── Existing tests ────────────────────────────────────────────────────── + it('should match the snapshot', () => { const { asFragment } = render(); expect(asFragment()).toMatchSnapshot(); @@ -40,10 +42,182 @@ describe('', () => { }); it('should switch to year mode', () => { - const { container, getByText } = render(); + const { container } = render(); const titleBtn = container.querySelector('.ty-calendar__title-btn'); fireEvent.click(titleBtn!); - // Should show months expect(container.querySelector('.ty-calendar__months')).toBeTruthy(); }); + + // ── weekStartsOn ─────────────────────────────────────────────────────── + + it('should start weeks on Monday when weekStartsOn={1}', () => { + const { container } = render(); + const headers = container.querySelectorAll('.ty-calendar__cell-header'); + expect(headers[0].textContent).toBe('Mo'); + expect(headers[6].textContent).toBe('Su'); + }); + + // ── showWeekNumber ───────────────────────────────────────────────────── + + it('should render week numbers when showWeekNumber is true', () => { + const { container } = render(); + const weekNumCells = container.querySelectorAll('.ty-calendar__week-number'); + expect(weekNumCells.length).toBeGreaterThan(0); + // Week number header + const weekNumHeader = container.querySelector('.ty-calendar__week-number-header'); + expect(weekNumHeader).toBeTruthy(); + expect(weekNumHeader!.textContent).toBe('#'); + }); + + // ── Range selection ──────────────────────────────────────────────────── + + it('should support range selection mode', () => { + const onRangeChange = jest.fn(); + const { container } = render( + + ); + const cells = container.querySelectorAll('.ty-calendar__cell_in-view'); + // Click first date to start range + fireEvent.click(cells[4]); + // Click second date to end range + fireEvent.click(cells[10]); + expect(onRangeChange).toHaveBeenCalledTimes(1); + const args = onRangeChange.mock.calls[0][0] as [Date, Date]; + expect(args[0]).toBeInstanceOf(Date); + expect(args[1]).toBeInstanceOf(Date); + }); + + // ── Multiple selection ───────────────────────────────────────────────── + + it('should support multiple selection mode', () => { + const onMultipleChange = jest.fn(); + const { container } = render( + + ); + const cells = container.querySelectorAll('.ty-calendar__cell_in-view'); + fireEvent.click(cells[2]); + expect(onMultipleChange).toHaveBeenCalledTimes(1); + expect(onMultipleChange.mock.calls[0][0]).toHaveLength(1); + fireEvent.click(cells[5]); + expect(onMultipleChange).toHaveBeenCalledTimes(2); + expect(onMultipleChange.mock.calls[1][0]).toHaveLength(2); + }); + + // ── showToday ────────────────────────────────────────────────────────── + + it('should render Today button when showToday is true', () => { + const { container } = render(); + const todayBtn = container.querySelector('.ty-calendar__today-btn'); + expect(todayBtn).toBeTruthy(); + expect(todayBtn!.textContent).toBe('Today'); + }); + + // ── Decade panel ─────────────────────────────────────────────────────── + + it('should navigate to decade panel', () => { + const { container } = render(); + const titleBtn = container.querySelector('.ty-calendar__title-btn'); + // month → year + fireEvent.click(titleBtn!); + expect(container.querySelector('.ty-calendar__months')).toBeTruthy(); + // year → decade + const titleBtn2 = container.querySelector('.ty-calendar__title-btn'); + fireEvent.click(titleBtn2!); + expect(container.querySelector('.ty-calendar__decades')).toBeTruthy(); + const decadeCells = container.querySelectorAll('.ty-calendar__decade-cell'); + expect(decadeCells.length).toBe(12); + }); + + // ── validRange ───────────────────────────────────────────────────────── + + it('should disable dates outside validRange', () => { + const min = new Date(2024, 0, 10); + const max = new Date(2024, 0, 20); + const { container } = render(); + const disabledCells = container.querySelectorAll('.ty-calendar__cell_disabled'); + expect(disabledCells.length).toBeGreaterThan(0); + }); + + it('should disable nav buttons at validRange boundaries', () => { + const min = new Date(2024, 0, 1); + const max = new Date(2024, 0, 31); + const { container } = render(); + const navBtns = container.querySelectorAll('.ty-calendar__nav-btn'); + // Both prev and next should be disabled since we can only view Jan 2024 + expect(navBtns[0]).toBeDisabled(); + expect(navBtns[1]).toBeDisabled(); + }); + + // ── Keyboard navigation ──────────────────────────────────────────────── + + it('should handle keyboard navigation', () => { + const onChange = jest.fn(); + const { container } = render(); + const calendarEl = container.firstChild as HTMLElement; + fireEvent.keyDown(calendarEl, { key: 'ArrowRight' }); + // Check that a cell is now focused + const focused = container.querySelector('.ty-calendar__cell_focused'); + expect(focused).toBeTruthy(); + // Press Enter to select + fireEvent.keyDown(calendarEl, { key: 'Enter' }); + expect(onChange).toHaveBeenCalled(); + }); + + // ── cellClassName ────────────────────────────────────────────────────── + + it('should apply cellClassName to cells', () => { + const { container } = render( + (date.getDay() === 0 ? 'sunday' : undefined)} + /> + ); + const sundayCells = container.querySelectorAll('.sunday'); + expect(sundayCells.length).toBeGreaterThan(0); + }); + + // ── dotRender ────────────────────────────────────────────────────────── + + it('should render dot indicators', () => { + const { container } = render( + date.getDate() === 15} + /> + ); + const dots = container.querySelectorAll('.ty-calendar__cell-dot'); + expect(dots.length).toBeGreaterThan(0); + }); + + it('should render dot with custom color', () => { + const { container } = render( + (date.getDate() === 15 ? '#ff0000' : false)} + /> + ); + const dot = container.querySelector('.ty-calendar__cell-dot') as HTMLElement; + expect(dot).toBeTruthy(); + expect(dot.style.backgroundColor).toBe('rgb(255, 0, 0)'); + }); + + // ── onMonthChange / onYearChange ─────────────────────────────────────── + + it('should fire onMonthChange when navigating months', () => { + const onMonthChange = jest.fn(); + const { container } = render( + + ); + const navBtns = container.querySelectorAll('.ty-calendar__nav-btn'); + fireEvent.click(navBtns[1]); // next + expect(onMonthChange).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/react/src/calendar/calendar.tsx b/packages/react/src/calendar/calendar.tsx index 7239995c..46c5ae9d 100755 --- a/packages/react/src/calendar/calendar.tsx +++ b/packages/react/src/calendar/calendar.tsx @@ -1,12 +1,14 @@ -import React, { useState, useEffect, useContext } from 'react'; +import React, { useState, useEffect, useContext, useCallback, useRef } from 'react'; import classNames from 'classnames'; import { ConfigContext } from '../config-provider/config-context'; import { getPrefixCls } from '../_utils/general'; import { useLocale } from '../_utils/use-locale'; import { getMonthDaysArray, + getISOWeekNumber, isSameDate, isToday, + compareDate, getPrevMonthDate, getNextMonthDate, getPrevYearDate, @@ -14,16 +16,27 @@ import { } from '../date-picker/utils'; import { CalendarProps, CalendarMode } from './types'; -const MONTH_NAMES_EN = [ - 'January', 'February', 'March', 'April', 'May', 'June', - 'July', 'August', 'September', 'October', 'November', 'December', -]; +// ─── helpers ──────────────────────────────────────────────────────────────── + +const dateKey = (d: Date) => `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`; + +const getIntlMonthName = (date: Date, locale: string, style: 'long' | 'short' = 'long') => { + try { + return new Intl.DateTimeFormat(locale, { month: style }).format(date); + } catch { + return ''; + } +}; + +const DEFAULT_WEEKS_EN = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']; + +// ─── component ────────────────────────────────────────────────────────────── const Calendar = React.forwardRef((props, ref) => { const { defaultValue, fullscreen = true, - disabledDate, + disabledDate: disabledDateProp, dateCellRender, monthCellRender, headerRender, @@ -36,12 +49,32 @@ const Calendar = React.forwardRef((props, ref) => onPanelChange, value: _value, mode: _mode, + // NEW PROPS + selectionMode = 'single', + rangeValue: _rangeValue, + defaultRangeValue, + onRangeChange, + multipleValue: _multipleValue, + defaultMultipleValue, + onMultipleChange, + weekStartsOn = 0, + showWeekNumber = false, + showToday = false, + validRange, + cellClassName, + cellStyle: cellStyleProp, + dotRender, + onMonthChange, + onYearChange, ...otherProps } = props; const configContext = useContext(ConfigContext); const prefixCls = getPrefixCls('calendar', configContext.prefixCls, customisedCls); const locale = useLocale(); + const calendarRef = useRef(null); + + // ── state ────────────────────────────────────────────────────────────────── const [selectedDate, setSelectedDate] = useState( 'value' in props ? (props.value as Date) : (defaultValue ?? new Date()) @@ -51,6 +84,29 @@ const Calendar = React.forwardRef((props, ref) => 'mode' in props ? (props.mode as CalendarMode) : defaultMode ); + // Range selection state + const [rangeStart, setRangeStart] = useState( + _rangeValue?.[0] ?? defaultRangeValue?.[0] ?? null + ); + const [rangeEnd, setRangeEnd] = useState( + _rangeValue?.[1] ?? defaultRangeValue?.[1] ?? null + ); + const [rangeHover, setRangeHover] = useState(null); + + // Multiple selection state + const [multipleSelected, setMultipleSelected] = useState>(() => { + const initial = _multipleValue ?? defaultMultipleValue ?? []; + const map = new Map(); + initial.forEach((d) => map.set(dateKey(d), d)); + return map; + }); + + // Keyboard focused cell + const [focusedDate, setFocusedDate] = useState(null); + + + // ── sync controlled props ────────────────────────────────────────────────── + useEffect(() => { if ('value' in props && props.value) { setSelectedDate(props.value); @@ -59,24 +115,147 @@ const Calendar = React.forwardRef((props, ref) => }, [props.value]); useEffect(() => { - if ('mode' in props) { - setMode(props.mode as CalendarMode); - } + if ('mode' in props) setMode(props.mode as CalendarMode); }, [props.mode]); - const weeks = locale?.DatePicker?.weeks ?? ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']; - const months = locale?.DatePicker?.months ?? [ + useEffect(() => { + if (_rangeValue !== undefined) { + setRangeStart(_rangeValue?.[0] ?? null); + setRangeEnd(_rangeValue?.[1] ?? null); + } + }, [_rangeValue]); + + useEffect(() => { + if (_multipleValue !== undefined) { + const map = new Map(); + (_multipleValue ?? []).forEach((d) => map.set(dateKey(d), d)); + setMultipleSelected(map); + } + }, [_multipleValue]); + + // Sync DOM focus with focusedDate so :focus-visible tracks correctly + useEffect(() => { + if (focusedDate) { + const key = dateKey(focusedDate); + const cell = calendarRef.current?.querySelector(`[data-date-key="${key}"]`); + cell?.focus({ preventScroll: true }); + } else { + // Return focus to the calendar container when focus is cleared + calendarRef.current?.focus({ preventScroll: true }); + } + }, [focusedDate]); + + // ── locale ───────────────────────────────────────────────────────────────── + + const localeWeeks = locale?.DatePicker?.weeks ?? DEFAULT_WEEKS_EN; + const localeMonths = locale?.DatePicker?.months ?? [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', ]; + // Rotate weeks array based on weekStartsOn + const weeks = [...localeWeeks.slice(weekStartsOn), ...localeWeeks.slice(0, weekStartsOn)]; + const months = localeMonths; + + // Intl-based full month name + const getFullMonthName = (date: Date): string => { + const intlLocale = locale?.locale === 'zh_CN' ? 'zh-CN' : 'en-US'; + const name = getIntlMonthName(date, intlLocale, 'long'); + return name || months[date.getMonth()]; + }; + + // ── validRange + disabledDate ────────────────────────────────────────────── + + const disabledDate = useCallback( + (date: Date): boolean => { + if (validRange) { + if (compareDate(date, validRange[0]) < 0 || compareDate(date, validRange[1]) > 0) { + return true; + } + } + return disabledDateProp?.(date) ?? false; + }, + [validRange, disabledDateProp] + ); + + const canGoPrev = (): boolean => { + if (!validRange) return true; + if (mode === 'month') { + const prev = getPrevMonthDate(panelDate); + return prev.getFullYear() > validRange[0].getFullYear() || + (prev.getFullYear() === validRange[0].getFullYear() && prev.getMonth() >= validRange[0].getMonth()); + } + if (mode === 'year') { + return panelDate.getFullYear() - 1 >= validRange[0].getFullYear(); + } + // decade + const decadeStart = Math.floor(panelDate.getFullYear() / 10) * 10 - 10; + return decadeStart + 11 >= validRange[0].getFullYear(); + }; + + const canGoNext = (): boolean => { + if (!validRange) return true; + if (mode === 'month') { + const next = getNextMonthDate(panelDate); + return next.getFullYear() < validRange[1].getFullYear() || + (next.getFullYear() === validRange[1].getFullYear() && next.getMonth() <= validRange[1].getMonth()); + } + if (mode === 'year') { + return panelDate.getFullYear() + 1 <= validRange[1].getFullYear(); + } + const decadeStart = Math.floor(panelDate.getFullYear() / 10) * 10 + 10; + return decadeStart <= validRange[1].getFullYear(); + }; + + // ── CSS ──────────────────────────────────────────────────────────────────── + const cls = classNames(prefixCls, className, { [`${prefixCls}_fullscreen`]: fullscreen, [`${prefixCls}_card`]: !fullscreen, }); + // ── handlers ─────────────────────────────────────────────────────────────── + const handleDateSelect = (date: Date) => { - if (disabledDate?.(date)) return; + if (disabledDate(date)) return; + + if (selectionMode === 'range') { + if (!rangeStart || (rangeStart && rangeEnd)) { + // Start new range + setRangeStart(date); + setRangeEnd(null); + setRangeHover(null); + } else { + // Complete range + const [start, end] = compareDate(date, rangeStart) >= 0 + ? [rangeStart, date] + : [date, rangeStart]; + setRangeStart(start); + setRangeEnd(end); + setRangeHover(null); + onRangeChange?.([start, end]); + } + setPanelDate(date); + onSelect?.(date); + return; + } + + if (selectionMode === 'multiple') { + const key = dateKey(date); + const newMap = new Map(multipleSelected); + if (newMap.has(key)) { + newMap.delete(key); + } else { + newMap.set(key, date); + } + setMultipleSelected(newMap); + onMultipleChange?.(Array.from(newMap.values())); + setPanelDate(date); + onSelect?.(date); + return; + } + + // Single selection if (!('value' in props)) { setSelectedDate(date); } @@ -100,20 +279,168 @@ const Calendar = React.forwardRef((props, ref) => const handleMonthSelect = (month: number) => { const newDate = new Date(panelDate.getFullYear(), month, 1); - handleDateSelect(newDate); + setPanelDate(newDate); handleModeChange('month'); + onMonthChange?.(newDate); + }; + + const handleYearSelect = (year: number) => { + const newDate = new Date(year, panelDate.getMonth(), 1); + setPanelDate(newDate); + handleModeChange('year'); + onYearChange?.(newDate); }; const goPrev = () => { - const newDate = mode === 'month' ? getPrevMonthDate(panelDate) : getPrevYearDate(panelDate); + if (!canGoPrev()) return; + setFocusedDate(null); + let newDate: Date; + if (mode === 'month') { + newDate = getPrevMonthDate(panelDate); + onMonthChange?.(newDate); + } else if (mode === 'year') { + newDate = getPrevYearDate(panelDate); + onYearChange?.(newDate); + } else { + // decade + newDate = new Date(panelDate.getFullYear() - 10, panelDate.getMonth(), 1); + } + handlePanelChange(newDate); }; const goNext = () => { - const newDate = mode === 'month' ? getNextMonthDate(panelDate) : getNextYearDate(panelDate); + if (!canGoNext()) return; + setFocusedDate(null); + let newDate: Date; + if (mode === 'month') { + newDate = getNextMonthDate(panelDate); + onMonthChange?.(newDate); + } else if (mode === 'year') { + newDate = getNextYearDate(panelDate); + onYearChange?.(newDate); + } else { + newDate = new Date(panelDate.getFullYear() + 10, panelDate.getMonth(), 1); + } + handlePanelChange(newDate); }; + const goToday = () => { + const today = new Date(); + if (disabledDate(today)) return; + setPanelDate(today); + if (selectionMode === 'single') { + if (!('value' in props)) setSelectedDate(today); + onChange?.(today); + } + onPanelChange?.(today, mode); + }; + + // ── Range helpers ────────────────────────────────────────────────────────── + + const isInRange = (date: Date): boolean => { + if (selectionMode !== 'range') return false; + const start = rangeStart; + const end = rangeEnd ?? rangeHover; + if (!start || !end) return false; + const [lo, hi] = compareDate(start, end) <= 0 ? [start, end] : [end, start]; + return compareDate(date, lo) > 0 && compareDate(date, hi) < 0; + }; + + const isRangeStart = (date: Date): boolean => { + if (selectionMode !== 'range' || !rangeStart) return false; + const end = rangeEnd ?? rangeHover; + if (!end) return isSameDate(date, rangeStart); + return compareDate(rangeStart, end) <= 0 + ? isSameDate(date, rangeStart) + : isSameDate(date, end); + }; + + const isRangeEnd = (date: Date): boolean => { + if (selectionMode !== 'range') return false; + const end = rangeEnd ?? rangeHover; + if (!end || !rangeStart) return false; + return compareDate(rangeStart, end) <= 0 + ? isSameDate(date, end) + : isSameDate(date, rangeStart); + }; + + const isDateSelected = (date: Date): boolean => { + if (selectionMode === 'multiple') { + return multipleSelected.has(dateKey(date)); + } + if (selectionMode === 'range') { + return isRangeStart(date) || isRangeEnd(date); + } + return isSameDate(selectedDate, date); + }; + + // ── keyboard ─────────────────────────────────────────────────────────────── + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (mode !== 'month') return; + const base = focusedDate ?? selectedDate; + let next: Date | null = null; + + switch (e.key) { + case 'ArrowLeft': + next = new Date(base.getFullYear(), base.getMonth(), base.getDate() - 1); + break; + case 'ArrowRight': + next = new Date(base.getFullYear(), base.getMonth(), base.getDate() + 1); + break; + case 'ArrowUp': + next = new Date(base.getFullYear(), base.getMonth(), base.getDate() - 7); + break; + case 'ArrowDown': + next = new Date(base.getFullYear(), base.getMonth(), base.getDate() + 7); + break; + case 'Enter': + handleDateSelect(base); + setFocusedDate(null); + e.preventDefault(); + return; + case 'Escape': + setFocusedDate(null); + return; + default: + return; + } + + if (next) { + e.preventDefault(); + if (!disabledDate(next)) { + setFocusedDate(next); + // If focused date leaves current month, navigate + if (next.getMonth() !== panelDate.getMonth() || next.getFullYear() !== panelDate.getFullYear()) { + setPanelDate(next); + } + } + } + }; + + // ── header title ─────────────────────────────────────────────────────────── + + const getTitleText = (): string => { + const year = panelDate.getFullYear(); + if (mode === 'decade') { + const decadeStart = Math.floor(year / 10) * 10; + return `${decadeStart} – ${decadeStart + 9}`; + } + if (mode === 'year') return `${year}`; + const monthName = getFullMonthName(panelDate); + return `${monthName} ${year}`; + }; + + const handleTitleClick = () => { + if (mode === 'month') handleModeChange('year'); + else if (mode === 'year') handleModeChange('decade'); + else handleModeChange('month'); + }; + + // ── render header ────────────────────────────────────────────────────────── + const renderHeader = () => { if (headerRender) { return headerRender({ @@ -124,40 +451,66 @@ const Calendar = React.forwardRef((props, ref) => }); } - const year = panelDate.getFullYear(); - const month = panelDate.getMonth(); - const monthName = locale?.locale === 'zh_CN' - ? `${months[month]}` - : MONTH_NAMES_EN[month]; - return (
- + {showToday && ( + + )} -
); }; + // ── render month panel ───────────────────────────────────────────────────── + const renderMonthPanel = () => { - const panelDays = getMonthDaysArray(panelDate); + const panelDays = getMonthDaysArray(panelDate, weekStartsOn); return ( + {showWeekNumber && ( + + )} {weeks.map((week, i) => ( - {Array.from({ length: panelDays.length / 7 }, (_, row) => ( - - {panelDays.slice(row * 7, row * 7 + 7).map((dayCell, col) => { - const isDisabled = disabledDate?.(dayCell.date) ?? false; - const cellCls = classNames(`${prefixCls}__cell`, { - [`${prefixCls}__cell_in-view`]: dayCell.isThisMonth, - [`${prefixCls}__cell_today`]: isToday(dayCell.date), - [`${prefixCls}__cell_selected`]: isSameDate(selectedDate, dayCell.date), - [`${prefixCls}__cell_disabled`]: isDisabled, - }); - return ( - + {showWeekNumber && ( + - ); - })} - - ))} + )} + {rowDays.map((dayCell, col) => { + const isDisabled = disabledDate(dayCell.date); + const selected = isDateSelected(dayCell.date); + const focused = focusedDate && isSameDate(focusedDate, dayCell.date); + const dot = dotRender?.(dayCell.date); + const customCls = cellClassName?.(dayCell.date); + const customStyle = cellStyleProp?.(dayCell.date); + + const cellCls = classNames(`${prefixCls}__cell`, customCls, { + [`${prefixCls}__cell_in-view`]: dayCell.isThisMonth, + [`${prefixCls}__cell_today`]: isToday(dayCell.date), + [`${prefixCls}__cell_selected`]: selected, + [`${prefixCls}__cell_disabled`]: isDisabled, + [`${prefixCls}__cell_focused`]: focused, + [`${prefixCls}__cell_in-range`]: isInRange(dayCell.date), + [`${prefixCls}__cell_range-start`]: isRangeStart(dayCell.date), + [`${prefixCls}__cell_range-end`]: isRangeEnd(dayCell.date), + }); + + return ( + + ); + })} + + ); + })}
+ # + {week} @@ -166,41 +519,80 @@ const Calendar = React.forwardRef((props, ref) =>
handleDateSelect(dayCell.date)} - role="gridcell" - > -
- {dayCell.label} - {dateCellRender && dayCell.isThisMonth && ( -
- {dateCellRender(dayCell.date)} -
- )} -
+ {Array.from({ length: panelDays.length / 7 }, (_, row) => { + const rowDays = panelDays.slice(row * 7, row * 7 + 7); + const weekNum = getISOWeekNumber(rowDays[0].date); + + return ( +
+ {weekNum}
handleDateSelect(dayCell.date)} + onMouseEnter={() => { + if (selectionMode === 'range' && rangeStart && !rangeEnd) { + setRangeHover(dayCell.date); + } + }} + role="gridcell" + tabIndex={focused ? 0 : -1} + aria-selected={selected} + aria-disabled={isDisabled} + > +
+ {dayCell.label} + {dot && ( + + )} + {dateCellRender && dayCell.isThisMonth && ( +
+ {dateCellRender(dayCell.date)} +
+ )} +
+
); }; + // ── render year panel ────────────────────────────────────────────────────── + const renderYearPanel = () => { return (
@@ -231,11 +623,62 @@ const Calendar = React.forwardRef((props, ref) => ); }; + // ── render decade panel ──────────────────────────────────────────────────── + + const renderDecadePanel = () => { + const currentYear = panelDate.getFullYear(); + const decadeStart = Math.floor(currentYear / 10) * 10; + const years = Array.from({ length: 12 }, (_, i) => decadeStart - 1 + i); + + return ( +
+ {years.map((year) => { + const isOutOfRange = year < decadeStart || year > decadeStart + 9; + const isCurrentYear = year === currentYear; + const cellCls = classNames(`${prefixCls}__decade-cell`, { + [`${prefixCls}__decade-cell_selected`]: isCurrentYear, + [`${prefixCls}__decade-cell_out`]: isOutOfRange, + }); + return ( +
handleYearSelect(year)} + > + {year} +
+ ); + })} +
+ ); + }; + + // ── render body ──────────────────────────────────────────────────────────── + + const renderBody = () => { + if (mode === 'decade') return renderDecadePanel(); + if (mode === 'year') return renderYearPanel(); + return renderMonthPanel(); + }; + + // ── main render ──────────────────────────────────────────────────────────── + return ( -
+
{ + (calendarRef as React.MutableRefObject).current = node; + if (typeof ref === 'function') ref(node); + else if (ref) (ref as React.MutableRefObject).current = node; + }} + className={cls} + style={style} + tabIndex={0} + onKeyDown={handleKeyDown} + > {renderHeader()}
- {mode === 'month' ? renderMonthPanel() : renderYearPanel()} + {renderBody()}
); diff --git a/packages/react/src/calendar/demo/CardMode.tsx b/packages/react/src/calendar/demo/CardMode.tsx new file mode 100644 index 00000000..7d9a7c10 --- /dev/null +++ b/packages/react/src/calendar/demo/CardMode.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { Calendar } from '@tiny-design/react'; + +export default function CardModeDemo() { + const [value, setValue] = React.useState(new Date()); + + return ( +
+ +

+ Use arrow keys to navigate, Enter to select, Escape to reset focus. +

+ setValue(date)} + fullscreen={false} + cellClassName={(date) => + date.getDay() === 0 || date.getDay() === 6 ? 'weekend-cell' : undefined + } + /> +
+ ); +} diff --git a/packages/react/src/calendar/demo/DotIndicator.tsx b/packages/react/src/calendar/demo/DotIndicator.tsx new file mode 100644 index 00000000..17815e5c --- /dev/null +++ b/packages/react/src/calendar/demo/DotIndicator.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { Calendar } from '@tiny-design/react'; + +// Simulate some event dates +const eventDates = new Set([3, 7, 12, 15, 20, 25]); + +export default function DotIndicatorDemo() { + const [value, setValue] = React.useState(new Date()); + + return ( + setValue(date)} + fullscreen={false} + dotRender={(date) => { + if (eventDates.has(date.getDate())) { + return date.getDate() % 2 === 0 ? '#f56c6c' : true; + } + return false; + }} + /> + ); +} diff --git a/packages/react/src/calendar/demo/MultipleSelection.tsx b/packages/react/src/calendar/demo/MultipleSelection.tsx new file mode 100644 index 00000000..b3452985 --- /dev/null +++ b/packages/react/src/calendar/demo/MultipleSelection.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { Calendar } from '@tiny-design/react'; + +export default function MultipleSelectionDemo() { + const [dates, setDates] = React.useState([]); + + return ( +
+ + {dates.length > 0 && ( +

+ Selected: {dates.map((d) => d.toLocaleDateString()).join(', ')} +

+ )} +
+ ); +} diff --git a/packages/react/src/calendar/demo/RangeSelection.tsx b/packages/react/src/calendar/demo/RangeSelection.tsx new file mode 100644 index 00000000..ef6d8cd3 --- /dev/null +++ b/packages/react/src/calendar/demo/RangeSelection.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { Calendar } from '@tiny-design/react'; + +export default function RangeSelectionDemo() { + const [range, setRange] = React.useState<[Date, Date] | null>(null); + + return ( +
+ + {range && ( +

+ {range[0].toLocaleDateString()} → {range[1].toLocaleDateString()} +

+ )} +
+ ); +} diff --git a/packages/react/src/calendar/demo/ValidRange.tsx b/packages/react/src/calendar/demo/ValidRange.tsx new file mode 100644 index 00000000..6c4fc0d5 --- /dev/null +++ b/packages/react/src/calendar/demo/ValidRange.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { Calendar } from '@tiny-design/react'; + +export default function ValidRangeDemo() { + const [value, setValue] = React.useState(new Date()); + + const today = new Date(); + const threeMonthsLater = new Date(today.getFullYear(), today.getMonth() + 3, today.getDate()); + const validRange: [Date, Date] = [today, threeMonthsLater]; + + return ( + setValue(date)} + validRange={validRange} + showToday + fullscreen={false} + /> + ); +} diff --git a/packages/react/src/calendar/demo/WeekNumber.tsx b/packages/react/src/calendar/demo/WeekNumber.tsx new file mode 100644 index 00000000..0987143d --- /dev/null +++ b/packages/react/src/calendar/demo/WeekNumber.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { Calendar } from '@tiny-design/react'; + +export default function WeekNumberDemo() { + const [value, setValue] = React.useState(new Date()); + + return ( + setValue(date)} + showWeekNumber + weekStartsOn={1} + fullscreen={false} + /> + ); +} diff --git a/packages/react/src/calendar/index.md b/packages/react/src/calendar/index.md index 2b29a5d4..070dfaed 100644 --- a/packages/react/src/calendar/index.md +++ b/packages/react/src/calendar/index.md @@ -1,13 +1,25 @@ import BasicDemo from './demo/Basic'; import BasicSource from './demo/Basic.tsx?raw'; +import RangeSelectionDemo from './demo/RangeSelection'; +import RangeSelectionSource from './demo/RangeSelection.tsx?raw'; +import MultipleSelectionDemo from './demo/MultipleSelection'; +import MultipleSelectionSource from './demo/MultipleSelection.tsx?raw'; +import WeekNumberDemo from './demo/WeekNumber'; +import WeekNumberSource from './demo/WeekNumber.tsx?raw'; +import ValidRangeDemo from './demo/ValidRange'; +import ValidRangeSource from './demo/ValidRange.tsx?raw'; +import DotIndicatorDemo from './demo/DotIndicator'; +import DotIndicatorSource from './demo/DotIndicator.tsx?raw'; +import CardModeDemo from './demo/CardMode'; +import CardModeSource from './demo/CardMode.tsx?raw'; # Calendar -A calendar component for displaying and selecting dates. +A calendar component for displaying and selecting dates, with support for range selection, multiple selection, week numbers, keyboard navigation, and more. ## Scenario -Display a full calendar view with date selection. Useful for scheduling and date-related features. +Display a full calendar view with date selection. Useful for scheduling, event management, and date-related features. ## Usage @@ -31,18 +43,120 @@ A full-size calendar with date selection. + + + + +### Range Selection + +Select a start and end date to form a range. Click a date to start, then click another to complete the range. + + + + + + + + +### Multiple Selection + +Click dates to toggle individual selection. Great for marking availability. + + + + + + + + + + + +### Week Numbers + +Show ISO week numbers with `showWeekNumber` and use `weekStartsOn={1}` for Monday start. + + + + + + + + +### Valid Range & Today + +Restrict the calendar to a 3-month window with `validRange`. The "Today" button is shown via `showToday`. + + + + + + + + + + + +### Dot Indicators + +Use `dotRender` to display small colored dots on dates with events. Return `true` for the primary color or a color string. + + + + + + + + +### Card Mode & Keyboard + +Compact card calendar with keyboard navigation. Use arrow keys, Enter, and Escape. Uses `cellClassName` for weekend highlighting. + + + + + + + ## Props -| Property | Description | Type | Default | -| --------------- | ---------------------------------------- | -------------------------------------------- | ------- | -| value | selected date (controlled) | Date | | -| defaultValue | default selected date | Date | today | -| mode | display mode | 'month' \| 'year' | month | -| fullscreen | full-size or card mode | boolean | true | -| disabledDate | disable specific dates | (date: Date) => boolean | | -| onChange | callback when date is selected | (date: Date) => void | | -| onSelect | callback when a date cell is clicked | (date: Date) => void | | -| onPanelChange | callback when panel changes | (date: Date, mode: string) => void | | -| dateCellRender | custom render for date cell content | (date: Date) => ReactNode | | -| monthCellRender | custom render for month cell content | (date: Date) => ReactNode | | -| headerRender | custom header render | (config) => ReactNode | | \ No newline at end of file +| Property | Description | Type | Default | +| --- | --- | --- | --- | +| value | Selected date (controlled) | Date | | +| defaultValue | Default selected date | Date | today | +| mode | Display mode | 'month' \| 'year' \| 'decade' | month | +| fullscreen | Full-size or card mode | boolean | true | +| selectionMode | Selection behavior | 'single' \| 'range' \| 'multiple' | single | +| rangeValue | Controlled range (range mode) | [Date, Date] \| null | | +| defaultRangeValue | Default range value | [Date, Date] \| null | | +| onRangeChange | Range change callback | (range: [Date, Date] \| null) => void | | +| multipleValue | Controlled multi-select dates | Date[] | | +| defaultMultipleValue | Default multi-select dates | Date[] | | +| onMultipleChange | Multi-select change callback | (dates: Date[]) => void | | +| weekStartsOn | First day of week (0=Sun) | 0\|1\|2\|3\|4\|5\|6 | 0 | +| showWeekNumber | Show ISO week number column | boolean | false | +| showToday | Show "Today" button in header | boolean | false | +| validRange | Restrict navigable date range | [Date, Date] | | +| disabledDate | Disable specific dates | (date: Date) => boolean | | +| cellClassName | Per-cell custom class name | (date: Date) => string | | +| cellStyle | Per-cell custom inline style | (date: Date) => CSSProperties | | +| dotRender | Dot indicator renderer | (date: Date) => boolean \| string | | +| onChange | Callback when date is selected | (date: Date) => void | | +| onSelect | Callback when a cell is clicked | (date: Date) => void | | +| onPanelChange | Callback when panel changes | (date: Date, mode: string) => void | | +| onMonthChange | Callback on month navigation | (date: Date) => void | | +| onYearChange | Callback on year navigation | (date: Date) => void | | +| dateCellRender | Custom render for date cell | (date: Date) => ReactNode | | +| monthCellRender | Custom render for month cell | (date: Date) => ReactNode | | +| headerRender | Custom header render | (config) => ReactNode | | + +## Keyboard Navigation + +| Key | Action | +| --- | --- | +| ← | Move focus to previous day | +| → | Move focus to next day | +| ↑ | Move focus to previous week | +| ↓ | Move focus to next week | +| Enter | Select the focused date | +| Escape | Reset focus | \ No newline at end of file diff --git a/packages/react/src/calendar/index.zh_CN.md b/packages/react/src/calendar/index.zh_CN.md index 85efe107..2ff2df60 100644 --- a/packages/react/src/calendar/index.zh_CN.md +++ b/packages/react/src/calendar/index.zh_CN.md @@ -1,13 +1,25 @@ import BasicDemo from './demo/Basic'; import BasicSource from './demo/Basic.tsx?raw'; +import RangeSelectionDemo from './demo/RangeSelection'; +import RangeSelectionSource from './demo/RangeSelection.tsx?raw'; +import MultipleSelectionDemo from './demo/MultipleSelection'; +import MultipleSelectionSource from './demo/MultipleSelection.tsx?raw'; +import WeekNumberDemo from './demo/WeekNumber'; +import WeekNumberSource from './demo/WeekNumber.tsx?raw'; +import ValidRangeDemo from './demo/ValidRange'; +import ValidRangeSource from './demo/ValidRange.tsx?raw'; +import DotIndicatorDemo from './demo/DotIndicator'; +import DotIndicatorSource from './demo/DotIndicator.tsx?raw'; +import CardModeDemo from './demo/CardMode'; +import CardModeSource from './demo/CardMode.tsx?raw'; # Calendar 日历 -日历组件,用于展示和选择日期。 +日历组件,用于展示和选择日期,支持范围选择、多选、周数显示、键盘导航等功能。 ## 使用场景 -展示完整的日历视图,支持日期选择,适用于日程安排等场景。 +展示完整的日历视图,支持日期选择,适用于日程安排、事件管理等场景。 ## 使用方式 @@ -31,18 +43,120 @@ import { Calendar } from 'tiny-design'; + + + + +### 范围选择 + +点击选择起始日期,再次点击完成范围选择。 + + + + + + + + +### 多选 + +单击日期可切换选中/取消。适合标记可用日期。 + + + + + + + + + + + +### 周数显示 + +使用 `showWeekNumber` 显示 ISO 周数,`weekStartsOn={1}` 设置周一为一周的开始。 + + + + + + + + +### 有效范围 & 今天按钮 + +通过 `validRange` 限制日历在3个月范围内,`showToday` 显示"今天"按钮。 + + + + + + + + + + + +### 圆点指示器 + +使用 `dotRender` 在有事件的日期下方显示彩色圆点。返回 `true` 使用主色,也可返回颜色字符串。 + + + + + + + + +### 卡片模式 & 键盘导航 + +紧凑的卡片日历,支持键盘方向键导航。使用 `cellClassName` 自定义周末样式。 + + + + + + + ## Props -| 属性 | 说明 | 类型 | 默认值 | -| --------------- | ---------------------- | -------------------------------------------- | ------- | -| value | 选中日期(受控) | Date | | -| defaultValue | 默认选中日期 | Date | 今天 | -| mode | 显示模式 | 'month' \| 'year' | month | -| fullscreen | 全尺寸或卡片模式 | boolean | true | -| disabledDate | 禁用特定日期 | (date: Date) => boolean | | -| onChange | 日期选中回调 | (date: Date) => void | | -| onSelect | 日期单元格点击回调 | (date: Date) => void | | -| onPanelChange | 面板切换回调 | (date: Date, mode: string) => void | | -| dateCellRender | 自定义日期单元格内容 | (date: Date) => ReactNode | | -| monthCellRender | 自定义月份单元格内容 | (date: Date) => ReactNode | | -| headerRender | 自定义头部渲染 | (config) => ReactNode | | \ No newline at end of file +| 属性 | 说明 | 类型 | 默认值 | +| --- | --- | --- | --- | +| value | 选中日期(受控) | Date | | +| defaultValue | 默认选中日期 | Date | 今天 | +| mode | 显示模式 | 'month' \| 'year' \| 'decade' | month | +| fullscreen | 全尺寸或卡片模式 | boolean | true | +| selectionMode | 选择行为 | 'single' \| 'range' \| 'multiple' | single | +| rangeValue | 受控范围值 | [Date, Date] \| null | | +| defaultRangeValue | 默认范围值 | [Date, Date] \| null | | +| onRangeChange | 范围变化回调 | (range: [Date, Date] \| null) => void | | +| multipleValue | 受控多选日期 | Date[] | | +| defaultMultipleValue | 默认多选日期 | Date[] | | +| onMultipleChange | 多选变化回调 | (dates: Date[]) => void | | +| weekStartsOn | 一周的起始天(0=周日) | 0\|1\|2\|3\|4\|5\|6 | 0 | +| showWeekNumber | 显示 ISO 周数列 | boolean | false | +| showToday | 显示"今天"按钮 | boolean | false | +| validRange | 限制可导航的日期范围 | [Date, Date] | | +| disabledDate | 禁用特定日期 | (date: Date) => boolean | | +| cellClassName | 单元格自定义类名 | (date: Date) => string | | +| cellStyle | 单元格自定义样式 | (date: Date) => CSSProperties | | +| dotRender | 圆点指示器渲染 | (date: Date) => boolean \| string | | +| onChange | 日期选中回调 | (date: Date) => void | | +| onSelect | 日期单元格点击回调 | (date: Date) => void | | +| onPanelChange | 面板切换回调 | (date: Date, mode: string) => void | | +| onMonthChange | 月份导航回调 | (date: Date) => void | | +| onYearChange | 年份导航回调 | (date: Date) => void | | +| dateCellRender | 自定义日期单元格内容 | (date: Date) => ReactNode | | +| monthCellRender | 自定义月份单元格内容 | (date: Date) => ReactNode | | +| headerRender | 自定义头部渲染 | (config) => ReactNode | | + +## 键盘导航 + +| 按键 | 操作 | +| --- | --- | +| ← | 移动到前一天 | +| → | 移动到后一天 | +| ↑ | 移动到上一周 | +| ↓ | 移动到下一周 | +| Enter | 选择当前焦点日期 | +| Escape | 重置焦点 | \ No newline at end of file diff --git a/packages/react/src/calendar/style/_index.scss b/packages/react/src/calendar/style/_index.scss index 1dd0719f..d170319d 100644 --- a/packages/react/src/calendar/style/_index.scss +++ b/packages/react/src/calendar/style/_index.scss @@ -4,6 +4,7 @@ background: var(--ty-calendar-bg, #fff); border: 1px solid var(--ty-calendar-border, #{$gray-200}); border-radius: var(--ty-border-radius); + outline: none; &_fullscreen { width: 100%; @@ -13,6 +14,8 @@ width: 300px; } + // ── Header ────────────────────────────────────────────────────────────── + &__header { display: flex; align-items: center; @@ -33,13 +36,22 @@ border-radius: var(--ty-border-radius); font-size: 18px; color: var(--ty-color-text, #{$gray-700}); + transition: background 0.2s, opacity 0.2s; - &:hover { + &:hover:not(&_disabled) { background: var(--ty-calendar-hover, #{$gray-100}); } + + &_disabled { + cursor: not-allowed; + opacity: 0.3; + } } &__title { + display: flex; + align-items: center; + gap: 8px; font-weight: 500; font-size: var(--ty-font-size-base); } @@ -59,10 +71,30 @@ } } + &__today-btn { + border: none; + background: transparent; + cursor: pointer; + font-size: var(--ty-font-size-sm); + padding: 2px 10px; + border-radius: var(--ty-border-radius); + color: var(--ty-color-primary, #{$primary-color}); + font-weight: 500; + transition: background 0.2s; + + &:hover { + background: var(--ty-color-primary-bg, rgba($primary-color, 0.08)); + } + } + + // ── Body & animation ──────────────────────────────────────────────────── + &__body { padding: 8px; } + // ── Table (month panel) ───────────────────────────────────────────────── + &__table { width: 100%; border-collapse: collapse; @@ -77,6 +109,25 @@ color: var(--ty-color-text-secondary, #{$gray-600}); } + // ── Week number ───────────────────────────────────────────────────────── + + &__week-number-header { + width: 32px; + color: var(--ty-color-text-secondary, #{$gray-400}); + font-weight: 400; + font-size: 12px; + } + + &__week-number { + text-align: center; + font-size: 12px; + color: var(--ty-color-text-secondary, #{$gray-400}); + padding: 4px 0; + user-select: none; + } + + // ── Day cell ──────────────────────────────────────────────────────────── + &__cell { text-align: center; cursor: pointer; @@ -84,7 +135,24 @@ &_disabled { cursor: not-allowed; - opacity: 0.3; + color: var(--ty-color-text-quaternary); + pointer-events: none; + + .#{$prefix}-calendar__cell-date { + text-decoration: line-through; + text-decoration-color: var(--ty-color-text-quaternary); + text-decoration-thickness: 1px; + } + + // Disabled dates in the current month should be darker than out-of-month dates + &#{&}_in-view { + color: var(--ty-color-text-tertiary); + + .#{$prefix}-calendar__cell-date { + text-decoration-color: var(--ty-color-text-tertiary); + text-decoration-thickness: 1px; + } + } } &:not(&_disabled):hover .#{$prefix}-calendar__cell-inner { @@ -100,7 +168,11 @@ } &:not(&_in-view) { - color: var(--ty-color-text-secondary, #{$gray-400}); + color: var(--ty-color-text-quaternary, #{$gray-300}); + + .#{$prefix}-calendar__cell-dot { + opacity: 0.4; + } } &_today .#{$prefix}-calendar__cell-date { @@ -117,8 +189,50 @@ &_selected .#{$prefix}-calendar__cell-date { color: inherit; } + + // ── Range selection ───────────────────────────────────────────────── + + &_in-range { + background: var(--ty-color-primary-bg, rgba($primary-color, 0.08)); + } + + &_range-start { + border-radius: var(--ty-border-radius) 0 0 var(--ty-border-radius); + background: var(--ty-color-primary-bg, rgba($primary-color, 0.08)); + } + + &_range-start .#{$prefix}-calendar__cell-inner { + background: var(--ty-color-primary, #{$primary-color}); + color: #fff; + border-radius: var(--ty-border-radius); + } + + &_range-end { + border-radius: 0 var(--ty-border-radius) var(--ty-border-radius) 0; + background: var(--ty-color-primary-bg, rgba($primary-color, 0.08)); + } + + &_range-end .#{$prefix}-calendar__cell-inner { + background: var(--ty-color-primary, #{$primary-color}); + color: #fff; + border-radius: var(--ty-border-radius); + } + + &_range-start#{&}_range-end { + border-radius: var(--ty-border-radius); + } + + // ── Keyboard focus ────────────────────────────────────────────────── + + &_focused .#{$prefix}-calendar__cell-inner, + &:focus-visible .#{$prefix}-calendar__cell-inner { + outline: 2px solid var(--ty-color-primary, #{$primary-color}); + outline-offset: 1px; + } } + // ── Cell inner ────────────────────────────────────────────────────────── + &__cell-inner { padding: 4px; border-radius: var(--ty-border-radius); @@ -126,6 +240,7 @@ display: flex; flex-direction: column; align-items: center; + position: relative; } &_fullscreen &__cell-inner { @@ -147,7 +262,21 @@ margin-top: 2px; } - // Month panel (year mode) + // ── Dot indicator ─────────────────────────────────────────────────────── + + &__cell-dot { + position: absolute; + bottom: 2px; + left: 50%; + transform: translateX(-50%); + width: 6px; + height: 6px; + border-radius: 50%; + background-color: var(--ty-color-primary, #{$primary-color}); + } + + // ── Month panel (year mode) ───────────────────────────────────────────── + &__months { display: grid; grid-template-columns: repeat(3, 1fr); @@ -187,4 +316,40 @@ font-size: 12px; margin-top: 4px; } -} + + // ── Decade panel ──────────────────────────────────────────────────────── + + &__decades { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; + padding: 8px; + } + + &__decade-cell { + text-align: center; + padding: 16px 8px; + cursor: pointer; + border-radius: var(--ty-border-radius); + transition: all 0.2s; + font-size: var(--ty-font-size-base); + + &:hover { + background: var(--ty-calendar-hover, #{$gray-100}); + } + + &_selected { + background: var(--ty-color-primary, #{$primary-color}); + color: #fff; + + &:hover { + background: var(--ty-color-primary, #{$primary-color}); + opacity: 0.9; + } + } + + &_out { + color: var(--ty-color-text-secondary, #{$gray-400}); + } + } +} \ No newline at end of file diff --git a/packages/react/src/calendar/types.ts b/packages/react/src/calendar/types.ts index b4f62b2b..6e708efd 100644 --- a/packages/react/src/calendar/types.ts +++ b/packages/react/src/calendar/types.ts @@ -1,21 +1,75 @@ import React from 'react'; import { BaseProps } from '../_utils/props'; -export type CalendarMode = 'month' | 'year'; +export type CalendarMode = 'month' | 'year' | 'decade'; + +export type SelectionMode = 'single' | 'range' | 'multiple'; export interface CalendarProps extends BaseProps, Omit, 'onChange' | 'onSelect' | 'defaultValue'> { + /** Selected date (controlled, single mode) */ defaultValue?: Date; + /** Controlled selected date */ value?: Date; + /** Display mode */ mode?: CalendarMode; + /** Default display mode */ defaultMode?: CalendarMode; + /** Full-size or card mode */ fullscreen?: boolean; + /** Disable specific dates */ disabledDate?: (currentDate: Date) => boolean; + /** Callback when date is selected (single mode) */ onChange?: (date: Date) => void; + /** Callback when a date cell is clicked */ onSelect?: (date: Date) => void; + /** Callback when panel changes */ onPanelChange?: (date: Date, mode: CalendarMode) => void; + /** Custom render for date cell content */ dateCellRender?: (date: Date) => React.ReactNode; + /** Custom render for month cell content */ monthCellRender?: (date: Date) => React.ReactNode; - headerRender?: (config: { value: Date; mode: CalendarMode; onChange: (date: Date) => void; onModeChange: (mode: CalendarMode) => void }) => React.ReactNode; + /** Custom header render */ + headerRender?: (config: { + value: Date; + mode: CalendarMode; + onChange: (date: Date) => void; + onModeChange: (mode: CalendarMode) => void; + }) => React.ReactNode; + + // --- New Props --- + + /** Selection mode: single, range, or multiple */ + selectionMode?: SelectionMode; + /** Controlled range value */ + rangeValue?: [Date, Date] | null; + /** Default range value */ + defaultRangeValue?: [Date, Date] | null; + /** Range change callback */ + onRangeChange?: (range: [Date, Date] | null) => void; + /** Controlled multi-select value */ + multipleValue?: Date[]; + /** Default multi-select value */ + defaultMultipleValue?: Date[]; + /** Multi-select change callback */ + onMultipleChange?: (dates: Date[]) => void; + /** First day of week (0=Sun, 1=Mon, ...) */ + weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6; + /** Show ISO week number column */ + showWeekNumber?: boolean; + /** Show "Today" button in header */ + showToday?: boolean; + /** Restrict navigable/selectable date range */ + validRange?: [Date, Date]; + /** Per-cell custom class name */ + cellClassName?: (date: Date) => string | undefined; + /** Per-cell custom inline style */ + cellStyle?: (date: Date) => React.CSSProperties | undefined; + /** Dot indicator: return true for primary color, or a color string */ + dotRender?: (date: Date) => boolean | string; + /** Fires when the displayed month changes via navigation */ + onMonthChange?: (date: Date) => void; + /** Fires when the displayed year changes via navigation */ + onYearChange?: (date: Date) => void; } diff --git a/packages/react/src/date-picker/__tests__/date-picker.test.tsx b/packages/react/src/date-picker/__tests__/date-picker.test.tsx index 0a5482b8..4598ab30 100644 --- a/packages/react/src/date-picker/__tests__/date-picker.test.tsx +++ b/packages/react/src/date-picker/__tests__/date-picker.test.tsx @@ -84,4 +84,27 @@ describe('', () => { clear && fireEvent.click(clear); expect(fn).toHaveBeenCalledWith(null, ''); }); + + it('should render range value', () => { + const { container } = render(); + expect(container.querySelector('input')?.value).toBe('2024-01-10 ~ 2024-01-15'); + }); + + it('should select a date range', () => { + const fn = jest.fn(); + const { container } = render(); + const input = container.querySelector('.ty-date-picker__input'); + input && fireEvent.click(input); + + const dayCells = Array.from(document.querySelectorAll('.ty-date-picker__cell.ty-date-picker__cell_in-view')); + const startCell = dayCells.find((cell) => cell.textContent?.trim() === '10'); + startCell && fireEvent.click(startCell); + + expect(fn).toHaveBeenCalledWith([expect.any(Date), expect.any(Date)], ['2024-01-01', '2024-01-10']); + + const endCell = dayCells.find((cell) => cell.textContent?.trim() === '15'); + endCell && fireEvent.click(endCell); + + expect(fn).toHaveBeenLastCalledWith([expect.any(Date), null], ['2024-01-15', '']); + }); }); diff --git a/packages/react/src/date-picker/date-picker.tsx b/packages/react/src/date-picker/date-picker.tsx index ef3290a7..72a5a2c8 100755 --- a/packages/react/src/date-picker/date-picker.tsx +++ b/packages/react/src/date-picker/date-picker.tsx @@ -9,7 +9,8 @@ import PickerHeader from './picker-header'; import PickerDay from './picker-day'; import PickerMonth from './picker-month'; import PickerYear from './picker-year'; -import { DatePickerProps, PanelMode } from './types'; +import { DatePickerProps, DateRangeValue, DatePickerValue, PanelMode } from './types'; +import { compareDate } from './utils'; function formatDate(date: Date, format: string): string { @@ -31,12 +32,35 @@ function getFormatByPicker(picker: string, customFormat?: string): string { } } +function isRangeValue(value: DatePickerProps['value'] | DatePickerProps['defaultValue']): value is DateRangeValue { + return Array.isArray(value); +} + +function getInitialRangeValue(value?: DatePickerProps['value'], defaultValue?: DatePickerProps['defaultValue']): DateRangeValue { + if (isRangeValue(value)) { + return [value[0] ?? null, value[1] ?? null]; + } + if (isRangeValue(defaultValue)) { + return [defaultValue[0] ?? null, defaultValue[1] ?? null]; + } + return [null, null]; +} + +function getInitialPanelDate(value?: DatePickerProps['value'], defaultValue?: DatePickerProps['defaultValue']): Date { + if (isRangeValue(value)) return value[0] ?? value[1] ?? new Date(); + if (value instanceof Date) return value; + if (isRangeValue(defaultValue)) return defaultValue[0] ?? defaultValue[1] ?? new Date(); + if (defaultValue instanceof Date) return defaultValue; + return new Date(); +} + const DatePicker = (props: DatePickerProps) => { const { defaultValue, value, open: controlledOpen, picker = 'date', + range = false, format: customFormat, disabled = false, placeholder, @@ -59,11 +83,14 @@ const DatePicker = (props: DatePickerProps) => { const configContext = useContext(ConfigContext); const prefixCls = getPrefixCls('date-picker', configContext.prefixCls, customisedCls); const format = getFormatByPicker(picker, customFormat); - - const [date, setDate] = useState(value ?? defaultValue ?? null); - const [panelDate, setPanelDate] = useState(value ?? defaultValue ?? new Date()); + const [date, setDate] = useState( + !range && value instanceof Date ? value : !range && defaultValue instanceof Date ? defaultValue : null, + ); + const [rangeValue, setRangeValue] = useState(() => getInitialRangeValue(value, defaultValue)); + const [panelDate, setPanelDate] = useState(() => getInitialPanelDate(value, defaultValue)); const [open, setOpen] = useState(false); const [mode, setMode] = useState(picker === 'date' ? 'date' : picker); + const [hoverDate, setHoverDate] = useState(null); const wrapperRef = useRef(null); const dropdownRef = useRef(null); @@ -72,10 +99,17 @@ const DatePicker = (props: DatePickerProps) => { // Controlled value useEffect(() => { if (value !== undefined) { - setDate(value); - if (value) setPanelDate(value); + if (range) { + const nextRange = isRangeValue(value) ? [value[0] ?? null, value[1] ?? null] as DateRangeValue : [null, null]; + setRangeValue(nextRange); + const nextPanelDate = nextRange[0] ?? nextRange[1]; + if (nextPanelDate) setPanelDate(nextPanelDate); + } else { + setDate(value instanceof Date ? value : null); + if (value instanceof Date) setPanelDate(value); + } } - }, [value]); + }, [value, range]); useEffect(() => { if (controlledOpen !== undefined) setOpen(controlledOpen); @@ -95,18 +129,48 @@ const DatePicker = (props: DatePickerProps) => { const toggleOpen = useCallback((val: boolean) => { if (controlledOpen === undefined) setOpen(val); onOpenChange?.(val); - if (val) setMode(picker === 'date' ? 'date' : picker); + if (val) { + setMode(picker === 'date' ? 'date' : picker); + } else { + setHoverDate(null); + } }, [controlledOpen, onOpenChange, picker]); - const fireChange = useCallback((d: Date | null) => { - if (value === undefined) setDate(d); - onChange?.(d, d ? formatDate(d, format) : ''); - }, [value, onChange, format]); + const fireChange = useCallback((nextValue: DatePickerValue) => { + if (range) { + const normalized = Array.isArray(nextValue) ? nextValue : [null, null]; + if (value === undefined) setRangeValue(normalized); + onChange?.(normalized, [ + normalized[0] ? formatDate(normalized[0], format) : '', + normalized[1] ? formatDate(normalized[1], format) : '', + ]); + return; + } + + const nextDate = nextValue instanceof Date ? nextValue : null; + if (value === undefined) setDate(nextDate); + onChange?.(nextDate, nextDate ? formatDate(nextDate, format) : ''); + }, [range, value, onChange, format]); const handleDateSelect = useCallback((d: Date) => { - fireChange(d); + if (!range) { + fireChange(d); + toggleOpen(false); + return; + } + + const [start, end] = rangeValue; + if (!start || end) { + fireChange([d, null]); + setHoverDate(null); + return; + } + + const nextRange: DateRangeValue = compareDate(d, start) < 0 ? [d, start] : [start, d]; + fireChange(nextRange); + setHoverDate(null); toggleOpen(false); - }, [fireChange, toggleOpen]); + }, [range, rangeValue, fireChange, toggleOpen]); const handleMonthSelect = useCallback((d: Date) => { if (picker === 'month') { @@ -125,7 +189,7 @@ const DatePicker = (props: DatePickerProps) => { toggleOpen(false); } else { setPanelDate(d); - setMode(picker === 'month' ? 'month' : 'month'); + setMode('month'); onPanelChange?.(d, 'month'); } }, [picker, fireChange, toggleOpen, onPanelChange]); @@ -137,21 +201,42 @@ const DatePicker = (props: DatePickerProps) => { const handleClear = (e: React.MouseEvent) => { e.stopPropagation(); - fireChange(null); + fireChange(range ? [null, null] : null); toggleOpen(false); }; const handleToday = () => { const today = new Date(); today.setHours(0, 0, 0, 0); + if (range) { + fireChange([today, null]); + setPanelDate(today); + return; + } fireChange(today); setPanelDate(today); toggleOpen(false); }; - const hasValue = date !== null; - const displayValue = hasValue ? formatDate(date, format) : ''; - const defaultPlaceholder = placeholder ?? (picker === 'month' ? locale.DatePicker.selectMonth : picker === 'year' ? locale.DatePicker.selectYear : locale.DatePicker.selectDate); + const hasValue = range ? !!(rangeValue[0] || rangeValue[1]) : date !== null; + const displayValue = range + ? rangeValue[0] && rangeValue[1] + ? `${formatDate(rangeValue[0], format)} ~ ${formatDate(rangeValue[1], format)}` + : rangeValue[0] + ? `${formatDate(rangeValue[0], format)} ~ ` + : '' + : hasValue && date + ? formatDate(date, format) + : ''; + const defaultPlaceholder = placeholder ?? ( + range + ? `${locale.DatePicker.selectDate} ~ ${locale.DatePicker.selectDate}` + : picker === 'month' + ? locale.DatePicker.selectMonth + : picker === 'year' + ? locale.DatePicker.selectYear + : locale.DatePicker.selectDate + ); const cls = classNames(prefixCls, className, `${prefixCls}_${size}`, { [`${prefixCls}_disabled`]: disabled, @@ -169,10 +254,14 @@ const DatePicker = (props: DatePickerProps) => { return ( diff --git a/packages/react/src/date-picker/demo/Range.tsx b/packages/react/src/date-picker/demo/Range.tsx new file mode 100644 index 00000000..3bd225a3 --- /dev/null +++ b/packages/react/src/date-picker/demo/Range.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { DatePicker } from '@tiny-design/react'; + +export default function RangeDemo() { + const [value, setValue] = React.useState<[Date | null, Date | null]>([ + new Date(2024, 0, 10), + new Date(2024, 0, 15), + ]); + + return ( + { + if (Array.isArray(nextValue)) { + setValue(nextValue); + } + console.log(dateStrings); + }} + /> + ); +} diff --git a/packages/react/src/date-picker/demo/RangeDisabled.tsx b/packages/react/src/date-picker/demo/RangeDisabled.tsx new file mode 100644 index 00000000..4fef37e5 --- /dev/null +++ b/packages/react/src/date-picker/demo/RangeDisabled.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { DatePicker } from '@tiny-design/react'; + +export default function RangeDisabledDemo() { + const disabledDate = (current: Date) => { + const day = current.getDay(); + return day === 0 || day === 6; + }; + + return ( + ( + Weekends are unavailable in range mode + )} + /> + ); +} diff --git a/packages/react/src/date-picker/index.md b/packages/react/src/date-picker/index.md index 0167423e..9808b6a9 100644 --- a/packages/react/src/date-picker/index.md +++ b/packages/react/src/date-picker/index.md @@ -10,6 +10,10 @@ import DisabledDemo from './demo/Disabled'; import DisabledSource from './demo/Disabled.tsx?raw'; import ExtraFooterDemo from './demo/ExtraFooter'; import ExtraFooterSource from './demo/ExtraFooter.tsx?raw'; +import RangeDemo from './demo/Range'; +import RangeSource from './demo/Range.tsx?raw'; +import RangeDisabledDemo from './demo/RangeDisabled'; +import RangeDisabledSource from './demo/RangeDisabled.tsx?raw'; # DatePicker @@ -51,6 +55,15 @@ Use `disabledDate` to make specific dates unselectable. This example disables al + + + +### Date Range + +Enable `range` to select a start and end date in one picker. + + + @@ -80,6 +93,15 @@ Render extra content in the panel footer. + + + +### Range With Disabled Dates + +Range mode works with `disabledDate` too. This example blocks weekend selection. + + + @@ -88,10 +110,11 @@ Render extra content in the panel footer. | Property | Description | Type | Default | | ------------------- | ------------------------------------ | ------------------------------------------------- | ------------- | -| defaultValue | Default date | Date | - | -| value | Controlled date value | Date | - | +| defaultValue | Default date or date range | Date | [Date | null, Date | null] | - | +| value | Controlled date or date range | Date | [Date | null, Date | null] | - | | open | Control popup visibility | boolean | - | | picker | Selection granularity | `date` | `month` | `year` | `date` | +| range | Enable date range selection | boolean | false | | format | Display format | string | `YYYY-MM-DD` | | disabled | Disable the picker | boolean | false | | placeholder | Input placeholder | string | `Select date` | @@ -102,8 +125,8 @@ Render extra content in the panel footer. | disabledDate | Disable specific dates | (current: Date) => boolean | - | | renderExtraFooter | Extra content in the footer | (mode: PanelMode) => ReactNode | - | | suffixIcon | Custom suffix icon | ReactNode | Calendar icon | -| onChange | Callback when date changes | (date: Date | null, dateString: string) => void | - | +| onChange | Callback when date changes | (date: Date | null | [Date | null, Date | null], dateString: string | [string, string]) => void | - | | onOpenChange | Callback on popup open/close | (open: boolean) => void | - | | onPanelChange | Callback on panel mode change | (date: Date, mode: PanelMode) => void | - | | style | Style object of container | CSSProperties | - | -| className | ClassName of container | string | - | \ No newline at end of file +| className | ClassName of container | string | - | diff --git a/packages/react/src/date-picker/index.zh_CN.md b/packages/react/src/date-picker/index.zh_CN.md index 26e9a2ad..a3fdc75b 100644 --- a/packages/react/src/date-picker/index.zh_CN.md +++ b/packages/react/src/date-picker/index.zh_CN.md @@ -10,6 +10,10 @@ import DisabledDemo from './demo/Disabled'; import DisabledSource from './demo/Disabled.tsx?raw'; import ExtraFooterDemo from './demo/ExtraFooter'; import ExtraFooterSource from './demo/ExtraFooter.tsx?raw'; +import RangeDemo from './demo/Range'; +import RangeSource from './demo/Range.tsx?raw'; +import RangeDisabledDemo from './demo/RangeDisabled'; +import RangeDisabledSource from './demo/RangeDisabled.tsx?raw'; # DatePicker 日期选择器 @@ -51,6 +55,15 @@ import { DatePicker } from 'tiny-design'; + + + +### 日期范围 + +开启 `range` 后,可在同一个选择器中选择开始和结束日期。 + + + @@ -80,6 +93,15 @@ import { DatePicker } from 'tiny-design'; + + + +### 范围选择与禁用日期 + +范围模式同样支持 `disabledDate`。此示例禁止选择周末。 + + + @@ -88,10 +110,11 @@ import { DatePicker } from 'tiny-design'; | 属性 | 说明 | 类型 | 默认值 | | ------------------- | ------------------------------------ | ------------------------------------------------- | ------------- | -| defaultValue | 默认日期 | Date | - | -| value | 受控日期值 | Date | - | +| defaultValue | 默认日期或日期范围 | Date | [Date | null, Date | null] | - | +| value | 受控日期值或日期范围 | Date | [Date | null, Date | null] | - | | open | 控制弹出层显示 | boolean | - | | picker | 选择粒度 | `date` | `month` | `year` | `date` | +| range | 启用日期范围选择 | boolean | false | | format | 显示格式 | string | `YYYY-MM-DD` | | disabled | 禁用选择器 | boolean | false | | placeholder | 输入框占位文本 | string | `Select date` | @@ -102,8 +125,8 @@ import { DatePicker } from 'tiny-design'; | disabledDate | 禁用特定日期 | (current: Date) => boolean | - | | renderExtraFooter | 页脚附加内容 | (mode: PanelMode) => ReactNode | - | | suffixIcon | 自定义后缀图标 | ReactNode | Calendar icon | -| onChange | 日期变化时的回调 | (date: Date | null, dateString: string) => void | - | +| onChange | 日期变化时的回调 | (date: Date | null | [Date | null, Date | null], dateString: string | [string, string]) => void | - | | onOpenChange | 弹出层打开/关闭时的回调 | (open: boolean) => void | - | | onPanelChange | 面板模式切换时的回调 | (date: Date, mode: PanelMode) => void | - | | style | 容器的样式对象 | CSSProperties | - | -| className | 容器的类名 | string | - | \ No newline at end of file +| className | 容器的类名 | string | - | diff --git a/packages/react/src/date-picker/picker-day.tsx b/packages/react/src/date-picker/picker-day.tsx index f8bd418a..f0048ae7 100755 --- a/packages/react/src/date-picker/picker-day.tsx +++ b/packages/react/src/date-picker/picker-day.tsx @@ -1,19 +1,39 @@ import classNames from 'classnames'; -import { getMonthDaysArray, isSameDate, isToday } from './utils'; +import { getMonthDaysArray, isDateInRange, isSameDate, isToday } from './utils'; +import type { DateRangeValue } from './types'; export type PickerDayProps = { date: Date | null; + range?: boolean; + rangeValue?: DateRangeValue; + hoverDate?: Date | null; panelDate: Date; weeks: string[]; disabledDate?: (current: Date) => boolean; onChange: (date: Date) => void; + onHoverDateChange?: (date: Date | null) => void; panelOnChange: (panelDate: Date) => void; prefixCls: string; }; const PickerDay = (props: PickerDayProps) => { - const { prefixCls, date, weeks, onChange, panelDate, panelOnChange, disabledDate } = props; + const { + prefixCls, + date, + range = false, + rangeValue, + hoverDate, + weeks, + onChange, + onHoverDateChange, + panelDate, + panelOnChange, + disabledDate, + } = props; const panelDays = getMonthDaysArray(panelDate); + const [rangeStart, rangeEnd] = rangeValue ?? [null, null]; + const previewStart = rangeStart && !rangeEnd ? rangeStart : null; + const previewEnd = rangeStart && !rangeEnd && hoverDate ? hoverDate : null; const handleClick = (dayCell: typeof panelDays[0]) => { if (disabledDate?.(dayCell.date)) return; @@ -38,14 +58,36 @@ const PickerDay = (props: PickerDayProps) => { {panelDays.slice(row * 7, row * 7 + 7).map((dayCell, col) => { const isDisabled = disabledDate?.(dayCell.date) ?? false; + const isSelected = !range && date && isSameDate(date, dayCell.date); + const isRangeStart = !!(rangeStart && isSameDate(rangeStart, dayCell.date)); + const isRangeEnd = !!(rangeEnd && isSameDate(rangeEnd, dayCell.date)); + const isPreviewStart = !!(previewStart && isSameDate(previewStart, dayCell.date)); + const isPreviewEnd = !!(previewEnd && isSameDate(previewEnd, dayCell.date)); + const hasFullRange = !!(rangeStart && rangeEnd); + const hasPreviewRange = !!(previewStart && previewEnd); + const isInRange = hasFullRange && rangeStart && rangeEnd + ? isDateInRange(dayCell.date, rangeStart, rangeEnd) + : false; + const isInPreviewRange = hasPreviewRange && previewStart && previewEnd + ? isDateInRange(dayCell.date, previewStart, previewEnd) + : false; const cls = classNames(`${prefixCls}__cell`, { [`${prefixCls}__cell_in-view`]: dayCell.isThisMonth, [`${prefixCls}__cell_today`]: isToday(dayCell.date), - [`${prefixCls}__cell_selected`]: date && isSameDate(date, dayCell.date), + [`${prefixCls}__cell_selected`]: isSelected, + [`${prefixCls}__cell_range-start`]: isRangeStart || (isPreviewStart && !rangeEnd), + [`${prefixCls}__cell_range-end`]: isRangeEnd || (isPreviewEnd && !rangeEnd), + [`${prefixCls}__cell_in-range`]: isInRange, + [`${prefixCls}__cell_in-preview-range`]: !hasFullRange && isInPreviewRange, [`${prefixCls}__cell_disabled`]: isDisabled, }); return ( - handleClick(dayCell)}> + handleClick(dayCell)} + onMouseEnter={() => !isDisabled && onHoverDateChange?.(dayCell.date)} + onMouseLeave={() => onHoverDateChange?.(null)}>
{dayCell.label}
diff --git a/packages/react/src/date-picker/style/_index.scss b/packages/react/src/date-picker/style/_index.scss index d743e350..dac9662e 100755 --- a/packages/react/src/date-picker/style/_index.scss +++ b/packages/react/src/date-picker/style/_index.scss @@ -239,6 +239,37 @@ $dp: #{$prefix}-date-picker; background: var(--ty-picker-cell-selected-hover-bg); } + &__cell_in-range, + &__cell_in-preview-range { + background: var(--ty-color-primary-bg, rgb(24 144 255 / 12%)); + } + + &__cell_range-start, + &__cell_range-end { + background: var(--ty-color-primary-bg, rgb(24 144 255 / 12%)); + } + + &__cell_range-start &__cell-inner, + &__cell_range-end &__cell-inner { + background: var(--ty-color-primary); + color: #fff; + font-weight: 500; + } + + &__cell_in-preview-range &__cell-inner, + &__cell_in-range &__cell-inner { + background: transparent; + color: var(--ty-color-text); + } + + &__cell_range-start.#{$dp}__cell_in-preview-range &__cell-inner, + &__cell_range-end.#{$dp}__cell_in-preview-range &__cell-inner, + &__cell_range-start.#{$dp}__cell_in-range &__cell-inner, + &__cell_range-end.#{$dp}__cell_in-range &__cell-inner { + background: var(--ty-color-primary); + color: #fff; + } + &__cell_disabled &__cell-inner { color: var(--ty-color-text-quaternary); background: var(--ty-picker-cell-disabled-bg); diff --git a/packages/react/src/date-picker/types.ts b/packages/react/src/date-picker/types.ts index 292dcea1..d748425b 100644 --- a/packages/react/src/date-picker/types.ts +++ b/packages/react/src/date-picker/types.ts @@ -3,16 +3,20 @@ import { BaseProps, SizeType } from '../_utils/props'; export type PickerType = 'date' | 'month' | 'year'; export type PanelMode = 'date' | 'month' | 'year'; +export type DateRangeValue = [Date | null, Date | null]; +export type DatePickerValue = Date | DateRangeValue | null; export interface DatePickerProps extends BaseProps { /** Default date value */ - defaultValue?: Date; + defaultValue?: Date | DateRangeValue; /** Controlled date value */ - value?: Date; + value?: Date | DateRangeValue; /** Control popup visibility */ open?: boolean; /** Selection granularity */ picker?: PickerType; + /** Enable range selection for date picker */ + range?: boolean; /** Display format string */ format?: string; /** Disable picker */ @@ -34,7 +38,7 @@ export interface DatePickerProps extends BaseProps { /** Custom suffix icon */ suffixIcon?: ReactNode; /** Callback when date changes */ - onChange?: (date: Date | null, dateString: string) => void; + onChange?: (date: DatePickerValue, dateString: string | [string, string]) => void; /** Callback when popup opens/closes */ onOpenChange?: (open: boolean) => void; /** Callback when panel mode changes */ diff --git a/packages/react/src/date-picker/utils.ts b/packages/react/src/date-picker/utils.ts index c4024f77..a5b48416 100755 --- a/packages/react/src/date-picker/utils.ts +++ b/packages/react/src/date-picker/utils.ts @@ -52,7 +52,7 @@ type DayCell = { isThisMonth: boolean; }; -export const getMonthDaysArray = (date: Date = TODAY): DayCell[] => { +export const getMonthDaysArray = (date: Date = TODAY, weekStartsOn: number = 0): DayCell[] => { const year = date.getFullYear(); const month = date.getMonth(); const dayArrays: DayCell[] = []; @@ -61,9 +61,12 @@ export const getMonthDaysArray = (date: Date = TODAY): DayCell[] => { const preDays = getMonthDays(preYear, preMonth); const thisMonthFirstDayInWeek = getWeekday(year, month, 1); + // Adjust for weekStartsOn + const leadingDays = (thisMonthFirstDayInWeek - weekStartsOn + 7) % 7; + // last month days - for (let i = 0; i < thisMonthFirstDayInWeek; i++) { - const day = preDays - thisMonthFirstDayInWeek + i + 1; + for (let i = 0; i < leadingDays; i++) { + const day = preDays - leadingDays + i + 1; dayArrays.push({ label: day, date: new Date(preYear, preMonth, day), @@ -80,8 +83,10 @@ export const getMonthDaysArray = (date: Date = TODAY): DayCell[] => { }); } - // next month days - for (let i = 1; i <= 42 - days - thisMonthFirstDayInWeek; i++) { + // next month days - always fill to 42 cells (6 rows) for stable layout + const totalCells = 42; + const trailingDays = totalCells - dayArrays.length; + for (let i = 1; i <= trailingDays; i++) { dayArrays.push({ label: i, date: new Date(year, month + 1, i), @@ -92,6 +97,13 @@ export const getMonthDaysArray = (date: Date = TODAY): DayCell[] => { return dayArrays; }; +export const getISOWeekNumber = (date: Date): number => { + const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7)); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); +}; + export const isSameDate = (dateA: Date, dateB: Date): boolean => { const dateAYear = dateA.getFullYear(); const dateAMonth = dateA.getMonth(); @@ -102,6 +114,16 @@ export const isSameDate = (dateA: Date, dateB: Date): boolean => { return dateAYear === dateBYear && dateAMonth === dateBMonth && dateADate === dateBDate; }; +export const compareDate = (dateA: Date, dateB: Date): number => { + const a = new Date(dateA.getFullYear(), dateA.getMonth(), dateA.getDate()).getTime(); + const b = new Date(dateB.getFullYear(), dateB.getMonth(), dateB.getDate()).getTime(); + return a - b; +}; + +export const isDateInRange = (current: Date, start: Date, end: Date): boolean => { + return compareDate(current, start) >= 0 && compareDate(current, end) <= 0; +}; + export const isToday = (date: Date): boolean => { return isSameDate(new Date(), date); }; From 0cdaaf5a02496b17734f141e1f6f4ebb06c970c8 Mon Sep 17 00:00:00 2001 From: wangdicoder Date: Fri, 3 Apr 2026 22:08:01 +1100 Subject: [PATCH 2/7] fix(react): fix DateRangeValue type inference in date-picker --- packages/react/src/date-picker/date-picker.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react/src/date-picker/date-picker.tsx b/packages/react/src/date-picker/date-picker.tsx index 72a5a2c8..a4c47120 100755 --- a/packages/react/src/date-picker/date-picker.tsx +++ b/packages/react/src/date-picker/date-picker.tsx @@ -100,7 +100,7 @@ const DatePicker = (props: DatePickerProps) => { useEffect(() => { if (value !== undefined) { if (range) { - const nextRange = isRangeValue(value) ? [value[0] ?? null, value[1] ?? null] as DateRangeValue : [null, null]; + const nextRange: DateRangeValue = isRangeValue(value) ? [value[0] ?? null, value[1] ?? null] : [null, null]; setRangeValue(nextRange); const nextPanelDate = nextRange[0] ?? nextRange[1]; if (nextPanelDate) setPanelDate(nextPanelDate); @@ -138,7 +138,7 @@ const DatePicker = (props: DatePickerProps) => { const fireChange = useCallback((nextValue: DatePickerValue) => { if (range) { - const normalized = Array.isArray(nextValue) ? nextValue : [null, null]; + const normalized: DateRangeValue = Array.isArray(nextValue) ? nextValue : [null, null]; if (value === undefined) setRangeValue(normalized); onChange?.(normalized, [ normalized[0] ? formatDate(normalized[0], format) : '', From af77c391033de5bc47dca4118beb0f0ad2430663 Mon Sep 17 00:00:00 2001 From: wangdicoder Date: Sat, 4 Apr 2026 11:11:46 +1100 Subject: [PATCH 3/7] fix: update styles --- packages/react/src/calendar/calendar.tsx | 118 +++++++++++++----- .../react/src/calendar/demo/WeekNumber.tsx | 3 + packages/react/src/calendar/index.md | 5 +- packages/react/src/calendar/index.zh_CN.md | 3 +- packages/react/src/calendar/style/_index.scss | 84 ++++++------- packages/react/src/calendar/types.ts | 2 + .../react/src/date-picker/style/_index.scss | 9 +- 7 files changed, 143 insertions(+), 81 deletions(-) diff --git a/packages/react/src/calendar/calendar.tsx b/packages/react/src/calendar/calendar.tsx index 46c5ae9d..a56c25dc 100755 --- a/packages/react/src/calendar/calendar.tsx +++ b/packages/react/src/calendar/calendar.tsx @@ -64,6 +64,7 @@ const Calendar = React.forwardRef((props, ref) => cellClassName, cellStyle: cellStyleProp, dotRender, + weekNumberRender, onMonthChange, onYearChange, ...otherProps @@ -422,6 +423,22 @@ const Calendar = React.forwardRef((props, ref) => // ── header title ─────────────────────────────────────────────────────────── + const goPrevYear = () => { + setFocusedDate(null); + const newDate = getPrevYearDate(panelDate); + handlePanelChange(newDate); + onYearChange?.(newDate); + }; + + const goNextYear = () => { + setFocusedDate(null); + const newDate = getNextYearDate(panelDate); + handlePanelChange(newDate); + onYearChange?.(newDate); + }; + + // ── header title ─────────────────────────────────────────────────────────── + const getTitleText = (): string => { const year = panelDate.getFullYear(); if (mode === 'decade') { @@ -429,8 +446,11 @@ const Calendar = React.forwardRef((props, ref) => return `${decadeStart} – ${decadeStart + 9}`; } if (mode === 'year') return `${year}`; - const monthName = getFullMonthName(panelDate); - return `${monthName} ${year}`; + if (fullscreen) { + const monthName = getFullMonthName(panelDate); + return `${monthName} ${year}`; + } + return `${months[panelDate.getMonth()]} ${year}`; }; const handleTitleClick = () => { @@ -451,19 +471,36 @@ const Calendar = React.forwardRef((props, ref) => }); } + const yearUnit = mode === 'year' ? 'decade' : 'year'; + return (
- +
+ + {mode === 'month' && ( + + )} +
- {showToday && ( + +
+ {mode === 'month' && ( )} - - + +
); }; @@ -527,7 +570,7 @@ const Calendar = React.forwardRef((props, ref) => {showWeekNumber && ( - {weekNum} + {weekNumberRender ? weekNumberRender(weekNum) : weekNum} )} {rowDays.map((dayCell, col) => { @@ -661,6 +704,20 @@ const Calendar = React.forwardRef((props, ref) => return renderMonthPanel(); }; + // ── render footer ────────────────────────────────────────────────────────── + + const renderFooter = () => { + if (!showToday || mode !== 'month') return null; + const todayLabel = locale?.DatePicker?.today ?? 'Today'; + return ( + + ); + }; + // ── main render ──────────────────────────────────────────────────────────── return ( @@ -680,6 +737,7 @@ const Calendar = React.forwardRef((props, ref) =>
{renderBody()}
+ {renderFooter()}
); }); diff --git a/packages/react/src/calendar/demo/WeekNumber.tsx b/packages/react/src/calendar/demo/WeekNumber.tsx index 0987143d..3cf57739 100644 --- a/packages/react/src/calendar/demo/WeekNumber.tsx +++ b/packages/react/src/calendar/demo/WeekNumber.tsx @@ -10,6 +10,9 @@ export default function WeekNumberDemo() { onChange={(date) => setValue(date)} showWeekNumber weekStartsOn={1} + weekNumberRender={(weekNum) => ( + W{weekNum} + )} fullscreen={false} /> ); diff --git a/packages/react/src/calendar/index.md b/packages/react/src/calendar/index.md index 070dfaed..4fcd826c 100644 --- a/packages/react/src/calendar/index.md +++ b/packages/react/src/calendar/index.md @@ -74,7 +74,7 @@ Click dates to toggle individual selection. Great for marking availability. ### Week Numbers -Show ISO week numbers with `showWeekNumber` and use `weekStartsOn={1}` for Monday start. +Show ISO week numbers with `showWeekNumber` and use `weekStartsOn={1}` for Monday start. Use `weekNumberRender` to customise the week number display. @@ -135,7 +135,8 @@ Compact card calendar with keyboard navigation. Use arrow keys, Enter, and Escap | onMultipleChange | Multi-select change callback | (dates: Date[]) => void | | | weekStartsOn | First day of week (0=Sun) | 0\|1\|2\|3\|4\|5\|6 | 0 | | showWeekNumber | Show ISO week number column | boolean | false | -| showToday | Show "Today" button in header | boolean | false | +| weekNumberRender | Custom render for week number | (weekNumber: number) => ReactNode | | +| showToday | Show "Today" button in footer | boolean | false | | validRange | Restrict navigable date range | [Date, Date] | | | disabledDate | Disable specific dates | (date: Date) => boolean | | | cellClassName | Per-cell custom class name | (date: Date) => string | | diff --git a/packages/react/src/calendar/index.zh_CN.md b/packages/react/src/calendar/index.zh_CN.md index 2ff2df60..41eb892f 100644 --- a/packages/react/src/calendar/index.zh_CN.md +++ b/packages/react/src/calendar/index.zh_CN.md @@ -74,7 +74,7 @@ import { Calendar } from 'tiny-design'; ### 周数显示 -使用 `showWeekNumber` 显示 ISO 周数,`weekStartsOn={1}` 设置周一为一周的开始。 +使用 `showWeekNumber` 显示 ISO 周数,`weekStartsOn={1}` 设置周一为一周的开始。通过 `weekNumberRender` 自定义周数的显示样式。 @@ -135,6 +135,7 @@ import { Calendar } from 'tiny-design'; | onMultipleChange | 多选变化回调 | (dates: Date[]) => void | | | weekStartsOn | 一周的起始天(0=周日) | 0\|1\|2\|3\|4\|5\|6 | 0 | | showWeekNumber | 显示 ISO 周数列 | boolean | false | +| weekNumberRender | 自定义周数渲染 | (weekNumber: number) => ReactNode | | | showToday | 显示"今天"按钮 | boolean | false | | validRange | 限制可导航的日期范围 | [Date, Date] | | | disabledDate | 禁用特定日期 | (date: Date) => boolean | | diff --git a/packages/react/src/calendar/style/_index.scss b/packages/react/src/calendar/style/_index.scss index d170319d..f7a0819a 100644 --- a/packages/react/src/calendar/style/_index.scss +++ b/packages/react/src/calendar/style/_index.scss @@ -20,10 +20,15 @@ display: flex; align-items: center; justify-content: space-between; - padding: 12px 16px; + padding: 8px 12px; border-bottom: 1px solid var(--ty-calendar-border, #{$gray-200}); } + &__header-nav { + display: flex; + align-items: center; + } + &__nav-btn { display: inline-flex; align-items: center; @@ -33,13 +38,13 @@ border: none; background: transparent; cursor: pointer; - border-radius: var(--ty-border-radius); + border-radius: 4px; font-size: 18px; - color: var(--ty-color-text, #{$gray-700}); - transition: background 0.2s, opacity 0.2s; + color: var(--ty-color-text-tertiary); + transition: color 0.2s; &:hover:not(&_disabled) { - background: var(--ty-calendar-hover, #{$gray-100}); + color: var(--ty-color-primary); } &_disabled { @@ -53,37 +58,22 @@ align-items: center; gap: 8px; font-weight: 500; - font-size: var(--ty-font-size-base); + font-size: 16px; } &__title-btn { border: none; background: transparent; cursor: pointer; - font-weight: inherit; + font-weight: 500; font-size: inherit; - padding: 4px 8px; - border-radius: var(--ty-border-radius); + padding: 2px 6px; + border-radius: 4px; color: var(--ty-color-text, #{$gray-800}); + transition: color 0.2s; &:hover { - background: var(--ty-calendar-hover, #{$gray-100}); - } - } - - &__today-btn { - border: none; - background: transparent; - cursor: pointer; - font-size: var(--ty-font-size-sm); - padding: 2px 10px; - border-radius: var(--ty-border-radius); - color: var(--ty-color-primary, #{$primary-color}); - font-weight: 500; - transition: background 0.2s; - - &:hover { - background: var(--ty-color-primary-bg, rgba($primary-color, 0.08)); + color: var(--ty-color-primary); } } @@ -136,23 +126,11 @@ &_disabled { cursor: not-allowed; color: var(--ty-color-text-quaternary); - pointer-events: none; - .#{$prefix}-calendar__cell-date { - text-decoration: line-through; - text-decoration-color: var(--ty-color-text-quaternary); - text-decoration-thickness: 1px; + .#{$prefix}-calendar__cell-inner { + background: var(--ty-picker-cell-disabled-bg); } - // Disabled dates in the current month should be darker than out-of-month dates - &#{&}_in-view { - color: var(--ty-color-text-tertiary); - - .#{$prefix}-calendar__cell-date { - text-decoration-color: var(--ty-color-text-tertiary); - text-decoration-thickness: 1px; - } - } } &:not(&_disabled):hover .#{$prefix}-calendar__cell-inner { @@ -163,7 +141,7 @@ background: var(--ty-color-primary-active); } - &_in-view { + &_in-view:not(&_disabled) { color: var(--ty-color-text, #{$gray-900}); } @@ -175,9 +153,9 @@ } } - &_today .#{$prefix}-calendar__cell-date { - color: var(--ty-color-primary, #{$primary-color}); - font-weight: 600; + &_today .#{$prefix}-calendar__cell-inner { + border: 1px solid var(--ty-color-primary); + color: var(--ty-color-primary); } &_selected .#{$prefix}-calendar__cell-inner { @@ -352,4 +330,22 @@ color: var(--ty-color-text-secondary, #{$gray-400}); } } + + // ── Footer ────────────────────────────────────────────────────────────── + + &__footer { + padding: 8px 12px; + border-top: 1px solid var(--ty-calendar-border, #{$gray-200}); + text-align: center; + } + + &__today-link { + color: var(--ty-color-primary); + cursor: pointer; + font-size: 13px; + + &:hover { + opacity: 0.8; + } + } } \ No newline at end of file diff --git a/packages/react/src/calendar/types.ts b/packages/react/src/calendar/types.ts index 6e708efd..2573729e 100644 --- a/packages/react/src/calendar/types.ts +++ b/packages/react/src/calendar/types.ts @@ -68,6 +68,8 @@ export interface CalendarProps cellStyle?: (date: Date) => React.CSSProperties | undefined; /** Dot indicator: return true for primary color, or a color string */ dotRender?: (date: Date) => boolean | string; + /** Custom render for week number cell */ + weekNumberRender?: (weekNumber: number) => React.ReactNode; /** Fires when the displayed month changes via navigation */ onMonthChange?: (date: Date) => void; /** Fires when the displayed year changes via navigation */ diff --git a/packages/react/src/date-picker/style/_index.scss b/packages/react/src/date-picker/style/_index.scss index dac9662e..329e5948 100755 --- a/packages/react/src/date-picker/style/_index.scss +++ b/packages/react/src/date-picker/style/_index.scss @@ -141,13 +141,13 @@ $dp: #{$prefix}-date-picker; display: inline-flex; align-items: center; justify-content: center; - width: 24px; - height: 24px; + width: 28px; + height: 28px; border: none; background: transparent; color: var(--ty-color-text-tertiary); cursor: pointer; - font-size: 14px; + font-size: 18px; border-radius: 4px; transition: color 0.2s; @@ -165,8 +165,9 @@ $dp: #{$prefix}-date-picker; &__header-label { font-weight: 500; + font-size: 16px; cursor: pointer; - padding: 0 4px; + padding: 2px 6px; border-radius: 4px; transition: color 0.2s; From 56f6c1ed1107d11827b8f5e8b6e4da03b4681309 Mon Sep 17 00:00:00 2001 From: wangdicoder Date: Sat, 4 Apr 2026 11:14:29 +1100 Subject: [PATCH 4/7] fix: package --- packages/mcp/package.json | 3 ++- pnpm-lock.yaml | 21 ++++++++++++--------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 54ad7079..ece41a6d 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -31,7 +31,8 @@ "test": "NODE_OPTIONS='--experimental-vm-modules' jest" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.12.1" + "@modelcontextprotocol/sdk": "^1.12.1", + "zod": "^3.23.0" }, "devDependencies": { "@tiny-design/extract": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e1d4677..19ec8c87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -284,7 +284,10 @@ importers: dependencies: '@modelcontextprotocol/sdk': specifier: ^1.12.1 - version: 1.27.1(zod@4.3.6) + version: 1.27.1(zod@3.25.76) + zod: + specifier: ^3.23.0 + version: 3.25.76 devDependencies: '@tiny-design/extract': specifier: workspace:* @@ -6250,8 +6253,8 @@ packages: peerDependencies: zod: ^3.25 || ^4 - zod@4.3.6: - resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -7192,7 +7195,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@modelcontextprotocol/sdk@1.27.1(zod@4.3.6)': + '@modelcontextprotocol/sdk@1.27.1(zod@3.25.76)': dependencies: '@hono/node-server': 1.19.11(hono@4.12.9) ajv: 8.18.0 @@ -7209,8 +7212,8 @@ snapshots: json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 - zod: 4.3.6 - zod-to-json-schema: 3.25.1(zod@4.3.6) + zod: 3.25.76 + zod-to-json-schema: 3.25.1(zod@3.25.76) transitivePeerDependencies: - supports-color @@ -13289,10 +13292,10 @@ snapshots: yocto-queue@0.1.0: {} - zod-to-json-schema@3.25.1(zod@4.3.6): + zod-to-json-schema@3.25.1(zod@3.25.76): dependencies: - zod: 4.3.6 + zod: 3.25.76 - zod@4.3.6: {} + zod@3.25.76: {} zwitch@2.0.4: {} From 11905418db63d2d53b047ab40643176d5995ad0e Mon Sep 17 00:00:00 2001 From: wangdicoder Date: Sat, 4 Apr 2026 11:19:11 +1100 Subject: [PATCH 5/7] fix: config --- apps/docs/vite.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/docs/vite.config.ts b/apps/docs/vite.config.ts index de6734d8..2a2868fc 100644 --- a/apps/docs/vite.config.ts +++ b/apps/docs/vite.config.ts @@ -33,6 +33,7 @@ export default defineConfig({ '@tiny-design/react': reactSrc, '@tiny-design/icons': iconsSrc, '@tiny-design/charts': chartsSrc, + '@mdx-js/react': path.resolve(__dirname, 'node_modules/@mdx-js/react'), }, dedupe: ['react', 'react-dom'], }, From 5a59b1f43a122cdfa02d8844fe79998155c7118e Mon Sep 17 00:00:00 2001 From: wangdicoder Date: Sat, 4 Apr 2026 16:10:52 +1100 Subject: [PATCH 6/7] fix: script --- packages/cli/package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 149e0f71..73176aed 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -27,8 +27,7 @@ "type": "module", "scripts": { "extract": "node --import tsx scripts/extract.ts", - "build": "pnpm extract && tsup", - "test": "NODE_OPTIONS='--experimental-vm-modules' jest --passWithNoTests" + "build": "pnpm extract && tsup" }, "dependencies": { "chalk": "^5.0.0", From 5734a08c647af697aa0fe23c7eb90feb15f2b58d Mon Sep 17 00:00:00 2001 From: wangdicoder Date: Sat, 4 Apr 2026 16:16:16 +1100 Subject: [PATCH 7/7] test: fix tests --- .../__snapshots__/calendar.test.tsx.snap | 46 ++++++++++++++----- .../src/calendar/__tests__/calendar.test.tsx | 2 +- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/packages/react/src/calendar/__tests__/__snapshots__/calendar.test.tsx.snap b/packages/react/src/calendar/__tests__/__snapshots__/calendar.test.tsx.snap index 1ce3d3bd..25555515 100644 --- a/packages/react/src/calendar/__tests__/__snapshots__/calendar.test.tsx.snap +++ b/packages/react/src/calendar/__tests__/__snapshots__/calendar.test.tsx.snap @@ -9,13 +9,24 @@ exports[` should match the snapshot 1`] = `
- + + +
@@ -26,13 +37,24 @@ exports[` should match the snapshot 1`] = ` January 2024 - + + +
', () => { it('should render Today button when showToday is true', () => { const { container } = render(); - const todayBtn = container.querySelector('.ty-calendar__today-btn'); + const todayBtn = container.querySelector('.ty-calendar__today-link'); expect(todayBtn).toBeTruthy(); expect(todayBtn!.textContent).toBe('Today'); });