لوحة الأوامر
لوحة أوامر قابلة للبحث تُفتح بـ Ctrl+K أو Cmd+K. تُحمَّل عند أول فتح لها، لا مع الصفحة أبدًا.
لوحة الأوامر
client - الحجم لم يُقَس بعد, عند الطلبلوحة أوامر قابلة للبحث تُفتح بـ Ctrl+K أو Cmd+K. تُحمَّل عند أول فتح لها، لا مع الصفحة أبدًا.
جرّبه
الشيفرة
'use client'
import { useState } from 'react'
import { magnifyingGlass } from 'topclassui/icons/magnifying-glass'
import { Button } from 'topclassui/button'
import { Svg } from 'topclassui/svg'
import { Command } from 'topclassui/command'
import type { CommandDesignProps } from 'topclassui/command'
interface Entry { id: string, label: string }
const COMMANDS: Entry[] = [
{ id: 'button', label: 'Button' },
{ id: 'dialog', label: 'Dialog' },
{ id: 'drawer', label: 'Drawer' },
{ id: 'popover', label: 'Popover' },
{ id: 'tabs', label: 'Tabs' },
{ id: 'accordion', label: 'Accordion' },
{ id: 'table', label: 'Table' },
{ id: 'pagination', label: 'Pagination' },
{ id: 'breadcrumb', label: 'Breadcrumb' },
{ id: 'tooltip', label: 'Tooltip' },
]
/** The docs stage: the basic palette, with the schema's props passed through, so the Try it
* controls drive it. defaultOpen is where the open state starts; the Button opens it too. */
export default function Demo({ visibleRows, virtualize, shortcut, defaultOpen }: CommandDesignProps = {}) {
const [open, setOpen] = useState(defaultOpen ?? false)
return (
<div className="grid justify-items-start gap-3">
<Button variant="outline" onClick={() => { setOpen(true) }}><Svg icon={magnifyingGlass} />Search</Button>
<Command
items={COMMANDS}
getKey={(command) => command.id}
getLabel={(command) => command.label}
onSelect={() => undefined}
visibleRows={visibleRows}
virtualize={virtualize}
shortcut={shortcut}
open={open}
onOpenChange={setOpen}
/>
</div>
)
}
getKey وgetLabel وonSelect دوال، لذا تُضبط اللوحة داخل ملف عميل.
كل الحالات، بدون تمرير الفأرة
6 حالاتأو استورده من الحزمة
import { Command } from 'topclassui/command'ثبّت مرة واحدة:npm i topclassui@beta zod
أمثلة
Commandهي اللوحة كلها، وأجزاؤها غير مُصدَّرة.- افتحها بـ
openوonOpenChangeمن زرك، أو اتركهما ودعshortcutيفتحها. - قبل أول فتح لا تعرض شيئًا ولم تحمّل شيئًا من Base UI، وتبقى مركّبة بعد ذلك.
- كل كلمة من الاستعلام تُطابق في أي موضع من
getLabelوبأي حالة أحرف، ويُحفظ ترتيبitems. getGroupيعيد عنوانًا، وrenderItemيرسم صفًا يبقى بارتفاع سطر واحد.virtualizeهو'auto'(عرض جزئي فوق 200 صف) أوtrueأوfalse، وvisibleRowsيحدّد عدد الصفوف الظاهرة.
Nothing ran yet.
'use client'
import { useState } from 'react'
import { magnifyingGlass } from 'topclassui/icons/magnifying-glass'
import { Button } from 'topclassui/button'
import { Svg } from 'topclassui/svg'
import { Command } from 'topclassui/command'
interface Entry { id: string, label: string }
const COMMANDS: Entry[] = [
{ id: 'button', label: 'Button' },
{ id: 'button-group', label: 'Button group' },
{ id: 'icon-button', label: 'Icon button' },
{ id: 'dialog', label: 'Dialog' },
{ id: 'drawer', label: 'Drawer' },
{ id: 'popover', label: 'Popover' },
{ id: 'tabs', label: 'Tabs' },
{ id: 'accordion', label: 'Accordion' },
{ id: 'table', label: 'Table' },
{ id: 'pagination', label: 'Pagination' },
{ id: 'breadcrumb', label: 'Breadcrumb' },
{ id: 'tooltip', label: 'Tooltip' },
]
/** The docs sample: a Button opens it, and so does Ctrl+K or Cmd+K. What ran is written below,
* with how many times, so a test can tell that Enter ran it once. */
export default function Demo() {
const [open, setOpen] = useState(false)
const [ran, setRan] = useState({ label: '', times: 0 })
return (
<div className="grid justify-items-start gap-3">
<Button variant="outline" onClick={() => { setOpen(true) }}><Svg icon={magnifyingGlass} />Search</Button>
<Command
items={COMMANDS}
getKey={(command) => command.id}
getLabel={(command) => command.label}
onSelect={(command) => { setRan((before) => ({ label: command.label, times: before.times + 1 })) }}
open={open}
onOpenChange={setOpen}
/>
<p data-testid="ran" className="text-sm text-muted">{ran.times === 0 ? 'Nothing ran yet.' : `Ran ${ran.label}, ${String(ran.times)} in all.`}</p>
</div>
)
}
Nothing ran yet.
'use client'
import { useState } from 'react'
import { Button } from 'topclassui/button'
import { Command } from 'topclassui/command'
interface Entry { id: string, label: string, group: string }
const COMMANDS: Entry[] = [
{ id: 'new-file', label: 'New file', group: 'Files' },
{ id: 'open-recent', label: 'Open recent', group: 'Files' },
{ id: 'theme', label: 'Change the theme', group: 'Preferences' },
{ id: 'save', label: 'Save all', group: 'Files' },
{ id: 'shortcuts', label: 'Keyboard shortcuts', group: 'Preferences' },
{ id: 'docs', label: 'Read the docs', group: 'Help' },
{ id: 'report', label: 'Report a problem', group: 'Help' },
]
/** Three headings. A group stands where its first command stood, and keeps its commands in order,
* so Save all joins New file and Open recent under Files. */
export default function Demo() {
const [open, setOpen] = useState(false)
const [ran, setRan] = useState('')
return (
<div className="grid justify-items-start gap-3">
<Button variant="outline" onClick={() => { setOpen(true) }}>Commands by group</Button>
<Command
items={COMMANDS}
getKey={(command) => command.id}
getLabel={(command) => command.label}
getGroup={(command) => command.group}
onSelect={(command) => { setRan(command.label) }}
open={open}
onOpenChange={setOpen}
/>
<p data-testid="ran" className="text-sm text-muted">{ran === '' ? 'Nothing ran yet.' : `Ran ${ran}.`}</p>
</div>
)
}
'use client'
import { useState } from 'react'
import { downloadSimple } from 'topclassui/icons/download-simple'
import { fileText } from 'topclassui/icons/file-text'
import { gear } from 'topclassui/icons/gear'
import { Button } from 'topclassui/button'
import { Kbd } from 'topclassui/kbd'
import { Svg } from 'topclassui/svg'
import type { IconData } from 'topclassui/icons'
import { Command } from 'topclassui/command'
interface Entry { id: string, label: string, icon: IconData, keys: string }
const COMMANDS: Entry[] = [
{ id: 'new', label: 'New document', icon: fileText, keys: 'Ctrl N' },
{ id: 'save', label: 'Download a copy', icon: downloadSimple, keys: 'Ctrl S' },
{ id: 'settings', label: 'Settings', icon: gear, keys: 'Ctrl ,' },
]
/** Each row drawn by renderItem: an icon, the label, and the shortcut at the end. The row stays one
* row tall, the label still truncates, and the query still matches the label alone. */
export default function Demo() {
const [open, setOpen] = useState(false)
return (
<div className="grid justify-items-start gap-3">
<Button variant="outline" onClick={() => { setOpen(true) }}>Commands with shortcuts</Button>
<Command
items={COMMANDS}
getKey={(command) => command.id}
getLabel={(command) => command.label}
renderItem={(command) => (
<>
<Svg icon={command.icon} />
<span className="min-w-0 flex-1 truncate">{command.label}</span>
<Kbd>{command.keys}</Kbd>
</>
)}
onSelect={() => undefined}
open={open}
onOpenChange={setOpen}
/>
</div>
)
}
'use client'
import { useState } from 'react'
import { Button } from 'topclassui/button'
import { Command } from 'topclassui/command'
interface Entry { id: string, label: string }
const COMMANDS: Entry[] = [
{ id: 'invite', label: 'Invite a teammate' },
{ id: 'billing', label: 'Open billing' },
]
/** A query that matches nothing shows the empty text of the labels, No results, under the input.
* The list itself is gone then, so no empty listbox is left for a reader to land in. */
export default function Demo() {
const [open, setOpen] = useState(false)
return (
<div className="grid justify-items-start gap-3">
<Button variant="outline" onClick={() => { setOpen(true) }}>Find a command</Button>
<Command
items={COMMANDS}
getKey={(command) => command.id}
getLabel={(command) => command.label}
onSelect={() => undefined}
open={open}
onOpenChange={setOpen}
/>
</div>
)
}
'use client'
import { useState } from 'react'
import { Button } from 'topclassui/button'
import { Command } from 'topclassui/command'
interface Entry { id: string, label: string }
const NONE: Entry[] = []
/** The commands are still on their way: status loading, with no items, says Loading where the
* empty text would be, and the same live region announces it. */
export default function Demo() {
const [open, setOpen] = useState(false)
return (
<div className="grid justify-items-start gap-3">
<Button variant="outline" onClick={() => { setOpen(true) }}>Commands that are loading</Button>
<Command
items={NONE}
status="loading"
getKey={(command) => command.id}
getLabel={(command) => command.label}
onSelect={() => undefined}
open={open}
onOpenChange={setOpen}
/>
</div>
)
}
Nothing ran yet.
'use client'
import { useState } from 'react'
import { Button } from 'topclassui/button'
import { Command } from 'topclassui/command'
const AREAS = ['Billing', 'Contacts', 'Documents', 'Files', 'Invoices', 'Messages', 'Projects', 'Reports', 'Settings', 'Tasks']
/** A thousand commands under ten headings, a hundred each: past the threshold, so the list is
* windowed, and a heading that scrolls away still names the rows of its group. */
const commands = Array.from({ length: 1000 }, (_, index) => {
const area = AREAS[Math.floor(index / 100)] ?? ''
return { id: `t${String(index + 1)}`, area, label: `${area} action ${String((index % 100) + 1)}` }
})
export default function Demo() {
const [open, setOpen] = useState(false)
const [ran, setRan] = useState('')
return (
<div className="grid justify-items-start gap-3">
<Button variant="outline" onClick={() => { setOpen(true) }}>A thousand commands</Button>
<Command
items={commands}
getKey={(command) => command.id}
getLabel={(command) => command.label}
getGroup={(command) => command.area}
onSelect={(command) => { setRan(command.label) }}
open={open}
onOpenChange={setOpen}
/>
<p data-testid="ran" className="text-sm text-muted">{ran === '' ? 'Nothing ran yet.' : `Ran ${ran}.`}</p>
</div>
)
}
Nothing ran yet.
'use client'
import { useState } from 'react'
import { Button } from 'topclassui/button'
import { Command } from 'topclassui/command'
const VERBS = ['Open', 'Close', 'Rename', 'Archive', 'Share', 'Export', 'Duplicate', 'Pin']
const NOUNS = ['report', 'invoice', 'project', 'contract', 'draft', 'ticket', 'board', 'note', 'folder', 'page']
/** Ten thousand commands in one flat list. Windowing mounts only the rows in view, so typing and
* scrolling stay quick; the last one is "Pin page 10000". */
const commands = Array.from({ length: 10_000 }, (_, index) => ({
id: `c${String(index + 1)}`,
label: `${VERBS[index % VERBS.length] ?? ''} ${NOUNS[Math.floor(index / VERBS.length) % NOUNS.length] ?? ''} ${String(index + 1)}`,
}))
export default function Demo() {
const [open, setOpen] = useState(false)
const [ran, setRan] = useState('')
return (
<div className="grid justify-items-start gap-3">
<Button variant="outline" onClick={() => { setOpen(true) }}>Ten thousand commands</Button>
<Command
items={commands}
getKey={(command) => command.id}
getLabel={(command) => command.label}
onSelect={(command) => { setRan(command.label) }}
open={open}
onOpenChange={setOpen}
/>
<p data-testid="ran" className="text-sm text-muted">{ran === '' ? 'Nothing ran yet.' : `Ran ${ran}.`}</p>
</div>
)
}
'use client'
import { useState } from 'react'
import { Button } from 'topclassui/button'
import { Command } from 'topclassui/command'
interface Entry { id: string, label: string, group: string }
const WORD = 'Unbreakable'.repeat(27).slice(0, 300)
/** Sixty groups of two, a heading of 300 characters with no space in it, a row of the same, and an
* element where the empty text would be. Nothing widens the palette or the page: rows and headings
* truncate, and the fallback wraps anywhere. */
const commands: Entry[] = [
{ id: 'word', label: WORD, group: WORD },
...Array.from({ length: 120 }, (_, index) => ({
id: `row-${String(index + 1)}`,
label: `Row ${String(index + 1)} of group ${String(Math.floor(index / 2) + 1)}`,
group: `Group ${String(Math.floor(index / 2) + 1)}`,
})),
]
export default function Demo() {
const [open, setOpen] = useState(false)
return (
<div className="grid justify-items-start gap-3">
<Button variant="outline" onClick={() => { setOpen(true) }}>Every hostile case</Button>
<Command
items={commands}
getKey={(command) => command.id}
getLabel={(command) => command.label}
getGroup={(command) => command.group}
emptyFallback={<span>Nothing matches <strong>{WORD}</strong></span>}
onSelect={() => undefined}
open={open}
onOpenChange={setOpen}
/>
</div>
)
}
Section 1. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 2. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 3. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 4. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 5. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 6. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 7. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 8. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 9. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 10. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 11. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 12. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 13. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 14. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 15. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 16. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 17. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 18. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 19. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 20. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 21. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 22. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 23. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 24. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 25. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 26. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 27. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 28. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 29. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 30. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 31. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 32. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 33. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 34. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 35. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 36. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 37. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 38. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 39. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 40. The page goes on below the palette, so a scroll that escaped it would move this text.
'use client'
import { useState } from 'react'
import { Button } from 'topclassui/button'
import { Command } from 'topclassui/command'
interface Entry { id: string, label: string }
const commands: Entry[] = Array.from({ length: 60 }, (_, index) => ({
id: `section-${String(index + 1)}`,
label: `Go to section ${String(index + 1)}`,
}))
const SECTIONS = Array.from({ length: 40 }, (_, index) => index + 1)
/** Sixty rows over a page far taller than the screen. The list scrolls inside the palette; the
* page behind stays where it was, under a wheel, a finger and the keyboard alike. */
export default function Demo() {
const [open, setOpen] = useState(false)
return (
<div className="grid justify-items-start gap-3">
<Button variant="outline" onClick={() => { setOpen(true) }}>Jump to a section</Button>
<Command
items={commands}
getKey={(command) => command.id}
getLabel={(command) => command.label}
onSelect={() => undefined}
open={open}
onOpenChange={setOpen}
/>
{SECTIONS.map((section) => (
<p key={section} className="text-sm text-muted">
Section {section}. The page goes on below the palette, so a scroll that escaped it would move this text.
</p>
))}
</div>
)
}
'use client'
import { useState } from 'react'
import { Button } from 'topclassui/button'
import { TooltipPopup, TooltipRoot, TooltipTrigger } from 'topclassui/tooltip'
import { Command } from 'topclassui/command'
interface Entry { id: string, label: string }
const COMMANDS: Entry[] = [
{ id: 'invite', label: 'Invite a teammate' },
{ id: 'billing', label: 'Open billing' },
]
/** An empty text that holds an action with a tooltip. One Escape closes one thing: while the
* tooltip is open the key closes it and the palette stays; the next one closes the palette. */
export default function Demo() {
const [open, setOpen] = useState(false)
return (
<div className="grid justify-items-start gap-3">
<Button variant="outline" onClick={() => { setOpen(true) }}>Find or ask</Button>
<Command
items={COMMANDS}
getKey={(command) => command.id}
getLabel={(command) => command.label}
emptyFallback={
<span className="grid justify-items-center gap-2">
No command by that name.
<TooltipRoot>
<TooltipTrigger render={<Button variant="outline">Ask for one</Button>} />
<TooltipPopup>Sends the query to the team</TooltipPopup>
</TooltipRoot>
</span>
}
onSelect={() => undefined}
open={open}
onOpenChange={setOpen}
/>
</div>
)
}
'use client'
import { useState } from 'react'
import { magnifyingGlass } from 'topclassui/icons/magnifying-glass'
import { TopClassProvider } from 'topclassui/provider'
import { Button } from 'topclassui/button'
import { Svg } from 'topclassui/svg'
import { Command } from 'topclassui/command'
interface Entry { id: string, label: string, group: string }
const COMMANDS: Entry[] = [
{ id: 'new', label: 'ملف جديد', group: 'الملفات' },
{ id: 'recent', label: 'فتح ملف حديث', group: 'الملفات' },
{ id: 'theme', label: 'تغيير المظهر', group: 'التفضيلات' },
{ id: 'language', label: 'تغيير اللغة', group: 'التفضيلات' },
{ id: 'docs', label: 'قراءة الدليل', group: 'المساعدة' },
]
/** Arabic, inside TopClassProvider as the root layout would mount it. The palette is portaled into
* the body, so it takes its direction and its language from the provider: the glass sits at the
* right of the input, the rows are right-aligned and the placeholder is Arabic, on an English page too. */
export default function Demo() {
const [open, setOpen] = useState(false)
const [ran, setRan] = useState('')
return (
<TopClassProvider direction="rtl" locale="ar">
<div dir="rtl" lang="ar" className="grid justify-items-start gap-3">
<Button variant="outline" onClick={() => { setOpen(true) }}><Svg icon={magnifyingGlass} />بحث</Button>
<Command
items={COMMANDS}
getKey={(command) => command.id}
getLabel={(command) => command.label}
getGroup={(command) => command.group}
onSelect={(command) => { setRan(command.label) }}
open={open}
onOpenChange={setOpen}
/>
<p data-testid="ran" className="text-sm text-muted">{ran}</p>
</div>
</TopClassProvider>
)
}
الخصائص
لوحة المفاتيح وإمكانية الوصول
- حقل البحث combobox فوق listbox، و
aria-activedescendantيشير إلى الصف النشط. - إن لم تطابق أي نتيجة تُزال القائمة ويظهر نص الفراغ، فلا تبقى listbox فارغة يقع فيها القارئ.
- يبقى Home وEnd لحقل البحث، لتحريك المؤشر فيه.
من اليمين إلى اليسار
تقع العدسة على يمين حقل البحث وتُحاذى الصفوف إلى اليمين. يعمل Ctrl+K أيضًا مع لوحة مفاتيح عربية، لأن المفتاح يُطابق بموضعه أيضًا.
الخادم والمتصفح
مكوّن متصفح: يُعرض على الخادم ثم يُرطَّب في المتصفح.
الملفات: index.tsx, client.tsx.
على الشارة: client - الحجم لم يُقَس بعد, عند الطلب.