カスタムHTMLヘルパーで連動するドロップダウンを作る

# はじめに
連動するドロップダウン、皆さんは作ったことありますか？
↓こんな感じで「地域」と「国」のように親子関係を持ち、親ドロップダウンを選択するとその選択値に応じて子ドロップダウンの選択肢が変化する、というものです。

![LinkedDropdownExampleGIF.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3890343/c14f7c1b-dbd0-a768-f42f-2cab72a69cfc.gif)




このような機能は比較的需要が高い割には定型的な実装手法が見当たらず、プロジェクト毎にJavaScript等を駆使して作成しているのが実情かなと思います。

今回はASP.NETを使ったWebシステムの開発において、連動するドロップダウンを簡単にレンダリングするカスタムHTMLヘルパーを作成してみたのでご紹介します。

# 環境とデータ構成

動作確認した環境は以下です。
- .NET 6.0
- HtmlAgilityPack 1.11.66（https://www.nuget.org/packages/HtmlAgilityPack/1.11.66?_src=template）

すぐに動かせるサンプルコードをGitHubに用意していますので、よかったら参照してみてください。
[CustomHtmlHelper_LinkedDropDown](https://github.com/yusho-inoue/CustomHtmlHelper_LinkedDropDown)

また、今回は以下のようなテーブル構成のデータを使うものとします。

<table>
  <caption>RegionMaster</caption>
  <thead>
    <tr>
      <th>Id</th> <th>Text</th><th>Value</th>
    </tr>
  </thead>
  <tr>
    <td> 1 </td><td>アジア</td><td>1</td>
  </tr>
  <tr>
    <td> 2 </td><td>ヨーロッパ</td><td>2</td>
  </tr>
  <tr>
    <td> 3 </td><td>アフリカ</td><td>3</td>
  </tr>
</table>
<br>
<table>
  <caption>CountryMaster</caption>
  <thead>
    <tr>
      <th>Id</th> <th>Text</th><th>Key</th><th>Value</th>
    </tr>
  </thead>
  <tr>
    <td> 1 </td><td>日本</td><td>1</td><td>1</td>
  </tr>
  <tr>
    <td> 2 </td><td>大韓民国</td><td>1</td><td>2</td>
  </tr>
  <tr>
    <td> 3 </td><td>台湾</td><td>1</td><td>3</td>
  </tr>
  <tr>
    <td> 4 </td><td>イギリス</td><td>2</td><td>4</td>
  </tr>
  <tr>
    <td> 5 </td><td>ドイツ</td><td>2</td><td>5</td>
  </tr>
  <tr>
    <td> 6 </td><td>エジプト</td><td>3</td><td>6</td>
  </tr>
  <tr>
    <td> 7 </td><td>エチオピア</td><td>3</td><td>7</td>
  </tr>
</table>

```c#:RegionMaster.cs
    public class RegionMaster
    {
        public int Id { get; set; }
        public string Value { get; set; }
        public string Text { get; set; }
    }
```
```c#:CountryMaster.cs
public class CountryMaster
{
    public int Id { get; set; }
    public string Key { get; set; }
    public string Value { get; set; }
    public string Text { get; set; }
}
```

# 実装の紹介
まずはメインのカスタムHTMLヘルパーの実装を紹介させてください。
コードはこちら↓
```c#:DropDownListForExtensions.cs
using HtmlAgilityPack;
using LinkedDropdownExample.ExtensionModels;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Mvc.Rendering;
using System.Linq.Expressions;
using System.Text.Encodings.Web;

#nullable disable
namespace LinkedDropdownExample.Extensions
{
    public static class DropDownListForExtensions
    {
        /// <summary>
        /// 親ドロップダウンと連動するドロップダウンを作成する
        /// </summary>
        /// <typeparam name="TModel"></typeparam>
        /// <param name="htmlHelper"></param>
        /// <param name="expression">ドロップダウンで選択した値</param>
        /// <param name="selectList">ドロップダウンの選択肢</param>
        /// <param name="masterExpression">親ドロップダウンの選択した値</param>
        /// <returns></returns>
        public static IHtmlContent LinkedDropDownListFor<TModel>(
            this IHtmlHelper<TModel> htmlHelper,
            Expression<Func<TModel, int?>> expression,
            IEnumerable<LinkedSelectListItem> selectList,
            Expression<Func<TModel, int?>> masterExpression)
        {
            // デフォルトのDropDownListForを呼び出してドロップダウン用のタグの枠組みを取得してhtmlDodumentに読み込む
            var dropdown = htmlHelper.DropDownListFor(expression, selectList, "選択してください", htmlAttributes: new { }) as TagBuilder;
            HtmlDocument htmlDocument = new HtmlDocument();
            htmlDocument.LoadHtml(GetHtmlString(dropdown!.InnerHtml));

            // ドロップダウンのタグからoptionタグ（選択肢の部分）を取得し、key属性を追加する
            var options = htmlDocument.DocumentNode.SelectNodes("//option");
            foreach (var option in options)
            {
                var value = option.Attributes["value"]?.Value;
                option.Attributes.Add("data-key", selectList.FirstOrDefault(e => e.Value == value)?.Key ?? "default");
            }

            // key属性を付与したoptionタグでドロップダウンの中身を入れ替え
            dropdown.InnerHtml.Clear();
            dropdown.InnerHtml.AppendHtml(htmlDocument.DocumentNode.OuterHtml);

            // ドロップダウンのidを取得する
            var masterDropdown = htmlHelper.DropDownListFor(masterExpression, selectList, htmlAttributes: new { }) as TagBuilder;
            var masterDropdownId = masterDropdown.Attributes["id"];
            var slaveDropdownId = dropdown.Attributes["id"];

            // ドロップダウンを連動させるJavaScript
            var scriptTag = new TagBuilder("script");
            var script =
                @"
                    const masterDropdown_" + masterDropdownId + @" = document.getElementById('" + masterDropdownId + @"');
                    const slaveDropdown_" + slaveDropdownId + @" = document.getElementById('" + slaveDropdownId + @"');
                    window.addEventListener('DOMContentLoaded', () => {
                        let masterDropdown = masterDropdown_" + masterDropdownId + @";
                        let slaveDropdown = slaveDropdown_" + slaveDropdownId + @";
                        let options = slaveDropdown.children;
                        masterDropdown.addEventListener('change', (e) => {
                            let parentValue = masterDropdown.value;
                            let slaveValue = slaveDropdown.value;

                            if (parentValue) {
                                slaveDropdown.removeAttribute('disabled');

                                Array.from(options).forEach(elm => {
                                    if(elm.tagName.toLowerCase() == 'span') {
                                        let option = elm.firstElementChild;                                    
                                        elm.parentNode.insertBefore(option, elm);
                                        elm.parentNode.removeChild(elm);
                                        elm = option;
                                    }

                                    let key = elm.dataset.key;
                                    if (key == parentValue || key == 'default') {
                                        elm.style.display = 'block';
                                    }
                                    else {
                                        let value = elm.attributes['value'].value;
                                        if (value == slaveValue) {
                                            slaveDropdown.value = '';
                                        }
                                        let span = document.createElement('span');
                                        span.style.display = 'none';
                                        elm.parentNode.insertBefore(span, elm);
                                        span.appendChild(elm);
                                    }
                                });
                            }
                            else {
                                slaveDropdown.value = '';
                                slaveDropdown.setAttribute('disabled', 'disabled');
                            }
                        });
                        let change = new Event('change');
                        masterDropdown.dispatchEvent(change);
                    });
                ";
            scriptTag.InnerHtml.AppendHtml(script);

            // ドロップダウンとJavaScriptをdivタグに入れる
            var outerDiv = new TagBuilder("div");
            outerDiv.Attributes.Add("style", "display: inline-block");
            outerDiv.InnerHtml.AppendHtml(dropdown);
            outerDiv.InnerHtml.AppendHtml(scriptTag);
            return outerDiv;
        }

        private static string GetHtmlString(IHtmlContent htmlContent)
        {
            using (var writer = new System.IO.StringWriter())
            {
                htmlContent.WriteTo(writer, HtmlEncoder.Default);
                return writer.ToString();
            }
        }
    }
}

```
メソッドの引数で使っているLinkedSelectListItemはこちら↓
デフォルトのSelectListItemを継承して変数にKeyを追加しているだけです。
```c#:LinkedSelectListItem.cs
using Microsoft.AspNetCore.Mvc.Rendering;

namespace LinkedDropdownExample.ExtensionModels
{
    public class LinkedSelectListItem : SelectListItem
    {
        public LinkedSelectListItem(string text, string value, string key) : base(text, value)
        {
            Key = key;
        }
        public string Key { get; set; }
    }
}

```

# 使用方法
使い方はGitHubにあげているサンプルプロジェクトのソースを見ていただくのが分かりやすいと思いますので、ここでは簡単に。
[CustomHtmlHelper_LinkedDropDown](https://github.com/yusho-inoue/CustomHtmlHelper_LinkedDropDown)
<br>
Viewからは以下のように呼び出すことができます。
`@Html.LinkedDropDownListFor`の1行だけで、親である地域ドロップダウンに連動する国名ドロップダウンを出力することができています。
```html
<form>
    <div>
        <label>地域：</label>
        @Html.DropDownListFor(e => e.RegionValue, Model.RegionListItems, "選択してください")
    </div>
    <div style="margin-top: 1rem;">
        <label>国名：</label>
        @Html.LinkedDropDownListFor(e => e.CountryValue, Model.CountryListItems, m => m.RegionValue)
    </div>
</form>
```

コントローラ部分は各自でいい感じにしてもらえば大丈夫ですが、今回はサンプルなので選択肢のリストを直接作ってしまっています。
```c#
public IActionResult Index()
{
    var exampleViewModel = new ExampleViewModel();
    exampleViewModel.RegionListItems = new List<SelectListItem>() {
        new SelectListItem("アジア", "1"),
        new SelectListItem("ヨーロッパ", "2"),
        new SelectListItem("アフリカ", "3"),
    };
    exampleViewModel.CountryListItems = new List<LinkedSelectListItem>() {
        new LinkedSelectListItem("日本", "1", "1"),
        new LinkedSelectListItem("大韓民国", "2", "1"),
        new LinkedSelectListItem("台湾", "3", "1"),
        new LinkedSelectListItem("イギリス", "4", "2"),
        new LinkedSelectListItem("ドイツ", "5", "2"),
        new LinkedSelectListItem("エジプト", "6", "3"),
        new LinkedSelectListItem("エチオピア", "7", "3"),
    };
    return View(exampleViewModel);
}
```


# ちょっと解説
ざっくり言うとドロップダウンのマークアップにJavaScriptも埋め込んでレンダリングさせちまおうという脳筋系実装になっています。
ドロップダウンのマークアップ部分はデフォルトのカスタムHTMLヘルパーを呼び出して作成し、optionタグに"data-key"属性として親ドロップダウンと連動するためのkeyを追加しています。
出力されるマークアップは以下のような感じ。
```html
<select id="CountryValue" name="CountryValue" disabled="disabled">
  <option value="" data-key="default">選択してください</option>
  <option value="1" data-key="1">日本</option>
  <option value="2" data-key="1">大韓民国</option>
  <option value="3" data-key="1">台湾</option>
  <option value="4" data-key="2">イギリス</option>
  <option value="5" data-key="2">ドイツ</option>
  <option value="6" data-key="3">エジプト</option>
  <option value="7" data-key="3">エチオピア</option>
</select>
```

このマークアップといっしょに、親ドロップダウンと連動させるJavaScriptも出力しています。
JavaScript部分を抜き出したものが以下になります。
```javascript
const masterDropdown_RegionValue = document.getElementById('RegionValue');
const slaveDropdown_CountryValue = document.getElementById('CountryValue');
window.addEventListener('DOMContentLoaded', () => {
    let masterDropdown = masterDropdown_RegionValue;
    let slaveDropdown = slaveDropdown_CountryValue;
    let options = slaveDropdown.children;
    // 親ドロップダウンのchangeイベントをリスナ登録
    masterDropdown.addEventListener('change', (e) => {
        let parentValue = masterDropdown.value;
        let slaveValue = slaveDropdown.value;

        // 親ドロップダウンで何かしらの値が選択された場合
        if (parentValue) {
            slaveDropdown.removeAttribute('disabled');

            Array.from(options).forEach(elm => {
                // iOS対応 spanタグを一度除去してoptionタグを対象にする
                if(elm.tagName.toLowerCase() == 'span') {
                    let option = elm.firstElementChild;                                    
                    elm.parentNode.insertBefore(option, elm);
                    elm.parentNode.removeChild(elm);
                    elm = option;
                }

                // 親ドロップダウンの選択値とkeyが一致する選択肢は表示、それ以外は非表示化
                let key = elm.dataset.key;
                if (key == parentValue || key == 'default') {
                    elm.style.display = 'block';
                }
                else {
                    let value = elm.attributes['value'].value;
                    if (value == slaveValue) {
                        slaveDropdown.value = '';
                    }
                    // iOS対応 選択肢非表示のためにoptionをspanタグで囲う
                    let span = document.createElement('span');
                    span.style.display = 'none';
                    elm.parentNode.insertBefore(span, elm);
                    span.appendChild(elm);
                }
            });
        }
        // 親ドロップダウンが非選択状態であれば子ドロップダウンは非活性化
        else {
            slaveDropdown.value = '';
            slaveDropdown.setAttribute('disabled', 'disabled');
        }
    });
    // 初期表示段階で親ドロップダウンに選択肢を連動させるため、changeイベントを強制的に発火させる
    let change = new Event('change');
    masterDropdown.dispatchEvent(change);
});

```
親ドロップダウンのchangeイベントでoptionタグをすべてチェックしていき、該当する選択肢以外を非表示にすることで、親ドロップダウンと連動しているように見せることができます。
イメージつきやすくするために、親ドロップダウンで「アジア」を選択したときの子ドロップダウンのマークアップが以下になります。
```html
<select id="CountryValue" name="CountryValue">
  <option value="" data-key="default" style="display: block;">選択してください</option>
  <option value="1" data-key="1" style="display: block;">日本</option>
  <option value="2" data-key="1" style="display: block;">大韓民国</option>
  <option value="3" data-key="1" style="display: block;">台湾</option>
  <span style="display: none;"><option value="4" data-key="2">イギリス</option></span>
  <span style="display: none;"><option value="5" data-key="2">ドイツ</option></span>
  <span style="display: none;"><option value="6" data-key="3">エジプト</option></span>
  <span style="display: none;"><option value="7" data-key="3">エチオピア</option></span>
</select>
```
もしかしたら非表示にしているoptionタグをspanタグで囲っているのが気になるかもですが、こちらはiOSのレンダリングエンジンに対応するため仕方なくこうしています。
iOSのレンダリングエンジンではoptionタグに`display: none`を適用できないため、他のタグで囲ってそちらに非表示のスタイルを設定する必要があるのです。
検索していただくといろいろな対応が出てくると思いますが、自分が調べた限りではこの対応方法が多く採用されていました。
<br>
以上、ざっとですが連動するドロップダウンを出力するカスタムHTMLヘルパーをご紹介させていただきました。
ぜひ使っていただけると嬉しいです。
もし不具合等あればこの記事やGitHubへのコメントで教えていただければ幸いです。