✔️

input が含まれないcustom element にすれば大体解決した話

2025/01/08に公開

ということで 前回の話<toggle-checkbox></toggle-checkbox> の内部で使っていた input を排除した toggle-checkbox が完成しました。

ポイントとしては以下の通り

  • 内部 checked は this.#checked に一元化
  • validation が必要かは ElementInternals.willValidate でチェック
  • validate で focus したい場合は 子として 何らかの要素が必要(自身ではダメ?
  • disabled の対応は別途実装必要
    • 例: tabindex とか
  • space キーによる check / uncheck 動作は別途実装必要
  • 値による要素の状態への反映処理の実装
  • ElementInternals にある ARIA 系のプロパティ対応

実働

で わかりにくいやつだけ解説

validation が必要かは ElementInternals.willValidate でチェック

Validation が 必要かは (※disabled が付いていない等)ElementInternals.willValidate が受け持っているのでそれを使えという話

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

validate で focus したい場合は 子として 何らかの要素が必要(自身ではダメ?

対応させようとした当初 part=toggle な要素だけでやろうとしていたのですが、 何らかのフォーカスさせる要素が無いと setValidity で エラーとなるのでなんだか無理そうだった為

disabled の対応は別途実装必要

disabled は 別途 対応しないと 普通に動作します。readOnly はそもそも checkbox では対応していないので今回は対応していませんが、ここも明示的に対応すれば使えるとは思います。
ただ、 :disabled は 特に何もしなくても CSS セレクタとしては利くみたいです(※ただし、デフォルトスタイルは無いので注意

space キーによる check / uncheck 動作は別途実装必要

これ、元のは input に全任せしていたので別途実装必要です。(それはそう

値による要素の状態への反映処理の実装

幾つか状態の反映が漏れていたりしたのでその対応含め #setValue() で状態を反映しています。

ElementInternals にある ARIA 系のプロパティ対応

特に実感はないが、念の為しておく。
ただ、確認方法がよくわからない(設定すると 未設定の場合の初期値となるらしい?
ElementInternals.role とか。

以上。

ソース

<!-- #region form sample -->
<h1>&lt;toggle-checkbox&gt;&lt;/toggle-checkbox&gt;</h1>
<form id="pattern-1">
  <h2>pattern 1 (value:ok)</h2>
  <label
    >toggle: <toggle-checkbox name="toggle" value="ok"></toggle-checkbox
  ></label>
  <button>submit</button>
  <output></output>
</form>
<form id="pattern-2">
  <h2>pattern 2 (value:ok/checked)</h2>
  <label
    >toggle: <toggle-checkbox name="toggle" value="ok" checked></toggle-checkbox
  ></label>
  <button>submit</button>
  <output></output>
</form>
<form id="pattern-3">
  <h2>pattern 3 (on:on/off:off)</h2>
  <label
    >toggle: <toggle-checkbox name="toggle" on="on" off="off"></toggle-checkbox
  ></label>
  <button>submit</button>
  <output></output>
</form>
<form id="pattern-4">
  <h2>pattern 4 (on:on/off:off/checked)</h2>
  <label
    >toggle:
    <toggle-checkbox name="toggle" on="on" off="off" checked></toggle-checkbox
  ></label>
  <button>submit</button>
  <output></output>
</form>
<form id="pattern-5">
  <h2>pattern 5 (value:ok / required)</h2>
  <label
    >toggle:
    <toggle-checkbox name="toggle" value="ok" required></toggle-checkbox
  ></label>
  <button>submit</button>
  <output></output>
</form>
<form id="pattern-6">
  <h2>pattern 6 (value:ok / disabled)</h2>
  <label
    >toggle:
    <toggle-checkbox name="toggle" value="ok" disabled></toggle-checkbox
  ></label>
  <button>submit</button>
  <output></output>
</form>
<form id="pattern-7">
  <h2>pattern 7 (value:ok / disabled / checked)</h2>
  <label
    >toggle:
    <toggle-checkbox name="toggle" value="ok" disabled checked></toggle-checkbox
  ></label>
  <button>submit</button>
  <output></output>
</form>
<form id="pattern-8">
  <h2>pattern 8 (value:ok / readonly / checked)</h2>
  <label
    >toggle:
    <toggle-checkbox name="toggle" value="ok" readonly checked></toggle-checkbox
  ></label>
  <button>submit</button>
  <output></output>
</form>
<h1>&lt;input type=checkbox /&gt;</h1>
<form id="checkbox-1">
  <h2>checkbox 1 (value:ok)</h2>
  <label
    >toggle: <input type="checkbox" name="toggle" value="ok"></label>
  <button>submit</button>
  <output></output>
</form>
<form id="checkbox-2">
  <h2>checkbox 2 (value:ok / required)</h2>
  <label
    >toggle: <input type="checkbox" name="toggle" value="ok" required></label>
  <button>submit</button>
  <output></output>
</form>
<form id="checkbox-3">
  <h2>checkbox 3 (value:ok / disabled / checked)</h2>
  <label
    >toggle: <input type="checkbox" name="toggle" value="ok" disabled checked></label>
  <button>submit</button>
  <output></output>
</form>
<form id="checkbox-3">
  <h2>checkbox 3 (value:ok / readonly / checked)</h2>
  <label
    >toggle: <input type="checkbox" name="toggle" value="ok" readonly checked></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>
  <span class="checkbox" part="base" tabindex="0"
    ><span part="toggle" class="toggle"></span
  ></span>
</template>
<!-- #endregion -->
@property --accent-color {
  syntax: "<color>";
  inherits: true;
  initial-value: black;
}
/* #region base style */
html {
  display: flex;
  flex-flow: column nowrap;
}
body {
  display: contents;
}
label {
  display: inline flex;
  gap: 0.5em;
}

:where(toggle-checkbox) {
  &:disabled {
    opacity: 0.5;
  }
}
h1 {
  background: black;
  color: white;
  margin:0;
  position: sticky;
  top:0;
  z-index: 100;
}
h2 {
  background: silver;
  color: white;
  margin:0;
}
/* #endregion */
/* #region toggle style */
: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;
  }
}
/* #endregion */
/** @type {HTMLTemplateElement} */
const toggleCheckboxTemplate = document.getElementById(
  "toggle-checkbox-template",
);
export default class ToggleCheckbox extends HTMLElement {
  static {
    globalThis.customElements.define("toggle-checkbox", ToggleCheckbox);
  }
  static get formAssociated() {
    return true;
  }
  static get observedAttributes() {
    return [
      "disabled",
      "required",
      "on",
      "off",
      "value",
      "checked",
      "name",
    ];
  }
  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();
    const internals = this.#internals;
    internals.role = "checkbox";
    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 {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);
  }
  /** @type {boolean} */
  #checked;
  get checked() {
    return this.#checked;
  }
  /**
   * @param {boolean} checked
   */
  set 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");
      internals.ariaChecked = "true";
    } else {
      internals.states.delete("checked");
      internals.ariaChecked = "false";
    }
    internals.ariaDisabled = this.disabled ? "true" : "false";
    if (this.#internals.willValidate) {
      if (this.off === "" && this.required && !this.checked) {
        internals.setValidity(
          {
            valueMissing: true,
          },
          "選択してください",
          this.#focus,
        );
        return;
      }
      internals.setValidity(undefined, undefined);
    }
    if (!this.disabled) {
      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) {
    // #region tabindex
    if (!this.hasAttribute("tabindex")) {
      this.tabIndex = disabled ? -1 : 0;
    }
    // #endregion
    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");
      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") ?? "";
    }
  }
  /**
   * @param {{signal: AbortSignal}} options
   */
  #setFormat({ signal }) {
    this.addEventListener(
      "keydown",
      (e) => {
        const { ctrlKey, shiftKey, key } = e;
        if (ctrlKey || shiftKey) return;
        if (key !== " ") return;
        e.preventDefault();
        if (this.disabled) return;
        this.checked = !this.checked;
        this.dispatchEvent(new Event("change"));
        this.#setValue();
      },
      { signal },
    );
    this.addEventListener(
      "click",
      (e) => {
        const { target } = e;
        if (this.disabled) {
          e.preventDefault();
          return;
        }
        this.checked = !this.checked;
        this.dispatchEvent(new Event("change"));
        this.#setValue();
      },
      { signal },
    );
    this.#setValue();
  }
}

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