123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363 |
- import Link from "next/link"
- import { FormattedMessage, useIntl } from "react-intl"
- import mastodonLogo from "../public/logos/wordmark-white-text.svg"
- import Image from "next/legacy/image"
- import { useState, useEffect, useRef, useId } from "react"
- import classNames from "classnames"
- import { locales } from "../data/locales"
- import MenuToggle from "./MenuToggle"
- import DisclosureArrow from "../public/ui/disclosure-arrow.svg?inline"
- import { useRouter } from "next/router"
- type HeaderProps = {
- /** determines whether the header is transparent on load, before scrolling down */
- transparent?: boolean
- }
- /** Sitewide header and navigation */
- const Header = ({ transparent = true }: HeaderProps) => {
- const intl = useIntl()
- const router = useRouter()
- const [pageScrolled, setPageScrolled] = useState(false)
- // prettier-ignore
- const navigationItems = [
- {
- value: "/servers",
- label: <FormattedMessage id="nav.servers.title" defaultMessage="Servers" />,
- }, {
- value: "/apps",
- label: <FormattedMessage id="nav.apps.title" defaultMessage="Apps" />,
- }, {
- value: "https://freshstore.co/collections/mastodon",
- label: <div className="flex gap-2 items-center"><FormattedMessage id="nav.merch.title" defaultMessage="Merch" /><span className="bg-goldenrod/[0.5] text-goldenrod c2 px-1.5 rounded border border-goldenrod"><FormattedMessage id='nav.new' defaultMessage='New' /></span></div>,
- }, {
- value: "/sponsors",
- label: <FormattedMessage id="nav.sponsors.title" defaultMessage="Donate" />,
- }, {
- key: "resources",
- label: <FormattedMessage id="nav.resources.title" defaultMessage="Resources" />,
- childItems: [
- {
- value: "https://blog.joinmastodon.org/",
- label: <FormattedMessage id="nav.blog.title" defaultMessage="Blog" />,
- description: <FormattedMessage id="nav.blog.description" defaultMessage="Get the latest news about the platform" />,
- }, {
- value: "https://docs.joinmastodon.org",
- label: <FormattedMessage id="nav.docs.title" defaultMessage="Documentation" />,
- description: <FormattedMessage id="nav.docs.description" defaultMessage="Learn how Mastodon works in-depth" />,
- }, {
- value: "https://github.com/mastodon/mastodon/discussions",
- label: <FormattedMessage id="nav.support.title" defaultMessage="Support" />,
- description: <FormattedMessage id="nav.support.description" defaultMessage="Get help or suggest a feature on GitHub" />,
- }, {
- value: "/branding",
- label: <FormattedMessage id="nav.branding.title" defaultMessage="Branding" />,
- description: <FormattedMessage id="nav.branding.description" defaultMessage="Download our logos and learn how to use them" />,
- },
- ],
- footer: {
- value: "https://github.com/mastodon/mastodon",
- label: <FormattedMessage id="nav.code.action" defaultMessage="Browse code" />,
- title: <FormattedMessage id="nav.code.title" defaultMessage="Source code" />,
- description: <FormattedMessage id="nav.code.description" defaultMessage="Mastodon is free and open-source software" />,
- },
- }, {
- value: "/verification",
- label: <FormattedMessage id="nav.verification.title" defaultMessage="Verification" />,
- }, {
- key: "locale",
- label: <span aria-label={intl.formatMessage({
- id: "translate_site",
- defaultMessage: "文A, Translate site",
- })}>文A</span>,
- compact: true,
- childItems: locales.map((locale) => ({
- key: locale.code,
- locale: locale.code,
- scroll: false,
- small: true,
- value: "", // current page
- label: locale.language,
- active: router.locale === locale.code,
- })),
- }
- ]
- // set active status on links
- .map((item) => ({ ...item, active: router.asPath === item.value }))
- const {
- mobileMenuOpen,
- openMenuIndex,
- bindToggle,
- bindPrimaryMenu,
- bindPrimaryMenuItem,
- bindSecondaryMenuItem,
- } = useMenu({ navigationItems })
- const checkPageScroll = () => {
- setPageScrolled(window.scrollY > 0)
- }
- useEffect(() => {
- window.addEventListener("scroll", checkPageScroll)
- checkPageScroll()
- return () => {
- window.removeEventListener("scroll", checkPageScroll)
- }
- }, [])
- return (
- <header
- // background needs to be on the ::before for now to get around nested compositing bug in chrome
- className={classNames(
- 'full-width-bg sticky -top-[var(--header-offset)] z-20 -mb-[var(--header-area)] pt-[var(--header-offset)] text-white before:absolute before:inset-0 before:bg-nightshade-900/[0.9] before:backdrop-blur before:transition-opacity before:content-[""]',
- pageScrolled || !transparent ? "before:opacity-100" : "before:opacity-0"
- )}
- >
- <div className="full-width-bg__inner flex h-[var(--header-height)] items-center justify-between">
- <div>
- <Link
- href="/"
- className="relative z-10 flex max-w-[11.375rem] pt-[6%] md:max-w-[12.625rem]"
- >
- <Image src={mastodonLogo} alt="Mastodon" />
- </Link>
- </div>
- <nav>
- <MenuToggle {...bindToggle()} />
- <ul
- {...bindPrimaryMenu()}
- className={classNames(
- "md:ms-0 md:-me-1 fixed inset-0 w-screen flex-col overflow-auto bg-black px-1 pt-[calc(var(--header-area)_+_1rem)] pb-8 md:relative md:w-auto md:flex-row md:gap-1 md:overflow-visible md:rounded-md md:bg-[transparent] md:p-1",
- mobileMenuOpen ? "flex" : "hidden md:flex"
- )}
- >
- {navigationItems.map((item, itemIndex) => (
- <li className="relative" key={item.key || item.value}>
- {"childItems" in item ? (
- // Top-level Dropdown
- <>
- <button
- {...bindPrimaryMenuItem(itemIndex, { hasPopup: true })}
- className="flex items-center gap-[0.125rem] whitespace-nowrap rounded-md p-3 px-5 text-h5 focus:outline-2 md:text-b2 md:font-medium"
- >
- {item.label}
- <DisclosureArrow
- className={classNames({
- "rotate-180": openMenuIndex === itemIndex,
- })}
- />
- </button>
- <div
- className={classNames(
- "end-0 top-full rounded-md md:absolute md:max-h-[calc(100vh_-_var(--header-height))] md:bg-white md:text-black md:shadow-lg",
- openMenuIndex === itemIndex ? "overflow-auto" : "hidden"
- )}
- >
- <ul
- role="menu"
- className={classNames(
- item.compact
- ? "py-2 md:px-2"
- : "w-screen max-w-md py-2 md:grid md:max-w-lg md:grid-cols-2 md:gap-6 md:px-3 md:py-4"
- )}
- >
- {item.childItems.map((child, childIndex) => (
- // Child Items
- <li key={child.key || child.value} role="menu">
- <Link
- href={child.value}
- locale={child.locale || undefined}
- scroll={child.scroll ?? true}
- {...bindSecondaryMenuItem(child)}
- className={classNames(
- "block rounded-md hover:md:bg-nightshade-50",
- item.compact
- ? "py-2 px-5 md:px-4"
- : "py-3 px-5 md:px-4",
- item.compact && child.active && "font-extrabold"
- )}
- aria-current={child.active ? "page" : undefined}
- >
- <span
- className={classNames(
- "block",
- !item.compact && "font-extrabold"
- )}
- >
- {child.label}
- </span>
- <span className="mt-1 block font-extranormal text-gray-1">
- {child.description}
- </span>
- </Link>
- </li>
- ))}
- </ul>
- {item.footer && (
- <div className="md:bg-gray-4 md:p-4">
- <a
- href={item.footer.value}
- className="group flex items-center justify-between rounded-md px-5 py-3 md:p-2"
- >
- <span>
- <span className="font-extrabold">
- {item.footer.title}
- </span>
- <span className="mt-1 block font-extranormal text-gray-1">
- {item.footer.description}
- </span>
- </span>
- <span className="b3 hidden h-12 items-center justify-center rounded-md border-2 border-blurple-500 bg-blurple-500 p-4 !font-semibold text-white transition-colors group-hover:border-blurple-600 group-hover:bg-blurple-600 md:flex">
- {item.footer.label}
- </span>
- </a>
- </div>
- )}
- </div>
- </>
- ) : (
- // Top-level Link
- <Link
- href={item.value}
- className={classNames(
- "block whitespace-nowrap rounded-md p-3 px-5 text-h5 font-medium md:text-b2",
- item.active && "font-extrabold"
- )}
- aria-current={item.active ? "page" : undefined}
- {...bindPrimaryMenuItem(itemIndex)}
- >
- {item.label}
- </Link>
- )}
- </li>
- ))}
- </ul>
- </nav>
- </div>
- </header>
- )
- }
- /**
- * `useMenu` provides a React Hook for managing menu state and attributes for accessibility.
- */
- const useMenu = ({ navigationItems }) => {
- const menuId = useId()
- const rootElement = useRef<HTMLUListElement>(null)
- const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
- /** `null` means the secondary menu is closed */
- const [openMenuIndex, setOpenMenuIndex] = useState<number | null>(null)
- const secondaryMenuOpen = openMenuIndex !== null
- // check for clicks outside of the menu
- useEffect(() => {
- const handleClickOutside = (e: MouseEvent) => {
- if (!rootElement.current.contains(e.target as Node)) {
- setOpenMenuIndex(null)
- }
- }
- if (rootElement.current) {
- document.addEventListener("click", handleClickOutside, false)
- }
- return () => {
- document.removeEventListener("click", handleClickOutside, false)
- }
- }, [])
- // Element attributes / listeners
- const bindToggle = () => ({
- open: mobileMenuOpen,
- attributes: {
- "aria-expanded": mobileMenuOpen,
- "aria-controls": menuId,
- },
- onClick: () => setMobileMenuOpen(!mobileMenuOpen),
- })
- const bindPrimaryMenu = () => {
- return {
- ref: rootElement,
- id: menuId,
- onBlur: (e) => {
- const focusLeftMenu = !rootElement.current.contains(e.relatedTarget)
- /*if (focusLeftMenu) {
- setOpenMenuIndex(null)
- setMobileMenuOpen(false)
- }*/
- },
- onKeyDown: (e) => {
- if (e.key === "Escape") {
- if (openMenuIndex) {
- setOpenMenuIndex(null)
- } else {
- setMobileMenuOpen(false)
- }
- }
- },
- }
- }
- const bindPrimaryMenuItem = (
- itemIndex: number,
- { hasPopup } = { hasPopup: false }
- ) => {
- const isDropdownOpen = openMenuIndex === itemIndex
- const isExpanded = hasPopup && isDropdownOpen
- return {
- "aria-haspopup": hasPopup,
- "aria-expanded": hasPopup ? isExpanded : undefined,
- onKeyDown: (e: React.KeyboardEvent) => {
- if (e.key === "Enter" || e.key === " ") {
- if (hasPopup) {
- e.preventDefault()
- }
- setOpenMenuIndex(itemIndex)
- }
- },
- onClick: () => {
- if (!hasPopup) {
- setMobileMenuOpen(false)
- }
- },
- onMouseDown: () => {
- if (hasPopup) {
- setOpenMenuIndex(isDropdownOpen ? null : itemIndex)
- } else {
- setOpenMenuIndex(null)
- }
- },
- }
- }
- const bindSecondaryMenuItem = (child) => {
- return {
- onKeyDown: (e) => {
- if (e.key === "Escape") {
- setOpenMenuIndex(null)
- }
- },
- onClick: () => {
- setMobileMenuOpen(false)
- },
- hrefLang: child.locale || undefined,
- lang: child.locale || undefined,
- role: "menuitem",
- }
- }
- return {
- mobileMenuOpen,
- openMenuIndex,
- bindToggle,
- bindPrimaryMenu,
- bindPrimaryMenuItem,
- bindSecondaryMenuItem,
- secondaryMenuOpen,
- }
- }
- export default Header
|