Component explorer / Collections
TileGrid: folders and devices
Explore square folder tiles and landscape device cards, with shared selection, paging and editable records.
Links retain the controls above the preview. Grid-header customizations, sample edits and selected keys reset on reload. The folders are an illustrative catalogue, not a filesystem connection.
Code for this example
These excerpts come from the code running above, formatted for reading. They focus on the component behaviour; imports, fixtures and page setup are available in Full application source below.
Provide accessible text and custom tile templates
presentTile describes each record for accessibility; renderTile supplies the folder or device presentation. The same outer tile shell handles focus and selection.
From scripts/site/playground/collection-demo.js
tiles.toggleAttribute("data-folders", folder);
tiles.presentTile = row => ({
title: row.name, description: folder ? `${row.items} items, ${row.owner}` : `${row.platform}, ${row.online ? "Online" : "Offline"}`
});
tiles.renderTile = folder
? row => html `<svg class="folder-icon" viewBox="0 0 48 48" aria-hidden="true"><path d="M5 15V10h14l5 5h19v25H5z"/></svg><strong class="folder-name">${row.name}</strong><span class="folder-meta">${row.items} items · ${row.owner}</span>`
: row => html `<div class="device-card"><svg viewBox="0 0 48 48" aria-hidden="true"><rect x="5" y="6" width="38" height="28" rx="3"/><path d="M16 42h16M24 34v8"/></svg><strong>${row.name}</strong><span>${row.platform} · ${row.owner}</span><span class="device-state" ?data-online=${row.online}>${row.online ? "Online" : "Offline"}</span></div>`;
Control tile size through CSS variables
The size control writes sizing intent. The component owns responsive packing and virtualization.
From scripts/site/playground/collection-demo.js
const size = () => {
const folder = state.dataset === "folders";
const widths = folder ? {
compact: 140, standard: 164, large: 196
} : {
compact: 208, standard: 248, large: 288
};
tiles.style.setProperty("--tile-min-width", `${widths[state.size]}px`);
tiles.style.setProperty("--tile-height", `${folder ? widths[state.size] + 32 : 194}px`);
$("#size-control").hidden = state.view !== "tiles";
};
Share selection with the table view
Selection is attached to the collection rather than to individual rendered tiles.
From scripts/site/playground/collection-demo.js
function bindSelection() {
const selection = new Selection(ctx.store, collection(), {
multiple: state.selection !== "single", ordered: state.selection === "ordered",
...(state.selection === "ordered" ? {
maxOrdered: 3
} : {}), fallback: false
});
table.selection = tiles.selection = selection;
currentSelection.value = selection;
}
Full application source JavaScript, HTML & CSS
import { DataContext, EditModel, Selection, effect, html, render, signal } from "../lib/aster-ui.js";
import { createMockService } from "../lib/aster-ui-mock.js";
const $ = selector => document.querySelector(selector);
const status = message => { $("#status").textContent = message; };
const params = new URLSearchParams(location.search);
const oneOf = (value, options, fallback) => options.includes(value) ? value : fallback;
const state = {
dataset: oneOf(params.get("dataset"), ["devices", "folders"], "devices"),
view: oneOf(params.get("view"), ["table", "tiles"], "table"),
sort: oneOf(params.get("sort"), ["name", "name-desc"], "name"),
selection: oneOf(params.get("selection"), ["single", "multiple", "ordered"], "multiple"),
q: (params.get("q") ?? "").slice(0, 80),
platform: oneOf(params.get("platform"), ["", "macOS", "Linux", "Windows"], ""),
online: params.get("online") === "true",
size: oneOf(params.get("size"), ["compact", "standard", "large"], "standard"),
};
const metadata = { $Version: "4.01", $EntityContainer: "Explorer.Container",
$Reference: {
"https://oasis-tcs.github.io/odata-vocabularies/vocabularies/Org.OData.Core.V1.json": { $Include: [{ $Namespace: "Org.OData.Core.V1", $Alias: "Core" }] },
"https://oasis-tcs.github.io/odata-vocabularies/vocabularies/Org.OData.Validation.V1.json": { $Include: [{ $Namespace: "Org.OData.Validation.V1", $Alias: "Validation" }] },
"https://aster.dev/vocabularies/Aster.Data.V1.json": { $Include: [{ $Namespace: "Aster.Data.V1", $Alias: "AsterData" }] },
},
Explorer: {
Device: { $Kind: "EntityType", $Key: ["id"], id: { $Type: "Edm.Int32" }, name: { "@Org.OData.Core.V1.Description": "Name", $Nullable: false, "@AsterData.MinLength": 1 },
platform: { "@Org.OData.Core.V1.Description": "Platform", "@Validation.AllowedValues": ["macOS", "Linux", "Windows"].map(Value => ({ Value })) },
online: { $Type: "Edm.Boolean" }, owner: { "@Org.OData.Core.V1.Description": "Owner" } },
Folder: { $Kind: "EntityType", $Key: ["id"], id: { $Type: "Edm.Int32" }, name: { "@Org.OData.Core.V1.Description": "Name", $Nullable: false, "@AsterData.MinLength": 1 },
owner: { "@Org.OData.Core.V1.Description": "Owner" }, items: { $Type: "Edm.Int32" } },
Container: { $Kind: "EntityContainer", Devices: { $Collection: true, $Type: "Explorer.Device" }, Folders: { $Collection: true, $Type: "Explorer.Folder" } },
},
};
const names = ["Design MacBook", "Studio workstation", "Travel laptop", "Build server", "Office desktop", "Support laptop"];
const folders = ["Brand assets", "Client projects", "Design system", "Finance", "Photography", "Product research", "Release notes", "Workshop recordings"];
const data = {
Devices: Array.from({ length: 96 }, (_, i) => ({ id: i + 1, name: i < names.length ? names[i] : `Team device ${String(i + 1).padStart(3, "0")}`,
platform: ["macOS", "Linux", "Windows"][i % 3], online: i % 4 !== 1, owner: ["Design", "Engineering", "Operations"][i % 3] })),
Folders: Array.from({ length: 48 }, (_, i) => ({ id: i + 1, name: i < folders.length ? folders[i] : `Project ${String(i + 1).padStart(2, "0")}`,
owner: ["Design", "Engineering", "Operations"][i % 3], items: 8 + i * 3 })),
};
const choices = {
dataset: [ ["devices", "Device fleet"], ["folders", "Workspace folders"] ],
view: [ ["table", "Table"], ["tiles", "Tiles"] ],
sort: [ ["name", "Name, A–Z"], ["name-desc", "Name, Z–A"] ],
selection: [ ["single", "Single"], ["multiple", "Multiple"], ["ordered", "Ordered, maximum 3"] ],
platform: [ ["", "All platforms"], ["macOS", "macOS"], ["Linux", "Linux"], ["Windows", "Windows"] ],
size: [ ["compact", "Compact"], ["standard", "Standard"], ["large", "Large"] ],
};
const labels = { dataset: "Sample data", view: "Presentation", sort: "Sort", selection: "Selection", platform: "Platform", size: "Tile size" };
const control = name => name === "selection" ? $("#selection-mode") : $(`#${name}`);
for (const [name, options] of Object.entries(choices)) {
control(name).options = options.map(([value, label]) => ({ value, label }));
control(name).label = labels[name];
control(name).value = state[name];
control(name).inert = true;
}
$("#query").value = state.q;
$("#online").checked = state.online;
$("#query").disabled = $("#online").disabled = true;
const notify = () => {
const url = new URL(location.href);
for (const [key, value] of Object.entries(state)) url.searchParams.set(key, String(value));
history.replaceState(null, "", url);
if (parent !== window) parent.postMessage({ type: "aster-collection-state", state }, location.origin);
};
try {
const mock = await createMockService({ metadata, data, wasm: new URL("../lib/odata_mock.wasm", import.meta.url) });
const ctx = await DataContext.connect(mock.transport);
const collections = {
devices: ctx.collection({ set: "Devices" }, { pageSize: 24, keepPages: 3 }),
folders: ctx.collection({ set: "Folders" }, { pageSize: 24, keepPages: 3 }),
};
const table = document.createElement("aster-grid");
table.ctx = ctx;
const tiles = document.createElement("aster-tile-grid");
tiles.virtualized = true;
const active = signal(state.dataset);
const currentSelection = signal();
let model;
let stopEditor;
let busy = false;
const collection = () => collections[active.value];
const hasDraft = () => model !== undefined;
const query = () => {
const clauses = [];
if (state.q.trim()) clauses.push({ op: "contains", field: "name", value: state.q.trim() });
if (state.dataset === "devices") {
if (state.platform) clauses.push({ op: "eq", field: "platform", value: state.platform });
if (state.online) clauses.push({ op: "eq", field: "online", value: true });
}
const previous = collection().query;
collection().setQuery({ ...previous, filter: clauses.length ? { op: "and", args: clauses } : undefined,
orderBy: [{ field: "name", descending: state.sort === "name-desc" }] });
};
const size = () => {
const folder = state.dataset === "folders";
const widths = folder ? { compact: 140, standard: 164, large: 196 } : { compact: 208, standard: 248, large: 288 };
tiles.style.setProperty("--tile-min-width", `${widths[state.size]}px`);
tiles.style.setProperty("--tile-height", `${folder ? widths[state.size] + 32 : 194}px`);
$("#size-control").hidden = state.view !== "tiles";
};
const show = () => {
const next = state.view === "table" ? table : tiles;
if (next.parentElement !== $("#out")) $("#out").replaceChildren(next);
$("#platform-control").hidden = $(".online-control").hidden = state.dataset !== "devices";
$("#simulate").hidden = state.dataset !== "devices";
size();
};
function bindSelection() {
const selection = new Selection(ctx.store, collection(), { multiple: state.selection !== "single", ordered: state.selection === "ordered",
...(state.selection === "ordered" ? { maxOrdered: 3 } : {}), fallback: false });
table.selection = tiles.selection = selection;
currentSelection.value = selection;
}
function bindDataset() {
active.value = state.dataset;
const folder = state.dataset === "folders";
table.selection = tiles.selection = undefined;
table.collection = tiles.collection = collection();
table.label = folder ? "Workspace folders table" : "Device fleet table";
tiles.label = folder ? "Workspace folder tiles" : "Device fleet tiles";
table.view = { columns: folder ? [{ field: "name" }, { field: "owner", width: 140 }, { field: "items", width: 90 }]
: [{ field: "name" }, { field: "platform", width: 140 }, { field: "online", width: 90 }], sort: [], filters: {}, frozen: 0 };
tiles.toggleAttribute("data-folders", folder);
tiles.presentTile = row => ({ title: row.name, description: folder ? `${row.items} items, ${row.owner}` : `${row.platform}, ${row.online ? "Online" : "Offline"}` });
tiles.renderTile = folder
? row => html`<svg class="folder-icon" viewBox="0 0 48 48" aria-hidden="true"><path d="M5 15V10h14l5 5h19v25H5z"/></svg><strong class="folder-name">${row.name}</strong><span class="folder-meta">${row.items} items · ${row.owner}</span>`
: row => html`<div class="device-card"><svg viewBox="0 0 48 48" aria-hidden="true"><rect x="5" y="6" width="38" height="28" rx="3"/><path d="M16 42h16M24 34v8"/></svg><strong>${row.name}</strong><span>${row.platform} · ${row.owner}</span><span class="device-state" ?data-online=${row.online}>${row.online ? "Online" : "Offline"}</span></div>`;
query(); bindSelection(); show();
}
bindDataset();
function syncActions() {
const any = currentSelection.value?.any.value ?? false;
$("#edit").disabled = busy || hasDraft() || !any;
$("#clear").disabled = busy || !any;
$("#simulate").disabled = busy || hasDraft() || !any;
}
const stopSelection = effect(() => {
const selection = currentSelection.value;
const records = selection?.records.value ?? [];
$("#selected-summary").textContent = records.length ? `${records.length} selected${selection.ordered ? " in pick order" : ""}: ${records.map(r => r.name).join(", ")}` : "No items selected.";
$("#edit").disabled = busy || hasDraft() || !records.length;
$("#clear").disabled = busy || !records.length;
$("#simulate").disabled = busy || hasDraft() || !records.length;
});
function closeEditor() {
stopEditor?.(); model?.dispose(); model = undefined;
render(html``, $("#editor-out"));
$("#save-actions").hidden = true;
$("#edit").disabled = $("#simulate").disabled = !currentSelection.value.any.value;
for (const name of ["dataset", "selection"]) control(name).inert = false;
}
$("#edit").addEventListener("click", () => {
const key = currentSelection.value.key.value;
if (key === undefined || model) return;
model = new EditModel(ctx, collection().query.set, key);
const folder = state.dataset === "folders";
render(html`<aster-form .model=${model} .sections=${[{ title: "Edit record", columns: 1, items: ["name", folder ? "owner" : "platform"] }]}></aster-form>`, $("#editor-out"));
$("#save-actions").hidden = false;
$("#edit").disabled = $("#simulate").disabled = true;
for (const name of ["dataset", "selection"]) control(name).inert = true;
stopEditor = effect(() => { $("#save").disabled = model.busy.value || !model.dirty.value; $("#cancel").disabled = model.busy.value; });
status("Editing a separate draft. Switch presentation to see that it stays open.");
queueMicrotask(() => $("#editor-out input")?.focus());
});
$("#cancel").addEventListener("click", () => { closeEditor(); status("Draft discarded."); $("#edit").focus(); });
$("#save").addEventListener("click", async () => {
busy = true;
try {
const saved = await model.save();
if (saved) { closeEditor(); status("Saved to the shared record store. Both presentations show the change."); $("#edit").focus(); }
else status("The draft could not be saved. Review the form errors.");
} catch { status("Save failed. Your draft is still open; try again."); }
finally { busy = false; syncActions(); }
});
$("#clear").addEventListener("click", () => currentSelection.value.clear());
$("#simulate").addEventListener("click", async () => {
const selection = currentSelection.value;
const key = selection.key.value;
if (key === undefined || busy || model) return;
busy = true; $("#simulate").disabled = true;
try { await ctx.update("Devices", key, { online: !selection.selected.value.online }); status("Status updated through the mock service and shared store."); }
catch { status("Could not update the status. Try again."); }
finally { busy = false; syncActions(); }
});
for (const name of Object.keys(choices)) {
control(name).inert = false;
control(name).addEventListener("change", event => {
if (event.target !== control(name)) return;
if (hasDraft() && ["dataset", "selection"].includes(name)) return;
state[name] = control(name).value;
switch (name) {
case "dataset": bindDataset(); break;
case "view": show(); break;
case "selection": bindSelection(); break;
case "size": size(); break;
default: query();
}
notify();
});
}
let debounce;
const applyName = () => { clearTimeout(debounce); state.q = $("#query").value; query(); notify(); };
$("#query").disabled = $("#online").disabled = false;
$("#query").addEventListener("input", () => { clearTimeout(debounce); debounce = setTimeout(applyName, 180); });
$("#query").addEventListener("change", applyName);
$("#query").addEventListener("keydown", e => { if (e.key === "Enter") applyName(); });
$("#online").addEventListener("change", () => { state.online = $("#online").checked; query(); notify(); });
tiles.addEventListener("tile-open", event => status(`Opened ${ctx.store.get(collection().query.set, event.detail.key)?.entity.name} (demonstration only).`));
notify();
status("Ready. Paging and selection are shared between the table and tiles.");
addEventListener("pagehide", event => {
if (event.persisted) return;
clearTimeout(debounce); stopSelection(); stopEditor?.(); model?.dispose();
table.remove(); tiles.remove(); Object.values(collections).forEach(c => ctx.release(c));
});
} catch (error) {
render(html`<p role="alert">The example could not start. Reload to try again.</p>`, $("#out"));
status(error instanceof Error ? error.message : String(error));
}
// The documentation host observes this content's size; CSS owns the preview layout.
const observer = new ResizeObserver(entries => {
const height = entries[0]?.borderBoxSize[0]?.blockSize;
if (height && parent !== window) parent.postMessage({ type: "aster-collection-height", height: Math.ceil(height) }, location.origin);
});
observer.observe(document.body);
addEventListener("message", event => {
if (event.origin === location.origin && event.source === parent && event.data?.type === "aster-collection-request") {
notify();
parent.postMessage({ type: "aster-collection-height", height: Math.ceil(document.body.getBoundingClientRect().height) }, location.origin);
}
});
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Shared collections — Aster UI</title>
<link rel="stylesheet" href="../lib/themes/lamplight.css"><link rel="stylesheet" href="../lib/aster-ui.css"><link rel="stylesheet" href="./collection-demo.css">
</head><body><main aria-label="Collection workspace"><h1 class="sr-only">Collection workspace</h1>
<div class="demo-controls">
<div class="control"><span>Sample data</span><aster-choice id="dataset"></aster-choice></div>
<div class="control"><span>Presentation</span><aster-choice id="view"></aster-choice></div>
<div class="control"><span>Sort</span><aster-choice id="sort"></aster-choice></div>
<div class="control"><span>Selection</span><aster-choice id="selection-mode"></aster-choice></div>
<label class="control search-control">Find by name<input type="search" id="query" placeholder="Try ‘Studio’…" maxlength="80"></label>
<div class="control" id="platform-control"><span>Platform</span><aster-choice id="platform"></aster-choice></div>
<label class="online-control"><input id="online" type="checkbox"> Online only</label>
<div class="control" id="size-control"><span>Tile size</span><aster-choice id="size"></aster-choice></div>
</div>
<p class="demo-hint">Choose items, switch presentation, then edit a selected record. Both views use the same collection and selection.</p>
<div class="demo-stage"><section class="collection-area" aria-label="Collection preview"><div id="out"><p role="status">Starting the local OData service…</p></div></section>
<aside class="record-editor" aria-labelledby="editor-title"><p class="demo-eyebrow">Shared record</p><h2 id="editor-title">Make a change</h2>
<p id="selected-summary" role="status">No items selected.</p>
<div class="demo-actions"><button type="button" class="aster-button" id="edit" disabled>Edit first selected</button><button type="button" class="aster-button" id="clear" disabled>Clear selection</button></div>
<div id="editor-out"></div>
<div class="demo-actions" id="save-actions" hidden><button type="button" class="aster-button" data-variant="primary" id="save">Save changes</button><button type="button" class="aster-button" id="cancel">Discard draft</button></div>
<button type="button" class="aster-button" id="simulate" disabled>Toggle selected status</button>
<p class="demo-hint">Mock data in this page only. Reloading restores the sample. Drafts stay open when you change presentation.</p></aside></div>
<p id="status" class="demo-status" role="status" aria-live="polite"></p>
</main><script type="module" src="./collection-demo.js"></script></body></html>
@import "../docs-assets/docs.css";
body { margin:0; padding:20px; height:auto; min-height:0; background:var(--aster-surface); color:var(--aster-text); font:14px/1.5 Geist,system-ui,sans-serif; }
main { min-width:0; }
.demo-controls { display:flex; align-items:end; flex-wrap:wrap; gap:12px; padding-bottom:16px; border-bottom:1px solid var(--aster-rule); }
.control { display:grid; gap:5px; flex:1 1 135px; max-width:190px; font-size:12px; color:var(--aster-text-2); min-width:0; }
.control aster-choice { color:var(--aster-text); }
.search-control { flex-basis:190px; max-width:240px; }
.control input { height:34px; width:100%; border:1px solid var(--aster-rule); border-radius:var(--aster-r-ctl); background:var(--aster-sunk); color:var(--aster-text); font:inherit; padding:6px 10px; }
input:focus-visible { outline:2px solid var(--aster-accent); outline-offset:2px; }
.online-control { display:flex; gap:6px; align-items:center; min-height:34px; font-size:13px; }
input[type=checkbox] { accent-color:var(--aster-accent); }
.demo-stage { display:grid; grid-template-columns:minmax(0,1fr) 250px; gap:20px; }
.collection-area { min-width:0; }
#out { min-width:0; }
#out aster-grid { display:flex; flex-direction:column; height:440px; min-width:0; }
#out aster-tile-grid { --tile-min-width:220px; --tile-height:194px; --tile-viewport-height:440px; }
#out aster-tile-grid .aster-tile-grid { height:440px; max-height:440px; }
.record-editor { min-width:0; padding:16px; border:1px solid var(--aster-rule); border-radius:7px; background:var(--aster-sunk); }
.record-editor h2 { font-size:18px; line-height:1.3; margin:4px 0 14px; }
.demo-eyebrow { color:var(--aster-accent); text-transform:uppercase; letter-spacing:1px; font-size:10px; margin:0; }
.demo-hint { color:var(--aster-text-2); font-size:12px; margin:14px 0; }
.demo-actions { display:flex; flex-wrap:wrap; gap:8px; margin:12px 0; }
#editor-out aster-form { display:block; margin-top:16px; }
.demo-status { color:var(--aster-text-2); font-size:12px; margin:14px 0 0; min-height:18px; overflow-wrap:anywhere; }
.device-card { display:grid; grid-template-columns:32px minmax(0,1fr); gap:5px 12px; align-content:start; }
.device-card svg { grid-row:1/span 3; width:32px; height:32px; fill:none; stroke:var(--aster-accent); stroke-width:1.5; }
.device-card strong { font-size:14px; line-height:1.3; overflow:hidden; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; }
.device-card span { grid-column:2; font-size:12px; color:var(--aster-text-2); }
.device-state { display:flex; align-items:center; gap:6px; }
.device-state::before { content:""; width:7px; height:7px; border-radius:50%; background:var(--aster-text-3); }
.device-state[data-online]::before { background:var(--aster-positive); }
#out aster-tile-grid[data-folders] { --tile-min-width:156px; --tile-height:194px; }
[data-folders] .aster-tile-content { text-align:center; display:grid; gap:8px; align-content:center; }
.folder-icon { width:42px; height:42px; margin:0 auto; fill:var(--aster-accent-soft); stroke:var(--aster-accent); stroke-width:1.5; }
.folder-name { display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; font-size:13px; line-height:1.3; }
.folder-meta { color:var(--aster-text-2); font-size:11px; }
[hidden] { display:none!important; }
@media(max-width:850px) { .demo-stage { grid-template-columns:minmax(0,1fr); } .record-editor { padding:16px; } }
@media(max-width:450px) { body { padding:12px; } .control { flex-basis:calc(50% - 12px); } .online-control { flex-basis:100%; } }
@media(pointer:coarse) { .aster-button,.control input,.online-control { min-height:44px; } }
@media(prefers-reduced-motion:reduce) { *,*::before,*::after { animation:none!important; transition:none!important; scroll-behavior:auto!important; } }
.control:has(aster-choice[inert]) { opacity:.55; }
Try the interaction between features
- Select an item, then change Presentation. The selected keys belong to the shared selection model.
- Choose Edit first selected, change its name, then switch presentation before saving. The draft stays open; saving updates the shared record.
- Filter by name or platform. The query applies to the complete collection, including pages that have not been loaded.
- Try ordered selection with a maximum of three, or change the sample to workspace folders and adjust the tile size.
What the example owns
One data context connects to the local OData engine. Each sample has a paged collection. Grid and TileGrid bind to the same active collection and selection. The editor owns a separate edit model until Save or Discard.
Presentation changes preserve the query, picks and draft. Changing the sample data or selection mode starts a new selection; those controls are unavailable while an editor is open. Filtering can remove a selected item from the result.