mirror of
https://github.com/reactjs/react.dev.git
synced 2026-02-22 03:42:14 +00:00
[Beta] Anchor for individual challenges & deepdive (#5318)
* [Beta] open deepdive content once access * [Beta] anchor individual challenges * [Beta] fix challenges anchor scroll when multiple * [Beta] refactor chanllenges anchor effects Co-authored-by: Jiawei.Jing <jiawei.jing@ambergroup.co.jp>
This commit is contained in:
@@ -9,6 +9,7 @@ import {ChallengeContents} from './Challenges';
|
||||
import {IconHint} from '../../Icon/IconHint';
|
||||
import {IconSolution} from '../../Icon/IconSolution';
|
||||
import {IconArrowSmall} from '../../Icon/IconArrowSmall';
|
||||
import {H4} from '../Heading';
|
||||
|
||||
interface ChallengeProps {
|
||||
isRecipes?: boolean;
|
||||
@@ -45,14 +46,16 @@ export function Challenge({
|
||||
return (
|
||||
<div className="p-5 sm:py-8 sm:px-8">
|
||||
<div>
|
||||
<h3 className="text-xl text-primary dark:text-primary-dark mb-2">
|
||||
<H4
|
||||
className="text-xl text-primary dark:text-primary-dark mb-2 mt-0 font-medium"
|
||||
id={currentChallenge.id}>
|
||||
<div className="font-bold block md:inline">
|
||||
{isRecipes ? 'Example' : 'Challenge'} {currentChallenge.order} of{' '}
|
||||
{totalChallenges}
|
||||
<span className="text-primary dark:text-primary-dark">: </span>
|
||||
</div>
|
||||
{currentChallenge.name}
|
||||
</h3>
|
||||
</H4>
|
||||
{currentChallenge.content}
|
||||
</div>
|
||||
<div className="flex justify-between items-center mt-4">
|
||||
|
||||
@@ -9,6 +9,7 @@ import {H2} from 'components/MDX/Heading';
|
||||
import {H4} from 'components/MDX/Heading';
|
||||
import {Challenge} from './Challenge';
|
||||
import {Navigation} from './Navigation';
|
||||
import {useRouter} from 'next/router';
|
||||
|
||||
interface ChallengesProps {
|
||||
children: React.ReactElement[];
|
||||
@@ -67,6 +68,11 @@ const parseChallengeContents = (
|
||||
return contents;
|
||||
};
|
||||
|
||||
enum QueuedScroll {
|
||||
INIT = 'init',
|
||||
NEXT = 'next',
|
||||
}
|
||||
|
||||
export function Challenges({
|
||||
children,
|
||||
isRecipes,
|
||||
@@ -76,19 +82,32 @@ export function Challenges({
|
||||
const challenges = parseChallengeContents(children);
|
||||
const totalChallenges = challenges.length;
|
||||
const scrollAnchorRef = useRef<HTMLDivElement>(null);
|
||||
const queuedScrollRef = useRef<boolean>(false);
|
||||
const queuedScrollRef = useRef<undefined | QueuedScroll>(QueuedScroll.INIT);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const currentChallenge = challenges[activeIndex];
|
||||
const {asPath} = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (queuedScrollRef.current === true) {
|
||||
queuedScrollRef.current = false;
|
||||
if (queuedScrollRef.current === QueuedScroll.INIT) {
|
||||
const initIndex = challenges.findIndex(
|
||||
(challenge) => challenge.id === asPath.split('#')[1]
|
||||
);
|
||||
if (initIndex === -1) {
|
||||
queuedScrollRef.current = undefined;
|
||||
} else if (initIndex !== activeIndex) {
|
||||
setActiveIndex(initIndex);
|
||||
}
|
||||
}
|
||||
if (queuedScrollRef.current) {
|
||||
scrollAnchorRef.current!.scrollIntoView({
|
||||
block: 'start',
|
||||
behavior: 'smooth',
|
||||
...(queuedScrollRef.current === QueuedScroll.NEXT && {
|
||||
behavior: 'smooth',
|
||||
}),
|
||||
});
|
||||
queuedScrollRef.current = undefined;
|
||||
}
|
||||
});
|
||||
}, [activeIndex, asPath, challenges]);
|
||||
|
||||
const handleChallengeChange = (index: number) => {
|
||||
setActiveIndex(index);
|
||||
@@ -129,7 +148,7 @@ export function Challenges({
|
||||
hasNextChallenge={activeIndex < totalChallenges - 1}
|
||||
handleClickNextChallenge={() => {
|
||||
setActiveIndex((i) => i + 1);
|
||||
queuedScrollRef.current = true;
|
||||
queuedScrollRef.current = QueuedScroll.NEXT;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,8 @@ import {IconDeepDive} from '../Icon/IconDeepDive';
|
||||
import {IconCodeBlock} from '../Icon/IconCodeBlock';
|
||||
import {Button} from '../Button';
|
||||
import {H4} from './Heading';
|
||||
import {useRouter} from 'next/router';
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
|
||||
interface ExpandableExampleProps {
|
||||
children: React.ReactNode;
|
||||
@@ -17,15 +19,29 @@ interface ExpandableExampleProps {
|
||||
}
|
||||
|
||||
function ExpandableExample({children, excerpt, type}: ExpandableExampleProps) {
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
const isDeepDive = type === 'DeepDive';
|
||||
const isExample = type === 'Example';
|
||||
|
||||
if (!Array.isArray(children) || children[0].type.mdxName !== 'h4') {
|
||||
throw Error(
|
||||
`Expandable content ${type} is missing a corresponding title at the beginning`
|
||||
);
|
||||
}
|
||||
const isDeepDive = type === 'DeepDive';
|
||||
const isExample = type === 'Example';
|
||||
const id = children[0].props.id;
|
||||
|
||||
const queuedExpandRef = useRef<boolean>(true);
|
||||
const {asPath} = useRouter();
|
||||
// init as expanded to prevent flash
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
|
||||
// asPath would mismatch between server and client, reset here instead of put it into init state
|
||||
useEffect(() => {
|
||||
if (queuedExpandRef.current) {
|
||||
queuedExpandRef.current = false;
|
||||
if (id !== asPath.split('#')[1]) {
|
||||
setIsExpanded(false);
|
||||
}
|
||||
}
|
||||
}, [asPath, id]);
|
||||
|
||||
return (
|
||||
<details
|
||||
@@ -67,7 +83,7 @@ function ExpandableExample({children, excerpt, type}: ExpandableExampleProps) {
|
||||
</h5>
|
||||
<div className="mb-4">
|
||||
<H4
|
||||
id={children[0].props.id}
|
||||
id={id}
|
||||
className="text-xl font-bold text-primary dark:text-primary-dark">
|
||||
{children[0].props.children}
|
||||
</H4>
|
||||
|
||||
Reference in New Issue
Block a user