one selection for any set shown: click, add, range, all, marquee
npx shadcn add @ag/selectionAlready installed? Preview an update
npx shadcn add @ag/selection --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-10-12
- Changed:
SelectionBarstays one row. Its verbs keep the order they are given (their menu's, so their priority) and as many as the room holds stand in the bar; the rest are a "more" menu at its end, separators kept. The count and the clear always stand. The split is measured from the verbs' own widths and follows the room as it changes, where a long list used to wrap the bar onto a second row.
the demo above, verbatim
"use client"
import { Archive, Star, Trash2 } from "lucide-react"
import * as React from "react"
import { Sample } from "@/app/samples"
import { cn } from "@/lib/utils"
import {
actsOn,
all,
inOrder,
NONE,
only,
range,
type Selection,
toggle,
} from "@/registry/base-nova/lib/selection"
import { Menu, type MenuEntry, verbFor } from "@/registry/base-nova/ui/menu"
import { SelectionBar } from "@/registry/base-nova/ui/selection-bar"
import { usePlatform } from "@/registry/base-nova/ui/shortcut"
// #region list
// Any list, selected the one way: a click is this one alone, ⌘ adds or takes back,
// ⇧ a range from the last click in the list's order, ⌘A all, Esc none. ↑ ↓ move,
// with ⇧ they extend.
const NAMES = [
"itinerary.pdf",
"budget.csv",
"shot list.csv",
"recipes.md",
"talk.key",
"notes.md",
"camera settings.json",
]
const ORDER = NAMES.map((_, i) => `f${i}`)
const nameOf = (id: string) => NAMES[ORDER.indexOf(id)] as string
// #endregion
// #region verbs
// One list of verbs, read from what it acts on: a star toggle that says which way
// it goes for all of them, and a verb that needs two.
const verbs = (
ids: readonly string[],
starred: ReadonlySet<string>,
star: (ids: readonly string[], on: boolean) => void,
say: (what: string) => void,
): MenuEntry[] => {
const all = ids.every((id) => starred.has(id))
return [
{
icon: <Star />,
label: all ? "unstar" : "star",
onSelect: () => star(ids, !all),
},
...(ids.length > 1
? [
{
icon: <Archive />,
label: `zip ${ids.length}`,
onSelect: () => say(`zip ${ids.map(nameOf).join(", ")}`),
},
]
: []),
"separator" as const,
{
icon: <Trash2 />,
label: "move to trash",
hint: "mod+backspace",
destructive: true,
onSelect: () => say(`trash ${ids.length}`),
},
]
}
// #endregion
function List() {
// Two files chosen to begin with, so the bar is there before the first click.
const [selection, setSelection] = React.useState<Selection>(() =>
toggle(only("f1"), "f3"),
)
const [starred, setStarred] = React.useState<ReadonlySet<string>>(new Set())
const [said, say] = React.useState("")
const [menu, setMenu] = React.useState<{
ids: string[]
at: { x: number; y: number }
} | null>(null)
const rows = React.useRef(new Map<string, HTMLButtonElement>())
const platform = usePlatform()
const star = (ids: readonly string[], on: boolean) =>
setStarred((was) => {
const next = new Set(was)
for (const id of ids) on ? next.add(id) : next.delete(id)
return next
})
const chosen = inOrder(ORDER, selection)
const press = (id: string, e: React.MouseEvent) => {
const mod = e.metaKey || e.ctrlKey
setSelection(
e.shiftKey
? range(ORDER, selection, id, mod)
: mod
? toggle(selection, id)
: only(id),
)
}
const onKeyDown = (e: React.KeyboardEvent) => {
const at = selection.cursor ? ORDER.indexOf(selection.cursor) : -1
const go = (i: number) => {
const id = ORDER[Math.max(0, Math.min(ORDER.length - 1, i))] as string
setSelection(e.shiftKey ? range(ORDER, selection, id) : only(id))
rows.current.get(id)?.focus()
}
if (e.key === "ArrowDown") go(at + 1)
else if (e.key === "ArrowUp") go(at < 0 ? 0 : at - 1)
else if (e.key === "Escape") setSelection(NONE)
else if (e.key === "a" && (e.metaKey || e.ctrlKey))
setSelection(all(ORDER, selection))
else {
// The keys the verbs draw are keys the list answers to.
const verb = verbFor(verbs(chosen, starred, star, say), e, platform)
if (!verb || chosen.length === 0) return
verb.onSelect()
}
e.preventDefault()
}
return (
<div className="flex flex-wrap items-start gap-x-6 gap-y-3">
<div
role="listbox"
aria-label="files"
aria-multiselectable="true"
tabIndex={-1}
onKeyDown={onKeyDown}
className="flex w-64 flex-col"
>
{ORDER.map((id) => {
const selected = selection.ids.has(id)
return (
<button
key={id}
ref={(node) => {
if (node) rows.current.set(id, node)
else rows.current.delete(id)
}}
type="button"
role="option"
aria-selected={selected}
tabIndex={id === (selection.cursor ?? ORDER[0]) ? 0 : -1}
onClick={(e) => press(id, e)}
onContextMenu={(e) => {
e.preventDefault()
setMenu({
ids: actsOn(ORDER, selection, id),
at: { x: e.clientX, y: e.clientY },
})
}}
className={cn(
"flex h-7 cursor-default select-none items-center gap-2 rounded-md px-2 text-left text-[13px] text-foreground/80",
selected && "bg-foreground/10 text-foreground",
)}
>
{nameOf(id)}
{starred.has(id) && (
<Star className="ml-auto size-3 fill-current text-foreground/45" />
)}
</button>
)
})}
</div>
<div className="flex w-80 flex-col gap-3">
<div className="h-10">
{chosen.length > 0 && (
<SelectionBar
count={chosen.length}
noun="file"
entries={verbs(chosen, starred, star, say)}
onClear={() => setSelection(NONE)}
/>
)}
</div>
<p className="text-[12px] text-foreground/50">{said || " "}</p>
</div>
<Menu
entries={menu ? verbs(menu.ids, starred, star, say) : []}
anchor={menu?.at ?? null}
onClose={() => setMenu(null)}
/>
</div>
)
}
export default function SelectionDemo() {
return (
<div className="space-y-8">
<Sample
name="list"
glance="wide"
title="one selection, one list of verbs"
description="click, ⌘-click, ⇧-click, ↑ ↓ with or without ⇧, ⌘A, Esc. The bar and a right click offer the same verbs: on a selected row for the whole selection, on any other row for that row alone."
with="list verbs"
>
<List />
</Sample>
</div>
)
}the source, every file an install writes
// THE SELECTION KERNEL, framework-free, one for every set that is shown (a gallery's
// files, a table's rows, a rail's rows): what a click, a ⌘-click, a ⇧-click, ⌘A, Esc
// and a marquee do to a selection, as pure functions over the set's order and the
// selection. The selection is `{ ids, anchor, cursor }`: the anchor is where a range
// starts (the last plain or ⌘ click), the cursor is where the keyboard is. A range is
// always in SET ORDER, never the rectangle on screen: the set's order is the
// meaningful one (from this date to that, this score to that), the keyboard extends
// the same way, and it is the list a slideshow plays. A marquee is the geometric
// selection and arrives here as the ids it touched.
//
// The selection outlives the set: a filter that hides selected items keeps them
// selected, and `shownOf` says how many are visible and how many the filter hides, so
// a bar can say "12 selected, 3 hidden by the filter" and act on the visible ones.
//
// What a verb acts on is decided once, here: `actsOn` is the visible selection when
// the item asked about is in it, else that item alone (a right click on an item
// outside the selection never acts on the selection).
export interface Selection {
ids: ReadonlySet<string>
anchor?: string
cursor?: string
}
export const NONE: Selection = { ids: new Set() }
/** A plain click, or an arrow without ⇧: this one alone. */
export function only(id: string): Selection {
return { ids: new Set([id]), anchor: id, cursor: id }
}
/** ⌘-click: this one in or out, the rest kept. */
export function toggle(selection: Selection, id: string): Selection {
const ids = new Set(selection.ids)
if (ids.has(id)) ids.delete(id)
else ids.add(id)
return { ids, anchor: id, cursor: id }
}
/** ⇧-click or ⇧-arrow: from the anchor to `to` in set order. `keep` (⌘ held as well)
* adds the range to what was selected instead of replacing it. With no anchor in the
* set (it was filtered away, or nothing was ever clicked) the range is `to` alone. */
export function range(
order: readonly string[],
selection: Selection,
to: string,
keep = false,
): Selection {
const anchor =
selection.anchor && order.includes(selection.anchor) ? selection.anchor : to
const a = order.indexOf(anchor)
const b = order.indexOf(to)
if (b < 0) throw new Error(`selection: ${to} is not in the set`)
const ids = new Set(keep ? selection.ids : [])
for (let i = Math.min(a, b); i <= Math.max(a, b); i++)
ids.add(order[i] as string)
return { ids, anchor, cursor: to }
}
/** ⌘A: every item of the set (what the filter hides stays as it was). */
export function all(order: readonly string[], selection: Selection): Selection {
return {
ids: new Set([...selection.ids, ...order]),
anchor: selection.anchor ?? order[0],
cursor: selection.cursor ?? order[0],
}
}
/** A marquee: the ids it touches, added to `base` when `keep` (⌘ or ⇧ held). */
export function area(
base: Selection,
touched: readonly string[],
keep: boolean,
): Selection {
const ids = new Set(keep ? base.ids : [])
for (const id of touched) ids.add(id)
const last = touched[touched.length - 1]
return { ids, anchor: touched[0] ?? base.anchor, cursor: last ?? base.cursor }
}
/** The keyboard cursor counts only while it points inside the selection: a
* deselected grid must never open a ghost. */
export function target(selection: Selection): string | undefined {
return selection.cursor && selection.ids.has(selection.cursor)
? selection.cursor
: undefined
}
/** The selected ids in set order (what "play the selection" plays). */
export function inOrder(
order: readonly string[],
selection: Selection,
): string[] {
return order.filter((id) => selection.ids.has(id))
}
/** What a verb asked of `id` acts on: the visible selection, in set order, when `id`
* is in it, else `id` alone. */
export function actsOn(
order: readonly string[],
selection: Selection,
id: string,
): string[] {
return selection.ids.has(id) ? inOrder(order, selection) : [id]
}
/** How many selected items the set shows and how many a filter hides. */
export function shownOf(
order: readonly string[],
selection: Selection,
): { shown: number; hidden: number } {
const shown = inOrder(order, selection).length
return { shown, hidden: selection.ids.size - shown }
}
/** Whether `id` lies inside the selection's own range (anchor to cursor, in set
* order): the band the grid washes so a set-order range reads as deliberate. */
export function inRange(
index: ReadonlyMap<string, number>,
selection: Selection,
id: string,
): boolean {
if (!selection.ids.has(id) || !selection.anchor || !selection.cursor)
return false
if (selection.anchor === selection.cursor) return false
const a = index.get(selection.anchor)
const b = index.get(selection.cursor)
const i = index.get(id)
if (a === undefined || b === undefined || i === undefined) return false
return i >= Math.min(a, b) && i <= Math.max(a, b)
}"use client"
// THE SELECTION'S VERBS, AS A BAR: it rises at the foot of the set while anything is
// selected, says how many (and how many the filter hides, which the verbs leave
// alone), and offers the consumer's verbs for exactly those, the same `MenuEntry`
// list a right click on a selected item opens, so a verb has one definition and two
// faces. Clear (or Esc with focus in the bar) empties the selection. The bar is
// placed by its parent: the gallery and the table pin it to the foot of their own
// scroller; anything else positions it as it likes.
//
// ONE ROW, NEVER WRAPPED (priority+): the verbs keep the order they are given, which is
// their menu's and so their priority, and as many as the room holds stand in the bar;
// the rest are a "more" menu at its end, the same entries, their separators kept. The
// count and the clear always stand. What fits is measured, never guessed: a ruler
// lays every verb out on one line where nothing sees it, and the split is worked out
// again whenever the room or the verbs change.
import { Ellipsis, X } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
import { Button } from "@/registry/base-nova/ui/button"
import { Hints, Menu, type MenuEntry } from "@/registry/base-nova/ui/menu"
import { Tip } from "@/registry/base-nova/ui/tooltip"
/** A rule between a bar's groups. */
export const BAR_RULE = "mx-1 h-4 w-px shrink-0 bg-foreground/12"
/** The bar's body: a floating strip at the foot of a set. Any other bar a set raises
* there (a table's unsaved changes) wears it, so the bars read as one family. */
export const BAR =
"flex w-fit max-w-full flex-wrap items-center gap-0.5 rounded-xl border border-border bg-popover/95 p-1 text-popover-foreground shadow-lg backdrop-blur-md transition-[opacity,translate] duration-200 starting:translate-y-2 starting:opacity-0 motion-reduce:transition-none"
/** Entries without a separator at either end or two in a row. */
const trimmed = (entries: readonly MenuEntry[]): MenuEntry[] =>
entries.filter(
(e, i, all) =>
e !== "separator" ||
(i > 0 && i < all.length - 1 && all[i - 1] !== "separator"),
)
export interface SelectionBarProps {
/** How many are selected and shown: what the verbs act on. */
count: number
/** How many more are selected but hidden by a filter: named, never acted on. */
hidden?: number
/** The verbs for the selection, the same list its right-click menu shows. */
entries: readonly MenuEntry[]
onClear: () => void
/** What one of the set is called, plural taken by adding s ("file", "row"). */
noun?: string
className?: string
}
export function SelectionBar({
count,
hidden = 0,
entries,
onClear,
noun = "item",
className,
}: SelectionBarProps) {
const bar = React.useRef<HTMLDivElement>(null)
const ruler = React.useRef<HTMLDivElement>(null)
// How many of the entries stand in the bar; the rest are the more menu's.
const [standing, setStanding] = React.useState(entries.length)
const [more, setMore] = React.useState<Element | null>(null)
React.useLayoutEffect(() => {
const el = bar.current
const r = ruler.current
const room = el?.parentElement
if (!el || !r || !room) return
const fit = () => {
// The ruler is the bar on one line: its parts' right edges, from its own left.
const end = (part: string) => {
const p = r.querySelector<HTMLElement>(`[data-part="${part}"]`)
if (!p) throw new Error(`selection-bar: the ruler has no ${part}`)
return p.offsetLeft + p.offsetWidth
}
const tail = (part: string) =>
(r.querySelector<HTMLElement>(`[data-part="${part}"]`)?.offsetWidth ??
0) + 2
// The bar's own frame: its border, and its right padding (the ruler's edges count
// its left, p-1 as the bar's).
const frame = el.offsetWidth - el.clientWidth + 4
const avail = room.clientWidth - frame
const all = end(`entry-${entries.length - 1}`)
if (entries.length === 0 || all + tail("clear") <= avail) {
setStanding(entries.length)
return
}
let n = 0
for (let i = 0; i < entries.length; i++)
if (end(`entry-${i}`) + tail("more") + tail("clear") <= avail) n = i + 1
setStanding(n)
}
fit()
const watch = new ResizeObserver(fit)
watch.observe(room)
watch.observe(r)
return () => watch.disconnect()
}, [entries.length])
const shown = trimmed(entries.slice(0, standing))
const rest = trimmed(entries.slice(standing))
const verbs = (list: readonly MenuEntry[], ruling: boolean) =>
list.map((entry, i) =>
entry === "separator" ? (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: entries are rebuilt per render, place is identity
key={i}
aria-hidden="true"
data-part={ruling ? `entry-${i}` : undefined}
className={BAR_RULE}
/>
) : (
<BarVerb
// biome-ignore lint/suspicious/noArrayIndexKey: entries are rebuilt per render, place is identity
key={i}
entry={entry}
part={ruling ? `entry-${i}` : undefined}
/>
),
)
const countLabel = (
<span
data-slot="selection-bar-count"
className="shrink-0 whitespace-nowrap px-2 text-[13px] font-medium tabular-nums"
>
{count} {noun}
{count === 1 ? "" : "s"} selected
{hidden > 0 && (
<span className="font-normal text-foreground/50">
{" "}
· {hidden} hidden by the filter
</span>
)}
</span>
)
return (
<div
ref={bar}
role="toolbar"
aria-label="selection"
data-slot="selection-bar"
onKeyDown={(event) => {
if (event.key !== "Escape") return
event.preventDefault()
onClear()
}}
className={cn(BAR, "relative flex-nowrap", className)}
>
{/* The ruler: every verb on one line, in a box of no size, never seen or reached. */}
<div aria-hidden="true" inert className="absolute size-0 overflow-hidden">
<div ref={ruler} className="flex w-max items-center gap-0.5 p-1">
{countLabel}
{entries.length > 0 && <span className={BAR_RULE} />}
{verbs(entries, true)}
<span data-part="more" className="flex items-center gap-0.5">
<span className={BAR_RULE} />
<span className="size-7" />
</span>
<span data-part="clear" className="flex items-center gap-0.5">
<span className={BAR_RULE} />
<span className="size-7" />
</span>
</div>
</div>
{countLabel}
{shown.length > 0 && <span aria-hidden="true" className={BAR_RULE} />}
{verbs(shown, false)}
{rest.length > 0 && (
<>
<span aria-hidden="true" className={BAR_RULE} />
<Tip content="more">
<Button
size="icon-sm"
variant="ghost"
aria-label="more"
aria-haspopup="menu"
onClick={(e) => setMore(more ? null : e.currentTarget)}
data-popup-open={more ? "" : undefined}
className="text-foreground/70 hover:text-foreground data-popup-open:bg-foreground/10"
>
<Ellipsis />
</Button>
</Tip>
<Menu entries={rest} anchor={more} onClose={() => setMore(null)} />
</>
)}
<span aria-hidden="true" className={BAR_RULE} />
<Tip content={<>clear the selection</>} keys="escape">
<Button
size="icon-sm"
variant="ghost"
aria-label="clear the selection"
onClick={onClear}
className="text-foreground/55 hover:text-foreground"
>
<X />
</Button>
</Tip>
</div>
)
}
/** A verb, its keyboard keys drawn after its name as faded caps, so the bar teaches
* them too without a glyph that reads as a second icon. */
export function BarVerb({
entry,
part,
}: {
entry: Exclude<MenuEntry, "separator">
/** the ruler's name for it (selection-bar's measure) */
part?: string
}) {
return (
<Button
size="sm"
variant="ghost"
disabled={entry.disabled}
onClick={entry.onSelect}
data-part={part}
className={cn(
"shrink-0 whitespace-nowrap px-2 text-[13px] font-normal [&_svg]:size-3.5",
entry.destructive
? "text-tone-bad hover:text-tone-bad"
: "text-foreground/80 hover:text-foreground",
)}
>
<span aria-hidden="true" className="grid size-4 place-items-center">
{entry.icon}
</span>
{entry.label}
{entry.hint && (
<Hints hint={entry.hint} keysOnly className="ml-1.5 opacity-60" />
)}
</Button>
)
}