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