From 402b5ce4c604223b4df18a657411702a89a78807 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Mon, 13 Jun 2022 09:10:19 -0700 Subject: [PATCH] SearchV2: support keyboard navigation (#49650) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Torkel Ödegaard --- .../search/components/DashboardSearch.tsx | 6 ++ .../search/components/ManageDashboardsNew.tsx | 4 + .../hooks/useSearchKeyboardSelection.ts | 100 ++++++++++++++++++ .../page/components/SearchResultsGrid.tsx | 15 ++- .../page/components/SearchResultsTable.tsx | 24 ++++- .../search/page/components/SearchView.tsx | 4 + 6 files changed, 145 insertions(+), 8 deletions(-) create mode 100644 public/app/features/search/hooks/useSearchKeyboardSelection.ts diff --git a/public/app/features/search/components/DashboardSearch.tsx b/public/app/features/search/components/DashboardSearch.tsx index 03bc805828949..27c32f8d012df 100644 --- a/public/app/features/search/components/DashboardSearch.tsx +++ b/public/app/features/search/components/DashboardSearch.tsx @@ -8,6 +8,7 @@ import { CustomScrollbar, IconButton, stylesFactory, useStyles2, useTheme2 } fro import { SEARCH_PANELS_LOCAL_STORAGE_KEY } from '../constants'; import { useDashboardSearch } from '../hooks/useDashboardSearch'; +import { useKeyNavigationListener } from '../hooks/useSearchKeyboardSelection'; import { useSearchQuery } from '../hooks/useSearchQuery'; import { SearchView } from '../page/components/SearchView'; @@ -42,8 +43,11 @@ function DashboardSearchNew({ onCloseSearch }: Props) { e.preventDefault(); setInputValue(e.currentTarget.value); }; + useDebounce(() => onQueryChange(inputValue), 200, [inputValue]); + const { onKeyDown, keyboardEvents } = useKeyNavigationListener(); + return (
@@ -54,6 +58,7 @@ function DashboardSearchNew({ onCloseSearch }: Props) { placeholder={includePanels ? 'Search dashboards and panels by name' : 'Search dashboards by name'} value={inputValue} onChange={onSearchQueryChange} + onKeyDown={onKeyDown} tabIndex={0} spellCheck={false} className={styles.input} @@ -74,6 +79,7 @@ function DashboardSearchNew({ onCloseSearch }: Props) { queryText={query.query} includePanels={includePanels!} setIncludePanels={setIncludePanels} + keyboardEvents={keyboardEvents} />
diff --git a/public/app/features/search/components/ManageDashboardsNew.tsx b/public/app/features/search/components/ManageDashboardsNew.tsx index 8bb968176e3fe..710a6ea2769d2 100644 --- a/public/app/features/search/components/ManageDashboardsNew.tsx +++ b/public/app/features/search/components/ManageDashboardsNew.tsx @@ -9,6 +9,7 @@ import { contextSrv } from 'app/core/services/context_srv'; import { FolderDTO, AccessControlAction } from 'app/types'; import { SEARCH_PANELS_LOCAL_STORAGE_KEY } from '../constants'; +import { useKeyNavigationListener } from '../hooks/useSearchKeyboardSelection'; import { useSearchQuery } from '../hooks/useSearchQuery'; import { SearchView } from '../page/components/SearchView'; @@ -22,6 +23,7 @@ export const ManageDashboardsNew = React.memo(({ folder }: Props) => { const styles = useStyles2(getStyles); // since we don't use "query" from use search... it is not actually loaded from the URL! const { query, onQueryChange } = useSearchQuery({}); + const { onKeyDown, keyboardEvents } = useKeyNavigationListener(); // TODO: we need to refactor DashboardActions to use folder.uid instead const folderId = folder?.id; @@ -50,6 +52,7 @@ export const ManageDashboardsNew = React.memo(({ folder }: Props) => { { hidePseudoFolders={true} includePanels={includePanels!} setIncludePanels={setIncludePanels} + keyboardEvents={keyboardEvents} /> ); diff --git a/public/app/features/search/hooks/useSearchKeyboardSelection.ts b/public/app/features/search/hooks/useSearchKeyboardSelection.ts new file mode 100644 index 0000000000000..6533a6d7ebc5f --- /dev/null +++ b/public/app/features/search/hooks/useSearchKeyboardSelection.ts @@ -0,0 +1,100 @@ +import { useEffect, useRef, useState } from 'react'; +import { Observable, Subject } from 'rxjs'; + +import { Field, locationUtil } from '@grafana/data'; +import { locationService } from '@grafana/runtime'; + +import { QueryResponse } from '../service'; + +export function useKeyNavigationListener() { + const eventsRef = useRef(new Subject()); + return { + keyboardEvents: eventsRef.current, + onKeyDown: (e: React.KeyboardEvent) => { + switch (e.code) { + case 'ArrowDown': + case 'ArrowUp': + case 'ArrowLeft': + case 'ArrowRight': + case 'Enter': + eventsRef.current.next(e); + default: + // ignore + } + }, + }; +} + +interface ItemSelection { + x: number; + y: number; +} + +export function useSearchKeyboardNavigation( + keyboardEvents: Observable, + numColumns: number, + response: QueryResponse +): ItemSelection { + const highlightIndexRef = useRef({ x: 0, y: -1 }); + const [highlightIndex, setHighlightIndex] = useState({ x: 0, y: -1 }); + const urlsRef = useRef(); + + // Clear selection when the search results change + useEffect(() => { + urlsRef.current = response.view.fields.url; + highlightIndexRef.current.x = 0; + highlightIndexRef.current.y = -1; + setHighlightIndex({ ...highlightIndexRef.current }); + }, [response]); + + useEffect(() => { + const sub = keyboardEvents.subscribe({ + next: (keyEvent) => { + switch (keyEvent?.code) { + case 'ArrowDown': { + highlightIndexRef.current.y++; + setHighlightIndex({ ...highlightIndexRef.current }); + break; + } + case 'ArrowUp': + highlightIndexRef.current.y = Math.max(0, highlightIndexRef.current.y - 1); + setHighlightIndex({ ...highlightIndexRef.current }); + break; + case 'ArrowRight': { + if (numColumns > 0) { + highlightIndexRef.current.x = Math.min(numColumns, highlightIndexRef.current.x + 1); + setHighlightIndex({ ...highlightIndexRef.current }); + } + break; + } + case 'ArrowLeft': { + if (numColumns > 0) { + highlightIndexRef.current.x = Math.max(0, highlightIndexRef.current.x - 1); + setHighlightIndex({ ...highlightIndexRef.current }); + } + break; + } + case 'Enter': + if (!urlsRef.current) { + break; + } + const idx = highlightIndexRef.current.x * numColumns + highlightIndexRef.current.y; + if (idx < 0) { + highlightIndexRef.current.x = 0; + highlightIndexRef.current.y = 0; + setHighlightIndex({ ...highlightIndexRef.current }); + break; + } + const url = urlsRef.current.values?.get(idx) as string; + if (url) { + locationService.push(locationUtil.stripBaseFromUrl(url)); + } + } + }, + }); + + return () => sub.unsubscribe(); + }, [keyboardEvents, numColumns]); + + return highlightIndex; +} diff --git a/public/app/features/search/page/components/SearchResultsGrid.tsx b/public/app/features/search/page/components/SearchResultsGrid.tsx index 5b4c7e24d548c..8d691b5d78269 100644 --- a/public/app/features/search/page/components/SearchResultsGrid.tsx +++ b/public/app/features/search/page/components/SearchResultsGrid.tsx @@ -8,6 +8,7 @@ import { config } from '@grafana/runtime'; import { useStyles2 } from '@grafana/ui'; import { SearchCard } from '../../components/SearchCard'; +import { useSearchKeyboardNavigation } from '../../hooks/useSearchKeyboardSelection'; import { DashboardSearchItemType, DashboardSectionItem } from '../../types'; import { SearchResultsProps } from './SearchResultsTable'; @@ -19,7 +20,7 @@ export const SearchResultsGrid = ({ selection, selectionToggle, onTagSelected, - onDatasourceChange, + keyboardEvents, }: SearchResultsProps) => { const styles = useStyles2(getStyles); @@ -37,12 +38,12 @@ export const SearchResultsGrid = ({ }; const itemCount = response.totalRows ?? response.view.length; - const view = response.view; const numColumns = Math.ceil(width / 320); const cellWidth = width / numColumns; const cellHeight = (cellWidth - 64) * 0.75 + 56 + 8; const numRows = Math.ceil(itemCount / numColumns); + const highlightIndex = useSearchKeyboardNavigation(keyboardEvents, numColumns, response); return ( @@ -100,10 +101,15 @@ export const SearchResultsGrid = ({ } } + let className = styles.virtualizedGridItemWrapper; + if (rowIndex === highlightIndex.y && columnIndex === highlightIndex.x) { + className += ' ' + styles.selectedItem; + } + // The wrapper div is needed as the inner SearchItem has margin-bottom spacing // And without this wrapper there is no room for that margin return item ? ( -
  • +
  • ) : null; @@ -126,4 +132,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ list-style: none; } `, + selectedItem: css` + box-shadow: inset 1px 1px 3px 3px ${theme.colors.primary.border}; + `, }); diff --git a/public/app/features/search/page/components/SearchResultsTable.tsx b/public/app/features/search/page/components/SearchResultsTable.tsx index 564ef43e3180d..125fab5c9e826 100644 --- a/public/app/features/search/page/components/SearchResultsTable.tsx +++ b/public/app/features/search/page/components/SearchResultsTable.tsx @@ -1,15 +1,17 @@ /* eslint-disable react/jsx-no-undef */ import { css } from '@emotion/css'; -import React, { useEffect, useMemo, useRef } from 'react'; +import React, { useEffect, useMemo, useRef, useCallback } from 'react'; import { useTable, Column, TableOptions, Cell, useAbsoluteLayout } from 'react-table'; import { FixedSizeList } from 'react-window'; import InfiniteLoader from 'react-window-infinite-loader'; +import { Observable } from 'rxjs'; import { Field, GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '@grafana/ui'; import { TableCell } from '@grafana/ui/src/components/Table/TableCell'; import { getTableStyles } from '@grafana/ui/src/components/Table/styles'; +import { useSearchKeyboardNavigation } from '../../hooks/useSearchKeyboardSelection'; import { QueryResponse } from '../../service'; import { SelectionChecker, SelectionToggle } from '../selection'; @@ -24,6 +26,7 @@ export type SearchResultsProps = { clearSelection: () => void; onTagSelected: (tag: string) => void; onDatasourceChange?: (datasource?: string) => void; + keyboardEvents: Observable; }; export type TableColumn = Column & { @@ -42,17 +45,19 @@ export const SearchResultsTable = React.memo( clearSelection, onTagSelected, onDatasourceChange, + keyboardEvents, }: SearchResultsProps) => { const styles = useStyles2(getStyles); const tableStyles = useStyles2(getTableStyles); - const infiniteLoaderRef = useRef(null); const listRef = useRef(null); + const highlightIndex = useSearchKeyboardNavigation(keyboardEvents, 0, response); const memoizedData = useMemo(() => { if (!response?.view?.dataFrame.fields.length) { return []; } + // as we only use this to fake the length of our data set for react-table we need to make sure we always return an array // filled with values at each index otherwise we'll end up trying to call accessRow for null|undefined value in // https://github.com/tannerlinsley/react-table/blob/7be2fc9d8b5e223fc998af88865ae86a88792fdb/src/hooks/useTable.js#L585 @@ -93,14 +98,19 @@ export const SearchResultsTable = React.memo( const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } = useTable(options, useAbsoluteLayout); - const RenderRow = React.useCallback( + const RenderRow = useCallback( ({ index: rowIndex, style }) => { const row = rows[rowIndex]; prepareRow(row); const url = response.view.fields.url?.values.get(rowIndex); + let className = styles.rowContainer; + if (rowIndex === highlightIndex.y) { + className += ' ' + styles.selectedRow; + } + return ( -
    +
    {row.cells.map((cell: Cell, index: number) => { return ( ); }, - [rows, prepareRow, response.view.fields.url?.values, styles.rowContainer, tableStyles] + [rows, prepareRow, response.view.fields.url?.values, highlightIndex, styles, tableStyles] ); if (!rows.length) { @@ -212,6 +222,10 @@ const getStyles = (theme: GrafanaTheme2) => { height: ${HEADER_HEIGHT}px; align-items: center; `, + selectedRow: css` + background-color: ${rowHoverBg}; + box-shadow: inset 3px 0px ${theme.colors.primary.border}; + `, rowContainer: css` label: row; &:hover { diff --git a/public/app/features/search/page/components/SearchView.tsx b/public/app/features/search/page/components/SearchView.tsx index 5ee3c712f5fc0..4bec2b71242d0 100644 --- a/public/app/features/search/page/components/SearchView.tsx +++ b/public/app/features/search/page/components/SearchView.tsx @@ -2,6 +2,7 @@ import { css } from '@emotion/css'; import React, { useCallback, useMemo, useState } from 'react'; import { useAsync, useDebounce } from 'react-use'; import AutoSizer from 'react-virtualized-auto-sizer'; +import { Observable } from 'rxjs'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2, Spinner, Button } from '@grafana/ui'; @@ -31,6 +32,7 @@ type SearchViewProps = { onQueryTextChange: (newQueryText: string) => void; includePanels: boolean; setIncludePanels: (v: boolean) => void; + keyboardEvents: Observable; }; export const SearchView = ({ @@ -41,6 +43,7 @@ export const SearchView = ({ hidePseudoFolders, includePanels, setIncludePanels, + keyboardEvents, }: SearchViewProps) => { const styles = useStyles2(getStyles); @@ -201,6 +204,7 @@ export const SearchView = ({ width: width, height: height, onTagSelected: onTagAdd, + keyboardEvents, onDatasourceChange: query.datasource ? onDatasourceChange : undefined, };