import { adopt, define, sheet } from "@nibling/web/internals"; import "@nibling/web/icons"; import "@nibling/web/menu"; import "@nibling/web/toolbar"; import type { MenuElement, MenuSelectEvent } from "@nibling/web/menu"; import type { ShellCommand } from "../../shell/commands.ts"; import type { Unsubscribe } from "../../shell/workbench.ts"; import { actorHref, repoHref } from "../../pages/route.ts"; import type { PromptRequest } from "../commands.ts"; import "./import-dialog.ts"; import { openName, openOwner, type KnownAccount, type Platform, type PlatformState } from "../state.ts"; const headerSheet = sheet(` :host { container: nib-header / inline-size; display: block; background: var(--nib-color-surface); color: var(--nib-color-text); border-block-end: var(--nib-size-border) solid var(--nib-color-border); font: var(--nib-font-ui); } [part="bar"] { display: flex; flex-wrap: wrap; align-items: center; column-gap: var(--nib-space-md); min-height: var(--nib-size-header); padding-inline: var(--nib-space-md) var(--nib-space-sm); white-space: nowrap; } a { color: inherit; text-decoration: none; border-radius: var(--nib-radius-sm); } :is(a, button):focus-visible { outline: var(--nib-size-focus-ring) solid var(--nib-color-focus-ring); outline-offset: var(--nib-size-border); } [part="home"] { font-weight: var(--nib-font-weight-strong); } [part="home"]:hover { text-decoration: underline; } [part="project"] { min-width: 0; overflow: hidden; text-overflow: ellipsis; color: var(--nib-color-text-muted); } [part="project"]:empty { display: none; } [part="project"]::before { content: "/" / ""; margin-inline-end: var(--nib-space-md); color: var(--nib-color-border); } [part="project"] strong { color: var(--nib-color-text); font-weight: var(--nib-font-weight-normal); } [part="dirty"] { color: var(--nib-color-accent); } nib-toolbar { gap: var(--nib-space-2xs); } nib-toolbar:empty { display: none; } [part="status"] { flex: 1 1 0; min-width: 0; margin: 0; overflow: hidden; text-overflow: ellipsis; text-align: end; color: var(--nib-color-text-muted); } [part="status"][data-tone="error"] { color: var(--nib-color-danger); } [part="end"] { display: flex; align-items: center; gap: var(--nib-space-xs); } button { display: inline-flex; align-items: center; gap: var(--nib-space-xs); padding-block: var(--nib-space-2xs); padding-inline: var(--nib-space-sm); border: var(--nib-size-border) solid var(--nib-color-border); border-radius: var(--nib-radius-sm); background: var(--nib-color-canvas); color: inherit; font: inherit; } button:hover, button[aria-expanded="true"] { background: var(--nib-color-hover); } button:disabled { color: var(--nib-color-text-muted); } .caret { color: var(--nib-color-text-muted); font-size: var(--nib-font-size-icon); } [part="account"] { padding-inline: var(--nib-space-2xs) var(--nib-space-sm); border-color: transparent; background: none; border-radius: var(--nib-size-avatar-small); } .avatar { display: inline-grid; place-items: center; flex: none; width: var(--nib-size-avatar-small); height: var(--nib-size-avatar-small); border-radius: 50%; object-fit: cover; background: var(--nib-color-selected); color: var(--nib-color-text); font-size: var(--nib-font-size-small); font-weight: var(--nib-font-weight-strong); } .handle { max-width: 16rem; overflow: hidden; text-overflow: ellipsis; } .visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } nib-menu { min-inline-size: 14rem; } nib-menu-item .muted { color: var(--nib-color-text-muted); } /* A phone or a narrow window: the handle shows only as the avatar, and a message wraps below the bar. */ @container nib-header (max-width: 640px) { [part="bar"] { column-gap: var(--nib-space-sm); padding-inline-start: var(--nib-space-sm); } .handle { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); } [part="status"]:not(:empty) { order: 1; flex-basis: 100%; padding-block-end: var(--nib-space-xs); white-space: normal; text-align: start; } [part="status"]:empty { flex-basis: 0; } } @media (pointer: coarse) { [part="bar"] { padding-block: var(--nib-space-2xs); } button { min-block-size: var(--nib-size-touch-target); min-inline-size: var(--nib-size-touch-target); justify-content: center; } [part="home"] { display: inline-flex; align-items: center; min-block-size: var(--nib-size-touch-target); } } dialog { padding: var(--nib-space-lg); border: var(--nib-size-border) solid var(--nib-color-border); border-radius: var(--nib-radius-md); background: var(--nib-color-surface); color: var(--nib-color-text); font: var(--nib-font-ui); white-space: normal; } dialog:modal { overscroll-behavior: contain; width: min(24rem, 100% - 2 * var(--nib-space-lg)); box-sizing: border-box; } dialog::backdrop { background: var(--nib-color-scrim); overflow: hidden; overscroll-behavior: contain; } dialog h2 { margin-block: 0 var(--nib-space-md); font-size: inherit; font-weight: var(--nib-font-weight-strong); } dialog label { display: grid; gap: var(--nib-space-2xs); } dialog :is(input, select) { box-sizing: border-box; width: 100%; padding: var(--nib-space-xs); border: var(--nib-size-border) solid var(--nib-color-border); border-radius: var(--nib-radius-sm); background: var(--nib-color-canvas); color: inherit; font: inherit; } dialog .actions { display: flex; justify-content: end; gap: var(--nib-space-xs); margin-block-start: var(--nib-space-md); } `); /** A command as the header runs it, already bound to whatever it acts on. */ export type HeaderCommand = Pick & { readonly run: () => void }; /** The project actions the editor offers in each state, in order. */ function projectActions(state: PlatformState): string[] { const open = state.project; if (!state.account) return []; if (!open) return ["project.save"]; const owned = openOwner(open) === state.account.session.did; // An opened repo saves as the viewer's project whoever owns it; only its owner can push to it. if (open.kind === "repo") return ["project.save", ...(owned && open.base ? ["tangled.push"] : [])]; return [owned ? "project.save" : "project.fork", ...(open.base ? ["tangled.push"] : [])]; } const actionLabels: Record = { "project.save": "Save", "project.fork": "Fork", "tangled.push": "Push" }; /** * The header on every page: a home link, in the editor the open project with an unsaved-changes marker * and its Save or Fork and Push actions, what is running or went wrong, and on the right a New menu and * the account menu (or Sign in). Each control runs a platform command or operation, so the header has * no behavior a keybinding or CLI lacks. It also draws the prompt those commands ask through, on the * top-level page where a sandboxed panel cannot imitate it (D10). */ export class AppHeaderElement extends HTMLElement { /** Shows the open project and its actions. Pages leave it off. */ editor = false; readonly #root = this.attachShadow({ mode: "open" }); readonly #project = Object.assign(document.createElement("span"), { part: "project" }); readonly #actions = document.createElement("nib-toolbar"); readonly #status = Object.assign(document.createElement("p"), { part: "status", role: "status" }); readonly #end = Object.assign(document.createElement("div"), { part: "end" }); readonly #dialog = document.createElement("dialog"); readonly #importDialog = document.createElement("nib-import-dialog"); #commands = new Map(); #platform: Platform | undefined; #rendered = { actions: "", end: "" }; constructor() { super(); adopt(this.#root, headerSheet); this.#actions.label = "Project"; this.#dialog.setAttribute("aria-labelledby", "prompt-title"); const home = Object.assign(document.createElement("a"), { part: "home", href: "/", textContent: "nibling" }); const bar = Object.assign(document.createElement("div"), { part: "bar" }); bar.append(home, this.#project, this.#actions, this.#status, this.#end); const banner = document.createElement("header"); banner.append(bar); this.#root.append(banner, this.#dialog, this.#importDialog); } /** Commands missing from `commands` are not offered, so a page can pass only the ones it runs. */ connect(platform: Platform, commands: readonly HeaderCommand[]): Unsubscribe { this.#platform = platform; this.#commands = new Map(commands.map((c) => [c.id, c])); this.#rendered = { actions: "", end: "" }; // Read now, not when the element was created: the OAuth callback rewrites the address in between. const home = this.#root.querySelector("[part=home]")!; home.ariaCurrent = location.pathname === "/" ? "page" : null; const render = (state: PlatformState) => { if (this.editor) this.#renderProject(state); this.#renderEnd(state); this.#status.textContent = state.busy ? `${state.busy}…` : (state.notice?.text ?? ""); this.#status.dataset.tone = state.busy ? "info" : (state.notice?.tone ?? "info"); for (const button of this.#root.querySelectorAll("button")) if (!this.#dialog.contains(button)) button.disabled = Boolean(state.busy); }; render(platform.state); return platform.subscribe(render); } #run(id: string) { this.#commands.get(id)?.run(); } #renderProject(state: PlatformState) { const open = state.project; if (!open) this.#project.replaceChildren(); else { const name = Object.assign(document.createElement("strong"), { textContent: openName(open) }); const parts: Node[] = [name]; if (open.files.dirty().length > 0) { const dot = Object.assign(document.createElement("span"), { part: "dirty", textContent: " ●" }); dot.ariaHidden = "true"; parts.push(dot, Object.assign(document.createElement("span"), { className: "visually-hidden", textContent: " (unsaved changes)" })); } this.#project.replaceChildren(...parts); } const ids = projectActions(state).filter((id) => this.#commands.has(id)); if (ids.join() === this.#rendered.actions) return; this.#rendered.actions = ids.join(); this.#actions.replaceChildren( ...ids.map((id) => { const button = this.#button(actionLabels[id] ?? id, () => this.#run(id)); if (id === "project.save") button.title = "Saving publishes this project on your PDS"; return button; }), ); } /** Rebuilds the New and account menus only when who is signed in, or how they look, changes. */ #renderEnd(state: PlatformState) { const key = JSON.stringify([state.account?.session.did, state.accounts]); if (key === this.#rendered.end) return; this.#rendered.end = key; if (!state.account) { this.#end.replaceChildren(this.#button("Sign in", () => this.#run("account.signIn"))); return; } const current = state.accounts.find((a) => a.did === state.account!.session.did) ?? { did: state.account.session.did, ...(state.account.handle && { handle: state.account.handle }) }; this.#end.replaceChildren(...this.#newMenu(), ...this.#accountMenu(current, state.accounts)); } #newMenu(): Node[] { const button = this.#button("New", () => {}); button.part.add("new"); button.prepend(Object.assign(document.createElement("nib-icon"), { name: "add" })); button.append(caret()); const menu = document.createElement("nib-menu"); menu.append(item("New project…", "project.new"), item("Import from Tangled…", "tangled.import")); menu.addEventListener("menuselect", (event) => this.#run((event as MenuSelectEvent).item.value)); menu.trigger = button; return [button, menu]; } #accountMenu(current: KnownAccount, accounts: readonly KnownAccount[]): Node[] { const name = current.handle ?? current.did; const button = this.#button("", () => {}); button.part.add("account"); button.ariaLabel = `Account: ${name}`; button.append(avatar(current), Object.assign(document.createElement("span"), { className: "handle", textContent: name }), caret()); const menu: MenuElement = document.createElement("nib-menu"); const group = document.createElement("nib-menu-group"); group.label = "Accounts"; for (const account of accounts) { const entry = item(account.handle ?? account.did, `switch:${account.did}`); entry.checked = account.did === current.did; group.append(entry); } group.append(item("Add account…", "account.signIn")); menu.append( group, document.createElement("hr"), item("Profile", `go:${actorHref(name)}`), item("Settings", "go:/settings"), document.createElement("hr"), item(`Sign out ${name}`, "account.signOut"), ); menu.addEventListener("menuselect", (event) => { const value = (event as MenuSelectEvent).item.value; if (value.startsWith("go:")) location.assign(value.slice(3)); else if (value.startsWith("switch:")) void this.#platform?.switchAccount(value.slice(7) as KnownAccount["did"]); else this.#run(value); }); menu.trigger = button; return [button, menu]; } #button(text: string, onClick: () => void): HTMLButtonElement { const button = Object.assign(document.createElement("button"), { type: "button", textContent: text }); button.addEventListener("click", onClick); return button; } /** Asks for one value in a modal dialog. Resolves undefined on cancel, Esc, or a second prompt replacing this one. */ ask(request: PromptRequest): Promise { if (this.#dialog.open) this.#dialog.close(); const form = document.createElement("form"); form.method = "dialog"; const title = Object.assign(document.createElement("h2"), { id: "prompt-title", textContent: request.title }); const label = document.createElement("label"); const field = request.choices ? select(request.choices) : input(request); field.name = "value"; field.required = true; field.autofocus = true; label.append(request.label, field); const submit = Object.assign(document.createElement("button"), { type: "submit", value: "ok", textContent: request.submit }); const cancel = Object.assign(document.createElement("button"), { type: "button", textContent: "Cancel" }); cancel.addEventListener("click", () => this.#dialog.close()); const actions = Object.assign(document.createElement("div"), { className: "actions" }); actions.append(cancel, submit); form.append(title, label, actions); this.#dialog.replaceChildren(form); this.#dialog.returnValue = ""; this.#dialog.showModal(); return new Promise((resolve) => { this.#dialog.addEventListener( "close", () => { resolve(this.#dialog.returnValue === "ok" ? field.value : undefined); this.#dialog.returnValue = ""; }, { once: true }, ); }); } /** * Shows the import picker (D20) and, once Import is chosen, opens its target. On a page, Open * navigates to the repo's own address — tangled.import already behaves like pasting that address in * the bar, and the route→editor flow does the fetch. In the editor, a navigation would reload the * shell, so this calls `platform.openRepo` directly instead, the way other editor commands do. */ async importRepo(platform: Platform): Promise { const target = await this.#importDialog.open(platform); if (!target) return; if (this.editor) await platform.openRepo(`${target.owner}/${target.name}`, { ref: target.ref, path: target.path, reuse: target.reuse }); else location.assign(repoHref(target.owner, target.name, target.ref, target.path)); } } function item(text: string, value: string) { return Object.assign(document.createElement("nib-menu-item"), { textContent: text, value }); } function caret(): HTMLElement { return Object.assign(document.createElement("nib-icon"), { className: "caret", name: "chevron-down" }); } /** * The account's avatar, or the first letter of its name on a tinted disc. The editor is cross-origin * isolated (COEP require-corp) and blob hosts send no CORP header, so the image loads with CORS, which * PDSes and our gateway allow. */ function avatar(account: KnownAccount): HTMLElement { if (account.avatarUrl) { return Object.assign(document.createElement("img"), { className: "avatar", crossOrigin: "anonymous", src: account.avatarUrl, alt: "", width: 24, height: 24 }); } const letter = (account.displayName ?? account.handle ?? "?").trim().charAt(0).toLocaleUpperCase(); return Object.assign(document.createElement("span"), { className: "avatar", textContent: letter, ariaHidden: "true" }); } function input(request: PromptRequest): HTMLInputElement { const field = document.createElement("input"); field.type = "text"; field.spellcheck = false; field.autocapitalize = "off"; if (request.placeholder) field.placeholder = request.placeholder; if (request.autocomplete) field.autocomplete = request.autocomplete; return field; } function select(choices: NonNullable): HTMLSelectElement { const field = document.createElement("select"); for (const choice of choices) field.append(new Option(choice.label, choice.value)); return field; } declare global { interface HTMLElementTagNameMap { "nib-app-header": AppHeaderElement; } } define("nib-app-header", AppHeaderElement);