Header.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. import Link from "next/link"
  2. import { FormattedMessage, useIntl } from "react-intl"
  3. import mastodonLogo from "../public/logos/wordmark-white-text.svg"
  4. import Image from "next/legacy/image"
  5. import { useState, useEffect, useRef, useId } from "react"
  6. import classNames from "classnames"
  7. import { locales } from "../data/locales"
  8. import MenuToggle from "./MenuToggle"
  9. import DisclosureArrow from "../public/ui/disclosure-arrow.svg?inline"
  10. import { useRouter } from "next/router"
  11. type HeaderProps = {
  12. /** determines whether the header is transparent on load, before scrolling down */
  13. transparent?: boolean
  14. }
  15. /** Sitewide header and navigation */
  16. const Header = ({ transparent = true }: HeaderProps) => {
  17. const intl = useIntl()
  18. const router = useRouter()
  19. const [pageScrolled, setPageScrolled] = useState(false)
  20. // prettier-ignore
  21. const navigationItems = [
  22. {
  23. value: "/servers",
  24. label: <FormattedMessage id="nav.servers.title" defaultMessage="Servers" />,
  25. }, {
  26. value: "/apps",
  27. label: <FormattedMessage id="nav.apps.title" defaultMessage="Apps" />,
  28. },
  29. {
  30. value: "https://shop.joinmastodon.org/",
  31. 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>,
  32. },
  33. {
  34. value: "/sponsors",
  35. label: <FormattedMessage id="nav.sponsors.title" defaultMessage="Donate" />,
  36. }, {
  37. key: "resources",
  38. label: <FormattedMessage id="nav.resources.title" defaultMessage="Resources" />,
  39. childItems: [
  40. {
  41. value: "/about",
  42. label: <FormattedMessage id="nav.about_us.title" defaultMessage="About us" />,
  43. description: <FormattedMessage id="nav.about_us.description" defaultMessage="Read about our story, the team, and recent media coverage" />
  44. }, {
  45. value: "/about#contact",
  46. label: <FormattedMessage id="nav.contact.title" defaultMessage="Contact" />,
  47. description: <FormattedMessage id="nav.contact.description" defaultMessage="Get in touch for press, business, legal and other inquiries" />
  48. },
  49. {
  50. value: "https://blog.joinmastodon.org/",
  51. label: <FormattedMessage id="nav.blog.title" defaultMessage="Blog" />,
  52. description: <FormattedMessage id="nav.blog.description" defaultMessage="Get the latest news about the platform" />,
  53. }, {
  54. value: "https://docs.joinmastodon.org",
  55. label: <FormattedMessage id="nav.docs.title" defaultMessage="Documentation" />,
  56. description: <FormattedMessage id="nav.docs.description" defaultMessage="Learn how Mastodon works in-depth" />,
  57. }, {
  58. value: "https://github.com/mastodon/mastodon/discussions",
  59. label: <FormattedMessage id="nav.support.title" defaultMessage="Support" />,
  60. description: <FormattedMessage id="nav.support.description" defaultMessage="Get help or suggest a feature on GitHub" />,
  61. }, {
  62. value: "/verification",
  63. label: <FormattedMessage id="nav.verification.title" defaultMessage="Verification" />,
  64. description: <FormattedMessage id="nav.verification.description" defaultMessage="Learn about verified profile links on Mastodon" />
  65. }, {
  66. value: "/branding",
  67. label: <FormattedMessage id="nav.branding.title" defaultMessage="Branding" />,
  68. description: <FormattedMessage id="nav.branding.description" defaultMessage="Download our logos and learn how to use them" />,
  69. },
  70. ],
  71. footer: {
  72. value: "https://github.com/mastodon/mastodon",
  73. label: <FormattedMessage id="nav.code.action" defaultMessage="Browse code" />,
  74. title: <FormattedMessage id="nav.code.title" defaultMessage="Source code" />,
  75. description: <FormattedMessage id="nav.code.description" defaultMessage="Mastodon is free and open-source software" />,
  76. },
  77. }, {
  78. key: "locale",
  79. label: <span aria-label={intl.formatMessage({
  80. id: "translate_site",
  81. defaultMessage: "文A, Translate site",
  82. })}>文A</span>,
  83. compact: true,
  84. childItems: locales.map((locale) => ({
  85. key: locale.code,
  86. locale: locale.code,
  87. scroll: false,
  88. small: true,
  89. value: "", // current page
  90. label: locale.language,
  91. active: router.locale === locale.code,
  92. })),
  93. }
  94. ]
  95. // set active status on links
  96. .map((item) => ({ ...item, active: router.asPath === item.value }))
  97. const {
  98. mobileMenuOpen,
  99. openMenuIndex,
  100. bindToggle,
  101. bindPrimaryMenu,
  102. bindPrimaryMenuItem,
  103. bindSecondaryMenuItem,
  104. } = useMenu({ navigationItems })
  105. const checkPageScroll = () => {
  106. setPageScrolled(window.scrollY > 0)
  107. }
  108. useEffect(() => {
  109. window.addEventListener("scroll", checkPageScroll)
  110. checkPageScroll()
  111. return () => {
  112. window.removeEventListener("scroll", checkPageScroll)
  113. }
  114. }, [])
  115. return (
  116. <header
  117. // background needs to be on the ::before for now to get around nested compositing bug in chrome
  118. className={classNames(
  119. '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-[""]',
  120. pageScrolled || !transparent ? "before:opacity-100" : "before:opacity-0"
  121. )}
  122. >
  123. <div className="full-width-bg__inner flex h-[var(--header-height)] items-center justify-between">
  124. <div>
  125. <Link
  126. href="/"
  127. className="relative z-10 flex max-w-[11.375rem] pt-[6%] md:max-w-[12.625rem]"
  128. >
  129. <Image src={mastodonLogo} alt="Mastodon" />
  130. </Link>
  131. </div>
  132. <nav>
  133. <MenuToggle {...bindToggle()} />
  134. <ul
  135. {...bindPrimaryMenu()}
  136. className={classNames(
  137. "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",
  138. mobileMenuOpen ? "flex" : "hidden md:flex"
  139. )}
  140. >
  141. {navigationItems.map((item, itemIndex) => (
  142. <li className="relative" key={item.key || item.value}>
  143. {"childItems" in item ? (
  144. // Top-level Dropdown
  145. <>
  146. <button
  147. {...bindPrimaryMenuItem(itemIndex, { hasPopup: true })}
  148. 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"
  149. >
  150. {item.label}
  151. <DisclosureArrow
  152. className={classNames({
  153. "rotate-180": openMenuIndex === itemIndex,
  154. })}
  155. />
  156. </button>
  157. <div
  158. className={classNames(
  159. "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",
  160. openMenuIndex === itemIndex ? "overflow-auto" : "hidden"
  161. )}
  162. >
  163. <ul
  164. role="menu"
  165. className={classNames(
  166. item.compact
  167. ? "py-2 md:px-2"
  168. : "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"
  169. )}
  170. >
  171. {item.childItems.map((child, childIndex) => (
  172. // Child Items
  173. <li key={child.key || child.value} role="menu">
  174. <Link
  175. href={child.value}
  176. locale={child.locale || undefined}
  177. scroll={child.scroll ?? true}
  178. {...bindSecondaryMenuItem(child)}
  179. className={classNames(
  180. "block rounded-md hover:md:bg-nightshade-50",
  181. item.compact
  182. ? "py-2 px-5 md:px-4"
  183. : "py-3 px-5 md:px-4",
  184. item.compact && child.active && "font-extrabold"
  185. )}
  186. aria-current={child.active ? "page" : undefined}
  187. >
  188. <span
  189. className={classNames(
  190. "block",
  191. !item.compact && "font-extrabold"
  192. )}
  193. >
  194. {child.label}
  195. </span>
  196. <span className="mt-1 block font-extranormal text-gray-1">
  197. {child.description}
  198. </span>
  199. </Link>
  200. </li>
  201. ))}
  202. </ul>
  203. {item.footer && (
  204. <div className="md:bg-gray-4 md:p-4">
  205. <a
  206. href={item.footer.value}
  207. className="group flex items-center justify-between rounded-md px-5 py-3 md:p-2"
  208. >
  209. <span>
  210. <span className="font-extrabold">
  211. {item.footer.title}
  212. </span>
  213. <span className="mt-1 block font-extranormal text-gray-1">
  214. {item.footer.description}
  215. </span>
  216. </span>
  217. <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">
  218. {item.footer.label}
  219. </span>
  220. </a>
  221. </div>
  222. )}
  223. </div>
  224. </>
  225. ) : (
  226. // Top-level Link
  227. <Link
  228. href={item.value}
  229. className={classNames(
  230. "block whitespace-nowrap rounded-md p-3 px-5 text-h5 font-medium md:text-b2",
  231. item.active && "font-extrabold"
  232. )}
  233. aria-current={item.active ? "page" : undefined}
  234. {...bindPrimaryMenuItem(itemIndex)}
  235. >
  236. {item.label}
  237. </Link>
  238. )}
  239. </li>
  240. ))}
  241. </ul>
  242. </nav>
  243. </div>
  244. </header>
  245. )
  246. }
  247. /**
  248. * `useMenu` provides a React Hook for managing menu state and attributes for accessibility.
  249. */
  250. const useMenu = ({ navigationItems }) => {
  251. const menuId = useId()
  252. const rootElement = useRef<HTMLUListElement>(null)
  253. const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
  254. /** `null` means the secondary menu is closed */
  255. const [openMenuIndex, setOpenMenuIndex] = useState<number | null>(null)
  256. const secondaryMenuOpen = openMenuIndex !== null
  257. // check for clicks outside of the menu
  258. useEffect(() => {
  259. const handleClickOutside = (e: MouseEvent) => {
  260. if (!rootElement.current.contains(e.target as Node)) {
  261. setOpenMenuIndex(null)
  262. }
  263. }
  264. if (rootElement.current) {
  265. document.addEventListener("click", handleClickOutside, false)
  266. }
  267. return () => {
  268. document.removeEventListener("click", handleClickOutside, false)
  269. }
  270. }, [])
  271. // Element attributes / listeners
  272. const bindToggle = () => ({
  273. open: mobileMenuOpen,
  274. attributes: {
  275. "aria-expanded": mobileMenuOpen,
  276. "aria-controls": menuId,
  277. },
  278. onClick: () => setMobileMenuOpen(!mobileMenuOpen),
  279. })
  280. const bindPrimaryMenu = () => {
  281. return {
  282. ref: rootElement,
  283. id: menuId,
  284. onBlur: (e) => {
  285. const focusLeftMenu = !rootElement.current.contains(e.relatedTarget)
  286. /*if (focusLeftMenu) {
  287. setOpenMenuIndex(null)
  288. setMobileMenuOpen(false)
  289. }*/
  290. },
  291. onKeyDown: (e) => {
  292. if (e.key === "Escape") {
  293. if (openMenuIndex) {
  294. setOpenMenuIndex(null)
  295. } else {
  296. setMobileMenuOpen(false)
  297. }
  298. }
  299. },
  300. }
  301. }
  302. const bindPrimaryMenuItem = (
  303. itemIndex: number,
  304. { hasPopup } = { hasPopup: false }
  305. ) => {
  306. const isDropdownOpen = openMenuIndex === itemIndex
  307. const isExpanded = hasPopup && isDropdownOpen
  308. return {
  309. "aria-haspopup": hasPopup,
  310. "aria-expanded": hasPopup ? isExpanded : undefined,
  311. onKeyDown: (e: React.KeyboardEvent) => {
  312. if (e.key === "Enter" || e.key === " ") {
  313. if (hasPopup) {
  314. e.preventDefault()
  315. }
  316. setOpenMenuIndex(itemIndex)
  317. }
  318. },
  319. onClick: () => {
  320. if (!hasPopup) {
  321. setMobileMenuOpen(false)
  322. }
  323. },
  324. onMouseDown: () => {
  325. if (hasPopup) {
  326. setOpenMenuIndex(isDropdownOpen ? null : itemIndex)
  327. } else {
  328. setOpenMenuIndex(null)
  329. }
  330. },
  331. }
  332. }
  333. const bindSecondaryMenuItem = (child) => {
  334. return {
  335. onKeyDown: (e) => {
  336. if (e.key === "Escape") {
  337. setOpenMenuIndex(null)
  338. }
  339. },
  340. onClick: () => {
  341. setMobileMenuOpen(false)
  342. },
  343. hrefLang: child.locale || undefined,
  344. lang: child.locale || undefined,
  345. role: "menuitem",
  346. }
  347. }
  348. return {
  349. mobileMenuOpen,
  350. openMenuIndex,
  351. bindToggle,
  352. bindPrimaryMenu,
  353. bindPrimaryMenuItem,
  354. bindSecondaryMenuItem,
  355. secondaryMenuOpen,
  356. }
  357. }
  358. export default Header