diff --git a/public/app/features/search/components/DashboardSearch.tsx b/public/app/features/search/components/DashboardSearch.tsx
index 03bc8058289..27c32f8d012 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 8bb968176e3..710a6ea2769 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 00000000000..6533a6d7ebc
--- /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 5b4c7e24d54..8d691b5d782 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 564ef43e318..125fab5c9e8 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 5ee3c712f5f..4bec2b71242 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,
};