command input

This commit is contained in:
Victor Oliva
2026-05-14 16:43:33 +02:00
parent 323e815646
commit c49b59c2d1
5 changed files with 247 additions and 131 deletions

View File

@@ -0,0 +1,235 @@
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import { useNavigate } from "@/hashParams"
import { GitGraph } from "lucide-react"
import { ComponentType, FC, useEffect, useMemo, useRef, useState } from "react"
import { twMerge } from "tailwind-merge"
type CommandPaletteIcon = ComponentType<{ size?: number; className?: string }>
export type CommandPaletteNavigationItem = {
path: string
label: string
icon: CommandPaletteIcon
}
type CommandAction = {
id: string
path: string
label: string
description: string
icon: CommandPaletteIcon
}
export const GlobalCommandPalette: FC<{
navigationItems: CommandPaletteNavigationItem[]
}> = ({ navigationItems }) => {
const navigate = useNavigate()
const [value, setValue] = useState("")
const [open, setOpen] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const commandRef = useRef<HTMLDivElement>(null)
const shortcutLabel = getCommandShortcutLabel()
const { primaryActions, sectionActions } = useMemo(
() => getCommandActions(value, navigationItems),
[navigationItems, value],
)
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
event.preventDefault()
setOpen(true)
inputRef.current?.focus()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [])
useEffect(() => {
const handlePointerDown = (event: PointerEvent) => {
if (!commandRef.current?.contains(event.target as Node)) {
setOpen(false)
}
}
document.addEventListener("pointerdown", handlePointerDown)
return () => document.removeEventListener("pointerdown", handlePointerDown)
}, [])
const runAction = (action: CommandAction) => {
navigate(action.path)
setValue("")
setOpen(false)
}
return (
<div ref={commandRef} className="relative max-w-2xl flex-1">
<Command
shouldFilter={false}
className={twMerge(
"h-9 overflow-visible rounded-md border bg-input text-foreground shadow-none",
"focus-within:ring-2 focus-within:ring-ring",
"**:data-[slot=command-input-wrapper]:h-9 **:data-[slot=command-input-wrapper]:border-b-0",
"**:data-[slot=command-input]:h-9 **:data-[slot=command-input]:py-0 **:data-[slot=command-input]:pr-16",
)}
onKeyDown={(event) => {
if (event.key === "Escape") {
setOpen(false)
inputRef.current?.blur()
}
}}
>
<CommandInput
ref={inputRef}
value={value}
onValueChange={(nextValue) => {
setValue(nextValue)
setOpen(true)
}}
onFocus={() => setOpen(true)}
placeholder="Block hash, block number, or command"
spellCheck={false}
autoComplete="off"
/>
<kbd className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 rounded border bg-background px-1.5 py-0.5 text-[10px] leading-none text-muted-foreground">
{shortcutLabel}
</kbd>
{open ? (
<div className="absolute left-0 top-[calc(100%+4px)] z-50 w-full min-w-80 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md">
<CommandList className="max-h-96">
<CommandEmpty>No matching command.</CommandEmpty>
{primaryActions.length ? (
<CommandGroup heading="Go to">
{primaryActions.map((action) => (
<CommandActionItem
key={action.id}
action={action}
onSelect={runAction}
/>
))}
</CommandGroup>
) : null}
<CommandGroup heading={value.trim() ? "Sections" : "Suggested"}>
{sectionActions.map((action) => (
<CommandActionItem
key={action.id}
action={action}
onSelect={runAction}
/>
))}
</CommandGroup>
</CommandList>
</div>
) : null}
</Command>
</div>
)
}
const CommandActionItem: FC<{
action: CommandAction
onSelect: (action: CommandAction) => void
}> = ({ action, onSelect }) => {
const Icon = action.icon
return (
<CommandItem value={action.label} onSelect={() => onSelect(action)}>
<Icon size={16} />
<div className="min-w-0">
<div className="truncate">{action.label}</div>
<div className="truncate text-xs text-muted-foreground">
{action.description}
</div>
</div>
</CommandItem>
)
}
const getCommandActions = (
value: string,
navigationItems: CommandPaletteNavigationItem[],
) => {
const query = value.trim()
const primaryActions: CommandAction[] = []
if (query.startsWith("/")) {
primaryActions.push({
id: `route:${query}`,
path: query,
label: `Open ${query}`,
description: "Navigate to route",
icon: GitGraph,
})
} else if (/^0x[0-9a-f]+$/i.test(query)) {
primaryActions.push({
id: `block-hash:${query}`,
path: `/explorer/${query}`,
label: "Open block hash",
description: query,
icon: GitGraph,
})
} else if (/^\d+$/.test(query)) {
primaryActions.push({
id: `block-number:${query}`,
path: `/explorer/${query}`,
label: `Open block ${Number(query).toLocaleString()}`,
description: "Navigate by block number",
icon: GitGraph,
})
}
return {
primaryActions,
sectionActions: getSectionActions(query, navigationItems),
}
}
const getSectionActions = (
query: string,
navigationItems: CommandPaletteNavigationItem[],
): CommandAction[] => {
const normalizedQuery = normalizeLabel(query)
const items = !normalizedQuery
? navigationItems
: [...navigationItems]
.filter(({ label }) => normalizeLabel(label).includes(normalizedQuery))
.sort((a, b) => {
const aLabel = normalizeLabel(a.label)
const bLabel = normalizeLabel(b.label)
if (aLabel === normalizedQuery) return -1
if (bLabel === normalizedQuery) return 1
if (
aLabel.startsWith(normalizedQuery) !==
bLabel.startsWith(normalizedQuery)
) {
return aLabel.startsWith(normalizedQuery) ? -1 : 1
}
return a.label.localeCompare(b.label)
})
return items.map((item) => ({
id: `section:${item.path}`,
path: item.path,
label: item.label,
description: "Open section",
icon: item.icon,
}))
}
const normalizeLabel = (value: string) =>
value.toLowerCase().replace(/[\s_-]/g, "")
const getCommandShortcutLabel = () =>
typeof navigator !== "undefined" &&
/Mac|iPhone|iPad|iPod/.test(navigator.platform)
? "⌘K"
: "Ctrl K"

View File

@@ -1,22 +0,0 @@
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { forwardRef } from "react"
export const SearchInput = forwardRef<
HTMLInputElement,
Omit<React.ComponentProps<"input">, "type">
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<input
type="text"
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
autoComplete="off"
{...props}
/>
</div>
))

View File

@@ -1,8 +1,12 @@
import {
GlobalCommandPalette,
type CommandPaletteNavigationItem,
} from "@/components/GlobalCommandPalette"
import SliderToggle from "@/components/Toggle"
import { GithubIcon } from "@/components/Icons"
import { Button } from "@/components/ui/button"
import { Sheet, SheetContent } from "@/components/ui/sheet"
import { Link, useNavigate } from "@/hashParams"
import { Link } from "@/hashParams"
import { changeTheme, useTheme } from "@/ThemeProvider"
import {
BookOpenText,
@@ -11,24 +15,18 @@ import {
GitGraph,
Menu,
MoonStar,
Search,
Send,
ServerCog,
SquareEqual,
SquareFunction,
UserRound,
} from "lucide-react"
import { FC, FormEvent, PropsWithChildren, useState } from "react"
import { FC, PropsWithChildren, useState } from "react"
import { useLocation } from "react-router-dom"
import { twMerge } from "tailwind-merge"
import { NetworkSwitcher } from "./Network/Network"
type NavigationItem = {
path: string
label: string
icon: IconComponent
}
type IconComponent = FC<{ size?: number; className?: string }>
type NavigationItem = CommandPaletteNavigationItem
const navigationGroups: Array<{
label: string
@@ -198,43 +196,12 @@ const TopBar: FC<{ onOpenSidebar: () => void }> = ({ onOpenSidebar }) => {
<div className="ml-3 hidden sm:block">
<NetworkSwitcher className="w-55" />
</div>
<GlobalJumpSearch />
<GlobalCommandPalette navigationItems={navigationItems} />
</div>
</header>
)
}
const GlobalJumpSearch = () => {
const navigate = useNavigate()
const [value, setValue] = useState("")
const handleSubmit = (evt: FormEvent) => {
evt.preventDefault()
const target = getJumpTarget(value)
if (!target) return
navigate(target)
setValue("")
}
return (
<form className="max-w-2xl flex-1" onSubmit={handleSubmit}>
<label className="flex h-9 items-center gap-2 rounded-md border bg-input px-3 text-sm focus-within:ring-2 focus-within:ring-ring">
<Search size={16} className="shrink-0 text-muted-foreground" />
<input
value={value}
onChange={(evt) => setValue(evt.target.value)}
className="flex-1 bg-transparent outline-hidden placeholder:text-muted-foreground"
placeholder="Jump to block, hash, or section"
autoComplete="off"
spellCheck={false}
/>
</label>
</form>
)
}
const SidebarLink: FC<{ item: NavigationItem; onClick?: () => void }> = ({
item,
onClick,
@@ -264,33 +231,6 @@ const SidebarLink: FC<{ item: NavigationItem; onClick?: () => void }> = ({
const isNavigationItemActive = (pathname: string, path: string) =>
pathname === path || pathname.startsWith(`${path}/`)
const getJumpTarget = (value: string) => {
const query = value.trim()
if (!query) return null
if (query.startsWith("/")) return query
const normalizedQuery = normalizeLabel(query)
const exactSection = navigationItems.find(
({ label }) => normalizeLabel(label) === normalizedQuery,
)
if (exactSection) return exactSection.path
const matchingSection = navigationItems.find(({ label }) =>
normalizeLabel(label).startsWith(normalizedQuery),
)
if (matchingSection) return matchingSection.path
if (/^(0x[0-9a-f]+|\d+)$/i.test(query)) {
return `/explorer/${query}`
}
return null
}
const normalizeLabel = (value: string) =>
value.toLowerCase().replace(/[\s_-]/g, "")
const ThemeToggle = () => {
const theme = useTheme()

View File

@@ -1,12 +1,9 @@
import { CopyText } from "@/components/Copy"
import { Popover } from "@/components/Popover"
import { Button } from "@/components/ui/button"
import { SearchInput } from "@/components/ui/search-input"
import { Link, useNavigate } from "@/hashParams"
import { Link } from "@/hashParams"
import { BlockInfo, blocksByHeight$, finalized$ } from "@/state/block.state"
import { client$ } from "@/state/chains/chain.state"
import { state, useStateObservable } from "@react-rxjs/core"
import { Search } from "lucide-react"
import { FC } from "react"
import { combineLatest, debounceTime, map, switchMap } from "rxjs"
import { twMerge } from "tailwind-merge"
@@ -87,36 +84,6 @@ const blockTable$ = state(
[],
)
export const BlockInput: FC = () => {
const navigate = useNavigate()
return (
<form
className="grow p-0 -my-2 flex"
onSubmit={(e) => {
const blockTarget = new FormData(e.currentTarget).get(
"blockTarget",
) as string
const isHex = blockTarget?.match(/^0[xX][0-9a-fA-F]+$/)
if (
(isHex && blockTarget.length === 66) ||
(!isHex && !Number.isNaN(Number(blockTarget)))
) {
navigate(blockTarget)
}
e.preventDefault()
e.stopPropagation()
}}
>
<div className="grow">
<SearchInput placeholder="Block hash or height" name="blockTarget" />
</div>
<Button className="grow-0" variant="secondary">
<Search />{" "}
</Button>
</form>
)
}
export const BlockTable = () => {
const rows = useStateObservable(blockTable$)
const finalized = useStateObservable(finalized$)
@@ -133,7 +100,7 @@ export const BlockTable = () => {
return (
<Finalizing.Root>
<Finalizing.Title search={<BlockInput />}>Recent Blocks</Finalizing.Title>
<Finalizing.Title>Recent Blocks</Finalizing.Title>
<Finalizing.Table>
{rows.map((row, i) => (
<Finalizing.Row

View File

@@ -1,13 +1,9 @@
import { FC, PropsWithChildren, ReactNode } from "react"
import { FC, PropsWithChildren } from "react"
import { twMerge } from "tailwind-merge"
export const Title: FC<PropsWithChildren<{ search?: ReactNode }>> = ({
children,
search,
}) => (
export const Title: FC<PropsWithChildren> = ({ children }) => (
<h2 className="font-bold p-2 border-b border-slate-400 mb-2 flex">
<span className="grow-0">{children}</span>
{search}
</h2>
)