diff --git a/.changeset/avatar-group-compact-limit.md b/.changeset/avatar-group-compact-limit.md new file mode 100644 index 0000000000000..de17e6542381b --- /dev/null +++ b/.changeset/avatar-group-compact-limit.md @@ -0,0 +1,5 @@ +--- +'@node-core/ui-components': minor +--- + +`AvatarGroup` takes a `compactLimit`, applied below the `xl` breakpoint, and the package now exports a `useMediaQuery` hook. diff --git a/.changeset/breadcrumbs-mobile-length.md b/.changeset/breadcrumbs-mobile-length.md new file mode 100644 index 0000000000000..2081f657433c9 --- /dev/null +++ b/.changeset/breadcrumbs-mobile-length.md @@ -0,0 +1,5 @@ +--- +'@node-core/ui-components': minor +--- + +`Breadcrumbs` takes a `mobileMaxLength`, truncating further below the `sm` breakpoint through CSS. diff --git a/.changeset/language-dropdown-links.md b/.changeset/language-dropdown-links.md new file mode 100644 index 0000000000000..800b75fcd05a8 --- /dev/null +++ b/.changeset/language-dropdown-links.md @@ -0,0 +1,5 @@ +--- +'@node-core/ui-components': minor +--- + +`LanguageDropDown` renders a link per language, through `href` and an `as` component that receives the `locale`, instead of calling `onChange`, which is removed. diff --git a/.changeset/navbar-stateless.md b/.changeset/navbar-stateless.md new file mode 100644 index 0000000000000..c990fb1970325 --- /dev/null +++ b/.changeset/navbar-stateless.md @@ -0,0 +1,5 @@ +--- +'@node-core/ui-components': patch +--- + +`NavBar` keeps no state, so it can be rendered from a React Server Component. diff --git a/.changeset/sidebar-mobile-disclosure.md b/.changeset/sidebar-mobile-disclosure.md new file mode 100644 index 0000000000000..1ed0f2648132a --- /dev/null +++ b/.changeset/sidebar-mobile-disclosure.md @@ -0,0 +1,5 @@ +--- +'@node-core/ui-components': minor +--- + +`Sidebar` collapses its navigation behind a native disclosure on mobile instead of duplicating every page into a `Select`. The `onSelect` prop is removed and `ref` is optional. diff --git a/apps/site/app/[locale]/error.tsx b/apps/site/app/[locale]/error.tsx index 0c4bf7f827ec8..0721185103d06 100644 --- a/apps/site/app/[locale]/error.tsx +++ b/apps/site/app/[locale]/error.tsx @@ -1,9 +1,10 @@ 'use client'; +import GlowingBackdrop from '@node-core/ui-components/Common/GlowingBackdrop'; import { useTranslations } from 'next-intl'; import Button from '#site/components/Common/Button'; -import GlowingBackdropLayout from '#site/layouts/GlowingBackdrop'; +import styles from '#site/layouts/layouts.module.css'; import type { FC } from 'react'; @@ -11,19 +12,23 @@ const ErrorPage: FC<{ error: Error }> = () => { const t = useTranslations(); return ( - - 500 +
+ -

- {t('layouts.error.internalServerError.title')} -

+
+ 500 -

- {t('layouts.error.internalServerError.description')} -

+

+ {t('layouts.error.internalServerError.title')} +

- - +

+ {t('layouts.error.internalServerError.description')} +

+ + +
+
); }; diff --git a/apps/site/components/AvatarGroup.tsx b/apps/site/components/AvatarGroup.tsx deleted file mode 100644 index 978bf7dea5abf..0000000000000 --- a/apps/site/components/AvatarGroup.tsx +++ /dev/null @@ -1,34 +0,0 @@ -'use client'; - -import AvatarGroupContainer from '@node-core/ui-components/Common/AvatarGroup'; - -import Link from '#site/components/Link'; -import { getAuthors } from '#site/util/author'; - -import type { AuthorProps } from '#site/types'; -import type { ComponentProps, FC } from 'react'; - -type AvatarGroupProps = Omit< - ComponentProps, - 'avatars' | 'as' -> & - AuthorProps; - -const AvatarGroup: FC = ({ - usernames, - names, - clickable = true, - ...props -}) => ( - -); - -export default AvatarGroup; diff --git a/apps/site/components/AvatarGroup/Avatars.tsx b/apps/site/components/AvatarGroup/Avatars.tsx new file mode 100644 index 0000000000000..dfcdff8d0fecb --- /dev/null +++ b/apps/site/components/AvatarGroup/Avatars.tsx @@ -0,0 +1,15 @@ +'use client'; + +import AvatarGroupContainer from '@node-core/ui-components/Common/AvatarGroup'; + +import Link from '#site/components/Link'; + +import type { ComponentProps, FC } from 'react'; + +type AvatarsProps = Omit, 'as'>; + +const Avatars: FC = props => ( + +); + +export default Avatars; diff --git a/apps/site/components/AvatarGroup/index.tsx b/apps/site/components/AvatarGroup/index.tsx new file mode 100644 index 0000000000000..1c7a52222fc2f --- /dev/null +++ b/apps/site/components/AvatarGroup/index.tsx @@ -0,0 +1,21 @@ +import { getAuthors } from '#site/util/author'; + +import type { AuthorProps } from '#site/types'; +import type { ComponentProps, FC } from 'react'; + +import Avatars from './Avatars'; + +type AvatarGroupProps = Omit, 'avatars'> & + AuthorProps; + +// Resolves the authors on the server, so `authors.json` stays there +const AvatarGroup: FC = ({ + usernames, + names, + clickable = true, + ...props +}) => ( + +); + +export default AvatarGroup; diff --git a/apps/site/components/Breadcrumbs.tsx b/apps/site/components/Breadcrumbs.tsx index a4641fd69b713..05a04cb428095 100644 --- a/apps/site/components/Breadcrumbs.tsx +++ b/apps/site/components/Breadcrumbs.tsx @@ -1,15 +1,13 @@ -'use client'; - import BreadcrumbsContainer from '@node-core/ui-components/Common/Breadcrumbs'; -import { useTranslations } from 'next-intl'; +import { getTranslations } from 'next-intl/server'; +import { getClientContext } from '#site/client-context'; import Link from '#site/components/Link'; -import useClientContext from '#site/hooks/useClientContext'; -import useMediaQuery from '#site/hooks/useMediaQuery'; -import useSiteNavigation from '#site/hooks/useSiteNavigation'; +import { getSideNavigation } from '#site/util/navigation'; import { dashToCamelCase } from '#site/util/string'; import type { NavigationKeys } from '#site/types'; +import type { NavigationItem } from '#site/util/navigation'; import type { BreadcrumbLink } from '@node-core/ui-components/Common/Breadcrumbs'; import type { FC } from 'react'; @@ -17,56 +15,54 @@ type BreadcrumbsProps = { navKeys?: Array; }; -const Breadcrumbs: FC = ({ navKeys = [] }) => { - const { getSideNavigation } = useSiteNavigation(); - const t = useTranslations(); - const { pathname } = useClientContext(); - const isMobileScreen = useMediaQuery('(max-width: 639px)'); - - const maxLength = isMobileScreen ? 2 : 4; - - const getBreadcrumbs = () => { - const navigationTree = getSideNavigation(navKeys); - - const pathList = pathname - .split('/') - .filter(item => item !== '') - .map(dashToCamelCase); - - let currentNode = navigationTree; - - // Reduce the pathList to a breadcrumbs array by finding each path in the current navigation layer, - // updating the currentNode to the found node's items(next layer) for the next iteration. - return pathList.reduce((breadcrumbs, path, index) => { - const nodeWithCurrentPath = currentNode.find( - ([nodePath, entry]) => - // Checking link in cases where nodePath cannot = path. Like 'discoverJavaScriptTimers' - (nodePath === path || entry.link === pathname) && - // Skip checking child path if it is the last path since there is no more child item inside - (index === pathList.length - 1 || - entry.items.some( - ([childPath, entry]) => - childPath === pathList[index + 1] || entry.link === pathname - )) - ); - - if (nodeWithCurrentPath) { - const [, { label, link = '', items = [] }] = nodeWithCurrentPath; - - // Goes deeper on the tree of items if there are any. - currentNode = items; - - return label ? [...breadcrumbs, { label, href: link }] : breadcrumbs; - } +// Reduce the path to a breadcrumbs array by finding each segment in the current navigation layer, +// descending into the found node's items (next layer) for the next segment. +const getBreadcrumbs = ( + navigationTree: Array, + pathname: string +) => { + const pathList = pathname + .split('/') + .filter(item => item !== '') + .map(dashToCamelCase); + + let currentNode = navigationTree; + + return pathList.reduce((breadcrumbs, path, index) => { + const nodeWithCurrentPath = currentNode.find( + entry => + // Checking link in cases where nodePath cannot = path. Like 'discoverJavaScriptTimers' + (entry.key === path || entry.link === pathname) && + // Skip checking child path if it is the last path since there is no more child item inside + (index === pathList.length - 1 || + entry.items.some( + child => + child.key === pathList[index + 1] || child.link === pathname + )) + ); + + if (nodeWithCurrentPath) { + const { label, link, items } = nodeWithCurrentPath; + + // Goes deeper on the tree of items if there are any. + currentNode = items; + + return label ? [...breadcrumbs, { label, href: link }] : breadcrumbs; + } + + return breadcrumbs; + }, [] as Array); +}; - return breadcrumbs; - }, [] as Array); - }; +const Breadcrumbs: FC = async ({ navKeys = [] }) => { + const t = await getTranslations(); + const { pathname } = getClientContext(); return ( diff --git a/apps/site/components/DownloadSection.tsx b/apps/site/components/DownloadSection.tsx index b6fe875fecb30..1386c1cf7d8f7 100644 --- a/apps/site/components/DownloadSection.tsx +++ b/apps/site/components/DownloadSection.tsx @@ -4,6 +4,7 @@ import { getLocale } from 'next-intl/server'; import { getClientContext } from '#site/client-context'; import WithNodeRelease from '#site/components/withNodeRelease'; import provideDownloadSnippets from '#site/next-data/providers/downloadSnippets'; +import { DetectedOSProvider } from '#site/providers/detectedOSProvider'; import { ReleaseProvider, ReleasesProvider, @@ -48,9 +49,13 @@ const DownloadSection: FC = async ({ return ( {({ release }) => ( - - {children} - + + + + {children} + + + )} ); diff --git a/apps/site/components/Downloads/DownloadButton/index.tsx b/apps/site/components/Downloads/DownloadButton/index.tsx index 8e8a2d9469e23..536a6d282772c 100644 --- a/apps/site/components/Downloads/DownloadButton/index.tsx +++ b/apps/site/components/Downloads/DownloadButton/index.tsx @@ -1,25 +1,26 @@ import { CloudArrowDownIcon } from '@heroicons/react/24/outline'; import classNames from 'classnames'; -import { getClientContext } from '#site/client-context'; import Button from '#site/components/Common/Button'; import { getNodeDownloadUrl } from '#site/util/url'; -import { getUserPlatform } from '#site/util/userAgent'; import type { NodeRelease } from '#site/types'; +import type { DownloadOptions } from '#site/util/url'; import type { FC, PropsWithChildren } from 'react'; import styles from './index.module.css'; -type DownloadButtonProps = { release: NodeRelease }; +type DownloadButtonProps = { release: NodeRelease } & Pick< + DownloadOptions, + 'os' | 'platform' +>; const DownloadButton: FC> = ({ release: { versionWithPrefix }, + os, + platform, children, }) => { - const { os, bitness, architecture } = getClientContext(); - - const platform = getUserPlatform(architecture, bitness); const downloadLink = getNodeDownloadUrl({ versionWithPrefix, os, platform }); return ( diff --git a/apps/site/components/Downloads/DownloadLink.tsx b/apps/site/components/Downloads/DownloadLink.tsx index 7ec914d6f2eed..4f8b2777ab589 100644 --- a/apps/site/components/Downloads/DownloadLink.tsx +++ b/apps/site/components/Downloads/DownloadLink.tsx @@ -1,22 +1,22 @@ -import { getClientContext } from '#site/client-context'; import LinkWithArrow from '#site/components/Common/LinkWithArrow'; import { getNodeDownloadUrl } from '#site/util/url'; -import { getUserPlatform } from '#site/util/userAgent'; import type { DownloadKind, NodeRelease } from '#site/types'; +import type { DownloadOptions } from '#site/util/url'; import type { FC, PropsWithChildren } from 'react'; -type DownloadLinkProps = { release: NodeRelease; kind?: DownloadKind }; +type DownloadLinkProps = { release: NodeRelease; kind?: DownloadKind } & Pick< + DownloadOptions, + 'os' | 'platform' +>; const DownloadLink: FC> = ({ release: { versionWithPrefix }, kind = 'installer', + os, + platform, children, }) => { - const { os, bitness, architecture } = getClientContext(); - - const platform = getUserPlatform(architecture, bitness); - const downloadLink = getNodeDownloadUrl({ versionWithPrefix, os, diff --git a/apps/site/components/Downloads/Release/DownloadLink.tsx b/apps/site/components/Downloads/Release/DownloadLink.tsx index ad915a68a7eac..e6029e77d39d1 100644 --- a/apps/site/components/Downloads/Release/DownloadLink.tsx +++ b/apps/site/components/Downloads/Release/DownloadLink.tsx @@ -14,10 +14,15 @@ const DownloadLink: FC> = ({ kind = 'installer', children, }) => { - const { release } = use(ReleaseContext); + const { release, os, platform } = use(ReleaseContext); return ( - + {children} ); diff --git a/apps/site/components/Downloads/Release/OperatingSystemDropdown.tsx b/apps/site/components/Downloads/Release/OperatingSystemDropdown.tsx index b559a7b046373..f5970e774a69b 100644 --- a/apps/site/components/Downloads/Release/OperatingSystemDropdown.tsx +++ b/apps/site/components/Downloads/Release/OperatingSystemDropdown.tsx @@ -4,7 +4,6 @@ import Select from '@node-core/ui-components/Common/Select'; import { useTranslations } from 'next-intl'; import { use, useEffect, useMemo } from 'react'; -import useClientContext from '#site/hooks/useClientContext'; import { ReleaseContext } from '#site/providers/releaseProvider'; import { nextItem, OPERATING_SYSTEMS, parseCompat } from '#site/util/download'; @@ -14,20 +13,9 @@ import type { FC } from 'react'; type OperatingSystemDropdownProps = { exclude?: Array }; const OperatingSystemDropdown: FC = () => { - const { os } = useClientContext(); const release = use(ReleaseContext); const t = useTranslations(); - useEffect(() => { - if (os !== 'LOADING') { - release.setOS(os); - } - // Reacts on Client Context change of OS - // Only this Hook is allowed to bypass the `setOS` from above - // As this Hook is what defined the initial OS state - // eslint-disable-next-line @eslint-react/exhaustive-deps - }, [os]); - // We parse the compatibility of the dropdown items const parsedOperatingSystems = useMemo( () => parseCompat(OPERATING_SYSTEMS, release), diff --git a/apps/site/components/Downloads/Release/PlatformDropdown.tsx b/apps/site/components/Downloads/Release/PlatformDropdown.tsx index 3285d32fdb21c..143b3798439e8 100644 --- a/apps/site/components/Downloads/Release/PlatformDropdown.tsx +++ b/apps/site/components/Downloads/Release/PlatformDropdown.tsx @@ -4,7 +4,7 @@ import Select from '@node-core/ui-components/Common/Select'; import { useTranslations } from 'next-intl'; import { useEffect, use, useMemo, useState } from 'react'; -import useClientContext from '#site/hooks/useClientContext'; +import { DetectedOSContext } from '#site/providers/detectedOSProvider'; import { ReleaseContext } from '#site/providers/releaseProvider'; import { PLATFORMS, nextItem, parseCompat } from '#site/util/download'; import { getUserPlatform } from '#site/util/userAgent'; @@ -13,14 +13,13 @@ import type { Platform } from '#site/types/userAgent'; import type { FC } from 'react'; const PlatformDropdown: FC = () => { - const { architecture, bitness } = useClientContext(); + const { architecture, bitness } = use(DetectedOSContext); const release = use(ReleaseContext); const t = useTranslations(); - // Compute the platform during render so it's available to both `useEffect` calls below in the same cycle - // (avoiding race conditions when architecture detection and OS detection arrive in the same batch) + // The detected platform, which the provider selects once it is known const currentPlatform = architecture && bitness ? getUserPlatform(architecture, bitness) : null; @@ -29,19 +28,6 @@ const PlatformDropdown: FC = () => { // resetting to the auto-detected value. const [userHasSelectedPlatform, setUserHasSelectedPlatform] = useState(false); - useEffect( - () => { - if (currentPlatform) { - // eslint-disable-next-line @eslint-react/set-state-in-effect - setUserHasSelectedPlatform(false); - release.setPlatform(currentPlatform); - } - }, - // Only react on the change of the Client Context Architecture and Bitness - // eslint-disable-next-line @eslint-react/exhaustive-deps - [architecture, bitness] - ); - // We parse the compatibility of the dropdown items const parsedPlatforms = useMemo( () => diff --git a/apps/site/components/Downloads/__tests__/DownloadLink.test.jsx b/apps/site/components/Downloads/__tests__/DownloadLink.test.jsx new file mode 100644 index 0000000000000..5d23925ca2b40 --- /dev/null +++ b/apps/site/components/Downloads/__tests__/DownloadLink.test.jsx @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { render, screen } from '@testing-library/react'; + +import DownloadLink from '#site/components/Downloads/DownloadLink'; + +const release = { versionWithPrefix: 'v22.0.0' }; + +describe('DownloadLink', () => { + it('links to the source tarball without an OS', () => { + render(Source); + + assert.match( + screen.getByRole('link').getAttribute('href'), + /\/v22\.0\.0\/node-v22\.0\.0\.tar\.gz$/ + ); + }); + + it('links to the installer of the given OS and platform', () => { + render( + + Installer + + ); + + assert.match( + screen.getByRole('link').getAttribute('href'), + /\/node-v22\.0\.0-arm64\.msi$/ + ); + }); +}); diff --git a/apps/site/components/MetaBar.tsx b/apps/site/components/MetaBar.tsx index 4d88cabd0ba9f..73401958b93c9 100644 --- a/apps/site/components/MetaBar.tsx +++ b/apps/site/components/MetaBar.tsx @@ -1,28 +1,28 @@ -'use client'; - import MetaBarContainer from '@node-core/ui-components/Containers/MetaBar'; import GitHubIcon from '@node-core/ui-components/Icons/Social/GitHub'; import { defaultLocale } from '@node-core/website-i18n'; -import { useFormatter, useLocale, useTranslations } from 'next-intl'; +import { getFormatter, getLocale, getTranslations } from 'next-intl/server'; +import { getClientContext } from '#site/client-context'; import AvatarGroup from '#site/components/AvatarGroup'; import Link from '#site/components/Link'; -import useClientContext from '#site/hooks/useClientContext'; -import useMediaQuery from '#site/hooks/useMediaQuery'; import { DEFAULT_DATE_FORMAT } from '#site/next.calendar.constants.mjs'; import { TRANSLATION_URL } from '#site/next.constants.mjs'; import { getGitHubBlobUrl } from '#site/util/github'; import type { FC } from 'react'; -const MetaBar: FC = () => { - const { headings, readingTime, frontmatter, filename } = useClientContext(); - const formatter = useFormatter(); - const lastUpdated = frontmatter.date - ? // "frontmatter.date" is deterministic +const MetaBar: FC = async () => { + const { headings, readingTime, frontmatter, filename } = getClientContext(); - formatter.dateTime(new Date(frontmatter.date), DEFAULT_DATE_FORMAT) + const t = await getTranslations(); + const locale = await getLocale(); + const formatter = await getFormatter(); + + const lastUpdated = frontmatter.date + ? formatter.dateTime(new Date(frontmatter.date), DEFAULT_DATE_FORMAT) : undefined; + const readingTimeText = formatter.number(readingTime.minutes, { style: 'unit', unit: 'minute', @@ -32,14 +32,6 @@ const MetaBar: FC = () => { const usernames = frontmatter.authors?.split(',').map(author => author.trim()) ?? []; - const t = useTranslations(); - const locale = useLocale(); - - // Since we cannot show the same number of avatars in Mobile / Tablet - // resolution as we do on desktop and there is overflow, we are adjusting - // the number of avatars manually for the resolutions below - const isSmallerThanDesktop = useMediaQuery('(max-width: 1280px)'); - return ( { ...(usernames.length && { [t( `components.metabar.${usernames.length > 1 ? 'authors' : 'author'}` - )]: ( - - ), + )]: , }), [t('components.metabar.contribute')]: ( <> diff --git a/apps/site/components/NavBar/ThemeToggle.tsx b/apps/site/components/NavBar/ThemeToggle.tsx new file mode 100644 index 0000000000000..67a8d71f5e7d8 --- /dev/null +++ b/apps/site/components/NavBar/ThemeToggle.tsx @@ -0,0 +1,38 @@ +'use client'; + +import Skeleton from '@node-core/ui-components/Common/Skeleton'; +import styles from '@node-core/ui-components/Containers/NavBar/index.module.css'; +import dynamic from 'next/dynamic'; +import { useTheme } from 'next-themes'; + +import type { Theme } from '@node-core/ui-components/Common/ThemeToggle'; +import type { FC } from 'react'; + +const ThemeToggleContainer = dynamic( + () => import('@node-core/ui-components/Common/ThemeToggle'), + { + ssr: false, + loading: () => ( + + ), + } +); + +type ThemeToggleProps = { + ariaLabel: string; + themeLabels: Record; +}; + +const ThemeToggle: FC = props => { + const { theme, setTheme } = useTheme(); + + return ( + + ); +}; + +export default ThemeToggle; diff --git a/apps/site/components/NavBar/__tests__/index.test.jsx b/apps/site/components/NavBar/__tests__/index.test.jsx new file mode 100644 index 0000000000000..4eb24fd6d5e50 --- /dev/null +++ b/apps/site/components/NavBar/__tests__/index.test.jsx @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import { describe, it, mock } from 'node:test'; + +import { render, screen } from '@testing-library/react'; + +mock.module('next-intl/server', { + namedExports: { + getTranslations: async () => key => key, + getLocale: async () => 'en', + }, +}); + +mock.module('#site/client-context', { + namedExports: { + getClientContext: () => ({ pathname: '/about', frontmatter: {} }), + }, +}); + +mock.module('#site/components/Search', { defaultExport: () => null }); +mock.module('../ThemeToggle', { defaultExport: () => null }); + +const { default: NavBar } = await import('..'); + +describe('NavBar', () => { + it('renders the top navigation on the server', async () => { + render(await NavBar()); + + const about = screen.getByRole('link', { + name: 'components.containers.navBar.links.about', + }); + + assert.equal(about.getAttribute('href'), '/about'); + }); +}); diff --git a/apps/site/components/NavBar.tsx b/apps/site/components/NavBar/index.tsx similarity index 61% rename from apps/site/components/NavBar.tsx rename to apps/site/components/NavBar/index.tsx index 6473bf42d6ab7..3b35fa9a3d61b 100644 --- a/apps/site/components/NavBar.tsx +++ b/apps/site/components/NavBar/index.tsx @@ -1,49 +1,27 @@ -'use client'; - import LanguageDropdown from '@node-core/ui-components/Common/LanguageDropDown'; -import Skeleton from '@node-core/ui-components/Common/Skeleton'; import SkipToContentButton from '@node-core/ui-components/Common/SkipToContentButton'; import NavBarContainer from '@node-core/ui-components/Containers/NavBar'; // TODO(@AvivKeller): I don't like that we are importing styles from another module import styles from '@node-core/ui-components/Containers/NavBar/index.module.css'; import GitHubIcon from '@node-core/ui-components/Icons/Social/GitHub'; import { availableLocales } from '@node-core/website-i18n'; -import dynamic from 'next/dynamic'; -import { useLocale, useTranslations } from 'next-intl'; -import { useTheme } from 'next-themes'; +import { getLocale, getTranslations } from 'next-intl/server'; +import { getClientContext } from '#site/client-context'; import Link from '#site/components/Link'; import Search from '#site/components/Search'; import SiteLogo from '#site/components/SiteLogo'; import WebsiteBanner from '#site/components/WebsiteBanner'; -import useSiteNavigation from '#site/hooks/useSiteNavigation'; -import { useRouter, usePathname } from '#site/navigation.mjs'; +import { getTopNavigation } from '#site/util/navigation'; -import type { Theme } from '@node-core/ui-components/Common/ThemeToggle'; -import type { SimpleLocaleConfig } from '@node-core/ui-components/types'; import type { FC } from 'react'; -const ThemeToggle = dynamic( - () => import('@node-core/ui-components/Common/ThemeToggle'), - { - ssr: false, - loading: () => ( - - ), - } -); - -const NavBar: FC = () => { - const { navigationItems } = useSiteNavigation(); - const { theme, setTheme } = useTheme(); - const { replace } = useRouter(); - const pathname = usePathname(); - const t = useTranslations(); - - const locale = useLocale(); +import ThemeToggle from './ThemeToggle'; - const changeLanguage = (locale: SimpleLocaleConfig) => - replace(pathname!, { locale: locale.code }); +const NavBar: FC = async () => { + const t = await getTranslations(); + const locale = await getLocale(); + const { pathname } = getClientContext(); return (
@@ -54,8 +32,8 @@ const NavBar: FC = () => { ({ + navItems={getTopNavigation(t).map( + ({ label, link, target, accent }) => ({ link, text: label, target, @@ -72,8 +50,6 @@ const NavBar: FC = () => { { /> ; - context?: Record; -}; - -type MappedItem = { - label: FormattedMessage; - link: string; - target?: string; - items?: Array<[string, MappedItem]>; -}; - -type SidebarMappedEntry = { - label: FormattedMessage; - link: string; - target?: string; - items?: Array; -}; - -const mapItem = ([, item]: [string, MappedItem]): SidebarMappedEntry => ({ - label: item.label, - link: item.link, - target: item.target, - items: item.items ? item.items.map(mapItem) : [], -}); - -const Sidebar: FC = ({ navKeys, context, ...props }) => { - const { getSideNavigation } = useSiteNavigation(); - const pathname = usePathname()!; - const t = useTranslations(); - const { push } = useRouter(); - const { frontmatter } = useClientContext(); - const sidebarRef = useRef(null); - const sideNavigation = getSideNavigation(navKeys, context); - - // Preserve sidebar scroll position across navigations - useScrollToElement('sidebar', sidebarRef); - - const mappedSidebarItems = - // If there's only a single navigation key, use its sub-items - // as our navigation. - (navKeys.length === 1 ? sideNavigation[0][1].items : sideNavigation).map( - ([, { label, items }]: [string, MappedItem]) => ({ - groupName: label, - items: items ? items.map(mapItem) : [], - }) - ); - - return ( - - ); -}; - -export default Sidebar; diff --git a/apps/site/components/Sidebar/ScrollRestore.tsx b/apps/site/components/Sidebar/ScrollRestore.tsx new file mode 100644 index 0000000000000..0aaa7b16e44ab --- /dev/null +++ b/apps/site/components/Sidebar/ScrollRestore.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { useLayoutEffect, useRef } from 'react'; + +import useScrollToElement from '#site/hooks/useScrollToElement'; + +import type { FC } from 'react'; + +// Preserves the scroll position of the sidebar it is rendered in +const ScrollRestore: FC = () => { + const sentinelRef = useRef(null); + const sidebarRef = useRef(null); + + // Runs before the effects of `useScrollToElement`, which read the ref + useLayoutEffect(() => { + sidebarRef.current = sentinelRef.current?.parentElement ?? null; + }, []); + + useScrollToElement('sidebar', sidebarRef); + + return
@@ -86,7 +86,6 @@ const NavBar: FC> = ({ className={classNames('peer', styles.sidebarItemToggler)} id="sidebarItemToggler" type="checkbox" - onChange={e => setIsMenuOpen(() => e.target.checked)} aria-label={sidebarItemTogglerAriaLabel} tabIndex={-1} /> diff --git a/packages/ui-components/src/Containers/Sidebar/SidebarCollapsible/index.module.css b/packages/ui-components/src/Containers/Sidebar/SidebarCollapsible/index.module.css new file mode 100644 index 0000000000000..59362b251c622 --- /dev/null +++ b/packages/ui-components/src/Containers/Sidebar/SidebarCollapsible/index.module.css @@ -0,0 +1,28 @@ +@reference "../../../styles/index.css"; + +/* + * These rules stay at single-class specificity so that the callers' rules + * (`.caller > summary`, `.caller > summary svg`) take precedence. + */ +.summary { + @apply flex + cursor-pointer + items-center + justify-between + select-none; + + list-style: none; + + &::-webkit-details-marker { + display: none; + } +} + +.chevron { + @apply shrink-0 + motion-safe:transition-transform; +} + +.collapsible[open] > .summary .chevron { + @apply rotate-180; +} diff --git a/packages/ui-components/src/Containers/Sidebar/SidebarCollapsible/index.stories.tsx b/packages/ui-components/src/Containers/Sidebar/SidebarCollapsible/index.stories.tsx new file mode 100644 index 0000000000000..1bff9e608bcd2 --- /dev/null +++ b/packages/ui-components/src/Containers/Sidebar/SidebarCollapsible/index.stories.tsx @@ -0,0 +1,19 @@ +import SidebarCollapsible from '#ui/Containers/Sidebar/SidebarCollapsible'; + +import type { Meta as MetaObj, StoryObj } from '@storybook/react-webpack5'; + +type Story = StoryObj; +type Meta = MetaObj; + +export const Default: Story = { + args: { + summary: 'File system', + children:

Content shown when the section is open.

, + }, +}; + +export const Open: Story = { + args: { ...Default.args, open: true }, +}; + +export default { component: SidebarCollapsible } as Meta; diff --git a/packages/ui-components/src/Containers/Sidebar/SidebarCollapsible/index.tsx b/packages/ui-components/src/Containers/Sidebar/SidebarCollapsible/index.tsx new file mode 100644 index 0000000000000..057f08e2fb534 --- /dev/null +++ b/packages/ui-components/src/Containers/Sidebar/SidebarCollapsible/index.tsx @@ -0,0 +1,31 @@ +import { ChevronDownIcon } from '@heroicons/react/24/outline'; +import classNames from 'classnames'; + +import type { FC, PropsWithChildren, ReactNode } from 'react'; + +import styles from './index.module.css'; + +type SidebarCollapsibleProps = { + summary: ReactNode; + open?: boolean; + className?: string; +}; + +// Callers style the summary and its chevron through `> summary` and `> summary svg` +const SidebarCollapsible: FC> = ({ + summary, + open, + className, + children, +}) => ( +
+ + {summary} + + + + {children} +
+); + +export default SidebarCollapsible; diff --git a/packages/ui-components/src/Containers/Sidebar/SidebarGroup/index.module.css b/packages/ui-components/src/Containers/Sidebar/SidebarGroup/index.module.css index 8e5a4c4027e5d..8a161a1925148 100644 --- a/packages/ui-components/src/Containers/Sidebar/SidebarGroup/index.module.css +++ b/packages/ui-components/src/Containers/Sidebar/SidebarGroup/index.module.css @@ -27,47 +27,33 @@ w-full flex-col gap-1; - } - - .summary { - @apply flex - cursor-pointer - items-center - justify-between - rounded-md - px-2 - py-1 - text-sm - font-semibold - text-neutral-800 - select-none - hover:bg-neutral-100 - dark:text-neutral-200 - hover:dark:bg-neutral-900; - list-style: none; + > summary { + @apply rounded-md + px-2 + py-1 + text-sm + font-semibold + text-neutral-800 + hover:bg-neutral-100 + dark:text-neutral-200 + hover:dark:bg-neutral-900; - &::-webkit-details-marker { - display: none; + svg { + @apply size-3 + text-neutral-500 + dark:text-neutral-200; + } } - .chevron { - @apply size-3 - shrink-0 - text-neutral-500 - motion-safe:transition-transform - dark:text-neutral-200; - } - } - - .subGroup[open] > .summary { - @apply text-brand-600 - dark:text-brand-400; - - .chevron { + &[open] > summary { @apply text-brand-600 - dark:text-brand-400 - rotate-180; + dark:text-brand-400; + + svg { + @apply text-brand-600 + dark:text-brand-400; + } } } diff --git a/packages/ui-components/src/Containers/Sidebar/SidebarGroup/index.tsx b/packages/ui-components/src/Containers/Sidebar/SidebarGroup/index.tsx index e00d689ef9240..036105967d63f 100644 --- a/packages/ui-components/src/Containers/Sidebar/SidebarGroup/index.tsx +++ b/packages/ui-components/src/Containers/Sidebar/SidebarGroup/index.tsx @@ -1,20 +1,15 @@ -import { ChevronDownIcon } from '@heroicons/react/24/outline'; import classNames from 'classnames'; +import SidebarCollapsible from '#ui/Containers/Sidebar/SidebarCollapsible'; import SidebarItem from '#ui/Containers/Sidebar/SidebarItem'; +import { findActiveItem } from '#ui/Containers/Sidebar/utils'; +import type { SidebarItemType } from '#ui/Containers/Sidebar/utils'; import type { FormattedMessage, LinkLike } from '#ui/types'; -import type { ComponentProps, FC } from 'react'; +import type { FC } from 'react'; import styles from './index.module.css'; -type SidebarItemType = Omit< - ComponentProps, - 'as' | 'pathname' -> & { - items?: Array; -}; - type SidebarGroupProps = { groupName: FormattedMessage; items: Array; @@ -23,17 +18,6 @@ type SidebarGroupProps = { className?: string; }; -const hasActivePath = ( - items: Array, - pathname?: string -): boolean => { - return items.some( - item => - item.link === pathname || - (item.items && hasActivePath(item.items, pathname)) - ); -}; - const renderItems = ( items: Array, props: { as?: LinkLike }, @@ -41,21 +25,24 @@ const renderItems = ( ) => { return items.map(({ label, link, items: subItems }) => { if (subItems && subItems.length > 0) { - const isOpen = link === pathname || hasActivePath(subItems, pathname); + const isOpen = + link === pathname || findActiveItem(subItems, pathname) !== undefined; + return (
  • -
    - - {label} - - +
      {renderItems(subItems, props, pathname)}
    -
    +
  • ); } + return ( { + const items = groups[0].items; + + it('finds a nested item', () => { + assert.equal(findActiveItem(items, '/fs/promises')?.label, 'Promises API'); + }); + + it('returns undefined when nothing matches', () => { + assert.equal(findActiveItem(items, '/missing'), undefined); + }); +}); + +describe('SideBar', () => { + it('summarizes the navigation with the current page', () => { + render( + + ); + + const [summary] = screen.getAllByText('Promises API'); + + assert.equal(summary.closest('summary') !== null, true); + }); + + it('falls back to the placeholder, then the title', () => { + const { rerender } = render( + + ); + + assert.ok(screen.getByText('Current page').closest('summary')); + + rerender(); + + assert.ok(screen.getByText('Navigation').closest('summary')); + }); + + it('renders each page once', () => { + render(); + + assert.equal(screen.getAllByRole('link', { name: 'Assert' }).length, 1); + assert.equal( + screen.getAllByRole('link', { name: 'Promises API' }).length, + 1 + ); + }); + + it('opens the nested group holding the current page', () => { + const { container } = render( + + ); + + const [, nested] = container.querySelectorAll('details'); + + assert.equal(nested.open, true); + }); +}); diff --git a/packages/ui-components/src/Containers/Sidebar/index.module.css b/packages/ui-components/src/Containers/Sidebar/index.module.css index 4bb1f000882f3..9c78991053f45 100644 --- a/packages/ui-components/src/Containers/Sidebar/index.module.css +++ b/packages/ui-components/src/Containers/Sidebar/index.module.css @@ -21,13 +21,60 @@ dark:bg-neutral-950; .navigation { - @apply ml:flex - hidden; + @apply ml:rounded-none + ml:border-0 + ml:bg-transparent + ml:shadow-none + w-full + rounded-sm + border + border-neutral-300 + bg-white + shadow-xs + dark:border-neutral-800 + dark:bg-neutral-950; + + /* The disclosure only applies on mobile; larger screens always show the navigation */ + @variant ml { + &::details-content { + content-visibility: visible; + display: block; + } + } + + > summary { + @apply ml:hidden + gap-2 + px-3.5 + py-2.5 + text-base + font-medium + text-neutral-900 + dark:text-white; + + svg { + @apply size-5 + text-neutral-600 + dark:text-neutral-400; + } + } + + &[open] > summary { + @apply border-b + border-neutral-200 + dark:border-neutral-800; + } } - .mobileSelect { - @apply ml:hidden + .groups { + @apply ml:max-h-none + ml:overflow-visible + ml:p-0 flex - w-full; + max-h-[60vh] + flex-col + gap-8 + overflow-y-auto + p-2; } } diff --git a/packages/ui-components/src/Containers/Sidebar/index.stories.tsx b/packages/ui-components/src/Containers/Sidebar/index.stories.tsx index 4f22e462c89c3..2e6077893ea89 100644 --- a/packages/ui-components/src/Containers/Sidebar/index.stories.tsx +++ b/packages/ui-components/src/Containers/Sidebar/index.stories.tsx @@ -76,10 +76,58 @@ const args = { }, ], title: 'Sidebar', - onSelect: console.log, pathname: '/item1', }; export const Default: Story = { args }; +export const Nested: Story = { + args: { + title: 'Sidebar', + pathname: '/fs/promises/read-file', + groups: [ + { + groupName: 'API', + items: [ + { link: '/assert', label: 'Assert' }, + { + link: '/fs', + label: 'File system', + items: [ + { link: '/fs', label: 'File system' }, + { + link: '/fs/promises', + label: 'Promises API', + items: [ + { link: '/fs/promises', label: 'Promises API' }, + { link: '/fs/promises/read-file', label: 'readFile' }, + { link: '/fs/promises/write-file', label: 'writeFile' }, + ], + }, + { link: '/fs/callback', label: 'Callback API' }, + ], + }, + { link: '/http', label: 'HTTP' }, + ], + }, + ], + }, +}; + +// Mirrors large documentation sites (e.g. webpack's), which have thousands of pages +export const LargeSite: Story = { + args: { + title: 'Sidebar', + pathname: '/section-10/page-25', + groups: Array.from({ length: 20 }, (_, section) => ({ + groupName: `Section ${section + 1}`, + items: Array.from({ length: 50 }, (_, page) => ({ + link: `/section-${section + 1}/page-${page + 1}`, + label: `Page ${page + 1}`, + })), + })), + }, + globals: { viewport: { value: 'small' } }, +}; + export default { component: Sidebar } as Meta; diff --git a/packages/ui-components/src/Containers/Sidebar/index.tsx b/packages/ui-components/src/Containers/Sidebar/index.tsx index b04e2b2a8512f..6450500f18b18 100644 --- a/packages/ui-components/src/Containers/Sidebar/index.tsx +++ b/packages/ui-components/src/Containers/Sidebar/index.tsx @@ -1,84 +1,61 @@ -import WithNoScriptSelect from '#ui/Common/Select/NoScriptSelect'; +import SidebarCollapsible from '#ui/Containers/Sidebar/SidebarCollapsible'; import SidebarGroup from '#ui/Containers/Sidebar/SidebarGroup'; +import { findActiveItem } from '#ui/Containers/Sidebar/utils'; -import type { FormattedMessage, LinkLike } from '#ui/types'; +import type { LinkLike } from '#ui/types'; import type { ComponentProps, FC, PropsWithChildren, RefObject } from 'react'; import styles from './index.module.css'; -type SidebarItemType = { - label: FormattedMessage; - link: string; - items?: Array; -}; - -const flattenItems = ( - items: Array -): Array => { - return items.flatMap((item: SidebarItemType) => - item.items && item.items.length ? flattenItems(item.items) : [item] - ); -}; - type SidebarProps = { groups: Array< Pick, 'items' | 'groupName'> >; pathname?: string; title: string; - onSelect: (value: string) => void; as?: LinkLike; placeholder?: string; - ref: RefObject; + ref?: RefObject; }; const SideBar: FC> = ({ groups, pathname, title, - onSelect, as, children, placeholder, ...props }) => { - const selectItems = groups.map(({ items, groupName }) => ({ - label: groupName, - items: flattenItems(items as Array).map( - ({ label, link }) => ({ value: link, label }) - ), - })); - - const currentItem = selectItems - .flatMap(item => item.items) - .find(item => pathname === item.value); + const currentItem = findActiveItem( + groups.flatMap(({ items }) => items), + pathname + ); return ( ); }; diff --git a/packages/ui-components/src/Containers/Sidebar/utils.ts b/packages/ui-components/src/Containers/Sidebar/utils.ts new file mode 100644 index 0000000000000..528a9b0904fe6 --- /dev/null +++ b/packages/ui-components/src/Containers/Sidebar/utils.ts @@ -0,0 +1,29 @@ +import type { FormattedMessage } from '#ui/types'; + +export type SidebarItemType = { + label: FormattedMessage; + link: string; + target?: string; + items?: Array; +}; + +/** + * Finds the item whose link matches the pathname, searching nested items + * depth-first. + */ +export const findActiveItem = ( + items: Array, + pathname?: string +): SidebarItemType | undefined => { + for (const item of items) { + if (item.link === pathname) { + return item; + } + + const active = item.items && findActiveItem(item.items, pathname); + + if (active) { + return active; + } + } +}; diff --git a/apps/site/hooks/__tests__/useMediaQuery.test.mjs b/packages/ui-components/src/hooks/__test__/useMediaQuery.test.mjs similarity index 96% rename from apps/site/hooks/__tests__/useMediaQuery.test.mjs rename to packages/ui-components/src/hooks/__test__/useMediaQuery.test.mjs index 71b51f0aed46e..c6a0beeffd8e1 100644 --- a/apps/site/hooks/__tests__/useMediaQuery.test.mjs +++ b/packages/ui-components/src/hooks/__test__/useMediaQuery.test.mjs @@ -3,7 +3,7 @@ import { describe, it } from 'node:test'; import { renderHook } from '@testing-library/react'; -import useMediaQuery from '#site/hooks/useMediaQuery'; +import useMediaQuery from '#ui/hooks/useMediaQuery'; const noop = () => {}; diff --git a/apps/site/hooks/useMediaQuery.ts b/packages/ui-components/src/hooks/useMediaQuery.ts similarity index 100% rename from apps/site/hooks/useMediaQuery.ts rename to packages/ui-components/src/hooks/useMediaQuery.ts diff --git a/tests/mocks/css.mjs b/tests/mocks/css.mjs index ff8b4c56321a3..a09a226bca9bc 100644 --- a/tests/mocks/css.mjs +++ b/tests/mocks/css.mjs @@ -1 +1,4 @@ -export default {}; +export default new Proxy( + {}, + { get: (_, key) => (typeof key === 'string' ? key : undefined) } +);