More robust keybindings.

This commit is contained in:
Gert Hengeveld 2020-11-27 17:14:15 +01:00
parent 8b2928552d
commit 8724e7d97b
5 changed files with 67 additions and 20 deletions

View File

@ -22,6 +22,7 @@ import {
isCloseType,
} from './types';
import { searchItem } from './utils';
import { matchesKeyCode, matchesModifiers } from './keybinding';
const DEFAULT_MAX_SEARCH_RESULTS = 50;
@ -175,9 +176,13 @@ export const Search = React.memo<{
useEffect(() => {
const focusSearch = (event: KeyboardEvent) => {
if (!enableShortcuts || isLoading || !inputRef.current) return;
if (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey) return;
if (event.key === '/' && inputRef.current !== document.activeElement) {
if (!enableShortcuts || isLoading || event.repeat) return;
if (!inputRef.current || inputRef.current === document.activeElement) return;
if (
// Shift is required to type `/` on some keyboard layouts
matchesModifiers({ ctrl: false, alt: false, meta: false }, event) &&
matchesKeyCode('Slash', event)
) {
inputRef.current.focus();
event.preventDefault();
}

View File

@ -21,6 +21,7 @@ import {
SearchResult,
} from './types';
import { getLink } from './utils';
import { matchesKeyCode, matchesModifiers } from './keybinding';
const ResultsList = styled.ol({
listStyle: 'none',
@ -188,9 +189,8 @@ export const SearchResults: FunctionComponent<{
}) => {
useEffect(() => {
const handleEscape = (event: KeyboardEvent) => {
if (!enableShortcuts || isLoading) return;
if (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey) return;
if (event.key === 'Escape') {
if (!enableShortcuts || isLoading || event.repeat) return;
if (matchesModifiers(false, event) && matchesKeyCode('Escape', event)) {
const target = event.target as Element;
if (target?.id === 'storybook-explorer-searchfield') return; // handled by downshift
event.preventDefault();

View File

@ -0,0 +1,34 @@
const codeToKeyMap = {
// event.code => event.key
Space: ' ',
Slash: '/',
ArrowLeft: 'ArrowLeft',
ArrowUp: 'ArrowUp',
ArrowRight: 'ArrowRight',
ArrowDown: 'ArrowDown',
Escape: 'Escape',
Enter: 'Enter',
};
interface Modifiers {
alt?: boolean;
ctrl?: boolean;
meta?: boolean;
shift?: boolean;
}
const allFalse = { alt: false, ctrl: false, meta: false, shift: false };
export const matchesModifiers = (modifiers: Modifiers | false, event: KeyboardEvent) => {
const { alt, ctrl, meta, shift } = modifiers === false ? allFalse : modifiers;
if (typeof alt === 'boolean' && alt !== event.altKey) return false;
if (typeof ctrl === 'boolean' && ctrl !== event.ctrlKey) return false;
if (typeof meta === 'boolean' && meta !== event.metaKey) return false;
if (typeof shift === 'boolean' && shift !== event.shiftKey) return false;
return true;
};
export const matchesKeyCode = (code: keyof typeof codeToKeyMap, event: KeyboardEvent) => {
// event.code is preferable but not supported in IE
return event.code ? event.code === code : event.key === codeToKeyMap[code];
};

View File

@ -2,6 +2,7 @@ import { StoriesHash } from '@storybook/api';
import { document } from 'global';
import throttle from 'lodash/throttle';
import React, { Dispatch, MutableRefObject, useCallback, useEffect, useReducer } from 'react';
import { matchesKeyCode, matchesModifiers } from './keybinding';
import { Highlight } from './types';
import { isAncestor, getAncestorIds, getDescendantIds, scrollIntoView } from './utils';
@ -114,9 +115,14 @@ export const useExpanded = ({
const navigateTree = throttle((event: KeyboardEvent) => {
const highlightedItemId =
highlightedRef.current?.refId === refId && highlightedRef.current?.itemId;
if (!isBrowsing || !event.key || !containerRef.current || !highlightedItemId) return;
if (event.repeat || event.shiftKey || event.metaKey || event.ctrlKey || event.altKey) return;
if (!['Enter', ' ', 'ArrowLeft', 'ArrowRight'].includes(event.key)) return;
if (!isBrowsing || !containerRef.current || !highlightedItemId || event.repeat) return;
if (!matchesModifiers(false, event)) return;
const isEnter = matchesKeyCode('Enter', event);
const isSpace = matchesKeyCode('Space', event);
const isArrowLeft = matchesKeyCode('ArrowLeft', event);
const isArrowRight = matchesKeyCode('ArrowRight', event);
if (!(isEnter || isSpace || isArrowLeft || isArrowRight)) return;
const highlightedElement = getElementByDataItemId(highlightedItemId);
if (!highlightedElement || highlightedElement.getAttribute('data-ref-id') !== refId) return;
@ -124,20 +130,20 @@ export const useExpanded = ({
const target = event.target as Element;
if (!isAncestor(menuElement, target) && !isAncestor(target, menuElement)) return;
if (target.hasAttribute('data-action')) {
if (['Enter', ' '].includes(event.key)) return;
if (isEnter || isSpace) return;
(target as HTMLButtonElement).blur();
}
event.preventDefault();
const type = highlightedElement.getAttribute('data-nodetype');
if (['Enter', ' '].includes(event.key) && ['component', 'story', 'document'].includes(type)) {
if ((isEnter || isSpace) && ['component', 'story', 'document'].includes(type)) {
onSelectStoryId(highlightedItemId);
}
const isExpanded = highlightedElement.getAttribute('aria-expanded');
if (event.key === 'ArrowLeft') {
if (isArrowLeft) {
if (isExpanded === 'true') {
// The highlighted node is expanded, so we collapse it.
setExpanded({ ids: [highlightedItemId], value: false });
@ -158,7 +164,7 @@ export const useExpanded = ({
return;
}
if (event.key === 'ArrowRight') {
if (isArrowRight) {
if (isExpanded === 'false') {
updateExpanded({ ids: [highlightedItemId], value: true });
} else if (isExpanded === 'true') {

View File

@ -8,6 +8,7 @@ import {
useRef,
useState,
} from 'react';
import { matchesKeyCode, matchesModifiers } from './keybinding';
import { CombinedDataset, Highlight, Selection } from './types';
import { cycle, isAncestor, scrollIntoView } from './utils';
@ -79,9 +80,12 @@ export const useHighlighted = ({
let lastRequestId: number;
const navigateTree = (event: KeyboardEvent) => {
if (isLoading || !isBrowsing || !event.key || !containerRef || !containerRef.current) return;
if (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey) return;
if (!['ArrowUp', 'ArrowDown'].includes(event.key)) return;
if (isLoading || !isBrowsing || !containerRef.current) return; // allow event.repeat
if (!matchesModifiers(false, event)) return;
const isArrowUp = matchesKeyCode('ArrowUp', event);
const isArrowDown = matchesKeyCode('ArrowDown', event);
if (!(isArrowUp || isArrowDown)) return;
event.preventDefault();
const requestId = window.requestAnimationFrame(() => {
@ -100,10 +104,8 @@ export const useHighlighted = ({
el.getAttribute('data-item-id') === highlightedRef.current?.itemId &&
el.getAttribute('data-ref-id') === highlightedRef.current?.refId
);
const nextIndex = cycle(highlightable, currentIndex, event.key === 'ArrowUp' ? -1 : 1);
const didRunAround =
(event.key === 'ArrowDown' && nextIndex === 0) ||
(event.key === 'ArrowUp' && nextIndex === highlightable.length - 1);
const nextIndex = cycle(highlightable, currentIndex, isArrowUp ? -1 : 1);
const didRunAround = isArrowUp ? nextIndex === highlightable.length - 1 : nextIndex === 0;
highlightElement(highlightable[nextIndex], didRunAround);
});
};