From 0152e5510e7663e57a3aee9accd8d899e6f89a54 Mon Sep 17 00:00:00 2001 From: bangdayeon Date: Wed, 12 Aug 2026 12:11:52 +0900 Subject: [PATCH] Design: mobile alllink design (#571) --- src/app/(route)/all-link/AllLink.tsx | 45 ++++-- src/app/(route)/chat/[id]/ChatPage.tsx | 8 + src/components/basics/CardList/CardList.tsx | 6 +- .../InfiniteScroll/InfiniteScroll.style.ts | 14 -- .../basics/InfiniteScroll/InfiniteScroll.tsx | 116 +++++++++----- src/components/basics/LinkCard/LinkCard.tsx | 6 +- src/components/basics/Modal/Modal.tsx | 2 +- .../layout/SideNavigation/SideNavigation.tsx | 16 +- .../LinkCardDetailPanel/DetailPanelShell.tsx | 149 ++++++++++++++++++ .../LinkCardDetailPanel.style.ts | 4 +- .../LinkCardDetailPanel.tsx | 39 +++-- .../Sections/HeaderSection.tsx | 10 +- src/stores/linkStore.ts | 5 + src/stories/LinkCard.stories.tsx | 8 + src/styles/globals.css | 11 ++ 15 files changed, 337 insertions(+), 102 deletions(-) delete mode 100644 src/components/basics/InfiniteScroll/InfiniteScroll.style.ts create mode 100644 src/components/wrappers/LinkCardDetailPanel/DetailPanelShell.tsx diff --git a/src/app/(route)/all-link/AllLink.tsx b/src/app/(route)/all-link/AllLink.tsx index 281e9da5..b6a9ec20 100644 --- a/src/app/(route)/all-link/AllLink.tsx +++ b/src/app/(route)/all-link/AllLink.tsx @@ -10,6 +10,7 @@ import InfiniteScroll from '@/components/basics/InfiniteScroll/InfiniteScroll'; import LinkCard from '@/components/basics/LinkCard/LinkCard'; import DeleteLinkModal from '@/components/basics/LinkCard/components/DeleteLinkModal'; import Spinner from '@/components/basics/Spinner/Spinner'; +import DetailPanelShell from '@/components/wrappers/LinkCardDetailPanel/DetailPanelShell'; import LinkCardDetailPanel from '@/components/wrappers/LinkCardDetailPanel/LinkCardDetailPanel'; import { useGetInfiniteLinks } from '@/hooks/useGetInfiniteLinks'; import { useGetLink } from '@/hooks/useGetLink'; @@ -144,7 +145,7 @@ const LinkCardItem = memo( ); export default function AllLink() { - const { selectedLinkId, selectLink } = useLinkStore(); + const { selectedLinkId, selectLink, setDetailPanelOpen } = useLinkStore(); const [isPanelOpen, setIsPanelOpen] = useState(false); const [isSocketConnected, setIsSocketConnected] = useState(false); const [selectedIds, setSelectedIds] = useState>(new Set()); @@ -697,6 +698,17 @@ export default function AllLink() { } }, [selectedLinkId]); + // 모바일에서 상세 패널이 화면 전체를 덮으므로, 사이드네비 트리거를 숨기도록 전역에 알린다. + useEffect(() => { + setDetailPanelOpen(isPanelOpen); + return () => setDetailPanelOpen(false); + }, [isPanelOpen, setDetailPanelOpen]); + + const handleClosePanel = useCallback(() => { + setIsPanelOpen(false); + selectLink(null); + }, [selectLink]); + const handleToggleSelect = useCallback((id: EntityId) => { setSelectedIds(prev => { const next = new Set(prev); @@ -786,9 +798,10 @@ export default function AllLink() { return (
-
+
-
+ {/* pl-12: 모바일에서 fixed 사이드네비 트리거(좌상단 20~60px)와 겹치지 않도록 비켜준다 */} +

전체 링크

({count ?? links.length})

@@ -808,7 +821,7 @@ export default function AllLink() { ) : ( item.id} renderItem={renderItem} @@ -823,15 +836,20 @@ export default function AllLink() { {isPanelOpen && ( )} diff --git a/src/app/(route)/chat/[id]/ChatPage.tsx b/src/app/(route)/chat/[id]/ChatPage.tsx index 7af6ed0b..299e1d25 100644 --- a/src/app/(route)/chat/[id]/ChatPage.tsx +++ b/src/app/(route)/chat/[id]/ChatPage.tsx @@ -12,6 +12,7 @@ import ReportModal from '@/components/wrappers/ReportModal/ReportModal'; import { useChatStream } from '@/hooks/server/Chats/useChatStream'; import useKeyboardInset from '@/hooks/util/useKeyboardInset'; import { trackEvent, trackQueryFeedback } from '@/lib/client/analytics'; +import { useLinkStore } from '@/stores/linkStore'; import { useModalStore } from '@/stores/modalStore'; import { showToast } from '@/stores/toastStore'; import type { ChatHistoryMessage } from '@/types/api/chatApi'; @@ -101,6 +102,13 @@ export default function Chat() { const [isAwaitingResponse, setIsAwaitingResponse] = useState(false); const [streamError, setStreamError] = useState(null); const [selectedLink, setSelectedLink] = useState(null); + const setDetailPanelOpen = useLinkStore(state => state.setDetailPanelOpen); + + // 모바일에서 상세 패널이 화면 전체를 덮으므로, 사이드네비 트리거를 숨기도록 전역에 알린다. + useEffect(() => { + setDetailPanelOpen(selectedLink !== null); + return () => setDetailPanelOpen(false); + }, [selectedLink, setDetailPanelOpen]); const [historyCursor, setHistoryCursor] = useState(null); const [historyHasNext, setHistoryHasNext] = useState(false); const [historyLoading, setHistoryLoading] = useState(false); diff --git a/src/components/basics/CardList/CardList.tsx b/src/components/basics/CardList/CardList.tsx index 3551d7ee..f74fa6ac 100644 --- a/src/components/basics/CardList/CardList.tsx +++ b/src/components/basics/CardList/CardList.tsx @@ -2,8 +2,6 @@ import React from 'react'; -// LinkCard Width = 48 (192px) - interface CardListProps { children: React.ReactNode; } @@ -11,6 +9,8 @@ interface CardListProps { export default function CardList({ children }: CardListProps) { // 답변 말풍선 안에 들어가므로 모바일에서는 2열로 두면 카드가 찌그러진다. return ( -
{children}
+
+ {children} +
); } diff --git a/src/components/basics/InfiniteScroll/InfiniteScroll.style.ts b/src/components/basics/InfiniteScroll/InfiniteScroll.style.ts deleted file mode 100644 index 6ef86a09..00000000 --- a/src/components/basics/InfiniteScroll/InfiniteScroll.style.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { tv } from 'tailwind-variants'; - -export const styles = tv({ - slots: { - root: 'relative w-full', - list: 'contents', - statusRow: 'flex items-center justify-center py-3 text-sm text-gray-500', - loader: 'animate-pulse', - end: 'text-gray-500', - error: 'flex items-center gap-2 text-red-500', - sentinel: 'h-1 w-full', - spinner: 'infinite-spinner mr-2', - }, -}); diff --git a/src/components/basics/InfiniteScroll/InfiniteScroll.tsx b/src/components/basics/InfiniteScroll/InfiniteScroll.tsx index 5e3071ec..d4f93fdd 100644 --- a/src/components/basics/InfiniteScroll/InfiniteScroll.tsx +++ b/src/components/basics/InfiniteScroll/InfiniteScroll.tsx @@ -2,15 +2,53 @@ import { useVirtualizer } from '@tanstack/react-virtual'; import clsx from 'clsx'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import Spinner from '../Spinner/Spinner'; -import { styles } from './InfiniteScroll.style'; -const { root: rootCls, statusRow, loader: loaderCls, end, error } = styles(); +export type ColumnConfig = { + /** < 768px */ + base?: number; + /** >= 768px (Tailwind md) */ + md?: number; + /** >= 1024px (Tailwind lg) */ + lg?: number; +}; + +const DEFAULT_COLUMNS: ColumnConfig = { base: 2, md: 3, lg: 4 }; +const ROW_ASPECT_RATIO = 232 / 182; // LinkCard 실제 비율 (aspect-[182/232]) +const GRID_PADDING_X = 8; // 그리드 좌우 px-1 = 4 + 4 +const COLUMN_GAP = 16; + +/** + * 뷰포트 기준 컬럼 수. + * 컨테이너 폭으로 나누면 좌우 여백만큼 항상 작게 잡혀 Tailwind 브레이크포인트와 + * 어긋나므로(390px 화면에서 컨테이너는 약 270px) matchMedia로 뷰포트를 직접 본다. + */ +function useResponsiveColumns({ base = 2, md = 3, lg = 4 }: ColumnConfig) { + const [currentColumns, setCurrentColumns] = useState(base); + + useLayoutEffect(() => { + const lgQuery = window.matchMedia('(min-width: 1024px)'); + const mdQuery = window.matchMedia('(min-width: 768px)'); + + const resolve = () => { + const next = lgQuery.matches ? lg : mdQuery.matches ? md : base; + setCurrentColumns(prev => (prev === next ? prev : next)); + }; + + resolve(); + lgQuery.addEventListener('change', resolve); + mdQuery.addEventListener('change', resolve); -const DEFAULT_COLUMNS = { mobile: 2, desktop: 4 }; -const ROW_ASPECT_RATIO = 1.3; // Estimated aspect ratio for a row of cards (58/47) + return () => { + lgQuery.removeEventListener('change', resolve); + mdQuery.removeEventListener('change', resolve); + }; + }, [base, md, lg]); + + return currentColumns; +} export type InfiniteScrollProps = Omit, 'children'> & { items: T[]; @@ -24,10 +62,7 @@ export type InfiniteScrollProps = Omit, endMessage?: React.ReactNode; errorSlot?: (msg: string) => React.ReactNode; rowGap?: number; - columns?: { - mobile?: number; - desktop?: number; - }; + columns?: ColumnConfig; }; function InfiniteScrollInner( @@ -50,7 +85,7 @@ function InfiniteScrollInner( ref: React.ForwardedRef ) { const scrollContainerRef = useRef(null); - const [currentColumns, setCurrentColumns] = useState(columns.mobile || 2); + const currentColumns = useResponsiveColumns(columns); const [containerWidth, setContainerWidth] = useState(0); const isFetchingRef = useRef(false); const controllerRef = useRef(null); @@ -91,22 +126,19 @@ function InfiniteScrollInner( [ref] ); - // Update columns based on container width + // 행 높이 추정에 쓸 컨테이너 폭만 측정한다 (컬럼 수는 뷰포트 기준으로 따로 계산). useEffect(() => { const el = scrollContainerRef.current; if (!el) return; const observer = new ResizeObserver(entries => { const width = entries[0].contentRect.width; - setContainerWidth(width); - - const newCols = width >= 768 ? columns.desktop || 4 : columns.mobile || 2; - setCurrentColumns(prev => (prev === newCols ? prev : newCols)); + setContainerWidth(prev => (prev === width ? prev : width)); }); observer.observe(el); return () => observer.disconnect(); - }, [columns.desktop, columns.mobile]); + }, []); // Group items into rows for grid virtualization const rows = useMemo(() => { @@ -120,8 +152,8 @@ function InfiniteScrollInner( const estimateRowSize = useCallback(() => { if (!containerWidth) return 300; // 초기 안전값 - const effectiveWidth = containerWidth - 32; - const itemWidth = (effectiveWidth - (currentColumns - 1) * 16) / currentColumns; + const effectiveWidth = containerWidth - GRID_PADDING_X; + const itemWidth = (effectiveWidth - (currentColumns - 1) * COLUMN_GAP) / currentColumns; return itemWidth * ROW_ASPECT_RATIO + rowGap; }, [containerWidth, currentColumns, rowGap]); @@ -160,7 +192,7 @@ function InfiniteScrollInner(
( style={{ display: 'grid', gridTemplateColumns: `repeat(${currentColumns}, minmax(0, 1fr))`, - columnGap: '16px', // gap-x-4 - paddingLeft: '1rem', - paddingRight: '1rem', + columnGap: `${COLUMN_GAP}px`, + paddingLeft: `${GRID_PADDING_X / 2}px`, // px-1 + paddingRight: `${GRID_PADDING_X / 2}px`, }} > {rows[virtualRow.index]?.map((item, colIndex) => { @@ -212,23 +244,29 @@ function InfiniteScrollInner( ))}
- {/* Status Indicators at the bottom of the scroll content */} -
- {isLoading && (loader || )} - - {!hasMore && items.length > 0 && ( -
- {endMessage || '모든 콘텐츠를 불러왔습니다.'} -
- )} - - {errorMessage && - (errorSlot ? ( - errorSlot(errorMessage) - ) : ( -
{errorMessage}
- ))} -
+ {/* + * Status Indicators at the bottom of the scroll content. + * 표시할 내용이 있을 때만 렌더한다 — 무조건 렌더하면 min-h-20 + p-6 만큼(약 104px) + * 리스트 아래에 빈 블록이 항상 남는다. + */} + {(isLoading || errorMessage || (!hasMore && items.length > 0)) && ( +
+ {isLoading && (loader || )} + + {!hasMore && items.length > 0 && ( +
+ {endMessage || '모든 콘텐츠를 불러왔습니다.'} +
+ )} + + {errorMessage && + (errorSlot ? ( + errorSlot(errorMessage) + ) : ( +
{errorMessage}
+ ))} +
+ )}
); } diff --git a/src/components/basics/LinkCard/LinkCard.tsx b/src/components/basics/LinkCard/LinkCard.tsx index e3420e02..c50c4cec 100644 --- a/src/components/basics/LinkCard/LinkCard.tsx +++ b/src/components/basics/LinkCard/LinkCard.tsx @@ -63,7 +63,7 @@ const LinkCard = React.forwardRef(function LinkCa return (
(function LinkCa /> )} -
+
{title}
diff --git a/src/components/basics/Modal/Modal.tsx b/src/components/basics/Modal/Modal.tsx index 5f0c2a86..5ac7921f 100644 --- a/src/components/basics/Modal/Modal.tsx +++ b/src/components/basics/Modal/Modal.tsx @@ -14,7 +14,7 @@ import { modalOverlayStyle, } from './Modal.style'; -const FOCUSABLE_SELECTORS = [ +export const FOCUSABLE_SELECTORS = [ 'a[href]', 'button:not([disabled])', 'input:not([disabled])', diff --git a/src/components/layout/SideNavigation/SideNavigation.tsx b/src/components/layout/SideNavigation/SideNavigation.tsx index 0c7f659b..95940faa 100644 --- a/src/components/layout/SideNavigation/SideNavigation.tsx +++ b/src/components/layout/SideNavigation/SideNavigation.tsx @@ -2,7 +2,9 @@ import useEscKeyPress from '@/hooks/util/useEscKeyPress'; import { useIsMobile } from '@/hooks/util/useIsMobile'; +import { useLinkStore } from '@/stores/linkStore'; import { useSideNavStore } from '@/stores/sideNavStore'; +import clsx from 'clsx'; import { AnimatePresence, motion } from 'framer-motion'; import { usePathname } from 'next/navigation'; import { useCallback, useEffect, useRef } from 'react'; @@ -20,6 +22,7 @@ export default function SideNavigation() { const isOpen = useSideNavStore(state => state.isOpen); const toggle = useSideNavStore(state => state.toggle); const setOpen = useSideNavStore(state => state.setOpen); + const isDetailPanelOpen = useLinkStore(state => state.isDetailPanelOpen); const isMobile = useIsMobile(); const pathname = usePathname(); @@ -104,8 +107,17 @@ export default function SideNavigation() { )} - {/* 트리거는 DOM 상 마지막 = 같은 z 안에서 맨 위. 마운트/언마운트 없이 항상 렌더 */} -
+ {/* + * 트리거는 DOM 상 마지막 = 같은 z 안에서 맨 위. 마운트/언마운트 없이 항상 렌더. + * 단 상세 패널이 열리면 화면 전체를 덮으므로 그 위에 겹쳐 보이지 않도록 숨긴다 + * (언마운트가 아니라 hidden으로 처리해 깜박임을 막는다). + */} +
{ + const mq = window.matchMedia(FULLSCREEN_QUERY); + setIsFullscreen(mq.matches); + + const handler = (e: MediaQueryListEvent) => setIsFullscreen(e.matches); + mq.addEventListener('change', handler); + return () => mq.removeEventListener('change', handler); + }, []); + + return isFullscreen; +} + +interface DetailPanelShellProps { + children: React.ReactNode; + /** + * 넘기면 닫기 버튼만 있는 최소 헤더를 렌더한다. + * 로딩·에러·빈 상태처럼 아직 URL이 없어 HeaderSection을 못 쓰는 경우용 — + * 데이터를 기다리는 동안에도 사용자가 패널을 닫을 수 있어야 한다. + */ + onClose?: () => void; + /** 본문을 남은 영역 중앙에 배치한다 (로딩·에러·빈 상태). */ + centered?: boolean; +} + +/** + * 상세 패널의 root 지오메트리를 단독으로 소유한다. + * 로딩·에러·본문 상태가 모두 이 셸을 거치므로 반응형 분기가 서로 어긋날 수 없다. + */ +export default function DetailPanelShell({ + children, + onClose, + centered = false, +}: DetailPanelShellProps) { + const { root, content } = styles(); + const panelRef = useRef(null); + const previousFocusRef = useRef(null); + const isFullscreen = useIsFullscreen(); + + /* + * 전체화면일 때만 모달처럼 동작한다: 열릴 때 포커스를 패널로 옮기고, + * Tab을 패널 안에 가두고, 닫힐 때 원래 요소로 되돌린다. + * 트랩/복원 방식은 Modal.tsx의 기존 구현을 그대로 따른다. + */ + useEffect(() => { + if (!isFullscreen) return; + + previousFocusRef.current = document.activeElement as HTMLElement | null; + + const frame = requestAnimationFrame(() => panelRef.current?.focus()); + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'Tab') return; + + const panel = panelRef.current; + if (!panel) return; + + const focusable = Array.from(panel.querySelectorAll(FOCUSABLE_SELECTORS)); + if (focusable.length === 0) { + e.preventDefault(); + panel.focus(); + return; + } + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + // 패널 밖(아래 깔린 링크 리스트 등)에 포커스가 있으면 안으로 되돌린다 + if (!panel.contains(document.activeElement)) { + e.preventDefault(); + (e.shiftKey ? last : first).focus(); + return; + } + + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + + return () => { + cancelAnimationFrame(frame); + document.removeEventListener('keydown', handleKeyDown); + previousFocusRef.current?.focus(); + }; + }, [isFullscreen]); + + return ( + + ); +} diff --git a/src/components/wrappers/LinkCardDetailPanel/LinkCardDetailPanel.style.ts b/src/components/wrappers/LinkCardDetailPanel/LinkCardDetailPanel.style.ts index 499af859..c460edec 100644 --- a/src/components/wrappers/LinkCardDetailPanel/LinkCardDetailPanel.style.ts +++ b/src/components/wrappers/LinkCardDetailPanel/LinkCardDetailPanel.style.ts @@ -2,7 +2,9 @@ import { tv } from 'tailwind-variants'; export const styles = tv({ slots: { - root: 'bg-gray50 custom-scrollbar fixed inset-0 z-50 w-full overflow-x-hidden overflow-y-auto overscroll-contain pt-[max(0.5rem,env(safe-area-inset-top))] pr-1 sm:inset-y-0 sm:right-0 sm:left-auto sm:w-[520px] xl:static xl:h-full xl:w-[520px] xl:pt-2', + // 전체화면 ↔ 사이드시트 분기는 md(768px)에 둔다 — useIsMobile 의 기준과 같아야 + // 패널이 전체화면인 구간과 사이드네비가 드로어인 구간이 어긋나지 않는다. + root: 'bg-gray50 custom-scrollbar fixed inset-0 z-50 w-full overflow-x-hidden overflow-y-auto overscroll-contain pt-[max(0.5rem,env(safe-area-inset-top))] pr-1 md:inset-y-0 md:right-0 md:left-auto md:w-[520px] xl:static xl:h-full xl:w-[520px] xl:pt-2', content: 'flex h-full flex-col gap-0 pb-[max(1.5rem,env(safe-area-inset-bottom))]', header: 'flex items-center justify-between px-5 py-3', section: 'flex flex-col gap-2 px-5 pt-5 pb-4', diff --git a/src/components/wrappers/LinkCardDetailPanel/LinkCardDetailPanel.tsx b/src/components/wrappers/LinkCardDetailPanel/LinkCardDetailPanel.tsx index a751331c..0ec02e1a 100644 --- a/src/components/wrappers/LinkCardDetailPanel/LinkCardDetailPanel.tsx +++ b/src/components/wrappers/LinkCardDetailPanel/LinkCardDetailPanel.tsx @@ -1,6 +1,5 @@ 'use client'; -import { styles } from '@/components/wrappers/LinkCardDetailPanel/LinkCardDetailPanel.style'; import { getSafeUrl } from '@/hooks/util/getSafeUrl'; import { useModalStore } from '@/stores/modalStore'; import type { EntityId } from '@/types/id'; @@ -8,6 +7,7 @@ import { useMediaQuery, useScrollLock } from '@reactuses/core'; import { useCallback, useEffect } from 'react'; import ReSummaryModal from '../ReSummaryModal/ReSummaryModal'; +import DetailPanelShell from './DetailPanelShell'; import HeaderSection from './Sections/HeaderSection'; import ImageSection from './Sections/ImageSection'; import MemoSection from './Sections/MemoSection'; @@ -40,7 +40,6 @@ const LinkCardDetailPanel = ({ onClose, }: LinkCardDetailPanelProps) => { const safeUrl = getSafeUrl(url); - const { root, content } = styles(); const { modal } = useModalStore(); // xl 미만에서는 패널이 전체 화면 오버레이(fixed inset-0)라 뒤 배경이 같이 스크롤되면 안 된다. @@ -59,28 +58,26 @@ const LinkCardDetailPanel = ({ return ( <> - + {/* Memo */} + + {modal.type === 'RE_SUMMARY' && } ); diff --git a/src/components/wrappers/LinkCardDetailPanel/Sections/HeaderSection.tsx b/src/components/wrappers/LinkCardDetailPanel/Sections/HeaderSection.tsx index 728a28ae..10915af2 100644 --- a/src/components/wrappers/LinkCardDetailPanel/Sections/HeaderSection.tsx +++ b/src/components/wrappers/LinkCardDetailPanel/Sections/HeaderSection.tsx @@ -16,10 +16,14 @@ export default function HeaderSection({ safeUrl, onClose }: Props) { return (
-
-
+
+
{safeUrl ? ( - + {safeUrl} ) : ( diff --git a/src/stores/linkStore.ts b/src/stores/linkStore.ts index 74457589..ca8568fc 100644 --- a/src/stores/linkStore.ts +++ b/src/stores/linkStore.ts @@ -5,16 +5,21 @@ import { create } from 'zustand'; type LinkStoreState = { links: LinkApiData[]; selectedLinkId: EntityId | null; + /** 상세 패널이 열려 있는지. 모바일에서 전체화면을 덮으므로 사이드네비 트리거를 숨기는 데 쓴다. */ + isDetailPanelOpen: boolean; setLinks: (links: LinkApiData[]) => void; selectLink: (id: EntityId | null) => void; + setDetailPanelOpen: (open: boolean) => void; updateLink: (id: EntityId, updates: Partial) => void; }; export const useLinkStore = create(set => ({ links: [], selectedLinkId: null, + isDetailPanelOpen: false, setLinks: links => set({ links }), selectLink: id => set({ selectedLinkId: id }), + setDetailPanelOpen: open => set({ isDetailPanelOpen: open }), updateLink: (id, updates) => set(state => ({ links: state.links.map(link => (link.id === id ? { ...link, ...updates } : link)), diff --git a/src/stories/LinkCard.stories.tsx b/src/stories/LinkCard.stories.tsx index 309cb445..ef5a9666 100644 --- a/src/stories/LinkCard.stories.tsx +++ b/src/stories/LinkCard.stories.tsx @@ -8,6 +8,14 @@ const meta = { parameters: { layout: 'centered', }, + // LinkCard는 그리드 셀을 채우는 유동형(w-full + aspect)이라, 단독 렌더 시 원래 디자인 폭을 준다. + decorators: [ + Story => ( +
+ +
+ ), + ], argTypes: { imageUrl: { control: 'text' }, title: { control: 'text' }, diff --git a/src/styles/globals.css b/src/styles/globals.css index 69a2ea1e..2dde0100 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -34,6 +34,17 @@ overscroll-behavior-y: contain; } + /* + * 좌우 스크롤 차단. + * `hidden`이 아닌 `clip`을 쓰는 이유: `overflow-x: hidden`은 스크롤 컨테이너를 + * 만들어 데스크탑 사이드네비의 `sticky top-0`을 깨뜨린다. `clip`은 스크롤 + * 컨테이너를 만들지 않아 sticky가 그대로 동작한다. + */ + html { + overflow-x: clip; + overscroll-behavior-x: none; + } + /* iOS Safari는 글자 크기가 16px 미만인 입력 요소에 포커스하면 화면을 자동 확대한다. 핀치 줌(접근성)은 유지한 채, 터치 기기에서 입력 요소의 최소 글자 크기만 보장한다. */ @media (pointer: coarse) {