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 (
+
+ );
+ }
+
+ /** @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 (
-