🐄

モーダルクラス

2022/12/17に公開

画像クリックで画像URLを取得してモーダル内でその画像を表示させるみたいなことはブログ(https://code-widgit.com/post-787/ )に書いていたけど、単純なモーダルのテンプレートがないなと思ってとりあえず作ってみました

>コードは書いたそのままここに投げてるので、まだ途中かつ整理してません

getTwoDigitsNumberはこちらを参照
https://zenn.dev/icchicw/articles/901ea64f6d16f1

assignConsecutiveNumberはこちらを参照
https://zenn.dev/icchicw/articles/99eb229abdbf12

参考:ICSさんの記事
https://ics.media/entry/220620/

参考:YENDさんの記事
https://zenn.dev/yend724/articles/20220511-pc51v32llyzu8kws

Special Thanks for ikeryo
https://twitter.com/Ikeryo11

VanillaJS(クラス)

main.js
import { Modal } from './_utils/_Modal';

//モーダル
new Modal({
  modals:".js-modal-sample",
  triggers:".js-modal-trigger",
  wrapper: ".test-wrapper",
  // closeAction: "modal",
});
new Modal({
  modals: ".js-modal-sample-02",
  triggers: ".js-modal-trigger-02",
  // wrapper: null,
  closeAction: "modal",
});
_Modal.js
'use strict'
import { BgFix } from './_BgFix';
import { assignConsecutiveNumber } from '../scripts/assignConsecutiveNumber';
const { log } = console;

export class Modal {
  constructor(obj) {
    //required modals and triggers   
    if (!obj.modals | !obj.triggers) return;
    this.DOM = {};
    this.DOM.modals = document.querySelectorAll(obj.modals);
    this.DOM.triggers = document.querySelectorAll(obj.triggers);
    if (!this.DOM.modals | !this.DOM.triggers) return;

    //モーダル内フォーカス関係
    this.focusableElements; //モーダル内のフォーカス可能な要素
    this.FOCUSABLE_ELEMENTS = this._focusableElements(); //focus可能要素配列

    //接頭辞
    this.prefix = obj.modals.split(".")[1];
    this.ariaPrefix = obj.modals.includes(".js-") ? obj.modals.split(".js-")[1] : obj.modals;
    
    //オーバーレイ
    this.DOM.overlays = this._overlayStoreInDOM();
    
    //WAI-ARIAを変更するDOM
    this.DOM.header = obj.header ? document.querySelector(obj.header) : document.querySelector(".js-header");
    this.DOM.main = [...document.getElementsByTagName("main")][0];
    this.DOM.wrapper = obj.wrapper ? document.querySelector(obj.wrapper) : null;
    this.DOM.footer = obj.footer ? document.querySelector(obj.footer) : document.querySelector(".js-footer");
    this.DOM.hiddenElements = [this.DOM.header, this.DOM.main, this.DOM.wrapper, this.DOM.footer].filter(Boolean);
    this.DOM.modalDialogs = this._storeAgainInNodeList("dialog");
    this.DOM.modalTitles = this._storeAgainInNodeList("title");
    this.DOM.modalDescs = this._storeAgainInNodeList("desc");

    //ID, aria系の自動修正
    this._idAriaAutoFix();

    this.index; //モーダルのインデックス
    this.HIDDEN_CLASS = "--hidden"; //hidden class
    this.lastFocus; //前回のフォーカス位置

    //何をトリガーにして閉じるか modal or button and overlay
    //modal全体をトリガーにする時のみ {closeAction: "modal",} を指定する
    this.closeAction = obj.closeAction ? obj.closeAction : null;

    this.BgFix = new BgFix(); //背景固定クラス
    this._openModal(); //open modal act
    this._closeModal(); //close modal act
  }

  _storeAgainInNodeList(type) {
    let items = [];
    this.DOM.modals.forEach((modal, i) => {
      items[i] = modal.querySelectorAll(`*[data-modal-type=${type}]`)[0];
    });
    items = Object.setPrototypeOf(items, NodeList.prototype);
    return items;
  }

  //focus可能要素を代入
  _focusableElements() {
    return [
      "a[href]",
      "area[href]",
      'input:not([disabled]):not([type="hidden"]):not([aria-hidden])',
      "select:not([disabled]):not([aria-hidden])",
      "textarea:not([disabled]):not([aria-hidden])",
      "button:not([disabled]):not([aria-hidden])",
      "iframe",
      "object",
      "embed",
      "[contenteditable]",
      '[tabindex]:not([tabindex^="-"])',
    ];
  }

  //Focus Trap
  _focusHandle(e) {
    const firstFocusableElement = this.focusableElements[0];
    const lastFocusableElement = this.focusableElements[this.focusableElements.length - 1];

    if (e.code === "Tab") {
      if (e.shiftKey) {
        // Shift + Tab
        if (document.activeElement === firstFocusableElement) {
          e.preventDefault();
          // ダイアログ内で最初のtabableの要素の時、最後のtabableの要素にフォーカスを移す
          lastFocusableElement.focus();
        }
      } else {
        // Tab
        if (document.activeElement === lastFocusableElement) {
          e.preventDefault();
          // ダイアログ内で最後のtabableの要素の時、最初のtabableの要素にフォーカスを移す
          firstFocusableElement.focus();
        }
      }
    }
  };

  //when "Escape" is pushed
  _escapeAction(e) {
    if (e.code === "Escape") {
      this._closeAction(this.DOM.modals[this.index], this.index);
    }
  }

  //this.DOM.overlaysにオーバーレイ要素を格納
  _overlayStoreInDOM() {
    this.DOM.modals.forEach(modal => {
      const modalChildren = [...modal.children];
      modalChildren.forEach(child => {
        if (child.dataset.modalType == "overlay") {
          child.classList.add(`${this.prefix}-overlay`);
        }
      });
    });
    return document.querySelectorAll(`.${this.prefix}-overlay`);
  }

  //idとariaの自動修正
  _idAriaAutoFix() {
    assignConsecutiveNumber(this.DOM.modals, "id", this.prefix);
    assignConsecutiveNumber(this.DOM.triggers, "aria-controls", this.prefix);
    assignConsecutiveNumber(this.DOM.modalDialogs, "aria-labelledby", `${this.ariaPrefix}-title`);
    assignConsecutiveNumber(this.DOM.modalDialogs, "aria-describeby", `${this.ariaPrefix}-desc`);
    assignConsecutiveNumber(this.DOM.modalTitles, "id", `${this.ariaPrefix}-title`);
    assignConsecutiveNumber(this.DOM.modalDescs, "id", `${this.ariaPrefix}-desc`);
  }

  //user-select変更
  _userSelect(boolean) {
    if (!this.DOM.main) return;
    if (boolean) {
      this.DOM.main.setAttribute("style", "user-select: none");
    } else {
      this.DOM.main.removeAttribute("style", "user-select: none");
    }
  }

  //モーダルオープン
  _openModal() {
    this.DOM.triggers.forEach((trigger, i) => {
      trigger.addEventListener("click", (event) => {
        this.index = i; //インデックスを記録
        this.BgFix.on(); //背景固定
        this._userSelect(true); //add user-select
        this.DOM.modals[i].classList.remove(this.HIDDEN_CLASS);

        //モーダル内のフォーカス可能な要素
        this.focusableElements = [
          ...this.DOM.modals[i].querySelectorAll(this.FOCUSABLE_ELEMENTS.join(","))
        ];

        //現在のfocusを記録
        this.lastFocus = document.activeElement;

        //モーダル内の最初の要素にフォーカス
        this.focusableElements[0].focus();

        //Focus Trap
        this.DOM.modals[i].addEventListener("keydown", this._focusHandle.bind(this));

        //when push "Escape"
        this.DOM.modals[i].addEventListener("keydown", this._escapeAction.bind(this));

        //WAI-ARIAの設定を追加
        this.DOM.hiddenElements.forEach((element) => element.setAttribute('aria-hidden', 'true'));
        this.DOM.modals[i].setAttribute('aria-hidden', 'false')
        trigger.setAttribute('aria-expanded', 'true');
      });
    });
  }

  //モーダルクローズ
  _closeModal() {
    this.DOM.modals.forEach((modal, i) => {
      //モーダル全体クリックで閉じるが有効の場合
      if (this.closeAction == "modal") {
        modal.addEventListener("click", () => {
          this._closeAction(modal, i);
        });
      } //モーダル全体クリックで閉じるが無効の場合
      else {
        //ボタンなどをフォーカス可能要素クリックで閉じる
        const focusableCloseTrigger = [ //フォーカス可能な要素
          ...modal.querySelectorAll(this.FOCUSABLE_ELEMENTS.join(","))
        ]
        focusableCloseTrigger.forEach(t => {
          t.addEventListener("click", () => {
            this._closeAction(modal, i);
          });
        });

        //オーバーレイクリックで閉じる
        this.DOM.overlays[i].addEventListener("click", () => {
          this._closeAction(modal, i);
        });
      }
    });
  }

  //モーダルクローズ動作
  _closeAction(modal, i) {
    this._userSelect(false); //remove user-select
    this.BgFix.off(); //背景固定解除
    modal.classList.add(this.HIDDEN_CLASS);

    //前回のフォーカス位置に戻す
    this.lastFocus.focus();

    //WAI-ARIAの設定を戻す
    this.DOM.hiddenElements.forEach((element) => element.removeAttribute('aria-hidden'))
    modal.setAttribute('aria-hidden', 'true');
    this.DOM.triggers[i].setAttribute('aria-expanded', 'false');
  }
}

HTML

index.html
<div class="test-wrapper">wrapperです</div>

<!-- modalオリジナル -->
<!-- modal1つ目 -->
<div id="modal_01" class="p-modal --hidden js-modal-sample" aria-hidden="true">
  <!-- <button type="button" id="testbtn" class="p-modal__btn jstest" aria-label="close this modal01">
    <span class="p-modal__close p-modal__close--01"></span>
    <span class="p-modal__close p-modal__close--02"></span>
  </button> -->
  <div class="p-modal__overlay" data-modal-type="overlay"></div>
  <div role="dialog" class="p-modal__content" aria-modal="true" aria-labelledby="dialog-title" aria-describeby="dialog-desc" data-modal-type="dialog">
    <h3 id="dialog-title" class="p-modal__title" data-modal-type="title">モーダルです!!その1</p>
    <p id="dialog-desc" class="p-modal__desc" data-modal-type="desc">これはダイアログのサンプルです。</p>
    <button type="button">→モーダルを閉じる←</button>
  </div>
</div>
<div class="p-hoge">
  <button class="p-hoge__btn js-modal-trigger" aria-controls="modal_01" aria-expanded="false">ここをクリックでモーダルを開く</button>
</div>
<!-- modal2つ目 -->
<div id="modal_02" class="p-modal --hidden js-modal-sample" aria-hidden="true">
  <button type="button" class="p-modal__btn" aria-label="close this modal">
    <span class="p-modal__close p-modal__close--01"></span>
    <span class="p-modal__close p-modal__close--02"></span>
  </button>
  <div class="p-modal__overlay" data-modal-type="overlay"></div>
  <div role="dialog" class="p-modal__content" aria-modal="true" aria-labelledby="dialog-title" aria-describeby="dialog-desc" data-modal-type="dialog">
    <h3 id="dialog-title" class="p-modal__title" data-modal-type="title">モーダルです!!222222</p>
    <p id="dialog-desc" class="p-modal__desc" data-modal-type="desc">これはダイアログのサンプルです。</p>
  </div>
</div>
<div class="p-hoge">
  <button class="p-hoge__btn js-modal-trigger" aria-controls="modal_02" aria-expanded="false">ここをクリックでモーダルを開く2222</button>
</div>

<div class="space"></div>

<!-- modalオリジナル複数設置テスト -->
<!-- modal1つ目 -->
<div id="modal_01" class="p-modal --hidden js-modal-sample-02" aria-hidden="true">
  <button type="button" id="testbtn" class="p-modal__btn jstest02" aria-label="close this modal01">
    <span class="p-modal__close p-modal__close--01"></span>
    <span class="p-modal__close p-modal__close--02"></span>
  </button>
  <div class="p-modal__overlay" data-modal-type="overlay"></div>
  <div role="dialog" class="p-modal__content" aria-modal="true" aria-labelledby="dialog-title" aria-describeby="dialog-desc" data-modal-type="dialog">
    <h3 id="dialog-title" class="p-modal__title" data-modal-type="title">複数モーダルのテストです!!その1</h3>
    <p id="dialog-desc" class="p-modal__desc" data-modal-type="desc">これはダイアログのサンプルです。</p>
    <button type="button">モーダルを閉じる</button>
  </div>
</div>
<div class="p-hoge">
  <button class="p-hoge__btn js-modal-trigger-02" aria-controls="modal_01" aria-expanded="false">(複数テスト)ここをクリックでモーダルを開く</button>
</div>
<!-- modal2つ目 -->
<div id="modal_02" class="p-modal --hidden js-modal-sample-02" aria-hidden="true">
  <button type="button" class="p-modal__btn" aria-label="close this modal">
    <span class="p-modal__close p-modal__close--01"></span>
    <span class="p-modal__close p-modal__close--02"></span>
  </button>
  <div class="p-modal__overlay" data-modal-type="overlay"></div>
  <div role="dialog" class="p-modal__content" aria-modal="true" aria-labelledby="dialog-title" aria-describeby="dialog-desc" data-modal-type="dialog">
    <h3 id="dialog-title" class="p-modal__title" data-modal-type="title">複数モーダルテストです!!222222</h3>
    <p id="dialog-desc" class="p-modal__desc" data-modal-type="desc">これはダイアログのサンプルです。</p>
    <!-- <button type="button">モーダルを閉じるのですうう</button> -->
  </div>
</div>
<div class="p-hoge ">
  <button class="p-hoge__btn js-modal-trigger-02" aria-controls="modal_02" aria-expanded="false">複数テスト)ここをクリックでモーダルを開く2222</button>
</div>

SCSS


.p-modal {
  width: 100%;
  height: 100vh;
  height: calc(var(--vh, 1vh) * 100);
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: $header_z-index + 1;
  visibility: visible;
  opacity: 1;
  transition: opacity .4s ease-out;
}

.p-modal.--hidden {
  opacity: 0;
  visibility: hidden;
  transition: all .4s ease-out;
}

.p-modal__overlay {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: inherit;
  height: calc(var(--vh, 1vh) * 100);
  background-color: rgba(0, 0, 0, .6);
}

.p-modal__content {
  background-color: #fff;
  padding: 30px 50px;
  border-radius: 10px;
  border: 4px solid rgb(89, 165, 246);
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%,-50%);
  z-index: $header_z-index + 1;
}

.p-modal__btn {
  top: 10px;
  left: 10px;
  width: 30px;
  height: 30px;
  border: none;
  display: block;
  position: relative;
}

.p-modal__close {
  position: absolute;
  top: 0;
  left: 45%;
  width: 4px;
  height: inherit;
  background-color: #fff;
  border-radius: 2px;
}

.p-modal__close--01 {
  display: inline-block;
  transform: rotate(45deg);
}

.p-modal__close--02 {
  display: inline-block;
  transform: rotate(-45deg);
}

Discussion