Archived
fix(relays): keep document tab strip scroll position on tab switch
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@jmfederico/pi-web": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Keep the relays panel document tab strip's horizontal scroll position when switching documents, instead of jumping back to the left edge on every tab click.
|
||||||
@@ -109,7 +109,7 @@ describe("multiple relays", () => {
|
|||||||
const select = picker(panel);
|
const select = picker(panel);
|
||||||
if (select === null) throw new Error("relay picker missing");
|
if (select === null) throw new Error("relay picker missing");
|
||||||
select.value = `${RELAYS_ROOT}/older`;
|
select.value = `${RELAYS_ROOT}/older`;
|
||||||
select.dispatchEvent(new Event("change"));
|
select.dispatchEvent(new Event("change", { bubbles: true })); // Real change events bubble; the panel listens at the region container.
|
||||||
await flushAsync();
|
await flushAsync();
|
||||||
|
|
||||||
expect(fake.listFiles).toHaveBeenCalledWith(`${RELAYS_ROOT}/older`);
|
expect(fake.listFiles).toHaveBeenCalledWith(`${RELAYS_ROOT}/older`);
|
||||||
@@ -167,6 +167,77 @@ describe("document tabs", () => {
|
|||||||
expect(children.map((child) => child.textContent)).toEqual(["status.md", "log.md", "notes.md"]);
|
expect(children.map((child) => child.textContent)).toEqual(["status.md", "log.md", "notes.md"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the tab strip mounted with its scroll and focus when switching documents", async () => {
|
||||||
|
const fake = workspaceFilesFake();
|
||||||
|
fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]);
|
||||||
|
fake.addDirectory(`${RELAYS_ROOT}/relay`, [
|
||||||
|
relayDocument("relay", "notes.md"),
|
||||||
|
relayDocument("relay", "log.md"),
|
||||||
|
relayDocument("relay", "status.md"),
|
||||||
|
]);
|
||||||
|
fake.addDocument(`${RELAYS_ROOT}/relay/status.md`, "status body");
|
||||||
|
fake.addDocument(`${RELAYS_ROOT}/relay/log.md`, "log body");
|
||||||
|
|
||||||
|
const panel = await mountPanel(panelContext(fake));
|
||||||
|
const strip = tabStrip(panel);
|
||||||
|
strip.scrollLeft = 120;
|
||||||
|
const logTab = tabNamed(panel, "log.md");
|
||||||
|
|
||||||
|
logTab.click();
|
||||||
|
await flushAsync();
|
||||||
|
|
||||||
|
// Switching documents only re-renders the viewer: the strip element and
|
||||||
|
// its buttons stay mounted, so scroll position and button identity survive.
|
||||||
|
expect(documentText(panel)).toBe("log body");
|
||||||
|
expect(tabStrip(panel)).toBe(strip);
|
||||||
|
expect(tabStrip(panel).scrollLeft).toBe(120);
|
||||||
|
expect(tabNamed(panel, "log.md")).toBe(logTab);
|
||||||
|
expect(activeTab(panel)?.textContent).toBe("log.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scrolls the viewer back to the top when switching documents", async () => {
|
||||||
|
const fake = workspaceFilesFake();
|
||||||
|
fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]);
|
||||||
|
fake.addDirectory(`${RELAYS_ROOT}/relay`, [relayDocument("relay", "log.md"), relayDocument("relay", "status.md")]);
|
||||||
|
fake.addDocument(`${RELAYS_ROOT}/relay/status.md`, "status body");
|
||||||
|
fake.addDocument(`${RELAYS_ROOT}/relay/log.md`, "log body");
|
||||||
|
|
||||||
|
const panel = await mountPanel(panelContext(fake));
|
||||||
|
const viewer = shadow(panel).querySelector("section.viewer");
|
||||||
|
if (!(viewer instanceof HTMLElement)) throw new Error("viewer missing");
|
||||||
|
viewer.scrollTop = 80;
|
||||||
|
|
||||||
|
tabNamed(panel, "log.md").click();
|
||||||
|
await flushAsync();
|
||||||
|
|
||||||
|
expect(documentText(panel)).toBe("log body");
|
||||||
|
expect(viewer.scrollTop).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts a different relay's tab strip at the left edge", async () => {
|
||||||
|
const fake = workspaceFilesFake();
|
||||||
|
fake.addDirectory(RELAYS_ROOT, [
|
||||||
|
relayDirectory("alpha", "2026-01-01T00:00:00.000Z"),
|
||||||
|
relayDirectory("beta", "2026-02-01T00:00:00.000Z"),
|
||||||
|
]);
|
||||||
|
fake.addDirectory(`${RELAYS_ROOT}/alpha`, [relayDocument("alpha", "status.md")]);
|
||||||
|
fake.addDirectory(`${RELAYS_ROOT}/beta`, [relayDocument("beta", "status.md"), relayDocument("beta", "log.md")]);
|
||||||
|
fake.addDocument(`${RELAYS_ROOT}/beta/status.md`, "beta status");
|
||||||
|
fake.addDocument(`${RELAYS_ROOT}/alpha/status.md`, "alpha status");
|
||||||
|
|
||||||
|
const panel = await mountPanel(panelContext(fake));
|
||||||
|
tabStrip(panel).scrollLeft = 120;
|
||||||
|
|
||||||
|
const select = picker(panel);
|
||||||
|
if (select === null) throw new Error("relay picker missing");
|
||||||
|
select.value = `${RELAYS_ROOT}/alpha`;
|
||||||
|
select.dispatchEvent(new Event("change", { bubbles: true })); // Real change events bubble; the panel listens at the region container.
|
||||||
|
await flushAsync();
|
||||||
|
|
||||||
|
expect(documentText(panel)).toBe("alpha status");
|
||||||
|
expect(tabStrip(panel).scrollLeft).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("shows a truncation notice when the open document is truncated", async () => {
|
it("shows a truncation notice when the open document is truncated", async () => {
|
||||||
const fake = workspaceFilesFake();
|
const fake = workspaceFilesFake();
|
||||||
fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]);
|
fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]);
|
||||||
@@ -308,7 +379,7 @@ describe("refresh and context changes", () => {
|
|||||||
const select = picker(panel);
|
const select = picker(panel);
|
||||||
if (select === null) throw new Error("relay picker missing");
|
if (select === null) throw new Error("relay picker missing");
|
||||||
select.value = `${RELAYS_ROOT}/alpha`;
|
select.value = `${RELAYS_ROOT}/alpha`;
|
||||||
select.dispatchEvent(new Event("change"));
|
select.dispatchEvent(new Event("change", { bubbles: true })); // Real change events bubble; the panel listens at the region container.
|
||||||
await flushAsync();
|
await flushAsync();
|
||||||
tabNamed(panel, "log.md").click();
|
tabNamed(panel, "log.md").click();
|
||||||
await flushAsync();
|
await flushAsync();
|
||||||
@@ -463,6 +534,12 @@ function refreshButton(panel: RelaysPanelTestElement): HTMLElement {
|
|||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tabStrip(panel: RelaysPanelTestElement): HTMLElement {
|
||||||
|
const strip = shadow(panel).querySelector("nav.document-tabs");
|
||||||
|
if (!(strip instanceof HTMLElement)) throw new Error("tab strip missing");
|
||||||
|
return strip;
|
||||||
|
}
|
||||||
|
|
||||||
function tabNames(panel: RelaysPanelTestElement): string[] {
|
function tabNames(panel: RelaysPanelTestElement): string[] {
|
||||||
return [...shadow(panel).querySelectorAll("button[data-document-path]")].map((tab) => tab.textContent);
|
return [...shadow(panel).querySelectorAll("button[data-document-path]")].map((tab) => tab.textContent);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ interface RelaySelection {
|
|||||||
* sanitized HTML and everything else as preformatted text. All async loads
|
* sanitized HTML and everything else as preformatted text. All async loads
|
||||||
* flow through scanToken so stale responses for a previous workspace or
|
* flow through scanToken so stale responses for a previous workspace or
|
||||||
* selection never overwrite newer state.
|
* selection never overwrite newer state.
|
||||||
|
*
|
||||||
|
* Rendering is region-scoped: the toolbar, tab strip, and viewer are
|
||||||
|
* persistent elements built once, and each async stage re-renders only its
|
||||||
|
* own region. Clicking a tab never rebuilds the strip — it toggles the
|
||||||
|
* active marker in place and re-renders the viewer — so the strip's
|
||||||
|
* horizontal scroll position and keyboard focus survive document switches.
|
||||||
*/
|
*/
|
||||||
class PiWebRelaysPanel extends HTMLElement {
|
class PiWebRelaysPanel extends HTMLElement {
|
||||||
private contextValue: WorkspacePanelContext | undefined;
|
private contextValue: WorkspacePanelContext | undefined;
|
||||||
@@ -39,36 +45,64 @@ class PiWebRelaysPanel extends HTMLElement {
|
|||||||
private documentContent: RelayDocumentContent | undefined;
|
private documentContent: RelayDocumentContent | undefined;
|
||||||
private scanToken = 0;
|
private scanToken = 0;
|
||||||
private readonly root: ShadowRoot;
|
private readonly root: ShadowRoot;
|
||||||
|
private readonly toolbar: HTMLElement;
|
||||||
|
private readonly tabStrip: HTMLElement;
|
||||||
|
private readonly viewer: HTMLElement;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.root = this.attachShadow({ mode: "open" });
|
this.root = this.attachShadow({ mode: "open" });
|
||||||
|
this.root.innerHTML = `
|
||||||
|
${relaysStyles()}
|
||||||
|
<section class="toolbar" hidden></section>
|
||||||
|
<nav class="document-tabs" aria-label="Relay documents" hidden></nav>
|
||||||
|
<section class="viewer"><div class="empty">Select a workspace.</div></section>
|
||||||
|
`;
|
||||||
|
this.toolbar = requiredRegion(this.root, ".toolbar");
|
||||||
|
this.tabStrip = requiredRegion(this.root, "nav.document-tabs");
|
||||||
|
this.viewer = requiredRegion(this.root, ".viewer");
|
||||||
|
|
||||||
|
// Listeners bind once against the persistent regions and delegate to
|
||||||
|
// whichever controls the latest region render produced.
|
||||||
|
this.toolbar.addEventListener("click", (event) => {
|
||||||
|
const button = event.target instanceof Element ? event.target.closest("button[data-refresh]") : null;
|
||||||
|
if (button !== null) this.refresh();
|
||||||
|
});
|
||||||
|
this.toolbar.addEventListener("change", (event) => {
|
||||||
|
const picker = event.target;
|
||||||
|
if (!(picker instanceof HTMLSelectElement) || !picker.matches("select[data-relay-picker]")) return;
|
||||||
|
const context = this.contextValue;
|
||||||
|
if (context !== undefined) void this.openRelay(context, picker.value);
|
||||||
|
});
|
||||||
|
this.tabStrip.addEventListener("click", (event) => {
|
||||||
|
const tab = event.target instanceof Element ? event.target.closest("button[data-document-path]") : null;
|
||||||
|
if (tab === null) return;
|
||||||
|
const documentPath = tab.getAttribute("data-document-path");
|
||||||
|
const context = this.contextValue;
|
||||||
|
if (context !== undefined && documentPath !== null) void this.openDocument(context, documentPath);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
set context(value: WorkspacePanelContext | undefined) {
|
set context(value: WorkspacePanelContext | undefined) {
|
||||||
const previousKey = this.contextValue === undefined ? undefined : contextKey(this.contextValue);
|
const previousKey = this.contextValue === undefined ? undefined : contextKey(this.contextValue);
|
||||||
const nextKey = value === undefined ? undefined : contextKey(value);
|
const nextKey = value === undefined ? undefined : contextKey(value);
|
||||||
this.contextValue = value;
|
this.contextValue = value;
|
||||||
// Parent app updates should not rescan or rebuild this shadow DOM for the
|
// Parent app updates should not rescan or re-render this panel for the
|
||||||
// same workspace (mirrors the workspace-tasks panel).
|
// same workspace (mirrors the workspace-tasks panel).
|
||||||
if (previousKey === nextKey) return;
|
if (previousKey === nextKey) return;
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
this.resetScanState();
|
this.resetScanState();
|
||||||
this.render();
|
this.renderAll();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void this.scan(value, {});
|
void this.scan(value, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback(): void {
|
|
||||||
this.render();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Rescan relays, then reload the selected relay's documents and the open document. */
|
/** Rescan relays, then reload the selected relay's documents and the open document. */
|
||||||
private async scan(context: WorkspacePanelContext, selection: RelaySelection): Promise<void> {
|
private async scan(context: WorkspacePanelContext, selection: RelaySelection): Promise<void> {
|
||||||
const token = ++this.scanToken;
|
const token = ++this.scanToken;
|
||||||
this.resetScanState();
|
this.resetScanState();
|
||||||
this.render();
|
this.renderAll();
|
||||||
|
|
||||||
const listing = await listWorkspaceRelays(context.files);
|
const listing = await listWorkspaceRelays(context.files);
|
||||||
if (!this.isCurrentScan(context, token)) return;
|
if (!this.isCurrentScan(context, token)) return;
|
||||||
@@ -80,8 +114,9 @@ class PiWebRelaysPanel extends HTMLElement {
|
|||||||
? listing.relays.find((candidate) => candidate.path === selection.relayPath) ?? listing.relays[0]
|
? listing.relays.find((candidate) => candidate.path === selection.relayPath) ?? listing.relays[0]
|
||||||
: undefined;
|
: undefined;
|
||||||
this.selectedRelayPath = relay?.path;
|
this.selectedRelayPath = relay?.path;
|
||||||
|
this.renderToolbar();
|
||||||
if (relay === undefined) {
|
if (relay === undefined) {
|
||||||
this.render();
|
this.renderViewer();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.loadDocuments(context, token, relay.path, selection.documentPath);
|
await this.loadDocuments(context, token, relay.path, selection.documentPath);
|
||||||
@@ -96,6 +131,11 @@ class PiWebRelaysPanel extends HTMLElement {
|
|||||||
private async openDocument(context: WorkspacePanelContext, documentPath: string): Promise<void> {
|
private async openDocument(context: WorkspacePanelContext, documentPath: string): Promise<void> {
|
||||||
const token = ++this.scanToken;
|
const token = ++this.scanToken;
|
||||||
this.selectedDocumentPath = documentPath;
|
this.selectedDocumentPath = documentPath;
|
||||||
|
// The tab set is unchanged: toggle the active marker on the mounted
|
||||||
|
// buttons instead of rebuilding the strip, so its scroll position and
|
||||||
|
// focus stay put. A different document starts reading from the top.
|
||||||
|
this.updateActiveTab();
|
||||||
|
this.viewer.scrollTop = 0;
|
||||||
await this.loadDocumentContent(context, token, documentPath);
|
await this.loadDocumentContent(context, token, documentPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +143,8 @@ class PiWebRelaysPanel extends HTMLElement {
|
|||||||
this.documents = undefined;
|
this.documents = undefined;
|
||||||
this.selectedDocumentPath = undefined;
|
this.selectedDocumentPath = undefined;
|
||||||
this.documentContent = undefined;
|
this.documentContent = undefined;
|
||||||
this.render();
|
this.renderTabs();
|
||||||
|
this.renderViewer();
|
||||||
|
|
||||||
const documents = await listRelayDocuments(context.files, relayPath);
|
const documents = await listRelayDocuments(context.files, relayPath);
|
||||||
if (!this.isCurrentScan(context, token)) return;
|
if (!this.isCurrentScan(context, token)) return;
|
||||||
@@ -113,8 +154,9 @@ class PiWebRelaysPanel extends HTMLElement {
|
|||||||
? documents.documents.find((candidate) => candidate.path === preferredDocumentPath) ?? defaultRelayDocument(documents.documents)
|
? documents.documents.find((candidate) => candidate.path === preferredDocumentPath) ?? defaultRelayDocument(documents.documents)
|
||||||
: undefined;
|
: undefined;
|
||||||
this.selectedDocumentPath = document?.path;
|
this.selectedDocumentPath = document?.path;
|
||||||
|
this.renderTabs();
|
||||||
if (document === undefined) {
|
if (document === undefined) {
|
||||||
this.render();
|
this.renderViewer();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.loadDocumentContent(context, token, document.path);
|
await this.loadDocumentContent(context, token, document.path);
|
||||||
@@ -122,12 +164,12 @@ class PiWebRelaysPanel extends HTMLElement {
|
|||||||
|
|
||||||
private async loadDocumentContent(context: WorkspacePanelContext, token: number, documentPath: string): Promise<void> {
|
private async loadDocumentContent(context: WorkspacePanelContext, token: number, documentPath: string): Promise<void> {
|
||||||
this.documentContent = undefined;
|
this.documentContent = undefined;
|
||||||
this.render();
|
this.renderViewer();
|
||||||
|
|
||||||
const content = await readRelayDocument(context.files, documentPath);
|
const content = await readRelayDocument(context.files, documentPath);
|
||||||
if (!this.isCurrentScan(context, token)) return;
|
if (!this.isCurrentScan(context, token)) return;
|
||||||
this.documentContent = content;
|
this.documentContent = content;
|
||||||
this.render();
|
this.renderViewer();
|
||||||
}
|
}
|
||||||
|
|
||||||
private refresh(): void {
|
private refresh(): void {
|
||||||
@@ -148,38 +190,26 @@ class PiWebRelaysPanel extends HTMLElement {
|
|||||||
return token === this.scanToken && this.contextValue !== undefined && contextKey(this.contextValue) === contextKey(context);
|
return token === this.scanToken && this.contextValue !== undefined && contextKey(this.contextValue) === contextKey(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
private render(): void {
|
private renderAll(): void {
|
||||||
const context = this.contextValue;
|
this.renderToolbar();
|
||||||
if (context === undefined) {
|
this.renderTabs();
|
||||||
this.root.innerHTML = `${relaysStyles()}<section class="empty">Select a workspace.</section>`;
|
this.renderViewer();
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderToolbar(): void {
|
||||||
|
if (this.contextValue === undefined) {
|
||||||
|
this.toolbar.hidden = true;
|
||||||
|
this.toolbar.replaceChildren();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.root.innerHTML = `
|
this.toolbar.hidden = false;
|
||||||
${relaysStyles()}
|
this.toolbar.innerHTML = `
|
||||||
<section class="toolbar">
|
<strong>Relays</strong>
|
||||||
<strong>Relays</strong>
|
<span class="toolbar-actions">
|
||||||
<span class="toolbar-actions">
|
${this.renderRelayPicker()}
|
||||||
${this.renderRelayPicker()}
|
<button class="icon-button" data-refresh aria-label="Refresh" title="Refresh">${refreshIconSvg()}</button>
|
||||||
<button class="icon-button" data-refresh aria-label="Refresh" title="Refresh">${refreshIconSvg()}</button>
|
</span>
|
||||||
</span>
|
|
||||||
</section>
|
|
||||||
${this.renderDocumentTabs()}
|
|
||||||
<section class="viewer">${this.renderViewer()}</section>
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
this.root.querySelector("button[data-refresh]")?.addEventListener("click", () => {
|
|
||||||
this.refresh();
|
|
||||||
});
|
|
||||||
this.root.querySelector("select[data-relay-picker]")?.addEventListener("change", (event) => {
|
|
||||||
const picker = event.target;
|
|
||||||
if (picker instanceof HTMLSelectElement) void this.openRelay(context, picker.value);
|
|
||||||
});
|
|
||||||
for (const tab of this.root.querySelectorAll("button[data-document-path]")) {
|
|
||||||
tab.addEventListener("click", () => {
|
|
||||||
const documentPath = tab.getAttribute("data-document-path");
|
|
||||||
if (documentPath !== null) void this.openDocument(context, documentPath);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderRelayPicker(): string {
|
private renderRelayPicker(): string {
|
||||||
@@ -197,17 +227,43 @@ class PiWebRelaysPanel extends HTMLElement {
|
|||||||
return `<select data-relay-picker aria-label="Relay">${options}</select>`;
|
return `<select data-relay-picker aria-label="Relay">${options}</select>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderDocumentTabs(): string {
|
private renderTabs(): void {
|
||||||
const documents = this.documents;
|
const documents = this.documents;
|
||||||
if (documents?.kind !== "loaded" || documents.documents.length === 0) return "";
|
if (documents?.kind !== "loaded" || documents.documents.length === 0) {
|
||||||
const tabs = documents.documents.map((document) => {
|
this.tabStrip.hidden = true;
|
||||||
|
// A new tab set starts at the left edge, not at the previous set's offset.
|
||||||
|
this.tabStrip.replaceChildren();
|
||||||
|
this.tabStrip.scrollLeft = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.tabStrip.hidden = false;
|
||||||
|
// The strip element itself persists across re-renders, so replacing its
|
||||||
|
// buttons keeps the container's horizontal scroll position.
|
||||||
|
this.tabStrip.innerHTML = documents.documents.map((document) => {
|
||||||
const active = document.path === this.selectedDocumentPath;
|
const active = document.path === this.selectedDocumentPath;
|
||||||
return `<button class="document-tab${active ? " active" : ""}" data-document-path="${escapeAttr(document.path)}"${active ? ' aria-current="true"' : ""}>${escapeHtml(document.name)}</button>`;
|
return `<button class="document-tab${active ? " active" : ""}" data-document-path="${escapeAttr(document.path)}"${active ? ' aria-current="true"' : ""}>${escapeHtml(document.name)}</button>`;
|
||||||
}).join("");
|
}).join("");
|
||||||
return `<nav class="document-tabs" aria-label="Relay documents">${tabs}</nav>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderViewer(): string {
|
/** Move the active marker between the mounted tab buttons without rebuilding them. */
|
||||||
|
private updateActiveTab(): void {
|
||||||
|
for (const tab of this.tabStrip.querySelectorAll("button[data-document-path]")) {
|
||||||
|
const active = tab.getAttribute("data-document-path") === this.selectedDocumentPath;
|
||||||
|
tab.classList.toggle("active", active);
|
||||||
|
if (active) tab.setAttribute("aria-current", "true");
|
||||||
|
else tab.removeAttribute("aria-current");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderViewer(): void {
|
||||||
|
if (this.contextValue === undefined) {
|
||||||
|
this.viewer.innerHTML = `<div class="empty">Select a workspace.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.viewer.innerHTML = this.renderViewerContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderViewerContent(): string {
|
||||||
const listing = this.listing;
|
const listing = this.listing;
|
||||||
if (listing === undefined) return `<p class="muted">Scanning ${escapeHtml(RELAYS_ROOT)}…</p>`;
|
if (listing === undefined) return `<p class="muted">Scanning ${escapeHtml(RELAYS_ROOT)}…</p>`;
|
||||||
if (listing.kind === "unavailable") return renderErrorState("Could not scan workspace relays.", listing.detail);
|
if (listing.kind === "unavailable") return renderErrorState("Could not scan workspace relays.", listing.detail);
|
||||||
@@ -255,6 +311,13 @@ class PiWebRelaysPanel extends HTMLElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Shell regions come from a literal template; absence means the template broke. */
|
||||||
|
function requiredRegion(root: ShadowRoot, selector: string): HTMLElement {
|
||||||
|
const element = root.querySelector(selector);
|
||||||
|
if (!(element instanceof HTMLElement)) throw new Error(`relays panel shell is missing ${selector}`);
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
/** Reload glyph matching the app's own refresh control (AppRefreshControl). */
|
/** Reload glyph matching the app's own refresh control (AppRefreshControl). */
|
||||||
function refreshIconSvg(): string {
|
function refreshIconSvg(): string {
|
||||||
return `
|
return `
|
||||||
@@ -297,6 +360,7 @@ function relaysStyles(): string {
|
|||||||
the viewer's huge content basis starves them down to a sliver once a
|
the viewer's huge content basis starves them down to a sliver once a
|
||||||
tall document renders). The viewer absorbs all shrinking instead. */
|
tall document renders). The viewer absorbs all shrinking instead. */
|
||||||
.toolbar { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--pi-border-muted); }
|
.toolbar { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--pi-border-muted); }
|
||||||
|
.toolbar[hidden], .document-tabs[hidden] { display: none; }
|
||||||
.toolbar-actions { display: inline-flex; align-items: center; flex-wrap: nowrap; justify-content: flex-end; gap: 8px; min-width: 0; }
|
.toolbar-actions { display: inline-flex; align-items: center; flex-wrap: nowrap; justify-content: flex-end; gap: 8px; min-width: 0; }
|
||||||
.relay-name { min-width: 0; color: var(--pi-text-secondary); overflow-wrap: anywhere; }
|
.relay-name { min-width: 0; color: var(--pi-text-secondary); overflow-wrap: anywhere; }
|
||||||
/* Bottom padding (not viewer margin) so the gap below the tabs persists
|
/* Bottom padding (not viewer margin) so the gap below the tabs persists
|
||||||
|
|||||||
Reference in New Issue
Block a user