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

enabled uncontrolled/un-controlled open behaviour for DatePicker #4500

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
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 uncontrolled/un-controlled open behaviour for `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.
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,34 @@ 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(<Range openOnClick />);
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(<Range openOnKeyDown />);
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);
});
});

describe("WHEN readOnly", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -35,7 +36,7 @@ const {
SingleWithMinMaxDate,
SingleWithTodayButton,
SingleCustomFormat,
} = datePickerStories as any; // not using composeStories yet, will break certain test below
} = datePickerStories as any;

describe("GIVEN a DatePicker where selectionVariant is single", () => {
describe("WHEN default state", () => {
Expand Down Expand Up @@ -65,6 +66,37 @@ 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(<Single openOnClick />);
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(<Single openOnKeyDown />);
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 control the overlay open state", () => {
cy.mount(<ControlledOpen />);
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", () => {
Expand All @@ -88,6 +120,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(<Single readOnly defaultOpen />);
cy.findByRole("application").should("not.exist");
});
});

adapters.forEach((adapter: SaltDateAdapter<DateFrameworkType>) => {
Expand Down Expand Up @@ -388,11 +425,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
18 changes: 16 additions & 2 deletions packages/lab/src/date-picker/DatePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ 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 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,12 +128,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, readOnly, ...rest } = props;
const {
defaultOpen,
open,
openOnClick,
openOnKeyDown,
onOpen,
readOnly,
...rest
} = props;

return (
<DatePickerOverlayProvider
open={open}
defaultOpen={defaultOpen}
open={open}
openOnClick={openOnClick}
openOnKeyDown={openOnKeyDown}
onOpen={onOpen}
readOnly={readOnly}
>
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
83 changes: 57 additions & 26 deletions packages/lab/src/date-picker/DatePickerOverlayProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import {
type ElementProps,
type FloatingContext,
type OpenChangeReason,
flip,
useClick,
useDismiss,
useInteractions,
} from "@floating-ui/react";
Expand All @@ -9,10 +12,10 @@ 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 +84,14 @@ interface DatePickerOverlayProviderProps {
* If `true`, the overlay is open.
*/
open?: 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,6 +105,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<ElementProps>;
/**
* When true, shouldn't open the overlay.
*/
Expand All @@ -102,46 +117,48 @@ interface DatePickerOverlayProviderProps {

export const DatePickerOverlayProvider: React.FC<
DatePickerOverlayProviderProps
> = ({ open: openProp, defaultOpen, onOpen, children, readOnly }) => {
const [open, setOpenState] = useControlled({
> = ({
open: openProp,
openOnClick,
openOnKeyDown = true,
defaultOpen,
onOpen,
children,
interactions,
readOnly,
}) => {
const [open, setOpenState, isOpenControlled] = useControlled({
controlled: openProp,
default: Boolean(defaultOpen),
default: readOnly ? false : 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 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)
Expand All @@ -162,7 +179,21 @@ export const DatePickerOverlayProvider: React.FC<
const {
getFloatingProps: _getFloatingPropsCallback,
getReferenceProps: _getReferenceProps,
} = useInteractions([useDismiss(floatingUIResult.context)]);
} = useInteractions(
interactions
? interactions(floatingUIResult.context)
: [
useDismiss(floatingUIResult.context),
useKeyboard(floatingUIResult.context, {
enabled: !!openOnKeyDown && !readOnly,
}),
useClick(floatingUIResult.context, {
enabled: !!openOnClick && !readOnly,
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
Loading