Roving tabindex w/ React
Asked Answered
C

2

9

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>
Cyrene answered 23/8, 2018 at 19:24 Comment(2)
Just realize, that ElementWithFocusManagement could be a HOC aswellCyrene
My solution will fail, if children gets mapped in component, mapping of withFocus HOCs just get failed.Cyrene
C
0

react-roving-tabindex looks quite good.

Cyrene answered 24/4, 2020 at 19:40 Comment(1)
It has some awful dependencies, though.Cyrene
E
0

Here's an example of roving tabindex implemented in a composite widget, following the recommendations from w3.org:

import React from "react";

import "./styles.css";

const BUTTON_NAMES = ["foo", "bar", "baz"];

export default function App() {
  const buttonRef = React.useRef(null);
  const [buttonIndex, setButtonIndex] = React.useState(0);
  const [hasFocus, setHasFocus] = React.useState(false);
  const handleBlur = () => setHasFocus(false);
  const handleFocus = (index) => () => {
    setHasFocus(true);
    setButtonIndex(index);
  };
  const handleKeyDown = (event) => {
    switch (event.code) {
      case "ArrowDown":
        setButtonIndex((prev) => Math.min(prev + 1, BUTTON_NAMES.length - 1));
        break;
      case "ArrowUp":
        setButtonIndex((prev) => Math.max(prev - 1, 0));
        break;
      default:
        break;
    }
  };
  React.useEffect(() => {
    if (hasFocus) {
      buttonRef.current?.focus();
    }
  }, [buttonIndex]);
  return (
    <div className="App">
      <h1>Roving tabindex demo</h1>
      <p>
        <button>Button outside the composite widget</button>
      </p>
      <div className={"widget" + (hasFocus ? " focus" : "")}>
        <p>Composite widget</p>
        {BUTTON_NAMES.map((name, index) => (
          <p>
            <button
              ref={index === buttonIndex ? buttonRef : null}
              tabIndex={index === buttonIndex ? 0 : -1}
              onBlur={handleBlur}
              onFocus={handleFocus(index)}
              onKeyDown={handleKeyDown}
            >
              {name}
            </button>
          </p>
        ))}
      </div>
      <p>
        <button>Another button outside the composite widget</button>
      </p>
    </div>
  );
}

The composite widget remembers the last focused button and allows the user to move between its buttons with ArrowDown, ArrowUp which focus the next, previous buttons.

See also the following critical points from the w3.org page:

The visual focus indicator must always be visible.

the tab sequence should include only one focusable element of a composite UI component. Once a composite contains focus, keys other than Tab and Shift + Tab enable the user to move focus among its focusable elements.

When using roving tabindex to manage focus in a composite UI component, the element that is to be included in the tab sequence has tabindex of "0" and all other focusable elements contained in the composite have tabindex of "-1".

Ethnic answered 2/2, 2024 at 10:11 Comment(0)

© 2022 - 2025 — McMap. All rights reserved.