Header.tsx 13 KB

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