From 83b3ba3e3fbbdafed10a59b7c83980608544e3f3 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 open on focus behaviour for `DatePicker`
- added `openOnFocus` prop 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 | 52 ++++++-
packages/lab/src/date-picker/DatePicker.tsx | 5 +-
.../lab/src/date-picker/DatePickerOverlay.tsx | 1 +
.../date-picker/DatePickerOverlayProvider.tsx | 64 +++++---
.../date-picker/date-picker.stories.tsx | 138 +++++++++++++++++-
site/docs/components/date-picker/examples.mdx | 16 ++
.../date-picker/ControlledOpenOnFocus.tsx | 134 +++++++++++++++++
.../date-picker/UncontrolledOpenOnFocus.tsx | 21 +++
site/src/examples/date-picker/index.ts | 2 +
10 files changed, 410 insertions(+), 32 deletions(-)
create mode 100644 .changeset/serious-kings-decide.md
create mode 100644 site/src/examples/date-picker/ControlledOpenOnFocus.tsx
create mode 100644 site/src/examples/date-picker/UncontrolledOpenOnFocus.tsx
diff --git a/.changeset/serious-kings-decide.md b/.changeset/serious-kings-decide.md
new file mode 100644
index 0000000000..f219fb6c3c
--- /dev/null
+++ b/.changeset/serious-kings-decide.md
@@ -0,0 +1,9 @@
+---
+"@salt-ds/lab": minor
+---
+
+enabled open on focus behaviour for `DatePicker`
+
+- added `openOnFocus` prop 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 8929ed51c1..481ce8c5eb 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
+ ControlledOpenOnFocus,
Single,
SingleControlled,
+ SingleCustomFormat,
SingleWithConfirmation,
SingleWithCustomPanel,
SingleWithCustomParser,
SingleWithFormField,
SingleWithMinMaxDate,
SingleWithTodayButton,
- SingleCustomFormat,
+ UncontrolledOpenOnFocus,
} = 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,27 @@ describe("GIVEN a DatePicker where selectionVariant is single", () => {
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").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);
+ });
});
describe("controlled component", () => {
@@ -515,6 +533,28 @@ 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 on focus
+ 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("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 a4668504bc..a51e7646cf 100644
--- a/packages/lab/src/date-picker/DatePicker.tsx
+++ b/packages/lab/src/date-picker/DatePicker.tsx
@@ -21,6 +21,8 @@ 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/click */
+ openOnFocus?: boolean;
/**
* Handler for when open state changes
* @param newOpen - true when opened
@@ -124,11 +126,12 @@ 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, openOnFocus, ...rest } = props;
return (
diff --git a/packages/lab/src/date-picker/DatePickerOverlay.tsx b/packages/lab/src/date-picker/DatePickerOverlay.tsx
index e4327331dd..548ab541ce 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 a9a325e5d1..9ddd435f7f 100644
--- a/packages/lab/src/date-picker/DatePickerOverlayProvider.tsx
+++ b/packages/lab/src/date-picker/DatePickerOverlayProvider.tsx
@@ -1,7 +1,9 @@
import {
type OpenChangeReason,
flip,
+ useClick,
useDismiss,
+ useFocus,
useInteractions,
} from "@floating-ui/react";
import { createContext, useControlled, useFloatingUI } from "@salt-ds/core";
@@ -9,7 +11,6 @@ import {
type ReactNode,
useCallback,
useContext,
- useEffect,
useMemo,
useRef,
} from "react";
@@ -81,6 +82,10 @@ interface DatePickerOverlayProviderProps {
* If `true`, the overlay is open.
*/
open?: boolean;
+ /**
+ * When `open` is uncontrolled, set this to `true` to open on focus/click
+ */
+ openOnFocus?: boolean;
/**
* Handler for when open state changes
* @param newOpen - true when opened
@@ -98,8 +103,8 @@ interface DatePickerOverlayProviderProps {
export const DatePickerOverlayProvider: React.FC<
DatePickerOverlayProviderProps
-> = ({ open: openProp, defaultOpen, onOpen, children }) => {
- const [open, setOpenState] = useControlled({
+> = ({ open: openProp, openOnFocus, defaultOpen, onOpen, children }) => {
+ const [open, setOpenState, isOpenControlled] = useControlled({
controlled: openProp,
default: Boolean(defaultOpen),
name: "DatePicker",
@@ -107,33 +112,34 @@ 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 programmaticClose = useRef(false);
const setOpen = useCallback(
- (
- newOpen: boolean,
- _event?: Event | undefined,
- reason?: OpenChangeReason | undefined,
- ) => {
+ (newOpen: boolean, _event?: Event, reason?: OpenChangeReason) => {
+ if (reason === undefined) {
+ programmaticClose.current = true;
+ }
if (newOpen) {
triggeringElement.current = document.activeElement as HTMLElement;
+ } else {
+ if (!isOpenControlled && programmaticClose.current) {
+ const trigger = triggeringElement.current as HTMLElement;
+ if (trigger) {
+ trigger.focus();
+ }
+ if (trigger instanceof HTMLInputElement) {
+ setTimeout(() => {
+ trigger.setSelectionRange(0, trigger.value.length);
+ }, 1);
+ }
+ programmaticClose.current = false;
+ triggeringElement.current = null;
+ }
}
+
setOpenState(newOpen);
onOpen?.(newOpen);
+
if (
reason === "escape-key" ||
(reason === "outside-press" && onDismissCallback.current)
@@ -154,7 +160,17 @@ export const DatePickerOverlayProvider: React.FC<
const {
getFloatingProps: _getFloatingPropsCallback,
getReferenceProps: _getReferenceProps,
- } = useInteractions([useDismiss(floatingUIResult.context)]);
+ } = useInteractions([
+ useDismiss(floatingUIResult.context),
+ useFocus(floatingUIResult.context, {
+ enabled: !!openOnFocus && !programmaticClose.current,
+ }),
+ useClick(floatingUIResult.context, {
+ enabled: !!openOnFocus,
+ toggle: false,
+ }),
+ ]);
+
const getFloatingPropsCallback = useMemo(
() => _getFloatingPropsCallback,
[_getFloatingPropsCallback],
diff --git a/packages/lab/stories/date-picker/date-picker.stories.tsx b/packages/lab/stories/date-picker/date-picker.stories.tsx
index 1e82c8fe3b..012edd69ec 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,
@@ -40,7 +41,7 @@ import {
} from "@salt-ds/lab";
import type { Meta, StoryFn } from "@storybook/react";
import type React from "react";
-import type { SyntheticEvent } from "react";
+import type { FocusEvent, SyntheticEvent } from "react";
import { useCallback, useRef, useState } from "react";
// CustomDatePickerPanel is a sample component, representing a composition you could create yourselves, not intended for importing into your own projects
// refer to https://github.com/jpmorganchase/salt-ds/blob/main/packages/lab/src/date-picker/useDatePicker.ts to create your own
@@ -2681,3 +2682,138 @@ WithExperimentalTime.parameters = {
},
},
};
+
+export const UncontrolledOpenOnFocus: StoryFn<
+ DatePickerSingleProps
+> = ({ selectionVariant, defaultSelectedDate, ...args }) => {
+ return (
+
+
+
+
+
+
+
+
+ );
+};
+
+export const ControlledOpenOnFocus: StoryFn<
+ DatePickerSingleProps
+> = ({ selectionVariant, defaultSelectedDate, ...args }) => {
+ const [selectedDate, setSelectedDate] = useState<
+ SingleDateSelection | null | undefined
+ >(defaultSelectedDate ?? null);
+ const [open, setOpen] = useState(false);
+ const { dateAdapter } = useLocalization();
+ const triggerRef = useRef(null);
+ const applyButtonRef = useRef(null);
+ const datePickerRef = useRef(null);
+ const programmaticClose = useRef(false);
+
+ const handleSelectionChange = useCallback(
+ (
+ _event: SyntheticEvent,
+ date: SingleDateSelection | null,
+ _details: DateInputSingleDetails | undefined,
+ ) => {
+ setSelectedDate(date ?? null);
+ },
+ [dateAdapter],
+ );
+
+ const handleClick = useCallback(() => {
+ setOpen(true);
+ }, []);
+
+ const handleInputFocus = useCallback((event: FocusEvent) => {
+ // Don't re-open if closing and returning focus
+ if (!programmaticClose.current) {
+ setOpen(true);
+ }
+ programmaticClose.current = false;
+ }, []);
+
+ const handleInputBlur = useCallback((event: FocusEvent) => {
+ // Don't close if the overlay now has focus
+ if (!datePickerRef?.current?.contains(event.relatedTarget)) {
+ setOpen(false);
+ }
+ }, []);
+
+ const handleOpen = useCallback(
+ (
+ newOpen: boolean,
+ _event?: Event | undefined,
+ reason?: OpenChangeReason | undefined,
+ ) => {
+ if (reason === undefined) {
+ programmaticClose.current = true;
+ triggerRef?.current?.focus();
+ setTimeout(() => {
+ triggerRef?.current?.setSelectionRange(
+ 0,
+ triggerRef.current.value.length,
+ );
+ }, 1);
+ }
+ setOpen(newOpen);
+ },
+ [],
+ );
+
+ const handleApply = useCallback(
+ (
+ event: SyntheticEvent,
+ date: SingleDateSelection | null,
+ ) => {
+ console.log(
+ `Applied StartDate: ${date ? dateAdapter.format(date, "DD MMM YYYY") : date}`,
+ );
+ setSelectedDate(date);
+ },
+ [dateAdapter],
+ );
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/site/docs/components/date-picker/examples.mdx b/site/docs/components/date-picker/examples.mdx
index 28f59328b0..93bc07ee47 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 on focus
+
+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 when an input receives focus by using the `openOnFocus` prop.
+
+
+
+
+
+## Controlled open on focus
+
+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/ControlledOpenOnFocus.tsx b/site/src/examples/date-picker/ControlledOpenOnFocus.tsx
new file mode 100644
index 0000000000..5b3a769774
--- /dev/null
+++ b/site/src/examples/date-picker/ControlledOpenOnFocus.tsx
@@ -0,0 +1,134 @@
+import type { OpenChangeReason } from "@floating-ui/react";
+import { Divider, FlexItem, FlexLayout } 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 FocusEvent,
+ type ReactElement,
+ type SyntheticEvent,
+ useCallback,
+ useRef,
+ useState,
+} from "react";
+
+export const ControlledOpenOnFocus = (): ReactElement => {
+ const [selectedDate, setSelectedDate] = useState<
+ SingleDateSelection | null | undefined
+ >(null);
+ const [open, setOpen] = useState(false);
+ const { dateAdapter } = useLocalization();
+ const triggerRef = useRef(null);
+ const applyButtonRef = useRef(null);
+ const datePickerRef = useRef(null);
+ const programmaticClose = useRef(false);
+
+ const handleSelectionChange = useCallback(
+ (
+ _event: SyntheticEvent,
+ date: SingleDateSelection | null,
+ _details: DateInputSingleDetails | undefined,
+ ) => {
+ setSelectedDate(date ?? null);
+ },
+ [dateAdapter],
+ );
+
+ const handleClick = useCallback(() => {
+ setOpen(true);
+ }, []);
+
+ const handleInputFocus = useCallback((event: FocusEvent) => {
+ // Don't re-open if closing and returning focus
+ if (!programmaticClose.current) {
+ setOpen(true);
+ }
+ programmaticClose.current = false;
+ }, []);
+
+ const handleInputBlur = useCallback((event: FocusEvent) => {
+ // Don't close if the overlay now has focus
+ if (!datePickerRef?.current?.contains(event.relatedTarget)) {
+ setOpen(false);
+ }
+ }, []);
+
+ const handleOpen = useCallback(
+ (
+ newOpen: boolean,
+ _event?: Event | undefined,
+ reason?: OpenChangeReason | undefined,
+ ) => {
+ if (reason === undefined) {
+ programmaticClose.current = true;
+ triggerRef?.current?.focus();
+ setTimeout(() => {
+ triggerRef?.current?.setSelectionRange(
+ 0,
+ triggerRef.current.value.length,
+ );
+ }, 1);
+ }
+ setOpen(newOpen);
+ },
+ [],
+ );
+
+ const handleApply = useCallback(
+ (
+ event: SyntheticEvent,
+ date: SingleDateSelection | null,
+ ) => {
+ console.log(
+ `Applied StartDate: ${date ? dateAdapter.format(date, "DD MMM YYYY") : date}`,
+ );
+ setSelectedDate(date);
+ },
+ [dateAdapter],
+ );
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/site/src/examples/date-picker/UncontrolledOpenOnFocus.tsx b/site/src/examples/date-picker/UncontrolledOpenOnFocus.tsx
new file mode 100644
index 0000000000..98035416eb
--- /dev/null
+++ b/site/src/examples/date-picker/UncontrolledOpenOnFocus.tsx
@@ -0,0 +1,21 @@
+import {
+ DatePicker,
+ DatePickerOverlay,
+ DatePickerSingleInput,
+ DatePickerSinglePanel,
+ DatePickerTrigger,
+} from "@salt-ds/lab";
+import type { ReactElement } from "react";
+
+export const UncontrolledOpenOnFocus = (): ReactElement => {
+ return (
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/site/src/examples/date-picker/index.ts b/site/src/examples/date-picker/index.ts
index 38d075f5ff..e9e0aa55c7 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 "./ControlledOpenOnFocus";
+export * from "./UncontrolledOpenOnFocus";