feat: version switcher controlling menu only

This commit is contained in:
Francesca Giannino
2026-02-17 09:37:54 +01:00
parent fa7296fe33
commit ddbf921a2f
7 changed files with 273 additions and 124 deletions

View File

@@ -214,6 +214,46 @@
background-color: var(--color-bg-inverse); background-color: var(--color-bg-inverse);
} }
.sidebar-nav-item--nested.sidebar-nav-item--inactive::before {
display: none;
}
.sidebar-nav-item--nested.sidebar-nav-item--inactive {
font-weight: var(--font-weight-normal);
}
.sidebar-nav-item--hidden,
.sidebar-section--hidden {
display: none !important;
}
.sidebar-version-warning {
background-color: var(--color-bg-warning, #fff3cd);
border: 1px solid var(--color-border-warning, #ffc107);
border-radius: var(--radius-base);
padding: var(--space-3);
margin-bottom: var(--space-4);
p {
color: var(--color-text-primary);
line-height: 1.5;
}
p + p {
margin-top: var(--space-2);
}
a {
color: var(--color-text-primary);
text-decoration: underline;
font-weight: var(--font-weight-semibold);
&:hover {
color: var(--color-text-link);
}
}
}
.sidebar-nav-icon { .sidebar-nav-icon {
padding: var(--space-2); padding: var(--space-2);
border-radius: var(--radius-base); border-radius: var(--radius-base);

View File

@@ -71,6 +71,15 @@ export class SidebarController {
this.initialActiveSubmenuPath = [...this.activeSubmenuPath]; this.initialActiveSubmenuPath = [...this.activeSubmenuPath];
this.versionManager?.updatePath(this.activeSubmenuPath); this.versionManager?.updatePath(this.activeSubmenuPath);
// Check version compatibility for the active submenu
const activeSubmenuId = this.activeSubmenuPath[this.activeSubmenuPath.length - 1];
const activeSubmenuColumn = this.sidebar?.querySelector(
`[data-parent-id="${activeSubmenuId}"]`
) as HTMLElement;
if (activeSubmenuColumn) {
this.checkVersionCompatibility(activeSubmenuColumn);
}
if (this.navContainer) { if (this.navContainer) {
this.navContainer.dataset.currentNavLevel = String(initialActiveLevel); this.navContainer.dataset.currentNavLevel = String(initialActiveLevel);
} }
@@ -117,6 +126,9 @@ export class SidebarController {
this.activeLevel = level; this.activeLevel = level;
this.versionManager?.updatePath(this.activeSubmenuPath); this.versionManager?.updatePath(this.activeSubmenuPath);
// Check version compatibility for this submenu
this.checkVersionCompatibility(submenuColumn);
this.updateActiveColumns(); this.updateActiveColumns();
if (this.navContainer) { if (this.navContainer) {
@@ -129,6 +141,41 @@ export class SidebarController {
}, TRANSITION_DURATION); }, TRANSITION_DURATION);
} }
private checkVersionCompatibility(submenuColumn: HTMLElement): void {
const supportedVersions = submenuColumn.dataset.supportedVersions?.split(',') || [];
const currentMenuVersion = this.versionManager?.getVersion() || '';
const urlVersion = this.versionManager?.getUrlVersion() || '';
const warningElement = submenuColumn.querySelector('[data-version-warning]') as HTMLElement;
if (!warningElement) return;
// If no versions are supported (non-versioned submenu), hide warning
if (supportedVersions.length === 0 || supportedVersions[0] === '') {
warningElement.style.display = 'none';
return;
}
// If current menu version is not supported by this submenu
if (!supportedVersions.includes(currentMenuVersion)) {
// Auto-revert to URL version if supported, otherwise to latest version
const targetVersion = supportedVersions.includes(urlVersion)
? urlVersion
: supportedVersions[0]; // First version is the latest
// Only show warning if we're switching from v3
if (currentMenuVersion === '3x') {
warningElement.style.display = 'block';
} else {
warningElement.style.display = 'none';
}
// Auto-switch to compatible version
this.versionManager?.setVersion(targetVersion);
} else {
warningElement.style.display = 'none';
}
}
private navigateBack(): void { private navigateBack(): void {
if (this.activeSubmenuPath.length <= 1) return; if (this.activeSubmenuPath.length <= 1) return;

View File

@@ -1,7 +1,7 @@
--- ---
import type { MenuItem, VersionPrefix } from '@/config/types'; import type { MenuItem, VersionPrefix } from '@/config/types';
import SidebarNavItem from './SidebarNavItem.astro'; import SidebarNavItem from './SidebarNavItem.astro';
import { isLink, hasSubmenu, filterItems, getItemId, resolveHref, normalizePath } from './utils'; import { isLink, hasSubmenu, getItemId, resolveHref, normalizePath } from './utils';
interface Props { interface Props {
items: MenuItem[]; items: MenuItem[];
@@ -31,7 +31,8 @@ const {
targetLevel, targetLevel,
} = Astro.props; } = Astro.props;
const filteredItems = filterItems(items, version); // Don't filter items server-side - render all items and filter client-side based on selected version
const filteredItems = items;
--- ---
{ {

View File

@@ -1,12 +1,11 @@
--- ---
import type { Menu, VersionPrefix } from '@/config/types'; import type { Menu, VersionPrefix } from '@/config/types';
import type { VersionConfig } from '@/components/patterns/VersionSwitcher/types'; import type { VersionConfig } from '@/components/patterns/VersionSwitcher/types';
import { Body } from '@/components/primitives'; import { Body, BodyMd } from '@/components/primitives';
import { Icon } from 'astro-icon/components'; import { Icon } from 'astro-icon/components';
import { VersionSwitcher } from '@/components/patterns'; import { VersionSwitcher } from '@/components/patterns';
import SidebarItemsList from './SidebarItemsList.astro'; import SidebarItemsList from './SidebarItemsList.astro';
import { import {
shouldOmitSection,
collectAllSubmenus, collectAllSubmenus,
submenuContainsCurrentPath, submenuContainsCurrentPath,
calculateInitialActiveLevel, calculateInitialActiveLevel,
@@ -63,28 +62,26 @@ const initialActiveLevel =
> >
<nav class="sidebar-nav" aria-label="Main navigation"> <nav class="sidebar-nav" aria-label="Main navigation">
<ul class="sidebar-nav-list"> <ul class="sidebar-nav-list">
{menu.sections {menu.sections?.map((section, sectionIndex) => (
?.filter((section) => !shouldOmitSection(section, version)) <li class="sidebar-section" data-omit-from={section.omitFrom?.join(',') || undefined}>
.map((section, sectionIndex) => ( {section.title && <Body as="h3">{section.title}</Body>}
<li class="sidebar-section"> <ul class="sidebar-section-list">
{section.title && <Body as="h3">{section.title}</Body>} <SidebarItemsList
<ul class="sidebar-section-list"> items={section.items}
<SidebarItemsList parentId={parentId}
items={section.items} sectionIndex={sectionIndex}
parentId={parentId} currentPath={currentPath}
sectionIndex={sectionIndex} lang={lang}
currentPath={currentPath} basePath={basePath}
lang={lang} versioned={versioned}
basePath={basePath} version={version}
versioned={versioned} variant="root"
version={version} showIcon={true}
variant="root" targetLevel={level + 1}
showIcon={true} />
targetLevel={level + 1} </ul>
/> </li>
</ul> ))}
</li>
))}
<SidebarItemsList <SidebarItemsList
items={menu.items ?? []} items={menu.items ?? []}
parentId={parentId} parentId={parentId}
@@ -133,6 +130,7 @@ const initialActiveLevel =
panelHasActiveItem && 'sidebar-nav-panel--active', panelHasActiveItem && 'sidebar-nav-panel--active',
]} ]}
data-parent-id={submenu.id} data-parent-id={submenu.id}
data-supported-versions={availableVersions.map((v) => v.id).join(',')}
aria-hidden="true" aria-hidden="true"
> >
{availableVersions.length > 0 && ( {availableVersions.length > 0 && (
@@ -145,6 +143,20 @@ const initialActiveLevel =
data-version-switcher data-version-switcher
/> />
)} )}
<div
class="sidebar-version-warning"
data-version-warning
aria-live="polite"
style="display: none;"
>
<BodyMd vMargin={false}>
<strong>Note:</strong> There is no documentation available for v3.x.
</BodyMd>
<BodyMd vMargin={false}>
Express v3 has reached end-of-life. For support information, visit the{' '}
<a href={`/${lang}/support`}>support page</a>.
</BodyMd>
</div>
<nav class="sidebar-nav" aria-label={`${submenu.title} navigation`}> <nav class="sidebar-nav" aria-label={`${submenu.title} navigation`}>
<button <button
class="sidebar-nav-back" class="sidebar-nav-back"
@@ -164,32 +176,33 @@ const initialActiveLevel =
</Body> </Body>
</button> </button>
<div class="sidebar-nav-content"> <div class="sidebar-nav-content">
{submenu.menu.sections {submenu.menu.sections?.map((section, sectionIndex) => (
?.filter((section) => !shouldOmitSection(section, version)) <div
.map((section, sectionIndex) => ( class="sidebar-section"
<div class="sidebar-section"> data-omit-from={section.omitFrom?.join(',') || undefined}
{section.title && ( >
<Body as="h3" weight="bold" aria-label={`Section: ${section.title}`}> {section.title && (
{section.title} <Body as="h3" weight="bold" aria-label={`Section: ${section.title}`}>
</Body> {section.title}
)} </Body>
<ul class="sidebar-section-list"> )}
<SidebarItemsList <ul class="sidebar-section-list">
items={section.items} <SidebarItemsList
parentId={submenu.id} items={section.items}
sectionIndex={sectionIndex} parentId={submenu.id}
currentPath={currentPath} sectionIndex={sectionIndex}
lang={lang} currentPath={currentPath}
basePath={submenu.basePath} lang={lang}
versioned={submenu.versioned} basePath={submenu.basePath}
version={version} versioned={submenu.versioned}
variant="nested" version={version}
showIcon={false} variant="nested"
targetLevel={navLevel + 1} showIcon={false}
/> targetLevel={navLevel + 1}
</ul> />
</div> </ul>
))} </div>
))}
{submenu.menu.items && submenu.menu.items.length > 0 && ( {submenu.menu.items && submenu.menu.items.length > 0 && (
<ul class="sidebar-section-list"> <ul class="sidebar-section-list">
<SidebarItemsList <SidebarItemsList

View File

@@ -65,6 +65,7 @@ const {
data-submenu-trigger data-submenu-trigger
data-target-id={submenuId} data-target-id={submenuId}
data-target-level={targetLevel} data-target-level={targetLevel}
data-omit-from={item.omitFrom?.join(',') || undefined}
> >
{showIcon && item.icon && ( {showIcon && item.icon && (
<div class="sidebar-nav-icon"> <div class="sidebar-nav-icon">

View File

@@ -1,11 +1,13 @@
export class SidebarVersionManager { export class SidebarVersionManager {
private sidebar: HTMLElement; private sidebar: HTMLElement;
private currentVersion: string; private urlVersion: string; // Version from the URL (content version)
private menuVersion: string; // Version selected in the menu switcher
private activeSubmenuPath: string[]; private activeSubmenuPath: string[];
constructor(sidebar: HTMLElement, currentVersion: string, activeSubmenuPath: string[]) { constructor(sidebar: HTMLElement, currentVersion: string, activeSubmenuPath: string[]) {
this.sidebar = sidebar; this.sidebar = sidebar;
this.currentVersion = currentVersion; this.urlVersion = currentVersion;
this.menuVersion = currentVersion;
this.activeSubmenuPath = activeSubmenuPath; this.activeSubmenuPath = activeSubmenuPath;
} }
@@ -20,11 +22,45 @@ export class SidebarVersionManager {
this.handleVersionChange(select.value); this.handleVersionChange(select.value);
}); });
}); });
// Listen for in-page link clicks to revert menu to URL version
this.setupInPageLinkListener();
// Initialize menu visibility for current URL version
this.updateMenuForVersion(this.urlVersion);
}
private setupInPageLinkListener(): void {
// Listen for clicks on any links in the main content area
document.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
const link = target.closest('a');
// Check if it's a link and not in the sidebar
if (link && !this.sidebar.contains(link)) {
const href = link.getAttribute('href');
// If it's an internal link (not external, not hash-only)
if (href && !href.startsWith('http') && !href.startsWith('#')) {
// Revert menu to URL version when clicking internal links
this.revertToUrlVersion();
}
}
});
} }
handleVersionChange(newVersion: string): void { handleVersionChange(newVersion: string): void {
const previousVersion = this.currentVersion; const previousVersion = this.menuVersion;
this.currentVersion = newVersion; this.menuVersion = newVersion;
// Sync all version switchers to the same value
const versionSelects =
this.sidebar.querySelectorAll<HTMLSelectElement>('[data-version-select]');
versionSelects.forEach((select) => {
if (select.value !== newVersion) {
select.value = newVersion;
}
});
document.dispatchEvent( document.dispatchEvent(
new CustomEvent('sidebar:versionChange', { new CustomEvent('sidebar:versionChange', {
@@ -32,82 +68,89 @@ export class SidebarVersionManager {
}) })
); );
const currentPath = this.sidebar.dataset.currentPath || ''; // Update menu visibility based on the new version
this.updateMenuForVersion(newVersion);
if (currentPath.includes(`/${previousVersion}/`)) { // Update link hrefs to point to the new version
if (this.isPathOmittedForVersion(currentPath, newVersion)) { this.updateLinkVersions(previousVersion, newVersion);
const fallbackPath = this.getFirstAvailableLinkForVersion(newVersion, previousVersion);
if (fallbackPath) { // Update active states - remove them if menu version doesn't match URL version
window.location.href = fallbackPath; this.updateActiveStates();
return; }
}
private updateMenuForVersion(version: string): void {
// Show/hide menu items based on omitFrom attribute
const allMenuItems = this.sidebar.querySelectorAll('[data-omit-from]');
allMenuItems.forEach((item) => {
const omitFrom = (item as HTMLElement).dataset.omitFrom?.split(',') || [];
const shouldHide = omitFrom.includes(version);
if (shouldHide) {
item.classList.add('sidebar-nav-item--hidden');
item.setAttribute('aria-hidden', 'true');
} else {
item.classList.remove('sidebar-nav-item--hidden');
item.setAttribute('aria-hidden', 'false');
} }
});
window.location.href = currentPath.replace(`/${previousVersion}/`, `/${newVersion}/`); // Show/hide sections based on omitFrom attribute
return; const allSections = this.sidebar.querySelectorAll('.sidebar-section[data-omit-from]');
}
if (this.isInVersionedSubmenu()) { allSections.forEach((section) => {
const fallbackPath = this.getFirstAvailableLinkForVersion(newVersion, previousVersion); const omitFrom = (section as HTMLElement).dataset.omitFrom?.split(',') || [];
if (fallbackPath) { const shouldHide = omitFrom.includes(version);
window.location.href = fallbackPath;
if (shouldHide) {
section.classList.add('sidebar-section--hidden');
section.setAttribute('aria-hidden', 'true');
} else {
section.classList.remove('sidebar-section--hidden');
section.setAttribute('aria-hidden', 'false');
} }
});
}
private updateLinkVersions(previousVersion: string, newVersion: string): void {
// Update all versioned links to point to the new version
const allLinks = this.sidebar.querySelectorAll<HTMLAnchorElement>('a.sidebar-nav-item[href]');
allLinks.forEach((link) => {
const href = link.getAttribute('href');
if (href && href.includes(`/${previousVersion}/`)) {
const newHref = href.replace(`/${previousVersion}/`, `/${newVersion}/`);
link.setAttribute('href', newHref);
}
});
}
private updateActiveStates(): void {
// If menu version doesn't match URL version, remove all active states
const allActiveItems = this.sidebar.querySelectorAll('.sidebar-nav-item--active');
if (this.menuVersion !== this.urlVersion) {
allActiveItems.forEach((item) => {
item.classList.add('sidebar-nav-item--inactive');
item.classList.remove('sidebar-nav-item--active');
});
} else {
allActiveItems.forEach((item) => {
item.classList.remove('sidebar-nav-item--inactive');
});
} }
} }
private isInVersionedSubmenu(): boolean { public revertToUrlVersion(): void {
const activeSubmenuId = this.activeSubmenuPath[this.activeSubmenuPath.length - 1]; // Revert menu back to URL version
if (activeSubmenuId === 'root') return false; if (this.menuVersion !== this.urlVersion) {
const versionSelects =
const activePanel = this.sidebar.querySelector( this.sidebar.querySelectorAll<HTMLSelectElement>('[data-version-select]');
`[data-parent-id="${activeSubmenuId}"]` versionSelects.forEach((select) => {
) as HTMLElement; select.value = this.urlVersion;
});
if (!activePanel) return false; this.handleVersionChange(this.urlVersion);
// Check if the active panel contains versioned links
const versionedLink = activePanel.querySelector('a[href*="/4x/"], a[href*="/5x/"]');
return versionedLink !== null;
}
private isPathOmittedForVersion(currentPath: string, targetVersion: string): boolean {
const activeLink = this.sidebar.querySelector(
'a.sidebar-nav-item--active[data-omit-from]'
) as HTMLAnchorElement;
if (!activeLink) return false;
const omitFrom = activeLink.dataset.omitFrom?.split(',') || [];
return omitFrom.includes(targetVersion);
}
private getFirstAvailableLinkForVersion(
targetVersion: string,
previousVersion: string
): string | null {
const activeSubmenuId = this.activeSubmenuPath[this.activeSubmenuPath.length - 1];
const activePanel = this.sidebar.querySelector(
`[data-parent-id="${activeSubmenuId}"]`
) as HTMLElement;
if (!activePanel) return null;
const links = activePanel.querySelectorAll(
'a.sidebar-nav-item[href]'
) as NodeListOf<HTMLAnchorElement>;
for (const link of links) {
const omitFrom = link.dataset.omitFrom?.split(',') || [];
if (!omitFrom.includes(targetVersion)) {
const href = link.getAttribute('href') || '';
if (href.includes(`/${previousVersion}/`)) {
return href.replace(`/${previousVersion}/`, `/${targetVersion}/`);
}
return href;
}
} }
return null;
} }
updateVisibility(activeLevel: number): void { updateVisibility(activeLevel: number): void {
@@ -131,7 +174,11 @@ export class SidebarVersionManager {
} }
getVersion(): string { getVersion(): string {
return this.currentVersion; return this.menuVersion;
}
getUrlVersion(): string {
return this.urlVersion;
} }
setVersion(version: string): void { setVersion(version: string): void {