react-slickでカルーセルをコントロールするUIを改造してアクセシビリティ対応する方法

## はじめに

Qiita株式会社 アドベントカレンダー13日目は、デザインチームの@gillyが担当します！
よろしくお願いします！

https://qiita.com/advent-calendar/2021/qiita

最近アクセシビリティをちょっとずつ勉強しています。
今回はリニューアルしたアドベントカレンダーでも使用しているカルーセルで行った対応を記事にまとめたいと思います。

## react-slickとは

react-slickとはjQueryプラグインのSlickを、Reactでも使用できるようにしたものです。
スライダーやカルーセルを簡単に実装することができます。

- GitHub
    - https://github.com/akiran/react-slick
- yarn
    - https://yarnpkg.com/package/react-slick
- npm
    - https://www.npmjs.com/package/react-slick
- ドキュメント
    - https://react-slick.neostack.com/


## 今回作りたいもの

|Qiita Advent Calendarのトップページのスポンサーカレンダーのカルーセル|
|:---:|
|![スクリーンショット 2021-12-11 11.08.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215239/b53fed0c-3a0f-1ae7-796e-1c64ba45e191.png)|
|[該当ページはこちらから確認できます](https://qiita.com/advent-calendar/2021)|

- 主な要件
    - 複数枚のバナーを掲載したカルーセル
    - ユーザーが任意で左右に動かせる
        - タッチジェスチャで左右に動かせる
        - `Previous`, `Next` のボタンを配置する
    - ユーザーが任意でカルーセルを停止・再生できる

## react-slickの導入

私は[yarn](https://yarnpkg.com/package/react-slick)でインストールしました。
npmが良い方は[npmのドキュメント](https://www.npmjs.com/package/react-slick)をご参照ください。

### パッケージのインストール

```
yarn add react-slick
```

### cssのインストール

```
yarn add slick-carousel

// Import css files
import "slick-carousel/slick/slick.css";
import "slick-carousel/slick/slick-theme.css";
```

## 実装

最終的にどんなコードになったのかを、最初にお見せします。

## 前提

前提としてQiitaではこのようにしてあります。

* スタイルを当てるときは、CSS in JS で記述できるEmotionを採用しています。
* react-slickで依存関係にある `slick-carousel` のcssのimportは別で行っているため、ここに記載してありません。
    * `import "slick-carousel/slick/slick.css";`
    * `import "slick-carousel/slick/slick-theme.css";`
* アイコンは[Font Awesome 4](https://fontawesome.com/v4.7/icons/)を使用しています。
* React Hooksに寄せていっているので、書き方がreact-slickのドキュメントと異なっている箇所があります。

## コード

```carousel.jsx
import { css } from '@emotion/react'
import React, { useRef, useState } from 'react'
import Slider, { CustomArrowProps } from 'react-slick'

type Props = {
  carouselItems: {
    url: string
    image: string
    alt: string
  }[]
}

export const Carousel = (props: Props) => {
  const [isStopped, setIsStopped] = useState(false)
  const sliderRef = useRef<Slider>(null)

  const slickPlay = () => {
    sliderRef.current?.slickPlay()
    setIsStopped(false)
  }

  const slickPause = () => {
    sliderRef.current?.slickPause()
    setIsStopped(true)
  }

  const handleClickSliderPrev = () => {
    if (sliderRef?.current) {
      sliderRef.current.slickPrev()
    }
  }

  const handleClickSliderNext = () => {
    if (sliderRef?.current) {
      sliderRef.current.slickNext()
    }
  }

  const sliderSettings = {
    autoplay: true,
    autoplaySpeed: 3000,
    arrows: false,
    cssEase: 'linear',
    centerMode: true,
    dots: true,
    infinite: true,
    pauseOnFocus: true,
    pauseOnHover: true,
    responsive: [
      {
        breakpoint: 480,
        settings: {
          adaptiveHeight: true,
        },
      },
    ],
    speed: 500,
    variableWidth: true,
    appendDots: dots => (
      <div css={carouselControlerStyle}>
        <SlickArrowLeft onClick={handleClickSliderPrev} />
        <ul css={carouselDotsStyle}>{dots}</ul>
        {isStopped ? (
          <button onClick={slickPlay} css={CarouselOperationButton}>
            <span className="fa fa-fw fa-play" />
          </button>
        ) : (
          <button onClick={slickPause} css={CarouselOperationButton}>
            <span className="fa fa-fw fa-pause" />
          </button>
        )}
        <SlickArrowRight onClick={handleClickSliderNext} />
      </div>
    ),
  }

  return (
    <div>
      <Slider ref={sliderRef} {...sliderSettings}>
        {props.carouselItems.map((carouselItem, index) => (
          <div css={bunnerCalouselLinkStyle} key={`carousel-item-${index}`}>
            <a href={carouselItem.url}>
              <img
                src={carouselItem.image}
                alt={carouselItem.alt}
                width="320px"
                height="100px"
                css={{ margin: 'auto' }}
              />
            </a>
          </div>
        ))}
      </Slider>
    </div>
  )
}

const bunnerCalouselLinkStyle = css({
  padding: `0 8px`,
})

const SlickArrowLeft = ({ onClick }: CustomArrowProps): JSX.Element => {
  return (
    <button css={prevArrowStyle} onClick={onClick}>
      <span className="fa fa-fw fa-chevron-left"></span>
    </button>
  )
}

const SlickArrowRight = ({ onClick }: CustomArrowProps): JSX.Element => {
  return (
    <button css={nextArrowStyle} onClick={onClick}>
      <span className="fa fa-fw fa-chevron-right"></span>
    </button>
  )
}

const carouselControlerStyle = css({
  display: 'flex',
  justifyContent: 'center',
  alignItems: 'center',
  position: 'initial',
})

const prevArrowStyle = css({
  color: 'rgba(0, 0, 0, 0.87)',
  cursor: 'pointer',
  padding: 8,
})

const nextArrowStyle = css({
  color: 'rgba(0, 0, 0, 0.6)',
  cursor: 'pointer',
  padding: 8,
})

const carouselDotsStyle = css({
  display: 'flex',
  margin: '0 8px',
  li: {
    alignItems: 'center',
    display: 'flex',
    height: 8,
    justifyContent: 'center',
    margin: `0 8px`,
    width: 8,
    '@media (max-width: 769px)':{
      margin: `0 4px`,
    },
  },
  'li button': {
    alignItems: 'center',
    display: 'flex',
    height: 8,
    margin: 0,
    width: 8,
  },
  'li button:focus::before': {
    color: 'rgba(0, 0, 0, 0.87)',
  },
  'li button::before': {
    color: '#C2C2C3',
    height: 8,
    lineHeight: 1,
    opacity: 1.0,
    width: 8,
  },
  'li.slick-active button::before': {
    color: '#8A8B8B',
  },
  '@media (max-width: 479px)': {
    display: 'none',
  }
})

const CarouselOperationButton = css({
  background: 'none',
  border: 'none',
  color: 'rgba(0, 0, 0, 0.6)',
  cursor: 'pointer',
  padding: 8,
})

```

## 解説

:::note warn
今回の解説はアクセシビリティに対応した箇所を中心に行います。装飾で使ったreact-slickの設定は解説しませんのでご了承ください。
:::

### 【解説1】カルーセルをコントロールするUIの下準備

|この部分|
|---|
|![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215239/f30f9c60-098a-0a51-8e69-706ec90725de.png)|

`appendDots` を応用します。
Ref https://react-slick.neostack.com/docs/api/#appendDots


>Custom dots templates. Works same as customPaging

とあるように、カルーセルの枚数を表示するドットを拡張できるようにするオプションです。

`Previous`, `Next` のボタンとドットと再生・停止ボタンを一列に並べられるようにマークアップをしています。

```react
  const sliderSettings = {
    appendDots: dots => (
      <div css={carouselControlerStyle}>
        <SlickArrowLeft onClick={handleClickSliderPrev} /> //Previpousボタン
        <ul css={carouselDotsStyle}>{dots}</ul> //ドット
        {isStopped ? (
          <button onClick={slickPlay} css={CarouselOperationButton}>
            <span className="fa fa-fw fa-play" />
          </button>
        ) : (
          <button onClick={slickPause} css={CarouselOperationButton}>
            <span className="fa fa-fw fa-pause" />
          </button>
        )} //停止・再生のボタン
        <SlickArrowRight onClick={handleClickSliderNext} /> //Neaxtボタン
      </div>
    ),
}
```

### 【解説2】タップ操作で左右に動かせる

これは特に何もせず、react-slickではデフォルトで実装可能です。

https://react-slick.neostack.com/docs/api#swipe に書いてあるように、 `Default: true` になっています。
もし、タップ操作やドラックで操作出来ないようにしたい場合は `false` にすれば止まります。

### 【解説3】`Previous`, `Next` のボタンを配置する

https://react-slick.neostack.com/docs/example/previous-next-methods を応用します。

react-slickのデフォルトの `Previous`, `Next` のボタンは、カルーセルのすぐ横に表示されます。
今回はバナー下のdotsを挟むように配置したいため、デフォルトの`Previous`, `Next` のボタンを非表示にして任意のアイコンをボタンとして利用します。


#### 前後の状態を参照できるようにする

```react
  const sliderRef = useRef<Slider>(null)

  const handleClickSliderPrev = () => {
    if (sliderRef?.current) {
      sliderRef.current.slickPrev()
    }
  }

  const handleClickSliderNext = () => {
    if (sliderRef?.current) {
      sliderRef.current.slickNext()
    }
  }
```

#### react-slickのsettingsの該当オプション

```react
  const sliderSettings = {
    arrows: false,
    appendDots: dots => (
      <div css={carouselControlerStyle}>
        <SlickArrowLeft onClick={handleClickSliderPrev} />
        <SlickArrowRight onClick={handleClickSliderNext} />
      </div>
    ),
  }
```

#### ボタンを `<`, `>` アイコンとして配置する

```react
const SlickArrowLeft = ({ onClick }: CustomArrowProps): JSX.Element => {
  return (
    <button css={prevArrowStyle} onClick={onClick}>
      <span className="fa fa-fw fa-chevron-left"></span>
    </button>
  )
}

const SlickArrowRight = ({ onClick }: CustomArrowProps): JSX.Element => {
  return (
    <button css={nextArrowStyle} onClick={onClick}>
      <span className="fa fa-fw fa-chevron-right"></span>
    </button>
  )
}
```


### 【解説4】ユーザーが任意でカルーセルを停止・再生できる

[達成基準 2.2.2: 一時停止、停止、非表示を理解する](https://waic.jp/docs/WCAG21/Understanding/pause-stop-hide.html)に下記のように記載があります。

>動きのある、点滅している、又はスクロールしている情報が、(1) 自動的に開始し、(2) 5 秒よりも長く継続し、かつ、(3) その他のコンテンツと並行して提示される場合、利用者がそれらを一時停止、停止、又は非表示にすることのできるメカニズムがある。

今回はユーザーが任意に一時停止できるようにしました。
また、ユーザーがバナーにhoverしている間も停止するようにしています。

#### 停止の状態を取得できるようにする

```react
  const [isStopped, setIsStopped] = useState(false)

  const slickPlay = () => {
    sliderRef.current?.slickPlay()
    setIsStopped(false)
  }

  const slickPause = () => {
    sliderRef.current?.slickPause()
    setIsStopped(true)
  }
```

#### react-slickのsettingsの該当オプション

```react
  const sliderSettings = {
    autoplay: true,
    autoplaySpeed: 3000,
    pauseOnFocus: true,
    pauseOnHover: true,
    appendDots: dots => (
      <div css={carouselControlerStyle}>
        {isStopped ? (
          <button onClick={slickPlay} css={CarouselOperationButton}>
            <span className="fa fa-fw fa-play" />
          </button>
        ) : (
          <button onClick={slickPause} css={CarouselOperationButton}>
            <span className="fa fa-fw fa-pause" />
          </button>
        )}
      </div>
    ),
  }
```

## まとめ

要件としていた下記について解説いたしました。

* ユーザーが任意で左右に動かせる
   * タッチジェスチャで左右に動かせる
   * Previous, Next のボタンを配置する
* ユーザーが任意でカルーセルを停止・再生できる

Qiitaのデザイナー達は、少しずつアクセシビリティについて学びながらサイトに反映していきたいと考えています。
たくさんの人がQiitaをより快適にご利用いただけるよう、日々精進してまいります。

ご意見・ご要望がございましたら、ぜひFooterのご意見や、[Discussions · increments/qiita-discussions](https://github.com/increments/qiita-discussions/discussions)にてお聞かせください :pray: 

## おわりに

ここまで読んでいただきありがとうございました。

明日の [Qiita株式会社 Advent Calendar 2021]() は、@kyntkが担当します！ :santa: