feat: arrow navigation in search results

This commit is contained in:
Roman Hotsiy 2018-03-14 12:55:52 +02:00
parent e5458c0564
commit fe3245a7a6
No known key found for this signature in database
GPG Key ID: 5CB7B3ACABA57CB0
6 changed files with 121 additions and 8 deletions

4
custom.d.ts vendored
View File

@ -20,3 +20,7 @@ declare var __REDOC_REVISION__: string;
declare type Dict<T> = { declare type Dict<T> = {
[key: string]: T; [key: string]: T;
}; };
interface Element {
scrollIntoViewIfNeeded(centerIfNeeded?: boolean): void;
}

View File

@ -21,6 +21,7 @@ export interface SearchBoxProps {
export interface SearchBoxState { export interface SearchBoxState {
results: any; results: any;
term: string; term: string;
activeItemIdx: number;
} }
interface SearchResult { interface SearchResult {
@ -29,11 +30,14 @@ interface SearchResult {
} }
export class SearchBox extends React.PureComponent<SearchBoxProps, SearchBoxState> { export class SearchBox extends React.PureComponent<SearchBoxProps, SearchBoxState> {
activeItemRef: MenuItem | null = null;
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
results: [], results: [],
term: '', term: '',
activeItemIdx: -1,
}; };
} }
@ -49,14 +53,40 @@ export class SearchBox extends React.PureComponent<SearchBoxProps, SearchBoxStat
this.setState({ this.setState({
results: [], results: [],
term: '', term: '',
activeItemIdx: -1,
}); });
this.props.marker.unmark(); this.props.marker.unmark();
}; };
clearIfEsq = event => { handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event && event.keyCode === 27) { if (event.keyCode === 27) {
// ESQ
this.clear(); 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) { setResults(results: SearchDocument[], term: string) {
@ -84,6 +114,7 @@ export class SearchBox extends React.PureComponent<SearchBoxProps, SearchBoxStat
}; };
render() { render() {
const { activeItemIdx } = this.state;
const results: SearchResult[] = this.state.results.map(res => ({ const results: SearchResult[] = this.state.results.map(res => ({
item: this.props.getItemById(res.id), item: this.props.getItemById(res.id),
score: res.score, score: res.score,
@ -97,16 +128,20 @@ export class SearchBox extends React.PureComponent<SearchBoxProps, SearchBoxStat
<SearchIcon /> <SearchIcon />
<SearchInput <SearchInput
value={this.state.term} value={this.state.term}
onKeyDown={this.clearIfEsq} onKeyDown={this.handleKeyDown}
placeholder="Search..." placeholder="Search..."
type="text" type="text"
onChange={this.search} onChange={this.search}
/> />
{results.length > 0 && ( {results.length > 0 && (
<SearchResultsBox> <SearchResultsBox>
{results.map(res => ( {results.map((res, idx) => (
<MenuItem <MenuItem
item={res.item} item={Object.create(res.item, {
active: {
value: idx === activeItemIdx,
},
})}
onActivate={this.props.onActivate} onActivate={this.props.onActivate}
withoutChildren={true} withoutChildren={true}
key={res.item.id} key={res.item.id}

View File

@ -71,6 +71,10 @@ export const SearchResultsBox = styled.div.attrs({
> svg { > svg {
display: none; display: none;
} }
&.active {
background-color: #e1e1e1;
}
} }
`; `;

View File

@ -14,15 +14,33 @@ interface MenuItemProps {
@observer @observer
export class MenuItem extends React.Component<MenuItemProps> { export class MenuItem extends React.Component<MenuItemProps> {
ref: Element | null;
activate = (evt: React.MouseEvent<HTMLElement>) => { activate = (evt: React.MouseEvent<HTMLElement>) => {
this.props.onActivate!(this.props.item); this.props.onActivate!(this.props.item);
evt.stopPropagation(); evt.stopPropagation();
}; };
componentDidUpdate() {
if (this.props.item.active) {
this.scrollIntoView();
}
}
scrollIntoView() {
if (this.ref) {
this.ref.scrollIntoViewIfNeeded();
}
}
saveRef = ref => {
this.ref = ref;
};
render() { render() {
const { item, withoutChildren } = this.props; const { item, withoutChildren } = this.props;
return ( return (
<MenuItemLi onClick={this.activate} depth={item.depth}> <MenuItemLi onClick={this.activate} depth={item.depth} innerRef={this.saveRef}>
{item.type === 'operation' ? ( {item.type === 'operation' ? (
<OperationMenuItemContent item={item as OperationModel} /> <OperationMenuItemContent item={item as OperationModel} />
) : ( ) : (

View File

@ -242,9 +242,11 @@ export class MenuStore {
*/ */
@action.bound @action.bound
activateAndScroll(item: IMenuItem | undefined, updateHash?: boolean, rewriteHistory?: boolean) { 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(); this.scrollToActive();
if (!item || !item.items.length) { if (!menuItem || !menuItem.items.length) {
this.closeSidebar(); this.closeSidebar();
} }
} }

View File

@ -23,3 +23,53 @@ export function html2Str(html: string): string {
}) })
.join(' '); .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);
}
};
}