Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .storybook/preview.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Preview } from '@storybook/nextjs';

import '../src/styles/globals.css';
import { withQueryClient } from './withQueryClient';

// Storybook 환경에서만 fallback 주입
if (!process.env.NEXT_PUBLIC_BASE_API_URL) {
Expand All @@ -12,6 +13,9 @@ if (!process.env.NEXT_PUBLIC_API_TOKEN) {
}

const preview: Preview = {
// react-query 를 쓰는 컴포넌트(ChatRoomSection, SideNavigationBottom 등)가
// "No QueryClient set" 으로 죽지 않도록 전역 Provider 를 씌운다.
decorators: [withQueryClient],
parameters: {
nextjs: {
appDirectory: true, // App Router 사용 시 필수
Expand Down
24 changes: 24 additions & 0 deletions .storybook/withQueryClient.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { Decorator } from '@storybook/nextjs';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';

/**
* react-query 를 쓰는 컴포넌트가 Storybook 에서 "No QueryClient set" 으로
* 죽지 않도록 전역으로 씌우는 Provider.
*
* 스토리별로 데이터를 시드하고 싶으면 스토리 데코레이터에서 자체 QueryClient 로
* 한 번 더 감싸면 된다 (가까운 Provider 가 이긴다).
*/
export const withQueryClient: Decorator = Story => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});

return (
<QueryClientProvider client={client}>
<Story />
</QueryClientProvider>
);
};

export default withQueryClient;
10 changes: 6 additions & 4 deletions src/app/(dev)/mock-chat/MockChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,9 +143,11 @@ export default function MockChatPage() {
};

return (
<div className="h-screen w-full xl:flex">
<div className="custom-scrollbar min-w-0 flex-1 overflow-x-hidden overflow-y-auto pr-1">
<div className="mx-auto flex h-screen w-full max-w-[816px] flex-col px-4 pt-6">
<div className="h-full w-full xl:flex">
{/* 부모가 xl 부터만 flex 라 모바일에서는 flex-1 이 무효 → h-full 로 직접 높이를 준다 */}
<div className="custom-scrollbar h-full min-w-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain pr-1">
{/* QueryBox 가 스크롤 흐름 안에 있으므로 h-full 이 아니라 min-h-full (내용이 길면 늘어나야 함) */}
<div className="mx-auto flex min-h-full w-full max-w-[816px] flex-col px-4 pt-6">
<div className="flex min-h-0 flex-1 flex-col gap-3 pb-42">
{messages.map((message, index) => (
<div
Expand Down Expand Up @@ -244,7 +246,7 @@ export default function MockChatPage() {
</div>

{selectedLink && (
<aside className="border-gray200 hidden h-screen shrink-0 border-l xl:block xl:w-130">
<aside className="border-gray200 hidden h-full shrink-0 border-l xl:block xl:w-130">
<LinkCardDetailPanel
id={selectedLink.linkId}
url={selectedLink.url}
Expand Down
12 changes: 6 additions & 6 deletions src/app/(route)/all-link/AllLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -768,24 +768,24 @@ export default function AllLink() {

if (isLoading) {
return (
<div className="flex min-h-screen w-full items-center justify-center">
<div className="flex h-full w-full items-center justify-center">
<Spinner size={36} />
</div>
);
}

if (isError && !data) {
return (
<div className="flex min-h-screen w-full flex-col items-center justify-center gap-2">
<div className="flex h-full w-full flex-col items-center justify-center gap-2">
<p className="text-gray600">링크를 불러오지 못했습니다.</p>
<Button onClick={() => refetch()} label="다시 시도" />
</div>
);
}

return (
<div className="h-screen min-w-0">
<div className="flex h-screen min-w-0 flex-col xl:flex-row">
<div className="h-full min-w-0">
<div className="flex h-full min-w-0 flex-col xl:flex-row">
<div className="min-w-0 flex-1 px-10 py-15" onWheel={handleLeftPaneWheel}>
<div className="mx-auto flex h-full w-full max-w-200 flex-col gap-5">
<header className="flex items-center justify-between">
Expand All @@ -808,7 +808,7 @@ export default function AllLink() {
) : (
<InfiniteScroll
ref={listRef}
className="custom-scrollbar h-full overflow-y-auto p-1"
className="custom-scrollbar h-full overflow-y-auto overscroll-contain p-1"
items={links}
getKey={item => item.id}
renderItem={renderItem}
Expand All @@ -822,7 +822,7 @@ export default function AllLink() {
</div>

{isPanelOpen && (
<aside className="xl:h-screen xl:w-130 xl:shrink-0">
<aside className="xl:h-full xl:w-130 xl:shrink-0">
{isSelectedLinkLoading ? (
<div className="border-gray200 flex h-full items-center justify-center rounded-2xl border bg-white p-6">
<Spinner />
Expand Down
6 changes: 3 additions & 3 deletions src/app/(route)/chat/[id]/ChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -660,12 +660,12 @@ export default function Chat() {
}, [latestUserMessageIndex, messages]);

return (
<div className="h-screen w-full xl:flex">
<div className="h-full w-full xl:flex">
<div className="relative h-full min-w-0 flex-1">
<div
ref={scrollRootRef}
onScroll={handleScroll}
className="custom-scrollbar h-full overflow-x-hidden overflow-y-auto pr-1"
className="custom-scrollbar h-full overflow-x-hidden overflow-y-auto overscroll-contain pr-1"
>
<div className="mx-auto flex min-h-full w-full max-w-[816px] flex-col px-4 pt-15">
{streamError && (
Expand Down Expand Up @@ -809,7 +809,7 @@ export default function Chat() {
</div>

{selectedLink && (
<aside className="xl:border-gray200 xl:h-screen xl:w-130 xl:shrink-0 xl:border-l">
<aside className="xl:border-gray200 xl:h-full xl:w-130 xl:shrink-0 xl:border-l">
<LinkCardDetailPanel
id={selectedLink.linkId}
url={selectedLink.url}
Expand Down
4 changes: 2 additions & 2 deletions src/app/(route)/home/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export default function Home() {
// 로딩중 화면
if (creating || redirecting) {
return (
<div className="relative flex h-screen w-full justify-center">
<div className="relative flex h-full w-full justify-center">
<div className="relative flex h-full w-full max-w-[816px] flex-1 flex-col px-4">
<div className="absolute top-15 right-4 max-w-[70%]">
<span className="bg-blue50 block rounded-2xl px-4 py-3 whitespace-pre-wrap">
Expand Down Expand Up @@ -51,7 +51,7 @@ export default function Home() {
}

return (
<div className="flex h-screen w-full flex-col items-center justify-center">
<div className="flex h-full w-full flex-col items-center justify-center">
<div className="mb-7 ml-4 flex w-full max-w-184 flex-col">
<div className="font-title-md ml-6 flex flex-col gap-1.5 md:flex-row">
<span>저장한 링크 속 내용을 바탕으로</span>
Expand Down
2 changes: 1 addition & 1 deletion src/app/(route)/home/loading.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import Spinner from '@/components/basics/Spinner/Spinner';

export default function Loading() {
return (
<div className="flex h-screen w-screen items-center justify-center">
<div className="flex h-full w-full items-center justify-center">
<Spinner />
</div>
);
Expand Down
2 changes: 1 addition & 1 deletion src/app/(route)/mypage/Mypage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export default function Mypage() {
// if (!user) return null;

return (
<div className="relative flex h-screen w-full flex-col items-center justify-center">
<div className="relative flex h-full w-full flex-col items-center justify-center">
<span>마이페이지</span>
<Button
label={isLoggingOut ? '로그아웃 중...' : '로그아웃'}
Expand Down
26 changes: 24 additions & 2 deletions src/app/layout-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,43 @@
import AnalyticsIdentity from '@/components/AnalyticsIdentity';
import ReactQueryProvider from '@/components/ReactQueryProvider';
import SideNavigation from '@/components/layout/SideNavigation/SideNavigation';
import SideNavModals from '@/components/layout/SideNavigation/components/SideNavModals';
import { useIsMobile } from '@/hooks/util/useIsMobile';
import { useSideNavStore } from '@/stores/sideNavStore';
import clsx from 'clsx';
import { usePathname } from 'next/navigation';

export default function LayoutClient({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const isMobile = useIsMobile();
const isSideNavOpen = useSideNavStore(state => state.isOpen);

// 랜딩에서는 SideNavigation 숨김
const showSideNav = !['/', '/signup', '/terms'].includes(pathname);
const isDrawerOpen = showSideNav && isMobile && isSideNavOpen;

return (
<ReactQueryProvider>
{showSideNav && <AnalyticsIdentity />}
<div className="flex min-h-screen bg-white">
{/*
사이드내비 라우트는 셸을 정확히 한 뷰포트로 고정한다.
- 문서가 스크롤되지 않으므로 모바일 URL 바가 접히지 않고,
그에 따른 position:fixed 히트테스트 어긋남(모바일 열림 버튼 미작동)이 원천 차단된다.
- 드로어를 열었을 때 배경 스크롤도 자동으로 막힌다 (별도 body lock 불필요).
해당 라우트들은 모두 내부 스크롤 컨테이너를 갖고 있어 잃는 것이 없다.
랜딩/가입/약관은 기존대로 창 스크롤을 유지한다.
*/}
<div className={clsx('flex bg-white', showSideNav ? 'h-dvh overflow-hidden' : 'min-h-dvh')}>
{showSideNav && <SideNavigation />}
<main className="min-h-screen flex-1 overflow-x-clip">{children}</main>
<main
inert={isDrawerOpen}
className={clsx('min-w-0 flex-1 overflow-x-clip', showSideNav ? 'h-full' : 'min-h-dvh')}
>
{children}
</main>
</div>
{/* 드로어가 닫힐 때 함께 언마운트되지 않도록 셸 밖에서 렌더한다 */}
{showSideNav && <SideNavModals />}
</ReactQueryProvider>
);
}
9 changes: 7 additions & 2 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import ToastContainer from '@/components/basics/Toast/ToastContainer';
import { GA_MEASUREMENT_ID } from '@/lib/client/analytics';
import '@/styles/globals.css';
import { GoogleAnalytics } from '@next/third-parties/google';
import type { Metadata } from 'next';
import type { Metadata, Viewport } from 'next';

import LayoutClient from './layout-client';

Expand Down Expand Up @@ -49,7 +49,12 @@ export const metadata: Metadata = {
},
};

export const viewport = 'width=device-width, initial-scale=1, maximum-scale=1';
// App Router 는 문자열이 아닌 Viewport 객체를 기대한다.
// maximum-scale 은 의도적으로 지정하지 않는다 (확대 차단은 WCAG 1.4.4 위반).
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
};

export default function RootLayout({
children,
Expand Down
2 changes: 1 addition & 1 deletion src/app/loading.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export default function Loading() {
return (
<div className="flex h-screen w-screen items-center justify-center">
<div className="flex h-full min-h-dvh w-full items-center justify-center">
<div className="relative h-14 w-16">
{/* Ring A: 왼쪽 아래 고리 */}
<svg
Expand Down
3 changes: 2 additions & 1 deletion src/components/basics/Button/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,15 +59,16 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
const Comp = asChild ? Slot : 'button'; // 인터랙션 요소 중첩 방지를 위해 Slot 적용

return (
// rest 를 먼저 펼쳐 disabled / type / aria-* 가 덮이지 않게 한다
<Comp
{...rest}
ref={ref}
className={clsx(classes, className)}
disabled={isDisabled}
type={type}
aria-disabled={isDisabled}
aria-busy={loading || undefined}
onClick={isDisabled ? undefined : onClick}
{...rest}
>
{loading ? (
<div className="flex h-full w-full items-center justify-center">
Expand Down
3 changes: 2 additions & 1 deletion src/components/basics/IconButton/IconButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,15 @@ const IconButton = React.forwardRef<HTMLButtonElement, IconButtonProps>(
const Comp = asChild ? Slot : 'button';

return (
// rest 를 먼저 펼쳐 aria-label / onClick / type 이 덮이지 않게 한다
<Comp
{...rest}
ref={ref}
className={clsx(classes, className)}
disabled={disabled}
type={type}
aria-label={ariaLabel}
onClick={onClick}
{...rest}
>
{asChild ? (
children
Expand Down
3 changes: 2 additions & 1 deletion src/components/basics/Modal/Modal.style.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ export const modalOverlayStyle = tv({
});

export const modalContentStyle = tv({
base: 'bg-gray50 max-md:modal-slide-up relative h-auto w-auto rounded-2xl shadow-lg max-md:m-0! max-md:w-full! max-md:max-w-none! max-md:min-w-0! max-md:rounded-b-none!',
// 짧은 모바일 화면에서 긴 본문이 잘리지 않도록 최대 높이 + 자체 스크롤
base: 'bg-gray50 custom-scrollbar max-md:modal-slide-up relative h-auto max-h-[85dvh] w-auto overflow-y-auto overscroll-contain rounded-2xl shadow-lg max-md:m-0! max-md:w-full! max-md:max-w-none! max-md:min-w-0! max-md:rounded-b-none!',
});

export const modalHeaderStyle = tv({
Expand Down
3 changes: 2 additions & 1 deletion src/components/basics/Popover/Content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ const PopoverContent = ({ children, popoverKey, className }: PopoverContentProps
anchorRef.current = anchorEl ?? null;
}, [anchorEl]);

const { refs, floatingStyles } = usePopoverPosition(anchorRef.current, isActive, placement);
// 위치 갱신은 usePopoverPosition 내부의 autoUpdate 가 담당한다 (스크롤/리사이즈 추적)
const { refs, floatingStyles } = usePopoverPosition(anchorRef.current, placement);

useOutsideClick([contentRef, anchorRef], close, isActive);

Expand Down
14 changes: 13 additions & 1 deletion src/components/basics/Popover/Trigger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ interface PopoverTriggerProps {
popoverKey: string;
label?: string;
}

/**
* Button/IconButton 은 접근성 이름을 `aria-label` 이 아니라 자체 prop 인 `ariaLabel` 로 받는다.
* 여기서 `aria-label: undefined` 를 무조건 주입하면, 두 컴포넌트가 `{...rest}` 를
* `aria-label={ariaLabel}` 뒤에 펼치기 때문에 이름이 지워진다.
*/
type LabelledChildProps = { ariaLabel?: string; 'aria-label'?: string };

const PopoverTrigger = ({ children, popoverKey, label }: PopoverTriggerProps) => {
const triggerRef = React.useRef<HTMLButtonElement>(null);
const { activeKey, toggle, close } = usePopover();
Expand All @@ -36,6 +44,9 @@ const PopoverTrigger = ({ children, popoverKey, label }: PopoverTriggerProps) =>
}
};

const childProps = children.props as LabelledChildProps;
const resolvedLabel = label ?? childProps.ariaLabel ?? childProps['aria-label'];

return cloneElement(children, {
ref: useMergedRefs(triggerRef, children.props.ref),
onClick: (e: React.MouseEvent<HTMLButtonElement>) => {
Expand All @@ -44,7 +55,8 @@ const PopoverTrigger = ({ children, popoverKey, label }: PopoverTriggerProps) =>
},
'aria-haspopup': true,
'aria-expanded': isActive,
'aria-label': label || children.props['aria-label'],
// 값이 있을 때만 넘긴다. undefined 를 명시적으로 넘기면 자식이 이미 설정한 값을 덮어쓴다.
...(resolvedLabel ? { 'aria-label': resolvedLabel } : {}),
} as Partial<ButtonLikeProps>);
};
export default PopoverTrigger;
16 changes: 7 additions & 9 deletions src/components/basics/Popover/hooks/usePopoverPosition.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,25 @@
'use client';

import { Placement, flip, offset, shift, useFloating } from '@floating-ui/react-dom';
import { Placement, autoUpdate, flip, offset, shift, useFloating } from '@floating-ui/react-dom';
import { useEffect } from 'react';

export const usePopoverPosition = (
triggerRef: HTMLElement | null, // 기준이 되는 엘리먼트 (trigger)
isOpen: boolean, // Popover가 열려있을 때만 위치를 계산
placement: Placement = 'bottom-start' // 기본 위치
) => {
const { refs, floatingStyles, update } = useFloating({
const { refs, floatingStyles } = useFloating({
placement,
middleware: [offset(6), flip(), shift()], // 기준 요소로부터의 거리, 화면 공간에 맞춰 뒤집기, 화면 안으로 밀어넣기
// 기준 요소로부터의 거리, 화면 공간에 맞춰 뒤집기, 화면 안으로 밀어넣기(가장자리 8px 여백)
middleware: [offset(6), flip(), shift({ padding: 8 })],
// 스크롤 가능한 조상/리사이즈를 구독해 위치를 계속 갱신한다.
// 사이드 내비게이션처럼 스크롤되는 컨테이너 안의 트리거에서 필수.
whileElementsMounted: autoUpdate,
});

// trigger를 Floating UI에 연결
useEffect(() => {
refs.setReference(triggerRef);
}, [triggerRef, refs]);

// Popover가 열릴 때마다 위치 업데이트
useEffect(() => {
if (isOpen) update();
}, [isOpen, update]);

return { refs, floatingStyles };
};
Loading
Loading