From 8923b480cea6cc0f5fe8c41729a9c52dc61c4a8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kai=20R=C3=B6der?= Date: Mon, 19 Jan 2026 15:58:48 +0100 Subject: [PATCH 1/6] refactor(manager): rewrite reducer into for-loop --- .../src/manager/components/sidebar/Search.tsx | 39 +++++++++++-------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/code/core/src/manager/components/sidebar/Search.tsx b/code/core/src/manager/components/sidebar/Search.tsx index ed8af74a642f..8e6284b91d37 100644 --- a/code/core/src/manager/components/sidebar/Search.tsx +++ b/code/core/src/manager/components/sidebar/Search.tsx @@ -43,8 +43,9 @@ const options = { maxPatternLength: 32, minMatchCharLength: 1, keys: [ - { name: 'name', weight: 0.7 }, + { name: 'name', weight: 0.6 }, { name: 'path', weight: 0.3 }, + { name: 'headings', weight: 0.1 }, ], } as FuseOptions; @@ -182,25 +183,29 @@ export const Search = React.memo(function Search({ const searchShortcut = api ? shortcutToHumanString(api.getShortcutKeys().search) : '/'; const makeFuse = useCallback(() => { - const list = dataset.entries.reduce((acc, [refId, { index, allStatuses }]) => { + const list: SearchItem[] = []; + + for (const [refId, { index, allStatuses }] of dataset.entries) { + if (!index) { + continue; + } + const groupStatus = getGroupStatus(index || {}, allStatuses ?? {}); + const datasetValues = Object.values(index); - if (index) { - acc.push( - ...Object.values(index).map((item) => { - const storyStatuses = allStatuses?.[item.id]; - const mostCriticalStatusValue = storyStatuses - ? getMostCriticalStatusValue(Object.values(storyStatuses).map((s) => s.value)) - : null; - return { - ...searchItem(item, dataset.hash[refId]), - status: mostCriticalStatusValue ?? groupStatus[item.id] ?? null, - }; - }) - ); + for (const datasetValue of datasetValues) { + const storyStatuses = allStatuses?.[datasetValue.id]; + const mostCriticalStatusValue = storyStatuses + ? getMostCriticalStatusValue(Object.values(storyStatuses).map((s) => s.value)) + : null; + + list.push({ + ...searchItem(datasetValue, dataset.hash[refId]), + status: mostCriticalStatusValue ?? groupStatus[datasetValue.id] ?? null, + }); } - return acc; - }, []); + } + return new Fuse(list, options); }, [dataset]); From ae457429e01a03db56804ac3811288bbe28d1491 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kai=20R=C3=B6der?= Date: Mon, 19 Jan 2026 16:07:06 +0100 Subject: [PATCH 2/6] Use canary docs-mdx with headings support --- code/core/package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/code/core/package.json b/code/core/package.json index c559c85eb418..4c9c98ae7824 100644 --- a/code/core/package.json +++ b/code/core/package.json @@ -273,7 +273,7 @@ "@react-stately/tabs": "^3.8.5", "@react-types/shared": "^3.32.0", "@rolldown/pluginutils": "1.0.0-beta.18", - "@storybook/docs-mdx": "4.0.0-next.3", + "@storybook/docs-mdx": "3.2.0--canary.16.3ffdb30.0", "@tanstack/react-virtual": "^3.3.0", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^14.0.0", diff --git a/yarn.lock b/yarn.lock index f7b406ffff3d..2c6e3ef316f1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8310,12 +8310,12 @@ __metadata: languageName: unknown linkType: soft -"@storybook/docs-mdx@npm:4.0.0-next.3": - version: 4.0.0-next.3 - resolution: "@storybook/docs-mdx@npm:4.0.0-next.3" +"@storybook/docs-mdx@npm:3.2.0--canary.16.3ffdb30.0": + version: 3.2.0--canary.16.3ffdb30.0 + resolution: "@storybook/docs-mdx@npm:3.2.0--canary.16.3ffdb30.0" dependencies: acorn: "npm:^8.12.1" - checksum: 10c0/7d9e689df6a098b0c294f3e835a5a1323460c80b8a33195f811f7de215c6abaf452988851a219ebd59fa472107747b8f54ab2b82d6044c81a76a7e4dc09e506c + checksum: 10c0/c9d707a928fef8e48e26f49ca74483aec69f6c887fcc2e6d104c4125a74440aca71cbe22fbb51df53432890a93be27b78396da89dc581373d1396b1be1d760b1 languageName: node linkType: hard @@ -28591,7 +28591,7 @@ __metadata: "@react-stately/tabs": "npm:^3.8.5" "@react-types/shared": "npm:^3.32.0" "@rolldown/pluginutils": "npm:1.0.0-beta.18" - "@storybook/docs-mdx": "npm:4.0.0-next.3" + "@storybook/docs-mdx": "npm:3.2.0--canary.16.3ffdb30.0" "@storybook/global": "npm:^5.0.0" "@storybook/icons": "npm:^2.0.1" "@tanstack/react-virtual": "npm:^3.3.0" From e947ad1284e5eea95f88dd9127d6a016548ec0e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kai=20R=C3=B6der?= Date: Tue, 7 Apr 2026 09:09:37 +0200 Subject: [PATCH 3/6] feat: add docs headings to search results --- .../core-server/utils/StoryIndexGenerator.ts | 3 + code/core/src/manager-api/lib/stories.ts | 3 +- .../src/manager/components/sidebar/Search.tsx | 23 +++++++ .../manager/components/sidebar/TreeNode.tsx | 64 ++++++++++--------- code/core/src/types/modules/api-stories.ts | 1 + code/core/src/types/modules/core-common.ts | 7 ++ code/core/src/types/modules/indexer.ts | 1 + 7 files changed, 71 insertions(+), 31 deletions(-) diff --git a/code/core/src/core-server/utils/StoryIndexGenerator.ts b/code/core/src/core-server/utils/StoryIndexGenerator.ts index 048fa7989c62..224a3ae5839e 100644 --- a/code/core/src/core-server/utils/StoryIndexGenerator.ts +++ b/code/core/src/core-server/utils/StoryIndexGenerator.ts @@ -492,6 +492,7 @@ export class StoryIndexGenerator { const entry = storyEntries[0]; const id = toId(metaId ?? entry.title, name); const tags = combineTags(...projectTags, ...(indexInputs[0].tags ?? [])); + const headings = indexInputs.map((input) => input.name).filter(Boolean) as string[]; const docsEntry: DocsCacheEntry & { tags: Tag[] } = { id, @@ -501,6 +502,7 @@ export class StoryIndexGenerator { type: 'docs', tags, storiesImports: [], + headings, }; return { @@ -624,6 +626,7 @@ export class StoryIndexGenerator { storiesImports: sortedDependencies.map((dep) => dep.entries[0].importPath), type: 'docs', tags, + headings: result.headings || [], }; return docsEntry; } catch (err) { diff --git a/code/core/src/manager-api/lib/stories.ts b/code/core/src/manager-api/lib/stories.ts index 0def3632f8db..140819cfebee 100644 --- a/code/core/src/manager-api/lib/stories.ts +++ b/code/core/src/manager-api/lib/stories.ts @@ -69,6 +69,7 @@ export const transformSetStoriesStoryDataToPreparedStoryIndex = ( type: 'docs', tags: ['stories-mdx'], storiesImports: [], + headings: [], ...base, }; } else { @@ -125,7 +126,7 @@ export const transformStoryIndexV3toV4 = (index: StoryIndexV3): API_PreparedStor } acc[entry.id] = { type, - ...(type === 'docs' && { tags: ['stories-mdx'], storiesImports: [] }), + ...(type === 'docs' && { tags: ['stories-mdx'], storiesImports: [], headings: [] }), ...entry, } as API_PreparedIndexEntry; diff --git a/code/core/src/manager/components/sidebar/Search.tsx b/code/core/src/manager/components/sidebar/Search.tsx index 8e6284b91d37..c6a1ba68e493 100644 --- a/code/core/src/manager/components/sidebar/Search.tsx +++ b/code/core/src/manager/components/sidebar/Search.tsx @@ -203,6 +203,29 @@ export const Search = React.memo(function Search({ ...searchItem(datasetValue, dataset.hash[refId]), status: mostCriticalStatusValue ?? groupStatus[datasetValue.id] ?? null, }); + + // Narrow down type to more specific API_DocsEntry with headings + if (datasetValue.type !== 'docs') { + continue; + } + + if (!globalThis?.FEATURES?.experimentalSearchDocsHeadings) { + continue; + } + + const headings = datasetValue.headings ?? []; + headings.forEach((heading: string) => { + const searchItemRef = searchItem(datasetValue, dataset.hash[refId]); + const namePostfix = searchItemRef.path?.[0] === heading ? '' : ` / ${heading}`; + + list.push({ + ...searchItemRef, + // TODO add comment about why -> fuse breaks if id is not unique + id: `${datasetValue.id}#${heading.replaceAll(' ', '-').toLowerCase()}`, + name: `${datasetValue.name}${namePostfix}`, + status: mostCriticalStatusValue || groupStatus[datasetValue.id] || null, + }); + }); } } diff --git a/code/core/src/manager/components/sidebar/TreeNode.tsx b/code/core/src/manager/components/sidebar/TreeNode.tsx index 3e5ed8c14bcd..4ff0bcd21174 100644 --- a/code/core/src/manager/components/sidebar/TreeNode.tsx +++ b/code/core/src/manager/components/sidebar/TreeNode.tsx @@ -6,36 +6,40 @@ import { type FunctionInterpolation, styled } from 'storybook/theming'; import { UseSymbol } from './IconSymbols.tsx'; import { CollapseIcon } from './components/CollapseIcon.tsx'; -export const TypeIcon = styled.svg<{ type: 'component' | 'story' | 'test' | 'group' | 'document' }>( - ({ theme, type }) => ({ - width: 14, - height: 14, - flex: '0 0 auto', - color: (() => { - if (type === 'group') { - return theme.base === 'dark' ? theme.color.primary : theme.color.ultraviolet; - } - - if (type === 'component') { - return theme.color.secondary; - } - - if (type === 'document') { - return theme.base === 'dark' ? theme.color.gold : '#ff8300'; - } - - if (type === 'story') { - return theme.color.seafoam; - } - - if (type === 'test') { - return theme.color.green; - } - - return 'currentColor'; - })(), - }) -); +export const TypeIcon = styled.svg<{ + type: 'component' | 'story' | 'test' | 'group' | 'document' | 'heading'; +}>(({ theme, type }) => ({ + width: 14, + height: 14, + flex: '0 0 auto', + color: (() => { + if (type === 'group') { + return theme.base === 'dark' ? theme.color.primary : theme.color.ultraviolet; + } + + if (type === 'component') { + return theme.color.secondary; + } + + if (type === 'document') { + return theme.base === 'dark' ? theme.color.gold : '#ff8300'; + } + + if (type === 'story') { + return theme.color.seafoam; + } + + if (type === 'heading') { + return theme.color.secondary; + } + + if (type === 'test') { + return theme.color.green; + } + + return 'currentColor'; + })(), +})); const commonNodeStyles: FunctionInterpolation<{ depth?: number; isExpandable?: boolean }> = ({ theme, diff --git a/code/core/src/types/modules/api-stories.ts b/code/core/src/types/modules/api-stories.ts index f23c8c060c5a..a9b883728623 100644 --- a/code/core/src/types/modules/api-stories.ts +++ b/code/core/src/types/modules/api-stories.ts @@ -40,6 +40,7 @@ export interface API_DocsEntry extends API_BaseEntry { parameters?: { [parameterName: string]: any; }; + headings?: string[]; } export interface API_StoryEntry extends API_BaseEntry { diff --git a/code/core/src/types/modules/core-common.ts b/code/core/src/types/modules/core-common.ts index 00540e32acc6..a010a33de973 100644 --- a/code/core/src/types/modules/core-common.ts +++ b/code/core/src/types/modules/core-common.ts @@ -518,6 +518,13 @@ export interface StorybookConfigRaw { */ experimentalRSC?: boolean; + /** + * Adds docs story subheadings to the search index. + * + * @experimental This feature is in early development and may change significantly in future releases. + */ + experimentalSearchDocsHeadings?: boolean; + /** * @temporary This feature flag is a migration assistant, and is scheduled to be removed. * diff --git a/code/core/src/types/modules/indexer.ts b/code/core/src/types/modules/indexer.ts index 56a17b36c752..7d9f357adbde 100644 --- a/code/core/src/types/modules/indexer.ts +++ b/code/core/src/types/modules/indexer.ts @@ -83,6 +83,7 @@ export type StoryIndexEntry = BaseIndexEntry & { export type DocsIndexEntry = BaseIndexEntry & { storiesImports: Path[]; type: 'docs'; + headings: string[]; }; export type IndexEntry = StoryIndexEntry | DocsIndexEntry; From 81d5bf3cc78db607007f930443f3201ae7691b0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kai=20R=C3=B6der?= Date: Tue, 7 Apr 2026 09:21:53 +0200 Subject: [PATCH 4/6] feat: implement scrollIntoView behavior for all navigation use-cases --- code/core/src/manager-api/modules/stories.ts | 6 +++--- code/core/src/manager/components/sidebar/Search.tsx | 12 +++++++++--- code/core/src/manager/components/sidebar/types.ts | 1 + code/core/src/preview/runtime.ts | 10 +++++++++- code/core/src/router/router.tsx | 10 ++++++++++ 5 files changed, 32 insertions(+), 7 deletions(-) diff --git a/code/core/src/manager-api/modules/stories.ts b/code/core/src/manager-api/modules/stories.ts index 5facc030ffd5..5d0bab095b01 100644 --- a/code/core/src/manager-api/modules/stories.ts +++ b/code/core/src/manager-api/modules/stories.ts @@ -137,7 +137,7 @@ export interface SubAPI { selectStory: ( kindOrId?: string, story?: StoryId, - obj?: { ref?: string; viewMode?: API_ViewMode } + obj?: { ref?: string; viewMode?: API_ViewMode; scrollTo?: string } ) => void; /** * Returns the current story's data, including its ID, kind, name, and parameters. @@ -555,14 +555,14 @@ export const init: ModuleFn = ({ navigateWithQueryParams('/'); }, selectStory: (titleOrId = undefined, name = undefined, options = {}) => { - const { ref } = options; + const { ref, scrollTo } = options; const { storyId, index, filteredIndex, refs, settings } = store.getState(); const gotoStory = (entry?: API_HashEntry) => { if (entry?.type === 'docs' || entry?.type === 'story') { store.setState({ settings: { ...settings, lastTrackedStoryId: entry.id } }); navigateWithQueryParams( - `/${entry.type}/${entry.refId ? `${entry.refId}_${entry.id}` : entry.id}` + `/${entry.type}/${entry.refId ? `${entry.refId}_${entry.id}` : entry.id}${scrollTo ? `#${scrollTo}` : ''}` ); return true; } diff --git a/code/core/src/manager/components/sidebar/Search.tsx b/code/core/src/manager/components/sidebar/Search.tsx index c6a1ba68e493..30e803be629f 100644 --- a/code/core/src/manager/components/sidebar/Search.tsx +++ b/code/core/src/manager/components/sidebar/Search.tsx @@ -273,9 +273,15 @@ export const Search = React.memo(function Search({ const onSelect = useCallback( (selectedItem: DownshiftItem) => { if (isSearchResult(selectedItem)) { - const { id, refId } = selectedItem.item; - // @ts-expect-error (non strict) - api?.selectStory(id, undefined, { ref: refId !== DEFAULT_REF_ID && refId }); + const { id: rawId, refId } = selectedItem.item; + const [storyId, anchor] = rawId.split('#'); + + api?.selectStory(storyId, undefined, { + // @ts-expect-error (non strict) + ref: refId !== DEFAULT_REF_ID && refId, + scrollTo: anchor, + }); + // @ts-expect-error (non strict) inputRef.current.blur(); showAllComponents(false); diff --git a/code/core/src/manager/components/sidebar/types.ts b/code/core/src/manager/components/sidebar/types.ts index aa956ab22b33..338a1acde05d 100644 --- a/code/core/src/manager/components/sidebar/types.ts +++ b/code/core/src/manager/components/sidebar/types.ts @@ -20,6 +20,7 @@ export interface ItemRef { export interface StoryRef { storyId: string; refId: string; + anchor?: string; } export type Highlight = ItemRef | null; diff --git a/code/core/src/preview/runtime.ts b/code/core/src/preview/runtime.ts index ac6dc9dfd5e7..5b0529744e0d 100644 --- a/code/core/src/preview/runtime.ts +++ b/code/core/src/preview/runtime.ts @@ -1,4 +1,8 @@ -import { MANAGER_INERT_ATTRIBUTE_CHANGED, TELEMETRY_ERROR } from 'storybook/internal/core-events'; +import { + MANAGER_INERT_ATTRIBUTE_CHANGED, + NAVIGATE_URL, + TELEMETRY_ERROR, +} from 'storybook/internal/core-events'; import { global } from '@storybook/global'; @@ -47,6 +51,10 @@ export function setup() { document.body.removeAttribute('inert'); } }); + + channel.on(NAVIGATE_URL, (hash: string) => { + document.querySelector(hash)?.scrollIntoView({ behavior: 'smooth' }); + }); }); // handle all uncaught StorybookError at the root of the application and log to telemetry if applicable diff --git a/code/core/src/router/router.tsx b/code/core/src/router/router.tsx index 493e8c6b8da9..36b9ead618cd 100644 --- a/code/core/src/router/router.tsx +++ b/code/core/src/router/router.tsx @@ -7,6 +7,8 @@ import * as R from 'react-router-dom'; import type { LinkProps, NavigateOptions, RenderData } from './types.ts'; import { getMatch, parsePath, queryFromLocation } from './utils.ts'; +import { addons } from '../manager-api/index.ts'; +import { NAVIGATE_URL } from '../core-events/index.ts'; const { document } = global; @@ -56,6 +58,14 @@ export const useNavigate = () => { } if (typeof to === 'string') { const target = plain ? to : `?path=${to}`; + const [search, hash] = target.split('#'); + + if (search === document.location.search && hash) { + addons.getChannel().emit(NAVIGATE_URL, `#${hash}`); + + return undefined; + } + return navigate(target, options); } if (typeof to === 'number') { From 75fbe961c2bc0aaba638a4346bbe24b2edf70c43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kai=20R=C3=B6der?= Date: Tue, 7 Apr 2026 09:22:45 +0200 Subject: [PATCH 5/6] feat: show anchor results in last viewed results --- .../src/manager/components/sidebar/Search.tsx | 27 ++++++++++++++++--- .../components/sidebar/useLastViewed.ts | 4 ++- code/core/src/manager/utils/tree.ts | 2 +- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/code/core/src/manager/components/sidebar/Search.tsx b/code/core/src/manager/components/sidebar/Search.tsx index 30e803be629f..7a954d37e506 100644 --- a/code/core/src/manager/components/sidebar/Search.tsx +++ b/code/core/src/manager/components/sidebar/Search.tsx @@ -162,6 +162,7 @@ export type SearchProps = { dataset: CombinedDataset; enableShortcuts?: boolean; getLastViewed: () => Selection[]; + updateLastViewed?: (story: { storyId: string; refId: string; anchor?: string }) => void; initialQuery?: string; searchBarContent?: ReactNode; searchFieldContent?: ReactNode; @@ -172,6 +173,7 @@ export const Search = React.memo(function Search({ dataset, enableShortcuts = true, getLastViewed, + updateLastViewed, initialQuery = '', searchBarContent, searchFieldContent, @@ -276,6 +278,8 @@ export const Search = React.memo(function Search({ const { id: rawId, refId } = selectedItem.item; const [storyId, anchor] = rawId.split('#'); + updateLastViewed?.({ storyId, refId, anchor }); + api?.selectStory(storyId, undefined, { // @ts-expect-error (non strict) ref: refId !== DEFAULT_REF_ID && refId, @@ -380,16 +384,33 @@ export const Search = React.memo(function Search({ const lastViewed = !input && getLastViewed(); if (lastViewed && lastViewed.length) { // @ts-expect-error (non strict) - results = lastViewed.reduce((acc, { storyId, refId }) => { + results = lastViewed.reduce((acc, { storyId, refId, anchor }) => { const data = dataset.hash[refId]; if (data && data.index && data.index[storyId]) { const story = data.index[storyId]; const item = story.type === 'story' ? data.index[story.parent] : story; + const entryId = anchor ? `${item.id}#${anchor}` : item.id; // prevent duplicates // @ts-expect-error (non strict) - if (!acc.some((res) => res.item.refId === refId && res.item.id === item.id)) { + if (!acc.some((res) => res.item.refId === refId && res.item.id === entryId)) { + const baseItem = searchItem(item, dataset.hash[refId]); + let resultItem = baseItem; + if (anchor && item.type === 'docs') { + const matchingHeading = item.headings?.find( + (h: string) => h.replaceAll(' ', '-').toLowerCase() === anchor + ); + if (matchingHeading) { + const namePostfix = + baseItem.path?.[0] === matchingHeading ? '' : ` / ${matchingHeading}`; + resultItem = { + ...baseItem, + id: entryId, + name: `${item.name}${namePostfix}`, + }; + } + } // @ts-expect-error (non strict) - acc.push({ item: searchItem(item, dataset.hash[refId]), matches: [], score: 0 }); + acc.push({ item: resultItem, matches: [], score: 0 }); } } return acc; diff --git a/code/core/src/manager/components/sidebar/useLastViewed.ts b/code/core/src/manager/components/sidebar/useLastViewed.ts index 298ccf8498d7..71e8cb849929 100644 --- a/code/core/src/manager/components/sidebar/useLastViewed.ts +++ b/code/core/src/manager/components/sidebar/useLastViewed.ts @@ -27,7 +27,8 @@ export const useLastViewed = (selection: Selection) => { (story: StoryRef) => { const items = lastViewedRef.current; const index = items.findIndex( - ({ storyId, refId }) => storyId === story.storyId && refId === story.refId + ({ storyId, refId, anchor }) => + storyId === story.storyId && refId === story.refId && anchor === story.anchor ); if (index === 0) { @@ -51,6 +52,7 @@ export const useLastViewed = (selection: Selection) => { return { getLastViewed: useCallback(() => lastViewedRef.current, [lastViewedRef]), + updateLastViewed, clearLastViewed: useCallback(() => { lastViewedRef.current = lastViewedRef.current.slice(0, 1); save(lastViewedRef.current); diff --git a/code/core/src/manager/utils/tree.ts b/code/core/src/manager/utils/tree.ts index 7d4f4ed8f0ed..9d1240f9a8ee 100644 --- a/code/core/src/manager/utils/tree.ts +++ b/code/core/src/manager/utils/tree.ts @@ -65,7 +65,7 @@ export function getPath(item: Item, ref: Pick } export const searchItem = (item: Item, ref: Parameters[1]): SearchItem => { - return { ...item, refId: ref.id, path: getPath(item, ref) }; + return { ...item, refId: ref.id, name: item.name, path: getPath(item, ref) }; }; export function cycle(array: T[], index: number, delta: number): number { From 77ef62c8c3a7bb7d97366953bc41093fcb01d7f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kai=20R=C3=B6der?= Date: Tue, 7 Apr 2026 09:23:35 +0200 Subject: [PATCH 6/6] test: add tests for new mdx heading search results --- code/e2e-tests/navigation.spec.ts | 90 +++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/code/e2e-tests/navigation.spec.ts b/code/e2e-tests/navigation.spec.ts index 72ac359e068c..b02444783d70 100644 --- a/code/e2e-tests/navigation.spec.ts +++ b/code/e2e-tests/navigation.spec.ts @@ -20,4 +20,94 @@ test.describe('navigating', () => { expect(sbPage.page.url()).toContain('/docs/example-button--docs'); }); + + test.describe('docs story anchor navigation', () => { + test('a subheading in a story can be searched and renders the subheading in a search result item', async ({ + page, + }) => { + await page.goto(`${storybookUrl}`); + await page.getByRole('searchbox').fill('Do more with Storybook'); + + const searchItem = page.getByRole('option', { + name: 'Docs / Do more with Storybook Configure your project', + exact: true, + }); + await expect(searchItem).toBeVisible(); + }); + + test('a root story title does not appear redundantly in search result item', async ({ + page, + }) => { + await page.goto(`${storybookUrl}`); + await page.getByRole('searchbox').fill('Configure your project'); + + const searchItem = page.getByRole('option', { + name: 'Configure your project', + exact: true, + }); + await expect(searchItem).toBeVisible(); + }); + + test('a subheading gets scrolled into view when navigating to an anchor link on the current docs page', async ({ + page, + }) => { + await page.goto(`${storybookUrl}`); + + const sbPage = new SbPage(page, expect); + await sbPage.waitUntilLoaded(); + + await page.getByRole('searchbox').fill('Do more with Storybook'); + + await page + .getByRole('option', { + name: 'Docs / Do more with Storybook Configure your project', + exact: true, + }) + .click(); + await sbPage.waitUntilLoaded(); + + const subheading = sbPage + .previewIframe() + .getByRole('heading', { name: 'Do more with Storybook' }); + + await expect(subheading).toBeVisible(); + + // Wait for smooth scroll to finish and verify the heading is near the top + await expect + .poll(() => subheading.evaluate((el) => el.getBoundingClientRect().top), { + intervals: [300], + }) + .toBeLessThan(50); + }); + + test('a subheading gets scrolled into view when navigating to a different story', async ({ + page, + }) => { + await page.goto(`${storybookUrl}?path=/docs/configure-your-project--docs`); + + const sbPage = new SbPage(page, expect); + await sbPage.waitUntilLoaded(); + + await page.getByRole('searchbox').fill('Primary'); + await page + .getByRole('option', { + name: 'Docs / Primary Example / Button', + exact: true, + }) + .click(); + + await sbPage.waitUntilLoaded(); + + const subheading = sbPage.previewIframe().getByRole('heading', { name: 'Primary' }); + + await expect(subheading).toBeVisible(); + + // Wait for smooth scroll to finish and verify the heading is near the top + await expect + .poll(() => subheading.evaluate((el) => el.getBoundingClientRect().top), { + intervals: [300], + }) + .toBeLessThan(50); + }); + }); });