Component explorer / Application controls
Composition: a JS and YAML workspace
Declare a table, tiles, actions and a lazy editor around one shared model, with application logic in ordinary JavaScript.
The definition is authored in YAML and compiled to JSON at build time. Registered functions execute the behaviour. Mock edits, selection and drafts last for this visit; browser reload does not restore them.
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.
Bind two presentations to one model
The YAML names the children and connects their collection, selection and renderer properties. It is configuration over the same runtime used by JavaScript definitions.
From scripts/site/playground/composition.ui.yaml
table:
type: Grid
props:
ctx: { ref: inputs.ctx }
label: Managed devices table
selectable: true
collection: { ref: model.collection }
selection: { ref: model.selection }
view:
columns:
- { field: name }
- { field: platform, width: 130 }
- { field: owner, width: 140 }
sort: []
filters: {}
frozen: 0
tiles:
type: TileGrid
create: on-demand
retain: true
props:
virtualized: true
label: Managed device tiles
selectable: true
collection: { ref: model.collection }
selection: { ref: model.selection }
renderTile: { renderer: device-card }
presentTile: { renderer: device-description }Connect actions and their PageRules
Command names resolve to exports from the registered model. The Save rule reads published editor state; the executable save function stays in JavaScript.
From scripts/site/playground/composition.ui.yaml
commands:
table: { use: model.commands.table }
tiles: { use: model.commands.tiles }
clear: { use: model.commands.clear }
edit:
use: model.commands.edit
enabledWhen: devices.any
reason: Select a device to edit.
save:
use: model.commands.save
enabledWhen:
op: and
args:
- editor.dirty
- { op: not, arg: editor.busy }
reason: Change a field before saving.
discard: { use: model.commands.discard }Implement queries, commands and cleanup in JavaScript
This is the running model and its save function. The lifetime releases the collection and editor. A single editor survives table/tile switching, and saving updates their shared store.
From scripts/site/playground/composition-model.js
async function saveDevice(editor) {
const saved = await editor.save();
if (!saved)
throw new Error("Review the field errors before saving again.");
return saved;
}
export function createDeviceWorkspace({ ctx, selection: selectionPolicy }, lifetime) {
const query = signal("");
const collection = ctx.collection(computed(() => {
const name = query.value.trim();
return {
set: "Devices", orderBy: [{
field: "name"
}], ...(name ? {
filter: {
op: "contains", field: "name", value: name
}
} : {})
};
}), {
pageSize: 24, keepPages: 3
});
lifetime.defer(() => ctx.release(collection));
const selection = new Selection(ctx.store, collection, selectionPolicy);
const presentation = signal("table");
const editor = signal();
const detailsOpen = computed(() => editor.value !== undefined);
const dirty = computed(() => editor.value?.dirty.value ?? false);
const busy = computed(() => editor.value?.busy.value ?? false);
const message = signal("Select a device, open its editor, then try both presentations.");
const closeEditor = () => { const previous = editor.value; editor.value = undefined; previous?.dispose(); };
lifetime.defer(closeEditor);
const ownCommand = options => lifetime.own(command(options));
const commands = {
table: ownCommand({
label: "Table", run: () => { presentation.value = "table"; }
}),
tiles: ownCommand({
label: "Tiles", run: () => { presentation.value = "tiles"; }
}),
clear: ownCommand({
label: "Clear selection", enabled: () => selection.any.value, run: () => selection.clear()
}),
edit: ownCommand({
label: "Edit selected", enabled: () => selection.any.value && !detailsOpen.value,
reason: () => detailsOpen.value ? "Save or discard the current draft first." : "Select a device to edit.",
run: () => { editor.value = new EditModel(ctx, "Devices", selection.key.value); message.value = "One draft is shared across both presentations. Save also works with Ctrl/⌘ S."; }
}),
save: ownCommand({
label: "Save device", enabled: () => dirty.value && !busy.value,
run: async (signal) => { const edit = editor.value; await saveDevice(edit); signal.throwIfAborted(); message.value = "Saved. The shared store updated both views."; closeEditor(); }
}),
discard: ownCommand({
label: "Discard draft", enabled: () => detailsOpen.value && !busy.value,
run: () => { closeEditor(); message.value = "Draft discarded. The stored record is unchanged."; }
}),
};
return {
values: {
collection, selection, presentation, editor, detailsOpen, dirty, busy, query, message
},
publications: {
selection: selectionPublication(selection), editor: valuesPublication({
dirty, busy
})
},
commands,
};
}
Register a lazy child through ordinary module loading
The registry allowlists the component and supplies its dynamic import. YAML activates the named child; it cannot invent executable code or arbitrary module URLs.
From scripts/site/playground/composition.js
let detailsModule;
for (const [type, factory] of [["DeviceDetails", "createDetails"], ["CompactDetails", "createCompactDetails"]]) {
components[type] = {
props: {
editor: t.optional(t.custom("EditModel", value => !!value?.save))
},
load: async () => { detailsModule = await import("./composition-details.js"); return detailsModule[factory]; },
apply: (element, props) => detailsModule.applyDetails(element, props),
};
}
Full application source Application code & shared presentation
import { asterComponents, Composite, compositeElement, compositeTypes as t, defineCompositeRegistry, defineCompositeRenderer, deriveComposite, effect, html } from "../lib/aster-ui.js";
import { DataContext } from "../lib/aster-ui.js";
import { createMockService } from "../lib/aster-ui-mock.js";
import { deviceWorkspaceType } from "./composition-model.js";
import { DraftStatus } from "./composition-status.js";
const models = { "device-workspace": deviceWorkspaceType };
const components = { DraftStatus: compositeElement(DraftStatus), CommandBar: asterComponents.CommandBar, Grid: asterComponents.Grid, TileGrid: asterComponents.TileGrid };
const renderers = {};
// YAML's renderer names resolve to these ordinary functions.
function deviceCard(device) {
return html`<div class="composition-device"><svg viewBox="0 0 48 48" aria-hidden="true"><rect x="5" y="6" width="38" height="28" rx="3"/><path d="M16 42h16M24 34v8"/></svg><strong>${device.name}</strong><span>${device.platform} · ${device.owner}</span></div>`;
}
renderers["device-card"] = defineCompositeRenderer(["renderTile"], deviceCard);
renderers["device-description"] = defineCompositeRenderer(["presentTile"], device => ({ title: device.name, description: `${device.platform}, ${device.owner}` }));
// The allowlisted module loader is code, not a URL supplied by YAML.
let detailsModule;
for (const [type, factory] of [["DeviceDetails", "createDetails"], ["CompactDetails", "createCompactDetails"]]) {
components[type] = {
props: { editor: t.optional(t.custom("EditModel", value => !!value?.save)) },
load: async () => { detailsModule = await import("./composition-details.js"); return detailsModule[factory]; },
apply: (element, props) => detailsModule.applyDetails(element, props),
};
}
const authoring = defineCompositeRegistry({ inputs: { ctx: t.instance(DataContext) }, models, components, renderers });
const registry = authoring.registry;
const metadata = { $Version: "4.01", $EntityContainer: "Demo.Container",
$Reference: {
"https://oasis-tcs.github.io/odata-vocabularies/vocabularies/Org.OData.Core.V1.json": { $Include: [{ $Namespace: "Org.OData.Core.V1", $Alias: "Core" }] },
"https://aster.dev/vocabularies/Aster.Data.V1.json": { $Include: [{ $Namespace: "Aster.Data.V1", $Alias: "AsterData" }] },
}, Demo: {
Device: { $Kind: "EntityType", $Key: ["id"], id: { $Type: "Edm.Int32" }, name: { $Nullable: false, "@Core.Description": "Name", "@AsterData.MinLength": 1 }, platform: { "@Core.Description": "Platform" }, owner: { "@Core.Description": "Owner" } },
Container: { $Kind: "EntityContainer", Devices: { $Collection: true, $Type: "Demo.Device" } },
} };
const data = { Devices: Array.from({ length: 72 }, (_, i) => ({ id: i + 1, name: `Device ${String(i + 1).padStart(3, "0")}`, platform: ["macOS", "Linux", "Windows"][i % 3], owner: ["Design", "Engineering", "Operations"][i % 3] })) };
const out = document.querySelector("#workspace");
try {
const mock = await createMockService({ metadata, data, wasm: new URL("../lib/odata_mock.wasm", import.meta.url) });
const ctx = await DataContext.connect(mock.transport);
const response = await fetch("./composition.ui.json");
if (!response.ok) throw new Error("The workspace definition could not be loaded.");
const { definition: sourceDefinition, sources } = await response.json();
const definition = authoring.validate(sourceDefinition, sources);
let instance;
let stop;
let timer;
let variant = "standard";
const variants = {
standard: definition,
semantic: definition,
single: deriveComposite(definition, { name: "SingleDeviceWorkspace", modelInputs: { selection: { multiple: false, fallback: false } } }),
inspect: deriveComposite(definition, { name: "DisplayOnlyTable", children: { table: { props: { selectable: false, label: "Devices (selection controlled by Tiles)" } } } }),
compact: deriveComposite(definition, { name: "CompactDeviceWorkspace", children: { details: { type: "CompactDetails" } } }),
};
const variantChoice = document.querySelector("#variant");
variantChoice.label = "Workspace variant";
variantChoice.options = [ { value: "standard", label: "Standard" }, { value: "single", label: "Single selection" }, { value: "inspect", label: "Display-only table" }, { value: "compact", label: "Compact inspector" }, { value: "semantic", label: "Semantic layout" } ];
variantChoice.value = variant;
function mount() {
const placement = variant === "semantic" ? authoring.placement(variants[variant], place => html`
<section class="composition-regions" aria-label="Device workspace">
<header>${place("views")}${place("actions")}${place("status")}</header>
<section aria-label="Device views">${place("table")}${place("tiles")}</section>
<aside aria-label="Device inspector">${place("details")}</aside>
</section>`) : undefined;
instance = new Composite(variants[variant], registry, { inputs: { ctx }, ctx, sources, ...(placement ? { placement } : {}) }).mount(out);
stop = effect(() => {
document.querySelector("#status").textContent = instance.model.message.value;
document.querySelector("#rule-status").textContent = instance.commands.save.reason.value ?? "Save is available.";
});
document.querySelector("#query").value = "";
globalThis.compositionDemo = { instance, registry, authoring, definition, variants, sources, ctx, mock };
}
mount();
const schemaLink = document.querySelector("#download-schema");
const schemaUrl = URL.createObjectURL(new Blob([JSON.stringify(authoring.schema(definition), null, 2)], { type: "application/schema+json" }));
schemaLink.href = schemaUrl;
schemaLink.download = "composition.schema.json";
addEventListener("pagehide", event => { if (!event.persisted) URL.revokeObjectURL(schemaUrl); });
document.querySelector("#query").addEventListener("input", event => { clearTimeout(timer); const value = event.target.value; timer = setTimeout(() => { instance.model.query.value = value; }, 160); });
addEventListener("keydown", event => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s") { event.preventDefault(); void instance.commands.save.execute().catch(() => {}); }
});
function teardown() { clearTimeout(timer); stop(); instance.dispose(); }
document.querySelector("#reset").addEventListener("click", async () => {
const dirty = instance.model.dirty.value;
if (instance.model.busy.value) return;
if (dirty && !await confirmDiscard()) return;
teardown(); mount();
});
let changing = false;
variantChoice.addEventListener("change", async event => {
if (event.target !== variantChoice || changing) return;
const next = variantChoice.value;
if (instance.model.busy.value) { variantChoice.value = variant; return; }
changing = true;
try {
if (instance.model.dirty.value && !await confirmDiscard()) { variantChoice.value = variant; return; }
teardown(); variant = next; mount();
} finally { changing = false; }
});
async function confirmDiscard() {
const dialog = document.querySelector("#confirm-reset");
dialog.returnValue = "keep";
dialog.showModal();
return new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue === "discard"), { once: true }));
}
addEventListener("pagehide", event => { if (!event.persisted) teardown(); });
} catch (error) { out.setAttribute("role", "alert"); out.textContent = error.message; }
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Declarative device workspace — 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="./showcase.css">
<style>
body { height:auto; min-height:100dvh; padding:24px; background:var(--aster-surface); }
main { max-width:1120px; margin:auto; } h1 { margin-block-end:8px; } a { color:var(--aster-accent); }
.composition-intro { max-width:75ch; color:var(--aster-text-2); }
.composition-tools { display:flex; flex-wrap:wrap; align-items:end; gap:16px; margin-block:24px; }
.composition-tools label { display:grid; gap:6px; flex:1; min-width:180px; }
.composition-tools #variant { width:13rem; }
.composition-tools input { color:var(--aster-text); background:var(--aster-sunk); border:1px solid var(--aster-rule); border-radius:var(--aster-r-ctl); padding:10px 12px; font:inherit; }
#workspace { border:1px solid var(--aster-rule); border-radius:var(--aster-r-card); padding:20px; }
#workspace aster-grid { display:flex; flex-direction:column; height:390px; min-width:0; }
#workspace aster-tile-grid { display:block; --tile-viewport-height:390px; --tile-min-width:208px; --tile-height:174px; }
.composition-device { display:flex; flex-direction:column; align-items:start; gap:8px; padding:12px; }
.composition-device svg { width:42px; height:42px; fill:none; stroke:var(--aster-accent); stroke-width:2; }
.composition-device span { color:var(--aster-text-2); }
.composition-regions { display:grid; gap:16px; }
.composition-regions > header { display:grid; gap:12px; }
.composition-regions > section,.composition-regions > aside { min-width:0; }
.composition-draft-status { display:flex; align-items:center; gap:8px; margin:0; color:var(--aster-text-2); font-size:.875rem; }
.composition-draft-status > span { color:var(--aster-accent); }
.composition-editor { background:var(--aster-sunk); padding:20px; border:1px solid var(--aster-rule); border-radius:var(--aster-r-card); }
.composition-editor h2 { margin-top:0; } .composition-editor p { color:var(--aster-text-2); }
.composition-status { margin-block:20px; padding:16px; border-inline-start:3px solid var(--aster-accent); background:var(--aster-sunk); }
.composition-status p { margin:4px 0; } #rule-status { color:var(--aster-text-2); }
.composition-sources { display:flex; flex-wrap:wrap; gap:16px; margin-block:20px; }
dialog { color:var(--aster-text); background:var(--aster-surface); border:1px solid var(--aster-rule); border-radius:var(--aster-r-card); padding:24px; max-width:min(28rem,90vw); }
dialog::backdrop { background:var(--aster-backdrop, #0008); }
@media(max-width:600px) { body { padding:12px; } #workspace { padding:12px; } .composition-editor { padding:12px; } }
@media(pointer:coarse) { .aster-command-bar .aster-button { min-height:44px; } }
</style></head><body><main>
<h1>Declaratively compose your layouts with YAML or JS</h1>
<p class="composition-intro">Named children, ordinary JavaScript behaviour, one shared selection and editing session. PageRules controls Save; details load only when opened. Mock changes last for this visit.</p>
<div class="composition-tools">
<div class="showcase-appearance"><span>Variant</span><aster-choice id="variant"></aster-choice></div>
<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>Find a device<input id="query" placeholder="Try ‘012’" type="search"></label>
<button class="aster-button" type="button" id="reset">Recreate workspace</button>
</div>
<div id="workspace" aria-label="Declarative device workspace"></div>
<div class="composition-status"><p id="status" role="status">Connecting to the sample fleet…</p><p id="rule-status"></p></div>
<div class="composition-sources"><a href="./composition.ui.yaml">YAML definition</a><a id="download-schema">Download YAML schema</a><a href="./composition.js">Host registrations & renderers</a><a href="./composition-model.js">Model & function bodies</a><a href="./composition-details.js">Lazy details component</a><a href="./composition-status.js">Custom component declaration</a><a href="../guide/declarative-workspace.html">How it works →</a></div>
<dialog id="confirm-reset" aria-labelledby="discard-title"><form method="dialog"><h2 id="discard-title">Discard this draft?</h2><p>Recreating the workspace clears its selection and unsaved draft.</p><button class="aster-button" value="keep" autofocus>Keep editing</button> <button class="aster-button" value="discard">Discard and recreate</button></form></dialog>
</main><script type="module" src="./showcase-nav.js"></script><script type="module" src="./composition.js"></script></body></html>
# Parsed at build time; the browser loads composition.ui.json.
version: 1
name: DeviceWorkspace
model:
type: device-workspace
inputs:
ctx: { ref: inputs.ctx }
selection:
multiple: true
ordered: false
fallback: false
customize:
modelInputs: [selection]
children:
views: { optional: true }
table:
props: [label, selectable, view]
tiles:
props: [label, selectable]
details:
replace: [CompactDetails]
publish:
devices: { ref: model.publications.selection }
editor: { ref: model.publications.editor }
commands:
table: { use: model.commands.table }
tiles: { use: model.commands.tiles }
clear: { use: model.commands.clear }
edit:
use: model.commands.edit
enabledWhen: devices.any
reason: Select a device to edit.
save:
use: model.commands.save
enabledWhen:
op: and
args:
- editor.dirty
- { op: not, arg: editor.busy }
reason: Change a field before saving.
discard: { use: model.commands.discard }
children:
status:
type: DraftStatus
props:
label: Device draft status
dirty: { bind: model.dirty }
busy: { bind: model.busy }
views:
type: CommandBar
props:
label: Presentation
commands:
- { ref: commands.table }
- { ref: commands.tiles }
actions:
type: CommandBar
props:
label: Device actions
commands:
- { ref: commands.edit }
- { ref: commands.save }
- { ref: commands.discard }
- { ref: commands.clear }
table:
type: Grid
props:
ctx: { ref: inputs.ctx }
label: Managed devices table
selectable: true
collection: { ref: model.collection }
selection: { ref: model.selection }
view:
columns:
- { field: name }
- { field: platform, width: 130 }
- { field: owner, width: 140 }
sort: []
filters: {}
frozen: 0
tiles:
type: TileGrid
create: on-demand
retain: true
props:
virtualized: true
label: Managed device tiles
selectable: true
collection: { ref: model.collection }
selection: { ref: model.selection }
renderTile: { renderer: device-card }
presentTile: { renderer: device-description }
details:
type: DeviceDetails
create: on-demand
retain: true
props:
editor: { bind: model.editor }
layout:
type: stack
items:
- { child: views }
- { child: actions }
- { child: status }
- type: deck
active: { bind: model.presentation }
items:
table: { child: table }
tiles: { child: tiles }
- type: outlet
active: { bind: model.detailsOpen }
content: { child: details }
import { Command, Collection, DataContext, EditModel, Selection, command, compositeTypes as t, computed, defineCompositeModel, selectionOptionsError, selectionPublication, signal, valuesPublication } from "../lib/aster-ui.js";
// Ordinary application functions, called by this instance's exported commands.
async function saveDevice(editor) {
const saved = await editor.save();
if (!saved) throw new Error("Review the field errors before saving again.");
return saved;
}
export function createDeviceWorkspace({ ctx, selection: selectionPolicy }, lifetime) {
const query = signal("");
const collection = ctx.collection(computed(() => {
const name = query.value.trim();
return { set: "Devices", orderBy: [{ field: "name" }], ...(name ? { filter: { op: "contains", field: "name", value: name } } : {}) };
}), { pageSize: 24, keepPages: 3 });
lifetime.defer(() => ctx.release(collection));
const selection = new Selection(ctx.store, collection, selectionPolicy);
const presentation = signal("table");
const editor = signal();
const detailsOpen = computed(() => editor.value !== undefined);
const dirty = computed(() => editor.value?.dirty.value ?? false);
const busy = computed(() => editor.value?.busy.value ?? false);
const message = signal("Select a device, open its editor, then try both presentations.");
const closeEditor = () => { const previous = editor.value; editor.value = undefined; previous?.dispose(); };
lifetime.defer(closeEditor);
const ownCommand = options => lifetime.own(command(options));
const commands = {
table: ownCommand({ label: "Table", run: () => { presentation.value = "table"; } }),
tiles: ownCommand({ label: "Tiles", run: () => { presentation.value = "tiles"; } }),
clear: ownCommand({ label: "Clear selection", enabled: () => selection.any.value, run: () => selection.clear() }),
edit: ownCommand({ label: "Edit selected", enabled: () => selection.any.value && !detailsOpen.value,
reason: () => detailsOpen.value ? "Save or discard the current draft first." : "Select a device to edit.",
run: () => { editor.value = new EditModel(ctx, "Devices", selection.key.value); message.value = "One draft is shared across both presentations. Save also works with Ctrl/⌘ S."; } }),
save: ownCommand({ label: "Save device", enabled: () => dirty.value && !busy.value,
run: async signal => { const edit = editor.value; await saveDevice(edit); signal.throwIfAborted(); message.value = "Saved. The shared store updated both views."; closeEditor(); } }),
discard: ownCommand({ label: "Discard draft", enabled: () => detailsOpen.value && !busy.value,
run: () => { closeEditor(); message.value = "Draft discarded. The stored record is unchanged."; } }),
};
return {
values: { collection, selection, presentation, editor, detailsOpen, dirty, busy, query, message },
publications: { selection: selectionPublication(selection), editor: valuesPublication({ dirty, busy }) },
commands,
};
}
export const deviceWorkspaceType = defineCompositeModel({
inputs: {
ctx: t.instance(DataContext),
selection: t.refine(
t.object({ multiple: t.boolean, ordered: t.optional(t.boolean), maxOrdered: t.optional(t.positiveInteger), fallback: t.optional(t.boolean) }),
"selection policy (ordered requires multiple; maxOrdered requires ordered)",
value => selectionOptionsError(value) === undefined,
{ allOf: [
{ if: { properties: { ordered: { const: true } }, required: ["ordered"] }, then: { properties: { multiple: { const: true } }, required: ["multiple"] } },
{ if: { required: ["maxOrdered"] }, then: { properties: { ordered: { const: true } }, required: ["ordered"] } },
] },
),
},
exports: {
collection: t.instance(Collection), selection: t.instance(Selection),
presentation: t.signal(t.string), editor: t.signal(t.optional(t.instance(EditModel))),
detailsOpen: t.signal(t.boolean), dirty: t.signal(t.boolean), busy: t.signal(t.boolean), query: t.signal(t.string), message: t.signal(t.string),
},
commands: Object.fromEntries(["table", "tiles", "clear", "edit", "save", "discard"].map(name => [name, t.instance(Command)])),
publications: {
selection: t.publication({ any: t.boolean, many: t.boolean, count: t.number }),
editor: t.publication({ dirty: t.boolean, busy: t.boolean }),
},
create: createDeviceWorkspace,
});
Download composition-details.js
import { Form } from "../lib/aster-ui.js";
// This ES module is fetched only when the details child is first activated.
export function createDetails({ props }) {
const element = document.createElement("section");
element.className = "composition-editor";
element.setAttribute("aria-label", "Device editor");
const title = document.createElement("h2"); title.textContent = "Edit the shared record";
const help = document.createElement("p"); help.textContent = "This panel was loaded on demand. Switching presentation keeps this draft.";
const form = new Form();
form.sections = [{ title: "Device details", columns: 2, items: ["name", "owner"] }];
form.model = props.editor;
element.append(title, help, form);
return { element };
}
export function applyDetails(element, props) {
element.querySelector("aster-form").model = props.editor;
}
export function createCompactDetails(context) {
const session = createDetails(context);
session.element.querySelector("h2").textContent = "Device inspector";
session.element.querySelector("aster-form").sections = [{ title: "Device details", columns: 1, items: ["name", "owner"] }];
return session;
}
Download composition-status.js
import { defineElement, elementProperty, compositeTypes as t, html } from "../lib/aster-ui.js";
// This declaration supplies JS accessors, YAML validation and property metadata.
export const DraftStatus = defineElement({
tag: "demo-draft-status",
properties: {
label: elementProperty(t.string, () => "Draft", "Accessible status label"),
dirty: elementProperty(t.boolean, () => false, "Whether the current draft has unsaved changes"),
busy: elementProperty(t.boolean, () => false, "Whether a save is in progress"),
},
render: ({ label, dirty, busy }) => html`<p class="composition-draft-status" role="status" aria-label=${label}>
<span aria-hidden="true">${busy ? "◷" : dirty ? "●" : "✓"}</span>
${busy ? "Saving changes…" : dirty ? "Unsaved changes" : "All changes saved"}
</p>`,
});
// 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
- Select a device in the table, then choose Edit selected.
- Change its name and switch to Tiles: selection and the draft stay shared.
- Save device and check the new name in both presentations.
- Try a workspace variant or Recreate workspace with an unsaved draft to exercise its guard.
What the example owns
The model owns its collection, selection, editor, commands and cleanup. The YAML definition connects named children, bindings, layout and PageRules. The host registry supplies the JavaScript factories, renderers and lazy module loader.