Skip to content

Commit

Permalink
enabled uncontrolled/un-controlled open behaviour for DatePicker
Browse files Browse the repository at this point in the history
- 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.
  • Loading branch information
mark-tate committed Dec 13, 2024
1 parent 86d2a28 commit c966b4f
Show file tree
Hide file tree
Showing 13 changed files with 595 additions and 72 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 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.
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
ControlledOpen,
Single,
SingleControlled,
SingleCustomFormat,
SingleWithConfirmation,
SingleWithCustomPanel,
SingleWithCustomParser,
SingleWithFormField,
SingleWithMinMaxDate,
SingleWithTodayButton,
SingleCustomFormat,
UncontrolledOpen,
} = 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,74 @@ describe("GIVEN a DatePicker where selectionVariant is single", () => {
updatedFormattedDateValue,
);
});

it("SHOULD be able to enable the overlay to open on click", () => {
cy.mount(
<UncontrolledOpen openOnClick defaultSelectedDate={initialDate} />,
);
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(
<UncontrolledOpen
openOnKeyDown
defaultSelectedDate={initialDate}
/>,
);
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(
<UncontrolledOpen openOnFocus defaultSelectedDate={initialDate} />,
);
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", () => {
Expand Down Expand Up @@ -515,6 +580,30 @@ describe("GIVEN a DatePicker where selectionVariant is single", () => {
});
});

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

Expand Down
19 changes: 18 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,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/click */
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
Expand Down Expand Up @@ -124,11 +130,22 @@ 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,
openOnClick,
openOnFocus,
openOnKeyDown,
...rest
} = props;

return (
<DatePickerOverlayProvider
open={open}
openOnClick={openOnClick}
openOnKeyDown={openOnKeyDown}
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
93 changes: 69 additions & 24 deletions packages/lab/src/date-picker/DatePickerOverlayProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
import {
type ElementProps,
type FloatingContext,
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";
import { useKeyboard } from "./useKeyboard";

/**
* Interface representing the state for a DatePicker overlay.
Expand Down Expand Up @@ -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
Expand All @@ -94,46 +110,60 @@ 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<ElementProps | void>;
}

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",
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 +184,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 && !programmaticClose.current,
}),
useKeyboard(floatingUIResult.context, { enabled: !!openOnKeyDown }),
useClick(floatingUIResult.context, {
enabled: !!openOnClick,
toggle: false,
}),
],
);

const getFloatingPropsCallback = useMemo(
() => _getFloatingPropsCallback,
[_getFloatingPropsCallback],
Expand Down
30 changes: 2 additions & 28 deletions packages/lab/src/date-picker/DatePickerRangeInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ import {
} from "@salt-ds/date-adapters";
import { clsx } from "clsx";
import {
type KeyboardEvent,
type KeyboardEventHandler,
type SyntheticEvent,
forwardRef,
useCallback,
Expand Down Expand Up @@ -121,9 +119,8 @@ export const DatePickerRangeInput = forwardRef(function DatePickerRangeInput<
const { dateAdapter } = useLocalization<TDate>();
const {
className,
endInputProps: endInputPropsProp,
startInputProps: startInputPropsProp,
onKeyDown,
endInputProps,
startInputProps,
defaultValue,
format,
value: valueProp,
Expand Down Expand Up @@ -193,29 +190,6 @@ export const DatePickerRangeInput = forwardRef(function DatePickerRangeInput<
}
}, [cancelled]);

const startInputProps: {
onKeyDown: KeyboardEventHandler<HTMLInputElement>;
} = {
onKeyDown: (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "ArrowDown") {
setOpen(true);
}
startInputPropsProp?.onKeyDown?.(event);
},
...startInputPropsProp,
};
const endInputProps: {
onKeyDown: KeyboardEventHandler<HTMLInputElement>;
} = {
onKeyDown: (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "ArrowDown") {
setOpen(true);
}
endInputPropsProp?.onKeyDown?.(event);
},
...endInputPropsProp,
};

return (
<DateInputRange
value={
Expand Down
Loading

0 comments on commit c966b4f

Please sign in to comment.