logo

Juniverse Dev

headless ui 라이브러리 개발기

headless ui 라이브러리 개발기

나만의 React 용 headless UI 라이브러리를 만들고 npm에 배포까지 해보자

TypescriptReact.jsRollup
74 views
 

개요

웹 개발을 하다 보면, 프로젝트마다 동일한 기능의 컴포넌트를 반복해서 개발해야 하는 경우가 많다.

라이브러리를 사용하기엔 기능이 너무 단순하고, 다른 프로젝트에서 그대로 복사해 붙여넣는 것도 번거롭다. 그래서 여러 프로젝트에서 쉽게 활용할 수 있도록, 나만의 Headless UI를 만들어 배포하기로 했다.

 

설계

개발하기전 몇가지 원칙을 정했다.

1. 유연할것

기존 HTML 태그에 적용할 수 있는 속성들은 Headless 컴포넌트에서도 그대로 사용할 수 있어야 하며, 사용자가 쉽게 변경할 수 있도록 설계해야 한다..

2. 직관적일 것

예를 들어, Dropdown 컴포넌트의 옵션을 props로 전달하는 대신 children으로 넘기는 방식처럼 직관적인 사용성을 추구했다.

ex)

<Dropdown>
  <Dropdown.Item value="option1">Option 1</Dropdown.Item>
  <Dropdown.Item value="option2">Option 2</Dropdown.Item>
</Dropdown>

3. 스타일은 최소화할것

스타일링 방식(Tailwind, CSS, inline styles 등)이 프로젝트마다 다를 수 있기 때문에, 스타일은 최대한 적용하지 않았다. 이는 Headless UI의 정의이기도 하지만, 개인적으로 개발하면서 가장 지키기 어려운 원칙이기도 했다.(기본컴포넌트가 넘 못생겨서)

 

사용한 기술 스택은 다음과 같다

  • Typescript
  • React
  • React-dom
  • Context API
  • Rollup
  • Storybook
  • Jest
  • React Test Libaray

 

Typescript, React, React-dom

react 에서 사용할것이기 때문에 선택.

typescript로 개발했지만 js 에서도 사용할 수 있게끔 tsconfig에 declaration 옵션을 true 로 설정했다.

Context API

일부 컴포넌트들은 컴파운드 컴포넌트 패던을 사용하여 자식 컴포넌트를 분리했는데, 부모컴포넌트와 자식컴포넌트의 상호작용을 위해 context api를 사용했다. react에 내장되어있는 기능이라 의존성 문제에 연연하지 않고 사용할 수 있을것이라 판단했기 때문!

Rollup

가볍고 라이브러리 배포시 가장 많이 사용되는 번들러라 채택했다. 원래 vite로 개발을 진행했었으나, 일부 IDE에서 프로퍼티 힌트가 정상적으로 표시되지 않는 문제가있어 롤업으로 교체.

 

개발과정

프로젝트 세팅

pnpm init

//package.json
{
  "name": "프로젝트 이름",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}
//tsconfig

{
  "compilerOptions": {
    "target": "ES6",
    "lib": ["dom", "es2015", "es2016", "es2019"],
    "module": "ESNext",
    "moduleResolution": "Node",
    "jsx": "react-jsx",
    "declaration": true,
    "declarationDir": "dist/types",
    "outDir": "dist",
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "baseUrl": ".",
    "paths": {
      "tslib": [
        "path/to/node_modules/tslib/tslib.d.ts"
      ]
    }
  },
  "include": [
    "src/**/*",
    "src/setupTests.ts"
  ],
  "exclude": [
    "deprecated/**/*",
    ".storybook/**/*",
    ".storybook/**/*",
    "stories/**/*",
    "**/*.test.ts",
    "**/*.test.tsx",
    "**/__tests__/*"
  ]
}

 

// rollup.config.mjs

export default {
	input: 'src/index.tsx',
	output: [
		{
			file: 'dist/index.js',
			format: 'cjs',
			sourcemap: true,
		},
		{
			file: 'dist/index.esm.js',
			format: 'esm',
			sourcemap: true,
		},
	],
	plugins: [
		resolve(),
		commonjs(),
		typescript({
			tsconfig: './tsconfig.json',
		}),
	],
	external: ['react', 'react-dom'],
};
  • tslib
    • TypeScript의 런타임 라이브러리로, js로 컴파일할때 .ts 파일을 .js로 컴파일하면서, 다양한 기능을 JavaScript에서 지원되도록 변환하는 역할을 함

 

컴포넌트 개발(예시: Button 컴포넌트)

export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  loading?: boolean;
  spinner?: ReactNode;
  prefixElement?: ReactNode;
  suffixElement?: ReactNode;
}

const Button = ({
  prefixElement,
  suffixElement,
  children,
  loading = false,
  spinner = <Spinner color={'black'} size={'1em'} />,
  style,
  ...restProps
}: ButtonProps) => {
  return (
    <button
      {...restProps}
      style={{
        display: 'inline-flex',
        alignItems: 'center',
        ...style,
      }}
    >
      {prefixElement}
      {loading ? spinner : children}
      {suffixElement}
    </button>
  );
};

export default Button;

 스토리 작성

 

const meta: Meta<typeof Button> = {
  title: 'Common/Button/Button',
  component: Button,
  parameters: {
    layout: 'centered',
  },
  argTypes: {
    loading: {
      control: 'boolean',
      description: 'Whether the button is in a loading state',
    },
    prefixElement: {
      control: 'text',
      description: 'Element to display before the button text',
    },
    suffixElement: {
      control: 'text',
      description: 'Element to display after the button text',
    },
    children: {
      control: 'text',
      description: 'Content of the button',
    },
  },
};

export default meta;

type Story = StoryObj<typeof Button>;

export const Default: Story = {
  args: {
    children: 'Click Me',
    loading: false,
    style: {
      padding: '10px 20px',
      backgroundColor: '#007BFF',
      color: 'white',
      border: 'none',
      borderRadius: '4px',
    },
  },
};

export const WithPrefixAndSuffix: Story = {
  args: {
    prefixElement: <span style={{ marginRight: '8px' }}>🚀</span>,
    suffixElement: <span style={{ marginLeft: '8px' }}>🔥</span>,
    children: 'Launch',
    style: {
      padding: '10px 20px',
      backgroundColor: '#28a745',
      color: 'white',
      border: 'none',
      borderRadius: '4px',
    },
  },
};

export const LoadingState: Story = {
  args: {
    children: 'Submitting',
    loading: true,
    spinner: <Spinner color={'white'} size={'1em'} />,
    style: {
      padding: '10px 20px',
      backgroundColor: '#6c757d',
      color: 'white',
      border: 'none',
      borderRadius: '4px',
    },
  },
};

export const CustomStyled: Story = {
  args: {
    children: 'Custom Button',
    style: {
      padding: '12px 24px',
      backgroundColor: '#17a2b8',
      color: 'white',
      border: '2px solid #117a8b',
      borderRadius: '8px',
      fontWeight: 'bold',
    },
  },
};

 테스트코드 작성 ( 향후 Storybook 으로 변경예정)

 

describe('Button component', () => {
  // 1. 버튼이 제대로 렌더링 되는지 확인
  it('renders the button with children text', () => {
    render(<Button>Click me</Button>);
    expect(screen.getByText('Click me')).toBeInTheDocument();
  });

  // 2. loading이 true일 때 spinner가 렌더링되는지 확인
  it('renders a spinner when loading is true', () => {
    render(<Button loading>Click me</Button>);
    expect(screen.getByTestId('spinner')).toBeInTheDocument();
  });

  // 3. loading이 false일 때 children이 렌더링되는지 확인
  it('renders children when loading is false', () => {
    render(<Button loading={false}>Click me</Button>);
    expect(screen.getByText('Click me')).toBeInTheDocument();
  });

  // 4. prefixElement가 제대로 렌더링되는지 확인
  it('renders prefixElement correctly', () => {
    render(<Button prefixElement={<span>Prefix</span>}>Click me</Button>);
    expect(screen.getByText('Prefix')).toBeInTheDocument();
  });

  // 5. suffixElement가 제대로 렌더링되는지 확인
  it('renders suffixElement correctly', () => {
    render(<Button suffixElement={<span>Suffix</span>}>Click me</Button>);
    expect(screen.getByText('Suffix')).toBeInTheDocument();
  });

  // 6. 버튼 클릭 이벤트 확인
  it('fires onClick event when clicked', () => {
    const handleClick = jest.fn();
    render(<Button onClick={handleClick}>Click me</Button>);
    fireEvent.click(screen.getByText('Click me'));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });
});

 배포

pnpm run build
pnpm publish

결과

 

 

구현한 컴포넌트 목록

  • Button
    • loading 옵션추가, loadingt=true 일시 버튼이 비활성화되며 스피너가 돈다
    • prefix/suffix 옵션 추가. 로딩의 영향을 받지 않음
  • Input
    • prefix/suffix 옵션 추가
    • prefix/suffix, input을 감싸 하나의 input 처럼 표시
  • ImageInput
    • 이미지 preview 기능 추가
  • Spinner
    • 로딩 스피너 컴포넌트. 계속 회전하는 애니메이션 효과를 제공.
  • RadioInput
    • 일반적인 라디오 버튼과 동일하지만, 선택 취소가 가능하도록 구현.
  • RadioGroup
    • - 여러 개의 `RadioInput`을 감싸는 그룹 컴포넌트.
    • 선택 취소, 다지선다, 다중선택 보두 지원
  • Modal
    • 모달(팝업). react potal을 사용해 root에 모달을 생성한다.
  • Tab
    • 기본적인 탭 컴포넌트.
  • Accordion
    • 커스텀 아이콘을 지원하여 아이콘을 원하는 것으로 변경 가능.  
  • Dropdown
    •  커스텀 옵션 기능 추가. 옵션으로 컴포넌트를 추가할 수 있다.

향후 계획

  • 번들 크기 최적화
    • 현재 222 kB 인데 200 kB 이내로 감소시키기
  • 추가 컴포넌트 개발
    • Form 아이템
    • Autocomplete
    • Date/Time picker
  • Jest -> Stroybook interaction test로 마이그레이션