Skip to content

Commit

Permalink
Merge pull request #13319 from storybookjs/13296-event-key
Browse files Browse the repository at this point in the history
UI: Fix keybindings on non-US keyboard layouts
  • Loading branch information
shilman committed Nov 29, 2020
2 parents b747c5c + ea753eb commit 1a42703
Show file tree
Hide file tree
Showing 5 changed files with 67 additions and 20 deletions.
11 changes: 8 additions & 3 deletions lib/ui/src/components/sidebar/Search.tsx
Expand Up @@ -22,6 +22,7 @@ import {
isCloseType,
} from './types';
import { searchItem } from './utils';
import { matchesKeyCode, matchesModifiers } from './keybinding';

const DEFAULT_MAX_SEARCH_RESULTS = 50;

Expand Down Expand Up @@ -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();
}
Expand Down
6 changes: 3 additions & 3 deletions lib/ui/src/components/sidebar/SearchResults.tsx
Expand Up @@ -21,6 +21,7 @@ import {
SearchResult,
} from './types';
import { getLink } from './utils';
import { matchesKeyCode, matchesModifiers } from './keybinding';

const ResultsList = styled.ol({
listStyle: 'none',
Expand Down Expand Up @@ -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();
Expand Down
34 changes: 34 additions & 0 deletions lib/ui/src/components/sidebar/keybinding.ts
@@ -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];
};
20 changes: 13 additions & 7 deletions lib/ui/src/components/sidebar/useExpanded.ts
Expand Up @@ -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';
Expand Down Expand Up @@ -114,30 +115,35 @@ 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;

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 });
Expand All @@ -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') {
Expand Down
16 changes: 9 additions & 7 deletions lib/ui/src/components/sidebar/useHighlighted.ts
Expand Up @@ -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';
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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);
});
};
Expand Down

0 comments on commit 1a42703

Please sign in to comment.