fix(toc): add trailing bottom spacing when the TOC scrolls

The desktop TOC is its own scroll container (overflow-y:auto + max-height),
but unlike article content — which gets trailing space via
.page-content > article::after — it had nothing below the last link. A long
TOC scrolled to the bottom left the final link flush against the edge.

Add .toc::after as a block pseudo-element inside the desktop media query,
using the same technique (and the same Firefox-clips-padding-bottom reason)
as the article. Shared component, so public (about/charte/licence) and admin
(#admin-toc) both get it. Verified with Playwright: 0px -> ~60px gap;
mobile unaffected (TOC is not a scroll container there).
This commit is contained in:
Pontoporeia
2026-09-18 16:26:49 +02:00
parent aa72aa5bc5
commit 626970770f
4 changed files with 212 additions and 54 deletions
+9
View File
@@ -136,6 +136,15 @@
padding-right: var(--space-3xs);
}
/* Trailing bottom spacing when the TOC scrolls (long lists). Pseudo-element
(real content in the scroll area) rather than the container's
padding-bottom, which Firefox clips in this nested flex layout. */
.toc::after {
content: "";
display: block;
height: var(--space-xl);
}
.toc summary {
pointer-events: none;
}
+51 -10
View File
@@ -8,8 +8,11 @@ class MarkdownHelper
/**
* Extract h1–h3 headings from raw markdown content as TOC items.
*
* Each heading gets an anchor id matching CommonMark's default slugification
* (lowercase, spaces → hyphens, punctuation stripped).
* The heading is parsed with CommonMark so inline markup (`*em*`,
* `**strong**`, `` `code` ``, `[links]()`) is resolved instead of leaking
* raw markdown into the TOC. The anchor id is slugified from the parsed
* inline text, which is what CommonMark uses to build the rendered heading
* ids — so TOC links and heading anchors always match.
*
* @return array<int, array{label: string, href: string, level: int}>
*/
@@ -17,19 +20,57 @@ class MarkdownHelper
{
$items = [];
$lines = explode("\n", $content);
// Use CommonMark's own SlugNormalizer so TOC links match the rendered heading IDs exactly.
$normalizer = new \League\CommonMark\Normalizer\SlugNormalizer();
foreach ($lines as $line) {
if (preg_match('/^(#{1,3})\s+(.+)$/', $line, $m)) {
$level = strlen($m[1]);
$label = trim($m[2]);
$id = $normalizer->normalize($label);
$items[] = ['label' => $label, 'href' => '#' . $id, 'level' => $level];
if (!preg_match('/^(#{1,3})\s+(.+)$/', $line, $m)) {
continue;
}
$headingText = self::parseInlineText(trim($m[2]));
$items[] = [
'label' => $headingText,
'href' => '#' . $normalizer->normalize($headingText),
'level' => strlen($m[1]),
];
}
return $items;
}
/**
* Parse markdown and return its plain-text content.
*
* Walks the inline AST and concatenates the text nodes, so links keep their
* label (`[lien](url)` → "lien") and markup is dropped.
*/
private static function parseInlineText(string $markdown): string
{
static $parser = null;
if ($parser === null) {
$environment = new \League\CommonMark\Environment\Environment([
'html_input' => 'strip',
]);
$environment->addExtension(new \League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension());
$parser = new \League\CommonMark\Parser\MarkdownParser($environment);
}
$document = $parser->parse($markdown);
$walker = $document->walker();
$text = '';
while ($event = $walker->next()) {
if (!$event->isEntering()) {
continue;
}
$node = $event->getNode();
if ($node instanceof \League\CommonMark\Node\Inline\Text
|| $node instanceof \League\CommonMark\Extension\CommonMark\Node\Inline\Code
) {
$text .= $node->getLiteral();
}
}
return trim($text);
}
}