Skip to content

Commit 49f4c90

Browse files
committed
feat(api): collapsible package groups with remembered state and a roll animation
Each Tier 2 group on /api/ is now a <details>, open by default, and a visitor's collapsed groups come back on their next visit. "Older versions" gets the same roll, having just gained the same hover. Four things about this were not obvious and are worth recording. THE HEADING STAYS MARKDOWN. [[toc]] is built by markdown-it-table-of-contents from the markdown AST, not from the DOM, so an <h3> written by hand inside the <details> would have vanished from the "On this page" sidebar with nothing failing. Keeping `### {{ group.group }}` on its own line with blank lines around it works because markdown-it ends an HTML block at a blank line — verified across four arrangements before committing to one, and verified again in the built page: the same four ids (`data-%26-events` and friends) and the same four sidebar entries. THE HEADING GOES IN THE SUMMARY, not the body, so a collapsed group is still a visible anchor target when its sidebar link is clicked. THE CLASS IS .api-pkg-group, NOT .api-group. `.api-group` was already taken by the symbol-kind labels in the API reference sidebar ("Classes", "Enumerations"), whose rule is uppercase, letter-spaced, muted 12.5px — and it inherited into every heading, package name and blurb in the section. THE STATE SCRIPT IS INLINE AND PARSER-BLOCKING. It must run after the <details> exist and before they paint; site.js is `defer`, and this page is long enough that first paint lands well before parsing ends, so a remembered-closed group would flash open. Two further details: Chrome fires `toggle` when a <details open> is INSERTED, not only when its state changes, so a naive handler wrote to storage on every page load including a first visit — now it skips a write that would not change the value and never creates the key just to record "nothing is closed". And `toggle` does not bubble, so the listener is in the capture phase. The roll is driven by hand because <details> cannot animate: the body is not rendered while closed, so there is nothing to transition from, and the browser flips the state on click leaving nothing to animate out. Opening sets `open` first and grows the body from zero; closing animates down and only then clears `open`. Height, padding and margin all animate, or the spacing would remain at zero height. Respects prefers-reduced-motion by standing aside and letting <details> behave natively. Also corrects a wrong assumption I had committed a rule for: a closed <details> does NOT need help hiding a body that sets `display: flex`. Forcing `display: flex !important` on a closed group leaves it still unpainted and still not hit-testable — it keeps a layout box, so getBoundingClientRect reports a height, and that measurement is what makes it look visible when it is not. All four site checks pass; sitemap-api.xml unchanged at 274 URLs.
1 parent bbe8fe3 commit 49f4c90

3 files changed

Lines changed: 196 additions & 2 deletions

File tree

src/_data/apiPackages.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,18 @@ const { shippedGroups, TAGS } = require('../../scripts/lib/api-packages');
1616
// and is the real repository, so do not "correct" this to follow npm.
1717
const repoOf = (name) => `https://github.com/imqueue/${name}`;
1818

19+
// Stable key for remembering a group's collapsed state in localStorage, derived
20+
// from the name rather than configured so a new group needs nothing extra. Renaming
21+
// a group changes its id and so resets that group to open, which is the right
22+
// default for what is effectively a new section.
23+
const idOf = (group) =>
24+
group.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
25+
1926
module.exports = () => ({
20-
// [{ group, packages: [{ name, scoped, url, repo, blurb, tags: [{ label, exclusive }] }] }]
27+
// [{ group, id, packages: [{ name, scoped, url, repo, blurb, tags: [{ label, exclusive }] }] }]
2128
groups: shippedGroups().map(({ group, packages }) => ({
2229
group,
30+
id: idOf(group),
2331
packages: packages.map(p => ({
2432
name: p.name,
2533
scoped: `@imqueue/${p.name}`,

src/_shared/css/prose.css

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,10 +86,47 @@
8686
.prose .api-older summary::-webkit-details-marker { display: none; }
8787
.prose .api-older summary::before { content: "▸"; color: var(--muted); margin-right: 8px; display: inline-block; }
8888
.prose .api-older[open] summary::before { content: "▾"; }
89+
/* See the note on .api-pkg-group: `open` is held for the length of a roll-up, so the
90+
marker is flipped from the roll state instead of from `open`. */
91+
.prose .api-older[data-rolling="close"] summary::before { content: "▸"; }
8992
.prose .api-older-body { padding: 0 18px 16px; display: flex; flex-direction: column; gap: 10px; font-family: var(--font-mono); font-size: 14.5px; }
9093
.prose .api-older-body a { color: var(--accent); margin-right: 10px; }
9194
.prose .api-older-pkg { color: var(--muted); margin-right: 10px; }
9295

96+
/* Each Tier 2 group is collapsible, open by default, state remembered per visitor.
97+
Deliberately NOT boxed like .api-older: the group heading is a structural heading
98+
in the page outline, and giving it a surface and a border would make four sections
99+
read as four cards and flatten the hierarchy the headings exist to create. So the
100+
only affordances are the marker, the pointer and the accent on hover — the same
101+
accent token as everything else here.
102+
103+
NOT `.api-group`: that name is already taken, by the symbol-kind labels in the API
104+
reference sidebar ("Classes", "Enumerations") a few rules below. Reusing it made
105+
every heading, name and blurb inside these groups inherit that rule's uppercase,
106+
letter-spacing and muted 12.5px. Hence `.api-pkg-group`, matching the
107+
.api-pkg-list / .api-pkg-link family it belongs to. */
108+
.prose .api-pkg-group { margin: 0; }
109+
/* The h3's own `margin: 30px 0 10px` moves to the summary, so the vertical rhythm
110+
is byte-for-byte what it was before these became <details> — a flex row would not
111+
collapse the heading's block margins with its neighbours, and zeroing them without
112+
restating them here would jam each group against the list above it. */
113+
.prose .api-pkg-group > summary { cursor: pointer; list-style: none; display: flex; align-items: baseline; gap: 8px; margin: 30px 0 10px; }
114+
.prose .api-pkg-group > summary::-webkit-details-marker { display: none; }
115+
.prose .api-pkg-group > summary::before { content: "▸"; color: var(--muted); font-family: var(--font-mono); font-size: 14px; transition: color .15s ease; }
116+
.prose .api-pkg-group[open] > summary::before { content: "▾"; }
117+
/* `open` stays set for the length of a roll-up so the list has something to animate
118+
out, which would otherwise hold the marker at ▾ until the movement finished. This
119+
flips it on the first frame instead, where the click expects it. */
120+
.prose .api-pkg-group[data-rolling="close"] > summary::before { content: "▸"; }
121+
.prose .api-pkg-group > summary h3 { margin: 0; }
122+
.prose .api-pkg-group > summary:hover h3, .prose .api-pkg-group > summary:hover::before { color: var(--accent); }
123+
/* No rule is needed to hide the list while the group is closed. A closed <details>
124+
skips painting and hit-testing its non-summary children even when they carry an
125+
explicit `display` — checked by forcing `display: flex !important` on a closed
126+
group, where the list still did not paint and elementFromPoint still returned the
127+
container behind it. It does keep a layout box, so getBoundingClientRect reports a
128+
height for it; that measurement is what makes it look visible when it is not. */
129+
93130
/* Tier 2 groups on /api/ — one flat list per group, tags as chips on the entry.
94131
Deliberately lighter than .api-ref-card: the two spine cards stay the visual
95132
anchor of the section, and 14 cards of equal weight would flatten that. */

src/org/api/index.md

Lines changed: 150 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,18 +114,167 @@ Browse the complete generated reference for the latest release — every class,
114114
115115
Only `shipped` packages appear. A `planned` package has no pages yet, so
116116
listing it would ship a 404 and fail check:links. There are no archived
117-
versions to list for any of these: Tier 2 is `latest` only. -->
117+
versions to list for any of these: Tier 2 is `latest` only.
118+
119+
Each group is a <details>, open by default, and its state is remembered per
120+
visitor. Two details of the markup are load-bearing:
121+
122+
* The `### heading` stays MARKDOWN, on its own line with blank lines around
123+
it. markdown-it ends an HTML block at a blank line, so the heading is still
124+
parsed as a heading and still reaches [[toc]] — which is generated from the
125+
markdown AST, not the DOM, so an HTML <h3> written by hand would silently
126+
vanish from the "On this page" sidebar.
127+
* The heading goes in the <summary>, not the body, so a collapsed group is
128+
still a visible anchor target when its sidebar link is clicked. -->
118129
{%- for group in apiPackages.groups %}
130+
<details class="api-pkg-group" open data-api-group="{{ group.id }}">
131+
<summary>
119132

120133
### {{ group.group }}
121134

135+
</summary>
136+
122137
<ul class="api-pkg-list">
123138
{%- for pkg in group.packages %}
124139
<li class="api-pkg"><a class="api-pkg-link" href="{{ pkg.url }}"><span class="api-pkg-name">{{ pkg.scoped | escape }}</span>{% if pkg.tags.size > 0 %} <span class="api-pkg-tags">{% for tag in pkg.tags %}<span class="topic-chip topic-chip--flat"{% if tag.exclusive %} title="Exclusive — pick at most one package carrying this tag"{% endif %}>{{ tag.label | escape }}</span>{% endfor %}</span>{% endif %}{% if apiVersions[pkg.name].latest %}<span class="api-ref-ver api-pkg-ver">v{{ apiVersions[pkg.name].latest }}</span>{% endif %}<span class="api-pkg-blurb">{{ pkg.blurb | escape }}</span></a></li>
125140
{%- endfor %}
126141
</ul>
142+
143+
</details>
127144
{%- endfor %}
128145

146+
<!-- Collapsed-group state, inline and parser-blocking ON PURPOSE.
147+
It has to run after these <details> exist and before they paint. site.js is
148+
`defer`, so it runs only after the whole document is parsed — and this page is
149+
long enough that first paint happens well before that, which would flash every
150+
remembered-closed group open. head.html's no-FOUC theme script has the mirror
151+
problem: it runs early but the elements do not exist yet.
152+
Storing the CLOSED ids (not the open ones) is what makes a group added by a
153+
later wave default to open without touching stored state. -->
154+
<script>
155+
(function () {
156+
var KEY = 'imqueue-api-groups';
157+
var groups = [].slice.call(document.querySelectorAll('.api-pkg-group[data-api-group]'));
158+
var stored;
159+
160+
try { stored = JSON.parse(localStorage.getItem(KEY)); } catch (e) { stored = null; }
161+
var shut = stored || [];
162+
163+
groups.forEach(function (el) {
164+
if (shut.indexOf(el.getAttribute('data-api-group')) !== -1) {
165+
el.removeAttribute('open');
166+
}
167+
});
168+
169+
// Derived from the DOM on every change rather than patched incrementally: that
170+
// cannot produce a duplicate or a stale id, and it does not care in which order
171+
// the events arrive — which matters, because they are not all user-driven.
172+
function state() {
173+
return JSON.stringify(groups.filter(function (el) { return !el.open; })
174+
.map(function (el) { return el.getAttribute('data-api-group'); }));
175+
}
176+
177+
// `toggle` does not bubble, so listen in the capture phase — that still reaches a
178+
// non-bubbling event on the way down, and needs one listener rather than one per
179+
// group.
180+
//
181+
// Chrome fires `toggle` when a <details open> is INSERTED, not only when its state
182+
// changes, so simply loading this page queues one event per group before anyone has
183+
// clicked anything. Writing unconditionally therefore wrote to storage on every
184+
// visit, including a first visit — hence the two guards below: skip when the value
185+
// has not changed, and never create the key just to record "nothing is closed".
186+
document.addEventListener('toggle', function (e) {
187+
var el = e.target;
188+
189+
if (!el.classList || !el.classList.contains('api-pkg-group')) { return; }
190+
191+
try {
192+
var next = state();
193+
194+
if (next === '[]' && localStorage.getItem(KEY) === null) { return; }
195+
if (next !== localStorage.getItem(KEY)) { localStorage.setItem(KEY, next); }
196+
} catch (e2) {}
197+
}, true);
198+
199+
// ---- roll down / roll up ------------------------------------------------
200+
// <details> cannot be animated natively: the body is not rendered at all while
201+
// the element is closed, so there is no height to transition from or to. The
202+
// browser also flips the state the instant the summary is clicked, which leaves
203+
// nothing on screen to animate out.
204+
//
205+
// So the open flag is driven by hand. Opening sets it first and animates the
206+
// list up from zero; closing animates down and only then clears it, which is
207+
// what keeps the content visible for the length of the roll-up.
208+
var DURATION = 200;
209+
var reduced = window.matchMedia
210+
? window.matchMedia('(prefers-reduced-motion: reduce)')
211+
: { matches: false };
212+
213+
// Both disclosures on this page roll: the package groups and the "Older versions"
214+
// list above them. Each needs its body named, because the element that grows is the
215+
// one whose height is animated — a <details> has no single "content" child to find
216+
// generically.
217+
var ROLLS = [
218+
{ group: '.api-pkg-group', body: '.api-pkg-list' },
219+
{ group: '.api-older', body: '.api-older-body' },
220+
];
221+
222+
function roll(el, body) {
223+
var summary = el.querySelector('summary');
224+
225+
if (!summary || !body || !body.animate) { return; }
226+
227+
summary.addEventListener('click', function (e) {
228+
// Honour the OS setting by doing nothing and letting <details> behave
229+
// natively — the state still changes, just without the movement.
230+
if (reduced.matches) { return; }
231+
// Ignore a click that lands mid-roll rather than queueing or reversing it.
232+
if (el.hasAttribute('data-rolling')) { e.preventDefault(); return; }
233+
234+
e.preventDefault();
235+
var opening = !el.open;
236+
237+
// The value, not just the presence, so the marker can flip at the start of a
238+
// roll-up instead of waiting for `open` to clear at the end of it.
239+
el.setAttribute('data-rolling', opening ? 'open' : 'close');
240+
241+
if (opening) { el.open = true; }
242+
243+
// Measured while open, so the target is the real laid-out height rather than
244+
// a guess. Padding and margin ride along with it, or a body that has either
245+
// would leave its spacing behind at zero height.
246+
var box = getComputedStyle(body);
247+
var end = {
248+
height: body.scrollHeight + 'px',
249+
marginBottom: box.marginBottom,
250+
paddingTop: box.paddingTop,
251+
paddingBottom: box.paddingBottom,
252+
opacity: 1,
253+
};
254+
var start = { height: '0px', marginBottom: '0px', paddingTop: '0px', paddingBottom: '0px', opacity: 0 };
255+
var frames = opening ? [start, end] : [end, start];
256+
257+
body.style.overflow = 'hidden';
258+
259+
var anim = body.animate(frames, { duration: DURATION, easing: 'ease' });
260+
261+
anim.onfinish = anim.oncancel = function () {
262+
body.style.overflow = '';
263+
// Only now, so the body was on screen for the whole roll-up.
264+
if (!opening) { el.open = false; }
265+
el.removeAttribute('data-rolling');
266+
};
267+
});
268+
}
269+
270+
ROLLS.forEach(function (kind) {
271+
[].forEach.call(document.querySelectorAll(kind.group), function (el) {
272+
roll(el, el.querySelector(kind.body));
273+
});
274+
});
275+
})();
276+
</script>
277+
129278
{% include "api/intro.md" %}
130279
{% include "api/rpc.md" %}
131280
{% include "api/mq.md" %}

0 commit comments

Comments
 (0)