feat(web): animate every expand/collapse with one collapsible

Add a Collapsible primitive to @reactive-resume/ui wrapping Base UI's
collapsible, styled like the accordion panel: height grows over 200ms and
folds over 140ms on ease-enter, reversing mid-animation.

Use it for entry cards, section rows, Basics, Check categories,
Design > Advanced, More options, AI provider rows and ATS checker findings,
so content no longer teleports while the chevron rotates. Chevrons at those
sites now share duration-standard ease-enter.

Opening an entry scrolls it back into view once the previous one has folded
away if it slid above the panel, and the page-to-panel reveal re-aims its
scroll after the panels settle. Advanced's open state moves up to the
Design panel so its nav pill can open it; the e2e fixture drives the new
trigger via aria-expanded.
This commit is contained in:
Amruth Pillai
2026-09-29 12:53:27 +02:00
parent d1aabaecb3
commit 7827ff11bf
11 changed files with 186 additions and 96 deletions
@@ -5,6 +5,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { useId, useState } from "react";
import { Button, buttonVariants } from "@reactive-resume/ui/components/button";
import { Collapsible, CollapsibleContent } from "@reactive-resume/ui/components/collapsible";
import { Icon } from "@reactive-resume/ui/components/icon";
import { Input } from "@reactive-resume/ui/components/input";
import { Label } from "@reactive-resume/ui/components/label";
@@ -54,10 +55,17 @@ export function ProviderSetup() {
<Icon
name="chevron_right"
size={18}
className={cn("text-ink-3 transition-transform duration-quick", expanded && "rotate-90")}
className={cn(
"text-ink-3 transition-transform duration-standard ease-enter",
expanded && "rotate-90",
)}
/>
</button>
{expanded && <ConnectForm choice={choice} />}
<Collapsible open={expanded}>
<CollapsibleContent>
<ConnectForm choice={choice} />
</CollapsibleContent>
</Collapsible>
</li>
);
})}
+10 -4
View File
@@ -7,6 +7,7 @@ import { Plural, Trans } from "@lingui/react/macro";
import { useNavigate } from "@tanstack/react-router";
import { useEffect, useId, useRef, useState } from "react";
import { Button } from "@reactive-resume/ui/components/button";
import { Collapsible, CollapsibleContent } from "@reactive-resume/ui/components/collapsible";
import { Icon } from "@reactive-resume/ui/components/icon";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { Tabs, TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs";
@@ -390,12 +391,17 @@ function Result({ result, file, onFix, onReset }: ResultProps) {
<Icon
name="expand_more"
size={20}
className={cn("ms-auto text-ink-3 transition-transform duration-quick", expanded && "rotate-180")}
className={cn(
"ms-auto text-ink-3 transition-transform duration-standard ease-enter",
expanded && "rotate-180",
)}
/>
</button>
{expanded && (
<div className="px-3.5 ps-11 pb-3.5 text-[13px] text-ink-2 leading-[19px]">{row.body}</div>
)}
<Collapsible open={expanded}>
<CollapsibleContent>
<div className="px-3.5 ps-11 pb-3.5 text-[13px] text-ink-2 leading-[19px]">{row.body}</div>
</CollapsibleContent>
</Collapsible>
</div>
);
})}
@@ -9,6 +9,7 @@ import { Plural, Trans } from "@lingui/react/macro";
import { useState } from "react";
import { ATS_CATEGORIES } from "@reactive-resume/resume/ats";
import { Button } from "@reactive-resume/ui/components/button";
import { Collapsible, CollapsibleContent } from "@reactive-resume/ui/components/collapsible";
import {
Dialog,
DialogContent,
@@ -303,16 +304,19 @@ function CategoryRows({ report }: { report: AtsReport }) {
</span>
<Icon
name="expand_more"
className={cn("ms-auto text-ink-3 transition-transform duration-standard", open && "rotate-180")}
className={cn(
"ms-auto text-ink-3 transition-transform duration-standard ease-enter",
open && "rotate-180",
)}
/>
</button>
<p
id={descriptionId}
hidden={!open}
className="px-3 ps-[42px] pb-3 text-[13px] text-ink-2 leading-[19px]"
>
{getCategoryDescription(category)}
</p>
<Collapsible open={open}>
<CollapsibleContent id={descriptionId} keepMounted>
<p className="px-3 ps-[42px] pb-3 text-[13px] text-ink-2 leading-[19px]">
{getCategoryDescription(category)}
</p>
</CollapsibleContent>
</Collapsible>
</div>
);
})}
@@ -2,6 +2,7 @@ import type { Basics, CustomField } from "@reactive-resume/schema/resume/data";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { Button } from "@reactive-resume/ui/components/button";
import { Collapsible, CollapsibleContent } from "@reactive-resume/ui/components/collapsible";
import { Icon } from "@reactive-resume/ui/components/icon";
import { IconButton } from "@reactive-resume/ui/components/icon-button";
import { Input } from "@reactive-resume/ui/components/input";
@@ -72,47 +73,49 @@ export function BasicsCard({ locked }: { locked: boolean }) {
</span>
<Icon
name="expand_more"
className={cn("text-ink-2 transition-transform duration-standard", open && "rotate-180")}
className={cn("text-ink-2 transition-transform duration-standard ease-enter", open && "rotate-180")}
/>
</button>
{open && (
<fieldset
disabled={locked}
className="m-0 grid min-w-0 grid-cols-2 gap-x-3 gap-y-2.5 border-0 border-line border-t px-3 pt-3 pb-3.5"
>
<PhotoRow locked={locked} />
<TextField
label={<Trans>Full name</Trans>}
wide
autoComplete="name"
// A blank resume opens straight into the name field.
autoFocus={!locked && !basics.name}
{...text("name")}
/>
<TextField label={<Trans>Headline</Trans>} wide {...text("headline")} />
<TextField
label={<Trans>Email</Trans>}
type="email"
autoComplete="email"
validate={validateEmail}
{...text("email")}
/>
<TextField label={<Trans>Phone</Trans>} type="tel" autoComplete="tel" {...text("phone")} />
<TextField label={<Trans>Location</Trans>} wide {...text("location")} />
<WebsiteField
label={<Trans>Website</Trans>}
value={basics.website}
allowInlineLink={false}
onChange={(website) =>
write("website", (target) => {
target.website = { url: website.url, label: website.label };
})
}
/>
<CustomFields fields={basics.customFields} />
</fieldset>
)}
<Collapsible open={open}>
<CollapsibleContent>
<fieldset
disabled={locked}
className="m-0 grid min-w-0 grid-cols-2 gap-x-3 gap-y-2.5 border-0 border-line border-t px-3 pt-3 pb-3.5"
>
<PhotoRow locked={locked} />
<TextField
label={<Trans>Full name</Trans>}
wide
autoComplete="name"
// A blank resume opens straight into the name field.
autoFocus={!locked && !basics.name}
{...text("name")}
/>
<TextField label={<Trans>Headline</Trans>} wide {...text("headline")} />
<TextField
label={<Trans>Email</Trans>}
type="email"
autoComplete="email"
validate={validateEmail}
{...text("email")}
/>
<TextField label={<Trans>Phone</Trans>} type="tel" autoComplete="tel" {...text("phone")} />
<TextField label={<Trans>Location</Trans>} wide {...text("location")} />
<WebsiteField
label={<Trans>Website</Trans>}
value={basics.website}
allowInlineLink={false}
onChange={(website) =>
write("website", (target) => {
target.website = { url: website.url, label: website.label };
})
}
/>
<CustomFields fields={basics.customFields} />
</fieldset>
</CollapsibleContent>
</Collapsible>
</section>
);
}
@@ -9,6 +9,7 @@ import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import { useEffect, useMemo } from "react";
import { Badge } from "@reactive-resume/ui/components/badge";
import { Collapsible, CollapsibleContent } from "@reactive-resume/ui/components/collapsible";
import {
DropdownMenu,
DropdownMenuContent,
@@ -31,7 +32,7 @@ import { useEditorStore } from "../store";
import { EntryFields } from "./entries";
import { useEntry, useEntryWriter } from "./fields";
import { createEntry, describeEntry, getEntries, getPrimaryField, isDraftEntry } from "./model";
import { entryElementId } from "./reveal";
import { entryElementId, keepOpenedEntryInView } from "./reveal";
import { useSectionTitle } from "./section-row";
const DRAFT_HINTS: Record<string, MessageDescriptor> = {
@@ -81,7 +82,10 @@ export function EntryCard({ section, entryId, index, count, page, locked, onMove
const primaryField = getPrimaryField(section.type);
const hint = draft && primaryField ? DRAFT_HINTS[primaryField] : undefined;
const toggle = () => select(open ? null : { kind: "item", sectionId: section.id, itemId: entryId });
const toggle = () => {
select(open ? null : { kind: "item", sectionId: section.id, itemId: entryId });
if (!open) keepOpenedEntryInView(entryId);
};
const fields = (
<>
{hint && <p className="col-span-full text-ink-3 text-xs">{i18n._(hint)}</p>}
@@ -160,13 +164,17 @@ export function EntryCard({ section, entryId, index, count, page, locked, onMove
{!locked && <EntryMenu section={section} entry={entry} />}
</div>
{open && !isPhone && (
<fieldset
disabled={locked}
className="m-0 grid min-w-0 grid-cols-2 gap-x-3 gap-y-2.5 border-0 border-line border-t px-3 pt-3 pb-3.5"
>
{fields}
</fieldset>
{!isPhone && (
<Collapsible open={open}>
<CollapsibleContent>
<fieldset
disabled={locked}
className="m-0 grid min-w-0 grid-cols-2 gap-x-3 gap-y-2.5 border-0 border-line border-t px-3 pt-3 pb-3.5"
>
{fields}
</fieldset>
</CollapsibleContent>
</Collapsible>
)}
{open && isPhone && (
@@ -5,6 +5,7 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useCallback, useState } from "react";
import { Checkbox } from "@reactive-resume/ui/components/checkbox";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@reactive-resume/ui/components/collapsible";
import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Input } from "@reactive-resume/ui/components/input";
import { cn } from "@reactive-resume/utils/style";
@@ -155,14 +156,19 @@ type MoreOptionsProps = { children: ReactNode };
/** Secondary fields (level, icon, colour) stay out of the way until asked for. */
export function MoreOptions({ children }: MoreOptionsProps) {
return (
<details className="group/more col-span-full">
<summary className="flex w-fit cursor-pointer list-none items-center gap-1 rounded-sm font-medium text-[13px] text-ink-2 hover:text-ink [&::-webkit-details-marker]:hidden">
<span aria-hidden="true" className="transition-transform duration-quick group-open/more:rotate-90">
<Collapsible className="col-span-full">
<CollapsibleTrigger className="group/more flex w-fit cursor-pointer items-center gap-1 rounded-sm font-medium text-[13px] text-ink-2 transition-colors duration-quick hover:text-ink">
<span
aria-hidden="true"
className="transition-transform duration-standard ease-enter group-data-panel-open/more:rotate-90"
>
›
</span>
<Trans>More options</Trans>
</summary>
<div className="mt-3 grid grid-cols-2 gap-x-3 gap-y-2.5">{children}</div>
</details>
</CollapsibleTrigger>
<CollapsibleContent hiddenUntilFound>
<div className="grid grid-cols-2 gap-x-3 gap-y-2.5 pt-3">{children}</div>
</CollapsibleContent>
</Collapsible>
);
}
@@ -1,4 +1,5 @@
import type { EditorSelection } from "../store";
import { D2 } from "@/libs/motion";
import { useEditorStore } from "../store";
/** The id of an entry's card in the Write panel, which the page and Check scroll to. */
@@ -26,7 +27,8 @@ export function selectionFromPanelElement(element: Element): EditorSelection | n
/**
* Page → panel: opens what was picked (the Basics card for the header; otherwise its section, with Basics
* collapsed) and scrolls it 60px from the top of the panel, instantly with reduced motion.
* collapsed) and scrolls it 60px from the top of the panel (re-aimed once the panels have finished opening and
* closing), instantly with reduced motion.
*/
export function revealSelectionInPanel(selection: EditorSelection) {
const editor = useEditorStore.getState();
@@ -37,11 +39,30 @@ export function revealSelectionInPanel(selection: EditorSelection) {
editor.setSectionOpen(selection.sectionId, true);
}
requestAnimationFrame(() => {
const scroll = () => {
const section = document.getElementById(
selection.kind === "header" ? "sidebar-basics" : `sidebar-${selection.sectionId}`,
);
const entry = selection.kind === "item" ? document.getElementById(entryElementId(selection.itemId)) : null;
(entry ?? section)?.scrollIntoView({ block: "start", behavior: getScrollBehavior() });
});
};
requestAnimationFrame(scroll);
// Opening the target and folding Basics or another entry away animate for D2, moving the target while the first
// scroll is under way; aim again once they've settled (a smooth scroll retargets from where it is).
window.setTimeout(scroll, D2 * 1000);
}
/**
* Opening an entry closes the one that was open. If that one sat above, this card slides up while it folds away;
* once both have settled, bring the card back to 60px from the panel's top if it slid out of view.
*/
export function keepOpenedEntryInView(entryId: string) {
window.setTimeout(() => {
const card = document.getElementById(entryElementId(entryId));
const panel = card?.closest('[data-slot="tabs-content"]');
if (!card || !panel) return;
if (card.getBoundingClientRect().top < panel.getBoundingClientRect().top) {
card.scrollIntoView({ block: "start", behavior: getScrollBehavior() });
}
}, D2 * 1000);
}
@@ -8,6 +8,7 @@ import { Plural, Trans } from "@lingui/react/macro";
import { useState } from "react";
import { sortSectionItemsByPeriod } from "@reactive-resume/resume/section-sort";
import { Badge } from "@reactive-resume/ui/components/badge";
import { Collapsible, CollapsibleContent } from "@reactive-resume/ui/components/collapsible";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
@@ -148,13 +149,17 @@ export function SectionRow({ section, locked, onMove, children }: SectionRowProp
icon="expand_more"
label={open ? t`Close ${title}` : t`Open ${title}`}
size="icon-sm"
className={cn("text-ink-2 transition-transform duration-standard", open && "rotate-180")}
className={cn("text-ink-2 transition-transform duration-standard ease-enter", open && "rotate-180")}
onClick={() => setOpen(section.id, !open)}
/>
{!locked && <SectionMenu section={section} title={title} onMove={onMove} />}
</div>
{open && <div className="grid gap-2 ps-[26px] pt-1 pb-3">{children}</div>}
<Collapsible open={open}>
<CollapsibleContent>
<div className="grid gap-2 ps-[26px] pt-1 pb-3">{children}</div>
</CollapsibleContent>
</Collapsible>
</div>
);
}
@@ -5,6 +5,7 @@ import { Trans } from "@lingui/react/macro";
import { useState } from "react";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { Button } from "@reactive-resume/ui/components/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@reactive-resume/ui/components/collapsible";
import { Icon } from "@reactive-resume/ui/components/icon";
import { NativeSelect } from "@reactive-resume/ui/components/native-select";
import { Tabs, TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs";
@@ -15,6 +16,7 @@ import { ColorGroup, PageGroup, TypeGroup } from "@/features/resume/editor/desig
import { TemplateGroup } from "@/features/resume/editor/design/template-group";
import { OfflineBanner } from "@/features/resume/editor/save-status";
import { getScrollBehavior } from "@/features/resume/editor/write/reveal";
import { D2 } from "@/libs/motion";
import { getSectionTitle } from "@/libs/resume/section";
import { CustomStylesSectionBuilder } from "../-sidebar/right/sections/custom-styles";
import { DesignSectionBuilder } from "../-sidebar/right/sections/design";
@@ -47,6 +49,7 @@ function Group({ id, title, children }: { id: string; title: ReactNode; children
*/
export function DesignPanel() {
const locked = useIsResumeLocked();
const [advancedOpen, setAdvancedOpen] = useState(false);
return (
<div>
@@ -61,12 +64,11 @@ export function DesignPanel() {
onClick={() => {
const target = document.getElementById(`design-${group.id}`);
const scroll = () => target?.scrollIntoView({ behavior: getScrollBehavior() });
// Advanced is collapsed until asked for: jumping to it opens it, then scrolls once its content
// has rendered (before that the panel may be too short to bring it to the top).
if (target instanceof HTMLDetailsElement && !target.open) {
target.addEventListener("toggle", () => requestAnimationFrame(scroll), { once: true });
target.open = true;
return;
// Advanced is collapsed until asked for: jumping to it opens it, then aims again once it has grown
// (before that the panel may be too short to bring it to the top).
if (group.id === "advanced" && !advancedOpen) {
setAdvancedOpen(true);
window.setTimeout(scroll, D2 * 1000);
}
scroll();
}}
@@ -90,7 +92,7 @@ export function DesignPanel() {
<Group id="page" title={<Trans>Page</Trans>}>
<PageGroup />
</Group>
<AdvancedGroup />
<AdvancedGroup open={advancedOpen} onOpenChange={setAdvancedOpen} />
</fieldset>
</div>
);
@@ -170,10 +172,12 @@ const EXACT_SECTIONS = [
] as const;
/** Every exact value, the date format, custom CSS and Reset to template defaults, collapsed until asked for. */
function AdvancedGroup() {
// The phone sheet leaves it uncontrolled; the desktop panel controls it so its nav can open it.
type AdvancedGroupProps = { open?: boolean; onOpenChange?: (open: boolean) => void };
function AdvancedGroup({ open, onOpenChange }: AdvancedGroupProps) {
const data = useResumeData();
const updateResumeData = useUpdateResumeData();
const [open, setOpen] = useState(false);
const reset = () => {
updateResumeData(
@@ -203,22 +207,17 @@ function AdvancedGroup() {
};
return (
<details
id="design-advanced"
open={open}
onToggle={(event) => setOpen(event.currentTarget.open)}
className="group/advanced scroll-mt-14 px-4 py-5"
>
<summary className="flex cursor-pointer list-none items-center justify-between font-semibold text-[15px] [&::-webkit-details-marker]:hidden">
<Collapsible id="design-advanced" open={open} onOpenChange={onOpenChange} className="scroll-mt-14 px-4 py-5">
<CollapsibleTrigger className="group/advanced flex w-full cursor-pointer items-center justify-between text-start font-semibold text-[15px]">
<Trans>Advanced</Trans>
<Icon
name="expand_more"
className="text-ink-2 transition-transform duration-standard group-open/advanced:rotate-180"
className="text-ink-2 transition-transform duration-standard ease-enter group-data-panel-open/advanced:rotate-180"
/>
</summary>
</CollapsibleTrigger>
{open && (
<div className="@container mt-4 grid gap-6">
<CollapsibleContent>
<div className="@container grid gap-6 pt-4">
<div className="grid gap-1.5">
<label htmlFor="design-date-format" className="font-medium text-[13px]">
<Trans>Date format</Trans>
@@ -257,7 +256,7 @@ function AdvancedGroup() {
<Trans>Reset to template defaults</Trans>
</Button>
</div>
)}
</details>
</CollapsibleContent>
</Collapsible>
);
}
@@ -0,0 +1,30 @@
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible";
import { cn } from "@reactive-resume/utils/style";
function Collapsible(props: CollapsiblePrimitive.Root.Props) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
}
function CollapsibleTrigger(props: CollapsiblePrimitive.Trigger.Props) {
return <CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />;
}
/**
* Grows from nothing to its content's height (200ms) and folds away faster (140ms), like `AccordionContent`.
* `overflow-clip`, not hidden: a field that autofocuses while the panel is still opening can't scroll it.
* Keep padding on the children; the panel itself animates from 0.
*/
function CollapsibleContent({ className, ...props }: CollapsiblePrimitive.Panel.Props) {
return (
<CollapsiblePrimitive.Panel
data-slot="collapsible-content"
className={cn(
"h-(--collapsible-panel-height) overflow-clip transition-[height] duration-standard ease-enter data-ending-style:h-0 data-starting-style:h-0 data-ending-style:duration-[calc(var(--d2)*0.7)]",
className,
)}
{...props}
/>
);
}
export { Collapsible, CollapsibleContent, CollapsibleTrigger };
+2 -2
View File
@@ -39,8 +39,8 @@ export async function openSidebarSection(page: Page, title: string) {
await page.getByRole("tab", { name: "Design", exact: true }).click();
const panel = page.getByRole("tabpanel", { name: "Design" });
if (advancedSections.has(title)) {
const advanced = panel.locator("#design-advanced");
if ((await advanced.getAttribute("open")) === null) await advanced.locator("summary").click();
const advanced = panel.locator("#design-advanced").getByRole("button", { name: "Advanced", exact: true });
if ((await advanced.getAttribute("aria-expanded")) !== "true") await advanced.click();
}
// Groups are level 2; the exact-value sections inside Advanced are level 3 (both have a "Page").
const level = advancedSections.has(title) ? 3 : 2;