diff --git a/config.json b/config.json index 269c2992..0188631f 100644 --- a/config.json +++ b/config.json @@ -80,7 +80,8 @@ }, { "name": "18.x", - "branch": "branch/v18", + "branch": "aatuvai/guided-steps", + "repo_path": "aatuvai/teleport", "isDefault": true }, { diff --git a/src/components/GuidedSteps/CodeBlock.tsx b/src/components/GuidedSteps/CodeBlock.tsx new file mode 100644 index 00000000..140289b8 --- /dev/null +++ b/src/components/GuidedSteps/CodeBlock.tsx @@ -0,0 +1,55 @@ +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, 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); + setActiveLines(true); + stepRef.current?.scrollIntoView({ + behavior: "smooth", + block: "nearest", + inline: "start", + container: "nearest", + }); + }, + deactivate: (): void => setActiveLines(false), + innerText: stepRef.current?.innerText || "", + }) + ); + + 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..36ceb519 --- /dev/null +++ b/src/components/GuidedSteps/File.module.css @@ -0,0 +1,172 @@ +.files { + position: relative; + height: 110%; + 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; + display: flex; + overflow: hidden; + flex-direction: column; + + &.active { + opacity: 1; + height: auto; + position: relative; + pointer-events: auto; + } + + :global(pre) { + border-radius: 0; + transition: all 0.2s ease-in; + } + & > div { + box-shadow: unset; + } +} + +.fileTabs { + position: sticky; + top: 0; + display: flex; + 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; + min-height: 65px; +} + +.fileTab { + display: flex; + align-items: center; + padding: var(--m-0-5) var(--m-2); + 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 { + 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; + filter: brightness(0) invert(1); + } + + span { + line-height: 1; + } +} + +.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; + scroll-margin-top: 72px; + :global(pre) { + padding: 0 0 0 var(--m-2); + 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 { + & > div { + padding-top: var(--m-2); + } + } + + &: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; + } + + & > div { + background-color: var(--color-code) !important; + } + } +} diff --git a/src/components/GuidedSteps/File.tsx b/src/components/GuidedSteps/File.tsx new file mode 100644 index 00000000..d264757c --- /dev/null +++ b/src/components/GuidedSteps/File.tsx @@ -0,0 +1,109 @@ +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, + fileRefs, + activeFileName, + fileNameHasType, + setActiveFileName, + } = useContext(GuidedStepsContext); + + return ( +
    + {files.map(({ name, icon }) => ( +
  • { + setActiveFileName(name); + }} + > + {icon && } + {name} +
  • + ))} + {fileNameHasType && ( + + )} +
+ ); +}; + +const FileComponent: React.FC = () => { + const { + files, + activeFileName, + showCopyButton, + fileNameHasType, + setCodeBlockRef, + setFileRef, + } = useContext(GuidedStepsContext); + + return ( +
+ {files.map((file) => ( +
setFileRef(file.name, el)} + > + {extractCodeBlocksFromFile(file).map(({ stepId, children }, i) => ( + setCodeBlockRef(stepId, el)} + copyButtonActive={showCopyButton && fileNameHasType} + fileName={file.name} + > + {children} + + ))} +
+ ))} +
+ ); +}; + +FileComponent.displayName = "File"; + +export default FileComponent; diff --git a/src/components/GuidedSteps/GuidedSteps.module.css b/src/components/GuidedSteps/GuidedSteps.module.css new file mode 100644 index 00000000..8ac17c80 --- /dev/null +++ b/src/components/GuidedSteps/GuidedSteps.module.css @@ -0,0 +1,62 @@ +.guidedSteps { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--m-4); + margin-bottom: var(--ifm-leading); +} + +.instructions { + position: relative; + height: max-content; + padding-bottom: 150%; + display: grid; + gap: var(--m-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); + } + + &.copyButtonActive { + background-color: var(--color-code); + } +} + +.copyButton { + position: absolute; + bottom: 53px; + left: 50%; + max-width: 136px; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + 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; + } + + &: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 new file mode 100644 index 00000000..1286f273 --- /dev/null +++ b/src/components/GuidedSteps/GuidedSteps.tsx @@ -0,0 +1,71 @@ +import { useContext, 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"; +import { useGuidedSteps } from "./hooks"; + +const GuidedStepsComponent: React.FC = (props) => { + const { + activeStepId, + activeFileName, + codeBlockRefs, + fileRefs, + showCopyButton, + fileNameHasType, + setShowCopyButton, + } = useContext(GuidedStepsContext); + const [copiedIndicator, setCopiedIndicator] = useState(false); + + useGuidedSteps(); + + 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(() => { + setCopiedIndicator(false); + }, 1500); + }; + + return ( +
+
+ {sanitizeLeftColumnChildren(props.children)} +
+ +
setShowCopyButton(true)} + onMouseLeave={() => setShowCopyButton(false)} + > + + + {showCopyButton && ( + + )} +
+
+ ); +}; + +const GuidedSteps: React.FC = (props) => { + return ( + + + + ); +}; + +export default GuidedSteps; diff --git a/src/components/GuidedSteps/Step.module.css b/src/components/GuidedSteps/Step.module.css new file mode 100644 index 00000000..8b6e7180 --- /dev/null +++ b/src/components/GuidedSteps/Step.module.css @@ -0,0 +1,84 @@ +.step { + 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); + scroll-margin-top: calc(var(--ifm-navbar-height) + var(--m-1)); + + &::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; + } + + &:hover { + background-color: var(--color-tonal-primary-0); + } + } + + &:hover { + background-color: var(--color-tonal-neutral-0); + cursor: pointer; + } +} + +.stepSection { + display: flex; + align-items: center; + 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; + 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); + } +} + +.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 new file mode 100644 index 00000000..d64fed8d --- /dev/null +++ b/src/components/GuidedSteps/Step.tsx @@ -0,0 +1,64 @@ +import { useCallback, useContext, useEffect, useState } from "react"; +import { GuidedStepsContext } from "./context"; +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, index, children }) => { + const { files, codeBlockRefs, stepRefs, 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); + 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]); + + return ( +
(stepRefs.current[index] = el)} + onClick={activateStep} + id={id} + > +
{children}
+
+ + {relatedFile.name} +
+
+ ); +}; + +Step.displayName = "Step"; + +export default Step; diff --git a/src/components/GuidedSteps/StepSection.tsx b/src/components/GuidedSteps/StepSection.tsx new file mode 100644 index 00000000..6aa4d6a9 --- /dev/null +++ b/src/components/GuidedSteps/StepSection.tsx @@ -0,0 +1,15 @@ +import styles from "./Step.module.css"; + +const StepSection: React.FC<{ index?: number; children: React.ReactNode }> = ({ + index, + children, +}) => ( +
+
{index}
+ {children} +
+); + +StepSection.displayName = "StepSection"; + +export default StepSection; diff --git a/src/components/GuidedSteps/context.tsx b/src/components/GuidedSteps/context.tsx new file mode 100644 index 00000000..190a68f6 --- /dev/null +++ b/src/components/GuidedSteps/context.tsx @@ -0,0 +1,110 @@ +import { + createContext, + useState, + useMemo, + useCallback, + ReactElement, + useEffect, + useRef, + MutableRefObject, +} from "react"; +import { useGuidedStepsData } from "./hooks"; +import { StepProps as Step, FileProps as File, CodeBlockHandle } from "./types"; + +interface GuidedStepsContextValue { + steps: Step[]; + files: File[]; + activeStepId: string | null; + activeFileName?: string; + codeBlockRefs: MutableRefObject>; + stepRefs: MutableRefObject; + 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); + +const GuidedStepsProvider: React.FC<{ children: ReactElement }> = ({ + children, +}) => { + const { steps, files } = useGuidedStepsData({ + children: children.props.children, + }); + 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, + files, + activeStepId, + activeFileName, + codeBlockRefs, + stepRefs, + fileRefs, + showCopyButton, + fileNameHasType, + setFileNameHasType, + setShowCopyButton, + setActiveStepId, + setActiveFileName, + setCodeBlockRef, + setFileRef, + }), + [ + steps, + files, + activeStepId, + activeFileName, + showCopyButton, + codeBlockRefs, + fileNameHasType, + ] + ); + + return ( + + {children} + + ); +}; + +export default GuidedStepsProvider; +export { GuidedStepsContext }; 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/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/types.ts b/src/components/GuidedSteps/types.ts new file mode 100644 index 00000000..561bf535 --- /dev/null +++ b/src/components/GuidedSteps/types.ts @@ -0,0 +1,49 @@ +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; + fileName?: string; + copyButtonActive?: boolean; + children: React.ReactNode; +} + +interface StepProps { + id: string; + index: number; + children: React.ReactNode; +} + +interface FileProps { + name: string; + icon?: IconName; + stepIds?: Array; + children: + | React.ReactNode + | React.ReactElement + | React.ReactElement[]; +} + +interface CodeBlockHandle { + activate: () => void; + deactivate: () => void; + innerText: string; +} + +export { + Step, + File, + GuidedStepsProps, + CodeBlockProps, + StepProps, + FileProps, + CodeBlockHandle, +}; diff --git a/src/components/GuidedSteps/utils.tsx b/src/components/GuidedSteps/utils.tsx new file mode 100644 index 00000000..4f7daee3 --- /dev/null +++ b/src/components/GuidedSteps/utils.tsx @@ -0,0 +1,174 @@ +import React, { Children, isValidElement, type ReactElement } from "react"; +import { + CodeBlockProps, + FileProps as File, + GuidedStepsProps, + StepProps, +} from "./types"; + +// Extract the instruction steps which are displayed on the left column. +export 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, index, children } }) => { + return { + id, + index, + children, + }; + }); +}; + +// Extract the code files which are displayed on the right column. +export const extractFiles = (children: GuidedStepsProps["children"]) => { + return sanitizeRightColumnChildren(children).map( + ({ props: { name, icon, stepIds, children } }) => { + return { + name, + icon, + stepIds, + children, + }; + } + ); +}; + +// Extract the code blocks from a File component in order to map them to the instruction steps. +export 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, + }; + }); +}; + +// Sanitize the children of the left column: File components are filtered out. +export const sanitizeLeftColumnChildren = ( + children: GuidedStepsProps["children"] +) => { + let stepSectionIndex = 0; + let stepIndex = 0; + return (Children.toArray(children) + .map((child) => { + if (!child || (isValidElement(child) && !isFile(child))) { + // 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( + child as ReactElement<{ + index?: number; + children: React.ReactNode; + }>, + { index: stepSectionIndex } + ); + } + + // 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, + { + index: stepIndex, + } + ); + stepIndex++; + return indexedStep; + } + return child; + } + }) + ?.filter(Boolean) ?? []) as ReactElement[]; +}; + +// Sanitize the children of the right column: Only File components are kept. +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 as ReactElement<{ + name: string; + icon?: string; + stepIds?: Array; + children: React.ReactNode; + }>, + { stepIds } + ); + } + }) + ?.filter(Boolean) ?? []) as ReactElement[]; +}; + +// Type guards to identify the different child components. + +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 isCodeBlock = ( + component: ReactElement +): component is ReactElement<{ stepId: string; children: React.ReactNode }> => { + const { props, type } = component; + return ( + !!props && + typeof props === "object" && + "children" in props && + "stepId" in props && + (type as React.ComponentType).displayName === "CodeBlock" + ); +}; diff --git a/src/components/Icon/icons.ts b/src/components/Icon/icons.ts index 2f17e2e9..cf271bee 100644 --- a/src/components/Icon/icons.ts +++ b/src/components/Icon/icons.ts @@ -72,6 +72,9 @@ 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"; +export { default as file } from "./svg/file.svg"; +export { default as terminal } from "./svg/terminal.svg"; export { default as awsIdentity } from "./svg/awsIdentity.svg"; export { default as googleCloud } from "./svg/googleCloud.svg"; export { default as azure } from "./svg/azure.svg"; @@ -85,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/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/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 @@ + + + + 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/styles/variables.css b/src/styles/variables.css index 59f9185f..4bc839cf 100644 --- a/src/styles/variables.css +++ b/src/styles/variables.css @@ -21,15 +21,27 @@ --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); + --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); + --color-interactive-tonal-neutral-dark-0: rgba(255, 255, 255, 0.07); + + --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; --color-text: #263238; - --color-code: #01172c; + --color-code: #0e1435; --color-tip: #00c7ae; --color-note: #009cf1; @@ -54,6 +66,7 @@ /* font weights */ --fw-regular: 400; + --fw-medium: 500; --fw-bold: 700; --fw-black: 900; diff --git a/src/theme/MDXComponents/index.tsx b/src/theme/MDXComponents/index.tsx index 71c74de8..f8583a16 100644 --- a/src/theme/MDXComponents/index.tsx +++ b/src/theme/MDXComponents/index.tsx @@ -22,6 +22,11 @@ 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 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, @@ -55,6 +60,11 @@ const MDXComponents: MDXComponentsObject = { ul: MDXUl, Var: (props) => , // needed to circumvent props mismatch in types ThumbsFeedback, + GuidedSteps, + Step, + File, + StepSection, + CodeBlock, }; export default MDXComponents;