Polymer サンプルコード (3) SPA

# SPA (Single Page Application)

<img src="https://www.polymer-project.org/images/logos/p-logo.png" height="26"> [Polymer](https://www.polymer-project.org/)でMarkdown形式のテキストを整形して表示する簡単な[SPA](https://ja.wikipedia.org/wiki/%E3%82%B7%E3%83%B3%E3%82%B0%E3%83%AB%E3%83%9A%E3%83%BC%E3%82%B8%E3%82%A2%E3%83%97%E3%83%AA%E3%82%B1%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3)を作ってみます。
サーバサイドの実行は<img src="https://www.gstatic.com/images/icons/material/product/2x/apps_script_64dp.png" height="26"> [Google Apps Script](https://developers.google.com/apps-script/) で、テキストの格納先として <img src="https://qiita-image-store.s3.amazonaws.com/0/90678/2624ae2d-fa33-9983-3046-0b2fa9743cec.png" height="26"> [Googleスプレッドシート](https://www.google.com/sheets/about/) を使用します。


※ [Polymer サンプルコード (2) FizzBuzz](http://qiita.com/howking/items/ba630eb8b0e4335944ec) の続きです。

<dl>
  <dt>動作確認</dt>
  <dd>Windows 10 : ○ Chrome60 Firefox54, △ Edge40, × IE11</dd>
  <dd>Mac 10.3 : ○ Chrome60 Safari10.1</dd>
  <dd>Android 7.1 : △ Chrome60</dd>
</dl>

## 画面イメージ

![qiita-gas-spa1](https://qiita-image-store.s3.amazonaws.com/0/90678/1d485111-f6e8-902e-c2f6-8fec1f805f43.gif)

[:globe_with_meridians: デモサイト (AppsScript)](https://script.google.com/macros/s/AKfycby3_Lc5YLBYlHy_6kamPftuTP10LSQkNATEh-f6pyo5t-v6Qjk/exec)
[:globe_with_meridians: データ (Spreadsheet)](https://docs.google.com/spreadsheets/d/15gqzpORd_fQCpfbU9anC08FOpE7q805JE6XdrQE9ZdQ/edit#gid=0)

## サンプルコード

[Google Drive](https://drive.google.com/drive/my-drive)でスプレッドシートを作成し、「ツール」→「スクリプトエディタ」からApps Scriptを編集、上部メニューの`公開`から`ウェブ アプリケーションとして導入`を選択して実行します。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/90678/d441fb7f-4fd8-04fa-ffd3-3b596f227588.png)

![image.png](https://qiita-image-store.s3.amazonaws.com/0/90678/69312782-3190-430e-7edd-dfcd7fdabdd0.png)



- `コード.gs` AppsScriptの本体

```js:コード.gs
/**
 * @OnlyCurrentDoc
 */

function doGet(e) {
  return HtmlService.createTemplateFromFile('index').evaluate()
  .setTitle('Polymer Simple CMS')
  .addMetaTag('viewport', 'width=device-width, initial-scale=1')
}

function getPages(){
  return SpreadsheetApp
  .getActive()
  .getSheetByName("シート1")
  .getRange("A2:B20")
  .getValues()
  .filter(function(c){ return c[0] })
  .map(function(c){ return { key:c[0], title:c[1]} })
}

function getContents(key) {
  return SpreadsheetApp
  .getActive()
  .getSheetByName("シート1")
  .getRange("A2:C20")
  .getValues()
  .filter(function(c){ return key == c[0] })
  .pop()[2]
}
```

- `index.html` `<my-view>`タグの呼出し元

```html:index.html
<base href="https://polygit.org/components/" target="_top">
<script src="webcomponentsjs/webcomponents-loader.js"></script>
<?!= HtmlService.createHtmlOutputFromFile('my-view').getContent() ?>
<my-view></my-view>
```

- `my-view.html` `<my-view>`タグを表示するHTML

```html:my-view.html
<link rel="import" href="polymer/polymer-element.html">
<link rel="import" href="polymer/lib/elements/dom-repeat.html">
<link rel="import" href="app-layout/app-layout.html">
<link rel="import" href="iron-icons/iron-icons.html">
<link rel="import" href="paper-icon-button/paper-icon-button.html">
<link rel="import" href="paper-listbox/paper-listbox.html">
<link rel="import" href="paper-item/paper-item.html">
<link rel="import" href="marked-element/marked-element.html">

<dom-module id="my-view">
  <template strip-whitespace>
    <style>
      :host {
        display: block;
        --app-primary-color: #29549a;
      }
      app-header {
        background-color: var(--app-primary-color);
        color: white;
      }
      app-header paper-icon-button {
        --paper-icon-button-ink-color: white;
      }
      div[main-title] { margin-left: 10px; }
      marked-element { margin-left: 15px; }
      paper-item:not(.iron-selected) { cursor: pointer; }
    </style>
    
    <app-drawer-layout force-narrow fullbleed>
    
      <app-drawer slot="drawer" swipe-open>
        
        <app-header fixed>
          <app-toolbar>
            <paper-icon-button icon="close" drawer-toggle></paper-icon-button>
          </app-toolbar>
        </app-header>
        
        <paper-listbox selected="{{key}}" attr-for-selected="key" selected-attribute="drawer-toggle">
          <template is="dom-repeat" items="[[_pages]]">
            <paper-item key="[[item.key]]">[[item.title]]</paper-item>
          </template>
        </paper-listbox>
      
      </app-drawer>
      
      <app-header-layout has-scrolling-region>
        
        <app-header slot="header" reveals>
          <app-toolbar>
            <paper-icon-button icon="menu" drawer-toggle></paper-icon-button>
            <div main-title>My app</div>
            <paper-icon-button icon="delete"></paper-icon-button>
            <paper-icon-button icon="search"></paper-icon-button>
            <paper-icon-button icon="close"></paper-icon-button>
          </app-toolbar>
        </app-header>
        
        <marked-element markdown="[[contents]]">
          <div slot="markdown-html"></div>
        </marked-element>
        
      </app-header-layout>
      
    </app-drawer-layout>
  </template>
  
  <script>
   window.addEventListener('WebComponentsReady', ()=>{
    class MyView extends Polymer.Element {
      static get is() { return 'my-view' }
      static get properties() {
        return {
          key: {
            type: String,
            value: "view1",
            observer: '_keyChanged'
          }
        }
      }
      ready() {
        super.ready()
        google.script.run.withSuccessHandler(c=>this._pages=c).getPages()
      }
      _keyChanged(key) {
        if (key) google.script.run.withSuccessHandler(c=>this.contents=c).getContents(key)
      }
    }
    customElements.define(MyView.is, MyView)
   })
  </script>
</dom-module>
```

### ページ表示の流れ

1. メニューの`<paper-item key="view2">Page 2`をクリックすると`{{key}}`に`view2`がセットされます。
2. `{{key}}`が更新されると`_keyChanged(key)`が呼ばれます。
3. `コード.gs`で定義したサーバサイドの関数 `getContents(key)` が呼ばれ、`view2`に対応したテキストがAppsScript経由で取得されます。
4. テキストを`withSuccessHandler`で受け取り、`{{contents}}`にセットします。
5. `{{contents}}`が`<marked-element>`によって整形されて表示されます。

## 各行の説明

### コード.gs

```js:コード.gs
/**
 * @OnlyCurrentDoc
 */
```

`@OnlyCurrentDoc`は、Polymerの動作とあまり関係ありませんが、AppsScriptでスプレッドシートへの権限を限定させる[アノテーション](https://developers.google.com/apps-script/guides/services/authorization#manual_authorization_scopes_for_sheets_docs_and_forms)です。

```js:コード.gs
function doGet(e) {
  return HtmlService.createTemplateFromFile('index').evaluate()
  .setTitle('Polymer Simple CMS')
  .addMetaTag('viewport', 'width=device-width, initial-scale=1')
}
```

Apps Scriptでは`doGet()`という決められた名前で関数を定義し、テンプレートファイル(`index.html`)を指定すると、その内容をブラウザで表示できます。
ただ、表示されるHTMLではタグが制限されているので、`.setTitle()`や`.addMetaTag()`でタイトルや`<meta>`タグを設定します。

```js:コード.gs
function getPages(){
  return SpreadsheetApp
  .getActive()
  .getSheetByName("シート1")
  .getRange("A2:B20")
  .getValues()
  .filter(function(c){ return c[0] })
  .map(function(c){ return { key:c[0], title:c[1]} })
}

function getContents(key) {
  return SpreadsheetApp
  .getActive()
  .getSheetByName("シート1")
  .getRange("A2:C20")
  .getValues()
  .filter(function(c){ return key == c[0] })
  .pop()[2]
}
```

Polymerから呼び出す用の、スプレッドシートからページキーの一覧とキー文字列で内容を取得する関数を定義しています。

### index.html

```html:index.html
<base href="https://polygit.org/components/" target="_top">
<script src="webcomponentsjs/webcomponents-loader.js"></script>
```

ライブラリの読み込みを簡潔にする為、`<base>`タグで集約し、[Web Components](https://www.webcomponents.org/)のポリフィルを読み込みます。
また、Apps Scriptで外部へのリンクを貼るには`<base>`タグに`target="_top"`を設定しておきます。

```html:index.html
<?!= HtmlService.createHtmlOutputFromFile('my-view').getContent() ?>
<my-view></my-view>
```

`<my-view>`タグを定義した`my-view.html`を読み込み、表示します。HTML Importsを使えればですが、AppsScriptでは外部ファイルを呼出しにくいので、テンプレートの内容を書き出すようにします(`<?!= ... ?>`の[`!(感嘆符)`を入れる必要がある](https://developers.google.com/apps-script/guides/html/templates#force-printing_scriptlets)ので注意です)。

### my-view.html

```html:my-view.html
<link rel="import" href="polymer/polymer-element.html">
<link rel="import" href="polymer/lib/elements/dom-repeat.html">
```

まずタグ(カスタム要素)を定義するのに`polymer/polymer-element.html`を読み込みます([前回説明](http://qiita.com/howking/items/ba630eb8b0e4335944ec#fizz-buzzhtml))。
ここで`<dom-module>`という定義用のタグと`<dom-repeat>`という繰り返し処理のタグが使えるようになります。

```html:my-view.html
<link rel="import" href="app-layout/app-layout.html">
```

Webアプリケーション用のレイアウトライブラリである[App Layout](https://polymerelements.github.io/app-layout/)を読み込みます。
`<app-drawer-layout>`と`<app-drawer>`で左メニュー、`<app-header-layout>`、`<app-header>`、`<app-toolbar>`で上部ツールバーを表示します。

> 上記は省略して書いていますが、ちゃんと書く場合は余計なタグを読み込まれないように個別指定します。
> 
> ```html
> <link rel="import" href="app-layout/app-header-layout/app-header-layout.html">
> <link rel="import" href="app-layout/app-drawer-layout/app-drawer-layout.html">
> <link rel="import" href="app-layout/app-header/app-header.html">
> <link rel="import" href="app-layout/app-toolbar/app-toolbar.html">
> <link rel="import" href="app-layout/app-drawer/app-drawer.html">
> ```

```html:my-view.html
<link rel="import" href="iron-icons/iron-icons.html">
<link rel="import" href="paper-icon-button/paper-icon-button.html">
<link rel="import" href="paper-listbox/paper-listbox.html">
<link rel="import" href="paper-item/paper-item.html">
```

`iron-icons`はアイコンセットで一覧は[こちら](https://www.webcomponents.org/element/PolymerElements/iron-icons/demo/demo/index.html)で確認できます。他、`<paper-icon-button>`でボタン、`<paper-listbox>`、`<paper-item>`でメニューリストを使用します。

```html:my-view.html
<link rel="import" href="marked-element/marked-element.html">
```

[Markdown](https://ja.wikipedia.org/wiki/Markdown)で書かれたテキストを変換するのにJSライブラリの[markd](https://github.com/chjj/marked)をラップした[marked-element](https://www.webcomponents.org/element/PolymerElements/marked-element)を使用します。

```html:my-view.html
<dom-module id="my-view">
  <template strip-whitespace>
    <style>
     ...
    </style>
    ...
  </template>
  <script>
   window.addEventListener('WebComponentsReady', ()=>{
    class MyView extends Polymer.Element {
      static get is() {
        return 'my-view'
      }
      ...
    }
    customElements.define(MyView.is, MyView)
   })
  </script>
</dom-module>
```

`<my-view>`タグを定義します。`<style>`タグは`<template>`タグの中に入れて使用します。
`window.addEventListener('WebComponentsReady', ()=>{})`はHTML Importを使わずChrome以外のブラウザに対応させる為に必要となります。

```css:my-view.html
      :host {
        display: block;
        --app-primary-color: #29549a;
      }
      app-header {
        background-color: var(--app-primary-color);
        color: white;
      }
      app-header paper-icon-button {
        --paper-icon-button-ink-color: white;
      }
      div[main-title] { margin-left: 10px; }
      marked-element { margin-left: 15px; }
      paper-item:not(.iron-selected) { cursor: pointer; }
```

CSSのセレクタで`:host`を指定すると、コンポーネント自体(`my-view`タグ)へのスタイル指定となります(例えば `:host { display: none; }` で`my-view`タグが表示されなくなります)。
`--app-primary-color`は[カスタムプロパティ](http://qiita.com/jtakgiuchi/items/b948f556bbe2fc780de0#%E3%82%AB%E3%82%B9%E3%82%BF%E3%83%A0%E3%83%97%E3%83%AD%E3%83%91%E3%83%86%E3%82%A3%E3%81%AEapi%E3%82%92%E4%BD%BF%E3%81%A3%E3%81%9F%E8%A6%81%E7%B4%A0%E3%81%AE%E3%82%B9%E3%82%BF%E3%82%A4%E3%83%AA%E3%83%B3%E3%82%B0)という機能で、 **CSSで変数として使えます**。
`<paper-icon-button>`のアイコンの色を変更する`--paper-icon-button-ink-color`など、すでに定義されているものは各タグの[仕様](https://www.webcomponents.org/element/PolymerElements/paper-icon-button)や[ソース](https://github.com/PolymerElements/paper-icon-button/blob/master/paper-icon-button.html#L102)をみながら利用します。
`div`タグや`marked-element`タグのセレクタ指定を ~~雑に~~ 簡潔にしていますが、**適切にコンポーネント化しておけば簡潔な指定が許容されるのはPolymerのよいところ**だと思います。

```html:my-view.html
    <app-drawer-layout force-narrow fullbleed>
    
      <app-drawer slot="drawer" swipe-open>
        <app-header fixed>
          <app-toolbar>
            <paper-icon-button icon="close" drawer-toggle></paper-icon-button>
          </app-toolbar>
        </app-header>
        ...
      </app-drawer>

    </app-drawer-layout>
```

左メニュー([app-drawer](https://www.webcomponents.org/element/PolymerElements/app-layout/elements/app-drawer))を使用するには`<app-drawer-layout>`タグで全体を囲み、メニュー部分は`<app-drawer>`で囲みます。
`<app-drawer slot="drawer">`の`slot`は`<app-drawer-layout>`で`drawer`という名前で定義された場所(slot)に入れる為に必要な指定です。
`drawer-toggle`属性があるタグはクリックでメニューの開閉となります。

```html:my-view.html
        <paper-listbox selected="{{key}}" attr-for-selected="key" selected-attribute="drawer-toggle">
          <template is="dom-repeat" items="[[_pages]]">
            <paper-item key="[[item.key]]">[[item.title]]</paper-item>
          </template>
        </paper-listbox>
```

左メニューの項目には`<paper-listbox>`を使用し、`attr-for-selected="key"`でクリックされた`<paper-item>`の`key`のデータが`selected="{{key}}"`にバインドされます。
`selected-attribute="drawer-toggle"`はメニューの項目の選択時に、`<paper-item>`へ`drawer-toggle`属性が付与され、メニューが閉じられます。
`dom-repeat`では`_pages`に格納されたページ一覧の情報をページ数分繰り返し処理をして表示しています。

> 例えば [スプレッドシード](https://docs.google.com/spreadsheets/d/15gqzpORd_fQCpfbU9anC08FOpE7q805JE6XdrQE9ZdQ/edit#gid=0)のA列(キー)に`view4`を入れて内容を登録すると
>
> ```html
>           <paper-item key="view4">新しいコンテンツ</paper-item>`
> ```
> が追加されて新しいページを表示することができます。

```html:my-view.html
      <app-header-layout has-scrolling-region>
        
        <app-header slot="header" reveals>
          <app-toolbar>
            <paper-icon-button icon="menu" drawer-toggle></paper-icon-button>
            <div main-title>My app</div>
            <paper-icon-button icon="delete"></paper-icon-button>
            <paper-icon-button icon="search"></paper-icon-button>
            <paper-icon-button icon="close"></paper-icon-button>
          </app-toolbar>
        </app-header>
        
        <marked-element markdown="[[contents]]">
          <div slot="markdown-html"></div>
        </marked-element>
        
      </app-header-layout>
```        

ツールバーをつくる為に、`<app-header-layout>`タグで全体を囲み、`<app-toolbar>`を`<app-header>`で囲みます(こちらも`slot="header"`が必要)。
`delete`、`search`、`close`アイコンは**飾り**です。
`<marked-element>`で`contents`変数に入ったMarkdownのテキストが直ちに整形されます。

```js:my-view.html
      static get properties() {
        return {
          key: {
            type: String,
            value: "view1",
            observer: '_keyChanged'
          }
        }
      }
      ready() {
        super.ready()
        google.script.run.withSuccessHandler(c=>this._pages=c).getPages()
      }
      _keyChanged(key) {
        if (key) google.script.run.withSuccessHandler(c=>this.contents=c).getContents(key)
      }
```

`<my-view>`タグの属性を定義します。属性に`value`を指定すると初期値となり、`observer`を定義するとその値が更新されるたびに指定したメソッド`_keyChanged(key)`が呼び出されます。
`_keyChanged(key)`では [google.script.run](https://developers.google.com/apps-script/guides/html/reference/run) を使用して`コード.gs`で作成した関数 `getContents(key)` を呼び出し、取得したテキストを`withSuccessHandler()`経由で`{{contents}}`にセットします。
`ready()`関数はカスタム要素の[初期処理で使えるコールバック関数](http://qiita.com/jtakiguchi/items/392ab42292b172830ff7#%E3%83%AF%E3%83%B3%E3%82%BF%E3%82%A4%E3%83%A0%E3%81%AE%E5%88%9D%E6%9C%9F%E5%8C%96)で、`super.ready()` を呼び出す必要があります。

## 最後に

**:bow: コメント、編集リクエスト歓迎**
[Polymer サンプルコード (4) PWA \[1\]](http://qiita.com/howking/items/27fec1598558045b74d1) に続きます。

以上