TimePickerCalendar: adds keyboard navigation to the widget (#39070)

* WIP: adds react-aria package to package.json

* adds focus trapping to the calendar widget

* makes focus to move in and out of the widget

* Chore: remove unused component

* WIP: adds keyboard nav to calendar

* adds close button to calendar on wide screen

* Chore: update test to reflect new changes

* use more descriptive aria label

* prune duplicate absolute timeRange aria label

* TimePicker: Use aria overlays to enable closing with esc (#40045)

* Move timepicker to function component

* update overlayprops

* Remove unused import

* Fix for picker closing before setting the range when selecting quick ranges

* use more descriptive aria label

* update test to correspond with new label

* chore: some nit fix

* chore: used specific version for react-aria/overlay package

* Chore: refactor timePickerCalendar component

* chore: nit fixes

* chore: nit fixes

* reverts back to main and re-add deps with yarn 2

* chore: removes react-aria deps from root

* Chore: replace default export

Co-authored-by: Oscar Kilhed <oscar.kilhed@grafana.com>
Co-authored-by: Hugo Häggmark <hugo.haggmark@gmail.com>
pull/40242/head^2
Uchechukwu Obasi 4 years ago committed by GitHub
parent f59aabbd3b
commit 3cec0b1227
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 10
      packages/grafana-e2e-selectors/src/selectors/components.ts
  2. 2
      packages/grafana-ui/package.json
  3. 2
      packages/grafana-ui/src/components/DateTimePickers/DatePicker/DatePicker.tsx
  4. 3
      packages/grafana-ui/src/components/DateTimePickers/DateTimePicker/DateTimePicker.tsx
  5. 184
      packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx
  6. 159
      packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/CalendarBody.tsx
  7. 40
      packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/CalendarFooter.tsx
  8. 38
      packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/CalendarHeader.tsx
  9. 2
      packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerCalendar.test.tsx
  10. 268
      packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerCalendar.tsx
  11. 8
      packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerContent.test.tsx
  12. 38
      packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeForm.test.tsx
  13. 25
      packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeForm.tsx
  14. 251
      yarn.lock

@ -12,10 +12,14 @@
export const Components = {
TimePicker: {
openButton: 'data-testid TimePicker Open Button',
fromField: 'TimePicker from field',
toField: 'TimePicker to field',
fromField: 'Time Range from field',
toField: 'Time Range to field',
applyTimeRange: 'data-testid TimePicker submit button',
calendar: 'TimePicker calendar',
calendar: {
label: 'Time Range calendar',
openButton: 'Open time range calendar',
closeButton: 'Close time range Calendar',
},
absoluteTimeRangeTitle: 'data-testid-absolute-time-range-narrow',
},
DataSource: {

@ -40,6 +40,8 @@
"@grafana/tsconfig": "^1.0.0-rc1",
"@monaco-editor/react": "4.2.2",
"@popperjs/core": "2.5.4",
"@react-aria/focus": "3.4.1",
"@react-aria/overlays": "3.7.2",
"@sentry/browser": "5.25.0",
"ansicolor": "1.1.95",
"classnames": "2.2.6",

@ -5,7 +5,7 @@ import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../../themes';
import { ClickOutsideWrapper } from '../../ClickOutsideWrapper/ClickOutsideWrapper';
import { Icon } from '../../Icon/Icon';
import { getBodyStyles } from '../TimeRangePicker/TimePickerCalendar';
import { getBodyStyles } from '../TimeRangePicker/CalendarBody';
/** @public */
export interface DatePickerProps {

@ -5,9 +5,10 @@ import { css, cx } from '@emotion/css';
import { dateTimeFormat, DateTime, dateTime, GrafanaTheme2, isDateTime } from '@grafana/data';
import { Button, ClickOutsideWrapper, HorizontalGroup, Icon, InlineField, Input, Portal } from '../..';
import { TimeOfDayPicker } from '../TimeOfDayPicker';
import { getBodyStyles, getStyles as getCalendarStyles } from '../TimeRangePicker/TimePickerCalendar';
import { getStyles as getCalendarStyles } from '../TimeRangePicker/TimePickerCalendar';
import { useStyles2, useTheme2 } from '../../../themes';
import { isValid } from '../utils';
import { getBodyStyles } from '../TimeRangePicker/CalendarBody';
export interface Props {
/** Input date for the component */

@ -1,11 +1,10 @@
// Libraries
import React, { PureComponent, memo, FormEvent } from 'react';
import React, { memo, FormEvent, createRef, useState, ReactElement } from 'react';
import { css } from '@emotion/css';
// Components
import { Tooltip } from '../Tooltip/Tooltip';
import { TimePickerContent } from './TimeRangePicker/TimePickerContent';
import { ClickOutsideWrapper } from '../ClickOutsideWrapper/ClickOutsideWrapper';
// Utils & Services
import { stylesFactory } from '../../themes/stylesFactory';
@ -26,6 +25,8 @@ import { Themeable } from '../../types';
import { quickOptions } from './options';
import { ButtonGroup, ToolbarButton } from '../Button';
import { selectors } from '@grafana/e2e-selectors';
import { useOverlay } from '@react-aria/overlays';
import { FocusScope } from '@react-aria/focus';
/** @public */
export interface TimeRangePickerProps extends Themeable {
@ -49,95 +50,80 @@ export interface State {
isOpen: boolean;
}
export class UnthemedTimeRangePicker extends PureComponent<TimeRangePickerProps, State> {
state: State = {
isOpen: false,
export function UnthemedTimeRangePicker(props: TimeRangePickerProps): ReactElement {
const [isOpen, setOpen] = useState(false);
const {
value,
onMoveBackward,
onMoveForward,
onZoom,
timeZone,
fiscalYearStartMonth,
timeSyncButton,
isSynced,
theme,
history,
onChangeTimeZone,
onChangeFiscalYearStartMonth,
hideQuickRanges,
} = props;
const onChange = (timeRange: TimeRange) => {
props.onChange(timeRange);
setOpen(false);
};
onChange = (timeRange: TimeRange) => {
this.props.onChange(timeRange);
this.setState({ isOpen: false });
};
onOpen = (event: FormEvent<HTMLButtonElement>) => {
const { isOpen } = this.state;
const onOpen = (event: FormEvent<HTMLButtonElement>) => {
event.stopPropagation();
event.preventDefault();
this.setState({ isOpen: !isOpen });
setOpen(!isOpen);
};
componentDidMount() {
window.addEventListener('keyup', this.onKeyUp);
}
componentWillUnmount() {
window.removeEventListener('keyup', this.onKeyUp);
}
onKeyUp = (event: KeyboardEvent) => {
if (event.code === 'Escape') {
this.onClose();
}
const onClose = () => {
setOpen(false);
};
onClose = () => {
this.setState({ isOpen: false });
};
const ref = createRef<HTMLElement>();
const { overlayProps } = useOverlay({ onClose, isOpen }, ref);
const styles = getStyles(theme);
const hasAbsolute = isDateTime(value.raw.from) || isDateTime(value.raw.to);
const variant = isSynced ? 'active' : 'default';
render() {
const {
value,
onMoveBackward,
onMoveForward,
onZoom,
timeZone,
fiscalYearStartMonth,
timeSyncButton,
isSynced,
theme,
history,
onChangeTimeZone,
onChangeFiscalYearStartMonth,
hideQuickRanges,
} = this.props;
const { isOpen } = this.state;
const styles = getStyles(theme);
const hasAbsolute = isDateTime(value.raw.from) || isDateTime(value.raw.to);
const variant = isSynced ? 'active' : 'default';
return (
<ButtonGroup className={styles.container}>
{hasAbsolute && (
<ToolbarButton
aria-label="Move time range backwards"
variant={variant}
onClick={onMoveBackward}
icon="angle-left"
narrow
/>
)}
<Tooltip content={<TimePickerTooltip timeRange={value} timeZone={timeZone} />} placement="bottom">
<ToolbarButton
data-testid={selectors.components.TimePicker.openButton}
aria-label={`Time range picker with current time range ${formattedRange(value, timeZone)} selected`}
aria-controls="TimePickerContent"
onClick={this.onOpen}
icon="clock-nine"
isOpen={isOpen}
variant={variant}
>
<TimePickerButtonLabel {...this.props} />
</ToolbarButton>
</Tooltip>
{isOpen && (
<ClickOutsideWrapper includeButtonPress={false} onClick={this.onClose}>
return (
<ButtonGroup className={styles.container}>
{hasAbsolute && (
<ToolbarButton
aria-label="Move time range backwards"
variant={variant}
onClick={onMoveBackward}
icon="angle-left"
narrow
/>
)}
<Tooltip content={<TimePickerTooltip timeRange={value} timeZone={timeZone} />} placement="bottom">
<ToolbarButton
data-testid={selectors.components.TimePicker.openButton}
aria-label={`Time range picker with current time range ${formattedRange(value, timeZone)} selected`}
aria-controls="TimePickerContent"
onClick={onOpen}
icon="clock-nine"
isOpen={isOpen}
variant={variant}
>
<TimePickerButtonLabel {...props} />
</ToolbarButton>
</Tooltip>
{isOpen && (
<FocusScope contain autoFocus restoreFocus>
<section ref={ref} {...overlayProps}>
<TimePickerContent
timeZone={timeZone}
fiscalYearStartMonth={fiscalYearStartMonth}
value={value}
onChange={this.onChange}
onChange={onChange}
quickOptions={quickOptions}
history={history}
showHistory
@ -145,27 +131,27 @@ export class UnthemedTimeRangePicker extends PureComponent<TimeRangePickerProps,
onChangeFiscalYearStartMonth={onChangeFiscalYearStartMonth}
hideQuickRanges={hideQuickRanges}
/>
</ClickOutsideWrapper>
)}
{timeSyncButton}
{hasAbsolute && (
<ToolbarButton
aria-label="Move time range forwards"
onClick={onMoveForward}
icon="angle-right"
narrow
variant={variant}
/>
)}
<Tooltip content={ZoomOutTooltip} placement="bottom">
<ToolbarButton aria-label="Zoom out time range" onClick={onZoom} icon="search-minus" variant={variant} />
</Tooltip>
</ButtonGroup>
);
}
</section>
</FocusScope>
)}
{timeSyncButton}
{hasAbsolute && (
<ToolbarButton
aria-label="Move time range forwards"
onClick={onMoveForward}
icon="angle-right"
narrow
variant={variant}
/>
)}
<Tooltip content={ZoomOutTooltip} placement="bottom">
<ToolbarButton aria-label="Zoom out time range" onClick={onZoom} icon="search-minus" variant={variant} />
</Tooltip>
</ButtonGroup>
);
}
const ZoomOutTooltip = () => (

@ -0,0 +1,159 @@
import React, { useCallback } from 'react';
import { useStyles2 } from '../../../themes';
import Calendar from 'react-calendar';
import { css } from '@emotion/css';
import { Icon } from '../../Icon/Icon';
import { TimePickerCalendarProps } from './TimePickerCalendar';
import { GrafanaTheme2, dateTime, dateTimeParse, DateTime, TimeZone } from '@grafana/data';
export function Body({ onChange, from, to, timeZone }: TimePickerCalendarProps) {
const value = inputToValue(from, to);
const onCalendarChange = useOnCalendarChange(onChange, timeZone);
const styles = useStyles2(getBodyStyles);
return (
<Calendar
selectRange={true}
next2Label={null}
prev2Label={null}
className={styles.body}
tileClassName={styles.title}
value={value}
nextLabel={<Icon name="angle-right" />}
prevLabel={<Icon name="angle-left" />}
onChange={onCalendarChange}
locale="en"
/>
);
}
Body.displayName = 'Body';
export function inputToValue(from: DateTime, to: DateTime, invalidDateDefault: Date = new Date()): Date[] {
const fromAsDate = from.toDate();
const toAsDate = to.toDate();
const fromAsValidDate = dateTime(fromAsDate).isValid() ? fromAsDate : invalidDateDefault;
const toAsValidDate = dateTime(toAsDate).isValid() ? toAsDate : invalidDateDefault;
if (fromAsValidDate > toAsValidDate) {
return [toAsValidDate, fromAsValidDate];
}
return [fromAsValidDate, toAsValidDate];
}
function useOnCalendarChange(onChange: (from: DateTime, to: DateTime) => void, timeZone?: TimeZone) {
return useCallback(
(value: Date | Date[]) => {
if (!Array.isArray(value)) {
return console.error('onCalendarChange: should be run in selectRange={true}');
}
const from = dateTimeParse(dateInfo(value[0]), { timeZone });
const to = dateTimeParse(dateInfo(value[1]), { timeZone });
onChange(from, to);
},
[onChange, timeZone]
);
}
function dateInfo(date: Date): number[] {
return [date.getFullYear(), date.getMonth(), date.getDate(), date.getHours(), date.getMinutes(), date.getSeconds()];
}
export const getBodyStyles = (theme: GrafanaTheme2) => {
return {
title: css`
color: ${theme.colors.text};
background-color: ${theme.colors.background.primary};
font-size: ${theme.typography.size.md};
border: 1px solid transparent;
&:hover {
position: relative;
}
`,
body: css`
z-index: ${theme.zIndex.modal};
background-color: ${theme.colors.background.primary};
width: 268px;
.react-calendar__navigation__label,
.react-calendar__navigation__arrow,
.react-calendar__navigation {
padding-top: 4px;
background-color: inherit;
color: ${theme.colors.text};
border: 0;
font-weight: ${theme.typography.fontWeightMedium};
}
.react-calendar__month-view__weekdays {
background-color: inherit;
text-align: center;
color: ${theme.colors.primary.text};
abbr {
border: 0;
text-decoration: none;
cursor: default;
display: block;
padding: 4px 0 4px 0;
}
}
.react-calendar__month-view__days {
background-color: inherit;
}
.react-calendar__tile,
.react-calendar__tile--now {
margin-bottom: 4px;
background-color: inherit;
height: 26px;
}
.react-calendar__navigation__label,
.react-calendar__navigation > button:focus,
.time-picker-calendar-tile:focus {
outline: 0;
}
.react-calendar__tile--active,
.react-calendar__tile--active:hover {
color: ${theme.colors.primary.contrastText};
font-weight: ${theme.typography.fontWeightMedium};
background: ${theme.colors.primary.main};
box-shadow: none;
border: 0px;
}
.react-calendar__tile--rangeEnd,
.react-calendar__tile--rangeStart {
padding: 0;
border: 0px;
color: ${theme.colors.primary.contrastText};
font-weight: ${theme.typography.fontWeightMedium};
background: ${theme.colors.primary.main};
abbr {
background-color: ${theme.colors.primary.main};
border-radius: 100px;
display: block;
padding-top: 2px;
height: 26px;
}
}
.react-calendar__tile--rangeStart {
border-top-left-radius: 20px;
border-bottom-left-radius: 20px;
}
.react-calendar__tile--rangeEnd {
border-top-right-radius: 20px;
border-bottom-right-radius: 20px;
}
`,
};
};

@ -0,0 +1,40 @@
import React from 'react';
import { useStyles2 } from '../../../themes';
import { Button } from '../../Button';
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { TimePickerCalendarProps } from './TimePickerCalendar';
export function Footer({ onClose, onApply }: TimePickerCalendarProps) {
const styles = useStyles2(getFooterStyles);
return (
<div className={styles.container}>
<Button className={styles.apply} onClick={onApply}>
Apply time range
</Button>
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
</div>
);
}
Footer.displayName = 'Footer';
const getFooterStyles = (theme: GrafanaTheme2) => {
return {
container: css`
background-color: ${theme.colors.background.primary};
display: flex;
justify-content: center;
padding: 10px;
align-items: stretch;
`,
apply: css`
margin-right: 4px;
width: 100%;
justify-content: center;
`,
};
};

@ -0,0 +1,38 @@
import React from 'react';
import { TimePickerTitle } from './TimePickerTitle';
import { Button } from '../../Button';
import { selectors } from '@grafana/e2e-selectors';
import { TimePickerCalendarProps } from './TimePickerCalendar';
import { useStyles2 } from '../../../themes';
import { GrafanaTheme2 } from '@grafana/data';
import { css } from '@emotion/css';
export function Header({ onClose }: TimePickerCalendarProps) {
const styles = useStyles2(getHeaderStyles);
return (
<div className={styles.container}>
<TimePickerTitle>Select a time range</TimePickerTitle>
<Button
aria-label={selectors.components.TimePicker.calendar.closeButton}
icon="times"
variant="secondary"
onClick={onClose}
/>
</div>
);
}
Header.displayName = 'Header';
const getHeaderStyles = (theme: GrafanaTheme2) => {
return {
container: css`
background-color: ${theme.colors.background.primary};
display: flex;
align-items: center;
justify-content: space-between;
padding: 7px;
`,
};
};

@ -1,6 +1,6 @@
import { dateTime } from '@grafana/data';
import { inputToValue } from './TimePickerCalendar';
import { inputToValue } from './CalendarBody';
describe('inputToValue', () => {
describe('when called with valid dates', () => {

@ -1,16 +1,16 @@
import React, { FormEvent, memo, useCallback } from 'react';
import React, { FormEvent, memo } from 'react';
import { css } from '@emotion/css';
import Calendar from 'react-calendar';
import { dateTime, DateTime, dateTimeParse, GrafanaTheme2, TimeZone } from '@grafana/data';
import { stylesFactory, useTheme2 } from '../../../themes';
import { TimePickerTitle } from './TimePickerTitle';
import { Button } from '../../Button';
import { Icon } from '../../Icon/Icon';
import { DateTime, GrafanaTheme2, TimeZone } from '@grafana/data';
import { useTheme2 } from '../../../themes';
import { Header } from './CalendarHeader';
import { Portal } from '../../Portal/Portal';
import { ClickOutsideWrapper } from '../../ClickOutsideWrapper/ClickOutsideWrapper';
import { selectors } from '@grafana/e2e-selectors';
import { FocusScope } from '@react-aria/focus';
import { useOverlay } from '@react-aria/overlays';
import { Body } from './CalendarBody';
import { Footer } from './CalendarFooter';
export const getStyles = stylesFactory((theme: GrafanaTheme2, isReversed = false) => {
export const getStyles = (theme: GrafanaTheme2, isReversed = false) => {
return {
container: css`
top: -1px;
@ -57,134 +57,9 @@ export const getStyles = stylesFactory((theme: GrafanaTheme2, isReversed = false
text-align: center;
`,
};
});
};
const getFooterStyles = stylesFactory((theme: GrafanaTheme2) => {
return {
container: css`
background-color: ${theme.colors.background.primary};
display: flex;
justify-content: center;
padding: 10px;
align-items: stretch;
`,
apply: css`
margin-right: 4px;
width: 100%;
justify-content: center;
`,
};
});
export const getBodyStyles = stylesFactory((theme: GrafanaTheme2) => {
return {
title: css`
color: ${theme.colors.text};
background-color: ${theme.colors.background.primary};
font-size: ${theme.typography.size.md};
border: 1px solid transparent;
&:hover {
position: relative;
}
`,
body: css`
z-index: ${theme.zIndex.modal};
background-color: ${theme.colors.background.primary};
width: 268px;
.react-calendar__navigation__label,
.react-calendar__navigation__arrow,
.react-calendar__navigation {
padding-top: 4px;
background-color: inherit;
color: ${theme.colors.text};
border: 0;
font-weight: ${theme.typography.fontWeightMedium};
}
.react-calendar__month-view__weekdays {
background-color: inherit;
text-align: center;
color: ${theme.colors.primary.text};
abbr {
border: 0;
text-decoration: none;
cursor: default;
display: block;
padding: 4px 0 4px 0;
}
}
.react-calendar__month-view__days {
background-color: inherit;
}
.react-calendar__tile,
.react-calendar__tile--now {
margin-bottom: 4px;
background-color: inherit;
height: 26px;
}
.react-calendar__navigation__label,
.react-calendar__navigation > button:focus,
.time-picker-calendar-tile:focus {
outline: 0;
}
.react-calendar__tile--active,
.react-calendar__tile--active:hover {
color: ${theme.colors.primary.contrastText};
font-weight: ${theme.typography.fontWeightMedium};
background: ${theme.colors.primary.main};
box-shadow: none;
border: 0px;
}
.react-calendar__tile--rangeEnd,
.react-calendar__tile--rangeStart {
padding: 0;
border: 0px;
color: ${theme.colors.primary.contrastText};
font-weight: ${theme.typography.fontWeightMedium};
background: ${theme.colors.primary.main};
abbr {
background-color: ${theme.colors.primary.main};
border-radius: 100px;
display: block;
padding-top: 2px;
height: 26px;
}
}
.react-calendar__tile--rangeStart {
border-top-left-radius: 20px;
border-bottom-left-radius: 20px;
}
.react-calendar__tile--rangeEnd {
border-top-right-radius: 20px;
border-bottom-right-radius: 20px;
}
`,
};
});
const getHeaderStyles = stylesFactory((theme: GrafanaTheme2) => {
return {
container: css`
background-color: ${theme.colors.background.primary};
display: flex;
justify-content: space-between;
padding: 7px;
`,
};
});
interface Props {
export interface TimePickerCalendarProps {
isOpen: boolean;
from: DateTime;
to: DateTime;
@ -198,10 +73,12 @@ interface Props {
const stopPropagation = (event: React.MouseEvent<HTMLDivElement>) => event.stopPropagation();
export const TimePickerCalendar = memo<Props>((props) => {
function TimePickerCalendar(props: TimePickerCalendarProps) {
const theme = useTheme2();
const styles = getStyles(theme, props.isReversed);
const { isOpen, isFullscreen } = props;
const ref = React.createRef<HTMLElement>();
const { overlayProps } = useOverlay(props, ref);
if (!isOpen) {
return null;
@ -209,118 +86,35 @@ export const TimePickerCalendar = memo<Props>((props) => {
if (isFullscreen) {
return (
<ClickOutsideWrapper onClick={props.onClose}>
<FocusScope contain restoreFocus autoFocus>
<section
className={styles.container}
onClick={stopPropagation}
aria-label={selectors.components.TimePicker.calendar}
aria-label={selectors.components.TimePicker.calendar.label}
ref={ref}
{...overlayProps}
>
<Header {...props} />
<Body {...props} />
</section>
</ClickOutsideWrapper>
</FocusScope>
);
}
return (
<Portal>
<div className={styles.modal} onClick={stopPropagation}>
<div className={styles.content} aria-label={selectors.components.TimePicker.calendar}>
<Header {...props} />
<Body {...props} />
<Footer {...props} />
</div>
</div>
<FocusScope contain autoFocus restoreFocus>
<section className={styles.modal} onClick={stopPropagation} ref={ref} {...overlayProps}>
<div className={styles.content} aria-label={selectors.components.TimePicker.calendar.label}>
<Header {...props} />
<Body {...props} />
<Footer {...props} />
</div>
</section>
</FocusScope>
<div className={styles.backdrop} onClick={stopPropagation} />
</Portal>
);
});
TimePickerCalendar.displayName = 'TimePickerCalendar';
const Header = memo<Props>(({ onClose }) => {
const theme = useTheme2();
const styles = getHeaderStyles(theme);
return (
<div className={styles.container}>
<TimePickerTitle>Select a time range</TimePickerTitle>
<Icon name="times" onClick={onClose} />
</div>
);
});
Header.displayName = 'Header';
export const Body = memo<Props>(({ onChange, from, to, timeZone }) => {
const value = inputToValue(from, to);
const theme = useTheme2();
const onCalendarChange = useOnCalendarChange(onChange, timeZone);
const styles = getBodyStyles(theme);
return (
<Calendar
selectRange={true}
next2Label={null}
prev2Label={null}
className={styles.body}
tileClassName={styles.title}
value={value}
nextLabel={<Icon name="angle-right" />}
prevLabel={<Icon name="angle-left" />}
onChange={onCalendarChange}
locale="en"
/>
);
});
Body.displayName = 'Body';
const Footer = memo<Props>(({ onClose, onApply }) => {
const theme = useTheme2();
const styles = getFooterStyles(theme);
return (
<div className={styles.container}>
<Button className={styles.apply} onClick={onApply}>
Apply time range
</Button>
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
</div>
);
});
Footer.displayName = 'Footer';
export function inputToValue(from: DateTime, to: DateTime, invalidDateDefault: Date = new Date()): Date[] {
const fromAsDate = from.toDate();
const toAsDate = to.toDate();
const fromAsValidDate = dateTime(fromAsDate).isValid() ? fromAsDate : invalidDateDefault;
const toAsValidDate = dateTime(toAsDate).isValid() ? toAsDate : invalidDateDefault;
if (fromAsValidDate > toAsValidDate) {
return [toAsValidDate, fromAsValidDate];
}
return [fromAsValidDate, toAsValidDate];
}
function useOnCalendarChange(onChange: (from: DateTime, to: DateTime) => void, timeZone?: TimeZone) {
return useCallback(
(value: Date | Date[]) => {
if (!Array.isArray(value)) {
return console.error('onCalendarChange: should be run in selectRange={true}');
}
const from = dateTimeParse(dateInfo(value[0]), { timeZone });
const to = dateTimeParse(dateInfo(value[1]), { timeZone });
onChange(from, to);
},
[onChange, timeZone]
);
}
function dateInfo(date: Date): number[] {
return [date.getFullYear(), date.getMonth(), date.getDate(), date.getHours(), date.getMinutes(), date.getSeconds()];
}
export default memo(TimePickerCalendar);
TimePickerCalendar.displayName = 'TimePickerCalendar';

@ -94,22 +94,22 @@ describe('TimePickerContent', () => {
it('renders with absolute picker when absolute value and quick ranges are visible', () => {
renderComponent({ value: absoluteValue, isFullscreen: false });
expect(screen.queryByLabelText(/timepicker from field/i)).toBeInTheDocument();
expect(screen.queryByLabelText(/time range from field/i)).toBeInTheDocument();
});
it('renders with absolute picker when absolute value and quick ranges are hidden', () => {
renderComponent({ value: absoluteValue, isFullscreen: false, hideQuickRanges: true });
expect(screen.queryByLabelText(/timepicker from field/i)).toBeInTheDocument();
expect(screen.queryByLabelText(/time range from field/i)).toBeInTheDocument();
});
it('renders without absolute picker when narrow screen and quick ranges are visible', () => {
renderComponent({ value: relativeValue, isFullscreen: false });
expect(screen.queryByLabelText(/timepicker from field/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/time range from field/i)).not.toBeInTheDocument();
});
it('renders with absolute picker when narrow screen and quick ranges are hidden', () => {
renderComponent({ value: relativeValue, isFullscreen: false, hideQuickRanges: true });
expect(screen.queryByLabelText(/timepicker from field/i)).toBeInTheDocument();
expect(screen.queryByLabelText(/time range from field/i)).toBeInTheDocument();
});
it('renders without timezone picker', () => {

@ -31,20 +31,22 @@ function setup(initial: TimeRange = defaultTimeRange, timeZone = 'utc'): TimeRan
describe('TimeRangeForm', () => {
it('should render form correcty', () => {
const { getByLabelText, getByText } = setup();
const { getByLabelText, getByText, getAllByRole } = setup();
const { TimePicker } = selectors.components;
expect(getByText('Apply time range')).toBeInTheDocument();
expect(getAllByRole('button', { name: TimePicker.calendar.openButton })).toHaveLength(2);
expect(getByLabelText(TimePicker.fromField)).toBeInTheDocument();
expect(getByLabelText(TimePicker.toField)).toBeInTheDocument();
});
it('should display calendar when clicking the from input field', () => {
const { getByLabelText } = setup();
it('should display calendar when clicking the calendar icon', () => {
const { getByLabelText, getAllByRole } = setup();
const { TimePicker } = selectors.components;
const openCalendarButton = getAllByRole('button', { name: TimePicker.calendar.openButton });
fireEvent.focus(getByLabelText(TimePicker.fromField));
expect(getByLabelText(TimePicker.calendar)).toBeInTheDocument();
fireEvent.click(openCalendarButton[0]);
expect(getByLabelText(TimePicker.calendar.label)).toBeInTheDocument();
});
it('should have passed time range entered in form', () => {
@ -58,26 +60,31 @@ describe('TimeRangeForm', () => {
expect(getByLabelText(TimePicker.toField)).toHaveValue(toValue);
});
it('should display calendar when clicking the to input field', () => {
const { getByLabelText } = setup();
it('should close calendar when clicking the close icon', () => {
const { queryByLabelText, getAllByRole, getByRole } = setup();
const { TimePicker } = selectors.components;
const openCalendarButton = getAllByRole('button', { name: TimePicker.calendar.openButton });
fireEvent.click(openCalendarButton[0]);
expect(getByRole('button', { name: TimePicker.calendar.closeButton })).toBeInTheDocument();
fireEvent.focus(getByLabelText(TimePicker.toField));
expect(getByLabelText(TimePicker.calendar)).toBeInTheDocument();
fireEvent.click(getByRole('button', { name: TimePicker.calendar.closeButton }));
expect(queryByLabelText(TimePicker.calendar.label)).toBeNull();
});
it('should not display calendar without clicking any input field', () => {
it('should not display calendar without clicking the calendar icon', () => {
const { queryByLabelText } = setup();
const { TimePicker } = selectors.components;
expect(queryByLabelText(TimePicker.calendar)).toBeNull();
expect(queryByLabelText(TimePicker.calendar.label)).toBeNull();
});
it('should have passed time range selected in calendar', () => {
const { getByLabelText, getCalendarDayByLabelText } = setup();
const { getAllByRole, getCalendarDayByLabelText } = setup();
const { TimePicker } = selectors.components;
const openCalendarButton = getAllByRole('button', { name: TimePicker.calendar.openButton });
fireEvent.focus(getByLabelText(TimePicker.toField));
fireEvent.click(openCalendarButton[0]);
const from = getCalendarDayByLabelText('June 17, 2021');
const to = getCalendarDayByLabelText('June 19, 2021');
@ -86,10 +93,11 @@ describe('TimeRangeForm', () => {
});
it('should select correct time range in calendar when having a custom time zone', () => {
const { getByLabelText, getCalendarDayByLabelText } = setup(defaultTimeRange, 'Asia/Tokyo');
const { getAllByRole, getCalendarDayByLabelText } = setup(defaultTimeRange, 'Asia/Tokyo');
const { TimePicker } = selectors.components;
const openCalendarButton = getAllByRole('button', { name: TimePicker.calendar.openButton });
fireEvent.focus(getByLabelText(TimePicker.toField));
fireEvent.click(openCalendarButton[1]);
const from = getCalendarDayByLabelText('June 17, 2021');
const to = getCalendarDayByLabelText('June 19, 2021');

@ -18,7 +18,7 @@ import { useStyles2 } from '../../..';
import { Button } from '../../Button';
import { Field } from '../../Forms/Field';
import { Input } from '../../Input/Input';
import { TimePickerCalendar } from './TimePickerCalendar';
import TimePickerCalendar from './TimePickerCalendar';
interface Props {
isFullscreen: boolean;
@ -65,16 +65,6 @@ export const TimeRangeForm: React.FC<Props> = (props) => {
[setOpen]
);
const onFocus = useCallback(
(event: FormEvent<HTMLElement>) => {
if (!isFullscreen) {
return;
}
onOpen(event);
},
[isFullscreen, onOpen]
);
const onApply = useCallback(
(e: FormEvent<HTMLButtonElement>) => {
e.preventDefault();
@ -111,15 +101,21 @@ export const TimeRangeForm: React.FC<Props> = (props) => {
</div>
);
const icon = isFullscreen ? null : <Button icon="calendar-alt" variant="secondary" onClick={onOpen} />;
const icon = (
<Button
aria-label={selectors.components.TimePicker.calendar.openButton}
icon="calendar-alt"
variant="secondary"
onClick={onOpen}
/>
);
return (
<div aria-label="Absolute time ranges">
<div>
<div className={style.fieldContainer}>
<Field label="From" invalid={from.invalid} error={from.errorMessage}>
<Input
onClick={(event) => event.stopPropagation()}
onFocus={onFocus}
onChange={(event) => onChange(event.currentTarget.value, to.value)}
addonAfter={icon}
aria-label={selectors.components.TimePicker.fromField}
@ -132,7 +128,6 @@ export const TimeRangeForm: React.FC<Props> = (props) => {
<Field label="To" invalid={to.invalid} error={to.errorMessage}>
<Input
onClick={(event) => event.stopPropagation()}
onFocus={onFocus}
onChange={(event) => onChange(from.value, event.currentTarget.value)}
addonAfter={icon}
aria-label={selectors.components.TimePicker.toField}

@ -1764,7 +1764,7 @@ __metadata:
languageName: node
linkType: hard
"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.10.2, @babel/runtime@npm:^7.11.1, @babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.0, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.13, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.13.10, @babel/runtime@npm:^7.14.0, @babel/runtime@npm:^7.14.8, @babel/runtime@npm:^7.3.1, @babel/runtime@npm:^7.4.4, @babel/runtime@npm:^7.5.0, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.7.2, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7, @babel/runtime@npm:^7.9.2":
"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.10.2, @babel/runtime@npm:^7.11.1, @babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.0, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.13, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.13.10, @babel/runtime@npm:^7.14.0, @babel/runtime@npm:^7.14.8, @babel/runtime@npm:^7.3.1, @babel/runtime@npm:^7.4.4, @babel/runtime@npm:^7.5.0, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.6.2, @babel/runtime@npm:^7.7.2, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7, @babel/runtime@npm:^7.9.2":
version: 7.15.4
resolution: "@babel/runtime@npm:7.15.4"
dependencies:
@ -2247,6 +2247,55 @@ __metadata:
languageName: node
linkType: hard
"@formatjs/ecma402-abstract@npm:1.9.9":
version: 1.9.9
resolution: "@formatjs/ecma402-abstract@npm:1.9.9"
dependencies:
"@formatjs/intl-localematcher": 0.2.21
tslib: ^2.1.0
checksum: f0834658319ed9add4a64ee13704f1616c0af771038debe38ec7ba96a79a65e684b8e363c1573841798856d87c832c29a8be249b50c41cf46980a1a0a38c5cf7
languageName: node
linkType: hard
"@formatjs/fast-memoize@npm:1.2.0":
version: 1.2.0
resolution: "@formatjs/fast-memoize@npm:1.2.0"
dependencies:
tslib: ^2.1.0
checksum: fbc94672c4d0abc595c5680052c1fdaa652e7ffca98175a631a19ec44c5b6e2861ce0410c8ea3c4b46827aad5d229f89c0143a2ccf34ca2fbff79bdf63d27377
languageName: node
linkType: hard
"@formatjs/icu-messageformat-parser@npm:2.0.12":
version: 2.0.12
resolution: "@formatjs/icu-messageformat-parser@npm:2.0.12"
dependencies:
"@formatjs/ecma402-abstract": 1.9.9
"@formatjs/icu-skeleton-parser": 1.2.13
tslib: ^2.1.0
checksum: e895428d242b72454fbaab6fd32638c4f963294ea7999a245004bf9326daac8669153995567c8161b55847197f00fb519a0bbbfe06a89dd600ccf45736ac26c7
languageName: node
linkType: hard
"@formatjs/icu-skeleton-parser@npm:1.2.13":
version: 1.2.13
resolution: "@formatjs/icu-skeleton-parser@npm:1.2.13"
dependencies:
"@formatjs/ecma402-abstract": 1.9.9
tslib: ^2.1.0
checksum: 4d17783f2b4a4e6fa3aa3901fb6249706a7dcec25811c86e979a76e14500d9f2c1034bbb4bf4a47b46ed6c027bdbd9353eda96ad1b6e7bf7a693267384526708
languageName: node
linkType: hard
"@formatjs/intl-localematcher@npm:0.2.21":
version: 0.2.21
resolution: "@formatjs/intl-localematcher@npm:0.2.21"
dependencies:
tslib: ^2.1.0
checksum: d766eb8ce8b2628d781fdb34fd0833a0a1b28f20e70a72dfabbca27cf02bd1b994a72c357b2b3d4888bc20c33b6b7cc7e10e92847ec228a40745a2e84d8d2e24
languageName: node
linkType: hard
"@gar/promisify@npm:^1.0.1":
version: 1.1.2
resolution: "@gar/promisify@npm:1.1.2"
@ -2602,6 +2651,8 @@ __metadata:
"@grafana/tsconfig": ^1.0.0-rc1
"@monaco-editor/react": 4.2.2
"@popperjs/core": 2.5.4
"@react-aria/focus": 3.4.1
"@react-aria/overlays": 3.7.2
"@rollup/plugin-commonjs": 16.0.0
"@rollup/plugin-image": 2.0.5
"@rollup/plugin-node-resolve": 10.0.0
@ -2715,6 +2766,25 @@ __metadata:
languageName: node
linkType: hard
"@internationalized/message@npm:^3.0.2":
version: 3.0.2
resolution: "@internationalized/message@npm:3.0.2"
dependencies:
"@babel/runtime": ^7.6.2
intl-messageformat: ^9.6.12
checksum: 3448ac0be3e6ed5359a8db2e423e6f0ff75d414dc08f24fb05097401f6358c2efe8b43913f4bb85a84509c3b1bc378fc03aa8c6681a1305e61e4be3bbf4b1bd2
languageName: node
linkType: hard
"@internationalized/number@npm:^3.0.2":
version: 3.0.3
resolution: "@internationalized/number@npm:3.0.3"
dependencies:
"@babel/runtime": ^7.6.2
checksum: 338cba3cde1385586ee9c1c913809ddc41f48349096b34cd9dd337e34798685771923cceca693bc708991b38f5d72dc02ddfdecd829f018369825f73f92bb890
languageName: node
linkType: hard
"@istanbuljs/load-nyc-config@npm:^1.0.0":
version: 1.1.0
resolution: "@istanbuljs/load-nyc-config@npm:1.1.0"
@ -4598,6 +4668,165 @@ __metadata:
languageName: node
linkType: hard
"@react-aria/focus@npm:3.4.1":
version: 3.4.1
resolution: "@react-aria/focus@npm:3.4.1"
dependencies:
"@babel/runtime": ^7.6.2
"@react-aria/interactions": ^3.5.1
"@react-aria/utils": ^3.8.2
"@react-types/shared": ^3.8.0
clsx: ^1.1.1
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1
checksum: 14b500a506068a6a9223a4f27f71e380a098586af26adc01bf7aff9261684cbf53b3319450297d1788ec5cac124ee29a6975eae56cc7d4a5114a37595a61c581
languageName: node
linkType: hard
"@react-aria/i18n@npm:^3.3.2":
version: 3.3.2
resolution: "@react-aria/i18n@npm:3.3.2"
dependencies:
"@babel/runtime": ^7.6.2
"@internationalized/message": ^3.0.2
"@internationalized/number": ^3.0.2
"@react-aria/ssr": ^3.0.3
"@react-aria/utils": ^3.8.2
"@react-types/shared": ^3.8.0
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1
checksum: f1065adc6aeda5c6888f9035ed130214ca1f86a3220dae21326a98469805e446df71f25e2a64ed30fbc9722a99e8fdb368a68e062bf406d60041a7b04ba94f97
languageName: node
linkType: hard
"@react-aria/interactions@npm:^3.5.1":
version: 3.6.0
resolution: "@react-aria/interactions@npm:3.6.0"
dependencies:
"@babel/runtime": ^7.6.2
"@react-aria/utils": ^3.9.0
"@react-types/shared": ^3.9.0
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1
checksum: 8f87574fdb68816eac5af92e87c8a2046564d12d6855061a909661b9036083776f29df22201c93f2576378f4596d253ede610c3bd762e74844cb642f59d014b1
languageName: node
linkType: hard
"@react-aria/overlays@npm:3.7.2":
version: 3.7.2
resolution: "@react-aria/overlays@npm:3.7.2"
dependencies:
"@babel/runtime": ^7.6.2
"@react-aria/i18n": ^3.3.2
"@react-aria/interactions": ^3.5.1
"@react-aria/utils": ^3.8.2
"@react-aria/visually-hidden": ^3.2.3
"@react-stately/overlays": ^3.1.3
"@react-types/button": ^3.4.1
"@react-types/overlays": ^3.5.1
dom-helpers: ^3.3.1
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1
react-dom: ^16.8.0 || ^17.0.0-rc.1
checksum: e9681409940ac092db616fb8e868cf480be3fdbea7f488c7e8a5de9f3a5c07174f00d715cc9691895ec74e8961efb91c5950c1d51502924499e3a2917480a5a2
languageName: node
linkType: hard
"@react-aria/ssr@npm:^3.0.3, @react-aria/ssr@npm:^3.1.0":
version: 3.1.0
resolution: "@react-aria/ssr@npm:3.1.0"
dependencies:
"@babel/runtime": ^7.6.2
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1
checksum: ae376d45dbd19e990891353a2eee10c13f3b54dbd807a2e528928983741c0ea9dbfefcc132c46c685e53440a53f7be82bd36a5c61bdfaec3c3518f02ae444e7f
languageName: node
linkType: hard
"@react-aria/utils@npm:^3.8.2, @react-aria/utils@npm:^3.9.0":
version: 3.9.0
resolution: "@react-aria/utils@npm:3.9.0"
dependencies:
"@babel/runtime": ^7.6.2
"@react-aria/ssr": ^3.1.0
"@react-stately/utils": ^3.2.2
"@react-types/shared": ^3.9.0
clsx: ^1.1.1
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1
checksum: ef465effe2d007870342e27e73b179ef51dc21403918a8807931a785606e6199b67e3b6b5e1f0a23ecc8200f4bd8dba558bcdfb1eb6a322955d55c7089ef1136
languageName: node
linkType: hard
"@react-aria/visually-hidden@npm:^3.2.3":
version: 3.2.3
resolution: "@react-aria/visually-hidden@npm:3.2.3"
dependencies:
"@babel/runtime": ^7.6.2
"@react-aria/interactions": ^3.5.1
"@react-aria/utils": ^3.8.2
clsx: ^1.1.1
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1
checksum: 3a7ebc55e54f51ac9118afaa2f032313adf03278b24e03a0e60c40d5cbf501d32c371314b85ab9590535b8e6873927f71f44be34b6adaca7c3e14372c2ecde90
languageName: node
linkType: hard
"@react-stately/overlays@npm:^3.1.3":
version: 3.1.3
resolution: "@react-stately/overlays@npm:3.1.3"
dependencies:
"@babel/runtime": ^7.6.2
"@react-stately/utils": ^3.2.2
"@react-types/overlays": ^3.5.1
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1
checksum: 7319502c2cfd9d86490145b314bb6c7505b8a4a18ba2db77d961d64e7885d3808b3f3079541a46dd1027bc1590e0dd2822245cc7acbcb31f2ad7e86929be81e4
languageName: node
linkType: hard
"@react-stately/utils@npm:^3.2.2":
version: 3.2.2
resolution: "@react-stately/utils@npm:3.2.2"
dependencies:
"@babel/runtime": ^7.6.2
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1
checksum: c51a6f4f173c7db5f00e22f5984427f1b3613e6d2e2eb8ee536448920962975cf04ebe52e07ffb0712b34a7a4e5aa309d03e6e2c364441d7765f84cfefdb5709
languageName: node
linkType: hard
"@react-types/button@npm:^3.4.1":
version: 3.4.1
resolution: "@react-types/button@npm:3.4.1"
dependencies:
"@react-types/shared": ^3.8.0
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1
checksum: 3d5c05ce228117b2c57085e9d2a5827bb4f2c5a6303902faf355495badd69fcbe751963be2266c7f79ba04681c98e0ac6f087aab3554089a5fd582493f21d03f
languageName: node
linkType: hard
"@react-types/overlays@npm:^3.5.1":
version: 3.5.1
resolution: "@react-types/overlays@npm:3.5.1"
dependencies:
"@react-types/shared": ^3.8.0
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1
checksum: a068dce4cf1f066c5015a2ef1e345815163f8cee005854d0cc1c6fd548447f1ed1afdee5b3b5407bf8f64101f4ce9dd87b65dee4546035a88ed48a7ae693db83
languageName: node
linkType: hard
"@react-types/shared@npm:^3.8.0, @react-types/shared@npm:^3.9.0":
version: 3.9.0
resolution: "@react-types/shared@npm:3.9.0"
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1
checksum: 0be11ba4234767d47707dff8601605f46bfd9e3e07dcd3213119517f9a627cca4b71e9030478d3941241d8a6be8b8bc00e92278342daad8f7397bc9eee290f1c
languageName: node
linkType: hard
"@reduxjs/toolkit@npm:1.6.1":
version: 1.6.1
resolution: "@reduxjs/toolkit@npm:1.6.1"
@ -14312,6 +14541,15 @@ __metadata:
languageName: node
linkType: hard
"dom-helpers@npm:^3.3.1":
version: 3.4.0
resolution: "dom-helpers@npm:3.4.0"
dependencies:
"@babel/runtime": ^7.1.2
checksum: 58d9f1c4a96daf77eddc63ae1236b826e1cddd6db66bbf39b18d7e21896d99365b376593352d52a60969d67fa4a8dbef26adc1439fa2c1b355efa37cacbaf637
languageName: node
linkType: hard
"dom-helpers@npm:^5.0.1":
version: 5.2.1
resolution: "dom-helpers@npm:5.2.1"
@ -18968,6 +19206,17 @@ fsevents@~2.1.2:
languageName: node
linkType: hard
"intl-messageformat@npm:^9.6.12":
version: 9.9.2
resolution: "intl-messageformat@npm:9.9.2"
dependencies:
"@formatjs/fast-memoize": 1.2.0
"@formatjs/icu-messageformat-parser": 2.0.12
tslib: ^2.1.0
checksum: a9dbe98375f6c735f0ced03f9ba6bb599afbc96c109238fbb52a8753b664649a78d98752c92813a6a535dea5c67a659801f29c4c32c57db8608c655203794564
languageName: node
linkType: hard
"invariant@npm:^2.2.2, invariant@npm:^2.2.3, invariant@npm:^2.2.4":
version: 2.2.4
resolution: "invariant@npm:2.2.4"

Loading…
Cancel
Save