neet/refined-itsukara-link

View on GitHub
packages/@neet/vschedule-client/src/components/app/Crown/Crown.tsx

Summary

Maintainability
D
1 day
Test Coverage
import {
  faChevronLeft,
  faChevronRight,
  faSpinner,
  faUndo,
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import classNames from 'classnames';
import dayjs from 'dayjs';
import { H } from 'react-headings';

import type { Genre } from '../../../types';
import { Button } from '../../ui/Button';
import { Radio } from '../../ui/Radio';
import { useTimetable } from '../../ui/Timetable';
import { Typography } from '../../ui/Typography';

export interface CrownProps {
  readonly genre: number;
  readonly genres?: readonly Genre[];
  readonly loading: boolean;
  readonly onGenreChange?: (genre: number) => void;
}

export const Crown = (props: CrownProps): JSX.Element => {
  const { genre, genres, loading, onGenreChange } = props;
  const { focusedAt, startAt, endAt, setFocusedAt } = useTimetable();

  // "today" or "yesterday" "tomorrow" here are defined as relative from the focus
  const zeroAmToday = focusedAt.millisecond(0).second(0).minute(0).hour(0);
  const zeroAmTomorrow = zeroAmToday.add(1, 'day');
  const zeroAmYesterday = zeroAmToday.subtract(1, 'day');

  const handleClickLatest = (): void => {
    gtag('event', 'click_crown_latest', {
      event_label: '最新の配信へ移動',
    });

    setFocusedAt(dayjs());
  };

  const handleClickRight = (): void => {
    gtag('event', 'click_crown_forward', {
      event_label: '一日前に移動',
    });

    // If focus is before the closest 0 AM
    if (focusedAt.isBefore(zeroAmToday)) {
      setFocusedAt(zeroAmToday);
      return;
    }

    setFocusedAt(dayjs.min(endAt, zeroAmTomorrow));
  };

  const handleClickLeft = (): void => {
    gtag('event', 'click_crown_forward', {
      event_label: '一日後に移動',
    });

    // If focus is after the closest 0 AM
    if (focusedAt.isAfter(zeroAmToday)) {
      setFocusedAt(zeroAmToday);
      return;
    }

    setFocusedAt(dayjs.max(startAt, zeroAmYesterday));
  };

  const handleGenreChange = (value: string): void => {
    gtag('event', 'click_change_genre', {
      event_label: value,
    });

    // DOMのフォームから取れる値をID where 数値に変換
    onGenreChange?.(Number(value));

    // 対応する座標がスクロール領域に存在する場合にしか動かないので、スクリーンリーダーでフォーカスされない
    // 本当はジャンルが変更された後の副作用として pages/index.tsx に持たせたいけど、
    // そうすると provider の関係上中間のコンポーネントが発生してしまう。そのコンポーネントの名前を考え中
    setFocusedAt(dayjs());
  };

  return (
    <header aria-labelledby="crown-title">
      <div
        className={classNames('flex', 'space-x-4', 'items-center', 'md:mb-1.5')}
      >
        <Typography
          id="crown-title"
          variant="wash"
          as={H}
          className={classNames('md:text-xl')}
        >
          今日のにじさんじの配信
        </Typography>

        {loading && (
          <Typography size="lg" leading="none" variant="wash">
            <FontAwesomeIcon icon={faSpinner} spin />
          </Typography>
        )}
      </div>

      <div
        className={classNames(
          'flex',
          'items-center',
          'justify-between',
          'md:space-x-4',
        )}
      >
        <div
          className={classNames(
            'grow',
            'shrink-0',
            'flex',
            'grow',
            'justify-between',
            'lg:justify-start',
            'lg:space-x-4',
            'items-center',
          )}
        >
          <time
            dateTime={focusedAt.toISOString()}
            className={classNames(
              'text-xl',
              'md:text-3xl',
              'text-primary-500',
              'dark:text-primary-400',
              'font-semibold',
              'tabular-nums',
              'leading-relaxed',
            )}
          >
            {focusedAt.format('LL')}
          </time>

          <div className="space-x-2 flex flex-nowrap">
            <Button
              title="一日前へ移動"
              aria-label="一日前へ移動"
              className="w-8 h-8"
              onClick={handleClickLeft}
              variant="wash"
              shape="circle"
              size="sm"
              disabled={focusedAt.isSame(startAt)}
            >
              <FontAwesomeIcon icon={faChevronLeft} />
            </Button>

            <Button
              title="最新の配信へ移動"
              aria-label="最新の配信へ移動"
              className="w-8 h-8"
              onClick={handleClickLatest}
              variant="wash"
              shape="circle"
              size="sm"
            >
              <FontAwesomeIcon icon={faUndo} />
            </Button>

            <Button
              title="一日後へ移動"
              aria-label="一日後へ移動"
              className="w-8 h-8"
              onClick={handleClickRight}
              variant="wash"
              shape="circle"
              size="sm"
              disabled={focusedAt.isSame(endAt)}
            >
              <FontAwesomeIcon icon={faChevronRight} />
            </Button>
          </div>
        </div>

        {genres != null && (
          // スマホはバーガーメニューから使えるのでクラウンに出す必要はないと思う
          <form
            className="grow shrink hidden xl:block"
            aria-label="タグでフィルター"
          >
            <Radio
              className="overflow-hidden justify-end"
              name="filter"
              value={genre.toString()}
              onChange={handleGenreChange}
            >
              <Radio.Item label="全ての配信" value="-1" />

              {genres.map((item, i) => (
                <Radio.Item
                  key={`${item.id}-${i}`}
                  label={item.name}
                  value={item.id.toString()}
                />
              ))}
            </Radio>
          </form>
        )}
      </div>
    </header>
  );
};