From d1830111f54863b8828b99ef218cb1292e4ee0f4 Mon Sep 17 00:00:00 2001 From: avivkeller Date: Sun, 1 Nov 2026 14:03:42 +0100 Subject: [PATCH 1/3] feat: make the mobile selector less render-heavy --- .changeset/sidebar-mobile-disclosure.md | 5 ++ apps/site/components/Sidebar.tsx | 4 +- packages/ui-components/.stylelintrc.mjs | 1 + .../src/Containers/Sidebar/index.module.css | 72 +++++++++++++++- .../src/Containers/Sidebar/index.stories.tsx | 50 ++++++++++- .../src/Containers/Sidebar/index.tsx | 82 ++++++++++--------- 6 files changed, 166 insertions(+), 48 deletions(-) create mode 100644 .changeset/sidebar-mobile-disclosure.md diff --git a/.changeset/sidebar-mobile-disclosure.md b/.changeset/sidebar-mobile-disclosure.md new file mode 100644 index 0000000000000..1fd54da301ea1 --- /dev/null +++ b/.changeset/sidebar-mobile-disclosure.md @@ -0,0 +1,5 @@ +--- +'@node-core/ui-components': major +--- + +On mobile, `Sidebar` now collapses its navigation tree behind a disclosure instead of flattening every page into a select, so large sites stay navigable and the navigation is rendered once. The `onSelect` prop is removed, since navigation now happens through the rendered links. diff --git a/apps/site/components/Sidebar.tsx b/apps/site/components/Sidebar.tsx index 8f13afa7d855d..f56901660eda0 100644 --- a/apps/site/components/Sidebar.tsx +++ b/apps/site/components/Sidebar.tsx @@ -8,7 +8,7 @@ import Link from '#site/components/Link'; import useClientContext from '#site/hooks/useClientContext'; import useScrollToElement from '#site/hooks/useScrollToElement'; import useSiteNavigation from '#site/hooks/useSiteNavigation'; -import { useRouter, usePathname } from '#site/navigation.mjs'; +import { usePathname } from '#site/navigation.mjs'; import type { FormattedMessage, NavigationKeys } from '#site/types'; import type { RichTranslationValues } from 'next-intl'; @@ -44,7 +44,6 @@ 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); @@ -69,7 +68,6 @@ const Sidebar: FC = ({ navKeys, context, ...props }) => { pathname={pathname} title={t('components.common.sidebar.title')} placeholder={frontmatter?.title} - onSelect={push} as={Link} {...props} /> diff --git a/packages/ui-components/.stylelintrc.mjs b/packages/ui-components/.stylelintrc.mjs index e371180e8fe7c..039e8b4932c8c 100644 --- a/packages/ui-components/.stylelintrc.mjs +++ b/packages/ui-components/.stylelintrc.mjs @@ -8,6 +8,7 @@ const CUSTOM_AT_RULES = [ 'utility', 'theme', 'custom-variant', + 'variant', 'screen', 'source', 'tailwind', diff --git a/packages/ui-components/src/Containers/Sidebar/index.module.css b/packages/ui-components/src/Containers/Sidebar/index.module.css index 4bb1f000882f3..5d31263ed505c 100644 --- a/packages/ui-components/src/Containers/Sidebar/index.module.css +++ b/packages/ui-components/src/Containers/Sidebar/index.module.css @@ -21,13 +21,77 @@ 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; + } + } } - .mobileSelect { + .summary { @apply ml:hidden flex - w-full; + cursor-pointer + items-center + justify-between + gap-2 + px-3.5 + py-2.5 + text-base + font-medium + text-neutral-900 + select-none + dark:text-white; + + list-style: none; + + &::-webkit-details-marker { + display: none; + } + + .chevron { + @apply size-5 + shrink-0 + text-neutral-600 + motion-safe:transition-transform + dark:text-neutral-400; + } + } + + .navigation[open] > .summary { + @apply border-b + border-neutral-200 + dark:border-neutral-800; + + .chevron { + @apply rotate-180; + } + } + + .groups { + @apply ml:max-h-none + ml:overflow-visible + ml:p-0 + flex + 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..98f0a4aff9efb 100644 --- a/packages/ui-components/src/Containers/Sidebar/index.tsx +++ b/packages/ui-components/src/Containers/Sidebar/index.tsx @@ -1,4 +1,5 @@ -import WithNoScriptSelect from '#ui/Common/Select/NoScriptSelect'; +import { ChevronDownIcon } from '@heroicons/react/24/outline'; + import SidebarGroup from '#ui/Containers/Sidebar/SidebarGroup'; import type { FormattedMessage, LinkLike } from '#ui/types'; @@ -12,12 +13,21 @@ type SidebarItemType = { items?: Array; }; -const flattenItems = ( - items: Array -): Array => { - return items.flatMap((item: SidebarItemType) => - item.items && item.items.length ? flattenItems(item.items) : [item] - ); +const findLabel = ( + items: Array, + pathname?: string +): FormattedMessage | undefined => { + for (const item of items) { + if (item.link === pathname) { + return item.label; + } + + const label = item.items && findLabel(item.items, pathname); + + if (label) { + return label; + } + } }; type SidebarProps = { @@ -26,7 +36,6 @@ type SidebarProps = { >; pathname?: string; title: string; - onSelect: (value: string) => void; as?: LinkLike; placeholder?: string; ref: RefObject; @@ -36,49 +45,42 @@ 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 currentLabel = findLabel( + groups.flatMap(({ items }) => items as Array), + pathname + ); return ( ); }; From 3ed7d66b950e4166a9f6a2da7bf63d0e24bc87f1 Mon Sep 17 00:00:00 2001 From: avivkeller Date: Sun, 1 Nov 2026 15:31:10 +0100 Subject: [PATCH 2/3] fixup! --- .changeset/avatar-group-compact-limit.md | 5 + .changeset/breadcrumbs-mobile-length.md | 5 + .changeset/language-dropdown-links.md | 5 + .changeset/navbar-stateless.md | 5 + .changeset/sidebar-mobile-disclosure.md | 4 +- apps/site/components/Breadcrumbs.tsx | 100 +++++++++--------- apps/site/components/DownloadSection.tsx | 11 +- .../Downloads/DownloadButton/index.tsx | 13 +-- .../components/Downloads/DownloadLink.tsx | 14 +-- .../Downloads/Release/DownloadLink.tsx | 9 +- .../Release/OperatingSystemDropdown.tsx | 12 --- .../Downloads/Release/PlatformDropdown.tsx | 20 +--- .../Downloads/__tests__/DownloadLink.test.jsx | 32 ++++++ apps/site/components/MetaBar.tsx | 37 +++---- apps/site/components/NavBar/ThemeToggle.tsx | 38 +++++++ .../NavBar/__tests__/index.test.jsx | 34 ++++++ .../{NavBar.tsx => NavBar/index.tsx} | 47 +++----- apps/site/components/Sidebar.tsx | 77 -------------- .../site/components/Sidebar/ScrollRestore.tsx | 24 +++++ .../Sidebar/__tests__/index.test.jsx | 42 ++++++++ apps/site/components/Sidebar/index.tsx | 47 ++++++++ .../hooks/__tests__/useClientContext.test.jsx | 29 ----- apps/site/hooks/useClientContext.ts | 9 -- apps/site/hooks/useSiteNavigation.ts | 81 -------------- apps/site/next.dynamic.page.mjs | 14 +-- .../__tests__/matterProvider.test.jsx | 34 ------ .../__tests__/releaseProvider.test.jsx | 31 ++++++ apps/site/providers/detectedOSProvider.tsx | 17 +++ apps/site/providers/matterProvider.tsx | 30 ------ apps/site/providers/releaseProvider.tsx | 16 +++ apps/site/types/server.ts | 3 +- apps/site/util/__tests__/context.test.mjs | 3 - apps/site/util/__tests__/navigation.test.mjs | 27 +++++ apps/site/util/context.ts | 3 - apps/site/util/navigation.ts | 46 ++++++++ apps/site/util/url.ts | 2 +- .../AvatarGroup/__tests__/index.test.jsx | 29 +++++ .../src/Common/AvatarGroup/index.stories.tsx | 9 ++ .../src/Common/AvatarGroup/index.tsx | 11 +- .../BreadcrumbItem/index.module.css | 8 ++ .../Breadcrumbs/BreadcrumbItem/index.tsx | 12 ++- .../BreadcrumbTruncatedItem/index.tsx | 13 ++- .../Breadcrumbs/__tests__/index.test.jsx | 63 +++++++++++ .../src/Common/Breadcrumbs/index.stories.tsx | 9 ++ .../src/Common/Breadcrumbs/index.tsx | 85 +++++++++------ .../Common/LanguageDropDown/index.stories.tsx | 1 + .../src/Common/LanguageDropDown/index.tsx | 41 ++++--- .../src/Containers/NavBar/index.module.css | 15 +++ .../src/Containers/NavBar/index.tsx | 35 +++--- .../SidebarCollapsible/index.module.css | 28 +++++ .../SidebarCollapsible/index.stories.tsx | 19 ++++ .../Sidebar/SidebarCollapsible/index.tsx | 31 ++++++ .../Sidebar/SidebarGroup/index.module.css | 58 ++++------ .../Containers/Sidebar/SidebarGroup/index.tsx | 41 +++---- .../Sidebar/__tests__/index.test.jsx | 78 ++++++++++++++ .../src/Containers/Sidebar/index.module.css | 53 ++++------ .../src/Containers/Sidebar/index.tsx | 47 ++------ .../src/Containers/Sidebar/utils.ts | 29 +++++ .../hooks/__test__}/useMediaQuery.test.mjs | 2 +- .../ui-components/src}/hooks/useMediaQuery.ts | 0 tests/mocks/css.mjs | 5 +- 61 files changed, 1001 insertions(+), 647 deletions(-) create mode 100644 .changeset/avatar-group-compact-limit.md create mode 100644 .changeset/breadcrumbs-mobile-length.md create mode 100644 .changeset/language-dropdown-links.md create mode 100644 .changeset/navbar-stateless.md create mode 100644 apps/site/components/Downloads/__tests__/DownloadLink.test.jsx create mode 100644 apps/site/components/NavBar/ThemeToggle.tsx create mode 100644 apps/site/components/NavBar/__tests__/index.test.jsx rename apps/site/components/{NavBar.tsx => NavBar/index.tsx} (61%) delete mode 100644 apps/site/components/Sidebar.tsx create mode 100644 apps/site/components/Sidebar/ScrollRestore.tsx create mode 100644 apps/site/components/Sidebar/__tests__/index.test.jsx create mode 100644 apps/site/components/Sidebar/index.tsx delete mode 100644 apps/site/hooks/__tests__/useClientContext.test.jsx delete mode 100644 apps/site/hooks/useClientContext.ts delete mode 100644 apps/site/hooks/useSiteNavigation.ts delete mode 100644 apps/site/providers/__tests__/matterProvider.test.jsx create mode 100644 apps/site/providers/detectedOSProvider.tsx delete mode 100644 apps/site/providers/matterProvider.tsx create mode 100644 apps/site/util/__tests__/navigation.test.mjs create mode 100644 apps/site/util/navigation.ts create mode 100644 packages/ui-components/src/Common/Breadcrumbs/__tests__/index.test.jsx create mode 100644 packages/ui-components/src/Containers/Sidebar/SidebarCollapsible/index.module.css create mode 100644 packages/ui-components/src/Containers/Sidebar/SidebarCollapsible/index.stories.tsx create mode 100644 packages/ui-components/src/Containers/Sidebar/SidebarCollapsible/index.tsx create mode 100644 packages/ui-components/src/Containers/Sidebar/__tests__/index.test.jsx create mode 100644 packages/ui-components/src/Containers/Sidebar/utils.ts rename {apps/site/hooks/__tests__ => packages/ui-components/src/hooks/__test__}/useMediaQuery.test.mjs (96%) rename {apps/site => packages/ui-components/src}/hooks/useMediaQuery.ts (100%) 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 index 1fd54da301ea1..1ed0f2648132a 100644 --- a/.changeset/sidebar-mobile-disclosure.md +++ b/.changeset/sidebar-mobile-disclosure.md @@ -1,5 +1,5 @@ --- -'@node-core/ui-components': major +'@node-core/ui-components': minor --- -On mobile, `Sidebar` now collapses its navigation tree behind a disclosure instead of flattening every page into a select, so large sites stay navigable and the navigation is rendered once. The `onSelect` prop is removed, since navigation now happens through the rendered links. +`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/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 { 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