Aster UI / Guides
Compose a workspace
A screen often combines a list, an editor and actions such as Save or Discard. Those controls need to agree on the selected record, the current draft and when an action is available. Aster lets you declare how they fit together in YAML or JavaScript, while keeping queries and application behaviour in ordinary functions.
This guide uses a device-management screen: a table and tile view share a selection, and an editor changes the selected device. You’ll see how to connect those parts and where to put your own code.
If you are new to Aster, begin with Get started and Connect a grid and form. They introduce the data context, collections and editing used here. You can also use Aster controls directly; a workspace definition is useful when you want to name, connect and reuse the parts of a larger screen.
Try the device workspace
Explore the live workspace and its source, or open it full size. It runs with sample data in your browser; no backend or account is needed.
- Select a device and choose Edit selected.
- Change its name, then switch to Tiles. The same draft stays open.
- Choose Save device or press Ctrl/⌘ S. Both views show the saved name.
The example also asks before discarding an unsaved draft when you recreate the workspace. You can explore its variants after the basic wiring is familiar.
Understand the three parts
A workspace is a named set of components connected to one model and placed
in a layout. In code, Aster represents it with Composite.
| Part | What you define | In this example |
|---|---|---|
| Model | Data, selection, editing state and action functions | A device collection, its selection and Save command |
| Children | Named components and their public properties | A Grid, a TileGrid and an editor |
| Layout | Where children appear and which are active | Switch between the table and tiles; show the editor when needed |
JavaScript registers the model and component types that a definition may use. YAML connects those registered names; it does not contain executable functions. The same definition can be written as a JavaScript object.
Connect a component to the model
Here is the table child from the running example, shortened to show the shared data and selection:
children:
table:
type: Grid
props:
label: Managed devices table
selectable: true
collection: { ref: model.collection }
selection: { ref: model.selection }
table is the name of this child. Grid selects a component registered by the
application. props sets that component’s public properties. A literal such as
selectable: true is passed as a value; ref passes an object exported by the
model. Both the table and tiles reference the same collection and selection,
so switching presentation keeps the selected device.
Use bind when a property should follow a changing signal value. For example,
the draft-status component follows whether the editor has unsaved changes:
status:
type: DraftStatus
props:
dirty: { bind: model.dirty }
ref passes the exported object itself. bind reads a signal and updates the
property as its value changes. The complete definition below includes the view
configuration, actions and layout omitted from these excerpts.
Keep actions in JavaScript
The model supplies commands such as Edit, Save and Discard. A command combines an action function with availability, busy and error state. Buttons and keyboard shortcuts invoke the same command.
The definition connects the workspace’s Save action to the model’s function:
commands:
save:
use: model.commands.save
enabledWhen:
op: and
args:
- editor.dirty
- { op: not, arg: editor.busy }
reason: Change a field before saving.
Here, PageRules evaluates the model’s published editing state: Save requires a
changed draft and no save already in progress. The rule also applies to direct
calls to workspace.commands.save.execute(). The model’s own validation still
runs, and a failed save keeps the draft open for correction.
The model source contains the actual Save function, query and selection setup. The complete source is also included below.
Declare a component's properties once
The workspace's draft status is a custom component. Its complete implementation is below, read from the running example's source:
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>`,
});
Register the constructor without repeating its property list:
registry.component("DraftStatus", compositeElement(DraftStatus));
YAML can immediately set every declared property or bind it to a model signal:
status:
type: DraftStatus
props:
label: Device draft status
dirty: { bind: model.dirty }
busy: { bind: model.busy }
elementProperty(type, initial, description) supplies validation, a per-instance
default factory and documentation. defineElement installs reactive accessors on
each new instance. Ordinary JavaScript assignments such as status.dirty = true
use the same validator and schedule rendering. TypeScript infers accessor and
render-property types. Render properties are read-only in TypeScript; the second
render argument is the typed element for event handlers and DOM events.
Omitted YAML properties keep their defaults. Explicit undefined is accepted
only by an optional type. Default factories should create fresh arrays/objects;
Aster does not clone borrowed models. Properties assigned before custom-element
upgrade are preserved and validated. Native DOM properties and lifecycle names
are reserved so a property declaration cannot replace element behaviour.
Properties are JavaScript APIs; HTML attribute reflection is not implicit.
DraftStatus.properties retains descriptions/default factories, and
describeCompositeContract(DraftStatus.contract) produces JSON-compatible type,
optionality and description metadata for documentation tools. No second property
list is maintained. The component
must still be registered by trusted code before a YAML definition can use it.
Existing elements and adapters continue to work with compositeElement(tag, contract).
Load and mount the workspace
Register the model and components in application code, then compile the YAML at build time. From this repository:
bun scripts/composite-yaml.ts screen.ui.yaml screen.ui.json
The compiler produces a definition and source locations for useful validation errors. The browser loads the JSON; it does not need a YAML parser.
const { definition, sources } = await response.json();
const workspace = new Composite(definition, registry, {
inputs: { ctx },
ctx,
sources,
}).mount(container);
ctx is the data context created by your application. The registry connects
names such as Grid and device-workspace to their implementations. See the
host registration source for the running example’s
component adapters, tile renderer and lazy editor loader.
For a JavaScript definition, pass defineComposite({ ... }) to the same
constructor. When the screen closes, check for unsaved work before calling
workspace.dispose() to release its owned resources.
Complete example source
These files are read directly from the running example when the documentation is built. Expand them to follow the full wiring and application functions.
Workspace definition — YAML
# 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 }
Model, query and action functions — JavaScript
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,
});
The host registrations connect these names to code. The editor implementation shows how the lazy component creates a Form and binds its editing model.
Continue building
Read Customize and manage workspaces for shared selection policy, public child overrides, reusable variants, lazy loading and cleanup. Use Registry & editor tooling for typed definitions, YAML completion and custom markup. The composition API documents the runtime contracts.