mirror of
https://github.com/grafana/grafana.git
synced 2025-02-20 11:48:34 -06:00
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:
parent
166641d66d
commit
836fef6785
@ -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>
|
||||
|
@ -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)}
|
||||
|
@ -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};
|
||||
}
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
@ -94,6 +94,7 @@ export function DataSourceModal({
|
||||
>
|
||||
<div className={styles.leftColumn}>
|
||||
<Input
|
||||
autoFocus
|
||||
className={styles.searchInput}
|
||||
value={search}
|
||||
prefix={<Icon name="search" />}
|
||||
|
@ -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;
|
||||
|
@ -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];
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user