mirror of
https://github.com/grafana/grafana.git
synced 2025-02-11 16:15:42 -06:00
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:
parent
07365f2a73
commit
cae68b955b
@ -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};
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user