diff --git a/src/core/dfn-panel.js b/src/core/dfn-panel.js index 358d89ccdd..b42d897338 100644 --- a/src/core/dfn-panel.js +++ b/src/core/dfn-panel.js @@ -29,6 +29,31 @@ export async function run() { el.tabIndex = 0; el.setAttribute("aria-haspopup", "dialog"); } + + /** @type {NodeListOf} */ + const bibrefLinks = document.querySelectorAll("a.bibref[href^='#bib-']"); + /** @type {Set} */ + const seenBibIds = new Set(); + /** @type {Set} bibIds that have an actual panel */ + const panelBibIds = new Set(); + bibrefLinks.forEach(link => { + const bibId = link.getAttribute("href")?.slice(1); + if (!bibId) return; + // Multiple links can share the same bibId (aliases); create the panel once. + if (!seenBibIds.has(bibId)) { + seenBibIds.add(bibId); + const panel = createBiblioPanel(link); + if (panel) { + panels.append(panel); + panelBibIds.add(bibId); + } + } + // Only mark links as dialog triggers when a panel was actually created. + if (panelBibIds.has(bibId)) { + link.setAttribute("aria-haspopup", "dialog"); + } + }); + const firstScript = document.body.querySelector("script"); if (firstScript) { firstScript.before(panels); @@ -79,6 +104,51 @@ function createPanel(dfn) { return panel; } +/** + * Creates a popup panel for a bibliography reference link. + * The panel clones the rendered `
` from the references section. + * + * @param {HTMLAnchorElement} link + * @returns {HTMLElement | null} + */ +function createBiblioPanel(link) { + const bibId = link.getAttribute("href")?.slice(1); // e.g. "bib-html" + if (!bibId) return null; + const dt = document.getElementById(bibId); + if (!dt) return null; + const dd = dt.nextElementSibling; + if (!dd || dd.tagName !== "DD") return null; + // Don't create a panel for references that couldn't be resolved. + if (dd.querySelector(".respec-offending-element")) return null; + + const panelId = `biblio-panel-for-${bibId}`; + const refKey = dt.textContent?.trim() ?? ""; + + /** @type {HTMLElement} */ + const panel = html` + + `; + return panel; +} + /** @param {HTMLElement} dfn */ function dfnExportedMarker(dfn) { if (!dfn.matches("dfn[data-export]")) return null; diff --git a/src/core/dfn-panel.runtime.js b/src/core/dfn-panel.runtime.js index 09397bdc8b..14fb2e83d7 100644 --- a/src/core/dfn-panel.runtime.js +++ b/src/core/dfn-panel.runtime.js @@ -46,6 +46,25 @@ function panelListener() { displayPanel(dfn, panel, coords); break; } + case "showBiblio": { + hidePanel(panel); + /** @type {HTMLAnchorElement | null} */ + const bibLink = /** @type {HTMLAnchorElement | null} */ ( + target.closest("a.bibref[href^='#bib-']") + ); + if (!bibLink) break; + const bibId = bibLink.getAttribute("href")?.slice(1); + if (!bibId) break; + panel = document.getElementById(`biblio-panel-for-${bibId}`); + if (!panel) break; + // Prevent the default scroll-to-references navigation. + event.preventDefault(); + const bibCoords = deriveCoordinates( + /** @type {MouseEvent|KeyboardEvent} */ (event) + ); + displayPanel(bibLink, panel, bibCoords); + break; + } case "dock": { if (panel) { panel.style.left = ""; @@ -98,7 +117,13 @@ function deriveAction(event) { if (target.closest("dfn:not([data-cite]), .index-term")) { return hitALink ? "none" : "show"; } + if (target.closest("a.bibref[href^='#bib-']")) { + return "showBiblio"; + } if (target.closest(".dfn-panel")) { + if (target.closest(".biblio-ref")) { + return "none"; + } if (hitALink) { return target.classList.contains("self-link") ? "hide" : "dock"; } diff --git a/src/styles/dfn-panel.css.js b/src/styles/dfn-panel.css.js index 8a96ced09c..6ec0bde9f3 100644 --- a/src/styles/dfn-panel.css.js +++ b/src/styles/dfn-panel.css.js @@ -12,6 +12,10 @@ dfn { cursor: pointer; } +a.bibref { + cursor: pointer; +} + .dfn-panel { position: absolute; z-index: 35; @@ -128,4 +132,8 @@ dfn { max-height: 30vh; overflow: auto; } + +.dfn-panel .biblio-ref { + margin-top: 0.5em; +} `; diff --git a/tests/spec/core/dfn-panel-spec.js b/tests/spec/core/dfn-panel-spec.js index 53d7ec6c1b..ffa96721f1 100644 --- a/tests/spec/core/dfn-panel-spec.js +++ b/tests/spec/core/dfn-panel-spec.js @@ -285,3 +285,155 @@ describe("Core — dfnPanel", () => { expect(references[0].hash).toBe("#ref-for-dfn-one-1"); }); }); + +describe("Core — biblioPanel", () => { + afterAll(flushIframes); + + const localBiblio = { + TestRef: { + title: "Test Reference Title", + href: "https://example.com/test", + authors: ["Author One", "Author Two"], + publisher: "Test Publisher", + date: "2024", + }, + AliasRef: { + aliasOf: "TestRef", + }, + }; + const body = ` +
+

[[TestRef]]

+

[[AliasRef]]

+
+ `; + const ops = makeStandardOps({ localBiblio }, body); + + describe("panel creation", () => { + it("creates a biblio panel for each unique bibref href", async () => { + const doc = await makeRSDoc(ops); + // [[TestRef]] and [[AliasRef]] both resolve to #bib-testref after + // alias normalisation, so there should be exactly one panel. + const panels = doc.querySelectorAll( + '[id^="biblio-panel-for-bib-testref"]' + ); + expect(panels).toHaveSize(1); + }); + + it("has correct ARIA attributes", async () => { + const doc = await makeRSDoc(ops); + const panel = doc.getElementById("biblio-panel-for-bib-testref"); + expect(panel).toBeTruthy(); + expect(panel.getAttribute("role")).toBe("dialog"); + expect(panel.getAttribute("aria-modal")).toBe("true"); + expect(panel.getAttribute("aria-label")).toBe( + "Citation details for [TestRef]" + ); + }); + + it("marks bibref links with aria-haspopup", async () => { + const doc = await makeRSDoc(ops); + const bibrefLinks = doc.querySelectorAll( + "#p-testref a.bibref, #p-aliasref a.bibref" + ); + bibrefLinks.forEach(link => { + expect(link.getAttribute("aria-haspopup")) + .withContext(`${link.textContent} should have aria-haspopup`) + .toBe("dialog"); + }); + }); + + it("panel is hidden by default", async () => { + const doc = await makeRSDoc(ops); + const panel = doc.getElementById("biblio-panel-for-bib-testref"); + expect(panel.hidden).toBeTrue(); + }); + + it("has a self-link pointing to the references entry", async () => { + const doc = await makeRSDoc(ops); + const panel = doc.getElementById("biblio-panel-for-bib-testref"); + const selfLink = panel.querySelector("a.self-link"); + expect(selfLink).toBeTruthy(); + expect(selfLink.hash).toBe("#bib-testref"); + expect(selfLink.textContent.trim()).toBe("Reference"); + }); + + it("contains citation content from the references section", async () => { + const doc = await makeRSDoc(ops); + const panel = doc.getElementById("biblio-panel-for-bib-testref"); + const biblioRef = panel.querySelector(".biblio-ref"); + expect(biblioRef).toBeTruthy(); + expect(biblioRef.textContent).toContain("Test Reference Title"); + expect(biblioRef.textContent).toContain("Author One"); + expect(biblioRef.textContent).toContain("Test Publisher"); + expect(biblioRef.textContent).toContain("2024"); + }); + }); + + describe("panel interaction", () => { + it("opens on bibref click", async () => { + const doc = await makeRSDoc(ops); + const panel = doc.getElementById("biblio-panel-for-bib-testref"); + expect(panel.hidden).toBeTrue(); + const bibLink = doc.querySelector("#p-testref a.bibref"); + bibLink.click(); + expect(panel.hidden).toBeFalse(); + }); + + it("closes on external click", async () => { + const doc = await makeRSDoc(ops); + const panel = doc.getElementById("biblio-panel-for-bib-testref"); + doc.querySelector("#p-testref a.bibref").click(); + expect(panel.hidden).toBeFalse(); + doc.body.click(); + expect(panel.hidden).toBeTrue(); + }); + + it("closes on self-link click", async () => { + const doc = await makeRSDoc(ops); + const panel = doc.getElementById("biblio-panel-for-bib-testref"); + doc.querySelector("#p-testref a.bibref").click(); + expect(panel.hidden).toBeFalse(); + panel.querySelector("a.self-link").click(); + expect(panel.hidden).toBeTrue(); + }); + + it("does not close when clicking inside the panel", async () => { + const doc = await makeRSDoc(ops); + const panel = doc.getElementById("biblio-panel-for-bib-testref"); + doc.querySelector("#p-testref a.bibref").click(); + expect(panel.hidden).toBeFalse(); + panel.click(); + expect(panel.hidden).toBeFalse(); + }); + }); + + it("does not create a panel for a bibref with no rendered entry", async () => { + const body = `

[[bad-ref-no-entry]]

`; + const ops = makeStandardOps(null, body); + const doc = await makeRSDoc(ops); + const panel = doc.querySelector('[id^="biblio-panel-for-"]'); + expect(panel).toBeNull(); + const link = doc.querySelector("#bad a.bibref"); + expect(link.hasAttribute("aria-haspopup")).toBeFalse(); + }); + + it("works in an exported document", async () => { + const rdoc = await makeRSDoc(ops); + const doc = await getExportedDoc(rdoc); + + const panel = doc.getElementById("biblio-panel-for-bib-testref"); + expect(panel).toBeTruthy(); + expect(panel.hidden).toBeTrue(); + + const bibLink = doc.querySelector("#p-testref a.bibref"); + bibLink.click(); + expect(panel.hidden).toBeFalse(); + + const selfLink = panel.querySelector("a.self-link"); + expect(selfLink.hash).toBe("#bib-testref"); + + const biblioRef = panel.querySelector(".biblio-ref"); + expect(biblioRef.textContent).toContain("Test Reference Title"); + }); +});