😄

shadcn/uiでGithubの草を作る

2024/04/07に公開

shadcn/uiでGithubの草を作る

先に
今回の成果物
https://github.com/mitate-gengaku/heatmap-calender

実際の画面
https://heatmap-calender.vercel.app/

Github Contributions Graph(草)いわゆるヒートマップといわれるUIをshadcn/uiを使って実装します。

Github Contributions Graphのようなheatmapを作成できるライブラリを探す

↑のスクラップを見る限り(3年前のですが)、あまりいいライブラリがないように思えます。なのでshadcn/uiに含まれるUIのみで実装しました。

前提条件

react、shadcn/uiのインストールはすでに終えていることとします

デフォルトではこんな感じ

import { Calendar } from "@/components/ui/calendar"

const Component = () => {
  return (
    <Calender />
  );
}
export default Component;

default.png

日本語に対応させる

date-fnsの日本語ローカライズデータをインポートしてlocaleとして渡す

ただしデータを渡すだけでは英語圏表記の”4月 2024”のように表示されるため、formatters propsからフォーマットデータを渡す

import { Calendar } from "@/components/ui/calendar";
import { ja } from "date-fns/locale";

const Component = () => {
  return (
    <Calender
      locale={ja}
      formatters={{
      formatCaption: (date) => {
        const date = new Date(date);
        return `${date.getFullYear()}${date.getMonth() + 1}`;
      }
    }}
    />
  );
}
export default Component;

レンダリング結果

ja.png

矢印ボタンの非表示

https://react-day-picker.js.org/api/types/StyledElement
の表を見る限り、navが両方のナビゲーションボタンが表示されている部分なのでこれにhiddenクラスを渡す

import { Calendar } from "@/components/ui/calendar";
import { ja } from "date-fns/locale";

const Component = () => {
  return (
    <Calender 
      locale={ja}
      classNames={{
        nav: "hidden"
      }}
      formatters={{
        formatCaption: (date) => {
        const date = new Date(date);

        return `${date.getFullYear()}${date.getMonth() + 1}`;
        }
      }}
    />
  );
}
export default Component;

hidden-nav.png

曜日表記を消す

https://react-day-picker.js.org/api/functions/Head
これが曜日を表示しているので、Headに空コンポーネントを渡します

import { Calendar } from "@/components/ui/calendar";
import { ja } from "date-fns/locale";

const Component = () => {
  return (
    <Calender 
      locale={ja}
      classNames={{
        nav: "hidden"
      }}
      components={{
        Head: () => <></>,
      }}
      formatters={{
      	formatCaption: (date) => {
          const date = new Date(date);

          return `${date.getFullYear()}${date.getMonth() + 1}`;
      	}
      }}
    />
  );
}
export default Component;

hidden-week.png

日の向きを変える

GithubのContribute GraphやZennの執筆文字数のヒートマップグラフを見る限り、月の進み方は左から右に、日は上から下向きに進んでいます

なのでカレンダーの並びを変更します。

import { Calendar } from "@/components/ui/calendar";
import { ja } from "date-fns/locale";

const Component = () => {
  return (
    <Calender 
      locale={ja}
      classNames={{
        nav: "hidden",
        month: "!ml-0 space-y-4",
        tbody: "flex",
        row: "[user-select:none;] flex flex-col",
      }}
      components={{
        Head: () => <></>,
      }}
      formatters={{
        formatCaption: (date) => {
          const date = new Date(date);

          return `${date.getFullYear()}${date.getMonth() + 1}`;
        }
      }}
    />
  );
}
export default Component;

fix-row.png

このままでは数字が見えてしまい、ヒートマップにはなりません。なので消します。(numberOfMonthsは表示する月数を制御します。)

import { Calendar } from "@/components/ui/calendar";
import { ja } from "date-fns/locale";

const Component = () => {
  return (
    <Calender 
      numberOfMonths={2}
      locale={ja}
      classNames={{
        nav: "hidden",
        month: "!ml-0 space-y-4",
        tbody: "flex",
        row: "[user-select:none;] flex flex-col",
        cell: cn(
          "relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-gray-200 [&:has([aria-selected].day-outside)]:bg-gray-200 rounded-sm"
        ),
        day: cn(
          buttonVariants({ variant: "ghost" }),
          "h-8 w-8 p-0 font-normal text-transparent cursor-pointer aria-selected:opacity-100 hover:text-transparent hover:bg-gray-200 bg-accent rounded-sm"
        ),
      }}
      components={{
        Head: () => <></>,
      }}
      formatters={{
        formatCaption: (date) => {
        const date = new Date(date);

        return `${date.getFullYear()}${date.getMonth() + 1}`;
        }
      }}
    />
  );
}
export default Component;

結果

若干色が薄いところは次の月もしくは前の月の日があることを示しています。

fixed-day.png

Rowコンポーネントを拡張

その前に色が薄くなっているところを直します。

classNames={{
  day_outside: "",
}}

これを追加すると色が他の日と同じようになります。

Rowコンポーネントは1週間ごとの行を表示しています。

詳しくは以下を見てください

https://github.com/gpbl/react-day-picker/blob/01b8e91/src/components/Row/Row.tsx

これを参考にしつつコンポーネントを作成します。

import { getUnixTime } from 'date-fns';

import { Day } from 'components/Day';
import { WeekNumber } from 'components/WeekNumber';
import { useDayPicker } from 'contexts/DayPicker';

export interface CustomRowProps {
  displayMonth: Date;
  weekNumber: number;
  dates: Date[];
}

export function CustomRow(props: CustomRowProps): JSX.Element {
  const { styles, classNames, showWeekNumber, components } = useDayPicker();

  const DayComponent = components?.Day ?? Day;
  const WeeknumberComponent = components?.WeekNumber ?? WeekNumber;

  let weekNumberCell;
  if (showWeekNumber) {
    weekNumberCell = (
      <td className={classNames.cell} style={styles.cell}>
        <WeeknumberComponent number={props.weekNumber} dates={props.dates} />
      </td>
    );
  }
  
  const thisMonth = new Date(props.displayMonth).getMonth();
  const monthOfData = new Date(props.dates[6]).getMonth();

  return (
    <tr 
      className={cn(
        classNames.row,
        thisMonth !== monthOfData && "last:-mr-[2rem]",
      )} 
      style={styles.row}
      >
      {weekNumberCell}
      {props.dates.map((date) => (
        <td
          className={classNames.cell}
          style={styles.cell}
          key={getUnixTime(date)}
          role="presentation"
        >
          <DayComponent displayMonth={props.displayMonth} date={date} />
        </td>
      ))}
    </tr>
  );
}

呼び出す側

import { Calendar } from "@/components/ui/calendar";
import { ja } from "date-fns/locale";
import { CustomRow } もしくは CustomRow from "ディレクトリから"

const Component = () => {
  return (
    <Calender 
      numberOfMonths={2}
      locale={ja}
      classNames={{
      nav: "hidden",
      month: "!ml-0 space-y-4",
      tbody: "flex",
      row: "[user-select:none;] flex flex-col",
      cell: cn(
        "relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-gray-200 [&:has([aria-selected].day-outside)]:bg-gray-200 rounded-sm"
      ),
      day: cn(
        buttonVariants({ variant: "ghost" }),
          "h-8 w-8 p-0 font-normal text-transparent cursor-pointer aria-selected:opacity-100 hover:text-transparent hover:bg-gray-200 bg-accent rounded-sm"
        ),
      }}
      components={{
        Head: () => <></>,
        Row: (props) => <CustomRow {...props} />
      }}
      formatters={{
        formatCaption: (date) => {
          const date = new Date(date);

          return `${date.getFullYear()}${date.getMonth() + 1}`;
        }
      }}
    />
  );
}
export default Component;

結果

わかりやすいように日付を表示しています。仕組みは単純で、

const thisMonth = new Date(props.displayMonth).getMonth();
const monthOfData = new Date(props.dates[6]).getMonth();

1行目がキャプションとして表示される月を取得しています(2024年 4月の部分)。2行目が最後の週の最後の日の月を取得しています。カレンダーでは週の最後の日が次の月にまたがって書かれている場合があります。その場合、1行目と2行目のコードでは結果が違うのでそのときだけ隙間を埋めるようマイナス値のmarginで重ねて表示することで連続した日付を表現しています。

hidden-outside.png

ツールチップを表示

GithubやZennではカーソルを合わせたとき、日付とコンテンツが表示されます。それをshadcnのtool tipを使って再現します。

先ほどのCustomRowコンポーネントを以下のように修正します。

contentsには表示したいデータを渡します。カーソルでホバーした日とデータのdateプロパティが一致した場合、最初の要素をレンダリングします。DisplayContentはcontentがある場合に表示する簡単なコンポーネントです。

import { getUnixTime } from 'date-fns';

import { Day } from 'components/Day';
import { WeekNumber } from 'components/WeekNumber';
import { useDayPicker } from 'contexts/DayPicker';
import {
  Tooltip,
  TooptipContent,
  TooltipProvider,
  TooltipTrigger
} from "tooltipのディレクトリから"
import { 
  cn
} from "cn"

export interface CustomRowProps {
  displayMonth: Date;
  weekNumber: number;
  dates: Date[];
  contents?: Array<{
    content: string;
    date: Date;
  }>
}

const DisplayContent = ({ date, contents }: { 
  date: Date, 
  contents?: Array<{
    content: string;
    date: Date;
  }>) => {
  if(!content || !contents.length) return <></>;

  const content = contents.filter((content) => {
  if(new Date(content.date).toLocalDateString("ja-JP" === "new Date(date).toLocalDateString("ja-JP")) {
    return content;
  }[0];

  if(!content) return <></>;
    return <span className="font-bold text-gray-800">{content.content}</span>
  }

export function CustomRow(props: CustomRowProps): JSX.Element {
  const { styles, classNames, showWeekNumber, components } = useDayPicker();

  const DayComponent = components?.Day ?? Day;
  const WeeknumberComponent = components?.WeekNumber ?? WeekNumber;

  let weekNumberCell;
  if (showWeekNumber) {
    weekNumberCell = (
      <td className={classNames.cell} style={styles.cell}>
        <WeeknumberComponent number={props.weekNumber} dates={props.dates} />
      </td>
    );
  }

  const thisMonth = new Date(props.displayMonth).getMonth();
  const monthOfData = new Date(props.dates[6]).getMonth();

  return (
    <TooltipProvider>
      <tr 
        className={cn(
          classNames.row,
          thisMonth !== monthOfData && "last:-mr-[2rem]",
        )} 
        style={styles.row}
      >
      {weekNumberCell}
      {props.dates.map((date) => (
      <Tooltip key={getUnixTime(date)}>
        {date && (
          <td
            className={classNames.cell}
            style={styles.cell}
            role="presentation"
          >
            <TooltipTrigger>
              <DayComponent displayMonth={props.displayMonth} date={date} />
            </TooltipTrigger>
            <TooltipContent
              side="top"
              align="start"
              className="flex flex-col gap-1 rounded-lg border p-2 shadow-sm"
              >
                <span
                  className="text-sm uppercase text-muted-foreground"
                  >
                  {new Date(date).toLocalDateString("ja-JP")}
                </span>
              <DisplayContent date={date} contents={props.contents} />
            </TooltipContent>
          </td>
        )}
        </Tooltip>
      ))}
      </tr>
    </TooltipProvider>
  );
}

呼び出す側

import { Calendar } from "@/components/ui/calendar";
import { ja } from "date-fns/locale";
import { CustomRow } もしくは CustomRow from "ディレクトリから"

const data = [
  {
    content: "Hello World",
    date: new Date(2024, 3, 7)
  }
]

const Component = () => {
  return (
    <Calender 
      numberOfMonths={2}
      locale={ja}
      classNames={{
        nav: "hidden",
        month: "!ml-0 space-y-4",
        tbody: "flex",
        row: "[user-select:none;] flex flex-col",
        cell: cn(
          "relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-gray-200 [&:has([aria-selected].day-outside)]:bg-gray-200 rounded-sm"
        ),
        day: cn(
          buttonVariants({ variant: "ghost" }),
          "h-8 w-8 p-0 font-normal text-transparent cursor-pointer aria-selected:opacity-100 hover:text-transparent hover:bg-gray-200 bg-accent rounded-sm"
        ),
      }}
      components={{
        Head: () => <></>,
        Row: (props) => <CustomRow {...props} />
      }}
      formatters={{
        formatCaption: (date) => {
          const date = new Date(date);

          return `${date.getFullYear()}${date.getMonth() + 1}`;
        }
      }}
    />
  );
}
export default Component;

結果

hover.png

コンテンツがない場合のツールチップ

other-tooltip.png

結論

実装することは不可能ではないが、専用のライブラリを探して使う方が楽かなと思いました。

ヒートマップのサイズを変更したい場合はclassNamesのday, cellとCustomRowのmarginRightをいじってください。

おすすめのヒートマップライブラリがある場合、もしくはこうした方がスマートに実装できるよなどなどコメントで教えていただけるとありがたいです。

参照サイト

https://zenn.dev/imaginelab/scraps/72da52aa60fe36

https://react-day-picker.js.org/

https://zenn.dev/yutakobayashi/articles/shadcn-calender-ja

GitHubで編集を提案

Discussion