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
내에 메모를 추가할 수 있게 했습니다.useState
와 uuidv4
를 임포트하여 메모의 상태 관리와 고유 ID 생성을 가능하게 했습니다.notes
):notes
상태를 도입하여 각 CanvasCard
내의 메모 목록을 관리합니다.handleAddNote
):notes
배열에 추가합니다.handleRemoveNote
):notes
배열에서 제거합니다.FaPlus
아이콘이 있는 버튼을 헤더에 추가하여 사용자가 메모를 추가할 수 있도록 했습니다.notes
배열을 순회하며 각 메모를 Note
컴포넌트로 렌더링합니다. 이를 통해 각 메모는 독립적으로 관리되고 표시됩니다.결과:
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>
컴포넌트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
):relative
와 absolute
포지셔닝을 사용하여 아이콘 버튼을 메모 상단 오른쪽에 배치했습니다.결과:
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
)을 표시하여 메모를 삭제할 수 있게 합니다.isEditing
을 false
로 설정하여 편집 모드를 종료합니다.e.stopPropagation()
을 호출하여 부모 요소의 클릭 이벤트가 트리거되지 않도록 합니다.onRemoveNote
함수를 호출하여 해당 메모를 삭제합니다.textarea
):readOnly
속성을 사용하여 편집 모드에 따라 입력 가능 여부를 제어합니다.결과:
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
):value
와 onChange
핸들러를 통해 content
상태를 동기화합니다.ref
를 통해 DOM 요소에 접근하여 높이를 동적으로 조절합니다.결과:
textarea
의 높이가 자동으로 조절되어 스크롤 없이 모든 내용을 볼 수 있습니다.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
를 배치합니다.상태 관리:
searchText
, isGridView
, dummyData
등의 상태를 관리하여 검색, 보기 방식, 데이터 목록을 제어합니다.notes
상태를 관리하여 메모의 추가 및 제거를 처리합니다.isEditing
, content
, color
등의 상태를 관리하여 메모의 편집, 내용, 색상 변경을 지원합니다.반응형 디자인:
grid-cols-5
와 grid-cols-2
클래스를 사용하여 그리드의 열 수를 조정하고, sm:flex-row
클래스를 통해 작은 화면에서는 세로로, 큰 화면에서는 가로로 레이아웃을 변경합니다.접근성:
aria-label
속성을 사용하여 버튼과 입력 필드의 목적을 명확히 전달했습니다. 이는 스크린 리더 사용자에게 유용합니다.애니메이션과 트랜지션:
transition-transform
, duration-300
, hover:scale-105
등을 활용하여 사용자 인터랙션 시 부드러운 애니메이션 효과를 추가했습니다.아이콘 사용:
react-icons
라이브러리를 사용하여 시각적인 아이콘을 추가함으로써 UI의 직관성과 미적 요소를 강화했습니다.FaPlus
아이콘을 사용하여 추가 기능을, FaEdit
아이콘을 사용하여 편집 기능을 직관적으로 표시했습니다.이번 실습에서는 메모 추가 및 제거 UI를 구현함으로써 린캔버스 애플리케이션의 핵심 기능을 강화했습니다. 주요 작업은 다음과 같습니다:
컴포넌트 구조 개선:
CanvasCard
와 Note
컴포넌트를 분리하여 각 기능을 독립적으로 관리할 수 있게 했습니다. 이는 코드의 재사용성과 유지보수성을 크게 향상시켰습니다.상태 관리 및 동적 기능 추가:
useState
와 uuidv4
를 활용하여 메모의 추가 및 제거 기능을 동적으로 구현했습니다. 이를 통해 사용자는 각 린캔버스 섹션에서 자유롭게 메모를 관리할 수 있습니다.편집 모드 및 사용자 인터랙션 강화:
Note
컴포넌트에 편집 모드를 추가하여 사용자가 메모를 클릭하면 즉시 편집할 수 있도록 했습니다. 또한, 색상 변경 옵션을 제공하여 메모의 시각적 구분을 용이하게 했습니다.반응형 디자인 및 접근성 향상:
aria-label
속성을 사용하여 접근성을 강화함으로써 모든 사용자가 편리하게 이용할 수 있는 UI를 제공했습니다.레이어 관리 및 사용자 경험 최적화:
z-index
를 적절히 활용하여 헤더와 모바일 메뉴 간의 레이어 우선순위를 조정했습니다. 이는 사용자 인터랙션 시 혼란을 방지하고, 깔끔한 UI를 유지하는 데 기여했습니다.종합적으로, 이번 실습을 통해 사용자 친화적이고 기능적인 메모 관리 UI를 구축할 수 있었으며, 이는 린캔버스 애플리케이션의 전체적인 완성도를 높이는 데 중요한 역할을 했습니다. 앞으로도 이러한 컴포넌트 기반의 설계와 Tailwind CSS의 강력한 유틸리티 클래스를 활용하여 더욱 향상된 사용자 경험을 제공할 수 있을 것입니다.