A list of things the user selects, acts on and opens, such as orders, messages, files or tasks. Its cells line up like a Table, but each row’s content is your own markup: a two-line cell, an avatar, chips, actions that appear on hover.
GridList declares one column template, and every row is a CSS subgrid of it,
so the cells line up across rows and across groups. You render each row
through a row render prop (a snippet in Svelte), and the list handles the
row itself: selection, keyboard focus, the stretched link that opens it,
groups and drag and drop.
GridList ships for Svelte, React, Vue, Solid and Astro (
@pindoba/{svelte,react,vue,solid,astro}-grid-list). Every framework runs the same logic from@pindoba/core-grid-list, and drag and drop lives in@pindoba/core-drag. See Framework notes for how each one takes its rows. The Rows lab shows it in full scenarios: a store’s orders, an inbox, a drag-and-drop board, a gallery, a photo library and a settings list.
idSelection.columns is the wide template: an id and a CSS track per column, plus an
optional header label (header draws the sticky header row). The row
render prop receives the item and a context. Spread cell(id) on each cell to
place it in its column, and spread primaryProps on the row’s main link. That
link stretches over the whole row, so a click anywhere on the row opens it,
while the controls inside the row stay real buttons.
---
// Hand-written: `packages/demos/grid-list/default.demo.tsx` skips Astro (see
// its `skip`) — Astro GridList takes its rows through one default-slot
// callback, not a `row` render prop.
import GridList, { type GridListRender } from "@pindoba/astro-grid-list";
import { css } from "@pindoba/styled-system/css";
interface Doc {
id: string;
name: string;
kind: string;
owner: string;
modified: string;
size: string;
}
const docs: Doc[] = [
{
id: "d1",
name: "Quarterly report",
kind: "PDF document",
owner: "Inês Duarte",
modified: "Today",
size: "2.4 MB",
},
{
id: "d2",
name: "Brand guidelines",
kind: "Design file",
owner: "Kofi Mensah",
modified: "Yesterday",
size: "18 MB",
},
{
id: "d3",
name: "Hiring plan",
kind: "Spreadsheet",
owner: "Yuna Choi",
modified: "Oct 24",
size: "320 KB",
},
{
id: "d4",
name: "Launch checklist",
kind: "Document",
owner: "Inês Duarte",
modified: "Oct 19",
size: "48 KB",
},
];
const nameCls = css({ display: "grid", minWidth: "0", lineHeight: "1.25" });
const kindCls = css({
color: "neutral.text.muted",
textStyle: "caption",
truncate: true,
});
const mutedCls = css({ color: "neutral.text.muted" });
const sizeCls = css({
justifyContent: "flex-end",
color: "neutral.text.muted",
fontVariantNumeric: "tabular-nums",
whiteSpace: "nowrap",
});
---
<GridList
label="Documents"
items={docs}
getId={(doc: Doc) => doc.id}
header
columns={[
{ id: "name", track: "minmax(0, 1fr)", header: "Name" },
{ id: "owner", track: "auto", header: "Owner" },
{ id: "modified", track: "auto", header: "Modified" },
{ id: "size", track: "auto", header: "Size" },
]}
narrow={{
columns: "minmax(0, 1fr) auto",
cells: {
name: { column: "1", row: "1" },
size: { column: "2", row: "1" },
owner: { column: "1", row: "2" },
modified: { column: "2", row: "2" },
},
}}
>
{(ctx: GridListRender<Doc>) =>
ctx.region === "row" ? (
<Fragment>
<span {...ctx.cell("name")}>
<span class={nameCls}>
<a {...ctx.primaryProps} href={`#${ctx.item.id}`}>
{ctx.item.name}
</a>
<span class={kindCls}>{ctx.item.kind}</span>
</span>
</span>
<span {...ctx.cell("owner", { class: mutedCls })}>
{ctx.item.owner}
</span>
<span {...ctx.cell("modified", { class: mutedCls })}>
{ctx.item.modified}
</span>
<span {...ctx.cell("size", { class: sizeCls })}>{ctx.item.size}</span>
</Fragment>
) : null
}
</GridList>Pass extra classes as cell(id, { class }). A class= written after the
spread replaces the cell’s own class and loses its placement. cell takes a
few more options:
span: "date" stretches the cell from its own column through date.line: 2 puts the cell on a second line of the row, for example a row of
chips.reveal: "rest" | "hover" stacks two cells in one area. The rest cell
shows normally, and the hover cell replaces it on hover or focus. Both
cells keep their size through visibility, so the row never re-flows as
the pointer crosses it. Touch screens have no hover, so a hover cell
never shows there: keep it for extra information, and put a row’s actions
(archive, delete) in actions, which has a touch path.narrow is a second template the list switches to when it is narrower
than 640px. The fold is a container query on the list itself, so the same
rows fit a split reading pane as well as a phone. narrow.cells places each
cell by column id, as { column, row } lines in the folded grid or
{ hidden: true }. A cell can take its own placement with narrowAs. Narrow
the window in the demo above: each document folds into a two-line row, and
the header hides. Leave narrow out and the list never folds.
Placement goes through CSS variables in each cell’s bag. The recipe has one rule per mode and never names your cells, so any set of columns folds.
selectable gives each row a checkbox and makes the grid
aria-multiselectable. The selection is value, a Set<string> of the
selected ids: bind it (or pass value + onValueChange) to read or set it,
or seed an uncontrolled list with defaultValue. The Shift-range anchor is
the list’s own. The checkbox shows on hover, and on every row once
anything is selected. In the plain and cards looks, adjacent selected
rows join into one highlighted block. strong makes a row read bolder (unread, unpaid), and
currentId marks the row whose detail is open (aria-current).
The rows are one tab stop, and focus moves between them with a roving tabindex. On a focused row:
| Key | Action |
|---|---|
↑ / ↓, K / J | Previous / next row |
Home / End | First / last row |
X / Space | Toggle the row (Shift: apply the anchor’s state up to it) |
Shift + ↑ / ↓ | Grow the range from the anchor |
⌘A / Ctrl+A | Select all (same scope as the header checkbox) |
Esc | Clear the selection |
Enter | Click the row’s primary link, then call onOpen(item) |
→ / F2 | Move into the row’s controls (see below) |
On a touch screen, a long press (500ms) on a row selects it, the way a phone starts a selection. The release doesn’t open the row’s link, and the long-press menu (link preview, text selection) stays away. A press on the row’s checkbox or another control in it is left to that control. Pair it with a visible way in, such as a “Select” button in the toolbar: a long press is a convention, not something the screen shows.
Focus a row and press Enter to open it.
---
// Hand-written: `packages/demos/grid-list/selection.demo.tsx` skips Astro
// (see its `skip`) — Astro GridList takes its rows through one default-slot
// callback, not a `row` render prop. The script below logs `grid-list:open`.
import GridList, { type GridListRender } from "@pindoba/astro-grid-list";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
interface Message {
id: string;
from: string;
subject: string;
preview: string;
date: string;
unread: boolean;
}
const messages: Message[] = [
{
id: "m1",
from: "Inês Duarte",
subject: "Venue shortlist",
preview: "Three options left, all within budget.",
date: "09:42",
unread: true,
},
{
id: "m2",
from: "Kofi Mensah",
subject: "Invoice #2041",
preview: "Paid in full, receipt attached.",
date: "08:15",
unread: true,
},
{
id: "m3",
from: "Yuna Choi",
subject: "Design review notes",
preview: "Mostly spacing; the empty state needs a pass.",
date: "Yesterday",
unread: false,
},
{
id: "m4",
from: "Tomás Rivera",
subject: "Offsite agenda",
preview: "Draft for Thursday, comments welcome.",
date: "Oct 26",
unread: false,
},
{
id: "m5",
from: "Amara Okafor",
subject: "Access request",
preview: "Could you add me to the analytics workspace?",
date: "Oct 25",
unread: false,
},
];
const subjects = Object.fromEntries(messages.map((m) => [m.id, m.subject]));
const fromCls = css({ truncate: true });
const textCls = css({ display: "flex", gap: "xs", minWidth: "0" });
const previewCls = css({
color: "neutral.text.muted",
fontWeight: "normal",
truncate: true,
});
const dateCls = css({
justifyContent: "flex-end",
color: "neutral.text.muted",
textStyle: "caption",
whiteSpace: "nowrap",
});
const logCls = css({
margin: "0",
textStyle: "body.sm",
color: "neutral.text.muted",
});
---
<div
class={stack({ gap: "sm", width: "100%" })}
data-demo-grid-list-inbox
data-subjects={JSON.stringify(subjects)}
>
<GridList
label="Inbox"
items={messages}
getId={(message: Message) => message.id}
selectable
selectAll="footer"
strong={(message: Message) => message.unread}
columns={[
{ id: "from", track: "minmax(10rem, 16rem)" },
{ id: "text", track: "minmax(0, 1fr)" },
{ id: "date", track: "auto" },
]}
>
{(ctx: GridListRender<Message>) =>
ctx.region === "row" ? (
<Fragment>
<span {...ctx.cell("from", { class: fromCls })}>{ctx.item.from}</span>
<span {...ctx.cell("text")}>
<span class={textCls}>
<a {...ctx.primaryProps} href={`#${ctx.item.id}`}>
{ctx.item.subject}
</a>
<span class={previewCls}>{ctx.item.preview}</span>
</span>
</span>
<span {...ctx.cell("date", { class: dateCls })}>{ctx.item.date}</span>
</Fragment>
) : null
}
</GridList>
<p class={logCls} aria-live="polite" data-demo-log>
Focus a row and press Enter to open it.
</p>
</div>
<script>
import { GRID_LIST_EVENTS } from "@pindoba/astro-grid-list/runtime";
for (const scope of document.querySelectorAll<HTMLElement>(
"[data-demo-grid-list-inbox]",
)) {
const subjects = new Map<string, string>(
Object.entries(JSON.parse(scope.dataset.subjects ?? "{}")),
);
const log = scope.querySelector("[data-demo-log]");
const opened = (id: string): void => {
if (log) log.textContent = `Opened “${subjects.get(id) ?? id}”`;
};
// The primary link is a demo anchor: open in place instead.
scope.addEventListener("click", (event) => {
const link =
event.target instanceof Element
? event.target.closest<HTMLAnchorElement>("a[href^='#']")
: null;
if (!link) return;
event.preventDefault();
opened(link.hash.slice(1));
});
scope.addEventListener(GRID_LIST_EVENTS.open, (event) => {
if (event instanceof CustomEvent && typeof event.detail?.id === "string")
opened(event.detail.id);
});
}
</script>disabledIds lists the rows the user can’t select or deselect. A disabled
row’s checkbox is disabled and the row is aria-disabled. Clicks, X /
Space, Shift ranges, ⌘A, select-all and Esc all leave it as value has
it, so a row that is already in value stays checked: the repositories
below that are already added can’t be unchecked. Select-all counts only the
rows it can change. A disabled row still takes the focus, opens with Enter
and keeps its own controls.
Pick repositories to add; added and archived ones are locked.
---
// Hand-written: `packages/demos/grid-list/disabled.demo.tsx` skips Astro
// (see its `skip`) — Astro GridList takes its rows through one default-slot
// callback, not a `row` render prop. The script below counts the picks from
// `grid-list:valuechange`.
import GridList, { type GridListRender } from "@pindoba/astro-grid-list";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
interface Repository {
id: string;
name: string;
path: string;
status: string;
}
const repositories: Repository[] = [
{ id: "web", name: "web", path: "~/code/web", status: "Added" },
{ id: "api", name: "api", path: "~/code/api", status: "" },
{
id: "design",
name: "design-tokens",
path: "~/code/design-tokens",
status: "Added",
},
{ id: "infra", name: "infra", path: "~/code/infra", status: "" },
{
id: "legacy",
name: "legacy-admin",
path: "~/archive/legacy-admin",
status: "Archived",
},
{ id: "docs", name: "docs", path: "~/code/docs", status: "" },
];
// Already added: shown checked, and no click, key or select-all unchecks them.
const added = ["web", "design"];
// Locked rows: the added ones plus an archived one that can't be picked.
const locked = ["web", "design", "legacy"];
const nameCls = css({ fontWeight: "medium", truncate: true });
const pathCls = css({ color: "neutral.text.muted", truncate: true });
const statusCls = css({
justifyContent: "flex-end",
color: "neutral.text.muted",
textStyle: "caption",
whiteSpace: "nowrap",
});
const logCls = css({
margin: "0",
textStyle: "body.sm",
color: "neutral.text.muted",
});
---
<div
class={stack({ gap: "sm", width: "100%" })}
data-demo-grid-list-disabled
data-added={JSON.stringify(added)}
>
<GridList
label="Repositories"
items={repositories}
getId={(repository: Repository) => repository.id}
selectable
header
disabledIds={locked}
defaultValue={added}
columns={[
{ id: "name", track: "minmax(8rem, 12rem)", header: "Repository" },
{ id: "path", track: "minmax(0, 1fr)", header: "Path" },
{ id: "status", track: "auto", srHeader: "Status" },
]}
>
{(ctx: GridListRender<Repository>) =>
ctx.region === "row" ? (
<Fragment>
<span {...ctx.cell("name", { class: nameCls })}>{ctx.item.name}</span>
<span {...ctx.cell("path", { class: pathCls })}>{ctx.item.path}</span>
<span {...ctx.cell("status", { class: statusCls })}>
{ctx.item.status}
</span>
</Fragment>
) : null
}
</GridList>
<p class={logCls} aria-live="polite" data-demo-log>
Pick repositories to add; added and archived ones are locked.
</p>
</div>
<script>
import { GRID_LIST_EVENTS } from "@pindoba/astro-grid-list/runtime";
for (const scope of document.querySelectorAll<HTMLElement>(
"[data-demo-grid-list-disabled]",
)) {
const added = new Set<string>(JSON.parse(scope.dataset.added ?? "[]"));
const log = scope.querySelector("[data-demo-log]");
const idle = log?.textContent ?? "";
scope.addEventListener(GRID_LIST_EVENTS.valueChange, (event) => {
if (!(event instanceof CustomEvent) || !log) return;
const value: Set<string> = event.detail?.value ?? new Set();
const picked = [...value].filter((id) => !added.has(id)).length;
log.textContent = picked === 0 ? idle.trim() : `${picked} to add`;
});
}
</script>toneA row with no primary link has nothing to open, so in a selectable list the
whole row is its checkbox’s target: a click anywhere on it (outside its own
controls) toggles it, Shift-click extends the range, and Enter toggles it
too, unless the list has an onOpen. Rows with a primary link keep opening
on click and on Enter.
tone colours the selection: the selected rows’ fill, a joined block’s fill,
the check badge and every checkbox. It takes a semantic tone (primary by
default, neutral, success, warning, danger). Use danger when
picking means deleting.
Click a row to pick it for deletion.
---
// Hand-written: `packages/demos/grid-list/pick-list.demo.tsx` skips Astro
// (see its `skip`) — Astro GridList takes its rows through one default-slot
// callback, not a `row` render prop. The script below counts the picks from
// `grid-list:valuechange`.
import GridList, { type GridListRender } from "@pindoba/astro-grid-list";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
interface Branch {
id: string;
name: string;
merged: string;
}
const branches: Branch[] = [
{ id: "b1", name: "fix/empty-state-spacing", merged: "2 days ago" },
{ id: "b2", name: "feat/toolbar-shortcuts", merged: "5 days ago" },
{ id: "b3", name: "chore/bump-dependencies", merged: "1 week ago" },
{ id: "b4", name: "refactor/export-button", merged: "2 weeks ago" },
{ id: "b5", name: "feat/search-cache", merged: "3 weeks ago" },
];
const nameCls = css({ fontFamily: "mono", truncate: true });
const mergedCls = css({
justifyContent: "flex-end",
color: "neutral.text.muted",
textStyle: "caption",
whiteSpace: "nowrap",
});
const logCls = css({
margin: "0",
textStyle: "body.sm",
color: "neutral.text.muted",
});
---
<div class={stack({ gap: "sm", width: "100%" })} data-demo-grid-list-pick>
<GridList
label="Merged branches"
items={branches}
getId={(branch: Branch) => branch.id}
selectable
selectAll="footer"
tone="danger"
columns={[
{ id: "name", track: "minmax(0, 1fr)" },
{ id: "merged", track: "auto" },
]}
>
{(ctx: GridListRender<Branch>) =>
ctx.region === "row" ? (
<Fragment>
<span {...ctx.cell("name", { class: nameCls })}>{ctx.item.name}</span>
<span {...ctx.cell("merged", { class: mergedCls })}>
{ctx.item.merged}
</span>
</Fragment>
) : null
}
</GridList>
<p class={logCls} aria-live="polite" data-demo-log>
Click a row to pick it for deletion.
</p>
</div>
<script>
import { GRID_LIST_EVENTS } from "@pindoba/astro-grid-list/runtime";
for (const scope of document.querySelectorAll<HTMLElement>(
"[data-demo-grid-list-pick]",
)) {
const log = scope.querySelector("[data-demo-log]");
const idle = log?.textContent?.trim() ?? "";
scope.addEventListener(GRID_LIST_EVENTS.valueChange, (event) => {
if (!(event instanceof CustomEvent) || !log) return;
const size: number = event.detail?.value?.size ?? 0;
log.textContent = size === 0 ? idle : `${size} to delete`;
});
}
</script>selectAll says where the select-all checkbox goes. By default
("header") it sits in the column header’s select column (or over the
selectOver column’s header), so it needs header. A list without a header, like the inbox above, can use "footer"
instead: a labelled “Select all” checkbox in the row under the list, before
the pager if there is one. A grid of tiles can use it too. false shows no
select-all checkbox anywhere. Wherever it sits, it covers the same items, and
⌘A works in every case.
When a BulkBar acts on the list, give it somewhere to put the
focus back once the selection empties under its buttons:
returnFocus={() => gridListFocusTarget(listElement)}. The helper, exported
by every GridList package, returns the list’s select-all checkbox (header or
footer) while it is enabled, else the row holding the list’s Tab stop.
The list follows the ARIA grid pattern. The rows rove, and every control
inside a row is taken out of the Tab order (tabindex="-1"): its checkbox,
buttons, and links other than the primary one. The list sets this itself,
including on rows that render later, so Tab leaves the grid in one stop. The
keys reach the controls instead:
| Key (in a row’s control) | Action |
|---|---|
→ / ← | Next / previous control in the row |
Home / End | First / last control |
← on the first, Esc | Back to the row |
F2 | Back to the row (on a row, F2 moves in as → does) |
↑ / ↓ | Back to the row, then to the previous / next row |
A text field in a row keeps its arrows, Home and End for the caret, so
only Esc and F2 leave it. In the grid layout, ← / → already move
between tiles, so only F2 moves into a tile’s controls. Controls that are
hidden while the row has focus are skipped, for example a reveal="rest" cell
or the cell under selectOver’s checkbox. The logic is
gridListCellKeydown and gridListRowControls in @pindoba/core-grid-list.
The keyboard model lives in pure reducers in @pindoba/core-grid-list
(gridListKeydown, toggleGridListRow, toggleGridListAll), so every
framework gets the same behaviour.
selectOverWithout selectOver, a selectable list adds its own select column, along with
its place in the narrow template. That track sits blank at the start of every
row while no checkbox shows. When rows already start with a leading cell (a
status or file-type icon, an avatar), selectOver="<column>" puts the
checkbox over that cell instead. The cell shows at rest and gives way to the
checkbox on hover, focus or selection, and on every row once anything is
selected. Touch screens have no hover, so the cell stays at rest there. A
long press on the row, or a tap on the cell (the transparent checkbox sits
above it), selects the row; from then on every row shows its checkbox, as on
a phone’s mail app. A list’s own select
column and tiles always show their checkboxes on touch. The header’s
select-all takes that column’s header, centred over the rows’ checkboxes.
On a narrow layout the checkbox folds with its host cell, unless
narrow.cells.select places it. Give the host column an auto track: it
takes the wider of the cell and the checkbox, and the list pads it like its
own select column, so the checkbox sits as far from the text as from the row’s
edge and the cell’s content centres on the checkbox.
A disabled row (disabledIds) keeps its cell in every state, and the
checkbox never shows over it. Render the reason there: the locked files below
keep their lock icon, while the other files’ type icons give way to the
checkbox. The drag demo below puts the checkbox over an
avatar.
Hover a file to pick it; locked files keep their lock.
---
// Hand-written: `packages/demos/grid-list/select-over.demo.tsx` skips Astro
// (see its `skip`) — Astro GridList takes its rows through one default-slot
// callback, not a `row` render prop. The script below counts the selection
// from `grid-list:valuechange`.
import GridList, { type GridListRender } from "@pindoba/astro-grid-list";
import { FileText, Image, Lock, Sheet } from "@lucide/astro";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
interface Doc {
id: string;
name: string;
kind: string;
size: string;
locked: boolean;
}
const files: Doc[] = [
{
id: "brief",
name: "Project brief.md",
kind: "text",
size: "4 KB",
locked: false,
},
{
id: "budget",
name: "Budget 2027.csv",
kind: "sheet",
size: "18 KB",
locked: true,
},
{
id: "cover",
name: "Cover photo.jpg",
kind: "image",
size: "2.1 MB",
locked: false,
},
{
id: "notes",
name: "Meeting notes.md",
kind: "text",
size: "7 KB",
locked: false,
},
{
id: "contract",
name: "Signed contract.pdf",
kind: "text",
size: "310 KB",
locked: true,
},
{
id: "chart",
name: "Growth chart.png",
kind: "image",
size: "640 KB",
locked: false,
},
];
// Locked files can't be picked: their lock icon stays where the checkbox
// would show, and tells why.
const locked = files.filter((file) => file.locked).map((file) => file.id);
const iconCls = css({ color: "neutral.text.muted" });
const nameCls = css({ fontWeight: "medium", truncate: true });
const sizeCls = css({
justifyContent: "flex-end",
color: "neutral.text.muted",
textStyle: "caption",
whiteSpace: "nowrap",
});
const logCls = css({
margin: "0",
textStyle: "body.sm",
color: "neutral.text.muted",
});
---
<div
class={stack({ gap: "sm", width: "100%" })}
data-demo-grid-list-select-over
>
<GridList
label="Files"
items={files}
getId={(file: Doc) => file.id}
selectable
header
selectOver="icon"
disabledIds={locked}
columns={[
{ id: "icon", track: "auto", srHeader: "Type" },
{ id: "name", track: "minmax(0, 1fr)", header: "Name" },
{ id: "size", track: "auto", header: "Size" },
]}
>
{(ctx: GridListRender<Doc>) =>
ctx.region === "row" ? (
<Fragment>
<span {...ctx.cell("icon", { class: iconCls })}>
{ctx.item.locked ? (
<Lock size={14} />
) : ctx.item.kind === "sheet" ? (
<Sheet size={14} />
) : ctx.item.kind === "image" ? (
<Image size={14} />
) : (
<FileText size={14} />
)}
</span>
<span {...ctx.cell("name", { class: nameCls })}>{ctx.item.name}</span>
<span {...ctx.cell("size", { class: sizeCls })}>{ctx.item.size}</span>
</Fragment>
) : null
}
</GridList>
<p class={logCls} aria-live="polite" data-demo-log>
Hover a file to pick it; locked files keep their lock.
</p>
</div>
<script>
import { GRID_LIST_EVENTS } from "@pindoba/astro-grid-list/runtime";
for (const scope of document.querySelectorAll<HTMLElement>(
"[data-demo-grid-list-select-over]",
)) {
const log = scope.querySelector("[data-demo-log]");
const idle = log?.textContent ?? "";
scope.addEventListener(GRID_LIST_EVENTS.valueChange, (event) => {
if (!(event instanceof CustomEvent) || !log) return;
const value: Set<string> = event.detail?.value ?? new Set();
log.textContent =
value.size === 0 ? idle.trim() : `${value.size} selected`;
});
}
</script>actions gives each row its actions (archive, star, delete) as data: an
id, a label (the icon button’s name and tooltip, and the menu item’s
text), an icon, and optionally tone: "danger", disabled and a
shortcut. Pass one list for every row, or a function of the row’s item
when the actions depend on it (Star or Unstar). onAction(actionId, item)
reports every run, and an action’s own onSelect(item) runs first when it
has one.
Each action is offered in several ways, all from the same list:
actionsInline enabled actions (2 by default) show as
icon buttons while the row is hovered or focused. The rest wait behind a
⋯ button, which shows only when there is something behind it.
actionsInline={0} keeps every action in the menu.Shift+F10 and the ContextMenu key open the same
menu with every action, the inline ones included, on any device: they open
it away from the buttons. A right-click opens it at the pointer and
extends down and to the right of it, like a desktop context menu; the
keys open it at the row’s ⋯, lined up with the button’s end edge. The browser’s own
menu stays closed only on rows that have actions. When the menu closes,
focus goes back to the row.shortcut is one key with no modifier ("e",
"Delete"). It runs its action on the focused row, or on a control inside
it, and shows in the button’s tooltip and the menu item. The keys the list
uses itself (x, Space, Enter, the arrows, j / k, Home / End,
PageUp / PageDown, Esc, F2, Tab) are refused: the list throws on its
first render, so a clash never silently takes over selection or
navigation. A shortcut does nothing while focus is in a text field.What the menu lists is decided when it opens, by core’s
gridListActionsMenuScope({ via, pointerType }) and passed to
rowActions(item, id, { menu }) ("all" or "overflow"). Where it opens
is the at option: a right-click’s point (gridListContextMenuPoint(event))
makes the menu bag’s anchorPoint and a bottom-start placement, none keeps
it at ⋯ (bottom-end, gridListActionsMenuPlacement). Every framework and
the Astro runtime use the same rules.
A disabled action stays in the menu, greyed out, but never becomes an icon
button and its shortcut does nothing. Rows in disabledIds keep their
actions: disabledIds only takes a row out of the selection. While anything
is selected, row actions are off everywhere (buttons, ⋯, the right-click menu
and shortcuts), and the selection’s Bulk Bar acts instead.
Destructive actions run at once, with no confirm: show an undo toast from
onAction.
Without actionsOver, the list adds an actions column of its own at the end
of each row (named by labels.actionsColumn). actionsOver="<column>" lays
the actions over that column’s cell instead, so no track sits empty at the
end of the row: the cell, often a date, gives way to the buttons on hover and
focus, as selectOver does for the checkbox. On touch the cell keeps its
content and the ⋯ sits beside it. On a narrow layout the actions fold with
their host cell, or with their own column, unless narrow.cells.actions
places them. Tiles (layout="grid") have no icon buttons: their ⋯ sits in
the corner opposite the checkbox, shown on hover and always on touch.
The inbox below lays its actions over the date. Archive and Star are icon
buttons; Mark as unread and Delete are in the ⋯ menu. Right-click a row to
see all four, press E, S, U or Delete on a focused row, or select
one to see the actions step aside.
Hover a message, right-click it, or press E on it.
---
// Hand-written: `packages/demos/grid-list/actions.demo.tsx` skips Astro (see
// its `skip`) — Astro GridList actions are data: each icon is the named slot
// `action-<id>`, and a run is the `grid-list:action` event (`detail: {
// action, id }`), which the script below writes to the log. The list itself
// stays as the server rendered it (the other frameworks drop archived rows).
import GridList, { type GridListRender } from "@pindoba/astro-grid-list";
import { Archive, Mail as MailIcon, Star, Trash } from "@lucide/astro";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
interface Mail {
id: string;
from: string;
subject: string;
date: string;
starred: boolean;
}
const inbox: Mail[] = [
{
id: "m1",
from: "Inês Duarte",
subject: "Venue shortlist for the offsite",
date: "09:41",
starred: true,
},
{
id: "m2",
from: "Kofi Mensah",
subject: "Receipts from the March trip",
date: "Yesterday",
starred: false,
},
{
id: "m3",
from: "Yuna Choi",
subject: "Draft agenda, second pass",
date: "Oct 24",
starred: false,
},
{
id: "m4",
from: "Tomás Reyes",
subject: "Catering quotes",
date: "Oct 19",
starred: false,
},
];
// The same actions for every row, but the star one reads the row: the
// function form gives each row its own list.
function actionsFor(mail: Mail) {
return [
{ id: "archive", label: "Archive", shortcut: "e" },
{ id: "star", label: mail.starred ? "Unstar" : "Star", shortcut: "s" },
{ id: "unread", label: "Mark as unread", shortcut: "u" },
{
id: "delete",
label: "Delete",
tone: "danger" as const,
shortcut: "Delete",
},
];
}
// What the script needs to word the log: each message's subject and star.
const mailData = JSON.stringify(
Object.fromEntries(
inbox.map((mail) => [mail.id, [mail.subject, mail.starred]]),
),
);
const fromCls = css({ fontWeight: "medium", truncate: true });
const subjectCls = css({ color: "neutral.text.muted", truncate: true });
const dateCls = css({
justifyContent: "flex-end",
color: "neutral.text.muted",
fontVariantNumeric: "tabular-nums",
whiteSpace: "nowrap",
});
const logCls = css({
margin: "0",
textStyle: "body.sm",
color: "neutral.text.muted",
});
---
<div
class={stack({ gap: "sm", width: "100%" })}
data-demo-grid-list-actions
data-demo-mail={mailData}
>
<GridList
label="Inbox"
items={inbox}
getId={(mail: Mail) => mail.id}
selectable
actions={actionsFor}
actionsOver="date"
columns={[
{ id: "from", track: "minmax(0, 16rem)" },
{ id: "subject", track: "minmax(0, 1fr)" },
{ id: "date", track: "auto" },
]}
narrow={{
columns: "auto minmax(0, 1fr) auto",
cells: {
from: { column: "2", row: "1" },
date: { column: "3", row: "1" },
subject: { column: "2 / -1", row: "2" },
},
}}
>
<Archive slot="action-archive" />
<Star slot="action-star" />
<MailIcon slot="action-unread" />
<Trash slot="action-delete" />
{(ctx: GridListRender<Mail>) =>
ctx.region === "row" ? (
<Fragment>
<span {...ctx.cell("from", { class: fromCls })}>{ctx.item.from}</span>
<span {...ctx.cell("subject", { class: subjectCls })}>
{ctx.item.starred && <Star size={12} />}
{ctx.item.subject}
</span>
<span {...ctx.cell("date", { class: dateCls })}>{ctx.item.date}</span>
</Fragment>
) : null
}
</GridList>
<p class={logCls} aria-live="polite" data-demo-log>
Hover a message, right-click it, or press E on it.
</p>
</div>
<script>
import { GRID_LIST_EVENTS } from "@pindoba/astro-grid-list/runtime";
for (const scope of document.querySelectorAll<HTMLElement>(
"[data-demo-grid-list-actions]",
)) {
const log = scope.querySelector("[data-demo-log]");
const mail: Record<string, [string, boolean]> = JSON.parse(
scope.dataset.demoMail ?? "{}",
);
scope.addEventListener(GRID_LIST_EVENTS.action, (event) => {
if (!(event instanceof CustomEvent) || !log) return;
const { action, id } = event.detail ?? {};
const [subject, starred] = mail[id] ?? ["", false];
const done: Record<string, string> = {
archive: "Archived",
delete: "Deleted",
star: starred ? "Unstarred" : "Starred",
unread: "Marked as unread",
};
log.textContent = `${done[action] ?? action}: ${subject}`;
});
}
</script>On a touch screen the row itself can reveal actions. Give an action swipe: "start" or swipe: "end" and its filled tile sits behind the row on that
side: "start" slides the row toward the end edge, "end" toward the start
edge, and under RTL the sides mirror. Several actions on one side stack into
one strip. Swipe is an accelerator: every swipe action stays in the ⋯ menu, the
context menu and its shortcut, so nothing depends on the gesture.
swipeCommit: true on the first action of a side runs it on a full swipe or a
flick, with no tap: the tile stretches once a release would run it. Without it
a swipe only reveals the tiles, and a tap on one runs it. Destructive actions
skip the confirm either way: show an undo toast from onAction, which gets
onAction(id, item, { via: "swipe" }) for a swipe (Astro: detail.via).
via is absent when the action ran from a button, the menu or a shortcut.
The rules, so the gesture never fights the rest of the list:
layout="grid") and disabled actions ignore swipe.Archive swipes in from the start and commits on a full swipe, Delete swipes in
from the end and reveals its tile, and Star has no swipe. Both run with an
undo toast. Use a touch device or its emulation to try it.
Swipe a message on a touch screen, or use its buttons.
---
// Hand-written: `packages/demos/grid-list/row-actions-swipe.demo.tsx` skips Astro (see
// its `skip`) — Astro GridList actions are data: each icon is the named slot
// `action-<id>`, and a run is the `grid-list:action` event (`detail: {
// action, id, via }`), which the script below writes to the log. The list itself
// stays as the server rendered it (the other frameworks drop archived rows).
import GridList, { type GridListRender } from "@pindoba/astro-grid-list";
import { Archive, Star, Trash } from "@lucide/astro";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
interface Mail {
id: string;
from: string;
subject: string;
date: string;
starred: boolean;
}
const inbox: Mail[] = [
{
id: "m1",
from: "Inês Duarte",
subject: "Venue shortlist for the offsite",
date: "09:41",
starred: true,
},
{
id: "m2",
from: "Kofi Mensah",
subject: "Receipts from the March trip",
date: "Yesterday",
starred: false,
},
{
id: "m3",
from: "Yuna Choi",
subject: "Draft agenda, second pass",
date: "Oct 24",
starred: false,
},
{
id: "m4",
from: "Tomás Reyes",
subject: "Catering quotes",
date: "Oct 19",
starred: false,
},
];
// The same actions for every row, but the star one reads the row: the
// function form gives each row its own list.
function actionsFor(mail: Mail) {
return [
{
id: "archive",
label: "Archive",
shortcut: "e",
swipe: "start" as const,
swipeCommit: true,
},
{ id: "star", label: mail.starred ? "Unstar" : "Star", shortcut: "s" },
{
id: "delete",
label: "Delete",
tone: "danger" as const,
shortcut: "Delete",
swipe: "end" as const,
},
];
}
// What the script needs to word the log: each message's subject and star.
const mailData = JSON.stringify(
Object.fromEntries(
inbox.map((mail) => [mail.id, [mail.subject, mail.starred]]),
),
);
const fromCls = css({ fontWeight: "medium", truncate: true });
const subjectCls = css({ color: "neutral.text.muted", truncate: true });
const dateCls = css({
justifyContent: "flex-end",
color: "neutral.text.muted",
fontVariantNumeric: "tabular-nums",
whiteSpace: "nowrap",
});
const logCls = css({
margin: "0",
textStyle: "body.sm",
color: "neutral.text.muted",
});
---
<div
class={stack({ gap: "sm", width: "100%" })}
data-demo-grid-list-row-actions-swipe
data-demo-mail={mailData}
>
<GridList
label="Inbox"
items={inbox}
getId={(mail: Mail) => mail.id}
selectable
actions={actionsFor}
actionsOver="date"
columns={[
{ id: "from", track: "minmax(0, 16rem)" },
{ id: "subject", track: "minmax(0, 1fr)" },
{ id: "date", track: "auto" },
]}
narrow={{
columns: "auto minmax(0, 1fr) auto",
cells: {
from: { column: "2", row: "1" },
date: { column: "3", row: "1" },
subject: { column: "2 / -1", row: "2" },
},
}}
>
<Archive slot="action-archive" />
<Star slot="action-star" />
<Trash slot="action-delete" />
{(ctx: GridListRender<Mail>) =>
ctx.region === "row" ? (
<Fragment>
<span {...ctx.cell("from", { class: fromCls })}>{ctx.item.from}</span>
<span {...ctx.cell("subject", { class: subjectCls })}>
{ctx.item.starred && <Star size={12} />}
{ctx.item.subject}
</span>
<span {...ctx.cell("date", { class: dateCls })}>{ctx.item.date}</span>
</Fragment>
) : null
}
</GridList>
<p class={logCls} aria-live="polite" data-demo-log>
Swipe a message on a touch screen, or use its buttons.
</p>
</div>
<script>
import { GRID_LIST_EVENTS } from "@pindoba/astro-grid-list/runtime";
for (const scope of document.querySelectorAll<HTMLElement>(
"[data-demo-grid-list-row-actions-swipe]",
)) {
const log = scope.querySelector("[data-demo-log]");
const mail: Record<string, [string, boolean]> = JSON.parse(
scope.dataset.demoMail ?? "{}",
);
scope.addEventListener(GRID_LIST_EVENTS.action, (event) => {
if (!(event instanceof CustomEvent) || !log) return;
const { action, id, via } = event.detail ?? {};
const [subject, starred] = mail[id] ?? ["", false];
const done: Record<string, string> = {
archive: "Archived",
delete: "Deleted",
star: starred ? "Unstarred" : "Starred",
};
log.textContent = `${done[action] ?? action}: ${subject} (via ${via ?? "button or menu"})`;
});
}
</script>Without actionsOver, the list gives the actions a trailing column of their
own, with an Actions header. Here actionsInline={0} keeps every action in
the menu, so ⋯ is the row’s only control: it shows on hover with a mouse and
at rest on touch. Locked files can’t be shared or trashed: the function form
marks those two disabled for them, so they stay in the menu, greyed out,
and their shortcuts do nothing. The locked files are also in disabledIds,
which only takes them out of the selection: they can’t be picked (the lock
stays where the checkbox would show), but they keep their menu and can
still be downloaded. Pick an unlocked file and the ⋯ buttons, the
right-click menu and the shortcuts all step aside until the selection is
cleared. Below 640px the menu folds onto the name’s line
(narrow.cells.actions).
Open a file's ⋯, right-click it, or press D on it.
---
// Hand-written: `packages/demos/grid-list/actions-column.demo.tsx` skips
// Astro (see its `skip`) — Astro GridList takes its rows through one
// default-slot callback, each action icon through the named slot
// `action-<id>`, and reports a run as the `grid-list:action` event
// (`detail: { action, id }`), which the script below writes to the log. The
// list itself stays as the server rendered it (the other frameworks drop a
// trashed file).
import GridList, { type GridListRender } from "@pindoba/astro-grid-list";
import {
Download,
FileText,
Image,
Link,
Lock,
Share2,
Sheet,
Trash,
} from "@lucide/astro";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
interface Doc {
id: string;
name: string;
kind: string;
size: string;
modified: string;
locked: boolean;
}
const files: Doc[] = [
{
id: "brief",
name: "Project brief.md",
kind: "text",
size: "4 KB",
modified: "Today",
locked: false,
},
{
id: "budget",
name: "Budget 2027.csv",
kind: "sheet",
size: "18 KB",
modified: "Yesterday",
locked: true,
},
{
id: "cover",
name: "Cover photo.jpg",
kind: "image",
size: "2.1 MB",
modified: "Oct 28",
locked: false,
},
{
id: "contract",
name: "Signed contract.pdf",
kind: "text",
size: "310 KB",
modified: "Oct 21",
locked: true,
},
{
id: "notes",
name: "Meeting notes.md",
kind: "text",
size: "7 KB",
modified: "Oct 17",
locked: false,
},
];
// Locked files can't be picked (their lock stays where the checkbox would
// show) — but `disabledIds` is about selection only: they keep their actions.
const locked = files.filter((file) => file.locked).map((file) => file.id);
// A locked file still downloads and copies its link, but can't be shared
// or trashed: those stay in its menu, greyed out.
function actionsFor(file: Doc) {
return [
{ id: "download", label: "Download", shortcut: "d" },
{ id: "link", label: "Copy link", shortcut: "l" },
{ id: "share", label: "Share", shortcut: "s", disabled: file.locked },
{
id: "trash",
label: "Move to trash",
tone: "danger" as const,
shortcut: "Delete",
disabled: file.locked,
},
];
}
// What the script needs to word the log: each file's name.
const names = JSON.stringify(
Object.fromEntries(files.map((file) => [file.id, file.name])),
);
const iconCls = css({ color: "neutral.text.muted" });
const nameCls = css({ fontWeight: "medium", truncate: true });
const metaCls = css({
color: "neutral.text.muted",
textStyle: "caption",
fontVariantNumeric: "tabular-nums",
whiteSpace: "nowrap",
});
const logCls = css({
margin: "0",
textStyle: "body.sm",
color: "neutral.text.muted",
});
---
<div
class={stack({ gap: "sm", width: "100%" })}
data-demo-grid-list-actions-column
data-demo-names={names}
>
<GridList
label="Files"
items={files}
getId={(file: Doc) => file.id}
selectable
header
selectOver="icon"
disabledIds={locked}
actions={actionsFor}
actionsInline={0}
columns={[
{ id: "icon", track: "auto", srHeader: "Type" },
{ id: "name", track: "minmax(0, 1fr)", header: "Name" },
{ id: "size", track: "auto", header: "Size" },
{ id: "modified", track: "auto", header: "Modified" },
]}
narrow={{
columns: "auto minmax(0, 1fr) auto",
// Folded: the menu stays on the name's line, the size and date
// drop below it.
cells: {
icon: { column: "1", row: "1" },
name: { column: "2", row: "1" },
actions: { column: "3", row: "1" },
size: { column: "2", row: "2" },
modified: { column: "3", row: "2" },
},
}}
>
<Download slot="action-download" />
<Link slot="action-link" />
<Share2 slot="action-share" />
<Trash slot="action-trash" />
{(ctx: GridListRender<Doc>) =>
ctx.region === "row" ? (
<Fragment>
<span {...ctx.cell("icon", { class: iconCls })}>
{ctx.item.locked ? (
<Lock size={14} />
) : ctx.item.kind === "sheet" ? (
<Sheet size={14} />
) : ctx.item.kind === "image" ? (
<Image size={14} />
) : (
<FileText size={14} />
)}
</span>
<span {...ctx.cell("name", { class: nameCls })}>{ctx.item.name}</span>
<span {...ctx.cell("size", { class: metaCls })}>{ctx.item.size}</span>
<span {...ctx.cell("modified", { class: metaCls })}>
{ctx.item.modified}
</span>
</Fragment>
) : null
}
</GridList>
<p class={logCls} aria-live="polite" data-demo-log>
Open a file's ⋯, right-click it, or press D on it.
</p>
</div>
<script>
import { GRID_LIST_EVENTS } from "@pindoba/astro-grid-list/runtime";
for (const scope of document.querySelectorAll<HTMLElement>(
"[data-demo-grid-list-actions-column]",
)) {
const log = scope.querySelector("[data-demo-log]");
let last = log?.textContent?.trim() ?? "";
const names: Record<string, string> = JSON.parse(
scope.dataset.demoNames ?? "{}",
);
const done: Record<string, string> = {
download: "Downloading",
link: "Link copied",
share: "Sharing",
trash: "Moved to trash",
};
scope.addEventListener(GRID_LIST_EVENTS.action, (event) => {
if (!(event instanceof CustomEvent) || !log) return;
const { action, id } = event.detail ?? {};
last = `${done[action] ?? action}: ${names[id] ?? id}`;
log.textContent = last;
});
scope.addEventListener(GRID_LIST_EVENTS.valueChange, (event) => {
if (!(event instanceof CustomEvent) || !log) return;
const value: Set<string> = event.detail?.value ?? new Set();
log.textContent = value.size === 0 ? last : `${value.size} selected`;
});
}
</script>Tiles have no icon buttons, whatever actionsInline says: every action is
in the ⋯ menu, which sits in the tile’s top-end corner, opposite the
checkbox. It is a filled button, like the checkbox’s box, so it stays
legible over a photo. It shows on hover and focus with a mouse and always on
touch. A
right-click on a tile opens the same menu at the pointer, and the shortcuts
(F, D, S, Delete) work on a focused tile.
Hover a photo for its ⋯, or right-click it.
---
// Hand-written: `packages/demos/grid-list/actions-tiles.demo.tsx` skips
// Astro (see its `skip`) — Astro GridList takes its tiles through one
// default-slot callback, each action icon through the named slot
// `action-<id>`, and reports a run as the `grid-list:action` event
// (`detail: { action, id }`), which the script below writes to the log. The
// grid itself stays as the server rendered it (the other frameworks drop a
// deleted photo and flip its favourite).
import GridList, { type GridListRender } from "@pindoba/astro-grid-list";
import { Download, Share2, Star, Trash } from "@lucide/astro";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
interface Picture {
id: string;
title: string;
meta: string;
seed: string;
favourite: boolean;
}
const pictures: Picture[] = [
{
id: "p1",
title: "Morning fog",
meta: "Sintra · 4032 × 3024",
seed: "pindoba-fog",
favourite: true,
},
{
id: "p2",
title: "Salt flats",
meta: "Uyuni · 6000 × 4000",
seed: "pindoba-salt",
favourite: false,
},
{
id: "p3",
title: "Tram 28",
meta: "Lisbon · 4032 × 3024",
seed: "pindoba-tram",
favourite: false,
},
{
id: "p4",
title: "Night market",
meta: "Taipei · 6000 × 4000",
seed: "pindoba-market",
favourite: false,
},
{
id: "p5",
title: "Rooftops",
meta: "Porto · 5472 × 3648",
seed: "pindoba-roofs",
favourite: true,
},
];
// Tiles have no icon buttons: every action is in the corner ⋯ menu.
function actionsFor(picture: Picture) {
return [
{
id: "favourite",
label: picture.favourite ? "Remove from favourites" : "Favourite",
shortcut: "f",
},
{ id: "download", label: "Download", shortcut: "d" },
{ id: "share", label: "Share", shortcut: "s" },
{
id: "delete",
label: "Delete",
tone: "danger" as const,
shortcut: "Delete",
},
];
}
// What the script needs to word the log: each photo's title and favourite.
const photoData = JSON.stringify(
Object.fromEntries(
pictures.map((picture) => [picture.id, [picture.title, picture.favourite]]),
),
);
const imageCls = css({
display: "block",
width: "100%",
aspectRatio: "4 / 3",
objectFit: "cover",
borderRadius: "sm",
background: "neutral.surface.valley",
});
const titleCls = css({ fontWeight: "medium", truncate: true });
const metaCls = css({
color: "neutral.text.muted",
textStyle: "caption",
fontVariantNumeric: "tabular-nums",
truncate: true,
});
const logCls = css({
margin: "0",
textStyle: "body.sm",
color: "neutral.text.muted",
});
---
<div
class={stack({ gap: "sm", width: "100%" })}
data-demo-grid-list-actions-tiles
data-demo-photos={photoData}
>
<GridList
label="Photos"
layout="grid"
tileSize="14rem"
items={pictures}
getId={(picture: Picture) => picture.id}
selectable
actions={actionsFor}
>
<Star slot="action-favourite" />
<Download slot="action-download" />
<Share2 slot="action-share" />
<Trash slot="action-delete" />
{(ctx: GridListRender<Picture>) =>
ctx.region === "row" ? (
<Fragment>
<img
class={imageCls}
src={`https://picsum.photos/seed/${ctx.item.seed}/400/300`}
alt=""
loading="lazy"
width="400"
height="300"
/>
<span class={titleCls}>
{ctx.item.favourite ? `★ ${ctx.item.title}` : ctx.item.title}
</span>
<span class={metaCls}>{ctx.item.meta}</span>
</Fragment>
) : null
}
</GridList>
<p class={logCls} aria-live="polite" data-demo-log>
Hover a photo for its ⋯, or right-click it.
</p>
</div>
<script>
import { GRID_LIST_EVENTS } from "@pindoba/astro-grid-list/runtime";
for (const scope of document.querySelectorAll<HTMLElement>(
"[data-demo-grid-list-actions-tiles]",
)) {
const log = scope.querySelector("[data-demo-log]");
let last = log?.textContent?.trim() ?? "";
const photos: Record<string, [string, boolean]> = JSON.parse(
scope.dataset.demoPhotos ?? "{}",
);
scope.addEventListener(GRID_LIST_EVENTS.action, (event) => {
if (!(event instanceof CustomEvent) || !log) return;
const { action, id } = event.detail ?? {};
const [title, favourite] = photos[id] ?? [id, false];
const done: Record<string, string> = {
favourite: favourite
? "Removed from favourites"
: "Added to favourites",
download: "Downloading",
share: "Sharing",
delete: "Deleted",
};
last = `${done[action] ?? action}: ${title}`;
log.textContent = last;
});
scope.addEventListener(GRID_LIST_EVENTS.valueChange, (event) => {
if (!(event instanceof CustomEvent) || !log) return;
const value: Set<string> = event.detail?.value ?? new Set();
log.textContent = value.size === 0 ? last : `${value.size} selected`;
});
}
</script>An action’s icon is a component that renders with no props, in every
framework, as in the Bulk Bar: pass the Lucide icon itself
(icon: Archive), not an element. Each framework exports the type as
GridListIcon: a Svelte Component, a React ComponentType, a Vue
Component and a Solid component. The list draws it bare in the icon button
and in a ghost Stamp as the menu item’s leading icon. In
Astro, actions are data only. Each icon is the list’s named slot action-<id>, and every run
dispatches grid-list:action on the list, with detail: { action, id }:
<GridList
items={mail}
getId={(m) => m.id}
actionsOver="date"
actions={[
{ id: "archive", label: "Archive", shortcut: "e" },
{ id: "delete", label: "Delete", tone: "danger", shortcut: "Delete" },
]}
>
<Archive slot="action-archive" />
<Trash slot="action-delete" />
{(ctx: GridListRender<Mail>) => …}
</GridList>
<script>
import { GRID_LIST_EVENTS } from "@pindoba/astro-grid-list/runtime";
list.addEventListener(GRID_LIST_EVENTS.action, (event) => {
const { action, id } = event.detail;
});
</script>
A row added on the client (an item passed through update({ items }) that
the server never rendered) is drawn by renderRow. renderRow returns only
the row’s content cells: the list draws the actions cell around them, the
same as on a server row. That cell holds the icon buttons and their tooltips,
the ⋯ menu, the right-click menu and the shortcuts. The icons are copies of
the action-<id> slots, which the list renders once into a template.
The menu and tooltip code loads only when a row has actions.
The template only holds the action ids the server rendered. An action that
first shows up on the client can bring its own icon: a DOM node, such as an
<svg> element. The list copies it into the icon button, the menu item and
the swipe tile, and leaves your node where it is. An action’s own icon wins
over the template’s, and an action with neither has no icon. The icon is a
node, not an HTML string, so no markup is ever parsed from your data.
const pin = document.createElementNS("http://www.w3.org/2000/svg", "svg");
// …draw the glyph…
handle.update({
items: [
...items,
{ id: "n1", actions: [{ id: "pin", label: "Pin", icon: pin }] },
],
});
The resolution (which actions a row has, which are inline, the menu items)
lives in connectGridList’s rowActions, and the keys and gestures in
gridListActionKeydown and gridListContextMenuRow in
@pindoba/core-grid-list. The rule behind the touch path is
docs/rules/hover-actions-touch-path.md.
groupOf(item) names each item’s group. Items must arrive sorted by it. Each
group gets a title row with a chevron that collapses it, the group name and
its count. Title rows stick to the top of the scroll container as you scroll,
and so does the header. Set --gl-sticky-top to clear a sticky toolbar
above. The stuck rows paint the opaque surface under the list (the nearest
filled Panel’s, else the page’s), so rows never show through them. On a
surface you painted by hand, set data-surface-floor="<step>" on it, or
--gl-surface on the list.
groupLeading adds content after the chevron (an icon, a badge), and
groupTrailing adds actions at the end of the title row. Both receive
{ name, items, open, selectedCount }, so an action can count the group’s
selection. Collapsed rows drop out of the keyboard order, but select-all (the
header checkbox and ⌘A) still takes every item.
groupSelect puts a select-all checkbox at the start of each title row (as
below). It selects or clears the group’s rows, collapsed ones included and
disabledIds left alone, and goes indeterminate while only some are
selected. It is a real control, so Tab reaches it like the chevron. Its
label comes from labels.selectGroup ("Select all in {name}").
Select orders, then print their labels from a section title.
---
// Hand-written: `packages/demos/grid-list/groups.demo.tsx` skips Astro (see
// its `skip`) — Astro GridList takes its rows and group title regions through
// one default-slot callback, not render props. Group titles render once on
// the server, so "Print labels" reads the live selection from the list's
// handle when clicked instead of disabling itself.
import GridList, { type GridListRender } from "@pindoba/astro-grid-list";
import Button from "@pindoba/astro-button";
import Stamp from "@pindoba/astro-stamp";
import { Package } from "@lucide/astro";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
interface Order {
id: string;
number: string;
customer: string;
items: string;
status: string;
total: string;
paid: boolean;
}
// Items arrive sorted by group: each run of one status is a section.
const orders: Order[] = [
{
id: "o1",
number: "#3184",
customer: "Inês Duarte",
items: "Linen shirt, Canvas tote",
status: "To pack",
total: "$148.00",
paid: false,
},
{
id: "o2",
number: "#3183",
customer: "Kofi Mensah",
items: "Wool scarf",
status: "To pack",
total: "$62.00",
paid: true,
},
{
id: "o3",
number: "#3181",
customer: "Yuna Choi",
items: "Leather wallet, Key ring",
status: "To pack",
total: "$95.50",
paid: true,
},
{
id: "o4",
number: "#3179",
customer: "Tomás Rivera",
items: "Rain jacket",
status: "Ready to ship",
total: "$210.00",
paid: true,
},
{
id: "o5",
number: "#3176",
customer: "Amara Okafor",
items: "Ceramic mug set",
status: "Ready to ship",
total: "$44.00",
paid: true,
},
{
id: "o6",
number: "#3170",
customer: "Lena Fischer",
items: "Desk lamp",
status: "Shipped",
total: "$89.00",
paid: true,
},
];
const statuses = Object.fromEntries(orders.map((o) => [o.id, o.status]));
const numberCls = css({
color: "neutral.text.muted",
fontVariantNumeric: "tabular-nums",
fontWeight: "normal",
});
const itemsCls = css({ truncate: true, fontWeight: "normal" });
const totalCls = css({
justifyContent: "flex-end",
fontVariantNumeric: "tabular-nums",
whiteSpace: "nowrap",
});
const logCls = css({
margin: "0",
textStyle: "body.sm",
color: "neutral.text.muted",
});
---
<div
class={stack({ gap: "sm", width: "100%" })}
data-demo-grid-list-groups
data-statuses={JSON.stringify(statuses)}
>
<GridList
label="Orders"
items={orders}
getId={(order: Order) => order.id}
groupOf={(order: Order) => order.status}
strong={(order: Order) => !order.paid}
selectable
groupSelect
columns={[
{ id: "customer", track: "minmax(10rem, 14rem)" },
{ id: "number", track: "auto" },
{ id: "items", track: "minmax(0, 1fr)" },
{ id: "total", track: "auto" },
]}
>
{(ctx: GridListRender<Order>) =>
ctx.region === "row" ? (
<Fragment>
<span {...ctx.cell("customer")}>
<a {...ctx.primaryProps} href={`#${ctx.item.id}`}>
{ctx.item.customer}
</a>
</span>
<span {...ctx.cell("number", { class: numberCls })}>
{ctx.item.number}
</span>
<span {...ctx.cell("items", { class: itemsCls })}>
{ctx.item.items}
</span>
<span {...ctx.cell("total", { class: totalCls })}>
{ctx.item.total}
</span>
</Fragment>
) : ctx.region === "groupLeading" ? (
<Stamp size="xs" emphasis="ghost">
<Package size={14} />
</Stamp>
) : (
<Button size="xs" emphasis="ghost" data-demo-print={ctx.group.name}>
Print labels
</Button>
)
}
</GridList>
<p class={logCls} aria-live="polite" data-demo-log>
Select orders, then print their labels from a section title.
</p>
</div>
<script>
import {
getGridList,
gridListHydration,
} from "@pindoba/astro-grid-list/runtime";
for (const scope of document.querySelectorAll<HTMLElement>(
"[data-demo-grid-list-groups]",
)) {
gridListHydration.boot(scope);
const statuses = new Map<string, string>(
Object.entries(JSON.parse(scope.dataset.statuses ?? "{}")),
);
const root = scope.querySelector("[data-grid-list-config]");
const log = scope.querySelector("[data-demo-log]");
scope.addEventListener("click", (event) => {
const button =
event.target instanceof Element
? event.target.closest<HTMLElement>("[data-demo-print]")
: null;
const group = button?.dataset.demoPrint;
if (!group || !log) return;
const selected = getGridList(root)?.value() ?? new Set();
const count = [...selected].filter(
(id) => statuses.get(id) === group,
).length;
log.textContent = count
? `Printing ${count} label(s) for ${group}`
: `Select orders in ${group} first`;
});
}
</script>look sets how rows read:
"plain" (the default) uses bare rows with a rounded hover and selection."divided" draws a rule between rows. Each group’s last row has none,
because the next title band separates it."cards" makes every row its own rounded tile, as on a board."inset" is a grouped settings list. Each group is a rounded card with its
title above it, and the rules between rows start at the second column, past
a leading icon. insetStart names another column to start them from.density sets the row height: "compact" (36px), "default" (44px) or
"relaxed" (48px). Both are attributes on the root (data-grid-list-look,
data-grid-list-density), not recipe variants, so every list shares one
stylesheet.
// GENERATED from packages/demos/grid-list/looks.demo.tsx — edit the source, not this file.
import { useState } from "react";
import { GridList } from "@pindoba/react-grid-list";
import { Choice, ChoiceItem } from "@pindoba/react-choice";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
interface Task {
id: string;
title: string;
assignee: string;
due: string;
}
const tasks = [
{ id: "t1", title: "Write the release notes", assignee: "Inês", due: "Mon" },
{ id: "t2", title: "Review the pricing page", assignee: "Kofi", due: "Tue" },
{ id: "t3", title: "Fix the export timeout", assignee: "Yuna", due: "Wed" },
{ id: "t4", title: "Plan the offsite", assignee: "Tomás", due: "Fri" },
];
const toolbarCls = css({
display: "flex",
flexWrap: "wrap",
gap: "sm",
});
const mutedCls = css({ color: "neutral.text.muted" });
const dueCls = css({
justifyContent: "flex-end",
color: "neutral.text.muted",
whiteSpace: "nowrap",
});
export default function LooksDemo() {
const [look, setLook] = useState(["plain"] as string[]);
const [density, setDensity] = useState(["default"] as string[]);
function lookOf(value: string | undefined) {
return value === "divided" || value === "cards" ? value : "plain";
}
function densityOf(value: string | undefined) {
return value === "compact" || value === "relaxed" ? value : "default";
}
return (
<div className={stack({ gap: "sm", width: "100%" })}>
<div className={toolbarCls}>
<Choice
type="radio"
appearance="button"
size="sm"
aria-label="Look"
value={look}
onValueChange={setLook}
>
<ChoiceItem value="plain" label="Plain" />
<ChoiceItem value="divided" label="Divided" />
<ChoiceItem value="cards" label="Cards" />
</Choice>
<Choice
type="radio"
appearance="button"
size="sm"
aria-label="Density"
value={density}
onValueChange={setDensity}
>
<ChoiceItem value="compact" label="Compact" />
<ChoiceItem value="default" label="Default" />
<ChoiceItem value="relaxed" label="Relaxed" />
</Choice>
</div>
<GridList
label="Tasks"
items={tasks}
getId={(task: Task) => task.id}
selectable
look={lookOf(look[0])}
density={densityOf(density[0])}
columns={[
{ id: "title", track: "minmax(0, 1fr)" },
{ id: "assignee", track: "auto" },
{ id: "due", track: "auto" },
]}
row={(task, ctx) => (
<>
<span {...ctx.cell("title")}>
<a {...ctx.primaryProps} href={`#${task.id}`}>
{task.title}
</a>
</span>
<span {...ctx.cell("assignee", { class: mutedCls })}>
{task.assignee}
</span>
<span {...ctx.cell("due", { class: dueCls })}>{task.due}</span>
</>
)}
/>
</div>
);
}The inset look, on a settings list with an icon column, a value and a chevron. The chevron is plain row content, not part of the look:
Each group is a card on the surface.
---
// Hand-written: `packages/demos/grid-list/inset.demo.tsx` skips Astro (see
// its `skip`) — Astro GridList takes its rows through one default-slot
// callback, not a `row` render prop.
import GridList, { type GridListRender } from "@pindoba/astro-grid-list";
import Stamp from "@pindoba/astro-stamp";
import {
Bell,
Bluetooth,
ChevronRight,
Lock,
Moon,
Shield,
Wifi,
} from "@lucide/astro";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
interface Setting {
id: string;
label: string;
value: string;
icon: typeof Wifi;
tone: "primary" | "success" | "danger" | "neutral";
group: string;
}
// Items arrive sorted by group: each group is one inset card.
const settings: Setting[] = [
{
id: "wifi",
label: "Wi-Fi",
value: "Studio 5G",
icon: Wifi,
tone: "primary",
group: "Connections",
},
{
id: "bluetooth",
label: "Bluetooth",
value: "On",
icon: Bluetooth,
tone: "primary",
group: "Connections",
},
{
id: "notifications",
label: "Notifications",
value: "",
icon: Bell,
tone: "danger",
group: "Alerts",
},
{
id: "focus",
label: "Focus",
value: "Work",
icon: Moon,
tone: "neutral",
group: "Alerts",
},
{
id: "passcode",
label: "Passcode",
value: "",
icon: Lock,
tone: "success",
group: "Security",
},
{
id: "privacy",
label: "Privacy",
value: "",
icon: Shield,
tone: "primary",
group: "Security",
},
];
const labels = Object.fromEntries(settings.map((s) => [s.id, s.label]));
const valueCls = css({
justifyContent: "flex-end",
color: "neutral.text.muted",
whiteSpace: "nowrap",
});
const chevronCls = css({ color: "neutral.text.muted" });
const logCls = css({
margin: "0",
textStyle: "body.sm",
color: "neutral.text.muted",
});
---
<div
class={stack({ gap: "sm", width: "100%" })}
data-demo-grid-list-settings
data-labels={JSON.stringify(labels)}
>
<GridList
label="Settings"
look="inset"
items={settings}
getId={(setting: Setting) => setting.id}
groupOf={(setting: Setting) => setting.group}
columns={[
{ id: "icon", track: "auto" },
{ id: "label", track: "minmax(0, 1fr)" },
{ id: "value", track: "auto" },
{ id: "chevron", track: "auto" },
]}
>
{(ctx: GridListRender<Setting>) =>
ctx.region === "row" ? (
<Fragment>
<span {...ctx.cell("icon")}>
<Stamp size="sm" emphasis="primary" tone={ctx.item.tone}>
<ctx.item.icon size={14} />
</Stamp>
</span>
<span {...ctx.cell("label")}>
<a {...ctx.primaryProps} href={`#${ctx.item.id}`}>
{ctx.item.label}
</a>
</span>
<span {...ctx.cell("value", { class: valueCls })}>
{ctx.item.value}
</span>
<span {...ctx.cell("chevron", { class: chevronCls })}>
<ChevronRight size={16} />
</span>
</Fragment>
) : null
}
</GridList>
<p class={logCls} aria-live="polite" data-demo-log>
Each group is a card on the surface.
</p>
</div>
<script>
import { GRID_LIST_EVENTS } from "@pindoba/astro-grid-list/runtime";
for (const scope of document.querySelectorAll<HTMLElement>(
"[data-demo-grid-list-settings]",
)) {
const labels = new Map<string, string>(
Object.entries(JSON.parse(scope.dataset.labels ?? "{}")),
);
const log = scope.querySelector("[data-demo-log]");
const opened = (id: string): void => {
if (log) log.textContent = `Opened ${labels.get(id) ?? id}`;
};
// The primary link is a demo anchor: open in place instead.
scope.addEventListener("click", (event) => {
const link =
event.target instanceof Element
? event.target.closest<HTMLAnchorElement>("a[href^='#']")
: null;
if (!link) return;
event.preventDefault();
opened(link.hash.slice(1));
});
scope.addEventListener(GRID_LIST_EVENTS.open, (event) => {
if (event instanceof CustomEvent && typeof event.detail?.id === "string")
opened(event.detail.id);
});
}
</script>rowBackground sets the resting fill of a cards or inset row, on Panel’s
surface ladder (surface.peak … surface.ground, surface.base by
default). Hover, the current row, selection and strong rows still paint over
it. The other looks have no resting fill.
card renders the list inside its own Card. It takes the Card’s surface
props, plus a heading, a subheading, header actions (trailing) and a
footer. Without it, the list is bare. The rows pad to the card’s row inset,
one step further in than the card’s own padding, and the card’s header and
footer text line up with them. divided rows run from edge to edge, like a
table’s. The rounded looks keep their fills a gutter inside the card’s edge.
Without a label, the card heading names the grid. A framed list that is the
only content of another Card blends into it, as a Table does.
// GENERATED from packages/demos/grid-list/card.demo.tsx — edit the source, not this file.
import { useState } from "react";
import { GridList } from "@pindoba/react-grid-list";
import { Button } from "@pindoba/react-button";
import { Choice, ChoiceItem } from "@pindoba/react-choice";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
interface Task {
id: string;
title: string;
assignee: string;
due: string;
}
const tasks = [
{ id: "t1", title: "Write the release notes", assignee: "Inês", due: "Mon" },
{ id: "t2", title: "Review the pricing page", assignee: "Kofi", due: "Tue" },
{ id: "t3", title: "Fix the export timeout", assignee: "Yuna", due: "Wed" },
{ id: "t4", title: "Plan the offsite", assignee: "Tomás", due: "Fri" },
];
const mutedCls = css({ color: "neutral.text.muted" });
const dueCls = css({
justifyContent: "flex-end",
color: "neutral.text.muted",
whiteSpace: "nowrap",
});
export default function CardDemo() {
const [look, setLook] = useState(["divided"] as string[]);
function lookOf(value: string | undefined) {
return value === "plain" || value === "cards" ? value : "divided";
}
const cardTrailing0 = (
<Button size="sm" emphasis="secondary">
New task
</Button>
);
const cardTrailing1 = (
<Button size="sm" emphasis="ghost">
View all tasks
</Button>
);
return (
<div className={stack({ gap: "sm", width: "100%" })}>
<Choice
type="radio"
appearance="button"
size="sm"
aria-label="Look"
value={look}
onValueChange={setLook}
>
<ChoiceItem value="divided" label="Divided" />
<ChoiceItem value="plain" label="Plain" />
<ChoiceItem value="cards" label="Cards" />
</Choice>
<GridList
items={tasks}
getId={(task: Task) => task.id}
selectable
header
look={lookOf(look[0])}
card={{
heading: "This week",
subheading: "4 open tasks",
trailing: cardTrailing0,
footer: {
trailing: cardTrailing1,
},
}}
columns={[
{ id: "title", track: "minmax(0, 1fr)", header: "Task" },
{ id: "assignee", track: "auto", header: "Assignee" },
{ id: "due", track: "auto", header: "Due" },
]}
row={(task, ctx) => (
<>
<span {...ctx.cell("title")}>
<a {...ctx.primaryProps} href={`#${task.id}`}>
{task.title}
</a>
</span>
<span {...ctx.cell("assignee", { class: mutedCls })}>
{task.assignee}
</span>
<span {...ctx.cell("due", { class: dueCls })}>{task.due}</span>
</>
)}
/>
</div>
);
}layout="grid" lays the items out as tiles that wrap, as many per line as
fit tileSize (default 16rem). Each tile is one gridcell holding the
row render prop’s markup. A grid has no column template, header or fold,
and its tiles take the plain and cards looks (the others draw as
plain). Groups render as full-width titles over each block of tiles, and
the checkbox sits over the tile’s corner. Without a header, put select-all
under the tiles with selectAll="footer". ← / → move to the previous /
next tile, and ↑ / ↓ move to the nearest tile in the line above or below,
measured on screen, so wraps and group titles in between work. Drag and drop
works in two dimensions: the marker stands upright between two tiles.
Hover a tile for its checkbox; click a picture to open it.
---
// Hand-written: `packages/demos/grid-list/gallery.demo.tsx` skips Astro (see
// its `skip`) — Astro GridList takes its tiles through one default-slot
// callback, not a `row` render prop.
import GridList, { type GridListRender } from "@pindoba/astro-grid-list";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
interface Picture {
id: string;
title: string;
meta: string;
seed: string;
collection: string;
}
// Items arrive sorted by collection: each run is a section under its title.
const pictures: Picture[] = [
{
id: "p1",
title: "Morning fog",
meta: "Sintra · 4032 × 3024",
seed: "pindoba-fog",
collection: "Landscapes",
},
{
id: "p2",
title: "Salt flats",
meta: "Uyuni · 6000 × 4000",
seed: "pindoba-salt",
collection: "Landscapes",
},
{
id: "p3",
title: "Pine ridge",
meta: "Gerês · 4032 × 3024",
seed: "pindoba-pine",
collection: "Landscapes",
},
{
id: "p4",
title: "Low tide",
meta: "Nazaré · 5472 × 3648",
seed: "pindoba-tide",
collection: "Landscapes",
},
{
id: "p5",
title: "Tram 28",
meta: "Lisbon · 4032 × 3024",
seed: "pindoba-tram",
collection: "Streets",
},
{
id: "p6",
title: "Night market",
meta: "Taipei · 6000 × 4000",
seed: "pindoba-market",
collection: "Streets",
},
{
id: "p7",
title: "Rooftops",
meta: "Porto · 5472 × 3648",
seed: "pindoba-roofs",
collection: "Streets",
},
];
const titles = Object.fromEntries(pictures.map((p) => [p.id, p.title]));
const imageCls = css({
display: "block",
width: "100%",
aspectRatio: "4 / 3",
objectFit: "cover",
borderRadius: "sm",
background: "neutral.surface.valley",
});
const titleCls = css({ fontWeight: "medium", truncate: true });
const metaCls = css({
color: "neutral.text.muted",
textStyle: "caption",
fontVariantNumeric: "tabular-nums",
truncate: true,
});
const logCls = css({
margin: "0",
textStyle: "body.sm",
color: "neutral.text.muted",
});
---
<div
class={stack({ gap: "sm", width: "100%" })}
data-demo-grid-list-gallery
data-titles={JSON.stringify(titles)}
>
<GridList
label="Gallery"
layout="grid"
tileSize="18rem"
items={pictures}
getId={(picture: Picture) => picture.id}
groupOf={(picture: Picture) => picture.collection}
selectable
selectAll="footer"
>
{(ctx: GridListRender<Picture>) =>
ctx.region === "row" ? (
<Fragment>
<a
{...ctx.primaryProps}
href={`#${ctx.item.id}`}
aria-label={ctx.item.title}
/>
<img
class={imageCls}
src={`https://picsum.photos/seed/${ctx.item.seed}/400/300`}
alt=""
loading="lazy"
width="400"
height="300"
/>
<span class={titleCls}>{ctx.item.title}</span>
<span class={metaCls}>{ctx.item.meta}</span>
</Fragment>
) : null
}
</GridList>
<p class={logCls} aria-live="polite" data-demo-log>
Hover a tile for its checkbox; click a picture to open it.
</p>
</div>
<script>
import { GRID_LIST_EVENTS } from "@pindoba/astro-grid-list/runtime";
for (const scope of document.querySelectorAll<HTMLElement>(
"[data-demo-grid-list-gallery]",
)) {
const titles = new Map<string, string>(
Object.entries(JSON.parse(scope.dataset.titles ?? "{}")),
);
const log = scope.querySelector("[data-demo-log]");
const opened = (id: string): void => {
if (log) log.textContent = `Opened ${titles.get(id) ?? id}`;
};
// The primary link is a demo anchor: open in place instead.
scope.addEventListener("click", (event) => {
const link =
event.target instanceof Element
? event.target.closest<HTMLAnchorElement>("a[href^='#']")
: null;
if (!link) return;
event.preventDefault();
opened(link.hash.slice(1));
});
scope.addEventListener(GRID_LIST_EVENTS.open, (event) => {
if (event instanceof CustomEvent && typeof event.detail?.id === "string")
opened(event.detail.id);
});
}
</script>selectionBehavior="replace" is the photo-library model. A click selects
only that item, ⌘ / Ctrl-click toggles it, and Shift-click selects the
range from the anchor. The arrows move the selection with the focus (Shift
extends it, ⌘ / Ctrl moves the focus alone). There are no checkboxes. A
selected tile shows a check badge instead (in the rows layout, only the
selected fill shows). On touch, a tap or a 500ms long press switches the list
to toggling until the selection is empty; every tile then shows an empty
ring.
---
// Hand-written: `packages/demos/grid-list/photo-library.demo.tsx` skips
// Astro (see its `skip`) — Astro GridList takes its tiles through one
// default-slot callback, not a `row` render prop. The script below follows
// the selection (`grid-list:valuechange`) and clears it through the handle.
import GridList, { type GridListRender } from "@pindoba/astro-grid-list";
import Button from "@pindoba/astro-button";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
interface Photo {
id: string;
seed: string;
day: string;
taken: string;
}
const days = ["Today", "Yesterday", "Monday"];
// Items arrive sorted by day: each run is a section under its title.
const library: Photo[] = Array.from({ length: 18 }, (_, i) => ({
id: `photo-${i + 1}`,
seed: `pindoba-photo-${i + 1}`,
day: days[Math.floor(i / 6)] ?? "Monday",
taken: `${days[Math.floor(i / 6)] ?? "Monday"}, photo ${(i % 6) + 1}`,
}));
const imageCls = css({
display: "block",
width: "100%",
aspectRatio: "1",
objectFit: "cover",
borderRadius: "xs",
background: "neutral.surface.valley",
});
const toolbarCls = css({
display: "flex",
alignItems: "center",
gap: "sm",
textStyle: "body.sm",
color: "neutral.text.muted",
});
---
<div class={stack({ gap: "sm", width: "100%" })} data-demo-grid-list-photos>
<div class={toolbarCls}>
<Button size="sm" emphasis="secondary" disabled data-demo-clear>
Clear
</Button>
<span aria-live="polite" data-demo-log>
Click a photo; ⌘/Ctrl-click or Shift-click for more.
</span>
</div>
<GridList
label="Photos"
layout="grid"
tileSize="9rem"
items={library}
getId={(photo: Photo) => photo.id}
groupOf={(photo: Photo) => photo.day}
selectable
selectionBehavior="replace"
>
{(ctx: GridListRender<Photo>) =>
ctx.region === "row" ? (
<img
class={imageCls}
src={`https://picsum.photos/seed/${ctx.item.seed}/200/200`}
alt={ctx.item.taken}
loading="lazy"
draggable="false"
width="200"
height="200"
/>
) : null
}
</GridList>
</div>
<script>
import {
GRID_LIST_EVENTS,
getGridList,
gridListHydration,
} from "@pindoba/astro-grid-list/runtime";
for (const scope of document.querySelectorAll<HTMLElement>(
"[data-demo-grid-list-photos]",
)) {
gridListHydration.boot(scope);
const root = scope.querySelector("[data-grid-list-config]");
const log = scope.querySelector("[data-demo-log]");
const clear = scope.querySelector<HTMLButtonElement>("[data-demo-clear]");
const render = (count: number): void => {
if (clear) clear.disabled = count === 0;
if (log)
log.textContent =
count === 0
? "Click a photo; ⌘/Ctrl-click or Shift-click for more."
: `${count} selected`;
};
scope.addEventListener(GRID_LIST_EVENTS.valueChange, () =>
render(getGridList(root)?.value().size ?? 0),
);
clear?.addEventListener("click", () => {
getGridList(root)?.setValue([]);
render(0);
});
}
</script>Paging is opt-in: set pageSize and the list shows one page at a time, with
a Pagination under it. page is zero-based; bind it
(bind:page) or follow onPageChange(page). pageSizeOptions adds the
rows-per-page select (without it there is none): picking a size calls
onPageSizeChange(size) and goes back to the first page. labels.pagination
localizes the pager. Without pageSize, every row shows, as before.
Page 1 · 5 per page
---
// Hand-written: `packages/demos/grid-list/pagination.demo.tsx` skips Astro
// (see its `skip`) — Astro GridList takes its rows through one default-slot
// callback, not a `row` render prop. Every page renders on the server; the
// runtime pages on the client and the script below follows its events.
import GridList, { type GridListRender } from "@pindoba/astro-grid-list";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
interface Task {
id: string;
title: string;
owner: string;
due: string;
}
const titles = [
"Draft the release notes",
"Review the onboarding copy",
"Fix the export timeout",
"Plan the Q4 roadmap review",
"Update the billing FAQ",
"Audit unused feature flags",
"Write the migration guide",
"Triage the support backlog",
"Refresh the pricing page",
"Rotate the staging keys",
"Prepare the board summary",
"Test the new invoice layout",
"Book the team offsite",
"Archive last year's projects",
"Tune the search ranking",
"Collect beta feedback",
"Rename the legacy endpoints",
"Sketch the empty states",
"Measure the cold start",
"Clean up the design tokens",
"Interview two candidates",
"Ship the dark mode fixes",
"Close the stale pull requests",
];
const owners = ["Inês", "Kofi", "Yuna", "Tomás", "Amara"];
const tasks: Task[] = titles.map((title, i) => ({
id: `t${i + 1}`,
title,
owner: owners[i % owners.length] ?? "",
due: `Nov ${(i % 28) + 1}`,
}));
const titleCls = css({ truncate: true });
const mutedCls = css({
color: "neutral.text.muted",
textStyle: "caption",
whiteSpace: "nowrap",
});
const logCls = css({
margin: "0",
textStyle: "body.sm",
color: "neutral.text.muted",
});
---
<div class={stack({ gap: "sm", width: "100%" })} data-demo-grid-list-pages>
<GridList
label="Tasks"
items={tasks}
getId={(task: Task) => task.id}
selectable
header
page={0}
pageSize={5}
pageSizeOptions={[5, 10, 20]}
columns={[
{ id: "title", track: "minmax(0, 1fr)", header: "Task" },
{ id: "owner", track: "minmax(6rem, 10rem)", header: "Owner" },
{ id: "due", track: "auto", header: "Due" },
]}
>
{(ctx: GridListRender<Task>) =>
ctx.region === "row" ? (
<Fragment>
<span {...ctx.cell("title", { class: titleCls })}>
{ctx.item.title}
</span>
<span {...ctx.cell("owner")}>{ctx.item.owner}</span>
<span {...ctx.cell("due", { class: mutedCls })}>{ctx.item.due}</span>
</Fragment>
) : null
}
</GridList>
<p class={logCls} aria-live="polite" data-demo-log>
Page 1 · 5 per page
</p>
</div>
<script>
import { GRID_LIST_EVENTS } from "@pindoba/astro-grid-list/runtime";
for (const scope of document.querySelectorAll<HTMLElement>(
"[data-demo-grid-list-pages]",
)) {
const log = scope.querySelector("[data-demo-log]");
let page = 0;
let pageSize = 5;
const render = (): void => {
if (log) log.textContent = `Page ${page + 1} · ${pageSize} per page`;
};
scope.addEventListener(GRID_LIST_EVENTS.pageChange, (event) => {
if (!(event instanceof CustomEvent)) return;
if (typeof event.detail?.page === "number") page = event.detail.page;
render();
});
scope.addEventListener(GRID_LIST_EVENTS.pageSizeChange, (event) => {
if (!(event instanceof CustomEvent)) return;
if (typeof event.detail?.pageSize === "number")
pageSize = event.detail.pageSize;
render();
});
}
</script>items is the whole collection and the
list slices it to the page. If the items shrink past the page (a delete, a
row dragged out), the list moves to the last page and reports it through
onPageChange.itemsTotal, and items is the current page
already: the list doesn’t slice it, and the total drives the page count.
Fetch the next page from onPageChange.⌘A) covers every item on every page, collapsed
groups included, and its indeterminate state reflects the whole list. With
server paging (itemsTotal) the items on other pages aren’t loaded, so it
covers only the current page (“Select all on this page”) and leaves the rest
of the selection alone. Esc clears the whole selection.onMove’s from.index and
to.index are positions in the full list (the page’s index plus
page * pageSize), so the consumer moves the item in its whole array.
Rows move within the page; reaching another page takes the pager. The
offset comes from dropZone(list, { indexOffset }) in @pindoba/core-drag.A list in a fixed-height region (a dialog body, a side panel) scrolls best on
its own: scroll makes the list fill a flex column (flex: 1,
min-height: 0), and its header and rows scroll inside it. The column header
and group titles stick to the top of the scroll area, and a paged list’s
pager sits under it. Padding around the list, its own padding (on Panel’s
spacing scale, xs below; paddingX / paddingY set one axis) or its
container’s, stays outside the scroll area, so rows never scroll through it. It is all CSS, so the server render
matches.
--gl-scroll-gap (xs by default) keeps the rows off the scrollbar, and
only while the list actually scrolls: a list that fits keeps its full
width. Overlay scrollbars float over the content and reserve no gutter.
The switch is CSS (a scroll-driven animation on the list’s own scroll
timeline); engines without scroll-driven animations show no gap.--gl-scroll-end-inset adds scrolled room under the last row, for
something floating over the list (a BulkBar): the rows scroll
out from under it.A list of 30 tasks in a 360px-tall region:
---
// Hand-written: `packages/demos/grid-list/scroll.demo.tsx` skips Astro (see
// its `skip`) — Astro GridList takes its rows through one default-slot
// callback, not a `row` render prop. `scroll` is CSS only, so the server
// render scrolls the same way.
import GridList, { type GridListRender } from "@pindoba/astro-grid-list";
import { css } from "@pindoba/styled-system/css";
interface Task {
id: string;
title: string;
owner: string;
due: string;
status: string;
}
const owners = ["Inês Duarte", "Kofi Mensah", "Yuna Choi", "Tomás Rivera"];
const titles = [
"Draft the release notes",
"Review the onboarding copy",
"Fix the export timeout",
"Plan the Q3 roadmap",
"Audit unused feature flags",
"Update the support macros",
];
const statuses = ["In progress", "To do", "Done"];
// 30 tasks, sorted by status: each status is a sticky group.
const tasks: Task[] = Array.from({ length: 30 }, (_, i) => ({
id: `t${i}`,
title: titles[i % titles.length]!,
owner: owners[i % owners.length]!,
due: `Nov ${(i % 28) + 1}`,
status: statuses[Math.floor(i / 10)]!,
}));
// A fixed-height region (a side panel, a dialog body): a flex column the
// list fills.
const regionCls = css({
display: "flex",
flexDirection: "column",
width: "100%",
height: "360px",
});
const ownerCls = css({ color: "neutral.text.muted", truncate: true });
const dueCls = css({
justifyContent: "flex-end",
color: "neutral.text.muted",
textStyle: "caption",
whiteSpace: "nowrap",
});
---
<div class={regionCls}>
<GridList
label="Tasks"
items={tasks}
getId={(task: Task) => task.id}
groupOf={(task: Task) => task.status}
selectable
header
scroll
padding="xs"
columns={[
{ id: "title", track: "minmax(0, 1fr)", header: "Task" },
{ id: "owner", track: "minmax(8rem, auto)", header: "Owner" },
{ id: "due", track: "auto", header: "Due" },
]}
>
{(ctx: GridListRender<Task>) =>
ctx.region === "row" ? (
<Fragment>
<span {...ctx.cell("title")}>{ctx.item.title}</span>
<span {...ctx.cell("owner", { class: ownerCls })}>
{ctx.item.owner}
</span>
<span {...ctx.cell("due", { class: dueCls })}>{ctx.item.due}</span>
</Fragment>
) : null
}
</GridList>
</div>virtual renders only the rows in view, plus a few past each end, so a list
of thousands of rows stays light. Rows are measured as they render, so they
can differ in height: a card with a second line, a row that wraps. Spacers
stand in for the rows that aren’t rendered.
virtual on its own scrolls the list’s own grid, so
give the grid a height (passThrough.grid.style, a maxHeight, as below).
To scroll with an element around the list (a dialog body, a scroll well),
pass it: virtual={{ scrollElement: () => wellElement }}. The sticky
column header and group titles stick inside the port either way.estimateSize is a row’s height before it is measured: a number, or
one per item ((item) => …). It defaults to the density’s row height
(36 / 44 / 48px). It only needs to be close. overscan sets how many rows
render past each end (default 6).↑ / ↓, Home / End move to rows that aren’t rendered yet: the list
renders the target and focuses it, which scrolls it into view. The focused
row always stays rendered, so the list’s Tab stop never disappears. Shift
ranges, ⌘A, select-all and disabledIds cover every item.virtual={{ onVisibleRangeChange }} reports the
rows on screen as they change, overscan left out: startIndex and
endIndex into items, and the rows’ ids. Use it for work worth doing
only for what the user sees, such as fetching per-row details.aria-rowcount (every row, the header
and group titles included) and each rendered row its aria-rowindex, so
screen readers announce the full size.// GENERATED from packages/demos/grid-list/virtual.demo.tsx — edit the source, not this file.
import { GridList } from "@pindoba/react-grid-list";
import { css } from "@pindoba/styled-system/css";
interface Commit {
id: string;
hash: string;
message: string;
body: string;
author: string;
day: string;
}
const authors = ["Inês Duarte", "Kofi Mensah", "Yuna Choi", "Tomás Rivera"];
const subjects = [
"Fix the empty state's spacing",
"Add keyboard shortcuts to the toolbar",
"Bump dependencies",
"Rename the export button",
"Cache the search index",
];
const commits = Array.from({ length: 1000 }, (_, i) => ({
id: `c${i}`,
hash: (0x9f3a2c + i * 7919).toString(16).slice(0, 7),
message: `${subjects[i % subjects.length]} (#${4000 - i})`,
body: i % 3 === 0 ? "Also updates the snapshots and the changelog." : "",
author: authors[i % authors.length]!,
day: `Day ${40 - Math.floor(i / 25)}`,
}));
const gridCls = css.raw({ maxHeight: "480px" });
const textCls = css({
display: "flex",
flexDirection: "column",
minWidth: "0",
});
const bodyCls = css({ color: "neutral.text.muted", truncate: true });
const metaCls = css({
justifyContent: "flex-end",
color: "neutral.text.muted",
textStyle: "caption",
fontFamily: "mono",
whiteSpace: "nowrap",
});
export default function VirtualDemo() {
return (
<GridList
label="Commits"
items={commits}
getId={(commit: Commit) => commit.id}
groupOf={(commit: Commit) => commit.day}
selectable
header
virtual
passThrough={{ grid: { style: gridCls } }}
columns={[
{ id: "message", track: "minmax(0, 1fr)", header: "Commit" },
{ id: "author", track: "minmax(8rem, auto)", header: "Author" },
{ id: "hash", track: "auto", srHeader: "Hash" },
]}
row={(commit, ctx) => (
<>
<span {...ctx.cell("message")}>
<span className={textCls}>
<a {...ctx.primaryProps} href={`#${commit.id}`}>
{commit.message}
</a>
{commit.body && <span className={bodyCls}>{commit.body}</span>}
</span>
</span>
<span {...ctx.cell("author")}>{commit.author}</span>
<span {...ctx.cell("hash", { class: metaCls })}>{commit.hash}</span>
</>
)}
/>
);
}Not supported yet (virtual is ignored and every row renders):
layout="grid"): tiles need lanes (several items per
visual row).drag): a drop needs every row in the DOM to land
between.items yourself; the window follows.Pass drag={{ context, list }} to make rows reorderable. context comes
from createDragContext() in @pindoba/core-drag, and several lists that
share one context move rows between them. A drop reports one
onMove({ id, from, to }), with { list, index } for both ends, and your
code moves the item. The list never reorders its own data.
import { createDragContext } from "@pindoba/core-drag";
const drag = createDragContext({
type: "task",
onMove: ({ id, from, to }) => moveTask(id, from, to),
nameOf: (id) => titleOf(id), // used in announcements
listName: (list) => listLabel[list],
});
$effect(() => () => drag.destroy()); // Svelte; React: a useEffect cleanup
// Hand-written: `packages/demos/grid-list/drag.demo.tsx` skips React (see its
// `skip`) — the drag context must be destroyed on unmount, which the demo
// vocabulary can't express. Two lists sharing one drag context: a drop
// (pointer, touch or Alt+arrows) reports one `{ id, from, to }`; the demo
// moves the task.
import { useEffect, useRef, useState } from "react";
import { Avatar } from "@pindoba/react-avatar";
import { GridList } from "@pindoba/react-grid-list";
import {
createDragContext,
type DragContext,
type DragMove,
} from "@pindoba/core-drag";
import { css } from "@pindoba/styled-system/css";
interface Task {
id: string;
title: string;
owner: string;
}
const lists = ["todo", "done"] as const;
type List = (typeof lists)[number];
const listLabel: Record<List, string> = { todo: "To do", done: "Done" };
const isList = (list: string): list is List =>
(lists as readonly string[]).includes(list);
const initialBoard: Record<List, Task[]> = {
todo: [
{ id: "t1", title: "Write the release notes", owner: "Inês Duarte" },
{ id: "t2", title: "Review the pricing page", owner: "Kofi Mensah" },
{ id: "t3", title: "Fix the export timeout", owner: "Yuna Choi" },
],
// Starts short: a drop below the last row, or into an emptied list, still
// lands.
done: [{ id: "t4", title: "Plan the offsite", owner: "Tomás Rivera" }],
};
const boardCls = css({
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(24rem, 1fr))",
gap: "sm",
width: "100%",
});
const columnCls = css({
display: "flex",
flexDirection: "column",
gap: "2xs",
padding: "xs",
borderRadius: "lg",
background: "neutral.surface.base",
minHeight: "20rem",
// Sticky titles and the drag placeholder paint this surface.
"--panel-bg": "{colors.neutral.surface.base}",
});
const headCls = css({
paddingInline: "2xs",
textStyle: "body.sm",
fontWeight: "semibold",
color: "neutral.text.bold",
});
// The list fills the column, so the whole column body takes drops.
const listCls = css({
flex: "1",
display: "flex",
flexDirection: "column",
"& > [data-component=grid-list]": {
flex: "1",
display: "flex",
flexDirection: "column",
},
"& [data-grid-list-grid]": { flex: "1", alignContent: "start" },
});
const titleCls = css({ truncate: true });
const logCls = css({
margin: "0",
marginBlockStart: "xs",
textStyle: "body.sm",
color: "neutral.text.muted",
minHeight: "2.4rem",
});
export default function Drag() {
const [board, setBoard] = useState(initialBoard);
const [log, setLog] = useState("");
const [drag, setDrag] = useState<DragContext>();
// The context's callbacks outlive renders: they read the latest board.
const boardRef = useRef(board);
boardRef.current = board;
useEffect(() => {
function onMove({ id, from, to }: DragMove) {
if (!isList(from.list) || !isList(to.list)) return;
const current = boardRef.current;
const task = current[from.list].find((t) => t.id === id);
if (!task) return;
const source = current[from.list].filter((t) => t.id !== id);
const target = from.list === to.list ? source : [...current[to.list]];
target.splice(Math.min(to.index, target.length), 0, task);
const next = { ...current, [from.list]: source, [to.list]: target };
boardRef.current = next;
setBoard(next);
setLog(
`“${task.title}” moved to ${listLabel[to.list]}, position ${to.index + 1}`,
);
}
const context = createDragContext({
type: "task",
onMove,
nameOf: (id) =>
Object.values(boardRef.current)
.flat()
.find((t) => t.id === id)?.title ?? "task",
listName: (list) => (isList(list) ? listLabel[list] : list),
});
setDrag(context);
return () => context.destroy();
}, []);
return (
<>
<div className={boardCls}>
{lists.map((list) => (
<section
key={list}
className={columnCls}
aria-label={listLabel[list]}
>
<div className={headCls}>
{listLabel[list]} · {board[list].length}
</div>
<div className={listCls}>
<GridList
label={listLabel[list]}
items={board[list]}
getId={(t) => t.id}
selectable
selectOver="avatar"
look="cards"
density="compact"
drag={drag ? { context: drag, list } : undefined}
columns={[
{ id: "avatar", track: "auto" },
{ id: "title", track: "minmax(0, 1fr)" },
]}
row={(task, { cell, primaryProps }) => (
<>
<span {...cell("avatar")}>
<Avatar name={task.owner} size="xs" />
</span>
<span {...cell("title")}>
<a
{...primaryProps}
className={titleCls}
href={`#${task.id}`}
onClick={(e) => {
e.preventDefault();
setLog(`Opened “${task.title}”`);
}}
>
{task.title}
</a>
</span>
</>
)}
/>
</div>
</section>
))}
</div>
<p className={logCls} aria-live="polite">
{log || " "}
</p>
</>
);
}data-drag-through, so it
passes the drag on, and links and images lose their native drag in a
draggable list.Alt+↑ / Alt+↓ moves it one slot within
its list, and Alt+← / Alt+→ moves it to the previous or next list, at
the same row number. Focus follows the row. A live region announces the
move, for example “Moved Plan the offsite to position 1 in Done.” (nameOf and listName supply the names) Rows
advertise these keys with aria-keyshortcuts and describe them with
aria-description.groupOf, drag={{ context, list, groups: true }} makes each group a drop list of its own. A drop under another
group’s title moves the item into that group: onMove reports the group as
to.list (with the position within the group) and the list’s id as
to.zone. Your code changes the item’s group. Keyboard moves cross into the
next group at a group’s ends. With client paging, to.index also counts
the group’s items on earlier pages.@pindoba/core-drag is the only package that imports
@dnd-kit/dom (about 36 KB gzip). Import it only in lists that enable drag.
Call destroy() when the lists unmount: it removes the manager and the
live region.Groups as drop lists, on one list of orders grouped by status: drag an order
under another status, or press Alt+↓ on a group’s last order.
// Hand-written: `packages/demos/grid-list/drag-groups.demo.tsx` skips React
// (see its `skip`) — the drag context must be destroyed on unmount, which the
// demo vocabulary can't express. One list grouped by status, each group a
// drop list (`drag.groups`): a drop under another group's title moves the
// order there, and Alt+↑/↓ crosses into the next group at a group's end.
// `to.list` is the target group and `to.index` the position within it.
import { useEffect, useRef, useState } from "react";
import { GridList } from "@pindoba/react-grid-list";
import {
createDragContext,
type DragContext,
type DragMove,
} from "@pindoba/core-drag";
import { css } from "@pindoba/styled-system/css";
interface Order {
id: string;
number: string;
customer: string;
status: string;
}
const initialOrders: Order[] = [
{ id: "o1", number: "#3184", customer: "Inês Duarte", status: "To pack" },
{ id: "o2", number: "#3183", customer: "Kofi Mensah", status: "To pack" },
{ id: "o3", number: "#3181", customer: "Yuna Choi", status: "To pack" },
{
id: "o4",
number: "#3179",
customer: "Tomás Rivera",
status: "Ready to ship",
},
{
id: "o5",
number: "#3176",
customer: "Amara Okafor",
status: "Ready to ship",
},
{ id: "o6", number: "#3170", customer: "Lena Fischer", status: "Shipped" },
];
// Items stay sorted by group: the order lands before the target group's
// order at that position, else after its last.
function applyMove(orders: Order[], { id, to }: DragMove): Order[] {
const order = orders.find((o) => o.id === id);
if (!order) return orders;
const rest = orders.filter((o) => o.id !== id);
const members = rest.filter((o) => o.status === to.list);
const before = members[to.index];
const last = members.at(-1);
const at = before
? rest.indexOf(before)
: last
? rest.indexOf(last) + 1
: orders.indexOf(order);
rest.splice(at, 0, { ...order, status: to.list });
return rest;
}
const numberCls = css({
color: "neutral.text.muted",
fontVariantNumeric: "tabular-nums",
});
const logCls = css({
margin: "0",
marginBlockStart: "xs",
textStyle: "body.sm",
color: "neutral.text.muted",
minHeight: "2.4rem",
});
export default function DragGroups() {
const [orders, setOrders] = useState(initialOrders);
const [log, setLog] = useState("");
const [drag, setDrag] = useState<DragContext>();
// The context's callbacks outlive renders: they read the latest orders.
const ordersRef = useRef(orders);
ordersRef.current = orders;
useEffect(() => {
const context = createDragContext({
type: "order",
onMove: (move) => {
const order = ordersRef.current.find((o) => o.id === move.id);
if (!order) return;
const next = applyMove(ordersRef.current, move);
ordersRef.current = next;
setOrders(next);
setLog(
`${order.number} moved to ${move.to.list}, position ${move.to.index + 1}`,
);
},
nameOf: (id) =>
ordersRef.current.find((o) => o.id === id)?.number ?? "order",
listName: (list) => list,
});
setDrag(context);
return () => context.destroy();
}, []);
return (
<>
<GridList
label="Orders"
items={orders}
getId={(o) => o.id}
groupOf={(o) => o.status}
look="cards"
density="compact"
drag={
drag ? { context: drag, list: "orders", groups: true } : undefined
}
columns={[
{ id: "customer", track: "minmax(0, 1fr)" },
{ id: "number", track: "auto" },
]}
row={(order, { cell, primaryProps }) => (
<>
<span {...cell("customer")}>
<a
{...primaryProps}
href={`#${order.id}`}
onClick={(e) => {
e.preventDefault();
setLog(`Opened ${order.number}`);
}}
>
{order.customer}
</a>
</span>
<span {...cell("number", { class: numberCls })}>
{order.number}
</span>
</>
)}
/>
<p className={logCls} aria-live="polite">
{log || " "}
</p>
</>
);
}role="grid" named by label, with role="row" rows and
role="gridcell" cells. Header cells are columnheaders, and a group’s
title band is a rowheader with its chevron button (aria-expanded, named
“Collapse Orders” / “Expand Orders”).aria-multiselectable on the grid and aria-selected on each row. A
column header that is only an icon can take an srHeader label for screen
readers.tabindex="-1"), and
→ / F2 reach them (see Cell navigation). Controls
outside the rows stay in the Tab order: the header’s select-all checkbox,
the group titles’ chevrons and trailing actions, and the footer.label and announces its shortcut (aria-keyshortcuts), and
the ⋯ button (“More actions”, labels.moreActions) opens a menu. The
same menu opens from the keyboard with Shift+F10 or the ContextMenu key.labels, for
translation. Drag announcements come from the drag context’s own labels.rowName={(item) => …} so each row’s checkbox
says which row it selects (“Select web”, from labels.selectRowNamed).
Without it every checkbox is “Select row” (labels.selectRow), which
screen readers can only tell apart by the row around it.Every framework spreads the same connectGridList bags; only the way rows
and two-way state reach the list differs.
| Framework | Row content | Selection, page, page size |
|---|---|---|
| Svelte | {#snippet row(item, { cell, primaryProps, selected })}, groupLeading / groupTrailing | bind:value, bind:page, bind:pageSize |
| React | row={(item, ctx) => …}, groupLeading / groupTrailing render props | value + onValueChange, page + onPageChange, pageSize + onPageSizeChange |
| Vue | :row="(item, ctx) => h(…)", or the scoped slot #row="{ item, cell, primaryProps }" | v-model:value (or onValueChange), v-model:page, v-model:pageSize |
| Solid | row={(item, ctx) => …} (the ctx fields update in place) | value + onValueChange, page + onPageChange, pageSize + onPageSizeChange |
| Astro | One default-slot callback for rows and group titles, told apart by ctx.region | value / defaultValue render; then getGridList(el), grid-list:valuechange / pagechange |
In React, Vue and Solid, ctx.cell() and primaryProps come back already
normalized for the framework (className in React), so a row spreads them
directly. Drag needs a createDragContext() per mount, destroyed on unmount
(a useEffect cleanup, onBeforeUnmount, onSettled’s cleanup).
Astro renders every row on the server, including the rows of other pages,
which wait in a <template> for client paging. The runtime
(@pindoba/astro-grid-list/runtime) then runs selection, keyboard, groups and
paging with the same core. Content is rendered once, so a group title’s
selectedCount is the server’s. Read the live selection from
getGridList(el)?.value() instead. Drag needs a context, which exists only
in the browser: pass it with getGridList(el)?.update({ drag: { list, context } }) and apply each onMove with update({ items }). The drag demos
have no Astro tab for that reason, and neither does the looks switcher, whose
controls change the list’s structure props after load.
Panda with jsxFramework. Once a project’s panda.config sets
jsxFramework, Panda reads the props of every capitalised component as
style props, so it can follow styled() wrappers. columns is also a CSS
property, so an inline columns={[…]} makes Panda warn (“columns in
columns.base.id is not a condition or selector”) and emit a rule that does
nothing. Set jsxStyleProps: "minimal" in that config: Panda then reads only
the css prop. A Svelte project loses nothing by it, since Panda generates
no style-prop components for Svelte.