diff --git a/docs/layout/components/navigationMap.ts b/docs/layout/components/navigationMap.ts index 430b4e5a5..eec4732d9 100644 --- a/docs/layout/components/navigationMap.ts +++ b/docs/layout/components/navigationMap.ts @@ -38,6 +38,7 @@ export const navItems = [ { title: 'Guides', children: [ + { title: 'Accessibility', href: '/guides/accessibility' }, { title: 'Form integration', href: '/guides/form-integration' }, { title: 'CSS overrides', href: '/guides/css-overrides' }, { title: 'Global format customization', href: '/guides/formats' }, diff --git a/docs/layout/styleOverrides.ts b/docs/layout/styleOverrides.ts index af9885ab6..cab7c4075 100644 --- a/docs/layout/styleOverrides.ts +++ b/docs/layout/styleOverrides.ts @@ -43,6 +43,21 @@ export const createOverrides = (theme: Theme): StyleRules => ({ color: theme.palette.text.primary, }, li: theme.typography.body1, + '.mui-pickers-markdown-table': { + boxShadow: theme.shadows[3], + backgroundColor: theme.palette.background.paper, + borderCollapse: 'collapse', + + '& th, td': { + padding: 16, + ...theme.typography.body1, + border: `1px solid ${theme.palette.divider}`, + }, + + '& th': { + ...theme.typography.h6, + }, + }, code: { fontSize: 16, lineHeight: 1.4, diff --git a/docs/next.config.js b/docs/next.config.js index 43eaed3d4..0fe3f9c53 100644 --- a/docs/next.config.js +++ b/docs/next.config.js @@ -4,6 +4,7 @@ const withImages = require('next-images'); const withTypescript = require('@zeit/next-typescript'); const rehypePrism = require('@mapbox/rehype-prism'); const headings = require('./utils/anchor-autolink'); +const tableStyler = require('./utils/table-styler'); const withTM = require('next-transpile-modules'); const slug = require('remark-slug'); const webpack = require('webpack'); @@ -13,7 +14,7 @@ const withMDX = require('@zeit/next-mdx')({ extension: /\.(md|mdx)?$/, options: { hastPlugins: [rehypePrism], - mdPlugins: [slug, headings], + mdPlugins: [slug, headings, tableStyler], }, }); diff --git a/docs/package.json b/docs/package.json index 749081ca3..c147c1cda 100644 --- a/docs/package.json +++ b/docs/package.json @@ -31,6 +31,7 @@ "@types/prismjs": "^1.9.1", "@types/react": "^16.8.13", "@types/react-kawaii": "^0.11.0", + "@types/react-redux": "^7.1.7", "@types/sinon": "^7.0.13", "@zeit/next-bundle-analyzer": "^0.1.2", "@zeit/next-css": "^1.0.1", @@ -71,8 +72,7 @@ "sinon": "^7.3.2", "styled-jsx": "^3.2.4", "ts-loader": "^6.0.2", - "typescript": "^3.4.4", - "@types/react-redux": "^7.1.7" + "typescript": "^3.4.4" }, "devDependencies": { "dotenv": "^7.0.0", diff --git a/docs/pages/demo/datepicker/BasicDatePicker.example.jsx b/docs/pages/demo/datepicker/BasicDatePicker.example.jsx index 627fa5e3d..27de91f13 100644 --- a/docs/pages/demo/datepicker/BasicDatePicker.example.jsx +++ b/docs/pages/demo/datepicker/BasicDatePicker.example.jsx @@ -8,7 +8,6 @@ function BasicDatePicker() { handleDateChange(date)} /> ); diff --git a/docs/pages/guides/Accessibility.example.jsx b/docs/pages/guides/Accessibility.example.jsx new file mode 100644 index 000000000..133b01e40 --- /dev/null +++ b/docs/pages/guides/Accessibility.example.jsx @@ -0,0 +1,18 @@ +import React, { useState } from 'react'; +import { DatePicker } from '@material-ui/pickers'; + +function BasicDatePicker() { + const [selectedDate, handleDateChange] = useState(new Date()); + + return ( + handleDateChange(date)} + /> + ); +} + +export default BasicDatePicker; diff --git a/docs/pages/guides/accessibility.mdx b/docs/pages/guides/accessibility.mdx new file mode 100644 index 000000000..ad36dec24 --- /dev/null +++ b/docs/pages/guides/accessibility.mdx @@ -0,0 +1,58 @@ +import Ad from '_shared/Ad'; +import Example from '_shared/Example'; +import PageMeta from '_shared/PageMeta'; +import * as Accessibility from './Accessibility.example'; + + + +## Accessibility + + + +Pickers accessibility is highly impoertant, because this control is becoming completely unusable if there is +no appropriate focus managment. + +The dialog contains a calendar that uses the [grid](https://www.w3.org/TR/wai-aria-practices/#grid) pattern to present buttons that enable the user to choose a day from the calendar. +Choosing a date from the calendar closes the dialog and populates the date input field. When the dialog is opened, +if the input field is empty, or does not contain a valid date, then the current date is focused in the calendar. +Otherwise, the focus is placed on the day in the calendar that matches the value of the date input field. +It is possible to navigate throught the calendar, year selection and clock with only keyboard. + +But still there are some limitations: + +- You need to provide accessible placeholder (`mm/dd/yyyy`) according to used format and locale +- Masked input is not really well announcing via screen readers, so you may consider disable it + +### Example + +Here is working accessible example, compare it to the [wia-aria datepicker dialog example](https://www.w3.org/TR/wai-aria-practices/examples/dialog-modal/datepicker-dialog.html) + + + +### Keyboard shortcats + +Here is the list of avaialble keyboard shortcats allows to control the date and time pickers with only keyboard. + +#### DatePicker + +| Shortcat | Action | +| ------------ | ------------------------------------------------------------------------------------- | +| ArrowUp | Moves focus to the same day of the previous week. | +| ArrowDown | Moves focus to the same day of the next week. | +| ArrowRight | Moves focus to the next day. | +| ArrowLeft | Moves focus to the previous day. | +| Home | Moves focus to the first day (e.g Monday) of the current week. | +| End | Moves focus to the last day (e.g. Sunday) of the current week. | +| Esc | Close dialog or popover | +| Space, Enter | Choose currently focused year or date. Closing picker if current view is the last one | + +#### TimePicker + +| Shortcat | Action | +| ------------ | --------------------------------------------------------------------------------------- | +| ArrowUp | Incrment (hours/minutes/seconds) according to the used step | +| ArrowDown | Decrmeent (hours/minutes/seconds) according to the used step | +| Home | Select maximal value of current clock type (hours/minutes/seconds) | +| End | Select minimal value of current clock type (hours/minutes/seconds) | +| Esc | Close dialog or popover | +| Space, Enter | Accept currently selected selected value, move to the next view or close popover/dialog | diff --git a/docs/pages/regression/Regression.tsx b/docs/pages/regression/Regression.tsx index 590ce0c4a..aadf1b88a 100644 --- a/docs/pages/regression/Regression.tsx +++ b/docs/pages/regression/Regression.tsx @@ -3,8 +3,13 @@ import LeftArrowIcon from '@material-ui/icons/KeyboardArrowLeft'; import RightArrowIcon from '@material-ui/icons/KeyboardArrowRight'; import { Grid, Typography } from '@material-ui/core'; import { MuiPickersContext } from '@material-ui/pickers'; -import { MobileDatePicker, DesktopDatePicker } from '@material-ui/pickers'; import { createRegressionDay as createRegressionDayRenderer } from './RegressionDay'; +import { + MobileDatePicker, + DesktopDatePicker, + MobileTimePicker, + DesktopTimePicker, +} from '@material-ui/pickers'; function Regression() { const utils = useContext(MuiPickersContext); @@ -50,6 +55,15 @@ function Regression() { + + + TimePicker + + + + + + ); } diff --git a/docs/prop-types.json b/docs/prop-types.json index 2943be1db..63a36fa57 100644 --- a/docs/prop-types.json +++ b/docs/prop-types.json @@ -249,19 +249,95 @@ "name": "(date: DateIOType) => void" } }, - "reduceAnimations": { - "defaultValue": { - "value": "/(android)/i.test(navigator.userAgent)" + "leftArrowIcon": { + "defaultValue": null, + "description": "Left arrow icon", + "name": "leftArrowIcon", + "parent": { + "fileName": "material-ui-pickers/lib/src/views/Calendar/CalendarHeader.tsx", + "name": "CalendarHeaderProps" }, - "description": "Do not show heavy animations, significantly improves performance on slow devices", - "name": "reduceAnimations", + "required": false, + "type": { + "name": "ReactNode" + } + }, + "rightArrowIcon": { + "defaultValue": null, + "description": "Right arrow icon", + "name": "rightArrowIcon", "parent": { - "fileName": "material-ui-pickers/lib/src/views/Calendar/CalendarView.tsx", - "name": "CalendarViewProps" + "fileName": "material-ui-pickers/lib/src/views/Calendar/CalendarHeader.tsx", + "name": "CalendarHeaderProps" }, "required": false, "type": { - "name": "boolean" + "name": "ReactNode" + } + }, + "leftArrowButtonProps": { + "defaultValue": null, + "description": "Props to pass to left arrow button", + "name": "leftArrowButtonProps", + "parent": { + "fileName": "material-ui-pickers/lib/src/views/Calendar/CalendarHeader.tsx", + "name": "CalendarHeaderProps" + }, + "required": false, + "type": { + "name": "Partial" + } + }, + "rightArrowButtonProps": { + "defaultValue": null, + "description": "Props to pass to right arrow button", + "name": "rightArrowButtonProps", + "parent": { + "fileName": "material-ui-pickers/lib/src/views/Calendar/CalendarHeader.tsx", + "name": "CalendarHeaderProps" + }, + "required": false, + "type": { + "name": "Partial" + } + }, + "leftArrowButtonText": { + "defaultValue": null, + "description": "Left arrow icon aria-label text", + "name": "leftArrowButtonText", + "parent": { + "fileName": "material-ui-pickers/lib/src/views/Calendar/CalendarHeader.tsx", + "name": "CalendarHeaderProps" + }, + "required": false, + "type": { + "name": "string" + } + }, + "rightArrowButtonText": { + "defaultValue": null, + "description": "Right arrow icon aria-label text", + "name": "rightArrowButtonText", + "parent": { + "fileName": "material-ui-pickers/lib/src/views/Calendar/CalendarHeader.tsx", + "name": "CalendarHeaderProps" + }, + "required": false, + "type": { + "name": "string" + } + }, + "getViewSwitchingButtonText": { + "defaultValue": null, + "description": "Get aria-label text for switching between views button", + "name": "getViewSwitchingButtonText", + "parent": { + "fileName": "material-ui-pickers/lib/src/views/Calendar/CalendarHeader.tsx", + "name": "CalendarHeaderProps" + }, + "required": false, + "type": { + "name": "(currentView: \"date\" | \"year\" | \"month\") => string" } }, "minDate": { @@ -302,7 +378,7 @@ "name": "disablePast", "parent": { "fileName": "material-ui-pickers/lib/src/views/Calendar/Calendar.tsx", - "name": "CalendarProps" + "name": "ExportedCalendarProps" }, "required": false, "type": { @@ -317,117 +393,80 @@ "name": "disableFuture", "parent": { "fileName": "material-ui-pickers/lib/src/views/Calendar/Calendar.tsx", - "name": "CalendarProps" + "name": "ExportedCalendarProps" }, "required": false, "type": { "name": "boolean" } }, - "leftArrowIcon": { - "defaultValue": null, - "description": "Left arrow icon", - "name": "leftArrowIcon", - "parent": { - "fileName": "material-ui-pickers/lib/src/views/Calendar/Calendar.tsx", - "name": "CalendarProps" - }, - "required": false, - "type": { - "name": "ReactNode" - } - }, - "rightArrowIcon": { - "defaultValue": null, - "description": "Right arrow icon", - "name": "rightArrowIcon", - "parent": { - "fileName": "material-ui-pickers/lib/src/views/Calendar/Calendar.tsx", - "name": "CalendarProps" - }, - "required": false, - "type": { - "name": "ReactNode" - } - }, - "renderDay": { + "onMonthChange": { "defaultValue": null, - "description": "Custom renderer for day", - "name": "renderDay", + "description": "Callback firing on month change. Return promise to render spinner till it will not be resolved", + "name": "onMonthChange", "parent": { - "fileName": "material-ui-pickers/lib/src/views/Calendar/Calendar.tsx", - "name": "CalendarProps" + "fileName": "material-ui-pickers/lib/src/views/Calendar/CalendarView.tsx", + "name": "CalendarViewProps" }, "required": false, "type": { - "name": "(day: DateIOType, selectedDate: DateIOType, dayInCurrentMonth: boolean, dayComponent: Element) => Element" + "name": "(date: DateIOType) => void | Promise" } }, - "allowKeyboardControl": { + "reduceAnimations": { "defaultValue": { - "value": "true" + "value": "/(android)/i.test(navigator.userAgent)" }, - "description": "Enables keyboard listener for moving between days in calendar", - "name": "allowKeyboardControl", + "description": "Do not show heavy animations, significantly improves performance on slow devices", + "name": "reduceAnimations", "parent": { - "fileName": "material-ui-pickers/lib/src/views/Calendar/Calendar.tsx", - "name": "CalendarProps" + "fileName": "material-ui-pickers/lib/src/views/Calendar/CalendarView.tsx", + "name": "CalendarViewProps" }, "required": false, "type": { "name": "boolean" } }, - "leftArrowButtonProps": { + "shouldDisableDate": { "defaultValue": null, - "description": "Props to pass to left arrow button", - "name": "leftArrowButtonProps", + "description": "Disable specific date", + "name": "shouldDisableDate", "parent": { - "fileName": "material-ui-pickers/lib/src/views/Calendar/Calendar.tsx", - "name": "CalendarProps" + "fileName": "material-ui-pickers/lib/src/views/Calendar/CalendarView.tsx", + "name": "CalendarViewProps" }, "required": false, "type": { - "name": "Partial" + "name": "(day: DateIOType) => boolean" } }, - "rightArrowButtonProps": { + "renderDay": { "defaultValue": null, - "description": "Props to pass to right arrow button", - "name": "rightArrowButtonProps", + "description": "Custom renderer for day", + "name": "renderDay", "parent": { "fileName": "material-ui-pickers/lib/src/views/Calendar/Calendar.tsx", - "name": "CalendarProps" + "name": "ExportedCalendarProps" }, "required": false, "type": { - "name": "Partial" + "name": "(day: DateIOType, selectedDate: DateIOType, dayInCurrentMonth: boolean, dayComponent: Element) => Element" } }, - "shouldDisableDate": { - "defaultValue": null, - "description": "Disable specific date", - "name": "shouldDisableDate", - "parent": { - "fileName": "material-ui-pickers/lib/src/views/Calendar/Calendar.tsx", - "name": "CalendarProps" + "allowKeyboardControl": { + "defaultValue": { + "value": "currentWrapper !== 'static'" }, - "required": false, - "type": { - "name": "(day: DateIOType) => boolean" - } - }, - "onMonthChange": { - "defaultValue": null, - "description": "Callback firing on month change. Return promise to render spinner till it will not be resolved", - "name": "onMonthChange", + "description": "Enables keyboard listener for moving between days in calendar", + "name": "allowKeyboardControl", "parent": { "fileName": "material-ui-pickers/lib/src/views/Calendar/Calendar.tsx", - "name": "CalendarProps" + "name": "ExportedCalendarProps" }, "required": false, "type": { - "name": "(date: DateIOType) => void | Promise" + "name": "boolean" } }, "loadingIndicator": { @@ -436,7 +475,7 @@ "name": "loadingIndicator", "parent": { "fileName": "material-ui-pickers/lib/src/views/Calendar/Calendar.tsx", - "name": "CalendarProps" + "name": "ExportedCalendarProps" }, "required": false, "type": { @@ -896,6 +935,19 @@ "name": "boolean" } }, + "getOpenDialogAriaText": { + "defaultValue": null, + "description": "Get aria-label text for control that opens datepicker dialog. Aria-label have to include selected date.", + "name": "getOpenDialogAriaText", + "parent": { + "fileName": "material-ui-pickers/lib/src/_shared/PureDateInput.tsx", + "name": "DateInputProps" + }, + "required": false, + "type": { + "name": "(value: ParsableDate, utils: MuiPickersUtils) => string" + } + }, "views": { "defaultValue": null, "description": "Array of views to show", @@ -1447,6 +1499,19 @@ "type": { "name": "boolean" } + }, + "getOpenDialogAriaText": { + "defaultValue": null, + "description": "Get aria-label text for control that opens datepicker dialog. Aria-label have to include selected date.", + "name": "getOpenDialogAriaText", + "parent": { + "fileName": "material-ui-pickers/lib/src/_shared/PureDateInput.tsx", + "name": "DateInputProps" + }, + "required": false, + "type": { + "name": "(value: ParsableDate, utils: MuiPickersUtils) => string" + } } }, "DateTimePicker": { @@ -1903,6 +1968,19 @@ "name": "boolean" } }, + "getOpenDialogAriaText": { + "defaultValue": null, + "description": "Get aria-label text for control that opens datepicker dialog. Aria-label have to include selected date.", + "name": "getOpenDialogAriaText", + "parent": { + "fileName": "material-ui-pickers/lib/src/_shared/PureDateInput.tsx", + "name": "DateInputProps" + }, + "required": false, + "type": { + "name": "(value: ParsableDate, utils: MuiPickersUtils) => string" + } + }, "hideTabs": { "defaultValue": null, "description": "To show tabs", @@ -2000,19 +2078,95 @@ "name": "(date: DateIOType) => void" } }, - "reduceAnimations": { - "defaultValue": { - "value": "/(android)/i.test(navigator.userAgent)" + "leftArrowIcon": { + "defaultValue": null, + "description": "Left arrow icon", + "name": "leftArrowIcon", + "parent": { + "fileName": "material-ui-pickers/lib/src/views/Calendar/CalendarHeader.tsx", + "name": "CalendarHeaderProps" }, - "description": "Do not show heavy animations, significantly improves performance on slow devices", - "name": "reduceAnimations", + "required": false, + "type": { + "name": "ReactNode" + } + }, + "rightArrowIcon": { + "defaultValue": null, + "description": "Right arrow icon", + "name": "rightArrowIcon", "parent": { - "fileName": "material-ui-pickers/lib/src/views/Calendar/CalendarView.tsx", - "name": "CalendarViewProps" + "fileName": "material-ui-pickers/lib/src/views/Calendar/CalendarHeader.tsx", + "name": "CalendarHeaderProps" }, "required": false, "type": { - "name": "boolean" + "name": "ReactNode" + } + }, + "leftArrowButtonProps": { + "defaultValue": null, + "description": "Props to pass to left arrow button", + "name": "leftArrowButtonProps", + "parent": { + "fileName": "material-ui-pickers/lib/src/views/Calendar/CalendarHeader.tsx", + "name": "CalendarHeaderProps" + }, + "required": false, + "type": { + "name": "Partial" + } + }, + "rightArrowButtonProps": { + "defaultValue": null, + "description": "Props to pass to right arrow button", + "name": "rightArrowButtonProps", + "parent": { + "fileName": "material-ui-pickers/lib/src/views/Calendar/CalendarHeader.tsx", + "name": "CalendarHeaderProps" + }, + "required": false, + "type": { + "name": "Partial" + } + }, + "leftArrowButtonText": { + "defaultValue": null, + "description": "Left arrow icon aria-label text", + "name": "leftArrowButtonText", + "parent": { + "fileName": "material-ui-pickers/lib/src/views/Calendar/CalendarHeader.tsx", + "name": "CalendarHeaderProps" + }, + "required": false, + "type": { + "name": "string" + } + }, + "rightArrowButtonText": { + "defaultValue": null, + "description": "Right arrow icon aria-label text", + "name": "rightArrowButtonText", + "parent": { + "fileName": "material-ui-pickers/lib/src/views/Calendar/CalendarHeader.tsx", + "name": "CalendarHeaderProps" + }, + "required": false, + "type": { + "name": "string" + } + }, + "getViewSwitchingButtonText": { + "defaultValue": null, + "description": "Get aria-label text for switching between views button", + "name": "getViewSwitchingButtonText", + "parent": { + "fileName": "material-ui-pickers/lib/src/views/Calendar/CalendarHeader.tsx", + "name": "CalendarHeaderProps" + }, + "required": false, + "type": { + "name": "(currentView: DatePickerView) => string" } }, "minDate": { @@ -2053,7 +2207,7 @@ "name": "disablePast", "parent": { "fileName": "material-ui-pickers/lib/src/views/Calendar/Calendar.tsx", - "name": "CalendarProps" + "name": "ExportedCalendarProps" }, "required": false, "type": { @@ -2068,117 +2222,80 @@ "name": "disableFuture", "parent": { "fileName": "material-ui-pickers/lib/src/views/Calendar/Calendar.tsx", - "name": "CalendarProps" + "name": "ExportedCalendarProps" }, "required": false, "type": { "name": "boolean" } }, - "leftArrowIcon": { - "defaultValue": null, - "description": "Left arrow icon", - "name": "leftArrowIcon", - "parent": { - "fileName": "material-ui-pickers/lib/src/views/Calendar/Calendar.tsx", - "name": "CalendarProps" - }, - "required": false, - "type": { - "name": "ReactNode" - } - }, - "rightArrowIcon": { - "defaultValue": null, - "description": "Right arrow icon", - "name": "rightArrowIcon", - "parent": { - "fileName": "material-ui-pickers/lib/src/views/Calendar/Calendar.tsx", - "name": "CalendarProps" - }, - "required": false, - "type": { - "name": "ReactNode" - } - }, - "renderDay": { + "onMonthChange": { "defaultValue": null, - "description": "Custom renderer for day", - "name": "renderDay", + "description": "Callback firing on month change. Return promise to render spinner till it will not be resolved", + "name": "onMonthChange", "parent": { - "fileName": "material-ui-pickers/lib/src/views/Calendar/Calendar.tsx", - "name": "CalendarProps" + "fileName": "material-ui-pickers/lib/src/views/Calendar/CalendarView.tsx", + "name": "CalendarViewProps" }, "required": false, "type": { - "name": "(day: DateIOType, selectedDate: DateIOType, dayInCurrentMonth: boolean, dayComponent: Element) => Element" + "name": "(date: DateIOType) => void | Promise" } }, - "allowKeyboardControl": { + "reduceAnimations": { "defaultValue": { - "value": "true" + "value": "/(android)/i.test(navigator.userAgent)" }, - "description": "Enables keyboard listener for moving between days in calendar", - "name": "allowKeyboardControl", + "description": "Do not show heavy animations, significantly improves performance on slow devices", + "name": "reduceAnimations", "parent": { - "fileName": "material-ui-pickers/lib/src/views/Calendar/Calendar.tsx", - "name": "CalendarProps" + "fileName": "material-ui-pickers/lib/src/views/Calendar/CalendarView.tsx", + "name": "CalendarViewProps" }, "required": false, "type": { "name": "boolean" } }, - "leftArrowButtonProps": { + "shouldDisableDate": { "defaultValue": null, - "description": "Props to pass to left arrow button", - "name": "leftArrowButtonProps", + "description": "Disable specific date", + "name": "shouldDisableDate", "parent": { - "fileName": "material-ui-pickers/lib/src/views/Calendar/Calendar.tsx", - "name": "CalendarProps" + "fileName": "material-ui-pickers/lib/src/views/Calendar/CalendarView.tsx", + "name": "CalendarViewProps" }, "required": false, "type": { - "name": "Partial" + "name": "(day: DateIOType) => boolean" } }, - "rightArrowButtonProps": { + "renderDay": { "defaultValue": null, - "description": "Props to pass to right arrow button", - "name": "rightArrowButtonProps", + "description": "Custom renderer for day", + "name": "renderDay", "parent": { "fileName": "material-ui-pickers/lib/src/views/Calendar/Calendar.tsx", - "name": "CalendarProps" + "name": "ExportedCalendarProps" }, "required": false, "type": { - "name": "Partial" + "name": "(day: DateIOType, selectedDate: DateIOType, dayInCurrentMonth: boolean, dayComponent: Element) => Element" } }, - "shouldDisableDate": { - "defaultValue": null, - "description": "Disable specific date", - "name": "shouldDisableDate", - "parent": { - "fileName": "material-ui-pickers/lib/src/views/Calendar/Calendar.tsx", - "name": "CalendarProps" + "allowKeyboardControl": { + "defaultValue": { + "value": "currentWrapper !== 'static'" }, - "required": false, - "type": { - "name": "(day: DateIOType) => boolean" - } - }, - "onMonthChange": { - "defaultValue": null, - "description": "Callback firing on month change. Return promise to render spinner till it will not be resolved", - "name": "onMonthChange", + "description": "Enables keyboard listener for moving between days in calendar", + "name": "allowKeyboardControl", "parent": { "fileName": "material-ui-pickers/lib/src/views/Calendar/Calendar.tsx", - "name": "CalendarProps" + "name": "ExportedCalendarProps" }, "required": false, "type": { - "name": "(date: DateIOType) => void | Promise" + "name": "boolean" } }, "loadingIndicator": { @@ -2187,7 +2304,7 @@ "name": "loadingIndicator", "parent": { "fileName": "material-ui-pickers/lib/src/views/Calendar/Calendar.tsx", - "name": "CalendarProps" + "name": "ExportedCalendarProps" }, "required": false, "type": { @@ -2228,7 +2345,7 @@ "name": "date", "parent": { "fileName": "material-ui-pickers/lib/src/views/Calendar/Calendar.tsx", - "name": "CalendarProps" + "name": "ExportedCalendarProps" }, "required": true, "type": { @@ -2241,11 +2358,11 @@ "name": "onChange", "parent": { "fileName": "material-ui-pickers/lib/src/views/Calendar/Calendar.tsx", - "name": "CalendarProps" + "name": "ExportedCalendarProps" }, "required": true, "type": { - "name": "(date: any, isFinish?: boolean) => void" + "name": "PickerOnChangeFn" } }, "disablePast": { @@ -2256,7 +2373,7 @@ "name": "disablePast", "parent": { "fileName": "material-ui-pickers/lib/src/views/Calendar/Calendar.tsx", - "name": "CalendarProps" + "name": "ExportedCalendarProps" }, "required": false, "type": { @@ -2271,46 +2388,20 @@ "name": "disableFuture", "parent": { "fileName": "material-ui-pickers/lib/src/views/Calendar/Calendar.tsx", - "name": "CalendarProps" + "name": "ExportedCalendarProps" }, "required": false, "type": { "name": "boolean" } }, - "leftArrowIcon": { - "defaultValue": null, - "description": "Left arrow icon", - "name": "leftArrowIcon", - "parent": { - "fileName": "material-ui-pickers/lib/src/views/Calendar/Calendar.tsx", - "name": "CalendarProps" - }, - "required": false, - "type": { - "name": "ReactNode" - } - }, - "rightArrowIcon": { - "defaultValue": null, - "description": "Right arrow icon", - "name": "rightArrowIcon", - "parent": { - "fileName": "material-ui-pickers/lib/src/views/Calendar/Calendar.tsx", - "name": "CalendarProps" - }, - "required": false, - "type": { - "name": "ReactNode" - } - }, "renderDay": { "defaultValue": null, "description": "Custom renderer for day", "name": "renderDay", "parent": { "fileName": "material-ui-pickers/lib/src/views/Calendar/Calendar.tsx", - "name": "CalendarProps" + "name": "ExportedCalendarProps" }, "required": false, "type": { @@ -2319,78 +2410,26 @@ }, "allowKeyboardControl": { "defaultValue": { - "value": "true" + "value": "currentWrapper !== 'static'" }, "description": "Enables keyboard listener for moving between days in calendar", "name": "allowKeyboardControl", "parent": { "fileName": "material-ui-pickers/lib/src/views/Calendar/Calendar.tsx", - "name": "CalendarProps" + "name": "ExportedCalendarProps" }, "required": false, "type": { "name": "boolean" } }, - "leftArrowButtonProps": { - "defaultValue": null, - "description": "Props to pass to left arrow button", - "name": "leftArrowButtonProps", - "parent": { - "fileName": "material-ui-pickers/lib/src/views/Calendar/Calendar.tsx", - "name": "CalendarProps" - }, - "required": false, - "type": { - "name": "Partial" - } - }, - "rightArrowButtonProps": { - "defaultValue": null, - "description": "Props to pass to right arrow button", - "name": "rightArrowButtonProps", - "parent": { - "fileName": "material-ui-pickers/lib/src/views/Calendar/Calendar.tsx", - "name": "CalendarProps" - }, - "required": false, - "type": { - "name": "Partial" - } - }, - "shouldDisableDate": { - "defaultValue": null, - "description": "Disable specific date", - "name": "shouldDisableDate", - "parent": { - "fileName": "material-ui-pickers/lib/src/views/Calendar/Calendar.tsx", - "name": "CalendarProps" - }, - "required": false, - "type": { - "name": "(day: DateIOType) => boolean" - } - }, - "onMonthChange": { - "defaultValue": null, - "description": "Callback firing on month change. Return promise to render spinner till it will not be resolved", - "name": "onMonthChange", - "parent": { - "fileName": "material-ui-pickers/lib/src/views/Calendar/Calendar.tsx", - "name": "CalendarProps" - }, - "required": false, - "type": { - "name": "(date: DateIOType) => void | Promise" - } - }, "loadingIndicator": { "defaultValue": null, "description": "Custom loading indicator", "name": "loadingIndicator", "parent": { "fileName": "material-ui-pickers/lib/src/views/Calendar/Calendar.tsx", - "name": "CalendarProps" + "name": "ExportedCalendarProps" }, "required": false, "type": { @@ -2435,46 +2474,80 @@ }, "required": true, "type": { - "name": "(date: DateIOType, isFinish?: boolean) => void" + "name": "PickerOnChangeFn" } }, - "onHourChange": { + "onChange": { "defaultValue": null, - "description": "On hour change", - "name": "onHourChange", + "description": "On change callback", + "name": "onChange", "parent": { "fileName": "material-ui-pickers/lib/src/views/Clock/ClockView.tsx", "name": "ClockViewProps" }, "required": true, "type": { - "name": "(date: DateIOType, isFinish?: boolean) => void" + "name": "PickerOnChangeFn" } }, - "onMinutesChange": { - "defaultValue": null, - "description": "On minutes change", - "name": "onMinutesChange", + "getHoursClockNumberText": { + "defaultValue": { + "value": null + }, + "description": "Get clock number aria-text for hours", + "name": "getHoursClockNumberText", "parent": { "fileName": "material-ui-pickers/lib/src/views/Clock/ClockView.tsx", "name": "ClockViewProps" }, - "required": true, + "required": false, "type": { - "name": "(date: DateIOType, isFinish?: boolean) => void" + "name": "(hoursText: string) => string" } }, - "onSecondsChange": { - "defaultValue": null, - "description": "On seconds change", - "name": "onSecondsChange", + "getMinutesClockNumberText": { + "defaultValue": { + "value": null + }, + "description": "Get clock number aria-text for minutes", + "name": "getMinutesClockNumberText", "parent": { "fileName": "material-ui-pickers/lib/src/views/Clock/ClockView.tsx", "name": "ClockViewProps" }, - "required": true, + "required": false, + "type": { + "name": "(minutesText: string) => string" + } + }, + "getSecondsClockNumberText": { + "defaultValue": { + "value": null + }, + "description": "Get clock number aria-text for seconds", + "name": "getSecondsClockNumberText", + "parent": { + "fileName": "material-ui-pickers/lib/src/views/Clock/ClockView.tsx", + "name": "ClockViewProps" + }, + "required": false, "type": { - "name": "(date: DateIOType, isFinish?: boolean) => void" + "name": "(secondsText: string) => string" + } + }, + "allowKeyboardControl": { + "defaultValue": { + "value": "currentWrapper !== 'static'" + }, + "description": "Enables keyboard listener for moving between days in calendar", + "name": "allowKeyboardControl", + "parent": { + "fileName": "material-ui-pickers/lib/src/views/Clock/ClockView.tsx", + "name": "ClockViewProps" + }, + "required": false, + "type": { + "name": "boolean" } }, "ampm": { diff --git a/docs/utils/table-styler.js b/docs/utils/table-styler.js new file mode 100644 index 000000000..79b4283b4 --- /dev/null +++ b/docs/utils/table-styler.js @@ -0,0 +1,7 @@ +const visit = require('unist-util-visit'); + +module.exports = () => tree => { + visit(tree, 'table', node => { + node.data = { hProperties: { className: ['mui-pickers-markdown-table'] } }; + }); +}; diff --git a/e2e/integration/DatePicker.spec.ts b/e2e/integration/DatePicker.spec.ts index 2b8d325c1..0665c1636 100644 --- a/e2e/integration/DatePicker.spec.ts +++ b/e2e/integration/DatePicker.spec.ts @@ -50,7 +50,8 @@ describe('DatePicker', () => { cy.get(`input${ids.basic}`).should('have.value', '02/11/2019'); }); - it('Should open mobile keyboard input by clicking on button', () => { + // TODO FIX flaky test + it.skip('Should open mobile keyboard input by clicking on button', () => { cy.get(ids.clearable).click({ force: true }); cy.get('div[role="dialog"] [data-mui-test="toggle-mobile-keyboard-view"]').click({ force: true, diff --git a/e2e/integration/KeyboardNavigation.spec.ts b/e2e/integration/KeyboardNavigation.spec.ts new file mode 100644 index 000000000..d1158434b --- /dev/null +++ b/e2e/integration/KeyboardNavigation.spec.ts @@ -0,0 +1,85 @@ +describe('Keyboard navigation', () => { + beforeEach(() => { + cy.visit('/regression'); + }); + + context('DatePicker', () => { + function testCalendarKeyboardNavigation() { + cy.get('body').type('{rightarrow}'); + cy.get('[data-mui-test="day"][aria-label="Jan 2, 2019"]').should('be.focused'); + + cy.get('body').type('{leftarrow}'); + cy.get('[data-mui-test="day"][aria-label="Jan 1, 2019"]').should('be.focused'); + + // check month switching + cy.get('body').type('{leftarrow}'); + cy.get('[data-mui-test="day"][aria-label="Dec 31, 2018"]').should('be.focused'); + + cy.get('body').type('{uparrow}'); + cy.get('[data-mui-test="day"][aria-label="Dec 24, 2018"]').should('be.focused'); + + cy.get('body').type('{downarrow}{downarrow}'); + cy.get('[data-mui-test="day"][aria-label="Jan 7, 2019"]').should('be.focused'); + + cy.get('body').type('{home}'); + cy.get('[data-mui-test="day"][aria-label="Jan 6, 2019"]').should('be.focused'); + + cy.get('body').type('{end}'); + cy.get('[data-mui-test="day"][aria-label="Jan 12, 2019"]').should('be.focused'); + } + + it('Modal calendar allows to change date with keyboard', () => { + cy.get('#basic-datepicker') + .focus() + .type(' '); + + testCalendarKeyboardNavigation(); + }); + + it('Popover calendar allows to change date with keyboard', () => { + cy.get('[data-mui-test="open-picker-from-keyboard"]') + .eq(1) + .type(' '); + + testCalendarKeyboardNavigation(); + }); + + it('Submits chosen date from keyboard and closing picker', () => { + cy.get('#basic-datepicker').click({ force: true }); + + cy.get('body').type('{downarrow}'); + cy.focused().type('{enter}'); + + cy.get('div[role="dialog"]').should('not.be.visible'); + cy.get('#basic-datepicker').should('have.value', '01/08/2019'); + }); + }); + + context('TimePicker', () => { + it('Allows keyboard control on hours view', () => { + cy.get('#mobile-timepicker').click({ force: true }); + cy.get('body').type('{uparrow}{downarrow}{uparrow}{uparrow}{uparrow}'); + + cy.get('[aria-label="3 hours"]').should('be.focused'); + cy.contains('03:00'); + }); + + it('Allows keyboard control on minutes view', () => { + cy.get('#mobile-timepicker').click({ force: true }); + + cy.focused().type('{enter}'); + cy.contains('12:00'); + + cy.focused().type( + Array(15) + .fill('{downarrow}') + .join('') + ); + cy.contains('11:45'); + cy.focused().type(' '); + + cy.get('div[role="dialog"]').should('not.be.visible'); + cy.get('#mobile-timepicker').should('have.value', '11:45 PM'); + }); + }); +}); diff --git a/lib/src/DatePicker/DatePicker.tsx b/lib/src/DatePicker/DatePicker.tsx index 1ae80f01b..2bee71c4a 100644 --- a/lib/src/DatePicker/DatePicker.tsx +++ b/lib/src/DatePicker/DatePicker.tsx @@ -4,7 +4,7 @@ import { DatePickerToolbar } from './DatePickerToolbar'; import { getFormatByViews } from '../_helpers/date-utils'; import { datePickerDefaultProps } from '../constants/prop-types'; import { ResponsiveWrapper } from '../wrappers/ResponsiveWrapper'; -import { ExportedCalendarProps } from '../views/Calendar/CalendarView'; +import { ExportedCalendarViewProps } from '../views/Calendar/CalendarView'; import { ModalWrapper, InlineWrapper, StaticWrapper } from '../wrappers/Wrapper'; import { WithDateInputProps, @@ -14,7 +14,7 @@ import { export type DatePickerView = 'year' | 'date' | 'month'; -export interface BaseDatePickerProps extends ExportedCalendarProps { +export interface BaseDatePickerProps extends ExportedCalendarViewProps { /** Callback firing on year change @DateIOType */ onYearChange?: (date: MaterialUiPickersDate) => void; } diff --git a/lib/src/DateTimePicker/DateTimePickerTabs.tsx b/lib/src/DateTimePicker/DateTimePickerTabs.tsx index 7f7f85dd6..8aa46e8b5 100644 --- a/lib/src/DateTimePicker/DateTimePickerTabs.tsx +++ b/lib/src/DateTimePicker/DateTimePickerTabs.tsx @@ -71,8 +71,8 @@ export const DateTimePickerTabs: React.SFC = ({ className={classes.tabs} indicatorColor={indicatorColor} > - {dateRangeIcon}} /> - {timeIcon}} /> + {dateRangeIcon}} /> + {timeIcon}} /> ); diff --git a/lib/src/DateTimePicker/DateTimePickerToolbar.tsx b/lib/src/DateTimePicker/DateTimePickerToolbar.tsx index 291c14797..69c3e0ca2 100644 --- a/lib/src/DateTimePicker/DateTimePickerToolbar.tsx +++ b/lib/src/DateTimePicker/DateTimePickerToolbar.tsx @@ -65,6 +65,7 @@ export const DateTimePickerToolbar: React.FC = ({ >
setOpenView('year')} selected={openView === 'year'} @@ -72,6 +73,7 @@ export const DateTimePickerToolbar: React.FC = ({ /> setOpenView('date')} selected={openView === 'date'} @@ -81,6 +83,7 @@ export const DateTimePickerToolbar: React.FC = ({
setOpenView('hours')} selected={openView === 'hours'} @@ -91,6 +94,7 @@ export const DateTimePickerToolbar: React.FC = ({ setOpenView('minutes')} selected={openView === 'minutes'} diff --git a/lib/src/Picker/Picker.tsx b/lib/src/Picker/Picker.tsx index 292f5cda7..2cf21d994 100644 --- a/lib/src/Picker/Picker.tsx +++ b/lib/src/Picker/Picker.tsx @@ -34,6 +34,7 @@ export type ToolbarComponentProps = BaseDatePickerPr ampmInClock?: boolean; isMobileKeyboardViewOpen: boolean; toggleMobileKeyboardView: () => void; + getMobileKeyboardInputViewButtonText?: () => string; }; export interface PickerViewProps @@ -58,7 +59,7 @@ interface PickerProps extends PickerViewProps { onDateChange: ( date: MaterialUiPickersDate, currentVariant: WrapperVariant, - isFinish?: boolean + isFinish?: boolean | symbol ) => void; } @@ -104,7 +105,7 @@ export function Picker({ const isLandscape = useIsLandscape(views, orientation); const wrapperVariant = React.useContext(WrapperVariantContext); const onChange = React.useCallback( - (date: MaterialUiPickersDate, isFinish?: boolean) => { + (date: MaterialUiPickersDate, isFinish?: boolean | symbol) => { onDateChange(date, wrapperVariant, isFinish); }, [onDateChange, wrapperVariant] @@ -170,9 +171,7 @@ export function Picker({ date={date} type={openView as 'hours' | 'minutes' | 'seconds'} onDateChange={onChange} - onHourChange={handleChangeAndOpenNext} - onMinutesChange={handleChangeAndOpenNext} - onSecondsChange={handleChangeAndOpenNext} + onChange={handleChangeAndOpenNext} /> )} diff --git a/lib/src/Picker/makePickerWithState.tsx b/lib/src/Picker/makePickerWithState.tsx index 9741cdaa2..c87ee5d2e 100644 --- a/lib/src/Picker/makePickerWithState.tsx +++ b/lib/src/Picker/makePickerWithState.tsx @@ -76,6 +76,8 @@ export function makePickerWithStateAndWrapper< title, invalidDateMessage, minDateMessage, + wider, + showTabs, maxDateMessage, // WrapperProps clearable, @@ -100,6 +102,8 @@ export function makePickerWithStateAndWrapper< todayLabel={todayLabel} cancelLabel={cancelLabel} DateInputProps={inputProps} + wider={wider} + showTabs={showTabs} {...wrapperProps} {...restPropsForTextField} > diff --git a/lib/src/TimePicker/TimePicker.tsx b/lib/src/TimePicker/TimePicker.tsx index 15839e164..840fd252c 100644 --- a/lib/src/TimePicker/TimePicker.tsx +++ b/lib/src/TimePicker/TimePicker.tsx @@ -1,9 +1,9 @@ -import { useUtils } from '../_shared/hooks/useUtils'; import { TimePickerToolbar } from './TimePickerToolbar'; import { BaseClockViewProps } from '../views/Clock/ClockView'; -import { timePickerDefaultProps } from '../constants/prop-types'; import { ResponsiveWrapper } from '../wrappers/ResponsiveWrapper'; import { pick12hOr24hFormat } from '../_helpers/text-field-helper'; +import { useUtils, MuiPickersUtils } from '../_shared/hooks/useUtils'; +import { timePickerDefaultProps, ParsableDate } from '../constants/prop-types'; import { ModalWrapper, InlineWrapper, StaticWrapper } from '../wrappers/Wrapper'; import { WithDateInputProps, @@ -16,6 +16,12 @@ export interface TimePickerProps WithViewsProps<'hours' | 'minutes' | 'seconds'>, WithDateInputProps {} +export function getTextFieldAriaText(value: ParsableDate, utils: MuiPickersUtils) { + return value && utils.isValid(utils.date(value)) + ? `Choose time, selected time is ${utils.format(utils.date(value), 'fullTime')}` + : 'Choose time'; +} + function useDefaultProps({ ampm, format, @@ -33,6 +39,7 @@ function useDefaultProps({ ampm: willUseAmPm, acceptRegex: willUseAmPm ? /[\dapAP]/gi : /\d/gi, mask: mask || willUseAmPm ? '__:__ _M' : '__:__', + getOpenDialogAriaText: getTextFieldAriaText, format: pick12hOr24hFormat(format, ampm, { localized: utils.formats.fullTime, '12h': utils.formats.fullTime12h, diff --git a/lib/src/TimePicker/TimePickerToolbar.tsx b/lib/src/TimePicker/TimePickerToolbar.tsx index 762cabe88..e3c144e60 100644 --- a/lib/src/TimePicker/TimePickerToolbar.tsx +++ b/lib/src/TimePicker/TimePickerToolbar.tsx @@ -68,6 +68,8 @@ export function useMeridiemMode( return { meridiemMode, handleMeridiemChange }; } +const clockTypographyVariant = 'h3'; + export const TimePickerToolbar: React.FC = ({ date, views, @@ -87,8 +89,6 @@ export const TimePickerToolbar: React.FC = ({ const showAmPmControl = ampm && !ampmInClock; const { meridiemMode, handleMeridiemChange } = useMeridiemMode(date, ampm, onChange); - const clockTypographyVariant = 'h3'; - return ( = ({ > {arrayIncludes(views, 'hours') && ( setOpenView('hours')} selected={openView === 'hours'} @@ -115,6 +116,7 @@ export const TimePickerToolbar: React.FC = ({ {arrayIncludes(views, ['hours', 'minutes']) && ( = ({ {arrayIncludes(views, 'minutes') && ( setOpenView('minutes')} selected={openView === 'minutes'} @@ -152,9 +155,9 @@ export const TimePickerToolbar: React.FC = ({ })} > = ({ /> , + utils: MuiPickersUtils, { format, invalidLabel = '', @@ -49,7 +55,11 @@ export interface DateValidationProps extends BaseValidationProps { maxDateMessage?: React.ReactNode; } -const getComparisonMaxDate = (utils: IUtils, strictCompareDates: boolean, date: Date) => { +const getComparisonMaxDate = ( + utils: MuiPickersUtils, + strictCompareDates: boolean, + date: MaterialUiPickersDate +) => { if (strictCompareDates) { return date; } @@ -57,7 +67,11 @@ const getComparisonMaxDate = (utils: IUtils, strictCompareDates: boolean, d return utils.endOfDay(date); }; -const getComparisonMinDate = (utils: IUtils, strictCompareDates: boolean, date: Date) => { +const getComparisonMinDate = ( + utils: MuiPickersUtils, + strictCompareDates: boolean, + date: MaterialUiPickersDate +) => { if (strictCompareDates) { return date; } @@ -67,7 +81,7 @@ const getComparisonMinDate = (utils: IUtils, strictCompareDates: boolean, d export const validate = ( value: ParsableDate, - utils: IUtils, + utils: MuiPickersUtils, { maxDate, minDate, @@ -149,7 +163,7 @@ export function checkMaskIsValidForCurrentFormat( maskChar: string, format: string, acceptRegex: RegExp, - utils: IUtils + utils: MuiPickersUtils ) { const formattedDateWith1Digit = utils.formatByString( utils.date(staticDateWith1DigitTokens), diff --git a/lib/src/_helpers/utils.ts b/lib/src/_helpers/utils.ts index a2c0c29b4..912e3d1eb 100644 --- a/lib/src/_helpers/utils.ts +++ b/lib/src/_helpers/utils.ts @@ -1,3 +1,5 @@ +import * as React from 'react'; + /** Use it instead of .includes method for IE support */ export function arrayIncludes(array: T[] | readonly T[], itemOrItems: T | T[]) { if (Array.isArray(itemOrItems)) { @@ -6,3 +8,13 @@ export function arrayIncludes(array: T[] | readonly T[], itemOrItems: T | T[] return array.indexOf(itemOrItems) !== -1; } + +export const onSpaceOrEnter = (innerFn: () => void) => (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + innerFn(); + + // prevent any side effects + e.preventDefault(); + e.stopPropagation(); + } +}; diff --git a/lib/src/_shared/KeyboardDateInput.tsx b/lib/src/_shared/KeyboardDateInput.tsx index 19b6037ce..caf758329 100644 --- a/lib/src/_shared/KeyboardDateInput.tsx +++ b/lib/src/_shared/KeyboardDateInput.tsx @@ -11,6 +11,7 @@ import { maskedDateFormatter, getDisplayDate, checkMaskIsValidForCurrentFormat, + getTextFieldAriaText, } from '../_helpers/text-field-helper'; export const KeyboardDateInput: React.FC = ({ @@ -38,6 +39,7 @@ export const KeyboardDateInput: React.FC = ({ ignoreInvalidInputs, onFocus, onBlur, + getOpenDialogAriaText = getTextFieldAriaText, ...other }) => { const utils = useUtils(); @@ -93,7 +95,7 @@ export const KeyboardDateInput: React.FC = ({ const adornmentPosition = InputAdornmentProps?.position || 'end'; const inputProps = { - type: 'tel', + type: shouldUseMaskedInput ? 'tel' : 'text', disabled, placeholder, variant: variant as any, @@ -110,6 +112,7 @@ export const KeyboardDateInput: React.FC = ({ diff --git a/lib/src/_shared/ModalDialog.tsx b/lib/src/_shared/ModalDialog.tsx index 60125a2cb..24c09a5a9 100644 --- a/lib/src/_shared/ModalDialog.tsx +++ b/lib/src/_shared/ModalDialog.tsx @@ -30,6 +30,11 @@ export const useStyles = makeStyles( dialogRootWider: { minWidth: DIALOG_WIDTH_WIDER, }, + dialogContainer: { + '&:focus > $dialogRoot': { + outline: 'auto', + }, + }, dialog: { '&:first-child': { padding: 0, @@ -69,6 +74,7 @@ export const ModalDialog: React.FC = ({ , - Pick { + Pick< + ToolbarComponentProps, + | 'getMobileKeyboardInputViewButtonText' + | 'isMobileKeyboardViewOpen' + | 'toggleMobileKeyboardView' + > { title: string; landscapeDirection?: 'row' | 'column'; isLandscape: boolean; penIconClassName?: string; } +function defaultGetKeyboardInputSwitchingButtonText(isKeyboardInputOpen: boolean) { + return isKeyboardInputOpen + ? 'text input view is open, go to calendar view' + : 'calendar view is open, go to text input view'; +} + const PickerToolbar: React.SFC = ({ children, isLandscape, @@ -58,6 +69,7 @@ const PickerToolbar: React.SFC = ({ penIconClassName, toggleMobileKeyboardView, isMobileKeyboardViewOpen, + getMobileKeyboardInputViewButtonText = defaultGetKeyboardInputSwitchingButtonText, ...other }) => { const classes = useStyles(); @@ -82,6 +94,7 @@ const PickerToolbar: React.SFC = ({ className={penIconClassName} color="inherit" data-mui-test="toggle-mobile-keyboard-view" + aria-label={getMobileKeyboardInputViewButtonText(isMobileKeyboardViewOpen)} > {isMobileKeyboardViewOpen ? ( diff --git a/lib/src/_shared/PureDateInput.tsx b/lib/src/_shared/PureDateInput.tsx index 33eb38d19..ef3c613dd 100644 --- a/lib/src/_shared/PureDateInput.tsx +++ b/lib/src/_shared/PureDateInput.tsx @@ -1,12 +1,13 @@ import * as React from 'react'; import TextField, { TextFieldProps } from '@material-ui/core/TextField'; -import { useUtils } from './hooks/useUtils'; import { ExtendMui } from '../typings/helpers'; +import { onSpaceOrEnter } from '../_helpers/utils'; import { ParsableDate } from '../constants/prop-types'; import { MaterialUiPickersDate } from '../typings/date'; +import { useUtils, MuiPickersUtils } from './hooks/useUtils'; import { IconButtonProps } from '@material-ui/core/IconButton'; -import { getDisplayDate } from '../_helpers/text-field-helper'; import { InputAdornmentProps } from '@material-ui/core/InputAdornment'; +import { getDisplayDate, getTextFieldAriaText } from '../_helpers/text-field-helper'; export type NotOverridableProps = | 'openPicker' @@ -77,6 +78,8 @@ export interface DateInputProps * @default false */ disableMaskedInput?: boolean; + /** Get aria-label text for control that opens datepicker dialog. Aria-label have to include selected date. */ + getOpenDialogAriaText?: (value: ParsableDate, utils: MuiPickersUtils) => string; // ?? TODO when it will be possible to display "empty" date in datepicker use it instead of ignoring invalid inputs ignoreInvalidInputs?: boolean; } @@ -102,6 +105,8 @@ export const PureDateInput: React.FC = ({ hideOpenPickerButton, ignoreInvalidInputs, KeyboardButtonProps, + disableMaskedInput, + getOpenDialogAriaText = getTextFieldAriaText, ...other }) => { const utils = useUtils(); @@ -126,17 +131,12 @@ export const PureDateInput: React.FC = ({ error={Boolean(validationError)} helperText={validationError} {...other} + aria-label={getOpenDialogAriaText(rawValue, utils)} // do not overridable onClick={onOpen} value={inputValue} InputProps={PureDateInputProps} - onKeyDown={e => { - // space - if (e.keyCode === 32) { - e.stopPropagation(); - onOpen(); - } - }} + onKeyDown={onSpaceOrEnter(onOpen)} /> ); }; diff --git a/lib/src/_shared/hooks/useKeyDown.ts b/lib/src/_shared/hooks/useKeyDown.ts index 73fd4ee7e..65ffd7be0 100644 --- a/lib/src/_shared/hooks/useKeyDown.ts +++ b/lib/src/_shared/hooks/useKeyDown.ts @@ -3,7 +3,7 @@ import * as React from 'react'; export const useIsomorphicEffect = typeof window === 'undefined' ? React.useEffect : React.useLayoutEffect; -type KeyHandlers = Record void>; +type KeyHandlers = Record void>; export function runKeyHandler(e: KeyboardEvent | React.KeyboardEvent, keyHandlers: KeyHandlers) { const handler = keyHandlers[e.keyCode]; @@ -44,3 +44,15 @@ export function useGlobalKeyDown(active: boolean, keyHandlers: KeyHandlers) { } }, [active]); } + +export const keycode = { + ArrowUp: 38, + ArrowDown: 40, + ArrowLeft: 37, + ArrowRight: 39, + Enter: 13, + Home: 36, + End: 35, + PageUp: 33, + PageDown: 34, +}; diff --git a/lib/src/_shared/hooks/usePickerState.ts b/lib/src/_shared/hooks/usePickerState.ts index bde52a4b7..64c7e33ba 100644 --- a/lib/src/_shared/hooks/usePickerState.ts +++ b/lib/src/_shared/hooks/usePickerState.ts @@ -1,20 +1,20 @@ -import { useUtils } from './useUtils'; +import { useUtils, useNow } from './useUtils'; import { IUtils } from '@date-io/core/IUtils'; import { useOpenState } from './useOpenState'; import { WrapperVariant } from '../../wrappers/Wrapper'; import { MaterialUiPickersDate } from '../../typings/date'; import { BasePickerProps } from '../../typings/BasePicker'; import { validate } from '../../_helpers/text-field-helper'; -import { useCallback, useDebugValue, useEffect, useMemo, useState, useRef } from 'react'; +import { useCallback, useDebugValue, useEffect, useMemo, useState } from 'react'; const useValueToDate = ( utils: IUtils, { value, initialFocusedDate }: BasePickerProps ) => { - const nowRef = useRef(utils.date()); - const date = utils.date(value || initialFocusedDate || nowRef.current); + const now = useNow(); + const date = utils.date(value || initialFocusedDate || now); - return date && utils.isValid(date) ? date : nowRef.current; + return date && utils.isValid(date) ? date : now; }; function useDateValues(props: BasePickerProps) { @@ -29,6 +29,8 @@ function useDateValues(props: BasePickerProps) { return { date, format }; } +export const FORCE_FINISH_PICKER = Symbol('Force closing picker, used for accessibility '); + export function usePickerState(props: BasePickerProps) { const { autoOk, disabled, readOnly, onAccept, onChange, onError, value } = props; @@ -49,13 +51,16 @@ export function usePickerState(props: BasePickerProps) { }, [date, isMobileKeyboardViewOpen, isOpen, pickerDate, utils]); const acceptDate = useCallback( - (acceptedDate: MaterialUiPickersDate) => { + (acceptedDate: MaterialUiPickersDate, needClosePicker: boolean) => { onChange(acceptedDate); - if (onAccept) { - onAccept(acceptedDate); - } - setIsOpen(false); + if (needClosePicker) { + setIsOpen(false); + + if (onAccept) { + onAccept(acceptedDate); + } + } }, [onAccept, onChange, setIsOpen] ); @@ -64,8 +69,8 @@ export function usePickerState(props: BasePickerProps) { () => ({ format, open: isOpen, - onClear: () => acceptDate(null), - onAccept: () => acceptDate(pickerDate), + onClear: () => acceptDate(null, true), + onAccept: () => acceptDate(pickerDate, true), onSetToday: () => setPickerDate(utils.date()), onDismiss: () => setIsOpen(false), }), @@ -87,23 +92,25 @@ export function usePickerState(props: BasePickerProps) { onDateChange: ( newDate: MaterialUiPickersDate, currentVariant: WrapperVariant, - isFinish = true + isFinish: boolean | symbol = true ) => { setPickerDate(newDate); - - if (isFinish && autoOk) { - acceptDate(newDate); - return; - } - - // simulate autoOk, but do not close the modal - if (currentVariant === 'desktop' || currentVariant === 'static') { - onChange(newDate); - onAccept && onAccept(newDate); + const isFinishing = + typeof isFinish === 'boolean' ? isFinish : isFinish === FORCE_FINISH_PICKER; + + if (isFinishing) { + const autoAcceptRequested = Boolean(autoOk) || isFinish === FORCE_FINISH_PICKER; + if (currentVariant === 'mobile' && autoAcceptRequested) { + acceptDate(newDate, true); + } + + if (currentVariant !== 'mobile') { + acceptDate(newDate, autoAcceptRequested); + } } }, }), - [acceptDate, autoOk, isMobileKeyboardViewOpen, onAccept, onChange, pickerDate] + [acceptDate, autoOk, isMobileKeyboardViewOpen, pickerDate] ); const validationError = validate(value, utils, props as any); diff --git a/lib/src/_shared/hooks/useUtils.ts b/lib/src/_shared/hooks/useUtils.ts index 252b3fa19..1f8984937 100644 --- a/lib/src/_shared/hooks/useUtils.ts +++ b/lib/src/_shared/hooks/useUtils.ts @@ -1,4 +1,4 @@ -import { useContext } from 'react'; +import { useContext, useRef } from 'react'; import { IUtils } from '@date-io/core/IUtils'; import { MaterialUiPickersDate } from '../../typings/date'; import { MuiPickersContext } from '../../MuiPickersUtilsProvider'; @@ -18,3 +18,12 @@ export function useUtils() { return utils!; } + +export function useNow() { + const utils = useUtils(); + const now = useRef(utils.date()); + + return now.current; +} + +export type MuiPickersUtils = IUtils; diff --git a/lib/src/_shared/hooks/useViews.tsx b/lib/src/_shared/hooks/useViews.tsx index 26e5cc176..ca622693d 100644 --- a/lib/src/_shared/hooks/useViews.tsx +++ b/lib/src/_shared/hooks/useViews.tsx @@ -3,6 +3,8 @@ import { PickerView } from '../../Picker/Picker'; import { arrayIncludes } from '../../_helpers/utils'; import { MaterialUiPickersDate } from '../../typings/date'; +export type PickerOnChangeFn = (date: MaterialUiPickersDate, isFinish?: boolean | symbol) => void; + export function useViews({ views, openTo, @@ -12,7 +14,7 @@ export function useViews({ }: { views: PickerView[]; openTo: PickerView; - onChange: (date: MaterialUiPickersDate, isFinish?: boolean) => void; + onChange: PickerOnChangeFn; isMobileKeyboardViewOpen: boolean; toggleMobileKeyboardView: () => void; }) { @@ -39,9 +41,9 @@ export function useViews({ }, [nextView, setOpenView]); const handleChangeAndOpenNext = React.useCallback( - (date: MaterialUiPickersDate, isFinish?: boolean) => { + (date: MaterialUiPickersDate, isFinish?: boolean | symbol) => { // do not close picker if needs to show next view - onChange(date, Boolean(isFinish && !nextView)); + onChange(date, Boolean(nextView) ? false : isFinish); if (isFinish) { openNext(); diff --git a/lib/src/constants/prop-types.ts b/lib/src/constants/prop-types.ts index 0e5fc504a..b7358f56b 100644 --- a/lib/src/constants/prop-types.ts +++ b/lib/src/constants/prop-types.ts @@ -26,7 +26,6 @@ export const datePickerDefaultProps = { invalidDateMessage: 'Invalid Date Format', minDateMessage: 'Date should not be before minimal date', maxDateMessage: 'Date should not be after maximal date', - allowKeyboardControl: true, } as BaseDatePickerProps; export const dateTimePickerDefaultProps = { diff --git a/lib/src/views/Calendar/Calendar.tsx b/lib/src/views/Calendar/Calendar.tsx index d96a9f0a4..0227827d7 100644 --- a/lib/src/views/Calendar/Calendar.tsx +++ b/lib/src/views/Calendar/Calendar.tsx @@ -2,19 +2,19 @@ import * as React from 'react'; import Day from './Day'; import DayWrapper from './DayWrapper'; import SlideTransition, { SlideDirection } from './SlideTransition'; -import { useUtils } from '../../_shared/hooks/useUtils'; import { WrapperVariant } from '../../wrappers/Wrapper'; import { MaterialUiPickersDate } from '../../typings/date'; -import { IconButtonProps } from '@material-ui/core/IconButton'; -import { useGlobalKeyDown } from '../../_shared/hooks/useKeyDown'; +import { useUtils, useNow } from '../../_shared/hooks/useUtils'; +import { PickerOnChangeFn } from '../../_shared/hooks/useViews'; import { findClosestEnabledDate } from '../../_helpers/date-utils'; import { makeStyles, useTheme, Typography } from '@material-ui/core'; +import { useGlobalKeyDown, keycode } from '../../_shared/hooks/useKeyDown'; -export interface CalendarProps { +export interface ExportedCalendarProps { /** Calendar Date @DateIOType */ date: MaterialUiPickersDate; /** Calendar onChange */ - onChange: (date: MaterialUiPickersDate, isFinish?: boolean) => void; + onChange: PickerOnChangeFn; /** * Disable past dates * @default false @@ -25,10 +25,6 @@ export interface CalendarProps { * @default false */ disableFuture?: boolean; - /** Left arrow icon */ - leftArrowIcon?: React.ReactNode; - /** Right arrow icon */ - rightArrowIcon?: React.ReactNode; /** Custom renderer for day @DateIOType */ renderDay?: ( day: MaterialUiPickersDate, @@ -38,30 +34,24 @@ export interface CalendarProps { ) => JSX.Element; /** * Enables keyboard listener for moving between days in calendar - * @default true + * @default currentWrapper !== 'static' */ allowKeyboardControl?: boolean; - /** - * Props to pass to left arrow button - * @type {Partial} - */ - leftArrowButtonProps?: Partial; - /** - * Props to pass to right arrow button - * @type {Partial} - */ - rightArrowButtonProps?: Partial; - /** Disable specific date @DateIOType */ - shouldDisableDate?: (day: MaterialUiPickersDate) => boolean; - /** Callback firing on month change. Return promise to render spinner till it will not be resolved @DateIOType */ - onMonthChange?: (date: MaterialUiPickersDate) => void | Promise; /** Custom loading indicator */ loadingIndicator?: JSX.Element; +} + +export interface CalendarProps extends ExportedCalendarProps { minDate?: MaterialUiPickersDate; maxDate?: MaterialUiPickersDate; + isDateDisabled: (day: MaterialUiPickersDate) => boolean; slideDirection: SlideDirection; currentMonth: MaterialUiPickersDate; reduceAnimations: boolean; + focusedDay: MaterialUiPickersDate | null; + changeFocusedDay: (newFocusedDay: MaterialUiPickersDate) => void; + isMonthSwitchingAnimating: boolean; + onMonthSwitchingAnimationEnd: () => void; wrapperVariant: WrapperVariant | null; } @@ -106,6 +96,10 @@ export const useStyles = makeStyles(theme => ({ export const Calendar: React.FC = ({ date, + isMonthSwitchingAnimating, + onMonthSwitchingAnimationEnd, + focusedDay, + changeFocusedDay, onChange, minDate, maxDate, @@ -117,52 +111,22 @@ export const Calendar: React.FC = ({ reduceAnimations, allowKeyboardControl, wrapperVariant, - ...props + isDateDisabled, }) => { + const now = useNow(); const utils = useUtils(); const theme = useTheme(); const classes = useStyles(); - const now = utils.date(); - - const validateMinMaxDate = React.useCallback( - (day: MaterialUiPickersDate) => { - return Boolean( - (disableFuture && utils.isAfterDay(day, now)) || - (disablePast && utils.isBeforeDay(day, now)) || - (minDate && utils.isBeforeDay(day, utils.date(minDate))) || - (maxDate && utils.isAfterDay(day, utils.date(maxDate))) - ); - }, - [disableFuture, disablePast, maxDate, minDate, now, utils] - ); - - const shouldDisableDate = React.useCallback( - (day: MaterialUiPickersDate) => { - return ( - validateMinMaxDate(day) || Boolean(props.shouldDisableDate && props.shouldDisableDate(day)) - ); - }, - [props, validateMinMaxDate] - ); const handleDaySelect = React.useCallback( - (day: MaterialUiPickersDate, isFinish = true) => { + (day: MaterialUiPickersDate, isFinish: boolean | symbol = true) => { onChange(utils.mergeDateAndTime(day, date), isFinish); }, [date, onChange, utils] ); - const moveToDay = React.useCallback( - (day: MaterialUiPickersDate) => { - if (day && !shouldDisableDate(day)) { - handleDaySelect(day, false); - } - }, - [handleDaySelect, shouldDisableDate] - ); - React.useEffect(() => { - if (shouldDisableDate(date)) { + if (isDateDisabled(date)) { const closestEnabledDate = findClosestEnabledDate({ date, utils, @@ -170,18 +134,25 @@ export const Calendar: React.FC = ({ maxDate: utils.date(maxDate), disablePast: Boolean(disablePast), disableFuture: Boolean(disableFuture), - shouldDisableDate: shouldDisableDate, + shouldDisableDate: isDateDisabled, }); handleDaySelect(closestEnabledDate, false); } }, []); // eslint-disable-line - useGlobalKeyDown(Boolean(allowKeyboardControl && wrapperVariant !== 'static'), { - 38: () => moveToDay(utils.addDays(date, -7)), // ArrowUp - 40: () => moveToDay(utils.addDays(date, 7)), // ArrowDown - 37: () => moveToDay(utils.addDays(date, theme.direction === 'ltr' ? -1 : 1)), // ArrowLeft - 39: () => moveToDay(utils.addDays(date, theme.direction === 'ltr' ? 1 : -1)), // ArrowRight + const nowFocusedDay = focusedDay || date; + useGlobalKeyDown(Boolean(allowKeyboardControl ?? wrapperVariant !== 'static'), { + [keycode.ArrowUp]: () => changeFocusedDay(utils.addDays(nowFocusedDay, -7)), + [keycode.ArrowDown]: () => changeFocusedDay(utils.addDays(nowFocusedDay, 7)), + [keycode.ArrowLeft]: () => + changeFocusedDay(utils.addDays(nowFocusedDay, theme.direction === 'ltr' ? -1 : 1)), + [keycode.ArrowRight]: () => + changeFocusedDay(utils.addDays(nowFocusedDay, theme.direction === 'ltr' ? 1 : -1)), + [keycode.Home]: () => changeFocusedDay(utils.startOfWeek(nowFocusedDay)), + [keycode.End]: () => changeFocusedDay(utils.endOfWeek(nowFocusedDay)), + [keycode.PageUp]: () => changeFocusedDay(utils.getNextMonth(nowFocusedDay)), + [keycode.PageDown]: () => changeFocusedDay(utils.getPreviousMonth(nowFocusedDay)), }); const selectedDate = utils.startOfDay(date); @@ -192,6 +163,7 @@ export const Calendar: React.FC = ({
{utils.getWeekdays().map((day, i) => ( = ({
-
+
{utils.getWeekArray(currentMonth).map(week => ( -
+
{week.map(day => { - const disabled = shouldDisableDate(day); + const disabled = isDateDisabled(day); const isDayInCurrentMonth = utils.getMonth(day) === currentMonthNumber; let dayComponent = ( changeFocusedDay(day)} + focusable={ + Boolean(nowFocusedDay) && + utils.toJsDate(nowFocusedDay).getDate() === utils.toJsDate(day).getDate() + } + isToday={utils.isSameDay(day, now)} hidden={!isDayInCurrentMonth} + isInCurrentMonth={isDayInCurrentMonth} selected={utils.isSameDay(selectedDate, day)} - children={utils.format(day, 'dayOfMonth')} /> ); diff --git a/lib/src/views/Calendar/CalendarHeader.tsx b/lib/src/views/Calendar/CalendarHeader.tsx index 6f36e1284..5911a342a 100644 --- a/lib/src/views/Calendar/CalendarHeader.tsx +++ b/lib/src/views/Calendar/CalendarHeader.tsx @@ -15,7 +15,7 @@ import { ArrowLeftIcon } from '../../_shared/icons/ArrowLeftIcon'; import { ArrowRightIcon } from '../../_shared/icons/ArrowRightIcon'; import { ArrowDropDownIcon } from '../../_shared/icons/ArrowDropDownIcon'; -export interface CalendarWithHeaderProps +export interface CalendarHeaderProps extends Pick { view: DatePickerView; views: DatePickerView[]; @@ -24,6 +24,10 @@ export interface CalendarWithHeaderProps leftArrowIcon?: React.ReactNode; /** Right arrow icon */ rightArrowIcon?: React.ReactNode; + /** Left arrow icon aria-label text */ + leftArrowButtonText?: string; + /** Right arrow icon aria-label text */ + rightArrowButtonText?: string; /** * Props to pass to left arrow button * @type {Partial} @@ -34,6 +38,8 @@ export interface CalendarWithHeaderProps * @type {Partial} */ rightArrowButtonProps?: Partial; + /** Get aria-label text for switching between views button */ + getViewSwitchingButtonText?: (currentView: DatePickerView) => string; reduceAnimations: boolean; changeView: (view: DatePickerView) => void; onMonthChange: (date: MaterialUiPickersDate, slideDirection: SlideDirection) => void; @@ -83,21 +89,30 @@ export const useStyles = makeStyles( { name: 'MuiPickersCalendarHeader' } ); -export const CalendarHeader: React.SFC = ({ +function getSwitchingViewAriaText(view: DatePickerView) { + return view === 'year' + ? 'year view is open, switch to calendar view' + : 'calendar view is open, switch to year view'; +} + +export const CalendarHeader: React.SFC = ({ view, views, month, - leftArrowIcon, - rightArrowIcon, - leftArrowButtonProps, - rightArrowButtonProps, changeView, - onMonthChange, minDate, maxDate, - reduceAnimations, - disableFuture, disablePast, + disableFuture, + onMonthChange, + reduceAnimations, + leftArrowIcon, + rightArrowIcon, + leftArrowButtonProps, + rightArrowButtonProps, + leftArrowButtonText = 'previous month', + rightArrowButtonText = 'next month', + getViewSwitchingButtonText = getSwitchingViewAriaText, }) => { const utils = useUtils(); const theme = useTheme(); @@ -152,6 +167,7 @@ export const CalendarHeader: React.SFC = ({ transKey={utils.format(month, 'month')} > = ({ transKey={utils.format(month, 'year')} > = ({ = ({ = ({ { +type PublicCalendarHeaderProps = Pick< + CalendarHeaderProps, + | 'leftArrowIcon' + | 'rightArrowIcon' + | 'leftArrowButtonProps' + | 'rightArrowButtonProps' + | 'leftArrowButtonText' + | 'rightArrowButtonText' + | 'getViewSwitchingButtonText' +>; + +export interface CalendarViewProps extends ExportedCalendarProps, PublicCalendarHeaderProps { date: MaterialUiPickersDate; view: DatePickerView; views: DatePickerView[]; changeView: (view: DatePickerView) => void; - onChange: (date: MaterialUiPickersDate, isFinish?: boolean) => void; + onChange: PickerOnChangeFn; + /** Callback firing on month change. Return promise to render spinner till it will not be resolved @DateIOType */ + onMonthChange?: (date: MaterialUiPickersDate) => void | Promise; /** * Min selectable date * @default Date(1900-01-01) @@ -43,9 +49,11 @@ export interface CalendarViewProps * @default /(android)/i.test(navigator.userAgent) */ reduceAnimations?: boolean; + /** Disable specific date @DateIOType */ + shouldDisableDate?: (day: MaterialUiPickersDate) => boolean; } -export type ExportedCalendarProps = Omit< +export type ExportedCalendarViewProps = Omit< CalendarViewProps, 'date' | 'view' | 'views' | 'onChange' | 'changeView' | 'slideDirection' | 'currentMonth' >; @@ -57,17 +65,26 @@ interface ChangeMonthPayload { newMonth: MaterialUiPickersDate; } -function calendarStateReducer( - state: { - loadingQueue: number; - currentMonth: MaterialUiPickersDate; - slideDirection: SlideDirection; - }, +interface State { + isMonthSwitchingAnimating: boolean; + loadingQueue: number; + currentMonth: MaterialUiPickersDate; + focusedDay: MaterialUiPickersDate | null; + slideDirection: SlideDirection; +} + +const createCalendarStateReducer = ( + reduceAnimations: boolean, + utils: IUtils +) => ( + state: State, action: | ReducerAction<'popLoadingQueue'> + | ReducerAction<'finishMonthSwitchingAnimation'> | ReducerAction<'changeMonth', ChangeMonthPayload> | ReducerAction<'changeMonthLoading', ChangeMonthPayload> -) { + | ReducerAction<'changeFocusedDay', { focusedDay: MaterialUiPickersDate }> +): State => { switch (action.type) { case 'changeMonthLoading': { return { @@ -75,6 +92,7 @@ function calendarStateReducer( loadingQueue: state.loadingQueue + 1, slideDirection: action.direction, currentMonth: action.newMonth, + isMonthSwitchingAnimating: !reduceAnimations, }; } case 'changeMonth': { @@ -82,6 +100,7 @@ function calendarStateReducer( ...state, slideDirection: action.direction, currentMonth: action.newMonth, + isMonthSwitchingAnimating: !reduceAnimations, }; } case 'popLoadingQueue': { @@ -90,8 +109,24 @@ function calendarStateReducer( loadingQueue: state.loadingQueue <= 0 ? 0 : state.loadingQueue - 1, }; } + case 'finishMonthSwitchingAnimation': { + return { + ...state, + isMonthSwitchingAnimating: false, + }; + } + case 'changeFocusedDay': { + const needMonthSwitch = !utils.isSameMonth(state.currentMonth, action.focusedDay); + return { + ...state, + focusedDay: action.focusedDay, + isMonthSwitchingAnimating: needMonthSwitch && !reduceAnimations, + currentMonth: needMonthSwitch ? utils.startOfMonth(action.focusedDay) : state.currentMonth, + slideDirection: utils.isAfterDay(action.focusedDay, state.currentMonth) ? 'left' : 'right', + }; + } } -} +}; export const useStyles = makeStyles( { @@ -117,51 +152,90 @@ export const CalendarView: React.FC = ({ maxDate: unparsedMaxDate, reduceAnimations = typeof window !== 'undefined' && /(android)/i.test(window.navigator.userAgent), loadingIndicator = , + shouldDisableDate, ...other }) => { + const now = useNow(); const utils = useUtils(); const classes = useStyles(); const minDate = useParsedDate(unparsedMinDate); const maxDate = useParsedDate(unparsedMaxDate); const wrapperVariant = React.useContext(WrapperVariantContext); - const [{ currentMonth, loadingQueue, slideDirection }, dispatch] = React.useReducer( - calendarStateReducer, - { - loadingQueue: 0, - currentMonth: utils.startOfMonth(date), - slideDirection: 'left', - } + const [ + { currentMonth, isMonthSwitchingAnimating, focusedDay, loadingQueue, slideDirection }, + dispatch, + ] = React.useReducer(createCalendarStateReducer(reduceAnimations, utils), { + isMonthSwitchingAnimating: false, + loadingQueue: 0, + focusedDay: date, + currentMonth: utils.startOfMonth(date), + slideDirection: 'left', + }); + + const handleChangeMonth = React.useCallback( + (payload: ChangeMonthPayload) => { + const returnedPromise = onMonthChange && onMonthChange(payload.newMonth); + + if (returnedPromise) { + dispatch({ + type: 'changeMonthLoading', + ...payload, + }); + + returnedPromise.then(() => dispatch({ type: 'popLoadingQueue' })); + } else { + dispatch({ + type: 'changeMonth', + ...payload, + }); + } + }, + [onMonthChange] ); - const handleChangeMonth = (payload: ChangeMonthPayload) => { - const returnedPromise = onMonthChange && onMonthChange(payload.newMonth); + const changeMonth = React.useCallback( + (date: MaterialUiPickersDate) => { + if (utils.isSameMonth(date, currentMonth)) { + return; + } - if (returnedPromise) { - dispatch({ - type: 'changeMonthLoading', - ...payload, + handleChangeMonth({ + newMonth: utils.startOfMonth(date), + direction: utils.isAfterDay(date, currentMonth) ? 'left' : 'right', }); + }, + [currentMonth, handleChangeMonth, utils] + ); - returnedPromise.then(() => dispatch({ type: 'popLoadingQueue' })); - } else { - dispatch({ - type: 'changeMonth', - ...payload, - }); - } - }; + React.useEffect(() => { + changeMonth(date); + }, [date]); // eslint-disable-line React.useEffect(() => { - if (utils.isSameMonth(date, currentMonth)) { - return; + if (view === 'date') { + dispatch({ type: 'changeFocusedDay', focusedDay: date }); } + }, [view]); // eslint-disable-line - handleChangeMonth({ - newMonth: utils.startOfMonth(date), - direction: utils.isAfterDay(date, currentMonth) ? 'left' : 'right', - }); - }, [date]); // eslint-disable-line + const validateMinMaxDate = React.useCallback( + (day: MaterialUiPickersDate) => { + return Boolean( + (other.disableFuture && utils.isAfterDay(day, now)) || + (other.disablePast && utils.isBeforeDay(day, now)) || + (minDate && utils.isBeforeDay(day, utils.date(minDate))) || + (maxDate && utils.isAfterDay(day, utils.date(maxDate))) + ); + }, + [maxDate, minDate, now, other.disableFuture, other.disablePast, utils] + ); + + const isDateDisabled = React.useCallback( + (day: MaterialUiPickersDate) => { + return validateMinMaxDate(day) || Boolean(shouldDisableDate && shouldDisableDate(day)); + }, + [shouldDisableDate, validateMinMaxDate] + ); return ( <> @@ -189,6 +263,7 @@ export const CalendarView: React.FC = ({ onChange={onChange} minDate={minDate} maxDate={maxDate} + isDateDisabled={isDateDisabled} /> )} @@ -216,6 +291,12 @@ export const CalendarView: React.FC = ({ ) : ( + dispatch({ type: 'finishMonthSwitchingAnimation' }) + } + focusedDay={focusedDay} + changeFocusedDay={focusedDay => dispatch({ type: 'changeFocusedDay', focusedDay })} reduceAnimations={reduceAnimations} currentMonth={currentMonth} slideDirection={slideDirection} @@ -224,6 +305,7 @@ export const CalendarView: React.FC = ({ minDate={minDate} maxDate={maxDate} wrapperVariant={wrapperVariant} + isDateDisabled={isDateDisabled} /> ))}
diff --git a/lib/src/views/Calendar/Day.tsx b/lib/src/views/Calendar/Day.tsx index 9ae51fde3..60b4af343 100644 --- a/lib/src/views/Calendar/Day.tsx +++ b/lib/src/views/Calendar/Day.tsx @@ -1,8 +1,10 @@ import * as React from 'react'; import * as PropTypes from 'prop-types'; import clsx from 'clsx'; -import { ButtonBase } from '@material-ui/core'; +import { useUtils } from '../../_shared/hooks/useUtils'; +import { MaterialUiPickersDate } from '../../typings/date'; import { makeStyles, fade } from '@material-ui/core/styles'; +import { ButtonBase, ButtonBaseProps } from '@material-ui/core'; export const useStyles = makeStyles( theme => ({ @@ -15,9 +17,6 @@ export const useStyles = makeStyles( color: theme.palette.text.primary, fontSize: theme.typography.caption.fontSize, fontWeight: theme.typography.fontWeightMedium, - '&:focus': { - backgroundColor: fade(theme.palette.action.active, theme.palette.action.hoverOpacity), - }, '&:hover': { backgroundColor: fade(theme.palette.action.active, theme.palette.action.hoverOpacity), }, @@ -26,7 +25,7 @@ export const useStyles = makeStyles( opacity: 0, pointerEvents: 'none', }, - current: { + today: { '&:not($daySelected)': { border: `1px solid ${theme.palette.text.hint}`, }, @@ -40,11 +39,7 @@ export const useStyles = makeStyles( }), '&:hover': { willChange: 'background-color', - backgroundColor: theme.palette.primary.light, - }, - '&:focus': { - willChange: 'background-color', - backgroundColor: theme.palette.primary.light, + backgroundColor: theme.palette.primary.dark, }, }, dayDisabled: { @@ -58,44 +53,72 @@ export const useStyles = makeStyles( { name: 'MuiPickersDay' } ); -export interface DayProps { - /** Day text */ - children: React.ReactNode; +export interface DayProps extends ButtonBaseProps { + /** The date to show */ + day: MaterialUiPickersDate; + /** Is focused by keyboard navigation */ + focused?: boolean; + /** Can be focused by tabbing in */ + focusable?: boolean; + /** Is day in current month */ + isInCurrentMonth: boolean; + /** Is switching month animation going on right now */ + isAnimating: boolean; /** Is today? */ - current?: boolean; + isToday?: boolean; /** Disabled? */ disabled?: boolean; - /** Hidden? */ - hidden?: boolean; /** Selected? */ selected?: boolean; } export const Day: React.FC = ({ - children, + day, disabled, - hidden, - current, + isInCurrentMonth, + isToday, selected, + focused = false, + focusable = false, + isAnimating, + onFocus, ...other }) => { + const ref = React.useRef(null); + const utils = useUtils(); const classes = useStyles(); const className = clsx(classes.day, { - [classes.hidden]: hidden, - [classes.current]: current, + [classes.hidden]: !isInCurrentMonth, + [classes.today]: isToday, [classes.daySelected]: selected, [classes.dayDisabled]: disabled, }); + React.useEffect(() => { + if (focused && !isAnimating && !disabled && isInCurrentMonth && ref.current) { + ref.current.focus(); + } + }, [disabled, focused, isAnimating, isInCurrentMonth]); + return ( { + if (!focused && onFocus) { + onFocus(e); + } + }} {...other} > - {children} + {utils.format(day, 'dayOfMonth')} ); }; @@ -103,7 +126,7 @@ export const Day: React.FC = ({ Day.displayName = 'Day'; Day.propTypes = { - current: PropTypes.bool, + isToday: PropTypes.bool, disabled: PropTypes.bool, hidden: PropTypes.bool, selected: PropTypes.bool, @@ -112,7 +135,7 @@ Day.propTypes = { Day.defaultProps = { disabled: false, hidden: false, - current: false, + isToday: false, selected: false, }; diff --git a/lib/src/views/Calendar/DayWrapper.tsx b/lib/src/views/Calendar/DayWrapper.tsx index b5493c2bf..819a91cda 100644 --- a/lib/src/views/Calendar/DayWrapper.tsx +++ b/lib/src/views/Calendar/DayWrapper.tsx @@ -1,11 +1,15 @@ import * as React from 'react'; +import { onSpaceOrEnter } from '../../_helpers/utils'; +import { MaterialUiPickersDate } from '../../typings/date'; +import { PickerOnChangeFn } from '../../_shared/hooks/useViews'; +import { FORCE_FINISH_PICKER } from '../../_shared/hooks/usePickerState'; export interface DayWrapperProps { - value: any; + value: MaterialUiPickersDate; children: React.ReactNode; dayInCurrentMonth?: boolean; disabled?: boolean; - onSelect: (value: any) => void; + onSelect: PickerOnChangeFn; } const DayWrapper: React.FC = ({ @@ -16,13 +20,17 @@ const DayWrapper: React.FC = ({ dayInCurrentMonth, ...other }) => { - const handleClick = React.useCallback(() => onSelect(value), [onSelect, value]); + const handleSelection = (isFinish: symbol | boolean) => { + if (dayInCurrentMonth && !disabled) { + onSelect(value, isFinish); + } + }; return (
handleSelection(true)} + onKeyDown={onSpaceOrEnter(() => handleSelection(FORCE_FINISH_PICKER))} children={children} {...other} /> diff --git a/lib/src/views/Calendar/Month.tsx b/lib/src/views/Calendar/Month.tsx index c881b235b..2a0d950bb 100644 --- a/lib/src/views/Calendar/Month.tsx +++ b/lib/src/views/Calendar/Month.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import clsx from 'clsx'; import Typography from '@material-ui/core/Typography'; import { makeStyles } from '@material-ui/core/styles'; +import { onSpaceOrEnter } from '../../_helpers/utils'; export interface MonthProps { children: React.ReactNode; @@ -63,7 +64,7 @@ export const Month: React.FC = ({ })} tabIndex={disabled ? -1 : 0} onClick={handleSelection} - onKeyPress={handleSelection} + onKeyDown={onSpaceOrEnter(handleSelection)} color={selected ? 'primary' : undefined} variant={selected ? 'h5' : 'subtitle1'} children={children} diff --git a/lib/src/views/Calendar/SlideTransition.tsx b/lib/src/views/Calendar/SlideTransition.tsx index 6c77d465f..e8a7bc06c 100644 --- a/lib/src/views/Calendar/SlideTransition.tsx +++ b/lib/src/views/Calendar/SlideTransition.tsx @@ -2,9 +2,10 @@ import * as React from 'react'; import clsx from 'clsx'; import { makeStyles } from '@material-ui/core/styles'; import { CSSTransition, TransitionGroup } from 'react-transition-group'; +import { CSSTransitionProps } from 'react-transition-group/CSSTransition'; export type SlideDirection = 'right' | 'left'; -interface SlideTransitionProps { +interface SlideTransitionProps extends Omit { transKey: React.Key; className?: string; reduceAnimations: boolean; @@ -12,11 +13,11 @@ interface SlideTransitionProps { children: React.ReactElement; } -const animationDuration = 350; +export const slideAnimationDuration = 350; export const useStyles = makeStyles( theme => { const slideTransition = theme.transitions.create('transform', { - duration: animationDuration, + duration: slideAnimationDuration, easing: 'cubic-bezier(0.35, 0.8, 0.4, 1)', }); @@ -67,11 +68,12 @@ const SlideTransition: React.SFC = ({ transKey, reduceAnimations, slideDirection, - className = null, + className = undefined, + ...other }) => { const classes = useStyles(); if (reduceAnimations) { - return children; + return
{children}
; } const transitionClasses = { @@ -95,10 +97,11 @@ const SlideTransition: React.SFC = ({ ); diff --git a/lib/src/views/Calendar/Year.tsx b/lib/src/views/Calendar/Year.tsx index 0559c7101..1210232d8 100644 --- a/lib/src/views/Calendar/Year.tsx +++ b/lib/src/views/Calendar/Year.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import clsx from 'clsx'; import Typography from '@material-ui/core/Typography'; +import { onSpaceOrEnter } from '../../_helpers/utils'; import { makeStyles, fade } from '@material-ui/core/styles'; import { WrapperVariantContext } from '../../wrappers/WrapperVariantContext'; @@ -8,23 +9,24 @@ export interface YearProps { children: React.ReactNode; disabled?: boolean; onSelect: (value: any) => void; - selected?: boolean; + selected: boolean; + focused: boolean; value: any; forwardedRef?: React.Ref; } export const useStyles = makeStyles( theme => ({ - yearContainer: { + yearButtonContainer: { flexBasis: '33.3%', display: 'flex', justifyContent: 'center', padding: '8px 0', }, - yearContainerDesktop: { + yearButtonContainerDesktop: { flexBasis: '25%', }, - yearLabel: { + yearButton: { height: 36, width: 72, borderRadius: 16, @@ -33,15 +35,15 @@ export const useStyles = makeStyles( justifyContent: 'center', cursor: 'pointer', outline: 'none', - '&:focus': { + '&:focus, &:hover': { backgroundColor: fade(theme.palette.action.active, theme.palette.action.hoverOpacity), }, }, yearSelected: { color: theme.palette.getContrastText(theme.palette.primary.main), backgroundColor: theme.palette.primary.main, - '&:focus': { - backgroundColor: theme.palette.primary.light, + '&:focus, &:hover': { + backgroundColor: theme.palette.primary.dark, }, }, yearDisabled: { @@ -59,28 +61,36 @@ export const Year: React.FC = ({ selected, disabled, children, + focused, ...other }) => { const classes = useStyles(); + const ref = React.useRef(null); const wrapperVariant = React.useContext(WrapperVariantContext); - const handleClick = React.useCallback(() => onSelect(value), [onSelect, value]); + + React.useEffect(() => { + if (focused && ref.current) { + ref.current.focus(); + } + }, [focused]); return (
onSelect(value)} + className={clsx(classes.yearButtonContainer, { + [classes.yearButtonContainerDesktop]: wrapperVariant === 'desktop', })} > onSelect(value))} + className={clsx(classes.yearButton, { [classes.yearSelected]: selected, [classes.yearDisabled]: disabled, })} diff --git a/lib/src/views/Calendar/YearSelection.tsx b/lib/src/views/Calendar/YearSelection.tsx index d5367c7e0..6f49d7c0c 100644 --- a/lib/src/views/Calendar/YearSelection.tsx +++ b/lib/src/views/Calendar/YearSelection.tsx @@ -1,10 +1,11 @@ import * as React from 'react'; import Year from './Year'; import { DateType } from '@date-io/type'; -import { makeStyles } from '@material-ui/core/styles'; import { useUtils } from '../../_shared/hooks/useUtils'; import { MaterialUiPickersDate } from '../../typings/date'; +import { makeStyles, useTheme } from '@material-ui/core/styles'; import { WrapperVariantContext } from '../../wrappers/WrapperVariantContext'; +import { useGlobalKeyDown, keycode as keys } from '../../_shared/hooks/useKeyDown'; export interface YearSelectionProps { date: MaterialUiPickersDate; @@ -13,6 +14,8 @@ export interface YearSelectionProps { onChange: (date: MaterialUiPickersDate, isFinish: boolean) => void; disablePast?: boolean | null | undefined; disableFuture?: boolean | null | undefined; + allowKeyboardControl?: boolean; + isDateDisabled: (day: MaterialUiPickersDate) => boolean; onYearChange?: (date: MaterialUiPickersDate) => void; } @@ -37,9 +40,14 @@ export const YearSelection: React.FC = ({ maxDate, disablePast, disableFuture, + isDateDisabled, + allowKeyboardControl, }) => { + const theme = useTheme(); const utils = useUtils(); const classes = useStyles(); + const currentYear = utils.getYear(date); + const [focusedYear, setFocused] = React.useState(currentYear); const wrapperVariant = React.useContext(WrapperVariantContext); const selectedYearRef = React.useRef(null); @@ -56,19 +64,31 @@ export const YearSelection: React.FC = ({ } }, []); // eslint-disable-line - const currentYear = utils.getYear(date); - const onYearSelect = React.useCallback( - (year: number) => { + const handleYearSelection = React.useCallback( + (year: number, isFinish = true) => { const newDate = utils.setYear(date, year); + if (isDateDisabled(newDate)) { + return; + } + if (onYearChange) { onYearChange(newDate); } - onChange(newDate, true); + onChange(newDate, isFinish); }, - [date, onChange, onYearChange, utils] + [date, isDateDisabled, onChange, onYearChange, utils] ); + const yearsInRow = wrapperVariant === 'desktop' ? 4 : 3; + const nowFocusedYear = focusedYear || currentYear; + useGlobalKeyDown(Boolean(allowKeyboardControl ?? wrapperVariant !== 'static'), { + [keys.ArrowUp]: () => setFocused(nowFocusedYear - yearsInRow), + [keys.ArrowDown]: () => setFocused(nowFocusedYear + yearsInRow), + [keys.ArrowLeft]: () => setFocused(nowFocusedYear + (theme.direction === 'ltr' ? -1 : 1)), + [keys.ArrowRight]: () => setFocused(nowFocusedYear + (theme.direction === 'ltr' ? 1 : -1)), + }); + return (
@@ -81,7 +101,8 @@ export const YearSelection: React.FC = ({ key={utils.format(year, 'year')} selected={selected} value={yearNumber} - onSelect={onYearSelect} + onSelect={handleYearSelection} + focused={yearNumber === focusedYear} ref={selected ? selectedYearRef : undefined} disabled={Boolean( (disablePast && utils.isBeforeYear(year, utils.date())) || diff --git a/lib/src/views/Clock/Clock.tsx b/lib/src/views/Clock/Clock.tsx index 305f0ac3e..479dac7ae 100644 --- a/lib/src/views/Clock/Clock.tsx +++ b/lib/src/views/Clock/Clock.tsx @@ -2,12 +2,15 @@ import * as React from 'react'; import * as PropTypes from 'prop-types'; import clsx from 'clsx'; import ClockPointer from './ClockPointer'; +import { useUtils } from '../../_shared/hooks/useUtils'; import { VIEW_HEIGHT } from '../../constants/dimensions'; import { ClockViewType } from '../../constants/ClockType'; import { MaterialUiPickersDate } from '../../typings/date'; +import { PickerOnChangeFn } from '../../_shared/hooks/useViews'; import { getHours, getMinutes } from '../../_helpers/time-utils'; import { useMeridiemMode } from '../../TimePicker/TimePickerToolbar'; import { IconButton, Typography, makeStyles } from '@material-ui/core'; +import { useGlobalKeyDown, keycode } from '../../_shared/hooks/useKeyDown'; import { WrapperVariantContext } from '../../wrappers/WrapperVariantContext'; export interface ClockProps { @@ -15,11 +18,12 @@ export interface ClockProps { type: ClockViewType; value: number; children: React.ReactElement[]; - onDateChange: (date: MaterialUiPickersDate, isFinish?: boolean) => void; - onChange: (value: number, isFinish?: boolean) => void; + onDateChange: PickerOnChangeFn; + onChange: (value: number, isFinish?: boolean | symbol) => void; ampm?: boolean; minutesStep?: number; ampmInClock?: boolean; + allowKeyboardControl?: boolean; } export const useStyles = makeStyles( @@ -94,9 +98,11 @@ export const Clock: React.FC = ({ children: numbersElementsArray, type, ampm, - minutesStep, + minutesStep = 1, + allowKeyboardControl, onChange, }) => { + const utils = useUtils(); const classes = useStyles(); const wrapperVariant = React.useContext(WrapperVariantContext); const isMoving = React.useRef(false); @@ -162,6 +168,17 @@ export const Clock: React.FC = ({ return value % 5 === 0; }, [type, value]); + const keyboardControlStep = type === 'minutes' ? minutesStep : 1; + useGlobalKeyDown( + Boolean(allowKeyboardControl ?? wrapperVariant !== 'static') && !isMoving.current, + { + [keycode.Home]: () => onChange(0), // annulate both hours and minutes + [keycode.End]: () => onChange(type === 'minutes' ? 59 : 23, false), + [keycode.ArrowUp]: () => onChange(value + keyboardControlStep, false), + [keycode.ArrowDown]: () => onChange(value - keyboardControlStep, false), + } + ); + return (
@@ -182,6 +199,8 @@ export const Clock: React.FC = ({ value={value} isInner={isPointerInner} hasSelected={hasSelected} + aria-live="polite" + aria-label={`Selected time ${utils.format(date, 'fullTime')}`} /> {numbersElementsArray} diff --git a/lib/src/views/Clock/ClockNumber.tsx b/lib/src/views/Clock/ClockNumber.tsx index c7b27cba6..7dd045004 100644 --- a/lib/src/views/Clock/ClockNumber.tsx +++ b/lib/src/views/Clock/ClockNumber.tsx @@ -1,7 +1,10 @@ import * as React from 'react'; import clsx from 'clsx'; import Typography from '@material-ui/core/Typography'; +import { ButtonBase } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; +import { onSpaceOrEnter } from '../../_helpers/utils'; +import { FORCE_FINISH_PICKER } from '../../_shared/hooks/usePickerState'; const positions: Record = { 0: [0, 40], @@ -34,7 +37,9 @@ export interface ClockNumberProps { index: number; label: string; selected: boolean; + onSelect: (isFinish: boolean | symbol) => void; isInner?: boolean; + getClockNumberText: (currentItemText: string) => string; } export const useStyles = makeStyles( @@ -43,6 +48,7 @@ export const useStyles = makeStyles( return { clockNumber: { + outline: 0, width: size, height: 32, userSelect: 'none', @@ -54,6 +60,9 @@ export const useStyles = makeStyles( borderRadius: '50%', color: theme.palette.type === 'light' ? theme.palette.text.primary : theme.palette.text.hint, + '&:focused': { + backgroundColor: theme.palette.background.paper, + }, }, clockNumberSelected: { color: theme.palette.primary.contrastText, @@ -63,7 +72,15 @@ export const useStyles = makeStyles( { name: 'MuiPickersClockNumber' } ); -export const ClockNumber: React.FC = ({ selected, label, index, isInner }) => { +export const ClockNumber: React.FC = ({ + selected, + label, + index, + onSelect, + isInner, + getClockNumberText, +}) => { + const ref = React.useRef(null); const classes = useStyles(); const className = clsx(classes.clockNumber, { [classes.clockNumberSelected]: selected, @@ -77,14 +94,26 @@ export const ClockNumber: React.FC = ({ selected, label, index }; }, [index]); + React.useEffect(() => { + if (selected && ref.current) { + ref.current.focus(); + } + }, [selected]); + return ( - + aria-label={getClockNumberText(label)} + onKeyDown={onSpaceOrEnter(() => onSelect(FORCE_FINISH_PICKER))} + > + {label} + ); }; diff --git a/lib/src/views/Clock/ClockNumbers.tsx b/lib/src/views/Clock/ClockNumbers.tsx index 64488f014..f1fe1e35b 100644 --- a/lib/src/views/Clock/ClockNumbers.tsx +++ b/lib/src/views/Clock/ClockNumbers.tsx @@ -6,11 +6,15 @@ import { MaterialUiPickersDate } from '../../typings/date'; export const getHourNumbers = ({ ampm, utils, + onChange, date, + getClockNumberText, }: { ampm: boolean; utils: IUtils; date: MaterialUiPickersDate; + onChange: (value: number, isFinish?: boolean) => void; + getClockNumberText: (hour: string) => string; }) => { const currentHours = utils.getHours(date); @@ -39,12 +43,20 @@ export const getHourNumbers = ({ const props = { index: hour, + onSelect: () => onChange(hour, true), label: utils.formatNumber(label), selected: isSelected(hour), isInner: !ampm && (hour === 0 || hour > 12), }; - hourNumbers.push(); + hourNumbers.push( + + ); } return hourNumbers; @@ -53,24 +65,112 @@ export const getHourNumbers = ({ export const getMinutesNumbers = ({ value, utils, + onChange, + getClockNumberText, }: { value: number; utils: IUtils; + onChange: (value: number, isFinish?: boolean | symbol) => void; + getClockNumberText: (hour: string) => string; }) => { const f = utils.formatNumber; return [ - , - , - , - , - , - , - , - , - , - , - , - , + onChange(0, isFinish)} + selected={value === 0} + key={12} + getClockNumberText={getClockNumberText} + />, + onChange(5, isFinish)} + selected={value === 5} + key={1} + getClockNumberText={getClockNumberText} + />, + onChange(10, isFinish)} + selected={value === 10} + key={2} + getClockNumberText={getClockNumberText} + />, + onChange(15, isFinish)} + selected={value === 15} + key={3} + getClockNumberText={getClockNumberText} + />, + onChange(20, isFinish)} + selected={value === 20} + key={4} + getClockNumberText={getClockNumberText} + />, + onChange(25, isFinish)} + selected={value === 25} + key={5} + getClockNumberText={getClockNumberText} + />, + onChange(30, isFinish)} + selected={value === 30} + key={6} + getClockNumberText={getClockNumberText} + />, + onChange(35, isFinish)} + selected={value === 35} + key={7} + getClockNumberText={getClockNumberText} + />, + onChange(40, isFinish)} + selected={value === 40} + key={8} + getClockNumberText={getClockNumberText} + />, + onChange(45, isFinish)} + selected={value === 45} + key={9} + getClockNumberText={getClockNumberText} + />, + onChange(50, isFinish)} + selected={value === 50} + key={10} + getClockNumberText={getClockNumberText} + />, + onChange(55, isFinish)} + selected={value === 55} + key={11} + getClockNumberText={getClockNumberText} + />, ]; }; diff --git a/lib/src/views/Clock/ClockPointer.tsx b/lib/src/views/Clock/ClockPointer.tsx index 8fbeba5ef..54feb21ff 100644 --- a/lib/src/views/Clock/ClockPointer.tsx +++ b/lib/src/views/Clock/ClockPointer.tsx @@ -3,7 +3,9 @@ import clsx from 'clsx'; import { ClockViewType } from '../../constants/ClockType'; import { withStyles, createStyles, Theme, WithStyles } from '@material-ui/core/styles'; -export interface ClockPointerProps extends WithStyles { +export interface ClockPointerProps + extends React.HTMLProps, + WithStyles { value: number; hasSelected: boolean; isInner: boolean; @@ -50,10 +52,11 @@ export class ClockPointer extends React.Component { }; public render() { - const { classes, hasSelected } = this.props; + const { classes, hasSelected, isInner, type, ...other } = this.props; return (
void; - /** On hour change @DateIOType */ - onHourChange: (date: MaterialUiPickersDate, isFinish?: boolean) => void; - /** On minutes change @DateIOType */ - onMinutesChange: (date: MaterialUiPickersDate, isFinish?: boolean) => void; - /** On seconds change @DateIOType */ - onSecondsChange: (date: MaterialUiPickersDate, isFinish?: boolean) => void; + onDateChange: PickerOnChangeFn; + /** On change callback @DateIOType */ + onChange: PickerOnChangeFn; + /** Get clock number aria-text for hours */ + getHoursClockNumberText?: (hoursText: string) => string; + /** Get clock number aria-text for minutes */ + getMinutesClockNumberText?: (minutesText: string) => string; + /** Get clock number aria-text for seconds */ + getSecondsClockNumberText?: (secondsText: string) => string; + /** + * Enables keyboard listener for moving between days in calendar + * @default currentWrapper !== 'static' + */ + allowKeyboardControl?: boolean; } +const getHoursAriaText = (hour: string) => `${hour} hours`; +const getMinutesAriaText = (minute: string) => `${minute} minutes`; +const getSecondsAriaText = (seconds: string) => `${seconds} seconds`; export const ClockView: React.FC = ({ type, onDateChange, - onHourChange, - onMinutesChange, - onSecondsChange, + onChange, ampm, date, minutesStep, ampmInClock, + allowKeyboardControl, + getHoursClockNumberText = getHoursAriaText, + getMinutesClockNumberText = getMinutesAriaText, + getSecondsClockNumberText = getSecondsAriaText, }) => { const utils = useUtils(); const viewProps = React.useMemo(() => { switch (type) { case 'hours': + const handleHoursChange = (value: number, isFinish?: boolean | symbol) => { + const currentMeridiem = getMeridiem(date, utils); + const updatedTimeWithMeridiem = convertToMeridiem( + utils.setHours(date, value), + currentMeridiem, + Boolean(ampm), + utils + ); + + onChange(updatedTimeWithMeridiem, isFinish); + }; + return { + onChange: handleHoursChange, value: utils.getHours(date), - children: getHourNumbers({ date, utils, ampm: Boolean(ampm) }), - onChange: (value: number, isFinish?: boolean) => { - const currentMeridiem = getMeridiem(date, utils); - const updatedTimeWithMeridiem = convertToMeridiem( - utils.setHours(date, value), - currentMeridiem, - Boolean(ampm), - utils - ); - - onHourChange(updatedTimeWithMeridiem, isFinish); - }, + children: getHourNumbers({ + date, + utils, + onChange: handleHoursChange, + ampm: Boolean(ampm), + getClockNumberText: getHoursClockNumberText, + }), }; case 'minutes': const minutesValue = utils.getMinutes(date); + const handleMinutesChange = (value: number, isFinish?: boolean | symbol) => { + const updatedTime = utils.setMinutes(date, value); + + onChange(updatedTime, isFinish); + }; + return { value: minutesValue, - children: getMinutesNumbers({ value: minutesValue, utils }), - onChange: (value: number, isFinish?: boolean) => { - const updatedTime = utils.setMinutes(date, value); - - onMinutesChange(updatedTime, isFinish); - }, + onChange: handleMinutesChange, + children: getMinutesNumbers({ + utils, + value: minutesValue, + onChange: handleMinutesChange, + getClockNumberText: getMinutesClockNumberText, + }), }; case 'seconds': const secondsValue = utils.getSeconds(date); + const handleSecondsChange = (value: number, isFinish?: boolean | symbol) => { + const updatedTime = utils.setSeconds(date, value); + + onChange(updatedTime, isFinish); + }; + return { value: secondsValue, - children: getMinutesNumbers({ value: secondsValue, utils }), - onChange: (value: number, isFinish?: boolean) => { - const updatedTime = utils.setSeconds(date, value); - - onSecondsChange(updatedTime, isFinish); - }, + onChange: handleSecondsChange, + children: getMinutesNumbers({ + utils, + value: secondsValue, + onChange: handleSecondsChange, + getClockNumberText: getSecondsClockNumberText, + }), }; default: throw new Error('You must provide the type for ClockView'); } - }, [ampm, date, onHourChange, onMinutesChange, onSecondsChange, type, utils]); + }, [ + ampm, + date, + getHoursClockNumberText, + getMinutesClockNumberText, + getSecondsClockNumberText, + onChange, + type, + utils, + ]); return ( = ({ type={type} ampm={ampm} minutesStep={minutesStep} + allowKeyboardControl={allowKeyboardControl} {...viewProps} /> ); @@ -117,9 +162,7 @@ ClockView.displayName = 'ClockView'; // @ts-ignore ClockView.propTypes = { date: PropTypes.object.isRequired, - onHourChange: PropTypes.func.isRequired, - onMinutesChange: PropTypes.func.isRequired, - onSecondsChange: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, ampm: PropTypes.bool, minutesStep: PropTypes.number, type: PropTypes.oneOf(['minutes', 'hours', 'seconds']).isRequired, diff --git a/lib/src/wrappers/DesktopWrapper.tsx b/lib/src/wrappers/DesktopWrapper.tsx index 84f483142..01a78a3fb 100644 --- a/lib/src/wrappers/DesktopWrapper.tsx +++ b/lib/src/wrappers/DesktopWrapper.tsx @@ -3,9 +3,9 @@ import * as PropTypes from 'prop-types'; import KeyboardDateInput from '../_shared/KeyboardDateInput'; import Popover, { PopoverProps } from '@material-ui/core/Popover'; import { WrapperProps } from './Wrapper'; +import { makeStyles } from '@material-ui/core'; import { InnerMobileWrapperProps } from './MobileWrapper'; import { WrapperVariantContext } from './WrapperVariantContext'; -import { useKeyDownHandler } from '../_shared/hooks/useKeyDown'; export interface InnerDesktopWrapperProps { /** Popover props passed to material-ui Popover */ @@ -17,6 +17,14 @@ export interface DesktopWrapperProps WrapperProps, Partial {} +const useStyles = makeStyles({ + popover: { + '&:focus': { + outline: 'auto', + }, + }, +}); + export const DesktopWrapper: React.FC = ({ open, wider, @@ -38,19 +46,18 @@ export const DesktopWrapper: React.FC = ({ ...other }) => { const ref = React.useRef(); - const handleKeydown = useKeyDownHandler(open, { - 13: onAccept, // Enter - }); + const classes = useStyles(); return ( = ({ vertical: 'top', horizontal: 'center', }} - children={children} {...PopoverProps} - /> + > + {children} + ); }; diff --git a/lib/src/wrappers/MobileWrapper.tsx b/lib/src/wrappers/MobileWrapper.tsx index 4a5ec30b8..94bbaa221 100644 --- a/lib/src/wrappers/MobileWrapper.tsx +++ b/lib/src/wrappers/MobileWrapper.tsx @@ -5,7 +5,6 @@ import { WrapperProps } from './Wrapper'; import { PureDateInput } from '../_shared/PureDateInput'; import { InnerDesktopWrapperProps } from './DesktopWrapper'; import { WrapperVariantContext } from './WrapperVariantContext'; -import { useKeyDownHandler } from '../_shared/hooks/useKeyDown'; import { DialogProps as MuiDialogProps } from '@material-ui/core/Dialog'; export interface InnerMobileWrapperProps { @@ -44,6 +43,8 @@ export interface InnerMobileWrapperProps { * @type {Partial} */ DialogProps?: Partial>; + showTabs?: boolean; + wider?: boolean; } export interface MobileWrapperProps @@ -71,16 +72,11 @@ export const MobileWrapper: React.FC = ({ PopoverProps, ...other }) => { - const handleKeyDown = useKeyDownHandler(open, { - 13: onAccept, // Enter - }); - return ( = ({ - desktopModeBreakpoint = 'sm', + desktopModeBreakpoint = 'md', okLabel, cancelLabel, clearLabel, diff --git a/lib/src/wrappers/Wrapper.tsx b/lib/src/wrappers/Wrapper.tsx index 4280f78a3..14f9bd2fa 100644 --- a/lib/src/wrappers/Wrapper.tsx +++ b/lib/src/wrappers/Wrapper.tsx @@ -11,8 +11,6 @@ export interface WrapperProps { onClear: () => void; onSetToday: () => void; DateInputProps: DateInputProps; - wider?: boolean; - showTabs?: boolean; } export type OmitInnerWrapperProps = Omit; diff --git a/yarn.lock b/yarn.lock index aff80a1de..e82a3e3f1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1269,52 +1269,52 @@ debug "^3.1.0" lodash.once "^4.1.1" -"@date-io/core@^2.2.0": - version "2.2.0" - resolved "https://registry.yarnpkg.com/@date-io/core/-/core-2.2.0.tgz#cb351cefa3544460f183ebf26b52f3b5781c28fa" - integrity sha512-QM0ESKqAtuz8kNz53XYJFLZtceVp1V1cbhqdIDMveB0wSfDfBk8m5iT0GkHv5RIs+p6PXVd3zkeqz/qD4UzdPw== +"@date-io/core@^2.4.0": + version "2.4.0" + resolved "https://registry.yarnpkg.com/@date-io/core/-/core-2.4.0.tgz#59e1c6de1d48ee63c6e97681b415076d04e7b5bf" + integrity sha512-XUr4TSwFmthcCn5QYnGqobbnBqOsSyCggRfvieMQHPSz5zei8KYpw4xlvFFQfu/MI3CmCHDjWMkVaPy/uFIDNA== "@date-io/date-fns@^2.1.0": - version "2.2.0" - resolved "https://registry.yarnpkg.com/@date-io/date-fns/-/date-fns-2.2.0.tgz#366a3fbeddf9bfbfe6286d44bdb0e5f8c1e12545" - integrity sha512-69+fw/RfOT8PwJrfR6xMzn0gewkFOBEgO/j4o2xiI/u4jw4mX26H5o8W43arzLTmIHQ8bg64sjci0M00QOpWAg== + version "2.4.0" + resolved "https://registry.yarnpkg.com/@date-io/date-fns/-/date-fns-2.4.0.tgz#d41aa353806a3b8aa28fbcadf2c860438bcbfaa9" + integrity sha512-DYlfSiTs6GuPmbAmJ9ws7aaOd8f89lVBBqU+71w4Qxxv9DW2qQvBWwOkvCJ5qSp4ae4+PtCzy6JKQDKsdgBaJg== dependencies: - "@date-io/core" "^2.2.0" + "@date-io/core" "^2.4.0" "@date-io/dayjs@^2.1.0": - version "2.2.0" - resolved "https://registry.yarnpkg.com/@date-io/dayjs/-/dayjs-2.2.0.tgz#236cbd1d49da3e871503adaa9ad1ebcd6628c8f6" - integrity sha512-U/jlTqb4+AoiPRl1Rs9/+kmorBQ5sEsAam5ovs1cKajh6OpxZ4LteD3E5fB/dzEWIxJAwVBwdnA4vLf1zsAbgg== + version "2.4.0" + resolved "https://registry.yarnpkg.com/@date-io/dayjs/-/dayjs-2.4.0.tgz#972094ba29af18422c01e2674a3d5c5704382a76" + integrity sha512-U4IxzpK4Us9lkMN+iTzwJayKjIcb+0X2Kdpt+XoB+0kug7ri+wGwypxncaYnRwwztYGBKWJcQiDlSOBU+0pkBA== dependencies: - "@date-io/core" "^2.2.0" + "@date-io/core" "^2.4.0" "@date-io/hijri@^2.2.0": - version "2.2.0" - resolved "https://registry.yarnpkg.com/@date-io/hijri/-/hijri-2.2.0.tgz#4a4e449e3a860ed9901c6d2270b2742d5b650646" - integrity sha512-a7CXbeEENFRz057ljDaBEYRWVN9M/1HiHVCwQpPEs7OjQLNGaullTpiMP+J/K2QQOo4r9sWP1SLZ9hMP64Mvqw== + version "2.4.0" + resolved "https://registry.yarnpkg.com/@date-io/hijri/-/hijri-2.4.0.tgz#0deaabd0f3431c357912defd4037e7b717906e58" + integrity sha512-0ZHbKvFCzncdc920wnVfIU1p/So9NNFXT7X0QvP0XzhQIq3qVMYuJsmpaogU20UNZrMQ612yOjuz94HtmdEjUA== dependencies: - "@date-io/moment" "^2.2.0" + "@date-io/moment" "^2.4.0" "@date-io/jalaali@^2.0.0": - version "2.2.0" - resolved "https://registry.yarnpkg.com/@date-io/jalaali/-/jalaali-2.2.0.tgz#5a6b00fdb3e7e969725764549931bc2ff5cd4a19" - integrity sha512-CTS6ERElHhuyhLqG8iuRLWqbXsRQknLpgCcxg2YeqP/PgVbZC4EDsO5bc8FLIGQbOeEPKGj8SC2q2yE4RyZzdA== + version "2.4.0" + resolved "https://registry.yarnpkg.com/@date-io/jalaali/-/jalaali-2.4.0.tgz#58a2a5df813285fe5d3c477d7d1b2a0a2cdd344a" + integrity sha512-f9Uj11F2KUmlp3AA+ocddXaK4Wit5Pc7tUVz90sS8Um1jM2vHb/f6F4d+RhouH0/8DuIFClMGCYPFWRvUjN1YQ== dependencies: - "@date-io/moment" "^2.2.0" + "@date-io/moment" "^2.4.0" "@date-io/luxon@^2.1.0": - version "2.2.0" - resolved "https://registry.yarnpkg.com/@date-io/luxon/-/luxon-2.2.0.tgz#91ed2ff6e88c9090ab50ff6ca4ecc2efc5a48c05" - integrity sha512-mRLEm08a2uvsxze9PwMyi7RAiQJF/UHjJXRHbc5FnuYSBHsKKzoyicafCntJxvGX0tKePdzDnusXrukW3JrBdA== + version "2.4.0" + resolved "https://registry.yarnpkg.com/@date-io/luxon/-/luxon-2.4.0.tgz#b032f1d4eac47c18dc1c447e9235295cf95bf166" + integrity sha512-EFbLSYVhXcxMxg4pJF7e5yCwB9KZc4MJlsWSpXUla29ylcB9rlU07AeV74ogdy+kzakFFN/QdFxhuMvxqyBAxw== dependencies: - "@date-io/core" "^2.2.0" + "@date-io/core" "^2.4.0" -"@date-io/moment@^2.1.0", "@date-io/moment@^2.2.0": - version "2.2.0" - resolved "https://registry.yarnpkg.com/@date-io/moment/-/moment-2.2.0.tgz#e9ed751c92d29e211ef729de67e67619720313f1" - integrity sha512-EFEwNBMCrUhYVic31dzKZLsftgu7DlKZ90yTDFrPnwt4Mk9PV1FcT4VYsmyp+F6V0LP9+X7zHSktxDJE9Jq9Qg== +"@date-io/moment@^2.1.0", "@date-io/moment@^2.4.0": + version "2.4.0" + resolved "https://registry.yarnpkg.com/@date-io/moment/-/moment-2.4.0.tgz#84612f4cf175ec8093c43a6b6176f874d2d1f007" + integrity sha512-857C9idmZGpD8NygxQsOXPa3OePC6UFAhU996dCUv8cY17p1tkjgj7/JZ3J7PTV2i8ybeUDUD/tAadACEtI05g== dependencies: - "@date-io/core" "^2.2.0" + "@date-io/core" "^2.4.0" "@emotion/hash@^0.7.4": version "0.7.4"