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..5d4635f36c --- /dev/null +++ b/packages/site_shared/lib/components/blog/blog_toc.dart @@ -0,0 +1,56 @@ +// 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'] as TableOfContents?; + if (toc == null || toc.entries.length < _minEntriesForToc) { + return const Component.empty(); + } + + return nav( + classes: 'toc', + attributes: {'aria-label': 'Table of contents'}, + [ + Component.element( + tag: 'details', + children: [ + const Component.element( + tag: 'summary', + children: [ + MaterialIcon('chevron_right'), + Component.text('On this page'), + ], + ), + div(classes: 'toc-list', [toc.build()]), + ], + ), + ], + ); + } +} diff --git a/packages/site_shared/lib/components/common/client/back_to_top_button.dart b/packages/site_shared/lib/components/common/client/back_to_top_button.dart new file mode 100644 index 0000000000..90ba021a2a --- /dev/null +++ b/packages/site_shared/lib/components/common/client/back_to_top_button.dart @@ -0,0 +1,31 @@ +// 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:universal_web/web.dart' as web; + +import '../material_icon.dart'; + +/// A floating button that scrolls the page back to the top when clicked. +/// +/// Stays hidden until the page has been scrolled, via CSS rules that key +/// off of the `in-content` class already toggled on `` by the site's +/// scroll-spy logic. +@client +final class BackToTopButton extends StatelessComponent { + const BackToTopButton({super.key}); + + @override + Component build(BuildContext _) => button( + classes: 'back-to-top', + attributes: {'aria-label': 'Back to top'}, + events: { + 'click': (_) { + web.window.scrollTo(web.ScrollToOptions(behavior: 'smooth', top: 0)); + }, + }, + [const MaterialIcon('arrow_upward')], + ); +} diff --git a/sites/www/lib/main.client.options.dart b/sites/www/lib/main.client.options.dart index 6fdfce6fc9..1f8f128ec1 100644 --- a/sites/www/lib/main.client.options.dart +++ b/sites/www/lib/main.client.options.dart @@ -40,6 +40,8 @@ import 'package:site_shared/components/blog/client/blog_categories.dart' deferred as _blog_categories; import 'package:site_shared/components/blog/client/share_button.dart' deferred as _share_button; +import 'package:site_shared/components/common/client/back_to_top_button.dart' + deferred as _back_to_top_button; import 'package:site_shared/components/common/client/collapse_button.dart' deferred as _collapse_button; import 'package:site_shared/components/common/client/copy_button.dart' @@ -206,6 +208,10 @@ ClientOptions get defaultClientOptions => ClientOptions( ), loader: _share_button.loadLibrary, ), + 'site_shared:back_to_top_button': ClientLoader( + (p) => _back_to_top_button.BackToTopButton(), + loader: _back_to_top_button.loadLibrary, + ), 'site_shared:collapse_button': ClientLoader( (p) => _collapse_button.CollapseButton( classes: (p['classes'] as List).cast(), 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/main.server.options.dart b/sites/www/lib/main.server.options.dart index c68f3a7351..3a2002b0ab 100644 --- a/sites/www/lib/main.server.options.dart +++ b/sites/www/lib/main.server.options.dart @@ -36,6 +36,8 @@ import 'package:site_shared/components/blog/client/blog_categories.dart' as _blog_categories; import 'package:site_shared/components/blog/client/share_button.dart' as _share_button; +import 'package:site_shared/components/common/client/back_to_top_button.dart' + as _back_to_top_button; import 'package:site_shared/components/common/client/collapse_button.dart' as _collapse_button; import 'package:site_shared/components/common/client/copy_button.dart' @@ -130,6 +132,10 @@ ServerOptions get defaultServerOptions => ServerOptions( 'site_shared:share_button', params: __share_buttonShareButton, ), + _back_to_top_button.BackToTopButton: + ClientTarget<_back_to_top_button.BackToTopButton>( + 'site_shared:back_to_top_button', + ), _collapse_button.CollapseButton: ClientTarget<_collapse_button.CollapseButton>( 'site_shared:collapse_button', diff --git a/sites/www/lib/src/layouts/blog_layout.dart b/sites/www/lib/src/layouts/blog_layout.dart index 26e051b561..ae5d684e5b 100644 --- a/sites/www/lib/src/layouts/blog_layout.dart +++ b/sites/www/lib/src/layouts/blog_layout.dart @@ -9,8 +9,10 @@ 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'; import 'package:site_shared/util.dart'; import '../utils/scroll_spy.dart'; @@ -91,12 +93,14 @@ 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), ], ), ]), + if (isPost) const BackToTopButton(), ]), ); } diff --git a/sites/www/lib/styles/core/_vars.scss b/sites/www/lib/styles/core/_vars.scss index 3ab6342d54..cb284a7317 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 90117f297a..0a0f241727 100644 --- a/sites/www/lib/styles/pages/_blog_page.scss +++ b/sites/www/lib/styles/pages/_blog_page.scss @@ -33,6 +33,61 @@ 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-weight: var(--site-fontWeight-bold); + cursor: pointer; + list-style: 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; @@ -106,3 +161,36 @@ letter-spacing: normal; } } + +.blog .back-to-top { + position: fixed; + right: 1.5rem; + bottom: 1.5rem; + z-index: 10; + display: flex; + align-items: center; + justify-content: center; + width: 3rem; + height: 3rem; + padding: 0; + color: var(--site-base-fgColor-alt); + background: var(--site-base-bgColor); + border: 1px solid var(--site-outline-variant); + border-radius: 50%; + box-shadow: var(--ui-drop-shadow-sm); + cursor: pointer; + opacity: 0; + transform: translateY(0.5rem); + transition: opacity 0.2s var(--ui-anim-func), transform 0.2s var(--ui-anim-func); + pointer-events: none; + + &:hover { + color: var(--site-base-fgColor); + } +} + +body.blog.in-content .back-to-top { + opacity: 1; + transform: none; + pointer-events: auto; +}