1
0
This repository has been archived on 2026-08-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tera/src/media/officeScreenPanel.ts
T

274 lines
11 KiB
TypeScript

/** Accessible, capture-free command panel for authored office screens. */
import type { MediaSurfaceDescriptor } from "./presentation.ts";
export interface OfficeScreenPanelOptions {
container: HTMLElement;
surfaces: readonly MediaSurfaceDescriptor[];
onSelect?: (surface: MediaSurfaceDescriptor) => void;
/** Intent only. The caller may prompt for capture after receiving it. */
onRequestShare?: (surface: MediaSurfaceDescriptor) => void;
/** Intent only. The caller owns presenter stop/revoke policy and media. */
onStopShare?: (surface: MediaSurfaceDescriptor) => void;
onViewerOptIn?: (surface: MediaSurfaceDescriptor, optedIn: boolean) => void;
}
export interface OfficeScreenPanelState {
open: boolean;
selectedId: string | null;
/** Explicit, local viewing choices. Empty initially and after disposal. */
optedInScreenIds: string[];
surfaces: MediaSurfaceDescriptor[];
}
export interface OfficeScreenPanel {
root: HTMLElement;
open(): OfficeScreenPanelState;
update(surfaces: readonly MediaSurfaceDescriptor[]): OfficeScreenPanelState;
close(): OfficeScreenPanelState;
state(): OfficeScreenPanelState;
dispose(): void;
}
const STYLES = `
.tera-screen-panel { width:min(520px,calc(100vw - 32px)); max-height:min(720px,calc(100dvh - 32px)); overflow:auto; padding:20px; color:#e8edf2; background:rgba(9,13,18,.94); border:1px solid rgba(255,255,255,.14); border-radius:8px; font:12px/1.5 ui-monospace,monospace; }
.tera-screen-panel__title { margin:0 0 4px; font-size:17px; }
.tera-screen-panel__intro,.tera-screen-panel__empty { color:rgba(255,255,255,.65); }
.tera-screen-panel__list { display:grid; gap:8px; margin:16px 0; }
.tera-screen-panel__screen { width:100%; padding:10px; color:inherit; text-align:left; background:rgba(255,255,255,.05); border:1px solid rgba(255,255,255,.13); border-radius:5px; cursor:pointer; }
.tera-screen-panel__screen[aria-pressed="true"] { border-color:#f2b134; background:rgba(242,177,52,.1); }
.tera-screen-panel__meta { display:block; color:rgba(255,255,255,.58); }
.tera-screen-panel__actions { display:flex; flex-wrap:wrap; justify-content:flex-end; gap:8px; }
.tera-screen-panel__button { min-height:40px; padding:8px 12px; color:inherit; background:rgba(255,255,255,.07); border:1px solid rgba(255,255,255,.15); border-radius:5px; cursor:pointer; }
.tera-screen-panel__button:disabled { opacity:.4; cursor:not-allowed; }
.tera-screen-panel :focus-visible { outline:2px solid #f2b134; outline-offset:2px; }
`;
let panelSequence = 0;
export function createOfficeScreenPanel(options: OfficeScreenPanelOptions): OfficeScreenPanel {
if (!options.container || typeof options.container.append !== "function") {
throw new RangeError("office screen panel: container must be an HTMLElement");
}
let surfaces = validateSurfaces(options.surfaces);
let selectedId: string | null = surfaces[0]?.screenId ?? null;
const optedIn = new Set<string>();
let isOpen = false;
let disposed = false;
let invoker: HTMLElement | null = null;
const doc = options.container.ownerDocument;
const number = ++panelSequence;
const titleId = `tera-screen-panel-title-${number}`;
const introId = `tera-screen-panel-intro-${number}`;
const root = doc.createElement("section");
root.className = "tera-screen-panel";
root.setAttribute("role", "dialog");
root.setAttribute("aria-modal", "true");
root.setAttribute("aria-labelledby", titleId);
root.setAttribute("aria-describedby", introId);
root.setAttribute("aria-hidden", "true");
root.hidden = true;
const style = doc.createElement("style");
style.textContent = STYLES;
const title = doc.createElement("h2");
title.id = titleId;
title.className = "tera-screen-panel__title";
title.textContent = "Office screens";
const intro = doc.createElement("p");
intro.id = introId;
intro.className = "tera-screen-panel__intro";
intro.textContent = "Media is off by default. Choose a screen, then explicitly opt in to view or request sharing.";
const list = doc.createElement("div");
list.className = "tera-screen-panel__list";
list.setAttribute("role", "list");
const actions = doc.createElement("div");
actions.className = "tera-screen-panel__actions";
const optButton = button(doc, "Opt in to view", "opt-in");
const shareButton = button(doc, "Share screen", "share");
const stopButton = button(doc, "Stop / revoke", "stop");
const closeButton = button(doc, "Close", "close");
actions.append(optButton, shareButton, stopButton, closeButton);
root.append(style, title, intro, list, actions);
options.container.append(root);
function selected(): MediaSurfaceDescriptor | null {
return surfaces.find((surface) => surface.screenId === selectedId) ?? null;
}
function render(): void {
for (const child of [...list.children]) child.remove();
if (surfaces.length === 0) {
const empty = doc.createElement("p");
empty.className = "tera-screen-panel__empty";
empty.textContent = "No authored office screens are available.";
list.append(empty);
}
for (const surface of surfaces) {
const row = button(doc, "", "select");
row.className = "tera-screen-panel__screen";
row.setAttribute("role", "listitem");
row.setAttribute("data-screen-id", surface.screenId);
row.setAttribute("aria-pressed", String(surface.screenId === selectedId));
const name = doc.createElement("span");
name.textContent = surface.screenId;
const meta = doc.createElement("span");
meta.className = "tera-screen-panel__meta";
const room = surface.roomId ?? "Unassigned room";
const status = surface.bound ? "Media active" : "Media off";
meta.textContent = `${surface.levelId} · ${room} · ${status}`;
row.append(name, meta);
row.addEventListener("click", () => {
selectedId = surface.screenId;
render();
options.onSelect?.(copySurface(surface));
});
list.append(row);
}
const surface = selected();
const viewing = surface ? optedIn.has(surface.screenId) : false;
optButton.disabled = surface === null;
shareButton.disabled = surface === null || !viewing;
stopButton.disabled = surface === null || !surface.bound;
optButton.textContent = viewing ? "Stop viewing" : "Opt in to view";
optButton.setAttribute("aria-pressed", String(viewing));
}
optButton.addEventListener("click", () => {
const surface = selected();
if (!surface) return;
const next = !optedIn.has(surface.screenId);
if (next) optedIn.add(surface.screenId);
else optedIn.delete(surface.screenId);
render();
options.onViewerOptIn?.(copySurface(surface), next);
});
shareButton.addEventListener("click", () => {
const surface = selected();
if (surface) options.onRequestShare?.(copySurface(surface));
});
stopButton.addEventListener("click", () => {
const surface = selected();
if (surface?.bound) options.onStopShare?.(copySurface(surface));
});
closeButton.addEventListener("click", () => close());
function snapshot(): OfficeScreenPanelState {
return {
open: isOpen,
selectedId,
optedInScreenIds: [...optedIn],
surfaces: surfaces.map(copySurface),
};
}
function close(): OfficeScreenPanelState {
if (disposed || !isOpen) return snapshot();
isOpen = false;
root.hidden = true;
root.setAttribute("aria-hidden", "true");
invoker?.focus();
invoker = null;
return snapshot();
}
root.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
event.preventDefault();
close();
return;
}
if (event.key !== "Tab") return;
const focusable = controls();
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable.at(-1);
if (!first || !last) return;
if (!event.shiftKey && doc.activeElement === last) {
event.preventDefault();
first.focus();
} else if (event.shiftKey && doc.activeElement === first) {
event.preventDefault();
last.focus();
}
});
function controls(): HTMLElement[] {
const rows = [...list.children].filter((child): child is HTMLElement =>
(child as HTMLElement).getAttribute("data-screen-id") !== null,
);
return [...rows, optButton, shareButton, stopButton, closeButton].filter(
(control) => !(control as HTMLButtonElement).disabled,
);
}
render();
return {
root,
open() {
if (disposed) return snapshot();
const active = doc.activeElement as HTMLElement | null;
invoker = active && typeof active.focus === "function" ? active : null;
isOpen = true;
root.hidden = false;
root.setAttribute("aria-hidden", "false");
controls()[0]?.focus();
return snapshot();
},
update(next) {
if (disposed) return snapshot();
surfaces = validateSurfaces(next);
const ids = new Set(surfaces.map((surface) => surface.screenId));
for (const id of optedIn) if (!ids.has(id)) optedIn.delete(id);
if (selectedId === null || !ids.has(selectedId)) selectedId = surfaces[0]?.screenId ?? null;
render();
return snapshot();
},
close,
state: snapshot,
dispose() {
if (disposed) return;
close();
disposed = true;
optedIn.clear();
surfaces = [];
selectedId = null;
root.remove();
},
};
}
function validateSurfaces(input: readonly MediaSurfaceDescriptor[]): MediaSurfaceDescriptor[] {
if (!Array.isArray(input)) throw new TypeError("office screen panel: surfaces must be an array");
const seen = new Set<string>();
return input.map((surface) => {
if (!surface || typeof surface !== "object") throw new TypeError("office screen panel: invalid surface");
for (const field of ["screenId", "officeId", "levelId", "kind"] as const) {
if (typeof surface[field] !== "string" || surface[field].length === 0) {
throw new TypeError(`office screen panel: invalid ${field}`);
}
}
if (surface.roomId !== null && typeof surface.roomId !== "string") {
throw new TypeError("office screen panel: invalid roomId");
}
if (typeof surface.bound !== "boolean") throw new TypeError("office screen panel: invalid bound state");
if (seen.has(surface.screenId)) throw new TypeError("office screen panel: duplicate screenId");
seen.add(surface.screenId);
return copySurface(surface);
});
}
function copySurface(surface: MediaSurfaceDescriptor): MediaSurfaceDescriptor {
return { ...surface };
}
function button(doc: Document, label: string, action: string): HTMLButtonElement {
const value = doc.createElement("button");
value.type = "button";
value.className = "tera-screen-panel__button";
value.textContent = label;
value.setAttribute("data-action", action);
return value;
}