Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open on focus datepicker #4498

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading