Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/ccxp-lite.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,10 @@ declare global {
};
}

interface CcxpLiteMainSections {
enhanceCollapsibleSections: (targetDocument: Document) => void;
}

interface CcxpLiteNamespace {
shared?: CcxpLiteShared;
sharedConstants?: CcxpLiteSharedConstants;
Expand All @@ -669,6 +673,7 @@ declare global {
sidebarData?: CcxpLiteSidebarData;
sidebarUi?: CcxpLiteSidebarUi;
sidebarRuntime?: CcxpLiteSidebarRuntime;
mainSections?: CcxpLiteMainSections;
menuData?: Record<string, unknown>;
menuFavorites?: Record<string, unknown>;
menuRuntime?: Record<string, unknown>;
Expand Down
1 change: 1 addition & 0 deletions src/main/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,7 @@
mainBody.classList.add(TOKENS.mainClass);
mainBody.style.setProperty("background-image", "none", "important");
mainBody.style.setProperty("background-color", "var(--ccxp-lite-bg)", "important");
globalThis.CCXP_LITE?.mainSections?.enhanceCollapsibleSections(mainDocument);
const { sidebarState } = globalThis.CCXP_LITE ?? {};
if (sidebarState) {
const state = sidebarState.getSidebarUiState(mainDocument);
Expand Down
128 changes: 128 additions & 0 deletions src/main/content.css
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,134 @@ html[data-ccxp-lite-scope="main"]
background-color: transparent !important;
}

/* CCXP draws its listings with ridged table borders and cellspacing, so a
listing reads as a grid of boxes rather than as rows of data. Collapse that
frame down to horizontal rules.

Only a table carrying CCXP's listing header rows is treated this way. The
same markup builds forms and page layout elsewhere, and those tables lean on
the original frame to stay legible. Widths are left alone for the same
reason. */
html[data-ccxp-lite-scope="main"] table:has(tr.class1, tr.class2) {
max-width: 100%;
border-collapse: collapse;
border: 0;
border-spacing: 0;
}

/* Listings carry a dozen columns, so the cells stay tight and wrap instead of
pushing the page into a sideways scroll. */
html[data-ccxp-lite-scope="main"] table:has(tr.class1, tr.class2) td,
html[data-ccxp-lite-scope="main"] table:has(tr.class1, tr.class2) th {
padding: var(--ccxp-lite-spacing-xs);
border: 0;
border-bottom: 1px solid var(--ccxp-lite-border);
vertical-align: top;
}

/* .class2 marks the column header row of a listing. */
html[data-ccxp-lite-scope="main"] tr.class2 td,
html[data-ccxp-lite-scope="main"] tr.class2 th {
border-bottom: 2px solid var(--ccxp-lite-border);
color: var(--ccxp-lite-type-body-strong-color) !important;
font-weight: var(--ccxp-lite-font-weight-strong);
vertical-align: bottom;
}

html[data-ccxp-lite-scope="main"]
table:has(tr.class1, tr.class2)
tr:nth-child(even):not(.class1, .class2)
td {
background-color: var(--ccxp-lite-surface-muted) !important;
}

html[data-ccxp-lite-scope="main"]
table:has(tr.class1, tr.class2)
tr:hover:not(.class1, .class2)
td {
background-color: var(--ccxp-lite-primary-hover-surface) !important;
}

/* CCXP paints section header rows with .class1 (white text on a blue band) in
its own stylesheet, so neither the colour attribute nor the inline style
rules below can reach them. Clearing the band above would leave white text on
the white page, hiding headings such as the 向度 groupings on the general
education listing, so restore a readable band here. */
html[data-ccxp-lite-scope="main"] tr.class1,
html[data-ccxp-lite-scope="main"] tr.class1 td,
html[data-ccxp-lite-scope="main"] tr.class1 th,
html[data-ccxp-lite-scope="main"] td.class1,
html[data-ccxp-lite-scope="main"] th.class1 {
background-color: var(--ccxp-lite-surface-muted) !important;
color: var(--ccxp-lite-type-body-strong-color) !important;
font-weight: var(--ccxp-lite-font-weight-strong);
}

html[data-ccxp-lite-scope="main"] [data-ccxp-lite-section-hidden="true"] {
display: none !important;
}

/* A row that became a toggle reads as a heading with a rule under it rather
than as a filled band, so the band restored above is dropped again here. */
html[data-ccxp-lite-scope="main"] tr[data-ccxp-lite-section-toggle="true"],
html[data-ccxp-lite-scope="main"] tr[data-ccxp-lite-section-toggle="true"] td {
background-color: transparent !important;
}

html[data-ccxp-lite-scope="main"] tr[data-ccxp-lite-section-toggle="true"] td {
padding: 0;
border-bottom: 1px solid var(--ccxp-lite-border);
}

html[data-ccxp-lite-scope="main"] .ccxp-lite-section-toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--ccxp-lite-spacing-md);
width: 100%;
padding: var(--ccxp-lite-spacing-md) var(--ccxp-lite-spacing-xs);
border: 0;
background: none;
color: var(--ccxp-lite-type-body-strong-color);
font: var(--ccxp-lite-type-body-strong);
text-align: start;
cursor: pointer;
}

html[data-ccxp-lite-scope="main"] .ccxp-lite-section-toggle:hover {
color: var(--ccxp-lite-primary);
}

html[data-ccxp-lite-scope="main"] .ccxp-lite-section-toggle:focus-visible {
outline: 2px solid var(--ccxp-lite-primary-focus-border);
outline-offset: 2px;
}

/* Chevron drawn from two borders so it needs no icon font. */
html[data-ccxp-lite-scope="main"] .ccxp-lite-section-toggle-marker {
flex: none;
width: 9px;
height: 9px;
margin-block-start: -4px;
border-right: 2px solid currentcolor;
border-bottom: 2px solid currentcolor;
transform: rotate(45deg);
transition: transform 150ms ease;
}

html[data-ccxp-lite-scope="main"]
.ccxp-lite-section-toggle[aria-expanded="true"]
.ccxp-lite-section-toggle-marker {
margin-block-start: 4px;
transform: rotate(-135deg);
}

@media (prefers-reduced-motion: reduce) {
html[data-ccxp-lite-scope="main"] .ccxp-lite-section-toggle-marker {
transition: none;
}
}

html[data-ccxp-lite-scope="main"] body,
html[data-ccxp-lite-scope="main"] table,
html[data-ccxp-lite-scope="main"] tbody,
Expand Down
142 changes: 142 additions & 0 deletions src/main/sections.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
(function registerCcxpLiteMainSections(globalScope: typeof globalThis) {
const { CCXP_LITE: namespace } = globalScope;
if (!namespace) {
return;
}
const TOGGLE_CLASS = "ccxp-lite-section-toggle";
const MARKER_CLASS = "ccxp-lite-section-toggle-marker";
const LABEL_CLASS = "ccxp-lite-section-toggle-label";
const ENHANCED_ATTRIBUTE = "data-ccxp-lite-section-toggle";
const HIDDEN_ATTRIBUTE = "data-ccxp-lite-section-hidden";

// CCXP marks section bands with .class1, but it reuses that class for column header rows as well.
// Only a row holding a single spanning cell introduces a section, so the structure rather than
// the class decides what is collapsible.
function isSectionHeaderRow(row: HTMLTableRowElement) {
if (!row.classList.contains("class1")) {
return false;
}
if (row.cells.length !== 1) {
return false;
}
return row.cells[0].hasAttribute("colspan");
}

function collectSectionRows(
rows: readonly HTMLTableRowElement[],
headerIndex: number,
): readonly HTMLTableRowElement[] {
const sectionRows: HTMLTableRowElement[] = [];
for (let index = headerIndex + 1; index < rows.length; index++) {
const row = rows[index];
if (isSectionHeaderRow(row)) {
break;
}
sectionRows.push(row);
}
return sectionRows;
}

// The markup indents the band with leading <br> tags; keeping them would open the toggle with a
// blank first line.
function trimLeadingBreaks(cell: HTMLTableCellElement) {
let node = cell.firstChild;
while (node) {
const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as Element).tagName === "BR";
const isBlankText =
node.nodeType === Node.TEXT_NODE && (node.textContent ?? "").trim() === "";
if (!isBreak && !isBlankText) {
break;
}
const next = node.nextSibling;
node.remove();
node = next;
}
}

function setSectionExpanded(
button: HTMLButtonElement,
sectionRows: readonly HTMLTableRowElement[],
expanded: boolean,
) {
button.setAttribute("aria-expanded", expanded ? "true" : "false");
for (const row of sectionRows) {
if (expanded) {
row.removeAttribute(HIDDEN_ATTRIBUTE);
} else {
row.setAttribute(HIDDEN_ATTRIBUTE, "true");
}
}
}

function enhanceSectionHeader(
targetDocument: Document,
header: HTMLTableRowElement,
sectionRows: readonly HTMLTableRowElement[],
) {
// A band with nothing under it only adds noise, so it is hidden rather than turned into a
// toggle that opens onto nothing.
if (sectionRows.length === 0) {
header.setAttribute(HIDDEN_ATTRIBUTE, "true");
header.setAttribute(ENHANCED_ATTRIBUTE, "true");
return;
}
const cell = header.cells[0];
header.setAttribute(ENHANCED_ATTRIBUTE, "true");
trimLeadingBreaks(cell);

const button = targetDocument.createElement("button");
button.type = "button";
button.className = TOGGLE_CLASS;

const marker = targetDocument.createElement("span");
marker.className = MARKER_CLASS;
marker.setAttribute("aria-hidden", "true");

// Moving the original nodes keeps the heading text as the button's accessible name, so no
// separate label string is needed.
const label = targetDocument.createElement("span");
label.className = LABEL_CLASS;
while (cell.firstChild) {
label.append(cell.firstChild);
}

button.append(label, marker);
cell.append(button);

setSectionExpanded(button, sectionRows, false);
button.addEventListener("click", () => {
setSectionExpanded(button, sectionRows, button.getAttribute("aria-expanded") !== "true");
});
}

// Some listings end with a heading whose table was rendered without a single row, which leaves a
// stray caption over nothing. The heading only goes when it sits directly on top of that table.
function hideEmptyTable(table: HTMLTableElement) {
if (table.rows.length > 0) {
return;
}
table.setAttribute(HIDDEN_ATTRIBUTE, "true");
const heading = table.previousElementSibling;
if (heading?.tagName === "P") {
heading.setAttribute(HIDDEN_ATTRIBUTE, "true");
}
}

function enhanceCollapsibleSections(targetDocument: Document) {
for (const table of targetDocument.querySelectorAll("table")) {
hideEmptyTable(table);
const rows = [...table.rows];
for (const [index, header] of rows.entries()) {
if (!isSectionHeaderRow(header) || header.hasAttribute(ENHANCED_ATTRIBUTE)) {
continue;
}
enhanceSectionHeader(targetDocument, header, collectSectionRows(rows, index));
}
}
}

namespace.mainSections = {
enhanceCollapsibleSections,
};
})(globalThis);
1 change: 1 addition & 0 deletions src/manifest.base.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
"login/pipeline/rewrite.js",
"login/pipeline/style.js",
"login/pipeline/bootstrap.js",
"main/sections.js",
"main/bootstrap.js"
]
},
Expand Down
Loading