From fe3245a7a665aa522bfbb7da72a0931ec56b39d4 Mon Sep 17 00:00:00 2001 From: Roman Hotsiy Date: Wed, 14 Mar 2018 12:55:52 +0200 Subject: [PATCH] feat: arrow navigation in search results --- custom.d.ts | 4 +++ src/components/SearchBox/SearchBox.tsx | 45 ++++++++++++++++++++--- src/components/SearchBox/elements.tsx | 4 +++ src/components/SideMenu/MenuItem.tsx | 20 ++++++++++- src/services/MenuStore.ts | 6 ++-- src/utils/dom.ts | 50 ++++++++++++++++++++++++++ 6 files changed, 121 insertions(+), 8 deletions(-) diff --git a/custom.d.ts b/custom.d.ts index 4fbe6022..a4bcfde9 100644 --- a/custom.d.ts +++ b/custom.d.ts @@ -20,3 +20,7 @@ declare var __REDOC_REVISION__: string; declare type Dict = { [key: string]: T; }; + +interface Element { + scrollIntoViewIfNeeded(centerIfNeeded?: boolean): void; +} diff --git a/src/components/SearchBox/SearchBox.tsx b/src/components/SearchBox/SearchBox.tsx index 01257940..8a6b4a4f 100644 --- a/src/components/SearchBox/SearchBox.tsx +++ b/src/components/SearchBox/SearchBox.tsx @@ -21,6 +21,7 @@ export interface SearchBoxProps { export interface SearchBoxState { results: any; term: string; + activeItemIdx: number; } interface SearchResult { @@ -29,11 +30,14 @@ interface SearchResult { } export class SearchBox extends React.PureComponent { + activeItemRef: MenuItem | null = null; + constructor(props) { super(props); this.state = { results: [], term: '', + activeItemIdx: -1, }; } @@ -49,14 +53,40 @@ export class SearchBox extends React.PureComponent { - if (event && event.keyCode === 27) { + handleKeyDown = (event: React.KeyboardEvent) => { + if (event.keyCode === 27) { + // ESQ this.clear(); } + if (event.keyCode === 40) { + // Arrow down + this.setState({ + activeItemIdx: Math.min(this.state.activeItemIdx + 1, this.state.results.length - 1), + }); + event.preventDefault(); + } + if (event.keyCode === 38) { + // Arrow up + this.setState({ + activeItemIdx: Math.max(0, this.state.activeItemIdx - 1), + }); + event.preventDefault(); + } + if (event.keyCode === 13) { + // enter + const activeResult = this.state.results[this.state.activeItemIdx]; + if (activeResult) { + const item = this.props.getItemById(activeResult.id); + if (item) { + this.props.onActivate(item); + } + } + } }; setResults(results: SearchDocument[], term: string) { @@ -84,6 +114,7 @@ export class SearchBox extends React.PureComponent ({ item: this.props.getItemById(res.id), score: res.score, @@ -97,16 +128,20 @@ export class SearchBox extends React.PureComponent {results.length > 0 && ( - {results.map(res => ( + {results.map((res, idx) => ( svg { display: none; } + + &.active { + background-color: #e1e1e1; + } } `; diff --git a/src/components/SideMenu/MenuItem.tsx b/src/components/SideMenu/MenuItem.tsx index a4f2f1c1..c25917ea 100644 --- a/src/components/SideMenu/MenuItem.tsx +++ b/src/components/SideMenu/MenuItem.tsx @@ -14,15 +14,33 @@ interface MenuItemProps { @observer export class MenuItem extends React.Component { + ref: Element | null; + activate = (evt: React.MouseEvent) => { this.props.onActivate!(this.props.item); evt.stopPropagation(); }; + componentDidUpdate() { + if (this.props.item.active) { + this.scrollIntoView(); + } + } + + scrollIntoView() { + if (this.ref) { + this.ref.scrollIntoViewIfNeeded(); + } + } + + saveRef = ref => { + this.ref = ref; + }; + render() { const { item, withoutChildren } = this.props; return ( - + {item.type === 'operation' ? ( ) : ( diff --git a/src/services/MenuStore.ts b/src/services/MenuStore.ts index a99632fd..e347f457 100644 --- a/src/services/MenuStore.ts +++ b/src/services/MenuStore.ts @@ -242,9 +242,11 @@ export class MenuStore { */ @action.bound activateAndScroll(item: IMenuItem | undefined, updateHash?: boolean, rewriteHistory?: boolean) { - this.activate(item, updateHash, rewriteHistory); + // item here can be a copy from search results so find corresponding item from menu + const menuItem = (item && this.getItemById(item.id)) || item; + this.activate(menuItem, updateHash, rewriteHistory); this.scrollToActive(); - if (!item || !item.items.length) { + if (!menuItem || !menuItem.items.length) { this.closeSidebar(); } } diff --git a/src/utils/dom.ts b/src/utils/dom.ts index dfd7a0b8..24ca15f3 100644 --- a/src/utils/dom.ts +++ b/src/utils/dom.ts @@ -23,3 +23,53 @@ export function html2Str(html: string): string { }) .join(' '); } + +// scrollIntoViewIfNeeded polyfill + +if (!(Element as any).prototype.scrollIntoViewIfNeeded) { + (Element as any).prototype.scrollIntoViewIfNeeded = function(centerIfNeeded) { + centerIfNeeded = arguments.length === 0 ? true : !!centerIfNeeded; + + const parent = this.parentNode; + const parentComputedStyle = window.getComputedStyle(parent, undefined); + const parentBorderTopWidth = parseInt( + parentComputedStyle.getPropertyValue('border-top-width'), + 10, + ); + const parentBorderLeftWidth = parseInt( + parentComputedStyle.getPropertyValue('border-left-width'), + 10, + ); + const overTop = this.offsetTop - parent.offsetTop < parent.scrollTop; + const overBottom = + this.offsetTop - parent.offsetTop + this.clientHeight - parentBorderTopWidth > + parent.scrollTop + parent.clientHeight; + const overLeft = this.offsetLeft - parent.offsetLeft < parent.scrollLeft; + const overRight = + this.offsetLeft - parent.offsetLeft + this.clientWidth - parentBorderLeftWidth > + parent.scrollLeft + parent.clientWidth; + const alignWithTop = overTop && !overBottom; + + if ((overTop || overBottom) && centerIfNeeded) { + parent.scrollTop = + this.offsetTop - + parent.offsetTop - + parent.clientHeight / 2 - + parentBorderTopWidth + + this.clientHeight / 2; + } + + if ((overLeft || overRight) && centerIfNeeded) { + parent.scrollLeft = + this.offsetLeft - + parent.offsetLeft - + parent.clientWidth / 2 - + parentBorderLeftWidth + + this.clientWidth / 2; + } + + if ((overTop || overBottom || overLeft || overRight) && !centerIfNeeded) { + this.scrollIntoView(alignWithTop); + } + }; +}