mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-03 10:13:47 +10:00
feat(web): fade between pages with view transitions
Pathname changes after the first load now run a typed `page` view transition: the old page fades out over 140ms and the new one fades up 6px over 200ms on the house tokens. Search-param and hash changes, the first load and browsers without view-transition types stay instant. The app nav, settings nav and auth logo are named so they hold still. The auth card's load-time entrance is removed. Applications views, the Documents grid/list switch and Calendar month changes now enter with a short fade (months slide from the direction of travel), only after the user switches.
This commit is contained in:
@@ -42,6 +42,8 @@ export function ApplicationCalendar({ applications, allApplications, onOpen }: A
|
||||
const now = new Date();
|
||||
return new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
});
|
||||
// Which way the last month change went: the new month's days slide in from that side. 0 = no change yet.
|
||||
const [direction, setDirection] = useState<-1 | 0 | 1>(0);
|
||||
const [dialog, setDialog] = useState<DialogState>({ open: false });
|
||||
|
||||
const interviews = collectInterviews(applications);
|
||||
@@ -63,7 +65,11 @@ export function ApplicationCalendar({ applications, allApplications, onOpen }: A
|
||||
const timeFormat = new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "2-digit" });
|
||||
const dayHeadingFormat = new Intl.DateTimeFormat(locale, { weekday: "long", month: "long", day: "numeric" });
|
||||
|
||||
const shiftMonth = (delta: number) => setMonth((prev) => new Date(prev.getFullYear(), prev.getMonth() + delta, 1));
|
||||
const goToMonth = (next: Date) => {
|
||||
setDirection(next > month ? 1 : -1);
|
||||
setMonth(next);
|
||||
};
|
||||
const shiftMonth = (delta: number) => goToMonth(new Date(month.getFullYear(), month.getMonth() + delta, 1));
|
||||
const schedule = (day: Date | null = null) => setDialog({ open: true, application: null, interview: null, day });
|
||||
const edit = (item: ScheduledInterview) =>
|
||||
setDialog({ open: true, application: item.application, interview: item.interview, day: null });
|
||||
@@ -100,7 +106,7 @@ export function ApplicationCalendar({ applications, allApplications, onOpen }: A
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={isCurrentMonth}
|
||||
onClick={() => setMonth(new Date(today.getFullYear(), today.getMonth(), 1))}
|
||||
onClick={() => goToMonth(new Date(today.getFullYear(), today.getMonth(), 1))}
|
||||
>
|
||||
<Trans>This month</Trans>
|
||||
</Button>
|
||||
@@ -114,91 +120,102 @@ export function ApplicationCalendar({ applications, allApplications, onOpen }: A
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid shrink-0 grid-cols-7 overflow-hidden rounded-xl border border-line bg-surface">
|
||||
{days.slice(0, 7).map((day) => (
|
||||
<div
|
||||
key={`weekday-${day.getDay()}`}
|
||||
className="border-line border-b px-2 py-2 text-center font-medium text-[11px] text-ink-3 uppercase tracking-wide"
|
||||
>
|
||||
{weekdayFormat.format(day)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{days.map((day, i) => {
|
||||
const key = dayKey(day);
|
||||
const items = byDay.get(key) ?? [];
|
||||
const inMonth = day.getMonth() === month.getMonth();
|
||||
const isToday = key === todayKey;
|
||||
const extra = items.length - MAX_CHIPS_PER_DAY;
|
||||
const date = dayHeadingFormat.format(day);
|
||||
const scheduleLabel = t`Schedule an interview on ${date}`;
|
||||
return (
|
||||
<div className="shrink-0 overflow-hidden rounded-xl border border-line bg-surface">
|
||||
<div className="grid grid-cols-7">
|
||||
{days.slice(0, 7).map((day) => (
|
||||
<div
|
||||
key={key}
|
||||
data-day={key}
|
||||
className={cn(
|
||||
"group/day relative flex min-h-16 flex-col gap-1 border-line p-1 sm:min-h-24 sm:p-1.5",
|
||||
i % 7 !== 6 && "border-e",
|
||||
i < 35 && "border-b",
|
||||
!inMonth && "bg-sunken/30",
|
||||
)}
|
||||
key={`weekday-${day.getDay()}`}
|
||||
className="border-line border-b px-2 py-2 text-center font-medium text-[11px] text-ink-3 uppercase tracking-wide"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
title={scheduleLabel}
|
||||
aria-label={scheduleLabel}
|
||||
className="flex size-6 items-center justify-center rounded-md text-ink-3 opacity-0 transition-opacity hover:bg-sunken hover:text-ink focus-visible:opacity-100 group-hover/day:opacity-100 max-sm:hidden"
|
||||
onClick={() => schedule(day)}
|
||||
>
|
||||
<Icon name="add" size={14} />
|
||||
</button>
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-6 items-center justify-center rounded-full text-xs",
|
||||
!inMonth && "text-ink-3/60",
|
||||
isToday && "bg-accent font-semibold text-on-accent",
|
||||
)}
|
||||
>
|
||||
{day.getDate()}
|
||||
</span>
|
||||
</div>
|
||||
{items.slice(0, MAX_CHIPS_PER_DAY).map((item) => (
|
||||
<InterviewChip
|
||||
key={item.interview.id}
|
||||
item={item}
|
||||
time={timeFormat.format(item.start)}
|
||||
onOpen={() => edit(item)}
|
||||
/>
|
||||
))}
|
||||
{extra > 0 && (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="self-start rounded-md px-1.5 py-0.5 text-[11px] text-ink-3 transition-colors hover:bg-sunken hover:text-ink"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trans>+{extra} more</Trans>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="flex w-60 flex-col gap-1 p-2">
|
||||
<p className="px-1 pb-1 font-medium text-xs">{dayHeading(day)}</p>
|
||||
{items.map((item) => (
|
||||
<InterviewChip
|
||||
key={item.interview.id}
|
||||
item={item}
|
||||
time={timeFormat.format(item.start)}
|
||||
onOpen={() => edit(item)}
|
||||
/>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
{weekdayFormat.format(day)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
key={`${month.getFullYear()}-${month.getMonth()}`}
|
||||
className={cn(
|
||||
"grid grid-cols-7",
|
||||
direction !== 0 && "starting:opacity-0 transition-[opacity,translate] duration-standard ease-enter",
|
||||
direction === 1 && "starting:translate-x-2 rtl:starting:-translate-x-2",
|
||||
direction === -1 && "starting:-translate-x-2 rtl:starting:translate-x-2",
|
||||
)}
|
||||
>
|
||||
{days.map((day, i) => {
|
||||
const key = dayKey(day);
|
||||
const items = byDay.get(key) ?? [];
|
||||
const inMonth = day.getMonth() === month.getMonth();
|
||||
const isToday = key === todayKey;
|
||||
const extra = items.length - MAX_CHIPS_PER_DAY;
|
||||
const date = dayHeadingFormat.format(day);
|
||||
const scheduleLabel = t`Schedule an interview on ${date}`;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
data-day={key}
|
||||
className={cn(
|
||||
"group/day relative flex min-h-16 flex-col gap-1 border-line p-1 sm:min-h-24 sm:p-1.5",
|
||||
i % 7 !== 6 && "border-e",
|
||||
i < 35 && "border-b",
|
||||
!inMonth && "bg-sunken/30",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
title={scheduleLabel}
|
||||
aria-label={scheduleLabel}
|
||||
className="flex size-6 items-center justify-center rounded-md text-ink-3 opacity-0 transition-opacity hover:bg-sunken hover:text-ink focus-visible:opacity-100 group-hover/day:opacity-100 max-sm:hidden"
|
||||
onClick={() => schedule(day)}
|
||||
>
|
||||
<Icon name="add" size={14} />
|
||||
</button>
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-6 items-center justify-center rounded-full text-xs",
|
||||
!inMonth && "text-ink-3/60",
|
||||
isToday && "bg-accent font-semibold text-on-accent",
|
||||
)}
|
||||
>
|
||||
{day.getDate()}
|
||||
</span>
|
||||
</div>
|
||||
{items.slice(0, MAX_CHIPS_PER_DAY).map((item) => (
|
||||
<InterviewChip
|
||||
key={item.interview.id}
|
||||
item={item}
|
||||
time={timeFormat.format(item.start)}
|
||||
onOpen={() => edit(item)}
|
||||
/>
|
||||
))}
|
||||
{extra > 0 && (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="self-start rounded-md px-1.5 py-0.5 text-[11px] text-ink-3 transition-colors hover:bg-sunken hover:text-ink"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trans>+{extra} more</Trans>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="flex w-60 flex-col gap-1 p-2">
|
||||
<p className="px-1 pb-1 font-medium text-xs">{dayHeading(day)}</p>
|
||||
{items.map((item) => (
|
||||
<InterviewChip
|
||||
key={item.interview.id}
|
||||
item={item}
|
||||
time={timeFormat.format(item.start)}
|
||||
onOpen={() => edit(item)}
|
||||
/>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-ink-3 text-xs">
|
||||
|
||||
@@ -3,8 +3,8 @@ import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
|
||||
|
||||
export function AuthLayout() {
|
||||
return (
|
||||
<div className="fade-in-0 slide-in-from-top-4 mx-auto flex h-svh w-dvw max-w-sm animate-in flex-col justify-center gap-y-6 px-4 xs:px-0 duration-emphasized ease-enter">
|
||||
<BrandIcon className="mb-4 size-20 self-center" />
|
||||
<div className="mx-auto flex h-svh w-dvw max-w-sm flex-col justify-center gap-y-6 px-4 xs:px-0">
|
||||
<BrandIcon className="mb-4 size-20 self-center [view-transition-name:auth-brand]" />
|
||||
|
||||
<Outlet />
|
||||
</div>
|
||||
|
||||
@@ -41,12 +41,18 @@ export function DocumentsPage({ search, onSearchChange }: DocumentsPageProps) {
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const [tagsFor, setTagsFor] = useState<DocumentSummary | null>(null);
|
||||
const [linkFor, setLinkFor] = useState<DocumentSummary | null>(null);
|
||||
// Grid and list animate in only after a switch, never on the page's first render.
|
||||
const [viewSwitched, setViewSwitched] = useState(false);
|
||||
const { data: documents, isPending } = useQuery(orpc.documents.list.queryOptions({ input: { trashed: false } }));
|
||||
const view = search.view ?? readStoredView();
|
||||
const setView = (next: "grid" | "list") => {
|
||||
setViewSwitched(true);
|
||||
storeView(next);
|
||||
onSearchChange({ view: next });
|
||||
};
|
||||
const viewEnter =
|
||||
viewSwitched &&
|
||||
"transition-[opacity,translate] duration-standard ease-enter starting:translate-y-1 starting:opacity-0";
|
||||
|
||||
useHotkey("/", (event) => {
|
||||
if (isEditableElementFocused()) return;
|
||||
@@ -200,7 +206,7 @@ export function DocumentsPage({ search, onSearchChange }: DocumentsPageProps) {
|
||||
)}
|
||||
</div>
|
||||
) : view === "list" ? (
|
||||
<table className="w-full border-collapse">
|
||||
<table className={cn("w-full border-collapse", viewEnter)}>
|
||||
<thead>
|
||||
<tr className="border-line border-b text-start font-medium text-ink-3 text-xs">
|
||||
<th className="h-10 ps-3 text-start font-medium">
|
||||
@@ -229,7 +235,12 @@ export function DocumentsPage({ search, onSearchChange }: DocumentsPageProps) {
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,204px))] gap-x-7 gap-y-[22px] max-sm:grid-cols-2 max-sm:gap-4">
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-[repeat(auto-fill,minmax(180px,204px))] gap-x-7 gap-y-[22px] max-sm:grid-cols-2 max-sm:gap-4",
|
||||
viewEnter,
|
||||
)}
|
||||
>
|
||||
{shown.map((document) => (
|
||||
<DocumentCard key={`${document.type}:${document.id}`} document={document} {...itemProps} />
|
||||
))}
|
||||
|
||||
@@ -19,3 +19,39 @@
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='6' height='4' viewBox='0 0 6 4'%3E%3Cpath d='M0 3 Q1.5 0 3 3 T6 3' fill='none' stroke='black' stroke-width='1.2'/%3E%3C/svg%3E")
|
||||
repeat-x left center / 6px 4px;
|
||||
}
|
||||
|
||||
/*
|
||||
* Page transitions: the router starts a view transition of type `page` when the pathname changes. The page fades
|
||||
* out quickly and the new one fades up 6px; named navigation (app-nav, settings-nav, auth-brand) stays in place.
|
||||
*/
|
||||
@keyframes vt-page-out {
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes vt-page-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
translate: 0 6px;
|
||||
}
|
||||
}
|
||||
|
||||
html:active-view-transition-type(page)::view-transition {
|
||||
background-color: var(--bg);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
html:active-view-transition-type(page)::view-transition-group(*) {
|
||||
animation-duration: var(--d2);
|
||||
animation-timing-function: var(--ease);
|
||||
}
|
||||
|
||||
html:active-view-transition-type(page)::view-transition-old(root),
|
||||
html:active-view-transition-type(page)::view-transition-old(*):only-child {
|
||||
animation: calc(var(--d2) * 0.7) var(--ease) both vt-page-out;
|
||||
}
|
||||
|
||||
html:active-view-transition-type(page)::view-transition-new(root) {
|
||||
animation: var(--d2) var(--ease) both vt-page-in;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@ import { getQueryClient } from "./libs/query/client";
|
||||
import { loadRootContext } from "./libs/root-context";
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
|
||||
// A pathname change fades the page (styles in index.css). Search-param and hash changes (filters, views, editor
|
||||
// modes), the first load, and browsers without view-transition types swap instantly.
|
||||
const supportsTransitionTypes = globalThis.CSS?.supports?.("selector(:active-view-transition-type(a))") === true;
|
||||
|
||||
export const getRouter = async () => {
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
@@ -16,6 +20,9 @@ export const getRouter = async () => {
|
||||
routeTree,
|
||||
scrollRestoration: true,
|
||||
defaultStructuralSharing: true,
|
||||
defaultViewTransition: supportsTransitionTypes && {
|
||||
types: ({ fromLocation, pathChanged }) => (fromLocation && pathChanged ? ["page"] : false),
|
||||
},
|
||||
// Hovering a link starts its loaders; TanStack Query decides freshness, not the router's preload cache.
|
||||
defaultPreload: "intent",
|
||||
defaultPreloadStaleTime: 0,
|
||||
|
||||
@@ -110,7 +110,7 @@ function Sidebar() {
|
||||
const openDialog = useDialogStore((state) => state.openDialog);
|
||||
|
||||
return (
|
||||
<aside className="sticky top-0 flex h-svh flex-col gap-3 border-line border-e bg-surface p-3">
|
||||
<aside className="sticky top-0 flex h-svh flex-col gap-3 border-line border-e bg-surface p-3 [view-transition-name:app-nav]">
|
||||
<Link to="/" className="flex h-9 items-center gap-2.5 px-1.5">
|
||||
<span className="grid size-6 place-items-center rounded-[5px] bg-ink font-display font-medium text-[13px] text-bg">
|
||||
Rr
|
||||
@@ -201,7 +201,7 @@ function Rail() {
|
||||
const openDialog = useDialogStore((state) => state.openDialog);
|
||||
|
||||
return (
|
||||
<aside className="sticky top-0 flex h-svh flex-col items-center gap-2 border-line border-e bg-surface py-3">
|
||||
<aside className="sticky top-0 flex h-svh flex-col items-center gap-2 border-line border-e bg-surface py-3 [view-transition-name:app-nav]">
|
||||
<Link
|
||||
to="/"
|
||||
aria-label="Reactive Resume"
|
||||
@@ -297,7 +297,7 @@ function MobileTabs() {
|
||||
return (
|
||||
<nav
|
||||
aria-label={t`App`}
|
||||
className="sticky bottom-0 z-30 grid grid-cols-4 border-line border-t bg-surface pb-[env(safe-area-inset-bottom)]"
|
||||
className="sticky bottom-0 z-30 grid grid-cols-4 border-line border-t bg-surface pb-[env(safe-area-inset-bottom)] [view-transition-name:app-nav]"
|
||||
>
|
||||
<Link
|
||||
to="/dashboard"
|
||||
|
||||
@@ -75,6 +75,8 @@ function RouteComponent() {
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [editing, setEditing] = useState<Application | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
// Views animate in only after a switch, never on the page's first render.
|
||||
const [viewSwitched, setViewSwitched] = useState(false);
|
||||
|
||||
const { data: applications, isPending } = useQuery(applicationsListQueryOptions());
|
||||
const setSearch = (patch: Partial<Search>) => void navigate({ search: (prev: Search) => ({ ...prev, ...patch }) });
|
||||
@@ -135,7 +137,13 @@ function RouteComponent() {
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Tabs value={shown} onValueChange={(value) => setSearch({ view: value as View })}>
|
||||
<Tabs
|
||||
value={shown}
|
||||
onValueChange={(value) => {
|
||||
setViewSwitched(true);
|
||||
setSearch({ view: value as View });
|
||||
}}
|
||||
>
|
||||
<TabsList aria-label={t`View`}>
|
||||
<TabsTrigger value="list">
|
||||
<Icon name="view_agenda" size={18} />
|
||||
@@ -199,7 +207,13 @@ function RouteComponent() {
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
key={shown}
|
||||
className={cn(
|
||||
viewSwitched &&
|
||||
"starting:translate-y-1 starting:opacity-0 transition-[opacity,translate] duration-standard ease-enter",
|
||||
)}
|
||||
>
|
||||
{shown === "list" && (
|
||||
<ApplicationList
|
||||
applications={filtered}
|
||||
@@ -225,7 +239,7 @@ function RouteComponent() {
|
||||
onOpen={(application) => setSelectedId(application.id)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -27,7 +27,7 @@ function RouteComponent() {
|
||||
<div className="grid min-h-full content-start lg:grid-cols-[220px_minmax(0,1fr)] lg:content-stretch">
|
||||
<nav
|
||||
aria-label={t`Settings`}
|
||||
className="flex flex-col gap-1 border-line max-sm:hidden max-lg:border-b lg:border-e lg:py-7 lg:ps-6 lg:pe-3"
|
||||
className="flex flex-col gap-1 border-line [view-transition-name:settings-nav] max-sm:hidden max-lg:border-b lg:border-e lg:py-7 lg:ps-6 lg:pe-3"
|
||||
>
|
||||
<h1 className="ms-2 mb-3.5 font-display font-medium text-[26px] leading-8 max-lg:hidden">
|
||||
<Trans>Settings</Trans>
|
||||
|
||||
Reference in New Issue
Block a user