🚀

toggle する カスタムコンポーネントを作成してみよう!

2024/12/31に公開

皆様、 <input type=checkbox /> を使っておられますでしょうか?

<input type="checkbox" /> とは

HTMLInputElement の type=checkbox は name が設定されており、 checked の時だけ value が FormData に設定される コントロールです。

よく css で :checked を使って toggle の再現をされているところを見ますが、 いっそのこと カスタムコンポーネントとして作り直して見ましょうというのが今回の 取り組みです。

カスタムコンポーネントと Form

まず、 フォームと連携する為には クラスの static プロパティとして formAssociated が true を返す必要があります。

https://web.dev/articles/more-capable-form-controls?hl=ja#defining_a_form-associated_custom_element

また、 shadowDOM 上の要素に フォーカスを合わせる為に attachShadow() の option として delegatesFocus プロパティに true を渡す必要があります。

https://developer.mozilla.org/ja/docs/Web/API/Element/attachShadow

値の設定は ElementInternals.setFormValue() で設定する必要があります。

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

Validation は ElementInternals.setValidity() で設定する必要があります。

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

以上を使いカスタムコンポーネントを作ります。

作成したもの

ここでは一端 template 要素として コンポーネント内の構成を組んでいますが、これは vite でやるなら html に書いてしまって、 import ?raw でソース文字列をとって shadowRoot.innerHTML で書き込むのがおすすめです。

また、 css を template 内に書いていますが、 これも vite なら css ファイルに移動して ?url で URL化して link 要素で読み込む様にすることをおすすめします。

四苦八苦したところ

中で label と input checkbox で toggle を作っている都合上、 カスタムコンポーネントへのクリックではうまく label からのクリックを 中の label クリック時に接続出来なかった為、 ElementInternals.labels で一端そとの label を繋げているところですね。

多分これは全部独自にすればそもそもここに依存しなくていいのではというところです。

以上。

ソース

<!-- #region form sample -->
<form id="pattern-1">
  <h1>pattern 1 (value:ok)</h1>
  <label
    >toggle: <toggle-checkbox name="toggle" value="ok"></toggle-checkbox
  ></label>
  <button>submit</button>
  <output></output>
</form>
<form id="pattern-2">
  <h1>pattern 2 (value:ok/checked)</h1>
  <label
    >toggle: <toggle-checkbox name="toggle" value="ok" checked></toggle-checkbox
  ></label>
  <button>submit</button>
  <output></output>
</form>
<form id="pattern-3">
  <h1>pattern 3 (on:on/off:off)</h1>
  <label
    >toggle: <toggle-checkbox name="toggle" on="on" off="off"></toggle-checkbox
  ></label>
  <button>submit</button>
  <output></output>
</form>
<form id="pattern-4">
  <h1>pattern 4 (on:on/off:off/checked)</h1>
  <label
    >toggle:
    <toggle-checkbox name="toggle" on="on" off="off" checked></toggle-checkbox
  ></label>
  <button>submit</button>
  <output></output>
</form>
<form id="pattern-5">
  <h1>pattern 5 (value:ok / required)</h1>
  <label
    >toggle:
    <toggle-checkbox name="toggle" value="ok" required></toggle-checkbox
  ></label>
  <button>submit</button>
  <output></output>
</form>
<!-- #endregion -->
<!-- #region toggle-checkbox template -->
<template id="toggle-checkbox-template">
  <style>
    :host {
      display: inline flex;
      flex-flow: row nowrap;
      align-items: center;
      min-width: var(--width);
      min-height: var(--height);
      --width: 2em;
      --height: 1em;

      .checkbox {
        display: block flex;
        flex: 1 1 auto;
        position: relative;
        width: var(--width);
        height: var(--height);
        border-radius: 50px;
        background-color: #dddddd;
        cursor: pointer;
        transition: background-color 0.4s;

        .toggle {
          position: absolute;
          pointer-events: none;
          top: 0;
          left: 0;
          width: calc(var(--width) * 0.5);
          height: var(--height);
          border-radius: 50%;
          box-shadow: 0 0 5px color(srgb 0 0 0 / 20%);
          background-color: #fff;
          transition: left 0.4s;
        }
      }
    }
    :host(:state(checked)) {
      .checkbox {
        background-color: var(--accent-color);

        .toggle {
          left: calc(var(--width) * 0.5);
        }
      }
    }
  </style>
  <label class="checkbox" part="base" tabindex="0"
    ><input type="checkbox" hidden /><span part="toggle" class="toggle"></span
  ></label>
</template>
<!-- #endregion -->
@property --accent-color {
  syntax: "<color>";
  inherits: true;
  initial-value: black;
}
label {
  display: inline flex;
  gap: 0.5em;
}
:where(#pattern-2) toggle-checkbox {
  --accent-color: yellow;
  &:state(checked) {
    --accent-color: red;
  }
}
:where(#pattern-3 toggle-checkbox) {
  &::part(base) {
    border-radius: 0;
  }
  &::part(toggle) {
    border-radius: 0;
    background-color: green;
  }
}

:where(#pattern-4 toggle-checkbox) {
  &:state(checked)::part(base) {
    background-color: goldenrod;
  }
}
:where(#pattern-5 toggle-checkbox):invalid {
  &::part(base) {
    border: red 1px solid;
  }
}
/** @type {HTMLTemplateElement} */
const toggleCheckboxTemplate = document.getElementById(
  "toggle-checkbox-template"
);
export default class ToggleCheckbox extends HTMLElement {
  static get formAssociated() {
    return true;
  }
  static get observedAttributes() {
    return [
      "disabled",
      "required",
      "on",
      "off",
      "value",
      "checked",
      "name",
      "readonly"
    ];
  }
  constructor({ mode } = {}) {
    super();
    this.#mode = mode ?? "closed";
  }
  /** @type {"closed" | "open"} */
  #mode;
  /** @type {ShadowRoot} */
  #shadow;
  /** @type {ElementInternals} */
  #internals;
  /** @type {AbortController} */
  #controller;

  connectedCallback() {
    this.#controller?.abort();
    this.#controller = new AbortController();
    const signal = this.#controller.signal;
    this.#shadow ??= (() => {
      const shadow = this.attachShadow({
        mode: this.#mode,
        delegatesFocus: true
      });
      shadow.appendChild(toggleCheckboxTemplate.content.cloneNode(true));
      return shadow;
    })();
    this.#internals ??= this.attachInternals();
    this.#setFormat({
      signal
    });
  }
  #on = "";
  #off = "";
  get on() {
    return this.#on;
  }
  /**
   * @param {string} on
   */
  set on(on) {
    this.#on = on;
    if (this.#on !== this.getAttribute("on")) this.setAttribute("on", on);
  }
  get off() {
    return this.#off;
  }
  /**
   * @param {string} off
   */
  set off(off) {
    this.#off = off;
    if (this.#off !== this.getAttribute("off")) this.setAttribute("off", off);
  }
  /** @type {HTMLInputElement} */
  get #input() {
    return this.#shadow?.querySelector(`input[type="checkbox"]`);
  }
  /** @type {HTMLSpanElement} */
  get #focus() {
    return this.#shadow?.querySelector(".checkbox");
  }
  get labels() {
    return this.#internals?.labels;
  }
  get name() {
    return this.getAttribute("name") ?? "";
  }
  /**
   * @param {string} name
   */
  set name(name) {
    if (this.getAttribute("name") !== name) this.setAttribute("name", name);
  }
  get readOnly() {
    return this.hasAttribute("readonly");
  }
  /**
   * @param {boolean} readOnly
   */
  set readOnly(readOnly) {
    if (readOnly === this.hasAttribute("readonly")) return;
    if (readOnly) this.setAttribute("readonly", "");
    else this.removeAttribute("readonly");
  }
  /** @type {boolean} */
  #checked;
  get checked() {
    return this.#input?.checked ?? this.#checked;
  }
  /**
   * @param {boolean} checked
   */
  set checked(checked) {
    const checkbox = this.#input;
    if (checkbox) {
      if (checkbox.checked !== checked) checkbox.checked = checked;
    }
    this.#checked = checked;
    this.#setValue();
  }
  #setValue() {
    if (this.hasAttribute("checked") !== this.checked) {
      if (this.checked) {
        this.setAttribute("checked", "");
      } else {
        this.removeAttribute("checked");
      }
    }
    const internals = this.#internals;
    if (!internals) return;

    if (this.checked) {
      internals.states.add("checked");
    } else {
      internals.states.delete("checked");
    }
    if (this.off === "" && this.required && !this.checked) {
      internals.setValidity(
        {
          valueMissing: true
        },
        "選択してください",
        this.#focus
      );
      return;
    }
    internals.setValidity(undefined, undefined);
    if (this.off === "" && this.value === "") {
      internals.setFormValue(new FormData());
    } else {
      internals.setFormValue(this.value);
    }
  }
  get required() {
    return this.hasAttribute("required");
  }
  /**
   * @param {boolean} required
   */
  set required(required) {
    if (required === this.hasAttribute("required")) return;
    if (required) this.setAttribute("required", "");
    else this.removeAttribute("required");
  }
  get disabled() {
    return this.hasAttribute("disabled");
  }
  /**
   * @paramn {boolean} disabled
   */
  set disabled(disabled) {
    if (disabled === this.hasAttribute("disabled")) return;
    if (disabled) this.setAttribute("disabled", "");
    else this.removeAttribute("disabled");
  }
  get value() {
    return this.checked ? this.on : this.off;
  }
  /**
   * @param {string} value
   */
  set value(value) {
    if (this.on === "") {
      this.on = value;
      return;
    }
    if (value === this.on) {
      this.checked = true;
      return;
    }
    if (value === this.off) {
      this.checked = false;
      return;
    }
    console.warn("not found value:");
  }
  /**
   * @param {string} name
   */
  attributeChangedCallback(name) {
    if (name === "disabled") {
      this.disabled = this.hasAttribute("disabled");
      const checkbox = this.#input;
      if (checkbox) {
        checkbox.disabled = this.disabled;
      }
      return;
    }
    if (name === "required") {
      this.required = this.hasAttribute("required");
      return;
    }
    if (name === "on") {
      this.on = this.getAttribute("on") ?? "";
      return;
    }
    if (name === "off") {
      this.off = this.getAttribute("off") ?? "";
      return;
    }
    if (name === "value") {
      this.value = this.getAttribute("value") ?? "";
      return;
    }
    if (name === "checked") {
      this.checked = this.hasAttribute("checked");
      return;
    }
    if (name === "name") {
      this.name = this.getAttribute("name") ?? "";
      const checkbox = this.#input;
      if (checkbox) checkbox.name = this.name;
    }
    if (name === "readonly") {
      this.readOnly = this.hasAttribute("readonly");
      const checkbox = this.#input;
      if (checkbox) checkbox.readOnly = this.readOnly;
    }
  }
  /**
   * @param {{signal: AbortSignal}} options
   */
  #setFormat({ signal }) {
    const checkbox = this.#input;
    checkbox.checked = this.#checked;
    checkbox.name = this.name;
    checkbox.disabled = this.disabled;
    checkbox.readOnly = this.readOnly;
    checkbox.addEventListener(
      "change",
      ({target}) => {
        const checked = target.checked;
        this.checked = checked;
        const event = new Event("change");
        this.dispatchEvent(event);
      },
      { signal }
    );
    checkbox.addEventListener("input", () => {
      const event = new Event("input");
      this.dispatchEvent(event);
    });
    this.labels.forEach(
      (label) => {
        label.addEventListener("click", (e) => {
          if (e.target !== e.currentTarget) return;
          e.preventDefault();
          checkbox.click();
        });
      },
      { signal }
    );
    this.#setValue();
  }
}
globalThis.customElements.define("toggle-checkbox", ToggleCheckbox);
document.querySelectorAll("form").forEach((form) => {
  form.addEventListener("submit", (e) => {
    e.preventDefault();
    const data = new FormData(e.target);
  });
  form.addEventListener("formdata", ({ formData, target }) => {
    /** @type {HTMLOutputElement} */
    const output = target.querySelector("output");
    output.innerHTML = Array.from(
      formData,
      ([key, value]) => `${key}=${value}`
    ).join(", ");
  });
});

Discussion