From a7c69883f613be7d06c15b23a3730cb8b528a597 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aatu=20V=C3=A4is=C3=A4nen?= Date: Thu, 28 Aug 2025 10:26:33 +0300 Subject: [PATCH 01/15] Start development of GuidedSteps --- .../GuidedSteps/GuidedStepItem.module.css | 18 ++ src/components/GuidedSteps/GuidedStepItem.tsx | 25 +++ src/components/GuidedSteps/GuidedStepTab.tsx | 121 +++++++++++++ .../GuidedSteps/GuidedSteps.module.css | 32 ++++ src/components/GuidedSteps/GuidedSteps.tsx | 79 ++++++++ src/components/GuidedSteps/index.ts | 1 + src/components/GuidedSteps/utils.tsx | 169 ++++++++++++++++++ src/theme/MDXComponents/index.tsx | 4 + 8 files changed, 449 insertions(+) create mode 100644 src/components/GuidedSteps/GuidedStepItem.module.css create mode 100644 src/components/GuidedSteps/GuidedStepItem.tsx create mode 100644 src/components/GuidedSteps/GuidedStepTab.tsx create mode 100644 src/components/GuidedSteps/GuidedSteps.module.css create mode 100644 src/components/GuidedSteps/GuidedSteps.tsx create mode 100644 src/components/GuidedSteps/index.ts create mode 100644 src/components/GuidedSteps/utils.tsx diff --git a/src/components/GuidedSteps/GuidedStepItem.module.css b/src/components/GuidedSteps/GuidedStepItem.module.css new file mode 100644 index 00000000..d51f5b48 --- /dev/null +++ b/src/components/GuidedSteps/GuidedStepItem.module.css @@ -0,0 +1,18 @@ +.step { + &.activeLines { + :global(pre) { + background-color: rgba(159, 133, 255, 0.25); + } + } + + :global(pre) { + border-radius: 0; + } + & > div { + box-shadow: unset; + } +} + +.codeLine { + transition: background-color 0.2s ease-in-out; +} diff --git a/src/components/GuidedSteps/GuidedStepItem.tsx b/src/components/GuidedSteps/GuidedStepItem.tsx new file mode 100644 index 00000000..2e3aafae --- /dev/null +++ b/src/components/GuidedSteps/GuidedStepItem.tsx @@ -0,0 +1,25 @@ +import { useImperativeHandle, useRef, forwardRef } from "react"; +import styles from "./Step.module.css"; +import { GuidedStepItemHandle, GuidedStepItemProps } from "./utils"; + +const GuidedStepItem = forwardRef(({ children }, ref) => { + const stepRef = useRef(null); + + useImperativeHandle(ref, () => { + const current = stepRef.current as HTMLSpanElement; + return Object.assign(current, { + activate: () => current?.classList.add(styles.activeLines), + deactivate: () => current?.classList.remove(styles.activeLines), + }) as GuidedStepItemHandle; + }); + + return ( + + {children} + + ); +}); + +GuidedStepItem.displayName = "GuidedStepItem"; + +export default GuidedStepItem; diff --git a/src/components/GuidedSteps/GuidedStepTab.tsx b/src/components/GuidedSteps/GuidedStepTab.tsx new file mode 100644 index 00000000..1c7c30d1 --- /dev/null +++ b/src/components/GuidedSteps/GuidedStepTab.tsx @@ -0,0 +1,121 @@ +import { + Children, + cloneElement, + isValidElement, + useLayoutEffect, + useRef, + useState, +} from "react"; +import cn from "classnames"; +import styles from "./GuidedSteps.module.css"; + +const GuidedStepTab: React.FC = ({ children }) => { + const [activeStepId, setActiveStepId] = useState(null); + const observerRef = useRef(null); + const observerContainerRef = useRef(null); + + // refs for debouncing highlights + const lastHighlightTime = useRef(0); + const pendingHighlight = useRef(undefined); + + const instructionsRef = useRef([]); + const stepsRef = useStepsRef(); + + useLayoutEffect(() => { + const instructionsHeight = + observerContainerRef.current?.getBoundingClientRect().height; + if (instructionsHeight) { + observerContainerRef.current.parentElement.style.height = `calc(100vh + ${instructionsHeight}px)`; + } + const debounceDelay = 150; + + const debounceHighlightedStep = (step: any) => { + const now = Date.now(); + pendingHighlight.current = step.id; + setActiveStepId(step.id); + + if (now - lastHighlightTime.current > debounceDelay) { + stepsRef.current.get(step.id)?.activate(); + lastHighlightTime.current = now; + pendingHighlight.current = undefined; + } else { + setTimeout(() => { + if (pendingHighlight.current === step.id) { + stepsRef.current.get(step.id)?.activate(); + lastHighlightTime.current = Date.now(); + pendingHighlight.current = undefined; + } + }, debounceDelay); + } + }; + + const options = { + rootMargin: "117px 0px 80% 0px", + threshold: [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7], + }; + + observerRef.current = new IntersectionObserver((entries) => { + const visibleEntries = entries.filter( + (entry) => entry.isIntersecting && entry.intersectionRatio > 0.2 + ); + + console.log(visibleEntries); + + if (visibleEntries.length > 0) { + const mostVisibleEntry = visibleEntries.reduce((max, entry) => + entry.intersectionRatio > max.intersectionRatio ? entry : max + ); + + debounceHighlightedStep(mostVisibleEntry.target.id); + } + }, options); + + instructionsRef.current.forEach((step) => { + if (observerRef.current) { + observerRef.current.observe(step); + } + }); + + stepsRef.current.get(instructions[0].id)?.activate(); + + return () => { + if (observerRef.current) { + observerRef.current.disconnect(); + observerRef.current = null; + } + }; + }, []); + + const highlightStep = (stepId: string) => { + setActiveStepId(stepId); + stepsRef.current.forEach((step) => step.deactivate()); + stepsRef.current.get(stepId)?.activate(); + }; + + return ( + <> +
+ {instructions.map((instruction, index) => ( +
(instructionsRef.current[index] = el)} + id={instruction.id} + className={cn(styles.instruction, { + [styles.active]: instruction.id === activeStepId, + })} + onClick={() => highlightStep(instruction.id)} + > +

{instruction.title}

+ {instruction.description &&

{instruction.description}

} +
+ ))} +
+ + {/* Code panel */} +
{childrenWithRefs}
+ + ); +}; + +export default GuidedStepTab; diff --git a/src/components/GuidedSteps/GuidedSteps.module.css b/src/components/GuidedSteps/GuidedSteps.module.css new file mode 100644 index 00000000..ac66c297 --- /dev/null +++ b/src/components/GuidedSteps/GuidedSteps.module.css @@ -0,0 +1,32 @@ +.guidedSteps { + display: grid; + grid-template-columns: minmax(40%, 1fr) minmax(60%, 1fr); +} + +.instructionsPanel { + height: calc(100vh - 60px); +} + +.instructions { + position: relative; + padding-top: var(--m-2); + height: max-content; +} + +.instruction { + padding: var(--m-2); + border-left: 4px solid transparent; + transition: all 0.2s ease-in-out; + &.active { + border-left-color: var(--color-dark-purple); + background-color: var(--color-tonal-neutral-0); + } +} + +.codePanel { + background-color: var(--color-code); + overflow: auto; + position: sticky; + top: 117px; + height: calc(100vh - var(--ifm-navbar-height)); +} diff --git a/src/components/GuidedSteps/GuidedSteps.tsx b/src/components/GuidedSteps/GuidedSteps.tsx new file mode 100644 index 00000000..ade8482a --- /dev/null +++ b/src/components/GuidedSteps/GuidedSteps.tsx @@ -0,0 +1,79 @@ +import { + Children, + cloneElement, + isValidElement, + useLayoutEffect, + useRef, + useState, +} from "react"; +import cn from "classnames"; +import styles from "./GuidedSteps.module.css"; +import { + GuidedStepsProps, + GuidedStepsProvider, + sanitizeGuidedStepsChildren, + useStepsRef, +} from "./utils"; +import GuidedStepTab from "./GuidedStepTab"; + +const GuidedStepTabs: React.FC = () => { + +} + +const GuidedStepsComponent: React.FC = (props) => { + const steps = useGuidedSteps(); + return ( +
+ + +
+ ); +}; + +const GuidedSteps: React.FC = ({ children }) => { + return ( + + + {sanitizeGuidedStepsChildren(children)} + + + ); +}; + +export default GuidedSteps; + +// +// +// ``` +// $ sudo dnf install selinux-policy-devel +// ``` +// +// +// ``` +// $ sudo teleport configure -o file +// ``` +// +// +// ``` +// $ sudo systemctl enable teleport +// ``` +// +// diff --git a/src/components/GuidedSteps/index.ts b/src/components/GuidedSteps/index.ts new file mode 100644 index 00000000..722cd272 --- /dev/null +++ b/src/components/GuidedSteps/index.ts @@ -0,0 +1 @@ +export { default } from "./GuidedSteps"; diff --git a/src/components/GuidedSteps/utils.tsx b/src/components/GuidedSteps/utils.tsx new file mode 100644 index 00000000..eb72af23 --- /dev/null +++ b/src/components/GuidedSteps/utils.tsx @@ -0,0 +1,169 @@ +import { + Children, + cloneElement, + createContext, + isValidElement, + RefObject, + useContext, + useMemo, + useRef, + type ReactElement, + type ReactNode, +} from "react"; + +type GuidedStepItem = + | ReactElement + | null + | false + | undefined; + +export interface GuidedStepTabProps { + title?: string; + value: any; + instructions: Array<{ + id: string; + title: string; + description: string; + }>; + children: GuidedStepItem | GuidedStepItem[]; +} + +type GuidedStepTab = + | ReactElement + | null + | false + | undefined; + +export interface GuidedStepsProps { + children?: GuidedStepTab | GuidedStepTab[]; +} + +export interface GuidedStepItemProps { + children: React.ReactNode; +} + +export interface GuidedStepItemHandle extends HTMLSpanElement { + activate: () => void; + deactivate: () => void; +} + +export const GuidedStepsItemContext = createContext +> | null>(null); + +export const GuidedStepTabProvider: React.FC<{ + children: React.ReactNode; +}> = ({ children }) => { + const stepsRef = useRef>(new Map()); + + return ( + + {children} + + ); +}; + +export const useGuidedStepItemsRef = () => { + const ref = useContext(GuidedStepsContext); + if (!ref) { + throw new Error( + "useGuidedStepItemsRef must be used within a GuidedStepsProvider" + ); + } + return ref; +}; + +export const GuidedStepsContext = createContext +> | null>(null); + +export const GuidedStepsProvider: React.FC<{ + children: React.ReactNode; +}> = ({ children }) => { + const stepsRef = useRef>(new Map()); + + return ( + + {children} + + ); +}; + +const extractGuidedStepTabs = (children: GuidedStepsProps["children"]) => { + return sanitizeGuidedStepsChildren(children).map( + ({ props: { value, title } }) => ({ + value, + title, + }) + ); +}; + +const useGuidedStepTabs = (props: Pick) => { + const { children } = props; + return useMemo(() => { + const tabs = extractGuidedStepTabs(children); + return tabs; + }, [children]); +}; + +export const useGuidedSteps = (props: GuidedStepsProps) => { + const tabs = useGuidedStepTabs(props); +}; + +const isGuidedStepTab = ( + component: ReactElement +): component is ReactElement => { + const { props, type } = component; + return ( + !!props && + typeof props === "object" && + "value" in props && + (type as React.ComponentType).displayName === "GuidedStepTab" + ); +}; + +export const sanitizeGuidedStepsChildren = ( + children: GuidedStepsProps["children"] +) => { + return (Children.toArray(children) + .filter((child) => child !== "/n") + .map((child) => { + if (!child || (isValidElement(child) && isGuidedStepTab(child))) { + return child; + } + + throw new Error( + "All children of the component must be components" + ); + }) + ?.filter(Boolean) ?? []) as ReactElement[]; +}; + +const isGuidedStepItem = ( + component: ReactElement +): component is ReactElement => { + const { props, type } = component; + return ( + !!props && + typeof props === "object" && + "children" in props && + (type as React.ComponentType).displayName === "GuidedStepItem" + ); +}; + +export const sanitizeGuidedStepTabChildren = ( + children: GuidedStepTabProps["children"] +) => { + return (Children.toArray(children) + .filter((child) => child !== "/n") + .map((child) => { + if (!child || (isValidElement(child) && isGuidedStepItem(child))) { + return child; + } + + throw new Error( + "All children of the component must be components" + ); + }) + ?.filter(Boolean) ?? []) as ReactElement[]; +}; diff --git a/src/theme/MDXComponents/index.tsx b/src/theme/MDXComponents/index.tsx index 71c74de8..4362e929 100644 --- a/src/theme/MDXComponents/index.tsx +++ b/src/theme/MDXComponents/index.tsx @@ -22,6 +22,8 @@ import Icon from "/src/components/Icon"; import Tile from "/src/components/Tile"; import TileGrid from "/src/components/TileGrid"; import ThumbsFeedback from "/src/components/ThumbsFeedback"; +import GuidedSteps from "/src/components/GuidedSteps"; +import GuidedStepItem from "/src/components/GuidedSteps/GuidedStepItem"; const MDXComponents: MDXComponentsObject = { ...OriginalMDXComponents, @@ -55,6 +57,8 @@ const MDXComponents: MDXComponentsObject = { ul: MDXUl, Var: (props) => , // needed to circumvent props mismatch in types ThumbsFeedback, + GuidedSteps, + GuidedStepItem, }; export default MDXComponents; From f1bb7e87628b740f1dc6226f83706f7e46c121bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aatu=20V=C3=A4is=C3=A4nen?= Date: Thu, 28 Aug 2025 14:51:20 +0300 Subject: [PATCH 02/15] Minimal GuidedSteps first iteration --- .../GuidedSteps/GuidedStepItem.module.css | 5 +- src/components/GuidedSteps/GuidedStepItem.tsx | 21 +- src/components/GuidedSteps/GuidedStepTab.tsx | 121 ----------- .../GuidedSteps/GuidedSteps.module.css | 31 ++- src/components/GuidedSteps/GuidedSteps.tsx | 189 ++++++++++++------ src/components/GuidedSteps/utils.tsx | 136 ++----------- 6 files changed, 188 insertions(+), 315 deletions(-) delete mode 100644 src/components/GuidedSteps/GuidedStepTab.tsx diff --git a/src/components/GuidedSteps/GuidedStepItem.module.css b/src/components/GuidedSteps/GuidedStepItem.module.css index d51f5b48..f48b1cb3 100644 --- a/src/components/GuidedSteps/GuidedStepItem.module.css +++ b/src/components/GuidedSteps/GuidedStepItem.module.css @@ -7,12 +7,9 @@ :global(pre) { border-radius: 0; + transition: background-color 0.2s ease-in-out; } & > div { box-shadow: unset; } } - -.codeLine { - transition: background-color 0.2s ease-in-out; -} diff --git a/src/components/GuidedSteps/GuidedStepItem.tsx b/src/components/GuidedSteps/GuidedStepItem.tsx index 2e3aafae..8d12eb89 100644 --- a/src/components/GuidedSteps/GuidedStepItem.tsx +++ b/src/components/GuidedSteps/GuidedStepItem.tsx @@ -1,17 +1,20 @@ import { useImperativeHandle, useRef, forwardRef } from "react"; -import styles from "./Step.module.css"; +import styles from "./GuidedStepItem.module.css"; import { GuidedStepItemHandle, GuidedStepItemProps } from "./utils"; -const GuidedStepItem = forwardRef(({ children }, ref) => { +const GuidedStepItem = forwardRef< + GuidedStepItemHandle, + Pick +>(({ children }, ref) => { const stepRef = useRef(null); - useImperativeHandle(ref, () => { - const current = stepRef.current as HTMLSpanElement; - return Object.assign(current, { - activate: () => current?.classList.add(styles.activeLines), - deactivate: () => current?.classList.remove(styles.activeLines), - }) as GuidedStepItemHandle; - }); + useImperativeHandle( + ref, + (): GuidedStepItemHandle => ({ + activate: (): void => stepRef.current?.classList.add(styles.activeLines), + deactivate: (): void => stepRef.current?.classList.remove(styles.activeLines), + }) + ); return ( diff --git a/src/components/GuidedSteps/GuidedStepTab.tsx b/src/components/GuidedSteps/GuidedStepTab.tsx deleted file mode 100644 index 1c7c30d1..00000000 --- a/src/components/GuidedSteps/GuidedStepTab.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { - Children, - cloneElement, - isValidElement, - useLayoutEffect, - useRef, - useState, -} from "react"; -import cn from "classnames"; -import styles from "./GuidedSteps.module.css"; - -const GuidedStepTab: React.FC = ({ children }) => { - const [activeStepId, setActiveStepId] = useState(null); - const observerRef = useRef(null); - const observerContainerRef = useRef(null); - - // refs for debouncing highlights - const lastHighlightTime = useRef(0); - const pendingHighlight = useRef(undefined); - - const instructionsRef = useRef([]); - const stepsRef = useStepsRef(); - - useLayoutEffect(() => { - const instructionsHeight = - observerContainerRef.current?.getBoundingClientRect().height; - if (instructionsHeight) { - observerContainerRef.current.parentElement.style.height = `calc(100vh + ${instructionsHeight}px)`; - } - const debounceDelay = 150; - - const debounceHighlightedStep = (step: any) => { - const now = Date.now(); - pendingHighlight.current = step.id; - setActiveStepId(step.id); - - if (now - lastHighlightTime.current > debounceDelay) { - stepsRef.current.get(step.id)?.activate(); - lastHighlightTime.current = now; - pendingHighlight.current = undefined; - } else { - setTimeout(() => { - if (pendingHighlight.current === step.id) { - stepsRef.current.get(step.id)?.activate(); - lastHighlightTime.current = Date.now(); - pendingHighlight.current = undefined; - } - }, debounceDelay); - } - }; - - const options = { - rootMargin: "117px 0px 80% 0px", - threshold: [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7], - }; - - observerRef.current = new IntersectionObserver((entries) => { - const visibleEntries = entries.filter( - (entry) => entry.isIntersecting && entry.intersectionRatio > 0.2 - ); - - console.log(visibleEntries); - - if (visibleEntries.length > 0) { - const mostVisibleEntry = visibleEntries.reduce((max, entry) => - entry.intersectionRatio > max.intersectionRatio ? entry : max - ); - - debounceHighlightedStep(mostVisibleEntry.target.id); - } - }, options); - - instructionsRef.current.forEach((step) => { - if (observerRef.current) { - observerRef.current.observe(step); - } - }); - - stepsRef.current.get(instructions[0].id)?.activate(); - - return () => { - if (observerRef.current) { - observerRef.current.disconnect(); - observerRef.current = null; - } - }; - }, []); - - const highlightStep = (stepId: string) => { - setActiveStepId(stepId); - stepsRef.current.forEach((step) => step.deactivate()); - stepsRef.current.get(stepId)?.activate(); - }; - - return ( - <> -
- {instructions.map((instruction, index) => ( -
(instructionsRef.current[index] = el)} - id={instruction.id} - className={cn(styles.instruction, { - [styles.active]: instruction.id === activeStepId, - })} - onClick={() => highlightStep(instruction.id)} - > -

{instruction.title}

- {instruction.description &&

{instruction.description}

} -
- ))} -
- - {/* Code panel */} -
{childrenWithRefs}
- - ); -}; - -export default GuidedStepTab; diff --git a/src/components/GuidedSteps/GuidedSteps.module.css b/src/components/GuidedSteps/GuidedSteps.module.css index ac66c297..7e69c992 100644 --- a/src/components/GuidedSteps/GuidedSteps.module.css +++ b/src/components/GuidedSteps/GuidedSteps.module.css @@ -1,32 +1,47 @@ .guidedSteps { display: grid; grid-template-columns: minmax(40%, 1fr) minmax(60%, 1fr); -} - -.instructionsPanel { - height: calc(100vh - 60px); + gap: var(--m-2); + margin-bottom: var(--ifm-leading); } .instructions { position: relative; - padding-top: var(--m-2); height: max-content; } .instruction { - padding: var(--m-2); + padding: var(--m-2) var(--m-1); border-left: 4px solid transparent; transition: all 0.2s ease-in-out; + min-height: 100px; + cursor: pointer; + border-bottom: 1px solid var(--color-tonal-neutral-1); + scroll-margin: var(--ifm-navbar-height); + &.active { border-left-color: var(--color-dark-purple); background-color: var(--color-tonal-neutral-0); } + &:last-child { + border-bottom: none; + } + + @media (--md-scr) { + padding: var(--m-2); + } } .codePanel { background-color: var(--color-code); overflow: auto; position: sticky; - top: 117px; - height: calc(100vh - var(--ifm-navbar-height)); + top: calc(var(--ifm-navbar-height) + var(--m-2)); + border-top-left-radius: var(--r-default); + border-bottom-left-radius: var(--r-default); + height: max-content; + + @media (--md-scr) { + border-radius: var(--r-default); + } } diff --git a/src/components/GuidedSteps/GuidedSteps.tsx b/src/components/GuidedSteps/GuidedSteps.tsx index ade8482a..bd856a36 100644 --- a/src/components/GuidedSteps/GuidedSteps.tsx +++ b/src/components/GuidedSteps/GuidedSteps.tsx @@ -1,79 +1,150 @@ -import { - Children, - cloneElement, - isValidElement, - useLayoutEffect, - useRef, - useState, -} from "react"; +import { useLayoutEffect, useRef, useState } from "react"; import cn from "classnames"; -import styles from "./GuidedSteps.module.css"; import { + GuidedStepItemHandle, GuidedStepsProps, - GuidedStepsProvider, sanitizeGuidedStepsChildren, - useStepsRef, + useGuidedSteps, } from "./utils"; -import GuidedStepTab from "./GuidedStepTab"; +import styles from "./GuidedSteps.module.css"; +import GuidedStepItem from "./GuidedStepItem"; -const GuidedStepTabs: React.FC = () => { +const GuidedStepsComponent: React.FC = (props) => { + const items = useGuidedSteps(props); + const [activeStepId, setActiveStepId] = useState( + items[0]?.id || null + ); + const observerRef = useRef(null); + const observerContainerRef = useRef(null); -} + const stepsRef = useRef>(new Map()); + + const lastHighlightTime = useRef(0); + const pendingHighlight = useRef(undefined); + + const instructionsRef = useRef([]); + + useLayoutEffect(() => { + const initializeObserver = () => { + const instructionsHeight = + observerContainerRef.current?.getBoundingClientRect().height; + if (instructionsHeight) { + observerContainerRef.current.parentElement.style.height = `calc(100px + ${instructionsHeight}px)`; + } + const debounceDelay = 200; + + const debounceHighlightedStep = (stepId: string) => { + const now = Date.now(); + pendingHighlight.current = stepId; + + if (now - lastHighlightTime.current > debounceDelay) { + highlightStep(stepId); + lastHighlightTime.current = now; + pendingHighlight.current = undefined; + } else { + setTimeout(() => { + if (pendingHighlight.current === stepId) { + highlightStep(stepId); + lastHighlightTime.current = Date.now(); + pendingHighlight.current = undefined; + } + }, debounceDelay); + } + }; + + const rootTopMargin = document.body.getBoundingClientRect().height - 200; + + const options = { + rootMargin: `-128px 0px -${rootTopMargin}px 0px`, + threshold: [0.2, 0.3, 0.4, 0.5, 0.6, 0.7], + }; + + observerRef.current = new IntersectionObserver((entries) => { + const visibleEntries = entries.filter( + (entry) => entry.isIntersecting && entry.intersectionRatio > 0.3 + ); + + if (visibleEntries.length > 0) { + const mostVisibleEntry = visibleEntries.reduce((max, entry) => + entry.intersectionRatio > max.intersectionRatio ? entry : max + ); + + debounceHighlightedStep(mostVisibleEntry.target.id); + } + }, options); + + instructionsRef.current.forEach((step) => { + if (observerRef.current) { + observerRef.current.observe(step); + } + }); + + stepsRef.current.get(items[0].id)?.activate(); + }; + + initializeObserver(); + + window.removeEventListener("resize", initializeObserver); + window.addEventListener("resize", initializeObserver); + + return () => { + if (observerRef.current) { + observerRef.current.disconnect(); + observerRef.current = null; + } + window.removeEventListener("resize", initializeObserver); + }; + }, []); + + const highlightStep = (stepId: string) => { + setActiveStepId(stepId); + stepsRef.current.forEach((step) => step.deactivate()); + stepsRef.current.get(stepId)?.activate(); + }; -const GuidedStepsComponent: React.FC = (props) => { - const steps = useGuidedSteps(); return (
- - +
+ {items.map(({ id, title, description }, index) => ( +
(instructionsRef.current[index] = el)} + id={id} + className={cn(styles.instruction, { + [styles.active]: id === activeStepId, + })} + onClick={() => { + highlightStep(id); + instructionsRef.current[index]?.scrollIntoView({ + block: "start", + behavior: "smooth", + }); + }} + > +

{title}

+ {description &&

{description}

} +
+ ))} +
+ +
+ {items.map(({ id, children }) => ( + stepsRef.current.set(id, el)}> + {children} + + ))} +
); }; const GuidedSteps: React.FC = ({ children }) => { return ( - - - {sanitizeGuidedStepsChildren(children)} - - + + {sanitizeGuidedStepsChildren(children)} + ); }; export default GuidedSteps; - -// -// -// ``` -// $ sudo dnf install selinux-policy-devel -// ``` -// -// -// ``` -// $ sudo teleport configure -o file -// ``` -// -// -// ``` -// $ sudo systemctl enable teleport -// ``` -// -// diff --git a/src/components/GuidedSteps/utils.tsx b/src/components/GuidedSteps/utils.tsx index eb72af23..f1c4b592 100644 --- a/src/components/GuidedSteps/utils.tsx +++ b/src/components/GuidedSteps/utils.tsx @@ -1,15 +1,4 @@ -import { - Children, - cloneElement, - createContext, - isValidElement, - RefObject, - useContext, - useMemo, - useRef, - type ReactElement, - type ReactNode, -} from "react"; +import { Children, isValidElement, useMemo, type ReactElement } from "react"; type GuidedStepItem = | ReactElement @@ -17,109 +6,45 @@ type GuidedStepItem = | false | undefined; -export interface GuidedStepTabProps { - title?: string; - value: any; - instructions: Array<{ - id: string; - title: string; - description: string; - }>; - children: GuidedStepItem | GuidedStepItem[]; -} - -type GuidedStepTab = - | ReactElement - | null - | false - | undefined; - export interface GuidedStepsProps { - children?: GuidedStepTab | GuidedStepTab[]; + children?: GuidedStepItem | GuidedStepItem[]; } export interface GuidedStepItemProps { + id: string; + title: string; + description?: string; children: React.ReactNode; } -export interface GuidedStepItemHandle extends HTMLSpanElement { +export interface GuidedStepItemHandle { activate: () => void; deactivate: () => void; } -export const GuidedStepsItemContext = createContext -> | null>(null); - -export const GuidedStepTabProvider: React.FC<{ - children: React.ReactNode; -}> = ({ children }) => { - const stepsRef = useRef>(new Map()); - - return ( - - {children} - - ); -}; - -export const useGuidedStepItemsRef = () => { - const ref = useContext(GuidedStepsContext); - if (!ref) { - throw new Error( - "useGuidedStepItemsRef must be used within a GuidedStepsProvider" - ); - } - return ref; -}; - -export const GuidedStepsContext = createContext -> | null>(null); - -export const GuidedStepsProvider: React.FC<{ - children: React.ReactNode; -}> = ({ children }) => { - const stepsRef = useRef>(new Map()); - - return ( - - {children} - - ); -}; - -const extractGuidedStepTabs = (children: GuidedStepsProps["children"]) => { +const extractGuidedStepItems = (children: GuidedStepsProps["children"]) => { return sanitizeGuidedStepsChildren(children).map( - ({ props: { value, title } }) => ({ - value, - title, - }) + ({ props: { id, title, description, children } }) => { + return { + id, + title, + description, + children, + }; + } ); }; -const useGuidedStepTabs = (props: Pick) => { +const useGuidedStepItems = (props: Pick) => { const { children } = props; return useMemo(() => { - const tabs = extractGuidedStepTabs(children); - return tabs; + const items = extractGuidedStepItems(children); + return items; }, [children]); }; export const useGuidedSteps = (props: GuidedStepsProps) => { - const tabs = useGuidedStepTabs(props); -}; - -const isGuidedStepTab = ( - component: ReactElement -): component is ReactElement => { - const { props, type } = component; - return ( - !!props && - typeof props === "object" && - "value" in props && - (type as React.ComponentType).displayName === "GuidedStepTab" - ); + return useGuidedStepItems(props); }; export const sanitizeGuidedStepsChildren = ( @@ -128,15 +53,15 @@ export const sanitizeGuidedStepsChildren = ( return (Children.toArray(children) .filter((child) => child !== "/n") .map((child) => { - if (!child || (isValidElement(child) && isGuidedStepTab(child))) { + if (!child || (isValidElement(child) && isGuidedStepItem(child))) { return child; } throw new Error( - "All children of the component must be components" + "All children of the component must be components" ); }) - ?.filter(Boolean) ?? []) as ReactElement[]; + ?.filter(Boolean) ?? []) as ReactElement[]; }; const isGuidedStepItem = ( @@ -150,20 +75,3 @@ const isGuidedStepItem = ( (type as React.ComponentType).displayName === "GuidedStepItem" ); }; - -export const sanitizeGuidedStepTabChildren = ( - children: GuidedStepTabProps["children"] -) => { - return (Children.toArray(children) - .filter((child) => child !== "/n") - .map((child) => { - if (!child || (isValidElement(child) && isGuidedStepItem(child))) { - return child; - } - - throw new Error( - "All children of the component must be components" - ); - }) - ?.filter(Boolean) ?? []) as ReactElement[]; -}; From 91c7b3d31d3177269f9c15c90c4c29d08f5236a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aatu=20V=C3=A4is=C3=A4nen?= Date: Thu, 28 Aug 2025 15:58:35 +0300 Subject: [PATCH 03/15] Point to test branch --- config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config.json b/config.json index 269c2992..7cf66841 100644 --- a/config.json +++ b/config.json @@ -80,7 +80,7 @@ }, { "name": "18.x", - "branch": "branch/v18", + "branch": "aatuvai:aatuvai/guided-steps", "isDefault": true }, { From 1f672e5de22b2e2db94f483992422a3160193238 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aatu=20V=C3=A4is=C3=A4nen?= Date: Mon, 1 Sep 2025 15:37:23 +0300 Subject: [PATCH 04/15] Add functionality to copy the link of an item --- .../GuidedSteps/GuidedStepItem.module.css | 6 +- .../GuidedSteps/GuidedSteps.module.css | 49 ++++++++++ src/components/GuidedSteps/GuidedSteps.tsx | 92 ++++++++++++++++--- src/components/Icon/icons.ts | 1 + src/components/Icon/svg/link.svg | 4 + 5 files changed, 137 insertions(+), 15 deletions(-) create mode 100644 src/components/Icon/svg/link.svg diff --git a/src/components/GuidedSteps/GuidedStepItem.module.css b/src/components/GuidedSteps/GuidedStepItem.module.css index f48b1cb3..9943e4ee 100644 --- a/src/components/GuidedSteps/GuidedStepItem.module.css +++ b/src/components/GuidedSteps/GuidedStepItem.module.css @@ -1,13 +1,17 @@ .step { + pointer-events: none; &.activeLines { + pointer-events: auto; :global(pre) { background-color: rgba(159, 133, 255, 0.25); + opacity: 1; } } :global(pre) { border-radius: 0; - transition: background-color 0.2s ease-in-out; + transition: all 0.2s ease-in-out; + opacity: 0.4; } & > div { box-shadow: unset; diff --git a/src/components/GuidedSteps/GuidedSteps.module.css b/src/components/GuidedSteps/GuidedSteps.module.css index 7e69c992..1dfb933f 100644 --- a/src/components/GuidedSteps/GuidedSteps.module.css +++ b/src/components/GuidedSteps/GuidedSteps.module.css @@ -16,6 +16,7 @@ transition: all 0.2s ease-in-out; min-height: 100px; cursor: pointer; + position: relative; border-bottom: 1px solid var(--color-tonal-neutral-1); scroll-margin: var(--ifm-navbar-height); @@ -27,11 +28,59 @@ border-bottom: none; } + &:hover { + border-left-color: var(--color-tonal-neutral-1); + .instructionLinkCopyButton { + opacity: 1; + } + } + @media (--md-scr) { padding: var(--m-2); } } +.instructionLinkCopyButton { + opacity: 0; + transition: opacity 0.2s ease-in-out; + background-color: transparent; + position: absolute; + top: var(--m-1); + right: -8px; + border: none; + padding: 0; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + min-width: 52px; + :global(svg) { + fill: var(--color-dark-purple); + stroke: var(--color-dark-purple); + } + &.active { + opacity: 1; + } +} + +.copiedText { + user-select: none; + font-size: 12px; + color: var(--color-dark-purple); + font-weight: 500; + animation: fadeIn 0.2s ease-in-out; + transform: translateX(-25%); +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + .codePanel { background-color: var(--color-code); overflow: auto; diff --git a/src/components/GuidedSteps/GuidedSteps.tsx b/src/components/GuidedSteps/GuidedSteps.tsx index bd856a36..2133dee8 100644 --- a/src/components/GuidedSteps/GuidedSteps.tsx +++ b/src/components/GuidedSteps/GuidedSteps.tsx @@ -1,4 +1,4 @@ -import { useLayoutEffect, useRef, useState } from "react"; +import { useCallback, useLayoutEffect, useRef, useState } from "react"; import cn from "classnames"; import { GuidedStepItemHandle, @@ -8,12 +8,12 @@ import { } from "./utils"; import styles from "./GuidedSteps.module.css"; import GuidedStepItem from "./GuidedStepItem"; +import Icon from "../Icon"; const GuidedStepsComponent: React.FC = (props) => { const items = useGuidedSteps(props); - const [activeStepId, setActiveStepId] = useState( - items[0]?.id || null - ); + const [activeStepId, setActiveStepId] = useState(null); + const [copiedId, setCopiedId] = useState(null); const observerRef = useRef(null); const observerContainerRef = useRef(null); @@ -22,6 +22,8 @@ const GuidedStepsComponent: React.FC = (props) => { const lastHighlightTime = useRef(0); const pendingHighlight = useRef(undefined); + const ignoreIntersection = useRef(false); + const instructionsRef = useRef([]); useLayoutEffect(() => { @@ -34,17 +36,19 @@ const GuidedStepsComponent: React.FC = (props) => { const debounceDelay = 200; const debounceHighlightedStep = (stepId: string) => { + if (ignoreIntersection.current) return; + const now = Date.now(); pendingHighlight.current = stepId; if (now - lastHighlightTime.current > debounceDelay) { - highlightStep(stepId); + highlightStep(stepId, true); lastHighlightTime.current = now; pendingHighlight.current = undefined; } else { setTimeout(() => { if (pendingHighlight.current === stepId) { - highlightStep(stepId); + highlightStep(stepId, true); lastHighlightTime.current = Date.now(); pendingHighlight.current = undefined; } @@ -52,16 +56,19 @@ const GuidedStepsComponent: React.FC = (props) => { } }; - const rootTopMargin = document.body.getBoundingClientRect().height - 200; + const rootBottomMargin = + document.body.getBoundingClientRect().height - 200; const options = { - rootMargin: `-128px 0px -${rootTopMargin}px 0px`, - threshold: [0.2, 0.3, 0.4, 0.5, 0.6, 0.7], + rootMargin: `-128px 0px -${rootBottomMargin}px 0px`, + threshold: [0.3, 0.4, 0.5, 0.6, 0.7], }; observerRef.current = new IntersectionObserver((entries) => { + if (ignoreIntersection.current) return; + const visibleEntries = entries.filter( - (entry) => entry.isIntersecting && entry.intersectionRatio > 0.3 + (entry) => entry.isIntersecting && entry.intersectionRatio > 0.4 ); if (visibleEntries.length > 0) { @@ -96,10 +103,55 @@ const GuidedStepsComponent: React.FC = (props) => { }; }, []); - const highlightStep = (stepId: string) => { - setActiveStepId(stepId); - stepsRef.current.forEach((step) => step.deactivate()); - stepsRef.current.get(stepId)?.activate(); + const highlightStep = useCallback( + (stepId: string, fromObserver = false) => { + console.log(stepId, activeStepId); + if (stepId === activeStepId) return; + setActiveStepId(stepId); + stepsRef.current.forEach((step) => step.deactivate()); + stepsRef.current.get(stepId)?.activate(); + + // Only set the ignore flag if this was triggered with a click + if (!fromObserver) { + ignoreIntersection.current = true; + + setTimeout(() => { + ignoreIntersection.current = false; + }, 1000); + } + }, + [activeStepId] + ); + + // Handle anchor links when the page loads + useLayoutEffect(() => { + const hash = window.location.hash.replace("#", ""); + + if (hash) { + const index = items.findIndex((item) => item.id === hash); + + if (index !== -1) { + highlightStep(hash); + + instructionsRef.current[index]?.scrollIntoView({ + block: "start", + }); + } + } else if (items.length > 0) { + highlightStep(items[0].id); + } + }, [items]); + + const copyLinkToClipboard = (id: string, event: React.MouseEvent) => { + const link = `${window.location.origin}${window.location.pathname}#${id}`; + window.history.pushState({}, "", `#${id}`); + navigator.clipboard.writeText(link); + + setCopiedId(id); + + setTimeout(() => { + setCopiedId(null); + }, 1000); }; return ( @@ -124,6 +176,18 @@ const GuidedStepsComponent: React.FC = (props) => { >

{title}

{description &&

{description}

} + ))} diff --git a/src/components/Icon/icons.ts b/src/components/Icon/icons.ts index 5eb56bbf..de693940 100644 --- a/src/components/Icon/icons.ts +++ b/src/components/Icon/icons.ts @@ -72,6 +72,7 @@ export { default as bitbucket } from "./svg/bitbucket.svg"; export { default as jenkins } from "./svg/jenkins.svg"; export { default as githubActions } from "./svg/githubActions.svg"; export { default as spacelift } from "./svg/spacelift.svg"; +export { default as link } from "./svg/link.svg"; // Teleport svgs export { default as cluster } from "./teleport-svg/cluster.svg"; diff --git a/src/components/Icon/svg/link.svg b/src/components/Icon/svg/link.svg new file mode 100644 index 00000000..69690228 --- /dev/null +++ b/src/components/Icon/svg/link.svg @@ -0,0 +1,4 @@ + + + + From 23b26e3b7c5ad1d136a07035cb4e6aebe4ae2817 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aatu=20V=C3=A4is=C3=A4nen?= Date: Wed, 3 Sep 2025 10:03:25 +0300 Subject: [PATCH 05/15] Adjust intersectionObserver config --- .../GuidedSteps/GuidedSteps.module.css | 2 +- src/components/GuidedSteps/GuidedSteps.tsx | 24 +++++++++---------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/components/GuidedSteps/GuidedSteps.module.css b/src/components/GuidedSteps/GuidedSteps.module.css index 1dfb933f..ac30d888 100644 --- a/src/components/GuidedSteps/GuidedSteps.module.css +++ b/src/components/GuidedSteps/GuidedSteps.module.css @@ -1,6 +1,6 @@ .guidedSteps { display: grid; - grid-template-columns: minmax(40%, 1fr) minmax(60%, 1fr); + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--m-2); margin-bottom: var(--ifm-leading); } diff --git a/src/components/GuidedSteps/GuidedSteps.tsx b/src/components/GuidedSteps/GuidedSteps.tsx index 2133dee8..bc42b31d 100644 --- a/src/components/GuidedSteps/GuidedSteps.tsx +++ b/src/components/GuidedSteps/GuidedSteps.tsx @@ -15,7 +15,6 @@ const GuidedStepsComponent: React.FC = (props) => { const [activeStepId, setActiveStepId] = useState(null); const [copiedId, setCopiedId] = useState(null); const observerRef = useRef(null); - const observerContainerRef = useRef(null); const stepsRef = useRef>(new Map()); @@ -28,12 +27,7 @@ const GuidedStepsComponent: React.FC = (props) => { useLayoutEffect(() => { const initializeObserver = () => { - const instructionsHeight = - observerContainerRef.current?.getBoundingClientRect().height; - if (instructionsHeight) { - observerContainerRef.current.parentElement.style.height = `calc(100px + ${instructionsHeight}px)`; - } - const debounceDelay = 200; + const debounceDelay = 10; const debounceHighlightedStep = (stepId: string) => { if (ignoreIntersection.current) return; @@ -56,19 +50,24 @@ const GuidedStepsComponent: React.FC = (props) => { } }; + const navHeight = + parseInt( + document.documentElement.style.getPropertyValue("--ifm-navbar-height") + ) || 117; + const rootBottomMargin = - document.body.getBoundingClientRect().height - 200; + document.body.getBoundingClientRect().height - navHeight * 2; const options = { - rootMargin: `-128px 0px -${rootBottomMargin}px 0px`, - threshold: [0.3, 0.4, 0.5, 0.6, 0.7], + rootMargin: `-${navHeight + 16}px 0px -${rootBottomMargin}px 0px`, + threshold: Array.from({ length: 1000 }, (_, i) => i / 1000), }; observerRef.current = new IntersectionObserver((entries) => { if (ignoreIntersection.current) return; const visibleEntries = entries.filter( - (entry) => entry.isIntersecting && entry.intersectionRatio > 0.4 + (entry) => entry.isIntersecting && entry.intersectionRatio > 0.3 ); if (visibleEntries.length > 0) { @@ -105,7 +104,6 @@ const GuidedStepsComponent: React.FC = (props) => { const highlightStep = useCallback( (stepId: string, fromObserver = false) => { - console.log(stepId, activeStepId); if (stepId === activeStepId) return; setActiveStepId(stepId); stepsRef.current.forEach((step) => step.deactivate()); @@ -156,7 +154,7 @@ const GuidedStepsComponent: React.FC = (props) => { return (
-
+
{items.map(({ id, title, description }, index) => (
Date: Fri, 12 Sep 2025 15:51:44 +0300 Subject: [PATCH 06/15] Edits according to updated design --- src/components/GuidedSteps/GuidedSteps.tsx | 87 +++++----- src/components/GuidedSteps/Step.module.css | 46 ++++++ src/components/GuidedSteps/Step.tsx | 26 +++ src/components/GuidedSteps/StepSection.tsx | 13 ++ src/components/GuidedSteps/context.tsx | 38 +++++ src/components/GuidedSteps/types.ts | 45 ++++++ src/components/GuidedSteps/utils.tsx | 175 +++++++++++++++------ src/components/Icon/icons.ts | 2 + src/components/Icon/svg/file.svg | 6 + src/components/Icon/svg/terminal.svg | 3 + src/theme/MDXComponents/index.tsx | 10 +- 11 files changed, 352 insertions(+), 99 deletions(-) create mode 100644 src/components/GuidedSteps/Step.module.css create mode 100644 src/components/GuidedSteps/Step.tsx create mode 100644 src/components/GuidedSteps/StepSection.tsx create mode 100644 src/components/GuidedSteps/context.tsx create mode 100644 src/components/GuidedSteps/types.ts create mode 100644 src/components/Icon/svg/file.svg create mode 100644 src/components/Icon/svg/terminal.svg diff --git a/src/components/GuidedSteps/GuidedSteps.tsx b/src/components/GuidedSteps/GuidedSteps.tsx index bc42b31d..6031ba5e 100644 --- a/src/components/GuidedSteps/GuidedSteps.tsx +++ b/src/components/GuidedSteps/GuidedSteps.tsx @@ -1,19 +1,22 @@ -import { useCallback, useLayoutEffect, useRef, useState } from "react"; +import { + useCallback, + useContext, + useLayoutEffect, + useRef, + useState, +} from "react"; import cn from "classnames"; import { - GuidedStepItemHandle, - GuidedStepsProps, - sanitizeGuidedStepsChildren, - useGuidedSteps, + sanitizeLeftColumnChildren, } from "./utils"; import styles from "./GuidedSteps.module.css"; -import GuidedStepItem from "./GuidedStepItem"; import Icon from "../Icon"; +import GuidedStepsContextProvider, { GuidedStepsContext } from "./context"; +import { GuidedStepsProps } from "./types"; const GuidedStepsComponent: React.FC = (props) => { - const items = useGuidedSteps(props); - const [activeStepId, setActiveStepId] = useState(null); - const [copiedId, setCopiedId] = useState(null); + const { steps, files, setActiveFileName } = useContext(GuidedStepsContext); + /* const [copiedId, setCopiedId] = useState(null); const observerRef = useRef(null); const stepsRef = useRef>(new Map()); @@ -150,62 +153,42 @@ const GuidedStepsComponent: React.FC = (props) => { setTimeout(() => { setCopiedId(null); }, 1000); - }; + }; */ return (
- {items.map(({ id, title, description }, index) => ( -
(instructionsRef.current[index] = el)} - id={id} - className={cn(styles.instruction, { - [styles.active]: id === activeStepId, - })} - onClick={() => { - highlightStep(id); - instructionsRef.current[index]?.scrollIntoView({ - block: "start", - behavior: "smooth", - }); - }} - > -

{title}

- {description &&

{description}

} - -
- ))} + {sanitizeLeftColumnChildren(props.children)}
- {items.map(({ id, children }) => ( - stepsRef.current.set(id, el)}> - {children} - - ))} +
    + {files.map(({ name, icon }) => ( +
  • + {icon && } + +
  • + ))} +
); }; -const GuidedSteps: React.FC = ({ children }) => { +const GuidedSteps: React.FC = (props) => { return ( - - {sanitizeGuidedStepsChildren(children)} - + + + ); }; diff --git a/src/components/GuidedSteps/Step.module.css b/src/components/GuidedSteps/Step.module.css new file mode 100644 index 00000000..00c739c7 --- /dev/null +++ b/src/components/GuidedSteps/Step.module.css @@ -0,0 +1,46 @@ +.step { + padding: var(--m-1-5) var(--m-7); + position: relative; + display: flex; + flex-direction: column; + justify-content: center; + + &::after { + content: ""; + opacity: 0; + display: block; + position: absolute; + right: var(--m-3); + height: var(--m-2); + width: var(--m-2); + margin-top: var(--m-3); + margin-bottom: var(--m-3); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='11' viewBox='0 0 12 11' fill='none'%3E%3Cpath d='M7.35355 0.646447C7.15829 0.451184 6.84171 0.451184 6.64645 0.646447C6.45118 0.841709 6.45118 1.15829 6.64645 1.35355L10.2929 5H0.5C0.223858 5 0 5.22386 0 5.5C0 5.77614 0.223858 6 0.5 6H10.2929L6.64645 9.64645C6.45118 9.84171 6.45118 10.1583 6.64645 10.3536C6.84171 10.5488 7.15829 10.5488 7.35355 10.3536L11.8536 5.85355C11.9512 5.75592 12 5.62796 12 5.5C12 5.43221 11.9865 5.36756 11.9621 5.30861C11.9377 5.24964 11.9015 5.19439 11.8536 5.14645L7.35355 0.646447Z' fill='%23512FC9'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: center; + transition: opacity 0.2s ease-in-out; + } + + &.active { + background-color: var(--color-tonal-primary-0); + &::after { + opacity: 1; + } + } +} + +.stepSection { + display: flex; + align-items: center; + gap: 0.75rem; + + * { + font-weight: var(--fw-bold); + } + + .index { + padding: 4px 10px; + background-color: var(--color-tonal-neutral-0); + border-radius: 50%; + } +} diff --git a/src/components/GuidedSteps/Step.tsx b/src/components/GuidedSteps/Step.tsx new file mode 100644 index 00000000..a462f55a --- /dev/null +++ b/src/components/GuidedSteps/Step.tsx @@ -0,0 +1,26 @@ +import { useContext } from "react"; +import { GuidedStepsContext } from "./context"; +import { StepProps } from "./types"; +import styles from "./Step.module.css"; +import cn from "classnames"; +import Icon from "../Icon"; + +const Step: React.FC = ({ id, file, children }) => { + const { files, activeStepId, setActiveStepId } = + useContext(GuidedStepsContext); + return ( +
setActiveStepId?.(id)} + > +
{children}
+
+ + {activeFile.name} +
+
+ ); +}; + +export default Step; diff --git a/src/components/GuidedSteps/StepSection.tsx b/src/components/GuidedSteps/StepSection.tsx new file mode 100644 index 00000000..49519809 --- /dev/null +++ b/src/components/GuidedSteps/StepSection.tsx @@ -0,0 +1,13 @@ +import styles from "./Step.module.css"; + +const StepSection: React.FC<{ index?: number; children: React.ReactNode }> = ({ + index, + children, +}) => ( +
+
{index}
+ {children} +
+); + +export default StepSection; \ No newline at end of file diff --git a/src/components/GuidedSteps/context.tsx b/src/components/GuidedSteps/context.tsx new file mode 100644 index 00000000..83d04b6f --- /dev/null +++ b/src/components/GuidedSteps/context.tsx @@ -0,0 +1,38 @@ +import React, { createContext, useState, useMemo } from "react"; +import { useGuidedSteps } from "./utils"; +import { StepProps as Step, FileProps as File, GuidedStepsProps } from "./types"; + +interface GuidedStepsContextValue { + steps: Step[]; + files: File[]; + activeStepId: string | null; + activeFileName?: string | null; + setActiveStepId?: (id: string | null) => void; + setActiveFileName?: (name: string | null) => void; +} + +const GuidedStepsContext = createContext(null); + +const GuidedStepsProvider: React.FC = ({ children }) => { + const { steps, files } = useGuidedSteps({ children }); + const [activeStepId, setActiveStepId] = useState(null); + const [activeFileName, setActiveFileName] = useState(null); + + const value = useMemo(() => ({ + steps, + files, + activeStepId, + activeFileName, + setActiveStepId, + setActiveFileName, + }), [steps, files, activeStepId, activeFileName]); + + return ( + + {children} + + ); +}; + +export default GuidedStepsProvider; +export { GuidedStepsContext }; \ No newline at end of file diff --git a/src/components/GuidedSteps/types.ts b/src/components/GuidedSteps/types.ts new file mode 100644 index 00000000..38091e47 --- /dev/null +++ b/src/components/GuidedSteps/types.ts @@ -0,0 +1,45 @@ +import { ReactElement } from "react"; +import { IconName } from "../Icon"; + +type Step = ReactElement | null | false | undefined; + +type File = ReactElement | null | false | undefined; + +interface GuidedStepsProps { + children?: React.ReactNode | Step | Step[] | File | File[]; +} + +interface CodeBlockProps { + stepId: string; + children: React.ReactNode; +} + +interface StepProps { + id: string; + children: React.ReactNode; +} + +interface FileProps { + name: string; + icon?: IconName; + stepIds?: Array; + children: + | React.ReactNode + | React.ReactElement + | React.ReactElement[]; +} + +interface GuidedStepItemHandle { + activate: () => void; + deactivate: () => void; +} + +export { + Step, + File, + GuidedStepsProps, + CodeBlockProps, + StepProps, + FileProps, + GuidedStepItemHandle +} \ No newline at end of file diff --git a/src/components/GuidedSteps/utils.tsx b/src/components/GuidedSteps/utils.tsx index f1c4b592..5b6431f6 100644 --- a/src/components/GuidedSteps/utils.tsx +++ b/src/components/GuidedSteps/utils.tsx @@ -1,77 +1,162 @@ -import { Children, isValidElement, useMemo, type ReactElement } from "react"; +import React, { + Children, + isValidElement, + useMemo, + type ReactElement, +} from "react"; +import { + CodeBlockProps, + FileProps as File, + GuidedStepsProps, + StepProps, +} from "./types"; -type GuidedStepItem = - | ReactElement - | null - | false - | undefined; - -export interface GuidedStepsProps { - children?: GuidedStepItem | GuidedStepItem[]; -} - -export interface GuidedStepItemProps { - id: string; - title: string; - description?: string; - children: React.ReactNode; -} - -export interface GuidedStepItemHandle { - activate: () => void; - deactivate: () => void; -} +// Extract the instruction steps which are displayed on the left column. +const extractSteps = (children: GuidedStepsProps["children"]) => { + return ( + (Children.toArray(children) + .filter((child) => child !== "/n") + .map((child) => { + if (!child || (isValidElement(child) && isStep(child))) { + return child; + } + return null; + }) + ?.filter(Boolean) ?? []) as ReactElement[] + ).map(({ props: { id, children } }) => { + return { + id, + children, + }; + }); +}; -const extractGuidedStepItems = (children: GuidedStepsProps["children"]) => { - return sanitizeGuidedStepsChildren(children).map( - ({ props: { id, title, description, children } }) => { +// Extract the code files which are displayed on the right column. +const extractFiles = (children: GuidedStepsProps["children"]) => { + return sanitizeRightColumnChildren(children).map( + ({ props: { name, icon, children } }) => { return { - id, - title, - description, + name, + icon, children, }; } ); }; -const useGuidedStepItems = (props: Pick) => { - const { children } = props; - return useMemo(() => { - const items = extractGuidedStepItems(children); - return items; - }, [children]); +// Extract the code blocks from a File component in order to map them to the instruction steps. +const extractCodeBlocksFromFile = (child: File) => { + return ( + (Children.toArray(child.children) + .filter((child) => child !== "/n") + .map((child) => { + if (!child || (isValidElement(child) && isCodeBlock(child))) { + return child; + } + return null; + }) + ?.filter(Boolean) ?? []) as ReactElement[] + ).map(({ props: { stepId, children } }) => { + return { + stepId, + children, + }; + }); }; export const useGuidedSteps = (props: GuidedStepsProps) => { - return useGuidedStepItems(props); + const { children } = props; + return useMemo(() => { + const steps = extractSteps(children); + const files = extractFiles(children); + return { steps, files }; + }, [children]); }; -export const sanitizeGuidedStepsChildren = ( +export const sanitizeLeftColumnChildren = ( children: GuidedStepsProps["children"] ) => { + let stepSectionIndex = 0; + return (Children.toArray(children) - .filter((child) => child !== "/n") .map((child) => { - if (!child || (isValidElement(child) && isGuidedStepItem(child))) { + if (!child || (isValidElement(child) && !isFile(child))) { + // If it's a StepSection, add the index prop + if (child && isValidElement(child) && isStepSection(child)) { + stepSectionIndex++; + return React.cloneElement( + child as ReactElement<{ + index?: number; + children: React.ReactNode; + }>, + { index: stepSectionIndex } + ); + } return child; } + }) + ?.filter(Boolean) ?? []) as ReactElement[]; +}; - throw new Error( - "All children of the component must be components" - ); +export const sanitizeRightColumnChildren = ( + children: GuidedStepsProps["children"] +) => { + return (Children.toArray(children) + .map((child) => { + if (child && isValidElement(child) && isFile(child)) { + const stepIds: Array = []; + const codeBlocks = extractCodeBlocksFromFile(child.props); + codeBlocks.forEach(({ stepId }) => { + stepIds.push(stepId); + }); + return React.cloneElement(child, { stepIds }); + } }) - ?.filter(Boolean) ?? []) as ReactElement[]; + ?.filter(Boolean) ?? []) as ReactElement[]; +}; + +const isStep = ( + component: ReactElement +): component is ReactElement => { + const { props, type } = component; + return ( + !!props && + typeof props === "object" && + "children" in props && + "id" in props && + (type as React.ComponentType).displayName === "Step" + ); +}; + +const isFile = ( + component: ReactElement +): component is ReactElement => { + const { props, type } = component; + return ( + !!props && + typeof props === "object" && + "children" in props && + "name" in props && + (type as React.ComponentType).displayName === "File" + ); +}; + +const isStepSection = ( + component: ReactElement +): component is ReactElement<{ index?: number; children: React.ReactNode }> => { + const { type } = component; + return (type as React.ComponentType).displayName === "StepSection"; }; -const isGuidedStepItem = ( +const isCodeBlock = ( component: ReactElement -): component is ReactElement => { +): component is ReactElement<{ stepId: string; children: React.ReactNode }> => { const { props, type } = component; return ( !!props && typeof props === "object" && "children" in props && - (type as React.ComponentType).displayName === "GuidedStepItem" + "stepId" in props && + (type as React.ComponentType).displayName === "CodeBlock" ); }; diff --git a/src/components/Icon/icons.ts b/src/components/Icon/icons.ts index de693940..6b53a4a1 100644 --- a/src/components/Icon/icons.ts +++ b/src/components/Icon/icons.ts @@ -73,6 +73,8 @@ export { default as jenkins } from "./svg/jenkins.svg"; export { default as githubActions } from "./svg/githubActions.svg"; export { default as spacelift } from "./svg/spacelift.svg"; export { default as link } from "./svg/link.svg"; +export { default as file } from "./svg/file.svg"; +export { default as terminal } from "./svg/terminal.svg"; // Teleport svgs export { default as cluster } from "./teleport-svg/cluster.svg"; diff --git a/src/components/Icon/svg/file.svg b/src/components/Icon/svg/file.svg new file mode 100644 index 00000000..ad653b24 --- /dev/null +++ b/src/components/Icon/svg/file.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/components/Icon/svg/terminal.svg b/src/components/Icon/svg/terminal.svg new file mode 100644 index 00000000..c723832b --- /dev/null +++ b/src/components/Icon/svg/terminal.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/theme/MDXComponents/index.tsx b/src/theme/MDXComponents/index.tsx index 4362e929..f8583a16 100644 --- a/src/theme/MDXComponents/index.tsx +++ b/src/theme/MDXComponents/index.tsx @@ -23,7 +23,10 @@ import Tile from "/src/components/Tile"; import TileGrid from "/src/components/TileGrid"; import ThumbsFeedback from "/src/components/ThumbsFeedback"; import GuidedSteps from "/src/components/GuidedSteps"; -import GuidedStepItem from "/src/components/GuidedSteps/GuidedStepItem"; +import Step from "/src/components/GuidedSteps/Step"; +import StepSection from "/src/components/GuidedSteps/StepSection"; +import File from "/src/components/GuidedSteps/File"; +import CodeBlock from "/src/components/GuidedSteps/CodeBlock"; const MDXComponents: MDXComponentsObject = { ...OriginalMDXComponents, @@ -58,7 +61,10 @@ const MDXComponents: MDXComponentsObject = { Var: (props) => , // needed to circumvent props mismatch in types ThumbsFeedback, GuidedSteps, - GuidedStepItem, + Step, + File, + StepSection, + CodeBlock, }; export default MDXComponents; From 58f1884787e127bc813ac38b3a9fc3965a1a1797 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aatu=20V=C3=A4is=C3=A4nen?= Date: Mon, 15 Sep 2025 16:08:48 +0300 Subject: [PATCH 07/15] Continued GuidedSteps implementation --- src/components/GuidedSteps/CodeBlock.tsx | 29 +++++++ src/components/GuidedSteps/File.module.css | 76 +++++++++++++++++++ src/components/GuidedSteps/File.tsx | 57 ++++++++++++++ .../GuidedSteps/GuidedStepItem.module.css | 19 ----- src/components/GuidedSteps/GuidedStepItem.tsx | 28 ------- .../GuidedSteps/GuidedSteps.module.css | 4 +- src/components/GuidedSteps/GuidedSteps.tsx | 25 ++---- src/components/GuidedSteps/Step.module.css | 10 +-- src/components/GuidedSteps/Step.tsx | 34 +++++++-- src/components/GuidedSteps/StepSection.tsx | 2 +- src/components/GuidedSteps/context.tsx | 53 +++++++++---- src/components/GuidedSteps/types.ts | 4 +- src/components/GuidedSteps/utils.tsx | 10 ++- src/styles/variables.css | 3 + 14 files changed, 253 insertions(+), 101 deletions(-) create mode 100644 src/components/GuidedSteps/CodeBlock.tsx create mode 100644 src/components/GuidedSteps/File.module.css create mode 100644 src/components/GuidedSteps/File.tsx delete mode 100644 src/components/GuidedSteps/GuidedStepItem.module.css delete mode 100644 src/components/GuidedSteps/GuidedStepItem.tsx diff --git a/src/components/GuidedSteps/CodeBlock.tsx b/src/components/GuidedSteps/CodeBlock.tsx new file mode 100644 index 00000000..510a2f09 --- /dev/null +++ b/src/components/GuidedSteps/CodeBlock.tsx @@ -0,0 +1,29 @@ +import { useImperativeHandle, useRef, forwardRef } from "react"; +import styles from "./File.module.css"; +import { CodeBlockProps, CodeBlockHandle } from "./types"; + +const CodeBlock = forwardRef>( + ({ children }, ref) => { + const stepRef = useRef(null); + + useImperativeHandle( + ref, + (): CodeBlockHandle => ({ + activate: (): void => + stepRef.current?.classList.add(styles.activeLines), + deactivate: (): void => + stepRef.current?.classList.remove(styles.activeLines), + }) + ); + + return ( + + {children} + + ); + } +); + +CodeBlock.displayName = "CodeBlock"; + +export default CodeBlock; diff --git a/src/components/GuidedSteps/File.module.css b/src/components/GuidedSteps/File.module.css new file mode 100644 index 00000000..17922a07 --- /dev/null +++ b/src/components/GuidedSteps/File.module.css @@ -0,0 +1,76 @@ +.files { + position: relative; + height: 100%; +} + +.file { + position: absolute; + top: 0; + left: 0; + width: 100%; + pointer-events: none; + opacity: 0; + transition: opacity 0.2s ease-in-out; + + &.active { + opacity: 1; + pointer-events: auto; + } + + :global(pre) { + border-radius: 0; + transition: all 0.2s ease-in-out; + opacity: 0.4; + } + & > div { + box-shadow: unset; + } +} + +.fileTabs { + display: flex; + padding: var(--m-1) var(--m-2); + background-color: var(----color-interactive-tonal-neutral-dark-0); + gap: var(--m-0-5); +} + +.fileTab { + display: flex; + align-items: center; + padding: var(--m-0-5) var(--m-2); + gap: 2px; + border-radius: 36px; + color: var(--color-white); + transition: all 0.2s ease-in-out; + + &.active { + background-color: var(--color-interactive-primary); + color: var(--color-black); + svg { + filter: brightness(0); + } + &:hover { + background-color: var(--color-interactive-primary); + } + } + + &:hover { + cursor: pointer; + background-color: var(--color-tonal-primary-2); + } + + svg { + width: 16px; + height: 16px; + position: relative; + filter: brightness(0) invert(1); + transform: translateY(10%); + } +} + +.codeBlock { + padding: var(--m-2); + :global(pre) { + padding: 0; + } +} \ No newline at end of file diff --git a/src/components/GuidedSteps/File.tsx b/src/components/GuidedSteps/File.tsx new file mode 100644 index 00000000..d1d5bbb6 --- /dev/null +++ b/src/components/GuidedSteps/File.tsx @@ -0,0 +1,57 @@ +import { useContext } from "react"; +import cn from "classnames"; +import { GuidedStepsContext } from "./context"; +import styles from "./File.module.css"; +import { extractCodeBlocksFromFile } from "./utils"; +import CodeBlock from "./CodeBlock"; +import Icon from "../Icon"; + +export const FileTabs: React.FC = () => { + const { files, setActiveFileName } = useContext(GuidedStepsContext); + + return ( +
    + {files.map(({ name, icon }) => ( +
  • { + setActiveFileName(name); + }} + > + {icon && } + {name} +
  • + ))} +
+ ); +}; + +const FileComponent: React.FC = () => { + const { files, activeFileName, setCodeBlockRef } = + useContext(GuidedStepsContext); + + return ( +
+ {files.map((file) => ( +
+ {extractCodeBlocksFromFile(file).map(({ stepId, children }, i) => ( + setCodeBlockRef(stepId, el)}> + {children} + + ))} +
+ ))} +
+ ); +}; + +export default FileComponent; diff --git a/src/components/GuidedSteps/GuidedStepItem.module.css b/src/components/GuidedSteps/GuidedStepItem.module.css deleted file mode 100644 index 9943e4ee..00000000 --- a/src/components/GuidedSteps/GuidedStepItem.module.css +++ /dev/null @@ -1,19 +0,0 @@ -.step { - pointer-events: none; - &.activeLines { - pointer-events: auto; - :global(pre) { - background-color: rgba(159, 133, 255, 0.25); - opacity: 1; - } - } - - :global(pre) { - border-radius: 0; - transition: all 0.2s ease-in-out; - opacity: 0.4; - } - & > div { - box-shadow: unset; - } -} diff --git a/src/components/GuidedSteps/GuidedStepItem.tsx b/src/components/GuidedSteps/GuidedStepItem.tsx deleted file mode 100644 index 8d12eb89..00000000 --- a/src/components/GuidedSteps/GuidedStepItem.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { useImperativeHandle, useRef, forwardRef } from "react"; -import styles from "./GuidedStepItem.module.css"; -import { GuidedStepItemHandle, GuidedStepItemProps } from "./utils"; - -const GuidedStepItem = forwardRef< - GuidedStepItemHandle, - Pick ->(({ children }, ref) => { - const stepRef = useRef(null); - - useImperativeHandle( - ref, - (): GuidedStepItemHandle => ({ - activate: (): void => stepRef.current?.classList.add(styles.activeLines), - deactivate: (): void => stepRef.current?.classList.remove(styles.activeLines), - }) - ); - - return ( - - {children} - - ); -}); - -GuidedStepItem.displayName = "GuidedStepItem"; - -export default GuidedStepItem; diff --git a/src/components/GuidedSteps/GuidedSteps.module.css b/src/components/GuidedSteps/GuidedSteps.module.css index ac30d888..fa139be5 100644 --- a/src/components/GuidedSteps/GuidedSteps.module.css +++ b/src/components/GuidedSteps/GuidedSteps.module.css @@ -8,6 +8,8 @@ .instructions { position: relative; height: max-content; + display: grid; + gap: var(--m-1); } .instruction { @@ -83,12 +85,10 @@ .codePanel { background-color: var(--color-code); - overflow: auto; position: sticky; top: calc(var(--ifm-navbar-height) + var(--m-2)); border-top-left-radius: var(--r-default); border-bottom-left-radius: var(--r-default); - height: max-content; @media (--md-scr) { border-radius: var(--r-default); diff --git a/src/components/GuidedSteps/GuidedSteps.tsx b/src/components/GuidedSteps/GuidedSteps.tsx index 6031ba5e..6a9d155c 100644 --- a/src/components/GuidedSteps/GuidedSteps.tsx +++ b/src/components/GuidedSteps/GuidedSteps.tsx @@ -1,18 +1,18 @@ import { useCallback, useContext, + useEffect, useLayoutEffect, useRef, useState, } from "react"; import cn from "classnames"; -import { - sanitizeLeftColumnChildren, -} from "./utils"; +import { sanitizeLeftColumnChildren } from "./utils"; import styles from "./GuidedSteps.module.css"; import Icon from "../Icon"; import GuidedStepsContextProvider, { GuidedStepsContext } from "./context"; import { GuidedStepsProps } from "./types"; +import File, { FileTabs } from "./File"; const GuidedStepsComponent: React.FC = (props) => { const { steps, files, setActiveFileName } = useContext(GuidedStepsContext); @@ -162,23 +162,8 @@ const GuidedStepsComponent: React.FC = (props) => {
-
    - {files.map(({ name, icon }) => ( -
  • - {icon && } - -
  • - ))} -
+ +
); diff --git a/src/components/GuidedSteps/Step.module.css b/src/components/GuidedSteps/Step.module.css index 00c739c7..3e3656e6 100644 --- a/src/components/GuidedSteps/Step.module.css +++ b/src/components/GuidedSteps/Step.module.css @@ -1,9 +1,11 @@ .step { - padding: var(--m-1-5) var(--m-7); + padding: var(--m-1-5) var(--m-7) var(--m-1-5) var(--m-2); position: relative; display: flex; flex-direction: column; justify-content: center; + background-color: #F1F2F4; + border-radius: var(--m-1); &::after { content: ""; @@ -33,10 +35,8 @@ display: flex; align-items: center; gap: 0.75rem; - - * { - font-weight: var(--fw-bold); - } + font-weight: var(--fw-bold); + font-size: var(--fs-text-lg); .index { padding: 4px 10px; diff --git a/src/components/GuidedSteps/Step.tsx b/src/components/GuidedSteps/Step.tsx index a462f55a..64194c99 100644 --- a/src/components/GuidedSteps/Step.tsx +++ b/src/components/GuidedSteps/Step.tsx @@ -1,23 +1,43 @@ -import { useContext } from "react"; +import { useCallback, useContext, useEffect, useState } from "react"; import { GuidedStepsContext } from "./context"; -import { StepProps } from "./types"; +import { CodeBlockHandle, FileProps, StepProps } from "./types"; import styles from "./Step.module.css"; import cn from "classnames"; import Icon from "../Icon"; -const Step: React.FC = ({ id, file, children }) => { - const { files, activeStepId, setActiveStepId } = +const Step: React.FC = ({ id, children }) => { + const { files, codeBlockRefs, activeStepId, setActiveStepId } = useContext(GuidedStepsContext); + + const [relatedCodeBlock, setRelatedCodeBlock] = + useState(null); + + const relatedFile: FileProps = + files.find((file) => file.stepIds?.includes(id)) || ({} as FileProps); + + useEffect(() => { + if (codeBlockRefs.current?.has(id)) { + const relatedCodeBlock = codeBlockRefs.current.get(id); + setRelatedCodeBlock(relatedCodeBlock); + } + }, [codeBlockRefs]); + + const activateStep = useCallback(() => { + setActiveStepId?.(id); + codeBlockRefs.current.forEach((step) => step.deactivate()); + relatedCodeBlock?.activate(); + }, [codeBlockRefs, relatedCodeBlock]); + return (
setActiveStepId?.(id)} + onClick={activateStep} >
{children}
- - {activeFile.name} + + {relatedFile.name}
); diff --git a/src/components/GuidedSteps/StepSection.tsx b/src/components/GuidedSteps/StepSection.tsx index 49519809..e321d221 100644 --- a/src/components/GuidedSteps/StepSection.tsx +++ b/src/components/GuidedSteps/StepSection.tsx @@ -5,7 +5,7 @@ const StepSection: React.FC<{ index?: number; children: React.ReactNode }> = ({ children, }) => (
-
{index}
+
{index}
{children}
); diff --git a/src/components/GuidedSteps/context.tsx b/src/components/GuidedSteps/context.tsx index 83d04b6f..6418a7f9 100644 --- a/src/components/GuidedSteps/context.tsx +++ b/src/components/GuidedSteps/context.tsx @@ -1,31 +1,58 @@ -import React, { createContext, useState, useMemo } from "react"; +import React, { + createContext, + useState, + useMemo, + useCallback, + ReactElement, +} from "react"; import { useGuidedSteps } from "./utils"; -import { StepProps as Step, FileProps as File, GuidedStepsProps } from "./types"; +import { + StepProps as Step, + FileProps as File, + GuidedStepsProps, + CodeBlockHandle, +} from "./types"; interface GuidedStepsContextValue { steps: Step[]; files: File[]; activeStepId: string | null; activeFileName?: string | null; + codeBlockRefs: React.MutableRefObject>; setActiveStepId?: (id: string | null) => void; setActiveFileName?: (name: string | null) => void; + setCodeBlockRef?: (stepId: string, ref: any) => void; } const GuidedStepsContext = createContext(null); -const GuidedStepsProvider: React.FC = ({ children }) => { - const { steps, files } = useGuidedSteps({ children }); +const GuidedStepsProvider: React.FC<{ children: ReactElement }> = ({ + children, +}) => { + const { steps, files } = useGuidedSteps({ + children: children.props.children, + }); const [activeStepId, setActiveStepId] = useState(null); const [activeFileName, setActiveFileName] = useState(null); + const codeBlockRefs = React.useRef>(new Map()); - const value = useMemo(() => ({ - steps, - files, - activeStepId, - activeFileName, - setActiveStepId, - setActiveFileName, - }), [steps, files, activeStepId, activeFileName]); + const setCodeBlockRef = useCallback((stepId: string, ref: any) => { + codeBlockRefs.current.set(stepId, ref); + }, []); + + const value = useMemo( + () => ({ + steps, + files, + activeStepId, + activeFileName, + codeBlockRefs, + setActiveStepId, + setActiveFileName, + setCodeBlockRef, + }), + [steps, files, activeStepId, activeFileName, codeBlockRefs] + ); return ( @@ -35,4 +62,4 @@ const GuidedStepsProvider: React.FC = ({ children }) => { }; export default GuidedStepsProvider; -export { GuidedStepsContext }; \ No newline at end of file +export { GuidedStepsContext }; diff --git a/src/components/GuidedSteps/types.ts b/src/components/GuidedSteps/types.ts index 38091e47..fc165f4c 100644 --- a/src/components/GuidedSteps/types.ts +++ b/src/components/GuidedSteps/types.ts @@ -29,7 +29,7 @@ interface FileProps { | React.ReactElement[]; } -interface GuidedStepItemHandle { +interface CodeBlockHandle { activate: () => void; deactivate: () => void; } @@ -41,5 +41,5 @@ export { CodeBlockProps, StepProps, FileProps, - GuidedStepItemHandle + CodeBlockHandle } \ No newline at end of file diff --git a/src/components/GuidedSteps/utils.tsx b/src/components/GuidedSteps/utils.tsx index 5b6431f6..8295702e 100644 --- a/src/components/GuidedSteps/utils.tsx +++ b/src/components/GuidedSteps/utils.tsx @@ -45,7 +45,7 @@ const extractFiles = (children: GuidedStepsProps["children"]) => { }; // Extract the code blocks from a File component in order to map them to the instruction steps. -const extractCodeBlocksFromFile = (child: File) => { +export const extractCodeBlocksFromFile = (child: File) => { return ( (Children.toArray(child.children) .filter((child) => child !== "/n") @@ -69,6 +69,7 @@ export const useGuidedSteps = (props: GuidedStepsProps) => { return useMemo(() => { const steps = extractSteps(children); const files = extractFiles(children); + console.log(files); return { steps, files }; }, [children]); }; @@ -103,6 +104,7 @@ export const sanitizeRightColumnChildren = ( ) => { return (Children.toArray(children) .map((child) => { + console.log(child); if (child && isValidElement(child) && isFile(child)) { const stepIds: Array = []; const codeBlocks = extractCodeBlocksFromFile(child.props); @@ -124,7 +126,7 @@ const isStep = ( typeof props === "object" && "children" in props && "id" in props && - (type as React.ComponentType).displayName === "Step" + (type as React.ComponentType).name === "Step" ); }; @@ -137,7 +139,7 @@ const isFile = ( typeof props === "object" && "children" in props && "name" in props && - (type as React.ComponentType).displayName === "File" + (type as React.ComponentType).name === "FileComponent" ); }; @@ -145,7 +147,7 @@ const isStepSection = ( component: ReactElement ): component is ReactElement<{ index?: number; children: React.ReactNode }> => { const { type } = component; - return (type as React.ComponentType).displayName === "StepSection"; + return (type as React.ComponentType).name === "StepSection"; }; const isCodeBlock = ( diff --git a/src/styles/variables.css b/src/styles/variables.css index 873a2709..bc6b7d97 100644 --- a/src/styles/variables.css +++ b/src/styles/variables.css @@ -24,6 +24,9 @@ --color-tonal-primary-0: rgba(81, 47, 201, 0.1); --color-tonal-primary-1: rgba(81, 47, 201, 0.18); --color-tonal-primary-2: rgba(81, 47, 201, 0.25); + --color-interactive-tonal-neutral-dark-0: rgba(255, 255, 255, 0.07); + + --color-interactive-primary: #9f85ff; --color-warning: #ffb400; --color-danger: #f80061; From 1c26a7080ba8cd5b545f7483c0d8fde7e96b0c3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aatu=20V=C3=A4is=C3=A4nen?= Date: Tue, 16 Sep 2025 15:46:11 +0300 Subject: [PATCH 08/15] Scroll mechanics --- src/components/GuidedSteps/CodeBlock.tsx | 46 +++++++------ src/components/GuidedSteps/File.module.css | 68 +++++++++++++++---- src/components/GuidedSteps/File.tsx | 11 ++- .../GuidedSteps/GuidedSteps.module.css | 11 +-- src/components/GuidedSteps/GuidedSteps.tsx | 32 ++++----- src/components/GuidedSteps/Step.module.css | 33 ++++++++- src/components/GuidedSteps/Step.tsx | 17 +++-- src/components/GuidedSteps/StepSection.tsx | 2 +- src/components/GuidedSteps/context.tsx | 19 ++++-- src/components/GuidedSteps/types.ts | 18 ++--- src/components/GuidedSteps/utils.tsx | 32 +++++++-- src/styles/variables.css | 8 ++- 12 files changed, 208 insertions(+), 89 deletions(-) diff --git a/src/components/GuidedSteps/CodeBlock.tsx b/src/components/GuidedSteps/CodeBlock.tsx index 510a2f09..ee7e48fb 100644 --- a/src/components/GuidedSteps/CodeBlock.tsx +++ b/src/components/GuidedSteps/CodeBlock.tsx @@ -1,28 +1,34 @@ -import { useImperativeHandle, useRef, forwardRef } from "react"; +import { useImperativeHandle, useRef, forwardRef, useContext } from "react"; import styles from "./File.module.css"; import { CodeBlockProps, CodeBlockHandle } from "./types"; +import { GuidedStepsContext } from "./context"; -const CodeBlock = forwardRef>( - ({ children }, ref) => { - const stepRef = useRef(null); +const CodeBlock = forwardRef< + CodeBlockHandle, + Pick +>(({ fileName, children }, ref) => { + const { activeFileName, setActiveFileName } = useContext(GuidedStepsContext); + const stepRef = useRef(null); - useImperativeHandle( - ref, - (): CodeBlockHandle => ({ - activate: (): void => - stepRef.current?.classList.add(styles.activeLines), - deactivate: (): void => - stepRef.current?.classList.remove(styles.activeLines), - }) - ); + useImperativeHandle( + ref, + (): CodeBlockHandle => ({ + activate: (): void => { + if (activeFileName !== fileName) setActiveFileName(fileName); + stepRef.current?.classList.add(styles.activeLines); + stepRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center", }); + }, + deactivate: (): void => + stepRef.current?.classList.remove(styles.activeLines), + }) + ); - return ( - - {children} - - ); - } -); + return ( + + {children} + + ); +}); CodeBlock.displayName = "CodeBlock"; diff --git a/src/components/GuidedSteps/File.module.css b/src/components/GuidedSteps/File.module.css index 17922a07..2a05092a 100644 --- a/src/components/GuidedSteps/File.module.css +++ b/src/components/GuidedSteps/File.module.css @@ -1,26 +1,29 @@ .files { position: relative; - height: 100%; + height: max-content; + border-bottom-left-radius: var(--m-1); + border-bottom-right-radius: var(--m-1); } .file { position: absolute; - top: 0; - left: 0; - width: 100%; - pointer-events: none; + height: 0; opacity: 0; - transition: opacity 0.2s ease-in-out; + transition: opacity 0.2s ease-in; + display: flex; + flex-direction: column; + padding-top: var(--m-2); &.active { opacity: 1; + height: auto; + position: relative; pointer-events: auto; } :global(pre) { border-radius: 0; - transition: all 0.2s ease-in-out; - opacity: 0.4; + transition: all 0.2s ease-in; } & > div { box-shadow: unset; @@ -28,10 +31,17 @@ } .fileTabs { + position: sticky; + top: 0; display: flex; - padding: var(--m-1) var(--m-2); - background-color: var(----color-interactive-tonal-neutral-dark-0); + padding: var(--m-2); + border-bottom: 1.5px solid var(--color-tonal-neutral-1-white); + background-color: #0e1435; + border-top-left-radius: var(--m-1); + border-top-right-radius: var(--m-1); + z-index: 1; gap: var(--m-0-5); + margin-bottom: 0; } .fileTab { @@ -41,6 +51,9 @@ gap: 2px; border-radius: 36px; color: var(--color-white); + font-size: var(--fs-text-md); + line-height: 1.42857; + margin-top: 0 !important; transition: all 0.2s ease-in-out; &.active { @@ -62,15 +75,40 @@ svg { width: 16px; height: 16px; - position: relative; filter: brightness(0) invert(1); - transform: translateY(10%); + } + + span { + line-height: 1; } } .codeBlock { - padding: var(--m-2); + position: relative; + pointer-events: none; + scroll-margin-top: 72px; :global(pre) { - padding: 0; + padding: 0 0 0 var(--m-2); + opacity: 0.52; + background-color: transparent !important; } -} \ No newline at end of file + + & > div { + background-color: transparent !important; + } + + &.activeLines { + &::after { + opacity: 0; + } + + :global(pre) { + background-color: var(--color-code) !important; + opacity: 1; + } + + & > div { + background-color: var(--color-code) !important; + } + } +} diff --git a/src/components/GuidedSteps/File.tsx b/src/components/GuidedSteps/File.tsx index d1d5bbb6..b252d0c0 100644 --- a/src/components/GuidedSteps/File.tsx +++ b/src/components/GuidedSteps/File.tsx @@ -7,7 +7,8 @@ import CodeBlock from "./CodeBlock"; import Icon from "../Icon"; export const FileTabs: React.FC = () => { - const { files, setActiveFileName } = useContext(GuidedStepsContext); + const { files, activeFileName, setActiveFileName } = + useContext(GuidedStepsContext); return (
    @@ -15,7 +16,7 @@ export const FileTabs: React.FC = () => {
  • { @@ -44,7 +45,11 @@ const FileComponent: React.FC = () => { })} > {extractCodeBlocksFromFile(file).map(({ stepId, children }, i) => ( - setCodeBlockRef(stepId, el)}> + setCodeBlockRef(stepId, el)} + fileName={file.name} + > {children} ))} diff --git a/src/components/GuidedSteps/GuidedSteps.module.css b/src/components/GuidedSteps/GuidedSteps.module.css index fa139be5..49f0ad98 100644 --- a/src/components/GuidedSteps/GuidedSteps.module.css +++ b/src/components/GuidedSteps/GuidedSteps.module.css @@ -8,6 +8,7 @@ .instructions { position: relative; height: max-content; + padding-bottom: 150%; display: grid; gap: var(--m-1); } @@ -84,12 +85,12 @@ } .codePanel { - background-color: var(--color-code); position: sticky; - top: calc(var(--ifm-navbar-height) + var(--m-2)); - border-top-left-radius: var(--r-default); - border-bottom-left-radius: var(--r-default); - + top: var(--ifm-navbar-height); + background-color: #060918; + max-height: calc(100dvh - var(--ifm-navbar-height)); + overflow-y: scroll; + height: 100%; @media (--md-scr) { border-radius: var(--r-default); } diff --git a/src/components/GuidedSteps/GuidedSteps.tsx b/src/components/GuidedSteps/GuidedSteps.tsx index 6a9d155c..1a3e66cd 100644 --- a/src/components/GuidedSteps/GuidedSteps.tsx +++ b/src/components/GuidedSteps/GuidedSteps.tsx @@ -1,33 +1,27 @@ import { useCallback, useContext, - useEffect, useLayoutEffect, useRef, useState, } from "react"; -import cn from "classnames"; import { sanitizeLeftColumnChildren } from "./utils"; import styles from "./GuidedSteps.module.css"; -import Icon from "../Icon"; import GuidedStepsContextProvider, { GuidedStepsContext } from "./context"; import { GuidedStepsProps } from "./types"; import File, { FileTabs } from "./File"; const GuidedStepsComponent: React.FC = (props) => { - const { steps, files, setActiveFileName } = useContext(GuidedStepsContext); - /* const [copiedId, setCopiedId] = useState(null); + const { steps, activeStepId, codeBlockRefs, stepRefs, setActiveStepId } = + useContext(GuidedStepsContext); + const [copiedId, setCopiedId] = useState(null); const observerRef = useRef(null); - const stepsRef = useRef>(new Map()); - const lastHighlightTime = useRef(0); const pendingHighlight = useRef(undefined); const ignoreIntersection = useRef(false); - const instructionsRef = useRef([]); - useLayoutEffect(() => { const initializeObserver = () => { const debounceDelay = 10; @@ -82,13 +76,11 @@ const GuidedStepsComponent: React.FC = (props) => { } }, options); - instructionsRef.current.forEach((step) => { + stepRefs.current.forEach((step) => { if (observerRef.current) { observerRef.current.observe(step); } }); - - stepsRef.current.get(items[0].id)?.activate(); }; initializeObserver(); @@ -109,8 +101,8 @@ const GuidedStepsComponent: React.FC = (props) => { (stepId: string, fromObserver = false) => { if (stepId === activeStepId) return; setActiveStepId(stepId); - stepsRef.current.forEach((step) => step.deactivate()); - stepsRef.current.get(stepId)?.activate(); + codeBlockRefs.current.forEach((step) => step.deactivate()); + codeBlockRefs.current.get(stepId)?.activate(); // Only set the ignore flag if this was triggered with a click if (!fromObserver) { @@ -129,19 +121,19 @@ const GuidedStepsComponent: React.FC = (props) => { const hash = window.location.hash.replace("#", ""); if (hash) { - const index = items.findIndex((item) => item.id === hash); + const index = steps.findIndex((item) => item.id === hash); if (index !== -1) { highlightStep(hash); - instructionsRef.current[index]?.scrollIntoView({ + stepRefs.current[index]?.scrollIntoView({ block: "start", }); } - } else if (items.length > 0) { - highlightStep(items[0].id); + } else if (steps.length > 0) { + highlightStep(steps[0].id); } - }, [items]); + }, [steps]); const copyLinkToClipboard = (id: string, event: React.MouseEvent) => { const link = `${window.location.origin}${window.location.pathname}#${id}`; @@ -153,7 +145,7 @@ const GuidedStepsComponent: React.FC = (props) => { setTimeout(() => { setCopiedId(null); }, 1000); - }; */ + }; return (
    diff --git a/src/components/GuidedSteps/Step.module.css b/src/components/GuidedSteps/Step.module.css index 3e3656e6..06ee9da5 100644 --- a/src/components/GuidedSteps/Step.module.css +++ b/src/components/GuidedSteps/Step.module.css @@ -4,7 +4,7 @@ display: flex; flex-direction: column; justify-content: center; - background-color: #F1F2F4; + background-color: #f1f2f4; border-radius: var(--m-1); &::after { @@ -28,6 +28,15 @@ &::after { opacity: 1; } + + &:hover { + background-color: var(--color-tonal-primary-0); + } + } + + &:hover { + background-color: var(--color-tonal-neutral-0); + cursor: pointer; } } @@ -37,6 +46,11 @@ gap: 0.75rem; font-weight: var(--fw-bold); font-size: var(--fs-text-lg); + margin-block: var(--m-2); + + &:first-of-type { + margin-top: 0; + } .index { padding: 4px 10px; @@ -44,3 +58,20 @@ border-radius: 50%; } } + +.fileLabel { + font-size: var(--fs-text-sm); + color: var(--color-foreground-slightly-muted); + display: flex; + align-items: center; + gap: var(--m-0-5); + svg { + width: 16px; + height: 16px; + filter: brightness(0); + opacity: 0.72; + } + span { + line-height: 1; + } +} diff --git a/src/components/GuidedSteps/Step.tsx b/src/components/GuidedSteps/Step.tsx index 64194c99..04d12887 100644 --- a/src/components/GuidedSteps/Step.tsx +++ b/src/components/GuidedSteps/Step.tsx @@ -5,9 +5,14 @@ import styles from "./Step.module.css"; import cn from "classnames"; import Icon from "../Icon"; -const Step: React.FC = ({ id, children }) => { - const { files, codeBlockRefs, activeStepId, setActiveStepId } = - useContext(GuidedStepsContext); +const Step: React.FC = ({ id, index, children }) => { + const { + files, + codeBlockRefs, + stepRefs, + activeStepId, + setActiveStepId, + } = useContext(GuidedStepsContext); const [relatedCodeBlock, setRelatedCodeBlock] = useState(null); @@ -25,19 +30,21 @@ const Step: React.FC = ({ id, children }) => { const activateStep = useCallback(() => { setActiveStepId?.(id); codeBlockRefs.current.forEach((step) => step.deactivate()); - relatedCodeBlock?.activate(); + if (activeStepId !== id) relatedCodeBlock?.activate(); }, [codeBlockRefs, relatedCodeBlock]); return (
    (stepRefs.current[index] = el)} onClick={activateStep} + id={id} >
    {children}
    - {relatedFile.name} + {relatedFile.name}
    ); diff --git a/src/components/GuidedSteps/StepSection.tsx b/src/components/GuidedSteps/StepSection.tsx index e321d221..6000a7f2 100644 --- a/src/components/GuidedSteps/StepSection.tsx +++ b/src/components/GuidedSteps/StepSection.tsx @@ -10,4 +10,4 @@ const StepSection: React.FC<{ index?: number; children: React.ReactNode }> = ({
    ); -export default StepSection; \ No newline at end of file +export default StepSection; diff --git a/src/components/GuidedSteps/context.tsx b/src/components/GuidedSteps/context.tsx index 6418a7f9..3d7b2928 100644 --- a/src/components/GuidedSteps/context.tsx +++ b/src/components/GuidedSteps/context.tsx @@ -1,15 +1,17 @@ -import React, { +import { createContext, useState, useMemo, useCallback, ReactElement, + useEffect, + useRef, + MutableRefObject, } from "react"; import { useGuidedSteps } from "./utils"; import { StepProps as Step, FileProps as File, - GuidedStepsProps, CodeBlockHandle, } from "./types"; @@ -18,7 +20,8 @@ interface GuidedStepsContextValue { files: File[]; activeStepId: string | null; activeFileName?: string | null; - codeBlockRefs: React.MutableRefObject>; + codeBlockRefs: MutableRefObject>; + stepRefs: MutableRefObject; setActiveStepId?: (id: string | null) => void; setActiveFileName?: (name: string | null) => void; setCodeBlockRef?: (stepId: string, ref: any) => void; @@ -34,12 +37,19 @@ const GuidedStepsProvider: React.FC<{ children: ReactElement }> = ({ }); const [activeStepId, setActiveStepId] = useState(null); const [activeFileName, setActiveFileName] = useState(null); - const codeBlockRefs = React.useRef>(new Map()); + const codeBlockRefs = useRef>(new Map()); + const stepRefs = useRef([]); const setCodeBlockRef = useCallback((stepId: string, ref: any) => { codeBlockRefs.current.set(stepId, ref); }, []); + useEffect(() => { + if (files.length > 0 && !activeFileName) { + setActiveFileName(files[0].name); + } + }, [files]); + const value = useMemo( () => ({ steps, @@ -47,6 +57,7 @@ const GuidedStepsProvider: React.FC<{ children: ReactElement }> = ({ activeStepId, activeFileName, codeBlockRefs, + stepRefs, setActiveStepId, setActiveFileName, setCodeBlockRef, diff --git a/src/components/GuidedSteps/types.ts b/src/components/GuidedSteps/types.ts index fc165f4c..ef088e4a 100644 --- a/src/components/GuidedSteps/types.ts +++ b/src/components/GuidedSteps/types.ts @@ -11,11 +11,13 @@ interface GuidedStepsProps { interface CodeBlockProps { stepId: string; + fileName?: string; children: React.ReactNode; } interface StepProps { id: string; + index: number; children: React.ReactNode; } @@ -35,11 +37,11 @@ interface CodeBlockHandle { } export { - Step, - File, - GuidedStepsProps, - CodeBlockProps, - StepProps, - FileProps, - CodeBlockHandle -} \ No newline at end of file + Step, + File, + GuidedStepsProps, + CodeBlockProps, + StepProps, + FileProps, + CodeBlockHandle, +}; diff --git a/src/components/GuidedSteps/utils.tsx b/src/components/GuidedSteps/utils.tsx index 8295702e..fee723b4 100644 --- a/src/components/GuidedSteps/utils.tsx +++ b/src/components/GuidedSteps/utils.tsx @@ -23,9 +23,10 @@ const extractSteps = (children: GuidedStepsProps["children"]) => { return null; }) ?.filter(Boolean) ?? []) as ReactElement[] - ).map(({ props: { id, children } }) => { + ).map(({ props: { id, index, children } }) => { return { id, + index, children, }; }); @@ -34,10 +35,11 @@ const extractSteps = (children: GuidedStepsProps["children"]) => { // Extract the code files which are displayed on the right column. const extractFiles = (children: GuidedStepsProps["children"]) => { return sanitizeRightColumnChildren(children).map( - ({ props: { name, icon, children } }) => { + ({ props: { name, icon, stepIds, children } }) => { return { name, icon, + stepIds, children, }; } @@ -69,7 +71,6 @@ export const useGuidedSteps = (props: GuidedStepsProps) => { return useMemo(() => { const steps = extractSteps(children); const files = extractFiles(children); - console.log(files); return { steps, files }; }, [children]); }; @@ -78,7 +79,7 @@ export const sanitizeLeftColumnChildren = ( children: GuidedStepsProps["children"] ) => { let stepSectionIndex = 0; - + let stepIndex = 0; return (Children.toArray(children) .map((child) => { if (!child || (isValidElement(child) && !isFile(child))) { @@ -93,6 +94,18 @@ export const sanitizeLeftColumnChildren = ( { index: stepSectionIndex } ); } + + // if it's a Step, add the index prop + if (child && isValidElement(child) && isStep(child)) { + const indexedStep = React.cloneElement( + child as ReactElement, + { + index: stepIndex, + } + ); + stepIndex++; + return indexedStep; + } return child; } }) @@ -104,14 +117,21 @@ export const sanitizeRightColumnChildren = ( ) => { return (Children.toArray(children) .map((child) => { - console.log(child); if (child && isValidElement(child) && isFile(child)) { const stepIds: Array = []; const codeBlocks = extractCodeBlocksFromFile(child.props); codeBlocks.forEach(({ stepId }) => { stepIds.push(stepId); }); - return React.cloneElement(child, { stepIds }); + return React.cloneElement( + child as ReactElement<{ + name: string; + icon?: string; + stepIds?: Array; + children: React.ReactNode; + }>, + { stepIds } + ); } }) ?.filter(Boolean) ?? []) as ReactElement[]; diff --git a/src/styles/variables.css b/src/styles/variables.css index bc6b7d97..cb131210 100644 --- a/src/styles/variables.css +++ b/src/styles/variables.css @@ -21,6 +21,12 @@ --color-tonal-neutral-0: rgba(0, 0, 0, 0.06); --color-tonal-neutral-1: rgba(0, 0, 0, 0.13); --color-tonal-neutral-2: rgba(0, 0, 0, 0.18); + --color-foreground-muted-white: rgba(255, 255, 255, 0.54); + --color-foreground-disabled-white: rgba(255, 255, 255, 0.36); + --color-foreground-tooltip-white: rgba(255, 255, 255, 0.8); + --color-tonal-neutral-0-white: rgba(255, 255, 255, 0.06); + --color-tonal-neutral-1-white: rgba(255, 255, 255, 0.13); + --color-tonal-neutral-2-white: rgba(255, 255, 255, 0.18); --color-tonal-primary-0: rgba(81, 47, 201, 0.1); --color-tonal-primary-1: rgba(81, 47, 201, 0.18); --color-tonal-primary-2: rgba(81, 47, 201, 0.25); @@ -31,7 +37,7 @@ --color-warning: #ffb400; --color-danger: #f80061; --color-text: #263238; - --color-code: #01172c; + --color-code: #0e1435; --color-tip: #00c7ae; --color-note: #009cf1; From da75c1443be1a25495711fd053bb0c4e1d837165 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aatu=20V=C3=A4is=C3=A4nen?= Date: Tue, 16 Sep 2025 15:49:24 +0300 Subject: [PATCH 09/15] Add test branch --- config.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config.json b/config.json index 7cf66841..0188631f 100644 --- a/config.json +++ b/config.json @@ -80,7 +80,8 @@ }, { "name": "18.x", - "branch": "aatuvai:aatuvai/guided-steps", + "branch": "aatuvai/guided-steps", + "repo_path": "aatuvai/teleport", "isDefault": true }, { From 3df28946a441d262722d6d68da97ad8ebcb4405c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aatu=20V=C3=A4is=C3=A4nen?= Date: Wed, 17 Sep 2025 08:56:18 +0300 Subject: [PATCH 10/15] Fix issue with component detection in production --- src/components/GuidedSteps/File.tsx | 2 ++ src/components/GuidedSteps/Step.tsx | 2 ++ src/components/GuidedSteps/StepSection.tsx | 2 ++ src/components/GuidedSteps/utils.tsx | 6 +++--- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/components/GuidedSteps/File.tsx b/src/components/GuidedSteps/File.tsx index b252d0c0..f38087dc 100644 --- a/src/components/GuidedSteps/File.tsx +++ b/src/components/GuidedSteps/File.tsx @@ -59,4 +59,6 @@ const FileComponent: React.FC = () => { ); }; +FileComponent.displayName = "File"; + export default FileComponent; diff --git a/src/components/GuidedSteps/Step.tsx b/src/components/GuidedSteps/Step.tsx index 04d12887..e0e6727c 100644 --- a/src/components/GuidedSteps/Step.tsx +++ b/src/components/GuidedSteps/Step.tsx @@ -50,4 +50,6 @@ const Step: React.FC = ({ id, index, children }) => { ); }; +Step.displayName = "Step"; + export default Step; diff --git a/src/components/GuidedSteps/StepSection.tsx b/src/components/GuidedSteps/StepSection.tsx index 6000a7f2..6aa4d6a9 100644 --- a/src/components/GuidedSteps/StepSection.tsx +++ b/src/components/GuidedSteps/StepSection.tsx @@ -10,4 +10,6 @@ const StepSection: React.FC<{ index?: number; children: React.ReactNode }> = ({
); +StepSection.displayName = "StepSection"; + export default StepSection; diff --git a/src/components/GuidedSteps/utils.tsx b/src/components/GuidedSteps/utils.tsx index fee723b4..c2149a13 100644 --- a/src/components/GuidedSteps/utils.tsx +++ b/src/components/GuidedSteps/utils.tsx @@ -146,7 +146,7 @@ const isStep = ( typeof props === "object" && "children" in props && "id" in props && - (type as React.ComponentType).name === "Step" + (type as React.ComponentType).displayName === "Step" ); }; @@ -159,7 +159,7 @@ const isFile = ( typeof props === "object" && "children" in props && "name" in props && - (type as React.ComponentType).name === "FileComponent" + (type as React.ComponentType).displayName === "File" ); }; @@ -167,7 +167,7 @@ const isStepSection = ( component: ReactElement ): component is ReactElement<{ index?: number; children: React.ReactNode }> => { const { type } = component; - return (type as React.ComponentType).name === "StepSection"; + return (type as React.ComponentType).displayName === "StepSection"; }; const isCodeBlock = ( From 2f1b76b686abd8d873d582bcddb7215a2591d361 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aatu=20V=C3=A4is=C3=A4nen?= Date: Wed, 17 Sep 2025 12:42:10 +0300 Subject: [PATCH 11/15] Add copy and download buttons, various fixes and adjustments --- src/components/GuidedSteps/CodeBlock.tsx | 35 ++++-- src/components/GuidedSteps/File.module.css | 61 +++++++++-- src/components/GuidedSteps/File.tsx | 53 ++++++++- .../GuidedSteps/GuidedSteps.module.css | 103 ++++++------------ src/components/GuidedSteps/GuidedSteps.tsx | 72 ++++++------ src/components/GuidedSteps/context.tsx | 54 +++++++-- src/components/GuidedSteps/types.ts | 2 + src/components/Icon/icons.ts | 3 + src/components/Icon/svg/check2.svg | 3 + src/components/Icon/svg/copy2.svg | 4 + src/components/Icon/svg/download2.svg | 4 + src/styles/variables.css | 2 + 12 files changed, 264 insertions(+), 132 deletions(-) create mode 100644 src/components/Icon/svg/check2.svg create mode 100644 src/components/Icon/svg/copy2.svg create mode 100644 src/components/Icon/svg/download2.svg diff --git a/src/components/GuidedSteps/CodeBlock.tsx b/src/components/GuidedSteps/CodeBlock.tsx index ee7e48fb..919aa071 100644 --- a/src/components/GuidedSteps/CodeBlock.tsx +++ b/src/components/GuidedSteps/CodeBlock.tsx @@ -1,30 +1,49 @@ -import { useImperativeHandle, useRef, forwardRef, useContext } from "react"; +import { + useImperativeHandle, + useRef, + forwardRef, + useContext, + useState, +} from "react"; +import cn from "classnames"; import styles from "./File.module.css"; import { CodeBlockProps, CodeBlockHandle } from "./types"; import { GuidedStepsContext } from "./context"; const CodeBlock = forwardRef< CodeBlockHandle, - Pick ->(({ fileName, children }, ref) => { + Pick +>(({ fileName, copyButtonActive, children }, ref) => { const { activeFileName, setActiveFileName } = useContext(GuidedStepsContext); const stepRef = useRef(null); + const [activeLines, setActiveLines] = useState(false); + useImperativeHandle( ref, (): CodeBlockHandle => ({ activate: (): void => { if (activeFileName !== fileName) setActiveFileName(fileName); - stepRef.current?.classList.add(styles.activeLines); - stepRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center", }); + setActiveLines(true); + stepRef.current?.scrollIntoView({ + behavior: "smooth", + block: "nearest", + inline: "start", + }); }, - deactivate: (): void => - stepRef.current?.classList.remove(styles.activeLines), + deactivate: (): void => setActiveLines(false), + innerText: stepRef.current?.innerText || "", }) ); return ( - + {children} ); diff --git a/src/components/GuidedSteps/File.module.css b/src/components/GuidedSteps/File.module.css index 2a05092a..d84c510a 100644 --- a/src/components/GuidedSteps/File.module.css +++ b/src/components/GuidedSteps/File.module.css @@ -3,16 +3,19 @@ height: max-content; border-bottom-left-radius: var(--m-1); border-bottom-right-radius: var(--m-1); + + &.copyButtonActive { + background-color: var(--color-code); + } } .file { position: absolute; height: 0; opacity: 0; - transition: opacity 0.2s ease-in; display: flex; + overflow: hidden; flex-direction: column; - padding-top: var(--m-2); &.active { opacity: 1; @@ -42,6 +45,7 @@ z-index: 1; gap: var(--m-0-5); margin-bottom: 0; + min-height: 65px; } .fileTab { @@ -83,6 +87,38 @@ } } +.downloadButton { + margin-left: auto; + display: flex; + align-items: center; + gap: var(--m-0-5); + background-color: var(--color-tonal-neutral-0-white); + border: none; + color: var(--color-foreground-slightly-muted-white); + font-size: var(--fs-text-sm); + cursor: pointer; + font-weight: var(--fw-medium); + line-height: 1rem; + padding: var(--m-1) var(--m-2); + border-radius: var(--m-0-5); + transition: all 0.2s ease-in-out; + + svg { + filter: brightness(0) invert(1); + opacity: 0.72; + transition: opacity 0.2s ease-in-out; + } + + &:hover { + background-color: var(--color-tonal-neutral-1-white); + color: var(--color-white); + + svg { + opacity: 1; + } + } +} + .codeBlock { position: relative; pointer-events: none; @@ -91,17 +127,28 @@ padding: 0 0 0 var(--m-2); opacity: 0.52; background-color: transparent !important; + transition: opacity 0.2s ease-in; } - & > div { - background-color: transparent !important; + &:first-of-type { + & > div { + padding-top: var(--m-2); + } } - &.activeLines { - &::after { - opacity: 0; + &:last-of-type { + & > div { + padding-bottom: var(--m-2); } + } + + & > div { + background-color: transparent !important; + box-shadow: unset !important; + } + &.activeLines, + &.copyButtonActive { :global(pre) { background-color: var(--color-code) !important; opacity: 1; diff --git a/src/components/GuidedSteps/File.tsx b/src/components/GuidedSteps/File.tsx index f38087dc..d264757c 100644 --- a/src/components/GuidedSteps/File.tsx +++ b/src/components/GuidedSteps/File.tsx @@ -7,8 +7,13 @@ import CodeBlock from "./CodeBlock"; import Icon from "../Icon"; export const FileTabs: React.FC = () => { - const { files, activeFileName, setActiveFileName } = - useContext(GuidedStepsContext); + const { + files, + fileRefs, + activeFileName, + fileNameHasType, + setActiveFileName, + } = useContext(GuidedStepsContext); return (
    @@ -27,13 +32,51 @@ export const FileTabs: React.FC = () => { {name} ))} + {fileNameHasType && ( + + )}
); }; const FileComponent: React.FC = () => { - const { files, activeFileName, setCodeBlockRef } = - useContext(GuidedStepsContext); + const { + files, + activeFileName, + showCopyButton, + fileNameHasType, + setCodeBlockRef, + setFileRef, + } = useContext(GuidedStepsContext); return (
@@ -43,11 +86,13 @@ const FileComponent: React.FC = () => { className={cn(styles.file, { [styles.active]: activeFileName === file.name, })} + ref={(el) => setFileRef(file.name, el)} > {extractCodeBlocksFromFile(file).map(({ stepId, children }, i) => ( setCodeBlockRef(stepId, el)} + copyButtonActive={showCopyButton && fileNameHasType} fileName={file.name} > {children} diff --git a/src/components/GuidedSteps/GuidedSteps.module.css b/src/components/GuidedSteps/GuidedSteps.module.css index 49f0ad98..714e4bf8 100644 --- a/src/components/GuidedSteps/GuidedSteps.module.css +++ b/src/components/GuidedSteps/GuidedSteps.module.css @@ -13,85 +13,50 @@ gap: var(--m-1); } -.instruction { - padding: var(--m-2) var(--m-1); - border-left: 4px solid transparent; - transition: all 0.2s ease-in-out; - min-height: 100px; - cursor: pointer; - position: relative; - border-bottom: 1px solid var(--color-tonal-neutral-1); - scroll-margin: var(--ifm-navbar-height); - - &.active { - border-left-color: var(--color-dark-purple); - background-color: var(--color-tonal-neutral-0); - } - &:last-child { - border-bottom: none; - } - - &:hover { - border-left-color: var(--color-tonal-neutral-1); - .instructionLinkCopyButton { - opacity: 1; - } +.codePanel { + position: sticky; + top: calc(var(--ifm-navbar-height) + var(--m-4)); + background-color: #060918; + max-height: calc(100dvh - var(--ifm-navbar-height) - var(--m-8)); + overflow-y: scroll; + height: 100%; + @media (--md-scr) { + border-radius: var(--r-default); } - @media (--md-scr) { - padding: var(--m-2); + &.copyButtonActive { + background-color: var(--color-code); } } -.instructionLinkCopyButton { - opacity: 0; - transition: opacity 0.2s ease-in-out; - background-color: transparent; +.copyButton { position: absolute; - top: var(--m-1); - right: -8px; - border: none; - padding: 0; - cursor: pointer; + bottom: 53px; + left: 50%; + max-width: 136px; + width: 100%; display: flex; align-items: center; justify-content: center; - min-width: 52px; - :global(svg) { - fill: var(--color-dark-purple); - stroke: var(--color-dark-purple); - } - &.active { - opacity: 1; - } -} - -.copiedText { - user-select: none; - font-size: 12px; - color: var(--color-dark-purple); - font-weight: 500; - animation: fadeIn 0.2s ease-in-out; - transform: translateX(-25%); -} - -@keyframes fadeIn { - from { - opacity: 0; - } - to { - opacity: 1; + gap: var(--m-0-5); + white-space: nowrap; + transform: translateX(-50%); + padding: var(--m-1) var(--m-2); + background-color: var(--color-white); + border: 1.5px solid transparent; + border-radius: 36px; + cursor: pointer; + font-size: var(--fs-text-lg); + transition: + box-shadow 0.1s ease-in-out, + border-color 0.1s ease-in-out; + span { + line-height: 1.5; + font-weight: 300; } -} -.codePanel { - position: sticky; - top: var(--ifm-navbar-height); - background-color: #060918; - max-height: calc(100dvh - var(--ifm-navbar-height)); - overflow-y: scroll; - height: 100%; - @media (--md-scr) { - border-radius: var(--r-default); + &:hover { + box-shadow: 0 0 25px 0 rgba(81, 47, 201, 0.5); + border-color: var(--color-interactive-primary); } } diff --git a/src/components/GuidedSteps/GuidedSteps.tsx b/src/components/GuidedSteps/GuidedSteps.tsx index 1a3e66cd..5c70246f 100644 --- a/src/components/GuidedSteps/GuidedSteps.tsx +++ b/src/components/GuidedSteps/GuidedSteps.tsx @@ -5,21 +5,32 @@ import { useRef, useState, } from "react"; +import cn from "classnames"; import { sanitizeLeftColumnChildren } from "./utils"; import styles from "./GuidedSteps.module.css"; import GuidedStepsContextProvider, { GuidedStepsContext } from "./context"; import { GuidedStepsProps } from "./types"; import File, { FileTabs } from "./File"; +import Icon from "../Icon"; const GuidedStepsComponent: React.FC = (props) => { - const { steps, activeStepId, codeBlockRefs, stepRefs, setActiveStepId } = - useContext(GuidedStepsContext); - const [copiedId, setCopiedId] = useState(null); - const observerRef = useRef(null); + const { + steps, + activeStepId, + activeFileName, + codeBlockRefs, + stepRefs, + fileRefs, + showCopyButton, + fileNameHasType, + setActiveStepId, + setShowCopyButton, + } = useContext(GuidedStepsContext); + const [copiedIndicator, setCopiedIndicator] = useState(false); + const observerRef = useRef(null); const lastHighlightTime = useRef(0); const pendingHighlight = useRef(undefined); - const ignoreIntersection = useRef(false); useLayoutEffect(() => { @@ -116,35 +127,16 @@ const GuidedStepsComponent: React.FC = (props) => { [activeStepId] ); - // Handle anchor links when the page loads - useLayoutEffect(() => { - const hash = window.location.hash.replace("#", ""); - - if (hash) { - const index = steps.findIndex((item) => item.id === hash); - - if (index !== -1) { - highlightStep(hash); - - stepRefs.current[index]?.scrollIntoView({ - block: "start", - }); - } - } else if (steps.length > 0) { - highlightStep(steps[0].id); - } - }, [steps]); - - const copyLinkToClipboard = (id: string, event: React.MouseEvent) => { - const link = `${window.location.origin}${window.location.pathname}#${id}`; - window.history.pushState({}, "", `#${id}`); - navigator.clipboard.writeText(link); - - setCopiedId(id); - + const handleCopyCode = () => { + const noFileType = activeFileName?.split(".").length === 1; + const codeText = noFileType + ? codeBlockRefs.current.get(activeStepId).innerText + : fileRefs.current.get(activeFileName)?.innerText; + navigator.clipboard.writeText(codeText); + setCopiedIndicator(true); setTimeout(() => { - setCopiedId(null); - }, 1000); + setCopiedIndicator(false); + }, 1500); }; return ( @@ -153,9 +145,21 @@ const GuidedStepsComponent: React.FC = (props) => { {sanitizeLeftColumnChildren(props.children)}
-
+
setShowCopyButton(true)} + onMouseLeave={() => setShowCopyButton(false)} + > + {showCopyButton && ( + + )}
); diff --git a/src/components/GuidedSteps/context.tsx b/src/components/GuidedSteps/context.tsx index 3d7b2928..e01ef68d 100644 --- a/src/components/GuidedSteps/context.tsx +++ b/src/components/GuidedSteps/context.tsx @@ -9,22 +9,23 @@ import { MutableRefObject, } from "react"; import { useGuidedSteps } from "./utils"; -import { - StepProps as Step, - FileProps as File, - CodeBlockHandle, -} from "./types"; +import { StepProps as Step, FileProps as File, CodeBlockHandle } from "./types"; interface GuidedStepsContextValue { steps: Step[]; files: File[]; activeStepId: string | null; - activeFileName?: string | null; + activeFileName?: string; codeBlockRefs: MutableRefObject>; stepRefs: MutableRefObject; - setActiveStepId?: (id: string | null) => void; - setActiveFileName?: (name: string | null) => void; - setCodeBlockRef?: (stepId: string, ref: any) => void; + fileRefs: MutableRefObject>; + showCopyButton: boolean; + fileNameHasType: boolean; + setShowCopyButton: (show: boolean) => void; + setActiveStepId: (id: string | null) => void; + setActiveFileName: (name: string | null) => void; + setCodeBlockRef: (stepId: string, ref: any) => void; + setFileRef: (fileName: string, ref: HTMLDivElement | null) => void; } const GuidedStepsContext = createContext(null); @@ -37,19 +38,38 @@ const GuidedStepsProvider: React.FC<{ children: ReactElement }> = ({ }); const [activeStepId, setActiveStepId] = useState(null); const [activeFileName, setActiveFileName] = useState(null); + const [showCopyButton, setShowCopyButton] = useState(false); + const [fileNameHasType, setFileNameHasType] = useState(false); + const codeBlockRefs = useRef>(new Map()); const stepRefs = useRef([]); + const fileRefs = useRef>(new Map()); const setCodeBlockRef = useCallback((stepId: string, ref: any) => { codeBlockRefs.current.set(stepId, ref); }, []); + const setFileRef = useCallback( + (fileName: string, ref: HTMLDivElement | null) => { + fileRefs.current.set(fileName, ref); + }, + [] + ); + useEffect(() => { if (files.length > 0 && !activeFileName) { setActiveFileName(files[0].name); } }, [files]); + useEffect(() => { + if (activeFileName?.split(".").length > 1) { + setFileNameHasType(true); + } else { + setFileNameHasType(false); + } + }, [activeFileName]); + const value = useMemo( () => ({ steps, @@ -58,11 +78,25 @@ const GuidedStepsProvider: React.FC<{ children: ReactElement }> = ({ activeFileName, codeBlockRefs, stepRefs, + fileRefs, + showCopyButton, + fileNameHasType, + setFileNameHasType, + setShowCopyButton, setActiveStepId, setActiveFileName, setCodeBlockRef, + setFileRef, }), - [steps, files, activeStepId, activeFileName, codeBlockRefs] + [ + steps, + files, + activeStepId, + activeFileName, + showCopyButton, + codeBlockRefs, + fileNameHasType, + ] ); return ( diff --git a/src/components/GuidedSteps/types.ts b/src/components/GuidedSteps/types.ts index ef088e4a..561bf535 100644 --- a/src/components/GuidedSteps/types.ts +++ b/src/components/GuidedSteps/types.ts @@ -12,6 +12,7 @@ interface GuidedStepsProps { interface CodeBlockProps { stepId: string; fileName?: string; + copyButtonActive?: boolean; children: React.ReactNode; } @@ -34,6 +35,7 @@ interface FileProps { interface CodeBlockHandle { activate: () => void; deactivate: () => void; + innerText: string; } export { diff --git a/src/components/Icon/icons.ts b/src/components/Icon/icons.ts index d902dd3c..cf271bee 100644 --- a/src/components/Icon/icons.ts +++ b/src/components/Icon/icons.ts @@ -88,6 +88,9 @@ export { default as panther } from "./svg/panther.svg"; export { default as youtube } from "./svg/youtube.svg"; export { default as aws } from "./svg/aws.svg"; export { default as terraform } from "./svg/terraform.svg"; +export { default as copy2 } from "./svg/copy2.svg"; +export { default as download2 } from "./svg/download2.svg"; +export { default as check2 } from "./svg/check2.svg"; // Teleport svgs export { default as cluster } from "./teleport-svg/cluster.svg"; diff --git a/src/components/Icon/svg/check2.svg b/src/components/Icon/svg/check2.svg new file mode 100644 index 00000000..986ca4ed --- /dev/null +++ b/src/components/Icon/svg/check2.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/Icon/svg/copy2.svg b/src/components/Icon/svg/copy2.svg new file mode 100644 index 00000000..5fa87b6f --- /dev/null +++ b/src/components/Icon/svg/copy2.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/Icon/svg/download2.svg b/src/components/Icon/svg/download2.svg new file mode 100644 index 00000000..3c396697 --- /dev/null +++ b/src/components/Icon/svg/download2.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/styles/variables.css b/src/styles/variables.css index 9e8f7123..8df4b04a 100644 --- a/src/styles/variables.css +++ b/src/styles/variables.css @@ -21,6 +21,7 @@ --color-tonal-neutral-0: rgba(0, 0, 0, 0.06); --color-tonal-neutral-1: rgba(0, 0, 0, 0.13); --color-tonal-neutral-2: rgba(0, 0, 0, 0.18); + --color-foreground-slightly-muted-white: rgba(255, 255, 255, 0.72); --color-foreground-muted-white: rgba(255, 255, 255, 0.54); --color-foreground-disabled-white: rgba(255, 255, 255, 0.36); --color-foreground-tooltip-white: rgba(255, 255, 255, 0.8); @@ -63,6 +64,7 @@ /* font weights */ --fw-regular: 400; + --fw-medium: 500; --fw-bold: 700; --fw-black: 900; From 620c04f0a031b88b920b535554532af5b904d8ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aatu=20V=C3=A4is=C3=A4nen?= Date: Wed, 17 Sep 2025 13:10:59 +0300 Subject: [PATCH 12/15] style changes --- src/components/GuidedSteps/GuidedSteps.module.css | 2 +- src/components/GuidedSteps/Step.module.css | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/GuidedSteps/GuidedSteps.module.css b/src/components/GuidedSteps/GuidedSteps.module.css index 714e4bf8..8ac17c80 100644 --- a/src/components/GuidedSteps/GuidedSteps.module.css +++ b/src/components/GuidedSteps/GuidedSteps.module.css @@ -1,7 +1,7 @@ .guidedSteps { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--m-2); + gap: var(--m-4); margin-bottom: var(--ifm-leading); } diff --git a/src/components/GuidedSteps/Step.module.css b/src/components/GuidedSteps/Step.module.css index 06ee9da5..0e1cedfe 100644 --- a/src/components/GuidedSteps/Step.module.css +++ b/src/components/GuidedSteps/Step.module.css @@ -56,6 +56,12 @@ padding: 4px 10px; background-color: var(--color-tonal-neutral-0); border-radius: 50%; + width: var(--m-4); + line-height: 1.625; + display: flex; + align-items: center; + justify-content: center; + height: var(--m-4); } } From bd3e0264f9b35dc12bee4b1680c7a924c211587d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aatu=20V=C3=A4is=C3=A4nen?= Date: Wed, 17 Sep 2025 15:05:02 +0300 Subject: [PATCH 13/15] Code style --- src/components/GuidedSteps/File.module.css | 10 ++++++++++ src/styles/variables.css | 2 ++ 2 files changed, 12 insertions(+) diff --git a/src/components/GuidedSteps/File.module.css b/src/components/GuidedSteps/File.module.css index d84c510a..da4335bb 100644 --- a/src/components/GuidedSteps/File.module.css +++ b/src/components/GuidedSteps/File.module.css @@ -128,6 +128,16 @@ opacity: 0.52; background-color: transparent !important; transition: opacity 0.2s ease-in; + + code { + color: var(--color-data-purple-tertiary); + :global(.hljs-attr) { + color: var(--color-data-purple-tertiary); + } + :global(.hljs-string), :global(.hljs-built_in) { + color: var(--color-data-caribbean-tertiary); + } + } } &:first-of-type { diff --git a/src/styles/variables.css b/src/styles/variables.css index 8df4b04a..4bc839cf 100644 --- a/src/styles/variables.css +++ b/src/styles/variables.css @@ -35,6 +35,8 @@ --color-interactive-primary: #9f85ff; --color-background-depth-2: #fbfbfc; + --color-data-purple-tertiary: #B9A6FF; + --color-data-caribbean-tertiary: #2effd5; --color-warning: #ffb400; --color-danger: #f80061; From f6d9e8ffb3f62d73c8ba004fec91ad8d8aa7e528 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aatu=20V=C3=A4is=C3=A4nen?= Date: Fri, 19 Sep 2025 12:11:32 +0300 Subject: [PATCH 14/15] Jump to clicked step if it's not fully in view --- src/components/GuidedSteps/Step.module.css | 1 + src/components/GuidedSteps/Step.tsx | 23 +++++++++++++++------- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/components/GuidedSteps/Step.module.css b/src/components/GuidedSteps/Step.module.css index 0e1cedfe..8b6e7180 100644 --- a/src/components/GuidedSteps/Step.module.css +++ b/src/components/GuidedSteps/Step.module.css @@ -6,6 +6,7 @@ justify-content: center; background-color: #f1f2f4; border-radius: var(--m-1); + scroll-margin-top: calc(var(--ifm-navbar-height) + var(--m-1)); &::after { content: ""; diff --git a/src/components/GuidedSteps/Step.tsx b/src/components/GuidedSteps/Step.tsx index e0e6727c..d64fed8d 100644 --- a/src/components/GuidedSteps/Step.tsx +++ b/src/components/GuidedSteps/Step.tsx @@ -6,13 +6,8 @@ import cn from "classnames"; import Icon from "../Icon"; const Step: React.FC = ({ id, index, children }) => { - const { - files, - codeBlockRefs, - stepRefs, - activeStepId, - setActiveStepId, - } = useContext(GuidedStepsContext); + const { files, codeBlockRefs, stepRefs, activeStepId, setActiveStepId } = + useContext(GuidedStepsContext); const [relatedCodeBlock, setRelatedCodeBlock] = useState(null); @@ -29,6 +24,20 @@ const Step: React.FC = ({ id, index, children }) => { const activateStep = useCallback(() => { setActiveStepId?.(id); + const stepRef = stepRefs.current[index]; + const stepRefRect = stepRef?.getBoundingClientRect(); + const navbarHeight = + parseInt( + getComputedStyle(document.documentElement).getPropertyValue( + "--ifm-navbar-height" + ) + ) || 0; + if ( + stepRefRect && + (stepRefRect.top < navbarHeight || + stepRefRect.bottom > window.innerHeight - navbarHeight) + ) + stepRef?.scrollIntoView({ behavior: "smooth", block: "start" }); codeBlockRefs.current.forEach((step) => step.deactivate()); if (activeStepId !== id) relatedCodeBlock?.activate(); }, [codeBlockRefs, relatedCodeBlock]); From e37c451eb048732d17d9d5191c8030608c904131 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aatu=20V=C3=A4is=C3=A4nen?= Date: Fri, 19 Sep 2025 14:26:13 +0300 Subject: [PATCH 15/15] Refactoring, adding comments, fix bug in CodeBlock activate() --- src/components/GuidedSteps/CodeBlock.tsx | 1 + src/components/GuidedSteps/File.module.css | 5 +- src/components/GuidedSteps/GuidedSteps.tsx | 111 +---------------- src/components/GuidedSteps/context.tsx | 4 +- src/components/GuidedSteps/hooks.tsx | 137 +++++++++++++++++++++ src/components/GuidedSteps/utils.tsx | 28 ++--- 6 files changed, 155 insertions(+), 131 deletions(-) create mode 100644 src/components/GuidedSteps/hooks.tsx diff --git a/src/components/GuidedSteps/CodeBlock.tsx b/src/components/GuidedSteps/CodeBlock.tsx index 919aa071..140289b8 100644 --- a/src/components/GuidedSteps/CodeBlock.tsx +++ b/src/components/GuidedSteps/CodeBlock.tsx @@ -29,6 +29,7 @@ const CodeBlock = forwardRef< behavior: "smooth", block: "nearest", inline: "start", + container: "nearest", }); }, deactivate: (): void => setActiveLines(false), diff --git a/src/components/GuidedSteps/File.module.css b/src/components/GuidedSteps/File.module.css index da4335bb..36ceb519 100644 --- a/src/components/GuidedSteps/File.module.css +++ b/src/components/GuidedSteps/File.module.css @@ -1,6 +1,6 @@ .files { position: relative; - height: max-content; + height: 110%; border-bottom-left-radius: var(--m-1); border-bottom-right-radius: var(--m-1); @@ -134,7 +134,8 @@ :global(.hljs-attr) { color: var(--color-data-purple-tertiary); } - :global(.hljs-string), :global(.hljs-built_in) { + :global(.hljs-string), + :global(.hljs-built_in) { color: var(--color-data-caribbean-tertiary); } } diff --git a/src/components/GuidedSteps/GuidedSteps.tsx b/src/components/GuidedSteps/GuidedSteps.tsx index 5c70246f..1286f273 100644 --- a/src/components/GuidedSteps/GuidedSteps.tsx +++ b/src/components/GuidedSteps/GuidedSteps.tsx @@ -1,10 +1,4 @@ -import { - useCallback, - useContext, - useLayoutEffect, - useRef, - useState, -} from "react"; +import { useContext, useState } from "react"; import cn from "classnames"; import { sanitizeLeftColumnChildren } from "./utils"; import styles from "./GuidedSteps.module.css"; @@ -12,120 +6,21 @@ import GuidedStepsContextProvider, { GuidedStepsContext } from "./context"; import { GuidedStepsProps } from "./types"; import File, { FileTabs } from "./File"; import Icon from "../Icon"; +import { useGuidedSteps } from "./hooks"; const GuidedStepsComponent: React.FC = (props) => { const { - steps, activeStepId, activeFileName, codeBlockRefs, - stepRefs, fileRefs, showCopyButton, fileNameHasType, - setActiveStepId, setShowCopyButton, } = useContext(GuidedStepsContext); const [copiedIndicator, setCopiedIndicator] = useState(false); - const observerRef = useRef(null); - const lastHighlightTime = useRef(0); - const pendingHighlight = useRef(undefined); - const ignoreIntersection = useRef(false); - - useLayoutEffect(() => { - const initializeObserver = () => { - const debounceDelay = 10; - - const debounceHighlightedStep = (stepId: string) => { - if (ignoreIntersection.current) return; - - const now = Date.now(); - pendingHighlight.current = stepId; - - if (now - lastHighlightTime.current > debounceDelay) { - highlightStep(stepId, true); - lastHighlightTime.current = now; - pendingHighlight.current = undefined; - } else { - setTimeout(() => { - if (pendingHighlight.current === stepId) { - highlightStep(stepId, true); - lastHighlightTime.current = Date.now(); - pendingHighlight.current = undefined; - } - }, debounceDelay); - } - }; - - const navHeight = - parseInt( - document.documentElement.style.getPropertyValue("--ifm-navbar-height") - ) || 117; - - const rootBottomMargin = - document.body.getBoundingClientRect().height - navHeight * 2; - - const options = { - rootMargin: `-${navHeight + 16}px 0px -${rootBottomMargin}px 0px`, - threshold: Array.from({ length: 1000 }, (_, i) => i / 1000), - }; - - observerRef.current = new IntersectionObserver((entries) => { - if (ignoreIntersection.current) return; - - const visibleEntries = entries.filter( - (entry) => entry.isIntersecting && entry.intersectionRatio > 0.3 - ); - - if (visibleEntries.length > 0) { - const mostVisibleEntry = visibleEntries.reduce((max, entry) => - entry.intersectionRatio > max.intersectionRatio ? entry : max - ); - - debounceHighlightedStep(mostVisibleEntry.target.id); - } - }, options); - - stepRefs.current.forEach((step) => { - if (observerRef.current) { - observerRef.current.observe(step); - } - }); - }; - - initializeObserver(); - - window.removeEventListener("resize", initializeObserver); - window.addEventListener("resize", initializeObserver); - - return () => { - if (observerRef.current) { - observerRef.current.disconnect(); - observerRef.current = null; - } - window.removeEventListener("resize", initializeObserver); - }; - }, []); - - const highlightStep = useCallback( - (stepId: string, fromObserver = false) => { - if (stepId === activeStepId) return; - setActiveStepId(stepId); - codeBlockRefs.current.forEach((step) => step.deactivate()); - codeBlockRefs.current.get(stepId)?.activate(); - - // Only set the ignore flag if this was triggered with a click - if (!fromObserver) { - ignoreIntersection.current = true; - - setTimeout(() => { - ignoreIntersection.current = false; - }, 1000); - } - }, - [activeStepId] - ); + useGuidedSteps(); const handleCopyCode = () => { const noFileType = activeFileName?.split(".").length === 1; diff --git a/src/components/GuidedSteps/context.tsx b/src/components/GuidedSteps/context.tsx index e01ef68d..190a68f6 100644 --- a/src/components/GuidedSteps/context.tsx +++ b/src/components/GuidedSteps/context.tsx @@ -8,7 +8,7 @@ import { useRef, MutableRefObject, } from "react"; -import { useGuidedSteps } from "./utils"; +import { useGuidedStepsData } from "./hooks"; import { StepProps as Step, FileProps as File, CodeBlockHandle } from "./types"; interface GuidedStepsContextValue { @@ -33,7 +33,7 @@ const GuidedStepsContext = createContext(null); const GuidedStepsProvider: React.FC<{ children: ReactElement }> = ({ children, }) => { - const { steps, files } = useGuidedSteps({ + const { steps, files } = useGuidedStepsData({ children: children.props.children, }); const [activeStepId, setActiveStepId] = useState(null); diff --git a/src/components/GuidedSteps/hooks.tsx b/src/components/GuidedSteps/hooks.tsx new file mode 100644 index 00000000..8a144ae2 --- /dev/null +++ b/src/components/GuidedSteps/hooks.tsx @@ -0,0 +1,137 @@ +import { + useCallback, + useContext, + useLayoutEffect, + useMemo, + useRef, +} from "react"; +import { GuidedStepsProps } from "./types"; +import { extractCodeBlocksFromFile, extractFiles, extractSteps } from "./utils"; +import { GuidedStepsContext } from "./context"; + +// Custom hook to extract steps and files from GuidedSteps children. +export const useGuidedStepsData = (props: GuidedStepsProps) => { + const { children } = props; + return useMemo(() => { + const steps = extractSteps(children); + const files = extractFiles(children); + + const codeBlocks = files.flatMap(extractCodeBlocksFromFile); + // Ensure that each step has a corresponding code block. + steps.forEach((step) => { + const matchingCodeBlock = codeBlocks.find( + (codeBlock) => codeBlock.stepId === step.id + ); + if (!matchingCodeBlock) { + throw new Error( + `GuidedSteps: No code block found for step with id "${step.id}". Please ensure that each step has a corresponding code block with a matching stepId.` + ); + } + }); + return { steps, files }; + }, [children]); +}; + +export const useGuidedSteps = () => { + const { activeStepId, codeBlockRefs, stepRefs, setActiveStepId } = + useContext(GuidedStepsContext); + + const observerRef = useRef(null); + const lastHighlightTime = useRef(0); + const pendingHighlight = useRef(undefined); + const ignoreIntersection = useRef(false); + + useLayoutEffect(() => { + const initializeObserver = () => { + const debounceDelay = 10; + + const debounceHighlightedStep = (stepId: string) => { + if (ignoreIntersection.current) return; + + const now = Date.now(); + pendingHighlight.current = stepId; + + if (now - lastHighlightTime.current > debounceDelay) { + highlightStep(stepId, true); + lastHighlightTime.current = now; + pendingHighlight.current = undefined; + } else { + setTimeout(() => { + if (pendingHighlight.current === stepId) { + highlightStep(stepId, true); + lastHighlightTime.current = Date.now(); + pendingHighlight.current = undefined; + } + }, debounceDelay); + } + }; + + const navHeight = + parseInt( + document.documentElement.style.getPropertyValue("--ifm-navbar-height") + ) || 117; + + const rootBottomMargin = + document.body.getBoundingClientRect().height - navHeight * 2; + + const options = { + rootMargin: `-${navHeight + 16}px 0px -${rootBottomMargin}px 0px`, + threshold: Array.from({ length: 1000 }, (_, i) => i / 1000), + }; + + observerRef.current = new IntersectionObserver((entries) => { + if (ignoreIntersection.current) return; + + const visibleEntries = entries.filter( + (entry) => entry.isIntersecting && entry.intersectionRatio > 0.3 + ); + + if (visibleEntries.length > 0) { + const mostVisibleEntry = visibleEntries.reduce((max, entry) => + entry.intersectionRatio > max.intersectionRatio ? entry : max + ); + + debounceHighlightedStep(mostVisibleEntry.target.id); + } + }, options); + + stepRefs.current.forEach((step) => { + if (observerRef.current) { + observerRef.current.observe(step); + } + }); + }; + + initializeObserver(); + + window.removeEventListener("resize", initializeObserver); + window.addEventListener("resize", initializeObserver); + + return () => { + if (observerRef.current) { + observerRef.current.disconnect(); + observerRef.current = null; + } + window.removeEventListener("resize", initializeObserver); + }; + }, []); + + const highlightStep = useCallback( + (stepId: string, fromObserver = false) => { + if (stepId === activeStepId) return; + setActiveStepId(stepId); + codeBlockRefs.current.forEach((step) => step.deactivate()); + codeBlockRefs.current.get(stepId)?.activate(); + + // Only set the ignore flag if this was triggered with a click + if (!fromObserver) { + ignoreIntersection.current = true; + + setTimeout(() => { + ignoreIntersection.current = false; + }, 1000); + } + }, + [activeStepId] + ); +}; diff --git a/src/components/GuidedSteps/utils.tsx b/src/components/GuidedSteps/utils.tsx index c2149a13..4f7daee3 100644 --- a/src/components/GuidedSteps/utils.tsx +++ b/src/components/GuidedSteps/utils.tsx @@ -1,9 +1,4 @@ -import React, { - Children, - isValidElement, - useMemo, - type ReactElement, -} from "react"; +import React, { Children, isValidElement, type ReactElement } from "react"; import { CodeBlockProps, FileProps as File, @@ -12,7 +7,7 @@ import { } from "./types"; // Extract the instruction steps which are displayed on the left column. -const extractSteps = (children: GuidedStepsProps["children"]) => { +export const extractSteps = (children: GuidedStepsProps["children"]) => { return ( (Children.toArray(children) .filter((child) => child !== "/n") @@ -33,7 +28,7 @@ const extractSteps = (children: GuidedStepsProps["children"]) => { }; // Extract the code files which are displayed on the right column. -const extractFiles = (children: GuidedStepsProps["children"]) => { +export const extractFiles = (children: GuidedStepsProps["children"]) => { return sanitizeRightColumnChildren(children).map( ({ props: { name, icon, stepIds, children } }) => { return { @@ -66,15 +61,7 @@ export const extractCodeBlocksFromFile = (child: File) => { }); }; -export const useGuidedSteps = (props: GuidedStepsProps) => { - const { children } = props; - return useMemo(() => { - const steps = extractSteps(children); - const files = extractFiles(children); - return { steps, files }; - }, [children]); -}; - +// Sanitize the children of the left column: File components are filtered out. export const sanitizeLeftColumnChildren = ( children: GuidedStepsProps["children"] ) => { @@ -83,7 +70,7 @@ export const sanitizeLeftColumnChildren = ( return (Children.toArray(children) .map((child) => { if (!child || (isValidElement(child) && !isFile(child))) { - // If it's a StepSection, add the index prop + // If it's a StepSection, add the index prop in order to number the sections if (child && isValidElement(child) && isStepSection(child)) { stepSectionIndex++; return React.cloneElement( @@ -95,7 +82,7 @@ export const sanitizeLeftColumnChildren = ( ); } - // if it's a Step, add the index prop + // if it's a Step, add the index prop in order to keep track of the step refs if (child && isValidElement(child) && isStep(child)) { const indexedStep = React.cloneElement( child as ReactElement, @@ -112,6 +99,7 @@ export const sanitizeLeftColumnChildren = ( ?.filter(Boolean) ?? []) as ReactElement[]; }; +// Sanitize the children of the right column: Only File components are kept. export const sanitizeRightColumnChildren = ( children: GuidedStepsProps["children"] ) => { @@ -137,6 +125,8 @@ export const sanitizeRightColumnChildren = ( ?.filter(Boolean) ?? []) as ReactElement[]; }; +// Type guards to identify the different child components. + const isStep = ( component: ReactElement ): component is ReactElement => {