diff --git a/site/lib/_sass/components/_stepper.scss b/site/lib/_sass/components/_stepper.scss index 41d14406f2..f12524c719 100644 --- a/site/lib/_sass/components/_stepper.scss +++ b/site/lib/_sass/components/_stepper.scss @@ -1,6 +1,6 @@ .stepper { - details { + >details { position: relative; margin: 0; padding-bottom: 1px; @@ -25,7 +25,7 @@ display: none; } - summary { + >summary { position: relative; display: flex; align-items: center; @@ -52,7 +52,14 @@ } .step-title { - .header-wrapper, h1, h2, h3, h4, h5, h6 { + + .header-wrapper, + h1, + h2, + h3, + h4, + h5, + h6 { margin: 0; } } @@ -66,7 +73,7 @@ } } - &[open] summary { + &[open]>summary { .step-number { background-color: var(--site-primary-color); color: var(--site-onPrimary-color-lightest); @@ -77,7 +84,7 @@ } } - &:not([open]):has(~[open]) summary .step-number { + &:not([open]):has(~[open])>summary .step-number { background-color: var(--site-onPrimary-color-light); color: var(--site-primary-color); } diff --git a/site/lib/main.server.dart b/site/lib/main.server.dart index bb66076e1a..2c897e0b8d 100644 --- a/site/lib/main.server.dart +++ b/site/lib/main.server.dart @@ -29,7 +29,6 @@ import 'src/components/tutorial/progress_ring.dart'; import 'src/components/tutorial/quiz.dart'; import 'src/components/tutorial/stepper.dart'; import 'src/components/tutorial/summary_card.dart'; -import 'src/components/tutorial/tutorial_lesson.dart'; import 'src/components/tutorial/tutorial_outline.dart'; import 'src/components/util/component_ref.dart'; import 'src/extensions/registry.dart'; @@ -119,7 +118,6 @@ List get _embeddableComponents => [ const SummaryCard(), const DownloadableSnippet(), const Stepper(), - const TutorialLesson(), const WidgetCatalogCategories(), const TutorialOutline(), const WidgetCatalogGrid(), diff --git a/site/lib/src/client/global_scripts.dart b/site/lib/src/client/global_scripts.dart index 248fca1678..78edaafbe5 100644 --- a/site/lib/src/client/global_scripts.dart +++ b/site/lib/src/client/global_scripts.dart @@ -390,10 +390,18 @@ void _setUpSteppers() { for (var i = 0; i < steppers.length; i++) { final stepper = steppers.item(i) as web.HTMLElement; - final steps = stepper.querySelectorAll('details'); + final children = stepper.childNodes; + final steps = [ + for (var j = 0; j < children.length; j++) + if (children.item(j) case web.Element( + nodeType: web.Node.ELEMENT_NODE, + tagName: 'DETAILS', + )) + children.item(j) as web.HTMLDetailsElement, + ]; for (var j = 0; j < steps.length; j++) { - final step = steps.item(j) as web.HTMLDetailsElement; + final step = steps[j]; step.addEventListener( 'toggle', @@ -401,7 +409,7 @@ void _setUpSteppers() { // Close all other steps when one is opened. if (step.open) { for (var k = 0; k < steps.length; k++) { - final otherStep = steps.item(k) as web.HTMLDetailsElement; + final otherStep = steps[k]; if (otherStep != step) { otherStep.open = false; } @@ -419,7 +427,7 @@ void _setUpSteppers() { step.open = false; _scrollTo(step, smooth: false); if (j + 1 < steps.length) { - final nextStep = steps.item(j + 1) as web.HTMLDetailsElement; + final nextStep = steps[j + 1]; nextStep.open = true; _scrollTo(nextStep, smooth: true); } diff --git a/site/lib/src/components/tutorial/stepper.dart b/site/lib/src/components/tutorial/stepper.dart index 9f2a87d22c..c621b55f98 100644 --- a/site/lib/src/components/tutorial/stepper.dart +++ b/site/lib/src/components/tutorial/stepper.dart @@ -39,7 +39,7 @@ class Stepper extends CustomComponent { attributes: {'class': 'header-wrapper'}, children: [final ElementNode heading, ..._], ) when heading.tag == 'h$level') { - steps.add((title: child, content: [])); + steps.add((title: heading, content: [])); } else { if (steps.isEmpty) { throw Exception( diff --git a/site/lib/src/components/tutorial/tutorial_lesson.dart b/site/lib/src/components/tutorial/tutorial_lesson.dart deleted file mode 100644 index 5cd454d8f9..0000000000 --- a/site/lib/src/components/tutorial/tutorial_lesson.dart +++ /dev/null @@ -1,132 +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 'package:jaspr/dom.dart'; -import 'package:jaspr/jaspr.dart'; -import 'package:jaspr_content/jaspr_content.dart'; - -/// A component that provides the structure for a tutorial lesson page. -class TutorialLesson extends CustomComponent { - const TutorialLesson() : super.base(); - - @override - Component? create(Node node, NodesBuilder builder) { - if (node case ElementNode(tag: 'TutorialLesson', :final children?)) { - List? introContent; - List? stepsContent; - List? outroContent; - - for (final child in children) { - if (child case ElementNode(tag: 'TutorialIntro', :final children?)) { - introContent = children; - } else if (child case ElementNode( - tag: 'TutorialSteps', - :final children?, - )) { - stepsContent = children; - } else if (child case ElementNode( - tag: 'TutorialOutro', - :final children?, - )) { - outroContent = children; - } - } - - return section(classes: 'tutorial-lesson', [ - // Intro section - if (introContent != null) - section(classes: 'tutorial-intro', [ - ..._buildIntroContent(introContent, builder), - ]), - // Divider between intro and steps - if (introContent != null && stepsContent != null) - const hr(classes: 'tutorial-divider'), - // Steps section - if (stepsContent != null) - section(classes: 'tutorial-steps', [ - const h2([.text('Steps')]), - // Wrap steps content in a Stepper with level="3" - builder.build([ - ElementNode('Stepper', {'level': '3'}, stepsContent), - ]), - ]), - // Outro section - if (outroContent != null) - section(classes: 'tutorial-outro', [ - builder.build(outroContent), - ]), - ]); - } - - return null; - } - - /// Builds the intro content, automatically wrapping: - /// - Paragraph elements (`

`) get 'description' class added - /// - YouTubeEmbed elements in a div with class 'intro-video' - List _buildIntroContent(List content, NodesBuilder builder) { - final wrappedContent = []; - - for (final node in content) { - if (node case ElementNode(tag: 'p', :final children)) { - // Check if paragraph contains only a video embed - final videoChild = _findVideoChild(children); - if (videoChild != null) { - // Extract video from paragraph and wrap in intro-video div - wrappedContent.add( - div(classes: 'intro-video', [ - builder.build([videoChild]), - ]), - ); - } else { - // Regular paragraph - add description class - wrappedContent.add( - p(classes: 'description', [builder.build(children ?? [])]), - ); - } - } else if (_isVideoEmbed(node)) { - // Direct video embed (not wrapped in paragraph) - wrappedContent.add( - div(classes: 'intro-video', [ - builder.build([node]), - ]), - ); - } else { - // Pass through other elements (SummaryCard, comments, etc.) - wrappedContent.add(builder.build([node])); - } - } - - return wrappedContent; - } - - /// Checks if a node is a YouTube video embed - bool _isVideoEmbed(Node node) { - if (node case ElementNode(tag: final tag)) { - final lowerTag = tag.toLowerCase(); - return lowerTag == 'youtubeembed' || lowerTag == 'lite-youtube'; - } - return false; - } - - /// Finds a video embed child in a list of nodes, returns it if it's the only - /// meaningful content (ignoring whitespace text nodes) - Node? _findVideoChild(List? children) { - if (children == null) return null; - - Node? videoChild; - for (final child in children) { - if (_isVideoEmbed(child)) { - videoChild = child; - } else if (child case TextNode(:final text)) { - // Ignore whitespace-only text nodes - if (text.trim().isNotEmpty) return null; - } else { - // Non-video, non-whitespace content found - return null; - } - } - return videoChild; - } -} diff --git a/site/lib/src/extensions/registry.dart b/site/lib/src/extensions/registry.dart index 13f168e801..fb51180b84 100644 --- a/site/lib/src/extensions/registry.dart +++ b/site/lib/src/extensions/registry.dart @@ -11,6 +11,7 @@ import 'header_extractor.dart'; import 'header_processor.dart'; import 'table_processor.dart'; import 'tutorial_prefetch_processor.dart'; +import 'tutorial_structure_processor.dart'; /// A list of all node-processing, page extensions to applied to /// content loaded with Jaspr Content. @@ -22,4 +23,5 @@ const List allNodeProcessingExtensions = [ CodeBlockProcessor(), GlossaryLinkProcessor(), TutorialNavigationExtension(), + TutorialStructureExtension(), ]; diff --git a/site/lib/src/extensions/tutorial_structure_processor.dart b/site/lib/src/extensions/tutorial_structure_processor.dart new file mode 100644 index 0000000000..ee06b2e727 --- /dev/null +++ b/site/lib/src/extensions/tutorial_structure_processor.dart @@ -0,0 +1,138 @@ +// 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'; + +/// A page extension for Jaspr Content that enforces the tutorial +/// page structure for all pages in the tutorial. +final class TutorialStructureExtension implements PageExtension { + const TutorialStructureExtension(); + + @override + Future> apply(Page page, List nodes) async { + if (!page.path.startsWith('learn/tutorial/') || + page.path.endsWith('index.md')) { + return nodes; + } + + final dividerIndex = nodes.indexWhere( + (node) => node is ElementNode && node.tag == 'hr', + ); + + List introContent; + List stepsContent; + List trailingContent; + + if (dividerIndex >= 0) { + introContent = nodes.sublist(0, dividerIndex); + + final trailingDividerIndex = nodes.indexWhere( + (node) => node is ElementNode && node.tag == 'hr', + dividerIndex + 1, + ); + + stepsContent = trailingDividerIndex >= 0 + ? nodes.sublist(dividerIndex + 1, trailingDividerIndex) + : nodes.sublist(dividerIndex + 1); + trailingContent = trailingDividerIndex >= 0 + ? nodes.sublist(trailingDividerIndex) + : []; + } else { + introContent = []; + stepsContent = []; + trailingContent = nodes; + } + + return [ + ElementNode( + 'section', + {'class': 'tutorial-lesson'}, + [ + if (introContent.isNotEmpty) + ElementNode( + 'section', + {'class': 'tutorial-intro'}, + _buildIntroContent(introContent), + ), + if (introContent.isNotEmpty && stepsContent.isNotEmpty) + const ElementNode('hr', {'class': 'tutorial-divider'}, []), + if (stepsContent.isNotEmpty) + ElementNode( + 'section', + {'class': 'tutorial-steps'}, + [ + const ElementNode('h2', {}, [TextNode('Steps')]), + ElementNode('Stepper', {'level': '3'}, stepsContent), + ], + ), + ...trailingContent, + ], + ), + ]; + } + + /// Builds the intro content, automatically wrapping: + /// - Paragraph elements (`

`) get 'description' class added + /// - YouTubeEmbed elements in a div with class 'intro-video' + List _buildIntroContent(List content) { + final wrappedContent = []; + + for (final node in content) { + if (node case ElementNode(tag: 'p', :final children)) { + // Check if paragraph contains only a video embed + final videoChild = _findVideoChild(children); + if (videoChild != null) { + // Extract video from paragraph and wrap in intro-video div + wrappedContent.add( + ElementNode('div', {'class': 'intro-video'}, [videoChild]), + ); + } else { + // Regular paragraph - add description class + wrappedContent.add( + ElementNode('p', {'class': 'description'}, children), + ); + } + } else if (_isVideoEmbed(node)) { + // Direct video embed (not wrapped in paragraph) + wrappedContent.add( + ElementNode('div', {'class': 'intro-video'}, [node]), + ); + } else { + // Pass through other elements (SummaryCard, comments, etc.) + wrappedContent.add(node); + } + } + + return wrappedContent; + } + + /// Checks if a node is a YouTube video embed + bool _isVideoEmbed(Node node) { + if (node case ElementNode(tag: final tag)) { + final lowerTag = tag.toLowerCase(); + return lowerTag == 'youtubeembed' || lowerTag == 'lite-youtube'; + } + return false; + } + + /// Finds a video embed child in a list of nodes, returns it if it's the only + /// meaningful content (ignoring whitespace text nodes) + Node? _findVideoChild(List? children) { + if (children == null) return null; + + Node? videoChild; + for (final child in children) { + if (_isVideoEmbed(child)) { + videoChild = child; + } else if (child case TextNode(:final text)) { + // Ignore whitespace-only text nodes + if (text.trim().isNotEmpty) return null; + } else { + // Non-video, non-whitespace content found + return null; + } + } + return videoChild; + } +} diff --git a/src/content/learn/tutorial/adaptive-layout.md b/src/content/learn/tutorial/adaptive-layout.md index 7dbb355c7f..237daa9fad 100644 --- a/src/content/learn/tutorial/adaptive-layout.md +++ b/src/content/learn/tutorial/adaptive-layout.md @@ -5,9 +5,6 @@ layout: tutorial sitemap: false --- - - - Learn how to create layouts that adapt to different screen widths. @@ -23,9 +20,7 @@ items: icon: view_sidebar - - - +--- ### Introduction @@ -438,6 +433,3 @@ items: correct: false explanation: ListView is for scrollable lists, not for side-by-side layout. - - - diff --git a/src/content/learn/tutorial/advanced-ui.md b/src/content/learn/tutorial/advanced-ui.md index 3fa1244d4c..812d7b18bf 100644 --- a/src/content/learn/tutorial/advanced-ui.md +++ b/src/content/learn/tutorial/advanced-ui.md @@ -7,9 +7,6 @@ layout: tutorial sitemap: false --- - - - Preview the Rolodex app you'll build and set up a Cupertino-based project with data models. @@ -25,9 +22,7 @@ items: icon: data_object - - - +--- ### Introduction @@ -607,6 +602,3 @@ items: correct: false explanation: ValueNotifier holds values in memory; persistence requires separate implementation. - - - diff --git a/src/content/learn/tutorial/change-notifier.md b/src/content/learn/tutorial/change-notifier.md index d8da361681..a2fcfd89f2 100644 --- a/src/content/learn/tutorial/change-notifier.md +++ b/src/content/learn/tutorial/change-notifier.md @@ -5,9 +5,6 @@ layout: tutorial sitemap: false --- - - - Learn to create a ViewModel with ChangeNotifier and manage loading, success, and error states. @@ -23,9 +20,7 @@ items: icon: notifications_active - - - +--- ### Introduction @@ -279,6 +274,3 @@ items: correct: false explanation: "`notifyListeners()` doesn't modify state; it just signals that state has changed." - - - diff --git a/src/content/learn/tutorial/create-an-app.md b/src/content/learn/tutorial/create-an-app.md index 6e0037e023..a0cff4ddcb 100644 --- a/src/content/learn/tutorial/create-an-app.md +++ b/src/content/learn/tutorial/create-an-app.md @@ -5,9 +5,6 @@ layout: tutorial sitemap: false --- - - - Learn the first steps to building a Flutter app, from creating a project to understanding widgets and hot reload. @@ -23,9 +20,7 @@ items: icon: bolt - - - +--- ### What you'll build @@ -215,5 +210,3 @@ items: explanation: "By default, you need to press `r` to trigger hot reload in the terminal." - - diff --git a/src/content/learn/tutorial/devtools.md b/src/content/learn/tutorial/devtools.md index f61886ecc1..bb3999504c 100644 --- a/src/content/learn/tutorial/devtools.md +++ b/src/content/learn/tutorial/devtools.md @@ -5,9 +5,6 @@ layout: tutorial sitemap: false --- - - - Learn to use the widget inspector and property editor to debug layout issues and experiment with properties in real-time. @@ -23,9 +20,7 @@ items: icon: tune - - - +--- ### Introduction @@ -281,6 +276,3 @@ items: correct: false explanation: Theme editing requires code changes; the Widget Inspector is for inspecting the current state. - - - diff --git a/src/content/learn/tutorial/http-requests.md b/src/content/learn/tutorial/http-requests.md index d873400565..d71c6429a9 100644 --- a/src/content/learn/tutorial/http-requests.md +++ b/src/content/learn/tutorial/http-requests.md @@ -5,9 +5,6 @@ layout: tutorial sitemap: false --- - - - Learn the MVVM architecture pattern and how to build HTTP requests with async/await. @@ -23,9 +20,7 @@ items: icon: data_object - - - +--- ### Introduction @@ -223,6 +218,3 @@ items: correct: false explanation: Uri.https builds the URL; it doesn't check if the endpoint exists. - - - diff --git a/src/content/learn/tutorial/implicit-animations.md b/src/content/learn/tutorial/implicit-animations.md index f981687caa..ec5852ff14 100644 --- a/src/content/learn/tutorial/implicit-animations.md +++ b/src/content/learn/tutorial/implicit-animations.md @@ -5,9 +5,6 @@ layout: tutorial sitemap: false --- - - - Flutter provides a rich set of animation APIs, and the simplest way to start using them is with **implicit animations**. "Implicit animations" refers to a group of widgets that @@ -32,8 +29,7 @@ animates to a new color in about half a second. [`AnimatedContainer`]: {{site.api}}/flutter/widgets/AnimatedContainer-class.html - - +--- ### Convert `Container` to `AnimatedContainer` @@ -267,6 +263,3 @@ items: correct: false explanation: Implicit animations run once per state change; repetition requires explicit animation controllers. - - - diff --git a/src/content/learn/tutorial/layout.md b/src/content/learn/tutorial/layout.md index 50164217c9..1fe48ed4b6 100644 --- a/src/content/learn/tutorial/layout.md +++ b/src/content/learn/tutorial/layout.md @@ -5,9 +5,6 @@ layout: tutorial sitemap: false --- - - - Learn how to build layouts with common widgets like Scaffold, AppBar, Column, and Row. @@ -25,9 +22,7 @@ items: icon: grid_view - - - +--- ### Introduction @@ -351,6 +346,3 @@ items: correct: false explanation: Scaffold doesn't manage state; you use StatefulWidget or state management solutions for that. - - - diff --git a/src/content/learn/tutorial/listenable-builder.md b/src/content/learn/tutorial/listenable-builder.md index 41e18ee926..7612ee51b4 100644 --- a/src/content/learn/tutorial/listenable-builder.md +++ b/src/content/learn/tutorial/listenable-builder.md @@ -5,9 +5,6 @@ layout: tutorial sitemap: false --- - - - Learn to use ListenableBuilder to automatically rebuild UI and handle all possible states with switch expressions. @@ -23,9 +20,7 @@ items: icon: article - - - +--- ### Introduction @@ -516,6 +511,3 @@ items: correct: false explanation: "ListenableBuilder rebuilds based on the Listenable, not parent rebuilds." - - - diff --git a/src/content/learn/tutorial/navigation.md b/src/content/learn/tutorial/navigation.md index c0a201fab5..8f537c424f 100644 --- a/src/content/learn/tutorial/navigation.md +++ b/src/content/learn/tutorial/navigation.md @@ -5,9 +5,6 @@ layout: tutorial sitemap: false --- - - - Learn to navigate between screens with Navigator.push and implement adaptive navigation patterns for different screen sizes. @@ -23,9 +20,7 @@ items: icon: devices - - - +--- ### Introduction @@ -314,6 +309,3 @@ items: correct: false explanation: Pop navigates back; to refresh, you'd use setState or other state management. - - - diff --git a/src/content/learn/tutorial/set-up-state-project.md b/src/content/learn/tutorial/set-up-state-project.md index 58db24b7a8..4d68b12a4c 100644 --- a/src/content/learn/tutorial/set-up-state-project.md +++ b/src/content/learn/tutorial/set-up-state-project.md @@ -5,9 +5,6 @@ layout: tutorial sitemap: false --- - - - Preview the Wikipedia reader app you'll build and set up the initial project with required packages. @@ -23,9 +20,7 @@ items: icon: code - - - +--- ### Introduction @@ -206,6 +201,3 @@ items: correct: false explanation: "There is no `flutter package` command; use `flutter pub add`." - - - diff --git a/src/content/learn/tutorial/slivers.md b/src/content/learn/tutorial/slivers.md index ecae56a642..38c27d594c 100644 --- a/src/content/learn/tutorial/slivers.md +++ b/src/content/learn/tutorial/slivers.md @@ -5,9 +5,6 @@ layout: tutorial sitemap: false --- - - - In this lesson, you'll learn about slivers, which are special widgets that can take advantage of Flutter's powerful and composable scrolling system. @@ -32,9 +29,7 @@ items: icon: sort_by_alpha - - - +--- ### Slivers and widgets @@ -612,6 +607,3 @@ items: correct: false explanation: CustomScrollView uses the slivers property; there's no child property for this purpose. - - - diff --git a/src/content/learn/tutorial/stateful-widget.md b/src/content/learn/tutorial/stateful-widget.md index b2bd014212..7166a20c00 100644 --- a/src/content/learn/tutorial/stateful-widget.md +++ b/src/content/learn/tutorial/stateful-widget.md @@ -5,9 +5,6 @@ layout: tutorial sitemap: false --- - - - Learn when widgets need to be stateful and how to trigger UI updates with setState. @@ -23,9 +20,7 @@ items: icon: refresh - - - +--- ### Introduction @@ -280,6 +275,3 @@ items: correct: false explanation: The widget remains; it just won't visually update without setState. - - - diff --git a/src/content/learn/tutorial/user-input.md b/src/content/learn/tutorial/user-input.md index 838cf50d50..b93e769f9f 100644 --- a/src/content/learn/tutorial/user-input.md +++ b/src/content/learn/tutorial/user-input.md @@ -5,9 +5,6 @@ layout: tutorial sitemap: false --- - - - Learn to build text inputs, manage text with controllers, and handle user actions with buttons. @@ -25,9 +22,7 @@ items: icon: touch_app - - - +--- ### Introduction @@ -747,7 +742,3 @@ items: correct: false explanation: This is not how focus is managed; FocusNode is the proper approach. - - - - diff --git a/src/content/learn/tutorial/widget-fundamentals.md b/src/content/learn/tutorial/widget-fundamentals.md index 6b343c5a78..15bcaf8173 100644 --- a/src/content/learn/tutorial/widget-fundamentals.md +++ b/src/content/learn/tutorial/widget-fundamentals.md @@ -5,9 +5,6 @@ layout: tutorial sitemap: false --- - - - Learn to create custom widgets and use the most common SDK widgets like Container, Center, and Text. @@ -24,9 +21,7 @@ items: icon: palette - - - +--- ### Before you start @@ -376,7 +371,3 @@ items: correct: false explanation: EdgeInsets is for specifying padding or margin, not visual decorations. - - - -