Files
Mathias Jakobsen 77f208633b Merge pull request #36233 from overleaf/mj-command-palette-events
[web] Add events to command palette usage

GitOrigin-RevId: 1bb40c20f3228027ee4470dc84fa2983bb910cbf
2026-09-17 11:49:19 +00:00

164 lines
4.7 KiB
TypeScript

import {
FC,
KeyboardEvent,
memo,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import { CommandPaletteSearchResult } from '../types'
import { OLModal, OLModalBody } from '@/shared/components/ol/ol-modal'
import classNames from 'classnames'
import useEventListener from '@/shared/hooks/use-event-listener'
import useCommandPaletteResults from '../hooks/use-command-palette-results'
import { debugConsole } from '@/utils/debugging'
import SplitTestBadge from '@/shared/components/split-test-badge'
import { useTranslation } from 'react-i18next'
import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics'
type CommandPaletteBodyProps = {
show: boolean
onHide(): void
}
const CommandPaletteBody: FC<CommandPaletteBodyProps> = ({ show, onHide }) => {
const [query, setQuery] = useState('')
const [selectedIndex, setSelectedIndex] = useState(0)
const resultsRef = useRef<HTMLUListElement>(null)
const results = useCommandPaletteResults(query)
const { t } = useTranslation()
const { sendEvent } = useEditorAnalytics()
const selectedResultRef = useRef(false)
useEffect(() => {
sendEvent('command-palette-opened')
return () => {
if (!selectedResultRef.current) {
sendEvent('command-palette-dismissed')
}
}
}, [sendEvent])
useEffect(() => {
setSelectedIndex(0)
}, [results])
useEffect(() => {
const el = resultsRef.current?.children[selectedIndex] as
| HTMLElement
| undefined
el?.scrollIntoView({ block: 'nearest' })
}, [selectedIndex])
const runResult = (result: CommandPaletteSearchResult) => {
selectedResultRef.current = true
sendEvent('command-palette-select', result.eventSegmentation)
try {
const res = result.onSelect(result)
if (res instanceof Promise) {
res.catch(err => {
debugConsole.error('Command palette command error', err)
})
}
} catch (err) {
debugConsole.error('Command palette command error', err)
} finally {
onHide()
}
}
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (results.length === 0) return
if (event.key === 'ArrowDown') {
event.preventDefault()
setSelectedIndex(i => (i + 1) % results.length)
} else if (event.key === 'ArrowUp') {
event.preventDefault()
setSelectedIndex(i => (i - 1 + results.length) % results.length)
} else if (event.key === 'Enter') {
event.preventDefault()
const selected = results[selectedIndex]
if (selected) runResult(selected)
}
}
useEventListener(
'mousedown',
useCallback(
(event: MouseEvent) => {
if (!show) return
const target = event.target as Element | null
if (target?.closest('.command-palette .modal-dialog')) return
onHide()
},
[show, onHide]
)
)
return (
<OLModal
show={show}
onHide={onHide}
className="command-palette"
returnFocusOnDeactivate
animation={false}
backdrop={false}
clickOutsideDeactivates
themed
>
<OLModalBody>
<div className="command-palette-input">
<input
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={onKeyDown}
placeholder={t('type_a_command')}
aria-label={t('command_palette_search')}
/>
<SplitTestBadge
displayOnVariants={['enabled']}
splitTestName="command-palette"
/>
</div>
<ul ref={resultsRef} className="command-palette-results">
{results.length === 0 && (
<li role="none" className="command-palette-result">
<div className="command-palette-result-title command-palette-no-matches">
{t('no_matches')}
</div>
</li>
)}
{results.map((result, index) => (
<li
role="none"
key={index}
className={classNames('command-palette-result', {
'command-palette-result-selected': index === selectedIndex,
})}
>
<button
role="menuitem"
onMouseMove={() => setSelectedIndex(index)}
onClick={() => runResult(result)}
>
<div className="command-palette-result-title">
{result.title}
{result.description && (
<span className="command-palette-result-description">
{result.description}
</span>
)}
</div>
</button>
</li>
))}
</ul>
</OLModalBody>
</OLModal>
)
}
export default memo(CommandPaletteBody)