Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/extensions/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -1021,9 +1021,26 @@ $tocExtension = new TableOfContentsExtension(
cssClass: 'toc', // CSS class for nav element
position: 'top', // 'top', 'bottom', or null for manual placement
separator: '<hr>', // Optional HTML between TOC and content
collapsible: true, // Wrap in a <details>/<summary> disclosure
summary: 'Contents', // Disclosure label (default 'Table of Contents')
open: false, // Start expanded when true (default collapsed)
);
```

**Collapsible:** with `collapsible: true` the TOC is wrapped in a
`<details>`/`<summary>` disclosure (closed unless `open: true`), with the heading
list directly inside it:

```html
<details class="toc">
<summary>Table of Contents</summary>
<ul> ... </ul>
</details>
```

When `collapsible` is off (the default) the output is the unchanged
`<nav class="toc">`.

**Auto-insertion:**

```php
Expand Down
24 changes: 22 additions & 2 deletions src/Extension/TableOfContentsExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@
* listType: 'ol', // Use ordered list
* position: 'top', // Auto-insert at 'top', 'bottom', or null for manual
* separator: "<hr>\n", // Optional separator between TOC and content
* collapsible: true, // Wrap in a <details>/<summary> disclosure
* summary: 'Contents', // Disclosure label (default 'Table of Contents')
* open: false, // Start expanded when true (default collapsed)
* );
* ```
*/
Expand All @@ -58,6 +61,10 @@ class TableOfContentsExtension implements ResettableExtensionInterface
* @param string $cssClass CSS class for the TOC container
* @param string|null $position Auto-insert position: 'top', 'bottom', or null for manual placement
* @param string $separator HTML separator between TOC and content (when position is set)
* @param bool $collapsible Wrap the TOC in a `<details>`/`<summary>` disclosure so it can be
* collapsed. Off by default; when off the output is the unchanged `<nav class="toc">`.
* @param string $summary Summary label for the disclosure (only used when $collapsible is true).
* @param bool $open Render the disclosure expanded by default (only used when $collapsible is true).
*/
public function __construct(
protected int $minLevel = 1,
Expand All @@ -66,6 +73,9 @@ public function __construct(
protected string $cssClass = 'toc',
protected ?string $position = null,
protected string $separator = '',
protected bool $collapsible = false,
protected string $summary = 'Table of Contents',
protected bool $open = false,
) {
}

Expand Down Expand Up @@ -169,9 +179,19 @@ protected function renderTocHtml(array $headings): string
return '';
}

$html = '<nav class="' . StringUtil::escapeHtml($this->cssClass) . '">' . "\n";
if (!$this->collapsible) {
return '<nav class="' . StringUtil::escapeHtml($this->cssClass) . '">' . "\n"
. $this->renderTocList($headings)
. '</nav>' . "\n";
}

// Collapsible: the heading list sits directly inside a <details>
// disclosure so it can be toggled, closed by default unless $open.
$open = $this->open ? ' open' : '';
$html = '<details class="' . StringUtil::escapeHtml($this->cssClass) . '"' . $open . '>' . "\n";
$html .= '<summary>' . StringUtil::escapeHtml($this->summary) . '</summary>' . "\n";
$html .= $this->renderTocList($headings);
$html .= '</nav>' . "\n";
$html .= '</details>' . "\n";

return $html;
}
Expand Down
60 changes: 60 additions & 0 deletions tests/TestCase/Extension/TableOfContentsExtensionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -430,4 +430,64 @@ public function testSkippedHeadingLevelsFollowedByShallowerHeadingReuseSameNeste
$this->assertStringNotContainsString("</ul>\n\n<ul>", $html);
$this->assertStringContainsString('<li><a href="#Three">Three</a></li>' . "\n<li><a href=\"#Two\">Two</a></li>", $html);
}

public function testNonCollapsibleTocKeepsPlainNav(): void
{
$converter = new DjotConverter();
$tocExtension = new TableOfContentsExtension();
$converter->addExtension($tocExtension);

$converter->convert("# One\n\n## Two\n");
$html = $tocExtension->getTocHtml();

$this->assertStringStartsWith('<nav class="toc">', $html);
$this->assertStringNotContainsString('<details', $html);
}

public function testCollapsibleWrapsTocInClosedDisclosure(): void
{
$converter = new DjotConverter();
$tocExtension = new TableOfContentsExtension(collapsible: true);
$converter->addExtension($tocExtension);

$converter->convert("# One\n\n## Two\n");
$html = $tocExtension->getTocHtml();

// Closed by default (no `open`), list sits directly inside <details>.
$this->assertStringStartsWith(
'<details class="toc">' . "\n" . '<summary>Table of Contents</summary>' . "\n" . '<ul>',
$html,
);
$this->assertStringEndsWith('</ul>' . "\n" . '</details>' . "\n", $html);
$this->assertStringNotContainsString('<nav', $html);
$this->assertStringNotContainsString('<details class="toc" open', $html);
}

public function testCollapsibleHonorsOpenAndCustomSummary(): void
{
$converter = new DjotConverter();
$tocExtension = new TableOfContentsExtension(collapsible: true, summary: 'Contents', open: true);
$converter->addExtension($tocExtension);

$converter->convert("# One\n");
$html = $tocExtension->getTocHtml();

$this->assertStringStartsWith(
'<details class="toc" open>' . "\n" . '<summary>Contents</summary>',
$html,
);
}

public function testCollapsibleSummaryIsHtmlEscaped(): void
{
$converter = new DjotConverter();
$tocExtension = new TableOfContentsExtension(collapsible: true, summary: 'A & <b>B</b>');
$converter->addExtension($tocExtension);

$converter->convert("# One\n");
$html = $tocExtension->getTocHtml();

$this->assertStringContainsString('<summary>A &amp; &lt;b&gt;B&lt;/b&gt;</summary>', $html);
$this->assertStringNotContainsString('<b>B</b>', $html);
}
}
Loading