Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions packages/components/src/components/steps/steps.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -226,3 +226,20 @@ export const StepsWithCustomClassName: Story = {
</Steps>
),
};

export const WithAnchorLinks: Story = {
render: () => (
<Steps titleSize="h2">
<Steps.Item id="installation" title="Installation">
Hover over the step number or title to see the link icon. Click to copy
the anchor URL.
</Steps.Item>
<Steps.Item id="configuration" title="Configuration">
Each step can have a unique ID for deep linking.
</Steps.Item>
<Steps.Item id="deployment" noAnchor title="Deployment (noAnchor)">
This step has anchor disabled via noAnchor prop.
</Steps.Item>
</Steps>
),
};
141 changes: 135 additions & 6 deletions packages/components/src/components/steps/steps.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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 = ({
Expand All @@ -30,8 +40,81 @@ const StepsItem = ({
titleSize = "p",
className,
isLast = false,
id,
noAnchor = false,
scrollElementIntoView,
onRegisterHeading,
onUnregisterHeading,
_hasContext,
onCopyAnchorLink,
}: StepsItemProps) => {
const [rect, setRect] = useState<Rect | null>(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" ? (
Expand Down Expand Up @@ -59,6 +142,8 @@ const StepsItem = ({
className
)}
data-component-part="step-item"
id={id}
ref={rectRef}
role="listitem"
>
<div
Expand All @@ -76,8 +161,39 @@ const StepsItem = ({
contentEditable={false}
data-component-part="step-number"
>
<div className="flex size-7 shrink-0 items-center justify-center rounded-full bg-stone-50 font-semibold text-stone-900 text-xs dark:bg-white/10 dark:text-stone-50">
{transformedIconOrNumber}
{/** biome-ignore lint/a11y/noNoninteractiveElementInteractions: TODO */}
{/** biome-ignore lint/a11y/noStaticElementInteractions: TODO */}
<div
className="relative flex size-7 shrink-0 items-center justify-center rounded-full bg-stone-50 font-semibold text-stone-900 text-xs dark:bg-white/10 dark:text-stone-50"
onMouseEnter={() => toggleAnchor(true)}
onMouseLeave={() => toggleAnchor(false)}
>
Comment thread
pqoqubbw marked this conversation as resolved.
<div className={cn(isTitleHovered && "opacity-0")}>
{transformedIconOrNumber}
</div>
{isAnchorEnabled && !noAnchor && id && (
<div
className="absolute"
data-component-part="step-number-anchor-link"
>
<a
aria-label="Navigate to header"
className={cn(
"flex items-center border-0 opacity-0",
isTitleHovered && "opacity-100"
)}
href={`#${encodeURIComponent(id as string)}`}
onClick={(e) => {
e.preventDefault();
copyAnchorLink();
}}
>
<div className="flex size-6 items-center justify-center">
<LinkIcon />
</div>
</a>
</div>
)}
Comment thread
pqoqubbw marked this conversation as resolved.
</div>
</div>
<div className="w-full overflow-hidden pr-px pl-8">
Expand All @@ -96,22 +212,35 @@ const StepsItem = ({
"prose dark:prose-invert font-semibold"
)}
{...titleProps}
{...interactionProps}
>
{title}
</p>
),
h2: (
<h2 className={titleClasses} {...titleProps}>
<h2
className={titleClasses}
{...titleProps}
{...interactionProps}
>
{title}
</h2>
),
h3: (
<h3 className={titleClasses} {...titleProps}>
<h3
className={titleClasses}
{...titleProps}
{...interactionProps}
>
{title}
</h3>
),
h4: (
<h4 className={titleClasses} {...titleProps}>
<h4
className={titleClasses}
{...titleProps}
{...interactionProps}
>
{title}
</h4>
),
Expand Down