mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-03 18:23:47 +10:00
feat(web): give phone surfaces their push, rise and swipe
The phone entry screen pushes in from the end edge and slides back out on Back, reversing mid-push. The Design sheet keeps its full height and moves by translate instead of animating height, and rises from the tab bar when Design opens. The selection bar rises and fades in. Sheet is now backed by Base UI Drawer: bottom sheets follow a downward swipe, dismiss on a flick or a drag past half their height, and settle back otherwise. Side and top sheets are unchanged. On phones, Settings rows push the section in from the end and the back link returns the list from the start, layered on the page view transition.
This commit is contained in:
@@ -2,11 +2,13 @@ import type { MessageDescriptor } from "@lingui/core";
|
||||
import type { KeyboardEvent, ReactNode } from "react";
|
||||
import type { PageSettings } from "./entries";
|
||||
import type { Entry, WriteSection } from "./model";
|
||||
import { useDirection } from "@base-ui/react/direction-provider";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { msg, t } from "@lingui/core/macro";
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, m } from "motion/react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Badge } from "@reactive-resume/ui/components/badge";
|
||||
import { Collapsible, CollapsibleContent } from "@reactive-resume/ui/components/collapsible";
|
||||
@@ -26,7 +28,7 @@ import { toast } from "@reactive-resume/ui/components/toast";
|
||||
import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useResumeStore, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { DRAG_SETTLE } from "@/libs/motion";
|
||||
import { D3, DRAG_SETTLE, EASE, EXIT } from "@/libs/motion";
|
||||
import { getCompatibleMoveTargets, getSourceSectionTitle, moveItem } from "@/libs/resume/move-item";
|
||||
import { useEditorStore } from "../store";
|
||||
import { EntryFields } from "./entries";
|
||||
@@ -177,17 +179,20 @@ export function EntryCard({ section, entryId, index, count, page, locked, onMove
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{open && isPhone && (
|
||||
<PhoneEntryScreen
|
||||
section={section}
|
||||
title={title}
|
||||
locked={locked}
|
||||
onDelete={() => deleteEntry(section, entryId)}
|
||||
onBack={() => select(null)}
|
||||
>
|
||||
{fields}
|
||||
</PhoneEntryScreen>
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{open && isPhone && (
|
||||
<PhoneEntryScreen
|
||||
key="entry"
|
||||
section={section}
|
||||
title={title}
|
||||
locked={locked}
|
||||
onDelete={() => deleteEntry(section, entryId)}
|
||||
onBack={() => select(null)}
|
||||
>
|
||||
{fields}
|
||||
</PhoneEntryScreen>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -204,12 +209,17 @@ type PhoneEntryScreenProps = {
|
||||
/** Phones: an open entry pushes in full screen, with a back label naming its section and 44px fields. */
|
||||
function PhoneEntryScreen({ section, title, locked, onBack, onDelete, children }: PhoneEntryScreenProps) {
|
||||
const sectionTitle = useSectionTitle(section);
|
||||
// Off-screen at the end edge: the screen pushes in from there and goes back there.
|
||||
const offscreen = useDirection() === "rtl" ? "translateX(-100%)" : "translateX(100%)";
|
||||
|
||||
return (
|
||||
<div
|
||||
<m.div
|
||||
role="dialog"
|
||||
aria-label={title || t`Untitled`}
|
||||
className="fixed inset-0 z-40 flex flex-col bg-surface pb-[env(safe-area-inset-bottom)]"
|
||||
initial={{ transform: offscreen }}
|
||||
animate={{ transform: "translateX(0%)", transition: { duration: D3, ease: EASE } }}
|
||||
exit={{ transform: offscreen, transition: { duration: D3 * EXIT, ease: EASE } }}
|
||||
>
|
||||
<div className="flex h-14 shrink-0 items-center gap-1 border-line border-b px-1.5">
|
||||
<button
|
||||
@@ -231,7 +241,7 @@ function PhoneEntryScreen({ section, title, locked, onBack, onDelete, children }
|
||||
>
|
||||
{children}
|
||||
</fieldset>
|
||||
</div>
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,12 @@ import { orpc } from "@/libs/orpc/client";
|
||||
import { themeMap } from "@/libs/theme";
|
||||
import { SignOutButton } from "./account/page";
|
||||
|
||||
// Phones: a section pushes in from the end and Back returns from the start (styles in index.css, after the page
|
||||
// transition). Browsers without view-transition types swap instantly, as for every other navigation.
|
||||
const supportsTransitionTypes = globalThis.CSS?.supports?.("selector(:active-view-transition-type(a))") === true;
|
||||
export const pushTransition = supportsTransitionTypes && { types: ["page", "forward"] };
|
||||
export const popTransition = supportsTransitionTypes && { types: ["page", "back"] };
|
||||
|
||||
type RowProps = { icon: IconName; label: ReactNode; value?: ReactNode };
|
||||
|
||||
function Row({ icon, label, value }: RowProps) {
|
||||
@@ -52,13 +58,13 @@ export function SettingsRoot() {
|
||||
</div>
|
||||
|
||||
<nav className="divide-y divide-line overflow-hidden rounded-[14px] border border-line bg-surface">
|
||||
<Link to="/dashboard/settings/account" className={rowClass}>
|
||||
<Link to="/dashboard/settings/account" viewTransition={pushTransition} className={rowClass}>
|
||||
<Row icon="account_circle" label={<Trans>Account</Trans>} />
|
||||
</Link>
|
||||
<Link to="/dashboard/settings/preferences" className={rowClass}>
|
||||
<Link to="/dashboard/settings/preferences" viewTransition={pushTransition} className={rowClass}>
|
||||
<Row icon="tune" label={<Trans>Preferences</Trans>} value={i18n.t(themeMap[theme])} />
|
||||
</Link>
|
||||
<Link to="/dashboard/settings/ai" className={rowClass}>
|
||||
<Link to="/dashboard/settings/ai" viewTransition={pushTransition} className={rowClass}>
|
||||
<Row icon="hub" label={<Trans>AI & developer</Trans>} value={provider?.label ?? <Trans>Not set up</Trans>} />
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
@@ -91,3 +91,40 @@ html:active-view-transition-type(page)::view-transition-old(*):only-child {
|
||||
html:active-view-transition-type(page)::view-transition-new(root) {
|
||||
animation: var(--d2) var(--ease) both vt-page-in;
|
||||
}
|
||||
|
||||
/*
|
||||
* Phones, Settings: a section pushes in from the end and Back returns to the list from the start. The links add a
|
||||
* `forward` or `back` type next to `page`, so the page rules above still fade the old page out; these replace only
|
||||
* the new page's entry. Same specificity as the page rules, so they must stay after them.
|
||||
*/
|
||||
@keyframes vt-push-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
translate: var(--vt-push) 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes vt-pop-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
translate: calc(var(--vt-push) * -1) 0;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
--vt-push: 24px;
|
||||
}
|
||||
|
||||
html[dir="rtl"] {
|
||||
--vt-push: -24px;
|
||||
}
|
||||
|
||||
@media (width < 640px) {
|
||||
html:active-view-transition-type(forward)::view-transition-new(root) {
|
||||
animation: var(--d2) var(--ease) both vt-push-in;
|
||||
}
|
||||
|
||||
html:active-view-transition-type(back)::view-transition-new(root) {
|
||||
animation: var(--d2) var(--ease) both vt-pop-in;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +102,8 @@ type SheetTab = "template" | "type" | "color" | "page";
|
||||
|
||||
/**
|
||||
* Phones: Design is a sheet over the lower half of the page, so every change shows above it. The handle
|
||||
* raises it to full height. Tabs replace the group nav; Advanced sits under Page.
|
||||
* raises it to full height. It keeps its full height and moves by `translate` (half out of view when lowered),
|
||||
* and rises into view when Design opens. Tabs replace the group nav; Advanced sits under Page.
|
||||
*/
|
||||
export function DesignSheet() {
|
||||
const locked = useIsResumeLocked();
|
||||
@@ -113,8 +114,8 @@ export function DesignSheet() {
|
||||
<section
|
||||
aria-label={t`Design`}
|
||||
className={cn(
|
||||
"absolute inset-x-0 bottom-0 z-20 flex flex-col rounded-t-2xl border-line border-t bg-surface shadow-e3 transition-[height] duration-emphasized ease-enter",
|
||||
expanded ? "h-[calc(100%-1rem)]" : "h-1/2",
|
||||
"absolute inset-x-0 bottom-0 z-20 flex h-[calc(100%-1rem)] starting:translate-y-full flex-col rounded-t-2xl border-line border-t bg-surface shadow-e3 transition-[translate] duration-emphasized ease-enter [container-type:size]",
|
||||
!expanded && "translate-y-[calc(50%-0.5rem)]",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
@@ -149,6 +150,8 @@ export function DesignSheet() {
|
||||
<AdvancedGroup />
|
||||
</div>
|
||||
)}
|
||||
{/* Lowered, the sheet's lower half is out of view: this keeps the end of the list scrollable into sight. */}
|
||||
{!expanded && <div aria-hidden="true" className="h-[calc(50cqh-0.5rem)]" />}
|
||||
</fieldset>
|
||||
</Tabs>
|
||||
</section>
|
||||
|
||||
@@ -238,7 +238,7 @@ function MobileBody({ mode, onModeChange }: MobileBodyProps) {
|
||||
|
||||
return (
|
||||
<div className="grid min-h-0 grid-rows-[minmax(0,1fr)_auto]">
|
||||
<div className="relative min-h-0">
|
||||
<div className="relative min-h-0 overflow-hidden">
|
||||
<main id="main-content" className={cn("h-full", !pageVisible && "invisible")}>
|
||||
<Outlet />
|
||||
</main>
|
||||
@@ -303,7 +303,7 @@ function SelectionBar({ onEdit }: { onEdit: () => void }) {
|
||||
if (!selection) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-[76px] left-1/2 z-10 flex -translate-x-1/2 items-center rounded-xl bg-ink p-1 text-bg shadow-e3">
|
||||
<div className="absolute bottom-[76px] left-1/2 z-10 flex -translate-x-1/2 starting:translate-y-2 items-center rounded-xl bg-ink p-1 text-bg starting:opacity-0 shadow-e3 transition-[opacity,translate] duration-standard ease-enter">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-10 items-center gap-1.5 rounded-lg px-3 font-semibold text-[15px]"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { createFileRoute, Link, Outlet, useRouterState } from "@tanstack/react-router";
|
||||
import { Icon } from "@reactive-resume/ui/components/icon";
|
||||
import { popTransition } from "@/features/settings/root";
|
||||
|
||||
type Page = {
|
||||
to: "/dashboard/settings/account" | "/dashboard/settings/preferences" | "/dashboard/settings/ai";
|
||||
@@ -76,6 +77,7 @@ function RouteComponent() {
|
||||
{!isRoot && (
|
||||
<Link
|
||||
to="/dashboard/settings"
|
||||
viewTransition={popTransition}
|
||||
className="mb-4 inline-flex h-9 items-center gap-1 text-ink-2 text-sm sm:hidden"
|
||||
>
|
||||
<Icon name="chevron_left" size={20} />
|
||||
|
||||
Reference in New Issue
Block a user