Skip to main content

popins.js

Path: js/popins.js | Language: JavaScript | Lines: ~666

Mobile-friendly inline content expansion - the touch-first alternative to floating popups.


Overview

Popins provide a mobile-optimized alternative to the desktop popup system. Rather than hovering to spawn floating windows (awkward on touch devices), users tap a link and a popin slides into place directly in the document flow, pushing page content down. This approach is natural for touch interfaces where users expect content to expand inline.

The module mirrors the Popups API surface closely—both implement the "popframe" abstraction—allowing extracts.js to use either system interchangeably depending on device capabilities. Where popups use hover events and absolute positioning, popins use click events and relative positioning within the DOM tree.

Key design decisions: popins use the same Shadow DOM isolation as popups (preventing style leakage), stack vertically when spawned from within other popins (creating a "drill-down" navigation feel), and automatically scroll the page to keep the active popin visible. Tapping the semi-transparent backdrop dismisses the popin.


Public API

Popins.setup()

Initializes the popin system. Cleans up any remnants, registers the Escape key handler, and fires Popins.setupDidComplete.

Called by: Page initialization (doWhenPageLoaded) Calls: cleanup()


Popins.addTarget(target, prepareFunction)

Registers an element as a popin trigger. The element will spawn a popin on click (tap).

Popins.addTarget(linkElement, (popin) => {
// Fill popin.body with content
// Set popin.titleBarContents = [buttons...]
return popin; // or null to cancel
});
  • Binds onclick event to targetClicked
  • Stores prepareFunction as target.preparePopin
  • Adds class spawns-popin to target

Called by: extracts.js Calls: Event binding


Popins.removeTarget(target)

Unregisters an element as a popin trigger. Removes any existing popin, clears event handlers, and removes the spawns-popin class.

Called by: extracts.js


Popins.setPopFrameContent(popin, content)

Replaces the popin body content. Returns true on success, false if content is null.

Popins.setPopFrameContent(popin, contentElement);

Called by: extracts.js, extracts-content.js


Popins.containingPopFrame(element)

Returns the popin containing the given element, or null. Handles both regular DOM children and Shadow DOM content via the .shadow-body reference.

let popin = Popins.containingPopFrame(event.target);

Called by: Many internal methods, extracts.js


Popins.allSpawnedPopins()

Returns the array of all spawned popins (the spawnedPopins stack).

Called by: extracts.js, internal cleanup


Popins.scrollElementIntoViewInPopFrame(element, alwaysRevealTopEdge)

Scrolls a popin's internal scroll view to make the given element visible. Used for deep-linking within popin content.

Called by: extracts.js


State Methods

MethodPurpose
popFrameStateLoading(popin)Returns true if .loading class present
popFrameStateLoadingFailed(popin)Returns true if .loading-failed class present
setPopFrameStateLoading(popin)Adds .loading, removes .loading-failed
setPopFrameStateLoadingFailed(popin)Adds .loading-failed, removes .loading
clearPopFrameState(popin)Removes both loading classes, clears provisional height

Called by: extracts.js during content loading lifecycle


Internal Architecture

Popin DOM Structure

div.popin.popframe                 (backdrop via ::before/::after)
div.popframe-title-bar (optional, positioned absolutely above)
span.popin-stack-counter ("2", "3", etc. for nested popins)
button.close-button
[other title bar contents]
div.popframe-scroll-view
div.popframe-content-view
#shadow-root (open)
style (CSS reset: all: initial)
div.popframe-body.popin-body.shadow-body
[content goes here]
div.popframe-loading-spinner-view
div.popframe-loading-failed-message-view
div.popin-footer-bar (optional, "Open in new tab" link)

Stack Model

Unlike popups which use a tree model for nested windows, popins use a simpler vertical stack. When you tap a link inside popin A, popin B is inserted before A in the DOM, pushing A down:

// DOM order (visual: top to bottom)
containingDocument.popin.parentElement.insertBefore(popin, containingDocument.popin);

// spawnedPopins array order (most recent first)
Popins.spawnedPopins.unshift(popin);

The stack counter in the title bar shows nesting depth ("2" for second level, etc.).

Insertion Point Algorithm

Popins must be inserted carefully to avoid breaking page layout. The module avoids inserting inside:

  • .table-wrapper (would break table scrolling)
let cannotInsertIntoTheseThingsSelector = [
".table-wrapper"
].join(", ");
do {
insertionTarget = insertWhere ?? target;
insertWhere = insertionTarget.parentElement;
} while (insertWhere.closest(cannotInsertIntoTheseThingsSelector));

Ancestor Marking

When a popin spawns, all its ancestors up to <MAIN> or <ARTICLE> get the .popin-ancestor class. This enables CSS tricks like adding bottom margin to push the rest of the page content down:

.markdownBody .popin-ancestor {
position: relative !important;
z-index: 100 !important;
margin-bottom: 75vh; /* room for popin content */
}

Key Patterns

Backdrop Dismissal

Popins create a backdrop via CSS ::before and ::after pseudo-elements. The popin element itself is sized to fill available space, with its visual content positioned inside. Clicking the popin element (not its children) dismisses it:

popinClicked: (event) => {
// Only dismiss if click is directly on popin backdrop
if (event.target.classList.contains("popin") == false)
return;

event.stopPropagation();
Popins.removePopin(event.target);
}

Scroll State Preservation

When pushing a popin down the stack, its scroll position is saved:

containingDocument.popin.lastScrollTop = containingDocument.popin.scrollView.scrollTop;

When popping (removing the top popin), the scroll position is restored:

if (popinBelow) {
popinBelow.scrollView.scrollTop = popinBelow.lastScrollTop;
}

Window Scroll Compensation

Popins track how much they've scrolled the main window and restore it on dismissal:

// On inject: record scroll offset
popin.dataset.windowScrollOffset = windowScrollOffsetForThisPopin + scrollWindowBy;

// On remove: restore window scroll
window.scrollBy(0, -1 * parseInt(popin.dataset.windowScrollOffset ?? '0'));

Height Inheritance

When spawning a popin from within another popin while content is still loading (or loading failed), the new popin inherits the parent's height to avoid layout thrashing:

if (options.inheritInitialHeight && Popins.popFrameStateLoading(popin))
popin.style.height = Math.round(containingDocument.popin.clientHeight) + "px";

Configuration

PropertyDefaultPurpose
windowTopPopinPositionMargin0.0Top viewport margin for scroll positioning
windowBottomPopinPositionMargin0.0Bottom viewport margin for scroll positioning
rootDocumentdocumentRoot document for containingDocumentForTarget

CSS variables control visual appearance (in default.css):

VariablePurpose
--GW-popins-popin-background-colorPopin background
--GW-popins-popin-border-colorBorder color
--GW-popins-popin-border-widthBorder thickness
--GW-popins-popin-title-bar-background-colorTitle bar background
--GW-popins-popin-title-bar-heightTitle bar height
--GW-popins-popin-title-bar-button-colorButton icon color

Integration Points

Events Fired (via GW.notificationCenter)

EventPayloadWhen
Popins.didLoadModule loaded
Popins.setupDidCompletesetup() finished
Popins.cleanupDidCompletecleanup() finished
Popins.popinDidInject{ popin }Popin injected into DOM
Popins.popinWillDespawn{ popin }About to remove popin

Hooks for Targets

Targets can provide optional methods for customization:

target.preparePopin = (popin) => { ... };  // Required: fill content
target.adjustPopinWidth = (popin) => { ... }; // Optional: width adjustment

Shared State

  • Popins.spawnedPopins: Array of all spawned popins (stack order)
  • Popins.rootDocument: Base document reference

Title Bar Components

Factory methods for title bar elements (parallel to Popups):

MethodReturns
titleBarComponents.genericButton()Base button element
titleBarComponents.closeButton()Close button with "×" icon
titleBarComponents.optionsButton()Gear icon button (no default action)

Popins vs Popups

AspectPopupsPopins
TriggerHover (mouseenter)Click/tap (onclick)
PositionFloating, absoluteInline, in document flow
NestingTree (popup stack)Vertical stack
DismissalMouse leave + timerTap backdrop or Escape
FeaturesPin, tile, drag, resize, minimizeClose only
Target classspawns-popupspawns-popin
ContainerGlobal popup containerInserted near target
Use caseDesktop, mouse usersMobile, touch users

The simpler feature set (no windowing, no tiling) is intentional—popins are meant to be quick, ephemeral views rather than persistent windows.


See Also

  • popups.js - Desktop popup system with hover-based triggering and full windowing
  • extracts.js - Content coordinator that chooses popups vs popins based on device
  • extracts-options.js - User preference UI for enabling/disabling popins
  • extracts-content.js - Content type definitions for popin content
  • initial.js - GW namespace and notification center for popin events
  • content.js - Content loading system that provides popin data