Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/content/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { defineCollection, z } from 'astro:content';

const menuCollection = defineCollection({
type: 'data',
schema: z.object({
items: z.array(
z.object({
label: z.string(),
href: z.string().optional(),
// optional
children: z.array(
z.object({
label: z.string(),
href: z.string(),
})
).optional(),
})
)
})
});

export const collections = {
'menu': menuCollection,
};
42 changes: 42 additions & 0 deletions src/content/menu/main.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"items": [
{
"label": "Inicio",
"href": "/"
},
{
"label": "La Conferencia",
"children": [
{
"label": "Speakers",
"href": "/speakers"
},
{
"label": "Agenda",
"href": "/agenda"
},
{
"label": "Sede",
"href": "/location"
}
]
},
{
"label": "Patrocinios",
"href": "/sponsorship"
},
{
"label": "Ediciones Anteriores",
"children": [
{
"label": "2025 (Sevilla)",
"href": "https://2025.es.pycon.org"
},
{
"label": "2024 (Vigo)",
"href": "https://2024.es.pycon.org"
}
]
}
]
}
13 changes: 11 additions & 2 deletions src/layouts/Layout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import '../style/global.css'
import '@fontsource-variable/jetbrains-mono'
import { ClientRouter } from 'astro:transitions'
import Nav from './components/Nav.astro'

interface Props {
title: string
Expand All @@ -27,8 +28,16 @@ const { title, description = 'PyconES 2026' } = Astro.props

<body
class="bg-slate-900 text-white antialiased h-screen flex flex-col justify-center bg-linear-to-r from-gray to-blue-900 bg-radial"
>
<slot />
><a
href="#main-content"
class="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 z-50 bg-blue-600 text-white px-4 py-2 rounded shadow-lg font-bold"
>
Saltar al contenido principal
</a>
<Nav />
<main id="main-content" class="flex-grow">
<slot />
</main>
</body>
</html>

Expand Down
204 changes: 204 additions & 0 deletions src/layouts/components/Nav.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
---
import { getEntry } from 'astro:content'

// load menu data
const menuData = await getEntry('menu', 'main')

if (!menuData) {
throw new Error('No se encontró el archivo src/content/menu/main.json')
}

const { items } = menuData.data
---

<header class="fixed top-0 w-full z-50 bg-slate-900/90 backdrop-blur-md border-b border-white/10">
<div class="container mx-auto px-4 h-20 flex items-center justify-between">
<a
href="/"
class="text-2xl font-bold font-mono text-white focus-visible:ring-2 focus-visible:ring-blue-400 rounded outline-none"
>
PyCon<span class="text-blue-500">ES</span>
</a>

<button
id="mobile-menu-btn"
class="lg:hidden text-white p-2 rounded focus-visible:ring-2 focus-visible:ring-blue-400 outline-none"
aria-label="Abrir menú principal"
aria-expanded="false"
aria-controls="nav-menu"
>
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
class="menu-icon-path"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16m-7 6h7"></path>
</svg>
</button>

<nav
id="nav-menu"
class="hidden absolute top-20 left-0 w-full bg-slate-900 lg:static lg:w-auto lg:bg-transparent lg:flex lg:items-center p-4 lg:p-0 border-b border-white/10 lg:border-none shadow-xl lg:shadow-none"
>
<ul class="flex flex-col lg:flex-row gap-2 lg:gap-8">
{
items.map((item, index) => (
<li class="relative">
{item.children ? (
<div class="dropdown-wrapper group">
<button
id={`dbtn-${index}`}
class="dropdown-btn flex items-center gap-1 w-full text-left text-slate-300 hover:text-white font-medium py-2 lg:py-0 transition-colors rounded outline-none focus-visible:text-blue-400 focus-visible:underline decoration-2 underline-offset-4"
aria-expanded="false"
aria-controls={`dmenu-${index}`}
aria-haspopup="true"
>
{item.label}
<svg
class="w-4 h-4 transition-transform duration-200 icon-chevron"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>

{/* Dropdown Menu */}
<ul
id={`dmenu-${index}`}
class="dropdown-menu hidden lg:absolute lg:top-full lg:left-0 lg:w-64 bg-slate-900 rounded-lg p-2 mt-2 lg:mt-4 shadow-2xl border border-white/10"
>
{item.children.map((child) => (
<li>
<a
href={child.href}
class="block px-4 py-3 rounded-md text-white font-medium hover:bg-white/5 focus-visible:bg-blue-600 focus-visible:text-white outline-none"
>
{child.label}
{child.description && (
<span class="block text-sm text-slate-400 mt-0.5">{child.description}</span>
)}
</a>
</li>
))}
</ul>
</div>
) : (
<a
href={item.href}
class="block text-slate-300 hover:text-white font-medium py-2 lg:py-0 transition-colors rounded outline-none focus-visible:text-blue-400 focus-visible:underline decoration-2 underline-offset-4"
>
{item.label}
</a>
)}
</li>
))
}
</ul>
</nav>
</div>
</header>

<script>
// Improved logic to comply with WAI-ARIA and the Bootstrap pattern
document.addEventListener('astro:page-load', () => {
const mobileBtn = document.getElementById('mobile-menu-btn')
const navMenu = document.getElementById('nav-menu')
const dropdownBtns = document.querySelectorAll('.dropdown-btn')

// Helper function to close all dropdowns
const closeAllDropdowns = () => {
dropdownBtns.forEach((btn) => {
btn.setAttribute('aria-expanded', 'false')
btn.nextElementSibling?.classList.add('hidden')
btn.querySelector('.icon-chevron')?.classList.remove('rotate-180')
})
}

// 1. Mobile Toggle
mobileBtn?.addEventListener('click', (e) => {
e.stopPropagation()
const isHidden = navMenu?.classList.contains('hidden')
navMenu?.classList.toggle('hidden')
const isExpanded = !isHidden
mobileBtn.setAttribute('aria-expanded', String(isExpanded))
mobileBtn.setAttribute('aria-label', isExpanded ? 'Cerrar menú' : 'Abrir menú')

const path = mobileBtn.querySelector('path')
if (isExpanded) {
path?.setAttribute('d', 'M6 18L18 6M6 6l12 12')
} else {
path?.setAttribute('d', 'M4 6h16M4 12h16m-7 6h7')
}
})

// 2. Dropdown Logic (Desktop & Mobile)
dropdownBtns.forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation() // Prevent immediate closing by click outside
const menu = btn.nextElementSibling
const icon = btn.querySelector('.icon-chevron')
const isExpanded = btn.getAttribute('aria-expanded') === 'true'

// If desktop, close other menus before opening this one
if (window.innerWidth >= 1024 && !isExpanded) {
closeAllDropdowns()
}

// Toggle state
if (isExpanded) {
btn.setAttribute('aria-expanded', 'false')
menu?.classList.add('hidden')
icon?.classList.remove('rotate-180')
} else {
btn.setAttribute('aria-expanded', 'true')
menu?.classList.remove('hidden')
icon?.classList.add('rotate-180')
}
})

// Arrow support (optional but recommended by reviewer)
btn.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault() // Prevent scroll
if (btn.getAttribute('aria-expanded') === 'false') btn.click()
// Move focus to first element of submenu
const firstLink = btn.nextElementSibling?.querySelector('a') as HTMLElement
firstLink?.focus()
}
})
})

// 3. Click Outside (Close all)
document.addEventListener('click', (e) => {
const target = e.target as Node
if (!navMenu?.contains(target) && !mobileBtn?.contains(target)) {
// Close mobile menu
if (!navMenu?.classList.contains('hidden') && window.innerWidth < 1024) {
mobileBtn?.click()
}
// Close desktop dropdowns
closeAllDropdowns()
}
})

// 4. ESC Key (Strict WCAG requirement)
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeAllDropdowns()
// If mobile menu is open, close it
if (navMenu && !navMenu.classList.contains('hidden') && window.innerWidth < 1024) {
mobileBtn?.click()
mobileBtn?.focus()
}
}
})
})
</script>