From 10feace73dc2935bcc1a524282d12a086a19e573 Mon Sep 17 00:00:00 2001
From: Kalle <38327916+Sendouc@users.noreply.github.com>
Date: Wed, 2 Sep 2026 22:04:54 +0300
Subject: [PATCH] Drive layout breakpoints with media and container queries
instead of resize listeners
---
app/components/MobileNav.tsx | 2 +-
app/components/layout/ChatSidebar.module.css | 13 +++++--
app/components/layout/ChatSidebar.tsx | 30 +++-------------
app/components/layout/GlobalSearch.module.css | 3 +-
app/components/layout/index.tsx | 29 ++++++---------
app/features/chat/ChatProvider.tsx | 2 +-
.../components/ImageExportDialog.tsx | 13 ++-----
.../components/CustomFlowBuilder.tsx | 2 +-
.../TournamentMatchAdminTab.module.css | 2 +-
.../user-card/components/UserCard.tsx | 2 +-
app/hooks/useLayoutSize.ts | 17 +++++++++
app/hooks/useMainContentWidth.ts | 15 --------
app/hooks/useMediaQuery.ts | 25 +++++++++++++
app/hooks/usePrefersReducedMotion.ts | 16 ++-------
app/hooks/useWindowSize.ts | 27 --------------
.../components/ResponsiveMasonry.tsx | 35 ++++++-------------
16 files changed, 87 insertions(+), 146 deletions(-)
create mode 100644 app/hooks/useLayoutSize.ts
create mode 100644 app/hooks/useMediaQuery.ts
delete mode 100644 app/hooks/useWindowSize.ts
diff --git a/app/components/MobileNav.tsx b/app/components/MobileNav.tsx
index e90e880b1..3f6280dfd 100644
--- a/app/components/MobileNav.tsx
+++ b/app/components/MobileNav.tsx
@@ -22,7 +22,7 @@ import { useChatContext } from "~/features/chat/ChatProvider";
import { FriendMenu } from "~/features/friends/components/FriendMenu";
import { SENDOUQ_ACTIVITY_LABEL } from "~/features/friends/friends-constants";
import { canAccessTrophies } from "~/features/trophies/trophies-utils";
-import { useLayoutSize } from "~/hooks/useMainContentWidth";
+import { useLayoutSize } from "~/hooks/useLayoutSize";
import { useUnseenFriendRequests } from "~/hooks/useUnseenFriendRequests";
import type { RootLoaderData } from "~/root";
import {
diff --git a/app/components/layout/ChatSidebar.module.css b/app/components/layout/ChatSidebar.module.css
index 02f16a025..b069271a2 100644
--- a/app/components/layout/ChatSidebar.module.css
+++ b/app/components/layout/ChatSidebar.module.css
@@ -215,9 +215,7 @@
}
.splitPanel {
- flex-grow: var(--split-grow, 1);
- flex-shrink: 1;
- flex-basis: 0;
+ flex: 1 1 0;
min-height: 0;
display: flex;
flex-direction: column;
@@ -226,6 +224,15 @@
& + & {
border-top: 1.5px solid var(--color-border);
}
+
+ /* desktop splits evenly, mobile gives the match chat on top 3/5 */
+ @media (width < 600px) {
+ flex-grow: 3;
+
+ & + & {
+ flex-grow: 2;
+ }
+ }
}
.splitPanelHeader {
diff --git a/app/components/layout/ChatSidebar.tsx b/app/components/layout/ChatSidebar.tsx
index 60936b076..d8cf58d08 100644
--- a/app/components/layout/ChatSidebar.tsx
+++ b/app/components/layout/ChatSidebar.tsx
@@ -18,7 +18,6 @@ import {
import type { ChatRoomListItem } from "~/features/chat/chat-types";
import { Chat } from "~/features/chat/components/Chat";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
-import { useLayoutSize } from "~/hooks/useMainContentWidth";
import {
databaseTimestampToDate,
dateToDatabaseTimestamp,
@@ -432,7 +431,6 @@ function CombinedChatView({
}) {
const chatContext = useChatContext()!;
const roomDisplay = useRoomDisplay();
- const isMobile = useLayoutSize() === "mobile";
const primary = rooms[0];
const display = roomDisplay(primary);
@@ -448,17 +446,6 @@ function CombinedChatView({
>
);
- // primary (match) sits on top with its sub-header hidden, the main header already names it;
- // desktop splits evenly, mobile gives the match chat 3/5
- const panels = [
- { room: primary, grow: isMobile ? 3 : 1, showHeader: false },
- ...rooms.slice(1).map((room) => ({
- room,
- grow: isMobile ? 2 : 1,
- showHeader: true,
- })),
- ];
-
return (
@@ -482,35 +469,26 @@ function CombinedChatView({
) : null}
- {panels.map(({ room, grow, showHeader }) => (
-
+ {rooms.map((room, index) => (
+ 0} />
))}
);
}
+/** The primary (match) room sits on top with its sub-header hidden, the main header already names it. */
function SplitPanel({
room,
- grow,
showHeader,
}: {
room: ChatRoomListItem;
- grow: number;
showHeader: boolean;
}) {
const { t } = useTranslation(["common"]);
return (
-
+
{showHeader ? (
{roomShortLabel(room, t)}
) : null}
diff --git a/app/components/layout/GlobalSearch.module.css b/app/components/layout/GlobalSearch.module.css
index de76fcba2..9406c14f6 100644
--- a/app/components/layout/GlobalSearch.module.css
+++ b/app/components/layout/GlobalSearch.module.css
@@ -195,8 +195,7 @@
object-fit: cover;
}
-/** needs to go away so we have enough space even with both side panels open */
-@media screen and (max-width: 1100px) {
+@container (width < 660px) {
.searchKbd {
display: none;
}
diff --git a/app/components/layout/index.tsx b/app/components/layout/index.tsx
index 20fe90141..1903d9a46 100644
--- a/app/components/layout/index.tsx
+++ b/app/components/layout/index.tsx
@@ -27,7 +27,8 @@ import { FriendMenu } from "~/features/friends/components/FriendMenu";
import { useLayoutData } from "~/features/layout/LayoutDataProvider";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { useHydrated } from "~/hooks/useHydrated";
-import { useLayoutSize } from "~/hooks/useMainContentWidth";
+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";
@@ -156,25 +157,24 @@ function useTabletModal(isTabletLayout: boolean) {
return [isOpen, setIsOpen] as const;
}
+/** Hides the mobile header while scrolling down and brings it back on scrolling up; always `0` outside the mobile layout. */
function useNavOffset(headerRef: React.RefObject
) {
const [navOffset, setNavOffset] = React.useState(0);
const lastScrollY = React.useRef(0);
+ const isMobileLayout = useMediaQuery(MOBILE_LAYOUT_QUERY);
- const MOBILE_BREAKPOINT = 600;
const NAV_HEIGHT_FALLBACK = 55;
const SCROLL_THRESHOLD_PX = 200;
const scrollAccumulator = React.useRef(0);
React.useEffect(() => {
- const handleScroll = () => {
- if (window.innerWidth >= MOBILE_BREAKPOINT) {
- setNavOffset(0);
- lastScrollY.current = window.scrollY;
- scrollAccumulator.current = 0;
- return;
- }
+ if (!isMobileLayout) return;
+ lastScrollY.current = window.scrollY;
+ scrollAccumulator.current = 0;
+
+ const handleScroll = () => {
const navHeight = headerRef.current?.offsetHeight ?? NAV_HEIGHT_FALLBACK;
const currentScrollY = window.scrollY;
const scrollDelta = currentScrollY - lastScrollY.current;
@@ -209,20 +209,13 @@ function useNavOffset(headerRef: React.RefObject) {
lastScrollY.current = currentScrollY;
};
- const handleResize = () => {
- if (window.innerWidth >= MOBILE_BREAKPOINT) {
- setNavOffset(0);
- }
- };
-
window.addEventListener("scroll", handleScroll, { passive: true });
- window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("scroll", handleScroll);
- window.removeEventListener("resize", handleResize);
+ setNavOffset(0);
};
- }, [headerRef]);
+ }, [headerRef, isMobileLayout]);
return navOffset;
}
diff --git a/app/features/chat/ChatProvider.tsx b/app/features/chat/ChatProvider.tsx
index 04a09e94a..c17a65b1e 100644
--- a/app/features/chat/ChatProvider.tsx
+++ b/app/features/chat/ChatProvider.tsx
@@ -7,7 +7,7 @@ import {
useEventsConnection,
} from "~/features/events/events-hooks";
import { chatRoomChannel } from "~/features/events/events-types";
-import { useLayoutSize } from "~/hooks/useMainContentWidth";
+import { useLayoutSize } from "~/hooks/useLayoutSize";
import type { LoggedInUser } from "~/root";
import { type ChatSnapshot, chatClient } from "./chat-client";
import { useServerRevalidationEvents } from "./chat-hooks";
diff --git a/app/features/img-export/components/ImageExportDialog.tsx b/app/features/img-export/components/ImageExportDialog.tsx
index 159198df7..2b5549b8e 100644
--- a/app/features/img-export/components/ImageExportDialog.tsx
+++ b/app/features/img-export/components/ImageExportDialog.tsx
@@ -11,6 +11,7 @@ import { SendouDialog } from "~/components/elements/Dialog";
import { SendouSwitch } from "~/components/elements/Switch";
import { useTheme } from "~/features/theme/core/provider";
import { useCopyPngToClipboard } from "~/hooks/useCopyToClipboard";
+import { useMediaQuery } from "~/hooks/useMediaQuery";
import { SENDOU_INK_BASE_URL } from "~/utils/urls";
import { GraphicQrCodeContext } from "./Graphic";
import styles from "./ImageExportDialog.module.css";
@@ -269,18 +270,8 @@ async function saveImage(
URL.revokeObjectURL(url);
}
-function subscribeToPointerQuery(callback: () => void) {
- const mediaQueryList = window.matchMedia(COARSE_POINTER_QUERY);
- mediaQueryList.addEventListener("change", callback);
- return () => mediaQueryList.removeEventListener("change", callback);
-}
-
function useIsMobile() {
- return React.useSyncExternalStore(
- subscribeToPointerQuery,
- () => window.matchMedia(COARSE_POINTER_QUERY).matches,
- () => false,
- );
+ return useMediaQuery(COARSE_POINTER_QUERY);
}
function usePageHasCustomTheme() {
diff --git a/app/features/tournament-bracket/components/CustomFlowBuilder.tsx b/app/features/tournament-bracket/components/CustomFlowBuilder.tsx
index 9d5d5ba18..44756b876 100644
--- a/app/features/tournament-bracket/components/CustomFlowBuilder.tsx
+++ b/app/features/tournament-bracket/components/CustomFlowBuilder.tsx
@@ -42,7 +42,7 @@ import {
WHO_SIDES,
type WhoSide,
} from "~/features/tournament-bracket/tournament-bracket-constants";
-import { useLayoutSize } from "~/hooks/useMainContentWidth";
+import { useLayoutSize } from "~/hooks/useLayoutSize";
import {
type CustomFlowValidationError,
validateCustomFlowSection,
diff --git a/app/features/tournament-match/components/TournamentMatchAdminTab.module.css b/app/features/tournament-match/components/TournamentMatchAdminTab.module.css
index 64bb85fa2..0eb9b402b 100644
--- a/app/features/tournament-match/components/TournamentMatchAdminTab.module.css
+++ b/app/features/tournament-match/components/TournamentMatchAdminTab.module.css
@@ -86,7 +86,7 @@
grid-template-columns: repeat(2, 1fr);
gap: var(--s-3);
- @media (max-width: 640px) {
+ @container (width < 640px) {
grid-template-columns: 1fr;
}
}
diff --git a/app/features/user-card/components/UserCard.tsx b/app/features/user-card/components/UserCard.tsx
index c31722fb9..f7d493047 100644
--- a/app/features/user-card/components/UserCard.tsx
+++ b/app/features/user-card/components/UserCard.tsx
@@ -35,7 +35,7 @@ import { userCardEditPage } from "~/features/user-card/user-card-urls";
import { MutualFriends } from "~/features/user-page/components/MutualFriends";
import { ReportUserDialog } from "~/features/user-report/components/ReportUserDialog";
import { useActionSubmit } from "~/hooks/useActionSubmit";
-import { useLayoutSize } from "~/hooks/useMainContentWidth";
+import { useLayoutSize } from "~/hooks/useLayoutSize";
import type { BrandId } from "~/modules/in-game-lists/types";
import { assertUnreachable } from "~/utils/types";
import {
diff --git a/app/hooks/useLayoutSize.ts b/app/hooks/useLayoutSize.ts
new file mode 100644
index 000000000..05c66467d
--- /dev/null
+++ b/app/hooks/useLayoutSize.ts
@@ -0,0 +1,17 @@
+import { useMediaQuery } from "./useMediaQuery";
+
+/** Below this the mobile tab bar layout applies; keep in sync with the `600px` media queries of the layout CSS. */
+export const MOBILE_LAYOUT_QUERY = "(width < 600px)";
+const DESKTOP_LAYOUT_QUERY = "(width >= 1000px)";
+
+type LayoutSize = "mobile" | "tablet" | "desktop";
+
+/** Which of the three site layouts the viewport is in. `"desktop"` on the server and the hydration render. */
+export function useLayoutSize(): LayoutSize {
+ const isMobile = useMediaQuery(MOBILE_LAYOUT_QUERY);
+ const isDesktop = useMediaQuery(DESKTOP_LAYOUT_QUERY, true);
+
+ if (isMobile) return "mobile";
+ if (isDesktop) return "desktop";
+ return "tablet";
+}
diff --git a/app/hooks/useMainContentWidth.ts b/app/hooks/useMainContentWidth.ts
index 48cfb3897..72bb85979 100644
--- a/app/hooks/useMainContentWidth.ts
+++ b/app/hooks/useMainContentWidth.ts
@@ -1,19 +1,4 @@
import * as React from "react";
-import { useWindowSize } from "./useWindowSize";
-
-const MOBILE_BREAKPOINT = 600;
-const DESKTOP_BREAKPOINT = 1000;
-
-type LayoutSize = "mobile" | "tablet" | "desktop";
-
-export function useLayoutSize(): LayoutSize {
- const { width } = useWindowSize();
-
- if (width === 0) return "desktop";
- if (width < MOBILE_BREAKPOINT) return "mobile";
- if (width < DESKTOP_BREAKPOINT) return "tablet";
- return "desktop";
-}
const listeners = new Set<() => void>();
let observer: ResizeObserver | null = null;
diff --git a/app/hooks/useMediaQuery.ts b/app/hooks/useMediaQuery.ts
new file mode 100644
index 000000000..636f4c1df
--- /dev/null
+++ b/app/hooks/useMediaQuery.ts
@@ -0,0 +1,25 @@
+import * as React from "react";
+
+const subscribers = new Map void) => () => void>();
+
+/** Whether the media `query` matches, re-rendering only when the match flips. `serverValue` on the server and the hydration render. */
+export function useMediaQuery(query: string, serverValue = false) {
+ return React.useSyncExternalStore(
+ subscriberFor(query),
+ () => window.matchMedia(query).matches,
+ () => serverValue,
+ );
+}
+
+function subscriberFor(query: string) {
+ let subscribe = subscribers.get(query);
+ if (!subscribe) {
+ subscribe = (onChange) => {
+ const mediaQueryList = window.matchMedia(query);
+ mediaQueryList.addEventListener("change", onChange);
+ return () => mediaQueryList.removeEventListener("change", onChange);
+ };
+ subscribers.set(query, subscribe);
+ }
+ return subscribe;
+}
diff --git a/app/hooks/usePrefersReducedMotion.ts b/app/hooks/usePrefersReducedMotion.ts
index cc7ce4b34..823bef20c 100644
--- a/app/hooks/usePrefersReducedMotion.ts
+++ b/app/hooks/usePrefersReducedMotion.ts
@@ -1,18 +1,6 @@
-import * as React from "react";
-
-const QUERY = "(prefers-reduced-motion: reduce)";
-
-function subscribe(callback: () => void) {
- const mediaQueryList = window.matchMedia(QUERY);
- mediaQueryList.addEventListener("change", callback);
- return () => mediaQueryList.removeEventListener("change", callback);
-}
+import { useMediaQuery } from "./useMediaQuery";
/** `prefers-reduced-motion` media query; `false` on the server and the first client render. */
export function usePrefersReducedMotion() {
- return React.useSyncExternalStore(
- subscribe,
- () => window.matchMedia(QUERY).matches,
- () => false,
- );
+ return useMediaQuery("(prefers-reduced-motion: reduce)");
}
diff --git a/app/hooks/useWindowSize.ts b/app/hooks/useWindowSize.ts
deleted file mode 100644
index c2bd359b3..000000000
--- a/app/hooks/useWindowSize.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import * as React from "react";
-
-interface WindowSize {
- width: number;
- height: number;
-}
-
-function subscribe(listener: () => void) {
- window.addEventListener("resize", listener);
- return () => window.removeEventListener("resize", listener);
-}
-
-/** Window dimensions, re-rendering on resize. `0` on the server and the hydration render. */
-export function useWindowSize(): WindowSize {
- const width = React.useSyncExternalStore(
- subscribe,
- () => window.innerWidth,
- () => 0,
- );
- const height = React.useSyncExternalStore(
- subscribe,
- () => window.innerHeight,
- () => 0,
- );
-
- return { width, height };
-}
diff --git a/app/modules/responsive-masonry/components/ResponsiveMasonry.tsx b/app/modules/responsive-masonry/components/ResponsiveMasonry.tsx
index 3180c2b94..013a7a45c 100644
--- a/app/modules/responsive-masonry/components/ResponsiveMasonry.tsx
+++ b/app/modules/responsive-masonry/components/ResponsiveMasonry.tsx
@@ -1,31 +1,18 @@
// adapted from https://github.com/cedricdelpoux/react-responsive-masonry
import React from "react";
-import { useWindowSize } from "~/hooks/useWindowSize";
+import { useMediaQuery } from "~/hooks/useMediaQuery";
import Masonry from "./Masonry";
-const COLUMN_COUNTS = {
- L: 3,
- M: 2,
- S: 1,
-};
-
-const BREAKPOINTS = {
- L: 900,
- M: 750,
- S: 350,
-} as const;
-
-type Breakpoint = keyof typeof BREAKPOINTS;
+const THREE_COLUMNS_QUERY = "(width >= 900px)";
+const TWO_COLUMNS_QUERY = "(width >= 750px)";
const MasonryResponsive = ({
children,
}: {
children: React.ReactNode | React.ReactNode[];
}) => {
- const breakpoint = useBreakpoint();
-
- const columnsCount = COLUMN_COUNTS[breakpoint];
+ const columnsCount = useColumnsCount();
return (
@@ -47,13 +34,11 @@ export function ResponsiveMasonry({ children }: { children: React.ReactNode }) {
);
}
-function useBreakpoint(): Breakpoint {
- const { width } = useWindowSize();
+function useColumnsCount() {
+ const threeColumns = useMediaQuery(THREE_COLUMNS_QUERY);
+ const twoColumns = useMediaQuery(TWO_COLUMNS_QUERY);
- const ascending = Object.entries(BREAKPOINTS).sort((a, b) => a[1] - b[1]);
- return ascending.reduce(
- (current, [name, minWidth]) =>
- width >= minWidth ? (name as Breakpoint) : current,
- ascending[0][0] as Breakpoint,
- );
+ if (threeColumns) return 3;
+ if (twoColumns) return 2;
+ return 1;
}