react-datepickerをカスタマイズ、i18n対応。ChatGPT4がカスタマイズコード作成。

2023/04/26に公開

react-datepickerをカスタマイズして、日付入力フィールドを国際化対応したので記事にしました。
ChatGPTが作成してくれたのは、カレンダーオブジェクトのカスタムヘッダー部分です。実用的なコードを提案してくれました。非常に優秀だと感じました。

環境

  • Next.js12.1.4 (built-inのi18n機能を使用)
  • TypeScript
  • Tailwind

要件1)日付入力フィールドの国際化対応(ラベル、PlaceHolder等)

日付入力(DatePicker)特有の国際化対応の要件として、ラベルやPlaceHolderの国際化対応に加え、各地域毎の年月日及び曜日の表記についても対応する必要があります。

例:
日本:年月の並び順、2023/4/15、2023年4月15日 のような標記
米国:年月の並び順、4/15/2023、 15 Apr 2023 のような標記

要件2)年月指定のUX向上

通常あるような、DatePickerの年月指定は少し直感的ではないので改善したい。
UXを最適化する為には、カレンダーの移動を、前月、翌月の矢印に加え、年月を直接ドロップダウンで指定できるようにしたい。

標準的なDatePickeにおける年月指定。
標準的なDatePickeにおける年月指定。

対応方針:react-datepickerをカスタマイズして導入する。

調査の結果、react-datepickerが、showMonthDropDown,showYearDropDown というオプションの指定により年月のドロップダウン指定が可能なので、採用を決定。

react-datepicker

今回の記事では、国際化対応の為に、react-datepickerのオプション設定やカスタマイズを行いましたので、コードを交えながら紹介したいと思います。

実装

1. react-datepickerのインストール

npm i react-datepicker
npm i @types/react-datepicker

react-datepickerは、国際化対応にも対応したライブラリなので、言語オプションを指定することにより、簡単に国際化対応を行うことができます。

2. カスタマイズコンポーネントの作成(ロケール設定)

import ja from 'date-fns/locale/ja'
import enUS from 'date-fns/locale/en-US'

// 中略

  registerLocale('ja', ja)
  registerLocale('en', enUS)

// 中略
  
  <DatePicker
   // 中略
    locale='ja'
  />

ただ、図をみていただければわかるように、右の日本語版では、カレンダーヘッダーの年月表示が英語版と同じになったまま、「月、年」となっているので、これを修正します。

カスタマイズ前のDatePicker日英
カスタマイズ前のDatePicker日英

幸い、react-datepickerは、カスタムヘッダーを設定できます。
ここでは、CustomCalendarHeaderを作成し、それをDatePickerから呼び出す形で記述しました。

CustomCalendarHeaderのコードについては、ChatGPT4に作成してもらいました。

プロンプトは以下の通りです。

react-datepickerのカレンダーを日英対応したいと思います。

環境
・Next.js
・Tailwind
・react-datepicke

現在のコードでは、日本語版において、
日付要素をクリックしたときに表示されるカレンダーの
年月のドロップダウンセレクトが
月、年の並びになります。
これは英語ではいいですが、日本語では、年、月の並びにしたいです。

どのようにコードを書けばいいですか?

現在のコード(省略)

細かい修正は必要でしたが、ほぼ要件通りのUXが実装可能なコードを出力してくれました。非常に優秀です。

3.ChatGPTの出力をもとに作成したカスタムヘッダーのコード

CustomCalendarHeader.tsx
import React, { ChangeEvent } from 'react'
import { MdKeyboardDoubleArrowLeft, MdKeyboardDoubleArrowRight } from 'react-icons/md'
import 'react-datepicker/dist/react-datepicker.css'

interface CustomCalendarHeaderProps {
  date: Date
  changeYear: (year: number) => void
  changeMonth: (month: number) => void
  decreaseMonth: () => void
  increaseMonth: () => void
  prevMonthButtonDisabled: boolean
  nextMonthButtonDisabled: boolean
}

const YEARS_FOR_DROPDOWN = 10

export const CustomCalendarHeader: React.FC<CustomCalendarHeaderProps> = ({
  date,
  changeYear,
  changeMonth,
  decreaseMonth,
  increaseMonth,
  prevMonthButtonDisabled,
  nextMonthButtonDisabled,
}) => {
  const years = [...Array(YEARS_FOR_DROPDOWN)].map((_, i) => new Date().getFullYear() - i)

  return (
    <div className='react-datepicker__header'>
      <div>
        <button
          className='react-datepicker__navigation react-datepicker__navigation--previous'
          onClick={decreaseMonth}
          disabled={prevMonthButtonDisabled}
        >
          <MdKeyboardDoubleArrowLeft />
        </button>
        <div className='mx-2'>
          {date.toLocaleDateString('ja-JP', { year: 'numeric', month: 'long' })}
        </div>
        <button
          className='react-datepicker__navigation react-datepicker__navigation--next'
          onClick={increaseMonth}
          disabled={nextMonthButtonDisabled}
        >
          <MdKeyboardDoubleArrowRight />
        </button>
      </div>

      <div>
        <button
          className='react-datepicker__navigation react-datepicker__navigation--previous'
          onClick={decreaseMonth}
          disabled={prevMonthButtonDisabled}
        />
        <select
          value={date.getFullYear()}
          onChange={({ target: { value } }: ChangeEvent<HTMLSelectElement>) =>
            changeYear(Number(value))
          }
          className='react-datepicker__year-select mr-2 mt-2'
        >
          {years.map((year) => (
            <option key={year} value={year}>
              {year}
            </option>
          ))}
        </select>
        <select
          value={date.getMonth()}
          onChange={({ target: { value } }: ChangeEvent<HTMLSelectElement>) =>
            changeMonth(Number(value))
          }
          className='react-datepicker__month-select'
        >
          {Array.from({ length: 12 }, (_, index) =>
            new Date(date.getFullYear(), index).toLocaleDateString('ja-JP', { month: 'long' })
          ).map((month, index) => (
            <option key={month} value={index}>
              {month}
            </option>
          ))}
        </select>
        <button
          className='react-datepicker__navigation react-datepicker__navigation--next'
          onClick={increaseMonth}
          disabled={nextMonthButtonDisabled}
        />
      </div>
    </div>
  )
}

上記、CustomCalendarHeaderコンポーネントを、renderCustomHeaderの値として指定。
以下は、Comment情報追加用のDatePickerのコード、日英対応部分です。

DatePickerCustomInput.tsx(一部)
// DatePicker related
import DatePicker, { registerLocale } from 'react-datepicker'
import 'react-datepicker/dist/react-datepicker.css'
import ja from 'date-fns/locale/ja'
import enUS from 'date-fns/locale/en-US'
import { CustomCalendarHeader } from '../functions/CustomCalendarHeader'
import { subYears } from 'date-fns'

// 中略

 {locale === 'ja-JP' ? (
    <DatePicker
      className={`rounded border-gray-100 p-1.5 text-base outline-0`}
      wrapperClassName='react-datepicker__input-container'
      placeholderText={'日付を選択'}
      selected={inputComment.date ? new Date(inputComment.date) : null}
      onChange={(date) =>
      setInputComment({
          ...inputComment,
          date: date ? date.toISOString().substring(0, 10) : null,
      })
      }
      required
      data-testid='commentDateInput'
      dropdownMode='select'
      disabled={editStatus}
      locale='ja'
      dateFormat='yyyy/MM/dd'
      renderCustomHeader={({
        date,
        changeYear,
        changeMonth,
        decreaseMonth,
        increaseMonth,
        prevMonthButtonDisabled,
        nextMonthButtonDisabled,
        }) => (
        <CustomCalendarHeader
          date={date}
          changeYear={changeYear}
          changeMonth={changeMonth}
          decreaseMonth={decreaseMonth}
          increaseMonth={increaseMonth}
          prevMonthButtonDisabled={prevMonthButtonDisabled}
          nextMonthButtonDisabled={nextMonthButtonDisabled}
        />
      )}
    />
    ) : (
    <DatePicker
      className={`rounded border-gray-100 p-1.5 text-base outline-0`}
      placeholderText={'Please select date'}
      selected={inputComment.date ? new Date(inputComment.date) : null}
      onChange={(date) =>
      setInputComment({
          ...inputComment,
          date: date ? date.toISOString().substring(0, 10) : null,
      })
      }
      required
      data-testid='commentDateInput'
      peekNextMonth
      showMonthDropdown
      showYearDropdown
      dropdownMode='select'
      disabled={editStatus}
      locale='en'
      dateFormat='MM/dd/yyyy'
      minDate={subYears(new Date(), 10)}
    />
    )}

※追記
英語対応の場合にも、renderCustomHeaderオプションを設定する場合は、CustomCalendarHeaderコンポーネントに渡すpropsにlocaleを追加して、言語毎にコンポーネント内で表示設定等を切り替えることにより、コンポーネントの共有化が可能です。

最終的なUI(日本語版)

カレンダーヘッダーの年月の並びが正しく表示されています。

日本語様式にカスタマイズされたDatePicker
日本語様式にカスタマイズされたDatePicker

単体テストのコード(testing-library)

createCommentMutationがsuccessした際に、mockDataを返すようにしています。

CommentInput.test.tsx
import { render, fireEvent, waitFor, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Provider } from 'react-redux'
import store from '../../store/store'
import InputComment from '../../components/InputCommentsState'
import { QueryClient, QueryClientProvider } from 'react-query'
import { useMutateComment } from '../../hooks/useMutateComment'
import ja from '../../locales/ja/ja'

const queryClient = new QueryClient()

jest.mock('../../hooks/useQueryComments', () => ({
  useQueryComment: () => ({
    data: [],
    status: 'success',
  }),
}))

jest.mock('../../hooks/useMutateComment', () => {
  const mockData = [{ id: 1, memo: 'Test Memo', date: '2023-05-01', ticker: 'AAPL' }]

  const createCommentMutation = {
    mutate: jest.fn(),
    onSuccess: jest.fn(),
  }

  createCommentMutation.mutate.mockImplementation((comment) => {
    mockData.push(comment)
    // onSuccess関数を呼び出す
    createCommentMutation.onSuccess(mockData)
  })

  return {
    useMutateComment: () => ({
      createCommentMutation,
      updateCommentMutation: {
        mutate: jest.fn(),
      },
      deleteCommentMutation: {
        mutate: jest.fn(),
      },
    }),
  }
})

jest.mock('../../hooks/useQueryComments', () => ({
  useQueryComments: () => ({
    data: [{ id: 1, memo: 'Test Memo', date: '2023-05-01', ticker: 'AAPL' }],
    error: null,
    isLoading: false,
    isSuccess: true,
    isError: false,
  }),
}))

jest.mock('next/router', () => ({
  useRouter: () => ({
    locale: 'ja-JP',
  }),
}))

const setup = () => {
  return render(
    <QueryClientProvider client={queryClient}>
      <Provider store={store}>
        <InputComment ticker='AAPL' t={ja} />
      </Provider>
    </QueryClientProvider>
  )
}

describe('InputComment component', () => {
  it('adds a Comment correctly', async () => {
    const { getByTestId, container } = setup()

    const commentDateInput = container.querySelector('.react-datepicker__input-container input')
    const commentMemoInput = screen.getByTestId('commentMemoInput')
    const addCommentButton = screen.getByTestId('addComment')

    const list = screen.getByRole('list')

    // Get the mocked createCommentMutation
    const { createCommentMutation } = useMutateComment()

    // Select date
    if (commentDateInput) {
      await userEvent.type(commentDateInput, '2023/05/01')
      expect(commentDateInput).toHaveValue('2023/05/01')
    }

    // Input memo
    await userEvent.type(commentMemoInput, 'Test Memo')
    expect(commentMemoInput).toHaveValue('Test Memo')

    // Check that the button is enabled
    expect(addCommentButton).not.toBeDisabled()

    // Click add comment button
    fireEvent.submit(getByTestId('inputCommentForm')) 

    // Wait for the mutate function to be called
    await waitFor(() => {
      expect(createCommentMutation.mutate).toHaveBeenCalledTimes(1)
    })

    await waitFor(async () => {
      const testMemoElement = await screen.findByText('Test Memo')
      expect(testMemoElement).toBeInTheDocument()
    })
  })

})


まとめ

ChatGPTは優秀です!

Discussion