Files
copilotkit__copilotkit/docs/components/react/tailored-content.tsx

257 lines
8.2 KiB
TypeScript

"use client";
import React, {
ReactNode,
Suspense,
useCallback,
useEffect,
useMemo,
useRef,
} from "react";
import { useRouter, useSearchParams } from "next/navigation";
// Local className-joining helper so this component has no external dep.
// Mirrors the subset of `classnames` behavior used below (strings + falsy values).
function cn(...values: Array<string | false | null | undefined>): string {
return values.filter(Boolean).join(" ");
}
type TailoredContentOptionProps = {
title: string;
description: string;
icon: ReactNode;
children: ReactNode;
id: string;
};
/**
* Declarative child marker for `TailoredContent`. This component intentionally
* renders nothing; the parent reads its props (including `children`) directly
* and renders the selected option's content itself.
*/
export function TailoredContentOption(_props: TailoredContentOptionProps) {
return null;
}
type TailoredContentProps = {
children: ReactNode;
header?: ReactNode;
className?: string;
defaultOptionIndex?: number;
id: string;
};
type IconElement = React.ReactElement<{ className?: string }>;
function TailoredContentInner({
children,
className,
defaultOptionIndex = 0,
id,
header,
}: TailoredContentProps) {
// All hooks must run unconditionally to satisfy the Rules of Hooks.
const router = useRouter();
const searchParams = useSearchParams();
const tabRefs = useRef<Array<HTMLDivElement | null>>([]);
const warnedKeyRef = useRef<string | null>(null);
// Memoize derived arrays so downstream hook deps have stable identities.
const options = useMemo(
() =>
React.Children.toArray(children).filter((child) =>
React.isValidElement(child),
) as React.ReactElement<TailoredContentOptionProps>[],
[children],
);
const optionIds = useMemo(
() => options.map((option) => option.props.id),
[options],
);
// Warn (dev-mode friendly) when duplicate option ids would cause ambiguous
// URL <-> selection mapping. Runs only when ids change; warnedKeyRef guards
// against duplicate warns for the same set (e.g. StrictMode double-invoke).
useEffect(() => {
const seen = new Set<string>();
const duplicates: string[] = [];
for (const oid of optionIds) {
if (seen.has(oid) && !duplicates.includes(oid)) {
duplicates.push(oid);
}
seen.add(oid);
}
if (duplicates.length === 0) return;
const warnKey = duplicates.join(",");
if (warnedKeyRef.current === warnKey) return;
warnedKeyRef.current = warnKey;
// eslint-disable-next-line no-console
console.warn(
`TailoredContent(id=${id}): duplicate option id(s) detected: ${duplicates
.map((d) => `"${d}"`)
.join(", ")}. Option ids must be unique.`,
);
}, [optionIds, id]);
const updateSelection = useCallback(
(index: number) => {
if (index < 0 || index >= options.length) return;
const newParams = new URLSearchParams(searchParams.toString());
newParams.set(id, optionIds[index]);
// Update URL without reload; derived selectedIndex will follow.
router.replace(`?${newParams.toString()}`, { scroll: false });
},
[router, searchParams, id, optionIds, options.length],
);
// No hooks below this point — safe to short-circuit when there are no options.
if (options.length === 0) return null;
// Clamp defaultOptionIndex to the valid range.
const clampedDefault = Math.min(
Math.max(0, defaultOptionIndex),
options.length - 1,
);
// Derive selectedIndex from the URL on every render so state stays in sync
// with navigation (back/forward, external updates to the search param).
const urlParam = searchParams.get(id);
const indexFromUrl = urlParam ? optionIds.indexOf(urlParam) : -1;
const selectedIndex = indexFromUrl >= 0 ? indexFromUrl : clampedDefault;
const focusTab = (index: number) => {
const el = tabRefs.current[index];
if (el) el.focus();
};
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>, index: number) => {
switch (e.key) {
case "Enter":
case " ":
case "Spacebar":
e.preventDefault();
updateSelection(index);
return;
case "ArrowRight": {
e.preventDefault();
const next = (index + 1) % options.length;
updateSelection(next);
focusTab(next);
return;
}
case "ArrowLeft": {
e.preventDefault();
const prev = (index - 1 + options.length) % options.length;
updateSelection(prev);
focusTab(prev);
return;
}
case "Home": {
e.preventDefault();
updateSelection(0);
focusTab(0);
return;
}
case "End": {
e.preventDefault();
const last = options.length - 1;
updateSelection(last);
focusTab(last);
return;
}
default:
return;
}
};
const itemCn =
"border p-4 rounded-md flex-1 flex md:block md:space-y-1 items-center md:items-start gap-4 cursor-pointer bg-white dark:bg-secondary relative overflow-hidden group transition-all";
const selectedCn =
"shadow-lg ring-1 ring-indigo-400 selected bg-gradient-to-r from-indigo-100/80 to-purple-200 dark:from-indigo-900/20 dark:to-purple-900/30";
const iconCn =
"w-10 h-10 mb-4 top-0 transition-all opacity-20 group-[.selected]:text-indigo-500 group-[.selected]:opacity-60 dark:group-[.selected]:text-indigo-400 dark:group-[.selected]:opacity-60 dark:text-gray-400";
const tablistId = `tailored-content-tablist-${id}`;
const tabId = (optId: string) => `tailored-content-tab-${id}-${optId}`;
const panelId = (optId: string) => `tailored-content-panel-${id}-${optId}`;
const selectedOption = options[selectedIndex];
return (
<div>
<div className={cn("tailored-content-wrapper mt-4", className)}>
{header}
<div
id={tablistId}
role="tablist"
aria-orientation="horizontal"
className="flex flex-col md:flex-row gap-3 my-2 w-full"
>
{options.map((option, index) => {
const isSelected = selectedIndex === index;
return (
<div
key={option.props.id}
ref={(el) => {
tabRefs.current[index] = el;
}}
id={tabId(option.props.id)}
className={cn(itemCn, isSelected && selectedCn)}
onClick={() => updateSelection(index)}
onKeyDown={(e) => onKeyDown(e, index)}
role="tab"
aria-selected={isSelected}
aria-controls={panelId(option.props.id)}
tabIndex={isSelected ? 0 : -1}
>
<div className="my-0">
{React.isValidElement(option.props.icon) ? (
(() => {
const icon = option.props.icon as IconElement;
return React.cloneElement(icon, {
className: cn(icon.props?.className, iconCn, "my-0"),
});
})()
) : (
<span className={cn(iconCn, "my-0")} />
)}
</div>
<div>
<p className="font-semibold text-lg">{option.props.title}</p>
<p className="text-xs md:text-sm">{option.props.description}</p>
</div>
</div>
);
})}
</div>
</div>
{selectedOption && (
<div
role="tabpanel"
id={panelId(selectedOption.props.id)}
aria-labelledby={tabId(selectedOption.props.id)}
>
{selectedOption.props.children}
</div>
)}
</div>
);
}
/**
* `TailoredContent` renders a set of tab-like options and the currently
* selected option's content. The selection is persisted in the URL via
* `?<id>=<optionId>` so links are shareable.
*
* Next.js App Router requires `useSearchParams()` to be wrapped in a
* `<Suspense>` boundary. The exported component wraps the inner
* implementation so consumers don't need to do that themselves.
*/
export function TailoredContent(props: TailoredContentProps) {
return (
<Suspense fallback={null}>
<TailoredContentInner {...props} />
</Suspense>
);
}