Przeglądaj źródła

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

14274 develop - VizPicker keyboard navigation
Torkel Ödegaard 7 lat temu
rodzic
commit
479869085c

+ 100 - 73
public/app/features/dashboard/dashgrid/DataSourcePicker.tsx

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

+ 62 - 16
public/app/features/dashboard/dashgrid/VizTypePicker.tsx

@@ -1,9 +1,9 @@
 import React, { PureComponent } from 'react';
-import classNames from 'classnames';
 import _ from 'lodash';
 
 import config from 'app/core/config';
 import { PanelPlugin } from 'app/types/plugins';
+import VizTypePickerPlugin from './VizTypePickerPlugin';
 
 interface Props {
   current: PanelPlugin;
@@ -12,6 +12,7 @@ interface Props {
 
 interface State {
   searchQuery: string;
+  selected: number;
 }
 
 export class VizTypePicker extends PureComponent<Props, State> {
@@ -23,9 +24,50 @@ export class VizTypePicker extends PureComponent<Props, State> {
 
     this.state = {
       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[] {
     const panels = _.chain(config.panels)
       .filter({ hideFromList: false })
@@ -36,26 +78,29 @@ export class VizTypePicker extends PureComponent<Props, State> {
     return _.sortBy(panels, 'sort');
   }
 
-  renderVizPlugin = (plugin: PanelPlugin, index: number) => {
-    const cssClass = classNames({
-      'viz-picker__item': true,
-      'viz-picker__item--selected': plugin.id === this.props.current.id,
+  onMouseEnter = (mouseEnterIndex: number) => {
+    this.setState({
+      selected: mouseEnterIndex,
     });
+  };
 
+  renderVizPlugin = (plugin: PanelPlugin, index: number) => {
+    const isSelected = this.state.selected === index;
+    const isCurrent = 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>
+      <VizTypePickerPlugin
+        key={plugin.id}
+        isSelected={isSelected}
+        isCurrent={isCurrent}
+        plugin={plugin}
+        onMouseEnter={() => {
+          this.onMouseEnter(index);
+        }}
+        onClick={() => this.props.onTypeChanged(plugin)}
+      />
     );
   };
 
-  componentDidMount() {
-    setTimeout(() => {
-      this.searchInput.focus();
-    }, 300);
-  }
-
   getFilteredPluginList = (): PanelPlugin[] => {
     const { searchQuery } = this.state;
     const regex = new RegExp(searchQuery, 'i');
@@ -73,6 +118,7 @@ export class VizTypePicker extends PureComponent<Props, State> {
     this.setState(prevState => ({
       ...prevState,
       searchQuery: value,
+      selected: 0,
     }));
   };
 
@@ -86,6 +132,7 @@ export class VizTypePicker extends PureComponent<Props, State> {
             placeholder=""
             ref={elem => (this.searchInput = elem)}
             onChange={this.onSearchQueryChange}
+            onKeyDown={this.onKeyDown}
           />
           <i className="gf-form-input-icon fa fa-search" />
         </label>
@@ -102,7 +149,6 @@ export class VizTypePicker extends PureComponent<Props, State> {
           {this.renderFilters()}
           <div className="gf-form--grow" />
         </div>
-
         <div className="viz-picker">{filteredPluginList.map(this.renderVizPlugin)}</div>
       </>
     );

+ 36 - 0
public/app/features/dashboard/dashgrid/VizTypePickerPlugin.tsx

@@ -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;

+ 65 - 0
public/app/features/dashboard/dashgrid/withKeyboardNavigation.tsx

@@ -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;

+ 8 - 14
public/sass/components/_panel_editor.scss

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