mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-03-27 02:01:02 -04:00
feat(client): sidebar-nav on review-pages (#65897)
Co-authored-by: Huyen Nguyen <25715018+huyenltnguyen@users.noreply.github.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
3
client/src/components/sidebar-panel/index.ts
Normal file
3
client/src/components/sidebar-panel/index.ts
Normal 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';
|
||||
27
client/src/components/sidebar-panel/sidebar-panel.css
Normal file
27
client/src/components/sidebar-panel/sidebar-panel.css
Normal 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;
|
||||
}
|
||||
37
client/src/components/sidebar-panel/sidebar-panel.tsx
Normal file
37
client/src/components/sidebar-panel/sidebar-panel.tsx
Normal 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;
|
||||
62
client/src/components/sidebar-panel/use-active-heading.ts
Normal file
62
client/src/components/sidebar-panel/use-active-heading.ts
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
112
client/src/templates/Challenges/generic/content-outline.css
Normal file
112
client/src/templates/Challenges/generic/content-outline.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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' }
|
||||
]);
|
||||
});
|
||||
});
|
||||
172
client/src/templates/Challenges/generic/content-outline.tsx
Normal file
172
client/src/templates/Challenges/generic/content-outline.tsx
Normal 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;
|
||||
@@ -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
100
e2e/content-outline.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user