diff --git a/sites/docs/lib/_sass/_site.scss b/sites/docs/lib/_sass/_site.scss index 99092c6a676..d29bb9a7b27 100644 --- a/sites/docs/lib/_sass/_site.scss +++ b/sites/docs/lib/_sass/_site.scss @@ -14,6 +14,7 @@ @use 'components/content'; @use 'components/expansion-list'; @use 'components/filter-search'; +@use 'components/filterable-index'; @use 'components/footer'; @use 'components/header'; @use 'components/icons'; @@ -48,6 +49,7 @@ @use 'package:site_shared/_sass/components/tooltip'; // Styles for specific pages, alphabetically ordered. +@use 'pages/cuj-index'; @use 'pages/glossary'; @use 'pages/learning-resources-index'; @use 'pages/not-found'; diff --git a/sites/docs/lib/_sass/components/_filterable-index.scss b/sites/docs/lib/_sass/components/_filterable-index.scss new file mode 100644 index 00000000000..1d881c23a3c --- /dev/null +++ b/sites/docs/lib/_sass/components/_filterable-index.scss @@ -0,0 +1,224 @@ +// Layout and controls shared by the filterable index pages: the learning +// resources index and the critical user journey index. +// +// The markup comes from the `FiltersSidebar` and `FilterSearchGroup` +// components in `filterable_index.dart`, so everything here is keyed off +// classes. Each page styles its own results list separately, keyed off the +// id of that list. + +$mobile-breakpoint: 839px; +$sidebar-width: 220px; + +.filterable-index { + display: flex; + flex-direction: row; + + .left-col { + margin-right: 1rem; + flex: 2; + } + + .right-col { + width: $sidebar-width; + } +} + +// The sidebar follows the page as it scrolls on wide screens, and becomes an +// off-canvas drawer on narrow ones. Everything within it, including any +// page-specific footer, slides in and out as a single unit. +.filter-sidebar { + position: sticky; + top: calc(var(--site-header-height) + 1rem); +} + +.filter-group-wrapper { + border: 1px solid var(--site-inset-borderColor); + background-color: var(--site-inset-bgColor); + border-radius: var(--site-radius); + overflow: hidden; +} + +// Only shown while the sidebar is a drawer, to close it again. +.filter-header { + display: none; + position: absolute; + top: 0; + right: 0; + padding: 0.75rem 1rem; + z-index: 10; +} + +.close-icon { + cursor: pointer; + padding: 0.25rem; + border-radius: 4px; + background: none; + border: none; + color: var(--site-base-fgColor-alt); + transition: all 0.2s ease; + position: absolute; + right: 1rem; + top: 75%; + transform: translateY(-50%); + + &:hover { + background-color: rgba(0, 0, 0, 0.1); + color: var(--site-primary-color); + } + + .material-symbols { + font-size: 20px; + } +} + +.filter-group { + .table-title { + text-align: center; + color: var(--site-base-fgColor-alt); + background-color: var(--site-raised-bgColor); + font-family: var(--site-ui-fontFamily); + font-weight: 600; + font-size: .925rem; + padding: .5rem; + border-bottom: 1px solid var(--site-inset-borderColor); + } + + .table-content { + padding: 1rem; + } + + ul { + padding-left: 0; + margin-bottom: .5rem; + + li { + list-style: none; + padding-left: 0; + padding-bottom: .25rem; + display: flex; + align-items: center; + + label { + padding-left: .35rem; + font-size: .9rem; + } + } + } + + h4 { + margin: 0 0 .5rem; + padding: 0; + } + + button { + color: var(--site-primary-color); + margin: 0 0 2rem; + padding: .5rem 0 1rem 1rem; + + &:hover { + color: var(--site-onPrimary-color); + } + } + + .hidden { + display: none; + } +} + +.filter-search-group { + display: flex; + flex-direction: column; + + button.show-filters-button { + @media (min-width: $mobile-breakpoint + 1) { + display: none; + } + } + + .label-row { + display: flex; + justify-content: space-between; + font-size: .925rem; + + label { + font-family: var(--site-ui-fontFamily); + color: var(--site-base-fgColor-lighter); + + padding: .25rem 1rem 0 0; + margin: 0; + text-align: end; + } + + button { + padding: .25rem; + color: var(--site-primary-color); + display: flex; + align-items: center; + + &:hover { + color: var(--site-onPrimary-color-light); + } + + &:disabled, + &[disabled] { + color: var(--site-inset-bgColor-translucent); + cursor: default; + } + } + } +} + +// On narrow screens the sidebar becomes a drawer that slides in from the +// right. Opening and closing is driven entirely by the hidden +// `#open-filter-toggle` checkbox that precedes it. +@media (max-width: $mobile-breakpoint) { + .filterable-index { + flex-direction: column; + } + + .left-col { + margin-right: 0; + } + + // The drawer itself is fixed-positioned, so this column no longer needs to + // reserve width for it in the flex layout. + .right-col { + width: auto; + } + + .filter-header { + display: block; + } + + .filter-sidebar { + position: fixed; + top: var(--site-header-height); + bottom: 0; + right: -$sidebar-width; + width: $sidebar-width; + display: flex; + flex-direction: column; + background-color: var(--site-inset-bgColor); + border-left: 1px solid var(--site-inset-borderColor); + transition: right 0.3s ease-in-out; + z-index: 1000; + } + + #open-filter-toggle:checked + .filter-sidebar { + right: 0; + } + + // Fill the drawer rather than floating within it as a card, + // and scroll on its own so any footer stays in view. + .filter-group-wrapper { + flex: 1; + min-height: 0; + overflow-y: auto; + border: none; + border-radius: 0; + } + + .filter-group .table-title { + background-color: var(--site-raised-bgColor-translucent); + } +} diff --git a/sites/docs/lib/_sass/pages/_cuj-index.scss b/sites/docs/lib/_sass/pages/_cuj-index.scss new file mode 100644 index 00000000000..9a07f49a1eb --- /dev/null +++ b/sites/docs/lib/_sass/pages/_cuj-index.scss @@ -0,0 +1,141 @@ +@use 'sass:color'; + +// The critical user journey index reuses the two column layout, search field, +// and filter sidebar of the learning resources index, which are styled in +// `_filterable-index.scss`. +// +// The journeys themselves render as full-width expandable cards, following +// the glossary in `_glossary.scss`. Expanding and collapsing is wired up +// by the `_setUpExpandableCards` global script. + +// The feedback button is the sidebar footer, so it sits below the filter card +// and slides in with it when the sidebar becomes a drawer. +.cuj-feedback { + margin-block-start: 1rem; + + .outlined-button { + width: 100%; + justify-content: center; + } + + @media (max-width: 839px) { + margin-block-start: 0; + padding: 0.75rem; + border-top: 1px solid var(--site-inset-borderColor); + } +} + +#all-cujs-list { + margin-block-start: 1rem; + + .cuj-card { + height: auto; + padding: 0.75rem 1rem; + gap: 0.25rem; + + .card-header { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: flex-start; + gap: 0.5rem; + } + + .cuj-card-heading { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.4rem; + // Allow long goals to wrap instead of widening the flex item. + min-width: 0; + } + + .card-title { + display: block; + margin: 0; + font-family: var(--site-ui-fontFamily); + font-size: 1.1rem; + font-weight: 500; + line-height: 1.35; + text-wrap: pretty; + } + + .card-header-buttons { + display: flex; + flex-direction: row; + align-items: center; + gap: 0.25rem; + flex-shrink: 0; + + .icon-button { + border-radius: 1.5rem; + + > span { + font-size: 1.5rem; + } + } + } + + .cuj-task-count { + margin: 0; + font-size: 0.9rem; + color: var(--site-base-fgColor-lighter); + } + + // The shared card styles lay `.card-content` out as a centered row. + // Journeys need a plain block so the task list stacks below the divider. + .card-content { + display: block; + border-top: 0.05rem solid var(--site-inset-borderColor); + margin-block-start: 0.5rem; + padding-block-start: 0.5rem; + } + + &.collapsed { + .card-content { + display: none; + } + + .expand-button { + transform: rotate(180deg); + } + } + + .expand-button { + &:hover, + &:focus-within { + transition: transform .25s ease-out; + } + } + + .cuj-task-list { + margin: 0; + padding-left: 1.15rem; + + li { + padding-left: 0; + margin-block-end: 0.5rem; + font-size: 0.9rem; + line-height: 1.45; + color: var(--site-base-fgColor-alt); + + &:last-child { + margin-block-end: 0; + } + } + } + + // Two additional persona colors, following the `.pill-sm` modifiers. + // TODO(parlough): Consider if these colors can be shared and if + // compatible with dark mode. + .pill-sm.magenta { + color: color.scale(#FBD9EF, $lightness: 10%); + background: #9A2E6E; + } + + .pill-sm.amber { + color: color.scale(#FCEBC8, $lightness: 10%); + background: #8A5300; + } + } +} diff --git a/sites/docs/lib/_sass/pages/_learning-resources-index.scss b/sites/docs/lib/_sass/pages/_learning-resources-index.scss index 0e6581adbd5..5d316703d11 100644 --- a/sites/docs/lib/_sass/pages/_learning-resources-index.scss +++ b/sites/docs/lib/_sass/pages/_learning-resources-index.scss @@ -1,235 +1,8 @@ @use 'package:site_shared/_sass/base/mixins'; -#resource-filter-group-wrapper { - border: 1px solid var(--site-inset-borderColor); - background-color: var(--site-inset-bgColor); - border-radius: var(--site-radius); - overflow: hidden; - position: sticky; - top: calc(var(--site-header-height) + 1rem); -} - -.filter-header { - display: none; - position: absolute; - top: 0; - right: 0; - padding: 0.75rem 1rem; - z-index: 10; -} - -.close-icon { - cursor: pointer; - padding: 0.25rem; - border-radius: 4px; - background: none; - border: none; - color: var(--site-base-fgColor-alt); - transition: all 0.2s ease; - position: absolute; - right: 1rem; - top: 75%; - transform: translateY(-50%); - - &:hover { - background-color: rgba(0, 0, 0, 0.1); - color: var(--site-primary-color); - } - - .material-symbols { - font-size: 20px; - } -} - -// Mobile screen customizations. -@media (max-width: 839px) { - .filter-header { - display: block; - } - - #resource-filter-group-wrapper { - position: fixed; - top: var(--site-header-height); - bottom: 0; - right: -220px; - width: 220px; - border-bottom: none; - height: 100%; - border-radius: 0; - transition: right 0.3s ease-in-out; - z-index: 1000; - } - - #open-filter-toggle:not(:checked)+#resource-filter-group-wrapper { - right: -220px; - } - - #open-filter-toggle:checked+#resource-filter-group-wrapper { - right: 0; - } -} - -//Desktop screens -@media (min-width: 840px) { - #resource-filter-group { - position: static !important; - right: auto !important; - } - - .filter-header { - display: none !important; - } -} - -#resource-index-content { - display: flex; - flex-direction: row; - - .left-col { - margin-right: 1rem; - flex: 2; - } - - .right-col { - width: 220px; - - @media (max-width: 840px) { - position: fixed; - top: var(--site-header-height); - bottom: 0; - right: -15rem; - box-shadow: 0 6px 18px 0 rgba(0, 0, 0, 0.2); - border-radius: 0.4rem; - width: 220px; - - @keyframes slidein { - 0% { - right: -10rem; - } - - 100% { - right: 0; - } - } - - &.show { - animation-duration: 500ms; - animation-delay: 200ms; - animation-name: slidein; - animation-iteration-count: 1; - animation-timing-function: ease; - animation-fill-mode: forwards; - } - } - } -} - -#resource-filter-group { - .table-title { - text-align: center; - color: var(--site-base-fgColor-alt); - background-color: var(--site-raised-bgColor); - font-family: var(--site-ui-fontFamily); - font-weight: 600; - font-size: .925rem; - padding: .5rem; - border-bottom: 1px solid var(--site-inset-borderColor); - } - - .table-content { - padding: 1rem; - } - - ul { - padding-left: 0; - margin-bottom: .5rem; - - li { - list-style: none; - padding-left: 0; - padding-bottom: .25rem; - display: flex; - align-items: center; - - label { - padding-left: .35rem; - font-size: .9rem; - } - } - } - - h4 { - margin: 0 0 .5rem; - padding: 0; - } - - button { - color: var(--site-primary-color); - margin: 0 0 2rem; - padding: .5rem 0 1rem 1rem; - - &:hover { - color: var(--site-onPrimary-color); - } - } - - @media (max-width: 840px) { - border-bottom: none; - height: 100%; - border-radius: 0; - - .table-title { - background-color: var(--site-raised-bgColor-translucent); - } - } - - .hidden { - display: none; - } -} - -#resource-search-group { - display: flex; - flex-direction: column; - - button.show-filters-button { - @media (min-width: 840px) { - display: none; - } - } - - .label-row { - display: flex; - justify-content: space-between; - font-size: .925rem; - - label { - font-family: var(--site-ui-fontFamily); - color: var(--site-base-fgColor-lighter); - - padding: .25rem 1rem 0 0; - margin: 0; - text-align: end; - } - - button { - padding: .25rem; - color: var(--site-primary-color); - display: flex; - align-items: center; - - &:hover { - color: var(--site-onPrimary-color-light); - } - - &:disabled, - &[disabled] { - color: var(--site-inset-bgColor-translucent); - cursor: default; - } - } - } -} +// The learning resources index shares its two column layout, search field, +// and filter sidebar with the critical user journey index, so those styles +// live in `_filterable-index.scss`. #all-resources-grid { margin-block-start: 1rem; diff --git a/sites/docs/lib/main.client.options.dart b/sites/docs/lib/main.client.options.dart index 23c1b571e81..3547ffcfd5b 100644 --- a/sites/docs/lib/main.client.options.dart +++ b/sites/docs/lib/main.client.options.dart @@ -14,6 +14,10 @@ import 'package:docs_flutter_dev_site/src/components/layout/client/pagenav.dart' deferred as _pagenav; import 'package:docs_flutter_dev_site/src/components/pages/archive_table.dart' deferred as _archive_table; +import 'package:docs_flutter_dev_site/src/components/pages/cuj_filters.dart' + deferred as _cuj_filters; +import 'package:docs_flutter_dev_site/src/components/pages/cuj_filters_sidebar.dart' + deferred as _cuj_filters_sidebar; import 'package:docs_flutter_dev_site/src/components/pages/glossary_search_section.dart' deferred as _glossary_search_section; import 'package:docs_flutter_dev_site/src/components/pages/learning_resource_filters.dart' @@ -96,6 +100,14 @@ ClientOptions get defaultClientOptions => ClientOptions( ), loader: _archive_table.loadLibrary, ), + 'cuj_filters': ClientLoader( + (p) => _cuj_filters.CujFilters(), + loader: _cuj_filters.loadLibrary, + ), + 'cuj_filters_sidebar': ClientLoader( + (p) => _cuj_filters_sidebar.CujFiltersSidebar(), + loader: _cuj_filters_sidebar.loadLibrary, + ), 'glossary_search_section': ClientLoader( (p) => _glossary_search_section.GlossarySearchSection(), loader: _glossary_search_section.loadLibrary, diff --git a/sites/docs/lib/main.server.dart b/sites/docs/lib/main.server.dart index 2738c1fd439..26b5373eb5f 100644 --- a/sites/docs/lib/main.server.dart +++ b/sites/docs/lib/main.server.dart @@ -27,6 +27,7 @@ import 'src/components/common/code_preview.dart'; import 'src/components/common/dash_image.dart'; import 'src/components/pages/architecture_recommendations.dart'; import 'src/components/pages/archive_table.dart'; +import 'src/components/pages/cuj_index.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'; @@ -116,6 +117,7 @@ List get _embeddableComponents => [ defineComponent('OSSelector', const OsSelector()), defineComponentWithChild('Card', Card.fromAttributes), defineComponent('LearningResourceIndex', const LearningResourceIndex()), + defineComponent('CujIndex', const CujIndex()), defineComponentWithAttrs('ArchiveTable', ArchiveTable.fromAttributes), defineComponentWithAttrs( 'DownloadLatestButton', diff --git a/sites/docs/lib/main.server.options.dart b/sites/docs/lib/main.server.options.dart index fcbd5e0a090..1c131de872a 100644 --- a/sites/docs/lib/main.server.options.dart +++ b/sites/docs/lib/main.server.options.dart @@ -13,6 +13,10 @@ import 'package:docs_flutter_dev_site/src/components/layout/client/pagenav.dart' as _pagenav; import 'package:docs_flutter_dev_site/src/components/pages/archive_table.dart' as _archive_table; +import 'package:docs_flutter_dev_site/src/components/pages/cuj_filters.dart' + as _cuj_filters; +import 'package:docs_flutter_dev_site/src/components/pages/cuj_filters_sidebar.dart' + as _cuj_filters_sidebar; import 'package:docs_flutter_dev_site/src/components/pages/glossary_search_section.dart' as _glossary_search_section; import 'package:docs_flutter_dev_site/src/components/pages/learning_resource_filters.dart' @@ -80,6 +84,13 @@ ServerOptions get defaultServerOptions => ServerOptions( 'archive_table', params: __archive_tableArchiveTable, ), + _cuj_filters.CujFilters: ClientTarget<_cuj_filters.CujFilters>( + 'cuj_filters', + ), + _cuj_filters_sidebar.CujFiltersSidebar: + ClientTarget<_cuj_filters_sidebar.CujFiltersSidebar>( + 'cuj_filters_sidebar', + ), _glossary_search_section.GlossarySearchSection: ClientTarget<_glossary_search_section.GlossarySearchSection>( 'glossary_search_section', diff --git a/sites/docs/lib/src/components/pages/cuj_filters.dart b/sites/docs/lib/src/components/pages/cuj_filters.dart new file mode 100644 index 00000000000..85264d10797 --- /dev/null +++ b/sites/docs/lib/src/components/pages/cuj_filters.dart @@ -0,0 +1,131 @@ +// 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/dom.dart'; +import 'package:jaspr/jaspr.dart'; +import 'package:site_shared/components/common/material_icon.dart'; +import 'package:universal_web/web.dart' as web; + +import '../../models/cuj_model.dart'; +import 'cuj_filters_sidebar.dart'; +import 'filterable_index.dart'; + +/// The id of the search field, so its result count can label it. +const _searchId = 'cuj-search'; + +@client +class CujFilters extends StatefulComponent { + const CujFilters({super.key}); + + @override + State createState() => _CujFiltersState(); +} + +class _CujFiltersState extends State { + String searchQuery = ''; + + CujFiltersNotifier get filters => CujFiltersSidebar.filters; + + final List cujs = []; + int filteredCujCount = 0; + + @override + void initState() { + super.initState(); + + if (kIsWeb) { + filters.addListener(setFilters); + + final cujList = web.document.getElementById('all-cujs-list'); + if (cujList == null) { + return; + } + + recreateCujs(cujList.querySelectorAll('.card')); + } + } + + void recreateCujs(web.NodeList cujCards) { + for (var i = 0; i < cujCards.length; i++) { + final element = cujCards.item(i) as web.Element; + cujs.add(Cuj.fromElement(element)); + } + filteredCujCount = cujs.length; + } + + /// Update the filter state and re-evaluate which journeys to show. + /// + /// Use like the `setState` method by passing a callback that updates + /// the relevant state variables. + void setFilters([void Function()? callback]) { + setState(callback ?? () {}); + + final cujsToShow = filters.filterCujs(cujs, searchQuery); + filteredCujCount = cujsToShow.length; + for (final cuj in cujs) { + final element = + web.document.getElementById(cuj.elementId) as web.HTMLElement?; + if (element == null) { + continue; + } + + if (cujsToShow.contains(cuj)) { + element.classList.remove('hidden'); + } else { + element.classList.add('hidden'); + } + } + } + + @override + void dispose() { + if (kIsWeb) { + filters.removeListener(setFilters); + } + super.dispose(); + } + + @override + Component build(BuildContext context) { + return FilterSearchGroup( + searchId: _searchId, + placeholder: 'Try "testing" or "architecture"...', + label: 'Search critical user journeys by goal, persona, and task', + value: searchQuery, + onInput: (value) { + setFilters(() { + searchQuery = value; + }); + }, + children: [ + div(classes: 'label-row', [ + label( + attributes: {'for': _searchId}, + [ + const .text('Showing '), + span([.text('$filteredCujCount')]), + const .text(' / '), + span([.text('${cujs.length}')]), + ], + ), + button( + attributes: { + if (searchQuery.isEmpty && filters.selectedPersonas.isEmpty) + 'disabled': 'true', + }, + onClick: () { + // No setState needed, since resetting filters will trigger it. + searchQuery = ''; + filters.reset(); + }, + [ + const MaterialIcon('close_small'), + const span([.text('Clear filters')]), + ], + ), + ]), + ], + ); + } +} diff --git a/sites/docs/lib/src/components/pages/cuj_filters_sidebar.dart b/sites/docs/lib/src/components/pages/cuj_filters_sidebar.dart new file mode 100644 index 00000000000..3fa24490f91 --- /dev/null +++ b/sites/docs/lib/src/components/pages/cuj_filters_sidebar.dart @@ -0,0 +1,127 @@ +// 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/dom.dart'; +import 'package:jaspr/jaspr.dart'; +import 'package:site_shared/components/common/button.dart'; + +import '../../models/cuj_model.dart'; +import 'cuj_filters.dart'; +import 'filterable_index.dart'; + +// TODO(ewindmill): Replace with the real feedback destination once it exists. +const _feedbackUrl = 'https://github.com/flutter/evals/issues'; + +@client +class CujFiltersSidebar extends StatelessComponent { + const CujFiltersSidebar({super.key}); + + /// The filter state for the critical user journey list. + /// + /// This is static so that [CujFilters] can access it, + /// since both client components don't share a common ancestor. + static CujFiltersNotifier filters = CujFiltersNotifier(); + + @override + Component build(BuildContext context) { + return FiltersSidebar( + footer: const [ + div(classes: 'cuj-feedback', [ + Button( + href: _feedbackUrl, + content: 'Feedback', + style: ButtonStyle.outlined, + title: 'Leave feedback or suggest new CUJs', + attributes: { + 'target': '_blank', + 'rel': 'noopener', + }, + ), + ]), + ], + children: [ + ListenableBuilder( + listenable: filters, + builder: (context) { + return div(classes: 'table-content', [ + const h4([.text('Persona')]), + ul([ + for (final persona in CujPersona.values) + li([ + input( + type: InputType.checkbox, + attributes: { + 'role': 'checkbox', + 'name': 'cuj-filter-${persona.name}', + }, + id: 'cuj-filter-${persona.name}', + checked: filters.selectedPersonas.contains(persona), + onChange: (checked) { + filters.setPersona(persona, checked as bool); + }, + ), + label( + attributes: {'for': 'cuj-filter-${persona.name}'}, + [.text(persona.label)], + ), + ]), + ]), + ]); + }, + ), + ], + ); + } +} + +/// Notifier to manage the state of the critical user journey filters. +class CujFiltersNotifier extends ChangeNotifier { + Set selectedPersonas = {}; + + void setPersona(CujPersona persona, bool isSelected) { + if (isSelected) { + selectedPersonas.add(persona); + } else { + selectedPersonas.remove(persona); + } + notifyListeners(); + } + + void reset() { + selectedPersonas.clear(); + notifyListeners(); + } + + Set filterCujs(List cujs, String searchQuery) { + searchQuery = searchQuery.trim().toLowerCase(); + + if (searchQuery.isEmpty && selectedPersonas.isEmpty) { + // No filters applied, return all journeys. + return cujs.toSet(); + } + + final cujsToShow = {}; + + for (final cuj in cujs) { + final matchesPersona = + selectedPersonas.isEmpty || selectedPersonas.contains(cuj.persona); + if (!matchesPersona) { + continue; + } + + final matchesSearchQuery = + searchQuery.isEmpty || + cuj.goal.toLowerCase().contains(searchQuery) || + (cuj.persona?.label.toLowerCase().contains(searchQuery) ?? false) || + cuj.tasks.any((t) => t.task.toLowerCase().contains(searchQuery)); + if (!matchesSearchQuery) { + continue; + } + + cujsToShow.add(cuj); + } + + return cujsToShow; + } +} diff --git a/sites/docs/lib/src/components/pages/cuj_index.dart b/sites/docs/lib/src/components/pages/cuj_index.dart new file mode 100644 index 00000000000..76c120e48b4 --- /dev/null +++ b/sites/docs/lib/src/components/pages/cuj_index.dart @@ -0,0 +1,103 @@ +// 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/dom.dart'; +import 'package:jaspr/jaspr.dart'; +import 'package:jaspr_content/jaspr_content.dart'; +import 'package:site_shared/components/common/button.dart'; +import 'package:site_shared/components/common/card.dart'; + +import '../../models/cuj_model.dart'; +import 'cuj_filters.dart'; +import 'cuj_filters_sidebar.dart'; + +final class CujIndex extends StatelessComponent { + const CujIndex({super.key}); + + @override + Component build(BuildContext context) { + final cujData = context.page.data['cujs'] as List?; + + final cujs = [ + for (final cuj in cujData ?? const []) + Cuj.fromMap(cuj as Map), + ]; + + return div(classes: 'filterable-index', [ + div(classes: 'left-col', [ + const CujFilters(), + div(classes: 'card-list', id: 'all-cujs-list', [ + for (final cuj in cujs) _CujCard(cuj), + ]), + ]), + const CujFiltersSidebar(), + ]); + } +} + +final class _CujCard extends StatelessComponent { + const _CujCard(this.cuj); + + final Cuj cuj; + + @override + Component build(BuildContext context) { + final cardId = cuj.elementId; + final taskCount = cuj.tasks.length; + + // Expanding and collapsing is handled for every `.expandable-card` + // by the `_setUpExpandableCards` global script. + return Card.expandable( + id: cardId, + outlined: true, + additionalClasses: 'cuj-card', + initiallyExpanded: false, + attributes: { + 'data-persona': cuj.persona?.name ?? '', + 'data-goal': cuj.goal, + 'data-tasks': cuj.taskData, + }, + header: [ + div(classes: 'cuj-card-heading', [ + if (cuj.persona case final persona?) + span(classes: 'pill-sm ${persona.pillClass}', [ + .text(persona.label), + ]), + h2(classes: 'card-title', [.text(cuj.goal)]), + ]), + div(classes: 'card-header-buttons', [ + Button( + href: '#$cardId', + icon: 'tag', + classes: const ['share-button'], + title: 'Link to journey', + attributes: { + 'aria-label': 'Link to the "${cuj.goal}" journey', + }, + ), + Button( + icon: 'keyboard_arrow_up', + classes: const ['expand-button'], + title: 'Expand or collapse tasks', + attributes: { + 'aria-expanded': 'false', + 'aria-controls': '$cardId-content', + 'aria-label': 'Expand or collapse the tasks for "${cuj.goal}"', + }, + ), + ]), + ], + collapsedContent: [ + p(classes: 'cuj-task-count', [ + .text(taskCount == 1 ? '1 task' : '$taskCount tasks'), + ]), + ], + expandedContent: [ + ul(classes: 'cuj-task-list', [ + for (final task in cuj.tasks) li([.text(task.task)]), + ]), + ], + ); + } +} diff --git a/sites/docs/lib/src/components/pages/filterable_index.dart b/sites/docs/lib/src/components/pages/filterable_index.dart new file mode 100644 index 00000000000..9f6cc824959 --- /dev/null +++ b/sites/docs/lib/src/components/pages/filterable_index.dart @@ -0,0 +1,153 @@ +// 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. + +/// The shell components shared by the filterable index pages: +/// the learning resources index and the critical user journey index. +/// +/// The pages provide their own filter controls and results list, +/// but the surrounding layout, search field, and sidebar are the same, +/// and are styled by `_filterable-index.scss`. +library; + +import 'package:jaspr/dom.dart'; +import 'package:jaspr/jaspr.dart'; +import 'package:site_shared/components/common/material_icon.dart'; +import 'package:site_shared/components/common/search.dart'; +import 'package:site_shared/components/utils/global_event_listener.dart'; +import 'package:universal_web/web.dart' as web; + +/// The id of the hidden checkbox that opens and closes the filter sidebar +/// while it is a drawer on narrow screens. +/// +/// The drawer is CSS-only: `_filterable-index.scss` keys off the checkbox's +/// `:checked` state, and the close button within the sidebar is a `