Angularの公式チュートリアル:第3章「Master/Detail」

第3章では、複数のヒーローをリスト表示し、ユーザがヒーローを選択すると詳細を表示する仕組みを作ります。 元の記事はこちら

ウェブサーバの起動

(既に実施していたら不要です)

cd ~/angular-tour-of-heroes
ng serve --host=0.0.0.0 --public=グローバルIP

ヒーローの名前の定義

app.component.tsを編集し、次の定数を追加します:

const HeRoEs: Hero[] = [
  { id: 11, name: 'ビアンカ' },
  { id: 12, name: 'フローラ' },
  { id: 13, name: 'デボラ'   }
];

追加する場所は、app.component.ts@Componentの上にします。追加後のソースはこちら:

import { Component } from '@angular/core';

export class Hero {
  id: number;
  name: string;
}

const HeRoEs: Hero[] = [
  { id: 11, name: 'ビアンカ' },
  { id: 12, name: 'フローラ' },
  { id: 13, name: 'デボラ'   }
];

@Component({
  selector: 'app-root',
  template: `<h1>{{title}}</h1>
    <div>
      <input [(ngModel)]="hero.name">
    </div>
    <div><label>id: </label>{{hero.id}}</div>
    <div><label>name: </label>{{hero.name}}</div>
  `,
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  title = 'Tour of Heroes';
  hero: Hero = {
    id: 1,
    name: 'Windstorm'
  };
}

HeRoEsは、Hero型の配列です。Heroは前章において定義したクラスです。このアプリは、いずれはウェブサービスからヒーローのデータを取得するようにしますが、現時点ではモックのヒーローを読み込みます。

次に、ヒーローを外部とバインド可能にする変数をAppComponentに定義します:

heroes = HeRoEs; // heroes: ではなく heroes = である点に注意

heroesに型の定義はありません。なぜならば、HeRoEs配列より型を推論するためです。 この時点でのソースは次の通りです:

import { Component } from '@angular/core';

export class Hero {
  id: number;
  name: string;
}

const HeRoEs: Hero[] = [
  { id: 11, name: 'ビアンカ' },
  { id: 12, name: 'フローラ' },
  { id: 13, name: 'デボラ'   }
];

@Component({
  selector: 'app-root',
  template: `<h1>{{title}}</h1>
    <div>
      <input [(ngModel)]="hero.name">
    </div>
    <div><label>id: </label>{{hero.id}}</div>
    <div><label>name: </label>{{hero.name}}</div>
  `,
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  title = 'Tour of Heroes';
  hero: Hero = {
    id: 1,
    name: 'Windstorm'
  };
  heroes = HeRoEs;
}

ヒーローのデータはクラスの実装から分けています。なぜかというと、最終的にはヒーローの名前はデータのサービスから取得するためです。

次に、ヒーローの名前の一覧を表示します。表示するには、ヒーローの名前の配列をテンプレートにバインドし、それらに対してイテレーションしてひとつひとつを表示します。app.component.tstemplateに次のようにHTMLを追加します:

  template: `<h1>{{title}}</h1>
    <h2>私のヒーロー一覧</h2>
    <ul class="heroes">
      <li *ngFor="let hero of heroes">
        <span class="badge">{{hero.id}}</span>{{hero.name}}
      </li>
    </ul>

ngForのプレフィックス(*)は、重要なポイントです。これは<li>とその子要素がテンプレートのマスターであることを示しています。 ngForディレクティブはヒーローの配列をイテレーションし、配列の中の1つ1つのヒーローに対してテンプレートのインスタンスを描画します。

let heroの部分は、heroをテンプレートの入力変数として明示しています。この変数はイテレーションにおける現在のヒーローの値を保持しています。テンプレートの中からこの変数を参照することによりヒーローの属性をアクセスできます。

この内容で保存し、ブラウザをリロードすると、次の画面が表示されるはずです:

f:id:thebaker:20171008191644p:plain

次に、デザインを変更します。目的は、マウスカーソルをホバーしたことが分かるようにすること、および、どのヒーローが選択されているかを分かるようにすること。 元のチュートリアルではapp.component.tsの中にCSSを埋め込んでいますが、これですとソースが長くなってしまうのでここでは別ファイルに切り出します。 app.component.cssというファイルがあるので開き、次のコードをペーストし保存します:

.selected {
  background-color: #CFD8DC !important;
  color: white;
}
.heroes {
  margin: 0 0 2em 0;
  list-style-type: none;
  padding: 0;
  width: 15em;
}
.heroes li {
  cursor: pointer;
  position: relative;
  left: 0;
  background-color: #EEE;
  margin: .5em;
  padding: .3em 0;
  height: 1.6em;
  border-radius: 4px;
}
.heroes li.selected:hover {
  background-color: #BBD8DC !important;
  color: white;
}
.heroes li:hover {
  color: #607D8B;
  background-color: #DDD;
  left: .1em;
}
.heroes .text {
  position: relative;
  top: -3px;
}
.heroes .badge {
  display: inline-block;
  font-size: small;
  color: white;
  padding: 0.8em 0.7em 0 0.7em;
  background-color: #607D8B;
  line-height: 1em;
  position: relative;
  left: -1px;
  top: -4px;
  height: 1.8em;
  margin-right: .8em;
  border-radius: 4px 0 0 4px;
}

このファイルはapp.component.tsstyleUrlsで読み込みを指定されています。ブラウザをリロードすると、スタイルが適用されているのが分かると思います:

f:id:thebaker:20171008201557p:plain

なお、このスタイルは、スタイルを定義したコンポーネント、この場合はapp.component.tsにのみ適用されます。その他のHTMLには影響がありません。

さて、現時点ではリスト内のヒーローと、その下のヒーローの紹介が紐付けられていません。そこで、ユーザがリストからヒーローを選択したら、 そのヒーローの詳細が下に表示される処理を実装します。これはmaster/detailというUIパターンです。masterとは、ヒーローの一覧を指し、 detailとはユーザが選択したヒーローを示します。

masterとdetailは、selectedHeroという属性を通じて関連付けます。そしてこの属性をクリックイベントと紐付けます。

まず、<li>タグに次のようにクリックイベントをバインドします:

<li *ngFor="let hero of heroes" (click)="onSelect(hero)">

丸括弧は、<li>タグのclickイベントをターゲットとして識別します。onSelect(hero)式はAppComponentonSelect()メソッドを呼び出し、その際、heroを引数として渡します。このheroは、以前にngForディレクティブで定義したheroと同じです。

さて、AppComponentクラスは現在hero属性を持っていますが、今後はユーザが一覧から選択したヒーロを表示するのでこの属性は不要になります。代わりにselectedHero属性を設けます:

- hero: Hero = {
-   id: 1,
-   name: 'Windstorm'
- };
+ selectedHero: Hero;
}

ユーザがクリックするまでヒーローの詳細表示は不要なので、selectedHeroは宣言するだけで初期化は行いません。

次にonSelect()メソッドを追加して、selectedHero属性をユーザがクリックしたheroに設定します:

onSelect(myhero: Hero): void {
  this.selectedHero = myhero;
}

さて、現時点ではテンプレートは古いheroとバインドしています。そこで今回作成したselectedHeroに代わりにバインドさせます:

  template: `<h1>{{title}}</h1>
    <h2>私のヒーロー一覧</h2>
    <ul class="heroes">
      <li *ngFor="let hero of heroes" (click)="onSelect(hero)">
        <span class="badge">{{hero.id}}</span>{{hero.name}}
      </li>
    </ul>
    <div>
      <input [(ngModel)]="selectedHero.name">
    </div>
    <div><label>id: </label>{{selectedHero.id}}</div>
    <div><label>name: </label>{{selectedHero.name}}</div>
  `,

この時点でブラウザをリロードするとヒーロー一覧が表示されないはずです。 アプリが読み込まれた時点ではselectedHeroは定義されていません。 定義されていないのにselectedHero.nameを参照しているため、エラーが出ているのです:

f:id:thebaker:20171008203503p:plain

ヒーローが選択されるまでselectedHeroへの参照をDOMに含めてはなりません。そこで、selectedHeroが値をもってない場合はDOMから除外するよう、 <div>で括り、<div>*ngIfのディレクティブを付与します:

  template: `<h1>{{title}}</h1>
    <h2>私のヒーロー一覧</h2>
    <ul class="heroes">
      <li *ngFor="let hero of heroes" (click)="onSelect(hero)">
        <span class="badge">{{hero.id}}</span>{{hero.name}}
      </li>
    </ul>
    <div *ngIf="selectedHero">
      <div>
        <input [(ngModel)]="selectedHero.name">
      </div>
      <div><label>id: </label>{{selectedHero.id}}</div>
      <div><label>name: </label>{{selectedHero.name}}</div>
    </div>
  `,

これでブラウザをリロードすると次のようになります:

f:id:thebaker:20171008203848p:plain

ユーザがヒーローを選択してない場合、ngIfディレクティブは子要素をDOMから除外します。 ユーザがヒーローを選択すると、子要素をDOMに含めネストされたバインディングを評価します。

次に、ユーザが選択したヒーローがどれであるかをわかりやすくします。 app.component.tsのテンプレートにおいて、<li>に次のバインドを追加します:

[class.selected]="hero === selectedHero"

hero===selectedHerotrueの場合はCSSとしてselectedクラスを付与します。 falseの場合はクラスを除きます。

ブラウザをリロードし、ヒーローを選択するとそのヒーローの色が変わるはずです:

f:id:thebaker:20171008204512p:plain

本章では次のことを行いました: * ヒーローの一覧を表示する * ヒーローを選択可能にし、選択したヒーローの詳細を表示する * ngIfngForディレクティブの利用方法を学習

参考までに、現時点のソースを記載します:

import { Component } from '@angular/core';

export class Hero {
  id: number;
  name: string;
}

const HeRoEs: Hero[] = [
  { id: 11, name: 'ビアンカ' },
  { id: 12, name: 'フローラ' },
  { id: 13, name: 'デボラ'   }
];

@Component({
  selector: 'app-root',
  template: `<h1>{{title}}</h1>
    <h2>私のヒーロー一覧</h2>
    <ul class="heroes">
      <li *ngFor="let hero of heroes" (click)="onSelect(hero)" [class.selected]="hero === selectedHero">
        <span class="badge">{{hero.id}}</span>{{hero.name}}
      </li>
    </ul>
    <div *ngIf="selectedHero">
      <div>
        <input [(ngModel)]="selectedHero.name">
      </div>
      <div><label>id: </label>{{selectedHero.id}}</div>
      <div><label>name: </label>{{selectedHero.name}}</div>
    </div>
  `,
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  title = 'Tour of Heroes';
  selectedHero: Hero;
  heroes = HeRoEs; // heroes: ではない点に注意

  onSelect(myhero: Hero): void {
    this.selectedHero = myhero;
  }
}