UNPKG

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