diff --git a/README.md b/README.md index 3df4b915..ea0f33af 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,8 @@ A lightweight and fast control to render a select component that can display hie ## Table of Contents - [Screenshot](#screenshot) -- [Demo](#example) - - [Vanilla (no framework)](#vanilla-no-framework) +- [Demo](#demo) + - [Vanilla, no framework](#vanilla-no-framework) - [With Bootstrap](#with-bootstrap) - [With Material Design](#with-material-design) - [As Single Select](#as-single-select) @@ -44,36 +44,41 @@ A lightweight and fast control to render a select component that can display hie - [clearSearchOnChange](#clearsearchonchange) - [onChange](#onchange) - [onNodeToggle](#onnodetoggle) + - [onAction](#onaction) + - [onFocus](#onfocus) + - [onBlur](#onblur) - [data](#data) - [texts](#texts) - [keepTreeOnSearch](#keeptreeonsearch) - [keepChildrenOnSearch](#keepchildrenonsearch) - [keepOpenOnSelect](#keepopenonselect) - [mode](#mode) - - [multiSelect](#multiSelect) + - [multiSelect](#multiselect) - [hierarchical](#hierarchical) - - [simpleSelect](#simpleSelect) - - [radioSelect](#radioSelect) + - [simpleSelect](#simpleselect) + - [radioSelect](#radioselect) - [showPartiallySelected](#showpartiallyselected) - - [showDropdown](#showDropdown) + - [showDropdown](#showdropdown) - [initial](#initial) - [always](#always) - - [form states (disabled|readOnly)](#formstates) + - [form states (disabled|readOnly)](#form-states-disabled-readonly) - [id](#id) - [searchPredicate](#searchpredicate) + - [inlineSearchInput](#inlinesearchinput) - [Styling and Customization](#styling-and-customization) - [Using default styles](#default-styles) - [Customizing with Bootstrap, Material Design styles](#customizing-styles) +- [Keyboard navigation](#keyboard-navigation) - [Performance](#performance) - [Search optimizations](#search-optimizations) - [Search debouncing](#search-debouncing) - [Virtualized rendering](#virtualized-rendering) - [Reducing costly DOM manipulations](#reducing-costly-dom-manipulations) -- [Keyboard navigation](#keyboard-navigation) - [FAQ](#faq) - [Doing more with HOCs](/docs/HOC.md) - [Development](#development) - [License](#license) +- [Contributors](#contributors) ## Screenshot @@ -401,6 +406,12 @@ function searchPredicate(node, searchTerm) { return ``` +### inlineSearchInput + +Type: `bool` (default: `false`) + +`inlineSearchInput=true` makes the search input renders **inside** the dropdown-content. This can be useful when your UX looks something like [this comment](https://github.com/dowjones/react-dropdown-tree-select/issues/308#issue-526467109). + ## Styling and Customization ### Default styles diff --git a/__snapshots__/src/index.test.js.md b/__snapshots__/src/index.test.js.md index 37d5c713..3c7d255b 100644 --- a/__snapshots__/src/index.test.js.md +++ b/__snapshots__/src/index.test.js.md @@ -22,17 +22,22 @@ Generated by [AVA](https://ava.li). tags={[]} texts={{}} > - + > + +
-## doesn't toggle dropdown if it's disabled +## always shows dropdown with inline search Input > Snapshot 1 @@ -181,24 +186,198 @@ Generated by [AVA](https://ava.li). > + + +
+ { + _children: [ + 'rdts-0-0', + 'rdts-0-1', + ], + _depth: 0, + _id: 'rdts-0', + actions: [ + { + className: 'fa fa-ban', + id: 'NOT', + title: 'NOT', + }, + ], + children: undefined, + label: 'item1', + value: 'value1', + }, + 'rdts-0-0' => { + _children: [ + 'rdts-0-0-0', + 'rdts-0-0-1', + ], + _depth: 1, + _id: 'rdts-0-0', + _parent: 'rdts-0', + children: undefined, + label: 'item1-1', + value: 'value1-1', + }, + 'rdts-0-0-0' => { + _depth: 2, + _id: 'rdts-0-0-0', + _parent: 'rdts-0-0', + label: 'item1-1-1', + value: 'value1-1-1', + }, + 'rdts-0-0-1' => { + _depth: 2, + _id: 'rdts-0-0-1', + _parent: 'rdts-0-0', + label: 'item1-1-2', + value: 'value1-1-2', + }, + 'rdts-0-1' => { + _depth: 1, + _id: 'rdts-0-1', + _parent: 'rdts-0', + label: 'item1-2', + value: 'value1-2', + }, + 'rdts-1' => { + _children: [ + 'rdts-1-0', + 'rdts-1-1', + ], + _depth: 0, + _id: 'rdts-1', + children: undefined, + label: 'item2', + value: 'value2', + }, + 'rdts-1-0' => { + _children: [ + 'rdts-1-0-0', + 'rdts-1-0-1', + 'rdts-1-0-2', + ], + _depth: 1, + _id: 'rdts-1-0', + _parent: 'rdts-1', + children: undefined, + label: 'item2-1', + value: 'value2-1', + }, + 'rdts-1-0-0' => { + _depth: 2, + _id: 'rdts-1-0-0', + _parent: 'rdts-1-0', + label: 'item2-1-1', + value: 'value2-1-1', + }, + 'rdts-1-0-1' => { + _depth: 2, + _id: 'rdts-1-0-1', + _parent: 'rdts-1-0', + label: 'item2-1-2', + value: 'value2-1-2', + }, + 'rdts-1-0-2' => { + _children: [ + 'rdts-1-0-2-0', + ], + _depth: 2, + _id: 'rdts-1-0-2', + _parent: 'rdts-1-0', + children: undefined, + label: 'item2-1-3', + value: 'value2-1-3', + }, + 'rdts-1-0-2-0' => { + _depth: 3, + _id: 'rdts-1-0-2-0', + _parent: 'rdts-1-0-2', + label: 'item2-1-3-1', + value: 'value2-1-3-1', + }, + 'rdts-1-1' => { + _depth: 1, + _id: 'rdts-1-1', + _parent: 'rdts-1', + label: 'item2-2', + value: 'value2-2', + }, + } + } + onAction={Function {}} + onCheckboxChange={Function {}} + onNodeToggle={Function {}} + pageSize={100} + searchModeOn={false} + texts={{}} + /> +
+ + + +## doesn't toggle dropdown if it's disabled + +> Snapshot 1 + +
+
+ + + > + +
@@ -278,6 +457,7 @@ Generated by [AVA](https://ava.li). ] } id="rdts" + inlineSearchInput={false} mode="radioSelect" onBlur={Function onBlur {}} onChange={Function onChange {}} @@ -310,14 +490,9 @@ Generated by [AVA](https://ava.li). role="button" tabIndex={0} > - - + texts={{}} + > + + - + @@ -422,6 +608,7 @@ Generated by [AVA](https://ava.li). ] } id="rdts" + inlineSearchInput={false} onBlur={Function onBlur {}} onChange={Function onChange {}} onFocus={Function onFocus {}} @@ -452,13 +639,8 @@ Generated by [AVA](https://ava.li). role="button" tabIndex={0} > - - + texts={{}} + > + + - + @@ -506,17 +698,22 @@ Generated by [AVA](https://ava.li). tags={[]} texts={{}} > - + > + +
Snapshot 1 - + ## renders placeholder > Snapshot 1 - - -## renders tags - -> Snapshot 1 - - - -## should render data attributes - -> Snapshot 1 - - + ## should render disabled input > Snapshot 1 - + diff --git a/__snapshots__/src/input/index.test.js.snap b/__snapshots__/src/input/index.test.js.snap index 48001613..81941268 100644 Binary files a/__snapshots__/src/input/index.test.js.snap and b/__snapshots__/src/input/index.test.js.snap differ diff --git a/__snapshots__/src/tags/index.test.js.md b/__snapshots__/src/tags/index.test.js.md new file mode 100644 index 00000000..93158c67 --- /dev/null +++ b/__snapshots__/src/tags/index.test.js.md @@ -0,0 +1,94 @@ +# Snapshot report for `src/tags/index.test.js` + +The actual snapshot is saved in `index.test.js.snap`. + +Generated by [AVA](https://ava.li). + +## renders tags + +> Snapshot 1 + + + +## renders tags when no tags are passed + +> Snapshot 1 + + + +## renders tags when no tags are passed nor Input + +> Snapshot 1 + + + +## should render data attributes + +> Snapshot 1 + + diff --git a/__snapshots__/src/tags/index.test.js.snap b/__snapshots__/src/tags/index.test.js.snap new file mode 100644 index 00000000..7a340dc4 Binary files /dev/null and b/__snapshots__/src/tags/index.test.js.snap differ diff --git a/docs/src/stories/Options/index.js b/docs/src/stories/Options/index.js index 459dd9ca..b6536767 100644 --- a/docs/src/stories/Options/index.js +++ b/docs/src/stories/Options/index.js @@ -15,6 +15,7 @@ class WithOptions extends PureComponent { keepTreeOnSearch: false, keepOpenOnSelect: false, mode: 'multiSelect', + inlineSearchInput: false, showPartiallySelected: false, disabled: false, readOnly: false, @@ -46,6 +47,7 @@ class WithOptions extends PureComponent { disabled, readOnly, showDropdown, + inlineSearchInput, } = this.state return ( @@ -81,6 +83,12 @@ class WithOptions extends PureComponent {
+ diff --git a/src/index.css b/src/index.css index 665e3a57..1f65a511 100644 --- a/src/index.css +++ b/src/index.css @@ -51,6 +51,13 @@ border-top: rgba(0, 0, 0, 0.05) 1px solid; box-shadow: 0 5px 8px rgba(0, 0, 0, 0.15); + .search { + width: 100%; + border: none; + border-bottom: solid 1px #ccc; + outline: none; + } + ul { margin: 0; padding: 0; diff --git a/src/index.js b/src/index.js index be2c8979..28a9c279 100644 --- a/src/index.js +++ b/src/index.js @@ -11,6 +11,7 @@ import React, { Component } from 'react' import { isOutsideClick, clientIdGenerator } from './utils' import Input from './input' +import Tags from './tags' import Trigger from './trigger' import Tree from './tree' import TreeManager from './tree-manager' @@ -45,6 +46,7 @@ class DropdownTreeSelect extends Component { readOnly: PropTypes.bool, id: PropTypes.string, searchPredicate: PropTypes.func, + inlineSearchInput: PropTypes.bool, } static defaultProps = { @@ -53,6 +55,7 @@ class DropdownTreeSelect extends Component { onChange: () => {}, texts: {}, showDropdown: 'default', + inlineSearchInput: false, } constructor(props) { @@ -273,13 +276,25 @@ class DropdownTreeSelect extends Component { } render() { - const { disabled, readOnly, mode, texts } = this.props + const { disabled, readOnly, mode, texts, inlineSearchInput } = this.props const { showDropdown, currentFocus, tags } = this.state const activeDescendant = currentFocus ? `${currentFocus}_li` : undefined const commonProps = { disabled, readOnly, activeDescendant, texts, mode, clientId: this.clientId } + const searchInput = ( + { + this.searchInput = el + }} + onInputChange={this.onInputChange} + onFocus={this.onInputFocus} + onBlur={this.onInputBlur} + onKeyDown={this.onKeyboardKeyDown} + {...commonProps} + /> + ) return (
- { - this.searchInput = el - }} - tags={tags} - onInputChange={this.onInputChange} - onFocus={this.onInputFocus} - onBlur={this.onInputBlur} - onTagRemove={this.onTagRemove} - onKeyDown={this.onKeyboardKeyDown} - {...commonProps} - /> + + {!inlineSearchInput && searchInput} + {showDropdown && (
+ {inlineSearchInput && searchInput} {this.state.allNodesHidden ? ( {texts.noMatches || 'No matches found'} ) : ( diff --git a/src/index.test.js b/src/index.test.js index 284e0fe0..adcdd1e9 100644 --- a/src/index.test.js +++ b/src/index.test.js @@ -86,6 +86,12 @@ test('always shows dropdown', t => { t.snapshot(toJson(wrapper)) }) +test('always shows dropdown with inline search Input', t => { + const { tree } = t.context + const wrapper = shallow() + t.snapshot(toJson(wrapper)) +}) + test('keeps dropdown open for showDropdown: always', t => { const { tree } = t.context const wrapper = mount() diff --git a/src/input/index.js b/src/input/index.js index 18c50810..e331f946 100644 --- a/src/input/index.js +++ b/src/input/index.js @@ -1,31 +1,8 @@ import React, { PureComponent } from 'react' import PropTypes from 'prop-types' -import Tag from '../tag' -import './index.css' -import { getDataset, debounce } from '../utils' +import { debounce } from '../utils' import { getAriaLabel } from '../a11y' -const getTags = (tags = [], onDelete, readOnly, disabled, labelRemove) => - tags.map(tag => { - const { _id, label, tagClassName, dataset } = tag - return ( -
  • - -
  • - ) - }) - class Input extends PureComponent { static propTypes = { tags: PropTypes.array, @@ -39,6 +16,7 @@ class Input extends PureComponent { disabled: PropTypes.bool, readOnly: PropTypes.bool, activeDescendant: PropTypes.string, + inlineSearchInput: PropTypes.bool, } constructor(props) { @@ -52,40 +30,24 @@ class Input extends PureComponent { } render() { - const { - tags, - onTagRemove, - inputRef, - texts = {}, - onFocus, - onBlur, - disabled, - readOnly, - onKeyDown, - activeDescendant, - } = this.props + const { inputRef, texts = {}, onFocus, onBlur, disabled, readOnly, onKeyDown, activeDescendant } = this.props return ( -
      - {getTags(tags, onTagRemove, readOnly, disabled, texts.labelRemove)} -
    • - -
    • -
    + ) } } diff --git a/src/input/index.test.js b/src/input/index.test.js index f02d7417..66c56c05 100644 --- a/src/input/index.test.js +++ b/src/input/index.test.js @@ -6,13 +6,7 @@ import toJson from 'enzyme-to-json' import Input from './index' -test('renders tags', t => { - const tags = [{ _id: 'i1', label: 'l1' }, { _id: 'i2', label: 'l2' }] - const wrapper = toJson(shallow()) - t.snapshot(wrapper) -}) - -test('renders input when no tags are passed', t => { +test('renders input', t => { const wrapper = toJson(shallow()) t.snapshot(wrapper) }) @@ -30,24 +24,6 @@ test('raises onchange', t => { t.true(onChange.calledWith('hello')) }) -test('should render data attributes', t => { - const tags = [ - { - _id: 'i1', - label: 'l1', - tagClassName: 'test', - dataset: { - first: 'john', - last: 'smith', - }, - }, - ] - - const wrapper = toJson(shallow()) - - t.snapshot(wrapper) -}) - test('should render disabled input', t => { const wrapper = toJson(shallow()) t.snapshot(wrapper) diff --git a/src/input/index.css b/src/tags/index.css similarity index 100% rename from src/input/index.css rename to src/tags/index.css diff --git a/src/tags/index.js b/src/tags/index.js new file mode 100644 index 00000000..b467e67a --- /dev/null +++ b/src/tags/index.js @@ -0,0 +1,52 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import Tag from '../tag' +import { getDataset } from '../utils' + +import './index.css' + +const getTags = (tags = [], onDelete, readOnly, disabled, labelRemove) => + tags.map(tag => { + const { _id, label, tagClassName, dataset } = tag + return ( +
  • + +
  • + ) + }) + +class Tags extends PureComponent { + static propTypes = { + tags: PropTypes.array, + onTagRemove: PropTypes.func, + readOnly: PropTypes.bool, + disabled: PropTypes.bool, + texts: PropTypes.object, + children: PropTypes.node, + } + + render() { + const { tags, onTagRemove, texts = {}, disabled, readOnly, children } = this.props + const lastItem = children || {texts.placeholder || 'Choose...'} + + return ( +
      + {getTags(tags, onTagRemove, readOnly, disabled, texts.labelRemove)} +
    • {lastItem}
    • +
    + ) + } +} + +export default Tags diff --git a/src/tags/index.test.js b/src/tags/index.test.js new file mode 100644 index 00000000..d7cfc705 --- /dev/null +++ b/src/tags/index.test.js @@ -0,0 +1,59 @@ +import { shallow } from 'enzyme' +import React from 'react' +import test from 'ava' +import toJson from 'enzyme-to-json' + +import Tags from './index' +import Input from '../input' + +test('renders tags', t => { + const tags = [{ _id: 'i1', label: 'l1' }, { _id: 'i2', label: 'l2' }] + const wrapper = toJson( + shallow( + + + + ) + ) + t.snapshot(wrapper) +}) + +test('renders tags when no tags are passed', t => { + const wrapper = toJson( + shallow( + + + + ) + ) + t.snapshot(wrapper) +}) + +test('renders tags when no tags are passed nor Input', t => { + const wrapper = toJson(shallow()) + t.snapshot(wrapper) +}) + +test('should render data attributes', t => { + const tags = [ + { + _id: 'i1', + label: 'l1', + tagClassName: 'test', + dataset: { + first: 'john', + last: 'smith', + }, + }, + ] + + const wrapper = toJson( + shallow( + + + + ) + ) + + t.snapshot(wrapper) +}) diff --git a/types/react-dropdown-tree-select.d.ts b/types/react-dropdown-tree-select.d.ts index 589b3690..844f579d 100644 --- a/types/react-dropdown-tree-select.d.ts +++ b/types/react-dropdown-tree-select.d.ts @@ -97,6 +97,8 @@ declare module 'react-dropdown-tree-select' { id?: string /** Optional search predicate to override the default case insensitive contains match on node labels. */ searchPredicate?: (currentNode: TreeNode, searchTerm: string) => boolean + /** inlineSearchInput=true Makes the search input renders inside the dropdown-content. Defaults to `false` */ + inlineSearchInput?: boolean } export interface DropdownTreeSelectState {