Skip to content

Commit

Permalink
feat(grid): create grid component (#118)
Browse files Browse the repository at this point in the history
* feat(grid): grid component

- Added a new Grid component with customizable grid properties
- Implemented GridItem for individual grid items
- Created Storybook stories for visual testing and documentation
- Added unit tests using Vitest to ensure functionality and style application

* refactor(grid): improve Grid component props and structure

BREAKING CHANGE: Remove sipe-team/card dependency and restructure Grid props

- Remove redundant grid props that are already supported
- Add new GridItem props (colSpan, rowSpan, colStart, colEnd, rowStart, rowEnd)
- Remove sipe-team/card library dependency
- Enhance test coverage for new GridItem props
- Add comprehensive Grid stories demonstrating new features

* chore: update pnpm-lock.yaml

* Create unlucky-rice-grab.md

* chore: update pnpm-lock

* refactor(grid): optimize with default values and memoization

- Add useMemo for memoizing grid column and row calculations
- Provide sensible default values for Grid and GridItem props

---------

Co-authored-by: y09n <[email protected]>
  • Loading branch information
synuns and noahluftyang authored Feb 6, 2025
1 parent 04c2635 commit 08121f0
Show file tree
Hide file tree
Showing 32 changed files with 1,544 additions and 1,034 deletions.
5 changes: 5 additions & 0 deletions .changeset/unlucky-rice-grab.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sipe-team/grid": minor
---

feat(grid): create grid component
12 changes: 2 additions & 10 deletions .storybook/main.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,8 @@
import type { StorybookConfig } from '@storybook/react-vite';

export default {
stories: [
'../docs/*.mdx',
'../packages/**/*.mdx',
'../packages/**/*.stories.@(js|jsx|mjs|ts|tsx)',
],
addons: [
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'@storybook/addon-links',
],
stories: ['../docs/*.mdx', '../packages/**/*.mdx', '../packages/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: ['@storybook/addon-essentials', '@storybook/addon-interactions', '@storybook/addon-links'],
framework: {
name: '@storybook/react-vite',
options: {},
Expand Down
13 changes: 8 additions & 5 deletions eslint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,14 @@ export default tseslint.config(
'@typescript-eslint/no-explicit-any': 'off',

'no-duplicate-imports': 'off',
'no-unused-expressions': "off",
'@typescript-eslint/no-unused-expressions': ['error', {
allowShortCircuit: false,
allowTernary: true
}]
'no-unused-expressions': 'off',
'@typescript-eslint/no-unused-expressions': [
'error',
{
allowShortCircuit: false,
allowTernary: true,
},
],
},
},

Expand Down
16 changes: 2 additions & 14 deletions packages/Input/src/Input.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,26 +35,14 @@ export const Default: Story = {

render: (args) => {
const [value, setValue] = useState('value');
return (
<Input
value={value}
onChange={(e) => setValue(e.target.value)}
{...args}
/>
);
return <Input value={value} onChange={(e) => setValue(e.target.value)} {...args} />;
},
};

export const disabled: Story = {
render: (args) => {
const [value, setValue] = useState(args.value);
return (
<Input
value={value}
onChange={(e) => setValue(e.target.value)}
{...args}
/>
);
return <Input value={value} onChange={(e) => setValue(e.target.value)} {...args} />;
},
args: {
disabled: true,
Expand Down
28 changes: 14 additions & 14 deletions packages/avatar/src/Avatar.module.css
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
.avatar {
width: var(--avatar-size);
height: var(--avatar-size);
border-radius: var(--avatar-shape);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background-color: #e2e8f0;
width: var(--avatar-size);
height: var(--avatar-size);
border-radius: var(--avatar-shape);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background-color: #e2e8f0;
}

.image {
width: 100%;
height: 100%;
object-fit: cover;
width: 100%;
height: 100%;
object-fit: cover;
}

.fallback {
font-size: 0.8rem;
color: #2d3748;
text-align: center;
font-size: 0.8rem;
color: #2d3748;
text-align: center;
}
90 changes: 38 additions & 52 deletions packages/avatar/src/Avatar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,74 +1,60 @@
import { faker } from "@faker-js/faker";
import { render, screen } from "@testing-library/react";
import { expect, test, describe, it } from "vitest";
import { Avatar } from "./Avatar";
import type { AvatarShape, AvatarSize } from "./Avatar";
import { faker } from '@faker-js/faker';
import { render, screen } from '@testing-library/react';
import { expect, test, describe, it } from 'vitest';
import { Avatar } from './Avatar';
import type { AvatarShape, AvatarSize } from './Avatar';

const testImage = faker.image.avatar();

test("Avatar 컴포넌트가 주입받은 이미지 주소를 src 속성으로 설정한다.", () => {
test('Avatar 컴포넌트가 주입받은 이미지 주소를 src 속성으로 설정한다.', () => {
render(<Avatar src={testImage} alt="대체 텍스트" />);

const img = screen.getByRole("img");
expect(img).toHaveAttribute("src", testImage);
const img = screen.getByRole('img');
expect(img).toHaveAttribute('src', testImage);
});

test("이미지가 없을 경우 대체 텍스트를 표시한다.", () => {
test('이미지가 없을 경우 대체 텍스트를 표시한다.', () => {
render(<Avatar alt="대체 텍스트" />);

expect(screen.getByText("대체 텍스트")).toBeInTheDocument();
expect(screen.getByText('대체 텍스트')).toBeInTheDocument();
});

test("이미지 로드 실패 시 fallback을 표시한다.", () => {
render(
<Avatar
src="broken-link"
fallback="https://randomuser.me/api/portraits/women/1.jpg"
/>
);
test('이미지 로드 실패 시 fallback을 표시한다.', () => {
render(<Avatar src="broken-link" fallback="https://randomuser.me/api/portraits/women/1.jpg" />);

const img = screen.getByRole("img");
img.dispatchEvent(new Event("error"));
const img = screen.getByRole('img');
img.dispatchEvent(new Event('error'));

expect(img).toHaveAttribute(
"src",
"https://randomuser.me/api/portraits/women/1.jpg"
);
expect(img).toHaveAttribute('src', 'https://randomuser.me/api/portraits/women/1.jpg');
});

describe("Avatar 컴포넌트", () => {
describe('Avatar 컴포넌트', () => {
const sizes: { size: AvatarSize; expectedSize: string }[] = [
{ size: "xs", expectedSize: "24px" },
{ size: "sm", expectedSize: "32px" },
{ size: "md", expectedSize: "40px" },
{ size: "lg", expectedSize: "70px" },
{ size: "xl", expectedSize: "96px" },
{ size: 'xs', expectedSize: '24px' },
{ size: 'sm', expectedSize: '32px' },
{ size: 'md', expectedSize: '40px' },
{ size: 'lg', expectedSize: '70px' },
{ size: 'xl', expectedSize: '96px' },
];

const shapes: { shape: AvatarShape; expectedRadius: string }[] = [
{ shape: "circle", expectedRadius: "50%" },
{ shape: "rounded", expectedRadius: "4px" },
{ shape: "square", expectedRadius: "0px" },
{ shape: 'circle', expectedRadius: '50%' },
{ shape: 'rounded', expectedRadius: '4px' },
{ shape: 'square', expectedRadius: '0px' },
];

it.each(sizes)(
"size가 $size일때 $expectedSize x $expectedSize 크기로 렌더링 된다.",
({ size, expectedSize }) => {
render(<Avatar src={testImage} size={size} />);
const container = screen.getByRole("img").parentElement;
expect(container).toHaveStyle({
width: expectedSize,
height: expectedSize,
});
}
);

it.each(shapes)(
"shape가 $shape일때 borderRadius는 $expectedRadius로 나타난다.",
({ shape, expectedRadius }) => {
render(<Avatar src={testImage} shape={shape} />);
const container = screen.getByRole("img").parentElement;
expect(container).toHaveStyle({ borderRadius: expectedRadius });
}
);
it.each(sizes)('size가 $size일때 $expectedSize x $expectedSize 크기로 렌더링 된다.', ({ size, expectedSize }) => {
render(<Avatar src={testImage} size={size} />);
const container = screen.getByRole('img').parentElement;
expect(container).toHaveStyle({
width: expectedSize,
height: expectedSize,
});
});

it.each(shapes)('shape가 $shape일때 borderRadius는 $expectedRadius로 나타난다.', ({ shape, expectedRadius }) => {
render(<Avatar src={testImage} shape={shape} />);
const container = screen.getByRole('img').parentElement;
expect(container).toHaveStyle({ borderRadius: expectedRadius });
});
});
31 changes: 4 additions & 27 deletions packages/card/src/Card.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,7 @@
import { Slot } from '@radix-ui/react-slot';
import { color } from '@sipe-team/tokens';
import { clsx as cx } from 'clsx';
import {
type CSSProperties,
type ComponentProps,
type ForwardedRef,
forwardRef,
} from 'react';
import { type CSSProperties, type ComponentProps, type ForwardedRef, forwardRef } from 'react';
import styles from './Card.module.css';

export type CardRatio = 'rectangle' | 'square' | 'wide' | 'portrait' | 'auto';
Expand All @@ -19,23 +14,13 @@ export interface CardProps extends ComponentProps<'div'> {
}

export const Card = forwardRef(function Card(
{
className,
ratio = 'rectangle',
style: _style,
variant = 'filled',
asChild,
...props
}: CardProps,
{ className, ratio = 'rectangle', style: _style, variant = 'filled', asChild, ...props }: CardProps,
ref: ForwardedRef<HTMLDivElement>,
) {
const style = {
'--padding': '20px',
'--background-color': getBackgroundColor(variant),
'--border':
variant === 'outline'
? `1px solid ${color.cyan300}`
: `1px solid ${color.gray200}`,
'--border': variant === 'outline' ? `1px solid ${color.cyan300}` : `1px solid ${color.gray200}`,
'--aspect-ratio': getAspectRatio(ratio),
display: 'flex',
justifyContent: 'center',
Expand All @@ -45,15 +30,7 @@ export const Card = forwardRef(function Card(

const Comp = asChild ? Slot : 'div';

return (
<Comp
className={cx(styles.card, className)}
ref={ref}
role="presentation"
style={style}
{...props}
/>
);
return <Comp className={cx(styles.card, className)} ref={ref} role="presentation" style={style} {...props} />;
});

const backgroundColors: Record<CardVariant, string> = {
Expand Down
3 changes: 1 addition & 2 deletions packages/checkbox/src/Checkbox.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
background-color: var(--background-color);
}


.checkbox-input:disabled {
background-color: var(--disabled-color);
border-color: var(--disabled-color);
Expand Down Expand Up @@ -45,4 +44,4 @@

.checkbox-input:indeterminate {
background-image: var(--indeterminate-icon);
}
}
19 changes: 7 additions & 12 deletions packages/checkbox/src/Checkbox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,22 +58,17 @@ describe('Checkbox 스타일 테스트', () => {
expect(checkbox.disabled).toBe(true);
});

test.each(['small', 'medium', 'large'])(
'size로 %s주입시 해당 크기로 checkbox의 크기를 설정한다.',
(size) => {
render(<Checkbox size={size as CheckboxSize} label="Test Checkbox" />);
const checkbox = screen.getByLabelText('Test Checkbox').parentElement;
const expectedSize = CHECKBOX_SIZES[size as CheckboxSize].checkboxSize;
expect(checkbox).toHaveStyle(`--checkbox-size: ${expectedSize}px`);
},
);
test.each(['small', 'medium', 'large'])('size로 %s주입시 해당 크기로 checkbox의 크기를 설정한다.', (size) => {
render(<Checkbox size={size as CheckboxSize} label="Test Checkbox" />);
const checkbox = screen.getByLabelText('Test Checkbox').parentElement;
const expectedSize = CHECKBOX_SIZES[size as CheckboxSize].checkboxSize;
expect(checkbox).toHaveStyle(`--checkbox-size: ${expectedSize}px`);
});

test('size를 주입하지 않으면 기본 값 medium로 크기를 설정한다.', () => {
render(<Checkbox label="Test Checkbox" />);
const checkbox = screen.getByLabelText('Test Checkbox').parentElement;
expect(checkbox).toHaveStyle(
`--checkbox-size: ${CHECKBOX_SIZES.medium.checkboxSize}px`,
);
expect(checkbox).toHaveStyle(`--checkbox-size: ${CHECKBOX_SIZES.medium.checkboxSize}px`);
});
});

Expand Down
15 changes: 3 additions & 12 deletions packages/checkbox/src/hooks/useCheckboxGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,11 @@ import { useCallback, useState } from 'react';

interface UseCheckboxGroupProps {
total: number;
onChange?: (
checkedItems: boolean[],
allChecked: boolean,
indeterminate: boolean,
) => void;
onChange?: (checkedItems: boolean[], allChecked: boolean, indeterminate: boolean) => void;
}

export const useCheckboxGroup = ({
total,
onChange,
}: UseCheckboxGroupProps) => {
const [checkedItems, setCheckedItems] = useState<boolean[]>(
new Array(total).fill(false),
);
export const useCheckboxGroup = ({ total, onChange }: UseCheckboxGroupProps) => {
const [checkedItems, setCheckedItems] = useState<boolean[]>(new Array(total).fill(false));

const updateCheckedItems = useCallback(
(index: number, checked: boolean) => {
Expand Down
4 changes: 1 addition & 3 deletions packages/flex/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,7 @@
},
"type": "module",
"exports": "./src/index.ts",
"files": [
"dist"
],
"files": ["dist"],
"scripts": {
"build": "tsup",
"build:storybook": "storybook build",
Expand Down
15 changes: 15 additions & 0 deletions packages/grid/.storybook/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { StorybookConfig } from '@storybook/react-vite';

export default {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: [
'@storybook/addon-onboarding',
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
],
framework: {
name: '@storybook/react-vite',
options: {},
},
} satisfies StorybookConfig;
5 changes: 5 additions & 0 deletions packages/grid/.storybook/preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { Preview } from '@storybook/react';

export default {
tags: ['autodocs'],
} satisfies Preview;
1 change: 1 addition & 0 deletions packages/grid/global.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
declare module '*.module.css';
Loading

0 comments on commit 08121f0

Please sign in to comment.