diff --git a/packages/site_shared/lib/_sass/components/_button.scss b/packages/site_shared/lib/_sass/components/_button.scss index 79b5b15e8f5..2ce2f7322ea 100644 --- a/packages/site_shared/lib/_sass/components/_button.scss +++ b/packages/site_shared/lib/_sass/components/_button.scss @@ -39,6 +39,16 @@ button { padding: 0.4rem 0.9rem; text-decoration: none; cursor: pointer; + + &:disabled { + cursor: default; + opacity: 0.5; + } + } + + &.compact-button { + gap: 0.2rem; + padding: 0.25rem 0.65rem; } &.filled-button { @@ -52,11 +62,11 @@ button { font-size: 20px; } - &:hover { + &:not(:disabled):hover { @include mixins.interaction-style(8%); } - &:active { + &:not(:disabled):active { @include mixins.interaction-style(16%); } } @@ -64,11 +74,11 @@ button { &.text-button { color: var(--site-primary-color); - &:hover { + &:not(:disabled):hover { @include mixins.interaction-style(4%); } - &:active { + &:not(:disabled):active { @include mixins.interaction-style(8%); } } @@ -77,11 +87,11 @@ button { color: var(--site-primary-color); border: 1px solid var(--site-primary-color); - &:hover { + &:not(:disabled):hover { @include mixins.interaction-style(4%); } - &:active { + &:not(:disabled):active { @include mixins.interaction-style(8%); } } @@ -103,7 +113,7 @@ button { font-size: 1.75rem; } - &:hover { + &:not(:disabled):hover { color: var(--site-base-fgColor); } } diff --git a/packages/site_shared/lib/components/common/button.dart b/packages/site_shared/lib/components/common/button.dart index 85feaebbd5d..2393c7f4706 100644 --- a/packages/site_shared/lib/components/common/button.dart +++ b/packages/site_shared/lib/components/common/button.dart @@ -18,6 +18,7 @@ class Button extends StatelessComponent { this.href, this.content, this.style = ButtonStyle.text, + this.size = ButtonSize.regular, this.id, this.attributes = const {}, this.classes, @@ -30,6 +31,7 @@ class Button extends StatelessComponent { final String? content; final String? title; final ButtonStyle style; + final ButtonSize size; final String? icon; final String? trailingIcon; final String? id; @@ -50,6 +52,7 @@ class Button extends StatelessComponent { final mergedClasses = [ style.cssClass, + if (size == ButtonSize.compact) 'compact-button', if ((icon != null || trailingIcon != null) && content == null) 'icon-button', ...?classes, @@ -83,6 +86,9 @@ class Button extends StatelessComponent { } } +/// The amount of space used within a [Button]. +enum ButtonSize { regular, compact } + enum ButtonStyle { filled, outlined, diff --git a/sites/docs/lib/_sass/_site.scss b/sites/docs/lib/_sass/_site.scss index 99092c6a676..a4d52a33ee6 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'; 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..99022c2e3cb --- /dev/null +++ b/sites/docs/lib/_sass/components/_filterable-index.scss @@ -0,0 +1,203 @@ +// 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. + +.filterable-index { + $mobile-breakpoint: 839px; + $sidebar-width: 220px; + + 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 stays within + // the viewport. On narrow screens, it becomes an off-canvas drawer. + .filter-sidebar { + position: sticky; + top: calc(var(--site-header-height) + 1rem); + display: flex; + flex-direction: column; + max-height: calc(100vh - var(--site-header-height) - 2rem); + } + + .filter-group-wrapper { + // Scroll expanded filters independently so any sidebar footer stays visible. + min-height: 0; + border: 1px solid var(--site-inset-borderColor); + background-color: var(--site-inset-bgColor); + border-radius: var(--site-radius); + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: none; + } + + // Keep the title visible while the filters scroll. The close control is only + // shown while the sidebar is a drawer. + .filter-header { + position: sticky; + top: 0; + z-index: 1; + display: grid; + grid-template-columns: 2.25rem 1fr 2.25rem; + align-items: center; + background-color: var(--site-raised-bgColor); + border-bottom: 1px solid var(--site-inset-borderColor); + + .table-title { + grid-column: 2; + padding: .5rem 0; + text-align: center; + color: var(--site-base-fgColor-alt); + font-family: var(--site-ui-fontFamily); + font-weight: 600; + font-size: .925rem; + } + + .close-icon { + display: none; + grid-column: 3; + align-items: center; + justify-self: center; + cursor: pointer; + padding: 0.25rem; + border-radius: 4px; + background: none; + border: none; + color: var(--site-base-fgColor-alt); + transition: background-color 0.2s ease, color 0.2s ease; + + &:hover { + background-color: rgba(0, 0, 0, 0.1); + color: var(--site-primary-color); + } + + .material-symbols { + font-size: 20px; + } + } + } + + .filter-group { + .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; + } + + .filter-expansion-button { + margin-bottom: 1rem; + } + + .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; + margin-top: .25rem; + + label { + font-family: var(--site-ui-fontFamily); + color: var(--site-base-fgColor-lighter); + + margin: 0 .5rem 0 0; + text-align: end; + } + } + } + + // On narrow screens the sidebar becomes a drawer that slides in from the + // right. Opening and closing is driven entirely by the hidden + // `.filter-drawer-toggle` checkbox that precedes it. + @media (max-width: $mobile-breakpoint) { + 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 { + .close-icon { + display: flex; + } + } + + .filter-sidebar { + position: fixed; + top: var(--site-header-height); + bottom: 0; + right: -$sidebar-width; + width: $sidebar-width; + max-height: none; + 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: var(--site-z-top); + } + + .filter-drawer-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; + } + } +} diff --git a/sites/docs/lib/_sass/pages/_learning-resources-index.scss b/sites/docs/lib/_sass/pages/_learning-resources-index.scss index 0e6581adbd5..031c44eb3d9 100644 --- a/sites/docs/lib/_sass/pages/_learning-resources-index.scss +++ b/sites/docs/lib/_sass/pages/_learning-resources-index.scss @@ -1,235 +1,6 @@ @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; - } - } - } -} +// Shared styles live in `_filterable-index.scss`. #all-resources-grid { margin-block-start: 1rem; 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..5a96dab8d91 --- /dev/null +++ b/sites/docs/lib/src/components/pages/filterable_index.dart @@ -0,0 +1,172 @@ +// 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. + +/// Reusable shell components for filterable index pages. +/// +/// 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/button.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/js_interop.dart'; +import 'package:universal_web/web.dart' as web; + +/// The right-hand column of a filterable index page. +/// +/// Renders [children] within a card titled 'Filter by', with [footer] pinned +/// below it. On narrow screens the card and footer become a drawer that slides +/// in from the right, toggled by the [FilterSearchGroup] search field. +class FiltersSidebar extends StatelessComponent { + const FiltersSidebar({ + required this.drawerToggleId, + required this.children, + this.footer = const [], + super.key, + }); + + /// The ID of the checkbox that toggles the drawer on narrow screens. + final String drawerToggleId; + + /// The filter controls, rendered within the filter card. + final List children; + + /// Optional page-specific content, rendered below the filter card. + final List footer; + + @override + Component build(BuildContext context) { + return div(classes: 'right-col', [ + input( + type: InputType.checkbox, + id: drawerToggleId, + classes: 'filter-drawer-toggle', + attributes: const {'hidden': 'true'}, + ), + div(classes: 'filter-sidebar', [ + div(classes: 'filter-group-wrapper', [ + div(classes: 'filter-group', [ + div(classes: 'filter-header', [ + const div(classes: 'table-title', [.text('Filter by')]), + label( + attributes: { + 'for': drawerToggleId, + 'aria-hidden': 'true', + }, + classes: 'close-icon', + const [MaterialIcon('close')], + ), + ]), + ...children, + ]), + ]), + ...footer, + ]), + ]); + } +} + +/// The search field of a filterable index page, +/// followed by the page-specific [children], such as a result count. +/// +/// On narrow screens, the field also contains the button +/// that opens the [FiltersSidebar] drawer. +class FilterSearchGroup extends StatelessComponent { + const FilterSearchGroup({ + required this.drawerToggleId, + required this.searchId, + required this.placeholder, + required this.label, + required this.value, + required this.onInput, + this.children = const [], + super.key, + }); + + /// The ID of the checkbox that toggles the [FiltersSidebar] drawer. + final String drawerToggleId; + + /// The ID of the search field, so the page can label it. + final String searchId; + + /// The placeholder text displayed in the search field. + final String placeholder; + + /// The accessibility label for the search field. + final String label; + + /// The current value of the search field. + final String value; + + /// Callback triggered when the search field input changes. + final void Function(String) onInput; + + /// Content rendered below the search field. + final List children; + + @override + Component build(BuildContext context) { + return div(classes: 'filter-search-group', [ + SearchBar( + placeholder: placeholder, + label: label, + value: value, + id: searchId, + onInput: onInput, + trailing: _DrawerToggleButton(drawerToggleId), + ), + ...children, + ]); + } +} + +/// The button that opens the filter drawer on narrow screens. +/// +/// Also closes the drawer again when anything outside of it is clicked. +class _DrawerToggleButton extends StatelessComponent { + const _DrawerToggleButton(this._drawerToggleId); + + /// The ID of the checkbox that this button toggles. + final String _drawerToggleId; + + /// The checkbox that controls the drawer, if present. + web.HTMLInputElement? get _toggle => + web.document.getElementById(_drawerToggleId) as web.HTMLInputElement?; + + @override + Component build(BuildContext context) { + return GlobalEventListener( + onClick: (event) { + final toggle = _toggle; + if (toggle == null || !toggle.checked) return; + + final target = event.target; + if (target == null || !target.isA()) return; + + final element = target as web.Element; + // Keep the drawer open for clicks anywhere within it, + // including in the sidebar footer. + if (element.closest('.filter-sidebar') == null && + element.closest('.show-filters-button') == null) { + toggle.checked = false; + } + }, + Button( + icon: 'filter_list', + classes: const ['show-filters-button'], + attributes: const {'aria-label': 'Show filters'}, + onClick: () { + if (_toggle case final toggle?) { + toggle.checked = !toggle.checked; + } + }, + ), + ); + } +} diff --git a/sites/docs/lib/src/components/pages/learning_resource_filters.dart b/sites/docs/lib/src/components/pages/learning_resource_filters.dart index dcd726c134d..a6b5badfa3c 100644 --- a/sites/docs/lib/src/components/pages/learning_resource_filters.dart +++ b/sites/docs/lib/src/components/pages/learning_resource_filters.dart @@ -7,38 +7,47 @@ import 'dart:math'; import 'package:jaspr/dom.dart'; import 'package:jaspr/jaspr.dart'; import 'package:site_shared/analytics.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:site_shared/components/common/button.dart'; import 'package:universal_web/js_interop.dart'; import 'package:universal_web/web.dart' as web; import '../../models/learning_resource_model.dart'; +import 'filterable_index.dart'; import 'learning_resource_filters_sidebar.dart'; +/// The search controls and result summary for the learning resources index. @client class LearningResourceFilters extends StatefulComponent { const LearningResourceFilters({super.key}); + /// The ID of the checkbox that toggles the filter drawer on narrow screens. + static const String drawerToggleId = 'learning-resource-filter-toggle'; + @override State createState() => _LearningResourceFiltersState(); } class _LearningResourceFiltersState extends State { - String searchQuery = ''; + /// The filters selected in the learning resource sidebar. + static LearningResourceFiltersNotifier get _filters => + LearningResourceFiltersSidebar.filters; + + /// The learning resources reconstructed from the rendered resource cards. + final List _resources = []; - FiltersNotifier get filters => LearningResourceFiltersSidebar.filters; + /// The current search query. + String _searchQuery = ''; - final List resources = []; - int filteredResourcesCount = 0; + /// The number of resources matching the active search and filters. + int _filteredResourcesCount = 0; @override void initState() { super.initState(); if (kIsWeb) { - filters.addListener(setFilters); + _filters.addListener(_setFilters); final resourceGrid = web.document.getElementById('all-resources-grid'); if (resourceGrid == null) { @@ -46,16 +55,17 @@ class _LearningResourceFiltersState extends State { } final resourceCards = resourceGrid.querySelectorAll('.card'); - recreateResources(resourceCards); - shuffleCards(resourceGrid); + _recreateResources(resourceCards); + _shuffleCards(resourceGrid); } } - void recreateResources(web.NodeList resourceCards) { + /// Populates [_resources] from [resourceCards] and registers click analytics. + void _recreateResources(web.NodeList resourceCards) { for (var i = 0; i < resourceCards.length; i++) { final element = resourceCards.item(i) as web.Element; final info = LearningResource.fromElement(element); - resources.add(info); + _resources.add(info); element.addEventListener( 'click', @@ -67,10 +77,11 @@ class _LearningResourceFiltersState extends State { }).toJS, ); } - filteredResourcesCount = resources.length; + _filteredResourcesCount = _resources.length; } - void shuffleCards(web.Element container) { + /// Randomizes the order of the resource cards in [container]. + void _shuffleCards(web.Element container) { final r = Random(); final elements = container.childNodes; for (var i = elements.length; i > 0; i--) { @@ -91,12 +102,12 @@ class _LearningResourceFiltersState extends State { /// searchQuery = '...'; /// }); /// ``` - void setFilters([void Function()? callback]) { + void _setFilters([void Function()? callback]) { setState(callback ?? () {}); - final resourcesToShow = filters.filterResources(resources, searchQuery); - filteredResourcesCount = resourcesToShow.length; - for (final info in resources) { + final resourcesToShow = _filters.filterResources(_resources, _searchQuery); + _filteredResourcesCount = resourcesToShow.length; + for (final info in _resources) { final element = web.document.getElementById(info.name) as web.HTMLElement?; if (element == null) { @@ -114,78 +125,51 @@ class _LearningResourceFiltersState extends State { @override void dispose() { if (kIsWeb) { - filters.removeListener(setFilters); + _filters.removeListener(_setFilters); } super.dispose(); } @override Component build(BuildContext context) { - return div(id: 'resource-search-group', classes: 'chip-filters-group', [ - SearchBar( - placeholder: 'Try "button" or "networking"...', - label: 'Search learning resources by name and category', - value: searchQuery, - id: 'resource-search', - onInput: (value) { - setFilters(() { - searchQuery = value; - }); - }, - trailing: GlobalEventListener( - onClick: (event) { - final target = event.target as web.Element?; - // If clicking outside the filters or toggle, close the filters. - if (target?.closest('#resource-filter-group-wrapper') == null && - target?.closest('.show-filters-button') == null) { - final toggle = web.document.getElementById( - 'open-filter-toggle', - ) as web.HTMLInputElement?; - toggle?.checked = false; - } - }, - button( - classes: 'icon-button show-filters-button', - onClick: () { - final toggle = web.document.getElementById( - 'open-filter-toggle', - ) as web.HTMLInputElement?; - toggle?.checked = !toggle.checked; - }, + return FilterSearchGroup( + drawerToggleId: LearningResourceFilters.drawerToggleId, + searchId: 'resource-search', + placeholder: 'Try "button" or "networking"...', + label: 'Search learning resources by name and category', + value: _searchQuery, + onInput: (value) { + _setFilters(() { + _searchQuery = value; + }); + }, + children: [ + div(classes: 'label-row', [ + label( + attributes: {'for': 'resource-search'}, [ - const MaterialIcon('filter_list'), + const .text('Showing '), + span([.text('$_filteredResourcesCount')]), + const .text(' / '), + span([.text('${_resources.length}')]), ], ), - ), - ), - div(classes: 'label-row', [ - label( - attributes: {'for': 'resource-search'}, - [ - const .text('Showing '), - span([.text('$filteredResourcesCount')]), - const .text(' / '), - span([.text('${resources.length}')]), - ], - ), - button( - attributes: { - if (searchQuery.isEmpty && - filters.selectedTags.isEmpty && - filters.selectedTypes.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')]), - ], - ), - ]), - ]); + Button( + icon: 'close_small', + content: 'Clear filters', + size: ButtonSize.compact, + disabled: + _searchQuery.isEmpty && + _filters.selectedTags.isEmpty && + _filters.selectedTypes.isEmpty, + onClick: () { + // No setState needed, since resetting filters will trigger it. + _searchQuery = ''; + _filters.reset(); + }, + ), + ]), + ], + ); } } diff --git a/sites/docs/lib/src/components/pages/learning_resource_filters_sidebar.dart b/sites/docs/lib/src/components/pages/learning_resource_filters_sidebar.dart index cf512193009..d662418fed5 100644 --- a/sites/docs/lib/src/components/pages/learning_resource_filters_sidebar.dart +++ b/sites/docs/lib/src/components/pages/learning_resource_filters_sidebar.dart @@ -5,12 +5,14 @@ import 'package:jaspr/dom.dart'; import 'package:jaspr/jaspr.dart'; import 'package:site_shared/analytics.dart'; -import 'package:site_shared/components/common/material_icon.dart'; +import 'package:site_shared/components/common/button.dart'; import 'package:site_shared/util.dart'; import '../../models/learning_resource_model.dart'; +import 'filterable_index.dart'; import 'learning_resource_filters.dart'; +/// The subject and type filters for the learning resources index. @client class LearningResourceFiltersSidebar extends StatelessComponent { const LearningResourceFiltersSidebar({super.key}); @@ -19,103 +21,91 @@ class LearningResourceFiltersSidebar extends StatelessComponent { /// /// This is static so that [LearningResourceFilters] can access it, /// since both client components don't share a common ancestor. - static FiltersNotifier filters = FiltersNotifier(); + static final LearningResourceFiltersNotifier filters = + LearningResourceFiltersNotifier(); @override Component build(BuildContext context) { - return div(classes: 'right-col', [ - const input( - type: InputType.checkbox, - id: 'open-filter-toggle', - attributes: {'hidden': 'true'}, - ), - div(id: 'resource-filter-group-wrapper', [ - div(id: 'resource-filter-group', [ - const div(classes: 'filter-header', [ - label( - attributes: {'for': 'open-filter-toggle', 'aria-hidden': 'true'}, - classes: 'close-icon', - [MaterialIcon('close')], - ), - ]), - const div(classes: 'table-title', [.text('Filter by')]), - ListenableBuilder( - listenable: filters, - builder: (context) { - return div(classes: 'table-content', [ - const h4([.text('Subject')]), - ul(classes: filters.tagsExpanded ? '' : 'collapsed', [ - for (final (index, tag) in LearningResourceTag.values.indexed) - li( - classes: [ - if (!filters.tagsExpanded && index > 3) 'hidden', - ].toClasses, - [ - input( - type: InputType.checkbox, - attributes: { - 'role': 'checkbox', - 'name': 'filter-${tag.name}', - }, - id: 'filter-${tag.name}', - checked: filters.selectedTags.contains(tag), - onChange: (checked) { - filters.setTag(tag, checked as bool); - }, - ), - label( - attributes: {'for': 'filter-${tag.name}'}, - [.text(tag.label)], - ), - ], - ), - ]), - button(onClick: filters.toggleTagsExpanded, [ - span(classes: 'label', [ - .text(filters.tagsExpanded ? 'Less' : 'More'), - ]), - MaterialIcon( - filters.tagsExpanded ? 'expand_less' : 'expand_more', - ), - ]), - const h4([.text('Type')]), - ul([ - for (final type in LearningResourceType.values) - li([ + return FiltersSidebar( + drawerToggleId: LearningResourceFilters.drawerToggleId, + children: [ + ListenableBuilder( + listenable: filters, + builder: (context) { + return div(classes: 'table-content', [ + const h4([.text('Subject')]), + ul([ + for (final (index, tag) in LearningResourceTag.values.indexed) + li( + classes: [ + if (!filters.tagsExpanded && index > 3) 'hidden', + ].toClasses, + [ input( type: InputType.checkbox, - attributes: { - 'role': 'checkbox', - 'name': 'filter-${type.name}', - }, - id: 'filter-${type.name}', - checked: filters.selectedTypes.contains(type), + attributes: {'name': 'resource-filter-${tag.name}'}, + id: 'resource-filter-${tag.name}', + checked: filters.selectedTags.contains(tag), onChange: (checked) { - filters.setType(type, checked as bool); + filters.setTag(tag, checked as bool); }, ), label( - attributes: {'for': 'filter-${type.name}'}, - [.text(type.label)], + attributes: {'for': 'resource-filter-${tag.name}'}, + [.text(tag.label)], ), - ]), - ]), - ]); - }, - ), - ]), - ]), - ]); + ], + ), + ]), + Button( + content: filters.tagsExpanded ? 'Less' : 'More', + classes: const ['filter-expansion-button'], + size: ButtonSize.compact, + trailingIcon: filters.tagsExpanded + ? 'expand_less' + : 'expand_more', + onClick: filters.toggleTagsExpanded, + ), + const h4([.text('Type')]), + ul([ + for (final type in LearningResourceType.values) + li([ + input( + type: InputType.checkbox, + attributes: {'name': 'resource-filter-${type.name}'}, + id: 'resource-filter-${type.name}', + checked: filters.selectedTypes.contains(type), + onChange: (checked) { + filters.setType(type, isSelected: checked as bool); + }, + ), + label( + attributes: {'for': 'resource-filter-${type.name}'}, + [.text(type.label)], + ), + ]), + ]), + ]); + }, + ), + ], + ); } } -/// Notifier to manage the state of the filters. -class FiltersNotifier extends ChangeNotifier { - Set selectedTags = {}; - Set selectedTypes = {}; +/// Stores the selected learning resource filters and +/// notifies listeners when they change. +final class LearningResourceFiltersNotifier extends ChangeNotifier { + /// The currently selected subject tags. + final Set selectedTags = {}; + + /// The currently selected resource types. + final Set selectedTypes = {}; + /// Whether all subject tags are visible. bool tagsExpanded = false; + /// Updates whether [tag] is selected and notifies listeners. void setTag(LearningResourceTag tag, bool isSelected) { if (isSelected) { selectedTags.add(tag); @@ -133,7 +123,8 @@ class FiltersNotifier extends ChangeNotifier { notifyListeners(); } - void setType(LearningResourceType type, bool isSelected) { + /// Updates whether [type] is selected and notifies listeners. + void setType(LearningResourceType type, {required bool isSelected}) { if (isSelected) { selectedTypes.add(type); @@ -150,17 +141,20 @@ class FiltersNotifier extends ChangeNotifier { notifyListeners(); } + /// Toggles whether all subject tags are visible. void toggleTagsExpanded() { tagsExpanded = !tagsExpanded; notifyListeners(); } + /// Clears all selected tags and resource types. void reset() { selectedTags.clear(); selectedTypes.clear(); notifyListeners(); } + /// Returns the resources matching [searchQuery] and the selected filters. Set filterResources( List resources, String searchQuery, diff --git a/sites/docs/lib/src/components/pages/learning_resource_index.dart b/sites/docs/lib/src/components/pages/learning_resource_index.dart index 3141659ba0e..140c25ea035 100644 --- a/sites/docs/lib/src/components/pages/learning_resource_index.dart +++ b/sites/docs/lib/src/components/pages/learning_resource_index.dart @@ -30,8 +30,8 @@ final class LearningResourceIndex extends StatelessComponent { } } - return div(id: 'resource-index-content', [ - div(classes: 'left-col', id: 'resource-index-main-content', [ + return div(classes: 'filterable-index', [ + div(classes: 'left-col', [ const LearningResourceFilters(), section(classes: 'card-grid', id: 'all-resources-grid', [ for (final item in learningResources) _ResourceCard(item),