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 && (
+
+
+
+
+
+ )}
+
+
+
+ );
+}
+
+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();
+ });
+});