Через несколько недель после начала моей первой работы программистом мне было поручено создать форму. Цель формы заключалась в сборе нескольких частей информации от пользователя, чтобы он мог выполнить поиск. Пользователь должен выбрать местоположение из выпадающего списка и указать дату начала и окончания, а также время.
Будучи новичком в React и javascript в целом, я просто начал с очень простой формы.
<form>
<input type="text"
name="location"
/>
<input type="date"
name="start"
/>
<input type="date"
name="end"
/>
<button type="submit">Search</button>
</form>
Я начал сталкиваться с трудностями, которые, как мне кажется, характерны для многих разработчиков React. Как мне настроить и стилизовать форму? У меня есть дизайн, которому я должен следовать, и который требует определенных цветов, форм, шрифтов и т.д., а родные html-формы просто не дают разработчику много контроля.
Я потратил время на исследования и нашел всевозможные учебники и сторонние библиотеки, которые можно попробовать. Я пробовал бесплатные, бесплатные пробные версии платных библиотек, библиотеки с открытым исходным кодом. У каждой из них есть своя кривая обучения, чтобы просто уметь ими пользоваться. Исследования и эксперименты отнимали много времени, но это был ценный опыт — просто научиться работать со сторонними компонентами и внедрять их. Однако в конце дня я просто не смог преодолеть барьеры, чтобы настроить форму и вводимые данные так, как мне нужно, в частности, переключатели дат.
Я показал своей команде «неплохую» версию с использованием react-hook-forms и react-datepicker, но когда они попросили меня переместить, изменить размер, изменить форму и цвет, мне пришлось взломать форму, создать ее на заказ и добавить везде !important, чтобы отменить встроенный CSS, настолько, что было решено, что эффективнее будет создать ее с нуля.
Хотя готовая форма имела несколько действительно классных переключателей времени, слайдеров и пользовательских выпадающих элементов с автозаполнением, основное внимание в этом руководстве будет уделено части календаря/переключателя дат.
Чтобы извлечь только самые важные части и сохранить простоту, я начну с npx create-react-app my-app --template typescript
и удалю некоторые ненужные файлы и логотипы.
Если вы хотите сделать TLDR и перейти сразу к готовому коду, не стесняйтесь делать это здесь. Или если вы хотите использовать мою опубликованную версию, которая имеет больше возможностей, чем это демо, ее можно найти на npm или просто npm i date-range-calendar
.
Я начинаю с формы, похожей на вышеприведенную, просто чтобы заложить основу и работать дальше небольшими постепенными шагами.
Создайте форму с несколькими входами и кнопкой отправки в App.tsx. Создайте несколько div, чтобы отделить форму от остальной части страницы. Что-то вроде этого должно быть достаточно
import React from 'react';
import './App.css';
const [form, formSet] = useState({
destination: '',
start: '',
end: ''
});
function handleSubmit() {
console.log(form);
}
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
formSet({ ...form, [e.target.name]: e.target.value });
}
function App() {
return (
<div className='container'>
<form className='form' >
<div className='input'>
<input type="text"
name="location"
/>
</div>
<div className='input'>
<input type="date"
name="start"
/>
</div>
<div className='input'>
<input type="date"
name="end"
/>
</div>
<div className='input'>
<button onClick={handleSubmit} type='button'>Search</button>
</div>
</form>
</div >
)
}
export default App;
Добавьте некоторые стили в App.css и установите границы вокруг наших div, чтобы помочь визуализировать размещение формы на странице.
.App {
text-align: center;
}
.container {
border: 5px solid rgb(46, 57, 110);
margin: 25px auto;
height: 600px;
width: 500px;
}
.form {
border: 2px solid blue;
height: 300px;
width: 300px;
margin: 25px auto;
padding: 5px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
Теперь у нас есть базовая форма с тремя входами, некоторыми значениями состояния, обработчиком onChange и обработчиком submit. Итак, встроенный вход для типа даты — это ваш основной стандартный datePicker. Он вполне функционален, но нам нужно что-то более современное и стильное.
Давайте удалим два ввода даты и заменим их компонентом. Создайте новый файл под названием DatePicker.tsx.
И создайте базовый компонент react. Этот компонент будет принимать некоторые реквизиты, чтобы он мог устанавливать значения в родительском компоненте. Давайте начнем с жестко закодированных чисел, чтобы получить представление о том, как это может выглядеть:
import React from "react"
import './DatePicker.css';
type DatePickerProps = {
}
const DatePicker = (props: DatePickerProps) => {
return (
<>
<div className='content'>
<div className="calendar">
<h4>June 2022</h4>
<div className="row"> 1 2 3 4 5 6 7</div>
<div className="row"> 8 9 10 11 12 13 14</div>
<div className="row">15 16 17 18 19 20 21</div>
<div className="row">22 23 24 25 26 27 28</div>
<div className="row">29 30 31</div>
</div>
</div>
</>
)
};
export default DatePicker;
А вот некоторые стили для DatePicker.css
.content {
position: absolute;
top: 65%;
bottom: 10%;
left: 50%;
transform: translate(-50%, 0);
background: #fff;
overflow: auto;
border: 1px solid #ccc;
border-radius: 11px;
}
.calendar{
width: 90%;
display: flex;
flex-flow: column nowrap;
align-items: center;
}
.row{
display: flex;
flex-flow: row nowrap;
justify-content: space-between;
align-items: center;
}
Теперь вернемся в App.tsx, мы хотим, чтобы наш календарь появлялся всякий раз, когда пользователь нажимает «начать» или «закончить», поэтому добавим еще одно значение состояния
И условно отобразим дочерний компонент в зависимости от значения open.
<div onClick={() => openSet(!open)}>{form.start}</div>
<div onClick={() => openSet(!open)}>{form.end}</div>
{open && <DatePicker />}
Теперь календарь открывается и закрывается при нажатии на начало или конец. Мы создаем dateRangePicker, поэтому мы хотим, чтобы календарь открывался и позволял пользователю выбрать обе даты.
Следующим шагом будет создание реального календаря и заполнение его реальными значениями с учетом високосного года и т.д. Создайте новый файл CalendarUtils.ts, и мы сохраним здесь все наши вспомогательные методы. У нас будет массив названий месяцев:
Календарь будет состоять из месяцев. Каждый месяц будет состоять из 4, 5 или 6 строк. В каждом ряду будет 7 отдельных блоков или ячеек. В большинстве из них будут дни, но несколько в начале и конце будут пустыми. Давайте начнем с уровня блоков и будем продвигаться вверх. Блок будет просто стилизованным
элемент со значением.
type BoxProps = {
value: number | string
}
function Box(props: BoxProps) {
return (
<p>
{props.value}
</p>
);
}
Далее, давайте отобразим несколько ящиков в другом компоненте под названием Row. В каждом ряду будет 7 ячеек. Поэтому создадим число number[] с помощью цикла for, нарежем его и отобразим на него, чтобы создать ряд из 7 ячеек. Нам нужно указать, с какого числа будет начинаться и заканчиваться каждый ряд, поэтому мы передадим эти значения в качестве реквизитов.
type IRowProps = {
startIndex: number
endIndex: number
}
function CalendarRow(props: IRowProps) {
const dates: number[] = [];
for (let i = 1; i < 32; i++) {
dates.push(i);
}
return (
<>
<div >
{
dates.slice(props.startIndex, props.endIndex).map((d, index) =>
<Box value={d} />
)
}
</div>
</>
)
}
Как видите, по умолчанию мы создаем интерфейс или тип реквизита для каждого компонента, поэтому мы можем легко передавать реквизиты по мере обнаружения того, что необходимо передать.
Теперь нам нужен компонент Month для отображения этих строк. Каждой строке нужны индексы, которые мы пока будем жестко кодировать, а также месяц и год.
type IMonthProps = {
}
function Month(props: IMonthProps) {
return (
<>
<h4 className='month'>February 2026</h4>
<div className='days'>Sun Mon Tue Wed Thu Fri Sat</div>
<div className='calendar'>
<Row startIndex={0} endIndex={6}/>
<Row startIndex={7} endIndex={13}/>
<Row startIndex={14} endIndex={19}/>
<Row startIndex={21} endIndex={25}/>
</div>
</>
)
}
И обновим некоторые стили в файле DatePicker.css
.calendar {
display: flex;
flex-flow: column nowrap;
align-items: center;
}
.month{
margin:6px;
}
.row {
display: flex;
flex-flow: row nowrap;
justify-content: space-between;
align-items: center;
}
Теперь мы можем вернуться к основной функции экспорта DatePicker и отобразить этот компонент. Но затем давайте продолжим и
используем цикл for, чтобы получить 2 года месяцев для рендеринга.
const DatePicker = (props: DatePickerProps) => {
let monthsArray = new Array();
for (let i = 0; i < 24; i++) {
monthsArray.push(<Month/>)
}
return (
<>
<div className='content'>
<div className="calendar">
{monthsArray}
</div>
</div>
</>
)
};
Следующий шаг — начать заменять некоторые из наших жестко закодированных дней и месяцев на фактические значения даты с помощью объекта Javascript Date(). Мы можем использовать библиотеку, Day.js был бы моим выбором, но пока мы можем свести наши зависимости к минимуму.
Вот здесь-то и начинается настоящее веселье. Давайте обновим компонент Month, чтобы он принимал пару реквизитов, чтобы мы могли определить фактические начальные и конечные индексы для каждой строки. Вместо того чтобы передавать начальный и конечный индекс в Row.tsx, я хочу обработать эту логику в самом компоненте, но я хочу указать номер ряда. Помните, нам всегда нужно как минимум 4 ряда, обычно 5, а иногда и 6. Также нам понадобится написать несколько вспомогательных методов. Нам нужен метод, который даст нам массив с точными значениями заданного месяца и пустые строки для нескольких дней до и после. Поэтому нам нужно будет передать в этот метод количество дней в месяце и числовой день недели, с которого начинается месяц. Мы должны творчески подойти к тому, как сделать так, чтобы эти методы давали нам правильные дни и точное количество дней в каждом месяце. Вот что я придумал. Конечно, есть и другие способы, но этот работает, и он эффективен.
type IMonthProps = {
month: number
year: number
}
Затем в нашем DatePicker мы добавим некоторую логику для определения дат и передадим их компоненту Month.
const today = new Date()
let month = today.getMonth() + 1
let year = today.getFullYear()
const numOfDays = new Date(today.getFullYear(), today.getMonth() + 1, 0).getDate();
const monthName = firstDateOfMonth.toLocaleString('default', { month: 'long' })
for (let i = 0; i < 24; i++) {
monthsArray.push(<Month month={month} year={year} />)
year = month == 12 ? year + 1 : year
month++
}
Помощники
export const getNumberOfRows = (numberOfDaysInMonth: number, dayOfTheWeek: number) => {
switch (numberOfDaysInMonth - (21 + (7 - dayOfTheWeek))) {
case 0: return 4;
case 8: case 9: return 6;
default: return 5;
}
}
Теперь мы должны рефакторить наш Row.tsx, чтобы создать реальные даты[] с нужными значениями.
tye IRowProps = {
dayOfWeek: number
numberOfDaysInMonth: number
rowNumber: number
}
function Row(props: IRowProps) {
const dates = getOneMonthArray(props.dayOfWeek, props.numberOfDaysInMonth)
let rowNumber = 0;
rowNumber += props.rowNumber;
let startIndex: number = (rowNumber * 7);
let endIndex = startIndex + 7;
return (...)
}
Теперь у нас есть отлично работающий календарь, который по умолчанию отображает текущий месяц. Вы можете легко изменить код в соответствии с вашим сценарием использования. Например, цикл for можно обновить, чтобы он выполнялся только один раз и, следовательно, показывал только текущий месяц. Или вместо того, чтобы начинать с сегодняшнего дня, вы можете начать с прошлого или будущего месяца.
Хорошо. Отлично! У нас есть основа, на которой можно строить. Следующим этапом будет превращение этого календаря в интерактивный datePicker или dateRangePicker. Мы добавим обработчики onClick, будем управлять состоянием, выделять выбранные даты и т.д. Помните, что в нашем главном компоненте App.tsx мы храним наше состояние вместе с главной формой. Поэтому нам нужно передать функции сеттера в наш компонент DatePicker.tsx. Так как мы разбили его на множество компонентов, нам нужно либо пропиарить наши сеттеры и пару значений по всему пути, либо использовать контекст или хранилище типа redux. Для этого демонстрационного ролика мы просто пропишем их. В App.tsx напишите еще две функции. Одну мы назовем handleClick() и передадим ее вниз в наш компонент Box.tsx и присвоим ей значение
в возврате.
Эта функция должна перехватить и обработать дату, на которую был сделан щелчок, включая месяц, день и год. Поскольку мы создаем DateRangePicker, а не просто DatePicker, нам нужно знать, была ли уже выбрана дата. Кроме того, нам нужно определить, была ли уже выбрана одна или обе даты, и если дата, на которую щелкнули, меньше или больше. Я предпочитаю начинать с onClick в
тега Box.tsx, чтобы посмотреть, к какой информации я имею доступ и каков ее тип. Если вы добавите onClick={(e)=> console.log(e)}
, вы увидите все, к чему у вас есть доступ. Добавьте следующие функции в App.tsx
const [startDate, startDateSet] = useState<Date | undefined>(undefined);
const [endDate, endDateSet] = useState<Date | undefined>(undefined);
function handleCalenderClicks(e: React.MouseEvent<HTMLDivElement>, value: string) {
let p = e.target as HTMLDivElement
if (!(startDate && !endDate)) {
startDateSet(new Date(value))
formSet({ ...form, start: value as string, end: 'end' })
endDateSet(undefined)
resetDivs()
p.style.color = 'green'
p.style.backgroundColor = 'lightblue'
}
else if (new Date(value) >= startDate) {
endDateSet(new Date(value))
formSet({ ...form, end: value as string })
p.style.color = 'red'
p.style.backgroundColor = 'lightblue'
}
else {
startDateSet(new Date(value))
formSet({ ...form, start: value as string })
resetDivs()
p.style.color = 'green'
p.style.backgroundColor = 'lightblue'
}
}
function resetDivs() {
let container = document.querySelectorAll('p')
container.forEach((div) => {
let box = div as HTMLParagraphElement;
if ((box.style.color == 'red' || 'green')) {
box.style.color = 'inherit';
box.style.fontWeight = 'inherit';
box.style.backgroundColor = 'inherit';
}
})
}
Как вы можете видеть, здесь мы учитываем все возможные состояния, в которых может находиться наш выбор, выделяем их и добавляем зеленый цвет к началу и красный к концу. Наиболее распространенное состояние — присвоить началу дату, на которую только что нажали, и сбросить все остальное. Я решил использовать document.querySelectorAll('p')
для того, чтобы снять подсветку с предыдущих вариантов, но будьте осторожны, если у вас есть другие интерактивные теги на той же странице.
теги на той же странице. Если у вас есть другие
теги, но вы никак не управляете их стилями, то функция resetDivs() не повредит им.
Обязательно добавьте функцию и значения месяца и года в типы реквизитов для каждого компонента по мере необходимости, например:
handleClick: (e: React.MouseEvent<HTMLDivElement>, value: string) => void
month: number
year: number
и добавьте их к компонентам по мере необходимости, например:
<Row month={props.month} year={props.year} handleClick={props.handleClick} dayOfWeek={dayOfWeek} numberOfDaysInMonth={numOfDays} rowNumber={0} />
Спасибо за прочтение и, пожалуйста, дайте мне знать, если у вас возникнут вопросы или комментарии.
Повторюсь, ссылки на готовый код можно найти здесь. Или если вы хотите использовать мою опубликованную версию, которая имеет больше возможностей, чем это демо, ее можно найти на npm или просто ‘npm i date-range-calendar’.