Skip to content

Commit

Permalink
feat(react/DatePicker): add closeOnSelect prop to automatically clo…
Browse files Browse the repository at this point in the history
…se the date picker after a date is selected (#903)

* feat(react/DatePicker): add `closeOnSelect` to automatically dismiss date picker after selection

* chore: add changeset `tonic-ui-900.md`

* docs: update date picker examples

* refactor: correct import statements in Calendar and DatePicker components

* test: add tests for DatePicker component

* fix: address accessibility issues in `Calendar` components

* test: enhance test coverage for `DatePicker` component
  • Loading branch information
cheton authored Aug 4, 2024
1 parent 993100d commit 4bb6350
Show file tree
Hide file tree
Showing 16 changed files with 1,092 additions and 15 deletions.
5 changes: 5 additions & 0 deletions .changeset/tonic-ui-900.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tonic-ui/react": minor
---

feat(react/DatePicker): add `closeOnSelect` prop to automatically close the date picker after a date is selected
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ The following example demonstrates a simple example of the `DatePicker` componen

```jsx disabled
<DatePicker
closeOnSelect={true}
inputFormat="yyyy-MM-dd"
value={value}
onChange={(nextValue) => {
Expand Down Expand Up @@ -74,6 +75,7 @@ const DateInput = React.forwardRef((props, ref) => {

| Name | Type | Default | Description |
| :--- | :--- | :------ | :---------- |
| closeOnSelect | boolean | false | Determines if the date picker should close automatically after a date is selected. |
| defaultValue | Date \| string | | The default selected date. If the `defaultValue` is a string, it will be parsed to a `Date` object in accordance with the `inputFormat`. |
| firstDayOfWeek | number | 0 | The first day of the week.<br/>0 = Sunday<br/>1 = Monday<br/>2 = Tuesday<br/>3 = Wednesday<br/>4 = Thursday<br/>5 = Friday<br/>6 = Saturday |
| formatDate | function | | A callback called to return the formatted date string in the given format.<br/><br/><b>Signature</b><br/>`function(date, format, options) => void`<br/>_date_: The original date.<br/>_format_: The string of [format tokens](https://www.unicode.org/reports/tr35/tr35-dates.html#Date_Field_Symbol_Table).<br/>_options_: An object with options. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const CustomDateInput = ({

return (
<DatePicker
closeOnSelect={true}
value={value}
onChange={handleChange}
inputFormat={inputFormat}
Expand Down Expand Up @@ -358,7 +359,7 @@ const App = () => {
}
}}
>
{({ closeMenu }) => (
{({ onClose }) => (
<>
<MenuButton variant="secondary">
<Text>{mapValueToLabel(state.value)}</Text>
Expand All @@ -372,7 +373,7 @@ const App = () => {
endDate={state.endDate}
endTime={state.endTime}
onApply={({ startDate, startTime, endDate, endTime }) => {
closeMenu();
onClose();
setState({
value: 'custom',
startDate,
Expand All @@ -382,7 +383,7 @@ const App = () => {
});
}}
onClose={() => {
closeMenu();
onClose();
}}
/>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ const disableWeekends = (date) => {
const App = () => {
const [maxDate, setMaxDate] = useState('');
const [minDate, setMinDate] = useState('');
const [closeOnSelect, toggleCloseOnSelect] = useToggle(false);
const [dateOption, changeDateOptionBy] = useSelection('none');
const [firstDayOfWeek, changeFirstDayOfWeekBy] = useSelection(0);
const [inputFormat, changeInputFormatBy] = useSelection(inputFormats[0]);
Expand Down Expand Up @@ -144,6 +145,7 @@ const App = () => {
</Flex>
</FormGroup>
<DatePicker
closeOnSelect={closeOnSelect}
firstDayOfWeek={firstDayOfWeek}
formatDate={(date, format) => {
const options = {
Expand Down Expand Up @@ -250,6 +252,16 @@ const App = () => {
DatePicker props
</Text>
</Box>
<FormGroup>
<TextLabel display="flex" alignItems="center">
<Checkbox
checked={closeOnSelect}
onChange={toggleCloseOnSelect}
/>
<Space width="2x" />
<Text fontFamily="mono" whiteSpace="nowrap">closeOnSelect</Text>
</TextLabel>
</FormGroup>
<FormGroup>
<Box mb="2x">
<TextLabel>
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/date-pickers/Calendar/Calendar.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { Box } from '@tonic-ui/react';
import { useConst, usePrevious } from '@tonic-ui/react-hooks';
import { isNullOrUndefined } from '@tonic-ui/utils';
import endOfDay from 'date-fns/endOfDay';
Expand All @@ -10,6 +9,7 @@ import isValid from 'date-fns/isValid';
import startOfDay from 'date-fns/startOfDay';
import memoize from 'micro-memoize';
import React, { forwardRef, useCallback, useEffect, useState } from 'react';
import { Box } from '../../box';
import { validateDate } from '../validation';
import { CalendarProvider } from './context';
import MonthView from './MonthView';
Expand Down
6 changes: 4 additions & 2 deletions packages/react/src/date-pickers/Calendar/MonthView/Day.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Box } from '@tonic-ui/react';
import { useEventCallback } from '@tonic-ui/react-hooks';
import { dataAttr } from '@tonic-ui/utils';
import isAfter from 'date-fns/isAfter';
import isBefore from 'date-fns/isBefore';
import isSameDay from 'date-fns/isSameDay';
import isSameMonth from 'date-fns/isSameMonth';
import React, { forwardRef } from 'react';
import { Box } from '../../../box';
import { useDayStyle } from '../styles';
import useCalendar from '../useCalendar';

Expand Down Expand Up @@ -53,7 +54,8 @@ const Day = forwardRef((
return (
<Box
ref={ref}
aria-selected={isSelected}
// Only use `aria-selected` with these roles: `option`, `tab`, `menuitemradio`, `treeitem`, `gridcell`, `row`, `rowheader`, and `columnheader`.
data-selected={dataAttr(isSelected)}
onClick={isSelectable ? handleClick : undefined}
{...styleProps}
{...rest}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Box, Grid } from '@tonic-ui/react';
import addDays from 'date-fns/addDays';
import startOfWeek from 'date-fns/startOfWeek';
import React, { forwardRef } from 'react';
import { Box } from '../../../box';
import { Grid } from '../../../grid';
import useCalendar from '../useCalendar';
import { useDaysOfWeekStyle } from '../styles';

Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { Grid } from '@tonic-ui/react';
import addDays from 'date-fns/addDays';
import addWeeks from 'date-fns/addWeeks';
import isSameMonth from 'date-fns/isSameMonth';
import startOfMonth from 'date-fns/startOfMonth';
import startOfWeek from 'date-fns/startOfWeek';
import React, { forwardRef } from 'react';
import { Grid } from '../../../grid';
import useCalendar from '../useCalendar';
import Week from './Week';

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Box } from '@tonic-ui/react';
import React, { forwardRef } from 'react';
import { Box } from '../../../box';
import { useMonthViewStyle } from '../styles';
import DaysOfWeek from './DaysOfWeek';
import Weeks from './Weeks';
Expand Down
8 changes: 7 additions & 1 deletion packages/react/src/date-pickers/Calendar/Navigation.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { Box, Button, Text } from '@tonic-ui/react';
import { useEventCallback } from '@tonic-ui/react-hooks';
import { AngleLeftIcon, AngleRightIcon, AngleUpIcon, AngleDownIcon } from '@tonic-ui/react-icons';
import addMonths from 'date-fns/addMonths';
import addYears from 'date-fns/addYears';
import subMonths from 'date-fns/subMonths';
import subYears from 'date-fns/subYears';
import React, { forwardRef } from 'react';
import { Box } from '../../box';
import { Button } from '../../button';
import { Text } from '../../text';
import useCalendar from './useCalendar';
import {
useNavigationStyle,
Expand Down Expand Up @@ -52,6 +54,7 @@ const Navigation = forwardRef((props, ref) => {
{...props}
>
<Button
aria-label="Previous month"
variant="ghost"
onClick={onClickPreviousMonth}
{...monthButtonStyleProps}
Expand All @@ -64,13 +67,15 @@ const Navigation = forwardRef((props, ref) => {
</Text>
<Box {...yearButtonGroupStyleProps}>
<Button
aria-label="Previous year"
variant="ghost"
onClick={onClickPreviousYear}
{...yearButtonStyleProps}
>
<AngleUpIcon size="4x" />
</Button>
<Button
aria-label="Next year"
variant="ghost"
onClick={onClickNextYear}
{...yearButtonStyleProps}
Expand All @@ -80,6 +85,7 @@ const Navigation = forwardRef((props, ref) => {
</Box>
</Box>
<Button
aria-label="Next month"
variant="ghost"
onClick={onClickNextMonth}
{...monthButtonStyleProps}
Expand Down
3 changes: 2 additions & 1 deletion packages/react/src/date-pickers/Calendar/styles.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useColorMode, useColorStyle } from '@tonic-ui/react';
import { useColorMode } from '../../color-mode';
import { useColorStyle } from '../../color-style';

const useCalendarStyle = () => {
const [colorMode] = useColorMode();
Expand Down
9 changes: 7 additions & 2 deletions packages/react/src/date-pickers/DatePicker/DatePicker.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { Box } from '@tonic-ui/react';
import { useConst, useEventCallback, useMergeRefs, useOutsideClick, usePrevious, useToggle } from '@tonic-ui/react-hooks';
import { callEventHandlers, isNullOrUndefined } from '@tonic-ui/utils';
import format from 'date-fns/format';
Expand All @@ -9,6 +8,7 @@ import parse from 'date-fns/parse';
import startOfDay from 'date-fns/startOfDay';
import memoize from 'micro-memoize';
import React, { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
import { Box } from '../../box';
import config from '../../shared/config';
import useAutoId from '../../utils/useAutoId';
import Calendar from '../Calendar';
Expand Down Expand Up @@ -64,6 +64,7 @@ const mapValueToEndOfDay = (value) => {
const DatePicker = forwardRef((
{
children, // not used
closeOnSelect = false, // Note: The default value will be changed to true in the next major release
defaultValue: defaultValueProp,
firstDayOfWeek,
formatDate,
Expand Down Expand Up @@ -154,7 +155,11 @@ const DatePicker = forwardRef((
if (typeof onChangeProp === 'function') {
onChangeProp(nextDate);
}
}, [valueProp, inputFormat, onChangeProp]);

if (closeOnSelect) {
onClose();
}
}, [valueProp, inputFormat, onChangeProp, closeOnSelect, onClose]);

const onCalendarError = useCallback((error, value) => {
setError(error);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Collapse, Popper } from '@tonic-ui/react';
import { useEventCallback, useMergeRefs } from '@tonic-ui/react-hooks';
import { callAll, callEventHandlers } from '@tonic-ui/utils';
import { ensureArray, ensureFunction } from 'ensure-type';
import React, { forwardRef, useMemo, useRef } from 'react';
import { Collapse } from '../../transitions';
import { Popper } from '../../popper';
import { useDatePickerContentStyle } from './styles';
import useDatePicker from './useDatePicker';

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Box } from '@tonic-ui/react';
import { useMergeRefs } from '@tonic-ui/react-hooks';
import { callEventHandlers } from '@tonic-ui/utils';
import React, { forwardRef, useCallback } from 'react';
import { Box } from '../../box';
import {
useDatePickerToggleStyle,
} from './styles';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { screen, waitForElementToBeRemoved } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '@tonic-ui/react/test-utils/render';
import { transitionDuration } from '@tonic-ui/utils/src';
import { testA11y } from '@tonic-ui/react/test-utils/accessibility';
import {
Box,
DatePicker,
InputAdornment,
InputControl,
Text,
useColorStyle,
} from '@tonic-ui/react/src';
import { CalendarIcon } from '@tonic-ui/react-icons/src';
import * as dateFns from 'date-fns';
import React, { useCallback } from 'react';

describe('DatePicker', () => {
const TestComponent = (props) => {
const [colorStyle] = useColorStyle();
const inputFormat = 'MM/dd/yyyy';
const onChange = jest.fn();
const onError = jest.fn();
const inputError = false;
const value = new Date('2024-08-01');
const formatDate = useCallback((date, format) => {
return dateFns.format(date, format);
}, []);
const renderInput = useCallback(({ error, inputProps }) => {
return (
<Box>
<InputControl
{...inputProps}
startAdornment={(
<InputAdornment color={colorStyle.color.secondary}>
<CalendarIcon />
</InputAdornment>
)}
data-testid="date-picker-input"
error={inputError}
placeholder={inputFormat}
/>
{inputError && (
<Text mt="1x" color="red:50">Invalid date</Text>
)}
</Box>
);
}, [colorStyle, inputError, inputFormat]);

return (
<DatePicker
data-testid="date-picker"
closeOnSelect={false}
firstDayOfWeek={0}
formatDate={formatDate}
onChange={onChange}
onError={onError}
value={value}
inputFormat={inputFormat}
renderInput={renderInput}
{...props}
/>
);
};

it('should render correctly', async () => {
const user = userEvent.setup();
const renderOptions = {
useCSSVariables: true,
};
const { container } = render((
<TestComponent />
), renderOptions);

const datePicker = screen.getByTestId('date-picker');
const datePickerInput = screen.getByTestId('date-picker-input');

// The date picker and date picker input should be in the document
expect(datePicker).toBeInTheDocument();
expect(datePickerInput).toBeInTheDocument();

// Open the date picker
await user.click(datePickerInput);

// The "menu" role should be in the document
expect(await screen.findByRole('menu')).toBeInTheDocument();

expect(container).toMatchSnapshot();

await testA11y(container, {
axeOptions: {
rules: {
// FIXME: Certain ARIA roles must contain particular children (aria-required-children)"
'aria-required-children': { enabled: false },

// FIXME: Interactive controls must not be nested (nested-interactive)
'nested-interactive': { enabled: false },
},
},
});
});

it('should close the date picker when a date is selected and closeOnSelect is true', async () => {
const user = userEvent.setup();
render(<TestComponent closeOnSelect={true} />);

const datePickerInput = screen.getByTestId('date-picker-input');

// Open the date picker
await user.click(datePickerInput);

// Select a date
const dateButton = screen.getByText('15'); // Assuming 15th is a selectable date
await user.click(dateButton);

const duration = 100; // Shorten the duration to 100ms for testing
// The "menu" role should not be in the document
await waitForElementToBeRemoved(() => screen.getByRole('menu'), {
// The toast should be removed after the duration plus the transition.
timeout: duration + transitionDuration.standard + 100, // see "date-pickers/DatePicker/DatePickerContent.js"
});
});
});
Loading

0 comments on commit 4bb6350

Please sign in to comment.