sw-list.gallery.ts
// Gallery entry for the live megacolumn: sw-list (the list region: header row,
// live rows, add-row, t/n) composed into a megacolumn console. This demo is the
// rehearsal for the infocard's task queue: a queue that churns on its own (heads
// complete, assignments refill) AND under player command (prepend, remove, append),
// fully keyboard-drivable, with focus surviving every kind of row death. The demo
// is deliberately a THIN consumer: it owns the data, the row rendering, and what
// the keys MEAN on task rows; sw-list owns the list norms (sw-list.ts holds that
// contract); sw-live-list under it owns row lifecycle. Copy this shape at
// migration time.
import { css, html, LitElement, nothing, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import type { GalleryEntry } from "./gallery/entry.js";
import { MegacolumnController, type MegacolumnKeyDetail, megacolumnStyles } from "./megacolumn-controller.js";
import { flashPress } from "./press-flash.js";
import type { SwListAddDetail } from "./sw-list.js";
import type { LiveListPhase } from "./sw-live-list.js";
import "./sw-list.js";
import "./sw-button.js";
interface QueueTask {
id: string;
label: string;
origin: "player" | "assignment";
}
const taskKey = (t: QueueTask): string => t.id;
// The task-queue console: one megacolumn whose lower section is a live list.
// The list stamps row identity itself (sw-live-list.ts holds that contract);
// this owner renders row CONTENT (main/aux/data-info) and gives the keys meaning.
@customElement("swg-livecolumn-demo")
export class SwgLivecolumnDemo extends LitElement {
static styles = [
megacolumnStyles,
css`
:host {
display: block;
}
[data-mc-container] {
border: 1px solid var(--sw-border-dim);
padding: var(--sw-space-md);
}
.title {
color: var(--sw-text-dim);
font-size: var(--sw-type-small);
letter-spacing: var(--sw-tracking-wide);
text-transform: uppercase;
margin-bottom: var(--sw-space-sm);
}
.section {
color: var(--sw-text-faint);
font-size: var(--sw-type-micro);
letter-spacing: var(--sw-tracking-wide);
text-transform: uppercase;
margin: var(--sw-space-xl) 0 var(--sw-space-xs);
}
.section:first-of-type {
margin-top: 0;
}
/* The list norms (header wash, ghosted add-row, hotkey gold) are sw-list's
own styles now; this sheet only spaces the list within the console. */
sw-list {
margin-top: var(--sw-space-xl);
}
[data-mc-row] {
min-height: var(--sw-hit-min-h);
}
.action-row {
display: flex;
align-items: center;
padding: 2px var(--sw-space-sm);
}
.action-row sw-button {
flex: 1;
}
/* Task-row content (rendered by the live list into this shadow root,
so this stylesheet reaches it directly -- no itemStyles hop). */
.task {
display: flex;
align-items: center;
gap: var(--sw-space-sm);
min-height: var(--sw-hit-min-h);
padding: 2px var(--sw-space-sm);
white-space: nowrap;
}
.task.committed {
opacity: 0.45;
}
.task .origin {
flex: none;
width: 88px;
font-size: var(--sw-type-small);
color: var(--sw-text-faint);
}
.task .origin.player {
color: var(--sw-good);
}
.task .grow {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
}
/* The ✓ occupies the same box the × does in live rows, so the glyph
column holds steady as rows commit (same size, same centering). */
/* The glyph box: one element across phases (× live, ✓ committed), so an
acknowledgment flash on it survives the phase flip. Same box either way:
the column holds steady as rows commit. All rules keyed to the CLASS,
not [data-mc-aux]: the ghost drops that attribute in the same re-render,
and attribute-keyed styling would un-match mid-flash. */
.task .glyph {
color: var(--sw-danger);
border: 1px solid transparent;
min-width: var(--sw-hit-min-w);
min-height: var(--sw-hit-min-h);
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
}
.task .glyph.gone {
color: var(--sw-good);
}
/* The aux's own pressed look: danger-family, not the blue activation wash --
ownership distinguishes the flashes, no enumeration of activation kinds. */
.task .glyph.sw-pressed {
background: hsl(from var(--sw-danger) h calc(s * 2) calc(l * 0.75) / 0.6);
color: var(--sw-text-bright);
}
.idle {
color: var(--sw-text-faint);
padding: 2px var(--sw-space-sm);
line-height: var(--sw-hit-min-h);
}
.status-row {
display: flex;
align-items: center;
color: var(--sw-text-dim);
padding: 2px var(--sw-space-sm);
margin-top: var(--sw-space-xl);
}
.log {
margin-top: var(--sw-space-md);
color: var(--sw-good);
font-size: var(--sw-type-ui);
min-height: 1.4em;
}
`,
];
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: wires itself via addController
private mc = new MegacolumnController(this);
@property()
label = "ops queue";
// Refill keeps the queue steady (a completed head is replaced); without it the
// queue drains to the empty state and only player prepends fill it.
@property({ type: Boolean })
refill = false;
@state()
private tasks: QueueTask[] = [];
@state()
private lastAction = "";
private seq = 0;
private timer: number | undefined;
connectedCallback(): void {
super.connectedCallback();
if (this.refill) {
this.tasks = [this.newTask(), this.newTask(), this.newTask(), this.newTask()];
this.timer = window.setInterval(() => this.churn(), 4000);
} else {
// Drain mode: heads still complete, nothing refills.
this.timer = window.setInterval(() => {
this.tasks = this.tasks.slice(1);
}, 4000);
}
}
disconnectedCallback(): void {
super.disconnectedCallback();
window.clearInterval(this.timer);
}
private newTask(origin: "player" | "assignment" = "assignment"): QueueTask {
this.seq += 1;
const chores = origin === "player"
? ["survey the anomaly", "escort the hauler", "scout the far gate"]
: ["move to waypoint", "load ore", "unload struts", "dock", "refuel"];
return {
id: `t${this.seq}`,
label: `${chores[this.seq % chores.length]} (#${this.seq})`,
origin,
};
}
// One churn beat: the head completes (leaves the data -> ghosts) and new
// assignment work arrives at the tail.
private churn(): void {
this.tasks = [...this.tasks.slice(1), this.newTask()];
}
private prependTask(): void {
const task = this.newTask("player");
this.tasks = [task, ...this.tasks];
this.lastAction = `${task.label} prepended`;
}
// The add-row's commit, arriving as sw-list's cancelable sw-list-add event
// (sw-list owns the dive/abandon/ghost mechanics and the nothing-syncs-until-
// commit contract; the owner just appends). Not cancelled here: any trimmed
// non-empty text is a legal task.
private onListAdd(e: Event): void {
const { text } = (e as CustomEvent<SwListAddDetail>).detail;
this.seq += 1;
this.tasks = [...this.tasks, { id: `t${this.seq}`, label: text, origin: "player" }];
this.lastAction = `${text} appended`;
}
// The queue rows' key semantics (the prepend row's keys are sw-button's own
// business -- direct-marked, it claims them before this handler could matter):
// x/Delete from anywhere in the row removes; Enter/Space on the aux removes;
// Enter/Space on main inspects (activation must not be destructive).
private onMegacolumnKey(e: Event): void {
const { detail } = e as CustomEvent<MegacolumnKeyDetail>;
// Only task rows have task semantics: sw-list's structural rows (the
// "<name>-header"/"<name>-add" id scheme is its contract) and the demo's
// own prepend/status rows decline everything here (the add-row's typing
// never reaches this handler; text entry dominates).
if (
detail.rowId === "prepend"
|| detail.rowId === "queue-header"
|| detail.rowId === "queue-add"
|| detail.rowId === "status"
) {
return;
}
const task = this.tasks.find((t) => t.id === detail.rowId);
const activate = detail.key === "Enter" || detail.key === " ";
const remove = detail.key === "x" || detail.key === "Delete" || (activate && detail.position === "aux");
if (remove) {
// A ghost's task is already out of the data: removing again is a no-op,
// and saying so quietly beats pretending something happened.
this.lastAction = task === undefined ? "already gone" : `${task.label} removed`;
if (task !== undefined) {
// Acknowledge on the aux x itself -- the control whose activation removal
// IS -- in its own danger-tinted pressed style. The glyph element survives
// the phase flip to ✓ (see renderTask), so the flash paints through it.
const aux = e.target instanceof Element
? e.target.closest("[data-mc-row]")?.querySelector("[data-mc-aux]")
: null;
if (aux instanceof HTMLElement) flashPress(aux);
this.tasks = this.tasks.filter((t) => t.id !== task.id);
}
e.preventDefault();
return;
}
if (activate) {
this.lastAction = task === undefined ? "inspected a completed task" : `${task.label} inspected`;
e.preventDefault();
// Acknowledge on the row's main control (inside the focused row).
if (e.target instanceof HTMLElement) flashPress(e.target);
}
}
// Row renderer, handed to the live list. The committed variant drops the aux
// control: a ghost is not removable (it is already gone from the data), and
// the list's scan-visible hasAux falls out of that automatically.
// One template for both phases, ON PURPOSE: the glyph span is the same element
// whether it shows the live × or the committed ✓, so an acknowledgment flash
// stamped on it survives the phase flip and paints (a per-phase template swap
// would destroy the very control being acknowledged -- the composition goof
// this demo exists to not make). data-mc-aux is conditionally present: ghosts
// drop the attribute (not the element), so the megacolumn scan still sees them
// as aux-less and the attribute-keyed styling flips with it.
private renderTask = (t: QueueTask, phase: LiveListPhase): TemplateResult => {
const done = phase === "committed";
return html`<div class=${classMap({ task: true, committed: done })}>
<span class="origin ${t.origin}">${t.origin}</span>
<span class="grow" data-mc-main data-info=${
done
? "a completed task, held briefly; Enter still inspects it"
: "a queued task; Enter inspects it"
}>${t.label}</span>
<span class=${classMap({ glyph: true, gone: done })}
data-mc-aux=${done ? nothing : ""}
title=${done ? nothing : "remove"}
data-info=${done ? nothing : "removes this task (x or Delete from anywhere in the row)"}
>${done ? "✓" : "×"}</span>
</div>`;
};
private renderQueueEmpty = (): TemplateResult => html`<div class="idle">queue empty (prepend something)</div>`;
render(): TemplateResult {
return html`
<div class="title">${this.label}</div>
<div
data-mc-container
aria-label=${this.label}
@megacolumn-key=${this.onMegacolumnKey}
>
<div class="section">orders</div>
<div data-mc-row="prepend" class="action-row">
<!-- Direct-marked (the deliberate exception to hotkeys-move-focus):
a prepend is cheaply undone -- x removes the new head -- so the
see-before-commit step would only slow the order down. -->
<sw-button
label="prepend priority task"
hotkey="p"
hotkey-direct
info="queues a player task at the HEAD of the queue; watch it appear instantly even mid-keyboard-burst"
@click=${() => this.prependTask()}
></sw-button>
</div>
<!-- The list region: sw-list owns the norms (header row speaking for the
region, ghosted add-row with the marked direct a, t/n jumps); this
owner supplies data, row rendering, and the commit handler. The
norm teaching in the info texts is sw-list's own appendix. -->
<sw-list
name="queue"
header="task queue (live)"
header-info="the task queue; every row is removable -- x or Delete"
addable
add-info="appends a task at the tail"
.items=${this.tasks}
.keyOf=${taskKey}
.renderItem=${this.renderTask}
.renderEmpty=${this.renderQueueEmpty}
@sw-list-add=${this.onListAdd}
></sw-list>
<!-- A stop AFTER the list, so n has somewhere to land (and the demo
shows what n is for: hopping past a long list without arrowing through
it). A plain informational row; no task semantics. -->
<div
data-mc-row="status"
class="status-row"
data-info="queue telemetry; n from anywhere in the list above jumps straight here"
>
telemetry: queue nominal
</div>
</div>
<div class="log">${this.lastAction}</div>
`;
}
}
const entry: GalleryEntry = {
id: "sw-list",
title: "sw-list: the live megacolumn's list region",
description: "The list region component, composed here into a megacolumn console: header row, live rows "
+ "(sw-live-list inside), ghosted add-row, and the t/n list-scoped jumps -- the list norms as "
+ "things the component does. The queue churns and stays fully keyboard-drivable: ghost rows "
+ "stay navigable while they hold (a stationary ghost is safe for the keyboard exactly as it "
+ "is for the mouse); collapsing rows leave navigation and focus falls to a neighbor under a "
+ "brief decisive-key lockout; key activity pins the FOCUSED ghost's geometry (the keyboard "
+ "aims by identity, so only the aimed-at row needs holding still). The mouse side is the "
+ "default click-guard: motion proceeds under the pointer, and only a click on a row that is "
+ "mid-displacement dies quietly. This is the rehearsal for the infocard's task queue.",
variants: [
{
caption: "A steady queue: the head completes every few seconds (ghosts ✓, holds, collapses) and new "
+ "assignment work arrives. Try: arrow onto a task and just WORK it -- the ghost under your "
+ "focus holds while you keep pressing keys (the key-activity pin), and collapses moments after "
+ "you pause; ghosts you are NOT on collapse on their own schedule, each at its own moment. "
+ "Removals flash the × itself, danger-tinted, before the row ghosts (x/Delete removes a task from anywhere in its row; "
+ "right-arrow reaches the ×; Enter on a ghost still inspects it), and if focus was on a ghost "
+ "when it collapsed, focus falls to the next row under a brief decisive-key lockout -- hammer "
+ "Enter across the collapse and note nothing mis-fires. The LIST NORMS are sw-list's own "
+ "behavior now: a header row (arrow onto it and the "
+ "whole region washes -- group identity without a nested container; i there describes the list as "
+ "a list) and the add-row at the tail. Two ways to add, both earning the direct marking under "
+ "the easy-undo doctrine: the prepend "
+ "button is hotkey-direct -- the exception to letters-move-focus: p acts immediately from any row "
+ "(focus following to the orders row), justified because a prepend is cheaply undone -- x the new "
+ "head and it is gone. The add-row's a dives straight into typing (direct on a text row means "
+ "what Enter means there), justified because nothing touches the queue until the Enter commit "
+ "(the owner hears a cancelable sw-list-add and appends; watch the task animate in) and Escape "
+ "abandons the draft. While focus is anywhere "
+ "in the list -- header, tasks, add-row -- t jumps back up to the header and n jumps past the "
+ "list to the next stop after it: list-scoped keys sw-list wires itself, deliberately not "
+ "gold-marked (they mean the same thing in every list).",
render: () => html`<swg-livecolumn-demo refill></swg-livecolumn-demo>`,
},
{
caption: "Drain to empty: nothing refills this queue. Prepend a few tasks (p), watch them complete and "
+ "drain away; the empty state is the owner's renderEmpty render-prop (mega mode has no slots). "
+ "Focus stays sane through the last row's death: the column itself keeps focus and the arrows "
+ "revive it the moment a row exists again.",
render: () => html`<swg-livecolumn-demo label="drain queue"></swg-livecolumn-demo>`,
},
],
};
export default entry;
packages/yukey/src/sw-list.gallery.ts · 16.1 KiB · f2166ce42f