Drive layout breakpoints with media and container queries instead of resize listeners
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-02 22:04:54 +03:00
parent b67ea59bde
commit 10feace73d
16 changed files with 87 additions and 146 deletions

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 (
<div className={styles.sidebar}>
<div className={styles.chatHeader}>
@@ -482,35 +469,26 @@ function CombinedChatView({
) : null}
</div>
<div className={styles.splitView}>
{panels.map(({ room, grow, showHeader }) => (
<SplitPanel
key={room.id}
room={room}
grow={grow}
showHeader={showHeader}
/>
{rooms.map((room, index) => (
<SplitPanel key={room.id} room={room} showHeader={index > 0} />
))}
</div>
</div>
);
}
/** 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 (
<div
className={styles.splitPanel}
style={{ "--split-grow": grow } as React.CSSProperties}
>
<div className={styles.splitPanel}>
{showHeader ? (
<div className={styles.splitPanelHeader}>{roomShortLabel(room, t)}</div>
) : null}

View File

@@ -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;
}

View File

@@ -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<HTMLElement | null>) {
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<HTMLElement | null>) {
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;
}

View File

@@ -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";

View File

@@ -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() {

View File

@@ -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,

View File

@@ -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;
}
}

View File

@@ -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 {

View File

@@ -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";
}

View File

@@ -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;

View File

@@ -0,0 +1,25 @@
import * as React from "react";
const subscribers = new Map<string, (onChange: () => 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;
}

View File

@@ -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)");
}

View File

@@ -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 };
}

View File

@@ -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 (
<div>
@@ -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<Breakpoint>(
(current, [name, minWidth]) =>
width >= minWidth ? (name as Breakpoint) : current,
ascending[0][0] as Breakpoint,
);
if (threeColumns) return 3;
if (twoColumns) return 2;
return 1;
}