From 273f428e1fd9431ae9ade5a2d1def56e871417b0 Mon Sep 17 00:00:00 2001 From: Parker Lougheed Date: Wed, 29 Oct 2025 19:30:13 +0100 Subject: [PATCH 1/2] Add searchable glossary --- firebase.json | 2 + site/lib/_sass/_site.scss | 2 + site/lib/_sass/components/_button.scss | 7 +- site/lib/_sass/components/_card.scss | 13 +- site/lib/_sass/components/_content.scss | 2 +- site/lib/_sass/components/_filter-search.scss | 62 ++++ site/lib/_sass/pages/_glossary.scss | 97 +++++++ site/lib/jaspr_options.dart | 19 +- site/lib/src/client/global_scripts.dart | 35 +++ .../pages/glossary_search_section.dart | 83 ++++++ site/lib/src/pages/custom_pages.dart | 21 ++ site/lib/src/pages/glossary.dart | 269 ++++++++++++++++++ site/lib/src/style_hash.dart | 2 +- src/data/glossary.yml | 62 ++++ 14 files changed, 665 insertions(+), 11 deletions(-) create mode 100644 site/lib/_sass/components/_filter-search.scss create mode 100644 site/lib/_sass/pages/_glossary.scss create mode 100644 site/lib/src/components/pages/glossary_search_section.dart create mode 100644 site/lib/src/pages/glossary.dart create mode 100644 src/data/glossary.yml diff --git a/firebase.json b/firebase.json index a1d9686cf7e..1f70f3146a0 100644 --- a/firebase.json +++ b/firebase.json @@ -58,6 +58,8 @@ { "source": "/flutter-for-:platform*", "destination": "/get-started/flutter-for/:platform*-devs", "type": 301 }, { "source": "/formatting", "destination": "/tools/formatting", "type": 301 }, { "source": "/gestures", "destination": "/ui/advanced/gestures", "type": 301 }, + { "source": "/glossary", "destination": "/resources/glossary", "type": 301 }, + { "source": "/glossary/:entry", "destination": "/resources/glossary#:entry", "type": 301 }, { "source": "/hot-reload", "destination": "/tools/hot-reload", "type": 301 }, { "source": "/ide-setup", "destination": "/tools/editors", "type": 301 }, { "source": "/images/catalog-widget-placeholder.png", "destination": "/assets/images/docs/catalog-widget-placeholder.png", "type": 301 }, diff --git a/site/lib/_sass/_site.scss b/site/lib/_sass/_site.scss index 477179e4b33..96dfe326bf0 100644 --- a/site/lib/_sass/_site.scss +++ b/site/lib/_sass/_site.scss @@ -22,6 +22,7 @@ @use 'components/cookie-notice'; @use 'components/dropdown'; @use 'components/expansion-list'; +@use 'components/filter-search'; @use 'components/footer'; @use 'components/header'; @use 'components/icons'; @@ -38,6 +39,7 @@ @use 'components/trailing'; // Styles for specific pages, alphabetically ordered. +@use 'pages/glossary'; @use 'pages/learning-resources-index'; @use 'pages/not-found'; @use 'pages/search'; diff --git a/site/lib/_sass/components/_button.scss b/site/lib/_sass/components/_button.scss index 784c029a4b8..1b44a8be255 100644 --- a/site/lib/_sass/components/_button.scss +++ b/site/lib/_sass/components/_button.scss @@ -33,7 +33,7 @@ a, button { font-weight: 500; font-family: var(--site-ui-fontFamily); gap: 0.3rem; - padding: 0.5rem 1rem; + padding: 0.4rem 0.9rem; text-decoration: none; cursor: pointer; } @@ -44,6 +44,11 @@ a, button { outline-offset: 2px; user-select: none; + span.material-symbols { + font-variation-settings: 'FILL' 1; + font-size: 20px; + } + &:hover { @include mixins.interaction-style(8%); } diff --git a/site/lib/_sass/components/_card.scss b/site/lib/_sass/components/_card.scss index 630cdf4e94e..af2b8f33739 100644 --- a/site/lib/_sass/components/_card.scss +++ b/site/lib/_sass/components/_card.scss @@ -1,5 +1,13 @@ @use '../base/mixins'; +.card-list { + display: flex; + flex-direction: column; + gap: var(--card-grid-gap, 1rem); + margin: 0; + justify-content: center; +} + .card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, var(--card-min-width, 15rem)), 1fr)); @@ -29,7 +37,9 @@ --card-min-width: 10rem; grid-auto-rows: 1fr; } +} +.card-grid, .card-list { .card { display: flex; flex-direction: column; @@ -100,7 +110,7 @@ } } - .card-content { + &:not(.glossary-card) .card-content { display: flex; align-items: center; gap: 0.75rem; @@ -223,7 +233,6 @@ } } - .card-image-holder-material-3 { position: relative; align-items: center; diff --git a/site/lib/_sass/components/_content.scss b/site/lib/_sass/components/_content.scss index 3598d7668a1..4fd52f7a062 100644 --- a/site/lib/_sass/components/_content.scss +++ b/site/lib/_sass/components/_content.scss @@ -1,4 +1,4 @@ -#page-content { +main { display: flex; flex-direction: column; justify-content: center; diff --git a/site/lib/_sass/components/_filter-search.scss b/site/lib/_sass/components/_filter-search.scss new file mode 100644 index 00000000000..db7319c3af9 --- /dev/null +++ b/site/lib/_sass/components/_filter-search.scss @@ -0,0 +1,62 @@ +#filter-and-search { + display: flex; + flex-direction: column; + flex-wrap: wrap; + justify-content: center; + gap: 0.75rem; + margin-block-start: 1rem; + margin-block-end: 1rem; + + &.hidden { + display: none; + } + + .search-row { + display: flex; + flex-direction: row; + align-items: center; + width: 100%; + gap: 0.5rem; + + .search-wrapper { + display: flex; + align-items: center; + width: 100%; + + border: 1px solid var(--site-outline); + border-radius: 1rem; + height: 3rem; + padding: 0 .5rem; + + &:has(:focus-visible) { + outline: 2px solid var(--site-primary-color); + border-color: transparent; + } + + .leading-icon { + padding-left: 0.25rem; + user-select: none; + } + + input { + background: none; + width: 100%; + font-size: 1rem; + cursor: text; + + &:focus { + outline: none; + } + + &::-webkit-search-cancel-button { + display: none; + } + } + } + } + + + section.content-search-results { + margin-block-start: 0.5rem; + margin-block-end: 1rem; + } +} diff --git a/site/lib/_sass/pages/_glossary.scss b/site/lib/_sass/pages/_glossary.scss new file mode 100644 index 00000000000..74fbe7ad353 --- /dev/null +++ b/site/lib/_sass/pages/_glossary.scss @@ -0,0 +1,97 @@ +@use '../base/mixins'; + +body.glossary-page main { + .glossary-card { + height: auto; + padding: 0.75rem; + + .expandable-content { + border-top: 0.05rem solid var(--site-inset-borderColor); + padding-top: 0.5rem; + } + + &.collapsed { + min-height: 8rem; + + .initial-content { + // Only show the first paragraph if collapsed. + > :not(:first-child) { + display: none; + } + } + + .expandable-content { + display: none; + } + + .expand-button { + transform: rotate(180deg); + } + } + + .expand-button { + &:hover, &:focus-within { + transition: transform .25s ease-out; + } + } + + .card-header { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + } + } + + .details-header { + font-weight: 500; + margin-bottom: 0.5rem; + margin-top: 0.5rem; + font-size: 1rem; + } + + .resources-list { + list-style: none; + padding: 0.5rem; + margin: 0; + + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 0.5rem; + + li { + display: flex; + + .filled-button { + text-wrap: pretty; + padding: 0.25rem 0.75rem; + } + } + } + + .initial-content, .expandable-content { + > :first-child { + margin-top: 0; + } + + > :last-child { + margin-bottom: 0; + } + } + + .card-header-buttons { + display: flex; + flex-direction: row; + align-items: center; + gap: 0.25rem; + + .icon-button { + border-radius: 1.5rem; + + > span { + font-size: 1.5rem; + } + } + } +} diff --git a/site/lib/jaspr_options.dart b/site/lib/jaspr_options.dart index 831027edc6c..b29abd0952a 100644 --- a/site/lib/jaspr_options.dart +++ b/site/lib/jaspr_options.dart @@ -29,10 +29,12 @@ import 'package:docs_flutter_dev_site/src/components/layout/theme_switcher.dart' as prefix10; import 'package:docs_flutter_dev_site/src/components/pages/archive_table.dart' as prefix11; -import 'package:docs_flutter_dev_site/src/components/pages/learning_resource_filters.dart' +import 'package:docs_flutter_dev_site/src/components/pages/glossary_search_section.dart' as prefix12; -import 'package:docs_flutter_dev_site/src/components/pages/learning_resource_filters_sidebar.dart' +import 'package:docs_flutter_dev_site/src/components/pages/learning_resource_filters.dart' as prefix13; +import 'package:docs_flutter_dev_site/src/components/pages/learning_resource_filters_sidebar.dart' + as prefix14; /// Default [JasprOptions] for use with your jaspr project. /// @@ -105,13 +107,18 @@ JasprOptions get defaultJasprOptions => JasprOptions( params: _prefix11ArchiveTable, ), - prefix12.LearningResourceFilters: - ClientTarget( + prefix12.GlossarySearchSection: + ClientTarget( + 'src/components/pages/glossary_search_section', + ), + + prefix13.LearningResourceFilters: + ClientTarget( 'src/components/pages/learning_resource_filters', ), - prefix13.LearningResourceFiltersSidebar: - ClientTarget( + prefix14.LearningResourceFiltersSidebar: + ClientTarget( 'src/components/pages/learning_resource_filters_sidebar', ), }, diff --git a/site/lib/src/client/global_scripts.dart b/site/lib/src/client/global_scripts.dart index 997a2321973..dc0608a50ba 100644 --- a/site/lib/src/client/global_scripts.dart +++ b/site/lib/src/client/global_scripts.dart @@ -41,6 +41,7 @@ void _setUpSite() { _setUpSearchKeybindings(); _setUpTabs(); _setUpCollapsibleElements(); + _setUpExpandableCards(); _setUpPlatformKeys(); _setUpToc(); } @@ -255,6 +256,40 @@ void _setUpCollapsibleElements() { } } +void _setUpExpandableCards() { + var currentFragment = web.window.location.hash.trim().toLowerCase(); + if (currentFragment.startsWith('#')) { + // Remove the leading '#' from the fragment. + currentFragment = currentFragment.substring(1); + } + final expandableCards = web.document.querySelectorAll('.expandable-card'); + + for (var i = 0; i < expandableCards.length; i++) { + final card = expandableCards.item(i) as web.Element; + final expandButton = card.querySelector('.expand-button'); + if (expandButton == null) continue; + + expandButton.addEventListener( + 'click', + ((web.Event e) { + if (card.classList.contains('collapsed')) { + card.classList.remove('collapsed'); + expandButton.ariaExpanded = 'true'; + } else { + card.classList.add('collapsed'); + expandButton.ariaExpanded = 'false'; + } + e.preventDefault(); + }).toJS, + ); + + if (card.id != currentFragment) { + card.classList.add('collapsed'); + expandButton.ariaExpanded = 'false'; + } + } +} + void _setUpPlatformKeys() { final os = getOS(); // Use Command key for macOS, Control key for other OS. diff --git a/site/lib/src/components/pages/glossary_search_section.dart b/site/lib/src/components/pages/glossary_search_section.dart new file mode 100644 index 00000000000..565133ae50f --- /dev/null +++ b/site/lib/src/components/pages/glossary_search_section.dart @@ -0,0 +1,83 @@ +// 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 '../common/search.dart'; + +@client +class GlossarySearchSection extends StatefulComponent { + const GlossarySearchSection({super.key}); + + @override + State createState() => _GlossarySearchSectionState(); +} + +class _GlossarySearchSectionState extends State { + String searchQuery = ''; + + final glossaryCards = []; + + @override + void initState() { + super.initState(); + if (kIsWeb) { + // Waiting until after the frame is only needed for tests, since there + // the cards are not pre-rendered. Does not affect the real app. + context.binding.addPostFrameCallback(() { + final cards = web.document + .getElementById('content-search-results') + ?.querySelectorAll('.card'); + if (cards == null) return; + + for (var i = 0; i < cards.length; i++) { + final card = cards.item(i) as web.HTMLElement; + glossaryCards.add(card); + } + }); + } + } + + void filterCards() { + final searchTerm = searchQuery.trim().toLowerCase(); + + List getMatches(web.HTMLElement elem, String name) => + elem.attributes.getNamedItem('data-$name')?.value.split(',') ?? []; + + for (final card in glossaryCards) { + final matchPartially = getMatches(card, 'partial-matches'); + final matchExactly = getMatches(card, 'full-matches'); + + if (matchPartially.any((m) => m.contains(searchTerm))) { + card.classList.remove('hidden'); + continue; + } + + if (matchExactly.any((m) => m == searchTerm)) { + card.classList.remove('hidden'); + continue; + } + + card.classList.add('hidden'); + } + } + + @override + Component build(BuildContext context) { + return section(id: 'filter-and-search', [ + SearchBar( + placeholder: 'Search terms...', + label: 'Search terms by name...', + value: searchQuery, + onInput: (value) { + setState(() { + searchQuery = value; + }); + filterCards(); + }, + ), + ]); + } +} diff --git a/site/lib/src/pages/custom_pages.dart b/site/lib/src/pages/custom_pages.dart index c2c0d337ebc..306df7d51fe 100644 --- a/site/lib/src/pages/custom_pages.dart +++ b/site/lib/src/pages/custom_pages.dart @@ -8,13 +8,34 @@ import 'package:jaspr/server.dart'; import 'package:jaspr_content/jaspr_content.dart'; import '../components/pages/devtools_release_notes_index.dart'; +import 'glossary.dart'; /// All pages that should be loaded from memory rather than /// from content loaded from the file system. List get allMemoryPages => [ + _glossaryPage, _devtoolsReleasesIndex, ]; +/// The `/resources/glossary` page which hosts the [GlossaryIndex]. +MemoryPage get _glossaryPage => MemoryPage.builder( + path: 'resources/glossary.md', + initialData: { + 'page': { + 'title': 'Glossary', + 'showBreadcrumbs': false, + 'description': + 'A glossary reference for terminology ' + 'used across docs.flutter.dev.', + 'showToc': false, + 'bodyClass': 'glossary-page', + }, + }, + builder: (_) { + return const GlossaryIndex(); + }, +); + /// The `/f/devtools-releases.json` file that DevTools consumes. MemoryPage get _devtoolsReleasesIndex => MemoryPage.builder( path: 'f/devtools-releases.json', diff --git a/site/lib/src/pages/glossary.dart b/site/lib/src/pages/glossary.dart new file mode 100644 index 00000000000..f204e3fd789 --- /dev/null +++ b/site/lib/src/pages/glossary.dart @@ -0,0 +1,269 @@ +// 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 '../components/common/button.dart'; +import '../components/common/card.dart'; +import '../components/pages/glossary_search_section.dart'; +import '../markdown/markdown_parser.dart'; +import '../util.dart'; + +/// Different types of resources that glossary terms might link to. +enum ResourceType { + term, + article, + tutorial, + apiDoc, + video, + code, + diagnostic, + external; + + /// The ID of the material symbol icon associated with each resource type. + String get icon => switch (this) { + term => 'dictionary', + tutorial => 'school', + apiDoc => 'description', + video => 'play_arrow', + code => 'code_blocks', + diagnostic => 'lightbulb', + external => 'open_in_new', + _ => 'article', + }; + + /// Retrieve the most relevant [ResourceType] that + /// corresponds to the [type] string. + static ResourceType fromString(String? type) => switch (type?.toLowerCase()) { + 'term' || 'glossary' => ResourceType.term, + 'article' || 'doc' => ResourceType.article, + 'tutorial' => ResourceType.tutorial, + 'api' => ResourceType.apiDoc, + 'video' => ResourceType.video, + 'code' || 'sample' => ResourceType.code, + 'diagnostic' || 'lint' => ResourceType.diagnostic, + 'external' => ResourceType.external, + _ => ResourceType.article, + }; +} + +/// Represents a single glossary entry with all its metadata. +@immutable +final class GlossaryEntry { + const GlossaryEntry({ + required this.term, + required this.shortDescription, + required this.id, + this.longDescription, + this.relatedLinks = const [], + this.labels = const [], + this.alternate = const [], + }); + + final String term; + final String shortDescription; + final String id; + final String? longDescription; + final List relatedLinks; + final List labels; + final List alternate; +} + +/// Represents a related link for a glossary entry. +@immutable +final class RelatedLink { + const RelatedLink({ + required this.text, + required this.link, + this.type = ResourceType.article, + }); + + final String text; + final String link; + final ResourceType type; +} + +/// Represents a complete glossary with multiple entries. +@immutable +final class Glossary { + const Glossary({required this.entries}); + + /// The entries to include in the glossary. One for each term. + final List entries; + + /// Create a [Glossary] from parsed data. + /// + /// Expects the format used by `src/data/glossary.yml`. + factory Glossary.fromList(List rawData) { + final entries = []; + + for (final item in rawData) { + if (item is Map) { + final term = item['term'] as String?; + final shortDescription = item['short_description'] as String?; + + if (term != null && shortDescription != null) { + final relatedLinks = []; + final rawLinks = item['related_links'] as List?; + + if (rawLinks != null) { + for (final link in rawLinks) { + if (link is Map) { + relatedLinks.add( + RelatedLink( + text: link['text'] as String, + link: link['link'] as String, + type: ResourceType.fromString(link['type'] as String?), + ), + ); + } + } + } + + entries.add( + GlossaryEntry( + term: term, + shortDescription: shortDescription, + id: item['id'] as String? ?? slugify(term), + longDescription: item['long_description'] as String?, + relatedLinks: relatedLinks, + labels: + (item['labels'] as List?)?.cast() ?? + const [], + alternate: + (item['alternate'] as List?)?.cast() ?? + const [], + ), + ); + } + } + } + + // Sort entries alphabetically by term. + entries.sort( + (a, b) => a.term.toLowerCase().compareTo(b.term.toLowerCase()), + ); + + return Glossary(entries: entries); + } +} + +/// A glossary component that displays a +/// searchable list of terms and definitions. +final class GlossaryIndex extends StatelessComponent { + const GlossaryIndex([this.glossaryData]); + + /// The raw glossary data to display. If `null`, the data will be + /// loaded from the 'glossary.yml' file. + final List? glossaryData; + + @override + Component build(BuildContext context) { + final glossary = Glossary.fromList( + glossaryData ?? context.page.data['glossary'] as List, + ); + return Component.fragment( + [ + p([ + text( + 'The following are definitions of terms used ' + 'across the Flutter documentation.', + ), + ]), + const GlossarySearchSection(), + section(id: 'content-search-results', [ + div(classes: 'card-list', [ + for (final entry in glossary.entries) GlossaryCard(entry: entry), + ]), + ]), + ], + ); + } +} + +final class GlossaryCard extends StatelessComponent { + const GlossaryCard({ + super.key, + required this.entry, + }); + + final GlossaryEntry entry; + + @override + Component build(BuildContext context) { + final cardId = entry.id; + final contentId = '$cardId-content'; + + final partialMatches = [ + entry.term.toLowerCase(), + ...entry.labels, + ].join(' '); + final fullMatches = entry.alternate.map((e) => e.toLowerCase()).join(','); + + return Card.expandable( + id: cardId, + outlined: true, + filled: true, + additionalClasses: 'glossary-card', + attributes: { + 'data-partial-matches': partialMatches, + 'data-full-matches': fullMatches, + }, + header: [ + h2(classes: 'card-title', [text(entry.term)]), + div(classes: 'card-header-buttons', [ + Button( + href: '#$cardId', + icon: 'tag', + style: ButtonStyle.text, + classes: const ['share-button'], + title: 'Link to card', + attributes: { + 'aria-label': 'Link to ${entry.term} card', + }, + ), + Button( + icon: 'keyboard_arrow_up', + style: ButtonStyle.text, + classes: const ['expand-button'], + title: 'Expand or collapse card', + attributes: { + 'aria-expanded': 'true', + 'aria-controls': contentId, + 'aria-label': 'Expand or collapse ${entry.term} card', + }, + ), + ]), + ], + collapsedContent: [ + if (entry.shortDescription.isNotEmpty) + DashMarkdown(content: entry.shortDescription, inline: true), + ], + expandedContent: [ + if (entry.longDescription case final longDescription?) + DashMarkdown(content: longDescription), + + if (entry.relatedLinks.isNotEmpty) + div([ + h3(classes: 'no_toc details-header', [ + text('Related docs and resources'), + ]), + ul(classes: 'resources-list', [ + for (final resource in entry.relatedLinks) + li([ + Button( + href: resource.link, + content: parseMarkdownToHtml(resource.text, inline: true), + icon: resource.type.icon, + style: ButtonStyle.filled, + asRaw: true, + ), + ]), + ]), + ]), + ], + ); + } +} diff --git a/site/lib/src/style_hash.dart b/site/lib/src/style_hash.dart index 9f473f0037b..34fb84d4b0d 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 = 'ugxj95DjrnBJ'; +const generatedStylesHash = 'xVSDdBJ1Zc0a'; diff --git a/src/data/glossary.yml b/src/data/glossary.yml new file mode 100644 index 00000000000..083bcdefb2f --- /dev/null +++ b/src/data/glossary.yml @@ -0,0 +1,62 @@ +- term: "Impeller" + short_description: |- + Flutter's modern graphics rendering engine, + designed for smooth, predictable performance. + long_description: |- + _Impeller_ is Flutter's high-performance rendering engine, + built from the ground up for Flutter's needs and modern graphics APIs. + + Its primary goal is to provide consistently smooth performance and + eliminate stuttering while rendering, particularly that + caused by shader compilation during animations and interactions. + + Impeller achieves this by pre-compiling a specific, smaller set of + shaders at application build time, rather than compiling at runtime. + related_links: + - text: "Impeller documentation" + link: "/perf/impeller" + type: "doc" + - text: "Impeller FAQ" + link: "https://github.com/flutter/flutter/blob/main/docs/engine/impeller/docs/faq.md" + type: "external" + labels: + - "rendering" + - "performance" + - "engine" + +- term: "Widget" + short_description: |- + The basic building block of a Flutter user interface. + long_description: |- + An immutable description of part of a user interface. + + In Flutter, almost everything is a _widget_. + Widgets are the fundamental building blocks you use to + create your application's UI with Flutter. + Each widget is an immutable declaration of _what the UI should + look like based on its current configuration and state. + + Widgets are composed together in a hierarchy to form the widget tree. + When a widget's state changes, the Flutter framework + rebuilds the necessary parts of the tree to update the UI. + + There are two primary types of widgets, + including [`StatelessWidget`][], which have no mutable state, and + [`StatefulWidget`][], which have a persistent [state][] that can be updated. + + [`StatelessWidget`]: https://api.flutter.dev/flutter/widgets/StatelessWidget-class.html + [`StatefulWidget`]: https://api.flutter.dev/flutter/widgets/StatefulWidget-class.html + [state]: https://api.flutter.dev/flutter/widgets/State-class.html + related_links: + - text: "Fundamentals: Widgets" + link: "/get-started/fundamentals/widgets" + type: "doc" + - text: "Widget catalog" + link: "/ui/widgets" + type: "doc" + - text: "Widget class" + link: "https://api.flutter.dev/flutter/widgets/Widget-class.html" + type: "api" + labels: + - "ui" + - "widgets" From 41e70b3ddd16f66adf9461b0f923618e0b9205aa Mon Sep 17 00:00:00 2001 From: Parker Lougheed Date: Tue, 4 Nov 2025 11:25:37 +0100 Subject: [PATCH 2/2] Ensure targetted glossary term is scrolled into view --- site/lib/src/client/global_scripts.dart | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/site/lib/src/client/global_scripts.dart b/site/lib/src/client/global_scripts.dart index dc0608a50ba..e97cfa8299f 100644 --- a/site/lib/src/client/global_scripts.dart +++ b/site/lib/src/client/global_scripts.dart @@ -263,6 +263,7 @@ void _setUpExpandableCards() { currentFragment = currentFragment.substring(1); } final expandableCards = web.document.querySelectorAll('.expandable-card'); + web.Element? targetCard; for (var i = 0; i < expandableCards.length; i++) { final card = expandableCards.item(i) as web.Element; @@ -286,8 +287,15 @@ void _setUpExpandableCards() { if (card.id != currentFragment) { card.classList.add('collapsed'); expandButton.ariaExpanded = 'false'; + } else { + targetCard = card; } } + + if (targetCard != null) { + // Scroll the expanded card into view. + targetCard.scrollIntoView(); + } } void _setUpPlatformKeys() {