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> = {
[key: string]: T;
};
interface Element {
scrollIntoViewIfNeeded(centerIfNeeded?: boolean): void;
}

View File

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

View File

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

View File

@ -14,15 +14,33 @@ interface MenuItemProps {
@observer
export class MenuItem extends React.Component<MenuItemProps> {
ref: Element | null;
activate = (evt: React.MouseEvent<HTMLElement>) => {
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 (
<MenuItemLi onClick={this.activate} depth={item.depth}>
<MenuItemLi onClick={this.activate} depth={item.depth} innerRef={this.saveRef}>
{item.type === 'operation' ? (
<OperationMenuItemContent item={item as OperationModel} />
) : (

View File

@ -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();
}
}

View File

@ -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);
}
};
}