Skip to content
Open
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
55 changes: 50 additions & 5 deletions packages/eclipse/src/components/accordion.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
"use client";

import { Check, Link as LinkIcon } from "lucide-react";
import { ComponentProps, type ReactNode, useCallback, useEffect, useRef, useState } from "react";
import {
ComponentProps,
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { cn } from "../lib/cn";
import { buttonVariants } from "./ui/button";
import { mergeRefs } from "../lib/merge-refs";
Expand All @@ -13,6 +23,12 @@ import {
AccordionTrigger,
} from "./ui/accordion";

// Which items are open, so a closed panel can be marked inert. Its content stays
// mounted for crawlers (see AccordionContent), and inert keeps any links or
// buttons inside it out of the tab order and the accessibility tree while the
// panel is collapsed.
const OpenValuesContext = createContext<readonly string[]>([]);

function useCopyButton(copy: () => void | Promise<void>, timeout = 2000) {
const [checked, setChecked] = useState(false);

Expand All @@ -32,6 +48,8 @@ export function Accordions({
ref,
className,
defaultValue,
value: controlledValue,
onValueChange,
...props
}: ComponentProps<typeof Root>) {
const rootRef = useRef<HTMLDivElement>(null);
Expand All @@ -52,13 +70,36 @@ export function Accordions({
if (value) setValue((prev) => (typeof prev === "string" ? value : [value, ...prev]));
}, []);

return (
// A caller may drive this controlled. Its value has to win for both the root
// and the inert context, or a panel can render open while its content is
// still inert.
const effectiveValue = controlledValue ?? value;

const handleValueChange = useCallback(
(next: string | string[]) => {
setValue(next);
(onValueChange as ((next: string | string[]) => void) | undefined)?.(next);
},
[onValueChange],
);

const openValues = useMemo(
() =>
typeof effectiveValue === "string"
? effectiveValue
? [effectiveValue]
: []
: effectiveValue,
[effectiveValue],
);

const root = (
// @ts-expect-error -- Multiple types
<Root
type={type}
ref={composedRef}
value={value}
onValueChange={setValue}
value={effectiveValue}
onValueChange={handleValueChange}
collapsible={type === "single" ? true : undefined}
className={cn(
"divide-y divide-fd-border overflow-hidden rounded-square border bg-background-default",
Expand All @@ -67,6 +108,8 @@ export function Accordions({
{...props}
/>
);

return <OpenValuesContext.Provider value={openValues}>{root}</OpenValuesContext.Provider>;
}

export function Accordion({
Expand All @@ -79,13 +122,15 @@ export function Accordion({
title: string | ReactNode;
value?: string;
}) {
const isOpen = useContext(OpenValuesContext).includes(value);

return (
<AccordionItem value={value} {...props}>
<AccordionHeader id={id} data-accordion-value={value}>
<AccordionTrigger>{title}</AccordionTrigger>
{id ? <CopyButton id={id} /> : null}
</AccordionHeader>
<AccordionContent>
<AccordionContent inert={!isOpen}>
<div className="ps-9 pr-4 pb-2 text-[0.9375rem] prose-no-margin">{children}</div>
</AccordionContent>
</AccordionItem>
Expand Down
33 changes: 30 additions & 3 deletions packages/eclipse/src/components/ui/accordion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import * as Primitive from "@radix-ui/react-accordion";
import { ChevronRight } from "lucide-react";
import { type ComponentProps } from "react";
import { type ComponentProps, type CSSProperties, useLayoutEffect, useRef, useState } from "react";
import { cn } from "../../lib/cn";

export function Accordion({ className, ...props }: ComponentProps<typeof Primitive.Root>) {
Expand Down Expand Up @@ -69,17 +69,44 @@ export function AccordionTrigger({
export function AccordionContent({
className,
children,
style,
...props
}: ComponentProps<typeof Primitive.Content>) {
// Radix sizes the open and close animations from a measurement of this node
// taken in a layout effect. With forceMount that measurement happens while
// the panel is closed and collapsed to h-0, so it reads 0 and the open
// animation snaps instead of sliding. Measure the inner wrapper instead: it
// keeps its natural height however the panel is clipped. Before hydration the
// variable is 0px, so a closed panel starts collapsed rather than animating
// from its full height on load.
const inner = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState(0);

useLayoutEffect(() => {
const node = inner.current;
if (!node) return;
const observer = new ResizeObserver(() => setHeight(node.getBoundingClientRect().height));
observer.observe(node);
return () => observer.disconnect();
}, []);

return (
<Primitive.Content
// Keep the panel mounted so its text is present in the server-rendered
// HTML. Radix unmounts closed content by default, which leaves FAQ
// answers out of the markup entirely: crawlers that do not execute
// JavaScript see the questions and none of the answers. A closed panel
// collapses to height 0 and stays clipped by overflow-hidden, so this
// changes what is in the DOM, not what a reader sees.
forceMount
Comment thread
coderabbitai[bot] marked this conversation as resolved.
className={cn(
"overflow-hidden data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down text-foreground-neutral-weak",
"overflow-hidden data-[state=closed]:h-0 data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down text-foreground-neutral-weak",
className,
)}
style={{ "--radix-accordion-content-height": `${height}px`, ...style } as CSSProperties}
{...props}
>
{children}
<div ref={inner}>{children}</div>
</Primitive.Content>
);
}
62 changes: 62 additions & 0 deletions packages/eclipse/test/accordion.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import assert from "node:assert/strict";
import test from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { Accordion, Accordions } from "../src/components/accordion";
import { textContent } from "./html-text";

// FAQ blocks are the most quotable part of a post, and most AI crawlers do not
// run JavaScript. The answers have to be in the server-rendered HTML, not only
// in the RSC payload, which means a closed panel stays mounted.
const QA: Array<[question: string, answer: string]> = [
["What is Prisma?", "Prisma is an ORM for TypeScript."],
["Is it type safe?", "Yes, end to end."],
];

function render(props: { defaultValue?: string } = {}) {
return renderToStaticMarkup(
<Accordions type="single" {...props}>
{QA.map(([question, answer]) => (
<Accordion key={question} title={question}>
{answer}
</Accordion>
))}
</Accordions>,
);
}

function panelTags(html: string): string[] {
return [...html.matchAll(/<div[^>]*role="region"[^>]*>/g)].map((m) => m[0]);
}

test("closed panels ship their answers in the server-rendered markup", () => {
const html = render();
const text = textContent(html);
for (const [question, answer] of QA) {
assert.ok(text.includes(question), `question missing from markup: ${question}`);
assert.ok(text.includes(answer), `answer missing from markup: ${answer}`);
}

const panels = panelTags(html);
assert.equal(panels.length, QA.length);
for (const panel of panels) {
assert.match(panel, /data-state="closed"/);
assert.doesNotMatch(panel, /\shidden=""/, "a closed panel must not be display:none");
}
});

test("a closed panel is inert and an open one is not", () => {
const [open, closed] = panelTags(render({ defaultValue: QA[0][0] }));
assert.match(open, /data-state="open"/);
assert.doesNotMatch(open, /\sinert=""/);
assert.match(closed, /data-state="closed"/);
assert.match(closed, /\sinert=""/, "links inside a closed panel must stay out of the tab order");
});

test("a closed panel starts collapsed before hydration", () => {
// The accordion-up keyframe animates from --radix-accordion-content-height to
// 0 and falls back to `auto` when the variable is unset, which would play a
// full-height-to-zero collapse on every closed panel as the page loads.
for (const panel of panelTags(render())) {
assert.match(panel, /--radix-accordion-content-height:0px/);
}
});
Loading