인턴 생활을 시작한 지 벌써 1달하고 2주가 지났다. 요즘 회사에서는 프론트엔드 개발자로서 UI 컴포넌트 구현 및 리팩터링을 담당하고 있다. 그 중에서 오늘 만든 날짜 선택 컴포넌트가 인상 깊어서 따로 정리해보고자 한다.
내 개인 프로젝트인 네컷사진에서 날짜 선택 기능을 구현한 적 있는데 자유롭지 못한 포맷이 꽤 아쉬웠던 기억이 있다. 근데 이번 테스크를 통해 더욱 유연한 날짜 선택 컴포넌트를 만들 수 있게 되었다.
지금부터 유연한 DateSelection 컴포넌트를 만들어보자.
1. React 설치 및 파일 생성
DateSelection 컴포넌트를 사용할 App.jsx
파일과 DateSelection.jsx
파일로 분리한다.
// App.jsx
import DateSelection from './components/DateSelection';
function App() {
return (
<div style={{ margin: '20px' }}>
<DateSelection />
</div>
);
}
export default App;
// ./components/DateSelection.jsx
import React from 'react';
import { BsFillCalendarHeartFill } from 'react-icons/bs';
const DateSelection = () => {
return (
<div>
<input
type='text'
value=''
placeholder='placeholder'
/>
<button type='button'>
<BsFillCalendarHeartFill />
</button>
</div>
);
};
export default DateSelection;
React-icons
npm install react-icons
공식문서에서 더 많은 아이콘을 볼 수 있다.
2. 기본적인 기능 구현
input에 키보드를 입력하면 date
, 즉 value가 바뀌도록 한다.
// ./components/DateSelection.jsx
...
const DateSelection = () => {
const [date, setDate] = useState('');
const handleChangeDate = (e) => {
const currentValue = e.target.value;
setDate(currentValue);
};
return (
<div>
<input
type='text'
value={date}
placeholder='placeholder'
onChange={handleChangeDate}
/>
...
3. 날짜 format 정보 저장하기
날짜 형식인 format
을 정한다. (추후에 format
을 자유롭게 변경할 수 있도록 한다.)
getSeparator
함수를 통해 format
에 사용된 날짜 구분 symbol
과 그것의 위치를 담은 indexes
를 구한다.
날짜 구분은 숫자나 문자가 될 수 없기 때문에 /[^0-9a-zA-Z]+/
과 같은 정규표현식을 사용한다. match
는 format
에서 정규표현식에 매치되는 것을 나타내며, ['-', index: 4, input: 'YYYY-MM-DD', groups: undefined]
이다.
symbol
은 매치되는 기호인 match[0]
, 즉 '-'이다.
format
의 문자를 하나씩 돌려 해당 문자가 symbol
일 경우 해당 위치를 indexes
에 넣는다.
정규표현식으로 매치되는 값이 없다면 빈 값을 반환한다.
반환한 값은 separator
에 저장한다.
// ./components/DateSelection.jsx
const DateSelection = () => {
const [date, setDate] = useState('');
const format = 'YYYY-MM-DD';
const getSeparator = () => {
const regex = /[^0-9a-zA-Z]+/;
const match = format.match(regex);
if (match) {
const symbol = match[0];
const indexes = [];
for (let i = 0; i < format.length; i++) {
if (format[i] === symbol) {
indexes.push(i);
}
}
return { symbol, indexes };
}
return { symbol: undefined, indexes: [] };
};
const separator = getSeparator();
// separator 값이 맞는지 확인
React.useEffect(() => {
console.log(separator);
// { symbol: '-', indexes: [4, 7] }
}, []);
...
4. 입력 값 formatting하기
input 값을 변경하는 handleChangeDate
함수에 separator
가 존재하는 경우에 대한 기능을 추가한다.
separator.indexs
를 돌려 currentDate
에 symbol
이 들어가야 하는지 판단한다.
만약 currentDate
길이가 해당 symbol
위치보다 크다면 당 index
를 기준으로 currentDate
를 나누고 그 사이에 symbol
을 넣는다.
예를 들어, '20221010'과 같은 값이 들어오면 '2022-10-10'으로 바꾼다.
// ./components/DateSelection.jsx
const DateSelection = () => {
...
const separator = getSeparator();
const handleChangeDate = (e) => {
let currentDate = e.target.value;
if (separator.symbol && separator.indexes.length > 0) {
separator.indexes.forEach((index) => {
if (currentDate.length > index) {
currentDate =
currentDate.slice(0, index) +
separator.symbol +
currentDate.slice(index);
}
});
}
setDate(currentDate);
};
return (
...
5. Calendar 위젯 기능
text 타입의 input은 달력을 제공하지 않기 때문에 리액트 라이브러리인 react-datetime
을 사용한다.
button을 클릭하면 캘린더를 열고 닫히도록 한다.
open
일 경우 캘린더인 Datetime
컴포넌트가 보이며 필요한 props를 넣어준다.
값을 넣는 input
은 앞에서 이미 만들었기 때문에 false
로 지정한다.
시간 조절하는 기능은 넣지 않을 것이기 때문에 dateFormat
도 false
로 지정한다. dateFormat
은 위에 미리 선언한 format
으로 지정한다.
Datetime
내 날짜를 클릭하면 date
가 변경되도록 한다.formattedDate
는 selected
, 즉 선택된 날짜가format
에 맞게 포멧팅 된 값이다.date
를 formattedDate
로 바꾸고 Datetime
을 닫아주면 기능이 끝난다.
...
import Datetime from 'react-datetime';
const DateSelection = () => {
const [date, setDate] = useState('');
const [open, setOpen] = useState(false);
...
const handleClickButton = () => {
setOpen(!open);
};
const handleChangeCalendar = (selected) => {
const formattedDate = selected.format(format);
setDate(formattedDate);
setOpen(false);
};
return (
...
<button type='button' onClick={handleClickButton}>
<BsFillCalendarHeartFill />
</button>
{open && (
<Datetime
input={false}
timeFormat={false}
dateFormat={format}
value={date}
onChange={handleChangeCalendar}
/>
)}
</section>
);
};
export default DateSelection;
React-datetime
npm install --save react-datetime
공식문서에서 다양한 속성을 볼 수 있다.
6. 날짜 유효성 체크
input의 focus가 해제(blur)되면 checkValidDate
함수를 호출시켜 날짜 유효성을 검사한다.
이것은 Moment.js 라이브러리를 활용하면 간단하게 검사할 수 있다.
그 중 moment
함수로 날짜나 시간을 포맷팅 할 수 있다.
매개변수는 값, 형식, 값이 형식에 맞는지 확인하는가(옵션)로 이루어져 있으며,
우리가 만들고 있는 컴포넌트는 value가 format에 정확히 맞아야 하기 때문에 moment(value, format, true)
라고 한다.selectedDate
는 Moment {_isAMomentObject: true, _i: '2023/07/30', _f: 'YYYY/MM/DD', _strict: true, _isUTC: false, …}
와 같은 Object를 나타낸다.
Moment.js 라이브러리의 메서드 중 하나인 isValid
로 해당 객체가 유효한 날짜인지 알 수 있다.
유효하지 않은 경우 빈 값으로 초기화한다.
// ./components/DateSelection.jsx
...
import moment from 'moment';
const DateSelection = () => {
...
const checkValidDate = (e) => {
const { value } = e.target;
const selectedDate = moment(value, format, true);
const isValid = selectedDate.isValid();
if (!isValid) {
setDate('');
}
};
return (
<section>
<input
...
onBlur={checkValidDate}
/>
...
Moment.js
npm install moment --save
공식문서에서 다양한 기능과 속성을 볼 수 있다.
7. format 및 autoFormatting 사용자 커스텀
// ./components/DateSelection.jsx
...
const DateSelection = ({
format = 'YYYY-MM-DD',
autoFormatting = true
}) => {
// const format = 'YYYY-MM-DD';
...
const handleChangeDate = (e) => {
let currentDate = e.target.value;
if (autoFormatting) {
if (separator.symbol && separator.indexes.length > 0) {
...
}
}
setDate(currentDate);
};
...
// ./App.jsx
function App() {
return (
<div style={{ margin: '20px' }}>
<DateSelection format='YYYY/MM/DD' autoFormatting={false} />
</div>
);
}
DateSelection.jsx 파일 안에서 format
을 선언해 사용했지만, DateSelection을 사용할 다른 컴포넌트에서 format
을 커스텀 할 수 있도록 props로 전달한다.
format
은 YYYY/MM/DD, YYYY-MM-DD, DD.MM.YY, YYMMDD과 같이 년(YY/YYYY)월(MM)일(DD)만 제대로 제공하면 원하는 대로 사용할 수 있다. Moment.js 덕분이다 :)
autoFormatting
은 날짜 입력 시 format
에 맞게 자동으로 수정해주는 지 선택하는 것이다.
seperator.indexes
를 순환하여 숫자 사이에 symbol
을 넣어주는 코드를 조건문 안에 옮겨준다.
8. 타이핑 오류 수정
autoFormatting
이 true인 경우에 한 가지 오류가 발견된다.
입력된 날짜의 중간에 커서를 두고 값을 수정하면 형식이 완전히 깨져버린다.
값을 수정할 때마다 indexes
위치에 symbal
이 존재하지 않으면 symbol
을 추가하는 기능만 있을 뿐,symbal
이 제자리에 없으면 삭제하는 기능은 없다.
따라서 값을 입력 받으면 symbol
을 모두 빼서 숫자만 남긴 후에 symbol
을 다시 추가한다.
더 이상 date[index]
가 symbol
과 다른지 체크할 필요도 없기 때문에 삭제한다.
// ./components/DateSelection.jsx
...
const handleChangeDate = (e) => {
const { target } = e
const { value } = target;
if (autoFormatting) {
let currentDate = [...value]
.filter((str) => str !== separator.symbol)
.join('');
if (separator.symbol && separator.indexes.length > 0) {
separator.indexes.forEach((index) => {
// if (currentDate.length > index && date[index] !== separator.symbol) {
if (currentDate.length > index) {
currentDate =
currentDate.slice(0, index) +
separator.symbol +
currentDate.slice(index);
}
});
}
setDate(currentDate);
} else {
setDate(value);
}
};
...
형식이 깨지는 오류는 해결하였지만 또 다른 문제가 발생했다. 중간 값을 수정하면 커서가 맨 뒤로 이동하는 것이다.
이것은 handleChangeDate
함수를 거치고 값이 date
로 통째로 바뀌었기 때문이다.
그렇다면 중간에 수정한 부분의 커서 위치를 알아내야 한다.
e.target
.selectionStart
를 통해 cursor가 선택된 첫 번째 자리를 알 수 있다.
cursor 위치인 cursorPosition
은 cursor가 있는 경우 selectionStart
로, 없는 경우 맨 뒤인 value.length
로 지정한다.
cursorPosition
과 value
의 길이가 동일할 때 cursor 위치를 오른쪽으로 한 칸 이동하는 이유는 중간에 기호가 들어오면 selectionStart
가 그것을 인지하지 못하기 때문이다.
커서 위치가 옮겨지는 시간을 벌기 위해 setTimeout을 사용한다.
e.target
.setSelectionRange
를 통해 해당 커서로 이동한다.
첫 번째 매개변수는 커서의 시작 위치, 두 번째 매개변수는 커서의 마지막 위치를 나타낸다.
// ./components/DateSelection.jsx
...
const handleChangeDate = (e) => {
const { target } = e;
const { value, selectionStart } = target;
if (autoFormatting) {
let cursorPosition = selectionStart || value.length;
if (cursorPosition === value.length) {
cursorPosition += 1;
}
setTimeout(() => {
target.setSelectionRange(cursorPosition, cursorPosition);
});
let currentDate = [...value]
.filter((str) => str !== separator.symbol)
.join('');
...
handleChangeDate
함수에서는 날짜 값을 바꾸는 역할만 하기 때문에 커서 위치를 바꾸는 기능을 handleCursorPosition
함수로 리팩터링한다.
// ./components/DateSelection.jsx
...
const handleCursorPosition = (target) => {
const { value, selectionStart } = target;
let cursorPosition = selectionStart || value.length;
if (cursorPosition === value.length) {
cursorPosition += 1;
}
setTimeout(() => {
target.setSelectionRange(cursorPosition, cursorPosition);
});
};
const handleChangeDate = (e) => {
const { target } = e;
const { value } = target;
if (autoFormatting) {
handleCursorPosition(target);
let currentDate = [...value]
.filter((str) => str !== separator.symbol)
.join('');
...
9. 마우스 이벤트
많은 사용자들은 Modal이나 Popover와 같은 컴포넌트가 뜨면 컴포넌트 바깥을 눌러 닫는 행동을 한다.
대략적인 로직은 마우스가 가리킨 위치가 입력창이나 달력이 아닌 곳일 경우에 달력을 닫아주는 것이다.
useRef
를 사용하여 DOM에 접근할 수 있다. input
과 button
을 감싸는 div
에 inputRef
를, Datetime
을 감싸는 div
에 calendarRef
를 부여한다.
open
상태이면서 inputRef
와 calendarRef
의 요소가 존재한다면 document 전체에 click 이벤트를 넣어준다. dependency 안에는 open
, inputRef
, calendarRef
를 넣으며, 이 값들 중 하나만 바뀌더라고 코드가 실행된다.
click 이벤트가 발생하면 handleClickout
으로 클릭 위치에 따른 조작을 한다. useCallback
을 사용한 이유는 useEffect
의 dependency가 변경될 때마다 handleClickout
이 호출되므로 불필요한 호출을 방지하기 위함이다.
inputRef
영역과 calendarRef
영역에 둘 다 클릭한 영역인 target
이 포함되어 있지 않다면 그 곳이 바깥 영역인 outArea
이다. outArea
가 true인 경우 열려 있는 달력을 닫아준다.
// ./components/DateSelection.jsx
const DateSelection = ({ format = 'YYYY-MM-DD', autoFormatting = true }) => {
const inputRef = useRef(null);
const calendarRef = useRef(null);
...
const handleClickOut = useCallback(
(e) => {
if (open && inputRef?.current && calendarRef?.current) {
const inputArea = inputRef.current;
const calendarArea = calendarRef.current;
const { target } = e;
const outArea =
!inputArea.contains(target) && !calendarArea.contains(target);
if (outArea) {
setOpen(false);
}
}
},
[open, inputRef, calendarRef]
);
useEffect(() => {
if (open && inputRef?.current && calendarRef?.current) {
document.addEventListener('click', handleClickOut);
}
}, [open, inputRef, calendarRef, handleClickOut]);
return (
<section>
<div ref={inputRef}>
<input ... />
<button type='button' onClick={handleClickButton}> ...
</div>
{open && (
<div className='calendar' ref={calendarRef}>
...
10. 키보드 이벤트
input
에 focus가 된 상태에서 키보드를 입력할 때 해당 이벤트의 코드가 Enter
인 경우에 달력을 열고 닫아준다.
button
에 focus가 된 상태에서 키보드를 입력할 때 해당 이벤트의 코드가 Tab
이고 달력이 열려 있는 경우에 달력을 닫아준다.
개인적으로 달력에도 키보드 이벤트를 넣고 싶었지만 react-datetime
에서 제공하지 않는 것 같다.
// ./components/DateSelection.jsx
...
const handleKeyDownInput = (e) => {
if (e.code === 'Enter') {
setOpen(!open);
}
};
const handleKeyDownButton = (e) => {
if (e.code === 'Tab' && open) {
setOpen(false);
}
};
...
return (
<section>
<div ref={inputRef}>
<input
...
onKeyDown={handleKeyDownInput}
/>
<button
...
onKeyDown={handleKeyDownButton}
>
<BsFillCalendarHeartFill />
</button>
...
11. 예쁘게 만들기 ✨
보기에 깔끔한 정도로만 CSS를 작성해보았다.
react-datetime
에서 제공하는 기본적인 스타일을 넣어주고 추가로 커스텀하였다. !important
를 꼭 넣어야만 기본 스타일을 덮어 씌운다.
// ./components/DateSelection.jsx
import 'react-datetime/css/react-datetime.css';
import '../App.css';
...
return (
<section className='dateselection'>
<div className='input-wrapper' ref={inputRef}>
<input
className='input'
...
/>
<button
className='input-button'
...
>
<BsFillCalendarHeartFill />
</button>
</div>
{open && (
<div className='calendar' ...>
...
// ./DateSelection.css
.dateselection {
position: relative;
width: 272px;
}
.input-wrapper {
position: relative;
width: 100%;
}
.input {
width: calc(100% - 34px);
padding: 8px 16px;
border-radius: 4px;
border: 1px solid #e0e2e7;
outline: none;
}
.input-button {
position: absolute;
top: 50%;
right: 8px;
transform: translateY(-50%);
padding: 0;
background: transparent;
border: none;
cursor: pointer;
}
.calendar {
width: 100%;
position: absolute;
left: 50%;
transform: translateX(-50%);
}
.calendar td {
border-radius: 100px;
}
.rdtPicker {
border: 1px solid #e0e2e7 !important;
border-radius: 4px !important;
}
td.rdtToday {
border: 1px solid #428bca !important;
}
td.rdtToday::before {
display: none !important;
}
마치며
사내에서 사용될 UI를 구축하다보니 여러 경우의 수를 생각하고 더욱 완벽한 컴포넌트를 구현하기 위해 힘을 쓰게 되었다. 아직까지는 기능 구현에 최대한 집중하고 있지만, 후에는 유지보수가 원활하도록 적절하게 리팩터링에도 힘써야겠다.