UNPKG

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