From 566414a6a32eef6c0567e2f23780cf38e9b73c03 Mon Sep 17 00:00:00 2001 From: mark-tate <143323+mark-tate@users.noreply.github.com> Date: Fri, 13 Dec 2024 10:55:30 +0000 Subject: [PATCH] enabled uncontrolled/un-controlled open behaviour for `DatePicker` - added `openOnClick`, `openOnKeyDown` and `openOnFocus` props to `DatePicker`. - revise the controlled behaviour of the `open` prop on `DatePickerOverlay`. - add examples for controlled and uncontrolled behaviour. --- .changeset/serious-kings-decide.md | 9 + .../date-picker/DatePicker.single.cy.tsx | 101 ++++++++++- packages/lab/src/date-picker/DatePicker.tsx | 19 ++- .../lab/src/date-picker/DatePickerOverlay.tsx | 1 + .../date-picker/DatePickerOverlayProvider.tsx | 86 +++++++--- .../src/date-picker/DatePickerRangeInput.tsx | 30 +--- .../src/date-picker/DatePickerSingleInput.tsx | 13 -- packages/lab/src/date-picker/useKeyboard.ts | 36 ++++ .../date-picker/date-picker.stories.tsx | 160 ++++++++++++++++++ site/docs/components/date-picker/examples.mdx | 16 ++ .../examples/date-picker/ControlledOpen.tsx | 126 ++++++++++++++ .../examples/date-picker/UncontrolledOpen.tsx | 61 +++++++ site/src/examples/date-picker/index.ts | 2 + 13 files changed, 588 insertions(+), 72 deletions(-) create mode 100644 .changeset/serious-kings-decide.md create mode 100644 packages/lab/src/date-picker/useKeyboard.ts create mode 100644 site/src/examples/date-picker/ControlledOpen.tsx create mode 100644 site/src/examples/date-picker/UncontrolledOpen.tsx diff --git a/.changeset/serious-kings-decide.md b/.changeset/serious-kings-decide.md new file mode 100644 index 00000000000..3bddf2257c5 --- /dev/null +++ b/.changeset/serious-kings-decide.md @@ -0,0 +1,9 @@ +--- +"@salt-ds/lab": minor +--- + +enabled uncontrolled/un-controlled open behaviour for `DatePicker` + +- added `openOnClick`, `openOnKeyDown` and `openOnFocus` props to `DatePicker`. +- revise the controlled behaviour of the `open` prop on `DatePickerOverlay`. +- add examples for controlled and uncontrolled behaviour. diff --git a/packages/lab/src/__tests__/__e2e__/date-picker/DatePicker.single.cy.tsx b/packages/lab/src/__tests__/__e2e__/date-picker/DatePicker.single.cy.tsx index 8929ed51c17..d74660a07f9 100644 --- a/packages/lab/src/__tests__/__e2e__/date-picker/DatePicker.single.cy.tsx +++ b/packages/lab/src/__tests__/__e2e__/date-picker/DatePicker.single.cy.tsx @@ -26,15 +26,17 @@ const adapters = [adapterDateFns, adapterDayjs, adapterLuxon, adapterMoment]; const { // Storybook wraps components in it's own LocalizationProvider, so do not compose Stories + ControlledOpen, Single, SingleControlled, + SingleCustomFormat, SingleWithConfirmation, SingleWithCustomPanel, SingleWithCustomParser, SingleWithFormField, SingleWithMinMaxDate, SingleWithTodayButton, - SingleCustomFormat, + UncontrolledOpen, } = datePickerStories as any; describe("GIVEN a DatePicker where selectionVariant is single", () => { @@ -336,11 +338,6 @@ describe("GIVEN a DatePicker where selectionVariant is single", () => { cy.findByRole("button", { name: "Apply" }).realClick(); // Verify that the calendar is closed and the new date is applied cy.findByRole("application").should("not.exist"); - // cy.get("@appliedDateSpy").should( - // "have.been.calledWith", - // Cypress.sinon.match.any, - // updatedDate, - // ); cy.get("@appliedDateSpy").should((spy: any) => { const [_event, date] = spy.lastCall.args; expect(adapter.isValid(date)).to.be.true; @@ -431,6 +428,74 @@ describe("GIVEN a DatePicker where selectionVariant is single", () => { updatedFormattedDateValue, ); }); + + it("SHOULD be able to enable the overlay to open on click", () => { + cy.mount( + , + ); + cy.findByRole("application").should("not.exist"); + // Simulate opening the calendar on click + cy.document().find("input").realClick(); + cy.findByRole("application").should("exist"); + // Simulate selecting a new date + cy.findByRole("button", { + name: adapter.format(updatedDate, "DD MMMM YYYY"), + }).should("exist"); + cy.findByRole("button", { + name: adapter.format(updatedDate, "DD MMMM YYYY"), + }).realClick(); + cy.findByRole("application").should("not.exist"); + cy.document() + .find("input") + .should("have.value", updatedFormattedDateValue); + }); + + it("SHOULD be able to enable the overlay to open on keydown", () => { + cy.mount( + , + ); + cy.findByRole("application").should("not.exist"); + // Simulate opening the calendar on arrow down + cy.document().find("input").realClick(); + cy.findByRole("application").should("not.exist"); + cy.realPress("ArrowDown"); + cy.findByRole("application").should("exist"); + // Simulate selecting a new date + cy.findByRole("button", { + name: adapter.format(updatedDate, "DD MMMM YYYY"), + }).should("exist"); + cy.findByRole("button", { + name: adapter.format(updatedDate, "DD MMMM YYYY"), + }).realClick(); + cy.findByRole("application").should("not.exist"); + cy.document() + .find("input") + .should("have.value", updatedFormattedDateValue); + }); + + it("SHOULD be able to enable the overlay to open on focus", () => { + cy.mount( + , + ); + cy.findByRole("application").should("not.exist"); + // Simulate opening the calendar on focus + cy.document().find("input").focus(); + cy.findByRole("application").should("exist"); + // Simulate selecting a new date + cy.findByRole("button", { + name: adapter.format(updatedDate, "DD MMMM YYYY"), + }).should("exist"); + cy.findByRole("button", { + name: adapter.format(updatedDate, "DD MMMM YYYY"), + }).realClick(); + cy.findByRole("application").should("not.exist"); + cy.document() + .find("input") + .should("have.value", updatedFormattedDateValue); + }); }); describe("controlled component", () => { @@ -515,6 +580,30 @@ describe("GIVEN a DatePicker where selectionVariant is single", () => { }); }); + it("SHOULD be able to control the overlay open state", () => { + cy.mount(); + cy.findByRole("application").should("not.exist"); + // Simulate opening the calendar + cy.document().find("input").realClick(); + cy.findByRole("application").should("not.exist"); + cy.findByRole("button", { name: "Open Calendar" }).realClick(); + cy.findByRole("application").should("exist"); + // Simulate selecting a new date + cy.findByRole("button", { + name: adapter.format(updatedDate, "DD MMMM YYYY"), + }).should("exist"); + cy.findByRole("button", { + name: adapter.format(updatedDate, "DD MMMM YYYY"), + }).realClick(); + cy.findByRole("application").should("exist"); + cy.findByRole("button", { name: "Apply" }).realClick(); + // Verify that the calendar is closed and the new date is applied + cy.findByRole("application").should("not.exist"); + cy.document() + .find("input") + .should("have.value", updatedFormattedDateValue); + }); + it("SHOULD support format prop on the input", () => { const format = "YYYY-MM-DD"; diff --git a/packages/lab/src/date-picker/DatePicker.tsx b/packages/lab/src/date-picker/DatePicker.tsx index a4668504bc7..d95d475dbab 100644 --- a/packages/lab/src/date-picker/DatePicker.tsx +++ b/packages/lab/src/date-picker/DatePicker.tsx @@ -21,6 +21,12 @@ export interface DatePickerBaseProps { children?: ReactNode; /** the open/close state of the overlay. The open/close state will be controlled when this prop is provided. */ open?: boolean; + /** When `open` is uncontrolled, set this to `true` to open on focus */ + openOnFocus?: boolean; + /** When `open` is uncontrolled, set this to `true` to open on click */ + openOnClick?: boolean; + /** When `open` is uncontrolled, set this to `true` to open on arrow key down */ + openOnKeyDown?: boolean; /** * Handler for when open state changes * @param newOpen - true when opened @@ -124,11 +130,22 @@ export const DatePickerMain = forwardRef>( export const DatePicker = forwardRef(function DatePicker< TDate extends DateFrameworkType, >(props: DatePickerProps, ref: React.Ref) { - const { open, defaultOpen, onOpen, ...rest } = props; + const { + open, + defaultOpen, + onOpen, + openOnClick, + openOnFocus, + openOnKeyDown, + ...rest + } = props; return ( diff --git a/packages/lab/src/date-picker/DatePickerOverlay.tsx b/packages/lab/src/date-picker/DatePickerOverlay.tsx index e4327331dd9..548ab541ce0 100644 --- a/packages/lab/src/date-picker/DatePickerOverlay.tsx +++ b/packages/lab/src/date-picker/DatePickerOverlay.tsx @@ -63,6 +63,7 @@ export const DatePickerOverlay = forwardRef< focusManagerProps={ floatingUIResult?.context ? { + returnFocus: false, context: floatingUIResult.context, initialFocus: 4, } diff --git a/packages/lab/src/date-picker/DatePickerOverlayProvider.tsx b/packages/lab/src/date-picker/DatePickerOverlayProvider.tsx index a9a325e5d13..478b75c0879 100644 --- a/packages/lab/src/date-picker/DatePickerOverlayProvider.tsx +++ b/packages/lab/src/date-picker/DatePickerOverlayProvider.tsx @@ -1,7 +1,11 @@ import { + type ElementProps, + type FloatingContext, type OpenChangeReason, flip, + useClick, useDismiss, + useFocus, useInteractions, } from "@floating-ui/react"; import { createContext, useControlled, useFloatingUI } from "@salt-ds/core"; @@ -9,10 +13,10 @@ import { type ReactNode, useCallback, useContext, - useEffect, useMemo, useRef, } from "react"; +import { useKeyboard } from "./useKeyboard"; /** * Interface representing the state for a DatePicker overlay. @@ -81,6 +85,18 @@ interface DatePickerOverlayProviderProps { * If `true`, the overlay is open. */ open?: boolean; + /** + * When `open` is uncontrolled, set this to `true` to open on focus + */ + openOnFocus?: boolean; + /** + * When `open` is uncontrolled, set this to `true` to open on click + */ + openOnClick?: boolean; + /** + * When `open` is uncontrolled, set this to `true` to open on arrow key down + */ + openOnKeyDown?: boolean; /** * Handler for when open state changes * @param newOpen - true when opened @@ -94,12 +110,25 @@ interface DatePickerOverlayProviderProps { * The content to be rendered inside the overlay provider. */ children: ReactNode; + /** + * A factory method to create a set of interaction, if provided overrides the default interactions + */ + interactions?: (context: FloatingContext) => Array; } export const DatePickerOverlayProvider: React.FC< DatePickerOverlayProviderProps -> = ({ open: openProp, defaultOpen, onOpen, children }) => { - const [open, setOpenState] = useControlled({ +> = ({ + open: openProp, + openOnClick, + openOnFocus, + openOnKeyDown, + defaultOpen, + onOpen, + children, + interactions, +}) => { + const [open, setOpenState, isOpenControlled] = useControlled({ controlled: openProp, default: Boolean(defaultOpen), name: "DatePicker", @@ -108,32 +137,26 @@ export const DatePickerOverlayProvider: React.FC< const triggeringElement = useRef(null); const onDismissCallback = useRef<() => void>(); - useEffect(() => { - if (!open) { - const trigger = triggeringElement.current as HTMLElement; - if (trigger) { - trigger.focus(); - } - if (trigger instanceof HTMLInputElement) { - setTimeout(() => { - trigger.setSelectionRange(0, trigger.value.length); - }, 0); - } - triggeringElement.current = null; - } - }, [open]); - const setOpen = useCallback( - ( - newOpen: boolean, - _event?: Event | undefined, - reason?: OpenChangeReason | undefined, - ) => { + (newOpen: boolean, _event?: Event, reason?: OpenChangeReason) => { if (newOpen) { triggeringElement.current = document.activeElement as HTMLElement; + } else if (!isOpenControlled) { + const trigger = triggeringElement.current as HTMLElement; + if (trigger) { + trigger.focus(); + } + if (trigger instanceof HTMLInputElement) { + setTimeout(() => { + trigger.setSelectionRange(0, trigger.value.length); + }, 1); + } + triggeringElement.current = null; } + setOpenState(newOpen); onOpen?.(newOpen); + if ( reason === "escape-key" || (reason === "outside-press" && onDismissCallback.current) @@ -154,7 +177,22 @@ export const DatePickerOverlayProvider: React.FC< const { getFloatingProps: _getFloatingPropsCallback, getReferenceProps: _getReferenceProps, - } = useInteractions([useDismiss(floatingUIResult.context)]); + } = useInteractions( + interactions + ? interactions(floatingUIResult.context) + : [ + useDismiss(floatingUIResult.context), + useFocus(floatingUIResult.context, { + enabled: !!openOnFocus, + }), + useKeyboard(floatingUIResult.context, { enabled: !!openOnKeyDown }), + useClick(floatingUIResult.context, { + enabled: !!openOnClick, + toggle: false, + }), + ], + ); + const getFloatingPropsCallback = useMemo( () => _getFloatingPropsCallback, [_getFloatingPropsCallback], diff --git a/packages/lab/src/date-picker/DatePickerRangeInput.tsx b/packages/lab/src/date-picker/DatePickerRangeInput.tsx index 506f1fcb5ec..1d940814052 100644 --- a/packages/lab/src/date-picker/DatePickerRangeInput.tsx +++ b/packages/lab/src/date-picker/DatePickerRangeInput.tsx @@ -6,8 +6,6 @@ import { } from "@salt-ds/date-adapters"; import { clsx } from "clsx"; import { - type KeyboardEvent, - type KeyboardEventHandler, type SyntheticEvent, forwardRef, useCallback, @@ -121,9 +119,8 @@ export const DatePickerRangeInput = forwardRef(function DatePickerRangeInput< const { dateAdapter } = useLocalization(); const { className, - endInputProps: endInputPropsProp, - startInputProps: startInputPropsProp, - onKeyDown, + endInputProps, + startInputProps, defaultValue, format, value: valueProp, @@ -193,29 +190,6 @@ export const DatePickerRangeInput = forwardRef(function DatePickerRangeInput< } }, [cancelled]); - const startInputProps: { - onKeyDown: KeyboardEventHandler; - } = { - onKeyDown: (event: KeyboardEvent) => { - if (event.key === "ArrowDown") { - setOpen(true); - } - startInputPropsProp?.onKeyDown?.(event); - }, - ...startInputPropsProp, - }; - const endInputProps: { - onKeyDown: KeyboardEventHandler; - } = { - onKeyDown: (event: KeyboardEvent) => { - if (event.key === "ArrowDown") { - setOpen(true); - } - endInputPropsProp?.onKeyDown?.(event); - }, - ...endInputPropsProp, - }; - return ( ) => { - if (event.key === "ArrowDown") { - setOpen(true); - onKeyDown?.(event); - } - }, - [onKeyDown], - ); - // biome-ignore lint/correctness/useExhaustiveDependencies: should run when open changes and not selected date or value useEffect(() => { if (open) { @@ -198,7 +186,6 @@ export const DatePickerSingleInput = forwardRef< } - onKeyDown={handleOnKeyDown} {...rest} /> ); diff --git a/packages/lab/src/date-picker/useKeyboard.ts b/packages/lab/src/date-picker/useKeyboard.ts new file mode 100644 index 00000000000..5c93c315277 --- /dev/null +++ b/packages/lab/src/date-picker/useKeyboard.ts @@ -0,0 +1,36 @@ +import type { ElementProps, FloatingContext } from "@floating-ui/react"; +import { useMemo } from "react"; + +export interface UseKeyboardProps { + /** + * Whether the hook is enabled + * @default true + */ + enabled?: boolean; +} + +/** + * Floating UI Interactions hook, that will open DatePicker on keydown + * @param context + * @param props + */ +export function useKeyboard( + context: FloatingContext, + props: UseKeyboardProps, +): ElementProps { + const { onOpenChange } = context; + const { enabled = true } = props; + const reference: ElementProps["reference"] = useMemo( + () => ({ + onKeyDown(event) { + if (event.key === "ArrowDown") { + event.preventDefault(); + onOpenChange(true, event.nativeEvent, "reference-press"); + } + }, + }), + [onOpenChange], + ); + + return useMemo(() => (enabled ? { reference } : {}), [enabled, reference]); +} diff --git a/packages/lab/stories/date-picker/date-picker.stories.tsx b/packages/lab/stories/date-picker/date-picker.stories.tsx index 1e82c8fe3b4..44e6c7e176e 100644 --- a/packages/lab/stories/date-picker/date-picker.stories.tsx +++ b/packages/lab/stories/date-picker/date-picker.stories.tsx @@ -1,3 +1,4 @@ +import type { OpenChangeReason } from "@floating-ui/react"; import { Button, Divider, @@ -8,6 +9,7 @@ import { FormFieldLabel as FormLabel, StackLayout, Text, + ToggleButton, } from "@salt-ds/core"; import { DateDetailErrorEnum, @@ -2681,3 +2683,161 @@ WithExperimentalTime.parameters = { }, }, }; + +export const UncontrolledOpen: StoryFn< + DatePickerSingleProps +> = ({ selectionVariant, defaultSelectedDate, ...args }) => { + const [openOnClick, setOpenOnClick] = useState(false); + const [openOnKeyDown, setOpenOnKeyDown] = useState(false); + const [openOnFocus, setOpenOnFocus] = useState(false); + return ( + + + + setOpenOnFocus(event.currentTarget.value === "true") + } + > + Open On Focus + + + setOpenOnClick(event.currentTarget.value === "true") + } + > + Open On Click + + + setOpenOnKeyDown(event.currentTarget.value === "true") + } + > + Open On Key Down + + + + + + + + + + + + ); +}; + +export const ControlledOpen: StoryFn< + DatePickerSingleProps +> = ({ selectionVariant, defaultSelectedDate, ...args }) => { + const [open, setOpen] = useState(false); + const [selectedDate, setSelectedDate] = useState< + SingleDateSelection | null | undefined + >(defaultSelectedDate ?? null); + const { dateAdapter } = useLocalization(); + const triggerRef = useRef(null); + const applyButtonRef = useRef(null); + const datePickerRef = useRef(null); + + const handleSelectionChange = useCallback( + ( + _event: SyntheticEvent, + date: SingleDateSelection | null, + _details: DateInputSingleDetails | undefined, + ) => { + setSelectedDate(date ?? null); + }, + [dateAdapter], + ); + + const handleApply = useCallback( + ( + event: SyntheticEvent, + date: SingleDateSelection | null, + ) => { + console.log( + `Applied StartDate: ${date ? dateAdapter.format(date, "DD MMM YYYY") : date}`, + ); + setSelectedDate(date); + setOpen(false); + }, + [dateAdapter], + ); + + const handleOpen = useCallback( + ( + newOpen: boolean, + _event?: Event | undefined, + reason?: OpenChangeReason | undefined, + ) => { + if (!newOpen && reason === undefined) { + triggerRef?.current?.focus(); + setTimeout(() => { + triggerRef?.current?.setSelectionRange( + 0, + triggerRef.current.value.length, + ); + }, 1); + } + setOpen(newOpen); + }, + [], + ); + + return ( + + + { + setOpen(event.currentTarget.value === "true"); + }} + > + Open + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/site/docs/components/date-picker/examples.mdx b/site/docs/components/date-picker/examples.mdx index 28f59328b09..8cd1d391fb0 100644 --- a/site/docs/components/date-picker/examples.mdx +++ b/site/docs/components/date-picker/examples.mdx @@ -170,5 +170,21 @@ A `DatePicker` component with a border provides a visually distinct area for sel + + +## Uncontrolled open + +By default, the overlay's open state is uncontrolled and opens only when the calendar button is used. However, it can also be configured to open using the `openOnClick`, `openOnKeyDown` and `openOnFocus` props. + + + + + +## Controlled open + +By default, the overlay's open state is uncontrolled and opens only when the calendar button is pressed. However, you can fully control the overlay's open behavior using the `open` prop. When you manage the open state, you also take responsibility for handling the input's focus behavior after a selection is made. + + + ``` diff --git a/site/src/examples/date-picker/ControlledOpen.tsx b/site/src/examples/date-picker/ControlledOpen.tsx new file mode 100644 index 00000000000..026876710c0 --- /dev/null +++ b/site/src/examples/date-picker/ControlledOpen.tsx @@ -0,0 +1,126 @@ +import type { OpenChangeReason } from "@floating-ui/react"; +import { + Divider, + FlexItem, + FlexLayout, + StackLayout, + ToggleButton, +} from "@salt-ds/core"; +import type { DateFrameworkType } from "@salt-ds/date-adapters"; +import { + type DateInputSingleDetails, + DatePicker, + DatePickerActions, + DatePickerOverlay, + DatePickerSingleInput, + DatePickerSinglePanel, + DatePickerTrigger, + type SingleDateSelection, + useLocalization, +} from "@salt-ds/lab"; +import { + type ReactElement, + type SyntheticEvent, + useCallback, + useRef, + useState, +} from "react"; + +export const ControlledOpen = (): ReactElement => { + const [open, setOpen] = useState(false); + const [selectedDate, setSelectedDate] = useState< + SingleDateSelection | null | undefined + >(null); + const { dateAdapter } = useLocalization(); + const triggerRef = useRef(null); + const applyButtonRef = useRef(null); + const datePickerRef = useRef(null); + + const handleSelectionChange = useCallback( + ( + _event: SyntheticEvent, + date: SingleDateSelection | null, + _details: DateInputSingleDetails | undefined, + ) => { + setSelectedDate(date ?? null); + }, + [dateAdapter], + ); + + const handleApply = useCallback( + ( + event: SyntheticEvent, + date: SingleDateSelection | null, + ) => { + console.log( + `Applied StartDate: ${date ? dateAdapter.format(date, "DD MMM YYYY") : date}`, + ); + setSelectedDate(date); + setOpen(false); + }, + [dateAdapter], + ); + + const handleOpen = useCallback( + ( + newOpen: boolean, + _event?: Event | undefined, + reason?: OpenChangeReason | undefined, + ) => { + if (reason === undefined) { + triggerRef?.current?.focus(); + setTimeout(() => { + triggerRef?.current?.setSelectionRange( + 0, + triggerRef.current.value.length, + ); + }, 1); + } + setOpen(newOpen); + }, + [], + ); + + return ( + + + { + setOpen(event.currentTarget.value === "true"); + }} + > + Open + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/site/src/examples/date-picker/UncontrolledOpen.tsx b/site/src/examples/date-picker/UncontrolledOpen.tsx new file mode 100644 index 00000000000..201b38760f3 --- /dev/null +++ b/site/src/examples/date-picker/UncontrolledOpen.tsx @@ -0,0 +1,61 @@ +import { FlexLayout, StackLayout, ToggleButton } from "@salt-ds/core"; +import { + DatePicker, + DatePickerOverlay, + DatePickerSingleInput, + DatePickerSinglePanel, + DatePickerTrigger, +} from "@salt-ds/lab"; +import { type ReactElement, useState } from "react"; + +export const UncontrolledOpen = (): ReactElement => { + const [openOnClick, setOpenOnClick] = useState(false); + const [openOnKeyDown, setOpenOnKeyDown] = useState(false); + const [openOnFocus, setOpenOnFocus] = useState(false); + return ( + + + + setOpenOnFocus(event.currentTarget.value === "true") + } + > + Open On Focus + + + setOpenOnClick(event.currentTarget.value === "true") + } + > + Open On Click + + + setOpenOnKeyDown(event.currentTarget.value === "true") + } + > + Open On Key Down + + + + + + + + + + + + ); +}; diff --git a/site/src/examples/date-picker/index.ts b/site/src/examples/date-picker/index.ts index 38d075f5ff2..3be5f46bf18 100644 --- a/site/src/examples/date-picker/index.ts +++ b/site/src/examples/date-picker/index.ts @@ -19,3 +19,5 @@ export * from "./RangeWithLocaleEsES"; export * from "./RangeWithMinMaxDate"; export * from "./RangeWithFormField"; export * from "./RangeBordered"; +export * from "./ControlledOpen"; +export * from "./UncontrolledOpen";