yukey

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