Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 33 additions & 8 deletions src/components/calendar-picker-view/calendar-picker-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,22 @@ export const CalendarPickerView = forwardRef<
const scrollTo = useSyncScroll(current, context.visible, bodyRef)

// ============================== Boundary ==============================
const VISIBLE_MONTHS = 6

const alignRange = (target: dayjs.Dayjs) => {
if (!props.min) {
setDefaultMin(target)
}
if (!props.max) {
setDefaultMax(target.add(VISIBLE_MONTHS, 'month'))
}
}

// 记录默认的 min 和 max,并在外部的值超出边界时自动扩充
const [defaultMin, setDefaultMin] = useState(current)
const [defaultMax, setDefaultMax] = useState(() => current.add(6, 'month'))
const [defaultMax, setDefaultMax] = useState(() =>
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

感觉 setDefaultXXX 可以包一下,传入的内容如果和之前的年月相同就不做事情。否则下面的 effect 你把条件都放进去了就会多次触发 set

current.add(VISIBLE_MONTHS, 'month')
)

useEffect(() => {
if (dateRange) {
Expand All @@ -150,7 +163,7 @@ export const CalendarPickerView = forwardRef<
setDefaultMax(dayjs(endDate).endOf('month'))
}
}
}, [dateRange])
}, [dateRange, defaultMin, defaultMax, props.min, props.max])

const maxDay = useMemo(
() => (props.max ? dayjs(props.max) : defaultMax),
Expand All @@ -162,6 +175,21 @@ export const CalendarPickerView = forwardRef<
)

// ================================ Refs ================================
const jumpToPage = (page: Page) => {
let next = convertPageToDayjs(page)
if (props.min && next.isBefore(minDay)) {
next = minDay
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

下面有 date 1 这里不应该同等一么?

}
if (props.max && next.isAfter(maxDay)) {
next = maxDay.date(1)
}
if (next.isBefore(defaultMin) || next.isAfter(defaultMax)) {
alignRange(next)
}
setCurrent(next)
scrollTo(next)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment on lines +178 to +191
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The jumpToPage function has two issues:

  1. Boundary Clamping: If props.min or props.max are provided, the rendering range is fixed. If the target page is outside these bounds, setCurrent(next) will set an unreachable month, and scrollTo(next) will fail because the corresponding DOM element won't be rendered. The target date should be clamped to the allowed range defined by minDay and maxDay.
  2. Dayjs Rollover Bug: The convertPageToDayjs utility (used here) has a potential bug where it can roll over to the wrong month if today is the 31st (e.g., calling it for February on January 31st might result in March). It's safer to create the dayjs object by setting the date to the 1st before setting the year and month.

Additionally, next.date(1) is redundant if the date is already set to the 1st.

  const jumpToPage = (page: Page) => {
    // Use a safe way to create the dayjs object to avoid rollover issues when today is the 31st
    let next = dayjs().date(1).year(page.year).month(page.month - 1)

    if (next.isBefore(minDay, 'month')) {
      if (props.min) {
        next = minDay.date(1)
      } else {
        setDefaultMin(next)
      }
    } else if (next.isAfter(maxDay, 'month')) {
      if (props.max) {
        next = maxDay.date(1)
      } else {
        setDefaultMax(next.endOf('month'))
      }
    }

    setCurrent(next)
    scrollTo(next)
  }


useImperativeHandle(ref, () => ({
jumpTo: pageOrPageGenerator => {
let page: Page
Expand All @@ -173,14 +201,11 @@ export const CalendarPickerView = forwardRef<
} else {
page = pageOrPageGenerator
}
const next = convertPageToDayjs(page)
setCurrent(next)
scrollTo(next)
jumpToPage(page)
},
jumpToToday: () => {
const next = dayjs().date(1)
setCurrent(next)
scrollTo(next)
const today = dayjs()
jumpToPage({ year: today.year(), month: today.month() + 1 })
},
getDateRange: () => dateRange,
}))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,122 @@ describe('Calendar', () => {
expect(container.querySelectorAll(`.${classPrefix}-cell`)).toHaveLength(30)
})

test('jumpTo expands rendering range', () => {
const App = () => {
const ref = useRef<CalendarPickerViewRef>(null)
return (
<>
<button
onClick={() => {
ref.current?.jumpTo({ year: 2021, month: 1 })
}}
>
jumpToPast
</button>
<button
onClick={() => {
ref.current?.jumpTo({ year: 2026, month: 12 })
}}
>
jumpToFuture
</button>
<CalendarPickerView ref={ref} selectionMode='single' />
</>
)
}
const { container, getByText } = render(<App />)

// defaultMin starts at today (2023-05), jumpTo 2021-01 resets window around target
fireEvent.click(getByText('jumpToPast'))
expect(
container.querySelector('[data-year-month="2021-1"]')
).toBeInTheDocument()

// jumpToFuture 2026-12 resets window, 2021-1 should no longer be rendered
fireEvent.click(getByText('jumpToFuture'))
expect(
container.querySelector('[data-year-month="2026-12"]')
).toBeInTheDocument()
expect(
container.querySelector('[data-year-month="2021-1"]')
).not.toBeInTheDocument()
})

test('jumpTo keeps selected date in rendering range', () => {
const App = () => {
const ref = useRef<CalendarPickerViewRef>(null)
return (
<>
<button
onClick={() => {
ref.current?.jumpTo({ year: 2021, month: 1 })
}}
>
jumpToPast
</button>
<CalendarPickerView
ref={ref}
selectionMode='single'
defaultValue={new Date(2023, 4, 15)}
/>
</>
)
}
const { container, getByText } = render(<App />)

// Selected date is 2023-05, jumpTo 2021-01 should keep 2023-05 rendered
fireEvent.click(getByText('jumpToPast'))
expect(
container.querySelector('[data-year-month="2021-1"]')
).toBeInTheDocument()
expect(
container.querySelector('[data-year-month="2023-5"]')
).toBeInTheDocument()
})

test('jumpTo clamps to min/max when bounds are set', () => {
const App = () => {
const ref = useRef<CalendarPickerViewRef>(null)
return (
<>
<button
onClick={() => {
ref.current?.jumpTo({ year: 2020, month: 1 })
}}
>
jumpBeforeMin
</button>
<button
onClick={() => {
ref.current?.jumpTo({ year: 2025, month: 6 })
}}
>
jumpAfterMax
</button>
<CalendarPickerView
ref={ref}
selectionMode='single'
min={new Date(2023, 0)}
max={new Date(2023, 11, 31)}
/>
</>
)
}
const { container, getByText } = render(<App />)

// jumpTo before min should clamp to min month (2023-01)
fireEvent.click(getByText('jumpBeforeMin'))
expect(
container.querySelector('[data-year-month="2023-1"]')
).toBeInTheDocument()

// jumpTo after max should clamp to max month (2023-12)
fireEvent.click(getByText('jumpAfterMax'))
expect(
container.querySelector('[data-year-month="2023-12"]')
).toBeInTheDocument()
})

test('auto expand month list', () => {
const { container, rerender } = render(
<CalendarPickerView value={new Date(2024, 9, 1)} selectionMode='single' />
Expand Down
Loading