DataSourcePicker: keyboard navigatable list hook and implementation in the new data source picker dropdown (#67370)

Co-authored-by: Ivan Ortega <ivanortegaalba@gmail.com>
This commit is contained in:
Oscar Kilhed 2023-04-28 18:19:31 +02:00 committed by GitHub
parent 166641d66d
commit 836fef6785
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 167 additions and 16 deletions

View File

@ -10,11 +10,16 @@ interface DataSourceCardProps {
selected: boolean;
}
export function DataSourceCard({ ds, onClick, selected }: DataSourceCardProps) {
export function DataSourceCard({ ds, onClick, selected, ...htmlProps }: DataSourceCardProps) {
const styles = useStyles2(getStyles);
return (
<Card key={ds.uid} onClick={onClick} className={cx(styles.card, selected ? styles.selected : undefined)}>
<Card
key={ds.uid}
onClick={onClick}
className={cx(styles.card, selected ? styles.selected : undefined)}
{...htmlProps}
>
<Card.Heading className={styles.heading}>
<div className={styles.headingContent}>
<span className={styles.name}>{ds.name}</span>

View File

@ -1,7 +1,7 @@
import { css } from '@emotion/css';
import { useDialog } from '@react-aria/dialog';
import { useOverlay } from '@react-aria/overlays';
import React, { useCallback, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { usePopper } from 'react-popper';
import { DataSourceInstanceSettings, GrafanaTheme2 } from '@grafana/data';
@ -9,6 +9,7 @@ import { reportInteraction } from '@grafana/runtime';
import { DataSourceJsonData } from '@grafana/schema';
import { Button, Icon, Input, ModalsController, Portal, useStyles2 } from '@grafana/ui';
import config from 'app/core/config';
import { useKeyNavigationListener } from 'app/features/search/hooks/useSearchKeyboardSelection';
import { useDatasource } from '../../hooks';
@ -30,15 +31,41 @@ export function DataSourceDropdown(props: DataSourceDropdownProps) {
const { current, onChange, ...restProps } = 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 [filterTerm, setFilterTerm] = useState<string>('');
const openDropdown = () => {
reportInteraction(INTERACTION_EVENT_NAME, { item: INTERACTION_ITEM.OPEN_DROPDOWN });
setOpen(true);
markerElement?.focus();
};
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 currentDataSourceInstanceSettings = useDatasource(current);
const popper = usePopper(markerElement, selectorElement, {
@ -56,8 +83,7 @@ export function DataSourceDropdown(props: DataSourceDropdownProps) {
const onClose = useCallback(() => {
setFilterTerm('');
setOpen(false);
markerElement?.blur();
}, [setOpen, markerElement]);
}, [setOpen]);
const ref = useRef<HTMLDivElement>(null);
const { overlayProps, underlayProps } = useOverlay(
@ -77,9 +103,11 @@ export function DataSourceDropdown(props: DataSourceDropdownProps) {
return (
<div className={styles.container}>
<div tabIndex={0} onFocus={openDropdown} role={'button'} className={styles.trigger} onClick={openDropdown}>
{/* This clickable div is just extending the clickable area on the input element to include the prefix and suffix. */}
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div className={styles.trigger} onClick={openDropdown}>
<Input
className={isOpen ? undefined : styles.input}
className={inputHasFocus ? undefined : styles.input}
prefix={
filterTerm && isOpen ? (
<DataSourceLogoPlaceHolder />
@ -89,10 +117,18 @@ export function DataSourceDropdown(props: DataSourceDropdownProps) {
}
suffix={<Icon name={isOpen ? 'search' : 'angle-down'} />}
placeholder={dataSourceLabel(currentDataSourceInstanceSettings)}
onFocus={openDropdown}
onClick={openDropdown}
onFocus={() => {
setInputHasFocus(true);
}}
onBlur={() => {
setInputHasFocus(false);
onClose();
}}
onKeyDown={onKeyDown}
value={filterTerm}
onChange={(e) => {
openDropdown();
setFilterTerm(e.currentTarget.value);
}}
ref={setMarkerElement}
@ -101,8 +137,16 @@ export function DataSourceDropdown(props: DataSourceDropdownProps) {
{isOpen ? (
<Portal>
<div {...underlayProps} />
<div ref={ref} {...overlayProps} {...dialogProps}>
<div
ref={ref}
{...overlayProps}
{...dialogProps}
onMouseDown={(e) => {
e.preventDefault(); /** Need to prevent default here to stop onMouseDown to trigger onBlur of the input element */
}}
>
<PickerContent
keyboardEvents={keyboardEvents}
filterTerm={filterTerm}
onChange={(ds: DataSourceInstanceSettings<DataSourceJsonData>) => {
onClose();
@ -164,6 +208,7 @@ const PickerContent = React.forwardRef<HTMLDivElement, PickerContentProps>((prop
<div className={styles.dataSourceList}>
<DataSourceList
{...props}
enableKeyboardNavigation
current={current}
onChange={changeCallback}
filter={(ds) => matchDataSourceWithSearch(ds, filterTerm)}

View File

@ -1,9 +1,12 @@
import React from 'react';
import { css, cx } from '@emotion/css';
import React, { useRef } from 'react';
import { Observable } from 'rxjs';
import { DataSourceInstanceSettings, DataSourceRef } from '@grafana/data';
import { DataSourceInstanceSettings, DataSourceRef, GrafanaTheme2 } from '@grafana/data';
import { getTemplateSrv } from '@grafana/runtime';
import { useTheme2 } from '@grafana/ui';
import { useDatasources, useRecentlyUsedDataSources } from '../../hooks';
import { useDatasources, useKeyboardNavigatableList, useRecentlyUsedDataSources } from '../../hooks';
import { DataSourceCard } from './DataSourceCard';
import { getDataSourceCompareFn, isDataSourceMatch } from './utils';
@ -30,13 +33,25 @@ export interface DataSourceListProps {
/** If true,we show only DSs with logs; and if true, pluginId shouldnt be passed in */
logs?: boolean;
width?: number;
keyboardEvents?: Observable<React.KeyboardEvent>;
inputId?: string;
filter?: (dataSource: DataSourceInstanceSettings) => boolean;
onClear?: () => void;
enableKeyboardNavigation?: boolean;
}
export function DataSourceList(props: DataSourceListProps) {
const { className, current, onChange } = props;
const containerRef = useRef<HTMLDivElement>(null);
const [navigatableProps, selectedItemCssSelector] = useKeyboardNavigatableList({
keyboardEvents: props.keyboardEvents,
containerRef: containerRef,
});
const theme = useTheme2();
const styles = getStyles(theme, selectedItemCssSelector);
const { className, current, onChange, enableKeyboardNavigation } = props;
// QUESTION: Should we use data from the Redux store as admin DS view does?
const dataSources = useDatasources({
alerting: props.alerting,
@ -54,7 +69,7 @@ export function DataSourceList(props: DataSourceListProps) {
const [recentlyUsedDataSources, pushRecentlyUsedDataSource] = useRecentlyUsedDataSources();
return (
<div className={className}>
<div ref={containerRef} className={cx(className, styles.container)}>
{dataSources
.filter((ds) => (props.filter ? props.filter(ds) : true))
.sort(getDataSourceCompareFn(current, recentlyUsedDataSources, getDataSourceVariableIDs()))
@ -67,6 +82,7 @@ export function DataSourceList(props: DataSourceListProps) {
onChange(ds);
}}
selected={!!isDataSourceMatch(ds, current)}
{...(enableKeyboardNavigation ? navigatableProps : {})}
/>
))}
</div>
@ -81,3 +97,13 @@ function getDataSourceVariableIDs() {
.filter((v) => v.type === 'datasource')
.map((v) => `\${${v.id}}`);
}
function getStyles(theme: GrafanaTheme2, selectedItemCssSelector: string) {
return {
container: css`
${selectedItemCssSelector} {
background-color: ${theme.colors.background.secondary};
}
`,
};
}

View File

@ -94,6 +94,7 @@ export function DataSourceModal({
>
<div className={styles.leftColumn}>
<Input
autoFocus
className={styles.searchInput}
value={search}
prefix={<Icon name="search" />}

View File

@ -1,5 +1,6 @@
import React from 'react';
import { DropzoneOptions } from 'react-dropzone';
import { Observable } from 'rxjs';
import { DataSourceInstanceSettings } from '@grafana/data';
import { DataSourceJsonData, DataSourceRef } from '@grafana/schema';
@ -14,6 +15,7 @@ export interface DataSourceDropdownProps {
}
export interface PickerContentProps extends DataSourceDropdownProps {
keyboardEvents: Observable<React.KeyboardEvent>;
style: React.CSSProperties;
filterTerm?: string;
onClose: () => void;

View File

@ -1,5 +1,6 @@
import { useCallback } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { useLocalStorage } from 'react-use';
import { Observable } from 'rxjs';
import { DataSourceInstanceSettings, DataSourceRef } from '@grafana/data';
import { GetDataSourceListFilters, getDataSourceSrv } from '@grafana/runtime';
@ -55,3 +56,74 @@ export function useDatasource(dataSource: string | DataSourceRef | DataSourceIns
return dataSourceSrv.getInstanceSettings(dataSource);
}
export interface KeybaordNavigatableListProps {
keyboardEvents?: Observable<React.KeyboardEvent>;
containerRef: React.RefObject<HTMLElement>;
}
/**
* Allows navigating lists of elements where the data-role attribute is set to "keyboardSelectableItem"
* @param props
*/
export function useKeyboardNavigatableList(props: KeybaordNavigatableListProps): [Record<string, string>, string] {
const { keyboardEvents, containerRef } = props;
const [selectedIndex, setSelectedIndex] = useState<number>(0);
const attributeName = 'data-role';
const roleName = 'keyboardSelectableItem';
const navigatableItemProps = { ...{ [attributeName]: roleName } };
const querySelectorNavigatableElements = `[${attributeName}="${roleName}"`;
const selectedAttributeName = 'data-selectedItem';
const selectedItemCssSelector = `[${selectedAttributeName}="true"]`;
useEffect(() => {
const listItems = containerRef?.current?.querySelectorAll<HTMLElement | HTMLButtonElement | HTMLAnchorElement>(
querySelectorNavigatableElements
);
const selectedItem = listItems?.item(selectedIndex % listItems?.length);
listItems?.forEach((li) => li.setAttribute(selectedAttributeName, 'false'));
if (selectedItem) {
selectedItem.scrollIntoView({ block: 'center' });
selectedItem.setAttribute(selectedAttributeName, 'true');
}
}, [selectedIndex, containerRef, selectedAttributeName, querySelectorNavigatableElements]);
const clickSelectedElement = () => {
containerRef?.current
?.querySelector<HTMLElement | HTMLButtonElement | HTMLAnchorElement>(selectedItemCssSelector)
?.querySelector<HTMLButtonElement>('button') // This is a bit weird. The main use for this would be to select card items, however the root of the card component does not have the click event handler, instead it's attached to a button inside it.
?.click();
};
useEffect(() => {
if (!keyboardEvents) {
return;
}
const sub = keyboardEvents.subscribe({
next: (keyEvent) => {
switch (keyEvent?.code) {
case 'ArrowDown': {
setSelectedIndex(selectedIndex + 1);
keyEvent.preventDefault();
break;
}
case 'ArrowUp':
setSelectedIndex(selectedIndex > 0 ? selectedIndex - 1 : selectedIndex);
keyEvent.preventDefault();
break;
case 'Enter':
clickSelectedElement();
break;
}
},
});
return () => sub.unsubscribe();
});
return [navigatableItemProps, selectedItemCssSelector];
}