Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added useGridKeyboardNavigation #461

Merged
merged 7 commits into from
Jan 20, 2022
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plop/stories/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ const fs = require("fs");

module.exports = plop => {
plop.setGenerator("Stories", {
description: "New stories files fore existing component",
description: "New stories files for existing component",
prompts: [
{
type: "input",
Expand Down
85 changes: 85 additions & 0 deletions src/hooks/useFullKeyboardListeners.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { useMemo, useLayoutEffect, useCallback } from "react";
import useKeyEvent from "./useKeyEvent";

export const NAV_DIRECTIONS = {
UP: "up",
DOWN: "down",
LEFT: "left",
RIGHT: "right"
};

const ARROW_DOWN_KEYS = ["ArrowDown"];
const ARROW_UP_KEYS = ["ArrowUp"];
const ARROW_RIGHT_KEYS = ["ArrowRight"];
const ARROW_LEFT_KEYS = ["ArrowLeft"];
const SELECTION_KEYS = ["Enter", " "];
const ESCAPE_KEYS = ["Escape"];

const NOOP = () => {};

export default function useFullKeyboardListeners({
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I saw that we had various places that add similar key listeners, but some had slightly different key-handling. For example, treating only "Enter" as selection key, and not "Space".
If you're ok with it, I can replace some of the existing hooks to use this hook, to reduce code duplication and having a more unified behavior.

ref, // the reference for the component that listens to keyboard
onSelectionKey = NOOP,
onArrowNavigation = NOOP,
onEscape = NOOP,
useDocumentEventListeners = false,
focusOnMount = false
}) {
const listenerOptions = useMemo(() => {
if (useDocumentEventListeners) return undefined;

return {
ref,
preventDefault: true,
stopPropagation: true
};
}, [useDocumentEventListeners, ref]);

const onArrowDown = useCallback(() => onArrowNavigation(NAV_DIRECTIONS.DOWN), [onArrowNavigation]);
const onArrowUp = useCallback(() => onArrowNavigation(NAV_DIRECTIONS.UP), [onArrowNavigation]);
const onArrowRight = useCallback(() => onArrowNavigation(NAV_DIRECTIONS.RIGHT), [onArrowNavigation]);
const onArrowLeft = useCallback(() => onArrowNavigation(NAV_DIRECTIONS.LEFT), [onArrowNavigation]);

useKeyEvent({
keys: ARROW_DOWN_KEYS,
callback: onArrowDown,
...listenerOptions
});

useKeyEvent({
keys: ARROW_UP_KEYS,
callback: onArrowUp,
...listenerOptions
});

useKeyEvent({
keys: ARROW_RIGHT_KEYS,
callback: onArrowRight,
...listenerOptions
});

useKeyEvent({
keys: ARROW_LEFT_KEYS,
callback: onArrowLeft,
...listenerOptions
});

useKeyEvent({
keys: SELECTION_KEYS,
callback: onSelectionKey,
...listenerOptions
});

useKeyEvent({
keys: ESCAPE_KEYS,
callback: onEscape,
...listenerOptions
});

useLayoutEffect(() => {
if (!focusOnMount || useDocumentEventListeners) return;
laviomri marked this conversation as resolved.
Show resolved Hide resolved
requestAnimationFrame(() => {
ref?.current?.focus();
});
}, [ref, focusOnMount, useDocumentEventListeners]);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { useRef, useCallback, useState, useMemo } from "react";
import { ArgsTable, Story, Canvas, Meta } from "@storybook/addon-docs";
import useGridKeyboardNavigation from "../useGridKeyboardNavigation";
import { action } from "@storybook/addon-actions";
import cx from "classnames";
import range from "lodash/range";
import "./useGridKeyboardNavigation.stories.scss";
import Button from "../../../../src/components/Button/Button";

<Meta title="Keyboard/useGridKeyboardNavigation" />
laviomri marked this conversation as resolved.
Show resolved Hide resolved

# useGridKeyboardNavigation

- [Overview](#overview)
- [Usage](#usage)
- [Arguments](#arguments)
- [Returns](#returns)
- [Feedback](#feedback)

## Overview

Used for accessible keyboard navigation. Useful for components rendering items that can be navigated and selected with a keyboard.



export const ELEMENT_WIDTH_PX = 72;
export const PADDING_PX = 24;

export const ON_CLICK = action('item selected');

<Canvas>
<Story name="Overview">
{() => {
const ref = useRef(null);
const [itemsCount, setItemsCount] = useState(15);
const [numberOfItemsInLine, setNumberOfItemsInLine] = useState(4);
const width = useMemo(() => numberOfItemsInLine * ELEMENT_WIDTH_PX + 2 * PADDING_PX, [numberOfItemsInLine]);
const items = useMemo(() => range(itemsCount).map(num => `${num}.`), [itemsCount]);
const getItemByIndex = useCallback(index => items[index], [items]);
const { activeIndex, onSelectionAction } = useGridKeyboardNavigation({
ref,
numberOfItemsInLine,
itemsCount,
getItemByIndex,
onItemClicked: ON_CLICK,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When developer will copy this code ON_CLICK will not be defined for him.
Form what I understood from @hadasfa presentations story should work just after copy/paste.
Maybe we need to consider to change to something like:

Suggested change
onItemClicked: ON_CLICK,
onItemClicked: () = > {console.log("item selected")},

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's a good point. the thing is that the ON_CLICK actually has some functionality (it adds an action to the "Actions" tab of the story). I also assume that the developer will have to change the callback either way (I guess they won't like a console log as a callback 😃 ).
@hadasfa what do you think about this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm gonna merge it as is for now, but if needed I'd be happy to change it in the future :)

});
const onClickByIndex = useCallback((index) => () => onSelectionAction(index), [onSelectionAction]);
return (
<div>
<div className="use-grid-keyboard-nav-comp-wrapper" style={{ width }} ref={ref} tabIndex={-1}>
{items.map((item, index) => (
<Button
key={item}
onClick={onClickByIndex(index)}
kind={Button.kinds.SECONDARY}
className={cx("use-grid-keyboard-nav-item", { "active-item": index === activeIndex })}>
{item}
</Button>
))}
</div>
<div className="use-grid-keyboard-nav-controls">
<div>Items count: <input value={itemsCount} onChange={e => setItemsCount(e.target.value)} type="number" min={1}/></div>
<div>Number of items in line: <input value={numberOfItemsInLine} onChange={e => setNumberOfItemsInLine(e.target.value)} type="number" min={1}/></div>
</div>
</div>
);
}}
</Story>
</Canvas>

## Usage

<UsageGuidelines
guidelines={["Use this hook when you want to add keyboard navigation to a grid-like component."]}
/>

## Arguments

<FunctionArguments>
<FunctionArgument name="options" type="Object">
<FunctionArgument
name="ref"
type="React.MutableRefObject"
description={<>A React <a href="https://reactjs.org/docs/hooks-reference.html#useref" target="_blank" rel="noopener noreferrer">ref</a> object. The reference for the component that listens to keyboard. <br/>
<b>Important:</b> the referred element must have a <code>tabIndex={-1}</code> for the focus to work properly.</>}
required
/>
<FunctionArgument
name="itemsCount"
type="Number"
description="The number of items."
required
/>
<FunctionArgument
name="numberOfItemsInLine"
type="Number"
description="The number of items on each line of the grid."
required
/>
<FunctionArgument
name="onItemClicked"
type="(item, index) => void"
description="The callback for selecting an item. It will be called when an active item is selected, for example with 'Enter'."
required
/>
<FunctionArgument
name="getItemByIndex"
type="(index) => item"
description="A function which gets an index as a param, and returns the item on that index."
/>
<FunctionArgument
name="focusOnMount"
type="boolean"
description="If true, the referenced element will be focused when mounted."
/>
</FunctionArgument>
</FunctionArguments>


## Returns

<FunctionArguments>
<FunctionArgument name="result" type="Object">
<FunctionArgument name="activeIndex" type="number" description="The index of the currently active item." />
<FunctionArgument
name="onSelectionAction"
type="(itemIndex) => void"
description={<>A wrapper around the passed <code>onItemClicked</code> function. Use it as the handler for selecting items (e.g. <code>onClick</code>)</>}
/>
</FunctionArgument>
</FunctionArguments>
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
@import "../../__stories__/general-hooks-stories.scss";
@import "../../../styles/states.scss";
@import "../../../styles/typography.scss";
@import "../../../styles/global-css-settings.scss";

.use-grid-keyboard-nav-comp-wrapper {
padding: 24px;
display: flex;
flex-wrap: wrap;
outline: none;
text-align: center;
}

.use-grid-keyboard-nav-item {
width: 60px;
margin: $spacing-xs-small;

&.active-item {
@include focus-style-css();
}
}

.use-grid-keyboard-nav-controls {
display: flex;

& :not(:first-child) {
margin-left: $spacing-medium;
laviomri marked this conversation as resolved.
Show resolved Hide resolved
}

input {
width: 60px;
}
}
Loading