import * as React from "react"; import { DISPLAYNAME_PREFIX, IProps, Keys, Menu, Utils } from "reui-core"; import { IItemListRendererProps, IItemModifiers, IListItemsProps, renderFilteredItems } from "../../common"; export interface IQueryListProps extends IListItemsProps { /** * Callback invoked when user presses a key, after processing `QueryList`'s own key events * (up/down to navigate active item). This callback is passed to `renderer` and (along with * `onKeyUp`) can be attached to arbitrary content elements to support keyboard selection. */ onKeyDown?: React.KeyboardEventHandler; /** * Callback invoked when user releases a key, after processing `QueryList`'s own key events * (enter to select active item). This callback is passed to `renderer` and (along with * `onKeyDown`) can be attached to arbitrary content elements to support keyboard selection. */ onKeyUp?: React.KeyboardEventHandler; /** * Customize rendering of the component. * Receives an object with props that should be applied to elements as necessary. */ renderer: (listProps: IQueryListRendererProps) => JSX.Element; } /** * An object describing how to render a `QueryList`. * A `QueryList` `renderer` receives this object as its sole argument. */ export interface IQueryListRendererProps extends IQueryListState, IProps { /** * Selection handler that should be invoked when a new item has been chosen, * perhaps because the user clicked it. */ handleItemSelect: (item: T, event?: React.SyntheticEvent) => void; /** * Keyboard handler for up/down arrow keys to shift the active item. * Attach this handler to any element that should support this interaction. */ handleKeyDown: React.KeyboardEventHandler; /** * Keyboard handler for enter key to select the active item. * Attach this handler to any element that should support this interaction. */ handleKeyUp: React.KeyboardEventHandler; /** * Change handler for query string. Attach this to an input element to allow * `QueryList` to control the query. */ handleQueryChange: React.ChangeEventHandler; /** Rendered elements returned from `itemListRenderer` prop. */ itemList: React.ReactNode; } export interface IQueryListState { /** The currently focused item (for keyboard interactions). */ activeItem: T | null; /** The original `items` array filtered by `itemListPredicate` or `itemPredicate`. */ filteredItems: T[]; /** The current query string. */ query: string; } export class QueryList extends React.Component, IQueryListState> { public static displayName = `${DISPLAYNAME_PREFIX}.QueryList`; public static ofType() { return QueryList as new (props: IQueryListProps) => QueryList; } private itemsParentRef?: HTMLElement | null; private refHandlers = { itemsParent: (ref: HTMLElement | null) => (this.itemsParentRef = ref), }; /** * flag indicating that we should check whether selected item is in viewport after rendering, * typically because of keyboard change. */ private shouldCheckActiveItemInViewport: boolean = false; public constructor(props: IQueryListProps, context?: any) { super(props, context); const { query = "" } = this.props; const filteredItems = getFilteredItems(query, this.props); this.state = { activeItem: getFirstEnabledItem(filteredItems, this.props.itemDisabled), filteredItems, query }; } public render() { const { className, items, renderer, itemListRenderer = this.renderItemList } = this.props; return renderer({ ...this.state, className, handleItemSelect: this.handleItemSelect, handleKeyDown: this.handleKeyDown, handleKeyUp: this.handleKeyUp, handleQueryChange: this.handleQueryChange, itemList: itemListRenderer({ ...this.state, items, itemsParentRef: this.refHandlers.itemsParent, renderItem: this.renderItem, }), }); } public componentWillReceiveProps(nextProps: IQueryListProps) { if (nextProps.activeItem !== undefined) { this.setState({ activeItem: nextProps.activeItem }); } if (nextProps.query != null) { this.setQuery(nextProps.query); } } public componentDidUpdate(prevProps: IQueryListProps) { if ( !Utils.shallowCompareKeys(this.props, prevProps, { include: ["items", "itemListPredicate", "itemPredicate"], }) ) { this.setState(state => ({ filteredItems: getFilteredItems(state.query, this.props) })); this.shouldCheckActiveItemInViewport = true; } if (this.shouldCheckActiveItemInViewport) { // update scroll position immediately before repaint so DOM is accurate // (latest filteredItems) and to avoid flicker. requestAnimationFrame(() => this.scrollActiveItemIntoView()); // reset the flag this.shouldCheckActiveItemInViewport = false; } // reset active item (in the same step) if it's no longer valid // Also don't fire the event if the active item is already undefined and there is nothing to pick const activeIndex = this.getActiveIndex(); if ( this.props.activeItem !== undefined && (activeIndex < 0 || isItemDisabled(this.props.activeItem, activeIndex, this.props.itemDisabled)) ) { this.setFirstActiveItem(); } } public scrollActiveItemIntoView() { const activeElement = this.getActiveElement(); if (this.itemsParentRef != null && activeElement != null) { const { offsetTop: activeTop, offsetHeight: activeHeight } = activeElement; const { offsetTop: parentOffsetTop, scrollTop: parentScrollTop, clientHeight: parentHeight, } = this.itemsParentRef; // compute padding on parent element to ensure we always leave space const { paddingTop, paddingBottom } = this.getItemsParentPadding(); // compute the two edges of the active item for comparison, including parent padding const activeBottomEdge = activeTop + activeHeight + paddingBottom - parentOffsetTop; const activeTopEdge = activeTop - paddingTop - parentOffsetTop; if (activeBottomEdge >= parentScrollTop + parentHeight) { // offscreen bottom: align bottom of item with bottom of viewport this.itemsParentRef.scrollTop = activeBottomEdge + activeHeight - parentHeight; } else if (activeTopEdge <= parentScrollTop) { // offscreen top: align top of item with top of viewport this.itemsParentRef.scrollTop = activeTopEdge - activeHeight; } } } // TODO resetActiveItem = this.props.resetOnQuery public setQuery(query: string, resetActiveItem = false) { if (query !== this.state.query) { Utils.safeInvoke(this.props.onQueryChange, query); } this.setState({ filteredItems: getFilteredItems(query, this.props), query }, () => { // wait will state has updated so we select the first from newly filtered items if (resetActiveItem) { this.setFirstActiveItem(); } }); } /** default `itemListRenderer` implementation */ private renderItemList = (listProps: IItemListRendererProps) => { const { initialContent, noResults } = this.props; const menuContent = renderFilteredItems(listProps, noResults, initialContent); return {menuContent}; }; /** wrapper around `itemRenderer` to inject props */ private renderItem = (item: T, index: number) => { const { activeItem, query } = this.state; const matchesPredicate = this.state.filteredItems.indexOf(item) >= 0; const modifiers: IItemModifiers = { active: activeItem === item, disabled: isItemDisabled(item, index, this.props.itemDisabled), matchesPredicate, }; return this.props.itemRenderer(item, { handleClick: e => this.handleItemSelect(item, e), index, modifiers, query, }); }; private getActiveElement() { if (this.itemsParentRef != null) { return this.itemsParentRef.children.item(this.getActiveIndex()) as HTMLElement; } return undefined; } private getActiveIndex() { const { activeItem } = this.state; // NOTE: this operation is O(n) so it should be avoided in render(). safe for events though. return activeItem == null ? -1 : this.state.filteredItems.indexOf(activeItem); } private getItemsParentPadding() { // assert ref exists because it was checked before calling const { paddingTop, paddingBottom } = getComputedStyle(this.itemsParentRef!); return { paddingBottom: pxToNumber(paddingBottom), paddingTop: pxToNumber(paddingTop), }; } private handleItemSelect = (item: T, event?: React.SyntheticEvent) => { this.setActiveItem(item); Utils.safeInvoke(this.props.onItemSelect, item, event); if (this.props.resetOnSelect) { this.setQuery("", true); } }; private handleKeyDown = (event: React.KeyboardEvent) => { const { keyCode } = event; if (keyCode === Keys.ARROW_UP || keyCode === Keys.ARROW_DOWN) { event.preventDefault(); const nextActiveItem = this.getNextActiveItem(keyCode === Keys.ARROW_UP ? -1 : 1); if (nextActiveItem != null) { // indicate that the active item may need to be scrolled into view after update. this.shouldCheckActiveItemInViewport = true; this.setActiveItem(nextActiveItem); } } Utils.safeInvoke(this.props.onKeyDown, event); }; private handleKeyUp = (event: React.KeyboardEvent) => { const { onKeyUp } = this.props; const { activeItem } = this.state; // using keyup for enter to play nice with Button's keyboard clicking. // if we were to process enter on keydown, then Button would click itself on keyup // and the popvoer would re-open out of our control :(. if (event.keyCode === Keys.ENTER && activeItem != null) { event.preventDefault(); this.handleItemSelect(activeItem, event); } Utils.safeInvoke(onKeyUp, event); }; private handleQueryChange = (event?: React.ChangeEvent) => { const query = event == null ? "" : event.target.value; this.setQuery(query); Utils.safeInvoke(this.props.onQueryChange, query, event); }; /** * Get the next enabled item, moving in the given direction from the start * index. An `undefined` return value means no suitable item was found. * @param direction amount to move in each iteration, typically +/-1 */ private getNextActiveItem(direction: number, startIndex = this.getActiveIndex()): T | null { return getFirstEnabledItem(this.state.filteredItems, this.props.itemDisabled, direction, startIndex); } private setActiveItem(activeItem: T | null) { if (this.props.activeItem === undefined) { this.setState({ activeItem }); } Utils.safeInvoke(this.props.onActiveItemChange, activeItem); } private setFirstActiveItem() { this.setActiveItem(this.getNextActiveItem(1, this.state.filteredItems.length - 1)); } } function pxToNumber(value: string | null) { return value == null ? 0 : parseInt(value.slice(0, -2), 10); } function getFilteredItems(query: string, { items, itemPredicate, itemListPredicate }: IQueryListProps) { if (Utils.isFunction(itemListPredicate)) { // note that implementations can reorder the items here return itemListPredicate(query, items); } else if (Utils.isFunction(itemPredicate)) { return items.filter((item, index) => itemPredicate(query, item, index)); } return items; } /** Wrap number around min/max values: if it exceeds one bound, return the other. */ function wrapNumber(value: number, min: number, max: number) { if (value < min) { return max; } else if (value > max) { return min; } return value; } function isItemDisabled(item: T | null, index: number, itemDisabled?: IListItemsProps["itemDisabled"]) { if (itemDisabled == null || item == null) { return false; } else if (Utils.isFunction(itemDisabled)) { return itemDisabled(item, index); } return !!item[itemDisabled]; } /** * Get the next enabled item, moving in the given direction from the start * index. An `undefined` return value means no suitable item was found. * @param items the list of items * @param isItemDisabled callback to determine if a given item is disabled * @param direction amount to move in each iteration, typically +/-1 * @param startIndex which index to begin moving from */ export function getFirstEnabledItem( items: T[], itemDisabled?: keyof T | ((item: T, index: number) => boolean), direction = 1, startIndex = items.length - 1, ): T | null { if (items.length === 0) { return null; } // remember where we started to prevent an infinite loop let index = startIndex; const maxIndex = items.length - 1; do { // find first non-disabled item index = wrapNumber(index + direction, 0, maxIndex); if (!isItemDisabled(items[index], index, itemDisabled)) { return items[index]; } } while (index !== startIndex); return null; }