Nuxt.js + TypeScript のプロジェクトに CoreUI を手動クリーンインストール【後編】

[前編](https://qiita.com/xfan/items/50b64317e505559e3ef1)の続き。

https://coreui.io/vue/demo/3.0.0/legacy/#/dashboard
公式テンプレートのデモサイト↑ような、ヘッダー・フッター・サイドバーのあるレイアウトを組んでいく。

ただ、サイドバーは表示・非表示の切り替えと、あと画面全体をPCサイズで表示している場合は最小化が可能となっており、公式テンプレートはこのサイドバーの状態をVuexストアで管理しているので、まずは先にそっちの仕組みを作ってしまおうと思う。

# ストアとサイドバー状態管理

## 普通のストア利用設定
CoreUIとは関係のない、一般的なNuxt.js+TypeScript環境におけるストア利用方法については[Nuxtの公式ドキュメントにあるストアのページ](https://typescript.nuxtjs.org/ja/cookbook/store.html)を参考にしている。この記事ではクラスベースのアプローチを採用することにし、 `vuex-module-decorators` をインストールしておく。

```bash
npm i -D vuex-module-decorators
```

`vuex-module-decorators` をNuxt.jsで利用するときのマナーというか手法は https://github.com/championswimmer/vuex-module-decorators#accessing-modules-with-nuxtjs に紹介されているのだけど、 `~/utils/store-accessor.ts` の存在と、let宣言した変数をそのままエクスポートしている実装がキモく感じたので、すこし形を変えさせてもらった。でもたいした違いはない。

ただ、自分はVueもNuxtもTypeScriptもド素人であるし、`~/utils/` 以下に記述を分けている事情をわかっていないだけかもしれない。

### サンプルとしてストアモジュールをひとつ作成
最終的には不要になるのだけれど、説明の手順上、ひとまずCoreUIとは完全に切り離してストアを利用するコードを書きたいので、そのためのサンプルモジュール `~/store/counter.ts` を作成する。 

```typescript
// ~/store/counter.ts
import { VuexModule, Module, Mutation } from 'vuex-module-decorators'

@Module({
  stateFactory: true,
  namespaced: true,
  name: 'count'
})
export default class Counter extends VuexModule {

  count: number = 0

  @Mutation
  increment() {
    this.count++
  }
}
```

### `~/store/index.ts`
続いて `~/store/index.ts` 。今後、新たなストアモジュールを追加したときは、そのモジュールをこのファイルにも追記していく。

```typescript
// ~/store/index.ts
import { Store } from 'vuex'
import { getModule } from 'vuex-module-decorators'
import Counter from '~/store/counter'

let counter: Counter

const initializer = (store: Store<any>): void => {
  counter = getModule(Counter, store)
}

export const plugins = [initializer]

export const store = new (class {
  get counter(): Counter {
    return counter
  }
})()
```

これで、Vueコンポーネント側では次のような記述でストアを利用することが可能になる。

```typescript
import { store } from '~/store/'

store.counter.increment()
```

## CoreUIサイドバー状態管理用モジュール追加
ここからは、一般的な話ではなくCoreUIに関する話に戻る。

CoreUIの公式テンプレートを参考に、サイドバーの状態管理用モジュールを書く。公式テンプレートの `src/store.js` ファイルの内容を `vuex-module-decorators` のマナーで書き直していく。

ついでに、状態をあらわす真偽値型変数名が `minimize` と動詞だったのがキモかったのですこし変えた。 `display` だって動詞っぽいじゃないかと言われそうだけど、これはCSSの `display` プロパティを連想するのでアリということにしとく。純粋な真偽値型でもないし。

```typescript
// ~/store/sidebar.ts
import { VuexModule, Module, Mutation } from 'vuex-module-decorators'

@Module({
  stateFactory: true,
  namespaced: true,
  name: 'sidebar'
})
export default class Sidebar extends VuexModule {

  display: string | boolean = 'responsive'
  minimized: boolean = false

  @Mutation
  setDisplay(value: string | boolean) {
    this.display = value
  }

  @Mutation
  setMinimized(value: boolean) {
    this.minimized = value
  }

  @Mutation
  toggleSidebarDesktop() {
    const opened = [true, 'responsive'].includes(this.display)
    this.display = opened ? false : 'responsive'
  }

  @Mutation
  toggleSidebarMobile() {
    const closed = [false, 'responsive'].includes(this.display)
    this.display = closed ? true : 'responsive'
  }
}
```

そして `~/store/index.ts` にこのモジュールを追加。やっぱりちょっと冗長。もうすこし短くはできると思うのでそこは好きに。下記のコードでは `Counter` も一応残しておくけれど、もちろん削除してくれてOK。

```typescript
// ~/store/index.ts
import { Store } from 'vuex'
import { getModule } from 'vuex-module-decorators'
import Counter from '~/store/counter'
import Sidebar from '~/store/sidebar'

let counter: Counter
let sidebar: Sidebar

const initializer = (store: Store<any>): void => {
  counter = getModule(Counter, store)
  sidebar = getModule(Sidebar, store)
}

export const plugins = [initializer]

export const store = new (class {
  get counter(): Counter {
    return counter
  }
  get sidebar(): Sidebar {
    return sidebar
  }
})()
```

まだ動作確認はできないけれど、これでサイドバーの状態管理は可能になったはず。

# 各種コンポーネントの追加
ヘッダー・フッター・サイドバーをVueコンポーネントとしてプロジェクトに追加していく。公式テンプレートの `src/containers` ディレクトリにある `TheHeader.vue`・`TheHeaderDropdownAccnt.vue`・`TheFooter.vue`・`TheSidebar.vue` の各ファイルをNuxtプロジェクトの `~/components/` 直下にコピーし、内容をTypeScript+vue-property-decoratorのマナーに書き換えていく。あとサイドバーについてはストアの状態名やミューテーションにすこし手を加えたので、それに合わせていく。

```vue
<!-- ~/components/Header.vue -->
<template>
  <CHeader fixed with-subheader light>
    <CToggler in-header class="ml-3 d-lg-none" @click="toggleSidebarMobile()" />
    <CToggler
      in-header
      class="ml-3 d-md-down-none"
      @click="toggleSidebarDesktop()"
    />
    <CHeaderBrand class="mx-auto d-lg-none" to="/">
      <CIcon name="logo" height="48" alt="Logo" />
    </CHeaderBrand>
    <CHeaderNav class="d-md-down-none mr-auto">
      <CHeaderNavItem class="px-3">
        <CHeaderNavLink to="/dashboard">Dashboard</CHeaderNavLink>
      </CHeaderNavItem>
      <CHeaderNavItem class="px-3">
        <CHeaderNavLink to="/users" exact>Users</CHeaderNavLink>
      </CHeaderNavItem>
      <CHeaderNavItem class="px-3">
        <CHeaderNavLink>Settings</CHeaderNavLink>
      </CHeaderNavItem>
    </CHeaderNav>
    <CHeaderNav class="mr-4">
      <CHeaderNavItem class="d-md-down-none mx-2">
        <CHeaderNavLink><CIcon name="cil-bell" /></CHeaderNavLink>
      </CHeaderNavItem>
      <CHeaderNavItem class="d-md-down-none mx-2">
        <CHeaderNavLink><CIcon name="cil-list" /></CHeaderNavLink>
      </CHeaderNavItem>
      <CHeaderNavItem class="d-md-down-none mx-2">
        <CHeaderNavLink><CIcon name="cil-envelope-open" /></CHeaderNavLink>
      </CHeaderNavItem>
      <HeaderDropdown />
    </CHeaderNav>
    <CSubheader class="px-3">
      <CBreadcrumbRouter class="border-0 mb-0" />
    </CSubheader>
  </CHeader>
</template>

<script lang="ts">
import { Vue, Component } from 'vue-property-decorator'
import HeaderDropdown from './HeaderDropdown.vue'
import { store } from '~/store/'

@Component({
  components: {
    HeaderDropdown
  }
})
export default class Header extends Vue {
  toggleSidebarMobile(): void {
    store.sidebar.toggleSidebarMobile()
  }
  toggleSidebarDesktop(): void {
    store.sidebar.toggleSidebarDesktop()
  }
}
</script>
```

```vue
<!-- ~/components/HeaderDropdown.vue -->
<template>
  <CDropdown
    in-nav
    class="c-header-nav-items"
    placement="bottom-end"
    add-menu-classes="pt-0"
  >
    <template #toggler>
      <CHeaderNavLink>
        <div class="c-avatar">
          <img src="/img/profile.jpg" class="c-avatar-img " />
        </div>
      </CHeaderNavLink>
    </template>
    <CDropdownHeader tag="div" class="text-center" color="light">
      <strong>Account</strong>
    </CDropdownHeader>
    <CDropdownItem>
      <CIcon name="cil-bell" /> Updates
      <CBadge color="info" class="ml-auto">{{ itemsCount }}</CBadge>
    </CDropdownItem>
    <CDropdownItem>
      <CIcon name="cil-envelope-open" /> Messages
      <CBadge color="success" class="ml-auto">{{ itemsCount }}</CBadge>
    </CDropdownItem>
    <CDropdownItem>
      <CIcon name="cil-task" /> Tasks
      <CBadge color="danger" class="ml-auto">{{ itemsCount }}</CBadge>
    </CDropdownItem>
    <CDropdownItem>
      <CIcon name="cil-comment-square" /> Comments
      <CBadge color="warning" class="ml-auto">{{ itemsCount }}</CBadge>
    </CDropdownItem>
    <CDropdownHeader tag="div" class="text-center" color="light">
      <strong>Settings</strong>
    </CDropdownHeader>
    <CDropdownItem><CIcon name="cil-user" /> Profile</CDropdownItem>
    <CDropdownItem><CIcon name="cil-settings" /> Settings</CDropdownItem>
    <CDropdownItem>
      <CIcon name="cil-dollar" /> Payments
      <CBadge color="secondary" class="ml-auto">{{ itemsCount }}</CBadge>
    </CDropdownItem>
    <CDropdownItem>
      <CIcon name="cil-file" /> Projects
      <CBadge color="primary" class="ml-auto">{{ itemsCount }}</CBadge>
    </CDropdownItem>
    <CDropdownDivider />
    <CDropdownItem>
      <CIcon name="cil-shield-alt" /> Lock Account
    </CDropdownItem>
    <CDropdownItem><CIcon name="cil-lock-locked" /> Logout</CDropdownItem>
  </CDropdown>
</template>

<script lang="ts">
import { Vue, Component } from 'vue-property-decorator'

@Component
export default class HeaderDropdown extends Vue {
  itemsCount: number = 42
}
</script>

<style scoped>
.c-icon {
  margin-right: 0.3rem;
}
</style>
```

```vue
<!-- ~/components/Footer.vue -->
<template>
  <CFooter :fixed="false">
    <div>
      <a href="https://coreui.io" target="_blank">CoreUI</a>
      <span class="ml-1">
        &copy; {{ new Date().getFullYear() }} creativeLabs.
      </span>
    </div>
    <div class="ml-auto">
      <span class="mr-1">Powered by</span>
      <a href="https://coreui.io/vue" target="_blank">CoreUI for Vue</a>
    </div>
  </CFooter>
</template>

<script lang="ts">
import { Vue, Component } from 'vue-property-decorator'

@Component
export default class Footer extends Vue {}
</script>
```

```vue
<!-- ~/components/Sidebar.vue -->
<template>
  <CSidebar
    fixed
    :minimize="minimized"
    :show="display"
    @update:show="(value) => (display = value)"
  >
    <CSidebarBrand class="d-md-down-none" to="/">
      <CIcon
        class="d-block"
        name="logo"
        size="custom-size"
        :height="35"
        :view-box="`0 0 ${minimized ? 110 : 556} 134`"
      />
    </CSidebarBrand>
    <CRenderFunction flat :content-to-render="nav" />
    <CSidebarMinimizer
      class="d-md-down-none"
      @click.native="minimized = !minimized"
    />
  </CSidebar>
</template>

<script lang="ts">
import { Vue, Component } from 'vue-property-decorator'
import nav from './_nav'
import { store } from '~/store/'

@Component
export default class Sidebar extends Vue {
  nav = nav
  get display() {
    return store.sidebar.display
  }
  set display(value: boolean | string) {
    store.sidebar.setDisplay(value)
  }
  get minimized() {
    return store.sidebar.minimized
  }
  set minimized(value: boolean) {
    store.sidebar.setMinimized(value)
  }
}
</script>
```

あとは、`HeaderDropdown.vue` でURLべた書きしているプロフィール画像 `/img/profile.jpg` と、 `Sidebar.vue` でインポートしている、サイドメニュー項目を記述した `~/components/_nav.ts` が必要。プロフィール画像は `~/static/img/profile.jpg` として適当な画像ファイルを置けばよい。`_nav.ts` は公式テンプレートのままでもOK。一応、公式テンプレートを参考に、プロパティをなるべく使いつつ内容を短くしたものを次に貼り付けておく。

```typescript
// ~/components/_nav.ts
export default [
  {
    _name: 'CSidebarNav',
    _children: [
      {
        _name: 'CSidebarNavItem',
        name: 'ダッシュボード',
        to: '/',
        icon: 'cil-speedometer',
        badge: {
          color: 'primary',
          text: 'NEW'
        }
      },
      {
        _name: 'CSidebarNavTitle',
        _children: ['管理メニュー']
      },
      {
        _name: 'CSidebarNavDropdown',
        name: 'ユーザ管理',
        route: '/users',
        icon: 'cil-user',
        items: [
          {
            name: 'ユーザ一覧',
            to: '/users/list'
          },
          {
            name: 'ユーザ新規登録',
            to: '/users/new'
          }
        ]
      },
      {
        _name: 'CSidebarNavItem',
        name: '設定',
        to: '/configs',
        icon: 'cil-puzzle'
      }
    ]
  }
]
```

# レイアウトファイル編集
`~/layouts/default.vue` を、ヘッダー・フッター・サイドバーを用いたレイアウトに書き換える。

```vue
<!-- ~/layouts/default.vue -->
<template>
  <div class="c-app">
    <Sidebar />
    <CWrapper>
      <Header />
      <div class="c-body">
        <main class="c-main">
          <div class="container-fluid">
            <nuxt />
          </div>
        </main>
        <Footer />
      </div>
    </CWrapper>
  </div>
</template>

<script lang="ts">
import { Vue, Component } from 'vue-property-decorator'
import { iconsSet as icons } from '~/assets/icons/icons'
import Header from '~/components/Header.vue'
import Sidebar from '~/components/Sidebar.vue'
import Footer from '~/components/Footer.vue'

@Component({
  components: {
    Header,
    Sidebar,
    Footer
  }
})
export default class DefaultLayout extends Vue {
  created() {
    this.$root.$options.icons = icons
  }
  public name() {
    return this.$route.name
  }
  public list() {
    return this.$route.matched
  }
}
</script>
```

# 最後にページを追加
`~/components/_nav.ts` で定義した各ページのURLとNuxt.jsのルーティングの規約にしたがって、 `~/pages/users/list.vue` と `~/pages/users/new.vue` と `~/pages/configs/index.vue` を作る。内容は好きに。

これで、CoreUIをフル利用したレイアウト完成。