Dropdown menu
A menu of actions and links behind one button. Link rows are real anchors, so open in a new tab and middle-click work.
Dropdown menu
client - size not measured yetA menu of actions and links behind one button. Link rows are real anchors, so open in a new tab and middle-click work.
Try it
The code
import { copy } from 'topclassui/icons/copy'
import { gear } from 'topclassui/icons/gear'
import { trash } from 'topclassui/icons/trash'
import { user } from 'topclassui/icons/user'
import { Button } from 'topclassui/button'
import { Svg } from 'topclassui/svg'
import { DropdownItem, DropdownPopup, DropdownRoot, DropdownSeparator, DropdownTrigger } from 'topclassui/dropdown'
<DropdownRoot>
<DropdownTrigger openOnHover render={<Button variant="outline">Actions</Button>} />
<DropdownPopup>
<DropdownItem icon={<Svg icon={user} />} shortcut="Ctrl P">Profile</DropdownItem>
<DropdownItem icon={<Svg icon={gear} />}>Settings</DropdownItem>
<DropdownItem icon={<Svg icon={copy} />}>Duplicate</DropdownItem>
<DropdownSeparator />
<DropdownItem icon={<Svg icon={trash} />}>Delete</DropdownItem>
</DropdownPopup>
</DropdownRoot>href and Server Action items work from a server page; onSelect is a plain function and needs a client file.
Every state
4 statesImport from the package
import { DropdownRoot, DropdownGroup, DropdownGroupLabel, DropdownItem, DropdownLinkItem, DropdownMenu, DropdownPopup, DropdownSeparator, DropdownTrigger } from 'topclassui/dropdown'Install once:npm i topclassui@beta zod
Examples
- For a short list of actions or links behind one button. Anything that is not a row goes in a Popover.
- Flat parts and no bare
Dropdown:DropdownRoot,DropdownTrigger,DropdownPopup,DropdownItem,DropdownLinkItem,DropdownGroup,DropdownGroupLabel,DropdownSeparator. DropdownMenubuilds the same menu fromitems: each is anhreflink, a Server Actionaction, or anonSelectfunction. Pass your framework'sLinkaslink.- A row takes
iconbefore the label andshortcutafter it; a long label ends in an ellipsis. - The menu is as wide as its longest row, 192 to 320 px, and shows about seven rows before it scrolls inside itself.
- Every row is mounted: keep a menu to tens of rows.
import { copy } from 'topclassui/icons/copy'
import { gear } from 'topclassui/icons/gear'
import { pencilSimple } from 'topclassui/icons/pencil-simple'
import { trash } from 'topclassui/icons/trash'
import { user } from 'topclassui/icons/user'
import { Button } from 'topclassui/button'
import { Svg } from 'topclassui/svg'
import { DropdownItem, DropdownPopup, DropdownRoot, DropdownSeparator, DropdownTrigger } from 'topclassui/dropdown'
/** The drawing, from the parts: an icon, a shortcut, a disabled row and a destructive one. The
* label of the trigger sits inside the Button passed to render, so a server render keeps it whole. */
export default function Demo() {
return (
<DropdownRoot>
<DropdownTrigger render={<Button variant="outline">Actions</Button>} />
<DropdownPopup>
<DropdownItem icon={<Svg icon={user} />} shortcut="Ctrl P">Profile</DropdownItem>
<DropdownItem icon={<Svg icon={gear} />}>Settings</DropdownItem>
<DropdownItem icon={<Svg icon={copy} />}>Duplicate</DropdownItem>
<DropdownItem icon={<Svg icon={pencilSimple} />} disabled>Rename</DropdownItem>
<DropdownSeparator />
<DropdownItem icon={<Svg icon={trash} />} shortcut="Del" tone="danger">Delete</DropdownItem>
</DropdownPopup>
</DropdownRoot>
)
}
import { Button } from 'topclassui/button'
import {
DropdownGroup, DropdownGroupLabel, DropdownItem, DropdownPopup, DropdownRoot, DropdownSeparator, DropdownTrigger,
} from 'topclassui/dropdown'
/** Two groups, each named by its label, with a line between them. A label is no row: the
* arrow keys pass it, and a screen reader reads it as the name of its group. */
export default function Demo() {
return (
<DropdownRoot>
<DropdownTrigger render={<Button variant="outline">View</Button>} />
<DropdownPopup>
<DropdownGroup>
<DropdownGroupLabel>Sort by</DropdownGroupLabel>
<DropdownItem>Name</DropdownItem>
<DropdownItem>Last edited</DropdownItem>
</DropdownGroup>
<DropdownSeparator />
<DropdownGroup>
<DropdownGroupLabel>Show as</DropdownGroupLabel>
<DropdownItem>Grid</DropdownItem>
<DropdownItem>Table</DropdownItem>
</DropdownGroup>
</DropdownPopup>
</DropdownRoot>
)
}
import { folder } from 'topclassui/icons/folder'
import { trash } from 'topclassui/icons/trash'
import { Button } from 'topclassui/button'
import { Svg } from 'topclassui/svg'
import { DropdownItem, DropdownPopup, DropdownRoot, DropdownSeparator, DropdownTrigger } from 'topclassui/dropdown'
/** A destructive row. tone="danger" only colours it, and a reader who cannot see red gets no
* colour: the label says what it does, an icon repeats it, and a line sets it apart. */
export default function Demo() {
return (
<DropdownRoot>
<DropdownTrigger render={<Button variant="outline">Project</Button>} />
<DropdownPopup>
<DropdownItem icon={<Svg icon={folder} />}>Move to archive</DropdownItem>
<DropdownSeparator />
<DropdownItem icon={<Svg icon={trash} />} tone="danger">Delete project</DropdownItem>
</DropdownPopup>
</DropdownRoot>
)
}
import { arrowSquareOut } from 'topclassui/icons/arrow-square-out'
import { bookOpen } from 'topclassui/icons/book-open'
import { Button } from 'topclassui/button'
import { Svg } from 'topclassui/svg'
import { DropdownLinkItem, DropdownPopup, DropdownRoot, DropdownTrigger } from 'topclassui/dropdown'
/** Rows that navigate are real anchors, so open in a new tab and middle-click work. A press
* closes the menu. One row is another element through render: its own href is the one used. */
export default function Demo() {
return (
<DropdownRoot>
<DropdownTrigger render={<Button variant="outline">Help</Button>} />
<DropdownPopup>
<DropdownLinkItem href="#docs" icon={<Svg icon={bookOpen} />}>Documentation</DropdownLinkItem>
<DropdownLinkItem href="#changelog">Changelog</DropdownLinkItem>
<DropdownLinkItem render={<a data-link="app" href="#support" />}>Contact support</DropdownLinkItem>
<DropdownLinkItem href="https://example.com/status" target="_blank" rel="noreferrer" icon={<Svg icon={arrowSquareOut} />}>
Status page
</DropdownLinkItem>
</DropdownPopup>
</DropdownRoot>
)
}
import { gear } from 'topclassui/icons/gear'
import { signOut } from 'topclassui/icons/sign-out'
import { user } from 'topclassui/icons/user'
import { Button } from 'topclassui/button'
import { Svg } from 'topclassui/svg'
import { DropdownMenu } from 'topclassui/dropdown'
/** A Server Action, the one function a server page may pass. In an app it ends the session. */
async function endSession() {
'use server'
await Promise.resolve()
}
/** The same menu from data, as a server page writes it: two links and a Server Action. The
* anchor in link stands in for the Link of a framework, and each href row is cloned from it. */
export default function Demo() {
return (
<DropdownMenu
trigger={<Button variant="outline">Account</Button>}
title="Account"
link={<a data-link="app" href="" />}
items={[
{ id: 'profile', label: 'Profile', icon: <Svg icon={user} />, shortcut: 'Ctrl P', href: '#profile' },
{ id: 'settings', label: 'Settings', icon: <Svg icon={gear} />, href: '#settings' },
{ id: 'billing', label: 'Billing', href: '#billing', disabled: true },
{ id: 'signout', label: 'Sign out', icon: <Svg icon={signOut} />, tone: 'danger', separatorBefore: true, action: endSession },
]}
/>
)
}
import { Button } from 'topclassui/button'
import { DropdownItem, DropdownPopup, DropdownRoot, DropdownTrigger } from 'topclassui/dropdown'
import type { DropdownPlacement } from 'topclassui/dropdown'
const PLACES: DropdownPlacement[] = [
'top-start', 'top', 'top-end',
'start-top', 'start', 'start-bottom',
'end-top', 'end', 'end-bottom',
'bottom-start', 'bottom', 'bottom-end',
]
/** All twelve placements, one outline Button each, named after the placement it opens at.
* start and end are the inline sides, so they mirror; a side with no room flips. */
export default function Demo() {
return (
<div className="grid grid-cols-3 gap-3 p-12">
{PLACES.map((place) => (
<DropdownRoot key={place}>
<DropdownTrigger render={<Button variant="outline" size="sm">{place}</Button>} />
<DropdownPopup placement={place}>
<DropdownItem>Rename</DropdownItem>
<DropdownItem>Duplicate</DropdownItem>
</DropdownPopup>
</DropdownRoot>
))}
</div>
)
}
import { Button } from 'topclassui/button'
import { DropdownItem, DropdownPopup, DropdownRoot, DropdownTrigger } from 'topclassui/dropdown'
const ROWS = Array.from({ length: 60 }, (_, index) => index + 1)
/** Sixty rows. The menu shows about seven (--tc-list-rows), cuts the last one so it is plain
* there are more, and scrolls inside itself (R-10.5-1). Every row is mounted: the arrow keys
* and type-ahead reach all sixty, and the page behind does not move. */
export default function Demo() {
return (
<DropdownRoot>
<DropdownTrigger render={<Button variant="outline">Time zone</Button>} />
<DropdownPopup>
{ROWS.map((row) => <DropdownItem key={row}>Zone {row}</DropdownItem>)}
</DropdownPopup>
</DropdownRoot>
)
}
import { Button } from 'topclassui/button'
import {
DialogBody, DialogClose, DialogFooter, DialogHeader, DialogPopup, DialogRoot, DialogTitle, DialogTrigger,
} from 'topclassui/dialog'
import { DropdownItem, DropdownPopup, DropdownRoot, DropdownTrigger } from 'topclassui/dropdown'
/** A menu inside a dialog. It opens above the dialog, and one Escape closes one thing: the
* menu first, with the focus back on its trigger, and the dialog on the next press. */
export default function Demo() {
return (
<DialogRoot>
<DialogTrigger render={<Button variant="outline">Invite people</Button>} />
<DialogPopup>
<DialogHeader>
<DialogTitle>Invite people</DialogTitle>
</DialogHeader>
<DialogBody className="flex items-center justify-between gap-3">
<span>New members join as</span>
<DropdownRoot>
<DropdownTrigger render={<Button variant="outline" size="sm">Role</Button>} />
<DropdownPopup placement="bottom-end">
<DropdownItem>Viewer</DropdownItem>
<DropdownItem>Editor</DropdownItem>
<DropdownItem>Owner</DropdownItem>
</DropdownPopup>
</DropdownRoot>
</DialogBody>
<DialogFooter>
<DialogClose render={<Button>Done</Button>} />
</DialogFooter>
</DialogPopup>
</DialogRoot>
)
}
import { gear } from 'topclassui/icons/gear'
import { trash } from 'topclassui/icons/trash'
import { user } from 'topclassui/icons/user'
import { TopClassProvider } from 'topclassui/provider'
import { Button } from 'topclassui/button'
import { Svg } from 'topclassui/svg'
import { DropdownItem, DropdownPopup, DropdownRoot, DropdownSeparator, DropdownTrigger } from 'topclassui/dropdown'
/** Arabic, inside TopClassProvider as the root layout would mount it. The menu is portaled into
* the body, so it takes its direction and its language from the provider: bottom-start lines up
* the right edges, the icon is at the right and the shortcut at the left, on an English page too. */
export default function Demo() {
return (
<TopClassProvider direction="rtl" locale="ar">
<div dir="rtl" lang="ar" className="flex flex-wrap items-center gap-3">
<DropdownRoot>
<DropdownTrigger render={<Button variant="outline">الإجراءات</Button>} />
<DropdownPopup>
<DropdownItem icon={<Svg icon={user} />} shortcut="Ctrl P">الملف الشخصي</DropdownItem>
<DropdownItem icon={<Svg icon={gear} />}>الإعدادات</DropdownItem>
<DropdownSeparator />
<DropdownItem icon={<Svg icon={trash} />} tone="danger">حذف</DropdownItem>
</DropdownPopup>
</DropdownRoot>
<DropdownRoot>
<DropdownTrigger render={<Button variant="outline">المزيد</Button>} />
<DropdownPopup placement="end-top">
<DropdownItem>نسخ الرابط</DropdownItem>
<DropdownItem>إعادة التسمية</DropdownItem>
</DropdownPopup>
</DropdownRoot>
</div>
</TopClassProvider>
)
}
Props
DropdownRoot
DropdownGroup
No props of its own: it takes the attributes of the element it renders.
DropdownGroupLabel
No props of its own: it takes the attributes of the element it renders.
DropdownItem
DropdownLinkItem
DropdownMenu
DropdownPopup
DropdownSeparator
No props of its own: it takes the attributes of the element it renders.
DropdownTrigger
Keyboard and accessibility
tone="danger"only colours the row: write what it does in the label, add an icon, and put a separator above it.- A row with an icon alone needs
aria-label, and the types ask for it. - A disabled row is announced as unavailable, and the arrow keys still stop on it.
Right to left
bottom-start lines up the right edges, the icon sits at the right and the shortcut at the left. The menu takes its direction from the nearest TopClassProvider.
Server and client
A client component: it renders on the server and hydrates in the browser.
Files: index.tsx, client.tsx.
On the chip: client - size not measured yet.