From b15845ed978f5861e1fcb9fcee2a5a140c1256b8 Mon Sep 17 00:00:00 2001 From: Kilian Schulte Date: Wed, 12 Nov 2025 18:22:16 +0100 Subject: [PATCH] add stepper component for fwe --- site/lib/_sass/_site.scss | 1 + site/lib/_sass/components/_stepper.scss | 90 ++++++++++ site/lib/main.dart | 2 + site/lib/src/client/global_scripts.dart | 63 +++++++ site/lib/src/components/tutorial/stepper.dart | 86 ++++++++++ site/lib/src/pages/custom_pages.dart | 160 ++++++++++++++++++ site/lib/src/style_hash.dart | 2 +- 7 files changed, 403 insertions(+), 1 deletion(-) create mode 100644 site/lib/_sass/components/_stepper.scss create mode 100644 site/lib/src/components/tutorial/stepper.dart diff --git a/site/lib/_sass/_site.scss b/site/lib/_sass/_site.scss index 992f23edafd..efcc769babb 100644 --- a/site/lib/_sass/_site.scss +++ b/site/lib/_sass/_site.scss @@ -34,6 +34,7 @@ @use 'components/sidebar'; @use 'components/side-menu'; @use 'components/site-switcher'; +@use 'components/stepper'; @use 'components/tabs'; @use 'components/theming'; @use 'components/toc'; diff --git a/site/lib/_sass/components/_stepper.scss b/site/lib/_sass/components/_stepper.scss new file mode 100644 index 00000000000..45031e075ff --- /dev/null +++ b/site/lib/_sass/components/_stepper.scss @@ -0,0 +1,90 @@ +.stepper { + + details { + position: relative; + margin: 0; + padding-bottom: 1px; + + // Vertical line between steps + &::before { + content: ''; + display: block; + position: absolute; + width: 2px; + height: 100%; + border-radius: 1px; + background: var(--site-inset-borderColor); + transform: translateY(2rem); + } + + &:last-child::before { + transform: none; + } + + summary { + position: relative; + display: flex; + align-items: center; + list-style: none; + + width: 100%; + padding-left: 1.5rem; + padding-block: 1rem; + + .step-number { + position: absolute; + left: -1rem; + width: 2rem; + height: 2rem; + border-radius: 50%; + background: var(--site-raised-bgColor); + color: var(--site-base-fgColor); + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + + transition: background-color 300ms ease, color 300ms ease; + } + + .step-title { + .header-wrapper, h1, h2, h3, h4, h5, h6 { + margin: 0; + } + } + + span.material-symbols { + position: absolute; + right: 0; + transition: transform 300ms ease; + transform: rotate(180deg); + transform-origin: center; + } + } + + &[open] summary { + .step-number { + background-color: var(--site-primary-color); + color: var(--site-onPrimary-color-lightest); + } + + span.material-symbols { + transform: rotate(0); + } + } + + &:not([open]):has(~[open]) summary .step-number { + background-color: var(--site-onPrimary-color-light); + color: var(--site-primary-color); + } + + .step-content { + margin-left: 1.5rem; + } + + .step-actions { + display: flex; + justify-content: flex-end; + } + } +} diff --git a/site/lib/main.dart b/site/lib/main.dart index af8dd804e06..5aa90394a85 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/tutorial/stepper.dart'; import 'src/extensions/registry.dart'; import 'src/layouts/catalog_page_layout.dart'; import 'src/layouts/doc_layout.dart'; @@ -100,6 +101,7 @@ List get _embeddableComponents => [ const FileTree(), const Quiz(), const ProgressRing(), + const Stepper(), CustomComponent( pattern: RegExp('OSSelector', caseSensitive: false), builder: (_, _, _) => const OsSelector(), diff --git a/site/lib/src/client/global_scripts.dart b/site/lib/src/client/global_scripts.dart index 890d91caa26..2b2bd98309b 100644 --- a/site/lib/src/client/global_scripts.dart +++ b/site/lib/src/client/global_scripts.dart @@ -45,6 +45,7 @@ void _setUpSite() { _setUpPlatformKeys(); _setUpToc(); _setUpTooltips(); + _setUpSteppers(); } void _setUpSearchKeybindings() { @@ -539,3 +540,65 @@ void _ensureVisible(web.HTMLElement tooltip) { tooltip.dataset['adjusted'] = '0'; } } + +void _setUpSteppers() { + final steppers = web.document.querySelectorAll('.stepper'); + + for (var i = 0; i < steppers.length; i++) { + final stepper = steppers.item(i) as web.HTMLElement; + final steps = stepper.querySelectorAll('details'); + + for (var j = 0; j < steps.length; j++) { + final step = steps.item(j) as web.HTMLDetailsElement; + + step.addEventListener( + 'toggle', + ((web.Event e) { + // 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; + if (otherStep != step) { + otherStep.open = false; + } + } + } + }).toJS, + ); + + final nextButton = step.querySelector('.next-step-button'); + if (nextButton != null) { + nextButton.addEventListener( + 'click', + ((web.Event e) { + e.preventDefault(); + step.open = false; + _scrollTo(step, smooth: false); + if (j + 1 < steps.length) { + final nextStep = steps.item(j + 1) as web.HTMLDetailsElement; + nextStep.open = true; + _scrollTo(nextStep, smooth: true); + } + }).toJS, + ); + } + } + } +} + +void _scrollTo(web.Element element, {required bool smooth}) { + // Scroll the next step into view, accounting for the fixed header and toc. + final headerOffset = + web.document.getElementById('site-header')?.clientHeight ?? 0; + final tocOffset = web.document.getElementById('toc-top')?.clientHeight ?? 0; + final elementPosition = element.getBoundingClientRect().top; + final offsetPosition = + elementPosition + web.window.scrollY - headerOffset - tocOffset; + + web.window.scrollTo( + web.ScrollToOptions( + top: offsetPosition, + behavior: smooth ? 'smooth' : 'auto', + ), + ); +} diff --git a/site/lib/src/components/tutorial/stepper.dart b/site/lib/src/components/tutorial/stepper.dart new file mode 100644 index 00000000000..b03d3533ba8 --- /dev/null +++ b/site/lib/src/components/tutorial/stepper.dart @@ -0,0 +1,86 @@ +// 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 '../common/button.dart'; +import '../common/material_icon.dart'; + +class Stepper extends CustomComponent { + const Stepper() : super.base(); + + @override + Component? create(Node node, NodesBuilder builder) { + if (node case ElementNode( + tag: 'Stepper', + :final attributes, + :final children, + )) { + final levelStr = attributes['level'] ?? '1'; + final level = int.tryParse(levelStr) ?? 1; + + assert( + level >= 1 && level <= 6, + 'Stepper level must be between 1 and 6, got $level', + ); + + final steps = <({Node title, List content})>[]; + + if (children != null) { + for (final child in children) { + if (child case final ElementNode heading + when heading.tag == 'h$level') { + steps.add((title: child, content: [])); + } else if (child case ElementNode( + tag: 'div', + attributes: {'class': 'header-wrapper'}, + children: [final ElementNode heading, ..._], + ) when heading.tag == 'h$level') { + steps.add((title: child, content: [])); + } else { + if (steps.isEmpty) { + throw Exception( + 'Content found before first step title in Stepper. Make sure ' + 'your Stepper content starts with a heading of level $level.', + ); + } + steps.last.content.add(child); + } + } + } + + assert(steps.isNotEmpty, 'Stepper must have at least one step.'); + + return div(classes: 'stepper', [ + for (final (index, step) in steps.indexed) + details(open: index == 0, [ + summary([ + span( + classes: 'step-number', + attributes: {'aria-label': 'Step ${index + 1}'}, + [text('${index + 1}')], + ), + div(classes: 'step-title', [ + builder.build([step.title]), + ]), + const MaterialIcon('keyboard_arrow_up'), + ]), + div(classes: 'step-content', [ + builder.build(step.content), + ]), + div(classes: 'step-actions', [ + Button( + classes: ['next-step-button'], + style: ButtonStyle.filled, + content: index == steps.length - 1 ? 'Finish' : 'Continue', + ), + ]), + ]), + ]); + } + + return null; + } +} diff --git a/site/lib/src/pages/custom_pages.dart b/site/lib/src/pages/custom_pages.dart index 8ad698dee0a..a73d6c08fe0 100644 --- a/site/lib/src/pages/custom_pages.dart +++ b/site/lib/src/pages/custom_pages.dart @@ -126,5 +126,165 @@ sitemap: false +## Stepper + + + +### Confirm your Dart setup + +First, make sure Dart is ready to go on your system by following these steps. + +1. Open a terminal (or command prompt). + +2. Run the following command to check your Dart SDK version: + + ```bash + dart --version + ``` + +3. Make sure that you see output similar to this + (the version numbers might be different): + + ```bash + Dart SDK version: 3.9.2 (stable) (Wed Aug 27 03:49:40 2025 -0700) on "linux_x64" + ``` + + If you see an error like "command not found," refer to the + [Dart installation guide](/get-dart) to set up your environment. + +### Create a new Dart project + +Now, create your first Dart command-line application. + +1. In the same terminal, + create a new directory called `dartpedia` to hold your project. + Then switch into that directory. + + ```bash + mkdir dartpedia + cd dartpedia + ``` + +1. Run the following command: + + ```bash + dart create cli + ``` + + The `dart create` command generates a basic Dart project named + "cli" (for Command Line Interface). + It sets up the essential files and directories you need. + +1. You should see output similar to this, confirming the project creation: + + ```bash + Creating cli using template console... + + .gitignore + analysis_options.yaml + CHANGELOG.md + pubspec.yaml + README.md + bin/cli.dart + lib/cli.dart + test/cli_test.dart + + Running pub get... 1.2s + Resolving dependencies... + Downloading packages... + Changed 49 dependencies! + + Created project cli in cli! In order to get started, run the following commands: + + cd cli + dart run + ``` + + :::note + The `dart create` command created a number of files. + Don't worry about these now. + Their specifics will be covered in future chapters. + ::: + +### Run your first Dart program + +Next, run your program to test it out. + +1. In the terminal, navigate into your new project directory: + + ```bash + cd cli + ``` + +1. Run the default application: + + ```bash + dart run + ``` + + This command tells Dart to execute your program. + +1. You should see the following output: + + ```bash + Building package executable... + Built cli:cli. + Hello world: 42! + ``` + + Congratulations! You've successfully run your first Dart program! + +### Make your first code change + +Next, modify the code that generated `Hello world: 42!`. + +1. In a code editor, open the `bin/cli.dart` file. + + The `bin/` directory is where your executable code lives. + `cli.dart` is the entry point of your application. + + Inside, you'll see the `main` function. + Every Dart program [starts executing from its `main` function](/language#hello-world). + +1. Check to make sure that your `bin/cli.dart` looks like this: + + ```dart title="bin/cli.dart" + import 'package:cli/cli.dart' as cli; + + void main(List arguments) { + print('Hello world: \${cli.calculate()}!'); + } + ``` + +1. Simplify the output for now. + Delete the first line (you don't need this import statement), and change the + `print` statement to display a simple greeting: + + ```dart title="bin/cli.dart" highlightLines=1,4 + import 'package:cli/cli.dart' as cli; // Delete this entire line + + void main(List arguments) { + print('Hello, Dart!'); // Change this line + } + ``` + +2. Save your file. Then in the terminal, run your program again: + + ```bash + dart run + ``` + +3. Check to make sure that you see the following: + + ```bash + Building package executable... + Built cli:cli. + Hello, Dart! + ``` + + You've successfully modified and re-run your first Dart program! + + + ''', ); diff --git a/site/lib/src/style_hash.dart b/site/lib/src/style_hash.dart index f24266cafb8..3809a8e015e 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 = 'm+c3m5q1zKUq'; +const generatedStylesHash = 'RSIRzXQzAwiu';