diff --git a/packages/components/date-input/__tests__/time-input.test.tsx b/packages/components/date-input/__tests__/time-input.test.tsx new file mode 100644 index 0000000000..d6bf5036a7 --- /dev/null +++ b/packages/components/date-input/__tests__/time-input.test.tsx @@ -0,0 +1,357 @@ +/* eslint-disable jsx-a11y/no-autofocus */ +import * as React from "react"; +import {act, fireEvent, render} from "@testing-library/react"; +import {Time, ZonedDateTime} from "@internationalized/date"; +import {TimeValue} from "@react-types/datepicker"; +import {pointerMap, triggerPress} from "@nextui-org/test-utils"; +import userEvent from "@testing-library/user-event"; + +import {TimeInput as TimeInputBase, TimeInputProps} from "../src"; + +/** + * Custom date-input to disable animations and avoid issues with react-motion and jest + */ +const TimeInput = React.forwardRef((props: TimeInputProps, ref: React.Ref) => { + return ; +}); + +TimeInput.displayName = "TimeInput"; + +describe("TimeInput", () => { + let user; + + beforeAll(() => { + user = userEvent.setup({delay: null, pointerMap}); + jest.useFakeTimers(); + }); + + 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 support autoFocus", function () { + let {getAllByRole} = render(); + + expect(document.activeElement).toBe(getAllByRole("spinbutton")[0]); + }); + + it("should pass through data attributes", function () { + let {getByTestId} = render(); + + const input = getByTestId("foo"); + + expect(input).toHaveAttribute("role", "group"); + }); + + it("should include a selected value description", function () { + let {getByRole, getAllByRole} = render(); + + let group = getByRole("group"); + + expect(group).toHaveAttribute("aria-describedby"); + + // @ts-ignore + let description = group + .getAttribute("aria-describedby") + .split(" ") + // @ts-ignore + .map((d) => document.getElementById(d).textContent) + .join(" "); + + expect(description).toBe("Selected Time: 8:45 AM"); + + let segments = getAllByRole("spinbutton"); + + expect(segments[0]).toHaveAttribute( + "aria-describedby", + group.getAttribute("aria-describedby"), + ); + + for (let segment of segments.slice(1)) { + expect(segment).not.toHaveAttribute("aria-describedby"); + } + }); + }); + + describe("Labelling", () => { + it("should support labeling", function () { + let {getAllByRole, getByText} = render(); + + let label = getByText("Time"); + + let combobox = getAllByRole("group")[0]; + + expect(combobox).toHaveAttribute("aria-labelledby", label.id); + + let segments = getAllByRole("spinbutton"); + + for (let segment of segments) { + expect(segment).toHaveAttribute("id"); + let segmentId = segment.getAttribute("id"); + + expect(segment).toHaveAttribute("aria-labelledby", `${segmentId} ${label.id}`); + } + }); + + it("should support labeling with aria-label", function () { + let {getByRole} = render(); + + let field = getByRole("group"); + + expect(field).toHaveAttribute("aria-label", "Event time"); + expect(field).toHaveAttribute("id"); + }); + + it("should support labeling with aria-labelledby", function () { + let {getByRole, getAllByRole} = render(); + + let combobox = getByRole("group"); + + expect(combobox).not.toHaveAttribute("aria-label"); + expect(combobox).toHaveAttribute("aria-labelledby", "foo"); + + let segments = getAllByRole("spinbutton"); + + for (let segment of segments) { + expect(segment).toHaveAttribute("id"); + let segmentId = segment.getAttribute("id"); + + expect(segment).toHaveAttribute("aria-labelledby", `${segmentId} foo`); + } + }); + + it("should support help text description", function () { + let {getByRole, getAllByRole} = render(); + + let group = getByRole("group"); + + expect(group).toHaveAttribute("aria-describedby"); + + const descById = group.getAttribute("aria-describedby"); + + let description = descById && document.getElementById(descById); + + expect(description).toHaveTextContent("Help text"); + + let segments = getAllByRole("spinbutton"); + + expect(segments[0]).toHaveAttribute( + "aria-describedby", + group.getAttribute("aria-describedby"), + ); + + for (let segment of segments.slice(1)) { + expect(segment).not.toHaveAttribute("aria-describedby"); + } + }); + + it("should support error message", function () { + let {getByRole, getAllByRole} = render( + , + ); + + let group = getByRole("group"); + + expect(group).toHaveAttribute("aria-describedby"); + + if (group) { + let descById = group.getAttribute("aria-describedby"); + let description = descById && document.getElementById(descById); + + expect(description).toHaveTextContent("Error message"); + + let segments = getAllByRole("spinbutton"); + + for (let segment of segments) { + expect(segment).toHaveAttribute( + "aria-describedby", + group.getAttribute("aria-describedby"), + ); + } + } + }); + }); + + describe("Events", function () { + 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 and switching segments via tab does not change focus", 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(async () => { + await user.tab(); + }); + expect(segments[1]).toHaveFocus(); + expect(onBlurSpy).not.toHaveBeenCalled(); + expect(onFocusChangeSpy).toHaveBeenCalledTimes(1); + expect(onFocusSpy).toHaveBeenCalledTimes(1); + }); + + it("should call blur when focus leaves", 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(); + await act(async () => { + await user.tab(); + }); + expect(segments[1]).toHaveFocus(); + await act(async () => { + await user.tab(); + }); + expect(segments[2]).toHaveFocus(); + expect(onBlurSpy).toHaveBeenCalledTimes(0); + await act(async () => { + await user.tab(); + }); + expect(onBlurSpy).toHaveBeenCalledTimes(1); + expect(onFocusChangeSpy).toHaveBeenCalledTimes(2); + expect(onFocusSpy).toHaveBeenCalledTimes(1); + }); + + 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(() => { + user.tab(); + }); + + expect(segments[0]).toHaveFocus(); + expect(onKeyDownSpy).not.toHaveBeenCalled(); + expect(onKeyUpSpy).toHaveBeenCalledTimes(1); + + if (document.activeElement) { + fireEvent.keyDown(document.activeElement, {key: "ArrowRight"}); + fireEvent.keyUp(document.activeElement, {key: "ArrowRight"}); + } + expect(segments[1]).toHaveFocus(); + expect(onKeyDownSpy).toHaveBeenCalledTimes(1); + expect(onKeyUpSpy).toHaveBeenCalledTimes(2); + }); + }); + + describe("Forms", () => { + it("supports form values", () => { + let {rerender} = render(); + let input = document.querySelector("input[name=time]"); + + expect(input).toHaveValue("08:30:00"); + + rerender(); + expect(input).toHaveValue("12:24:45"); + + rerender( + , + ); + + expect(input).toHaveValue("12:24:45"); + }); + + it("supports form reset", async () => { + function Test() { + let [value, setValue] = React.useState(new Time(8, 30)); + + return ( +
+ + + + ); + } + + let {getByTestId, getByRole, getAllByRole} = render(); + let group = getByRole("group"); + let input = document.querySelector("input[name=time]"); + let segments = getAllByRole("spinbutton"); + + let getDescription = () => + // @ts-ignore + group + .getAttribute("aria-describedby") + .split(" ") + // @ts-ignore + .map((d) => document.getElementById(d).textContent) + .join(" "); + + expect(getDescription()).toBe("Selected Time: 8:30 AM"); + + expect(input).toHaveValue("08:30:00"); + expect(input).toHaveAttribute("name", "time"); + fireEvent.keyDown(segments[0], {key: "ArrowUp"}); + fireEvent.keyUp(segments[0], {key: "ArrowUp"}); + expect(getDescription()).toBe("Selected Time: 9:30 AM"); + expect(input).toHaveValue("09:30:00"); + + let button = getByTestId("reset"); + + triggerPress(button); + + expect(getDescription()).toBe("Selected Time: 8:30 AM"); + expect(input).toHaveValue("08:30:00"); + }); + }); +}); diff --git a/packages/components/date-input/src/index.ts b/packages/components/date-input/src/index.ts index c8a03679a1..b05ef7a36c 100644 --- a/packages/components/date-input/src/index.ts +++ b/packages/components/date-input/src/index.ts @@ -1,10 +1,15 @@ import DateInput from "./date-input"; +import TimeInput from "./time-input"; // export types export type {DateInputProps} from "./date-input"; +export type {TimeInputProps} from "./time-input"; +export type {DateValue} from "@react-types/datepicker"; +export type {TimeValue} from "@react-types/datepicker"; // export hooks export {useDateInput} from "./use-date-input"; +export {useTimeInput} from "./use-time-input"; -// export component -export {DateInput}; +// export components +export {DateInput, TimeInput}; diff --git a/packages/components/date-input/src/time-input.tsx b/packages/components/date-input/src/time-input.tsx new file mode 100644 index 0000000000..ccec206139 --- /dev/null +++ b/packages/components/date-input/src/time-input.tsx @@ -0,0 +1,104 @@ +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"; + +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], + ); + + return ( + + {shouldLabelBeOutside ? labelContent : null} +
+ {!shouldLabelBeOutside ? labelContent : null} +
+ {startContent} + {inputContent} + {endContent} +
+ {shouldLabelBeOutside ? helperWrapper : null} +
+ {!shouldLabelBeOutside ? helperWrapper : null} +
+ ); +} + +TimeInput.displayName = "NextUI.TimeInput"; + +export type TimeInputProps = Props & {ref?: Ref}; + +// forwardRef doesn't support generic parameters, so cast the result to the correct type +export default forwardRef(TimeInput) as ( + props: TimeInputProps, +) => ReactElement; diff --git a/packages/components/date-input/src/use-date-input.ts b/packages/components/date-input/src/use-date-input.ts index 08d0a19a31..f3157e9a1d 100644 --- a/packages/components/date-input/src/use-date-input.ts +++ b/packages/components/date-input/src/use-date-input.ts @@ -1,5 +1,5 @@ import type {DateInputVariantProps, DateInputSlots, SlotsToClasses} from "@nextui-org/theme"; -import type {AriaDatePickerProps} from "@react-types/datepicker"; +import type {AriaDateFieldProps} 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"; @@ -20,7 +20,7 @@ import {useMemo} from "react"; type NextUIBaseProps = Omit< HTMLNextUIProps<"div">, - keyof AriaDatePickerProps | "onChange" + keyof AriaDateFieldProps | "onChange" >; interface Props extends NextUIBaseProps { @@ -108,7 +108,7 @@ interface Props extends NextUIBaseProps { export type UseDateInputProps = Props & DateInputVariantProps & - AriaDatePickerProps; + AriaDateFieldProps; export function useDateInput(originalProps: UseDateInputProps) { const [props, variantProps] = mapPropsVariants(originalProps, dateInput.variantKeys); diff --git a/packages/components/date-input/src/use-time-input.ts b/packages/components/date-input/src/use-time-input.ts new file mode 100644 index 0000000000..1ff2f0130a --- /dev/null +++ b/packages/components/date-input/src/use-time-input.ts @@ -0,0 +1,283 @@ +import type {DateInputVariantProps, DateInputSlots, SlotsToClasses} from "@nextui-org/theme"; +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 {useLocale} from "@react-aria/i18n"; +import {mergeProps} from "@react-aria/utils"; +import {PropGetter} from "@nextui-org/system"; +import {HTMLNextUIProps, mapPropsVariants} from "@nextui-org/system"; +import {useDOMRef} from "@nextui-org/react-utils"; +import {useTimeField as useAriaTimeField} from "@react-aria/datepicker"; +import {useTimeFieldState} from "@react-stately/datepicker"; +import {objectToDeps, clsx, dataAttr} from "@nextui-org/shared-utils"; +import {dateInput} from "@nextui-org/theme"; +import {useMemo} from "react"; + +type NextUIBaseProps = Omit< + HTMLNextUIProps<"div">, + keyof AriaTimeFieldProps | "onChange" +>; + +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. + */ + inputRef?: ReactRef; + /** + * Element to be rendered in the left side of the input. + */ + startContent?: React.ReactNode; + /** + * Element to be rendered in the right side of the input. + */ + endContent?: React.ReactNode; + /** + * Classname or List of classes to change the classNames of the element. + * if `className` is passed, it will be added to the base slot. + * + * @example + * ```ts + * + * ``` + */ + classNames?: SlotsToClasses; +} + +export type UseTimeInputProps = Props & + DateInputVariantProps & + AriaTimeFieldProps; + +export function useTimeInput(originalProps: UseTimeInputProps) { + const [props, variantProps] = mapPropsVariants(originalProps, dateInput.variantKeys); + + const { + ref, + as, + label, + inputRef: inputRefProp, + description, + startContent, + endContent, + className, + classNames, + validationState, + groupProps = {}, + labelProps: labelPropsProp, + fieldProps: fieldPropsProp, + errorMessageProps: errorMessagePropsProp, + descriptionProps: descriptionPropsProp, + validationBehavior = "native", + shouldForceLeadingZeros = true, + minValue, + maxValue, + isInvalid: isInvalidProp = validationState ? validationState === "invalid" : false, + errorMessage: errorMessageProp, + } = props; + + const domRef = useDOMRef(ref); + const inputRef = useDOMRef(inputRefProp); + + const Component = as || "div"; + + const {locale} = useLocale(); + const state = useTimeFieldState({ + ...originalProps, + label, + locale, + minValue, + maxValue, + isInvalid: isInvalidProp, + shouldForceLeadingZeros, + }); + + const { + labelProps, + fieldProps, + inputProps, + validationErrors, + validationDetails, + descriptionProps, + errorMessageProps, + isInvalid: ariaIsInvalid, + } = useAriaTimeField({...originalProps, label, validationBehavior, inputRef}, state, domRef); + + const baseStyles = clsx(classNames?.base, className); + + 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") && + !props.label + ) { + return "outside"; + } + + return originalProps.labelPlacement ?? "inside"; + }, [originalProps.labelPlacement, props.label]); + + const shouldLabelBeOutside = labelPlacement === "outside" || labelPlacement === "outside-left"; + + const slots = useMemo( + () => + dateInput({ + ...variantProps, + labelPlacement, + className, + }), + [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), + "data-slot": "label", + className: slots.label({ + class: clsx(classNames?.label, props?.className), + }), + }; + }; + + const getInputProps: PropGetter = (props) => { + return { + ...props, + ...inputProps, + ref: inputRef, + }; + }; + + const getFieldProps: PropGetter = (props) => { + return { + ref: domRef, + "data-slot": "input", + ...mergeProps(fieldProps, fieldPropsProp, props), + className: slots.input({ + class: clsx(classNames?.input, props?.className), + }), + }; + }; + + const getInputWrapperProps: PropGetter = (props) => { + return { + ...props, + ...groupProps, + "data-slot": "input-wrapper", + className: slots.inputWrapper({ + class: classNames?.inputWrapper, + }), + onClick: fieldProps.onClick, + }; + }; + + const getInnerWrapperProps: PropGetter = (props) => { + return { + ...props, + "data-slot": "inner-wrapper", + className: slots.innerWrapper({ + class: classNames?.innerWrapper, + }), + }; + }; + + const getHelperWrapperProps: PropGetter = (props) => { + return { + ...props, + "data-slot": "helper-wrapper", + className: slots.helperWrapper({ + class: clsx(classNames?.helperWrapper, props?.className), + }), + }; + }; + + const getErrorMessageProps: PropGetter = (props = {}) => { + return { + ...mergeProps(errorMessageProps, errorMessagePropsProp, props), + "data-slot": "error-message", + className: slots.errorMessage({class: clsx(classNames?.errorMessage, props?.className)}), + }; + }; + + const getDescriptionProps: PropGetter = (props = {}) => { + return { + ...mergeProps(descriptionProps, descriptionPropsProp, props), + "data-slot": "description", + className: slots.description({class: clsx(classNames?.description, props?.className)}), + }; + }; + + return { + Component, + state, + domRef, + slots, + label, + hasHelper, + shouldLabelBeOutside, + classNames, + description, + errorMessage, + labelPlacement, + startContent, + endContent, + getBaseProps, + getLabelProps, + getFieldProps, + getInputProps, + getInputWrapperProps, + getInnerWrapperProps, + getHelperWrapperProps, + getErrorMessageProps, + getDescriptionProps, + }; +} + +export type UseTimeInputReturn = ReturnType; diff --git a/packages/components/date-input/stories/time-input.stories.tsx b/packages/components/date-input/stories/time-input.stories.tsx new file mode 100644 index 0000000000..95a38c4790 --- /dev/null +++ b/packages/components/date-input/stories/time-input.stories.tsx @@ -0,0 +1,284 @@ +import React from "react"; +import {Meta} from "@storybook/react"; +import {dateInput} from "@nextui-org/theme"; +import {ClockCircleLinearIcon} from "@nextui-org/shared-icons"; +import { + parseAbsoluteToLocal, + parseZonedDateTime, + Time, + ZonedDateTime, +} from "@internationalized/date"; +import {useDateFormatter} from "@react-aria/i18n"; + +import {TimeInput, TimeInputProps, TimeValue} from "../src"; + +export default { + title: "Components/TimeInput", + component: TimeInput, + 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", + }, + }, + }, +} as Meta; + +const defaultProps = { + label: "Event Time", + ...dateInput.defaultVariants, +}; + +const Template = (args: TimeInputProps) => ; + +export const Default = { + render: Template, + args: { + ...defaultProps, + }, +}; + +const LabelPlacementTemplate = (args: TimeInputProps) => ( +
+ + + +
+); + +const ControlledTemplate = (args: TimeInputProps) => { + let [value, setValue] = React.useState(parseAbsoluteToLocal("2024-04-08T18:45:22Z")); + + let formatter = useDateFormatter({dateStyle: "short", timeStyle: "long"}); + + return ( +
+
+ +

+ {value instanceof ZonedDateTime + ? (value?.toDate && formatter.format(value.toDate())) || + (value && value.toString()) || + "--" + : ""} +

+
+ + +
+ ); +}; + +const TimeZonesTemplate = (args: TimeInputProps) => ( +
+ + +
+); + +const GranularityTemplate = (args: TimeInputProps) => { + let [date, setDate] = React.useState(parseAbsoluteToLocal("2021-04-07T18:45:22Z")); + + return ( +
+ + + +
+ ); +}; + +export const Required = { + render: Template, + args: { + ...defaultProps, + isRequired: true, + }, +}; + +export const Disabled = { + render: Template, + args: { + ...defaultProps, + isDisabled: true, + defaultValue: new Time(11, 45), + }, +}; + +export const ReadOnly = { + render: Template, + args: { + ...defaultProps, + isReadOnly: true, + defaultValue: new Time(11, 45), + }, +}; + +export const WithoutLabel = { + render: Template, + + args: { + ...defaultProps, + label: null, + "aria-label": "Event Time", + }, +}; + +export const WithDescription = { + render: Template, + + args: { + ...defaultProps, + description: "Please enter your birth date", + }, +}; + +export const LabelPlacement = { + render: LabelPlacementTemplate, + + args: { + ...defaultProps, + }, +}; + +export const StartContent = { + render: Template, + + args: { + ...defaultProps, + labelPlacement: "outside", + startContent: ( + + ), + }, +}; + +export const EndContent = { + render: Template, + + args: { + ...defaultProps, + labelPlacement: "outside", + endContent: ( + + ), + }, +}; + +export const Controlled = { + render: ControlledTemplate, + + args: { + ...defaultProps, + variant: "bordered", + }, +}; + +export const TimeZones = { + render: TimeZonesTemplate, + + args: { + ...defaultProps, + label: "Event time", + defaultValue: parseZonedDateTime("2022-11-07T00:45[America/Los_Angeles]"), + }, +}; + +export const Granularity = { + render: GranularityTemplate, + + args: { + ...defaultProps, + }, +}; + +export const MinDateValue = { + render: Template, + + args: { + ...defaultProps, + minValue: new Time(9), + defaultValue: new Time(8), + }, +}; + +export const MaxDateValue = { + render: Template, + + args: { + ...defaultProps, + maxValue: new Time(17), + defaultValue: new Time(18), + }, +}; + +export const PlaceholderValue = { + render: Template, + + args: { + ...defaultProps, + label: "Meeting time", + placeholderValue: new Time(9), + }, +}; + +export const HideTimeZone = { + render: Template, + + args: { + ...defaultProps, + label: "Meeting time", + hideTimeZone: true, + defaultValue: parseZonedDateTime("2022-11-07T10:45[America/Los_Angeles]"), + }, +}; + +export const HourCycle = { + render: Template, + + args: { + ...defaultProps, + label: "Meeting time", + hourCycle: 24, + defaultValue: parseZonedDateTime("2022-11-07T00:45[America/Los_Angeles]"), + granularity: "minute", + }, +}; diff --git a/packages/components/date-picker/intl/messages.ts b/packages/components/date-picker/intl/messages.ts new file mode 100644 index 0000000000..9f789606ad --- /dev/null +++ b/packages/components/date-picker/intl/messages.ts @@ -0,0 +1,173 @@ +export default { + "ar-AE": { + endTime: "وقت الانتهاء", + startTime: "وقت البدء", + time: "الوقت", + }, + "bg-BG": { + endTime: "Краен час", + startTime: "Начален час", + time: "Време", + }, + "cs-CZ": { + endTime: "Konečný čas", + startTime: "Počáteční čas", + time: "Čas", + }, + + "da-DK": { + endTime: "Sluttidspunkt", + startTime: "Starttidspunkt", + time: "Klokkeslæt", + }, + "de-DE": { + endTime: "Endzeit", + startTime: "Startzeit", + time: "Uhrzeit", + }, + "el-GR": { + endTime: "Χρόνος λήξης", + startTime: "Ώρα έναρξης", + time: "Χρόνος", + }, + "en-US": { + time: "Time", + startTime: "Start time", + endTime: "End time", + }, + "es-ES": { + endTime: "Hora de finalización", + startTime: "Hora de inicio", + time: "Hora", + }, + "et-EE": { + endTime: "Lõpuaeg", + startTime: "Algusaeg", + time: "Aeg", + }, + "fi-FI": { + endTime: "Päättymisaika", + startTime: "Alkamisaika", + time: "Aika", + }, + "fr-FR": { + endTime: "Heure de fin", + startTime: "Heure de début", + time: "Heure", + }, + "he-IL": { + endTime: "שעת סיום", + startTime: "שעת התחלה", + time: "זמן", + }, + "hr-HR": { + endTime: "Vrijeme završetka", + startTime: "Vrijeme početka", + time: "Vrijeme", + }, + "hu-HU": { + endTime: "Befejezés ideje", + startTime: "Kezdés ideje", + time: "Idő", + }, + "it-IT": { + endTime: "Ora di fine", + startTime: "Ora di inizio", + time: "Ora", + }, + "ja-JP": { + endTime: "終了時刻", + startTime: "開始時刻", + time: "時刻", + }, + "ko-KR": { + endTime: "종료 시간", + startTime: "시작 시간", + time: "시간", + }, + "it-LT": { + endTime: "Pabaigos laikas", + startTime: "Pradžios laikas", + time: "Laikas", + }, + "lv-LV": { + endTime: "Beigu laiks", + startTime: "Sākuma laiks", + time: "Laiks", + }, + "nb-NO": { + endTime: "Sluttid", + startTime: "Starttid", + time: "Tid", + }, + "nl-NL": { + endTime: "Eindtijd", + startTime: "Starttijd", + time: "Tijd", + }, + "pl-PL": { + endTime: "Godzina końcowa", + startTime: "Godzina początkowa", + time: "Godzina", + }, + "pt-BR": { + endTime: "Hora final", + startTime: "Hora inicial", + time: "Hora", + }, + "pt-PT": { + endTime: "Terminar tempo", + startTime: "Iniciar tempo", + time: "Tempo", + }, + "ro-RO": { + endTime: "Ora de sfârșit", + startTime: "Ora de început", + time: "Ora", + }, + "ru-RU": { + endTime: "Время окончания", + startTime: "Время начала", + time: "Время", + }, + "sk-SK": { + endTime: "Čas ukončenia", + startTime: "Čas začiatku", + time: "Čas", + }, + "sl-SI": { + endTime: "Končni čas", + startTime: "Začetni čas", + time: "Čas", + }, + "sr-SP": { + endTime: "Završno vreme", + startTime: "Početno vreme", + time: "Vreme", + }, + "sv-SE": { + endTime: "Sluttid", + startTime: "Starttid", + time: "Tid", + }, + "tr-TR": { + endTime: "Bitiş saati", + startTime: "Başlangıç saati", + time: "Saat", + }, + "uk-UA": { + endTime: "Час завершення", + startTime: "Час початку", + time: "Час", + }, + "zh-CN": { + endTime: "结束时间", + startTime: "开始时间", + time: "时间", + }, + "zh-TW": { + endTime: "結束時間", + startTime: "開始時間", + time: "時間", + }, +}; diff --git a/packages/components/date-picker/src/date-picker.tsx b/packages/components/date-picker/src/date-picker.tsx index ac29e03bde..9ff90198ee 100644 --- a/packages/components/date-picker/src/date-picker.tsx +++ b/packages/components/date-picker/src/date-picker.tsx @@ -1,10 +1,10 @@ import type {DateValue} from "@internationalized/date"; -import type {ForwardedRef, ReactElement, Ref} from "react"; +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 {DateInput} from "@nextui-org/date-input"; +import {DateInput, TimeInput} from "@nextui-org/date-input"; import {FreeSoloPopover} from "@nextui-org/popover"; import {Calendar} from "@nextui-org/calendar"; import {AnimatePresence} from "framer-motion"; @@ -20,9 +20,12 @@ function DatePicker(props: Props, ref: ForwardedRef(props: Props, ref: ForwardedRef ); + const calendarBottomContent = useMemo(() => { + if (isCalendarHeaderExpanded) return null; + + return showTimeField ? ( + <> + + {CalendarBottomContent} + + ) : ( + CalendarBottomContent + ); + }, [showTimeField, CalendarBottomContent, isCalendarHeaderExpanded]); + + const calendarTopContent = useMemo(() => { + if (isCalendarHeaderExpanded) return null; + + return CalendarTopContent; + }, [showTimeField, CalendarTopContent, isCalendarHeaderExpanded]); + const popoverContent = state.isOpen ? ( ) : null; diff --git a/packages/components/date-picker/src/use-date-picker.ts b/packages/components/date-picker/src/use-date-picker.ts index 3ce116fede..38adeffcd8 100644 --- a/packages/components/date-picker/src/use-date-picker.ts +++ b/packages/components/date-picker/src/use-date-picker.ts @@ -1,10 +1,11 @@ import type {DateValue} from "@internationalized/date"; import type {AriaDatePickerProps} from "@react-types/datepicker"; -import type {DateInputProps} from "@nextui-org/date-input"; +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 { DatePickerVariantProps, @@ -12,7 +13,7 @@ import { SlotsToClasses, dateInput, } from "@nextui-org/theme"; -import {ReactNode} from "react"; +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"; @@ -21,7 +22,9 @@ 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"; +import {useLocalizedStringFormatter} from "@react-aria/i18n"; + +import intlMessages from "../intl/messages"; type NextUIBaseProps = Omit< HTMLNextUIProps<"div">, @@ -81,6 +84,13 @@ interface Props extends NextUIBaseProps { * @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. */ @@ -124,6 +134,8 @@ export type UseDatePickerProps = Props & export function useDatePicker(originalProps: UseDatePickerProps) { const [props, variantProps] = mapPropsVariants(originalProps, dateInput.variantKeys); + const [isCalendarHeaderExpanded, setIsCalendarHeaderExpanded] = useState(false); + const { as, ref, @@ -144,6 +156,7 @@ export function useDatePicker(originalProps: UseDatePickerP shouldForceLeadingZeros, showMonthAndYearPickers = false, popoverProps = {}, + timeInputProps = {}, selectorButtonProps = {}, calendarProps: userCalendarProps = {}, CalendarTopContent, @@ -178,9 +191,23 @@ export function useDatePicker(originalProps: UseDatePickerP 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; + const timeGranularity = + state.granularity === "hour" || state.granularity === "minute" || state.granularity === "second" + ? state.granularity + : null; + + const showTimeField = !!timeGranularity; + const slotsProps: { popoverProps: UseDatePickerProps["popoverProps"]; selectorButtonProps: ButtonProps; @@ -212,6 +239,7 @@ export function useDatePicker(originalProps: UseDatePickerP pageBehavior, isDateUnavailable, showMonthAndYearPickers, + onHeaderExpandedChange: setIsCalendarHeaderExpanded, color: (originalProps.variant === "bordered" || originalProps.variant === "underlined") && isDefaultColor @@ -263,7 +291,34 @@ export function useDatePicker(originalProps: UseDatePickerP "data-open": dataAttr(state.isOpen), className: slots.base({class: baseStyles}), classNames, - } as unknown as DateInputProps; + } as DateInputProps; + }; + + const getTimeInputProps = () => { + if (!showTimeField) return {}; + + return { + ...timeInputProps, + size: "sm", + labelPlacement: "outside-left", + classNames: { + base: slots.timeInput({ + class: clsx(classNames?.timeInput, timeInputProps?.classNames?.base), + }), + label: slots.timeInputLabel({ + class: clsx(classNames?.timeInputLabel, timeInputProps?.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; }; const getPopoverProps = (props: DOMAttributes = {}) => { @@ -326,6 +381,8 @@ export function useDatePicker(originalProps: UseDatePickerP state, endContent, selectorIcon, + showTimeField, + isCalendarHeaderExpanded, disableAnimation, CalendarTopContent, CalendarBottomContent, @@ -333,6 +390,7 @@ export function useDatePicker(originalProps: UseDatePickerP getPopoverProps, getSelectorButtonProps, getCalendarProps, + getTimeInputProps, getSelectorIconProps, }; } diff --git a/packages/components/date-picker/stories/date-picker.stories.tsx b/packages/components/date-picker/stories/date-picker.stories.tsx index 2025895a1d..0118eabc8e 100644 --- a/packages/components/date-picker/stories/date-picker.stories.tsx +++ b/packages/components/date-picker/stories/date-picker.stories.tsx @@ -305,6 +305,17 @@ export const WithMonthAndYearPickers = { }, }; +export const WithTimeField = { + render: Template, + args: { + ...defaultProps, + label: "Event date", + hideTimeZone: true, + showMonthAndYearPickers: true, + defaultValue: now(getLocalTimeZone()), + }, +}; + export const LabelPlacement = { render: LabelPlacementTemplate, diff --git a/packages/components/popover/src/free-solo-popover.tsx b/packages/components/popover/src/free-solo-popover.tsx index 9af4ab15cf..edab8f19ec 100644 --- a/packages/components/popover/src/free-solo-popover.tsx +++ b/packages/components/popover/src/free-solo-popover.tsx @@ -19,17 +19,48 @@ import {usePopover, UsePopoverProps, UsePopoverReturn} from "./use-popover"; export interface FreeSoloPopoverProps extends Omit { children: React.ReactNode | ((titleProps: React.DOMAttributes) => React.ReactNode); + transformOrigin?: { + originX?: number; + originY?: number; + }; } type FreeSoloPopoverWrapperProps = { children: React.ReactNode; disableAnimation: boolean; + transformOrigin?: FreeSoloPopoverProps["transformOrigin"]; placement: UsePopoverReturn["placement"]; motionProps?: UsePopoverProps["motionProps"]; } & React.HTMLAttributes; const FreeSoloPopoverWrapper = forwardRef<"div", FreeSoloPopoverWrapperProps>( - ({children, motionProps, placement, disableAnimation, style = {}, ...otherProps}, ref) => { + ( + { + children, + motionProps, + placement, + disableAnimation, + style: styleProp = {}, + transformOrigin = {}, + ...otherProps + }, + ref, + ) => { + let style = styleProp; + + if (transformOrigin.originX !== undefined || transformOrigin.originY !== undefined) { + style = { + ...style, + // @ts-ignore + transformOrigin, + }; + } else { + style = { + ...style, + ...getTransformOrigins(placement === "center" ? "top" : placement), + }; + } + return disableAnimation ? (
{children} @@ -41,10 +72,7 @@ const FreeSoloPopoverWrapper = forwardRef<"div", FreeSoloPopoverWrapperProps>( animate="enter" exit="exit" initial="initial" - style={{ - ...style, - ...getTransformOrigins(placement === "center" ? "top" : placement), - }} + style={style} variants={TRANSITION_VARIANTS.scaleSpringOpacity} {...mergeProps(otherProps, motionProps)} > @@ -57,69 +85,72 @@ const FreeSoloPopoverWrapper = forwardRef<"div", FreeSoloPopoverWrapperProps>( FreeSoloPopoverWrapper.displayName = "NextUI.FreeSoloPopoverWrapper"; -const FreeSoloPopover = forwardRef<"div", FreeSoloPopoverProps>(({children, ...props}, ref) => { - const { - Component, - state, - placement, - backdrop, - titleProps, - portalContainer, - disableAnimation, - motionProps, - isNonModal, - getPopoverProps, - getBackdropProps, - getDialogProps, - getContentProps, - } = usePopover({ - ...props, - ref, - }); +const FreeSoloPopover = forwardRef<"div", FreeSoloPopoverProps>( + ({children, transformOrigin, ...props}, ref) => { + const { + Component, + state, + placement, + backdrop, + titleProps, + portalContainer, + disableAnimation, + motionProps, + isNonModal, + getPopoverProps, + getBackdropProps, + getDialogProps, + getContentProps, + } = usePopover({ + ...props, + ref, + }); - const backdropContent = React.useMemo(() => { - if (backdrop === "transparent") { - return null; - } + const backdropContent = React.useMemo(() => { + if (backdrop === "transparent") { + return null; + } - if (disableAnimation) { - return
; - } + if (disableAnimation) { + return
; + } + + return ( + + )} + /> + + ); + }, [backdrop, disableAnimation, getBackdropProps]); return ( - - )} - /> - + + {!isNonModal && backdropContent} + + + {!isNonModal && } +
+ {typeof children === "function" ? children(titleProps) : children} +
+ +
+
+
); - }, [backdrop, disableAnimation, getBackdropProps]); - - return ( - - {!isNonModal && backdropContent} - - - {!isNonModal && } -
- {typeof children === "function" ? children(titleProps) : children} -
- -
-
-
- ); -}); + }, +); FreeSoloPopover.displayName = "NextUI.FreeSoloPopover"; diff --git a/packages/core/theme/src/components/date-picker.ts b/packages/core/theme/src/components/date-picker.ts index 9ebf4d7b6d..32566786a3 100644 --- a/packages/core/theme/src/components/date-picker.ts +++ b/packages/core/theme/src/components/date-picker.ts @@ -15,6 +15,8 @@ const datePicker = tv({ popoverContent: "p-0 w-full", calendar: "w-[var(--calendar-width)] shadow-none", calendarContent: "w-[var(--calendar-width)]", + timeInputLabel: "font-medium", + timeInput: "px-5 pb-4 flex-wrap gap-x-6", }, variants: { // @internal diff --git a/packages/utilities/shared-icons/src/bold/clock-square-bold.tsx b/packages/utilities/shared-icons/src/bold/clock-square-bold.tsx new file mode 100644 index 0000000000..b338539504 --- /dev/null +++ b/packages/utilities/shared-icons/src/bold/clock-square-bold.tsx @@ -0,0 +1,32 @@ +import {IconSvgProps} from "../types"; + +export const ClockSquareBoldIcon = (props: IconSvgProps) => ( + +); diff --git a/packages/utilities/shared-icons/src/bold/index.ts b/packages/utilities/shared-icons/src/bold/index.ts index c4437bd528..7a768df70c 100644 --- a/packages/utilities/shared-icons/src/bold/index.ts +++ b/packages/utilities/shared-icons/src/bold/index.ts @@ -11,3 +11,4 @@ export * from "./shopping-cart"; export * from "./send"; export * from "./plus"; export * from "./calendar-bold"; +export * from "./clock-square-bold"; diff --git a/packages/utilities/shared-icons/src/linear/clock-circle-linear.tsx b/packages/utilities/shared-icons/src/linear/clock-circle-linear.tsx new file mode 100644 index 0000000000..cc68de0559 --- /dev/null +++ b/packages/utilities/shared-icons/src/linear/clock-circle-linear.tsx @@ -0,0 +1,19 @@ +import {IconSvgProps} from "../types"; + +export const ClockCircleLinearIcon = (props: IconSvgProps) => ( + +); diff --git a/packages/utilities/shared-icons/src/linear/index.ts b/packages/utilities/shared-icons/src/linear/index.ts index ddfa51993e..34e9cf8f65 100644 --- a/packages/utilities/shared-icons/src/linear/index.ts +++ b/packages/utilities/shared-icons/src/linear/index.ts @@ -2,3 +2,4 @@ export * from "./check"; export * from "./copy"; export * from "./chevron-circle-top"; export * from "./search"; +export * from "./clock-circle-linear";