Component explorer / Application controls
Charts: connected third-party components
Connect ECharts to shared filters, selected records and live data, with accessible HTML alternatives and reduced-motion support.
The charts use an optional ECharts chunk loaded on demand. Data changes are mock writes for this visit. The cost chart describes loaded devices; the platform chart uses server aggregates.
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.
Mount, update, resize and dispose the ECharts instance
This is the actual echartsAdapter implementation used by the preview. It shows runtime.init, setOption, resize, click listener registration/removal and disposal. options is supplied by the application; environment is supplied by ComponentHost.
From packages/elements/src/component/echarts.ts
export function echartsAdapter(options: EChartsAdapterOptions): ComponentAdapter {
return {
async mount({ node, signal, environment }) {
const runtime = await options.load();
if (signal.aborted)
return {
update() { }, dispose() { }
};
let chart: EChartsInstance | undefined;
let locale = environment.locale;
const click = (event: unknown) => { if (!signal.aborted)
options.click?.(event); };
const dispose = () => {
const owned = chart;
chart = undefined;
if (!owned)
return;
try {
owned.off("click", click);
}
finally {
owned.dispose();
}
};
const init = (current: ComponentEnvironment) => {
chart = runtime.init(node, undefined, {
renderer: "svg", locale: current.locale, devicePixelRatio: current.pixelRatio
});
try {
chart.on("click", click);
}
catch (error) {
dispose();
throw error;
}
locale = current.locale;
};
init(environment);
let previous: ComponentEnvironment | undefined;
let previousMotion: boolean | undefined;
return {
update(current) {
// Read data while hidden too, so showing the component uses the latest state.
const option = options.option(current);
if (!current.visible || current.forcedColors) {
node.hidden = true;
previous = undefined;
return;
}
node.hidden = false;
if (locale !== current.locale) {
dispose();
init(current);
previous = undefined;
previousMotion = undefined;
}
const colors = echartsColors(current);
chart!.setOption({
backgroundColor: colors.surface,
color: [colors.accent, colors.muted, colors.negative],
textStyle: {
color: colors.text, fontFamily: current.font
},
...option,
// HTML tooltips would escape the host's presentation-only subtree.
tooltip: {
show: false
},
animation: !current.reducedMotion && option.animation !== false,
...(current.reducedMotion ? {
animationDuration: 0, animationDurationUpdate: 0,
series: (Array.isArray(option.series) ? option.series : option.series ? [option.series] : []).map((series: Record<string, unknown>) => ({
...series, animation: false, animationDuration: 0, animationDurationUpdate: 0, universalTransition: false
})),
} : {}),
}, options.updateMode === "merge" && (previousMotion === undefined || previousMotion === current.reducedMotion)
? {
notMerge: false, replaceMerge: ["series"], silent: true
}
: {
notMerge: true, silent: true
});
previousMotion = current.reducedMotion;
if (!previous || previous.width !== current.width || previous.height !== current.height || previous.pixelRatio !== current.pixelRatio) {
chart!.resize({
width: current.width, height: current.height, silent: true
});
}
previous = current;
},
dispose,
};
},
};
}
Bundle the optional ECharts runtime
This vendor entry imports ECharts itself. The site build emits the module as playground/vendor/echarts.js; the application loads it lazily. The integration guide includes installation and the standalone bundle command.
From scripts/site/vendors/echarts.ts
import { init as createChart } from "echarts";
import type { EChartsRuntime } from "@getaster/elements";
export const init: EChartsRuntime["init"] = createChart;
Turn live data signals into chart options and actions
groups is a grouped query over the shared context. Reading its signals in option makes updates reactive. The click callback translates a vendor event into a shared platform filter.
From scripts/site/playground/components.js
platforms.adapter = echartsAdapter({
load, updateMode: "merge", option: env => {
const colors = echartsColors(env);
// Groups retain the last result during a same-query refresh; keep those bars mounted.
const rows = groups.rows.value ?? [];
return {
animation: true, animationDuration: 250, animationDurationUpdate: 200, animationEasing: "cubicOut", grid: {
left: 55, right: 15, top: 25, bottom: 35
}, xAxis: {
type: "category", data: rows.map(r => r.value ?? "Unspecified"), axisLabel: {
color: colors.muted
}
}, yAxis: {
type: "value", minInterval: 1, max: range => Math.max(10, Math.ceil(range.max * 1.15 / 10) * 10), axisLabel: {
color: colors.muted
}, splitLine: {
lineStyle: {
color: colors.rule
}
}
}, series: [{
id: "platform-counts", type: "bar", data: rows.map(r => ({
id: String(r.value), name: String(r.value), value: r.count, platform: r.value, itemStyle: {
color: platform.value === r.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)
choosePlatform(event.data.platform); }
});
Full application source Application code & shared presentation
import { DataContext, PageScope, SharedFilters, Selection, FilterDraft, viewQuery, echartsAdapter, echartsColors, provideScope, signal, computed, effect, html } 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 requests=[];
const ctx=await DataContext.connect(r=>{requests.push(r);return mock.transport(r);});
const scope=new PageScope(undefined,{ctx});
provideScope(document.querySelector("main"),scope);
const filters=new SharedFilters(ctx.schema);
document.querySelector("#filters").filters=filters;
const platform=signal(undefined);
const stopShare=filters.share("platform-chart",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 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 grid=document.querySelector("#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};
const gridView=scope.value("devices.view");
const groupBase=computed(()=>{
const view=gridView.value??grid.view;
const valid=!view.criteria||new FilterDraft(ctx.schema,"Devices",view.criteria).result.value.valid;
return viewQuery("Devices",valid?view:{...view,criteria:{op:"or",args:[]}});
});
const groups=ctx.groups(filters.query(groupBase,{self:"platform-chart"}),"platform");
const platforms=document.querySelector("#platforms");
const costs=document.querySelector("#costs");
let vendor,attempt=0;
const load=()=>vendor??=import(`./vendor/echarts.js${attempt?`?attempt=${attempt}`:""}`).catch(error=>{vendor=undefined;attempt++;throw error;});
const choosePlatform=value=>platform.value=platform.peek()===value?undefined:value;
const loaded=computed(()=>collection.keys.value.flatMap(key=>{
if(key===undefined)return [];
const entity=ctx.store.record("Devices",key).value?.entity;
return entity?[{key,entity}]:[];
}).slice(0,20));
const chooseDevice=key=>selection.toggle(key);
platforms.label="Devices by platform";
platforms.alternative=()=>html`<table aria-busy=${groups.status.value === "loading" ? "true" : "false"}><caption>Server totals for all matching devices. Choose a platform to filter the grid.
<aster-progress-status class="component-data-state" .busy=${groups.status.value === "loading"}
.pendingText=${"Updating totals…"} .completeText=${"Totals updated"}
.error=${groups.status.value === "error" ? "Unable to update totals." : undefined}></aster-progress-status>
${groups.status.value === "error" ? html`<button class="aster-button" @click=${()=>groups.refresh()}>Retry totals</button>` : ""}</caption><thead><tr><th scope="col">Platform</th><th scope="col">Devices</th></tr></thead><tbody>
${groups.rows.value!==undefined?groups.rows.value.map(row=>html`<tr><td><button class="aster-button" type="button" aria-pressed=${platform.value===row.value} @click=${()=>choosePlatform(row.value)}>${row.value??"Unspecified"}</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>`;
platforms.adapter=echartsAdapter({load,updateMode:"merge",option:env=>{
const colors=echartsColors(env);
// Groups retain the last result during a same-query refresh; keep those bars mounted.
const rows=groups.rows.value??[];
return {animation:true,animationDuration:250,animationDurationUpdate:200,animationEasing:"cubicOut",grid:{left:55,right:15,top:25,bottom:35},xAxis:{type:"category",data:rows.map(r=>r.value??"Unspecified"),axisLabel:{color:colors.muted}},yAxis:{type:"value",minInterval:1,max:range=>Math.max(10,Math.ceil(range.max*1.15/10)*10),axisLabel:{color:colors.muted},splitLine:{lineStyle:{color:colors.rule}}},series:[{id:"platform-counts",type:"bar",data:rows.map(r=>({id:String(r.value),name:String(r.value),value:r.count,platform:r.value,itemStyle:{color:platform.value===r.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)choosePlatform(event.data.platform);}});
costs.label="Loaded device costs";
costs.alternative=()=>html`<table><caption>First ${loaded.value.length} loaded devices of ${collection.count.value??"…"} matching. Grid scrolling loads other records. Select devices here or in the grid.</caption><thead><tr><th scope="col">Device</th><th scope="col">Cost</th></tr></thead><tbody>
${loaded.value.map(({key,entity})=>html`<tr><td><button class="aster-button" type="button" aria-pressed=${selection.keys.value.includes(key)} @click=${()=>chooseDevice(key)}>${entity.name}</button></td><td>${entity.cost}</td></tr>`)}
</tbody></table>`;
costs.adapter=echartsAdapter({load,updateMode:"merge",option:env=>{
const colors=echartsColors(env);
const selected=selection.keys.value;
return {animation:true,animationDuration:250,animationDurationUpdate:200,animationEasing:"cubicOut",grid:{left:45,right:15,top:25,bottom:50},xAxis:{type:"category",data:loaded.value.map(r=>String(r.entity.id)),axisLabel:{color:colors.muted}},yAxis:{type:"value",axisLabel:{color:colors.muted},splitLine:{lineStyle:{color:colors.rule}}},series:[{id:"device-costs",type:"bar",data:loaded.value.map(({key,entity})=>({id:key,name:key,key,value:entity.cost,itemStyle:{color:selected.includes(key)?colors.text:colors.accent}}))}]};
},click:event=>{if(event.componentType==="series"&&typeof event.data?.key==="string")chooseDevice(event.data.key);}});
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"));
document.querySelector("#hide").addEventListener("click",event=>{const charts=document.querySelector("#charts");charts.hidden=!charts.hidden;event.target.textContent=charts.hidden?"Show charts":"Hide charts";});
document.querySelector("#reload").addEventListener("click",()=>{platforms.remove();costs.remove();document.querySelector("#charts").append(platforms,costs);});
const changing=signal(false);
const targets=()=>selection.records.peek().length?selection.records.peek():loaded.peek().map(row=>row.entity);
effect(()=>{
const busy=changing.value;
const selected=selection.count.value;
const count=selected||loaded.value.length;
document.querySelector("#raise-costs").textContent=selected?"Raise selected costs":"Raise displayed costs";
document.querySelector("#toggle-status").textContent=selected?"Toggle selected status":"Toggle displayed status";
for(const id of ["raise-costs","toggle-status"])document.querySelector(`#${id}`).disabled=busy||count===0;
for(const id of ["add-linux","add-windows"])document.querySelector(`#${id}`).disabled=busy;
});
async function changeData(action) {
if(changing.peek())return;
changing.value=true;
try {
if(!(await grid.finishEditing())){document.querySelector("#data-status").textContent="Finish the current edit before changing the fleet.";return;}
document.querySelector("#data-status").textContent=await action();
}
catch(error){document.querySelector("#data-status").textContent=`Couldn’t change the fleet: ${error.message}`;}
finally{changing.value=false;}
}
document.querySelector("#raise-costs").addEventListener("click",()=>changeData(async()=>{
const records=[...targets()];
await Promise.all(records.map(record=>ctx.update("Devices",JSON.stringify([record.id]),{cost:Number(record.cost)+10})));
return `Raised costs by 10 for ${records.length} ${records.length===1?"device":"devices"}.`;
}));
document.querySelector("#toggle-status").addEventListener("click",()=>changeData(async()=>{
const records=[...targets()];
await Promise.all(records.map(record=>ctx.update("Devices",JSON.stringify([record.id]),{online:!record.online})));
return `Updated online status for ${records.length} ${records.length===1?"device":"devices"}.`;
}));
let nextId=241;
for(const [id,platform] of [["add-linux","Linux"],["add-windows","Windows"]])document.querySelector(`#${id}`).addEventListener("click",()=>changeData(async()=>{
const number=nextId++;
await ctx.create("Devices",{id:number,name:`Device ${String(number).padStart(3,"0")}`,platform,online:true,cost:35});
return `Added ${platform} device ${number}. Only devices matching the current filters appear in the grid.`;
}));
// The page owns borrowed data models; individual hosts dispose only their sessions.
window.addEventListener("pagehide",()=>{stopShare();groups.dispose();ctx.release(collection);},{once:true});
if(new URLSearchParams(location.search).has("debug"))globalThis.componentDemo={ctx,mock,requests,collection,selection,filters,groups,platforms,costs,loaded,platform};
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Third-party components — 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:1160px; margin:auto; }
.bar { display:flex; flex-wrap:wrap; align-items:center; gap:12px; margin-block:16px; }
.charts { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:16px; margin-block:16px; }
.component-data-state { display:block; min-block-size:1.5em; }
.data-playground { padding:16px; margin-block:20px; background:var(--aster-sunk); border:1px solid var(--aster-rule); border-radius:var(--aster-r-card); }
.data-playground h2 { margin:0 0 8px; } .data-playground p { margin:8px 0; color:var(--aster-text-2); }
#devices { display:flex; height:28rem; min-width:0; flex-direction:column; }
a { color:var(--aster-accent); } .aster-component-alternative button { width:100%; text-align:start; }
.aster-component-alternative button[aria-pressed=true] { border-color:var(--aster-accent); background:var(--aster-accent-soft); }
@media(max-width:760px) { .charts { grid-template-columns:minmax(0,1fr); } body { padding:12px; } }
@media(pointer:coarse) { .aster-component-alternative button { min-height:44px; } }
</style><link rel="stylesheet" href="./showcase.css"></head><body><main>
<h1>Third-party components</h1>
<p>Optional ECharts components use the public data layer. Platform totals cover every matching device; the cost chart explicitly shows loaded records. Both have the same actions in the HTML data views.</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="hide">Hide charts</button>
<button type="button" class="aster-button" id="reload">Remount charts</button>
</div>
<section class="data-playground" aria-labelledby="data-title">
<h2 id="data-title">Make the data move</h2>
<p>Change the fleet and watch the charts and grid respond together. Cost and status actions use your selection, or the displayed devices when nothing is selected. You can also double-click a grid cell to edit it.</p>
<div class="bar">
<button type="button" class="aster-button" id="raise-costs">Raise displayed costs</button>
<button type="button" class="aster-button" id="toggle-status">Toggle displayed status</button>
<button type="button" class="aster-button" id="add-linux">Add Linux device</button>
<button type="button" class="aster-button" id="add-windows">Add Windows device</button>
</div>
<p id="data-status" role="status" aria-live="polite">Ready to try. Select devices in either view to target your changes.</p>
</section>
<aster-slicer id="filters"></aster-slicer>
<h2>Charts</h2>
<div class="charts" id="charts"><aster-component id="platforms"></aster-component><aster-component id="costs"></aster-component></div>
<h2>Devices</h2><aster-grid id="devices"></aster-grid>
<p><a href="./">Back to the playground</a></p>
</main><script type="module" src="./showcase-nav.js"></script><script type="module" src="./components.js"></script></body></html>
import { init as createChart } from "echarts";
import type { EChartsRuntime } from "@getaster/elements";
export const init: EChartsRuntime["init"] = createChart;
import type { ComponentAdapter, ComponentEnvironment } from "./component.ts";
/** The small structural API the adapter needs; pass an optional ECharts module via load. */
export interface EChartsRuntime {
/** Creates one SVG chart. Locale packs are registered by the application loader. */
init(node: HTMLElement, theme?: undefined, options?: { renderer: "svg"; locale: string; devicePixelRatio: number }): EChartsInstance;
}
/** Public ECharts methods used by the reference adapter. */
export interface EChartsInstance {
/** Updates chart options without publishing synthetic selection events. */
setOption(option: object, options: { notMerge: boolean; silent: boolean; replaceMerge?: string[] }): void;
/** Resizes internal chart rendering to observed CSS dimensions. */
resize(options: { width: number; height: number; silent: boolean }): void;
/** Attaches the adapter's pointer action callback. */
on(event: "click", listener: (event: unknown) => void): void;
/** Removes the same callback before disposing. */
off(event: "click", listener: (event: unknown) => void): void;
/** Releases vendor-owned rendering and event resources. */
dispose(): void;
}
/** Configuration stays in the application; no ECharts dependency ships in the elements package. */
export interface EChartsAdapterOptions {
/** Lazy module load. Register any modular charts/components before returning it. */
readonly load: () => Promise<EChartsRuntime>;
/** Reads public data signals; must return a complete option, including empty/loading states. */
readonly option: (environment: ComponentEnvironment) => Readonly<Record<string, unknown>>;
/** Merge stable named series/data for incremental transitions; omitted option fields are retained. Default replace. */
readonly updateMode?: "replace" | "merge";
/** Explicit mapping of vendor events to public data actions; no implicit record/index mapping. */
readonly click?: (event: unknown) => void;
}
/** CSS colours resolved to sRGB for vendor parsers that don't understand modern CSS colours. */
export function echartsColors(environment: ComponentEnvironment): ComponentEnvironment["theme"] {
const canvas = document.createElement("canvas");
canvas.width = canvas.height = 1;
const ctx = canvas.getContext("2d", { willReadFrequently: true })!;
const colour = (value: string) => {
ctx.clearRect(0, 0, 1, 1);
ctx.fillStyle = value;
ctx.fillRect(0, 0, 1, 1);
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
return `rgba(${r}, ${g}, ${b}, ${a! / 255})`;
};
const theme = environment.theme;
return { surface: colour(theme.surface), text: colour(theme.text), muted: colour(theme.muted), rule: colour(theme.rule), accent: colour(theme.accent), positive: colour(theme.positive), negative: colour(theme.negative) };
}
/** ECharts reference lifecycle: stable SVG, reactive options, silent updates and exact disposal. */
export function echartsAdapter(options: EChartsAdapterOptions): ComponentAdapter {
return {
async mount({ node, signal, environment }) {
const runtime = await options.load();
if (signal.aborted) return { update() {}, dispose() {} };
let chart: EChartsInstance | undefined;
let locale = environment.locale;
const click = (event: unknown) => { if (!signal.aborted) options.click?.(event); };
const dispose = () => {
const owned = chart; chart = undefined;
if (!owned) return;
try { owned.off("click", click); }
finally { owned.dispose(); }
};
const init = (current: ComponentEnvironment) => {
chart = runtime.init(node, undefined, { renderer: "svg", locale: current.locale, devicePixelRatio: current.pixelRatio });
try { chart.on("click", click); }
catch (error) { dispose(); throw error; }
locale = current.locale;
};
init(environment);
let previous: ComponentEnvironment | undefined;
let previousMotion: boolean | undefined;
return {
update(current) {
// Read data while hidden too, so showing the component uses the latest state.
const option = options.option(current);
if (!current.visible || current.forcedColors) {
node.hidden = true;
previous = undefined;
return;
}
node.hidden = false;
if (locale !== current.locale) {
dispose();
init(current);
previous = undefined;
previousMotion = undefined;
}
const colors = echartsColors(current);
chart!.setOption({
backgroundColor: colors.surface,
color: [colors.accent, colors.muted, colors.negative],
textStyle: { color: colors.text, fontFamily: current.font },
...option,
// HTML tooltips would escape the host's presentation-only subtree.
tooltip: { show: false },
animation: !current.reducedMotion && option.animation !== false,
...(current.reducedMotion ? {
animationDuration: 0, animationDurationUpdate: 0,
series: (Array.isArray(option.series) ? option.series : option.series ? [option.series] : []).map((series: Record<string, unknown>) => ({ ...series, animation: false, animationDuration: 0, animationDurationUpdate: 0, universalTransition: false })),
} : {}),
}, options.updateMode === "merge" && (previousMotion === undefined || previousMotion === current.reducedMotion)
? { notMerge: false, replaceMerge: ["series"], silent: true }
: { notMerge: true, silent: true });
previousMotion = current.reducedMotion;
if (!previous || previous.width !== current.width || previous.height !== current.height || previous.pixelRatio !== current.pixelRatio) {
chart!.resize({ width: current.width, height: current.height, silent: true });
}
previous = current;
},
dispose,
};
},
};
}
// 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
- Choose Linux in the chart’s data table to filter the device grid.
- Add a Linux device and watch the server-backed platform totals update.
- Select a displayed device, raise its cost and compare the chart with the grid.
- Hide and show the charts, remount them, then try a dark scheme or reduced motion.
What the example owns
ComponentHost provides the vendor-owned content surface and its lifecycle. The injected ECharts adapter owns the chart instance; the page owns shared data, filters and selection. HTML data/action views remain available alongside the visualization.