Browse Source

feat(ui): dropdown button component (#7308)

* basic structure for storybook component dev

* copy pasta autocomplete

* naive onblur solution, click shield solution probably necessary :((((

* fix bug using pointer-events

* working solution using _.throttle, might consider nesting DropdownMenus

* couple very light positioning styles

* allow component injection into item list

* add chevron

* button styles

* border radius for dropdown

* whoops

* fix some typos in test data

* use styled components for highlighted state

* style input

* gooder padding

* hover states

* allow literally whatever to be put in as a group label

* restructure the code so that groups are completely removed if no items are present

* correct indexing for elements with labels mixed in

* toString function should not return content because it can be html

* close on select

* onSelect and items params

* onSelect can be optional

* add indeces at the end so that filtering doesn't effect arrow key stuff

* add label html example to storybook

* re-add onBlur prop which accidentally got removed

* fix styles

* fix some style hover problems

* remove border radius on menu when opengs

* move dropdownAutoComplete into it's own component

* allow dropdownAutoComplete to manage state and have whatever control it

* allows communication of openNess to children

* pass onBlur event directly to the autocomplete component using the getInputProps object

* seperate dropdown button and dropdown autocomplete inside of storybook

* dropdownButton is a pure function

* autocomplete onblur handles the settimeout stuff so we don't need to worry about it

* remove default option for now

* couple small changes to methods

* don't need to check and see if the menu is open, we can just open it now

* refactor filtering and data transformation to be shorter and easier to read

* revert some stuff that we don't need in this pull

* improved storybook docs

* improved storybook docs

* dropdownAutoComplete tests

* remove accidentally added .jsx from import

* use more common import for input styles (why we have the other i'm not sure)

* keep items grouped for longer (but not too long)

* keep items grouped for longer (but not too long)

* use value as key not index

* more robust propTypes

* a little dash of formatting and one small bugfix for ungrouped inputs
Chris Clark 7 years ago
parent
commit
351a3b18fc

+ 101 - 0
docs-ui/components/dropdownAutoComplete.stories.js

@@ -0,0 +1,101 @@
+import React from 'react';
+import {storiesOf} from '@storybook/react';
+import {withInfo} from '@storybook/addon-info';
+
+import DropdownAutoComplete from 'sentry-ui/dropdownAutoComplete';
+import DropdownButton from 'sentry-ui/dropdownButton';
+
+const items = [
+  {
+    value: 'apple',
+    label: '🍎 Apple',
+  },
+  {
+    value: 'bacon',
+    label: 'πŸ₯“ Bacon',
+  },
+  {
+    value: 'corn',
+    label: '🌽 Corn',
+  },
+];
+
+const groupedItems = [
+  {
+    group: {
+      value: 'countries',
+      label: (
+        <div>
+          Countries{' '}
+          <a style={{float: 'right'}} href="#">
+            + Add
+          </a>
+        </div>
+      ),
+    },
+    items: [
+      {
+        value: 'new zealand',
+        label: <div>πŸ‡¨πŸ‡· New Zealand</div>,
+      },
+      {
+        value: 'australia',
+        label: <div>πŸ‡¦πŸ‡Ί Australia</div>,
+      },
+      {
+        value: 'brazil',
+        label: <div>πŸ‡§πŸ‡· Brazil</div>,
+      },
+    ],
+  },
+  {
+    group: {
+      value: 'foods',
+      label: 'Foods',
+    },
+    items: [
+      {
+        value: 'apple',
+        label: <div>🍎 Apple</div>,
+      },
+      {
+        value: 'bacon',
+        label: <div>πŸ₯“ Bacon</div>,
+      },
+      {
+        value: 'corn',
+        label: <div>🌽 Corn</div>,
+      },
+    ],
+  },
+];
+
+storiesOf('DropdownAutoComplete', module)
+  .add(
+    'ungrouped',
+    withInfo('The item label can be a component or a string')(() => (
+      <DropdownAutoComplete items={items}>
+        {({isOpen, selectedItem}) => (selectedItem ? selectedItem.label : 'Click me!')}
+      </DropdownAutoComplete>
+    ))
+  )
+  .add(
+    'grouped',
+    withInfo('Group labels can receive a component too')(() => (
+      <DropdownAutoComplete items={groupedItems}>
+        {({isOpen, selectedItem}) => (selectedItem ? selectedItem.label : 'Click me!')}
+      </DropdownAutoComplete>
+    ))
+  )
+  .add(
+    'with dropdownButton',
+    withInfo('Use it with dropdownbutton for maximum fun')(() => (
+      <DropdownAutoComplete items={groupedItems}>
+        {({isOpen, selectedItem}) => (
+          <DropdownButton isOpen={isOpen}>
+            {selectedItem ? selectedItem.label : 'Click me!'}
+          </DropdownButton>
+        )}
+      </DropdownAutoComplete>
+    ))
+  );

+ 19 - 0
docs-ui/components/dropdownButton.stories.js

@@ -0,0 +1,19 @@
+import React from 'react';
+import {storiesOf} from '@storybook/react';
+import {withInfo} from '@storybook/addon-info';
+
+import DropdownButton from 'sentry-ui/dropdownButton';
+
+storiesOf('DropdownButton', module)
+  .add(
+    'closed',
+    withInfo('A button meant to be used with some sort of dropdown')(() => (
+      <DropdownButton isOpen={false}>Add Something</DropdownButton>
+    ))
+  )
+  .add(
+    'open',
+    withInfo('A button meant to be used with some sort of dropdown')(() => (
+      <DropdownButton isOpen={true}>Add Something</DropdownButton>
+    ))
+  );

+ 189 - 0
src/sentry/static/sentry/app/components/dropdownAutoComplete.jsx

@@ -0,0 +1,189 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styled from 'react-emotion';
+import _ from 'lodash';
+import AutoComplete from './autoComplete';
+import Input from '../views/settings/components/forms/controls/input';
+
+class DropdownAutoComplete extends React.Component {
+  static propTypes = {
+    items: PropTypes.oneOfType([
+      // flat item array
+      PropTypes.arrayOf(
+        PropTypes.shape({
+          value: PropTypes.string,
+          label: PropTypes.node,
+        })
+      ),
+      // grouped item array
+      PropTypes.arrayOf(
+        PropTypes.shape({
+          value: PropTypes.string,
+          label: PropTypes.node,
+          items: PropTypes.arrayOf(
+            PropTypes.shape({
+              value: PropTypes.string,
+              label: PropTypes.node,
+            })
+          ),
+        })
+      ),
+    ]),
+    isOpen: PropTypes.bool,
+    onSelect: PropTypes.func,
+    children: PropTypes.func,
+  };
+
+  static defaultProps = {
+    isOpen: false,
+  };
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      isOpen: this.props.isOpen,
+      selectedItem: undefined,
+    };
+  }
+
+  toggleMenu = () => this.setState({isOpen: !this.state.isOpen});
+
+  openMenu = e => this.setState({isOpen: true});
+
+  onSelect = selectedItem => {
+    this.setState({selectedItem});
+    if (this.props.onSelect) this.props.onSelect(selectedItem);
+    this.toggleMenu();
+  };
+
+  filterItems = (items, inputValue) =>
+    items.filter(item => {
+      return item.value.toLowerCase().indexOf(inputValue.toLowerCase()) > -1;
+    });
+
+  filterGroupedItems = (groups, inputValue) =>
+    groups
+      .map(group => {
+        return {
+          ...group,
+          items: this.filterItems(group.items, inputValue),
+        };
+      })
+      .filter(group => group.items.length > 0);
+
+  autoCompleteFilter = (items, inputValue) => {
+    let itemCount = 0;
+
+    if (items[0].items) {
+      //if the first item has children, we assume it is a group
+      return _.flatMap(this.filterGroupedItems(items, inputValue), item => {
+        return [
+          {...item.group, groupLabel: true},
+          ...item.items.map(groupedItem => ({...groupedItem, index: itemCount++})),
+        ];
+      });
+    } else {
+      return this.filterItems(items, inputValue).map((item, index) => ({...item, index}));
+    }
+  };
+
+  render() {
+    return (
+      <div style={{position: 'relative', display: 'inline-block'}}>
+        {this.state.isOpen && (
+          <StyledMenu>
+            <AutoComplete itemToString={item => item.searchKey} onSelect={this.onSelect}>
+              {({
+                getRootProps,
+                getInputProps,
+                getMenuProps,
+                getItemProps,
+                inputValue,
+                selectedItem,
+                highlightedIndex,
+                isOpen,
+              }) => {
+                return (
+                  <div {...getRootProps()}>
+                    <StyledInputContainer>
+                      <StyledInput
+                        autoFocus
+                        {...getInputProps({onBlur: this.toggleMenu})}
+                      />
+                    </StyledInputContainer>
+                    <div {...getMenuProps()}>
+                      <div>
+                        {this.autoCompleteFilter(this.props.items, inputValue).map(
+                          (item, index) =>
+                            item.groupLabel ? (
+                              <StyledLabel key={item.value}>{item.label}</StyledLabel>
+                            ) : (
+                              <StyledItem
+                                key={item.value}
+                                highlightedIndex={highlightedIndex}
+                                index={item.index}
+                                {...getItemProps({item, index: item.index})}
+                              >
+                                {item.label}
+                              </StyledItem>
+                            )
+                        )}
+                      </div>
+                    </div>
+                  </div>
+                );
+              }}
+            </AutoComplete>
+          </StyledMenu>
+        )}
+        <div onClick={this.openMenu}>
+          {this.props.children({
+            isOpen: this.state.isOpen,
+            selectedItem: this.state.selectedItem,
+          })}
+        </div>
+      </div>
+    );
+  }
+}
+
+const StyledInput = styled(Input)`
+  height: 1.75em;
+  font-size: 0.75em;
+`;
+
+const StyledItem = styled('div')`
+  background-color: ${p =>
+    p.index == p.highlightedIndex ? p.theme.offWhite : 'transparent'};
+  padding: 0.25em 0.5em;
+  cursor: pointer;
+  &:hover {
+    background-color: ${p => p.theme.offWhite};
+  }
+`;
+
+const StyledInputContainer = styled('div')`
+  padding: 0.75em 0.5em;
+`;
+
+const StyledLabel = styled('div')`
+  padding: 0 0.5em;
+  background-color: ${p => p.theme.offWhite};
+  border: 1px solid ${p => p.theme.borderLight};
+  border-width: 1px 0;
+`;
+
+const StyledMenu = styled('div')`
+  background: #fff;
+  border: 1px solid ${p => p.theme.borderLight};
+  border-radius: 0 ${p => p.theme.borderRadius} ${p => p.theme.borderRadius}
+    ${p => p.theme.borderRadius};
+  position: absolute;
+  top: calc(100% - 1px);
+  left: 0;
+  min-width: 250px;
+  font-size: 0.9em;
+`;
+
+export default DropdownAutoComplete;

+ 37 - 0
src/sentry/static/sentry/app/components/dropdownButton.jsx

@@ -0,0 +1,37 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styled from 'react-emotion';
+import Button from './buttons/button';
+import InlineSvg from './inlineSvg';
+
+const DropdownButton = ({isOpen, children}) => {
+  return (
+    <StyledButton isOpen={isOpen}>
+      <StyledChevronDown />
+      {children}
+    </StyledButton>
+  );
+};
+
+DropdownButton.propTypes = {
+  isOpen: PropTypes.bool,
+};
+
+const StyledChevronDown = styled(props => (
+  <InlineSvg src="icon-chevron-down" {...props} />
+))`
+  margin-right: 0.5em;
+`;
+
+const StyledButton = styled(props => <Button {...props} />)`
+  border-bottom-right-radius: ${p => (p.isOpen ? 0 : p.theme.borderRadius)};
+  border-bottom-left-radius: ${p => (p.isOpen ? 0 : p.theme.borderRadius)};
+  position: relative;
+  z-index; 1;
+  box-shadow: none;
+
+  &, &:hover { border-bottom-color: ${p =>
+    p.isOpen ? 'transparent' : p.theme.borderDark};}
+`;
+
+export default DropdownButton;

+ 12 - 0
src/sentry/static/sentry/app/icons/icon-chevron-down.svg

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="10px" height="5px" viewBox="0 0 10 5" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 48.2 (47327) - http://www.bohemiancoding.com/sketch -->
+    <title>icon-chevron-down</title>
+    <desc>Created with Sketch.</desc>
+    <defs></defs>
+    <g id="Icons" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
+        <g id="icon-chevron-down" stroke="#493E54">
+            <polyline id="Shape" transform="translate(5.000000, 2.715559) rotate(-90.000000) translate(-5.000000, -2.715559) " points="6.715559 6.715559 3.284441 2.71258796 6.71186974 -1.284441"></polyline>
+        </g>
+    </g>
+</svg>

+ 49 - 0
tests/js/spec/components/__snapshots__/dropdownAutoComplete.spec.jsx.snap

@@ -0,0 +1,49 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`DropdownAutoComplete render() renders with a group 1`] = `
+<div
+  style={
+    Object {
+      "display": "inline-block",
+      "position": "relative",
+    }
+  }
+>
+  <StyledMenu>
+    <AutoComplete
+      itemToString={[Function]}
+      onSelect={[Function]}
+    />
+  </StyledMenu>
+  <div
+    onClick={[Function]}
+  >
+    Click Me!
+  </div>
+</div>
+`;
+
+exports[`DropdownAutoComplete render() renders without a group 1`] = `
+<div
+  style={
+    Object {
+      "display": "inline-block",
+      "position": "relative",
+    }
+  }
+>
+  <StyledMenu>
+    <AutoComplete
+      itemToString={[Function]}
+      onSelect={[Function]}
+    />
+  </StyledMenu>
+  <div
+    onClick={[Function]}
+  >
+    Click Me!
+  </div>
+</div>
+`;
+
+exports[`DropdownAutoComplete render() selects 1`] = `[MockFunction]`;

+ 97 - 0
tests/js/spec/components/dropdownAutoComplete.spec.jsx

@@ -0,0 +1,97 @@
+import React from 'react';
+import {mount, shallow} from 'enzyme';
+
+import DropdownAutoComplete from 'app/components/dropdownAutoComplete';
+
+describe('DropdownAutoComplete', function() {
+  describe('render()', function() {
+    it('renders without a group', function() {
+      const wrapper = shallow(
+        <DropdownAutoComplete
+          isOpen={true}
+          items={[
+            {
+              value: 'apple',
+              label: <div>Apple</div>,
+            },
+            {
+              value: 'bacon',
+              label: <div>Bacon</div>,
+            },
+            {
+              value: 'corn',
+              label: <div>Corn</div>,
+            },
+          ]}
+        >
+          {() => 'Click Me!'}
+        </DropdownAutoComplete>
+      );
+      expect(wrapper).toMatchSnapshot();
+    });
+
+    it('renders with a group', function() {
+      const wrapper = shallow(
+        <DropdownAutoComplete
+          isOpen={true}
+          items={[
+            {
+              group: {
+                value: 'countries',
+                label: 'countries',
+              },
+              items: [
+                {
+                  value: 'new zealand',
+                  label: <div>New Zealand</div>,
+                },
+                {
+                  value: 'australia',
+                  label: <div>Australia</div>,
+                },
+              ],
+            },
+          ]}
+        >
+          {() => 'Click Me!'}
+        </DropdownAutoComplete>
+      );
+      expect(wrapper).toMatchSnapshot();
+    });
+
+    it('selects', function() {
+      const mock = jest.fn();
+
+      const wrapper = mount(
+        <DropdownAutoComplete
+          isOpen={true}
+          items={[
+            {
+              group: {
+                value: 'countries',
+                label: 'countries',
+              },
+              items: [
+                {
+                  value: 'new zealand',
+                  label: <div>New Zealand</div>,
+                },
+                {
+                  value: 'australia',
+                  label: <div>Australia</div>,
+                },
+              ],
+            },
+          ]}
+        >
+          {({selectedItem}) => (selectedItem ? selectedItem.label : 'Click me!')}
+        </DropdownAutoComplete>
+      );
+      wrapper
+        .find('[index]')
+        .last()
+        .simulate('click');
+      expect(mock).toMatchSnapshot();
+    });
+  });
+});