diff --git a/packages/components/src/components/steps/steps.stories.tsx b/packages/components/src/components/steps/steps.stories.tsx index bd35387b..94972c00 100644 --- a/packages/components/src/components/steps/steps.stories.tsx +++ b/packages/components/src/components/steps/steps.stories.tsx @@ -226,3 +226,20 @@ export const StepsWithCustomClassName: Story = { ), }; + +export const WithAnchorLinks: Story = { + render: () => ( + + + Hover over the step number or title to see the link icon. Click to copy + the anchor URL. + + + Each step can have a unique ID for deep linking. + + + This step has anchor disabled via noAnchor prop. + + + ), +}; diff --git a/packages/components/src/components/steps/steps.tsx b/packages/components/src/components/steps/steps.tsx index 25451bcc..01d2910d 100644 --- a/packages/components/src/components/steps/steps.tsx +++ b/packages/components/src/components/steps/steps.tsx @@ -1,8 +1,11 @@ import type React from "react"; -import type { ReactNode } from "react"; +import { type ReactNode, useCallback, useEffect, useState } from "react"; +import { type Rect, useRect } from "react-use-rect"; import { Icon } from "@/components/icon"; import { Classes } from "@/constants/selectors"; +import { LinkIcon } from "@/icons"; import { cn } from "@/utils/cn"; +import { copyToClipboard } from "@/utils/copy-to-clipboard"; import type { IconLibrary, IconType } from "@/utils/icon-utils"; import { STEP_TITLE_SIZES, type StepTitleSize } from "./constants"; @@ -18,6 +21,13 @@ type StepsItemProps = { titleSize?: StepTitleSize; className?: string; isLast?: boolean; + id?: string; + noAnchor?: boolean; + scrollElementIntoView?: (id: string) => void; + onCopyAnchorLink?: (id: string | undefined) => void; + onRegisterHeading?: (id: string, rect: Rect) => void; + onUnregisterHeading?: (id: string) => void; + _hasContext?: boolean; }; const StepsItem = ({ @@ -30,8 +40,81 @@ const StepsItem = ({ titleSize = "p", className, isLast = false, + id, + noAnchor = false, + scrollElementIntoView, + onRegisterHeading, + onUnregisterHeading, + _hasContext, + onCopyAnchorLink, }: StepsItemProps) => { + const [rect, setRect] = useState(null); + const [isTitleHovered, setIsTitleHovered] = useState(false); + const [rectRef] = useRect(setRect); + const titleTag = STEP_TITLE_SIZES.includes(titleSize) ? titleSize : "p"; + const isAnchorEnabled = ["h2", "h3"].includes(titleTag); + + const copyAnchorLink = useCallback(async () => { + if (!isAnchorEnabled || noAnchor || !id || typeof window === "undefined") { + return; + } + + const result = await copyToClipboard( + `${window.location.href.split("#")[0]}#${encodeURIComponent(id)}` + ); + + if (result === "success") { + window.location.hash = encodeURIComponent(id); + onCopyAnchorLink?.(id); + } + }, [isAnchorEnabled, id, noAnchor, onCopyAnchorLink]); + + useEffect(() => { + if (!(_hasContext && id && rect)) { + return; + } + + onRegisterHeading?.(id, rect); + + return () => { + onUnregisterHeading?.(id); + }; + }, [rect, id, _hasContext, onRegisterHeading, onUnregisterHeading]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: scrolling should only happen once, on initial load + useEffect(() => { + if (!id || noAnchor) { + return; + } + + try { + const hash = decodeURIComponent(window.location.hash.substring(1)); + + if (hash === id) { + scrollElementIntoView?.(id); + } + } catch { + // silently fail if the hash is invalid + } + }, []); + + const toggleAnchor = useCallback( + (shown: boolean) => { + if (!isAnchorEnabled || noAnchor || !id) { + return; + } + + setIsTitleHovered(shown); + }, + [isAnchorEnabled, noAnchor, id] + ); + + const interactionProps = { + onMouseEnter: () => toggleAnchor(true), + onMouseLeave: () => toggleAnchor(false), + onClick: copyAnchorLink, + }; const transformedIconOrNumber = typeof icon === "string" ? ( @@ -59,6 +142,8 @@ const StepsItem = ({ className )} data-component-part="step-item" + id={id} + ref={rectRef} role="listitem" >
-
- {transformedIconOrNumber} + {/** biome-ignore lint/a11y/noNoninteractiveElementInteractions: TODO */} + {/** biome-ignore lint/a11y/noStaticElementInteractions: TODO */} +
toggleAnchor(true)} + onMouseLeave={() => toggleAnchor(false)} + > +
+ {transformedIconOrNumber} +
+ {isAnchorEnabled && !noAnchor && id && ( +
+ { + e.preventDefault(); + copyAnchorLink(); + }} + > +
+ +
+
+
+ )}
@@ -96,22 +212,35 @@ const StepsItem = ({ "prose dark:prose-invert font-semibold" )} {...titleProps} + {...interactionProps} > {title}

), h2: ( -

+

{title}

), h3: ( -

+

{title}

), h4: ( -

+

{title}

),