実践的レイアウトプリミティブ

CSSにおける汎用化の先送り、ユーティリティファーストCSS、レイアウトプリミティブ」の続き。



同じようなレイアウトを実現するためのCSSを僕は実のところ何度も繰り返し書いていた。そのたびに新しいコンポーネントを作り、意図を表明するための名前を捻り出し、やってることはたいして変わらないのに別々になった実装を増やしていた。その総量に埋もれて全体が見えなくなっていった。

個別のコンポーネントを汎用的なように変換するのは難しい。本当にまったく同じレイアウトならそれほど難しくないが、多くの場合には微妙な差分がある。余白の大きさが違う、グリッドのカラム数が違う、コンテナの幅が違う。いかにしてそれらに規則性を見い出してうまくいく設計ができるかは、腕の見せどころとも言える一方で再現性がなく見通しのつかない仕事だと思っていた。

Every Layoutのレイアウトプリミティブはそのようなレイアウトの構成要素が最小単位まで分解され、パターン集として文書化されたもの。これら最小単位のパターンはパターンと対応するコンポーネントとして切り出せる粒度になっていて、また複数のパターン同士を組み合わせることによって最終的なレイアウトを実現する前提で設計されている。有名なところではBootstrapのグリッドシステムの設計が近い:

<div class="container">
  <div class="row">
    <div class="col-sm">
      One of three columns
    </div>
    <div class="col-sm">
      One of three columns
    </div>
    <div class="col-sm">
      One of three columns
    </div>
  </div>
</div>

出典:Grid system · Bootstrap v4.5

.rowは子要素をカラムとして扱うためのコンテナであり、.col-smはカラムである子要素につねに対応するので、カラムのパターンとしては.row.col-smはセットになるが、.containerはコンテナ幅を制御するためだけのクラスであるためカラムの実現には必要ではない。.container.rowは互いに依存関係を持たない独立した存在であり、それぞれは自らの責務のみを意識している。そしてテンプレート側でこれら別々のパターンを組み合わせることで、最終的な結果として、制御されたコンテナ幅の中でカラム分割されたレイアウトが実現される。

このように独立したパターンを組み合わせてレイアウトする利点は、あるパターンの利用が別のパターンに制約されなくなることにより組み合わせ可能なバリエーションが大幅に増え、より少ないCSSでより多くのレイアウトを実現できるようになる冗長性にある。あるパターンに3のバリエーションがあり、別のパターンには5のバリエーションがあるとき、テンプレート側でそれらをかけ合わせると15のバリエーションを表現できるようになる。CSSは8のままで。

レイアウトプリミティブを利用するとそれだけで相当数のレイアウトが表現できる。すべてのパターンがプリミティブであり、かつそれぞれが組み合わせ可能なことを前提としているからだ。ひとつひとつのパターンとしても利用頻度が高いものが多く、自分が携わるほとんどのサイトの構築において汎用的なコンポーネント設計のパターンとして効果を上げた実績もある。

しかしEvery Layoutでの解説にもとづくとそのままでは現実のプロジェクトに適用しづらい部分がある。そのひとつはパターンのバリエーションをカスタムプロパティを用いて表現していること。多くのプロジェクトではIE11でもほとんど同じように表示できることを求められるのでこれは採用できない。ポリフィルもあまり信用できないし。もうひとつはメディアクエリによる上書きを意図的に想定していないこと。Every Layoutはレイアウトの制御をブラウザや公理に委ねることを強く主張しており、ビューポート幅にもとづくスタイル宣言の変更というある種恣意的なレイアウトの操作に依存しないようにレイアウトプリミティブも設計されている。しかしそれでは少なくとも業務での実践は難しいだろう。

この記事ではレイアウトプリミティブを現実のプロジェクトに取り入れるために行ったいくつかの対処方法を紹介する。レイアウトプリミティブをコンポーネントとして実装していく方向で進めるが、後述する理由によりすべてをコンポーネントにはしない(あるいはできない)。なおこの記事ではそれぞれのレイアウトプリミティブについては詳しく言及せず、あらかじめ理解されている前提で述べる。必要に応じてEvery Layoutをご参照いただきたい。また記事中では説明の都合上、ソースコードの一部のみを抜粋して掲載している。完全な状態は記事の末尾でまとめて確認できる。

バリエーション

Stackは縦方向に配置された要素間に均一の余白を設定するパターン。この場合の余白の大きさの指定方法として次のような実装が紹介されている。

<div class="stack">
  <p>Lorem ipsum dolor sit amet.</p>
  <p>Lorem ipsum dolor sit amet.</p>
  <h2>title</h2>
  <p>Lorem ipsum dolor sit amet.</p>
  <p>Lorem ipsum dolor sit amet.</p>
</div>
.stack {
  --space: 1.5rem;
}

.stack > * + * {
  margin-top: var(--space);
}

h2,
h2 + * {
  --space: 3rem;
}

.stack--spaceプロパティの上書きによってデフォルトの余白が変更可能になっており、個別の要素の前後の余白は--spaceプロパティの宣言によって、詳細度の高い.stack > * + *margin-topを上書きせずに変更できる。

--spaceプロパティのおかげで.stackは自らが表現する余白のバリエーションを知っておかなくてもよくなる。場面に応じて利用する側から指定されればそれに対応できる。これがカスタムプロパティを使えないとすれば、BEMのモディファイアのようなやり方で余白の大きさを指定することになる。モディファイアでそのままサイズを指定することはできないので、キーであるモディファイアと値との対応を考えなければならない。つまりあらゆる余白のバリエーションを把握する必要がある。

デザインガイドラインとして余白のバリエーションが文書化されていればそれを利用できるが、多くの場合では各ページのデザインファイルから地道に拾い上げていくしかない。しかしそうするとバリエーションが膨大になってしまったり、デザイン変更のたびにバリエーションが影響を受けて再考の作業が生まれてしまう。多くの箇所で再利用する前提のコンポーネントに参照されている以上、余白のバリエーションは可能な限り早い段階でフリーズさせたい。最初からあらかじめわかっている状態にできればベストだ。そのため特定のページだけに依存せずあらゆるプロジェクトに適用できるパターンを模索してきたが、現在としては「音楽、数学、タイポグラフィ」で紹介された8px(0.5rem)を基数としてフィボナッチ数列をかけ合わせて生成されたバリエーションをベースに少し変形させたものを足がかり的に利用している。その状態から最後まで変更なしのままプロジェクトを見届けることもあれば、細かい調整が必要になることもあるが、まったく見当違いの設定になっていることはなかった。

こうして計画した余白のバリエーションから、Sassを利用してモディファイアを次のように実装している。

_core.scss:

// Spacing

$-spacing-unit: 0.5rem;

$spacing-1: $-spacing-unit / 2;
$spacing-2: $-spacing-unit * 1;
$spacing-3: $-spacing-unit * 1.5;
$spacing-4: $-spacing-unit * 2;
$spacing-5: $-spacing-unit * 3;
$spacing-6: $-spacing-unit * 5;
$spacing-7: $-spacing-unit * 8;
$spacing-8: $-spacing-unit * 13;
$spacing-9: $-spacing-unit * 21;

$spacings: (
  0: 0,
  1: $spacing-1, // 0.25rem =   4px
  2: $spacing-2, //  0.5rem =   8px
  3: $spacing-3, // 0.75rem =  12px
  4: $spacing-4, //    1rem =  16px
  5: $spacing-5, //  1.5rem =  24px
  6: $spacing-6, //  2.5rem =  40px
  7: $spacing-7, //    4rem =  64px
  8: $spacing-8, //  6.5rem = 104px
  9: $spacing-9, // 10.5rem = 168px
);

(余白のバリエーションを個別の変数とマップで宣言しているのはエディタの補完とループのためという事情。ループを用いない場面では個々の変数を参照する。)

_Stack.scss:

/**
 * Spacing variant:
 *
 * <div class="Stack -s{spacing}"></div>
 */

@each $spacing-key, $spacing in $spacings {
  $name: s#{$spacing-key};

  .Stack.-#{$name} > * + * {
    margin-top: $spacing;
  }
}
<div class="Stack -s4">
  <p>foo</p>
  <p>bar</p>
  <p>baz</p>
</div>

こうしてあらゆる縦の余白のバリエーションがひとつのコンポーネントだけで実現できるようになった。しかしこれだけでは特定の箇所に異なる余白がある場合に対応できない。それについては2つのやり方を場面によって使い分けている。ひとつはユーティリティクラスの導入だ。なんとなく導入されたユーティリティクラスは悪い設計を招いてしまうが、利用の目的がはっきりとしていれば問題はない。この場合ではStackのコンテキスト内で個別のmargin-topを設定すること。またそのユーティリティクラスを使うことで新たなセレクタを増やさず済ませられる場合にも利用できる。

_utilities.scss:

// margin-top property

/**
 * Usage:
 *
 * <div class="Stack -s2">
 *   <div>foo</div>
 *   <div class="mt-4">bar</div>
 *   <div>baz</div>
 * </div>
 *
 * <div class="mt-3"></div>
 *
 * Spacing variant:
 *
 * <div class="mt-{spacing}"></div>
 */

@each $spacing-key, $spacing in $spacings {
  $name: mt-#{$spacing-key};

  .#{$name} {
    margin-top: $spacing !important;
  }
}

margin-bottompaddingなどのユーティリティクラスは本当に必要になるタイミングまで作らない。

もうひとつのやり方は、Stackとそもそも別のコンポーネントを作ってしまうこと。新しいコンポーネントは増えてしまうが、特殊な対応は個別のコンポーネントに切り出して閉じ込めておいた方がいい場合もある。あるいはすでに存在するコンポーネントにBEMでいうエレメントとして追加するのであれば比較的気兼ねなく行えるだろう。

<div class="ArticleBody">
  <p>レイアウトプリミティブを現実のプロジェクトに取り入れるために…</p>
  <h2>バリエーション</h2>
  <p>Stackは縦方向に配置された要素間に均一の余白を…</p>
</div>

_ArticleBody.scss

.ArticleBody > * + * {
  margin-top: 1.5rem;
}

.ArticleBody > h2,
.ArticleBody > h2 + * {
  margin-top: 3rem;
}

ほとんどのレイアウトプリミティブは余白を表現する責務を持つので、余白のモディファイアについては先ほどのバリエーションを参照することで同様の解法がとれる。しかしStackの余白は縦方向のみなのに対して、たとえばClusterには縦横両方向の余白がある。Every Layoutでは縦横で同じ余白が挿入される実装になっているが、汎用性としては別々の値を指定できた方が良い。そのために全方向の余白・X軸の余白・Y軸の余白を個別に指定できる別々のモディファイアを設定するようにした:

_Cluster.scss:

.Cluster {
  display: block;
  overflow: hidden;
}

.Cluster > * {
  display: flex;
  flex-wrap: wrap;
}

/**
 * Spacing variant:
 *
 * <div class="Cluster -s{spacing}"></div>
 * <div class="Cluster -sx{spacing}"></div>
 * <div class="Cluster -sy{spacing}"></div>
 */

@each $spacing-key, $spacing in $spacings {
  $name: s#{$spacing-key};

  .Cluster.-#{$name} > * {
    margin: ($spacing / 2 * -1);
  }

  .Cluster.-#{$name} > * > * {
    margin: ($spacing / 2);
  }
}

@each $spacing-key, $spacing in $spacings {
  $name-x: sx#{$spacing-key};

  .Cluster.-#{$name-x} > * {
    margin-right: ($spacing / 2 * -1);
    margin-left: ($spacing / 2 * -1);
  }

  .Cluster.-#{$name-x} > * > * {
    margin-right: ($spacing / 2);
    margin-left: ($spacing / 2);
  }

  $name-y: sy#{$spacing-key};

  .Cluster.-#{$name-y} > * {
    margin-top: ($spacing / 2 * -1);
    margin-bottom: ($spacing / 2 * -1);
  }

  .Cluster.-#{$name-y} > * > * {
    margin-top: ($spacing / 2);
    margin-bottom: ($spacing / 2);
  }
}

またEvery Layoutではあまり言及されていない点として、ネガティブマージンによってはみ出る領域をoverflow: hiddenで非表示にしてしまうハックの扱いにくさがある。この手を使ってしまうと、ネガティブマージン以外にもはみ出るoutlineプロパティやbox-shadowプロパティ、あるいは外方向に動くアニメーションが見切れてしまう。これについては、デフォルトとしてはoverflow: hiddenを設定しておいて、問題になる箇所だけを個別にオプトアウトするようにした:

_Cluster.scss:

.Cluster {
  display: block;
}

/**
 * Overflow variant:
 *
 * <div class="Cluster -overflow"></div>
 */

.Cluster:not(.-overflow) {
  overflow: hidden;
}

.Cluster > * {
  display: flex;
  flex-wrap: wrap;
}

デフォルトをhiddenにしているのは、ネガティブマージンによるはみ出しの方がより無意識的なバグを生んでしまいそうに思えるから。

そしてClusterには余白だけでなくjustify-contentalign-itemsも個別に設定できるようになっている。オリジナルではカスタムプロパティで直接値を渡せるが、モディファイアとして表現するには余白と同様にあらかじめバリエーションを列挙する必要がある。これらのプロパティでよく使う値はだいたいわかっているのでほぼ決め打ちにできる。

_Cluster.scss

/**
 * Justify variant:
 *
 * <div class="Cluster -justify-{justify}"></div>
 */

$Cluster-justifiers: (
  start: flex-start,
  end: flex-end,
  center: center,
  between: space-between,
);

@each $justifier-key, $justifier in $Cluster-justifiers {
  $name: justify-#{$justifier-key};

  .Cluster.-#{$name} > * {
    justify-content: $justifier;
  }
}

/**
 * Align variant:
 *
 * <div class="Cluster -align-{align}"></div>
 */

$Cluster-aligners: (
  start: flex-start,
  end: flex-end,
  center: center,
  stretch: stretch,
);

@each $aligner-key, $aligner in $Cluster-aligners {
  $name: align-#{$aligner-key};

  .Cluster.-#{$name} > * {
    align-items: $aligner;
  }
}

次に、CenterはBootstrapの.containerに似たパターンで、max-widthの値はカスタムプロパティから渡される想定になっている。具体的な幅はサイトによってまちまちなので、この設定は実際のものを作り始めてみるまでどうにもならない。多くのサイトは複数のコンテンツ幅を組み合わせて構成されているので、作りながらバリエーションを探って、やはりこれもモディファイアで表現していく:

_Center.scss:

.Center {
  box-sizing: content-box;
  display: block;
  max-width: 60rem;
  margin-right: auto;
  margin-left: auto;
}

.Center.-wide {
  max-width: 75rem;
}

.Center.-narrow {
  max-width: 45rem;
}

/**
 * Gutters variant:
 *
 * <div class="Grid -noGutters"></div>
 */

.Center:not(.-noGutters) {
  padding-right: $spacing-5;
  padding-left: $spacing-5;
}

これは単純化した例で、実際にはブレイクポイントごとに個別の幅を設定することが多い。しかしブレイクポイントをまたいだときの変化が均一化されていない場合もあり、モディファイアの設計が難しいパターンかもしれない。そういった場合はStackについて述べたように別コンポーネント化してしまうか、あるいはメディアクエリ付きモディファイアの導入を検討する。

メディアクエリ

ビューポート幅が変化しても同じパターンのレイアウトのままで成立させられる場面は多いが、余白などのキーだけは個別に変更が必要になることがほとんどだ。たとえば狭いビューポート幅では余白も狭く、広いビューポート幅では余白も広くというのが典型的。結局これに対応できないと再利用性のない個別のコンポーネントを作り込んでいくしかなくなってしまう。そのためメディアクエリごとに機能するモディファイアを用意して利用側で個別に指定する形で対処した:

_core.scss:

$mq-breakpoints: (
  xs: 0,
  sm: 36em, //  576px
  md: 48em, //  768px
  lg: 64em, // 1024px
  xl: 80em, // 1280px
);

@mixin breakpoint($key, $until: false) {
  @if map.has-key($mq-breakpoints, $key) == false {
    @error "`#{$key}` not found in $mq-breakpoints";
  }
  $breakpoint: map.get($mq-breakpoints, $key);
  $is-zero: $breakpoint == 0;

  @if $is-zero and $until {
    @error "Breakpoints are not available for screens smaller than 0px";
  }

  @if $is-zero {
    @content;
  } @else if $until {
    @media not all and (min-width: #{$breakpoint}) {
      @content;
    }
  } @else {
    @media (min-width: #{$breakpoint}) {
      @content;
    }
  }
}

_Stack.scss:

/**
 * Spacing variant:
 *
 * <div class="Stack -s{spacing}"></div>
 * <div class="Stack -{breakpoint}:s{spacing}"></div>
 */

@each $breakpoint-key, $breakpoint in $mq-breakpoints {
  $uses-media-query: $breakpoint != 0;
  $breakpoint-prefix: if($uses-media-query, "#{$breakpoint-key}\\:", null);

  @include breakpoint($breakpoint-key) {
    @each $spacing-key, $spacing in $spacings {
      $name: s#{$spacing-key};

      .Stack.-#{$breakpoint-prefix}#{$name} > * + * {
        margin-top: $spacing;
      }
    }
  }
}
<div class="Stack -s3 -md:s5 -lg:s6">
  <p>foo</p>
  <p>bar</p>
  <p>baz</p>
</div>

ほかのパターンの余白やjustify-content、ユーティリティクラスなどについても同様に実装する。

メディアクエリごとの宣言を追加することでCSSの出力サイズは増えてしまうが、レイアウトプリミティブが全体の個別性を吸収することで結果的にはむしろサイズを抑えられる場合もある。あるいはレイアウトプリミティブによってCSSの実装時間を節約することで、より費用対効果の高いパフォーマンス改善に臨めるとも考えられる。

メディアクエリごとのモディファイアを記述する煩雑さについては、テンプレートエンジンの機能によってある程度軽減できる。たとえばReactであれば、次のような宣言によって上記と同様のクラス属性値が出力されるようにすると良い:

<Stack s={[3, null, 5, 6]}>
  <p>foo</p>
  <p>bar</p>
  <p>baz</p>
</Stack>

こうすれば必要に応じて型チェックも挿入できる。配列としてキーを渡すアイデアStyled SystemのArray Propsから拝借した。同様のインターフェースはPugのmixin機能などでも実現できる。

またオリジナルのレイアウトプリミティブの中には、メディアクエリには依存せずに要素自身の幅の変化によって子要素の並びが変わるSidebarやSwitchがある。これらには並びを変化させるブレイクポイントとしてそのタイミングの要素の幅をカスタムプロパティで指定するが、メディアクエリを前提とした設計であれば普通にメディアクエリで上書きした方が素直だろう。ブレイクポイントの指定は先述と同様にモディファイアで行う:

_Switcher.scss:

.Switcher > * {
  display: flex;
  flex-direction: column;
}

.Switcher > * > * {
  flex-shrink: 0;
  width: 100%;
}

/**
 * Row variant:
 *
 * <div class="Switcher -row"></div>
 * <div class="Switcher -{breakpoint}:row"></div>
 */

$Switcher-row-name: row;

@each $breakpoint-key, $breakpoint in $mq-breakpoints {
  $uses-media-query: $breakpoint != 0;
  $breakpoint-prefix: if($uses-media-query, "#{$breakpoint-key}\\:", null);

  @include breakpoint($breakpoint-key) {
    .Switcher.-#{$breakpoint-prefix}#{$Switcher-row-name} > * {
      flex-direction: row;
    }

    .Switcher.-#{$breakpoint-prefix}#{$Switcher-row-name} > * > * {
      flex-shrink: 1;
    }
  }
}
<div class="Switcher -md:row">
  <div>
    <p>foo</p>
    <p>bar</p>
    <p>baz</p>
  </div>
</div>

ビューポートの幅によってレイアウトプリミティブのパターン自体を切り替えたい場面もある。狭い幅ではカードをReel(横スクロール)で並べ、広い幅ではGridで並べるというような。個別のコンポーネントにすれば共通のマークアップで実現できなくもないが、ここは汎用性のためブレイクポイントごとに別々のマークアップを使う。JavaScriptで出し分けてもいいが、両方のパターンを含んだ静的テンプレートを記述した上で、メディアクエリごとにdisplay: noneを制御するユーティリティクラスを付与する方が簡単になる:

_utilities.scss:

// display property

/**
 * Usage:
 *
 * <div class="hidden md:block">hello</div>
 *
 * Display variant:
 *
 * <div class="{display}"></div>
 * <div class="{breakpoint}:{display}"></div>
 */

$-displayers: (
  block: block,
  inline: inline,
  hidden: none,
  inlineBlock: inline-block,
);

@each $breakpoint-key, $breakpoint in $mq-breakpoints {
  $uses-media-query: $breakpoint != 0;
  $breakpoint-prefix: if($uses-media-query, "#{$breakpoint-key}\\:", null);

  @include breakpoint($breakpoint-key) {
    @each $name, $displayer in $-displayers {
      .#{$breakpoint-prefix}#{$name} {
        display: $displayer !important;
      }
    }
  }
}
<div class="md:hidden">
  <div class="CardReel">
    <div class="Card">...</div>
    <div class="Card">...</div>
    <div class="Card">...</div>
  </div>
</div>

<div class="hidden md:block">
  <div class="Grid -sm:col-2 -lg:col-3 -s3">
    <div>
      <div>
        <div class="Card">...</div>
      </div>
      <div>
        <div class="Card">...</div>
      </div>
      <div>
        <div class="Card">...</div>
      </div>
    </div>
  </div>
</div>

この場合カードの内容をテンプレートの2箇所に記述しなければならないが、テンプレートエンジンを使っていれば問題にならないだろう。

ちなみにReelはカスタムプロパティの表現を代替するのが難しく、また利用頻度も少ないため個別のコンポーネントにすることが多い。

そのほかのIEへの対処

Gridについてはdisplay: gridによる実装ではグリッドアイテムの折り返しをIEで実現できないので、フレックスボックスを用いた独自の実装にしている:

.Grid > * {
  display: flex;
  flex-wrap: wrap;
}

.Grid > * > * {
  width: 100%;
}

/**
 * Columuns variant:
 *
 * <div class="Grid -col-{columns}"></div>
 * <div class="Grid -{breakpoint}:col-{columns}"></div>
 */

$Grid-columns-list: (2, 3, 4);

@each $breakpoint-key, $breakpoint in $mq-breakpoints {
  $uses-media-query: $breakpoint != 0;
  $breakpoint-prefix: if($uses-media-query, "#{$breakpoint-key}\\:", null);

  @include breakpoint($breakpoint-key) {
    @each $columns in $Grid-columns-list {
      $name: col-#{$columns};

      .Grid.-#{$breakpoint-prefix}#{$name} > * > * {
        width: percentage(1 / $columns);
      }
    }
  }
}

/**
 * Spacing variant:
 *
 * <div class="Grid -s{spacing}"></div>
 * <div class="Grid -{breakpoint}:s{spacing}"></div>
 */

@each $breakpoint-key, $breakpoint in $mq-breakpoints {
  $uses-media-query: $breakpoint != 0;
  $breakpoint-prefix: if($uses-media-query, "#{$breakpoint-key}\\:", null);

  @include breakpoint($breakpoint-key) {
    @each $spacing-key, $spacing in $spacings {
      $name: s#{$spacing-key};

      .Grid.-#{$breakpoint-prefix}#{$name} > * {
        margin: ($spacing / 2 * -1);
      }

      .Grid.-#{$breakpoint-prefix}#{$name} > * > * {
        padding: ($spacing / 2);
      }
    }
  }
}

またIEにはflex-direction: columnを利用するとそのフレックスアイテムや子孫の固有のアスペクト比(intrinsic aspect ratios)が維持されないバグがある。StackとSwitcherではflex-direction: columnを利用しているので、フレックスアイテムに対してflex-shrink: 0を明示的に指定することでバグを回避している:

_Stack.scss:

.Stack {
  display: flex;
  flex-direction: column;
  justify-content: flex-start;
}

.Stack > * {
  flex-shrink: 0;
}

_Switcher.scss:

.Switcher > * {
  display: flex;
  flex-direction: column;
}

.Switcher > * > * {
  flex-shrink: 0;
  width: 100%;
}

しかしそれでも予期しないバグはまれに発生するため、そういったときには別コンポーネントを作るなどしてバグを回避するなにかしらの対応をしている。

外部からのスタイル宣言の上書き

実装の汎用化を図ろうといろいろ工夫しても例外的な対応が必要になってしまうのは珍しくない。もっともレイアウトプリミティブによる汎用化は全体の個別性の程度を軽減させるためのメソッドであって、これだけですべてを完全に表現し切るのが目的ではない。Every Layoutはレイアウトプリミティブをプログラミング言語におけるプリミティブなデータ型に例えている。標準的なレイアウトの型を活用することで、すべてのレイアウトは実現できないにしても、無駄なルーティンワークはかなり削減できるだろうという話だ。

モディファイアによってバリエーションを表現するこのアプローチの難点は利用するあらゆる値を中央集権的に管理しなければならない点だ。末端のページでのちょっとしたアドリブのためにコアに手を入れなければならないというような。余白のバリエーションなら比較的規則化しやすいが、Sidebarの幅やSwitchのアイテムの比率などどうしても必要なときになってみるまでわからないものもある。こうした場合には汎用的な解決を考えるのは諦めて、レイアウトプリミティブをラップする個別のコンポーネントなどを作って上書きするようにする:

<div class="MyComponent">
  <div class="Switch">
    <div>
      <div>default</div>
      <div class="MyComponent__featuredItem">featured</div>
      <div>default</div>
    </div>
  </div>
</div>
<div class="MyComponent">
  <div class="Stack">
    <p>Lorem ipsum dolor sit amet.</p>
    <p>Lorem ipsum dolor sit amet.</p>

    <div class="MyComponent__specialItem">
      <div class="Card">...</div>
    </div>

    <p>Lorem ipsum dolor sit amet.</p>
  </div>
</div>

こうすればエッジケースへの対応の影響を局所的にできる。

またレイアウトプリミティブとそれ以外のコンポーネントの境界を明確にするためにレイヤリングを行うこともできる。既存のCSSアーキテクチャでいうとITCSSのObjectsとComponentsのモデルが適しているように思える。ObjectsはOOCSSと同様の装飾がなく汎用的なパターンだ。装飾はComponentsのレイヤーで施され、また汎用性のないスタイルもここに属する。レイアウトプリミティブのみがあらかじめObjectsとして位置していて、プロジェクトの開始後に追加された実装は基本的にはComponentsとして扱うのが良いだろう。

// Center, Cluster, Grid, Stack, Switcher...
@import "objects/*";

// ArticleBody, Card, CardReel, MyComponent...
@import "components/*";

ITCSSの目的はスタイル記述順の制御であり、同じ詳細度の宣言はより後のレイヤーによって上書きされる仕組み。もっともレイアウトプリミティブでは全称セレクタを多用するので、詳細度が高まっていてあまりうまくは機能させられないが……。はっきり区別させる意味では役に立つ。

各レイアウトプリミティブのユースケースの例示

よくある「目に見える」コンポーネントカタログと違って、レイアウトプリミティブのユースケースは最初は少し想像しづらい。その汎用性ゆえに抽象的で、具体例が欲しくなる。その解決のためにパターンごとのユースケースを掲載したスタイルガイドを作成した。

f:id:yuheiy:20200517215124j:plain

_shifted/1-objects.pug at master · yuheiy/_shifted

利用方法はこれらの例から学習できるだろう。

プロジェクトのセットアップ

これまで紹介してきたレイアウトプリミティブの実装はひとつのリポジトリに集約させていて、それらがあらかじめ用意された状態で新しいプロジェクトをはじめられるようにしてある。基本的なパターンを繰り返し実装し直す手間を省いて、個別の問題により集中できるようにする狙いがある。

_shifted/boilerplate-static/app/assets/objects at master · yuheiy/_shifted

カスタムプロパティを用いずに汎用化するのが困難なパターンや、利用頻度が少ないパターンは含んでいない。

ちなみにCSS以外の開発環境構築についても汎用化できないか長らく考えていて、これについてもまたいつか書きたい。

宣伝

この記事では自分なりのEvery Layoutの応用について書いたが、その根底には原著が伝えるもとの意図がなければ成立しない。しかしながらEvery Layoutは英語であり有料コンテンツであるためになかなか紹介しづらく、また読んでもらうハードルも高く、非常にやりきれない思いになっていた。

そうしたところで偶然、編集者の岡本さんにお声がけいただき、Every Layoutを日本語訳して出版する事の運びとなった。友人の横内さんとも一緒に。

原著の内容をなんとかうまく伝えられるよう精一杯やりますので、みなさん何卒よろしくお願いします。