From 38e20e8cd218cb22d239a23f94b8661433e3607d Mon Sep 17 00:00:00 2001 From: Federico Zivolo Date: Mon, 28 Jan 2019 20:27:51 +0100 Subject: [PATCH] feat: new react-dropdown component --- flow-typed/npm/downshift_v2.x.x.js.flow | 251 +++ package.json | 4 +- packages/react-dropdown/README.md | 31 + packages/react-dropdown/package.json | 37 + packages/react-dropdown/src/Categories.js | 163 ++ packages/react-dropdown/src/HighlightValue.js | 45 + packages/react-dropdown/src/InputCreator.js | 31 + packages/react-dropdown/src/Items.js | 136 ++ packages/react-dropdown/src/List.js | 103 ++ packages/react-dropdown/src/MultiDownshift.js | 139 ++ packages/react-dropdown/src/MultiSelect.md | 127 ++ .../src/__snapshots__/index.test.js.snap | 1619 +++++++++++++++++ packages/react-dropdown/src/dropdownTypes.js | 34 + packages/react-dropdown/src/index.js | 169 ++ packages/react-dropdown/src/index.md | 122 ++ packages/react-dropdown/src/index.test.js | 523 ++++++ packages/react-dropdown/src/utils.js | 125 ++ yarn.lock | 114 +- 18 files changed, 3752 insertions(+), 21 deletions(-) create mode 100644 flow-typed/npm/downshift_v2.x.x.js.flow create mode 100644 packages/react-dropdown/README.md create mode 100644 packages/react-dropdown/package.json create mode 100644 packages/react-dropdown/src/Categories.js create mode 100644 packages/react-dropdown/src/HighlightValue.js create mode 100644 packages/react-dropdown/src/InputCreator.js create mode 100644 packages/react-dropdown/src/Items.js create mode 100644 packages/react-dropdown/src/List.js create mode 100644 packages/react-dropdown/src/MultiDownshift.js create mode 100644 packages/react-dropdown/src/MultiSelect.md create mode 100644 packages/react-dropdown/src/__snapshots__/index.test.js.snap create mode 100644 packages/react-dropdown/src/dropdownTypes.js create mode 100644 packages/react-dropdown/src/index.js create mode 100644 packages/react-dropdown/src/index.md create mode 100644 packages/react-dropdown/src/index.test.js create mode 100644 packages/react-dropdown/src/utils.js diff --git a/flow-typed/npm/downshift_v2.x.x.js.flow b/flow-typed/npm/downshift_v2.x.x.js.flow new file mode 100644 index 00000000..c0af9fe2 --- /dev/null +++ b/flow-typed/npm/downshift_v2.x.x.js.flow @@ -0,0 +1,251 @@ +/** + * Flowtype definitions for index + * Generated by Flowgen from a Typescript Definition + * Flowgen v1.2.0 + * Author: [Joar Wilk](http://twitter.com/joarwilk) + * Repo: http://github.com/joarwilk/flowgen + */ + + // These types have been copied from the Downshift repository + // they aren't high quality and so we need to occasionally patch them when needed + +import React from 'react' + +declare module downshift { + declare type StateChangeTypes = { + unknown: '__autocomplete_unknown__', + mouseUp: '__autocomplete_mouseup__', + itemMouseEnter: '__autocomplete_item_mouseenter__', + keyDownArrowUp: '__autocomplete_keydown_arrow_up__', + keyDownArrowDown: '__autocomplete_keydown_arrow_down__', + keyDownEscape: '__autocomplete_keydown_escape__', + keyDownEnter: '__autocomplete_keydown_enter__', + clickItem: '__autocomplete_click_item__', + blurInput: '__autocomplete_blur_input__', + changeInput: '__autocomplete_change_input__', + keyDownSpaceButton: '__autocomplete_keydown_space_button__', + clickButton: '__autocomplete_click_button__', + blurButton: '__autocomplete_blur_button__', + controlledPropUpdatedSelectedItem: '__autocomplete_controlled_prop_updated_selected_item__', + } + declare type StateChangeValues = + | '__autocomplete_unknown__' + | '__autocomplete_mouseup__' + | '__autocomplete_item_mouseenter__' + | '__autocomplete_keydown_arrow_up__' + | '__autocomplete_keydown_arrow_down__' + | '__autocomplete_keydown_escape__' + | '__autocomplete_keydown_enter__' + | '__autocomplete_click_item__' + | '__autocomplete_blur_input__' + | '__autocomplete_change_input__' + | '__autocomplete_keydown_space_button__' + | '__autocomplete_click_button__' + | '__autocomplete_blur_button__' + | '__autocomplete_controlled_prop_updated_selected_item__' + declare type Callback = () => void + declare export interface DownshiftState { + highlightedIndex: number | null; + inputValue: string | null; + isOpen: boolean; + selectedItem: Item; + } + declare export interface DownshiftProps { + defaultSelectedItem?: Item; + defaultHighlightedIndex?: number | null; + defaultInputValue?: string; + defaultIsOpen?: boolean; + itemToString?: (item: Item) => string; + selectedItemChanged?: (prevItem: Item, item: Item) => boolean; + getA11yStatusMessage?: (options: A11yStatusMessageOptions) => string; + onChange?: ( + selectedItem: Item, + stateAndHelpers: ControllerStateAndHelpers, + ) => void; + onSelect?: ( + selectedItem: Item, + stateAndHelpers: ControllerStateAndHelpers, + ) => void; + onStateChange?: ( + options: StateChangeOptions, + stateAndHelpers: ControllerStateAndHelpers, + ) => void; + onInputValueChange?: ( + inputValue: string, + stateAndHelpers: ControllerStateAndHelpers, + ) => void; + stateReducer?: ( + state: DownshiftState, + changes: StateChangeOptions, + ) => StateChangeOptions; + itemCount?: number; + highlightedIndex?: number; + inputValue?: string; + isOpen?: boolean; + selectedItem?: Item; + children: ChildrenFunction; + id?: string; + environment?: Environment; + onOuterClick?: () => void; + onUserAction?: ( + options: StateChangeOptions, + stateAndHelpers: ControllerStateAndHelpers, + ) => void; + } + declare export interface Environment { + addEventListener: typeof window.addEventListener; + removeEventListener: typeof window.removeEventListener; + document: Document; + } + declare export interface A11yStatusMessageOptions { + highlightedIndex: number | null; + inputValue: string; + isOpen: boolean; + itemToString: (item: Item) => string; + previousResultCount: number; + resultCount: number; + selectedItem: Item; + } + declare export interface StateChangeOptions { + type: StateChangeValues; + highlightedIndex: number; + inputValue: string; + isOpen: boolean; + selectedItem: Item; + } + declare export type StateChangeFunction = ( + state: DownshiftState, + ) => StateChangeOptions + + declare export type GetRootPropsReturn = { + role: 'combobox'; + 'aria-expanded': boolean; + 'aria-haspopup': 'listbox'; + 'aria-owns': string | null; + 'aria-labelledby': string; + } + declare export interface GetRootPropsOptions { + refKey: string; + } + + declare type GetToggleButtonCallbacks = { + onMouseMove: (e: SyntheticEvent) => void; + onMouseDown: (e: SyntheticEvent) => void; + onBlur: (e: SyntheticEvent) => void; + } | { + onPress: (e: SyntheticEvent) => void; // should be react native type + } | {} + declare export type GetToggleButtonReturn = { + type: 'button'; + role: 'button'; + 'aria-label': 'close menu' | 'open menu'; + 'aria-haspopup': true; + 'data-toggle': true; + } & GetInputPropsCallbacks + declare export interface getToggleButtonPropsOptions + extends React.HTMLProps {} + + declare export interface GetLabelPropsReturn { + htmlFor: string; + id: string; + } + declare export interface GetLabelPropsOptions + extends React.HTMLProps {} + + declare export type getMenuPropsReturn = { + role: 'listbox'; + 'aria-labelledby': string | null; + id: string; + } + + declare type GetInputPropsCallbacks = ({ + onKeyDown: (e: SyntheticEvent) => void; + onBlur: (e: SyntheticEvent) => void; + } & ({ + onInput: (e: SyntheticEvent) => void; + } | { + onChangeText: (e: SyntheticEvent) => void; + } | { + onChange: (e: SyntheticEvent) => void; + })) | {} + declare export type GetInputPropsReturn = { + 'aria-autocomplete': 'list'; + 'aria-activedescendant': string | null; + 'aria-controls': string | null; + 'aria-labelledby': string; + autoComplete: 'off'; + value: string; + id: string; + } & GetInputPropsCallbacks; + declare export interface GetInputPropsOptions + extends React.HTMLProps {} + + declare type GetItemPropsCallbacks = { + onMouseMove: (e: SyntheticEvent) => void; + onMouseDown: (e: SyntheticEvent) => void; + } & ({ + onPress: (e: SyntheticEvent) => void; + } | { + onClick: (e: SyntheticEvent) => void; + }) + declare export type GetItemPropsReturn = { + id: string; + role: 'option'; + 'aria-selected': boolean; + } & GetItemPropsCallbacks + declare export type GetItemPropsOptions = { + index?: number, + item: Item, + } + + declare export interface PropGetters { + getRootProps: (options: GetRootPropsOptions & T) => GetRootPropsReturn & T; + getButtonProps: (options?: getToggleButtonPropsOptions & T) => GetToggleButtonReturn & T; + getToggleButtonProps: (options?: getToggleButtonPropsOptions & T) => GetToggleButtonReturn & T; + getLabelProps: (options?: GetLabelPropsOptions & T) => GetLabelPropsReturn & T; + getMenuProps: (options?: T) => getMenuPropsReturn & T; + getInputProps: (options?: GetInputPropsOptions & T) => GetInputPropsReturn & T; + getItemProps: (options: GetItemPropsOptions & T) => GetItemPropsReturn & T; + } + declare export interface Actions { + reset: (otherStateToSet?: {}, cb?: Callback) => void; + openMenu: (cb?: Callback) => void; + closeMenu: (cb?: Callback) => void; + toggleMenu: (otherStateToSet?: {}, cb?: Callback) => void; + selectItem: (item: Item, otherStateToSet?: {}, cb?: Callback) => void; + selectItemAtIndex: ( + index: number, + otherStateToSet?: {}, + cb?: Callback, + ) => void; + selectHighlightedItem: (otherStateToSet?: {}, cb?: Callback) => void; + setHighlightedIndex: ( + index: number, + otherStateToSet?: {}, + cb?: Callback, + ) => void; + clearSelection: (cb?: Callback) => void; + clearItems: () => void; + setItemCount: (count: number) => void; + unsetItemCount: () => void; + setState: ( + stateToSet: StateChangeOptions | StateChangeFunction, + cb?: Callback, + ) => void; + // props + itemToString: (item: Item) => string; + } + declare export type ControllerStateAndHelpers = DownshiftState & + PropGetters & + Actions + declare export type ChildrenFunction = ( + options: ControllerStateAndHelpers, + ) => React.ReactNode + declare export type DownshiftType = Class< + React.Component, DownshiftState>, + > & { + stateChangeTypes: StateChangeTypes, + } + declare var DownshiftComponent: DownshiftType + declare export default DownshiftComponent +} diff --git a/package.json b/package.json index 8d132b1f..9ffe5253 100644 --- a/package.json +++ b/package.json @@ -51,8 +51,8 @@ "@quid/theme": "^1.0.0", "builder-init": "^0.5.1", "customize-cra": "^0.2.7", - "enzyme": "^3.7.0", - "enzyme-adapter-react-16": "^1.7.1", + "enzyme": "^3.8.0", + "enzyme-adapter-react-16": "^1.8.0", "enzyme-to-json": "^3.3.5", "eslint-plugin-flow-header": "^0.2.0", "eslint-plugin-notice": "^0.7.7", diff --git a/packages/react-dropdown/README.md b/packages/react-dropdown/README.md new file mode 100644 index 00000000..5014330d --- /dev/null +++ b/packages/react-dropdown/README.md @@ -0,0 +1,31 @@ +A feature complete "dropdown" component built with React and Downshift. + +It supports most of the possible use cases, included category grouping, two column layout, and more. + +#### Installation + +```bash +npm install --save @quid/react-dropdown + +# or + +yarn add @quid/react-dropdown +``` + +#### Usage + +```jsx static +import Dropdown from '@quid/react-dropdown'; + +const items = [ + { id: 1, label: 'One' }, + { id: 2, label: 'Two' }, + { id: 3, label: 'Three' }, + { id: 4, label: 'Four' }, + { id: 5, label: 'Five' }, +]; + + + {({ getInputProps }) => } +; +``` diff --git a/packages/react-dropdown/package.json b/packages/react-dropdown/package.json new file mode 100644 index 00000000..2a668d2f --- /dev/null +++ b/packages/react-dropdown/package.json @@ -0,0 +1,37 @@ +{ + "name": "@quid/react-dropdown", + "version": "1.0.0", + "description": "React dropdown component with ARIA accessibility and advanced functionalities", + "main": "dist/index.js", + "main:umd": "dist/index.umd.js", + "module": "dist/index.es.js", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/quid/ui-framework.git" + }, + "scripts": { + "start": "microbundle watch", + "prepare": "microbundle build --jsx React.createElement && flow-copy-source --ignore '{__mocks__/*,*.test}.js' src dist", + "test": "cd ../.. && yarn test --testPathPattern packages/react-dropdown" + }, + "devDependencies": { + "flow-copy-source": "^2.0.2", + "microbundle": "^0.8.3", + "react": "^16.0.0" + }, + "peerDependencies": { + "react": "15||16" + }, + "dependencies": { + "@emotion/core": "^10.0.6", + "@emotion/css": "^10.0.6", + "@emotion/styled": "^10.0.6", + "@quid/react-core": "^1.0.0", + "@quid/theme": "^1.0.0", + "color": "^3.1.0", + "downshift": "^3.2.0", + "popper.js": "^1.14.7", + "react-popper": "^1.3.3" + } +} diff --git a/packages/react-dropdown/src/Categories.js b/packages/react-dropdown/src/Categories.js new file mode 100644 index 00000000..82af8f02 --- /dev/null +++ b/packages/react-dropdown/src/Categories.js @@ -0,0 +1,163 @@ +/** + * Copyright (c) Quid, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +// @flow +import * as React from 'react'; +import { withFallback as wf, textStyles } from '@quid/theme'; +import { isItemInCategorySelected } from './utils'; +import styled from '@emotion/styled/macro'; +import css from '@emotion/css/macro'; +import DropdownItems from './Items'; + +import { + type DropdownItem, + type DropdownCategory, + type DropdownSelectedItem, + type GetItemProps, +} from './dropdownTypes.js'; + +type Props = { + items: Array, + categories: Array, + inputValue: ?string, + getItemProps: GetItemProps, + twoColumn?: boolean, + highlightedIndex: ?number, + highlight: boolean, + selectedItems: Array, +}; + +const Categories = styled.div` + display: flex; + flex-direction: row; + &:not(:last-child) { + border-bottom: 1px solid ${wf(props => props.theme.colors.gray2)}; + } +`; + +const Category = styled.main` + display: flex; + flex-direction: ${props => (props.twoColumn ? 'row' : 'column')}; + flex-grow: 1; +`; + +export const Divider = styled.section` + display: flex; + flex-grow: ${props => (props.twoColumn ? '1' : '0')}; + flex-basis: ${props => (props.twoColumn ? '50%' : 'auto')}; + + ${wf( + props => + props.isHighlighted && + props.twoColumn && + css` + background-color: ${props.theme.current === 'light' + ? props.theme.colors.gray1 + : props.theme.colors.gray5}; + ` + )}; + + ${wf( + props => + props.twoColumn && + css` + &:first-of-type { + border-right: 1px solid ${props.theme.colors.gray2}; + } + ` + )}; +`; + +const GroupTitle = styled.h3` + ${textStyles('normal', 'bold')}; + margin: 0; + display: flex; + padding: 5px; +`; + +export default function DropdownCategories({ + items, + categories, + inputValue, + getItemProps, + twoColumn, + highlightedIndex, + highlight, + selectedItems, +}: Props) { + const sortedItems = categories + .map(category => items.filter(item => item.categoryId === category.id)) + .reduce((acc, category) => acc.concat(category), []); + + // Add index to our items + const itemsWithIndex = sortedItems + .sort((a, b) => (String(a.categoryId) < String(b.categoryId) ? -1 : 1)) + .map((item, index) => ({ ...item, index })); + + // put the items inside their categories + const categoriesedItems = categories.reduce((acc, category) => { + // find the items of the current category + const categoryItems = itemsWithIndex.filter( + item => item.categoryId === category.id + ); + + // if no items are available, skip the category + return categoryItems.length > 0 + ? [ + ...acc, + { + ...category, + firstIndex: categoryItems[0].index, + lastIndex: categoryItems.slice(-1)[0].index, + items: categoryItems, + }, + ] + : acc; + }, []); + + return ( +
+ {categoriesedItems.map(category => { + const categoryId = category.id; + const isHighlighted = + highlightedIndex != null && + highlightedIndex >= category.firstIndex && + highlightedIndex <= category.lastIndex; + + const isSelected = isItemInCategorySelected( + selectedItems, + category.items + ); + + return ( + + + + {category.label} + + + + + + + ); + })} +
+ ); +} diff --git a/packages/react-dropdown/src/HighlightValue.js b/packages/react-dropdown/src/HighlightValue.js new file mode 100644 index 00000000..4bdd7cb5 --- /dev/null +++ b/packages/react-dropdown/src/HighlightValue.js @@ -0,0 +1,45 @@ +/** + * Copyright (c) Quid, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +// @flow +import * as React from 'react'; +import { Text } from '@quid/react-core'; +import styled from '@emotion/styled/macro'; +import { textStyles } from '@quid/theme'; +import { splitStringByValue } from './utils'; + +type Props = { + highlight: boolean, + value: string, + valueToHighlight: string, + className: string, +}; + +const HighlightValue = styled( + ({ highlight, value, valueToHighlight, className }: Props) => { + if (highlight && valueToHighlight.length && value.length) { + const splittedText = splitStringByValue(value, valueToHighlight, 1); + return ( + + {splittedText.map((chunk, index) => { + return chunk.highlight ? ( + + {chunk.value} + + ) : ( + chunk.value + ); + })} + + ); + } + return {value}; + } +)` + ${props => textStyles(props.disabled ? 'disabled' : '')}; +`; + +export default HighlightValue; diff --git a/packages/react-dropdown/src/InputCreator.js b/packages/react-dropdown/src/InputCreator.js new file mode 100644 index 00000000..4fab9c85 --- /dev/null +++ b/packages/react-dropdown/src/InputCreator.js @@ -0,0 +1,31 @@ +/** + * Copyright (c) Quid, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +// @flow +import * as React from 'react'; +import { type DropdownSelectedItem } from './dropdownTypes'; +type Props = { + selectedItems: Array, + name: string, + multiselect: boolean, +}; + +export default function InputCreator({ + selectedItems, + name, + multiselect, +}: Props) { + const inputName = name + (multiselect ? '[]' : ''); + return ( + + {selectedItems.map((item, index) => { + return ( + + ); + })} + + ); +} diff --git a/packages/react-dropdown/src/Items.js b/packages/react-dropdown/src/Items.js new file mode 100644 index 00000000..4dd4503f --- /dev/null +++ b/packages/react-dropdown/src/Items.js @@ -0,0 +1,136 @@ +/** + * Copyright (c) Quid, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +// @flow +import * as React from 'react'; +import styled from '@emotion/styled/macro'; +import css from '@emotion/css/macro'; +import { withFallback as wf } from '@quid/theme'; +import HighlightValue from './HighlightValue'; +import { includesId } from './utils'; +import Color from 'color'; +import { + type DropdownItem, + type DropdownSelectedItem, + type GetItemProps, +} from './dropdownTypes.js'; + +type Props = { + categoryId?: number | string, + twoColumn?: boolean, + items: Array<$Shape>, + getItemProps: GetItemProps, + inputValue: ?string, + highlightedIndex: ?number, + highlight: boolean, + selectedItems: Array, +}; + +const Items = styled.ul` + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + flex-grow: 1; +`; + +export const HIGHLIGHTED = (props: { theme: Object }) => + props.theme.current === 'light' + ? props.theme.colors.gray1 + : props.theme.colors.gray5; + +export const SELECTED = (props: { theme: Object }) => + props.theme.current === 'light' + ? props.theme.colors.gray3 + : props.theme.colors.gray1; + +export const Item = styled.li` + display: flex; + flex-direction: row; + cursor: pointer; + padding: 5px; + background-color: ${wf(props => + props.isSelected && props.isHighlighted + ? Color(HIGHLIGHTED(props)) + .mix(Color(SELECTED(props))) + .string() + : props.isHighlighted + ? HIGHLIGHTED(props) + : props.isSelected + ? SELECTED(props) + : 'transparent' + )}; + ${wf( + props => + ((props.isHighlighted && props.theme.current === 'light') || + props.isSelected) && + css` + color: ${props.theme.current === 'light' + ? props.theme.primary + : props.theme.primaryInverse}; + ` + )}; + + ${wf( + props => + props.twoColumn && + props.categoryId && + css` + &:not(:last-child) { + border-bottom: 1px solid ${props.theme.colors.gray2}; + } + ` + )}; +`; + +export default function DropdownItems({ + twoColumn, + items, + getItemProps, + inputValue, + highlightedIndex, + selectedItems, + categoryId, + highlight, + ...props +}: Props) { + return ( + + {items.map((item, index) => { + const itemIndex = item.hasOwnProperty('index') ? item.index : index; + const isHighlighted = itemIndex === highlightedIndex; + const isSelected = includesId(selectedItems, item.id); + const isDisabled = Boolean(item.disabled); + + return ( + + + + ); + })} + + ); +} diff --git a/packages/react-dropdown/src/List.js b/packages/react-dropdown/src/List.js new file mode 100644 index 00000000..bb74252c --- /dev/null +++ b/packages/react-dropdown/src/List.js @@ -0,0 +1,103 @@ +/** + * Copyright (c) Quid, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +// @flow +import * as React from 'react'; +import { withFallback as wf } from '@quid/theme'; +import DropdownItems from './Items'; +import DropdownCategories from './Categories'; +import styled from '@emotion/styled/macro'; +import Color from 'color'; +import { + type DropdownItem, + type DropdownCategory, + type DropdownSelectedItem, + type GetItemProps, +} from './dropdownTypes.js'; + +type Props = { + items: Array, + categories: Array, + inputValue: ?string, + getItemProps: GetItemProps, + useFilter?: boolean, + filterFn: (Array, ?string) => Array, + twoColumn?: boolean, + highlightedIndex: ?number, + selectedItems: Array, + highlight: boolean, +}; + +export const List = styled.div` + z-index: 1; + list-style: none; + background-color: ${wf(props => + props.theme.current === 'light' + ? props.theme.colors.white + : props.theme.colors.gray6 + )}; + color: ${wf(props => props.theme.primary)}; + border: 1px solid ${wf(props => props.theme.colors.gray2)}; + border-radius: 2px; + box-shadow: 0 1px 2px + ${wf(props => + Color(props.theme.colors.black) + .alpha(0.4) + .string() + )}; + min-width: 27.86em; +`; + +const DropdownList: React.ComponentType = React.forwardRef( + ( + { + items, + categories, + inputValue, + getItemProps, + useFilter, + filterFn, + twoColumn, + highlightedIndex, + selectedItems, + highlight, + ...props + }: Props, + ref: React.ElementRef + ) => { + const filteredItems = useFilter ? filterFn(items, inputValue) : items; + if (filteredItems.length) { + return ( + + {categories.length > 0 ? ( + + ) : ( + + )} + + ); + } + return null; + } +); + +export default DropdownList; diff --git a/packages/react-dropdown/src/MultiDownshift.js b/packages/react-dropdown/src/MultiDownshift.js new file mode 100644 index 00000000..92e20333 --- /dev/null +++ b/packages/react-dropdown/src/MultiDownshift.js @@ -0,0 +1,139 @@ +/** + * Copyright (c) Quid, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +// @flow +import Downshift, { + type ControllerStateAndHelpers, + type StateChangeOptions, +} from 'downshift'; +import * as React from 'react'; +import { includesId } from './utils'; +import { type DropdownSelectedItem } from './dropdownTypes.js'; + +export type MultiControllerStateAndHelpers = ControllerStateAndHelpers & { + selectedItems: Array, +}; + +type Props = { + selectedItems: Array, + onSelect?: ( + Array, + MultiControllerStateAndHelpers + ) => void, + onChange?: ( + Array, + MultiControllerStateAndHelpers + ) => void, + initialIsOpen: boolean, + multiselect: boolean, + children: MultiControllerStateAndHelpers => React.Node, + selectedItem?: ?string, +}; + +type State = { + selectedItems: Array, +}; + +class MultiDownshift extends React.Component { + state = { selectedItems: this.props.selectedItems }; + + stateReducer = ( + state: ControllerStateAndHelpers, + changes: StateChangeOptions + ) => { + if (this.props.multiselect) { + switch (changes.type) { + case Downshift.stateChangeTypes.keyDownEnter: + case Downshift.stateChangeTypes.clickItem: + return { + ...changes, + highlightedIndex: state.highlightedIndex, + isOpen: true, + }; + default: + return changes; + } + } else { + return changes; + } + }; + + handleSelection = ( + selectedItem: DropdownSelectedItem, + downshift: ControllerStateAndHelpers + ) => { + const callOnChange = () => { + const { onChange } = this.props; + const { selectedItems } = this.state; + if (onChange) { + onChange(selectedItems, this.getStateAndHelpers(downshift)); + } + }; + + if (this.props.multiselect) { + if (includesId(this.state.selectedItems, selectedItem.id)) { + this.removeItem(selectedItem, callOnChange); + } else { + this.addSelectedItem(selectedItem, callOnChange); + } + } else { + this.replaceItem(selectedItem, callOnChange); + } + }; + + replaceItem = (item: DropdownSelectedItem, cb?: () => void) => { + this.setState(({ selectedItems }) => { + return { + selectedItems: [item], + }; + }, cb); + }; + + removeItem = (item: DropdownSelectedItem, cb?: () => void) => { + this.setState(({ selectedItems }) => { + return { + selectedItems: selectedItems.filter(({ id }) => id !== item.id), + }; + }, cb); + }; + + addSelectedItem = (item: DropdownSelectedItem, cb?: () => void) => { + this.setState( + ({ selectedItems }) => ({ + selectedItems: [...selectedItems, item], + }), + cb + ); + }; + + getStateAndHelpers = ( + downshift: ControllerStateAndHelpers + ): MultiControllerStateAndHelpers => { + const { selectedItems } = this.state; + const { removeItem } = this; + return { + removeItem, + selectedItems, + ...downshift, + }; + }; + + render() { + const { multiselect, children, selectedItem, ...props } = this.props; + return ( + + {downshift => children(this.getStateAndHelpers(downshift))} + + ); + } +} + +export default MultiDownshift; diff --git a/packages/react-dropdown/src/MultiSelect.md b/packages/react-dropdown/src/MultiSelect.md new file mode 100644 index 00000000..84e2c2e9 --- /dev/null +++ b/packages/react-dropdown/src/MultiSelect.md @@ -0,0 +1,127 @@ +Disabled for now, we need the PillList component to demo them. + +### Dropdown with single item list and multiselect option. + +```js +const items = [ + { id: 10, label: 'One' }, + { id: 22, label: 'Two' }, + { id: 33, label: 'Three' }, + { id: 44, label: 'Four' }, + { id: 55, label: 'Four' }, + { id: 66, label: 'Three', disabled: true }, + { id: 77, label: 'Four', disabled: true }, + { id: 88, label: 'Three' }, + { id: 99, label: 'Four' }, + { id: 101, label: 'Three' }, + { id: 111, label: 'Four' }, + { id: 121, label: 'Three' }, + { id: 131, label: 'One' }, +]; + +initialState = { + pills: [], +}; + +const mirrorDropdownState = inputStates => { + setState({ + pills: inputStates, + }); +}; + +const handleDelete = ({ id }) => { + setState(({ pills }) => ({ + pills: pills.filter(pill => id !== pill.id), + })); +}; + + + {({ getInputProps, removeItem, ...props }) => { + return ( +
+ { + removeItem(item); + handleDelete(item); + }} + /> + +
+ ); + }} +
; +``` + +### Dropdown with multi-level item list (two columns) and multiselect. + +```js +const items = [ + { id: 10, categoryId: 'a', label: 'One' }, + { id: 22, categoryId: 'a', label: 'Two', disabled: true }, + { id: 33, categoryId: 'b', label: 'Three' }, + { id: 44, categoryId: 'b', label: 'Four' }, + { id: 55, categoryId: 'b', label: 'Four' }, + { id: 66, categoryId: 'b', label: 'Three', disabled: true }, + { id: 77, categoryId: 'b', label: 'Four' }, + { id: 88, categoryId: 'b', label: 'Three' }, + { id: 99, categoryId: 'b', label: 'Four' }, + { id: 101, categoryId: 'b', label: 'Three' }, + { id: 111, categoryId: 'b', label: 'Four' }, + { id: 121, categoryId: 'b', label: 'Three' }, + { id: 131, categoryId: 'c', label: 'One' }, +]; + +const categories = [ + { id: 'a', label: 'Category A' }, + { id: 'b', label: 'Category B' }, + { id: 'c', label: 'Category C' }, +]; + +const selectedItems = [items[3], items[12]]; + +initialState = { + pills: selectedItems, +}; + +const mirrorDropdownState = inputStates => { + setState({ + pills: inputStates, + }); +}; + +const handleDelete = ({ id }) => { + setState(({ pills }) => ({ + pills: pills.filter(pill => id !== pill.id), + })); +}; + + + {({ getInputProps, removeItem, ...props }) => { + return ( +
+ { + removeItem(item); + handleDelete(item); + }} + /> + +
+ ); + }} +
; +``` diff --git a/packages/react-dropdown/src/__snapshots__/index.test.js.snap b/packages/react-dropdown/src/__snapshots__/index.test.js.snap new file mode 100644 index 00000000..7d20e6db --- /dev/null +++ b/packages/react-dropdown/src/__snapshots__/index.test.js.snap @@ -0,0 +1,1619 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders a basic closed dropdown 1`] = ` +.emotion-0 { + display: inline-block; +} + + + +
+ + + +
+
+
+`; + +exports[`renders an open dropdown 1`] = ` +.emotion-12 { + display: inline-block; +} + +.emotion-10 { + z-index: 1; + list-style: none; + background-color: #FFFFFF; + color: #2E3338; + border: 1px solid #C7CCD1; + border-radius: 2px; + box-shadow: 0 1px 2px rgba(18,18,18,0.4); + min-width: 27.86em; +} + +.emotion-8 { + list-style: none; + margin: 0; + padding: 0; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-flex: 1; + -webkit-flex-grow: 1; + -ms-flex-positive: 1; + flex-grow: 1; +} + +.emotion-2 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; + cursor: pointer; + padding: 5px; + background-color: transparent; +} + +.emotion-0 { + font-family: IBM Plex Sans,Lucida Grande,Tahoma,Verdana,Arial,sans-serif; +} + + + +
+ + + + +
+ +
    + +
  • + + + One + + +
  • +
    + +
  • + + + Two + + +
  • +
    +
+
+
+
+
+
+
+
+
+`; + +exports[`renders an open dropdown with categories 1`] = ` +.emotion-42 { + display: inline-block; +} + +.emotion-40 { + z-index: 1; + list-style: none; + background-color: #FFFFFF; + color: #2E3338; + border: 1px solid #C7CCD1; + border-radius: 2px; + box-shadow: 0 1px 2px rgba(18,18,18,0.4); + min-width: 27.86em; +} + +.emotion-12 { + list-style: none; + margin: 0; + padding: 0; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-flex: 1; + -webkit-flex-grow: 1; + -ms-flex-positive: 1; + flex-grow: 1; +} + +.emotion-6 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; + cursor: pointer; + padding: 5px; + background-color: transparent; +} + +.emotion-4 { + font-family: IBM Plex Sans,Lucida Grande,Tahoma,Verdana,Arial,sans-serif; +} + +.emotion-18 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; +} + +.emotion-18:not(:last-child) { + border-bottom: 1px solid #C7CCD1; +} + +.emotion-16 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-flex: 1; + -webkit-flex-grow: 1; + -ms-flex-positive: 1; + flex-grow: 1; +} + +.emotion-2 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-box-flex: 0; + -webkit-flex-grow: 0; + -ms-flex-positive: 0; + flex-grow: 0; + -webkit-flex-basis: auto; + -ms-flex-preferred-size: auto; + flex-basis: auto; +} + +.emotion-0 { + font-family: IBM Plex Sans,Lucida Grande,Tahoma,Verdana,Arial,sans-serif; + font-size: 14px; + line-height: 1.57; + font-weight: bold; + margin: 0; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + padding: 5px; +} + + +
+ + + + +
+
+ +
+ +
+ +
+ +

+ Category A +

+
+
+
+ +
+ +
    + +
  • + + + One + + +
  • +
    + +
  • + + + Two + + +
  • +
    +
+
+
+
+
+
+
+
+ +
+ +
+ +
+ +

+ Category B +

+
+
+
+ +
+ +
    + +
  • + + + Three + + +
  • +
    + +
  • + + + Four + + +
  • +
    +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`; + +exports[`renders an open dropdown with categories and twoColumn 1`] = ` +.emotion-42 { + display: inline-block; +} + +.emotion-40 { + z-index: 1; + list-style: none; + background-color: #FFFFFF; + color: #2E3338; + border: 1px solid #C7CCD1; + border-radius: 2px; + box-shadow: 0 1px 2px rgba(18,18,18,0.4); + min-width: 27.86em; +} + +.emotion-12 { + list-style: none; + margin: 0; + padding: 0; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-flex: 1; + -webkit-flex-grow: 1; + -ms-flex-positive: 1; + flex-grow: 1; +} + +.emotion-4 { + font-family: IBM Plex Sans,Lucida Grande,Tahoma,Verdana,Arial,sans-serif; +} + +.emotion-18 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; +} + +.emotion-18:not(:last-child) { + border-bottom: 1px solid #C7CCD1; +} + +.emotion-0 { + font-family: IBM Plex Sans,Lucida Grande,Tahoma,Verdana,Arial,sans-serif; + font-size: 14px; + line-height: 1.57; + font-weight: bold; + margin: 0; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + padding: 5px; +} + +.emotion-16 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; + -webkit-box-flex: 1; + -webkit-flex-grow: 1; + -ms-flex-positive: 1; + flex-grow: 1; +} + +.emotion-2 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-box-flex: 1; + -webkit-flex-grow: 1; + -ms-flex-positive: 1; + flex-grow: 1; + -webkit-flex-basis: 50%; + -ms-flex-preferred-size: 50%; + flex-basis: 50%; +} + +.emotion-2:first-of-type { + border-right: 1px solid #C7CCD1; +} + +.emotion-6 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; + cursor: pointer; + padding: 5px; + background-color: transparent; +} + +.emotion-6:not(:last-child) { + border-bottom: 1px solid #C7CCD1; +} + + + +
+ + + + +
+
+ +
+ +
+ +
+ +

+ Category A +

+
+
+
+ +
+ +
    + +
  • + + + One + + +
  • +
    + +
  • + + + Two + + +
  • +
    +
+
+
+
+
+
+
+
+ +
+ +
+ +
+ +

+ Category B +

+
+
+
+ +
+ +
    + +
  • + + + Three + + +
  • +
    + +
  • + + + Four + + +
  • +
    +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`; + +exports[`test utils [filterItems] should filter items based on a string 1`] = ` +Array [ + Object { + "categoryId": "a", + "id": 10, + "label": "One", + }, + Object { + "categoryId": "a", + "id": 22, + "label": "Two", + }, + Object { + "categoryId": "b", + "id": 33, + "label": "Three", + }, + Object { + "categoryId": "b", + "id": 44, + "label": "Four", + }, + Object { + "categoryId": "b", + "id": 55, + "label": "Five", + }, + Object { + "categoryId": "b", + "disabled": true, + "id": 66, + "label": "Six", + }, + Object { + "categoryId": "b", + "disabled": true, + "id": 77, + "label": "Seven", + }, + Object { + "categoryId": "b", + "id": 88, + "label": "Eight", + }, + Object { + "categoryId": "b", + "id": 99, + "label": "Nine", + }, + Object { + "categoryId": "b", + "id": 101, + "label": "Ten", + }, + Object { + "categoryId": "b", + "id": 111, + "label": "Eleven", + }, + Object { + "categoryId": "b", + "id": 121, + "label": "Twelve", + }, + Object { + "categoryId": "c", + "id": 131, + "label": "Thirteen", + }, +] +`; + +exports[`using arrow down should hover on element 1`] = ` +.emotion-8 { + list-style: none; + margin: 0; + padding: 0; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-flex: 1; + -webkit-flex-grow: 1; + -ms-flex-positive: 1; + flex-grow: 1; +} + +.emotion-0 { + font-family: IBM Plex Sans,Lucida Grande,Tahoma,Verdana,Arial,sans-serif; +} + +.emotion-6 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; + cursor: pointer; + padding: 5px; + background-color: #8F9BA3; + color: #2E3338; +} + +.emotion-2 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; + cursor: pointer; + padding: 5px; + background-color: #E3E6E8; + color: #2E3338; +} + +
    + +
  • + + + One + + +
  • +
    + +
  • + + + Two + + +
  • +
    +
+`; + +exports[`using useFilter should only return items that match the input value 1`] = ` +.emotion-16 { + display: inline-block; +} + +.emotion-14 { + z-index: 1; + list-style: none; + background-color: #FFFFFF; + color: #2E3338; + border: 1px solid #C7CCD1; + border-radius: 2px; + box-shadow: 0 1px 2px rgba(18,18,18,0.4); + min-width: 27.86em; +} + +.emotion-12 { + list-style: none; + margin: 0; + padding: 0; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-flex: 1; + -webkit-flex-grow: 1; + -ms-flex-positive: 1; + flex-grow: 1; +} + +.emotion-4 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; + cursor: pointer; + padding: 5px; + background-color: transparent; +} + +.emotion-2 { + font-family: IBM Plex Sans,Lucida Grande,Tahoma,Verdana,Arial,sans-serif; +} + +.emotion-0 { + font-family: IBM Plex Sans,Lucida Grande,Tahoma,Verdana,Arial,sans-serif; + font-weight: bold; +} + + + +
+ + + + +
+ +
    + +
  • + + + + + F + + + our + + +
  • +
    + +
  • + + + + + F + + + ive + + +
  • +
    +
+
+
+
+
+
+
+
+
+`; diff --git a/packages/react-dropdown/src/dropdownTypes.js b/packages/react-dropdown/src/dropdownTypes.js new file mode 100644 index 00000000..5192fd8b --- /dev/null +++ b/packages/react-dropdown/src/dropdownTypes.js @@ -0,0 +1,34 @@ +/** + * Copyright (c) Quid, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +// @flow +import { type PropGetters } from 'downshift'; + +export type DropdownItem = { + id: number | string, + label: string, + categoryId?: number | string, + disabled?: boolean, +}; + +export type DropdownCategory = { + id: number | string, + label: string, +}; + +export type ExtendedCategory = DropdownCategory & { + items?: Array, +}; + +export type DropdownSelectedItem = { + id: number | string, + label?: string, +}; + +export type GetItemProps = $PropertyType< + PropGetters, + 'getItemProps' +>; diff --git a/packages/react-dropdown/src/index.js b/packages/react-dropdown/src/index.js new file mode 100644 index 00000000..a20fcf59 --- /dev/null +++ b/packages/react-dropdown/src/index.js @@ -0,0 +1,169 @@ +/** + * Copyright (c) Quid, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +// @flow +import * as React from 'react'; +import { type GetInputPropsReturn } from 'downshift'; +import { filterItems, callAll } from './utils'; +import DropdownList from './List'; +import InputCreator from './InputCreator'; +import styled from '@emotion/styled/macro'; +import MultiDownshift, { + type MultiControllerStateAndHelpers, +} from './MultiDownshift'; +import { type Modifiers } from 'popper.js'; +import { Manager, Reference, Popper, type Placement } from 'react-popper'; + +import { + type DropdownItem, + type DropdownCategory, + type DropdownSelectedItem, +} from './dropdownTypes.js'; + +// There's something wrong with Enzyme + jest-emotion that prevents us to use +// React.Fragment as child of the Popper component in this instance +// This is a dirty workaround +// istanbul ignore next +const DevFragment = + process.env.NODE_ENV === 'test' ? 'x-fragment' : React.Fragment; + +type Props = { + items: Array, + categories?: Array, + selectedItems?: Array, + useFilter?: boolean, + filterFn?: ( + items: Array, + filter: ?string + ) => Array, + multiselect?: boolean, + name?: string, + children: ( + Object & { getInputProps: Object => GetInputPropsReturn } + ) => React.Node, + twoColumn?: boolean, + initialIsOpen?: boolean, + highlight?: boolean, + placement?: Placement, + popperModifiers?: Modifiers, + popperPositionFixed?: boolean, + onSelect?: ( + Array, + MultiControllerStateAndHelpers + ) => void, + onChange?: ( + Array, + MultiControllerStateAndHelpers + ) => void, +}; + +const DropdownContainer = styled.div` + display: inline-block; +`; + +/** @visibleName Usage example */ +const Dropdown = ({ + filterFn = filterItems, + useFilter = false, + items, + categories = [], + multiselect = false, + children, + name = 'dropdown', + twoColumn = true, + selectedItems = [], + initialIsOpen = false, + placement = 'bottom-start', + popperModifiers, + popperPositionFixed = false, + onChange, + onSelect, + highlight = false, + ...props +}: Props) => ( + + (item && item.label ? item.label : '')} + selectedItems={selectedItems} + initialIsOpen={initialIsOpen} + onChange={onChange} + onSelect={onSelect} + > + {( + downshift, + { + openMenu, + getItemProps, + isOpen, + inputValue, + highlightedIndex, + selectedItems, + getInputProps, + getRootProps, + } = downshift + ) => ( + + + + {({ ref, style, scheduleUpdate }) => ( + + + {({ ref }) => + children({ + ...downshift, + getInputProps: (inputProps = {}) => { + // NOTE: if you contract this to (inputProps ...) => getInputProps + // this thing is going to break compliation for some reason + return getInputProps({ + ...inputProps, + ref, + onFocus: callAll(inputProps.onFocus, openMenu), + onClick: callAll(inputProps.onClick, openMenu), + onChange: callAll( + inputProps.onChange, + scheduleUpdate + ), + }); + }, + }) + } + + {isOpen && ( + + )} + + )} + + + )} + + +); + +export default Dropdown; diff --git a/packages/react-dropdown/src/index.md b/packages/react-dropdown/src/index.md new file mode 100644 index 00000000..eed8ae0f --- /dev/null +++ b/packages/react-dropdown/src/index.md @@ -0,0 +1,122 @@ +#### Dropdown with single item list + +This is the most basic use case, the dropdown will behave like a +normal HTML `select` element: + +```js +const items = [ + { id: 10, label: 'One' }, + { id: 22, label: 'Two' }, + { id: 33, label: 'Three' }, + { id: 44, label: 'Four' }, + { id: 55, label: 'Four' }, + { id: 66, label: 'Three' }, + { id: 77, label: 'Four' }, + { id: 88, label: 'Three' }, + { id: 99, label: 'Four' }, + { id: 101, label: 'Three' }, + { id: 111, label: 'Four' }, + { id: 121, label: 'Three' }, + { id: 131, label: 'One' }, +]; + + + {({ getInputProps }) => } +; +``` + +#### Dropdown with multi-level item list (one column) + +In this example, the items are grouped by category. + +```jsx +const items = [ + { id: 10, categoryId: 'a', label: 'One', disabled: true }, + { id: 22, categoryId: 'a', label: 'Two', disabled: true }, + { id: 33, categoryId: 'b', label: 'Three' }, + { id: 44, categoryId: 'b', label: 'Four' }, + { id: 55, categoryId: 'b', label: 'Four' }, + { id: 66, categoryId: 'b', label: 'Three' }, + { id: 77, categoryId: 'b', label: 'Four' }, + { id: 88, categoryId: 'b', label: 'Three' }, + { id: 99, categoryId: 'b', label: 'Four' }, + { id: 101, categoryId: 'b', label: 'Three' }, + { id: 111, categoryId: 'b', label: 'Four' }, + { id: 121, categoryId: 'b', label: 'Three' }, + { id: 131, categoryId: 'c', label: 'One' }, +]; + +const categories = [ + { id: 'a', label: 'Category A' }, + { id: 'b', label: 'Category B' }, + { id: 'c', label: 'Category C' }, +]; + + + {({ getInputProps }) => } +; +``` + +#### Dropdown with multi-level item list (two columns). + +```js +const items = [ + { id: 10, categoryId: 'a', label: 'One' }, + { id: 22, categoryId: 'a', label: 'Two' }, + { id: 33, categoryId: 'b', label: 'Three' }, + { id: 44, categoryId: 'b', label: 'Four' }, + { id: 55, categoryId: 'b', label: 'Four', disabled: true }, + { id: 66, categoryId: 'b', label: 'Three', disabled: true }, + { id: 77, categoryId: 'b', label: 'Four' }, + { id: 88, categoryId: 'b', label: 'Three' }, + { id: 99, categoryId: 'b', label: 'Four' }, + { id: 101, categoryId: 'b', label: 'Three' }, + { id: 111, categoryId: 'b', label: 'Four' }, + { id: 121, categoryId: 'b', label: 'Three' }, + { id: 131, categoryId: 'c', label: 'One' }, +]; + +const categories = [ + { id: 'a', label: 'Category A' }, + { id: 'b', label: 'Category B' }, + { id: 'c', label: 'Category C' }, +]; + + + {({ getInputProps }) => ( + + )} +; +``` + +#### Dropdown with highlight and filter function. + +```js +const items = [ + { id: 1, label: 'Alaska airlines' }, + { id: 2, label: 'Allegiant Air' }, + { id: 3, label: 'American Airlines' }, + { id: 4, label: 'Delta Air Lines' }, + { id: 5, label: 'Frontier Airlines' }, + { id: 6, label: 'Hawaiian Airlines' }, + { id: 7, label: 'JetBlue Airways' }, + { id: 8, label: 'Southwest Airlines' }, + { id: 9, label: 'Spirit Airlines' }, + { id: 10, label: 'Sun Country Airlines' }, + { id: 11, label: 'United Airlines' }, +]; + + {({ getInputProps }) => ( + + )} +; +``` diff --git a/packages/react-dropdown/src/index.test.js b/packages/react-dropdown/src/index.test.js new file mode 100644 index 00000000..64dd6427 --- /dev/null +++ b/packages/react-dropdown/src/index.test.js @@ -0,0 +1,523 @@ +/** + * Copyright (c) Quid, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +// @flow +import * as React from 'react'; +import ReactDOM from 'react-dom'; +import { mount } from 'enzyme'; +import Dropdown from './index'; +import DropdownList, { List } from './List'; +import { Item, HIGHLIGHTED, SELECTED } from './Items'; +import { Divider } from './Categories'; +import { + filterItems, + includesId, + filterByCategoryId, + isItemInCategorySelected, + splitStringByValue, +} from './utils'; + +jest.mock('react-popper', () => ({ + Manager: 'x-manager', + Reference: ({ children }) => children({}), + Popper: ({ children }) => children({}), +})); + +class Input extends React.Component { + render() { + return ; + } +} + +const items = [ + { id: 10, categoryId: 'a', label: 'One' }, + { id: 22, categoryId: 'a', label: 'Two' }, + { id: 33, categoryId: 'b', label: 'Three' }, + { id: 44, categoryId: 'b', label: 'Four' }, + { id: 55, categoryId: 'b', label: 'Five' }, + { id: 66, categoryId: 'b', label: 'Six', disabled: true }, + { id: 77, categoryId: 'b', label: 'Seven', disabled: true }, + { id: 88, categoryId: 'b', label: 'Eight' }, + { id: 99, categoryId: 'b', label: 'Nine' }, + { id: 101, categoryId: 'b', label: 'Ten' }, + { id: 111, categoryId: 'b', label: 'Eleven' }, + { id: 121, categoryId: 'b', label: 'Twelve' }, + { id: 131, categoryId: 'c', label: 'Thirteen' }, +]; + +const categories = [ + { id: 'a', label: 'Category A' }, + { id: 'b', label: 'Category B' }, + { id: 'c', label: 'Category C' }, +]; + +it('renders items without crashing', () => { + const div = document.createElement('div'); + ReactDOM.render( + + {({ getInputProps }) => } + , + div + ); +}); + +it('renders a basic closed dropdown', () => { + const wrapper = mount( + + {({ getInputProps }) => } + + ); + expect(wrapper).toMatchSnapshot(); +}); + +it('renders an open dropdown', () => { + const wrapper = mount( + + {({ getInputProps }) => } + + ); + expect(wrapper).toMatchSnapshot(); +}); + +it('renders an open dropdown with categories', () => { + const wrapper = mount( + + {({ getInputProps }) => } + + ); + expect(wrapper.find('DropdownContainer')).toMatchSnapshot(); +}); + +it('renders an open dropdown with categories and twoColumn', () => { + const wrapper = mount( + + {({ getInputProps }) => } + + ); + expect(wrapper).toMatchSnapshot(); +}); + +it('onChanges gets called when selecting second value', () => { + const handleChange = jest.fn(); + const wrapper = mount( + + {({ getInputProps }) => } + + ); + + wrapper + .find('ul li') + .at(1) + .simulate('click'); + + expect(handleChange).toHaveBeenCalledWith( + [{ id: 22, label: 'Two' }], + expect.any(Object) + ); +}); + +it('onChanges gets called when selecting first value', () => { + const handleChange = jest.fn(); + + const wrapper = mount( + + {({ getInputProps }) => } + + ); + + //Click on the first element + wrapper + .find('section ul li') + .at(0) + .simulate('click'); + + //Click on the input element + wrapper.find(Input).simulate('click'); + + //Click on the second element + wrapper + .find('section ul li') + .at(1) + .simulate('click'); + + expect(handleChange).toHaveBeenCalledTimes(2); + expect(handleChange).toHaveBeenCalledWith( + [{ id: 10, label: 'One' }], + expect.any(Object) + ); + expect(handleChange).toHaveBeenCalledWith( + [{ id: 22, label: 'Two' }], + expect.any(Object) + ); +}); + +it('multiselect should be visible in the onChange callback', () => { + const handleChange = jest.fn(); + + const wrapper = mount( + + {({ getInputProps }) => } + + ); + //Clicks on the first element + wrapper + .find('ul li') + .at(0) + .simulate('click'); + + wrapper + .find('ul li') + .at(1) + .simulate('click'); + + expect(handleChange).toHaveBeenCalledTimes(2); + expect(handleChange).toHaveBeenCalledWith( + [{ id: 10, label: 'One' }, { id: 22, label: 'Two' }], + expect.any(Object) + ); +}); + +describe('test utils', () => { + it('[filterItems] should filter items based on a string', () => { + expect(filterItems(items, 'Thirteen')).toEqual([ + { id: 131, categoryId: 'c', label: 'Thirteen' }, + ]); + + expect(filterItems(items, 'Thir')).toEqual([ + { id: 131, categoryId: 'c', label: 'Thirteen' }, + ]); + + expect(filterItems(items, 'One')).toEqual([ + { id: 10, categoryId: 'a', label: 'One' }, + ]); + + expect(filterItems(items, '')).toMatchSnapshot(); + }); + + it('[includesId] should return true if id was found', () => { + expect(includesId(items, 101)).toBe(true); + + expect(includesId(items, 111)).toBe(true); + + expect(includesId(items, 50)).toBe(false); + }); + + it('[filterByCategoryId] should return items that match the category', () => { + expect(filterByCategoryId(items, 'a')).toEqual([ + { id: 10, categoryId: 'a', label: 'One' }, + { id: 22, categoryId: 'a', label: 'Two' }, + ]); + + expect(filterByCategoryId(items, 'x')).toEqual([]); + }); + + it('[isItemInCategorySelected] should return true if any of selected items was found in the items', () => { + const selectedItems = [ + { id: 101, label: 'Ten' }, + { id: 111, label: 'Eleven' }, + ]; + expect(isItemInCategorySelected(selectedItems, items)).toBe(true); + expect( + isItemInCategorySelected(selectedItems, [ + { id: 998, label: 'Ten' }, + { id: 999, label: 'Eleven' }, + ]) + ).toBe(false); + }); + + it('[splitStringByValue] should return chunks containing pieces of stirng', () => { + expect(splitStringByValue('Hello world', 'world')).toEqual([ + { value: 'Hello ', highlight: false }, + { value: 'world', highlight: true }, + ]); + + expect(splitStringByValue('Hello world', 'wo')).toEqual([ + { value: 'Hello world', highlight: false }, + ]); + + expect(splitStringByValue('Hello world', 'wo', 2)).toEqual([ + { value: 'Hello ', highlight: false }, + { value: 'wo', highlight: true }, + { value: 'rld', highlight: false }, + ]); + + expect(splitStringByValue('Hello world', 'w', 1)).toEqual([ + { value: 'Hello ', highlight: false }, + { value: 'w', highlight: true }, + { value: 'orld', highlight: false }, + ]); + + expect(splitStringByValue('Hello world worldie', 'world', 1)).toEqual([ + { value: 'Hello ', highlight: false }, + { value: 'world', highlight: true }, + { value: ' ', highlight: false }, + { value: 'world', highlight: true }, + { value: 'ie', highlight: false }, + ]); + + expect(splitStringByValue('Hello world worldie', 'world', 6)).toEqual([ + { value: 'Hello world worldie', highlight: false }, + ]); + }); +}); + +it('using arrow down and return key should select another element', () => { + const handleSelect = jest.fn(); + const wrapper = mount( + + {({ getInputProps }) => } + + ); + wrapper.find(Input).simulate('keyDown', { key: 'ArrowDown', keyCode: 40 }); + wrapper.find(Input).simulate('keyDown', { key: 'Enter', keyCode: 13 }); + + expect(handleSelect).toHaveBeenCalledWith( + { id: 10, label: 'One' }, + expect.any(Object) + ); +}); + +it('using useFilter should only return items that match the input value', () => { + const wrapper = mount( + + {({ getInputProps }) => } + + ); + + wrapper.find(Input).simulate('change', { target: { value: 'F' } }); + + expect(wrapper.find('ul li')).toHaveLength(2); + expect(wrapper).toMatchSnapshot(); +}); + +it('DropDown list should return null when filtering returns 0 items', () => { + const wrapper = mount( + + {({ getInputProps }) => } + + ); + + wrapper.find(Input).simulate('change', { target: { value: 'Fooour' } }); + + expect(wrapper.find('ul li')).toHaveLength(0); + expect(wrapper.find(DropdownList).prop('children')).toBe(undefined); +}); + +it('Two clicks on the same item should call onChange twice, at the end nothing should be selected', () => { + const handleChange = jest.fn(); + const wrapper = mount( + + {({ getInputProps }) => } + + ); + + wrapper + .find('ul li') + .at(0) + .simulate('click'); + + wrapper + .find('ul li') + .at(0) + .simulate('click'); + + expect(handleChange).toHaveBeenCalledTimes(2); + expect(handleChange).toHaveBeenCalledWith( + [{ id: 10, label: 'One' }], + expect.any(Object) + ); + expect(handleChange).toHaveBeenCalledWith([], expect.any(Object)); +}); + +it('hovering on an item makes it highlighted', () => { + const wrapper = mount( + + {({ getInputProps }) => } + + ); + + wrapper + .find('ul li') + .at(0) + .simulate('mousemove'); + + expect( + wrapper + .find(Item) + .at(0) + .prop('isHighlighted') + ).toBe(true); +}); + +it('Should not fail when a certaing category doesnt have any item.', () => { + const extraCategory = [...categories, { id: 'x', label: 'Category X' }]; + expect(() => + mount( + + {({ getInputProps }) => } + + ) + ).not.toThrowError(); +}); + +it('Empty item label should not break anything.', () => { + const handleSelect = jest.fn(); + const wrapper = mount( + + {({ getInputProps }) => } + + ); + + wrapper + .find('ul li') + .at(0) + .simulate('click'); + + expect(handleSelect).toHaveBeenCalledWith( + { id: 10, label: '' }, + expect.any(Object) + ); +}); + +it('initial selected item should set the input value', () => { + const selectedItems = [{ id: 22, label: 'Two' }]; + const wrapper = mount( + + {({ getInputProps }) => } + + ); + + expect(wrapper.find(Input).props().value).toEqual(selectedItems[0].label); +}); + +it('using arrow down should hover on element', () => { + const selectedItems = [{ id: 22, label: 'Two' }]; + const wrapper = mount( + + {({ getInputProps }) => } + + ); + + wrapper.find(Input).simulate('keyDown', { + key: 'ArrowDown', + keyCode: 40, + }); + + expect( + wrapper + .find('ul') + .hostNodes() + .at(2) + ).toMatchSnapshot(); +}); + +it('supports dark theme', () => { + expect( + mount( + + ) + ).toHaveStyleRule('background-color', 'gray5'); + + expect( + HIGHLIGHTED({ + theme: { current: 'dark', colors: { gray5: 'gray5' } }, + }) + ).toBe('gray5'); + + expect( + SELECTED({ + theme: { current: 'dark', colors: { gray1: 'gray1' } }, + }) + ).toBe('gray1'); + + expect( + mount( + + ) + ).toHaveStyleRule('color', 'primaryInverse'); + + expect( + mount( + + ) + ).toHaveStyleRule('background-color', 'gray6'); +}); diff --git a/packages/react-dropdown/src/utils.js b/packages/react-dropdown/src/utils.js new file mode 100644 index 00000000..0e3700f4 --- /dev/null +++ b/packages/react-dropdown/src/utils.js @@ -0,0 +1,125 @@ +/** + * Copyright (c) Quid, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +// @flow +import { type DropdownItem } from './dropdownTypes.js'; + +/** + * Filters items where filter value is not included + */ +export function filterItems( + items: Array, + filter: ?string +): Array { + if (filter) { + const lowerCaseFilter = filter.toLowerCase(); + return items.filter( + ({ label }) => label.toLowerCase().indexOf(lowerCaseFilter) !== -1 + ); + } else { + return items; + } +} + +/** + * Filters items based on categoryId + */ +export function filterByCategoryId( + items: Array, + categoryId: string | number +): Array { + return items.filter(item => { + if (item.categoryId === categoryId) { + return { + ...item, + }; + } + }); +} + +/** + * Checks if the id is in the items + */ +export function includesId( + items: Array, + searchId: number | string +): boolean { + return items.findIndex(({ id }) => id === searchId) !== -1; +} + +/** + * Iterates trough selectedItems and checks if any of items exist there + */ +export function isItemInCategorySelected( + selectedItems: Array, + items: Array +): boolean { + for (let x = 0; x < selectedItems.length; x++) { + if (includesId(items, selectedItems[x].id)) { + return true; + } + } + + return false; +} + +export function splitStringByValue( + heystack: string, + needle: string, + minLenght: number = 3 +): Array<{ + value: string, + highlight: boolean, +}> { + let textSlices = []; + const needleLength = needle.length; + const lowHeystack = heystack.toLowerCase(); + const lowNeedle = needle.toLowerCase(); + let startIndex = 0; + let index = 0; + while ((index = lowHeystack.indexOf(lowNeedle, startIndex)) > -1) { + textSlices.push({ + value: heystack.slice(startIndex, index), + highlight: false, + }); + + startIndex = index + needleLength; + textSlices.push({ + value: heystack.slice(index, startIndex), + highlight: true, + }); + } + + if (textSlices.length === 0 || minLenght > needle.length) { + textSlices = []; + textSlices.push({ + value: heystack, + highlight: false, + }); + } else if (startIndex < heystack.length) { + textSlices.push({ + value: heystack.slice(startIndex, heystack.length), + highlight: false, + }); + } + + return textSlices; +} + +/** + * This return a function that will call all the given functions with + * the arguments with which it's called. It does a null-check before + * attempting to call the functions and can take any number of functions. + */ +export function callAll(...fns: Array): Function { + return (...args: Array) => { + fns.forEach(fn => { + if (fn) { + fn(...args); + } + }); + }; +} diff --git a/yarn.lock b/yarn.lock index 420f4ac4..9076f9cf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1069,6 +1069,17 @@ "@emotion/sheet" "0.9.2" "@emotion/utils" "0.11.1" +"@emotion/core@^10.0.6": + version "10.0.6" + resolved "https://registry.npmjs.org/@emotion/core/-/core-10.0.6.tgz#c10d7884a525728f05589b31da1c804a5d7449fa" + integrity sha512-S5KkrodTKby1S6pKZnH8LzjzlebHvjactujfVzzu/mYYdVdKYegJuJdrAz3m9zhIeizzeQGD8xWF490ioGpUtw== + dependencies: + "@emotion/cache" "10.0.0" + "@emotion/css" "^10.0.6" + "@emotion/serialize" "^0.11.3" + "@emotion/sheet" "0.9.2" + "@emotion/utils" "0.11.1" + "@emotion/css@^10.0.4": version "10.0.4" resolved "https://registry.npmjs.org/@emotion/css/-/css-10.0.4.tgz#efa2818a63207ba7c038fdc22167f6f2b2de6230" @@ -3884,6 +3895,11 @@ compression@^1.5.2: safe-buffer "5.1.2" vary "~1.1.2" +compute-scroll-into-view@^1.0.9: + version "1.0.11" + resolved "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.11.tgz#7ff0a57f9aeda6314132d8994cce7aeca794fecf" + integrity sha512-uUnglJowSe0IPmWOdDtrlHXof5CTIJitfJEyITHBW6zDVOGu9Pjk5puaLM73SLcwak0L4hEjO7Td88/a6P5i7A== + concat-map@0.0.1: version "0.0.1" resolved "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -4734,7 +4750,7 @@ defaults@^1.0.3: dependencies: clone "^1.0.2" -define-properties@^1.1.2: +define-properties@^1.1.2, define-properties@^1.1.3: version "1.1.3" resolved "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1" integrity sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ== @@ -5039,6 +5055,16 @@ dotenv@^4.0.0: resolved "http://registry.npmjs.org/dotenv/-/dotenv-4.0.0.tgz#864ef1379aced55ce6f95debecdce179f7a0cd1d" integrity sha1-hk7xN5rO1Vzm+V3r7NzhefegzR0= +downshift@^3.2.0: + version "3.2.0" + resolved "https://registry.npmjs.org/downshift/-/downshift-3.2.0.tgz#820f40575683d3fc521a9ffd409ef4a69cef531d" + integrity sha512-IXZ3IB7xU51R04olRGSp8GBmJhPlCL8tW5FjZrJh8e4kB4BGUWsQHZ9tNsO+qNLABieR5DIgYBZ9MncPlwZSDg== + dependencies: + "@babel/runtime" "^7.1.2" + compute-scroll-into-view "^1.0.9" + prop-types "^15.6.0" + react-is "^16.5.2" + duplexer@^0.1.1: version "0.1.1" resolved "http://registry.npmjs.org/duplexer/-/duplexer-0.1.1.tgz#ace6ff808c1ce66b57d1ebf97977acb02334cfc1" @@ -5173,26 +5199,27 @@ entities@^1.1.1, entities@~1.1.1: resolved "https://registry.npmjs.org/entities/-/entities-1.1.2.tgz#bdfa735299664dfafd34529ed4f8522a275fea56" integrity sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w== -enzyme-adapter-react-16@^1.7.1: - version "1.7.1" - resolved "https://registry.npmjs.org/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.7.1.tgz#c37c4cb0fd75e88a063154a7a88096474914496a" - integrity sha512-OQXKgfHWyHN3sFu2nKj3mhgRcqIPIJX6aOzq5AHVFES4R9Dw/vCBZFMPyaG81g2AZ5DogVh39P3MMNUbqNLTcw== +enzyme-adapter-react-16@^1.8.0: + version "1.8.0" + resolved "https://registry.npmjs.org/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.8.0.tgz#7055d8e908d8d27b807cf4292244db3c815ca11d" + integrity sha512-7cVHIKutqnesGeM3CjNFHSvktpypSWBokrBO8wIW+BVx+HGxWCF87W9TpkIIYJqgCtdw9FQGFrAbLg8kSwPRuQ== dependencies: - enzyme-adapter-utils "^1.9.0" + enzyme-adapter-utils "^1.10.0" function.prototype.name "^1.1.0" object.assign "^4.1.0" - object.values "^1.0.4" + object.values "^1.1.0" prop-types "^15.6.2" - react-is "^16.6.1" + react-is "^16.7.0" react-test-renderer "^16.0.0-0" -enzyme-adapter-utils@^1.9.0: - version "1.9.0" - resolved "https://registry.npmjs.org/enzyme-adapter-utils/-/enzyme-adapter-utils-1.9.0.tgz#3997c20f3387fdcd932b155b3740829ea10aa86c" - integrity sha512-uMe4xw4l/Iloh2Fz+EO23XUYMEQXj5k/5ioLUXCNOUCI8Dml5XQMO9+QwUq962hBsY5qftfHHns+d990byWHvg== +enzyme-adapter-utils@^1.10.0: + version "1.10.0" + resolved "https://registry.npmjs.org/enzyme-adapter-utils/-/enzyme-adapter-utils-1.10.0.tgz#5836169f68b9e8733cb5b69cad5da2a49e34f550" + integrity sha512-VnIXJDYVTzKGbdW+lgK8MQmYHJquTQZiGzu/AseCZ7eHtOMAj4Rtvk8ZRopodkfPves0EXaHkXBDkVhPa3t0jA== dependencies: function.prototype.name "^1.1.0" object.assign "^4.1.0" + object.fromentries "^2.0.0" prop-types "^15.6.2" semver "^5.6.0" @@ -5203,10 +5230,10 @@ enzyme-to-json@^3.3.5: dependencies: lodash "^4.17.4" -enzyme@^3.7.0: - version "3.7.0" - resolved "https://registry.npmjs.org/enzyme/-/enzyme-3.7.0.tgz#9b499e8ca155df44fef64d9f1558961ba1385a46" - integrity sha512-QLWx+krGK6iDNyR1KlH5YPZqxZCQaVF6ike1eDJAOg0HvSkSCVImPsdWaNw6v+VrnK92Kg8jIOYhuOSS9sBpyg== +enzyme@^3.8.0: + version "3.8.0" + resolved "https://registry.npmjs.org/enzyme/-/enzyme-3.8.0.tgz#646d2d5d0798cb98fdec39afcee8a53237b47ad5" + integrity sha512-bfsWo5nHyZm1O1vnIsbwdfhU989jk+squU9NKvB+Puwo5j6/Wg9pN5CO0YJelm98Dao3NPjkDZk+vvgwpMwYxw== dependencies: array.prototype.flat "^1.2.1" cheerio "^1.0.0-rc.2" @@ -5258,7 +5285,19 @@ es-abstract@^1.10.0, es-abstract@^1.5.0, es-abstract@^1.5.1, es-abstract@^1.6.1, is-callable "^1.1.3" is-regex "^1.0.4" -es-to-primitive@^1.1.1: +es-abstract@^1.11.0, es-abstract@^1.12.0: + version "1.13.0" + resolved "https://registry.npmjs.org/es-abstract/-/es-abstract-1.13.0.tgz#ac86145fdd5099d8dd49558ccba2eaf9b88e24e9" + integrity sha512-vDZfg/ykNxQVwup/8E1BZhVzFfBxs9NqMzGcvIJrqg5k2/5Za2bWo40dK2J1pgLngZ7c+Shh8lwYtLGyrwPutg== + dependencies: + es-to-primitive "^1.2.0" + function-bind "^1.1.1" + has "^1.0.3" + is-callable "^1.1.4" + is-regex "^1.0.4" + object-keys "^1.0.12" + +es-to-primitive@^1.1.1, es-to-primitive@^1.2.0: version "1.2.0" resolved "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.0.tgz#edf72478033456e8dda8ef09e00ad9650707f377" integrity sha512-qZryBOJjV//LaxLTV6UC//WewneB3LcXOL9NP++ozKVXsIIIpm/2c13UDiD9Jp2eThsecw9m3jPqDwTyobcdbg== @@ -9146,7 +9185,7 @@ metric-lcs@^0.1.2: resolved "https://registry.npmjs.org/metric-lcs/-/metric-lcs-0.1.2.tgz#87913f149410e39c7c5a19037512814eaf155e11" integrity sha512-+TZ5dUDPKPJaU/rscTzxyN8ZkX7eAVLAiQU/e+YINleXPv03SCmJShaMT1If1liTH8OcmWXZs0CmzCBRBLcMpA== -microbundle@^0.8.3, "microbundle@https://github.com/FezVrasta/microbundle.git#quid-fork": +microbundle@^0.8.3: version "0.8.3" resolved "https://github.com/FezVrasta/microbundle.git#834297a3134eaf24e0cc35da60812a53b2aef96b" dependencies: @@ -9914,6 +9953,16 @@ object.entries@^1.0.4: function-bind "^1.1.0" has "^1.0.1" +object.fromentries@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.0.tgz#49a543d92151f8277b3ac9600f1e930b189d30ab" + integrity sha512-9iLiI6H083uiqUuvzyY6qrlmc/Gz8hLQFOcb/Ri/0xXFkSNS3ctV+CbE6yM2+AnkYfOB3dGjdzC0wrMLIhQICA== + dependencies: + define-properties "^1.1.2" + es-abstract "^1.11.0" + function-bind "^1.1.1" + has "^1.0.1" + object.getownpropertydescriptors@^2.0.3: version "2.0.3" resolved "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.0.3.tgz#8758c846f5b407adab0f236e0986f14b051caa16" @@ -9947,6 +9996,16 @@ object.values@^1.0.4: function-bind "^1.1.0" has "^1.0.1" +object.values@^1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/object.values/-/object.values-1.1.0.tgz#bf6810ef5da3e5325790eaaa2be213ea84624da9" + integrity sha512-8mf0nKLAoFX6VlNVdhGj31SVYpaNFtUnuoOXWyFEstsWRgU837AK+JYM0iAxwkSzGRbwn8cbFmgbyxj1j4VbXg== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.12.0" + function-bind "^1.1.1" + has "^1.0.3" + obuf@^1.0.0, obuf@^1.1.1: version "1.1.2" resolved "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e" @@ -10494,6 +10553,11 @@ popper.js@^1.14.4: resolved "https://registry.npmjs.org/popper.js/-/popper.js-1.14.6.tgz#ab20dd4edf9288b8b3b6531c47c361107b60b4b0" integrity sha512-AGwHGQBKumlk/MDfrSOf0JHhJCImdDMcGNoqKmKkU+68GFazv3CQ6q9r7Ja1sKDZmYWTckY/uLyEznheTDycnA== +popper.js@^1.14.7: + version "1.14.7" + resolved "https://registry.npmjs.org/popper.js/-/popper.js-1.14.7.tgz#e31ec06cfac6a97a53280c3e55e4e0c860e7738e" + integrity sha512-4q1hNvoUre/8srWsH7hnoSJ5xVmIL4qgz+s4qf2TnJIMyZFUFMGH+9vE7mXynAlHSZ/NdTmmow86muD0myUkVQ== + portfinder@^1.0.9: version "1.0.19" resolved "https://registry.npmjs.org/portfinder/-/portfinder-1.0.19.tgz#07e87914a55242dcda5b833d42f018d6875b595f" @@ -11883,7 +11947,7 @@ react-icons@^2.2.7: dependencies: react-icon-base "2.1.0" -react-is@^16.6.1, react-is@^16.7.0: +react-is@^16.5.2, react-is@^16.7.0: version "16.7.0" resolved "https://registry.npmjs.org/react-is/-/react-is-16.7.0.tgz#c1bd21c64f1f1364c6f70695ec02d69392f41bfa" integrity sha512-Z0VRQdF4NPDoI0tsXVMLkJLiwEBa+RP66g0xDHxgxysxSoCUccSten4RTF/UFvZF1dZvZ9Zu1sx+MDXwcOR34g== @@ -11905,6 +11969,18 @@ react-popper@^1.3.2: typed-styles "^0.0.7" warning "^4.0.2" +react-popper@^1.3.3: + version "1.3.3" + resolved "https://registry.npmjs.org/react-popper/-/react-popper-1.3.3.tgz#2c6cef7515a991256b4f0536cd4bdcb58a7b6af6" + integrity sha512-ynMZBPkXONPc5K4P5yFWgZx5JGAUIP3pGGLNs58cfAPgK67olx7fmLp+AdpZ0+GoQ+ieFDa/z4cdV6u7sioH6w== + dependencies: + "@babel/runtime" "^7.1.2" + create-react-context "<=0.2.2" + popper.js "^1.14.4" + prop-types "^15.6.1" + typed-styles "^0.0.7" + warning "^4.0.2" + react-resize-aware@^2.7.2: version "2.7.2" resolved "https://registry.npmjs.org/react-resize-aware/-/react-resize-aware-2.7.2.tgz#38a0040daaa28dfa9b88994889fbb1e2aa66df83"