command-palette-body.tsx 3.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133
  1. import {
  2. FC,
  3. KeyboardEvent,
  4. memo,
  5. useCallback,
  6. useEffect,
  7. useRef,
  8. useState,
  9. } from 'react'
  10. import { CommandPaletteSearchResult } from '../types'
  11. import { OLModal, OLModalBody } from '@/shared/components/ol/ol-modal'
  12. import classNames from 'classnames'
  13. import useEventListener from '@/shared/hooks/use-event-listener'
  14. import useCommandPaletteResults from '../hooks/use-command-palette-results'
  15. import { debugConsole } from '@/utils/debugging'
  16. type CommandPaletteBodyProps = {
  17. show: boolean
  18. onHide(): void
  19. }
  20. const CommandPaletteBody: FC<CommandPaletteBodyProps> = ({ show, onHide }) => {
  21. const [query, setQuery] = useState('')
  22. const [selectedIndex, setSelectedIndex] = useState(0)
  23. const resultsRef = useRef<HTMLUListElement>(null)
  24. const results = useCommandPaletteResults(query)
  25. useEffect(() => {
  26. setSelectedIndex(0)
  27. }, [results])
  28. useEffect(() => {
  29. const el = resultsRef.current?.children[selectedIndex] as
  30. | HTMLElement
  31. | undefined
  32. el?.scrollIntoView({ block: 'nearest' })
  33. }, [selectedIndex])
  34. const runResult = (result: CommandPaletteSearchResult) => {
  35. try {
  36. const res = result.onSelect(result)
  37. if (res instanceof Promise) {
  38. res.catch(err => {
  39. debugConsole.error('Command palette command error', err)
  40. })
  41. }
  42. } catch (err) {
  43. debugConsole.error('Command palette command error', err)
  44. } finally {
  45. onHide()
  46. }
  47. }
  48. const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
  49. if (results.length === 0) return
  50. if (event.key === 'ArrowDown') {
  51. event.preventDefault()
  52. setSelectedIndex(i => (i + 1) % results.length)
  53. } else if (event.key === 'ArrowUp') {
  54. event.preventDefault()
  55. setSelectedIndex(i => (i - 1 + results.length) % results.length)
  56. } else if (event.key === 'Enter') {
  57. event.preventDefault()
  58. const selected = results[selectedIndex]
  59. if (selected) runResult(selected)
  60. }
  61. }
  62. useEventListener(
  63. 'mousedown',
  64. useCallback(
  65. (event: MouseEvent) => {
  66. if (!show) return
  67. const target = event.target as Element | null
  68. if (target?.closest('.command-palette .modal-dialog')) return
  69. onHide()
  70. },
  71. [show, onHide]
  72. )
  73. )
  74. return (
  75. <OLModal
  76. show={show}
  77. onHide={onHide}
  78. className="command-palette"
  79. returnFocusOnDeactivate
  80. animation={false}
  81. backdrop={false}
  82. clickOutsideDeactivates
  83. >
  84. <OLModalBody>
  85. <input
  86. type="text"
  87. value={query}
  88. onChange={e => setQuery(e.target.value)}
  89. onKeyDown={onKeyDown}
  90. placeholder="Type a command..."
  91. aria-label="Command palette search"
  92. className="command-palette-input"
  93. />
  94. <ul ref={resultsRef} className="command-palette-results">
  95. {results.map((result, index) => (
  96. <li
  97. role="none"
  98. key={index}
  99. className={classNames('command-palette-result', {
  100. 'command-palette-result-selected': index === selectedIndex,
  101. })}
  102. >
  103. <button
  104. role="menuitem"
  105. onMouseEnter={() => setSelectedIndex(index)}
  106. onClick={() => runResult(result)}
  107. >
  108. <div className="command-palette-result-title">
  109. {result.title}
  110. </div>
  111. {result.description && (
  112. <div className="command-palette-result-description">
  113. {result.description}
  114. </div>
  115. )}
  116. </button>
  117. </li>
  118. ))}
  119. </ul>
  120. </OLModalBody>
  121. </OLModal>
  122. )
  123. }
  124. export default memo(CommandPaletteBody)