@momentum-ui/react-collaboration
Version:
Cisco Momentum UI Framework for React Collaboration Applications
364 lines (323 loc) • 11.8 kB
text/typescript
import React, { useCallback, useContext } from 'react';
import {
Direction,
ExpandedBoundingRect,
FarEdge,
Rect,
RectWithMidPoint,
RelativeElementDistance,
SpatialNavigationContextValue,
} from './SpatialNavigationProvider.types';
import { getKeyboardFocusableElements } from '../../utils/navigation';
import { STYLE as TreeNodeBaseStyle } from '../TreeNodeBase/TreeNodeBase.constants';
import { STYLE as MeetingListItemStyle } from '../MeetingListItem/MeetingListItem.constants';
export const SpatialNavigationContext =
React.createContext<SpatialNavigationContextValue>(undefined);
const DIRECTIONS = ['left', 'right', 'up', 'down'] as const;
const FAR_EDGE_VALUES: FarEdge[] = ['none', 'horizontal', 'vertical', 'both'] as const;
const NESTED_FOCUSABLE_DIRECTION_MAP: Record<string, FarEdge> = {
[TreeNodeBaseStyle.wrapper]: 'horizontal',
[MeetingListItemStyle.wrapper]: 'horizontal',
};
/**
* Get the current spatial navigation context.
*/
export const useSpatialNavigationContext = (): SpatialNavigationContextValue => {
return useContext(SpatialNavigationContext);
};
/**
* Calculate the center point of the element
*/
export const getElementRectWithMidPoint = (element: HTMLElement): RectWithMidPoint => {
const { x, y, width, height, left, top, right, bottom } = element.getBoundingClientRect();
const xMid = x + width / 2;
const yMid = y + height / 2;
return { x, y, width, height, left, top, right, bottom, xMid, yMid };
};
/**
* Complex components can have nested focusable items, for example
* List or Tree nodes
*
* Spatial navigation can process 4 cases:
* - none - no nested focusable item (default)
* - horizontal - the focusable items are in a row
* - vertical - the focusable items are in a column
* - both - the focusable items are in a grid
*
* @param el - checked element
*/
export const getNestedFocusableDirection = (el: HTMLElement): FarEdge => {
const farEdge = el.dataset.spatialNestedFocusableDirection as FarEdge;
if (farEdge && FAR_EDGE_VALUES.includes(farEdge)) return farEdge;
const key = Object.keys(NESTED_FOCUSABLE_DIRECTION_MAP).find((cls) => el.classList.contains(cls));
if (key) return NESTED_FOCUSABLE_DIRECTION_MAP[key];
return 'none';
};
/**
* Calculate distance between the closest edges of the passed bounding boxes for the specified direction
*
* @param a bounding box 1
* @param b bounding box 2
* @param dir direction specify which edge measured
* @param farEdge
* @returns distance
*/
export const getEdgeDistance = (
a: Rect,
b: Rect,
dir: Direction,
farEdge: FarEdge = 'none'
): number => {
if (dir === 'left') {
if (farEdge === 'horizontal' || farEdge === 'both') {
return a.right - b.right;
} else {
return a.left - b.right;
}
}
if (dir === 'right') {
if (farEdge === 'horizontal' || farEdge === 'both') {
return b.left - a.left;
} else {
return b.left - a.right;
}
}
if (dir === 'up') {
if (farEdge === 'vertical' || farEdge === 'both') {
return a.bottom - b.bottom;
} else {
return a.top - b.bottom;
}
}
if (dir === 'down') {
if (farEdge === 'vertical' || farEdge === 'both') {
return b.top - a.top;
} else {
return b.top - a.bottom;
}
}
};
/**
* Calculate expanded bounding boxes in all four directions with the specified size
*
* @param baseRect
* @param size
*/
export const getExpandedRect = (baseRect: Rect, size: number): ExpandedBoundingRect => {
return DIRECTIONS.reduce((acc, dir) => {
const x = dir === 'left' ? baseRect.x - size : baseRect.x;
const width = dir === 'right' || dir === 'left' ? baseRect.width + size : baseRect.width;
const y = dir === 'up' ? baseRect.y - size : baseRect.y;
const height = dir === 'down' || dir === 'up' ? baseRect.height + size : baseRect.height;
acc[dir] = {
x,
y,
width,
height,
left: x,
top: y,
right: x + width,
bottom: y + height,
};
return acc;
}, {} as ExpandedBoundingRect);
};
/**
* Rectangle overlap check
*
* @param a first rectangle
* @param b second rectangle
* @return `true` when the two rectangles overlap otherwise `false`
*/
export const isRectOverlap = (a: Rect, b: Rect): boolean => {
const xOverlap = Math.max(0, Math.min(a.right, b.right) - Math.max(a.left, b.left));
const yOverlap = Math.max(0, Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top));
return xOverlap * yOverlap > 0;
};
/**
* Get the relative position of the element from the active element.
*
* @remarks
* - `edgeDistance` is a distance between the closest edge of the `element` and one of active element's edge
* the actual edge depends on the `farEdge` parameter
* - `distance` is `infinite` when `element` is not in the specified `direction`
* - `edgeDistance` is `infinite` when the `element` does not overlap the active element's expanded bounding rect
* on the specified `direction`
* - in the return value both `edgeDistance` and `distance` are the square of the real distance
*
* @param element
* @param direction
* @param activeElementMidPoint
* @param activeElementExpandedRects
* @param farEdge
*/
export const getElementRelativeDistances = (
element: HTMLElement,
direction: Direction,
activeElementMidPoint: RectWithMidPoint,
activeElementExpandedRects: ExpandedBoundingRect,
farEdge: FarEdge
): RelativeElementDistance => {
const extendedBoundRect = activeElementExpandedRects[direction];
const elementRect = getElementRectWithMidPoint(element);
let edgeDistance = Math.round(
getEdgeDistance(activeElementMidPoint, elementRect, direction, farEdge) * 1.2
);
if (edgeDistance < 0) {
return { element, distance: Infinity, edgeDistance };
}
edgeDistance = isRectOverlap(elementRect, extendedBoundRect)
? edgeDistance * edgeDistance
: Infinity;
const x = elementRect.xMid - activeElementMidPoint.xMid;
const y = elementRect.yMid - activeElementMidPoint.yMid;
const distance = x * x + y * y;
return { element, distance, edgeDistance };
};
/**
* Calculate the distance of the focusable elements form the active element
* and return the sorted list of elements based on the distance.
* The first element is the closest.
*
* @param activeEl Active/focused Dom element
* @param focusableElements All focusable elements
* @param direction Direction of the navigation
*/
export const orderElementsByDistance = (
activeEl: HTMLElement,
focusableElements: HTMLElement[],
direction: Direction
): RelativeElementDistance[] => {
const active = getElementRectWithMidPoint(activeEl);
const farEdge = getNestedFocusableDirection(activeEl);
const expandedBoundingRects = getExpandedRect(active, window.innerWidth / 2);
return focusableElements
.map((el) => getElementRelativeDistances(el, direction, active, expandedBoundingRects, farEdge))
.filter(({ element, edgeDistance }) => element !== activeEl && edgeDistance >= 0)
.sort((a, b) => a.edgeDistance - b.edgeDistance || a.distance - b.distance);
};
/**
* This hook helps to integrate spatial navigation with the radio group.
*
* Stop event propagation when user navigate between radio buttons with arrow keys except
* when the first or last radio button is focused and the user presses "prev" (left or up) or "next" (right or down) arrow keys.
*
* @param onKeyDown Optional onKeyDown event handler
*/
export const useSpatialRadioGroupNavigation = (
onKeyDown?: (e: React.KeyboardEvent<HTMLElement>) => void
) => {
const spatialNav = useSpatialNavigationContext();
return useCallback(
(evt: React.KeyboardEvent<HTMLElement>) => {
if (
spatialNav &&
spatialNav.directionKeys.includes(evt.key) &&
evt.target instanceof HTMLElement &&
evt.target.tagName === 'INPUT'
) {
const inputs = Array.from(
evt.currentTarget.querySelectorAll(`[name="${evt.target.getAttribute('name')}"]`)
);
const isPrevKey = evt.key === spatialNav.left || evt.key === spatialNav.up;
const isNextKey = evt.key === spatialNav.right || evt.key === spatialNav.down;
if (
(inputs.at(0) === evt.target && isPrevKey) ||
(inputs.at(-1) === evt.target && isNextKey)
) {
// Prevent loop back to the other end on the radio group
evt.preventDefault();
} else {
// Prevent spatial navigation from moving the focus
evt.nativeEvent.stopImmediatePropagation();
onKeyDown?.(evt);
}
} else {
// Without spatial navigation, just call the onKeyDown handler
onKeyDown?.(evt);
}
},
[spatialNav, onKeyDown]
);
};
/**
* This function helps to visually debug spatial navigation
*
* Press Shift + Arrow keys to inspect navigation in the given direction.
* Pressing any other key hide the debug layer
*
* Legends:
* - red rectangle - focused element
* - green rectangle - expanded bounding box of the focused element for edge distance calculation
* - blue rectangle - considered as next focusable elements
* - white/gray dot - mid-point of the element for distance calculation, it fades based on the distance
* - # - order number #1 will be the next focused element
* - ed - edge distance
* - d - distance
*/
export const visualDebugger = (root = document.body): void => {
if (document.getElementById('spatialNavigationVisualDebugger')) return;
const canvas = document.createElement('canvas');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
canvas.style.position = 'fixed';
canvas.style.top = '0';
canvas.style.left = '0';
canvas.style.pointerEvents = 'none';
canvas.id = 'spatialNavigationVisualDebugger';
root.appendChild(canvas);
document.addEventListener('keydown', (evt) => {
if (!evt.shiftKey || !evt.key.startsWith('Arrow')) {
return draw();
}
switch (evt.key) {
case 'ArrowLeft':
return draw('left');
case 'ArrowUp':
return draw('up');
case 'ArrowDown':
return draw('down');
case 'ArrowRight':
return draw('right');
}
});
const draw = (direction?: Direction) => {
if (!direction) {
return (canvas.hidden = true);
}
canvas.hidden = false;
const currentActiveElement = document.activeElement as HTMLElement;
const active = getElementRectWithMidPoint(currentActiveElement);
const expandedBoundingRects = getExpandedRect(active, window.innerWidth / 2);
const elements = getKeyboardFocusableElements(root);
const results = orderElementsByDistance(currentActiveElement, elements, direction);
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.lineWidth = 5;
ctx.strokeStyle = 'rgba(8,99,134,0.75)';
ctx.font = '15px sans-serif';
results.forEach(({ element, edgeDistance, distance }, idx) => {
const rect = getElementRectWithMidPoint(element);
ctx.fillStyle = `rgba(255, 255, 255, ${1 - idx / elements.length})`;
ctx.fillText(
`#${idx + 1}, ed: ${Math.round(Math.sqrt(edgeDistance))} d: ${Math.round(
Math.sqrt(distance)
)} `,
rect.x,
rect.y - 10
);
ctx.strokeRect(rect.x, rect.y, rect.width, rect.height);
ctx.beginPath();
ctx.arc(rect.xMid, rect.yMid, 10, 0, 2 * Math.PI);
ctx.fill();
});
const exRect = expandedBoundingRects[direction];
ctx.lineWidth = 5;
ctx.strokeStyle = 'rgba(19,87,5,0.75)';
ctx.strokeRect(exRect.x, exRect.y, exRect.width, exRect.height);
// active
ctx.lineWidth = 10;
ctx.strokeStyle = 'rgba(87,5,5,0.75)';
ctx.strokeRect(active.x, active.y, active.width, active.height);
};
};