tdc
tdc

Reputation: 5464

Keyboard accessible React component that mounts on hover and focus?

In a React application, I have a grid of <Card /> components. Each <Card /> renders several <Button /> components.

Due to constraints outside my control, Button is an inefficient component. It is expensive to render, and we need to render hundreds of <Card /> components, so it becomes a performance concern.

To account for this, I only want <Button /> to mount/render when the user is actively interacting with the <Card />:

The hover portion is fairly straightforward, and my implementation can be seen below.

I am having trouble with the keyboard portion. Since <Button /> is not mounted yet, how can the user keyboard navigate to it?

CodeSandbox link of what I've tried

This mostly works, except in two important ways:

  1. the first <Button /> should receive focus as soon as the <Card/> is tabbed into (not requiring a second tab press)
  2. reverse keyboard navigation (ie shift+tab) does not allow any <Button> to be focused. It should focus the "last" button, and allow the user to navigate through all the buttons and then out to the previous <Card>

Question: What modifications must I make to the below code to support the above two points?

import { useState, Fragment, useEffect, useRef, MutableRefObject } from "react";
import Button from "./Button";

export default function Card() {
  const [isHovered, setIsHovered] = useState<boolean>(false);
  const [showButtons, setShowButtons] = useState<boolean>(false);
  const containerRef: MutableRefObject<HTMLDivElement | null> = useRef(null);

  useEffect(() => {
    setShowButtons(isHovered);
  }, [isHovered]);

  return (
    <div
      onMouseOver={() => setIsHovered(true)}
      onMouseLeave={() => setIsHovered(false)}
      onFocus={(event) => {
        event.persist();
        setIsHovered(true);
      }}
      onBlur={(event) => {
        event.persist();

        window.requestAnimationFrame(() => {
          if (containerRef.current) {
            setIsHovered(
              containerRef.current.contains(document.activeElement) === true
            );
          }
        });
      }}
      tabIndex={0}
    >
      <div ref={containerRef}>
        <h2>Card</h2>
        {showButtons && (
          <Fragment>
            <Button>Expensive Button One</Button>
            <Button>Expensive Button Two</Button>
          </Fragment>
        )}
      </div>
    </div>
  );
}

Upvotes: 3

Views: 1066

Answers (1)

claurennt
claurennt

Reputation: 78

You can reach the desired outcome with simple CSS (the opacity property) and a state change on focus.

Be aware that making a non-interactive Card container component interactive with tabIndex={0} is an anti-pattern.

//styles.css
.invisible-button {
opacity:0;
}


//Button.tsx
import React, { useState, FC } from "react";
export const Button: FC<{ children: React.ReactNode }> = ({
  children,
    }) => {
  const [hasBeenFocused, setHasBeenFocused] = useState(false);
  const handleFocus = () => setHasBeenFocused(true);
  const handleBlur = () => setHasBeenFocused(false);
  return (
    <button
      className={hasBeenFocused ? "" : "invisible-button"}
      onFocus={handleFocus}
      onBlur={handleBlur}
      onMouseEnter={handleFocus}
      onMouseLeave={handleBlur}        
    >
      {children}
    </button>
  );
};
export default Button;

Have a look at the complete sandbox: https://codesandbox.io/p/sandbox/intelligent-matsumoto-frc9kk?file=%2Fsrc%2FApp.js%3A14%2C1

If you wish to have just the buttons receive focus you do not need to make your card component interactive with the tabIndex property.

I hope my solution helps :-)

Upvotes: 0

Related Questions