업데이트 사항 (w. 2023/08/24)
https://lerryroad.tistory.com/136
컴포넌트 props 인터페이스를 대폭 수정, 구현 구조를 변경하여 버전 2.X.X로 업데이트했습니다. outdated된 내용 또한 아래 본문에 표시해뒀는데 오래된 버전에 대한 내용은 🚫, 업데이트된 버전에 대한 내용은 그 옆에 ✅로 작성했으니 참고하면 좋을 것 같습니다.
npm & github
이번 글에서는 간단하게 코드 구조를 살펴보고자 합니다.
0. 디렉토리 구조
🚫 Outdated version
./src
├── components
│ └── DraggableSelector
│ ├── ColumnLabel
│ │ ├── index.tsx
│ │ └── styles.ts
│ ├── RowLabel
│ │ ├── index.tsx
│ │ └── styles.ts
│ ├── TimeSlots
│ │ ├── index.tsx
│ │ └── styles.ts
│ ├── index.tsx
│ └── styles.ts
├── constant
│ └── options.ts
├── data
│ └── date.ts
├── index.ts
├── styles
│ └── global.css
├── types
│ ├── draggableSelector.ts
│ ├── event.ts
│ └── time.ts
├── utils
│ ├── date.ts
│ └── time.ts
└── vite-env.d.ts
✅ Updated version
./src
├── components
│ └── DraggableSelector
│ ├── DateLabel
│ │ ├── index.tsx
│ │ └── styles.ts
│ ├── Selector
│ │ ├── index.tsx
│ │ └── styles.ts
│ ├── TimeLabel
│ │ ├── index.tsx
│ │ └── styles.ts
│ ├── TimeSlots
│ │ ├── index.tsx
│ │ └── styles.ts
│ └── index.tsx
├── index.ts
├── styles
│ └── global.css
├── types
│ ├── domEvent.ts
│ └── timeInfo.ts
├── utils
│ └── time.ts
└── vite-env.d.ts
1. index.ts
- 외부에 컴포넌트를 제공하는 통로
- component 폴더에서 리액트 엘리먼트를 가져와 그대로 export 해줍니다.
🚫 Outdated version
import DraggableSelector from './components/DraggableSelector';
export { DraggableSelector };
✅ Updated version
import DraggableSelector from './components/DraggableSelector';
import { type TimeSlot } from './types/timeInfo';
import { type DraggableSelectorProps } from './components/DraggableSelector';
export { DraggableSelector, DraggableSelectorProps, TimeSlot };
2. 타입 정보 (types)
- 타입 정보를 제공
- event에 대한 타입, 시간에 대한 타입, 그리고 DraggableSelector의 인터페이스를 제공합니다.
🚫 Outdated version
📂 types/draggableSelector.ts
import React from 'react';
import { TimeSlot } from './time';
export interface DraggableSelectorProps {
/* REQUIRED */
dates: Date[]; // Required default value: []
endTime: string;
startTime: string;
selectedTimeSlots: TimeSlot[]; // Required default value: []
setSelectedTimeSlots: React.Dispatch<React.SetStateAction<TimeSlot[]>>;
/* OPTIONAL */
timeUnit?: 5 | 10 | 15 | 20 | 30 | 60; // default: 30
dateFormat?: string;
timeFormat?: string;
mode?: 'date' | 'day';
language?: 'en' | 'ko';
width?: string;
height?: string;
margin?: string;
padding?: string;
minWidth?: string;
maxWidth?: string;
minHeight?: string;
maxHeight?: string;
scrollWidth?: string;
scrollColor?: string;
scrollBgColor?: string;
slotHeight?: string;
slotMinWidth?: string;
slotRowGap?: string;
slotColumnGap?: string;
defaultSlotColor?: string;
hoveredSlotColor?: string;
selectedSlotColor?: string;
disabledSlotColor?: string;
slotBorderStyle?: string;
slotBorderRadius?: string;
rowLabelWidth?: string;
rowLabelBgColor?: string;
rowLabelPadding?: string;
rowLabelBorderRadius?: string;
rowLabelsColor?: string;
rowLabelsMargin?: string;
rowLabelsBgColor?: string;
rowLabelsFontSize?: string;
rowLabelsFontWeight?: number;
rowLabelsFontFamily?: string;
rowLabelsBorderRadius?: string;
isRowLabelInvisible?: boolean;
columnLabelHeight?: string;
columnLabelBgColor?: string;
columnLabelPadding?: string;
columnLabelBorderRadius?: string;
columnLabelsColor?: string;
columnLabelsMargin?: string;
columnLabelsBgColor?: string;
columnLabelsFontSize?: string;
columnLabelsFontFamily?: string;
columnLabelsFontWeight?: number;
columnLabelsBorderRadius?: string;
isColumnLabelInvisible?: boolean;
}
✅ Updated version
📂 DraggableSelectorProps 타입
export interface DraggableSelectorProps {
/*
* The start time of each day. Assign the value in `number`. e.g. `9`, `14`
*/
minTime: number;
/*
* The end time of each day. Assign the value in `number`. e.g. `8`, `22`
*/
maxTime: number;
/*
* The dates selected. Assign the value in `Date[]`. e.g. `[new Date('2021-01-01'), new Date('2021-01-02')]`
*/
dates: Date[];
/*
* Use the date format method of dayjs. You can use the following link to set the formatting form. (https://day.js.org/docs/en/display/format), `string`. e.g. `MM.DD`, `YYYY-MM-DD`
*/
dateFormat?: string;
/*
Use the time format method of dayjs. You can use the following link to set the formatting form. (https://day.js.org/docs/en/display/format), `string`. e.g. `HH:mm A`, `HH:mm`
*/
timeFormat?: string;
/*
* Decide whether to indicate all dates or by day of the week. (In the 'day' version) If there is no day of the week corresponding to the selected date, the cell is blocked so that it cannot be selected. `day | date`. e.g. `day`, `date`
*/
mode?: 'day' | 'date';
/*
* The time slots you selected. If you put the setTimeSlots in the props together, the result value will be automatically set according to the cell you selected. Create Date objects using the obtained timeSlot arrangement or use them in various ways. `TimeSlot[]`
*/
timeSlots: TimeSlot[];
/*
* The function to set the time slots you selected. If you put the timeSlots in the props together, the result value will be automatically set according to the cell you selected. Create Date objects using the obtained timeSlot arrangement or use them in various ways. `React.Dispatch<React.SetStateAction<TimeSlot[]>>`
*/
setTimeSlots: React.Dispatch<React.SetStateAction<TimeSlot[]>>;
/*
* The time unit of each slot. Assign the value in `5 | 10 | 15 | 20 | 30 | 60`. e.g. `5`, `10`, `15`, `20`, `30`, `60`
*/
timeUnit?: 5 | 10 | 15 | 20 | 30 | 60;
/*
* The width of each slot. Assign the value in `number`.
*/
slotWidth?: number;
/*
* The height of each slot. Assign the value in `number`.
*/
slotHeight?: number;
/*
* The margin-top of slots container. Assign the value in `number`.
*/
slotsMarginTop?: number;
/*
* The margin-left of slots container. Assign the value in `number`.
*/
slotsMarginLeft?: number;
/*
* The max-width of selector. Assign the value in `string`. e.g. `536px`
*/
maxWidth?: string;
/*
* The max-height of selector. Assign the value in `string`. e.g. `452px`, `100%`
*/
maxHeight?: string;
/*
* The default color of each slot. Assign the value in `string`. e.g. `#FFFFFF`
*/
defaultSlotColor?: string;
/*
* The color of each slot when it is selected. Assign the value in `string`. e.g. `#FFF5E5`
*/
selectedSlotColor?: string;
/*
* The color of each slot when it is disabled. Assign the value in `string`. e.g. `#e1e1e1`
*/
disabledSlotColor?: string;
/*
* The color of each slot when it is hovered. Assign the value in `string`. e.g. `#FFF5E5`
*/
hoveredSlotColor?: string;
/*
* The border of slots container. Assign the value in `string`. e.g. `1px solid #8c8d94`
*/
slotsContainerBorder?: string;
/*
* The border-radius of slots container. Assign the value in `string`. e.g. `0px`, `5px`
*/
slotsContainerBorderRadius?: string;
}
📂 types/event.ts
import { type TimeSlot } from './time';
export enum Selection {
ADD = 'add',
REMOVE = 'remove',
}
export interface DragEventStates {
selectionType: Selection | null;
startedTimeSlot: TimeSlot | null;
cachedSelectedTimeSlots: TimeSlot[];
}
📂 types/time.ts
🚫 Outdated version
export enum Day {
SUN = 'SUN',
MON = 'MON',
TUE = 'TUE',
WED = 'WED',
THU = 'THU',
FRI = 'FRI',
SAT = 'SAT',
}
export interface TimeSlot {
date: string;
startTime: string;
endTime: string;
day: Day;
}
✅ Updated version
export interface TimeSlot {
day: number;
date: string;
minTime: string;
maxTime: string;
}
export type TimeSlotRecord = Record<string, Record<string, TimeSlot>>;
3. 🚫 상수 관리
- optional props에 대한 디폴트 값을 관리
🚫 Outdated version
/* ABOUT CORE INFO */
export const DEFAULT_TIMEUNIT = 30;
export const DEFAULT_MODE = 'date';
export const DEFAULT_LANG = 'en';
export const DEFAULT_DATE_FORMAT = 'M/D';
export const DEFAULT_TIME_FORMAT = 'hh:mm A';
/* ABOUT SELECTOR */
export const DEFAULT_PADDING = '0 10px 0 0';
export const DEFAULT_MARGIN = '0px';
export const DEFAULT_WIDTH = '500px';
export const DEFAULT_MIN_WIDTH = 'auto';
export const DEFAULT_MAX_WIDTH = '100%';
export const DEFAULT_HEIGHT = 'auto';
export const DEFAULT_MIN_HEIGHT = 'auto';
export const DEFAULT_MAX_HEIGHT = '500px';
export const DEFAULT_SCROLL_WIDTH = '3px';
export const DEFAULT_SCROLL_COLOR = '#595959';
export const DEFAULT_SCROLL_BG_COLOR = '#e1e1e1';
/* ABOUT SLOTS */
export const DEFAULT_ROW_GAP = '3px';
export const DEFAULT_COLUMN_GAP = '3px';
export const DEFAULT_SLOT_MIN_WIDTH = '40px';
export const DEFAULT_SLOT_HEIGHT = '30px';
export const DEFAULT_SLOT_BG_COLOR = '#f1f1f1';
export const DEFAULT_SELECTED_SLOT_BG_COLOR = '#3f3f3f';
export const DEFAULT_HOVERED_SLOT_BG_COLOR = '#cbcbcb';
export const DEFAULT_DISABLED_SLOT_BG_COLOR = '#939393';
export const DEFAULT_SLOT_BORDER_STYLE = 'none';
export const DEFAULT_SLOT_BORDER_RADIUS = '2px';
/* ABOUT COLUMN LABELS */
export const DEFAULT_COLUMN_LABELS_COLOR = '#7a7a7a';
export const DEFAULT_COLUMN_LABELS_BG_COLOR = 'transparent';
export const DEFAULT_COLUMN_LABELS_MARGIN = '0px';
export const DEFAULT_COLUMN_LABELS_BORDER_RADIUS = '0px';
export const DEFAULT_COLUMN_LABELS_FONT_SIZE = '15px';
export const DEFAULT_COLUMN_LABELS_FONT_WEIGHT = 600;
export const DEFAULT_COLUMN_LABELS_FONT_FAMILY = 'NanumSquareRound';
export const DEFAULT_COLUMN_LABELS_HEIGHT = '36px';
export const DEFAULT_COLUMN_LABEL_PADDING = '0px';
export const DEFAULT_COLUMN_LABEL_BORDER_RADIUS = '0px';
export const DEFAULT_COLUMN_LABEL_BG_COLOR = 'transparent';
/* ABOUT ROW LABELS */
export const DEFAULT_ROW_LABELS_COLOR = '#939393';
export const DEFAULT_ROW_LABEL_WIDTH = '68px';
export const DEFAULT_ROW_LABELS_BG_COLOR = 'transparent';
export const DEFAULT_ROW_LABELS_MARGIN = '0px';
export const DEFAULT_ROW_LABELS_BORDER_RADIUS = '0px';
export const DEFAULT_ROW_LABELS_FONT_SIZE = '12px';
export const DEFAULT_ROW_LABELS_FONT_WEIGHT = 400;
export const DEFAULT_ROW_LABELS_FONT_FAMILY = 'NanumSquareRound';
export const DEFAULT_ROW_LABEL_PADDING = '0px';
export const DEFAULT_ROW_LABEL_BORDER_RADIUS = '0px';
export const DEFAULT_ROW_LABEL_BG_COLOR = 'transparent';
4. Util 함수들
📂 🚫 utils/date.ts
🚫 Outdated version
- day 버전과 date 버전이 컴포넌트 내부 로직은 대부분 동일하게 진행되고, 외부에서 내려주는 데이터를 다르게 내려주는 방향으로 변경되어서, 불필요한 유틸함수들이 삭제되었습니다.
- 날짜와 관련한 데이터를 다루는 유틸함수들이다.
- isBetween: 타겟 날짜가 기준이 되는 두 날짜들 사이에 포함되는지 확인하는 dayjs 내장 함수, 플러그인을 통해 기능을 확장해 사용할 수 있다.
- removeDuplicatesAndSortByDate: 중복되는 날짜가 들어왔을 때 이를 제거하고 정렬하는 함수인데, 중복 제거와 sort 함수를 분리해 하나의 관심사만을 갖게 한다. -> removeDuplicates / sortByDate
- isDateBetween: 날짜 기준으로 isBetween 여부를 판별하는 함수, 시간에 상관없이 날짜로만 판별한다.
- getTimeSlotMatrixByDay: 같은 요일에 해당하는 날짜들이 같은 인덱스 안의 배열에 있을 수 있게 이차원 배열을 리턴하는 함수
import dayjs from 'dayjs';
import isBetween from 'dayjs/plugin/isBetween';
import { Day, type TimeSlot } from '../types/time';
/* MODULE EXTEND */
dayjs.extend(isBetween);
export const getUniqueDateKey = (date: Date) => {
return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`;
};
export const removeDuplicatesAndSortByDate = (dates: Date[]) => {
const uniqueDates = new Set<string>();
for (const date of dates) {
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
uniqueDates.add(`${year}/${month}/${day}`);
}
const sortedDates = Array.from(uniqueDates)?.sort(
(a, b) => new Date(a).getTime() - new Date(b).getTime(),
);
return sortedDates?.map(strDate => new Date(strDate));
};
export const isDateBetween = (
target: TimeSlot,
start: TimeSlot,
end: TimeSlot,
) => {
const endDate = dayjs(end.date);
const startDate = dayjs(start.date);
const targetDate = dayjs(target.date);
return targetDate.isBetween(startDate, endDate, 'day', '[]');
};
export const getDay = (dayNumber: 0 | 1 | 2 | 3 | 4 | 5 | 6) => {
const DAY = {
0: Day.SUN,
1: Day.MON,
2: Day.TUE,
3: Day.WED,
4: Day.THU,
5: Day.FRI,
6: Day.SAT,
};
return DAY[dayNumber];
};
export const getDayNum = (day: Day) => {
const DAY_NUM = {
SUN: 0,
MON: 1,
TUE: 2,
WED: 3,
THU: 4,
FRI: 5,
SAT: 6,
};
return DAY_NUM[day];
};
export const getIterableDays = (version: 'en' | 'ko') => {
const DAYS = {
en: ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'],
ko: ['일', '월', '화', '수', '목', '금', '토'],
};
return DAYS[version];
};
export const getTimeSlotMatrixByDay = (matrix: TimeSlot[][]) => {
if (!matrix || matrix?.length === 0) return;
const sortedMatrix: TimeSlot[][] = [[], [], [], [], [], [], []];
matrix?.forEach((timeSlots: TimeSlot[]) => {
timeSlots?.forEach((timeSlot: TimeSlot) => {
sortedMatrix[getDayNum(timeSlot.day)].push(timeSlot);
});
});
return sortedMatrix;
};
📂 utils/time.ts
- 시간과 관련한 데이터를 다루는 유틸함수들입니다.
- areTimeSlotsEqual: 동일한 셀인지 확인하는 함수.
- getTimeSlotMatrix: 주어진 시작 시간과 종료 시간, 날짜 목록, 시간 단위에 따라 시간 슬롯 행렬을 생성하는 역할을 한다. 주어진 입력에 따라 시간 간격을 계산하고, 각 날짜에 해당하는 시간 슬롯을 생성하여 이를 행렬로 구성한다.
- isTimeBetween: 시간 기준으로 isBetween 여부를 판별하는 함수, 날짜에 상관없이 시간으로만 판별한다.
- updateCachedSelectedTimeSlots: 주어진 조건에 따라 선택된 시간 슬롯을 업데이트하는 역할을 하는 함수. React 컴포넌트에서 사용될 것을 가정하고, 해당 컴포넌트의 상태 업데이트를 위해 setDragEventStates를 호출하여 상태를 업데이트한다.
mode: 모드를 나타내는 문자열. 가능한 값은 'date' 또는 'day'.
timeSlotMatrix: 날짜별 시간을 나타내는 이차원 배열
timeSlotMatrixByDay: 요일별 시간을 나타내는 이차원 배열
selectedTimeSlots: 선택된 시간 슬롯들을 나타내는 배열
endedTimeSlot: 드래그가 끝난 시간 슬롯 객체 또는 null
dragEventStates: 드래그 이벤트 상태를 나타내는 객체
setDragEventStates: 드래그 이벤트 상태를 업데이트하기 위한 React setState 함수
- startedTimeSlot과 selectionType이 존재하지 않으면 함수를 종료한다. 이 두 값은 드래그 이벤트의 시작 시간 슬롯과 선택 유형을 나타낸다.
- endedTimeSlot이 존재한다면, 선택된 시간 슬롯을 계산하여 updatedCachedSelectedTimeSlots 배열에 저장한다. 이 배열은 드래그가 시작 시간 슬롯보다 뒤로 이동한 경우와 앞으로 이동한 경우에 따라 선택된 시간 슬롯을 조정한다.
- setDragEventStates를 사용하여 최종적으로 cachedSelectedTimeSlots 상태를 업데이트한다.
🚫 Outdated version
import React from 'react';
import dayjs from 'dayjs';
import isBetween from 'dayjs/plugin/isBetween';
import { type TimeSlot } from '../types/time';
import { getDay, getDayNum, isDateBetween } from './date';
import { type DragEventStates, Selection } from '../types/event';
/* MODULE EXTEND */
dayjs.extend(isBetween);
export const areTimeSlotsEqual = (
slot1: TimeSlot,
slot2: TimeSlot,
mode: 'date' | 'day',
) => {
if (mode === 'day') {
return (
slot1.day === slot2.day &&
slot1.endTime === slot2.endTime &&
slot1.startTime === slot2.startTime
);
} else {
return (
slot1.date === slot2.date &&
slot1.endTime === slot2.endTime &&
slot1.startTime === slot2.startTime
);
}
};
const isTimeBetween = (target: TimeSlot, start: TimeSlot, end: TimeSlot) => {
const standardDate = start.date;
const endStartTime = dayjs(`${standardDate} ${end.startTime}`);
const startStartTime = dayjs(`${standardDate} ${start.startTime}`);
const targetStartTime = dayjs(`${standardDate} ${target.startTime}`);
return targetStartTime.isBetween(
startStartTime,
endStartTime,
undefined,
'[]',
);
};
export const getTimeSlotMatrix = ({
dates,
timeUnit,
endTime,
startTime,
}: {
dates?: Date[];
startTime?: string | null;
endTime?: string | null;
timeUnit: 5 | 10 | 15 | 20 | 30 | 60;
}) => {
if (!dates || !startTime || !endTime) return;
const startHour = Number(startTime?.split(':')[0]);
const startMinute = Number(startTime?.split(':')[1]);
const endHour = Number(endTime?.split(':')[0]);
const endMinute = Number(endTime?.split(':')[1]);
const matrix: TimeSlot[][] = [];
dates?.forEach(date => {
const times: TimeSlot[] = [];
const key = dayjs(date)?.format('YYYY/MM/DD');
let hour = startHour;
let minute = startMinute;
while (hour < endHour || (hour === endHour && minute < endMinute)) {
const formattedHour = hour.toString().padStart(2, '0');
const formattedMinute = minute.toString().padStart(2, '0');
const currEndMinute = minute + timeUnit;
let formattedEndHour = hour.toString().padStart(2, '0');
let formattedEndMinute = currEndMinute.toString().padStart(2, '0');
if (currEndMinute >= 60) {
formattedEndHour = (hour + 1).toString().padStart(2, '0');
formattedEndMinute = (currEndMinute - 60).toString().padStart(2, '0');
}
times.push({
date: key,
startTime: `${formattedHour}:${formattedMinute}`,
endTime: `${formattedEndHour}:${formattedEndMinute}`,
day: getDay(date.getDay() as 0 | 1 | 2 | 3 | 4 | 5 | 6),
});
minute += timeUnit;
if (minute >= 60) {
hour += 1;
minute -= 60;
}
}
matrix.push(times);
});
return matrix;
};
export const updateCachedSelectedTimeSlots = ({
mode,
endedTimeSlot,
timeSlotMatrix,
dragEventStates,
selectedTimeSlots,
setDragEventStates,
timeSlotMatrixByDay,
}: {
mode: 'date' | 'day';
timeSlotMatrix: TimeSlot[][];
timeSlotMatrixByDay: TimeSlot[][];
selectedTimeSlots: TimeSlot[];
endedTimeSlot: TimeSlot | null;
dragEventStates: DragEventStates;
setDragEventStates: React.Dispatch<React.SetStateAction<DragEventStates>>;
}) => {
const { startedTimeSlot, selectionType } = dragEventStates;
if (!startedTimeSlot || !selectionType) return;
const updatedCachedSelectedTimeSlots: TimeSlot[] =
startedTimeSlot && endedTimeSlot && selectionType
? endedTimeSlot
? timeSlotMatrix?.reduce((acc, dayOfTimes) => {
const dateIsReversed = dayjs(endedTimeSlot.date).isBefore(
dayjs(startedTimeSlot.date),
);
const standardDate = startedTimeSlot.date;
const timeIsReversed = dayjs(
`${standardDate} ${endedTimeSlot.startTime}`,
).isBefore(dayjs(`${standardDate} ${startedTimeSlot.startTime}`));
return acc?.concat(
dayOfTimes?.filter(
t =>
isDateBetween(
t,
dateIsReversed ? endedTimeSlot : startedTimeSlot,
dateIsReversed ? startedTimeSlot : endedTimeSlot,
) &&
isTimeBetween(
t,
timeIsReversed ? endedTimeSlot : startedTimeSlot,
timeIsReversed ? startedTimeSlot : endedTimeSlot,
),
),
);
}, [])
: [startedTimeSlot]
: [];
if (mode === 'day') {
const cachedSelectedAllTimeSlots: TimeSlot[] = [];
updatedCachedSelectedTimeSlots?.forEach(slot => {
const target = timeSlotMatrixByDay[getDayNum(slot.day)];
target?.forEach(t => {
if (areTimeSlotsEqual(slot, t, 'day')) {
cachedSelectedAllTimeSlots.push(t);
}
});
});
const nextCache =
selectionType === Selection.ADD
? Array.from(
new Set([...selectedTimeSlots, ...cachedSelectedAllTimeSlots]),
)
: selectionType === Selection.REMOVE
? selectedTimeSlots?.filter(a => {
return !cachedSelectedAllTimeSlots.find(b =>
areTimeSlotsEqual(a, b, mode),
);
})
: [...selectedTimeSlots];
setDragEventStates(prev => ({
...prev,
cachedSelectedTimeSlots: nextCache,
}));
} else {
const nextCache =
selectionType === Selection.ADD
? Array.from(
new Set([...selectedTimeSlots, ...updatedCachedSelectedTimeSlots]),
)
: selectionType === Selection.REMOVE
? selectedTimeSlots?.filter(a => {
return !updatedCachedSelectedTimeSlots.find(b =>
areTimeSlotsEqual(a, b, mode),
);
})
: [...selectedTimeSlots];
setDragEventStates(prev => ({
...prev,
cachedSelectedTimeSlots: nextCache,
}));
}
};
✅ Updated version
import React from 'react';
import dayjs from 'dayjs';
import isBetween from 'dayjs/plugin/isBetween';
import { TimeSlot, TimeSlotRecord } from '../types/timeInfo';
import { DragEventStates, Selection } from '../types/domEvent';
/* MODULE EXTEND */
dayjs.extend(isBetween);
const getStrDateKey = (date: Date) => {
return dayjs(date).format('YYYYMMDD');
};
export const getLabelsFromDates = (dates: Date[], form?: string) => {
return dates.map(date => {
return dayjs(date).format(`${form ?? 'MM.DD'}`);
});
};
export const getFilteredTimeSlotsByDate = (dates: Date[], timeSlots: TimeSlot[]) => {
return [...timeSlots]?.filter(slot => {
const strDates = dates.map(date => {
return getStrDateKey(date);
});
return strDates.includes(slot.date);
});
};
export const areTimeSlotsEqual = (a: TimeSlot, b: TimeSlot) => {
return a.date === b.date && a.minTime === b.minTime && a.maxTime === b.maxTime;
};
export const areTimeSlotsEqualByDayAndTime = (a: TimeSlot, b: TimeSlot) => {
return a.day === b.day && a.minTime === b.minTime && a.maxTime === b.maxTime;
};
export const getTimeSlotMatrixByDay = (matrix: TimeSlot[][]) => {
if (!matrix || matrix?.length === 0) return;
const sortedMatrix: TimeSlot[][] = [[], [], [], [], [], [], []];
matrix?.forEach((timeSlots: TimeSlot[]) => {
timeSlots?.forEach((timeSlot: TimeSlot) => {
sortedMatrix[timeSlot.day].push(timeSlot);
});
});
return sortedMatrix;
};
function isDateBetween(start: TimeSlot, target: TimeSlot, end: TimeSlot): boolean {
const endDate = dayjs(end.date);
const startDate = dayjs(start.date);
const targetDate = dayjs(target.date);
return targetDate.isBetween(startDate, endDate, 'day', '[]');
}
function isTimeBetween(start: TimeSlot, target: TimeSlot, end: TimeSlot): boolean {
const date = start.date;
const endTime = dayjs(`${date} ${end.minTime}`);
const startTime = dayjs(`${date} ${start.minTime}`);
const targetTime = dayjs(`${date} ${target.minTime}`);
return targetTime.isBetween(startTime, endTime, undefined, '[]');
}
export const getStrTime = (num: number) => {
return `${String(num).padStart(2, '0')}:00`;
};
export const getTimeSlotRecord = ({
dates,
minTime: numMinTime,
maxTime: numMaxTime,
timeUnit,
}: {
dates?: Date[];
minTime: number;
maxTime: number;
timeUnit: 5 | 10 | 15 | 20 | 30 | 60;
}) => {
const minTime = getStrTime(numMinTime);
const maxTime = getStrTime(numMaxTime);
if (!dates || !minTime || !maxTime) return;
const startHour = Number(minTime.split(':')[0]);
const startMinute = Number(minTime.split(':')[1]);
const endHour = Number(maxTime.split(':')[0]);
const endMinute = Number(maxTime.split(':')[1]);
const record: TimeSlotRecord = {};
dates.forEach(date => {
const times: Record<string, TimeSlot> = {};
const key = getStrDateKey(date);
let hour = startHour;
let minute = startMinute;
while (hour < endHour || (hour === endHour && minute < endMinute)) {
const formattedHour = hour.toString().padStart(2, '0');
const formattedMinute = minute.toString().padStart(2, '0');
const currEndMinute = minute + timeUnit;
let formattedEndHour = hour.toString().padStart(2, '0');
let formattedEndMinute = currEndMinute.toString().padStart(2, '0');
if (currEndMinute >= 60) {
formattedEndHour = (hour + 1).toString().padStart(2, '0');
formattedEndMinute = (currEndMinute - 60).toString().padStart(2, '0');
}
times[`${formattedHour}:${formattedMinute}`] = {
date: key,
day: date.getDay(),
minTime: `${formattedHour}:${formattedMinute}`,
maxTime: `${formattedEndHour}:${formattedEndMinute}`,
};
minute += timeUnit;
if (minute >= 60) {
hour += 1;
minute -= 60;
}
}
record[key] = times;
});
return record;
};
export const getTimeSlotMatrix = (timeSlotRecord: TimeSlotRecord) => {
const matrix: TimeSlot[][] = [];
for (const date in timeSlotRecord) {
const timeSlots = [];
for (const time in timeSlotRecord[date]) {
timeSlots.push(timeSlotRecord[date][time]);
}
matrix.push(timeSlots);
}
return matrix;
};
export const updateCachedSelectedTimeSlots = ({
endedTimeSlot,
timeSlotMatrix,
dragEventStates,
selectedTimeSlots,
setDragEventStates,
}: {
timeSlotMatrix: TimeSlot[][];
selectedTimeSlots: TimeSlot[];
endedTimeSlot: TimeSlot | null;
dragEventStates: DragEventStates;
setDragEventStates: React.Dispatch<React.SetStateAction<DragEventStates>>;
}) => {
const { startedTimeSlot, selectionType } = dragEventStates;
if (!startedTimeSlot || !selectionType) return;
const updatedCachedSelectedTimeSlots: TimeSlot[] =
startedTimeSlot && endedTimeSlot && selectionType
? endedTimeSlot
? timeSlotMatrix.reduce((acc, dayOfTimes) => {
const dateIsReversed = dayjs(endedTimeSlot.date).isBefore(dayjs(startedTimeSlot.date));
const date = startedTimeSlot.date;
const timeIsReversed = dayjs(`${date}
${endedTimeSlot.minTime}`).isBefore(
dayjs(`${date}
${startedTimeSlot.minTime}`),
);
return acc.concat(
dayOfTimes.filter(
t =>
isDateBetween(
dateIsReversed ? endedTimeSlot : startedTimeSlot,
t,
dateIsReversed ? startedTimeSlot : endedTimeSlot,
) &&
isTimeBetween(
timeIsReversed ? endedTimeSlot : startedTimeSlot,
t,
timeIsReversed ? startedTimeSlot : endedTimeSlot,
),
),
);
}, [])
: [startedTimeSlot]
: [];
const nextDraft =
selectionType === Selection.ADD
? Array.from(new Set([...selectedTimeSlots, ...updatedCachedSelectedTimeSlots]))
: selectionType === Selection.REMOVE
? selectedTimeSlots.filter(a => !updatedCachedSelectedTimeSlots.find(b => areTimeSlotsEqual(a, b)))
: [...selectedTimeSlots];
setDragEventStates(prev => ({
...prev,
cachedSelectedTimeSlots: nextDraft,
}));
};
5. DraggableSelector
📂 🚫 ColumnLabel → ✅ DateLabel
- 시간 간격을 나타내는 부분
🚫 Outdated version
import dayjs from 'dayjs';
import * as S from './styles';
import { DEFAULT_DATE_FORMAT } from '../../../constant/options';
import { getIterableDays, getUniqueDateKey } from '../../../utils/date';
interface ColumnLabelProps {
dates?: Date[];
dateFormat?: string;
mode: 'date' | 'day';
language: 'en' | 'ko';
gap?: string;
slotMinWidth?: string;
columnLabelHeight?: string;
columnLabelBgColor?: string;
columnLabelPadding?: string;
columnLabelBorderRadius?: string;
columnLabelsColor?: string;
columnLabelsMargin?: string;
columnLabelsBgColor?: string;
columnLabelsFontSize?: string;
columnLabelsFontFamily?: string;
columnLabelsFontWeight?: number;
columnLabelsBorderRadius?: string;
}
/*
"ColumnLabel" component is used to display the "date or day" of the column.
*/
export default function ColumnLabel({
gap,
mode,
dates,
language,
dateFormat,
slotMinWidth,
columnLabelHeight,
columnLabelBgColor,
columnLabelPadding,
columnLabelBorderRadius,
columnLabelsColor,
columnLabelsMargin,
columnLabelsBgColor,
columnLabelsFontSize,
columnLabelsFontFamily,
columnLabelsFontWeight,
columnLabelsBorderRadius,
}: ColumnLabelProps) {
if (!dates || dates?.length === 0) {
return <></>;
}
return (
<S.Items
$gap={gap}
$columnLabelsColor={columnLabelsColor}
$columnLabelsMargin={columnLabelsMargin}
$columnLabelsBgColor={columnLabelsBgColor}
$columnLabelsFontSize={columnLabelsFontSize}
$columnLabelsFontFamily={columnLabelsFontFamily}
$columnLabelsFontWeight={columnLabelsFontWeight}
$columnLabelsBorderRadius={columnLabelsBorderRadius}
>
{mode === 'day' ? (
<>
{getIterableDays(language)?.map(day => {
return (
<S.Item
key={day}
$width={slotMinWidth}
$height={columnLabelHeight}
>
<S.Label
$padding={columnLabelPadding}
$columnLabelBgColor={columnLabelBgColor}
$columnLabelBorderRadius={columnLabelBorderRadius}
>
{day}
</S.Label>
</S.Item>
);
})}
</>
) : (
<>
{dates?.map(date => {
return (
<S.Item
$width={slotMinWidth}
$height={columnLabelHeight}
key={getUniqueDateKey(date)}
>
<S.Label
$padding={columnLabelPadding}
$columnLabelBgColor={columnLabelBgColor}
$columnLabelBorderRadius={columnLabelBorderRadius}
>
{dayjs(date).format(dateFormat || DEFAULT_DATE_FORMAT)}
</S.Label>
</S.Item>
);
})}
</>
)}
</S.Items>
);
}
✅ Updated version
import * as S from './styles';
interface DateLabelProps {
labels?: string[];
slotWidth: number;
marginBottom: number;
}
const DateLabel = ({ labels, slotWidth, marginBottom }: DateLabelProps) => {
if (!labels || labels.length === 0) {
return <></>;
}
return (
<S.Container $marginBottom={marginBottom}>
{labels?.map((label, index) => {
return (
<S.Label key={`${label}${index}`} $width={slotWidth}>
{label}
</S.Label>
);
})}
</S.Container>
);
};
export default DateLabel;
📂 🚫 RowLabel → ✅ TimeLabel
- 날짜를 나타내는 부분
🚫 Outdated version
import dayjs from 'dayjs';
import 'dayjs/locale/ko';
import * as S from './styles';
import { type TimeSlot } from '../../../types/time';
import { DEFAULT_TIME_FORMAT } from '../../../constant/options';
interface RowLabelProps {
timeFormat?: string;
timeSlots?: TimeSlot[];
language?: 'en' | 'ko';
gap?: string;
slotHeight?: string;
rowLabelBgColor?: string;
rowLabelPadding?: string;
rowLabelBorderRadius?: string;
rowLabelsColor?: string;
rowLabelsMargin?: string;
rowLabelsBgColor?: string;
rowLabelsFontSize?: string;
rowLabelsFontFamily?: string;
rowLabelsFontWeight?: number;
rowLabelsBorderRadius?: string;
}
/*
"RowLabel" component is used to display the "times" of the row.
*/
export default function RowLabel({
timeFormat,
timeSlots,
language,
gap,
slotHeight,
rowLabelPadding,
rowLabelBgColor,
rowLabelBorderRadius,
rowLabelsColor,
rowLabelsMargin,
rowLabelsBgColor,
rowLabelsFontSize,
rowLabelsFontFamily,
rowLabelsFontWeight,
rowLabelsBorderRadius,
}: RowLabelProps) {
if (!timeSlots || timeSlots?.length === 0) {
return <></>;
}
if (language === 'ko') {
dayjs.locale('ko');
} else {
dayjs.locale('en');
}
return (
<S.Items
$gap={gap}
$rowLabelsColor={rowLabelsColor}
$rowLabelsMargin={rowLabelsMargin}
$rowLabelsBgColor={rowLabelsBgColor}
$rowLabelsFontSize={rowLabelsFontSize}
$rowLabelsFontFamily={rowLabelsFontFamily}
$rowLabelsFontWeight={rowLabelsFontWeight}
$rowLabelsBorderRadius={rowLabelsBorderRadius}
>
{timeSlots?.map(({ date, startTime, endTime }) => {
const dayjsDate = dayjs(`${date} ${startTime}:${endTime}`);
return (
<S.Item key={startTime} $height={slotHeight}>
<S.Label
$padding={rowLabelPadding}
$rowLabelBgColor={rowLabelBgColor}
$rowLabelBorderRadius={rowLabelBorderRadius}
>
{dayjsDate.format(timeFormat || DEFAULT_TIME_FORMAT)}
</S.Label>
</S.Item>
);
})}
</S.Items>
);
}
✅ Updated version
import { useMemo } from 'react';
import dayjs from 'dayjs';
import * as S from './styles';
import { TimeSlot } from '../../../types/timeInfo';
interface TimeLabelProps {
slotHeight: number;
timeFormat: string;
timeSlots?: TimeSlot[];
}
const TimeLabel = ({ slotHeight, timeFormat, timeSlots }: TimeLabelProps) => {
const getEvenIdxTimeSlots = useMemo(() => {
return timeSlots?.filter((_, idx) => idx % 2 === 0);
}, [timeSlots]);
if (!timeSlots || timeSlots.length === 0) {
return <></>;
}
return (
<S.Container>
{getEvenIdxTimeSlots?.map(({ date, minTime }) => {
const dayjsDate = dayjs(`${date} ${minTime}`);
return (
<S.Label key={minTime} $height={slotHeight * 2}>
{dayjsDate.format(timeFormat)}
</S.Label>
);
})}
</S.Container>
);
};
export default TimeLabel;
📂 TimeSlots
- 시간 슬롯들을 나타내는 부분
🚫 Outdated version
import { useMemo } from 'react';
import * as S from './styles';
import { getDayNum } from '../../../utils/date';
import { type TimeSlot } from '../../../types/time';
import { areTimeSlotsEqual } from '../../../utils/time';
interface TimeSlotsProps {
mode: 'date' | 'day';
timeSlotMatrix?: TimeSlot[][];
mockTimeSlotMatrix?: TimeSlot[][];
timeSlotMatrixByDay?: TimeSlot[][];
cachedSelectedTimeSlots?: TimeSlot[];
handleMouseUp: (timeSlot: TimeSlot) => void;
handleMouseDown: (timeSlot: TimeSlot) => void;
handleMouseEnter: (timeSlot: TimeSlot) => void;
slotHeight?: string;
slotMinWidth?: string;
slotRowGap?: string;
slotColumnGap?: string;
slotBorderStyle?: string;
slotBorderRadius?: string;
defaultSlotColor?: string;
hoveredSlotColor?: string;
selectedSlotColor?: string;
disabledSlotColor?: string;
}
export default function TimeSlots({
mode,
timeSlotMatrix,
mockTimeSlotMatrix,
timeSlotMatrixByDay,
cachedSelectedTimeSlots,
handleMouseUp,
handleMouseDown,
handleMouseEnter,
slotHeight,
slotMinWidth,
slotRowGap,
slotColumnGap,
slotBorderStyle,
slotBorderRadius,
hoveredSlotColor,
defaultSlotColor,
selectedSlotColor,
disabledSlotColor,
}: TimeSlotsProps) {
const matrix = useMemo(() => {
return mode === 'day' ? mockTimeSlotMatrix : timeSlotMatrix;
}, [mockTimeSlotMatrix, mode, timeSlotMatrix]);
if (!matrix || !timeSlotMatrixByDay) {
return <></>;
}
const cols: number = matrix?.length;
const rows: number = matrix[0]?.length;
const gridTemplateRows: string = `repeat(${rows}, 1fr)`;
const gridTemplateColumns: string = `repeat(${cols}, 1fr)`;
return (
<S.ItemsGrid
$rowGap={slotRowGap}
$rows={gridTemplateRows}
$columnGap={slotColumnGap}
$cols={gridTemplateColumns}
onDragStart={() => false}
>
{matrix[0]?.map(
(_, colIndex: number) =>
matrix?.map(timeSlots => {
const targetSlot = timeSlots[colIndex];
const { date, startTime, endTime } = targetSlot;
const key = `${date}${startTime}${endTime}`;
const selected = Boolean(
cachedSelectedTimeSlots?.find(slot =>
areTimeSlotsEqual(slot, targetSlot, mode),
),
);
return (
<S.Item
key={key}
selected={selected}
$selectDisabled={
mode === 'day'
? timeSlotMatrixByDay[getDayNum(targetSlot.day)]?.length ===
0
: false
}
$height={slotHeight}
$width={slotMinWidth}
$slotBorderStyle={slotBorderStyle}
$slotBorderRadius={slotBorderRadius}
$hoveredSlotColor={hoveredSlotColor}
$defaultSlotColor={defaultSlotColor}
$selectedSlotColor={selectedSlotColor}
$disabledSlotColor={disabledSlotColor}
onMouseUp={() => {
handleMouseUp(targetSlot);
}}
onMouseDown={() => {
handleMouseDown(targetSlot);
}}
onMouseEnter={() => {
handleMouseEnter(targetSlot);
}}
></S.Item>
);
}),
)}
</S.ItemsGrid>
);
}
✅ Updated version
import * as S from './styles';
import { TimeSlot } from '../../../types/timeInfo';
import { areTimeSlotsEqual } from '../../../utils/time';
interface TimeSlotsProps {
mode: 'day' | 'date';
slotWidth: number;
slotHeight: number;
defaultSlotColor: string;
selectedSlotColor: string;
disabledSlotColor: string;
hoveredSlotColor: string;
slotsContainerBorder: string;
slotsContainerBorderRadius: string;
timeSlotMatrix?: TimeSlot[][];
cachedMatrixByDay: TimeSlot[][];
cachedSelectedTimeSlots?: TimeSlot[];
handleMouseUp: (timeSlot: TimeSlot) => void;
handleMouseDown: (timeSlot: TimeSlot) => void;
handleMouseEnter: (timeSlot: TimeSlot) => void;
}
const TimeSlots = ({
mode,
slotWidth,
slotHeight,
defaultSlotColor,
selectedSlotColor,
disabledSlotColor,
hoveredSlotColor,
slotsContainerBorder,
slotsContainerBorderRadius,
timeSlotMatrix,
cachedMatrixByDay,
handleMouseUp,
handleMouseDown,
handleMouseEnter,
cachedSelectedTimeSlots,
}: TimeSlotsProps) => {
if (!timeSlotMatrix) {
return <></>;
}
const cols: number = timeSlotMatrix?.length;
const rows: number = timeSlotMatrix[0]?.length;
const gridTemplateRows: string = `repeat(${rows}, 1fr)`;
const gridTemplateColumns: string = `repeat(${cols}, 1fr)`;
return (
<S.Grid
$gridTemplateRows={gridTemplateRows}
$gridTemplateColumns={gridTemplateColumns}
$slotsContainerBorder={slotsContainerBorder}
$slotsContainerBorderRadius={slotsContainerBorderRadius}
>
{timeSlotMatrix[0]?.map(
(_, colIndex: number) =>
timeSlotMatrix?.map(timeSlots => {
const target = timeSlots[colIndex];
const isDisabled =
mode === 'day' && (cachedMatrixByDay.length === 0 || cachedMatrixByDay[target?.day]?.length === 0);
const selected = isDisabled
? false
: Boolean(cachedSelectedTimeSlots?.find(slot => areTimeSlotsEqual(slot, target)));
const isEvenIdx = colIndex % 2 === 0;
const lastDate = timeSlotMatrix[cols - 1][0]?.date;
const lastMinTime = timeSlotMatrix[0][rows - 1]?.minTime;
const isRightSide = target?.date === lastDate;
const isBottomSide = target?.minTime === lastMinTime;
return (
<S.Slot
$width={slotWidth}
$height={slotHeight}
$isDisabled={isDisabled}
$isEvenIdx={isEvenIdx}
$isRightMost={isRightSide}
$isBottomMost={isBottomSide}
$defaultSlotColor={defaultSlotColor}
$selectedSlotColor={selectedSlotColor}
$disabledSlotColor={disabledSlotColor}
$hoveredSlotColor={hoveredSlotColor}
$selected={selected}
key={`${target?.date}-${target?.minTime}`}
onMouseUp={() => {
!isDisabled && handleMouseUp(target);
}}
onMouseDown={() => {
!isDisabled && handleMouseDown(target);
}}
onMouseEnter={() => {
!isDisabled && handleMouseEnter(target);
}}
/>
);
}),
)}
</S.Grid>
);
};
export default TimeSlots;
📂 DraggableSelector/index.tsx
- rowLabel, columnLabel, TimeSlots를 조합해 state를 props로 내려주는 부분
🚫 Outdated version
import React, { useCallback, useEffect, useState } from 'react';
import * as S from './styles';
import '../../styles/global.css';
import {
areTimeSlotsEqual,
getTimeSlotMatrix,
updateCachedSelectedTimeSlots,
} from '../../utils/time';
import {
getTimeSlotMatrixByDay,
removeDuplicatesAndSortByDate,
} from '../../utils/date';
import RowLabel from './RowLabel';
import TimeSlots from './TimeSlots';
import ColumnLabel from './ColumnLabel';
import { sampleDates } from '../../data/date';
import { type TimeSlot } from '../../types/time';
import { type DragEventStates, Selection } from '../../types/event';
import { DraggableSelectorProps } from '../../types/draggableSelector';
import {
DEFAULT_LANG,
DEFAULT_MODE,
DEFAULT_TIMEUNIT,
} from '../../constant/options';
const DraggableSelector = React.memo(
({
dates,
endTime,
startTime,
selectedTimeSlots,
setSelectedTimeSlots,
mode,
language,
timeUnit,
dateFormat,
timeFormat,
width,
height,
margin,
padding,
minWidth,
maxWidth,
minHeight,
maxHeight,
slotHeight,
slotMinWidth,
slotRowGap,
slotColumnGap,
slotBorderStyle,
slotBorderRadius,
defaultSlotColor,
hoveredSlotColor,
selectedSlotColor,
disabledSlotColor,
rowLabelBgColor,
rowLabelPadding,
rowLabelBorderRadius,
rowLabelWidth,
rowLabelsColor,
rowLabelsMargin,
rowLabelsBgColor,
rowLabelsFontSize,
rowLabelsFontFamily,
rowLabelsFontWeight,
rowLabelsBorderRadius,
isRowLabelInvisible,
columnLabelHeight,
columnLabelBgColor,
columnLabelPadding,
columnLabelBorderRadius,
columnLabelsColor,
columnLabelsMargin,
columnLabelsBgColor,
columnLabelsFontSize,
columnLabelsFontFamily,
columnLabelsFontWeight,
columnLabelsBorderRadius,
isColumnLabelInvisible,
scrollWidth,
scrollColor,
scrollBgColor,
}: DraggableSelectorProps) => {
/* ----- STATES ----- */
const [selectedDates, setSelectedDates] = useState<Date[]>(
removeDuplicatesAndSortByDate(dates),
);
const [timeSlotMatrix, setTimeSlotMatrix] = useState<TimeSlot[][]>([]);
const [dragEventStates, setDragEventStates] = useState<DragEventStates>({
selectionType: null,
startedTimeSlot: null,
cachedSelectedTimeSlots: [...selectedTimeSlots],
});
const [mockTimeSlotMatrix, setMockTimeSlotMatrix] = useState<TimeSlot[][]>(
[],
);
const [timeSlotMatrixByDay, setTimeSlotMatrixByDay] = useState<
TimeSlot[][]
>([]);
/* ----- STATES ----- */
/* ----- FUNC related with SELECTION & UPDATING ----- */
const startSelection = useCallback(
(startedTimeSlot: TimeSlot, selectedTimeSlots: TimeSlot[]) => {
const selectedTimeSlot = selectedTimeSlots.find(slot =>
areTimeSlotsEqual(startedTimeSlot, slot, mode || DEFAULT_MODE),
);
setDragEventStates(prev => ({
...prev,
startedTimeSlot: startedTimeSlot,
selectionType: selectedTimeSlot ? Selection.REMOVE : Selection.ADD,
}));
},
[mode],
);
const updateSlots = useCallback(() => {
setSelectedTimeSlots(dragEventStates.cachedSelectedTimeSlots);
setDragEventStates(prev => ({
...prev,
selectionType: null,
startedTimeSlot: null,
}));
}, [dragEventStates.cachedSelectedTimeSlots, setSelectedTimeSlots]);
const updateCache = useCallback(
(endedTimeSlot: TimeSlot) => {
if (mode === 'day') {
updateCachedSelectedTimeSlots({
mode: 'day',
endedTimeSlot,
timeSlotMatrix: mockTimeSlotMatrix,
dragEventStates,
selectedTimeSlots,
setDragEventStates,
timeSlotMatrixByDay,
});
} else {
updateCachedSelectedTimeSlots({
mode: 'date',
endedTimeSlot,
timeSlotMatrix,
dragEventStates,
selectedTimeSlots,
setDragEventStates,
timeSlotMatrixByDay,
});
}
},
[mode, dragEventStates, selectedTimeSlots, timeSlotMatrix],
);
/* ----- FUNC related with SELECTION & UPDATING ----- */
/* ----- EVENT HANDLERS ----- */
const handleMouseUp = useCallback(
(endedTimeSlot: TimeSlot) => {
updateCache(endedTimeSlot);
},
[updateCache],
);
const handleMouseEnter = useCallback(
(endedTimeSlot: TimeSlot) => {
updateCache(endedTimeSlot);
},
[updateCache],
);
const handleMouseDown = useCallback(
(startedTimeSlot: TimeSlot) => {
startSelection(startedTimeSlot, selectedTimeSlots);
},
[selectedTimeSlots, startSelection],
);
/* ----- EVENT HANDLERS ----- */
/* ----- EFFECTS ----- */
// Initialize and remove duplicated data when dates changed
useEffect(() => {
setSelectedDates(removeDuplicatesAndSortByDate(dates));
}, [dates]);
// Initialize data when options changed
useEffect(() => {
setDragEventStates({
selectionType: null,
startedTimeSlot: null,
cachedSelectedTimeSlots: [],
});
setSelectedTimeSlots([]);
}, [mode, startTime, endTime, timeUnit]);
// Initialize data when dates changed
useEffect(() => {
if (mode === 'day') {
setDragEventStates({
selectionType: null,
startedTimeSlot: null,
cachedSelectedTimeSlots: [],
});
setSelectedTimeSlots([]);
}
}, [mode, dates]);
// Remove timeSlots if date is not in the selectedDates
useEffect(() => {
const filteredTimeSlots = selectedTimeSlots.filter(slot => {
return selectedDates.some(date => {
const standardDate = new Date(slot.date);
return (
standardDate.getFullYear() === date.getFullYear() &&
standardDate.getMonth() === date.getMonth() &&
standardDate.getDate() === date.getDate()
);
});
});
setSelectedTimeSlots(filteredTimeSlots);
setDragEventStates(prev => ({
...prev,
cachedSelectedTimeSlots: filteredTimeSlots,
}));
}, [selectedDates]);
// Initialize timeSlotMatrix when dates changed
useEffect(() => {
const matrix = getTimeSlotMatrix({
timeUnit: timeUnit || DEFAULT_TIMEUNIT,
dates: selectedDates,
startTime: startTime,
endTime: endTime,
});
if (matrix) {
setTimeSlotMatrix(matrix);
}
}, [startTime, endTime, timeUnit, selectedDates]);
// Initialize timeSlotMatrixByDay when timeSlotMatrix changed
useEffect(() => {
const sortedMatrix = getTimeSlotMatrixByDay(timeSlotMatrix);
if (sortedMatrix) {
setTimeSlotMatrixByDay(sortedMatrix);
}
}, [timeSlotMatrix]);
// Initialize mockTimeSlotMatrix when dates changed
useEffect(() => {
const mockMatrix = getTimeSlotMatrix({
timeUnit: timeUnit || DEFAULT_TIMEUNIT,
dates: removeDuplicatesAndSortByDate(sampleDates),
startTime: startTime,
endTime: endTime,
});
if (mockMatrix) {
setMockTimeSlotMatrix(mockMatrix);
}
}, [startTime, endTime, timeUnit, selectedDates]);
// Add, Remove event listener
useEffect(() => {
document.addEventListener('mouseup', updateSlots);
return () => {
document.removeEventListener('mouseup', updateSlots);
};
}, [updateSlots]);
/* ----- EFFECTS ----- */
return (
<>
<S.Container
$width={width}
$height={height}
$margin={margin}
$padding={padding}
$minWidth={minWidth}
$maxWidth={maxWidth}
$minHeight={minHeight}
$maxHeight={maxHeight}
$scrollWidth={scrollWidth}
$scrollColor={scrollColor}
$scrollBgColor={scrollBgColor}
>
{selectedDates && startTime && endTime && (
<>
{!isRowLabelInvisible && (
<S.LeftContainer $rowLabelWidth={rowLabelWidth}>
{!isColumnLabelInvisible && (
<S.EmptySlot height={columnLabelHeight} />
)}
<RowLabel
gap={slotRowGap}
language={language || DEFAULT_LANG}
slotHeight={slotHeight}
timeFormat={timeFormat}
timeSlots={timeSlotMatrix[0]}
rowLabelsColor={rowLabelsColor}
rowLabelsMargin={rowLabelsMargin}
rowLabelBgColor={rowLabelBgColor}
rowLabelPadding={rowLabelPadding}
rowLabelsBgColor={rowLabelsBgColor}
rowLabelsFontSize={rowLabelsFontSize}
rowLabelsFontWeight={rowLabelsFontWeight}
rowLabelsFontFamily={rowLabelsFontFamily}
rowLabelBorderRadius={rowLabelBorderRadius}
rowLabelsBorderRadius={rowLabelsBorderRadius}
/>
</S.LeftContainer>
)}
<S.RightContainer>
{!isColumnLabelInvisible && (
<ColumnLabel
dates={selectedDates}
dateFormat={dateFormat}
mode={mode || DEFAULT_MODE}
language={language || DEFAULT_LANG}
gap={slotColumnGap}
slotMinWidth={slotMinWidth}
columnLabelHeight={columnLabelHeight}
columnLabelsColor={columnLabelsColor}
columnLabelsMargin={columnLabelsMargin}
columnLabelBgColor={columnLabelBgColor}
columnLabelPadding={columnLabelPadding}
columnLabelsBgColor={columnLabelsBgColor}
columnLabelsFontSize={columnLabelsFontSize}
columnLabelsFontFamily={columnLabelsFontFamily}
columnLabelsFontWeight={columnLabelsFontWeight}
columnLabelBorderRadius={columnLabelBorderRadius}
columnLabelsBorderRadius={columnLabelsBorderRadius}
/>
)}
<TimeSlots
slotRowGap={slotRowGap}
slotHeight={slotHeight}
mode={mode || DEFAULT_MODE}
slotMinWidth={slotMinWidth}
slotColumnGap={slotColumnGap}
timeSlotMatrix={timeSlotMatrix}
slotBorderStyle={slotBorderStyle}
hoveredSlotColor={hoveredSlotColor}
defaultSlotColor={defaultSlotColor}
slotBorderRadius={slotBorderRadius}
selectedSlotColor={selectedSlotColor}
disabledSlotColor={disabledSlotColor}
mockTimeSlotMatrix={mockTimeSlotMatrix}
timeSlotMatrixByDay={timeSlotMatrixByDay}
handleMouseUp={handleMouseUp}
handleMouseDown={handleMouseDown}
handleMouseEnter={handleMouseEnter}
cachedSelectedTimeSlots={
dragEventStates.cachedSelectedTimeSlots
}
/>
</S.RightContainer>
</>
)}
</S.Container>
</>
);
},
);
export default DraggableSelector;
✅ Updated version
Selector와 DraggableSelector 컴포넌트의 분리
- DraggableSelector는 Selector의 래퍼 컴포넌트입니다.
- Selector는 타임 슬롯 선택 로직에 집중하며, DraggableSelector는 사용자에게 제공되는 시간 슬롯에 대한 데이터를 처리합니다. 각 컴포넌트의 역할이 명확해진다 생각해 각각의 관심사를 분리했습니다.
📂 Selector/index.tsx
import React, { useEffect, useLayoutEffect, useState } from 'react';
import * as S from './styles';
import {
areTimeSlotsEqual,
getLabelsFromDates,
getFilteredTimeSlotsByDate,
updateCachedSelectedTimeSlots,
} from '../../../utils/time';
import TimeLabel from '../TimeLabel';
import DateLabel from '../DateLabel';
import TimeSlots from '../TimeSlots';
import { TimeSlot } from '../../../types/timeInfo';
import { DragEventStates, Selection } from '../../../types/domEvent';
interface SelectorProps {
slotWidth: number;
slotHeight: number;
slotsMarginTop: number;
slotsMarginLeft: number;
maxWidth: string;
maxHeight: string;
defaultSlotColor: string;
selectedSlotColor: string;
disabledSlotColor: string;
hoveredSlotColor: string;
slotsContainerBorder: string;
slotsContainerBorderRadius: string;
minTime: string;
maxTime: string;
dateFormat: string;
timeFormat: string;
mode: 'day' | 'date';
selectedDates: Date[];
timeSlotMatrix: TimeSlot[][];
cachedMatrixByDay: TimeSlot[][];
selectedTimeSlots: TimeSlot[];
timeUnit: 5 | 10 | 15 | 20 | 30 | 60;
setSelectedTimeSlots: React.Dispatch<React.SetStateAction<TimeSlot[]>>;
}
export default function Selector({
mode,
slotWidth,
slotHeight,
slotsMarginTop,
slotsMarginLeft,
maxWidth,
maxHeight,
defaultSlotColor,
selectedSlotColor,
disabledSlotColor,
hoveredSlotColor,
slotsContainerBorder,
slotsContainerBorderRadius,
minTime,
maxTime,
timeUnit,
dateFormat,
timeFormat,
selectedDates,
timeSlotMatrix,
cachedMatrixByDay,
selectedTimeSlots,
setSelectedTimeSlots,
}: SelectorProps) {
/* STATES */
const [dragEventStates, setDragEventStates] = useState<DragEventStates>({
selectionType: null,
startedTimeSlot: null,
cachedSelectedTimeSlots: [],
});
/* ACTIONS */
const actions = {
startSelection: (startedTimeSlot: TimeSlot, selectedTimeSlots: TimeSlot[]) => {
const selectedTimeSlot = selectedTimeSlots.find(slot => areTimeSlotsEqual(startedTimeSlot, slot));
setDragEventStates(prev => ({
...prev,
startedTimeSlot: startedTimeSlot,
selectionType: selectedTimeSlot ? Selection.REMOVE : Selection.ADD,
}));
},
updateData: () => {
setSelectedTimeSlots(dragEventStates.cachedSelectedTimeSlots);
setDragEventStates(prev => ({
...prev,
selectionType: null,
startedTimeSlot: null,
}));
},
updateCachedSelectedTimeSlots: (endedTimeSlot: TimeSlot) => {
updateCachedSelectedTimeSlots({
endedTimeSlot,
timeSlotMatrix,
dragEventStates,
selectedTimeSlots,
setDragEventStates,
});
},
};
/* HANDLERS */
const handlers = {
handleMouseUp: (endedTimeSlot: TimeSlot) => {
actions.updateCachedSelectedTimeSlots(endedTimeSlot);
},
handleMouseEnter: (endedTimeSlot: TimeSlot) => {
actions.updateCachedSelectedTimeSlots(endedTimeSlot);
},
handleMouseDown: (startedTimeSlot: TimeSlot) => {
actions.startSelection(startedTimeSlot, selectedTimeSlots);
},
};
/* EFFECTS */
useEffect(() => {
document.addEventListener('mouseup', actions.updateData);
return () => {
document.removeEventListener('mouseup', actions.updateData);
};
}, [actions.updateData]);
/* 🧹 Cleanup cache when mode, minTime, maxTime, timeUnit is changed */
useEffect(() => {
setDragEventStates({
selectionType: null,
startedTimeSlot: null,
cachedSelectedTimeSlots: [],
});
}, [mode, minTime, maxTime, timeUnit]);
/* Remove timeSlots if date is not in the selectedDates */
useLayoutEffect(() => {
const filteredTimeSlots = getFilteredTimeSlotsByDate(selectedDates, selectedTimeSlots);
setSelectedTimeSlots(filteredTimeSlots);
setDragEventStates(prev => ({
...prev,
cachedSelectedTimeSlots: filteredTimeSlots,
}));
}, [selectedDates, cachedMatrixByDay]);
return (
<S.Container $maxWidth={maxWidth} $maxHeight={maxHeight}>
{selectedDates && minTime && maxTime && (
<>
<S.ContainerL $marginRight={slotsMarginLeft}>
<S.EmptySlot $marginBottom={slotsMarginTop} />
<TimeLabel slotHeight={slotHeight} timeFormat={timeFormat} timeSlots={timeSlotMatrix[0]} />
</S.ContainerL>
<S.ContainerR>
<DateLabel
slotWidth={slotWidth}
marginBottom={slotsMarginTop}
labels={
mode === 'day'
? ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT']
: getLabelsFromDates(selectedDates, dateFormat)
}
/>
<TimeSlots
mode={mode}
slotWidth={slotWidth}
slotHeight={slotHeight}
defaultSlotColor={defaultSlotColor}
selectedSlotColor={selectedSlotColor}
disabledSlotColor={disabledSlotColor}
hoveredSlotColor={hoveredSlotColor}
slotsContainerBorder={slotsContainerBorder}
slotsContainerBorderRadius={slotsContainerBorderRadius}
timeSlotMatrix={timeSlotMatrix}
cachedMatrixByDay={cachedMatrixByDay}
handleMouseUp={handlers.handleMouseUp}
handleMouseDown={handlers.handleMouseDown}
handleMouseEnter={handlers.handleMouseEnter}
cachedSelectedTimeSlots={dragEventStates.cachedSelectedTimeSlots}
/>
</S.ContainerR>
</>
)}
</S.Container>
);
}
📂 DraggableSelector/index.tsx
import React, { useLayoutEffect, useMemo, useState } from 'react';
import Selector from './Selector';
import {
getStrTime,
getTimeSlotRecord,
getTimeSlotMatrix,
getTimeSlotMatrixByDay,
getFilteredTimeSlotsByDate,
areTimeSlotsEqualByDayAndTime,
} from '../../utils/time';
import '../../styles/global.css';
import { TimeSlot, TimeSlotRecord } from '../../types/timeInfo';
export interface DraggableSelectorProps {
/*
* The start time of each day. Assign the value in `number`. e.g. `9`, `14`
*/
minTime: number;
/*
* The end time of each day. Assign the value in `number`. e.g. `8`, `22`
*/
maxTime: number;
/*
* The dates selected. Assign the value in `Date[]`. e.g. `[new Date('2021-01-01'), new Date('2021-01-02')]`
*/
dates: Date[];
/*
* Use the date format method of dayjs. You can use the following link to set the formatting form. (https://day.js.org/docs/en/display/format), `string`. e.g. `MM.DD`, `YYYY-MM-DD`
*/
dateFormat?: string;
/*
Use the time format method of dayjs. You can use the following link to set the formatting form. (https://day.js.org/docs/en/display/format), `string`. e.g. `HH:mm A`, `HH:mm`
*/
timeFormat?: string;
/*
* Decide whether to indicate all dates or by day of the week. (In the 'day' version) If there is no day of the week corresponding to the selected date, the cell is blocked so that it cannot be selected. `day | date`. e.g. `day`, `date`
*/
mode?: 'day' | 'date';
/*
* The time slots you selected. If you put the setTimeSlots in the props together, the result value will be automatically set according to the cell you selected. Create Date objects using the obtained timeSlot arrangement or use them in various ways. `TimeSlot[]`
*/
timeSlots: TimeSlot[];
/*
* The function to set the time slots you selected. If you put the timeSlots in the props together, the result value will be automatically set according to the cell you selected. Create Date objects using the obtained timeSlot arrangement or use them in various ways. `React.Dispatch<React.SetStateAction<TimeSlot[]>>`
*/
setTimeSlots: React.Dispatch<React.SetStateAction<TimeSlot[]>>;
/*
* The time unit of each slot. Assign the value in `5 | 10 | 15 | 20 | 30 | 60`. e.g. `5`, `10`, `15`, `20`, `30`, `60`
*/
timeUnit?: 5 | 10 | 15 | 20 | 30 | 60;
/*
* The width of each slot. Assign the value in `number`.
*/
slotWidth?: number;
/*
* The height of each slot. Assign the value in `number`.
*/
slotHeight?: number;
/*
* The margin-top of slots container. Assign the value in `number`.
*/
slotsMarginTop?: number;
/*
* The margin-left of slots container. Assign the value in `number`.
*/
slotsMarginLeft?: number;
/*
* The max-width of selector. Assign the value in `string`. e.g. `536px`
*/
maxWidth?: string;
/*
* The max-height of selector. Assign the value in `string`. e.g. `452px`, `100%`
*/
maxHeight?: string;
/*
* The default color of each slot. Assign the value in `string`. e.g. `#FFFFFF`
*/
defaultSlotColor?: string;
/*
* The color of each slot when it is selected. Assign the value in `string`. e.g. `#FFF5E5`
*/
selectedSlotColor?: string;
/*
* The color of each slot when it is disabled. Assign the value in `string`. e.g. `#e1e1e1`
*/
disabledSlotColor?: string;
/*
* The color of each slot when it is hovered. Assign the value in `string`. e.g. `#FFF5E5`
*/
hoveredSlotColor?: string;
/*
* The border of slots container. Assign the value in `string`. e.g. `1px solid #8c8d94`
*/
slotsContainerBorder?: string;
/*
* The border-radius of slots container. Assign the value in `string`. e.g. `0px`, `5px`
*/
slotsContainerBorderRadius?: string;
}
export default function DraggableSelector({
minTime,
maxTime,
timeUnit = 30,
dateFormat = 'MM.DD',
timeFormat = 'HH:mm A',
timeSlots,
setTimeSlots,
mode = 'day',
dates,
slotWidth = 62,
slotHeight = 18,
slotsMarginTop = 11,
slotsMarginLeft = 20,
maxWidth = '546px',
maxHeight = '452px',
defaultSlotColor = '#FFFFFF',
selectedSlotColor = '#b3c6d3',
disabledSlotColor = '#e1e1e1',
hoveredSlotColor = '#eef2f6',
slotsContainerBorder = '1px solid #8c8d94',
slotsContainerBorderRadius = '0px',
}: DraggableSelectorProps) {
const [, setTimeSlotRecord] = useState<TimeSlotRecord>();
const [cachedMatrix, setCachedMatrix] = useState<TimeSlot[][]>([]);
const [timeSlotMatrix, setTimeSlotMatrix] = useState<TimeSlot[][]>([]);
const [selectedTimeSlots, setSelectedTimeSlots] = useState<TimeSlot[]>([]);
const [cachedMatrixByDay, setCachedMatrixByDay] = useState<TimeSlot[][]>([]);
const datesForDayMode = useMemo(() => {
return [
new Date('2023-08-20'),
new Date('2023-08-21'),
new Date('2023-08-22'),
new Date('2023-08-23'),
new Date('2023-08-24'),
new Date('2023-08-25'),
new Date('2023-08-26'),
];
}, [mode]);
/* 🧹 Cleanup timeSlots & selectedTimeSlots when mode, minTime, maxTime, timeUnit is changed */
useLayoutEffect(() => {
setTimeSlots([]);
setSelectedTimeSlots([]);
}, [mode, minTime, maxTime, timeUnit]);
/* Initialize timeSlotMatrix when mode, dates, minTime, maxTime, timeUnit is changed */
useLayoutEffect(() => {
const targetDates = mode === 'day' ? datesForDayMode : dates;
const record = getTimeSlotRecord({
minTime,
maxTime,
timeUnit,
dates: targetDates,
});
if (record) {
setTimeSlotRecord(record);
setTimeSlotMatrix(getTimeSlotMatrix(record));
} else {
setTimeSlotRecord({});
setTimeSlotMatrix([]);
}
if (mode === 'day') {
const cachedRecord = getTimeSlotRecord({
dates,
minTime,
maxTime,
timeUnit,
});
if (cachedRecord) {
setCachedMatrix(getTimeSlotMatrix(cachedRecord));
} else {
setCachedMatrix([]);
}
}
}, [mode, dates, minTime, maxTime, timeUnit]);
/* (🧪 Reprocessing) SET TIME SLOTS for USER DATA */
useLayoutEffect(() => {
if (mode === 'date') {
setTimeSlots([...selectedTimeSlots]);
} else {
const updatedTimeSlots: TimeSlot[] = [];
selectedTimeSlots?.forEach(slot => {
const target = getTimeSlotMatrixByDay(cachedMatrix);
if (target) {
const targetArr = target[slot.day];
targetArr?.forEach(t => {
if (areTimeSlotsEqualByDayAndTime(t, slot)) {
updatedTimeSlots.push(t);
}
});
}
});
setTimeSlots(updatedTimeSlots);
}
}, [selectedTimeSlots, cachedMatrix]);
useLayoutEffect(() => {
if (mode === 'day') {
const updatedTimeSlots = getFilteredTimeSlotsByDate(dates, timeSlots);
setTimeSlots(updatedTimeSlots);
}
}, [dates]);
useLayoutEffect(() => {
const updatedCachedMatrixByDay = getTimeSlotMatrixByDay(cachedMatrix);
if (updatedCachedMatrixByDay) {
setCachedMatrixByDay(updatedCachedMatrixByDay);
} else {
setCachedMatrixByDay([]);
}
}, [dates, cachedMatrix]);
return (
<Selector
mode={mode}
slotWidth={slotWidth}
slotHeight={slotHeight}
slotsMarginTop={slotsMarginTop}
slotsMarginLeft={slotsMarginLeft}
maxWidth={maxWidth}
maxHeight={maxHeight}
defaultSlotColor={defaultSlotColor}
selectedSlotColor={selectedSlotColor}
disabledSlotColor={disabledSlotColor}
hoveredSlotColor={hoveredSlotColor}
slotsContainerBorder={slotsContainerBorder}
slotsContainerBorderRadius={slotsContainerBorderRadius}
minTime={getStrTime(minTime)}
maxTime={getStrTime(maxTime)}
timeUnit={timeUnit}
dateFormat={dateFormat}
timeFormat={timeFormat}
timeSlotMatrix={timeSlotMatrix}
cachedMatrixByDay={cachedMatrixByDay}
selectedTimeSlots={selectedTimeSlots}
setSelectedTimeSlots={setSelectedTimeSlots}
selectedDates={mode === 'day' ? datesForDayMode : dates}
/>
);
}
'프로젝트 기록' 카테고리의 다른 글
| [Meetable] Styled-components를 twin.macro로 마이그레이션하기 (0) | 2023.08.20 |
|---|---|
| [React-draggable-selector] 셀렉터 라이브러리 제작하기 (3) - npm 배포하기 (0) | 2023.08.16 |
| [React-draggable-selector] 셀렉터 라이브러리 제작하기 (1) - 개요 및 기능탐색 (0) | 2023.08.12 |
| [Meetable] Draggable-time-selector 구현기 (with REACT, TS) (4) | 2023.08.01 |
| [Waffle-market] 랜딩페이지 성능 개선하기 (0) | 2023.07.27 |