Skip to content

Instantly share code, notes, and snippets.

@souporserious
Created November 26, 2017 17:41
Show Gist options
  • Save souporserious/4b6422fed49801a38cda28110c392340 to your computer and use it in GitHub Desktop.
Save souporserious/4b6422fed49801a38cda28110c392340 to your computer and use it in GitHub Desktop.
const DATA_ATTRIBUTE_INDEX = 'data-focus-index'
const DATA_ATTRIBUTE_SKIP = 'data-focus-skip'
const focusableElements = [
'a[href]',
'area[href]',
'button:not([disabled])',
'embed',
'iframe',
'input:not([disabled])',
'object',
'select:not([disabled])',
'textarea:not([disabled])',
'*[tabindex]',
'*[contenteditable]',
]
/**
* FocusGroup is designed not to care about the component types it is wrapping. Due to this, you can pass
* whatever HTML tag you like into `props.component` or even a React component you've made elsewhere. Additional props
* passed to `<FocusGroup ...>` will be forwarded on to the component or HTML tag name you've supplied.
*
* The children, similarly, can be any type of component.
*/
class FocusGroup extends Component {
static mode = {
HORIZONTAL: 0,
VERTICAL: 1,
BOTH: 2,
}
static propTypes = {
/**
* any [React-supported attribute]
* (https://facebook.github.io/react/docs/tags-and-attributes.html#html-attributes)
*/
'*': PropTypes.any,
/**
* Any valid HTML tag name or a React component factory, anything that can be passed as the first argument to
* `React.createElement`
*/
component: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
/**
* Allows for a particular child to be initially reachable via tabbing; only applied during first render
*/
defaultActiveChildIndex: PropTypes.number,
/**
* controls which arrow key events are captured to move active focus within the list:
*
* Mode | Keys
* ---- | ----
* `FocusGroup.mode.BOTH` | ⬅️ ➡️ ⬆️ ⬇️
* `FocusGroup.mode.HORIZONTAL` | ⬅️ ➡️
* `FocusGroup.mode.VERTICAL` | ⬆️ ⬇️
*
* _Note: focus loops when arrowing past one of the boundaries; tabbing moves the user away from the list._
*/
mode: PropTypes.oneOf([
FocusGroup.mode.BOTH,
FocusGroup.mode.HORIZONTAL,
FocusGroup.mode.VERTICAL,
]),
}
static defaultProps = {
component: 'div',
defaultActiveChildIndex: 0,
mode: FocusGroup.mode.BOTH,
onKeyDown: () => {},
}
state = {
activeChildIndex: this.props.defaultActiveChildIndex,
children: [],
}
getFilteredChildren(props = this.props) {
return Children.toArray(props.children).filter(Boolean)
}
setActiveChildIndex() {
if (this.state.activeChildIndex !== 0) {
const childCount = Children.count(this.state.children)
if (childCount === 0) {
this.setState({ activeChildIndex: 0 })
} else if (this.state.activeChildIndex >= childCount) {
this.setState({ activeChildIndex: childCount - 1 })
}
}
}
componentWillMount() {
this.setState({ children: this.getFilteredChildren() })
}
componentWillReceiveProps(nextProps) {
if (this.props.children !== nextProps.children) {
this.setState(
{ children: this.getFilteredChildren(nextProps) },
this.setActiveChildIndex
)
} else {
this.setActiveChildIndex()
}
}
componentDidUpdate(prevProps, prevState) {
if (this.state.activeChildIndex !== prevState.activeChildIndex) {
this.setFocus(this.state.activeChildIndex)
}
}
setFocus(index) {
const childNode = this.wrapper.children[index]
console.log(childNode)
if (childNode && childNode.hasAttribute(DATA_ATTRIBUTE_SKIP)) {
this.moveFocus(
childNode.compareDocumentPosition(document.activeElement) &
Node.DOCUMENT_POSITION_FOLLOWING
? -1
: 1
)
} else if (childNode && document.activeElement !== childNode) {
childNode.focus()
}
}
moveFocus(amount) {
this.setState(state => {
const childCount = state.children ? Children.count(state.children) : 0
let nextIndex = state.activeChildIndex + amount
if (nextIndex >= childCount) {
nextIndex = 0
} else if (nextIndex < 0) {
nextIndex = childCount - 1
}
return { activeChildIndex: nextIndex }
})
}
handleKeyDown = event => {
switch (event.key) {
case 'ArrowUp':
if (
this.props.mode === FocusGroup.mode.VERTICAL ||
this.props.mode === FocusGroup.mode.BOTH
) {
event.preventDefault()
this.moveFocus(-1)
}
break
case 'ArrowLeft':
if (
this.props.mode === FocusGroup.mode.HORIZONTAL ||
this.props.mode === FocusGroup.mode.BOTH
) {
event.preventDefault()
this.moveFocus(-1)
}
break
case 'ArrowDown':
if (
this.props.mode === FocusGroup.mode.VERTICAL ||
this.props.mode === FocusGroup.mode.BOTH
) {
event.preventDefault()
this.moveFocus(1)
}
break
case 'ArrowRight':
if (
this.props.mode === FocusGroup.mode.HORIZONTAL ||
this.props.mode === FocusGroup.mode.BOTH
) {
event.preventDefault()
this.moveFocus(1)
}
break
}
if (this.props.onKeyDown) {
this.props.onKeyDown(event)
}
}
handleFocus = event => {
if (event.target.hasAttribute(DATA_ATTRIBUTE_INDEX)) {
const index = parseInt(
event.target.getAttribute(DATA_ATTRIBUTE_INDEX),
10
)
const child = Children.toArray(this.state.children)[index]
this.setState({ activeChildIndex: index })
if (child.props.onFocus) {
child.props.onFocus(event)
}
}
}
renderChildren() {
return Children.map(this.state.children, (child, index) => {
console.log({ child })
return cloneElement(child, {
[DATA_ATTRIBUTE_INDEX]: index,
[DATA_ATTRIBUTE_SKIP]:
parseInt(child.props.tabIndex, 10) === -1 || undefined,
key: child.key || index,
tabIndex: this.state.activeChildIndex === index ? 0 : -1,
})
})
}
render() {
return (
<Box
innerRef={c => (this.wrapper = c)}
onFocus={this.handleFocus}
onKeyDown={this.handleKeyDown}
>
{this.renderChildren()}
</Box>
)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment