目を押したらパスワードが表示されるアレをSymfonyのFormTypeで作る

# 良くあるアレ

![image](https://raw.github.com/wiki/ggg-mzkr/show_hide_password/images/image.gif)


以下のように`formBuilder`にaddするだけで勝手に↑のようになってくれるFormTypeを作ります。

```php
$formBuilder()->add('password', ShowHidePasswordType::class);
```


# 作り方

## プロジェクト作成
```bash
# sensio/framework-extra-bundleはsymfony最新版に対応していないのに何故かデフォルトでインストールされるので削除
$ symfony new show_hide_password --full
$ composer remove sensio/framework-extra-bundle 
```

## ベースのフォーム画面作成

以下のコマンドでControllerを作成します。

```bash

$ bin/console make:controller

 Choose a name for your controller class (e.g. TinyGnomeController):
 > home

 created: src/Controller/HomeController.php
 created: templates/home/index.html.twig
```


Controllerとtemplateを以下のように書き換えてフォームを作成します。

```php:Controller/HomeController.php
<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Routing\Annotation\Route;

class HomeController extends AbstractController
{
    /**
     * @Route("/home", name="home")
     */
    public function index()
    {
        $form = $this->createFormBuilder()
            ->add('username', TextType::class)
            ->add('password', PasswordType::class)
            ->add('submit', SubmitType::class)
            ->getForm()
        ;

        return $this->render('/home/index.html.twig', [
            'form' => $form->createView()
        ]);
    }
}
```

```twig:templates/home/index.html.twig
{% extends 'base.html.twig' %}

{% block title %}Hello HomeController!{% endblock %}

{% block body %}
    <div class="container mt-5">
        <div class="row">
            <div class="col-8 offset-2">
                <div class="card">
                    <div class="card-body">
                        {{ form_start(form) }}
                        {{ form_rest(form) }}
                        {{ form_end(form) }}
                    </div>
                </div>
            </div>
        </div>
    </div>
{% endblock %}
```


また、bootstrapのフォームテーマを使用するので、有効化の設定 & templateにCDNのリンクを追加します。

```yaml:config/packages/twig.yaml
twig:
    default_path: '%kernel.project_dir%/templates'
    form_theme: ['bootstrap_4_layout.html.twig']  # 追加
```

```twig:templates/base.html.twig
<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>{% block title %}Welcome!{% endblock %}</title>
        {% block stylesheets %}
            // ここから
            <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
            // ここまで
        {% endblock %}
    </head>
    <body>
        {% block body %}{% endblock %}
        {% block javascripts %}
            // ここから
            <script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
            <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js" integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1" crossorigin="anonymous"></script>
            <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js" integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" crossorigin="anonymous"></script>
            // ここまで
        {% endblock %}
    </body>
</html>
```




以下のコマンドでサーバーを起動し、https://127.0.0.1:8000 へアクセスすると画像のようなページが表示されます。

```bash
$ symfony server:start -d
                                                                                                                        
 [OK] Web server listening                                                                                              
      The Web server is using PHP FPM 7.4.9                                                                             
      https://127.0.0.1:8000    
```

![image](https://raw.github.com/wiki/ggg-mzkr/show_hide_password/images/form.png)


## 例のアレを作る

まず、FormTypeを作成します。名前は適当です（笑）。こいつはPasswordTypeの子Typeにします。
ControllerではPasswordTypeの代わりに、このShowHidePasswordTypeを使用するようにします。

```php:Form\ShowHidePasswordType.php
<?php

namespace App\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;

class ShowHidePasswordType extends AbstractType
{
    public function getParent()
    {
        return PasswordType::class;
    }
}
```


```php:Controller/HomeController.php
...
use App\Form\ShowHidePasswordType; // 追加
...
        $form = $this->createFormBuilder()
            ->add('username', TextType::class)
            //->add('password', PasswordType::class)
            ->add('password', ShowHidePasswordType::class) // 追加
            ->add('submit', SubmitType::class)
            ->getForm()
        ;
...
```

次に、カスタムフォームテーマを作成し、このテーマを使用するように設定を変更します。`bootstrap_4_layout.html.twig`を継承し、ShowHidePasswordTypeの表示方法だけ上書きします。

```twig:templates/form_theme.html.twig
{% extends 'bootstrap_4_layout.html.twig' %}
```

```yaml:config/packages/twig.yaml
twig:
    default_path: '%kernel.project_dir%/templates'
    # form_theme: ['bootstrap_4_layout.html.twig']
    form_theme: ['form_theme.html.twig']  # 追加
```

Symfonyのフォームテーマでは、デフォルトでは`{% block form_row %} ... {% endblock %}`ブロックがレンダリングに用いられますが、`ShowHidePasswordType`なら`{% block show_hide_password %} ... {% endblock %}`というブロックが定義されていれば、そちらが優先的にレンダリングに用いられます。命名規則などについての詳しい解説はドキュメント[^doc]を参照ください。

`bootstrap_4_layout.html.twig`の`{% block form_row %} ... {% endblock %}`は以下の通りです。この中の`{{- form_widget(form, widget_attr) -}}`という箇所が実際にinput要素をレンダリングしている部分です。なので、ここをラップし、cssやJavaScriptでゴニョゴニョすることで例のアレの表示を試みます。

```twig:bootstrap_4_layout.html.twig`
{% block form_row -%}
    {%- if compound is defined and compound -%}
        {%- set element = 'fieldset' -%}
    {%- endif -%}
    {%- set widget_attr = {} -%}
    {%- if help is not empty -%}
        {%- set widget_attr = {attr: {'aria-describedby': id ~"_help"}} -%}
    {%- endif -%}
    <{{ element|default('div') }}{% with {attr: row_attr|merge({class: (row_attr.class|default('') ~ ' form-group')|trim})} %}{{ block('attributes') }}{% endwith %}>
        {{- form_label(form) -}}
        {{- form_widget(form, widget_attr) -}}  # ココ
        {{- form_help(form) -}}
    </{{ element|default('div') }}>
{%- endblock form_row %}
```


最終的に完成したのがこちら。input要素と目のアイコンをdivで包み、`position:absolute`でいい感じに表示しているだけですので、大した事はしていません。目のアイコンがクリックされた時にJavaScriptでinputのtypeを変更しています。

目のアイコンはfontawesomeのリソースを利用しているので、`base.html.twig`にCDNを追加してください。ブラウザを再読み込みすれば、冒頭のような良くあるアレが表示されます。

```twig:templates/form_theme.html.twig
{% extends 'bootstrap_4_layout.html.twig' %}

{% block show_hide_password_row %}
    <style>
        .showHiddenPassword-wrapper {
            position: relative;
        }
        .showHiddenPassword-wrapper .is-invalid {
            background-image: none!important;
            background-size: 0!important;
        }

        .showHiddenPassword-toggle {
            position: absolute;
            top: 50%;
            right: 1.5em;
            transform: translateY(-50%);
        }
    </style>

    <script>
        function __togglePassword__{{ form.vars.id }}() {
            const _passwordField = document.querySelector('#{{ form.vars.id }}');
            const _showHideToggle = document.querySelector('#showHideToggle-{{ form.vars.id }}');
            if (_showHideToggle.classList.contains('fa-eye-slash')) {
                _showHideToggle.classList.remove('fa-eye-slash')
                _showHideToggle.classList.add('fa-eye')
                _passwordField.type = 'text'
            } else {
                _showHideToggle.classList.remove('fa-eye')
                _showHideToggle.classList.add('fa-eye-slash')
                _passwordField.type = 'password'
            }
        }
    </script>

    {%- if compound is defined and compound -%}
        {%- set element = 'fieldset' -%}
    {%- endif -%}
    {%- set widget_attr = {} -%}
    {%- if help is not empty -%}
        {%- set widget_attr = {attr: {'aria-describedby': id ~"_help", 'class': 'showHiddenPassword-widget'}} -%} {#  class 追加  #}
    {%- endif -%}
    <{{ element|default('div') }}{% with {attr: row_attr|merge({class: (row_attr.class|default('') ~ ' form-group')|trim})} %}{{ block('attributes') }}{% endwith %}>
    {{- form_label(form) -}}

    {# ここから #}
    <div class='showHiddenPassword-wrapper'>
        {{- form_widget(form, widget_attr) -}}
        <span class='showHiddenPassword-toggle'
              onclick='__togglePassword__{{ form.vars.id }}()'
        >
            <i id='showHideToggle-{{ form.vars.id }}' class="fa fa-eye-slash"></i>
        </span>
    </div>
    {# ここまで #}

    {{- form_help(form) -}}
    </{{ element|default('div') }}>
{% endblock %}

```


```twig:templates/base.html.twig
...
        {% block stylesheets %}
            <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
            // ここから
            <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.13.0/css/all.min.css" rel="stylesheet" crossorigin="anonymous">
           // ここまで
        {% endblock %}
...
```

当たり前ですが、CSSやJavaScriptは別ファイルに定義した方がいいです。が、その場合そのファイルを読み込む手間がかかりますので、FormTypeだけで完結したい場合は敢えてこのようにするのも個人的にはアリなのかなと思います。（そこんとこどうなんでしょうか）


# 今回のソースコード
https://github.com/ggg-mzkr/show_hide_password


# 参考文献
[^doc]: https://symfony.com/doc/current/form/form_themes.html#custom-fragment-naming-for-individual-fields