ということで 前回の話の <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><toggle-checkbox></toggle-checkbox></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><input type=checkbox /></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(", ");
});
});