From 53a318d8971761cb32ebfc9493e979802c154971 Mon Sep 17 00:00:00 2001 From: Junior Garcia Date: Tue, 2 Apr 2024 15:08:52 -0300 Subject: [PATCH 1/4] feat(calendar): range calendar added, calendar and context adapted --- .../calendar/src/calendar-context.ts | 9 +- .../calendar/src/calendar-header.tsx | 2 +- packages/components/calendar/src/calendar.tsx | 2 +- packages/components/calendar/src/index.ts | 8 +- .../calendar/src/range-calendar.tsx | 35 +++ .../calendar/src/use-calendar-base.ts | 293 ++++++++++++++++++ .../calendar/src/use-calendar-picker.ts | 4 +- .../components/calendar/src/use-calendar.ts | 276 +++-------------- .../calendar/src/use-range-calendar.ts | 129 ++++++++ .../stories/range-calendar.stories.tsx | 46 +++ packages/components/switch/src/use-switch.ts | 1 + 11 files changed, 560 insertions(+), 245 deletions(-) create mode 100644 packages/components/calendar/src/range-calendar.tsx create mode 100644 packages/components/calendar/src/use-calendar-base.ts create mode 100644 packages/components/calendar/src/use-range-calendar.ts create mode 100644 packages/components/calendar/stories/range-calendar.stories.tsx diff --git a/packages/components/calendar/src/calendar-context.ts b/packages/components/calendar/src/calendar-context.ts index f2f9215ac8..474799e91d 100644 --- a/packages/components/calendar/src/calendar-context.ts +++ b/packages/components/calendar/src/calendar-context.ts @@ -1,8 +1,13 @@ -import type {ContextType} from "./use-calendar"; +import type {ContextType} from "./use-calendar-base"; +import type {CalendarState, RangeCalendarState} from "@react-stately/calendar"; import {createContext} from "@nextui-org/react-utils"; -export const [CalendarProvider, useCalendarContext] = createContext({ +export const [CalendarProvider, useCalendarContext] = createContext< + ContextType +>({ name: "CalendarContext", strict: true, + errorMessage: + "useContext: `context` is undefined. Seems you forgot to wrap component within the CalendarProvider", }); diff --git a/packages/components/calendar/src/calendar-header.tsx b/packages/components/calendar/src/calendar-header.tsx index 0579ee4f0c..7e04926dd1 100644 --- a/packages/components/calendar/src/calendar-header.tsx +++ b/packages/components/calendar/src/calendar-header.tsx @@ -90,7 +90,7 @@ export function CalendarHeader(props: CalendarHeaderProps) { e.preventDefault(); e.stopPropagation(); // Close the month and year pickers - setIsHeaderExpanded(false); + setIsHeaderExpanded?.(false); } }, [setIsHeaderExpanded], diff --git a/packages/components/calendar/src/calendar.tsx b/packages/components/calendar/src/calendar.tsx index 19f8693960..2d89699dd3 100644 --- a/packages/components/calendar/src/calendar.tsx +++ b/packages/components/calendar/src/calendar.tsx @@ -4,8 +4,8 @@ import type {ForwardedRef, ReactElement, Ref} from "react"; import {forwardRef} from "@nextui-org/system"; import {UseCalendarProps, useCalendar} from "./use-calendar"; -import {CalendarBase} from "./calendar-base"; import {CalendarProvider} from "./calendar-context"; +import {CalendarBase} from "./calendar-base"; interface Props extends Omit, "isHeaderWrapperExpanded"> {} diff --git a/packages/components/calendar/src/index.ts b/packages/components/calendar/src/index.ts index fbba613dd9..e534c283e0 100644 --- a/packages/components/calendar/src/index.ts +++ b/packages/components/calendar/src/index.ts @@ -1,12 +1,18 @@ import Calendar from "./calendar"; +import RangeCalendar from "./range-calendar"; // export types export type {CalendarProps} from "./calendar"; +export type {RangeCalendarProps} from "./range-calendar"; export type {CalendarDate} from "@internationalized/date"; export type {DateValue} from "@react-types/calendar"; // export hooks export {useCalendar} from "./use-calendar"; +export {useRangeCalendar} from "./use-range-calendar"; + +// export context +export {CalendarProvider, useCalendarContext} from "./calendar-context"; // export component -export {Calendar}; +export {Calendar, RangeCalendar}; diff --git a/packages/components/calendar/src/range-calendar.tsx b/packages/components/calendar/src/range-calendar.tsx new file mode 100644 index 0000000000..04cefe512c --- /dev/null +++ b/packages/components/calendar/src/range-calendar.tsx @@ -0,0 +1,35 @@ +import type {DateValue} from "@react-types/calendar"; +import type {ForwardedRef, ReactElement, Ref} from "react"; + +import {forwardRef} from "@nextui-org/system"; + +import {UseRangeCalendarProps, useRangeCalendar} from "./use-range-calendar"; +import {CalendarProvider} from "./calendar-context"; +import {CalendarBase} from "./calendar-base"; + +interface Props + extends Omit< + UseRangeCalendarProps, + "isHeaderExpanded" | "onHeaderExpandedChange" | "isHeaderWrapperExpanded" + > {} + +function RangeCalendar(props: Props, ref: ForwardedRef) { + const {context, getBaseCalendarProps} = useRangeCalendar({...props, ref}); + + return ( + + + + ); +} + +RangeCalendar.displayName = "NextUI.RangeCalendar"; + +export type RangeCalendarProps = Props & { + ref?: Ref; +}; + +// forwardRef doesn't support generic parameters, so cast the result to the correct type +export default forwardRef(RangeCalendar) as ( + props: RangeCalendarProps, +) => ReactElement; diff --git a/packages/components/calendar/src/use-calendar-base.ts b/packages/components/calendar/src/use-calendar-base.ts new file mode 100644 index 0000000000..4909d60b8d --- /dev/null +++ b/packages/components/calendar/src/use-calendar-base.ts @@ -0,0 +1,293 @@ +import type {CalendarReturnType, CalendarVariantProps} from "@nextui-org/theme"; +import type {CalendarPropsBase as AriaCalendarPropsBase} from "@react-types/calendar"; +import type {CalendarSlots, SlotsToClasses} from "@nextui-org/theme"; +import type {AriaCalendarGridProps} from "@react-aria/calendar"; +import type {AriaButtonProps} from "@react-types/button"; +import type {HTMLNextUIProps, PropGetter} from "@nextui-org/system"; +import type {ButtonProps} from "@nextui-org/button"; +import type {SupportedCalendars} from "@nextui-org/system"; +import type {CalendarState, RangeCalendarState} from "@react-stately/calendar"; +import type {RefObject, ReactNode} from "react"; + +import {Calendar, CalendarDate} from "@internationalized/date"; +import {mapPropsVariants} from "@nextui-org/system"; +import {useCallback, useMemo} from "react"; +import {calendar} from "@nextui-org/theme"; +import {useControlledState} from "@react-stately/utils"; +import {ReactRef, useDOMRef} from "@nextui-org/react-utils"; +import {useLocale} from "@react-aria/i18n"; +import {clamp, objectToDeps} from "@nextui-org/shared-utils"; +import {mergeProps} from "@react-aria/utils"; +import {useProviderContext} from "@nextui-org/system"; + +type NextUIBaseProps = Omit, keyof AriaCalendarPropsBase | "onChange">; + +interface Props extends NextUIBaseProps { + /** + * Ref to the DOM node. + */ + ref?: ReactRef; + + /** + * @internal + */ + isRange?: boolean; + /** + * Custom content to be included in the top of the calendar. + */ + topContent?: ReactNode; + /** + * Custom content to be included in the bottom of the calendar. + */ + bottomContent?: ReactNode; + /** + * The number of months to display at once. Up to 3 months are supported. + * Passing a number greater than 1 will disable the `showMonthAndYearPickers` prop. + * + * @default 1 + */ + visibleMonths?: number; + /** + * Props for the navigation button, prev button and next button. + */ + navButtonProps?: ButtonProps; + /** + * Props for the previous button. + */ + prevButtonProps?: ButtonProps; + /** + * Props for the next button. + */ + nextButtonProps?: ButtonProps; + /** + * Whether the calendar header is expanded. This is only available if the `showMonthAndYearPickers` prop is set to `true`. + * @default false + */ + isHeaderExpanded?: boolean; + /** + * Whether the calendar header should be expanded by default.This is only available if the `showMonthAndYearPickers` prop is set to `true`. + * @default false + */ + isHeaderDefaultExpanded?: boolean; + /** + * The event handler for the calendar header expanded state. This is only available if the `showMonthAndYearPickers` prop is set to `true`. + * @param ixExpanded boolean + * @returns void + */ + onHeaderExpandedChange?: (ixExpanded: boolean) => void; + /** + * This function helps to reduce the bundle size by providing a custom calendar system. + * + * In the example above, the createCalendar function from the `@internationalized/date` package + * is passed to the useCalendarState hook. This function receives a calendar identifier string, + * and provides Calendar instances to React Stately, which are used to implement date manipulation. + * + * By default, this includes all calendar systems supported by @internationalized/date. However, + * if your application supports a more limited set of regions, or you know you will only be picking dates + * in a certain calendar system, you can reduce your bundle size by providing your own implementation + * of `createCalendar` that includes a subset of these Calendar implementations. + * + * For example, if your application only supports Gregorian dates, you could implement a `createCalendar` + * function like this: + * + * @example + * + * import {GregorianCalendar} from '@internationalized/date'; + * + * function createCalendar(identifier) { + * switch (identifier) { + * case 'gregory': + * return new GregorianCalendar(); + * default: + * throw new Error(`Unsupported calendar ${identifier}`); + * } + * } + * + * This way, only GregorianCalendar is imported, and the other calendar implementations can be tree-shaken. + * + * You can also use the NextUIProvider to provide the createCalendar function to all nested components. + * + * @default all calendars + */ + createCalendar?: (calendar: SupportedCalendars) => Calendar | null; + /** + * The style of weekday names to display in the calendar grid header, + * e.g. single letter, abbreviation, or full day name. + * @default "narrow" + */ + weekdayStyle?: AriaCalendarGridProps["weekdayStyle"]; + /** + * Classname or List of classes to change the classNames of the element. + * if `className` is passed, it will be added to the base slot. + * + * @example + * ```ts + * + * + * + * ``` + */ + classNames?: SlotsToClasses; +} + +export type UseCalendarBasePropsComplete = Props & CalendarVariantProps & AriaCalendarPropsBase; + +// Omit internal props +export type UseCalendarBaseProps = Omit; + +export type ContextType = { + state: T; + visibleMonths: number; + headerRef?: RefObject; + slots?: CalendarReturnType; + weekdayStyle?: AriaCalendarGridProps["weekdayStyle"]; + isHeaderExpanded?: boolean; + showMonthAndYearPickers?: boolean; + setIsHeaderExpanded?: (isExpanded: boolean) => void; + classNames?: SlotsToClasses; + disableAnimation?: boolean; +}; + +export function useCalendarBase(originalProps: UseCalendarBasePropsComplete) { + const [props, variantProps] = mapPropsVariants(originalProps, calendar.variantKeys); + + const providerContext = useProviderContext(); + + const { + ref, + as, + children, + className, + topContent, + bottomContent, + isRange = false, + visibleMonths: visibleMonthsProp = 1, + weekdayStyle = "narrow", + navButtonProps = {}, + isHeaderExpanded: isHeaderExpandedProp, + isHeaderDefaultExpanded, + onHeaderExpandedChange = () => {}, + minValue = providerContext?.defaultDates?.minDate ?? new CalendarDate(1900, 1, 1), + maxValue = providerContext?.defaultDates?.maxDate ?? new CalendarDate(2099, 12, 31), + createCalendar: createCalendarProp = providerContext?.createCalendar ?? null, + prevButtonProps: prevButtonPropsProp, + nextButtonProps: nextButtonPropsProp, + errorMessage, + classNames, + ...otherProps + } = props; + + const Component = as || "div"; + const visibleMonths = clamp(visibleMonthsProp, 1, 3); + + /** + * Determines whether to show the month and year pickers. + * The pickers are shown if `showMonthAndYearPickers` is true, + * there is only one visible month (`visibleMonths === 1`), + * and it's not a range calendar (`!isRange`). + */ + const showMonthAndYearPickers = + originalProps.showMonthAndYearPickers && visibleMonths === 1 && !isRange; + + const domRef = useDOMRef(ref); + + const handleHeaderExpandedChange = useCallback( + (isExpanded: boolean | undefined) => { + onHeaderExpandedChange(isExpanded || false); + }, + [onHeaderExpandedChange], + ); + + const [isHeaderExpanded, setIsHeaderExpanded] = useControlledState( + isHeaderExpandedProp, + isHeaderDefaultExpanded, + handleHeaderExpandedChange, + ); + + const visibleDuration = useMemo(() => ({months: visibleMonths}), [visibleMonths]); + const shouldFilterDOMProps = typeof Component === "string"; + + const {locale} = useLocale(); + + const slots = useMemo( + () => + calendar({ + ...variantProps, + showMonthAndYearPickers, + isHeaderWrapperExpanded: isHeaderExpanded, + className, + }), + [objectToDeps(variantProps), showMonthAndYearPickers, isHeaderExpanded, className], + ); + + const disableAnimation = originalProps.disableAnimation ?? false; + + const commonButtonProps: ButtonProps = { + size: "sm", + variant: "light", + radius: "full", + isIconOnly: true, + disableAnimation, + ...navButtonProps, + }; + + const getPrevButtonProps = (props = {}) => { + return { + "data-slot": "prev-button", + tabIndex: isHeaderExpanded ? -1 : 0, + className: slots.prevButton({class: classNames?.prevButton}), + ...mergeProps(commonButtonProps, prevButtonPropsProp, props), + } as AriaButtonProps; + }; + + const getNextButtonProps = (props = {}) => { + return { + "data-slot": "next-button", + tabIndex: isHeaderExpanded ? -1 : 0, + className: slots.nextButton({class: classNames?.nextButton}), + ...mergeProps(commonButtonProps, nextButtonPropsProp, props), + } as AriaButtonProps; + }; + + const getErrorMessageProps: PropGetter = (props = {}) => { + return { + "data-slot": "error-message", + className: slots.errorMessage({class: classNames?.errorMessage}), + ...props, + }; + }; + + return { + Component, + children, + domRef, + slots, + locale, + minValue, + maxValue, + weekdayStyle, + visibleMonths, + visibleDuration, + shouldFilterDOMProps, + isHeaderExpanded, + showMonthAndYearPickers, + createCalendar: createCalendarProp, + getPrevButtonProps, + getNextButtonProps, + getErrorMessageProps, + setIsHeaderExpanded, + topContent, + bottomContent, + errorMessage, + classNames, + otherProps, + }; +} + +export type UseCalendarBaseReturn = ReturnType; diff --git a/packages/components/calendar/src/use-calendar-picker.ts b/packages/components/calendar/src/use-calendar-picker.ts index 3409a2ec5c..7877381f49 100644 --- a/packages/components/calendar/src/use-calendar-picker.ts +++ b/packages/components/calendar/src/use-calendar-picker.ts @@ -209,8 +209,8 @@ export function useCalendarPicker(props: CalendarPickerProps) { nextValue = value + 3; break; case "Escape": - setIsHeaderExpanded(false); - headerRef.current?.focus(); + setIsHeaderExpanded?.(false); + headerRef?.current?.focus(); return; } diff --git a/packages/components/calendar/src/use-calendar.ts b/packages/components/calendar/src/use-calendar.ts index d7c4ee3388..442e334e05 100644 --- a/packages/components/calendar/src/use-calendar.ts +++ b/packages/components/calendar/src/use-calendar.ts @@ -1,213 +1,62 @@ -import type {CalendarReturnType, CalendarVariantProps} from "@nextui-org/theme"; import type {DateValue, AriaCalendarProps} from "@react-types/calendar"; -import type {CalendarSlots, SlotsToClasses} from "@nextui-org/theme"; -import type {AriaCalendarGridProps} from "@react-aria/calendar"; -import type {HTMLNextUIProps, PropGetter} from "@nextui-org/system"; import type {ButtonProps} from "@nextui-org/button"; -import type {SupportedCalendars} from "@nextui-org/system"; -import type {CalendarState, RangeCalendarState} from "@react-stately/calendar"; -import type {RefObject, ReactNode} from "react"; +import type {CalendarState} from "@react-stately/calendar"; -import {Calendar, CalendarDate} from "@internationalized/date"; -import {mapPropsVariants} from "@nextui-org/system"; -import {useCallback, useMemo, useRef} from "react"; -import {calendar} from "@nextui-org/theme"; -import {useControlledState} from "@react-stately/utils"; -import {ReactRef, useDOMRef, filterDOMProps} from "@nextui-org/react-utils"; -import {useLocale} from "@react-aria/i18n"; +import {useMemo, useRef} from "react"; +import {filterDOMProps} from "@nextui-org/react-utils"; import {useCalendar as useAriaCalendar} from "@react-aria/calendar"; import {useCalendarState} from "@react-stately/calendar"; import {createCalendar} from "@internationalized/date"; -import {clamp, clsx, objectToDeps} from "@nextui-org/shared-utils"; -import {chain, mergeProps} from "@react-aria/utils"; -import {useProviderContext} from "@nextui-org/system"; +import {clsx} from "@nextui-org/shared-utils"; +import {chain} from "@react-aria/utils"; +import {ContextType, useCalendarBase, UseCalendarBaseProps} from "./use-calendar-base"; import {CalendarBaseProps} from "./calendar-base"; -type NextUIBaseProps = Omit< - HTMLNextUIProps<"div">, - keyof AriaCalendarProps ->; - -interface Props extends NextUIBaseProps { - /** - * Ref to the DOM node. - */ - ref?: ReactRef; - /** - * Custom content to be included in the top of the calendar. - */ - topContent?: ReactNode; - /** - * Custom content to be included in the bottom of the calendar. - */ - bottomContent?: ReactNode; - /** - * The number of months to display at once. Up to 3 months are supported. - * @default 1 - */ - visibleMonths?: number; - /** - * Props for the navigation button, prev button and next button. - */ - navButtonProps?: ButtonProps; - /** - * Props for the previous button. - */ - prevButtonProps?: ButtonProps; - /** - * Props for the next button. - */ - nextButtonProps?: ButtonProps; +interface Props extends UseCalendarBaseProps { /** * Props for the button picker, which is used to select the month, year and expand the header. */ buttonPickerProps?: ButtonProps; - /** - * Whether the calendar header is expanded. This is only available if the `showMonthAndYearPickers` prop is set to `true`. - * @default false - */ - isHeaderExpanded?: boolean; - /** - * Whether the calendar header should be expanded by default.This is only available if the `showMonthAndYearPickers` prop is set to `true`. - * @default false - */ - isHeaderDefaultExpanded?: boolean; - /** - * The event handler for the calendar header expanded state. This is only available if the `showMonthAndYearPickers` prop is set to `true`. - * @param ixExpanded boolean - * @returns void - */ - onHeaderExpandedChange?: (ixExpanded: boolean) => void; - /** - * This function helps to reduce the bundle size by providing a custom calendar system. - * - * In the example above, the createCalendar function from the `@internationalized/date` package - * is passed to the useCalendarState hook. This function receives a calendar identifier string, - * and provides Calendar instances to React Stately, which are used to implement date manipulation. - * - * By default, this includes all calendar systems supported by @internationalized/date. However, - * if your application supports a more limited set of regions, or you know you will only be picking dates - * in a certain calendar system, you can reduce your bundle size by providing your own implementation - * of `createCalendar` that includes a subset of these Calendar implementations. - * - * For example, if your application only supports Gregorian dates, you could implement a `createCalendar` - * function like this: - * - * @example - * - * import {GregorianCalendar} from '@internationalized/date'; - * - * function createCalendar(identifier) { - * switch (identifier) { - * case 'gregory': - * return new GregorianCalendar(); - * default: - * throw new Error(`Unsupported calendar ${identifier}`); - * } - * } - * - * This way, only GregorianCalendar is imported, and the other calendar implementations can be tree-shaken. - * - * You can also use the NextUIProvider to provide the createCalendar function to all nested components. - * - * @default all calendars - */ - createCalendar?: (calendar: SupportedCalendars) => Calendar | null; - /** - * The style of weekday names to display in the calendar grid header, - * e.g. single letter, abbreviation, or full day name. - * @default "narrow" - */ - weekdayStyle?: AriaCalendarGridProps["weekdayStyle"]; - /** - * Classname or List of classes to change the classNames of the element. - * if `className` is passed, it will be added to the base slot. - * - * @example - * ```ts - * - * ``` - */ - classNames?: SlotsToClasses; } -export type UseCalendarProps = Props & - CalendarVariantProps & - AriaCalendarProps; - -export type ContextType = { - state: T; - visibleMonths: number; - headerRef: RefObject; - slots?: CalendarReturnType; - weekdayStyle?: AriaCalendarGridProps["weekdayStyle"]; - isHeaderExpanded?: boolean; - showMonthAndYearPickers?: boolean; - setIsHeaderExpanded: (isExpanded: boolean) => void; - classNames?: SlotsToClasses; - disableAnimation?: boolean; -}; - -export function useCalendar(originalProps: UseCalendarProps) { - const [props, variantProps] = mapPropsVariants(originalProps, calendar.variantKeys); - - const providerContext = useProviderContext(); +export type UseCalendarProps = Props & AriaCalendarProps; +export function useCalendar({ + buttonPickerProps: buttonPickerPropsProp, + className, + ...originalProps +}: UseCalendarProps) { const { - ref, - as, + Component, + slots, children, - className, + domRef, + locale, + minValue, + maxValue, + weekdayStyle, + visibleDuration, + shouldFilterDOMProps, + isHeaderExpanded, + visibleMonths, + createCalendar: createCalendarProp, + showMonthAndYearPickers, + getPrevButtonProps, + getNextButtonProps, + getErrorMessageProps, + setIsHeaderExpanded, topContent, bottomContent, - visibleMonths: visibleMonthsProp = 1, - weekdayStyle = "narrow", - navButtonProps = {}, - isHeaderExpanded: isHeaderExpandedProp, - isHeaderDefaultExpanded, - onHeaderExpandedChange = () => {}, - minValue = providerContext?.defaultDates?.minDate ?? new CalendarDate(1900, 1, 1), - maxValue = providerContext?.defaultDates?.maxDate ?? new CalendarDate(2099, 12, 31), - createCalendar: createCalendarProp = providerContext?.createCalendar ?? null, - prevButtonProps: prevButtonPropsProp, - nextButtonProps: nextButtonPropsProp, - buttonPickerProps: buttonPickerPropsProp, errorMessage, classNames, - ...otherProps - } = props; - - const Component = as || "div"; - const visibleMonths = clamp(visibleMonthsProp, 1, 3); + otherProps, + } = useCalendarBase(originalProps); const headerRef = useRef(null); - const handleHeaderExpandedChange = useCallback( - (isExpanded: boolean | undefined) => { - onHeaderExpandedChange(isExpanded || false); - }, - [onHeaderExpandedChange], - ); - - const [isHeaderExpanded, setIsHeaderExpanded] = useControlledState( - isHeaderExpandedProp, - isHeaderDefaultExpanded, - handleHeaderExpandedChange, - ); - - const visibleDuration = useMemo(() => ({months: visibleMonths}), [visibleMonths]); - const shouldFilterDOMProps = typeof Component === "string"; - - const domRef = useDOMRef(ref); - - const {locale} = useLocale(); - const state = useCalendarState({ - ...otherProps, + ...originalProps, locale, minValue, maxValue, @@ -221,60 +70,14 @@ export function useCalendar(originalProps: UseCalendarProps const {title, calendarProps, prevButtonProps, nextButtonProps, errorMessageProps} = useAriaCalendar(originalProps, state); - const slots = useMemo( - () => - calendar({ - ...variantProps, - isHeaderWrapperExpanded: isHeaderExpanded, - className, - }), - [objectToDeps(variantProps), isHeaderExpanded, className], - ); - const baseStyles = clsx(classNames?.base, className); const disableAnimation = originalProps.disableAnimation ?? false; - const commonButtonProps: ButtonProps = { - size: "sm", - variant: "light", - radius: "full", - isIconOnly: true, - disableAnimation, - ...navButtonProps, - }; - const buttonPickerProps: ButtonProps = { ...buttonPickerPropsProp, onPress: chain(buttonPickerPropsProp?.onPress, () => setIsHeaderExpanded(!isHeaderExpanded)), }; - const getPrevButtonProps = (props = {}) => { - return { - "data-slot": "prev-button", - tabIndex: isHeaderExpanded ? -1 : 0, - className: slots.prevButton({class: classNames?.prevButton}), - ...mergeProps(commonButtonProps, prevButtonProps, prevButtonPropsProp, props), - } as ButtonProps; - }; - - const getNextButtonProps = (props = {}) => { - return { - "data-slot": "next-button", - tabIndex: isHeaderExpanded ? -1 : 0, - className: slots.nextButton({class: classNames?.nextButton}), - ...mergeProps(commonButtonProps, nextButtonProps, nextButtonPropsProp, props), - } as ButtonProps; - }; - - const getErrorMessageProps: PropGetter = (props = {}) => { - return { - "data-slot": "error-message", - className: slots.errorMessage({class: classNames?.errorMessage}), - ...errorMessageProps, - ...props, - }; - }; - const getBaseCalendarProps = (props = {}): CalendarBaseProps => { return { Component, @@ -283,9 +86,9 @@ export function useCalendar(originalProps: UseCalendarProps buttonPickerProps, calendarRef: domRef, calendarProps: calendarProps, - prevButtonProps: getPrevButtonProps(), - nextButtonProps: getNextButtonProps(), - errorMessageProps: getErrorMessageProps(), + prevButtonProps: getPrevButtonProps(prevButtonProps), + nextButtonProps: getNextButtonProps(nextButtonProps), + errorMessageProps: getErrorMessageProps(errorMessageProps), className: slots.base({class: baseStyles}), errorMessage, ...filterDOMProps(otherProps, { @@ -305,7 +108,7 @@ export function useCalendar(originalProps: UseCalendarProps setIsHeaderExpanded, visibleMonths, classNames, - showMonthAndYearPickers: originalProps.showMonthAndYearPickers, + showMonthAndYearPickers, disableAnimation, }), [ @@ -317,7 +120,7 @@ export function useCalendar(originalProps: UseCalendarProps setIsHeaderExpanded, visibleMonths, disableAnimation, - originalProps.showMonthAndYearPickers, + showMonthAndYearPickers, ], ); @@ -331,9 +134,6 @@ export function useCalendar(originalProps: UseCalendarProps title, classNames, getBaseCalendarProps, - getPrevButtonProps, - getNextButtonProps, - getErrorMessageProps, }; } diff --git a/packages/components/calendar/src/use-range-calendar.ts b/packages/components/calendar/src/use-range-calendar.ts new file mode 100644 index 0000000000..8d42035fec --- /dev/null +++ b/packages/components/calendar/src/use-range-calendar.ts @@ -0,0 +1,129 @@ +import type {DateValue, AriaRangeCalendarProps} from "@react-types/calendar"; +import type {HTMLNextUIProps} from "@nextui-org/system"; +import type {RangeCalendarState} from "@react-stately/calendar"; + +import {useMemo, useRef} from "react"; +import {filterDOMProps} from "@nextui-org/react-utils"; +import {useRangeCalendar as useAriaRangeCalendar} from "@react-aria/calendar"; +import {useRangeCalendarState} from "@react-stately/calendar"; +import {createCalendar} from "@internationalized/date"; +import {clsx} from "@nextui-org/shared-utils"; + +import {ContextType, useCalendarBase, UseCalendarBaseProps} from "./use-calendar-base"; +import {CalendarBaseProps} from "./calendar-base"; + +type NextUIBaseProps = Omit< + HTMLNextUIProps<"div">, + keyof AriaRangeCalendarProps +>; + +interface Props extends UseCalendarBaseProps, NextUIBaseProps {} + +export type UseRangeCalendarProps = Props & AriaRangeCalendarProps; + +export function useRangeCalendar({ + className, + ...originalProps +}: UseRangeCalendarProps) { + const { + Component, + slots, + children, + domRef, + locale, + minValue, + maxValue, + weekdayStyle, + visibleDuration, + shouldFilterDOMProps, + isHeaderExpanded, + visibleMonths, + createCalendar: createCalendarProp, + getPrevButtonProps, + getNextButtonProps, + getErrorMessageProps, + setIsHeaderExpanded, + topContent, + bottomContent, + errorMessage, + classNames, + otherProps, + } = useCalendarBase({...originalProps, isRange: true}); + + const headerRef = useRef(null); + + const state = useRangeCalendarState({ + ...originalProps, + locale, + minValue, + maxValue, + visibleDuration, + createCalendar: + !createCalendarProp || typeof createCalendarProp !== "function" + ? createCalendar + : (createCalendarProp as typeof createCalendar), + }); + + const {title, calendarProps, prevButtonProps, nextButtonProps, errorMessageProps} = + useAriaRangeCalendar(originalProps, state, domRef); + + const baseStyles = clsx(classNames?.base, className); + const disableAnimation = originalProps.disableAnimation ?? false; + + const getBaseCalendarProps = (props = {}): CalendarBaseProps => { + return { + Component, + topContent, + bottomContent, + calendarRef: domRef, + calendarProps: calendarProps, + prevButtonProps: getPrevButtonProps(prevButtonProps), + nextButtonProps: getNextButtonProps(nextButtonProps), + errorMessageProps: getErrorMessageProps(errorMessageProps), + className: slots.base({class: baseStyles}), + errorMessage, + ...filterDOMProps(otherProps, { + enabled: shouldFilterDOMProps, + }), + ...props, + }; + }; + + const context = useMemo>( + () => ({ + state, + slots, + headerRef, + weekdayStyle, + isHeaderExpanded, + setIsHeaderExpanded, + visibleMonths, + classNames, + disableAnimation, + }), + [ + state, + slots, + classNames, + weekdayStyle, + isHeaderExpanded, + setIsHeaderExpanded, + visibleMonths, + disableAnimation, + ], + ); + + return { + Component, + children, + domRef, + context, + state, + slots, + title, + classNames, + getBaseCalendarProps, + }; +} + +export type UseRangeCalendarReturn = ReturnType; diff --git a/packages/components/calendar/stories/range-calendar.stories.tsx b/packages/components/calendar/stories/range-calendar.stories.tsx new file mode 100644 index 0000000000..3f82758d0a --- /dev/null +++ b/packages/components/calendar/stories/range-calendar.stories.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import {Meta} from "@storybook/react"; +import {calendar} from "@nextui-org/theme"; + +import {RangeCalendar, RangeCalendarProps} from "../src"; + +export default { + title: "Components/RangeCalendar", + component: RangeCalendar, + parameters: { + layout: "centered", + }, + argTypes: { + visibleMonths: { + control: {type: "number", min: 1, max: 3}, + }, + color: { + control: { + type: "select", + }, + options: ["foreground", "primary", "secondary", "success", "warning", "danger"], + }, + weekdayStyle: { + control: { + type: "select", + }, + options: ["narrow", "short", "long"], + }, + }, +} as Meta; + +delete calendar.defaultVariants?.showMonthAndYearPickers; + +const defaultProps = { + ...calendar.defaultVariants, + visibleMonths: 1, +}; + +const Template = (args: RangeCalendarProps) => ; + +export const Default = { + render: Template, + args: { + ...defaultProps, + }, +}; diff --git a/packages/components/switch/src/use-switch.ts b/packages/components/switch/src/use-switch.ts index 98c1038621..494a6394f8 100644 --- a/packages/components/switch/src/use-switch.ts +++ b/packages/components/switch/src/use-switch.ts @@ -23,6 +23,7 @@ export type SwitchThumbIconProps = { isSelected: boolean; className: string; }; + interface Props extends HTMLNextUIProps<"input"> { /** * Ref to the DOM node. From bb2017083f7bf20533dbf1cdfbc05ae7eb2927f0 Mon Sep 17 00:00:00 2001 From: Junior Garcia Date: Tue, 2 Apr 2024 17:54:48 -0300 Subject: [PATCH 2/4] feat(calendar): range calendar stories added --- packages/components/calendar/src/index.ts | 1 + .../calendar/src/range-calendar.tsx | 5 +- .../calendar/src/use-calendar-base.ts | 8 +- .../stories/range-calendar.stories.tsx | 345 ++++++++++++++++++ .../core/theme/src/components/calendar.ts | 250 ++++++++++++- 5 files changed, 581 insertions(+), 28 deletions(-) diff --git a/packages/components/calendar/src/index.ts b/packages/components/calendar/src/index.ts index e534c283e0..f34634627c 100644 --- a/packages/components/calendar/src/index.ts +++ b/packages/components/calendar/src/index.ts @@ -6,6 +6,7 @@ export type {CalendarProps} from "./calendar"; export type {RangeCalendarProps} from "./range-calendar"; export type {CalendarDate} from "@internationalized/date"; export type {DateValue} from "@react-types/calendar"; +export type {RangeValue} from "@react-types/shared"; // export hooks export {useCalendar} from "./use-calendar"; diff --git a/packages/components/calendar/src/range-calendar.tsx b/packages/components/calendar/src/range-calendar.tsx index 04cefe512c..e2bb221f30 100644 --- a/packages/components/calendar/src/range-calendar.tsx +++ b/packages/components/calendar/src/range-calendar.tsx @@ -10,7 +10,10 @@ import {CalendarBase} from "./calendar-base"; interface Props extends Omit< UseRangeCalendarProps, - "isHeaderExpanded" | "onHeaderExpandedChange" | "isHeaderWrapperExpanded" + | "isHeaderExpanded" + | "onHeaderExpandedChange" + | "isHeaderWrapperExpanded" + | "showMonthAndYearPickers" > {} function RangeCalendar(props: Props, ref: ForwardedRef) { diff --git a/packages/components/calendar/src/use-calendar-base.ts b/packages/components/calendar/src/use-calendar-base.ts index 4909d60b8d..703c61988e 100644 --- a/packages/components/calendar/src/use-calendar-base.ts +++ b/packages/components/calendar/src/use-calendar-base.ts @@ -27,11 +27,6 @@ interface Props extends NextUIBaseProps { * Ref to the DOM node. */ ref?: ReactRef; - - /** - * @internal - */ - isRange?: boolean; /** * Custom content to be included in the top of the calendar. */ @@ -166,7 +161,6 @@ export function useCalendarBase(originalProps: UseCalendarBasePropsComplete) { className, topContent, bottomContent, - isRange = false, visibleMonths: visibleMonthsProp = 1, weekdayStyle = "narrow", navButtonProps = {}, @@ -193,7 +187,7 @@ export function useCalendarBase(originalProps: UseCalendarBasePropsComplete) { * and it's not a range calendar (`!isRange`). */ const showMonthAndYearPickers = - originalProps.showMonthAndYearPickers && visibleMonths === 1 && !isRange; + originalProps.showMonthAndYearPickers && visibleMonths === 1 && !originalProps?.isRange; const domRef = useDOMRef(ref); diff --git a/packages/components/calendar/stories/range-calendar.stories.tsx b/packages/components/calendar/stories/range-calendar.stories.tsx index 3f82758d0a..194c387ba8 100644 --- a/packages/components/calendar/stories/range-calendar.stories.tsx +++ b/packages/components/calendar/stories/range-calendar.stories.tsx @@ -1,6 +1,22 @@ +import type {RangeValue, DateValue} from "../src"; + import React from "react"; import {Meta} from "@storybook/react"; import {calendar} from "@nextui-org/theme"; +import { + today, + getLocalTimeZone, + isWeekend, + CalendarDate, + startOfMonth, + startOfWeek, + endOfMonth, + endOfWeek, +} from "@internationalized/date"; +import {I18nProvider, useLocale} from "@react-aria/i18n"; +import {Button, ButtonGroup} from "@nextui-org/button"; +import {Radio, RadioGroup} from "@nextui-org/radio"; +import {cn} from "@nextui-org/system"; import {RangeCalendar, RangeCalendarProps} from "../src"; @@ -38,9 +54,338 @@ const defaultProps = { const Template = (args: RangeCalendarProps) => ; +const ControlledTemplate = (args: RangeCalendarProps) => { + const defaultValue = { + start: today(getLocalTimeZone()), + end: today(getLocalTimeZone()).add({weeks: 1}), + }; + + let [value, setValue] = React.useState>(defaultValue); + + return ( +
+
+

Date (uncontrolled)

+ +
+
+

Date (controlled)

+ +
+
+ ); +}; + +const UnavailableDatesTemplate = (args: RangeCalendarProps) => { + let now = today(getLocalTimeZone()); + + let disabledRanges = [ + [now, now.add({days: 5})], + [now.add({days: 14}), now.add({days: 16})], + [now.add({days: 23}), now.add({days: 24})], + ]; + + let isDateUnavailable = (date) => + disabledRanges.some( + (interval) => date.compare(interval[0]) >= 0 && date.compare(interval[1]) <= 0, + ); + + return ( + + ); +}; + +const NonContiguousRangesTemplate = (args: RangeCalendarProps) => { + let {locale} = useLocale(); + + return ( + isWeekend(date, locale)} + {...args} + /> + ); +}; + +const ControlledFocusedValueTemplate = (args: RangeCalendarProps) => { + let defaultDate = new CalendarDate(2024, 3, 1); + let [focusedDate, setFocusedDate] = React.useState(defaultDate); + + return ( +
+ + +
+ ); +}; + +const InvalidDatesTemplate = (args: RangeCalendarProps) => { + let [date, setDate] = React.useState>({ + start: today(getLocalTimeZone()), + end: today(getLocalTimeZone()).add({weeks: 1}), + }); + + let {locale} = useLocale(); + let isInvalid = isWeekend(date.start, locale) || isWeekend(date.end, locale); + + return ( + + ); +}; + +const InternationalCalendarsTemplate = (args: RangeCalendarProps) => { + return ( +
+ + + +
+ ); +}; + +const PresetsTemplate = (args: RangeCalendarProps) => { + let [value, setValue] = React.useState>({ + start: today(getLocalTimeZone()), + end: today(getLocalTimeZone()).add({weeks: 1, days: 3}), + }); + + let [focusedValue, setFocusedValue] = React.useState(today(getLocalTimeZone())); + + let {locale} = useLocale(); + + let now = today(getLocalTimeZone()); + let nextMonth = now.add({months: 1}); + + let thisMonth = {start: startOfMonth(now), end: endOfMonth(now)}; + let thisWeek = {start: startOfWeek(now, locale), end: endOfWeek(now, locale)}; + let nextMonthValue = {start: startOfMonth(nextMonth), end: endOfMonth(nextMonth)}; + + const CustomRadio = (props) => { + const {children, ...otherProps} = props; + + return ( + + {children} + + ); + }; + + return ( +
+ + Exact dates + 1 day + 2 days + 3 days + 7 days + 14 days + + } + focusedValue={focusedValue} + nextButtonProps={{ + variant: "bordered", + }} + prevButtonProps={{ + variant: "bordered", + }} + topContent={ + + + + + + } + value={value} + onChange={setValue} + onFocusChange={setFocusedValue} + {...args} + /> +
+ ); +}; + export const Default = { render: Template, args: { ...defaultProps, }, }; + +export const Disabled = { + render: Template, + args: { + ...defaultProps, + isDisabled: true, + }, +}; + +export const ReadOnly = { + render: Template, + args: { + ...defaultProps, + value: { + start: today(getLocalTimeZone()), + end: today(getLocalTimeZone()).add({weeks: 1}), + }, + isReadOnly: true, + }, +}; + +export const Controlled = { + render: ControlledTemplate, + args: { + ...defaultProps, + }, +}; + +export const MinDateValue = { + render: Template, + args: { + ...defaultProps, + defaultValue: { + start: today(getLocalTimeZone()), + end: today(getLocalTimeZone()).add({weeks: 1}), + }, + minValue: today(getLocalTimeZone()), + }, +}; + +export const MaxDateValue = { + render: Template, + args: { + ...defaultProps, + defaultValue: { + start: today(getLocalTimeZone()).subtract({weeks: 1}), + end: today(getLocalTimeZone()), + }, + maxValue: today(getLocalTimeZone()), + }, +}; + +export const UnavailableDates = { + render: UnavailableDatesTemplate, + args: { + ...defaultProps, + defaultValue: { + start: today(getLocalTimeZone()), + end: today(getLocalTimeZone()).add({weeks: 1}), + }, + }, +}; + +export const NonContiguousRanges = { + render: NonContiguousRangesTemplate, + args: { + ...defaultProps, + }, +}; + +export const ControlledFocusedValue = { + render: ControlledFocusedValueTemplate, + args: { + ...defaultProps, + }, +}; + +export const InvalidDates = { + render: InvalidDatesTemplate, + args: { + ...defaultProps, + }, +}; + +export const InternationalCalendars = { + render: InternationalCalendarsTemplate, + args: { + ...defaultProps, + showMonthAndYearPickers: true, + }, +}; + +export const VisibleMonths = { + render: Template, + args: { + ...defaultProps, + visibleMonths: 3, + }, +}; + +export const PageBehavior = { + render: Template, + args: { + ...defaultProps, + visibleMonths: 3, + pageBehavior: "single", + }, +}; + +export const Presets = { + render: PresetsTemplate, + args: { + ...defaultProps, + }, +}; diff --git a/packages/core/theme/src/components/calendar.ts b/packages/core/theme/src/components/calendar.ts index 8955bf8ca4..f866177bb6 100644 --- a/packages/core/theme/src/components/calendar.ts +++ b/packages/core/theme/src/components/calendar.ts @@ -22,13 +22,13 @@ const calendar = tv({ header: "flex w-full items-center justify-center gap-2 z-10", title: "text-default-500 text-small font-medium", gridWrapper: "flex max-w-full overflow-auto pb-2 h-auto relative", - grid: "w-full", + grid: "w-full z-0", gridHeader: "bg-content1 shadow-[0px_20px_20px_0px_rgb(0_0_0/0.05)]", gridHeaderRow: "text-default-400", gridHeaderCell: "font-medium text-small pb-2 first:ps-4 last:pe-4", gridBody: "", gridBodyRow: "[&>td]:first:pt-2", - cell: "py-0.5 first:ps-4 last:pe-4 [&:not(:first-child):not(:last-child)]:px-0.5", + cell: "py-0.5 px-0 first:ps-4 last:pe-4", cellButton: [ "w-8 h-8 flex items-center text-foreground justify-center rounded-full", "box-border appearance-none select-none whitespace-nowrap font-normal", @@ -61,8 +61,6 @@ const calendar = tv({ foreground: { cellButton: [ "data-[hover=true]:bg-default-200", - "data-[selected=true]:shadow-md", - "data-[selected=true]:shadow-foreground/40", "data-[selected=true]:bg-foreground", "data-[selected=true]:text-background", "data-[hover=true]:bg-foreground-200", @@ -73,8 +71,6 @@ const calendar = tv({ }, primary: { cellButton: [ - "data-[selected=true]:shadow-md", - "data-[selected=true]:shadow-primary/40", "data-[selected=true]:bg-primary", "data-[selected=true]:text-primary-foreground", "data-[hover=true]:bg-primary-50", @@ -85,8 +81,6 @@ const calendar = tv({ }, secondary: { cellButton: [ - "data-[selected=true]:shadow-md", - "data-[selected=true]:shadow-secondary/40", "data-[selected=true]:bg-secondary", "data-[selected=true]:text-secondary-foreground", "data-[hover=true]:bg-secondary-50", @@ -97,8 +91,6 @@ const calendar = tv({ }, success: { cellButton: [ - "data-[selected=true]:shadow-md", - "data-[selected=true]:shadow-success/40", "data-[selected=true]:bg-success", "data-[selected=true]:text-success-foreground", "data-[hover=true]:bg-success-100", @@ -113,8 +105,6 @@ const calendar = tv({ }, warning: { cellButton: [ - "data-[selected=true]:shadow-md", - "data-[selected=true]:shadow-warning/40", "data-[selected=true]:bg-warning", "data-[selected=true]:text-warning-foreground", "data-[hover=true]:bg-warning-100", @@ -129,8 +119,6 @@ const calendar = tv({ }, danger: { cellButton: [ - "data-[selected=true]:shadow-md", - "data-[selected=true]:shadow-danger/40", "data-[selected=true]:bg-danger", "data-[selected=true]:text-danger-foreground", "data-[hover=true]:bg-danger-100", @@ -144,6 +132,48 @@ const calendar = tv({ ], }, }, + // @internal + isRange: { + true: { + cellButton: [ + // base + "relative", + "overflow-visible", + + // before pseudo element + "before:content-[''] before:absolute before:inset-0 before:z-[-1] before:rounded-none", + + // hide before pseudo element when the selected cell is outside the month + "data-[outside-month=true]:before:hidden", + "data-[selected=true]:data-[outside-month=true]:bg-transparent", + "data-[selected=true]:data-[outside-month=true]:text-default-300", + + // middle + "data-[selected=true]:data-[range-selection=true]:bg-transparent", + + // start (pseudo) + "data-[range-start=true]:before:rounded-l-full", + "data-[selection-start=true]:before:rounded-l-full", + + // end (pseudo) + "data-[range-end=true]:before:rounded-r-full", + "data-[selection-end=true]:before:rounded-r-full", + + // start (selected) + "data-[selected=true]:data-[selection-start=true]:data-[range-selection=true]:rounded-full", + + // end (selected) + "data-[selected=true]:data-[selection-end=true]:data-[range-selection=true]:rounded-full", + ], + }, + false: {}, + }, + hideDisabledDates: { + true: { + cellButton: "data-[disabled=true]:opacity-0", + }, + false: {}, + }, isHeaderWrapperExpanded: { true: { headerWrapper: ["[&_.chevron-icon]:rotate-180", "after:h-full", "after:z-0"], @@ -163,7 +193,9 @@ const calendar = tv({ false: {}, }, showShadow: { - true: "", + true: { + cellButton: "data-[selected=true]:shadow-md", + }, false: { cellButton: "shadow-none data-[selected=true]:shadow-none", }, @@ -175,11 +207,8 @@ const calendar = tv({ false: { headerWrapper: ["[&_.chevron-icon]:transition-transform", "after:transition-height"], grid: "transition-opacity", - cellButton: [ - "data-[pressed=true]:scale-95", - "origin-center transition-[transform,background-color,color] !duration-200", - ], - pickerWrapper: "transition-opacity !duration-300", + cellButton: ["origin-center transition-[transform,background-color,color] !duration-150"], + pickerWrapper: "transition-opacity !duration-250", pickerItem: "transition-opacity", }, }, @@ -187,9 +216,190 @@ const calendar = tv({ defaultVariants: { color: "primary", showShadow: false, + hideDisabledDates: false, showMonthAndYearPickers: false, disableAnimation: false, }, + compoundVariants: [ + // isRange & colors + { + isRange: true, + color: "foreground", + class: { + cellButton: [ + // middle + "data-[selected=true]:data-[range-selection=true]:before:bg-foreground/10", + "data-[selected=true]:data-[range-selection=true]:text-foreground", + + // start (selected) + "data-[selected=true]:data-[selection-start=true]:data-[range-selection=true]:bg-foreground", + "data-[selected=true]:data-[selection-start=true]:data-[range-selection=true]:text-background", + + // end (selected) + "data-[selected=true]:data-[selection-end=true]:data-[range-selection=true]:bg-foreground", + "data-[selected=true]:data-[selection-end=true]:data-[range-selection=true]:text-background", + ], + }, + }, + { + isRange: true, + color: "primary", + class: { + cellButton: [ + // middle + "data-[selected=true]:data-[range-selection=true]:before:bg-primary-50", + "data-[selected=true]:data-[range-selection=true]:text-primary", + + // start (selected) + "data-[selected=true]:data-[selection-start=true]:data-[range-selection=true]:bg-primary", + "data-[selected=true]:data-[selection-start=true]:data-[range-selection=true]:text-primary-foreground", + + // end (selected) + "data-[selected=true]:data-[selection-end=true]:data-[range-selection=true]:bg-primary", + "data-[selected=true]:data-[selection-end=true]:data-[range-selection=true]:text-primary-foreground", + ], + }, + }, + { + isRange: true, + color: "secondary", + class: { + cellButton: [ + // middle + "data-[selected=true]:data-[range-selection=true]:before:bg-secondary-50", + "data-[selected=true]:data-[range-selection=true]:text-secondary", + + // start (selected) + "data-[selected=true]:data-[selection-start=true]:data-[range-selection=true]:bg-secondary", + "data-[selected=true]:data-[selection-start=true]:data-[range-selection=true]:text-secondary-foreground", + + // end (selected) + "data-[selected=true]:data-[selection-end=true]:data-[range-selection=true]:bg-secondary", + "data-[selected=true]:data-[selection-end=true]:data-[range-selection=true]:text-secondary-foreground", + ], + }, + }, + { + isRange: true, + color: "success", + class: { + cellButton: [ + // middle + "data-[selected=true]:data-[range-selection=true]:before:bg-success-100", + "data-[selected=true]:data-[range-selection=true]:text-success-600", + "dark:data-[selected=true]:data-[range-selection=true]:before:bg-success-50", + "dark:data-[selected=true]:data-[range-selection=true]:text-success-500", + + // start (selected) + "data-[selected=true]:data-[selection-start=true]:data-[range-selection=true]:bg-success", + "data-[selected=true]:data-[selection-start=true]:data-[range-selection=true]:text-success-foreground", + "dark:data-[selected=true]:data-[selection-start=true]:data-[range-selection=true]:text-success-foreground", + + // end (selected) + "data-[selected=true]:data-[selection-end=true]:data-[range-selection=true]:bg-success", + "data-[selected=true]:data-[selection-end=true]:data-[range-selection=true]:text-success-foreground", + "dark:data-[selected=true]:data-[selection-end=true]:data-[range-selection=true]:text-success-foreground", + ], + }, + }, + { + isRange: true, + color: "warning", + class: { + cellButton: [ + // middle + "data-[selected=true]:data-[range-selection=true]:before:bg-warning-100", + "dark:data-[selected=true]:data-[range-selection=true]:before:bg-warning-50", + "data-[selected=true]:data-[range-selection=true]:text-warning-500", + + // start (selected) + "data-[selected=true]:data-[selection-start=true]:data-[range-selection=true]:bg-warning", + "data-[selected=true]:data-[selection-start=true]:data-[range-selection=true]:text-warning-foreground", + + // end (selected) + "data-[selected=true]:data-[selection-end=true]:data-[range-selection=true]:bg-warning", + "data-[selected=true]:data-[selection-end=true]:data-[range-selection=true]:text-warning-foreground", + ], + }, + }, + { + isRange: true, + color: "danger", + class: { + cellButton: [ + // middle + "data-[selected=true]:data-[range-selection=true]:before:bg-danger-50", + "data-[selected=true]:data-[range-selection=true]:text-danger-500", + + // start (selected) + "data-[selected=true]:data-[selection-start=true]:data-[range-selection=true]:bg-danger", + "data-[selected=true]:data-[selection-start=true]:data-[range-selection=true]:text-danger-foreground", + + // end (selected) + "data-[selected=true]:data-[selection-end=true]:data-[range-selection=true]:bg-danger", + "data-[selected=true]:data-[selection-end=true]:data-[range-selection=true]:text-danger-foreground", + ], + }, + }, + // showShadow & colors + { + showShadow: true, + color: "foreground", + class: { + cellButton: "data-[selected=true]:shadow-foreground/40", + }, + }, + { + showShadow: true, + color: "primary", + class: { + cellButton: "data-[selected=true]:shadow-primary/40", + }, + }, + { + showShadow: true, + color: "secondary", + class: { + cellButton: "data-[selected=true]:shadow-secondary/40", + }, + }, + { + showShadow: true, + color: "success", + class: { + cellButton: "data-[selected=true]:shadow-success/40", + }, + }, + { + showShadow: true, + color: "warning", + class: { + cellButton: "data-[selected=true]:shadow-warning/40", + }, + }, + { + showShadow: true, + color: "danger", + class: { + cellButton: "data-[selected=true]:shadow-danger/40", + }, + }, + // showShadow & isRange + { + showShadow: true, + isRange: true, + class: { + cellButton: [ + // remove shadow from middle + "data-[selected=true]:shadow-none", + // add shadow to start (selected) + "data-[selected=true]:data-[selection-start=true]:shadow-md", + // add shadow to end (selected) + "data-[selected=true]:data-[selection-end=true]:shadow-md", + ], + }, + }, + ], compoundSlots: [ { slots: ["prevButton", "nextButton"], From 35bcbb150acea938d4dc7181dca04c9cb8a3e07a Mon Sep 17 00:00:00 2001 From: Junior Garcia Date: Tue, 2 Apr 2024 21:03:51 -0300 Subject: [PATCH 3/4] chore(calendar): range calendar tests added --- .../calendar/__tests__/calendar.test.tsx | 15 +- .../__tests__/range-calendar.test.tsx | 751 ++++++++++++++++++ .../utilities/test-utils/src/constants.ts | 26 + packages/utilities/test-utils/src/events.ts | 13 + packages/utilities/test-utils/src/index.ts | 1 + 5 files changed, 792 insertions(+), 14 deletions(-) create mode 100644 packages/components/calendar/__tests__/range-calendar.test.tsx create mode 100644 packages/utilities/test-utils/src/constants.ts diff --git a/packages/components/calendar/__tests__/calendar.test.tsx b/packages/components/calendar/__tests__/calendar.test.tsx index 60f5c28c1f..30b26442d3 100644 --- a/packages/components/calendar/__tests__/calendar.test.tsx +++ b/packages/components/calendar/__tests__/calendar.test.tsx @@ -2,24 +2,11 @@ import * as React from "react"; import {render, act, fireEvent} from "@testing-library/react"; import {CalendarDate, isWeekend} from "@internationalized/date"; -import {triggerPress} from "@nextui-org/test-utils"; +import {triggerPress, keyCodes} from "@nextui-org/test-utils"; import {useLocale} from "@react-aria/i18n"; import {Calendar as CalendarBase, CalendarProps} from "../src"; -let keyCodes = { - Enter: 13, - " ": 32, - PageUp: 33, - PageDown: 34, - End: 35, - Home: 36, - ArrowLeft: 37, - ArrowUp: 38, - ArrowRight: 39, - ArrowDown: 40, -}; - /** * Custom calendar to disable animations and avoid issues with react-motion and jest */ diff --git a/packages/components/calendar/__tests__/range-calendar.test.tsx b/packages/components/calendar/__tests__/range-calendar.test.tsx new file mode 100644 index 0000000000..c1ac49944e --- /dev/null +++ b/packages/components/calendar/__tests__/range-calendar.test.tsx @@ -0,0 +1,751 @@ +/* eslint-disable jsx-a11y/no-autofocus */ +import * as React from "react"; +import {render, act, fireEvent} from "@testing-library/react"; +import {CalendarDate} from "@internationalized/date"; +import {keyCodes, triggerPress, type} from "@nextui-org/test-utils"; + +import {RangeCalendar as RangeCalendarCalendarBase, RangeCalendarProps} from "../src"; + +let cellFormatter = new Intl.DateTimeFormat("en-US", { + weekday: "long", + day: "numeric", + month: "long", + year: "numeric", +}); + +/** + * Custom range-calendar to disable animations and avoid issues with react-motion and jest + */ +const RangeCalendar = React.forwardRef( + (props: RangeCalendarProps, ref: React.Ref) => { + return ; + }, +); + +RangeCalendar.displayName = "RangeCalendar"; + +describe("RangeCalendar", () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + afterEach(() => { + act(() => { + jest.runAllTimers(); + }); + }); + + describe("Basics", () => { + it("should render correctly", () => { + const wrapper = render(); + + expect(() => wrapper.unmount()).not.toThrow(); + }); + + it("ref should be forwarded", () => { + const ref = React.createRef(); + + render(); + expect(ref.current).not.toBeNull(); + }); + + it("should render with defaultValue", () => { + let {getAllByLabelText, getByRole, getAllByRole} = render( + , + ); + + let heading = getByRole("heading"); + + expect(heading).toHaveTextContent("June 2019"); + + let gridCells = getAllByRole("gridcell").filter( + (cell) => cell.getAttribute("aria-disabled") !== "true", + ); + + expect(gridCells.length).toBe(30); + + let selectedDates = getAllByLabelText("Selected", {exact: false}); + let labels = [ + "Selected Range: Wednesday, June 5 to Monday, June 10, 2019, Wednesday, June 5, 2019 selected", + "Thursday, June 6, 2019 selected", + "Friday, June 7, 2019 selected", + "Saturday, June 8, 2019 selected", + "Sunday, June 9, 2019 selected", + "Selected Range: Wednesday, June 5 to Monday, June 10, 2019, Monday, June 10, 2019 selected", + ]; + + expect(selectedDates.length).toBe(6); + + let i = 0; + + for (let cell of selectedDates) { + expect(cell.parentElement).toHaveAttribute("role", "gridcell"); + expect(cell.parentElement).toHaveAttribute("aria-selected", "true"); + expect(cell).toHaveAttribute("aria-label", labels[i++]); + } + }); + + it("should render with value", () => { + let {getAllByLabelText, getByRole, getAllByRole} = render( + , + ); + + let heading = getByRole("heading"); + + expect(heading).toHaveTextContent("June 2019"); + + let gridCells = getAllByRole("gridcell").filter( + (cell) => cell.getAttribute("aria-disabled") !== "true", + ); + + expect(gridCells.length).toBe(30); + + let selectedDates = getAllByLabelText("Selected", {exact: false}); + let labels = [ + "Selected Range: Wednesday, June 5 to Monday, June 10, 2019, Wednesday, June 5, 2019 selected", + "Thursday, June 6, 2019 selected", + "Friday, June 7, 2019 selected", + "Saturday, June 8, 2019 selected", + "Sunday, June 9, 2019 selected", + "Selected Range: Wednesday, June 5 to Monday, June 10, 2019, Monday, June 10, 2019 selected", + ]; + + expect(selectedDates.length).toBe(6); + + let i = 0; + + for (let cell of selectedDates) { + expect(cell.parentElement).toHaveAttribute("role", "gridcell"); + expect(cell.parentElement).toHaveAttribute("aria-selected", "true"); + expect(cell).toHaveAttribute("aria-label", labels[i++]); + } + }); + + it("should focus the first selected date if autoFocus is set", () => { + let {getByRole, getAllByLabelText} = render( + , + ); + + let cells = getAllByLabelText("selected", {exact: false}); + let grid = getByRole("grid"); + + expect(cells[0].parentElement).toHaveAttribute("role", "gridcell"); + expect(cells[0].parentElement).toHaveAttribute("aria-selected", "true"); + expect(cells[0]).toHaveFocus(); + expect(grid).not.toHaveAttribute("aria-activedescendant"); + }); + + it("should show selected dates across multiple months", async () => { + let {getByRole, getByTestId, getAllByLabelText, getAllByRole} = render( + , + ); + + let heading = getByRole("heading"); + + expect(heading).toHaveTextContent("June 2019"); + + let gridCells = getAllByRole("gridcell").filter( + (cell) => cell.getAttribute("aria-disabled") !== "true", + ); + + expect(gridCells.length).toBe(30); + + let selected = getAllByLabelText("selected", {exact: false}).filter( + (cell) => cell.getAttribute("aria-disabled") !== "true", + ); + + expect(selected.length).toBe(11); + + let juneLabels = [ + "Selected Range: Thursday, June 20 to Wednesday, July 10, 2019, Thursday, June 20, 2019 selected", + "Friday, June 21, 2019 selected", + "Saturday, June 22, 2019 selected", + "Sunday, June 23, 2019 selected", + "Monday, June 24, 2019 selected", + "Tuesday, June 25, 2019 selected", + "Wednesday, June 26, 2019 selected", + "Thursday, June 27, 2019 selected", + "Friday, June 28, 2019 selected", + "Saturday, June 29, 2019 selected", + "Sunday, June 30, 2019 selected", + ]; + + let i = 0; + + for (let cell of selected) { + expect(cell.parentElement).toHaveAttribute("aria-selected", "true"); + expect(cell).toHaveAttribute("aria-label", juneLabels[i++]); + } + + let nextButton = getByTestId("next-button"); + + triggerPress(nextButton); + + selected = getAllByLabelText("selected", {exact: false}).filter( + (cell) => cell.getAttribute("aria-disabled") !== "true", + ); + + expect(selected.length).toBe(10); + + let julyLabels = [ + "Monday, July 1, 2019 selected", + "Tuesday, July 2, 2019 selected", + "Wednesday, July 3, 2019 selected", + "Thursday, July 4, 2019 selected", + "Friday, July 5, 2019 selected", + "Saturday, July 6, 2019 selected", + "Sunday, July 7, 2019 selected", + "Monday, July 8, 2019 selected", + "Tuesday, July 9, 2019 selected", + "Selected Range: Thursday, June 20 to Wednesday, July 10, 2019, Wednesday, July 10, 2019 selected", + ]; + + i = 0; + for (let cell of selected) { + expect(cell.parentElement).toHaveAttribute("aria-selected", "true"); + expect(cell).toHaveAttribute("aria-label", julyLabels[i++]); + } + + expect(heading).toHaveTextContent("July 2019"); + gridCells = getAllByRole("gridcell").filter( + (cell) => cell.getAttribute("aria-disabled") !== "true", + ); + expect(gridCells.length).toBe(31); + + expect(nextButton).toHaveFocus(); + + let prevButton = getByTestId("prev-button"); + + triggerPress(prevButton); + + expect(heading).toHaveTextContent("June 2019"); + gridCells = getAllByRole("gridcell").filter( + (cell) => cell.getAttribute("aria-disabled") !== "true", + ); + expect(gridCells.length).toBe(30); + + selected = getAllByLabelText("selected", {exact: false}).filter( + (cell) => cell.getAttribute("aria-disabled") !== "true", + ); + expect(selected.length).toBe(11); + i = 0; + for (let cell of selected) { + expect(cell.parentElement).toHaveAttribute("aria-selected", "true"); + expect(cell).toHaveAttribute("aria-label", juneLabels[i++]); + } + + expect(prevButton).toHaveFocus(); + }); + + it("should center the selected range if multiple months are visible", () => { + let {getAllByRole, getAllByLabelText} = render( + , + ); + + let grids = getAllByRole("grid"); + + expect(grids).toHaveLength(3); + + let cells = getAllByLabelText("selected", {exact: false}); + + expect(cells.every((cell) => grids[1].contains(cell))).toBe(true); + }); + + it("should constrain the visible region depending on the minValue", () => { + let {getAllByRole, getAllByLabelText} = render( + , + ); + + let grids = getAllByRole("grid"); + + expect(grids).toHaveLength(3); + + let cells = getAllByLabelText("selected", {exact: false}); + + expect(cells.every((cell) => grids[0].contains(cell))).toBe(true); + }); + + it("should start align the selected range if it would go out of view when centered", () => { + let {getAllByRole, getAllByLabelText} = render( + , + ); + + let grids = getAllByRole("grid"); + + expect(grids).toHaveLength(3); + + let cells = getAllByLabelText("selected", {exact: false}); + + expect(grids[0].contains(cells[0])).toBe(true); + }); + }); + + describe("Keyboard interactions", () => { + it("should add a range selection prompt to the focused cell", () => { + let {getByRole, getByLabelText} = render(); + + let grid = getByRole("grid"); + let cell = getByLabelText("today", {exact: false}); + + expect(grid).not.toHaveAttribute("aria-activedescendant"); + expect(cell).toHaveAttribute("aria-label", `Today, ${cellFormatter.format(new Date())}`); + expect(cell).toHaveAttribute("aria-describedby"); + + const cellDescBy = cell.getAttribute("aria-describedby"); + + if (cellDescBy) { + expect(document.getElementById(cellDescBy)).toHaveTextContent( + "Click to start selecting date range", + ); + } + + // enter selection mode + fireEvent.keyDown(grid, {key: "Enter", keyCode: keyCodes.Enter}); + expect(grid).not.toHaveAttribute("aria-activedescendant"); + expect(cell.parentElement).toHaveAttribute("aria-selected"); + expect(cell).toHaveAttribute( + "aria-label", + `Today, ${cellFormatter.format(new Date())} selected`, + ); + expect(cell).toHaveAttribute("aria-describedby"); + + const cellDescBySelected = cell.getAttribute("aria-describedby"); + + if (cellDescBySelected) { + expect(document.getElementById(cellDescBySelected)).toHaveTextContent( + "Click to finish selecting date range", + ); + } + }); + + it("should select a range with the keyboard (uncontrolled)", () => { + let onChange = jest.fn(); + + let {getAllByLabelText} = render( + , + ); + + let selectedDates = getAllByLabelText("Selected", {exact: false}); + + expect(selectedDates[0].textContent).toBe("5"); + expect(selectedDates[selectedDates.length - 1].textContent).toBe("10"); + + // Select a new date + type("ArrowLeft"); + + // Begin selecting + type("Enter"); + + // Auto advances by one day + selectedDates = getAllByLabelText("Selected", {exact: false}); + expect(selectedDates[0].textContent).toBe("4"); + expect(selectedDates[selectedDates.length - 1].textContent).toBe("5"); + expect(onChange).toHaveBeenCalledTimes(0); + + // Move focus + type("ArrowRight"); + type("ArrowRight"); + type("ArrowRight"); + type("ArrowRight"); + + selectedDates = getAllByLabelText("Selected", {exact: false}); + expect(selectedDates[0].textContent).toBe("4"); + expect(selectedDates[selectedDates.length - 1].textContent).toBe("9"); + expect(onChange).toHaveBeenCalledTimes(0); + + // End selection + type(" "); + selectedDates = getAllByLabelText("Selected", {exact: false}); + expect(selectedDates[0].textContent).toBe("4"); // uncontrolled + expect(selectedDates[selectedDates.length - 1].textContent).toBe("9"); + expect(onChange).toHaveBeenCalledTimes(1); + + let {start, end} = onChange.mock.calls[0][0]; + + expect(start).toEqual(new CalendarDate(2019, 6, 4)); + expect(end).toEqual(new CalendarDate(2019, 6, 9)); + }); + + it("select a range with the keyboard (controlled)", () => { + let onChange = jest.fn(); + let {getAllByLabelText} = render( + , + ); + + let selectedDates = getAllByLabelText("selected", {exact: false}); + + expect(selectedDates[0].textContent).toBe("5"); + expect(selectedDates[selectedDates.length - 1].textContent).toBe("10"); + + // Select a new date + type("ArrowLeft"); + + // Begin selecting + type("Enter"); + + // Auto advances by one day + selectedDates = getAllByLabelText("selected", {exact: false}); + expect(selectedDates[0].textContent).toBe("4"); + expect(selectedDates[selectedDates.length - 1].textContent).toBe("5"); + expect(onChange).toHaveBeenCalledTimes(0); + + // Move focus + type("ArrowRight"); + type("ArrowRight"); + type("ArrowRight"); + type("ArrowRight"); + + selectedDates = getAllByLabelText("selected", {exact: false}); + expect(selectedDates[0].textContent).toBe("4"); + expect(selectedDates[selectedDates.length - 1].textContent).toBe("9"); + expect(onChange).toHaveBeenCalledTimes(0); + + // End selection + type(" "); + selectedDates = getAllByLabelText("selected", {exact: false}); + expect(selectedDates[0].textContent).toBe("5"); // controlled + expect(selectedDates[selectedDates.length - 1].textContent).toBe("10"); + expect(onChange).toHaveBeenCalledTimes(1); + + let {start, end} = onChange.mock.calls[0][0]; + + expect(start).toEqual(new CalendarDate(2019, 6, 4)); + expect(end).toEqual(new CalendarDate(2019, 6, 9)); + }); + + it("should not enter selection mode with the keyboard if isReadOnly", () => { + let {getByRole, getByLabelText} = render(); + + let grid = getByRole("grid"); + let cell = getByLabelText("today", {exact: false}); + + expect(grid).not.toHaveAttribute("aria-activedescendant"); + expect(cell).toHaveAttribute("aria-label", `Today, ${cellFormatter.format(new Date())}`); + expect(document.activeElement).toBe(cell); + + // try to enter selection mode + fireEvent.keyDown(grid, {key: "Enter", keyCode: keyCodes.Enter}); + expect(grid).not.toHaveAttribute("aria-activedescendant"); + expect(cell.parentElement).not.toHaveAttribute("aria-selected"); + expect(cell).toHaveAttribute("aria-label", `Today, ${cellFormatter.format(new Date())}`); + expect(document.activeElement).toBe(cell); + }); + + it("should select a range with the mouse (uncontrolled)", () => { + let onChange = jest.fn(); + let {getAllByLabelText, getByText} = render( + , + ); + + triggerPress(getByText("17")); + + let selectedDates = getAllByLabelText("selected", {exact: false}); + + expect(selectedDates[0].textContent).toBe("17"); + expect(selectedDates[selectedDates.length - 1].textContent).toBe("17"); + expect(onChange).toHaveBeenCalledTimes(0); + + // hovering updates the highlighted dates + // @ts-ignore + fireEvent.pointerEnter(getByText("10").parentElement); + selectedDates = getAllByLabelText("selected", {exact: false}); + expect(selectedDates[0].textContent).toBe("10"); + expect(selectedDates[selectedDates.length - 1].textContent).toBe("17"); + expect(onChange).toHaveBeenCalledTimes(0); + + // @ts-ignore + fireEvent.pointerEnter(getByText("7").parentElement); + triggerPress(getByText("7")); + + selectedDates = getAllByLabelText("selected", {exact: false}); + expect(selectedDates[0].textContent).toBe("7"); // uncontrolled + expect(selectedDates[selectedDates.length - 1].textContent).toBe("17"); + expect(onChange).toHaveBeenCalledTimes(1); + + let {start, end} = onChange.mock.calls[0][0]; + + expect(start).toEqual(new CalendarDate(2019, 6, 7)); + expect(end).toEqual(new CalendarDate(2019, 6, 17)); + }); + + it("should select a range with the mouse (controlled)", () => { + let onChange = jest.fn(); + let {getAllByLabelText, getByText} = render( + , + ); + + triggerPress(getByText("17")); + + let selectedDates = getAllByLabelText("selected", {exact: false}); + + expect(selectedDates[0].textContent).toBe("17"); + expect(selectedDates[selectedDates.length - 1].textContent).toBe("17"); + expect(onChange).toHaveBeenCalledTimes(0); + + // hovering updates the highlighted dates + fireEvent.pointerEnter(getByText("10")); + selectedDates = getAllByLabelText("selected", {exact: false}); + expect(selectedDates[0].textContent).toBe("10"); + expect(selectedDates[selectedDates.length - 1].textContent).toBe("17"); + expect(onChange).toHaveBeenCalledTimes(0); + + fireEvent.pointerEnter(getByText("7")); + triggerPress(getByText("7")); + + selectedDates = getAllByLabelText("selected", {exact: false}); + expect(selectedDates[0].textContent).toBe("5"); // controlled + expect(selectedDates[selectedDates.length - 1].textContent).toBe("10"); + expect(onChange).toHaveBeenCalledTimes(1); + + let {start, end} = onChange.mock.calls[0][0]; + + expect(start).toEqual(new CalendarDate(2019, 6, 7)); + expect(end).toEqual(new CalendarDate(2019, 6, 17)); + }); + + it("selects by dragging with the mouse", () => { + let onChange = jest.fn(); + let {getAllByLabelText, getByText} = render( + , + ); + + fireEvent.mouseDown(getByText("17"), {detail: 1}); + + let selectedDates = getAllByLabelText("selected", {exact: false}); + + expect(selectedDates[0].textContent).toBe("17"); + expect(selectedDates[selectedDates.length - 1].textContent).toBe("17"); + expect(onChange).toHaveBeenCalledTimes(0); + + // dragging updates the highlighted dates + fireEvent.pointerEnter(getByText("18")); + selectedDates = getAllByLabelText("selected", {exact: false}); + expect(selectedDates[0].textContent).toBe("17"); + expect(selectedDates[selectedDates.length - 1].textContent).toBe("18"); + expect(onChange).toHaveBeenCalledTimes(0); + + fireEvent.pointerEnter(getByText("23")); + selectedDates = getAllByLabelText("selected", {exact: false}); + expect(selectedDates[0].textContent).toBe("17"); + expect(selectedDates[selectedDates.length - 1].textContent).toBe("23"); + expect(onChange).toHaveBeenCalledTimes(0); + + fireEvent.mouseUp(getByText("23"), {detail: 1}); + + selectedDates = getAllByLabelText("selected", {exact: false}); + expect(selectedDates[0].textContent).toBe("17"); + expect(selectedDates[selectedDates.length - 1].textContent).toBe("23"); + expect(onChange).toHaveBeenCalledTimes(1); + + let {start, end} = onChange.mock.calls[0][0]; + + expect(start).toEqual(new CalendarDate(2019, 6, 17)); + expect(end).toEqual(new CalendarDate(2019, 6, 23)); + }); + + it("allows dragging the start of the highlighted range to modify it", () => { + let onChange = jest.fn(); + let {getAllByLabelText, getByText} = render( + , + ); + + fireEvent.mouseDown(getByText("10"), {detail: 1}); + + // mouse down on a range end should not reset it + let selectedDates = getAllByLabelText("selected", {exact: false}); + + expect(selectedDates[0].textContent).toBe("10"); + expect(selectedDates[selectedDates.length - 1].textContent).toBe("20"); + expect(onChange).toHaveBeenCalledTimes(0); + + // dragging updates the highlighted dates + fireEvent.pointerEnter(getByText("11")); + selectedDates = getAllByLabelText("selected", {exact: false}); + expect(selectedDates[0].textContent).toBe("11"); + expect(selectedDates[selectedDates.length - 1].textContent).toBe("20"); + expect(onChange).toHaveBeenCalledTimes(0); + + fireEvent.pointerEnter(getByText("8")); + selectedDates = getAllByLabelText("selected", {exact: false}); + expect(selectedDates[0].textContent).toBe("8"); + expect(selectedDates[selectedDates.length - 1].textContent).toBe("20"); + expect(onChange).toHaveBeenCalledTimes(0); + + fireEvent.mouseUp(getByText("8"), {detail: 1}); + + selectedDates = getAllByLabelText("selected", {exact: false}); + expect(selectedDates[0].textContent).toBe("8"); + expect(selectedDates[selectedDates.length - 1].textContent).toBe("20"); + expect(onChange).toHaveBeenCalledTimes(1); + + let {start, end} = onChange.mock.calls[0][0]; + + expect(start).toEqual(new CalendarDate(2019, 6, 8)); + expect(end).toEqual(new CalendarDate(2019, 6, 20)); + }); + + it("allows dragging the end of the highlighted range to modify it", () => { + let onChange = jest.fn(); + let {getAllByLabelText, getByText} = render( + , + ); + + fireEvent.mouseDown(getByText("20"), {detail: 1}); + + // mouse down on a range end should not reset it + let selectedDates = getAllByLabelText("selected", {exact: false}); + + expect(selectedDates[0].textContent).toBe("10"); + expect(selectedDates[selectedDates.length - 1].textContent).toBe("20"); + expect(onChange).toHaveBeenCalledTimes(0); + + // dragging updates the highlighted dates + fireEvent.pointerEnter(getByText("21")); + selectedDates = getAllByLabelText("selected", {exact: false}); + expect(selectedDates[0].textContent).toBe("10"); + expect(selectedDates[selectedDates.length - 1].textContent).toBe("21"); + expect(onChange).toHaveBeenCalledTimes(0); + + fireEvent.pointerEnter(getByText("19")); + selectedDates = getAllByLabelText("selected", {exact: false}); + expect(selectedDates[0].textContent).toBe("10"); + expect(selectedDates[selectedDates.length - 1].textContent).toBe("19"); + expect(onChange).toHaveBeenCalledTimes(0); + + fireEvent.mouseUp(getByText("19"), {detail: 1}); + + selectedDates = getAllByLabelText("selected", {exact: false}); + expect(selectedDates[0].textContent).toBe("10"); + expect(selectedDates[selectedDates.length - 1].textContent).toBe("19"); + expect(onChange).toHaveBeenCalledTimes(1); + + let {start, end} = onChange.mock.calls[0][0]; + + expect(start).toEqual(new CalendarDate(2019, 6, 10)); + expect(end).toEqual(new CalendarDate(2019, 6, 19)); + }); + + it("releasing drag outside calendar commits it", () => { + let onChange = jest.fn(); + let {getAllByLabelText, getByText} = render( + , + ); + + fireEvent.mouseDown(getByText("22"), {detail: 1}); + + let selectedDates = getAllByLabelText("selected", {exact: false}); + + expect(selectedDates[0].textContent).toBe("22"); + expect(selectedDates[selectedDates.length - 1].textContent).toBe("22"); + expect(onChange).toHaveBeenCalledTimes(0); + + // dragging updates the highlighted dates + fireEvent.pointerEnter(getByText("25")); + selectedDates = getAllByLabelText("selected", {exact: false}); + expect(selectedDates[0].textContent).toBe("22"); + expect(selectedDates[selectedDates.length - 1].textContent).toBe("25"); + expect(onChange).toHaveBeenCalledTimes(0); + + fireEvent.pointerUp(document.body); + + selectedDates = getAllByLabelText("selected", {exact: false}); + expect(selectedDates[0].textContent).toBe("22"); + expect(selectedDates[selectedDates.length - 1].textContent).toBe("25"); + expect(onChange).toHaveBeenCalledTimes(1); + + let {start, end} = onChange.mock.calls[0][0]; + + expect(start).toEqual(new CalendarDate(2019, 6, 22)); + expect(end).toEqual(new CalendarDate(2019, 6, 25)); + }); + + it("releasing drag outside calendar commits it", () => { + let onChange = jest.fn(); + let {getAllByLabelText, getByText} = render( + , + ); + + fireEvent.mouseDown(getByText("22"), {detail: 1}); + + let selectedDates = getAllByLabelText("selected", {exact: false}); + + expect(selectedDates[0].textContent).toBe("22"); + expect(selectedDates[selectedDates.length - 1].textContent).toBe("22"); + expect(onChange).toHaveBeenCalledTimes(0); + + // dragging updates the highlighted dates + fireEvent.pointerEnter(getByText("25")); + selectedDates = getAllByLabelText("selected", {exact: false}); + expect(selectedDates[0].textContent).toBe("22"); + expect(selectedDates[selectedDates.length - 1].textContent).toBe("25"); + expect(onChange).toHaveBeenCalledTimes(0); + + fireEvent.pointerUp(document.body); + + selectedDates = getAllByLabelText("selected", {exact: false}); + expect(selectedDates[0].textContent).toBe("22"); + expect(selectedDates[selectedDates.length - 1].textContent).toBe("25"); + expect(onChange).toHaveBeenCalledTimes(1); + + let {start, end} = onChange.mock.calls[0][0]; + + expect(start).toEqual(new CalendarDate(2019, 6, 22)); + expect(end).toEqual(new CalendarDate(2019, 6, 25)); + }); + }); +}); diff --git a/packages/utilities/test-utils/src/constants.ts b/packages/utilities/test-utils/src/constants.ts new file mode 100644 index 0000000000..cdf6f4ce11 --- /dev/null +++ b/packages/utilities/test-utils/src/constants.ts @@ -0,0 +1,26 @@ +import {pointerKey} from "@testing-library/user-event/system/pointer/shared"; + +/** + * Object containing key codes for various keyboard keys. + */ +export const keyCodes = { + Enter: 13, + " ": 32, + PageUp: 33, + PageDown: 34, + End: 35, + Home: 36, + ArrowLeft: 37, + ArrowUp: 38, + ArrowRight: 39, + ArrowDown: 40, +}; + +export const pointerMap: pointerKey[] = [ + {name: "MouseLeft", pointerType: "mouse", button: "primary", height: 1, width: 1, pressure: 0.5}, + {name: "MouseRight", pointerType: "mouse", button: "secondary"}, + {name: "MouseMiddle", pointerType: "mouse", button: "auxiliary"}, + {name: "TouchA", pointerType: "touch", height: 1, width: 1}, + {name: "TouchB", pointerType: "touch"}, + {name: "TouchC", pointerType: "touch"}, +] as unknown as pointerKey[]; diff --git a/packages/utilities/test-utils/src/events.ts b/packages/utilities/test-utils/src/events.ts index 52d116bcdb..b24230cb1b 100644 --- a/packages/utilities/test-utils/src/events.ts +++ b/packages/utilities/test-utils/src/events.ts @@ -10,3 +10,16 @@ export function triggerPress(element: HTMLElement, opts = {}) { fireEvent.mouseUp(element, {detail: 1, ...opts}); fireEvent.click(element, {detail: 1, ...opts}); } + +/** + * Triggers a simulated key press event on the active element. + * @param key - The key to press. + */ +export function type(key: string) { + if (!document.activeElement) { + throw new Error("No active element found."); + } + + fireEvent.keyDown(document.activeElement, {key}); + fireEvent.keyUp(document.activeElement, {key}); +} diff --git a/packages/utilities/test-utils/src/index.ts b/packages/utilities/test-utils/src/index.ts index 81c462f76c..2ef3f257eb 100644 --- a/packages/utilities/test-utils/src/index.ts +++ b/packages/utilities/test-utils/src/index.ts @@ -4,3 +4,4 @@ export * from "./tabbable"; export * from "./dom"; export * from "./drag"; export * from "./events"; +export * from "./constants"; From b640974237477860bbff8b2ab20d5110f81b9af9 Mon Sep 17 00:00:00 2001 From: Junior Garcia Date: Wed, 3 Apr 2024 15:53:54 -0300 Subject: [PATCH 4/4] fix(calendar): update calendar styles to adjust to dynamic width --- .../calendar/src/use-calendar-base.ts | 30 ++- .../stories/range-calendar.stories.tsx | 23 ++- .../core/theme/src/components/calendar.ts | 195 ++++++++++-------- 3 files changed, 157 insertions(+), 91 deletions(-) diff --git a/packages/components/calendar/src/use-calendar-base.ts b/packages/components/calendar/src/use-calendar-base.ts index 703c61988e..46f7cb8194 100644 --- a/packages/components/calendar/src/use-calendar-base.ts +++ b/packages/components/calendar/src/use-calendar-base.ts @@ -117,14 +117,31 @@ interface Props extends NextUIBaseProps { * * @example * ```ts - * * - * * ``` */ @@ -214,6 +231,7 @@ export function useCalendarBase(originalProps: UseCalendarBasePropsComplete) { calendar({ ...variantProps, showMonthAndYearPickers, + isRange: !!originalProps.isRange, isHeaderWrapperExpanded: isHeaderExpanded, className, }), diff --git a/packages/components/calendar/stories/range-calendar.stories.tsx b/packages/components/calendar/stories/range-calendar.stories.tsx index 194c387ba8..4bec788a91 100644 --- a/packages/components/calendar/stories/range-calendar.stories.tsx +++ b/packages/components/calendar/stories/range-calendar.stories.tsx @@ -186,8 +186,11 @@ const PresetsTemplate = (args: RangeCalendarProps) => { let now = today(getLocalTimeZone()); let nextMonth = now.add({months: 1}); + let nextWeek = { + start: startOfWeek(now.add({weeks: 1}), locale), + end: endOfWeek(now.add({weeks: 1}), locale), + }; let thisMonth = {start: startOfMonth(now), end: endOfMonth(now)}; - let thisWeek = {start: startOfWeek(now, locale), end: endOfWeek(now, locale)}; let nextMonthValue = {start: startOfMonth(nextMonth), end: endOfMonth(nextMonth)}; const CustomRadio = (props) => { @@ -248,8 +251,22 @@ const PresetsTemplate = (args: RangeCalendarProps) => { size="sm" variant="bordered" > - - + +