diff --git a/src/app/(with-header-sidebar)/dashboard/[id]/components/Column.tsx b/src/app/(with-header-sidebar)/dashboard/[id]/components/Column.tsx index 9a8aedc..27bd8ee 100644 --- a/src/app/(with-header-sidebar)/dashboard/[id]/components/Column.tsx +++ b/src/app/(with-header-sidebar)/dashboard/[id]/components/Column.tsx @@ -4,9 +4,17 @@ import { ColumnData } from '@/types/dashboardView'; import Button from '@/components/Button'; import Image from 'next/image'; import Card from './Card'; +import useModalStore from '@/store/modalStore'; +import CreateTaskModal from './CreateTaskModal'; import styles from './Column.module.css'; function Column({ color, title, totalCount, id, items }: ColumnData) { + const { openModal } = useModalStore(); + + const handleCreateTask = () => { + openModal(); + }; + return (
@@ -32,6 +40,7 @@ function Column({ color, title, totalCount, id, items }: ColumnData) { type="button" className={styles.createCard} aria-label="컬럼 생성 버튼" + onClick={handleCreateTask} > ({ mode: 'onChange' }); + + const onSubmit = () => { + // closeModal(); + }; + + const options = [ + { id: 1, name: '김김김' }, + { id: 2, name: '이이이' }, + { id: 3, name: '최최최' }, + ]; + + const handleSelect = (selected: ManagerOption) => { + console.log('선택된 담당자:', selected); + }; + + return ( +
+

할일 생성

+ + + +
+ + +
+ + ); +} diff --git a/src/app/(with-header-sidebar)/dashboard/[id]/components/DatePicker.module.css b/src/app/(with-header-sidebar)/dashboard/[id]/components/DatePicker.module.css new file mode 100644 index 0000000..a6ebc9d --- /dev/null +++ b/src/app/(with-header-sidebar)/dashboard/[id]/components/DatePicker.module.css @@ -0,0 +1,122 @@ +.container { + display: flex; + flex-direction: column; + gap: 8px; + position: relative; + width: 287px; +} + +.label { + font-size: 14px; + font-weight: 500; + line-height: 24px; + color: var(--black-100); +} + +.inputWrapper { + position: relative; +} + +.input { + width: 100%; + border: 1px solid var(--gray-300); + border-radius: 6px; + font-size: 14px; + font-weight: 400; + line-height: 24px; + padding: 11px 14px; + cursor: pointer; +} + +.input:focus { + outline-color: var(--violet); +} + +.input::placeholder { + color: var(--gray-400); +} + +.calendarWrapper { + position: absolute; + top: 50px; + left: 0; + width: 100%; + background: var(--white); + border: 1px solid var(--gray-200); + border-radius: 4px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + z-index: 1000; +} + +.calendarHeader { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px; + background: var(--gray-200); + border-bottom: 1px solid var(--gray-200); +} + +.daysOfWeek { + display: grid; + grid-template-columns: repeat(7, 1fr); + background: var(--gray-300); + text-align: center; + padding: 5px 0; +} + +.day { + font-weight: bold; + color: var(--black-100); +} + +.days { + display: grid; + grid-template-columns: repeat(7, 1fr); + text-align: center; +} + +.dayButton { + padding: 10px; +} + +.dayButton:hover { + background: var(--violet-light); +} + +.dayButton.selected { + background: var(--violet); + color: var(--white); + border-radius: 50%; +} + +.dayButton:disabled { + cursor: default; +} + +.timePicker { + padding: 10px; + border-top: 1px solid var(--gray-200); +} + +.timeSelect { + width: 100%; + padding: 8px; + border: 1px solid var(--gray-200); + border-radius: 4px; +} + +.image { + display: block; +} + +@media screen and (min-width: 768px) { + .container { + width: 520px; + } + + .label { + font-size: 18px; + line-height: 26px; + } +} diff --git a/src/app/(with-header-sidebar)/dashboard/[id]/components/DatePicker.tsx b/src/app/(with-header-sidebar)/dashboard/[id]/components/DatePicker.tsx new file mode 100644 index 0000000..e6fd4f6 --- /dev/null +++ b/src/app/(with-header-sidebar)/dashboard/[id]/components/DatePicker.tsx @@ -0,0 +1,139 @@ +'use client'; + +import React, { useState, ChangeEvent } from 'react'; +import Image from 'next/image'; +import styles from './DatePicker.module.css'; + +const DatePicker = () => { + const [selectedDate, setSelectedDate] = useState(''); + const [selectedTime, setSelectedTime] = useState(''); + const [isCalendarVisible, setIsCalendarVisible] = useState(false); + const [currentMonth, setCurrentMonth] = useState(new Date()); + + const daysOfWeek = ['일', '월', '화', '수', '목', '금', '토']; + + const getDaysInMonth = (date: Date) => { + const startOfMonth = new Date(date.getFullYear(), date.getMonth(), 1); + const endOfMonth = new Date(date.getFullYear(), date.getMonth() + 1, 0); + const days: (Date | null)[] = []; + + for (let i = 0; i < startOfMonth.getDay(); i++) { + days.push(null); + } + + for (let i = 1; i <= endOfMonth.getDate(); i++) { + days.push(new Date(date.getFullYear(), date.getMonth(), i)); + } + + return days; + }; + + const handleDateClick = (date: Date) => { + setSelectedDate(date.toISOString().split('T')[0]); + }; + + const handleMonthChange = (direction: 'prev' | 'next') => { + setCurrentMonth( + (prev) => + new Date( + prev.getFullYear(), + prev.getMonth() + (direction === 'next' ? 1 : -1), + 1 + ) + ); + }; + + const handleTimeChange = (event: ChangeEvent) => { + setSelectedTime(event.target.value); + }; + + const daysInMonth = getDaysInMonth(currentMonth); + + return ( +
+ +
+ setIsCalendarVisible((prev) => !prev)} + /> + {isCalendarVisible && ( +
+
+
+ + + {currentMonth.getFullYear()}년 {currentMonth.getMonth() + 1}월 + + +
+
+ {daysOfWeek.map((day) => ( +
+ {day} +
+ ))} +
+
+ {daysInMonth.map((date, index) => ( + + ))} +
+
+
+ +
+
+ )} +
+
+ ); +}; + +export default DatePicker; diff --git a/src/app/(with-header-sidebar)/dashboard/[id]/components/SearchDropdown.module.css b/src/app/(with-header-sidebar)/dashboard/[id]/components/SearchDropdown.module.css new file mode 100644 index 0000000..f5f16a5 --- /dev/null +++ b/src/app/(with-header-sidebar)/dashboard/[id]/components/SearchDropdown.module.css @@ -0,0 +1,140 @@ +.container { + display: flex; + flex-direction: column; + gap: 8px; + position: relative; + width: 287px; +} + +.label { + font-size: 14px; + font-weight: 500; + line-height: 24px; + color: var(--black-100); +} + +.inputWrapper { + position: relative; + display: flex; + align-items: center; +} + +.input { + width: 100%; + border: 1px solid var(--gray-300); + border-radius: 6px; + font-size: 14px; + font-weight: 400; + line-height: 24px; + padding: 11px 14px; +} + +.input.withAvatar { + padding-left: 46px; +} + +.avatarContainer { + position: absolute; + top: 50%; + left: 14px; + transform: translateY(-50%); +} + +.input:focus { + outline-color: var(--violet); +} + +.input::placeholder { + color: var(--gray-400); +} + +.dropdownIcon { + position: absolute; + right: 14px; + pointer-events: none; +} + +.dropdown { + position: absolute; + top: calc(100% + 4px); + width: 100%; + background: var(--white); + border: 1px solid var(--gray-300); + border-radius: 6px; + max-height: 150px; + overflow-y: auto; + scrollbar-width: none; + z-index: 1000; + box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.1); +} + +.avatar.avatar { + width: 26px; + height: 26px; + font-size: 12px; +} + +.dropdownItem { + display: flex; + align-items: center; + gap: 6px; + padding: 11px 46px; + cursor: pointer; + font-size: 16px; + font-weight: 400; + line-height: 26px; + color: var(--black-100); + position: relative; +} + +.dropdownItem:hover, +.focus { + background: var(--violet-light); + color: var(--violet); +} + +.noResult { + padding: 11px 0; + font-size: 16px; + color: var(--gray-400); + text-align: center; +} + +.check { + position: absolute; + width: 22px; + height: 22px; + top: 50%; + left: 16px; + transform: translateY(-50%); +} + +.check path { + fill: var(--gray-500); +} + +@media screen and (min-width: 768px) { + .container { + width: 520px; + gap: 8px; + } + + .label { + font-size: 18px; + line-height: 26px; + } + + .input { + font-size: 16px; + line-height: 26px; + padding: 11px 16px; + } + + .input.withAvatar { + padding-left: 48px; + } + + .avatarContainer { + left: 16px; + } +} diff --git a/src/app/(with-header-sidebar)/dashboard/[id]/components/SearchDropdown.tsx b/src/app/(with-header-sidebar)/dashboard/[id]/components/SearchDropdown.tsx new file mode 100644 index 0000000..79aa1f0 --- /dev/null +++ b/src/app/(with-header-sidebar)/dashboard/[id]/components/SearchDropdown.tsx @@ -0,0 +1,171 @@ +'use client'; + +import { useState, useRef, useEffect } from 'react'; +import styles from './SearchDropdown.module.css'; +import Image from 'next/image'; +import { ManagerOption } from './CreateTaskModal'; +import CheckIcon from '/public/icons/done.svg'; +import Avatar from '@/components/Avatar'; + +interface SearchDropdownProps { + options: ManagerOption[]; + placeholder?: string; + onSelect: (selected: ManagerOption) => void; +} + +export default function SearchDropdown({ + options, + placeholder = '이름을 입력해 주세요', + onSelect, +}: SearchDropdownProps) { + const [query, setQuery] = useState(''); + const [isDropdownVisible, setIsDropdownVisible] = useState(false); + const [focusedIndex, setFocusedIndex] = useState(-1); + const [selectedOption, setSelectedOption] = useState( + null + ); + const dropdownRef = useRef(null); + + const filteredOptions = options.filter((option) => + option.name.toLowerCase().includes(query.toLowerCase()) + ); + + const handleSelect = (option: ManagerOption) => { + setQuery(option.name); + setSelectedOption(option); + setIsDropdownVisible(false); + onSelect(option); + setFocusedIndex(-1); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!isDropdownVisible && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) { + setIsDropdownVisible(true); + return; + } + if (!isDropdownVisible) return; + + const lastIndex = filteredOptions.length - 1; + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setFocusedIndex((prevIndex) => + prevIndex < lastIndex ? prevIndex + 1 : 0 + ); + break; + case 'ArrowUp': + e.preventDefault(); + setFocusedIndex((prevIndex) => + prevIndex > 0 ? prevIndex - 1 : lastIndex + ); + break; + case 'Enter': + e.preventDefault(); + if (focusedIndex >= 0 && focusedIndex < filteredOptions.length) { + handleSelect(filteredOptions[focusedIndex]); + } + break; + case 'Tab': + setIsDropdownVisible(false); + setFocusedIndex(-1); + break; + case 'Escape': + e.preventDefault(); + setIsDropdownVisible(false); + setFocusedIndex(-1); + break; + default: + break; + } + }; + + const handleBlur = (e: React.FocusEvent) => { + if (!dropdownRef.current?.contains(e.relatedTarget as Node)) { + setIsDropdownVisible(false); + setFocusedIndex(-1); + setQuery(selectedOption?.name || ''); + } + }; + + useEffect(() => { + if (focusedIndex !== -1 && dropdownRef.current) { + const focusedItem = dropdownRef.current.children[ + focusedIndex + ] as HTMLElement; + focusedItem?.scrollIntoView({ block: 'nearest' }); + } + }, [focusedIndex]); + + const DropdownItem = ({ + option, + index, + }: { + option: ManagerOption; + index: number; + }) => { + const isSelected = selectedOption?.id === option.id; + const isFocused = index === focusedIndex; + + return ( +
  • handleSelect(option)} + onMouseEnter={() => setFocusedIndex(index)} + role="option" + aria-selected={isSelected} + > + {isSelected && } + + {option.name} +
  • + ); + }; + + return ( +
    + +
    + { + setQuery(e.target.value); + setIsDropdownVisible(true); + }} + onFocus={() => setIsDropdownVisible(true)} + /> + {selectedOption?.name === query && ( +
    + +
    + )} + 드롭다운 +
    + {isDropdownVisible && ( +
      + {filteredOptions.length > 0 ? ( + filteredOptions.map((option, index) => ( + + )) + ) : ( +
    • 검색 결과가 없습니다
    • + )} +
    + )} +
    + ); +} diff --git a/src/app/(with-header-sidebar)/dashboard/[id]/edit/ClientLayout.tsx b/src/app/(with-header-sidebar)/dashboard/[id]/edit/ClientLayout.tsx index c2ebbf6..c6784b4 100644 --- a/src/app/(with-header-sidebar)/dashboard/[id]/edit/ClientLayout.tsx +++ b/src/app/(with-header-sidebar)/dashboard/[id]/edit/ClientLayout.tsx @@ -4,7 +4,6 @@ import { ReactNode } from 'react'; import { useRouter } from 'next/navigation'; import Image from 'next/image'; import Button from '@/components/Button'; -import Modal from '@/app/(with-header-sidebar)/mypage/_components/Modal'; import styles from './layout.module.css'; import useIdStore from '@/store/idStore'; @@ -34,7 +33,6 @@ export default function Layout({ 돌아가기 {children} - ); } diff --git a/src/app/(with-header-sidebar)/dashboard/[id]/edit/_components/InvitationModal.module.css b/src/app/(with-header-sidebar)/dashboard/[id]/edit/_components/InvitationModal.module.css index 3974303..3ec1d9c 100644 --- a/src/app/(with-header-sidebar)/dashboard/[id]/edit/_components/InvitationModal.module.css +++ b/src/app/(with-header-sidebar)/dashboard/[id]/edit/_components/InvitationModal.module.css @@ -23,6 +23,10 @@ background-color: transparent; } +.image { + display: block; +} + .input input { width: 295px; font-size: 14px; diff --git a/src/app/(with-header-sidebar)/dashboard/[id]/edit/_components/InvitationModal.tsx b/src/app/(with-header-sidebar)/dashboard/[id]/edit/_components/InvitationModal.tsx index 1a7b457..c735dab 100644 --- a/src/app/(with-header-sidebar)/dashboard/[id]/edit/_components/InvitationModal.tsx +++ b/src/app/(with-header-sidebar)/dashboard/[id]/edit/_components/InvitationModal.tsx @@ -28,7 +28,13 @@ export default function InvitationModal({

    초대하기

    {children}
    +
    ); } diff --git a/src/app/(with-header-sidebar)/mypage/_components/FocusTrap.tsx b/src/app/(with-header-sidebar)/mypage/_components/FocusTrap.tsx new file mode 100644 index 0000000..7581d0c --- /dev/null +++ b/src/app/(with-header-sidebar)/mypage/_components/FocusTrap.tsx @@ -0,0 +1,51 @@ +import { useEffect, useRef, ReactNode } from 'react'; + +interface FocusTrapProps { + children: ReactNode; +} + +export default function FocusTrap({ children }: FocusTrapProps) { + const startRef = useRef(null); + + useEffect(() => { + const focusableElements = startRef.current?.querySelectorAll( + 'a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])' + ) as NodeListOf; + + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + + const trapFocus = (e: KeyboardEvent) => { + if (e.key !== 'Tab') return; + + if (e.shiftKey) { + // Shift + Tab + if (document.activeElement === firstElement) { + e.preventDefault(); + lastElement.focus(); + } + } else { + // Tab + if (document.activeElement === lastElement) { + e.preventDefault(); + firstElement.focus(); + } + } + }; + + const container = startRef.current; + if (container) container.focus(); + + document.addEventListener('keydown', trapFocus); + + return () => { + document.removeEventListener('keydown', trapFocus); + }; + }, []); + + return ( +
    + {children} +
    + ); +} diff --git a/src/app/(with-header-sidebar)/mypage/_components/Modal.module.css b/src/app/(with-header-sidebar)/mypage/_components/Modal.module.css index 15ee52f..79dc9cd 100644 --- a/src/app/(with-header-sidebar)/mypage/_components/Modal.module.css +++ b/src/app/(with-header-sidebar)/mypage/_components/Modal.module.css @@ -15,3 +15,33 @@ background: var(--white); border-radius: 16px; } + +@keyframes fadeIn { + from { + transform: translateY(-20px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +@keyframes fadeOut { + from { + transform: translateY(0); + opacity: 1; + } + to { + transform: translateY(-20px); + opacity: 0; + } +} + +.visible { + animation: fadeIn 0.3s; +} + +.hidden { + animation: fadeOut 0.3s; +} diff --git a/src/app/(with-header-sidebar)/mypage/_components/Modal.tsx b/src/app/(with-header-sidebar)/mypage/_components/Modal.tsx index 0edc9f2..fed0376 100644 --- a/src/app/(with-header-sidebar)/mypage/_components/Modal.tsx +++ b/src/app/(with-header-sidebar)/mypage/_components/Modal.tsx @@ -2,34 +2,32 @@ import { MouseEvent, useEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; +import { usePathname } from 'next/navigation'; import useModalStore from '@/store/modalStore'; +import FocusTrap from './FocusTrap'; import styles from './Modal.module.css'; export default function Modal() { - const { modals, closeModal } = useModalStore(); + const { modals, closeModal, isModalVisible, closeAllModal } = useModalStore(); const modalRef = useRef(null); + const currentPath = usePathname(); useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - closeModal(); - } - }; - if (modals.length > 0) { document.body.style.overflow = 'hidden'; } else { document.body.style.overflow = ''; } - window.addEventListener('keydown', handleKeyDown); - return () => { - window.removeEventListener('keydown', handleKeyDown); document.body.style.overflow = ''; }; }, [closeModal, modals]); + useEffect(() => { + closeAllModal(); + }, [currentPath, closeAllModal]); + const handleOutsideClick = (e: MouseEvent) => { if (modalRef.current && !modalRef.current.contains(e.target as Node)) { closeModal(); @@ -41,8 +39,12 @@ export default function Modal() { return createPortal(
    {modals.map((content, index) => ( -
    - {content} +
    + {content}
    ))}
    , diff --git a/src/app/(with-header-sidebar)/mypage/layout.tsx b/src/app/(with-header-sidebar)/mypage/layout.tsx index 609159e..4b5a04d 100644 --- a/src/app/(with-header-sidebar)/mypage/layout.tsx +++ b/src/app/(with-header-sidebar)/mypage/layout.tsx @@ -4,7 +4,6 @@ import { ReactNode } from 'react'; import { useRouter } from 'next/navigation'; import Image from 'next/image'; import Button from '@/components/Button'; -import Modal from './_components/Modal'; import styles from './layout.module.css'; export default function Layout({ children }: { children: ReactNode }) { @@ -26,7 +25,6 @@ export default function Layout({ children }: { children: ReactNode }) { 돌아가기 {children} - ); } diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx index e6c598d..7f17ee2 100644 --- a/src/components/Avatar.tsx +++ b/src/components/Avatar.tsx @@ -42,7 +42,11 @@ const getRandomColor = (name: string) => { (acc, char) => acc + char.charCodeAt(0) * 31, 0 ); - const getValue = (offset: number) => (((hash >> offset) % 0xff) % 76) + 180; + const getValue = (offset: number) => { + const baseValue = (((hash >> offset) % 0xff) % 76) + 180; + const maxValue = 225; + return Math.min(baseValue, maxValue); + }; const red = getValue(0); const green = getValue(8); const blue = getValue(16); diff --git a/src/store/modalStore.ts b/src/store/modalStore.ts index 54e0579..a7e30bb 100644 --- a/src/store/modalStore.ts +++ b/src/store/modalStore.ts @@ -3,19 +3,32 @@ import { ReactNode } from 'react'; interface ModalState { modals: ReactNode[]; + isModalVisible: boolean; openModal: (content: ReactNode) => void; closeModal: () => void; + closeAllModal: () => void; } const useModalStore = create((set) => ({ modals: [], + isModalVisible: false, openModal: (content) => set((state) => ({ modals: [...state.modals, content], + isModalVisible: true, })), - closeModal: () => - set((state) => ({ - modals: state.modals.slice(0, -1), + closeModal: () => { + set({ isModalVisible: false }); + setTimeout(() => { + set((state) => ({ + modals: state.modals.slice(0, -1), + })); + }, 250); + }, + closeAllModal: () => + set(() => ({ + modals: [], + isModalVisible: false, })), }));