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 ( + <> +
+ Students performing at a NUS College event +
+
+

+ {archive.name} +

+
+
+ +
+
+

+ {archive.description} +

+ +
+ {archive.years.map((year) => ( +
+

+ {year.year} +

+ {year.editions ? ( +
    + {year.editions.map((edition) => ( +
  • + + {edition.label} +
  • + ))} +
+ ) : ( +

+ 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); + } + }} > - + {/* 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. */} + +
+ ) : ( + + )} + {/* 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. */} +