Skip to content

Commit ba621cd

Browse files
박은우박은우
authored andcommitted
refactor(home): streamline archive browsing
1 parent 0f4a56d commit ba621cd

10 files changed

Lines changed: 284 additions & 80 deletions

blog/ui/components/CategoryFilter.test.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -79,28 +79,30 @@ describe('CategoryFilter', () => {
7979
expect(onCategoryChange).toHaveBeenCalledWith('Life');
8080
});
8181

82-
it('renders active style classes', () => {
82+
it('renders the active category as an underlined text control', () => {
8383
render(
8484
<CategoryFilter
8585
{...baseProps}
8686
activeCategory="Life"
8787
onCategoryChange={vi.fn()}
88+
variant="links"
8889
/>
8990
);
9091

9192
const lifeButton = screen
9293
.getAllByRole('button')
93-
.find((button) => button.textContent === 'Life2');
94+
.find((button) => button.textContent === 'Life(2)');
9495

9596
expect(lifeButton).toBeDefined();
9697

9798
if (!lifeButton) {
9899
throw new Error('Life category button not found');
99100
}
100101

101-
expect(lifeButton).toHaveClass('bg-[var(--color-toss-blue)]');
102-
expect(lifeButton).toHaveClass('rounded-[var(--radius-selection)]');
103-
expect(lifeButton).not.toHaveClass('shadow-sm');
104-
expect(lifeButton).not.toHaveClass('active:translate-y-px');
102+
expect(lifeButton).toHaveClass('border-b-2');
103+
expect(lifeButton).toHaveClass('border-[var(--color-toss-blue)]');
104+
expect(lifeButton).toHaveClass('text-[var(--color-toss-blue)]');
105+
expect(lifeButton).not.toHaveClass('rounded-[var(--radius-selection)]');
106+
expect(lifeButton).not.toHaveClass('bg-[var(--color-toss-blue)]');
105107
});
106108
});

blog/ui/components/CategoryFilter/CategoryFilter.tsx

Lines changed: 53 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { clsx } from 'clsx';
44

55
type Category = 'All' | 'Tech' | 'Life';
6+
type CategoryFilterVariant = 'links' | 'pills';
67

78
const CATEGORY_LABELS: Record<Category, string> = {
89
All: 'All',
@@ -15,44 +16,70 @@ interface CategoryFilterProps {
1516
activeCategory: Category;
1617
onCategoryChange: (category: Category) => void;
1718
categoryCounts: Record<Category, number>;
19+
variant?: CategoryFilterVariant;
1820
}
1921

2022
export default function CategoryFilter({
2123
categories,
2224
activeCategory,
2325
onCategoryChange,
2426
categoryCounts,
27+
variant = 'pills',
2528
}: CategoryFilterProps) {
29+
const isLinkVariant = variant === 'links';
30+
2631
return (
27-
<div className="flex gap-2 flex-wrap">
28-
{categories.map((category) => (
29-
<button
30-
key={category}
31-
onClick={() => onCategoryChange(category)}
32-
className={clsx(
33-
'inline-flex min-h-11 items-center gap-2 rounded-[var(--radius-selection)] border px-4 py-2 text-sm transition-colors duration-[var(--duration-200)] ease-[var(--ease-default)]',
34-
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-toss-blue)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-bg-primary)]',
35-
activeCategory === category
36-
? 'border-[var(--color-toss-blue)] bg-[var(--color-toss-blue)] text-white'
37-
: 'border-[var(--color-border)] bg-[var(--color-bg-primary)] text-[var(--color-grey-600)] hover:border-[var(--color-border-hover)] hover:bg-[var(--color-grey-50)]'
38-
)}
39-
aria-pressed={activeCategory === category}
40-
>
41-
<span className="font-medium">{CATEGORY_LABELS[category]}</span>
42-
<span
32+
<nav aria-label="글 분류">
33+
<div
34+
className={clsx(
35+
'flex flex-wrap items-center',
36+
isLinkVariant ? 'gap-x-5 gap-y-1' : 'gap-2'
37+
)}
38+
>
39+
{categories.map((category) => (
40+
<button
41+
key={category}
42+
onClick={() => onCategoryChange(category)}
4343
className={clsx(
44-
'rounded-[var(--radius-selection)] px-1.5 py-0.5 text-[10px] font-semibold',
45-
activeCategory === category
46-
? 'bg-white/20 text-white'
47-
: 'bg-[var(--color-grey-100)] text-[var(--color-text-tertiary)]'
44+
'inline-flex min-h-11 items-center text-sm transition-colors duration-[var(--duration-200)] ease-[var(--ease-default)]',
45+
isLinkVariant
46+
? 'border-b-2 px-0 py-2 font-medium'
47+
: 'gap-2 rounded-[var(--radius-selection)] border px-4 py-2',
48+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-toss-blue)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-bg-primary)]',
49+
isLinkVariant
50+
? activeCategory === category
51+
? 'border-[var(--color-toss-blue)] text-[var(--color-toss-blue)]'
52+
: 'border-transparent text-[var(--color-text-secondary)] hover:border-[var(--color-grey-300)] hover:text-[var(--color-text-primary)]'
53+
: activeCategory === category
54+
? 'border-[var(--color-toss-blue)] bg-[var(--color-toss-blue)] text-[var(--color-accent-foreground)]'
55+
: 'border-[var(--color-border)] bg-[var(--color-bg-primary)] text-[var(--color-grey-600)] hover:border-[var(--color-border-hover)] hover:bg-[var(--color-grey-50)]'
4856
)}
57+
aria-pressed={activeCategory === category}
4958
>
50-
{categoryCounts[category]}
51-
</span>
52-
</button>
53-
))}
54-
</div>
59+
<span className={clsx(!isLinkVariant && 'font-medium')}>
60+
{CATEGORY_LABELS[category]}
61+
</span>
62+
{isLinkVariant ? (
63+
<span className="ml-1 text-meta font-normal text-[var(--color-text-tertiary)]">
64+
({categoryCounts[category]})
65+
</span>
66+
) : (
67+
<span
68+
className={clsx(
69+
'rounded-[var(--radius-selection)] px-1.5 py-0.5 text-[10px] font-semibold',
70+
activeCategory === category
71+
? 'bg-[var(--color-grey-700)] text-[var(--color-accent-foreground)]'
72+
: 'bg-[var(--color-grey-100)] text-[var(--color-text-tertiary)]'
73+
)}
74+
>
75+
{categoryCounts[category]}
76+
</span>
77+
)}
78+
</button>
79+
))}
80+
</div>
81+
</nav>
5582
);
5683
}
5784

58-
export type { CategoryFilterProps, Category };
85+
export type { CategoryFilterProps, Category, CategoryFilterVariant };

blog/ui/components/PostCard.test.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,37 @@ describe('PostCard', () => {
6565
expect(screen.queryByRole('presentation')).not.toBeInTheDocument();
6666
});
6767

68+
it('renders date and title only in archive variant', () => {
69+
const detailedPost: FeedData = {
70+
...basePost,
71+
readingTime: 12,
72+
series: {
73+
id: 'redis',
74+
title: 'Redis 완전정복',
75+
order: 1,
76+
},
77+
tags: ['redis', 'cache'],
78+
};
79+
80+
render(<PostCard post={detailedPost} variant="archive" />);
81+
82+
const link = screen.getByRole('link', {
83+
name: /2026\.02\.10 /,
84+
});
85+
86+
expect(link).toHaveAttribute('href', '/blog/test-post');
87+
expect(link).toHaveClass('min-h-11');
88+
expect(link).not.toHaveClass('border');
89+
expect(screen.getByText('2026.02.10')).toHaveAttribute(
90+
'dateTime',
91+
'2026-02-10T00:00:00.000Z'
92+
);
93+
expect(screen.queryByText('Tech')).not.toBeInTheDocument();
94+
expect(screen.queryByText('테스트 설명')).not.toBeInTheDocument();
95+
expect(screen.queryByText('#redis')).not.toBeInTheDocument();
96+
expect(screen.queryByText('약 12분')).not.toBeInTheDocument();
97+
});
98+
6899
it('uses a static content surface for the list variant', () => {
69100
render(<PostCard post={basePost} variant="list" />);
70101

blog/ui/components/PostCard/PostCard.tsx

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,17 @@ import { CategoryIcon } from '@/ui/icons/AppSectionIcon';
55

66
interface PostCardProps {
77
post: FeedData;
8-
variant?: 'default' | 'list';
8+
variant?: 'archive' | 'default' | 'list';
9+
}
10+
11+
function formatArchiveDate(date: string): string {
12+
const matchedDate = /^(\d{4})-(\d{2})-(\d{2})/.exec(date);
13+
14+
if (!matchedDate) {
15+
return date;
16+
}
17+
18+
return `${matchedDate[1]}.${matchedDate[2]}.${matchedDate[3]}`;
919
}
1020

1121
export default function PostCard({ post, variant = 'default' }: PostCardProps) {
@@ -17,6 +27,29 @@ export default function PostCard({ post, variant = 'default' }: PostCardProps) {
1727
const readingTimeLabel = post.readingTime ? `약 ${post.readingTime}분` : null;
1828
const visibleTags = post.tags?.slice(0, 3) ?? [];
1929

30+
if (variant === 'archive') {
31+
return (
32+
<Link
33+
href={`/blog/${post.slug}`}
34+
className={clsx(
35+
'group flex min-h-11 items-baseline gap-3 rounded-[var(--radius-action)] py-2',
36+
'transition-colors duration-[var(--duration-200)] ease-[var(--ease-default)]',
37+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-toss-blue)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-bg-primary)]'
38+
)}
39+
>
40+
<time
41+
dateTime={post.date}
42+
className="shrink-0 text-meta font-medium tabular-nums text-[var(--color-text-tertiary)]"
43+
>
44+
{formatArchiveDate(post.date)}
45+
</time>
46+
<h3 className="min-w-0 text-base font-semibold leading-snug tracking-tight text-[var(--color-text-primary)] transition-colors duration-[var(--duration-200)] ease-[var(--ease-default)] group-hover:text-[var(--color-toss-blue)] group-focus-visible:text-[var(--color-toss-blue)]">
47+
{post.title}
48+
</h3>
49+
</Link>
50+
);
51+
}
52+
2053
if (variant === 'list') {
2154
return (
2255
<Link

blog/ui/components/PostList.test.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ describe('PostList', () => {
3434
it('shows empty state when no posts exist', () => {
3535
render(<PostList posts={[]} />);
3636

37-
expect(screen.getByRole('status')).toHaveTextContent('아직 작성된 글이 없어요');
37+
expect(screen.getByRole('status')).toHaveTextContent(
38+
'아직 작성된 글이 없어요'
39+
);
3840
});
3941

4042
it('renders a list of posts', () => {
@@ -59,4 +61,14 @@ describe('PostList', () => {
5961
expect(links[0]).toHaveAttribute('href', '/blog/one');
6062
expect(screen.getByText('첫 번째 글')).toBeInTheDocument();
6163
});
64+
65+
it('renders archive layout when requested', () => {
66+
render(<PostList posts={samplePosts} layout="archive" />);
67+
68+
expect(screen.getByRole('list')).toBeInTheDocument();
69+
expect(screen.getAllByRole('listitem')).toHaveLength(2);
70+
expect(
71+
screen.getByRole('link', { name: /2026\.02\.01 / })
72+
).toHaveAttribute('href', '/blog/one');
73+
});
6274
});

blog/ui/components/PostList/PostList.tsx

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212

1313
interface PostListProps {
1414
posts: FeedData[];
15-
layout?: 'grid' | 'list';
15+
layout?: 'archive' | 'grid' | 'list';
1616
}
1717

1818
function getContainerVariants(
@@ -49,6 +49,7 @@ function getItemVariants(effectiveMotionMode: EffectiveMotionMode): Variants {
4949
}
5050

5151
const layoutClassNames = {
52+
archive: 'space-y-1',
5253
grid: 'grid gap-6 md:grid-cols-2',
5354
list: 'space-y-4',
5455
} satisfies Record<NonNullable<PostListProps['layout']>, string>;
@@ -68,12 +69,25 @@ export default function PostList({ posts, layout = 'grid' }: PostListProps) {
6869

6970
if (effectiveMotionMode === 'off') {
7071
return (
71-
<div className={layoutClassNames[layout]}>
72+
<div
73+
className={layoutClassNames[layout]}
74+
role={layout === 'archive' ? 'list' : undefined}
75+
>
7276
{posts.map((post) => (
73-
<div key={post.slug} className={layout === 'grid' ? 'h-full' : ''}>
77+
<div
78+
key={post.slug}
79+
className={layout === 'grid' ? 'h-full' : ''}
80+
role={layout === 'archive' ? 'listitem' : undefined}
81+
>
7482
<PostCard
7583
post={post}
76-
variant={layout === 'list' ? 'list' : 'default'}
84+
variant={
85+
layout === 'archive'
86+
? 'archive'
87+
: layout === 'list'
88+
? 'list'
89+
: 'default'
90+
}
7791
/>
7892
</div>
7993
))}
@@ -84,17 +98,25 @@ export default function PostList({ posts, layout = 'grid' }: PostListProps) {
8498
const containerVariants = getContainerVariants(effectiveMotionMode);
8599
const itemVariants = getItemVariants(effectiveMotionMode);
86100

87-
if (layout === 'list') {
101+
if (layout === 'list' || layout === 'archive') {
88102
return (
89103
<motion.div
90104
variants={containerVariants}
91105
initial="hidden"
92106
animate="visible"
93-
className={layoutClassNames.list}
107+
className={layoutClassNames[layout]}
108+
role={layout === 'archive' ? 'list' : undefined}
94109
>
95110
{posts.map((post) => (
96-
<motion.div key={post.slug} variants={itemVariants}>
97-
<PostCard post={post} variant="list" />
111+
<motion.div
112+
key={post.slug}
113+
variants={itemVariants}
114+
role={layout === 'archive' ? 'listitem' : undefined}
115+
>
116+
<PostCard
117+
post={post}
118+
variant={layout === 'archive' ? 'archive' : 'list'}
119+
/>
98120
</motion.div>
99121
))}
100122
</motion.div>
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# 0029. 홈 카테고리 필터를 링크형 상태 컨트롤로 표현한다
2+
3+
Date: 2026-07-19
4+
Status: Accepted
5+
6+
## 배경
7+
8+
홈은 최신 글을 훑는 아카이브다. 기존 카테고리 필터는 테두리 컨테이너 안에
9+
채워진 pill 버튼과 카운트 배지로 배치되어, 글 탐색보다 독립된 설정 화면처럼
10+
보였다. 홈 피드는 선택해야 할 복잡한 옵션이 아니라 글의 범위를 빠르게
11+
좁히는 짧은 상태 전환만 제공한다.
12+
13+
필터 상태는 현재 클라이언트에서 즉시 전환되며 URL이나 route를 변경하지
14+
않는다. 시각적으로 링크처럼 보여도 실제 anchor를 사용하면 브라우저 탐색
15+
의미와 동작이 맞지 않는다.
16+
17+
## 결정
18+
19+
- 홈 카테고리 필터는 별도 테두리 컨테이너 없이 제목 아래에 직접 배치한다.
20+
- 각 항목은 `button`의 semantic을 유지하되, 텍스트 링크처럼 보이게 한다.
21+
- 활성 항목은 Toss blue와 하단선으로만 구분하고, 비활성 항목은 중립 텍스트와
22+
hover 하단선으로 처리한다.
23+
- 카운트는 배지 대신 항목 이름 뒤 괄호 표기로 표시한다.
24+
- `aria-pressed`와 keyboard focus ring을 유지해 현재 선택 상태와 조작 가능성을
25+
명시한다.
26+
27+
## 결과
28+
29+
- 아카이브의 시각적 계층이 제목과 글 목록에 집중된다.
30+
- 필터는 상태 전환이라는 실제 동작에 맞는 semantic을 유지한다.
31+
- 이후 URL 기반 카테고리 archive를 도입할 때에는 이 컨트롤을 anchor와 route
32+
상태로 별도 전환해야 한다.
33+
34+
## 검토한 대안
35+
36+
- 채워진 pill 버튼과 독립 컨테이너를 유지한다: 선택 상태는 강하지만, 홈
37+
아카이브에는 과한 표면과 시각적 무게를 만든다.
38+
- 실제 anchor 링크로 바꾼다: 링크 모양과 의미는 일치하지만 현재의 in-place
39+
filtering 및 URL 유지 동작과는 맞지 않는다.
40+
- 카운트 배지를 유지한다: 수치 구분은 쉽지만, 작은 인터페이스 표면을 더해
41+
링크형 톤을 흐린다.
42+
43+
## Related History
44+
45+
- [ADR 0018](0018-use-semantic-surface-tokens-for-content-discovery.md): 콘텐츠
46+
탐색 화면의 surface 선택 원칙
47+
- [ADR 0024](0024-use-latest-only-home-feed.md): 홈의 최신순 단일 피드 결정

0 commit comments

Comments
 (0)