1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

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

<input type="checkbox" /> とは

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

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

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

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

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

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

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

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

作成したもの

See the Pen toggle-checkbox custom element by juner clarinet (@juner) on CodePen.

ここでは一端 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(", ");
  });
});
1
0
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?