diff --git a/docusaurus.config.ts b/docusaurus.config.ts index 293d7e4048..5e32305d9e 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -218,23 +218,23 @@ const config: Config = { }, { type: 'html', - value: `Application Development & Automation`, + value: `AI & Machine Learning`, }, { type: 'html', - value: `Artificial Intelligence`, + value: `Application Development & Automation`, }, { type: 'html', - value: `Data & Analytics`, + value: `Data & Analytics`, }, { type: 'html', - value: `Integration`, + value: `Integration`, }, { type: 'html', - value: `Operation & Security`, + value: `Operation & Security`, }, { type: 'html', diff --git a/src/components/FilterBar/CollapsibleFilterBar.module.css b/src/components/FilterBar/CollapsibleFilterBar.module.css index 712ba59e4a..cb430ea74a 100644 --- a/src/components/FilterBar/CollapsibleFilterBar.module.css +++ b/src/components/FilterBar/CollapsibleFilterBar.module.css @@ -1,12 +1,11 @@ .filterBarContainer { width: calc(100% - 16px); - margin-left: 8px; - margin-right: 8px; background: var(--ifm-background-color); border: 1px solid var(--color-border-light); border-radius: var(--border-radius-sm); padding: 12px; - margin-bottom: 16px; + margin: 0 8px 16px 8px; + box-sizing: border-box; } /* Top Bar with Filter Toggle and Clear Button */ diff --git a/src/components/FilterBar/CollapsibleFilterBar.tsx b/src/components/FilterBar/CollapsibleFilterBar.tsx index 6526c6ebc5..4154a5dcee 100644 --- a/src/components/FilterBar/CollapsibleFilterBar.tsx +++ b/src/components/FilterBar/CollapsibleFilterBar.tsx @@ -9,11 +9,8 @@ interface Option { } interface CollapsibleFilterBarProps { - techDomains: Option[]; partners: Option[]; - selectedTechDomains: Option[]; selectedPartners: Option[]; - onTechDomainsChange: (values: Option[]) => void; onPartnersChange: (values: Option[]) => void; resetFilters: () => void; isResetEnabled: boolean; @@ -23,11 +20,8 @@ interface CollapsibleFilterBarProps { } const CollapsibleFilterBar: React.FC = ({ - techDomains, partners, - selectedTechDomains, selectedPartners, - onTechDomainsChange, onPartnersChange, resetFilters, isResetEnabled, @@ -50,7 +44,7 @@ const CollapsibleFilterBar: React.FC = ({ onChange(currentSelection.filter((item) => item.value !== option.value)); }; - const hasActiveFilters = selectedTechDomains.length > 0 || selectedPartners.length > 0 || searchTerm.length > 0; + const hasActiveFilters = selectedPartners.length > 0 || searchTerm.length > 0; return (
@@ -65,7 +59,7 @@ const CollapsibleFilterBar: React.FC = ({ Filters {hasActiveFilters && ( - {selectedTechDomains.length + selectedPartners.length} + {selectedPartners.length} )} @@ -81,16 +75,6 @@ const CollapsibleFilterBar: React.FC = ({ {hasActiveFilters && (
- {selectedTechDomains.map((domain) => ( - - ))} {selectedPartners.map((partner) => ( - ); - })} -
-
-

Technology Partners

diff --git a/src/constant/constants.ts b/src/constant/constants.ts index 4f90781515..83af350747 100644 --- a/src/constant/constants.ts +++ b/src/constant/constants.ts @@ -37,8 +37,8 @@ export const navigationCardsData = [ // Keep items sorted alphabetically by `title` export const techDomain = [ - { id: 'appdev', title: 'Application Dev. & Automation', icon: 'sap-icon://syntax' }, { id: 'ai', title: 'AI & Machine Learning', icon: 'sap-icon://da' }, + { id: 'appdev', title: 'Application Dev. & Automation', icon: 'sap-icon://syntax' }, { id: 'data', title: 'Data & Analytics', icon: 'sap-icon://database' }, { id: 'integration', title: 'Integration', icon: 'sap-icon://exit-full-screen' }, { id: 'opsec', title: 'Operation & Security', icon: 'sap-icon://shield' }, diff --git a/src/css/custom.css b/src/css/custom.css index 1c83ec294d..50b6546c7c 100644 --- a/src/css/custom.css +++ b/src/css/custom.css @@ -39,6 +39,7 @@ --ifm-footer-link-hover-color: #6b84a0; --ifm-footer-title-color: var(--ifm-font-color-base); --doc-sidebar-width: 350px !important; + --doc-sidebar-hidden-width: 30px; --ifm-table-cell-padding: 0.25rem 0.5rem; --ifm-menu-link-padding-vertical: 0.375rem; --ifm-menu-link-padding-horizontal: 0.75rem; @@ -363,10 +364,6 @@ svg[aria-roledescription="flowchart-v2"] span { /* Remove bullet points */ } -.menu__list-item>.menu__link--active { - background-color: var(--ifm-sidebar-selected-item-background-color); -} - .menu__link { /* Changed by PO: All pages should use the same colors */ color: #535353; @@ -376,6 +373,23 @@ svg[aria-roledescription="flowchart-v2"] span { line-height: 1rem; } +/* Align domain category arrows with nested level arrows */ +/* Target domain-level links by the --sublist-caret modifier */ +a.menu__link--sublist-caret { + padding-right: 0.1875rem; + padding-top: 0.25rem; + padding-bottom: 0.25rem; +} + +/* Keep domain category arrows fixed on hover - only shift the text */ +.menu__link--sublist-caret:hover { + transform: none !important; +} + +.menu__link--sublist-caret:hover [class*="categoryLinkLabel"] { + transform: translateX(4px); +} + /* Changing the padding size of the symbol to expand the content in the sidebar */ .menu__caret { padding: 0.2rem !important; @@ -1372,33 +1386,84 @@ html[data-theme='dark'] .theme-doc-toc-desktop { } /* Interactive Sidebar Links */ -.theme-doc-sidebar-menu .menu__link { +.theme-doc-sidebar-menu .menu__link, +nav[class*="domainSidebar"] .menu__link { transition: transform 0.3s ease, color 0.3s ease, text-shadow 0.3s ease; } -.theme-doc-sidebar-menu .menu__link:hover { +.theme-doc-sidebar-menu .menu__link:hover, +nav[class*="domainSidebar"] .menu__link:hover { transform: translateX(4px); color: var(--ifm-color-primary) !important; } +/* Change [+N] counter color on link hover - Light mode only */ +html[data-theme='light'] .theme-doc-sidebar-menu .menu__link:hover .sidebar-duplicate-counter, +html[data-theme='light'] nav[class*="domainSidebar"] .menu__link:hover .sidebar-duplicate-counter { + color: var(--ifm-color-primary) !important; +} + +/* Prevent [+N] counter color change on hover in dark mode */ +html[data-theme='dark'] .sidebar-duplicate-counter:hover { + color: var(--ifm-color-content-secondary) !important; +} + /* All active links get bold */ -.theme-doc-sidebar-menu .menu__link--active { +.theme-doc-sidebar-menu .menu__link--active, +nav[class*="domainSidebar"] .menu__link--active { font-weight: bold; } +/* Active document links (not folders) - hover animation combining both transforms */ +.menu__list-item:not(.menu__list-item-collapsible) > .menu__link--active:hover { + transform: translateX(4px) translateZ(0) !important; +} + /* Gradient effect on document links (all documents, not folders) */ -.theme-doc-sidebar-menu .menu__list-item:not(.menu__list-item-collapsible) > .menu__link--active { - background: var(--gradient-premium); - -webkit-background-clip: text; - background-clip: text; - -webkit-text-fill-color: transparent; +/* Generic selector that works for desktop, mobile, both themes */ +.menu__list-item:not(.menu__list-item-collapsible) > .menu__link--active { + color: var(--ifm-color-primary) !important; + background: var(--gradient-premium) !important; + background-color: transparent !important; + -webkit-background-clip: text !important; + background-clip: text !important; + -webkit-text-fill-color: transparent !important; + font-weight: bold !important; + /* Force hardware acceleration and separate layer to ensure gradient renders */ + transform: translateZ(0); + will-change: background; + backface-visibility: hidden; + -webkit-backface-visibility: hidden; +} + +/* Dark theme - ensure gradient variable works */ +html[data-theme='dark'] .menu__list-item:not(.menu__list-item-collapsible) > .menu__link--active { + color: var(--ifm-color-primary) !important; + background: var(--gradient-premium) !important; + background-color: transparent !important; + -webkit-background-clip: text !important; + background-clip: text !important; + -webkit-text-fill-color: transparent !important; + font-weight: bold !important; + transform: translateZ(0); + will-change: background; + backface-visibility: hidden; + -webkit-backface-visibility: hidden; +} + +/* Fallback for browsers that don't support background-clip: text */ +@supports not (background-clip: text) { + .menu__list-item:not(.menu__list-item-collapsible) > .menu__link--active { + color: var(--ifm-color-primary) !important; + -webkit-text-fill-color: var(--ifm-color-primary) !important; + background: none !important; + } } /* Folder nodes - default light theme */ .theme-doc-sidebar-menu .menu__list-item-collapsible > .menu__link, .menu__list-item-collapsible > .menu__link { color: #535353; - -webkit-text-fill-color: #535353; } /* Parent folder (expanded but NOT selected) - light theme */ @@ -1417,6 +1482,11 @@ html[data-theme='dark'] .theme-doc-toc-desktop { background-clip: text !important; -webkit-text-fill-color: transparent !important; font-weight: bold !important; + /* Force hardware acceleration and separate layer to ensure gradient renders */ + transform: translateZ(0); + will-change: background; + backface-visibility: hidden; + -webkit-backface-visibility: hidden; } /* Folder nodes - default dark theme */ @@ -1442,12 +1512,38 @@ html[data-theme='dark'] .menu__list-item-collapsible--active > .menu__link.menu_ background-clip: text !important; -webkit-text-fill-color: transparent !important; font-weight: bold !important; + /* Force hardware acceleration and separate layer to ensure gradient renders */ + transform: translateZ(0); + will-change: background; + backface-visibility: hidden; + -webkit-backface-visibility: hidden; } -html[data-theme='dark'] .theme-doc-sidebar-menu .menu__link:hover { +html[data-theme='dark'] .theme-doc-sidebar-menu .menu__link:hover, +html[data-theme='dark'] nav[class*="domainSidebar"] .menu__link:hover { + transform: translateX(4px); text-shadow: 0 0 8px rgba(0, 112, 242, 0.6); } +/* Disable slide animation and color change on mobile - light theme only */ +@media (max-width: 996px) { + .theme-doc-sidebar-menu .menu__link:hover, + nav[class*="domainSidebar"] .menu__link:hover, + html[data-theme='dark'] .theme-doc-sidebar-menu .menu__link:hover, + html[data-theme='dark'] nav[class*="domainSidebar"] .menu__link:hover, + .menu__link--sublist-caret:hover [class*="categoryLinkLabel"], + .menu__list-item:not(.menu__list-item-collapsible) > .menu__link--active:hover { + transform: none !important; + } + + /* Disable color change on mobile in light theme */ + html[data-theme='light'] .theme-doc-sidebar-menu .menu__link:hover, + html[data-theme='light'] nav[class*="domainSidebar"] .menu__link:hover, + html[data-theme='light'] .menu__link:hover .sidebar-duplicate-counter { + color: inherit !important; + } +} + /* Content Container Card */ .theme-doc-markdown { background: transparent; @@ -1603,6 +1699,7 @@ body:has(.homepage-main) footer.footer { } /* On mobile, allow normal scrolling */ + @media (max-width: 768px) { .homepage-main { scroll-snap-type: none !important; diff --git a/src/plugins/security-headers/index.js b/src/plugins/security-headers/index.js index a02736e68b..5bc9b59c1c 100644 --- a/src/plugins/security-headers/index.js +++ b/src/plugins/security-headers/index.js @@ -50,13 +50,6 @@ module.exports = function (_context, _options) { content: 'nosniff', }, }, - { - tagName: 'meta', - attributes: { - 'http-equiv': 'X-Frame-Options', - content: 'DENY', - }, - }, { tagName: 'meta', attributes: { diff --git a/src/sections/TechnologyDomainSection.tsx b/src/sections/TechnologyDomainSection.tsx index 299fcb0ff5..16b0168b58 100644 --- a/src/sections/TechnologyDomainSection.tsx +++ b/src/sections/TechnologyDomainSection.tsx @@ -23,22 +23,22 @@ interface DomainCardProps { title: string; icon: string; }; + onNavigationStart: () => void; } -function DomainCard({ domain }: DomainCardProps): JSX.Element { - const setTechDomains = useSidebarFilterStore((state) => state.setTechDomains); +function DomainCard({ domain, onNavigationStart }: DomainCardProps): JSX.Element { const docsUrl = useBaseUrl('/docs/ref-arch'); const isHighlighted = domain.id === 'ai' || domain.id === 'data'; - const handleClick = () => { - setTechDomains([domain.id]); + const handlePointerDown = () => { + onNavigationStart(); }; return (
{iconMap[domain.id] || } @@ -113,7 +113,30 @@ export default function TechnologyDomainSection(): JSX.Element { const imgBaseUrl = useBaseUrl('/img/landingPage/'); const history = useHistory(); const setPartners = useSidebarFilterStore((state) => state.setPartners); - const setTechDomains = useSidebarFilterStore((state) => state.setTechDomains); + const [isNavigating, setIsNavigating] = React.useState(false); + + // Disable scroll-snap when navigating to prevent interference + React.useEffect(() => { + if (isNavigating) { + const originalHtmlSnap = document.documentElement.style.scrollSnapType; + const originalBodySnap = document.body.style.scrollSnapType; + + document.documentElement.style.scrollSnapType = 'none'; + document.body.style.scrollSnapType = 'none'; + + const timer = setTimeout(() => { + document.documentElement.style.scrollSnapType = originalHtmlSnap; + document.body.style.scrollSnapType = originalBodySnap; + setIsNavigating(false); + }, 300); + + return () => { + clearTimeout(timer); + document.documentElement.style.scrollSnapType = originalHtmlSnap; + document.body.style.scrollSnapType = originalBodySnap; + }; + } + }, [isNavigating]); // Helper function to get image URL with baseUrl const getImg = (name: string) => `${imgBaseUrl}${name}`; @@ -124,16 +147,13 @@ export default function TechnologyDomainSection(): JSX.Element { e.preventDefault(); const partners = item.filter?.partners ?? []; - const techDomains = item.filter?.techDomains ?? []; - // Set the global store + // Set the global store - only partners filter now if (partners.length) setPartners(partners); - if (techDomains.length) setTechDomains(techDomains); - // Build query string + // Build query string - only partners const params = new URLSearchParams(); if (partners.length) params.set('partners', partners.join(',')); - if (techDomains.length) params.set('techDomains', techDomains.join(',')); history.push(`${docsUrl}?${params.toString()}`); }; @@ -160,7 +180,11 @@ export default function TechnologyDomainSection(): JSX.Element {
{techDomain.map((domain) => ( - + setIsNavigating(true)} + /> ))}
@@ -183,4 +207,4 @@ export default function TechnologyDomainSection(): JSX.Element {
); -} +} \ No newline at end of file diff --git a/src/store/sidebar-store.ts b/src/store/sidebar-store.ts index a4b8b6d8c3..2bdafb91b6 100644 --- a/src/store/sidebar-store.ts +++ b/src/store/sidebar-store.ts @@ -7,6 +7,10 @@ interface SidebarFilterState { partners: string[]; setPartners: (partners: string[]) => void; + // Expanded domain categories (for collapsible sidebar) + expandedDomains: string[]; + setExpandedDomains: (domains: string[]) => void; + resetFilters: () => void; } @@ -17,5 +21,9 @@ export const useSidebarFilterStore = create((set) => ({ partners: [], setPartners: (partners) => set({ partners }), - resetFilters: () => set({ techDomains: [], partners: [] }), + // Start with all domains collapsed by default + expandedDomains: [], + setExpandedDomains: (expandedDomains) => set({ expandedDomains }), + + resetFilters: () => set({ techDomains: [], partners: [], expandedDomains: [] }), })); diff --git a/src/theme/DocSidebar/index.tsx b/src/theme/DocSidebar/index.tsx index f2ea4b623f..0da0b5eabd 100644 --- a/src/theme/DocSidebar/index.tsx +++ b/src/theme/DocSidebar/index.tsx @@ -1,17 +1,28 @@ import React, { useMemo, useEffect, useState } from 'react'; +import clsx from 'clsx'; import DocSidebar from '@theme-original/DocSidebar'; import DocSidebarItems from '@theme-original/DocSidebarItems'; -import { NavbarSecondaryMenuFiller, useWindowSize } from '@docusaurus/theme-common'; +import { NavbarSecondaryMenuFiller, useWindowSize, useThemeConfig } from '@docusaurus/theme-common'; import { useDocsSidebar } from '@docusaurus/plugin-content-docs/client'; import CollapsibleFilterBar from '@site/src/components/FilterBar/CollapsibleFilterBar'; +import CollapseButton from '@theme/DocSidebar/Desktop/CollapseButton'; import styles from './styles.module.css'; import { useSidebarFilterStore } from '@site/src/store/sidebar-store'; import useGlobalData from '@docusaurus/useGlobalData'; import tagsMap from '@site/src/constant/tagsMapping.json'; -import { useHistory } from '@docusaurus/router'; +import { useHistory, useLocation } from '@docusaurus/router'; import useBaseUrl from '@docusaurus/useBaseUrl'; import { logger } from '@site/src/utils/logger'; +// Domain definitions with labels +const DOMAIN_DEFINITIONS = [ + { id: 'ai', label: 'AI & Machine Learning' }, + { id: 'appdev', label: 'Application Dev. & Automation' }, + { id: 'data', label: 'Data & Analytics' }, + { id: 'integration', label: 'Integration' }, + { id: 'opsec', label: 'Operation & Security' }, +]; + const categoryIdToTags = Object.entries(tagsMap).reduce((acc, [tagKey, meta]) => { const cat = meta?.categoryid; if (!cat) return acc; @@ -82,18 +93,291 @@ function filterSidebarItems(items, selectedDomains, selectedPartners, docIdToTag return recurse(items); } +// Helper to count occurrences of a docId in items (recursive) +function countDocsInItems(items, docId): number { + let count = 0; + for (const item of items) { + if ((item.type === 'doc' || item.type === 'link') && (item.docId === docId || item.id === docId)) { + count++; + } else if (item.type === 'category') { + // Check if the category itself matches (for parent architectures with href) + if (item.href === docId) { + count++; + } + // Recursively check children + if (item.items) { + count += countDocsInItems(item.items, docId); + } + } + } + return count; +} + +// Helper to count total docs in items (recursive) - for badge display +function countTotalDocsInItems(items): number { + let count = 0; + for (const item of items) { + if (item.type === 'doc' || item.type === 'link') { + count++; + } else if (item.type === 'category') { + // Count the category itself if it has a link (parent architecture that's also a document) + if (item.link || item.docId || item.href) { + count++; + } + // Also count children recursively + if (item.items) { + count += countTotalDocsInItems(item.items); + } + } + } + return count; +} + +// Group sidebar items by technology domain (preserving hierarchy) +function groupSidebarByDomain(items, docIdToTags) { + const domainIds = DOMAIN_DEFINITIONS.map((d) => d.id); + const grouped: Record = {}; + const duplicateCounts: Record = {}; + + // Initialize empty arrays for each domain + domainIds.forEach((id) => { grouped[id] = []; }); + + // Helper: Check if a doc/link belongs to a domain + const itemBelongsToDomain = (item, domainId) => { + const itemId = item.docId || item.id || ''; + const tags = docIdToTags?.[itemId] || []; + + // Direct match + if (tags.includes(domainId)) return true; + + // Check category mappings + const domainTags = categoryIdToTags[domainId] || []; + return domainTags.some((tag) => tags.includes(tag)); + }; + + // Helper: Recursively check if a category contains any docs for this domain + const categoryHasDocsForDomain = (category, domainId): boolean => { + if (!category.items || category.items.length === 0) return false; + + for (const child of category.items) { + if ((child.type === 'doc' || child.type === 'link') && itemBelongsToDomain(child, domainId)) { + return true; + } + if (child.type === 'category' && categoryHasDocsForDomain(child, domainId)) { + return true; + } + } + return false; + }; + + // First pass: Collect ALL document IDs (including parent architectures) from the entire sidebar + const collectAllDocIds = (itemList: any[]): Set => { + const docIds = new Set(); + + const traverse = (item: any) => { + if (item.type === 'doc' || item.type === 'link') { + const docId = item.docId || item.id; + if (docId) docIds.add(docId); + } else if (item.type === 'category') { + // If category has href and children, it's a parent architecture (expandable document) + if (item.href && item.items && item.items.length > 0) { + docIds.add(item.href); + } + // Recursively process children + if (item.items) { + item.items.forEach(traverse); + } + } + }; + + itemList.forEach(traverse); + return docIds; + }; + + // Collect all doc IDs first + const allDocIds = collectAllDocIds(items); + + // Helper: Recursively filter category items by domain + const filterCategoryForDomain = (category, domainId) => { + const filteredItems = []; + + for (const child of category.items || []) { + if ((child.type === 'doc' || child.type === 'link') && itemBelongsToDomain(child, domainId)) { + filteredItems.push(child); + } else if (child.type === 'category' && categoryHasDocsForDomain(child, domainId)) { + filteredItems.push(filterCategoryForDomain(child, domainId)); + } + } + + return { ...category, items: filteredItems }; + }; + + // Group items by domain, preserving category structure + items.forEach((item) => { + domainIds.forEach((domainId) => { + if ((item.type === 'doc' || item.type === 'link') && itemBelongsToDomain(item, domainId)) { + grouped[domainId].push(item); + } else if (item.type === 'category' && categoryHasDocsForDomain(item, domainId)) { + const filteredCategory = filterCategoryForDomain(item, domainId); + grouped[domainId].push(filteredCategory); + } + }); + }); + + // Calculate duplicate counts for all doc IDs + allDocIds.forEach((docId) => { + let count = 0; + domainIds.forEach((domainId) => { + const hasDoc = countDocsInItems(grouped[domainId], docId) > 0; + if (hasDoc) count++; + }); + if (count > 1) { + duplicateCounts[docId] = count - 1; + } + }); + + return { grouped, duplicateCounts }; +} + +// Filter grouped items by partner (preserving hierarchy) +function filterGroupedByPartner(grouped, selectedPartners, docIdToTags) { + if (!selectedPartners?.length) return grouped; + + const expand = (ids) => + Array.from(new Set(ids.flatMap((id) => [id, ...(categoryIdToTags[id] ?? [])]))); + const partnerTags = expand(selectedPartners); + + // Helper: Check if item matches partner filter + const itemMatchesPartner = (item) => { + const itemId = item.docId || item.id || ''; + const tags = docIdToTags?.[itemId] || []; + return partnerTags.some((p) => tags.includes(p)); + }; + + // Helper: Recursively filter category + const filterCategory = (category) => { + const filteredItems = []; + for (const child of category.items || []) { + if ((child.type === 'doc' || child.type === 'link') && itemMatchesPartner(child)) { + filteredItems.push(child); + } else if (child.type === 'category') { + const filteredChild = filterCategory(child); + if (filteredChild.items.length > 0) { + filteredItems.push(filteredChild); + } + } + } + return { ...category, items: filteredItems }; + }; + + const filtered: Record = {}; + Object.entries(grouped).forEach(([domainId, items]) => { + filtered[domainId] = []; + for (const item of items) { + if ((item.type === 'doc' || item.type === 'link') && itemMatchesPartner(item)) { + filtered[domainId].push(item); + } else if (item.type === 'category') { + const filteredCategory = filterCategory(item); + if (filteredCategory.items.length > 0) { + filtered[domainId].push(filteredCategory); + } + } + } + }); + + return filtered; +} + // ============================================================================ -// Desktop Version +// Shared Helper Functions // ============================================================================ -// Constant options defined outside component to avoid recreating on each render -const TECH_DOMAIN_OPTIONS = [ - { value: 'ai', label: 'AI & Machine Learning' }, - { value: 'appdev', label: 'Application Dev. & Automation' }, - { value: 'data', label: 'Data & Analytics' }, - { value: 'integration', label: 'Integration' }, - { value: 'opsec', label: 'Operation & Security' } -]; +// Collect unique doc IDs from grouped items (for result count display) +function collectUniqueDocIds(groupedItems: Record): Set { + const uniqueDocIds = new Set(); + + const traverse = (items: any[]) => { + items.forEach(item => { + if (item.type === 'doc' || item.type === 'link') { + const id = item.docId || item.id || ''; + if (id) uniqueDocIds.add(id); + } else if (item.type === 'category') { + // Count the category itself if it has href (parent architecture) + if (item.href) { + uniqueDocIds.add(item.href); + } + // Recursively count children + if (item.items) { + traverse(item.items); + } + } + }); + }; + + Object.values(groupedItems).forEach(items => traverse(items)); + return uniqueDocIds; +} + +// Add duplicate counters to item customProps recursively +function addDuplicateCountersToItems(items: any[], duplicateCounts: Record): any[] { + return items.map(item => { + if (item.type === 'category') { + // For parent architectures (categories with href), use href for matching + const categoryId = item.href || item.docId || item.id || ''; + const categoryDuplicateCount = duplicateCounts[categoryId]; + + return { + ...item, + customProps: { + ...item.customProps, + ...(categoryDuplicateCount && { duplicateCount: categoryDuplicateCount }) + }, + items: item.items ? addDuplicateCountersToItems(item.items, duplicateCounts) : [] + }; + } else if (item.type === 'doc' || item.type === 'link') { + const itemId = item.docId || item.id || ''; + const duplicateCount = duplicateCounts[itemId]; + + return { + ...item, + customProps: { + ...item.customProps, + ...(duplicateCount && { duplicateCount }) + } + }; + } + + return item; + }); +} + +// Transform domain-grouped data into Docusaurus category structure +function buildDomainCategories( + filteredGrouped: Record, + duplicateCounts: Record, + expandedDomains: string[] +) { + return DOMAIN_DEFINITIONS.map(domain => { + const domainItems = filteredGrouped[domain.id] || []; + const docCount = countTotalDocsInItems(domainItems); + const itemsWithCounters = addDuplicateCountersToItems(domainItems, duplicateCounts); + + return { + type: 'category', + label: `${domain.label} (${docCount})`, + items: itemsWithCounters, + collapsible: true, + collapsed: !expandedDomains.includes(domain.id), + customProps: { + domainId: domain.id + } + }; + }).filter(category => category.items.length > 0); +} + +// ============================================================================ +// Desktop Version +// ============================================================================ const PARTNER_OPTIONS = [ { value: 'aws', label: 'Amazon Web Services' }, { value: 'azure', label: 'Microsoft Azure' }, @@ -108,26 +392,34 @@ function DocSidebarDesktop(props) { const tagsDocId = useGlobalData()['docusaurus-tags-plugin'].default?.docIdToTags; const sidebar = useDocsSidebar(); const shouldShowFilters = sidebar?.name === 'refarchSidebar'; + const location = useLocation(); + const { + navbar: { hideOnScroll }, + docs: { + sidebar: { hideable }, + }, + } = useThemeConfig(); - const techDomains = useSidebarFilterStore((state) => state.techDomains); - const setTechDomains = useSidebarFilterStore((state) => state.setTechDomains); const partners = useSidebarFilterStore((state) => state.partners); const setPartners = useSidebarFilterStore((state) => state.setPartners); const resetFilters = useSidebarFilterStore((state) => state.resetFilters); + const expandedDomains = useSidebarFilterStore((state) => state.expandedDomains); const [searchTerm, setSearchTerm] = useState(''); - // All hooks must be called before any conditional returns - const filteredSidebar = useMemo( - () => filterSidebarItems(props.sidebar, techDomains, partners, tagsDocId), - [props.sidebar, techDomains, partners, tagsDocId] + // Group sidebar items by domain + const grouped = useMemo( + () => groupSidebarByDomain(props.sidebar, tagsDocId), + [props.sidebar, tagsDocId] ); - // Convert string arrays to Option arrays - const selectedTechDomainOptions = useMemo( - () => TECH_DOMAIN_OPTIONS.filter(opt => techDomains.includes(opt.value)), - [techDomains] + // Filter by selected partners + const filteredGrouped = useMemo( + () => filterGroupedByPartner(grouped.grouped, partners, tagsDocId), + [grouped.grouped, partners, tagsDocId] ); + + // Convert string arrays to Option arrays const selectedPartnerOptions = useMemo( () => PARTNER_OPTIONS.filter(opt => partners.includes(opt.value)), [partners] @@ -137,17 +429,6 @@ function DocSidebarDesktop(props) { return ; } - const handleTechDomainsChange = (selected) => { - const selectedKeys = selected.map(opt => opt.value); - setTechDomains(selectedKeys); - - // Sync URL - const params = new URLSearchParams(location.search); - if (selectedKeys.length) params.set('techDomains', selectedKeys.join(',')); - else params.delete('techDomains'); - window.history.replaceState({}, '', `${location.pathname}?${params.toString()}`); - }; - const handlePartnersChange = (selected) => { const selectedKeys = selected.map(opt => opt.value); setPartners(selectedKeys); @@ -156,6 +437,7 @@ function DocSidebarDesktop(props) { const params = new URLSearchParams(location.search); if (selectedKeys.length) params.set('partners', selectedKeys.join(',')); else params.delete('partners'); + params.delete('techDomains'); // Remove old techDomains param window.history.replaceState({}, '', `${location.pathname}?${params.toString()}`); }; @@ -165,43 +447,50 @@ function DocSidebarDesktop(props) { window.history.replaceState({}, '', location.pathname); }; - // Count total filtered docs - const countDocs = (items) => { - let count = 0; - items.forEach(item => { - if (item.type === 'doc' || item.type === 'link') { - count++; - } else if (item.type === 'category' && item.items) { - count += countDocs(item.items); - } - }); - return count; - }; + // Count unique documents (across all domains, no duplicates) + const resultCount = collectUniqueDocIds(filteredGrouped).size; - const resultCount = countDocs(filteredSidebar); - const newProps = { ...props, sidebar: filteredSidebar }; + // Transform domain-grouped data into Docusaurus category structure + const domainCategories = buildDomainCategories( + filteredGrouped, + grouped.duplicateCounts, + expandedDomains + ); return ( -
-
- 0 || partners.length > 0 || searchTerm.length > 0} - searchTerm={searchTerm} - onSearchChange={setSearchTerm} - resultCount={resultCount} +
+
+ 0 || searchTerm.length > 0} + searchTerm={searchTerm} + onSearchChange={setSearchTerm} + resultCount={resultCount} + /> +
+
+
+
-
- -
+ + {hideable && } +
+
); } @@ -210,22 +499,27 @@ function DocSidebarDesktop(props) { // ============================================================================ function FilteredMobileSidebarView({ sidebar, path, onItemClick }) { const tagsDocId = useGlobalData()['docusaurus-tags-plugin'].default?.docIdToTags; - const techDomains = useSidebarFilterStore((state) => state.techDomains); - const setTechDomains = useSidebarFilterStore((state) => state.setTechDomains); const partners = useSidebarFilterStore((state) => state.partners); const setPartners = useSidebarFilterStore((state) => state.setPartners); const resetFilters = useSidebarFilterStore((state) => state.resetFilters); + const expandedDomains = useSidebarFilterStore((state) => state.expandedDomains); const [searchTerm, setSearchTerm] = useState(''); // Convert string arrays to Option arrays - const selectedTechDomainOptions = TECH_DOMAIN_OPTIONS.filter(opt => techDomains.includes(opt.value)); const selectedPartnerOptions = PARTNER_OPTIONS.filter(opt => partners.includes(opt.value)); - const handleTechDomainsChange = (selected) => { - const selectedKeys = selected.map(opt => opt.value); - setTechDomains(selectedKeys); - }; + // Group sidebar items by domain + const grouped = useMemo( + () => groupSidebarByDomain(sidebar, tagsDocId), + [sidebar, tagsDocId] + ); + + // Filter by selected partners + const filteredGrouped = useMemo( + () => filterGroupedByPartner(grouped.grouped, partners, tagsDocId), + [grouped.grouped, partners, tagsDocId] + ); const handlePartnersChange = (selected) => { const selectedKeys = selected.map(opt => opt.value); @@ -238,42 +532,37 @@ function FilteredMobileSidebarView({ sidebar, path, onItemClick }) { window.history.replaceState({}, '', location.pathname); }; - const filteredSidebar = useMemo( - () => filterSidebarItems(sidebar, techDomains, partners, tagsDocId), - [sidebar, techDomains, partners, tagsDocId] - ); - - // Count total filtered docs - const countDocs = (items) => { - let count = 0; - items.forEach(item => { - if (item.type === 'doc' || item.type === 'link') { - count++; - } else if (item.type === 'category' && item.items) { - count += countDocs(item.items); - } - }); - return count; - }; + // Count unique documents + const resultCount = collectUniqueDocIds(filteredGrouped).size; - const resultCount = countDocs(filteredSidebar); + // Transform domain-grouped data into Docusaurus category structure + const domainCategories = buildDomainCategories( + filteredGrouped, + grouped.duplicateCounts, + expandedDomains + ); return ( <> 0 || partners.length > 0 || searchTerm.length > 0} + isResetEnabled={partners.length > 0 || searchTerm.length > 0} searchTerm={searchTerm} onSearchChange={setSearchTerm} resultCount={resultCount} /> - + ); } @@ -306,27 +595,74 @@ function DocSidebarMobile({ shouldShowFilters, ...props }) { const DocSidebarDesktopMemo = React.memo(DocSidebarDesktop); const DocSidebarMobileMemo = React.memo(DocSidebarMobile); +// Helper function to find a doc in sidebar by path +function findDocByPath(items, pathname) { + for (const item of items) { + if (item.type === 'doc' || item.type === 'link') { + if (item.href === pathname || pathname.startsWith(item.href)) { + return item.docId || item.id; + } + } else if (item.type === 'category' && item.items) { + const found = findDocByPath(item.items, pathname); + if (found) return found; + } + } + return null; +} + export default function DocSidebarWrapper(props) { const windowSize = useWindowSize(); const sidebarContext = useDocsSidebar(); const shouldShowFilters = sidebarContext?.name === 'refarchSidebar'; const setPartners = useSidebarFilterStore((state) => state.setPartners); - const setTechDomains = useSidebarFilterStore((state) => state.setTechDomains); + const setExpandedDomains = useSidebarFilterStore((state) => state.setExpandedDomains); const resetFilters = useSidebarFilterStore((state) => state.resetFilters); const history = useHistory(); const docsBase = useBaseUrl('/docs'); + const location = useLocation(); + const tagsDocId = useGlobalData()['docusaurus-tags-plugin']?.default?.docIdToTags; useEffect(() => { if (!location.pathname.startsWith(docsBase)) return; + if (!shouldShowFilters) return; // Only run for ref-arch sidebar + if (!tagsDocId) return; // Wait for tags data to load + if (!props.sidebar) return; // Wait for sidebar to load const params = new URLSearchParams(location.search); - const partnersParam = params.get('partners'); - const techDomainsParam = params.get('techDomains'); + const expandedParam = params.get('expanded'); if (partnersParam) setPartners(partnersParam.split(',')); - if (techDomainsParam) setTechDomains(techDomainsParam.split(',')); - }, [docsBase, setPartners, setTechDomains]); + + // If expanded param is set, use it (explicit choice from landing page) + if (expandedParam) { + setExpandedDomains(expandedParam.split(',')); + return; + } + + // Auto-expand domains for the current doc + // Find the doc ID by matching the current pathname to sidebar items + const docId = findDocByPath(props.sidebar, location.pathname); + + // If we're on a specific doc page + if (docId && tagsDocId[docId]) { + const docTags = tagsDocId[docId] || []; + const domainIds = DOMAIN_DEFINITIONS.map((d) => d.id); + + // Find which domains this doc belongs to + const matchingDomains = domainIds.filter((domainId) => { + // Direct match + if (docTags.includes(domainId)) return true; + // Check category mappings + const domainTags = categoryIdToTags[domainId] || []; + return domainTags.some((tag) => docTags.includes(tag)); + }); + + if (matchingDomains.length > 0) { + setExpandedDomains(matchingDomains); + } + } + }, [location.pathname, location.search, docsBase, setPartners, setExpandedDomains, shouldShowFilters, tagsDocId, props.sidebar]); useEffect(() => { diff --git a/src/theme/DocSidebar/styles.module.css b/src/theme/DocSidebar/styles.module.css index fd7662ea90..5d4b880403 100644 --- a/src/theme/DocSidebar/styles.module.css +++ b/src/theme/DocSidebar/styles.module.css @@ -1,7 +1,6 @@ -.refarchSidebarActive { - height: 100%; - display: flex; - flex-direction: column; +/* Make sidebarViewport take full width of its container */ +:global([class*="sidebarViewport"]) { + width: 100% !important; } .scrollableContent { @@ -9,31 +8,10 @@ flex-grow: 1; } -:global(html[data-sidebar-collapsed='true']) .scrollableContent { - display: none; -} - -:global(html[data-sidebar-collapsed='true']) .sidebarWithFiltersContainer > div:first-child { - display: none !important; -} - -:global(.theme-doc-sidebar-container.theme-doc-sidebar-container-hidden) .sidebarWithFiltersContainer > div:first-child { - display: none !important; -} - -:global([class*="docSidebarContainer"][class*="Hidden"]) .sidebarWithFiltersContainer > div:first-child { - display: none !important; -} - -.theme-doc-sidebar-container-collapsed .custom-sidebar-filters-container { - display: none; -} - -.sidebarMenuList .menu.thin-scrollbar { - flex: 1 1 auto; - min-height: 0; - overflow-y: auto; - overflow-x: hidden; +/* Hide filter bar and sidebar content when collapsed */ +.sidebarHidden { + opacity: 0 !important; + visibility: hidden !important; } .sidebarWithFiltersContainer { @@ -51,6 +29,14 @@ flex-direction: column; } +.sidebar { + border-right: 1px solid var(--ifm-toc-border-color); + display: flex; + flex-direction: column; + height: 100%; + width: var(--doc-sidebar-width); +} + [class^="sidebar_"] { border-right: 1px solid var(--ifm-toc-border-color); } @@ -58,3 +44,58 @@ :global(button[class*="collapseSidebarButton"]) { border-right: none !important; } + +/* Ensure expand button icon is visible */ +:global([class*="expandButton"]) { + cursor: pointer; +} + +:global([class*="expandButtonIcon"]) { + width: 20px; + height: 20px; + display: inline-block; +} + +/* Domain Sidebar Categories */ +.domainSidebar { + display: flex; + flex-direction: column; + padding: 0.5rem; + overflow-y: auto; + flex: 1; +} + +/* Apply Docusaurus thin-scrollbar styling */ +.domainSidebar.thin-scrollbar::-webkit-scrollbar { + width: 7px; +} + +.domainSidebar.thin-scrollbar::-webkit-scrollbar-track { + background: transparent; + border-radius: 10px; +} + +.domainSidebar.thin-scrollbar::-webkit-scrollbar-thumb { + background-color: rgba(0, 0, 0, 0.2); + border-radius: 10px; +} + +.domainSidebar.thin-scrollbar::-webkit-scrollbar-thumb:hover { + background-color: rgba(0, 0, 0, 0.3); +} + +[data-theme='dark'] .domainSidebar.thin-scrollbar::-webkit-scrollbar-thumb { + background-color: rgba(255, 255, 255, 0.2); +} + +[data-theme='dark'] .domainSidebar.thin-scrollbar::-webkit-scrollbar-thumb:hover { + background-color: rgba(255, 255, 255, 0.3); +} + +.domainSidebarMobile { + display: flex; + flex-direction: column; + padding: 0.5rem 0; +} + +/* Remove custom viewport styling - let Docusaurus handle it */ diff --git a/src/theme/DocSidebarItem/Category/index.tsx b/src/theme/DocSidebarItem/Category/index.tsx new file mode 100644 index 0000000000..590fa86801 --- /dev/null +++ b/src/theme/DocSidebarItem/Category/index.tsx @@ -0,0 +1,325 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React, { + type ComponentProps, + type ReactNode, + useEffect, + useMemo, +} from 'react'; +import clsx from 'clsx'; +import { + ThemeClassNames, + useThemeConfig, + usePrevious, + Collapsible, + useCollapsible, +} from '@docusaurus/theme-common'; +import {isSamePath} from '@docusaurus/theme-common/internal'; +import { + isActiveSidebarItem, + findFirstSidebarItemLink, + useDocSidebarItemsExpandedState, + useVisibleSidebarItems, +} from '@docusaurus/plugin-content-docs/client'; +import Link from '@docusaurus/Link'; +import {translate} from '@docusaurus/Translate'; +import useIsBrowser from '@docusaurus/useIsBrowser'; +import DocSidebarItems from '@theme/DocSidebarItems'; +import DocSidebarItemLink from '@theme/DocSidebarItem/Link'; +import type {Props} from '@theme/DocSidebarItem/Category'; + +import type { + PropSidebarItemCategory, + PropSidebarItemLink, +} from '@docusaurus/plugin-content-docs'; +import DuplicateCounter from '@theme/DocSidebarItem/DuplicateCounter'; +import styles from './styles.module.css'; + +// If we navigate to a category and it becomes active, it should automatically +// expand itself +function useAutoExpandActiveCategory({ + isActive, + collapsed, + updateCollapsed, + activePath, +}: { + isActive: boolean; + collapsed: boolean; + updateCollapsed: (b: boolean) => void; + activePath: string; +}) { + const wasActive = usePrevious(isActive); + const previousActivePath = usePrevious(activePath); + useEffect(() => { + const justBecameActive = isActive && !wasActive; + const stillActiveButPathChanged = + isActive && wasActive && activePath !== previousActivePath; + if ((justBecameActive || stillActiveButPathChanged) && collapsed) { + updateCollapsed(false); + } + }, [ + isActive, + wasActive, + collapsed, + updateCollapsed, + activePath, + previousActivePath, + ]); +} + +/** + * When a collapsible category has no link, we still link it to its first child + * during SSR as a temporary fallback. This allows to be able to navigate inside + * the category even when JS fails to load, is delayed or simply disabled + * React hydration becomes an optional progressive enhancement + * see https://github.com/facebookincubator/infima/issues/36#issuecomment-772543188 + * see https://github.com/facebook/docusaurus/issues/3030 + */ +function useCategoryHrefWithSSRFallback( + item: Props['item'], +): string | undefined { + const isBrowser = useIsBrowser(); + return useMemo(() => { + if (item.href && !item.linkUnlisted) { + return item.href; + } + // In these cases, it's not necessary to render a fallback + // We skip the "findFirstCategoryLink" computation + if (isBrowser || !item.collapsible) { + return undefined; + } + return findFirstSidebarItemLink(item); + }, [item, isBrowser]); +} + +function CollapseButton({ + collapsed, + categoryLabel, + onClick, +}: { + collapsed: boolean; + categoryLabel: string; + onClick: ComponentProps<'button'>['onClick']; +}) { + return ( +