mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-02 17:54:22 +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} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type * as React from "react";
|
||||
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog";
|
||||
import { Drawer as SheetPrimitive } from "@base-ui/react/drawer";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Icon } from "@reactive-resume/ui/components/icon";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
@@ -41,8 +41,9 @@ type SheetContentProps = SheetPrimitive.Popup.Props & {
|
||||
};
|
||||
|
||||
/**
|
||||
* A task beside the page. Side sheets slide in from their edge over 320ms; the bottom variant is the
|
||||
* mobile form (18px top radius and a grabber).
|
||||
* A task beside the page. Side sheets slide in from their edge over 320ms; the bottom variant is the mobile
|
||||
* form (18px top radius and a grabber) and follows a downward swipe: a flick or a drag past half its height
|
||||
* dismisses it, anything less settles back.
|
||||
*/
|
||||
function SheetContent({
|
||||
className,
|
||||
@@ -52,37 +53,52 @@ function SheetContent({
|
||||
closeLabel = "Close",
|
||||
...props
|
||||
}: SheetContentProps) {
|
||||
const popup = (
|
||||
<SheetPrimitive.Popup
|
||||
data-slot="sheet-content"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col gap-4 bg-raised text-ink text-sm shadow-e3 outline-none transition-[translate] duration-emphasized ease-enter data-ending-style:duration-[calc(var(--d3)*0.7)]",
|
||||
"data-[side=left]:data-ending-style:-translate-x-full data-[side=left]:data-starting-style:-translate-x-full data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-full data-[side=left]:sm:max-w-[440px]",
|
||||
"data-[side=right]:data-ending-style:translate-x-full data-[side=right]:data-starting-style:translate-x-full data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-full data-[side=right]:sm:max-w-[440px]",
|
||||
"rtl:data-[side=left]:data-ending-style:translate-x-full rtl:data-[side=left]:data-starting-style:translate-x-full rtl:data-[side=right]:data-ending-style:-translate-x-full rtl:data-[side=right]:data-starting-style:-translate-x-full",
|
||||
"data-[side=top]:data-ending-style:-translate-y-full data-[side=top]:data-starting-style:-translate-y-full data-[side=top]:inset-x-0 data-[side=top]:top-0",
|
||||
"data-[side=bottom]:data-ending-style:translate-y-full data-[side=bottom]:data-starting-style:translate-y-full data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:max-h-[calc(100svh-2rem)] data-[side=bottom]:rounded-t-3xl data-[side=bottom]:pt-3",
|
||||
// Swipe: Base UI moves the popup inline while dragging; on release it rests at the swipe offset and
|
||||
// transitions back to 0, or leaves on the drawer curve, faster for a harder flick.
|
||||
"data-swipe-dismiss:data-ending-style:duration-[calc(var(--d3)*0.7*var(--drawer-swipe-strength,1))] data-swipe-dismiss:data-ending-style:ease-drawer data-[side=bottom]:pointer-events-auto data-swiping:select-none data-[side=bottom]:transition-[translate,transform] data-[side=bottom]:[transform:translateY(var(--drawer-swipe-movement-y,0px))]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{side === "bottom" && (
|
||||
<span aria-hidden="true" className="mx-auto block h-[5px] w-9 shrink-0 rounded-full bg-line-2" />
|
||||
)}
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close
|
||||
data-slot="sheet-close"
|
||||
aria-label={closeLabel}
|
||||
render={<Button variant="ghost" className="absolute inset-e-3 top-3 text-ink-2" size="icon" />}
|
||||
>
|
||||
<Icon name="close" />
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Popup>
|
||||
);
|
||||
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Popup
|
||||
data-slot="sheet-content"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col gap-4 bg-raised text-ink text-sm shadow-e3 outline-none transition-[translate] duration-emphasized ease-enter data-ending-style:duration-[calc(var(--d3)*0.7)]",
|
||||
"data-[side=left]:data-ending-style:-translate-x-full data-[side=left]:data-starting-style:-translate-x-full data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-full data-[side=left]:sm:max-w-[440px]",
|
||||
"data-[side=right]:data-ending-style:translate-x-full data-[side=right]:data-starting-style:translate-x-full data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-full data-[side=right]:sm:max-w-[440px]",
|
||||
"rtl:data-[side=left]:data-ending-style:translate-x-full rtl:data-[side=left]:data-starting-style:translate-x-full rtl:data-[side=right]:data-ending-style:-translate-x-full rtl:data-[side=right]:data-starting-style:-translate-x-full",
|
||||
"data-[side=top]:data-ending-style:-translate-y-full data-[side=top]:data-starting-style:-translate-y-full data-[side=top]:inset-x-0 data-[side=top]:top-0",
|
||||
"data-[side=bottom]:data-ending-style:translate-y-full data-[side=bottom]:data-starting-style:translate-y-full data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:max-h-[calc(100svh-2rem)] data-[side=bottom]:rounded-t-3xl data-[side=bottom]:pt-3",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{side === "bottom" && (
|
||||
<span aria-hidden="true" className="mx-auto block h-[5px] w-9 shrink-0 rounded-full bg-line-2" />
|
||||
)}
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close
|
||||
data-slot="sheet-close"
|
||||
aria-label={closeLabel}
|
||||
render={<Button variant="ghost" className="absolute inset-e-3 top-3 text-ink-2" size="icon" />}
|
||||
>
|
||||
<Icon name="close" />
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Popup>
|
||||
{side === "bottom" ? (
|
||||
// The viewport tracks the swipe. It covers the screen without taking clicks, which fall through to the
|
||||
// backdrop, while the popup's pointer events still bubble to it.
|
||||
<SheetPrimitive.Viewport data-slot="sheet-viewport" className="pointer-events-none fixed inset-0 z-50">
|
||||
{popup}
|
||||
</SheetPrimitive.Viewport>
|
||||
) : (
|
||||
popup
|
||||
)}
|
||||
</SheetPortal>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user