Component explorer / Application controls
Windows: a managed workspace
Keep device details and notes open together, with managed activation, keyboard geometry and guarded closing.
Layout snapshots are stored on this browser. Notes and their Save action last for this visit. Links identify the scenario and appearance, not an open-window session.
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 managed windows with durable content
workspace is a Workspace instance; device and notes are existing aster-window elements. Stable registration IDs let the task list reopen the same content.
From scripts/site/playground/windows.js
for (const [id, window, heading] of [["device", device, "Device details"], ["notes", notes, "Notes"]]) {
window.heading = heading;
window.managed = true;
window.preserveContent = true;
workspace.register(id, window);
window.addEventListener("closeerror", () => say("Could not close the window. Your content is still open."));
}
device.setPlacement({
state: "normal", bounds: {
x: 64, y: 460, width: 390, height: 340
}
});
notes.setPlacement({
state: "normal", bounds: {
x: 490, y: 460, width: 390, height: 340
}
});
Guard unsaved notes before closing
The content owner supplies a boolean or asynchronous close decision. Here a dialog settles that decision; the workspace does not discard drafts on its own.
From scripts/site/playground/windows.js
notes.beforeClose = () => {
if (!dirty.peek())
return true;
return new Promise(resolve => {
resolveClose = resolve;
decision = false;
confirm.labelledBy = "confirm-title";
confirm.content = html `<h2 id="confirm-title">Close unsaved notes?</h2><p>Your changes will be discarded.</p>
<div class="bar"><button type="button" class="aster-button" @click=${() => confirm.close()}>Keep editing</button>
<button type="button" class="aster-button" @click=${() => {
decision = true;
const input = notes.querySelector("textarea");
if (input)
input.value = saved.peek();
dirty.value = false;
confirm.close();
}}>Discard changes</button></div>`;
queueMicrotask(() => confirm.show(document.activeElement));
});
};
confirm.addEventListener("close", () => { resolveClose?.(decision); resolveClose = undefined; });
Save and restore window placement
The snapshot is placement data. Note contents have a separate lifetime and are not serialized here.
From scripts/site/playground/windows.js
document.querySelector("#save-layout").addEventListener("click", () => {
try {
localStorage.setItem(storageKey, JSON.stringify(workspace.snapshot()));
say("Layout saved on this browser.");
}
catch {
say("Layout could not be saved on this browser.");
}
});
document.querySelector("#restore-layout").addEventListener("click", () => {
try {
const json = localStorage.getItem(storageKey);
if (!json)
return say("Save a layout first.");
workspace.restore(JSON.parse(json));
say("Saved layout restored. Notes remain unchanged.");
}
catch {
say("The saved layout could not be restored.");
}
});
Full application source Application code & shared presentation
import { Workspace, html, render, signal, effect } from "../lib/aster-ui.js";
const workspace = new Workspace();
const device = document.querySelector("#device");
const notes = document.querySelector("#notes");
const confirm = document.querySelector("#confirm");
const status = document.querySelector("#workspace-status");
const dirty = signal(false);
const saved = signal("");
let resolveClose;
let decision = false;
const storageKey = "aster-showcase-workspace-v1";
const say = text => { status.textContent = text; };
for (const [id, window, heading] of [["device", device, "Device details"], ["notes", notes, "Notes"]]) {
window.heading = heading;
window.managed = true;
window.preserveContent = true;
workspace.register(id, window);
window.addEventListener("closeerror", () => say("Could not close the window. Your content is still open."));
}
device.setPlacement({ state: "normal", bounds: { x: 64, y: 460, width: 390, height: 340 } });
notes.setPlacement({ state: "normal", bounds: { x: 490, y: 460, width: 390, height: 340 } });
device.content = html`<p>A device inspector stays mounted while you work elsewhere.</p>
<dl class="device-detail"><dt>Device</dt><dd>Engineering workstation</dd><dt>Platform</dt><dd>Linux</dd><dt>Connection</dt><dd>Online</dd></dl>
<aster-choice .label=${"Device action"} .value=${"inspect"} .options=${[{ value: "inspect", label: "Inspect device" }, { value: "logs", label: "View logs" }, { value: "restart", label: "Schedule restart" }]}></aster-choice>
<p>Try the dropdown while another window overlaps this one. Its popup stays above the active window.</p>`;
function saveNotes() {
saved.value = notes.querySelector("textarea")?.value ?? saved.peek();
dirty.value = false;
say("Notes saved for this visit.");
}
notes.content = html`<label for="note-text">Workspace notes</label><textarea id="note-text" placeholder="Write a note, then minimize this window…"
@input=${() => { dirty.value = true; }}></textarea>
<footer><button type="button" class="aster-button" @click=${saveNotes}>Save notes</button><span role="status" id="notes-status">Saved</span></footer>`;
effect(() => {
const changed = dirty.value;
queueMicrotask(() => { const label = notes.querySelector("#notes-status"); if (label) label.textContent = changed ? "Unsaved changes" : "Saved"; });
});
notes.beforeClose = () => {
if (!dirty.peek()) return true;
return new Promise(resolve => {
resolveClose = resolve;
decision = false;
confirm.labelledBy = "confirm-title";
confirm.content = html`<h2 id="confirm-title">Close unsaved notes?</h2><p>Your changes will be discarded.</p>
<div class="bar"><button type="button" class="aster-button" @click=${() => confirm.close()}>Keep editing</button>
<button type="button" class="aster-button" @click=${() => {
decision = true;
const input = notes.querySelector("textarea");
if (input) input.value = saved.peek();
dirty.value = false;
confirm.close();
}}>Discard changes</button></div>`;
queueMicrotask(() => confirm.show(document.activeElement));
});
};
confirm.addEventListener("close", () => { resolveClose?.(decision); resolveClose = undefined; });
for (const id of ["device", "notes"]) document.querySelector(`#open-${id}`).addEventListener("click", event => workspace.show(id, event.currentTarget));
effect(() => {
const active = workspace.active.value;
const entries = workspace.entries.filter(entry => entry.window.open);
const buttons = entries.map(entry => ({ id: entry.id, heading: entry.window.heading, state: entry.window.state }));
render(html`${buttons.map(entry => html`<button type="button" class="aster-button" aria-pressed=${String(active === entry.id)}
@click=${event => workspace.show(entry.id, event.currentTarget)}>${entry.heading}${entry.state === "minimized" ? " — minimized" : ""}</button>`)}`, document.querySelector("#tasks"));
});
document.addEventListener("keydown", event => {
if (event.key !== "F6" || document.querySelector("dialog:modal")) return;
event.preventDefault(); workspace.cycle(event.shiftKey);
});
document.querySelector("#save-layout").addEventListener("click", () => {
try { localStorage.setItem(storageKey, JSON.stringify(workspace.snapshot())); say("Layout saved on this browser."); }
catch { say("Layout could not be saved on this browser."); }
});
document.querySelector("#restore-layout").addEventListener("click", () => {
try {
const json = localStorage.getItem(storageKey);
if (!json) return say("Save a layout first.");
workspace.restore(JSON.parse(json)); say("Saved layout restored. Notes remain unchanged.");
} catch { say("The saved layout could not be restored."); }
});
window.addEventListener("pagehide", () => workspace.dispose(), { once: true });
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Application windows — 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:1100px; margin:auto; }
.bar { display:flex; flex-wrap:wrap; align-items:center; gap:12px; margin-block:20px; }
.workspace-intro { padding:24px; border:1px solid var(--aster-rule); border-radius:var(--aster-r-card); background:var(--aster-sunk); }
.workspace-header { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:8px; }
.workspace-header h2, .workspace-header p { margin:0; } .workspace-header p { font-size:12px; }
.workspace-intro h2 { margin-top:0; } .workspace-intro p { color:var(--aster-text-2); max-width:65ch; }
#tasks { display:flex; gap:8px; flex-wrap:wrap; } #tasks button[aria-pressed=true] { background:var(--aster-accent-soft); border-color:var(--aster-accent); }
.aster-window-body { display:flex; flex-direction:column; gap:14px; } .aster-window-body p { margin:0; color:var(--aster-text-2); }
.aster-window-body textarea { flex:1; min-height:100px; width:100%; padding:12px; border:1px solid var(--aster-rule); border-radius:var(--aster-r-ctl); background:var(--aster-sunk); color:var(--aster-text); font:inherit; resize:none; }
.aster-window-body label { display:flex; flex-direction:column; gap:6px; }
.aster-window-body .aster-field { background:var(--aster-sunk); }
.device-detail { display:grid; grid-template-columns:auto 1fr; gap:10px 20px; margin:0; } .device-detail dd { margin:0; }
.aster-window-body footer { display:flex; align-items:center; gap:12px; margin-top:auto; }
a { color:var(--aster-accent); }
@media(max-width:760px) { body { padding:12px; } .workspace-intro { padding:16px; } }
</style><link rel="stylesheet" href="./showcase.css"></head><body><main>
<h1>Application windows</h1>
<p>Keep several tasks open without losing your place. Move, resize, maximize or minimize a window, then return to the same content.</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>
</div>
<section class="workspace-intro" aria-labelledby="workspace-title">
<div class="workspace-header"><h2 id="workspace-title">Your workspace</h2><p id="workspace-status" role="status" aria-live="polite">Open a window to begin.</p></div>
<p>Open a device and take notes beside it. Minimized windows stay in the task list. Unsaved notes ask before closing. Save the layout to reopen it after a refresh.</p>
<div class="bar">
<button type="button" class="aster-button" id="open-device">Open device</button>
<button type="button" class="aster-button" id="open-notes">Open notes</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>
</div>
<div id="tasks" role="group" aria-label="Open windows"></div>
</section>
<p>Keyboard: use each window’s Move or Resize button with arrow keys; hold Shift for larger steps. F6 switches between open windows, Shift+F6 goes back. Escape requests close.</p>
<aster-window id="device"></aster-window><aster-window id="notes"></aster-window>
<aster-dialog id="confirm"></aster-dialog>
<p><a href="./">Back to the playground</a></p>
</main><script type="module" src="./showcase-nav.js"></script><script type="module" src="./windows.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
- Open the device inspector and a Notes window.
- Write a note, minimize its window, then restore it from the task list.
- Move or resize with the window controls and arrow keys; try maximize and restore.
- Close dirty notes to try the confirmation, then save and restore the workspace layout.
What the example owns
Workspace manages registration, activation and placement. Each Window keeps its own content. The notes owner supplies beforeClose; saved placement contains window geometry and state, rather than note contents.