UNPKG

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