Skip to content
Open
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 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';
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>
);
};
82 changes: 82 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,82 @@
'use client';

import { cn } from '@/lib/cn';
import { BookmarkIcon } from '@/shared/components/icons';
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;
className?: string;
}

export const CardList = ({
title,
description,
images,
isBookmarked,
onBookmarkClick,
className,
}: CardListProps) => {
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">
<div className="flex min-w-0 flex-1 flex-col items-start gap-1">
<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>
</div>

<button
type="button"
aria-label={isBookmarked ? '북마크 해제' : '북마크 추가'}
aria-pressed={isBookmarked}
className={cn(
'focus-visible:outline-mint-300 flex h-6 w-6.25 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',
)}
onClick={onBookmarkClick}
>
<BookmarkIcon
className={cn('size-6', isBookmarked && 'fill-current')}
/>
</button>
</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';
2 changes: 2 additions & 0 deletions src/shared/components/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ 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 +32,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