From 62613d9deb706cca0904a8c28d7bac72bdc3d093 Mon Sep 17 00:00:00 2001 From: Kilian Schulte Date: Tue, 4 Nov 2025 13:47:37 +0100 Subject: [PATCH 1/5] add quiz component --- site/lib/_sass/_site.scss | 1 + site/lib/_sass/components/_quiz.scss | 102 +++++++++++++++ site/lib/jaspr_options.dart | 52 ++++---- site/lib/main.dart | 2 + site/lib/src/components/fwe/client/quiz.dart | 123 +++++++++++++++++++ site/lib/src/components/fwe/quiz.dart | 36 ++++++ site/lib/src/pages/custom_pages.dart | 31 +++++ site/lib/src/style_hash.dart | 2 +- site/pubspec.yaml | 1 + 9 files changed, 328 insertions(+), 22 deletions(-) create mode 100644 site/lib/_sass/components/_quiz.scss create mode 100644 site/lib/src/components/fwe/client/quiz.dart create mode 100644 site/lib/src/components/fwe/quiz.dart diff --git a/site/lib/_sass/_site.scss b/site/lib/_sass/_site.scss index a2afa1e4589..992f23edafd 100644 --- a/site/lib/_sass/_site.scss +++ b/site/lib/_sass/_site.scss @@ -30,6 +30,7 @@ @use 'components/next-prev-nav'; @use 'components/os-selector'; @use 'components/pill'; +@use 'components/quiz'; @use 'components/sidebar'; @use 'components/side-menu'; @use 'components/site-switcher'; diff --git a/site/lib/_sass/components/_quiz.scss b/site/lib/_sass/components/_quiz.scss new file mode 100644 index 00000000000..2c207329f7f --- /dev/null +++ b/site/lib/_sass/components/_quiz.scss @@ -0,0 +1,102 @@ +.quiz { + + + ol { + padding: 0; + margin: 0; + margin-top: 1rem; + list-style: upper-alpha; + list-style-position: inside; + + + li { + padding: 1rem; + background-color: var(--site-raised-bgColor); + border-radius: var(--site-radius); + margin-bottom: 0.2rem; + transition: background-color 500ms; + + &:not(:where(.selected, .disabled)):hover { + background-color: var(--site-inset-bgColor); + cursor: pointer; + } + + &.selected:has(.correct) { + background-color: oklch(from var(--site-alert-tip-color) l c h / 0.2); + } + + &.selected:has(.incorrect) { + background-color: oklch(from var(--site-alert-error-color) l c h / 0.2); + } + + &.disabled { + opacity: 0.6; + } + + p { + margin-bottom: 0; + } + + .question-wrapper { + display: grid; + grid-template-rows: min-content 0fr; + transition: grid-template-rows 500ms; + } + + &.selected .question-wrapper { + grid-template-rows: min-content 1fr; + } + + .question { + margin-top: -1lh; + margin-left: 1.4rem; + } + + .solution { + position: relative; + padding-left: 1.4rem; + font-size: 0.9rem; + overflow: hidden; + + p.correct, + p.incorrect { + padding-top: 0.5rem; + font-weight: 600; + margin-bottom: 0.5rem; + + &::before { + position: absolute; + left: 0; + } + } + + p.correct { + color: green; + + &::before { + content: "✓"; + } + } + + p.incorrect { + color: red; + + &::before { + content: "✗"; + } + } + } + } + } + + .quiz-button { + margin-top: 1rem; + + &[disabled] { + opacity: 0.4; + pointer-events: none; + } + } + + +} \ No newline at end of file diff --git a/site/lib/jaspr_options.dart b/site/lib/jaspr_options.dart index 24580968e97..085313bd01b 100644 --- a/site/lib/jaspr_options.dart +++ b/site/lib/jaspr_options.dart @@ -21,21 +21,23 @@ import 'package:docs_flutter_dev_site/src/components/common/client/os_selector.d as prefix6; import 'package:docs_flutter_dev_site/src/components/dartpad/dartpad_injector.dart' as prefix7; -import 'package:docs_flutter_dev_site/src/components/layout/menu_toggle.dart' +import 'package:docs_flutter_dev_site/src/components/fwe/client/quiz.dart' as prefix8; -import 'package:docs_flutter_dev_site/src/components/layout/site_switcher.dart' +import 'package:docs_flutter_dev_site/src/components/layout/menu_toggle.dart' as prefix9; -import 'package:docs_flutter_dev_site/src/components/layout/theme_switcher.dart' +import 'package:docs_flutter_dev_site/src/components/layout/site_switcher.dart' as prefix10; -import 'package:docs_flutter_dev_site/src/components/pages/archive_table.dart' +import 'package:docs_flutter_dev_site/src/components/layout/theme_switcher.dart' as prefix11; -import 'package:docs_flutter_dev_site/src/components/pages/glossary_search_section.dart' +import 'package:docs_flutter_dev_site/src/components/pages/archive_table.dart' as prefix12; -import 'package:docs_flutter_dev_site/src/components/pages/learning_resource_filters.dart' +import 'package:docs_flutter_dev_site/src/components/pages/glossary_search_section.dart' as prefix13; -import 'package:docs_flutter_dev_site/src/components/pages/learning_resource_filters_sidebar.dart' +import 'package:docs_flutter_dev_site/src/components/pages/learning_resource_filters.dart' as prefix14; -import 'package:jaspr_content/components/file_tree.dart' as prefix15; +import 'package:docs_flutter_dev_site/src/components/pages/learning_resource_filters_sidebar.dart' + as prefix15; +import 'package:jaspr_content/components/file_tree.dart' as prefix16; /// Default [JasprOptions] for use with your jaspr project. /// @@ -91,39 +93,44 @@ JasprOptions get defaultJasprOptions => JasprOptions( params: _prefix7DartPadInjector, ), - prefix8.MenuToggle: ClientTarget( + prefix8.InteractiveQuiz: ClientTarget( + 'src/components/fwe/client/quiz', + params: _prefix8InteractiveQuiz, + ), + + prefix9.MenuToggle: ClientTarget( 'src/components/layout/menu_toggle', ), - prefix9.SiteSwitcher: ClientTarget( + prefix10.SiteSwitcher: ClientTarget( 'src/components/layout/site_switcher', ), - prefix10.ThemeSwitcher: ClientTarget( + prefix11.ThemeSwitcher: ClientTarget( 'src/components/layout/theme_switcher', ), - prefix11.ArchiveTable: ClientTarget( + prefix12.ArchiveTable: ClientTarget( 'src/components/pages/archive_table', - params: _prefix11ArchiveTable, + params: _prefix12ArchiveTable, ), - prefix12.GlossarySearchSection: - ClientTarget( + prefix13.GlossarySearchSection: + ClientTarget( 'src/components/pages/glossary_search_section', ), - prefix13.LearningResourceFilters: - ClientTarget( + prefix14.LearningResourceFilters: + ClientTarget( 'src/components/pages/learning_resource_filters', ), - prefix14.LearningResourceFiltersSidebar: - ClientTarget( + prefix15.LearningResourceFiltersSidebar: + ClientTarget( 'src/components/pages/learning_resource_filters_sidebar', ), }, - styles: () => [...prefix15.FileTree.styles], + styles: () => [...prefix16.FileTree.styles], ); Map _prefix2CopyButton(prefix2.CopyButton c) => { @@ -144,7 +151,10 @@ Map _prefix7DartPadInjector(prefix7.DartPadInjector c) => { 'height': c.height, 'runAutomatically': c.runAutomatically, }; -Map _prefix11ArchiveTable(prefix11.ArchiveTable c) => { +Map _prefix8InteractiveQuiz(prefix8.InteractiveQuiz c) => { + 'question': c.question.toJson(), +}; +Map _prefix12ArchiveTable(prefix12.ArchiveTable c) => { 'os': c.os, 'channel': c.channel, }; diff --git a/site/lib/main.dart b/site/lib/main.dart index ad23bfe11d2..93599c4e27a 100644 --- a/site/lib/main.dart +++ b/site/lib/main.dart @@ -15,6 +15,7 @@ import 'src/components/common/client/os_selector.dart'; import 'src/components/common/dash_image.dart'; import 'src/components/common/tabs.dart'; import 'src/components/common/youtube_embed.dart'; +import 'src/components/fwe/quiz.dart'; import 'src/components/pages/archive_table.dart'; import 'src/components/pages/devtools_release_notes_index.dart'; import 'src/components/pages/expansion_list.dart'; @@ -96,6 +97,7 @@ List get _embeddableComponents => [ const DashImage(), const YoutubeEmbed(), const FileTree(), + const Quiz(), CustomComponent( pattern: RegExp('OSSelector', caseSensitive: false), builder: (_, _, _) => const OsSelector(), diff --git a/site/lib/src/components/fwe/client/quiz.dart b/site/lib/src/components/fwe/client/quiz.dart new file mode 100644 index 00000000000..2ceb79f43a1 --- /dev/null +++ b/site/lib/src/components/fwe/client/quiz.dart @@ -0,0 +1,123 @@ +// 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 '../../../util.dart'; +import '../../common/button.dart'; + +@client +class InteractiveQuiz extends StatefulComponent { + const InteractiveQuiz({required this.question, super.key}); + + final Question question; + + @override + State createState() => _InteractiveQuizState(); +} + +class _InteractiveQuizState extends State { + int? selectedOption; + + @override + Component build(BuildContext context) { + return div([ + strong([text(component.question.question)]), + ol([ + for (final (index, option) in component.question.options.indexed) + li( + classes: [ + if (selectedOption != null) + if (selectedOption == index) 'selected' else 'disabled', + ].toClasses, + events: { + 'click': (_) { + if (selectedOption != null) { + return; + } + setState(() { + selectedOption = index; + }); + }, + }, + [ + div(classes: 'question-wrapper', [ + div(classes: 'question', [ + p([text(option.text)]), + ]), + div(classes: 'solution', [ + if (option.correct) + p(classes: 'correct', [text('That\'s right!')]) + else + p(classes: 'incorrect', [text('Not quite')]), + p([text(option.explanation)]), + ]), + ]), + ], + ), + ]), + + Button( + classes: ['quiz-button'], + style: ButtonStyle.filled, + disabled: selectedOption == null, + onClick: () { + setState(() { + selectedOption = null; + }); + }, + content: selectedOption == null || component.question.options[selectedOption!].correct + ? 'Next question' + : 'Try again', + ), + ]); + } +} + +class Question { + const Question(this.question, this.options); + + final String question; + final List options; + + @decoder + factory Question.fromMap(Map json) { + return Question( + json['question'] as String, + (json['options'] as List) + .map((e) => AnswerOption.fromJson(e as Map)) + .toList(), + ); + } + + @encoder + Map toJson() => { + 'question': question, + 'options': options.map((e) => e.toJson()).toList(), + }; +} + +class AnswerOption { + const AnswerOption(this.text, this.correct, this.explanation); + + final String text; + final bool correct; + final String explanation; + + @decoder + factory AnswerOption.fromJson(Map json) { + return AnswerOption( + json['text'] as String, + json['correct'] as bool? ?? false, + json['explanation'] as String, + ); + } + + @encoder + Map toJson() => { + 'text': text, + 'correct': correct, + 'explanation': explanation, + }; +} diff --git a/site/lib/src/components/fwe/quiz.dart b/site/lib/src/components/fwe/quiz.dart new file mode 100644 index 00000000000..87dc5bef42c --- /dev/null +++ b/site/lib/src/components/fwe/quiz.dart @@ -0,0 +1,36 @@ +// 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 'package:yaml/yaml.dart'; + +import 'client/quiz.dart'; + +class Quiz extends CustomComponent { + const Quiz() : super.base(); + + @override + Component? create(Node node, NodesBuilder builder) { + if (node is ElementNode && node.tag.toLowerCase() == 'quiz') { + if (node.children?.whereType().isNotEmpty ?? false) { + throw Exception( + 'Invalid Quiz content. Remove any leading empty lines to ' + 'avoid parsing as markdown.', + ); + } + + final content = node.children?.map((n) => n.innerText).join('\n') ?? ''; + final data = loadYamlNode(content); + assert(data is YamlList, 'Invalid Quiz content. Expected a YAML list.'); + final questions = (data as YamlList).nodes + .map((n) => Question.fromMap(n as YamlMap)) + .toList(); + return div(classes: 'quiz not-content', [ + for (final question in questions) InteractiveQuiz(question: question), + ]); + } + return null; + } +} diff --git a/site/lib/src/pages/custom_pages.dart b/site/lib/src/pages/custom_pages.dart index 306df7d51fe..6f2c864cefb 100644 --- a/site/lib/src/pages/custom_pages.dart +++ b/site/lib/src/pages/custom_pages.dart @@ -15,6 +15,8 @@ import 'glossary.dart'; List get allMemoryPages => [ _glossaryPage, _devtoolsReleasesIndex, + // TODO(schultek): Remove this test page when FWE lands. + if (kDebugMode) _fweTestingPage, ]; /// The `/resources/glossary` page which hosts the [GlossaryIndex]. @@ -66,3 +68,32 @@ MemoryPage get _devtoolsReleasesIndex => MemoryPage.builder( return const Component.empty(); }, ); + +MemoryPage get _fweTestingPage => const MemoryPage( + path: 'fwe.md', + content: ''' +--- +title: FWE Testing Page +description: This is a test page for experimenting with First Week Experience (FWE) features. +sitemap: false +--- + + +- question: What is the Effective Dart guideline for the first sentence of a documentation comment? + options: + - text: It should be a complete paragraph with at least two sentences to provide sufficient context. + correct: false + explanation: The guideline recommends a short summary sentence, not a full paragraph. + - text: It must include the names of all parameters using square brackets. + correct: false + explanation: Parameter names can be included elsewhere, but the first sentence is a summary. + - text: It should be a single-sentence summary, separated from the rest of the comment by a blank line. + correct: true + explanation: Effective Dart recommends starting with a single-sentence summary, followed by a blank line before details. + - text: It should always begin with the name of the member being documented. + correct: false + explanation: Starting with the member name is not required; a concise summary is preferred. + + +''', +); diff --git a/site/lib/src/style_hash.dart b/site/lib/src/style_hash.dart index a32fe5cadcd..a55717726f3 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 = 'ZFZ+YS8Vr+JP'; +const generatedStylesHash = 'IqEKMf2RfnU2'; diff --git a/site/pubspec.yaml b/site/pubspec.yaml index 4a69f28c6ba..ca98fb94723 100644 --- a/site/pubspec.yaml +++ b/site/pubspec.yaml @@ -24,6 +24,7 @@ dependencies: path: ^1.9.1 pub_semver: ^2.2.0 universal_web: ^1.1.1+1 + yaml: ^3.1.3 dev_dependencies: analysis_defaults: From 691aabc66942c68a6d20a3a02c36b0e8fe7f5482 Mon Sep 17 00:00:00 2001 From: Kilian Schulte Date: Mon, 10 Nov 2025 10:49:36 +0100 Subject: [PATCH 2/5] add multi-question flow --- site/lib/_sass/components/_quiz.scss | 172 ++++++++++++------- site/lib/jaspr_options.dart | 3 +- site/lib/src/components/fwe/client/quiz.dart | 154 ++++++++++++----- site/lib/src/components/fwe/quiz.dart | 6 +- site/lib/src/pages/custom_pages.dart | 17 +- site/lib/src/style_hash.dart | 2 +- site/pubspec.yaml | 2 +- 7 files changed, 240 insertions(+), 116 deletions(-) diff --git a/site/lib/_sass/components/_quiz.scss b/site/lib/_sass/components/_quiz.scss index 2c207329f7f..585a7d8a430 100644 --- a/site/lib/_sass/components/_quiz.scss +++ b/site/lib/_sass/components/_quiz.scss @@ -1,95 +1,137 @@ .quiz { + display: flex; + flex-direction: column; - ol { - padding: 0; + background-color: var(--site-raised-bgColor-translucent); + border-radius: var(--site-radius); + padding: 1rem; + + .quiz-title { margin: 0; - margin-top: 1rem; - list-style: upper-alpha; - list-style-position: inside; + font-size: 1.5rem; + font-weight: 500; + color: var(--site-base-fgColor-lighter); + } + .quiz-progress { + font-size: 0.9rem; + color: var(--site-primary-color); + font-weight: 500; + } - li { - padding: 1rem; - background-color: var(--site-raised-bgColor); - border-radius: var(--site-radius); - margin-bottom: 0.2rem; - transition: background-color 500ms; + .quiz-question { + margin-top: 1.5rem; + display: none; - &:not(:where(.selected, .disabled)):hover { - background-color: var(--site-inset-bgColor); - cursor: pointer; - } + &.active { + display: block; + } - &.selected:has(.correct) { - background-color: oklch(from var(--site-alert-tip-color) l c h / 0.2); - } + ol { + padding: 0; + margin: 0; + margin-top: 1rem; + list-style: upper-alpha; + list-style-position: inside; - &.selected:has(.incorrect) { - background-color: oklch(from var(--site-alert-error-color) l c h / 0.2); - } - &.disabled { - opacity: 0.6; - } + li { + padding: 1rem; + background-color: var(--site-raised-bgColor); + border-radius: var(--site-radius); + margin-bottom: 0.2rem; + transition: background-color 500ms; - p { - margin-bottom: 0; - } + &:not(:where(.selected, .disabled)):hover { + background-color: var(--site-inset-bgColor); + cursor: pointer; + } - .question-wrapper { - display: grid; - grid-template-rows: min-content 0fr; - transition: grid-template-rows 500ms; - } + &.selected:has(.correct) { + background-color: oklch(from var(--site-alert-tip-color) l c h / 0.2); + } - &.selected .question-wrapper { - grid-template-rows: min-content 1fr; - } + &.selected:has(.incorrect) { + background-color: oklch(from var(--site-alert-error-color) l c h / 0.2); + } - .question { - margin-top: -1lh; - margin-left: 1.4rem; - } + &.disabled { + opacity: 0.6; + } - .solution { - position: relative; - padding-left: 1.4rem; - font-size: 0.9rem; - overflow: hidden; - - p.correct, - p.incorrect { - padding-top: 0.5rem; - font-weight: 600; - margin-bottom: 0.5rem; - - &::before { - position: absolute; - left: 0; - } + p { + margin-bottom: 0; } - p.correct { - color: green; + .question-wrapper { + display: grid; + grid-template-rows: min-content 0fr; + transition: grid-template-rows 500ms; + } - &::before { - content: "✓"; - } + &.selected .question-wrapper { + grid-template-rows: min-content 1fr; } - p.incorrect { - color: red; + .question { + margin-top: -1lh; + margin-left: 1.4rem; + } - &::before { - content: "✗"; + .solution { + position: relative; + padding-left: 1.4rem; + font-size: 0.9rem; + overflow: hidden; + + p.correct, + p.incorrect { + padding-top: 0.5rem; + font-weight: 600; + margin-bottom: 0.5rem; + + &::before { + position: absolute; + left: 0; + } + } + + p.correct { + color: green; + + &::before { + content: "✓"; + } + } + + p.incorrect { + color: red; + + &::before { + content: "✗"; + } } } } } + + } + + .quiz-complete { + min-height: 15rem; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + strong { + font-size: 2rem; + } } .quiz-button { + align-self: flex-end; margin-top: 1rem; &[disabled] { @@ -97,6 +139,4 @@ pointer-events: none; } } - - } \ No newline at end of file diff --git a/site/lib/jaspr_options.dart b/site/lib/jaspr_options.dart index 085313bd01b..81cf9cf6817 100644 --- a/site/lib/jaspr_options.dart +++ b/site/lib/jaspr_options.dart @@ -152,7 +152,8 @@ Map _prefix7DartPadInjector(prefix7.DartPadInjector c) => { 'runAutomatically': c.runAutomatically, }; Map _prefix8InteractiveQuiz(prefix8.InteractiveQuiz c) => { - 'question': c.question.toJson(), + 'title': c.title, + 'questions': c.questions.map((i) => i.toJson()).toList(), }; Map _prefix12ArchiveTable(prefix12.ArchiveTable c) => { 'os': c.os, diff --git a/site/lib/src/components/fwe/client/quiz.dart b/site/lib/src/components/fwe/client/quiz.dart index 2ceb79f43a1..d646941647a 100644 --- a/site/lib/src/components/fwe/client/quiz.dart +++ b/site/lib/src/components/fwe/client/quiz.dart @@ -9,67 +9,137 @@ import '../../common/button.dart'; @client class InteractiveQuiz extends StatefulComponent { - const InteractiveQuiz({required this.question, super.key}); + const InteractiveQuiz({ + required this.title, + required this.questions, + super.key, + }); - final Question question; + final String? title; + final List questions; @override State createState() => _InteractiveQuizState(); } class _InteractiveQuizState extends State { - int? selectedOption; + int currentQuestionIndex = 0; + int? selectedOptionIndex; + + Question? get currentQuestion { + if (currentQuestionIndex >= component.questions.length) { + return null; + } + return component.questions[currentQuestionIndex]; + } + + AnswerOption? get selectedOption { + final question = currentQuestion; + if (question == null || selectedOptionIndex == null) { + return null; + } + return question.options[selectedOptionIndex!]; + } @override Component build(BuildContext context) { - return div([ - strong([text(component.question.question)]), - ol([ - for (final (index, option) in component.question.options.indexed) - li( - classes: [ - if (selectedOption != null) - if (selectedOption == index) 'selected' else 'disabled', - ].toClasses, - events: { - 'click': (_) { - if (selectedOption != null) { - return; - } - setState(() { - selectedOption = index; - }); - }, - }, - [ - div(classes: 'question-wrapper', [ - div(classes: 'question', [ - p([text(option.text)]), - ]), - div(classes: 'solution', [ - if (option.correct) - p(classes: 'correct', [text('That\'s right!')]) - else - p(classes: 'incorrect', [text('Not quite')]), - p([text(option.explanation)]), - ]), - ]), - ], - ), + return div(classes: 'quiz not-content', [ + if (component.title case final title?) + h3(classes: 'quiz-title', [ + text(title), + ]), + span(classes: 'quiz-progress', [ + text( + currentQuestion != null + ? '${currentQuestionIndex + 1} / ${component.questions.length}' + : 'Complete', + ), ]), + for (final question in component.questions) + div( + classes: [ + 'quiz-question', + if (question == currentQuestion) 'active', + ].toClasses, + [ + strong([text(question.question)]), + ol([ + for (final (index, option) in question.options.indexed) + li( + classes: [ + if (option == selectedOption) + 'selected' + else if (selectedOption != null) + 'disabled', + ].toClasses, + events: { + 'click': (_) { + if (selectedOption != null) { + return; + } + setState(() { + selectedOptionIndex = index; + }); + }, + }, + [ + div(classes: 'question-wrapper', [ + div(classes: 'question', [ + p([text(option.text)]), + ]), + div(classes: 'solution', [ + if (option.correct) + p(classes: 'correct', [text('That\'s right!')]) + else + p(classes: 'incorrect', [text('Not quite')]), + p([text(option.explanation)]), + ]), + ]), + ], + ), + ]), + ], + ), + + if (currentQuestion == null) + div(classes: 'quiz-complete', [ + strong([text('Great job!')]), + p([text('You completed the quiz.')]), + ]), Button( classes: ['quiz-button'], style: ButtonStyle.filled, - disabled: selectedOption == null, + disabled: currentQuestion != null && selectedOption == null, onClick: () { + if (currentQuestion == null) { + // Restart the quiz. + setState(() { + currentQuestionIndex = 0; + selectedOptionIndex = null; + }); + return; + } + if (selectedOption == null) return; + final correct = selectedOption!.correct; setState(() { - selectedOption = null; + selectedOptionIndex = null; + if (correct) { + currentQuestionIndex++; + } }); }, - content: selectedOption == null || component.question.options[selectedOption!].correct - ? 'Next question' - : 'Try again', + content: switch (( + currentQuestion == null, + currentQuestionIndex == component.questions.length - 1, + selectedOption?.correct, + )) { + // (isComplete, isLast, isCorrect) + (true, _, _) => 'Restart', + (false, _, false) => 'Try again', + (false, false, _) => 'Next question', + (false, true, _) => 'Finish', + }, ), ]); } diff --git a/site/lib/src/components/fwe/quiz.dart b/site/lib/src/components/fwe/quiz.dart index 87dc5bef42c..d5f9aa5ff4c 100644 --- a/site/lib/src/components/fwe/quiz.dart +++ b/site/lib/src/components/fwe/quiz.dart @@ -21,15 +21,15 @@ class Quiz extends CustomComponent { ); } + final title = node.attributes['title']; + final content = node.children?.map((n) => n.innerText).join('\n') ?? ''; final data = loadYamlNode(content); assert(data is YamlList, 'Invalid Quiz content. Expected a YAML list.'); final questions = (data as YamlList).nodes .map((n) => Question.fromMap(n as YamlMap)) .toList(); - return div(classes: 'quiz not-content', [ - for (final question in questions) InteractiveQuiz(question: question), - ]); + return InteractiveQuiz(title: title, questions: questions); } return null; } diff --git a/site/lib/src/pages/custom_pages.dart b/site/lib/src/pages/custom_pages.dart index 6f2c864cefb..283d5690303 100644 --- a/site/lib/src/pages/custom_pages.dart +++ b/site/lib/src/pages/custom_pages.dart @@ -78,7 +78,7 @@ description: This is a test page for experimenting with First Week Experience (F sitemap: false --- - + - question: What is the Effective Dart guideline for the first sentence of a documentation comment? options: - text: It should be a complete paragraph with at least two sentences to provide sufficient context. @@ -93,7 +93,20 @@ sitemap: false - text: It should always begin with the name of the member being documented. correct: false explanation: Starting with the member name is not required; a concise summary is preferred. - +- question: In Flutter, which widget is typically used to create a scrollable list of items? + options: + - text: Column + correct: false + explanation: A Column is not scrollable by default; use ListView for scrollable lists. + - text: ListView + correct: true + explanation: ListView is the standard widget for creating scrollable lists in Flutter. + - text: Row + correct: false + explanation: A Row arranges items horizontally and is not scrollable by default. + - text: Stack + correct: false + explanation: Stack is used for overlapping widgets, not for scrollable lists. ''', ); diff --git a/site/lib/src/style_hash.dart b/site/lib/src/style_hash.dart index a55717726f3..b41a00d4972 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 = 'IqEKMf2RfnU2'; +const generatedStylesHash = '6SVQoGaxdzin'; diff --git a/site/pubspec.yaml b/site/pubspec.yaml index ca98fb94723..706bf7a27cc 100644 --- a/site/pubspec.yaml +++ b/site/pubspec.yaml @@ -32,7 +32,7 @@ dev_dependencies: url: https://github.com/dart-lang/site-shared path: pkgs/analysis_defaults ref: f91ed8ecef6a0b31685804fe4102b25fda021460 - build_runner: ^2.10.1 + build_runner: ^2.10.2 build_web_compilers: ^4.4.0 jaspr_builder: ^0.21.6 sass: ^1.93.3 From b0abca77bc4d4ef9433e48bea1e46f76a49cc365 Mon Sep 17 00:00:00 2001 From: Kilian Schulte Date: Tue, 11 Nov 2025 18:33:13 +0100 Subject: [PATCH 3/5] add Previous button to quiz --- site/lib/_sass/components/_quiz.scss | 21 +++-- site/lib/src/components/fwe/client/quiz.dart | 92 ++++++++++++-------- site/lib/src/style_hash.dart | 2 +- 3 files changed, 72 insertions(+), 43 deletions(-) diff --git a/site/lib/_sass/components/_quiz.scss b/site/lib/_sass/components/_quiz.scss index 585a7d8a430..ff6f3178d2f 100644 --- a/site/lib/_sass/components/_quiz.scss +++ b/site/lib/_sass/components/_quiz.scss @@ -130,13 +130,22 @@ } } - .quiz-button { - align-self: flex-end; + .quiz-actions { + display: flex; + justify-content: space-between; margin-top: 1rem; - &[disabled] { - opacity: 0.4; - pointer-events: none; + + .quiz-button { + &.secondary { + background-color: var(--site-inset-bgColor); + color: var(--site-base-fgColor); + } + + &[disabled] { + opacity: 0.4; + pointer-events: none; + } } } -} \ No newline at end of file +} diff --git a/site/lib/src/components/fwe/client/quiz.dart b/site/lib/src/components/fwe/client/quiz.dart index d646941647a..8edfdf3a909 100644 --- a/site/lib/src/components/fwe/client/quiz.dart +++ b/site/lib/src/components/fwe/client/quiz.dart @@ -24,7 +24,7 @@ class InteractiveQuiz extends StatefulComponent { class _InteractiveQuizState extends State { int currentQuestionIndex = 0; - int? selectedOptionIndex; + List selectedOptionIndices = []; Question? get currentQuestion { if (currentQuestionIndex >= component.questions.length) { @@ -35,10 +35,11 @@ class _InteractiveQuizState extends State { AnswerOption? get selectedOption { final question = currentQuestion; - if (question == null || selectedOptionIndex == null) { + if (question == null || + selectedOptionIndices.length <= currentQuestionIndex) { return null; } - return question.options[selectedOptionIndex!]; + return question.options[selectedOptionIndices[currentQuestionIndex]]; } @override @@ -78,7 +79,12 @@ class _InteractiveQuizState extends State { return; } setState(() { - selectedOptionIndex = index; + if (selectedOptionIndices.length <= + currentQuestionIndex) { + selectedOptionIndices.add(index); + } else { + selectedOptionIndices[currentQuestionIndex] = index; + } }); }, }, @@ -106,41 +112,55 @@ class _InteractiveQuizState extends State { strong([text('Great job!')]), p([text('You completed the quiz.')]), ]), - - Button( - classes: ['quiz-button'], - style: ButtonStyle.filled, - disabled: currentQuestion != null && selectedOption == null, - onClick: () { - if (currentQuestion == null) { - // Restart the quiz. + div(classes: 'quiz-actions', [ + Button( + classes: ['quiz-button', 'secondary'], + style: ButtonStyle.filled, + disabled: currentQuestionIndex == 0, + onClick: () { setState(() { - currentQuestionIndex = 0; - selectedOptionIndex = null; + currentQuestionIndex--; }); - return; - } - if (selectedOption == null) return; - final correct = selectedOption!.correct; - setState(() { - selectedOptionIndex = null; - if (correct) { - currentQuestionIndex++; + }, + content: 'Previous', + ), + Button( + classes: ['quiz-button'], + style: ButtonStyle.filled, + disabled: currentQuestion != null && selectedOption == null, + onClick: () { + if (currentQuestion == null) { + // Restart the quiz. + setState(() { + currentQuestionIndex = 0; + selectedOptionIndices = []; + }); + return; } - }); - }, - content: switch (( - currentQuestion == null, - currentQuestionIndex == component.questions.length - 1, - selectedOption?.correct, - )) { - // (isComplete, isLast, isCorrect) - (true, _, _) => 'Restart', - (false, _, false) => 'Try again', - (false, false, _) => 'Next question', - (false, true, _) => 'Finish', - }, - ), + if (selectedOption == null) return; + final correct = selectedOption!.correct; + setState(() { + if (correct) { + currentQuestionIndex++; + } else { + // Clear the selected option to allow retry. + selectedOptionIndices.removeLast(); + } + }); + }, + content: switch (( + currentQuestion == null, + currentQuestionIndex == component.questions.length - 1, + selectedOption?.correct, + )) { + // (isComplete, isLast, isCorrect) + (true, _, _) => 'Restart', + (false, _, false) => 'Try again', + (false, false, _) => 'Next question', + (false, true, _) => 'Finish', + }, + ), + ]), ]); } } diff --git a/site/lib/src/style_hash.dart b/site/lib/src/style_hash.dart index b41a00d4972..01c19f49c0e 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 = '6SVQoGaxdzin'; +const generatedStylesHash = 'QrzHTJ1NLibt'; From e15a3a000b360318792800a416aa1c71da295c1a Mon Sep 17 00:00:00 2001 From: Kilian Schulte Date: Wed, 12 Nov 2025 11:09:48 +0100 Subject: [PATCH 4/5] apply review and improve accessibility and keyboard interaction --- site/lib/_sass/components/_quiz.scss | 10 +- site/lib/jaspr_options.dart | 58 +++++----- site/lib/main.dart | 2 +- site/lib/src/components/common/button.dart | 4 + .../{fwe => tutorial}/client/quiz.dart | 105 +++++++----------- .../components/{fwe => tutorial}/quiz.dart | 2 + site/lib/src/models/quiz_model.dart | 48 ++++++++ site/lib/src/style_hash.dart | 2 +- 8 files changed, 130 insertions(+), 101 deletions(-) rename site/lib/src/components/{fwe => tutorial}/client/quiz.dart (70%) rename site/lib/src/components/{fwe => tutorial}/quiz.dart (91%) create mode 100644 site/lib/src/models/quiz_model.dart diff --git a/site/lib/_sass/components/_quiz.scss b/site/lib/_sass/components/_quiz.scss index ff6f3178d2f..5ff758be64d 100644 --- a/site/lib/_sass/components/_quiz.scss +++ b/site/lib/_sass/components/_quiz.scss @@ -43,20 +43,20 @@ margin-bottom: 0.2rem; transition: background-color 500ms; - &:not(:where(.selected, .disabled)):hover { + &:not(:where([aria-pressed="true"], [aria-disabled="true"])):hover { background-color: var(--site-inset-bgColor); cursor: pointer; } - &.selected:has(.correct) { + &[aria-pressed="true"]:has(.correct) { background-color: oklch(from var(--site-alert-tip-color) l c h / 0.2); } - &.selected:has(.incorrect) { + &[aria-pressed="true"]:has(.incorrect) { background-color: oklch(from var(--site-alert-error-color) l c h / 0.2); } - &.disabled { + &:not([aria-pressed="true"])[aria-disabled="true"] { opacity: 0.6; } @@ -70,7 +70,7 @@ transition: grid-template-rows 500ms; } - &.selected .question-wrapper { + &[aria-pressed="true"] .question-wrapper { grid-template-rows: min-content 1fr; } diff --git a/site/lib/jaspr_options.dart b/site/lib/jaspr_options.dart index 81cf9cf6817..175ad48d16a 100644 --- a/site/lib/jaspr_options.dart +++ b/site/lib/jaspr_options.dart @@ -21,21 +21,21 @@ import 'package:docs_flutter_dev_site/src/components/common/client/os_selector.d as prefix6; import 'package:docs_flutter_dev_site/src/components/dartpad/dartpad_injector.dart' as prefix7; -import 'package:docs_flutter_dev_site/src/components/fwe/client/quiz.dart' - as prefix8; import 'package:docs_flutter_dev_site/src/components/layout/menu_toggle.dart' - as prefix9; + as prefix8; import 'package:docs_flutter_dev_site/src/components/layout/site_switcher.dart' - as prefix10; + as prefix9; import 'package:docs_flutter_dev_site/src/components/layout/theme_switcher.dart' - as prefix11; + as prefix10; import 'package:docs_flutter_dev_site/src/components/pages/archive_table.dart' - as prefix12; + as prefix11; import 'package:docs_flutter_dev_site/src/components/pages/glossary_search_section.dart' - as prefix13; + as prefix12; import 'package:docs_flutter_dev_site/src/components/pages/learning_resource_filters.dart' - as prefix14; + as prefix13; import 'package:docs_flutter_dev_site/src/components/pages/learning_resource_filters_sidebar.dart' + as prefix14; +import 'package:docs_flutter_dev_site/src/components/tutorial/client/quiz.dart' as prefix15; import 'package:jaspr_content/components/file_tree.dart' as prefix16; @@ -93,42 +93,42 @@ JasprOptions get defaultJasprOptions => JasprOptions( params: _prefix7DartPadInjector, ), - prefix8.InteractiveQuiz: ClientTarget( - 'src/components/fwe/client/quiz', - params: _prefix8InteractiveQuiz, - ), - - prefix9.MenuToggle: ClientTarget( + prefix8.MenuToggle: ClientTarget( 'src/components/layout/menu_toggle', ), - prefix10.SiteSwitcher: ClientTarget( + prefix9.SiteSwitcher: ClientTarget( 'src/components/layout/site_switcher', ), - prefix11.ThemeSwitcher: ClientTarget( + prefix10.ThemeSwitcher: ClientTarget( 'src/components/layout/theme_switcher', ), - prefix12.ArchiveTable: ClientTarget( + prefix11.ArchiveTable: ClientTarget( 'src/components/pages/archive_table', - params: _prefix12ArchiveTable, + params: _prefix11ArchiveTable, ), - prefix13.GlossarySearchSection: - ClientTarget( + prefix12.GlossarySearchSection: + ClientTarget( 'src/components/pages/glossary_search_section', ), - prefix14.LearningResourceFilters: - ClientTarget( + prefix13.LearningResourceFilters: + ClientTarget( 'src/components/pages/learning_resource_filters', ), - prefix15.LearningResourceFiltersSidebar: - ClientTarget( + prefix14.LearningResourceFiltersSidebar: + ClientTarget( 'src/components/pages/learning_resource_filters_sidebar', ), + + prefix15.InteractiveQuiz: ClientTarget( + 'src/components/tutorial/client/quiz', + params: _prefix15InteractiveQuiz, + ), }, styles: () => [...prefix16.FileTree.styles], ); @@ -151,11 +151,11 @@ Map _prefix7DartPadInjector(prefix7.DartPadInjector c) => { 'height': c.height, 'runAutomatically': c.runAutomatically, }; -Map _prefix8InteractiveQuiz(prefix8.InteractiveQuiz c) => { - 'title': c.title, - 'questions': c.questions.map((i) => i.toJson()).toList(), -}; -Map _prefix12ArchiveTable(prefix12.ArchiveTable c) => { +Map _prefix11ArchiveTable(prefix11.ArchiveTable c) => { 'os': c.os, 'channel': c.channel, }; +Map _prefix15InteractiveQuiz(prefix15.InteractiveQuiz c) => { + 'title': c.title, + 'questions': c.questions.map((i) => i.toJson()).toList(), +}; diff --git a/site/lib/main.dart b/site/lib/main.dart index 93599c4e27a..9d58ec40e3c 100644 --- a/site/lib/main.dart +++ b/site/lib/main.dart @@ -15,11 +15,11 @@ import 'src/components/common/client/os_selector.dart'; import 'src/components/common/dash_image.dart'; import 'src/components/common/tabs.dart'; import 'src/components/common/youtube_embed.dart'; -import 'src/components/fwe/quiz.dart'; import 'src/components/pages/archive_table.dart'; import 'src/components/pages/devtools_release_notes_index.dart'; import 'src/components/pages/expansion_list.dart'; import 'src/components/pages/learning_resource_index.dart'; +import 'src/components/tutorial/quiz.dart'; import 'src/extensions/registry.dart'; import 'src/layouts/catalog_page_layout.dart'; import 'src/layouts/doc_layout.dart'; diff --git a/site/lib/src/components/common/button.dart b/site/lib/src/components/common/button.dart index 41eea870529..e1c486c0202 100644 --- a/site/lib/src/components/common/button.dart +++ b/site/lib/src/components/common/button.dart @@ -16,6 +16,7 @@ class Button extends StatelessComponent { this.href, this.content, this.style = ButtonStyle.text, + this.ref, this.id, this.attributes = const {}, this.classes, @@ -29,6 +30,7 @@ class Button extends StatelessComponent { final String? title; final ButtonStyle style; final String? icon; + final Key? ref; final String? id; final String? href; final Map attributes; @@ -59,6 +61,7 @@ class Button extends StatelessComponent { if (href case final href?) { return a( + key: ref, id: id, href: href, classes: mergedClasses, @@ -68,6 +71,7 @@ class Button extends StatelessComponent { ); } else { return button( + key: ref, id: id, classes: mergedClasses, attributes: mergedAttributes, diff --git a/site/lib/src/components/fwe/client/quiz.dart b/site/lib/src/components/tutorial/client/quiz.dart similarity index 70% rename from site/lib/src/components/fwe/client/quiz.dart rename to site/lib/src/components/tutorial/client/quiz.dart index 8edfdf3a909..bc354c09ca3 100644 --- a/site/lib/src/components/fwe/client/quiz.dart +++ b/site/lib/src/components/tutorial/client/quiz.dart @@ -3,7 +3,9 @@ // found in the LICENSE file. import 'package:jaspr/jaspr.dart'; +import 'package:universal_web/web.dart' as web; +import '../../../models/quiz_model.dart'; import '../../../util.dart'; import '../../common/button.dart'; @@ -23,6 +25,9 @@ class InteractiveQuiz extends StatefulComponent { } class _InteractiveQuizState extends State { + final quizKey = GlobalNodeKey(); + final nextButtonKey = GlobalNodeKey(); + int currentQuestionIndex = 0; List selectedOptionIndices = []; @@ -42,9 +47,29 @@ class _InteractiveQuizState extends State { return question.options[selectedOptionIndices[currentQuestionIndex]]; } + void toggleOption(int index, [bool fromKeyboard = false]) { + if (selectedOption != null) { + return; + } + setState(() { + if (selectedOptionIndices.length <= currentQuestionIndex) { + selectedOptionIndices.add(index); + } else { + selectedOptionIndices[currentQuestionIndex] = index; + } + }); + if (fromKeyboard) { + context.binding.addPostFrameCallback(() { + // Move focus to the next button. + final nextButton = nextButtonKey.currentNode; + nextButton?.focus(); + }); + } + } + @override Component build(BuildContext context) { - return div(classes: 'quiz not-content', [ + return div(key: quizKey, classes: 'quiz not-content', [ if (component.title case final title?) h3(classes: 'quiz-title', [ text(title), @@ -67,25 +92,21 @@ class _InteractiveQuizState extends State { ol([ for (final (index, option) in question.options.indexed) li( - classes: [ - if (option == selectedOption) - 'selected' - else if (selectedOption != null) - 'disabled', - ].toClasses, + attributes: { + 'role': 'button', + if (selectedOption == null) 'tabindex': '0', + if (option == selectedOption) 'aria-pressed': 'true', + if (selectedOption != null) 'aria-disabled': 'true', + }, events: { 'click': (_) { - if (selectedOption != null) { - return; + toggleOption(index); + }, + 'keyup': (event) { + if ((event as web.KeyboardEvent).key == 'Enter' || + event.key == ' ') { + toggleOption(index, true); } - setState(() { - if (selectedOptionIndices.length <= - currentQuestionIndex) { - selectedOptionIndices.add(index); - } else { - selectedOptionIndices[currentQuestionIndex] = index; - } - }); }, }, [ @@ -125,6 +146,7 @@ class _InteractiveQuizState extends State { content: 'Previous', ), Button( + ref: nextButtonKey, classes: ['quiz-button'], style: ButtonStyle.filled, disabled: currentQuestion != null && selectedOption == null, @@ -157,57 +179,10 @@ class _InteractiveQuizState extends State { (true, _, _) => 'Restart', (false, _, false) => 'Try again', (false, false, _) => 'Next question', - (false, true, _) => 'Finish', + (false, true, _) => 'Finish quiz', }, ), ]), ]); } } - -class Question { - const Question(this.question, this.options); - - final String question; - final List options; - - @decoder - factory Question.fromMap(Map json) { - return Question( - json['question'] as String, - (json['options'] as List) - .map((e) => AnswerOption.fromJson(e as Map)) - .toList(), - ); - } - - @encoder - Map toJson() => { - 'question': question, - 'options': options.map((e) => e.toJson()).toList(), - }; -} - -class AnswerOption { - const AnswerOption(this.text, this.correct, this.explanation); - - final String text; - final bool correct; - final String explanation; - - @decoder - factory AnswerOption.fromJson(Map json) { - return AnswerOption( - json['text'] as String, - json['correct'] as bool? ?? false, - json['explanation'] as String, - ); - } - - @encoder - Map toJson() => { - 'text': text, - 'correct': correct, - 'explanation': explanation, - }; -} diff --git a/site/lib/src/components/fwe/quiz.dart b/site/lib/src/components/tutorial/quiz.dart similarity index 91% rename from site/lib/src/components/fwe/quiz.dart rename to site/lib/src/components/tutorial/quiz.dart index d5f9aa5ff4c..d552df4f8c3 100644 --- a/site/lib/src/components/fwe/quiz.dart +++ b/site/lib/src/components/tutorial/quiz.dart @@ -6,6 +6,7 @@ import 'package:jaspr/jaspr.dart'; import 'package:jaspr_content/jaspr_content.dart'; import 'package:yaml/yaml.dart'; +import '../../models/quiz_model.dart'; import 'client/quiz.dart'; class Quiz extends CustomComponent { @@ -29,6 +30,7 @@ class Quiz extends CustomComponent { final questions = (data as YamlList).nodes .map((n) => Question.fromMap(n as YamlMap)) .toList(); + assert(questions.isNotEmpty, 'Quiz must contain at least one question.'); return InteractiveQuiz(title: title, questions: questions); } return null; diff --git a/site/lib/src/models/quiz_model.dart b/site/lib/src/models/quiz_model.dart new file mode 100644 index 00000000000..4f38e27b687 --- /dev/null +++ b/site/lib/src/models/quiz_model.dart @@ -0,0 +1,48 @@ +import 'package:jaspr/jaspr.dart'; + +class Question { + const Question(this.question, this.options); + + final String question; + final List options; + + @decoder + factory Question.fromMap(Map json) { + return Question( + json['question'] as String, + (json['options'] as List) + .map((e) => AnswerOption.fromJson(e as Map)) + .toList(), + ); + } + + @encoder + Map toJson() => { + 'question': question, + 'options': options.map((e) => e.toJson()).toList(), + }; +} + +class AnswerOption { + const AnswerOption(this.text, this.correct, this.explanation); + + final String text; + final bool correct; + final String explanation; + + @decoder + factory AnswerOption.fromJson(Map json) { + return AnswerOption( + json['text'] as String, + json['correct'] as bool? ?? false, + json['explanation'] as String, + ); + } + + @encoder + Map toJson() => { + 'text': text, + 'correct': correct, + 'explanation': explanation, + }; +} diff --git a/site/lib/src/style_hash.dart b/site/lib/src/style_hash.dart index 01c19f49c0e..857bd898044 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 = 'QrzHTJ1NLibt'; +const generatedStylesHash = 'qOfrsugdLKU5'; From 0bf4f1896272161e5c85bdfd071900cfc0aa8012 Mon Sep 17 00:00:00 2001 From: Parker Lougheed Date: Wed, 12 Nov 2025 11:17:58 -0600 Subject: [PATCH 5/5] Remove extra blank lines --- site/lib/_sass/components/_quiz.scss | 3 --- 1 file changed, 3 deletions(-) diff --git a/site/lib/_sass/components/_quiz.scss b/site/lib/_sass/components/_quiz.scss index 5ff758be64d..cff526640a7 100644 --- a/site/lib/_sass/components/_quiz.scss +++ b/site/lib/_sass/components/_quiz.scss @@ -1,5 +1,4 @@ .quiz { - display: flex; flex-direction: column; @@ -35,7 +34,6 @@ list-style: upper-alpha; list-style-position: inside; - li { padding: 1rem; background-color: var(--site-raised-bgColor); @@ -135,7 +133,6 @@ justify-content: space-between; margin-top: 1rem; - .quiz-button { &.secondary { background-color: var(--site-inset-bgColor);