diff --git a/.changeset/lovely-feet-float.md b/.changeset/lovely-feet-float.md new file mode 100644 index 000000000..86f89a7d6 --- /dev/null +++ b/.changeset/lovely-feet-float.md @@ -0,0 +1,5 @@ +--- +'@theguild/components': minor +--- + +Extract Tabs component from Nextra diff --git a/package.json b/package.json index 6072489e2..9bfb0ca89 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@storybook/nextjs": "8.4.2", "@storybook/preview-api": "8.4.2", "@storybook/react": "8.4.2", + "@storybook/test": "^8.4.2", "@storybook/theming": "8.4.2", "@svgr/webpack": "8.1.0", "@theguild/eslint-config": "0.13.2", diff --git a/packages/components/package.json b/packages/components/package.json index aa3c9f453..c5876ea93 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -44,6 +44,7 @@ "types:check": "tsc --noEmit" }, "peerDependencies": { + "@headlessui/react": "2.2.0", "@theguild/tailwind-config": "^0.6.3", "next": "^13 || ^14 || ^15.0.0", "react": "^18.2.0", diff --git a/packages/components/src/components/legacy-package-cmd.tsx b/packages/components/src/components/legacy-package-cmd.tsx index a19b639e2..ab43e8f62 100644 --- a/packages/components/src/components/legacy-package-cmd.tsx +++ b/packages/components/src/components/legacy-package-cmd.tsx @@ -1,5 +1,6 @@ import { ReactElement, useMemo } from 'react'; -import { Pre, Tabs } from 'nextra/components'; +import { Pre } from 'nextra/components'; +import { Tabs } from './tabs'; const PACKAGE_MANAGERS = ['yarn', 'npm', 'pnpm']; diff --git a/packages/components/src/components/marketplace-search.tsx b/packages/components/src/components/marketplace-search.tsx index 5c982bb6c..9c7f108b7 100644 --- a/packages/components/src/components/marketplace-search.tsx +++ b/packages/components/src/components/marketplace-search.tsx @@ -2,12 +2,12 @@ import { isValidElement, ReactElement, useMemo, useState } from 'react'; import fuzzy from 'fuzzy'; -import { Tabs } from 'nextra/components'; import { cn } from '../cn'; import { IMarketplaceListProps, IMarketplaceSearchProps } from '../types/components'; import { Heading } from './heading'; import { CloseIcon, SearchIcon } from './icons'; import { MarketplaceList } from './marketplace-list'; +import { Tabs } from './tabs'; import { Tag, TagsContainer } from './tag'; const renderQueryPlaceholder = (placeholder: string | ReactElement, query: string) => { diff --git a/packages/components/src/components/tabs/index.client.tsx b/packages/components/src/components/tabs/index.client.tsx new file mode 100644 index 000000000..1552518e5 --- /dev/null +++ b/packages/components/src/components/tabs/index.client.tsx @@ -0,0 +1,338 @@ +'use client'; + +import { + FC, + Fragment, + ReactElement, + ReactNode, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, +} from 'react'; +import { useSearchParams } from 'next/navigation'; +import cn from 'clsx'; +import { + Tab as HeadlessTab, + TabProps as HeadlessTabProps, + TabGroup, + TabGroupProps, + TabList, + TabListProps, + TabPanel, + TabPanelProps, + TabPanels, + // this component is almost verbatim copied from Nextra, so keep @headlessui/react to guarantee it works the same +} from '@headlessui/react'; +import { useHash } from '../use-hash'; + +type TabItem = string | ReactElement; + +type TabObjectItem = { + key?: string; + label: TabItem; + disabled: boolean; +}; + +function isTabObjectItem(item: unknown): item is TabObjectItem { + return !!item && typeof item === 'object' && 'label' in item; +} + +export interface TabsProps + extends Pick { + items: (TabItem | TabObjectItem)[]; + children: ReactNode; + /** + * URLSearchParams key for persisting the selected tab. + * @default "tab" + */ + searchParamKey?: string; + /** + * LocalStorage key for persisting the selected tab. + * Defaults to `tabs-${id}` if not provided. + * Set to `null` to disable localStorage persistence. + */ + storageKey?: string | null; + /** Tabs CSS class name. */ + className?: TabListProps['className']; + /** Tab CSS class name. */ + tabClassName?: HeadlessTabProps['className']; +} + +export const Tabs = ({ + items, + children, + searchParamKey = 'tab', + storageKey, + defaultIndex = 0, + selectedIndex: _selectedIndex, + onChange, + className, + tabClassName, +}: TabsProps) => { + const id = useId(); + + if (storageKey === undefined) { + storageKey = `tabs-${id}`; + } + + let [selectedIndex, setSelectedIndex] = useState(defaultIndex); + if (_selectedIndex !== undefined) { + selectedIndex = _selectedIndex; + } + + const tabPanelsRef = useRef(null!); + + const tabIndexFromSearchParams = useActiveTabFromURL( + tabPanelsRef, + items, + searchParamKey, + setSelectedIndex, + id, + ); + + useActiveTabFromStorage(storageKey, items, setSelectedIndex, tabIndexFromSearchParams !== -1, id); + + const handleChange = (index: number) => { + onChange?.(index); + + if (storageKey) { + const newValue = getTabKey(items, index, id); + localStorage.setItem(storageKey, newValue); + + // the storage event only get picked up (by the listener) if the localStorage was changed in + // another browser's tab/window (of the same app), but not within the context of the current tab. + window.dispatchEvent(new StorageEvent('storage', { key: storageKey, newValue })); + } else { + setSelectedIndex(index); + } + + if (searchParamKey) { + const searchParams = new URLSearchParams(window.location.search); + const tabKeys = new Set(searchParams.getAll(searchParamKey)); + + // we remove only tabs from this list from search params + for (let i = 0; i < items.length; i++) { + const key = getTabKey(items, i, id); + tabKeys.delete(key); + } + + // we add tabs from outside of this list back + searchParams.delete(searchParamKey); + for (const key of tabKeys) { + searchParams.append(searchParamKey, key); + } + + // and finally, we add the clicked tab + searchParams.append(searchParamKey, getTabKey(items, index, id)); + + window.history.replaceState( + null, + '', + `${window.location.pathname}?${searchParams.toString()}`, + ); + } + }; + + return ( + + + cn( + 'nextra-scrollbar overflow-x-auto overflow-y-hidden overscroll-x-contain', + 'mt-4 flex w-full gap-2 border-b border-beige-200 pb-px dark:border-neutral-800', + 'focus-visible:hive-focus', + typeof className === 'function' ? className(args) : className, + ) + } + > + {items.map((item, index) => ( + { + const { selected, disabled, hover, focus } = args; + return cn( + focus && 'hive-focus ring-inset', + 'cursor-pointer whitespace-nowrap', + 'rounded-t p-2 font-medium leading-5 transition-colors', + '-mb-0.5 select-none border-b-2', + selected + ? 'border-current outline-none' + : hover + ? 'border-beige-200 dark:border-neutral-800' + : 'border-transparent', + selected + ? 'text-green-900 dark:text-primary' + : disabled + ? 'pointer-events-none text-beige-400 dark:text-neutral-600' + : hover + ? 'text-black dark:text-white' + : 'text-beige-600 dark:text-beige-200', + typeof tabClassName === 'function' ? tabClassName(args) : tabClassName, + ); + }} + > + {isTabObjectItem(item) ? item.label : item} + + ))} + + {children} + + ); +}; + +export const Tab: FC = ({ + children, + // For SEO display all the Panel in the DOM and set `display: none;` for those that are not selected + unmount = false, + className, + ...props +}) => { + return ( + + cn( + 'mt-[1.25em] rounded', + args.focus && 'hive-focus', + typeof className === 'function' ? className(args) : className, + ) + } + > + {children} + + ); +}; + +function useActiveTabFromURL( + tabPanelsRef: React.RefObject, + items: (TabItem | TabObjectItem)[], + searchParamKey: string, + setSelectedIndex: (index: number) => void, + id: string, +) { + const hash = useHash(); + const searchParams = useSearchParams(); + const tabsInSearchParams = searchParams.getAll(searchParamKey).sort(); + + const tabIndexFromSearchParams = items.findIndex((_, index) => + tabsInSearchParams.includes(getTabKey(items, index, id)), + ); + + useIsomorphicLayoutEffect(() => { + const tabPanel = hash + ? tabPanelsRef.current?.querySelector(`[role=tabpanel]:has([id="${hash}"])`) + : null; + + if (tabPanel) { + let index = 0; + for (const el of tabPanelsRef.current!.children) { + if (el === tabPanel) { + setSelectedIndex(Number(index)); + // Note for posterity: + // This is not an infinite loop. Clearing and restoring the hash is necessary + // for the browser to scroll to the element. The intermediate empty hash triggers + // a hashchange event, but we don't look for a tab panel if there is no hash. + + // Clear hash first, otherwise page isn't scrolled + location.hash = ''; + // Execute on next tick after `selectedIndex` update + requestAnimationFrame(() => (location.hash = `#${hash}`)); + } + index++; + } + } else if (tabIndexFromSearchParams !== -1) { + // if we don't have content to scroll to, we look at the search params + setSelectedIndex(tabIndexFromSearchParams); + } + + return function cleanUpTabFromSearchParams() { + const newSearchParams = new URLSearchParams(window.location.search); + newSearchParams.delete(searchParamKey); + window.history.replaceState( + null, + '', + `${window.location.pathname}?${newSearchParams.toString()}`, + ); + }; + // tabPanelsRef is a ref, so it's not a dependency + }, [hash, tabsInSearchParams.join(',')]); + + return tabIndexFromSearchParams; +} + +function useActiveTabFromStorage( + storageKey: string | null, + items: (TabItem | TabObjectItem)[], + setSelectedIndex: (index: number) => void, + ignoreLocalStorage: boolean, + id: string, +) { + useIsomorphicLayoutEffect(() => { + if (!storageKey || ignoreLocalStorage) { + // Do not listen storage events if there is no storage key + return; + } + + const setSelectedTab = (key: string) => { + const index = items.findIndex((_, i) => getTabKey(items, i, id) === key); + if (index !== -1) { + setSelectedIndex(index); + } + }; + + function onStorageChange(event: StorageEvent) { + if (event.key === storageKey) { + const value = event.newValue; + if (value) { + setSelectedTab(value); + } + } + } + + const value = localStorage.getItem(storageKey); + if (value) { + setSelectedTab(value); + } + + window.addEventListener('storage', onStorageChange); + return () => { + window.removeEventListener('storage', onStorageChange); + }; + }, [storageKey]); +} + +type TabKey = string & { __brand: 'TabKey' }; + +function getTabKey(items: (TabItem | TabObjectItem)[], index: number, prefix: string): TabKey { + const item = items[index]; + const isObject = isTabObjectItem(item); + // if the key is defined by user, we use it + if (isObject && item.key) { + return item.key as TabKey; + } + const label = isObject ? item.label : item; + // otherwise we use the slugified label prefixed by the tab group id, if the label is a string + // or the index of the item in the items array prefixed by the tab group id if the label is a ReactElement + const key = typeof label === 'string' ? slugify(label) : `${prefix}-${index.toString()}`; + return key as TabKey; +} + +function slugify(label: string) { + return label + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') // strip accents + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect; diff --git a/packages/components/src/components/tabs/index.tsx b/packages/components/src/components/tabs/index.tsx new file mode 100644 index 000000000..d21eb5ae0 --- /dev/null +++ b/packages/components/src/components/tabs/index.tsx @@ -0,0 +1,55 @@ +'use no memo'; + +import { ComponentProps } from 'react'; +import { Tabs as _Tabs, Tab } from './index.client'; + +export type { TabsProps } from './index.client'; + +// Workaround to fix +// Error: Cannot access Tab.propTypes on the server. You cannot dot into a client module from a +// server component. You can only pass the imported name through. +/** + * A built-in component for creating tabbed content, helping organize related information in a + * compact, interactive layout. + * + * @example + * + * **pnpm**: Fast, disk space efficient package manager. + * **npm** is a package manager for the JavaScript programming language. + * **Yarn** is a software packaging system. + * + * + * @usage + * ```mdx + * import { Tabs } from '@theguild/components' + * + * + * **pnpm**: Fast, disk space efficient package manager. + * **npm** is a package manager for the JavaScript programming language. + * **Yarn** is a software packaging system. + * + * ``` + * + * ### Default Selected Index + * + * You can use the `defaultIndex` prop to set the default tab index: + * + * ```mdx /defaultIndex="1"/ + * import { Tabs } from '@theguild/components' + * + * + * ... + * + * ``` + * + * And you will have `npm` as the default tab: + * + * + * **pnpm**: Fast, disk space efficient package manager. + * **npm** is a package manager for the JavaScript programming language. + * **Yarn** is a software packaging system. + * + */ +export const Tabs = Object.assign((props: ComponentProps) => <_Tabs {...props} />, { + Tab, +}); diff --git a/packages/components/src/components/tabs/tabs.stories.tsx b/packages/components/src/components/tabs/tabs.stories.tsx new file mode 100644 index 000000000..a9784cb13 --- /dev/null +++ b/packages/components/src/components/tabs/tabs.stories.tsx @@ -0,0 +1,544 @@ +import React from 'react'; +import { Meta, StoryObj } from '@storybook/react'; +import { expect, fn, userEvent, within } from '@storybook/test'; +import { hiveThemeDecorator } from '../../../../../.storybook/hive-theme-decorator'; +import { CallToAction } from '../call-to-action'; +import { Tabs, TabsProps } from './index'; + +export default { + title: 'Components/Tabs', + component: Tabs, + decorators: [hiveThemeDecorator], + parameters: { + padding: true, + nextjs: { + appDirectory: true, + navigation: { + query: { + tab: '', + }, + }, + }, + }, + argTypes: { + items: { + control: 'object', + description: 'Array of tab labels (strings or React elements)', + }, + defaultIndex: { + control: 'number', + description: 'Default selected tab index', + }, + storageKey: { + control: 'text', + description: 'localStorage key for persisting the selected tab', + }, + }, +} satisfies Meta; + +type Story = StoryObj; + +/** + * Basic tabs with package manager examples + */ +export const Basic: Story = { + args: { + items: ['pnpm', 'npm', 'yarn'], + children: ( + <> + + pnpm: Fast, disk space efficient package manager. +
+            pnpm install
+          
+
+ + npm is a package manager for the JavaScript programming language. +
+            npm install
+          
+
+ + Yarn used to have funny emojis and then it had a lot of major versions. +
+            yarn install
+          
+
+ + ), + }, +}; + +/** + * Tabs with a default selected index + */ +export const WithDefaultIndex: Story = { + args: { + items: ['pnpm', 'npm', 'yarn'], + defaultIndex: 1, + children: ( + <> + pnpm content + npm content (default selected) + yarn content + + ), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Check that the second tab is selected by default + const npmTab = canvas.getByRole('tab', { name: 'npm' }); + await expect(npmTab).toHaveAttribute('aria-selected', 'true'); + + // Check that the npm content is visible + const npmContent = canvas.getByText('npm content (default selected)'); + await expect(npmContent).toBeVisible(); + }, +}; + +/** + * Tabs with disabled items + */ +export const WithDisabledTabs: Story = { + args: { + items: ['Active Tab', { label: 'Disabled Tab', disabled: true }, 'Another Active Tab'], + children: ( + <> + Content for active tab + Content for disabled tab + Content for another active tab + + ), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Check that the disabled tab has the correct attribute + const disabledTab = canvas.getByRole('tab', { name: 'Disabled Tab' }); + await expect(disabledTab).toHaveAttribute('disabled', ''); + + // Try to click the disabled tab - it should not become selected + + const activeTab = canvas.getByRole('tab', { name: 'Active Tab' }); + await expect(activeTab).toHaveAttribute('aria-selected', 'true'); + }, +}; + +/** + * Test tab switching interaction + */ +export const TabSwitching: Story = { + args: { + items: ['First', 'Second', 'Third'], + onChange: fn(), + children: ( + <> + First panel content + Second panel content + Third panel content + + ), + }, + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement); + + const secondTab = canvas.getByRole('tab', { name: 'Second' }); + await userEvent.click(secondTab); + + await expect(args.onChange).toHaveBeenCalledWith(1); + await expect(secondTab).toHaveAttribute('aria-selected', 'true'); + + const thirdTab = canvas.getByRole('tab', { name: 'Third' }); + await userEvent.click(thirdTab); + + await expect(args.onChange).toHaveBeenCalledWith(2); + await expect(canvas.getByText('Third panel content')).toBeVisible(); + await expect(thirdTab).toHaveAttribute('aria-selected', 'true'); + }, +}; + +/** + * Test URL search params synchronization + * This tests the complex logic of syncing tab state with URLSearchParams + */ +export const URLSearchParamsSync: Story = { + args: { + items: ['react', 'vue', 'angular'], + searchParamKey: 'framework', + children: ( + <> + React is a JavaScript library for building user interfaces. + Vue.js is a progressive JavaScript framework. + Angular is a platform for building mobile and desktop web applications. + + ), + }, + parameters: { + nextjs: { + appDirectory: true, + navigation: { + query: { + framework: 'vue', + }, + }, + }, + }, +}; + +/** + * Test localStorage persistence + * This tests the complex logic of syncing tab state across browser tabs/windows + */ +export const LocalStoragePersistence: Story = { + args: { + items: ['Tab 1', 'Tab 2', 'Tab 3'], + storageKey: 'test-tabs-storage', + children: ( + <> + Content 1 + Content 2 + Content 3 + + ), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Clear localStorage first + localStorage.removeItem('test-tabs-storage'); + + // Click on Tab 2 + const tab2 = canvas.getByRole('tab', { name: 'Tab 2' }); + await userEvent.click(tab2); + + // Check that the value was stored in localStorage (slugified key) + const storedValue = localStorage.getItem('test-tabs-storage'); + await expect(storedValue).toBe('tab-2'); + + // Simulate storage event from another tab/window + window.dispatchEvent( + new StorageEvent('storage', { + key: 'test-tabs-storage', + newValue: 'tab-3', + }), + ); + + // Wait a bit for the effect to trigger + await new Promise(resolve => setTimeout(resolve, 100)); + + // Tab 3 should now be selected + const tab3 = canvas.getByRole('tab', { name: 'Tab 3' }); + await expect(tab3).toHaveAttribute('aria-selected', 'true'); + + // Clean up + localStorage.removeItem('test-tabs-storage'); + }, +}; + +/** + * Tabs with custom keys + * Tests the custom key feature for tab identification + */ +export const WithCustomKeys: Story = { + args: { + items: [ + { label: 'Getting Started', key: 'intro', disabled: false }, + { label: 'API Reference', key: 'api', disabled: false }, + { label: 'Examples', key: 'examples', disabled: false }, + ], + searchParamKey: 'section', + children: ( + <> + Introduction and getting started guide + Complete API documentation + Code examples and tutorials + + ), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Clear search params + const url = new URL(window.location.href); + url.searchParams.delete('section'); + window.history.replaceState(null, '', url.toString()); + + // Click on API Reference + const apiTab = canvas.getByRole('tab', { name: 'API Reference' }); + await userEvent.click(apiTab); + + // URL should use the custom key 'api' instead of slugified label + await expect(window.location.search).toContain('section=api'); + + // Click on Examples + const examplesTab = canvas.getByRole('tab', { name: 'Examples' }); + await userEvent.click(examplesTab); + + await expect(window.location.search).toContain('section=examples'); + }, +}; + +/** + * Test controlled mode + * Tabs can be controlled externally via selectedIndex prop + */ +export const ControlledMode: Story = { + render: function ControlledTabs() { + const [selectedIndex, setSelectedIndex] = React.useState(0); + + return ( +
+
+ setSelectedIndex(0)} variant="tertiary"> + Select Tab 1 + + setSelectedIndex(1)} variant="tertiary"> + Select Tab 2 + + setSelectedIndex(2)} variant="tertiary"> + Select Tab 3 + +
+ + Content 1 + Content 2 + Content 3 + +
+ ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Initially Tab 1 should be selected + const tab1 = canvas.getByRole('tab', { name: 'Tab 1' }); + await expect(tab1).toHaveAttribute('aria-selected', 'true'); + + // Click external button to select Tab 2 + const selectTab2Button = canvas.getByRole('button', { name: 'Select Tab 2' }); + await userEvent.click(selectTab2Button); + + // Tab 2 should now be selected + const tab2 = canvas.getByRole('tab', { name: 'Tab 2' }); + await expect(tab2).toHaveAttribute('aria-selected', 'true'); + + // Click on Tab 3 directly + const tab3 = canvas.getByRole('tab', { name: 'Tab 3' }); + await userEvent.click(tab3); + + // Tab 3 should now be selected + await expect(tab3).toHaveAttribute('aria-selected', 'true'); + + // Use external button to go back to Tab 1 + const selectTab1Button = canvas.getByRole('button', { name: 'Select Tab 1' }); + await userEvent.click(selectTab1Button); + + await expect(tab1).toHaveAttribute('aria-selected', 'true'); + }, +}; + +/** + * Tabs with React elements as labels + */ +export const WithReactElementLabels: Story = { + args: { + searchParamKey: 'benefit', + items: [ + + 🚀 Fast + , + + 🔒 Secure + , + + 📦 Reliable + , + ], + children: ( + <> + Lightning-fast performance + Enterprise-grade security + 99.9% uptime SLA + + ), + }, + parameters: { + nextjs: { + appDirectory: true, + navigation: { + query: { + benefit: ':r0:-2', + }, + }, + }, + }, +}; + +/** + * Many tabs with horizontal scrolling + */ +export const ManyTabs: Story = { + args: { + items: [ + 'JavaScript', + 'TypeScript', + 'Python', + 'Rust', + 'Go', + 'Java', + 'C++', + 'Ruby', + 'PHP', + 'Swift', + ], + defaultIndex: 4, + children: ( + <> + JavaScript content + TypeScript content + Python content + Rust content + Go content + Java content + C++ content + Ruby content + PHP content + Swift content + + ), + }, +}; + +/** + * Tabs with custom styling + */ +export const CustomStyling: Story = { + args: { + items: ['Design', 'Develop', 'Deploy'], + className: 'border-b-4 gap-0.5 border-green-800', + tabClassName: args => + args.selected + ? 'bg-green-800 text-white rounded-t-none' + : 'bg-gray-100 text-gray-700 rounded-t-none', + children: ( + <> + Design your application + Develop with modern tools + Deploy to production + + ), + }, +}; + +export const TabsSyncedWithStorageEvents: Story = { + args: { + items: ['Tab 1', 'Tab 2', 'Tab 3'], + storageKey: 'test-tabs-storage', + searchParamKey: 'package-manager', + }, + render() { + return ( +
+ + {['pnpm', 'npm', 'yarn'].map(item => ( + {item} + ))} + + + {['pnpm', 'npm', 'yarn'].map(item => ( + {item} + ))} + +
+ This one doesn't have a `storageKey`, so it's not connected: + + {['pnpm', 'npm', 'yarn'].map(item => ( + {item} + ))} + +
+ ); + }, +}; + +export const ContentInHiddenPanelOpensByHash: Story = { + args: { + items: ['Docker', 'Binary'], + children: ( + <> + + + Navigate to #binary + + + +
Binary
+
+ + ), + }, +}; + +export const MultipleTabsInParams: Story = { + parameters: { + nextjs: { + appDirectory: true, + navigation: { + query: 'tab=shrimp&tab=brown-rice&tab=mango&tab=ponzu', + }, + }, + }, + render() { + return ( +
+
+ Protein + + 🐟 + 🐠 + 🧈 + 🦐 + +
+ +
+ Base + + 🍚 + 🍘 + 🥬 + 🥒 + +
+ +
+ Toppings + + 🫘 + 🥑 + 🥒 + 🥭 + +
+ +
+ Sauce + + 🍶 + 🍋 + 🌶️ + 🫚 + +
+
+ ); + }, +}; diff --git a/packages/components/src/components/use-hash.ts b/packages/components/src/components/use-hash.ts new file mode 100644 index 000000000..188f953e7 --- /dev/null +++ b/packages/components/src/components/use-hash.ts @@ -0,0 +1,20 @@ +'use client'; + +// don't need to memoize string `hash` value +'use no memo'; + +import { useEffect, useState } from 'react'; + +export function useHash() { + const [hash, setHash] = useState(''); + + useEffect(() => { + const handleHashChange = () => setHash(location.hash.replace('#', '')); + handleHashChange(); + + window.addEventListener('hashchange', handleHashChange); + return () => window.removeEventListener('hashchange', handleHashChange); + }, []); + + return hash; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf7ffe865..c19a577ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,6 +48,9 @@ importers: '@storybook/react': specifier: 8.4.2 version: 8.4.2(@storybook/test@8.4.2(storybook@8.4.2(prettier@3.6.2)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.4.2(prettier@3.6.2))(typescript@5.9.2) + '@storybook/test': + specifier: ^8.4.2 + version: 8.4.2(storybook@8.4.2(prettier@3.6.2)) '@storybook/theming': specifier: 8.4.2 version: 8.4.2(storybook@8.4.2(prettier@3.6.2)) @@ -162,6 +165,9 @@ importers: '@giscus/react': specifier: 3.1.0 version: 3.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@headlessui/react': + specifier: 2.2.0 + version: 2.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@next/bundle-analyzer': specifier: 15.1.5 version: 15.1.5 @@ -182,10 +188,10 @@ importers: version: 0.1.3 nextra: specifier: 4.0.5 - version: 4.0.5(acorn@8.14.1)(next@15.1.5(@babel/core@7.28.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.2) + version: 4.0.5(acorn@8.14.1)(next@15.1.5(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.2) nextra-theme-docs: specifier: 4.0.5 - version: 4.0.5(@types/react@18.3.18)(next@15.1.5(@babel/core@7.28.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nextra@4.0.5(acorn@8.14.1)(next@15.1.5(@babel/core@7.28.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 4.0.5(@types/react@18.3.18)(next@15.1.5(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nextra@4.0.5(acorn@8.14.1)(next@15.1.5(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-paginate: specifier: 8.2.0 version: 8.2.0(react@18.3.1) @@ -237,7 +243,7 @@ importers: version: 16.11.0 next: specifier: 15.1.5 - version: 15.1.5(@babel/core@7.28.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 15.1.5(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: specifier: 18.3.1 version: 18.3.1 @@ -6023,6 +6029,7 @@ packages: mathjax-full@3.2.2: resolution: {integrity: sha512-+LfG9Fik+OuI8SLwsiR02IVdjcnRCy5MufYLi0C3TdMT56L/pjB0alMVGgoWJF8pN9Rc7FESycZB9BMNWIid5w==} + deprecated: Version 4 replaces this package with the scoped package @mathjax/src md5.js@1.3.5: resolution: {integrity: sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==} @@ -12137,7 +12144,7 @@ snapshots: '@testing-library/dom@10.4.0': dependencies: - '@babel/code-frame': 7.26.2 + '@babel/code-frame': 7.27.1 '@babel/runtime': 7.26.10 '@types/aria-query': 5.0.4 aria-query: 5.3.0 @@ -16705,31 +16712,6 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@15.1.5(@babel/core@7.28.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - '@next/env': 15.1.5 - '@swc/counter': 0.1.3 - '@swc/helpers': 0.5.15 - busboy: 1.6.0 - caniuse-lite: 1.0.30001706 - postcss: 8.4.31 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - styled-jsx: 5.1.6(@babel/core@7.28.0)(react@18.3.1) - optionalDependencies: - '@next/swc-darwin-arm64': 15.1.5 - '@next/swc-darwin-x64': 15.1.5 - '@next/swc-linux-arm64-gnu': 15.1.5 - '@next/swc-linux-arm64-musl': 15.1.5 - '@next/swc-linux-x64-gnu': 15.1.5 - '@next/swc-linux-x64-musl': 15.1.5 - '@next/swc-win32-arm64-msvc': 15.1.5 - '@next/swc-win32-x64-msvc': 15.1.5 - sharp: 0.33.5 - transitivePeerDependencies: - - '@babel/core' - - babel-plugin-macros - nextra-theme-docs@3.0.0-alpha.32(next@15.1.5(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nextra@3.0.0-alpha.32(@types/react@18.3.18)(acorn@8.14.1)(next@15.1.5(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@headlessui/react': 1.7.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -16747,13 +16729,13 @@ snapshots: scroll-into-view-if-needed: 3.1.0 zod: 3.24.2 - nextra-theme-docs@4.0.5(@types/react@18.3.18)(next@15.1.5(@babel/core@7.28.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nextra@4.0.5(acorn@8.14.1)(next@15.1.5(@babel/core@7.28.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + nextra-theme-docs@4.0.5(@types/react@18.3.18)(next@15.1.5(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nextra@4.0.5(acorn@8.14.1)(next@15.1.5(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@headlessui/react': 2.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) clsx: 2.1.1 - next: 15.1.5(@babel/core@7.28.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + next: 15.1.5(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-themes: 0.4.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - nextra: 4.0.5(acorn@8.14.1)(next@15.1.5(@babel/core@7.28.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.2) + nextra: 4.0.5(acorn@8.14.1)(next@15.1.5(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.2) react: 18.3.1 react-compiler-runtime: 0.0.0-experimental-22c6e49-20241219(react@18.3.1) react-dom: 18.3.1(react@18.3.1) @@ -16857,53 +16839,6 @@ snapshots: - supports-color - typescript - nextra@4.0.5(acorn@8.14.1)(next@15.1.5(@babel/core@7.28.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.2): - dependencies: - '@formatjs/intl-localematcher': 0.5.10 - '@headlessui/react': 2.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@mdx-js/mdx': 3.1.0(acorn@8.14.1) - '@napi-rs/simple-git': 0.1.19 - '@shikijs/twoslash': 1.29.2(typescript@5.9.2) - '@theguild/remark-mermaid': link:packages/remark-mermaid - '@theguild/remark-npm2yarn': link:packages/remark-npm2yarn - better-react-mathjax: 2.1.0(react@18.3.1) - clsx: 2.1.1 - estree-util-to-js: 2.0.0 - estree-util-value-to-estree: 3.3.2 - fast-glob: 3.3.3 - github-slugger: 2.0.0 - hast-util-to-estree: 3.1.3 - katex: 0.16.21 - mdast-util-from-markdown: 2.0.2 - mdast-util-gfm: 3.1.0 - mdast-util-to-hast: 13.2.0 - negotiator: 1.0.0 - next: 15.1.5(@babel/core@7.28.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-compiler-runtime: 0.0.0-experimental-22c6e49-20241219(react@18.3.1) - react-dom: 18.3.1(react@18.3.1) - react-medium-image-zoom: 5.2.14(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - rehype-katex: 7.0.1 - rehype-pretty-code: 0.14.0(shiki@1.29.2) - rehype-raw: 7.0.0 - remark-frontmatter: 5.0.0 - remark-gfm: 4.0.1 - remark-math: 6.0.0 - remark-reading-time: 2.0.1 - remark-smartypants: 3.0.2 - shiki: 1.29.2 - slash: 5.1.0 - title: 4.0.1 - unist-util-remove: 4.0.0 - unist-util-visit: 5.0.0 - yaml: 2.7.0 - zod: 3.24.2 - zod-validation-error: 3.4.0(zod@3.24.2) - transitivePeerDependencies: - - acorn - - supports-color - - typescript - nlcst-to-string@4.0.0: dependencies: '@types/nlcst': 2.0.3 @@ -18588,13 +18523,6 @@ snapshots: optionalDependencies: '@babel/core': 7.26.10 - styled-jsx@5.1.6(@babel/core@7.28.0)(react@18.3.1): - dependencies: - client-only: 0.0.1 - react: 18.3.1 - optionalDependencies: - '@babel/core': 7.28.0 - stylis@4.3.6: {} sucrase@3.35.0: