diff --git a/packages/components/autocomplete/src/autocomplete.tsx b/packages/components/autocomplete/src/autocomplete.tsx index ac2aa0743e..b60dc6b7b4 100644 --- a/packages/components/autocomplete/src/autocomplete.tsx +++ b/packages/components/autocomplete/src/autocomplete.tsx @@ -32,12 +32,7 @@ function Autocomplete(props: Props, ref: ForwardedRef({...props, ref}); const popoverContent = isOpen ? ( - false} - state={state} - > + diff --git a/packages/components/calendar/src/calendar-base.tsx b/packages/components/calendar/src/calendar-base.tsx index cb2e7fd1bb..0d5def77b1 100644 --- a/packages/components/calendar/src/calendar-base.tsx +++ b/packages/components/calendar/src/calendar-base.tsx @@ -22,6 +22,7 @@ import {useCalendarContext} from "./calendar-context"; export interface CalendarBaseProps extends HTMLNextUIProps<"div"> { Component?: As; + showHelper?: boolean; topContent?: ReactNode; bottomContent?: ReactNode; calendarProps: HTMLAttributes; @@ -36,6 +37,7 @@ export interface CalendarBaseProps extends HTMLNextUIProps<"div"> { export function CalendarBase(props: CalendarBaseProps) { const { Component = "div", + showHelper, topContent, bottomContent, calendarProps, @@ -143,9 +145,14 @@ export function CalendarBase(props: CalendarBaseProps) {

{calendarProps["aria-label"]}

{disableAnimation ? ( - calendarContent +
+ {calendarContent} +
) : ( - + <> @@ -166,7 +173,7 @@ export function CalendarBase(props: CalendarBaseProps) { onClick={() => state.focusNextPage()} /> - {state.isValueInvalid && ( + {state.isValueInvalid && showHelper && (
extends Omit, "isHeaderWrapperExpanded"> {} function Calendar(props: Props, ref: ForwardedRef) { - const {context, getBaseCalendarProps} = useCalendar({...props, ref}); + const {context, getBaseCalendarProps} = useCalendar({...props, ref}); return ( diff --git a/packages/components/calendar/src/range-calendar.tsx b/packages/components/calendar/src/range-calendar.tsx index 59d2c6d499..a245f012dc 100644 --- a/packages/components/calendar/src/range-calendar.tsx +++ b/packages/components/calendar/src/range-calendar.tsx @@ -17,7 +17,7 @@ interface Props > {} function RangeCalendar(props: Props, ref: ForwardedRef) { - const {context, getBaseCalendarProps} = useRangeCalendar({...props, ref}); + const {context, getBaseCalendarProps} = useRangeCalendar({...props, ref}); return ( diff --git a/packages/components/calendar/src/use-calendar-base.ts b/packages/components/calendar/src/use-calendar-base.ts index 46f7cb8194..8f63509565 100644 --- a/packages/components/calendar/src/use-calendar-base.ts +++ b/packages/components/calendar/src/use-calendar-base.ts @@ -54,6 +54,11 @@ interface Props extends NextUIBaseProps { * Props for the next button. */ nextButtonProps?: ButtonProps; + /** + * Whether to show the description or error message. + * @default true + */ + showHelper?: boolean; /** * Whether the calendar header is expanded. This is only available if the `showMonthAndYearPickers` prop is set to `true`. * @default false @@ -126,6 +131,7 @@ interface Props extends NextUIBaseProps { * prevButton:"prev-button-classes", * header:"header-classes", * title:"title-classes", + * content:"content-classes", * gridWrapper:"grid-wrapper-classes", * grid:"grid-classes", * gridHeader:"grid-header-classes", @@ -178,6 +184,7 @@ export function useCalendarBase(originalProps: UseCalendarBasePropsComplete) { className, topContent, bottomContent, + showHelper = true, visibleMonths: visibleMonthsProp = 1, weekdayStyle = "narrow", navButtonProps = {}, @@ -283,6 +290,7 @@ export function useCalendarBase(originalProps: UseCalendarBasePropsComplete) { locale, minValue, maxValue, + showHelper, weekdayStyle, visibleMonths, visibleDuration, diff --git a/packages/components/calendar/src/use-calendar-picker.ts b/packages/components/calendar/src/use-calendar-picker.ts index 7877381f49..fd93535779 100644 --- a/packages/components/calendar/src/use-calendar-picker.ts +++ b/packages/components/calendar/src/use-calendar-picker.ts @@ -209,6 +209,8 @@ export function useCalendarPicker(props: CalendarPickerProps) { nextValue = value + 3; break; case "Escape": + case "Enter": + case " ": setIsHeaderExpanded?.(false); headerRef?.current?.focus(); diff --git a/packages/components/calendar/src/use-calendar.ts b/packages/components/calendar/src/use-calendar.ts index 442e334e05..7f99573b67 100644 --- a/packages/components/calendar/src/use-calendar.ts +++ b/packages/components/calendar/src/use-calendar.ts @@ -35,6 +35,7 @@ export function useCalendar({ locale, minValue, maxValue, + showHelper, weekdayStyle, visibleDuration, shouldFilterDOMProps, @@ -81,6 +82,7 @@ export function useCalendar({ const getBaseCalendarProps = (props = {}): CalendarBaseProps => { return { Component, + showHelper, topContent, bottomContent, buttonPickerProps, diff --git a/packages/components/calendar/src/use-range-calendar.ts b/packages/components/calendar/src/use-range-calendar.ts index 8d42035fec..ba2cabab6b 100644 --- a/packages/components/calendar/src/use-range-calendar.ts +++ b/packages/components/calendar/src/use-range-calendar.ts @@ -31,6 +31,7 @@ export function useRangeCalendar({ children, domRef, locale, + showHelper, minValue, maxValue, weekdayStyle, @@ -73,6 +74,7 @@ export function useRangeCalendar({ const getBaseCalendarProps = (props = {}): CalendarBaseProps => { return { Component, + showHelper, topContent, bottomContent, calendarRef: domRef, diff --git a/packages/components/calendar/stories/calendar.stories.tsx b/packages/components/calendar/stories/calendar.stories.tsx index 3bc124c093..e9a70b1c56 100644 --- a/packages/components/calendar/stories/calendar.stories.tsx +++ b/packages/components/calendar/stories/calendar.stories.tsx @@ -205,6 +205,9 @@ const PresetsTemplate = (args: CalendarProps) => { 14 days } + classNames={{ + content: "w-full", + }} focusedValue={value} nextButtonProps={{ variant: "bordered", diff --git a/packages/components/date-input/__tests__/date-input.test.tsx b/packages/components/date-input/__tests__/date-input.test.tsx index 9208cdccd5..aa48657e87 100644 --- a/packages/components/date-input/__tests__/date-input.test.tsx +++ b/packages/components/date-input/__tests__/date-input.test.tsx @@ -1,11 +1,20 @@ /* eslint-disable jsx-a11y/no-autofocus */ import * as React from "react"; import {act, fireEvent, render} from "@testing-library/react"; -import {CalendarDate, CalendarDateTime, ZonedDateTime} from "@internationalized/date"; +import {CalendarDate, CalendarDateTime, DateValue, ZonedDateTime} from "@internationalized/date"; import {pointerMap, triggerPress} from "@nextui-org/test-utils"; import userEvent from "@testing-library/user-event"; -import {DateInput} from "../src"; +import {DateInput as DateInputBase, DateInputProps} from "../src"; + +/** + * Custom date-input to disable animations and avoid issues with react-motion and jest + */ +const DateInput = React.forwardRef((props: DateInputProps, ref: React.Ref) => { + return ; +}); + +DateInput.displayName = "DateInput"; describe("DateInput", () => { let user; @@ -57,12 +66,12 @@ describe("DateInput", () => { />, ); - await act(() => { - user.tab(); + await act(async () => { + await user.tab(); }); - await act(() => { - user.keyboard("01011980"); + await act(async () => { + await user.keyboard("01011980"); }); expect(tree.getByText("Date unavailable.")).toBeInTheDocument(); @@ -197,15 +206,17 @@ describe("DateInput", () => { expect(onBlurSpy).not.toHaveBeenCalled(); expect(onFocusChangeSpy).not.toHaveBeenCalled(); expect(onFocusSpy).not.toHaveBeenCalled(); - - await user.tab(); + await act(async () => { + await user.tab(); + }); expect(segments[0]).toHaveFocus(); expect(onBlurSpy).not.toHaveBeenCalled(); expect(onFocusChangeSpy).toHaveBeenCalledTimes(1); expect(onFocusSpy).toHaveBeenCalledTimes(1); - - await user.tab(); + await act(async () => { + await user.tab(); + }); expect(segments[1]).toHaveFocus(); expect(onBlurSpy).not.toHaveBeenCalled(); expect(onFocusChangeSpy).toHaveBeenCalledTimes(1); @@ -226,18 +237,22 @@ describe("DateInput", () => { expect(onBlurSpy).not.toHaveBeenCalled(); expect(onFocusChangeSpy).not.toHaveBeenCalled(); expect(onFocusSpy).not.toHaveBeenCalled(); - - await user.tab(); + await act(async () => { + await user.tab(); + }); expect(segments[0]).toHaveFocus(); - - await user.tab(); + await act(async () => { + await user.tab(); + }); expect(segments[1]).toHaveFocus(); - - await user.tab(); + await act(async () => { + await user.tab(); + }); expect(segments[2]).toHaveFocus(); expect(onBlurSpy).toHaveBeenCalledTimes(0); - - await user.tab(); + await act(async () => { + await user.tab(); + }); expect(onBlurSpy).toHaveBeenCalledTimes(1); expect(onFocusChangeSpy).toHaveBeenCalledTimes(2); expect(onFocusSpy).toHaveBeenCalledTimes(1); @@ -296,7 +311,7 @@ describe("DateInput", () => { it("supports form reset", async () => { function Test() { - let [value, setValue] = React.useState(new CalendarDate(2020, 2, 3)); + let [value, setValue] = React.useState(new CalendarDate(2020, 2, 3)); return (
diff --git a/packages/components/date-input/src/date-input.tsx b/packages/components/date-input/src/date-input.tsx index f1da3e9d66..75ad7e497d 100644 --- a/packages/components/date-input/src/date-input.tsx +++ b/packages/components/date-input/src/date-input.tsx @@ -28,15 +28,16 @@ function DateInput(props: Props, ref: ForwardedRef({ ...props, ref, }); - const labelContent = label ? : null; + const labelContent = label ? {label} : null; const helperWrapper = useMemo(() => { if (!hasHelper) return null; @@ -81,10 +82,12 @@ function DateInput(props: Props, ref: ForwardedRef {shouldLabelBeOutside ? labelContent : null}
- {startContent} {!shouldLabelBeOutside ? labelContent : null} - {inputContent} - {endContent} +
+ {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 daa988044c..08d0a19a31 100644 --- a/packages/components/date-input/src/use-date-input.ts +++ b/packages/components/date-input/src/use-date-input.ts @@ -3,12 +3,14 @@ import type {AriaDatePickerProps} from "@react-types/datepicker"; 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 {PropGetter, useProviderContext} from "@nextui-org/system"; +import {useLocale} from "@react-aria/i18n"; import {CalendarDate} from "@internationalized/date"; +import {mergeProps} from "@react-aria/utils"; +import {PropGetter, useProviderContext} from "@nextui-org/system"; import {HTMLNextUIProps, mapPropsVariants} from "@nextui-org/system"; -import {useDOMRef, filterDOMProps} from "@nextui-org/react-utils"; -import {useLocale} from "@react-aria/i18n"; +import {useDOMRef} from "@nextui-org/react-utils"; import {useDateField as useAriaDateField} from "@react-aria/datepicker"; import {useDateFieldState} from "@react-stately/datepicker"; import {createCalendar} from "@internationalized/date"; @@ -26,6 +28,16 @@ interface Props extends NextUIBaseProps { * Ref to the DOM node. */ ref?: ReactRef; + /** Props for the grouping element containing the date field and button. */ + groupProps?: GroupDOMAttributes; + /** Props for the date picker's visible label element, if any. */ + labelProps?: DOMAttributes; + /** Props for the date field. */ + fieldProps?: DOMAttributes; + /** Props for the description element, if any. */ + descriptionProps?: DOMAttributes; + /** Props for the error message element, if any. */ + errorMessageProps?: DOMAttributes; /** * The value of the hidden input. */ @@ -106,6 +118,7 @@ export function useDateInput(originalProps: UseDateInputPro const { ref, as, + label, inputRef: inputRefProp, description, startContent, @@ -113,6 +126,11 @@ export function useDateInput(originalProps: UseDateInputPro className, classNames, validationState, + groupProps = {}, + labelProps: labelPropsProp, + fieldProps: fieldPropsProp, + errorMessageProps: errorMessagePropsProp, + descriptionProps: descriptionPropsProp, validationBehavior = "native", shouldForceLeadingZeros = true, minValue = providerContext?.defaultDates?.minDate ?? new CalendarDate(1900, 1, 1), @@ -120,18 +138,17 @@ export function useDateInput(originalProps: UseDateInputPro createCalendar: createCalendarProp = providerContext?.createCalendar ?? null, isInvalid: isInvalidProp = validationState ? validationState === "invalid" : false, errorMessage: errorMessageProp, - ...otherProps } = props; const domRef = useDOMRef(ref); const inputRef = useDOMRef(inputRefProp); const Component = as || "div"; - const shouldFilterDOMProps = typeof Component === "string"; const {locale} = useLocale(); const state = useDateFieldState({ ...originalProps, + label, locale, minValue, maxValue, @@ -152,7 +169,7 @@ export function useDateInput(originalProps: UseDateInputPro descriptionProps, errorMessageProps, isInvalid: ariaIsInvalid, - } = useAriaDateField({...originalProps, validationBehavior, inputRef}, state, domRef); + } = useAriaDateField({...originalProps, label, validationBehavior, inputRef}, state, domRef); const baseStyles = clsx(classNames?.base, className); @@ -193,15 +210,6 @@ export function useDateInput(originalProps: UseDateInputPro ); const getBaseProps: PropGetter = () => { - // filter other props that are included in fieldProps to avoid duplication - const filteredUserProps = Object.keys(otherProps).reduce((acc, key) => { - if (!fieldProps[key as keyof typeof fieldProps]) { - acc[key] = otherProps[key as keyof typeof otherProps]; - } - - return acc; - }, {} as Record); - return { "data-slot": "base", "data-has-helper": dataAttr(hasHelper), @@ -212,16 +220,12 @@ export function useDateInput(originalProps: UseDateInputPro "data-has-start-content": dataAttr(!!startContent), "data-has-end-content": dataAttr(!!endContent), className: slots.base({class: baseStyles}), - ...filterDOMProps(filteredUserProps, { - enabled: shouldFilterDOMProps, - }), }; }; const getLabelProps: PropGetter = (props) => { return { - ...props, - ...labelProps, + ...mergeProps(labelProps, labelPropsProp, props), "data-slot": "label", className: slots.label({ class: clsx(classNames?.label, props?.className), @@ -239,10 +243,9 @@ export function useDateInput(originalProps: UseDateInputPro const getFieldProps: PropGetter = (props) => { return { - ...props, - ...fieldProps, ref: domRef, "data-slot": "input", + ...mergeProps(fieldProps, fieldPropsProp, props), className: slots.input({ class: clsx(classNames?.input, props?.className), }), @@ -252,6 +255,7 @@ export function useDateInput(originalProps: UseDateInputPro const getInputWrapperProps: PropGetter = (props) => { return { ...props, + ...groupProps, "data-slot": "input-wrapper", className: slots.inputWrapper({ class: classNames?.inputWrapper, @@ -260,6 +264,16 @@ export function useDateInput(originalProps: UseDateInputPro }; }; + const getInnerWrapperProps: PropGetter = (props) => { + return { + ...props, + "data-slot": "inner-wrapper", + className: slots.innerWrapper({ + class: classNames?.innerWrapper, + }), + }; + }; + const getHelperWrapperProps: PropGetter = (props) => { return { ...props, @@ -272,8 +286,7 @@ export function useDateInput(originalProps: UseDateInputPro const getErrorMessageProps: PropGetter = (props = {}) => { return { - ...props, - ...errorMessageProps, + ...mergeProps(errorMessageProps, errorMessagePropsProp, props), "data-slot": "error-message", className: slots.errorMessage({class: clsx(classNames?.errorMessage, props?.className)}), }; @@ -281,8 +294,7 @@ export function useDateInput(originalProps: UseDateInputPro const getDescriptionProps: PropGetter = (props = {}) => { return { - ...props, - ...descriptionProps, + ...mergeProps(descriptionProps, descriptionPropsProp, props), "data-slot": "description", className: slots.description({class: clsx(classNames?.description, props?.className)}), }; @@ -293,9 +305,9 @@ export function useDateInput(originalProps: UseDateInputPro state, domRef, slots, + label, hasHelper, shouldLabelBeOutside, - label: originalProps?.label, classNames, description, errorMessage, @@ -307,6 +319,7 @@ export function useDateInput(originalProps: UseDateInputPro 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 1b1f972f41..fc6081f75e 100644 --- a/packages/components/date-input/stories/date-input.stories.tsx +++ b/packages/components/date-input/stories/date-input.stories.tsx @@ -183,7 +183,7 @@ export const WithoutLabel = { args: { ...defaultProps, label: null, - "aria-label": "Birthday", + "aria-label": "Birth date", }, }; @@ -192,7 +192,7 @@ export const WithDescription = { args: { ...defaultProps, - description: "Please enter your birthday", + description: "Please enter your birth date", }, }; diff --git a/packages/components/date-picker/README.md b/packages/components/date-picker/README.md new file mode 100644 index 0000000000..1ad9b0f3fd --- /dev/null +++ b/packages/components/date-picker/README.md @@ -0,0 +1,22 @@ +# @nextui-org/date-picker + +A date picker combines a DateInput and a Calendar popover to allow users to enter or select a date and time value. + +## Installation + +```sh +yarn add @nextui-org/date-picker +# or +npm i @nextui-org/date-picker +``` + +## Contribution + +Yes please! See the +[contributing guidelines](https://github.com/nextui-org/nextui/blob/master/CONTRIBUTING.md) +for details. + +## License + +This project is licensed under the terms of the +[MIT license](https://github.com/nextui-org/nextui/blob/master/LICENSE). diff --git a/packages/components/date-picker/__tests__/date-picker.test.tsx b/packages/components/date-picker/__tests__/date-picker.test.tsx new file mode 100644 index 0000000000..9a8e39e247 --- /dev/null +++ b/packages/components/date-picker/__tests__/date-picker.test.tsx @@ -0,0 +1,447 @@ +/* eslint-disable jsx-a11y/no-autofocus */ +import * as React from "react"; +import {render, act, fireEvent, waitFor} from "@testing-library/react"; +import {pointerMap, triggerPress} from "@nextui-org/test-utils"; +import userEvent from "@testing-library/user-event"; +import {CalendarDate, CalendarDateTime} from "@internationalized/date"; + +import {DatePicker as DatePickerBase, DatePickerProps} from "../src"; + +/** + * Custom date-picker to disable animations and avoid issues with react-motion and jest + */ +const DatePicker = React.forwardRef((props: DatePickerProps, ref: React.Ref) => { + return ( + + ); +}); + +DatePicker.displayName = "DatePicker"; + +function getTextValue(el: any) { + if ( + el.className?.includes?.("DatePicker-placeholder") && + el.attributes?.getNamedItem("data-placeholder")?.value === "true" + ) { + return ""; + } + + return [...el.childNodes] + .map((el) => (el.nodeType === 3 ? el.textContent : getTextValue(el))) + .join(""); +} + +describe("DatePicker", () => { + let user; + + beforeAll(() => { + user = userEvent.setup({delay: null, pointerMap}); + 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 a datepicker with a specified date", function () { + let {getAllByRole} = render(); + + let combobox = getAllByRole("group")[0]; + + expect(combobox).toBeVisible(); + expect(combobox).not.toHaveAttribute("aria-disabled"); + expect(combobox).not.toHaveAttribute("aria-invalid"); + + let segments = getAllByRole("spinbutton"); + + expect(segments.length).toBe(3); + + expect(getTextValue(segments[0])).toBe("2"); + expect(segments[0].getAttribute("aria-label")).toBe("month, "); + expect(segments[0].getAttribute("aria-valuenow")).toBe("2"); + expect(segments[0].getAttribute("aria-valuetext")).toBe("2 – February"); + expect(segments[0].getAttribute("aria-valuemin")).toBe("1"); + expect(segments[0].getAttribute("aria-valuemax")).toBe("12"); + + expect(getTextValue(segments[1])).toBe("3"); + expect(segments[1].getAttribute("aria-label")).toBe("day, "); + expect(segments[1].getAttribute("aria-valuenow")).toBe("3"); + expect(segments[1].getAttribute("aria-valuetext")).toBe("3"); + expect(segments[1].getAttribute("aria-valuemin")).toBe("1"); + expect(segments[1].getAttribute("aria-valuemax")).toBe("28"); + + expect(getTextValue(segments[2])).toBe("2019"); + expect(segments[2].getAttribute("aria-label")).toBe("year, "); + expect(segments[2].getAttribute("aria-valuenow")).toBe("2019"); + expect(segments[2].getAttribute("aria-valuetext")).toBe("2019"); + expect(segments[2].getAttribute("aria-valuemin")).toBe("1"); + expect(segments[2].getAttribute("aria-valuemax")).toBe("9999"); + }); + + it('should render a datepicker with granularity="second"', function () { + let {getAllByRole} = render( + , + ); + + let combobox = getAllByRole("group")[0]; + + expect(combobox).toBeVisible(); + expect(combobox).not.toHaveAttribute("aria-disabled"); + expect(combobox).not.toHaveAttribute("aria-invalid"); + + let segments = getAllByRole("spinbutton"); + + expect(segments.length).toBe(7); + + expect(getTextValue(segments[0])).toBe("2"); + expect(segments[0].getAttribute("aria-label")).toBe("month, "); + expect(segments[0].getAttribute("aria-valuenow")).toBe("2"); + expect(segments[0].getAttribute("aria-valuetext")).toBe("2 – February"); + expect(segments[0].getAttribute("aria-valuemin")).toBe("1"); + expect(segments[0].getAttribute("aria-valuemax")).toBe("12"); + + expect(getTextValue(segments[1])).toBe("3"); + expect(segments[1].getAttribute("aria-label")).toBe("day, "); + expect(segments[1].getAttribute("aria-valuenow")).toBe("3"); + expect(segments[1].getAttribute("aria-valuetext")).toBe("3"); + expect(segments[1].getAttribute("aria-valuemin")).toBe("1"); + expect(segments[1].getAttribute("aria-valuemax")).toBe("28"); + + expect(getTextValue(segments[2])).toBe("2019"); + expect(segments[2].getAttribute("aria-label")).toBe("year, "); + expect(segments[2].getAttribute("aria-valuenow")).toBe("2019"); + expect(segments[2].getAttribute("aria-valuetext")).toBe("2019"); + expect(segments[2].getAttribute("aria-valuemin")).toBe("1"); + expect(segments[2].getAttribute("aria-valuemax")).toBe("9999"); + + expect(getTextValue(segments[3])).toBe("12"); + expect(segments[3].getAttribute("aria-label")).toBe("hour, "); + expect(segments[3].getAttribute("aria-valuenow")).toBe("0"); + expect(segments[3].getAttribute("aria-valuetext")).toBe("12 AM"); + expect(segments[3].getAttribute("aria-valuemin")).toBe("0"); + expect(segments[3].getAttribute("aria-valuemax")).toBe("11"); + + expect(getTextValue(segments[4])).toBe("00"); + expect(segments[4].getAttribute("aria-label")).toBe("minute, "); + expect(segments[4].getAttribute("aria-valuenow")).toBe("0"); + expect(segments[4].getAttribute("aria-valuetext")).toBe("00"); + expect(segments[4].getAttribute("aria-valuemin")).toBe("0"); + expect(segments[4].getAttribute("aria-valuemax")).toBe("59"); + + expect(getTextValue(segments[5])).toBe("00"); + expect(segments[5].getAttribute("aria-label")).toBe("second, "); + expect(segments[5].getAttribute("aria-valuenow")).toBe("0"); + expect(segments[5].getAttribute("aria-valuetext")).toBe("00"); + expect(segments[5].getAttribute("aria-valuemin")).toBe("0"); + expect(segments[5].getAttribute("aria-valuemax")).toBe("59"); + + expect(getTextValue(segments[6])).toBe("AM"); + expect(segments[6].getAttribute("aria-label")).toBe("AM/PM, "); + expect(segments[6].getAttribute("aria-valuetext")).toBe("AM"); + }); + + it("should support autoFocus", function () { + let {getAllByRole} = render(); + + expect(document.activeElement).toBe(getAllByRole("spinbutton")[0]); + }); + + it("should pass through data attributes", function () { + let {getByTestId} = render(); + + expect(getByTestId("foo")).toHaveAttribute("role", "group"); + }); + }); + + describe("Events", () => { + let onBlurSpy = jest.fn(); + let onFocusChangeSpy = jest.fn(); + let onFocusSpy = jest.fn(); + let onKeyDownSpy = jest.fn(); + let onKeyUpSpy = jest.fn(); + + afterEach(() => { + onBlurSpy.mockClear(); + onFocusChangeSpy.mockClear(); + onFocusSpy.mockClear(); + onKeyDownSpy.mockClear(); + onKeyUpSpy.mockClear(); + }); + + it("should focus field, move a segment, and open popover and does not blur", async function () { + let {getByRole, getAllByRole} = render( + , + ); + let segments = getAllByRole("spinbutton"); + let button = getByRole("button"); + + expect(onBlurSpy).not.toHaveBeenCalled(); + expect(onFocusChangeSpy).not.toHaveBeenCalled(); + expect(onFocusSpy).not.toHaveBeenCalled(); + + await act(async () => { + await user.tab(); + }); + + expect(segments[0]).toHaveFocus(); + expect(onBlurSpy).not.toHaveBeenCalled(); + expect(onFocusChangeSpy).toHaveBeenCalledTimes(1); + expect(onFocusSpy).toHaveBeenCalledTimes(1); + + await act(async () => { + await user.tab(); + }); + + expect(segments[1]).toHaveFocus(); + expect(onBlurSpy).not.toHaveBeenCalled(); + expect(onFocusChangeSpy).toHaveBeenCalledTimes(1); + expect(onFocusSpy).toHaveBeenCalledTimes(1); + + triggerPress(button); + + act(() => jest.runAllTimers()); + + let dialog = getByRole("dialog"); + + expect(dialog).toBeVisible(); + }); + + it("should focus field and leave to blur", async function () { + let {getAllByRole} = render( + , + ); + let segments = getAllByRole("spinbutton"); + + expect(onBlurSpy).not.toHaveBeenCalled(); + expect(onFocusChangeSpy).not.toHaveBeenCalled(); + expect(onFocusSpy).not.toHaveBeenCalled(); + + await act(async () => { + await user.tab(); + }); + + expect(segments[0]).toHaveFocus(); + expect(onBlurSpy).not.toHaveBeenCalled(); + expect(onFocusChangeSpy).toHaveBeenCalledTimes(1); + expect(onFocusSpy).toHaveBeenCalledTimes(1); + + await act(() => { + user.click(document.body); + }); + + expect(document.body).toHaveFocus(); + expect(onBlurSpy).toHaveBeenCalledTimes(1); + expect(onFocusChangeSpy).toHaveBeenCalledTimes(2); + expect(onFocusSpy).toHaveBeenCalledTimes(1); + }); + + it("should open popover and call picker onFocus", function () { + let {getByRole} = render( + , + ); + + let button = getByRole("button"); + + expect(onBlurSpy).not.toHaveBeenCalled(); + expect(onFocusChangeSpy).not.toHaveBeenCalled(); + expect(onFocusSpy).not.toHaveBeenCalled(); + + triggerPress(button); + + act(() => jest.runAllTimers()); + + let dialog = getByRole("dialog"); + + expect(dialog).toBeVisible(); + expect(onBlurSpy).not.toHaveBeenCalled(); + }); + + it("should open and close popover and only call blur when focus leaves picker", async function () { + let {getByRole} = render( + , + ); + let button = getByRole("button"); + + expect(onBlurSpy).not.toHaveBeenCalled(); + expect(onFocusChangeSpy).not.toHaveBeenCalled(); + expect(onFocusSpy).not.toHaveBeenCalled(); + + triggerPress(button); + act(() => jest.runAllTimers()); + + let dialog = getByRole("dialog"); + + expect(dialog).toBeVisible(); + + //@ts-ignore + fireEvent.keyDown(document.activeElement, {key: "Escape"}); + //@ts-ignore + fireEvent.keyUp(document.activeElement, {key: "Escape"}); + + act(() => jest.runAllTimers()); + + await waitFor(() => { + expect(dialog).not.toBeInTheDocument(); + }); // wait for animation + + // now that it's been unmounted, run the raf callback + act(() => { + jest.runAllTimers(); + }); + + expect(dialog).not.toBeInTheDocument(); + expect(document.activeElement).toBe(button); + expect(button).toHaveFocus(); + + await act(async () => { + await user.tab(); + }); + + expect(document.body).toHaveFocus(); + }); + + it("should trigger right arrow key event for segment navigation", async function () { + let {getAllByRole} = render( + , + ); + let segments = getAllByRole("spinbutton"); + + expect(onKeyDownSpy).not.toHaveBeenCalled(); + expect(onKeyUpSpy).not.toHaveBeenCalled(); + + await act(async () => { + await user.tab(); + }); + + expect(segments[0]).toHaveFocus(); + expect(onKeyDownSpy).not.toHaveBeenCalled(); + expect(onKeyUpSpy).toHaveBeenCalledTimes(1); + + // @ts-ignore + fireEvent.keyDown(document.activeElement, {key: "ArrowRight"}); + // @ts-ignore + fireEvent.keyUp(document.activeElement, {key: "ArrowRight"}); + + expect(segments[1]).toHaveFocus(); + expect(onKeyDownSpy).toHaveBeenCalledTimes(1); + expect(onKeyUpSpy).toHaveBeenCalledTimes(2); + }); + }); + + describe("Calendar popover", function () { + it("should emit onChange when selecting a date in the calendar in controlled mode", function () { + let onChange = jest.fn(); + let {getByRole, getAllByRole, queryByLabelText} = render( + , + ); + + let combobox = getAllByRole("group")[0]; + + expect(getTextValue(combobox)).toBe("2/3/2019"); + + let button = getByRole("button"); + + triggerPress(button); + + let dialog = getByRole("dialog"); + + expect(dialog).toBeVisible(); + + expect(queryByLabelText("Time")).toBeNull(); + + let cells = getAllByRole("gridcell"); + let selected = cells.find((cell) => cell.getAttribute("aria-selected") === "true"); + + // @ts-ignore + expect(selected.children[0]).toHaveAttribute( + "aria-label", + "Sunday, February 3, 2019 selected", + ); + + // @ts-ignore + triggerPress(selected.nextSibling.children[0]); + + expect(dialog).not.toBeInTheDocument(); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(new CalendarDate(2019, 2, 4)); + expect(getTextValue(combobox)).toBe("2/3/2019"); // controlled + }); + + it("should emit onChange when selecting a date in the calendar in uncontrolled mode", function () { + let onChange = jest.fn(); + let {getByRole, getAllByRole} = render( + , + ); + + let combobox = getAllByRole("group")[0]; + + expect(getTextValue(combobox)).toBe("2/3/2019"); + + let button = getByRole("button"); + + triggerPress(button); + + let dialog = getByRole("dialog"); + + expect(dialog).toBeVisible(); + + let cells = getAllByRole("gridcell"); + let selected = cells.find((cell) => cell.getAttribute("aria-selected") === "true"); + + // @ts-ignore + expect(selected.children[0]).toHaveAttribute( + "aria-label", + "Sunday, February 3, 2019 selected", + ); + + // @ts-ignore + triggerPress(selected.nextSibling.children[0]); + + expect(dialog).not.toBeInTheDocument(); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(new CalendarDate(2019, 2, 4)); + expect(getTextValue(combobox)).toBe("2/4/2019"); // uncontrolled + }); + }); +}); diff --git a/packages/components/date-picker/package.json b/packages/components/date-picker/package.json new file mode 100644 index 0000000000..ca8e7dc915 --- /dev/null +++ b/packages/components/date-picker/package.json @@ -0,0 +1,70 @@ +{ + "name": "@nextui-org/date-picker", + "version": "2.0.0", + "description": "A date picker combines a DateInput and a Calendar popover to allow users to enter or select a date and time value.", + "keywords": [ + "date-picker" + ], + "author": "Junior Garcia ", + "homepage": "https://nextui.org", + "license": "MIT", + "main": "src/index.ts", + "sideEffects": false, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/nextui-org/nextui.git", + "directory": "packages/components/date-picker" + }, + "bugs": { + "url": "https://github.com/nextui-org/nextui/issues" + }, + "scripts": { + "build": "tsup src --dts", + "build:fast": "tsup src", + "dev": "pnpm build:fast --watch", + "clean": "rimraf dist .turbo", + "typecheck": "tsc --noEmit", + "prepack": "clean-package", + "postpack": "clean-package restore" + }, + "peerDependencies": { + "@nextui-org/system": ">=2.0.0", + "@nextui-org/theme": ">=2.0.0", + "react": ">=18", + "react-dom": ">=18" + }, + "dependencies": { + "@nextui-org/react-utils": "workspace:*", + "@nextui-org/shared-utils": "workspace:*", + "@nextui-org/popover": "workspace:*", + "@nextui-org/calendar": "workspace:*", + "@nextui-org/button": "workspace:*", + "@nextui-org/date-input": "workspace:*", + "@nextui-org/shared-icons": "workspace:*", + "@react-stately/overlays": "^3.6.3", + "@react-stately/utils": "^3.8.0", + "@internationalized/date": "^3.5.2", + "@react-aria/datepicker": "^3.9.3", + "@react-aria/i18n": "^3.8.4", + "@react-stately/datepicker": "^3.9.2", + "@react-types/datepicker": "^3.7.2", + "@react-types/shared": "3.21.0", + "@react-aria/utils": "^3.21.1" + }, + "devDependencies": { + "@nextui-org/system": "workspace:*", + "@nextui-org/theme": "workspace:*", + "@nextui-org/radio": "workspace:*", + "@nextui-org/test-utils": "workspace:*", + "clean-package": "2.2.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "clean-package": "../../../clean-package.config.json" +} diff --git a/packages/components/date-picker/src/date-picker.tsx b/packages/components/date-picker/src/date-picker.tsx new file mode 100644 index 0000000000..ac29e03bde --- /dev/null +++ b/packages/components/date-picker/src/date-picker.tsx @@ -0,0 +1,67 @@ +import type {DateValue} from "@internationalized/date"; +import type {ForwardedRef, ReactElement, Ref} from "react"; + +import {cloneElement, isValidElement} from "react"; +import {forwardRef} from "@nextui-org/system"; +import {Button} from "@nextui-org/button"; +import {DateInput} from "@nextui-org/date-input"; +import {FreeSoloPopover} from "@nextui-org/popover"; +import {Calendar} from "@nextui-org/calendar"; +import {AnimatePresence} from "framer-motion"; +import {CalendarBoldIcon} from "@nextui-org/shared-icons"; + +import {UseDatePickerProps, useDatePicker} from "./use-date-picker"; + +export interface Props + extends Omit, "hasMultipleMonths"> {} + +function DatePicker(props: Props, ref: ForwardedRef) { + const { + state, + endContent, + selectorIcon, + disableAnimation, + getDateInputProps, + getPopoverProps, + getSelectorButtonProps, + getSelectorIconProps, + getCalendarProps, + CalendarTopContent, + CalendarBottomContent, + } = useDatePicker({...props, ref}); + + const selectorContent = isValidElement(selectorIcon) ? ( + cloneElement(selectorIcon, getSelectorIconProps()) + ) : ( + + ); + + const popoverContent = state.isOpen ? ( + + + + ) : null; + + return ( + <> + {endContent || selectorContent}} + /> + {disableAnimation ? popoverContent : {popoverContent}} + + ); +} + +DatePicker.displayName = "NextUI.DatePicker"; + +export type DatePickerProps = Props & {ref?: Ref}; + +// forwardRef doesn't support generic parameters, so cast the result to the correct type +export default forwardRef(DatePicker) as ( + props: DatePickerProps, +) => ReactElement; diff --git a/packages/components/date-picker/src/index.ts b/packages/components/date-picker/src/index.ts new file mode 100644 index 0000000000..7f848bfa74 --- /dev/null +++ b/packages/components/date-picker/src/index.ts @@ -0,0 +1,10 @@ +import DatePicker from "./date-picker"; + +// export types +export type {DatePickerProps} from "./date-picker"; + +// export hooks +export {useDatePicker} from "./use-date-picker"; + +// export component +export {DatePicker}; diff --git a/packages/components/date-picker/src/use-date-picker.ts b/packages/components/date-picker/src/use-date-picker.ts new file mode 100644 index 0000000000..3ce116fede --- /dev/null +++ b/packages/components/date-picker/src/use-date-picker.ts @@ -0,0 +1,340 @@ +import type {DateValue} from "@internationalized/date"; +import type {AriaDatePickerProps} from "@react-types/datepicker"; +import type {DateInputProps} 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 { + DatePickerVariantProps, + DatePickerSlots, + SlotsToClasses, + dateInput, +} from "@nextui-org/theme"; +import {ReactNode} 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 {datePicker} from "@nextui-org/theme"; +import {mergeProps} from "@react-aria/utils"; +import {useDOMRef} from "@nextui-org/react-utils"; +import {clsx, dataAttr, objectToDeps} from "@nextui-org/shared-utils"; +import {useMemo} from "react"; + +type NextUIBaseProps = Omit< + HTMLNextUIProps<"div">, + keyof AriaDatePickerProps | "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>; + /** + * 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; + /** + * 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 UseDatePickerProps = Props & + DatePickerVariantProps & + Omit, "groupProps" | "fieldProps" | "labelProps" | "errorMessageProps">; + +export function useDatePicker(originalProps: UseDatePickerProps) { + const [props, variantProps] = mapPropsVariants(originalProps, dateInput.variantKeys); + + const { + as, + ref, + label, + selectorIcon, + inputRef, + isInvalid, + errorMessage, + description, + startContent, + endContent, + validationState, + validationBehavior, + visibleMonths = 1, + pageBehavior = "visible", + calendarWidth = 256, + isDateUnavailable, + shouldForceLeadingZeros, + showMonthAndYearPickers = false, + popoverProps = {}, + selectorButtonProps = {}, + calendarProps: userCalendarProps = {}, + CalendarTopContent, + CalendarBottomContent, + minValue, + maxValue, + createCalendar, + className, + classNames, + } = props; + + const domRef = useDOMRef(ref); + const disableAnimation = originalProps.disableAnimation ?? false; + + let state: DatePickerState = useDatePickerState({ + ...originalProps, + minValue, + maxValue, + shouldCloseOnSelect: () => !state.hasTime, + }); + + let { + groupProps, + labelProps, + fieldProps, + buttonProps, + dialogProps, + calendarProps, + descriptionProps, + errorMessageProps, + } = useAriaDatePicker(originalProps, state, domRef); + + const baseStyles = clsx(classNames?.base, className); + + const isDefaultColor = originalProps.color === "default" || !originalProps.color; + const hasMultipleMonths = visibleMonths > 1; + + 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, + 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, + groupProps, + labelProps, + createCalendar, + errorMessageProps, + descriptionProps, + ...mergeProps(variantProps, fieldProps, { + minValue, + maxValue, + fullWidth: true, + disableAnimation, + }), + "data-invalid": dataAttr(originalProps?.isInvalid), + "data-open": dataAttr(state.isOpen), + className: slots.base({class: baseStyles}), + classNames, + } as unknown as DateInputProps; + }; + + const getPopoverProps = (props: DOMAttributes = {}) => { + return { + state, + dialogProps, + ...mergeProps(slotsProps.popoverProps, props), + triggerRef: domRef, + classNames: { + content: slots.popoverContent({ + class: clsx( + classNames?.popoverContent, + slotsProps.popoverProps?.classNames?.["content"], + props.className, + ), + }), + }, + } as unknown as PopoverProps; + }; + + const getCalendarProps = () => { + return { + ...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; + }; + + const getSelectorButtonProps = () => { + return { + ...buttonProps, + ...slotsProps.selectorButtonProps, + "data-slot": "selector-button", + className: slots.selectorButton({class: classNames?.selectorButton}), + } as unknown as ButtonProps; + }; + + const getSelectorIconProps = () => { + return { + "data-slot": "selector-icon", + className: slots.selectorIcon({class: classNames?.selectorIcon}), + }; + }; + + return { + state, + endContent, + selectorIcon, + disableAnimation, + CalendarTopContent, + CalendarBottomContent, + getDateInputProps, + getPopoverProps, + getSelectorButtonProps, + getCalendarProps, + getSelectorIconProps, + }; +} + +export type UseDatePickerReturn = ReturnType; diff --git a/packages/components/date-picker/stories/date-picker.stories.tsx b/packages/components/date-picker/stories/date-picker.stories.tsx new file mode 100644 index 0000000000..2025895a1d --- /dev/null +++ b/packages/components/date-picker/stories/date-picker.stories.tsx @@ -0,0 +1,491 @@ +import React from "react"; +import {Meta} from "@storybook/react"; +import {datePicker, dateInput} from "@nextui-org/theme"; +import { + DateValue, + getLocalTimeZone, + isWeekend, + now, + parseAbsoluteToLocal, + parseDate, + parseZonedDateTime, + startOfMonth, + startOfWeek, + today, +} from "@internationalized/date"; +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 {DatePicker, DatePickerProps} from "../src"; + +export default { + title: "Components/DatePicker", + component: DatePicker, + 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: "Birth Date", + className: "max-w-[256px]", + ...dateInput.defaultVariants, + ...datePicker.defaultVariants, +}; + +const Template = (args: DatePickerProps) => ; + +const LabelPlacementTemplate = (args: DatePickerProps) => ( +
+ + + +
+); + +const ControlledTemplate = (args: DatePickerProps) => { + const [value, setValue] = React.useState(parseDate("2024-04-04")); + + let formatter = useDateFormatter({dateStyle: "full"}); + + return ( +
+
+ +

+ Selected date: {value ? formatter.format(value.toDate(getLocalTimeZone())) : "--"} +

+
+ +
+ ); +}; + +const TimeZonesTemplate = (args: DatePickerProps) => ( +
+ + +
+); + +const GranularityTemplate = (args: DatePickerProps) => { + let [date, setDate] = React.useState(parseAbsoluteToLocal("2021-04-07T18:45:22Z")); + + return ( +
+ + + + +
+ ); +}; + +const InternationalCalendarsTemplate = (args: DatePickerProps) => { + let [date, setDate] = React.useState(parseAbsoluteToLocal("2021-04-07T18:45:22Z")); + + return ( +
+ + + +
+ ); +}; + +const PresetsTemplate = (args: DatePickerProps) => { + let defaultDate = today(getLocalTimeZone()); + + const [value, setValue] = React.useState(defaultDate); + + let {locale} = useLocale(); + let formatter = useDateFormatter({dateStyle: "full"}); + + let now = today(getLocalTimeZone()); + let nextWeek = startOfWeek(now.add({weeks: 1}), locale); + let nextMonth = startOfMonth(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, + onFocusChange: setValue, + nextButtonProps: { + variant: "bordered", + }, + prevButtonProps: { + variant: "bordered", + }, + }} + value={value} + onChange={setValue} + {...args} + label="Event date" + /> +

+ Selected date: {value ? formatter.format(value.toDate(getLocalTimeZone())) : "--"} +

+
+ ); +}; + +const UnavailableDatesTemplate = (args: DatePickerProps) => { + 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 {locale} = useLocale(); + + let isDateUnavailable = (date) => + isWeekend(date, locale) || + disabledRanges.some( + (interval) => date.compare(interval[0]) >= 0 && date.compare(interval[1]) <= 0, + ); + + return ( + + ); +}; + +export const Default = { + render: Template, + args: { + ...defaultProps, + }, +}; + +export const WithMonthAndYearPickers = { + render: Template, + args: { + ...defaultProps, + variant: "bordered", + showMonthAndYearPickers: true, + }, +}; + +export const LabelPlacement = { + render: LabelPlacementTemplate, + + args: { + ...defaultProps, + }, +}; + +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: parseDate("2024-04-04"), + }, +}; + +export const ReadOnly = { + render: Template, + args: { + ...defaultProps, + isReadOnly: true, + defaultValue: parseDate("2024-04-04"), + }, +}; + +export const WithoutLabel = { + render: Template, + + args: { + ...defaultProps, + label: null, + "aria-label": "Birth date", + }, +}; + +export const WithDescription = { + render: Template, + + args: { + ...defaultProps, + description: "Please enter your birth date", + }, +}; + +export const SelectorIcon = { + render: Template, + + args: { + ...defaultProps, + selectorIcon: ( + + + + + + + + ), + }, +}; + +export const WithErrorMessage = { + render: Template, + + args: { + ...defaultProps, + errorMessage: "Please enter a valid date", + }, +}; + +export const IsInvalid = { + render: Template, + + args: { + ...defaultProps, + variant: "bordered", + isInvalid: true, + defaultValue: parseDate("2024-04-04"), + 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, + }, +}; + +export const InternationalCalendars = { + render: InternationalCalendarsTemplate, + + args: { + ...defaultProps, + showMonthAndYearPickers: true, + }, +}; + +export const MinDateValue = { + render: Template, + + args: { + ...defaultProps, + minValue: today(getLocalTimeZone()), + defaultValue: parseDate("2024-04-03"), + }, +}; + +export const MaxDateValue = { + render: Template, + + args: { + ...defaultProps, + maxValue: today(getLocalTimeZone()), + defaultValue: parseDate("2024-04-05"), + }, +}; + +export const UnavailableDates = { + render: UnavailableDatesTemplate, + args: { + ...defaultProps, + defaultValue: today(getLocalTimeZone()), + unavailableDates: [today(getLocalTimeZone())], + }, +}; + +export const VisibleMonths = { + render: Template, + + args: { + ...defaultProps, + visibleMonths: 2, + }, +}; + +export const PageBehavior = { + render: Template, + args: { + ...defaultProps, + visibleMonths: 2, + pageBehavior: "single", + }, +}; + +export const Presets = { + render: PresetsTemplate, + args: { + ...defaultProps, + }, +}; diff --git a/packages/components/date-picker/tsconfig.json b/packages/components/date-picker/tsconfig.json new file mode 100644 index 0000000000..5d012f6e61 --- /dev/null +++ b/packages/components/date-picker/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "tailwind-variants": ["../../../node_modules/tailwind-variants"] + }, + }, + "include": ["src", "index.ts"] +} diff --git a/packages/components/date-picker/tsup.config.ts b/packages/components/date-picker/tsup.config.ts new file mode 100644 index 0000000000..3e2bcff6cc --- /dev/null +++ b/packages/components/date-picker/tsup.config.ts @@ -0,0 +1,8 @@ +import {defineConfig} from "tsup"; + +export default defineConfig({ + clean: true, + target: "es2019", + format: ["cjs", "esm"], + banner: {js: '"use client";'}, +}); diff --git a/packages/components/popover/src/free-solo-popover.tsx b/packages/components/popover/src/free-solo-popover.tsx index 1cf7fa9f58..9af4ab15cf 100644 --- a/packages/components/popover/src/free-solo-popover.tsx +++ b/packages/components/popover/src/free-solo-popover.tsx @@ -17,8 +17,8 @@ import {TRANSITION_VARIANTS} from "@nextui-org/framer-utils"; import {usePopover, UsePopoverProps, UsePopoverReturn} from "./use-popover"; -export interface FreeSoloPopoverProps extends UsePopoverProps { - children: React.ReactNode; +export interface FreeSoloPopoverProps extends Omit { + children: React.ReactNode | ((titleProps: React.DOMAttributes) => React.ReactNode); } type FreeSoloPopoverWrapperProps = { @@ -28,42 +28,42 @@ type FreeSoloPopoverWrapperProps = { motionProps?: UsePopoverProps["motionProps"]; } & React.HTMLAttributes; -const FreeSoloPopoverWrapper = ({ - children, - motionProps, - placement, - disableAnimation, - style = {}, - ...otherProps -}: FreeSoloPopoverWrapperProps) => { - return disableAnimation ? ( -
{children}
- ) : ( - - +const FreeSoloPopoverWrapper = forwardRef<"div", FreeSoloPopoverWrapperProps>( + ({children, motionProps, placement, disableAnimation, style = {}, ...otherProps}, ref) => { + return disableAnimation ? ( +
{children} - - - ); -}; +
+ ) : ( + + + {children} + + + ); + }, +); + +FreeSoloPopoverWrapper.displayName = "NextUI.FreeSoloPopoverWrapper"; -const FreeSoloPopover = forwardRef<"div", FreeSoloPopoverProps>((props, ref) => { +const FreeSoloPopover = forwardRef<"div", FreeSoloPopoverProps>(({children, ...props}, ref) => { const { Component, state, - children, placement, backdrop, + titleProps, portalContainer, disableAnimation, motionProps, @@ -111,7 +111,9 @@ const FreeSoloPopover = forwardRef<"div", FreeSoloPopoverProps>((props, ref) => {...getDialogProps()} > {!isNonModal && } -
{children}
+
+ {typeof children === "function" ? children(titleProps) : children} +
diff --git a/packages/components/popover/src/popover-content.tsx b/packages/components/popover/src/popover-content.tsx index accb4ec898..0c22513c8e 100644 --- a/packages/components/popover/src/popover-content.tsx +++ b/packages/components/popover/src/popover-content.tsx @@ -1,13 +1,11 @@ import type {AriaDialogProps} from "@react-aria/dialog"; import type {HTMLMotionProps} from "framer-motion"; -import {DOMAttributes, ReactNode, useMemo, useRef, useCallback, ReactElement} from "react"; +import {DOMAttributes, ReactNode, useMemo, useCallback, ReactElement} from "react"; import {forwardRef} from "@nextui-org/system"; import {DismissButton} from "@react-aria/overlays"; import {TRANSITION_VARIANTS} from "@nextui-org/framer-utils"; import {m, domAnimation, LazyMotion} from "framer-motion"; -import {useDialog} from "@react-aria/dialog"; -import {mergeProps} from "@react-aria/utils"; import {HTMLNextUIProps} from "@nextui-org/system"; import {RemoveScroll} from "react-remove-scroll"; import {getTransformOrigins} from "@nextui-org/aria-utils"; @@ -27,8 +25,9 @@ const PopoverContent = forwardRef<"div", PopoverContentProps>((props, _) => { Component: OverlayComponent, isOpen, placement, - motionProps, backdrop, + motionProps, + titleProps, disableAnimation, shouldBlockScroll, getPopoverProps, @@ -39,18 +38,17 @@ const PopoverContent = forwardRef<"div", PopoverContentProps>((props, _) => { onClose, } = usePopoverContext(); - const Component = as || OverlayComponent || "div"; - - const dialogRef = useRef(null); - const {dialogProps, titleProps} = useDialog({}, dialogRef); + const dialogProps = getDialogProps(otherProps); // Not needed in the popover context, the popover role comes from getPopoverProps delete dialogProps.role; + const Component = as || OverlayComponent || "div"; + const content = ( <> {!isNonModal && } - +
{typeof children === "function" ? children(titleProps) : children}
diff --git a/packages/components/popover/src/use-popover.ts b/packages/components/popover/src/use-popover.ts index 5ae2b90d77..d64e1c4cd8 100644 --- a/packages/components/popover/src/use-popover.ts +++ b/packages/components/popover/src/use-popover.ts @@ -1,5 +1,6 @@ import type {PopoverVariantProps, SlotsToClasses, PopoverSlots} from "@nextui-org/theme"; import type {HTMLMotionProps} from "framer-motion"; +import type {PressEvent} from "@react-types/shared"; import {RefObject, Ref, useEffect} from "react"; import {ReactRef, useDOMRef} from "@nextui-org/react-utils"; @@ -13,7 +14,7 @@ import {popover} from "@nextui-org/theme"; import {mergeProps, mergeRefs} from "@react-aria/utils"; import {clsx, dataAttr, objectToDeps} from "@nextui-org/shared-utils"; import {useMemo, useCallback, useRef} from "react"; -import {PressEvent} from "@react-types/shared"; +import {AriaDialogProps, useDialog} from "@react-aria/dialog"; import {useReactAriaPopover, ReactAriaPopoverProps} from "./use-aria-popover"; @@ -35,6 +36,12 @@ export interface Props extends HTMLNextUIProps<"div"> { * @default true */ shouldBlockScroll?: boolean; + /** + * Custom props to be passed to the dialog container. + * + * @default {} + */ + dialogProps?: AriaDialogProps; /** * Type of overlay that is opened by the trigger. */ @@ -79,8 +86,8 @@ export function usePopover(originalProps: UsePopoverProps) { const { as, - children, ref, + children, state: stateProp, triggerRef: triggerRefProp, scrollRef, @@ -95,6 +102,7 @@ export function usePopover(originalProps: UsePopoverProps) { shouldCloseOnBlur, portalContainer, updatePositionDeps, + dialogProps: dialogPropsProp, placement: placementProp = "top", triggerType = "dialog", showArrow = false, @@ -116,7 +124,7 @@ export function usePopover(originalProps: UsePopoverProps) { const domTriggerRef = useRef(null); const wasTriggerPressedRef = useRef(false); - + const dialogRef = useRef(null); const triggerRef = triggerRefProp || domTriggerRef; const disableAnimation = originalProps.disableAnimation ?? false; @@ -144,7 +152,7 @@ export function usePopover(originalProps: UsePopoverProps) { isNonModal, popoverRef: domRef, placement: placementProp, - offset: offset, + offset, scrollRef, isDismissable, shouldCloseOnBlur, @@ -163,6 +171,8 @@ export function usePopover(originalProps: UsePopoverProps) { const {isFocusVisible, isFocused, focusProps} = useFocusRing(); + const {dialogProps, titleProps} = useDialog({}, dialogRef); + const slots = useMemo( () => popover({ @@ -180,13 +190,14 @@ export function usePopover(originalProps: UsePopoverProps) { }); const getDialogProps: PropGetter = (props = {}) => ({ + ref: dialogRef, "data-slot": "base", "data-open": dataAttr(state.isOpen), "data-focus": dataAttr(isFocused), "data-arrow": dataAttr(showArrow), "data-focus-visible": dataAttr(isFocusVisible), "data-placement": getArrowPlacement(ariaPlacement, placementProp), - ...mergeProps(focusProps, props), + ...mergeProps(focusProps, dialogProps, dialogPropsProp, props), className: slots.base({class: clsx(baseStyles)}), style: { // this prevent the dialog to have a default outline @@ -206,7 +217,10 @@ export function usePopover(originalProps: UsePopoverProps) { ); const placement = useMemo( - () => (getShouldUseAxisPlacement(ariaPlacement, placementProp) ? ariaPlacement : placementProp), + () => + getShouldUseAxisPlacement(ariaPlacement, placementProp) + ? ariaPlacement || placementProp + : placementProp, [ariaPlacement, placementProp], ); @@ -291,6 +305,7 @@ export function usePopover(originalProps: UsePopoverProps) { triggerRef, placement, isNonModal, + titleProps, popoverRef: domRef, portalContainer, isOpen: state.isOpen, diff --git a/packages/core/theme/src/components/calendar.ts b/packages/core/theme/src/components/calendar.ts index a637f5f47c..9888d3fe6e 100644 --- a/packages/core/theme/src/components/calendar.ts +++ b/packages/core/theme/src/components/calendar.ts @@ -7,12 +7,13 @@ const calendar = tv({ slots: { base: [ "relative w-fit max-w-full shadow-small inline-block", - "rounded-large overflow-scroll bg-default-50 dark:bg-background", + "rounded-large overflow-x-scroll bg-default-50 dark:bg-background", ], prevButton: [], nextButton: [], headerWrapper: [ "px-4 py-2 flex items-center justify-between gap-2 bg-content1", + "[&_.chevron-icon]:flex-none", // month/year picker wrapper "after:content-['']", "after:bg-content1 origin-top", @@ -21,13 +22,14 @@ const calendar = tv({ ], header: "flex w-full items-center justify-center gap-2 z-10", title: "text-default-500 text-small font-medium", + content: "w-fit", gridWrapper: "flex max-w-full overflow-auto pb-2 h-auto relative", grid: "w-full border-collapse z-0", gridHeader: "bg-content1 shadow-[0px_20px_20px_0px_rgb(0_0_0/0.05)]", gridHeaderRow: "px-4 pb-2 flex justify-center text-default-400", gridHeaderCell: "flex w-8 justify-center items-center font-medium text-small", gridBody: "", - gridBodyRow: "flex mt-2 justify-center items-center first:mt-2", + gridBodyRow: "flex justify-center items-center first:mt-2", cell: "py-0.5 px-0", cellButton: [ "w-8 h-8 flex items-center text-foreground justify-center rounded-full", diff --git a/packages/core/theme/src/components/date-input.ts b/packages/core/theme/src/components/date-input.ts index 70cc148f0e..ed70100f01 100644 --- a/packages/core/theme/src/components/date-input.ts +++ b/packages/core/theme/src/components/date-input.ts @@ -19,14 +19,19 @@ const dateInput = tv({ ], inputWrapper: [ "relative px-3 gap-3 w-full inline-flex flex-row items-center", - "cursor-text tap-highlight-transparent shadow-sm ", + "cursor-text tap-highlight-transparent shadow-sm", ], input: "flex h-full gap-x-0.5 w-full font-normal", + innerWrapper: [ + "flex items-center text-default-400 w-full gap-x-2 h-6", + // isInValid=true + "group-data-[invalid=true]:text-danger", + ], // this wraps the input and the start/end content segment: [ - "group -ml-0.5 px-0.5 py-0.5 box-content tabular-nums text-start", - "inline-block my-auto outline-none focus:shadow-sm rounded-md", - "text-foreground-500 data-[editable=true]:text-inherit", - "data-[placeholder=true]:text-foreground-500", + "group -ml-0.5 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", // isInvalid=true "data-[invalid=true]:text-danger-300 data-[invalid=true]:data-[editable=true]:text-danger", "data-[invalid=true]:focus:bg-danger-400/50 dark:data-[invalid=true]:focus:bg-danger-400/20", @@ -159,12 +164,12 @@ const dateInput = tv({ }, labelPlacement: { outside: { - base: "flex flex-col pb-[calc(theme(fontSize.tiny)_+8px)] gap-y-1.5", + base: "flex flex-col data-[has-helper=true]:pb-[calc(theme(fontSize.tiny)_+8px)] gap-y-1.5", label: "w-full text-foreground", helperWrapper: "absolute top-[calc(100%_+_2px)] left-0 rtl:right-0", }, "outside-left": { - base: "flex-row items-center pb-[calc(theme(fontSize.tiny)_+_8px)] gap-x-2 flex-nowrap", + base: "flex-row items-center data-[has-helper=true]:pb-[calc(theme(fontSize.tiny)_+_8px)] gap-x-2 flex-nowrap", label: "relative text-foreground", inputWrapper: "relative flex-1", helperWrapper: "absolute top-[calc(100%_+_2px)] left-0 rtl:right-0", @@ -221,8 +226,10 @@ const dateInput = tv({ variant: "flat", color: "primary", class: { + innerWrapper: "text-primary", inputWrapper: ["bg-primary-50", "hover:bg-primary-100", "focus-within:bg-primary-50"], - segment: "text-primary-300 data-[editable=true]:text-primary", + segment: + "text-primary-300 data-[editable=true]:data-[placeholder=true]:text-primary-300 data-[editable=true]:text-primary", label: "text-primary", }, }, @@ -230,8 +237,10 @@ const dateInput = tv({ variant: "flat", color: "secondary", class: { + innerWrapper: "text-secondary", inputWrapper: ["bg-secondary-50", "hover:bg-secondary-100", "focus-within:bg-secondary-50"], - segment: "text-secondary-300 data-[editable=true]:text-secondary", + segment: + "text-secondary-300 data-[editable=true]:data-[placeholder=true]:text-secondary-300 data-[editable=true]:text-secondary", label: "text-secondary", }, }, @@ -239,9 +248,10 @@ const dateInput = tv({ variant: "flat", color: "success", class: { + innerWrapper: "text-success-600 dark:text-success", inputWrapper: ["bg-success-50", "hover:bg-success-100", "focus-within:bg-success-50"], segment: - "text-success-300 data-[editable=true]:text-success-600 data-[editable=true]:focus:text-success-600", + "text-success-400 data-[editable=true]:data-[placeholder=true]:text-success-400 data-[editable=true]:text-success-600 data-[editable=true]:focus:text-success-600", label: "text-success-600 dark:text-success", }, }, @@ -249,9 +259,10 @@ const dateInput = tv({ variant: "flat", color: "warning", class: { + innerWrapper: "text-warning-600 dark:text-warning", inputWrapper: ["bg-warning-50", "hover:bg-warning-100", "focus-within:bg-warning-50"], segment: - "text-warning-300 data-[editable=true]:text-warning-600 data-[editable=true]:focus:text-warning-600", + "text-warning-400 data-[editable=true]:data-[placeholder=true]:text-warning-400 data-[editable=true]:text-warning-600 data-[editable=true]:focus:text-warning-600", label: "text-warning-600 dark:text-warning", }, }, @@ -259,8 +270,11 @@ const dateInput = tv({ variant: "flat", color: "danger", class: { + innerWrapper: "text-danger", inputWrapper: ["bg-danger-50", "hover:bg-danger-100", "focus-within:bg-danger-50"], - segment: "text-danger-300 data-[editable=true]:text-danger", + segment: + "text-danger-300 data-[editable=true]:data-[placeholder=true]:text-danger-300 data-[editable=true]:text-danger", + label: "text-danger", }, }, // bordered & color @@ -268,6 +282,7 @@ const dateInput = tv({ variant: ["bordered", "faded"], color: "primary", class: { + innerWrapper: "text-primary", inputWrapper: ["focus-within:border-primary", "focus-within:hover:border-primary"], label: "text-primary", }, @@ -276,6 +291,7 @@ const dateInput = tv({ variant: ["bordered", "faded"], color: "secondary", class: { + innerWrapper: "text-secondary", inputWrapper: ["focus-within:border-secondary", "focus-within:hover:border-secondary"], label: "text-secondary", }, @@ -284,6 +300,7 @@ const dateInput = tv({ variant: ["bordered", "faded"], color: "success", class: { + innerWrapper: "text-success", inputWrapper: ["focus-within:border-success", "focus-within:hover:border-success"], label: "text-success", }, @@ -292,6 +309,7 @@ const dateInput = tv({ variant: ["bordered", "faded"], color: "warning", class: { + innerWrapper: "text-warning", inputWrapper: ["focus-within:border-warning", "focus-within:hover:border-warning"], label: "text-warning", }, @@ -300,6 +318,7 @@ const dateInput = tv({ variant: ["bordered", "faded"], color: "danger", class: { + innerWrapper: "text-danger", inputWrapper: ["focus-within:border-danger", "focus-within:hover:border-danger"], label: "text-danger", }, @@ -309,6 +328,7 @@ const dateInput = tv({ variant: "underlined", color: "primary", class: { + innerWrapper: "text-primary", inputWrapper: "after:bg-primary", label: "text-primary", }, @@ -317,6 +337,7 @@ const dateInput = tv({ variant: "underlined", color: "secondary", class: { + innerWrapper: "text-secondary", inputWrapper: "after:bg-secondary", label: "text-secondary", }, @@ -325,6 +346,7 @@ const dateInput = tv({ variant: "underlined", color: "success", class: { + innerWrapper: "text-success", inputWrapper: "after:bg-success", label: "text-success", }, @@ -333,6 +355,7 @@ const dateInput = tv({ variant: "underlined", color: "warning", class: { + innerWrapper: "text-warning", inputWrapper: "after:bg-warning", label: "text-warning", }, @@ -341,6 +364,7 @@ const dateInput = tv({ variant: "underlined", color: "danger", class: { + innerWrapper: "text-danger", inputWrapper: "after:bg-danger", label: "text-danger", }, diff --git a/packages/core/theme/src/components/date-picker.ts b/packages/core/theme/src/components/date-picker.ts new file mode 100644 index 0000000000..9ebf4d7b6d --- /dev/null +++ b/packages/core/theme/src/components/date-picker.ts @@ -0,0 +1,37 @@ +import type {VariantProps} from "tailwind-variants"; + +import {tv} from "../utils/tv"; + +/** + * DatePicker wrapper **Tailwind Variants** component + * + * @example + */ +const datePicker = tv({ + slots: { + base: "group w-full", + selectorButton: "-mr-2 text-inherit", + selectorIcon: "text-lg text-inherit pointer-events-none flex-shrink-0", + popoverContent: "p-0 w-full", + calendar: "w-[var(--calendar-width)] shadow-none", + calendarContent: "w-[var(--calendar-width)]", + }, + variants: { + // @internal + hasMultipleMonths: { + true: { + calendar: "w-[calc(var(--visible-months)_*_var(--calendar-width))]", + }, + false: {}, + }, + }, + defaultVariants: { + hasMultipleMonths: false, + }, +}); + +export type DatePickerReturnType = ReturnType; +export type DatePickerVariantProps = VariantProps; +export type DatePickerSlots = keyof ReturnType; + +export {datePicker}; diff --git a/packages/core/theme/src/components/index.ts b/packages/core/theme/src/components/index.ts index 7e471e6aca..7776b24c52 100644 --- a/packages/core/theme/src/components/index.ts +++ b/packages/core/theme/src/components/index.ts @@ -37,3 +37,4 @@ export * from "./breadcrumbs"; export * from "./autocomplete"; export * from "./calendar"; export * from "./date-input"; +export * from "./date-picker"; diff --git a/packages/utilities/framer-utils/src/resizable-panel.tsx b/packages/utilities/framer-utils/src/resizable-panel.tsx index 8fc653abeb..0b32f8e49d 100644 --- a/packages/utilities/framer-utils/src/resizable-panel.tsx +++ b/packages/utilities/framer-utils/src/resizable-panel.tsx @@ -1,15 +1,14 @@ -import type {ReactNode, Ref} from "react"; +import type {Ref} from "react"; import {forwardRef} from "react"; import {domAnimation, LazyMotion, m} from "framer-motion"; import {useMeasure} from "@nextui-org/use-measure"; +import {HTMLNextUIProps} from "@nextui-org/system"; /** * Props for the ResizablePanel component. */ -export interface ResizablePanelProps { - children?: ReactNode; -} +export interface ResizablePanelProps extends HTMLNextUIProps<"div"> {} const ResizablePanel = forwardRef( (originalProps: ResizablePanelProps, ref: Ref) => { @@ -25,9 +24,10 @@ const ResizablePanel = forwardRef( width: bounds.width && bounds?.width > 0 ? bounds.width : "auto", height: bounds.height && bounds.height > 0 ? bounds.height : "auto", }} - {...props} > -
{children}
+
+ {children} +
); diff --git a/packages/utilities/framer-utils/src/transition-utils.ts b/packages/utilities/framer-utils/src/transition-utils.ts index 2287eaac5b..bab9937548 100644 --- a/packages/utilities/framer-utils/src/transition-utils.ts +++ b/packages/utilities/framer-utils/src/transition-utils.ts @@ -87,11 +87,11 @@ export const TRANSITION_VARIANTS: Variants = { }, exit: { opacity: 0, - transform: "scale(0.7)", + transform: "scale(0.96)", transition: { type: "easeOut", bounce: 0, - duration: 0.18, + duration: 0.15, }, }, }, diff --git a/packages/utilities/react-rsc-utils/src/filter-dom-props.ts b/packages/utilities/react-rsc-utils/src/filter-dom-props.ts index 24900279f5..02a6a5d41f 100644 --- a/packages/utilities/react-rsc-utils/src/filter-dom-props.ts +++ b/packages/utilities/react-rsc-utils/src/filter-dom-props.ts @@ -23,6 +23,14 @@ interface Options { * A Set of event names that should be excluded from the filter. */ omitEventNames?: Set; + /** + * Whether to omit data-* props. + */ + omitDataProps?: boolean; + /** + * Whether to omit event props. + */ + omitEventProps?: boolean; } const propRe = /^(data-.*)$/; @@ -38,7 +46,15 @@ export function filterDOMProps( props: DOMProps & AriaLabelingProps, opts: Options = {}, ): DOMProps & AriaLabelingProps { - let {labelable = true, enabled = true, propNames, omitPropNames, omitEventNames} = opts; + let { + labelable = true, + enabled = true, + propNames, + omitPropNames, + omitEventNames, + omitDataProps, + omitEventProps, + } = opts; let filteredProps = {}; if (!enabled) { @@ -57,6 +73,14 @@ export function filterDOMProps( continue; } + if (omitDataProps && propRe.test(prop)) { + continue; + } + + if (omitEventProps && funcRe.test(prop)) { + continue; + } + if ( (Object.prototype.hasOwnProperty.call(props, prop) && (DOMPropNames.has(prop) || diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 38e67f7525..70e4e9a61a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1313,6 +1313,79 @@ importers: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) + packages/components/date-picker: + dependencies: + '@internationalized/date': + specifier: ^3.5.2 + version: 3.5.2 + '@nextui-org/button': + specifier: workspace:* + version: link:../button + '@nextui-org/calendar': + specifier: workspace:* + version: link:../calendar + '@nextui-org/date-input': + specifier: workspace:* + version: link:../date-input + '@nextui-org/popover': + specifier: workspace:* + version: link:../popover + '@nextui-org/react-utils': + specifier: workspace:* + version: link:../../utilities/react-utils + '@nextui-org/shared-icons': + specifier: workspace:* + version: link:../../utilities/shared-icons + '@nextui-org/shared-utils': + specifier: workspace:* + version: link:../../utilities/shared-utils + '@react-aria/datepicker': + specifier: ^3.9.3 + version: 3.9.3(react-dom@18.2.0)(react@18.2.0) + '@react-aria/i18n': + specifier: ^3.8.4 + version: 3.10.2(react@18.2.0) + '@react-aria/utils': + specifier: ^3.21.1 + version: 3.23.2(react@18.2.0) + '@react-stately/datepicker': + specifier: ^3.9.2 + version: 3.9.2(react@18.2.0) + '@react-stately/overlays': + specifier: ^3.6.3 + version: 3.6.5(react@18.2.0) + '@react-stately/utils': + specifier: ^3.8.0 + version: 3.9.1(react@18.2.0) + '@react-types/datepicker': + specifier: ^3.7.2 + version: 3.7.2(react@18.2.0) + '@react-types/shared': + specifier: 3.21.0 + version: 3.21.0(react@18.2.0) + devDependencies: + '@nextui-org/radio': + specifier: workspace:* + version: link:../radio + '@nextui-org/system': + specifier: workspace:* + version: link:../../core/system + '@nextui-org/test-utils': + specifier: workspace:* + version: link:../../utilities/test-utils + '@nextui-org/theme': + specifier: workspace:* + version: link:../../core/theme + clean-package: + specifier: 2.2.0 + version: 2.2.0 + react: + specifier: ^18.2.0 + version: 18.2.0 + react-dom: + specifier: ^18.2.0 + version: 18.2.0(react@18.2.0) + packages/components/divider: dependencies: '@nextui-org/react-rsc-utils': @@ -6570,16 +6643,10 @@ packages: '@swc/helpers': 0.5.3 dev: false - /@internationalized/string@3.1.1: - resolution: {integrity: sha512-fvSr6YRoVPgONiVIUhgCmIAlifMVCeej/snPZVzbzRPxGpHl3o1GRe+d/qh92D8KhgOciruDUH8I5mjdfdjzfA==} - dependencies: - '@swc/helpers': 0.5.3 - /@internationalized/string@3.2.1: resolution: {integrity: sha512-vWQOvRIauvFMzOO+h7QrdsJmtN1AXAFVcaLWP9AseRN2o7iHceZ6bIXhBD4teZl8i91A3gxKnWBlGgjCwU6MFQ==} dependencies: '@swc/helpers': 0.5.3 - dev: false /@isaacs/cliui@8.0.2: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} @@ -9524,7 +9591,7 @@ packages: '@react-aria/focus': 3.14.3(react@18.2.0) '@react-aria/overlays': 3.18.1(react-dom@18.2.0)(react@18.2.0) '@react-aria/utils': 3.23.2(react@18.2.0) - '@react-stately/overlays': 3.6.3(react@18.2.0) + '@react-stately/overlays': 3.6.5(react@18.2.0) '@react-types/dialog': 3.5.6(react@18.2.0) '@react-types/shared': 3.21.0(react@18.2.0) '@swc/helpers': 0.5.3 @@ -9619,7 +9686,7 @@ packages: '@internationalized/date': 3.5.2 '@internationalized/message': 3.1.1 '@internationalized/number': 3.3.0 - '@internationalized/string': 3.1.1 + '@internationalized/string': 3.2.1 '@react-aria/ssr': 3.8.0(react@18.2.0) '@react-aria/utils': 3.21.1(react@18.2.0) '@react-types/shared': 3.21.0(react@18.2.0) @@ -9750,12 +9817,12 @@ packages: '@react-aria/focus': 3.14.3(react@18.2.0) '@react-aria/i18n': 3.10.2(react@18.2.0) '@react-aria/interactions': 3.21.1(react@18.2.0) - '@react-aria/ssr': 3.8.0(react@18.2.0) + '@react-aria/ssr': 3.9.2(react@18.2.0) '@react-aria/utils': 3.23.2(react@18.2.0) '@react-aria/visually-hidden': 3.8.6(react@18.2.0) - '@react-stately/overlays': 3.6.3(react@18.2.0) + '@react-stately/overlays': 3.6.5(react@18.2.0) '@react-types/button': 3.9.2(react@18.2.0) - '@react-types/overlays': 3.8.3(react@18.2.0) + '@react-types/overlays': 3.8.5(react@18.2.0) '@react-types/shared': 3.21.0(react@18.2.0) '@swc/helpers': 0.5.3 react: 18.2.0 @@ -10200,7 +10267,7 @@ packages: peerDependencies: react: ^18.2.0 dependencies: - '@react-stately/overlays': 3.6.3(react@18.2.0) + '@react-stately/overlays': 3.6.5(react@18.2.0) '@react-stately/utils': 3.9.1(react@18.2.0) '@react-types/menu': 3.9.5(react@18.2.0) '@react-types/shared': 3.21.0(react@18.2.0) @@ -10331,7 +10398,7 @@ packages: peerDependencies: react: ^18.2.0 dependencies: - '@react-stately/overlays': 3.6.3(react@18.2.0) + '@react-stately/overlays': 3.6.5(react@18.2.0) '@react-stately/utils': 3.9.1(react@18.2.0) '@react-types/tooltip': 3.4.5(react@18.2.0) '@swc/helpers': 0.5.3