diff --git a/apps/web/src/features/assistant/provider-setup.tsx b/apps/web/src/features/assistant/provider-setup.tsx index 9bbc548b0..7b6168b61 100644 --- a/apps/web/src/features/assistant/provider-setup.tsx +++ b/apps/web/src/features/assistant/provider-setup.tsx @@ -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() { - {expanded && } + + + + + ); })} diff --git a/apps/web/src/features/ats-checker/checker.tsx b/apps/web/src/features/ats-checker/checker.tsx index 956421d40..9a6a2e208 100644 --- a/apps/web/src/features/ats-checker/checker.tsx +++ b/apps/web/src/features/ats-checker/checker.tsx @@ -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) { - {expanded && ( -
{row.body}
- )} + + +
{row.body}
+
+
); })} diff --git a/apps/web/src/features/resume/editor/check/check-panel.tsx b/apps/web/src/features/resume/editor/check/check-panel.tsx index bdfd2702c..49a7a3750 100644 --- a/apps/web/src/features/resume/editor/check/check-panel.tsx +++ b/apps/web/src/features/resume/editor/check/check-panel.tsx @@ -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 }) { - + + +

+ {getCategoryDescription(category)} +

+
+
); })} diff --git a/apps/web/src/features/resume/editor/write/basics-card.tsx b/apps/web/src/features/resume/editor/write/basics-card.tsx index d16083f72..cc6eb8314 100644 --- a/apps/web/src/features/resume/editor/write/basics-card.tsx +++ b/apps/web/src/features/resume/editor/write/basics-card.tsx @@ -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 }) { - {open && ( -
- - Full name} - wide - autoComplete="name" - // A blank resume opens straight into the name field. - autoFocus={!locked && !basics.name} - {...text("name")} - /> - Headline} wide {...text("headline")} /> - Email} - type="email" - autoComplete="email" - validate={validateEmail} - {...text("email")} - /> - Phone} type="tel" autoComplete="tel" {...text("phone")} /> - Location} wide {...text("location")} /> - Website} - value={basics.website} - allowInlineLink={false} - onChange={(website) => - write("website", (target) => { - target.website = { url: website.url, label: website.label }; - }) - } - /> - -
- )} + + +
+ + Full name} + wide + autoComplete="name" + // A blank resume opens straight into the name field. + autoFocus={!locked && !basics.name} + {...text("name")} + /> + Headline} wide {...text("headline")} /> + Email} + type="email" + autoComplete="email" + validate={validateEmail} + {...text("email")} + /> + Phone} type="tel" autoComplete="tel" {...text("phone")} /> + Location} wide {...text("location")} /> + Website} + value={basics.website} + allowInlineLink={false} + onChange={(website) => + write("website", (target) => { + target.website = { url: website.url, label: website.label }; + }) + } + /> + +
+
+
); } diff --git a/apps/web/src/features/resume/editor/write/entry-card.tsx b/apps/web/src/features/resume/editor/write/entry-card.tsx index 2001767f4..5a531c45d 100644 --- a/apps/web/src/features/resume/editor/write/entry-card.tsx +++ b/apps/web/src/features/resume/editor/write/entry-card.tsx @@ -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 = { @@ -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 &&

{i18n._(hint)}

} @@ -160,13 +164,17 @@ export function EntryCard({ section, entryId, index, count, page, locked, onMove {!locked && } - {open && !isPhone && ( -
- {fields} -
+ {!isPhone && ( + + +
+ {fields} +
+
+
)} {open && isPhone && ( diff --git a/apps/web/src/features/resume/editor/write/fields.tsx b/apps/web/src/features/resume/editor/write/fields.tsx index ae9a1db6f..2155643cb 100644 --- a/apps/web/src/features/resume/editor/write/fields.tsx +++ b/apps/web/src/features/resume/editor/write/fields.tsx @@ -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 ( -
- - -
{children}
-
+ + +
{children}
+
+ ); } diff --git a/apps/web/src/features/resume/editor/write/reveal.ts b/apps/web/src/features/resume/editor/write/reveal.ts index 620e84e2b..2a37bfd72 100644 --- a/apps/web/src/features/resume/editor/write/reveal.ts +++ b/apps/web/src/features/resume/editor/write/reveal.ts @@ -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); } diff --git a/apps/web/src/features/resume/editor/write/section-row.tsx b/apps/web/src/features/resume/editor/write/section-row.tsx index 53840044a..4ccb73436 100644 --- a/apps/web/src/features/resume/editor/write/section-row.tsx +++ b/apps/web/src/features/resume/editor/write/section-row.tsx @@ -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 && } - {open &&
{children}
} + + +
{children}
+
+
); } diff --git a/apps/web/src/routes/builder/$resumeId/-components/design-panel.tsx b/apps/web/src/routes/builder/$resumeId/-components/design-panel.tsx index 7ca54c442..2b09f276b 100644 --- a/apps/web/src/routes/builder/$resumeId/-components/design-panel.tsx +++ b/apps/web/src/routes/builder/$resumeId/-components/design-panel.tsx @@ -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 (
@@ -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() { Page}> - +
); @@ -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 ( -
setOpen(event.currentTarget.open)} - className="group/advanced scroll-mt-14 px-4 py-5" - > - + + Advanced - + - {open && ( -
+ +
- )} -
+ + ); } diff --git a/packages/ui/src/components/collapsible.tsx b/packages/ui/src/components/collapsible.tsx new file mode 100644 index 000000000..e4b780c4a --- /dev/null +++ b/packages/ui/src/components/collapsible.tsx @@ -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 ; +} + +function CollapsibleTrigger(props: CollapsiblePrimitive.Trigger.Props) { + return ; +} + +/** + * 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 ( + + ); +} + +export { Collapsible, CollapsibleContent, CollapsibleTrigger }; diff --git a/tests/e2e/fixtures/resume.ts b/tests/e2e/fixtures/resume.ts index b3fea7100..71efcf8ae 100644 --- a/tests/e2e/fixtures/resume.ts +++ b/tests/e2e/fixtures/resume.ts @@ -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;