Skip to content

Commit 56154c2

Browse files
authored
fix(a11y): 모바일 읽기 경험 개선
1 parent a49ce1d commit 56154c2

17 files changed

Lines changed: 600 additions & 143 deletions

File tree

src/features/blog/ui/components/CategoryFilter/CategoryFilter.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export default function CategoryFilter({
3030
key={category}
3131
onClick={() => onCategoryChange(category)}
3232
className={clsx(
33-
'inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm transition-all duration-200 ease-[cubic-bezier(0.4,0,0.2,1)]',
33+
'inline-flex min-h-11 items-center gap-2 rounded-full border px-4 py-2 text-sm transition-all duration-200 ease-[cubic-bezier(0.4,0,0.2,1)]',
3434
'active:translate-y-px',
3535
'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)]',
3636
activeCategory === category

src/features/blog/ui/components/PostList/PostList.tsx

Lines changed: 61 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,61 @@
11
'use client';
22

33
import { motion } from 'framer-motion';
4+
import type { Variants } from 'framer-motion';
45
import { PostCard } from '../PostCard';
56
import { EmptyState } from '@/shared/ui';
67
import type { FeedData } from '@/domains/post/model/types';
8+
import {
9+
useEffectiveMotionMode,
10+
type EffectiveMotionMode,
11+
} from '@/shared/motion/model/motion-mode';
712

813
interface PostListProps {
914
posts: FeedData[];
1015
layout?: 'grid' | 'list';
1116
}
1217

13-
const containerVariants = {
14-
hidden: { opacity: 0 },
15-
visible: {
16-
opacity: 1,
17-
transition: {
18-
staggerChildren: 0.1,
18+
function getContainerVariants(
19+
effectiveMotionMode: EffectiveMotionMode
20+
): Variants {
21+
return {
22+
hidden: { opacity: 0 },
23+
visible: {
24+
opacity: 1,
25+
transition: {
26+
staggerChildren: effectiveMotionMode === 'reduced' ? 0.03 : 0.08,
27+
},
1928
},
20-
},
21-
};
29+
};
30+
}
31+
32+
function getItemVariants(effectiveMotionMode: EffectiveMotionMode): Variants {
33+
const shouldTranslate = effectiveMotionMode === 'full';
2234

23-
const itemVariants = {
24-
hidden: { opacity: 0, y: 20 },
25-
visible: {
26-
opacity: 1,
27-
y: 0,
28-
transition: {
29-
type: 'spring',
30-
stiffness: 300,
31-
damping: 30,
35+
return {
36+
hidden: {
37+
opacity: 0,
38+
y: shouldTranslate ? 20 : 0,
3239
},
33-
},
34-
};
40+
visible: {
41+
opacity: 1,
42+
y: 0,
43+
transition: {
44+
duration: effectiveMotionMode === 'reduced' ? 0.16 : 0.28,
45+
ease: [0.22, 1, 0.36, 1],
46+
},
47+
},
48+
};
49+
}
50+
51+
const layoutClassNames = {
52+
grid: 'grid gap-6 md:grid-cols-2',
53+
list: 'space-y-4',
54+
} satisfies Record<NonNullable<PostListProps['layout']>, string>;
3555

3656
export default function PostList({ posts, layout = 'grid' }: PostListProps) {
57+
const effectiveMotionMode = useEffectiveMotionMode();
58+
3759
if (posts.length === 0) {
3860
return (
3961
<EmptyState
@@ -44,13 +66,31 @@ export default function PostList({ posts, layout = 'grid' }: PostListProps) {
4466
);
4567
}
4668

69+
if (effectiveMotionMode === 'off') {
70+
return (
71+
<div className={layoutClassNames[layout]}>
72+
{posts.map((post) => (
73+
<div key={post.slug} className={layout === 'grid' ? 'h-full' : ''}>
74+
<PostCard
75+
post={post}
76+
variant={layout === 'list' ? 'list' : 'default'}
77+
/>
78+
</div>
79+
))}
80+
</div>
81+
);
82+
}
83+
84+
const containerVariants = getContainerVariants(effectiveMotionMode);
85+
const itemVariants = getItemVariants(effectiveMotionMode);
86+
4787
if (layout === 'list') {
4888
return (
4989
<motion.div
5090
variants={containerVariants}
5191
initial="hidden"
5292
animate="visible"
53-
className="space-y-4"
93+
className={layoutClassNames.list}
5494
>
5595
{posts.map((post) => (
5696
<motion.div key={post.slug} variants={itemVariants}>
@@ -66,7 +106,7 @@ export default function PostList({ posts, layout = 'grid' }: PostListProps) {
66106
variants={containerVariants}
67107
initial="hidden"
68108
animate="visible"
69-
className="grid gap-6 md:grid-cols-2"
109+
className={layoutClassNames.grid}
70110
>
71111
{posts.map((post) => (
72112
<motion.div key={post.slug} variants={itemVariants} className="h-full">

src/features/blog/ui/components/ReadingProgress/ReadingProgress.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,25 @@
22

33
import { useEffect, useState } from 'react';
44
import { motion, useScroll, useSpring } from 'framer-motion';
5+
import { useEffectiveMotionMode } from '@/shared/motion/model/motion-mode';
56

67
export default function ReadingProgress() {
78
const [isVisible, setIsVisible] = useState(false);
9+
const effectiveMotionMode = useEffectiveMotionMode();
810
const { scrollYProgress } = useScroll();
9-
const scaleX = useSpring(scrollYProgress, {
11+
const smoothScaleX = useSpring(scrollYProgress, {
1012
stiffness: 100,
1113
damping: 30,
1214
restDelta: 0.001,
1315
});
16+
const scaleX =
17+
effectiveMotionMode === 'full' ? smoothScaleX : scrollYProgress;
18+
const opacityTransitionDuration =
19+
effectiveMotionMode === 'off'
20+
? 0
21+
: effectiveMotionMode === 'reduced'
22+
? 0.08
23+
: 0.2;
1424

1525
useEffect(() => {
1626
const handleScroll = () => {
@@ -26,7 +36,7 @@ export default function ReadingProgress() {
2636
className="fixed top-0 left-0 right-0 h-1 bg-[var(--color-grey-100)] z-[var(--z-sticky)] origin-left"
2737
initial={{ opacity: 0 }}
2838
animate={{ opacity: isVisible ? 1 : 0 }}
29-
transition={{ duration: 0.2 }}
39+
transition={{ duration: opacityTransitionDuration }}
3040
>
3141
<motion.div
3242
className="h-full bg-[var(--color-toss-blue)] origin-left"

src/features/blog/ui/pages/BlogPostPage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ export default async function BlogPostPage({
126126
{post.tags.map((tag) => (
127127
<span
128128
key={tag}
129-
className="text-xs text-[var(--color-grey-500)] bg-[var(--color-grey-100)] px-2 py-1 rounded"
129+
className="rounded-full bg-[var(--color-grey-100)] px-2.5 py-1.5 text-xs font-medium text-[var(--color-grey-500)]"
130130
>
131131
#{tag}
132132
</span>

src/features/search/model/get-search-actions.test.ts

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,23 @@ describe('getSearchActions', () => {
2424
},
2525
]);
2626

27-
expect(actions).toHaveLength(1);
28-
expect(actions[0].id).toBe('redis-basics');
29-
expect(actions[0].name).toBe('Redis Basics');
30-
expect(actions[0].section).toBe('블로그 포스트');
31-
expect(actions[0].keywords).toContain('Redis');
27+
const postAction = actions.find((action) => action.id === 'redis-basics');
28+
29+
expect(postAction).toBeDefined();
30+
expect(postAction?.name).toBe('Redis Basics');
31+
expect(postAction?.section).toBe('블로그 포스트');
32+
expect(postAction?.keywords).toContain('Redis');
33+
});
34+
35+
it('includes section navigation actions for scoped search', () => {
36+
const actions = getSearchActions([]);
37+
38+
expect(actions.map((action) => action.id)).toEqual([
39+
'go-engineering',
40+
'go-life',
41+
'go-resume',
42+
]);
43+
expect(actions[2].keywords).toContain('이력서');
3244
});
3345

3446
it('handles missing tag arrays without crashing', () => {
@@ -42,39 +54,55 @@ describe('getSearchActions', () => {
4254
},
4355
]);
4456

45-
expect(actions).toHaveLength(1);
46-
expect(actions[0].keywords).toBe('No Tags Life Tags missing post');
57+
const postAction = actions.find((action) => action.id === 'no-tags');
58+
59+
expect(postAction?.keywords).toBe('No Tags Life Tags missing post');
4760
});
4861

4962
it('keeps post list order as provided', () => {
5063
const actions = getSearchActions([
51-
{ slug: 'second', title: 'B', category: 'Tech', tags: ['A'], description: 'B' },
52-
{ slug: 'first', title: 'A', category: 'Tech', tags: ['A'], description: 'A' },
64+
{
65+
slug: 'second',
66+
title: 'B',
67+
category: 'Tech',
68+
tags: ['A'],
69+
description: 'B',
70+
},
71+
{
72+
slug: 'first',
73+
title: 'A',
74+
category: 'Tech',
75+
tags: ['A'],
76+
description: 'A',
77+
},
5378
]);
5479

55-
expect(actions.map((action) => action.id)).toEqual(['second', 'first']);
80+
expect(actions.map((action) => action.id).slice(3)).toEqual([
81+
'second',
82+
'first',
83+
]);
5684
});
5785

5886
it('tracks and navigates when action is performed', () => {
5987
mockTrackEvent.mockClear();
6088

61-
const [action] = getSearchActions([
89+
const action = getSearchActions([
6290
{
6391
slug: 'redis-basics',
6492
title: 'Redis Basics',
6593
category: 'Tech',
6694
tags: ['Redis'],
6795
description: 'Redis 기초',
6896
},
69-
]);
97+
]).find((candidate) => candidate.id === 'redis-basics');
7098

71-
action.perform?.();
99+
action?.perform?.();
72100

73101
expect(mockTrackEvent).toHaveBeenCalledWith('click', {
74102
target: 'command_palette_result',
75103
post_slug: 'redis-basics',
76104
});
77-
expect(typeof action.perform).toBe('function');
78-
expect(() => action.perform?.()).not.toThrow();
105+
expect(typeof action?.perform).toBe('function');
106+
expect(() => action?.perform?.()).not.toThrow();
79107
});
80108
});

src/features/search/model/get-search-actions.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,38 @@ type SearchablePost = Pick<
77
'slug' | 'title' | 'category' | 'tags' | 'description'
88
>;
99

10+
interface NavigationActionSource {
11+
id: string;
12+
name: string;
13+
href: string;
14+
keywords: string;
15+
subtitle: string;
16+
}
17+
18+
const navigationActions: NavigationActionSource[] = [
19+
{
20+
id: 'go-engineering',
21+
name: 'Tech',
22+
href: '/engineering',
23+
keywords: 'Tech Engineering 기술 글 시리즈',
24+
subtitle: '기술 글과 시리즈 보기',
25+
},
26+
{
27+
id: 'go-life',
28+
name: 'Life',
29+
href: '/life',
30+
keywords: 'Life 회고 에세이 일상',
31+
subtitle: '회고와 에세이 보기',
32+
},
33+
{
34+
id: 'go-resume',
35+
name: 'Resume',
36+
href: '/resume',
37+
keywords: 'Resume 이력서 경력 프로젝트',
38+
subtitle: '경력과 프로젝트 보기',
39+
},
40+
];
41+
1042
/**
1143
* 전역 검색을 위한 액션 초기 데이터 생성 함수
1244
* 블로그 포스트를 검색할 수 있게 액션 객체 배열을 반환합니다.
@@ -36,5 +68,21 @@ export const getSearchActions = (posts: SearchablePost[]): Action[] => {
3668
subtitle: post.description,
3769
}));
3870

39-
return postActions;
71+
const sectionActions = navigationActions.map((action) => ({
72+
id: action.id,
73+
name: action.name,
74+
shortcut: [],
75+
keywords: action.keywords,
76+
section: '섹션',
77+
perform: () => {
78+
trackEvent(AnalyticsEvents.click, {
79+
target: 'command_palette_section',
80+
destination: action.href,
81+
});
82+
window.location.assign(action.href);
83+
},
84+
subtitle: action.subtitle,
85+
}));
86+
87+
return [...sectionActions, ...postActions];
4088
};
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { getRecommendedSearchTerms } from './search-recommendations';
3+
4+
describe('getRecommendedSearchTerms', () => {
5+
it('returns popular tags ordered by frequency', () => {
6+
const terms = getRecommendedSearchTerms([
7+
{ tags: ['Redis', '회고'] },
8+
{ tags: ['Redis', 'Flink'] },
9+
{ tags: ['회고'] },
10+
]);
11+
12+
expect(terms.slice(0, 3)).toEqual(['Redis', '회고', 'Flink']);
13+
});
14+
15+
it('falls back to section terms when posts do not have tags', () => {
16+
expect(getRecommendedSearchTerms([{ tags: [] }])).toEqual([
17+
'Tech',
18+
'Life',
19+
'Resume',
20+
]);
21+
});
22+
23+
it('trims empty tags and respects the requested limit', () => {
24+
expect(
25+
getRecommendedSearchTerms([{ tags: [' Redis ', '', 'Flink', '회고'] }], 2)
26+
).toHaveLength(2);
27+
});
28+
});

0 commit comments

Comments
 (0)