-
Notifications
You must be signed in to change notification settings - Fork 317
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
Changes from 2 commits
557e795
8de493b
edb3561
5b94fa6
e62c8ca
0db6eca
5135aae
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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({ | ||
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, | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. that's a good point. the thing is that the There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
} | ||
} |
There was a problem hiding this comment.
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.