From a0ff4dc811f18e89063bf7ae163b83425101e2b7 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 1/4] 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 | 22 ++- .../lab/src/date-picker/DatePickerOverlay.tsx | 1 + .../date-picker/DatePickerOverlayProvider.tsx | 87 +++++++--- .../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 | 162 +++++++++++++++++- 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, 592 insertions(+), 74 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 7759c061a3a..a1e771ad296 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,6 +26,7 @@ const adapters = [adapterDateFns, adapterDayjs, adapterLuxon, adapterMoment]; const { // Storybook wraps components in it's own LocalizationProvider, so do not compose Stories + ControlledOpen, Single, SingleControlled, SingleWithConfirmation, @@ -35,7 +36,8 @@ const { SingleWithMinMaxDate, SingleWithTodayButton, SingleCustomFormat, -} = datePickerStories as any; // not using composeStories yet, will break certain test below + UncontrolledOpen, +} = datePickerStories as any; describe("GIVEN a DatePicker where selectionVariant is single", () => { describe("WHEN default state", () => { @@ -388,11 +390,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; @@ -483,6 +480,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", () => { @@ -567,6 +632,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 e9a162dd46f..dcf524995e7 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,12 +130,24 @@ export const DatePickerMain = forwardRef>( export const DatePicker = forwardRef(function DatePicker< TDate extends DateFrameworkType, >(props: DatePickerProps, ref: React.Ref) { - const { open, defaultOpen, onOpen, readOnly, ...rest } = props; + const { + defaultOpen, + open, + openOnClick, + openOnFocus, + openOnKeyDown, + onOpen, + readOnly, + ...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 a8ff0a923f2..6a8f30cf3ed 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,6 +110,10 @@ 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; /** * When true, shouldn't open the overlay. */ @@ -102,8 +122,18 @@ interface DatePickerOverlayProviderProps { export const DatePickerOverlayProvider: React.FC< DatePickerOverlayProviderProps -> = ({ open: openProp, defaultOpen, onOpen, children, readOnly }) => { - const [open, setOpenState] = useControlled({ +> = ({ + open: openProp, + openOnClick, + openOnFocus, + openOnKeyDown, + defaultOpen, + onOpen, + children, + interactions, + readOnly, +}) => { + const [open, setOpenState, isOpenControlled] = useControlled({ controlled: openProp, default: Boolean(defaultOpen), name: "DatePicker", @@ -112,36 +142,30 @@ 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) { if (readOnly) { // When not open overlay when readOnly return; } 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) @@ -162,7 +186,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 f8a9db18e6d..e0d15bdf664 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) { @@ -200,7 +188,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 8e22b0270bc..81b220b40bc 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, @@ -175,7 +177,7 @@ Range.args = { export const SingleReadOnly = DatePickerSingleTemplate.bind({}); SingleReadOnly.args = { - readOnly: true, + readOnly: true }; export const RangeReadOnly = DatePickerRangeTemplate.bind({}); @@ -2692,3 +2694,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"; From 57c3dffdcd915943a9ac4a6fe1e608ad035e1f64 Mon Sep 17 00:00:00 2001 From: mark-tate <143323+mark-tate@users.noreply.github.com> Date: Fri, 13 Dec 2024 21:46:19 +0000 Subject: [PATCH 2/4] update un-controlled open behaviour to be disabled when read-only --- .../date-picker/DatePicker.single.cy.tsx | 41 +++++++++---------- .../date-picker/DatePickerOverlayProvider.tsx | 15 +++---- .../date-picker/date-picker.stories.tsx | 2 +- 3 files changed, 29 insertions(+), 29 deletions(-) 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 a1e771ad296..032495cca33 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 @@ -90,6 +90,11 @@ describe("GIVEN a DatePicker where selectionVariant is single", () => { cy.findByRole("textbox").click().type("{downArrow}", { force: true }); cy.findByRole("application").should("not.exist"); }); + + it("SHOULD not open overlay if defaultOpen is set", () => { + cy.mount(); + cy.findByRole("application").should("not.exist"); + }); }); adapters.forEach((adapter: SaltDateAdapter) => { @@ -482,9 +487,7 @@ describe("GIVEN a DatePicker where selectionVariant is single", () => { }); it("SHOULD be able to enable the overlay to open on click", () => { - cy.mount( - , - ); + cy.mount(); cy.findByRole("application").should("not.exist"); // Simulate opening the calendar on click cy.document().find("input").realClick(); @@ -497,18 +500,14 @@ describe("GIVEN a DatePicker where selectionVariant is single", () => { name: adapter.format(updatedDate, "DD MMMM YYYY"), }).realClick(); cy.findByRole("application").should("not.exist"); - cy.document() - .find("input") - .should("have.value", updatedFormattedDateValue); + cy.findByRole("textbox").should( + "have.value", + updatedFormattedDateValue, + ); }); it("SHOULD be able to enable the overlay to open on keydown", () => { - cy.mount( - , - ); + cy.mount(); cy.findByRole("application").should("not.exist"); // Simulate opening the calendar on arrow down cy.document().find("input").realClick(); @@ -523,15 +522,14 @@ describe("GIVEN a DatePicker where selectionVariant is single", () => { name: adapter.format(updatedDate, "DD MMMM YYYY"), }).realClick(); cy.findByRole("application").should("not.exist"); - cy.document() - .find("input") - .should("have.value", updatedFormattedDateValue); + cy.findByRole("textbox").should( + "have.value", + updatedFormattedDateValue, + ); }); it("SHOULD be able to enable the overlay to open on focus", () => { - cy.mount( - , - ); + cy.mount(); cy.findByRole("application").should("not.exist"); // Simulate opening the calendar on focus cy.document().find("input").focus(); @@ -544,9 +542,10 @@ describe("GIVEN a DatePicker where selectionVariant is single", () => { name: adapter.format(updatedDate, "DD MMMM YYYY"), }).realClick(); cy.findByRole("application").should("not.exist"); - cy.document() - .find("input") - .should("have.value", updatedFormattedDateValue); + cy.findByRole("textbox").should( + "have.value", + updatedFormattedDateValue, + ); }); }); diff --git a/packages/lab/src/date-picker/DatePickerOverlayProvider.tsx b/packages/lab/src/date-picker/DatePickerOverlayProvider.tsx index 6a8f30cf3ed..3ca3623b84e 100644 --- a/packages/lab/src/date-picker/DatePickerOverlayProvider.tsx +++ b/packages/lab/src/date-picker/DatePickerOverlayProvider.tsx @@ -113,7 +113,7 @@ interface DatePickerOverlayProviderProps { /** * A factory method to create a set of interaction, if provided overrides the default interactions */ - interactions?: (context: FloatingContext) => Array; + interactions?: (context: FloatingContext) => Array; /** * When true, shouldn't open the overlay. */ @@ -126,7 +126,7 @@ export const DatePickerOverlayProvider: React.FC< open: openProp, openOnClick, openOnFocus, - openOnKeyDown, + openOnKeyDown = true, defaultOpen, onOpen, children, @@ -135,7 +135,7 @@ export const DatePickerOverlayProvider: React.FC< }) => { const [open, setOpenState, isOpenControlled] = useControlled({ controlled: openProp, - default: Boolean(defaultOpen), + default: readOnly ? false : Boolean(defaultOpen), name: "DatePicker", state: "openDatePickerOverlay", }); @@ -146,7 +146,6 @@ export const DatePickerOverlayProvider: React.FC< (newOpen: boolean, _event?: Event, reason?: OpenChangeReason) => { if (newOpen) { if (readOnly) { - // When not open overlay when readOnly return; } triggeringElement.current = document.activeElement as HTMLElement; @@ -192,11 +191,13 @@ export const DatePickerOverlayProvider: React.FC< : [ useDismiss(floatingUIResult.context), useFocus(floatingUIResult.context, { - enabled: !!openOnFocus, + enabled: !!openOnFocus && !readOnly, + }), + useKeyboard(floatingUIResult.context, { + enabled: !!openOnKeyDown && !readOnly, }), - useKeyboard(floatingUIResult.context, { enabled: !!openOnKeyDown }), useClick(floatingUIResult.context, { - enabled: !!openOnClick, + enabled: !!openOnClick && !readOnly, toggle: false, }), ], diff --git a/packages/lab/stories/date-picker/date-picker.stories.tsx b/packages/lab/stories/date-picker/date-picker.stories.tsx index 81b220b40bc..af025a9cb65 100644 --- a/packages/lab/stories/date-picker/date-picker.stories.tsx +++ b/packages/lab/stories/date-picker/date-picker.stories.tsx @@ -177,7 +177,7 @@ Range.args = { export const SingleReadOnly = DatePickerSingleTemplate.bind({}); SingleReadOnly.args = { - readOnly: true + readOnly: true, }; export const RangeReadOnly = DatePickerRangeTemplate.bind({}); From 2b97d3d447ba7cfac597096ca6134de5c72964ae Mon Sep 17 00:00:00 2001 From: mark-tate <143323+mark-tate@users.noreply.github.com> Date: Mon, 16 Dec 2024 14:57:34 +0000 Subject: [PATCH 3/4] fix tests --- .../date-picker/DatePicker.range.cy.tsx | 40 ++++++ .../date-picker/DatePicker.single.cy.tsx | 126 ++++++------------ 2 files changed, 79 insertions(+), 87 deletions(-) diff --git a/packages/lab/src/__tests__/__e2e__/date-picker/DatePicker.range.cy.tsx b/packages/lab/src/__tests__/__e2e__/date-picker/DatePicker.range.cy.tsx index 86d2e0c0d0a..637e156d6c3 100644 --- a/packages/lab/src/__tests__/__e2e__/date-picker/DatePicker.range.cy.tsx +++ b/packages/lab/src/__tests__/__e2e__/date-picker/DatePicker.range.cy.tsx @@ -65,6 +65,46 @@ describe("GIVEN a DatePicker where selectionVariant is single", () => { // Verify that the calendar is displayed cy.findAllByRole("application").should("have.length", 2); }); + + 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.findByLabelText("Start date").realClick(); + cy.findAllByRole("application").should("have.length", 2); + cy.document().find("body").realClick(); + cy.findByRole("application").should("not.exist"); + cy.findByLabelText("End date").realClick(); + cy.findAllByRole("application").should("have.length", 2); + }); + + 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.findByLabelText("Start date").realClick(); + cy.findByRole("application").should("not.exist"); + cy.realPress("ArrowDown"); + cy.findAllByRole("application").should("have.length", 2); + cy.document().find("body").realClick(); + cy.findByRole("application").should("not.exist"); + cy.findByLabelText("End date").realClick(); + cy.findByRole("application").should("not.exist"); + cy.realPress("ArrowDown"); + cy.findAllByRole("application").should("have.length", 2); + }); + + 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.findByLabelText("Start date").focus(); + cy.findAllByRole("application").should("have.length", 2); + cy.document().find("body").realClick(); + cy.findByRole("application").should("not.exist"); + cy.findByLabelText("End date").focus(); + cy.findAllByRole("application").should("have.length", 2); + }); }); describe("WHEN readOnly", () => { 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 032495cca33..2809282a37b 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 @@ -36,7 +36,6 @@ const { SingleWithMinMaxDate, SingleWithTodayButton, SingleCustomFormat, - UncontrolledOpen, } = datePickerStories as any; describe("GIVEN a DatePicker where selectionVariant is single", () => { @@ -67,6 +66,45 @@ describe("GIVEN a DatePicker where selectionVariant is single", () => { // Verify that the calendar is displayed cy.findByRole("application").should("exist"); }); + + 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"); + }); + + 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"); + }); + + 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"); + }); + + it("SHOULD be able to control the overlay open state", () => { + cy.mount(); + cy.findByRole("application").should("not.exist"); + // Simulate opening the calendar through a controlled state + cy.document().find("input").realClick(); + cy.findByRole("application").should("not.exist"); + cy.findByRole("button", { name: "Open Calendar" }).realClick(); + cy.findByRole("application").should("exist"); + cy.findByRole("button", { name: "Cancel" }).realClick(); + // Verify that the calendar can be closed by user + cy.findByRole("application").should("not.exist"); + }); }); describe("WHEN readOnly", () => { @@ -485,68 +523,6 @@ 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.findByRole("textbox").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.findByRole("textbox").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.findByRole("textbox").should( - "have.value", - updatedFormattedDateValue, - ); - }); }); describe("controlled component", () => { @@ -631,30 +607,6 @@ 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"; From 1ed601d35402b47b314d202527017ee4d98591c8 Mon Sep 17 00:00:00 2001 From: mark-tate <143323+mark-tate@users.noreply.github.com> Date: Fri, 20 Dec 2024 15:01:06 +0000 Subject: [PATCH 4/4] remove `openOnFocus` --- .changeset/serious-kings-decide.md | 2 +- .../__e2e__/date-picker/DatePicker.range.cy.tsx | 12 ------------ .../__e2e__/date-picker/DatePicker.single.cy.tsx | 8 -------- packages/lab/src/date-picker/DatePicker.tsx | 4 ---- .../src/date-picker/DatePickerOverlayProvider.tsx | 9 --------- .../lab/stories/date-picker/date-picker.stories.tsx | 11 ----------- site/docs/components/date-picker/examples.mdx | 2 +- site/src/examples/date-picker/UncontrolledOpen.tsx | 11 ----------- 8 files changed, 2 insertions(+), 57 deletions(-) diff --git a/.changeset/serious-kings-decide.md b/.changeset/serious-kings-decide.md index 3bddf2257c5..5d16a1da34c 100644 --- a/.changeset/serious-kings-decide.md +++ b/.changeset/serious-kings-decide.md @@ -4,6 +4,6 @@ enabled uncontrolled/un-controlled open behaviour for `DatePicker` -- added `openOnClick`, `openOnKeyDown` and `openOnFocus` props to `DatePicker`. +- added `openOnClick` and `openOnKeyDown` 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.range.cy.tsx b/packages/lab/src/__tests__/__e2e__/date-picker/DatePicker.range.cy.tsx index 637e156d6c3..d9a2be0fb16 100644 --- a/packages/lab/src/__tests__/__e2e__/date-picker/DatePicker.range.cy.tsx +++ b/packages/lab/src/__tests__/__e2e__/date-picker/DatePicker.range.cy.tsx @@ -93,18 +93,6 @@ describe("GIVEN a DatePicker where selectionVariant is single", () => { cy.realPress("ArrowDown"); cy.findAllByRole("application").should("have.length", 2); }); - - 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.findByLabelText("Start date").focus(); - cy.findAllByRole("application").should("have.length", 2); - cy.document().find("body").realClick(); - cy.findByRole("application").should("not.exist"); - cy.findByLabelText("End date").focus(); - cy.findAllByRole("application").should("have.length", 2); - }); }); describe("WHEN readOnly", () => { 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 2809282a37b..a1368df1f6f 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 @@ -85,14 +85,6 @@ describe("GIVEN a DatePicker where selectionVariant is single", () => { cy.findByRole("application").should("exist"); }); - 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"); - }); - it("SHOULD be able to control the overlay open state", () => { cy.mount(); cy.findByRole("application").should("not.exist"); diff --git a/packages/lab/src/date-picker/DatePicker.tsx b/packages/lab/src/date-picker/DatePicker.tsx index dcf524995e7..55c9c553003 100644 --- a/packages/lab/src/date-picker/DatePicker.tsx +++ b/packages/lab/src/date-picker/DatePicker.tsx @@ -21,8 +21,6 @@ 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 */ @@ -134,7 +132,6 @@ export const DatePicker = forwardRef(function DatePicker< defaultOpen, open, openOnClick, - openOnFocus, openOnKeyDown, onOpen, readOnly, @@ -146,7 +143,6 @@ export const DatePicker = forwardRef(function DatePicker< defaultOpen={defaultOpen} open={open} openOnClick={openOnClick} - openOnFocus={openOnFocus} openOnKeyDown={openOnKeyDown} onOpen={onOpen} readOnly={readOnly} diff --git a/packages/lab/src/date-picker/DatePickerOverlayProvider.tsx b/packages/lab/src/date-picker/DatePickerOverlayProvider.tsx index 3ca3623b84e..2d0b32a209a 100644 --- a/packages/lab/src/date-picker/DatePickerOverlayProvider.tsx +++ b/packages/lab/src/date-picker/DatePickerOverlayProvider.tsx @@ -5,7 +5,6 @@ import { flip, useClick, useDismiss, - useFocus, useInteractions, } from "@floating-ui/react"; import { createContext, useControlled, useFloatingUI } from "@salt-ds/core"; @@ -85,10 +84,6 @@ 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 */ @@ -125,7 +120,6 @@ export const DatePickerOverlayProvider: React.FC< > = ({ open: openProp, openOnClick, - openOnFocus, openOnKeyDown = true, defaultOpen, onOpen, @@ -190,9 +184,6 @@ export const DatePickerOverlayProvider: React.FC< ? interactions(floatingUIResult.context) : [ useDismiss(floatingUIResult.context), - useFocus(floatingUIResult.context, { - enabled: !!openOnFocus && !readOnly, - }), useKeyboard(floatingUIResult.context, { enabled: !!openOnKeyDown && !readOnly, }), diff --git a/packages/lab/stories/date-picker/date-picker.stories.tsx b/packages/lab/stories/date-picker/date-picker.stories.tsx index af025a9cb65..4c2250a969f 100644 --- a/packages/lab/stories/date-picker/date-picker.stories.tsx +++ b/packages/lab/stories/date-picker/date-picker.stories.tsx @@ -2700,19 +2700,9 @@ export const UncontrolledOpen: StoryFn< > = ({ 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 - diff --git a/site/docs/components/date-picker/examples.mdx b/site/docs/components/date-picker/examples.mdx index 8cd1d391fb0..35ce418c458 100644 --- a/site/docs/components/date-picker/examples.mdx +++ b/site/docs/components/date-picker/examples.mdx @@ -174,7 +174,7 @@ 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. +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` and `openOnKeyDown`props. diff --git a/site/src/examples/date-picker/UncontrolledOpen.tsx b/site/src/examples/date-picker/UncontrolledOpen.tsx index 201b38760f3..77d3f492599 100644 --- a/site/src/examples/date-picker/UncontrolledOpen.tsx +++ b/site/src/examples/date-picker/UncontrolledOpen.tsx @@ -11,19 +11,9 @@ 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 - { selectionVariant={"single"} openOnClick={openOnClick} openOnKeyDown={openOnKeyDown} - openOnFocus={openOnFocus} >