274 lines
11 KiB
TypeScript
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;
|
|
}
|