Dashboard: New Datasource picker link is keyboard accessible (#72134)

* WIP

* fixes for readability

* fix

* WIP

* Keep tab index working with portal

* Use callback and clean up

* Fix linting errors

* Ignore clickable element

---------

Co-authored-by: Ivan Ortega <ivanortegaalba@gmail.com>
This commit is contained in:
Polina Boneva 2023-07-26 16:02:04 +03:00 committed by GitHub
parent 07365f2a73
commit cae68b955b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

View File

@ -1,5 +1,6 @@
import { css } from '@emotion/css';
import { useDialog } from '@react-aria/dialog';
import { FocusScope } from '@react-aria/focus';
import { useOverlay } from '@react-aria/overlays';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { usePopper } from 'react-popper';
@ -70,55 +71,25 @@ export function DataSourceDropdown(props: DataSourceDropdownProps) {
...restProps
} = props;
const styles = useStyles2((theme: GrafanaTheme2) => getStylesDropdown(theme, props));
const [isOpen, setOpen] = useState(false);
const [inputHasFocus, setInputHasFocus] = useState(false);
const [markerElement, setMarkerElement] = useState<HTMLInputElement | null>();
const [selectorElement, setSelectorElement] = useState<HTMLDivElement | null>();
const [filterTerm, setFilterTerm] = useState<string>('');
const openDropdown = () => {
reportInteraction(INTERACTION_EVENT_NAME, { item: INTERACTION_ITEM.OPEN_DROPDOWN });
setOpen(true);
markerElement?.focus();
};
const { onKeyDown, keyboardEvents } = useKeyNavigationListener();
const ref = useRef<HTMLDivElement>(null);
// Used to position the popper correctly and to bring back the focus when navigating from footer to input
const [markerElement, setMarkerElement] = useState<HTMLInputElement | null>();
// Used to position the popper correctly
const [selectorElement, setSelectorElement] = useState<HTMLDivElement | null>();
// Used to move the focus to the footer when tabbing from the input
const [footerRef, setFooterRef] = useState<HTMLElement | null>();
const currentDataSourceInstanceSettings = useDatasource(current);
const grafanaDS = useDatasource('-- Grafana --');
const currentValue = Boolean(!current && noDefault) ? undefined : currentDataSourceInstanceSettings;
const prefixIcon =
filterTerm && isOpen ? <DataSourceLogoPlaceHolder /> : <DataSourceLogo dataSource={currentValue} />;
const { onKeyDown, keyboardEvents } = useKeyNavigationListener();
useEffect(() => {
const sub = keyboardEvents.subscribe({
next: (keyEvent) => {
switch (keyEvent?.code) {
case 'ArrowDown': {
openDropdown();
keyEvent.preventDefault();
break;
}
case 'ArrowUp':
openDropdown();
keyEvent.preventDefault();
break;
case 'Escape':
onClose();
markerElement?.focus();
keyEvent.preventDefault();
}
},
});
return () => sub.unsubscribe();
});
const grafanaDS = useDatasource('-- Grafana --');
const onClickAddCSV = () => {
if (!grafanaDS) {
return;
}
onChange(grafanaDS, [defaultFileUploadQuery]);
};
const popper = usePopper(markerElement, selectorElement, {
placement: 'bottom-start',
modifiers: [
@ -136,9 +107,9 @@ export function DataSourceDropdown(props: DataSourceDropdownProps) {
const onClose = useCallback(() => {
setFilterTerm('');
setOpen(false);
}, [setOpen]);
markerElement?.focus();
}, [setOpen, markerElement]);
const ref = useRef<HTMLDivElement>(null);
const { overlayProps, underlayProps } = useOverlay(
{
onClose: onClose,
@ -150,9 +121,72 @@ export function DataSourceDropdown(props: DataSourceDropdownProps) {
},
ref
);
const { dialogProps } = useDialog({}, ref);
const { dialogProps } = useDialog(
{
'aria-label': 'Opened data source picker list',
},
ref
);
const styles = useStyles2((theme: GrafanaTheme2) => getStylesDropdown(theme, props));
function openDropdown() {
reportInteraction(INTERACTION_EVENT_NAME, { item: INTERACTION_ITEM.OPEN_DROPDOWN });
setOpen(true);
markerElement?.focus();
}
function onClickAddCSV() {
if (!grafanaDS) {
return;
}
onChange(grafanaDS, [defaultFileUploadQuery]);
}
function onKeyDownInput(keyEvent: React.KeyboardEvent<HTMLInputElement>) {
// From the input, it navigates to the footer
if (keyEvent.key === 'Tab' && !keyEvent.shiftKey && isOpen) {
keyEvent.preventDefault();
footerRef?.focus();
}
// From the input, if we navigate back, it closes the dropdown
if (keyEvent.key === 'Tab' && keyEvent.shiftKey && isOpen) {
onClose();
}
onKeyDown(keyEvent);
}
function onNavigateOutsiteFooter(e: React.KeyboardEvent<HTMLButtonElement>) {
// When navigating back, the dropdown keeps open and the input element is focused.
if (e.shiftKey) {
e.preventDefault();
markerElement?.focus();
// When navigating forward, the dropdown closes and and the element next to the input element is focused.
} else {
onClose();
}
}
useEffect(() => {
const sub = keyboardEvents.subscribe({
next: (keyEvent) => {
switch (keyEvent?.code) {
case 'ArrowDown':
openDropdown();
keyEvent.preventDefault();
break;
case 'ArrowUp':
openDropdown();
keyEvent.preventDefault();
break;
case 'Escape':
onClose();
keyEvent.preventDefault();
break;
}
},
});
return () => sub.unsubscribe();
});
return (
<div className={styles.container} data-testid={selectors.components.DataSourcePicker.container}>
@ -166,15 +200,13 @@ export function DataSourceDropdown(props: DataSourceDropdownProps) {
prefix={currentValue ? prefixIcon : undefined}
suffix={<Icon name={isOpen ? 'search' : 'angle-down'} />}
placeholder={hideTextValue ? '' : dataSourceLabel(currentValue) || placeholder}
onClick={openDropdown}
onFocus={() => {
setInputHasFocus(true);
}}
onBlur={() => {
setInputHasFocus(false);
onClose();
}}
onKeyDown={onKeyDown}
onKeyDown={onKeyDownInput}
value={filterTerm}
onChange={(e) => {
openDropdown();
@ -187,19 +219,16 @@ export function DataSourceDropdown(props: DataSourceDropdownProps) {
{isOpen ? (
<Portal>
<div {...underlayProps} />
{/* TODO: fix keyboard a11y */}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
ref={ref}
{...overlayProps}
{...dialogProps}
onMouseDown={(e) => {
e.preventDefault(); /** Need to prevent default here to stop onMouseDown to trigger onBlur of the input element */
}}
>
<div ref={ref} {...overlayProps} {...dialogProps}>
<PickerContent
keyboardEvents={keyboardEvents}
{...restProps}
{...popper.attributes.popper}
style={popper.styles.popper}
ref={setSelectorElement}
footerRef={setFooterRef}
current={currentValue}
filterTerm={filterTerm}
keyboardEvents={keyboardEvents}
onChange={(ds: DataSourceInstanceSettings, defaultQueries?: DataQuery[] | GrafanaQuery[]) => {
onClose();
if (ds.uid !== currentValue?.uid) {
@ -208,13 +237,9 @@ export function DataSourceDropdown(props: DataSourceDropdownProps) {
}
}}
onClose={onClose}
current={currentValue}
style={popper.styles.popper}
ref={setSelectorElement}
onClickAddCSV={onClickAddCSV}
{...restProps}
onDismiss={onClose}
{...popper.attributes.popper}
onNavigateOutsiteFooter={onNavigateOutsiteFooter}
/>
</div>
</Portal>
@ -249,10 +274,13 @@ export interface PickerContentProps extends DataSourceDropdownProps {
filterTerm?: string;
onClose: () => void;
onDismiss: () => void;
footerRef: (element: HTMLElement | null) => void;
onNavigateOutsiteFooter: (e: React.KeyboardEvent<HTMLButtonElement>) => void;
}
const PickerContent = React.forwardRef<HTMLDivElement, PickerContentProps>((props, ref) => {
const { filterTerm, onChange, onClose, onClickAddCSV, current, filter, uploadFile } = props;
const { filterTerm, onChange, onClose, onClickAddCSV, current, filter } = props;
const changeCallback = useCallback(
(ds: DataSourceInstanceSettings) => {
onChange(ds);
@ -285,50 +313,14 @@ const PickerContent = React.forwardRef<HTMLDivElement, PickerContentProps>((prop
}
></DataSourceList>
</CustomScrollbar>
<div className={styles.footer}>
<ModalsController>
{({ showModal, hideModal }) => (
<Button
size="sm"
variant="secondary"
fill="text"
onClick={() => {
onClose();
showModal(DataSourceModal, {
reportedInteractionFrom: 'ds_picker',
tracing: props.tracing,
dashboard: props.dashboard,
mixed: props.mixed,
metrics: props.metrics,
type: props.type,
annotations: props.annotations,
variables: props.variables,
alerting: props.alerting,
pluginId: props.pluginId,
logs: props.logs,
filter: props.filter,
uploadFile: props.uploadFile,
current: props.current,
onDismiss: hideModal,
onChange: (ds, defaultQueries) => {
onChange(ds, defaultQueries);
hideModal();
},
});
reportInteraction(INTERACTION_EVENT_NAME, { item: INTERACTION_ITEM.OPEN_ADVANCED_DS_PICKER });
}}
>
Open advanced data source picker
<Icon name="arrow-right" />
</Button>
)}
</ModalsController>
{uploadFile && config.featureToggles.editPanelCSVDragAndDrop && (
<Button variant="secondary" size="sm" onClick={clickAddCSVCallback}>
Add csv or spreadsheet
</Button>
)}
</div>
<FocusScope>
<Footer
{...props}
onClickAddCSV={clickAddCSVCallback}
onChange={changeCallback}
onNavigateOutsiteFooter={props.onNavigateOutsiteFooter}
/>
</FocusScope>
</div>
);
});
@ -359,3 +351,84 @@ function getStylesPickerContent(theme: GrafanaTheme2) {
`,
};
}
export interface FooterProps extends PickerContentProps {}
function Footer({ onClose, onChange, onClickAddCSV, ...props }: FooterProps) {
const styles = useStyles2(getStylesFooter);
const isUploadFileEnabled = props.uploadFile && config.featureToggles.editPanelCSVDragAndDrop;
const onKeyDownLastButton = (e: React.KeyboardEvent<HTMLButtonElement>) => {
if (e.key === 'Tab') {
props.onNavigateOutsiteFooter(e);
}
};
const onKeyDownFirstButton = (e: React.KeyboardEvent<HTMLButtonElement>) => {
if (e.key === 'Tab' && e.shiftKey) {
props.onNavigateOutsiteFooter(e);
}
};
return (
<div className={styles.footer}>
<ModalsController>
{({ showModal, hideModal }) => (
<Button
size="sm"
variant="secondary"
fill="text"
onClick={() => {
onClose();
showModal(DataSourceModal, {
reportedInteractionFrom: 'ds_picker',
tracing: props.tracing,
dashboard: props.dashboard,
mixed: props.mixed,
metrics: props.metrics,
type: props.type,
annotations: props.annotations,
variables: props.variables,
alerting: props.alerting,
pluginId: props.pluginId,
logs: props.logs,
filter: props.filter,
uploadFile: props.uploadFile,
current: props.current,
onDismiss: hideModal,
onChange: (ds, defaultQueries) => {
onChange(ds, defaultQueries);
hideModal();
},
});
reportInteraction(INTERACTION_EVENT_NAME, { item: INTERACTION_ITEM.OPEN_ADVANCED_DS_PICKER });
}}
ref={props.footerRef}
onKeyDown={isUploadFileEnabled ? onKeyDownFirstButton : onKeyDownLastButton}
>
Open advanced data source picker
<Icon name="arrow-right" />
</Button>
)}
</ModalsController>
{isUploadFileEnabled && (
<Button variant="secondary" size="sm" onClick={onClickAddCSV} onKeyDown={onKeyDownLastButton}>
Add csv or spreadsheet
</Button>
)}
</div>
);
}
function getStylesFooter(theme: GrafanaTheme2) {
return {
footer: css`
flex: 0;
display: flex;
flex-direction: row-reverse;
justify-content: space-between;
padding: ${theme.spacing(1.5)};
border-top: 1px solid ${theme.colors.border.weak};
background-color: ${theme.colors.background.secondary};
`,
};
}