UNPKG

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