Skip to content

Commit

Permalink
feat(DatePicker, RangeDatePicker): add popup trigger props (#89)
Browse files Browse the repository at this point in the history
  • Loading branch information
ValeraS authored Jun 27, 2024
1 parent bdd9505 commit 5139151
Show file tree
Hide file tree
Showing 6 changed files with 140 additions and 15 deletions.
6 changes: 4 additions & 2 deletions src/components/DatePicker/DatePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import {DateField} from '../DateField';
import {HiddenInput} from '../HiddenInput/HiddenInput';
import type {
AccessibilityProps,
DateFieldBase,
DomProps,
FocusableProps,
InputDOMProps,
Expand All @@ -23,14 +22,15 @@ import type {

import {MobileCalendar} from './MobileCalendar';
import {StubButton} from './StubButton';
import type {DatePickerStateOptions} from './hooks/datePickerStateFactory';
import {useDatePickerProps} from './hooks/useDatePickerProps';
import {useDatePickerState} from './hooks/useDatePickerState';
import {b} from './utils';

import './DatePicker.scss';

export interface DatePickerProps<T = DateTime>
extends DateFieldBase<T>,
extends DatePickerStateOptions<T>,
TextInputProps,
FocusableProps,
KeyboardEvents,
Expand All @@ -39,6 +39,8 @@ export interface DatePickerProps<T = DateTime>
StyleProps,
AccessibilityProps {
children?: (props: CalendarProps<T>) => React.ReactNode;
disablePortal?: boolean;
disableFocusTrap?: boolean;
}

export function DatePicker({className, ...props}: DatePickerProps) {
Expand Down
30 changes: 30 additions & 0 deletions src/components/DatePicker/__stories__/DatePicker.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,33 @@ export const InsideDialog = {
);
},
} satisfies Story;

export const ControlledOpenState = {
...Default,
render: function ControlledOpenState(args) {
const [open, onOpenChange] = React.useState(false);
return (
<div>
{Default.render({
...args,
disableFocusTrap: true,
open,
onOpenChange: (newOpen, reason) => {
if (reason !== 'ClickOutside') {
onOpenChange(newOpen);
}
},
onFocus: (e) => {
if (e.target.nodeName !== 'BUTTON') {
onOpenChange(true);
}
},
onBlur: () => {
onOpenChange(false);
},
children: (props) => <Calendar {...props} autoFocus={false} />,
})}
</div>
);
},
} satisfies Story;
32 changes: 25 additions & 7 deletions src/components/DatePicker/hooks/datePickerStateFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,17 @@ import type {DateTime} from '@gravity-ui/date-utils';
import {useControlledState} from '@gravity-ui/uikit';

import type {DateFieldState} from '../../DateField';
import type {DateFieldBase} from '../../types';
import type {DateFieldBase, PopupTriggerProps} from '../../types';
import {useDefaultTimeZone} from '../../utils/useDefaultTimeZone';

type OpenChangeReason =
| 'EscapeKeyDown'
| 'FocusOut'
| 'ClickOutside'
| 'ValueSelected'
| 'TriggerButtonClick'
| 'ShortcutKeyDown';

export interface DatePickerState<T = DateTime> {
/** The currently selected date. */
value: T | null;
Expand Down Expand Up @@ -42,7 +50,7 @@ export interface DatePickerState<T = DateTime> {
/** Whether the calendar popover is currently open. */
isOpen: boolean;
/** Sets whether the calendar popover is open. */
setOpen: (isOpen: boolean) => void;
setOpen: (isOpen: boolean, reason: OpenChangeReason) => void;
dateFieldState: DateFieldState<T>;
}

Expand All @@ -54,7 +62,11 @@ export interface DatePickerStateFactoryOptions<T, O extends DateFieldBase<T>> {
useDateFieldState: (props: O) => DateFieldState<T>;
}

export function datePickerStateFactory<T, O extends DateFieldBase<T>>({
export interface DatePickerStateOptions<T>
extends DateFieldBase<T>,
PopupTriggerProps<[OpenChangeReason]> {}

export function datePickerStateFactory<T, O extends DatePickerStateOptions<T>>({
getPlaceholderTime,
mergeDateTime,
setTimezone,
Expand All @@ -63,7 +75,13 @@ export function datePickerStateFactory<T, O extends DateFieldBase<T>>({
}: DatePickerStateFactoryOptions<T, O>) {
return function useDatePickerState(props: O): DatePickerState<T> {
const {disabled, readOnly} = props;
const [isOpen, setOpen] = React.useState(false);
const [isOpen, _setOpen] = useControlledState(
props.open,
props.defaultOpen ?? false,
props.onOpenChange,
);

const setOpen: NonNullable<typeof props.onOpenChange> = _setOpen;

const [value, setValue] = useControlledState(
props.value as never,
Expand Down Expand Up @@ -144,7 +162,7 @@ export function datePickerStateFactory<T, O extends DateFieldBase<T>>({
}

if (shouldClose) {
setOpen(false);
setOpen(false, 'ValueSelected');
}
};

Expand Down Expand Up @@ -191,15 +209,15 @@ export function datePickerStateFactory<T, O extends DateFieldBase<T>>({
timeFormat,
timeZone,
isOpen,
setOpen(newIsOpen) {
setOpen(newIsOpen, reason) {
if (!newIsOpen && !value && selectedDate && dateFieldState.hasTime) {
commitValue(
selectedDate,
selectedTime || getPlaceholderTime(props.placeholderValue, props.timeZone),
);
}

setOpen(newIsOpen);
setOpen(newIsOpen, reason);
},
dateFieldState,
};
Expand Down
45 changes: 39 additions & 6 deletions src/components/DatePicker/hooks/useDatePickerProps.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from 'react';

import {isDateTime} from '@gravity-ui/date-utils';
import type {DateTime} from '@gravity-ui/date-utils';
import {useFocusWithin, useForkRef} from '@gravity-ui/uikit';
import type {ButtonProps, PopupProps, TextInputProps} from '@gravity-ui/uikit';
Expand Down Expand Up @@ -32,13 +33,35 @@ export function useDatePickerProps<T extends DateTime | RangeValue<DateTime>>(
): InnerDatePickerProps<T> {
const [isActive, setActive] = React.useState(false);

const [focusedDate, setFocusedDate] = React.useState(
isDateTime(state.dateFieldState.displayValue)
? state.dateFieldState.displayValue
: state.dateFieldState.displayValue.start,
);
const [prevDateValue, setPrevDateValue] = React.useState<any>(

Check warning on line 41 in src/components/DatePicker/hooks/useDatePickerProps.ts

View workflow job for this annotation

GitHub Actions / Verify Files

Unexpected any. Specify a different type
state.dateFieldState.displayValue,
);

if (isDateTime(state.dateFieldState.displayValue)) {
if (!state.dateFieldState.displayValue.isSame(prevDateValue, 'day')) {
setPrevDateValue(state.dateFieldState.displayValue);
setFocusedDate(state.dateFieldState.displayValue);
}
} else if (!state.dateFieldState.displayValue.start.isSame(prevDateValue.start, 'day')) {
setPrevDateValue(state.dateFieldState.displayValue);
setFocusedDate(state.dateFieldState.displayValue.start);
} else if (!state.dateFieldState.displayValue.end.isSame(prevDateValue.end, 'day')) {
setPrevDateValue(state.dateFieldState.displayValue);
setFocusedDate(state.dateFieldState.displayValue.end);
}

const {focusWithinProps} = useFocusWithin({
onFocusWithin: onFocus,
onBlurWithin: onBlur,
onFocusWithinChange(isFocusWithin) {
setActive(isFocusWithin);
if (!isFocusWithin) {
state.setOpen(false);
state.setOpen(false, 'FocusOut');
}
},
});
Expand Down Expand Up @@ -73,7 +96,7 @@ export function useDatePickerProps<T extends DateTime | RangeValue<DateTime>>(
if (!onlyTime && e.altKey && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
e.preventDefault();
e.stopPropagation();
state.setOpen(true);
state.setOpen(true, 'ShortcutKeyDown');
}
},
},
Expand All @@ -96,24 +119,32 @@ export function useDatePickerProps<T extends DateTime | RangeValue<DateTime>>(
view: 'flat-secondary',
onClick: () => {
setActive(true);
state.setOpen(!state.isOpen);
state.setOpen(!state.isOpen, 'TriggerButtonClick');
},
},
popupProps: {
open: state.isOpen,
onEscapeKeyDown: () => {
state.setOpen(false);
state.setOpen(false, 'EscapeKeyDown');
focusInput();
},
onOutsideClick: (e) => {
if (e.target !== calendarButtonRef.current) {
state.setOpen(false);
state.setOpen(false, 'ClickOutside');
}
if (e.target && groupRef.current?.contains(e.target as Node)) {
focusInput();
}
},
focusTrap: true,
onTransitionExited: () => {
setFocusedDate(
isDateTime(state.dateFieldState.displayValue)
? state.dateFieldState.displayValue
: state.dateFieldState.displayValue.start,
);
},
focusTrap: !props.disableFocusTrap,
disablePortal: props.disablePortal,
},
calendarProps: {
ref: calendarRef,
Expand All @@ -132,6 +163,8 @@ export function useDatePickerProps<T extends DateTime | RangeValue<DateTime>>(
maxValue: props.maxValue,
isDateUnavailable: props.isDateUnavailable,
timeZone: state.timeZone,
focusedValue: focusedDate,
onFocusUpdate: setFocusedDate,
},
timeInputProps: {
value: state.timeValue,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import React from 'react';

import {dateTime, dateTimeParse} from '@gravity-ui/date-utils';
import {toaster} from '@gravity-ui/uikit/toaster-singleton-react-18';
import type {Meta, StoryObj} from '@storybook/react';

import {RangeCalendar} from '../../Calendar';
import {RangeDatePicker} from '../RangeDatePicker';

import './RangeDatePicker.stories.scss';
Expand Down Expand Up @@ -98,3 +101,33 @@ function parseRangeDateTime(text: any, format?: string, timeZone?: string) {
const end = dateTimeParse(list?.[1]?.trim(), {format, timeZone}) ?? dateTime();
return {start, end};
}

export const ControlledOpenState = {
...Default,
render: function ControlledOpenState(args) {
const [open, onOpenChange] = React.useState(false);
return (
<div>
{Default.render({
...args,
disableFocusTrap: true,
open,
onOpenChange: (newOpen, reason) => {
if (reason !== 'ClickOutside') {
onOpenChange(newOpen);
}
},
onFocus: (e) => {
if (e.target.nodeName !== 'BUTTON') {
onOpenChange(true);
}
},
onBlur: () => {
onOpenChange(false);
},
children: (props) => <RangeCalendar {...props} autoFocus={false} />,
})}
</div>
);
},
} satisfies Story;
9 changes: 9 additions & 0 deletions src/components/types/datePicker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,12 @@ export interface DateFieldBase<T = DateTime> extends ValueBase<T | null>, InputB
*/
timeZone?: string;
}

export interface PopupTriggerProps<Args extends unknown[] = []> {
/** Whether the popup is open (controlled). */
open?: boolean;
/** Whether the popup is open by default (uncontrolled). */
defaultOpen?: boolean;
/** Handler that is called when the popup's open state changes. */
onOpenChange?: (open: boolean, ...args: Args) => void;
}

0 comments on commit 5139151

Please sign in to comment.