Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions apps/web/src/lib/view-transitions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'bun:test';

describe('folder navigation view transitions', () => {
it('uses a typed transition only when the browser supports transition types', async () => {
const cssDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'CSS');

try {
Object.defineProperty(globalThis, 'CSS', {
configurable: true,
value: { supports: () => true },
});
const { folderNavigationViewTransition } = await import('./view-transitions');
expect(folderNavigationViewTransition()).toEqual({ types: ['folder-navigation'] });

Object.defineProperty(globalThis, 'CSS', {
configurable: true,
value: { supports: () => false },
});
expect(folderNavigationViewTransition()).toBe(false);
} finally {
if (cssDescriptor) Object.defineProperty(globalThis, 'CSS', cssDescriptor);
else Reflect.deleteProperty(globalThis, 'CSS');
}
});
});
10 changes: 10 additions & 0 deletions apps/web/src/lib/view-transitions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export function folderNavigationViewTransition(): false | { types: string[] } {
if (
typeof CSS === 'undefined' ||
!CSS.supports('selector(:active-view-transition-type(folder-navigation))')
) {
return false;
}

return { types: ['folder-navigation'] };
}
27 changes: 23 additions & 4 deletions apps/web/src/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, expect, it } from 'bun:test';
import type { QueryClient } from '@tanstack/react-query';

describe('router view transitions', () => {
it('leaves visual transition ownership to React component boundaries', async () => {
it('starts typed native transitions for route changes only', async () => {
const windowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window');
const cssDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'CSS');

Expand All @@ -17,9 +17,28 @@ describe('router view transitions', () => {
});
const { createRouter } = await import('./router');

expect(createRouter({ queryClient: {} as QueryClient }).options.defaultViewTransition).toBe(
false,
);
const transition = createRouter({ queryClient: {} as QueryClient }).options
.defaultViewTransition;

expect(transition).toEqual({
types: expect.any(Function),
});

if (!transition || typeof transition !== 'object' || typeof transition.types !== 'function') {
throw new Error('missing view transition type resolver');
}

const location = (index: number) => ({ state: { __TSR_index: index } });
const change = (pathChanged: boolean, fromIndex: number, toIndex: number) =>
({
pathChanged,
fromLocation: location(fromIndex),
toLocation: location(toIndex),
}) as Parameters<typeof transition.types>[0];

expect(transition.types(change(true, 1, 2))).toEqual(['route-forward']);
expect(transition.types(change(true, 2, 1))).toEqual(['route-back']);
expect(transition.types(change(false, 1, 2))).toBe(false);
} finally {
if (windowDescriptor) Object.defineProperty(globalThis, 'window', windowDescriptor);
else Reflect.deleteProperty(globalThis, 'window');
Expand Down
10 changes: 9 additions & 1 deletion apps/web/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ export function createRouter({ queryClient }: { queryClient: QueryClient }) {
context: { queryClient },
defaultPreload: 'intent',
scrollRestoration: true,
defaultViewTransition: false,
defaultViewTransition: {
types: ({ fromLocation, pathChanged, toLocation }) => {
if (!pathChanged) return false;

const fromIndex = fromLocation?.state.__TSR_index;
const toIndex = toLocation.state.__TSR_index;
return fromIndex != null && toIndex < fromIndex ? ['route-back'] : ['route-forward'];
},
},
});
}
15 changes: 7 additions & 8 deletions apps/web/src/routes/_app.files.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,13 @@ import {
} from 'lucide-react';
import { QRCodeSVG } from 'qrcode.react';
import {
addTransitionType,
type ChangeEvent,
type DragEvent,
startTransition,
useCallback,
useEffect,
useMemo,
useRef,
useState,
ViewTransition,
} from 'react';
import { toast } from 'sonner';
import { Badge } from '~/components/ui/badge';
Expand Down Expand Up @@ -87,6 +84,7 @@ import {
uploadButtonLabel,
} from '~/lib/upload-progress';
import { useUploadTrigger } from '~/lib/upload-trigger';
import { folderNavigationViewTransition } from '~/lib/view-transitions';

type ListedEntry = Entry & { isParentLink?: boolean };
type SortMode = 'name-asc' | 'name-desc' | 'size-desc' | 'size-asc' | 'date-desc' | 'date-asc';
Expand Down Expand Up @@ -415,9 +413,10 @@ function FilesPage() {

const navigateFolder = useCallback(
(nextPath: string) => {
startTransition(() => {
addTransitionType('folder-navigation');
navigate({ to: '/files', search: buildFilesSearch({ path: nextPath, q, mode }) });
navigate({
to: '/files',
search: buildFilesSearch({ path: nextPath, q, mode }),
viewTransition: folderNavigationViewTransition(),
});
},
[mode, navigate, q],
Expand Down Expand Up @@ -695,7 +694,7 @@ function FilesPage() {
)
)}
</div>
<ViewTransition update={{ 'folder-navigation': 'folder-list', default: 'none' }}>
<div className="folder-transition-content">
{!isGlobalSearch && !allModePrompt && list.isLoading && (
<p className="p-6 text-sm text-[hsl(var(--muted-foreground))]">Loading…</p>
)}
Expand Down Expand Up @@ -805,7 +804,7 @@ function FilesPage() {
No files match your search.
</p>
)}
</ViewTransition>
</div>
</section>

{list.data && !isGlobalSearch && !allModePrompt && (
Expand Down
5 changes: 2 additions & 3 deletions apps/web/src/routes/_app.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { useQuery } from '@tanstack/react-query';
import { createFileRoute, Navigate, Outlet } from '@tanstack/react-router';
import { ViewTransition } from 'react';
import { AppShell } from '~/components/layout/app-shell';
import { SplashScreen } from '~/components/layout/splash-screen';
import { authClient } from '~/lib/auth-client';
Expand All @@ -20,9 +19,9 @@ function AppLayout() {

return (
<AppShell>
<ViewTransition update={{ 'folder-navigation': 'none', default: 'app-route-content' }}>
<div className="route-transition-content">
<Outlet />
</ViewTransition>
</div>
</AppShell>
);
}
32 changes: 24 additions & 8 deletions apps/web/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -239,30 +239,46 @@
}
}

::view-transition-old(.app-route-content) {
:root:active-view-transition {
view-transition-name: none;
}

:root:active-view-transition .route-transition-content {
view-transition-name: app-route-content;
}

:root:active-view-transition-type(folder-navigation) .route-transition-content {
view-transition-name: none;
}

:root:active-view-transition-type(folder-navigation) .folder-transition-content {
view-transition-name: folder-list;
}

::view-transition-old(app-route-content) {
animation: 160ms ease-in both route-exit-to-left;
}

::view-transition-new(.app-route-content) {
::view-transition-new(app-route-content) {
animation: 200ms ease-out both route-enter-from-right;
}

::view-transition-old(.folder-list) {
::view-transition-old(folder-list) {
animation: 120ms ease-in both route-exit-to-left;
}

::view-transition-new(.folder-list) {
::view-transition-new(folder-list) {
animation: 160ms ease-out both route-enter-from-right;
}

@media (prefers-reduced-motion: reduce) {
::view-transition-old(.app-route-content),
::view-transition-old(.folder-list) {
::view-transition-old(app-route-content),
::view-transition-old(folder-list) {
animation: 100ms linear both route-fade-out;
}

::view-transition-new(.app-route-content),
::view-transition-new(.folder-list) {
::view-transition-new(app-route-content),
::view-transition-new(folder-list) {
animation: 100ms linear both route-fade-in;
}
}
Expand Down
Loading