「フィルタもソートも無いリスト」にフィルタとソート機能を追加するブックマークレットを作ってみた

この記事は、All About Group（株式会社オールアバウト） Advent Calendar 2023 7日目の記事です。

初めての方ははじめまして、そうでない方はお久しぶりです。
NACKと申します。

今年も「毎月７日は罠（わな＝０７）の日」……と言い張ってみます。
※記念日認定されているわけではありませんので、ご注意ください。

毎年「罠の日」ということでトラップネタを書いてきましたが、ネタが切れてしまったので、今年は別の話をします。

# 問題
こんなサンプルページがあったとします。


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/57720/3f41132c-8e7d-bd56-a5c7-eb3bd8c86a71.png)


リストの左に書いてあるのが魚の名前と説明、右に書いてあるのがその魚の旬です。
色々書いてあるのは良いのですが、順番がばらばらで読みにくいです。

利用者としては「指定した季節が旬の魚だけをフィルタしたい」とか「名前順にソートしたい」などと思うわけですが、残念ながら、そのサイトにはフィルタ機能もソート機能も実装されていません。

さてどうしましょうか？
- 諦める
- サイトの管理者に要望を出す
- 自分で頑張る

# 答え
## 「諦める」を選んだ場合
正攻法その１です。そのうち慣れると良いですね。

## 「サイトの管理者に要望を出す」を選んだ場合
正攻法その２です。実装されると良いですね。

## 「自分で頑張る」を選んだ場合
こういう時は、ブックマークレットを作って、自分でソートやフィルタを実装してしまいましょう（ただし自己責任で）。


# というわけで、作ってみた。
## そもそもブックマークレットって何？どうやって登録するの？
詳しく説明してくださっている方がいらっしゃったので、こちらをご覧ください。
簡単に言うと「どこもサイト上でも動かせる、自作JSコード」のことです。

https://qiita.com/aqril_1132/items/b5f9040ccb8cbc705d04

## 作ったブックマークレット
サンプルページから、「夏が旬で、冬が旬ではない魚」を、魚の名前順に取得するブックマークレットです。


```js:ブックマークレット
javascript:( function(){ const filterOkWord = "夏"; const filterNgWord = "冬"; const filterSel = ".season"; const sortSel = ".fishname"; const listSel = ".list-group"; const defaultDisplayProp = ""; const sorts=[]; const rows = document.querySelectorAll(listSel + " > *"); rows.forEach(function(row, index){ if (sortSel !== "") { sorts.push({"i":index,"v":row.querySelector(sortSel).innerText}); } if (filterSel !== "") { const filterTxt=row.querySelector(filterSel).innerText; if (filterOkWord !== "" && filterTxt.indexOf(filterOkWord) == -1) { row.style.setProperty("display" ,"none", "important"); return; } if (filterNgWord !== "" && filterTxt.indexOf(filterNgWord) !== -1) { row.style.setProperty("display", "none", "important"); return; } row.style.setProperty("display", defaultDisplayProp, ""); } }); if (sortSel !== "") { sorts.sort((a, b) => { return a.v > b.v ? 1 : -1; }); sorts.forEach(function(sort){ document.querySelector(listSel).appendChild(rows[sort["i"]]); }); } } )();
```

<details><summary>サンプルページのHTML（長いので折りたたんでいます。クリックするとHTMLソースが展開されます）</summary>
<div>

```html:サンプルHTML
<!doctype html>
<html lang="ja">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>ソートもフィルタも無い表のサンプル</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM" crossorigin="anonymous">
  </head>
  <body>
    <h1 class="h1">魚の旬</h1>
    <div class="d-flex flex-column flex-md-row p-4 gap-4 py-md-5">
      <div class="list-group">
        <a href="#" class="list-group-item list-group-item-action d-flex gap-3 py-3" aria-current="true">
          <div class="d-flex gap-2 w-100 justify-content-between">
            <div>
              <h6 class="mb-0 fishname">さわら</h6>
              <p class="mb-0 opacity-75">魚へんに春（鰆）。</p>
            </div>
            <small class="opacity-50 text-nowrap season">春</small>
          </div>
        </a>
        <a href="#" class="list-group-item list-group-item-action d-flex gap-3 py-3" aria-current="true">
          <div class="d-flex gap-2 w-100 justify-content-between">
            <div>
              <h6 class="mb-0 fishname">あゆ</h6>
              <p class="mb-0 opacity-75">魚へんに占（鮎）。</p>
            </div>
            <small class="opacity-50 text-nowrap season">夏</small>
          </div>
        </a>
        <a href="#" class="list-group-item list-group-item-action d-flex gap-3 py-3" aria-current="true">
          <div class="d-flex gap-2 w-100 justify-content-between">
            <div>
              <h6 class="mb-0 fishname">すずき</h6>
              <p class="mb-0 opacity-75">魚へんに盧（鱸）。</p>
            </div>
            <small class="opacity-50 text-nowrap season">夏</small>
          </div>
        </a>
        <a href="#" class="list-group-item list-group-item-action d-flex gap-3 py-3" aria-current="true">
          <div class="d-flex gap-2 w-100 justify-content-between">
            <div>
              <h6 class="mb-0 fishname">あじ</h6>
              <p class="mb-0 opacity-75">魚へんに参（鯵）。</p>
            </div>
            <small class="opacity-50 text-nowrap season">夏</small>
          </div>
        </a>
        <a href="#" class="list-group-item list-group-item-action d-flex gap-3 py-3" aria-current="true">
          <div class="d-flex gap-2 w-100 justify-content-between">
            <div>
              <h6 class="mb-0 fishname">いわし</h6>
              <p class="mb-0 opacity-75">魚へんに弱（鰯）。種類によって旬が違う。</p>
            </div>
            <small class="opacity-50 text-nowrap season">夏、冬</small>
          </div>
        </a>
        <a href="#" class="list-group-item list-group-item-action d-flex gap-3 py-3" aria-current="true">
          <div class="d-flex gap-2 w-100 justify-content-between">
            <div>
              <h6 class="mb-0 fishname">さんま</h6>
              <p class="mb-0 opacity-75">魚へんに秋……ではなく秋刀魚。なお、魚へんに秋（鰍）は「いなだ、かじか、どじょう」で、いずれも別の魚。</p>
            </div>
            <small class="opacity-50 text-nowrap season">秋</small>
          </div>
        </a>
        <a href="#" class="list-group-item list-group-item-action d-flex gap-3 py-3" aria-current="true">
          <div class="d-flex gap-2 w-100 justify-content-between">
            <div>
              <h6 class="mb-0 fishname">ぶり</h6>
              <p class="mb-0 opacity-75">魚へんに師（鰤）。</p>
            </div>
            <small class="opacity-50 text-nowrap season">冬</small>
          </div>
        </a>
      </div>
    </div>
  </body>
</html>
```

</div>
</details>


このブックマークレットをサンプルページで動かすと、こうなります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/57720/350aa46c-2113-5c91-3ed4-956ed85e9387.png)




### 他の条件に変更したい場合
このブックマークレットは汎用的に作っておりますので、設定（各種定数）を修正すれば、他サイトでもうまくいけば動かせると思います。
ただし、 **ご利用は自己責任で**お願いします。

#### フィルタ条件を変えたい
```js
const filterOkWord = "夏"; const filterNgWord = "冬";
```
を変更してください。
- filterOkWord
フィルタ条件：OK（この文字列が「含まれている」行を表示する。未設定時は判定しない）
- filterNgWord
フィルタ条件：NG（この文字列が「含まれている」行を表示しない。未設定時は判定しない）

です（完全一致ではなく部分一致なのでご注意ください）。

```js:変更例：「秋」だけを取得。除外条件は特に無し。
const filterOkWord = "秋"; const filterNgWord = "";
```

```js:変更例：取得条件は「全て」、除外条件は「夏、冬」
const filterOkWord = ""; const filterNgWord = "夏、冬";
```

#### フィルタ対象項目を変えたい

```js
const filterSel = ".season";
```

を、フィルタ判定したい文字列が取得できるセレクタ（行要素内で相対的に有効であればOK）に変更してください。
未設定時はフィルタしません。

```js:変更例：魚の名前でフィルタしたい
const filterSel = ".fishname"; // "h6" でも可
```

```js:変更例：フィルタしない
const filterSel = "";
```

#### ソート対象項目を変えたい

```js
const sortSel = ".fishname";
```

を、ソートしたい文字列が取得できるセレクタ（行要素内で相対的に有効であればOK）に変更してください。
未設定時はソートしません。

```js:変更例：魚の旬でソートしたい
const sortSel = ".season"; // "small" でも可
```

```js:変更例：ソートしない
const sortSel = "";
```



#### ソート対象の表を変えたい

```js
const listSel = ".list-group";
```

を、ソート対象の表（フィルタ＆ソート対象の行の親要素）のセレクタに変更してください。

#### 別のサイトの表でフィルタ＆ソートしたい
ここまでの説明に出てきた各項目の値を、そのサイトでフィルタ＆ソートしたい表の作りに合わせて、全て修正してください。

#### 「セレクタの変更」について

セレクタを変更するには「その要素がHTML上でどんな構成になっているか（何タグなのか、適用されているid、classは何なのか、など）」と「その構成を、どうセレクタとして設定するか」が必要になります。

下記の記事で詳しく説明されていらっしゃるので、詳細はそちらをご覧ください。

- 要素の確認方法（下記の記事の「Elementsタブ」項参照のこと）

https://qiita.com/d-dai/items/0b580b26bb1d1622eb46#elements%E3%82%BF%E3%83%96


- 「確認した要素」を指定するためのセレクタの設定方法

https://qiita.com/shimajiri/items/dd97247dc95c3dd5f4e6

## コードの解説
ブックマークレットはワンライナーになっているので、これをフォーマット（他）して、見やすくしてみます。

```js:ブックマークレットのコードを見やすくフォーマット＆コメントを追加したもの
javascript:(
    function(){
        // --- ユーザー設定ここから
        // フィルタ条件：OK（この文字列が含まれている行を表示する。未設定時は判定しない）
        const filterOkWord = "夏";
        // フィルタ条件：NG（この文字列が含まれている行を表示しない。未設定時は判定しない）
        const filterNgWord = "冬";
        // フィルタオブジェクトのセレクター（フィルタ判定に使用する文字列が取得できる要素を設定。未設定時はフィルタしない）
        const filterSel = ".season";
        // リストオブジェクトのセレクター。これの子要素が「フィルタ＆ソート対象」の行になるよう設定する。
        const sortSel = ".fishname";
        // 「フィルタ＆ソート対象」オブジェクトのdisplay:○○のデフォルト値。基本は空でOKのはず。
        const listSel = ".list-group";
        // ソートオブジェクトのセレクター（ソート判定に使用する文字列が取得できる要素を設定。未設定時はソートしない）
        const defaultDisplayProp = "";
        // --- ユーザー設定ここまで

        // ソート＆フィルタ対象になる行オブジェクト（複数）
        const rows = document.querySelectorAll(listSel + " > *");

        // ソート用の連想配列を入れる配列
        const sorts=[];

        // １行ずつ回す
        rows.forEach(function(row, index){
            // ソート準備
            if (sortSel !== "") {
                // ソート用の連想配列を入れる配列。i=行オブジェクトのindex、v=ソート対象文字列
                sorts.push({"i":index,"v":row.querySelector(sortSel).innerText});
            }
            
            // フィルター処理
            if (filterSel !== "") {
                const filterTxt=row.querySelector(filterSel).innerText;
            
                if (filterOkWord !== "" && filterTxt.indexOf(filterOkWord) == -1) {
                    // 非表示
                    row.style.setProperty("display" ,"none", "important");
                    return;
                }
                if (filterNgWord !== "" && filterTxt.indexOf(filterNgWord) !== -1) {
                    // 非表示
                    row.style.setProperty("display", "none", "important");
                    return;
                }
                // 表示
                row.style.setProperty("display", defaultDisplayProp, "");
            }
        });
        
        // ソート処理
        if (sortSel !== "") {
            // ソート用の値をソートする
            sorts.sort((a, b) => {
                return a.v > b.v ? 1 : -1;  
            });
            // ソートした値
            sorts.forEach(function(sort, index){
                document.querySelector(listSel).appendChild(rows[sort["i"]]);
            });
        }
    }
)();
```

## ブックマークレットの解説
### フィルタ処理
基本的にはコード内にコメントで記載しているとおりです（フィルタ対象文字列が含まれている要素から値を取得して、対象の文字列が含まれるかどうかを判定して、結果に応じてdisplayプロパティを設定しているだけ）。

一点だけ補足すると、表示・非表示の切り替えのところで
```js
row.style.setProperty("display", "none", "important");
```
という書き方をしています。
こちら、通常は `row.style.display = "none";` で十分なのですが、bootstrap等で、CSSでdisplayをimportantで設定されている場合、 `row.style.display = "none";` では無効になってしまいます（important指定されているCSSの設定が優先されるため）。
CSSで指定した値を強制的に上書きするため、style設定で`display:none` を `important` しています。

### ソート処理
ソート対象行を１行ずつ回している時に、ソート用に「各行のindexと、ソート用文字列の連想配列」の配列（ `sorts` ）を取得します。

全ての行の値を取得すると、`sorts` の値はこうなります。
```js
[
    {"i": 0, "v": "さわら"},
    {"i": 1, "v": "あゆ"},
    {"i": 2, "v": "すずき"},
    {"i": 3, "v": "あじ"},
    {"i": 4, "v": "いわし"},
    {"i": 5, "v": "さんま"},
    {"i": 6, "v": "ぶり"}
]
```

これを、`v` の値でソートすると、こうなります。
```js
[
    {"i": 3, "v": "あじ"},
    {"i": 1, "v": "あゆ"},
    {"i": 4, "v": "いわし"},
    {"i": 0, "v": "さわら"},
    {"i": 5, "v": "さんま"},
    {"i": 2, "v": "すずき"},
    {"i": 6, "v": "ぶり"}
]
```

並べ替え前の行INDEX値は `i` に格納されているので、`sorts` を１行ずつ回して、該当行を、親要素の「子要素」として追加します（このサンプルの場合だと、row[3]、row[1]、row[4]、……の順に追加する）。

```js
// ソートした値
sorts.forEach(function(sort, index){
    document.querySelector(listSel).appendChild(rows[sort["i"]]);
});
```

なお、appendChildで「追加」しているはずなのに、なぜ項目が増えるのではなく移動するのか……については、下記の記事で解説されています。

https://qiita.com/yomogenium/items/f82b24d0e2a854e840e9

# 余談
本ブックマークレットですが、もともと、社内にて「Power Automateの『マイフロー』のリストを並べ替えたい」という要望があって作ったものです（フィルタ機能は本家にもありますが、「フィルタ条件をブックマークしたい」という要望もあったので、ついでに実装）。
注：この記事に掲載したブックマークレットは「サンプルHTML」で動くように改変したものです（といっても、修正箇所は定数部分だけですが）。

本家に並べ替え＆フィルタ条件の保存機能が実装されて、このブックマークレットがお役御免になる日が早く来ますように:pray: