mirror of
https://github.com/expressjs/expressjs.com.git
synced 2026-02-26 03:35:16 +00:00
feat: implement menu versioning
This commit is contained in:
319
astro/src/components/patterns/SidebarV2/SidebarV2.astro
Normal file
319
astro/src/components/patterns/SidebarV2/SidebarV2.astro
Normal 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>
|
||||
353
astro/src/components/patterns/SidebarV2/SidebarV2.css
Normal file
353
astro/src/components/patterns/SidebarV2/SidebarV2.css
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
685
astro/src/components/patterns/SidebarV2/SidebarV2Controller.ts
Normal file
685
astro/src/components/patterns/SidebarV2/SidebarV2Controller.ts
Normal 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;
|
||||
25
astro/src/components/patterns/SidebarV2/index.ts
Normal file
25
astro/src/components/patterns/SidebarV2/index.ts
Normal 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';
|
||||
148
astro/src/components/patterns/SidebarV2/types.ts
Normal file
148
astro/src/components/patterns/SidebarV2/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
19
astro/src/config/api-reference-menu.ts
Normal file
19
astro/src/config/api-reference-menu.ts
Normal 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' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
42
astro/src/config/docs-menu.ts
Normal file
42
astro/src/config/docs-menu.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
34
astro/src/config/main-menu.ts
Normal file
34
astro/src/config/main-menu.ts
Normal 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' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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'];
|
||||
|
||||
13
astro/src/config/middleware-menu.ts
Normal file
13
astro/src/config/middleware-menu.ts
Normal 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
30
astro/src/config/types.ts
Normal 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
Reference in New Issue
Block a user