Component explorer / Application controls
Fields and layouts: project settings
Edit related values with exact numeric input, resize nested panes and navigate retained settings and context.
Project edits and notes stay in this visit. ContextualHierarchy is preview-only. The Number Field uses canonical numeric input; additional business constraints belong in model/server validation.
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.
Choose sources while browsing folders
The host owns selected IDs, lazy folder loading, cancellation, history and return positions. Row selection and opening a folder are separate actions. The shared hierarchy supplies retained ancestor content and accessible focus transitions.
From scripts/site/playground/folder-picker.js
function content(id) {
const rows = cache.get(id);
return html `<div class="folder-browser-list" data-folder-list=${id} tabindex="0" role="group" aria-label=${`${nodes.get(id).name} entries`} aria-busy=${busy}>
${rows === undefined ? html `<p class="folder-empty">${error ? "Retry to load this folder." : "Loading workspace folders…"}</p>` : rows.length === 0 ? html `<p class="folder-empty">This folder is empty. You can still choose the folder itself.</p>` : each(rows, entry => entry.id, entry => {
const picked = selected.has(entry.id);
const reason = !picked && (entry.unavailable || (foldersOnly && !entry.directory ? "Choose a folder in folders-only mode" : ""));
return html `<div class="folder-browser-row" ?data-selected=${picked}>
<button type="button" class="folder-choose" data-node=${entry.id} data-action="choose" aria-label=${`Select ${entry.name}`} aria-pressed=${picked ? "true" : "false"}
?disabled=${!!reason} title=${reason || pathText(entry.id)} @click=${e => choose(entry, e.currentTarget)}>
${icon(entry.directory ? "folder" : "file")}<span>${entry.name}${reason ? html `<small>${reason}</small>` : nothing}</span><small>${picked ? "Selected" : entry.directory ? "Folder" : entry.size}</small>
</button>
${entry.directory ? html `<button type="button" class="folder-open" data-node=${entry.id} data-action="open" aria-label=${`Open ${entry.name}`} title=${`Open ${entry.name}`} @click=${e => navigate(ancestorIds(entry.id), "push", e.currentTarget)}>${icon("forward")}</button>` : nothing}
</div>`;
})}
</div>`;
}
Load folders without losing confirmed content
Only a successful, current request changes location and history. A superseding request aborts its predecessor; failure retains the visible folder and choices for retry.
From scripts/site/playground/folder-picker.js
async function navigate(path, mode = "push", opener) {
if (disposed)
return;
const request = ++generation;
controller?.abort();
controller = new AbortController();
remember(opener);
busy = true;
error = "";
confirmation = "";
failedRequest = undefined;
paint();
try {
const id = path.at(-1);
const rows = cache.has(id) && !failNext ? cache.get(id) : await children(id, controller.signal);
if (request !== generation || disposed)
return;
cache.set(id, rows);
if (current.join("/") !== path.join("/")) {
switch (mode) {
case "back":
forward.push(current);
back.pop();
break;
case "forward":
back.push(current);
forward.pop();
break;
case "push":
back.push(current);
forward = [];
break;
}
}
current = path;
busy = false;
paint(opener);
cancelAnimationFrame(restoreFrame);
restoreFrame = requestAnimationFrame(() => {
if (request !== generation || disposed)
return;
const memory = positions.get(id), list = host.querySelector(`[data-folder-list="${id}"]`);
if (memory && list) {
list.scrollTop = memory.top;
if (mode !== "push" && memory.node)
list.querySelector(`[data-node="${memory.node}"][data-action="${memory.action}"]`)?.focus({
preventScroll: true
});
}
});
}
catch (failure) {
if (request !== generation || disposed)
return;
busy = false;
error = failure.message;
failedRequest = {
path, mode
};
paint();
}
}
Keep model fields together without merging their values
The group borrows the project editor. Each numeric child keeps its own metadata, validation and pending state; group revert acts on budget and spend only.
From scripts/site/playground/foundations.js
render(html `<aster-field .model=${model} .field=${"name"} .labelled=${true}></aster-field>
<aster-composite-field id="budget-group" .model=${model} .fields=${["budget", "spent"]} .label=${"Project finances"} .help=${"Amounts in GBP. Both values belong to the same project edit."}
.content=${html `<aster-number-field .model=${model} .field=${"budget"} .labelled=${true} .prefixText=${"£"} .stepButtons=${true}></aster-number-field><aster-number-field .model=${model} .field=${"spent"} .labelled=${true} .prefixText=${"£"} .stepButtons=${true}></aster-number-field>`}></aster-composite-field>`, byId("group"));
Configure exact numeric input
Canonical numeric strings preserve every digit. An explicit step controls exact increments without binary floating-point conversion.
From scripts/site/playground/foundations.js
Object.assign(exact, {
label: "Unit price", labelled: true, value: "9007199254740993.25", step: "0.05", stepButtons: true, prefixText: "£", min: "0"
});
Object.assign(quantity, {
label: "Quantity", labelled: true, numericType: "integer", value: "2", min: "0", max: "9", stepButtons: true, suffixText: "devices"
});
effect(() => { byId("exact-output").textContent = `Canonical value: ${exact.value ?? "empty"}`; byId("quantity-output").textContent = `Canonical value: ${quantity.value ?? "empty"}`; });
Retain settings after lazy creation
Panel IDs own template identity. The Notes factory runs on first expansion; ordinary toggling retains its textarea.
From scripts/site/playground/foundations.js
accordion.items = [
{
id: "general", heading: "General", body: html `<label class="demo-note">Workspace name<span class="aster-field" data-ring-owner><input aria-label="Workspace name" value="Design operations"></span></label>`
},
{
id: "notes", heading: "Notes", summary: html `<span>Created when first opened</span>`, create: () => { lazyCalls++; return html `<label class="demo-note">Private note<span class="aster-field aster-field-prose" data-ring-owner><textarea aria-label="Private note"></textarea></span></label>`; }
},
{
id: "limits", heading: "Limits", body: html `<aster-number-field id="limit" .label=${"Storage limit"} .labelled=${true} .value=${"10"} .min=${"0"} .max=${"100"} .suffixText=${"GB"}></aster-number-field>`
},
{
id: "managed", heading: "Managed by administrator", disabled: true, body: html `<p>Contact your administrator.</p>`
},
];
byId("multiple").onchange = e => accordion.multiple = e.target.checked;
byId("show-error").onclick = () => { byId("limit").value = "-1"; };
Own the contextual navigation path
Breadcrumb actions, child buttons and Back call the same showLocation action. It updates the hierarchy and breadcrumb trail together. The host retains folder notes across ancestor jumps; browser history is unchanged.
From scripts/site/playground/foundations.js
const path = byId("path");
const hierarchy = byId("hierarchy");
const locations = [
{
id: "workspace", title: "Workspace", summary: "Teams and shared projects"
},
{
id: "engineering", parent: "workspace", title: "Engineering"
},
{
id: "platform", parent: "engineering", title: "Platform"
},
{
id: "projects", parent: "platform", title: "Projects"
},
{
id: "aster", parent: "projects", title: "Aster", summary: "Project folders"
},
{
id: "design", parent: "aster", title: "Design", summary: "Layouts and interaction notes", editable: true
},
{
id: "research", parent: "aster", title: "Research", summary: "Saved studies and references", editable: true
},
{
id: "design-details", parent: "design", title: "Folder details"
},
{
id: "research-details", parent: "research", title: "Folder details"
},
];
const locationsById = new Map(locations.map(location => [location.id, location]));
const folderNotes = new Map();
function locationContent(location) {
if (location.editable)
return html `<label class="demo-note">Folder note
<span class="aster-field aster-field-prose" data-ring-owner><textarea aria-label=${`${location.title} note`}
.value=${folderNotes.get(location.id) ?? ""} @input=${e => folderNotes.set(location.id, e.target.value)}></textarea></span>
</label><button class="aster-button" type="button" @click=${e => showLocation(`${location.id}-details`, e.currentTarget)}>Open folder details</button>`;
if (location.id.endsWith("-details"))
return html `<p>Shared with the project team.</p>`;
const children = locations.filter(child => child.parent === location.id);
return html `<aster-vertical-layout .content=${html `${children.map(child => html `<button class="aster-button" type="button"
@click=${e => showLocation(child.id, e.currentTarget)}>${child.title} →</button>`)}`}></aster-vertical-layout>`;
}
// Breadcrumbs, child navigation and Back all change the same application-owned path.
function showLocation(id, opener) {
const trail = [];
for (let location = locationsById.get(id); location; location = locationsById.get(location.parent))
trail.unshift(location);
hierarchy.navigate(trail.map(location => ({
id: location.id, title: location.title, summary: location.summary, content: locationContent(location)
})), opener);
path.items = trail.map(location => ({
id: location.id, label: location.title, activate: () => showLocation(location.id)
}));
}
showLocation("aster");
hierarchy.addEventListener("back", e => showLocation(e.detail.id));
Full application source Application code & shared presentation
import { DataContext, EditModel, html, render, effect } from "../lib/aster-ui.js";
import "../lib/aster-ui-preview.js";
import { createFolderPicker } from "./folder-picker.js";
import { createMockService } from "../lib/aster-ui-mock.js";
const byId = id => document.getElementById(id);
const folderPicker = createFolderPicker(byId("folder-picker"));
window.folderPickerDemo = folderPicker;
addEventListener("pagehide", () => folderPicker.dispose());
const exact = byId("exact"), quantity = byId("quantity");
Object.assign(exact, { label: "Unit price", labelled: true, value: "9007199254740993.25", step: "0.05", stepButtons: true, prefixText: "£", min: "0" });
Object.assign(quantity, { label: "Quantity", labelled: true, numericType: "integer", value: "2", min: "0", max: "9", stepButtons: true, suffixText: "devices" });
effect(() => { byId("exact-output").textContent = `Canonical value: ${exact.value ?? "empty"}`; byId("quantity-output").textContent = `Canonical value: ${quantity.value ?? "empty"}`; });
render(html`<aster-composite-field .label=${"Dimensions"} .help=${"An application-owned structured value; no implicit serialization."}
.value=${{ width: "20", height: "10" }} .renderValue=${({ value, commit, readOnly }) => html`
<aster-number-field .label=${"Width"} .labelled=${true} .value=${value.width} .readOnly=${readOnly} .suffixText=${"cm"}
@value-change=${e => commit({ ...value, width: e.detail.value })}></aster-number-field>
<aster-number-field .label=${"Height"} .labelled=${true} .value=${value.height} .readOnly=${readOnly} .suffixText=${"cm"}
@value-change=${e => commit({ ...value, height: e.detail.value })}></aster-number-field>`}></aster-composite-field>`, byId("structured"));
const split = byId("layout-split");
Object.assign(split, { size: 280, min: 160, max: 520, label: "Resize project navigation", collapsible: true,
start: html`<div class="layout-content"><h3>Project navigation</h3><aster-vertical-layout .gap=${"small"} .content=${html`<a href="#group-heading">Project budget</a><a href="#accordion-heading">Settings</a><a href="#path-heading">Workspace folders</a>`}></aster-vertical-layout></div>`,
end: html`<aster-split id="level-split" .orientation=${"vertical"} .size=${135} .min=${80} .max=${240} .label=${"Resize project notes"}
.start=${html`<div class="layout-content"><h3>Project notes</h3><label class="demo-note">Working note<span class="aster-field aster-field-prose" data-ring-owner><textarea aria-label="Working note" placeholder="This note survives resizing and collapse"></textarea></span></label></div>`}
.end=${html`<div class="layout-content"><aster-horizontal-layout .gap=${"large"} .content=${html`<div><strong>Budget review</strong><p>Friday, 10:00</p></div><div><strong>Owner</strong><p>Alex Morgan</p></div>`}></aster-horizontal-layout></div>`}></aster-split>` });
byId("responsive").onchange = e => split.responsive = e.target.checked;
byId("narrow").onclick = () => { const narrow = split.style.maxWidth !== "460px"; split.style.maxWidth = narrow ? "460px" : ""; byId("narrow").textContent = narrow ? "Use full container" : "Use narrow container"; };
const accordion = byId("settings");
let lazyCalls = 0;
accordion.items = [
{ id: "general", heading: "General", body: html`<label class="demo-note">Workspace name<span class="aster-field" data-ring-owner><input aria-label="Workspace name" value="Design operations"></span></label>` },
{ id: "notes", heading: "Notes", summary: html`<span>Created when first opened</span>`, create: () => { lazyCalls++; return html`<label class="demo-note">Private note<span class="aster-field aster-field-prose" data-ring-owner><textarea aria-label="Private note"></textarea></span></label>`; } },
{ id: "limits", heading: "Limits", body: html`<aster-number-field id="limit" .label=${"Storage limit"} .labelled=${true} .value=${"10"} .min=${"0"} .max=${"100"} .suffixText=${"GB"}></aster-number-field>` },
{ id: "managed", heading: "Managed by administrator", disabled: true, body: html`<p>Contact your administrator.</p>` },
];
byId("multiple").onchange = e => accordion.multiple = e.target.checked;
byId("show-error").onclick = () => { byId("limit").value = "-1"; };
const path = byId("path");
const hierarchy = byId("hierarchy");
const locations = [
{ id: "workspace", title: "Workspace", summary: "Teams and shared projects" },
{ id: "engineering", parent: "workspace", title: "Engineering" },
{ id: "platform", parent: "engineering", title: "Platform" },
{ id: "projects", parent: "platform", title: "Projects" },
{ id: "aster", parent: "projects", title: "Aster", summary: "Project folders" },
{ id: "design", parent: "aster", title: "Design", summary: "Layouts and interaction notes", editable: true },
{ id: "research", parent: "aster", title: "Research", summary: "Saved studies and references", editable: true },
{ id: "design-details", parent: "design", title: "Folder details" },
{ id: "research-details", parent: "research", title: "Folder details" },
];
const locationsById = new Map(locations.map(location => [location.id, location]));
const folderNotes = new Map();
function locationContent(location) {
if (location.editable) return html`<label class="demo-note">Folder note
<span class="aster-field aster-field-prose" data-ring-owner><textarea aria-label=${`${location.title} note`}
.value=${folderNotes.get(location.id) ?? ""} @input=${e => folderNotes.set(location.id, e.target.value)}></textarea></span>
</label><button class="aster-button" type="button" @click=${e => showLocation(`${location.id}-details`, e.currentTarget)}>Open folder details</button>`;
if (location.id.endsWith("-details")) return html`<p>Shared with the project team.</p>`;
const children = locations.filter(child => child.parent === location.id);
return html`<aster-vertical-layout .content=${html`${children.map(child => html`<button class="aster-button" type="button"
@click=${e => showLocation(child.id, e.currentTarget)}>${child.title} →</button>`)}`}></aster-vertical-layout>`;
}
// Breadcrumbs, child navigation and Back all change the same application-owned path.
function showLocation(id, opener) {
const trail = [];
for (let location = locationsById.get(id); location; location = locationsById.get(location.parent)) trail.unshift(location);
hierarchy.navigate(trail.map(location => ({ id: location.id, title: location.title, summary: location.summary, content: locationContent(location) })), opener);
path.items = trail.map(location => ({ id: location.id, label: location.title, activate: () => showLocation(location.id) }));
}
showLocation("aster");
hierarchy.addEventListener("back", e => showLocation(e.detail.id));
const rail = byId("rail");
rail.label = "Related projects";
rail.slides = ["Spring launch", "Brand refresh", "Billing update"].map((label, i) => ({ id: String(i), label, view: html`<article class="demo-box"><h3>${label}</h3><p>${["Campaign assets and launch checklist", "Updated identity and shared templates", "Invoices, exports and reconciliation"][i]}</p><label class="demo-note">Quick note<span class="aster-field" data-ring-owner><input aria-label=${`Note for ${label}`} placeholder="Keep a note on this card"></span></label></article>` }));
byId("reorder").onclick = () => rail.slides = [...rail.slides].reverse();
byId("rtl").onchange = e => { rail.dir = e.target.checked ? "rtl" : "ltr"; rail.show(0); };
byId("view-all").onclick = () => { const all = byId("all-cards"); all.hidden = !all.hidden; byId("view-all").textContent = all.hidden ? "View all" : "Hide list"; render(html`<ul>${rail.slides.map(s => html`<li>${s.label}</li>`)}</ul>`, all); };
let model, ctx, collection;
try {
const json = async name => { const response = await fetch(`../lib/demo/${name}.json`); if (!response.ok) throw new Error(`Could not load ${name}`); return response.json(); };
const mock = await createMockService({ metadata: await json("metadata"), data: await json("data"), wasm: new URL("../lib/odata_mock.wasm", import.meta.url) });
ctx = await DataContext.connect(mock.transport);
collection = ctx.collection({ set: "Projects", sort: [{ field: "id" }] }, { pageSize: 1 });
await collection.ensure(0, 1);
model = new EditModel(ctx, "Projects", collection.keys.peek()[0]);
byId("group").replaceChildren();
render(html`<aster-field .model=${model} .field=${"name"} .labelled=${true}></aster-field>
<aster-composite-field id="budget-group" .model=${model} .fields=${["budget", "spent"]} .label=${"Project finances"} .help=${"Amounts in GBP. Both values belong to the same project edit."}
.content=${html`<aster-number-field .model=${model} .field=${"budget"} .labelled=${true} .prefixText=${"£"} .stepButtons=${true}></aster-number-field><aster-number-field .model=${model} .field=${"spent"} .labelled=${true} .prefixText=${"£"} .stepButtons=${true}></aster-number-field>`}></aster-composite-field>`, byId("group"));
effect(() => { byId("group-output").textContent = model.dirty.value ? "Unsaved changes in this project" : "Project matches its saved values"; });
byId("lock-group").onchange = e => byId("budget-group").disabled = e.target.checked;
byId("validate").onclick = () => { const valid = model.validate(); byId("group-output").textContent = valid ? "Project values are valid" : "Review the highlighted fields"; if (!valid) byId("budget-group").focus(); };
} catch (error) { byId("group").textContent = error.message; byId("group").setAttribute("role", "alert"); }
window.foundationsDemo = { exact, quantity, split, accordion, hierarchy, rail, get model() { return model; }, get lazyCalls() { return lazyCalls; } };
addEventListener("pagehide", () => { model?.dispose(); if (collection) ctx.release(collection); });
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Fields and layouts — Aster UI</title>
<link id="identity" rel="stylesheet" href="../lib/themes/lamplight.css"><link rel="stylesheet" href="../lib/aster-ui.css"><link rel="stylesheet" href="../lib/aster-ui-preview.css"><link rel="stylesheet" href="./showcase.css"><link rel="stylesheet" href="./folder-picker.css">
<style>
body {height:auto;min-height:100dvh;background:var(--aster-surface);padding:24px} main{max-width:1120px;margin:auto} h1{margin-block:16px} .intro{max-width:70ch;color:var(--aster-text-2)} .bar{display:flex;flex-wrap:wrap;gap:16px;align-items:center;margin-block:16px}.examples{display:grid;gap:20px;grid-template-columns:repeat(2,minmax(0,1fr))}.card{padding:20px;border:1px solid var(--aster-rule);border-radius:var(--aster-r-card);min-width:0;background:var(--aster-page)}.card h2{font-size:20px;margin:0 0 8px}.card>p{color:var(--aster-text-2)}.wide{grid-column:1/-1}.demo-box{padding:16px;background:var(--aster-sunk);border:1px solid var(--aster-rule-2);border-radius:var(--aster-r-card)}.demo-note{display:grid;gap:8px}.demo-note textarea{min-height:7rem;width:100%}.receipt{font-family:var(--aster-font-mono,monospace);overflow-wrap:anywhere;color:var(--aster-text-2);font-size:12px}#layout-split{height:330px}#level-split{height:100%}#hierarchy{height:340px;border:1px solid var(--aster-rule);border-radius:var(--aster-r-card)}#rail{--slide-width:17rem}.aster-carousel-slide>.demo-box{height:100%}.aster-carousel-slide h3{margin-block:0 8px}#group-output{min-height:1.5em}.layout-content{padding:16px} .stack-controls{margin-block:12px}.aster-hierarchy-level .aster-button{margin-block:8px}a{color:var(--aster-accent)}@media(max-width:720px){body{padding:12px}.examples{grid-template-columns:minmax(0,1fr)}.card{padding:16px}#layout-split{height:460px}}
</style></head><body><main>
<h1>Fields and layouts</h1><p class="intro">Edit related values, arrange a workspace and move through its details while preserving your work. These controls use the same Aster field states, theme and composition contracts.</p>
<div class="bar"><div class="showcase-appearance"><span>Theme</span><aster-choice id="theme"></aster-choice></div><div class="showcase-appearance"><span>Scheme</span><aster-choice id="scheme"></aster-choice></div><a href="../guide/fields-and-layouts.html">Implementation guide</a></div>
<div class="examples">
<section class="card" aria-labelledby="group-heading"><h2 id="group-heading">Related model fields</h2><p>Budget and spend keep their own validation and pending state. Revert the group without changing the project name.</p><div id="group">Loading project…</div><p id="group-output" role="status"></p><div class="bar"><button class="aster-button" id="validate" type="button">Validate project</button><label><input type="checkbox" id="lock-group"> Disable group</label></div></section>
<section class="card" aria-labelledby="numeric-heading"><h2 id="numeric-heading">Exact numeric input</h2><p>Increase either value. Every digit survives, including values beyond JavaScript’s safe integer range.</p><aster-number-field id="exact"></aster-number-field><p class="receipt" id="exact-output"></p><aster-number-field id="quantity"></aster-number-field><p class="receipt" id="quantity-output"></p><div id="structured"></div></section>
<section class="card wide" aria-labelledby="layout-heading"><h2 id="layout-heading">A workspace that fits its container</h2><p>Resize the side-by-side panes or the nested vertical split. Enter on a separator collapses its first pane; drafts remain mounted.</p><div class="bar"><label><input type="checkbox" id="responsive" checked> Stack in narrow containers</label><button class="aster-button" id="narrow" type="button">Use narrow container</button></div><aster-split id="layout-split"></aster-split></section>
<section class="card" aria-labelledby="accordion-heading"><h2 id="accordion-heading">Progressive settings</h2><p>Open a section and enter a note. Closing and reopening retains it; validation can reveal an error in a closed section.</p><div class="bar"><label><input id="multiple" type="checkbox"> Allow multiple open sections</label><button class="aster-button" id="show-error" type="button">Reveal a validation error</button></div><aster-accordion id="settings"></aster-accordion></section>
<section class="card" aria-labelledby="path-heading"><h2 id="path-heading">Location and context</h2><p>Breadcrumbs and Back follow the same location. Open a folder, enter a note and visit its details; use the path above to return to any ancestor.</p><aster-breadcrumbs id="path"></aster-breadcrumbs><aster-contextual-hierarchy id="hierarchy"></aster-contextual-hierarchy><p class="receipt">Contextual Hierarchy is an opt-in preview. This example owns navigation; it does not change browser history.</p></section>
<section class="card wide" aria-labelledby="folder-picker-heading"><h2 id="folder-picker-heading">Choose files and folders</h2><p>Choose a row to add a source, or use its arrow to browse inside. Your choices remain selected across folders; Back and Forward retain your place.</p><div id="folder-picker"></div></section>
<section class="card wide" aria-labelledby="rail-heading"><h2 id="rail-heading">Browse related work</h2><p>Card IDs retain input state when reordered. Navigation is manual, with native scrolling and touch swiping.</p><div class="bar"><button type="button" class="aster-button" id="reorder">Reverse cards</button><button type="button" class="aster-button" id="view-all">View all</button><label><input type="checkbox" id="rtl"> Right-to-left rail</label></div><aster-carousel id="rail"></aster-carousel><div id="all-cards" hidden></div></section>
</div></main><script type="module" src="./showcase-nav.js"></script><script type="module" src="./foundations.js"></script></body></html>
import { html, render, each, nothing } from "../lib/aster-ui.js";
// Simulated workspace entries. An application supplies its own authorized directory service.
const entries = [
{ id: "workspace", name: "Workspace", directory: true },
{ id: "projects", parent: "workspace", name: "Projects", directory: true },
{ id: "shared", parent: "workspace", name: "Shared", directory: true },
{ id: "archive", parent: "workspace", name: "Archive", directory: true },
{ id: "welcome", parent: "workspace", name: "Welcome.md", size: "2 KB" },
{ id: "aster", parent: "projects", name: "Aster", directory: true },
{ id: "launch", parent: "projects", name: "Launch notes.md", size: "8 KB" },
{ id: "design", parent: "aster", name: "Design", directory: true },
{ id: "brief", parent: "aster", name: "Project brief.pdf", size: "146 KB" },
{ id: "budget", parent: "aster", name: "Budget.csv", size: "12 KB" },
{ id: "design-notes", parent: "design", name: "Interaction notes.md", size: "6 KB" },
{ id: "reviews", parent: "shared", name: "Reviews", directory: true },
{ id: "handbook", parent: "shared", name: "Team handbook.pdf", size: "320 KB" },
{ id: "installer", parent: "shared", name: "Installer.exe", size: "18 MB", unavailable: "This example accepts Markdown, PDF and CSV files" },
...Array.from({ length: 32 }, (_, index) => ({ id: `review-${index + 1}`, parent: "reviews", name: `Review ${String(index + 1).padStart(2, "0")}.md`, size: "4 KB" })),
];
const nodes = new Map(entries.map(entry => [entry.id, entry]));
const ancestorIds = id => { const path = []; for (let node = nodes.get(id); node; node = nodes.get(node.parent)) path.unshift(node.id); return path; };
const pathText = id => ancestorIds(id).map(id => nodes.get(id).name).join(" / ");
const icon = kind => html`<svg viewBox="0 0 24 24" aria-hidden="true"><path d=${kind === "folder" ? "M3 6h7l2 2h9v12H3Z" : kind === "file" ? "M6 3h8l4 4v14H6Zm8 0v5h4" : kind === "close" ? "m6 6 12 12M18 6 6 18" : kind === "back" ? "m14 5-7 7 7 7" : "m10 5 7 7-7 7"}/></svg>`;
/** A host-owned selection/navigation example over the shared hierarchy surface. */
export function createFolderPicker(host) {
const cache = new Map(), selected = new Map(), positions = new Map();
let current = ["workspace"], back = [], forward = [];
let busy = false, error = "", failedRequest, failNext = false, foldersOnly = false, confirmation = "";
let generation = 0, controller, restoreFrame, disposed = false, delayMs = 220;
const requests = [];
// The provider is cancellable; navigation does not replace confirmed content until it succeeds.
async function children(id, signal) {
requests.push(id);
const fail = failNext; failNext = false;
await new Promise((resolve, reject) => {
const abort = () => { clearTimeout(timer); reject(new DOMException("Cancelled", "AbortError")); };
const timer = setTimeout(() => { signal.removeEventListener("abort", abort); resolve(); }, delayMs);
signal.addEventListener("abort", abort, { once: true });
if (signal.aborted) abort();
});
if (fail) throw new Error("The folder could not be opened. Your current location and choices are unchanged.");
return entries.filter(entry => entry.parent === id);
}
function remember(opener) {
const list = host.querySelector(`[data-folder-list="${current.at(-1)}"]`);
const active = opener ?? document.activeElement;
positions.set(current.at(-1), { top: list?.scrollTop ?? 0, node: list?.contains(active) ? active.dataset.node : positions.get(current.at(-1))?.node, action: list?.contains(active) ? active.dataset.action : positions.get(current.at(-1))?.action });
}
async function navigate(path, mode = "push", opener) {
if (disposed) return;
const request = ++generation;
controller?.abort(); controller = new AbortController();
remember(opener);
busy = true; error = ""; confirmation = ""; failedRequest = undefined; paint();
try {
const id = path.at(-1);
const rows = cache.has(id) && !failNext ? cache.get(id) : await children(id, controller.signal);
if (request !== generation || disposed) return;
cache.set(id, rows);
if (current.join("/") !== path.join("/")) {
switch (mode) {
case "back": forward.push(current); back.pop(); break;
case "forward": back.push(current); forward.pop(); break;
case "push": back.push(current); forward = []; break;
}
}
current = path; busy = false; paint(opener);
cancelAnimationFrame(restoreFrame);
restoreFrame = requestAnimationFrame(() => {
if (request !== generation || disposed) return;
const memory = positions.get(id), list = host.querySelector(`[data-folder-list="${id}"]`);
if (memory && list) {
list.scrollTop = memory.top;
if (mode !== "push" && memory.node) list.querySelector(`[data-node="${memory.node}"][data-action="${memory.action}"]`)?.focus({ preventScroll: true });
}
});
} catch (failure) {
if (request !== generation || disposed) return;
busy = false; error = failure.message; failedRequest = { path, mode }; paint();
}
}
function choose(entry, opener) {
if (opener) remember(opener);
if (selected.has(entry.id)) selected.delete(entry.id);
else selected.set(entry.id, entry);
confirmation = ""; paint();
}
function content(id) {
const rows = cache.get(id);
return html`<div class="folder-browser-list" data-folder-list=${id} tabindex="0" role="group" aria-label=${`${nodes.get(id).name} entries`} aria-busy=${busy}>
${rows === undefined ? html`<p class="folder-empty">${error ? "Retry to load this folder." : "Loading workspace folders…"}</p>` : rows.length === 0 ? html`<p class="folder-empty">This folder is empty. You can still choose the folder itself.</p>` : each(rows, entry => entry.id, entry => {
const picked = selected.has(entry.id);
const reason = !picked && (entry.unavailable || (foldersOnly && !entry.directory ? "Choose a folder in folders-only mode" : ""));
return html`<div class="folder-browser-row" ?data-selected=${picked}>
<button type="button" class="folder-choose" data-node=${entry.id} data-action="choose" aria-label=${`Select ${entry.name}`} aria-pressed=${picked ? "true" : "false"}
?disabled=${!!reason} title=${reason || pathText(entry.id)} @click=${e => choose(entry, e.currentTarget)}>
${icon(entry.directory ? "folder" : "file")}<span>${entry.name}${reason ? html`<small>${reason}</small>` : nothing}</span><small>${picked ? "Selected" : entry.directory ? "Folder" : entry.size}</small>
</button>
${entry.directory ? html`<button type="button" class="folder-open" data-node=${entry.id} data-action="open" aria-label=${`Open ${entry.name}`} title=${`Open ${entry.name}`} @click=${e => navigate(ancestorIds(entry.id), "push", e.currentTarget)}>${icon("forward")}</button>` : nothing}
</div>`;
})}
</div>`;
}
function paint(opener) {
if (disposed) return;
const invalidSelection = foldersOnly && [...selected.values()].some(entry => !entry.directory);
render(html`<div class="folder-picker-layout">
<div class="folder-browser">
<div class="folder-browser-toolbar" role="group" aria-label="Folder navigation">
<button type="button" class="folder-history" aria-label="Back in folder history" title="Back" ?disabled=${!back.length} @click=${e => navigate(back.at(-1), "back", e.currentTarget)}>${icon("back")}</button>
<button type="button" class="folder-history" aria-label="Forward in folder history" title="Forward" ?disabled=${!forward.length} @click=${e => navigate(forward.at(-1), "forward", e.currentTarget)}>${icon("forward")}</button>
<button type="button" class="folder-home" @click=${e => navigate(["workspace"], "push", e.currentTarget)}>Workspace</button>
<button type="button" class="folder-current" aria-pressed=${selected.has(current.at(-1)) ? "true" : "false"} @click=${() => choose(nodes.get(current.at(-1)))}>Choose this folder</button>
</div>
<aster-breadcrumbs .label=${"Folder location"} .maxVisible=${4} .items=${current.map(id => ({ id, label: nodes.get(id).name, activate: () => navigate(ancestorIds(id)) }))}></aster-breadcrumbs>
<aster-progress-status .busy=${busy} .reserveSpace=${true} .pendingText=${"Opening folder…"} .completeText=${"Folder opened"}></aster-progress-status>
${error ? html`<div class="folder-load-error" role="alert"><p>${error}</p><button type="button" class="aster-button" @click=${() => navigate(failedRequest.path, failedRequest.mode)}>Retry opening folder</button></div>` : nothing}
<aster-contextual-hierarchy .navigationVisible=${false} .motion=${"slide"}></aster-contextual-hierarchy>
</div>
<aside class="folder-selection" aria-labelledby="folder-selection-heading">
<h3 id="folder-selection-heading">Selected sources <span>${selected.size}</span></h3>
<div class="folder-selected-list" tabindex="0" aria-label="Selected sources">
${selected.size ? html`<ul>${each([...selected.values()], entry => entry.id, entry => html`<li>${icon(entry.directory ? "folder" : "file")}<span title=${pathText(entry.id)}>${entry.name}<small>${pathText(entry.id)}</small></span><button type="button" aria-label=${`Remove ${entry.name}`} title=${`Remove ${entry.name}`} @click=${() => {
choose(entry);
queueMicrotask(() => host.querySelector(".folder-selected-list button, .folder-current")?.focus());
}}>${icon("close")}</button></li>`)}</ul>` : html`<p>Choose a file or folder. Your choices stay here as you browse.</p>`}
</div>
<div class="bar"><button type="button" class="aster-button" ?disabled=${!selected.size || invalidSelection} @click=${() => { confirmation = `${selected.size} ${selected.size === 1 ? "source is" : "sources are"} ready to use in this example.`; paint(); }}>Use selected sources</button><button type="button" class="folder-clear" ?disabled=${!selected.size} @click=${() => { selected.clear(); confirmation = ""; paint(); }}>Clear</button></div>
<p class="folder-confirmation" role="status">${invalidSelection ? "Remove selected files to use folders-only mode." : confirmation}</p>
<label><input type="checkbox" .checked=${foldersOnly} @change=${e => { foldersOnly = e.target.checked; paint(); }}> Choose folders only</label>
<p class="folder-simulation">Simulated workspace. This example does not read your computer’s files.</p>
<button type="button" class="folder-fault" @click=${() => { failNext = true; confirmation = "The next folder request will fail, so you can try recovery."; paint(); }}>Fail next folder request</button>
</aside>
</div>`, host);
const viewer = host.querySelector("aster-contextual-hierarchy");
viewer.navigate(current.map(id => ({ id, title: nodes.get(id).name, content: content(id) })), opener);
}
paint(); void navigate(current, "initial");
return {
get selected() { return [...selected.keys()]; }, get path() { return [...current]; }, get requests() { return [...requests]; },
get delayMs() { return delayMs; }, set delayMs(value) { delayMs = value; },
dispose() { disposed = true; generation++; controller?.abort(); cancelAnimationFrame(restoreFrame); },
};
}
.folder-picker-layout { display:grid; grid-template-columns:minmax(0,1.6fr) minmax(16rem,1fr); gap:24px; align-items:start; }
.folder-browser { min-width:0; border:1px solid var(--aster-rule); border-radius:var(--aster-r-card); background:var(--aster-surface); overflow:hidden; }
.folder-browser-toolbar { display:flex; align-items:center; gap:4px; padding:8px 12px; border-block-end:1px solid var(--aster-rule-2); }
.folder-browser-toolbar button { min-height:var(--aster-ctl); border-radius:var(--aster-r-ctl); }
.folder-browser-toolbar button:hover:not(:disabled), .folder-selected-list button:hover { background:var(--aster-accent-soft); }
.folder-history { display:grid; place-items:center; width:var(--aster-ctl); flex:none; }
.folder-browser-toolbar svg, .folder-open svg, .folder-selected-list button svg { width:16px; height:16px; fill:none; stroke:currentColor; stroke-width:1.6; stroke-linecap:round; stroke-linejoin:round; }
.folder-home { padding-inline:8px; color:var(--aster-text-2); }
.folder-current { margin-inline-start:auto; padding-inline:8px; color:var(--aster-accent); }
.folder-current[aria-pressed=true] { background:var(--aster-accent-soft); }
.folder-browser > aster-breadcrumbs { display:block; padding:8px 16px 0; font-size:12px; }
.folder-browser > aster-progress-status { display:block; padding-inline:16px; font-size:12px; color:var(--aster-text-2); }
.folder-browser aster-contextual-hierarchy { height:22rem; }
.folder-browser .aster-hierarchy-level { display:flex; flex-direction:column; overflow:hidden; height:100%; }
.folder-browser .aster-hierarchy-level[hidden] { display:none; }
.folder-browser .aster-hierarchy-level > header { flex:none; }
.folder-browser-list { overflow:auto; min-height:0; flex:1; padding:2px; scrollbar-gutter:stable; }
.folder-browser-row { display:flex; align-items:stretch; min-width:0; border-radius:var(--aster-r-ctl); border:1px solid transparent; }
.folder-browser-row + .folder-browser-row { margin-block-start:3px; }
.folder-browser-row[data-selected] { background:var(--aster-accent-soft); border-color:var(--aster-rule); }
.folder-choose { display:flex; align-items:center; gap:10px; min-width:0; flex:1; padding:10px 8px; border-radius:var(--aster-r-ctl); }
.folder-choose > svg, .folder-selected-list li > svg { width:20px; height:20px; fill:none; stroke:currentColor; stroke-width:1.5; flex:none; }
.folder-choose > span { min-width:0; overflow-wrap:anywhere; }
.folder-choose > small { margin-inline-start:auto; white-space:nowrap; }
.folder-choose small { color:var(--aster-text-2); font-size:11px; }
.folder-choose > span > small { display:block; }
.folder-choose:hover:not(:disabled), .folder-open:hover { background:var(--aster-accent-soft); }
.folder-open { width:36px; display:grid; place-items:center; flex:none; border-radius:var(--aster-r-ctl); color:var(--aster-accent); }
.folder-picker-layout button:disabled { opacity:.5; cursor:not-allowed; }
.folder-selection { min-width:0; }
.folder-selection h3 { display:flex; gap:8px; align-items:baseline; margin:0 0 12px; }
.folder-selection h3 > span { color:var(--aster-text-2); font-size:13px; font-variant-numeric:tabular-nums; }
.folder-selected-list { min-height:10rem; max-height:18rem; overflow:auto; padding:12px; background:var(--aster-sunk); border:1px solid var(--aster-rule-2); border-radius:var(--aster-r-card); }
.folder-selected-list ul { list-style:none; padding:0; margin:0; display:grid; gap:12px; }
.folder-selected-list li { display:flex; align-items:start; gap:8px; min-width:0; }
.folder-selected-list li > span { flex:1; min-width:0; overflow-wrap:anywhere; }
.folder-selected-list li small { display:block; font-size:11px; color:var(--aster-text-2); }
.folder-selected-list button { display:grid; place-items:center; width:28px; height:28px; flex:none; border-radius:var(--aster-r-ctl); }
.folder-selected-list p, .folder-empty { margin:0; color:var(--aster-text-2); }
.folder-clear, .folder-fault { color:var(--aster-accent); min-height:var(--aster-ctl); }
.folder-confirmation { min-height:1.5em; font-size:12px; color:var(--aster-text-2); }
.folder-simulation { color:var(--aster-text-2); font-size:12px; margin-block:12px 4px; }
.folder-load-error { margin:8px 16px; color:var(--aster-negative); }
.folder-load-error p { margin-block:0 8px; }
@media(max-width:720px) { .folder-picker-layout { grid-template-columns:minmax(0,1fr); gap:20px; } .folder-selection { grid-row:1; } .folder-selected-list { min-height:5rem; max-height:10rem; } .folder-browser-toolbar { flex-wrap:wrap; } }
@media(pointer:coarse) { .folder-picker-layout button { min-height:44px; } .folder-open, .folder-history, .folder-selected-list button { min-width:44px; } }
@media(forced-colors:active) { .folder-browser-row[data-selected] { border-color:Highlight; } }
// Shared navigation for the independent examples; no vendor code is loaded here.
import "../lib/aster-ui.js";
for (const [id, label, values] of [["theme", "Theme", ["lamplight", "harbour"]], ["scheme", "Scheme", ["light", "dark"]]]) {
const choice = document.querySelector(`#${id}`);
choice.label = label;
choice.options = values.map(value => ({ value, label: value[0].toUpperCase() + value.slice(1) }));
choice.value = values[0];
}
const params = new URLSearchParams(location.search);
const themes = ["lamplight", "harbour"];
const schemes = ["light", "dark"];
const theme = params.get("theme");
const scheme = params.get("scheme");
if (themes.includes(theme)) {
document.querySelector("#theme").value = theme;
document.querySelector("#identity").href = `../lib/themes/${theme}.css`;
document.documentElement.dataset.identity = theme;
}
if (schemes.includes(scheme)) {
document.querySelector("#scheme").value = scheme;
document.documentElement.dataset.scheme = scheme;
document.documentElement.style.colorScheme = scheme;
}
const nav = document.createElement("nav");
nav.className = "showcase-nav";
nav.setAttribute("aria-label", "Component examples");
const examples = [["foundations.html", "Fields & layouts"], ["mail.html", "Mail workspace"], ["composition.html", "Composition"], ["tabs.html", "Tabs"], ["tiles.html", "Tile grids"], ["dataview.html", "DataView"], ["filtering.html", "Filtering"], ["reporting.html", "Reporting"], ["components.html", "Charts"], ["windows.html", "Windows"], ["dashboard.html", "Dashboard"]];
const back = document.createElement("a");
back.textContent = "← Component showcase";
const defaultApp = new URL(document.querySelector('meta[name="aster-site-layout"]') ? "../showcase/" : "../", document.baseURI);
let requestedApp = defaultApp;
try { requestedApp = new URL(params.get("app") ?? defaultApp.href, location.href); } catch { /* Malformed return URLs use the showcase root. */ }
const app = requestedApp.origin === location.origin ? requestedApp : defaultApp;
app.hash = "showcase";
back.href = app.href;
nav.append(back);
for (const [file, label] of examples) {
const link = document.createElement("a");
link.textContent = label;
link.href = file;
if (location.pathname.endsWith(`/${file}`)) link.setAttribute("aria-current", "page");
nav.append(link);
}
function updateLinks() {
for (const link of nav.querySelectorAll("a")) {
const url = new URL(link.href);
if (link !== back) {
url.searchParams.set("theme", document.querySelector("#theme").value);
url.searchParams.set("scheme", document.querySelector("#scheme").value);
url.searchParams.set("showcase", "1");
url.searchParams.set("app", app.pathname);
} else {
url.searchParams.set("identity", document.querySelector("#theme").value);
url.searchParams.set("scheme", document.querySelector("#scheme").value);
}
link.href = url.href;
}
}
function applyAppearance() {
const theme = document.querySelector("#theme").value;
const scheme = document.querySelector("#scheme").value;
const stylesheet = document.querySelector("#identity");
const href = new URL(`../lib/themes/${theme}.css`, document.baseURI).href;
if (stylesheet.href !== href) stylesheet.href = href;
document.documentElement.dataset.identity = theme;
document.documentElement.dataset.scheme = scheme;
document.documentElement.style.colorScheme = scheme;
updateLinks();
}
for (const id of ["theme", "scheme"]) document.querySelector(`#${id}`).addEventListener("change", applyAppearance);
applyAppearance();
const embedded = params.get("embedded") === "1" && parent !== window;
if (!embedded) {
document.querySelector("main").prepend(nav);
const demo = location.pathname.split("/").pop().replace(".html", "");
for (const link of document.querySelectorAll("main a[href]")) {
if (link.hasAttribute("download")) continue;
const target = new URL(link.href);
if (target.origin !== location.origin) continue;
if (["composition", "mail"].includes(demo) && /\.(?:js|yaml|css)$/.test(target.pathname)) {
const viewer = new URL(`../examples/${demo}.html`, location.href);
viewer.searchParams.set("source", target.pathname.split("/").pop());
viewer.searchParams.set("theme", document.querySelector("#theme").value);
viewer.searchParams.set("scheme", document.querySelector("#scheme").value);
viewer.hash = "application-source";
link.href = viewer.href;
} else if (!/\/(?:guide|reference)\//.test(target.pathname)) continue;
link.target = "_blank";
link.rel = "noopener";
link.setAttribute("aria-label", `${link.textContent.trim()} (opens in a new tab)`);
}
}
else {
const demo = location.pathname.split("/").pop().replace(".html", "");
document.documentElement.dataset.embedded = "";
document.documentElement.dataset.demo = demo;
document.querySelector("main").setAttribute("aria-label", document.title);
const state = () => parent.postMessage({ type: "aster-demo-state", state: { theme: document.querySelector("#theme").value, scheme: document.querySelector("#scheme").value } }, location.origin);
const height = () => parent.postMessage({ type: "aster-demo-height", height: Math.ceil(document.body.getBoundingClientRect().height) }, location.origin);
new ResizeObserver(height).observe(document.body);
for (const id of ["theme", "scheme"]) document.querySelector(`#${id}`).addEventListener("change", state);
addEventListener("message", event => {
if (event.origin !== location.origin || event.source !== parent) return;
if (event.data?.type === "aster-example-appearance" && schemes.includes(event.data.scheme)) {
document.querySelector("#scheme").value = event.data.scheme;
if (themes.includes(event.data.theme)) document.querySelector("#theme").value = event.data.theme;
applyAppearance(); state();
}
if (event.data?.type === "aster-demo-request") { state(); height(); }
});
state(); height();
}
.showcase-nav { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 16px; padding-bottom: 12px; margin-bottom: 20px; border-bottom: 1px solid var(--aster-rule); }
.showcase-nav a { display: inline-flex; align-items: center; min-height: 44px; color: var(--aster-accent); text-underline-offset: 4px; }
.showcase-nav > a:first-child { margin-right: auto; }
.showcase-nav [aria-current] { font-weight: var(--aster-weight-strong); text-decoration-thickness: 2px; }
.showcase-appearance { display: inline-flex; align-items: center; gap: 8px; }
.showcase-appearance > span { color: var(--aster-text-2); font-weight: var(--aster-weight-label); }
.showcase-appearance aster-choice { width: 9rem; }
.showcase-appearance #scheme { width: 7rem; }
.showcase-grouping aster-choice { width: min(17rem, 65vw); }
@media (pointer: coarse) {
.showcase-appearance .aster-field, .showcase-appearance .aster-option { min-height: 44px; }
}
@media (max-width: 600px) { .showcase-nav > a:first-child { flex-basis: 100%; } }
html[data-embedded] body { height:auto; min-height:0!important; padding:20px; }
html[data-embedded] .showcase-appearance:has(#scheme) { display:none; }
html[data-embedded] main > p:last-child:has(a[href="./"]) { display:none; }
html[data-embedded][data-demo="windows"] body { min-height:820px!important; }
@media(max-width:600px) { html[data-embedded] body { padding:12px; } }
Try the workflow
- Change the project name and budget, then revert only Project finances.
- Step the large unit price and check every digit remains exact.
- Resize and collapse panes, then return to the retained note.
- Open lazy settings, reveal a validation error, and navigate folder details and Back.
What the example owns
The page owns the project EditModel and explicit navigation levels. Stable elements borrow that state. ContextualHierarchy is an optional preview; no routing or session resources are implicitly owned.