🌀

ローディングをスクリプトに混ぜられるような カスタマイズポイント豊富な web component を作ってみよう!

2024/12/06に公開

というわけで 素の js で loading Dialog を作っていきたいと思います。

ソース

でソースがここにあります。

/**
 * loading Dialog
 */
export class LoadingDialog extends HTMLElement {
  /** @type {"open"|"closed"} */
  #mode;
  /** @type {ShadowRoot} */
  #shadow;
  /** @type {ElementInternals} */
  #internals;
  /** @type {AbortController|undefined} */
  #currentController;
  /** @type {boolean} */
  #cancelable_ = false;
  /** 現在 モーダルを表示しているかどうか */
  get open() {
    return !(this.#currentController?.signal.aborted ?? true);
  }
  #startOpen() {
    this.#internals.states.add("open");
    this.setAttribute("open", "");
  }
  #stopOpen() {
    this.#internals.states.delete("open");
    this.removeAttribute("open");
  }
  abort() {
    if (!this.open) return;
    this.#currentController?.abort();
    this.#currentController = undefined;
  }
  get #cancelable() {
    return this.#cancelable_;
  }
  set #cancelable(v) {
    if (v) {
      this.#internals.states.add("cancelable");
      this.setAttribute("cancelable", "");
    } else {
      this.#internals.states.delete("cancelable");
      this.removeAttribute("cancelable");
    }
    this.#cancelable_ = v;
  }
  get cancelable() {
    return this.#cancelable_;
  }
  /**
   *
   * @param {{
   *  mode?: "open"|"closed";
   * }} param0
   */
  constructor({ mode } = {}) {
    super();
    this.#mode = mode ?? "closed";
  }
  #makeInnerHTML() {
    return `
    <dialog part="dialog" id="loading">
      <slot>
        <div class="container">
          <div class="circle"></div>
        </div>
      </slot>
      <form method="dialog">
        <button part="close" id="close" title="閉じる">✕</button>
      </form>
    </dialog>
    <style>
:host {
  display: none;
  :where(#close) {
    display: none;
  }
}
:host(:state(cancelable)) {
  :where(#close) {
    display: initial;
    cursor: pointer;
    position: fixed;
    right: 0;
    top: 0;
  }
}
:host(:state(open)) {
  display: contents;
  --border-color: rgb(255,255,255, 0.2);
  --top-color: #FFF;
  outline:none;
  :where(#loading) {
    border: none;
    background: transparent;
    outline:0;
    :where(.container) {
      position: relative;
      display: inline-block;
      box-sizing: border-box;
      padding: 30px;
      width: 25%;
      height: 140px;
      .circle {
        box-sizing: border-box;
        width: 80px;
        height: 80px;
        border-radius: 100%;
        border: 10px solid var(--border-color);
        border-top-color: var(--top-color);
        animation: spin 1s infinite linear;
      }
    }
  }
}
@keyframes spin {
  100% {
    transform: rotate(360deg);
  }
}
    </style>
    `;
  }
  connectedCallback() {
    const shadow = this.attachShadow({ mode: this.#mode });
    this.#internals = this.attachInternals();
    this.#cancelable = this.hasAttribute('cancelable');
    const opened = this.hasAttribute('open');
    shadow.innerHTML = this.#makeInnerHTML();
    this.#shadow = shadow;
    if (opened)
      this.showModal();
  }
  /**
   *
   * @param {Parameters<InstanceType<typeof LoadingDialog>["showModal"]>[0]} options
   */
  startModal(options) {
    const wait = this.showModal(options);
    const controller = this.#currentController;
    return {
      closeModal() {
        return closeAndWaitModal();
      },
      [Symbol.asyncDispose]: async () => {
        return closeAndWaitModal();
      }
    };
    async function closeAndWaitModal() {
      if (!controller.signal.aborted) controller.abort();
      await wait;
    }
  }
  /** @returns {HTMLDialogElement} */
  get #dialog() {
    return this.#shadow.getElementById("loading");
  }
  /**
   *
   * @param {{
   *   signal?: AbortSignal;
   *   cancelable?: boolean;
   * }} options
   * @returns
   */
  showModal({ signal: parentSignal, cancelable } = {}) {
    this.abort();
    /** @type {HTMLDialogElement} */
    const dialog = this.#dialog;
    if (!dialog) throw new Error("required id=loading element");
    if (typeof cancelable === "boolean") {
      this.#cancelable = cancelable;
    }
    cancelable = this.cancelable;
    const controller = new AbortController();
    const dialogSignal = AbortSignal.any([
      controller.signal,
      ...(parentSignal ? [parentSignal] : [])
    ]);
    this.#currentController = controller;
    /** @type {{promise: Promise<void>, resolve: () => void, reject: () => void}} */
    const { promise, resolve, reject } = Promise.withResolvers();

    promise.finally(() => controller.abort());
    this.#startOpen();
    promise.finally(() => this.#stopOpen());
    promise.finally(() => {
      if (!dialog.open) return;
      dialog.close();
    });

    let closable = cancelable;
    dialogSignal.addEventListener("abort", abort);
    dialog.addEventListener(
      "cancel",
      () => {
        if (cancelable) return;
        closable = false;
      },
      { signal: dialogSignal }
    );
    dialog.addEventListener(
      "keydown",
      (event) => {
        if (cancelable) return;
        if (event.key === "Escape" && !closable) {
          event.preventDefault();
        }
      },
      { signal: dialogSignal }
    );
    dialog.addEventListener(
      "close",
      (e) => {
        if (!closable) {
          e.preventDefault();
          return;
        }
        resolve();
      },
      { signal: dialogSignal }
    );
    dialog.showModal();
    return promise;
    function abort() {
      closable = true;
      resolve();
      if (controller.signal.aborted) return;
      controller.abort();
    }
  }
}

globalThis.customElements.define("loading-dialog", LoadingDialog);

ポイント

状態を公開する為の :state()

loading を表示しているときだけスタイルを適用する為に :state(open) で開いている状態をとることができます。

https://developer.mozilla.org/ja/docs/Web/CSS/:state

:state() で状態を公開する為には HTMLElement.attachInternals();ElementInternals を取得する必要があります。

this.#internals = this.attachInternals();

https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/attachInternals

https://developer.mozilla.org/ja/docs/Web/API/ElementInternals

そして 有効になる際に ElementInternals.states にキーを追加 / 無効になった際に キーを削除する必要があります。

  #startOpen() {
    this.#internals.states.add("open");
    this.setAttribute("open", "");
  }
  #stopOpen() {
    this.#internals.states.delete("open");
    this.removeAttribute("open");
  }

https://developer.mozilla.org/ja/docs/Web/API/ElementInternals/states

CSS側からアクセスする場合は次の様にします。

コンポーネントの中から :state()

もしも コンポーネントの中からアクセスする場合は
次の様に :host() の中に :state() を指定してチェックします。

:host {
  display: none;
}
:host(:state(open)) {
  display: contents;
}

コンポーネントの外から :state()

もしも コンポーネントの外からアクセスする場合は
次の様に対象の コンポーネントに :state() をつけてチェックします。

loading-dialog:where(#loading2):state(open) {
    /* todo */
}

中の要素を公開する ::part()

外から dialog のスタイリングをする為に loading Dialog では 内部ダイアログを ::part(dialog) で公開しています。

https://developer.mozilla.org/ja/docs/Web/CSS/::part

これは 公開したい要素の part 属性に名前をつけてやることで実現できます。

https://developer.mozilla.org/ja/docs/Web/HTML/Global_attributes/part

スタイルの一部を挿入できるようにする CSS カスタムプロパティ

web component は 外のCSSの影響を受けないは受けないですが、 css custom property で一部のスタイルを適用することができます。

https://developer.mozilla.org/ja/docs/Web/CSS/--*

今回ので言うと次の様に初期値を宣言したうえで中の方でつかっていた感じです。

:host(:state(open)) {
  display: contents;
  --border-color: rgb(255,255,255, 0.2);
  --top-color: #FFF;
  /* ... */ {
        border: 10px solid var(--border-color);
        border-top-color: var(--top-color);
        animation: spin 1s infinite linear;
  /* ... */ }
}

外からは次の様に適用していました。

loading-dialog:where(#loading2):state(open) {
  /* rotate border base color */
  --border-color: yellow;
  /* rotate border highlight color */
  --top-color: green;
}

コンポーネント内に書いた要素を 転送する slot 要素

今回は name 無しではありましたが、 ローディングを自由に表現できるようにする為に slot 要素を用意していました。

<dialog part="dialog" id="loading">
  <slot>
    <div class="container">
      <div class="circle"></div>
    </div>
  </slot>
</dialog>

https://developer.mozilla.org/ja/docs/Web/HTML/Element/slot

https://developer.mozilla.org/ja/docs/Web/API/Web_components/Using_templates_and_slots

今回は name 無しの slot なので 直下に於いていた要素が転記されていました。

<loading-dialog id="loading3">
  <div class="loader"></div>
</loading-dialog>

コンポーネント にメソッドを生やす

今回の主題は loading Dialog なので スコープを抜けたら閉じる .startModal() と そろそろ流行りの Explicit resource management (async) を実装してみました。

https://chromestatus.com/feature/5087324181102592

使い方は 次の様にしたいのですが、 まだ TypeScript でしか使えない為

{
    await using source = loadingDialog.startModal();
    await new Promise((resolve) => setTimeout(resolve, timeout));
}

それを展開した形でとりあえずお茶を濁しておきます。

const source = loadingDialog.startModal();
/** @type {() => Promise<void>} */
const asyncDispose = source[Symbol.asyncDispose].bind(source);
try {
    await new Promise((resolve) => setTimeout(resolve, timeout));
} finally {
    await asyncDispose();
}

ちなみに typescript なら await using が既に使えるので次の様に書けます。

await using source = loadingDialog.startModal();
await new Promise((resolve) => setTimeout(resolve, timeout));

codepen は await using 対応した TypeScript は使えないので livecodes.io にて TypeScript 版も貼っておきます。

https://livecodes.io/?x=id/jvjgvqibfdj

おわりに

皆さんも カスタマイザブル な web component で幸せな カスタムライフを!

以上。

Discussion