アーカイブ

生まれ変わった​ログインページに​まつわる​フロントエンド開発の​話

GitHub Edit Page
この記事は公開から1年以上が経過しています。内容が一部古い箇所があります。

こんにちは!クラウドワークスで引き続きフロントエンドと Web の可能性を模索し続けている @yamanoku です。

最近の取り組みとして、この度「デザイン基盤整理」という有志活動の中でログインページを刷新しました。

Before

スクリーンショット:リニューアル前のログインページ

After

スクリーンショット:リニューアル後のログインページ

以前は PC・モバイル画面それぞれ定義されていましたが、今回の改修でレスポンシブ対応したモバイルファーストな改修になりましたね。

今回はその刷新した取り組み・内容についてをフロントエンドの観点から紹介してみようと思います。

8/25 更新:デザイナーブログも公開されましたのでこちらも合わせてご覧ください。

生まれ変わったログインページとデザインシステムのスタート|タカシ| note

デザイン基盤整理とは

ユーザーに価値を提供するために日々デザイン・開発をしていますがプロダクトが大きくなるにつれて以下の問題点が生じてきます。

そうした問題点を解消し、ユーザーへの価値を最速で届けられる基盤を作る目的で発足されたものがデザイン基盤整理です。

当初はデザイナーが主体となって基盤整理を進めていました。
その後フロントエンドやデザインへ関心があるエンジニアも参加して徐々に大きくなってきました。

そんなデザイン基盤整理の最初の取り組みとして、今年の 3 月から新ログインページを設計・リリースを目指す動きが始まりました。

ちなみにリニューアルに際して、コード上で判別できるようにコードネームを決めようとなり、プロダクトオーナーが何気なく提案した「Norman」がそのまま採用されました。

ミハイ・チクセントミハイさんの後継者とか居ないかな、または誰デザの著者への敬意を込めて「Norman」とか じゃあ、Normanで(スマイルマーク)

ノリで決まったところはありますが、個人的にはお気に入りです。
※ミハイの後継者、というくだりは以前作成していた CSS フレームワーク「Mihaly」のプロジェクトネームから来ています。

なぜログインページから着手したのか

最初にデザイナーが体験設計における重要度順で改修すべきページをリストアップしていました。

しかしクラウドワークスでは、バックエンドとフロントエンドの分離が出来ていない状態であるので、目に見えている範囲以外にもコード上で考慮しなければならない箇所や複雑な要件も存在します。

そこで @t0yohei がエンジニア観点での実装難易度を比較したシートを作成してくれました。

重要性検討一覧(実装難易度メモ付き)シートが開いているスプレッドシートのスクリーンショット

実装難易度については以下の観点で考慮しました。

そうしたデザイナー・エンジニアの総合的な観点で、まずはログインページから着手するのが良さそうではないか、という結論になりました。

実装に関して考慮したこと

新ログインページを実装するにあたり、フロントエンド観点で考慮したことを紹介します。
※ 執筆時点では Vue 2 × @vue/composition-apiで実装したものになっています。

デザイン基盤整理用の新たな erb ファイルの作成

今回の改修では erb ファイルを id でマウントする以外は Vue.js のみで操作できるよう Rails 依存を極力脱した実装をしています。

弊プロダクトでは Sprockets と webpacker が入り交じっており、デザイン基盤整理用に新たな分岐をつくるのはさらなる混沌を産みかねないと判断しました。

そこでまっさらな状態のレイアウトファイルを作成してそこにマウントできるような新たなページを作成しました。

  #
  # RawPage 用のページ用にビルドされた JS を読み込むタグを返す
  # 存在しなければ例外を発生させる
  #
  # @return [String] script tag
  def raw_page_javascript_pack_tag(page:)
    # EntryPoint は app/javascript/packs/raw_pages/**/*.ts となります。
    pack_path = "raw_pages#{page}"
    full_path = "app/javascript/packs/#{pack_path}"

    if Rails.root.join("#{full_path}.ts").exist? || Rails.root.join("#{full_path}.js").exist?
      return javascript_packs_with_chunks_tag(pack_path)
    else
      raise RuntimeError, "javascript for RawPages is not found: #{full_path}"
    end
  end
import Vue from 'vue';
import VueMeta from 'vue-meta';
import VueCompositionApi from '@vue/composition-api';
import '/app/javascript/norman/lib/common_css.ts';
import LoginContainer from 'norman/components/pages/login/LoginContainer.vue';

const buildVue = () => {
  const el = '#vue-container';

  if (!document.querySelector(el)) {
    return;
  }

  Vue.use(VueCompositionApi);
  Vue.use(VueMeta);

  document.addEventListener('DOMContentLoaded', () => {
    new Vue({
      el,
      components: { LoginContainer },
      render: (h) => h('LoginContainer', {}),
    });
  });
};

buildVue();

<div id='vue-container' /> のみの view ファイルを作成してそこにマウントしています。
meta 情報については vue-meta を使用してメタ情報を認識できるように実装しています。

Storybook を使用した開発

クラウドワークスのフロントエンド開発では去年より @Bugfire の方で Storybook を試験的に導入し、今年からプロダクト開発において積極的に活用するようになりました。
Storybook の導入により以下メリットのある開発ができるようになりました。

Storybook 開発は今年 6 月にリリースされたカンタン発注プラン診断でも使用されており、今回のデザイン基盤整理での開発においても活躍してくれました。

オススメの発注方法・予算相場を約 1 分で知れる「カンタン発注プラン診断」機能をスタートしました – クラウドワークス お知らせブログ

デザイントークンの活用

デザインから実装へ移る際、参考にしたものの 1 つとしてデザイントークンの考え方があります。
デザイントークンとはデザインシステムを構築する上で使われるパタン・ランゲージのことです。

たとえば色、フォントサイズ、余白や空きの数値などを一貫性のある値として定義し、それを共同で使用して認識できるようにします。

最初から共通のコンポーネントを作って運用していくことも考えました。
ですが、まだ始まったばかりの改修から共通化を進めると、拾えきれない・想定しきれなかったユースケースが出てきたりして破綻する可能性もあります。
とはいえ、ルールなき実装のまま進めていくとデザイナー・エンジニアの認識を揃えることも難しくなります。

どう進めていくか悩んでいたとき、以前参加した pixiv テックカンファレンスでのデザインシステムの発表から「コンポーネント集から作るのではなくまずは定数を決めていく」という取り組み方が参考になりました。

まずはユーティリティとして使うデザイントークンという共通の定数で認識を揃えていくところからはじめました。

どういった粒度や命名にするかを考えるにあたり、SmartHR のデザインシステムGMO ペパボのデザインシステムのドキュメントを拝見しました。

最終的にはベーストークン(根底となる値)とセマンティクストークン(意味づけられた値)という考え方で分離することにしました。

:root {
  /**
   * color
   */
  /* loginページ用トークン */
  --login-gray-050: #f9f9f9;
  --login-gray-100: #d5d8dc;
  --login-gray-800: #353d48;
  --login-blue-050: #e6f1fd;
  --login-blue-300: #6bb8ff;
  --login-blue-800: #0068b6;
  --login-blue-900: #064da0;
  --login-red-900: #d91808;
}
.button-login {
  background-color: var(--login-blue-800);
}

実際に色の変更があったときにトークンで管理することによって、共通で使われていた箇所を洗い出し、漏れを防ぐことができました。
逆に使い回す予定がない一時的に作られたものとしてはトークンとして管理をしないようにもしました。

今現在のデザイントークン命名規則については以下のルールを決めて運用しています。

また、color の数値を決める際には Material Design のカラーピックツールを使用しました。

Color palettesで青色の数値を検証しているスクリーンショット

レイアウト制御コンポーネント

Vue.js で実装するにあたりボタンや見出し、フォームパーツといったものは 1 つずつコンポーネントとして細分化して実装しています。

更に要素間の空きについても Stack というレイアウト制御コンポーネントで管理するようにしてみました。

Stack については Every Layout で紹介されているレイアウトパターンの 1 つで、内部コンテンツの空き関係を親側で制御するようにしたものです。

<template>
  <div :class="`stack-${size}`">
    <slot />
  </div>
</template>

<script lang="ts">
  import { defineComponent, PropType } from '@vue/composition-api';

  type Spacing = 4 | 8 | 12 | 16 | 24 | 32 | 40;

  export default defineComponent({
    name: 'Stack',
    props: {
      stackSize: {
        type: Number as PropType<Spacing>,
        required: true,
      },
    },
    setup(props: { stackSize: Spacing }) {
      const size = String(props.stackSize).padStart(2, '0');
      return { size };
    },
  });
</script>

<style scoped>
  [class^='stack'] > * {
    margin-top: 0;
    margin-bottom: 0;
  }

  .stack-04 > * + * {
    margin-top: var(--login-space-04);
  }

  .stack-08 > * + * {
    margin-top: var(--login-space-08);
  }

  /* 以下4の倍数ごとに続く */
</style>

Stack を用いることによって、内部にいかなるコンテンツが入ってきても決められた margin-top で制御することができます。

<ContainerLayout>
  <Stack :stack-size="24">
    <Stack :stack-size="32">
      <Stack :stack-size="16">
        <Stack :stack-size="isShowError ? 16 : 8">
          <template v-if="isShowError">
            <Stack :stack-size="8">
              <HeadingLevel2 :heading-text="'ログイン'" />
              <ErrorArea :error-message="errorMessage" />
            </Stack>
          </template>
          <template v-else>
            <HeadingLevel2 :heading-text="'ログイン'" />
          </template>
          <LoginFieldArea
            :text-field-id="'username'"
            :text-field-label="'メールアドレス'"
            :password-field-id="'password'"
            :password-field-label="'パスワード'"
            :submit-button-label="'ログイン'"
            :authenticity-token="authenticityToken"
            :redirect-to-params-value="redirectToParamsValue"
          />
        </Stack>
        <LinkBlock
          :url="newPasswordResetRequestsPath"
          :link-text="'パスワードをお忘れですか?'"
        />
      </Stack>
      <Border />
    </Stack>
    <Stack :stack-size="12">
      <HeadingLevel3 :heading-text="'他のアカウントでログイン'" />
      <div>
        <SnsAccountList
          :sns-service-list="snsList"
          :authenticity-token="authenticityToken"
        />
      </div>
    </Stack>
  </Stack>
</ContainerLayout>

フォーム実装に関するベストプラクティス

今回の新ログインページではメールアドレスとパスワードの入力(SNS アカウントでのログイン導線)という簡易的なフォーム構成になっています。
単純に入力フィールドをForm Design Patterns のログインパターンより参考にして実装しました。

<label :for="fieldId" class="textfield-label">{{ fieldLabel }}</label>
<input
  :id="fieldId"
  :name="fieldId"
  type="text"
  class="textfield-area"
  autocapitalize="none"
  autocorrect="off"
  spellcheck="false"
/>

autocorrect="off" については標準外の属性ではあるものの、 iOS Safari ユーザーの入力負荷をへらすためのユーザビリティ考慮として実装しています。

エラー表示箇所については role="group" でグループ化されているものとして、aria-labelledby で見出しの ID を紐付けて何のグループなのかを伝えるようにも実装しています。

<div role="group" aria-labelledby="error-summary-heading">
  <h3 id="error-summary-heading" class="error-visually-hidden-heading">
    入力内容に問題があります
  </h3>
  <Stack :stack-size="8">
    <div class="text-center">
      <div class="alert-text">{{ error.text }}</div>
    </div>
  </Stack>
</div>

Rails 特有の記述への留意

リニューアル前の SNS アカウントでのログイン導線は以下のように link_to で実装されていました。

<% link_to '/auth/facebook', rel: 'nofollow', method: :post do %>
<% end %>

最初はページ遷移するリンク要素として実装していたのですが、実際に動かしてみると SNS アカウントの連携先には遷移されませんでした。

原因は何かを探ってみると link_todata-method 属性によってフォームに送信する挙動として使われていました。

この方法を用いると、リンクをクリックしたときにドキュメント内に「隠しフォーム」が 1 つ作成されます。隠しフォームにはリンクの href 値に対応する「action」属性や data-method 値に対応する HTTP メソッドを含まれており、そのフォームが送信されます。 https://railsguides.jp/working_with_javascript_in_rails.html#data-method

リンクコンポーネントとしてではなく、POST 処理を行うボタンコンポーネントとして変更するようにして動作させるように変更しました。

<template>
  <form method="post" :action="serviceUrl">
    <input name="_method" value="post" type="hidden" />
    <input :value="authenticityToken" name="authenticity_token" type="hidden" />
    <button type="submit" class="button-login-sns_account">
      <IconGoogle v-if="serviceId === 'google'" />
      <IconFacebook v-if="serviceId === 'facebook'" />
      <IconYahooJapan v-if="serviceId === 'yahoojp'" />
      {{ serviceName }}でログイン
    </button>
  </form>
</template>

ブラウザ上のソースでは一見してわからなかったため、Rails テンプレートから素の HTML で実装する際は、Rails 特有の記法がないかをチェックしないといけないと感じました。

これからのデザイン基盤整理の開発について

新ログインページがリリースされましたが、今後もユーザー体験と実装難易度の優先度の中で徐々に対象ページを広げていければと思っております。

次のステップとして今回作ったコンポーネントがほかページでも流用できるか、実装工数を削減して生産性が高い開発できるかなども検証しつつ進めていきたいと考えています。

また今後の計画としてモノリシックな Rails アプリをバックエンドとフロントエンドに分離し、BFF 層と Nuxt.js とを活用したフロントエンド開発にしていきたい所存です。

おれたちのフロントエンド開発はこれからだ!(未完)

(宣伝)アクセシビリティ試験されます

8 月 27 日に WP ZoomUP 様が主催している勉強会にて、freee 株式会社の伊原力也さんに新ログインページのアクセシビリティ試験をしていただく予定です。

今回の改修についてお褒めの言葉をいただけるか、公開処刑となるか、その目で確かめてみてください(?)

続・もしあなたが『アクセシビリティ試験』をやることになったら WP ZoomUP #71 - connpass

9/14 追記:当日の試験結果の YouTube が公開されました。1:45:00 より試験が始まります。

続・もしあなたが『アクセシビリティ試験』をやることになったら WP ZoomUP #71 - YouTube

アーカイブ記事のため、内容に関する更新依頼は受け付けておりませんが、誤字や脱字などありましたらご連絡ください。

この記事に関する修正依頼
トップへ戻る