Skip to content
Merged
1 change: 1 addition & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@

@import '../styles/colors.css';
@import '../styles/typography.css';
@import '../styles/animations.css';
@import '../styles/base.css';
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import type { ReactNode } from 'react';

import { cn } from '@/lib/cn';
import { BookmarkIcon } from '@/shared/components/icons';
import { BookmarkButton } from '@/shared/components/ui';

type BookmarkOverlayVariant = 'card' | 'summary';

Expand All @@ -29,24 +29,11 @@ export const BookmarkContainer = ({
<div className="relative w-full">
{children}

<button
type="button"
className={cn(
'absolute',
bookmarkPosition[variant],
isBookmarked ? 'text-mint-300' : 'text-gray-200',
)}
aria-label={isBookmarked ? '북마크 해제' : '북마크 추가'}
aria-pressed={isBookmarked}
onClick={(event) => {
event.stopPropagation();
onBookmarkClick();
}}
>
<BookmarkIcon
className={cn('size-6', isBookmarked && 'fill-current')}
/>
</button>
<BookmarkButton
isBookmarked={isBookmarked}
className={cn('absolute', bookmarkPosition[variant])}
onClick={onBookmarkClick}
/>
</div>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,9 @@ import { POST_MUTATION_OPTIONS } from '@/domains/posts/api/query';
import { PostRecruitmentStatusBottomSheet } from '@/domains/posts/components/post-recruitment-status-bottom-sheet/post-recruitment-status-bottom-sheet';
import { PostRecruitmentStatusButton } from '@/domains/posts/components/post-recruitment-status-button/post-recruitment-status-button';
import type { PostRecruitmentStatusTypes } from '@/domains/posts/model/post-recruitment-status';
import { cn } from '@/lib/cn';
import { POST_QUERY_KEY, RECOMMENDATION_QUERY_KEY } from '@/shared/api';
import { defaultProfileImage } from '@/shared/assets/illustrations';
import { BookmarkIcon } from '@/shared/components/icons';
import { useToast } from '@/shared/components/ui';
import { BookmarkButton, useToast } from '@/shared/components/ui';
import { CommonImage } from '@/shared/components/ui/common-image/common-image';
import { ROUTES } from '@/shared/config';

Expand Down Expand Up @@ -146,20 +144,11 @@ export const PostDetailProfileHeader = ({
/>
</>
) : (
<button
aria-label={isBookmarked ? '북마크 해제' : '북마크 추가'}
aria-pressed={isBookmarked}
className={cn(
'flex size-12 shrink-0 items-center justify-center',
isBookmarked ? 'text-mint-300' : 'text-gray-500',
)}
type="button"
<BookmarkButton
isBookmarked={isBookmarked}
className={isBookmarked ? 'size-12' : 'size-12 text-gray-500'}
onClick={handleBookmarkClick}
>
<BookmarkIcon
className={cn('size-6', isBookmarked && 'fill-current')}
/>
</button>
/>
)}
</header>
);
Expand Down
35 changes: 35 additions & 0 deletions src/shared/components/ui/bookmark-button/bookmark-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
'use client';

import { cn } from '@/lib/cn';
import { BookmarkIcon } from '@/shared/components/icons';

interface BookmarkButtonProps {
isBookmarked: boolean;
onClick: () => void;
className?: string;
}

export const BookmarkButton = ({
isBookmarked,
onClick,
className,
}: BookmarkButtonProps) => {
return (
<button
type="button"
aria-label={isBookmarked ? '북마크 해제' : '북마크 추가'}
aria-pressed={isBookmarked}
className={cn(
'focus-visible:outline-mint-300 flex shrink-0 items-center justify-center focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid',
isBookmarked ? 'text-mint-300' : 'text-gray-200',
className,
)}
onClick={(event) => {
event.stopPropagation();
onClick();
}}
>
<BookmarkIcon className={cn('size-6', isBookmarked && 'fill-current')} />
</button>
);
};
31 changes: 31 additions & 0 deletions src/shared/components/ui/card-list/card-list-skeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { cn } from '@/lib/cn';
import { Skeleton } from '@/shared/components/ui/skeleton/skeleton';

const SKELETON_IMAGE_KEYS = [1, 2, 3, 4] as const;

interface CardListSkeletonProps {
className?: string;
}

export const CardListSkeleton = ({ className }: CardListSkeletonProps) => {
return (
<div
aria-hidden
className={cn(
'flex w-full min-w-0 flex-col items-start gap-3 overflow-clip',
className,
)}
>
<div className="flex w-52 flex-col items-start gap-1">
<Skeleton className="my-0.5 h-5 w-full rounded-[3px]" />
<Skeleton className="my-0.5 h-3.5 w-3/4 rounded-[3px]" />
</div>

<div className="flex items-start gap-2">
{SKELETON_IMAGE_KEYS.map((imageKey) => (
<Skeleton key={imageKey} className="size-25 shrink-0 rounded-lg" />
))}
</div>
Comment thread
jin-evergreen marked this conversation as resolved.
</div>
);
};
90 changes: 90 additions & 0 deletions src/shared/components/ui/card-list/card-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
'use client';

import Link from 'next/link';

import { cn } from '@/lib/cn';
import { BookmarkButton } from '@/shared/components/ui/bookmark-button/bookmark-button';
import { CommonImage } from '@/shared/components/ui/common-image/common-image';

interface CardListImage {
src: string;
alt: string;
}

interface CardListProps {
title: string;
description: string;
images: CardListImage[];
isBookmarked: boolean;
onBookmarkClick: () => void;
href?: string;
className?: string;
}

export const CardList = ({
title,
description,
images,
isBookmarked,
onBookmarkClick,
href,
className,
}: CardListProps) => {
const information = (
<>
<h3 className="text-body-sb-16 w-full truncate text-gray-800">{title}</h3>
<p className="text-caption-m-12 w-full truncate text-gray-500">
{description}
</p>
</>
);

return (
<article
className={cn(
'flex w-full min-w-0 flex-col items-start gap-3',
className,
)}
>
<div className="flex w-full items-center gap-8">
{href ? (
<Link
href={href}
className="focus-visible:outline-mint-300 flex min-w-0 flex-1 flex-col items-start gap-1 rounded-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid"
>
{information}
</Link>
) : (
<div className="flex min-w-0 flex-1 flex-col items-start gap-1">
{information}
</div>
)}

<BookmarkButton
isBookmarked={isBookmarked}
className="h-6 w-6.25"
onClick={onBookmarkClick}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CardList가 추후에는 Link로 감싸져서 상세 코스 게시물로 이동하는 용도로 쓰일 예정이겠죠?? 그렇다면 지금 구조에서는 북마크 버튼 클릭 시 이벤트가 Link까지 전파돼서 상세 페이지로 같이 이동해버리는 문제가 발생할 것 같습니다.

home 도메인에 있던 카드 컴포넌트를 찾아보니까 비슷한 문제를 해결한 BookmarkContainer 패턴(북마크 버튼을 Link 같은 레벨로 분리 + stopPropagation)이 있는데, 이건 도메인 전용 컴포넌트라 현재 shared에 있는 CardList가 직접 가져다 쓰기엔 의존성 방향이 맞지 않아 보여서

1)BookmarkContainer 같은 패턴 자체를 공통 컴포넌트로 분리해서 Card, SummaryCard, CardList (... 그 외 추가되는 카드 컴포넌트들 등등... )가 다 같이 쓰게 하는 방법 또는 2) CardList는 지금처럼 북마크 버튼을 내부에 두되, onClickstopPropagation을 추가해서 간단하게 보완하는 방법이 있을 것 같다고 생각해봤는데 ... 어떻게 생각하시는 지 궁금합니다 !

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

생각하지 못했었는데 좋은 의견인 것 같아요!

어떤 개선 방향이 좋을지 고민해봤는데 BookmarkContainer는 카드별 위치까지 variant로 관리하고 있어 그대로 shared로 옮기는 방향은 적절하지 않다고 판단했어요.

BookmarkContainer 전체를 공통화하면 카드마다 다른 북마크 위치나 레이아웃까지 shared에서 관리해야 해서, home에 필요한 배치 로직은 기존처럼 도메인 내부에 유지했어요. 대신 북마크 상태에 따른 색상, 접근성 속성, 아이콘, stopPropagation 같은 공통 동작은 BookmarkButton에서 일관되게 처리하도록 수정했어요.

CardList에는 optional href를 추가하고, 링크와 북마크 버튼을 형제 요소로 배치해 북마크 클릭 시 상세 페이지로 이동하지 않도록 했어요. 이미지 영역은 가로 스크롤과 키보드 포커스가 필요해서 링크 안에 중첩하지 않았어요.

꼼꼼히 봐주시고 좋은 의견 주셔서 감사합니다~!!

/>
</div>

<div
role="group"
aria-label={`${title} 이미지 목록`}
tabIndex={0}
className="focus-visible:outline-mint-300 flex w-full scrollbar-none items-center gap-2 overflow-x-auto focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid [&::-webkit-scrollbar]:hidden"
>
{images.map(({ src, alt }) => (
<CommonImage
key={src}
src={src}
alt={alt}
width={100}
height={100}
radius="rounded-xl"
className="size-25"
/>
))}
</div>
</article>
);
};
2 changes: 2 additions & 0 deletions src/shared/components/ui/card-list/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { CardList } from './card-list';
export { CardListSkeleton } from './card-list-skeleton';
3 changes: 3 additions & 0 deletions src/shared/components/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ export {
AsyncLoadingState,
type AsyncLoadingStateProps,
} from './async-boundary';
export { BookmarkButton } from './bookmark-button/bookmark-button';
export { BottomActionBar } from './bottom-action-bar/bottom-action-bar';
export { BottomSheet } from './bottom-sheet/bottom-sheet';
export { Button, type ButtonProps } from './button/button';
export { IconButton, type IconButtonProps } from './button/icon-button';
export { Card } from './card/card';
export { CardDate } from './card/card-date';
export { PostStatusTag, type RecruitmentStatus, Tag } from './card/card-tag';
export { CardList, CardListSkeleton } from './card-list';
export { Chip, ChipButton } from './chip/chip';
export { ChipGroup, type ChipGroupProps } from './chip-group/chip-group';
export { CommonImage } from './common-image/common-image';
Expand All @@ -31,6 +33,7 @@ export { ProfileImageInput } from './profile-image-input/profile-image-input';
export { ProgressBar } from './progress-bar/progress-bar';
export { Searchbar, type SearchbarSize } from './searchbar/searchbar';
export { SearchbarWithDropdown } from './searchbar/searchbar-with-dropdown';
export { Skeleton } from './skeleton/skeleton';
export { Tab } from './tab/tab';
export { TextArea, type TextAreaStatus } from './text-area/text-area';
export { TextField, type TextFieldStatus } from './text-field/text-field';
Expand Down
14 changes: 14 additions & 0 deletions src/shared/components/ui/skeleton/skeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { cn } from '@/lib/cn';

interface SkeletonProps {
className?: string;
}

export const Skeleton = ({ className }: SkeletonProps) => {
return (
<div
aria-hidden
className={cn('animate-skeleton-wave bg-gray-200/20', className)}
/>
);
};
30 changes: 30 additions & 0 deletions src/styles/animations.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
@keyframes skeleton-gradient-wave {
to {
transform: translateX(100%);
}
}

@utility animate-skeleton-wave {
position: relative;
overflow: hidden;

&::after {
position: absolute;
inset: 0;
background: linear-gradient(
100deg,
transparent 20%,
color-mix(in srgb, var(--color-white) 55%, transparent) 50%,
transparent 80%
);
content: '';
transform: translateX(-100%);
animation: skeleton-gradient-wave 1.7s linear infinite;
}
}

@media (prefers-reduced-motion: reduce) {
.animate-skeleton-wave::after {
animation: none;
}
}
Loading