Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat <Calendar /> component #1361

Merged
merged 4 commits into from
Aug 14, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ninety-timers-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ingred-ui": minor
---

feat `<Calendar />` component
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 { default } from "./Calendar";
Loading