npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

mtv-rbf

v1.19.2

Published

Keyboard navigation for React

Downloads

40

Readme

   __   __   __   __                            __      __       __  __              ___
  / /  / /  / /  / /__  _____________ ___ _____/ /_____/ /  ___ / /_/ /____ ________/ _/__  ______ _____
 / _ \/ _ \/ _ \/ __/ |/ /___/ __/ -_) _ `/ __/ __/___/ _ \/ -_) __/ __/ -_) __/___/ _/ _ \/ __/ // (_-<
/_//_/_//_/_.__/\__/|___/   /_/  \__/\_,_/\__/\__/   /_.__/\__/\__/\__/\__/_/     /_/ \___/\__/\_,_/___/

hhbtv-react-better-focus

Keyboard navigation for React.

Example Demo App

This repo sports an example app to be found in /example. You can run it like this cd example && npm ci && npm start

Description

Enables context- and path-based focusing of elements. It is possible to focus elements via a delegation or spatially. This functionality is made available via ref on a group of focusable elements. When interacting with an arrow key, the next element to focus is searched for in the group starting from the currently focused element. If a group does not have a next element, the event is bubbled to the parent group, which performs the same handling.

⚠️ Important usage note

It is important that the ref returned by the navigation hooks is already set to a DOM element during the first rendering.

Usage

BetterFocusContext

Include the BetterFocusContextProvider inside your App component.

The BetterFocusContext provides the current focus state with the focusedPath and lastFocus and the focusApi with focusItem and rememberPosition.

focusApi.focusItem

focusApi.focusItem accepts a focusPath as string. This will force to set an element in focus. Use it carefully!

focusApi.rememberPosition

focusApi.rememberPosition accepts as first attribute a focusPath as string and as second attribute a rect object. Probably you will never need this method.

Example:

const [focusState, focusApi] = useBetterFocusContext();

if (focusState.focusedPath === 'app/foo') {
  focusApi.rememberPosition('app/foo', { top: 0, right: 0, bottom: 0, left: 0 });
  focusApi.focusItem('app/bar');
}

useNavigate

useNavigate is used for navigation with delegation. The return value is a ref. You should call this hook inside your focusGroup and pass the returned ref on the container which wraps your focusable elements. useNavigate checks if the current focusedPath matches the focusPath for the navigationMap. If so, the next possible focusPath will be searched from the map and will be focused. Propagation of the event is prevented. If no next focusPath exists, the event is propagated.

useNavigate accepts following properties:

| Argument | Accepts | Required | | ------------- | -------------------------- | -------- | | rootFocusPath | parent focusPath as string | yes | | navigationMap | navigationObject | yes | | options | navigateOptionsObject | no |

Example:

function Component({ parentFocusPath = 'app/home' }) {
  const focusPath = `${parentFocusPath}/component`;
  const map = [`${focusPath}/a`, `${focusPath}/b`, `${focusPath}/c`];

  const ref = useNavigate(focusPath, vertical(map));

  return (
    <div ref={ref}>
      <FocusableElement focusPath={focusPath} focusKey={'a'} />
      <FocusableElement focusPath={focusPath} focusKey={'b'} />
      <FocusableElement focusPath={focusPath} focusKey={'c'} />
    </div>
  );
}

navigationObject

The navigationObject can contain a vertical and horizontal navigation map of focusPaths. In most cases you only need one of these orientations, so you can use the vertical([]) or horizontal([]) method like in the above example.

Example:

const navigationMap = horizontal(['app/homepage/a', 'app/homepage/b']);

const navigationMap = vertical(['app/homepage/a', 'app/homepage/b']);

const navigationMap = {
  [Orientation.VERTICAL]: ['app/homepage/a', 'app/homepage/b'],
  [Orientation.HORIZONTAL]: ['app/homepage/c', 'app/homepage/d'],
};

navigateOptionsObject

The object contains two configurable options.

| Option | Accepts | Default | Affect | | -------------- | -------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | isSpatial | boolean | false | should be true for usePureSpatial. set lastPosition in context map if the map has no next focusable element. | | debounce | number | 0 | enables a debounce for navigation events. | | shouldNavigate | function | | a function that gets called with up/down/left/right and the current focus path when the user tries to navigate; can return false to prevent navigation | | preventShouldNavigateBubble | boolean | false | when set to true it prevents the useNavigate from bubbling up events (e.g. keydown) to parent components. Useful in nested components, where parents' useNavigate callbacks interfer with the desired behavior inside of children |

Example:

const options = {
  isSpatial: false, // should be true for usePureSpatial
  debounce: 0, // optional debounce time between navigation events
};

useSetFocus

useSetFocus sets the focus on mount. This is useful for example for displaying a modal which should steal the focus or after a navigation on a new page.

Example:

function Page() {
  const focusPath = 'app/page';

  useSetFocus('app/page');

  return <PageContent focusPath={focusPath} />;
}

useInitialComponentFocus

useInitialComponentFocus checks whenever the focusedPath from the context match's the component focusPath. If it's a match, the given focusPath and the optional focusKey will be focused. This is useful to keep navigationMaps clear and focus a specific item in a component, if the component get the focus.

Example with focusPath and focusKey:

function Component(parentFocusPath) {
  const focusPath = `${parentFocusPath}/component`;

  useInitialComponentFocus(focusPath, focusPath, 'item-1');

  return (
    <div>
      <FocusableElement focusPath={focusPath} focusKey={'item-1'} />
      <FocusableElement focusPath={focusPath} focusKey={'item-2'} />
      <FocusableElement focusPath={focusPath} focusKey={'item-3'} />
      <FocusableElement focusPath={focusPath} focusKey={'item-4'} />
    </div>
  );
}

Example with focusPath:

function Component(parentFocusPath) {
  const focusPath = `${parentFocusPath}/component`;

  useInitialComponentFocus(focusPath, `${focusPath}/item-1`);

  return (
    <div>
      <FocusableElement focusPath={focusPath} focusKey={'item-1'} />
      <FocusableElement focusPath={focusPath} focusKey={'item-2'} />
      <FocusableElement focusPath={focusPath} focusKey={'item-3'} />
      <FocusableElement focusPath={focusPath} focusKey={'item-4'} />
    </div>
  );
}

In some cases we might want to wait with the initial focusing until the focusable element content is available as well, e.g. waiting for an API to deliver the content for an element that is in a loading state. It is possible to wait before the child component get the focus. Simply pass a false boolean or undefined as focusPath.

Example - wait for API:

function Component(parentFocusPath) {
  const { isLoading, data } = axios.get('https://example.com');
  const focusPath = `${parentFocusPath}/component`;

  useInitialComponentFocus(focusPath, !isLoading && `${focusPath}/${data.results[0].id}`);

  return (
    <div>
      <FocusableElement focusPath={focusPath} focusKey={data.results[0].id} />
      <FocusableElement focusPath={focusPath} focusKey={data.results[1].id} />
      <FocusableElement focusPath={focusPath} focusKey={data.results[2].id} />
      <FocusableElement focusPath={focusPath} focusKey={data.results[3].id} />
    </div>
  );
}

useSpatialNavigate

useSpatialNavigate is used for spatial navigation in components that can contain dynamic aligned focusable elements. For example in a modal that can contain months. Each month has a different size. In addition, there may be wrapping into multiple lines when rendering the individual months. It is difficult to position them consistently and make them focusable via a path map. The return value is a ref. You should call this hook inside your focusGroup and pass the returned ref on the container which wraps your focusable elements.

useSpatialNavigate checks if the current focusedPath matches the focusPath for the navigationMap. If so, the next closest focusPath in direction will be searched from the items and will be focused. Propagation of the event is prevented. If no next focusPath exists, the event is propagated.

Note that the FocusableWidget requires the property spatial={true} and a spatialItemCallback.

| Argument | Accepts | Required | | --------------- | --------------- | -------- | | navigationItems | navigationItems | yes |

Example:

function Component({ parentFocusPath = 'app/home' }) {
  const focusPath = `${parentFocusPath}/component`;
  const [items, setItems] = useState({});

  const updateItems = (item) => {
    setItems((prevItems) => ({ ...prevItems, ...item }));
  };

  const ref = useSpatialNavigate(items);

  return (
    <div ref={ref}>
      <FocusableWidget
        focusPath={focusPath}
        focusKey={'a'}
        spatial
        spatialItemCallback={updateItems}
      />
      <FocusableWidget
        focusPath={focusPath}
        focusKey={'b'}
        spatial
        spatialItemCallback={updateItems}
      />
      <FocusableWidget
        focusPath={focusPath}
        focusKey={'c'}
        spatial
        spatialItemCallback={updateItems}
      />
    </div>
  );
}

usePureSpatial

usePureSpatial is used for spatial navigation between parent components with focusable elements. For example, on a page you want to allow the user to focus on the element directly below, which is located in a different dynamic component. This hook requires the use of useNavigate for focusing the child focusable elements. The return value is a navigationRefCallback. This callback updates the given ref from useNavigate and ensures the correct working of internal logic.

usePureSpatial checks if the current focusedPath matches the focusPath. If so, isSpatial is true, and the context contains a lastFocus element with a rect, the next closest focusPath will be searched from the child focusable elements and will be focused.

Note, that the option isSpatial for useNavigate and useInitialComponentFocus need to be true.

usePureSpatial accepts following properties:

| Argument | Accepts | Required | | ------------- | ----------------------------- | -------- | | navigationRef | ref returned from useNavigate | yes | | focusPath | component focus path | yes | | options | pureSpatialOptionsObject | no |

Example:

function ComponentA({ focusPath: parentFocusPath, focusKey }) {
  const focusPath = `${parentFocusPath}/${focusKey}`;
  const map = [`${focusPath}/a`, `${focusPath}/b`, `${focusPath}/c`];

  useInitialComponentFocus(focusPath, focusPath, 'a', { isSpatial: true });

  const ref = useNavigate(focusPath, vertical(map), { isSpatial: true });

  const [navigationRefCallback] = usePureSpatial(ref, focusPath, { isSpatial: true });

  return (
    <div ref={navigationRefCallback}>
      <FocusableElement focusPath={focusPath} focusKey={'a'} spatialItemCallback={updateItems} />
      <FocusableElement focusPath={focusPath} focusKey={'b'} spatialItemCallback={updateItems} />
      <FocusableElement focusPath={focusPath} focusKey={'c'} spatialItemCallback={updateItems} />
    </div>
  );
}

function ComponentB({ focusPath: parentFocusPath, focusKey }) {
  const focusPath = `${parentFocusPath}/${focusKey}`;
  const map = [`${focusPath}/a`, `${focusPath}/b`, `${focusPath}/c`];

  useInitialComponentFocus(focusPath, focusPath, 'a', { isSpatial: true });

  const ref = useNavigate(focusPath, vertical(map), { isSpatial: true });

  const [navigationRefCallback] = usePureSpatial(ref, focusPath, { isSpatial: true });

  return (
    <div ref={navigationRefCallback}>
      <FocusableElement focusPath={focusPath} focusKey={'a'} spatialItemCallback={updateItems} />
      <FocusableElement focusPath={focusPath} focusKey={'b'} spatialItemCallback={updateItems} />
      <FocusableElement focusPath={focusPath} focusKey={'c'} spatialItemCallback={updateItems} />
    </div>
  );
}

function ParentComponent() {
  const focusPath = 'app/home';
  const map = [`${focusPath}/componentA`, `${focusPath}/componentB`];

  const ref = useNavigate(focusPath, vertical(map));

  return (
    <div ref={ref}>
      <ComponentA focusPath={focusPath} focusKey={'componentA'} />
      <ComponentB focusPath={focusPath} focusKey={'componentB'} />
    </div>
  );
}

useRestoreFocus

useRestoreFocus stores the last focused focusPath in a map based on the focusPath attribute.

The path is focused inside the useInitialComponentFocus and overrides the default focus behavior from the hook.

| Argument | Accepts | Required | Default | | --------------- | -------------------- | -------- | ------- | | focusPath | component focus path | yes | | | removeOnUnmount | boolean | no | false | | useShortenedPath| boolean | no | false |

If removeOnUnmount is true, the path will be removed from the map when the component becomes unmounted. If useShortenedPath is true, the last part of the focused path to restore gets removed. @Magenta TV: You should use set this parameter to true when using the useRestoreFocus hook. You can find more details in a comment in useRestoreFocus.ts

function Component({ parentFocusPath = 'app/home' }) {
  const focusPath = `${parentFocusPath}/component`;
  const map = [`${focusPath}/a`, `${focusPath}/b`, `${focusPath}/c`];

  const ref = useNavigate(focusPath, vertical(map));

  useRestoreFocus(focusPath, false);

  return (
    <div ref={ref}>
      <FocusableElement focusPath={focusPath} focusKey={'a'} />
      <FocusableElement focusPath={focusPath} focusKey={'b'} />
      <FocusableElement focusPath={focusPath} focusKey={'c'} />
    </div>
  );
}

useFocusEffect

useFocusEffect is like useEffect but has the current focus path added as a dependency. This has better performance than using useBetterFocusContext() and using useEffect on the focus state because it does not cause rerenders if the focus changes.

function Component() {
  useFocusEffect((focusedPath, focusState) => {
    console.log(`focus changed to ${focusedPath}`);
  }, []);

  return <div />;
}

This example is equivalent to the following (but doesn't cause Component to rerender on focus changes).

function Component() {
  const [focusState] = useBetterFocusContext();
  useEffect(() => {
    console.log(`focus changed to ${focusState.focusedPath}`);
  }, [focusState.focusedPath]);

  return <div />;
}

useIsFocused

useIsFocused checks if a given focus path is focused, which is useful if you need logic that depends on focus state.

function Component({ focusPath }) {
  const isFocused = useIsFocused(focusPath);

  return <div>{isFocused ? 'i am focused' : 'focus me'}</div>;
}

It also has a mode to check for prefix only, which will return true if any child path is focused.

function Component({ focusPath }) {
  const isFocused = useIsFocused(focusPath, { prefix: true });

  return (
    <div>
      {isFocused ? 'something is focused' : 'focus me'}
      <FocusableWidget focusPath={focusPath} focusKey="first">
        <div>first</div>
      </FocusableWidget>
      <FocusableWidget focusPath={focusPath} focusKey="second">
        <div>second</div>
      </FocusableWidget>
    </div>
  );
}

FocusableWidget

The FocusableWidget includes logic to focus an element. You should wrap every focusable element inside a FocusableWidget.

Properties

FocusableWidget accepts following properties:

| Argument | Accepts | Required | Default | | ------------------- | --------------------------- | -------- | ------- | | focusPath | component focus path | yes | | | focusKey | component focus key | yes | | | onFocusChange | function | no | | | onBeforeFocus | function | no | | | onOkPressed | function | no | | | spatial | boolean | no | false | | spatialItemCallback | function | no | | | className | accepts optional classNames | no | | | ~~testId~~ | string | no | | | strategy | 'wrap' or 'clone' | no | 'wrap' | | preventScroll | boolean | no | false | | Component | React component | no | | | ignoreHotKeys | boolean | no | false |

onFocusChange is called with true or false, if the element will be focused or will lost the focus.

onOkPressed is called when the OK key (Enter by default) is pressed on the focused component. You can change the key mapping in the BetterFocusContextProvider.

onBeforeFocus is called with the HTML element immediately before it is focused. You can use this to eg. implement your own scrolling logic.

spatialItemCallback is required for spatial navigation with useSpatialNavigate and if spatial is true.

preventScroll will set preventScroll option on the element.focus() call. This can be helpful with e.g. hero sliders but doesn't work in all browsers.

ignoreHotKeys will prevent this instance from attaching the event listener for key events. By extension this allows to react to navigation events from a component higher up in the DOM and do validations before setting the focus. As the event bubbles to FocusableWidgets further up, it is adviseable to stop bubbling the event further.

Deprecation

  • testId will be removed in v2.0.0, use data-testid instead
  • strategy and the wrap strategy will be removed in v2.0.0, read below for more information

Strategies

:zap: It is strongly recommended to use the 'clone' strategy. Why? Read below!

The initial API of the focusable widget wraps the underlying component with an additional div and passes down the focus state by an anonymous component as a prop. To style your components focus state, you can use the passed property. See the following example:

const Item = styled.div`
  ${({ isFocused }) =>
    isFocused && 'color: red;'
  }
`

<FocusableWidget
  focusPath={'path'}
  focusKey={'key'}
>
  {(focused) => <Item>Item 1 is focused: {String(focused)}</Item>}
</FocusableWidget>

Because this strategy causes unnecessary re-renders, it is strongly recommended using the new introduced API, which does not wrap the underlying component but clones it instead. With this strategy, you will be able to style your components focus state with the :focus css pseudo selector.

const Item = styled.div`
  &:focus {
    color: red;
  }
`

<FocusableWidget
  focusPath={'path'}
  focusKey={'key'}
  strategy={'clone'}
>
  <Item>Item 1</Item>
</FocusableWidget>

You can improve performance by passing in the component type to be used. All unknown props and the children will be passed down to that component.

This pattern avoids cloning but, more importantly, allows the FocusableWidget to be memoized (which would not work by default because JSX children change on every render).

const Item = styled.div`
  &:focus {
    color: red;
  }
`

<FocusableWidget
  focusPath={'path'}
  focusKey={'key'}
  Component={Item}
>
  Item 1
</FocusableWidget>

Note that the component must support passing references to it, so use React.forwardRef if it is not an HTML element:


const IconButton = React.forwardRef(({ src, label, ...other }, ref) => (
  <div ref={ref} {...other}>
    <img src={src} />
    {label}
  </div>
)

<FocusableWidget
  focusPath={'path'}
  focusKey={'key'}
  Component={IconButton}
  src="exit.png"
/>