ng-modelのやや踏み込んだ話

`ng-model`を使うようなdirectiveを作るときのパターンを何個か紹介します。

# 対象

## Angularのversion

1.4系

## 対象な人

* `ng-model`が使えるdirectiveを作りたい
* `NgModelController`について知りたい

## 対象じゃない人

* `ng-model`自体の使い方を知りたい

# NgModelControllerのプロパティ、メソッド一覧

割りと説明なしで使うので[AngularJS: API: ngModel.NgModelController](https://docs.angularjs.org/api/ng/type/ngModel.NgModelController)を参照しながらみてください。

# Validators

`form.$error.foo`でエラーを受け取りたいなら、`ngModel.$validators.foo`にbooleanを返す関数を書きます。引数はそれぞれ`ngModel.$modelValue`, `ngModel.$viewValue`と同じものになります。formの詳しい情報は[formとの連携](#formとの連携)を参照して下さい。

```html
<form name="form">
  <input type="text" ng-model="hoge" name="hoge" foo>
  {{form.$error.foo}}
</form>
```

```js
app.directive('foo', () => {
  return {
    restrict: 'A',
    require: 'ngModel',
    priority: -1,
    link: (scope, element, attr, ngModel) => {
      ngModel.$validators.foo = (modelValue, viewValue) => (modelValue | viewValue) === 'foo';
    }
  };
});
```

※ `$validators.foo`に登録される関数は厳密にbooleanを返すべきです。例えば横着して`undefined`はfalsyだろと思ってそのまま返すとformがvalidでもinvalidでもない状態になってしまいます。その状態でformの送信を`form.$invalid`を見てガードしていたりするとすり抜けます。

# 装飾目的で標準のinputをラップする

イケてるUIを要素一つで表現したい等。

```html
<!-- コード上はこう書きたい -->
<awesome-checkbox ng-model="foo" ng-true-value="'TRUE'"></awesome-checkbox>
<!-- こう展開されて欲しい -->
<div class="awesome-checkbox">
  <input type="" ng-model="foo" ng-true-value="'TRUE'">
  ...
</div>
```

やり方は単純で、compileするときに`ng-model`など、使用するinput要素の属性にコピーするだけです。

例: [ionic/checkbox.js at master · driftyco/ionic](https://github.com/driftyco/ionic/blob/master/js%2Fangular%2Fdirective%2Fcheckbox.js)より

```js
/**
 * @ngdoc directive
 * @name ionCheckbox
 * @module ionic
 * @restrict E
 * @codepen hqcju
 * @description
 * The checkbox is no different than the HTML checkbox input, except it's styled differently.
 *
 * The checkbox behaves like any [AngularJS checkbox](http://docs.angularjs.org/api/ng/input/input[checkbox]).
 *
 * @usage
 * ```html
 * <ion-checkbox ng-model="isChecked">Checkbox Label</ion-checkbox>
 * ```
 */

IonicModule
.directive('ionCheckbox', ['$ionicConfig', function($ionicConfig) {
  return {
    restrict: 'E',
    replace: true,
    require: '?ngModel',
    transclude: true,
    template:
      '<label class="item item-checkbox">' +
        '<div class="checkbox checkbox-input-hidden disable-pointer-events">' +
          '<input type="checkbox">' +
          '<i class="checkbox-icon"></i>' +
        '</div>' +
        '<div class="item-content disable-pointer-events" ng-transclude></div>' +
      '</label>',
    compile: function(element, attr) {
      var input = element.find('input');
      // compileでng-modelなどのattributeを追加する
      forEach({
        'name': attr.name,
        'ng-value': attr.ngValue,
        'ng-model': attr.ngModel,
        'ng-checked': attr.ngChecked,
        'ng-disabled': attr.ngDisabled,
        'ng-true-value': attr.ngTrueValue,
        'ng-false-value': attr.ngFalseValue,
        'ng-change': attr.ngChange,
        'ng-required': attr.ngRequired,
        'required': attr.required
      }, function(value, name) {
        if (isDefined(value)) {
          input.attr(name, value);
        }
      });
      var checkboxWrapper = element[0].querySelector('.checkbox');
      checkboxWrapper.classList.add('checkbox-' + $ionicConfig.form.checkbox());
    }
  };
}]);
```

# 見た目($viewValue)とモデル($modelValue)を分離する

input要素上では文字列で入力するが、コード上はオブジェクト等にしたい等。

**実装すべきもの**

* `ngModel.$formatters`に`ngModel.$modelValue`から`ngModel.$viewValue`へ変換する関数を追加する。
* `ngModel.$parsers`に`ngModel.$viewValue`から`ngModel.$modelValue`へ変換する関数を追加する。

**あると嬉しいもの**

* `ngModel.$validators.hoge`

例:見た目`'YYYY/MM/DD'`、値がmoment [Plunkerでみる](http://plnkr.co/edit/VMUOHMJniTezyRhWtxuK?p=preview)

```html
<input type="text" ng-model="date" moment-input>
```

```js
app.directive('momentInput', () => {
  return {
    priority: -1, // ngModelのpriorityが0なので-1以下にする
    restrict: 'A',
    require: '?ngModel',
    controller: function($element) {
      var ngModel = $element.controller('ngModel');
      if (!ngModel) return;
      // $modelValue to $viewValue
      ngModel.$formatters.push(value => value && moment(value).format('YYYY/MM/DD'));
      // $viewValue to $modelValue
      ngModel.$parsers.push(value => value && moment(value, 'YYYY/MM/DD', true));
      // ついでにvalidate
      ngModel.$validators.date = modelValue => {
        if (!modelValue) return true;
        return modelValue.isValid();
      };
    }
  }
})
```

# 中身が複雑なdirectiveを隠ぺいする

とにかくデータのやりとりは`ng-model`でやりたい、他のライブラリとかをラップしたい等。インターフェースがAngular標準のdirectiveと同じになるので使い方を共有しやすい。

**実装すべきもの**

* directive内部で値が更新したときに`ngModel.$setViewValue`でngModelの値を明示的に更新する処理を書く。
* `ngModel.$render`、(`ngModel.$formatters`でもいいかも？)に外部から値が変更されたときの処理を書く。

**あると嬉しいもの**

* `ngModel.$isEmpty`に空判定を書けば`required`が(`length`プロパティを持っている場合は`maxlength`、`minlength`も)効く。
* `ngModel.$validators.hoge`

例: チェックボックスが複数あって、値を配列で扱う [Plunkerでみる(上のURLと同じものです)](http://plnkr.co/edit/VMUOHMJniTezyRhWtxuK?p=preview)

```html
<checkbox-group ng-model="arr" items="items" required minlength="2" maxlength="3"></checkbox-group>
```


```js
app.directive('checkboxGroup', () => {
  return {
    scope: {},
    priority: -1,
    restrict: 'E',
    require: '?ngModel',
    bindToController: {
      items: '='
    },
    template: `
    <div>
      <label for="checkbox-{{$id}}" ng-repeat="item in ctrl.items">
        <input id="checkbox-{{$id}}" type="checkbox" ng-model="item.selected" ng-change="ctrl.update()">{{item.label}}
      </label>
    </div>
    `,
    controller: function($element) {
      var ngModel = $element.controller('ngModel');
      if (!ngModel) return;
      
      // 空判定
      ngModel.$isEmpty = value => {
        return !(angular.isArray(value) && value.length);
      };
      // 外から更新された時の処理
      ngModel.$render = () => {
        var value = ngModel.$viewValue;
        if (!angular.isArray(value) || !angular.isArray(this.items)) return value;
        this.items.forEach(item => item.selected = false);
        value.forEach(v => this.items.some(item => item.selected = v === item.value));
        return value;
      };
      // 内部で更新された時の処理
      this.update = () => {
        ngModel.$setViewValue(this.items.filter(item => item.selected).map(item => item.value));
      };
    },
    controllerAs: 'ctrl'
  }
})
```

**ng-modelにオブジェクトを設定したときの注意点**

`ng-model`に設定された値(`ng-model="ctrl.foo"`とします)がinvalidとなる場合、`ctrl.foo`が`undefined`になります。この挙動は割と困る場合が多いかと思われます。`ng-model-options`の`allowInvalid`を`true`とすることで防ぐことができます。

```html
<div ng-model-options="{allowInvalid: true}">
  <hoge ng-model="ctrl.foo"></hoge>
  <div>{{ctrl.foo.firstName}} {{ctrl.foo.lastName}}</div>
</div>
```

# formとの連携

`name`属性を付けるだけ。`FormController`から辿れるようになります。下の例の場合は`hoge.foo`、`hoge.bar`。form全体のvalidationは`name`がなくても適用されます。`ngModel.$validators`をしっかり作っておくと、controllerでのvalidationや、viewにエラーを表示する部分で楽できます。

```html
<form name="hoge">
  <checkbox-group name="foo" ng-model="foo" items="items" required></checkbox-group>
  <input type="text" name="bar" ng-model="moment" moment-input>
</form>
```

# ng-changeとの連携

`ng-model`を使うともれなく`ng-change`を使えるようになります。ただし、`ng-model`にオブジェクトが設定されているときはプロパティだけ変えても何も起こりません(`!==`で判定してる)。