From f7753e8a22e80b2b7bb28a74527a3009db04df12 Mon Sep 17 00:00:00 2001 From: Sem Bauke Date: Mon, 16 Mar 2026 11:18:12 +0100 Subject: [PATCH] feat(client): sidebar-nav on review-pages (#65897) Co-authored-by: Huyen Nguyen <25715018+huyenltnguyen@users.noreply.github.com> --- client/i18n/locales/english/translations.json | 3 +- .../src/client-only-routes/show-settings.css | 18 -- .../settings/settings-sidebar-nav.tsx | 79 ++--- client/src/components/sidebar-panel/index.ts | 3 + .../sidebar-panel/sidebar-panel.css | 27 ++ .../sidebar-panel/sidebar-panel.tsx | 37 +++ .../sidebar-panel/use-active-heading.ts | 62 ++++ .../sidebar-panel/use-sticky-scroll-offset.ts | 42 +++ .../Challenges/classic/action-row.tsx | 65 +++- .../Challenges/generic/content-outline.css | 112 +++++++ .../generic/content-outline.test.tsx | 64 ++++ .../Challenges/generic/content-outline.tsx | 172 +++++++++++ .../src/templates/Challenges/generic/show.tsx | 286 ++++++++++-------- e2e/content-outline.spec.ts | 100 ++++++ 14 files changed, 878 insertions(+), 192 deletions(-) create mode 100644 client/src/components/sidebar-panel/index.ts create mode 100644 client/src/components/sidebar-panel/sidebar-panel.css create mode 100644 client/src/components/sidebar-panel/sidebar-panel.tsx create mode 100644 client/src/components/sidebar-panel/use-active-heading.ts create mode 100644 client/src/components/sidebar-panel/use-sticky-scroll-offset.ts create mode 100644 client/src/templates/Challenges/generic/content-outline.css create mode 100644 client/src/templates/Challenges/generic/content-outline.test.tsx create mode 100644 client/src/templates/Challenges/generic/content-outline.tsx create mode 100644 e2e/content-outline.spec.ts diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json index 8edf5766952..29c7b5a05b4 100644 --- a/client/i18n/locales/english/translations.json +++ b/client/i18n/locales/english/translations.json @@ -121,7 +121,8 @@ "more-ways-to-sign-in": "More ways to sign in", "sign-in-with-google": "Sign in with Google", "go-to-dcc-today": "Go to Today's Challenge", - "go-to-dcc-archive": "Go to Daily Coding Challenge Archive" + "go-to-dcc-archive": "Go to Daily Coding Challenge Archive", + "outline": "Outline" }, "daily-coding-challenges": { "title": "Daily Coding Challenges", diff --git a/client/src/client-only-routes/show-settings.css b/client/src/client-only-routes/show-settings.css index 2536140d232..ab571cc8f29 100644 --- a/client/src/client-only-routes/show-settings.css +++ b/client/src/client-only-routes/show-settings.css @@ -25,25 +25,7 @@ flex: 1; position: sticky; top: var(--header-height); - padding: 1rem 0; - overflow-y: auto; height: calc(100vh - var(--header-height)); - border-right: 3px solid var(--tertiary-background); - scrollbar-width: thin; - scrollbar-color: var(--quaternary-background) var(--secondary-background); -} - -.settings-sidebar-nav::-webkit-scrollbar { - width: 6px; -} - -.settings-sidebar-nav::-webkit-scrollbar-track { - background: var(--secondary-background); -} - -.settings-sidebar-nav::-webkit-scrollbar-thumb { - background-color: var(--quaternary-background); - border-radius: 3px; } .settings-sidebar-nav .sidebar-nav-section-heading { diff --git a/client/src/components/settings/settings-sidebar-nav.tsx b/client/src/components/settings/settings-sidebar-nav.tsx index a65e7475140..96371f4303c 100644 --- a/client/src/components/settings/settings-sidebar-nav.tsx +++ b/client/src/components/settings/settings-sidebar-nav.tsx @@ -1,6 +1,8 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { Link as ScrollLink } from 'react-scroll'; + +import SidebarPanel, { useStickyScrollOffset } from '../sidebar-panel'; import { currentCertifications, legacyCertifications, @@ -19,21 +21,22 @@ function SettingsSidebarNav({ userToken }: SettingsSidebarNavProps): JSX.Element { const { t } = useTranslation(); + const scrollOffset = useStickyScrollOffset(['--header-height']); const allLegacyCertifications = [ ...legacyFullStackCertification, ...legacyCertifications ]; return ( - + ); } diff --git a/client/src/components/sidebar-panel/index.ts b/client/src/components/sidebar-panel/index.ts new file mode 100644 index 00000000000..93b56f5e2db --- /dev/null +++ b/client/src/components/sidebar-panel/index.ts @@ -0,0 +1,3 @@ +export { default } from './sidebar-panel'; +export { default as useActiveHeading } from './use-active-heading'; +export { default as useStickyScrollOffset } from './use-sticky-scroll-offset'; diff --git a/client/src/components/sidebar-panel/sidebar-panel.css b/client/src/components/sidebar-panel/sidebar-panel.css new file mode 100644 index 00000000000..746657f7593 --- /dev/null +++ b/client/src/components/sidebar-panel/sidebar-panel.css @@ -0,0 +1,27 @@ +.sidebar-panel { + overflow-y: auto; + scrollbar-color: var(--quaternary-background) var(--secondary-background); + scrollbar-width: thin; + background-color: var(--secondary-background); + border-right: 3px solid var(--tertiary-background); + padding: 1rem 0; + color: var(--primary-color); + font-size: 1rem; +} + +.sidebar-panel::-webkit-scrollbar { + width: 6px; +} + +.sidebar-panel::-webkit-scrollbar-track { + background: var(--secondary-background); +} + +.sidebar-panel::-webkit-scrollbar-thumb { + background-color: var(--quaternary-background); + border-radius: 3px; +} + +.sidebar-panel-item { + list-style: none; +} diff --git a/client/src/components/sidebar-panel/sidebar-panel.tsx b/client/src/components/sidebar-panel/sidebar-panel.tsx new file mode 100644 index 00000000000..ac8147df9ff --- /dev/null +++ b/client/src/components/sidebar-panel/sidebar-panel.tsx @@ -0,0 +1,37 @@ +import React from 'react'; + +import './sidebar-panel.css'; + +type SidebarPanelProps = { + className?: string; + children: React.ReactNode; +}; + +function SidebarPanelRoot({ className, children }: SidebarPanelProps) { + const asideClassName = ['sidebar-panel', className].filter(Boolean).join(' '); + + return ; +} + +SidebarPanelRoot.displayName = 'SidebarPanel'; + +type SidebarPanelItemProps = { + className?: string; + children: React.ReactNode; +}; + +function SidebarPanelItem({ className, children }: SidebarPanelItemProps) { + const liClassName = ['sidebar-panel-item', className] + .filter(Boolean) + .join(' '); + + return
  • {children}
  • ; +} + +SidebarPanelItem.displayName = 'SidebarPanel.Item'; + +const SidebarPanel = Object.assign(SidebarPanelRoot, { + Item: SidebarPanelItem +}); + +export default SidebarPanel; diff --git a/client/src/components/sidebar-panel/use-active-heading.ts b/client/src/components/sidebar-panel/use-active-heading.ts new file mode 100644 index 00000000000..b4dd4dafc90 --- /dev/null +++ b/client/src/components/sidebar-panel/use-active-heading.ts @@ -0,0 +1,62 @@ +import { useEffect, useState } from 'react'; + +/** + * Returns the id of the last heading whose top edge has scrolled past the + * bottom of the sticky chrome (header + any other sticky elements above the + * content area), accounting for `topOffset` which is the negative value + * returned by useStickyScrollOffset. + * + * @param ids Heading element ids in document order (top → bottom). + * @param topOffset Negative offset from useStickyScrollOffset. + */ +function useActiveHeading(ids: string[], topOffset: number): string | null { + const [activeId, setActiveId] = useState(null); + const idsKey = ids.join(','); + + useEffect(() => { + if (!idsKey) { + setActiveId(null); + return; + } + + let rafId: number | null = null; + + const update = () => { + const marker = window.scrollY + Math.abs(topOffset) + 12; + let nextId: string | null = ids[0] ?? null; + + for (const id of ids) { + const el = document.getElementById(id); + if (!el) continue; + if (el.getBoundingClientRect().top + window.scrollY <= marker) { + nextId = id; + } else { + break; + } + } + + setActiveId(prev => (prev === nextId ? prev : nextId)); + rafId = null; + }; + + const onScroll = () => { + if (rafId !== null) return; + rafId = window.requestAnimationFrame(update); + }; + + update(); + window.addEventListener('scroll', onScroll, { passive: true }); + window.addEventListener('resize', onScroll); + + return () => { + if (rafId !== null) window.cancelAnimationFrame(rafId); + window.removeEventListener('scroll', onScroll); + window.removeEventListener('resize', onScroll); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [idsKey, topOffset]); + + return activeId; +} + +export default useActiveHeading; diff --git a/client/src/components/sidebar-panel/use-sticky-scroll-offset.ts b/client/src/components/sidebar-panel/use-sticky-scroll-offset.ts new file mode 100644 index 00000000000..e02aa379a99 --- /dev/null +++ b/client/src/components/sidebar-panel/use-sticky-scroll-offset.ts @@ -0,0 +1,42 @@ +import { useEffect, useState } from 'react'; + +/** + * Computes a negative scroll offset suitable for `react-scroll`'s `offset` + * prop so that scrolled-to elements are not hidden behind a sticky header. + * + * Reads the current pixel value of each supplied CSS custom property from + * `:root` on mount and on every resize, returning the negated sum plus any + * additional fixed offset. + */ +function useStickyScrollOffset( + cssVarNames: string[], + additionalOffset = 0 +): number { + const [offset, setOffset] = useState(0); + + // Join to a stable string so the effect only re-runs when the actual + // variable names change, not merely when the caller passes a new array. + const varNamesKey = cssVarNames.join(','); + + useEffect(() => { + const compute = () => { + const rootStyle = window.getComputedStyle(document.documentElement); + const total = cssVarNames.reduce( + (sum, name) => + sum + (Number.parseFloat(rootStyle.getPropertyValue(name)) || 0), + 0 + ); + setOffset(-(total + additionalOffset)); + }; + + compute(); + window.addEventListener('resize', compute); + return () => window.removeEventListener('resize', compute); + // varNamesKey is the stable primitive representation of cssVarNames. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [varNamesKey, additionalOffset]); + + return offset; +} + +export default useStickyScrollOffset; diff --git a/client/src/templates/Challenges/classic/action-row.tsx b/client/src/templates/Challenges/classic/action-row.tsx index 90f32186e51..fa4725b5789 100644 --- a/client/src/templates/Challenges/classic/action-row.tsx +++ b/client/src/templates/Challenges/classic/action-row.tsx @@ -24,36 +24,71 @@ interface ClassicLayoutProps { challengeType: number; togglePane: (pane: string) => void; hasInteractiveEditor?: never; + hasContentOutline?: never; } interface InteractiveEditorProps { hasInteractiveEditor: true; + hasContentOutline?: never; showInteractiveEditor: boolean; toggleInteractiveEditor: () => void; } -type ActionRowProps = ClassicLayoutProps | InteractiveEditorProps; +interface ReviewChallengeProps { + hasContentOutline: true; + hasInteractiveEditor?: never; + showContentOutline: boolean; + onToggleContentOutline: () => void; +} + +interface ReviewWithInteractiveEditorProps { + hasContentOutline: true; + hasInteractiveEditor: true; + showContentOutline: boolean; + onToggleContentOutline: () => void; + showInteractiveEditor: boolean; + toggleInteractiveEditor: () => void; +} + +type ActionRowProps = + | ClassicLayoutProps + | InteractiveEditorProps + | ReviewChallengeProps + | ReviewWithInteractiveEditorProps; const ActionRow = (props: ActionRowProps): JSX.Element => { const { t } = useTranslation(); - if (props.hasInteractiveEditor) { - const { toggleInteractiveEditor, showInteractiveEditor } = props; - + if (props.hasContentOutline || props.hasInteractiveEditor) { return (
    -
    - - - {t('aria.interactive-editor-desc')} - +
    + {props.hasContentOutline && ( + + )} +
    +
    + {props.hasInteractiveEditor && ( +
    + + + {t('aria.interactive-editor-desc')} + +
    + )}
    diff --git a/client/src/templates/Challenges/generic/content-outline.css b/client/src/templates/Challenges/generic/content-outline.css new file mode 100644 index 00000000000..7e692039ec0 --- /dev/null +++ b/client/src/templates/Challenges/generic/content-outline.css @@ -0,0 +1,112 @@ +.content-layout-fluid { + padding-inline: 0 !important; +} + +.content-layout-container { + padding-inline: 0; + width: 100%; +} + +/* Two-column flex row containing the sidebar and the main content */ +.content-layout-row { + align-items: flex-start; + display: flex; +} + +.content-main-column { + flex: 1 1 auto; + margin-inline: auto; + max-width: calc(100% - 16rem); + min-width: 0; + /* Compensate row negative gutters used inside challengeBody. */ + padding-inline: 15px; +} + +.content-sidebar-column { + display: flex; + flex-direction: column; + flex: 0 0 16rem; + gap: 0.75rem; +} + +.content-outline-panel { + height: calc( + 100vh - var(--header-height) - var(--breadcrumbs-height) - + var(--action-row-height) + ); +} + +.content-outline-list { + list-style: none; + margin: 0; + padding: 0; +} + +.content-outline-item { + margin-bottom: 0.35rem; +} + +.content-outline-item-level-2 { + padding-inline-start: 0.85rem; +} + +.content-outline-item-level-3 { + padding-inline-start: 1.5rem; +} + +.content-outline-link { + color: var(--primary-color); + cursor: pointer; + display: block; + font-weight: 600; + padding: 0.2rem 0.45rem 0.2rem 1rem; + text-decoration: none; +} + +.content-outline-link:hover, +.content-outline-link:focus { + background-color: var(--tertiary-background); + text-decoration: none; +} + +.content-outline-link.active { + background-color: var(--tertiary-background); + box-shadow: inset 3px 0 0 0 var(--highlight-color); +} + +@media (min-width: 992px) { + .content-sidebar-column { + align-self: flex-start; + position: sticky; + top: calc( + var(--header-height) + var(--breadcrumbs-height) + + var(--action-row-height) + ); + } +} + +@media (max-width: 991px) { + .content-sidebar-column { + flex: 0 0 0; + overflow: visible; + } + + .content-main-column { + max-width: 100%; + } + + .content-outline-panel { + height: calc( + 100vh - var(--header-height) - var(--breadcrumbs-height) - + var(--action-row-height) + ); + left: 0; + position: fixed; + top: calc( + var(--header-height) + var(--breadcrumbs-height) + + var(--action-row-height) + ); + width: 100vw; + z-index: 200; + } +} diff --git a/client/src/templates/Challenges/generic/content-outline.test.tsx b/client/src/templates/Challenges/generic/content-outline.test.tsx new file mode 100644 index 00000000000..0ce900d6949 --- /dev/null +++ b/client/src/templates/Challenges/generic/content-outline.test.tsx @@ -0,0 +1,64 @@ +// @vitest-environment jsdom +import { describe, expect, test } from 'vitest'; + +import { + buildContentOutlineItems, + createAnchorId, + contentHeadingSelector +} from './content-outline'; + +describe('content-outline', () => { + test('createAnchorId should normalize heading text', () => { + expect(createAnchorId(' HTML Basics! ')).toBe('html-basics'); + expect(createAnchorId('A11y & SEO')).toBe('a11y-seo'); + }); + + test('buildContentOutlineItems should include h2/h3 headings and assign ids', () => { + const root = document.createElement('div'); + root.innerHTML = ` +

    HTML Basics

    +

    Semantic Elements

    +

    HTML Basics

    +

    With Existing Id

    +

    + `; + + const headingElements = Array.from( + root.querySelectorAll(contentHeadingSelector) + ); + + const items = buildContentOutlineItems(headingElements); + + expect(items).toEqual([ + { id: 'html-basics', level: 2, text: 'HTML Basics' }, + { id: 'semantic-elements', level: 3, text: 'Semantic Elements' }, + { id: 'html-basics-2', level: 2, text: 'HTML Basics' }, + { id: 'existing-id', level: 3, text: 'With Existing Id' } + ]); + + expect(headingElements[0]?.id).toBe('html-basics'); + expect(headingElements[1]?.id).toBe('semantic-elements'); + expect(headingElements[2]?.id).toBe('html-basics-2'); + expect(headingElements[3]?.id).toBe('existing-id'); + }); + + test('buildContentOutlineItems should ignore headings outside h2/h3', () => { + const root = document.createElement('div'); + root.innerHTML = ` +

    Page Title

    +

    Section One

    +

    Ignored Subsection

    +

    Section One Details

    + `; + + const headingElements = Array.from( + root.querySelectorAll(contentHeadingSelector) + ); + const items = buildContentOutlineItems(headingElements); + + expect(items).toEqual([ + { id: 'section-one', level: 2, text: 'Section One' }, + { id: 'section-one-details', level: 3, text: 'Section One Details' } + ]); + }); +}); diff --git a/client/src/templates/Challenges/generic/content-outline.tsx b/client/src/templates/Challenges/generic/content-outline.tsx new file mode 100644 index 00000000000..5671e85027c --- /dev/null +++ b/client/src/templates/Challenges/generic/content-outline.tsx @@ -0,0 +1,172 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { useMediaQuery } from 'react-responsive'; +import { Link as ScrollLink, scroller } from 'react-scroll'; + +import { ChallengeNode } from '../../../redux/prop-types'; +import SidebarPanel, { + useActiveHeading, + useStickyScrollOffset +} from '../../../components/sidebar-panel'; +import { MAX_MOBILE_WIDTH } from '../../../../config/misc'; + +import './content-outline.css'; + +export interface ContentOutlineItem { + id: string; + level: 1 | 2 | 3; + text: string; +} + +export const contentHeadingSelector = 'h2, h3'; + +export const createAnchorId = (value: string): string => + value + .trim() + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-'); + +export const buildContentOutlineItems = ( + headingElements: HTMLHeadingElement[] +): ContentOutlineItem[] => { + const idCounts = new Map(); + + return headingElements + .map((heading, index) => { + const text = heading.textContent?.trim() ?? ''; + if (!text) { + return null; + } + + const baseId = heading.id || createAnchorId(text); + const normalizedBaseId = baseId || `content-section-${index + 1}`; + const count = (idCounts.get(normalizedBaseId) ?? 0) + 1; + idCounts.set(normalizedBaseId, count); + const id = count > 1 ? `${normalizedBaseId}-${count}` : normalizedBaseId; + + if (heading.id !== id) { + heading.id = id; + } + + return { + id, + level: heading.tagName === 'H3' ? 3 : 2, + text + }; + }) + .filter((item): item is ContentOutlineItem => item !== null); +}; + +type ContentOutlineProps = { + description?: string; + instructions?: string; + nodules?: ChallengeNode['challenge']['nodules']; + showInteractiveEditor: boolean; + showOutline: boolean; + onClose?: () => void; + children: React.ReactNode; +}; + +function ContentOutline({ + description, + instructions, + nodules, + showInteractiveEditor, + showOutline: showContentOutline, + onClose, + children +}: ContentOutlineProps) { + const isMobileSidebar = useMediaQuery({ maxWidth: MAX_MOBILE_WIDTH }); + const [contentOutlineItems, setContentOutlineItems] = useState< + ContentOutlineItem[] + >([]); + const contentRef = useRef(null); + + const contentScrollOffset = useStickyScrollOffset( + ['--header-height', '--breadcrumbs-height', '--action-row-height'], + 8 + ); + + const activeHeadingId = useActiveHeading( + showContentOutline ? contentOutlineItems.map(item => item.id) : [], + contentScrollOffset + ); + + const handleItemClick = React.useCallback( + (id: string) => { + scroller.scrollTo(id, { + duration: 0, + smooth: false, + offset: contentScrollOffset + }); + if (isMobileSidebar) { + onClose?.(); + } + }, + [contentScrollOffset, isMobileSidebar, onClose] + ); + + useEffect(() => { + if (!contentRef.current) { + setContentOutlineItems([]); + return; + } + + const headingElements = Array.from( + contentRef.current.querySelectorAll( + contentHeadingSelector + ) + ); + const nextOutlineItems = buildContentOutlineItems(headingElements); + + setContentOutlineItems(nextOutlineItems); + }, [ + description, + instructions, + nodules, + showInteractiveEditor, + showContentOutline + ]); + + return ( +
    +
    + {showContentOutline && ( +
    + + + +
    + )} +
    +
    {children}
    +
    +
    +
    + ); +} + +ContentOutline.displayName = 'ContentOutline'; + +export default ContentOutline; diff --git a/client/src/templates/Challenges/generic/show.tsx b/client/src/templates/Challenges/generic/show.tsx index 6369d0c2271..b454842a1fe 100644 --- a/client/src/templates/Challenges/generic/show.tsx +++ b/client/src/templates/Challenges/generic/show.tsx @@ -4,10 +4,11 @@ import Helmet from 'react-helmet'; import { useTranslation } from 'react-i18next'; import { connect } from 'react-redux'; import { Container, Col, Row, Button, Spacer } from '@freecodecamp/ui'; +import { challengeTypes } from '@freecodecamp/shared/config/challenge-types'; import { isEqual } from 'lodash'; import store from 'store'; -import { YouTubeEvent } from 'react-youtube'; import { ObserveKeys } from 'react-hotkeys'; +import { YouTubeEvent } from 'react-youtube'; // Local Utilities import PrismFormatted from '../components/prism-formatted'; @@ -36,6 +37,7 @@ import ChallengeExplanation from '../components/challenge-explanation'; import ChallengeTranscript from '../components/challenge-transcript'; import HelpModal from '../components/help-modal'; import { SceneSubject } from '../components/scene/scene-subject'; +import ContentOutline from './content-outline'; // Styles import './show.css'; @@ -246,6 +248,149 @@ const ShowGeneric = ({ setShowInteractiveEditor(!showInteractiveEditor); }; + const isReviewChallenge = challengeType === challengeTypes.review; + + const [showContentOutline, setShowContentOutline] = useState(false); + + const actionRowProps = + isReviewChallenge && hasInteractiveEditor + ? { + hasContentOutline: true as const, + showContentOutline, + onToggleContentOutline: () => + setShowContentOutline(current => !current), + hasInteractiveEditor: true as const, + showInteractiveEditor, + toggleInteractiveEditor + } + : isReviewChallenge + ? { + hasContentOutline: true as const, + showContentOutline, + onToggleContentOutline: () => + setShowContentOutline(current => !current) + } + : hasInteractiveEditor + ? { + hasInteractiveEditor: true as const, + showInteractiveEditor, + toggleInteractiveEditor + } + : null; + + const challengeBody = ( + <> + + + {title} + + + + + {description && ( + + + + + )} + + {nodules?.map((nodule, i) => { + return ( + + {renderNodule(nodule, showInteractiveEditor)} + + ); + })} + + + {videoId && ( + <> + + + + )} + + + {scene && } + + + {transcript && } + + {instructions && ( + <> + + + + )} + + {assignments.length > 0 && ( + + + + )} + + {questions.length > 0 && ( + + + + )} + + {explanation ? ( + + ) : null} + + {!hasAnsweredMcqCorrectly && ( +

    {t('learn.answered-mcq')}

    + )} + + + + + + + + + + + ); + return ( - - {hasInteractiveEditor && ( - + {actionRowProps && } + + {isReviewChallenge ? ( + + showOutline={showContentOutline} + onClose={() => setShowContentOutline(false)} + > + {challengeBody} + + ) : ( + + {challengeBody} + )} - - - - - - {title} - - - - - {description && ( - - - - - )} - - {nodules?.map((nodule, i) => { - return ( - - {renderNodule(nodule, showInteractiveEditor)} - - ); - })} - - - {videoId && ( - <> - - - - )} - - - {scene && } - - - {transcript && } - - {instructions && ( - <> - - - - )} - - {assignments.length > 0 && ( - - - - )} - - {questions.length > 0 && ( - - - - )} - - {explanation ? ( - - ) : null} - - {!hasAnsweredMcqCorrectly && ( -

    {t('learn.answered-mcq')}

    - )} - - - - - - - - - -
    -
    diff --git a/e2e/content-outline.spec.ts b/e2e/content-outline.spec.ts new file mode 100644 index 00000000000..6e9f4c44c61 --- /dev/null +++ b/e2e/content-outline.spec.ts @@ -0,0 +1,100 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Content outline - Desktop', () => { + test.skip(({ isMobile }) => isMobile, 'Only test on desktop'); + + test('shows section headings without a top menu header item', async ({ + page + }) => { + await page.goto( + '/learn/responsive-web-design-v9/review-semantic-html/review-semantic-html' + ); + + await expect( + page.getByRole('heading', { name: 'Semantic HTML Review' }) + ).toBeVisible(); + + const toggleButton = page.getByRole('button', { name: 'Outline' }); + await toggleButton.click(); + await expect(toggleButton).toHaveAttribute('aria-expanded', 'true'); + + const outlinePanel = page.getByRole('navigation', { + name: 'Content outline' + }); + await expect(outlinePanel).toBeVisible(); + + const outlineItems = outlinePanel.getByRole('listitem'); + expect(await outlineItems.count()).toBeGreaterThan(0); + + await expect( + outlinePanel.getByRole('link', { name: 'Semantic HTML Review' }) + ).toHaveCount(0); + }); + + test('keeps active nav item in view while scrolling', async ({ page }) => { + await page.goto( + '/learn/responsive-web-design-v9/review-semantic-html/review-semantic-html' + ); + + await page.getByRole('button', { name: 'Outline' }).click(); + + const outlinePanel = page.getByRole('navigation', { + name: 'Content outline' + }); + await expect(outlinePanel).toBeVisible(); + + const firstOutlineLink = outlinePanel.locator('a').first(); + await firstOutlineLink.click(); + await expect(firstOutlineLink).toHaveClass(/active/); + + await page.evaluate(() => { + window.scrollTo({ + top: document.body.scrollHeight, + behavior: 'auto' + }); + }); + + const activeLink = outlinePanel + .locator('.content-outline-link.active') + .first(); + await expect(activeLink).toBeVisible(); + + const inView = await activeLink.evaluate(item => { + const panel = item.closest('nav'); + if (!panel) return false; + + const panelRect = panel.getBoundingClientRect(); + const itemRect = item.getBoundingClientRect(); + return ( + itemRect.top >= panelRect.top && itemRect.bottom <= panelRect.bottom + ); + }); + + expect(inView).toBe(true); + }); +}); + +test.describe('Content outline - Mobile', () => { + test.skip(({ isMobile }) => !isMobile, 'Only test on mobile'); + + test('sidebar closes on mobile when an item is clicked', async ({ page }) => { + await page.goto( + '/learn/responsive-web-design-v9/review-semantic-html/review-semantic-html' + ); + + const toggleButton = page.getByRole('button', { name: 'Outline' }); + await toggleButton.click(); + await expect(toggleButton).toHaveAttribute('aria-expanded', 'true'); + + const outlinePanel = page.getByRole('navigation', { + name: 'Content outline' + }); + await expect(outlinePanel).toBeVisible(); + + const firstLink = outlinePanel.locator('a').first(); + await firstLink.click(); + + // panel should hide after click + await expect(outlinePanel).toBeHidden(); + }); +});