Migrate react-flip-toolkit to React 19.3 ViewTransition
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Tests and checks on push / run-checks-and-tests (push) Has been cancelled
Updates translation progress / update-translation-progress-issue (push) Has been cancelled

This commit is contained in:
Kalle
2026-09-18 07:45:52 +03:00
parent dad4a8234f
commit 8e3ae39413
16 changed files with 843 additions and 856 deletions

View File

@@ -1,6 +1,8 @@
.mobileNav {
display: block;
position: fixed;
/* captured on its own so animating content slides under it instead of over it */
view-transition-name: layout-mobile-nav;
inset: auto 0 0 0;
width: auto;
height: auto;
@@ -403,3 +405,13 @@
animation: none;
}
}
::view-transition-group(layout-mobile-nav) {
z-index: 1;
}
::view-transition-group(layout-mobile-nav),
::view-transition-old(layout-mobile-nav),
::view-transition-new(layout-mobile-nav) {
animation-duration: 0s;
}

View File

@@ -30,15 +30,14 @@
border: 2px solid var(--color-info);
width: 250px;
outline: none;
view-transition-class: toast;
}
::view-transition-new(.toast):only-child {
::view-transition-new(:global(.toast)):only-child {
animation: appear 400ms;
}
::view-transition-old(.toast):only-child {
animation: disappear 400ms;
::view-transition-old(:global(.toast)):only-child {
animation: disappear 400ms forwards;
}
@keyframes disappear {

View File

@@ -1,9 +1,11 @@
import clsx from "clsx";
import { Check, CircleAlert, OctagonAlert, X } from "lucide-react";
import * as React from "react";
import { flushSync } from "react-dom";
import { ViewTransition } from "react";
import { useTranslation } from "react-i18next";
import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect";
import { IS_E2E_TEST_RUN } from "~/utils/e2e";
import { finishUpdateIfUnmoved } from "~/utils/view-transition";
import { SendouButton } from "./Button";
import styles from "./Toast.module.css";
@@ -42,27 +44,16 @@ class ToastQueue {
subscribe = (listener: () => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
return () => {
this.listeners.delete(listener);
};
};
getSnapshot = () => this.toasts;
private update(mutate: () => void) {
const notify = () => {
mutate();
flushSync(() => {
for (const listener of this.listeners) listener();
});
};
if (typeof document !== "undefined" && "startViewTransition" in document) {
const transition = document.startViewTransition(notify);
// rejects with AbortError if another transition interrupts this one
transition.ready.catch(() => {});
} else {
mutate();
for (const listener of this.listeners) listener();
}
mutate();
for (const listener of this.listeners) listener();
}
}
@@ -72,14 +63,20 @@ const EMPTY_TOASTS: QueuedToast[] = [];
export function SendouToastRegion() {
const { t } = useTranslation(["common"]);
const toasts = React.useSyncExternalStore(
toastQueue.subscribe,
toastQueue.getSnapshot,
() => EMPTY_TOASTS,
);
const [toasts, setToasts] = React.useState(EMPTY_TOASTS);
React.useEffect(() => {
const sync = () => {
React.startTransition(() => setToasts(toastQueue.getSnapshot()));
};
const unsubscribe = toastQueue.subscribe(sync);
// a toast added before this subscription (e.g. from a layout effect on load)
sync();
return unsubscribe;
}, []);
const regionRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
// layout effect so the region is open before the entering toast gets snapshotted
useIsomorphicLayoutEffect(() => {
const region = regionRef.current;
if (!region) return;
if (toasts.length > 0 && !region.matches(":popover-open")) {
@@ -97,35 +94,40 @@ export function SendouToastRegion() {
className={clsx(styles.toastRegion, { hidden: IS_E2E_TEST_RUN })}
>
{toasts.map((toast) => (
<div
<ViewTransition
key={toast.key}
role={toast.content.variant === "error" ? "alert" : "status"}
style={{ viewTransitionName: toast.key }}
className={clsx(styles.toast, {
[styles.errorToast]: toast.content.variant === "error",
[styles.successToast]: toast.content.variant === "success",
[styles.infoToast]: toast.content.variant === "info",
})}
enter="toast"
exit="toast"
onUpdate={finishUpdateIfUnmoved}
>
<div className={styles.topRow}>
{toast.content.variant === "success" ? (
<Check className={styles.alertIcon} />
) : toast.content.variant === "error" ? (
<OctagonAlert className={styles.alertIcon} />
) : (
<CircleAlert className={styles.alertIcon} />
)}
{t(`common:toasts.${toast.content.variant}`)}
<SendouButton
variant="minimal"
icon={<X />}
className={styles.closeButton}
aria-label="Close"
onClick={() => toastQueue.close(toast.key)}
/>
<div
role={toast.content.variant === "error" ? "alert" : "status"}
className={clsx(styles.toast, {
[styles.errorToast]: toast.content.variant === "error",
[styles.successToast]: toast.content.variant === "success",
[styles.infoToast]: toast.content.variant === "info",
})}
>
<div className={styles.topRow}>
{toast.content.variant === "success" ? (
<Check className={styles.alertIcon} />
) : toast.content.variant === "error" ? (
<OctagonAlert className={styles.alertIcon} />
) : (
<CircleAlert className={styles.alertIcon} />
)}
{t(`common:toasts.${toast.content.variant}`)}
<SendouButton
variant="minimal"
icon={<X />}
className={styles.closeButton}
aria-label="Close"
onClick={() => toastQueue.close(toast.key)}
/>
</div>
<div>{toast.content.message}</div>
</div>
<div>{toast.content.message}</div>
</div>
</ViewTransition>
))}
</section>
);

View File

@@ -17,12 +17,18 @@
position: sticky;
top: 0;
z-index: 10;
/* captured on its own so animating content slides under it instead of over it */
view-transition-name: layout-header;
}
.siteTitleFlipper {
min-width: 0;
padding: 3px;
margin: -3px;
::view-transition-group(layout-header) {
z-index: 1;
}
::view-transition-group(layout-header),
::view-transition-old(layout-header),
::view-transition-new(layout-header) {
animation-duration: 0s;
}
.siteTitle {

View File

@@ -10,7 +10,7 @@ import {
Users,
} from "lucide-react";
import * as React from "react";
import { Flipped, Flipper } from "react-flip-toolkit";
import { ViewTransition } from "react";
import { useTranslation } from "react-i18next";
import { Link, useFetcher, useLocation, useMatches } from "react-router";
import { Config } from "~/config";
@@ -24,7 +24,6 @@ import { useClosePopoversOnNavigation } from "~/hooks/useClosePopoversOnNavigati
import { useHydrated } from "~/hooks/useHydrated";
import { MOBILE_LAYOUT_QUERY, useLayoutSize } from "~/hooks/useLayoutSize";
import { useMediaQuery } from "~/hooks/useMediaQuery";
import { usePrefersReducedMotion } from "~/hooks/usePrefersReducedMotion";
import { useUnseenFriendRequests } from "~/hooks/useUnseenFriendRequests";
import { useVisualViewportHeight } from "~/hooks/useVisualViewportHeight";
import { useSearchParam } from "~/modules/search-params/hooks";
@@ -35,7 +34,6 @@ import {
EVENTS_PAGE,
FRIENDS_PAGE,
navIconUrl,
PLANNER_URL,
SETTINGS_PAGE,
teamPage,
userPage,
@@ -508,58 +506,52 @@ export function Layout({
function SiteTitle() {
const location = useLocation();
const prefersReducedMotion = usePrefersReducedMotion();
const { breadcrumbs, currentPageText } = useBreadcrumbData();
const isFrontPage = location.pathname === "/";
const hasBreadcrumbs = breadcrumbs.length > 0;
return (
<Flipper
flipKey={isFrontPage ? "front" : "other"}
className={styles.siteTitleFlipper}
decisionData={{ pathname: location.pathname }}
>
<div className={styles.siteTitle}>
<Flipped
flipId="site-logo"
shouldFlip={(prev, current) =>
!prefersReducedMotion &&
prev?.pathname !== PLANNER_URL &&
current?.pathname !== PLANNER_URL
}
>
<Link to="/" className={styles.siteLogo}>
<SiteLogoContent />
</Link>
</Flipped>
<div className={styles.siteTitle}>
{/* the key remounts the logo when it changes place so it animates as a
shared element; an update animation would instead run on every
navigation since React can't tell the logo didn't move */}
<ViewTransition
key={isFrontPage ? "front" : "other"}
name="site-logo"
share="auto"
default="none"
>
<Link to="/" className={styles.siteLogo}>
<SiteLogoContent />
</Link>
</ViewTransition>
{hasBreadcrumbs ? (
<>
{breadcrumbs.map((crumb) => {
const isCurrentPage = location.pathname === crumb.href;
{hasBreadcrumbs ? (
<>
{breadcrumbs.map((crumb) => {
const isCurrentPage = location.pathname === crumb.href;
return (
<React.Fragment key={crumb.href}>
<span className={styles.separator}>/</span>
{isCurrentPage ? (
return (
<React.Fragment key={crumb.href}>
<span className={styles.separator}>/</span>
{isCurrentPage ? (
<PageIcon crumb={crumb} />
) : (
<Link to={crumb.href} className={styles.breadcrumbLink}>
<PageIcon crumb={crumb} />
) : (
<Link to={crumb.href} className={styles.breadcrumbLink}>
<PageIcon crumb={crumb} />
</Link>
)}
</React.Fragment>
);
})}
</Link>
)}
</React.Fragment>
);
})}
{currentPageText ? (
<span className={styles.pageName}>{currentPageText}</span>
) : null}
</>
) : null}
</div>
</Flipper>
{currentPageText ? (
<span className={styles.pageName}>{currentPageText}</span>
) : null}
</>
) : null}
</div>
);
}

View File

@@ -2,7 +2,7 @@ import clsx from "clsx";
import type { SqlBool } from "kysely";
import { Check, Hourglass, Mic, Volume2, VolumeX } from "lucide-react";
import * as React from "react";
import { Flipped } from "react-flip-toolkit";
import { ViewTransition } from "react";
import { Trans, useTranslation } from "react-i18next";
import { Link } from "react-router";
import { ActionButton } from "~/components/ActionButton";
@@ -30,6 +30,7 @@ import {
TIERS_PAGE,
tierImageUrl,
} from "~/utils/urls";
import { finishUpdateIfUnmoved } from "~/utils/view-transition";
import type {
SQGroup,
SQGroupMember,
@@ -60,7 +61,6 @@ export function GroupCard({
ownGroup,
readyUserIds,
kickableUserIds,
layout = "desktop",
}: {
group: SQGroup | SQOwnGroup;
action?: "LIKE" | "UNLIKE" | "GROUP_UP" | "MATCH_UP" | "MATCH_UP_RECHALLENGE";
@@ -78,7 +78,6 @@ export function GroupCard({
readyUserIds?: number[];
/** Members the viewer can kick out of the group. */
kickableUserIds?: number[];
layout?: "mobile" | "desktop";
}) {
const { t } = useTranslation(["q"]);
@@ -98,11 +97,7 @@ export function GroupCard({
const actionToShow = ownGroup ? action : undefined;
return (
<GroupCardContainer
groupId={group.id}
isOwnGroup={isOwnGroup}
layout={layout}
>
<GroupCardContainer isOwnGroup={isOwnGroup}>
<section
className={clsx(styles.group, { [styles.suggested]: isSuggested })}
data-testid="sendouq-group-card"
@@ -307,18 +302,18 @@ function GroupCardTrailText({
function GroupCardContainer({
isOwnGroup,
groupId,
layout,
children,
}: {
isOwnGroup: boolean;
groupId: number;
layout: "mobile" | "desktop";
children: React.ReactNode;
}) {
if (isOwnGroup) return <>{children}</>;
return <Flipped flipId={`${layout}-${groupId}`}>{children}</Flipped>;
return (
<ViewTransition update="card-update" onUpdate={finishUpdateIfUnmoved}>
{children}
</ViewTransition>
);
}
function GroupMember({

View File

@@ -1,6 +1,5 @@
import clsx from "clsx";
import type * as React from "react";
import { Flipper } from "react-flip-toolkit";
import { useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
import { useLoaderData } from "react-router";
@@ -224,13 +223,10 @@ function Groups() {
const width = useMainContentWidth();
// width === 0 means the main content hasn't been measured yet; rendering the
// Flipper before measurement makes it snapshot the width-0 (mobile) default
// layout and then morph every card into the real layout on first navigation
// width === 0 means the main content hasn't been measured yet
if (!isHydrated || width === 0) return null;
const isMobile = width < IS_Q_LOOKING_MOBILE_BREAKPOINT;
const layout = isMobile ? "mobile" : "desktop";
const isFullGroup =
data.ownGroup && data.ownGroup.members.length === FULL_GROUP_SIZE;
@@ -294,7 +290,6 @@ function Groups() {
trail={trailOf(group.id)}
isSuggested={suggestedGroupIds.has(group.id)}
ownGroup={data.ownGroup}
layout={layout}
/>
);
})}
@@ -333,153 +328,135 @@ function Groups() {
data.likes.received.some((like) => like.groupId === group.id),
);
// no animations needed if liking group on mobile as they stay in place
const flipKey = `${neutralGroups
.map(
(g) =>
`${g.id}-${isMobile ? true : data.likes.given.some((l) => l.groupId === g.id)}`,
)
.join(":")};${groupsReceivedLikesFrom.map((g) => g.id).join(":")}`;
return (
<Flipper flipKey={flipKey}>
<div
className={clsx(styles.container, {
[styles.containerMobile]: isMobile,
})}
>
{!isMobile ? <div>{ownGroupElement}</div> : null}
<div className={styles.innerContainer}>
<SendouTabs>
<SendouTabList>
<SendouTab id="groups" number={neutralGroups.length}>
{t("q:looking.columns.groups")}
<div
className={clsx(styles.container, {
[styles.containerMobile]: isMobile,
})}
>
{!isMobile ? <div>{ownGroupElement}</div> : null}
<div className={styles.innerContainer}>
<SendouTabs>
<SendouTabList>
<SendouTab id="groups" number={neutralGroups.length}>
{t("q:looking.columns.groups")}
</SendouTab>
{isMobile ? (
<SendouTab id="received" number={groupsReceivedLikesFrom.length}>
{t(
isFullGroup
? "q:looking.columns.challenges"
: "q:looking.columns.invitations",
)}
</SendouTab>
{isMobile ? (
<SendouTab
id="received"
number={groupsReceivedLikesFrom.length}
>
{t(
isFullGroup
? "q:looking.columns.challenges"
: "q:looking.columns.invitations",
)}
</SendouTab>
) : null}
{isMobile && data.ownGroup ? (
<SendouTab id="own" number={data.ownGroup.members.length}>
{t("q:looking.columns.myGroup")}
</SendouTab>
) : null}
</SendouTabList>
<SendouTabPanel id="groups">
<div className="stack sm">
<ColumnHeader isMobile={isMobile}>
{t("q:looking.columns.available")}
</ColumnHeader>
{(isMobile
? groups.filter(
(group) =>
!data.likes.received.some(
(like) => like.groupId === group.id,
),
)
: neutralGroups
).map((group) => {
return (
<GroupCard
key={group.id}
group={group}
action={
data.likes.given.some(
(like) => like.groupId === group.id,
)
? "UNLIKE"
: "LIKE"
}
suggestable={isSuggestable(group.id)}
trail={trailOf(group.id)}
isSuggested={suggestedGroupIds.has(group.id)}
ownGroup={data.ownGroup}
layout={layout}
/>
);
})}
</div>
</SendouTabPanel>
<SendouTabPanel id="received">
<div className="stack sm">
{!data.ownGroup ? <JoinQueuePrompt /> : null}
{groupsReceivedLikesFrom.map((group) => {
const like = data.likes.received.find(
(l) => l.groupId === group.id,
)!;
) : null}
{isMobile && data.ownGroup ? (
<SendouTab id="own" number={data.ownGroup.members.length}>
{t("q:looking.columns.myGroup")}
</SendouTab>
) : null}
</SendouTabList>
<SendouTabPanel id="groups">
<div className="stack sm">
<ColumnHeader isMobile={isMobile}>
{t("q:looking.columns.available")}
</ColumnHeader>
{(isMobile
? groups.filter(
(group) =>
!data.likes.received.some(
(like) => like.groupId === group.id,
),
)
: neutralGroups
).map((group) => {
return (
<GroupCard
key={group.id}
group={group}
action={
data.likes.given.some((like) => like.groupId === group.id)
? "UNLIKE"
: "LIKE"
}
suggestable={isSuggestable(group.id)}
trail={trailOf(group.id)}
isSuggested={suggestedGroupIds.has(group.id)}
ownGroup={data.ownGroup}
/>
);
})}
</div>
</SendouTabPanel>
<SendouTabPanel id="received">
<div className="stack sm">
{!data.ownGroup ? <JoinQueuePrompt /> : null}
{groupsReceivedLikesFrom.map((group) => {
const like = data.likes.received.find(
(l) => l.groupId === group.id,
)!;
const likeAction = () => {
if (!isFullGroup) return "GROUP_UP";
const likeAction = () => {
if (!isFullGroup) return "GROUP_UP";
if (like.isRechallenge) return "MATCH_UP_RECHALLENGE";
return "MATCH_UP";
};
if (like.isRechallenge) return "MATCH_UP_RECHALLENGE";
return "MATCH_UP";
};
return (
<GroupCard
key={group.id}
group={group}
action={likeAction()}
suggestable={isSuggestable(group.id)}
trail={trailOf(group.id)}
isSuggested={suggestedGroupIds.has(group.id)}
ownGroup={data.ownGroup}
layout={layout}
/>
);
})}
</div>
</SendouTabPanel>
<SendouTabPanel id="own">{ownGroupElement}</SendouTabPanel>
</SendouTabs>
</div>
{!isMobile ? (
<div className="stack sm">
<ColumnHeader isMobile={isMobile}>
{t(
isFullGroup
? "q:looking.columns.challenges"
: "q:looking.columns.invitations",
)}
</ColumnHeader>
{!data.ownGroup ? <JoinQueuePrompt /> : null}
{groupsReceivedLikesFrom.map((group) => {
const like = data.likes.received.find(
(l) => l.groupId === group.id,
)!;
const likeAction = () => {
if (!isFullGroup) return "GROUP_UP";
if (like.isRechallenge) return "MATCH_UP_RECHALLENGE";
return "MATCH_UP";
};
return (
<GroupCard
key={group.id}
group={group}
action={likeAction()}
suggestable={isSuggestable(group.id)}
trail={trailOf(group.id)}
isSuggested={suggestedGroupIds.has(group.id)}
ownGroup={data.ownGroup}
layout={layout}
/>
);
})}
</div>
) : null}
return (
<GroupCard
key={group.id}
group={group}
action={likeAction()}
suggestable={isSuggestable(group.id)}
trail={trailOf(group.id)}
isSuggested={suggestedGroupIds.has(group.id)}
ownGroup={data.ownGroup}
/>
);
})}
</div>
</SendouTabPanel>
<SendouTabPanel id="own">{ownGroupElement}</SendouTabPanel>
</SendouTabs>
</div>
</Flipper>
{!isMobile ? (
<div className="stack sm">
<ColumnHeader isMobile={isMobile}>
{t(
isFullGroup
? "q:looking.columns.challenges"
: "q:looking.columns.invitations",
)}
</ColumnHeader>
{!data.ownGroup ? <JoinQueuePrompt /> : null}
{groupsReceivedLikesFrom.map((group) => {
const like = data.likes.received.find(
(l) => l.groupId === group.id,
)!;
const likeAction = () => {
if (!isFullGroup) return "GROUP_UP";
if (like.isRechallenge) return "MATCH_UP_RECHALLENGE";
return "MATCH_UP";
};
return (
<GroupCard
key={group.id}
group={group}
action={likeAction()}
suggestable={isSuggestable(group.id)}
trail={trailOf(group.id)}
isSuggested={suggestedGroupIds.has(group.id)}
ownGroup={data.ownGroup}
/>
);
})}
</div>
) : null}
</div>
);
}

View File

@@ -1,7 +1,7 @@
import clsx from "clsx";
import { Edit, Mic, Star, Trash, Volume2, VolumeX } from "lucide-react";
import * as React from "react";
import { Flipped } from "react-flip-toolkit";
import { ViewTransition } from "react";
import { useTranslation } from "react-i18next";
import { ActionButton } from "~/components/ActionButton";
import { Avatar } from "~/components/Avatar";
@@ -13,7 +13,6 @@ import { FormWithConfirm } from "~/components/FormWithConfirm";
import { Image, WeaponImage } from "~/components/Image";
import { NoteAvatar } from "~/components/NoteAvatar";
import { useUser } from "~/features/auth/core/user";
import { IS_Q_LOOKING_MOBILE_BREAKPOINT } from "~/features/sendouq/q-constants";
import { useTournament } from "~/features/tournament/tournament-context";
import {
UserCard,
@@ -21,11 +20,11 @@ import {
} from "~/features/user-card/components/UserCard";
import { SendouForm } from "~/form/SendouForm";
import { useActionSubmit } from "~/hooks/useActionSubmit";
import { useMainContentWidth } from "~/hooks/useMainContentWidth";
import type { UnifiedLanguageCode } from "~/modules/i18n/config";
import { languagesUnified } from "~/modules/i18n/config";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { navIconUrl } from "~/utils/urls";
import { finishUpdateIfUnmoved } from "~/utils/view-transition";
import {
lookingSchema,
updateGroupFormSchema,
@@ -84,7 +83,7 @@ export function LFGGroupCard({
const showOrganizerDelete = !currentMember && tournament.isOrganizer(user);
return (
<LFGGroupCardContainer groupId={group.id} isOwnGroup={isOwnGroup}>
<LFGGroupCardContainer isOwnGroup={isOwnGroup}>
<section className={styles.group}>
{group.teamName ? (
<Divider smallText className={styles.teamHeader}>
@@ -178,19 +177,18 @@ function LFGOrganizerGroupRemover({ group }: { group: LFGGroup }) {
function LFGGroupCardContainer({
isOwnGroup,
groupId,
children,
}: {
isOwnGroup: boolean;
groupId: number;
children: React.ReactNode;
}) {
const width = useMainContentWidth();
const layout = width < IS_Q_LOOKING_MOBILE_BREAKPOINT ? "mobile" : "desktop";
if (isOwnGroup) return <>{children}</>;
return <Flipped flipId={`${layout}-${groupId}`}>{children}</Flipped>;
return (
<ViewTransition update="card-update" onUpdate={finishUpdateIfUnmoved}>
{children}
</ViewTransition>
);
}
function LFGGroupMemberRow({

View File

@@ -1,7 +1,6 @@
import clsx from "clsx";
import { Mic, Trash } from "lucide-react";
import * as React from "react";
import { Flipper } from "react-flip-toolkit";
import { useTranslation } from "react-i18next";
import { useLoaderData } from "react-router";
import { ActionButton } from "~/components/ActionButton";
@@ -100,8 +99,6 @@ function GroupsView({
data.likes.received.some((like) => like.teamId === group.id),
);
const flipKey = `${neutralGroups.map((g) => `${g.id}-${isMobile ? true : data.likes.given.some((l) => l.teamId === g.id)}`).join(":")}:${groupsReceivedLikesFrom.map((g) => g.id).join(":")}`;
const invitedGroupsDesktop = (
<div className="stack sm">
<ColumnHeader>{t("q:looking.columns.invited")}</ColumnHeader>
@@ -139,99 +136,94 @@ function GroupsView({
);
return (
<Flipper flipKey={flipKey}>
<div
className={clsx(styles.container, {
[styles.containerMobile]: isMobile,
})}
>
{!isMobile ? (
<div>
<SendouTabs>
<SendouTabList>
<SendouTab id="own" number={ownTabMemberCount}>
{t("q:looking.columns.myGroup")}
</SendouTab>
</SendouTabList>
<SendouTabPanel id="own">{leftColumnContent}</SendouTabPanel>
</SendouTabs>
</div>
) : null}
<div className={styles.innerContainer}>
<div
className={clsx(styles.container, {
[styles.containerMobile]: isMobile,
})}
>
{!isMobile ? (
<div>
<SendouTabs>
<SendouTabList>
<SendouTab id="groups" number={neutralGroups.length}>
{t("q:looking.columns.groups")}
<SendouTab id="own" number={ownTabMemberCount}>
{t("q:looking.columns.myGroup")}
</SendouTab>
{isMobile ? (
<SendouTab
id="received"
number={groupsReceivedLikesFrom.length}
>
{t("q:looking.columns.invitations")}
</SendouTab>
) : null}
{isMobile ? (
<SendouTab id="own" number={ownTabMemberCount}>
{t("q:looking.columns.myGroup")}
</SendouTab>
) : null}
</SendouTabList>
<SendouTabPanel id="groups">
<div className="stack sm">
<ColumnHeader>{t("q:looking.columns.available")}</ColumnHeader>
{(isMobile
? compatibleGroups.filter(
(group) =>
!data.likes.received.some(
(like) => like.teamId === group.id,
),
)
: neutralGroups
).map((group) => (
<LFGGroupCard
key={group.id}
group={group}
action={
data.likes.given.some((like) => like.teamId === group.id)
? "UNLIKE"
: "LIKE"
}
ownGroup={data.ownGroup ?? undefined}
/>
))}
</div>
</SendouTabPanel>
<SendouTabPanel id="received">
<div className="stack sm">
{groupsReceivedLikesFrom.map((group) => (
<LFGGroupCard
key={group.id}
group={group}
action="ACCEPT"
ownGroup={data.ownGroup ?? undefined}
/>
))}
</div>
</SendouTabPanel>
<SendouTabPanel id="own">{leftColumnContent}</SendouTabPanel>
</SendouTabs>
</div>
{!isMobile ? (
<div className="stack sm">
<ColumnHeader>{t("q:looking.columns.invitations")}</ColumnHeader>
{groupsReceivedLikesFrom.map((group) => (
<LFGGroupCard
key={group.id}
group={group}
action="ACCEPT"
ownGroup={data.ownGroup ?? undefined}
/>
))}
</div>
) : null}
) : null}
<div className={styles.innerContainer}>
<SendouTabs>
<SendouTabList>
<SendouTab id="groups" number={neutralGroups.length}>
{t("q:looking.columns.groups")}
</SendouTab>
{isMobile ? (
<SendouTab id="received" number={groupsReceivedLikesFrom.length}>
{t("q:looking.columns.invitations")}
</SendouTab>
) : null}
{isMobile ? (
<SendouTab id="own" number={ownTabMemberCount}>
{t("q:looking.columns.myGroup")}
</SendouTab>
) : null}
</SendouTabList>
<SendouTabPanel id="groups">
<div className="stack sm">
<ColumnHeader>{t("q:looking.columns.available")}</ColumnHeader>
{(isMobile
? compatibleGroups.filter(
(group) =>
!data.likes.received.some(
(like) => like.teamId === group.id,
),
)
: neutralGroups
).map((group) => (
<LFGGroupCard
key={group.id}
group={group}
action={
data.likes.given.some((like) => like.teamId === group.id)
? "UNLIKE"
: "LIKE"
}
ownGroup={data.ownGroup ?? undefined}
/>
))}
</div>
</SendouTabPanel>
<SendouTabPanel id="received">
<div className="stack sm">
{groupsReceivedLikesFrom.map((group) => (
<LFGGroupCard
key={group.id}
group={group}
action="ACCEPT"
ownGroup={data.ownGroup ?? undefined}
/>
))}
</div>
</SendouTabPanel>
<SendouTabPanel id="own">{leftColumnContent}</SendouTabPanel>
</SendouTabs>
</div>
</Flipper>
{!isMobile ? (
<div className="stack sm">
<ColumnHeader>{t("q:looking.columns.invitations")}</ColumnHeader>
{groupsReceivedLikesFrom.map((group) => (
<LFGGroupCard
key={group.id}
group={group}
action="ACCEPT"
ownGroup={data.ownGroup ?? undefined}
/>
))}
</div>
) : null}
</div>
);
}

View File

@@ -1,6 +0,0 @@
import { useMediaQuery } from "./useMediaQuery";
/** `prefers-reduced-motion` media query; `false` on the server and the first client render. */
export function usePrefersReducedMotion() {
return useMediaQuery("(prefers-reduced-motion: reduce)");
}

View File

@@ -7,10 +7,23 @@
html {
color-scheme: light dark;
/* only elements React names take part in view transitions, the rest of the page swaps instantly */
view-transition-name: none;
accent-color: var(--color-text-accent);
scroll-padding-top: var(--layout-sticky-top);
}
/* the page stays usable while a transition plays */
::view-transition {
pointer-events: none;
}
/* a card React decided not to animate is left as an old-only snapshot that would still
fade for the full duration, holding the next render for a transition of no change */
::view-transition-old(.card-update):only-child {
animation-duration: 0s;
}
body {
width: 100%;
min-height: 100vh;

View File

@@ -534,4 +534,13 @@
max-width: 24rem;
margin-inline: auto;
}
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-image-pair(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none;
}
}
}

View File

@@ -0,0 +1,58 @@
import type { ViewTransitionInstance } from "react";
interface PseudoElement {
getAnimations(): Animation[];
getComputedStyle(): CSSStyleDeclaration;
}
type PseudoElements = Record<
"group" | "imagePair" | "old" | "new",
PseudoElement
>;
const MEASURED_PROPERTIES = ["transform", "width", "height"] as const;
/**
* `onUpdate` handler for a `<ViewTransition>` that ends its animation right away when the
* boundary kept its size and position. React starts an update transition for any DOM change
* inside the boundary (e.g. a button going disabled) and holds the next render until it ends,
* so without this a full animation of no visible change would delay every server response.
*/
export function finishUpdateIfUnmoved(instance: ViewTransitionInstance) {
const pseudos = instance as ViewTransitionInstance & PseudoElements;
const groupAnimation = pseudos.group.getAnimations()[0];
if (!groupAnimation || hasMoved(groupAnimation, pseudos.group)) return;
for (const pseudo of [
pseudos.group,
pseudos.imagePair,
pseudos.old,
pseudos.new,
]) {
for (const animation of pseudo.getAnimations()) {
animation.finish();
}
}
}
function hasMoved(groupAnimation: Animation, group: PseudoElement) {
const effect = groupAnimation.effect;
if (!(effect instanceof KeyframeEffect)) return true;
const [from] = effect.getKeyframes();
if (!from) return true;
// while the animation plays the computed style is the animated value, so the end
// state is only readable with the animation seeked to its end
const startTime = groupAnimation.currentTime;
groupAnimation.currentTime = effect.getComputedTiming().endTime ?? 0;
const style = group.getComputedStyle();
const to = MEASURED_PROPERTIES.map((property) =>
style.getPropertyValue(property),
);
groupAnimation.currentTime = startTime;
return MEASURED_PROPERTIES.some(
(property, i) => property in from && from[property] !== to[i],
);
}

View File

@@ -90,11 +90,10 @@
"p-limit": "7.3.1",
"picocad2-web": "2.0.2",
"qrcode.react": "4.2.0",
"react": "19.2.8",
"react": "19.3.0",
"react-chartjs-2": "5.3.1",
"react-dom": "19.2.8",
"react-dom": "19.3.0",
"react-error-boundary": "6.1.3",
"react-flip-toolkit": "7.2.4",
"react-i18next": "17.0.11",
"react-router": "8.3.0",
"remeda": "2.45.0",
@@ -116,8 +115,8 @@
"@react-router/dev": "8.3.0",
"@types/node": "26.4.0",
"@types/nprogress": "0.2.3",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.5",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@types/web-push": "3.6.4",
"@vitest/browser-playwright": "4.1.11",
"@vitest/ui": "4.1.11",

910
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -137,7 +137,6 @@ export default defineConfig((config) => {
"picocad2-web",
"qrcode.react",
"react-chartjs-2",
"react-flip-toolkit",
"remeda",
"remix-auth",
"remix-auth-oauth2",