Skip to content

Commit ce0d21b

Browse files
committed
add stepper component for fwe
1 parent 803b526 commit ce0d21b

File tree

7 files changed

+401
-1
lines changed

7 files changed

+401
-1
lines changed

site/lib/_sass/_site.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
@use 'components/sidebar';
3535
@use 'components/side-menu';
3636
@use 'components/site-switcher';
37+
@use 'components/stepper';
3738
@use 'components/tabs';
3839
@use 'components/theming';
3940
@use 'components/toc';
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
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+
display: flex;
27+
align-items: center;
28+
list-style: none;
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+
span.material-symbols {
57+
position: absolute;
58+
right: 0;
59+
transition: transform 300ms ease;
60+
transform: rotate(180deg);
61+
transform-origin: center;
62+
}
63+
}
64+
65+
&[open] summary {
66+
.step-number {
67+
background-color: var(--site-primary-color);
68+
color: var(--site-onPrimary-color-lightest);
69+
}
70+
71+
span.material-symbols {
72+
transform: rotate(0);
73+
}
74+
}
75+
76+
.step-content {
77+
margin-left: 1.5rem;
78+
}
79+
80+
.step-actions {
81+
display: flex;
82+
justify-content: flex-end;
83+
}
84+
}
85+
}

site/lib/main.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ 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';
2222
import 'src/components/tutorial/quiz.dart';
23+
import 'src/components/tutorial/stepper.dart';
2324
import 'src/extensions/registry.dart';
2425
import 'src/layouts/catalog_page_layout.dart';
2526
import 'src/layouts/doc_layout.dart';
@@ -98,6 +99,7 @@ List<CustomComponent> get _embeddableComponents => [
9899
const YoutubeEmbed(),
99100
const FileTree(),
100101
const Quiz(),
102+
const Stepper(),
101103
CustomComponent(
102104
pattern: RegExp('OSSelector', caseSensitive: false),
103105
builder: (_, _, _) => const OsSelector(),

site/lib/src/client/global_scripts.dart

Lines changed: 63 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,65 @@ 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 and toc.
591+
final headerOffset =
592+
web.document.getElementById('site-header')?.clientHeight ?? 0;
593+
final tocOffset = web.document.getElementById('toc-top')?.clientHeight ?? 0;
594+
final elementPosition = element.getBoundingClientRect().top;
595+
final offsetPosition =
596+
elementPosition + web.window.scrollY - headerOffset - tocOffset;
597+
598+
web.window.scrollTo(
599+
web.ScrollToOptions(
600+
top: offsetPosition,
601+
behavior: smooth ? 'smooth' : 'auto',
602+
),
603+
);
604+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
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+
import '../common/material_icon.dart';
10+
11+
class Stepper extends CustomComponent {
12+
const Stepper() : super.base();
13+
14+
@override
15+
Component? create(Node node, NodesBuilder builder) {
16+
if (node case ElementNode(
17+
tag: 'Stepper',
18+
:final attributes,
19+
:final children,
20+
)) {
21+
final levelStr = attributes['level'] ?? '1';
22+
final level = int.tryParse(levelStr) ?? 1;
23+
24+
assert(
25+
level >= 1 && level <= 6,
26+
'Stepper level must be between 1 and 6, got $level',
27+
);
28+
29+
final steps = <({Node title, List<Node> content})>[];
30+
31+
if (children != null) {
32+
for (final child in children) {
33+
if (child case final ElementNode heading
34+
when heading.tag == 'h$level') {
35+
steps.add((title: child, content: []));
36+
} else if (child case ElementNode(
37+
tag: 'div',
38+
attributes: {'class': 'header-wrapper'},
39+
children: [final ElementNode heading, ..._],
40+
) when heading.tag == 'h$level') {
41+
steps.add((title: child, content: []));
42+
} else {
43+
if (steps.isEmpty) {
44+
throw Exception(
45+
'Content found before first step title in Stepper. Make sure '
46+
'your Stepper content starts with a heading of level $level.',
47+
);
48+
}
49+
steps.last.content.add(child);
50+
}
51+
}
52+
}
53+
54+
assert(steps.isNotEmpty, 'Stepper must have at least one step.');
55+
56+
return div(classes: 'stepper', [
57+
for (final (index, step) in steps.indexed)
58+
details(open: index == 0, [
59+
summary([
60+
span(
61+
classes: 'step-number',
62+
attributes: {'aria-label': 'Step ${index + 1}'},
63+
[text('${index + 1}')],
64+
),
65+
div(classes: 'step-title', [
66+
builder.build([step.title]),
67+
]),
68+
const MaterialIcon('keyboard_arrow_up'),
69+
]),
70+
div(classes: 'step-content', [
71+
builder.build(step.content),
72+
]),
73+
div(classes: 'step-actions', [
74+
Button(
75+
classes: ['next-step-button'],
76+
style: ButtonStyle.filled,
77+
content: index == steps.length - 1 ? 'Finish' : 'Continue',
78+
),
79+
]),
80+
]),
81+
]);
82+
}
83+
84+
return null;
85+
}
86+
}

0 commit comments

Comments
 (0)