Search code examples
javascriptreactjsaccessibility

Roving tabindex w/ React


What is most simple way to make "roving tabindex" in React? It's basically switch focus and tabindex=0/-1 between child elements. Only a single element have tabindex of 0, while other receives -1. Arrow keys switch tabindex between child elements, and focus it.

For now, I do a simple children mapping of required type, and set index prop and get ref, to use it later. It looks robust, but may be there more simple solution?

My current solution (pseudo-javascript, for idea illustration only):

ElementWithFocusManagement.js

function recursivelyMapElementsOfType(children, isRequiredType, getProps) {
  return Children.map(children, function(child) {
    if (isValidElement(child) === false) {return child;}

    if (isRequiredType(child)) {

      return cloneElement(
        child,
        // Return new props
        // {
        //   index, iterated in getProps closure
        //   focusRef, saved to `this.focusable` aswell, w/ index above
        // }
        getProps()
      );
    }

    if (child.props.children) {
      return cloneElement(child, {
        children: recursivelyMapElementsOfType(child.props.children, isRequiredType, getProps)
      });
    }

    return child;
  });
}

export class ElementWithFocusManagement {
  constructor(props) {
    super(props);

    // Map of all refs, that should receive focus
    // {
    //   0: {current: HTMLElement}
    //   ...
    // }
    this.focusable = {};
    this.state = {
      lastInteractionIndex: 0
    };
  }

  handleKeyDown() {
    // Handle arrow keys,
    // check that element index in `this.focusable`
    // update state if it is
    // focus element
  }

  render() {
    return (
      <div onKeyDown={this.handleKeyDown}>
        <Provider value={{lastInteractionIndex: this.state.lastInteractionIndex}}>
          {recursivelyMapElementsOfType(
            children,
            isRequiredType, // Check for required `displayName` match
            getProps(this.focusable) // Get index, and pass ref, that would be saved to `this.focusable[index]`
          )}
        </Provider>
      </div>
    );
  }
}

with-focus.js

export function withFocus(WrappedComponent) {
  function Focus({index, focusRef, ...props}) {
    return (
      <Consumer>
        {({lastInteractionIndex}) => (
          <WrappedComponent
            {...props}

            elementRef={focusRef}
            tabIndex={lastInteractionIndex === index ? 0 : -1}
          />
        )}
      </Consumer>
    );
  }

  // We will match for this name later
  Focus.displayName = `WithFocus(${WrappedComponent.name})`;

  return Focus;
}

Anything.js

const FooWithFocus = withFocus(Foo);

<ElementWithFocusManagement> // Like toolbar, dropdown menu and etc.
  <FooWithFocus>Hi there</FooWithFocus> // Button, menu item and etc.

  <AnythingThatPreventSimpleMapping>
    <FooWithFocus>How it's going?</FooWithFocus>
  </AnythingThatPreventSimpleMapping>

  <SomethingWithoutFocus />
</ElementWithFocusManagement>

Solution

  • react-roving-tabindex looks quite good.