UNPKG

14.6 kBJavaScriptView Raw
1'use strict';
2
3var React = require('react');
4var ReactDOM = require('react-dom');
5var assign = require('object-assign');
6
7var keyboard = {
8 space: 32,
9 enter: 13,
10 escape: 27,
11 tab: 9,
12 upArrow: 38,
13 downArrow: 40
14};
15
16var doesOptionMatch = function doesOptionMatch(option, s) {
17 s = s.toLowerCase();
18
19 // Check that passed in option wraps a string, if it wraps a component, match val
20 if (typeof option.props.children === 'string') {
21 return option.props.children.toLowerCase().indexOf(s) === 0;
22 } else {
23 return option.props.value.toLowerCase().indexOf(s) === 0;
24 }
25};
26
27var classBase = React.createClass({
28 displayName: 'RadonSelect',
29 propTypes: {
30 children: function children(props, propName) {
31 if (!props[propName] || !Array.isArray(props[propName])) {
32 return new Error('children must be an array of RadonSelect.Option');
33 }
34
35 props[propName].forEach(function (child) {
36 if (child.type.displayName !== 'RadonSelectOption') {
37 return new Error('children must be an array of RadonSelect.Option');
38 }
39 });
40 },
41 selectName: React.PropTypes.string.isRequired,
42 defaultValue: React.PropTypes.string,
43 ariaLabel: React.PropTypes.string,
44 placeholderText: React.PropTypes.string,
45 typeaheadDelay: React.PropTypes.number,
46 showCurrentOptionWhenOpen: React.PropTypes.bool,
47 disabled: React.PropTypes.bool,
48 onChange: React.PropTypes.func,
49 onBlur: React.PropTypes.func,
50 // Should there just be a baseClassName that these are derived from?
51 className: React.PropTypes.string,
52 openClassName: React.PropTypes.string,
53 focusClassName: React.PropTypes.string,
54 listClassName: React.PropTypes.string,
55 disabledClassName: React.PropTypes.string,
56 currentOptionClassName: React.PropTypes.string,
57 hiddenSelectClassName: React.PropTypes.string,
58 currentOptionStyle: React.PropTypes.object,
59 optionListStyle: React.PropTypes.object
60 },
61 getDefaultProps: function getDefaultProps() {
62 return {
63 disabled: false,
64 typeaheadDelay: 1000,
65 showCurrentOptionWhenOpen: false,
66 onChange: function onChange() {},
67 onBlur: function onBlur() {},
68 className: 'radon-select',
69 openClassName: 'open',
70 focusClassName: 'focus',
71 listClassName: 'radon-select-list',
72 currentOptionClassName: 'radon-select-current',
73 hiddenSelectClassName: 'radon-select-hidden-select',
74 disabledClassName: 'radon-select-disabled',
75 currentOptionStyle: {},
76 optionListStyle: {}
77 };
78 },
79 getInitialState: function getInitialState() {
80 var initialIndex = this.props.defaultValue !== undefined ? this.getValueIndex(this.props.defaultValue) : -1;
81
82 var defaultValue = initialIndex === -1 ? this.props.children[0].props.value : this.props.defaultValue;
83
84 return {
85 selectedOptionIndex: initialIndex === -1 ? false : initialIndex,
86 selectedOptionVal: defaultValue,
87 open: false,
88 focus: false
89 };
90 },
91 getValueIndex: function getValueIndex(val) {
92 for (var i = 0; i < this.props.children.length; ++i) {
93 if (this.props.children[i].props.value === val) {
94 return i;
95 }
96 }
97 return -1;
98 },
99 getValue: function getValue() {
100 return this.state.selectedOptionVal;
101 },
102 setValue: function setValue(val, silent) {
103 var index = this.getValueIndex(val);
104
105 if (index !== -1) {
106 this.setState({
107 selectedOptionIndex: index,
108 selectedOptionVal: val
109 }, function () {
110 if (!silent) {
111 this.props.onChange(this.state.selectedOptionVal);
112 }
113 });
114 }
115 },
116 onChange: function onChange() {
117 this.props.onChange(this.state.selectedOptionVal);
118 },
119 moveIndexByOne: function moveIndexByOne(decrement) {
120 var selectedOptionIndex = this.state.selectedOptionIndex || 0;
121 // Don't go out of array bounds
122 if (decrement && this.state.selectedOptionIndex === 0 || !decrement && this.state.selectedOptionIndex === this.props.children.length - 1) {
123 return;
124 }
125
126 selectedOptionIndex += decrement ? -1 : 1;
127
128 this.setState({
129 selectedOptionIndex: selectedOptionIndex,
130 selectedOptionVal: this.props.children[selectedOptionIndex].props.value
131 }, function () {
132 this.onChange();
133
134 if (this.state.open) {
135 this.isFocusing = true;
136 this.focus(this.refs['option' + this.state.selectedOptionIndex]);
137 }
138 });
139 },
140 typeahead: function typeahead(character) {
141 var self = this;
142 var matchFound = false;
143 var currentIndex = 0;
144
145 // If we've got a selectedOptionIndex start at the next one (with wrapping), or start at 0
146 if (this.state.selectedOptionIndex !== false && this.state.selectedOptionIndex !== this.props.children.length - 1) {
147 currentIndex = this.state.selectedOptionIndex + 1;
148 }
149
150 clearTimeout(self.typeaheadCountdown);
151
152 this.typingAhead = true;
153 this.currentString = this.currentString ? this.currentString + character : character;
154
155 // Browser will match any other instance starting from the currently selected index and wrapping.
156 for (var i = currentIndex; i < this.props.children.length; i++) {
157 if (doesOptionMatch(this.props.children[i], this.currentString)) {
158 matchFound = i;
159 break;
160 }
161 }
162
163 // If we didn't find a match in the loop from current index to end, check from 0 to current index
164 if (!matchFound) {
165 for (var j = 0; j <= currentIndex; j++) {
166 if (doesOptionMatch(this.props.children[j], this.currentString)) {
167 matchFound = j;
168 break;
169 }
170 }
171 }
172
173 if (matchFound !== false) {
174 this.setState({
175 selectedOptionIndex: matchFound,
176 selectedOptionVal: this.props.children[matchFound].props.value
177 }, function () {
178 this.onChange();
179
180 if (this.state.open) {
181 this.isFocusing = true;
182 this.refs['option' + this.state.selectedOptionIndex].focus();
183 }
184 });
185 }
186
187 self.typeaheadCountdown = setTimeout(function () {
188 self.typeaheadCountdown = undefined;
189 self.typingAhead = false;
190 self.currentString = '';
191 }, this.props.typeaheadDelay);
192 },
193 toggleOpen: function toggleOpen() {
194 if (this.props.disabled) {
195 return;
196 }
197
198 this.isFocusing = false;
199
200 this.setState({
201 open: !this.state.open,
202 selectedOptionIndex: this.state.selectedOptionIndex || 0
203 }, function () {
204 this.onChange();
205
206 if (!this.state.open) {
207 this.focus(this.refs['currentOption']); //eslint-disable-line dot-notation
208 } else {
209 this.focus(this.refs['option' + (this.state.selectedOptionIndex || 0)]);
210 }
211 });
212 },
213 onFocus: function onFocus() {
214 this.setState({
215 focus: true
216 });
217 },
218 onBlur: function onBlur() {
219 var _this = this;
220
221 this.setState({
222 focus: false
223 }, function () {
224 _this.props.onBlur();
225 });
226 },
227
228 // Arrow keys are only captured by onKeyDown not onKeyPress
229 // http://stackoverflow.com/questions/5597060/detecting-arrow-key-presses-in-javascript
230 onKeyDown: function onKeyDown(ev) {
231 var isArrowKey = ev.keyCode === keyboard.upArrow || ev.keyCode === keyboard.downArrow;
232
233 if (this.state.open) {
234 ev.preventDefault();
235
236 //charcode is enter, esc, or not typingahead and space
237 if (ev.keyCode === keyboard.enter || ev.keyCode === keyboard.escape || !this.typingAhead && ev.keyCode === keyboard.space) {
238
239 this.toggleOpen();
240 } else if (isArrowKey) {
241 this.moveIndexByOne( /*decrement*/ev.keyCode === keyboard.upArrow);
242 // If not tab, assume alphanumeric
243 } else if (ev.keyCode !== keyboard.tab) {
244 this.typeahead(String.fromCharCode(ev.keyCode));
245 }
246 } else {
247 if (ev.keyCode === keyboard.space || isArrowKey) {
248 ev.preventDefault();
249 this.toggleOpen();
250 // If not tab, escape, or enter, assume alphanumeric
251 } else if (ev.keyCode !== keyboard.enter || ev.keyCode !== keyboard.escape || ev.keyCode !== keyboard.tab) {
252 this.typeahead(String.fromCharCode(ev.keyCode));
253 }
254 }
255 },
256 onClickOption: function onClickOption(index, ev) {
257 var child = this.refs['option' + index];
258
259 // Null safety here prevents an iOS-specific bug preventing selection of options
260 ev ? ev.preventDefault() : null;
261
262 this.setState({
263 selectedOptionIndex: index,
264 selectedOptionVal: child.props.value,
265 open: false
266 }, function () {
267 this.onChange();
268
269 this.refs['currentOption'].focus(); //eslint-disable-line dot-notation
270 });
271 },
272 onBlurOption: function onBlurOption() {
273 // Make sure we only catch blur that wasn't triggered by this component
274 if (this.isFocusing) {
275 this.isFocusing = false;
276
277 return;
278 }
279
280 var hoveredSelectEl = ReactDOM.findDOMNode(this).querySelector(':hover');
281 // Clicks on the scrollbar trigger blur, only test is hover.
282 // If the mouse is over the select, don't close the option list
283 if (hoveredSelectEl) {
284 return;
285 }
286
287 this.toggleOpen();
288 },
289 onMouseDown: function onMouseDown(ev) {
290 // Make sure that clicks on the scrollbar don't steal focus
291 if (this.state.open) {
292 ev.preventDefault();
293 }
294 },
295 getWrapperClasses: function getWrapperClasses() {
296 var wrapperClassNames = [this.props.className];
297
298 if (this.state.open) {
299 wrapperClassNames.push(this.props.openClassName);
300 }
301
302 if (this.state.focus) {
303 wrapperClassNames.push(this.props.focusClassName);
304 }
305
306 if (this.props.disabled) {
307 wrapperClassNames.push(this.props.disabledClassName);
308 }
309
310 return wrapperClassNames.join(' ');
311 },
312 focus: function focus(ref) {
313 ReactDOM.findDOMNode(ref).focus();
314 },
315 renderChild: function renderChild(child, index) {
316 return React.cloneElement(child, {
317 key: index,
318 ref: 'option' + index,
319 isActive: this.state.selectedOptionIndex === index,
320 onClick: this.onClickOption.bind(this, index),
321 onKeyDown: this.onKeyDown,
322 automationId: (this.props.automationId ? this.props.automationId : 'select') + '-option-' + index
323 });
324 },
325 renderSpacerChild: function renderSpacerChild(child, index) {
326 return React.cloneElement(child, {
327 key: index,
328 style: {
329 visibility: 'hidden',
330 height: '0 !important',
331 paddingTop: 0,
332 paddingBottom: 0
333 }
334 });
335 },
336 render: function render() {
337 var hiddenListStyle = { visibility: 'hidden' };
338 var selectedOptionContent = this.state.selectedOptionIndex !== false && this.props.children[this.state.selectedOptionIndex].props.children;
339
340 if (this.props.optionListStyle) {
341 hiddenListStyle = assign({}, this.props.optionListStyle, hiddenListStyle);
342 }
343
344 return React.createElement(
345 'div',
346 {
347 className: this.getWrapperClasses(),
348 onMouseDown: this.onMouseDown,
349 style: this.props.style },
350 this.props.showCurrentOptionWhenOpen || !this.state.open ? React.createElement(
351 'div',
352 {
353 ref: 'currentOption',
354 className: this.props.currentOptionClassName,
355 tabIndex: 0,
356 'data-automation-id': this.props.automationId,
357 role: 'button',
358 onFocus: this.onFocus,
359 onKeyDown: this.onKeyDown,
360 onBlur: this.onBlur,
361 onClick: this.toggleOpen,
362 'aria-expanded': this.state.open,
363 style: this.props.currentOptionStyle },
364 selectedOptionContent || this.props.placeholderText || this.props.children[0].props.children
365 ) : '',
366 this.state.open ? React.createElement(
367 'div',
368 { className: this.props.listClassName, onBlur: this.onBlurOption, style: this.props.optionListStyle },
369 React.Children.map(this.props.children, this.renderChild)
370 ) : '',
371 React.createElement(
372 'select',
373 {
374 disabled: 'true',
375 name: this.props.selectName,
376 value: this.state.selectedOptionVal,
377 className: this.props.hiddenSelectClassName,
378 tabIndex: -1,
379 'aria-label': this.props.ariaLabel ? this.props.ariaLabel : this.props.selectName,
380 'aria-hidden': true },
381 React.Children.map(this.props.children, function (child, index) {
382 return React.createElement(
383 'option',
384 { key: index, value: child.props.value },
385 child.props.value
386 );
387 })
388 ),
389 React.createElement(
390 'span',
391 { 'aria-hidden': true, style: hiddenListStyle, tabIndex: -1 },
392 React.createElement(
393 'div',
394 { style: { visibility: 'hidden', height: 0, position: 'relative' } },
395 React.Children.map(this.props.children, this.renderSpacerChild)
396 )
397 )
398 );
399 }
400});
401
402classBase.Option = React.createClass({
403 displayName: 'RadonSelectOption',
404 propTypes: {
405 // TODO: Disabled
406 value: React.PropTypes.string.isRequired,
407 children: React.PropTypes.oneOfType([React.PropTypes.node, React.PropTypes.string]).isRequired,
408 onClick: React.PropTypes.func,
409 automationId: React.PropTypes.string
410 },
411 getDefaultProps: function getDefaultProps() {
412 return {
413 value: '',
414 automationId: undefined,
415 className: 'radon-select-option',
416 activeClassName: 'active',
417 hoverClassName: 'hover',
418 onClick: function onClick() {}
419 };
420 },
421 getInitialState: function getInitialState() {
422 return {
423 hovered: false
424 };
425 },
426 getClassNames: function getClassNames() {
427 var classNames = [this.props.className];
428
429 if (this.props.isActive) {
430 classNames.push(this.props.activeClassName);
431 }
432
433 if (this.state.hovered) {
434 classNames.push(this.props.hoverClassName);
435 }
436
437 return classNames.join(' ');
438 },
439 setHover: function setHover(isHover) {
440 this.setState({
441 hovered: isHover
442 });
443 },
444 render: function render() {
445 return (
446 // Safari ignores tabindex on buttons, and Firefox ignores tabindex on anchors
447 // use a <div role="button">.
448 React.createElement(
449 'div',
450 {
451 role: 'button',
452 className: this.getClassNames(),
453 'data-automation-id': this.props.automationId,
454 tabIndex: -1,
455 onMouseDown: this.props.onClick,
456 onMouseEnter: this.setHover.bind(this, true),
457 onMouseLeave: this.setHover.bind(this, false),
458 onKeyDown: this.props.onKeyDown,
459 style: this.props.style },
460 this.props.children
461 )
462 );
463 }
464});
465
466module.exports = classBase;
\No newline at end of file