Command panel
A command browser with a selectable suggestion list and a detailed option card.
A command browser with a selectable suggestion list and a detailed option card. Compose it from the exported registry parts so local content and behaviour stay in the application while the visual contract stays token-driven.
import { CommandPanel } from "@/registry/bases/newt/blocks/command-panel/command-panel"
const COMMANDS = [
{
id: "poll",
name: "poll create",
description: "Create a poll for the current channel.",
appLabel: "Community",
options: [
{
name: "question",
type: "STRING",
required: true,
description: "The question people will answer.",
},
{
name: "duration",
type: "INTEGER",
description: "How many hours the poll stays open.",
},
],
},
{
id: "remind",
name: "remind",
description: "Schedule a reminder in this channel.",
appLabel: "Utilities",
options: [
{ name: "when", type: "STRING", required: true },
{ name: "message", type: "STRING", required: true },
],
},
{
id: "topic",
name: "topic suggest",
description: "Suggest a conversation prompt.",
appLabel: "Community",
},
] as const
export default function CommandPanelDemo() {
return <CommandPanel commands={COMMANDS} defaultSelectedId="poll" />
}<script setup lang="ts">
import { CommandPanel } from "~~/registry/bases/newt/blocks/command-panel"
import type { CommandPanelCommand } from "~~/registry/bases/newt/blocks/command-panel"
const COMMANDS: readonly CommandPanelCommand[] = [
{
id: "poll",
name: "poll create",
description: "Create a poll for the current channel.",
appLabel: "Community",
options: [
{
name: "question",
type: "STRING",
required: true,
description: "The question people will answer.",
},
{
name: "duration",
type: "INTEGER",
description: "How many hours the poll stays open.",
},
],
},
{
id: "remind",
name: "remind",
description: "Schedule a reminder in this channel.",
appLabel: "Utilities",
options: [
{ name: "when", type: "STRING", required: true },
{ name: "message", type: "STRING", required: true },
],
},
{
id: "topic",
name: "topic suggest",
description: "Suggest a conversation prompt.",
appLabel: "Community",
},
]
</script>
<template>
<CommandPanel :commands="COMMANDS" default-selected-id="poll" />
</template>Installation
pnpm dlx newtui@latest add command-panelnpx newtui@latest add command-panelyarn dlx newtui@latest add command-panelbunx newtui@latest add command-panelAdd the components it builds on
Copy the source into your project
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import {
BotCommandCard,
BotCommandCardDescription,
BotCommandCardHeader,
BotCommandCardName,
BotCommandCardOption,
BotCommandCardOptions,
} from "@/registry/bases/newt/ui/bot-command-card"
import {
SlashCommandAppIcon,
SlashCommandSuggestion,
SlashCommandSuggestionGroup,
SlashCommandSuggestions,
SlashCommandSuggestionsHeader,
SlashCommandSuggestionsList,
} from "@/registry/bases/newt/ui/slash-command-suggestions"
export interface CommandPanelOption {
name: string
type?: string
required?: boolean
description?: string
}
export interface CommandPanelCommand {
id: string
name: string
description: string
appLabel?: string
options?: readonly CommandPanelOption[]
}
export interface CommandPanelProps extends Omit<
React.ComponentProps<"section">,
"children"
> {
commands: readonly CommandPanelCommand[]
selectedId?: string
defaultSelectedId?: string
onSelectedIdChange?: (id: string) => void
listLabel?: string
emptyLabel?: string
}
export function CommandPanel({
commands,
selectedId: controlledSelectedId,
defaultSelectedId,
onSelectedIdChange,
listLabel = "Available commands",
emptyLabel = "No commands available.",
className,
...props
}: CommandPanelProps) {
const [uncontrolledSelectedId, setUncontrolledSelectedId] = React.useState(
defaultSelectedId ?? commands[0]?.id
)
const instanceId = React.useId().replaceAll(":", "")
const selectedId = controlledSelectedId ?? uncontrolledSelectedId
const selectedIndex = Math.max(
0,
commands.findIndex((command) => command.id === selectedId)
)
const selectedCommand = commands[selectedIndex]
function select(id: string) {
if (controlledSelectedId === undefined) setUncontrolledSelectedId(id)
onSelectedIdChange?.(id)
}
function moveSelection(key: "first" | "last" | "next" | "previous") {
if (commands.length === 0) return
const nextIndex =
key === "first"
? 0
: key === "last"
? commands.length - 1
: key === "next"
? (selectedIndex + 1) % commands.length
: (selectedIndex - 1 + commands.length) % commands.length
const command = commands[nextIndex]
if (command) select(command.id)
}
return (
<section
className={cn(
"grid w-full grid-cols-1 gap-4 md:grid-cols-[minmax(0,1fr)_minmax(18rem,24rem)]",
className
)}
{...props}
>
<SlashCommandSuggestions className="max-w-none">
<SlashCommandSuggestionsList
aria-label={listLabel}
aria-activedescendant={
selectedCommand
? `${instanceId}-command-${selectedCommand.id}`
: undefined
}
tabIndex={0}
onKeyDown={(event) => {
if (event.key === "ArrowDown") {
event.preventDefault()
moveSelection("next")
} else if (event.key === "ArrowUp") {
event.preventDefault()
moveSelection("previous")
} else if (event.key === "Home") {
event.preventDefault()
moveSelection("first")
} else if (event.key === "End") {
event.preventDefault()
moveSelection("last")
}
}}
>
<SlashCommandSuggestionsHeader>
Commands
</SlashCommandSuggestionsHeader>
<SlashCommandSuggestionGroup aria-label={listLabel}>
{commands.map((command) => (
<SlashCommandSuggestion
key={command.id}
id={`${instanceId}-command-${command.id}`}
active={command.id === selectedCommand?.id}
name={command.name}
description={command.description}
appLabel={command.appLabel}
icon={
<SlashCommandAppIcon>
{command.name.slice(0, 1).toUpperCase()}
</SlashCommandAppIcon>
}
onClick={() => select(command.id)}
onMouseEnter={() => select(command.id)}
/>
))}
</SlashCommandSuggestionGroup>
{commands.length === 0 ? (
<p className="px-2 py-4 text-sm text-newt-text-muted">
{emptyLabel}
</p>
) : null}
</SlashCommandSuggestionsList>
</SlashCommandSuggestions>
{selectedCommand ? (
<BotCommandCard className="max-w-none" aria-live="polite">
<BotCommandCardHeader>
<BotCommandCardName>{selectedCommand.name}</BotCommandCardName>
{selectedCommand.appLabel ? (
<span className="text-xs text-newt-text-muted">
{selectedCommand.appLabel}
</span>
) : null}
</BotCommandCardHeader>
<BotCommandCardDescription>
{selectedCommand.description}
</BotCommandCardDescription>
{selectedCommand.options?.length ? (
<BotCommandCardOptions>
{selectedCommand.options.map((option) => (
<BotCommandCardOption key={option.name} {...option} />
))}
</BotCommandCardOptions>
) : null}
</BotCommandCard>
) : (
<div
className="rounded-md border border-newt-border bg-newt-bg-elevated p-4 text-sm text-newt-text-muted"
aria-live="polite"
>
{emptyLabel}
</div>
)}
</section>
)
}<script setup lang="ts">
import { computed, shallowRef, useId } from "vue"
import type { HTMLAttributes } from "vue"
import {
BotCommandCard,
BotCommandCardDescription,
BotCommandCardHeader,
BotCommandCardName,
BotCommandCardOption,
BotCommandCardOptions,
} from "~~/registry/bases/newt/ui/bot-command-card"
import {
SlashCommandAppIcon,
SlashCommandSuggestion,
SlashCommandSuggestionGroup,
SlashCommandSuggestions,
SlashCommandSuggestionsHeader,
SlashCommandSuggestionsList,
} from "~~/registry/bases/newt/ui/slash-command-suggestions"
import { cn } from "@/lib/utils"
import type { CommandPanelCommand } from "./types"
const props = withDefaults(
defineProps<{
commands: readonly CommandPanelCommand[]
selectedId?: string
defaultSelectedId?: string
listLabel?: string
emptyLabel?: string
class?: HTMLAttributes["class"]
}>(),
{
listLabel: "Available commands",
emptyLabel: "No commands available.",
}
)
const emit = defineEmits<{ "update:selectedId": [id: string] }>()
const uncontrolledSelectedId = shallowRef(
props.defaultSelectedId ?? props.commands[0]?.id
)
const instanceId = useId().replaceAll(":", "")
const currentSelectedId = computed(
() => props.selectedId ?? uncontrolledSelectedId.value
)
const selectedIndex = computed(() =>
Math.max(
0,
props.commands.findIndex(
(command) => command.id === currentSelectedId.value
)
)
)
const selectedCommand = computed(() => props.commands[selectedIndex.value])
function select(id: string) {
if (props.selectedId === undefined) uncontrolledSelectedId.value = id
emit("update:selectedId", id)
}
function moveSelection(key: "first" | "last" | "next" | "previous") {
if (props.commands.length === 0) return
const nextIndex =
key === "first"
? 0
: key === "last"
? props.commands.length - 1
: key === "next"
? (selectedIndex.value + 1) % props.commands.length
: (selectedIndex.value - 1 + props.commands.length) %
props.commands.length
const command = props.commands[nextIndex]
if (command) select(command.id)
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === "ArrowDown") {
event.preventDefault()
moveSelection("next")
} else if (event.key === "ArrowUp") {
event.preventDefault()
moveSelection("previous")
} else if (event.key === "Home") {
event.preventDefault()
moveSelection("first")
} else if (event.key === "End") {
event.preventDefault()
moveSelection("last")
}
}
</script>
<template>
<section
:class="
cn(
'grid w-full grid-cols-1 gap-4 md:grid-cols-[minmax(0,1fr)_minmax(18rem,24rem)]',
props.class
)
"
>
<SlashCommandSuggestions class="max-w-none">
<SlashCommandSuggestionsList
:aria-label="props.listLabel"
:aria-activedescendant="
selectedCommand
? `${instanceId}-command-${selectedCommand.id}`
: undefined
"
tabindex="0"
@keydown="handleKeydown"
>
<SlashCommandSuggestionsHeader>Commands</SlashCommandSuggestionsHeader>
<SlashCommandSuggestionGroup :aria-label="props.listLabel">
<SlashCommandSuggestion
v-for="command in props.commands"
:id="`${instanceId}-command-${command.id}`"
:key="command.id"
:active="command.id === selectedCommand?.id"
:name="command.name"
:description="command.description"
:app-label="command.appLabel"
@click="select(command.id)"
@mouseenter="select(command.id)"
>
<template #icon>
<SlashCommandAppIcon>
{{ command.name.slice(0, 1).toUpperCase() }}
</SlashCommandAppIcon>
</template>
</SlashCommandSuggestion>
</SlashCommandSuggestionGroup>
<p
v-if="props.commands.length === 0"
class="px-2 py-4 text-sm text-newt-text-muted"
>
{{ props.emptyLabel }}
</p>
</SlashCommandSuggestionsList>
</SlashCommandSuggestions>
<BotCommandCard
v-if="selectedCommand"
class="max-w-none"
aria-live="polite"
>
<BotCommandCardHeader>
<BotCommandCardName>{{ selectedCommand.name }}</BotCommandCardName>
<span
v-if="selectedCommand.appLabel"
class="text-xs text-newt-text-muted"
>
{{ selectedCommand.appLabel }}
</span>
</BotCommandCardHeader>
<BotCommandCardDescription>
{{ selectedCommand.description }}
</BotCommandCardDescription>
<BotCommandCardOptions v-if="selectedCommand.options?.length">
<BotCommandCardOption
v-for="option in selectedCommand.options"
:key="option.name"
v-bind="option"
/>
</BotCommandCardOptions>
</BotCommandCard>
<div
v-else
class="rounded-md border border-newt-border bg-newt-bg-elevated p-4 text-sm text-newt-text-muted"
aria-live="polite"
>
{{ props.emptyLabel }}
</div>
</section>
</template>export interface CommandPanelOption {
name: string
type?: string
required?: boolean
description?: string
}
export interface CommandPanelCommand {
id: string
name: string
description: string
appLabel?: string
options?: readonly CommandPanelOption[]
}export { default as CommandPanel } from "./CommandPanel.vue"
export type { CommandPanelCommand, CommandPanelOption } from "./types"Update the import paths
The files import @/lib/utils and sibling components through the aliases in your components.json. Point them at wherever those live in your project.
Usage
import { CommandPanel } from "@/components/command-panel"
export function Example() {
return <CommandPanel />
}<script setup lang="ts">
import { CommandPanel } from "@/components/command-panel"
</script>
<template>
<CommandPanel />
</template>API reference
CommandPanel
commands?readonly CommandPanelCommand[]
Required commands value.
readonly CommandPanelCommand[]selectedId?string | undefined
Optional selectedId value.
string | undefineddefaultSelectedId?string | undefined
Optional defaultSelectedId value.
string | undefinedonSelectedIdChange?(id: string) => void | undefined
Optional onSelectedIdChange value.
(id: string) => void | undefinedlistLabel?string | undefined
Optional listLabel value.
string | undefinedemptyLabel?string | undefined
Optional emptyLabel value.
string | undefinedThe registry exports CommandPanel plus the CommandPanelCommand and CommandPanelOption types. It also accepts the native section attributes; React uses className, while Vue uses class and fallthrough attributes.
Accessibility
The suggestion list is one keyboard focus stop. Arrow keys, Home and End move its active descendant while the detail card is announced through a polite live region. Give the list a contextual listLabel, keep command IDs unique, and do not communicate the active command by colour alone.
Tokens
Command panel reads its colours, radii and motion from the --newt-* tokens installed with the library. Override a token and every component that reads it follows, including this one — see the token layer.