Skip to content

Commit 1c8c779

Browse files
committed
add quiz component
1 parent eb85efd commit 1c8c779

File tree

9 files changed

+326
-20
lines changed

9 files changed

+326
-20
lines changed

site/lib/_sass/_site.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
@use 'components/next-prev-nav';
3131
@use 'components/os-selector';
3232
@use 'components/pill';
33+
@use 'components/quiz';
3334
@use 'components/sidebar';
3435
@use 'components/side-menu';
3536
@use 'components/site-switcher';
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
.quiz {
2+
3+
4+
ol {
5+
padding: 0;
6+
margin: 0;
7+
margin-top: 1rem;
8+
list-style: upper-alpha;
9+
list-style-position: inside;
10+
11+
12+
li {
13+
padding: 1rem;
14+
background-color: var(--site-raised-bgColor);
15+
border-radius: var(--site-radius);
16+
margin-bottom: 0.2rem;
17+
transition: background-color 500ms;
18+
19+
&:not(:where(.selected, .disabled)):hover {
20+
background-color: var(--site-inset-bgColor);
21+
cursor: pointer;
22+
}
23+
24+
&.selected:has(.correct) {
25+
background-color: oklch(from var(--site-alert-tip-color) l c h / 0.2);
26+
}
27+
28+
&.selected:has(.incorrect) {
29+
background-color: oklch(from var(--site-alert-error-color) l c h / 0.2);
30+
}
31+
32+
&.disabled {
33+
opacity: 0.6;
34+
}
35+
36+
p {
37+
margin-bottom: 0;
38+
}
39+
40+
.question-wrapper {
41+
display: grid;
42+
grid-template-rows: min-content 0fr;
43+
transition: grid-template-rows 500ms;
44+
}
45+
46+
&.selected .question-wrapper {
47+
grid-template-rows: min-content 1fr;
48+
}
49+
50+
.question {
51+
margin-top: -1lh;
52+
margin-left: 1.4rem;
53+
}
54+
55+
.solution {
56+
position: relative;
57+
padding-left: 1.4rem;
58+
font-size: 0.9rem;
59+
overflow: hidden;
60+
61+
p.correct,
62+
p.incorrect {
63+
padding-top: 0.5rem;
64+
font-weight: 600;
65+
margin-bottom: 0.5rem;
66+
67+
&::before {
68+
position: absolute;
69+
left: 0;
70+
}
71+
}
72+
73+
p.correct {
74+
color: green;
75+
76+
&::before {
77+
content: "";
78+
}
79+
}
80+
81+
p.incorrect {
82+
color: red;
83+
84+
&::before {
85+
content: "";
86+
}
87+
}
88+
}
89+
}
90+
}
91+
92+
.quiz-button {
93+
margin-top: 1rem;
94+
95+
&[disabled] {
96+
opacity: 0.4;
97+
pointer-events: none;
98+
}
99+
}
100+
101+
102+
}

site/lib/jaspr_options.dart

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -21,20 +21,22 @@ import 'package:docs_flutter_dev_site/src/components/common/client/os_selector.d
2121
as prefix6;
2222
import 'package:docs_flutter_dev_site/src/components/dartpad/dartpad_injector.dart'
2323
as prefix7;
24-
import 'package:docs_flutter_dev_site/src/components/layout/menu_toggle.dart'
24+
import 'package:docs_flutter_dev_site/src/components/fwe/client/quiz.dart'
2525
as prefix8;
26-
import 'package:docs_flutter_dev_site/src/components/layout/site_switcher.dart'
26+
import 'package:docs_flutter_dev_site/src/components/layout/menu_toggle.dart'
2727
as prefix9;
28-
import 'package:docs_flutter_dev_site/src/components/layout/theme_switcher.dart'
28+
import 'package:docs_flutter_dev_site/src/components/layout/site_switcher.dart'
2929
as prefix10;
30-
import 'package:docs_flutter_dev_site/src/components/pages/archive_table.dart'
30+
import 'package:docs_flutter_dev_site/src/components/layout/theme_switcher.dart'
3131
as prefix11;
32-
import 'package:docs_flutter_dev_site/src/components/pages/glossary_search_section.dart'
32+
import 'package:docs_flutter_dev_site/src/components/pages/archive_table.dart'
3333
as prefix12;
34-
import 'package:docs_flutter_dev_site/src/components/pages/learning_resource_filters.dart'
34+
import 'package:docs_flutter_dev_site/src/components/pages/glossary_search_section.dart'
3535
as prefix13;
36-
import 'package:docs_flutter_dev_site/src/components/pages/learning_resource_filters_sidebar.dart'
36+
import 'package:docs_flutter_dev_site/src/components/pages/learning_resource_filters.dart'
3737
as prefix14;
38+
import 'package:docs_flutter_dev_site/src/components/pages/learning_resource_filters_sidebar.dart'
39+
as prefix15;
3840

3941
/// Default [JasprOptions] for use with your jaspr project.
4042
///
@@ -90,35 +92,40 @@ JasprOptions get defaultJasprOptions => JasprOptions(
9092
params: _prefix7DartPadInjector,
9193
),
9294

93-
prefix8.MenuToggle: ClientTarget<prefix8.MenuToggle>(
95+
prefix8.InteractiveQuiz: ClientTarget<prefix8.InteractiveQuiz>(
96+
'src/components/fwe/client/quiz',
97+
params: _prefix8InteractiveQuiz,
98+
),
99+
100+
prefix9.MenuToggle: ClientTarget<prefix9.MenuToggle>(
94101
'src/components/layout/menu_toggle',
95102
),
96103

97-
prefix9.SiteSwitcher: ClientTarget<prefix9.SiteSwitcher>(
104+
prefix10.SiteSwitcher: ClientTarget<prefix10.SiteSwitcher>(
98105
'src/components/layout/site_switcher',
99106
),
100107

101-
prefix10.ThemeSwitcher: ClientTarget<prefix10.ThemeSwitcher>(
108+
prefix11.ThemeSwitcher: ClientTarget<prefix11.ThemeSwitcher>(
102109
'src/components/layout/theme_switcher',
103110
),
104111

105-
prefix11.ArchiveTable: ClientTarget<prefix11.ArchiveTable>(
112+
prefix12.ArchiveTable: ClientTarget<prefix12.ArchiveTable>(
106113
'src/components/pages/archive_table',
107-
params: _prefix11ArchiveTable,
114+
params: _prefix12ArchiveTable,
108115
),
109116

110-
prefix12.GlossarySearchSection:
111-
ClientTarget<prefix12.GlossarySearchSection>(
117+
prefix13.GlossarySearchSection:
118+
ClientTarget<prefix13.GlossarySearchSection>(
112119
'src/components/pages/glossary_search_section',
113120
),
114121

115-
prefix13.LearningResourceFilters:
116-
ClientTarget<prefix13.LearningResourceFilters>(
122+
prefix14.LearningResourceFilters:
123+
ClientTarget<prefix14.LearningResourceFilters>(
117124
'src/components/pages/learning_resource_filters',
118125
),
119126

120-
prefix14.LearningResourceFiltersSidebar:
121-
ClientTarget<prefix14.LearningResourceFiltersSidebar>(
127+
prefix15.LearningResourceFiltersSidebar:
128+
ClientTarget<prefix15.LearningResourceFiltersSidebar>(
122129
'src/components/pages/learning_resource_filters_sidebar',
123130
),
124131
},
@@ -143,7 +150,10 @@ Map<String, dynamic> _prefix7DartPadInjector(prefix7.DartPadInjector c) => {
143150
'height': c.height,
144151
'runAutomatically': c.runAutomatically,
145152
};
146-
Map<String, dynamic> _prefix11ArchiveTable(prefix11.ArchiveTable c) => {
153+
Map<String, dynamic> _prefix8InteractiveQuiz(prefix8.InteractiveQuiz c) => {
154+
'question': c.question.toJson(),
155+
};
156+
Map<String, dynamic> _prefix12ArchiveTable(prefix12.ArchiveTable c) => {
147157
'os': c.os,
148158
'channel': c.channel,
149159
};

site/lib/main.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import 'src/components/common/client/os_selector.dart';
1414
import 'src/components/common/dash_image.dart';
1515
import 'src/components/common/tabs.dart';
1616
import 'src/components/common/youtube_embed.dart';
17+
import 'src/components/fwe/quiz.dart';
1718
import 'src/components/pages/archive_table.dart';
1819
import 'src/components/pages/devtools_release_notes_index.dart';
1920
import 'src/components/pages/expansion_list.dart';
@@ -94,6 +95,7 @@ List<CustomComponent> get _embeddableComponents => [
9495
const DashTabs(),
9596
const DashImage(),
9697
const YoutubeEmbed(),
98+
const Quiz(),
9799
CustomComponent(
98100
pattern: RegExp('OSSelector', caseSensitive: false),
99101
builder: (_, _, _) => const OsSelector(),
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
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+
7+
import '../../../util.dart';
8+
import '../../common/button.dart';
9+
10+
@client
11+
class InteractiveQuiz extends StatefulComponent {
12+
const InteractiveQuiz({required this.question, super.key});
13+
14+
final Question question;
15+
16+
@override
17+
State<InteractiveQuiz> createState() => _InteractiveQuizState();
18+
}
19+
20+
class _InteractiveQuizState extends State<InteractiveQuiz> {
21+
int? selectedOption;
22+
23+
@override
24+
Component build(BuildContext context) {
25+
return div([
26+
strong([text(component.question.question)]),
27+
ol([
28+
for (final (index, option) in component.question.options.indexed)
29+
li(
30+
classes: [
31+
if (selectedOption != null)
32+
if (selectedOption == index) 'selected' else 'disabled',
33+
].toClasses,
34+
events: {
35+
'click': (_) {
36+
if (selectedOption != null) {
37+
return;
38+
}
39+
setState(() {
40+
selectedOption = index;
41+
});
42+
},
43+
},
44+
[
45+
div(classes: 'question-wrapper', [
46+
div(classes: 'question', [
47+
p([text(option.text)]),
48+
]),
49+
div(classes: 'solution', [
50+
if (option.correct)
51+
p(classes: 'correct', [text('That\'s right!')])
52+
else
53+
p(classes: 'incorrect', [text('Not quite')]),
54+
p([text(option.explanation)]),
55+
]),
56+
]),
57+
],
58+
),
59+
]),
60+
61+
Button(
62+
classes: ['quiz-button'],
63+
style: ButtonStyle.filled,
64+
disabled: selectedOption == null,
65+
onClick: () {
66+
setState(() {
67+
selectedOption = null;
68+
});
69+
},
70+
content: selectedOption == null || component.question.options[selectedOption!].correct
71+
? 'Next question'
72+
: 'Try again',
73+
),
74+
]);
75+
}
76+
}
77+
78+
class Question {
79+
const Question(this.question, this.options);
80+
81+
final String question;
82+
final List<AnswerOption> options;
83+
84+
@decoder
85+
factory Question.fromMap(Map<Object?, Object?> json) {
86+
return Question(
87+
json['question'] as String,
88+
(json['options'] as List<Object?>)
89+
.map((e) => AnswerOption.fromJson(e as Map<Object?, Object?>))
90+
.toList(),
91+
);
92+
}
93+
94+
@encoder
95+
Map<Object?, Object?> toJson() => {
96+
'question': question,
97+
'options': options.map((e) => e.toJson()).toList(),
98+
};
99+
}
100+
101+
class AnswerOption {
102+
const AnswerOption(this.text, this.correct, this.explanation);
103+
104+
final String text;
105+
final bool correct;
106+
final String explanation;
107+
108+
@decoder
109+
factory AnswerOption.fromJson(Map<Object?, Object?> json) {
110+
return AnswerOption(
111+
json['text'] as String,
112+
json['correct'] as bool? ?? false,
113+
json['explanation'] as String,
114+
);
115+
}
116+
117+
@encoder
118+
Map<Object?, Object?> toJson() => {
119+
'text': text,
120+
'correct': correct,
121+
'explanation': explanation,
122+
};
123+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
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+
import 'package:yaml/yaml.dart';
8+
9+
import 'client/quiz.dart';
10+
11+
class Quiz extends CustomComponent {
12+
const Quiz() : super.base();
13+
14+
@override
15+
Component? create(Node node, NodesBuilder builder) {
16+
if (node is ElementNode && node.tag.toLowerCase() == 'quiz') {
17+
if (node.children?.whereType<ElementNode>().isNotEmpty ?? false) {
18+
throw Exception(
19+
'Invalid Quiz content. Remove any leading empty lines to '
20+
'avoid parsing as markdown.',
21+
);
22+
}
23+
24+
final content = node.children?.map((n) => n.innerText).join('\n') ?? '';
25+
final data = loadYamlNode(content);
26+
assert(data is YamlList, 'Invalid Quiz content. Expected a YAML list.');
27+
final questions = (data as YamlList).nodes
28+
.map((n) => Question.fromMap(n as YamlMap))
29+
.toList();
30+
return div(classes: 'quiz not-content', [
31+
for (final question in questions) InteractiveQuiz(question: question),
32+
]);
33+
}
34+
return null;
35+
}
36+
}

0 commit comments

Comments
 (0)