diff --git a/app/newsletter/page.tsx b/app/newsletter/page.tsx
new file mode 100644
index 0000000..2d1404c
--- /dev/null
+++ b/app/newsletter/page.tsx
@@ -0,0 +1,6 @@
+import ArchivePage from '@/components/ArchivePage';
+import { newsletterArchive } from '@/lib/archive';
+
+export default function NewsletterPage() {
+ return ;
+}
diff --git a/app/wiki/page.tsx b/app/wiki/page.tsx
new file mode 100644
index 0000000..2d23e22
--- /dev/null
+++ b/app/wiki/page.tsx
@@ -0,0 +1,6 @@
+import ArchivePage from '@/components/ArchivePage';
+import { wikiArchive } from '@/lib/archive';
+
+export default function WikiPage() {
+ return ;
+}
diff --git a/components/ArchivePage.tsx b/components/ArchivePage.tsx
new file mode 100644
index 0000000..2dc0d72
--- /dev/null
+++ b/components/ArchivePage.tsx
@@ -0,0 +1,79 @@
+import { ExternalLinkIcon } from 'lucide-react';
+import Image from 'next/image';
+import Link from 'next/link';
+
+import { anchorForArchiveYear, type ArchiveDefinition } from '@/lib/archive';
+
+export default function ArchivePage({
+ archive,
+}: {
+ archive: ArchiveDefinition;
+}) {
+ return (
+ <>
+
+
+
+
+
+ {archive.name}
+
+
+
+
+
+
+
+ {archive.description}
+
+
+
+ {archive.years.map((year) => (
+
+
+ {year.year}
+
+ {year.editions ? (
+
+ {year.editions.map((edition) => (
+
+
+ {edition.label}
+
+ (opens in a new tab)
+
+
+ ))}
+
+ ) : (
+
+ FORTHCOMING
+
+ )}
+
+ ))}
+
+
+
+ >
+ );
+}
diff --git a/components/Header.tsx b/components/Header.tsx
index f0a284b..b4858b8 100644
--- a/components/Header.tsx
+++ b/components/Header.tsx
@@ -29,6 +29,7 @@ import {
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet';
+import { newsletterArchive, wikiArchive } from '@/lib/archive';
import { useAuth } from '@/lib/hooks/useAuth';
import CustomDropdown from './custom-dropdown';
@@ -60,11 +61,15 @@ export default function Header() {
-
-
+
@@ -140,14 +145,25 @@ export default function Header() {
setIsSidebarOpen(false)}
>
NEWSLETTER
+
+
+ setIsSidebarOpen(false)}
+ >
+ WIKI
+
+
+
+
{/* Admin menu for logged in users */}
{isAuthenticated && (
@@ -236,12 +252,15 @@ export default function Header() {
className={navigationMenuTriggerStyle()}
asChild
>
-
- NEWSLETTER
-
+ NEWSLETTER
+
+
+
+
+ WIKI
diff --git a/components/custom-dropdown.tsx b/components/custom-dropdown.tsx
index e094767..ede15bd 100644
--- a/components/custom-dropdown.tsx
+++ b/components/custom-dropdown.tsx
@@ -2,7 +2,7 @@
import { ChevronDownIcon } from 'lucide-react';
import Link from 'next/link';
-import { useEffect, useRef, useState } from 'react';
+import { useEffect, useId, useRef, useState } from 'react';
interface DropdownItem {
label: string;
@@ -12,20 +12,41 @@ interface DropdownItem {
interface CustomDropdownProps {
label: string;
items: DropdownItem[];
+ href?: string;
}
-const CustomDropdown: React.FC = ({ label, items }) => {
+const CustomDropdown: React.FC = ({
+ label,
+ items,
+ href,
+}) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef(null);
+ const menuRef = useRef(null);
+ const toggleRef = useRef(null);
const timeoutRef = useRef(null);
+ // why: hover/focus visibility is transient, while a click pins the menu open.
+ const isClickOpenRef = useRef(false);
+ const menuId = useId();
- const handleMouseLeave = () => {
+ const handlePointerLeave = (event: React.PointerEvent) => {
+ // why: touch and pen pointers should use the stable click interaction.
+ if (event.pointerType !== 'mouse') return;
+
+ // why: a short grace period lets the pointer cross the visual gap to the
+ // menu without closing it mid-movement and causing a flicker.
timeoutRef.current = setTimeout(() => {
- setIsOpen(false);
+ if (
+ !isClickOpenRef.current &&
+ !dropdownRef.current?.contains(document.activeElement)
+ )
+ setIsOpen(false);
}, 100);
};
- const handleMouseEnter = () => {
+ const handlePointerEnter = (event: React.PointerEvent) => {
+ if (event.pointerType !== 'mouse') return;
+
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
@@ -33,11 +54,74 @@ const CustomDropdown: React.FC = ({ label, items }) => {
};
const handleClick = () => {
- setIsOpen(!isOpen);
+ isClickOpenRef.current = !isClickOpenRef.current;
+ setIsOpen(isClickOpenRef.current);
+ };
+
+ const handleFocus = () => {
+ // why: keyboard users who Tab into the control need the same menu reveal
+ // that mouse users get from hovering.
+ setIsOpen(true);
+ };
+
+ const handleBlur = () => {
+ // why: blur fires before focus settles on the next element, so checking
+ // immediately would close the menu while the user Tabs into one of its links.
+ window.setTimeout(() => {
+ if (!dropdownRef.current?.contains(document.activeElement)) {
+ isClickOpenRef.current = false;
+ setIsOpen(false);
+ }
+ }, 0);
+ };
+
+ const handleTriggerKeyDown = (event: React.KeyboardEvent) => {
+ // why: ArrowDown is the standard shortcut for entering an attached menu;
+ // handling keydown lets us prevent page scrolling before it occurs.
+ if (event.key !== 'ArrowDown') return;
+
+ event.preventDefault();
+ setIsOpen(true);
+ // why: let React commit the open state before moving keyboard focus into the menu.
+ window.requestAnimationFrame(() => {
+ menuRef.current?.querySelector('a')?.focus();
+ });
+ };
+
+ const handleMenuKeyDown = (event: React.KeyboardEvent) => {
+ // why: menu-style arrow navigation moves focus between choices without
+ // making keyboard users Tab through the rest of the page.
+ const items = Array.from(
+ menuRef.current?.querySelectorAll('a') ?? [],
+ );
+ const currentIndex = items.indexOf(
+ document.activeElement as HTMLAnchorElement,
+ );
+
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
+ event.preventDefault();
+ const direction = event.key === 'ArrowDown' ? 1 : -1;
+ items
+ .at((currentIndex + direction + items.length) % items.length)
+ ?.focus();
+ } else if (event.key === 'Home' || event.key === 'End') {
+ event.preventDefault();
+ items.at(event.key === 'Home' ? 0 : -1)?.focus();
+ }
};
useEffect(() => {
+ const closeOnOutsidePointerDown = (event: PointerEvent) => {
+ if (!dropdownRef.current?.contains(event.target as Node)) {
+ isClickOpenRef.current = false;
+ setIsOpen(false);
+ }
+ };
+
+ document.addEventListener('pointerdown', closeOnOutsidePointerDown);
+
return () => {
+ document.removeEventListener('pointerdown', closeOnOutsidePointerDown);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
@@ -48,40 +132,118 @@ const CustomDropdown: React.FC = ({ label, items }) => {
{
+ if (event.key === 'Escape') {
+ isClickOpenRef.current = false;
+ toggleRef.current?.focus();
+ setIsOpen(false);
+ }
+ }}
>
-
- {label}
-
-
+ {/* why: both trigger variants must expose the controlled menu and its open state to screen readers. */}
+ {href ? (
+
+ {
+ isClickOpenRef.current = false;
+ setIsOpen(false);
+ }}
+ >
+ {label}
+
+ {/* why: screen readers need to distinguish this menu toggle from the adjacent page link. */}
+
+
+
+
+ ) : (
+
+ {label}
+
+
+ )}
+ {/* why: bridge the visual gap so moving a mouse into the menu does not close it. */}
{isOpen && }
+ {/* why: keep the menu mounted for its close animation, but make closed links inert. */}
+ {/* why: assistive technology also needs an explicit signal while those mounted contents are unavailable. */}
e.stopPropagation()}
>
-
+ {/* why: menu semantics announce that arrow keys move between these choices. */}
+