Bootstrap 流 Responsive Web Design feat. styled-components

## はじめに

CSS フレームワークで有名な [Bootstrap](https://getbootstrap.com/) にはレスポンシブ Web デザインを構築するための便利な Sass mixins（e.g. media-breakpoint-up, media-breakpoint-down etc.）があります。今回、[styled-components](https://styled-components.com/) を利用するにあたり Bootstrap と同等のメディアクエリを作成する関数を紹介します。また、関数の利便性を考慮して [TypeScript](https://www.typescriptlang.org/) を採用しています。

## TL;DR

styled-components でレスポンシブ Web デザインを構築する場合、下記コードのように記述できるようになります。

```ts:pages/index.tsx
import styled from 'styled-components'
import { breakpointUp, breakpointDown } from '../utils/breakpoints'

...

const Container = styled.div`
  padding: 40px;

  ${breakpointDown('md')} { // プレースホルダーは @media (max-width: 767.98px) に置換されます。
    padding-right: 6.25%;
    padding-left: 6.25%;
  }
`

const Card = styled.li`
  position: relative;
  display: flex;

  ...

  ${breakpointUp('md')} { // プレースホルダーは @media (min-width: 768px) に置換されます。
    &:hover {
      color: var(--base-link-color);
      box-shadow: 0 0 0 2px;
    }
  }
`
```

## Bootstrap のブレークポイントの仕様

まずは Bootstrap の Sass mixins について解説します。なお、これから解説する仕様は現在（2020/06/03 現在）策定中の Bootstrap v5 の考案になります。策定中の [GitHub issue](https://github.com/twbs/bootstrap/pull/29148) にある下図と併せて読み進めていただけると理解しやすいかと思います。また、図内の黒丸（●）と白丸（○）は数学の座標で使われる記号で、黒丸は含む、白丸は含まないを意味します。

![breakpoints.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88525/4cb4bf65-bd2c-9989-72c1-0beca7958745.png)

はじめに、理解しやすい `media-breakpoint-up` について解説します。こちらは指定された引数（ブレークポイント）以上の画面幅に適用されるメディアクエリを作成します。上図の場合は、`md（768px）` を引数に指定しているので `@media (min-width: 768px) { ... }` が作成されます。逆に `media-breakpoint-down` は指定された引数（ブレークポイント）未満の画面幅に適用されるメディアクエリを作成します。ここで作成されるメディアクエリには **ブレークポイントを含まれない** ことに注意してください。上図の場合は、`sm（576px）` を引数に指定されているので `@media (max-width: 575.8px) { ... }` が作成されます。ちなみに、0.01 px ではなく 0.02 px を減らしている理由は Safari の端数処理を考慮しているからです。詳しくは[こちら](https://bugs.webkit.org/show_bug.cgi?id=178261)を参照ください。

## 定数の宣言

まずは、各ブレークポイントを定数で管理します。型のメンバーに `readonly` 修飾子を指定する、Numeric Literal Types（数値リテラル）として適用するために **const アサーション** を利用します。`readonly` 修飾子を使えば、読み込み専用にできます。つまり、ある値に初期値を割り当てた後でその値を変更できないことを宣言できます。また、Numeric Literal Types により型推論から各ブレークポイントの値が把握しやすくなります。

```ts:constants/breakpoints.ts
export const breakpoints = {
  xs: 0,
  sm: 576,
  md: 768,
  lg: 992,
  xl: 1200,
} as const
```
ブレークポイントの定数は ES Modules で import / export します。また、ブレークポイント名の型をのちに作成する関数で利用したいので **Type alias（型エイリアス）**と **`typeof` キーワード** 、**`keyof` キーワード** を用いて宣言します。`typeof` キーワードと `keyof` キーワードの併用で各ブレークポイント名の Union Types（ユニオン型）を生成できます。

```ts:utils/breakpoints.ts
import { breakpoints } from '../constants/breakpoints'

type Breakpoints = keyof typeof breakpoints // 'xs' | 'sm' | 'md' | 'lg' | 'xl'
```

## ヘルパー関数の作成

本題の関数を作成する前にヘルパー関数を用意します。用意するヘルパー関数は breakpointNext, breakpointMin, breakpointMax の 3 つになります。では、順番に説明します。

### breakpointNext

breakpointNext は指定された引数（ブレークポイント名）の次のブレークポイント名を返す関数です。たとえば `'md'` を引数に指定する場合、戻り値が `'lg'` になります。`'xl'` のような次のブレークポイントがない場合、戻り値は `null` になります。

```ts:utils/breakpoints.ts
const breakpointNext = (name: Breakpoints) => {
  const breakpointNames  = Object.keys(breakpoints) as Breakpoints[]
  const nextIndex = breakpointNames.indexOf(name) + 1
  if (nextIndex < breakpointNames.length) {
    return breakpointNames[nextIndex]
  } else {
    return null
  }
}
```

TypeScript の解説をします。breakpointNext の引数は各ブレークポイント名を指定することを期待しているので、さきほど作成した Breakpoints で型を制約します。後述する breakpointMin, breakpointMax も同様です。あと、` Object.keys()` メソッドで breakpointNames 変数を宣言している行に着目してください。ここでは **型アサーション** を利用しています。

型アサーションには 2 つの構文があります。1 つは `<>` を使用した構文で、もう 1 つは `as` シグネチャを使用した構文になります。2 つの構文は同等ですが、前者は JSX の構文とぶつかる可能性があるので、後者の `as` シグネチャを使用することを推奨します。

` Object.keys()` メソッドで返す配列の型推論は `string[]` になります。こちらを型アサーションを利用してブレークポイント名が格納される配列の型に **ダウンキャスト**[^1] します。

### breakpointMin

breakpointMin はメディアクエリの min-width の値を返す関数です。たとえば引数に `'md'` を指定した場合、戻り値は `768` になります。引数に `'xs'` が指定された場合、戻り値は `null` になります。

```ts:utils/breakpoints.ts
const breakpointMin = (name: Breakpoints) => {
  const min = breakpoints[name]
  return min !== 0 ? min : null
}
```

### breakpointMax

breakpointMax はメディアクエリの max-width を返す関数です。たとえば引数に `'md'` を指定した場合、戻り値は `575.8` になります。引数に `'xs'` が指定された場合、戻り値は `null` になります。

```ts:utils/breakpoints.ts
const breakpointMax = (name: Breakpoints) => {
  const max = breakpoints[name]
  return max && max > 0 ? max - 0.02 : null
}
```

すべてのヘルパー関数を用意できましたので、本題の関数を作成しましょう。

## 本題

作成する関数は、breakpointUp, breakpointDown, breakpointBetween, breakpointOnly の 4 つになります。では、順番に説明します。

### breakpointUp

breakpointUp は最小幅を定義するメディアクエリを返す関数です。[Bootstrap のブレークポイントの仕様](#bootstrap-のブレークポイントの仕様)で先述した `media-breakpoint-up` と同等です。ヘルパー関数 breakpointMin が `null` を返した場合、メディアクエリを生成せず、アンパサンド（&）を返します。& は styled-components で生成される一意のクラス名に置換されます。後述する他の関数でも同様の処理を行います。

```ts:utils/breakpoints.ts
export const breakpointUp = (name: Breakpoints) => {
  const min = breakpointMin(name)
  if (min) {
    return `@media (min-width: ${min}px)`
  } else  {
    return '&'
  }
}
```

### breakpointDown

breakpointDown は最大幅を定義するメディアクエリを返す関数です。[Bootstrap のブレークポイントの仕様](#bootstrap-のブレークポイントの仕様)で先述した `media-breakpoint-down` と同等です。

```ts:utils/breakpoints.ts
export const breakpointDown = (name: Breakpoints) => {
  const max = breakpointMax(name)
  if (max) {
    return `@media (max-width: ${max}px)`
  } else {
    return '&'
  }
}

```

### breakpointBetween

breakpointBetween は 2 つのブレークポイントで形成される画面幅に適用するメディアクエリを返す関数です。2 つの引数が lower < upper の大小関係になるようにブレークポイント名を指定します。たとえば lower 引数に `'sm'`、upper 引数に `'lg'` を指定した場合、戻り値は `@media (min-width: 576px) and (max-width: 991.98px)`になります。

```ts:utils/breakpoints.ts
export const breakpointBetween = (lower: Breakpoints, upper: Breakpoints) => {
  const min = breakpointMin(lower)
  const max = breakpointMax(upper)

  if (min !== null && max !== null) {
    return `@media (min-width: ${min}px) and (max-width: ${max}px)`
  } else if (max === null) {
    return breakpointUp(lower)
  } else if (min === null) {
    return breakpointDown(upper)
  }
}
```

### breakpointOnly

breakpointOnly は指定したブレークポイントのみに適用されるメディアクエリを返す関数です。たとえば `'md'` を引数に指定した場合、戻り値は `@media (min-width: 768px) and (max-width: 991.98px)` になります。

```ts:utils/breakpoints.ts
export const breakpointOnly = (name: Breakpoints) => {
  const next = breakpointNext(name)

  if (next === null) return breakpointUp(name) ... ①

  const min = breakpointMin(name)
  const max = breakpointMax(next)

  if (min !== null && max !== null) {
    return `@media (min-width: ${min}px) and (max-width: ${max}px)`
  } else if (max == null) {
    return breakpointUp(name)
  } else if (min == null) {
    return breakpointDown(next)
  }
}

```

TypeScript の解説をします。① の `null` チェックにより、①以降の next 変数の型を絞り込んでいます。[^2]これにより breakpointMax, breakpointDown の引数には `null` がない型推論になり、コンパイルエラーになりません。

## おわりに

いかがでしたでしょうか。今回紹介した内容は styled-components でスタイリングする方法を対象にしていますが、Bootstrap のブレークポイントの仕様は styled-components 以外のスタイリングでも活用できます。また、TypeScript を利用することで設定されているブレークポイント、開発者の意図を把握しやすくなります。本記事が読者の Bootstrap のブレークポイントの仕様、および、TypeScript の理解の一助になれば幸いです。

[^1]: 抽象的な型から詳細な型を付与することを「**ダウンキャスト**」といいます。
[^2]: TypeScript はフローベースの型推論を行うため、プログラマがコードを読むときと同様の型の絞り込みを行ってくれます。