独自コンポーネントでng-modelを使い、値を変更・参照する方法。

AngularJS1を使ったウェブアプリで要素を選択させる際に通常のselectoptionを使っていたのだけど、環境によって見た目が異なる、CSS Transformでscaleを使っていても無視して元のフォントサイズで描画される、スタイルを上手く指定できない、という問題があったため別の手段を使う必要があった。 Bootstrapのドロップダウンを使おうともしたんだけど、選択ボックスに選択された候補のテキストを当て込むようにしたい、そのため横幅を選択させる候補中の最大のものにしたい、それとBootstrapのドロップダウンは普通のインライン要素にできない?ので独自で実装することにした。

デモ

使用側

      <my-select items="['apple', 'banana', 'carrot']"
                 ng-model="$ctrl.value"
                 ng-change="$ctrl.onSelected()">
      </my-select>

仮にコンポーネントのタグをmy-selectとして、itemsに配列を渡すとその中から1つをユーザが選択できる。 選択された項目はng-modelで指定した変数に格納される。 変更はng-changeで通知される。

  • itemsの各要素は固定で、状態によって変わらないという前提
  • 選択された項目はそのインデクスがng-modelで指定した変数に格納されるものとする
    • 未選択の場合に-1を指定しておくことで、表示の位置調整を簡潔にする
  • 変更があったらng-changeが実行される

コンポーネントの実装

クラス定義:

class MySelect {
  constructor($element, $timeout) {
    this.$element = $element
    this.$timeout = $timeout

    this.ngModel = $element.controller('ngModel')  // Must be exist, since it is specified in `require`
    //this.ngModel.$render = () => {
    //  console.log(`$render: ${this.ngModel.$viewValue}`)
    //}

    this.popupStyle = {
      display: 'none',
      backgroundColor: 'white',
      left: '-1px',  // -1 for border
      opacity: 0,
    }
  }
  • $element.controller('ngModel')でタグの属性として指定されたngModelのコントローラを取得できる
    • 値の変更を通知するときに使用するため、保持しておく
  $onInit() {
    this.$timeout(() => {  // I don't know why this is needed...
      const widths = this.items.map(item => MySelect.calcTextWidth(this.$element[0], item))
      this.width = widths.reduce((acc, x) => Math.max(acc, x))
      const style = window.getComputedStyle(this.$element[0])
      const border = 2
      this.height = parseInt(style.height, 10) - border
    })
  }
  • 初期化でitemsの各要素の幅を調べ、一番広いものを選択ボックスの幅とする
  • 要素の高さは選択ボックスの高さから求める(ボーダーが1pxという前提…)
  • なぜ$timeoutで待たなければいけないのかよくわからない…

テキストの幅計算:

  static calcTextWidth(parent, text) {
    const span = document.createElement('span')
    parent.appendChild(span)
    span.style.cssText = 'visibility:hidden;position:absolute;white-space:nowrap'
    span.innerText = text
    const width = span.offsetWidth
    parent.removeChild(span)
    return width
  }
  • 埋め込み先の要素にテキストを生成し、幅を取得する
  • white-space:nowrapとした上でテキストを設定し、offsetWidthで取得

選択ボックスがクリックされたとき:

  onClick($event) {
    $event.stopPropagation()
    $event.preventDefault()
    if (this.popupStyle.display !== 'none')
      return this.closePopup()

    delete this.popupStyle.display
    const selectedIndex = this.ngModel.$viewValue
    this.popupStyle.top = `${-(selectedIndex * this.height) - 1}px`  // -1 for border

    // Fade in
    this.popupStyle.opacity = 0
    this.$timeout(() => {
      this.popupStyle.opacity = 1
    })

    const docClicked = () => {
      document.removeEventListener('click', docClicked)
      this.$timeout(() => {
        this.closePopup()
      })
    }
    document.addEventListener('click', docClicked)
  }
  • ポップアップを開くためにスタイルを変更
    • その際、フェードインさせるためopacityを操作(CSSでtransition-durationを指定しておく)
    • フェードインが効かないことがある…なぜだ
  • 現在選択されている項目がポップアップ内の項目と合うように、現在の値(this.ngModel.$viewValue)から位置を計算
  • ポップアップやその他どこかをクリックされた時に閉じるために、documentにクリックハンドラを設定する

項目をクリックされたとき:

  onClickItem($event, index) {
    $event.stopPropagation()
    $event.preventDefault()
    this.closePopup()

    this.ngModel.$setViewValue(index)
  }
  • ngModel$setViewValue()で、ng-modelで指定された変数に設定できる
  • イベントの伝播を止めてやらないと、クリックイベントが選択ボックスに渡って再度開いてしまう

ポップアップを閉じる

  closePopup() {
    this.popupStyle.opacity = 0
    this.$timeout(() => {
      this.popupStyle.display = 'none'
    }, 500)
  }
}
  • フェードアウトさせてから非表示にする

コンポーネント定義:

angular.module('mySelect', [])
  .component('mySelect', {
    restrict: 'AE',
    controller: ['$element', '$timeout', MySelect],
    require: 'ngModel',
    bindings: {
      items: '=',
    },
    templateUrl: './my_select.html',
  })
  • require: 'ngModel'で必須にしてやる
  • ngModelを扱うにはdirectiveを使い、linkでなんか処理する必要があるのかと思っていたが、componentだけでできた
  • ngModelを使用すれば自動的にngChangeが呼び出される?ようで、明示的に扱う必要はなかった

HTML:

<span class="select"
      ng-class="{empty:!$ctrl.getText()}"
      ng-style="{width:$ctrl.width+'px'}"
      ng-click="$ctrl.onClick($event)">
  <!-- select box -->
  <span class="text"
        ng-bind="$ctrl.getText()"></span>

  <!-- popup -->
  <div class="popup"
       ng-style="$ctrl.popupStyle">
    <div ng-repeat="item in $ctrl.items"
         class="item"
         ng-class="{selected:$index===$ctrl.ngModel.$viewValue}"
         ng-bind="item"
         ng-click="$ctrl.onClickItem($event, $index)"></div>
  </div>
</span>
  • 未指定の場合(テキストが空の場合)に見た目を変えられるように、emptyクラスを追加してやる
  • ポップアップ中の選択されている要素にはselectedクラスを追加する

SCSS:

my-select {
  display: inline-block;

  .select {
    position: relative;
    display: inline-block;
    padding: 8px;
    border: 1px solid gray;
    border-radius: 8px;
    background-color: white;
    box-sizing: content-box;
    white-space: nowrap;
    text-align: center;
    cursor: pointer;
    transition-duration:0.25s;

    .text {
      display: inline-block;
    }

    .popup {
      position: absolute;
      border: 1px solid gray;
      border-radius: 8px;
      overflow: hidden;
      box-shadow: 0 0 40px rgba(0,0,0,1.0);
      opacity: 0;
      transition-duration:0.25s;

      .item {
        padding:8px;
        color: black;
        transition-duration:0.25s;
      }

      .item:hover {
        background: cyan;
        color: black;
      }

      .item.selected {
        background: blue;
        color: white;
      }
    }
  }

  .select.empty {
    background: red;
  }

  .select:hover {
    background: blue;
    color: white;
  }
}
  • 選択ボックス、ポップアップなどはdisplay: inline-blockを指定
  • 選択ボックスをposition:relativeにしてポップアップをposition:absoluteにすることで、ポップアップを選択ボックスからの相対位置で指定できるようにする
  • ポップアップ要素のhtmlは選択ボックス内の後の方に記述することで、z-indexを指定しなくても他の要素より手前に描画されている(かぶる位置にz-indexを指定している要素がない前提)