見出し画像

よくある2ペインのレイアウト用コンポーネントを作りつつ、Web Componentsの仕組みを理解する

Web Componentsを一言で説明すると「HTML/CSSが独立したカスタムタグを作れる仕組み」です。理解するには、まずは素のJavascriptだけでよくある部品を作ってみることが一番だと思います。

多くのサンプルではボタンやアイコンなどの部品を作りますが、今回はflexboxを使ったレイアウト定義のコンポーネントを作ってみます。この部分にはちょっとしたハマりどころがあるのでちょうど良い練習になります。

もう一つハマりやすいのはフォーム部品ですが、これはまた後日書きます。

この記事の前に 「今なら使えるWebComponents」を軽くでも読んでおいてもらえると嬉しいです。

2ペインのレイアウトを考える

最初にWebアプリでよくある、左にナビゲーション、右にコンテンツを置く2ペインのレイアウト用のタグを作ります。

整理するために画面を書いて、そのタグ構造を想像で書いてみます。

Web app系でよくみる画面構造
<a1-app-container>
  <a1-app-leftnav>
    <a1-app-tab>Menu 1</a1-app-tab>
    <a1-app-tab>Menu 2</a1-app-tab>
    <a1-app-tab>Menu 3</a1-app-tab>
    <a1-app-tab position="bottom">
      Logout
    </a1-app-tab>
  </a1-app-leftnav>
  <a1-app-body>
    Body
  </a1-app-body>
</a1-app-container>

CodePenで書き始める

試す環境として、CodePenを利用します。https://codepen.io/pen/ を開いてHTMLの部分に上のコードをコピーして始めます。

https://codepen.io/pen/

こういう2ペインを作るときはflexboxを使います。flexboxの使い方は適当にググってください。

まずは空のカスタムエレメントを定義する

createElement.defineを使って自分用のタグ=カスタムエレメントを定義します。下のコードだけでカスタムエレメントを定義できます。

customElements.define('a1-***', class extends HTMLElement {
  constructor() {
    super();
    const shadowRoot = this.attachShadow({ mode: "open" });
    shadowRoot.innerHTML = `
<style></style>
<slot></slot>
    `;
  }
}); 

このコードの"a1-***"の部分を"a1-app-container", "a1-app-leftnav", "a1-app-tab", "a1-app-body"に変えつつ、4つコピーします。

<slot></slot>はそこに子要素を展開する

innerHTMLの中にはCSSを書く<style>と共に<slot></slot>という見たことないタグが出てきています。これは、子要素がそこに展開されます。

<foo>
  <div>テスト</div>
</foo>

としていた場合、fooタグの<slot></slot>は<div>テスト</div>に置換されます。
もちろん、この子要素の部分に他のカスタムエレメントを入れることができます。

まずは左右ペインの分割を作る

左右分割を普通にHTMLで書くと下記のようになります。分かりやすくするために各エレメントにborderを設定します。

<div style="display: flex; min-height: 100vh; border: 1px solid red;">
  <div style="width: 16em; border: 1px solid green;">nav</div>
  <div style="flex-grow: 1; border: 1px solid blue;">body</div>
</div>
Preview部分にmarginが指定されているので高さが合わないですが

これをカスタムエレメントに分解しますが、都合よく今回は全て1 : 1になっています。なので3つのカスタムエレメントに分解して、1つずつdivを設定します。

customElements.define('a1-app-container', class extends HTMLElement {
  constructor() {
    super();
    const shadowRoot = this.attachShadow({ mode: "open" });
    shadowRoot.innerHTML = `
<div style="display: flex; min-height: 100vh; border: 1px solid red;">
  <slot></slot>
</div>
    `;
  }
});
 
customElements.define('a1-app-leftnav', class extends HTMLElement {
  constructor() {
    super();
    const shadowRoot = this.attachShadow({ mode: "open" });
    shadowRoot.innerHTML = `
<div style="width: 16em; border: 1px solid green;">
  <slot></slot>
</div>
    `;
  }
}); 

customElements.define('a1-app-body', class extends HTMLElement {
  constructor() {
    super();
    const shadowRoot = this.attachShadow({ mode: "open" });
    shadowRoot.innerHTML = `
<div style="flex-grow: 1; border: 1px solid blue;">
  <slot></slot>
</div>
    `;
  }
});

shadowRootはカスタムエレメントの中に設置される

これを実行すると下記のように表示されます。元のHTMLと違いうまくいきませんでした。

思ったように表示されない・・・

これはなぜかというと、flexboxは`display: flex`の直下のエレメントを子要素として扱います。上のコードだとHTMLは下記のように扱われます。

<a1-app-container>
  <div style="display: flex; min-height: 100vh; border: 1px solid red;">
    <a1-app-leftnav>
      <div style="width: 16em; border: 1px solid green;">
        nav
      </div>
    </a1-app-leftnav>
    <a1-app-body>
      <div style="flex-grow: 1; border: 1px solid blue;">
        body
      </div>
    </a1-app-body>
  </div>
</a1-app-container>

2行目の`display: flex`の直下は<a1-app-leftnav>でこれにはwidthやflex-growが設定されていません。なので先ほどのような表示になります。

shadowRootはカスタムエレメントと入れ替わるわけでなく、その中に設置されます。そして、このカスタムエレメントにスタイルを反映させるには、<style>の中で`:host { … }`で指定します。書き換えてみましょう。

customElements.define('a1-app-container', class extends HTMLElement {
  constructor() {
    super();
    const shadowRoot = this.attachShadow({ mode: "open" });
    shadowRoot.innerHTML = `
<style>:host { display: flex; min-height: 100vh; border: 1px solid red; }</style>
<slot></slot>
    `;
  }
});

customElements.define('a1-app-leftnav', class extends HTMLElement {
  constructor() {
    super();
    const shadowRoot = this.attachShadow({ mode: "open" });
    shadowRoot.innerHTML = `
<style>:host { width: 16em; border: 1px solid green; }</style>
<slot></slot>
    `;
  }
});

customElements.define('a1-app-body', class extends HTMLElement {
  constructor() {
    super();
    const shadowRoot = this.attachShadow({ mode: "open" });
    shadowRoot.innerHTML = `
<style>:host { flex-grow: 1; border: 1px solid blue; }</style>
<slot></slot>
    `;
  }
});
今度は思い通りに成功

HTMLにスタイル指定すると:hostの指定は上書きでちゃう

今度は意地悪をして、HTML側の`a1-app-container`に`style`を指定してみましょう。

<a1-app-container style="display: block; border: 4px solid yellow;">

これを指定すると、displayが上書きされレイアウトが崩れてしまいます。

HTML側のスタイルが反映されちゃった

:hostの中で!importantすると上書きを防止できる

このような事が起こらないようにするには、カスタムエレメントの`:host`の定義の中で`!important`を追加します。

<style>:host { display: flex !important; min-height: 100vh; border: 1px solid red; }</style>
黄色のborder残したままレイアウトは元通り

今度は、`display: flex`は有効なまま`border: 1px solid red`だけ上書きされました。このように外から上書きされたくないスタイルには`!important`を指定してください。

もちろん、HTMLで指定したスタイルは:hostのみ反映され、shadowRootの中のHTMLには反映されません。

アトリビュートを受け取って処理を分岐する

さて、最後に<a1-app-leftnav>の中のメニューを追加します。

<a1-app-leftnav>
  <a1-app-tab>Menu 1</a1-app-tab>
  <a1-app-tab>Menu 2</a1-app-tab>
  <a1-app-tab>Menu 3</a1-app-tab>
  <a1-app-tab position="bottom">
    Logout
  </a1-app-tab>
</a1-app-leftnav>

今度はpositionアトリビューを受け取るようにします。アトリビュートはthis.getAttribute("position")で受け取れるので、ただそれをif文で分岐するだけです。

// 👇 追加
customElements.define('a1-app-tab', class extends HTMLElement {
  constructor() {
    super();
    const shadowRoot = this.attachShadow({ mode: "open" });
    if(this.getAttribute("position")==="bottom") {
      shadowRoot.innerHTML = `
<style>:host { display: block; padding: 0.8em; margin: 0; background-color: purple;  margin-top: auto;}</style>
<slot></slot>
    `;
    }
    else {
      shadowRoot.innerHTML = `
<style>:host { display: block; padding: 0.8em; margin: 0; background-color: cyan; }</style>
<slot></slot>
    `;
    }
  }
});

// 👇 スタイルを変更
customElements.define('a1-app-leftnav', class extends HTMLElement {
  constructor() {
    super();
    const shadowRoot = this.attachShadow({ mode: "open" });
    shadowRoot.innerHTML = `
<style>:host { width: 16em; min-height: 100%; padding: 0; margin: 0; display: flex; flex-direction: column; border: 1px solid green; }</style>
<slot></slot>
    `;
  }
});
うまく動いた!

完成!

必須のスタイルに!importantを付けた全文が下記のようになっています。
実際の動作はhttps://codepen.io/masuidrive/pen/RwjdZpoで確認ができます。

実用には?

さて、これがそのまま実用になるかというと、このカスタムエレメントならそのまま使えそうです。でももっと大きなCSSを書くならreset.css的なものも使いたいし、もっと高度な機能を使う場合はpolyfillを使うことになるかもしれません。

次回以降でそういう時に便利なGoogle製のWeb ComponentsフレームワークのLitを使って書き換えたいと思います。これをすればpolyfillも同時に読み込め、サポートブラウザも広がりますし、複雑なCSSや処理も書きやすくなります。

customElements.define('a1-app-container', class extends HTMLElement {
  constructor() {
    super();
    const shadowRoot = this.attachShadow({ mode: "open" });
    shadowRoot.innerHTML = `
<style>:host { display: flex !important; min-height: 100vh; padding: 0; margin: 0; }</style>
<slot></slot>
    `;
  }
});

customElements.define('a1-app-leftnav', class extends HTMLElement {
  constructor() {
    super();
    const shadowRoot = this.attachShadow({ mode: "open" });
    shadowRoot.innerHTML = `
<style>:host { width: 16em; min-height: 100% !important; padding: 0; margin: 0; display: flex !important; flex-direction: column !important; }</style>
<slot></slot>
    `;
  }
});

customElements.define('a1-app-tab', class extends HTMLElement {
  constructor() {
    super();
    const shadowRoot = this.attachShadow({ mode: "open" });
    if(this.getAttribute("position")==="bottom") {
      shadowRoot.innerHTML = `
<style>:host { display: block !important; padding: 0.8em; margin: auto 0 0 0!important; background-color: purple;}</style>
<slot></slot>
    `;
    }
    else {
      shadowRoot.innerHTML = `
<style>:host { display: block !important; padding: 0.8em; margin: 0; background-color: cyan; }</style>
<slot></slot>
    `;
    }
  }
});

customElements.define('a1-app-body', class extends HTMLElement {
  constructor() {
    super();
    const shadowRoot = this.attachShadow({ mode: "open" });
    shadowRoot.innerHTML = `
<style>:host { flex-grow: 1 !important; padding: 0; margin: 0; }</style>
<slot></slot>
    `;
  }
});