Skip to content

Commit

Permalink
feat: calendar
Browse files Browse the repository at this point in the history
  • Loading branch information
takurinton committed Aug 14, 2023
1 parent f1dae04 commit 25eae8a
Show file tree
Hide file tree
Showing 14 changed files with 513 additions and 0 deletions.
38 changes: 38 additions & 0 deletions src/components/Calendar/Calendar.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import React from "react";
import { StoryObj } from "@storybook/react";

import Calendar, { CalendarProps } from "./Calendar";
import dayjs from "dayjs";

export default {
title: "Components/Inputs/Calendar",
components: Calendar,
};

export const Default: StoryObj<CalendarProps> = {
render: () => {
const [date, setDate] = React.useState(dayjs());
return <Calendar date={date} onChange={setDate} />;
},
};

export const WithActions: StoryObj<CalendarProps> = {
render: () => {
const [date, setDate] = React.useState(dayjs());
const actions = [
{
text: "今日",
onClick: () => setDate(dayjs()),
},
{
text: "来週",
onClick: () => setDate(dayjs().add(1, "week")),
},
{
text: "来月",
onClick: () => setDate(dayjs().add(1, "month")),
},
];
return <Calendar date={date} actions={actions} onChange={setDate} />;
},
};
96 changes: 96 additions & 0 deletions src/components/Calendar/Calendar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import dayjs, { Dayjs } from "dayjs";
import { Card, ScrollArea, Typography } from "..";
import React, { forwardRef, memo, useRef } from "react";
import { Day } from "./internal/Day";
import { weekList, HEIGHT } from "./constants";
import {
Container,
CalendarContainer,
DatePickerContainer,
DayStyle,
CalendarMonth,
} from "./styled";
import { useScroll } from "./hooks/useScroll";
import { Action, Actions } from "./internal/Actions";

export type CalendarProps = {
date: Dayjs;
actions?: Action[];
onChange: (value: Dayjs) => void;
};

const Calendar = forwardRef<HTMLDivElement, CalendarProps>(function Calendar({
date,
actions,
onChange,
}) {
const ref = useRef<HTMLDivElement>(null);
const { monthList } = useScroll(date, ref);

return (
<Card ref={ref} display="flex" style={{ width: "fit-content" }}>
<Actions actions={actions} />
<Container>
<ScrollArea
ref={ref}
minHeight={HEIGHT}
maxHeight={HEIGHT}
id="calendar"
>
<>
{monthList.map((m) => (
<DatePickerContainer
key={m.format("YYYY-MM")}
id={m.format("YYYY-MM")}
className={m.format("YYYY-MM")}
>
{/* 年月の表示 */}
<CalendarMonth>
<Typography weight="bold" size="xl">
{m.format("YYYY年MM月")}
</Typography>
</CalendarMonth>

{/* カレンダーの表示 */}
<CalendarContainer>
{/* 曜日の表示 */}
{weekList["ja"].map((week) => (
<DayStyle key={week}>{week}</DayStyle>
))}

{/* 開始曜日まで空白をセット */}
{Array.from(new Array(m.startOf("month").day()), (_, i) => (
<DayStyle key={i} />
))}

{/* 日付の表示 */}
{Array.from(new Array(m.daysInMonth()), (_, i) => i + 1).map(
(day) => (
<Day
key={day}
value={dayjs(new Date(m.year(), m.month(), day))}
// ややこしいけど、ここでのselectedは、選択中の日付かどうかを判定している
// つまり、選択中の日付の場合はtrueになり、style で色を変える
selected={
date.format("YYYY-MM-DD") ===
dayjs(new Date(m.year(), m.month(), day)).format(
"YYYY-MM-DD",
)
}
onClickDate={onChange}
>
{day}
</Day>
),
)}
</CalendarContainer>
</DatePickerContainer>
))}
</>
</ScrollArea>
</Container>
</Card>
);
});

export default memo(Calendar);
38 changes: 38 additions & 0 deletions src/components/Calendar/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// for `<ScrollArea />`
export const HEIGHT = "400px";

export const weekList = {
en: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
ja: ["日", "月", "火", "水", "木", "金", "土"],
};

export const monthList = {
en: [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"June",
"July",
"Aug",
"Sept",
"Oct",
"Nov",
"Dec",
],
ja: [
"1月",
"2月",
"3月",
"4月",
"5月",
"6月",
"7月",
"8月",
"9月",
"10月",
"11月",
"12月",
],
};
49 changes: 49 additions & 0 deletions src/components/Calendar/hooks/__tests__/useScroll.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { renderHook, act } from "@testing-library/react";
import dayjs, { Dayjs } from "dayjs";
import { useScroll, getNextMonthList, getPrevMonthList } from "../useScroll";

(global as any).IntersectionObserver = jest.fn().mockImplementation(() => ({
observe: () => jest.fn(),
unobserve: () => jest.fn(),
disconnect: () => jest.fn(),
}));

describe("useScroll hook", () => {
let date: Dayjs;
let ref: React.RefObject<HTMLDivElement>;

beforeEach(() => {
date = dayjs("2021-01-01");
ref = { current: document.createElement("div") };
});

test("loads next six months when reaching the bottom 10% of ScrollArea", () => {
const { result } = renderHook(() => useScroll(date, ref));

act(() => {
// const targets = document.getElementsByClassName(date.format("YYYY-MM"));
});

const months = [...getPrevMonthList(date), ...getNextMonthList(date)].map(
(d) => d.format("YYYY-MM"),
);
expect(result.current.monthList.map((m) => m.format("YYYY-MM"))).toEqual(
expect.arrayContaining(months),
);
});

test("loads previous six months when reaching the top 10% of ScrollArea", () => {
const { result } = renderHook(() => useScroll(date, ref));

act(() => {
// const targets = document.getElementsByClassName(date.format("YYYY-MM"));
});

const months = [...getPrevMonthList(date), ...getNextMonthList(date)].map(
(d) => d.format("YYYY-MM"),
);
expect(result.current.monthList.map((m) => m.format("YYYY-MM"))).toEqual(
expect.arrayContaining(months),
);
});
});
2 changes: 2 additions & 0 deletions src/components/Calendar/hooks/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// MEMO: 3ヶ月でもいいかも?
export const MONTH_SIZE = 4;
136 changes: 136 additions & 0 deletions src/components/Calendar/hooks/useScroll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { Dayjs } from "dayjs";
import { useEffect, useState } from "react";
import { MONTH_SIZE } from "./constants";

export const getNextMonthList = (date: Dayjs) =>
Array.from(new Array(MONTH_SIZE)).map((_, i) => date.add(i, "month"));

export const getPrevMonthList = (date: Dayjs) =>
Array.from(new Array(MONTH_SIZE)).map((_, i) =>
date.subtract(MONTH_SIZE - i, "month"),
);

/**
* @memo カレンダーを選択中の月にするときに、アニメーション等があるといいかもしれない
*
* @param date 選択中の日付
* @param ref カレンダーの親要素のref、IntersectionObserverのrootに使う
* @return monthList 表示する月のリスト
*/
export const useScroll = (
date: Dayjs,
ref: React.RefObject<HTMLDivElement>,
) => {
// 読み込み済みの日付を保持する
const [loaded, setLoaded] = useState<{
prev: Dayjs;
next: Dayjs;
}>({
prev: date.subtract(MONTH_SIZE, "month"),
next: date.add(MONTH_SIZE, "month"),
});
// 表示する月のリスト
// この hooks の戻り値
const [monthList, setMonthList] = useState<Dayjs[]>([
...getPrevMonthList(date),
...getNextMonthList(date),
]);

useEffect(() => {
// 全てのカレンダーに 2023-06 のような名前の className を振ってある
const targets = document.getElementsByClassName(date.format("YYYY-MM"));
for (const target of Array.from(targets)) {
target.scrollIntoView({ block: "center" });
}
}, [date]);

useEffect(() => {
setLoaded({
prev: date.subtract(MONTH_SIZE, "month"),
next: date.add(MONTH_SIZE, "month"),
});
setMonthList([...getPrevMonthList(date), ...getNextMonthList(date)]);
}, [date]);

// next を読み込む
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
// next、prev をそれぞれ MONTH_SIZE 分ずらす
const next = loaded.next.add(MONTH_SIZE, "month");
const prev = loaded.prev.add(MONTH_SIZE, "month");

// prev と next の月のリストを取得
const prevYearMonthList = getPrevMonthList(loaded.next);
const nextYearMonthList = getNextMonthList(loaded.next);

setLoaded({ next, prev });
setMonthList([...prevYearMonthList, ...nextYearMonthList]);
}
});
},
{
root: ref.current,
threshold: 0.1,
},
);

const targets = document.getElementsByClassName(
loaded.next.subtract(1, "month").format("YYYY-MM"),
);

for (const target of Array.from(targets)) {
observer.observe(target);
}

return () => {
for (const target of Array.from(targets)) {
observer.unobserve(target);
}
};
}, [loaded, ref]);

// prev を読み込む
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
// next、prev をそれぞれ MONTH_SIZE 分ずらす
const next = loaded.next.subtract(MONTH_SIZE, "month");
const prev = loaded.prev.subtract(MONTH_SIZE, "month");

// prev と next の月のリストを取得
const prevYearMonthList = getPrevMonthList(loaded.prev);
const nextYearMonthList = getNextMonthList(loaded.prev);

setLoaded({ next, prev });
setMonthList([...prevYearMonthList, ...nextYearMonthList]);
}
});
},
{
root: ref.current,
threshold: 0.1,
},
);

const targets = document.getElementsByClassName(
loaded.prev.add(1, "month").format("YYYY-MM"),
);

for (const target of Array.from(targets)) {
observer.observe(target);
}

return () => {
for (const target of Array.from(targets)) {
observer.unobserve(target);
}
};
}, [loaded, ref]);

return { monthList };
};
1 change: 1 addition & 0 deletions src/components/Calendar/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { Calendar } from "./Calendar";
Loading

0 comments on commit 25eae8a

Please sign in to comment.