mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-08 20:26:08 -05:00
Drive layout breakpoints with media and container queries instead of resize listeners
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
17
app/hooks/useLayoutSize.ts
Normal file
17
app/hooks/useLayoutSize.ts
Normal 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";
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
25
app/hooks/useMediaQuery.ts
Normal file
25
app/hooks/useMediaQuery.ts
Normal 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;
|
||||
}
|
||||
@@ -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)");
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user