Skip to main content

collapse.js

Path: js/collapse.js | Language: JavaScript | Lines: ~1,150

Collapsible content sections with disclosure buttons and auto-expansion


Overview

collapse.js implements a collapsible section system that hides long content behind disclosure buttons. It supports two collapse types: block collapses (full-width sections with chevron-based disclosure buttons) and inline collapses (in-text collapsed spans with bracket-style buttons at both ends).

The module handles three core concerns: (1) preparing raw .collapse elements into fully functional collapse blocks with appropriate wrappers, buttons, and visual indicators; (2) managing expand/collapse state transitions including nested collapse handling; (3) auto-expanding collapse blocks when the user navigates to contained elements via URL hash or browser find (Ctrl+F).

A notable feature is the "iceberg indicator"—a visual progress bar showing how much content remains hidden. This is calculated lazily (only when scrolled into view) based on either pixel heights (block collapses) or text length (inline collapses). The module also implements hover-to-expand behavior on desktop, with safeguards to prevent accidental expansion during scrolling.


Public API

expandCollapseBlocksToReveal(node, options) → boolean

Recursively expands all collapse blocks containing the given node. Returns true if any expansion occurred.

expandCollapseBlocksToReveal(document.getElementById("footnote-5"), {
fireStateChangedEvent: true // default
});

Called by: revealElement(), GW.selectionChangedRevealElement Calls: isWithinCollapsedBlock(), isCollapsed(), toggleCollapseBlockState()


collapseCollapseBlock(collapseBlock, options)

Collapses the specified block and all nested collapse blocks within it.

collapseCollapseBlock(collapseBlock, {
fireStateChangedEvent: true // default
});

Called by: GW.contentInjectHandlers.collapseExpandedCollapseBlocks Calls: isCollapsed(), toggleCollapseBlockState()


isCollapsed(collapseBlock) → boolean|undefined

Returns true if collapsed, false if expanded, undefined if not yet initialized.

// State is tracked via classes:
collapseBlock.classList.contains("expanded") // → false
collapseBlock.classList.contains("expanded-not") // → true

Called by: Most functions in this module Calls: None


isWithinCollapsedBlock(element) → boolean

Returns true if the element is inside any currently-collapsed block (checking ancestors recursively).

Called by: expandCollapseBlocksToReveal(), GW.selectionChangedRevealElement Calls: isCollapsed()


containsBlockChildren(element) → boolean

Returns true if the element's immediate children include any block-level elements (DIV, P, UL, LI, SECTION, BLOCKQUOTE, FIGURE) or include-links.


newDisclosureButton(options) → Element

Constructs and returns a disclosure button.

newDisclosureButton({
block: true, // block collapse (chevron) vs inline (brackets)
start: true // for inline: start vs end button
});

revealElement(element, options) → boolean

Expands collapse blocks to reveal an element and optionally scrolls it into view.

revealElement(targetElement, {
scrollIntoView: true, // default
offset: 0 // scroll offset
});

Called by: revealTarget(), GW.selectionChangedRevealElement Calls: expandCollapseBlocksToReveal(), scrollElementIntoView()


revealTarget(options)

Expands collapses to reveal the element targeted by the current URL hash.

Called by: GW.revealTargetOnPageLayoutComplete, GW.revealTargetOnHashChange Calls: getHashTargetedElement(), revealElement()


expandLockCollapseBlock(collapseBlock)

Permanently expands a collapse block, removes its disclosure button, and strips all collapse-related classes/wrappers. Used when stripping collapses (e.g., for popups that shouldn't have collapsible content).

Called by: GW.contentInjectHandlers.expandLockCollapseBlocks Calls: None


Internal Architecture

State Model

Collapse state is managed via CSS classes:

ClassMeaning
.expandedBlock is expanded
.expanded-notBlock is collapsed
.collapse-blockBlock-level collapse
.collapse-inlineInline collapse
.has-abstractHas preview content
.has-abstract-collapse-onlyHas abstract-collapse-only
.no-abstractNo preview content
.expand-on-hoverDesktop hover behavior enabled
.bare-contentContent starts with p or list
.iceberg-notHide iceberg indicator

DOM Structure After Preparation

Block collapse:

<div class="collapse collapse-block expanded-not has-abstract">
<div class="abstract-collapse">Preview content...</div>
<button class="disclosure-button">
<span class="part top"><span class="label">Click to expand</span><span class="icon">...</span></span>
<span class="part bottom"><span class="label"></span><span class="icon">...</span></span>
<span class="collapse-iceberg-indicator">...</span>
</button>
<div class="collapse-content-wrapper">Hidden content...</div>
</div>

Inline collapse:

<span class="collapse collapse-inline expanded-not">
<span class="abstract-collapse-only"></span>
<span class="collapse-content-outer-wrapper">
<button class="disclosure-button start">[</button>
<span class="collapse-content-wrapper">Hidden text</span>
<button class="disclosure-button end">→]</button>
</span>
</span>

Content Load Handlers

The module registers several content handlers at different phases:

HandlerPhasePurpose
preprocessMismatchedCollapseHTMLrewriteFix malformed abstract/collapse nesting
prepareCollapseBlocksrewriteBuild complete collapse DOM structure
rectifySectionCollapseLayout>rewriteAdjust section heading heights
collapseExpandedCollapseBlocks<eventListenersRe-collapse blocks when content moves to new context
activateCollapseBlockDisclosureButtonseventListenersAdd click/hover handlers
expandLockCollapseBlocks<rewriteRemove collapses when stripCollapses is set

Key Patterns

Lazy Iceberg Indicator Calculation

The "iceberg indicator" shows what percentage of content is visible. It's expensive to calculate, so the module uses an IntersectionObserver to defer calculation until the collapse is scrolled into view:

function setCollapseBlockIcebergIndicatorUpdateWhenNeeded(collapseBlock) {
lazyLoadObserver(() => {
updateCollapseBlockIcebergIndicatorIfNeeded(collapseBlock);
}, collapseBlock, {
root: scrollContainerOf(collapseBlock),
rootMargin: "100%"
});
}

The calculation differs by collapse type:

  • Block with abstract: abstractHeight / (abstractHeight + contentHeight)
  • Block without abstract: visibleHeight / totalContentHeight
  • Inline: abstractLength / (abstractLength + contentLength) (character count)

Hover Events with Scroll Guard

Desktop collapses expand on hover, but this would be annoying during scrolling. The module disables hover events during scroll, re-enabling on mouse movement:

// Disable during scroll
addScrollListener((event) => {
GW.collapse.hoverEventsActive = false;
}, { name: "disableCollapseHoverEventsOnScrollListener" });

// Re-enable on mouse move
addMousemoveListener((event) => {
GW.collapse.hoverEventsActive = true;
}, { name: "enableCollapseHoverEventsOnMousemoveListener" });

The module also adds scroll listeners to popup scroll views via a Popups.popupDidSpawn event handler.

Hover expansion has a 1-second delay and can be cancelled by mouseleave or mousedown.

Click Counter for UI Hints

The module tracks how many times users manually expand collapse blocks. After a threshold (3 on desktop, 6 on mobile), disclosure button labels are hidden:

GW.collapse = {
alwaysShowCollapseInteractionHints: (getSavedCount("clicked-to-expand-collapse-block-count") < (GW.isMobile() ? 6 : 3)),
showCollapseInteractionHintsOnHover: (getSavedCount(...) < 6)
};

XOR State Coupling

Collapse blocks can be linked so that expanding one collapses another:

if (collapseBlock.dataset.collapseXorStateWithSelector > "") {
let otherCollapseElement = collapseBlock.getRootNode()
.querySelector(collapseBlock.dataset.collapseXorStateWithSelector);
toggleCollapseBlockState(otherCollapseElement, expanding ? false : true);
}

Configuration

GW.collapse Namespace

PropertyTypeDefaultDescription
alwaysShowCollapseInteractionHintsbooleanvariesAlways show "Click to expand" labels
showCollapseInteractionHintsOnHoverbooleanvariesShow labels on hover
hoverEventsEnabledboolean!GW.isMobile()Master hover toggle
hoverEventsActiveboolean!GW.isMobile()Current hover state (toggled by scroll/mousemove)

Collapse Classes (Author-Applied)

ClassEffect
.collapseMarks element as collapsible
.start-expandedStart in expanded state
.collapse-smallMinimal inline collapse (no abstract)
.bare-content-notPrevent "bare content" styling
data-collapse-xor-state-with-selectorCSS selector for XOR-linked collapse

Integration Points

Events Fired

EventSourcePayload
Collapse.collapseStateDidChangeState toggle, reveal, expand-lock{ source, collapseBlock }
Collapse.targetDidRevealHash target revealed(none)

Events Listened

EventHandlerPurpose
Popups.popupDidSpawnAdd scroll listener to popupDisable hover in popup scroll
GW.hashHandlingSetupDidCompleterevealTargetOnPageLayoutCompleteReveal hash target on load
GW.hashDidChangerevealTargetOnHashChangeReveal hash target on navigation
Rewrite.contentDidChangeupdateIcebergIndicatorsOnContentChangeWithinCollapseBlocksRecalc iceberg on content mutation
selectionchange (document)selectionChangedRevealElementExpand for Ctrl+F hits

Shared State

  • GW.collapse: Module configuration namespace
  • getSavedCount / incrementSavedCount: Persistent click counter (localStorage)
  • GW.TOC.getMainTOC(): Used for layout compensation when TOC floats

Transclusion Integration

The module handles collapse-inducing include-links specially:

if (collapseBlock.tagName == "A")
collapseBlock = wrapElement(wrapElement(collapseBlock, "p", wrapOptions), "div", wrapOptions);

It also checks Transclude.isIncludeLink() when determining block-level children.


See Also

  • rewrite.js - DOM transformation pipeline that processes collapses
  • initial.js - Core framework that loads collapse module
  • layout.js - Block layout system that responds to collapse state changes
  • sidenotes.js - Sidenotes reposition when collapses expand/contract
  • content.js - Content loading phases that trigger collapse preparation
  • transclude.js - Include-links that may create collapses
  • popups.js - Popups interact with collapse hover events