diff --git a/packages/components/calendar/src/calendar-picker.tsx b/packages/components/calendar/src/calendar-picker.tsx index 3652535c40..3762af7ee5 100644 --- a/packages/components/calendar/src/calendar-picker.tsx +++ b/packages/components/calendar/src/calendar-picker.tsx @@ -24,6 +24,7 @@ export function CalendarPicker(props: CalendarPickerProps) { yearsListRef, classNames, getItemRef, + isHeaderExpanded, onPickerItemPressed, onPickerItemKeyDown, } = useCalendarPicker(props); @@ -82,7 +83,7 @@ export function CalendarPicker(props: CalendarPickerProps) { ref={(node) => getItemRef(node, month.value, "months")} className={slots?.pickerItem({class: classNames?.pickerItem})} data-value={month.value} - tabIndex={state.focusedDate?.month === month.value ? 0 : -1} + tabIndex={!isHeaderExpanded || state.focusedDate?.month !== month.value ? -1 : 0} onKeyDown={(e) => onPickerItemKeyDown(e, month.value, "months")} onPress={(e) => onPickerItemPressed(e, "months")} > @@ -103,7 +104,7 @@ export function CalendarPicker(props: CalendarPickerProps) { ref={(node) => getItemRef(node, year.value, "years")} className={slots?.pickerItem({class: classNames?.pickerItem})} data-value={year.value} - tabIndex={state.focusedDate?.year === year.value ? 0 : -1} + tabIndex={!isHeaderExpanded || state.focusedDate?.year !== year.value ? -1 : 0} onKeyDown={(e) => onPickerItemKeyDown(e, year.value, "years")} onPress={(e) => onPickerItemPressed(e, "years")} > diff --git a/packages/components/calendar/src/use-calendar-picker.ts b/packages/components/calendar/src/use-calendar-picker.ts index fd93535779..ff9dce3109 100644 --- a/packages/components/calendar/src/use-calendar-picker.ts +++ b/packages/components/calendar/src/use-calendar-picker.ts @@ -234,6 +234,7 @@ export function useCalendarPicker(props: CalendarPickerProps) { monthsListRef, yearsListRef, getItemRef, + isHeaderExpanded, onPickerItemPressed, onPickerItemKeyDown, }; diff --git a/packages/components/date-input/src/date-input-field.tsx b/packages/components/date-input/src/date-input-field.tsx new file mode 100644 index 0000000000..67982209f8 --- /dev/null +++ b/packages/components/date-input/src/date-input-field.tsx @@ -0,0 +1,45 @@ +import type {InputHTMLAttributes} from "react"; +import type {GroupDOMAttributes} from "@react-types/shared"; +import type {DateInputReturnType, DateInputSlots, SlotsToClasses} from "@nextui-org/theme"; +import type {DateFieldState} from "@react-stately/datepicker"; +import type {HTMLNextUIProps} from "@nextui-org/system"; + +import {forwardRef} from "react"; + +import {DateInputSegment} from "./date-input-segment"; + +type NextUIBaseProps = Omit, keyof GroupDOMAttributes | "onChange">; + +export interface DateInputFieldProps extends NextUIBaseProps, GroupDOMAttributes { + /** State for the date field. */ + state: DateFieldState; + /** Props for the hidden input element for HTML form submission. */ + inputProps: InputHTMLAttributes; + /** DateInput classes slots. */ + slots: DateInputReturnType; + /** DateInput classes. */ + classNames?: SlotsToClasses; +} + +export const DateInputField = forwardRef<"div", DateInputFieldProps>((props, ref) => { + const {as, state, slots, inputProps, classNames, ...otherProps} = props; + + const Component = as || "div"; + + return ( + + {state.segments.map((segment, i) => ( + + ))} + + + ); +}); + +DateInputField.displayName = "NextUI.DateInputField"; diff --git a/packages/components/date-input/src/date-input-group.tsx b/packages/components/date-input/src/date-input-group.tsx new file mode 100644 index 0000000000..f87c2b731d --- /dev/null +++ b/packages/components/date-input/src/date-input-group.tsx @@ -0,0 +1,111 @@ +import type {HTMLAttributes, ReactElement, ReactNode} from "react"; +import type {GroupDOMAttributes} from "@react-types/shared"; + +import {useMemo} from "react"; +import {forwardRef} from "@nextui-org/system"; +import {dataAttr} from "@nextui-org/shared-utils"; + +// TODO: Use HelpTextProps from "@react-types/shared"; once we upgrade react-aria packages to the latest version. +export interface ValidationResult { + /** Whether the input value is invalid. */ + isInvalid: boolean; + /** The current error messages for the input if it is invalid, otherwise an empty array. */ + validationErrors: string[]; + /** The native validation details for the input. */ + validationDetails: ValidityState; +} + +export interface DateInputGroupProps extends ValidationResult { + children?: ReactElement | ReactElement[]; + shouldLabelBeOutside?: boolean; + label?: ReactNode; + startContent?: React.ReactNode; + endContent?: React.ReactNode; + groupProps?: GroupDOMAttributes; + wrapperProps?: HTMLAttributes; // <- inner wrapper props + helperWrapperProps?: HTMLAttributes; + labelProps?: HTMLAttributes; + descriptionProps?: HTMLAttributes; + errorMessageProps?: HTMLAttributes; + /** A description for the field. Provides a hint such as specific requirements for what to choose. */ + description?: ReactNode; + /** An error message for the field. */ + errorMessage?: ReactNode | ((v: ValidationResult) => ReactNode); +} + +export const DateInputGroup = forwardRef<"div", DateInputGroupProps>((props, ref) => { + const { + as, + label, + children, + description, + startContent, + endContent, + errorMessage: errorMessageProp, + shouldLabelBeOutside, + isInvalid, + groupProps, + labelProps, + wrapperProps, + helperWrapperProps, + errorMessageProps, + descriptionProps, + validationErrors, + validationDetails, + ...otherProps + } = props; + + const Component = as || "div"; + + const labelContent = label ? {label} : null; + + const errorMessage = + typeof errorMessageProp === "function" + ? errorMessageProp({ + isInvalid, + validationErrors, + validationDetails, + }) + : errorMessageProp || validationErrors?.join(" "); + + const hasHelper = !!description || !!errorMessage; + + const helperWrapper = useMemo(() => { + if (!hasHelper) return null; + + return ( +
+ {errorMessage ? ( +
{errorMessage}
+ ) : description ? ( +
{description}
+ ) : null} +
+ ); + }, [ + hasHelper, + errorMessage, + description, + helperWrapperProps, + errorMessageProps, + descriptionProps, + ]); + + return ( + + {shouldLabelBeOutside ? labelContent : null} +
+ {!shouldLabelBeOutside ? labelContent : null} +
+ {startContent} + {children} + {endContent} +
+ {shouldLabelBeOutside ? helperWrapper : null} +
+ {!shouldLabelBeOutside ? helperWrapper : null} +
+ ); +}); + +DateInputGroup.displayName = "NextUI.DateInputGroup"; diff --git a/packages/components/date-input/src/date-input.tsx b/packages/components/date-input/src/date-input.tsx index 75ad7e497d..daa549d115 100644 --- a/packages/components/date-input/src/date-input.tsx +++ b/packages/components/date-input/src/date-input.tsx @@ -1,97 +1,31 @@ -/* eslint-disable jsx-a11y/no-static-element-interactions */ import type {DateValue} from "@internationalized/date"; import type {ForwardedRef, ReactElement, Ref} from "react"; -import {useMemo} from "react"; import {forwardRef} from "@nextui-org/system"; import {UseDateInputProps, useDateInput} from "./use-date-input"; -import {DateInputSegment} from "./date-input-segment"; +import {DateInputGroup} from "./date-input-group"; +import {DateInputField} from "./date-input-field"; export interface Props extends UseDateInputProps {} function DateInput(props: Props, ref: ForwardedRef) { - const { - Component, - state, - label, - slots, - hasHelper, - errorMessage, - description, - startContent, - endContent, - shouldLabelBeOutside, - classNames, - getBaseProps, - getInputProps, - getFieldProps, - getLabelProps, - getInputWrapperProps, - getInnerWrapperProps, - getDescriptionProps, - getHelperWrapperProps, - getErrorMessageProps, - } = useDateInput({ - ...props, - ref, - }); - - const labelContent = label ? {label} : null; - - const helperWrapper = useMemo(() => { - if (!hasHelper) return null; - - return ( -
- {errorMessage ? ( -
{errorMessage}
- ) : description ? ( -
{description}
- ) : null} -
- ); - }, [ - hasHelper, - errorMessage, - description, - getHelperWrapperProps, - getErrorMessageProps, - getDescriptionProps, - ]); - - const inputContent = useMemo( - () => ( -
- {state.segments.map((segment, i) => ( - - ))} - -
- ), - [state, slots, classNames?.segment, getFieldProps], - ); + const {state, slots, classNames, getBaseGroupProps, getInputProps, getFieldProps} = + useDateInput({ + ...props, + ref, + }); return ( - - {shouldLabelBeOutside ? labelContent : null} -
- {!shouldLabelBeOutside ? labelContent : null} -
- {startContent} - {inputContent} - {endContent} -
- {shouldLabelBeOutside ? helperWrapper : null} -
- {!shouldLabelBeOutside ? helperWrapper : null} -
+ + + ); } diff --git a/packages/components/date-input/src/index.ts b/packages/components/date-input/src/index.ts index b064e8c251..63409e3734 100644 --- a/packages/components/date-input/src/index.ts +++ b/packages/components/date-input/src/index.ts @@ -6,10 +6,15 @@ export type {DateInputProps} from "./date-input"; export type {TimeInputProps} from "./time-input"; export type {DateValue as DateInputValue} from "@react-types/datepicker"; export type {TimeValue as TimeInputValue} from "@react-types/datepicker"; +export type {DateInputGroupProps} from "./date-input-group"; +export type {DateInputFieldProps} from "./date-input-field"; // export hooks export {useDateInput} from "./use-date-input"; export {useTimeInput} from "./use-time-input"; // export components +export {DateInputGroup} from "./date-input-group"; +export {DateInputField} from "./date-input-field"; +export {DateInputSegment} from "./date-input-segment"; export {DateInput, TimeInput}; diff --git a/packages/components/date-input/src/time-input.tsx b/packages/components/date-input/src/time-input.tsx index ccec206139..a393fc7a17 100644 --- a/packages/components/date-input/src/time-input.tsx +++ b/packages/components/date-input/src/time-input.tsx @@ -1,96 +1,31 @@ import type {TimeValue} from "@react-types/datepicker"; import type {ForwardedRef, ReactElement, Ref} from "react"; -import {useMemo} from "react"; import {forwardRef} from "@nextui-org/system"; import {UseTimeInputProps, useTimeInput} from "./use-time-input"; -import {DateInputSegment} from "./date-input-segment"; +import {DateInputField} from "./date-input-field"; +import {DateInputGroup} from "./date-input-group"; export interface Props extends UseTimeInputProps {} function TimeInput(props: Props, ref: ForwardedRef) { - const { - Component, - state, - label, - slots, - hasHelper, - errorMessage, - description, - startContent, - endContent, - shouldLabelBeOutside, - classNames, - getBaseProps, - getInputProps, - getFieldProps, - getLabelProps, - getInputWrapperProps, - getInnerWrapperProps, - getDescriptionProps, - getHelperWrapperProps, - getErrorMessageProps, - } = useTimeInput({ - ...props, - ref, - }); - - const labelContent = label ? {label} : null; - - const helperWrapper = useMemo(() => { - if (!hasHelper) return null; - - return ( -
- {errorMessage ? ( -
{errorMessage}
- ) : description ? ( -
{description}
- ) : null} -
- ); - }, [ - hasHelper, - errorMessage, - description, - getHelperWrapperProps, - getErrorMessageProps, - getDescriptionProps, - ]); - - const inputContent = useMemo( - () => ( -
- {state.segments.map((segment, i) => ( - - ))} - -
- ), - [state, slots, classNames?.segment, getFieldProps], - ); + const {state, slots, classNames, getBaseGroupProps, getInputProps, getFieldProps} = + useTimeInput({ + ...props, + ref, + }); return ( - - {shouldLabelBeOutside ? labelContent : null} -
- {!shouldLabelBeOutside ? labelContent : null} -
- {startContent} - {inputContent} - {endContent} -
- {shouldLabelBeOutside ? helperWrapper : null} -
- {!shouldLabelBeOutside ? helperWrapper : null} -
+ + + ); } diff --git a/packages/components/date-input/src/use-date-input.ts b/packages/components/date-input/src/use-date-input.ts index f3157e9a1d..ee58c9527b 100644 --- a/packages/components/date-input/src/use-date-input.ts +++ b/packages/components/date-input/src/use-date-input.ts @@ -4,6 +4,7 @@ import type {SupportedCalendars} from "@nextui-org/system"; import type {DateValue, Calendar} from "@internationalized/date"; import type {ReactRef} from "@nextui-org/react-utils"; import type {DOMAttributes, GroupDOMAttributes} from "@react-types/shared"; +import type {DateInputGroupProps} from "./date-input-group"; import {useLocale} from "@react-aria/i18n"; import {CalendarDate} from "@internationalized/date"; @@ -137,15 +138,14 @@ export function useDateInput(originalProps: UseDateInputPro maxValue = providerContext?.defaultDates?.maxDate ?? new CalendarDate(2099, 12, 31), createCalendar: createCalendarProp = providerContext?.createCalendar ?? null, isInvalid: isInvalidProp = validationState ? validationState === "invalid" : false, - errorMessage: errorMessageProp, + errorMessage, } = props; const domRef = useDOMRef(ref); const inputRef = useDOMRef(inputRefProp); - const Component = as || "div"; - const {locale} = useLocale(); + const state = useDateFieldState({ ...originalProps, label, @@ -175,17 +175,6 @@ export function useDateInput(originalProps: UseDateInputPro const isInvalid = isInvalidProp || ariaIsInvalid; - const errorMessage = - typeof errorMessageProp === "function" - ? errorMessageProp({ - isInvalid, - validationErrors, - validationDetails, - }) - : errorMessageProp || validationErrors.join(" "); - - const hasHelper = !!description || !!errorMessage; - const labelPlacement = useMemo(() => { if ( (!originalProps.labelPlacement || originalProps.labelPlacement === "inside") && @@ -209,20 +198,6 @@ export function useDateInput(originalProps: UseDateInputPro [objectToDeps(variantProps), labelPlacement, className], ); - const getBaseProps: PropGetter = () => { - return { - "data-slot": "base", - "data-has-helper": dataAttr(hasHelper), - "data-required": dataAttr(originalProps.isRequired), - "data-disabled": dataAttr(originalProps.isDisabled), - "data-readonly": dataAttr(originalProps.isReadOnly), - "data-invalid": dataAttr(isInvalid), - "data-has-start-content": dataAttr(!!startContent), - "data-has-end-content": dataAttr(!!endContent), - className: slots.base({class: baseStyles}), - }; - }; - const getLabelProps: PropGetter = (props) => { return { ...mergeProps(labelProps, labelPropsProp, props), @@ -241,18 +216,18 @@ export function useDateInput(originalProps: UseDateInputPro }; }; - const getFieldProps: PropGetter = (props) => { + const getFieldProps = (props: DOMAttributes = {}) => { return { ref: domRef, - "data-slot": "input", + "data-slot": "input-field", ...mergeProps(fieldProps, fieldPropsProp, props), className: slots.input({ class: clsx(classNames?.input, props?.className), }), - }; + } as GroupDOMAttributes; }; - const getInputWrapperProps: PropGetter = (props) => { + const getInputWrapperProps = (props = {}) => { return { ...props, ...groupProps, @@ -261,7 +236,7 @@ export function useDateInput(originalProps: UseDateInputPro class: classNames?.inputWrapper, }), onClick: fieldProps.onClick, - }; + } as GroupDOMAttributes; }; const getInnerWrapperProps: PropGetter = (props) => { @@ -300,29 +275,44 @@ export function useDateInput(originalProps: UseDateInputPro }; }; + const getBaseGroupProps = () => { + return { + as, + label, + description, + endContent, + errorMessage, + isInvalid, + startContent, + validationDetails, + validationErrors, + shouldLabelBeOutside, + "data-slot": "base", + "data-required": dataAttr(originalProps.isRequired), + "data-disabled": dataAttr(originalProps.isDisabled), + "data-readonly": dataAttr(originalProps.isReadOnly), + "data-invalid": dataAttr(isInvalid), + "data-has-start-content": dataAttr(!!startContent), + "data-has-end-content": dataAttr(!!endContent), + descriptionProps: getDescriptionProps(), + errorMessageProps: getErrorMessageProps(), + groupProps: getInputWrapperProps(), + helperWrapperProps: getHelperWrapperProps(), + labelProps: getLabelProps(), + wrapperProps: getInnerWrapperProps(), + className: slots.base({class: baseStyles}), + } as DateInputGroupProps; + }; + return { - Component, state, domRef, slots, - label, - hasHelper, - shouldLabelBeOutside, classNames, - description, - errorMessage, labelPlacement, - startContent, - endContent, - getBaseProps, - getLabelProps, + getBaseGroupProps, getFieldProps, getInputProps, - getInputWrapperProps, - getInnerWrapperProps, - getHelperWrapperProps, - getErrorMessageProps, - getDescriptionProps, }; } diff --git a/packages/components/date-input/src/use-time-input.ts b/packages/components/date-input/src/use-time-input.ts index 1ff2f0130a..21e009857d 100644 --- a/packages/components/date-input/src/use-time-input.ts +++ b/packages/components/date-input/src/use-time-input.ts @@ -2,6 +2,7 @@ import type {DateInputVariantProps, DateInputSlots, SlotsToClasses} from "@nextu import type {AriaTimeFieldProps, TimeValue} from "@react-types/datepicker"; import type {ReactRef} from "@nextui-org/react-utils"; import type {DOMAttributes, GroupDOMAttributes} from "@react-types/shared"; +import type {DateInputGroupProps} from "./date-input-group"; import {useLocale} from "@react-aria/i18n"; import {mergeProps} from "@react-aria/utils"; @@ -95,15 +96,14 @@ export function useTimeInput(originalProps: UseTimeInputPro minValue, maxValue, isInvalid: isInvalidProp = validationState ? validationState === "invalid" : false, - errorMessage: errorMessageProp, + errorMessage, } = props; const domRef = useDOMRef(ref); const inputRef = useDOMRef(inputRefProp); - const Component = as || "div"; - const {locale} = useLocale(); + const state = useTimeFieldState({ ...originalProps, label, @@ -129,17 +129,6 @@ export function useTimeInput(originalProps: UseTimeInputPro const isInvalid = isInvalidProp || ariaIsInvalid; - const errorMessage = - typeof errorMessageProp === "function" - ? errorMessageProp({ - isInvalid, - validationErrors, - validationDetails, - }) - : errorMessageProp || validationErrors.join(" "); - - const hasHelper = !!description || !!errorMessage; - const labelPlacement = useMemo(() => { if ( (!originalProps.labelPlacement || originalProps.labelPlacement === "inside") && @@ -163,20 +152,6 @@ export function useTimeInput(originalProps: UseTimeInputPro [objectToDeps(variantProps), labelPlacement, className], ); - const getBaseProps: PropGetter = () => { - return { - "data-slot": "base", - "data-has-helper": dataAttr(hasHelper), - "data-required": dataAttr(originalProps.isRequired), - "data-disabled": dataAttr(originalProps.isDisabled), - "data-readonly": dataAttr(originalProps.isReadOnly), - "data-invalid": dataAttr(isInvalid), - "data-has-start-content": dataAttr(!!startContent), - "data-has-end-content": dataAttr(!!endContent), - className: slots.base({class: baseStyles}), - }; - }; - const getLabelProps: PropGetter = (props) => { return { ...mergeProps(labelProps, labelPropsProp, props), @@ -195,7 +170,7 @@ export function useTimeInput(originalProps: UseTimeInputPro }; }; - const getFieldProps: PropGetter = (props) => { + const getFieldProps = (props: DOMAttributes = {}) => { return { ref: domRef, "data-slot": "input", @@ -203,10 +178,10 @@ export function useTimeInput(originalProps: UseTimeInputPro className: slots.input({ class: clsx(classNames?.input, props?.className), }), - }; + } as GroupDOMAttributes; }; - const getInputWrapperProps: PropGetter = (props) => { + const getInputWrapperProps = (props = {}) => { return { ...props, ...groupProps, @@ -215,7 +190,7 @@ export function useTimeInput(originalProps: UseTimeInputPro class: classNames?.inputWrapper, }), onClick: fieldProps.onClick, - }; + } as GroupDOMAttributes; }; const getInnerWrapperProps: PropGetter = (props) => { @@ -254,29 +229,44 @@ export function useTimeInput(originalProps: UseTimeInputPro }; }; + const getBaseGroupProps = () => { + return { + as, + label, + description, + endContent, + errorMessage, + isInvalid, + startContent, + validationDetails, + validationErrors, + shouldLabelBeOutside, + "data-slot": "base", + "data-required": dataAttr(originalProps.isRequired), + "data-disabled": dataAttr(originalProps.isDisabled), + "data-readonly": dataAttr(originalProps.isReadOnly), + "data-invalid": dataAttr(isInvalid), + "data-has-start-content": dataAttr(!!startContent), + "data-has-end-content": dataAttr(!!endContent), + descriptionProps: getDescriptionProps(), + errorMessageProps: getErrorMessageProps(), + groupProps: getInputWrapperProps(), + helperWrapperProps: getHelperWrapperProps(), + labelProps: getLabelProps(), + wrapperProps: getInnerWrapperProps(), + className: slots.base({class: baseStyles}), + } as DateInputGroupProps; + }; + return { - Component, state, domRef, slots, - label, - hasHelper, - shouldLabelBeOutside, classNames, - description, - errorMessage, labelPlacement, - startContent, - endContent, - getBaseProps, - getLabelProps, + getBaseGroupProps, getFieldProps, getInputProps, - getInputWrapperProps, - getInnerWrapperProps, - getHelperWrapperProps, - getErrorMessageProps, - getDescriptionProps, }; } diff --git a/packages/components/date-input/stories/date-input.stories.tsx b/packages/components/date-input/stories/date-input.stories.tsx index fc6081f75e..a23e4e211b 100644 --- a/packages/components/date-input/stories/date-input.stories.tsx +++ b/packages/components/date-input/stories/date-input.stories.tsx @@ -300,7 +300,7 @@ export const MaxDateValue = { args: { ...defaultProps, maxValue: today(getLocalTimeZone()), - defaultValue: parseDate("2024-04-05"), + defaultValue: today(getLocalTimeZone()).add({days: 1}), }, }; diff --git a/packages/components/date-picker/src/date-picker.tsx b/packages/components/date-picker/src/date-picker.tsx index 9ff90198ee..54d3e0ff36 100644 --- a/packages/components/date-picker/src/date-picker.tsx +++ b/packages/components/date-picker/src/date-picker.tsx @@ -50,7 +50,7 @@ function DatePicker(props: Props, ref: ForwardedRef { if (isCalendarHeaderExpanded) return null; diff --git a/packages/components/date-picker/src/date-range-picker-field.tsx b/packages/components/date-picker/src/date-range-picker-field.tsx new file mode 100644 index 0000000000..4cce73bae2 --- /dev/null +++ b/packages/components/date-picker/src/date-range-picker-field.tsx @@ -0,0 +1,82 @@ +import type {DateInputReturnType, DateInputSlots, SlotsToClasses} from "@nextui-org/theme"; +import type {AriaDatePickerProps} from "@react-types/datepicker"; +import type {HTMLNextUIProps} from "@nextui-org/system"; +import type {DateInputProps} from "@nextui-org/date-input"; + +import {createCalendar} from "@internationalized/date"; +import {forwardRef, useRef} from "react"; +import {DateValue} from "@react-types/datepicker"; +import {useDateField as useAriaDateField} from "@react-aria/datepicker"; +import {ForwardedRef, ReactElement, Ref} from "react"; +import {useDateFieldState} from "@react-stately/datepicker"; +import {DateInputSegment} from "@nextui-org/date-input"; +import {filterDOMProps, useDOMRef} from "@nextui-org/react-utils"; +import {useLocale} from "@react-aria/i18n"; +import {mergeProps} from "@react-aria/utils"; + +type NextUIBaseProps = Omit< + HTMLNextUIProps<"div">, + keyof AriaDatePickerProps | "onChange" +>; + +export interface Props + extends NextUIBaseProps, + AriaDatePickerProps, + Pick { + /** DateInput classes slots. */ + slots: DateInputReturnType; + /** DateInput classes. */ + classNames?: SlotsToClasses; +} + +function DateRangePickerField( + props: Props, + ref: ForwardedRef, +) { + const {as, slots, createCalendar: createCalendarProp, classNames, ...otherProps} = props; + + const Component = as || "div"; + + const domRef = useDOMRef(ref); + + const {locale} = useLocale(); + + const state = useDateFieldState({ + ...otherProps, + locale, + createCalendar: + !createCalendarProp || typeof createCalendarProp !== "function" + ? createCalendar + : (createCalendarProp as typeof createCalendar), + }); + + const inputRef = useRef(null); + + const {fieldProps, inputProps} = useAriaDateField({...otherProps, inputRef}, state, domRef); + + return ( + + {state.segments.map((segment, i) => ( + + ))} + + + ); +} + +DateRangePickerField.displayName = "NextUI.DateRangePickerField"; + +export type DateRangePickerFieldProps = Props & { + ref?: Ref; +}; + +// forwardRef doesn't support generic parameters, so cast the result to the correct type +export default forwardRef(DateRangePickerField) as ( + props: DateRangePickerFieldProps, +) => ReactElement; diff --git a/packages/components/date-picker/src/date-range-picker.tsx b/packages/components/date-picker/src/date-range-picker.tsx new file mode 100644 index 0000000000..5db390b9bc --- /dev/null +++ b/packages/components/date-picker/src/date-range-picker.tsx @@ -0,0 +1,108 @@ +import type {DateValue} from "@internationalized/date"; + +import {ForwardedRef, ReactElement, Ref, useMemo} from "react"; +import {cloneElement, isValidElement} from "react"; +import {forwardRef} from "@nextui-org/system"; +import {Button} from "@nextui-org/button"; +import {TimeInput, DateInputGroup} from "@nextui-org/date-input"; +import {FreeSoloPopover} from "@nextui-org/popover"; +import {RangeCalendar} from "@nextui-org/calendar"; +import {AnimatePresence} from "framer-motion"; +import {CalendarBoldIcon} from "@nextui-org/shared-icons"; + +import DateRangePickerField from "./date-range-picker-field"; +import {UseDateRangePickerProps, useDateRangePicker} from "./use-date-range-picker"; + +export interface Props + extends Omit, "hasMultipleMonths"> {} + +function DateRangePicker(props: Props, ref: ForwardedRef) { + const { + state, + slots, + endContent, + selectorIcon, + showTimeField, + classNames, + disableAnimation, + isCalendarHeaderExpanded, + getDateInputGroupProps, + getStartDateInputProps, + getEndDateInputProps, + getPopoverProps, + getSeparatorProps, + getStartTimeInputProps, + getEndTimeInputProps, + getSelectorButtonProps, + getSelectorIconProps, + getCalendarProps, + CalendarTopContent, + CalendarBottomContent, + } = useDateRangePicker({...props, ref}); + + const selectorContent = isValidElement(selectorIcon) ? ( + cloneElement(selectorIcon, getSelectorIconProps()) + ) : ( + + ); + + const calendarBottomContent = useMemo(() => { + if (isCalendarHeaderExpanded) return null; + + return showTimeField ? ( +
+
+ + +
+ {CalendarBottomContent} +
+ ) : ( + CalendarBottomContent + ); + }, [state, showTimeField, CalendarBottomContent, isCalendarHeaderExpanded]); + + const calendarTopContent = useMemo(() => { + if (isCalendarHeaderExpanded) return null; + + return CalendarTopContent; + }, [showTimeField, CalendarTopContent, isCalendarHeaderExpanded]); + + const popoverContent = state.isOpen ? ( + + + + ) : null; + + return ( + <> + {endContent || selectorContent}} + > + + + + + + {disableAnimation ? popoverContent : {popoverContent}} + + ); +} + +DateRangePicker.displayName = "NextUI.DateRangePicker"; + +export type DateRangePickerProps = Props & { + ref?: Ref; +}; + +// forwardRef doesn't support generic parameters, so cast the result to the correct type +export default forwardRef(DateRangePicker) as ( + props: DateRangePickerProps, +) => ReactElement; diff --git a/packages/components/date-picker/src/index.ts b/packages/components/date-picker/src/index.ts index 7f848bfa74..9a5b018bcc 100644 --- a/packages/components/date-picker/src/index.ts +++ b/packages/components/date-picker/src/index.ts @@ -1,10 +1,12 @@ import DatePicker from "./date-picker"; +import DateRangePicker from "./date-range-picker"; // export types export type {DatePickerProps} from "./date-picker"; +export type {DateRangePickerProps} from "./date-range-picker"; // export hooks export {useDatePicker} from "./use-date-picker"; -// export component -export {DatePicker}; +// export components +export {DatePicker, DateRangePicker}; diff --git a/packages/components/date-picker/src/use-date-picker-base.ts b/packages/components/date-picker/src/use-date-picker-base.ts new file mode 100644 index 0000000000..e03498d7d6 --- /dev/null +++ b/packages/components/date-picker/src/use-date-picker-base.ts @@ -0,0 +1,289 @@ +import type {DateValue} from "@internationalized/date"; +import type {AriaDatePickerBaseProps} from "@react-types/datepicker"; +import type {DateInputProps, TimeInputProps} from "@nextui-org/date-input"; +import type {ButtonProps} from "@nextui-org/button"; +import type {CalendarProps} from "@nextui-org/calendar"; +import type {PopoverProps} from "@nextui-org/popover"; +import type {ReactNode} from "react"; +import type {ValueBase} from "@react-types/shared"; + +import {dateInput, DatePickerVariantProps} from "@nextui-org/theme"; +import {useState} from "react"; +import {HTMLNextUIProps, mapPropsVariants} from "@nextui-org/system"; +import {mergeProps} from "@react-aria/utils"; +import {useDOMRef} from "@nextui-org/react-utils"; +import {dataAttr} from "@nextui-org/shared-utils"; +import {useLocalizedStringFormatter} from "@react-aria/i18n"; + +import intlMessages from "../intl/messages"; + +type NextUIBaseProps = Omit< + HTMLNextUIProps<"div">, + keyof AriaDatePickerBaseProps | "onChange" +>; + +interface Props extends NextUIBaseProps { + /** + * The icon to toggle the date picker popover. Usually a calendar icon. + */ + selectorIcon?: ReactNode; + /** + * Controls the behavior of paging. Pagination either works by advancing the visible page by visibleDuration (default) or one unit of visibleDuration. + * @default visible + */ + pageBehavior?: CalendarProps["pageBehavior"]; + /** + * 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?: CalendarProps["visibleMonths"]; + /** + * The width to be applied to the calendar component. + * + * @default 256 + */ + calendarWidth?: number; + /** + * Top content to be rendered in the calendar component. + */ + CalendarTopContent?: CalendarProps["topContent"]; + /** + * Bottom content to be rendered in the calendar component. + */ + CalendarBottomContent?: CalendarProps["bottomContent"]; + /** + * Whether the calendar should show month and year pickers. + * + * @default false + */ + showMonthAndYearPickers?: CalendarProps["showMonthAndYearPickers"]; + /** + * Props to be passed to the popover component. + * + * @default { placement: "bottom", triggerScaleOnOpen: false, offset: 13 } + */ + popoverProps?: Partial; + /** + * Props to be passed to the selector button component. + * @default { size: "sm", variant: "light", radius: "full", isIconOnly: true } + */ + selectorButtonProps?: Partial; + /** + * Props to be passed to the calendar component. + * @default {} + */ + calendarProps?: Partial>; + /** + * Props to be passed to the time input component. + * + * @default {} + */ + timeInputProps?: TimeInputProps; + /** + * Callback that is called for each date of the calendar. If it returns true, then the date is unavailable. + */ + isDateUnavailable?: CalendarProps["isDateUnavailable"]; + /** + * Whether to disable all animations in the date picker. Including the DateInput, Button, Calendar, and Popover. + * + * @default false + */ + disableAnimation?: boolean; +} + +type Variants = + | "color" + | "size" + | "isDisabled" + | "disableAnimation" + | "variant" + | "radius" + | "labelPlacement" + | "fullWidth"; + +export type UseDatePickerBaseProps = Props & + DatePickerVariantProps & + Pick< + DateInputProps, + Variants | "ref" | "createCalendar" | "startContent" | "endContent" | "inputRef" + > & + Omit, keyof ValueBase | "validate">; + +export function useDatePickerBase(originalProps: UseDatePickerBaseProps) { + const [props, variantProps] = mapPropsVariants(originalProps, dateInput.variantKeys); + + const [isCalendarHeaderExpanded, setIsCalendarHeaderExpanded] = useState(false); + + const { + as, + ref, + label, + endContent, + selectorIcon, + inputRef, + isInvalid, + errorMessage, + description, + startContent, + validationState, + validationBehavior, + visibleMonths = 1, + pageBehavior = "visible", + calendarWidth = 256, + isDateUnavailable, + shouldForceLeadingZeros, + showMonthAndYearPickers = false, + selectorButtonProps: userSelectorButtonProps = {}, + popoverProps: userPopoverProps = {}, + timeInputProps: userTimeInputProps = {}, + calendarProps: userCalendarProps = {}, + CalendarTopContent, + CalendarBottomContent, + createCalendar, + } = props; + + const domRef = useDOMRef(ref); + const disableAnimation = originalProps.disableAnimation ?? false; + + let stringFormatter = useLocalizedStringFormatter(intlMessages) as any; + + const isDefaultColor = originalProps.color === "default" || !originalProps.color; + const hasMultipleMonths = visibleMonths > 1; + + // Time field values + const placeholder = originalProps?.placeholderValue; + const timePlaceholder = placeholder && "hour" in placeholder ? placeholder : null; + const timeMinValue = + originalProps.minValue && "hour" in originalProps.minValue ? originalProps.minValue : null; + const timeMaxValue = + originalProps.maxValue && "hour" in originalProps.maxValue ? originalProps.maxValue : null; + + const slotsProps: { + popoverProps: UseDatePickerBaseProps["popoverProps"]; + selectorButtonProps: ButtonProps; + calendarProps: CalendarProps; + } = { + popoverProps: mergeProps( + { + offset: 13, + placement: "bottom", + triggerScaleOnOpen: false, + disableAnimation, + }, + userPopoverProps, + ), + selectorButtonProps: mergeProps( + { + isIconOnly: true, + radius: "full", + size: "sm", + variant: "light", + disableAnimation, + }, + userSelectorButtonProps, + ), + calendarProps: mergeProps( + { + showHelper: false, + visibleMonths, + pageBehavior, + isDateUnavailable, + showMonthAndYearPickers, + onHeaderExpandedChange: setIsCalendarHeaderExpanded, + color: + (originalProps.variant === "bordered" || originalProps.variant === "underlined") && + isDefaultColor + ? "foreground" + : isDefaultColor + ? "primary" + : originalProps.color, + disableAnimation, + }, + userCalendarProps, + ), + }; + + const dateInputProps = { + as, + label, + ref: domRef, + inputRef, + description, + startContent, + validationState, + validationBehavior, + shouldForceLeadingZeros, + isInvalid, + errorMessage, + "data-invalid": dataAttr(originalProps?.isInvalid), + } as DateInputProps; + + const timeInputProps = { + ...userTimeInputProps, + size: "sm", + labelPlacement: "outside-left", + label: userTimeInputProps?.label || stringFormatter.format("time"), + placeholderValue: timePlaceholder, + hourCycle: props.hourCycle, + hideTimeZone: props.hideTimeZone, + } as TimeInputProps; + + const popoverProps = { + ...mergeProps(slotsProps.popoverProps, props), + triggerRef: domRef, + } as PopoverProps; + + const calendarProps = { + ...slotsProps.calendarProps, + "data-slot": "calendar", + "data-has-multiple-months": dataAttr(hasMultipleMonths), + style: mergeProps( + hasMultipleMonths + ? { + // @ts-ignore + "--visible-months": visibleMonths, + } + : {}, + {"--calendar-width": `${calendarWidth}px`}, + slotsProps.calendarProps.style, + ), + } as CalendarProps; + + const selectorButtonProps = { + ...slotsProps.selectorButtonProps, + "data-slot": "selector-button", + } as ButtonProps; + + const selectorIconProps = { + "data-slot": "selector-icon", + }; + + return { + domRef, + endContent, + selectorIcon, + createCalendar, + stringFormatter, + hasMultipleMonths, + slotsProps, + timeMinValue, + timeMaxValue, + visibleMonths, + isCalendarHeaderExpanded, + disableAnimation, + CalendarTopContent, + CalendarBottomContent, + variantProps, + dateInputProps, + timeInputProps, + popoverProps, + calendarProps, + userTimeInputProps, + selectorButtonProps, + selectorIconProps, + }; +} + +export type UseDatePickerBaseReturn = ReturnType; diff --git a/packages/components/date-picker/src/use-date-picker.ts b/packages/components/date-picker/src/use-date-picker.ts index 38adeffcd8..19f96eb412 100644 --- a/packages/components/date-picker/src/use-date-picker.ts +++ b/packages/components/date-picker/src/use-date-picker.ts @@ -1,106 +1,26 @@ import type {DateValue} from "@internationalized/date"; -import type {AriaDatePickerProps} from "@react-types/datepicker"; import type {DateInputProps, TimeInputProps} from "@nextui-org/date-input"; import type {DatePickerState} from "@react-stately/datepicker"; import type {ButtonProps} from "@nextui-org/button"; import type {CalendarProps} from "@nextui-org/calendar"; import type {PopoverProps} from "@nextui-org/popover"; -import type {ReactNode} from "react"; +import type {UseDatePickerBaseProps} from "./use-date-picker-base"; +import type {DOMAttributes} from "@nextui-org/system"; +import type {DatePickerSlots, SlotsToClasses} from "@nextui-org/theme"; -import { - DatePickerVariantProps, - DatePickerSlots, - SlotsToClasses, - dateInput, -} from "@nextui-org/theme"; -import {useMemo, useState} from "react"; -import {DOMAttributes} from "@nextui-org/system"; -import {useDatePickerState} from "@react-stately/datepicker"; -import {useDatePicker as useAriaDatePicker} from "@react-aria/datepicker"; -import {HTMLNextUIProps, mapPropsVariants} from "@nextui-org/system"; +import {useMemo} from "react"; import {datePicker} from "@nextui-org/theme"; -import {mergeProps} from "@react-aria/utils"; -import {useDOMRef} from "@nextui-org/react-utils"; +import {useDatePickerState} from "@react-stately/datepicker"; +import {AriaDatePickerProps, useDatePicker as useAriaDatePicker} from "@react-aria/datepicker"; import {clsx, dataAttr, objectToDeps} from "@nextui-org/shared-utils"; -import {useLocalizedStringFormatter} from "@react-aria/i18n"; +import {mergeProps} from "@react-aria/utils"; -import intlMessages from "../intl/messages"; +import {useDatePickerBase} from "./use-date-picker-base"; -type NextUIBaseProps = Omit< - HTMLNextUIProps<"div">, - keyof AriaDatePickerProps | "onChange" ->; +interface Props extends UseDatePickerBaseProps {} -interface Props extends NextUIBaseProps { - /** - * The icon to toggle the date picker popover. Usually a calendar icon. - */ - selectorIcon?: ReactNode; - /** - * Controls the behavior of paging. Pagination either works by advancing the visible page by visibleDuration (default) or one unit of visibleDuration. - * @default visible - */ - pageBehavior?: CalendarProps["pageBehavior"]; - /** - * 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?: CalendarProps["visibleMonths"]; - /** - * The width to be applied to the calendar component. - * - * @default 256 - */ - calendarWidth?: number; - /** - * Top content to be rendered in the calendar component. - */ - CalendarTopContent?: CalendarProps["topContent"]; - /** - * Bottom content to be rendered in the calendar component. - */ - CalendarBottomContent?: CalendarProps["bottomContent"]; - /** - * Whether the calendar should show month and year pickers. - * - * @default false - */ - showMonthAndYearPickers?: CalendarProps["showMonthAndYearPickers"]; - /** - * Props to be passed to the popover component. - * - * @default { placement: "bottom", triggerScaleOnOpen: false, offset: 13 } - */ - popoverProps?: Partial; - /** - * Props to be passed to the selector button component. - * @default { size: "sm", variant: "light", radius: "full", isIconOnly: true } - */ - selectorButtonProps?: Partial; - /** - * Props to be passed to the calendar component. - * @default {} - */ - calendarProps?: Partial>; - - /** - * Props to be passed to the time input component. - * - * @default {} - */ - timeInputProps?: TimeInputProps; - /** - * Callback that is called for each date of the calendar. If it returns true, then the date is unavailable. - */ - isDateUnavailable?: CalendarProps["isDateUnavailable"]; - /** - * Whether to disable all animations in the date picker. Including the DateInput, Button, Calendar, and Popover. - * - * @default false - */ - disableAnimation?: boolean; +interface Props + extends Omit, keyof AriaDatePickerProps> { /** * Classname or List of classes to change the classNames of the element. * if `className` is passed, it will be added to the base slot. @@ -127,80 +47,66 @@ interface Props extends NextUIBaseProps { classNames?: SlotsToClasses & DateInputProps["classNames"]; } -export type UseDatePickerProps = Props & - DatePickerVariantProps & - Omit, "groupProps" | "fieldProps" | "labelProps" | "errorMessageProps">; - -export function useDatePicker(originalProps: UseDatePickerProps) { - const [props, variantProps] = mapPropsVariants(originalProps, dateInput.variantKeys); - - const [isCalendarHeaderExpanded, setIsCalendarHeaderExpanded] = useState(false); +export type UseDatePickerProps = Props & AriaDatePickerProps; +export function useDatePicker({ + className, + classNames, + ...originalProps +}: UseDatePickerProps) { const { - as, - ref, - label, - selectorIcon, - inputRef, - isInvalid, - errorMessage, - description, - startContent, + domRef, endContent, - validationState, - validationBehavior, - visibleMonths = 1, - pageBehavior = "visible", - calendarWidth = 256, - isDateUnavailable, - shouldForceLeadingZeros, - showMonthAndYearPickers = false, - popoverProps = {}, - timeInputProps = {}, - selectorButtonProps = {}, - calendarProps: userCalendarProps = {}, + selectorIcon, + createCalendar, + hasMultipleMonths, + isCalendarHeaderExpanded, + disableAnimation, CalendarTopContent, + slotsProps, + timeMinValue, + timeMaxValue, CalendarBottomContent, - minValue, - maxValue, - createCalendar, - className, - classNames, - } = props; - - const domRef = useDOMRef(ref); - const disableAnimation = originalProps.disableAnimation ?? false; + dateInputProps, + timeInputProps, + popoverProps, + calendarProps, + variantProps, + userTimeInputProps, + selectorButtonProps, + selectorIconProps, + } = useDatePickerBase(originalProps); let state: DatePickerState = useDatePickerState({ ...originalProps, - minValue, - maxValue, shouldCloseOnSelect: () => !state.hasTime, }); + const baseStyles = clsx(classNames?.base, className); + + const slots = useMemo( + () => + datePicker({ + ...variantProps, + hasMultipleMonths, + className, + }), + [objectToDeps(variantProps), hasMultipleMonths, className], + ); + let { groupProps, labelProps, fieldProps, buttonProps, dialogProps, - calendarProps, + calendarProps: ariaCalendarProps, descriptionProps, errorMessageProps, } = useAriaDatePicker(originalProps, state, domRef); - const baseStyles = clsx(classNames?.base, className); - - let stringFormatter = useLocalizedStringFormatter(intlMessages); - - const isDefaultColor = originalProps.color === "default" || !originalProps.color; - const hasMultipleMonths = visibleMonths > 1; - // Time field values - const placeholder = originalProps?.placeholderValue; - const timePlaceholder = placeholder && "hour" in placeholder ? placeholder : null; - const timeMinValue = props.minValue && "hour" in props.minValue ? props.minValue : null; - const timeMaxValue = props.maxValue && "hour" in props.maxValue ? props.maxValue : null; + originalProps.maxValue && "hour" in originalProps.maxValue ? originalProps.maxValue : null; const timeGranularity = state.granularity === "hour" || state.granularity === "minute" || state.granularity === "second" ? state.granularity @@ -208,89 +114,22 @@ export function useDatePicker(originalProps: UseDatePickerP const showTimeField = !!timeGranularity; - const slotsProps: { - popoverProps: UseDatePickerProps["popoverProps"]; - selectorButtonProps: ButtonProps; - calendarProps: CalendarProps; - } = { - popoverProps: mergeProps( - { - offset: 13, - placement: "bottom", - triggerScaleOnOpen: false, - disableAnimation, - }, - popoverProps, - ), - selectorButtonProps: mergeProps( - { - isIconOnly: true, - radius: "full", - size: "sm", - variant: "light", - disableAnimation, - }, - selectorButtonProps, - ), - calendarProps: mergeProps( - { - showHelper: false, - visibleMonths, - pageBehavior, - isDateUnavailable, - showMonthAndYearPickers, - onHeaderExpandedChange: setIsCalendarHeaderExpanded, - color: - (originalProps.variant === "bordered" || originalProps.variant === "underlined") && - isDefaultColor - ? "foreground" - : isDefaultColor - ? "primary" - : originalProps.color, - disableAnimation, - }, - userCalendarProps, - ), - }; - - const slots = useMemo( - () => - datePicker({ - ...variantProps, - hasMultipleMonths, - className, - }), - [objectToDeps(variantProps), hasMultipleMonths, className], - ); - const getDateInputProps = () => { return { - as, - label, - ref: domRef, - inputRef, - description, - startContent, - validationState, - validationBehavior, - shouldForceLeadingZeros, - isInvalid, - errorMessage, + ...dateInputProps, groupProps, labelProps, createCalendar, errorMessageProps, descriptionProps, ...mergeProps(variantProps, fieldProps, { - minValue, - maxValue, + minValue: originalProps.minValue, + maxValue: originalProps.maxValue, fullWidth: true, disableAnimation, }), - "data-invalid": dataAttr(originalProps?.isInvalid), - "data-open": dataAttr(state.isOpen), className: slots.base({class: baseStyles}), - classNames, + "data-open": dataAttr(state.isOpen), } as DateInputProps; }; @@ -299,25 +138,19 @@ export function useDatePicker(originalProps: UseDatePickerP return { ...timeInputProps, - size: "sm", - labelPlacement: "outside-left", + value: state.timeValue, + onChange: state.setTimeValue, + granularity: timeGranularity, + minValue: timeMinValue, + maxValue: timeMaxValue, classNames: { base: slots.timeInput({ - class: clsx(classNames?.timeInput, timeInputProps?.classNames?.base), + class: clsx(classNames?.timeInput, userTimeInputProps?.classNames?.base), }), label: slots.timeInputLabel({ - class: clsx(classNames?.timeInputLabel, timeInputProps?.classNames?.label), + class: clsx(classNames?.timeInputLabel, userTimeInputProps?.classNames?.label), }), }, - label: timeInputProps?.label || stringFormatter.format("time"), - value: state.timeValue, - onChange: state.setTimeValue, - placeholderValue: timePlaceholder, - granularity: timeGranularity, - minValue: timeMinValue, - maxValue: timeMaxValue, - hourCycle: props.hourCycle, - hideTimeZone: props.hideTimeZone, } as TimeInputProps; }; @@ -325,8 +158,8 @@ export function useDatePicker(originalProps: UseDatePickerP return { state, dialogProps, - ...mergeProps(slotsProps.popoverProps, props), - triggerRef: domRef, + ...popoverProps, + ...props, classNames: { content: slots.popoverContent({ class: clsx( @@ -336,43 +169,31 @@ export function useDatePicker(originalProps: UseDatePickerP ), }), }, - } as unknown as PopoverProps; + } as PopoverProps; }; const getCalendarProps = () => { return { + ...ariaCalendarProps, ...calendarProps, - ...slotsProps.calendarProps, - "data-slot": "calendar", classNames: { base: slots.calendar({class: classNames?.calendar}), content: slots.calendarContent({class: classNames?.calendarContent}), }, - style: mergeProps( - hasMultipleMonths - ? { - // @ts-ignore - "--visible-months": visibleMonths, - } - : {}, - {"--calendar-width": `${calendarWidth}px`}, - slotsProps.calendarProps.style, - ), - } as unknown as CalendarProps; + } as CalendarProps; }; const getSelectorButtonProps = () => { return { ...buttonProps, - ...slotsProps.selectorButtonProps, - "data-slot": "selector-button", + ...selectorButtonProps, className: slots.selectorButton({class: classNames?.selectorButton}), - } as unknown as ButtonProps; + } as ButtonProps; }; const getSelectorIconProps = () => { return { - "data-slot": "selector-icon", + ...selectorIconProps, className: slots.selectorIcon({class: classNames?.selectorIcon}), }; }; diff --git a/packages/components/date-picker/src/use-date-range-picker.ts b/packages/components/date-picker/src/use-date-range-picker.ts new file mode 100644 index 0000000000..f21c3755f4 --- /dev/null +++ b/packages/components/date-picker/src/use-date-range-picker.ts @@ -0,0 +1,424 @@ +import type {DateValue} from "@internationalized/date"; +import type {DateInputVariantProps} from "@nextui-org/theme"; +import type {TimeInputProps} from "@nextui-org/date-input"; +import type {ButtonProps} from "@nextui-org/button"; +import type {RangeCalendarProps} from "@nextui-org/calendar"; +import type {PopoverProps} from "@nextui-org/popover"; +import type {DOMAttributes, GroupDOMAttributes} from "@react-types/shared"; +import type {AriaDateRangePickerProps} from "@react-types/datepicker"; +import type {DateRangePickerState} from "@react-stately/datepicker"; +import type {UseDatePickerBaseProps} from "./use-date-picker-base"; +import type {PropGetter} from "@nextui-org/system"; +import type {DateRangePickerFieldProps} from "./date-range-picker-field"; +import type {DateInputGroupProps} from "@nextui-org/date-input"; +import type {DateRangePickerSlots, SlotsToClasses} from "@nextui-org/theme"; +import type {DateInputProps} from "@nextui-org/date-input"; + +import {useMemo, useRef} from "react"; +import {useDateRangePickerState} from "@react-stately/datepicker"; +import {useDateRangePicker as useAriaDateRangePicker} from "@react-aria/datepicker"; +import {clsx, dataAttr, objectToDeps} from "@nextui-org/shared-utils"; +import {mergeProps} from "@react-aria/utils"; +import {dateRangePicker, dateInput} from "@nextui-org/theme"; + +import {useDatePickerBase} from "./use-date-picker-base"; +interface Props + extends Omit, keyof AriaDateRangePickerProps> { + /** + * 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 & DateInputProps["classNames"]; +} + +export type UseDateRangePickerProps = Props & AriaDateRangePickerProps; + +export function useDateRangePicker({ + as, + label, + isInvalid, + description, + startContent, + endContent, + selectorIcon, + errorMessage, + className, + classNames, + ...originalProps +}: UseDateRangePickerProps) { + const { + domRef, + slotsProps, + createCalendar, + stringFormatter, + timeMinValue, + timeMaxValue, + isCalendarHeaderExpanded, + disableAnimation, + CalendarTopContent, + CalendarBottomContent, + timeInputProps, + popoverProps, + calendarProps, + variantProps, + userTimeInputProps, + hasMultipleMonths, + selectorButtonProps, + selectorIconProps, + } = useDatePickerBase(originalProps); + + let state: DateRangePickerState = useDateRangePickerState({ + ...originalProps, + shouldCloseOnSelect: () => !state.hasTime, + }); + + const popoverTriggerRef = useRef(null); + + originalProps.minValue; + + let { + groupProps, + labelProps, + startFieldProps, + endFieldProps, + buttonProps, + dialogProps, + calendarProps: ariaCalendarProps, + validationDetails, + validationErrors, + descriptionProps, + errorMessageProps, + } = useAriaDateRangePicker(originalProps, state, domRef); + + const slots = useMemo( + () => + dateRangePicker({ + ...variantProps, + hasMultipleMonths, + className, + }), + [objectToDeps(variantProps), hasMultipleMonths, className], + ); + + // Time field values + + const timeGranularity = + state.granularity === "hour" || state.granularity === "minute" || state.granularity === "second" + ? state.granularity + : null; + + const showTimeField = !!timeGranularity; + + const labelPlacement = useMemo(() => { + if ((!originalProps.labelPlacement || originalProps.labelPlacement === "inside") && !label) { + return "outside"; + } + + return originalProps.labelPlacement ?? "inside"; + }, [originalProps.labelPlacement, label]); + + const shouldLabelBeOutside = labelPlacement === "outside" || labelPlacement === "outside-left"; + + /** + * ------------------------------ + * DateRangePicker Props + * ------------------------------ + */ + const getStartTimeInputProps = () => { + if (!showTimeField) return {}; + + return { + ...timeInputProps, + label: stringFormatter.format("startTime"), + value: state.timeRange?.start || null, + onChange: (v) => state.setTime("start", v), + granularity: timeGranularity, + minValue: timeMinValue, + maxValue: timeMaxValue, + classNames: { + base: slots.timeInput({ + class: clsx(classNames?.timeInput, userTimeInputProps?.classNames?.base), + }), + label: slots.timeInputLabel({ + class: clsx(classNames?.timeInputLabel, userTimeInputProps?.classNames?.label), + }), + }, + } as TimeInputProps; + }; + + const getEndTimeInputProps = () => { + if (!showTimeField) return {}; + + return { + ...timeInputProps, + label: stringFormatter.format("endTime"), + value: state.timeRange?.end || null, + onChange: (v) => state.setTime("end", v), + granularity: timeGranularity, + minValue: timeMinValue, + maxValue: timeMaxValue, + classNames: { + base: slots.timeInput({ + class: clsx(classNames?.timeInput, userTimeInputProps?.classNames?.base), + }), + label: slots.timeInputLabel({ + class: clsx(classNames?.timeInputLabel, userTimeInputProps?.classNames?.label), + }), + }, + } as TimeInputProps; + }; + + const getPopoverProps = (props: DOMAttributes = {}) => { + return { + state, + dialogProps, + ...props, + ...popoverProps, + triggerRef: popoverTriggerRef, + classNames: { + content: slots.popoverContent({ + class: clsx( + classNames?.popoverContent, + slotsProps.popoverProps?.classNames?.["content"], + props.className, + ), + }), + }, + } as PopoverProps; + }; + + const getCalendarProps = () => { + return { + ...ariaCalendarProps, + ...calendarProps, + classNames: { + base: slots.calendar({class: classNames?.calendar}), + content: slots.calendarContent({class: classNames?.calendarContent}), + }, + } as RangeCalendarProps; + }; + + const getSelectorButtonProps = () => { + return { + ...buttonProps, + ...selectorButtonProps, + className: slots.selectorButton({class: classNames?.selectorButton}), + } as ButtonProps; + }; + + const getSeparatorProps = () => { + return { + "data-slot": "separator", + className: slots.separator({class: classNames?.separator}), + }; + }; + + const getSelectorIconProps = () => { + return { + ...selectorIconProps, + className: slots.selectorIcon({class: classNames?.selectorIcon}), + }; + }; + + /** + * ------------------------------ + * DateInput Props + * ------------------------------ + */ + + const baseStyles = clsx(classNames?.base, className); + + const dateInputSlots = useMemo( + () => + dateInput({ + ...variantProps, + labelPlacement, + className, + }), + [objectToDeps(variantProps), hasMultipleMonths, className], + ); + + const getStartDateInputProps = (props: DOMAttributes = {}) => { + return { + ...startFieldProps, + ref: popoverTriggerRef, + "data-slot": "start-input", + slots: dateInputSlots, + createCalendar, + ...mergeProps(variantProps, startFieldProps, { + fullWidth: true, + disableAnimation, + }), + "data-open": dataAttr(state.isOpen), + classNames, + style: { + ...props.style, + maxWidth: "fit-content", + }, + className: dateInputSlots.input({ + class: clsx(classNames?.input, props?.className), + }), + } as DateRangePickerFieldProps; + }; + + const getEndDateInputProps = (props: DOMAttributes = {}) => { + return { + ...endFieldProps, + "data-slot": "end-input", + slots: dateInputSlots, + createCalendar, + ...mergeProps(variantProps, endFieldProps, { + fullWidth: true, + disableAnimation, + }), + "data-open": dataAttr(state.isOpen), + classNames, + className: dateInputSlots.input({ + class: clsx(classNames?.input, props?.className), + }), + } as DateRangePickerFieldProps; + }; + + const getLabelProps: PropGetter = (props) => { + return { + ...props, + ...labelProps, + "data-slot": "label", + className: dateInputSlots.label({ + class: clsx(classNames?.label, props?.className), + }), + }; + }; + + const getInputWrapperProps = (props = {}) => { + return { + ...props, + ...groupProps, + "data-slot": "input-wrapper", + className: dateInputSlots.inputWrapper({ + class: classNames?.inputWrapper, + }), + onClick: labelProps.onClick, + } as GroupDOMAttributes; + }; + + const getInnerWrapperProps: PropGetter = (props) => { + return { + ...props, + "data-slot": "inner-wrapper", + className: dateInputSlots.innerWrapper({ + class: classNames?.innerWrapper, + }), + }; + }; + + const getHelperWrapperProps: PropGetter = (props) => { + return { + ...props, + "data-slot": "helper-wrapper", + className: dateInputSlots.helperWrapper({ + class: clsx(classNames?.helperWrapper, props?.className), + }), + }; + }; + + const getErrorMessageProps: PropGetter = (props = {}) => { + return { + ...props, + ...errorMessageProps, + "data-slot": "error-message", + className: dateInputSlots.errorMessage({ + class: clsx(classNames?.errorMessage, props?.className), + }), + }; + }; + + const getDescriptionProps: PropGetter = (props = {}) => { + return { + ...props, + ...descriptionProps, + "data-slot": "description", + className: dateInputSlots.description({ + class: clsx(classNames?.description, props?.className), + }), + }; + }; + + const getDateInputGroupProps = () => { + return { + as, + label, + description, + endContent, + errorMessage, + isInvalid, + startContent, + validationDetails, + validationErrors, + shouldLabelBeOutside, + "data-slot": "base", + "data-required": dataAttr(originalProps.isRequired), + "data-disabled": dataAttr(originalProps.isDisabled), + "data-readonly": dataAttr(originalProps.isReadOnly), + "data-invalid": dataAttr(isInvalid), + "data-has-start-content": dataAttr(!!startContent), + "data-has-multiple-months": dataAttr(hasMultipleMonths), + "data-has-end-content": dataAttr(!!endContent), + descriptionProps: getDescriptionProps(), + errorMessageProps: getErrorMessageProps(), + groupProps: getInputWrapperProps(), + helperWrapperProps: getHelperWrapperProps(), + labelProps: getLabelProps(), + wrapperProps: getInnerWrapperProps(), + className: dateInputSlots.base({class: baseStyles}), + } as DateInputGroupProps; + }; + + return { + state, + label, + slots, + classNames, + endContent, + selectorIcon, + showTimeField, + isCalendarHeaderExpanded, + disableAnimation, + CalendarTopContent, + CalendarBottomContent, + getStartDateInputProps, + getEndDateInputProps, + getStartTimeInputProps, + getEndTimeInputProps, + getPopoverProps, + getSelectorButtonProps, + getCalendarProps, + getSeparatorProps, + getSelectorIconProps, + getDateInputGroupProps, + }; +} + +export type UseDateRangePickerReturn = ReturnType; diff --git a/packages/components/date-picker/stories/date-picker.stories.tsx b/packages/components/date-picker/stories/date-picker.stories.tsx index 0118eabc8e..0db7446cd8 100644 --- a/packages/components/date-picker/stories/date-picker.stories.tsx +++ b/packages/components/date-picker/stories/date-picker.stories.tsx @@ -1,6 +1,6 @@ import React from "react"; import {Meta} from "@storybook/react"; -import {datePicker, dateInput} from "@nextui-org/theme"; +import {dateInput} from "@nextui-org/theme"; import { DateValue, getLocalTimeZone, @@ -73,7 +73,6 @@ const defaultProps = { label: "Birth Date", className: "max-w-[256px]", ...dateInput.defaultVariants, - ...datePicker.defaultVariants, }; const Template = (args: DatePickerProps) => ; diff --git a/packages/components/date-picker/stories/date-range-picker.stories.tsx b/packages/components/date-picker/stories/date-range-picker.stories.tsx new file mode 100644 index 0000000000..2dbaa2217a --- /dev/null +++ b/packages/components/date-picker/stories/date-range-picker.stories.tsx @@ -0,0 +1,581 @@ +import React from "react"; +import {Meta} from "@storybook/react"; +import {dateInput} from "@nextui-org/theme"; +import { + endOfMonth, + endOfWeek, + getLocalTimeZone, + isWeekend, + parseAbsoluteToLocal, + parseDate, + parseZonedDateTime, + startOfMonth, + startOfWeek, + today, +} from "@internationalized/date"; +import {RangeValue} from "@react-types/shared"; +import {DateValue} from "@react-types/datepicker"; +import {I18nProvider, useDateFormatter, 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 {DateRangePicker, DateRangePickerProps} from "../src"; + +export default { + title: "Components/DateRangePicker", + component: DateRangePicker, + argTypes: { + variant: { + control: { + type: "select", + }, + options: ["flat", "faded", "bordered", "underlined"], + }, + color: { + control: { + type: "select", + }, + options: ["default", "primary", "secondary", "success", "warning", "danger"], + }, + radius: { + control: { + type: "select", + }, + options: ["none", "sm", "md", "lg", "full"], + }, + size: { + control: { + type: "select", + }, + options: ["sm", "md", "lg"], + }, + labelPlacement: { + control: { + type: "select", + }, + options: ["inside", "outside", "outside-left"], + }, + isDisabled: { + control: { + type: "boolean", + }, + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} as Meta; + +const defaultProps = { + label: "Stay duration", + ...dateInput.defaultVariants, +}; + +const Template = (args: DateRangePickerProps) => ; + +const LabelPlacementTemplate = (args: DateRangePickerProps) => ( +
+ + + +
+); + +const ControlledTemplate = (args: DateRangePickerProps) => { + const [value, setValue] = React.useState>({ + start: parseDate("2024-04-01"), + end: parseDate("2024-04-08"), + }); + + let formatter = useDateFormatter({dateStyle: "long"}); + + return ( +
+
+ +

+ Selected date:{" "} + {value + ? formatter.formatRange( + value.start.toDate(getLocalTimeZone()), + value.end.toDate(getLocalTimeZone()), + ) + : "--"} +

+
+ +
+ ); +}; + +const TimeZonesTemplate = (args: DateRangePickerProps) => ( +
+ + +
+); + +const GranularityTemplate = (args: DateRangePickerProps) => { + let [date, setDate] = React.useState>({ + start: parseAbsoluteToLocal("2024-04-01T18:45:22Z"), + end: parseAbsoluteToLocal("2024-04-08T19:15:22Z"), + }); + + return ( +
+ + +
+ ); +}; + +const InternationalCalendarsTemplate = (args: DateRangePickerProps) => { + let [date, setDate] = React.useState>({ + start: parseAbsoluteToLocal("2021-04-01T18:45:22Z"), + end: parseAbsoluteToLocal("2021-04-14T19:15:22Z"), + }); + + return ( +
+ + + +
+ ); +}; + +const UnavailableDatesTemplate = (args: DateRangePickerProps) => { + 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})], + ]; + + return ( + + disabledRanges.some( + (interval) => date.compare(interval[0]) >= 0 && date.compare(interval[1]) <= 0, + ) + } + minValue={today(getLocalTimeZone())} + validate={(value) => + disabledRanges.some( + (interval) => + value && value.end.compare(interval[0]) >= 0 && value.start.compare(interval[1]) <= 0, + ) + ? "Selected date range may not include unavailable dates." + : null + } + validationBehavior="native" + {...args} + /> + ); +}; + +const NonContiguousRangesTemplate = (args: DateRangePickerProps) => { + let {locale} = useLocale(); + + return ( + isWeekend(date, locale)} + label="Time off request" + minValue={today(getLocalTimeZone())} + visibleMonths={2} + /> + ); +}; + +const PresetsTemplate = (args: DateRangePickerProps) => { + let defaultDate = { + start: today(getLocalTimeZone()), + end: today(getLocalTimeZone()).add({days: 7}), + }; + + const [value, setValue] = React.useState>(defaultDate); + + let {locale} = useLocale(); + let formatter = useDateFormatter({dateStyle: "full"}); + + let now = today(getLocalTimeZone()); + let nextWeek = { + start: startOfWeek(now.add({weeks: 1}), locale), + end: endOfWeek(now.add({weeks: 1}), locale), + }; + let nextMonth = { + start: startOfMonth(now.add({months: 1})), + end: endOfMonth(now.add({months: 1})), + }; + + const CustomRadio = (props) => { + const {children, ...otherProps} = props; + + return ( + + {children} + + ); + }; + + return ( +
+ + Exact dates + 1 day + 2 days + 3 days + 7 days + 14 days + + } + CalendarTopContent={ + + + + + + } + calendarProps={{ + focusedValue: value.start, + onFocusChange: (val) => setValue({...value, start: val}), + nextButtonProps: { + variant: "bordered", + }, + prevButtonProps: { + variant: "bordered", + }, + }} + value={value} + onChange={setValue} + {...args} + label="Event date" + /> +

+ Selected date:{" "} + {value + ? formatter.formatRange( + value.start.toDate(getLocalTimeZone()), + value.end.toDate(getLocalTimeZone()), + ) + : "--"} +

+
+ ); +}; + +export const Default = { + render: Template, + args: { + ...defaultProps, + }, +}; + +export const VisibleMonths = { + render: Template, + args: { + ...defaultProps, + visibleMonths: 2, + }, +}; + +export const LabelPlacement = { + render: LabelPlacementTemplate, + + args: { + ...defaultProps, + }, +}; + +export const WithTimeField = { + render: Template, + args: { + ...defaultProps, + label: "Event duration", + hideTimeZone: true, + visibleMonths: 2, + defaultValue: { + start: parseZonedDateTime("2024-04-01T00:45[America/Los_Angeles]"), + end: parseZonedDateTime("2024-04-08T11:15[America/Los_Angeles]"), + }, + }, +}; + +export const Controlled = { + render: ControlledTemplate, + args: { + ...defaultProps, + }, +}; + +export const Required = { + render: Template, + args: { + ...defaultProps, + isRequired: true, + }, +}; + +export const Disabled = { + render: Template, + args: { + ...defaultProps, + isDisabled: true, + defaultValue: { + start: parseDate("2024-04-01"), + end: parseDate("2024-04-08"), + }, + }, +}; + +export const ReadOnly = { + render: Template, + args: { + ...defaultProps, + isReadOnly: true, + defaultValue: { + start: parseDate("2024-04-01"), + end: parseDate("2024-04-08"), + }, + }, +}; + +export const WithoutLabel = { + render: Template, + + args: { + ...defaultProps, + label: null, + "aria-label": "Stay duration", + }, +}; + +export const WithDescription = { + render: Template, + + args: { + ...defaultProps, + description: "Please enter your stay duration", + }, +}; + +export const SelectorIcon = { + render: Template, + + args: { + ...defaultProps, + selectorIcon: ( + + + + + + + + ), + }, +}; + +export const WithErrorMessage = { + render: Template, + + args: { + ...defaultProps, + errorMessage: "Please enter your stay duration", + }, +}; + +export const IsInvalid = { + render: Template, + + args: { + ...defaultProps, + variant: "bordered", + isInvalid: true, + defaultValue: { + start: parseDate("2024-04-01"), + end: parseDate("2024-04-08"), + }, + errorMessage: "Please enter a valid date", + }, +}; + +export const TimeZones = { + render: TimeZonesTemplate, + + args: { + ...defaultProps, + label: "Event date", + defaultValue: parseZonedDateTime("2022-11-07T00:45[America/Los_Angeles]"), + }, +}; + +export const Granularity = { + render: GranularityTemplate, + + args: { + ...defaultProps, + visibleMonths: 2, + }, +}; + +export const InternationalCalendars = { + render: InternationalCalendarsTemplate, + + args: { + ...defaultProps, + hideTimeZone: true, + }, +}; + +export const MinDateValue = { + render: Template, + + args: { + ...defaultProps, + minValue: today(getLocalTimeZone()), + defaultValue: { + start: today(getLocalTimeZone()).subtract({days: 1}), + end: parseDate("2024-04-08"), + }, + }, +}; + +export const MaxDateValue = { + render: Template, + + args: { + ...defaultProps, + maxValue: today(getLocalTimeZone()), + defaultValue: { + start: parseDate("2024-04-01"), + end: today(getLocalTimeZone()).add({days: 1}), + }, + }, +}; + +export const UnavailableDates = { + render: UnavailableDatesTemplate, + args: { + ...defaultProps, + }, +}; + +export const PageBehavior = { + render: Template, + args: { + ...defaultProps, + visibleMonths: 2, + pageBehavior: "single", + }, +}; + +export const NonContiguous = { + render: NonContiguousRangesTemplate, + args: { + ...defaultProps, + }, +}; + +export const Presets = { + render: PresetsTemplate, + args: { + ...defaultProps, + visibleMonths: 2, + }, +}; diff --git a/packages/core/theme/src/components/date-input.ts b/packages/core/theme/src/components/date-input.ts index ed70100f01..bbff942348 100644 --- a/packages/core/theme/src/components/date-input.ts +++ b/packages/core/theme/src/components/date-input.ts @@ -28,7 +28,7 @@ const dateInput = tv({ "group-data-[invalid=true]:text-danger", ], // this wraps the input and the start/end content segment: [ - "group -ml-0.5 px-0.5 my-auto box-content tabular-nums text-start", + "group first:-ml-0.5 [&:not(:first-child)]:-ml-1 px-0.5 my-auto box-content tabular-nums text-start", "inline-block outline-none focus:shadow-sm rounded-md", "text-foreground-500 data-[editable=true]:text-foreground", "data-[editable=true]:data-[placeholder=true]:text-foreground-500", diff --git a/packages/core/theme/src/components/date-picker.ts b/packages/core/theme/src/components/date-picker.ts index bd00949cb5..5e0ae8dc82 100644 --- a/packages/core/theme/src/components/date-picker.ts +++ b/packages/core/theme/src/components/date-picker.ts @@ -33,8 +33,24 @@ const datePicker = tv({ }, }); +const dateRangePicker = tv({ + extend: datePicker, + slots: { + calendar: "group", + bottomContent: "flex flex-col gap-y-2", + timeInputWrapper: "flex flex-col group-data-[has-multiple-months=true]:flex-row", + separator: "-mx-1 text-inherit", + }, +}); + +/** Base */ export type DatePickerReturnType = ReturnType; export type DatePickerVariantProps = VariantProps; export type DatePickerSlots = keyof ReturnType; -export {datePicker}; +/** Range */ +export type DateRangePickerReturnType = ReturnType; +export type DateRangePickerVariantProps = VariantProps; +export type DateRangePickerSlots = keyof ReturnType; + +export {datePicker, dateRangePicker};