mirror of https://github.com/grafana/grafana
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
parent
f59aabbd3b
commit
3cec0b1227
@ -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; |
||||
`,
|
||||
}; |
||||
}; |
Loading…
Reference in new issue