import * as React from 'react'; import { compose, memoize } from 'ramda'; import { findDOMNode } from 'react-dom'; import { cloneElement, Component, ComponentClass, MouseEvent, ReactChild, ReactChildren, } from 'react'; import RenderChildren from '../../components/RenderChildren'; import getFilteredReactChildrenIndexes from '../../utils/getFilteredReactChildrenIndexes'; import withKeys from '../withKeys'; export interface WithNavigationArgs { isSelectable(child: ReactChild): boolean; } export interface WithNavigationProps { children: ReactChildren; hoverIdx?: number; isSelectable?(child: ReactChild): boolean; onEndReached?(): void; onHoverChange( idx: number | undefined, node: Component | undefined, ): void; onMouseLeave(event: MouseEvent): void; onStartReached?(): void; restartOnEnd: boolean; rootNode?: HTMLElement; scrollOffset?: number; useKeys?: boolean; } export type WithNavigationHOC = ( decorated: ComponentClass, ) => ComponentClass; export interface INavigableComponent extends Component { getNextIndex(increment: number): number; justPressedKey(): void; } const SCROLL_NAVIGATE_OFFSET = 8; const Keybindings = withKeys()(RenderChildren); export default function withNavigation({ isSelectable: defaultIsSelectable, }: WithNavigationArgs): WithNavigationHOC { return compose< ComponentClass, ComponentClass >(function addNavigation(DecoratedComponent) { return class NavigableComponent extends Component< T & WithNavigationProps, {} > { fromKeyboard = false; justPressedKeyTimeout; rootNode: HTMLElement | undefined; componentDidUpdate(prevProps) { if ( this.props.hoverIdx !== prevProps.hoverIdx && this.fromKeyboard && this.props.hoverIdx !== undefined ) { this.adjustScroll(); } } handleRootRef = node => { this.rootNode = node; }; handleChildRef = memoize(idx => node => { this[itemId(idx)] = node; }); handleChildComponentRef = memoize(idx => node => { this[childId(idx)] = node; }); handleItemEnter = (idx, child, params) => { if (this.justPressedKeyTimeout === undefined) { this.fromKeyboard = false; if (this.props.onHoverChange) { this.props.onHoverChange(idx, child); } } }; handleMouseLeave = (event: MouseEvent) => { if (this.justPressedKeyTimeout === undefined) { this.fromKeyboard = false; if (this.props.onHoverChange) { this.props.onHoverChange(undefined, undefined); } } if (this.props.onMouseLeave) { this.props.onMouseLeave(event); } }; handleArrowDown = event => { event.preventDefault(); const index = this.getNextIndex(1); if (index !== this.props.hoverIdx) { this.justPressedKey(); this.props.onHoverChange(index, this[childId(index)]); } else if (this.props.onEndReached) { this.props.onEndReached(); } }; handleArrowUp = event => { event.preventDefault(); const index = this.getNextIndex(-1); if (index !== this.props.hoverIdx) { this.justPressedKey(); this.props.onHoverChange(index, this[childId(index)]); } else if (this.props.onStartReached) { this.props.onStartReached(); } }; handleEnterOrSpacebar = event => { event.preventDefault(); const { hoverIdx } = this.props; if (hoverIdx !== undefined && this[itemId(hoverIdx as number)]) { this[itemId(hoverIdx as number)].click(); } }; justPressedKey = () => { this.fromKeyboard = true; this.justPressedKeyTimeout = setTimeout(() => { this.justPressedKeyTimeout = undefined; }, 200); }; getRootNode = () => this.props.rootNode || this.rootNode; getNextIndex = increment => { const { children, hoverIdx, restartOnEnd } = this.props; const indexes = getFilteredReactChildrenIndexes( children, this.isSelectable, ); let nextIndex = hoverIdx !== undefined ? indexes.indexOf(hoverIdx as number) + increment : 0; if (nextIndex < 0) { nextIndex = restartOnEnd ? indexes.length - 1 : 0; } if (nextIndex >= indexes.length) { nextIndex = !restartOnEnd ? indexes.length - 1 : 0; } return indexes[nextIndex]; }; adjustScroll = () => { const scrollOffset = this.props.scrollOffset !== undefined ? this.props.scrollOffset as number : SCROLL_NAVIGATE_OFFSET; const rootNode = findDOMNode(this.getRootNode()); const itemNode = this[itemId(this.props.hoverIdx as number)]; if (rootNode && itemNode && this.props.hoverIdx !== undefined) { const list = rootNode.getBoundingClientRect(); const item = itemNode.getBoundingClientRect(); // Adjust scroll for this item if (item.bottom > list.bottom) { const diff = item.bottom - list.bottom + scrollOffset; rootNode.scrollTop = rootNode.scrollTop + diff; } else if (item.top < list.top) { const diff = list.top - item.top + scrollOffset; rootNode.scrollTop = rootNode.scrollTop - diff; } } }; isSelectable = (child: ReactChild) => this.props.isSelectable !== undefined ? this.props.isSelectable(child) : defaultIsSelectable(child); injectSelectableProps = (child, idx) => cloneElement(child, { getRef: this.handleChildRef(idx), ref: this.handleChildComponentRef(idx), highlighted: idx === this.props.hoverIdx, onMouseLeave: (event, ...params) => { this.handleMouseLeave(event); if (child.props.onMouseLeave) { child.props.onMouseLeave(event, ...params); } }, onMouseEnter: (...params) => { this.handleItemEnter(idx, child, params); if (child.props.onMouseEnter) { child.props.onMouseEnter(...params); } }, }); handleEsc = event => { event.preventDefault(); this.props.onHoverChange(undefined, undefined); }; render() { const { onMouseLeave, onHoverChange, useKeys, ...rest } = this .props as any; return ( {React.Children.map( this.props.children, (child, idx) => this.isSelectable(child) ? this.injectSelectableProps(child, idx) : child, )} ); } }; }); } function itemId(idx: number): string { return `item${idx}`; } function childId(idx: number): string { return `child${idx}`; }