Merge pull request #14433 from grafana/14274-develop-viz-keynav

14274 develop - VizPicker keyboard navigation
This commit is contained in:
Torkel Ödegaard 2018-12-11 09:14:26 +01:00 committed by GitHub
commit 479869085c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 276 additions and 108 deletions

View File

@ -1,97 +1,124 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import _ from 'lodash'; import _ from 'lodash';
import withKeyboardNavigation from './withKeyboardNavigation';
import { DataSourceSelectItem } from 'app/types'; import { DataSourceSelectItem } from 'app/types';
interface Props { export interface Props {
onChangeDataSource: (ds: any) => void; onChangeDataSource: (ds: any) => void;
datasources: DataSourceSelectItem[]; datasources: DataSourceSelectItem[];
selected?: number;
onKeyDown?: (evt: any, maxSelectedIndex: number, onEnterAction: () => void) => void;
onMouseEnter?: (select: number) => void;
} }
interface State { interface State {
searchQuery: string; searchQuery: string;
} }
export class DataSourcePicker extends PureComponent<Props, State> { export const DataSourcePicker = withKeyboardNavigation(
searchInput: HTMLElement; class DataSourcePicker extends PureComponent<Props, State> {
searchInput: HTMLElement;
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
searchQuery: '', searchQuery: '',
}; };
} }
getDataSources() { getDataSources() {
const { searchQuery } = this.state; const { searchQuery } = this.state;
const regex = new RegExp(searchQuery, 'i'); const regex = new RegExp(searchQuery, 'i');
const { datasources } = this.props; const { datasources } = this.props;
const filtered = datasources.filter(item => { const filtered = datasources.filter(item => {
return regex.test(item.name) || regex.test(item.meta.name); return regex.test(item.name) || regex.test(item.meta.name);
}); });
return filtered; return filtered;
} }
renderDataSource = (ds: DataSourceSelectItem, index: number) => { get maxSelectedIndex() {
const { onChangeDataSource } = this.props; const filtered = this.getDataSources();
const onClick = () => onChangeDataSource(ds); return filtered.length - 1;
const cssClass = classNames({ }
'ds-picker-list__item': true,
});
return ( renderDataSource = (ds: DataSourceSelectItem, index: number) => {
<div key={index} className={cssClass} title={ds.name} onClick={onClick}> const { onChangeDataSource, selected, onMouseEnter } = this.props;
<img className="ds-picker-list__img" src={ds.meta.info.logos.small} /> const onClick = () => onChangeDataSource(ds);
<div className="ds-picker-list__name">{ds.name}</div> const isSelected = selected === index;
</div> const cssClass = classNames({
); 'ds-picker-list__item': true,
}; 'ds-picker-list__item--selected': isSelected,
});
componentDidMount() { return (
setTimeout(() => { <div
this.searchInput.focus(); key={index}
}, 300); className={cssClass}
} title={ds.name}
onClick={onClick}
onSearchQueryChange = evt => { onMouseEnter={() => onMouseEnter(index)}
const value = evt.target.value; >
this.setState(prevState => ({ <img className="ds-picker-list__img" src={ds.meta.info.logos.small} />
...prevState, <div className="ds-picker-list__name">{ds.name}</div>
searchQuery: value,
}));
};
renderFilters() {
const { searchQuery } = this.state;
return (
<>
<label className="gf-form--has-input-icon">
<input
type="text"
className="gf-form-input width-13"
placeholder=""
ref={elem => (this.searchInput = elem)}
onChange={this.onSearchQueryChange}
value={searchQuery}
/>
<i className="gf-form-input-icon fa fa-search" />
</label>
</>
);
}
render() {
return (
<>
<div className="cta-form__bar">
{this.renderFilters()}
<div className="gf-form--grow" />
</div> </div>
<div className="ds-picker-list">{this.getDataSources().map(this.renderDataSource)}</div> );
</> };
);
componentDidMount() {
setTimeout(() => {
this.searchInput.focus();
}, 300);
}
onSearchQueryChange = evt => {
const value = evt.target.value;
this.setState(prevState => ({
...prevState,
searchQuery: value,
}));
};
renderFilters() {
const { searchQuery } = this.state;
const { onKeyDown } = this.props;
return (
<>
<label className="gf-form--has-input-icon">
<input
type="text"
className="gf-form-input width-13"
placeholder=""
ref={elem => (this.searchInput = elem)}
onChange={this.onSearchQueryChange}
value={searchQuery}
onKeyDown={evt => {
onKeyDown(evt, this.maxSelectedIndex, () => {
const { onChangeDataSource, selected } = this.props;
const ds = this.getDataSources()[selected];
onChangeDataSource(ds);
});
}}
/>
<i className="gf-form-input-icon fa fa-search" />
</label>
</>
);
}
render() {
return (
<>
<div className="cta-form__bar">
{this.renderFilters()}
<div className="gf-form--grow" />
</div>
<div className="ds-picker-list">{this.getDataSources().map(this.renderDataSource)}</div>
</>
);
}
} }
} );
export default DataSourcePicker;

View File

@ -1,9 +1,9 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import classNames from 'classnames';
import _ from 'lodash'; import _ from 'lodash';
import config from 'app/core/config'; import config from 'app/core/config';
import { PanelPlugin } from 'app/types/plugins'; import { PanelPlugin } from 'app/types/plugins';
import VizTypePickerPlugin from './VizTypePickerPlugin';
interface Props { interface Props {
current: PanelPlugin; current: PanelPlugin;
@ -12,6 +12,7 @@ interface Props {
interface State { interface State {
searchQuery: string; searchQuery: string;
selected: number;
} }
export class VizTypePicker extends PureComponent<Props, State> { export class VizTypePicker extends PureComponent<Props, State> {
@ -23,9 +24,50 @@ export class VizTypePicker extends PureComponent<Props, State> {
this.state = { this.state = {
searchQuery: '', searchQuery: '',
selected: 0,
}; };
} }
get maxSelectedIndex() {
const filteredPluginList = this.getFilteredPluginList();
return filteredPluginList.length - 1;
}
goRight = () => {
const nextIndex = this.state.selected >= this.maxSelectedIndex ? 0 : this.state.selected + 1;
this.setState({
selected: nextIndex,
});
};
goLeft = () => {
const nextIndex = this.state.selected <= 0 ? this.maxSelectedIndex : this.state.selected - 1;
this.setState({
selected: nextIndex,
});
};
onKeyDown = evt => {
if (evt.key === 'ArrowDown') {
evt.preventDefault();
this.goRight();
}
if (evt.key === 'ArrowUp') {
evt.preventDefault();
this.goLeft();
}
if (evt.key === 'Enter') {
const filteredPluginList = this.getFilteredPluginList();
this.props.onTypeChanged(filteredPluginList[this.state.selected]);
}
};
componentDidMount() {
setTimeout(() => {
this.searchInput.focus();
}, 300);
}
getPanelPlugins(filter): PanelPlugin[] { getPanelPlugins(filter): PanelPlugin[] {
const panels = _.chain(config.panels) const panels = _.chain(config.panels)
.filter({ hideFromList: false }) .filter({ hideFromList: false })
@ -36,25 +78,28 @@ export class VizTypePicker extends PureComponent<Props, State> {
return _.sortBy(panels, 'sort'); return _.sortBy(panels, 'sort');
} }
renderVizPlugin = (plugin: PanelPlugin, index: number) => { onMouseEnter = (mouseEnterIndex: number) => {
const cssClass = classNames({ this.setState({
'viz-picker__item': true, selected: mouseEnterIndex,
'viz-picker__item--selected': plugin.id === this.props.current.id,
}); });
return (
<div key={index} className={cssClass} onClick={() => this.props.onTypeChanged(plugin)} title={plugin.name}>
<div className="viz-picker__item-name">{plugin.name}</div>
<img className="viz-picker__item-img" src={plugin.info.logos.small} />
</div>
);
}; };
componentDidMount() { renderVizPlugin = (plugin: PanelPlugin, index: number) => {
setTimeout(() => { const isSelected = this.state.selected === index;
this.searchInput.focus(); const isCurrent = plugin.id === this.props.current.id;
}, 300); return (
} <VizTypePickerPlugin
key={plugin.id}
isSelected={isSelected}
isCurrent={isCurrent}
plugin={plugin}
onMouseEnter={() => {
this.onMouseEnter(index);
}}
onClick={() => this.props.onTypeChanged(plugin)}
/>
);
};
getFilteredPluginList = (): PanelPlugin[] => { getFilteredPluginList = (): PanelPlugin[] => {
const { searchQuery } = this.state; const { searchQuery } = this.state;
@ -73,6 +118,7 @@ export class VizTypePicker extends PureComponent<Props, State> {
this.setState(prevState => ({ this.setState(prevState => ({
...prevState, ...prevState,
searchQuery: value, searchQuery: value,
selected: 0,
})); }));
}; };
@ -86,6 +132,7 @@ export class VizTypePicker extends PureComponent<Props, State> {
placeholder="" placeholder=""
ref={elem => (this.searchInput = elem)} ref={elem => (this.searchInput = elem)}
onChange={this.onSearchQueryChange} onChange={this.onSearchQueryChange}
onKeyDown={this.onKeyDown}
/> />
<i className="gf-form-input-icon fa fa-search" /> <i className="gf-form-input-icon fa fa-search" />
</label> </label>
@ -102,7 +149,6 @@ export class VizTypePicker extends PureComponent<Props, State> {
{this.renderFilters()} {this.renderFilters()}
<div className="gf-form--grow" /> <div className="gf-form--grow" />
</div> </div>
<div className="viz-picker">{filteredPluginList.map(this.renderVizPlugin)}</div> <div className="viz-picker">{filteredPluginList.map(this.renderVizPlugin)}</div>
</> </>
); );

View File

@ -0,0 +1,36 @@
import React from 'react';
import classNames from 'classnames';
import { PanelPlugin } from 'app/types/plugins';
interface Props {
isSelected: boolean;
isCurrent: boolean;
plugin: PanelPlugin;
onClick: () => void;
onMouseEnter: () => void;
}
const VizTypePickerPlugin = React.memo(
({ isSelected, isCurrent, plugin, onClick, onMouseEnter }: Props) => {
const cssClass = classNames({
'viz-picker__item': true,
'viz-picker__item--selected': isSelected,
'viz-picker__item--current': isCurrent,
});
return (
<div className={cssClass} onClick={onClick} title={plugin.name} onMouseEnter={onMouseEnter}>
<div className="viz-picker__item-name">{plugin.name}</div>
<img className="viz-picker__item-img" src={plugin.info.logos.small} />
</div>
);
},
(prevProps, nextProps) => {
if (prevProps.isSelected === nextProps.isSelected && prevProps.isCurrent === nextProps.isCurrent) {
return true;
}
return false;
}
);
export default VizTypePickerPlugin;

View File

@ -0,0 +1,65 @@
import React from 'react';
import { Props } from './DataSourcePicker';
interface State {
selected: number;
}
const withKeyboardNavigation = WrappedComponent => {
return class extends React.Component<Props, State> {
constructor(props) {
super(props);
this.state = {
selected: 0,
};
}
goToNext = (maxSelectedIndex: number) => {
const nextIndex = this.state.selected >= maxSelectedIndex ? 0 : this.state.selected + 1;
this.setState({
selected: nextIndex,
});
};
goToPrev = (maxSelectedIndex: number) => {
const nextIndex = this.state.selected <= 0 ? maxSelectedIndex : this.state.selected - 1;
this.setState({
selected: nextIndex,
});
};
onKeyDown = (evt: KeyboardEvent, maxSelectedIndex: number, onEnterAction: any) => {
if (evt.key === 'ArrowDown') {
evt.preventDefault();
this.goToNext(maxSelectedIndex);
}
if (evt.key === 'ArrowUp') {
evt.preventDefault();
this.goToPrev(maxSelectedIndex);
}
if (evt.key === 'Enter' && onEnterAction) {
onEnterAction();
}
};
onMouseEnter = (mouseEnterIndex: number) => {
this.setState({
selected: mouseEnterIndex,
});
};
render() {
return (
<WrappedComponent
selected={this.state.selected}
onKeyDown={this.onKeyDown}
onMouseEnter={this.onMouseEnter}
{...this.props}
/>
);
}
};
};
export default withKeyboardNavigation;

View File

@ -157,21 +157,15 @@
padding-bottom: 6px; padding-bottom: 6px;
transition: transform 1 ease; transition: transform 1 ease;
&:hover { &--current {
box-shadow: $panel-editor-viz-item-shadow-hover; box-shadow: 0 0 6px $orange;
background: $panel-editor-viz-item-bg-hover; border: 1px solid $orange;
border: $panel-editor-viz-item-border-hover;
} }
&--selected { &--selected {
box-shadow: 0 0 6px $orange; box-shadow: $panel-editor-viz-item-shadow-hover;
border: 1px solid $orange; background: $panel-editor-viz-item-bg-hover;
border: $panel-editor-viz-item-border-hover;
&:hover {
box-shadow: 0 0 6px $orange;
border: 1px solid $orange;
background: $panel-editor-viz-item-bg-hover-active;
}
} }
} }
@ -263,13 +257,13 @@
align-items: center; align-items: center;
height: 44px; height: 44px;
&:hover { &--selected {
background: $panel-editor-viz-item-bg-hover; background: $panel-editor-viz-item-bg-hover;
border: $panel-editor-viz-item-border-hover; border: $panel-editor-viz-item-border-hover;
box-shadow: $panel-editor-viz-item-shadow-hover; box-shadow: $panel-editor-viz-item-shadow-hover;
} }
&--selected { &--active {
box-shadow: 0 0 6px $orange; box-shadow: 0 0 6px $orange;
border: 1px solid $orange; border: 1px solid $orange;