DEV Community

Cover image for Setting Up a Controlled Component
ShaynaProductions
ShaynaProductions

Posted on

Setting Up a Controlled Component

Prologue

A while ago, I decided to develop a fully accessible main navigation component in React and write a series of articles documenting the steps it took to create a non-trivial accessible component.

In my last development article, I finally completed the requirements for delivering an uncontrolled navigation component with a horizontal layout; with full keyboard functionality, along with adding the niceties of closing sublists when lists are closed and making sure any open sublist on the top row closes when focus shifts away from it.

Rather than write an entirely new component for a mobile version, I'm going to modify the existing code. This first article outlines the steps necessary to set up and work with a controlled component.
-—

Note: This article is one of a series demonstrating building a React navigational component from scratch while considering accessibility through the process. The articles are accompanied by a GitHub repository with releases tied to one or more articles; each builds on the previous one until a fully implemented navigation component is complete.

Each release and its associated tag contain fully runnable code for the article. The code discussed in this article is available in the release. and may be downloaded at release 1.0.0. Links in the article will take you to the proper file in the tagged GitHub Repository.

Because the code for this release is scattered across components, line numbers are added to make it easier to locate in the linked GitHub file. Line numbers are also provided for those who would like to follow along with a downloaded copy.

While code examples are written in JavaScript for brevity, all actual code is written in Typescript and targets React 19.x, all while using vanilla CSS. Examples use Next.js v16.x, which is not required to run the navigation component.

You can view the requirements for the Controlled Components Release along with previous requirements.


Content Links

Introduction

Everything up to this point has been built with the default requirement of an uncontrolled component. As a desktop horizontally aligned navigation component, there hasn't been a need to control the component's visibility. If you've been following these articles from the start, you might recall that one of the requirements was for the ability to use the same component as a mobile version, which means a small icon button, typically shown with three horizontal lines, colloquially referred to as a "hamburger menu," which, when triggered, opens a vertically aligned navigation component.

The decision to expand the navigation component to work in a more constrained screen environment means less work, since much of the code already written would have to be replicated.

When adding more or changing existing functionality, accessibility still needs to be considered. What works in one situation may need to be refactored for another. And that's the case when considering how to add controlled-component functionality to an uncontrolled component.

The remaining issues are documented in this video.

Return to Content Links

Acceptance Criteria

Controlled Components
AC 1 - When the controlling element is activated, and the navigation component is closed, the navigation component should open and set focus on the first child in the first row.
AC 2 - When Escape is pressed anywhere in a controlled navigation component, all open sublists close and focus shifts to the controlling element.
AC 3 - When tabbing past the controlling element when navigation is closed, focus skips over the navigation component to the next focusable element.
AC 4 - When SHIFT+Tab is applied to a focusable element just past the closed navigation component, focus skips the component and lands on the controlling element.
AC 5 - When tabbing out of a controlled component, the component should close.

Rather than staying on the icon button once navigation opens, focus should shift to the first focusable element in the navigation component. When the component is closed by pressing Escape, focus should shift back to the icon button.

The difficulty, once again, stems from the architectural decision to place all navigation links within the DOM to facilitate screen reader access via the various elements list (or rotor for VoiceOver). When the navigation component is closed, pressing the Tab key should move focus from the icon button to the first focusable element on the other side of the component. Conversely, if Shift+Tab is used, focus should move from the focusable element on the other side, back to the icon button.

The last acceptance criteria requires the component to close when an outside event is consumed. Since the navigation component also provides an OutsideEventListener, the duplication must be handled so that only one listening component is active.

Return to Content Links

Controlled Component

A component is considered controlled when its states and behaviors are managed by the parent component rather than relying on its own internal code. In this case, a MobileNavigation component toggles its open state when the icon button is activated.

Interaction between the controlling element and the navigation component requires handling focus and determining whether the navigation component is open or closed.

Return to Content Links

<MobileNavigation />

AC 1

When the controlling element is activated, and the navigation component is closed, the navigation component should open and set focus on the first child in the first row.

export function MobileNavigation({
  children,
  cx,
  label,
  skipName = "skip",
  ...rest
}) {
  const [open, setOpen] = useState(false);
  const buttonRef = useRef(null);
  const closeNavigation = () => {
    setOpen(false);
  };

  const handleFocus = () => {
    if (open) {
      closeNavigation();
    }
  };

  const handleKeyDown = (e) => {
    switch (e.key) {
      case Keys.TAB:
        /* istanbul ignore else */
        if (!open) {
          const nextEl = getFocusableElementFromDOM(buttonRef.current, "next");
          nextEl.setAttribute(`data-${skipName}`, "true");
        }
        break;
    }
  };

  const handlePress = (e) => {
    if (!open) {
      const nextEl = getFocusableElementFromDOM(buttonRef.current, "next");
      nextEl.focus({ preventScroll: true });
    }
    setOpen(!open);
  };
  const buttonProps = {
    "aria-expanded": open,
    "aria-controls": "mobile-menu",
    "aria-label": "Navigation Menu",
    id: "mobile",
    isOpen: open,
    onFocus: handleFocus,
    onKeyDown: handleKeyDown,
    onPress: handlePress,
    ref: buttonRef,
    style: { "--component-item-size": 24 } as CSSProperties,
  };

  const iconProps: IconProps = {
    IconComponent: MenuIcon,
    isSilent: true,
  };

  const navigationProps = {
    ...rest,
    cx: cx,
    controllingRef: buttonRef,
    id: "mobile-menu",
    isOpen: open,
    label: label,
    orientation: "vertical" as Orientation,
  };
  return (
    <Box cx="mobile-navigation">
      <Button {...buttonProps}>
        <Icon {...iconProps} />
      </Button>
      <Navigation {...navigationProps}>{children}</Navigation>
    </Box>
  );
}
Enter fullscreen mode Exit fullscreen mode

GitHub (release 1.0.0) - MobileNavigation.tsx

The MobileNavigation component contains and handles the icon button along with the navigation component, setting up event handlers for onFocus (closing the navigation component if it is set to open) and onPress, which, at the moment, simply toggles the open state.

Notice that the button and open state are passed into the Navigation component, along with a new prop, skipName, that creates a unified data attribute on a focusable element to handle focus when the navigation component should be skipped entirely.

In this case, when the navigation component is closed, and a keyboard user presses the Tab key on the icon button, focus should skip the actual component and move to the element on the other side. The data name could be hardcoded, and indeed, there is a default, but it's always a good idea when setting up an interaction between components to allow the data name to be agreed upon by both parties.

When the mobile navigation component sets the open state to true, focus should shift to the first focusable element within the navigation component. Pushing focus into the component helps both screen/keyboard and screenreader/keyboard users.

Return to Content Links

Data Setup

A controlled navigation component needs to keep track of more information than an uncontrolled component. The controllingRef needs to be added to the context provider and sent to the NavigationWrapper, along with the open state.

export default function Navigation({
  children,
  controllingRef,
  cx,
  isOpen = true,
  label,
  orientation = "horizontal",
  SkipName,
  ...rest
}: NavigationProps) {
    const parentRef = useRef(null);
    const uncontrolledRef = useRef(null);
    const mergedRef = useMergedRef(uncontrolledRef, controllingRef,);

    const navigationContextProps: NavigationContextStoredValueProps = {
        data: {
            controllingRef: mergedRef, isSubListOpen: isOpen, storedParentEl: null, storedList: [],
        }, config: {skipName: skipName || "skip"},
    };
}
Enter fullscreen mode Exit fullscreen mode

GitHub (release 1.0.0) - Navigation.tsx

While the controllingRef is managed in the data object, a new object is also being passed to the context provider, config. Any elements in the config object should be considered stable and non-changeable.

  const navigationListProps: NavigationListProps = {
    ...rest,
    isOpen,
    orientation,
    parentRef,
  };

  const navigationWrapperProps = {
    cx,
    controllingRef,
    isOpen,
    label: label,
  };

  return (
    <NavigationProvider value={navigationContextProps}>
      <NavigationWrapper {...navigationWrapperProps}>
        <NavigationList {...navigationListProps}>{children}</NavigationList>
      </NavigationWrapper>
    </NavigationProvider>
  );
Enter fullscreen mode Exit fullscreen mode

GitHub (release 1.0.0) - Navigation.tsx

The controlling reference needs to be stored and retrieved to shift focus back to the button when the Escape key is pressed. The controlling reference is not the parentRef; for a top-row navigation object, the parentRef will always return as null and be passed through to the top-level list. The controlling reference is not only passed to the data object but also to the NavigationWrapper.

NavigationProvider.tsx

Before anything, the controllingRef needs to be set up in the reducer. The config object is simply destructured and is ready to return information.

export function NavigationProvider({ children, value }) {
  const { data, config } = value;
  ...

  const [state, dispatch] = useReducer(navigationReducer, {
    navigationArray: [navigationObject],
    isComponentActive: false,
    controllingRef: data.controllingRef,
  });
  ...
  const getControllingElement = useCallback(() => {
    return returnElementFromRefObject(state.controllingRef);
  }, [state.controllingRef]);

const isComponentControlled = useCallback(() => {
  return getControllingElement() !== null;
},[getControllingElement]);

const getSkipName = useCallback(() => {
  return config.skipName;
}, [config.skipName]);

 ...
  return (
    <NavigationContext.Provider
      value={{
        getControllingElement,
        ...,
        isComponentControlled,
        ...
      }}
    >
      {children}
    </NavigationContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

GitHub (release 1.0.0) - NavigationProvider.tsx

Three functions are added to the context provider: getControllingElement, which returns the actual element stored; isComponentControlled, which returns false if the controlling element is null; and getSkipName, which returns the value from the config object.

The controllingRef is added to the reducer. Since the controlling reference will never change once the mobile navigation button renders, there's no need to add extra code to the reducer.

With the data set up, it's time to implement the functionality.

Return to Content Links

Close on Escape

AC 2

When Escape is pressed anywhere in a controlled navigation component, all open sublists close and focus shifts to the controlling element.

The next criterion to implement is also the easiest; all that's necessary is to modify closeComponentWithFocus inside the useNavigation hook.

const closeComponentWithFocus = (focusedEl) => {
  closeComponent();

  const controllingElement = getControllingElement();
  return controllingElement !== null
    ? controllingElement
    : _getTopRowElement(focusedEl);
};
Enter fullscreen mode Exit fullscreen mode

GitHub (release 1.0.0) - useNavigation.tsx closeComponentWithFocus() - Line 310

When Escape is pressed, and the component is controlled, focus should shift to the controlling element rather than the top-level parent of the currently focused item.

Return to Content Links

Passthrough When Closed

AC 3

When tabbing past the controlling element after navigation is closed, focus skips the navigation component and moves to the next focusable element.

As a result of the architectural decision to keep all links in the DOM, tabbing on the mobile button should pass through the component to the focusable element on the other side. Realistically, the mobile navigation has no idea how to do that; it has no way of reaching into the navigation component to find the last element and shift focus beyond the navigation component when tabbing off its icon button.

const handleKeyDown = (e) => {
  switch (e.key) {
    case Keys.TAB:
      if (!open) {
        const nextEl = getFocusableElementFromDOM(buttonRef.current, "next");
        nextEl.setAttribute(`data-${skipName}`, "true");
      }
      break;
  }
};
Enter fullscreen mode Exit fullscreen mode

GitHub (release 1.0.0) - MobileNavigation.tsx - handleKeyDown Line 35.

The challenge is how to communicate to the navigation component that, when it receives focus, it should immediately shift outside the component. The solution is to set a data attribute on the first focusable element in the component that instructs the element to shift. The skipName is the same one passed through to the navigation component.

const _handlePassthroughNavigation = (focusedEl) => {
  const shouldSkip = focusedEl.getAttribute(`data-${getSkipName()}`);

  let returnEl;
  if (shouldSkip) {
    const lastElement = _getLastElementInComponent();
    focusedEl.removeAttribute(`data-${getSkipName()}`);
    returnEl = getFocusableElementFromDOM(lastElement, "next");
  }

  return returnEl;
};

const _handleTopRowItemFocus = (focusedEl) => {
  let returnEl: FocusableElementType | undefined;
  if (isComponentActive()) {
    _closeOpenSiblings(focusedEl);
  }

  if (isComponentControlled() && _isFirstElementInComponent(focusedEl)) {
    returnEl = _handlePassthroughNavigation(focusedEl);
  }
  if (!isComponentActive()) {
    setIsComponentActive(true);
  }

  return returnEl;
};
Enter fullscreen mode Exit fullscreen mode

GitHub (release 1.0.0) - useNavigation.tsx- _handlePassthroughNavigation - Line 218

If a skip data attribute exists on the focused element, the last component element is found and returned to shift outside the component. The call to the _handlePassthroughNavigation function is made only if the component is controlled, and focus is currently on the first element in the top row.

The issue with Tabbing through a closed navigation component is now resolved.

AC 4

When SHIFT+Tab is applied to a focusable element just past the closed navigation component, focus skips the component and lands on the controlling element.

Just as a check was made when the currently focused element was the first focusable element in a component, shifting focus to the mobile button needs to happen when focus is on the last focusable element in the component.

const _handleLastChildFocus = (focusedEl) => {
  const { isSubListOpen } = _getNavigationObjectByListElement(focusedEl);
  if (!isSubListOpen) {
    setIsComponentActive(false);
    if (isComponentControlled()) {
      return getControllingElement();
    } else {
      return _getLastElementInTopRow(focusedEl);
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

GitHub (release 1.0.0) - useNavigation.tsx - _handleLastChildFocus - Line 280

Code has been added to check if the component is controlled only when the sublist the focusable element is a part of is not open. If this is the case, the controlling element is returned rather than the last element in the top row.

Return to Content Links

Component Closing

AC 5

When tabbing out of a controlled component, the component should close.

When the focus shifts outside of the mobile navigation component, the component should become invisible on the screen. This requires an OutsideEventListener in the Mobile component to capture clicks and blurs outside the component and close it. But the uncontrolled component also has the same listener, and having two of them can create issues.

if (isComponentControlled()) {
  return <nav {...navigationProps}>{children}</nav>;
} else {
  return (
    <OutsideEventListener {...outsideElementListenerProps}>
      <nav {...navigationProps}>{children}</nav>
    </OutsideEventListener>
  );
}
Enter fullscreen mode Exit fullscreen mode

GitHub (release 1.0.0) - NavigationWrapper.tsx - Line 49

The solution, of course, is only to load the listener in the NavigationWrapper when the component is uncontrolled.

export function MobileNavigation({ ... }){
  ...
  const outsideEventProps = {
    onOutsideEvent: returnTrueElementOrUndefined(open, closeNavigation),
  };

  ...
  return (
    <OutsideEventListener {...outsideEventProps}>
      <Box cx="mobile-navigation">
        <Button {...buttonProps}>
          <Icon {...iconProps} />
        </Button>
        <Navigation {...navigationProps}>{children}</Navigation>
      </Box>
    </OutsideEventListener>
  );
}
Enter fullscreen mode Exit fullscreen mode

GitHub (release 1.0.0) - MobileNavigation.tsx - Line 74

Adding the OutsideEventListener and requiring the close function to be called only when the navigation component is open solves the last issue when a component is controlled.

And with that, the last of the issues surrounding a controlled component are solved.

Return to Content Links

Summary

Adding functionality to allow a component to be controlled requires a new look at keyboard functionality and confirming keyboard focus works both when the controlled component is on the screen and when it is not. It means making sure there are no conflicts with competing utilities when the component is controlled versus when it is not.

The next article will address the remaining issues with the vertical layout, and the navigation component will be ready for use in production.

Top comments (0)