feat: shortcut for focusing search input

This commit is contained in:
EnixCoda 2022-08-20 21:53:04 +08:00
parent 3476ed1072
commit b8866aa415
11 changed files with 232 additions and 127 deletions

View file

@ -1,8 +0,0 @@
import * as React from 'react'
import * as DOMHelper from 'utils/DOMHelper'
export function useFocusFileExplorerOnFirstRender() {
React.useEffect(() => {
DOMHelper.focusFileExplorer()
}, [])
}

View file

@ -1,4 +1,5 @@
import { Label, Text } from '@primer/react'
import { useFocusOnPendingTarget } from 'components/FocusTarget'
import { LoadingIndicator } from 'components/LoadingIndicator'
import { SearchBar } from 'components/SearchBar'
import { useConfigs } from 'containers/ConfigsContext'
@ -14,7 +15,6 @@ import { useLoadedContext } from 'utils/hooks/useLoadedContext'
import { useOnLocationChange } from 'utils/hooks/useOnLocationChange'
import { VisibleNodes, VisibleNodesGenerator } from 'utils/VisibleNodesGenerator'
import { SideBarStateContext } from '../../containers/SideBarState'
import { useFocusFileExplorerOnFirstRender } from './hooks/useFocusFileExplorerOnFirstRender'
import { useGetCurrentPath } from './hooks/useGetCurrentPath'
import { useHandleKeyDown } from './hooks/useHandleKeyDown'
import {
@ -23,7 +23,7 @@ import {
useRenderFileCommentAmounts,
useRenderFileStatus,
useRenderFindInFolderButton,
useRenderGoToButton
useRenderGoToButton,
} from './hooks/useNodeRenderers'
import { useHandleNodeClick } from './hooks/useOnNodeClick'
import { useOnSearch } from './hooks/useOnSearch'
@ -153,8 +153,6 @@ function LoadedFileExplorer({
])
const renderLabelText = useRenderLabelText(searchKey)
useFocusFileExplorerOnFirstRender()
const goToCurrentItem = React.useCallback(() => {
const targetPath = platform.getCurrentPath(metaData.branchName)
if (targetPath) expandTo(targetPath)
@ -163,8 +161,14 @@ function LoadedFileExplorer({
useOnLocationChange(goToCurrentItem)
useAfterRedirect(goToCurrentItem)
const ref = React.useRef<HTMLDivElement | null>(null)
useFocusOnPendingTarget(
'files',
React.useCallback(() => ref.current?.focus(), []),
)
return (
<div className={`file-explorer`} tabIndex={-1} onKeyDown={handleKeyDown}>
<div ref={ref} className={`file-explorer`} tabIndex={-1} onKeyDown={handleKeyDown}>
{visibleNodesGenerator?.defer && (
<div className={'status'}>
<Label

View file

@ -0,0 +1,14 @@
import * as React from 'react'
import { SidebarContext } from './SidebarContext'
export type FocusTarget = 'files' | 'search' | null
export function useFocusOnPendingTarget(target: FocusTarget, method: () => void) {
const { pendingFocusTarget } = React.useContext(SidebarContext)
React.useEffect(() => {
if (pendingFocusTarget.value === target) {
method()
pendingFocusTarget.onChange(null)
}
}, [target, method, pendingFocusTarget])
}

View file

@ -2,7 +2,8 @@ import { SearchIcon } from '@primer/octicons-react'
import { TextInput, TextInputProps } from '@primer/react'
import { useConfigs } from 'containers/ConfigsContext'
import * as React from 'react'
import { isValidRegexpSource } from 'utils/general'
import { formatWithShortcut, isValidRegexpSource } from 'utils/general'
import { useFocusOnPendingTarget } from './FocusTarget'
import { SearchMode } from './searchModes'
type Props = {
@ -11,8 +12,14 @@ type Props = {
} & Required<Pick<TextInputProps, 'onFocus'>>
export function SearchBar({ onSearch, onFocus, value }: Props) {
const ref = React.useRef<HTMLInputElement | null>(null)
useFocusOnPendingTarget(
'search',
React.useCallback(() => ref.current?.focus(), []),
)
const configs = useConfigs()
const { searchMode } = configs.value
const { searchMode, focusSearchInputShortcut } = configs.value
const toggleButtonDescription =
searchMode === 'regex'
@ -26,6 +33,7 @@ export function SearchBar({ onSearch, onFocus, value }: Props) {
return (
<TextInput
ref={ref}
leadingVisual={SearchIcon}
onFocus={e => {
onFocus(e)
@ -35,7 +43,7 @@ export function SearchBar({ onSearch, onFocus, value }: Props) {
sx={{ borderRadius: 0 }}
className={'search-input'}
aria-label="search files"
placeholder={`Search files`}
placeholder={formatWithShortcut(`Search files`, focusSearchInputShortcut)}
onChange={({ target: { value } }) => onSearch(value, searchMode)}
value={value}
validationStatus={validationStatus}

View file

@ -14,18 +14,21 @@ import { Config } from 'utils/config/helper'
import { cx } from 'utils/cx'
import * as DOMHelper from 'utils/DOMHelper'
import * as features from 'utils/features'
import { detectBrowser } from 'utils/general'
import { detectBrowser, formatWithShortcut } from 'utils/general'
import { useConditionalHook } from 'utils/hooks/useConditionalHook'
import { useAfterRedirect, usePJAXAPI } from 'utils/hooks/useFastRedirect'
import { useLoadedContext } from 'utils/hooks/useLoadedContext'
import { ResizeState } from 'utils/hooks/useResizeHandler'
import * as keyHelper from 'utils/keyHelper'
import { useStateIO } from 'utils/hooks/useStateIO'
import { SideBarErrorContext } from '../containers/ErrorContext'
import { SideBarStateContext } from '../containers/SideBarState'
import { Theme } from '../containers/Theme'
import { useOnShortcutPressed } from '../utils/hooks/useOnShortcutPressed'
import { FocusTarget } from './FocusTarget'
import { LoadingIndicator } from './LoadingIndicator'
import { RoundIconButton } from './RoundIconButton'
import { SettingsBarContent } from './settings/SettingsBar'
import { SidebarContext } from './SidebarContext'
import { SideBarResizeHandler } from './SideBarResizeHandler'
export function SideBar() {
@ -36,11 +39,19 @@ export function SideBar() {
const error = useLoadedContext(SideBarErrorContext).value
const [shouldExpand, setShouldExpand, toggleShowSideBar] = useShouldExpand()
useFocusSidebarOnExpand(shouldExpand)
const pendingFocusTarget = useStateIO<FocusTarget>(null)
useShowSidebarKeyboard(
shouldExpand,
setShouldExpand,
toggleShowSideBar,
pendingFocusTarget.onChange,
)
const configContext = useConfigs()
const blockLeaveRef = React.useRef(false)
const { sidebarToggleMode } = configContext.value
const { sidebarToggleMode, shortcut, focusSearchInputShortcut } = configContext.value
const onResizeStateChange = React.useCallback((state: ResizeState) => {
blockLeaveRef.current = state === 'resizing'
}, [])
@ -50,110 +61,126 @@ export function SideBar() {
() => useWindowSize().height, // eslint-disable-line react-hooks/rules-of-hooks
)
const sidebarContextValue = React.useMemo(() => ({ pendingFocusTarget }), [pendingFocusTarget])
return (
<Theme>
<IIFC>
{() => {
const logoContainerElement = useLogoContainerElement()
return (
<Portal into={logoContainerElement}>
<ToggleShowButton
error={error}
className={cx({
hidden: shouldExpand,
})}
onHover={sidebarToggleMode === 'float' ? () => setShouldExpand(true) : undefined}
onClick={toggleShowSideBar}
/>
</Portal>
)
}}
</IIFC>
<div className={'gitako-side-bar'}>
<div
className={cx('gitako-side-bar-body-wrapper', `toggle-mode-${sidebarToggleMode}`, {
collapsed: error || !shouldExpand,
})}
style={{ height: heightForSafari }}
onMouseLeave={() => {
if (blockLeaveRef.current) return
if (sidebarToggleMode === 'float') setShouldExpand(false)
<SidebarContext.Provider value={sidebarContextValue}>
<IIFC>
{() => {
const logoContainerElement = useLogoContainerElement()
return (
<Portal into={logoContainerElement}>
<ToggleShowButton
error={error}
className={cx({
hidden: shouldExpand,
})}
onHover={sidebarToggleMode === 'float' ? () => setShouldExpand(true) : undefined}
onClick={toggleShowSideBar}
/>
</Portal>
)
}}
>
<div className={'gitako-side-bar-body'}>
<div className={'gitako-side-bar-content'}>
<div className={'header'}>
<div className={'side-bar-position-controls'}>
{sidebarToggleMode === 'persistent' && (
</IIFC>
<div className={'gitako-side-bar'}>
<div
className={cx('gitako-side-bar-body-wrapper', `toggle-mode-${sidebarToggleMode}`, {
collapsed: error || !shouldExpand,
})}
style={{ height: heightForSafari }}
onMouseLeave={() => {
if (blockLeaveRef.current) return
if (sidebarToggleMode === 'float') setShouldExpand(false)
}}
>
<div className={'gitako-side-bar-body'}>
<div className={'gitako-side-bar-content'}>
<div className={'header'}>
<div className={'side-bar-position-controls'}>
{sidebarToggleMode === 'persistent' && (
<RoundIconButton
icon={TabIcon}
aria-label={formatWithShortcut('Collapse sidebar', shortcut)}
sx={{
transform: 'rotateY(180deg)',
}}
onClick={toggleShowSideBar}
/>
)}
<RoundIconButton
icon={TabIcon}
aria-label={'Collapse sidebar'}
icon={PinIcon}
aria-label={'Toggle sidebar dock mode between float and persistent'}
iconColor={sidebarToggleMode === 'persistent' ? 'fg.default' : undefined}
sx={{
transform: 'rotateY(180deg)',
}}
onClick={toggleShowSideBar}
onClick={() =>
configContext.onChange({
sidebarToggleMode:
sidebarToggleMode === 'persistent' ? 'float' : 'persistent',
})
}
/>
)}
<RoundIconButton
icon={PinIcon}
aria-label={'Toggle sidebar dock mode between float and persistent'}
iconColor={sidebarToggleMode === 'persistent' ? 'fg.default' : undefined}
sx={{
transform: 'rotateY(180deg)',
}}
onClick={() =>
configContext.onChange({
sidebarToggleMode:
sidebarToggleMode === 'persistent' ? 'float' : 'persistent',
})
}
/>
</div>
<MetaBar />
</div>
<MetaBar />
<IIFC>
{() => {
switch (useLoadedContext(SideBarStateContext).value) {
case 'getting-access-token':
return <LoadingIndicator text={'Getting access token...'} />
case 'after-getting-access-token':
case 'meta-loading':
return <LoadingIndicator text={'Fetching repo meta...'} />
case 'error-due-to-auth':
return <AccessDeniedDescription />
case 'meta-loaded':
case 'tree-loading':
case 'tree-rendering':
case 'tree-rendered':
return <FileExplorer />
}
}}
</IIFC>
</div>
<IIFC>
{() => {
switch (useLoadedContext(SideBarStateContext).value) {
case 'getting-access-token':
return <LoadingIndicator text={'Getting access token...'} />
case 'after-getting-access-token':
case 'meta-loading':
return <LoadingIndicator text={'Fetching repo meta...'} />
case 'error-due-to-auth':
return <AccessDeniedDescription />
case 'meta-loaded':
case 'tree-loading':
case 'tree-rendering':
case 'tree-rendered':
return <FileExplorer />
}
const [showSettings, setShowSettings] = React.useState(false)
const toggleShowSettings = React.useCallback(
() => setShowSettings(show => !show),
[],
)
useOnShortcutPressed(
focusSearchInputShortcut,
React.useCallback(() => setShowSettings(false), []),
)
return (
<>
{showSettings && <SettingsBarContent toggleShow={toggleShowSettings} />}
<Footer toggleShowSettings={toggleShowSettings} />
</>
)
}}
</IIFC>
</div>
<IIFC>
{() => {
const [showSettings, setShowSettings] = React.useState(false)
const toggleShowSettings = React.useCallback(
() => setShowSettings(show => !show),
[],
)
return (
<>
{showSettings && <SettingsBarContent toggleShow={toggleShowSettings} />}
<Footer toggleShowSettings={toggleShowSettings} />
</>
)
}}
</IIFC>
{features.resize && <SideBarResizeHandler onResizeStateChange={onResizeStateChange} />}
</div>
{features.resize && <SideBarResizeHandler onResizeStateChange={onResizeStateChange} />}
</div>
</div>
</SidebarContext.Provider>
</Theme>
)
}
function useFocusSidebarOnExpand(shouldExpand: boolean) {
React.useEffect(() => {
// prevent keeping focus within Gitako
if (!shouldExpand) document.body.focus()
}, [shouldExpand])
}
function useMarkGitakoReadyState() {
React.useEffect(() => {
DOMHelper.markGitakoReadyState(true)
@ -221,25 +248,6 @@ function useSaveExpandStateOnToggle(shouldExpand: boolean) {
}, [shouldExpand, intelligentToggle]) // eslint-disable-line react-hooks/exhaustive-deps
}
function useToggleSideBarWithKeyboard(toggleShowSideBar: () => void) {
const { shortcut } = useConfigs().value
const state = useLoadedContext(SideBarStateContext).value
const isDisabled = state === 'disabled' || !shortcut
React.useEffect(
function attachKeyDown() {
if (isDisabled) return
function onKeyDown(e: KeyboardEvent) {
const keys = keyHelper.parseEvent(e)
if (keys === shortcut) toggleShowSideBar()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
},
[toggleShowSideBar, isDisabled, shortcut],
)
}
function useCollapseOnNoPermissionWhenTokenHasBeenSet(
setShowSideBar: React.Dispatch<React.SetStateAction<boolean>>,
) {
@ -266,12 +274,40 @@ function useShouldExpand() {
useSaveExpandStateOnToggle(shouldExpand)
useUpdateBodyIndentOnStateUpdate(shouldExpand)
useUpdateBodyIndentAfterRedirect(setShouldExpand)
useToggleSideBarWithKeyboard(toggleShowSideBar)
useCollapseOnNoPermissionWhenTokenHasBeenSet(setShouldExpand)
React.useEffect(() => {
if (shouldExpand) DOMHelper.focusFileExplorer() // TODO: verify if it works
}, [shouldExpand])
return [shouldExpand, setShouldExpand, toggleShowSideBar] as const
}
function useShowSidebarKeyboard(
shouldExpand: boolean,
setShouldExpand: React.Dispatch<React.SetStateAction<boolean>>,
toggleShowSideBar: () => void,
setFocusTarget: React.Dispatch<React.SetStateAction<FocusTarget>>,
) {
const config = useConfigs().value
useOnShortcutPressed(
config.shortcut,
React.useCallback(
e => {
DOMHelper.cancelEvent(e)
toggleShowSideBar()
if (!shouldExpand) setFocusTarget('files')
},
[shouldExpand, toggleShowSideBar, setFocusTarget],
),
)
useOnShortcutPressed(
config.focusSearchInputShortcut,
React.useCallback(
e => {
DOMHelper.cancelEvent(e)
if (!shouldExpand) setShouldExpand(true)
setFocusTarget('search')
},
[shouldExpand, setShouldExpand, setFocusTarget],
),
)
}

View file

@ -0,0 +1,10 @@
import React from 'react'
import { noop } from 'utils/general'
import { FocusTarget } from './FocusTarget'
// Use this to pass state across components under Sidebar
export const SidebarContext = React.createContext<{
pendingFocusTarget: IO<FocusTarget>
}>({
pendingFocusTarget: { onChange: noop, value: null },
})

View file

@ -14,6 +14,10 @@ export function SidebarSettings() {
label={'Keyboard shortcut to toggle visibility'}
{...subIO(useConfigs(), 'shortcut')}
/>
<KeyboardShortcutSetting
label={'Keyboard shortcut to focus search input'}
{...subIO(useConfigs(), 'focusSearchInputShortcut')}
/>
<SimpleConfigFieldCheckbox
field={{
key: 'intelligentToggle',

View file

@ -179,3 +179,8 @@ export function formatClass(className: string) {
export function parseIntFromElement(e: HTMLElement): number {
return parseInt((e.innerText || '').replace(/[^0-9]/g, ''))
}
export function cancelEvent(e: KeyboardEvent): void {
e.stopPropagation()
e.preventDefault()
}

View file

@ -5,7 +5,8 @@ import { migrateConfig } from './migrations'
export type Config = {
sideBarWidth: number
shortcut: string | undefined
shortcut: string | undefined // shortcut for toggling sidebar
focusSearchInputShortcut: string | undefined // shortcut for focusing search input
accessToken: string | undefined
compressSingletonFolder: boolean
copyFileButton: boolean
@ -29,6 +30,7 @@ export type ConfigKeys = keyof Config
enum configKeys {
sideBarWidth = 'sideBarWidth',
shortcut = 'shortcut',
focusSearchInputShortcut = 'focusSearchInputShortcut',
accessToken = 'accessToken',
compressSingletonFolder = 'compressSingletonFolder',
copyFileButton = 'copyFileButton',
@ -54,6 +56,7 @@ const isInGitHub = platformStorageKey === 'platform_github.com'
export const getDefaultConfigs: () => Config = () => ({
sideBarWidth: 260,
shortcut: undefined,
focusSearchInputShortcut: undefined,
accessToken: '',
compressSingletonFolder: true,
copyFileButton: !isInGitHub, // disable on github.com

View file

@ -73,6 +73,10 @@ export function friendlyFormatShortcut(shortcut?: string) {
}
}
export function formatWithShortcut(prefix: string, shortcut?: string) {
return shortcut ? `${prefix} (${friendlyFormatShortcut(shortcut)})` : prefix
}
export async function traverse<T>(
range: T[] = [],
conditionAndEffect: (node: T) => Async<boolean>,

View file

@ -0,0 +1,25 @@
import * as React from 'react'
import { useLoadedContext } from 'utils/hooks/useLoadedContext'
import * as keyHelper from 'utils/keyHelper'
import { SideBarStateContext } from '../../containers/SideBarState'
export function useOnShortcutPressed(
shortcut: string | undefined,
onPressed: (e: KeyboardEvent) => void,
) {
const state = useLoadedContext(SideBarStateContext).value
const isDisabled = state === 'disabled' || !shortcut
React.useEffect(
function attachKeyDown() {
if (isDisabled) return
function onKeyDown(e: KeyboardEvent) {
const keys = keyHelper.parseEvent(e)
if (keys === shortcut) onPressed(e)
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
},
[onPressed, isDisabled, shortcut],
)
}