Skip to content
70 changes: 70 additions & 0 deletions src/core/dfn-panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,31 @@ export async function run() {
el.tabIndex = 0;
el.setAttribute("aria-haspopup", "dialog");
}

/** @type {NodeListOf<HTMLAnchorElement>} */
const bibrefLinks = document.querySelectorAll("a.bibref[href^='#bib-']");
/** @type {Set<string>} */
const seenBibIds = new Set();
/** @type {Set<string>} 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);
Expand Down Expand Up @@ -79,6 +104,51 @@ function createPanel(dfn) {
return panel;
}

/**
* Creates a popup panel for a bibliography reference link.
* The panel clones the rendered `<dd>` 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`
<div
class="dfn-panel"
id="${panelId}"
hidden
role="dialog"
aria-modal="true"
aria-label="Citation details for ${refKey}"
>
<span class="caret"></span>
<div>
<a
class="self-link"
href="#${bibId}"
aria-label="Go to reference ${refKey}. Activate to close this dialog."
>Reference</a
>
</div>
<p class="biblio-ref">${{ html: dd.innerHTML }}</p>
</div>
`;
return panel;
}

/** @param {HTMLElement} dfn */
function dfnExportedMarker(dfn) {
if (!dfn.matches("dfn[data-export]")) return null;
Expand Down
25 changes: 25 additions & 0 deletions src/core/dfn-panel.runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Comment thread
marcoscaceres marked this conversation as resolved.
/** @type {MouseEvent|KeyboardEvent} */ (event)
);
displayPanel(bibLink, panel, bibCoords);
break;
}
case "dock": {
if (panel) {
panel.style.left = "";
Expand Down Expand Up @@ -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";
}
Expand Down
8 changes: 8 additions & 0 deletions src/styles/dfn-panel.css.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ dfn {
cursor: pointer;
}

a.bibref {
cursor: pointer;
}

.dfn-panel {
position: absolute;
z-index: 35;
Expand Down Expand Up @@ -128,4 +132,8 @@ dfn {
max-height: 30vh;
overflow: auto;
}

.dfn-panel .biblio-ref {
margin-top: 0.5em;
}
`;
152 changes: 152 additions & 0 deletions tests/spec/core/dfn-panel-spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
<section id="conformance">
<p id="p-testref">[[TestRef]]</p>
<p id="p-aliasref">[[AliasRef]]</p>
</section>
`;
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 = `<p id="bad">[[bad-ref-no-entry]]</p>`;
const ops = makeStandardOps(null, body);
const doc = await makeRSDoc(ops);
const panel = doc.querySelector('[id^="biblio-panel-for-"]');
Comment thread
marcoscaceres marked this conversation as resolved.
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");
});
});