diff --git a/packages/site_shared/lib/components/blog/blog_toc.dart b/packages/site_shared/lib/components/blog/blog_toc.dart new file mode 100644 index 0000000000..8dcdc73751 --- /dev/null +++ b/packages/site_shared/lib/components/blog/blog_toc.dart @@ -0,0 +1,54 @@ +// Copyright (c) 2026, the Dart project authors. All rights reserved. +// Copyright 2026, the Flutter authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that +// can be found in the LICENSE file. + +import 'package:jaspr/dom.dart'; +import 'package:jaspr/jaspr.dart'; +import 'package:jaspr_content/jaspr_content.dart'; + +import '../common/material_icon.dart'; + +/// The minimum number of top-level entries an article needs to have +/// before a table of contents is worth showing. +const _minEntriesForToc = 2; + +/// Displays an automatically generated table of contents for the current +/// page, derived from the `h2` and `h3` headings found in its rendered +/// content. +/// +/// Requires [TableOfContentsExtension] to be applied to the page so that +/// its headings are available as a [TableOfContents] under the page's `toc` +/// data. +/// +/// Renders nothing if the page doesn't contain enough headings to warrant +/// a table of contents. +final class BlogTableOfContents extends StatelessComponent { + const BlogTableOfContents({super.key}); + + @override + Component build(BuildContext context) { + final toc = context.page.data['toc']; + if (toc is! TableOfContents || toc.entries.length < _minEntriesForToc) { + return const .empty(); + } + + return nav( + classes: 'toc', + attributes: {'aria-label': 'Table of contents'}, + [ + details( + [ + const summary( + [ + MaterialIcon('chevron_right'), + .text('In this article'), + ], + ), + div(classes: 'toc-list', [toc.build()]), + ], + ), + ], + ); + } +} diff --git a/sites/www/lib/main.server.dart b/sites/www/lib/main.server.dart index ad3623362a..9e59710697 100644 --- a/sites/www/lib/main.server.dart +++ b/sites/www/lib/main.server.dart @@ -97,6 +97,7 @@ void main() { ], extensions: [ ShowcaseStoryExtension(), + const TableOfContentsExtension(), const TableWrapperExtension(), const MermaidProcessor(), const CodeBlockProcessor(defaultTitle: 'Runnable Flutter example'), diff --git a/sites/www/lib/src/layouts/blog_layout.dart b/sites/www/lib/src/layouts/blog_layout.dart index 394a613907..706da0cbb6 100644 --- a/sites/www/lib/src/layouts/blog_layout.dart +++ b/sites/www/lib/src/layouts/blog_layout.dart @@ -9,6 +9,7 @@ import 'package:jaspr/jaspr.dart'; import 'package:jaspr_content/jaspr_content.dart'; import 'package:site_shared/blog.dart'; import 'package:site_shared/components/blog/blog_next_posts.dart'; +import 'package:site_shared/components/blog/blog_toc.dart'; import 'package:site_shared/components/blog/post_info.dart'; import 'package:site_shared/components/common/breadcrumbs.dart'; import 'package:site_shared/components/common/client/back_to_top_button.dart'; @@ -96,6 +97,7 @@ class BlogLayout extends DefaultLayout { ]), ]), if (post != null) PostInfo(post: post, url: page.url), + if (post != null) const BlogTableOfContents(), child, if (isPost) BlogNextPosts(currentPage: page, category: pageCategory), diff --git a/sites/www/lib/styles/core/_vars.scss b/sites/www/lib/styles/core/_vars.scss index d9160e9f5f..857ecbfdf6 100644 --- a/sites/www/lib/styles/core/_vars.scss +++ b/sites/www/lib/styles/core/_vars.scss @@ -45,6 +45,12 @@ --ui-header-height: calc(var(--ui-header-navigation-height) + var(--ui-header-event-banner-height)); --ui-logo-width: 126px; + // Used by shared content styles (such as the heading `scroll-margin-top` + // rules in `_content.scss`) to keep anchor-linked headings clear of the + // fixed header. This site has no subheader, unlike docs.flutter.dev. + --site-header-height: var(--ui-header-height); + --site-subheader-height: 0px; + --font-size-heading-1: 46px; --font-size-heading-2: 34px; --font-size-heading-3: 24px; diff --git a/sites/www/lib/styles/pages/_blog_page.scss b/sites/www/lib/styles/pages/_blog_page.scss index 019b6124c4..8d4875cfbc 100644 --- a/sites/www/lib/styles/pages/_blog_page.scss +++ b/sites/www/lib/styles/pages/_blog_page.scss @@ -1,4 +1,4 @@ -@use 'package:site_shared/_sass/base/mixins'; +@use "package:site_shared/_sass/base/mixins"; .blog main { display: flex; @@ -27,12 +27,68 @@ } } - > .content { padding: 1.5rem; color: var(--site-base-fgColor); } + .toc { + margin: 1.5rem 0; + background: var(--site-inset-bgColor); + border: 1px solid var(--site-inset-borderColor); + border-radius: var(--ui-border-radius-sm); + + details { + padding: 1rem 1.25rem; + } + + summary { + display: flex; + align-items: center; + gap: 0.25rem; + font-family: var(--site-ui-fontFamily); + font-weight: var(--site-fontWeight-bold); + cursor: pointer; + list-style: none; + user-select: none; + + &::-webkit-details-marker { + display: none; + } + + .material-symbols { + transition: transform 0.15s var(--ui-anim-func); + } + } + + details[open] summary .material-symbols { + transform: rotate(90deg); + } + + .toc-list { + margin: 0.75rem 0 0; + + ul { + margin: 0; + padding-left: 1rem; + list-style: none; + border-left: 1px solid var(--site-outline-variant); + } + + li { + margin: 0.5rem 0; + } + + a { + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + } + } + @media (min-width: 576px) { > .content { padding: 2rem; @@ -73,7 +129,7 @@ color: var(--site-base-fgColor-alt); user-select: none; - >span { + > span { font-size: 1.75rem; } @@ -126,7 +182,9 @@ cursor: pointer; opacity: 0; transform: translateY(0.5rem); - transition: opacity 0.2s var(--ui-anim-func), transform 0.2s var(--ui-anim-func); + transition: + opacity 0.2s var(--ui-anim-func), + transform 0.2s var(--ui-anim-func); pointer-events: none; visibility: hidden;