Skip to content

Instantly share code, notes, and snippets.

@chrispcode
Created December 7, 2018 14:37
Show Gist options
  • Save chrispcode/bbb8247f97f7ba8a1c59dd4fa1b418ae to your computer and use it in GitHub Desktop.
Save chrispcode/bbb8247f97f7ba8a1c59dd4fa1b418ae to your computer and use it in GitHub Desktop.
// eslint-disable-next-line
import React, { PureComponent, KeyboardEvent, FocusEvent, MouseEvent } from 'react';
import { autobind } from 'core-decorators';
import { Option, OptionWrap, Selected } from './styled';
import { Label, Wrap } from '../Input/styled';
const keyCodes = {
enter: 13,
space: 32,
arrowUp: 38,
arrowDown: 40,
tab: 9,
esc: 27,
home: 36,
end: 35
};
type Option = {
key: string
value: string
};
type Props = {
label: string
value: string
options: Option[]
onChange: (selectedOption: Option) => void
};
const defaultState = {
isFocused: false,
isSelectOpen: false,
selectedOption: {
key: '',
value: ''
}
};
type State = Readonly<typeof defaultState>;
@autobind
class Select extends PureComponent<Props, State> {
state = defaultState;
private wrapRef: any = React.createRef<HTMLDivElement>();
private optionWrapRef: any = React.createRef<HTMLUListElement>();
getNextOption() {
const { selectedOption } = this.state;
const { options } = this.props;
const nextIndex = options.indexOf(selectedOption) + 1;
return options[nextIndex] || selectedOption;
}
getPreviousOption() {
const { selectedOption } = this.state;
const { options } = this.props;
const previousIndex = options.indexOf(selectedOption) - 1;
return options[previousIndex] || selectedOption;
}
private optionRefs: {
[string: string]: any
};
triggerOptionSelected() {
const { selectedOption } = this.state;
const { onChange } = this.props;
onChange(selectedOption);
}
handleWrapKeyPress(event: KeyboardEvent) {
if (event.keyCode === keyCodes.enter || event.keyCode === keyCodes.space) {
this.handleOpen();
}
}
handleWrapFocus(event: FocusEvent) {
event.stopPropagation();
this.setState(() => ({
isFocused: true
}));
}
handleOpen() {
const { selectedOption } = this.state;
const { options } = this.props;
const firstOption = options[0];
const optionToSelect = selectedOption.key
? selectedOption
: firstOption;
setImmediate(() => {
this.setState(() => ({
isSelectOpen: true,
selectedOption: optionToSelect
}));
this.optionWrapRef.current.focus();
this.optionRefs[optionToSelect.key].scrollIntoView();
});
}
handleBlur(event: FocusEvent) {
event.stopPropagation();
// if IE11
let target: EventTarget | Element | null = event.relatedTarget;
if (target === null) {
target = document.activeElement;
}
const wrapNode = this.wrapRef.current;
if (wrapNode && !wrapNode.contains(target)) {
this.setState(() => ({
isSelectOpen: false
}));
this.triggerOptionSelected();
}
this.setState(() => ({
isFocused: false
}));
}
handleOptionWrapClose() {
this.setState(() => ({
isSelectOpen: false
}));
setImmediate(() => {
this.wrapRef.current.focus();
});
}
handleOptionWrapKeyDown(event: KeyboardEvent) {
event.stopPropagation();
switch (event.keyCode) {
case keyCodes.arrowDown: {
const nextOption = this.getNextOption();
this.setState({
selectedOption: nextOption
});
break;
}
case keyCodes.arrowUp: {
const previousOption = this.getPreviousOption();
this.setState({
selectedOption: previousOption
});
break;
}
case keyCodes.enter:
case keyCodes.esc:
case keyCodes.space: {
this.handleOptionWrapClose();
this.triggerOptionSelected();
break;
}
case keyCodes.tab: {
if (event.shiftKey) {
this.handleOptionWrapClose();
this.triggerOptionSelected();
}
break;
}
case keyCodes.home: {
const { options } = this.props;
const firstOption = options[0];
this.setState({
selectedOption: firstOption
});
break;
}
case keyCodes.end: {
const { options } = this.props;
const lastOption = options[options.length - 1];
this.setState({
selectedOption: lastOption
});
break;
}
default:
}
}
handleOptionClick(
option: Option
) {
return (event: MouseEvent) => {
event.stopPropagation();
this.wrapRef.current.focus();
this.setState(() => ({
isSelectOpen: false,
selectedOption: option
}));
this.triggerOptionSelected();
};
}
renderOptions(labelId: string) {
const { options } = this.props;
const { isSelectOpen, selectedOption } = this.state;
return (
<OptionWrap
tabIndex={-1}
isSelectOpen={isSelectOpen}
ref={this.optionWrapRef}
role="listbox"
aria-labelledby={labelId}
aria-activedescendant={selectedOption.key}
onKeyDown={this.handleOptionWrapKeyDown}
>
{options.map(option => (
<Option
id={option.key}
key={option.key}
role="option"
aria-selected={option.key === selectedOption.key}
onClick={this.handleOptionClick(option)}
ref={(ref: any) => {
this.optionRefs = {
...this.optionRefs,
[option.key]: ref
};
}}
>
{option.value}
</Option>
))}
</OptionWrap>
);
}
render() {
const {
isFocused,
selectedOption,
isSelectOpen
} = this.state;
const { label } = this.props;
const common = {
isFocused,
currentValue: selectedOption.value
};
const labelId = `${label.replace(/\s/g, '')}-label`;
const buttonId = `${label.replace(/\s/g, '')}-button`;
const selectedTextId = `${label.replace(/\s/g, '')}-text`;
return (
<Wrap
as="div"
id={buttonId}
tabIndex={0}
ref={this.wrapRef}
role="button"
aria-haspopup="listbox"
aria-expanded={isSelectOpen}
aria-labelledby={`${labelId} ${buttonId} ${selectedTextId}`}
onFocus={this.handleWrapFocus}
onClick={this.handleOpen}
onKeyDown={this.handleWrapKeyPress}
onBlur={this.handleBlur}
{...common}
>
<Label as="span" id={labelId} {...common}>
{label}
</Label>
{ isSelectOpen && this.renderOptions(labelId) }
<Selected id={selectedTextId}>
{ selectedOption.value }
</Selected>
</Wrap>
);
}
}
export default Select;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment