diff --git a/src/components/LibraryLayout.tsx b/src/components/LibraryLayout.tsx index 40eba3fb7..ddcc0cfd7 100644 --- a/src/components/LibraryLayout.tsx +++ b/src/components/LibraryLayout.tsx @@ -44,8 +44,11 @@ import { getLibraryLayoutVersion, getLibraryTabLinkOptions, getMenuGroupInitialOpenState, - isChartsCatalogTarget, } from './library-layout-navigation' +import { + customToolTabs, + isCustomToolTarget, +} from '~/libraries/custom-tool-tabs' // Number of days a doc page is flagged as "New"/"Updated" in the sidebar. const RECENCY_WINDOW_DAYS = 7 @@ -675,18 +678,6 @@ const useMenuConfig = ({ const currentFramework = useCurrentFramework(frameworks) const statsAvailable = getLibrary(libraryId as LibraryId).statsAvailable !== false - const chartsExamplesMenuItems: MenuItem['children'] = [ - { - label: 'Examples', - to: '/charts/catalog', - tab: 'home', - }, - { - label: 'Examples', - to: '/charts/catalog', - tab: 'examples', - }, - ] const localMenu: MenuItem = { label: 'Menu', @@ -695,7 +686,6 @@ const useMenuConfig = ({ label: 'Home', to: '..', }, - ...(libraryId === 'charts' ? chartsExamplesMenuItems : []), { label: 'Blog', to: '/$libraryId/$version/docs/blog', @@ -830,17 +820,7 @@ export function LibraryLayout({ const menuConfig = useMenuConfig({ config, frameworks, repo, libraryId }) const libraryGroupColor = categoryTextColor[categoryOf(libraryId)] - const isExample = matches.some( - (d) => - d.pathname.includes('/examples/') || - d.routeId.startsWith('/_library/charts/catalog'), - ) - - const isThemeEditor = matches.some((d) => - d.routeId.startsWith('/_library/highlight/$version/theme-editor'), - ) - - const isNpmStats = matches.some((d) => d.pathname.includes('/docs/npm-stats')) + const docsTab = matches.find((d) => d.staticData.docsTab)?.staticData.docsTab // The library blog already lists posts, so the "Latest Posts" rail widget is // redundant there — hide it on the blog while keeping it on other docs pages. @@ -872,60 +852,17 @@ export function LibraryLayout({ }, [closeMobileMenu, mobileMenuOpen]) const tabbedMenuConfig = React.useMemo(() => { - const tabs = getTabbedMenuConfig(menuConfig) - - if (libraryId === 'charts') { - return tabs.map((tab) => - tab.id === 'examples' - ? { - ...tab, - firstItem: { - label: 'Examples', - to: '/charts/catalog', - tab: 'examples', - }, - } - : tab, - ) - } - - if (libraryId === 'highlight') { - return [ - ...tabs, - { - id: 'theme-editor' as const, - label: 'Theme Editor', - groups: [], - firstItem: { - label: 'Theme Editor', - to: '/highlight/$version/theme-editor', - tab: 'theme-editor' as const, - }, - }, - ] - } - - return tabs + return getTabbedMenuConfig(menuConfig, customToolTabs[libraryId]) }, [libraryId, menuConfig]) const activeTabId = React.useMemo(() => { - if (isThemeEditor) { - return 'theme-editor' as const - } - return getActiveDocsNavTabId({ - isExample, + tabIdOverride: docsTab?.id, menuConfig, pathname: lastMatch.pathname, relativePathname, }) - }, [ - isExample, - isThemeEditor, - lastMatch.pathname, - menuConfig, - relativePathname, - ]) + }, [docsTab?.id, lastMatch.pathname, menuConfig, relativePathname]) const visibleMenuConfig = React.useMemo(() => { return ( @@ -964,11 +901,12 @@ export function LibraryLayout({ const groupInitialOpenState = React.useMemo(() => { return getMenuGroupInitialOpenState( + libraryId, visibleMenuConfig, _splat, lastMatch.pathname, ) - }, [lastMatch.pathname, visibleMenuConfig, _splat]) + }, [libraryId, lastMatch.pathname, visibleMenuConfig, _splat]) const [openGroups, setOpenGroups] = React.useState(groupInitialOpenState) @@ -1042,7 +980,7 @@ export function LibraryLayout({ ? ({ libraryId, version } as never) : undefined const isHomeLink = child.to === '..' - const isChartsExamplesLink = isChartsCatalogTarget(child.to) + const isCustomToolLink = isCustomToolTarget(libraryId, child.to) const frameworkDocsTarget = getFrameworkDocsLinkTarget(child.to) const recency = getDocRecency(child.addedAt, child.updatedAt) @@ -1130,14 +1068,12 @@ export function LibraryLayout({ ) : ( diff --git a/src/components/library-layout-navigation.ts b/src/components/library-layout-navigation.ts index 3adbfc764..f5c2fe3cf 100644 --- a/src/components/library-layout-navigation.ts +++ b/src/components/library-layout-navigation.ts @@ -1,3 +1,5 @@ +import { isCustomToolTarget } from '~/libraries/custom-tool-tabs' +import type { LibraryId } from '~/libraries/ids' import type { MenuItem } from '~/utils/config' export function isChartsCatalogTarget(to: string) { @@ -22,7 +24,7 @@ export function getLibraryTabLinkOptions({ version, to, }: { - libraryId: string + libraryId: LibraryId version: string to: string }) { @@ -34,7 +36,7 @@ export function getLibraryTabLinkOptions({ return { from: - isHomeTarget || isChartsCatalogTarget(to) + isHomeTarget || isCustomToolTarget(libraryId, to) ? undefined : '/$libraryId/$version/docs', to: isHomeTarget ? `/${libraryId}/${version}` : to, @@ -50,6 +52,7 @@ function normalizeMenuPath(path: string) { } export function isMenuTargetActive( + libraryId: LibraryId, to: string, relativePathname: string | undefined, pathname: string, @@ -59,12 +62,13 @@ export function isMenuTargetActive( } return ( - isChartsCatalogTarget(to) && + isCustomToolTarget(libraryId, to) && normalizeMenuPath(to) === normalizeMenuPath(pathname) ) } export function getMenuGroupInitialOpenState( + libraryId: LibraryId, groups: MenuItem[], relativePathname: string | undefined, pathname: string, @@ -73,7 +77,7 @@ export function getMenuGroupInitialOpenState( groups.forEach((group, index) => { const isChildActive = group.children.some((child) => - isMenuTargetActive(child.to, relativePathname, pathname), + isMenuTargetActive(libraryId, child.to, relativePathname, pathname), ) const key = `${index}:${String(group.label)}` diff --git a/src/libraries/custom-tool-tabs.ts b/src/libraries/custom-tool-tabs.ts new file mode 100644 index 000000000..fdd82f042 --- /dev/null +++ b/src/libraries/custom-tool-tabs.ts @@ -0,0 +1,29 @@ +import type { DocsNavTabId } from '~/utils/docsNavTabs' +import type { LibraryId } from './ids' +import type { FileRouteTypes } from '~/routeTree.gen' + +// A "custom tool tab" is a page bolted onto a library's docs nav bar that isn't sourced from the library's docs/config.json +export type CustomToolTabId = DocsNavTabId + +export type CustomToolTab = { + id: CustomToolTabId + label: string + to: FileRouteTypes['fullPaths'] +} + +export const customToolTabs: Partial> = { + charts: [{ id: 'examples', label: 'Examples', to: '/charts/catalog' }], + highlight: [ + { + id: 'theme-editor', + label: 'Theme Editor', + to: '/highlight/$version/theme-editor', + }, + ], +} + +export function isCustomToolTarget(libraryId: LibraryId, to: string) { + return (customToolTabs[libraryId] ?? []).some( + (tab) => to === tab.to || to.startsWith(`${tab.to}/`), + ) +} diff --git a/src/router.tsx b/src/router.tsx index d62b954ba..0362d4998 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -10,6 +10,8 @@ import { consumeStaleAppRecovery, } from './utils/stale-app-reload' import { redactByokRequestHeaders } from './utils/sentry-redaction' +import type { DocsNavTabId } from './utils/docsNavTabs' +import type { CustomToolTabId } from './libraries/custom-tool-tabs' if (typeof document !== 'undefined') { installStaleAppReloadHandlers() @@ -97,6 +99,13 @@ declare module '@tanstack/react-router' { showNavbar?: boolean includeSearchInCanonical?: boolean ownsCanonicalLink?: boolean + + // allows for the setting of custom routes to show up in the page tab + docsTab?: { + id: DocsNavTabId | CustomToolTabId + // config to disable clamping of page + fullBleed?: boolean + } } } diff --git a/src/routes/_library/$libraryId/$version.docs.framework.$framework.examples.$.tsx b/src/routes/_library/$libraryId/$version.docs.framework.$framework.examples.$.tsx index c00b040ab..39cd83e03 100644 --- a/src/routes/_library/$libraryId/$version.docs.framework.$framework.examples.$.tsx +++ b/src/routes/_library/$libraryId/$version.docs.framework.$framework.examples.$.tsx @@ -122,6 +122,7 @@ export const Route = createFileRoute( // /latest equivalent), so the root route must not emit its own. staticData: { ownsCanonicalLink: true, + docsTab: { id: 'examples', fullBleed: true }, }, validateSearch: v.object({ path: v.optional(v.string()), diff --git a/src/routes/_library/$libraryId/$version.docs.npm-stats.tsx b/src/routes/_library/$libraryId/$version.docs.npm-stats.tsx index 4df97a637..108698a58 100644 --- a/src/routes/_library/$libraryId/$version.docs.npm-stats.tsx +++ b/src/routes/_library/$libraryId/$version.docs.npm-stats.tsx @@ -142,6 +142,7 @@ export const Route = createFileRoute( component: RouteComponent, staticData: { includeSearchInCanonical: true, + docsTab: { id: 'home', fullBleed: true }, }, }) diff --git a/src/routes/_library/charts.catalog.tsx b/src/routes/_library/charts.catalog.tsx index 051a8c099..040b4e305 100644 --- a/src/routes/_library/charts.catalog.tsx +++ b/src/routes/_library/charts.catalog.tsx @@ -10,6 +10,9 @@ export const Route = createFileRoute('/_library/charts/catalog')({ version: 'latest', }), component: ChartsCatalogLayout, + staticData: { + docsTab: { id: 'examples', fullBleed: true }, + }, }) function ChartsCatalogLayout() { diff --git a/src/routes/_library/highlight.$version.theme-editor.tsx b/src/routes/_library/highlight.$version.theme-editor.tsx index 141c9a046..dad771ba7 100644 --- a/src/routes/_library/highlight.$version.theme-editor.tsx +++ b/src/routes/_library/highlight.$version.theme-editor.tsx @@ -25,5 +25,6 @@ export const Route = createFileRoute( } }, component: ThemeEditorPage, + staticData: { docsTab: { id: 'theme-editor', fullBleed: true } }, ssr: false, }) diff --git a/src/utils/docsNavTabs.ts b/src/utils/docsNavTabs.ts index fa68bb317..480fb9d48 100644 --- a/src/utils/docsNavTabs.ts +++ b/src/utils/docsNavTabs.ts @@ -114,8 +114,11 @@ function isDocsTabTarget(to: string) { return true } -export function getTabbedMenuConfig(menuConfig: MenuItem[]) { - return docsNavTabs +export function getTabbedMenuConfig( + menuConfig: MenuItem[], + tools: Array<{ id: DocsNavTabId; label: string; to: string }> = [], +) { + const tabs = docsNavTabs .map((tab) => { const groups = menuConfig .map((group) => { @@ -148,6 +151,17 @@ export function getTabbedMenuConfig(menuConfig: MenuItem[]) { } }) .filter((tab) => tab.groups.length) + + for (const tool of tools) { + const existing = tabs.find((tab) => tab.id === tool.id) + const firstItem = { label: tool.label, to: tool.to } + if (existing) { + existing.firstItem = firstItem + } else { + tabs.push({ id: tool.id, label: tool.label, groups: [], firstItem }) + } + } + return tabs } // Matches a menu child's `to` against the current pathname, handling the @@ -184,19 +198,19 @@ function isChildPathMatch({ return false } -export function getActiveDocsNavTabId({ - isExample, +export function getActiveDocsNavTabId({ + tabIdOverride, menuConfig, pathname, relativePathname, }: { - isExample: boolean + tabIdOverride?: TTabIdOverride menuConfig: MenuItem[] pathname: string relativePathname: string }) { - if (isExample) { - return 'examples' + if (tabIdOverride) { + return tabIdOverride } const activeGroup = menuConfig.find((group) => diff --git a/tests/charts-sidebar-navigation.test.ts b/tests/charts-sidebar-navigation.test.ts index bde42c1e6..9d0274c2c 100644 --- a/tests/charts-sidebar-navigation.test.ts +++ b/tests/charts-sidebar-navigation.test.ts @@ -1,3 +1,8 @@ +import { + getTabbedMenuConfig, + getActiveDocsNavTabId, +} from '../src/utils/docsNavTabs' +import { customToolTabs } from '../src/libraries/custom-tool-tabs' import assert from 'node:assert/strict' import { getLibraryLayoutVersion, @@ -83,6 +88,7 @@ const groups = [ assert.deepEqual( getMenuGroupInitialOpenState( + 'charts', groups, undefined, '/charts/catalog/charts/01-line-gaps/', @@ -92,13 +98,11 @@ assert.deepEqual( ) assert.deepEqual( - getMenuGroupInitialOpenState(groups, undefined, '/charts/catalog'), + getMenuGroupInitialOpenState('charts', groups, undefined, '/charts/catalog'), { '0:Individual Charts': false }, 'an inactive catalog group keeps its configured collapsed state', ) -console.log('charts sidebar navigation tests passed') - assert.deepEqual( getLibraryTabLinkOptions({ libraryId: 'highlight', @@ -112,3 +116,53 @@ assert.deepEqual( }, 'the theme editor uses its own versioned route without a docs route prefix', ) + +const catalogGroups = groups.map((group) => ({ + ...group, + children: group.children.map((child) => ({ + ...child, + label: 'Example chart', + })), +})) +const chartsTabs = getTabbedMenuConfig(catalogGroups, customToolTabs.charts) +assert.equal(chartsTabs.filter((tab) => tab.label === 'Examples').length, 1) +const examplesTab = chartsTabs.find((tab) => tab.id === 'examples') +assert.equal(examplesTab?.firstItem?.to, '/charts/catalog') +assert.equal( + examplesTab?.groups[0]?.children.length, + groups[0]?.children.length, +) +assert.equal( + getActiveDocsNavTabId({ + tabIdOverride: 'examples', + menuConfig: catalogGroups, + pathname: '/charts/catalog/charts/01-line-gaps/', + relativePathname: '', + }), + examplesTab?.id, +) +assert.equal( + getTabbedMenuConfig([], customToolTabs.charts)[0]?.firstItem?.to, + '/charts/catalog', +) +const highlightTabs = getTabbedMenuConfig([], customToolTabs.highlight) +assert.equal(highlightTabs[0]?.id, 'theme-editor') +assert.equal( + highlightTabs[0]?.firstItem?.to, + '/highlight/$version/theme-editor', +) +assert.ok(highlightTabs[0]?.firstItem) +assert.deepEqual( + getLibraryTabLinkOptions({ + libraryId: 'highlight', + version: 'v1', + to: highlightTabs[0].firstItem.to, + }), + { + from: undefined, + to: '/highlight/$version/theme-editor', + params: { version: 'v1' }, + }, +) + +console.log('charts sidebar navigation tests passed')