diff --git a/apps/site/app/[locale]/layout.tsx b/apps/site/app/[locale]/layout.tsx index 3e1c358b1103f..6f46973abc806 100644 --- a/apps/site/app/[locale]/layout.tsx +++ b/apps/site/app/[locale]/layout.tsx @@ -6,6 +6,7 @@ import { getLocale } from 'next-intl/server'; import BaseLayout from '#site/layouts/Base'; import { IBM_PLEX_MONO, OPEN_SANS } from '#site/next.fonts'; +import { HashProvider } from '#site/providers/hashProvider'; import { ThemeProvider } from '#site/providers/themeProvider'; import type { FC, PropsWithChildren } from 'react'; @@ -29,9 +30,11 @@ const RootLayout: FC = async ({ children }) => { > - - {children} - + + + {children} + + void>(); +let isListening = false; + +const onHashChange = () => { + listeners.forEach(rerender => rerender()); +}; + +const subscribe = (callback: () => void) => { + listeners.add(callback); + if (!isListening && typeof window !== 'undefined') { + window.addEventListener('hashchange', onHashChange); + isListening = true; + } + return () => { + listeners.delete(callback); + if (listeners.size === 0 && typeof window !== 'undefined') { + window.removeEventListener('hashchange', onHashChange); + isListening = false; + } + }; +}; + +const getSnapshot = () => + typeof window !== 'undefined' ? window.location.hash.slice(1) : ''; +const getServerSnapshot = () => ''; + +const useHash = () => { + // Works in both client and server environments, provides the initial value for the store during server rendering and hydration avoiding hydration mismatches + const hash = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + + const setHash = (newHash: string) => { + if (typeof window !== 'undefined') { + window.history.replaceState(null, '', `#${newHash}`); + onHashChange(); + } + }; + + return [hash, setHash] as const; +}; + +export default useHash; diff --git a/apps/site/providers/hashProvider.tsx b/apps/site/providers/hashProvider.tsx new file mode 100644 index 0000000000000..ad4439dfb8d01 --- /dev/null +++ b/apps/site/providers/hashProvider.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { HashContext } from '@node-core/ui-components/contexts/HashContext'; + +import useHash from '#site/hooks/useHash'; + +import type { FC, PropsWithChildren } from 'react'; + +export const HashProvider: FC = ({ children }) => { + const [hash, setHash] = useHash(); + + return {children}; +}; diff --git a/packages/ui-components/src/Common/CodeTabs/index.tsx b/packages/ui-components/src/Common/CodeTabs/index.tsx index 12ff05973037e..910fda8abfc58 100644 --- a/packages/ui-components/src/Common/CodeTabs/index.tsx +++ b/packages/ui-components/src/Common/CodeTabs/index.tsx @@ -6,7 +6,7 @@ import styles from './index.module.css'; type CodeTabsProps = Pick< ComponentProps, - 'tabs' | 'defaultValue' | 'children' | 'addons' + 'tabs' | 'defaultValue' | 'value' | 'onValueChange' | 'children' | 'addons' >; const CodeTabs: FC = ({ ...props }) => ( diff --git a/packages/ui-components/src/Common/Tabs/index.tsx b/packages/ui-components/src/Common/Tabs/index.tsx index bd2f769b596a6..c20e40eeaccf2 100644 --- a/packages/ui-components/src/Common/Tabs/index.tsx +++ b/packages/ui-components/src/Common/Tabs/index.tsx @@ -7,6 +7,7 @@ import styles from './index.module.css'; type Tab = { key: string; + id?: string; label: string; secondaryLabel?: string; value?: string; @@ -34,6 +35,7 @@ const Tabs: FC> = ({ {tabs.map(tab => ( diff --git a/packages/ui-components/src/MDX/CodeTabs/CodeTabsWithHash.tsx b/packages/ui-components/src/MDX/CodeTabs/CodeTabsWithHash.tsx new file mode 100644 index 0000000000000..0794ded34a742 --- /dev/null +++ b/packages/ui-components/src/MDX/CodeTabs/CodeTabsWithHash.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { useCallback, useState } from 'react'; + +import CodeTabs from '#ui/Common/CodeTabs'; +import { useHashContext } from '#ui/contexts/HashContext'; + +import type { ComponentProps, FC } from 'react'; + +type CodeTabsWithHashProps = Omit, 'tabs'> & { + tabs: Array<{ key: string; label: string; id?: string }>; +}; + +const CodeTabsWithHash: FC = ({ + tabs, + defaultValue, + ...props +}) => { + const { hash, setHash } = useHashContext(); + const [activeTab, setActiveTab] = useState(defaultValue); + const [prevHash, setPrevHash] = useState(hash); + + if (hash !== prevHash) { + setPrevHash(hash); + const matched = tabs.find(t => t.id === hash); + if (matched && matched.key !== activeTab) { + setActiveTab(matched.key); + } + } + + const handleValueChange = useCallback( + (value: string) => { + setActiveTab(value); + const matched = tabs.find(t => t.key === value); + if (matched?.id) { + setHash(matched.id); + } + }, + [tabs, setHash] + ); + + return ( + + ); +}; + +export default CodeTabsWithHash; diff --git a/packages/ui-components/src/MDX/CodeTabs.tsx b/packages/ui-components/src/MDX/CodeTabs/index.tsx similarity index 82% rename from packages/ui-components/src/MDX/CodeTabs.tsx rename to packages/ui-components/src/MDX/CodeTabs/index.tsx index a4092ae6ece73..1042bcaff57ed 100644 --- a/packages/ui-components/src/MDX/CodeTabs.tsx +++ b/packages/ui-components/src/MDX/CodeTabs/index.tsx @@ -5,11 +5,14 @@ import CodeTabs from '#ui/Common/CodeTabs'; import type { FC, ReactElement } from 'react'; +import CodeTabsWithHash from './CodeTabsWithHash'; + type MDXCodeTabsProps = { children: Array>; languages: string; displayNames?: string; defaultTab?: string; + groupId?: string; }; const NAME_OVERRIDES: Record = { @@ -21,6 +24,7 @@ const MDXCodeTabs: FC = ({ displayNames: rawDisplayNames, children: codes, defaultTab = '0', + groupId, ...props }) => { const { tabs, languages } = useMemo(() => { @@ -44,14 +48,19 @@ const MDXCodeTabs: FC = ({ return { key: `${language}-${index}`, label, + id: + groupId && + `${groupId}-${language}-${index}`.replace(/[^a-zA-Z0-9-_]/g, '-'), }; }); return { tabs, languages }; - }, [rawLanguages, rawDisplayNames]); + }, [rawLanguages, rawDisplayNames, groupId]); + + const Component = groupId ? CodeTabsWithHash : CodeTabs; return ( - = ({ {codes[index]} ))} - + ); }; diff --git a/packages/ui-components/src/contexts/HashContext.tsx b/packages/ui-components/src/contexts/HashContext.tsx new file mode 100644 index 0000000000000..7a738fd53eea7 --- /dev/null +++ b/packages/ui-components/src/contexts/HashContext.tsx @@ -0,0 +1,15 @@ +'use client'; + +import { createContext, use } from 'react'; + +type HashContextType = { + hash: string; + setHash: (newHash: string) => void; +}; + +export const HashContext = createContext({ + hash: '', + setHash: () => {}, +}); + +export const useHashContext = () => use(HashContext);