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
1 change: 1 addition & 0 deletions docs/conventions/component-convention.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ interface RentalItemCardProps {
- 예: Figma `Chip/Chip` → `import { Chip } from '@wanteddev/wds'`
- 아이콘은 `@wanteddev/wds-icon`에서 가져온다.
- **WDS 컴포넌트 내부를 임의로 오버라이드하지 않는다.** 간격·배치 같은 레이아웃 조정은 감싸는 wrapper에서 한다.
- **화면 헤더는 화면에서 `TopNavigation`을 직접 새로 조립하지 않고 `src/components/ui/ScreenHeader.tsx`를 거친다.** Figma의 Top Navigation 패턴(타이틀 정렬, leading/trailing 조합, 게시판류의 토글형 타이틀 등)을 `ScreenHeader`의 props(`variant`/`title`/`leading`/`trailing`)로 고르게 돼 있다 — 화면마다 손으로 다시 조립하면 컨벤션이 흩어진다. 새 헤더 패턴이 필요하면 `ScreenHeader`부터 확장한다(`docs/plans/unified-screen-header.md` 참고).

## 4. Stream 고유 UI (신규 컴포넌트)

Expand Down
135 changes: 107 additions & 28 deletions src/components/ui/ScreenHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,38 +1,117 @@
import { TopNavigationButton, Typography } from "@wanteddev/wds";
import { IconBell, IconSearch } from "@wanteddev/wds-icon";
import { TopNavigation, Typography } from "@wanteddev/wds";
import type { ReactNode } from "react";

interface ScreenHeaderProps {
title: string;
interface ScreenHeaderToggleTitle {
options: string[];
activeIndex: number;
onChange?: (index: number) => void;
}

// Figma: Top Navigation(nodeId 1765:71193)의 타이틀 + 검색/알림 아이콘 부분 — 행사·빌릴게 등
// 여러 화면에서 완전히 동일하게 반복되는 진짜 공통 패턴이라 재사용 컴포넌트로 뺐다.
// "Tool" 슬롯(세그먼트 토글 등)은 화면마다 값·동작이 달라서(대여/반납 vs 행사/신청내역)
// 여기 포함하지 않고 각 화면이 자기 본문에서 직접 그린다.
type ScreenHeaderTitle = string | ScreenHeaderToggleTitle;

function isToggleTitle(
title: ScreenHeaderTitle,
): title is ScreenHeaderToggleTitle {
return typeof title !== "string";
}

// Figma: 게시판류 화면의 "공지 | 열린피드백" 같은 2단 탭 타이틀(nodeId 1256:81792 "Board Title").
// 활성 옵션은 Label/Strong(검정), 비활성은 Label/Disable(흐림) — 둘 다 같은 Title 3/Bold(24px).
function ScreenHeaderToggleTitle({
options,
activeIndex,
onChange,
}: ScreenHeaderToggleTitle) {
return (
<div className="flex items-center gap-2">
{options.map((option, index) => {
const active = index === activeIndex;
return (
<button key={option} onClick={() => onChange?.(index)} type="button">
<Typography
color={
active ? "semantic.label.strong" : "semantic.label.disable"
}
variant="title3"
weight="bold"
>
{option}
</Typography>
</button>
);
})}
</div>
);
}

type ScreenHeaderProps =
| {
variant?: "display";
title?: ScreenHeaderTitle;
trailing?: ReactNode;
}
| {
variant: "normal";
title?: ScreenHeaderTitle;
leading?: ReactNode;
trailing?: ReactNode;
};

// variant="display"(기본값, 빌릴게/홈)는 더 이상 WDS `Top Navigation/Resource/Contents`가 아니다 —
// Figma가 별도 Stream 로컬 컴포넌트(nodeId 1765:71193 "Top Navigation")로 바뀌었다: 세로 패딩
// 12px(기존 WDS display variant는 16px 고정이라 오버라이드 불가) + Title 3/Bold(32px)가 정확히
// 들어가서 총 56px. leading은 이 패턴에서 쓴 적이 없어 그대로 받지 않는다.
//
// variant="normal"(모달형 닫기 버튼 등, leading 필요)은 아직 WDS `TopNavigation`을 그대로 쓴다 —
// Figma 쪽 해당 패턴은 안 바뀌었다(component-convention.md "WDS 컴포넌트 내부를 임의로
// 오버라이드하지 않는다" 원칙 유지).
//
// 이 Top Navigation은 WDS Top Navigation/Resource/Contents가 아니라 Stream 로컬
// 컴포넌트다 — 세로 패딩 12px + Title 3/Bold(32px)로 총 56px인데, WDS display variant는
// 세로 패딩이 16px 고정이라 64px이 된다. 그래서 레이아웃만 직접 구현하고, 아이콘 버튼
// (TopNavigationButton)은 그대로 재사용한다.
function ScreenHeader({ title }: ScreenHeaderProps) {
// title이 문자열이면 그대로 렌더링하고, { options, activeIndex } 형태(활성 상태가 있는 경우)면
// 게시판류의 토글형 2단 타이틀로 렌더링한다.
//
// search variant(타이틀 자리가 검색 필드로 바뀌는 패턴)는 이번 범위에서 뺐다 —
// docs/plans/unified-screen-header.md 참고. 화면이 실제로 생기면 그때 추가한다.
function ScreenHeader(props: ScreenHeaderProps) {
const { title, trailing } = props;

if (props.variant === "normal") {
return (
<TopNavigation
background={false}
leadingContent={props.leading}
trailingContent={trailing}
variant="normal"
>
{title !== undefined &&
(isToggleTitle(title) ? (
<ScreenHeaderToggleTitle {...title} />
) : (
title
))}
</TopNavigation>
);
}

return (
<div className="flex w-full items-center justify-between px-5 py-3">
<Typography
as="h2"
color="semantic.label.strong"
variant="title3"
weight="bold"
>
{title}
</Typography>
<div className="flex shrink-0 items-center gap-4">
<TopNavigationButton aria-label="검색" variant="icon">
<IconSearch />
</TopNavigationButton>
<TopNavigationButton aria-label="알림" variant="icon">
<IconBell />
</TopNavigationButton>
<div className="flex min-w-0 items-center">
{title !== undefined &&
(isToggleTitle(title) ? (
<ScreenHeaderToggleTitle {...title} />
) : (
<Typography
as="h2"
color="semantic.label.strong"
variant="title3"
weight="bold"
>
{title}
</Typography>
))}
</div>
{trailing !== undefined && (
<div className="flex shrink-0 items-center gap-4">{trailing}</div>
)}
</div>
);
}
Expand Down
23 changes: 21 additions & 2 deletions src/features/bililge/BililgeListScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { SegmentedControl, SegmentedControlItem } from "@wanteddev/wds";
import {
SegmentedControl,
SegmentedControlItem,
TopNavigationButton,
} from "@wanteddev/wds";
import { IconBell, IconSearch } from "@wanteddev/wds-icon";
import { startTransition, useState } from "react";

import ScreenHeader from "@/components/ui/ScreenHeader";
Expand All @@ -18,7 +23,21 @@ function BililgeListScreen() {
const [rentalItem, setRentalItem] = useState<BililgeItem | null>(null);
const [rentalSheetOpen, setRentalSheetOpen] = useState(false);

useScreenHeader(<ScreenHeader title="빌릴게" />);
useScreenHeader(
<ScreenHeader
title="빌릴게"
trailing={
<>
<TopNavigationButton aria-label="검색" variant="icon">
<IconSearch />
</TopNavigationButton>
<TopNavigationButton aria-label="알림" variant="icon">
<IconBell />
</TopNavigationButton>
</>
}
/>,
);

return (
<div className="flex h-full flex-col">
Expand Down
18 changes: 17 additions & 1 deletion src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,27 @@
import { TopNavigationButton } from "@wanteddev/wds";
import { IconBell, IconSearch } from "@wanteddev/wds-icon";
import { Link } from "react-router-dom";

import ScreenHeader from "@/components/ui/ScreenHeader";
import { useScreenHeader } from "@/components/ui/useScreenHeader";

// 홈 화면 콘텐츠는 아직 없어서, 라우팅이 실제로 동작하는지 확인할 placeholder만 둔다.
function HomeScreen() {
useScreenHeader(<ScreenHeader title="STREAM" />);
useScreenHeader(
<ScreenHeader
title="STREAM"
trailing={
<>
<TopNavigationButton aria-label="검색" variant="icon">
<IconSearch />
</TopNavigationButton>
<TopNavigationButton aria-label="알림" variant="icon">
<IconBell />
</TopNavigationButton>
</>
}
/>,
);

return (
<div className="flex flex-col items-center justify-center gap-4 py-20">
Expand Down