Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions site/lib/_sass/_site.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
90 changes: 90 additions & 0 deletions site/lib/_sass/components/_stepper.scss
Original file line number Diff line number Diff line change
@@ -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;
}
Comment on lines +20 to +22
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The vertical line connecting steps should not be visible for the last step. Using transform: none; resets the vertical translation but still renders the line, causing it to overlap with the step number. To hide the line for the last step, you should use display: none; instead.

Suggested change
&:last-child::before {
transform: none;
}
&:last-child::before {
display: none;
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should, to still provide a border for the content. Wdyt @parlough?


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;
}
}
}
2 changes: 2 additions & 0 deletions site/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -100,6 +101,7 @@ List<CustomComponent> get _embeddableComponents => [
const FileTree(),
const Quiz(),
const ProgressRing(),
const Stepper(),
CustomComponent(
pattern: RegExp('OSSelector', caseSensitive: false),
builder: (_, _, _) => const OsSelector(),
Expand Down
63 changes: 63 additions & 0 deletions site/lib/src/client/global_scripts.dart
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ void _setUpSite() {
_setUpPlatformKeys();
_setUpToc();
_setUpTooltips();
_setUpSteppers();
}

void _setUpSearchKeybindings() {
Expand Down Expand Up @@ -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',
),
);
}
86 changes: 86 additions & 0 deletions site/lib/src/components/tutorial/stepper.dart
Original file line number Diff line number Diff line change
@@ -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<Node> 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;
}
}
Loading