diff --git a/packages/site_shared/lib/_sass/base/_breakpoints.scss b/packages/site_shared/lib/_sass/base/_breakpoints.scss new file mode 100644 index 00000000000..c1005c03959 --- /dev/null +++ b/packages/site_shared/lib/_sass/base/_breakpoints.scss @@ -0,0 +1,31 @@ +@use 'sass:map'; + +$breakpoints: ( + 'sm': 640px, + 'md': 768px, + 'lg': 1024px, + 'xl': 1280px, + '2xl': 1536px, + 'max': 1440px, + '3xl': 1920px, +); + +@mixin screen($name) { + $value: map.get($breakpoints, $name); + @if $value == null { + @error 'Unknown breakpoint: #{$name}'; + } + @media (min-width: $value) { + @content; + } +} + +@mixin screen-below($name) { + $value: map.get($breakpoints, $name); + @if $value == null { + @error 'Unknown breakpoint: #{$name}'; + } + @media (max-width: $value - 1px) { + @content; + } +} \ No newline at end of file diff --git a/packages/site_shared/lib/_sass/components/_ide-explorer.scss b/packages/site_shared/lib/_sass/components/_ide-explorer.scss new file mode 100644 index 00000000000..fd9a6b96a1e --- /dev/null +++ b/packages/site_shared/lib/_sass/components/_ide-explorer.scss @@ -0,0 +1,533 @@ +@use '../base/mixins'; +@use '../base/breakpoints'; + +// ============================================================================ +// Variables +// ============================================================================ + +// Spacing +$ide-spacing-xs: 0.25rem; +$ide-spacing-sm: 0.3rem; +$ide-spacing-md: 0.4rem; +$ide-spacing-lg: 0.5rem; +$ide-spacing-xl: 0.6rem; +$ide-spacing-2xl: 0.75rem; +$ide-spacing-3xl: 0.85rem; +$ide-spacing-4xl: 1rem; +$ide-spacing-5xl: 1.25rem; +$ide-spacing-6xl: 1.5rem; + +// Icon sizes +$ide-icon-xs: 0.55rem; +$ide-icon-sm: 0.9rem; +$ide-icon-md: 1.1rem; +$ide-icon-lg: 1.5rem; +$ide-icon-xl: 1.75rem; + +// Font sizes +$ide-font-xs: 0.6875rem; +$ide-font-sm: 0.75rem; +$ide-font-md: 0.8125rem; +$ide-font-lg: 0.9rem; +$ide-font-xl: 0.9375rem; +$ide-font-2xl: 1.25rem; + +// Border radius +$ide-radius-sm: 0.2rem; +$ide-radius-md: 0.3rem; +$ide-radius-lg: 1rem; + +// Other sizes +$ide-button-size: 1.75rem; +$ide-folder-arrow-width: 1.25rem; +$ide-folder-arrow-height: 1.5rem; + +// Badge colors map +$badge-colors: ( + 'info': var(--site-alert-info-color), + 'tip': var(--site-alert-tip-color), + 'important': var(--site-alert-important-color), + 'warning': var(--site-alert-warning-color), + 'error': var(--site-alert-error-color), +); + +// ============================================================================ +// Mixins +// ============================================================================ + +@mixin flex-center { + display: flex; + align-items: center; +} + +@mixin no-shrink { + flex-shrink: 0; +} + +@mixin reset-list { + margin: 0; + list-style: none; +} + +@mixin interactive-hover { + &:hover { + @include mixins.interaction-style(4%); + } +} + +@mixin ide-button-base { + border: none; + background: transparent; + cursor: pointer; + font-family: inherit; + @include interactive-hover; +} + +@mixin uppercase-label { + font-size: $ide-font-xs; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +// ============================================================================ +// Main Component +// ============================================================================ + +.ide-explorer { + display: flex; + align-items: stretch; + overflow: hidden; + height: min(600px, 80vh); + margin-block: 1.71em; + border: 1px solid var(--site-outline); + border-radius: var(--site-radius); + background-color: var(--site-base-bgColor); + font-family: var(--site-ui-fontFamily); + + + @include breakpoints.screen-below('md') { + flex-direction: column; + height: auto; + } +} + +.ide-sidebar { + display: flex; + flex-direction: column; + flex-shrink: 0; + width: min(15rem, 35%); + border-right: 1px solid var(--site-outline); + background-color: var(--site-raised-bgColor-translucent); + + @include breakpoints.screen-below('md') { + width: auto; + max-height: 14rem; + border-right: none; + border-bottom: 1px solid var(--site-outline); + } +} + +.ide-root-tabs { + @include flex-center; + gap: $ide-spacing-xs; + padding: 0.375rem; + border-bottom: 1px solid var(--site-outline-variant); + + &.ide-root-tabs-single { + justify-content: flex-end; + } +} + +.ide-root-tab { + @include ide-button-base; + flex: 1; + padding: $ide-spacing-sm 0; + border-radius: $ide-radius-sm; + color: var(--site-base-fgColor-alt); + font-size: $ide-font-sm; + font-weight: 500; + + &.active { + background-color: var(--site-base-bgColor); + color: var(--site-primary-color); + } +} + +.ide-toggle-all { + @include no-shrink; + @include flex-center; + @include ide-button-base; + justify-content: center; + width: $ide-button-size; + height: $ide-button-size; + border-radius: $ide-radius-sm; + color: var(--site-base-fgColor-alt); + + .material-symbols { + font-size: $ide-icon-md; + } +} + +.ide-tree { + display: none; + flex: 1; + overflow-y: auto; + padding: 0.375rem 0; + + &.active { + display: block; + } + + ul { + @include reset-list; + padding-inline-start: 0.9rem; + } + + > ul { + padding-inline-start: 0; + } + + ul > li.ide-node:last-child { + margin-bottom: $ide-spacing-lg; + } + + li.ide-node { + margin-bottom: 0; + } + + details { + margin: 0; + } + + details[open] > summary { + margin: 0; + } + + summary { + @include flex-center; + width: 100%; + list-style: none; + padding: 0; + margin: 0; + cursor: default; + + &::-webkit-details-marker { + display: none; + } + } + + .ide-folder-arrow { + @include no-shrink; + display: inline-flex; + align-items: center; + justify-content: center; + width: $ide-folder-arrow-width; + height: $ide-folder-arrow-height; + padding: 0; + padding-inline-start: 0.35rem; + border: none; + background: transparent; + cursor: pointer; + + &::before { + content: ''; + display: inline-block; + @include no-shrink; + width: $ide-icon-xs; + height: $ide-icon-xs; + background-color: var(--site-base-fgColor-alt); + clip-path: polygon(15% 0%, 100% 50%, 15% 100%); + transition: transform 0.1s ease; + } + + &:hover::before { + background-color: var(--site-base-fgColor); + } + } + + details[open] > summary .ide-folder-arrow::before { + transform: rotate(90deg); + } + + .ide-node-row { + @include flex-center; + @include ide-button-base; + flex: 1; + min-width: 0; + padding: $ide-spacing-xs $ide-spacing-xl; + border-radius: $ide-radius-sm; + color: inherit; + font-family: var(--site-code-fontFamily); + font-size: $ide-font-md; + text-align: left; + outline-offset: -2px; + + &.active { + background-color: var(--site-primary-color-highlight); + color: var(--site-primary-color); + font-weight: 500; + } + + svg { + @include no-shrink; + width: $ide-icon-sm; + height: $ide-icon-sm; + margin-inline-end: $ide-spacing-md; + } + } + + li.ide-node-file { + @include flex-center; + width: 100%; + + > .ide-node-row { + margin-inline-start: $ide-folder-arrow-width; + } + } + + .ide-node-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.ide-badge-dot { + @include no-shrink; + width: $ide-spacing-md; + height: $ide-spacing-md; + margin-inline-start: $ide-spacing-md; + border-radius: 50%; + background-color: var(--site-base-fgColor-alt); +} + +.ide-badge { + @include no-shrink; + padding: 0.1rem $ide-spacing-lg; + border-radius: $ide-radius-lg; + background-color: var(--site-raised-bgColor); + color: var(--site-base-fgColor-alt); + font-family: var(--site-ui-fontFamily); + font-size: $ide-font-xs; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +// Generate badge color variants +@each $name, $color in $badge-colors { + .ide-badge-color-#{$name} { + &.ide-badge-dot { + background-color: $color; + } + + &.ide-badge { + background-color: color-mix(in srgb, $color 12%, transparent); + color: $color; + } + } +} + +// Neutral badge color (special case) +.ide-badge-color-neutral { + &.ide-badge-dot { + background-color: var(--site-base-fgColor-alt); + } + + &.ide-badge { + background-color: var(--site-raised-bgColor); + color: var(--site-base-fgColor-alt); + } +} + +.ide-detail { + flex: 1; + min-width: 0; + padding: $ide-spacing-5xl $ide-spacing-6xl; + overflow-y: auto; + + @include breakpoints.screen-below('md') { + padding: $ide-spacing-4xl; + } +} + +.ide-detail-panel { + display: none; + + &.active { + display: block; + } + + > :last-child { + margin-block-end: 0; + } +} + +.ide-path { + margin-block-end: $ide-spacing-lg; + color: var(--site-base-fgColor-alt); + font-family: var(--site-code-fontFamily); + font-size: $ide-font-sm; +} + +.ide-path-sep { + margin-inline: $ide-spacing-sm; + color: var(--site-outline); +} + +.ide-detail-header { + display: flex; + align-items: flex-start; + gap: $ide-spacing-xl; + margin-block-end: $ide-spacing-4xl; + padding-bottom: $ide-spacing-4xl; + border-bottom: 0.5px solid; + + svg { + @include no-shrink; + width: $ide-icon-lg; + height: $ide-icon-lg; + margin-block-start: 0.15rem; + } +} + +.ide-detail-heading { + flex: 1; + min-width: 0; +} + +.ide-detail-title-row { + display: flex; + justify-content: space-between; + align-items: center; +} + +.ide-detail-title { + font-family: var(--site-code-fontFamily); + font-size: $ide-font-2xl; + font-weight: 600; +} + +.ide-detail-subtitle { + margin-block-start: 0.15rem; + color: var(--site-base-fgColor-alt); + font-size: $ide-font-xl; +} + +.ide-note { + padding: $ide-spacing-xl $ide-spacing-2xl; + margin-block-end: $ide-spacing-4xl; + border-radius: $ide-radius-md; + background-color: var(--site-inset-bgColor); + color: var(--site-inset-fgColor); + font-size: $ide-font-lg; + + p { + margin: 0; + } +} + +.ide-note-title { + @include uppercase-label; + margin-block-end: 0.2rem; + color: var(--site-base-fgColor-alt); +} + +.ide-description { + margin-block-end: $ide-spacing-4xl; +} + +.ide-tips { + padding: $ide-spacing-xl $ide-spacing-3xl; + margin-block-end: $ide-spacing-4xl; + border: 1px solid var(--site-outline-variant); + border-radius: $ide-radius-md; + + ul { + @include reset-list; + padding-inline-start: 1.1rem; + } + + li { + margin-block-end: $ide-spacing-xs; + + &:last-child { + margin-block-end: 0; + } + } +} + +.ide-tips-title { + @include uppercase-label; + margin-block-end: $ide-spacing-md; + color: var(--site-primary-color); +} + +.ide-example { + margin-block-end: $ide-spacing-4xl; +} + +.ide-docs-link { + @include flex-center; + display: inline-flex; + gap: $ide-spacing-sm; + padding: $ide-spacing-sm $ide-spacing-2xl; + margin-block-end: $ide-spacing-4xl; + border-radius: $ide-radius-lg; + background-color: var(--site-primary-color-highlight); + color: var(--site-primary-color); + font-size: $ide-font-md; + font-weight: 600; + text-decoration: none; + + .material-symbols { + font-size: $ide-spacing-4xl; + } + + &:hover { + text-decoration: underline; + } +} + +.ide-contents-title { + @include uppercase-label; + margin-block-end: $ide-spacing-lg; + color: var(--site-base-fgColor-alt); +} + +.ide-contents-list { + display: flex; + flex-direction: column; + gap: $ide-spacing-xs; +} + +.ide-content-link { + @include flex-center; + @include ide-button-base; + gap: $ide-spacing-lg; + padding: $ide-spacing-md $ide-spacing-xl; + border-radius: $ide-radius-md; + background-color: var(--site-raised-bgColor-translucent); + color: inherit; + font-family: var(--site-code-fontFamily); + font-size: $ide-font-md; + text-align: left; + + svg { + @include no-shrink; + width: $ide-icon-sm; + height: $ide-icon-sm; + } + + .ide-content-one-liner { + overflow: hidden; + color: var(--site-base-fgColor-alt); + font-family: var(--site-ui-fontFamily); + font-size: $ide-font-sm; + text-overflow: ellipsis; + white-space: nowrap; + } +} \ No newline at end of file diff --git a/packages/site_shared/lib/components/common/ide_explorer/ide_explorer.dart b/packages/site_shared/lib/components/common/ide_explorer/ide_explorer.dart new file mode 100644 index 00000000000..7bfc1f2a04c --- /dev/null +++ b/packages/site_shared/lib/components/common/ide_explorer/ide_explorer.dart @@ -0,0 +1,707 @@ +// Copyright 2026 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:collection/collection.dart'; +import 'package:jaspr/dom.dart'; +import 'package:jaspr/jaspr.dart'; +import 'package:jaspr_content/components/file_icon.dart'; + +import '../../../util.dart'; +import '../material_icon.dart'; + +/// A single top-level file tree shown by an [IdeExplorer], which may +/// have multiple roots to represent multiple packages +/// (e.g. "Project" and "Shared"). +/// +/// When multiple roots are provided, they are rendered as tabs that can be +/// switched between. +class IdeExplorerProjectRoot { + const IdeExplorerProjectRoot({ + required this.id, + this.label = '', + this.children = const [], + }); + + final String id; + final String label; + final List children; +} + +/// A single file or folder entry in an [IdeExplorer] tree. +class IdeTreeNode { + const IdeTreeNode({ + required this.id, + required this.label, + this.isDefaultPage = false, + this.startsClosed = true, + this.badge, + this.badgeColor, + this.subtitle, + this.title, + this.children = const [], + }); + + final String id; + final String label; + final bool isDefaultPage; + final bool startsClosed; + final String? badge; + final IdeBadgeColor? badgeColor; + final String? title; + final String? subtitle; + final List children; + + bool get isFolder => children.isNotEmpty || label.endsWith('/'); +} + +/// Corresponds to colors used for badges on an [IdeTreeNode]. +enum IdeBadgeColor { + neutral, + info, + tip, + important, + warning, + error; + + static IdeBadgeColor fromString(String? data) { + if (data == null) return IdeBadgeColor.neutral; + return switch (data.toLowerCase()) { + 'info' => IdeBadgeColor.info, + 'tip' => IdeBadgeColor.tip, + 'important' => IdeBadgeColor.important, + 'warning' => IdeBadgeColor.warning, + 'error' => IdeBadgeColor.error, + _ => IdeBadgeColor.neutral, + }; + } +} + +/// A flattened [IdeTreeNode] paired with the path of ancestor labels +/// leading to it, used to render the breadcrumb in its detail pane. +typedef _BreadcrumbNode = ({ + IdeTreeNode node, + String domId, + List path, +}); + +/// Generates a unique DOM id for an IDE tree node. +String _ideDomId(String instanceId, String nodeId) => 'ide-$instanceId-$nodeId'; + +// --------------------------------------------------------------------------- +// Main Explorer Component +// --------------------------------------------------------------------------- + +/// An interactive file-tree explorer, similar to an IDE's sidebar. +/// +/// Renders a clickable directory tree next to a detail pane that shows +/// per-file/folder documentation. Clicking an entry in the tree swaps which +/// detail pane is shown. +class IdeExplorer extends StatelessComponent { + const IdeExplorer({ + super.key, + required this.roots, + this.instanceId, + this.customContents = const {}, + }); + + static int _nextInstanceId = 0; + + final List roots; + final String? instanceId; + final Map customContents; + + @override + Component build(BuildContext context) { + if (roots.isEmpty) { + return const Component.empty(); + } + + final effectiveInstanceId = instanceId ?? '${_nextInstanceId++}'; + + final flatNodesByRoot = _buildFlatNodesByRoot(effectiveInstanceId); + final allFlatNodes = flatNodesByRoot.values + .expand((nodes) => nodes) + .toList(growable: false); + + final defaultFlatNode = _findDefaultNode(allFlatNodes); + final activeRootId = _determineActiveRoot( + defaultFlatNode, + flatNodesByRoot, + ); + final selectedNodeDomId = _determineSelectedNode( + defaultFlatNode, + flatNodesByRoot, + activeRootId, + ); + + return div(classes: 'ide-explorer not-content', [ + _IdeSidebar( + roots: roots, + activeRootId: activeRootId, + effectiveInstanceId: effectiveInstanceId, + selectedNodeDomId: selectedNodeDomId, + ), + _IdeDetailPane( + allFlatNodes: allFlatNodes, + customContents: customContents, + effectiveInstanceId: effectiveInstanceId, + selectedNodeDomId: selectedNodeDomId, + ), + ]); + } + + /// Builds a map of flattened nodes organized by root ID. + Map> _buildFlatNodesByRoot( + String effectiveInstanceId, + ) { + return { + for (final root in roots) + root.id: _flatten( + root.children, + instanceId: effectiveInstanceId, + path: root.label.isEmpty ? [] : [root.label], + ), + }; + } + + /// Finds the first node marked as the default page. + _BreadcrumbNode? _findDefaultNode(List<_BreadcrumbNode> allFlatNodes) { + return allFlatNodes.firstWhereOrNull( + (flat) => flat.node.isDefaultPage, + ); + } + + /// Determines which root should be active based on the default node. + String _determineActiveRoot( + _BreadcrumbNode? defaultFlatNode, + Map> flatNodesByRoot, + ) { + if (defaultFlatNode == null) { + return roots.first.id; + } + + final rootWithDefault = roots.firstWhereOrNull( + (r) => + flatNodesByRoot[r.id]?.any( + (n) => n.domId == defaultFlatNode.domId, + ) ?? + false, + ); + + return rootWithDefault?.id ?? roots.first.id; + } + + /// Determines which node should be selected initially. + String? _determineSelectedNode( + _BreadcrumbNode? defaultFlatNode, + Map> flatNodesByRoot, + String activeRootId, + ) { + return defaultFlatNode?.domId ?? + flatNodesByRoot[activeRootId]?.firstOrNull?.domId; + } + + List<_BreadcrumbNode> _flatten( + List nodes, { + required String instanceId, + required List path, + }) { + final result = <_BreadcrumbNode>[]; + for (final node in nodes) { + final nodePath = [...path, node.label]; + result.add(( + node: node, + domId: _ideDomId(instanceId, node.id), + path: path, + )); + result.addAll( + _flatten( + node.children, + instanceId: instanceId, + path: nodePath, + ), + ); + } + return result; + } +} + +// --------------------------------------------------------------------------- +// Sidebar Components +// --------------------------------------------------------------------------- + +/// The left sidebar containing the root switcher tabs and directory trees. +class _IdeSidebar extends StatelessComponent { + const _IdeSidebar({ + required this.roots, + required this.activeRootId, + required this.effectiveInstanceId, + required this.selectedNodeDomId, + }); + + final List roots; + final String activeRootId; + final String effectiveInstanceId; + final String? selectedNodeDomId; + + @override + Component build(BuildContext context) { + return div(classes: 'ide-sidebar', [ + if (roots.length > 1) + _IdeRootTabs(roots: roots, activeRootId: activeRootId) + else + const div(classes: 'ide-root-tabs ide-root-tabs-single', [ + _IdeToggleAllButton(), + ]), + for (final root in roots) + _IdeTree( + root: root, + isActive: root.id == activeRootId, + effectiveInstanceId: effectiveInstanceId, + selectedNodeDomId: selectedNodeDomId, + ), + ]); + } +} + +/// The tab list for switching between project roots, plus toggle-all button. +class _IdeRootTabs extends StatelessComponent { + const _IdeRootTabs({ + required this.roots, + required this.activeRootId, + }); + + final List roots; + final String activeRootId; + + @override + Component build(BuildContext context) { + return div( + classes: 'ide-root-tabs', + attributes: {'role': 'tablist'}, + [ + for (final root in roots) + button( + classes: [ + 'ide-root-tab', + if (root.id == activeRootId) 'active', + ].toClasses, + attributes: { + 'data-ide-root': root.id, + 'role': 'tab', + 'aria-selected': '${root.id == activeRootId}', + }, + [.text(root.label.isEmpty ? root.id : root.label)], + ), + const _IdeToggleAllButton(), + ], + ); + } +} + +/// A button that expands or collapses all folders in the active tree. +class _IdeToggleAllButton extends StatelessComponent { + const _IdeToggleAllButton(); + + @override + Component build(BuildContext context) { + return const button( + classes: 'ide-toggle-all', + type: ButtonType.button, + attributes: { + 'data-ide-toggle-all': '', + 'title': 'Expand or collapse all folders', + 'aria-label': 'Expand or collapse all folders', + }, + [ + MaterialIcon('unfold_more', label: 'Expand or collapse all folders'), + ], + ); + } +} + +/// A single root file tree in the sidebar. +class _IdeTree extends StatelessComponent { + const _IdeTree({ + required this.root, + required this.isActive, + required this.effectiveInstanceId, + required this.selectedNodeDomId, + }); + + final IdeExplorerProjectRoot root; + final bool isActive; + final String effectiveInstanceId; + final String? selectedNodeDomId; + + @override + Component build(BuildContext context) { + return div( + classes: [ + 'ide-tree', + if (isActive) 'active', + ].toClasses, + attributes: {'data-ide-root': root.id}, + [ + ul([ + for (final node in root.children) + _IdeTreeNode( + node: node, + instanceId: effectiveInstanceId, + selectedDomId: selectedNodeDomId, + ), + ]), + ], + ); + } +} + +/// An individual file or folder node in the directory tree. +class _IdeTreeNode extends StatelessComponent { + const _IdeTreeNode({ + required this.node, + required this.instanceId, + required this.selectedDomId, + }); + + final IdeTreeNode node; + final String instanceId; + final String? selectedDomId; + + bool _hasSelectedChild(IdeTreeNode parent) { + if (selectedDomId == null) return false; + for (final child in parent.children) { + if (_ideDomId(instanceId, child.id) == selectedDomId) return true; + if (_hasSelectedChild(child)) return true; + } + return false; + } + + @override + Component build(BuildContext context) { + final domId = _ideDomId(instanceId, node.id); + final isSelected = domId == selectedDomId; + final row = _IdeNodeRow(node: node, domId: domId, isSelected: isSelected); + + if (!node.isFolder) { + return li(classes: 'ide-node ide-node-file', [row]); + } + + final isOpen = !node.startsClosed || _hasSelectedChild(node); + + return li(classes: 'ide-node ide-node-folder', [ + details( + open: isOpen, + [ + summary( + classes: 'ide-folder-summary', + [ + const span( + classes: 'ide-folder-arrow', + attributes: {'aria-hidden': 'true'}, + [], + ), + row, + ], + ), + ul([ + for (final child in node.children) + _IdeTreeNode( + node: child, + instanceId: instanceId, + selectedDomId: selectedDomId, + ), + ]), + ], + ), + ]); + } +} + +/// The clickable button row for a file or folder node in the tree. +class _IdeNodeRow extends StatelessComponent { + const _IdeNodeRow({ + required this.node, + required this.domId, + required this.isSelected, + }); + + final IdeTreeNode node; + final String domId; + final bool isSelected; + + @override + Component build(BuildContext context) { + final icon = node.isFolder + ? FileIcon.folderIcon + : FileIcon.forFile(node.label); + final classesList = ['ide-node-row', if (isSelected) 'active'].toClasses; + final attributesMap = { + 'data-ide-select': domId, + if (isSelected) 'aria-current': 'true', + }; + final childrenList = [ + icon, + span(classes: 'ide-node-label', [.text(node.label)]), + _IdeBadgeDot( + badge: node.badge, + color: node.badgeColor ?? IdeBadgeColor.neutral, + ), + ]; + + if (node.isFolder) { + return span( + classes: classesList, + attributes: attributesMap, + childrenList, + ); + } + + return button( + classes: classesList, + type: ButtonType.button, + attributes: attributesMap, + childrenList, + ); + } +} + +/// A colored indicator dot indicating status or category on a node row. +class _IdeBadgeDot extends StatelessComponent { + const _IdeBadgeDot({ + required this.badge, + this.color = IdeBadgeColor.neutral, + }); + + final String? badge; + final IdeBadgeColor color; + + @override + Component build(BuildContext context) { + if (badge == null) return const Component.empty(); + + return span( + classes: [ + 'ide-badge-dot', + 'ide-badge-color-${color.name}', + ].toClasses, + attributes: { + 'role': 'img', + 'aria-label': badge!, + 'title': badge!, + }, + [], + ); + } +} + +// --------------------------------------------------------------------------- +// Detail Pane Components +// --------------------------------------------------------------------------- + +/// The right-side container holding detail panels for all tree nodes. +class _IdeDetailPane extends StatelessComponent { + const _IdeDetailPane({ + required this.allFlatNodes, + required this.customContents, + required this.effectiveInstanceId, + required this.selectedNodeDomId, + }); + + final List<_BreadcrumbNode> allFlatNodes; + final Map customContents; + final String effectiveInstanceId; + final String? selectedNodeDomId; + + @override + Component build(BuildContext context) { + return div(classes: 'ide-detail', [ + for (final flat in allFlatNodes) + _IdeDetailPanel( + flat: flat, + customBody: customContents[flat.node.id], + instanceId: effectiveInstanceId, + isActive: flat.domId == selectedNodeDomId, + ), + ]); + } +} + +/// An individual documentation panel for a selected file or folder. +/// +/// ```text +/// ┌──────────────────────────────────────────────────────────┐ +/// │ [Path / Breadcrumbs] │ +/// │ [Icon] Title [Optional Badge] │ +/// │ Subtitle text... │ +/// ├──────────────────────────────────────────────────────────┤ +/// │ _IdePageBody: │ +/// │ Custom markdown explanation and code blocks... │ +/// ├──────────────────────────────────────────────────────────┤ +/// │ _IdeFolderContentsSection (if folder has children): │ +/// │ CONTENTS │ +/// │ [📁 lib/] Application source code │ +/// │ [📁 test/] Target directory for widget tests │ +/// │ [📄 pubspec.yaml] Project manifest... │ +/// └──────────────────────────────────────────────────────────┘ +/// ``` +class _IdeDetailPanel extends StatelessComponent { + const _IdeDetailPanel({ + required this.flat, + required this.customBody, + required this.instanceId, + required this.isActive, + }); + + final _BreadcrumbNode flat; + final Component? customBody; + final String instanceId; + final bool isActive; + + @override + Component build(BuildContext context) { + final node = flat.node; + + return div( + classes: ['ide-detail-panel', if (isActive) 'active'].toClasses, + attributes: {'data-ide-panel': flat.domId}, + [ + if (flat.path.isNotEmpty) _IdeBreadcrumb(path: flat.path), + _IdePageHeader(node: node), + if (customBody != null) _IdePageBody(child: customBody!), + if (node.isFolder && node.children.isNotEmpty) + _IdeFolderContentsSection( + children: node.children, + instanceId: instanceId, + ), + ], + ); + } +} + +/// The breadcrumb path displayed above the detail header. +class _IdeBreadcrumb extends StatelessComponent { + const _IdeBreadcrumb({required this.path}); + + final List path; + + @override + Component build(BuildContext context) { + return div( + classes: 'ide-path', + [ + for (final (i, segment) in path.indexed) ...[ + if (i > 0) const span(classes: 'ide-path-sep', [.text('/')]), + span([.text(segment.replaceFirst(RegExp(r'/$'), ''))]), + ], + ], + ); + } +} + +/// The header of a detail panel showing the file/folder icon, title, badge, +/// and subtitle. +class _IdePageHeader extends StatelessComponent { + const _IdePageHeader({required this.node}); + + final IdeTreeNode node; + + @override + Component build(BuildContext context) { + final icon = node.isFolder + ? FileIcon.folderIcon + : FileIcon.forFile(node.label); + + return div(classes: 'ide-detail-header', [ + icon, + div(classes: 'ide-detail-heading', [ + div(classes: 'ide-detail-title-row', [ + div(classes: 'ide-detail-title', [ + .text(node.title ?? node.label), + ]), + if (node.badge case final badge?) + span( + classes: [ + 'ide-badge', + 'ide-badge-color-${node.badgeColor?.name ?? 'neutral'}', + ].toClasses, + [.text(badge)], + ), + ]), + if (node.subtitle case final subtitle?) + div(classes: 'ide-detail-subtitle', [.text(subtitle)]), + ]), + ]); + } +} + +/// Container for the custom markdown prose and code blocks of a page/folder. +class _IdePageBody extends StatelessComponent { + const _IdePageBody({required this.child}); + + final Component child; + + @override + Component build(BuildContext context) { + return div(classes: 'ide-custom-body', [child]); + } +} + +/// The automatic "Contents" section shown on folder detail panels, listing +/// clickable shortcut links to all child files and subfolders. +class _IdeFolderContentsSection extends StatelessComponent { + const _IdeFolderContentsSection({ + required this.children, + required this.instanceId, + }); + + final List children; + final String instanceId; + + @override + Component build(BuildContext context) { + return div(classes: 'ide-contents', [ + const div(classes: 'ide-contents-title', [.text('Contents')]), + div(classes: 'ide-contents-list', [ + for (final child in children) + _IdeContentLink(child: child, instanceId: instanceId), + ]), + ]); + } +} + +/// A clickable link card within the folder "Contents" listing. +class _IdeContentLink extends StatelessComponent { + const _IdeContentLink({ + required this.child, + required this.instanceId, + }); + + final IdeTreeNode child; + final String instanceId; + + @override + Component build(BuildContext context) { + final icon = child.isFolder + ? FileIcon.folderIcon + : FileIcon.forFile(child.label); + + return button( + classes: 'ide-content-link', + type: ButtonType.button, + attributes: { + 'data-ide-select': _ideDomId(instanceId, child.id), + }, + [ + icon, + span(classes: 'ide-node-label', [.text(child.label)]), + if (child.subtitle case final subtitle?) + span( + classes: 'ide-content-one-liner', + [.text(subtitle)], + ), + ], + ); + } +} diff --git a/packages/site_shared/lib/components/common/ide_explorer/markdown_component.dart b/packages/site_shared/lib/components/common/ide_explorer/markdown_component.dart new file mode 100644 index 00000000000..b5ff66d74de --- /dev/null +++ b/packages/site_shared/lib/components/common/ide_explorer/markdown_component.dart @@ -0,0 +1,241 @@ +import 'package:jaspr/jaspr.dart'; +import 'package:jaspr_content/jaspr_content.dart'; + +import '../../../util.dart'; +import 'ide_explorer.dart'; + +/// A custom markdown component that parses `` and its +/// ``, ``, and `` children. Defers +/// building the IDE html to the [IdeExplorer] component. +class IdeExplorerMarkdownComponent extends CustomComponent { + const IdeExplorerMarkdownComponent() : super.base(); + + // Tag name constants + static const String _tagIdeExplorer = 'IdeExplorer'; + static const String _tagIdeProjectRoot = 'IdeProjectRoot'; + static const String _tagIdeFolder = 'IdeFolder'; + static const String _tagIdePage = 'IdePage'; + + // Attribute name constants + static const String _attrId = 'id'; + static const String _attrLabel = 'label'; + static const String _attrIsDefaultPage = 'is-default-page'; + static const String _attrStartsClosed = 'starts-closed'; + static const String _attrBadge = 'badge'; + static const String _attrBadgeColor = 'badge-color'; + static const String _attrSubtitle = 'subtitle'; + + // Default values + static const String _defaultRootPrefix = 'root'; + static const String _defaultNodePrefix = 'node'; + + @override + Component? create(Node node, NodesBuilder builder) { + if (node is! ElementNode || node.tag != _tagIdeExplorer) { + return null; + } + + final projectRootElements = node.children + ?.whereType() + .where((n) => n.tag == _tagIdeProjectRoot) + .toList(); + + if (projectRootElements == null || projectRootElements.isEmpty) { + print( + '[ERROR] <$_tagIdeExplorer> requires at ' + 'least one <$_tagIdeProjectRoot> child element.', + ); + return const Component.empty(); + } + + final customContents = {}; + final roots = []; + for (final (index, rootEl) in projectRootElements.indexed) { + final rootId = _generateNodeId( + rootEl.attributes, + _defaultRootPrefix, + index, + ); + roots.add( + IdeExplorerProjectRoot( + id: rootId, + label: rootEl.attributes[_attrLabel] ?? '', + children: _parseTreeNodes( + rootEl.children, + builder, + customContents, + rootId, + ), + ), + ); + } + + return IdeExplorer( + roots: roots, + customContents: customContents, + ); + } + + /// Generates a node ID from attributes or creates a default one. + String _generateNodeId( + Map attributes, + String prefix, + int index, [ + String? parentId, + ]) { + if (attributes[_attrId] != null) { + return attributes[_attrId]!; + } + final label = attributes[_attrLabel]; + final localId = (label != null && label.isNotEmpty) + ? slugify(label) + : '$prefix-$index'; + return parentId != null ? '$parentId-$localId' : localId; + } + + List _parseTreeNodes( + List? nodes, + NodesBuilder builder, + Map customContents, + String parentId, + ) { + if (nodes == null || nodes.isEmpty) return const []; + + final result = []; + + for (final (index, child) in nodes.whereType().indexed) { + if (child.tag != _tagIdeFolder && child.tag != _tagIdePage) { + continue; + } + + final treeNode = _buildTreeNodeFromElement( + child, + index, + builder, + customContents, + parentId, + ); + + result.add(treeNode); + } + + return result; + } + + /// Builds a single [IdeTreeNode] from an [ElementNode]. + IdeTreeNode _buildTreeNodeFromElement( + ElementNode element, + int index, + NodesBuilder builder, + Map customContents, + String parentId, + ) { + final attributes = element.attributes; + final label = attributes[_attrLabel] ?? ''; + final id = _generateNodeId(attributes, _defaultNodePrefix, index, parentId); + + // Parse boolean attributes + final isDefaultPage = _getBoolAttribute( + attributes, + _attrIsDefaultPage, + defaultValue: false, + ); + final startsClosed = _getBoolAttribute( + attributes, + _attrStartsClosed, + defaultValue: true, + ); + + // Parse badge attributes + final badge = attributes[_attrBadge]; + final badgeColor = attributes[_attrBadgeColor] != null + ? IdeBadgeColor.fromString(attributes[_attrBadgeColor]) + : null; + + final subtitle = attributes[_attrSubtitle]; + + // Recursively parse children + final nestedTreeNodes = _parseTreeNodes( + element.children, + builder, + customContents, + id, + ); + + // Extract and store custom body content if present + _storeCustomContentIfPresent( + element.children, + id, + builder, + customContents, + ); + + return IdeTreeNode( + id: id, + label: label, + isDefaultPage: isDefaultPage, + startsClosed: startsClosed, + badge: badge, + badgeColor: badgeColor, + subtitle: subtitle, + children: nestedTreeNodes, + ); + } + + /// Parses a boolean attribute value, returning [defaultValue] if not present. + bool _getBoolAttribute( + Map attributes, + String key, { + required bool defaultValue, + }) { + final value = attributes[key]; + return value != null ? value == 'true' : defaultValue; + } + + /// Checks if a node represents body content (not a folder/page structure). + bool _isBodyContent(Node node) { + if (node is ElementNode && + (node.tag == _tagIdeFolder || node.tag == _tagIdePage)) { + return false; + } + if (node is TextNode && node.text.trim().isEmpty) { + return false; + } + return true; + } + + /// Extracts non-structural content nodes from children. + List _extractContentNodes(List children) { + return children + .where((n) { + if (n is ElementNode && + (n.tag == _tagIdeFolder || n.tag == _tagIdePage)) { + return false; + } + return true; + }) + .toList(growable: false); + } + + /// Stores custom body content for a node if it exists. + void _storeCustomContentIfPresent( + List? children, + String nodeId, + NodesBuilder builder, + Map customContents, + ) { + if (!_hasCustomBodyContent(children)) { + return; + } + + final contentNodes = _extractContentNodes(children!); + if (contentNodes.isNotEmpty) { + customContents[nodeId] = builder.build(contentNodes); + } + } + + /// Checks if a node has custom body content. + bool _hasCustomBodyContent(List? children) { + return children?.any(_isBodyContent) ?? false; + } +} diff --git a/sites/docs/lib/_sass/_site.scss b/sites/docs/lib/_sass/_site.scss index 99092c6a676..4d2dc647b98 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'; @@ -37,6 +38,7 @@ @use 'package:site_shared/_sass/components/code'; @use 'package:site_shared/_sass/components/cookie-notice'; @use 'package:site_shared/_sass/components/dropdown'; +@use 'package:site_shared/_sass/components/ide-explorer'; @use 'package:site_shared/_sass/components/menu-toggle'; @use 'package:site_shared/_sass/components/progress-ring'; @use 'package:site_shared/_sass/components/quiz'; @@ -48,6 +50,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..a5f9a2acb33 100644 --- a/sites/docs/lib/main.server.dart +++ b/sites/docs/lib/main.server.dart @@ -8,6 +8,7 @@ import 'package:jaspr_content/jaspr_content.dart'; import 'package:jaspr_content/theme.dart'; import 'package:path/path.dart' as path; import 'package:site_shared/components/common/card.dart'; +import 'package:site_shared/components/common/ide_explorer/markdown_component.dart'; import 'package:site_shared/components/common/material_icon.dart'; import 'package:site_shared/components/common/tabs.dart'; import 'package:site_shared/components/common/youtube_embed.dart'; @@ -27,6 +28,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'; @@ -99,6 +101,7 @@ List get _embeddableComponents => [ const CodePreview(), const YoutubeEmbed(), const FileTree(), + const IdeExplorerMarkdownComponent(), const Quiz(), const ProgressRing(), const SummaryCard(), @@ -116,6 +119,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/client/global_scripts.dart b/sites/docs/lib/src/client/global_scripts.dart index caa9bf379de..2da567a2261 100644 --- a/sites/docs/lib/src/client/global_scripts.dart +++ b/sites/docs/lib/src/client/global_scripts.dart @@ -20,6 +20,7 @@ void setUpSite() { _setUpPlatformKeys(); _setUpToc(); _setUpSteppers(); + _setUpIdeExplorers(); } void _setUpSearchKeybindings() { @@ -477,3 +478,121 @@ void _scrollTo(web.Element element, {required bool smooth}) { ), ); } + +/// Set up interactivity of the file/detail explorer created with +/// the `` custom component. +void _setUpIdeExplorers() { + final explorers = web.document.querySelectorAll('.ide-explorer'); + for (var i = 0; i < explorers.length; i++) { + _setUpIdeExplorer(explorers.item(i) as web.Element); + } +} + +void _setUpIdeExplorer(web.Element explorer) { + void selectIdeNode(String domId) { + final selectTargets = explorer.querySelectorAll('[data-ide-select]'); + web.Element? sidebarTarget; + for (var i = 0; i < selectTargets.length; i++) { + final target = selectTargets.item(i) as web.Element; + final isMatch = target.getAttribute('data-ide-select') == domId; + target.classList.toggle('active', isMatch); + if (isMatch && target.closest('.ide-tree') != null) { + sidebarTarget = target; + } + } + + final panels = explorer.querySelectorAll('[data-ide-panel]'); + for (var i = 0; i < panels.length; i++) { + final panel = panels.item(i) as web.Element; + panel.classList.toggle( + 'active', + panel.getAttribute('data-ide-panel') == domId, + ); + } + + // Expand every ancestor folder so the selected item stays visible. + final ownDetails = sidebarTarget?.closest('details'); + final isFolderSelf = + sidebarTarget?.parentElement?.tagName.toLowerCase() == 'summary'; + var current = isFolderSelf ? ownDetails?.parentElement : ownDetails; + while (current != null) { + final ancestorDetails = current.closest('details'); + if (ancestorDetails == null) break; + (ancestorDetails as web.HTMLDetailsElement).open = true; + current = ancestorDetails.parentElement; + } + } + + void switchIdeRoot(String rootId) { + final tabs = explorer.querySelectorAll('.ide-root-tab'); + for (var i = 0; i < tabs.length; i++) { + final tab = tabs.item(i) as web.Element; + tab.classList.toggle( + 'active', + tab.getAttribute('data-ide-root') == rootId, + ); + } + + final trees = explorer.querySelectorAll('.ide-tree'); + web.Element? activeTree; + for (var i = 0; i < trees.length; i++) { + final tree = trees.item(i) as web.Element; + final isMatch = tree.getAttribute('data-ide-root') == rootId; + tree.classList.toggle('active', isMatch); + if (isMatch) activeTree = tree; + } + + final firstDomId = activeTree + ?.querySelector('[data-ide-select]') + ?.getAttribute('data-ide-select'); + if (firstDomId != null) { + selectIdeNode(firstDomId); + } + } + + void toggleAllIdeFolders() { + final activeTree = + explorer.querySelector('.ide-tree.active') ?? + explorer.querySelector('.ide-tree'); + if (activeTree == null) return; + + final allDetails = activeTree.querySelectorAll('details'); + var anyClosed = false; + for (var i = 0; i < allDetails.length; i++) { + if (!(allDetails.item(i) as web.HTMLDetailsElement).open) { + anyClosed = true; + break; + } + } + + for (var i = 0; i < allDetails.length; i++) { + (allDetails.item(i) as web.HTMLDetailsElement).open = anyClosed; + } + } + + void handleClick(web.Event event) { + final target = event.target as web.Element?; + if (target == null) return; + + final selectTarget = target.closest('[data-ide-select]'); + if (selectTarget != null) { + final domId = selectTarget.getAttribute('data-ide-select'); + if (domId != null) selectIdeNode(domId); + event.preventDefault(); + return; + } + + final rootTab = target.closest('.ide-root-tab'); + if (rootTab != null) { + final rootId = rootTab.getAttribute('data-ide-root'); + if (rootId != null) switchIdeRoot(rootId); + return; + } + + if (target.closest('[data-ide-toggle-all]') != null) { + toggleAllIdeFolders(); + } + } + + explorer.addEventListener('click', handleClick.toJS); +} 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 `