diff --git a/site/lib/_sass/_site.scss b/site/lib/_sass/_site.scss index 992f23edafd..fed73d29595 100644 --- a/site/lib/_sass/_site.scss +++ b/site/lib/_sass/_site.scss @@ -29,6 +29,7 @@ @use 'components/misc'; @use 'components/next-prev-nav'; @use 'components/os-selector'; +@use 'components/pagenav'; @use 'components/pill'; @use 'components/quiz'; @use 'components/sidebar'; @@ -36,7 +37,6 @@ @use 'components/site-switcher'; @use 'components/tabs'; @use 'components/theming'; -@use 'components/toc'; @use 'components/tooltip'; @use 'components/trailing'; diff --git a/site/lib/_sass/components/_header.scss b/site/lib/_sass/components/_header.scss index 03b28663534..3228e80b6ac 100644 --- a/site/lib/_sass/components/_header.scss +++ b/site/lib/_sass/components/_header.scss @@ -167,3 +167,26 @@ body.open_menu #menu-toggle span.material-symbols { } } } + +#site-subheader { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + + position: sticky; + top: var(--site-header-height); + z-index: var(--site-z-subheader); + height: var(--site-subheader-height); + + font-family: var(--site-ui-fontFamily); + font-size: 0.875rem; + + background-color: var(--site-base-bgColor); + 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; + } +} \ No newline at end of file diff --git a/site/lib/_sass/components/_toc.scss b/site/lib/_sass/components/_pagenav.scss similarity index 74% rename from site/lib/_sass/components/_toc.scss rename to site/lib/_sass/components/_pagenav.scss index 8d183cf9b33..b470ea7d753 100644 --- a/site/lib/_sass/components/_toc.scss +++ b/site/lib/_sass/components/_pagenav.scss @@ -1,30 +1,4 @@ -#toc-top { - font-family: var(--site-ui-fontFamily); - - display: none; - flex-direction: row; - flex-wrap: wrap; - justify-content: space-between; - align-content: center; - height: var(--site-subheader-height); - - @media (min-width: 240px) { - display: flex; - } - - @media (min-width: 1200px) { - display: none; - } - - position: sticky; - top: var(--site-header-height); - - background-color: var(--site-base-bgColor); - border-bottom: 0.1rem solid var(--site-outline-variant); - box-shadow: 0 2px 4px rgba(0, 0, 0, .05); - - font-size: 0.875rem; - z-index: var(--site-z-subheader); +#pagenav { >button.dropdown-button { display: flex; @@ -58,25 +32,21 @@ } .toc-current { - flex-wrap: nowrap; - white-space: nowrap; - overflow: hidden; - display: none; @media (min-width: 320px) { display: flex; } - } - - #current-header { - color: var(--site-base-fgColor-alt); + flex-wrap: nowrap; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + + color: var(--site-base-fgColor-alt); } - .dropdown-content { + #pagenav-content { position: absolute; box-shadow: 0 2px 4px rgba(0, 0, 0, .05); border-bottom: 0.1rem solid var(--site-outline-variant); @@ -106,7 +76,7 @@ max-width: 24rem; } - >a { + a#return-to-top { margin: 0.4rem 0; padding: 0.1rem; font-size: 1rem; @@ -134,7 +104,7 @@ } } - >nav { + nav { padding: 0.6rem 0 0.8rem; } } diff --git a/site/lib/_sass/components/_side-menu.scss b/site/lib/_sass/components/_side-menu.scss index eba9b9a7b0d..06cf7f2d817 100644 --- a/site/lib/_sass/components/_side-menu.scss +++ b/site/lib/_sass/components/_side-menu.scss @@ -1,4 +1,4 @@ -.styled-toc-list { +.toc-list { margin: 0; --toc-indent: 0; diff --git a/site/lib/_sass/components/_tooltip.scss b/site/lib/_sass/components/_tooltip.scss index ec160911ddd..fc56668fd2a 100644 --- a/site/lib/_sass/components/_tooltip.scss +++ b/site/lib/_sass/components/_tooltip.scss @@ -1,7 +1,7 @@ .tooltip-wrapper { position: relative; - a.tooltip-target { + .tooltip-target:has(+.tooltip) a { color: inherit; text-decoration: underline; text-decoration-style: dotted; @@ -10,14 +10,13 @@ .tooltip { visibility: hidden; - display: flex; + display: block; position: absolute; z-index: var(--site-z-floating); top: 100%; left: 50%; transform: translateX(-50%); - flex-flow: column nowrap; width: 16rem; background: var(--site-raised-bgColor); @@ -26,20 +25,22 @@ box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, .15); padding: 0.8rem; - font-size: 1rem; + font-size: 0.875rem; font-weight: normal; font-style: normal; + .tooltip-content { + display: flex; + flex-flow: column nowrap; + color: var(--site-secondary-textColor); + } + .tooltip-header { font-size: 1.2rem; font-weight: 500; margin-bottom: 0.25rem; } - .tooltip-content { - font-size: 0.875rem; - color: var(--site-secondary-textColor); - } } // On non-touch devices, show tooltip on hover or focus. @@ -53,7 +54,7 @@ } } - // On touch devices, show tooltip on click (see global_scripts.dart). + // On touch devices, show tooltip on click. @media all and (pointer: coarse) { .tooltip.visible { visibility: visible; diff --git a/site/lib/jaspr_options.dart b/site/lib/jaspr_options.dart index 175ad48d16a..50c6e05e546 100644 --- a/site/lib/jaspr_options.dart +++ b/site/lib/jaspr_options.dart @@ -7,37 +7,43 @@ import 'package:jaspr/jaspr.dart'; import 'package:docs_flutter_dev_site/src/client/global_scripts.dart' as prefix0; -import 'package:docs_flutter_dev_site/src/components/common/client/cookie_notice.dart' +import 'package:docs_flutter_dev_site/src/components/common/client/api_link_tooltip.dart' as prefix1; -import 'package:docs_flutter_dev_site/src/components/common/client/copy_button.dart' +import 'package:docs_flutter_dev_site/src/components/common/client/cookie_notice.dart' as prefix2; -import 'package:docs_flutter_dev_site/src/components/common/client/download_latest_button.dart' +import 'package:docs_flutter_dev_site/src/components/common/client/copy_button.dart' as prefix3; -import 'package:docs_flutter_dev_site/src/components/common/client/feedback.dart' +import 'package:docs_flutter_dev_site/src/components/common/client/download_latest_button.dart' as prefix4; -import 'package:docs_flutter_dev_site/src/components/common/client/on_this_page_button.dart' +import 'package:docs_flutter_dev_site/src/components/common/client/feedback.dart' as prefix5; -import 'package:docs_flutter_dev_site/src/components/common/client/os_selector.dart' +import 'package:docs_flutter_dev_site/src/components/common/client/on_this_page_button.dart' as prefix6; -import 'package:docs_flutter_dev_site/src/components/dartpad/dartpad_injector.dart' +import 'package:docs_flutter_dev_site/src/components/common/client/os_selector.dart' as prefix7; -import 'package:docs_flutter_dev_site/src/components/layout/menu_toggle.dart' +import 'package:docs_flutter_dev_site/src/components/common/client/simple_tooltip.dart' as prefix8; -import 'package:docs_flutter_dev_site/src/components/layout/site_switcher.dart' +import 'package:docs_flutter_dev_site/src/components/dartpad/dartpad_injector.dart' as prefix9; -import 'package:docs_flutter_dev_site/src/components/layout/theme_switcher.dart' +import 'package:docs_flutter_dev_site/src/components/layout/client/pagenav.dart' as prefix10; -import 'package:docs_flutter_dev_site/src/components/pages/archive_table.dart' +import 'package:docs_flutter_dev_site/src/components/layout/menu_toggle.dart' as prefix11; -import 'package:docs_flutter_dev_site/src/components/pages/glossary_search_section.dart' +import 'package:docs_flutter_dev_site/src/components/layout/site_switcher.dart' as prefix12; -import 'package:docs_flutter_dev_site/src/components/pages/learning_resource_filters.dart' +import 'package:docs_flutter_dev_site/src/components/layout/theme_switcher.dart' as prefix13; -import 'package:docs_flutter_dev_site/src/components/pages/learning_resource_filters_sidebar.dart' +import 'package:docs_flutter_dev_site/src/components/pages/archive_table.dart' as prefix14; -import 'package:docs_flutter_dev_site/src/components/tutorial/client/quiz.dart' +import 'package:docs_flutter_dev_site/src/components/pages/glossary_search_section.dart' as prefix15; -import 'package:jaspr_content/components/file_tree.dart' as prefix16; +import 'package:docs_flutter_dev_site/src/components/pages/learning_resource_filters.dart' + as prefix16; +import 'package:docs_flutter_dev_site/src/components/pages/learning_resource_filters_sidebar.dart' + as prefix17; +import 'package:docs_flutter_dev_site/src/components/tutorial/client/quiz.dart' + as prefix18; +import 'package:jaspr_content/components/file_tree.dart' as prefix19; /// Default [JasprOptions] for use with your jaspr project. /// @@ -61,101 +67,128 @@ JasprOptions get defaultJasprOptions => JasprOptions( 'src/client/global_scripts', ), - prefix1.CookieNotice: ClientTarget( + prefix1.ApiLinkTooltip: ClientTarget( + 'src/components/common/client/api_link_tooltip', + params: _prefix1ApiLinkTooltip, + ), + + prefix2.CookieNotice: ClientTarget( 'src/components/common/client/cookie_notice', ), - prefix2.CopyButton: ClientTarget( + prefix3.CopyButton: ClientTarget( 'src/components/common/client/copy_button', - params: _prefix2CopyButton, + params: _prefix3CopyButton, ), - prefix3.DownloadLatestButton: ClientTarget( + prefix4.DownloadLatestButton: ClientTarget( 'src/components/common/client/download_latest_button', - params: _prefix3DownloadLatestButton, + params: _prefix4DownloadLatestButton, ), - prefix4.FeedbackComponent: ClientTarget( + prefix5.FeedbackComponent: ClientTarget( 'src/components/common/client/feedback', - params: _prefix4FeedbackComponent, + params: _prefix5FeedbackComponent, ), - prefix5.OnThisPageButton: ClientTarget( + prefix6.OnThisPageButton: ClientTarget( 'src/components/common/client/on_this_page_button', ), - prefix6.OsSelector: ClientTarget( + prefix7.OsSelector: ClientTarget( 'src/components/common/client/os_selector', ), - prefix7.DartPadInjector: ClientTarget( + prefix8.SimpleTooltip: ClientTarget( + 'src/components/common/client/simple_tooltip', + params: _prefix8SimpleTooltip, + ), + + prefix9.DartPadInjector: ClientTarget( 'src/components/dartpad/dartpad_injector', - params: _prefix7DartPadInjector, + params: _prefix9DartPadInjector, + ), + + prefix10.PageNav: ClientTarget( + 'src/components/layout/client/pagenav', + params: _prefix10PageNav, ), - prefix8.MenuToggle: ClientTarget( + prefix11.MenuToggle: ClientTarget( 'src/components/layout/menu_toggle', ), - prefix9.SiteSwitcher: ClientTarget( + prefix12.SiteSwitcher: ClientTarget( 'src/components/layout/site_switcher', ), - prefix10.ThemeSwitcher: ClientTarget( + prefix13.ThemeSwitcher: ClientTarget( 'src/components/layout/theme_switcher', ), - prefix11.ArchiveTable: ClientTarget( + prefix14.ArchiveTable: ClientTarget( 'src/components/pages/archive_table', - params: _prefix11ArchiveTable, + params: _prefix14ArchiveTable, ), - prefix12.GlossarySearchSection: - ClientTarget( + prefix15.GlossarySearchSection: + ClientTarget( 'src/components/pages/glossary_search_section', ), - prefix13.LearningResourceFilters: - ClientTarget( + prefix16.LearningResourceFilters: + ClientTarget( 'src/components/pages/learning_resource_filters', ), - prefix14.LearningResourceFiltersSidebar: - ClientTarget( + prefix17.LearningResourceFiltersSidebar: + ClientTarget( 'src/components/pages/learning_resource_filters_sidebar', ), - prefix15.InteractiveQuiz: ClientTarget( + prefix18.InteractiveQuiz: ClientTarget( 'src/components/tutorial/client/quiz', - params: _prefix15InteractiveQuiz, + params: _prefix18InteractiveQuiz, ), }, - styles: () => [...prefix16.FileTree.styles], + styles: () => [...prefix19.FileTree.styles], ); -Map _prefix2CopyButton(prefix2.CopyButton c) => { +Map _prefix1ApiLinkTooltip(prefix1.ApiLinkTooltip c) => { + 'url': c.url, + 'text': c.text, +}; +Map _prefix3CopyButton(prefix3.CopyButton c) => { 'toCopy': c.toCopy, 'buttonText': c.buttonText, 'classes': c.classes, 'title': c.title, }; -Map _prefix3DownloadLatestButton( - prefix3.DownloadLatestButton c, +Map _prefix4DownloadLatestButton( + prefix4.DownloadLatestButton c, ) => {'os': c.os, 'arch': c.arch}; -Map _prefix4FeedbackComponent(prefix4.FeedbackComponent c) => { +Map _prefix5FeedbackComponent(prefix5.FeedbackComponent c) => { 'issueUrl': c.issueUrl, }; -Map _prefix7DartPadInjector(prefix7.DartPadInjector c) => { +Map _prefix8SimpleTooltip(prefix8.SimpleTooltip c) => { + 'target': c.target.toId(), + 'content': c.content.toId(), +}; +Map _prefix9DartPadInjector(prefix9.DartPadInjector c) => { 'title': c.title, 'theme': c.theme, 'height': c.height, 'runAutomatically': c.runAutomatically, }; -Map _prefix11ArchiveTable(prefix11.ArchiveTable c) => { +Map _prefix10PageNav(prefix10.PageNav c) => { + 'title': c.title, + 'content': c.content.toId(), +}; +Map _prefix14ArchiveTable(prefix14.ArchiveTable c) => { 'os': c.os, 'channel': c.channel, }; -Map _prefix15InteractiveQuiz(prefix15.InteractiveQuiz c) => { +Map _prefix18InteractiveQuiz(prefix18.InteractiveQuiz c) => { 'title': c.title, 'questions': c.questions.map((i) => i.toJson()).toList(), }; diff --git a/site/lib/main.dart b/site/lib/main.dart index af8dd804e06..2b41724cdb7 100644 --- a/site/lib/main.dart +++ b/site/lib/main.dart @@ -21,6 +21,7 @@ import 'src/components/pages/expansion_list.dart'; import 'src/components/pages/learning_resource_index.dart'; import 'src/components/tutorial/progress_ring.dart'; import 'src/components/tutorial/quiz.dart'; +import 'src/components/util/component_ref.dart'; import 'src/extensions/registry.dart'; import 'src/layouts/catalog_page_layout.dart'; import 'src/layouts/doc_layout.dart'; @@ -36,7 +37,7 @@ void main() { // Initializes the server environment with the generated default options. Jaspr.initializeApp(options: defaultJasprOptions); - runApp(_docsFlutterDevSite); + runApp(ComponentRefScope(child: _docsFlutterDevSite)); } Component get _docsFlutterDevSite => ContentApp.custom( diff --git a/site/lib/src/client/global_scripts.dart b/site/lib/src/client/global_scripts.dart index 890d91caa26..b846533361f 100644 --- a/site/lib/src/client/global_scripts.dart +++ b/site/lib/src/client/global_scripts.dart @@ -44,7 +44,6 @@ void _setUpSite() { _setUpExpandableCards(); _setUpPlatformKeys(); _setUpToc(); - _setUpTooltips(); } void _setUpSearchKeybindings() { @@ -322,78 +321,19 @@ void _setUpPlatformKeys() { /// Enables a "back to top" button in the TOC header. void _setUpToc() { _setUpTocActiveObserver(); - _setUpInlineTocDropdown(); } -void _setUpInlineTocDropdown() { - final inlineToc = web.document.getElementById('toc-top'); - if (inlineToc == null) return; - - final dropdownButton = inlineToc.querySelector('.dropdown-button'); - final dropdownMenu = inlineToc.querySelector('.dropdown-content'); - if (dropdownButton == null || dropdownMenu == null) return; - - void closeMenu() { - inlineToc.setAttribute('data-expanded', 'false'); - dropdownButton.ariaExpanded = 'false'; - } - - dropdownButton.addEventListener( - 'click', - ((web.Event _) { - if (inlineToc.getAttribute('data-expanded') == 'true') { - closeMenu(); - } else { - inlineToc.setAttribute('data-expanded', 'true'); - dropdownButton.ariaExpanded = 'true'; - } - }).toJS, - ); - - web.document.addEventListener( - 'keydown', - ((web.KeyboardEvent event) { - if (event.key == 'Escape') { - closeMenu(); - } - }).toJS, - ); - - // Close the dropdown if any link in the TOC is navigated to. - final inlineTocLinks = inlineToc.querySelectorAll('a'); - for (var i = 0; i < inlineTocLinks.length; i++) { - final tocLink = inlineTocLinks.item(i) as web.Element; - tocLink.addEventListener( - 'click', - ((web.Event _) { - closeMenu(); - }).toJS, - ); - } - - // Close the dropdown if anywhere not in the inline TOC is clicked. - web.document.addEventListener( - 'click', - ((web.Event event) { - if ((event.target as web.Element).closest('#toc-top') != null) { - return; - } - closeMenu(); - }).toJS, - ); -} +final ValueNotifier currentPageHeading = ValueNotifier(null); void _setUpTocActiveObserver() { final headings = web.document.querySelectorAll( 'article .header-wrapper, #site-content-title', ); - final currentHeaderText = web.document.getElementById('current-header'); // No need to have toc scrollspy if there is only one non-title heading. - if (headings.length < 2 || currentHeaderText == null) return; + if (headings.length < 2) return; final visibleAnchors = {}; - final initialHeaderText = currentHeaderText.textContent; final observer = web.IntersectionObserver( ((JSArray entries) { @@ -414,12 +354,12 @@ void _setUpTocActiveObserver() { // If the page title is visible, set the current header to its contents. if (visibleAnchors.contains('document-title')) { - currentHeaderText.textContent = initialHeaderText; + currentPageHeading.value = null; isFirst = false; } final tocLinks = web.document.querySelectorAll( - '.site-toc .sidenav-item a', + '.toc-list .sidenav-item a', ); for (var i = 0; i < tocLinks.length; i++) { final tocLink = tocLinks.item(i) as web.Element; @@ -433,7 +373,7 @@ void _setUpTocActiveObserver() { sidenavItem.classList.add('active'); if (isFirst) { - currentHeaderText.textContent = tocLink.textContent; + currentPageHeading.value = tocLink.textContent!; isFirst = false; } } else { @@ -449,93 +389,3 @@ void _setUpTocActiveObserver() { observer.observe(headings.item(i) as web.Element); } } - -void _setUpTooltips() { - final tooltipWrappers = web.document.querySelectorAll('.tooltip-wrapper'); - - final isTouchscreen = web.window.matchMedia('(pointer: coarse)').matches; - - void setup({required bool setUpClickListener}) { - for (var i = 0; i < tooltipWrappers.length; i++) { - final linkWrapper = tooltipWrappers.item(i) as web.HTMLElement; - final target = linkWrapper.querySelector('.tooltip-target'); - final tooltip = linkWrapper.querySelector('.tooltip') as web.HTMLElement?; - - if (target == null || tooltip == null) { - continue; - } - _ensureVisible(tooltip); - - if (setUpClickListener && isTouchscreen) { - // On touchscreen devices, toggle tooltip visibility on tap. - target.addEventListener( - 'click', - ((web.Event e) { - final isVisible = tooltip.classList.contains('visible'); - if (!isVisible) { - tooltip.classList.add('visible'); - e.preventDefault(); - } - }).toJS, - ); - } - } - } - - void closeAll() { - final visibleTooltips = web.document.querySelectorAll( - '.tooltip.visible', - ); - for (var i = 0; i < visibleTooltips.length; i++) { - final tooltip = visibleTooltips.item(i) as web.HTMLElement; - tooltip.classList.remove('visible'); - } - } - - setup(setUpClickListener: true); - - // Reposition tooltips on window resize. - web.EventStreamProviders.resizeEvent.forTarget(web.window).listen((_) { - setup(setUpClickListener: false); - }); - - // Close tooltips when clicking outside of any tooltip wrapper. - web.EventStreamProviders.clickEvent.forTarget(web.document).listen((e) { - if ((e.target as web.Element).closest('.tooltip-wrapper') == null) { - closeAll(); - } - }); - - // On touchscreen devices, close tooltips when scrolling. - if (isTouchscreen) { - web.EventStreamProviders.scrollEvent.forTarget(web.window).listen((_) { - closeAll(); - }); - } -} - -/// Adjust the tooltip position to ensure it is fully inside the -/// ancestor .content element. -void _ensureVisible(web.HTMLElement tooltip) { - final containerRect = tooltip.closest('.content')?.getBoundingClientRect(); - final tooltipRect = tooltip.getBoundingClientRect(); - final offset = double.parse(tooltip.getAttribute('data-adjusted') ?? '0'); - - final tooltipLeft = tooltipRect.left - offset; - final tooltipRight = tooltipRect.right - offset; - final containerLeft = containerRect?.left ?? 0.0; - final containerRight = containerRect?.right ?? web.window.innerWidth; - - if (tooltipLeft < containerLeft) { - final offset = containerLeft - tooltipLeft; - tooltip.style.left = 'calc(50% + ${offset}px)'; - tooltip.dataset['adjusted'] = offset.toString(); - } else if (tooltipRight > containerRight) { - final offset = tooltipRight - containerRight; - tooltip.style.left = 'calc(50% - ${offset}px)'; - tooltip.dataset['adjusted'] = (-offset).toString(); - } else { - tooltip.style.left = '50%'; - tooltip.dataset['adjusted'] = '0'; - } -} diff --git a/site/lib/src/components/common/client/api_link_tooltip.dart b/site/lib/src/components/common/client/api_link_tooltip.dart new file mode 100644 index 00000000000..7bb709c47e3 --- /dev/null +++ b/site/lib/src/components/common/client/api_link_tooltip.dart @@ -0,0 +1,173 @@ +// 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:http/http.dart' as http; +import 'package:jaspr/jaspr.dart'; +import 'package:meta/meta.dart'; +import 'package:universal_web/js_interop.dart'; +import 'package:universal_web/web.dart' as web; + +import '../tooltip.dart'; + +@client +class ApiLinkTooltip extends StatefulComponent { + const ApiLinkTooltip({required this.url, required this.text, super.key}); + + final String url; + final String text; + + @override + State createState() => _ApiLinkTooltipState(); +} + +class _ApiLinkTooltipState extends State { + Component? tooltipContent; + + @override + void initState() { + super.initState(); + + if (kIsWeb) { + setupTooltip(); + } + } + + @awaitNotRequired + Future setupTooltip() async { + final (extractedHeader, extractedDescription) = await scrapeApiDocs( + component.url, + ); + + if (extractedHeader == null && extractedDescription == null) { + return; + } + + if (!mounted) return; + setState(() { + tooltipContent = fragment([ + if (extractedHeader != null) + span(classes: 'tooltip-header', [raw(extractedHeader)]), + if (extractedDescription != null) + span(classes: 'tooltip-content', [raw(extractedDescription)]), + ]); + }); + } + + @override + Component build(BuildContext context) { + return Tooltip( + target: a(href: component.url, [ + code([text(component.text)]), + ]), + content: tooltipContent, + ); + } +} + +const contentId = 'dartdoc-main-content'; +// This seems to be a good limit to avoid overly small or large tooltips. +const maxDescriptionLength = 400; +const minTrailingParagraphLength = 20; + +@awaitNotRequired +Future<(String?, String?)> scrapeApiDocs(String url) async { + try { + final response = await http.get(Uri.parse(url)); + var content = response.body; + + final startIndex = content.indexOf(RegExp(' characters. + // This only removes full paragraphs and does not truncate individual ones. + var charCount = 0; + if (description != null) { + final children = description.childNodes; + var removeFrom = -1; + for (var i = 0; i < children.length; i++) { + final child = children.item(i)!; + + if (child.instanceOfString('HTMLHeadingElement')) { + // Stop at any headings. + removeFrom = i; + break; + } + + if (child.textContent?.startsWith('See also') == true) { + // Stop at "See also" sections. + removeFrom = i; + break; + } + + if (!child.instanceOfString('HTMLParagraphElement')) { + // Skip non-paragraph elements (such as video embeds, code snippets). + description.removeChild(child); + i--; + continue; + } + + charCount += child.textContent?.length ?? 0; + + if (charCount > maxDescriptionLength) { + removeFrom = i; + break; + } + } + + // Remove any extra paragraphs beyond the max characters. + if (removeFrom > 0) { + while (children.length > removeFrom) { + description.removeChild(children.item(children.length - 1)!); + } + + // If the last paragraph is very short, remove it as well. + // This avoids having trailing "See also" or similar. + while (children.length > 1 && + (children.item(children.length - 1)!.textContent?.length ?? 0) < + minTrailingParagraphLength) { + description.removeChild(children.item(children.length - 1)!); + } + } + + // Append a "Read more" link to the full docs. + description.appendChild( + web.document.createElement('a') + ..setAttribute('href', url) + ..textContent = 'Read more.', + ); + } + + return ( + (header?.innerHTML as JSString?)?.toDart, + (description?.innerHTML as JSString?)?.toDart, + ); + } catch (e) { + print('Error fetching API docs for $url: $e'); + return (null, null); + } +} diff --git a/site/lib/src/components/common/client/simple_tooltip.dart b/site/lib/src/components/common/client/simple_tooltip.dart new file mode 100644 index 00000000000..869527f5de5 --- /dev/null +++ b/site/lib/src/components/common/client/simple_tooltip.dart @@ -0,0 +1,28 @@ +// 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 '../../util/component_ref.dart'; +import '../tooltip.dart'; + +@client +class SimpleTooltip extends StatelessComponent { + const SimpleTooltip({ + required this.target, + required this.content, + super.key, + }); + + final ComponentRef target; + final ComponentRef content; + + @override + Component build(BuildContext context) { + return Tooltip( + target: target.component, + content: content.component, + ); + } +} diff --git a/site/lib/src/components/common/dropdown.dart b/site/lib/src/components/common/dropdown.dart index f5ade0574c7..ad301c90ccd 100644 --- a/site/lib/src/components/common/dropdown.dart +++ b/site/lib/src/components/common/dropdown.dart @@ -8,21 +8,24 @@ import 'package:universal_web/web.dart' as web; import '../util/global_event_listener.dart'; -/// The root component of a dropdown in a client component. -/// -/// Should include a [DropdownToggle] and [DropdownContent] -/// as children. +/// A dropdown with a toggle button and expandable content. final class Dropdown extends StatefulComponent { - const Dropdown({required this.id, required this.children}); + const Dropdown({ + required this.id, + required this.toggle, + required this.content, + super.key, + }); final String id; - final List children; + final Component toggle; + final Component content; @override - State createState() => _DropdownState(); + State createState() => DropdownState(); } -final class _DropdownState extends State { +final class DropdownState extends State { bool _expanded = false; void toggle({bool? to}) { @@ -41,93 +44,45 @@ final class _DropdownState extends State { toggle(to: false); } }, - _DropdownRoot( + div( id: component.id, - expanded: _expanded, - toggle: toggle, - child: div( - id: component.id, - classes: 'dropdown', - attributes: {'data-expanded': _expanded.toString()}, - events: { - 'keydown': (e) { - final keydownEvent = e as web.KeyboardEvent; - if (_expanded && keydownEvent.key == 'Escape') { - toggle(to: false); - } + classes: 'dropdown', + attributes: {'data-expanded': _expanded.toString()}, + events: { + 'keydown': (e) { + final keydownEvent = e as web.KeyboardEvent; + if (_expanded && keydownEvent.key == 'Escape') { + toggle(to: false); + } + }, + 'focusout': (e) { + final relatedTarget = + (e as web.FocusEvent).relatedTarget as web.HTMLElement?; + if (relatedTarget == null || + relatedTarget.closest('#${component.id}') == null) { + toggle(to: false); + } + }, + }, + [ + Component.wrapElement( + classes: 'dropdown-button', + events: { + 'click': (e) { + toggle(); + }, }, - 'focusout': (e) { - final relatedTarget = - (e as web.FocusEvent).relatedTarget as web.HTMLElement?; - if (relatedTarget != null && - relatedTarget.closest('#${component.id}') == null) { - toggle(to: false); - } + attributes: { + 'aria-controls': '${component.id}-content', + 'aria-expanded': _expanded.toString(), }, - }, - component.children, - ), + child: component.toggle, + ), + div(id: '${component.id}-content', classes: 'dropdown-content', [ + component.content, + ]), + ], ), ); } } - -final class DropdownToggle extends StatelessComponent { - const DropdownToggle(this.child); - - final Component child; - - @override - Component build(BuildContext context) { - final root = _DropdownRoot.of(context); - - return Component.wrapElement( - child: child, - classes: 'dropdown-button', - events: { - 'click': (e) { - root.toggle(); - }, - }, - attributes: { - 'aria-controls': root.contentId, - 'aria-expanded': root.expanded.toString(), - }, - ); - } -} - -final class DropdownContent extends StatelessComponent { - const DropdownContent(this.child); - - final Component child; - - @override - Component build(BuildContext context) => div( - id: _DropdownRoot.of(context).contentId, - classes: 'dropdown-content', - [child], - ); -} - -final class _DropdownRoot extends InheritedComponent { - const _DropdownRoot({ - required this.id, - required this.toggle, - this.expanded = false, - required super.child, - }); - - final String id; - final bool expanded; - final void Function({bool? to}) toggle; - - String get contentId => '$id-content'; - - @override - bool updateShouldNotify(_DropdownRoot oldRoot) => - expanded != oldRoot.expanded; - - static _DropdownRoot of(BuildContext context) => - context.dependOnInheritedComponentOfExactType<_DropdownRoot>()!; -} diff --git a/site/lib/src/components/common/tooltip.dart b/site/lib/src/components/common/tooltip.dart new file mode 100644 index 00000000000..630ed3b4338 --- /dev/null +++ b/site/lib/src/components/common/tooltip.dart @@ -0,0 +1,149 @@ +// 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:universal_web/web.dart' as web; + +import '../../util.dart'; +import '../util/global_event_listener.dart'; + +class Tooltip extends StatefulComponent { + const Tooltip({ + required this.target, + required this.content, + super.key, + }); + + final Component target; + final Component? content; + + @override + State createState() => _TooltipState(); +} + +class _TooltipState extends State { + static final isTouchscreen = + kIsWeb && web.window.matchMedia('(pointer: coarse)').matches; + + final wrapperKey = GlobalNodeKey(); + final targetKey = GlobalNodeKey(); + final tooltipKey = GlobalNodeKey(); + + bool isVisible = false; + double tooltipOffset = 0.0; + + @override + void initState() { + super.initState(); + + if (kIsWeb) { + setupTooltip(); + } + } + + void setupTooltip() { + context.binding.addPostFrameCallback(ensureVisible); + + // Reposition tooltips on window resize. + web.EventStreamProviders.resizeEvent.forTarget(web.window).listen((_) { + ensureVisible(); + }); + } + + /// Adjust the tooltip position to ensure it is fully inside the + /// ancestor .content element. + void ensureVisible() { + final target = targetKey.currentNode; + final tooltip = tooltipKey.currentNode; + if (tooltip == null || target == null) return; + + setState(() { + tooltipOffset = calculateTooltipOffset(target, tooltip); + }); + } + + @override + Component build(BuildContext context) { + return span( + key: wrapperKey, + classes: 'tooltip-wrapper', + [ + span( + key: targetKey, + classes: 'tooltip-target', + events: { + if (isTouchscreen) + 'click': (e) { + if (!isVisible) { + setState(() => isVisible = true); + e.preventDefault(); + } + }, + }, + [component.target], + ), + if (component.content case final content?) + GlobalEventListener( + // Close tooltip when clicking outside of this wrapper. + onClick: isTouchscreen + ? (e) { + if (wrapperKey.currentNode?.contains( + e.target as web.Node?, + ) == + true) { + return; + } + setState(() => isVisible = false); + } + : null, + // On touchscreen devices, close tooltips when scrolling. + onScroll: isTouchscreen + ? (_) { + setState(() => isVisible = false); + } + : null, + span( + key: tooltipKey, + classes: ['tooltip', if (isVisible) 'visible'].toClasses, + styles: Styles( + raw: { + 'left': tooltipOffset == 0 + ? '50%' + : tooltipOffset > 0 + ? 'calc(50% + ${tooltipOffset}px)' + : 'calc(50% - ${tooltipOffset.abs()}px)', + }, + ), + [ + content, + ], + ), + ), + ], + ); + } +} + +double calculateTooltipOffset(web.HTMLElement target, web.HTMLElement tooltip) { + final targetRect = target.getBoundingClientRect(); + final tooltipRect = tooltip.getBoundingClientRect(); + final containerRect = tooltip.closest('.content')?.getBoundingClientRect(); + + final targetCenter = targetRect.left + (targetRect.width / 2); + final tooltipWidth = tooltipRect.width; + + final initialLeft = targetCenter - (tooltipWidth / 2); + final initialRight = targetCenter + (tooltipWidth / 2); + + final containerLeft = containerRect?.left ?? 0.0; + final containerRight = containerRect?.right ?? web.window.innerWidth; + + if (initialLeft < containerLeft) { + return containerLeft - initialLeft; + } else if (initialRight > containerRight) { + return containerRight - initialRight; + } else { + return 0; + } +} diff --git a/site/lib/src/components/dartpad/dartpad_injector.dart b/site/lib/src/components/dartpad/dartpad_injector.dart index f9545c4dae2..d36224ce8c6 100644 --- a/site/lib/src/components/dartpad/dartpad_injector.dart +++ b/site/lib/src/components/dartpad/dartpad_injector.dart @@ -3,10 +3,9 @@ // found in the LICENSE file. import 'package:jaspr/jaspr.dart'; +import '../util/retake_element.dart'; import 'embedded_dartpad.dart'; -import 'extract_content.dart' if (dart.library.io) 'extract_content_vm.dart'; - /// Prepares a code block that will be replaced with an embedded /// DartPad when the site is loaded. final class DartPadWrapper extends StatefulComponent { @@ -79,7 +78,16 @@ class _DartPadInjectorState extends State { if (kIsWeb) { // During hydration, extract the content from the pre-rendered code block. - content = extractContent(context as Element); + final elem = retakeElement(context, (elem) { + return elem.tagName.toLowerCase() == 'pre'; + }); + + if (elem == null) { + content = ''; + } else { + elem.parentNode?.removeChild(elem); + content = elem.textContent ?? ''; + } } } diff --git a/site/lib/src/components/dartpad/extract_content.dart b/site/lib/src/components/dartpad/extract_content.dart deleted file mode 100644 index 433eab54e30..00000000000 --- a/site/lib/src/components/dartpad/extract_content.dart +++ /dev/null @@ -1,25 +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 'dart:js_interop'; - -import 'package:jaspr/browser.dart'; -import 'package:universal_web/web.dart' as web; - -/// Extracts the content of a
 block inside the given
-/// [element] during hydration.
-String extractContent(Element element) {
-  final r = element.parentRenderObjectElement?.renderObject as DomRenderObject?;
-  if (r == null) return '';
-
-  final code = r.retakeNode((node) {
-    return node.instanceOfString('Element') &&
-        (node as web.Element).tagName.toLowerCase() == 'pre';
-  });
-
-  if (code == null) return '';
-
-  code.parentNode?.removeChild(code);
-  return (code as web.Element).textContent ?? '';
-}
diff --git a/site/lib/src/components/layout/client/pagenav.dart b/site/lib/src/components/layout/client/pagenav.dart
new file mode 100644
index 00000000000..18737c4b68a
--- /dev/null
+++ b/site/lib/src/components/layout/client/pagenav.dart
@@ -0,0 +1,89 @@
+// 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:universal_web/js_interop.dart';
+import 'package:universal_web/web.dart' as web;
+
+import '../../../client/global_scripts.dart';
+import '../../common/dropdown.dart';
+import '../../common/material_icon.dart';
+import '../../util/component_ref.dart';
+
+@client
+class PageNav extends StatefulComponent {
+  const PageNav({
+    required this.title,
+    required this.content,
+    super.key,
+  });
+
+  final String title;
+  final ComponentRef content;
+
+  @override
+  State createState() => _PageNavState();
+}
+
+class _PageNavState extends State {
+  final dropdownKey = GlobalStateKey();
+
+  @override
+  void initState() {
+    super.initState();
+
+    if (kIsWeb) {
+      context.binding.addPostFrameCallback(() {
+        // Close the dropdown if any link in the TOC is navigated to.
+        final inlineTocLinks = web.document.querySelectorAll(
+          '#pagenav-content a',
+        );
+        for (var i = 0; i < inlineTocLinks.length; i++) {
+          final tocLink = inlineTocLinks.item(i) as web.Element;
+          tocLink.addEventListener(
+            'click',
+            ((web.Event _) {
+              dropdownKey.currentState?.toggle(to: false);
+            }).toJS,
+          );
+        }
+      });
+    }
+  }
+
+  @override
+  Component build(BuildContext context) {
+    return Dropdown(
+      key: dropdownKey,
+      id: 'pagenav',
+      toggle: button(
+        attributes: {
+          'title': 'Toggle the table of contents dropdown',
+          'aria-label': 'Toggle the table of contents dropdown',
+        },
+        [
+          span(classes: 'toc-intro', [
+            const MaterialIcon('list'),
+            span(
+              attributes: {'aria-label': 'On this page'},
+              [
+                text('On this page'),
+              ],
+            ),
+          ]),
+          span(classes: 'toc-current', [
+            const MaterialIcon('chevron_right'),
+            ValueListenableBuilder(
+              listenable: currentPageHeading,
+              builder: (context, value) {
+                return span([text(value ?? component.title)]);
+              },
+            ),
+          ]),
+        ],
+      ),
+      content: component.content.component,
+    );
+  }
+}
diff --git a/site/lib/src/components/layout/site_switcher.dart b/site/lib/src/components/layout/site_switcher.dart
index 311b35f2bbc..606b263501c 100644
--- a/site/lib/src/components/layout/site_switcher.dart
+++ b/site/lib/src/components/layout/site_switcher.dart
@@ -13,66 +13,62 @@ final class SiteSwitcher extends StatelessComponent {
   const SiteSwitcher();
 
   @override
-  Component build(BuildContext _) => Dropdown(
-    id: 'site-switcher',
-    children: [
-      const DropdownToggle(Button(icon: 'apps', title: 'Visit related sites.')),
-      DropdownContent(
-        nav(
-          classes: 'dropdown-menu',
-          attributes: {
-            'role': 'menu',
-          },
-          [
-            ul(
-              const [
-                _SiteWordMarkListEntry(
-                  name: 'Flutter',
-                  href: 'https://flutter.dev',
-                ),
-                _SiteWordMarkListEntry(
-                  name: 'Flutter',
-                  subtype: 'Docs',
-                  href: '/',
-                  current: true,
-                ),
-                _SiteWordMarkListEntry(
-                  name: 'Flutter',
-                  subtype: 'API',
-                  href: 'https://api.flutter.dev',
-                ),
-                _SiteWordMarkListEntry(
-                  name: 'Flutter',
-                  subtype: 'Blog',
-                  href: 'https://blog.flutter.dev',
-                ),
-                Component.element(
-                  tag: 'li',
-                  classes: 'dropdown-divider',
-                  attributes: {'aria-hidden': 'true', 'role': 'separator'},
-                ),
-                _SiteWordMarkListEntry(
-                  name: 'Dart',
-                  href: 'https://dart.dev',
-                  dart: true,
-                ),
-                _SiteWordMarkListEntry(
-                  name: 'DartPad',
-                  href: 'https://dartpad.dev',
-                  dart: true,
-                ),
-                _SiteWordMarkListEntry(
-                  name: 'pub.dev',
-                  href: 'https://pub.dev',
-                  dart: true,
-                ),
-              ],
-            ),
-          ],
-        ),
+  Component build(BuildContext _) {
+    return Dropdown(
+      id: 'site-switcher',
+      toggle: const Button(icon: 'apps', title: 'Visit related sites.'),
+      content: nav(
+        classes: 'dropdown-menu',
+        attributes: {'role': 'menu'},
+        [
+          ul(
+            const [
+              _SiteWordMarkListEntry(
+                name: 'Flutter',
+                href: 'https://flutter.dev',
+              ),
+              _SiteWordMarkListEntry(
+                name: 'Flutter',
+                subtype: 'Docs',
+                href: '/',
+                current: true,
+              ),
+              _SiteWordMarkListEntry(
+                name: 'Flutter',
+                subtype: 'API',
+                href: 'https://api.flutter.dev',
+              ),
+              _SiteWordMarkListEntry(
+                name: 'Flutter',
+                subtype: 'Blog',
+                href: 'https://blog.flutter.dev',
+              ),
+              Component.element(
+                tag: 'li',
+                classes: 'dropdown-divider',
+                attributes: {'aria-hidden': 'true', 'role': 'separator'},
+              ),
+              _SiteWordMarkListEntry(
+                name: 'Dart',
+                href: 'https://dart.dev',
+                dart: true,
+              ),
+              _SiteWordMarkListEntry(
+                name: 'DartPad',
+                href: 'https://dartpad.dev',
+                dart: true,
+              ),
+              _SiteWordMarkListEntry(
+                name: 'pub.dev',
+                href: 'https://pub.dev',
+                dart: true,
+              ),
+            ],
+          ),
+        ],
       ),
-    ],
-  );
+    );
+  }
 }
 
 class _SiteWordMarkListEntry extends StatelessComponent {
diff --git a/site/lib/src/components/layout/theme_switcher.dart b/site/lib/src/components/layout/theme_switcher.dart
index 65c53445d67..3b077ea0e2b 100644
--- a/site/lib/src/components/layout/theme_switcher.dart
+++ b/site/lib/src/components/layout/theme_switcher.dart
@@ -81,30 +81,25 @@ final class _ThemeSwitcherState extends State {
   }
 
   @override
-  Component build(BuildContext _) => Dropdown(
-    id: 'theme-switcher',
-    children: [
-      const DropdownToggle(Button(icon: 'routine', title: 'Select a theme.')),
-      DropdownContent(
-        div(
-          classes: 'dropdown-menu',
+  Component build(BuildContext _) {
+    return Dropdown(
+      id: 'theme-switcher',
+      toggle: const Button(icon: 'routine', title: 'Select a theme.'),
+      content: div(classes: 'dropdown-menu', [
+        ul(
+          attributes: {'role': 'listbox'},
           [
-            ul(
-              attributes: {'role': 'listbox'},
-              [
-                for (final mode in _Theme.values)
-                  _ThemeButtonEntry(
-                    mode: mode,
-                    selected: _currentTheme == mode,
-                    setMode: _setTheme,
-                  ),
-              ],
-            ),
+            for (final mode in _Theme.values)
+              _ThemeButtonEntry(
+                mode: mode,
+                selected: _currentTheme == mode,
+                setMode: _setTheme,
+              ),
           ],
         ),
-      ),
-    ],
-  );
+      ]),
+    );
+  }
 }
 
 final class _ThemeButtonEntry extends StatelessComponent {
diff --git a/site/lib/src/components/layout/toc.dart b/site/lib/src/components/layout/toc.dart
index 3721ff1d66c..4b0d5c50634 100644
--- a/site/lib/src/components/layout/toc.dart
+++ b/site/lib/src/components/layout/toc.dart
@@ -7,77 +7,54 @@ import 'package:jaspr/jaspr.dart';
 import '../../models/on_this_page_model.dart';
 import '../common/client/on_this_page_button.dart';
 import '../common/material_icon.dart';
+import '../util/component_ref.dart';
+import 'client/pagenav.dart';
 
-final class WideTableOfContents extends StatelessComponent {
-  const WideTableOfContents(this.data);
+final class DashTableOfContents extends StatelessComponent {
+  const DashTableOfContents(this.data);
 
   final OnThisPageData data;
 
   @override
   Component build(BuildContext _) {
-    return nav(id: 'toc-side', classes: 'site-toc', [
+    return nav(id: 'toc-side', [
       const OnThisPageButton(),
       _TocContents(data),
     ]);
   }
-}
-
-final class NarrowTableOfContents extends StatelessComponent {
-  const NarrowTableOfContents(
-    this.data, {
-    required this.currentTitle,
-  });
 
-  final OnThisPageData data;
-  final String currentTitle;
-
-  @override
-  Component build(BuildContext _) {
-    return div(id: 'toc-top', classes: 'site-toc dropdown', [
-      button(
-        classes: 'dropdown-button',
-        attributes: {
-          'title': 'Toggle the table of contents dropdown',
-          'aria-expanded': 'false',
-          'aria-controls': 'toc-dropdown',
-          'aria-label': 'Toggle the table of contents dropdown',
-        },
-        [
-          span(classes: 'toc-intro', [
-            const MaterialIcon('list'),
-            span(
-              attributes: {'aria-label': 'On this page'},
-              [
-                text('On this page'),
-              ],
-            ),
-          ]),
-          span(classes: 'toc-current', [
-            const MaterialIcon('chevron_right'),
-            span(id: 'current-header', [text(currentTitle)]),
-          ]),
-        ],
-      ),
-      div(id: 'toc-dropdown', classes: 'dropdown-content', [
-        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'},
-          [],
-        ),
-        nav(
-          attributes: {'role': 'menu'},
-          [_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'},
+                [],
+              ),
+              nav(
+                attributes: {'role': 'menu'},
+                [_TocContents(data)],
+              ),
+            ]),
+          ),
+        );
+      },
+    );
   }
 }
 
@@ -88,7 +65,7 @@ final class _TocContents extends StatelessComponent {
 
   @override
   Component build(BuildContext _) => ul(
-    classes: 'styled-toc-list',
+    classes: 'toc-list',
     _buildEntries(data.topLevelEntries, 0),
   );
 
diff --git a/site/lib/src/components/util/component_ref.dart b/site/lib/src/components/util/component_ref.dart
new file mode 100644
index 00000000000..f4a0c499d03
--- /dev/null
+++ b/site/lib/src/components/util/component_ref.dart
@@ -0,0 +1,102 @@
+// 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:nanoid2/nanoid2.dart';
+
+import 'retake_element.dart';
+
+/// A wrapper around [Component] to make it usable across server/client boundaries.
+///
+/// This is a temporary (and limited) solution until server components have
+/// landed in Jaspr. They enable passing components to @client components
+/// directly, by creating a unique ID on the server and retaking the dom node
+/// on the client.
+///
+/// On the server, wrap your component with `context.ref(yourComponent)`, and
+/// pass the resulting [ComponentRef] to your @client component.
+/// On the client, retrieve the original component by calling `myRef.component`.
+class ComponentRef {
+  const ComponentRef._(this.id);
+
+  final String id;
+
+  Component get component {
+    return Builder(
+      builder: (context) {
+        if (!kIsWeb) {
+          final scope =
+              context
+                      .getElementForInheritedComponentOfExactType<
+                        ComponentRefScope
+                      >()
+                  as _ComponentRefScopeElement?;
+          return Component.wrapElement(
+            id: id,
+            child: scope!.getComponentById(id),
+          );
+        } else {
+          final elem = retakeElement(context, (elem) => elem.id == id);
+          assert(elem != null, 'Element with id "$id" not found');
+          return wrapNode(elem!);
+        }
+      },
+    );
+  }
+
+  @decoder
+  factory ComponentRef.fromId(String id) {
+    return ComponentRef._(id);
+  }
+
+  @encoder
+  String toId() => id;
+}
+
+extension ComponentRefExtension on BuildContext {
+  /// Wraps a [Component] in a [ComponentRef] for use in @client components.
+  ComponentRef ref(Component child) {
+    final scope =
+        getElementForInheritedComponentOfExactType()
+            as _ComponentRefScopeElement?;
+    assert(scope != null, 'No ComponentRefScope found in context');
+    final ref = scope!.register(child);
+    return ref;
+  }
+}
+
+/// A scope for registering and retrieving component references.
+///
+/// This should wrap your entire app, typically in `main.dart`.
+class ComponentRefScope extends InheritedComponent {
+  const ComponentRefScope({
+    required super.child,
+  });
+
+  @override
+  bool updateShouldNotify(ComponentRefScope oldComponent) {
+    return false;
+  }
+
+  @override
+  InheritedElement createElement() => _ComponentRefScopeElement(this);
+}
+
+class _ComponentRefScopeElement extends InheritedElement {
+  _ComponentRefScopeElement(ComponentRefScope super.component);
+
+  final Map _registeredComponents = {};
+
+  Component getComponentById(String id) {
+    final component = _registeredComponents[id];
+    assert(component != null, 'No component registered with id "$id"');
+    return component!;
+  }
+
+  ComponentRef register(Component child) {
+    final id = 'ref-${nanoid(length: 8)}';
+    _registeredComponents[id] = child;
+    return ComponentRef._(id);
+  }
+}
diff --git a/site/lib/src/components/util/global_event_listener.dart b/site/lib/src/components/util/global_event_listener.dart
index d761217ee7f..50292859d77 100644
--- a/site/lib/src/components/util/global_event_listener.dart
+++ b/site/lib/src/components/util/global_event_listener.dart
@@ -8,11 +8,18 @@ import 'package:jaspr/jaspr.dart';
 import 'package:universal_web/web.dart' as web;
 
 final class GlobalEventListener extends StatefulComponent {
-  const GlobalEventListener(this.child, {this.onClick, this.onKeyDown});
+  const GlobalEventListener(
+    this.child, {
+    this.onClick,
+    this.onKeyDown,
+    this.onScroll,
+    super.key,
+  });
 
   final Component child;
   final void Function(web.MouseEvent)? onClick;
   final void Function(web.KeyboardEvent)? onKeyDown;
+  final void Function(web.Event)? onScroll;
 
   @override
   State createState() => _GlobalClickListenerState();
@@ -21,6 +28,7 @@ final class GlobalEventListener extends StatefulComponent {
 class _GlobalClickListenerState extends State {
   StreamSubscription? _clickSubscription;
   StreamSubscription? _keyDownSubscription;
+  StreamSubscription? _scrollSubscription;
 
   @override
   void initState() {
@@ -37,6 +45,11 @@ class _GlobalClickListenerState extends State {
             .forTarget(web.document)
             .listen(onKeyDown);
       }
+      if (component.onScroll case final onScroll?) {
+        _scrollSubscription = web.EventStreamProviders.scrollEvent
+            .forTarget(web.document)
+            .listen(onScroll);
+      }
     }
   }
 
@@ -44,6 +57,7 @@ class _GlobalClickListenerState extends State {
   void dispose() {
     unawaited(_clickSubscription?.cancel());
     unawaited(_keyDownSubscription?.cancel());
+    unawaited(_scrollSubscription?.cancel());
     super.dispose();
   }
 
diff --git a/site/lib/src/components/dartpad/extract_content_vm.dart b/site/lib/src/components/util/retake_element.dart
similarity index 58%
rename from site/lib/src/components/dartpad/extract_content_vm.dart
rename to site/lib/src/components/util/retake_element.dart
index 80b59939c22..b025116df58 100644
--- a/site/lib/src/components/dartpad/extract_content_vm.dart
+++ b/site/lib/src/components/util/retake_element.dart
@@ -2,7 +2,4 @@
 // 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';
-
-// Stub for non-web platforms.
-String extractContent(BuildContext context) => '';
+export 'retake_element_web.dart' if (dart.library.io) 'retake_element_vm.dart';
diff --git a/site/lib/src/components/util/retake_element_vm.dart b/site/lib/src/components/util/retake_element_vm.dart
new file mode 100644
index 00000000000..775262eedd1
--- /dev/null
+++ b/site/lib/src/components/util/retake_element_vm.dart
@@ -0,0 +1,17 @@
+// 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:universal_web/web.dart' as web;
+
+web.Element? retakeElement(
+  BuildContext context,
+  bool Function(web.Element element) predicate,
+) {
+  throw UnimplementedError();
+}
+
+Component wrapNode(web.Node node) {
+  throw UnimplementedError();
+}
diff --git a/site/lib/src/components/util/retake_element_web.dart b/site/lib/src/components/util/retake_element_web.dart
new file mode 100644
index 00000000000..8ff7e84efb3
--- /dev/null
+++ b/site/lib/src/components/util/retake_element_web.dart
@@ -0,0 +1,25 @@
+// 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/browser.dart';
+// ignore: implementation_imports
+import 'package:jaspr/src/foundation/type_checks.dart';
+import 'package:universal_web/web.dart' as web;
+
+/// Retakes the element matching [predicate] during hydration.
+web.Element? retakeElement(
+  BuildContext context,
+  bool Function(web.Element element) predicate,
+) {
+  final r = (context as Element).parentRenderObjectElement?.renderObject;
+  if (r == null) return null;
+  final node = (r as DomRenderObject).retakeNode((node) {
+    return node.isElement && predicate(node as web.Element);
+  });
+  return node as web.Element?;
+}
+
+Component wrapNode(web.Node node) {
+  return RawNode(node);
+}
diff --git a/site/lib/src/extensions/api_link_processor.dart b/site/lib/src/extensions/api_link_processor.dart
new file mode 100644
index 00000000000..5a2ce8acbef
--- /dev/null
+++ b/site/lib/src/extensions/api_link_processor.dart
@@ -0,0 +1,51 @@
+// 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 '../components/common/client/api_link_tooltip.dart';
+
+/// A node-processing, page extension for Jaspr Content that looks for links to
+/// the flutter API docs and enhances them with interactive tooltips.
+class ApiLinkProcessor implements PageExtension {
+  const ApiLinkProcessor();
+
+  @override
+  Future> apply(Page page, List nodes) async {
+    return _processNodes(nodes);
+  }
+
+  List _processNodes(List nodes) {
+    final processedNodes = [];
+
+    for (var i = 0; i < nodes.length; i++) {
+      final node = nodes[i];
+
+      if (node case ElementNode(
+        tag: 'a',
+        attributes: {'href': final href},
+      ) when href.startsWith('https://api.flutter.dev/')) {
+        if (node.children case [
+          ElementNode(tag: 'code', children: [TextNode(:final text)]),
+        ]) {
+          // Only enable the tooltip for links that contain a code element.
+          processedNodes.add(
+            ComponentNode(ApiLinkTooltip(url: href, text: text)),
+          );
+          continue;
+        } else {
+          processedNodes.add(node);
+        }
+      } else if (node is ElementNode && node.children != null) {
+        processedNodes.add(
+          ElementNode(node.tag, node.attributes, _processNodes(node.children!)),
+        );
+      } else {
+        processedNodes.add(node);
+      }
+    }
+
+    return processedNodes;
+  }
+}
diff --git a/site/lib/src/extensions/glossary_link_processor.dart b/site/lib/src/extensions/glossary_link_processor.dart
index 6d77acf7fda..fb688574fe4 100644
--- a/site/lib/src/extensions/glossary_link_processor.dart
+++ b/site/lib/src/extensions/glossary_link_processor.dart
@@ -5,8 +5,9 @@
 import 'package:jaspr/jaspr.dart';
 import 'package:jaspr_content/jaspr_content.dart';
 
+import '../components/common/client/simple_tooltip.dart';
+import '../components/util/component_ref.dart';
 import '../pages/glossary.dart';
-import '../util.dart';
 
 /// A node-processing, page extension for Jaspr Content that looks for links to
 /// glossary entries and enhances them with interactive glossary tooltips.
@@ -38,20 +39,23 @@ class GlossaryLinkProcessor implements PageExtension {
           continue;
         }
 
+        final target = a(
+          href: node.attributes['href'] ?? '',
+          attributes: node.attributes,
+          [const NodesBuilder([]).build(node.children)],
+        );
+        final content = GlossaryTooltipContent(entry: entry);
+
         processedNodes.add(
-          ElementNode(
-            'span',
-            {'class': 'tooltip-wrapper'},
-            [
-              ElementNode('a', {
-                ...node.attributes,
-                'class': [
-                  ?node.attributes['class'],
-                  'tooltip-target',
-                ].toClasses,
-              }, node.children),
-              ComponentNode(GlossaryTooltip(entry: entry)),
-            ],
+          ComponentNode(
+            Builder(
+              builder: (context) {
+                return SimpleTooltip(
+                  target: context.ref(target),
+                  content: context.ref(content),
+                );
+              },
+            ),
           ),
         );
       } else if (node is ElementNode && node.children != null) {
@@ -71,18 +75,17 @@ class GlossaryLinkProcessor implements PageExtension {
   }
 }
 
-class GlossaryTooltip extends StatelessComponent {
-  const GlossaryTooltip({required this.entry});
+class GlossaryTooltipContent extends StatelessComponent {
+  const GlossaryTooltipContent({required this.entry});
 
   final GlossaryEntry entry;
 
   @override
   Component build(BuildContext context) {
-    return span(classes: 'tooltip', [
+    return span(classes: 'tooltip-content', [
       span(classes: 'tooltip-header', [text(entry.term)]),
-      span(classes: 'tooltip-content', [
-        text(entry.shortDescription),
-        text(' '),
+      span([
+        text('${entry.shortDescription} '),
         a(
           href: '/resources/glossary#${entry.id}',
           attributes: {
diff --git a/site/lib/src/extensions/registry.dart b/site/lib/src/extensions/registry.dart
index dc65b250513..468580bcc3a 100644
--- a/site/lib/src/extensions/registry.dart
+++ b/site/lib/src/extensions/registry.dart
@@ -4,6 +4,7 @@
 
 import 'package:jaspr_content/jaspr_content.dart';
 
+import 'api_link_processor.dart';
 import 'attribute_processor.dart';
 import 'code_block_processor.dart';
 import 'glossary_link_processor.dart';
@@ -20,4 +21,5 @@ const List allNodeProcessingExtensions = [
   TableWrapperExtension(),
   CodeBlockProcessor(),
   GlossaryLinkProcessor(),
+  ApiLinkProcessor(),
 ];
diff --git a/site/lib/src/layouts/doc_layout.dart b/site/lib/src/layouts/doc_layout.dart
index e4cddf58918..a15a3a0b038 100644
--- a/site/lib/src/layouts/doc_layout.dart
+++ b/site/lib/src/layouts/doc_layout.dart
@@ -42,10 +42,12 @@ class DocLayout extends FlutterDocsLayout {
           if (tocData == null)
             const Document.body(attributes: {'data-toc': 'false'})
           else
-            NarrowTableOfContents(
-              tocData,
-              currentTitle: pageTitle,
-            ),
+            div(id: 'site-subheader', [
+              DashTableOfContents.asDropdown(
+                tocData,
+                currentTitle: pageTitle,
+              ),
+            ]),
           if (showBanner)
             if (siteData['bannerHtml'] case final String bannerHtml
                 when bannerHtml.trim().isNotEmpty)
@@ -53,7 +55,7 @@ class DocLayout extends FlutterDocsLayout {
           div(classes: 'after-leading-content', [
             if (tocData != null)
               aside(id: 'side-menu', [
-                WideTableOfContents(tocData),
+                DashTableOfContents(tocData),
               ]),
             article([
               div(id: 'site-content-title', [
diff --git a/site/lib/src/style_hash.dart b/site/lib/src/style_hash.dart
index 9e83601e86f..88d8554f68a 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 = 'Eg+fKqSIkBP5';
+const generatedStylesHash = 'ygLbAvNrFpcr';
diff --git a/site/pubspec.yaml b/site/pubspec.yaml
index a5b4b99ed2e..b0adf132f48 100644
--- a/site/pubspec.yaml
+++ b/site/pubspec.yaml
@@ -19,6 +19,7 @@ dependencies:
   markdown: ^7.3.0
   markdown_description_list: ^0.1.1
   meta: ^1.17.0
+  nanoid2: ^2.0.1
   # Used for syntax highlighting.
   opal: ^0.2.0
   path: ^1.9.1