RelativeTimeRangePicker: correctly trap focus in overlay (#60984)

* correctly trap focus in relativetimerangepicker overlay

* add underlay

Co-authored-by: Michael Mandrus <michael.mandrus@grafana.com>
This commit is contained in:
Ashley Harrison 2023-01-05 11:35:47 +00:00 committed by GitHub
parent 772e8cbf60
commit a9e39a108c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

View File

@ -1,5 +1,8 @@
import { css, cx } from '@emotion/css';
import React, { FormEvent, useCallback, useState } from 'react';
import { useDialog } from '@react-aria/dialog';
import { FocusScope } from '@react-aria/focus';
import { useOverlay } from '@react-aria/overlays';
import React, { FormEvent, useCallback, useRef, useState } from 'react';
import { usePopper } from 'react-popper';
import { RelativeTimeRange, GrafanaTheme2, TimeOption } from '@grafana/data';
@ -7,7 +10,6 @@ import { RelativeTimeRange, GrafanaTheme2, TimeOption } from '@grafana/data';
import { useStyles2 } from '../../../themes';
import { Trans, t } from '../../../utils/i18n';
import { Button } from '../../Button';
import { ClickOutsideWrapper } from '../../ClickOutsideWrapper/ClickOutsideWrapper';
import CustomScrollbar from '../../CustomScrollbar/CustomScrollbar';
import { Field } from '../../Forms/Field';
import { Icon } from '../../Icon/Icon';
@ -51,6 +53,12 @@ export function RelativeTimeRangePicker(props: RelativeTimeRangePickerProps) {
const timeOption = mapRelativeTimeRangeToOption(timeRange);
const [from, setFrom] = useState<InputState>({ value: timeOption.from, validation: isRangeValid(timeOption.from) });
const [to, setTo] = useState<InputState>({ value: timeOption.to, validation: isRangeValid(timeOption.to) });
const ref = useRef<HTMLDivElement>(null);
const { overlayProps, underlayProps } = useOverlay(
{ onClose: () => setIsOpen(false), isDismissable: true, isOpen },
ref
);
const { dialogProps } = useDialog({}, ref);
const [markerElement, setMarkerElement] = useState<HTMLDivElement | null>(null);
const [selectorElement, setSelectorElement] = useState<HTMLDivElement | null>(null);
@ -116,57 +124,60 @@ export function RelativeTimeRangePicker(props: RelativeTimeRangePickerProps) {
</button>
{isOpen && (
<Portal>
<ClickOutsideWrapper includeButtonPress={false} onClick={onClose}>
<div
className={styles.content}
ref={setSelectorElement}
style={popper.styles.popper}
{...popper.attributes}
>
<div className={styles.body}>
<CustomScrollbar className={styles.leftSide} hideHorizontalTrack>
<TimeRangeList
title={t('time-picker.time-range.example-title', 'Example time ranges')}
options={validOptions}
onChange={onChangeTimeOption}
value={timeOption}
/>
</CustomScrollbar>
<div className={styles.rightSide}>
<div className={styles.title}>
<TimePickerTitle>
<Tooltip content={<TooltipContent />} placement="bottom" theme="info">
<div>
<Trans i18nKey="time-picker.time-range.specify">
Specify time range <Icon name="info-circle" />
</Trans>
</div>
</Tooltip>
</TimePickerTitle>
<div role="presentation" className={styles.backdrop} {...underlayProps} />
<FocusScope contain autoFocus restoreFocus>
<div ref={ref} {...overlayProps} {...dialogProps}>
<div
className={styles.content}
ref={setSelectorElement}
style={popper.styles.popper}
{...popper.attributes}
>
<div className={styles.body}>
<CustomScrollbar className={styles.leftSide} hideHorizontalTrack>
<TimeRangeList
title={t('time-picker.time-range.example-title', 'Example time ranges')}
options={validOptions}
onChange={onChangeTimeOption}
value={timeOption}
/>
</CustomScrollbar>
<div className={styles.rightSide}>
<div className={styles.title}>
<TimePickerTitle>
<Tooltip content={<TooltipContent />} placement="bottom" theme="info">
<div>
<Trans i18nKey="time-picker.time-range.specify">
Specify time range <Icon name="info-circle" />
</Trans>
</div>
</Tooltip>
</TimePickerTitle>
</div>
<Field label="From" invalid={!from.validation.isValid} error={from.validation.errorMessage}>
<Input
onClick={(event) => event.stopPropagation()}
onBlur={() => setFrom({ ...from, validation: isRangeValid(from.value) })}
onChange={(event) => setFrom({ ...from, value: event.currentTarget.value })}
value={from.value}
/>
</Field>
<Field label="To" invalid={!to.validation.isValid} error={to.validation.errorMessage}>
<Input
onClick={(event) => event.stopPropagation()}
onBlur={() => setTo({ ...to, validation: isRangeValid(to.value) })}
onChange={(event) => setTo({ ...to, value: event.currentTarget.value })}
value={to.value}
/>
</Field>
<Button aria-label="TimePicker submit button" onClick={onApply}>
Apply time range
</Button>
</div>
<Field label="From" invalid={!from.validation.isValid} error={from.validation.errorMessage}>
<Input
onClick={(event) => event.stopPropagation()}
onBlur={() => setFrom({ ...from, validation: isRangeValid(from.value) })}
onChange={(event) => setFrom({ ...from, value: event.currentTarget.value })}
value={from.value}
/>
</Field>
<Field label="To" invalid={!to.validation.isValid} error={to.validation.errorMessage}>
<Input
onClick={(event) => event.stopPropagation()}
onBlur={() => setTo({ ...to, validation: isRangeValid(to.value) })}
onChange={(event) => setTo({ ...to, value: event.currentTarget.value })}
value={to.value}
/>
</Field>
<Button aria-label="TimePicker submit button" onClick={onApply}>
Apply time range
</Button>
</div>
</div>
</div>
</ClickOutsideWrapper>
</FocusScope>
</Portal>
)}
</div>
@ -211,6 +222,14 @@ const getStyles = (fromError?: string, toError?: string) => (theme: GrafanaTheme
const bodyHeight = bodyMinimumHeight + calculateErrorHeight(theme, fromError) + calculateErrorHeight(theme, toError);
return {
backdrop: css`
position: fixed;
z-index: ${theme.zIndex.modalBackdrop};
top: 0;
right: 0;
bottom: 0;
left: 0;
`,
container: css`
display: flex;
position: relative;
@ -246,7 +265,7 @@ const getStyles = (fromError?: string, toError?: string) => (theme: GrafanaTheme
background: ${theme.colors.background.primary};
box-shadow: ${theme.shadows.z3};
position: absolute;
z-index: ${theme.zIndex.dropdown};
z-index: ${theme.zIndex.modal};
width: 500px;
top: 100%;
border-radius: 2px;