React - 메모 추가 및 제거 UI

김명원·2025년 1월 8일
0

learnReact

목록 보기
16/26

메모 추가 및 제거 UI

Note 컴포넌트 추가&제거


1. src/components/CanvasCard.jsx

import { FaPlus } from 'react-icons/fa';
import Note from './Note';
import { useState } from 'react';
import { v4 as uuidv4 } from 'uuid';

function CanvasCard({ title, isSubtitle = false }) {
  const [notes, setNotes] = useState([]);

  const handleAddNote = () => {
    setNotes([...notes, { id: uuidv4(), content: '' }]);
  };
  
  const handleRemoveNote = id => {
    setNotes(notes.filter(note => note.id !== id));
  };

  return (
    <div className="row-span-1 bg-white min-h-48 border border-collapse border-gray-300">
      <div
        className={`${isSubtitle === false && 'bg-gray-100 border-b border-b-gray-300'} flex items-start justify-between px-3 py-2`}
      >
        <h3 className={`${isSubtitle === false && 'font-bold'} `}>{title}</h3>
        <button
          className="bg-blue-400 text-white p-1.5 text-xs rounded-md"
          onClick={handleAddNote}
        >
          <FaPlus />
        </button>
      </div>
      <div className="space-y-3 min-h-32 p-3">
        {notes.map(note => (
          <Note
            key={note.id}
            id={note.id}
            content={note.content}
            onRemoveNote={handleRemoveNote}
          />
        ))}
      </div>
    </div>
  );
}

export default CanvasCard;

설명:

  • 추가된 임포트:
    • Note 컴포넌트를 임포트하여 각 CanvasCard 내에 메모를 추가할 수 있게 했습니다.
    • useStateuuidv4를 임포트하여 메모의 상태 관리와 고유 ID 생성을 가능하게 했습니다.
  • 상태 관리 (notes):
    • notes 상태를 도입하여 각 CanvasCard 내의 메모 목록을 관리합니다.
  • 메모 추가 함수 (handleAddNote):
    • 새로운 메모를 추가할 때마다 고유 ID와 빈 내용을 가진 메모 객체를 notes 배열에 추가합니다.
  • 메모 제거 함수 (handleRemoveNote):
    • 특정 메모의 ID를 받아 해당 메모를 notes 배열에서 제거합니다.
  • 추가된 버튼:
    • FaPlus 아이콘이 있는 버튼을 헤더에 추가하여 사용자가 메모를 추가할 수 있도록 했습니다.
  • 메모 렌더링:
    • notes 배열을 순회하며 각 메모를 Note 컴포넌트로 렌더링합니다. 이를 통해 각 메모는 독립적으로 관리되고 표시됩니다.

결과:

  • 사용자는 각 린캔버스 섹션에서 메모를 추가하고 제거할 수 있습니다.
  • 메모는 고유한 ID를 가지며, 상태 관리 덕분에 동적으로 추가 및 제거됩니다.

2. src/components/Note.jsx

import { AiOutlineClose } from 'react-icons/ai';

function Note({ id, onRemoveNote }) {
  return (
    <div className="border border-black">
      <button onClick={() => onRemoveNote(id)}>
        <AiOutlineClose size={20} />
      </button>
    </div>
  );
}

export default Note;

설명:

  • 삭제 기능 추가:
    • AiOutlineClose 아이콘을 임포트하여 메모 삭제 버튼에 사용합니다.
  • 삭제 버튼:
    • 버튼을 클릭하면 onRemoveNote 함수가 호출되어 해당 메모가 삭제됩니다.
  • 스타일링:
    • 메모 컨테이너에 검정색 테두리를 추가하여 시각적으로 구분됩니다.

결과:

  • 사용자는 각 메모 옆의 삭제 버튼을 클릭하여 원치 않는 메모를 쉽게 제거할 수 있습니다.

실습 - <Note> 컴포넌트


1. 마크업

src/components/Note.jsx

import { AiOutlineClose, AiOutlineCheck } from 'react-icons/ai';

const Note = () => {
  const colorOptions = [
    'bg-yellow-300',
    'bg-pink-300',
    'bg-blue-300',
    'bg-green-300',
  ];

  return (
    <div className={`p-4 bg-yellow-300 relative max-h-[32rem] overflow-hidden`}>
      <div className="absolute top-2 right-2">
        <button aria-label="Check Note" className="text-gray-700">
          <AiOutlineCheck size={20} />
        </button>
        <button aria-label="Close Note" className="text-gray-700">
          <AiOutlineClose size={20} />
        </button>
      </div>
      <textarea
        className={`w-full h-full bg-transparent resize-none border-none focus:outline-none text-gray-900 overflow-hidden`}
        aria-label="Edit Note"
        placeholder="메모를 작성하세요."
        style={{ height: 'auto', minHeight: '8rem' }}
      />
      <div className="flex space-x-2">
        {colorOptions.map((option, index) => (
          <button
            key={index}
            className={`w-6 h-6 rounded-full cursor-pointer outline outline-gray-50 ${option}`}
            aria-label={`Change color to ${option}`}
          />
        ))}
      </div>
    </div>
  );
};

export default Note;

설명:

  • 목적: Note 컴포넌트는 사용자 메모를 입력하고 관리할 수 있는 인터페이스를 제공합니다.
  • 구성 요소:
    • 아이콘 버튼:
      • AiOutlineCheck: 메모 저장 버튼으로, 메모 편집을 완료할 때 사용됩니다.
      • AiOutlineClose: 메모 삭제 버튼으로, 메모를 제거할 때 사용됩니다.
    • 텍스트 영역 (textarea):
      • 사용자 메모를 입력할 수 있는 필드입니다.
      • 자동 높이 조절과 최소 높이를 설정하여 사용자 경험을 향상시킵니다.
    • 컬러 옵션 버튼:
      • 다양한 배경색을 선택할 수 있는 버튼들로, 메모의 시각적 구분을 돕습니다.
  • 스타일링:
    • Tailwind CSS 클래스를 사용하여 패딩, 배경색, 테두리, 레이아웃 등을 설정했습니다.
    • relativeabsolute 포지셔닝을 사용하여 아이콘 버튼을 메모 상단 오른쪽에 배치했습니다.

결과:

  • 사용자는 메모를 작성하고, 색상을 변경하며, 저장 또는 삭제할 수 있는 직관적인 UI를 경험할 수 있습니다.

2. 실습 - 수정 모드 추가

src/components/Note.jsx

import { useState } from 'react';
import { AiOutlineClose, AiOutlineCheck } from 'react-icons/ai';

const Note = ({ id, onRemoveNote }) => {
  const colorOptions = [
    'bg-yellow-300',
    'bg-pink-300',
    'bg-blue-300',
    'bg-green-300',
  ];

  const [isEditing, setIsEditing] = useState(false);

  return (
    <div
      className={`p-4 bg-yellow-300 relative max-h-[32rem] overflow-hidden`}
      onClick={() => setIsEditing(true)}
    >
      <div className="absolute top-2 right-2">
        {isEditing ? (
          <button
            aria-label="Check Note"
            className="text-gray-700"
            onClick={e => {
              e.stopPropagation();
              setIsEditing(false);
            }}
          >
            <AiOutlineCheck size={20} />
          </button>
        ) : (
          <button
            aria-label="Close Note"
            className="text-gray-700"
            onClick={() => onRemoveNote(id)}
          >
            <AiOutlineClose size={20} />
          </button>
        )}
      </div>
      <textarea
        className={`w-full h-full bg-transparent resize-none border-none focus:outline-none text-gray-900 overflow-hidden`}
        aria-label="Edit Note"
        placeholder="메모를 작성하세요."
        style={{ height: 'auto', minHeight: '8rem' }}
        readOnly={!isEditing}
      />
      {isEditing && (
        <div className="flex space-x-2">
          {colorOptions.map((option, index) => (
            <button
              key={index}
              className={`w-6 h-6 rounded-full cursor-pointer outline outline-gray-50 ${option}`}
              aria-label={`Change color to ${option}`}
            />
          ))}
        </div>
      )}
    </div>
  );
};

export default Note;

설명:

  • 편집 모드 (isEditing) 추가:
    • useState 훅을 사용하여 메모의 편집 상태를 관리합니다.
    • 사용자가 메모 영역을 클릭하면 편집 모드로 전환됩니다.
  • 조건부 렌더링:
    • 편집 중일 때 (isEditing === true):
      • 체크 아이콘 버튼(AiOutlineCheck)을 표시하여 편집을 완료할 수 있게 합니다.
      • 컬러 옵션 버튼들이 나타나 사용자가 메모의 배경색을 변경할 수 있습니다.
    • 편집 중이 아닐 때 (isEditing === false):
      • 삭제 아이콘 버튼(AiOutlineClose)을 표시하여 메모를 삭제할 수 있게 합니다.
  • 이벤트 핸들링:
    • 편집 완료 버튼:
      • 클릭 시 isEditingfalse로 설정하여 편집 모드를 종료합니다.
      • e.stopPropagation()을 호출하여 부모 요소의 클릭 이벤트가 트리거되지 않도록 합니다.
    • 삭제 버튼:
      • 클릭 시 onRemoveNote 함수를 호출하여 해당 메모를 삭제합니다.
  • 텍스트 영역 (textarea):
    • readOnly 속성을 사용하여 편집 모드에 따라 입력 가능 여부를 제어합니다.

결과:

  • 사용자는 메모를 클릭하여 편집 모드로 전환할 수 있으며, 편집을 완료하거나 메모를 삭제할 수 있습니다.
  • 편집 모드에서는 메모의 배경색을 변경할 수 있는 옵션이 제공되어 개인화된 메모 관리가 가능합니다.

3. 실습 - textarea height 조절

src/components/Note.jsx

import { useEffect, useRef, useState } from 'react';
import { AiOutlineClose, AiOutlineCheck } from 'react-icons/ai';

const Note = ({ id, onRemoveNote }) => {
  const colorOptions = [
    'bg-yellow-300',
    'bg-pink-300',
    'bg-blue-300',
    'bg-green-300',
  ];

  const [isEditing, setIsEditing] = useState(false);

  const textareaRef = useRef(null);
  const [content, setContent] = useState('');
  
  useEffect(() => {
    if (textareaRef.current) {
      textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
    }
  }, [content]);

  return (
    <div
      className={`p-4 bg-yellow-300 relative max-h-[32rem] overflow-hidden`}
      onClick={() => setIsEditing(true)}
    >
      <div className="absolute top-2 right-2">
        {isEditing ? (
          <button
            aria-label="Check Note"
            className="text-gray-700"
            onClick={e => {
              e.stopPropagation();
              setIsEditing(false);
            }}
          >
            <AiOutlineCheck size={20} />
          </button>
        ) : (
          <button
            aria-label="Close Note"
            className="text-gray-700"
            onClick={() => onRemoveNote(id)}
          >
            <AiOutlineClose size={20} />
          </button>
        )}
      </div>
      <textarea
        ref={textareaRef}
        value={content}
        onChange={e => setContent(e.target.value)}
        className={`w-full h-full bg-transparent resize-none border-none focus:outline-none text-gray-900 overflow-hidden`}
        aria-label="Edit Note"
        placeholder="메모를 작성하세요."
        style={{ height: 'auto', minHeight: '8rem' }}
        readOnly={!isEditing}
      />
      {isEditing && (
        <div className="flex space-x-2">
          {colorOptions.map((option, index) => (
            <button
              key={index}
              className={`w-6 h-6 rounded-full cursor-pointer outline outline-gray-50 ${option}`}
              aria-label={`Change color to ${option}`}
            />
          ))}
        </div>
      )}
    </div>
  );
};

export default Note;

설명:

  • 자동 높이 조절:
    • useRef를 사용하여 textarea DOM 요소에 직접 접근합니다.
    • useEffect 훅을 통해 content가 변경될 때마다 textarea의 높이를 자동으로 조절합니다. 이는 사용자 입력에 따라 textarea가 적절한 높이로 확장되도록 합니다.
  • 상태 관리 (content):
    • content 상태를 도입하여 사용자가 입력한 메모 내용을 관리합니다.
  • 텍스트 영역 (textarea):
    • valueonChange 핸들러를 통해 content 상태를 동기화합니다.
    • ref를 통해 DOM 요소에 접근하여 높이를 동적으로 조절합니다.

결과:

  • 사용자가 메모를 입력할 때 textarea의 높이가 자동으로 조절되어 스크롤 없이 모든 내용을 볼 수 있습니다.
  • 이는 사용자 경험을 향상시키고, 메모 작성 시 편리함을 제공합니다.

4. 실습 - 컬러 변경 옵션 추가

src/components/Note.jsx

import { useEffect, useRef, useState } from 'react';
import { AiOutlineClose, AiOutlineCheck } from 'react-icons/ai';

const Note = ({ id, onRemoveNote }) => {
  const colorOptions = [
    'bg-yellow-300',
    'bg-pink-300',
    'bg-blue-300',
    'bg-green-300',
  ];

  // 0, 1, 2, 3
  const randomIndex = Math.floor(Math.random() * colorOptions.length);

  const [color, setColor] = useState(colorOptions[randomIndex]);

  const [isEditing, setIsEditing] = useState(false);

  const textareaRef = useRef(null);
  const [content, setContent] = useState('');
  
  useEffect(() => {
    if (textareaRef.current) {
      textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
    }
  }, [content]);

  return (
    <div
      className={`p-4 ${color} relative max-h-[32rem] overflow-hidden`}
      onClick={() => setIsEditing(true)}
    >
      <div className="absolute top-2 right-2">
        {isEditing ? (
          <button
            aria-label="Check Note"
            className="text-gray-700"
            onClick={e => {
              e.stopPropagation();
              setIsEditing(false);
            }}
          >
            <AiOutlineCheck size={20} />
          </button>
        ) : (
          <button
            aria-label="Close Note"
            className="text-gray-700"
            onClick={() => onRemoveNote(id)}
          >
            <AiOutlineClose size={20} />
          </button>
        )}
      </div>
      <textarea
        ref={textareaRef}
        value={content}
        onChange={e => setContent(e.target.value)}
        className={`w-full h-full bg-transparent resize-none border-none focus:outline-none text-gray-900 overflow-hidden`}
        aria-label="Edit Note"
        placeholder="메모를 작성하세요."
        style={{ height: 'auto', minHeight: '8rem' }}
        readOnly={!isEditing}
      />
      {isEditing && (
        <div className="flex space-x-2">
          {colorOptions.map((option, index) => (
            <button
              key={index}
              className={`w-6 h-6 rounded-full cursor-pointer outline outline-gray-50 ${option}`}
              onClick={() => setColor(option)}
              aria-label={`Change color to ${option}`}
            />
          ))}
        </div>
      )}
    </div>
  );
};

export default Note;

설명:

  • 랜덤 초기 색상 선택:
    • randomIndex를 사용하여 메모가 생성될 때마다 colorOptions 중 하나의 색상을 무작위로 선택합니다.
    • 이를 통해 각 메모가 다양한 색상으로 시작되어 시각적 다양성을 제공합니다.
  • 색상 상태 (color):
    • color 상태를 도입하여 메모의 배경색을 관리합니다.
    • 사용자가 색상 옵션 버튼을 클릭하면 setColor 함수를 통해 배경색을 변경합니다.
  • 색상 변경 버튼:
    • colorOptions 배열을 순회하며 각 색상에 해당하는 버튼을 생성합니다.
    • 각 버튼은 해당 색상으로 메모의 배경색을 변경하는 역할을 합니다.
  • 클릭 이벤트 (onClick):
    • 색상 버튼을 클릭하면 setColor(option)을 호출하여 메모의 배경색을 업데이트합니다.

결과:

  • 메모는 생성 시 무작위로 선택된 색상으로 시작되며, 사용자는 편집 모드에서 원하는 색상으로 쉽게 변경할 수 있습니다.
  • 이는 메모의 시각적 구분을 용이하게 하여 사용자 경험을 향상시킵니다.

실습 - <Header> z-index 추가


src/components/Header.jsx

import { Link, NavLink } from 'react-router-dom';
import {
  FaHome,
  FaInfoCircle,
  FaEnvelope,
  FaBars,
  FaTimes,
} from 'react-icons/fa';
import { useState } from 'react';

function Header() {
  const navItems = [
    { id: 'home', label: 'Home', icon: <FaHome />, to: '/' },
    { id: 'about', label: 'About', icon: <FaInfoCircle />, to: '/about' },
    { id: 'contact', label: 'Contact', icon: <FaEnvelope />, to: '/contact' },
  ];

  const [isMenuOpen, setIsMenuOpen] = useState(false);
  const toggleMenu = () => setIsMenuOpen(!isMenuOpen);
  
  return (
    <header className="sticky top-0 bg-gray-800 text-white z-30">
      <div className="container mx-auto flex justify-between items-center h-14">
        <div>
          <Link to="/" className="text-xl font-bold">
            Lean Canvas
          </Link>
        </div>
        <nav className="hidden md:flex space-x-4">
          {navItems.map(item => (
            <NavLink
              key={item.id}
              to={item.to}
              className={({ isActive }) =>
                isActive ? 'text-blue-700' : 'hover:text-gray-300'
              }
            >
              {item.icon} {item.label}
            </NavLink>
          ))}
        </nav>
        <button className="md:hidden" onClick={toggleMenu}>
          <FaBars />
        </button>
        <button className="hidden md:block bg-blue-500 hover:bg-blue-600 text-white font-bold py-1.5 px-4 rounded transition-colors duration-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50">
          짐코딩 강의
        </button>
      </div>

      {/* Mobile Menu */}
      <aside
        className={`
          fixed top-0 left-0 w-64 h-full bg-gray-800 z-40
          ${isMenuOpen ? 'translate-x-0' : '-translate-x-full'}
          md:hidden transform transition-transform duration-300 ease-in-out
        `}
      >
        <div className="flex justify-end p-4">
          <button
            className="text-white focus:outline-none"
            aria-label="Close menu"
            onClick={toggleMenu}
          >
            <FaTimes className="h-6 w-6" />
          </button>
        </div>
        <nav className="flex flex-col space-y-4 p-4">
          {navItems.map(item => (
            <NavLink
              key={item.id}
              to={item.to}
              className="hover:text-gray-300"
              onClick={toggleMenu} // 메뉴 클릭 시 닫기
            >
              {item.icon} {item.label}
            </NavLink>
          ))}
        </nav>
      </aside>
    </header>
  );
}

export default Header;

설명:

  • z-index 추가 (z-30):
    • header 요소에 z-30 클래스를 추가하여 다른 요소들보다 우선적으로 렌더링되도록 설정했습니다.
    • 이는 스크롤 시 헤더가 다른 콘텐츠 위에 고정되어 항상 보이도록 보장합니다.
  • 모바일 메뉴 z-index 증가 (z-40):
    • 모바일 메뉴(aside)에 z-40 클래스를 추가하여 헤더보다 높은 우선순위를 가지도록 했습니다.
    • 이는 모바일 메뉴가 헤더 위에 겹쳐 보이도록 하여 사용자 인터랙션 시 혼란을 방지합니다.
  • 변경된 클래스:
    • header의 클래스명을 sticky top-0 bg-gray-800 text-white z-30으로 변경하여 고정 위치와 z-index를 설정했습니다.
    • 모바일 메뉴의 클래스명을 fixed top-0 left-0 w-64 h-full bg-gray-800 z-40으로 변경하여 고정 위치와 높은 z-index를 적용했습니다.

결과:

  • 헤더 고정 및 가시성 향상:
    • 페이지를 스크롤해도 헤더가 항상 상단에 고정되어 사용자에게 일관된 네비게이션을 제공합니다.
  • 모바일 메뉴의 우선순위 조정:
    • 모바일 메뉴가 헤더 위에 표시되어 메뉴가 열렸을 때 헤더와 겹치지 않고 명확하게 보입니다.
  • 레이어 관리:
    • z-index를 활용하여 헤더와 모바일 메뉴 간의 레이어 우선순위를 효과적으로 관리했습니다. 이는 UI 요소 간의 겹침 문제를 해결하고, 사용자 경험을 향상시킵니다.

전체적인 구조와 동작 방식


  • 컴포넌트 분리:

    • CanvasCard: 린캔버스의 각 섹션을 카드 형태로 표시하며, 메모를 추가하고 제거할 수 있는 기능을 제공합니다.
    • Note: 사용자가 메모를 작성하고 관리할 수 있는 컴포넌트로, 편집 모드, 자동 높이 조절, 색상 변경 기능을 포함합니다.
    • LeanCanvas: 린캔버스의 전체 레이아웃을 그리드 시스템으로 구성하며, 여러 CanvasCard를 배치합니다.
    • Main: 주요 콘텐츠 영역을 감싸는 레이아웃 컴포넌트로, 일관된 패딩과 중앙 정렬을 제공합니다.
    • Header: 네비게이션 바를 포함하며, 반응형 디자인과 모바일 메뉴를 지원합니다.
    • SearchBar & ViewToggle: 검색 기능과 그리드/리스트 보기 전환 기능을 제공하여 사용자 경험을 향상시킵니다.
  • 상태 관리:

    • Home 페이지: searchText, isGridView, dummyData 등의 상태를 관리하여 검색, 보기 방식, 데이터 목록을 제어합니다.
    • CanvasCard: 각 카드 내에서 notes 상태를 관리하여 메모의 추가 및 제거를 처리합니다.
    • Note: isEditing, content, color 등의 상태를 관리하여 메모의 편집, 내용, 색상 변경을 지원합니다.
  • 반응형 디자인:

    • Tailwind CSS의 유틸리티 클래스를 사용하여 다양한 화면 크기에 대응하는 레이아웃을 구현했습니다.
    • 예를 들어, grid-cols-5grid-cols-2 클래스를 사용하여 그리드의 열 수를 조정하고, sm:flex-row 클래스를 통해 작은 화면에서는 세로로, 큰 화면에서는 가로로 레이아웃을 변경합니다.
  • 접근성:

    • aria-label 속성을 사용하여 버튼과 입력 필드의 목적을 명확히 전달했습니다. 이는 스크린 리더 사용자에게 유용합니다.
    • 시맨틱 HTML 요소와 적절한 ARIA 속성을 사용하여 모든 사용자가 접근 가능한 UI를 구현했습니다.
  • 애니메이션과 트랜지션:

    • Tailwind CSS의 transition-transform, duration-300, hover:scale-105 등을 활용하여 사용자 인터랙션 시 부드러운 애니메이션 효과를 추가했습니다.
    • 삭제 버튼과 추가 버튼에 호버 효과를 적용하여 시각적인 피드백을 제공합니다.
  • 아이콘 사용:

    • react-icons 라이브러리를 사용하여 시각적인 아이콘을 추가함으로써 UI의 직관성과 미적 요소를 강화했습니다.
    • 예를 들어, FaPlus 아이콘을 사용하여 추가 기능을, FaEdit 아이콘을 사용하여 편집 기능을 직관적으로 표시했습니다.

결론

이번 실습에서는 메모 추가 및 제거 UI를 구현함으로써 린캔버스 애플리케이션의 핵심 기능을 강화했습니다. 주요 작업은 다음과 같습니다:

  1. 컴포넌트 구조 개선:

    • CanvasCardNote 컴포넌트를 분리하여 각 기능을 독립적으로 관리할 수 있게 했습니다. 이는 코드의 재사용성과 유지보수성을 크게 향상시켰습니다.
  2. 상태 관리 및 동적 기능 추가:

    • useStateuuidv4를 활용하여 메모의 추가 및 제거 기능을 동적으로 구현했습니다. 이를 통해 사용자는 각 린캔버스 섹션에서 자유롭게 메모를 관리할 수 있습니다.
  3. 편집 모드 및 사용자 인터랙션 강화:

    • Note 컴포넌트에 편집 모드를 추가하여 사용자가 메모를 클릭하면 즉시 편집할 수 있도록 했습니다. 또한, 색상 변경 옵션을 제공하여 메모의 시각적 구분을 용이하게 했습니다.
  4. 반응형 디자인 및 접근성 향상:

    • Tailwind CSS를 활용하여 다양한 화면 크기에 대응하는 레이아웃을 구현했습니다. 또한, aria-label 속성을 사용하여 접근성을 강화함으로써 모든 사용자가 편리하게 이용할 수 있는 UI를 제공했습니다.
  5. 레이어 관리 및 사용자 경험 최적화:

    • z-index를 적절히 활용하여 헤더와 모바일 메뉴 간의 레이어 우선순위를 조정했습니다. 이는 사용자 인터랙션 시 혼란을 방지하고, 깔끔한 UI를 유지하는 데 기여했습니다.

종합적으로, 이번 실습을 통해 사용자 친화적이고 기능적인 메모 관리 UI를 구축할 수 있었으며, 이는 린캔버스 애플리케이션의 전체적인 완성도를 높이는 데 중요한 역할을 했습니다. 앞으로도 이러한 컴포넌트 기반의 설계와 Tailwind CSS의 강력한 유틸리티 클래스를 활용하여 더욱 향상된 사용자 경험을 제공할 수 있을 것입니다.


profile
개발자가 되고 싶은 정치학도생의 기술 블로그

0개의 댓글