Skip to content

Commit 9597a29

Browse files
committed
add stepper component for fwe
1 parent 7af86bd commit 9597a29

File tree

7 files changed

+405
-1
lines changed

7 files changed

+405
-1
lines changed

site/lib/_sass/_site.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
@use 'components/sidebar';
3434
@use 'components/side-menu';
3535
@use 'components/site-switcher';
36+
@use 'components/stepper';
3637
@use 'components/tabs';
3738
@use 'components/theming';
3839
@use 'components/toc';
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
.stepper {
2+
3+
details {
4+
position: relative;
5+
margin: 0;
6+
padding-bottom: 1px;
7+
8+
// Vertical line between steps
9+
&::before {
10+
content: '';
11+
display: block;
12+
position: absolute;
13+
width: 2px;
14+
height: 100%;
15+
border-radius: 1px;
16+
background: var(--site-inset-borderColor);
17+
transform: translateY(2rem);
18+
}
19+
20+
&:last-child::before {
21+
transform: none;
22+
}
23+
24+
summary {
25+
position: relative;
26+
list-style: none;
27+
display: inline-flex;
28+
align-items: center;
29+
30+
width: 100%;
31+
padding-left: 1.5rem;
32+
padding-block: 1rem;
33+
34+
.step-number {
35+
position: absolute;
36+
left: -1rem;
37+
width: 2rem;
38+
height: 2rem;
39+
border-radius: 50%;
40+
background: var(--site-secondaryContainer-bgColor);
41+
color: var(--site-base-fgColor);
42+
display: flex;
43+
align-items: center;
44+
justify-content: center;
45+
font-weight: 600;
46+
47+
transition: background-color 300ms ease, color 300ms ease;
48+
}
49+
50+
.step-title {
51+
.header-wrapper, h1, h2, h3, h4, h5, h6 {
52+
margin: 0;
53+
}
54+
}
55+
56+
&::after {
57+
content: '^';
58+
position: absolute;
59+
right: 0;
60+
height: 1em;
61+
transition: transform 300ms ease;
62+
transform: rotate(180deg);
63+
transform-origin: center;
64+
}
65+
}
66+
67+
&[open] summary {
68+
.step-number {
69+
background-color: var(--site-primary-color);
70+
color: var(--site-onPrimary-color-lightest);
71+
}
72+
73+
&::after {
74+
transform: rotate(0);
75+
}
76+
}
77+
78+
.step-content {
79+
margin-left: 1.5rem;
80+
}
81+
82+
.step-actions {
83+
display: flex;
84+
justify-content: flex-end;
85+
}
86+
}
87+
}

site/lib/main.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import 'src/components/pages/archive_table.dart';
1919
import 'src/components/pages/devtools_release_notes_index.dart';
2020
import 'src/components/pages/expansion_list.dart';
2121
import 'src/components/pages/learning_resource_index.dart';
22+
import 'src/components/tutorial/stepper.dart';
2223
import 'src/extensions/registry.dart';
2324
import 'src/layouts/catalog_page_layout.dart';
2425
import 'src/layouts/doc_layout.dart';
@@ -96,6 +97,7 @@ List<CustomComponent> get _embeddableComponents => [
9697
const DashImage(),
9798
const YoutubeEmbed(),
9899
const FileTree(),
100+
const Stepper(),
99101
CustomComponent(
100102
pattern: RegExp('OSSelector', caseSensitive: false),
101103
builder: (_, _, _) => const OsSelector(),

site/lib/src/client/global_scripts.dart

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ void _setUpSite() {
4545
_setUpPlatformKeys();
4646
_setUpToc();
4747
_setUpTooltips();
48+
_setUpSteppers();
4849
}
4950

5051
void _setUpSearchKeybindings() {
@@ -539,3 +540,63 @@ void _ensureVisible(web.HTMLElement tooltip) {
539540
tooltip.dataset['adjusted'] = '0';
540541
}
541542
}
543+
544+
void _setUpSteppers() {
545+
final steppers = web.document.querySelectorAll('.stepper');
546+
547+
for (var i = 0; i < steppers.length; i++) {
548+
final stepper = steppers.item(i) as web.HTMLElement;
549+
final steps = stepper.querySelectorAll('details');
550+
551+
for (var j = 0; j < steps.length; j++) {
552+
final step = steps.item(j) as web.HTMLDetailsElement;
553+
554+
step.addEventListener(
555+
'toggle',
556+
((web.Event e) {
557+
// Close all other steps when one is opened.
558+
if (step.open) {
559+
for (var k = 0; k < steps.length; k++) {
560+
final otherStep = steps.item(k) as web.HTMLDetailsElement;
561+
if (otherStep != step) {
562+
otherStep.open = false;
563+
}
564+
}
565+
}
566+
}).toJS,
567+
);
568+
569+
final nextButton = step.querySelector('.next-step-button');
570+
if (nextButton != null) {
571+
nextButton.addEventListener(
572+
'click',
573+
((web.Event e) {
574+
e.preventDefault();
575+
step.open = false;
576+
_scrollTo(step, smooth: false);
577+
if (j + 1 < steps.length) {
578+
final nextStep = steps.item(j + 1) as web.HTMLDetailsElement;
579+
nextStep.open = true;
580+
_scrollTo(nextStep, smooth: true);
581+
}
582+
}).toJS,
583+
);
584+
}
585+
}
586+
}
587+
}
588+
589+
void _scrollTo(web.Element element, {required bool smooth}) {
590+
// Scroll the next step into view, accounting for the fixed header.
591+
final headerOffset =
592+
web.document.getElementById('site-header')?.clientHeight ?? 0;
593+
final elementPosition = element.getBoundingClientRect().top;
594+
final offsetPosition = elementPosition + web.window.scrollY - headerOffset;
595+
596+
web.window.scrollTo(
597+
web.ScrollToOptions(
598+
top: offsetPosition,
599+
behavior: smooth ? 'smooth' : 'auto',
600+
),
601+
);
602+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Copyright 2025 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:jaspr/jaspr.dart';
6+
import 'package:jaspr_content/jaspr_content.dart';
7+
8+
import '../common/button.dart';
9+
10+
class Stepper extends CustomComponent {
11+
const Stepper() : super.base();
12+
13+
@override
14+
Component? create(Node node, NodesBuilder builder) {
15+
if (node case ElementNode(
16+
tag: 'Stepper',
17+
:final attributes,
18+
:final children,
19+
)) {
20+
final levelStr = attributes['level'] ?? '1';
21+
final level = int.tryParse(levelStr) ?? 1;
22+
23+
assert(
24+
level >= 1 && level <= 6,
25+
'Stepper level must be between 1 and 6, got $level',
26+
);
27+
28+
final steps = <({Node title, List<Node> content})>[];
29+
30+
if (children != null) {
31+
for (final child in children) {
32+
if (child case final ElementNode heading
33+
when heading.tag == 'h$level') {
34+
steps.add((title: child, content: []));
35+
} else if (child case ElementNode(
36+
tag: 'div',
37+
attributes: {'class': 'header-wrapper'},
38+
children: [final ElementNode heading, ..._],
39+
) when heading.tag == 'h$level') {
40+
steps.add((title: child, content: []));
41+
} else {
42+
if (steps.isEmpty) {
43+
throw Exception(
44+
'Content found before first step title in Stepper. Make sure '
45+
'your Stepper content starts with a heading of level $level.',
46+
);
47+
}
48+
steps.last.content.add(child);
49+
}
50+
}
51+
}
52+
53+
assert(steps.isNotEmpty, 'Stepper must have at least one step.');
54+
55+
return div(classes: 'stepper', [
56+
for (final (index, step) in steps.indexed)
57+
details(open: index == 0, [
58+
summary([
59+
span(classes: 'step-number', [text('${index + 1}')]),
60+
div(classes: 'step-title', [
61+
builder.build([step.title]),
62+
]),
63+
]),
64+
div(classes: 'step-content', [
65+
builder.build(step.content),
66+
]),
67+
div(classes: 'step-actions', [
68+
Button(
69+
classes: ['next-step-button'],
70+
style: ButtonStyle.filled,
71+
content: index == steps.length - 1 ? 'Finish' : 'Continue',
72+
),
73+
]),
74+
]),
75+
]);
76+
}
77+
78+
return null;
79+
}
80+
}

0 commit comments

Comments
 (0)