diff --git a/README.md b/README.md
index 5f3afe2..24b00e7 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,7 @@
##Installation
-`npm i material-ui-superselectfield`
+`yarn add material-ui-superselectfield`
This component requires 4 dependencies :
- react
@@ -27,6 +27,12 @@ This component requires 4 dependencies :
... so make sure they are installed in your project, or install them as well ;)
+Also don't forget to update your index.js entry file with :
+```js
+import injectTapEventPlugin from 'react-tap-event-plugin'
+injectTapEventPlugin()
+```
+
##Properties
| Name | Type | Default | Description |
|:---- |:---- |:---- |:---- |
@@ -34,13 +40,15 @@ This component requires 4 dependencies :
| hintText | string | 'Click me' | Placeholder text for the main selections display. |
| hintTextAutocomplete | string | 'Type something' | Placeholder text for the autocomplete. |
| noMatchFound | string | 'No match found' | Placeholder text when the autocomplete filter fails. |
+| anchorOrigin | object | `{ vertical: 'top', horizontal: 'left' }` | Anchor position of the menu, accepted values: `top, bottom / left, right` |
+| checkPosition | string | | Position of the checkmark in multiple mode. Accepted values: `'', left, right` |
| multiple | bool | false | Include this property to turn superSelectField into a multi-selection dropdown. Checkboxes will appear.|
| disabled | bool | false | Include this property to disable superSelectField.|
| value | null, object, object[] | null | Selected value(s).
/!\ REQUIRED: each object must expose a 'value' property. |
| onChange | function | | Triggers when selecting/unselecting an option from the Menu.
signature: (selectedValues, name) with `selectedValues` array of selected values based on selected nodes' value property, and `name` the value of the superSelectField instance's name property |
| children | any | [] | Datasource is an array of any type of nodes, styled at your convenience.
/!\ REQUIRED: each node must expose a `value` property. This value property will be used by default for both option's value and label.
A `label` property can be provided to specify a different value than `value`. |
| nb2show | number | 5 | Number of options displayed from the menu. |
-| elementHeight | number | 58 | Height in pixels of one option element. |
+| elementHeight | number | 36 | Height in pixels of one option element. |
| showAutocompleteTreshold | number | 10 | Maximum number of options from which to display the autocomplete search field. |
| autocompleteFilter | function | see below | Provide your own filtering parser. Default: case insensitive.
The search field will appear only if there are more than 10 children (see `showAutocompleteTreshold`).
By default, the parser will check for `label` props, 'value' otherwise. |
#####Note when setting value :
@@ -53,24 +61,32 @@ PropTypes should raise warnings if implementing otherwise.
| Name | Type | Default | Description |
|:---- |:---- |:---- |:---- |
| style | object | {} | Insert your own inlined styles, applied to the root component. |
-| menuStyle | object | {} | Styles to be applied to the comtainer which will receive your children components. |
-| menuGroupStyle | object | {} | Styles to be applied to the MenuItems hosting your \. |
-| innerDivStyle | object | {} | Styles to be applied to the inner div of MenuItems hosting each of your children components. |
+| menuStyle | object | {} | Styles applied to the comtainer which will receive your children components. |
+| menuGroupStyle | object | {} | Styles applied to the MenuItems hosting your \. |
+| innerDivStyle | object | {} | Styles applied to the inner div of MenuItems hosting each of your children components. |
+| menuFooterStyle | object | {} | Styles applied to the Menu's footer. |
+| menuCloseButton | node | | A button for an explicit closing of the menu. Useful on mobiles. |
| selectedMenuItemStyle | object | {color: muiTheme.menuItem.selectedTextColor} | Styles to be applied to the selected MenuItem. |
| selectionsRenderer | function | see below | Provide your own renderer for selected options. Defaults to concatenating children's values text. Check CodeExample4 for a more advanced renderer example. |
-| checkedIcon | SVGicon |
(values, hintText) => { - if (!values || !values.length) return hintText + if (!values) return hintText const { value, label } = values - if (Array.isArray(values)) return values.map(({ value, label }) => label || value).join(', ') + if (Array.isArray(values)) { + return values.length + ? values.map(({ value, label }) => label || value).join(', ') + : hintText + } else if (label || value) return label || value else return hintText }@@ -81,29 +97,31 @@ Check the `CodeExampleX.js` provided in the repository. ##Building -You can build the project with : +You can build the project with : ``` git clone https://github.com/Sharlaan/material-ui-superselectfield.git - -npm i - -npm start -``` +yarn && yarn start +``` It should open a new page on your default browser @ localhost:3000 ## Tests -``` -npm test -``` +`yarn test` + + ## Contributing In lieu of a formal style guide, take care to maintain the existing coding style. Add unit tests for any new or changed functionality. Lint and test your code. + ## Known bugs -- keyboard-focus handling +- keyboard-focus handling combined with optgroups and autocompleted results +- dynamic heights calculation + ## TodoList +- [x] implement onClose handler for multiple mode, to allow registering selected values in oneshot instead of exposing values at each selection (ie one single server request) + - [ ] set autoWidth to false automatically if width prop has a value - [ ] add a css rule for this.root :focus { outline: 'none' }, and :hover { darken } @@ -123,9 +141,10 @@ In lieu of a formal style guide, take care to maintain the existing coding style - [x] noMatchFound message - [ ] floatingLabelText - [ ] canAutoPosition - - [ ] anchorOrigin + - [x] checkPosition + - [x] anchorOrigin - [ ] popoverStyle - - [ ] hoverColor + - [x] hoverColor - [x] disabled - [ ] required - [ ] errorMessage @@ -134,7 +153,7 @@ In lieu of a formal style guide, take care to maintain the existing coding style - [x] add props.disableAutoComplete (default: false), or a nbItems2showAutocomplete (default: null, meaning never show the searchTextField) - [x] make Autocomplete appears only if current numberOfMenuItems > props.autocompleteTreshold -- [ ] implement a checkboxRenderer for MenuItem (or expose 2 properties CheckIconFalse & CheckIconTrue) -- [ ] make a PR reimplementing MenuItem.insetChildren replaced with checkPosition={'left'(default) or 'right'} +- [x] implement a checkboxRenderer for MenuItem (or expose 2 properties CheckIconFalse & CheckIconTrue) +- [x] make a PR reimplementing MenuItem.insetChildren replaced with checkPosition={'left'(default) or 'right'} - [ ] add an example with GooglePlaces diff --git a/lib/SuperSelectField.js b/lib/SuperSelectField.js index 0114e6e..8a5cf4f 100644 --- a/lib/SuperSelectField.js +++ b/lib/SuperSelectField.js @@ -30,9 +30,13 @@ var _TextField = require('material-ui/TextField/TextField'); var _TextField2 = _interopRequireDefault(_TextField); -var _MenuItem = require('material-ui/MenuItem/MenuItem'); +var _ListItem = require('material-ui/List/ListItem'); -var _MenuItem2 = _interopRequireDefault(_MenuItem); +var _ListItem2 = _interopRequireDefault(_ListItem); + +var _check = require('material-ui/svg-icons/navigation/check'); + +var _check2 = _interopRequireDefault(_check); var _checkBoxOutlineBlank = require('material-ui/svg-icons/toggle/check-box-outline-blank'); @@ -56,22 +60,16 @@ function _defineProperty(obj, key, value) { if (key in obj) { Object.definePrope // Utilities var areEqual = function areEqual(val1, val2) { - if ((typeof val1 === 'undefined' ? 'undefined' : _typeof(val1)) !== (typeof val2 === 'undefined' ? 'undefined' : _typeof(val2))) return false;else if (typeof val1 === 'string' || typeof val1 === 'number') return val1 === val2;else if ((typeof val1 === 'undefined' ? 'undefined' : _typeof(val1)) === 'object') { - var _ret = function () { - var props1 = Object.keys(val1); - var props2 = Object.keys(val2); - var values1 = Object.values(val1); - var values2 = Object.values(val2); - return { - v: props1.length === props2.length && props1.every(function (key) { - return props2.includes(key); - }) && values1.every(function (val) { - return values2.includes(val); - }) - }; - }(); - - if ((typeof _ret === 'undefined' ? 'undefined' : _typeof(_ret)) === "object") return _ret.v; + if (!val1 || !val2 || (typeof val1 === 'undefined' ? 'undefined' : _typeof(val1)) !== (typeof val2 === 'undefined' ? 'undefined' : _typeof(val2))) return false;else if (typeof val1 === 'string' || typeof val1 === 'number') return val1 === val2;else if ((typeof val1 === 'undefined' ? 'undefined' : _typeof(val1)) === 'object') { + var props1 = Object.keys(val1); + var props2 = Object.keys(val2); + var values1 = Object.values(val1); + var values2 = Object.values(val2); + return props1.length === props2.length && props1.every(function (key) { + return props2.includes(key); + }) && values1.every(function (val) { + return values2.includes(val); + }); } }; @@ -105,7 +103,7 @@ var styles = { }; var SelectionsPresenter = function SelectionsPresenter(_ref) { - var value = _ref.value, + var selectedValues = _ref.selectedValues, hintText = _ref.hintText, selectionsRenderer = _ref.selectionsRenderer; @@ -119,7 +117,7 @@ var SelectionsPresenter = function SelectionsPresenter(_ref) { _react2.default.createElement( 'div', { style: styles.div3 }, - selectionsRenderer(value, hintText) + selectionsRenderer(selectedValues, hintText) ), _react2.default.createElement(_arrowDropDown2.default, null) ), @@ -141,17 +139,18 @@ SelectionsPresenter.propTypes = { // noinspection JSUnusedGlobalSymbols SelectionsPresenter.defaultProps = { hintText: 'Click me', - value: { value: '' }, + value: null, selectionsRenderer: function selectionsRenderer(values, hintText) { + if (!values) return hintText; var value = values.value, label = values.label; if (Array.isArray(values)) { - return values.map(function (_ref2) { + return values.length ? values.map(function (_ref2) { var value = _ref2.value, label = _ref2.label; return label || value; - }).join(', '); + }).join(', ') : hintText; } else if (label || value) return label || value;else return hintText; } }; @@ -168,16 +167,16 @@ var SelectField = function (_Component) { var _this = _possibleConstructorReturn(this, (SelectField.__proto__ || Object.getPrototypeOf(SelectField)).call(this, props, context)); - _this.handleClick = function () { - _this.openMenu(); // toggle instead of close ? (in case user changes targetOrigin/anchorOrigin) + _this.handleClick = function (event) { + return !_this.props.disabled && _this.openMenu(); }; _this.handleKeyDown = function (event) { - if (/ArrowDown|Enter/.test(event.key)) _this.openMenu(); + return !_this.props.disabled && /ArrowDown|Enter/.test(event.key) && _this.openMenu(); }; _this.handlePopoverClose = function (reason) { - _this.closeMenu(); // toggle instead of close ? (in case user changes targetOrigin/anchorOrigin) + return _this.closeMenu(); }; _this.handleTextFieldAutocompletionFiltering = function (event, searchText) { @@ -186,10 +185,12 @@ var SelectField = function (_Component) { }); }; - _this.handleTextFieldKeyDown = function (event) { - switch (event.key) { + _this.handleTextFieldKeyDown = function (_ref3) { + var key = _ref3.key; + + switch (key) { case 'ArrowDown': - _this.focusFirstMenuItem(); + _this.focusMenuItem(); break; case 'Escape': @@ -204,6 +205,7 @@ var SelectField = function (_Component) { _this.handleMenuSelection = function (selectedItem) { return function (event) { + event.preventDefault(); var _this$props = _this.props, value = _this$props.value, multiple = _this$props.multiple, @@ -222,37 +224,54 @@ var SelectField = function (_Component) { return _this.focusTextField(); }); } else { - onChange(selectedItem, name); + var updatedValue = areEqual(value, selectedItem) ? null : selectedItem; + onChange(updatedValue, name); _this.closeMenu(); } }; }; - _this.handleMenuEscKeyDown = function () { - return _this.closeMenu(); - }; + _this.handleMenuKeyDown = function (_ref4) { + var key = _ref4.key, + tabIndex = _ref4.target.tabIndex; - _this.handleMenuKeyDown = function (event) { - // TODO: this solution propagates and triggers double onKeyDown - // if event.stopPropagation(), nothing works, so the correct trigger is the 2nd one - switch (event.key) { + var cleanMenuItems = _this.menuItems.filter(function (item) { + return !!item; + }); + var firstTabIndex = cleanMenuItems[0].props.tabIndex; + var lastTabIndex = cleanMenuItems[cleanMenuItems.length - 1].props.tabIndex; + var currentElementIndex = cleanMenuItems.findIndex(function (item) { + return item.props.tabIndex === tabIndex; + }); + switch (key) { case 'ArrowUp': - // TODO: add Shift+Tab - // TODO: add if current MenuItem === firstChild - _this.focusTextField(); + if (+tabIndex === firstTabIndex) { + _this.showAutocomplete() ? _this.focusTextField() : _this.focusMenuItem(lastTabIndex); + } else { + var previousTabIndex = cleanMenuItems.slice(0, currentElementIndex).slice(-1)[0].props.tabIndex; + _this.focusMenuItem(previousTabIndex); + } break; case 'ArrowDown': - // TODO: if current MenuItem === lastChild, this.focusFirstMenuItem() + if (+tabIndex === lastTabIndex) { + _this.showAutocomplete() ? _this.focusTextField() : _this.focusMenuItem(); + } else { + var nextTabIndex = cleanMenuItems.slice(currentElementIndex + 1)[0].props.tabIndex; + _this.focusMenuItem(nextTabIndex); + } break; case 'PageUp': - // TODO: this.focusFirstMenuItem() + _this.focusMenuItem(); break; case 'PageDown': - // TODO: this.focusLastMenuItem() - _this.focusLastMenuItem(); + _this.focusMenuItem(lastTabIndex); + break; + + case 'Escape': + _this.closeMenu(); break; default: @@ -265,14 +284,20 @@ var SelectField = function (_Component) { itemsLength: _this.getChildrenLength(props.children), searchText: '' }; + _this.menuItems = []; return _this; } - // Counts nodes with non-null value property + optgroups - // noinspection JSMethodCanBeStatic + _createClass(SelectField, [{ + key: 'componentDidUpdate', + value: function componentDidUpdate() { + console.debug('this.menuItems did Update', this.menuItems); + } + // Counts nodes with non-null value property + optgroups + // noinspection JSMethodCanBeStatic - _createClass(SelectField, [{ + }, { key: 'getChildrenLength', value: function getChildrenLength(children) { var count = 0; @@ -348,44 +373,50 @@ var SelectField = function (_Component) { }); } }, { - key: 'clearTextField', - value: function clearTextField(callback) { - this.setState({ searchText: '' }, callback); + key: 'showAutocomplete', + value: function showAutocomplete() { + return this.state.itemsLength > this.props.showAutocompleteTreshold; } }, { key: 'focusTextField', value: function focusTextField() { - if (this.state.itemsLength > 10) { + if (this.showAutocomplete()) { var input = (0, _reactDom.findDOMNode)(this.searchTextField).getElementsByTagName('input')[0]; input.focus(); - } + } else this.focusMenuItem(); } }, { - key: 'focusFirstMenuItem', - value: function focusFirstMenuItem() { - var firstMenuItem = (0, _reactDom.findDOMNode)(this.menu).querySelector('[tabindex="0"]'); - firstMenuItem.focus(); + key: 'focusMenuItem', + value: function focusMenuItem(index) { + console.debug('index', index, 'this.menuItems', this.menuItems); + var targetMenuItem = this.menuItems.find(function (item) { + console.debug('item', item); + return !!item && (index ? item.props.tabIndex === index : true); + }); + + if (!targetMenuItem) throw Error('targetMenuItem not found.'); + targetMenuItem.applyFocusState('keyboard-focused'); } }, { - key: 'focusLastMenuItem', - value: function focusLastMenuItem() { - var menuItems = (0, _reactDom.findDOMNode)(this.menu).querySelectorAll('[tabindex]'); - var lastMenuItem = menuItems[menuItems.length - 1]; - lastMenuItem.focus(); + key: 'clearTextField', + value: function clearTextField(callback) { + this.setState({ searchText: '' }, callback); } /** * Main Component Wrapper methods */ + // toggle instead of close ? (in case user changes targetOrigin/anchorOrigin) /** * Popover methods */ + // toggle instead of close ? (in case user changes targetOrigin/anchorOrigin) /** - * SelectionPresenter methods + * TextField autocomplete methods */ @@ -393,6 +424,12 @@ var SelectField = function (_Component) { * Menu methods */ + + // TODO: add Shift+Tab + /** + * this.menuItems can contain uncontinuous React elements, because of filtering + */ + }, { key: 'render', value: function render() { @@ -402,33 +439,43 @@ var SelectField = function (_Component) { value = _props.value, hintText = _props.hintText, hintTextAutocomplete = _props.hintTextAutocomplete, + noMatchFound = _props.noMatchFound, multiple = _props.multiple, + disabled = _props.disabled, children = _props.children, nb2show = _props.nb2show, - showAutocompleteTreshold = _props.showAutocompleteTreshold, autocompleteFilter = _props.autocompleteFilter, selectionsRenderer = _props.selectionsRenderer, + anchorOrigin = _props.anchorOrigin, style = _props.style, menuStyle = _props.menuStyle, elementHeight = _props.elementHeight, innerDivStyle = _props.innerDivStyle, selectedMenuItemStyle = _props.selectedMenuItemStyle, - menuGroupStyle = _props.menuGroupStyle; + menuGroupStyle = _props.menuGroupStyle, + checkedIcon = _props.checkedIcon, + unCheckedIcon = _props.unCheckedIcon, + hoverColor = _props.hoverColor, + checkPosition = _props.checkPosition; + var _context$muiTheme = this.context.muiTheme, + palette = _context$muiTheme.baseTheme.palette, + menuItem = _context$muiTheme.menuItem; // Default style depending on Material-UI context var mergedSelectedMenuItemStyle = _extends({ - color: this.context.muiTheme.menuItem.selectedTextColor }, selectedMenuItemStyle); + color: menuItem.selectedTextColor }, selectedMenuItemStyle); + if (checkedIcon) checkedIcon.props.style.fill = mergedSelectedMenuItemStyle.color; + var mergedHoverColor = hoverColor || menuItem.hoverColor; /** * MenuItems building, based on user's children - * 1st unction is the base process for producing a MenuItem, + * 1st function is the base process for producing a MenuItem, * including filtering from the Autocomplete. * 2nd function is the main loop over children array, * accounting for optgroups. */ var menuItemBuilder = function menuItemBuilder(nodes, child, index) { - var groupIndex = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : ''; var _child$props = child.props, childValue = _child$props.value, label = _child$props.label; @@ -436,55 +483,78 @@ var SelectField = function (_Component) { if (!autocompleteFilter(_this4.state.searchText, label || childValue)) return nodes; var isSelected = Array.isArray(value) ? value.some(function (obj) { return areEqual(obj.value, childValue); - }) : value.value === childValue; - return [].concat(_toConsumableArray(nodes), [_react2.default.createElement(_MenuItem2.default, { - key: groupIndex + index, + }) : value ? value.value === childValue : false; + var leftCheckbox = multiple && checkPosition === 'left' && (isSelected ? checkedIcon : unCheckedIcon) || null; + var rightCheckbox = multiple && checkPosition === 'right' && (isSelected ? checkedIcon : unCheckedIcon) || null; + if (multiple && checkPosition !== '') { + if (checkedIcon) checkedIcon.props.style.marginTop = 0; + if (unCheckedIcon) unCheckedIcon.props.style.marginTop = 0; + } + return [].concat(_toConsumableArray(nodes), [_react2.default.createElement(_ListItem2.default, { + key: ++index, tabIndex: index, - checked: multiple && isSelected, - leftIcon: multiple && !isSelected ? _react2.default.createElement(_checkBoxOutlineBlank2.default, null) : null, - primaryText: child, + ref: function ref(_ref5) { + return _this4.menuItems[++index] = _ref5; + }, + onTouchTap: _this4.handleMenuSelection({ value: childValue, label: label }), disableFocusRipple: true, - innerDivStyle: _extends({ paddingTop: 5, paddingBottom: 5 }, innerDivStyle), - style: isSelected ? mergedSelectedMenuItemStyle : null, - onTouchTap: _this4.handleMenuSelection({ value: childValue, label: label }) + leftIcon: leftCheckbox, + rightIcon: rightCheckbox, + primaryText: child, + hoverColor: mergedHoverColor, + innerDivStyle: _extends({ + paddingTop: 10, + paddingBottom: 10, + paddingLeft: multiple && checkPosition === 'left' ? 56 : 16, + paddingRight: multiple && checkPosition === 'right' ? 56 : 16 + }, innerDivStyle), + style: isSelected ? mergedSelectedMenuItemStyle : {} })]); }; - var menuItems = this.state.isOpen && children && children.reduce(function (nodes, child, index) { - if (child.type !== 'optgroup') return menuItemBuilder(nodes, child, index); - var menuGroup = _react2.default.createElement(_MenuItem2.default, { + var menuItems = !disabled && this.state.isOpen && children && children.reduce(function (nodes, child, index) { + if (child.type !== 'optgroup') return menuItemBuilder(nodes, child, index); + var nextIndex = nodes.length ? +nodes[nodes.length - 1].key + 1 : 0; + var menuGroup = _react2.default.createElement(_ListItem2.default, { disabled: true, - key: 'group' + index, + key: nextIndex, primaryText: child.props.label, style: _extends({ cursor: 'default' }, menuGroupStyle) }); var groupedItems = child.props.children.reduce(function (nodes, child, idx) { - return menuItemBuilder(nodes, child, idx, 'group' + index); + return menuItemBuilder(nodes, child, nextIndex + idx); }, []); return [].concat(_toConsumableArray(nodes), [menuGroup], _toConsumableArray(groupedItems)); }, []); - var containerHeight = elementHeight * (nb2show < menuItems.length ? nb2show : menuItems.length); - var showAutocomplete = this.state.itemsLength > showAutocompleteTreshold; - var popoverHeight = (showAutocomplete ? 53 : 0) + (containerHeight || elementHeight); + /* + const menuItemsHeights = this.state.isOpen + ? this.menuItems.map(item => findDOMNode(item).clientHeight) // can't resolve since items not rendered yet, need componentDiDMount + : elementHeight + */ + var containerHeight = elementHeight * (nb2show < menuItems.length ? nb2show : menuItems.length) || 0; + var popoverHeight = (this.showAutocomplete() ? 53 : 0) + (containerHeight || elementHeight); var scrollableStyle = { overflowY: nb2show >= menuItems.length ? 'hidden' : 'scroll' }; var menuWidth = this.root ? this.root.clientWidth : null; + console.debug('menuItems', menuItems); return _react2.default.createElement( 'div', { - ref: function ref(_ref5) { - return _this4.root = _ref5; + ref: function ref(_ref8) { + return _this4.root = _ref8; }, tabIndex: '0', - style: _extends({ cursor: 'pointer' }, style), onKeyDown: this.handleKeyDown, - onClick: this.handleClick, - onBlur: this.handleBlur + onTouchTap: this.handleClick, + style: _extends({ + cursor: disabled ? 'not-allowed' : 'pointer', + color: disabled ? palette.disabledColor : palette.textColor + }, style) }, _react2.default.createElement(SelectionsPresenter, { hintText: hintText, - value: value, + selectedValues: value, selectionsRenderer: selectionsRenderer }), _react2.default.createElement( @@ -493,15 +563,14 @@ var SelectField = function (_Component) { open: this.state.isOpen, anchorEl: this.root, canAutoPosition: false, - anchorOrigin: { vertical: 'top', horizontal: 'left' }, + anchorOrigin: anchorOrigin, useLayerForClickAway: false, onRequestClose: this.handlePopoverClose, style: { height: popoverHeight || 0 } }, - showAutocomplete && _react2.default.createElement(_TextField2.default, { - name: 'autoComplete', - ref: function ref(_ref3) { - return _this4.searchTextField = _ref3; + this.showAutocomplete() && _react2.default.createElement(_TextField2.default, { + ref: function ref(_ref6) { + return _this4.searchTextField = _ref6; }, value: this.state.searchText, hintText: hintTextAutocomplete, @@ -512,20 +581,28 @@ var SelectField = function (_Component) { _react2.default.createElement( 'div', { - ref: function ref(_ref4) { - return _this4.menu = _ref4; + ref: function ref(_ref7) { + return _this4.menu = _ref7; }, + onKeyDown: this.handleMenuKeyDown, style: _extends({ width: menuWidth }, menuStyle) }, - menuItems.length ? _react2.default.createElement( + menuItems.length + /* ?