Component explorer / Application controls
Tabs: a device workspace
Open data-driven tabs, retain lazy panels and protect unsaved notes when closing a tab.
Tab additions and edits stay in this visit. The example link identifies this scenario; it does not serialize open tabs or drafts.
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.
Drive descriptors and lazy panels from records
records and selected are application signals. The renderer returns panel templates; visited panels remain mounted while the owner retains their descriptors.
From scripts/site/playground/tabs.js
tabs.label = "Open devices";
tabs.items = computed(() => [{
value: "overview", label: "Overview"
}, ...records.value.map(r => ({
value: r.id, label: r.name + (r.dirty ? " •" : ""), closable: true
}))]);
tabs.value = selected;
tabs.addEventListener("change", e => { if (e.target === tabs) {
e.stopPropagation();
selected.value = e.detail;
} });
const update = (id, patch) => { records.value = records.value.map(r => r.id === id ? {
...r, ...patch
} : r); };
tabs.renderPanel = item => {
if (item.value === "overview")
return html `<h2>Your device workspace</h2><p>Choose a device tab to mount its panel for the first time. Returning to a tab preserves its fields and draft notes.</p><dl><dt>Open devices</dt><dd>${records.value.length}</dd><dt>Rendering</dt><dd>Panels mount on first visit</dd></dl>`;
const record = records.value.find(r => r.id === item.value);
return html `<h2>${record.name}</h2><p>${record.platform} · Managed device · Connected</p><div class="device-fields">
<label>Device name<span class="aster-field"><input .value=${record.name} @input=${e => update(record.id, {
name: e.target.value, dirty: true
})}></span></label>
<label>Workspace notes<textarea placeholder="Write a note, then switch tabs…" @input=${e => update(record.id, {
notes: e.target.value, dirty: true
})}>${record.notes}</textarea></label>
<div class="bar"><button type="button" class="aster-button" @click=${() => { update(record.id, {
dirty: false
}); status.textContent = "Changes saved for this visit."; }}>Save changes</button><span>${record.dirty ? "Unsaved changes" : "All changes saved"}</span></div></div>`;
};
Handle the close request in the application
The owner decides whether a dirty record may close. A refused close leaves the panel and its draft intact.
From scripts/site/playground/tabs.js
function requestClose(id) {
const record = records.peek().find(r => r.id === id);
if (!record)
return;
if (pending) {
if (!confirm.open)
queuedClose = id;
return;
}
if (!record.dirty) {
remove(record.id);
return;
}
pending = record.id;
confirm.labelledBy = "discard-title";
confirm.content = html `<h2 id="discard-title">Close unsaved device?</h2><p>Your draft will be discarded.</p><div class="bar"><button class="aster-button" @click=${() => confirm.close()}>Keep editing</button><button class="aster-button" @click=${() => { const id = pending; confirm.close(); remove(id); }}>Discard and close</button></div>`;
queueMicrotask(() => confirm.show(document.activeElement));
}
tabs.addEventListener("close", e => { if (e.target === tabs)
requestClose(e.detail); });
confirm.addEventListener("close", () => {
pending = undefined;
const queued = queuedClose;
queuedClose = undefined;
if (queued)
queueMicrotask(() => requestClose(queued));
});
Full application source Application code & shared presentation
import { html, signal, computed } from "../lib/aster-ui.js";
const tabs = document.querySelector("#devices");
const confirm = document.querySelector("#confirm");
const status = document.querySelector("#status");
const records = signal(Array.from({ length: 6 }, (_, i) => ({ id: `device-${i+1}`, name: `Studio workstation ${i+1}`, platform: i % 2 ? "Linux" : "Windows", notes: "", dirty: false })));
const selected = signal("overview");
let next = 7;
let pending;
let queuedClose;
tabs.label = "Open devices";
tabs.items = computed(() => [{ value: "overview", label: "Overview" }, ...records.value.map(r => ({ value: r.id, label: r.name + (r.dirty ? " •" : ""), closable: true }))]);
tabs.value = selected;
tabs.addEventListener("change", e => { if (e.target === tabs) { e.stopPropagation(); selected.value = e.detail; } });
const update = (id, patch) => { records.value = records.value.map(r => r.id === id ? { ...r, ...patch } : r); };
tabs.renderPanel = item => {
if (item.value === "overview") return html`<h2>Your device workspace</h2><p>Choose a device tab to mount its panel for the first time. Returning to a tab preserves its fields and draft notes.</p><dl><dt>Open devices</dt><dd>${records.value.length}</dd><dt>Rendering</dt><dd>Panels mount on first visit</dd></dl>`;
const record = records.value.find(r => r.id === item.value);
return html`<h2>${record.name}</h2><p>${record.platform} · Managed device · Connected</p><div class="device-fields">
<label>Device name<span class="aster-field"><input .value=${record.name} @input=${e => update(record.id, { name: e.target.value, dirty: true })}></span></label>
<label>Workspace notes<textarea placeholder="Write a note, then switch tabs…" @input=${e => update(record.id, { notes: e.target.value, dirty: true })}>${record.notes}</textarea></label>
<div class="bar"><button type="button" class="aster-button" @click=${() => { update(record.id, { dirty: false }); status.textContent = "Changes saved for this visit."; }}>Save changes</button><span>${record.dirty ? "Unsaved changes" : "All changes saved"}</span></div></div>`;
};
function remove(id) {
const items = tabs.items.peek();
const index = items.findIndex(t => t.value === id);
records.value = records.value.filter(r => r.id !== id);
if (selected.value === id) selected.value = items[index + 1]?.value ?? items[index - 1]?.value ?? "overview";
queueMicrotask(() => tabs.querySelector('[role="tab"][aria-selected="true"]')?.focus());
status.textContent = "Tab closed. Other device drafts are retained.";
}
function requestClose(id) {
const record = records.peek().find(r => r.id === id);
if (!record) return;
if (pending) {
if (!confirm.open) queuedClose = id;
return;
}
if (!record.dirty) { remove(record.id); return; }
pending = record.id;
confirm.labelledBy = "discard-title";
confirm.content = html`<h2 id="discard-title">Close unsaved device?</h2><p>Your draft will be discarded.</p><div class="bar"><button class="aster-button" @click=${() => confirm.close()}>Keep editing</button><button class="aster-button" @click=${() => { const id = pending; confirm.close(); remove(id); }}>Discard and close</button></div>`;
queueMicrotask(() => confirm.show(document.activeElement));
}
tabs.addEventListener("close", e => { if (e.target === tabs) requestClose(e.detail); });
confirm.addEventListener("close", () => {
pending = undefined;
const queued = queuedClose;
queuedClose = undefined;
if (queued) queueMicrotask(() => requestClose(queued));
});
document.querySelector("#add").addEventListener("click", () => {
const id = `device-${next}`;
records.value = [...records.value, { id, name: `Studio workstation ${next++}`, platform: "Linux", notes: "", dirty: false }];
selected.value = id;
status.textContent = "Device added to the data-driven tab list.";
});
document.querySelector("#manual").addEventListener("change", e => { tabs.activation = e.target.checked ? "manual" : "automatic"; });
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Tabs — 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:1100px; margin:auto; min-width:0; }
.bar { display:flex; flex-wrap:wrap; align-items:center; gap:12px; margin-block:18px; }
.workspace { min-width:0; border:1px solid var(--aster-rule); border-radius:var(--aster-r-card); padding:20px; background:var(--aster-surface); box-shadow:var(--aster-e1); }
.workspace h2 { margin-block:0 12px; } .workspace p { color:var(--aster-text-2); }
.device-fields { display:grid; gap:16px; max-width:560px; }
.device-fields label { display:grid; gap:6px; } .device-fields .aster-field { flex:none; } .device-fields input { width:100%; }
.device-fields textarea { width:100%; min-height:130px; padding:12px; border:1px solid var(--aster-rule); border-radius:var(--aster-r-ctl); background:var(--aster-sunk); }
.workspace dl { display:grid; grid-template-columns:max-content 1fr; gap:8px 18px; } .workspace dd { margin:0; }
#status { color:var(--aster-text-2); } .hint { color:var(--aster-text-2); }
@media(max-width:600px) { body { padding:12px; } .workspace { padding:12px; } }
</style></head><body><main>
<h1>Tabs</h1><p>A device workspace with lazy panels, protected edits and a scrolling strip that keeps your place.</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>
<button type="button" class="aster-button" id="add">Open another device</button>
<label><input type="checkbox" id="manual"> Activate with Enter or Space</label></div>
<p class="hint">Arrow keys browse · Home / End jump · Delete requests close. Swipe or use the scroll buttons on a narrow screen. Notes remain when switching tabs.</p>
<section class="workspace" aria-label="Device workspace"><aster-tabs id="devices"></aster-tabs></section>
<p id="status" role="status" aria-live="polite">Drafts and saved changes stay in this visit.</p>
<aster-dialog id="confirm"></aster-dialog>
</main><script type="module" src="./showcase-nav.js"></script><script type="module" src="./tabs.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 a device tab and change its notes.
- Switch away and return: the panel and draft remain mounted.
- Close the edited tab and choose Keep editing or Discard changes.
- Add tabs, resize the preview and try manual keyboard activation.
What the example owns
The owner supplies descriptors and selection. Tabs requests changes and closes; the application decides whether to remove a tab. Lazy panels stay mounted after their first visit.