🥲

WebComponentでSwitchコンポーネントを作ってみた

2022/05/04に公開

こんなのを作りました

過去に作ったChromeの拡張を修正する過程でWebComponentに興味が出てきたのでSwitch型のUIコンポーネントを作ってみました。

画面キャプチャ

ソース

この記事で作ったソースはGitHubで公開しています。MITライセンスです。
https://github.com/Harurow/zenn-sample-web-component

今回作るもの

チェックボックスに代わる Switch型のUIが欲しかったのでSwitchコンポーネントを作りました。形は以下参考。

https://mui.com/material-ui/getting-started/installation/

Material UI では素敵なUIが沢山ありますが、React前提なので今回は生のJavaScriptで作ります。

全体像

とりあえずコンポーネントを試したいのでファイル構成[1]は以下、

   src
   +- components
   |  +- switch
   |  |  +- style.css
   |  |  +- switch.js
   |  +- extendCustomProperties.js
   +- css
   |  +- style.css
   +- index.html

まず外枠を作ってみる

src/components/switch/switch.js
class MySwitch extends HTMLElement {
  ......
}

customElements.define('my-switch', MySwitch)

これだけでHTMLで<my-switch></my-switch>として記述できます。

src/index.html
  ... 略 ...
  <my-switch></my-switch>
  ... 略 ...

中身を書く

テンプレートとして以下を利用、

src/components/switch/switch.js
class MySwitch extends HTMLElement {
  /**
   * コンポーネント作成
   */
  constructor () {
    super()

    // shadow domにアタッチ
    this.shadow = this.attachShadow({ mode: 'open' })
  }

  /**
   * コンポーネントが document に追加したタイミングで呼び出される。
   * 追加、削除を繰り返すとその度に呼び出される。
   */
  connectedCallback () {
    this.render()
  }

  /**
   * コンポーネントが document から削除されたときに呼び出されます。
   * 追加、削除を繰り返すとその度に呼び出される。
   */
  disconnectedCallback () {
  }

  /**
   * 監視対象のタグ名を配列で返す
   */
  static get observedAttributes () {
    return [/* 監視する属性名 */]
  }

  /**
   * observedAttributes で列挙した属性が変更されたときに呼び出される
   * @param {string} name
   * @param {any} oldValue
   * @param {any} newValue
   */
  attributeChangedCallback (name, oldValue, newValue) {
    // 監視属性の変更に合わせて再レンダリング
    this.render()
  }

  /**
   * コンポーネントが新しい document に移動 (adoptNode)した場合に呼び出される
   */
  adoptedCallback () {
  }

  /**
   * レンダリング
   */
  render () {
    this.shadow.innerHTML = `<style
      >@import 'components/${/*TODO:実態に合わせ調整*/}/style.css'</style
      ><span id="root"
      ></span>`
  }
}

......

各メソッドについてはコメントを参照してださい。
今回はコンポーネント用のcssを内包するので src/components/switch/style.css
renderメソッドで @import します。
また、<input type="checkbox">の挙動をまねしたいので<input type="checkbox">をレンダリングします。

src/component/switch/switch.js
  ......
  render () {
    this.shadow.innerHTML = `<style
      >@import 'components/switch/style.css'</style
      ><span id="root"
        ><label for="checkbox" id="switch"
          ><input id="checkbox" type="checkbox"></input
          ><span id="slider"></span
          ><span id="effects"></span
          ><span id="thumbs"></span
        ></label
      ></span>`
  }
  ......

コンポーネント全体をspanタグで囲み、その中をlabel for="checkbox"タグで囲みます。
全体がクリックされた時にチェックボックスと同じ動作になります。
他のspanはSwitchのUIを構成するタグです。形や色はsytle.cssで定義します。

属性の追加

チェックボックスの様に振る舞うためchecked, value, disabled, indeterminateの属性を追加します。

src/components/switch/switch.js
......
  /**
   * 監視対象のタグ名を配列で返す
   */
  static get observedAttributes () {
    return ['checked', 'value', 'disabled', 'indeterminate']
  }
......

これでchecked, value, disabled, indeterminateの属性が変更されたときに、attributeChangedCallbackメソッドが呼び出されます。attributeChangedCallbackではrenderを呼び出していますので属性の変更があると再レンダリングされます。

属性にアクセスするプロパティ

属性にアクセスするためのプロパティも定義します。

src/components/switch/switch.js
  /**
   * value属性の取得
   */
  get value () {
    return this.getAttribute('value') ?? 'on'
  }

  /**
   * value属性の設定
   */
  set value (v) {
    this.setAttribute('value', v)
  }

  /**
   * indeterminate属性の取得
   */
  get indeterminate () {
    return this.hasAttribute('indeterminate')
  }

  /**
   * indeterminate属性の設定
   */
  set indeterminate (v) {
    const b = Boolean(v)
    if (b) {
      this.setAttribute('indeterminate', undefined)
    } else {
      this.removeAttribute('indeterminate')
    }
  }

  /**
   * checked属性の取得
   */
  get checked () {
    return this.hasAttribute('checked')
  }

  /**
   * checked属性の設定
   */
  set checked (v) {
    const b = Boolean(v)
    if (b) {
      this.setAttribute('checked', undefined)
    } else {
      this.removeAttribute('checked')
    }
  }

  /**
   * disabled属性の取得
   */
  get disabled () {
    return this.hasAttribute('disabled')
  }

  /**
   * disabled属性の設定
   */
  set disabled (v) {
    const b = Boolean(v)
    if (b) {
      this.setAttribute('disabled', undefined)
    } else {
      this.removeAttribute('disabled')
    }
  }

属性をレンダリングに利用する

src/components/switch/switch.js
  render () {
    const checked = this.checked
    const disabled = this.disabled

    const checkBoxAttr = (checked ? ' checked' : '')
                       + (disabled ? ' disabled' : '')
    const rootCss = disabled ? ' style="opacity: .5;"': ''
    const labelCss = disabled ? ' style="cursor: default"' : ''

    this.shadow.innerHTML = `<style
      >@import 'components/switch/style.css'</style
      ><span id="root"${rootCss}
        ><label for="checkbox" id="switch"${labelCss}
          ><input id="checkbox" type="checkbox"${checkBoxAttr}></input
          ><span id="slider"></span
          ><span id="effects"></span
          ><span id="thumbs"></span
        ></label
      ></span>`

    const checkbox = this.shadow.querySelector('#checkbox')

    if (this.indeterminate) {
      checkbox.indeterminate = true
    }
  }

イベントハンドラの追加

<input type="checkbox">changeイベントを発火します。
今回のコンポーネントでも同様のイベントを発火します。
イベントのcomposedtrueに変更します。
また元のcomposedfalseの場合は発火させません。

src/components/switch/switch.js
  /**
   * changeイベントの発行
   * @param {Event} e
   */
  fireChangeEvent(e) {
    if (e.composed === false) {
      const event = new CustomEvent('change', {
        composed: true,
      })
      this.dispatchEvent(event)
    }
  }

checkboxのchangeイベントを紐づける

checkboxの値が変更されたときにfireChangeEventを発火させます。

src/components/switch/switch.js
  render () {
    ......
    checkbox.addEventListener('change', this.fireChangeEvent)
  }

色について

テーマにも対応したいので、外のCSSで指定されたカスタムプロパティを継承したい。
一旦 primary, secondaryに連なる変数が定義されていると仮定して作る。

src/css/style.css
:root {
  --primary:         #0277bd;
  --primary-light:   #58a5f0;
  --primary-dark:    #004c8c;
  --secondary:       #ff9800;
  --secondary-light: #ffc947;
  --secondary-dark:  #c66900;
}

色変数(カスタムプロパティ)のバリエーションについて

色の変数--primaryから--primary-rgbを自動的に生成して利用しています。

https://zenn.dev/harurow/articles/287b15dca0dd40

定義では --primaryしか定義していませんが、自動的に--primary-rgbを追加しています。

src/components/switch/style.css
#root.primary { /* 標準・プライマリの色 */
  --x-slider-rgb: var(--primary-rgb); /* スライダーの色 */
  --x-thumbs: var(--primary-light);   /* つまみ部分の色 */
}

#root.secondary { /* アクセント用・セカンダリの色 */
  --x-slider-rgb: var(--secondary-rgb); /* スライダーの色 */
  --x-thumbs: var(--secondary-light);   /* つまみ部分の色 */
}

#rootのクラスにprimary, secondaryを付けることで色を変更しています。
色変数はWebComponentの境界を超えて利用できました。

色をレンダリングに反映する

renderメソッドを変更してcolor属性で色が変わる様に修正します。
color属性を取得そのまま<span id="root">タグのクラス名に指定しています。指定がない場合はprimaryを利用します。

src/components/switch/switch.js
  render () {
    ......
    const color = this.getAttribute('color') ?? 'primary'
    ......
      ><span id="root" class="${color}"${rootCss}
    ......
  }

Switchの形を整える

今回はサイズをフォントベースで考えます。フォントのサイズを大きくするとSwitchも大きくなります。
他には特に説明箇所がないので一気に最終段階のソースを記載します。

src/components/switch/style.css
#root {
  position: relative;
  display: inline-block;
  margin: 0 0 0 0;
  padding: 0 0 0 0;
  line-height: 1em;
  height: 1em;
  width: 1.8em;
  text-align: start;
  vertical-align: baseline;
}

#root.primary {
  --x-slider-rgb: var(--primary-rgb);
  --x-thumbs: var(--primary-light);
}

#root.secondary {
  --x-slider-rgb: var(--secondary-rgb);
  --x-thumbs: var(--secondary-light);
}

#switch {
  position: relative;
  display: inline-block;
  font-size: 1em;
  position: relative;
  margin: 0 0;
  width: 1.8em;
  height: 1em;
  padding: 0;
  text-align: left;
  vertical-align: middle;
  cursor: pointer;
}

/* チェックボックス本体は非表示 */
#checkbox {
  display: none;
}

#slider {
  display: inline-block;
  width: 1.3em;
  height: .5em;
  margin: .25em .25em;
  padding: 0 0 0 0;
  background-color: #cccc;
  border-radius: 1em;
  transition: all .1s 0s ease-out;
}

input:checked~#slider {
  background: rgba(var(--x-slider-rgb), .8);
}

#thumbs {
  position: absolute;
  top: 0;
  right: auto;
  bottom: 1px;
  left: 0em;
  margin: auto 0;
  width: 1em;
  height: 1em;
  border-radius: 100%;
  background: #fff;
  box-shadow: 0 .06em .12em .00em #0013;
  transition: all .1s 0s ease-out;
}

input:indeterminate~#thumbs {
  opacity: 0;
}

input:disabled~#thumbs {
  box-shadow: 0 0 .03em .01em #0015;
}

input:checked~#thumbs {
  background: var(--x-thumbs);
}

input:checked~#thumbs {
  transform: translateX(.8em);
}

#effects {
  position: absolute;
  left: -.5em;
  top: -.5em;
  display: noneblock;
  width: 2em;
  height: 2em;
  border-radius: 100%;
  background: #ccc0;
  transition: all .1s 0s ease-out;
}

input:checked~#effects {
  display: inline-block;
  left: .25em
}

input:hover:not(:disabled)~#effects {
  background: #ccc4;
}

出来上がり

これでSwith型のUIコンポーネントが出上がりました。
主なソースの全体を掲載します。

src/components/switch/switch.js
/**
 * スイッチの形したチェックボックス
 */
class MySwitch extends HTMLElement {
  /**
   * コンポーネント作成
   */
  constructor () {
    super()

    // shadow domにアタッチ
    this.shadow = this.attachShadow({ mode: 'open' })
  }

  /**
   * コンポーネントが document に追加したタイミングで呼び出される。
   * 追加、削除を繰り返すとその度に呼び出される。
   */
  connectedCallback () {
    this.render()
  }

  /**
   * コンポーネントが document から削除されたときに呼び出されます。
   * 追加、削除を繰り返すとその度に呼び出される。
   */
  disconnectedCallback () {
  }

  /**
   * 監視対象のタグ名を配列で返す
   */
  static get observedAttributes () {
    return ['checked', 'value', 'disabled', 'indeterminate']
  }

  /**
   * observedAttributes で列挙した属性が変更されたときに呼び出される
   * @param {string} name
   * @param {any} oldValue
   * @param {any} newValue
   */
  attributeChangedCallback (name, oldValue, newValue) {
    // 監視属性の変更に合わせて再レンダリング
    this.render()
  }

  /**
   * コンポーネントが新しい document に移動 (adoptNode)した場合に呼び出される
   */
  adoptedCallback() {
  }

  /**
   * レンダリング
   */
  render () {
    const checked = this.checked
    const disabled = this.disabled

    const color = this.getAttribute('color') ?? 'primary'
    const checkBoxAttr = (checked ? ' checked' : '')
                       + (disabled ? ' disabled' : '')
    const rootCss = disabled ? ' style="opacity: .5;"': ''
    const labelCss = disabled ? ' style="cursor: default"' : ''

    this.shadow.innerHTML = `<style
      >@import 'components/switch/style.css'</style
      ><span id="root" class="${color}"${rootCss}
        ><label for="checkbox" id="switch"${labelCss}
          ><input id="checkbox" type="checkbox"${checkBoxAttr}></input
          ><span id="slider"></span
          ><span id="effects"></span
          ><span id="thumbs"></span
        ></label
      ></span>`

    const checkbox = this.shadow.querySelector('#checkbox')

    if (this.indeterminate) {
      checkbox.indeterminate = true
    }

    checkbox.addEventListener('change', this.fireChangeEvent)
  }

  /**
   * value属性の取得
   */
  get value () {
    return this.getAttribute('value') ?? 'on'
  }

  /**
   * value属性の設定
   */
  set value (v) {
    this.setAttribute('value', v)
  }

  /**
   * indeterminate属性の取得
   */
  get indeterminate () {
    return this.hasAttribute('indeterminate')
  }

  /**
   * indeterminate属性の設定
   */
  set indeterminate (v) {
    const b = Boolean(v)
    if (b) {
      this.setAttribute('indeterminate', undefined)
    } else {
      this.removeAttribute('indeterminate')
    }
  }

  /**
   * checked属性の取得
   */
  get checked () {
    return this.hasAttribute('checked')
  }

  /**
   * checked属性の設定
   */
  set checked (v) {
    const b = Boolean(v)
    if (b) {
      this.setAttribute('checked', undefined)
    } else {
      this.removeAttribute('checked')
    }
  }

  /**
   * disabled属性の取得
   */
  get disabled () {
    return this.hasAttribute('disabled')
  }

  /**
   * disabled属性の設定
   */
  set disabled (v) {
    const b = Boolean(v)
    if (b) {
      this.setAttribute('disabled', undefined)
    } else {
      this.removeAttribute('disabled')
    }
  }

  /**
   * changeイベントの発行
   * @param {Event} e
   */
  fireChangeEvent(e) {
    if (e.composed === false) {
      const event = new CustomEvent('change', {
        composed: true,
      })
      this.dispatchEvent(event)
    }
  }
}

customElements.define('my-switch', MySwitch)
src/components/switch/style.css
#root {
  position: relative;
  display: inline-block;
  margin: 0 0 0 0;
  padding: 0 0 0 0;
  line-height: 1em;
  height: 1em;
  width: 1.8em;
  text-align: start;
  vertical-align: baseline;
}

#root.primary {
  --x-slider-rgb: var(--primary-rgb);
  --x-thumbs: var(--primary-light);
}

#root.secondary {
  --x-slider-rgb: var(--secondary-rgb);
  --x-thumbs: var(--secondary-light);
}

#switch {
  position: relative;
  display: inline-block;
  font-size: 1em;
  position: relative;
  margin: 0 0;
  width: 1.8em;
  height: 1em;
  padding: 0;
  text-align: left;
  vertical-align: middle;
  cursor: pointer;
}

/* チェックボックス本体は非表示 */
#checkbox {
  display: none;
}

#slider {
  display: inline-block;
  width: 1.3em;
  height: .5em;
  margin: .25em .25em;
  padding: 0 0 0 0;
  background-color: #cccc;
  border-radius: 1em;
  transition: all .1s 0s ease-out;
}

input:checked~#slider {
  background: rgba(var(--x-slider-rgb), .8);
}

#thumbs {
  position: absolute;
  top: 0;
  right: auto;
  bottom: 1px;
  left: 0em;
  margin: auto 0;
  width: 1em;
  height: 1em;
  border-radius: 100%;
  background: #fff;
  box-shadow: 0 .06em .12em .00em #0013;
  transition: all .1s 0s ease-out;
}

input:indeterminate~#thumbs {
  opacity: 0;
}

input:disabled~#thumbs {
  box-shadow: 0 0 .03em .01em #0015;
}

input:checked~#thumbs {
  background: var(--x-thumbs);
}

input:checked~#thumbs {
  transform: translateX(.8em);
}

#effects {
  position: absolute;
  left: -.5em;
  top: -.5em;
  display: noneblock;
  width: 2em;
  height: 2em;
  border-radius: 100%;
  background: #ccc0;
  transition: all .1s 0s ease-out;
}

input:checked~#effects {
  display: inline-block;
  left: .25em
}

input:hover:not(:disabled)~#effects {
  background: #ccc4;
}

ソース全体像はgithubを参照ください。

https://github.com/Harurow/zenn-sample-web-component

出来上がった画面のキャプチャ

画面キャプチャ

課題

画面キャプチャで背景が赤になっているところがあります。
ここはLabelタグ<label for="cb">を使っていて
チェックボックス<input type="checkbox" id="cb"> に対しての連携をしたかったのですが、解決できませんでした。

<label for="switch-1">ラベル/label>
<my-switch id="switch-1"></my-switch>

<label for="switch-2">
  囲んでみた
  <my-switch id="switch-2"></my-switch>
</label>

参考

https://ja.javascript.info/custom-elements

脚注
  1. githubにはもう一階層上の階層も含まれています ↩︎

Discussion