diff --git a/lib/ui/src/components/sidebar/Search.tsx b/lib/ui/src/components/sidebar/Search.tsx index 034fea29271d..fbceb15d53e8 100644 --- a/lib/ui/src/components/sidebar/Search.tsx +++ b/lib/ui/src/components/sidebar/Search.tsx @@ -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(); } diff --git a/lib/ui/src/components/sidebar/SearchResults.tsx b/lib/ui/src/components/sidebar/SearchResults.tsx index 68bae2a066f2..a72bfcd030cf 100644 --- a/lib/ui/src/components/sidebar/SearchResults.tsx +++ b/lib/ui/src/components/sidebar/SearchResults.tsx @@ -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(); diff --git a/lib/ui/src/components/sidebar/keybinding.ts b/lib/ui/src/components/sidebar/keybinding.ts new file mode 100644 index 000000000000..f66ee3cfc344 --- /dev/null +++ b/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]; +}; diff --git a/lib/ui/src/components/sidebar/useExpanded.ts b/lib/ui/src/components/sidebar/useExpanded.ts index 84fa23ee9e22..45a643a3bb49 100644 --- a/lib/ui/src/components/sidebar/useExpanded.ts +++ b/lib/ui/src/components/sidebar/useExpanded.ts @@ -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') { diff --git a/lib/ui/src/components/sidebar/useHighlighted.ts b/lib/ui/src/components/sidebar/useHighlighted.ts index b66f93d038a5..1da2e5281d13 100644 --- a/lib/ui/src/components/sidebar/useHighlighted.ts +++ b/lib/ui/src/components/sidebar/useHighlighted.ts @@ -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); }); };