Choose items, extend a selection, or select a rectangle of shapes or cells.
createSelection chooses item keys and extends ranges, createLasso selects items with a drawn rectangle, and createCellRange selects a block of grid cells.
Import them from @ui-sdk/core. Pair them with Focus and Keys to move between items and choose them.
Selecting Items
Choose One
Keep the selected keys in a string[], even when only one item can be chosen. In api, add createSelection alongside the existing roving focus behavior:
const change = createChange(context)
const selection = createSelection(context, {
key: "value",
set: (value, details) => change("value", value, details),
order: () => roving.items().map((item) => item.key),
})Start value at [] and return selection from api. The set callback uses the same change pattern as the guides.
The behavior gives you methods to call from your event handlers. For example, a pointer press can choose one item:
pointerdown: (event, { api, key }) => api.selection.choose(key, { reason: "item-press", event }),choose selects that item. toggle also lets the user clear it. Add this branch to your existing key handler for Space:
if (event.key === " ") {
event.preventDefault()
selection.toggle(key, { reason: "item-press", event })
}This plan picker uses both. selection.isSelected(key) lets its Enter handler check whether a plan is chosen before continuing.
import * as React from "react"
import {
createChange,
createRovingFocus,
createSelection,
definePrimitive,
selects,
type ChangeDetails,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
import { useLog } from "./log"
type PlansState = { value: string[]; focused: string | null }
type PlansOptions = {
onValueChange?: (
value: string[],
details: ChangeDetails<"item-press">
) => void
onContinue?: (plan: string) => void
}
const plans = definePrimitive({
name: "plans",
options: { onValueChange: "callback", onContinue: "callback" },
state: (_options: PlansOptions): PlansState => ({ value: [], focused: null }),
api: (context) => {
const change = createChange(context)
const roving = createRovingFocus<PlansState, { value: string }>(context, {
slot: "plan",
key: "focused",
orientation: () => "vertical",
})
const selection = createSelection(context, {
key: "value",
set: (value, details) => change("value", value, details),
order: () => roving.items().map((item) => item.key),
})
return {
roving,
selection,
keydown(event: KeyboardEvent, el: HTMLElement, key: string) {
if (event.key === " ") {
event.preventDefault()
selection.toggle(key, { reason: "item-press", event })
} else if (event.key === "Enter" && selection.isSelected(key)) {
context.options().onContinue?.(key)
} else {
roving.keydown(event, el)
}
},
}
},
slots: (slot) => ({
root: slot({ attrs: () => ({ role: "listbox" }) }),
plan: slot<{ value: string }>({
props: { value: "string" },
selects: selects({ focused: ({ key }) => [key, null] }),
attrs: (state, part) => ({
role: "option",
"aria-selected": String(state.value.includes(part.key)),
tabindex: part.api.roving.tabIndex(state, part),
}),
on: {
keydown: (event, { el, api, key }) => api.keydown(event, el!, key),
pointerdown: (event, { api, key }) =>
api.selection.choose(key, { reason: "item-press", event }),
},
bind: (el, part) => part.api.roving.bind(el, part),
}),
}),
})
const parts = createParts(plans, { root: "div", plan: "div" })
const Plans = parts.Root
const PlansPlan = parts.Plan
const options = [
{ name: "Free", price: "$0" },
{ name: "Pro", price: "$12 / month" },
{ name: "Team", price: "$30 / month" },
]
function Example() {
const log = useLog()
return (
<Plans
aria-label="Plan"
onValueChange={(value: string[]) =>
log(value.length ? `Chose ${value[0]}` : "Cleared the plan")
}
onContinue={(plan: string) => log(`Continuing with ${plan}`)}
>
{options.map(({ name, price }) => (
<PlansPlan key={name} value={name}>
{name}
<span data-slot="hint">{price}</span>
</PlansPlan>
))}
</Plans>
)
}
export { Example, plans, Plans, PlansPlan, type PlansOptions, type PlansState }For a listbox, give the root role="listbox", each item role="option", and set its aria-selected from state.value.includes(key). Keep the focus wiring from Focus and Keys.
Choose Many
Add multiple to the selection options:
multiple: () => true,Now toggle adds or removes one item while keeping the others. Use it for both pointer presses and Space, as in this topping picker. A listbox that allows this also needs aria-multiselectable="true" on its root.
import * as React from "react"
import {
createChange,
createRovingFocus,
createSelection,
definePrimitive,
selects,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
type ToppingsState = { value: string[]; focused: string | null }
const toppings = definePrimitive({
name: "toppings",
state: (): ToppingsState => ({ value: ["Cheese"], focused: null }),
api: (context) => {
const change = createChange(context)
const roving = createRovingFocus<ToppingsState, { value: string }>(
context,
{
slot: "topping",
key: "focused",
orientation: () => "horizontal",
}
)
const selection = createSelection(context, {
key: "value",
set: (value, details) => change("value", value, details),
order: () => roving.items().map((item) => item.key),
multiple: () => true,
})
return {
roving,
selection,
keydown(event: KeyboardEvent, el: HTMLElement, key: string) {
if (event.key !== " ") {
return roving.keydown(event, el)
}
event.preventDefault()
selection.toggle(key, { reason: "item-press", event })
},
}
},
slots: (slot) => ({
root: slot({
attrs: () => ({ role: "listbox", "aria-multiselectable": "true" }),
}),
topping: slot<{ value: string }>({
props: { value: "string" },
selects: selects({ focused: ({ key }) => [key, null] }),
attrs: (state, part) => ({
role: "option",
"aria-selected": String(state.value.includes(part.key)),
tabindex: part.api.roving.tabIndex(state, part),
}),
on: {
keydown: (event, { el, api, key }) => api.keydown(event, el!, key),
pointerdown: (event, { api, key }) =>
api.selection.toggle(key, { reason: "item-press", event }),
},
bind: (el, part) => part.api.roving.bind(el, part),
}),
}),
})
const parts = createParts(toppings, { root: "div", topping: "div" })
const Toppings = parts.Root
const ToppingsTopping = parts.Topping
const names = ["Cheese", "Basil", "Olives", "Mushrooms", "Peppers"]
function Example() {
return (
<Toppings aria-label="Toppings">
{names.map((name) => (
<ToppingsTopping key={name} value={name}>
{name}
</ToppingsTopping>
))}
</Toppings>
)
}
export { Example, toppings, Toppings, ToppingsTopping, type ToppingsState }Ranges and Select All
extend selects from an anchor to another item. The anchor is the last item chosen or toggled without Shift. These three calls give a file list its usual pointer interactions:
press(event: PointerEvent, key: string) {
const details = { reason: "item-press" as const, event }
if (event.shiftKey) {
selection.extend(key, { ...details, from: context.store.get().focused })
} else if (event.metaKey || event.ctrlKey) {
selection.toggle(key, details)
} else {
selection.choose(key, details)
}
},For Shift with the arrows, use the result of the existing focus handler:
const moved = roving.keydown(event, el)
if (moved?.shift) {
selection.extend(moved.key, {
from: moved.from,
reason: "list-navigation",
event,
})
}from supplies the starting point when there isn't an anchor yet. selection.anchor() returns the current anchor's key.
A range keeps what was selected when the anchor was set, then adds the items between the anchor and the target. Pass additive: false to select only the range. Supplying a different selection from outside starts the next range over.
import * as React from "react"
import {
createChange,
createRovingFocus,
createSelection,
definePrimitive,
selects,
type ChangeDetails,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
import { useLog } from "./log"
type FilesState = { value: string[]; focused: string | null }
type FilesOptions = {
replaceRanges?: boolean
onValueChange?: (
value: string[],
details: ChangeDetails<"item-press" | "list-navigation">
) => void
}
const files = definePrimitive({
name: "files",
options: { replaceRanges: "boolean", onValueChange: "callback" },
state: (_options: FilesOptions): FilesState => ({ value: [], focused: null }),
api: (context) => {
const { store } = context
const change = createChange(context)
const additive = () => !context.options().replaceRanges
const roving = createRovingFocus<FilesState, { value: string }>(context, {
slot: "file",
key: "focused",
})
const selection = createSelection(context, {
key: "value",
set: (value, details) => change("value", value, details),
order: () => roving.items().map((item) => item.key),
multiple: () => true,
})
return {
roving,
keydown(event: KeyboardEvent, el: HTMLElement, key: string) {
if ((event.metaKey || event.ctrlKey) && event.key === "a") {
event.preventDefault()
selection.toggleAll({ reason: "list-navigation", event })
} else if (event.key === " ") {
event.preventDefault()
selection.toggle(key, { reason: "item-press", event })
} else {
const moved = roving.keydown(event, el)
if (moved?.shift) {
selection.extend(moved.key, {
from: moved.from,
additive: additive(),
reason: "list-navigation",
event,
})
}
}
},
press(event: PointerEvent, key: string) {
const why = { reason: "item-press" as const, event }
if (event.shiftKey) {
selection.extend(key, {
...why,
from: store.get().focused,
additive: additive(),
})
} else if (event.metaKey || event.ctrlKey) {
selection.toggle(key, why)
} else {
selection.choose(key, why)
}
},
}
},
slots: (slot) => ({
root: slot({
attrs: () => ({ role: "listbox", "aria-multiselectable": "true" }),
}),
file: slot<{ value: string }, { selected: boolean }>({
props: { value: "string" },
selects: selects({ focused: ({ key }) => [key, null] }),
state: (state, { key }) => ({ selected: state.value.includes(key) }),
attrs: (state, part) => ({
role: "option",
"aria-selected": String(state.value.includes(part.key)),
tabindex: part.api.roving.tabIndex(state, part),
"data-selected": state.value.includes(part.key) ? "" : undefined,
}),
on: {
keydown: (event, { el, api, key }) => api.keydown(event, el!, key),
pointerdown: (event, { api, key }) => api.press(event, key),
},
bind: (el, part) => part.api.roving.bind(el, part),
}),
}),
})
const parts = createParts(files, { root: "div", file: "div" })
const Files = parts.Root
const FilesItem = parts.File
const names = [
"notes.md",
"budget.xlsx",
"photo.jpg",
"slides.key",
"draft.docx",
"logo.svg",
]
function Example() {
const log = useLog()
const [replace, setReplace] = React.useState(false)
return (
<div data-slot="stack">
<div data-slot="controls">
<label data-slot="label">
<input
type="checkbox"
checked={replace}
onChange={(event) => setReplace(event.target.checked)}
/>
Ranges replace the selection
</label>
</div>
<Files
aria-label="Files"
replaceRanges={replace}
onValueChange={(value: string[]) =>
log(`${value.length} selected: ${value.join(", ") || "none"}`)
}
>
{names.map((name) => (
<FilesItem key={name} value={name}>
{name}
</FilesItem>
))}
</Files>
</div>
)
}
export { Example, files, Files, FilesItem, type FilesOptions, type FilesState }In multiple mode, toggleAll selects every available key in order, or clears them if they're already selected. Call it from your Control or Command+A handler after preventing the browser's default action:
selection.toggleAll({ reason: "list-navigation", event })order determines what ranges and select-all can reach. For a virtualized list, pass the full list of keys, including items that aren't rendered.
Items That Can't Be Selected
Use selectable to skip keys that shouldn't be chosen, toggled, or added to a range:
selectable: (key) => !context.options().owners.includes(key),In this member list, owners start selected. The demo uses toggle and toggleAll, which leave those owners alone.
import * as React from "react"
import {
createChange,
createRovingFocus,
createSelection,
definePrimitive,
selects,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
type MembersState = { value: string[]; focused: string | null }
type MembersOptions = {
owners: string[]
}
const members = definePrimitive({
name: "members",
options: { owners: "list" },
state: (options: MembersOptions): MembersState => ({
value: [...options.owners],
focused: null,
}),
api: (context) => {
const change = createChange(context)
const roving = createRovingFocus<MembersState, { value: string }>(context, {
slot: "member",
key: "focused",
})
const selection = createSelection(context, {
key: "value",
set: (value, details) => change("value", value, details),
order: () => roving.items().map((item) => item.key),
multiple: () => true,
selectable: (key) => !context.options().owners.includes(key),
})
return {
roving,
selection,
keydown(event: KeyboardEvent, el: HTMLElement, key: string) {
if ((event.metaKey || event.ctrlKey) && event.key === "a") {
event.preventDefault()
selection.toggleAll({ reason: "list-navigation", event })
} else if (event.key === " ") {
event.preventDefault()
selection.toggle(key, { reason: "item-press", event })
} else {
roving.keydown(event, el)
}
},
}
},
slots: (slot) => ({
root: slot({
attrs: () => ({ role: "listbox", "aria-multiselectable": "true" }),
}),
member: slot<{ value: string }>({
props: { value: "string" },
selects: selects({ focused: ({ key }) => [key, null] }),
attrs: (state, part) => ({
role: "option",
"aria-selected": String(state.value.includes(part.key)),
tabindex: part.api.roving.tabIndex(state, part),
}),
on: {
keydown: (event, { el, api, key }) => api.keydown(event, el!, key),
pointerdown: (event, { api, key }) =>
api.selection.toggle(key, { reason: "item-press", event }),
},
bind: (el, part) => part.api.roving.bind(el, part),
}),
}),
})
const parts = createParts(members, { root: "div", member: "div" })
const Members = parts.Root
const MembersMember = parts.Member
const names = [
"Hollis Grant",
"Inรจs Moreau",
"Kofi Mensah",
"Wren Takahashi",
"Tomasz Wรณjcik",
]
const owners = ["Hollis Grant"]
function Example() {
return (
<Members owners={owners} aria-label="Members">
{names.map((name) => (
<MembersMember
key={name}
value={name}
aria-disabled={owners.includes(name) || undefined}
>
{name}
{owners.includes(name) && <span data-slot="hint">Owner</span>}
</MembersMember>
))}
</Members>
)
}
export {
Example,
members,
Members,
MembersMember,
type MembersOptions,
type MembersState,
}This doesn't pin an item in the selection: choose and a range with additive: false can replace the whole value. If some keys must always stay selected, enforce that in your set callback.
Rectangle Selection
Draw a Rectangle
createLasso selects items touched by a rectangle. Start with selected: string[] and lasso: LassoDraft | null in state, with lasso set to null. Add lasso to the primitive's live fields, as covered in Live State.
Create the behavior in api and return it as lasso:
const lasso = createLasso(context, {
slot: "shape",
draft: "lasso",
selected: () => context.store.get().selected,
set: (keys, details) => change("selected", keys, details),
})Give the surface position: relative, touch-action: none and user-select: none so drawing doesn't scroll the page or select text. Start when the pointer lands on its empty space:
pointerdown: (event, { el, api }) => {
if (el && event.target === el) {
api.lasso.pointerdown(event, el)
}
},Keep the rectangle part rendered. Its style hides it when there's no drag:
lasso: slot({
attrs: () => ({ "aria-hidden": "true" }),
live: live((state, { api }) => ({ style: api.lasso.style(state) })),
}),import * as React from "react"
import {
createChange,
createLasso,
definePrimitive,
live,
type ChangeDetails,
type LassoDraft,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
import { useLog } from "./log"
type ShapesState = { selected: string[]; lasso: LassoDraft | null }
type ShapesOptions = {
onSelectedChange?: (
selected: string[],
details: ChangeDetails<"lasso">
) => void
}
const shapes = definePrimitive({
name: "shapes",
options: { onSelectedChange: "callback" },
state: (_options: ShapesOptions): ShapesState => ({
selected: [],
lasso: null,
}),
live: ["lasso"],
api: (context) => {
const change = createChange(context)
return {
lasso: createLasso<ShapesState>(context, {
slot: "shape",
draft: "lasso",
selected: () => context.store.get().selected,
set: (keys, details) => change("selected", keys, details),
}),
}
},
slots: (slot) => ({
root: slot({
attrs: () => ({
style: "position:relative;touch-action:none;user-select:none",
}),
on: {
pointerdown: (event, { el, api }) =>
event.target === el && api.lasso.pointerdown(event, el!),
},
}),
shape: slot<{ value: string }>({
props: { value: "string" },
attrs: (state, { key }) => ({
"data-selected": state.selected.includes(key) ? "" : undefined,
}),
live: live<ShapesState, any, { value: string }>(
(state, { key, api }) => ({
"data-lassoed": api.lasso.touches(state, key) ? "" : undefined,
})
),
}),
lasso: slot({
attrs: () => ({ "aria-hidden": "true" }),
live: live<ShapesState, any, {}>((state, { api }) => ({
style: api.lasso.style(state),
})),
}),
}),
})
const parts = createParts(shapes, { root: "div", shape: "div", lasso: "div" })
const Shapes = parts.Root
const ShapesItem = parts.Shape
const ShapesLasso = parts.Lasso
const items = [
{ key: "Circle", x: 24, y: 24, round: true },
{ key: "Square", x: 134, y: 36, round: false },
{ key: "Dot", x: 70, y: 130, round: true },
{ key: "Tile", x: 206, y: 116, round: false },
]
function Example() {
const log = useLog()
return (
<Shapes
aria-label="Shapes"
onSelectedChange={(selected: string[]) =>
log(`Selected: ${selected.join(", ") || "none"}`)
}
>
{items.map((item) => (
<ShapesItem
key={item.key}
value={item.key}
data-shape={item.round ? "round" : undefined}
style={{ left: item.x, top: item.y }}
/>
))}
<ShapesLasso />
</Shapes>
)
}
export {
Example,
shapes,
Shapes,
ShapesItem,
ShapesLasso,
type ShapesOptions,
type ShapesState,
}lasso.touches(state, key) tells you which items belong to the preview selection. With Shift, that includes items selected before the drag. Use it for a data-lassoed highlight.
The selection changes once, when the pointer is released, with the reason "lasso". Escape cancels the drag and keeps the previous selection.
Items It Skips
Add selectable to exclude items from the rectangle's hits, and message to customize the screen reader announcement:
selectable: (key) => !context.options().locked.includes(key),
message: (count) => `${count} ${count === 1 ? "photo" : "photos"} selected`,The announcement runs after the selection request is accepted.
import * as React from "react"
import {
createChange,
createLasso,
definePrimitive,
live,
type LassoDraft,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
type PhotosState = { selected: string[]; lasso: LassoDraft | null }
type PhotosOptions = {
locked: string[]
}
const photos = definePrimitive({
name: "photos",
options: { locked: "list" },
state: (_options: PhotosOptions): PhotosState => ({
selected: [],
lasso: null,
}),
live: ["lasso"],
api: (context) => {
const change = createChange(context)
return {
lasso: createLasso<PhotosState>(context, {
slot: "photo",
draft: "lasso",
selected: () => context.store.get().selected,
set: (keys, details) => change("selected", keys, details),
selectable: (key) => !context.options().locked.includes(key),
message: (count) =>
`${count} ${count === 1 ? "photo" : "photos"} selected`,
}),
}
},
slots: (slot) => ({
root: slot({
attrs: () => ({
style: "position:relative;touch-action:none;user-select:none",
}),
on: {
pointerdown: (event, { el, api }) =>
event.target === el && api.lasso.pointerdown(event, el!),
},
}),
photo: slot<{ value: string }>({
props: { value: "string" },
attrs: (state, { key }) => ({
"data-selected": state.selected.includes(key) ? "" : undefined,
}),
live: live<PhotosState, any, { value: string }>(
(state, { key, api }) => ({
"data-lassoed": api.lasso.touches(state, key) ? "" : undefined,
})
),
}),
lasso: slot({
attrs: () => ({ "aria-hidden": "true" }),
live: live<PhotosState, any, {}>((state, { api }) => ({
style: api.lasso.style(state),
})),
}),
}),
})
const parts = createParts(photos, { root: "div", photo: "div", lasso: "div" })
const Photos = parts.Root
const PhotosPhoto = parts.Photo
const PhotosLasso = parts.Lasso
const names = ["Beach", "Forest", "City", "Desert", "Lake", "Snow"]
const locked = ["City", "Lake"]
function Example() {
return (
<Photos locked={locked} aria-label="Photos">
{names.map((name) => (
<PhotosPhoto key={name} value={name}>
{name}
{locked.includes(name) && <span data-slot="badge">Locked</span>}
</PhotosPhoto>
))}
<PhotosLasso />
</Photos>
)
}
export {
Example,
photos,
Photos,
PhotosLasso,
PhotosPhoto,
type PhotosOptions,
type PhotosState,
}Ranges of Cells
Select a Block
Focus and Keys shows how to navigate a grid. Add createCellRange when you also need to drag across cells, select whole rows or columns, or copy a block.
Keep a range: CellRange | null field in state, starting at null. Pass the existing grid behavior and its row and column counts:
const range = createCellRange(context, {
key: "range",
grid,
rows: () => 4,
cols: () => 5,
})Return range from api, then update the existing cell slot:
selects: selects({ active: byCell, range: byCellRange }),
attrs: (state, { props, api }) => ({
role: "gridcell",
tabindex: api.grid.tabIndex(state, props),
...api.range.cellAttrs(state, props),
}),
on: {
keydown: (event, { api }) => api.range.keydown(event),
pointerdown: (event, { api, props }) => api.range.pointerdown(event, props),
},
bind: (el, { api, props }) => api.range.bind(el, props),range.bind also binds grid focus, and range.keydown forwards ordinary navigation to the grid. You don't need to call both behaviors for the same event. byCellRange lets a range change reach only the cells that join or leave it and the cells along the sides that move.
import * as React from "react"
import {
byCell,
byCellRange,
createCellRange,
createGridFocus,
definePrimitive,
selects,
type CellRange,
type GridCell,
} from "@ui-sdk/core"
import { createParts, usePart, type PartProps } from "@ui-sdk/react/primitive"
type SheetState = { active: GridCell | null; range: CellRange | null }
type SheetOptions = { rows: number; cols: number }
type SheetSummaryState = {
top: number
left: number
bottom: number
right: number
}
const sheet = definePrimitive({
name: "sheet",
options: { rows: "number", cols: "number" },
state: (_options: SheetOptions): SheetState => ({
active: null,
range: null,
}),
api: (context) => {
const rows = () => context.options().rows
const cols = () => context.options().cols
const grid = createGridFocus<SheetState>(context, {
key: "active",
rows,
cols,
})
return {
grid,
range: createCellRange<SheetState>(context, {
key: "range",
grid,
rows,
cols,
}),
}
},
slots: (slot) => ({
root: slot({
attrs: () => ({
role: "grid",
style: "user-select:none;touch-action:none",
}),
}),
row: slot<{ id: string }>({
props: { id: "string" },
attrs: () => ({ role: "row" }),
}),
summary: slot<{}, SheetSummaryState>({
state: (state, { api }) => {
const bounds = state.active && api.range.bounds(state)
return bounds ?? { top: -1, left: -1, bottom: -1, right: -1 }
},
attrs: () => ({ "aria-live": "polite" }),
}),
cell: slot<{ id: string; row: number; col: number }>({
props: { row: "number", col: "number" },
selects: selects({ active: byCell, range: byCellRange }),
attrs: (state, { props, api }) => ({
role: "gridcell",
tabindex: api.grid.tabIndex(state, props),
...api.range.cellAttrs(state, props),
}),
on: {
keydown: (event, { api }) => api.range.keydown(event),
pointerdown: (event, { api, props }) =>
api.range.pointerdown(event, props),
},
bind: (el, { api, props }) => api.range.bind(el, props),
}),
}),
})
const parts = createParts(sheet, { root: "div", row: "div", summary: "p" })
const Sheet = parts.Root
const SheetRow = parts.Row
function SheetCell({
row,
col,
...props
}: { row: number; col: number } & PartProps<"div">) {
return usePart(sheet, "cell", { id: `${row}:${col}`, row, col }, props, "div")
}
const SheetSummary = parts.Summary
const values = [
[4, 8, 15, 16],
[23, 42, 7, 1],
[9, 3, 12, 5],
]
function Example() {
return (
<Sheet rows={3} cols={4} aria-label="Numbers">
{values.map((row, r) => (
<SheetRow key={r} id={String(r)}>
{row.map((value, c) => (
<SheetCell key={c} row={r} col={c}>
{value}
</SheetCell>
))}
</SheetRow>
))}
<SheetSummary>
{({ top, left, bottom, right }) => {
let total = 0
for (let row = Math.max(top, 0); row <= bottom; row++) {
for (let col = Math.max(left, 0); col <= right; col++) {
total += values[row][col]
}
}
return `Total: ${total}`
}}
</SheetSummary>
</Sheet>
)
}
export {
Example,
sheet,
Sheet,
SheetCell,
SheetRow,
SheetSummary,
type SheetOptions,
type SheetState,
type SheetSummaryState,
}Focus stays on the active cell as the range grows. A move without Shift clears the range. The behavior also handles these shortcuts:
| Keys | Action |
|---|---|
| Shift+Space | Select the range's rows. |
| Control+Space | Select its columns. |
| Control+A | Select every cell. |
| Escape | Clear the range. |
cellAttrs marks the explicit range with aria-selected, data-selected, and data-range-edge. The last one lists the sides of the range a cell sits on, such as "top left", so you can draw an outline with [data-range-edge~="top"].
Selecting from Code
The same range can be changed from buttons or other controls. Row and column indexes start at zero:
range.selectColumns(1)
range.selectRows(0, 1)
range.select({ anchor: { row: 1, col: 1 }, end: { row: 2, col: 2 } })
range.selectAll()
range.select(null)These calls change the range without moving focus. select(null) clears it.
import * as React from "react"
import {
byCell,
byCellRange,
createCellRange,
createChange,
createGridFocus,
definePrimitive,
selects,
type CellRange,
type GridCell,
} from "@ui-sdk/core"
import { createParts, usePart, type PartProps } from "@ui-sdk/react/primitive"
type LedgerState = { active: GridCell | null; range: CellRange | null }
const values = [
[4, 8, 15, 16],
[23, 42, 7, 1],
[9, 3, 12, 5],
]
const rows = values.length
const cols = values[0].length
const letters = "ABCD"
const nameOf = (row: number, col: number) => `${letters[col]}${row + 1}`
const shortcuts: Record<string, string> = {
"column-b": "Column B",
"rows-1-2": "Rows 1 and 2",
block: "B2 to C3",
all: "All",
}
const ledgerGrid = definePrimitive({
name: "ledger-grid",
state: (): LedgerState => ({ active: null, range: null }),
api: (context) => {
const change = createChange(context)
const grid = createGridFocus<LedgerState>(context, {
key: "active",
rows: () => rows,
cols: () => cols,
})
const range = createCellRange<LedgerState>(context, {
key: "range",
grid,
rows: () => rows,
cols: () => cols,
set: (value, details) => change("range", value, details),
message: ({ top, left, bottom, right }) =>
`${nameOf(top, left)} to ${nameOf(bottom, right)} selected`,
})
return {
grid,
range,
pick(shortcut: string) {
if (shortcut === "column-b") {
range.selectColumns(1)
} else if (shortcut === "rows-1-2") {
range.selectRows(0, 1)
} else if (shortcut === "block") {
range.select({ anchor: { row: 1, col: 1 }, end: { row: 2, col: 2 } })
} else {
range.selectAll()
}
},
}
},
slots: (slot) => ({
grid: slot({
attrs: () => ({
role: "grid",
style: "user-select:none;touch-action:none",
}),
}),
row: slot<{ id: string }>({
props: { id: "string" },
attrs: () => ({ role: "row" }),
}),
cell: slot<{ id: string; row: number; col: number }>({
props: { row: "number", col: "number" },
selects: selects({ active: byCell, range: byCellRange }),
attrs: (state, { props, api }) => ({
role: "gridcell",
tabindex: api.grid.tabIndex(state, props),
...api.range.cellAttrs(state, props),
}),
on: {
keydown: (event, { api }) => api.range.keydown(event),
pointerdown: (event, { api, props }) =>
api.range.pointerdown(event, props),
},
bind: (el, { api, props }) => api.range.bind(el, props),
}),
shortcut: slot<{ value: string }>({
props: { value: "string" },
attrs: () => ({ type: "button" }),
press: ({ api, key }) => api.pick(key),
}),
summary: slot<{}, { text: string }>({
state: (state, { api }) => {
const bounds = api.range.bounds(state)
if (!bounds) {
return { text: "Nothing selected" }
}
let big = 0
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
if (
api.range.contains(state, { row, col }) &&
values[row][col] > 10
) {
big++
}
}
}
const name = `${nameOf(bounds.top, bounds.left)}:${nameOf(bounds.bottom, bounds.right)}`
return { text: `${name}, ${big} over 10` }
},
}),
}),
})
const parts = createParts(ledgerGrid, {
grid: "div",
row: "div",
shortcut: "button",
summary: "p",
})
const LedgerGrid = parts.Root
const LedgerGridGrid = parts.Grid
const LedgerGridRow = parts.Row
function LedgerGridCell({
row,
col,
...props
}: { row: number; col: number } & PartProps<"div">) {
return usePart(
ledgerGrid,
"cell",
{ id: `${row}:${col}`, row, col },
props,
"div"
)
}
const LedgerGridShortcut = parts.Shortcut
const LedgerGridSummary = parts.Summary
function Example() {
return (
<LedgerGrid>
<div data-slot="stack">
<div data-slot="controls">
{Object.entries(shortcuts).map(([value, label]) => (
<LedgerGridShortcut data-size="sm" key={value} value={value}>
{label}
</LedgerGridShortcut>
))}
</div>
<LedgerGridGrid aria-label="Ledger">
{values.map((row, r) => (
<LedgerGridRow key={r} id={String(r)}>
{row.map((value, c) => (
<LedgerGridCell key={c} row={r} col={c}>
{value}
</LedgerGridCell>
))}
</LedgerGridRow>
))}
</LedgerGridGrid>
<LedgerGridSummary data-summary>{({ text }) => text}</LedgerGridSummary>
</div>
</LedgerGrid>
)
}
export {
Example,
ledgerGrid,
LedgerGrid,
LedgerGridCell,
LedgerGridGrid,
LedgerGridRow,
LedgerGridShortcut,
LedgerGridSummary,
type LedgerState,
}range.bounds(state) gives the inclusive top, left, bottom and right indexes. With no explicit range, it falls back to the active cell, or returns null if there isn't one. range.contains(state, cell) uses the same rule when you need to total or count cells.
By default, range changes write to state directly. To report them through createChange, add set. You can also customize what screen readers hear after keyboard selection:
set: (value, details) => change("range", value, details),
message: ({ top, left, bottom, right }) =>
`Rows ${top + 1} to ${bottom + 1}, columns ${left + 1} to ${right + 1} selected`,For rows that aren't rendered yet, reveal works as it does in grid focus.
Copy and Paste
range.text(state, read) reads the selected cells as tab-separated text. Call it from the grid's copy handler:
copy(event: ClipboardEvent) {
const state = context.store.get()
const text = range.text(state, ({ row, col }) => state.cells[row][col])
event.preventDefault()
event.clipboardData?.setData("text/plain", text)
},In the paste handler, textToCells turns clipboard text into rows:
const rows = textToCells(event.clipboardData?.getData("text/plain") ?? "")Your handler prevents the default paste and writes those values into state, starting at the selection's top-left cell. Stop at the grid's edges. The demo's Code tab shows the full handler.
import * as React from "react"
import {
byCell,
byCellRange,
cellsToText,
createCellRange,
createGridFocus,
definePrimitive,
selects,
textToCells,
type CellRange,
type GridCell,
} from "@ui-sdk/core"
import { createParts, usePart, type PartProps } from "@ui-sdk/react/primitive"
import { useLog } from "./log"
type PricesOptions = { onCopy?: (text: string) => void }
type PricesState = {
active: GridCell | null
range: CellRange | null
cells: string[][]
}
const start = [
["Tea", "3", "12"],
["Coffee", "4", "9"],
["Cake", "5", "4"],
["", "", ""],
]
const rows = start.length
const cols = start[0].length
const prices = definePrimitive({
name: "prices",
options: { onCopy: "callback" },
state: (_options: PricesOptions): PricesState => ({
active: null,
range: null,
cells: start,
}),
api: (context) => {
const { store } = context
const grid = createGridFocus<PricesState>(context, {
key: "active",
rows: () => rows,
cols: () => cols,
})
const range = createCellRange<PricesState>(context, {
key: "range",
grid,
rows: () => rows,
cols: () => cols,
})
return {
grid,
range,
copy(event: ClipboardEvent) {
const state = store.get()
const text = range.text(state, ({ row, col }) => state.cells[row][col])
event.preventDefault()
event.clipboardData?.setData("text/plain", text)
context.options().onCopy?.(text)
},
paste(event: ClipboardEvent) {
const state = store.get()
const bounds = range.bounds(state)
if (!bounds) {
return
}
event.preventDefault()
const pasted = textToCells(
event.clipboardData?.getData("text/plain") ?? ""
)
const cells = state.cells.map((row) => [...row])
pasted.forEach((values, r) =>
values.forEach((value, c) => {
const row = bounds.top + r
const col = bounds.left + c
if (row < rows && col < cols) {
cells[row][col] = value
}
})
)
store.set({ cells })
},
copyAll() {
const text = cellsToText(store.get().cells)
context.options().onCopy?.(text)
navigator.clipboard?.writeText(text).catch(() => {})
},
}
},
slots: (slot) => ({
root: slot({}),
grid: slot({
attrs: () => ({
role: "grid",
style: "user-select:none;touch-action:none",
}),
on: {
copy: (event, { api }) => api.copy(event as ClipboardEvent),
paste: (event, { api }) => api.paste(event as ClipboardEvent),
},
}),
row: slot<{ id: string }>({
props: { id: "string" },
attrs: () => ({ role: "row" }),
}),
cell: slot<{ id: string; row: number; col: number }, { text: string }>({
props: { row: "number", col: "number" },
selects: selects({ active: byCell, range: byCellRange }),
state: (state, { props }) => ({
text: state.cells[props.row][props.col],
}),
attrs: (state, { props, api }) => ({
role: "gridcell",
tabindex: api.grid.tabIndex(state, props),
...api.range.cellAttrs(state, props),
}),
on: {
keydown: (event, { api }) => api.range.keydown(event),
pointerdown: (event, { api, props }) =>
api.range.pointerdown(event, props),
},
bind: (el, { api, props }) => api.range.bind(el, props),
}),
copyAll: slot({
attrs: () => ({ type: "button" }),
press: ({ api }) => api.copyAll(),
}),
}),
})
const parts = createParts(prices, {
root: "div",
grid: "div",
row: "div",
copyAll: "button",
})
const Prices = parts.Root
const PricesGrid = parts.Grid
const PricesRow = parts.Row
function PricesCell({
row,
col,
...props
}: { row: number; col: number } & PartProps<"div", { text: string }>) {
return usePart(
prices,
"cell",
{ id: `${row}:${col}`, row, col },
props,
"div"
)
}
const PricesCopyAll = parts.CopyAll
function Example() {
const log = useLog()
return (
<Prices onCopy={(text: string) => log(`Copied ${JSON.stringify(text)}`)}>
<div data-slot="stack">
<div data-slot="controls">
<PricesCopyAll data-size="sm">Copy Whole Sheet</PricesCopyAll>
</div>
<PricesGrid aria-label="Prices">
{start.map((cells, r) => (
<PricesRow key={r} id={String(r)}>
{cells.map((_, c) => (
<PricesCell key={c} row={r} col={c}>
{({ text }) => text}
</PricesCell>
))}
</PricesRow>
))}
</PricesGrid>
</div>
</Prices>
)
}
export {
Example,
prices,
Prices,
PricesCell,
PricesCopyAll,
PricesGrid,
PricesRow,
type PricesOptions,
type PricesState,
}cellsToText(rows) works in the other direction. Use it to copy an entire sheet, even when only part of it is selected.
API Reference
Item Selection Options
set(value: string[], details: Why<Reason>) => boolean- Asks for a new selection and returns whether it went through. Pass the primitive's change, so
on<Key>Change,cancel()and controlled values work:set: (value, details) => change("value", value, details). - Example: Choose One
order() => readonly string[]- Every key in list order, for ranges and select all, like the visible items' keys.
- Example: Choose One
multiple?(() => boolean)- Whether more than one item can be selected. Defaults to
false. - Example: Choose Many
selectable?((key: string) => boolean)- Whether a key can be chosen, toggled, or added to a range. Replacing the selection can still remove it. Defaults to every item.
- Example: Items That Can't Be Selected
Rectangle Selection Options
draftkeyof State & string- The state key that holds the
LassoDraftwhile one is drawn. Start it atnull, and list it inlive. - Example: Draw a Rectangle
set(keys: string[], details: { reason: "lasso"; event?: Event; }) => boolean- Asks for a new selection. Pass the primitive's change.
- Example: Draw a Rectangle
selectable?((key: string) => boolean)- Whether an item can be selected. Defaults to every item.
- Example: Items It Skips
message?((count: number) => string)- What screen readers hear when the rectangle lifts. Defaults to English.
- Example: Items It Skips
Cell Range Options
keykeyof State & string- The state key that holds the
CellRange, ornullwhen only the active cell is selected. - Example: Select a Block
reveal?((cell: GridCell) => void)- Shows a cell the range grows to that may not be rendered, like
createGridFocus'sreveal. - Example: Rows That Aren't Rendered
set?((range: CellRange | null, details: { reason: CellRangeReason; event?: Event; }) => boolean)- Asks for a new range and returns whether it went through. Defaults to writing the state; pass the primitive's change to report it.
- Example: Selecting from Code
message?((bounds: CellBounds) => string)- What screen readers hear after the range changes from the keyboard. Defaults to English.
- Example: Selecting from Code