diff --git a/src/components/LibraryLayout.tsx b/src/components/LibraryLayout.tsx index 55a28c64a..afae1e410 100644 --- a/src/components/LibraryLayout.tsx +++ b/src/components/LibraryLayout.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import { ChevronLeft, ChevronRight, Menu } from 'lucide-react' +import { ChevronLeft, ChevronRight, Menu, X } from 'lucide-react' import { GithubIcon } from '~/components/icons/GithubIcon' import { DiscordIcon } from '~/components/icons/DiscordIcon' import { YouTubeIcon } from '~/components/icons/YouTubeIcon' @@ -8,6 +8,7 @@ import { useLocalStorage } from '~/utils/useLocalStorage' import { useClickOutside } from '~/hooks/useClickOutside' import { last } from '~/utils/utils' import type { ConfigSchema, MenuItem } from '~/utils/config' +import { getActiveDocsNavTabId, getTabbedMenuConfig } from '~/utils/docsNavTabs' import { Framework, LibraryId } from '~/libraries' import { frameworkOptions } from '~/libraries/frameworks' import { twMerge } from 'tailwind-merge' @@ -123,15 +124,19 @@ function MobilePartnersStrip({ const inner = innerRef.current if (!inner) return + const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)') + // The strip is mobile-only (md:hidden), so don't run the rAF loop on md+ + // where it isn't painted. + const isDesktop = window.matchMedia('(min-width: 768px)') + let animationId: number let timeoutId: ReturnType - const scrollSpeed = 0.15 // pixels per frame - const startDelay = 4000 // wait 4 seconds before starting (first time only) + const scrollSpeed = 0.15 + const startDelay = 4000 const animate = () => { if (!isHovered && inner) { scrollPositionRef.current += scrollSpeed - // Reset when we've scrolled past the first set if (scrollPositionRef.current >= inner.scrollWidth / 2) { scrollPositionRef.current = 0 } @@ -140,19 +145,37 @@ function MobilePartnersStrip({ animationId = requestAnimationFrame(animate) } - if (!hasStartedRef.current) { - timeoutId = setTimeout(() => { - hasStartedRef.current = true + const start = () => { + if (reduceMotion.matches || isDesktop.matches) return + if (!hasStartedRef.current) { + timeoutId = setTimeout(() => { + hasStartedRef.current = true + animationId = requestAnimationFrame(animate) + }, startDelay) + } else { animationId = requestAnimationFrame(animate) - }, startDelay) - } else { - animationId = requestAnimationFrame(animate) + } } - return () => { + const stop = () => { clearTimeout(timeoutId) cancelAnimationFrame(animationId) } + + const restart = () => { + stop() + start() + } + + isDesktop.addEventListener('change', restart) + reduceMotion.addEventListener('change', restart) + start() + + return () => { + isDesktop.removeEventListener('change', restart) + reduceMotion.removeEventListener('change', restart) + stop() + } }, [isHovered]) return ( @@ -722,6 +745,7 @@ const useMenuConfig = ({ return { label: section.label, + tab: section.tab, children, collapsible: section.collapsible ?? false, defaultCollapsed: section.defaultCollapsed ?? false, @@ -769,24 +793,132 @@ export function LibraryLayout({ const isNpmStats = matches.some((d) => d.pathname.includes('/docs/npm-stats')) - const detailsRef = React.useRef(null!) + const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false) + const mobileMenuDialogRef = React.useRef(null) + const closeMobileMenu = React.useCallback(() => { + setMobileMenuOpen(false) + }, []) + + const docsMatch = matches.find((d) => d.pathname.includes('/docs')) + const docsPathname = docsMatch?.pathname ?? '' + + const relativePathname = lastMatch.pathname.replace(docsPathname + '/', '') + + React.useEffect(() => { + closeMobileMenu() + }, [closeMobileMenu, lastMatch.pathname]) + + React.useEffect(() => { + if (!mobileMenuOpen) return + + const dialog = mobileMenuDialogRef.current + if (!dialog) return + + const previouslyFocused = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null + const focusableSelector = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', + ].join(',') + const getFocusableElements = () => + Array.from( + dialog.querySelectorAll(focusableSelector), + ).filter( + (element) => + element.getAttribute('aria-hidden') !== 'true' && + element.getClientRects().length > 0, + ) + + const focusFirstElement = () => { + const [firstElement] = getFocusableElements() + const target = firstElement ?? dialog + target.focus({ preventScroll: true }) + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + closeMobileMenu() + return + } + + if (event.key !== 'Tab') return + + const focusableElements = getFocusableElements() + if (!focusableElements.length) { + event.preventDefault() + dialog.focus({ preventScroll: true }) + return + } + + const firstElement = focusableElements[0] + const lastElement = focusableElements[focusableElements.length - 1] + const activeElement = document.activeElement + + if (!dialog.contains(activeElement)) { + event.preventDefault() + firstElement.focus({ preventScroll: true }) + return + } + + if (event.shiftKey && activeElement === firstElement) { + event.preventDefault() + lastElement.focus({ preventScroll: true }) + return + } + + if (!event.shiftKey && activeElement === lastElement) { + event.preventDefault() + firstElement.focus({ preventScroll: true }) + } + } + + const focusTimer = window.setTimeout(focusFirstElement, 0) + window.addEventListener('keydown', onKeyDown) + + return () => { + window.clearTimeout(focusTimer) + window.removeEventListener('keydown', onKeyDown) + previouslyFocused?.focus({ preventScroll: true }) + } + }, [closeMobileMenu, mobileMenuOpen]) + + const tabbedMenuConfig = React.useMemo(() => { + return getTabbedMenuConfig(menuConfig) + }, [menuConfig]) + + const activeTabId = React.useMemo(() => { + return getActiveDocsNavTabId({ + isExample, + menuConfig, + pathname: lastMatch.pathname, + relativePathname, + }) + }, [isExample, lastMatch.pathname, menuConfig, relativePathname]) + + const visibleMenuConfig = React.useMemo(() => { + return ( + tabbedMenuConfig.find((tab) => tab.id === activeTabId)?.groups ?? + menuConfig + ) + }, [activeTabId, menuConfig, tabbedMenuConfig]) const flatMenu = React.useMemo( - () => menuConfig.flatMap((d) => d?.children), - [menuConfig], + () => visibleMenuConfig.flatMap((d) => d.children), + [visibleMenuConfig], ) // Filter out external links for prev/next navigation const internalFlatMenu = React.useMemo( - () => flatMenu.filter((d) => d && !d.to.startsWith('http')), + () => flatMenu.filter((d) => !d.to.startsWith('http')), [flatMenu], ) - const docsMatch = matches.find((d) => d.pathname.includes('/docs')) - const docsPathname = docsMatch?.pathname ?? '' - - const relativePathname = lastMatch.pathname.replace(docsPathname + '/', '') - const index = internalFlatMenu.findIndex((d) => d?.to === relativePathname) const prevItem = internalFlatMenu[index - 1] const nextItem = internalFlatMenu[index + 1] @@ -810,19 +942,24 @@ export function LibraryLayout({ ) const groupInitialOpenState = React.useMemo(() => { - return menuConfig.reduce>((acc, group, index) => { - const isChildActive = group.children.some((child) => child.to === _splat) - const key = `${index}:${String(group.label)}` + return visibleMenuConfig.reduce>( + (acc, group, index) => { + const isChildActive = group.children.some( + (child) => child.to === _splat, + ) + const key = `${index}:${String(group.label)}` - acc[key] = isChildActive - ? true - : typeof group.defaultCollapsed !== 'undefined' - ? !group.defaultCollapsed - : false + acc[key] = isChildActive + ? true + : typeof group.defaultCollapsed !== 'undefined' + ? !group.defaultCollapsed + : false - return acc - }, {}) - }, [menuConfig, _splat]) + return acc + }, + {}, + ) + }, [visibleMenuConfig, _splat]) const [openGroups, setOpenGroups] = React.useState(groupInitialOpenState) @@ -850,7 +987,7 @@ export function LibraryLayout({ const libraryHomePath = `/${libraryId}/${version}` - const menuItems = menuConfig.map((group, i) => { + const menuItems = visibleMenuConfig.map((group, i) => { const groupKey = `${i}:${String(group.label)}` const groupContent = ( @@ -923,7 +1060,7 @@ export function LibraryLayout({ { - detailsRef.current.removeAttribute('open') + closeMobileMenu() }} className="relative" > @@ -955,7 +1092,7 @@ export function LibraryLayout({ _splat: frameworkDocsTarget.splat, }} onClick={() => { - detailsRef.current.removeAttribute('open') + closeMobileMenu() }} preload="intent" activeOptions={{ @@ -977,7 +1114,7 @@ export function LibraryLayout({ _splat: frameworkDocsTarget.splat, }} onClick={() => { - detailsRef.current.removeAttribute('open') + closeMobileMenu() }} preload="intent" activeOptions={{ @@ -995,7 +1132,7 @@ export function LibraryLayout({ to={child.to} params={linkParams} onClick={() => { - detailsRef.current.removeAttribute('open') + closeMobileMenu() }} preload="intent" activeOptions={{ @@ -1034,33 +1171,39 @@ export function LibraryLayout({ }) const smallMenu = ( -
-
+ {mobileMenuOpen ? ( + +
+
@@ -1068,7 +1211,7 @@ export function LibraryLayout({ {menuItems}
- +
) @@ -1098,7 +1241,7 @@ export function LibraryLayout({ )} >