Component explorer / Application controls
Dashboards: a live fleet workspace
Compose summaries, charts, notes and a grid over shared data, then change the layout without losing widget content.
Layouts are saved on this browser. Mock records and notes are local to the visit; saved layouts do not include note text. Appearance is included in example links.
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.
Register a live widget factory and its disposer
ctx and filters are shared page models. This factory owns its exact summary and releases it when disposed. track is demo lifecycle instrumentation around the returned session.
From scripts/site/playground/dashboard.js
dashboard.registerType("summary", {
create: context => track(context, () => {
const summary = ctx.summary(filters.query(base), [{
field: "cost", method: "sum", as: "cost"
}]);
return {
render() {
const metric = context.config.value.metric ?? "count";
const values = summary.status.value === "idle" ? summary.values.value : undefined;
return html `<div class="dashboard-metric"><aster-choice .label=${"Summary metric"} .value=${metric}
.options=${[{
value: "count", label: "Matching devices"
}, {
value: "cost", label: "Monthly cost"
}]}
@change=${event => dashboard.configure(context.id, {
metric: event.detail
})}></aster-choice>
<output aria-label=${metric === "cost" ? "Monthly cost" : "Matching devices"}>${values ? Number(values[metric]).toLocaleString("en-GB") : "…"}</output>
<p>Exact totals across all matching records, including devices outside the loaded page.</p>
${summary.status.value === "error" ? html `<p role="status">Unable to load totals. <button type="button" class="aster-button" @click=${() => summary.refresh()}>Retry totals</button></p>` : html ``}</div>`;
}, dispose: () => summary.dispose()
};
})
});
Restore a layout with stable widget IDs
Each descriptor names a registered type. Configuration belongs to the widget; the dashboard owns placement and order.
From scripts/site/playground/dashboard.js
const defaults = [
{
id: "summary-1", type: "summary", title: "Fleet overview", width: 1, height: 1, config: {
metric: "count"
}
},
{
id: "platforms-1", type: "platforms", title: "Platform mix", width: 2, height: 2
},
{
id: "notes-1", type: "notes", title: "Notes", width: 1, height: 1
},
{
id: "devices-1", type: "devices", title: "Device fleet", width: 3, height: 2
},
];
await dashboard.restore({
version: 1, widgets: defaults
});
Create a notes widget with a removal guard
The widget owns its input and dirty signal. beforeRemove asks the application to settle a destructive removal; moving the widget keeps its session.
From scripts/site/playground/dashboard.js
dashboard.registerType("notes", {
create: context => track(context, () => {
const dirty = signal(false);
const input = document.createElement("textarea");
input.setAttribute("aria-label", "Notes text");
input.placeholder = "Keep a note while you explore the fleet…";
input.addEventListener("input", () => dirty.value = true, {
signal: context.signal
});
return {
render: () => html `<div class="dashboard-notes"><p>Your draft stays here when you move, resize or maximize this widget.</p>${input}
<footer><button type="button" class="aster-button" @click=${() => { dirty.value = false; say("Notes saved for this visit."); }}>Save notes</button><span role="status">${dirty.value ? "Unsaved changes" : "Saved"}</span></footer></div>`,
beforeRemove: () => dirty.peek() ? askRemove(dashboard.widgets.find(widget => widget.id === context.id)?.title ?? "notes") : true, dispose() { }
};
})
});
Full application source Application code & shared presentation
import { DataContext, PageScope, SharedFilters, Selection, provideScope, signal, computed, effect, html, echartsAdapter, echartsColors } from "../lib/aster-ui.js";
import { createMockService } from "../lib/aster-ui-mock.js";
const metadata = { $Version:"4.01", $EntityContainer:"C.Container", C:{
Device: { $Kind:"EntityType", $Key:["id"], "@AsterData.TitleField":"name", id:{$Type:"Edm.Int32"}, name:{}, platform:{"@Validation.AllowedValues":[{Value:"Linux"},{Value:"macOS"},{Value:"Windows"}]}, online:{$Type:"Edm.Boolean"}, cost:{$Type:"Edm.Int32"} },
Container: { $Kind:"EntityContainer", Devices:{$Collection:true,$Type:"C.Device"} },
} };
const data = { Devices:Array.from({length:240},(_,i)=>({ id:i+1, name:`Device ${String(i+1).padStart(3,"0")}`, platform:["Linux","macOS","Windows"][i%3], online:i%2===0, cost:20+i%50 })) };
const mock = await createMockService({metadata,data,wasm:new URL("../lib/odata_mock.wasm",document.baseURI)});
const ctx = await DataContext.connect(mock.transport);
const scope = new PageScope(undefined,{ctx});
const dashboard = document.querySelector("#dashboard");
provideScope(dashboard,scope);
dashboard.label = "Fleet dashboard";
const filters = new SharedFilters(ctx.schema);
document.querySelector("#filters").filters = filters;
const base = { set:"Devices", orderBy:[{field:"id"}] };
const collection = ctx.collection(filters.query(base),{pageSize:20,keepPages:2});
const selection = new Selection(ctx.store,collection,{multiple:true,fallback:false});
const status = document.querySelector("#dashboard-status");
const say = text => { status.textContent = text; };
const metrics = { created: new Map(), disposed: new Map(), scopes: new Map() };
function track(context, create) {
metrics.created.set(context.id,(metrics.created.get(context.id)??0)+1);
metrics.scopes.set(context.id,context.scope);
const session = create();
return { ...session, dispose() { metrics.disposed.set(context.id,(metrics.disposed.get(context.id)??0)+1); session.dispose(); } };
}
const confirm = document.querySelector("#confirm");
let settle, decision = false;
function askRemove(title) {
return new Promise(resolve => {
settle = resolve; decision = false;
confirm.labelledBy = "confirm-title";
confirm.content = html`<h2 id="confirm-title">Remove unsaved ${title}?</h2><p>This widget has unsaved notes. Discard them and remove the widget?</p>
<div class="bar"><button type="button" class="aster-button" @click=${()=>confirm.close()}>Keep widget</button>
<button type="button" class="aster-button" @click=${()=>{decision=true;confirm.close();}}>Discard and remove</button></div>`;
queueMicrotask(()=>confirm.show(document.activeElement));
});
}
confirm.addEventListener("close",()=>{settle?.(decision);settle=undefined;});
dashboard.registerType("summary",{create:context=>track(context,()=>{
const summary = ctx.summary(filters.query(base),[{field:"cost",method:"sum",as:"cost"}]);
return { render() {
const metric = context.config.value.metric ?? "count";
const values = summary.status.value === "idle" ? summary.values.value : undefined;
return html`<div class="dashboard-metric"><aster-choice .label=${"Summary metric"} .value=${metric}
.options=${[{value:"count",label:"Matching devices"},{value:"cost",label:"Monthly cost"}]}
@change=${event=>dashboard.configure(context.id,{metric:event.detail})}></aster-choice>
<output aria-label=${metric === "cost" ? "Monthly cost" : "Matching devices"}>${values ? Number(values[metric]).toLocaleString("en-GB") : "…"}</output>
<p>Exact totals across all matching records, including devices outside the loaded page.</p>
${summary.status.value === "error" ? html`<p role="status">Unable to load totals. <button type="button" class="aster-button" @click=${()=>summary.refresh()}>Retry totals</button></p>` : html``}</div>`;
},dispose:()=>summary.dispose() };
})});
let vendor;
const load = ()=>vendor??=import("./vendor/echarts.js").catch(error=>{vendor=undefined;throw error;});
dashboard.registerType("platforms",{create:context=>track(context,()=>{
const platform=signal(undefined);
const share=`dashboard-chart:${context.id}`;
const stop=filters.share(share,computed(()=>platform.value===undefined?undefined:{set:"Devices",kind:"value",label:`Platform is ${platform.value}`,criteria:{op:"eq",field:"platform",value:platform.value}}),()=>platform.value=undefined);
const groups=ctx.groups(filters.query(base,{self:share}),"platform");
const choose=value=>platform.value=platform.peek()===value?undefined:value;
const host=document.createElement("aster-component");
host.className="dashboard-chart";
host.label="Devices by platform";
host.alternative=()=>html`<table><caption>All matching devices. Choose a platform to filter every widget.</caption><thead><tr><th scope="col">Platform</th><th scope="col">Devices</th></tr></thead><tbody>
${groups.status.value==="idle"?(groups.rows.value??[]).map(row=>html`<tr><td><button type="button" class="aster-button" aria-pressed=${String(platform.value===row.value)} @click=${()=>choose(row.value)}>${row.value}</button></td><td>${row.count}</td></tr>`):html`<tr><td colspan="2">${groups.status.value==="error"?html`Unable to load totals. <button class="aster-button" @click=${()=>groups.refresh()}>Retry totals</button>`:"Loading totals…"}</td></tr>`}
</tbody></table>`;
host.adapter=echartsAdapter({load,option:env=>{
const colors=echartsColors(env);
const rows=groups.status.value==="idle"?(groups.rows.value??[]):[];
return {animation:true,animationDuration:450,animationDurationUpdate:450,grid:{left:50,right:20,top:25,bottom:30},xAxis:{type:"category",data:rows.map(row=>row.value),axisLabel:{color:colors.muted}},yAxis:{type:"value",minInterval:1,axisLabel:{color:colors.muted},splitLine:{lineStyle:{color:colors.rule}}},series:[{id:"platforms",type:"bar",data:rows.map(row=>({id:String(row.value),value:row.count,platform:row.value,itemStyle:{color:platform.value===row.value?colors.text:colors.accent}})),label:{show:true,position:"top",color:colors.text}}]};
},click:event=>{if(event.componentType==="series"&&event.data&&"platform" in event.data)choose(event.data.platform);}});
return {render:()=>html`${host}`,dispose(){stop();groups.dispose();}};
})});
dashboard.registerType("devices",{create:context=>track(context,()=>{
const grid=document.createElement("aster-grid");
grid.className="dashboard-devices";
grid.ctx=ctx; grid.collection=collection; grid.selection=selection; grid.label="Devices"; grid.publish="devices"; grid.editable=true;
grid.view={columns:["name","platform","online","cost"].map(field=>({field})),sort:[{field:"id"}],filters:{},frozen:0};
return {render:()=>html`${grid}`,beforeRemove:()=>grid.finishEditing(),dispose(){}};
})});
dashboard.registerType("notes",{create:context=>track(context,()=>{
const dirty=signal(false);
const input=document.createElement("textarea");
input.setAttribute("aria-label","Notes text");
input.placeholder="Keep a note while you explore the fleet…";
input.addEventListener("input",()=>dirty.value=true,{signal:context.signal});
return {render:()=>html`<div class="dashboard-notes"><p>Your draft stays here when you move, resize or maximize this widget.</p>${input}
<footer><button type="button" class="aster-button" @click=${()=>{dirty.value=false;say("Notes saved for this visit.");}}>Save notes</button><span role="status">${dirty.value?"Unsaved changes":"Saved"}</span></footer></div>`,
beforeRemove:()=>dirty.peek()?askRemove(dashboard.widgets.find(widget=>widget.id===context.id)?.title??"notes"):true,dispose(){}};
})});
const defaults = [
{id:"summary-1",type:"summary",title:"Fleet overview",width:1,height:1,config:{metric:"count"}},
{id:"platforms-1",type:"platforms",title:"Platform mix",width:2,height:2},
{id:"notes-1",type:"notes",title:"Notes",width:1,height:1},
{id:"devices-1",type:"devices",title:"Device fleet",width:3,height:2},
];
await dashboard.restore({version:1,widgets:defaults});
const picker=document.querySelector("#widget-type");
picker.label="Widget type"; picker.value="summary";
picker.options=[{value:"summary",label:"Summary"},{value:"platforms",label:"Platform chart"},{value:"devices",label:"Device grid"},{value:"notes",label:"Notes"}];
const titles={summary:"Fleet overview",platforms:"Platform mix",devices:"Device fleet",notes:"Notes"};
let nextWidget=2;
document.querySelector("#add-widget").addEventListener("click",()=>{
const type=picker.value;
let number;
do { number=nextWidget++; } while(dashboard.widgets.some(widget=>widget.id===`${type}-${number}`));
dashboard.add({id:`${type}-${number}`,type,title:`${titles[type]} ${number}`,width:type==="devices"?3:1,height:type==="platforms"?2:1,config:type==="summary"?{metric:"count"}:{}});
say(`${titles[type]} widget added.`);
});
const storageKey="aster-showcase-dashboard-v1";
document.querySelector("#save-layout").addEventListener("click",()=>{
try {localStorage.setItem(storageKey,JSON.stringify(dashboard.snapshot()));say("Dashboard layout saved on this browser.");}
catch {say("The dashboard layout could not be saved.");}
});
async function restore(saved) {
try {say(await dashboard.restore(saved)?"Dashboard layout restored. Retained widgets keep their content.":"Layout unchanged. Your widget content is still here.");}
catch {say("The saved dashboard layout is invalid. Your layout is unchanged.");}
}
document.querySelector("#restore-layout").addEventListener("click",()=>{
try {const json=localStorage.getItem(storageKey);if(!json)return say("Save a layout first.");void restore(JSON.parse(json));}
catch {say("The saved dashboard layout could not be read.");}
});
document.querySelector("#reset-layout").addEventListener("click",()=>restore({version:1,widgets:defaults}));
document.querySelector("#online").addEventListener("change",event=>event.target.checked?filters.set({by:"online",label:"Online only",set:"Devices",criteria:{op:"eq",field:"online",value:true}}):filters.remove("online"));
const changing=signal(false);
let nextId=241;
async function change(action) {
if(changing.peek())return;
changing.value=true;
try {await action();} catch(error){say(`Could not update devices: ${error.message}`);} finally {changing.value=false;}
}
document.querySelector("#add-device").addEventListener("click",()=>change(async()=>{
const id=nextId++;
await ctx.create("Devices",{id,name:`Device ${String(id).padStart(3,"0")}`,platform:"Linux",online:true,cost:35});
say(`Added Linux device ${id}. The widgets share the updated data.`);
}));
document.querySelector("#raise-costs").addEventListener("click",()=>change(async()=>{
const records=[...selection.records.peek()];
await Promise.all(records.map(record=>ctx.update("Devices",JSON.stringify([record.id]),{cost:Number(record.cost)+10})));
say(`Raised costs by 10 for ${records.length} selected devices.`);
}));
effect(()=>{
for(const id of ["add-widget","save-layout","restore-layout","reset-layout"])document.querySelector(`#${id}`).disabled=dashboard.busy;
document.querySelector("#raise-costs").disabled=changing.value||selection.count.value===0;
document.querySelector("#add-device").disabled=changing.value;
});
say("Ready. Filter the fleet, change data, or make this layout your own.");
window.addEventListener("pagehide",()=>ctx.release(collection),{once:true});
if(new URLSearchParams(location.search).has("debug"))globalThis.dashboardDemo={dashboard,ctx,filters,selection,collection,scope,metrics,mock,defaults};
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Dashboard composition — Aster UI</title>
<link id="identity" rel="stylesheet" href="../lib/themes/lamplight.css"><link rel="stylesheet" href="../lib/aster-ui.css">
<style>
body { height:auto; min-height:100dvh; padding:24px; background:var(--aster-surface); } main { max-width:1360px; margin:auto; }
.bar { display:flex; flex-wrap:wrap; align-items:center; gap:12px; margin-block:16px; }
.dashboard-toolbar { padding:16px; border:1px solid var(--aster-rule); border-radius:var(--aster-r-card); background:var(--aster-surface); margin-block:20px; }
.dashboard-toolbar .bar { margin:0; } .dashboard-toolbar p { margin:12px 0 0; color:var(--aster-text-2); }
#widget-type { width:12rem; } #dashboard { margin-block:20px; }
.dashboard-metric { display:flex; flex-direction:column; gap:12px; } .dashboard-metric output { font-size:clamp(38px,5vw,60px); font-weight:var(--aster-weight-strong); line-height:1.1; color:var(--aster-accent); }
.dashboard-metric p { color:var(--aster-text-2); margin:0; } .dashboard-metric aster-choice { max-width:220px; }
.dashboard-notes { display:flex; flex-direction:column; height:100%; gap:12px; } .dashboard-notes p { margin:0; color:var(--aster-text-2); }
.dashboard-notes textarea { flex:1; min-height:70px; width:100%; padding:10px; resize:none; font:inherit; background:var(--aster-sunk); color:var(--aster-text); border:1px solid var(--aster-rule); border-radius:var(--aster-r-ctl); }
.dashboard-notes footer { display:flex; align-items:center; gap:12px; }
.dashboard-devices { height:100%; display:flex; flex-direction:column; }
.dashboard-chart .aster-component { border:0; padding:0; } .dashboard-chart { --component-height:13rem; --component-data-height:13rem; }
.dashboard-chart .aster-component-alternative button { width:100%; text-align:start; }
.dashboard-chart button[aria-pressed=true] { background:var(--aster-accent-soft); border-color:var(--aster-accent); }
a { color:var(--aster-accent); }
@media(max-width:760px) { body { padding:12px; } }
</style><link rel="stylesheet" href="./showcase.css"></head><body><main>
<h1>Dashboard composition</h1>
<p>A fleet workspace you can make your own. Widgets share live data, filters and selection while keeping their own content and configuration.</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>
<label><input id="online" type="checkbox"> Online only</label>
<button type="button" class="aster-button" id="add-device">Add Linux device</button>
<button type="button" class="aster-button" id="raise-costs">Raise selected costs</button>
</div>
<aster-slicer id="filters"></aster-slicer>
<section class="dashboard-toolbar" aria-label="Dashboard controls"><div class="bar">
<aster-choice id="widget-type"></aster-choice><button type="button" class="aster-button" id="add-widget">Add widget</button>
<button type="button" class="aster-button" id="save-layout">Save layout</button><button type="button" class="aster-button" id="restore-layout">Restore layout</button>
<button type="button" class="aster-button" id="reset-layout">Reset layout</button>
</div><p id="dashboard-status" role="status" aria-live="polite">Loading fleet workspace…</p></section>
<p>Drag a widget’s Move handle onto another widget, or focus Move / Resize and use arrow keys. The actions menu offers the same controls. Maximize gives a widget more room; Escape restores it.</p>
<aster-dashboard id="dashboard"></aster-dashboard><aster-dialog id="confirm"></aster-dialog>
<p>Layouts are saved on this browser. Notes are kept for this visit; their text is not included in saved layouts.</p>
<p><a href="./">Back to the playground</a></p>
</main><script type="module" src="./showcase-nav.js"></script><script type="module" src="./dashboard.js"></script></body></html>
// 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
- Filter by Linux in the platform widget and watch the summary and grid follow.
- Add a Linux device to update the exact total and chart.
- Write a note, then use the widget Move and Resize controls with arrow keys.
- Save a layout, make a change and restore it. Try removing a dirty notes widget.
What the example owns
Registered widget factories own their sessions and disposers. The dashboard supplies stable IDs, child scopes and layout state. This demo shares a data context, filters and selection across widgets, while keeping notes and layout persistence separate.