diff --git a/Calendar.tsx b/Calendar.tsx index 61d17874da..ad24a31b2f 100644 --- a/Calendar.tsx +++ b/Calendar.tsx @@ -1,4 +1,4 @@ -import { forwardRef } from "react"; +import { forwardRef, useCallback } from "react"; import classnames from "classnames"; import { makePrefixer } from "@jpmorganchase/uitk-core"; import { @@ -37,6 +37,16 @@ export const Calendar = forwardRef( const { state, helpers } = useCalendar({ ...rest }); + const { setCalendarFocused } = helpers; + + const handleFocus = useCallback(() => { + setCalendarFocused(true); + }, [setCalendarFocused]); + + const handleBlur = useCallback(() => { + setCalendarFocused(false); + }, [setCalendarFocused]); + return ( ( diff --git a/internal/CalendarCarousel.tsx b/internal/CalendarCarousel.tsx index 51dc4d8026..6be9521298 100644 --- a/internal/CalendarCarousel.tsx +++ b/internal/CalendarCarousel.tsx @@ -1,5 +1,10 @@ import { forwardRef, useRef, useEffect, useState } from "react"; -import dayjs from "./dayjs"; +import { + DateValue, + getLocalTimeZone, + isSameMonth, + today, +} from "@internationalized/date"; import { CalendarMonth, CalendarMonthProps } from "./CalendarMonth"; import { makePrefixer, @@ -9,19 +14,18 @@ import { usePrevious } from "../../utils"; import { useCalendarContext } from "./CalendarContext"; import "./CalendarCarousel.css"; +import { formatDate, monthDiff } from "./utils"; export type CalendarCarouselProps = Omit; -function getMonths(month: Date | undefined) { - return [ - dayjs(month) - .startOf("month") - .subtract(1, "month") - .startOf("month") - .format("L"), - dayjs(month).startOf("month").format("L"), - dayjs(month).startOf("month").add(1, "month").startOf("month").format("L"), - ]; +function getMonths(month: DateValue) { + return [month.subtract({ months: 1 }), month, month.add({ months: 1 })]; +} + +function usePreviousMonth(visibleMonth: DateValue) { + const previous = usePrevious(visibleMonth, [formatDate(visibleMonth)]); + + return previous ?? today(getLocalTimeZone()); } const withBaseName = makePrefixer("uitkCalendarCarousel"); @@ -36,21 +40,18 @@ export const CalendarCarousel = forwardRef< state: { visibleMonth }, } = useCalendarContext(); const containerRef = useRef(null); - const diffIndex = (a: Date | undefined, b: Date | undefined) => - dayjs(a).diff(dayjs(b), "month"); + const diffIndex = (a: DateValue, b: DateValue) => monthDiff(a, b); - const previousMonth = usePrevious(visibleMonth, [ - dayjs(visibleMonth).format("L"), - ]); const { current: baseIndex } = useRef(visibleMonth); + const previousVisibleMonth = usePreviousMonth(visibleMonth); useIsomorphicLayoutEffect(() => { - if (Math.abs(diffIndex(visibleMonth, previousMonth)) > 1) { + if (Math.abs(diffIndex(visibleMonth, previousVisibleMonth)) > 1) { containerRef.current?.classList.remove(withBaseName("shouldAnimate")); } else { containerRef.current?.classList.add(withBaseName("shouldAnimate")); } - }, [dayjs(visibleMonth).format("L"), dayjs(previousMonth).format("L")]); + }, [formatDate(visibleMonth), formatDate(previousVisibleMonth)]); useIsomorphicLayoutEffect(() => { if (containerRef.current) { @@ -64,10 +65,11 @@ export const CalendarCarousel = forwardRef< useEffect(() => { setMonths((oldMonths) => { - const newMonths = getMonths(visibleMonth); - const monthSet = new Set(oldMonths.concat(newMonths)); + const newMonths = getMonths(visibleMonth).filter((month) => { + return !oldMonths.find((oldMonth) => isSameMonth(oldMonth, month)); + }); - return Array.from(monthSet); + return oldMonths.concat(newMonths); }); const finishTransition = () => { setMonths(getMonths(visibleMonth)); @@ -88,7 +90,7 @@ export const CalendarCarousel = forwardRef< } return undefined; - }, [dayjs(visibleMonth).format("L")]); // eslint-disable-line react-hooks/exhaustive-deps + }, [formatDate(visibleMonth)]); // eslint-disable-line react-hooks/exhaustive-deps return (
{months.map((date, index) => (
- +
))}
diff --git a/internal/CalendarDay.tsx b/internal/CalendarDay.tsx index 3852adea81..3e0cc20a45 100644 --- a/internal/CalendarDay.tsx +++ b/internal/CalendarDay.tsx @@ -7,26 +7,22 @@ import { } from "@jpmorganchase/uitk-core"; import { CloseIcon } from "@jpmorganchase/uitk-icons"; import cx from "classnames"; -import { - ComponentPropsWithRef, - forwardRef, - ReactElement, - useCallback, -} from "react"; -import { DayStatus, useCalendarDay } from "../useCalendarDay"; -import dayjs from "./dayjs"; +import { ComponentPropsWithRef, forwardRef, ReactElement, useRef } from "react"; +import { DateValue } from "@internationalized/date"; +import { DayStatus, useCalendarDay } from "../useCalendarDay"; import "./CalendarDay.css"; +import { formatDate } from "./utils"; export type DateFormatter = (day: Date) => string | undefined; export interface CalendarDayProps extends Omit, "children"> { - day: Date; + day: DateValue; formatDate?: DateFormatter; - renderDayContents?: (date: Date, status: DayStatus) => ReactElement; + renderDayContents?: (date: DateValue, status: DayStatus) => ReactElement; status?: DayStatus; - month: Date; + month: DateValue; TooltipProps?: Partial; } @@ -37,12 +33,14 @@ export const CalendarDay = forwardRef( const { className, day, renderDayContents, month, TooltipProps, ...rest } = props; - const { status, dayProps, registerDay, unselectableReason } = - useCalendarDay({ + const dayRef = useRef(null); + const { status, dayProps, unselectableReason } = useCalendarDay( + { date: day, month, - }); - + }, + dayRef + ); const { outOfRange, today, unselectable, hidden } = status; const { getTriggerProps, getTooltipProps } = useTooltip({ @@ -50,7 +48,7 @@ export const CalendarDay = forwardRef( }); const { ref: triggerRef, ...triggerProps } = getTriggerProps<"button">({ - "aria-label": dayjs(day).format("dddd, LL"), + "aria-label": formatDate(day), ...dayProps, ...rest, className: cx( @@ -67,13 +65,7 @@ export const CalendarDay = forwardRef( ), }); - const registerDayRef = useCallback( - (node: HTMLButtonElement) => { - registerDay(day, node); - }, - [registerDay, day] - ); - const handleTriggerRef = useForkRef(triggerRef, registerDayRef); + const handleTriggerRef = useForkRef(triggerRef, dayRef); const handleRef = useForkRef(handleTriggerRef, ref); return ( @@ -97,7 +89,7 @@ export const CalendarDay = forwardRef( {renderDayContents ? renderDayContents(day, status) - : dayjs(day).format("D")} + : formatDate(day, { day: "numeric" })} ); diff --git a/internal/CalendarMonth.tsx b/internal/CalendarMonth.tsx index b37ccf88f4..04b97d1c66 100644 --- a/internal/CalendarMonth.tsx +++ b/internal/CalendarMonth.tsx @@ -5,16 +5,16 @@ import { SyntheticEvent, } from "react"; import cx from "classnames"; -import dayjs from "./dayjs"; -import { CalendarDay, CalendarDayProps } from "./CalendarDay"; -import { generateVisibleDays } from "./calendarUtils"; import { makePrefixer } from "@jpmorganchase/uitk-core"; +import { DateValue } from "@internationalized/date"; +import { CalendarDay, CalendarDayProps } from "./CalendarDay"; +import { formatDate, generateVisibleDays } from "./utils"; import "./CalendarMonth.css"; import { useCalendarContext } from "./CalendarContext"; export interface CalendarMonthProps extends ComponentPropsWithRef<"div"> { - date: Date; + date: DateValue; hideOutOfRangeDates?: boolean; renderDayContents?: CalendarDayProps["renderDayContents"]; isVisible?: boolean; @@ -36,10 +36,7 @@ export const CalendarMonth = forwardRef( ...rest } = props; - const month = dayjs(date).month(); - const year = dayjs(date).year(); - - const days = generateVisibleDays(year, month); + const days = generateVisibleDays(date); const { helpers: { setHoveredDate }, } = useCalendarContext(); @@ -63,7 +60,7 @@ export const CalendarMonth = forwardRef( {days.map((day) => { return ( { - setVisibleMonth(event, dayjs(visibleMonth).add(1, "month").toDate()); + setVisibleMonth(event, visibleMonth.add({ months: 1 })); }; const moveToPreviousMonth = (event: SyntheticEvent) => { - setVisibleMonth(event, dayjs(visibleMonth).subtract(1, "month").toDate()); + setVisibleMonth(event, visibleMonth.subtract({ months: 1 })); }; - const moveToMonth = (event: SyntheticEvent, month: Date) => { + const moveToMonth = (event: SyntheticEvent, month: DateValue) => { let newMonth = month; - if (isDateNavigable(newMonth, "year")) { - if (!isDateNavigable(newMonth, "month")) { + if (!isOutsideAllowedYears(newMonth)) { + if (isOutsideAllowedMonths(newMonth)) { // If month is navigable we should move to the closest navigable month - const navigableMonths = dayjs - .months() - .map((_, index) => dayjs(newMonth).set("month", index).toDate()) - .filter((n) => isDateNavigable(n, "month")); + const navigableMonths = monthsForLocale(visibleMonth).filter( + (n) => !isOutsideAllowedMonths(n) + ); newMonth = navigableMonths.reduce((closestMonth, currentMonth) => - Math.abs(dayjs(currentMonth).diff(newMonth, "month")) < - Math.abs(dayjs(closestMonth).diff(newMonth, "month")) + Math.abs(monthDiff(currentMonth, newMonth)) < + Math.abs(monthDiff(closestMonth, newMonth)) ? currentMonth : closestMonth ); @@ -77,23 +90,19 @@ function useCalendarNavigation() { } }; - const months = dayjs.months().map((month, monthIndex) => { - const date = dayjs(visibleMonth).set("month", monthIndex).toDate(); - return { value: date, disabled: !isDateNavigable(date, "month") }; + const months = monthsForLocale(visibleMonth).map((month) => { + return { value: month, disabled: isOutsideAllowedMonths(month) }; }); const years = [-2, -1, 0, 1, 2] - .map((delta) => { - const date = dayjs(visibleMonth).add(delta, "years").toDate(); - return { value: date }; - }) - .filter(({ value }) => isDateNavigable(value, "year")); + .map((delta) => ({ value: visibleMonth.add({ years: delta }) })) + .filter(({ value }) => !isOutsideAllowedYears(value)); const selectedMonth = months.find((item: DropdownItem) => - dayjs(item.value).isSame(visibleMonth, "month") + isSameMonth(item.value, visibleMonth) ); const selectedYear = years.find((item: DropdownItem) => - dayjs(item.value).isSame(visibleMonth, "year") + isSameYear(item.value, visibleMonth) ); const canNavigatePrevious = !(minDate && isDayVisible(minDate)); @@ -113,12 +122,11 @@ function useCalendarNavigation() { }; } -const ListItemWithTooltip = forwardRef< - HTMLDivElement, - ListItemProps ->((props, ref) => { - const { item, label } = props; - +const ListItemWithTooltip: ListItemType = ({ + item, + label, + ...props +}) => { const { getTooltipProps, getTriggerProps } = useTooltip({ placement: "right", disabled: !item?.disabled, @@ -127,10 +135,8 @@ const ListItemWithTooltip = forwardRef< const { ref: triggerRef, ...triggerProps } = getTriggerProps(props); - const handleRef = useForkRef(triggerRef, ref); - return ( - + {label} ); -}) as ListItemType; +}; export const CalendarNavigation = forwardRef< HTMLDivElement, @@ -176,7 +182,7 @@ export const CalendarNavigation = forwardRef< moveToNextMonth(event); }; - const handleMonthSelect: dateDropdownProps["onSelectionChange"] = ( + const handleMonthSelect: SelectionChangeHandler = ( event, month ) => { @@ -185,7 +191,7 @@ export const CalendarNavigation = forwardRef< } }; - const handleYearSelect: dateDropdownProps["onSelectionChange"] = ( + const handleYearSelect: SelectionChangeHandler = ( event, year ) => { @@ -197,7 +203,7 @@ export const CalendarNavigation = forwardRef< const monthDropdownId = useId(MonthDropdownProps?.id); const monthDropdownLabelledBy = cx( MonthDropdownProps?.["aria-labelledby"], - // TODO need a prop on Dropdown to aloow buttonId to be passed, should not make assumptions about internal + // TODO need a prop on Dropdown to allow buttonId to be passed, should not make assumptions about internal // id assignment like this `${monthDropdownId}-control` ); @@ -209,13 +215,13 @@ export const CalendarNavigation = forwardRef< const defaultItemToMonth = (date: DropdownItem) => { if (hideYearDropdown) { - return dayjs(date.value).format("MMMM"); + return formatDate(date.value, { month: "long" }); } - return dayjs(date.value).format("MMM"); + return formatDate(date.value, { month: "short" }); }; const defaultItemToYear = (date: DropdownItem) => { - return dayjs(date.value).format("YYYY"); + return formatDate(date.value, { year: "numeric" }); }; return ( @@ -235,9 +241,9 @@ export const CalendarNavigation = forwardRef< className={withBaseName("previousButton")} > @@ -273,9 +279,9 @@ export const CalendarNavigation = forwardRef< className={withBaseName("nextButton")} > diff --git a/internal/CalendarWeekHeader.tsx b/internal/CalendarWeekHeader.tsx index b6fc08c446..0ee760909d 100644 --- a/internal/CalendarWeekHeader.tsx +++ b/internal/CalendarWeekHeader.tsx @@ -1,9 +1,9 @@ import { forwardRef, ComponentPropsWithRef } from "react"; import cx from "classnames"; import { makePrefixer } from "@jpmorganchase/uitk-core"; -import dayjs from "./dayjs"; import "./CalendarWeekHeader.css"; +import { daysForLocale } from "./utils"; export type CalendarWeekHeaderProps = ComponentPropsWithRef<"div">; @@ -13,7 +13,7 @@ export const CalendarWeekHeader = forwardRef< HTMLDivElement, CalendarWeekHeaderProps >(function CalendarWeekHeader({ className, ...rest }, ref) { - const weekdaysShort = dayjs.weekdaysMin(true); + const weekdaysShort = daysForLocale("narrow"); return (
{ - const dayOfMonth = index + 1; - return { - date: dayjs(currentDate).set("date", dayOfMonth).toDate(), - dayOfMonth, - isCurrentMonth: true, - }; - }); -} - -export function getPreviousMonthDays( - year: number, - month: number, - firstDayOfTheMonth: Date -) { - const firstDayOfTheWeek = dayjs(firstDayOfTheMonth).weekday(0); - const previousMonth = dayjs(firstDayOfTheMonth).subtract(1, "month"); - - const visibleNumberOfDaysFromPreviousMonth = dayjs(firstDayOfTheMonth).diff( - firstDayOfTheWeek, - "days" - ); - - return new Array(visibleNumberOfDaysFromPreviousMonth) - .fill(0) - .map((_v, index) => { - const dayOfMonth = firstDayOfTheWeek.date() + index; - return { - date: dayjs(previousMonth).set("date", dayOfMonth).toDate(), - dayOfMonth, - isCurrentMonth: false, - }; - }); -} - -export function getNextMonthDays(year: number, month: number) { - const lastDayOfTheMonth = dayjs() - .set("year", year) - .set("month", month) - .endOf("month"); - - const endOfWeek = dayjs(lastDayOfTheMonth).weekday(6); - - const visibleNumberOfDaysFromNextMonth = dayjs(endOfWeek).diff( - lastDayOfTheMonth, - "days" - ); - - return new Array(visibleNumberOfDaysFromNextMonth) - .fill(0) - .map((_v, index) => { - const dayOfMonth = index + 1; - return { - date: dayjs(endOfWeek).set("date", dayOfMonth).toDate(), - dayOfMonth, - isCurrentMonth: false, - }; - }); -} - -export function getPadDays(startDate: Date, numberOfDays: number) { - return new Array(numberOfDays).fill(0).map((_v, index) => { - const dayOfMonth = dayjs(startDate).date() + index + 1; - return { - date: dayjs(startDate).set("date", dayOfMonth).toDate(), - dayOfMonth, - isCurrentMonth: false, - }; - }); -} - -export function generateVisibleDays(year: number, month: number) { - // Six weeks - const totalDays = 6 * 7; - - const currentMonthDays = getCurrentMonthDays(year, month); - const previousMonthDays = getPreviousMonthDays( - year, - month, - currentMonthDays[0].date - ); - const nextMonthDays = getNextMonthDays(year, month); - const lastDate = - nextMonthDays.length > 0 - ? nextMonthDays[nextMonthDays.length - 1].date - : currentMonthDays[currentMonthDays.length - 1].date; - const padDays = getPadDays( - lastDate, - totalDays - - (currentMonthDays.length + - previousMonthDays.length + - nextMonthDays.length) - ); - - return previousMonthDays.concat(currentMonthDays, nextMonthDays, padDays); -} diff --git a/internal/dayjs.ts b/internal/dayjs.ts deleted file mode 100644 index 173c4b1d5c..0000000000 --- a/internal/dayjs.ts +++ /dev/null @@ -1,16 +0,0 @@ -import dayjs from "dayjs"; -import weekday from "dayjs/plugin/weekday"; -import weekOfYear from "dayjs/plugin/weekOfYear"; -import localeData from "dayjs/plugin/localeData"; -import updateLocale from "dayjs/plugin/updateLocale"; -import localizedFormat from "dayjs/plugin/localizedFormat"; -import isBetween from "dayjs/plugin/isBetween"; - -dayjs.extend(weekday); -dayjs.extend(weekOfYear); -dayjs.extend(localeData); -dayjs.extend(updateLocale); -dayjs.extend(localizedFormat); -dayjs.extend(isBetween); - -export default dayjs; diff --git a/internal/useFocusManagement.ts b/internal/useFocusManagement.ts index d8df9bb678..1935c2b4c5 100644 --- a/internal/useFocusManagement.ts +++ b/internal/useFocusManagement.ts @@ -4,15 +4,11 @@ import { MouseEventHandler, } from "react"; import { useCalendarContext } from "./CalendarContext"; -import dayjs from "./dayjs"; +import { DateValue, endOfWeek, startOfWeek } from "@internationalized/date"; +import { getCurrentLocale } from "./utils"; -interface useFocusManagementProps { - date: Date; -} - -export function useFocusManagement({ date }: useFocusManagementProps) { +export function useFocusManagement({ date }: { date: DateValue }) { const { - state: { focusedDate }, helpers: { setFocusedDate }, } = useCalendarContext(); const handleClick: MouseEventHandler = (event) => { @@ -23,46 +19,46 @@ export function useFocusManagement({ date }: useFocusManagementProps) { let newDate = date; switch (event.key) { case "ArrowUp": - newDate = dayjs(date).subtract(1, "week").toDate(); + newDate = date.subtract({ weeks: 1 }); break; case "ArrowDown": - newDate = dayjs(date).add(1, "week").toDate(); + newDate = date.add({ weeks: 1 }); break; case "ArrowLeft": - newDate = dayjs(date).subtract(1, "day").toDate(); + newDate = date.subtract({ days: 1 }); break; case "ArrowRight": - newDate = dayjs(date).add(1, "day").toDate(); + newDate = date.add({ days: 1 }); break; case "Home": - newDate = dayjs(date).startOf("week").toDate(); + newDate = startOfWeek(date, getCurrentLocale()); break; case "End": - newDate = dayjs(date).endOf("week").toDate(); + // @ts-ignore TODO bug in @internationalized/date + newDate = endOfWeek(date, getCurrentLocale()); break; case "PageUp": if (event.shiftKey) { - newDate = dayjs(date).subtract(1, "year").toDate(); + newDate = date.subtract({ years: 1 }); } else { - newDate = dayjs(date).subtract(1, "month").toDate(); + newDate = date.subtract({ months: 1 }); } break; case "PageDown": if (event.shiftKey) { - newDate = dayjs(date).add(1, "year").toDate(); + newDate = date.add({ years: 1 }); } else { - newDate = dayjs(date).add(1, "month").toDate(); + newDate = date.add({ months: 1 }); } break; default: } + setFocusedDate(event, newDate); }; const handleFocus: FocusEventHandler = (event) => { - if (!dayjs(date).isSame(focusedDate, "day")) { - setFocusedDate(event, date); - } + setFocusedDate(event, date); }; return { diff --git a/internal/useSelection.ts b/internal/useSelection.ts index 11d0e5f868..3a11c70db9 100644 --- a/internal/useSelection.ts +++ b/internal/useSelection.ts @@ -3,32 +3,32 @@ import classnames from "classnames"; import { KeyboardEventHandler, MouseEventHandler, SyntheticEvent } from "react"; import { isPlainObject } from "../../utils"; import { useCalendarContext } from "./CalendarContext"; -import dayjs from "./dayjs"; +import { CalendarDate, DateValue, isSameDay } from "@internationalized/date"; interface BaseUseSelectionCalendarProps { - hoveredDate?: Date | null; + hoveredDate?: DateValue | null; selectedDate?: SelectionVariantType | null; - initialSelectedDate?: SelectionVariantType; + defaultSelectedDate?: SelectionVariantType; onSelectedDateChange?: ( event: SyntheticEvent, selectedDate: SelectionVariantType ) => void; - isDaySelectable: (date?: Date) => boolean; + isDaySelectable: (date?: DateValue) => boolean; onHoveredDateChange?: ( event: SyntheticEvent, - hoveredDate: Date | null + hoveredDate: DateValue | null ) => void; } -type SingleSelectionValueType = Date; -type MultiSelectionValueType = Date[]; +type SingleSelectionValueType = DateValue; +type MultiSelectionValueType = DateValue[]; type RangeSelectionValueType = { - startDate?: Date; - endDate?: Date; + startDate?: DateValue; + endDate?: DateValue; }; type OffsetSelectionValueType = { - startDate?: Date; - endDate?: Date; + startDate?: DateValue; + endDate?: DateValue; }; type AllSelectionValueType = @@ -44,8 +44,8 @@ export interface UseOffsetSelectionCalendarProps "startDateOffset" | "endDateOffset" > { selectionVariant: "offset"; - startDateOffset?: (date: Date) => Date; - endDateOffset?: (date: Date) => Date; + startDateOffset?: (date: DateValue) => DateValue; + endDateOffset?: (date: DateValue) => DateValue; } export interface UseRangeSelectionCalendarProps @@ -71,18 +71,18 @@ export type useSelectionCalendarProps = function addOrRemoveFromArray( array: AllSelectionValueType | null = [], - item: Date + item: DateValue ) { if (Array.isArray(array)) { - if (array.find((element) => dayjs(element).isSame(item, "day"))) { - return array.filter((element) => !dayjs(element).isSame(item, "day")); + if (array.find((element) => isSameDay(element, item))) { + return array.filter((element) => !isSameDay(element, item)); } return array.concat(item); } return [item]; } -const defaultOffset = (date: Date) => date; +const defaultOffset = (date: DateValue) => date; function isRangeOrOffsetSelectionValue( selectionValue: AllSelectionValueType @@ -96,7 +96,7 @@ export function useSelectionCalendar(props: useSelectionCalendarProps) { const { hoveredDate: hoveredDateProp, selectedDate: selectedDateProp, - initialSelectedDate, + defaultSelectedDate, // onSelectedDateChange, onHoveredDateChange, isDaySelectable, @@ -106,12 +106,12 @@ export function useSelectionCalendar(props: useSelectionCalendarProps) { } = props; const [selectedDate, setSelectedDateState] = useControlled({ controlled: selectedDateProp, - default: initialSelectedDate, + default: defaultSelectedDate, name: "Calendar", state: "selectedDate", }); - const getStartDateOffset = (date: Date) => { + const getStartDateOffset = (date: DateValue) => { if (props.selectionVariant === "offset" && props.startDateOffset) { return props.startDateOffset(date); } else { @@ -119,7 +119,7 @@ export function useSelectionCalendar(props: useSelectionCalendarProps) { } }; - const getEndDateOffset = (date: Date) => { + const getEndDateOffset = (date: DateValue) => { if (props.selectionVariant === "offset" && props.endDateOffset) { return props.endDateOffset(date); } else { @@ -129,7 +129,7 @@ export function useSelectionCalendar(props: useSelectionCalendarProps) { const setSelectedDate = ( event: SyntheticEvent, - newSelectedDate: Date + newSelectedDate: DateValue ) => { if (isDaySelectable(newSelectedDate)) { switch (props.selectionVariant) { @@ -149,7 +149,7 @@ export function useSelectionCalendar(props: useSelectionCalendarProps) { base = { startDate: newSelectedDate }; } else if ( base?.startDate && - dayjs(newSelectedDate).isAfter(base.startDate, "day") + newSelectedDate.compare(base.startDate) > 0 ) { base = { ...base, endDate: newSelectedDate }; } else { @@ -172,17 +172,16 @@ export function useSelectionCalendar(props: useSelectionCalendarProps) { } }; - const isSelected = (date: Date) => { + const isSelected = (date: DateValue) => { switch (selectionVariant) { case "default": return ( - selectedDate instanceof Date && - dayjs(selectedDate).isSame(date, "day") + selectedDate instanceof CalendarDate && isSameDay(selectedDate, date) ); case "multiselect": return ( Array.isArray(selectedDate) && - !!selectedDate.find((element) => dayjs(element).isSame(date, "day")) + !!selectedDate.find((element) => isSameDay(element, date)) ); default: return false; @@ -196,31 +195,30 @@ export function useSelectionCalendar(props: useSelectionCalendarProps) { state: "hoveredDate", }); - const setHoveredDate = (event: SyntheticEvent, date: Date | null) => { + const setHoveredDate = (event: SyntheticEvent, date: DateValue | null) => { setHoveredDateState(date); onHoveredDateChange?.(event, date); }; - const isHovered = (date: Date) => { - return !!hoveredDate && dayjs(date).isSame(hoveredDate, "date"); + const isHovered = (date: DateValue) => { + return !!hoveredDate && isSameDay(date, hoveredDate); }; - const isSelectedSpan = (date: Date) => { + const isSelectedSpan = (date: DateValue) => { if ( (selectionVariant === "range" || selectionVariant === "offset") && isRangeOrOffsetSelectionValue(selectedDate) && selectedDate?.startDate && selectedDate?.endDate ) { - return dayjs(date).isBetween( - selectedDate.startDate, - selectedDate.endDate, - "days" + return ( + date.compare(selectedDate.startDate) > 0 && + date.compare(selectedDate.endDate) < 0 ); } return false; }; - const isHoveredSpan = (date: Date) => { + const isHoveredSpan = (date: DateValue) => { if ( (selectionVariant === "range" || selectionVariant === "offset") && isRangeOrOffsetSelectionValue(selectedDate) && @@ -229,9 +227,10 @@ export function useSelectionCalendar(props: useSelectionCalendarProps) { hoveredDate ) { const isForwardRange = - dayjs(hoveredDate).isAfter(selectedDate.startDate) && - (dayjs(date).isBetween(selectedDate.startDate, hoveredDate, "day") || - dayjs(date).isSame(hoveredDate, "day")); + hoveredDate.compare(selectedDate.startDate) > 0 && + ((date.compare(selectedDate.startDate) > 0 && + date.compare(hoveredDate) < 0) || + isSameDay(date, hoveredDate)); const isValidDayHovered = isDaySelectable(hoveredDate); @@ -240,35 +239,36 @@ export function useSelectionCalendar(props: useSelectionCalendarProps) { return false; }; - const isSelectedStart = (date: Date) => { + const isSelectedStart = (date: DateValue) => { if ( (selectionVariant === "range" || selectionVariant === "offset") && isRangeOrOffsetSelectionValue(selectedDate) && selectedDate.startDate ) { - return dayjs(selectedDate.startDate).isSame(date, "day"); + return isSameDay(selectedDate.startDate, date); } return false; }; - const isSelectedEnd = (date: Date) => { + const isSelectedEnd = (date: DateValue) => { if ( (selectionVariant === "range" || selectionVariant === "offset") && isRangeOrOffsetSelectionValue(selectedDate) && selectedDate.endDate ) { - return dayjs(selectedDate.endDate).isSame(date, "day"); + return isSameDay(selectedDate.endDate, date); } return false; }; - const isHoveredOffset = (date: Date) => { + const isHoveredOffset = (date: DateValue) => { if (hoveredDate && selectionVariant === "offset") { const startDate = getStartDateOffset(hoveredDate); const endDate = getEndDateOffset(hoveredDate); return ( - dayjs(date).isBetween(dayjs(startDate), dayjs(endDate), "days", "[]") && + date.compare(startDate) >= 0 && + date.compare(endDate) <= 0 && isDaySelectable(date) ); } @@ -295,7 +295,7 @@ export function useSelectionCalendar(props: useSelectionCalendarProps) { }; } -export function useSelectionDay({ date }: { date: Date }) { +export function useSelectionDay({ date }: { date: DateValue }) { const { helpers: { setSelectedDate, diff --git a/internal/utils.ts b/internal/utils.ts new file mode 100644 index 0000000000..e842d0fcd9 --- /dev/null +++ b/internal/utils.ts @@ -0,0 +1,77 @@ +import { + createCalendar, + DateFormatter, + DateValue, + getLocalTimeZone, + isSameMonth, + startOfMonth, + startOfWeek, + startOfYear, + today, +} from "@internationalized/date"; + +const localTimezone = getLocalTimeZone(); + +export function getCurrentLocale() { + return navigator.languages[0]; +} + +export function getDateFormatter(options?: Intl.DateTimeFormatOptions) { + return new DateFormatter(getCurrentLocale(), options); +} + +export function formatDate( + date: DateValue, + options?: Intl.DateTimeFormatOptions +) { + const formatter = getDateFormatter(options); + return formatter.format(date.toDate(localTimezone)); +} + +export function getCalender() { + const calendarIdentifier = getDateFormatter().resolvedOptions().calendar; + return createCalendar(calendarIdentifier); +} + +type WeekdayFormat = Intl.DateTimeFormatOptions["weekday"]; + +export function daysForLocale(weekday: WeekdayFormat = "long") { + return [...Array(7).keys()].map((day) => + formatDate( + startOfWeek(today(getLocalTimeZone()), getCurrentLocale()).add({ + days: day, + }), + { weekday } + ) + ); +} + +export function monthsForLocale(currentYear: DateValue) { + const calendar = getCalender(); + return [...Array(calendar.getMonthsInYear(currentYear)).keys()].map((month) => + startOfYear(currentYear).add({ months: month }) + ); +} + +function mapDate(currentDate: DateValue, currentMonth: DateValue) { + return { + date: currentDate, + dateOfMonth: currentDate.month, + isCurrentMonth: isSameMonth(currentDate, currentMonth), + }; +} + +export function generateVisibleDays(currentMonth: DateValue) { + const totalDays = 6 * 7; + const currentLocale = getCurrentLocale(); + const startDate = startOfWeek(startOfMonth(currentMonth), currentLocale); + + return [...Array(totalDays).keys()].map((dayDelta) => { + const day = startDate.add({ days: dayDelta }); + return mapDate(day, currentMonth); + }); +} + +export function monthDiff(a: DateValue, b: DateValue) { + return b.month - a.month + 12 * (b.year - a.year); +} diff --git a/useCalendar.ts b/useCalendar.ts index d2a0598507..cab98d5a1b 100644 --- a/useCalendar.ts +++ b/useCalendar.ts @@ -1,6 +1,15 @@ import { useControlled } from "@jpmorganchase/uitk-core"; -import { SyntheticEvent, useRef, useState } from "react"; -import dayjs from "./internal/dayjs"; +import { SyntheticEvent, useEffect, useState } from "react"; +import { + DateValue, + endOfMonth, + endOfYear, + getLocalTimeZone, + isSameDay, + startOfMonth, + startOfYear, + today, +} from "@internationalized/date"; import { UseMultiSelectionCalendarProps, UseOffsetSelectionCalendarProps, @@ -15,14 +24,16 @@ export type UnselectableInfo = | { emphasis: "low" }; interface BaseUseCalendarProps { - initialVisibleMonth?: Date; - onVisibleMonthChange?: (event: SyntheticEvent, visibleMonth: Date) => void; - isDayUnselectable?: (date: Date) => UnselectableInfo | boolean; - visibleMonth?: Date; - firstDayOfWeek?: number; + defaultVisibleMonth?: DateValue; + onVisibleMonthChange?: ( + event: SyntheticEvent, + visibleMonth: DateValue + ) => void; + isDayUnselectable?: (date: DateValue) => UnselectableInfo | boolean; + visibleMonth?: DateValue; hideOutOfRangeDates?: boolean; - minDate?: Date; - maxDate?: Date; + minDate?: DateValue; + maxDate?: DateValue; } export type useCalendarProps = ( @@ -38,13 +49,12 @@ const defaultIsDayUnselectable = (): UnselectableInfo | boolean => false; export function useCalendar(props: useCalendarProps) { const { selectedDate, - initialSelectedDate, + defaultSelectedDate, visibleMonth: visibleMonthProp, hideOutOfRangeDates, - initialVisibleMonth = dayjs().startOf("month").toDate(), + defaultVisibleMonth = today(getLocalTimeZone()), onSelectedDateChange, onVisibleMonthChange, - firstDayOfWeek = 1, isDayUnselectable = defaultIsDayUnselectable, minDate, maxDate, @@ -55,10 +65,11 @@ export function useCalendar(props: useCalendarProps) { // endDateOffset, } = props; - const isDaySelectable = (date?: Date) => !(date && isDayUnselectable(date)); + const isDaySelectable = (date?: DateValue) => + !(date && isDayUnselectable(date)); const selectionManager = useSelectionCalendar({ - initialSelectedDate, + defaultSelectedDate: defaultSelectedDate, selectedDate, onSelectedDateChange, startDateOffset: @@ -75,77 +86,77 @@ export function useCalendar(props: useCalendarProps) { hoveredDate, } as useSelectionCalendarProps); - dayjs.updateLocale(dayjs.locale(), { weekStart: firstDayOfWeek }); - const [visibleMonth, setVisibleMonthState] = useControlled({ - controlled: visibleMonthProp - ? dayjs(visibleMonthProp).startOf("month").toDate() - : undefined, - default: dayjs(initialVisibleMonth).startOf("month").toDate(), + controlled: visibleMonthProp ? startOfMonth(visibleMonthProp) : undefined, + default: startOfMonth(defaultVisibleMonth), name: "Calendar", state: "visibleMonth", }); - const [focusedDate, setFocusedDateState] = useState( - dayjs(visibleMonth).startOf("month").toDate() + const [calendarFocused, setCalendarFocused] = useState(false); + + const [focusedDate, setFocusedDateState] = useState( + startOfMonth(visibleMonth) ); - const isDayVisible = (date: Date) => { - const startInsideDays = dayjs(visibleMonth).startOf("month"); + const isDayVisible = (date: DateValue) => { + const startInsideDays = startOfMonth(visibleMonth); - if (dayjs(date).isBefore(startInsideDays, "day")) return false; + if (date.compare(startInsideDays) < 0) return false; - const endInsideDays = dayjs(visibleMonth).endOf("month"); + const endInsideDays = endOfMonth(visibleMonth); - return !dayjs(date).isAfter(endInsideDays, "day"); + return !(date.compare(endInsideDays) > 0); }; - const isDateNavigable = (date: Date, type: "month" | "year") => { - if (minDate && dayjs(date).isBefore(dayjs(minDate), type)) { - return false; - } + const isOutsideAllowedDates = (date: DateValue) => { + return ( + (minDate != null && date.compare(minDate) < 0) || + (maxDate != null && date.compare(maxDate) > 0) + ); + }; - if (maxDate && dayjs(date).isAfter(dayjs(maxDate), type)) { - return false; - } + const isOutsideAllowedMonths = (date: DateValue) => { + return ( + (minDate != null && endOfMonth(date).compare(minDate) < 0) || + (maxDate != null && startOfMonth(date).compare(maxDate) > 0) + ); + }; - return true; + const isOutsideAllowedYears = (date: DateValue) => { + return ( + (minDate != null && endOfYear(date).compare(minDate) < 0) || + (maxDate != null && startOfYear(date).compare(maxDate) > 0) + ); }; - const setFocusedDate = (event: SyntheticEvent, date: Date) => { - if ( - dayjs(date).isSame(focusedDate, "day") || - !isDateNavigable(date, "month") - ) - return; + const setFocusedDate = (event: SyntheticEvent, date: DateValue) => { + if (isSameDay(date, focusedDate) || isOutsideAllowedDates(date)) return; setFocusedDateState(date); const shouldTransition = !isDayVisible(date) && isDaySelectable(date) && - isDateNavigable(date, "month"); + !isOutsideAllowedDates(date); if (shouldTransition) { - setVisibleMonth(event, dayjs(date).startOf("month").toDate()); + setVisibleMonth(event, startOfMonth(date)); } - setTimeout(() => { - dayRefs.current[dayjs(date).format("L")]?.focus({ preventScroll: true }); - }); }; - const setVisibleMonth = (event: SyntheticEvent, newVisibleMonth: Date) => { + const setVisibleMonth = ( + event: SyntheticEvent, + newVisibleMonth: DateValue + ) => { setVisibleMonthState(newVisibleMonth); - if (!dayjs(focusedDate).isSame(newVisibleMonth, "month")) { - setFocusedDateState(dayjs(newVisibleMonth).startOf("month").toDate()); - } onVisibleMonthChange?.(event, newVisibleMonth); }; - const dayRefs = useRef>({}); - - const registerDayRef = (date: Date, element: HTMLElement) => { - dayRefs.current[dayjs(date).format("L")] = element; - }; + useEffect(() => { + if (!isDayVisible(focusedDate)) { + setFocusedDateState(startOfMonth(visibleMonth)); + } + }, [isDayVisible, focusedDate, visibleMonth, setFocusedDate]); return { state: { @@ -155,16 +166,19 @@ export function useCalendar(props: useCalendarProps) { maxDate, selectionVariant, hideOutOfRangeDates, + calendarFocused, ...selectionManager.state, }, helpers: { setVisibleMonth, setFocusedDate, + setCalendarFocused, isDayUnselectable, isDayVisible, - isDateNavigable, + isOutsideAllowedDates, + isOutsideAllowedMonths, + isOutsideAllowedYears, ...selectionManager.helpers, - registerDayRef, }, }; } diff --git a/useCalendarDay.ts b/useCalendarDay.ts index bc59b1495e..2a7a3d4eb7 100644 --- a/useCalendarDay.ts +++ b/useCalendarDay.ts @@ -1,14 +1,21 @@ -import dayjs from "./internal/dayjs"; import { useCalendarContext } from "./internal/CalendarContext"; import { KeyboardEventHandler, MouseEventHandler, FocusEventHandler, ComponentPropsWithoutRef, - useCallback, + RefObject, + useEffect, } from "react"; import { useSelectionDay } from "./internal/useSelection"; import { useFocusManagement } from "./internal/useFocusManagement"; +import { + DateValue, + getLocalTimeZone, + isSameDay, + isSameMonth, + isToday, +} from "@internationalized/date"; export type DayStatus = { outOfRange?: boolean; @@ -20,14 +27,17 @@ export type DayStatus = { }; export interface useCalendarDayProps { - date: Date; - month: Date; + date: DateValue; + month: DateValue; } -export function useCalendarDay({ date, month }: useCalendarDayProps) { +export function useCalendarDay( + { date, month }: useCalendarDayProps, + ref: RefObject +) { const { - state: { focusedDate, hideOutOfRangeDates }, - helpers: { isDayUnselectable, registerDayRef, isDateNavigable }, + state: { focusedDate, hideOutOfRangeDates, calendarFocused }, + helpers: { isDayUnselectable, isOutsideAllowedMonths }, } = useCalendarContext(); const selectionManager = useSelectionDay({ date }); const focusManager = useFocusManagement({ date }); @@ -57,22 +67,14 @@ export function useCalendarDay({ date, month }: useCalendarDayProps) { onMouseOver: handleMouseOver, }; - const focused = dayjs(date).isSame(focusedDate, "day"); - const outOfRange = !dayjs(date).isSame(month, "month"); - const tabIndex = focused && !outOfRange ? 0 : -1; - const today = dayjs().isSame(date, "day"); - - const registerDay = useCallback( - (day: Date, element: HTMLElement) => { - if (!outOfRange) { - registerDayRef(date, element); - } - }, - [date, outOfRange, registerDayRef] - ); + const outOfRange = !isSameMonth(date, month); + const focused = + isSameDay(date, focusedDate) && calendarFocused && !outOfRange; + const tabIndex = isSameDay(date, focusedDate) && !outOfRange ? 0 : -1; + const today = isToday(date, getLocalTimeZone()); const unselectableResult = - isDayUnselectable(date) || (outOfRange && !isDateNavigable(date, "month")); + isDayUnselectable(date) || (outOfRange && isOutsideAllowedMonths(date)); const unselectableReason = typeof unselectableResult !== "boolean" && unselectableResult.emphasis === "high" @@ -86,6 +88,12 @@ export function useCalendarDay({ date, month }: useCalendarDayProps) { : false; const hidden = hideOutOfRangeDates && outOfRange; + useEffect(() => { + if (focused) { + ref.current?.focus({ preventScroll: true }); + } + }, [ref, focused]); + return { status: { outOfRange, @@ -103,6 +111,5 @@ export function useCalendarDay({ date, month }: useCalendarDayProps) { ...selectionManager.dayProps, } as ComponentPropsWithoutRef<"button">, unselectableReason, - registerDay, }; }