@@ -17,7 +17,6 @@ | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import key from 'keymaster'; | |||
import { debounce, keyBy, uniqBy } from 'lodash'; | |||
import * as React from 'react'; | |||
import { FormattedMessage } from 'react-intl'; | |||
@@ -29,7 +28,7 @@ import SearchBox from '../../../components/controls/SearchBox'; | |||
import ClockIcon from '../../../components/icons/ClockIcon'; | |||
import { lazyLoadComponent } from '../../../components/lazyLoadComponent'; | |||
import DeferredSpinner from '../../../components/ui/DeferredSpinner'; | |||
import { KeyboardCodes } from '../../../helpers/keycodes'; | |||
import { KeyboardKeys } from '../../../helpers/keycodes'; | |||
import { translate, translateWithParameters } from '../../../helpers/l10n'; | |||
import { scrollToElement } from '../../../helpers/scrolling'; | |||
import { getComponentOverviewUrl } from '../../../helpers/urls'; | |||
@@ -77,11 +76,7 @@ export class Search extends React.PureComponent<WithRouterProps, State> { | |||
componentDidMount() { | |||
this.mounted = true; | |||
key('s', () => { | |||
this.focusInput(); | |||
this.openSearch(); | |||
return false; | |||
}); | |||
document.addEventListener('keydown', this.handleSKeyDown); | |||
} | |||
componentDidUpdate(_prevProps: WithRouterProps, prevState: State) { | |||
@@ -92,7 +87,7 @@ export class Search extends React.PureComponent<WithRouterProps, State> { | |||
componentWillUnmount() { | |||
this.mounted = false; | |||
key.unbind('s'); | |||
document.removeEventListener('keydown', this.handleSKeyDown); | |||
} | |||
focusInput = () => { | |||
@@ -227,9 +222,8 @@ export class Search extends React.PureComponent<WithRouterProps, State> { | |||
const list = this.getPlainComponentsList(results, more); | |||
const index = list.indexOf(selected); | |||
return index > 0 ? { selected: list[index - 1] } : null; | |||
} else { | |||
return null; | |||
} | |||
return null; | |||
}); | |||
}; | |||
@@ -239,9 +233,8 @@ export class Search extends React.PureComponent<WithRouterProps, State> { | |||
const list = this.getPlainComponentsList(results, more); | |||
const index = list.indexOf(selected); | |||
return index >= 0 && index < list.length - 1 ? { selected: list[index + 1] } : null; | |||
} else { | |||
return null; | |||
} | |||
return null; | |||
}); | |||
}; | |||
@@ -278,22 +271,38 @@ export class Search extends React.PureComponent<WithRouterProps, State> { | |||
} | |||
}; | |||
handleSKeyDown = (event: KeyboardEvent) => { | |||
const { tagName } = event.target as HTMLElement; | |||
const isInput = tagName === 'INPUT' || tagName === 'SELECT' || tagName === 'TEXTAREA'; | |||
if (event.key === KeyboardKeys.KeyS && !isInput) { | |||
event.preventDefault(); | |||
this.focusInput(); | |||
this.openSearch(); | |||
} | |||
}; | |||
handleKeyDown = (event: React.KeyboardEvent) => { | |||
switch (event.nativeEvent.code) { | |||
case KeyboardCodes.Enter: | |||
switch (event.nativeEvent.key) { | |||
case KeyboardKeys.Enter: | |||
event.preventDefault(); | |||
event.nativeEvent.stopImmediatePropagation(); | |||
this.openSelected(); | |||
return; | |||
case KeyboardCodes.UpArrow: | |||
break; | |||
case KeyboardKeys.UpArrow: | |||
event.preventDefault(); | |||
event.nativeEvent.stopImmediatePropagation(); | |||
this.selectPrevious(); | |||
return; | |||
case KeyboardCodes.DownArrow: | |||
break; | |||
case KeyboardKeys.Escape: | |||
event.preventDefault(); | |||
event.nativeEvent.stopImmediatePropagation(); | |||
this.closeSearch(); | |||
break; | |||
case KeyboardKeys.DownArrow: | |||
event.preventDefault(); | |||
event.nativeEvent.stopImmediatePropagation(); | |||
this.selectNext(); | |||
// keep this return to prevent fall-through in case more cases will be adder later | |||
// eslint-disable-next-line no-useless-return | |||
return; | |||
break; | |||
} | |||
}; | |||
@@ -19,9 +19,9 @@ | |||
*/ | |||
import { shallow, ShallowWrapper } from 'enzyme'; | |||
import * as React from 'react'; | |||
import { KeyboardCodes } from '../../../../helpers/keycodes'; | |||
import { KeyboardKeys } from '../../../../helpers/keycodes'; | |||
import { mockRouter } from '../../../../helpers/testMocks'; | |||
import { elementKeydown } from '../../../../helpers/testUtils'; | |||
import { elementKeydown, keydown } from '../../../../helpers/testUtils'; | |||
import { ComponentQualifier } from '../../../../types/component'; | |||
import { Search } from '../Search'; | |||
@@ -57,7 +57,7 @@ it('opens selected project on enter', () => { | |||
selected: selectedKey | |||
}); | |||
elementKeydown(form.find('SearchBox'), KeyboardCodes.Enter); | |||
elementKeydown(form.find('SearchBox'), KeyboardKeys.Enter); | |||
expect(router.push).toBeCalledWith({ pathname: '/dashboard', query: { id: selectedKey } }); | |||
}); | |||
@@ -73,7 +73,7 @@ it('opens selected portfolio on enter', () => { | |||
selected: selectedKey | |||
}); | |||
elementKeydown(form.find('SearchBox'), KeyboardCodes.Enter); | |||
elementKeydown(form.find('SearchBox'), KeyboardKeys.Enter); | |||
expect(router.push).toBeCalledWith({ pathname: '/portfolio', query: { id: selectedKey } }); | |||
}); | |||
@@ -89,7 +89,7 @@ it('opens selected subportfolio on enter', () => { | |||
selected: selectedKey | |||
}); | |||
elementKeydown(form.find('SearchBox'), KeyboardCodes.Enter); | |||
elementKeydown(form.find('SearchBox'), KeyboardKeys.Enter); | |||
expect(router.push).toBeCalledWith({ pathname: '/portfolio', query: { id: selectedKey } }); | |||
}); | |||
@@ -101,6 +101,15 @@ it('shows warning about short input', () => { | |||
expect(form.find('.navbar-search-input-hint')).toMatchSnapshot(); | |||
}); | |||
it('should open the results when pressing key S and close it when pressing Escape', () => { | |||
const router = mockRouter(); | |||
const form = shallowRender({ router }); | |||
keydown({ key: KeyboardKeys.KeyS }); | |||
expect(form.state().open).toBe(true); | |||
elementKeydown(form.find('SearchBox'), KeyboardKeys.Escape); | |||
expect(form.state().open).toBe(false); | |||
}); | |||
function shallowRender(props: Partial<Search['props']> = {}) { | |||
return shallow<Search>( | |||
// @ts-ignore | |||
@@ -113,12 +122,12 @@ function component(key: string, qualifier = ComponentQualifier.Project) { | |||
} | |||
function next(form: ShallowWrapper<Search['props'], Search['state']>, expected: string) { | |||
elementKeydown(form.find('SearchBox'), KeyboardCodes.DownArrow); | |||
elementKeydown(form.find('SearchBox'), KeyboardKeys.DownArrow); | |||
expect(form.state().selected).toBe(expected); | |||
} | |||
function prev(form: ShallowWrapper<Search['props'], Search['state']>, expected: string) { | |||
elementKeydown(form.find('SearchBox'), KeyboardCodes.UpArrow); | |||
elementKeydown(form.find('SearchBox'), KeyboardKeys.UpArrow); | |||
expect(form.state().selected).toBe(expected); | |||
} | |||
@@ -37,6 +37,11 @@ export enum KeyboardCodes { | |||
} | |||
export enum KeyboardKeys { | |||
Escape = 'Escape', | |||
UpArrow = 'ArrowUp', | |||
DownArrow = 'ArrowDown', | |||
Enter = 'Enter', | |||
Space = ' ', | |||
Alt = 'Alt', | |||
KeyF = 'f', | |||
KeyA = 'a', | |||
@@ -44,6 +49,5 @@ export enum KeyboardKeys { | |||
KeyI = 'i', | |||
KeyC = 'c', | |||
KeyT = 't', | |||
Space = ' ', | |||
Escape = 'Escape' | |||
KeyS = 's' | |||
} |
@@ -87,10 +87,15 @@ export function keydown(args: { code?: KeyboardCodes; key?: KeyboardKeys }): voi | |||
document.dispatchEvent(event); | |||
} | |||
export function elementKeydown(element: ShallowWrapper, code: KeyboardCodes): void { | |||
export function elementKeydown(element: ShallowWrapper, key: KeyboardKeys): void { | |||
const event = { | |||
currentTarget: { element }, | |||
nativeEvent: { code }, | |||
nativeEvent: { | |||
key, | |||
stopImmediatePropagation: () => { | |||
/* noop */ | |||
} | |||
}, | |||
preventDefault() { | |||
/*noop*/ | |||
} |