diff --git a/.storybook/preview.js b/.storybook/preview.js index 8a1a1ef0ff..627f2ed650 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -314,6 +314,73 @@ const withExplicitFullscreenStoryCanvas = (Story, context) => { return React.createElement(Story); }; +const shouldBlockStorybookLinkNavigation = (anchor) => { + const href = anchor.getAttribute('href'); + + if (!href) { + return false; + } + + if ( + href.startsWith('#') || + href.startsWith('mailto:') || + href.startsWith('tel:') + ) { + return false; + } + + return true; +}; + +/** + * Prevent links rendered inside stories from navigating away from Storybook. + * + * @type {(Story: any, context: any) => import('react').ReactElement} + */ +const withStorybookLinkNavigationGuard = (Story, context) => { + React.useEffect(() => { + if (context.viewMode !== 'story' && context.viewMode !== 'docs') { + return undefined; + } + + const handleAnchorClick = (event) => { + if ( + event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) { + return; + } + + const target = event.target; + if (!(target instanceof Element)) { + return; + } + + const anchor = target.closest('a[href]'); + if (!(anchor instanceof HTMLAnchorElement)) { + return; + } + + if (!shouldBlockStorybookLinkNavigation(anchor)) { + return; + } + + event.preventDefault(); + }; + + document.addEventListener('click', handleAnchorClick, true); + return () => { + document.removeEventListener('click', handleAnchorClick, true); + }; + }, [context.id, context.viewMode]); + + return React.createElement(Story); +}; + export const globalTypes = { responsivePreview: { name: 'Responsive preview', @@ -337,6 +404,7 @@ export const initialGlobals = { export const decorators = [ renderResponsivePreviews, withExplicitFullscreenStoryCanvas, + withStorybookLinkNavigationGuard, ]; export const preview = { diff --git a/src/components/link/link.stories.tsx b/src/components/link/link.stories.tsx index 0e5d36e381..36fa27e860 100644 --- a/src/components/link/link.stories.tsx +++ b/src/components/link/link.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { expect, within } from 'storybook/test'; +import { expect, userEvent, within } from 'storybook/test'; import { Heading } from '../headings/heading'; import Link, { ListLink } from './link'; import List from '../list/list'; @@ -41,6 +41,10 @@ export const Standalone: Story = { const canvas = within(canvasElement); const link = canvas.getByRole('link', { name: /standalone link/i }); await expect(link).toHaveAttribute('to', '/#'); + + const initialHref = location.href; + await userEvent.click(link); + await expect(location.href).toBe(initialHref); }, }; diff --git a/src/components/secondary-nav/secondary-nav.scss b/src/components/secondary-nav/secondary-nav.scss index 009af00985..073b40781d 100644 --- a/src/components/secondary-nav/secondary-nav.scss +++ b/src/components/secondary-nav/secondary-nav.scss @@ -199,7 +199,6 @@ // Add drop-shadow. box-shadow: 0 5px 5px rgb(0, 0, 0, 20%); - // cfgov initializes FlyoutMenu + MaxHeightTransition in SecondaryNav.js. // Collapse content from header state when that JS is not running. &__header[aria-expanded='false'] ~ &__content { diff --git a/src/components/skip-nav/skip-nav.scss b/src/components/skip-nav/skip-nav.scss index b360965d25..571c33478c 100644 --- a/src/components/skip-nav/skip-nav.scss +++ b/src/components/skip-nav/skip-nav.scss @@ -14,7 +14,9 @@ width: 1px; overflow: hidden; background: transparent; - transition: transform 1s ease, background 0.5s linear; + transition: + transform 1s ease, + background 0.5s linear; z-index: 11; &:focus { @@ -26,7 +28,9 @@ height: auto; width: auto; overflow: visible; - transition: transform 0.1s ease, background 0.2s linear; + transition: + transform 0.1s ease, + background 0.2s linear; outline: 1px dotted var(--pacific); outline-offset: 1px; }