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?

input が含まれないcustom element の toggle-checkbox を作ったところlabels とか色々と問題が解決した話

Posted at

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

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

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

実働

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

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

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

Validation が 必要かは (※disabled が付いていない等)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(", ");
  });
});
1
0
0

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?