WebComponentでSwitchコンポーネントを作ってみた
こんなのを作りました
過去に作ったChromeの拡張を修正する過程でWebComponentに興味が出てきたのでSwitch型のUIコンポーネントを作ってみました。
ソース
この記事で作ったソースはGitHubで公開しています。MITライセンスです。
今回作るもの
チェックボックスに代わる Switch型のUIが欲しかったのでSwitchコンポーネントを作りました。形は以下参考。
Material UI では素敵なUIが沢山ありますが、React前提なので今回は生のJavaScriptで作ります。
全体像
とりあえずコンポーネントを試したいのでファイル構成[1]は以下、
src
+- components
| +- switch
| | +- style.css
| | +- switch.js
| +- extendCustomProperties.js
+- css
| +- style.css
+- index.html
まず外枠を作ってみる
class MySwitch extends HTMLElement {
... 略 ...
}
customElements.define('my-switch', MySwitch)
これだけでHTMLで<my-switch></my-switch>
として記述できます。
... 略 ...
<my-switch></my-switch>
... 略 ...
中身を書く
テンプレートとして以下を利用、
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">
をレンダリングします。
... 略 ...
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
の属性を追加します。
...略...
/**
* 監視対象のタグ名を配列で返す
*/
static get observedAttributes () {
return ['checked', 'value', 'disabled', 'indeterminate']
}
...略...
これでchecked
, value
, disabled
, indeterminate
の属性が変更されたときに、attributeChangedCallback
メソッドが呼び出されます。attributeChangedCallback
ではrender
を呼び出していますので属性の変更があると再レンダリングされます。
属性にアクセスするプロパティ
属性にアクセスするためのプロパティも定義します。
/**
* 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')
}
}
属性をレンダリングに利用する
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
イベントを発火します。
今回のコンポーネントでも同様のイベントを発火します。
イベントのcomposed
をtrue
に変更します。
また元のcomposed
がfalse
の場合は発火させません。
/**
* changeイベントの発行
* @param {Event} e
*/
fireChangeEvent(e) {
if (e.composed === false) {
const event = new CustomEvent('change', {
composed: true,
})
this.dispatchEvent(event)
}
}
change
イベントを紐づける
checkboxのcheckboxの値が変更されたときにfireChangeEvent
を発火させます。
render () {
...略...
checkbox.addEventListener('change', this.fireChangeEvent)
}
色について
テーマにも対応したいので、外のCSSで指定されたカスタムプロパティを継承したい。
一旦 primary
, secondary
に連なる変数が定義されていると仮定して作る。
:root {
--primary: #0277bd;
--primary-light: #58a5f0;
--primary-dark: #004c8c;
--secondary: #ff9800;
--secondary-light: #ffc947;
--secondary-dark: #c66900;
}
色変数(カスタムプロパティ)のバリエーションについて
色の変数--primary
から--primary-rgb
を自動的に生成して利用しています。
定義では --primary
しか定義していませんが、自動的に--primary-rgb
を追加しています。
#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
を利用します。
render () {
...略...
const color = this.getAttribute('color') ?? 'primary'
...略...
><span id="root" class="${color}"${rootCss}
...略...
}
Switchの形を整える
今回はサイズをフォントベースで考えます。フォントのサイズを大きくするとSwitchも大きくなります。
他には特に説明箇所がないので一気に最終段階のソースを記載します。
#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コンポーネントが出上がりました。
主なソースの全体を掲載します。
/**
* スイッチの形したチェックボックス
*/
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)
#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を参照ください。
出来上がった画面のキャプチャ
課題
画面キャプチャで背景が赤になっているところがあります。
ここは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>
参考
-
githubにはもう一階層上の階層も含まれています ↩︎
Discussion