Something went wrong. Try again.
Web frontend and supporting services for lance.blue
Something went wrong. Try again.
12 kB · 324 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325/** * The card that appears when you point at another player. * * A lobby names people constantly — a chat line, a roster chip, whoever * holds a force — and every one of those names is a handle and nothing * else. This is what a handle opens into: the picture, the name they chose, * the DID underneath it, what they have to show, and what they are doing in * this room right now. * * ## What it may say * * Only what is readable about somebody who is not the person looking. That * rules out most of `screens/pilot.ts`'s figures: its Matches tile counts * the *caller's* own matches through a session-scoped route, and Wins and * Kills are placeholders there because nothing records a match result yet * (see [match-records](../../plan/match-records.md)). Camo survives the * change of subject, because a player's camo lives in their own repository * and listing a public collection needs no session. * * So the card is honest in the same way that one is: a real figure where * there is one, an em dash and a reason where there is not. It does not * infer, and it never shows the caller's own numbers under somebody else's * name. * * ## One card, many triggers * * There is exactly one card element per screen, moved and refilled on each * hover, rather than one per name. A lobby with eight seats, a roster and a * scrollback has a hundred places a name appears, and a hundred detached * cards is a hundred subscriptions to keep in sync with a room that changes * under them. * * ## Pointing is not the only way to point * * Hover alone would make this mouse-only. Every trigger opens on focus as * well, closes on blur, and closes on Escape, and the card itself is inert * to the pointer (`pointer-events: none`) so it can never sit between a * cursor and the control underneath it. * * ## Pointing at something is not the same as crossing it * * A pointer on its way to the Ready button crosses four names, and a card * that opened on each of them would be four cards flashing at somebody who * asked for none. So the pointer has to rest on a name before it counts. * Focus does not wait: arriving at a trigger with a keyboard is already the * deliberate act the delay is waiting for. */
import { profileFor } from "../avatars";import { el } from "../dom";
/** Who the card is about, and what they are doing here. */export interface PilotFacts { did: string; /** Their verified handle, or null for an account with none to show. */ handle: string | null; /** * What this player is to this screen — "Holds TraineeA", "Spectating". * The caller's to decide: the card knows about people, not about lobbies. */ note?: string;}
/** A figure with no source yet, and why. Same posture as `pilot.ts`'s * placeholder tiles: the shape is right and the number is not there. */const UNRECORDED = "No match result is recorded yet.";
/** * How long the pointer has to rest on a name before the card opens. * * Long enough that crossing a name is not pointing at one, short enough * that pointing at one does not feel like waiting. */const OPEN_DELAY_MS = 180;
/** Cards made so far, for the `id` that `aria-describedby` needs. Two * screens alive at once would otherwise both answer to the same id. */let cards = 0;
/** * One camo count per DID for the life of the page. * * A hover is a cheap gesture and people repeat it; a listing walks every * page of a collection in somebody's PDS. Misses are cached too — an * account whose PDS will not answer is not going to start answering because * the pointer moved away and came back. */const camoCounts = new Map<string, Promise<number | null>>();
function camoCount(did: string): Promise<number | null> { let kept = camoCounts.get(did); if (!kept) { kept = (async () => { const repo = await import("../camo/repo"); return (await repo.list(did)).length; })().catch(() => null); camoCounts.set(did, kept); } return kept;}
export interface PilotHover { /** Append this once, anywhere: it positions itself against the viewport. */ card: HTMLElement; /** * Point `trigger` at a player. `facts` is read on each open, so a trigger * that outlives a change of seat still says the current thing. * * `focusable` puts the trigger in the tab order, and defaults to true: a * card only a mouse can open is a card a keyboard cannot read. Pass false * for a name that repeats — every line of a scrollback naming the same * four people would otherwise be forty tab stops to get past, and those * people are all in the roster, which is one stop each. */ attach( trigger: HTMLElement, facts: () => PilotFacts, options?: { focusable?: boolean }, ): void; /** Close it now — a screen tearing down, or a list being replaced. */ close(): void;}
export function pilotHover(): PilotHover { const face = el("img", { className: "pilot-hover-avatar", alt: "" }); const name = el("p", { className: "pilot-hover-name" }); const did = el("p", { className: "pilot-hover-did" }); const note = el("p", { className: "hint pilot-hover-note" }); const camo = el("span", { className: "pilot-hover-figure" }); const stats = el("div", { className: "pilot-hover-stats" }, [ stat("Camo", camo), stat("Wins", dash()), stat("Kills", dash()), ]);
const card = el("div", { className: "pilot-hover", hidden: true }, [ el("div", { className: "pilot-hover-identity" }, [ face, el("div", {}, [name, did]), ]), note, stats, ]); card.setAttribute("role", "tooltip"); card.id = `pilot-hover-${++cards}`;
/** Which open the in-flight reads belong to. A pointer moving down a * roster starts one read per name it crosses; only the newest may write * into the card that is now showing somebody else. */ let opening = 0;
/** The trigger the card is currently describing, so its * `aria-describedby` can be taken off again when the card closes: a * pointer that has moved on must not leave a screen reader announcing a * card nobody can see. */ let described: HTMLElement | null = null;
/** A rest that has not finished yet. One at a time — a pointer moving * from one name to the next replaces the wait rather than queueing it. */ let resting: ReturnType<typeof setTimeout> | undefined;
function stopResting(): void { if (resting !== undefined) { clearTimeout(resting); resting = undefined; } }
function open(trigger: HTMLElement, facts: PilotFacts): void { const mine = ++opening; name.textContent = facts.handle ? `@${facts.handle}` : "No handle"; did.textContent = facts.did; note.textContent = facts.note ?? ""; note.hidden = !facts.note; camo.textContent = "—"; face.hidden = true; card.hidden = false; place(trigger); // Sighted or not, the card is about the thing under the pointer. The // link is made on open and broken on close, because the card is one // element that every trigger takes a turn at. if (described && described !== trigger) described.removeAttribute("aria-describedby"); trigger.setAttribute("aria-describedby", card.id); described = trigger;
void profileFor(facts.handle ?? facts.did).then((profile) => { if (mine !== opening) return; if (profile.displayName) name.textContent = profile.displayName; if (profile.avatar) { face.src = profile.avatar; face.hidden = false; } // The handle is the address; a display name is a label somebody chose // for themselves. When both exist the label goes on top and the // address moves down beside the DID, so the card never shows a name // with nothing verifiable under it. did.textContent = profile.displayName && facts.handle ? `@${facts.handle} · ${facts.did}` : facts.did; });
void camoCount(facts.did).then((count) => { if (mine !== opening) return; camo.textContent = count === null ? "—" : String(count); }); }
function place(trigger: HTMLElement): void { const rect = trigger.getBoundingClientRect(); const width = card.offsetWidth; const height = card.offsetHeight; const margin = 8; // Above the trigger by preference, below it when there is no room — // a name near the top of the viewport is as common as one near the // bottom, and a card that runs off the screen says nothing at all. // Below, it is clamped to the viewport too: a card opened from a name // near the bottom of a tall roster used to run off the edge, and the // figures are the part that went. const above = rect.top - height - margin; const top = above >= margin ? above : Math.max( margin, Math.min( rect.bottom + margin, window.innerHeight - height - margin, ), ); const left = Math.min( Math.max(margin, rect.left), Math.max(margin, window.innerWidth - width - margin), ); card.style.top = `${Math.round(top)}px`; card.style.left = `${Math.round(left)}px`; }
function close(): void { opening += 1; stopResting(); card.hidden = true; if (described) { described.removeAttribute("aria-describedby"); described = null; } document.removeEventListener("keydown", onKey); window.removeEventListener("scroll", close, true); window.removeEventListener("resize", close); }
/** Escape closes it, per WAI-ARIA's tooltip pattern. Bound only while the * card is open and removed when it closes, so a screen visited five times * leaves no listeners behind — `close()` runs on teardown. */ function onKey(event: KeyboardEvent): void { if (event.key === "Escape") close(); }
return { card, close, attach(trigger, facts, options) { const show = () => { stopResting(); open(trigger, facts()); document.addEventListener("keydown", onKey); // The card is placed against the viewport once, from where the // trigger was standing. Anything that moves the trigger afterwards // leaves it pointing at nothing, so scrolling or resizing closes it // rather than re-placing it: a card is a glance, and a glance that // follows you down the page is a distraction. Capture, because a // scroll inside the chat panel does not bubble. window.addEventListener("scroll", close, true); window.addEventListener("resize", close); }; // A pointer crossing a name is not pointing at it, so the pointer // waits and the keyboard does not. Leaving before the rest is up // cancels it: close() clears the pending open along with everything // else, so a name crossed on the way past never opens at all. trigger.addEventListener("mouseenter", () => { stopResting(); resting = setTimeout(show, OPEN_DELAY_MS); }); trigger.addEventListener("mouseleave", close); // Focus rather than click: the card is a thing to read, not a thing to // operate, and a trigger that is already a button (a seat, a name in // the roster) keeps whatever its click already did. trigger.addEventListener("focus", show); trigger.addEventListener("blur", close); const focusable = options?.focusable ?? true; if ( focusable && !trigger.hasAttribute("tabindex") && !isFocusable(trigger) ) { trigger.tabIndex = 0; } }, };}
/** Whether the browser already puts this element in the tab order, so * `attach` does not make a button focusable twice or a span never. */function isFocusable(node: HTMLElement): boolean { return ["A", "BUTTON", "INPUT", "SELECT", "TEXTAREA"].includes(node.tagName);}
function stat(label: string, value: HTMLElement): HTMLElement { return el("div", { className: "pilot-hover-stat" }, [ value, el("span", { className: "pilot-hover-label", textContent: label }), ]);}
/** A figure nothing records yet: an em dash that says why it is one. */function dash(): HTMLElement { const value = el("span", { className: "pilot-hover-figure pilot-hover-soon", textContent: "—", title: UNRECORDED, }); return value;}