Suggestions: Add keyboard support (#114517)

* Suggestions: hashes on suggestions, update logic to select first suggestion

* fix types

* Suggestions: New UI style updates

* update some styles

* getting styles just right

* remove grouping when not on flag

* adjust minimum width for sidebar

* CI cleanups

* updates from ad hoc review

* add loading and error states to suggestions

* remove unused import

* update header ui for panel editor

* restore back button to vizpicker

* fix e2e test

* fix e2e

* add i18n update

* use new util for setVisualization operation

* Apply suggestions from code review

Co-authored-by: Torkel Ödegaard <torkel@grafana.com>

* comments from review

* updates from review

* Suggestions: Add keyboard support

* fix selector for PluginVisualization.item

---------

Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
This commit is contained in:
Paul Marbach
2025-12-11 14:13:33 -05:00
committed by GitHub
parent 0c264b7a5f
commit f5b2dde4a1
25 changed files with 132 additions and 57 deletions
-5
View File
@@ -2882,11 +2882,6 @@
"count": 1
}
},
"public/app/features/panel/components/VizTypePicker/PanelTypeCard.tsx": {
"@grafana/no-aria-label-selectors": {
"count": 1
}
},
"public/app/features/panel/panellinks/linkSuppliers.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
@@ -1057,6 +1057,7 @@ export const versionedComponents = {
},
PluginVisualization: {
item: {
'12.4.0': (title: string) => `data-testid Plugin visualization item ${title}`,
[MIN_GRAFANA_VERSION]: (title: string) => `Plugin visualization item ${title}`,
},
current: {
@@ -17,6 +17,10 @@ export interface Options {
* Controls the height of the rows
*/
cellHeight?: ui.TableCellHeight;
/**
* If true, disables all keyboard events in the table. this is used when previewing a table (i.e. suggestions)
*/
disableKeyboardEvents?: boolean;
/**
* Enable pagination on the table
*/
@@ -13,6 +13,7 @@ import * as common from '@grafana/schema';
export const pluginVersion = "12.4.0-pre";
export interface Options extends common.OptionsWithTimezones, common.OptionsWithAnnotations {
disableKeyboardEvents?: boolean;
legend: common.VizLegendOptions;
orientation?: common.VizOrientation;
timeCompare?: common.TimeCompareOptions;
@@ -105,6 +105,7 @@ export function TableNG(props: TableNGProps) {
const {
cellHeight,
data,
disableKeyboardEvents,
disableSanitizeHtml,
enablePagination = false,
enableSharedCrosshair = false,
@@ -819,9 +820,9 @@ export function TableNG(props: TableNGProps) {
}
}}
onCellKeyDown={
hasNestedFrames
hasNestedFrames || disableKeyboardEvents
? (_, event) => {
if (event.isDefaultPrevented()) {
if (disableKeyboardEvents || event.isDefaultPrevented()) {
// skip parent grid keyboard navigation if nested grid handled it
event.preventGridDefault();
}
@@ -138,6 +138,8 @@ export interface BaseTableProps {
enableVirtualization?: boolean;
// for MarkdownCell, this flag disables sanitization of HTML content. Configured via config.ini.
disableSanitizeHtml?: boolean;
// if true, disables all keyboard events in the table. this is used when previewing a table (i.e. suggestions)
disableKeyboardEvents?: boolean;
}
/* ---------------------------- Table cell props ---------------------------- */
@@ -1,6 +1,6 @@
import { css } from '@emotion/css';
import { debounce } from 'lodash';
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useId, useMemo, useState } from 'react';
import { useSessionStorage } from 'react-use';
import { GrafanaTheme2, PanelData } from '@grafana/data';
@@ -8,7 +8,7 @@ import { selectors } from '@grafana/e2e-selectors';
import { t, Trans } from '@grafana/i18n';
import { config, reportInteraction } from '@grafana/runtime';
import { VizPanel } from '@grafana/scenes';
import { Button, FilterInput, ScrollContainer, Stack, Tab, TabContent, TabsBar, useStyles2 } from '@grafana/ui';
import { Button, Field, FilterInput, ScrollContainer, Stack, Tab, TabContent, TabsBar, useStyles2 } from '@grafana/ui';
import { LS_VISUALIZATION_SELECT_TAB_KEY } from 'app/core/constants';
import { VisualizationSelectPaneTab } from 'app/features/dashboard/components/PanelEditor/types';
import { VisualizationSuggestions } from 'app/features/panel/components/VizTypePicker/VisualizationSuggestions';
@@ -44,6 +44,7 @@ const getTabs = (): Array<{ label: string; value: VisualizationSelectPaneTab }>
export function PanelVizTypePicker({ panel, data, onChange, onClose, showBackButton }: Props) {
const styles = useStyles2(getStyles);
const panelModel = useMemo(() => new PanelModelCompatibilityWrapper(panel), [panel]);
const filterId = useId();
/** SEARCH */
const [searchQuery, setSearchQuery] = useState('');
@@ -101,26 +102,36 @@ export function PanelVizTypePicker({ panel, data, onChange, onClose, showBackBut
)}
{listMode === VisualizationSelectPaneTab.Visualizations && (
<Stack gap={1} direction="column">
<Stack direction="row" gap={1}>
{showBackButton && (
<Button
aria-label={t('dashboard-scene.panel-viz-type-picker.title-close', 'Close')}
fill="text"
variant="secondary"
icon="arrow-left"
data-testid={selectors.components.PanelEditor.toggleVizPicker}
onClick={onClose}
>
<Trans i18nKey="dashboard-scene.panel-viz-type-picker.button.close">Back</Trans>
</Button>
)}
<FilterInput
className={styles.filter}
value={searchQuery}
onChange={setSearchQuery}
placeholder={t('dashboard-scene.panel-viz-type-picker.placeholder-search-for', 'Search for...')}
/>
</Stack>
<Field
tabIndex={0}
className={styles.searchField}
noMargin
htmlFor={filterId}
aria-label={t('dashboard-scene.panel-viz-type-picker.placeholder-search-for', 'Search for...')}
>
<Stack direction="row" gap={1}>
{showBackButton && (
<Button
aria-label={t('dashboard-scene.panel-viz-type-picker.title-close', 'Close')}
fill="text"
variant="secondary"
icon="arrow-left"
data-testid={selectors.components.PanelEditor.toggleVizPicker}
onClick={onClose}
>
<Trans i18nKey="dashboard-scene.panel-viz-type-picker.button.close">Back</Trans>
</Button>
)}
<FilterInput
id={filterId}
className={styles.filter}
value={searchQuery}
onChange={setSearchQuery}
placeholder={t('dashboard-scene.panel-viz-type-picker.placeholder-search-for', 'Search for...')}
/>
</Stack>
</Field>
<VizTypePicker
pluginId={panel.state.pluginId}
searchQuery={searchQuery}
@@ -143,9 +154,8 @@ const getStyles = (theme: GrafanaTheme2) => ({
height: '100%',
gap: theme.spacing(2),
}),
searchRow: css({
display: 'flex',
marginBottom: theme.spacing(2),
searchField: css({
marginTop: theme.spacing(0.5), // input glow with the boundary without this
}),
tabs: css({
width: '100%',
@@ -39,7 +39,7 @@ const LibraryPanelCardComponent = ({ libraryPanel, onClick, onDelete, showSecond
title={libraryPanel.name}
description={libraryPanel.description}
plugin={panelPlugin}
onClick={() => onClick?.(libraryPanel)}
onSelect={() => onClick?.(libraryPanel)}
onDelete={showSecondaryActions ? () => setShowDeletionModal(true) : undefined}
>
<FolderLink libraryPanel={libraryPanel} />
@@ -252,7 +252,7 @@ describe('LibraryPanelsSearch', () => {
}
);
const card = () => screen.getByLabelText(/plugin visualization item time series/i);
const card = () => screen.getByTestId(/plugin visualization item time series/i);
expect(screen.queryByText(/you haven\'t created any library panels yet/i)).not.toBeInTheDocument();
expect(card()).toBeInTheDocument();
@@ -293,7 +293,7 @@ describe('LibraryPanelsSearch', () => {
}
);
const card = () => screen.getByLabelText(/plugin visualization item time series/i);
const card = () => screen.getByTestId(/plugin visualization item time series/i);
expect(screen.queryByText(/you haven\'t created any library panels yet/i)).not.toBeInTheDocument();
expect(card()).toBeInTheDocument();
@@ -1,5 +1,4 @@
import { css, cx } from '@emotion/css';
import { MouseEventHandler } from 'react';
import * as React from 'react';
import Skeleton from 'react-loading-skeleton';
@@ -14,11 +13,12 @@ interface Props {
isCurrent: boolean;
plugin: PanelPluginMeta;
title: string;
onClick: MouseEventHandler<HTMLDivElement>;
onSelect: (withModKey?: boolean) => void;
onDelete?: () => void;
disabled?: boolean;
showBadge?: boolean;
description?: string;
tabIndex?: number;
}
const IMAGE_SIZE = 38;
@@ -27,12 +27,13 @@ const PanelTypeCardComponent = ({
isCurrent,
title,
plugin,
onClick,
onSelect,
onDelete,
disabled,
showBadge,
description,
children,
tabIndex = 0,
}: React.PropsWithChildren<Props>) => {
const styles = useStyles2(getStyles);
@@ -44,13 +45,22 @@ const PanelTypeCardComponent = ({
});
return (
// TODO: fix keyboard a11y
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
<div
className={cssClass}
aria-label={selectors.components.PluginVisualization.item(plugin.name)}
data-testid={selectors.components.PluginVisualization.item(plugin.name)}
onClick={isDisabled ? undefined : onClick}
onClick={isDisabled ? undefined : (ev) => onSelect(ev.metaKey || ev.ctrlKey || ev.altKey)}
role="button"
tabIndex={0}
onKeyDown={
isDisabled
? undefined
: (ev) => {
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
onSelect(ev.metaKey || ev.ctrlKey || ev.altKey);
}
}
}
title={
isCurrent ? t('panel.panel-type-card.title-click-to-close', 'Click again to close this section') : plugin.name
}
@@ -34,8 +34,9 @@ export function VisualizationSuggestionCard({
className: cx(className, styles.vizBox),
'data-testid': selectors.components.VisualizationPreview.card(suggestion.name),
style: outerStyles,
tabIndex: -1, // selection is handled by parent container
...restProps,
};
} satisfies HTMLAttributes<HTMLButtonElement> & { 'data-testid': string };
let content: ReactNode;
@@ -147,10 +147,21 @@ export function VisualizationSuggestions({ onChange, data, panel }: Props) {
<div
key={suggestion.hash}
className={styles.cardContainer}
tabIndex={0}
role="button"
aria-pressed={isCardSelected}
onKeyDown={(ev) => {
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
applySuggestion(suggestion, isNewVizSuggestionsEnabled && !isCardSelected);
}
}}
ref={index === 0 ? firstCardRef : undefined}
>
{isCardSelected && (
<Button
// rather than allow direct focus, we handle ketboard events in the card.
tabIndex={-1}
variant="primary"
size={'md'}
className={styles.applySuggestionButton}
@@ -174,7 +185,6 @@ export function VisualizationSuggestions({ onChange, data, panel }: Props) {
suggestion={suggestion}
width={width}
isSelected={isCardSelected}
tabIndex={index}
onClick={() => applySuggestion(suggestion, true)}
/>
</div>
@@ -41,16 +41,16 @@ export function VizTypePicker({ pluginId, searchQuery, onChange, trackSearch }:
return (
<div className={styles.grid}>
{filteredPluginTypes.map((plugin) => (
{filteredPluginTypes.map((plugin, idx) => (
<VizTypePickerPlugin
disabled={false}
key={plugin.id}
isCurrent={plugin.id === pluginId}
plugin={plugin}
onClick={(e) =>
onSelect={(withModKey) =>
onChange({
pluginId: plugin.id,
withModKey: e.metaKey || e.ctrlKey || e.altKey,
withModKey,
})
}
/>
@@ -1,5 +1,3 @@
import { MouseEventHandler } from 'react';
import { PanelPluginMeta } from '@grafana/data';
import { PanelTypeCard } from './PanelTypeCard';
@@ -7,17 +5,17 @@ import { PanelTypeCard } from './PanelTypeCard';
interface Props {
isCurrent: boolean;
plugin: PanelPluginMeta;
onClick: MouseEventHandler<HTMLDivElement>;
onSelect: (withModKey?: boolean) => void;
disabled: boolean;
}
export const VizTypePickerPlugin = ({ isCurrent, plugin, onClick, disabled }: Props) => {
export const VizTypePickerPlugin = ({ isCurrent, plugin, onSelect, disabled }: Props) => {
return (
<PanelTypeCard
title={plugin.name}
plugin={plugin}
description={plugin.info.description}
onClick={onClick}
onSelect={onSelect}
isCurrent={isCurrent}
disabled={disabled}
showBadge={true}
@@ -69,5 +69,14 @@ export const heatmapSuggestionsSupplier: VisualizationSuggestionsSupplier<Option
return;
}
return [{ score: determineScore(dataSummary) }];
return [
{
score: determineScore(dataSummary),
cardOptions: {
previewModifier: (s) => {
s.options!.legend = { show: false };
},
},
},
];
};
+11 -1
View File
@@ -10,7 +10,7 @@ import {
DataFrameType,
} from '@grafana/data';
import { t } from '@grafana/i18n';
import { commonOptionsBuilder, getGraphFieldOptions } from '@grafana/ui';
import { commonOptionsBuilder, getGraphFieldOptions, LegendDisplayMode } from '@grafana/ui';
import { StackingEditor } from '@grafana/ui/internal';
import { HistogramPanel } from './HistogramPanel';
@@ -160,6 +160,16 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(HistogramPanel)
score: ds.hasDataFrameType(DataFrameType.Histogram)
? VisualizationSuggestionScore.Best
: VisualizationSuggestionScore.OK,
cardOptions: {
previewModifier: (s) => {
s.options!.legend = {
calcs: [],
displayMode: LegendDisplayMode.Hidden,
placement: 'bottom',
showLegend: false,
};
},
},
},
];
}
@@ -1,6 +1,6 @@
import { FieldColorModeId, FieldConfigProperty, FieldType, PanelPlugin } from '@grafana/data';
import { t } from '@grafana/i18n';
import { AxisPlacement, VisibilityMode } from '@grafana/schema';
import { AxisPlacement, LegendDisplayMode, VisibilityMode } from '@grafana/schema';
import { commonOptionsBuilder } from '@grafana/ui';
import { StatusHistoryPanel } from './StatusHistoryPanel';
@@ -144,6 +144,12 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(StatusHistoryPanel)
{
cardOptions: {
previewModifier: (s) => {
s.options!.legend = {
displayMode: LegendDisplayMode.Hidden,
placement: 'bottom',
calcs: [],
showLegend: false,
};
s.options!.colWidth = 0.7;
},
},
@@ -89,6 +89,7 @@ export function TablePanel(props: Props) {
structureRev={data.structureRev}
transparent={transparent}
disableSanitizeHtml={disableSanitizeHtml}
disableKeyboardEvents={options.disableKeyboardEvents}
/>
);
@@ -107,7 +108,12 @@ export function TablePanel(props: Props) {
<div className={tableStyles.wrapper}>
{tableElement}
<div className={tableStyles.selectWrapper}>
<Select options={names} value={names[currentIndex]} onChange={(val) => onChangeTableSelection(val, props)} />
<Select
tabIndex={options.disableKeyboardEvents ? -1 : 0}
options={names}
value={names[currentIndex]}
onChange={(val) => onChangeTableSelection(val, props)}
/>
</div>
</div>
);
@@ -43,6 +43,8 @@ composableKinds: PanelCfg: {
frozenColumns?: {
left?: number | *0
}
// If true, disables all keyboard events in the table. this is used when previewing a table (i.e. suggestions)
disableKeyboardEvents?: bool
} @cuetsy(kind="interface")
FieldConfig: {
ui.TableFieldOptions
+4
View File
@@ -15,6 +15,10 @@ export interface Options {
* Controls the height of the rows
*/
cellHeight?: ui.TableCellHeight;
/**
* If true, disables all keyboard events in the table. this is used when previewing a table (i.e. suggestions)
*/
disableKeyboardEvents?: boolean;
/**
* Enable pagination on the table
*/
@@ -21,6 +21,8 @@ export const tableSuggestionsSupplier: VisualizationSuggestionsSupplier<Options,
score: getTableSuggestionScore(dataSummary),
cardOptions: {
previewModifier: (s) => {
s.options!.showHeader = false;
s.options!.disableKeyboardEvents = true;
if (s.fieldConfig && s.fieldConfig.defaults.custom) {
s.fieldConfig.defaults.custom.minWidth = 50;
}
@@ -145,7 +145,7 @@ export const TimeSeriesPanel = ({
{(uplotConfig, alignedFrame) => {
return (
<>
<KeyboardPlugin config={uplotConfig} />
{!options.disableKeyboardEvents && <KeyboardPlugin config={uplotConfig} />}
{cursorSync !== DashboardCursorSync.Off && (
<EventBusPlugin config={uplotConfig} eventBus={eventBus} frame={alignedFrame} />
)}
@@ -31,6 +31,7 @@ composableKinds: PanelCfg: lineage: {
timeCompare?: common.TimeCompareOptions
orientation?: common.VizOrientation
annotations?: common.VizAnnotations
disableKeyboardEvents?: bool
} @cuetsy(kind="interface")
FieldConfig: common.GraphFieldConfig & {} @cuetsy(kind="interface")
+1
View File
@@ -11,6 +11,7 @@
import * as common from '@grafana/schema';
export interface Options extends common.OptionsWithTimezones, common.OptionsWithAnnotations {
disableKeyboardEvents?: boolean;
legend: common.VizLegendOptions;
orientation?: common.VizOrientation;
timeCompare?: common.TimeCompareOptions;
@@ -45,6 +45,7 @@ const withDefaults = (
},
cardOptions: {
previewModifier: (s) => {
s.options!.disableKeyboardEvents = true;
if (s.fieldConfig?.defaults.custom?.drawStyle !== GraphDrawStyle.Bars) {
s.fieldConfig!.defaults.custom!.lineWidth = Math.max(s.fieldConfig!.defaults.custom!.lineWidth ?? 1, 2);
}