mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Template variables: Keyboard navigation improvements (#38001)
* Fix variable labels * Add proper labeling for input * Add ids to PickerRenderer * Fix tests * Update PR feedback * OptionsPicker: Change to id * Inherit aria attributes * Add checkbox role * Fix typo * Add proper label reference * Update role and label * Prevent spreadng non-DOM attributes * Move form layout to other component * Remove haspopup * Add testid to selector * Add HTMLProps extension * Use list * Move styles outside of class * Add cx
This commit is contained in:
parent
28cf93e42c
commit
1f091c448f
@ -47,13 +47,13 @@ export const Pages = {
|
|||||||
},
|
},
|
||||||
SubMenu: {
|
SubMenu: {
|
||||||
submenu: 'Dashboard submenu',
|
submenu: 'Dashboard submenu',
|
||||||
submenuItem: 'Dashboard template variables submenu item',
|
submenuItem: 'data-testid template variable',
|
||||||
submenuItemLabels: (item: string) => `Dashboard template variables submenu Label ${item}`,
|
submenuItemLabels: (item: string) => `data-testid Dashboard template variables submenu Label ${item}`,
|
||||||
submenuItemValueDropDownValueLinkTexts: (item: string) =>
|
submenuItemValueDropDownValueLinkTexts: (item: string) =>
|
||||||
`Dashboard template variables Variable Value DropDown value link text ${item}`,
|
`data-testid Dashboard template variables Variable Value DropDown value link text ${item}`,
|
||||||
submenuItemValueDropDownDropDown: 'Dashboard template variables Variable Value DropDown DropDown',
|
submenuItemValueDropDownDropDown: 'Variable options',
|
||||||
submenuItemValueDropDownOptionTexts: (item: string) =>
|
submenuItemValueDropDownOptionTexts: (item: string) =>
|
||||||
`Dashboard template variables Variable Value DropDown option text ${item}`,
|
`data-testid Dashboard template variables Variable Value DropDown option text ${item}`,
|
||||||
},
|
},
|
||||||
Settings: {
|
Settings: {
|
||||||
General: {
|
General: {
|
||||||
|
@ -44,15 +44,15 @@ export const DashboardLinks: FC<Props> = ({ dashboard, links }) => {
|
|||||||
href={sanitizeUrl(linkInfo.href)}
|
href={sanitizeUrl(linkInfo.href)}
|
||||||
target={link.targetBlank ? '_blank' : undefined}
|
target={link.targetBlank ? '_blank' : undefined}
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
aria-label={selectors.components.DashboardLinks.link}
|
data-testid={selectors.components.DashboardLinks.link}
|
||||||
>
|
>
|
||||||
<Icon name={linkIconMap[link.icon] as IconName} style={{ marginRight: '4px' }} />
|
<Icon aria-hidden name={linkIconMap[link.icon] as IconName} style={{ marginRight: '4px' }} />
|
||||||
<span>{linkInfo.title}</span>
|
<span>{linkInfo.title}</span>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={key} className="gf-form" aria-label={selectors.components.DashboardLinks.container}>
|
<div key={key} className="gf-form" data-testid={selectors.components.DashboardLinks.container}>
|
||||||
{link.tooltip ? <Tooltip content={linkInfo.tooltip}>{linkElement}</Tooltip> : linkElement}
|
{link.tooltip ? <Tooltip content={linkInfo.tooltip}>{linkElement}</Tooltip> : linkElement}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -9,6 +9,7 @@ import { Annotations } from './Annotations';
|
|||||||
import { SubMenuItems } from './SubMenuItems';
|
import { SubMenuItems } from './SubMenuItems';
|
||||||
import { DashboardLink } from '../../state/DashboardModel';
|
import { DashboardLink } from '../../state/DashboardModel';
|
||||||
import { AnnotationQuery } from '@grafana/data';
|
import { AnnotationQuery } from '@grafana/data';
|
||||||
|
import { css } from '@emotion/css';
|
||||||
|
|
||||||
interface OwnProps {
|
interface OwnProps {
|
||||||
dashboard: DashboardModel;
|
dashboard: DashboardModel;
|
||||||
@ -47,7 +48,9 @@ class SubMenuUnConnected extends PureComponent<Props> {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="submenu-controls">
|
<div className="submenu-controls">
|
||||||
<SubMenuItems variables={variables} />
|
<form aria-label="Template variables" className={styles}>
|
||||||
|
<SubMenuItems variables={variables} />
|
||||||
|
</form>
|
||||||
<Annotations
|
<Annotations
|
||||||
annotations={annotations}
|
annotations={annotations}
|
||||||
onAnnotationChanged={this.onAnnotationStateChanged}
|
onAnnotationChanged={this.onAnnotationStateChanged}
|
||||||
@ -67,6 +70,12 @@ const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const styles = css`
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
display: contents;
|
||||||
|
`;
|
||||||
|
|
||||||
export const SubMenu = connect(mapStateToProps)(SubMenuUnConnected);
|
export const SubMenu = connect(mapStateToProps)(SubMenuUnConnected);
|
||||||
|
|
||||||
SubMenu.displayName = 'SubMenu';
|
SubMenu.displayName = 'SubMenu';
|
||||||
|
@ -9,6 +9,7 @@ interface Props {
|
|||||||
|
|
||||||
export const SubMenuItems: FunctionComponent<Props> = ({ variables }) => {
|
export const SubMenuItems: FunctionComponent<Props> = ({ variables }) => {
|
||||||
const [visibleVariables, setVisibleVariables] = useState<VariableModel[]>([]);
|
const [visibleVariables, setVisibleVariables] = useState<VariableModel[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setVisibleVariables(variables.filter((state) => state.hide !== VariableHide.hideVariable));
|
setVisibleVariables(variables.filter((state) => state.hide !== VariableHide.hideVariable));
|
||||||
}, [variables]);
|
}, [variables]);
|
||||||
@ -24,7 +25,7 @@ export const SubMenuItems: FunctionComponent<Props> = ({ variables }) => {
|
|||||||
<div
|
<div
|
||||||
key={variable.id}
|
key={variable.id}
|
||||||
className="submenu-item gf-form-inline"
|
className="submenu-item gf-form-inline"
|
||||||
aria-label={selectors.pages.Dashboard.SubMenu.submenuItem}
|
data-testid={selectors.pages.Dashboard.SubMenu.submenuItem}
|
||||||
>
|
>
|
||||||
<PickerRenderer variable={variable} />
|
<PickerRenderer variable={variable} />
|
||||||
</div>
|
</div>
|
||||||
|
@ -56,11 +56,11 @@ function setupTestContext({ pickerState = {}, variable = {} }: Args = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getSubMenu(text: string) {
|
function getSubMenu(text: string) {
|
||||||
return screen.getByLabelText(selectors.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts(text));
|
return screen.getByTestId(selectors.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts(text));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOption(text: string) {
|
function getOption(text: string) {
|
||||||
return screen.getByLabelText(selectors.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('A'));
|
return screen.getByTestId(selectors.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('A'));
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('OptionPicker', () => {
|
describe('OptionPicker', () => {
|
||||||
|
@ -75,7 +75,15 @@ export const optionPickerFactory = <Model extends VariableWithOptions | Variable
|
|||||||
const linkText = formatVariableLabel(variable);
|
const linkText = formatVariableLabel(variable);
|
||||||
const loading = variable.state === LoadingState.Loading;
|
const loading = variable.state === LoadingState.Loading;
|
||||||
|
|
||||||
return <VariableLink text={linkText} onClick={this.onShowOptions} loading={loading} onCancel={this.onCancel} />;
|
return (
|
||||||
|
<VariableLink
|
||||||
|
id={variable.id}
|
||||||
|
text={linkText}
|
||||||
|
onClick={this.onShowOptions}
|
||||||
|
loading={loading}
|
||||||
|
onCancel={this.onCancel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
onCancel = () => {
|
onCancel = () => {
|
||||||
@ -83,12 +91,16 @@ export const optionPickerFactory = <Model extends VariableWithOptions | Variable
|
|||||||
};
|
};
|
||||||
|
|
||||||
renderOptions(picker: OptionsPickerState) {
|
renderOptions(picker: OptionsPickerState) {
|
||||||
|
const { id } = this.props.variable;
|
||||||
return (
|
return (
|
||||||
<ClickOutsideWrapper onClick={this.onHideOptions}>
|
<ClickOutsideWrapper onClick={this.onHideOptions}>
|
||||||
<VariableInput
|
<VariableInput
|
||||||
|
id={id}
|
||||||
value={picker.queryValue}
|
value={picker.queryValue}
|
||||||
onChange={this.props.filterOrSearchOptions}
|
onChange={this.props.filterOrSearchOptions}
|
||||||
onNavigate={this.props.navigateOptions}
|
onNavigate={this.props.navigateOptions}
|
||||||
|
aria-expanded={true}
|
||||||
|
aria-controls={`options-${id}`}
|
||||||
/>
|
/>
|
||||||
<VariableOptions
|
<VariableOptions
|
||||||
values={picker.options}
|
values={picker.options}
|
||||||
@ -97,6 +109,7 @@ export const optionPickerFactory = <Model extends VariableWithOptions | Variable
|
|||||||
highlightIndex={picker.highlightIndex}
|
highlightIndex={picker.highlightIndex}
|
||||||
multi={picker.multi}
|
multi={picker.multi}
|
||||||
selectedValues={picker.selectedValues}
|
selectedValues={picker.selectedValues}
|
||||||
|
id={`options-${id}`}
|
||||||
/>
|
/>
|
||||||
</ClickOutsideWrapper>
|
</ClickOutsideWrapper>
|
||||||
);
|
);
|
||||||
|
@ -37,7 +37,8 @@ function PickerLabel({ variable }: PropsWithChildren<Props>): ReactElement | nul
|
|||||||
<Tooltip content={variable.description} placement={'bottom'}>
|
<Tooltip content={variable.description} placement={'bottom'}>
|
||||||
<label
|
<label
|
||||||
className="gf-form-label gf-form-label--variable"
|
className="gf-form-label gf-form-label--variable"
|
||||||
aria-label={selectors.pages.Dashboard.SubMenu.submenuItemLabels(labelOrName)}
|
data-testid={selectors.pages.Dashboard.SubMenu.submenuItemLabels(labelOrName)}
|
||||||
|
htmlFor={variable.id}
|
||||||
>
|
>
|
||||||
{labelOrName}
|
{labelOrName}
|
||||||
</label>
|
</label>
|
||||||
@ -48,7 +49,8 @@ function PickerLabel({ variable }: PropsWithChildren<Props>): ReactElement | nul
|
|||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
className="gf-form-label gf-form-label--variable"
|
className="gf-form-label gf-form-label--variable"
|
||||||
aria-label={selectors.pages.Dashboard.SubMenu.submenuItemLabels(labelOrName)}
|
data-testid={selectors.pages.Dashboard.SubMenu.submenuItemLabels(labelOrName)}
|
||||||
|
htmlFor={variable.id}
|
||||||
>
|
>
|
||||||
{labelOrName}
|
{labelOrName}
|
||||||
</label>
|
</label>
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import React, { PureComponent } from 'react';
|
import React, { PureComponent } from 'react';
|
||||||
import { NavigationKey } from '../types';
|
import { NavigationKey } from '../types';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props extends Omit<React.HTMLProps<HTMLInputElement>, 'onChange' | 'value'> {
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
onNavigate: (key: NavigationKey, clearOthers: boolean) => void;
|
onNavigate: (key: NavigationKey, clearOthers: boolean) => void;
|
||||||
value: string | null;
|
value: string | null;
|
||||||
@ -21,8 +21,10 @@ export class VariableInput extends PureComponent<Props> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
const { value, id, onNavigate, ...restProps } = this.props;
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
|
{...restProps}
|
||||||
ref={(instance) => {
|
ref={(instance) => {
|
||||||
if (instance) {
|
if (instance) {
|
||||||
instance.focus();
|
instance.focus();
|
||||||
@ -31,9 +33,10 @@ export class VariableInput extends PureComponent<Props> {
|
|||||||
}}
|
}}
|
||||||
type="text"
|
type="text"
|
||||||
className="gf-form-input"
|
className="gf-form-input"
|
||||||
value={this.props.value ?? ''}
|
value={value ?? ''}
|
||||||
onChange={this.onChange}
|
onChange={this.onChange}
|
||||||
onKeyDown={this.onKeyDown}
|
onKeyDown={this.onKeyDown}
|
||||||
|
placeholder="Enter variable value"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -9,12 +9,16 @@ interface Props {
|
|||||||
text: string;
|
text: string;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
|
/**
|
||||||
|
* htmlFor, needed for the label
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const VariableLink: FC<Props> = ({ loading, onClick: propsOnClick, text, onCancel }) => {
|
export const VariableLink: FC<Props> = ({ loading, onClick: propsOnClick, text, onCancel, id }) => {
|
||||||
const styles = useStyles(getStyles);
|
const styles = useStyles(getStyles);
|
||||||
const onClick = useCallback(
|
const onClick = useCallback(
|
||||||
(event: MouseEvent<HTMLAnchorElement>) => {
|
(event: MouseEvent<HTMLButtonElement>) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
propsOnClick();
|
propsOnClick();
|
||||||
@ -26,8 +30,9 @@ export const VariableLink: FC<Props> = ({ loading, onClick: propsOnClick, text,
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={styles.container}
|
className={styles.container}
|
||||||
aria-label={selectors.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts(`${text}`)}
|
data-testid={selectors.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts(`${text}`)}
|
||||||
title={text}
|
title={text}
|
||||||
|
id={id}
|
||||||
>
|
>
|
||||||
<VariableLinkText text={text} />
|
<VariableLinkText text={text} />
|
||||||
<LoadingIndicator onCancel={onCancel} />
|
<LoadingIndicator onCancel={onCancel} />
|
||||||
@ -36,15 +41,18 @@ export const VariableLink: FC<Props> = ({ loading, onClick: propsOnClick, text,
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={styles.container}
|
className={styles.container}
|
||||||
aria-label={selectors.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts(`${text}`)}
|
data-testid={selectors.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts(`${text}`)}
|
||||||
|
aria-expanded={false}
|
||||||
|
aria-controls={`options-${id}`}
|
||||||
|
id={id}
|
||||||
title={text}
|
title={text}
|
||||||
>
|
>
|
||||||
<VariableLinkText text={text} />
|
<VariableLinkText text={text} />
|
||||||
<Icon name="angle-down" size="sm" />
|
<Icon aria-hidden name="angle-down" size="sm" />
|
||||||
</a>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -3,14 +3,19 @@ import { Tooltip } from '@grafana/ui';
|
|||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
|
|
||||||
import { VariableOption } from '../../types';
|
import { VariableOption } from '../../types';
|
||||||
|
import { css, cx } from '@emotion/css';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props extends React.HTMLProps<HTMLUListElement> {
|
||||||
multi: boolean;
|
multi: boolean;
|
||||||
values: VariableOption[];
|
values: VariableOption[];
|
||||||
selectedValues: VariableOption[];
|
selectedValues: VariableOption[];
|
||||||
highlightIndex: number;
|
highlightIndex: number;
|
||||||
onToggle: (option: VariableOption, clearOthers: boolean) => void;
|
onToggle: (option: VariableOption, clearOthers: boolean) => void;
|
||||||
onToggleAll: () => void;
|
onToggleAll: () => void;
|
||||||
|
/**
|
||||||
|
* Used for aria-controls
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class VariableOptions extends PureComponent<Props> {
|
export class VariableOptions extends PureComponent<Props> {
|
||||||
@ -31,18 +36,20 @@ export class VariableOptions extends PureComponent<Props> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { multi, values } = this.props;
|
// Don't want to pass faulty rest props to the div
|
||||||
|
const { multi, values, highlightIndex, selectedValues, onToggle, onToggleAll, ...restProps } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={`${multi ? 'variable-value-dropdown multi' : 'variable-value-dropdown single'}`}>
|
||||||
className={`${multi ? 'variable-value-dropdown multi' : 'variable-value-dropdown single'}`}
|
|
||||||
aria-label={selectors.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown}
|
|
||||||
>
|
|
||||||
<div className="variable-options-wrapper">
|
<div className="variable-options-wrapper">
|
||||||
<div className="variable-options-column">
|
<ul
|
||||||
|
className={listStyles}
|
||||||
|
aria-label={selectors.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
{this.renderMultiToggle()}
|
{this.renderMultiToggle()}
|
||||||
{values.map((option, index) => this.renderOption(option, index))}
|
{values.map((option, index) => this.renderOption(option, index))}
|
||||||
</div>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -54,12 +61,20 @@ export class VariableOptions extends PureComponent<Props> {
|
|||||||
const highlightClass = index === highlightIndex ? `${selectClass} highlighted` : selectClass;
|
const highlightClass = index === highlightIndex ? `${selectClass} highlighted` : selectClass;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a key={`${option.value}`} className={highlightClass} onClick={this.onToggle(option)}>
|
<li>
|
||||||
<span className="variable-option-icon"></span>
|
<a
|
||||||
<span aria-label={selectors.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts(`${option.text}`)}>
|
key={`${option.value}`}
|
||||||
{option.text}
|
role="checkbox"
|
||||||
</span>
|
aria-checked={option.selected}
|
||||||
</a>
|
className={highlightClass}
|
||||||
|
onClick={this.onToggle(option)}
|
||||||
|
>
|
||||||
|
<span className="variable-option-icon"></span>
|
||||||
|
<span data-testid={selectors.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts(`${option.text}`)}>
|
||||||
|
{option.text}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,7 +93,10 @@ export class VariableOptions extends PureComponent<Props> {
|
|||||||
? 'variable-options-column-header many-selected'
|
? 'variable-options-column-header many-selected'
|
||||||
: 'variable-options-column-header'
|
: 'variable-options-column-header'
|
||||||
}`}
|
}`}
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={selectedValues.length > 1 ? 'mixed' : 'false'}
|
||||||
onClick={this.onToggleAll}
|
onClick={this.onToggleAll}
|
||||||
|
aria-label="Toggle all values"
|
||||||
data-placement="top"
|
data-placement="top"
|
||||||
>
|
>
|
||||||
<span className="variable-option-icon"></span>
|
<span className="variable-option-icon"></span>
|
||||||
@ -88,3 +106,10 @@ export class VariableOptions extends PureComponent<Props> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const listStyles = cx(
|
||||||
|
'variable-options-column',
|
||||||
|
css`
|
||||||
|
list-style-type: none;
|
||||||
|
`
|
||||||
|
);
|
||||||
|
@ -50,5 +50,15 @@ export function TextBoxVariablePicker({ variable, onVariableChange }: Props): Re
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return <Input type="text" value={updatedValue} onChange={onChange} onBlur={onBlur} onKeyDown={onKeyDown} />;
|
return (
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={updatedValue}
|
||||||
|
onChange={onChange}
|
||||||
|
onBlur={onBlur}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
placeholder="Enter variable value"
|
||||||
|
id={variable.id}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user