From 97017b3ef07973c08aa479edd7f5fff2427e2aa6 Mon Sep 17 00:00:00 2001 From: Kilian Schulte Date: Wed, 19 Nov 2025 16:09:05 +0100 Subject: [PATCH 1/3] refactor to more idiomatic jaspr code --- site/lib/_sass/components/_tooltip.scss | 2 +- site/lib/src/style_hash.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/site/lib/_sass/components/_tooltip.scss b/site/lib/_sass/components/_tooltip.scss index cc71c23224..5584613fb1 100644 --- a/site/lib/_sass/components/_tooltip.scss +++ b/site/lib/_sass/components/_tooltip.scss @@ -60,4 +60,4 @@ visibility: visible; } } -} +} \ No newline at end of file diff --git a/site/lib/src/style_hash.dart b/site/lib/src/style_hash.dart index 306c4ee4e6..620cd3d65b 100644 --- a/site/lib/src/style_hash.dart +++ b/site/lib/src/style_hash.dart @@ -2,4 +2,4 @@ // dart format off /// The generated hash of the `main.css` file. -const generatedStylesHash = 'VAQsUT7crZAw'; +const generatedStylesHash = 'Nx/ufc4onkpD'; From 56e652947ac60c8810c1dd8a7834d3d5b64a355f Mon Sep 17 00:00:00 2001 From: Kilian Schulte Date: Tue, 18 Nov 2025 14:58:38 +0100 Subject: [PATCH 2/3] Add optional page navigation to top toc dropdown --- site/lib/_sass/base/_base.scss | 6 +- site/lib/_sass/components/_header.scss | 6 +- site/lib/_sass/components/_pagenav.scss | 51 +++++- site/lib/main.dart | 8 +- site/lib/src/components/common/prev_next.dart | 7 +- .../src/components/layout/client/pagenav.dart | 6 +- site/lib/src/components/layout/header.dart | 3 +- site/lib/src/components/layout/toc.dart | 114 +++++++++---- site/lib/src/layouts/doc_layout.dart | 65 +++----- site/lib/src/layouts/tutorial_layout.dart | 54 +++++++ site/lib/src/models/on_this_page_model.dart | 75 --------- .../lib/src/models/page_navigation_model.dart | 150 ++++++++++++++++++ site/lib/src/pages/custom_pages.dart | 1 + 13 files changed, 380 insertions(+), 166 deletions(-) create mode 100644 site/lib/src/layouts/tutorial_layout.dart delete mode 100644 site/lib/src/models/on_this_page_model.dart create mode 100644 site/lib/src/models/page_navigation_model.dart diff --git a/site/lib/_sass/base/_base.scss b/site/lib/_sass/base/_base.scss index 40ae9db1c6..985d5a1c51 100644 --- a/site/lib/_sass/base/_base.scss +++ b/site/lib/_sass/base/_base.scss @@ -8,8 +8,10 @@ body { color: var(--site-base-fgColor); // The top TOC is not shown on narrow screens. - @media (min-width: 1200px) { - --site-subheader-height: 0rem; + &:not(:has(#toc-top.show-always)) { + @media (min-width: 1200px) { + --site-subheader-height: 0rem; + } } // If the TOC is disabled, reduce the subheader height to diff --git a/site/lib/_sass/components/_header.scss b/site/lib/_sass/components/_header.scss index d2926018f9..26142d7d7a 100644 --- a/site/lib/_sass/components/_header.scss +++ b/site/lib/_sass/components/_header.scss @@ -186,7 +186,9 @@ body.open_menu #menu-toggle span.material-symbols { border-bottom: 0.1rem solid var(--site-outline-variant); box-shadow: 0 2px 4px rgba(0, 0, 0, .05); - @media (width < 240px), (width >= 1200px) { - display: none; + &:not(.show-always) { + @media (width < 240px), (width >= 1200px) { + display: none; + } } } diff --git a/site/lib/_sass/components/_pagenav.scss b/site/lib/_sass/components/_pagenav.scss index c541f41ca6..9a1e38ada4 100644 --- a/site/lib/_sass/components/_pagenav.scss +++ b/site/lib/_sass/components/_pagenav.scss @@ -85,6 +85,7 @@ text-decoration: none; display: flex; align-items: center; + gap: 4px; color: var(--site-base-fgColor-alt); font-weight: 500; @@ -93,10 +94,6 @@ user-select: none; } - span:last-child { - margin-left: 3px; - } - &:hover { color: var(--site-link-fgColor); } @@ -109,5 +106,51 @@ nav { padding: 0.6rem 0 0.8rem; } + + .page-link { + padding: 0; + font-weight: 400; + color: var(--site-base-fgColor); + margin-top: 0.6rem; + + .page-number { + width: 25px; + height: 25px; + border-radius: 50%; + background: var(--site-raised-bgColor); + color: var(--site-base-fgColor); + display: flex; + align-items: center; + justify-content: center; + font-weight: 500; + + transition: background-color 300ms ease, color 300ms ease; + } + + &.active .page-number { + background-color: var(--site-primary-color); + color: var(--site-onPrimary-color-lightest); + } + + &:not(.active):has(~.page-link.active) .page-number { + background-color: var(--site-onPrimary-color-light); + color: var(--site-primary-color); + } + + ~ nav { + padding: 0; + } + } + + .page-divider { + padding-left: 0.25rem; + padding-top: 0.25rem; + font-weight: 600; + color: var(--site-base-fgColor-alt); + } + + .dropdown-divider:has(~.page-link) { + margin-top: 0.6rem; + } } } diff --git a/site/lib/main.dart b/site/lib/main.dart index 2b41724cdb..fffc7e2185 100644 --- a/site/lib/main.dart +++ b/site/lib/main.dart @@ -26,6 +26,7 @@ import 'src/extensions/registry.dart'; import 'src/layouts/catalog_page_layout.dart'; import 'src/layouts/doc_layout.dart'; import 'src/layouts/toc_layout.dart'; +import 'src/layouts/tutorial_layout.dart'; import 'src/loaders/data_processor.dart'; import 'src/markdown/markdown_parser.dart'; import 'src/pages/custom_pages.dart'; @@ -66,7 +67,12 @@ Component get _docsFlutterDevSite => ContentApp.custom( rawOutputPattern: _passThroughPattern, extensions: allNodeProcessingExtensions, components: _embeddableComponents, - layouts: const [DocLayout(), TocLayout(), CatalogPageLayout()], + layouts: const [ + DocLayout(), + TocLayout(), + CatalogPageLayout(), + TutorialLayout(), + ], theme: const ContentTheme.none(), secondaryOutputs: [ const RobotsTxtOutput(), diff --git a/site/lib/src/components/common/prev_next.dart b/site/lib/src/components/common/prev_next.dart index 3f207450bc..600e899a63 100644 --- a/site/lib/src/components/common/prev_next.dart +++ b/site/lib/src/components/common/prev_next.dart @@ -4,6 +4,7 @@ import 'package:jaspr/jaspr.dart'; +import '../../models/page_navigation_model.dart'; import 'material_icon.dart'; /// Previous and next page buttons to display at the end of a page @@ -11,8 +12,8 @@ import 'material_icon.dart'; class PrevNext extends StatelessComponent { const PrevNext({super.key, this.previousPage, this.nextPage}); - final ({String url, String title})? previousPage; - final ({String url, String title})? nextPage; + final PageNavigationEntry? previousPage; + final PageNavigationEntry? nextPage; @override Component build(BuildContext context) { @@ -32,7 +33,7 @@ class PrevNext extends StatelessComponent { class _PrevNextCard extends StatelessComponent { const _PrevNextCard({required this.page, required this.isPrevious}); - final ({String url, String title}) page; + final PageNavigationEntry page; final bool isPrevious; @override diff --git a/site/lib/src/components/layout/client/pagenav.dart b/site/lib/src/components/layout/client/pagenav.dart index 18737c4b68..97932ac476 100644 --- a/site/lib/src/components/layout/client/pagenav.dart +++ b/site/lib/src/components/layout/client/pagenav.dart @@ -14,11 +14,13 @@ import '../../util/component_ref.dart'; @client class PageNav extends StatefulComponent { const PageNav({ + this.label, required this.title, required this.content, super.key, }); + final String? label; final String title; final ComponentRef content; @@ -66,9 +68,9 @@ class _PageNavState extends State { span(classes: 'toc-intro', [ const MaterialIcon('list'), span( - attributes: {'aria-label': 'On this page'}, + attributes: {'aria-label': component.label ?? 'On this page'}, [ - text('On this page'), + text(component.label ?? 'On this page'), ], ), ]), diff --git a/site/lib/src/components/layout/header.dart b/site/lib/src/components/layout/header.dart index 14a0d962f6..2daabbb885 100644 --- a/site/lib/src/components/layout/header.dart +++ b/site/lib/src/components/layout/header.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'package:jaspr/jaspr.dart'; +import 'package:jaspr_content/jaspr_content.dart'; import '../common/button.dart'; import '../common/material_icon.dart'; @@ -84,7 +85,7 @@ class DashHeader extends StatelessComponent { content: 'Get started', href: '/get-started/quick', ), - const MenuToggle(), + if (context.page.data['sidenav'] != null) const MenuToggle(), ], ), ]), diff --git a/site/lib/src/components/layout/toc.dart b/site/lib/src/components/layout/toc.dart index 4b0d5c5063..f3c760c460 100644 --- a/site/lib/src/components/layout/toc.dart +++ b/site/lib/src/components/layout/toc.dart @@ -3,8 +3,10 @@ // found in the LICENSE file. import 'package:jaspr/jaspr.dart'; +import 'package:jaspr_content/jaspr_content.dart'; -import '../../models/on_this_page_model.dart'; +import '../../models/page_navigation_model.dart'; +import '../../util.dart'; import '../common/client/on_this_page_button.dart'; import '../common/material_icon.dart'; import '../util/component_ref.dart'; @@ -13,7 +15,7 @@ import 'client/pagenav.dart'; final class DashTableOfContents extends StatelessComponent { const DashTableOfContents(this.data); - final OnThisPageData data; + final TocNavigationData data; @override Component build(BuildContext _) { @@ -22,38 +24,86 @@ final class DashTableOfContents extends StatelessComponent { _TocContents(data), ]); } +} - static Component asDropdown( - OnThisPageData data, { - required String currentTitle, - }) { - return Builder( - builder: (context) { - return PageNav( - title: currentTitle, - content: context.ref( - div([ - a( - href: '#site-content-title', - id: 'return-to-top', - [ - const MaterialIcon('vertical_align_top'), - span([text(currentTitle)]), - ], - ), - div( - classes: 'dropdown-divider', - attributes: {'aria-hidden': 'true', 'role': 'separator'}, - [], - ), +final class PageNavBar extends StatelessComponent { + const PageNavBar(this.data); + + final PageNavigationData data; + + @override + Component build(BuildContext context) { + final currentLinkedPage = data.pageEntries + .where((page) => page.url == context.page.url) + .firstOrNull; + + final linkedPageTitle = currentLinkedPage?.title; + final currentTitle = context.page.data.page['title'] as String; + + var pageEntryNumber = 1; + + return PageNav( + label: linkedPageTitle, + title: currentTitle, + content: context.ref( + div([ + if (data.pageEntries.isEmpty) ...[ + a( + href: '#site-content-title', + id: 'return-to-top', + [ + const MaterialIcon('vertical_align_top'), + span([text(currentTitle)]), + ], + ), + div( + classes: 'dropdown-divider', + attributes: {'aria-hidden': 'true', 'role': 'separator'}, + [], + ), + if (data.toc != null) nav( attributes: {'role': 'menu'}, - [_TocContents(data)], + [_TocContents(data.toc!)], ), - ]), - ), - ); - }, + ] else ...[ + for (final page in data.pageEntries) ...[ + if (!page.isDivider) ...[ + a( + classes: [ + 'page-link', + if (page == currentLinkedPage) 'active', + ].toClasses, + href: page.url, + attributes: {'role': 'menuitem'}, + [ + span(classes: 'page-number', [ + text('${pageEntryNumber++}'), + ]), + text(page.title), + ], + ), + if (currentLinkedPage == page && data.toc != null) + nav( + attributes: {'role': 'menu'}, + [_TocContents(data.toc!)], + ), + ] else ...[ + if (page != data.pageEntries.first) + div( + classes: 'dropdown-divider', + attributes: {'aria-hidden': 'true', 'role': 'separator'}, + [], + ), + div( + classes: 'page-divider', + [text(page.title)], + ), + ], + ], + ], + ]), + ), ); } } @@ -61,7 +111,7 @@ final class DashTableOfContents extends StatelessComponent { final class _TocContents extends StatelessComponent { const _TocContents(this.data); - final OnThisPageData data; + final TocNavigationData data; @override Component build(BuildContext _) => ul( @@ -69,7 +119,7 @@ final class _TocContents extends StatelessComponent { _buildEntries(data.topLevelEntries, 0), ); - List _buildEntries(List entries, int depth) { + List _buildEntries(List entries, int depth) { final nextDepth = depth + 1; return [ diff --git a/site/lib/src/layouts/doc_layout.dart b/site/lib/src/layouts/doc_layout.dart index a15a3a0b03..3b00c9379b 100644 --- a/site/lib/src/layouts/doc_layout.dart +++ b/site/lib/src/layouts/doc_layout.dart @@ -10,8 +10,7 @@ import '../components/common/prev_next.dart'; import '../components/layout/banner.dart'; import '../components/layout/toc.dart'; import '../components/layout/trailing_content.dart'; -import '../extensions/header_extractor.dart'; -import '../models/on_this_page_model.dart'; +import '../models/page_navigation_model.dart'; import '../util.dart'; import 'dash_layout.dart'; @@ -33,29 +32,37 @@ class DocLayout extends FlutterDocsLayout { (pageData['showBanner'] as bool?) ?? (siteData['showBanner'] as bool?) ?? false; - final tocData = _tocForPage(page); + final navigationData = page.navigationData; return super.buildBody( page, Component.fragment( [ - if (tocData == null) + if (navigationData == null) const Document.body(attributes: {'data-toc': 'false'}) else - div(id: 'site-subheader', [ - DashTableOfContents.asDropdown( - tocData, - currentTitle: pageTitle, - ), - ]), + div( + id: 'site-subheader', + classes: navigationData.pageEntries.isNotEmpty + ? 'show-always' + : null, + [ + PageNavBar( + navigationData, + ), + ], + ), if (showBanner) if (siteData['bannerHtml'] case final String bannerHtml when bannerHtml.trim().isNotEmpty) DashBanner(bannerHtml), div(classes: 'after-leading-content', [ - if (tocData != null) + if (navigationData case PageNavigationData( + toc: final toc?, + pageEntries: [], + )) aside(id: 'side-menu', [ - DashTableOfContents(tocData), + DashTableOfContents(toc), ]), article([ div(id: 'site-content-title', [ @@ -72,8 +79,8 @@ class DocLayout extends FlutterDocsLayout { child, PrevNext( - previousPage: _pageInfoFromObject(pageData['prev']), - nextPage: _pageInfoFromObject(pageData['next']), + previousPage: PageNavigationEntry.fromData(pageData['prev']), + nextPage: PageNavigationEntry.fromData(pageData['next']), ), const TrailingContent(), ]), @@ -82,34 +89,4 @@ class DocLayout extends FlutterDocsLayout { ), ); } - - OnThisPageData? _tocForPage(Page page) { - final pageData = page.data.page; - final showToc = pageData['showToc'] as bool? ?? true; - - // If 'showToc' was explicitly set to false, hide the toc. - if (!showToc) return null; - - final onThisPageData = OnThisPageData.fromContentHeaders( - page.data['contentHeaders'] as List? ?? const [], - minLevel: pageData['minTocDepth'] as int? ?? 2, - maxLevel: pageData['maxTocDepth'] as int? ?? 3, - ); - - // If there are less than 2 top-level entries, hide the toc. - if (onThisPageData.topLevelEntries.length < 2) return null; - - return onThisPageData; - } -} - -({String url, String title})? _pageInfoFromObject(Object? data) { - if (data case { - 'path': final String pageUrl, - 'title': final String pageTitle, - }) { - return (url: pageUrl, title: pageTitle); - } - - return null; } diff --git a/site/lib/src/layouts/tutorial_layout.dart b/site/lib/src/layouts/tutorial_layout.dart new file mode 100644 index 0000000000..be9a0497d1 --- /dev/null +++ b/site/lib/src/layouts/tutorial_layout.dart @@ -0,0 +1,54 @@ +// Copyright 2025 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/jaspr.dart'; +import 'package:jaspr_content/jaspr_content.dart'; + +import 'doc_layout.dart'; + +class TutorialLayout extends DocLayout { + const TutorialLayout(); + + @override + String get name => 'tutorial'; + + @override + Component buildBody(Page page, Component child) { + page.apply( + data: { + 'page': { + 'showBanner': false, + //TODO(schultek): Extract the real pages in some way. + 'navigationEntries': [ + {'type': 'divider', 'title': 'Introdution to Flutter UI'}, + {'title': 'Create a Flutter app', 'path': '/fwe0'}, + {'title': 'Widget fundamentals', 'path': '/fwe1'}, + {'title': 'Layout widgets on a screen', 'path': '/fwe2'}, + {'title': 'FWE Testing Page', 'path': '/fwe'}, + {'title': 'Devtools', 'path': '/fwe3'}, + {'title': 'Handle user input', 'path': '/fwe4'}, + {'type': 'divider', 'title': 'State in Flutter apps'}, + {'title': 'Set up a new project', 'path': '/fwe5'}, + {'title': 'Make Http Requests', 'path': '/fwe6'}, + { + 'title': 'Use ChangeNotifier to update app state', + 'path': '/fwe7', + }, + { + 'title': 'Use ListenableBuilder to update app UI', + 'path': '/fwe8', + }, + {'type': 'divider', 'title': 'Flutter UI 102'}, + {'title': 'Set up your project', 'path': '/fwe9'}, + {'title': 'LayoutBuilder and adaptive layouts', 'path': '/fwe10'}, + {'title': 'Scrolling and slivers', 'path': '/fwe11'}, + {'title': 'Stack based navigation', 'path': '/fwe12'}, + ], + }, + 'sidenav': null, + }, + ); + return super.buildBody(page, child); + } +} diff --git a/site/lib/src/models/on_this_page_model.dart b/site/lib/src/models/on_this_page_model.dart deleted file mode 100644 index 2efd3eb7a4..0000000000 --- a/site/lib/src/models/on_this_page_model.dart +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright 2025 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 '../extensions/header_extractor.dart'; - -class OnThisPageData { - final List topLevelEntries; - - OnThisPageData(this.topLevelEntries); - - factory OnThisPageData.fromContentHeaders( - List headers, { - required int minLevel, - required int maxLevel, - }) { - final rootEntries = []; - final levelMap = {}; - - for (final header in headers) { - // Clear entries at this level and below - // so that they aren't tracked any more. - for ( - var removeLevel = header.level; - removeLevel <= maxLevel; - removeLevel += 1 - ) { - levelMap.remove(removeLevel); - } - - final id = header.attributes['id']; - final classes = header.attributes['class']?.split(' ') ?? []; - - // Check if header should be skipped. - if (id == null || - classes.contains('no_toc') || - header.level < minLevel || - header.level > maxLevel) { - continue; - } - - final entry = OnThisPageEntry( - id: id, - text: header.text, - children: [], - ); - - // Check if this is a root level entry. - if (header.level == minLevel) { - rootEntries.add(entry); - levelMap[header.level] = entry; - } else { - // Look for parent at exactly one level above. - if (levelMap[header.level - 1] case final parent?) { - parent.children.add(entry); - levelMap[header.level] = entry; - } - } - } - - return OnThisPageData(rootEntries); - } -} - -final class OnThisPageEntry { - final String id; - final String text; - final List children; - - const OnThisPageEntry({ - required this.id, - required this.text, - this.children = const [], - }); -} diff --git a/site/lib/src/models/page_navigation_model.dart b/site/lib/src/models/page_navigation_model.dart new file mode 100644 index 0000000000..3304ea65bc --- /dev/null +++ b/site/lib/src/models/page_navigation_model.dart @@ -0,0 +1,150 @@ +// Copyright 2025 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_content/jaspr_content.dart'; + +import '../extensions/header_extractor.dart'; + +extension GetPageNavigationData on Page { + PageNavigationData? get navigationData { + final pageData = data.page; + final showToc = pageData['showToc'] as bool? ?? true; + + // If 'showToc' was explicitly set to false, hide the toc. + if (!showToc) return null; + + final tocData = _getTocData( + data['contentHeaders'] as List? ?? const [], + minLevel: pageData['minTocDepth'] as int? ?? 2, + maxLevel: pageData['maxTocDepth'] as int? ?? 3, + ); + + final pageEntries = []; + if (pageData['navigationEntries'] case final List entries) { + for (final entry in entries) { + if (PageNavigationEntry.fromData(entry) case final entry?) { + pageEntries.add(entry); + } + } + } + + // If there are less than 2 top-level entries, hide the toc. + if (tocData.topLevelEntries.length < 2) { + return PageNavigationData(null, pageEntries); + } + + return PageNavigationData(tocData, pageEntries); + } + + TocNavigationData _getTocData( + List headers, { + required int minLevel, + required int maxLevel, + }) { + final rootEntries = []; + final levelMap = {}; + + for (final header in headers) { + // Clear entries at this level and below + // so that they aren't tracked any more. + for ( + var removeLevel = header.level; + removeLevel <= maxLevel; + removeLevel += 1 + ) { + levelMap.remove(removeLevel); + } + + final id = header.attributes['id']; + final classes = header.attributes['class']?.split(' ') ?? []; + + // Check if header should be skipped. + if (id == null || + classes.contains('no_toc') || + header.level < minLevel || + header.level > maxLevel) { + continue; + } + + final entry = TocNavigationEntry( + id: id, + text: header.text, + children: [], + ); + + // Check if this is a root level entry. + if (header.level == minLevel) { + rootEntries.add(entry); + levelMap[header.level] = entry; + } else { + // Look for parent at exactly one level above. + if (levelMap[header.level - 1] case final parent?) { + parent.children.add(entry); + levelMap[header.level] = entry; + } + } + } + + return TocNavigationData(rootEntries); + } +} + +final class PageNavigationData { + PageNavigationData(this.toc, this.pageEntries); + + final TocNavigationData? toc; + final List pageEntries; +} + +class TocNavigationData { + TocNavigationData(this.topLevelEntries); + + final List topLevelEntries; +} + +final class TocNavigationEntry { + const TocNavigationEntry({ + required this.id, + required this.text, + this.children = const [], + }); + + final String id; + final String text; + final List children; +} + +final class PageNavigationEntry { + const PageNavigationEntry({ + required this.title, + required this.url, + }) : isDivider = false; + + const PageNavigationEntry.divider({ + required this.title, + }) : url = '', + isDivider = true; + + static PageNavigationEntry? fromData(Object? data) { + if (data case { + 'type': 'divider', + 'title': final String title, + }) { + return PageNavigationEntry.divider(title: title); + } + + if (data case { + 'title': final String title, + 'path': final String path, + }) { + return PageNavigationEntry(title: title, url: path); + } + + return null; + } + + final String title; + final String url; + final bool isDivider; +} diff --git a/site/lib/src/pages/custom_pages.dart b/site/lib/src/pages/custom_pages.dart index 2cffefadca..b6703d3ead 100644 --- a/site/lib/src/pages/custom_pages.dart +++ b/site/lib/src/pages/custom_pages.dart @@ -76,6 +76,7 @@ MemoryPage get _fweTestingPage => const MemoryPage( title: FWE Testing Page description: This is a test page for experimenting with First Week Experience (FWE) features. sitemap: false +layout: tutorial --- ## Quiz From 21fcef2d696d9dde0a1ba0eae08efa9ef9990faf Mon Sep 17 00:00:00 2001 From: Kilian Schulte Date: Tue, 18 Nov 2025 15:35:59 +0100 Subject: [PATCH 3/3] add page nav breadcrumbs and fix styling --- site/lib/_sass/base/_base.scss | 2 +- site/lib/_sass/components/_header.scss | 6 +- site/lib/_sass/components/_pagenav.scss | 32 +++++---- site/lib/jaspr_options.dart | 3 +- .../src/components/layout/client/pagenav.dart | 41 ++++++++---- site/lib/src/components/layout/toc.dart | 19 ++++-- site/lib/src/layouts/tutorial_layout.dart | 65 +++++++++---------- .../lib/src/models/page_navigation_model.dart | 11 ++-- site/lib/src/style_hash.dart | 2 +- 9 files changed, 109 insertions(+), 72 deletions(-) diff --git a/site/lib/_sass/base/_base.scss b/site/lib/_sass/base/_base.scss index 985d5a1c51..ce855aca2b 100644 --- a/site/lib/_sass/base/_base.scss +++ b/site/lib/_sass/base/_base.scss @@ -8,7 +8,7 @@ body { color: var(--site-base-fgColor); // The top TOC is not shown on narrow screens. - &:not(:has(#toc-top.show-always)) { + &:not(:has(#site-subheader.show-always)) { @media (min-width: 1200px) { --site-subheader-height: 0rem; } diff --git a/site/lib/_sass/components/_header.scss b/site/lib/_sass/components/_header.scss index 26142d7d7a..57528e26ad 100644 --- a/site/lib/_sass/components/_header.scss +++ b/site/lib/_sass/components/_header.scss @@ -7,8 +7,10 @@ border-bottom: 0.1rem solid var(--site-outline-variant); @media (min-width: 1200px) { - box-shadow: 0 2px 4px rgba(0, 0, 0, .05); - border-bottom: none; + &:not(:has(~* #site-subheader.show-always)) { + box-shadow: 0 2px 4px rgba(0, 0, 0, .05); + border-bottom: none; + } } .navbar { diff --git a/site/lib/_sass/components/_pagenav.scss b/site/lib/_sass/components/_pagenav.scss index 9a1e38ada4..8d3adb183d 100644 --- a/site/lib/_sass/components/_pagenav.scss +++ b/site/lib/_sass/components/_pagenav.scss @@ -1,6 +1,7 @@ #pagenav { flex-grow: 1; min-width: 0; + max-width: 100%; >button.dropdown-button { display: flex; @@ -25,27 +26,36 @@ } } - .toc-intro { + .toc-breadcrumb { + flex-shrink: 2; white-space: nowrap; + overflow: hidden; - .material-symbols { + &:first-child .material-symbols { margin-right: 0.25rem; } - } - .toc-current { - display: none; - - @media (min-width: 320px) { - display: flex; + span:last-child { + overflow: hidden; + text-overflow: ellipsis; } + } - flex-wrap: nowrap; + .toc-current { + flex-shrink: 1; white-space: nowrap; overflow: hidden; - text-overflow: ellipsis; color: var(--site-base-fgColor-alt); + + @media (max-width: 320px) { + display: none !important; + } + + span:last-child { + overflow: hidden; + text-overflow: ellipsis; + } } #pagenav-content { @@ -137,7 +147,7 @@ color: var(--site-primary-color); } - ~ nav { + ~nav { padding: 0; } } diff --git a/site/lib/jaspr_options.dart b/site/lib/jaspr_options.dart index 5ef15399ed..a0d7a68a07 100644 --- a/site/lib/jaspr_options.dart +++ b/site/lib/jaspr_options.dart @@ -170,7 +170,8 @@ Map _prefix8DartPadInjector(prefix8.DartPadInjector c) => { 'runAutomatically': c.runAutomatically, }; Map _prefix9PageNav(prefix9.PageNav c) => { - 'title': c.title, + 'breadcrumbs': c.breadcrumbs, + 'initialHeading': c.initialHeading, 'content': c.content.toId(), }; Map _prefix13ArchiveTable(prefix13.ArchiveTable c) => { diff --git a/site/lib/src/components/layout/client/pagenav.dart b/site/lib/src/components/layout/client/pagenav.dart index 97932ac476..b661d02a13 100644 --- a/site/lib/src/components/layout/client/pagenav.dart +++ b/site/lib/src/components/layout/client/pagenav.dart @@ -14,14 +14,14 @@ import '../../util/component_ref.dart'; @client class PageNav extends StatefulComponent { const PageNav({ - this.label, - required this.title, + this.breadcrumbs = const [], + required this.initialHeading, required this.content, super.key, }); - final String? label; - final String title; + final List breadcrumbs; + final String initialHeading; final ComponentRef content; @override @@ -65,21 +65,34 @@ class _PageNavState extends State { 'aria-label': 'Toggle the table of contents dropdown', }, [ - span(classes: 'toc-intro', [ - const MaterialIcon('list'), - span( - attributes: {'aria-label': component.label ?? 'On this page'}, - [ - text(component.label ?? 'On this page'), - ], - ), - ]), + if (component.breadcrumbs.isEmpty) + span(classes: 'toc-intro', [ + const MaterialIcon('list'), + span( + attributes: {'aria-label': 'On this page'}, + [text('On this page')], + ), + ]) + else ...[ + for (final (index, crumb) in component.breadcrumbs.indexed) ...[ + span(classes: 'toc-breadcrumb', [ + if (index == 0) + const MaterialIcon('list') + else + const MaterialIcon('chevron_right'), + span([ + text(crumb), + ]), + ]), + ], + ], + span(classes: 'toc-current', [ const MaterialIcon('chevron_right'), ValueListenableBuilder( listenable: currentPageHeading, builder: (context, value) { - return span([text(value ?? component.title)]); + return span([text(value ?? component.initialHeading)]); }, ), ]), diff --git a/site/lib/src/components/layout/toc.dart b/site/lib/src/components/layout/toc.dart index f3c760c460..30d798a29c 100644 --- a/site/lib/src/components/layout/toc.dart +++ b/site/lib/src/components/layout/toc.dart @@ -33,9 +33,18 @@ final class PageNavBar extends StatelessComponent { @override Component build(BuildContext context) { - final currentLinkedPage = data.pageEntries - .where((page) => page.url == context.page.url) - .firstOrNull; + PageNavigationEntry? currentLinkedPage; + String? currentDivider; + + for (final page in data.pageEntries) { + if (page.url == context.page.url) { + currentLinkedPage = page; + break; + } + if (page.isDivider) { + currentDivider = page.title; + } + } final linkedPageTitle = currentLinkedPage?.title; final currentTitle = context.page.data.page['title'] as String; @@ -43,8 +52,8 @@ final class PageNavBar extends StatelessComponent { var pageEntryNumber = 1; return PageNav( - label: linkedPageTitle, - title: currentTitle, + breadcrumbs: [?data.parentTitle, ?currentDivider, ?linkedPageTitle], + initialHeading: currentTitle, content: context.ref( div([ if (data.pageEntries.isEmpty) ...[ diff --git a/site/lib/src/layouts/tutorial_layout.dart b/site/lib/src/layouts/tutorial_layout.dart index be9a0497d1..6126e9e9ec 100644 --- a/site/lib/src/layouts/tutorial_layout.dart +++ b/site/lib/src/layouts/tutorial_layout.dart @@ -15,40 +15,39 @@ class TutorialLayout extends DocLayout { @override Component buildBody(Page page, Component child) { - page.apply( - data: { - 'page': { - 'showBanner': false, - //TODO(schultek): Extract the real pages in some way. - 'navigationEntries': [ - {'type': 'divider', 'title': 'Introdution to Flutter UI'}, - {'title': 'Create a Flutter app', 'path': '/fwe0'}, - {'title': 'Widget fundamentals', 'path': '/fwe1'}, - {'title': 'Layout widgets on a screen', 'path': '/fwe2'}, - {'title': 'FWE Testing Page', 'path': '/fwe'}, - {'title': 'Devtools', 'path': '/fwe3'}, - {'title': 'Handle user input', 'path': '/fwe4'}, - {'type': 'divider', 'title': 'State in Flutter apps'}, - {'title': 'Set up a new project', 'path': '/fwe5'}, - {'title': 'Make Http Requests', 'path': '/fwe6'}, - { - 'title': 'Use ChangeNotifier to update app state', - 'path': '/fwe7', - }, - { - 'title': 'Use ListenableBuilder to update app UI', - 'path': '/fwe8', - }, - {'type': 'divider', 'title': 'Flutter UI 102'}, - {'title': 'Set up your project', 'path': '/fwe9'}, - {'title': 'LayoutBuilder and adaptive layouts', 'path': '/fwe10'}, - {'title': 'Scrolling and slivers', 'path': '/fwe11'}, - {'title': 'Stack based navigation', 'path': '/fwe12'}, - ], + //TODO(schultek): Extract the real pages in some way. + const navigationEntries = [ + {'type': 'divider', 'title': 'Introdution to Flutter UI'}, + {'title': 'Create a Flutter app', 'path': '/fwe0'}, + {'title': 'Widget fundamentals', 'path': '/fwe1'}, + {'title': 'Layout widgets on a screen', 'path': '/fwe2'}, + {'title': 'FWE Testing Page', 'path': '/fwe'}, + {'title': 'Devtools', 'path': '/fwe3'}, + {'title': 'Handle user input', 'path': '/fwe4'}, + {'type': 'divider', 'title': 'State in Flutter apps'}, + {'title': 'Set up a new project', 'path': '/fwe5'}, + {'title': 'Make Http Requests', 'path': '/fwe6'}, + {'title': 'Use ChangeNotifier to update app state', 'path': '/fwe7'}, + {'title': 'Use ListenableBuilder to update app UI', 'path': '/fwe8'}, + {'type': 'divider', 'title': 'Flutter UI 102'}, + {'title': 'Set up your project', 'path': '/fwe9'}, + {'title': 'LayoutBuilder and adaptive layouts', 'path': '/fwe10'}, + {'title': 'Scrolling and slivers', 'path': '/fwe11'}, + {'title': 'Stack based navigation', 'path': '/fwe12'}, + ]; + + return super.buildBody( + page..apply( + data: { + 'page': { + 'showBanner': false, + 'navigationCollectionTitle': 'Flutter Fundamentals', + 'navigationEntries': navigationEntries, + }, + 'sidenav': null, }, - 'sidenav': null, - }, + ), + child, ); - return super.buildBody(page, child); } } diff --git a/site/lib/src/models/page_navigation_model.dart b/site/lib/src/models/page_navigation_model.dart index 3304ea65bc..54c8728f76 100644 --- a/site/lib/src/models/page_navigation_model.dart +++ b/site/lib/src/models/page_navigation_model.dart @@ -20,6 +20,8 @@ extension GetPageNavigationData on Page { maxLevel: pageData['maxTocDepth'] as int? ?? 3, ); + final parentTitle = pageData['navigationCollectionTitle'] as String?; + final pageEntries = []; if (pageData['navigationEntries'] case final List entries) { for (final entry in entries) { @@ -31,10 +33,10 @@ extension GetPageNavigationData on Page { // If there are less than 2 top-level entries, hide the toc. if (tocData.topLevelEntries.length < 2) { - return PageNavigationData(null, pageEntries); + return PageNavigationData(null, pageEntries, parentTitle); } - return PageNavigationData(tocData, pageEntries); + return PageNavigationData(tocData, pageEntries, parentTitle); } TocNavigationData _getTocData( @@ -91,13 +93,14 @@ extension GetPageNavigationData on Page { } final class PageNavigationData { - PageNavigationData(this.toc, this.pageEntries); + PageNavigationData(this.toc, this.pageEntries, this.parentTitle); final TocNavigationData? toc; final List pageEntries; + final String? parentTitle; } -class TocNavigationData { +final class TocNavigationData { TocNavigationData(this.topLevelEntries); final List topLevelEntries; diff --git a/site/lib/src/style_hash.dart b/site/lib/src/style_hash.dart index 620cd3d65b..5bfa059bfd 100644 --- a/site/lib/src/style_hash.dart +++ b/site/lib/src/style_hash.dart @@ -2,4 +2,4 @@ // dart format off /// The generated hash of the `main.css` file. -const generatedStylesHash = 'Nx/ufc4onkpD'; +const generatedStylesHash = 'udYDN8P9KB1z';