Symbol Palette: improve keyboard input experience

This commit is contained in:
yu-i-i
2026-09-22 02:41:34 +02:00
parent 6487e204e5
commit 63e1c74a8d
3 changed files with 83 additions and 29 deletions
@@ -1,19 +1,29 @@
import { useEditorContext } from '../../../shared/context/editor-context' import { useEditorContext } from '../../../shared/context/editor-context'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import PropTypes from 'prop-types'
export default function SymbolPaletteCloseButton() { export default function SymbolPaletteCloseButton() {
const { toggleSymbolPalette } = useEditorContext() const { toggleSymbolPalette } = useEditorContext()
const { t } = useTranslation() const { t } = useTranslation()
const handleClick = () => {
toggleSymbolPalette()
window.dispatchEvent(new CustomEvent('editor:focus'))
}
return ( return (
<div className="symbol-palette-close-button-outer"> <div className="symbol-palette-close-button-outer">
<button <button
type="button" type="button"
className="btn-close symbol-palette-close-button" className="btn-close symbol-palette-close-button"
onClick={toggleSymbolPalette} onClick={handleClick}
aria-label={t('close')} aria-label={t('close')}
> >
</button> </button>
</div> </div>
) )
} }
SymbolPaletteCloseButton.propTypes = {
focusInput: PropTypes.func,
}
@@ -1,16 +1,27 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef, forwardRef } from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip' import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
export default function SymbolPaletteItem({ const SymbolPaletteItem = forwardRef(function ({
focused, focused,
handleSelect, handleSelect,
handleKeyDown, handleKeyDown,
symbol, symbol,
}) { }, ref) {
const buttonRef = useRef(null) const buttonRef = useRef(null)
// call focus() on this item when appropriate // Forward internal ref to parent
useEffect(() => {
if (ref) {
if (typeof ref === 'function') {
ref(buttonRef.current)
} else {
ref.current = buttonRef.current
}
}
}, [ref])
// Focus the item when it becomes focused
useEffect(() => { useEffect(() => {
if ( if (
focused && focused &&
@@ -56,7 +67,7 @@ export default function SymbolPaletteItem({
</button> </button>
</OLTooltip> </OLTooltip>
) )
} })
SymbolPaletteItem.propTypes = { SymbolPaletteItem.propTypes = {
symbol: PropTypes.shape({ symbol: PropTypes.shape({
@@ -70,3 +81,4 @@ SymbolPaletteItem.propTypes = {
handleSelect: PropTypes.func.isRequired, handleSelect: PropTypes.func.isRequired,
focused: PropTypes.bool, focused: PropTypes.bool,
} }
export default SymbolPaletteItem
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { useEditorContext } from '../../../shared/context/editor-context'
import SymbolPaletteItem from './symbol-palette-item' import SymbolPaletteItem from './symbol-palette-item'
export default function SymbolPaletteItems({ export default function SymbolPaletteItems({
@@ -8,54 +9,80 @@ export default function SymbolPaletteItems({
focusInput, focusInput,
}) { }) {
const [focusedIndex, setFocusedIndex] = useState(0) const [focusedIndex, setFocusedIndex] = useState(0)
const itemRefs = useRef([])
// reset the focused item when the list of items changes
useEffect(() => { useEffect(() => {
itemRefs.current = items.map((_, i) => itemRefs.current[i] || null)
setFocusedIndex(0) setFocusedIndex(0)
}, [items]) }, [items])
// navigate through items with left and right arrows const getItemRects = () => {
return itemRefs.current.map(ref => ref?.getBoundingClientRect?.() ?? null)
}
const { toggleSymbolPalette } = useEditorContext()
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
event => { event => {
if (event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) { if (event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return
return
} const rects = getItemRects()
const currentRect = rects[focusedIndex]
if (!currentRect) return
let newIndex = focusedIndex
switch (event.key) { switch (event.key) {
// focus previous item
case 'ArrowLeft': case 'ArrowLeft':
case 'ArrowUp': newIndex = focusedIndex > 0 ? focusedIndex - 1 : items.length - 1
setFocusedIndex(index => (index > 0 ? index - 1 : items.length - 1))
break break
// focus next item
case 'ArrowRight': case 'ArrowRight':
case 'ArrowDown': newIndex = focusedIndex < items.length - 1 ? focusedIndex + 1 : 0
setFocusedIndex(index => (index < items.length - 1 ? index + 1 : 0))
break break
case 'ArrowUp':
case 'ArrowDown': {
const direction = event.key === 'ArrowUp' ? -1 : 1
const candidates = rects
.map((rect, i) => ({ rect, i }))
.filter(({ rect }, i) =>
i !== focusedIndex &&
rect &&
Math.abs(rect.x - currentRect.x) < currentRect.width * 0.8 &&
(direction === -1 ? rect.y < currentRect.y : rect.y > currentRect.y)
)
// focus first item if (candidates.length > 0) {
const closest = candidates.reduce((a, b) =>
Math.abs(b.rect.y - currentRect.y) < Math.abs(a.rect.y - currentRect.y) ? b : a
)
newIndex = closest.i
}
break
}
case 'Home': case 'Home':
setFocusedIndex(0) newIndex = 0
break break
// focus last item
case 'End': case 'End':
setFocusedIndex(items.length - 1) newIndex = items.length - 1
break break
// allow the default action
case 'Enter': case 'Enter':
case ' ': case ' ':
handleSelect(items[focusedIndex])
toggleSymbolPalette()
break
case 'Escape':
toggleSymbolPalette()
window.dispatchEvent(new CustomEvent('editor:focus'))
break break
// any other key returns focus to the input
default: default:
focusInput() focusInput()
break return
} }
event.preventDefault()
setFocusedIndex(newIndex)
}, },
[focusInput, items.length] [focusedIndex, items, focusInput, handleSelect]
) )
return ( return (
@@ -70,11 +97,15 @@ export default function SymbolPaletteItems({
}} }}
handleKeyDown={handleKeyDown} handleKeyDown={handleKeyDown}
focused={index === focusedIndex} focused={index === focusedIndex}
ref={el => {
itemRefs.current[index] = el
}}
/> />
))} ))}
</div> </div>
) )
} }
SymbolPaletteItems.propTypes = { SymbolPaletteItems.propTypes = {
items: PropTypes.arrayOf( items: PropTypes.arrayOf(
PropTypes.shape({ PropTypes.shape({
@@ -84,3 +115,4 @@ SymbolPaletteItems.propTypes = {
handleSelect: PropTypes.func.isRequired, handleSelect: PropTypes.func.isRequired,
focusInput: PropTypes.func.isRequired, focusInput: PropTypes.func.isRequired,
} }