Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UI: Fix keybindings on non-US keyboard layouts #13319

Merged
merged 2 commits into from Nov 29, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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