UNPKG

13.7 kBJavaScriptView 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 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 // 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: function getDefaultProps() {
60 return {
61 typeaheadDelay: 1000,
62 showCurrentOptionWhenOpen: false,
63 onChange: function onChange() {},
64 onBlur: function onBlur() {},
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: function getInitialState() {
76 return {
77 selectedOptionIndex: false,
78 selectedOptionVal: this.props.children[0].props.value,
79 open: false,
80 focus: false
81 };
82 },
83 getValue: function getValue() {
84 return this.state.selectedOptionVal;
85 },
86 setValue: function 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: function onChange() {
103 this.props.onChange(this.state.selectedOptionVal);
104 },
105 moveIndexByOne: function moveIndexByOne(decrement) {
106 var selectedOptionIndex = this.state.selectedOptionIndex || 0;
107 // Don't go out of array bounds
108 if (decrement && this.state.selectedOptionIndex === 0 || !decrement && this.state.selectedOptionIndex === this.props.children.length - 1) {
109 return;
110 }
111
112 selectedOptionIndex += decrement ? -1 : 1;
113
114 this.setState({
115 selectedOptionIndex: selectedOptionIndex,
116 selectedOptionVal: this.props.children[selectedOptionIndex].props.value
117 }, function () {
118 this.onChange();
119
120 if (this.state.open) {
121 this.isFocusing = true;
122 React.findDOMNode(this.refs['option' + this.state.selectedOptionIndex]).focus();
123 }
124 });
125 },
126 typeahead: function typeahead(character) {
127 var self = this;
128 var matchFound = false;
129 var currentIndex = 0;
130
131 // If we've got a selectedOptionIndex start at the next one (with wrapping), or start at 0
132 if (this.state.selectedOptionIndex !== false && 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: function 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: function onFocus() {
196 this.setState({
197 focus: true
198 });
199 },
200 onBlur: function onBlur() {
201 var _this = this;
202
203 this.setState({
204 focus: false
205 }, function () {
206 _this.props.onBlur();
207 });
208 },
209 // Arrow keys are only captured by onKeyDown not onKeyPress
210 // http://stackoverflow.com/questions/5597060/detecting-arrow-key-presses-in-javascript
211 onKeyDown: function onKeyDown(ev) {
212 var isArrowKey = ev.keyCode === keyboard.upArrow || ev.keyCode === keyboard.downArrow;
213
214 if (this.state.open) {
215 ev.preventDefault();
216
217 //charcode is enter, esc, or not typingahead and space
218 if (ev.keyCode === keyboard.enter || ev.keyCode === keyboard.escape || !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 || ev.keyCode !== keyboard.escape || ev.keyCode !== keyboard.tab) {
233 this.typeahead(String.fromCharCode(ev.keyCode));
234 }
235 }
236 },
237 onClickOption: function 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: function onBlurOption() {
253 // Make sure we only catch blur that wasn't triggered by this component
254 if (this.isFocusing) {
255 this.isFocusing = false;
256 } else {
257 this.toggleOpen();
258 }
259 },
260 getWrapperClasses: function getWrapperClasses() {
261 var wrapperClassNames = [this.props.className];
262
263 if (this.state.open) {
264 wrapperClassNames.push(this.props.openClassName);
265 }
266
267 if (this.state.focus) {
268 wrapperClassNames.push(this.props.focusClassName);
269 }
270
271 return wrapperClassNames.join(' ');
272 },
273 renderChild: function renderChild(child, index) {
274 return React.cloneElement(child, {
275 key: index,
276 ref: 'option' + index,
277 isActive: this.state.selectedOptionIndex === index,
278 onClick: this.onClickOption.bind(this, index),
279 onKeyDown: this.onKeyDown,
280 automationId: (this.props.automationId ? this.props.automationId : 'select') + '-option-' + index
281 });
282 },
283 renderSpacerChild: function renderSpacerChild(child, index) {
284 return React.cloneElement(child, {
285 key: index,
286 style: {
287 visibility: 'hidden',
288 height: '0 !important',
289 paddingTop: 0,
290 paddingBottom: 0
291 }
292 });
293 },
294 render: function render() {
295 var hiddenListStyle = { visibility: 'hidden' };
296 var selectedOptionContent = this.state.selectedOptionIndex !== false && this.props.children[this.state.selectedOptionIndex].props.children;
297
298 if (this.props.optionListStyle) {
299 hiddenListStyle = assign({}, this.props.optionListStyle, hiddenListStyle);
300 }
301
302 return React.createElement(
303 'div',
304 {
305 className: this.getWrapperClasses(),
306 style: this.props.style },
307 this.props.showCurrentOptionWhenOpen || !this.state.open ? React.createElement(
308 'div',
309 {
310 ref: 'currentOption',
311 className: this.props.currentOptionClassName,
312 tabIndex: 0,
313 'data-automation-id': this.props.automationId,
314 role: 'button',
315 onFocus: this.onFocus,
316 onKeyDown: this.onKeyDown,
317 onBlur: this.onBlur,
318 onClick: this.toggleOpen,
319 'aria-expanded': this.state.open,
320 style: this.props.currentOptionStyle },
321 selectedOptionContent || this.props.placeholderText || this.props.children[0].props.children
322 ) : '',
323 this.state.open ? React.createElement(
324 'div',
325 { className: this.props.listClassName, onBlur: this.onBlurOption, style: this.props.optionListStyle },
326 React.Children.map(this.props.children, this.renderChild)
327 ) : '',
328 React.createElement(
329 'select',
330 { name: this.props.selectName, value: this.state.selectedOptionVal, className: this.props.hiddenSelectClassName, tabIndex: -1, 'aria-hidden': true },
331 React.Children.map(this.props.children, function (child, index) {
332 return React.createElement(
333 'option',
334 { key: index, value: child.props.value },
335 child.props.children
336 );
337 })
338 ),
339 React.createElement(
340 'span',
341 { 'aria-hidden': true, style: hiddenListStyle, tabIndex: -1 },
342 React.createElement(
343 'div',
344 { style: { visibility: 'hidden', height: 0, position: 'relative' } },
345 React.Children.map(this.props.children, this.renderSpacerChild)
346 )
347 )
348 );
349 }
350});
351
352classBase.Option = React.createClass({
353 displayName: 'RadonSelectOption',
354 propTypes: {
355 // TODO: Disabled
356 value: React.PropTypes.string.isRequired,
357 children: React.PropTypes.string.isRequired,
358 onClick: React.PropTypes.func,
359 automationId: React.PropTypes.string
360 },
361 getDefaultProps: function getDefaultProps() {
362 return {
363 value: '',
364 automationId: undefined,
365 className: 'radon-select-option',
366 activeClassName: 'active',
367 hoverClassName: 'hover',
368 onClick: function onClick() {}
369 };
370 },
371 getInitialState: function getInitialState() {
372 return {
373 hovered: false
374 };
375 },
376 getClassNames: function getClassNames() {
377 var classNames = [this.props.className];
378
379 if (this.props.isActive) {
380 classNames.push(this.props.activeClassName);
381 }
382
383 if (this.state.hovered) {
384 classNames.push(this.props.hoverClassName);
385 }
386
387 return classNames.join(' ');
388 },
389 setHover: function setHover(isHover) {
390 this.setState({
391 hovered: isHover
392 });
393 },
394 tap: function tap() {
395 // Call onClick indirectly so that React's Synthetic Touch Event doesn't propagate.
396 // The resulting behavior should be that an options dropdown list can be scrolled
397 // and only selects an option when a user has tapped an option without dragging the parent.
398 this.props.onClick();
399 },
400 render: function render() {
401 return(
402 // Safari ignores tabindex on buttons, and Firefox ignores tabindex on anchors
403 // use a <div role="button">.
404 React.createElement(
405 'div',
406 {
407 role: 'button',
408 className: this.getClassNames(),
409 'data-automation-id': this.props.automationId,
410 tabIndex: -1,
411
412 // This is a workaround for a long-standing iOS/React issue with click events.
413 // See https://github.com/facebook/react/issues/134 for more information.
414 onTouchStart: this.tap,
415
416 onMouseDown: this.props.onClick,
417 onMouseEnter: this.setHover.bind(this, true),
418 onMouseLeave: this.setHover.bind(this, false),
419 onKeyDown: this.props.onKeyDown,
420 style: this.props.style },
421 this.props.children
422 )
423 );
424 }
425});
426
427module.exports = classBase;
\No newline at end of file