explico 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +273 -1
- package/README.md +103 -8
- package/dist/compiler/astro.d.ts +9 -1
- package/dist/compiler/astro.js +182 -3
- package/dist/compiler/cli.d.ts +13 -0
- package/dist/compiler/cli.js +28 -4
- package/dist/compiler/collections.d.ts +3 -2
- package/dist/compiler/collections.js +28 -1
- package/dist/compiler/course-config.d.ts +58 -1
- package/dist/compiler/course-config.js +28 -1
- package/dist/compiler/exports/standalone-html.d.ts +34 -0
- package/dist/compiler/exports/standalone-html.js +803 -0
- package/dist/compiler/manifest.d.ts +90 -6
- package/dist/compiler/manifest.js +150 -17
- package/dist/compiler/resolution-report.d.ts +4 -0
- package/dist/reference/base-abbreviations.d.mts +30 -0
- package/dist/reference/base-abbreviations.mjs +60 -0
- package/dist/reference/base-operators.d.mts +15 -0
- package/dist/reference/base-operators.mjs +40 -0
- package/dist/reference/base-roman-labels.d.mts +35 -0
- package/dist/reference/base-roman-labels.mjs +43 -0
- package/dist/reference/consistency.d.ts +8 -2
- package/dist/reference/consistency.js +50 -3
- package/dist/reference/diagrams-validate.d.ts +21 -0
- package/dist/reference/diagrams-validate.js +13 -0
- package/dist/reference/equations.d.mts +10 -15
- package/dist/reference/equations.mjs +67 -14
- package/dist/reference/gate-abbreviations.d.ts +17 -0
- package/dist/reference/gate-abbreviations.js +64 -0
- package/dist/reference/gate-math.d.ts +28 -5
- package/dist/reference/gate-math.js +5 -2
- package/dist/reference/gate-prose-notation.d.ts +60 -0
- package/dist/reference/gate-prose-notation.js +125 -0
- package/dist/reference/keyed-float-validate.d.ts +5 -5
- package/dist/reference/keyed-jsx-floats.d.mts +7 -12
- package/dist/reference/keyed-jsx-floats.mjs +50 -25
- package/dist/reference/knowledge-checks-validate.d.ts +35 -0
- package/dist/reference/knowledge-checks-validate.js +78 -0
- package/dist/reference/knowledge-checks.d.mts +25 -0
- package/dist/reference/knowledge-checks.mjs +45 -0
- package/dist/reference/lab-math-scope.d.ts +9 -1
- package/dist/reference/lab-math-scope.js +32 -4
- package/dist/reference/math-glyphs.d.mts +30 -0
- package/dist/reference/math-glyphs.mjs +367 -0
- package/dist/reference/mdx-attributes.d.mts +13 -0
- package/dist/reference/mdx-attributes.mjs +38 -0
- package/dist/reference/mixed-content.d.ts +17 -0
- package/dist/reference/mixed-content.js +29 -0
- package/dist/reference/page-notation.d.ts +52 -0
- package/dist/reference/page-notation.js +171 -0
- package/dist/reference/raw-floats-validate.d.ts +18 -0
- package/dist/reference/raw-floats-validate.js +53 -0
- package/dist/reference/raw-floats.d.mts +18 -0
- package/dist/reference/raw-floats.mjs +129 -0
- package/dist/reference/rehype-heading-anchors.d.mts +1 -1
- package/dist/reference/rehype-heading-anchors.mjs +107 -1
- package/dist/reference/rehype-table-header-labels.d.mts +26 -0
- package/dist/reference/rehype-table-header-labels.mjs +152 -0
- package/dist/reference/remark-abbreviations.d.mts +7 -0
- package/dist/reference/remark-abbreviations.mjs +174 -0
- package/dist/reference/remark-memo.d.mts +16 -0
- package/dist/reference/remark-memo.mjs +339 -0
- package/dist/reference/remark-notation.d.mts +57 -9
- package/dist/reference/remark-notation.mjs +354 -42
- package/dist/reference/remark-short-note.d.mts +4 -0
- package/dist/reference/remark-short-note.mjs +237 -0
- package/dist/reference/types.d.ts +16 -1
- package/dist/reference/units-vocab.js +4 -0
- package/docs/VERSIONING.md +33 -4
- package/docs/authoring/README.md +8 -0
- package/docs/authoring/agents.md +17 -2
- package/docs/authoring/components.md +135 -0
- package/docs/authoring/migrating.md +92 -7
- package/docs/authoring/notation.md +560 -1
- package/docs/authoring/structure.md +120 -8
- package/docs/authoring/visualizations.md +36 -3
- package/package.json +5 -2
- package/src/compiler/astro.ts +208 -3
- package/src/compiler/cli.ts +29 -2
- package/src/compiler/collections.ts +45 -4
- package/src/compiler/course-config.ts +92 -0
- package/src/compiler/exports/standalone-html.ts +963 -0
- package/src/compiler/manifest.ts +268 -22
- package/src/components/HoverPanel.astro +45 -4
- package/src/components/abbreviation/AbbreviationGlossary.astro +70 -0
- package/src/components/abbreviation/AbbreviationLayer.astro +127 -0
- package/src/components/assessments/AssessmentRunner.tsx +52 -17
- package/src/components/assessments/AssessmentSet.astro +98 -14
- package/src/components/assessments/RenderedContent.tsx +55 -0
- package/src/components/assessments/assessment-randomization.ts +16 -0
- package/src/components/diagram/Diagram.astro +444 -83
- package/src/components/disclosure/hover-preview.ts +202 -0
- package/src/components/disclosure/persisted-disclosure.ts +66 -0
- package/src/components/examples/CompactExamples.astro +2 -0
- package/src/components/figure/Figure.astro +279 -10
- package/src/components/memo/Memo.astro +453 -0
- package/src/components/memo/ShortNote.astro +223 -0
- package/src/components/notation/NotationFormula.astro +18 -1
- package/src/components/notation/NotationLayer.astro +105 -8
- package/src/components/notation/render-notation-math.ts +39 -2
- package/src/components/notation/safe-math-to-react.ts +50 -0
- package/src/components/reference/FloatRefLayer.astro +233 -15
- package/src/components/starlight/CollapsibleSections.astro +461 -0
- package/src/components/starlight/DiagramEnhancer.astro +385 -0
- package/src/components/starlight/HeadingAnchorEnhancer.astro +5 -17
- package/src/components/starlight/KeyedFloatEnhancer.astro +81 -3
- package/src/components/starlight/LayoutHeader.astro +50 -0
- package/src/components/starlight/LayoutSocialIcons.astro +41 -2
- package/src/components/starlight/LessonExportActions.astro +436 -0
- package/src/components/starlight/LessonFooter.astro +27 -2
- package/src/components/starlight/section-parts.ts +31 -0
- package/src/components/table/Table.astro +293 -7
- package/src/components/table/TableOverflowExpander.astro +489 -0
- package/src/content-config.ts +20 -0
- package/src/reference/base-abbreviations.mjs +60 -0
- package/src/reference/base-operators.mjs +40 -0
- package/src/reference/base-roman-labels.mjs +43 -0
- package/src/reference/consistency.ts +73 -2
- package/src/reference/diagrams-validate.ts +40 -0
- package/src/reference/equations.mjs +67 -14
- package/src/reference/gate-abbreviations.ts +128 -0
- package/src/reference/gate-math.ts +27 -7
- package/src/reference/gate-prose-notation.ts +224 -0
- package/src/reference/keyed-float-validate.ts +5 -5
- package/src/reference/keyed-jsx-floats.mjs +50 -25
- package/src/reference/knowledge-checks-validate.ts +130 -0
- package/src/reference/knowledge-checks.mjs +45 -0
- package/src/reference/lab-math-scope.ts +45 -3
- package/src/reference/math-glyphs.mjs +367 -0
- package/src/reference/mdx-attributes.mjs +38 -0
- package/src/reference/mixed-content.ts +69 -0
- package/src/reference/page-notation.ts +252 -0
- package/src/reference/raw-floats-validate.ts +103 -0
- package/src/reference/raw-floats.mjs +129 -0
- package/src/reference/rehype-heading-anchors.mjs +107 -1
- package/src/reference/rehype-table-header-labels.mjs +152 -0
- package/src/reference/remark-abbreviations.mjs +174 -0
- package/src/reference/remark-memo.mjs +339 -0
- package/src/reference/remark-notation.mjs +354 -42
- package/src/reference/remark-short-note.mjs +237 -0
- package/src/reference/types.ts +16 -0
- package/src/reference/units-vocab.ts +4 -0
- package/src/styles/global.css +996 -50
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Collapsible/persistent sections (docs/plans/collapsible-disclosure.md Part
|
|
4
|
+
* 3) — a runtime wrap over Starlight's static heading markup, the same
|
|
5
|
+
* progressive-enhancement approach `HeadingAnchorEnhancer.astro` and
|
|
6
|
+
* `KeyedFloatEnhancer.astro` already use, since `rehype-heading-anchors.mjs`
|
|
7
|
+
* doesn't nest a heading's following content under it at build time.
|
|
8
|
+
*
|
|
9
|
+
* **Authoring**: `rehype-heading-anchors.mjs` resolves each heading's
|
|
10
|
+
* collapsibility — a `[collapsible]`/`[collapsed]`/`[no-collapsible]` marker,
|
|
11
|
+
* or a site-wide default from `astro.config.mjs` when a heading has none —
|
|
12
|
+
* down to two plain attributes on the wrapper: `data-collapsible="true"` and
|
|
13
|
+
* `data-default-open="true"|"false"`. This component only ever reads those
|
|
14
|
+
* two; it knows nothing about markers, ranks, or site config. A heading
|
|
15
|
+
* without `data-collapsible` is left completely alone, exactly as if this
|
|
16
|
+
* component didn't exist — wrapping *every* heading (the site default for
|
|
17
|
+
* both `sections` and `subsections`) is a valid choice, but not the
|
|
18
|
+
* hardcoded one: most sections have no reason to hide, and wrapping one
|
|
19
|
+
* cascades width/font-size side effects into ordinary content (see the CSS
|
|
20
|
+
* overrides below and their comments for two that actually happened during
|
|
21
|
+
* development).
|
|
22
|
+
*
|
|
23
|
+
* For every opted-in heading, this wraps
|
|
24
|
+
* `[.sl-heading-wrapper, ...body-until-next-heading-of-equal-or-higher-rank]`
|
|
25
|
+
* (the same boundary `sectionParts()` in `./section-parts.ts` already finds
|
|
26
|
+
* for the deep-link flash) in a `<details class="sl-section-collapsible">`,
|
|
27
|
+
* with the body itself further wrapped in one `.sl-section-body` div — see
|
|
28
|
+
* below for why that inner wrapper exists.
|
|
29
|
+
*
|
|
30
|
+
* **Only the bare heading moves into `<summary>` — not its `.sl-anchor-link`
|
|
31
|
+
* copy-link icon.** Moving the whole `.sl-heading-wrapper` (heading + anchor
|
|
32
|
+
* together, matching how `rehype-heading-anchors` emits them) into
|
|
33
|
+
* `<summary>` breaks axe's `no-focusable-content` check — a `<summary>` must
|
|
34
|
+
* have no focusable descendants, full stop, no matter how its click handling
|
|
35
|
+
* is patched up. `Figure.astro` hit the same problem for its own title bar
|
|
36
|
+
* and solved it by keeping the anchor a sibling of `<summary>` rather than a
|
|
37
|
+
* child; this mirrors that, reusing the existing `.sl-heading-wrapper` div as
|
|
38
|
+
* that sibling-holding container.
|
|
39
|
+
*
|
|
40
|
+
* Headings are processed deepest-rank first (h6 before h2, etc.) so that by
|
|
41
|
+
* the time a shallower heading's `sectionParts()` walk runs, any nested
|
|
42
|
+
* child section is already wrapped in its own `<details>` and reads as one
|
|
43
|
+
* ordinary sibling — `sectionParts()`'s `querySelector(HEADINGS)` still finds
|
|
44
|
+
* the nested heading regardless of depth, so the rank check that stops the
|
|
45
|
+
* walk keeps working unchanged.
|
|
46
|
+
*
|
|
47
|
+
* **Three states, not two.** Besides `closed`/`open`, a collapsed section
|
|
48
|
+
* has a third `peeking` state entered on hover/focus: the *real* body content
|
|
49
|
+
* (not a clone — a clone couldn't stay in sync with anything interactive or
|
|
50
|
+
* later-hydrated inside it) renders at up to 10em tall, absolutely
|
|
51
|
+
* positioned so it overlays whatever comes next on the page without shifting
|
|
52
|
+
* it — then, if the reader clicks while peeking, it grows in place from
|
|
53
|
+
* that exact height to its full height, switching to normal flow so
|
|
54
|
+
* following content is pushed down in sync with the growth instead of
|
|
55
|
+
* jumping once the animation ends. A native closed `<details>`'s
|
|
56
|
+
* non-summary children can't be rendered at all (the HTML spec skips them
|
|
57
|
+
* outright, a rule no CSS can override — confirmed the hard way while
|
|
58
|
+
* building the print handling below), so `peeking` still has to force
|
|
59
|
+
* `details.open = true` to render anything; only `data-state` distinguishes
|
|
60
|
+
* a real expand from a peek. `wirePersistedDetails`'s own `toggle`-event
|
|
61
|
+
* listener is only fed a synthetic event on a genuine `open`/`closed`
|
|
62
|
+
* commit — never for a peek — so hovering never persists anything.
|
|
63
|
+
*
|
|
64
|
+
* Persistence goes through `../disclosure/persisted-disclosure` (keyed by
|
|
65
|
+
* heading `id` + page path, same convention as `Figure.astro`'s
|
|
66
|
+
* `figure:<id>` keys). This does *not* use `../disclosure/hover-preview`'s
|
|
67
|
+
* `attachHoverPeek` — that utility clones content into a single shared
|
|
68
|
+
* floating panel positioned near the trigger, built for a "reference card"
|
|
69
|
+
* feel; the peek here needs to be the real, in-place content, sized and
|
|
70
|
+
* positioned relative to its own section, animatable into a real expand.
|
|
71
|
+
*
|
|
72
|
+
* Wired only on lesson pages (see `LessonFooter.astro`), same gating as
|
|
73
|
+
* `HeadingAnchorEnhancer.astro`/`KeyedFloatEnhancer.astro` — and placed
|
|
74
|
+
* *before* `HeadingAnchorEnhancer` in that file so this component's own
|
|
75
|
+
* hashchange listener (which expands any collapsed ancestor section) runs
|
|
76
|
+
* before `HeadingAnchorEnhancer`'s flash/scroll listener, registered after
|
|
77
|
+
* it in document order.
|
|
78
|
+
*/
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
<script>
|
|
82
|
+
import { HEADINGS, sectionParts } from './section-parts';
|
|
83
|
+
import {
|
|
84
|
+
wirePersistedDetails,
|
|
85
|
+
savePersistedOpenState,
|
|
86
|
+
} from '../disclosure/persisted-disclosure';
|
|
87
|
+
|
|
88
|
+
(() => {
|
|
89
|
+
const root = document.querySelector('.sl-markdown-content');
|
|
90
|
+
if (!root) return;
|
|
91
|
+
|
|
92
|
+
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
93
|
+
|
|
94
|
+
type State = 'closed' | 'peeking' | 'open';
|
|
95
|
+
|
|
96
|
+
// A collapsed section's peek caps at 10em, measured in the body's own
|
|
97
|
+
// font-size (reset to normal body text below, not the heading's larger
|
|
98
|
+
// size — see the CSS comment on `.sl-section-collapsible.sl-section-body`).
|
|
99
|
+
const peekMaxPx = (body: HTMLElement): number =>
|
|
100
|
+
(parseFloat(getComputedStyle(body).fontSize) || 16) * 10;
|
|
101
|
+
|
|
102
|
+
// A rapid state change (e.g. collapsing a section, then hovering it again
|
|
103
|
+
// before the collapse animation finishes) starts a *second* `growTo` on
|
|
104
|
+
// the same `body` while the first is still in flight. Without tracking
|
|
105
|
+
// this, the first animation's stale `finish()` still fires later (its
|
|
106
|
+
// `transitionend`/timeout don't know a newer call superseded them) and
|
|
107
|
+
// stomps on state a second, unrelated animation already moved on from —
|
|
108
|
+
// observed as a peek overlay that never closes after a collapse. Each
|
|
109
|
+
// `body` gets at most one live "cancel the previous run" callback.
|
|
110
|
+
const supersede = new WeakMap<HTMLElement, () => void>();
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Animate `body`'s `max-height` from its current rendered height to
|
|
114
|
+
* `toPx`, calling `after` once the transition (or, with reduced motion,
|
|
115
|
+
* an immediate jump) finishes — unless a *later* `growTo` call on the
|
|
116
|
+
* same `body` supersedes this one first, in which case `after` never
|
|
117
|
+
* runs at all. Caller is responsible for anything that must happen
|
|
118
|
+
* *before* the animation starts (e.g. flipping `data-state` so
|
|
119
|
+
* `position` is already correct for the target state) — but not
|
|
120
|
+
* `overflow`: clipping to `hidden` for the animation's duration and
|
|
121
|
+
* clearing it again afterward (so the CSS class's own `overflow` value —
|
|
122
|
+
* `visible` for `open`, `auto` for `peeking` — takes back over) is
|
|
123
|
+
* entirely this function's job, every single call, so no call site can
|
|
124
|
+
* forget the cleanup half and leave a stray inline `overflow: hidden`
|
|
125
|
+
* behind (that exact bug once left a fully "open" section permanently
|
|
126
|
+
* clipped after a `peeking` animation elsewhere hadn't cleaned up after
|
|
127
|
+
* itself).
|
|
128
|
+
*/
|
|
129
|
+
const growTo = (body: HTMLElement, toPx: number, after: () => void) => {
|
|
130
|
+
supersede.get(body)?.(); // cancel whatever this body was mid-animating
|
|
131
|
+
supersede.delete(body);
|
|
132
|
+
|
|
133
|
+
if (reduceMotion.matches) {
|
|
134
|
+
body.style.maxHeight = `${toPx}px`;
|
|
135
|
+
body.style.removeProperty('overflow');
|
|
136
|
+
after();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const fromPx = body.getBoundingClientRect().height;
|
|
140
|
+
body.style.transition = 'none';
|
|
141
|
+
body.style.maxHeight = `${fromPx}px`;
|
|
142
|
+
body.style.overflow = 'hidden';
|
|
143
|
+
void body.offsetHeight; // force reflow so the jump above isn't animated
|
|
144
|
+
body.style.transition = '';
|
|
145
|
+
|
|
146
|
+
let done = false;
|
|
147
|
+
const cancel = () => {
|
|
148
|
+
if (done) return;
|
|
149
|
+
done = true;
|
|
150
|
+
window.clearTimeout(fallback);
|
|
151
|
+
body.removeEventListener('transitionend', onEnd);
|
|
152
|
+
body.style.removeProperty('overflow');
|
|
153
|
+
};
|
|
154
|
+
const finish = () => {
|
|
155
|
+
if (done) return;
|
|
156
|
+
cancel();
|
|
157
|
+
supersede.delete(body);
|
|
158
|
+
after();
|
|
159
|
+
};
|
|
160
|
+
const onEnd = (event: Event) => {
|
|
161
|
+
if (
|
|
162
|
+
event.target === body &&
|
|
163
|
+
(event as TransitionEvent).propertyName === 'max-height'
|
|
164
|
+
) {
|
|
165
|
+
finish();
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
body.addEventListener('transitionend', onEnd);
|
|
169
|
+
// `fromPx === toPx` (nothing actually changes) never fires
|
|
170
|
+
// `transitionend` — this safety net still resolves `after`.
|
|
171
|
+
const fallback = window.setTimeout(finish, 260);
|
|
172
|
+
supersede.set(body, cancel);
|
|
173
|
+
|
|
174
|
+
requestAnimationFrame(() => {
|
|
175
|
+
body.style.maxHeight = `${toPx}px`;
|
|
176
|
+
});
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
for (const heading of Array.from(
|
|
180
|
+
root.querySelectorAll<HTMLElement>(HEADINGS),
|
|
181
|
+
)
|
|
182
|
+
.filter(
|
|
183
|
+
(heading) =>
|
|
184
|
+
!!heading.id &&
|
|
185
|
+
heading.closest<HTMLElement>('.sl-heading-wrapper')?.dataset
|
|
186
|
+
.collapsible === 'true',
|
|
187
|
+
)
|
|
188
|
+
// Deepest rank first — see the module comment above.
|
|
189
|
+
.sort((a, b) => Number(b.tagName[1]) - Number(a.tagName[1]))) {
|
|
190
|
+
const parts = sectionParts(heading);
|
|
191
|
+
const bodyParts = parts.slice(1);
|
|
192
|
+
const wrapper = heading.closest<HTMLElement>('.sl-heading-wrapper')!;
|
|
193
|
+
|
|
194
|
+
const details = document.createElement('details');
|
|
195
|
+
details.className = 'sl-section-collapsible';
|
|
196
|
+
details.dataset.state = 'closed' satisfies State;
|
|
197
|
+
|
|
198
|
+
const summary = document.createElement('summary');
|
|
199
|
+
summary.className = 'sl-section-summary';
|
|
200
|
+
|
|
201
|
+
const body = document.createElement('div');
|
|
202
|
+
body.className = 'sl-section-body';
|
|
203
|
+
for (const part of bodyParts) body.append(part);
|
|
204
|
+
|
|
205
|
+
// Capture the anchor link's position *before* moving the heading —
|
|
206
|
+
// once `heading` is gone, `wrapper`'s only remaining child is it.
|
|
207
|
+
const anchorLink = wrapper.querySelector<HTMLElement>(
|
|
208
|
+
':scope > .sl-anchor-link',
|
|
209
|
+
);
|
|
210
|
+
summary.append(heading);
|
|
211
|
+
details.append(summary, body);
|
|
212
|
+
|
|
213
|
+
// The anchor link stays put as `details`'s sibling inside `wrapper`
|
|
214
|
+
// (see the module comment) — insert `details` right before it so
|
|
215
|
+
// heading/body content still comes first in DOM/tab order.
|
|
216
|
+
wrapper.classList.add('sl-section-titlebar');
|
|
217
|
+
if (anchorLink) wrapper.insertBefore(details, anchorLink);
|
|
218
|
+
else wrapper.append(details);
|
|
219
|
+
|
|
220
|
+
// `wirePersistedDetails` sets `details.open` *after* `details` is
|
|
221
|
+
// attached above, not before — deliberately never set `.open` on the
|
|
222
|
+
// still-detached element. At least one browser has been observed
|
|
223
|
+
// mis-computing a nested `<table>`'s intrinsic (auto-layout) width
|
|
224
|
+
// when a `<details>` is built pre-opened and inserted already-open,
|
|
225
|
+
// versus being opened only after attachment.
|
|
226
|
+
const defaultOpen = wrapper.dataset.defaultOpen !== 'false';
|
|
227
|
+
wirePersistedDetails(details, `section:${heading.id}`, defaultOpen);
|
|
228
|
+
details.dataset.state = (details.open ? 'open' : 'closed') satisfies State;
|
|
229
|
+
const persistKey = `section:${heading.id}`;
|
|
230
|
+
|
|
231
|
+
// A real (non-peek) commit: the only place a choice gets persisted —
|
|
232
|
+
// via `savePersistedOpenState`, called *synchronously* with the
|
|
233
|
+
// intended end state, not by waiting for the animation and reading
|
|
234
|
+
// back `details.open` (which has to stay `true` for the *entire*
|
|
235
|
+
// close animation — the browser refuses to render a closed
|
|
236
|
+
// `<details>`'s content at all, so an `open → closed` collapse can't
|
|
237
|
+
// flip the attribute to `false` until the shrink finishes — a
|
|
238
|
+
// deferred read-and-save would otherwise race a reader who navigates
|
|
239
|
+
// away mid-animation and save the wrong value). `data-state` flips to
|
|
240
|
+
// `next` *before* animating: for `closed`→`open` there's no prior
|
|
241
|
+
// `position: absolute` to transition out of, so timing doesn't
|
|
242
|
+
// matter; for `open`→`closed` the body stays `position: static`
|
|
243
|
+
// throughout (nothing to switch), so following content rises in step
|
|
244
|
+
// with the shrink. The one transition that *does* need mid-animation
|
|
245
|
+
// care — growing out of a peek — is `commitFromPeek` below, not this.
|
|
246
|
+
const commitOpenOrClosed = (next: 'open' | 'closed') => {
|
|
247
|
+
const goingOpen = next === 'open';
|
|
248
|
+
if (goingOpen) details.open = true; // render before animating/measuring
|
|
249
|
+
details.dataset.state = next satisfies State;
|
|
250
|
+
savePersistedOpenState(persistKey, goingOpen);
|
|
251
|
+
growTo(body, goingOpen ? body.scrollHeight : 0, () => {
|
|
252
|
+
details.open = goingOpen;
|
|
253
|
+
if (goingOpen) body.style.maxHeight = '';
|
|
254
|
+
});
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
// Promote an in-progress peek to a real, persisted expand — grown
|
|
258
|
+
// from the peek's *current* rendered height, not from zero. The
|
|
259
|
+
// `position: absolute` → `static` switch happens the instant before
|
|
260
|
+
// growing (with the peek height frozen as an explicit `max-height`
|
|
261
|
+
// first) so following content starts moving down from the exact
|
|
262
|
+
// moment the box re-enters normal flow, not after the growth
|
|
263
|
+
// animation completes.
|
|
264
|
+
const commitFromPeek = () => {
|
|
265
|
+
const peekPx = body.getBoundingClientRect().height;
|
|
266
|
+
body.style.transition = 'none';
|
|
267
|
+
body.style.maxHeight = `${peekPx}px`;
|
|
268
|
+
void body.offsetHeight; // reflow with the peek height frozen…
|
|
269
|
+
details.dataset.state = 'open' satisfies State; // …then re-enter flow at that same height
|
|
270
|
+
void body.offsetHeight; // reflow again before animating the grow
|
|
271
|
+
body.style.transition = '';
|
|
272
|
+
savePersistedOpenState(persistKey, true);
|
|
273
|
+
growTo(body, body.scrollHeight, () => {
|
|
274
|
+
details.open = true;
|
|
275
|
+
body.style.maxHeight = '';
|
|
276
|
+
});
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const enterPeek = () => {
|
|
280
|
+
if (details.dataset.state !== 'closed') return;
|
|
281
|
+
details.open = true; // render — see the module comment
|
|
282
|
+
details.dataset.state = 'peeking' satisfies State;
|
|
283
|
+
growTo(body, Math.min(body.scrollHeight, peekMaxPx(body)), () => {});
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const exitPeek = () => {
|
|
287
|
+
if (details.dataset.state !== 'peeking') return;
|
|
288
|
+
growTo(body, 0, () => {
|
|
289
|
+
details.dataset.state = 'closed' satisfies State;
|
|
290
|
+
details.open = false;
|
|
291
|
+
});
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
// Debounced leave, mirroring `FloatRefLayer.astro`'s `scheduleLeave`:
|
|
295
|
+
// a pointer move from the title into the peeked body itself must not
|
|
296
|
+
// close it. `:focus-visible`, not `:focus-within` — the same reasoning
|
|
297
|
+
// as `hover-preview.ts`'s own `scheduleLeave`: clicking `summary` to
|
|
298
|
+
// commit a peek (or to collapse an open section) leaves it focused as
|
|
299
|
+
// an ordinary side effect of the click, and that focus lingers
|
|
300
|
+
// (`:focus-within` stays true) long after the pointer has moved
|
|
301
|
+
// elsewhere — which, before this used `:focus-visible` instead, meant
|
|
302
|
+
// a peek entered right after such a click could never close on
|
|
303
|
+
// mouse-leave at all. `:focus-visible` is only true for
|
|
304
|
+
// keyboard/keyboard-equivalent focus, so a mouse click doesn't count,
|
|
305
|
+
// but a reader who actually tabbed here still keeps their peek open.
|
|
306
|
+
let leaveTimer: number | null = null;
|
|
307
|
+
const cancelLeave = () => {
|
|
308
|
+
if (leaveTimer !== null) {
|
|
309
|
+
window.cancelAnimationFrame(leaveTimer);
|
|
310
|
+
leaveTimer = null;
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
const scheduleLeave = () => {
|
|
314
|
+
cancelLeave();
|
|
315
|
+
leaveTimer = window.requestAnimationFrame(() => {
|
|
316
|
+
leaveTimer = null;
|
|
317
|
+
if (!details.matches(':hover') && !summary.matches(':focus-visible')) {
|
|
318
|
+
exitPeek();
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
summary.addEventListener('pointerenter', (event) => {
|
|
324
|
+
if (event.pointerType === 'touch') return;
|
|
325
|
+
cancelLeave();
|
|
326
|
+
enterPeek();
|
|
327
|
+
});
|
|
328
|
+
summary.addEventListener('focus', enterPeek);
|
|
329
|
+
details.addEventListener('pointerleave', (event) => {
|
|
330
|
+
if (event.pointerType === 'touch') return;
|
|
331
|
+
scheduleLeave();
|
|
332
|
+
});
|
|
333
|
+
details.addEventListener('focusout', scheduleLeave);
|
|
334
|
+
|
|
335
|
+
summary.addEventListener('click', (event) => {
|
|
336
|
+
// Fully own the open/close/peek state machine — never let the
|
|
337
|
+
// native toggle run (it only knows two states and would fight the
|
|
338
|
+
// grow-from-peek animation).
|
|
339
|
+
event.preventDefault();
|
|
340
|
+
cancelLeave();
|
|
341
|
+
if (details.dataset.state === 'peeking') commitFromPeek();
|
|
342
|
+
else if (details.dataset.state === 'open') commitOpenOrClosed('closed');
|
|
343
|
+
else commitOpenOrClosed('open');
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// A closed native `<details>` doesn't just hide its non-`<summary>`
|
|
348
|
+
// children visually — the HTML spec skips rendering them outright, so a
|
|
349
|
+
// heading nested inside one isn't scrollable, focusable, or even present
|
|
350
|
+
// in layout until that ancestor opens. `includeSelf` controls whether the
|
|
351
|
+
// *targeted* heading's own section is forced open too, or left exactly
|
|
352
|
+
// as the reader last set it:
|
|
353
|
+
// - a fresh navigation with a hash already in the URL (an external/
|
|
354
|
+
// bookmarked deep link — see the initial call below, made once at
|
|
355
|
+
// startup, not from a `hashchange` event) is a reader's only way in,
|
|
356
|
+
// so it must fully open the destination, not just its ancestors.
|
|
357
|
+
// - an in-page jump — clicking an "On this page" sidebar link, a
|
|
358
|
+
// cross-reference, or a section's own permalink icon — is a reader
|
|
359
|
+
// already on the page choosing to look *at* a title; forcing its
|
|
360
|
+
// content open too would show content they may have deliberately
|
|
361
|
+
// collapsed, without the animation or the following-content reflow a
|
|
362
|
+
// real expand gets everywhere else. Only its ancestors (structurally
|
|
363
|
+
// unavoidable) open; the target itself is just scrolled to and
|
|
364
|
+
// flashed by `HeadingAnchorEnhancer`, same as an always-visible
|
|
365
|
+
// heading gets.
|
|
366
|
+
const expandAncestors = (id: string, includeSelf: boolean) => {
|
|
367
|
+
if (!id) return;
|
|
368
|
+
const target = document.getElementById(id);
|
|
369
|
+
if (!target) return;
|
|
370
|
+
let details = target.closest<HTMLDetailsElement>(
|
|
371
|
+
'.sl-section-collapsible',
|
|
372
|
+
);
|
|
373
|
+
if (details && !includeSelf) {
|
|
374
|
+
details =
|
|
375
|
+
details.parentElement?.closest<HTMLDetailsElement>(
|
|
376
|
+
'.sl-section-collapsible',
|
|
377
|
+
) ?? null;
|
|
378
|
+
}
|
|
379
|
+
while (details) {
|
|
380
|
+
if (details.dataset.state !== 'open') {
|
|
381
|
+
details.dataset.state = 'open' satisfies State;
|
|
382
|
+
details.open = true;
|
|
383
|
+
details.dispatchEvent(new Event('toggle'));
|
|
384
|
+
}
|
|
385
|
+
details =
|
|
386
|
+
details.parentElement?.closest<HTMLDetailsElement>(
|
|
387
|
+
'.sl-section-collapsible',
|
|
388
|
+
) ?? null;
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
const currentHashId = (): string => {
|
|
393
|
+
try {
|
|
394
|
+
return decodeURIComponent(location.hash.slice(1));
|
|
395
|
+
} catch {
|
|
396
|
+
return location.hash.slice(1);
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
expandAncestors(currentHashId(), true);
|
|
401
|
+
window.addEventListener('hashchange', () => {
|
|
402
|
+
expandAncestors(currentHashId(), false);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
// Print must always show every section, regardless of on-screen
|
|
406
|
+
// collapse state. Unlike ordinary hidden content, a closed native
|
|
407
|
+
// `<details>`'s non-summary children aren't just styled hidden — the
|
|
408
|
+
// HTML spec has the browser skip rendering them outright, a rule no
|
|
409
|
+
// amount of author CSS (`display`, `content-visibility`, `!important`)
|
|
410
|
+
// can override. Only the `open` *attribute* controls it, so this has to
|
|
411
|
+
// force every section's real `open`/`data-state` for print in JS and
|
|
412
|
+
// restore afterward. `beforeprint`/`afterprint` cover an actual print
|
|
413
|
+
// dialog; `matchMedia('print')`'s `change` event additionally covers
|
|
414
|
+
// tools (headless browsers, some print-preview UIs) that flip the media
|
|
415
|
+
// type without firing those events.
|
|
416
|
+
let savedState: { open: boolean; state: string }[] | null = null;
|
|
417
|
+
|
|
418
|
+
const expandAllForPrint = () => {
|
|
419
|
+
if (document.querySelector('[data-explico-print-controller]')) return;
|
|
420
|
+
if (savedState) return; // already expanded for a print in progress
|
|
421
|
+
const all = Array.from(
|
|
422
|
+
document.querySelectorAll<HTMLDetailsElement>(
|
|
423
|
+
'.sl-section-collapsible',
|
|
424
|
+
),
|
|
425
|
+
);
|
|
426
|
+
savedState = all.map((d) => ({
|
|
427
|
+
open: d.open,
|
|
428
|
+
state: d.dataset.state ?? 'closed',
|
|
429
|
+
}));
|
|
430
|
+
for (const d of all) {
|
|
431
|
+
d.open = true;
|
|
432
|
+
d.dataset.state = 'open' satisfies State;
|
|
433
|
+
(d.querySelector<HTMLElement>(':scope > .sl-section-body') ??
|
|
434
|
+
undefined)?.style.removeProperty('max-height');
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
const restoreAfterPrint = () => {
|
|
439
|
+
if (!savedState) return;
|
|
440
|
+
const all = document.querySelectorAll<HTMLDetailsElement>(
|
|
441
|
+
'.sl-section-collapsible',
|
|
442
|
+
);
|
|
443
|
+
all.forEach((d, i) => {
|
|
444
|
+
const saved = savedState?.[i];
|
|
445
|
+
if (!saved) return;
|
|
446
|
+
d.open = saved.open;
|
|
447
|
+
d.dataset.state = saved.state;
|
|
448
|
+
d.querySelector<HTMLElement>(':scope > .sl-section-body')?.style
|
|
449
|
+
.removeProperty('max-height');
|
|
450
|
+
});
|
|
451
|
+
savedState = null;
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
window.addEventListener('beforeprint', expandAllForPrint);
|
|
455
|
+
window.addEventListener('afterprint', restoreAfterPrint);
|
|
456
|
+
window.matchMedia('print').addEventListener('change', (event) => {
|
|
457
|
+
if (event.matches) expandAllForPrint();
|
|
458
|
+
else restoreAfterPrint();
|
|
459
|
+
});
|
|
460
|
+
})();
|
|
461
|
+
</script>
|