From 980977c94a1826acc639a4751487c36cd6d2c20c Mon Sep 17 00:00:00 2001 From: takurinton Date: Mon, 14 Aug 2023 14:25:45 +0900 Subject: [PATCH 1/3] feat: DateField --- .changeset/three-toys-smash.md | 5 + .../DateField/DateField.stories.tsx | 33 ++ src/components/DateField/DateField.tsx | 25 ++ .../DateField/__tests__/useDateField.test.ts | 152 +++++++++ .../DateField/__tests__/utils.test.ts | 64 ++++ src/components/DateField/constants.ts | 29 ++ src/components/DateField/index.ts | 1 + src/components/DateField/plugin/LICENCE | 21 ++ src/components/DateField/plugin/index.ts | 296 ++++++++++++++++++ src/components/DateField/styled.ts | 43 +++ src/components/DateField/types.ts | 32 ++ src/components/DateField/useDateField.ts | 254 +++++++++++++++ src/components/DateField/utils.ts | 87 +++++ 13 files changed, 1042 insertions(+) create mode 100644 .changeset/three-toys-smash.md create mode 100644 src/components/DateField/DateField.stories.tsx create mode 100644 src/components/DateField/DateField.tsx create mode 100644 src/components/DateField/__tests__/useDateField.test.ts create mode 100644 src/components/DateField/__tests__/utils.test.ts create mode 100644 src/components/DateField/constants.ts create mode 100644 src/components/DateField/index.ts create mode 100644 src/components/DateField/plugin/LICENCE create mode 100644 src/components/DateField/plugin/index.ts create mode 100644 src/components/DateField/styled.ts create mode 100644 src/components/DateField/types.ts create mode 100644 src/components/DateField/useDateField.ts create mode 100644 src/components/DateField/utils.ts diff --git a/.changeset/three-toys-smash.md b/.changeset/three-toys-smash.md new file mode 100644 index 000000000..0583adfe6 --- /dev/null +++ b/.changeset/three-toys-smash.md @@ -0,0 +1,5 @@ +--- +"ingred-ui": minor +--- + +feat `` diff --git a/src/components/DateField/DateField.stories.tsx b/src/components/DateField/DateField.stories.tsx new file mode 100644 index 000000000..1110ebec0 --- /dev/null +++ b/src/components/DateField/DateField.stories.tsx @@ -0,0 +1,33 @@ +import { StoryObj } from "@storybook/react"; +import DateField, { DateFieldProps } from "./DateField"; +import dayjs from "dayjs"; +import React, { useState } from "react"; + +export default { + title: "Components/Inputs/DateField", + component: DateField, +}; + +export const Example: StoryObj = { + render: (args) => { + const [date, setDate] = useState(dayjs()); + return ; + }, +}; + +export const Custom: StoryObj = { + args: { + format: "MM/DD/YYYY", + }, + render: (args) => { + const [date, setDate] = useState(dayjs()); + return ; + }, +}; + +export const Japanese: StoryObj = { + ...Example, + args: { + format: "YYYY月MM月DD日", + }, +}; diff --git a/src/components/DateField/DateField.tsx b/src/components/DateField/DateField.tsx new file mode 100644 index 000000000..dc507fbfb --- /dev/null +++ b/src/components/DateField/DateField.tsx @@ -0,0 +1,25 @@ +import React, { forwardRef, memo } from "react"; +import { Icon, Input } from ".."; +import { useDateField } from "./useDateField"; +import { useMergeRefs } from "./utils"; +import { CalendarIcon, InputContainer } from "./styled"; +import { DateFieldProps } from "./types"; + +const DateField = forwardRef( + function DateField({ onClick, ...rest }, propRef) { + const { ref: inputRef, ...props } = useDateField({ ...rest }); + const ref = useMergeRefs(propRef, inputRef); + + return ( + + + + + + + ); + }, +); + +export type { DateFieldProps } from "./types"; +export default memo(DateField); diff --git a/src/components/DateField/__tests__/useDateField.test.ts b/src/components/DateField/__tests__/useDateField.test.ts new file mode 100644 index 000000000..d6f38a99f --- /dev/null +++ b/src/components/DateField/__tests__/useDateField.test.ts @@ -0,0 +1,152 @@ +import { renderHook, act } from "@testing-library/react"; +import dayjs, { Dayjs } from "dayjs"; +import { useDateField } from "../useDateField"; + +import React from "react"; + +/** + * なんかユニットテストじゃまかないきれない気がしてきた + * + * @memo setSelectionRange のテストができないので、ArrowRight/ArrowLeft で移動してそのセクションで操作した結果をテストする + */ +describe("useDateField", () => { + let date: Dayjs; + let onDateChange: (date: Dayjs) => void; + const rest = { + changeState: null, + handleChangeState: () => {}, + }; + + beforeEach(() => { + date = dayjs("2023-01-01"); + onDateChange = jest.fn(); + jest.resetAllMocks(); + }); + + it("should initialize correctly", () => { + const { result } = renderHook(() => + useDateField({ date, format: "YYYY-MM-DD", onDateChange, ...rest }), + ); + + expect(result.current.value).toBe(date.format("YYYY-MM-DD")); + }); + + it("should initialize correctly when format is MM/DD/YYYY", () => { + const { result } = renderHook(() => + useDateField({ date, format: "MM/DD/YYYY", onDateChange, ...rest }), + ); + + expect(result.current.value).toBe(date.format("MM/DD/YYYY")); + }); + + // setSelectionRange のテストができないので、ArrowRight/ArrowLeft で移動してそのセクションで操作した結果をテストする + // つまり、ArrowRight/ArrowLeft で移動できることのテストも兼ねている(本当は分離したい) + describe("should update the date when a number key is pressed", () => { + it('should change the year to "1999" when press 1999 in year section', () => { + const { result } = renderHook(() => + useDateField({ date, format: "YYYY-MM-DD", onDateChange, ...rest }), + ); + + act(() => { + result.current.onMouseDown(); + }); + + const keys = ["1", "9", "9", "9"]; + + keys.forEach((key) => { + act(() => { + result.current.onKeyDown({ + key, + preventDefault: jest.fn(), + } as unknown as React.KeyboardEvent); + }); + }); + + expect(result.current.value).toBe("1999-01-01"); + }); + + it('should change the month to "02" when press 2 in month section', () => { + const { result } = renderHook(() => + useDateField({ date, format: "YYYY-MM-DD", onDateChange, ...rest }), + ); + + act(() => { + result.current.onKeyDown({ + key: "ArrowRight", + preventDefault: jest.fn(), + } as unknown as React.KeyboardEvent); + }); + + act(() => { + result.current.onKeyDown({ + key: "2", + preventDefault: jest.fn(), + } as unknown as React.KeyboardEvent); + }); + + expect(result.current.value).toBe("2023-02-01"); + }); + + it('should change the day to "02" when press 2 in day section', () => { + const { result } = renderHook(() => + useDateField({ date, format: "YYYY-MM-DD", onDateChange, ...rest }), + ); + + act(() => { + result.current.onKeyDown({ + key: "ArrowRight", + preventDefault: jest.fn(), + } as unknown as React.KeyboardEvent); + }); + + act(() => { + result.current.onKeyDown({ + key: "ArrowRight", + preventDefault: jest.fn(), + } as unknown as React.KeyboardEvent); + }); + + act(() => { + result.current.onKeyDown({ + key: "2", + preventDefault: jest.fn(), + } as unknown as React.KeyboardEvent); + }); + + expect(result.current.value).toBe("2023-01-02"); + }); + + it('should change the year to "1999" when press 1999 in year section after ArrowRight and ArrowLeft', () => { + const { result } = renderHook(() => + useDateField({ date, format: "YYYY-MM-DD", onDateChange, ...rest }), + ); + + act(() => { + result.current.onKeyDown({ + key: "ArrowRight", + preventDefault: jest.fn(), + } as unknown as React.KeyboardEvent); + }); + + act(() => { + result.current.onKeyDown({ + key: "ArrowLeft", + preventDefault: jest.fn(), + } as unknown as React.KeyboardEvent); + }); + + const keys = ["1", "9", "9", "9"]; + + keys.forEach((key) => { + act(() => { + result.current.onKeyDown({ + key, + preventDefault: jest.fn(), + } as unknown as React.KeyboardEvent); + }); + }); + + expect(result.current.value).toBe("1999-01-01"); + }); + }); +}); diff --git a/src/components/DateField/__tests__/utils.test.ts b/src/components/DateField/__tests__/utils.test.ts new file mode 100644 index 000000000..50778d266 --- /dev/null +++ b/src/components/DateField/__tests__/utils.test.ts @@ -0,0 +1,64 @@ +import { getSections } from "../utils"; + +describe("getSections", () => { + it("should return a start:0 end: -1 when no numbers are present", () => { + const formattedDate = "abcd"; + const result = getSections(formattedDate); + expect(result).toEqual([ + { start: 0, end: 3, value: "abcd", editable: false }, + ]); + }); + + it("should correctly parse a date string with YYYY-MM-DD", () => { + const formattedDate = "2023-01-02"; + const result = getSections(formattedDate); + const expected = [ + { start: 0, end: 3, value: "2023", editable: true }, + { start: 4, end: 4, value: "-", editable: false }, + { start: 5, end: 6, value: "01", editable: true }, + { start: 7, end: 7, value: "-", editable: false }, + { start: 8, end: 9, value: "02", editable: true }, + ]; + expect(result).toEqual(expected); + }); + + it("should handle a date string with MM/DD/YYYY", () => { + const formattedDate = "01/02/2023"; + const result = getSections(formattedDate); + const expected = [ + { start: 0, end: 1, value: "01", editable: true }, + { start: 2, end: 2, value: "/", editable: false }, + { start: 3, end: 4, value: "02", editable: true }, + { start: 5, end: 5, value: "/", editable: false }, + { start: 6, end: 9, value: "2023", editable: true }, + ]; + expect(result).toEqual(expected); + }); + + it("should handle a date string with YYYY年MM月NN日", () => { + const formattedDate = "2023年01月02日"; + const result = getSections(formattedDate); + const expected = [ + { start: 0, end: 3, value: "2023", editable: true }, + { start: 4, end: 4, value: "年", editable: false }, + { start: 5, end: 6, value: "01", editable: true }, + { start: 7, end: 7, value: "月", editable: false }, + { start: 8, end: 9, value: "02", editable: true }, + { start: 10, end: 10, value: "日", editable: false }, + ]; + expect(result).toEqual(expected); + }); + + it("should handle a date string with DD----MM+-*/===YY", () => { + const formattedDate = "02----01+-*/===23"; + const result = getSections(formattedDate); + const expected = [ + { start: 0, end: 1, value: "02", editable: true }, + { start: 2, end: 5, value: "----", editable: false }, + { start: 6, end: 7, value: "01", editable: true }, + { start: 8, end: 14, value: "+-*/===", editable: false }, + { start: 15, end: 16, value: "23", editable: true }, + ]; + expect(result).toEqual(expected); + }); +}); diff --git a/src/components/DateField/constants.ts b/src/components/DateField/constants.ts new file mode 100644 index 000000000..216378200 --- /dev/null +++ b/src/components/DateField/constants.ts @@ -0,0 +1,29 @@ +export const AllowedKeys = { + Backspace: "Backspace", + Delete: "Delete", + ArrowLeft: "ArrowLeft", + ArrowRight: "ArrowRight", + ArrowUp: "ArrowUp", + ArrowDown: "ArrowDown", + Tab: "Tab", + // Enter: "Enter", + // Escape: "Escape", + "0": "0", + "1": "1", + "2": "2", + "3": "3", + "4": "4", + "5": "5", + "6": "6", + "7": "7", + "8": "8", + "9": "9", +} as const; + +export const numberKeys = Object.keys(AllowedKeys).filter( + (key) => !isNaN(Number(key)), +); + +export const allowedKeys = Object.values(AllowedKeys); + +export type AllowedKeys = (typeof AllowedKeys)[keyof typeof AllowedKeys]; diff --git a/src/components/DateField/index.ts b/src/components/DateField/index.ts new file mode 100644 index 000000000..f7434805b --- /dev/null +++ b/src/components/DateField/index.ts @@ -0,0 +1 @@ +export { DateField } from "./DateField"; diff --git a/src/components/DateField/plugin/LICENCE b/src/components/DateField/plugin/LICENCE new file mode 100644 index 000000000..4a2c701e9 --- /dev/null +++ b/src/components/DateField/plugin/LICENCE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018-present, iamkun + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/src/components/DateField/plugin/index.ts b/src/components/DateField/plugin/index.ts new file mode 100644 index 000000000..4cc73d873 --- /dev/null +++ b/src/components/DateField/plugin/index.ts @@ -0,0 +1,296 @@ +// dayjs の customParseFormat を clone してカスタムしてる。Thank you dayjs. +// https://github.com/iamkun/dayjs/blob/dev/src/plugin/customParseFormat/index.js +/* eslint-disable */ +// @ts-nocheck +const formattingTokens = + /(\[[^[]*\])|([-_:/.,()\s]+)|(A|a|YYYY|YY?|MM?M?M?|Do|DD?|hh?|HH?|mm?|ss?|S{1,3}|z|ZZ?)/g; + +const match1 = /\d/; // 0 - 9 +const match2 = /\d\d/; // 00 - 99 +const match3 = /\d{3}/; // 000 - 999 +const match4 = /\d{4}/; // 0000 - 9999 +const match1to2 = /\d\d?/; // 0 - 99 +const matchSigned = /[+-]?\d+/; // -inf - inf +const matchOffset = /[+-]\d\d:?(\d\d)?|Z/; // +00:00 -00:00 +0000 or -0000 +00 or Z +const matchWord = /\d*[^-_:/,()\s\d]+/; // Word + +let locale = {}; + +let parseTwoDigitYear = function (input) { + input = +input; + return input + (input > 68 ? 1900 : 2000); +}; + +function offsetFromString(string) { + if (!string) return 0; + if (string === "Z") return 0; + const parts = string.match(/([+-]|\d\d)/g); + const minutes = +(parts[1] * 60) + (+parts[2] || 0); + return minutes === 0 ? 0 : parts[0] === "+" ? -minutes : minutes; // eslint-disable-line no-nested-ternary +} + +const addInput = function (property) { + return function (input) { + this[property] = +input; + }; +}; + +const zoneExpressions = [ + matchOffset, + function (input) { + const zone = this.zone || (this.zone = {}); + zone.offset = offsetFromString(input); + }, +]; + +const getLocalePart = (name) => { + const part = locale[name]; + return part && (part.indexOf ? part : part.s.concat(part.f)); +}; +const meridiemMatch = (input, isLowerCase) => { + let isAfternoon; + const { meridiem } = locale; + if (!meridiem) { + isAfternoon = input === (isLowerCase ? "pm" : "PM"); + } else { + for (let i = 1; i <= 24; i += 1) { + // todo: fix input === meridiem(i, 0, isLowerCase) + if (input.indexOf(meridiem(i, 0, isLowerCase)) > -1) { + isAfternoon = i > 12; + break; + } + } + } + return isAfternoon; +}; + +// 自分がやりたいことに対して機能過多すぎるので削っていいかも +const expressions = { + A: [ + matchWord, + function (input) { + this.afternoon = meridiemMatch(input, false); + }, + ], + a: [ + matchWord, + function (input) { + this.afternoon = meridiemMatch(input, true); + }, + ], + S: [ + match1, + function (input) { + this.milliseconds = +input * 100; + }, + ], + SS: [ + match2, + function (input) { + this.milliseconds = +input * 10; + }, + ], + SSS: [ + match3, + function (input) { + this.milliseconds = +input; + }, + ], + s: [match1to2, addInput("seconds")], + ss: [match1to2, addInput("seconds")], + m: [match1to2, addInput("minutes")], + mm: [match1to2, addInput("minutes")], + H: [match1to2, addInput("hours")], + h: [match1to2, addInput("hours")], + HH: [match1to2, addInput("hours")], + hh: [match1to2, addInput("hours")], + D: [match1to2, addInput("day")], + DD: [match2, addInput("day")], + Do: [ + matchWord, + function (input) { + const { ordinal } = locale; + [this.day] = input.match(/\d+/); + if (!ordinal) return; + for (let i = 1; i <= 31; i += 1) { + if (ordinal(i).replace(/\[|\]/g, "") === input) { + this.day = i; + } + } + }, + ], + M: [match1to2, addInput("month")], + MM: [match2, addInput("month")], + MMM: [ + matchWord, + function (input) { + const months = getLocalePart("months"); + const monthsShort = getLocalePart("monthsShort"); + const matchIndex = + (monthsShort || months.map((_) => _.slice(0, 3))).indexOf(input) + 1; + if (matchIndex < 1) { + throw new Error(); + } + this.month = matchIndex % 12 || matchIndex; + }, + ], + MMMM: [ + matchWord, + function (input) { + const months = getLocalePart("months"); + const matchIndex = months.indexOf(input) + 1; + if (matchIndex < 1) { + throw new Error(); + } + this.month = matchIndex % 12 || matchIndex; + }, + ], + Y: [matchSigned, addInput("year")], + YY: [ + match2, + function (input) { + this.year = parseTwoDigitYear(input); + }, + ], + YYYY: [match4, addInput("year")], + Z: zoneExpressions, + ZZ: zoneExpressions, +}; + +function correctHours(time) { + const { afternoon } = time; + if (afternoon !== undefined) { + const { hours } = time; + if (afternoon) { + if (hours < 12) { + time.hours += 12; + } + } else if (hours === 12) { + time.hours = 0; + } + delete time.afternoon; + } +} + +export function makeParser(format) { + // array に ['YYYY', 'MM', 'DD'] のような形で format が格納される + const array = format.match(formattingTokens); + const { length } = array; + for (let i = 0; i < length; i += 1) { + const token = array[i]; + const parseTo = expressions[token]; // マッチする対象 + const regex = parseTo && parseTo[0]; // マッチした正規表現 + const parser = parseTo && parseTo[1]; // マッチした正規表現にマッチした時の処理 + if (parser) { + // dayjs が認識できるフォーマットにマッチしたとき + array[i] = { regex, parser }; + } else { + // dayjs が認識できるフォーマットにマッチしなかったとき + array[i] = token.replace(/^\[|\]$/g, ""); + } + } + return function (input) { + const time = {}; + for (let i = 0, start = 0; i < length; i += 1) { + const token = array[i]; + if (typeof token === "string") { + start += token.length; + } else { + // フォーマットにマッチしたとき、マッチした部分を前から処理する + const { regex, parser } = token; + const part = input.slice(start); + const match = regex.exec(part); + const value = match[0]; + + parser.call(time, value); + + // ここで置換してマッチした部分を消す + input = input.replace(value, ""); + } + } + correctHours(time); + return time; + }; +} + +const parseFormattedInput = (input, format, utc) => { + try { + if (["x", "X"].indexOf(format) > -1) + return new Date((format === "X" ? 1000 : 1) * input); + const parser = makeParser(format); + const { year, month, day, hours, minutes, seconds, milliseconds, zone } = + parser(input); + const now = new Date(); + const y = year || now.getFullYear(); + // Date オブジェクトに渡す month は 0 から始まるため、ここで -1 する + const M = month - 1; + const h = hours || 0; + const m = minutes || 0; + const s = seconds || 0; + const ms = milliseconds || 0; + if (zone) { + return new Date( + Date.UTC(y, M, day, h, m, s, ms + zone.offset * 60 * 1000), + ); + } + if (utc) { + return new Date(Date.UTC(y, M, day, h, m, s, ms)); + } + return new Date(y, M, day, h, m, s, ms); + } catch (e) { + return new Date(""); // Invalid Date + } +}; + +export default (o, C, d) => { + d.p.customParseFormat = true; + if (o && o.parseTwoDigitYear) { + ({ parseTwoDigitYear } = o); + } + const proto = C.prototype; + const oldParse = proto.parse; + proto.parse = function (cfg) { + const { date, utc, args } = cfg; + this.$u = utc; + + const format = args[1]; // format prop の中身がくる + + if (typeof format === "string") { + const isStrictWithoutLocale = args[2] === true; + const isStrictWithLocale = args[3] === true; + const isStrict = isStrictWithoutLocale || isStrictWithLocale; + let pl = args[2]; + if (isStrictWithLocale) [, , pl] = args; + locale = this.$locale(); + if (!isStrictWithoutLocale && pl) { + locale = d.Ls[pl]; + } + this.$d = parseFormattedInput(date, format, utc); + this.init(); + if (pl && pl !== true) this.$L = this.locale(pl).$L; + // use != to treat + // input number 1410715640579 and format string '1410715640579' equal + // eslint-disable-next-line eqeqeq + if (isStrict && date != this.format(format)) { + this.$d = new Date(""); + } + // reset global locale to make parallel unit test + locale = {}; + } else if (format instanceof Array) { + const len = format.length; + for (let i = 1; i <= len; i += 1) { + args[1] = format[i - 1]; + const result = d.apply(this, args); + if (result.isValid()) { + this.$d = result.$d; + this.$L = result.$L; + this.init(); + break; + } + if (i === len) this.$d = new Date(""); + } + } else { + oldParse.call(this, cfg); + } + }; +}; diff --git a/src/components/DateField/styled.ts b/src/components/DateField/styled.ts new file mode 100644 index 000000000..fa0236fc7 --- /dev/null +++ b/src/components/DateField/styled.ts @@ -0,0 +1,43 @@ +import styled from "styled-components"; + +export const InputContainer = styled.div` + display: flex; + align-items: center; + width: 150px; + /* MEMO: for calendar icon */ + padding: 0 8px 0 0; + border: 0; + font-size: 14px; + border: 1px solid ${({ theme }) => theme.palette.divider}; + border-radius: ${({ theme }) => theme.radius}px; + border-color: ${({ theme }) => theme.palette.divider}; + overflow: scroll; + /* MEMO: To take a place that display LastPass icon. */ + background-position: calc(100% - 35px) 50% !important; + &:focus { + outline: none; + border-color: ${({ theme }) => theme.palette.primary.main}; + } + &::placeholder { + color: ${({ theme }) => theme.palette.text.hint}; + } + /* Edge */ + &::-ms-input-placeholder { + color: ${({ theme }) => theme.palette.text.hint}; + } + &:disabled { + color: ${({ theme }) => theme.palette.text.disabled}; + border-color: ${({ theme }) => theme.palette.divider}; + box-shadow: "none"; + background-color: ${({ theme }) => theme.palette.gray.light}; + cursor: not-allowed; + } +`; + +export const CalendarIcon = styled.button` + border: none; + text-align: right; + background: none; + outline: none; + cursor: pointer; +`; diff --git a/src/components/DateField/types.ts b/src/components/DateField/types.ts new file mode 100644 index 000000000..ece4c6bba --- /dev/null +++ b/src/components/DateField/types.ts @@ -0,0 +1,32 @@ +import { Dayjs } from "dayjs"; + +export type DateFieldProps = { + /** + * 日付 + * @default dayjs() + */ + date: Dayjs; + /** + * 指定したい format + * @default YYYY-MM-DD + */ + format?: string; + /** + * 日付が変更されたときに呼ばれる関数 + */ + onDateChange?: (date: Dayjs) => void; + /** + * カレンダーアイコンをクリックした時に呼ばれる関数 + */ + onClick: () => void; +}; + +export type UseDateFieldProps = Omit; + +export type Sections = { + start: number; + end: number; + value: string; + editable: boolean; + type?: "year" | "month" | "day"; +}; diff --git a/src/components/DateField/useDateField.ts b/src/components/DateField/useDateField.ts new file mode 100644 index 000000000..71d5311de --- /dev/null +++ b/src/components/DateField/useDateField.ts @@ -0,0 +1,254 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import dayjs, { Dayjs } from "dayjs"; +import { getSections, formatString } from "./utils"; +import { AllowedKeys, allowedKeys, numberKeys } from "../DateField/constants"; + +// CustomParseFormat の clone +import customParseFormat from "./plugin"; + +type Props = { + date: Dayjs | null; + format?: string; + onDateChange?: (date: Dayjs) => void; +}; + +/** + * 指定された format で日付を表示・操作するための hooks + * 左右キーでセクション移動、上下キーで選択中のセクションの値を増減する + * 直接キーボード入力することもできる + * + */ +export const useDateField = ({ + date, + format = "YYYY-MM-DD", + onDateChange, +}: Props) => { + dayjs.extend(customParseFormat); + + const ref = useRef(null); + const [value, setValue] = useState(date?.format(format)); + const [sections, setSections] = useState(getSections(value)); + const [placement, setPlacement] = useState({ + start: 0, + end: sections.length - 1, + current: 0, + }); + + const [keyDownCount, setKeyDownCount] = useState(0); + + const setCurrent = useCallback(() => { + setTimeout(() => { + const selectionStart = ref.current?.selectionStart ?? 0; + const lastSectionEnd = sections[sections.length - 1].end + 1; + const currentFocusIndex = + selectionStart >= lastSectionEnd ? lastSectionEnd : selectionStart; + + const currentSectionIndex = sections.findIndex( + (section) => + currentFocusIndex >= section.start && + currentFocusIndex <= section.end + 1, + ); + + setPlacement((prev) => { + if (prev.current === currentSectionIndex) { + return prev; + } + + return { + ...prev, + current: currentSectionIndex, + }; + }); + + ref.current?.setSelectionRange( + sections[currentSectionIndex].start, + sections[currentSectionIndex].end + 1, + ); + }); + }, [sections]); + + const onFocus = useCallback(() => { + setCurrent(); + }, [setCurrent]); + + const onBlur = useCallback(() => { + setPlacement((prev) => ({ + ...prev, + current: 0, + })); + setKeyDownCount(0); + }, []); + + const onKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (!allowedKeys.includes(event.key as AllowedKeys)) { + return; + } + + // 左右キーでセクションを移動する + // TODO: Tab and Tab + Command + // ブラウザやOSでの差分吸収がめんどくさいので一旦保留 + if ( + event.key === AllowedKeys.ArrowLeft || + event.key === AllowedKeys.ArrowRight + ) { + event.preventDefault(); + + const getIndex = (key: AllowedKeys) => { + const currentIndex = placement.current; + if (key === AllowedKeys.ArrowLeft) { + for (let i = currentIndex - 1; i >= 0; i--) { + if (sections[i].editable) { + return i - currentIndex; + } + } + } else if (key === AllowedKeys.ArrowRight) { + for (let i = currentIndex + 1; i < sections.length; i++) { + if (sections[i].editable) { + return i - currentIndex; + } + } + } + + return 0; + }; + + const key = event.key; + + setPlacement((prev) => ({ + ...prev, + current: prev.current + getIndex(key), + })); + } + + // 上下キーでインクリメント・デクリメントする + if ( + event.key === AllowedKeys.ArrowUp || + event.key === AllowedKeys.ArrowDown + ) { + event.preventDefault(); + const i = event.key === AllowedKeys.ArrowUp ? 1 : -1; + const newValueNumber = Number(sections[placement.current].value) + i; + + const newValue = String(newValueNumber).padStart( + sections[placement.current].value.length, + "0", + ); + + const newSections = [...sections]; + newSections[placement.current].value = newValue; + + setSections(newSections); + + const v = formatString(newSections); + const newDate = dayjs(v, format); + + setValue(newDate.format(format)); + + if (newDate.isValid()) { + onDateChange && onDateChange(newDate); + } + } + + // 数字を直接入力した時の挙動 + if (numberKeys.includes(event.key)) { + event.preventDefault(); + + if (keyDownCount === 0) { + sections[placement.current].value = "".padStart( + sections[placement.current].value.length, + "0", + ); + } + + const newValue = `${sections[placement.current].value.slice(1)}${ + event.key + }`; + + const newSections = [...sections]; + newSections[placement.current].value = newValue; + + setSections(newSections); + + setKeyDownCount(keyDownCount + 1); + + // そのセクションで入力が完了したら keydown をリセットする + if (keyDownCount + 1 === sections[placement.current].value.length) { + setKeyDownCount(0); + + // 入力が完了したら次のセクションに移動する + // MEMO: 一旦保留 + // if (placement.current + 1 < sections.length) { + // setPlacement((prev) => ({ + // ...prev, + // current: prev.current + 1, + // })); + // } + + const v = formatString(sections); + + const newDate = dayjs(v, format); + + setValue(newDate.format(format)); + onDateChange && onDateChange(newDate); + } else { + const v = formatString(sections); + + if (!dayjs(v, format, true).isValid()) { + setValue(v); + return; + } + + const newDate = dayjs(v, format); + + setValue(newDate.format(format)); + onDateChange && onDateChange(newDate); + } + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [sections, format, onDateChange, placement], + ); + + const onMouseDown = useCallback(() => { + setCurrent(); + }, [setCurrent]); + + // 日付をコピペした時の挙動 + // どのセクションにフォーカスしていてもペーストした日付がすべてのセクションに適用される + const onPaste = useCallback( + (event: React.ClipboardEvent) => { + event.preventDefault(); + + const pastedText = event.clipboardData.getData("text"); + const newDate = dayjs(pastedText); + + onDateChange && onDateChange(newDate); + }, + [onDateChange], + ); + + // input をクリックしたときにカーソルを正しい位置に移動する + useEffect(() => { + ref.current?.setSelectionRange( + sections[placement.current].start, + sections[placement.current].end + 1, + ); + }, [placement, sections]); + + // 日付が変更されたら input と sections を更新する + useEffect(() => { + setValue(date?.format(format)); + setSections(getSections(date?.format(format))); + }, [date, format]); + + return { + ref, + value, + onFocus, + onBlur, + onKeyDown, + onMouseDown, + onPaste, + }; +}; diff --git a/src/components/DateField/utils.ts b/src/components/DateField/utils.ts new file mode 100644 index 000000000..be0f892a2 --- /dev/null +++ b/src/components/DateField/utils.ts @@ -0,0 +1,87 @@ +import { useMemo } from "react"; + +type Sections = { + start: number; + end: number; + value: string; + editable: boolean; +}; + +/** + * 何らかのフォーマットで入ってくる日付を開始位置と終了位置と値を持つセクションに分割する + * useDateField で format された日付操作を汎用的に行うために必要なプロパティを返す + * 例) 2023-01-02 -> [ + * { start: 0, end: 3, value: "2023", editable: true }, + * { start: 4, end: 4, value: "-", editable: false }, + * { start: 5, end: 6, value: "01", editable: true }, + * { start: 7, end: 7, value: "-", editable: false }, + * { start: 8, end: 9, value: "02", editable: true } + * ] + * + * @param formattedDate 何らかのフォーマットで入ってくる日付 + * @returns 開始位置と終了位置と値を持つセクション + */ +export const getSections = (formattedDate?: string | null) => { + if (!formattedDate) { + return []; + } + + const sections: Sections[] = []; + let start = 0; + let isPrevCharDigit = !isNaN(Number(formattedDate[0])); + + for (let index = 1; index <= formattedDate.length; index++) { + const currentChar = formattedDate[index]; + const isCurrentCharDigit = + !isNaN(Number(currentChar)) && currentChar !== " "; + + if ( + isCurrentCharDigit !== isPrevCharDigit || + currentChar === " " || + index === formattedDate.length + ) { + sections.push({ + start, + end: index - 1, + value: formattedDate.slice(start, index), + editable: isPrevCharDigit, + }); + start = index; + isPrevCharDigit = isCurrentCharDigit; + } + } + + return sections; +}; + +/** + * 開始位置と終了位置と値を持つセクションをフォーマットされた日付に変換する + */ +export const formatString = (sectionsWithCharactor: Sections[]) => + sectionsWithCharactor.map((section) => section.value).join(""); + +type ReactRef = + | React.RefCallback + | React.MutableRefObject + | React.ForwardedRef + | string + | null + | undefined; + +// from: https://github.com/voyagegroup/ingred-ui/blob/master/src/hooks/useMergeRefs.ts +export function useMergeRefs(...refs: ReactRef[]): React.Ref { + return useMemo(() => { + if (refs.every((ref) => ref === null)) { + return null; + } + return (refValue: T) => { + for (const ref of refs) { + if (typeof ref === "function") { + ref(refValue); + } else if (ref && typeof ref !== "string") { + ref.current = refValue; + } + } + }; + }, [refs]); +} From e70a0ee876e1f1c5e08cd4de5e6a6959748b8044 Mon Sep 17 00:00:00 2001 From: takurinton Date: Mon, 14 Aug 2023 14:28:00 +0900 Subject: [PATCH 2/3] fix: export --- src/components/DateField/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/DateField/index.ts b/src/components/DateField/index.ts index f7434805b..a6fc15800 100644 --- a/src/components/DateField/index.ts +++ b/src/components/DateField/index.ts @@ -1 +1 @@ -export { DateField } from "./DateField"; +export { default } from "./DateField"; From d1511c46d3805fac7f5e4228cd55f8f94bceb1ac Mon Sep 17 00:00:00 2001 From: takurinton Date: Mon, 14 Aug 2023 14:28:19 +0900 Subject: [PATCH 3/3] export --- src/components/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/index.ts b/src/components/index.ts index 8098b3128..4d3b524a3 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -41,6 +41,9 @@ export * from "./CreatableSelect"; export { default as DataTable } from "./DataTable"; export * from "./DataTable"; +export { default as DateField } from "./DateField"; +export * from "./DateField"; + export { default as DatePicker } from "./DatePicker"; export * from "./DatePicker";