diff --git a/apps/site/components/Common/ArticleWithSidebarState.tsx b/apps/site/components/Common/ArticleWithSidebarState.tsx new file mode 100644 index 0000000000000..42ba7a62809c3 --- /dev/null +++ b/apps/site/components/Common/ArticleWithSidebarState.tsx @@ -0,0 +1,22 @@ +'use client'; + +import Article from '@node-core/ui-components/Containers/Article'; + +import { useSidebarState } from '#site/providers/sidebarStateProvider'; + +import type { FC, PropsWithChildren } from 'react'; + +const ArticleWithSidebarState: FC = ({ children }) => { + const { isLeftSidebarCollapsed, isRightSidebarCollapsed } = useSidebarState(); + + return ( +
+ {children} +
+ ); +}; + +export default ArticleWithSidebarState; diff --git a/apps/site/components/Common/CollapsedSidebarRail.tsx b/apps/site/components/Common/CollapsedSidebarRail.tsx new file mode 100644 index 0000000000000..79d03ad6a475b --- /dev/null +++ b/apps/site/components/Common/CollapsedSidebarRail.tsx @@ -0,0 +1,26 @@ +'use client'; + +import type { FC, PropsWithChildren } from 'react'; + +type CollapsedSidebarRailProps = { + side: 'left' | 'right'; +}; + +const CollapsedSidebarRail: FC< + PropsWithChildren +> = ({ side, children }) => { + return ( + + ); +}; + +export default CollapsedSidebarRail; diff --git a/apps/site/components/Common/ContentLayoutWithSidebarState.tsx b/apps/site/components/Common/ContentLayoutWithSidebarState.tsx new file mode 100644 index 0000000000000..d1039b73f21a8 --- /dev/null +++ b/apps/site/components/Common/ContentLayoutWithSidebarState.tsx @@ -0,0 +1,27 @@ +'use client'; + +import { useSidebarState } from '#site/providers/sidebarStateProvider'; + +import type { FC, PropsWithChildren } from 'react'; + +type ContentLayoutWithSidebarStateProps = { + className?: string; +}; + +const ContentLayoutWithSidebarState: FC< + PropsWithChildren +> = ({ children, className = '' }) => { + const { isLeftSidebarCollapsed, isRightSidebarCollapsed } = useSidebarState(); + + return ( +
+ {children} +
+ ); +}; + +export default ContentLayoutWithSidebarState; diff --git a/apps/site/components/Common/SidebarToggleButton.tsx b/apps/site/components/Common/SidebarToggleButton.tsx new file mode 100644 index 0000000000000..c8811331d9418 --- /dev/null +++ b/apps/site/components/Common/SidebarToggleButton.tsx @@ -0,0 +1,59 @@ +'use client'; + +import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline'; +import { useTranslations } from 'next-intl'; + +import type { FC, ButtonHTMLAttributes } from 'react'; + +type SidebarToggleButtonProps = { + side: 'left' | 'right'; + isCollapsed: boolean; + onToggle: () => void; +} & ButtonHTMLAttributes; + +const SidebarToggleButton: FC = ({ + side, + isCollapsed, + onToggle, + ...props +}) => { + const t = useTranslations(); + + const icon = + side === 'left' ? ( + isCollapsed ? ( + + ) : ( + + ) + ) : isCollapsed ? ( + + ) : ( + + ); + + const label = + side === 'left' + ? isCollapsed + ? t('components.common.sidebar.expandLeftSidebar') + : t('components.common.sidebar.collapseLeftSidebar') + : isCollapsed + ? t('components.common.sidebar.expandRightSidebar') + : t('components.common.sidebar.collapseRightSidebar'); + + return ( + + ); +}; + +export default SidebarToggleButton; diff --git a/apps/site/components/withMetaBar.tsx b/apps/site/components/withMetaBar.tsx index 64b796734972a..39cdb8f099c85 100644 --- a/apps/site/components/withMetaBar.tsx +++ b/apps/site/components/withMetaBar.tsx @@ -5,12 +5,15 @@ 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 CollapsedSidebarRail from '#site/components/Common/CollapsedSidebarRail'; +import SidebarToggleButton from '#site/components/Common/SidebarToggleButton'; import Link from '#site/components/Link'; import WithAvatarGroup from '#site/components/withAvatarGroup'; 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 { useSidebarState } from '#site/providers/sidebarStateProvider'; import { getGitHubBlobUrl } from '#site/util/github'; import type { FC } from 'react'; @@ -34,12 +37,48 @@ const WithMetaBar: FC = () => { const t = useTranslations(); const locale = useLocale(); + const { isRightSidebarCollapsed, toggleRightSidebar } = useSidebarState(); // 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)'); + // Check if there's any content to show in the metabar + const hasContent = + lastUpdated || + readingTimeText || + usernames.length > 0 || + headings.length > 0; + + // Always show collapsed rail when right sidebar is collapsed + if (isRightSidebarCollapsed) { + return ( + + + + ); + } + + // If no content, show empty metabar area with toggle button + if (!hasContent) { + return ( +
+
+ +
+
+ ); + } + return ( { ), }} headings={{ items: headings }} - /> + > +
+ +
+
); }; diff --git a/apps/site/components/withSidebar.tsx b/apps/site/components/withSidebar.tsx index fb9f04b2c27da..85c688fae8159 100644 --- a/apps/site/components/withSidebar.tsx +++ b/apps/site/components/withSidebar.tsx @@ -4,11 +4,14 @@ import Sidebar from '@node-core/ui-components/Containers/Sidebar'; import { useTranslations } from 'next-intl'; import { useRef } from 'react'; +import CollapsedSidebarRail from '#site/components/Common/CollapsedSidebarRail'; +import SidebarToggleButton from '#site/components/Common/SidebarToggleButton'; 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 { useSidebarState } from '#site/providers/sidebarStateProvider'; import type { FormattedMessage, NavigationKeys } from '#site/types'; import type { RichTranslationValues } from 'next-intl'; @@ -48,6 +51,7 @@ const WithSidebar: FC = ({ navKeys, context, ...props }) => { const { frontmatter } = useClientContext(); const sidebarRef = useRef(null); const sideNavigation = getSideNavigation(navKeys, context); + const { isLeftSidebarCollapsed, toggleLeftSidebar } = useSidebarState(); // Preserve sidebar scroll position across navigations useScrollToElement('sidebar', sidebarRef); @@ -62,6 +66,37 @@ const WithSidebar: FC = ({ navKeys, context, ...props }) => { }) ); + const hasNavigationContent = + mappedSidebarItems.length > 0 && navKeys.length > 0; + + // Always show collapsed rail when sidebar is collapsed + if (isLeftSidebarCollapsed) { + return ( + + + + ); + } + + // If no navigation content, show empty sidebar with toggle button + if (!hasNavigationContent) { + return ( +
+
+ +
+
+ ); + } + return ( = ({ navKeys, context, ...props }) => { onSelect={push} as={Link} {...props} - /> + > +
+ +
+
); }; diff --git a/apps/site/layouts/About.tsx b/apps/site/layouts/About.tsx index ea6260b27f1f5..903dc607b7533 100644 --- a/apps/site/layouts/About.tsx +++ b/apps/site/layouts/About.tsx @@ -1,5 +1,4 @@ -import Article from '@node-core/ui-components/Containers/Article'; - +import ArticleWithSidebarState from '#site/components/Common/ArticleWithSidebarState'; import WithBreadcrumbs from '#site/components/withBreadcrumbs'; import WithFooter from '#site/components/withFooter'; import WithMetaBar from '#site/components/withMetaBar'; @@ -12,7 +11,7 @@ const AboutLayout: FC = ({ children }) => ( <> -
+
@@ -24,7 +23,7 @@ const AboutLayout: FC = ({ children }) => (
-
+ diff --git a/apps/site/layouts/ArticlePage.tsx b/apps/site/layouts/ArticlePage.tsx index 37216ae07be11..f710ebcbb0ea2 100644 --- a/apps/site/layouts/ArticlePage.tsx +++ b/apps/site/layouts/ArticlePage.tsx @@ -1,5 +1,4 @@ -import Article from '@node-core/ui-components/Containers/Article'; - +import ArticleWithSidebarState from '#site/components/Common/ArticleWithSidebarState'; import WithFooter from '#site/components/withFooter'; import WithMetaBar from '#site/components/withMetaBar'; import WithNavBar from '#site/components/withNavBar'; @@ -11,7 +10,7 @@ const ArticlePageLayout: FC = ({ children }) => ( <> -
+
@@ -21,7 +20,7 @@ const ArticlePageLayout: FC = ({ children }) => (
-
+ diff --git a/apps/site/layouts/Base.tsx b/apps/site/layouts/Base.tsx index 8e8747940e458..6f3972121ee50 100644 --- a/apps/site/layouts/Base.tsx +++ b/apps/site/layouts/Base.tsx @@ -1,4 +1,5 @@ import { NavigationStateProvider } from '#site/providers/navigationStateProvider'; +import { SidebarStateProvider } from '#site/providers/sidebarStateProvider'; import type { FC, PropsWithChildren } from 'react'; @@ -6,7 +7,9 @@ import styles from './layouts.module.css'; const BaseLayout: FC = ({ children }) => ( -
{children}
+ +
{children}
+
); diff --git a/apps/site/layouts/Default.tsx b/apps/site/layouts/Default.tsx index 14fe4a4cb5134..ff668dc2a6297 100644 --- a/apps/site/layouts/Default.tsx +++ b/apps/site/layouts/Default.tsx @@ -1,5 +1,4 @@ -import Article from '@node-core/ui-components/Containers/Article'; - +import ArticleWithSidebarState from '#site/components/Common/ArticleWithSidebarState'; import WithFooter from '#site/components/withFooter'; import WithNavBar from '#site/components/withNavBar'; import WithSidebar from '#site/components/withSidebar'; @@ -10,7 +9,7 @@ const DefaultLayout: FC = ({ children }) => ( <> -
+
@@ -18,7 +17,7 @@ const DefaultLayout: FC = ({ children }) => ( {children}
-
+ diff --git a/apps/site/layouts/Post.tsx b/apps/site/layouts/Post.tsx index 123bafe87ccdc..30e4836c0dee4 100644 --- a/apps/site/layouts/Post.tsx +++ b/apps/site/layouts/Post.tsx @@ -1,12 +1,14 @@ import Preview from '@node-core/ui-components/Common/Preview'; import { getClientContext } from '#site/client-context'; +import ContentLayoutWithSidebarState from '#site/components/Common/ContentLayoutWithSidebarState'; import EOLAlert from '#site/components/EOL/EOLAlert'; import WithAvatarGroup from '#site/components/withAvatarGroup'; import WithBlogCrossLinks from '#site/components/withBlogCrossLinks'; import WithFooter from '#site/components/withFooter'; import WithMetaBar from '#site/components/withMetaBar'; import WithNavBar from '#site/components/withNavBar'; +import WithSidebar from '#site/components/withSidebar'; import { mapAuthorToCardAuthors } from '#site/util/author'; import { mapBlogCategoryToPreviewType } from '#site/util/blog'; @@ -25,8 +27,8 @@ const PostLayout: FC = ({ children }) => { <> -
-
+ +
@@ -49,7 +51,7 @@ const PostLayout: FC = ({ children }) => {
-
+ diff --git a/apps/site/layouts/layouts.module.css b/apps/site/layouts/layouts.module.css index a2b8a89315dea..e3916ba6d9ceb 100644 --- a/apps/site/layouts/layouts.module.css +++ b/apps/site/layouts/layouts.module.css @@ -144,6 +144,9 @@ block w-full grid-rows-[1fr] + motion-safe:transition-[grid-template-columns] + motion-safe:duration-200 + motion-safe:ease-out xl:grid-cols-[--spacing(56)_1fr_--spacing(64)] 2xl:grid-cols-[--spacing(72)_1fr_--spacing(72)]; @@ -181,6 +184,27 @@ } } +/* Content layout with collapsed sidebars */ +.contentLayout[data-left-sidebar-collapsed='true'] { + @apply ml:grid-cols-[--spacing(11)_1fr_--spacing(56)] + 3xl:grid-cols-[--spacing(11)_1fr_--spacing(80)] + xl:grid-cols-[--spacing(11)_1fr_--spacing(64)] + 2xl:grid-cols-[--spacing(11)_1fr_--spacing(72)]; +} + +.contentLayout[data-right-sidebar-collapsed='true'] { + @apply 3xl:grid-cols-[--spacing(80)_1fr_--spacing(11)] + xl:grid-cols-[--spacing(56)_1fr_--spacing(11)] + 2xl:grid-cols-[--spacing(72)_1fr_--spacing(11)]; +} + +.contentLayout[data-left-sidebar-collapsed='true'][data-right-sidebar-collapsed='true'] { + @apply ml:grid-cols-[--spacing(11)_1fr_--spacing(11)] + 3xl:grid-cols-[--spacing(11)_1fr_--spacing(11)] + xl:grid-cols-[--spacing(11)_1fr_--spacing(11)] + 2xl:grid-cols-[--spacing(11)_1fr_--spacing(11)]; +} + .postLayout { main { > section { diff --git a/apps/site/providers/sidebarStateProvider.tsx b/apps/site/providers/sidebarStateProvider.tsx new file mode 100644 index 0000000000000..08eacccf98921 --- /dev/null +++ b/apps/site/providers/sidebarStateProvider.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { + createContext, + useContext, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; + +import type { FC, PropsWithChildren } from 'react'; + +type SidebarState = { + isLeftSidebarCollapsed: boolean; + isRightSidebarCollapsed: boolean; + toggleLeftSidebar: () => void; + toggleRightSidebar: () => void; +}; + +const SIDEBAR_STATE_KEY = 'nodejs-website-sidebar-state'; + +const SidebarStateContext = createContext(undefined); + +export const useSidebarState = () => { + const context = useContext(SidebarStateContext); + if (!context) { + throw new Error( + 'useSidebarState must be used within a SidebarStateProvider' + ); + } + return context; +}; + +export const SidebarStateProvider: FC = ({ children }) => { + // Always start with identical state for server and client + const [sidebarState, setSidebarState] = useState({ + left: false, + right: false, + }); + + // Track if we've loaded from localStorage to avoid overwriting + const hasLoadedFromStorage = useRef(false); + + // Load state from localStorage after hydration + useEffect(() => { + try { + const saved = localStorage.getItem(SIDEBAR_STATE_KEY); + if (saved) { + const parsed = JSON.parse(saved); + // Validate the parsed data + if ( + typeof parsed.left === 'boolean' && + typeof parsed.right === 'boolean' + ) { + setSidebarState(parsed); + } + } + } catch (error) { + console.warn('Failed to load sidebar state from localStorage:', error); + } + hasLoadedFromStorage.current = true; + }, []); + + // Save state to localStorage when it changes (but only after initial load) + useEffect(() => { + if (!hasLoadedFromStorage.current) { + return; + } + + try { + localStorage.setItem(SIDEBAR_STATE_KEY, JSON.stringify(sidebarState)); + } catch { + // localStorage might be unavailable in private mode + // Silently fail - state will still work for current session + } + }, [sidebarState]); + + const toggleLeftSidebar = useCallback(() => { + setSidebarState(prev => ({ ...prev, left: !prev.left })); + }, []); + + const toggleRightSidebar = useCallback(() => { + setSidebarState(prev => ({ ...prev, right: !prev.right })); + }, []); + + return ( + + {children} + + ); +}; diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index 645b36dc9320a..89522e69501dc 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -160,7 +160,11 @@ "pageLabel": "Go to page {pageNumber}" }, "sidebar": { - "title": "Change page" + "title": "Change page", + "expandLeftSidebar": "Expand left sidebar", + "collapseLeftSidebar": "Collapse left sidebar", + "expandRightSidebar": "Expand right sidebar", + "collapseRightSidebar": "Collapse right sidebar" }, "languageDropdown": { "label": "Choose Language" diff --git a/packages/ui-components/src/Containers/Article/index.module.css b/packages/ui-components/src/Containers/Article/index.module.css index 0dd9ce4655dbe..bf04b3d2c4f7a 100644 --- a/packages/ui-components/src/Containers/Article/index.module.css +++ b/packages/ui-components/src/Containers/Article/index.module.css @@ -12,6 +12,9 @@ mx-auto block w-full + motion-safe:transition-[grid-template-columns] + motion-safe:duration-200 + motion-safe:ease-out xl:grid-cols-[--spacing(56)_1fr_--spacing(64)] xl:[grid-template-areas:'sidebar_main_metabar''sidebar_footer_metabar'] 2xl:grid-cols-[--spacing(72)_1fr_--spacing(72)]; @@ -72,3 +75,24 @@ dark:bg-neutral-950; } } + +/* Article layout with collapsed sidebars */ +.articleLayout[data-left-sidebar-collapsed='true'] { + @apply ml:grid-cols-[--spacing(11)_1fr] + 3xl:grid-cols-[--spacing(11)_1fr_--spacing(144)] + xl:grid-cols-[--spacing(11)_1fr_--spacing(64)] + 2xl:grid-cols-[--spacing(11)_1fr_--spacing(72)]; +} + +.articleLayout[data-right-sidebar-collapsed='true'] { + @apply 3xl:grid-cols-[--spacing(80)_1fr_--spacing(11)] + xl:grid-cols-[--spacing(56)_1fr_--spacing(11)] + 2xl:grid-cols-[--spacing(72)_1fr_--spacing(11)]; +} + +.articleLayout[data-left-sidebar-collapsed='true'][data-right-sidebar-collapsed='true'] { + @apply ml:grid-cols-[--spacing(11)_1fr_--spacing(11)] + 3xl:grid-cols-[--spacing(11)_1fr_--spacing(11)] + xl:grid-cols-[--spacing(11)_1fr_--spacing(11)] + 2xl:grid-cols-[--spacing(11)_1fr_--spacing(11)]; +} diff --git a/packages/ui-components/src/Containers/Article/index.tsx b/packages/ui-components/src/Containers/Article/index.tsx index 7b31c225a914f..639a41b4fdec9 100644 --- a/packages/ui-components/src/Containers/Article/index.tsx +++ b/packages/ui-components/src/Containers/Article/index.tsx @@ -1,9 +1,16 @@ -import type { FC, PropsWithChildren } from 'react'; +import type { FC, PropsWithChildren, HTMLAttributes } from 'react'; import styles from './index.module.css'; -const ArticleLayout: FC = ({ children }) => ( -
{children}
+type ArticleLayoutProps = HTMLAttributes; + +const ArticleLayout: FC> = ({ + children, + ...props +}) => ( +
+ {children} +
); export default ArticleLayout; diff --git a/packages/ui-components/src/Containers/MetaBar/index.tsx b/packages/ui-components/src/Containers/MetaBar/index.tsx index 1fb1dbdb3f9e7..01d4b823f1d97 100644 --- a/packages/ui-components/src/Containers/MetaBar/index.tsx +++ b/packages/ui-components/src/Containers/MetaBar/index.tsx @@ -5,7 +5,7 @@ import { CODE_LIKE_TYPES } from '#ui/constants'; import type { LinkLike } from '#ui/types'; import type { Heading } from '@vcarl/remark-headings'; -import type { FC, HTMLAttributes } from 'react'; +import type { FC, HTMLAttributes, PropsWithChildren } from 'react'; import styles from './index.module.css'; @@ -19,11 +19,12 @@ type MetaBarProps = { heading: string; } & HTMLAttributes; -const MetaBar: FC = ({ +const MetaBar: FC> = ({ items, headings, as: Component = 'a', heading, + children, ...props }) => { // The default depth of headings to display in the table of contents. @@ -36,6 +37,7 @@ const MetaBar: FC = ({ return (