* Add Cmd + / to support windows, update walkthrough * Fix #628 dark mode <select> bg color * Fix #621 by narrowing margins and moving to left --------- Co-authored-by: Jess Frazelle <jessfraz@users.noreply.github.com>
291 lines
9.2 KiB
TypeScript
291 lines
9.2 KiB
TypeScript
import { Combobox, Dialog, Transition } from '@headlessui/react'
|
|
import {
|
|
Dispatch,
|
|
Fragment,
|
|
SetStateAction,
|
|
createContext,
|
|
useState,
|
|
} from 'react'
|
|
import { useHotkeys } from 'react-hotkeys-hook'
|
|
import { ActionIcon } from './ActionIcon'
|
|
import { faSearch } from '@fortawesome/free-solid-svg-icons'
|
|
import Fuse from 'fuse.js'
|
|
import { Command, SubCommand } from '../lib/commands'
|
|
import { useCommandsContext } from 'hooks/useCommandsContext'
|
|
|
|
export type SortedCommand = {
|
|
item: Partial<Command | SubCommand> & { name: string }
|
|
}
|
|
|
|
export const CommandsContext = createContext(
|
|
{} as {
|
|
commands: Command[]
|
|
addCommands: (commands: Command[]) => void
|
|
removeCommands: (commands: Command[]) => void
|
|
commandBarOpen: boolean
|
|
setCommandBarOpen: Dispatch<SetStateAction<boolean>>
|
|
}
|
|
)
|
|
|
|
export const CommandBarProvider = ({
|
|
children,
|
|
}: {
|
|
children: React.ReactNode
|
|
}) => {
|
|
const [commands, internalSetCommands] = useState([] as Command[])
|
|
const [commandBarOpen, setCommandBarOpen] = useState(false)
|
|
|
|
const addCommands = (newCommands: Command[]) => {
|
|
internalSetCommands((prevCommands) => [...newCommands, ...prevCommands])
|
|
}
|
|
const removeCommands = (newCommands: Command[]) => {
|
|
internalSetCommands((prevCommands) =>
|
|
prevCommands.filter((command) => !newCommands.includes(command))
|
|
)
|
|
}
|
|
|
|
return (
|
|
<CommandsContext.Provider
|
|
value={{
|
|
commands,
|
|
addCommands,
|
|
removeCommands,
|
|
commandBarOpen,
|
|
setCommandBarOpen,
|
|
}}
|
|
>
|
|
{children}
|
|
<CommandBar />
|
|
</CommandsContext.Provider>
|
|
)
|
|
}
|
|
|
|
const CommandBar = () => {
|
|
const { commands, commandBarOpen, setCommandBarOpen } = useCommandsContext()
|
|
useHotkeys(['meta+k', 'meta+/'], () => {
|
|
if (commands.length === 0) return
|
|
setCommandBarOpen(!commandBarOpen)
|
|
})
|
|
|
|
const [selectedCommand, setSelectedCommand] = useState<SortedCommand | null>(
|
|
null
|
|
)
|
|
// keep track of the current subcommand index
|
|
const [subCommandIndex, setSubCommandIndex] = useState<number>()
|
|
const [subCommandData, setSubCommandData] = useState<{
|
|
[key: string]: string
|
|
}>({})
|
|
|
|
// if the subcommand index is null, we're not in a subcommand
|
|
const inSubCommand =
|
|
selectedCommand &&
|
|
'meta' in selectedCommand.item &&
|
|
selectedCommand.item.meta?.args !== undefined &&
|
|
subCommandIndex !== undefined
|
|
const currentSubCommand =
|
|
inSubCommand && 'meta' in selectedCommand.item
|
|
? selectedCommand.item.meta?.args[subCommandIndex]
|
|
: undefined
|
|
|
|
const [query, setQuery] = useState('')
|
|
|
|
const availableCommands =
|
|
inSubCommand && currentSubCommand
|
|
? currentSubCommand.type === 'string'
|
|
? query
|
|
? [{ name: query }]
|
|
: currentSubCommand.options
|
|
: currentSubCommand.options
|
|
: commands
|
|
|
|
const fuse = new Fuse(availableCommands || [], {
|
|
keys: ['name', 'description'],
|
|
})
|
|
|
|
const filteredCommands = query
|
|
? fuse.search(query)
|
|
: availableCommands?.map((c) => ({ item: c } as SortedCommand))
|
|
|
|
function clearState() {
|
|
setQuery('')
|
|
setCommandBarOpen(false)
|
|
setSelectedCommand(null)
|
|
setSubCommandIndex(undefined)
|
|
setSubCommandData({})
|
|
}
|
|
|
|
function handleCommandSelection(entry: SortedCommand) {
|
|
// If we have subcommands and have not yet gathered all the
|
|
// data required from them, set the selected command to the
|
|
// current command and increment the subcommand index
|
|
if (selectedCommand === null && 'meta' in entry.item && entry.item.meta) {
|
|
setSelectedCommand(entry)
|
|
setSubCommandIndex(0)
|
|
setQuery('')
|
|
return
|
|
}
|
|
|
|
const { item } = entry
|
|
// If we have just selected a command with no subcommands, run it
|
|
const isCommandWithoutSubcommands =
|
|
'callback' in item && !('meta' in item && item.meta)
|
|
if (isCommandWithoutSubcommands) {
|
|
if (item.callback === undefined) return
|
|
item.callback()
|
|
setCommandBarOpen(false)
|
|
return
|
|
}
|
|
|
|
// If we have subcommands and have not yet gathered all the
|
|
// data required from them, set the selected command to the
|
|
// current command and increment the subcommand index
|
|
if (
|
|
selectedCommand &&
|
|
subCommandIndex !== undefined &&
|
|
'meta' in selectedCommand.item
|
|
) {
|
|
const subCommand = selectedCommand.item.meta?.args[subCommandIndex]
|
|
|
|
if (subCommand) {
|
|
const newSubCommandData = {
|
|
...subCommandData,
|
|
[subCommand.name]: item.name,
|
|
}
|
|
const newSubCommandIndex = subCommandIndex + 1
|
|
|
|
// If we have subcommands and have gathered all the data required
|
|
// from them, run the command with the gathered data
|
|
if (
|
|
selectedCommand.item.callback &&
|
|
selectedCommand.item.meta?.args.length === newSubCommandIndex
|
|
) {
|
|
selectedCommand.item.callback(newSubCommandData)
|
|
setCommandBarOpen(false)
|
|
} else {
|
|
// Otherwise, set the subcommand data and increment the subcommand index
|
|
setSubCommandData(newSubCommandData)
|
|
setSubCommandIndex(newSubCommandIndex)
|
|
setQuery('')
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function getDisplayValue(command: Command) {
|
|
if (command.meta?.displayValue === undefined || !command.meta.args)
|
|
return command.name
|
|
return command.meta?.displayValue(
|
|
command.meta.args.map((c) =>
|
|
subCommandData[c.name] ? subCommandData[c.name] : `<${c.name}>`
|
|
)
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Transition.Root
|
|
show={
|
|
commandBarOpen &&
|
|
availableCommands?.length !== undefined &&
|
|
availableCommands.length > 0
|
|
}
|
|
as={Fragment}
|
|
afterLeave={() => clearState()}
|
|
>
|
|
<Dialog
|
|
onClose={() => {
|
|
setCommandBarOpen(false)
|
|
clearState()
|
|
}}
|
|
className="fixed inset-0 z-40 overflow-y-auto p-4 pt-[25vh]"
|
|
>
|
|
<Transition.Child
|
|
enter="duration-100 ease-out"
|
|
enterFrom="opacity-0"
|
|
enterTo="opacity-100"
|
|
leave="duration-75 ease-in"
|
|
leaveFrom="opacity-100"
|
|
leaveTo="opacity-0"
|
|
as={Fragment}
|
|
>
|
|
<Dialog.Overlay className="fixed inset-0 bg-chalkboard-10/70 dark:bg-chalkboard-110/50" />
|
|
</Transition.Child>
|
|
<Transition.Child
|
|
enter="duration-100 ease-out"
|
|
enterFrom="opacity-0 scale-95"
|
|
enterTo="opacity-100 scale-100"
|
|
leave="duration-75 ease-in"
|
|
leaveFrom="opacity-100 scale-100"
|
|
leaveTo="opacity-0 scale-95"
|
|
as={Fragment}
|
|
>
|
|
<Combobox
|
|
value={selectedCommand}
|
|
onChange={handleCommandSelection}
|
|
className="relative w-full max-w-xl p-2 mx-auto border rounded shadow-lg bg-chalkboard-10 dark:bg-chalkboard-100 dark:border-chalkboard-70"
|
|
as="div"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<ActionIcon icon={faSearch} size="xl" className="rounded-sm" />
|
|
<div>
|
|
{inSubCommand && (
|
|
<p className="text-liquid-70 dark:text-liquid-30">
|
|
{selectedCommand.item &&
|
|
getDisplayValue(selectedCommand.item as Command)}
|
|
</p>
|
|
)}
|
|
<Combobox.Input
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
className="w-full bg-transparent focus:outline-none"
|
|
onKeyDown={(event) => {
|
|
if (event.metaKey && event.key === 'k')
|
|
setCommandBarOpen(false)
|
|
if (
|
|
inSubCommand &&
|
|
event.key === 'Backspace' &&
|
|
!event.currentTarget.value
|
|
) {
|
|
setSubCommandIndex(subCommandIndex - 1)
|
|
setSelectedCommand(null)
|
|
}
|
|
}}
|
|
displayValue={(command: SortedCommand) =>
|
|
command !== null ? command.item.name : ''
|
|
}
|
|
placeholder={
|
|
inSubCommand
|
|
? `Enter <${currentSubCommand?.name}>`
|
|
: 'Search for a command'
|
|
}
|
|
value={query}
|
|
autoCapitalize="off"
|
|
autoComplete="off"
|
|
autoCorrect="off"
|
|
spellCheck="false"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<Combobox.Options static className="overflow-y-auto max-h-96">
|
|
{filteredCommands?.map((commandResult) => (
|
|
<Combobox.Option
|
|
key={commandResult.item.name}
|
|
value={commandResult}
|
|
className="px-2 py-1 my-2 first:mt-4 last:mb-4 ui-active:bg-liquid-10 dark:ui-active:bg-liquid-90"
|
|
>
|
|
<p>{commandResult.item.name}</p>
|
|
{(commandResult.item as SubCommand).description && (
|
|
<p className="mt-0.5 text-liquid-70 dark:text-liquid-30 text-sm">
|
|
{(commandResult.item as SubCommand).description}
|
|
</p>
|
|
)}
|
|
</Combobox.Option>
|
|
))}
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
</Transition.Child>
|
|
</Dialog>
|
|
</Transition.Root>
|
|
)
|
|
}
|
|
|
|
export default CommandBarProvider
|