diff --git a/src/ComboBox/examples.js b/src/ComboBox/examples.js new file mode 100644 index 0000000..845d8c7 --- /dev/null +++ b/src/ComboBox/examples.js @@ -0,0 +1,40 @@ +import React from 'react'; +import App from '../App'; +import ComboBox from './index.js'; +import Item from '../Item'; + +class ComboBoxExample extends React.Component { + constructor() { + super(); + this.state = { + value: ['1902'], + }; + this.years = Array.from({ length: 150 }, (_, i) => {1900 + i}); + } + + render() { + return ( + this.setState({ value })} + > + {this.years} + + ); + } +} + +function Example() { + return ( + +
+ +
+
+ ); +} + +export default Example; diff --git a/src/ComboBox/index.js b/src/ComboBox/index.js new file mode 100644 index 0000000..e14f539 --- /dev/null +++ b/src/ComboBox/index.js @@ -0,0 +1,132 @@ +import React from 'react'; +import Select from '../Select'; +import Menu from '../Menu'; +import TextInput from '../TextInput'; + +class ComboBox extends Select { + constructor(props) { + super(props); + + this.state = { + ...this.state, + textInputValue: '', + }; + + this.onKeyDown = this.onKeyDown.bind(this); + this.onTextInputChange = this.onTextInputChange.bind(this); + } + + /** @override */ + componentWillUpdate(_, nextState) { + if (nextState.popupVisible !== this.state.popupVisible && !nextState.popupVisible) { + this._wasPopupVisible = false; + this.setState({ textInputValue: '' }); + this.getMenu().onBlur(); + } + } + + /** @override */ + componentDidUpdate(prevProps) { + this._shouldRestoreControlFocus = false; + + if (!this._wasPopupVisible && this.state.popupVisible) { + this._wasPopupVisible = true; + this.updateMenuWidth(); + this.getMenu().onFocus(); + } else if (this.props.value !== prevProps.value) { + this.updateMenuWidth(); + } + } + + /** @override */ + renderControl() { + return this.state.popupVisible ? this.renderTextInput() : this.renderButton() + } + + renderTextInput() { + const { theme, size } = this.props; + const { textInputValue } = this.state; + const placeholder = this.props.value[0]; + + return ( + + ); + } + + /** @override */ + renderMenu() { + const { theme, size, disabled, mode, value } = this.props; + const { menuHeight } = this.state; + // NOTE: "nullify" the tabIndex of the Menu + const tabIndex = null; + + return ( + + {this.props.children} + + ); + } + + /** @override */ + onKeyDown(e) { + super.onKeyDown(e); + + if (this.state.popupVisible) { + // NODE(narqo@): Proxy "some" keyDown events to the Menu + if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter') { + this.getMenu().onKeyDown(e); + } else if (e.key === 'Tab') { + this._shouldRestoreControlFocus = true; + this.setState({ popupVisible: false }); + } + } + } + + onTextInputChange(value) { + if (this.props.onTextInputChange) { + this.props.onTextInputChange(value, this.props); + } + this.setState({ textInputValue: value }); + this.getMenu().hoverItemByText(value); + } +} + +ComboBox.contextTypes = { + theme: React.PropTypes.string, +}; + +ComboBox.propTypes = { + theme: React.PropTypes.string, + size: React.PropTypes.string, + name: React.PropTypes.string, + value: React.PropTypes.any, + placeholder: React.PropTypes.string, + disabled: React.PropTypes.bool, + maxHeight: React.PropTypes.number, + onChange: React.PropTypes.func, + onTextInputChange: React.PropTypes.func, +}; + +ComboBox.defaultProps = { + placeholder: '—', + mode: 'radio', + maxHeight: 0, + onChange() {}, +}; + +export default ComboBox; diff --git a/src/Menu/MenuItem/index.js b/src/Menu/MenuItem/index.js index 0368627..fb3a007 100644 --- a/src/Menu/MenuItem/index.js +++ b/src/Menu/MenuItem/index.js @@ -90,6 +90,7 @@ MenuItem.propTypes = { type: React.PropTypes.string, disabled: React.PropTypes.bool, checked: React.PropTypes.bool, + hovered: React.PropTypes.bool, onClick: React.PropTypes.func, onHover: React.PropTypes.func, }; diff --git a/src/Menu/index.js b/src/Menu/index.js index 15f0d27..50196e2 100644 --- a/src/Menu/index.js +++ b/src/Menu/index.js @@ -5,6 +5,9 @@ import Item from '../Item'; import Group from '../Group'; import MenuItem from './MenuItem'; +const TIMEOUT_KEYBOARD_SEARCH = 1500; +const KEY_CODE_SPACE = 32; + function appendItemToCache(item, cache) { if (Component.is(item, Item)) { cache.push(item); @@ -15,21 +18,31 @@ class Menu extends Component { constructor(props) { super(props); + const value = this._validateValue(this.props.value); + this.state = { ...this.state, - value: this._validateValue(this.props.value), + value, hoveredIndex: null, + focusedIndex: null, }; this._cachedChildren = null; this._hoveredItemIndex = null; this._shouldScrollToItem = false; + this._lastTyping = { + char: '', + text: '', + index: 0, + time: 0, + }; this.onMouseUp = this.onMouseUp.bind(this); this.onMouseDown = this.onMouseDown.bind(this); this.onFocus = this.onFocus.bind(this); this.onBlur = this.onBlur.bind(this); this.onKeyDown = this.onKeyDown.bind(this); + this.onKeyPress = this.onKeyPress.bind(this); this.onItemClick = this.onItemClick.bind(this); this.onItemHover = this.onItemHover.bind(this); } @@ -40,6 +53,11 @@ class Menu extends Component { if (this.props.value !== this.state.value) { this.props.onChange(this.state.value, this.props); } + + const selectedIdx = this._getFirstSelectedChildIndex(); + if (selectedIdx) { + this.setState({ focusedIndex: selectedIdx }); + } } componentDidMount() { @@ -119,6 +137,20 @@ class Menu extends Component { return null; } + _getFirstSelectedChildIndex() { + const { value } = this.state; + const children = this._getChildren(); + + for (let i = 0; i < children.length; i++) { + const item = children[i]; + if (!item.props.disabled && value.indexOf(item.props.value) !== -1) { + return i; + } + } + + return this._getFirstEnabledChildIndex(); + } + _getFirstEnabledChildIndex() { return this._getChildren().indexOf(this._getFirstEnabledChild()); } @@ -204,10 +236,12 @@ class Menu extends Component { } return ( -
TIMEOUT_KEYBOARD_SEARCH || isSameChar) { + lastTyping.text = char; + } else { + lastTyping.text += char; + } + + lastTyping.char = char; + lastTyping.time = timeNow; + + let nextIndex = lastTyping.index; + + // If key is pressed again, then continue to search to next menu item + if (isSameChar && Component.textValue(children[nextIndex]).search(lastTyping.char) === 0) { + nextIndex = nextIndex >= children.length - 1 ? 0 : nextIndex + 1; + } + + // 2 passes: from index to children.length and from 0 to index. + let i = nextIndex; + let len = children.length; + while (i < len) { + if (this._checkItemMatchText(children[i], lastTyping.text)) { + lastTyping.index = i; + return i; + } + + i++; + + if (i === children.length) { + i = 0; + len = nextIndex; + } + } + + return null; + } + + hoverNextItem(dir) { + const children = this._getChildren(); + const len = children.length; + if (!len) { + return; + } + + let nextIndex = this.state.hoveredIndex; + do { + nextIndex = (nextIndex + len + dir) % len; + if (nextIndex === this.state.hoveredIndex) { + return; + } + } while (children[nextIndex].props.disabled); + + if (nextIndex !== null) { + this.hoverItemByIndex(nextIndex); + } + } + + hoverItemByText(text) { + const children = this._getChildren(); + let i = 0; + while (i < children.length) { + if (this._checkItemMatchText(children[i], text)) { + this.hoverItemByIndex(i); + return; + } + i++; + } + this.hoverItemByIndex(0); + } + + hoverItemByIndex(index) { + this._hoveredItemIndex = index; + this._shouldScrollToItem = true; + this.setState({ hoveredIndex: index }); + } + + _checkItemMatchText(item, text) { + return !item.props.disabled && + Component.textValue(item) + .toLowerCase() + .search(text) === 0; + } + onItemHover(hovered, itemProps) { this.setState({ hoveredIndex: hovered ? itemProps.index : null, @@ -348,7 +478,7 @@ class Menu extends Component { if (!this._mousePressed) { let hoveredIndex = this._hoveredItemIndex; if (hoveredIndex === null) { - hoveredIndex = this._getFirstEnabledChildIndex() + hoveredIndex = this._getFirstSelectedChildIndex(); } if (hoveredIndex !== this.state.hoveredIndex) { this._hoveredItemIndex = hoveredIndex; @@ -364,38 +494,19 @@ class Menu extends Component { focused: false, hoveredIndex: null, }); - - this.dispatchFocusChange(false); + this._hoveredItemIndex = null; + this.dispatchFocusChange(); } onKeyDown(e) { - if (this.props.disabled || !this.state.focused) { + if (this.props.disabled) { return; } if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); - const children = this._getChildren(); - const len = children.length; - if (!len) { - return; - } - - const dir = (e.key === 'ArrowDown' ? 1 : -1); - let nextIndex = this.state.hoveredIndex; - do { - nextIndex = (nextIndex + len + dir) % len; - if (nextIndex === this.state.hoveredIndex) { - return; - } - } while (children[nextIndex].props.disabled); - - if (nextIndex !== null) { - this._hoveredItemIndex = nextIndex; - this._shouldScrollToItem = true; - this.setState({ hoveredIndex: nextIndex }); - } + this.hoverNextItem(e.key === 'ArrowDown' ? 1 : -1); } else if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); @@ -409,6 +520,18 @@ class Menu extends Component { } } + onKeyPress(e) { + if (this.props.disabled || !this.state.focused) { + return; + } + + const hoveredIndex = this.searchIndexByKeyboardEvent(e); + + if (hoveredIndex !== null) { + this.hoverItemByIndex(hoveredIndex); + } + } + onItemCheck(index) { const { mode } = this.props; if (!mode) { diff --git a/src/Select/examples.js b/src/Select/examples.js index d0f263c..8b47509 100644 --- a/src/Select/examples.js +++ b/src/Select/examples.js @@ -8,7 +8,7 @@ class SelectExample extends React.Component { constructor(props) { super(props); this.state = { - value: ['1'], + value: ['10'], clicks: 0, }; this.onSelectChange = this.onSelectChange.bind(this); diff --git a/src/Select/index.js b/src/Select/index.js index 901d341..3e0bb60 100644 --- a/src/Select/index.js +++ b/src/Select/index.js @@ -1,6 +1,5 @@ import React from 'react'; import ReactDOM from 'react-dom'; - import Component from '../Component'; import Button from '../Button'; import Popup from '../Popup'; @@ -20,14 +19,12 @@ class Select extends Component { }; this._wasPopupVisible = false; - this._shouldRestoreButtonFocus = false; - this._preventTrapMenuFocus = false; + this._shouldRestoreControlFocus = false; this._cachedChildren = null; + this.onKeyDown = this.onKeyDown.bind(this); this.onButtonClick = this.onButtonClick.bind(this); - this.onButtonKeyDown = this.onButtonKeyDown.bind(this); this.onMenuChange = this.onMenuChange.bind(this); - this.onMenuFocusChange = this.onMenuFocusChange.bind(this); this.onMenuItemClick = this.onMenuItemClick.bind(this); this.onMenuKeyDown = this.onMenuKeyDown.bind(this); this.onPopupRequestHide = this.onPopupRequestHide.bind(this); @@ -43,13 +40,11 @@ class Select extends Component { componentWillUpdate(_, nextState) { if (nextState.popupVisible !== this.state.popupVisible && !nextState.popupVisible) { this._wasPopupVisible = false; - this.setState({ menuFocused: false }); } } componentDidUpdate(prevProps) { - this._shouldRestoreButtonFocus = false; - this._preventTrapMenuFocus = false; + this._shouldRestoreControlFocus = false; // FIXME(narqo@): an ugly trick to prevent DOM-focus from jumping to the bottom of the page on first open // @see https://github.com/narqo/react-islands/issues/41 @@ -58,7 +53,6 @@ class Select extends Component { this.updateMenuWidth(); process.nextTick(() => { this.setState({ menuFocused: true }); - this.trapMenuFocus() }); } else if (this.props.value !== prevProps.value) { this.updateMenuWidth(); @@ -72,11 +66,11 @@ class Select extends Component { render() { return ( -
+
{this.renderInputs()} - {this.renderButton()} + {this.renderControl()} 0 ); return ( -