@itenthusiasm/custom-elements 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,86 @@
1
+ export default CheckboxGroup;
2
+ /** @import {ExposedInternals, FieldPropertiesAndMethods, FieldMinMax} from "../types/helpers.js" */
3
+ /** @implements {ExposedInternals} @implements {FieldPropertiesAndMethods} @implements {FieldMinMax} */
4
+ declare class CheckboxGroup extends HTMLElement implements ExposedInternals, FieldPropertiesAndMethods, FieldMinMax {
5
+ /** @returns {true} */
6
+ static get formAssociated(): true;
7
+ static get observedAttributes(): readonly ["required", "valuemissingerror", "min", "rangeunderflowerror", "max", "rangeoverflowerror"];
8
+ /**
9
+ * @param {typeof CheckboxGroup.observedAttributes[number]} _name
10
+ * @param {string | null} _oldValue
11
+ * @param {string | null} _newValue
12
+ * @returns {void}
13
+ */
14
+ attributeChangedCallback(_name: (typeof CheckboxGroup.observedAttributes)[number], _oldValue: string | null, _newValue: string | null): void;
15
+ /** "On Mount" for Custom Elements @returns {void} */
16
+ connectedCallback(): void;
17
+ set name(value: HTMLInputElement["name"]);
18
+ /** @returns {HTMLInputElement["name"]} */
19
+ get name(): HTMLInputElement["name"];
20
+ set value(v: string[]);
21
+ /** Sets or retrieves the `value` of the `CheckboxGroup` @returns {string[]} */
22
+ get value(): string[];
23
+ /** The singular `<fieldset>` owned by this checkbox group @returns {HTMLFieldSetElement} */
24
+ get fieldset(): HTMLFieldSetElement;
25
+ set disabled(value: HTMLInputElement["disabled"]);
26
+ /** @returns {HTMLInputElement["disabled"]} */
27
+ get disabled(): HTMLInputElement["disabled"];
28
+ set required(value: HTMLInputElement["required"]);
29
+ /** @returns {HTMLInputElement["required"]} */
30
+ get required(): HTMLInputElement["required"];
31
+ set min(value: HTMLInputElement["min"]);
32
+ /** @returns {HTMLInputElement["min"]} */
33
+ get min(): HTMLInputElement["min"];
34
+ set max(value: HTMLInputElement["max"]);
35
+ /** @returns {HTMLInputElement["max"]} */
36
+ get max(): HTMLInputElement["max"];
37
+ set manual(value: boolean);
38
+ /**
39
+ * Indicates that the `CheckboxGroup` should not replace its `<fieldset>`'s accessible label (`<legend>`)
40
+ * with its own (`<label>`) when mounted.
41
+ *
42
+ * **WARNING**: Only enable this property if you intend to perform the label replacement yourself.
43
+ * @returns {boolean}
44
+ */
45
+ get manual(): boolean;
46
+ set valueMissingError(value: string);
47
+ /** The error message displayed to users when the group's `required` constraint is broken. @returns {string} */
48
+ get valueMissingError(): string;
49
+ set rangeUnderflowError(value: string);
50
+ /** The error message displayed to users when the group's `min` constraint is broken. @returns {string} */
51
+ get rangeUnderflowError(): string;
52
+ set rangeOverflowError(value: string);
53
+ /** The error message displayed to users when the group's `max` constraint is broken. @returns {string} */
54
+ get rangeOverflowError(): string;
55
+ /** @returns {ElementInternals["labels"]} */
56
+ get labels(): ElementInternals["labels"];
57
+ /** @returns {ElementInternals["form"]} */
58
+ get form(): ElementInternals["form"];
59
+ /** @returns {ElementInternals["validity"]} */
60
+ get validity(): ElementInternals["validity"];
61
+ /** @returns {ElementInternals["validationMessage"]} */
62
+ get validationMessage(): ElementInternals["validationMessage"];
63
+ /** @returns {ElementInternals["willValidate"]} */
64
+ get willValidate(): ElementInternals["willValidate"];
65
+ checkValidity(): boolean;
66
+ reportValidity(): boolean;
67
+ setCustomValidity(error: string): void;
68
+ /** @returns {void} */
69
+ formResetCallback(): void;
70
+ /**
71
+ * @param {FormData | null} state
72
+ * @param {"restore" | "autocomplete"} _mode
73
+ * @returns {void}
74
+ */
75
+ formStateRestoreCallback(state: FormData | null, _mode: "restore" | "autocomplete"): void;
76
+ /**
77
+ * @param {boolean} disabled
78
+ * @returns {void}
79
+ */
80
+ formDisabledCallback(disabled: boolean): void;
81
+ #private;
82
+ }
83
+ import type { ExposedInternals } from "../types/helpers.js";
84
+ import type { FieldPropertiesAndMethods } from "../types/helpers.js";
85
+ import type { FieldMinMax } from "../types/helpers.js";
86
+ //# sourceMappingURL=CheckboxGroup.d.ts.map
@@ -0,0 +1,389 @@
1
+ /** @import {ExposedInternals, FieldPropertiesAndMethods, FieldMinMax} from "../types/helpers.js" */
2
+
3
+ /** @implements {ExposedInternals} @implements {FieldPropertiesAndMethods} @implements {FieldMinMax} */
4
+ class CheckboxGroup extends HTMLElement {
5
+ /* ------------------------------ Custom Element Settings ------------------------------ */
6
+ /** @returns {true} */
7
+ static get formAssociated() {
8
+ return true;
9
+ }
10
+
11
+ static get observedAttributes() {
12
+ return /** @type {const} */ ([
13
+ "required",
14
+ "valuemissingerror",
15
+ "min",
16
+ "rangeunderflowerror",
17
+ "max",
18
+ "rangeoverflowerror",
19
+ ]);
20
+ }
21
+
22
+ /** @readonly */ #internals = this.attachInternals();
23
+
24
+ /* ------------------------------ Lifecycle Callbacks ------------------------------ */
25
+ constructor() {
26
+ super();
27
+ // NOTE: For Playwright and Browser Compatibility purposes, we'll still need to set the `role` attribute explicitly.
28
+ this.#internals.role = "group";
29
+
30
+ // NOTE: We are intentionally adding these here to monitor behavior of elements not connected to the DOM.
31
+ this.addEventListener("click", this.#handleClick.bind(this));
32
+ new MutationObserver(this.#watchChildren.bind(this)).observe(this, { childList: true, subtree: true });
33
+ }
34
+
35
+ /**
36
+ * @param {typeof CheckboxGroup.observedAttributes[number]} _name
37
+ * @param {string | null} _oldValue
38
+ * @param {string | null} _newValue
39
+ * @returns {void}
40
+ */
41
+ attributeChangedCallback(_name, _oldValue, _newValue) {
42
+ // NOTE: If we're only watching constraint-related attributes, then we only need to re-run validation here.
43
+ this.#validateConstraints();
44
+ }
45
+
46
+ /** "On Mount" for Custom Elements @returns {void} */
47
+ connectedCallback() {
48
+ if (!this.isConnected) return;
49
+ if (!(this.lastElementChild instanceof HTMLFieldSetElement) || this.firstElementChild !== this.lastElementChild) {
50
+ throw new TypeError("A <fieldset> element must be the only direct descendant of the `CheckboxGroup`.");
51
+ }
52
+
53
+ // Transfer `<fieldset>` attributes to `CheckboxGroup`
54
+ const { fieldset } = this;
55
+ const attributeNames = fieldset.getAttributeNames();
56
+ for (let i = 0; i < attributeNames.length; i++) {
57
+ const attrName = attributeNames[i];
58
+ this.setAttribute(attrName, /** @type {string} */ (fieldset.getAttribute(attrName)));
59
+ fieldset.removeAttribute(attrName);
60
+ }
61
+
62
+ // Initialize essential attributes
63
+ this.id ||= Math.random().toString(36).slice(2);
64
+ this.setAttribute("role", "group");
65
+ this.lastElementChild.setAttribute("role", "none");
66
+
67
+ // Transfer `<fieldset>`'s label if it exists
68
+ const legend = fieldset.firstElementChild;
69
+ if (legend instanceof HTMLLegendElement && !this.manual) {
70
+ const label = document.createElement("label");
71
+ label.htmlFor = this.id;
72
+ label.append(...legend.childNodes);
73
+ legend.replaceWith(label);
74
+ }
75
+
76
+ // Initialize checkbox data
77
+ const { elements } = this.fieldset;
78
+ for (let i = 0; i < elements.length; i++) {
79
+ const checkbox = elements[i];
80
+ // TODO: Document that, at least for now, nesting `checkbox` groups is not suppoted
81
+ if (!(checkbox instanceof HTMLInputElement) || checkbox.type !== "checkbox") {
82
+ throw new TypeError("`checkbox`es are the only form controls allowed inside the `CheckboxGroup`'s <fieldset>");
83
+ }
84
+
85
+ // TODO: Document the auto-`name`-ing behavior. It's worth being aware of.
86
+ this.#value[checkbox.checked ? "add" : "delete"](checkbox.value);
87
+ if (i === 0 && checkbox.name) this.name = checkbox.name;
88
+ checkbox.removeAttribute("name");
89
+ checkbox.setAttribute("form", "");
90
+ }
91
+
92
+ this.#updateFormValue();
93
+ this.#validateConstraints();
94
+ }
95
+
96
+ /* ------------------------------ Exposed Form Properties ------------------------------ */
97
+ /** @type {Set<string>} */ #value = new Set();
98
+
99
+ // TODO: Document that for Data Integrity purposes, this getter always creates a new `Array`. So devs should be mindful.
100
+ /** Sets or retrieves the `value` of the `CheckboxGroup` @returns {string[]} */
101
+ get value() {
102
+ return Array.from(this.#value);
103
+ }
104
+
105
+ // TODO: Document why devs should be mindful of setting `value` as well.
106
+ set value(v) {
107
+ this.#value.clear();
108
+
109
+ const checkboxes = /** @type {HTMLCollectionOf<HTMLInputElement>} */ (this.fieldset.elements);
110
+ for (let i = 0; i < this.fieldset.elements.length; i++) {
111
+ const checkbox = checkboxes[i];
112
+ checkbox.checked = v.findIndex((V) => String(V) === checkbox.value) !== -1;
113
+ if (checkbox.checked) this.#value.add(checkbox.value);
114
+ }
115
+
116
+ this.#updateFormValue();
117
+ this.#validateConstraints();
118
+ }
119
+
120
+ /** Synchronizes the component's form value with its internal value @returns {void} */
121
+ #updateFormValue() {
122
+ if (!this.#value.size) return this.#internals.setFormValue(null);
123
+
124
+ const formData = new FormData();
125
+ this.#value.forEach((v) => formData.append(this.name, v));
126
+ this.#internals.setFormValue(formData);
127
+ }
128
+
129
+ /** The singular `<fieldset>` owned by this checkbox group @returns {HTMLFieldSetElement} */
130
+ get fieldset() {
131
+ return /** @type {HTMLFieldSetElement} */ (this.lastElementChild);
132
+ }
133
+
134
+ /** @returns {HTMLInputElement["name"]} */
135
+ get name() {
136
+ return this.getAttribute("name") ?? "";
137
+ }
138
+
139
+ set name(value) {
140
+ this.setAttribute("name", value);
141
+ }
142
+
143
+ /** @returns {HTMLInputElement["disabled"]} */
144
+ get disabled() {
145
+ return this.hasAttribute("disabled");
146
+ }
147
+
148
+ set disabled(value) {
149
+ this.toggleAttribute("disabled", value);
150
+ }
151
+
152
+ /** @returns {HTMLInputElement["required"]} */
153
+ get required() {
154
+ return this.hasAttribute("required");
155
+ }
156
+
157
+ set required(value) {
158
+ this.toggleAttribute("required", value);
159
+ }
160
+
161
+ /** @returns {HTMLInputElement["min"]} */
162
+ get min() {
163
+ return this.getAttribute("min") ?? "";
164
+ }
165
+
166
+ set min(value) {
167
+ this.setAttribute("min", value);
168
+ }
169
+
170
+ /** @returns {HTMLInputElement["max"]} */
171
+ get max() {
172
+ return this.getAttribute("max") ?? "";
173
+ }
174
+
175
+ set max(value) {
176
+ this.setAttribute("max", value);
177
+ }
178
+
179
+ /**
180
+ * Indicates that the `CheckboxGroup` should not replace its `<fieldset>`'s accessible label (`<legend>`)
181
+ * with its own (`<label>`) when mounted.
182
+ *
183
+ * **WARNING**: Only enable this property if you intend to perform the label replacement yourself.
184
+ * @returns {boolean}
185
+ */
186
+ get manual() {
187
+ return this.hasAttribute("manual");
188
+ }
189
+
190
+ set manual(value) {
191
+ this.toggleAttribute("manual", value);
192
+ }
193
+
194
+ /* ------------------------------ Custom Attributes and Properties ------------------------------ */
195
+ /** The error message displayed to users when the group's `required` constraint is broken. @returns {string} */
196
+ get valueMissingError() {
197
+ return this.getAttribute("valuemissingerror") ?? "Please select one or more items.";
198
+ }
199
+
200
+ set valueMissingError(value) {
201
+ this.setAttribute("valuemissingerror", value);
202
+ }
203
+
204
+ /** The error message displayed to users when the group's `min` constraint is broken. @returns {string} */
205
+ get rangeUnderflowError() {
206
+ const items = Number(this.min) === 1 ? "item" : "items";
207
+ return this.getAttribute("rangeunderflowerror") ?? `Please select at least ${this.min} ${items}.`;
208
+ }
209
+
210
+ set rangeUnderflowError(value) {
211
+ this.setAttribute("rangeunderflowerror", value);
212
+ }
213
+
214
+ /** The error message displayed to users when the group's `max` constraint is broken. @returns {string} */
215
+ get rangeOverflowError() {
216
+ const items = Number(this.max) === 1 ? "item" : "items";
217
+ return this.getAttribute("rangeoverflowerror") ?? `Please select no more than ${this.max} ${items}.`;
218
+ }
219
+
220
+ set rangeOverflowError(value) {
221
+ this.setAttribute("rangeoverflowerror", value);
222
+ }
223
+
224
+ /* ------------------------------ Exposed `ElementInternals` ------------------------------ */
225
+ /** @returns {ElementInternals["labels"]} */
226
+ get labels() {
227
+ return this.#internals.labels;
228
+ }
229
+
230
+ /** @returns {ElementInternals["form"]} */
231
+ get form() {
232
+ return this.#internals.form;
233
+ }
234
+
235
+ /** @returns {ElementInternals["validity"]} */
236
+ get validity() {
237
+ return this.#internals.validity;
238
+ }
239
+
240
+ /** @returns {ElementInternals["validationMessage"]} */
241
+ get validationMessage() {
242
+ return this.#internals.validationMessage;
243
+ }
244
+
245
+ /** @returns {ElementInternals["willValidate"]} */
246
+ get willValidate() {
247
+ return this.#internals.willValidate;
248
+ }
249
+
250
+ /** @type {ElementInternals["checkValidity"]} */
251
+ checkValidity() {
252
+ return this.#internals.checkValidity();
253
+ }
254
+
255
+ /** @type {ElementInternals["reportValidity"]} */
256
+ reportValidity() {
257
+ return this.#internals.reportValidity();
258
+ }
259
+
260
+ /** @type {HTMLInputElement["setCustomValidity"]} */
261
+ setCustomValidity(error) {
262
+ this.#customError = error;
263
+ this.#validateConstraints();
264
+ }
265
+
266
+ #customError = "";
267
+ /** @returns {void} */
268
+ #validateConstraints() {
269
+ // TODO: Document that `radio`s should be used if `min` === `max` (a11y).
270
+ const min = Number(this.min);
271
+ const max = Number(this.max);
272
+ const count = this.#value.size;
273
+
274
+ // TODO: Document that users should provide `min/max` values that make sense. `min > max` is not logical and will hurt UX.
275
+ // TODO: Also document that `min` might be preferable to `required` since it's more flexible.
276
+ const valueMissing = this.required && !count;
277
+ const rangeUnderflow = this.hasAttribute("min") && !Number.isNaN(min) && count < min;
278
+ const rangeOverflow = this.hasAttribute("max") && !Number.isNaN(max) && count > max;
279
+ const customError = Boolean(this.#customError);
280
+
281
+ /** @type {string | undefined} */ let message;
282
+ if (customError) message = this.#customError;
283
+ else if (rangeUnderflow) message = this.rangeUnderflowError;
284
+ else if (valueMissing) message = this.valueMissingError;
285
+ else if (rangeOverflow) message = this.rangeOverflowError;
286
+ this.#internals.setValidity({ valueMissing, rangeUnderflow, rangeOverflow, customError }, message);
287
+ }
288
+
289
+ /* ------------------------------ Form Control Callbacks ------------------------------ */
290
+ /** @returns {void} */
291
+ formResetCallback() {
292
+ this.#value.clear(); // NOTE: This is done to guarantee consistent ordering during `reset`s (nice-to-have)
293
+ const checkboxes = /** @type {HTMLCollectionOf<HTMLInputElement>} */ (this.fieldset.elements);
294
+ for (let i = 0; i < checkboxes.length; i++) {
295
+ const checkbox = checkboxes[i];
296
+ checkbox.checked = checkbox.defaultChecked;
297
+ if (checkbox.checked) this.#value.add(checkbox.value);
298
+ }
299
+
300
+ this.#updateFormValue();
301
+ }
302
+
303
+ /**
304
+ * @param {FormData | null} state
305
+ * @param {"restore" | "autocomplete"} _mode
306
+ * @returns {void}
307
+ */
308
+ formStateRestoreCallback(state, _mode) {
309
+ this.#value = new Set(/** @type {FormDataIterator<string> | undefined} */ (state?.values()));
310
+ this.#updateFormValue();
311
+ }
312
+
313
+ /**
314
+ * @param {boolean} disabled
315
+ * @returns {void}
316
+ */
317
+ formDisabledCallback(disabled) {
318
+ this.fieldset.disabled = disabled;
319
+ }
320
+
321
+ /* ------------------------------ Event Handlers + Mutation Observers ------------------------------ */
322
+ /**
323
+ * Monitors the clicks on individual `checkbox`es, updating the {@link CheckboxGroup}'s value as needed.
324
+ * @param {MouseEvent} event
325
+ * @returns {void}
326
+ */
327
+ #handleClick(event) {
328
+ const input = event.target;
329
+ if (!event.isTrusted || event.defaultPrevented || !(input instanceof HTMLInputElement)) return;
330
+
331
+ event.preventDefault(); // Prevent developers from receiving irrelevant `input`/`change` events
332
+ const { checked } = input;
333
+ setTimeout(() => {
334
+ input.checked = checked;
335
+ this.#value[checked ? "add" : "delete"](input.value);
336
+ this.#updateFormValue();
337
+ this.#validateConstraints();
338
+
339
+ this.dispatchEvent(new Event("input", { bubbles: true, composed: true, cancelable: false }));
340
+ this.dispatchEvent(new Event("change", { bubbles: true, composed: false, cancelable: false }));
341
+ });
342
+ }
343
+
344
+ /**
345
+ * Monitors the `checkbox`es that are added to or removed from the {@link CheckboxGroup}, updating its value as needed.
346
+ * @param {MutationRecord[]} mutations
347
+ * @returns {void}
348
+ */
349
+ #watchChildren(mutations) {
350
+ if (!(this.lastElementChild instanceof HTMLFieldSetElement) || this.firstElementChild !== this.lastElementChild) {
351
+ throw new TypeError("A <fieldset> element must be the only direct descendant of the `CheckboxGroup`.");
352
+ }
353
+
354
+ for (let i = 0; i < mutations.length; i++) {
355
+ const { addedNodes, removedNodes } = mutations[i];
356
+
357
+ let workingWithRemovedNodes = Boolean(removedNodes.length);
358
+ let nodes = workingWithRemovedNodes ? removedNodes : addedNodes;
359
+ for (let j = 0; j < nodes.length; j++) {
360
+ const node = nodes[j];
361
+ if (!(node instanceof HTMLElement)) continue;
362
+
363
+ /** @type {ArrayLike<HTMLInputElement>} */
364
+ const checkboxes = node instanceof HTMLInputElement ? [node] : node.querySelectorAll(":scope input");
365
+
366
+ for (let k = 0; k < checkboxes.length; k++) {
367
+ const checkbox = checkboxes[k];
368
+ if (checkbox.checked) this.#value[workingWithRemovedNodes ? "delete" : "add"](checkbox.value);
369
+
370
+ if (!workingWithRemovedNodes) {
371
+ checkbox.removeAttribute("name");
372
+ checkbox.setAttribute("form", "");
373
+ }
374
+ }
375
+
376
+ if (workingWithRemovedNodes && j === removedNodes.length - 1) {
377
+ nodes = addedNodes;
378
+ workingWithRemovedNodes = false;
379
+ j = -1;
380
+ }
381
+ }
382
+ }
383
+
384
+ this.#updateFormValue();
385
+ this.#validateConstraints();
386
+ }
387
+ }
388
+
389
+ export default CheckboxGroup;