command input
This commit is contained in:
235
src/components/GlobalCommandPalette.tsx
Normal file
235
src/components/GlobalCommandPalette.tsx
Normal 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"
|
||||
@@ -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>
|
||||
))
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user