Skip to content

Commit

Permalink
enabled open on focus behaviour for DatePicker
Browse files Browse the repository at this point in the history
- added `openOnFocus` prop to `DatePicker`
- revise the controlled behaviour of the `open` prop on `DatePickerOverlay`
- add examples for controlled and uncontrolled behaviour
  • Loading branch information
mark-tate committed Dec 13, 2024
1 parent 2719afb commit 8406e52
Show file tree
Hide file tree
Showing 10 changed files with 410 additions and 32 deletions.
9 changes: 9 additions & 0 deletions .changeset/serious-kings-decide.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(
<UncontrolledOpenOnFocus defaultSelectedDate={initialDate} />,
);
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", () => {
Expand Down Expand Up @@ -515,6 +533,28 @@ describe("GIVEN a DatePicker where selectionVariant is single", () => {
});
});

it("SHOULD be able to control the overlay open state", () => {
cy.mount(<ControlledOpenOnFocus defaultSelectedDate={initialDate} />);
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";

Expand Down
5 changes: 4 additions & 1 deletion packages/lab/src/date-picker/DatePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -124,11 +126,12 @@ export const DatePickerMain = forwardRef<HTMLDivElement, DatePickerProps<any>>(
export const DatePicker = forwardRef(function DatePicker<
TDate extends DateFrameworkType,
>(props: DatePickerProps<TDate>, ref: React.Ref<HTMLDivElement>) {
const { open, defaultOpen, onOpen, ...rest } = props;
const { open, defaultOpen, onOpen, openOnFocus, ...rest } = props;

return (
<DatePickerOverlayProvider
open={open}
openOnFocus={openOnFocus}
defaultOpen={defaultOpen}
onOpen={onOpen}
>
Expand Down
1 change: 1 addition & 0 deletions packages/lab/src/date-picker/DatePickerOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export const DatePickerOverlay = forwardRef<
focusManagerProps={
floatingUIResult?.context
? {
returnFocus: false,
context: floatingUIResult.context,
initialFocus: 4,
}
Expand Down
64 changes: 40 additions & 24 deletions packages/lab/src/date-picker/DatePickerOverlayProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import {
type OpenChangeReason,
flip,
useClick,
useDismiss,
useFocus,
useInteractions,
} from "@floating-ui/react";
import { createContext, useControlled, useFloatingUI } from "@salt-ds/core";
import {
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
} from "react";
Expand Down Expand Up @@ -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
Expand All @@ -98,42 +103,43 @@ 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",
state: "openDatePickerOverlay",
});
const triggeringElement = useRef<HTMLElement | null>(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)
Expand All @@ -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],
Expand Down
138 changes: 137 additions & 1 deletion packages/lab/stories/date-picker/date-picker.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { OpenChangeReason } from "@floating-ui/react";
import {
Button,
Divider,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -2681,3 +2682,138 @@ WithExperimentalTime.parameters = {
},
},
};

export const UncontrolledOpenOnFocus: StoryFn<
DatePickerSingleProps<DateFrameworkType>
> = ({ selectionVariant, defaultSelectedDate, ...args }) => {
return (
<DatePicker
selectionVariant={"single"}
{...args}
openOnFocus
defaultSelectedDate={defaultSelectedDate}
>
<DatePickerTrigger>
<DatePickerSingleInput />
</DatePickerTrigger>
<DatePickerOverlay>
<DatePickerSinglePanel />
</DatePickerOverlay>
</DatePicker>
);
};

export const ControlledOpenOnFocus: StoryFn<
DatePickerSingleProps<DateFrameworkType>
> = ({ selectionVariant, defaultSelectedDate, ...args }) => {
const [selectedDate, setSelectedDate] = useState<
SingleDateSelection<DateFrameworkType> | null | undefined
>(defaultSelectedDate ?? null);
const [open, setOpen] = useState(false);
const { dateAdapter } = useLocalization();
const triggerRef = useRef<HTMLInputElement | null>(null);
const applyButtonRef = useRef<HTMLButtonElement>(null);
const datePickerRef = useRef<HTMLDivElement>(null);
const programmaticClose = useRef(false);

const handleSelectionChange = useCallback(
(
_event: SyntheticEvent,
date: SingleDateSelection<DateFrameworkType> | 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<DateFrameworkType> | null,
) => {
console.log(
`Applied StartDate: ${date ? dateAdapter.format(date, "DD MMM YYYY") : date}`,
);
setSelectedDate(date);
},
[dateAdapter],
);

return (
<DatePicker
selectionVariant={"single"}
{...args}
onSelectionChange={handleSelectionChange}
selectedDate={selectedDate}
onApply={handleApply}
onOpen={handleOpen}
open={open}
>
<DatePickerTrigger>
<DatePickerSingleInput
inputRef={triggerRef}
inputProps={{
onClick: handleClick,
onFocus: handleInputFocus,
onBlur: handleInputBlur,
}}
/>
</DatePickerTrigger>
<DatePickerOverlay ref={datePickerRef}>
<FlexLayout gap={0} direction="column">
<FlexItem>
<DatePickerSinglePanel />
<Divider variant="tertiary" />
</FlexItem>
<FlexItem>
<DatePickerActions
selectionVariant="single"
applyButtonRef={applyButtonRef}
/>
</FlexItem>
</FlexLayout>
</DatePickerOverlay>
</DatePicker>
);
};
Loading

0 comments on commit 8406e52

Please sign in to comment.