feat: implement menu versioning

This commit is contained in:
Francesca Giannino
2026-02-04 13:42:41 +01:00
parent efcee5a639
commit b0e74cd19c
297 changed files with 2711 additions and 107 deletions

View File

@@ -0,0 +1,319 @@
---
/**
* SidebarV2 Component
*
* Enhanced mobile navigation sidebar with:
* - Unlimited navigation levels
* - Sections with optional titles at any level
* - Items can be links or buttons opening submenus
* - Version switcher for versioned content
* - Full accessibility support (focus trap, keyboard navigation, ARIA)
*/
import './SidebarV2.css';
import type { HTMLAttributes } from 'astro/types';
import { Body, BodyMd } from '@/components/primitives';
import { Icon } from 'astro-icon/components';
import { getLangFromUrl } from '@/i18n/utils';
import { mainMenu } from '@/config/main-menu';
import type { MenuConfig, VersionConfig } from './types';
type Props = HTMLAttributes<'div'> & {
/** Menu configuration (defaults to mainMenu) */
menu?: MenuConfig;
/** Available versions for the version switcher */
versions?: VersionConfig[];
/** Default version */
defaultVersion?: string;
};
const {
class: className,
menu = mainMenu as MenuConfig,
versions = [
{ id: 'v5', label: 'v5.x', isDefault: true },
{ id: 'v4', label: 'v4.x' },
{ id: 'v3', label: 'v3.x', isDeprecated: true },
],
defaultVersion = 'v5',
...rest
} = Astro.props;
const lang = getLangFromUrl(Astro.url);
const currentPath = Astro.url.pathname;
// Helper function to check if item is a link
function isLink(item: { href?: string; submenu?: unknown }): boolean {
return 'href' in item && item.href !== undefined;
}
---
<div id="sidebarV2Backdrop" class="sidebar-v2-backdrop" aria-hidden="true" data-sidebar-v2-backdrop>
</div>
<aside
id="sidebarV2"
class:list={['sidebar-v2', className]}
role="dialog"
aria-modal="true"
aria-labelledby="sidebar-v2-title"
aria-hidden="true"
data-sidebar-v2
data-lang={lang}
data-current-path={currentPath}
data-menu={JSON.stringify(menu)}
data-versions={JSON.stringify(versions)}
data-default-version={defaultVersion}
{...rest}
>
<h2 id="sidebar-v2-title" class="sr-only">Main menu</h2>
<!-- Navigation Container (holds all levels) -->
<div class="sidebar-v2-nav-container" data-nav-container>
<!-- Root Navigation Level (Level 0) -->
<nav
class="sidebar-v2-nav sidebar-v2-nav--active"
aria-label="Main navigation"
data-nav-level="0"
>
<ul class="sidebar-v2-nav-list">
{
menu.sections?.map((section, sectionIndex) => (
<li class="sidebar-v2-section" data-section-index={sectionIndex}>
{section.title && <h3 class="sidebar-v2-section-title">{section.title}</h3>}
<ul class="sidebar-v2-section-list">
{section.items.map((item, itemIndex) => (
<li>
{isLink(item) ? (
<a
href={`/${lang}${item.href}`}
class:list={[
'sidebar-v2-nav-item',
currentPath === `/${lang}${item.href}` && 'sidebar-v2-nav-item--active',
]}
aria-label={item.ariaLabel}
aria-current={currentPath === `/${lang}${item.href}` ? 'page' : undefined}
>
{item.icon && (
<div class="sidebar-v2-nav-icon">
<Icon
name={`ph:${item.icon}`}
size="24"
color="var(--color-icon-primary)"
/>
</div>
)}
<BodyMd vMargin={false} class="sidebar-v2-nav-label">
{item.label}
</BodyMd>
</a>
) : (
<button
class="sidebar-v2-nav-item"
aria-label={item.ariaLabel}
aria-expanded="false"
type="button"
data-submenu-trigger
data-item-path={`sections.${sectionIndex}.items.${itemIndex}`}
data-item-label={item.label}
>
{item.icon && (
<div class="sidebar-v2-nav-icon">
<Icon
name={`ph:${item.icon}`}
size="24"
color="var(--color-icon-primary)"
/>
</div>
)}
<BodyMd vMargin={false} class="sidebar-v2-nav-label">
{item.label}
</BodyMd>
<Icon
name="ph:arrow-right"
size="16"
color="var(--color-icon-secondary)"
class="sidebar-v2-nav-arrow"
aria-hidden="true"
/>
</button>
)}
</li>
))}
</ul>
</li>
))
}
{
menu.items?.map((item, itemIndex) => (
<li>
{isLink(item) ? (
<a
href={`/${lang}${item.href}`}
class:list={[
'sidebar-v2-nav-item',
currentPath === `/${lang}${item.href}` && 'sidebar-v2-nav-item--active',
]}
aria-label={item.ariaLabel}
aria-current={currentPath === `/${lang}${item.href}` ? 'page' : undefined}
>
{item.icon && (
<div class="sidebar-v2-nav-icon">
<Icon name={`ph:${item.icon}`} size="24" color="var(--color-icon-primary)" />
</div>
)}
<BodyMd vMargin={false} class="sidebar-v2-nav-label">
{item.label}
</BodyMd>
</a>
) : (
<button
class="sidebar-v2-nav-item"
aria-label={item.ariaLabel}
aria-expanded="false"
type="button"
data-submenu-trigger
data-item-path={`items.${itemIndex}`}
data-item-label={item.label}
>
{item.icon && (
<div class="sidebar-v2-nav-icon">
<Icon name={`ph:${item.icon}`} size="24" color="var(--color-icon-primary)" />
</div>
)}
<BodyMd vMargin={false} class="sidebar-v2-nav-label">
{item.label}
</BodyMd>
<Icon
name="ph:arrow-right"
size="16"
color="var(--color-icon-secondary)"
class="sidebar-v2-nav-arrow"
aria-hidden="true"
/>
</button>
)}
</li>
))
}
</ul>
</nav>
<!-- Dynamic Navigation Levels Container (populated via JS) -->
<div class="sidebar-v2-dynamic-levels" data-dynamic-levels>
<!-- Version Switcher (hidden by default, shown in deeper levels) -->
<div
class="sidebar-v2-version-switcher sidebar-v2-version-switcher--hidden"
data-version-switcher
>
<label class="sidebar-v2-version-label" for="version-select">
<Icon
name="ph:git-branch"
size="16"
color="var(--color-icon-secondary)"
aria-hidden="true"
/>
<span class="sr-only">API Version</span>
</label>
<select
id="version-select"
class="sidebar-v2-version-select"
data-version-select
aria-label="Select API version"
>
{
versions.map((version) => (
<option
value={version.id}
selected={version.id === defaultVersion}
disabled={version.isDeprecated}
>
{version.label} {version.isDeprecated ? '(deprecated)' : ''}
</option>
))
}
</select>
</div>
<!-- Dynamic navigation levels will be inserted here -->
</div>
</div>
<!-- Hidden template for navigation level -->
<template id="sidebar-v2-nav-template">
<nav class="sidebar-v2-nav" aria-label="Submenu navigation" data-nav-level>
<button class="sidebar-v2-nav-back" type="button" aria-label="Go back" data-back-button>
<Icon
name="ph:arrow-line-left"
size="16"
color="var(--color-icon-primary)"
aria-hidden="true"
/>
<Body
as="h3"
weight="bold"
class="sidebar-v2-nav-back-title"
data-nav-title
vMargin={false}
>
Navigation
</Body>
</button>
<div class="sidebar-v2-nav-content" data-nav-content>
<!-- Content will be populated dynamically -->
</div>
</nav>
</template>
<!-- Hidden template for section (content populated dynamically) -->
<!-- eslint-disable-next-line jsx-a11y/heading-has-content -->
<template id="sidebar-v2-section-template">
<div class="sidebar-v2-section" data-section>
<h4 class="sidebar-v2-section-title" data-section-title aria-hidden="true">Section</h4>
<ul class="sidebar-v2-section-list" data-section-list></ul>
</div>
</template>
<!-- Hidden template for link item (href populated dynamically via JS) -->
<template id="sidebar-v2-link-template">
<li>
<a class="sidebar-v2-nav-item sidebar-v2-nav-item--inner" href="/placeholder" data-link-item>
<span class="sidebar-v2-nav-label" data-link-label></span>
</a>
</li>
</template>
<!-- Hidden template for button item -->
<template id="sidebar-v2-button-template">
<li>
<button
class="sidebar-v2-nav-item sidebar-v2-nav-item--inner sidebar-v2-nav-item--submenu"
type="button"
data-submenu-trigger
>
<span class="sidebar-v2-nav-label" data-button-label></span>
<Icon
name="ph:arrow-right"
size="16"
color="var(--color-icon-secondary)"
class="sidebar-v2-nav-arrow"
aria-hidden="true"
/>
</button>
</li>
</template>
<!-- Hidden template for arrow icon -->
<template id="sidebar-v2-arrow-template">
<Icon
name="ph:arrow-right"
size="16"
color="var(--color-icon-secondary)"
class="sidebar-v2-nav-arrow"
aria-hidden="true"
/>
</template>
<script>
import './SidebarV2Controller';
</script>
</aside>

View File

@@ -0,0 +1,353 @@
/**
* SidebarV2 Styles
*
* Enhanced mobile navigation sidebar with:
* - Unlimited navigation levels (stack-based sliding)
* - Version switcher
* - Full accessibility support
*/
@layer patterns {
/* Backdrop */
.sidebar-v2-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
opacity: 0;
visibility: hidden;
transition:
opacity var(--duration-300) var(--ease-in-out),
visibility var(--duration-300) var(--ease-in-out);
z-index: var(--z-index-sidebar-backdrop);
}
.sidebar-v2-backdrop--visible {
opacity: 1;
visibility: visible;
}
/* Main Sidebar Container */
.sidebar-v2 {
position: fixed;
left: 0;
bottom: 0;
width: 80%;
max-width: var(--size-80);
background-color: var(--color-bg-secondary);
transform: translateX(-100%);
transition: transform var(--duration-300) var(--ease-in-out);
display: flex;
flex-direction: column;
overflow: hidden;
overscroll-behavior: contain;
z-index: var(--z-index-sidebar);
top: var(--space-14);
}
.sidebar-v2--open {
transform: translateX(0);
}
.sidebar-v2[aria-hidden='true'] {
visibility: hidden;
}
.sidebar-v2[aria-hidden='false'] {
visibility: visible;
}
/* Version Switcher */
.sidebar-v2-version-switcher {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border-mute);
background-color: var(--color-bg-tertiary);
}
/* Hidden state for version switcher (shown only in deeper levels) */
.sidebar-v2-version-switcher--hidden {
display: none;
}
.sidebar-v2-version-label {
display: flex;
align-items: center;
}
.sidebar-v2-version-select {
flex: 1;
padding: var(--space-2) var(--space-3);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
background-color: var(--color-bg-secondary);
border: 1px solid var(--color-border-mute);
border-radius: var(--radius-base);
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M3 4.5L6 7.5L9 4.5'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right var(--space-2) center;
padding-right: var(--space-6);
}
.sidebar-v2-version-select:hover {
border-color: var(--color-border-primary);
}
.sidebar-v2-version-select:focus-visible {
outline: 2px solid var(--color-focus-ring);
outline-offset: 2px;
}
.sidebar-v2-version-select option[disabled] {
color: var(--color-text-mute);
}
/* Navigation Container */
.sidebar-v2-nav-container {
flex: 1;
position: relative;
overflow: hidden;
}
/* Navigation Levels */
.sidebar-v2-nav {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
padding: var(--space-4);
background-color: var(--color-bg-secondary);
transform: translateX(100%);
transition: transform var(--duration-300) var(--ease-in-out);
overflow-y: auto;
overscroll-behavior: contain;
}
.sidebar-v2-nav--active {
transform: translateX(0);
}
.sidebar-v2-nav--slide-out {
transform: translateX(-100%);
}
/* First level (root) starts visible */
.sidebar-v2-nav[data-nav-level='0'] {
transform: translateX(0);
}
.sidebar-v2-nav[data-nav-level='0'].sidebar-v2-nav--slide-out {
transform: translateX(-100%);
}
/* Dynamic levels with different background */
.sidebar-v2-dynamic-levels .sidebar-v2-nav {
background-color: var(--color-bg-tertiary);
}
/* Navigation List */
.sidebar-v2-nav-list {
list-style: none;
margin: 0;
padding: 0;
flex: 1;
}
.sidebar-v2-nav-list li {
margin: 0;
}
/* Navigation Content (for dynamic levels) */
.sidebar-v2-nav-content {
flex: 1;
overflow-y: auto;
padding-right: var(--space-2);
}
/* Back Button */
.sidebar-v2-nav-back {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) 0;
padding-bottom: var(--space-4);
margin-bottom: var(--space-4);
background: none;
border: none;
border-bottom: 1px solid var(--color-border-mute);
cursor: pointer;
color: var(--color-text-primary);
transition: color var(--duration-200) var(--ease-in-out);
width: 100%;
text-align: left;
}
.sidebar-v2-nav-back:hover {
color: var(--color-primary);
}
.sidebar-v2-nav-back:focus-visible {
outline: 2px solid var(--color-focus-ring);
outline-offset: 2px;
}
.sidebar-v2-nav-back-title {
flex: 1;
}
/* Sections */
.sidebar-v2-section {
margin-bottom: var(--space-6);
}
.sidebar-v2-section:last-child {
margin-bottom: 0;
}
.sidebar-v2-section-title {
margin: 0 0 var(--space-2) 0;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.sidebar-v2-section-list {
list-style: none;
margin: 0;
padding: 0;
}
.sidebar-v2-section-list--no-title {
margin-top: 0;
}
/* Add left border for nested sections */
.sidebar-v2-nav-content .sidebar-v2-section-list {
margin-left: var(--space-3);
padding-left: var(--space-3);
border-left: 1px solid var(--color-border-mute);
}
.sidebar-v2-section-list li {
margin: 0;
}
/* Navigation Items (Root Level) */
.sidebar-v2-nav-item {
display: flex;
align-items: center;
gap: var(--space-3);
width: 100%;
padding: var(--space-3) 0;
text-decoration: none;
background-color: transparent;
border: 0;
cursor: pointer;
color: var(--color-text-primary);
transition: background-color var(--duration-200) var(--ease-in-out);
}
.sidebar-v2-nav-item:hover,
.sidebar-v2-nav-item--active {
background-color: var(--color-bg-secondary);
border-radius: 0 var(--radius-base) var(--radius-base) 0;
}
.sidebar-v2-nav-item:focus-visible {
outline: 2px solid var(--color-focus-ring);
outline-offset: -2px;
background-color: var(--color-bg-secondary);
}
/* Active/Current page styling */
.sidebar-v2-nav-item[aria-current='page'] {
color: var(--color-primary);
background-color: var(--color-bg-secondary);
font-weight: var(--font-weight-semibold);
position: relative;
}
.sidebar-v2-nav-item[aria-current='page']::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: var(--size-0-5);
background-color: var(--color-bg-inverse);
}
/* Navigation Icon */
.sidebar-v2-nav-icon {
padding: var(--space-2);
border-radius: var(--radius-base);
background-color: var(--color-bg-secondary);
transition: background-color var(--duration-200) var(--ease-in-out);
flex-shrink: 0;
}
.sidebar-v2-nav-item:hover .sidebar-v2-nav-icon {
background-color: var(--color-bg-mute);
}
/* Navigation Label */
.sidebar-v2-nav-label {
flex-grow: 1;
text-align: left;
}
/* Navigation Arrow */
.sidebar-v2-nav-arrow {
flex-shrink: 0;
opacity: 0.6;
transition: opacity var(--duration-200) var(--ease-in-out);
}
.sidebar-v2-nav-item:hover .sidebar-v2-nav-arrow {
opacity: 1;
}
/* Inner Level Navigation Items */
.sidebar-v2-nav-item--inner {
padding: var(--space-3);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-normal);
color: var(--color-text-secondary);
border-radius: var(--radius-base);
}
.sidebar-v2-nav-item--inner:hover {
background-color: var(--color-bg-secondary);
color: var(--color-text-primary);
}
/* Submenu Button */
.sidebar-v2-nav-item--submenu {
display: flex;
align-items: center;
justify-content: space-between;
text-align: left;
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
.sidebar-v2,
.sidebar-v2-backdrop,
.sidebar-v2-nav,
.sidebar-v2-nav-item {
transition: none;
}
}
}

View File

@@ -0,0 +1,685 @@
import type { MenuConfig, MenuLevel, MenuItem, VersionConfig, NavigationLevel } from './types';
/**
* SidebarV2Controller
*
* Enhanced sidebar controller supporting:
* - Unlimited navigation levels (dynamic stack-based navigation)
* - Version switching for versioned content
* - Focus trap and keyboard navigation
* - Current page detection and auto-navigation
*/
export class SidebarV2Controller {
private sidebar: HTMLElement | null;
private backdrop: HTMLElement | null;
private navContainer: HTMLElement | null;
private dynamicLevels: HTMLElement | null;
private versionSelect: HTMLSelectElement | null;
private versionSwitcher: HTMLElement | null;
private isOpen = false;
private focusableElements: HTMLElement[] = [];
private lastFocusedElement: HTMLElement | null = null;
// Navigation state
private menu: MenuConfig | null = null;
private versions: VersionConfig[] = [];
private currentVersion: string = 'v5';
private lang: string = 'en';
private currentPath: string = '';
private navigationStack: NavigationLevel[] = [];
private activeLevel = 0;
private currentBasePath: string = '';
private isVersionedContent: boolean = false;
// Templates
private navTemplate: HTMLTemplateElement | null = null;
private sectionTemplate: HTMLTemplateElement | null = null;
private linkTemplate: HTMLTemplateElement | null = null;
private buttonTemplate: HTMLTemplateElement | null = null;
private arrowTemplate: HTMLTemplateElement | null = null;
constructor() {
this.sidebar = document.querySelector('[data-sidebar-v2]');
this.backdrop = document.querySelector('[data-sidebar-v2-backdrop]');
this.navContainer = document.querySelector('[data-nav-container]');
this.dynamicLevels = document.querySelector('[data-dynamic-levels]');
this.versionSelect = document.querySelector('[data-version-select]');
this.versionSwitcher = document.querySelector('[data-version-switcher]');
// Get templates
this.navTemplate = document.getElementById('sidebar-v2-nav-template') as HTMLTemplateElement;
this.sectionTemplate = document.getElementById(
'sidebar-v2-section-template'
) as HTMLTemplateElement;
this.linkTemplate = document.getElementById('sidebar-v2-link-template') as HTMLTemplateElement;
this.buttonTemplate = document.getElementById(
'sidebar-v2-button-template'
) as HTMLTemplateElement;
this.arrowTemplate = document.getElementById(
'sidebar-v2-arrow-template'
) as HTMLTemplateElement;
this.loadConfiguration();
if (this.sidebar && this.backdrop) this.init();
}
private loadConfiguration(): void {
if (!this.sidebar) return;
const { menu, versions, defaultVersion, lang, currentPath } = this.sidebar.dataset;
if (menu) {
try {
this.menu = JSON.parse(menu);
} catch (e) {
console.error('Failed to parse menu configuration:', e);
}
}
if (versions) {
try {
this.versions = JSON.parse(versions);
} catch (e) {
console.error('Failed to parse versions configuration:', e);
}
}
this.currentVersion = defaultVersion || 'v5';
this.lang = lang || 'en';
this.currentPath = currentPath || '';
// Initialize root level in navigation stack
if (this.menu) {
this.navigationStack = [
{
title: 'Main Menu',
content: this.menu,
},
];
}
}
private init(): void {
// Backdrop click closes sidebar
this.backdrop?.addEventListener('click', () => this.close());
// Keyboard navigation
document.addEventListener('keydown', (e) => this.handleKeyDown(e));
// Custom toggle event
document.addEventListener('sidebar:toggle', () => this.toggle());
// Initialize root level navigation handlers
this.initRootLevelHandlers();
// Version switcher handler
this.versionSelect?.addEventListener('change', (e) => {
this.handleVersionChange((e.target as HTMLSelectElement).value);
});
}
private initRootLevelHandlers(): void {
const rootNav = this.navContainer?.querySelector('[data-nav-level="0"]');
if (!rootNav) return;
rootNav.querySelectorAll('[data-submenu-trigger]').forEach((button) => {
button.addEventListener('click', (e) => this.handleSubmenuClick(e, 0));
});
}
private handleSubmenuClick(e: Event, currentLevel: number): void {
const button = e.currentTarget as HTMLButtonElement;
const itemPath = button.dataset.itemPath;
const itemLabel = button.dataset.itemLabel;
if (!itemPath || !this.menu) return;
// Navigate to the item's submenu
const item = this.getItemByPath(itemPath);
if (item && 'submenu' in item && item.submenu) {
const submenu = item.submenu;
// Extract basePath and versioned from the submenu
const basePath = submenu.basePath || this.currentBasePath;
const versioned = submenu.versioned ?? this.isVersionedContent;
this.pushLevel(itemLabel || 'Navigation', submenu, currentLevel + 1, basePath, versioned);
}
}
/**
* Get a menu item by its path (e.g., 'sections.0.items.1')
*/
private getItemByPath(path: string): MenuItem | null {
if (!this.menu) return null;
const parts = path.split('.');
let current: MenuLevel | MenuItem[] | MenuItem | undefined = this.menu;
for (let i = 0; i < parts.length; i++) {
const key = parts[i];
const index = parseInt(parts[i + 1], 10);
if (key === 'sections' && 'sections' in current && current.sections) {
current = current.sections[index];
i++; // Skip the index
} else if (key === 'items' && 'items' in current && current.items) {
current = current.items[index];
i++; // Skip the index
} else if (key === 'submenu' && current && 'submenu' in current) {
current = current.submenu;
}
if (!current) return null;
}
return current as MenuItem;
}
/**
* Push a new navigation level onto the stack
*/
private pushLevel(
title: string,
content: MenuLevel,
levelIndex: number,
basePath?: string,
versioned?: boolean
): void {
// Remove any levels after the current active level
while (this.navigationStack.length > levelIndex) {
this.navigationStack.pop();
this.removeNavLevel(this.navigationStack.length);
}
// Update current basePath and versioned state
if (basePath !== undefined) {
this.currentBasePath = basePath;
}
if (versioned !== undefined) {
this.isVersionedContent = versioned;
}
// Add the new level
this.navigationStack.push({
title,
content,
currentPath: this.currentPath,
basePath: this.currentBasePath,
versioned: this.isVersionedContent,
});
// Create and show the new level UI
this.createNavLevel(content, title, levelIndex);
this.slideToLevel(levelIndex);
this.activeLevel = levelIndex;
// Show/hide version switcher based on versioned content
this.updateVersionSwitcherVisibility();
}
/**
* Update version switcher visibility based on current navigation state
*/
private updateVersionSwitcherVisibility(): void {
if (!this.versionSwitcher) return;
if (this.isVersionedContent && this.activeLevel > 0) {
this.versionSwitcher.classList.remove('sidebar-v2-version-switcher--hidden');
} else {
this.versionSwitcher.classList.add('sidebar-v2-version-switcher--hidden');
}
}
/**
* Go back one navigation level
*/
private popLevel(): void {
if (this.activeLevel <= 0) return;
const previousLevel = this.activeLevel - 1;
this.slideToLevel(previousLevel);
// Remove the popped level after animation
setTimeout(() => {
this.navigationStack.pop();
this.removeNavLevel(this.activeLevel);
this.activeLevel = previousLevel;
// Restore basePath and versioned state from previous level
const prevLevelState = this.navigationStack[previousLevel];
if (prevLevelState) {
this.currentBasePath = prevLevelState.basePath || '';
this.isVersionedContent = prevLevelState.versioned || false;
} else {
this.currentBasePath = '';
this.isVersionedContent = false;
}
// Update version switcher visibility
this.updateVersionSwitcherVisibility();
}, 300);
}
/**
* Create a navigation level element
*/
private createNavLevel(content: MenuLevel, title: string, levelIndex: number): void {
if (!this.navTemplate || !this.dynamicLevels) return;
const navClone = this.navTemplate.content.cloneNode(true) as DocumentFragment;
const nav = navClone.querySelector('.sidebar-v2-nav') as HTMLElement;
if (!nav) return;
nav.dataset.navLevel = String(levelIndex);
nav.setAttribute('aria-label', `${title} navigation`);
// Set the title
const titleEl = nav.querySelector('[data-nav-title]');
if (titleEl) titleEl.textContent = title;
// Setup back button
const backButton = nav.querySelector('[data-back-button]');
backButton?.addEventListener('click', () => this.popLevel());
// Populate content
const contentContainer = nav.querySelector('[data-nav-content]');
if (contentContainer) {
this.populateNavContent(contentContainer as HTMLElement, content, levelIndex);
}
this.dynamicLevels.appendChild(nav);
}
/**
* Populate navigation content with sections and items
*/
private populateNavContent(container: HTMLElement, content: MenuLevel, levelIndex: number): void {
container.innerHTML = '';
// Render sections
if (content.sections) {
content.sections.forEach((section, sectionIndex) => {
const sectionEl = this.createSectionElement(
section.title || '',
section.items,
levelIndex,
`sections.${sectionIndex}.items`
);
container.appendChild(sectionEl);
});
}
// Render standalone items (not in a section)
if (content.items && content.items.length > 0) {
const list = document.createElement('ul');
list.className = 'sidebar-v2-section-list sidebar-v2-section-list--no-title';
content.items.forEach((item, itemIndex) => {
const li = this.createItemElement(item, levelIndex, `items.${itemIndex}`);
list.appendChild(li);
});
container.appendChild(list);
}
}
/**
* Create a section element with title and items
*/
private createSectionElement(
title: string,
items: MenuItem[],
levelIndex: number,
basePath: string
): HTMLElement {
const section = document.createElement('div');
section.className = 'sidebar-v2-section';
if (title) {
const titleEl = document.createElement('h4');
titleEl.className = 'sidebar-v2-section-title';
titleEl.textContent = title;
section.appendChild(titleEl);
}
const list = document.createElement('ul');
list.className = 'sidebar-v2-section-list';
items.forEach((item, itemIndex) => {
const li = this.createItemElement(item, levelIndex, `${basePath}.${itemIndex}`);
list.appendChild(li);
});
section.appendChild(list);
return section;
}
/**
* Create an individual item element (link or button)
*/
private createItemElement(item: MenuItem, levelIndex: number, itemPath: string): HTMLLIElement {
const li = document.createElement('li');
if ('href' in item && item.href) {
// It's a link
const href = this.resolveHref(item.href, item.version);
const a = document.createElement('a');
a.className = 'sidebar-v2-nav-item sidebar-v2-nav-item--inner';
a.href = href;
a.textContent = item.label;
if (this.currentPath === href) {
a.setAttribute('aria-current', 'page');
a.classList.add('sidebar-v2-nav-item--active');
}
li.appendChild(a);
} else if ('submenu' in item && item.submenu) {
// It's a button with submenu
const button = document.createElement('button');
button.type = 'button';
button.className =
'sidebar-v2-nav-item sidebar-v2-nav-item--inner sidebar-v2-nav-item--submenu';
button.dataset.submenuTrigger = '';
button.dataset.itemPath = itemPath;
button.dataset.itemLabel = item.label;
button.setAttribute('aria-expanded', 'false');
const labelSpan = document.createElement('span');
labelSpan.className = 'sidebar-v2-nav-label';
labelSpan.textContent = item.label;
button.appendChild(labelSpan);
// Add arrow icon
if (this.arrowTemplate) {
button.appendChild(this.arrowTemplate.content.cloneNode(true));
} else {
const arrow = document.createElement('span');
arrow.className = 'sidebar-v2-nav-arrow';
arrow.innerHTML = '→';
button.appendChild(arrow);
}
button.addEventListener('click', (e) => {
this.handleDynamicSubmenuClick(e, item.submenu!, item.label, levelIndex);
});
li.appendChild(button);
}
return li;
}
/**
* Handle submenu click on dynamically created buttons
*/
private handleDynamicSubmenuClick(
e: Event,
submenu: MenuLevel,
label: string,
currentLevel: number
): void {
e.stopPropagation();
// Extract basePath and versioned from the submenu
const basePath = submenu.basePath || this.currentBasePath;
const versioned = submenu.versioned ?? this.isVersionedContent;
this.pushLevel(label, submenu, currentLevel + 1, basePath, versioned);
}
/**
* Resolve href with basePath and version
* Builds full paths like: /en/docs/v5/starter/installing
*/
private resolveHref(href: string, itemVersion?: string): string {
const version = itemVersion || this.currentVersion;
// If href contains {version} placeholder, replace it
if (href.includes('{version}')) {
href = href.replace('{version}', version);
}
// Build the full path with basePath and version for versioned content
let fullPath = '';
if (this.isVersionedContent && this.currentBasePath) {
// For versioned content: /lang/basePath/version/section/page
// e.g., /en/docs/v5/starter/installing
fullPath = `/${this.lang}${this.currentBasePath}/${version}${href}`;
} else if (this.currentBasePath) {
// For non-versioned content with basePath: /lang/basePath/section/page
fullPath = `/${this.lang}${this.currentBasePath}${href}`;
} else {
// Default: /lang/href
fullPath = `/${this.lang}${href}`;
}
return fullPath;
}
/**
* Remove a navigation level element
*/
private removeNavLevel(levelIndex: number): void {
const nav = this.dynamicLevels?.querySelector(`[data-nav-level="${levelIndex}"]`);
nav?.remove();
}
/**
* Slide to a specific navigation level
*/
private slideToLevel(levelIndex: number): void {
if (!this.navContainer) return;
// Update all nav levels
const allNavs = this.navContainer.querySelectorAll('.sidebar-v2-nav');
allNavs.forEach((nav) => {
const navLevel = parseInt((nav as HTMLElement).dataset.navLevel || '0', 10);
nav.classList.remove(
'sidebar-v2-nav--active',
'sidebar-v2-nav--slide-out',
'sidebar-v2-nav--slide-in'
);
if (navLevel < levelIndex) {
nav.classList.add('sidebar-v2-nav--slide-out');
nav.setAttribute('aria-hidden', 'true');
} else if (navLevel === levelIndex) {
nav.classList.add('sidebar-v2-nav--active');
nav.setAttribute('aria-hidden', 'false');
} else {
// Levels after active should be hidden to the right
nav.setAttribute('aria-hidden', 'true');
}
});
}
/**
* Handle version change
*/
private handleVersionChange(newVersion: string): void {
const previousVersion = this.currentVersion;
this.currentVersion = newVersion;
// Dispatch custom event for version change
document.dispatchEvent(
new CustomEvent('sidebar:versionChange', {
detail: { previousVersion, newVersion },
})
);
// If on a versioned page, navigate to the equivalent page in the new version
if (this.currentPath.includes(`/${previousVersion}/`)) {
const newPath = this.currentPath.replace(`/${previousVersion}/`, `/${newVersion}/`);
window.location.href = newPath;
}
}
/**
* Get focusable elements within the sidebar
*/
private getFocusableElements(): HTMLElement[] {
if (!this.sidebar) return [];
const selectors =
'a[href], button:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
return Array.from(this.sidebar.querySelectorAll(selectors));
}
/**
* Trap focus within the sidebar
*/
private trapFocus(e: KeyboardEvent): void {
if (!this.isOpen || e.key !== 'Tab') return;
this.focusableElements = this.getFocusableElements();
const first = this.focusableElements[0];
const last = this.focusableElements[this.focusableElements.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last?.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first?.focus();
}
}
/**
* Handle keyboard events
*/
private handleKeyDown(e: KeyboardEvent): void {
if (e.key === 'Escape' && this.isOpen) {
if (this.activeLevel > 0) {
this.popLevel();
} else {
this.close();
}
}
this.trapFocus(e);
}
/**
* Open the sidebar
*/
public open(): void {
if (this.isOpen || !this.sidebar || !this.backdrop) return;
this.lastFocusedElement = document.activeElement as HTMLElement;
this.isOpen = true;
this.sidebar.setAttribute('aria-hidden', 'false');
this.backdrop.setAttribute('aria-hidden', 'false');
this.sidebar.classList.add('sidebar-v2--open');
this.backdrop.classList.add('sidebar-v2-backdrop--visible');
document.body.style.overflow = 'hidden';
// Auto-navigate to current page if found
this.navigateToCurrentPage();
// Focus first focusable element
setTimeout(() => {
this.focusableElements = this.getFocusableElements();
this.focusableElements[0]?.focus();
}, 300);
}
/**
* Navigate to the current page's location in the menu
*/
private navigateToCurrentPage(): void {
// This would need to traverse the menu tree to find the current page
// and auto-expand to that level. Implementation depends on the actual
// menu structure and current path matching logic.
// For now, this is a placeholder for the auto-navigation feature.
}
/**
* Close the sidebar
*/
public close(): void {
if (!this.isOpen || !this.sidebar || !this.backdrop) return;
document.dispatchEvent(new CustomEvent('sidebar:closed'));
this.isOpen = false;
this.sidebar.setAttribute('aria-hidden', 'true');
this.backdrop.setAttribute('aria-hidden', 'true');
this.sidebar.classList.remove('sidebar-v2--open');
this.backdrop.classList.remove('sidebar-v2-backdrop--visible');
// Reset to root level after close animation
setTimeout(() => {
this.resetToRootLevel();
}, 300);
document.body.style.overflow = '';
this.lastFocusedElement?.focus();
this.lastFocusedElement = null;
}
/**
* Reset navigation to root level
*/
private resetToRootLevel(): void {
// Remove all dynamic levels
if (this.dynamicLevels) {
this.dynamicLevels.innerHTML = '';
}
// Reset navigation stack
if (this.menu) {
this.navigationStack = [
{
title: 'Main Menu',
content: this.menu,
},
];
}
// Reset basePath and versioned state
this.currentBasePath = '';
this.isVersionedContent = false;
this.activeLevel = 0;
this.slideToLevel(0);
// Hide version switcher
this.updateVersionSwitcherVisibility();
}
/**
* Toggle the sidebar open/closed
*/
public toggle(): void {
if (this.isOpen) {
this.close();
} else {
this.open();
}
}
/**
* Get current version
*/
public getVersion(): string {
return this.currentVersion;
}
/**
* Set version programmatically
*/
public setVersion(version: string): void {
if (this.versionSelect) {
this.versionSelect.value = version;
}
this.handleVersionChange(version);
}
}
// Initialize and expose globally
const sidebarV2Controller = new SidebarV2Controller();
(window as Window & { sidebarV2Controller?: SidebarV2Controller }).sidebarV2Controller =
sidebarV2Controller;

View File

@@ -0,0 +1,25 @@
/**
* SidebarV2 Component Exports
*
* Enhanced sidebar with unlimited navigation levels and version switching.
*/
// Export types
export type {
MenuConfig,
MenuLevel,
MenuItem,
MenuLink,
MenuButton,
MenuSection,
VersionConfig,
NavigationLevel,
NavigationStack,
PageInfo,
SubfolderInfo,
SectionWithPages,
NavigationData,
} from './types';
// Export type guards
export { isMenuLink, isMenuButton } from './types';

View File

@@ -0,0 +1,148 @@
/**
* SidebarV2 Types
*
* Flexible menu structure supporting unlimited navigation levels.
* Each level can contain single items and/or grouped sections.
*/
/** Base properties for all menu items */
export interface MenuItemBase {
/** Display label for the menu item */
label: string;
/** Accessibility label for screen readers */
ariaLabel?: string;
/** Icon name (using phosphor icons) */
icon?: string;
/** Order for sorting items within a section */
order?: number;
}
/** A menu item that links to a specific page */
export interface MenuLink extends MenuItemBase {
/** URL path for the link (relative, without language prefix) */
href: string;
/** Version folder for versioned content (e.g., 'v5', 'v4') */
version?: string;
submenu?: never;
}
/** A menu item that opens a submenu */
export interface MenuButton extends MenuItemBase {
/** Nested menu structure */
submenu: MenuLevel;
href?: never;
}
/** Union type for any menu item */
export type MenuItem = MenuLink | MenuButton;
/** A titled section containing menu items */
export interface MenuSection {
/** Optional title for the section (can be omitted for ungrouped items) */
title?: string;
/** Collection key for content fetching (e.g., 'docs/starter') */
collection?: string;
/** Base path for items in this section (e.g., '/docs') */
basePath?: string;
/** Whether this section contains versioned content */
versioned?: boolean;
/** Menu items within this section */
items: MenuItem[];
}
/** A navigation level containing sections and/or standalone items */
export interface MenuLevel {
/** Grouped sections with optional titles */
sections?: MenuSection[];
/** Standalone items not in a section */
items?: MenuItem[];
/** Base path inherited from parent (e.g., '/docs') */
basePath?: string;
/** Whether this level contains versioned content */
versioned?: boolean;
}
/** Root menu configuration */
export interface MenuConfig extends MenuLevel {
/** Default version for versioned content */
defaultVersion?: string;
/** Available versions for the version switcher */
versions?: VersionConfig[];
}
/** Version configuration for the version switcher */
export interface VersionConfig {
/** Version identifier (e.g., '5x') */
id: string;
/** Display label (e.g., 'v5.x') */
label: string;
/** Whether this is the default version */
isDefault?: boolean;
/** Whether this version is deprecated */
isDeprecated?: boolean;
}
/** Navigation state for a single level */
export interface NavigationLevel {
/** Title for the back button */
title: string;
/** Parent menu item key for tracking breadcrumb */
parentKey?: string;
/** Content to render in this level */
content: MenuLevel;
/** Current path at this level for highlighting */
currentPath?: string;
/** Base path for this level (e.g., '/docs') */
basePath?: string;
/** Whether this level contains versioned content */
versioned?: boolean;
/** Collection path for this level (e.g., 'starter') */
collectionPath?: string;
}
/** Complete navigation stack state */
export interface NavigationStack {
/** Stack of navigation levels (index 0 is root) */
levels: NavigationLevel[];
/** Currently active level index */
activeIndex: number;
/** Selected version */
currentVersion: string;
}
/** Page info for dynamic content */
export interface PageInfo {
title: string;
slug: string;
href: string;
order: number;
isOverview?: boolean;
version?: string;
}
/** Subfolder info for nested content */
export interface SubfolderInfo {
label: string;
pages: PageInfo[];
order: number;
}
/** Section with dynamically loaded pages */
export interface SectionWithPages {
label: string;
pages: PageInfo[];
subfolders: Record<string, SubfolderInfo>;
}
/** Complete navigation data structure */
export type NavigationData = Record<string, Record<string, SectionWithPages>>;
/** Helper type to check if an item is a link */
export function isMenuLink(item: MenuItem): item is MenuLink {
return 'href' in item && item.href !== undefined;
}
/** Helper type to check if an item is a button with submenu */
export function isMenuButton(item: MenuItem): item is MenuButton {
return 'submenu' in item && item.submenu !== undefined;
}

View File

@@ -7,4 +7,5 @@
export { default as Breadcrumbs } from './Breadcrumbs/Breadcrumbs.astro';
export { default as Header } from './Header/Header.astro';
export { default as Sidebar } from './Sidebar/Sidebar.astro';
export { default as SidebarV2 } from './SidebarV2/SidebarV2.astro';
export { default as ThemeSwitcher } from './ThemeSwitcher/ThemeSwitcher.astro';

View File

@@ -0,0 +1,19 @@
export const apiReferenceMenu = {
sections: [
{
title: 'Application',
items: [
{ href: `/application/application`, label: 'Overview' },
{ href: `/application/app-all`, label: 'app.all' },
{ href: `/application/app-delete`, label: 'app.delete' },
],
},
{
title: 'Request',
items: [
{ href: `/request/request`, label: 'Overview' },
{ href: `/request/req-accept`, label: 'req.accepts' },
],
},
],
};

View File

@@ -0,0 +1,42 @@
import type { Menu } from './types';
import { middlewareMenu } from './middleware-menu';
export const docsMenu: Menu = {
sections: [
{
title: 'Getting started',
items: [
{ href: `/starter/installing`, label: 'Installing' },
{ href: `/starter/hello-world`, label: 'Hello world' },
{ href: `/starter/generator`, label: 'Express generator' },
],
},
{
title: 'Guide',
items: [
{ href: `/guide/routing`, label: 'Routing' },
{ href: `/guide/writing-middleware`, label: 'Writing middleware' },
{ href: `/guide/using-middleware`, label: 'Using middleware' },
],
},
{
title: 'Advanced topics',
items: [
{ href: `/advanced/developing-template-engines`, label: 'Building template engines' },
],
},
{
title: 'Resources',
items: [
{ href: `/resources/community`, label: 'Community' },
{ href: `/resources/glossary`, label: 'Glossary' },
{
label: 'Middleware',
submenu: {
sections: middlewareMenu.sections,
},
},
],
},
],
};

View File

@@ -0,0 +1,34 @@
import { docsMenu } from './docs-menu';
import { apiReferenceMenu } from './api-reference-menu';
import type { Menu } from './types';
export const mainMenu: Menu = {
sections: [
{
items: [
{
label: 'Docs',
ariaLabel: 'Documentation',
icon: 'files',
submenu: {
basePath: '/docs',
versioned: true,
sections: docsMenu.sections,
},
},
{
label: 'API Reference',
ariaLabel: 'API Reference',
icon: 'code',
submenu: {
basePath: '/api-reference',
versioned: true,
sections: apiReferenceMenu.sections,
},
},
{ href: `/blog`, label: 'Blog', icon: 'newspaper' },
{ href: `/support`, label: 'Support', icon: 'info' },
],
},
],
};

View File

@@ -1,35 +1,30 @@
export const menuSections = {
docs: {
starter: 'Getting started',
guide: 'Guide',
advanced: 'Advanced topics',
resources: 'Resources',
},
'api-reference': {
'5x': '5.x API',
'4x': '4.x API',
'3x': '3.x API (deprecated)',
},
};
export const navItems = [
{
key: 'docs',
label: 'Docs',
ariaLabel: 'Documentation',
icon: 'files',
sections: menuSections.docs,
sections: {
starter: 'Getting started',
guide: 'Guide',
advanced: 'Advanced topics',
resources: 'Resources',
},
},
{
key: 'api-reference',
label: 'API Reference',
ariaLabel: 'API Reference',
icon: 'code',
sections: menuSections['api-reference'],
sections: {
'5x': '5.x API',
'4x': '4.x API',
'3x': '3.x API (deprecated)',
},
},
{ href: `/blog/`, label: 'Blog', ariaLabel: 'Blog', icon: 'newspaper' },
{ href: `/support/`, label: 'Support', ariaLabel: 'Support', icon: 'info' },
];
export type NavItem = (typeof navItems)[number];
export type MenuSection = keyof typeof menuSections;
export type MenuSection = keyof (typeof navItems)[number]['sections'];

View File

@@ -0,0 +1,13 @@
import type { Menu } from './types';
export const middlewareMenu: Menu = {
sections: [
{
items: [
{ href: `/resources/middleware/body-parser`, label: 'body-parser' },
{ href: `/resources/middleware/compression`, label: 'compression' },
{ href: `/resources/middleware/connect-rid`, label: 'connect-rid' },
],
},
],
};

30
astro/src/config/types.ts Normal file
View File

@@ -0,0 +1,30 @@
type MenuItemBaseProps = {
label: string;
ariaLabel?: string;
icon?: string;
};
// Menu item can be either a link or a button that opens a submenu
type MenuItem =
| (MenuItemBaseProps & {
href: string;
submenu?: never;
})
| (MenuItemBaseProps & {
submenu: Menu;
href?: never;
});
type MenuSection = {
title?: string;
basePath?: string;
versioned?: boolean;
items: MenuItem[];
};
export type Menu = {
sections?: MenuSection[];
items?: MenuItem[];
basePath?: string;
versioned?: boolean;
};

Some files were not shown because too many files have changed in this diff Show More