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 GridKeyboardNavigationContext #462

Merged
merged 22 commits into from
Jan 20, 2022
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
557e795
apply implementation patch
laviomri Jan 19, 2022
3e5e528
added GridKeyboardNavigationContext
laviomri Jan 19, 2022
8de493b
temp remove unused var for linting
laviomri Jan 19, 2022
c11364d
Merge branch 'feature/omri/user-grid-keyboard-navigation' of github.c…
laviomri Jan 19, 2022
758e86e
added missing useContext
laviomri Jan 19, 2022
edb3561
Orr CR - various fixes
laviomri Jan 19, 2022
5b94fa6
exporting the hook
laviomri Jan 19, 2022
2fa82a3
Merge branch 'feature/omri/user-grid-keyboard-navigation' of github.c…
laviomri Jan 19, 2022
7b12afb
renamed folder
laviomri Jan 19, 2022
6a284d3
publishing the component
laviomri Jan 19, 2022
972f92b
moved the story under hooks
laviomri Jan 19, 2022
e62c8ca
fix linting
laviomri Jan 19, 2022
1750a2e
Merge branch 'feature/omri/user-grid-keyboard-navigation' of github.c…
laviomri Jan 19, 2022
0db6eca
using CSS var instead of SASS var
laviomri Jan 20, 2022
8eac74a
Merge branch 'feature/omri/user-grid-keyboard-navigation' of github.c…
laviomri Jan 20, 2022
7426843
moved helper function below the its
laviomri Jan 20, 2022
5135aae
Merge branch 'master' of github.com:mondaycom/monday-ui-react-core in…
laviomri Jan 20, 2022
84d6d62
Merge branch 'feature/omri/user-grid-keyboard-navigation' of github.c…
laviomri Jan 20, 2022
0d205fc
Merge branch 'master' of github.com:mondaycom/monday-ui-react-core in…
laviomri Jan 20, 2022
f057041
protecting circular positioning + tests
laviomri Jan 20, 2022
42ca53c
rename to a shorter name
laviomri Jan 20, 2022
de9b900
fixed file names, more robust getOppositeDirection
laviomri Jan 20, 2022
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import React, { useRef, useContext, useCallback } from "react";
import useEventListener from "../../hooks/useEventListener";
import { focusElementWithDirection, getDirectionMaps, getOppositeDirection, getOutmostElementInDirection } from "./GridKeyboardNavigationContextHelper";

export const GridKeyboardNavigationContext = React.createContext();

export const useGridKeyboardNavigationContext = (positions, wrapperRef) => {
const directionMaps = useRef(getDirectionMaps(positions));
const upperContext = useContext(GridKeyboardNavigationContext);

const onWrapperFocus = useCallback(
e => {
const keyboardDirection = e?.detail?.keyboardDirection;
if (!keyboardDirection) {
return;
}
const oppositeDirection = getOppositeDirection(keyboardDirection);
const elementToFocus = getOutmostElementInDirection(directionMaps.current, oppositeDirection);
focusElementWithDirection(elementToFocus, keyboardDirection);
},
[directionMaps]
);
useEventListener({ eventName: "focus", callback: onWrapperFocus, ref: wrapperRef });

const onOutboundNavigation = useCallback(
(elementRef, direction) => {
const maybeNextElement = directionMaps.current[direction].get(elementRef);
if (maybeNextElement?.current) {
elementRef.current?.blur();
focusElementWithDirection(maybeNextElement, direction);
return;
}
// nothing on that direction - try updating the upper context
upperContext?.onOutboundNavigation(wrapperRef, direction);
},
[upperContext, wrapperRef]
);
return { onOutboundNavigation };
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { NAV_DIRECTIONS } from "../../hooks/useFullKeyboardListeners";
laviomri marked this conversation as resolved.
Show resolved Hide resolved

export const getDirectionMaps = positions => {
const directionMaps = {
[NAV_DIRECTIONS.RIGHT]: new Map(),
[NAV_DIRECTIONS.LEFT]: new Map(),
[NAV_DIRECTIONS.UP]: new Map(),
[NAV_DIRECTIONS.DOWN]: new Map()
Copy link
Contributor

Choose a reason for hiding this comment

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

why not simple {} ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

since I use Objects (refs) as the keys. If I'll use a regular object as a map, the actual value which will be used as key is the result of .toString():
image

on the contrary, a Map can handle objects as keys

};
positions.forEach(position => {
if (position.topElement && position.bottomElement) {
directionMaps[NAV_DIRECTIONS.UP].set(position.bottomElement, position.topElement);
directionMaps[NAV_DIRECTIONS.DOWN].set(position.topElement, position.bottomElement);
}
if (position.leftElement && position.rightElement) {
directionMaps[NAV_DIRECTIONS.LEFT].set(position.rightElement, position.leftElement);
directionMaps[NAV_DIRECTIONS.RIGHT].set(position.leftElement, position.rightElement);
}
});
return directionMaps;
};

export const getOppositeDirection = direction => {
if (direction === NAV_DIRECTIONS.LEFT) return NAV_DIRECTIONS.RIGHT;
if (direction === NAV_DIRECTIONS.RIGHT) return NAV_DIRECTIONS.LEFT;
if (direction === NAV_DIRECTIONS.UP) return NAV_DIRECTIONS.DOWN;
if (direction === NAV_DIRECTIONS.DOWN) return NAV_DIRECTIONS.UP;
laviomri marked this conversation as resolved.
Show resolved Hide resolved
};

export const focusElementWithDirection = (elementRef, direction) =>
elementRef?.current?.dispatchEvent(new CustomEvent("focus", { detail: { keyboardDirection: direction } }));

export const getOutmostElementInDirection = (directionMaps, direction) => {
const directionMap = directionMaps[direction];
const firstEntry = [...directionMap][0]; // start with any element
if (!firstEntry) {
// no relations were registered for this direction - fallback to a different direction
if ([NAV_DIRECTIONS.LEFT, NAV_DIRECTIONS.RIGHT].includes(direction)) {
// there are no registered horizontal relations registered, try vertical relations. Get the top-most element.
return getOutmostElementInDirection(directionMaps, NAV_DIRECTIONS.UP);
}
// there are no registered vertical relations registered, try horizontal relations. Get the left-most element.
return getOutmostElementInDirection(directionMaps, NAV_DIRECTIONS.LEFT);
}
let result = firstEntry?.[0];
while (directionMap.get(result)) {
laviomri marked this conversation as resolved.
Show resolved Hide resolved
// as long as there's an element which is outward of the keyboard direction, take it.
result = directionMap.get(result);
}
return result;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import range from "lodash/range";
import { forwardRef, useMemo, useCallback, useRef } from "react";
import cx from "classnames";
import { action } from "@storybook/addon-actions";
import { Button, Flex } from "../..";
import useGridKeyboardNavigation from "../../../hooks/useGridKeyboardNavigation/useGridKeyboardNavigation";
import "./useGridKeyboardNavigationContext.stories.scss";
import { GridKeyboardNavigationContext, useGridKeyboardNavigationContext } from "../GridKeyboardNavigationContext";

const ELEMENT_WIDTH_PX = 72;
const PADDING_PX = 24;

const ON_CLICK = action("Selected");

export const DummyNavigableGrid = forwardRef(({ itemsCount, numberOfItemsInLine, itemPrefix }, ref) => {
const width = useMemo(() => numberOfItemsInLine * ELEMENT_WIDTH_PX + 2 * PADDING_PX, [numberOfItemsInLine]);
const items = useMemo(() => range(itemsCount).map(num => `${itemPrefix} ${num}`), [itemPrefix, itemsCount]);
const getItemByIndex = useCallback(index => items[index], [items]);
const { activeIndex, onSelectionAction } = useGridKeyboardNavigation({
ref,
numberOfItemsInLine,
itemsCount,
getItemByIndex,
onItemClicked: ON_CLICK
});
const onClickByIndex = useCallback(index => () => onSelectionAction(index), [onSelectionAction]);
return (
<div
className="use-grid-keyboard-dummy-grid-wrapper"
style={{ width }}
ref={ref}
tabIndex={-1}
>
{items.map((item, index) => (
<Button
key={item}
kind={Button.kinds.SECONDARY}
onClick={onClickByIndex(index)}
className={cx("use-grid-keyboard-dummy-grid-item", { "active-item": index === activeIndex })}
>
{item}
</Button>
))}
</div>
);
});

export const LayoutWithInnerKeyboardNavigation = forwardRef((_ignored, ref) => {
const leftElRef = useRef(null);
const rightElRef = useRef(null);
const bottomElRef = useRef(null);
const keyboardContext = useGridKeyboardNavigationContext(
[
{ leftElement: leftElRef, rightElement: rightElRef },
{ topElement: leftElRef, bottomElement: bottomElRef }
],
ref
);
return (
<GridKeyboardNavigationContext.Provider value={keyboardContext}>
<Flex ref={ref} direction={Flex.directions.COLUMN} align={Flex.align.START} className="use-grid-keyboard-dummy-grid-wrapper">
<Flex>
<DummyNavigableGrid ref={leftElRef} itemsCount={6} numberOfItemsInLine={3} itemPrefix="L " />
<DummyNavigableGrid ref={rightElRef} itemsCount={6} numberOfItemsInLine={2} itemPrefix="R " />
</Flex>
<DummyNavigableGrid ref={bottomElRef} itemsCount={6} numberOfItemsInLine={2} itemPrefix="B " />
</Flex>
</GridKeyboardNavigationContext.Provider>
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { useRef } from "react";
import { useGridKeyboardNavigationContext, GridKeyboardNavigationContext } from "../GridKeyboardNavigationContext";
import { ArgsTable, Story, Canvas, Meta } from "@storybook/addon-docs";
import { createStoryMetaSettings } from "../../../storybook/functions/create-component-story";
import { DummyNavigableGrid, LayoutWithInnerKeyboardNavigation } from "./useGridKeyboardNavigationContext.stories";
import { Flex } from "../..";

export const metaSettings = createStoryMetaSettings({
component: useGridKeyboardNavigationContext
});

<Meta
title="Hooks/useGridKeyboardNavigationContext"
component={ useGridKeyboardNavigationContext }
argTypes={ metaSettings.argTypes }
decorators={ metaSettings.decorators }
/>

<!--- Component documentation -->

# useGridKeyboardNavigationContext

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

## Overview

A hook used to specify a connection between multiple navigable components, which are navigable between each other.

<Canvas>
<Story name="Overview">{
() => {
const wrapperRef = useRef(null);
const leftElRef = useRef(null);
const rightElRef = useRef(null);
const keyboardContext = useGridKeyboardNavigationContext([{leftElement: leftElRef, rightElement: rightElRef}], wrapperRef)
return (
<GridKeyboardNavigationContext.Provider value={keyboardContext}>
<Flex ref={wrapperRef}>
<DummyNavigableGrid ref={leftElRef} itemsCount={15} numberOfItemsInLine={4} itemPrefix="L "/>
<DummyNavigableGrid ref={rightElRef} itemsCount={7} numberOfItemsInLine={2} itemPrefix="R "/>
</Flex>
</GridKeyboardNavigationContext.Provider>
)
}}</Story>
</Canvas>

## Usage

<UsageGuidelines
guidelines={[
"Use this hook when you want to add keyboard navigation between multiple grid-like components.",
"Each of the components should use `useGridKeyboardNavigation`.",
"The components should be wrapped with a single `GridKeyboardNavigationContext`."
]}
/>


## Arguments
<FunctionArguments>
<FunctionArgument
name="positions"
type="Array[ { topElement: React.MutableRefObject, bottomElement: React.MutableRefObject } | { leftElement: React.MutableRefObject, rightElement: React.MutableRefObject } ]"
description="An array of relations between pairs of elements"
required
/>
<FunctionArgument
name="wrapperRef"
type="React.MutableRefObject"
description={<>A React ref for an element which contains all the elements which are listed on the <code>positions</code> argument.</>}
required
/>
</FunctionArguments>

## Returns

<FunctionArguments>
<FunctionArgument
name="result"
type="Object"
description={<>A <code>GridKeyboardNavigationContext</code> which should be provided to wrap all the elements from <code>positions</code></>}
/>
</FunctionArguments>

## Variants

### Multiple Depths
The hook can be used inside multiple depths, in more complex layout requirements.

<Canvas>
<Story name="Multiple Depths">{
() => {
const wrapperRef = useRef(null);
const topElRef = useRef(null);
const bottomElRef = useRef(null);
const keyboardContext = useGridKeyboardNavigationContext([{topElement: topElRef, bottomElement: bottomElRef}], wrapperRef)
return (
<GridKeyboardNavigationContext.Provider value={keyboardContext}>
<Flex ref={wrapperRef} direction={Flex.directions.COLUMN}>
<LayoutWithInnerKeyboardNavigation ref={topElRef} />
<LayoutWithInnerKeyboardNavigation ref={bottomElRef} />
</Flex>
</GridKeyboardNavigationContext.Provider>
);
}}</Story>
</Canvas>
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
@import "../../../styles/states.scss";
@import "../../../styles/typography.scss";
@import "../../../styles/global-css-settings.scss";

.use-grid-keyboard-dummy-grid-wrapper {
padding: 12px;
display: flex;
flex-wrap: wrap;
outline: none;
text-align: center;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1), 0 0 0 1px var(--primary-hover-color) inset;
}

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

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