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
6 changes: 3 additions & 3 deletions src/components/shared/card/card-info-row/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ type CardInfoRowProps = {
};

export const CardInfoRow = ({ iconId, label }: CardInfoRowProps) => (
<div className='flex items-center gap-1.5'>
<Icon id={iconId} width={12} className='text-gray-600' height={12} />
<span className='text-text-xs-regular text-gray-600'>{label}</span>
<div className='flex min-w-0 items-center gap-1.5'>
<Icon id={iconId} width={12} className='shrink-0 text-gray-600' height={12} />
<span className='text-text-xs-regular truncate text-gray-600'>{label}</span>
</div>
);
64 changes: 64 additions & 0 deletions src/components/shared/card/card-tags/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { render, screen } from '@testing-library/react';

import { type CardTag, CardTags, getLastVisibleIndex } from '.';

// ResizeObserver 목
global.ResizeObserver = class ResizeObserver {
observe = jest.fn();
disconnect = jest.fn();
unobserve = jest.fn();
} as unknown as typeof global.ResizeObserver;

describe('getLastVisibleIndex', () => {
it('태그들이 카드 너비를 넘어가지 않으면 모든 태그를 표시한다', () => {
const maxWidth = 300;
const tagWidths = [50, 60, 70];

const result = getLastVisibleIndex(maxWidth, tagWidths, 4);

expect(result).toBe(2);
});

it('카드 너비가 부족하면 일부 태그만 표시한다', () => {
const maxWidth = 130;
const tagWidths = [50, 60, 70];

const result = getLastVisibleIndex(maxWidth, tagWidths, 4);

expect(result).toBe(1);
});

it('첫 번째 태그도 카드 너비보다 크면 아무 태그도 표시하지 않는다', () => {
const maxWidth = 30;
const tagWidths = [50, 60];

const result = getLastVisibleIndex(maxWidth, tagWidths, 4);

expect(result).toBeNull();
});

it('태그가 없으면 null을 반환한다', () => {
const maxWidth = 200;
const tagWidths: number[] = [];

const result = getLastVisibleIndex(maxWidth, tagWidths, 4);

expect(result).toBeNull();
});
});

describe('CardTags', () => {
const mockTags: CardTag[] = [
{ id: 1, label: '자바' },
{ id: 2, label: '백엔드' },
{ id: 3, label: '스터디' },
];

it('태그 텍스트가 렌더링된다', () => {
render(<CardTags tags={mockTags} />);

mockTags.forEach((tag) => {
expect(screen.getByText(tag.label)).toBeInTheDocument();
});
});
});
109 changes: 100 additions & 9 deletions src/components/shared/card/card-tags/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
'use client';

/* eslint-disable react-hooks/set-state-in-effect */
import { useCallback, useLayoutEffect, useRef, useState } from 'react';

export type CardTag = {
id: string | number;
label: string;
Expand All @@ -7,17 +12,103 @@ type CardTagsProps = {
tags: CardTag[];
};

const TAG_GAP = 4;

const BASE_TAG_CLASSES =
'bg-mint-100 text-text-2xs-medium text-mint-700 inline-flex shrink-0 items-center rounded-full px-2 py-0.5';

const HIDDEN_TAG_CLASSES = `${BASE_TAG_CLASSES} invisible absolute`;

export const getLastVisibleIndex = (
maxWidth: number,
tagWidths: number[],
gap: number,
): number | null => {
let usedWidth = 0;
let lastVisibleIndex: number | null = null;

for (let index = 0; index < tagWidths.length; index++) {
const tagWidth = tagWidths[index];
const extraGap = index === 0 ? 0 : gap;
const requiredWidth = tagWidth + extraGap;

if (usedWidth + requiredWidth <= maxWidth) {
usedWidth += requiredWidth;
lastVisibleIndex = index;
} else {
break;
}
}

return lastVisibleIndex;
};

export const CardTags = ({ tags }: CardTagsProps) => {
const containerRef = useRef<HTMLDivElement>(null);

const tagRefs = useRef<(HTMLSpanElement | null)[]>([]);

const [lastVisibleIndex, setLastVisibleIndex] = useState<number | null>(null);

const updateVisibleTags = useCallback(() => {
const container = containerRef.current;

if (!container || tags.length === 0) {
setLastVisibleIndex(null);
return;
}

const maxWidth = container.offsetWidth;
const tagWidths: number[] = [];

for (let index = 0; index < tags.length; index++) {
const tagElement = tagRefs.current[index];
if (!tagElement) {
tagWidths.push(0);
continue;
}

tagWidths.push(tagElement.offsetWidth);
}

const nextLastVisibleIndex = getLastVisibleIndex(maxWidth, tagWidths, TAG_GAP);
setLastVisibleIndex(nextLastVisibleIndex);
}, [tags]);

useLayoutEffect(() => {
updateVisibleTags();

const container = containerRef.current;
if (!container) return;

const resizeObserver = new ResizeObserver(() => {
updateVisibleTags();
});

resizeObserver.observe(container);

return () => {
resizeObserver.disconnect();
};
}, [updateVisibleTags]);

return (
<div className='mt-1 flex min-h-5 gap-1 overflow-hidden'>
{tags?.map((tag) => (
<span
key={tag.id}
className='bg-mint-100 text-text-2xs-medium text-mint-700 inline-flex shrink-0 items-center rounded-full px-2 py-0.5'
>
{tag.label}
</span>
))}
<div ref={containerRef} className='mt-1 flex min-h-5 gap-1'>
{tags?.map((tag, index) => {
const isVisible = lastVisibleIndex !== null && index <= lastVisibleIndex;

return (
<span
key={tag.id}
ref={(element) => {
tagRefs.current[index] = element;
}}
className={isVisible ? BASE_TAG_CLASSES : HIDDEN_TAG_CLASSES}
>
{tag.label}
</span>
);
})}
</div>
);
};
7 changes: 7 additions & 0 deletions src/components/shared/card/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ import userEvent from '@testing-library/user-event';

import Card from '.';

// ResizeObserver 목
global.ResizeObserver = class ResizeObserver {
observe = jest.fn();
disconnect = jest.fn();
unobserve = jest.fn();
} as unknown as typeof global.ResizeObserver;

describe('Card', () => {
const defaultProps = {
title: '네즈코와 함께하는 자바 스터디',
Expand Down
4 changes: 2 additions & 2 deletions src/components/shared/card/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,9 @@ const Card = ({
>
<div className='flex min-w-0 gap-4'>
<div className='flex flex-col justify-between'>
<div>
<CardThumbnail thumbnail={thumbnail} title={title} />
<CardThumbnail thumbnail={thumbnail} title={title} />

<div className='flex flex-1 items-center'>
<CardProfile nickName={nickName} profileImage={profileImage} />
</div>

Expand Down