From 836fef67856775c59823a3e0ae091246880b6cee Mon Sep 17 00:00:00 2001 From: Oscar Kilhed Date: Fri, 28 Apr 2023 18:19:31 +0200 Subject: [PATCH] DataSourcePicker: keyboard navigatable list hook and implementation in the new data source picker dropdown (#67370) Co-authored-by: Ivan Ortega --- .../components/picker/DataSourceCard.tsx | 9 ++- .../components/picker/DataSourceDropdown.tsx | 61 +++++++++++++-- .../components/picker/DataSourceList.tsx | 36 +++++++-- .../components/picker/DataSourceModal.tsx | 1 + .../datasources/components/picker/types.ts | 2 + public/app/features/datasources/hooks.ts | 74 ++++++++++++++++++- 6 files changed, 167 insertions(+), 16 deletions(-) diff --git a/public/app/features/datasources/components/picker/DataSourceCard.tsx b/public/app/features/datasources/components/picker/DataSourceCard.tsx index b68c08622df..ca21443c054 100644 --- a/public/app/features/datasources/components/picker/DataSourceCard.tsx +++ b/public/app/features/datasources/components/picker/DataSourceCard.tsx @@ -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 ( - +
{ds.name} diff --git a/public/app/features/datasources/components/picker/DataSourceDropdown.tsx b/public/app/features/datasources/components/picker/DataSourceDropdown.tsx index 960c692faa0..560d54dbec3 100644 --- a/public/app/features/datasources/components/picker/DataSourceDropdown.tsx +++ b/public/app/features/datasources/components/picker/DataSourceDropdown.tsx @@ -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(); const [selectorElement, setSelectorElement] = useState(); - const [filterTerm, setFilterTerm] = useState(); + const [filterTerm, setFilterTerm] = useState(''); 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(null); const { overlayProps, underlayProps } = useOverlay( @@ -77,9 +103,11 @@ export function DataSourceDropdown(props: DataSourceDropdownProps) { return (
-
+ {/* 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 */} +
@@ -89,10 +117,18 @@ export function DataSourceDropdown(props: DataSourceDropdownProps) { } suffix={} 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 ? (
-
+
{ + e.preventDefault(); /** Need to prevent default here to stop onMouseDown to trigger onBlur of the input element */ + }} + > ) => { onClose(); @@ -164,6 +208,7 @@ const PickerContent = React.forwardRef((prop
matchDataSourceWithSearch(ds, filterTerm)} diff --git a/public/app/features/datasources/components/picker/DataSourceList.tsx b/public/app/features/datasources/components/picker/DataSourceList.tsx index afefb30f4b8..815336e95bd 100644 --- a/public/app/features/datasources/components/picker/DataSourceList.tsx +++ b/public/app/features/datasources/components/picker/DataSourceList.tsx @@ -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; inputId?: string; filter?: (dataSource: DataSourceInstanceSettings) => boolean; onClear?: () => void; + enableKeyboardNavigation?: boolean; } export function DataSourceList(props: DataSourceListProps) { - const { className, current, onChange } = props; + const containerRef = useRef(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 ( -
+
{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 : {})} /> ))}
@@ -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}; + } + `, + }; +} diff --git a/public/app/features/datasources/components/picker/DataSourceModal.tsx b/public/app/features/datasources/components/picker/DataSourceModal.tsx index 315f8a55aca..a7845f742b4 100644 --- a/public/app/features/datasources/components/picker/DataSourceModal.tsx +++ b/public/app/features/datasources/components/picker/DataSourceModal.tsx @@ -94,6 +94,7 @@ export function DataSourceModal({ >
} diff --git a/public/app/features/datasources/components/picker/types.ts b/public/app/features/datasources/components/picker/types.ts index 4dc7dd36a4e..0358f227b41 100644 --- a/public/app/features/datasources/components/picker/types.ts +++ b/public/app/features/datasources/components/picker/types.ts @@ -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; style: React.CSSProperties; filterTerm?: string; onClose: () => void; diff --git a/public/app/features/datasources/hooks.ts b/public/app/features/datasources/hooks.ts index b0985ed5863..07c700119e8 100644 --- a/public/app/features/datasources/hooks.ts +++ b/public/app/features/datasources/hooks.ts @@ -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; + containerRef: React.RefObject; +} + +/** + * Allows navigating lists of elements where the data-role attribute is set to "keyboardSelectableItem" + * @param props + */ +export function useKeyboardNavigatableList(props: KeybaordNavigatableListProps): [Record, string] { + const { keyboardEvents, containerRef } = props; + const [selectedIndex, setSelectedIndex] = useState(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( + 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(selectedItemCssSelector) + ?.querySelector('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]; +}