feat(client): sidebar-nav on review-pages (#65897)

Co-authored-by: Huyen Nguyen <25715018+huyenltnguyen@users.noreply.github.com>
This commit is contained in:
Sem Bauke
2026-03-16 11:18:12 +01:00
committed by GitHub
parent b68af56d7d
commit f7753e8a22
14 changed files with 878 additions and 192 deletions

View File

@@ -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",

View File

@@ -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 {

View File

@@ -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 (
<aside className='settings-sidebar-nav'>
<SidebarPanel className='settings-sidebar-nav'>
<ul>
<li>
<SidebarPanel.Item>
<ScrollLink
to='account'
href='#account'
className='sidebar-nav-section-heading'
smooth={true}
offset={-48}
offset={scrollOffset}
duration={300}
spy={true}
hashSpy={true}
@@ -41,14 +44,14 @@ function SettingsSidebarNav({
>
{t('settings.headings.account')}
</ScrollLink>
</li>
<li>
</SidebarPanel.Item>
<SidebarPanel.Item>
<ScrollLink
to='privacy'
href='#privacy'
className='sidebar-nav-section-heading'
smooth={true}
offset={-48}
offset={scrollOffset}
duration={300}
spy={true}
hashSpy={true}
@@ -56,14 +59,14 @@ function SettingsSidebarNav({
>
{t('settings.headings.privacy')}
</ScrollLink>
</li>
<li>
</SidebarPanel.Item>
<SidebarPanel.Item>
<ScrollLink
to='email'
href='#email'
className='sidebar-nav-section-heading'
smooth={true}
offset={-48}
offset={scrollOffset}
duration={300}
spy={true}
hashSpy={true}
@@ -71,14 +74,14 @@ function SettingsSidebarNav({
>
{t('settings.email.heading')}
</ScrollLink>
</li>
<li>
</SidebarPanel.Item>
<SidebarPanel.Item>
<ScrollLink
to='honesty'
href='#honesty'
className='sidebar-nav-section-heading'
smooth={true}
offset={-48}
offset={scrollOffset}
duration={300}
spy={true}
hashSpy={true}
@@ -86,14 +89,14 @@ function SettingsSidebarNav({
>
{t('settings.headings.honesty')}
</ScrollLink>
</li>
<li>
</SidebarPanel.Item>
<SidebarPanel.Item>
<ScrollLink
to='exam-token'
href='#exam-token'
className='sidebar-nav-section-heading'
smooth={true}
offset={-48}
offset={scrollOffset}
duration={300}
spy={true}
hashSpy={true}
@@ -101,14 +104,14 @@ function SettingsSidebarNav({
>
{t('exam-token.exam-token')}
</ScrollLink>
</li>
<li>
</SidebarPanel.Item>
<SidebarPanel.Item>
<ScrollLink
to='certifications'
href='#certifications'
className='sidebar-nav-section-heading'
smooth={true}
offset={-48}
offset={scrollOffset}
duration={300}
spy={true}
hashSpy={true}
@@ -118,13 +121,13 @@ function SettingsSidebarNav({
</ScrollLink>
<ul>
{currentCertifications.map(slug => (
<li key={slug}>
<SidebarPanel.Item key={slug}>
<ScrollLink
to={`cert-${slug}`}
href={`#cert-${slug}`}
className={'sidebar-nav-anchor-btn'}
smooth={true}
offset={-48}
offset={scrollOffset}
duration={300}
spy={true}
hashSpy={true}
@@ -132,17 +135,17 @@ function SettingsSidebarNav({
>
{t(`certification.title.${slug}`, slug)}
</ScrollLink>
</li>
</SidebarPanel.Item>
))}
</ul>
</li>
<li>
</SidebarPanel.Item>
<SidebarPanel.Item>
<ScrollLink
to='legacy-certifications'
href='#legacy-certifications'
className='sidebar-nav-section-heading'
smooth={true}
offset={-48}
offset={scrollOffset}
duration={300}
spy={true}
hashSpy={true}
@@ -152,13 +155,13 @@ function SettingsSidebarNav({
</ScrollLink>
<ul>
{allLegacyCertifications.map(slug => (
<li key={slug}>
<SidebarPanel.Item key={slug}>
<ScrollLink
to={`cert-${slug}`}
href={`#cert-${slug}`}
className={'sidebar-nav-anchor-btn'}
smooth={true}
offset={-48}
offset={scrollOffset}
duration={300}
spy={true}
hashSpy={true}
@@ -166,19 +169,19 @@ function SettingsSidebarNav({
>
{t(`certification.title.${slug}`, slug)}
</ScrollLink>
</li>
</SidebarPanel.Item>
))}
</ul>
<ul>
{showUpcomingChanges &&
upcomingCertifications.map(slug => (
<li key={slug}>
<SidebarPanel.Item key={slug}>
<ScrollLink
to={`cert-${slug}`}
href={`#cert-${slug}`}
className={'sidebar-nav-anchor-btn'}
smooth={true}
offset={-48}
offset={scrollOffset}
duration={300}
spy={true}
hashSpy={true}
@@ -186,18 +189,18 @@ function SettingsSidebarNav({
>
{t(`certification.title.${slug}`, slug)}
</ScrollLink>
</li>
</SidebarPanel.Item>
))}
</ul>
</li>
</SidebarPanel.Item>
{userToken && (
<li>
<SidebarPanel.Item>
<ScrollLink
to='user-token'
href='#user-token'
className='sidebar-nav-section-heading'
smooth={true}
offset={-48}
offset={scrollOffset}
duration={300}
spy={true}
hashSpy={true}
@@ -205,15 +208,15 @@ function SettingsSidebarNav({
>
{t('user-token.title')}
</ScrollLink>
</li>
</SidebarPanel.Item>
)}
<li>
<SidebarPanel.Item>
<ScrollLink
to='danger-zone'
href='#danger-zone'
className='sidebar-nav-section-heading'
smooth={true}
offset={-48}
offset={scrollOffset}
duration={300}
spy={true}
hashSpy={true}
@@ -221,9 +224,9 @@ function SettingsSidebarNav({
>
{t('settings.danger.heading')}
</ScrollLink>
</li>
</SidebarPanel.Item>
</ul>
</aside>
</SidebarPanel>
);
}

View File

@@ -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';

View File

@@ -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;
}

View File

@@ -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 <aside className={asideClassName}>{children}</aside>;
}
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 <li className={liClassName}>{children}</li>;
}
SidebarPanelItem.displayName = 'SidebarPanel.Item';
const SidebarPanel = Object.assign(SidebarPanelRoot, {
Item: SidebarPanelItem
});
export default SidebarPanel;

View File

@@ -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<string | null>(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;

View File

@@ -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;

View File

@@ -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 (
<div className='action-row'>
<div className='tabs-row'>
<div className='interactive-editor-tab'>
<button
aria-expanded={!!showInteractiveEditor}
aria-describedby='interactive-editor-desc'
onClick={toggleInteractiveEditor}
>
{t('learn.editor-tabs.interactive-editor')}
</button>
<span id='interactive-editor-desc' className='sr-only'>
{t('aria.interactive-editor-desc')}
</span>
<div className='tabs-row-left'>
{props.hasContentOutline && (
<button
aria-controls='content-outline-panel'
aria-expanded={props.showContentOutline}
onClick={props.onToggleContentOutline}
>
{t('buttons.outline')}
</button>
)}
</div>
<div className='tabs-row-right'>
{props.hasInteractiveEditor && (
<div className='interactive-editor-tab'>
<button
aria-expanded={!!props.showInteractiveEditor}
aria-describedby='interactive-editor-desc'
onClick={props.toggleInteractiveEditor}
>
{t('learn.editor-tabs.interactive-editor')}
</button>
<span id='interactive-editor-desc' className='sr-only'>
{t('aria.interactive-editor-desc')}
</span>
</div>
)}
</div>
</div>
</div>

View File

@@ -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;
}
}

View File

@@ -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 = `
<h2>HTML Basics</h2>
<h3>Semantic Elements</h3>
<h2>HTML Basics</h2>
<h3 id="existing-id">With Existing Id</h3>
<h2> </h2>
`;
const headingElements = Array.from(
root.querySelectorAll<HTMLHeadingElement>(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 = `
<h1>Page Title</h1>
<h2>Section One</h2>
<h4>Ignored Subsection</h4>
<h3>Section One Details</h3>
`;
const headingElements = Array.from(
root.querySelectorAll<HTMLHeadingElement>(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' }
]);
});
});

View File

@@ -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<string, number>();
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<HTMLDivElement | null>(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<HTMLHeadingElement>(
contentHeadingSelector
)
);
const nextOutlineItems = buildContentOutlineItems(headingElements);
setContentOutlineItems(nextOutlineItems);
}, [
description,
instructions,
nodules,
showInteractiveEditor,
showContentOutline
]);
return (
<div className='content-layout-container'>
<div className='content-layout-row'>
{showContentOutline && (
<div className='content-sidebar-column'>
<SidebarPanel className='content-outline-panel'>
<nav aria-label='Content outline'>
<ul className='content-outline-list'>
{contentOutlineItems.map(item => (
<SidebarPanel.Item
className={`content-outline-item content-outline-item-level-${item.level}`}
key={item.id}
>
<ScrollLink
className={`content-outline-link${activeHeadingId === item.id ? ' active' : ''}`}
duration={0}
isDynamic={true}
offset={contentScrollOffset}
onClick={() => handleItemClick(item.id)}
smooth={false}
to={item.id}
>
{item.text}
</ScrollLink>
</SidebarPanel.Item>
))}
</ul>
</nav>
</SidebarPanel>
</div>
)}
<div className='content-main-column'>
<div ref={contentRef}>{children}</div>
</div>
</div>
</div>
);
}
ContentOutline.displayName = 'ContentOutline';
export default ContentOutline;

View File

@@ -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 = (
<>
<Spacer size='m' />
<ChallengeTitle
isCompleted={isChallengeCompleted}
translationPending={translationPending}
>
{title}
</ChallengeTitle>
<Spacer size='m' />
{description && (
<Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}>
<ChallengeDescription
description={description}
superBlock={superBlock}
/>
<Spacer size='m' />
</Col>
)}
{nodules?.map((nodule, i) => {
return (
<React.Fragment key={i}>
{renderNodule(nodule, showInteractiveEditor)}
</React.Fragment>
);
})}
<Col lg={10} lgOffset={1} md={10} mdOffset={1}>
{videoId && (
<>
<VideoPlayer
bilibiliIds={bilibiliIds}
onVideoLoad={handleVideoIsLoaded}
title={title}
videoId={videoId}
videoIsLoaded={videoIsLoaded}
videoLocaleIds={videoLocaleIds}
/>
<Spacer size='m' />
</>
)}
</Col>
{scene && <Scene scene={scene} sceneSubject={sceneSubject} />}
<Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}>
{transcript && <ChallengeTranscript transcript={transcript} />}
{instructions && (
<>
<ChallengeDescription
instructions={instructions}
superBlock={superBlock}
/>
<Spacer size='m' />
</>
)}
{assignments.length > 0 && (
<ObserveKeys only={['ctrl', 'cmd', 'enter']}>
<Assignments
assignments={assignments}
allAssignmentsCompleted={allAssignmentsCompleted}
handleAssignmentChange={handleAssignmentChange}
/>
</ObserveKeys>
)}
{questions.length > 0 && (
<ObserveKeys only={['ctrl', 'cmd', 'enter']}>
<MultipleChoiceQuestions
questions={questions}
selectedOptions={selectedMcqOptions}
handleOptionChange={handleMcqOptionChange}
submittedMcqAnswers={submittedMcqAnswers}
showFeedback={showFeedback}
superBlock={superBlock}
/>
</ObserveKeys>
)}
{explanation ? (
<ChallengeExplanation explanation={explanation} />
) : null}
{!hasAnsweredMcqCorrectly && (
<p className='text-center'>{t('learn.answered-mcq')}</p>
)}
<Button block={true} variant='primary' onClick={handleSubmit}>
{questions.length == 0
? t('buttons.submit')
: t('buttons.check-answer')}
</Button>
<Spacer size='xxs' />
<Button block={true} variant='primary' onClick={openHelpModal}>
{t('buttons.ask-for-help')}
</Button>
<Spacer size='l' />
</Col>
<CompletionModal />
<HelpModal
challengeTitle={title}
challengeBlock={block}
superBlock={superBlock}
/>
</>
);
return (
<Hotkeys
executeChallenge={handleSubmit}
@@ -256,127 +401,28 @@ const ShowGeneric = ({
<Helmet
title={`${blockNameTitle} | ${t('learn.learn')} | freeCodeCamp.org`}
/>
<Container fluid>
{hasInteractiveEditor && (
<ActionRow
hasInteractiveEditor={hasInteractiveEditor}
<Container
className={isReviewChallenge ? 'content-layout-fluid' : undefined}
fluid
>
{actionRowProps && <ActionRow {...actionRowProps} />}
{isReviewChallenge ? (
<ContentOutline
description={description}
instructions={instructions}
nodules={nodules}
showInteractiveEditor={showInteractiveEditor}
toggleInteractiveEditor={toggleInteractiveEditor}
/>
showOutline={showContentOutline}
onClose={() => setShowContentOutline(false)}
>
<Row>{challengeBody}</Row>
</ContentOutline>
) : (
<Container>
<Row>{challengeBody}</Row>
</Container>
)}
<Container>
<Row>
<Spacer size='m' />
<ChallengeTitle
isCompleted={isChallengeCompleted}
translationPending={translationPending}
>
{title}
</ChallengeTitle>
<Spacer size='m' />
{description && (
<Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}>
<ChallengeDescription
description={description}
superBlock={superBlock}
/>
<Spacer size='m' />
</Col>
)}
{nodules?.map((nodule, i) => {
return (
<React.Fragment key={i}>
{renderNodule(nodule, showInteractiveEditor)}
</React.Fragment>
);
})}
<Col lg={10} lgOffset={1} md={10} mdOffset={1}>
{videoId && (
<>
<VideoPlayer
bilibiliIds={bilibiliIds}
onVideoLoad={handleVideoIsLoaded}
title={title}
videoId={videoId}
videoIsLoaded={videoIsLoaded}
videoLocaleIds={videoLocaleIds}
/>
<Spacer size='m' />
</>
)}
</Col>
{scene && <Scene scene={scene} sceneSubject={sceneSubject} />}
<Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}>
{transcript && <ChallengeTranscript transcript={transcript} />}
{instructions && (
<>
<ChallengeDescription
instructions={instructions}
superBlock={superBlock}
/>
<Spacer size='m' />
</>
)}
{assignments.length > 0 && (
<ObserveKeys only={['ctrl', 'cmd', 'enter']}>
<Assignments
assignments={assignments}
allAssignmentsCompleted={allAssignmentsCompleted}
handleAssignmentChange={handleAssignmentChange}
/>
</ObserveKeys>
)}
{questions.length > 0 && (
<ObserveKeys only={['ctrl', 'cmd', 'enter']}>
<MultipleChoiceQuestions
questions={questions}
selectedOptions={selectedMcqOptions}
handleOptionChange={handleMcqOptionChange}
submittedMcqAnswers={submittedMcqAnswers}
showFeedback={showFeedback}
superBlock={superBlock}
/>
</ObserveKeys>
)}
{explanation ? (
<ChallengeExplanation explanation={explanation} />
) : null}
{!hasAnsweredMcqCorrectly && (
<p className='text-center'>{t('learn.answered-mcq')}</p>
)}
<Button block={true} variant='primary' onClick={handleSubmit}>
{questions.length == 0
? t('buttons.submit')
: t('buttons.check-answer')}
</Button>
<Spacer size='xxs' />
<Button block={true} variant='primary' onClick={openHelpModal}>
{t('buttons.ask-for-help')}
</Button>
<Spacer size='l' />
</Col>
<CompletionModal />
<HelpModal
challengeTitle={title}
challengeBlock={block}
superBlock={superBlock}
/>
</Row>
</Container>
</Container>
</LearnLayout>
</Hotkeys>

100
e2e/content-outline.spec.ts Normal file
View File

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