@t007/input 0.0.1

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.
package/dist/index.js ADDED
@@ -0,0 +1,307 @@
1
+ // src/index.js
2
+ import { createEl, loadResource, initScrollAssist } from "@t007/utils";
3
+ var T007_Form_Manager = {
4
+ forms: document.getElementsByClassName("t007-input-form"),
5
+ violationKeys: ["valueMissing", "typeMismatch", "patternMismatch", "stepMismatch", "tooShort", "tooLong", "rangeUnderflow", "rangeOverflow", "badInput", "customError"],
6
+ init() {
7
+ t007.FM.observeDOMForFields();
8
+ Array.from(t007.FM.forms).forEach(t007.FM.handleFormValidation);
9
+ },
10
+ observeDOMForFields() {
11
+ new MutationObserver((mutations) => {
12
+ for (const mutation of mutations) {
13
+ for (const node of mutation.addedNodes) {
14
+ if (!node.tagName || !(node?.classList?.contains("t007-input-field") || node?.querySelector?.(".t007-input-field"))) continue;
15
+ for (const field of [...node.querySelector(".t007-input-field") ? node.querySelectorAll(".t007-input-field") : [node]]) t007.FM.setUpField(field);
16
+ }
17
+ }
18
+ }).observe(document.body, { childList: true, subtree: true });
19
+ },
20
+ getFilesHelper(files, opts) {
21
+ if (!files || !files.length) return { violation: null, message: "" };
22
+ const totalFiles = files.length;
23
+ let totalSize = 0;
24
+ let currFiles = 0;
25
+ const setMaxError = (size, max, n = 0) => ({ violation: "rangeOverflow", message: n ? `File ${files.length > 1 ? n : ""} size of ${t007.FM.formatSize(size)} exceeds the per file maximum of ${t007.FM.formatSize(max)}` : `Total files size of ${t007.FM.formatSize(size)} exceeds the total maximum of ${t007.FM.formatSize(max)}` });
26
+ const setMinError = (size, min, n = 0) => ({ violation: "rangeUnderflow", message: n ? `File ${files.length > 1 ? n : ""} size of ${t007.FM.formatSize(size)} is less than the per file minimum of ${t007.FM.formatSize(min)}` : `Total files size of ${t007.FM.formatSize(size)} is less than the total minimum of ${t007.FM.formatSize(min)}` });
27
+ for (const file of files) {
28
+ currFiles++;
29
+ totalSize += file.size;
30
+ if (opts.accept) {
31
+ const acceptedTypes = opts.accept.split(",").map((type) => type.trim().replace(/^[*\.]+|[*\.]+$/g, "")).filter(Boolean) || [];
32
+ if (!acceptedTypes.some((type) => file.type.includes(type))) return { violation: "typeMismatch", message: `File${currFiles > 1 ? currFiles : ""} type of '${file.type}' is not accepted.` };
33
+ }
34
+ if (opts.maxSize && file.size > opts.maxSize) return setMaxError(file.size, opts.maxSize, currFiles);
35
+ if (opts.minSize && file.size < opts.minSize) return setMinError(file.size, opts.minSize, currFiles);
36
+ if (opts.multiple) {
37
+ if (opts.maxTotalSize && totalSize > opts.maxTotalSize) return setMaxError(totalSize, opts.maxTotalSize);
38
+ if (opts.minTotalSize && totalSize < opts.minTotalSize) return setMinError(totalSize, opts.minTotalSize);
39
+ if (opts.maxLength && totalFiles > opts.maxLength) return { violation: "tooLong", message: `Selected ${totalFiles} files exceeds the maximum of ${opts.maxLength} allowed file${opts.maxLength == 1 ? "" : "s"}` };
40
+ if (opts.minLength && totalFiles < opts.minLength) return { violation: "tooShort", message: `Selected ${totalFiles} files is less than the minimum of ${opts.minLength} allowed file${opts.minLength == 1 ? "" : "s"}` };
41
+ }
42
+ }
43
+ return { violation: null, message: "" };
44
+ },
45
+ formatSize(size, decimals = 3, base = 1e3) {
46
+ if (size < base) return `${size} byte${size == 1 ? "" : "s"}`;
47
+ const units = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"], exponent = Math.min(Math.floor(Math.log(size) / Math.log(base)), units.length - 1);
48
+ return `${(size / Math.pow(base, exponent)).toFixed(decimals).replace(/\.0+$/, "")} ${units[exponent]}`;
49
+ },
50
+ togglePasswordType: (input) => input.type = input.type === "password" ? "text" : "password",
51
+ toggleFilled: (input) => input?.toggleAttribute("data-filled", input.type === "checkbox" || input.type === "radio" ? input.checked : input.value !== "" || input.files?.length > 0),
52
+ setFallbackHelper(field) {
53
+ const helperTextWrapper = field?.querySelector(".t007-input-helper-text-wrapper");
54
+ if (!helperTextWrapper || helperTextWrapper.querySelector(".t007-input-helper-text[data-violation='auto']")) return;
55
+ helperTextWrapper.append(createEl("p", { className: "t007-input-helper-text" }, { violation: "auto" }));
56
+ },
57
+ setFieldListeners(field) {
58
+ if (!field) return;
59
+ const input = field.querySelector(".t007-input"), floatingLabel = field.querySelector(".t007-input-floating-label"), eyeOpen = field.querySelector(".t007-input-password-visible-icon"), eyeClosed = field.querySelector(".t007-input-password-hidden-icon");
60
+ if (input.type === "file")
61
+ input.addEventListener("input", async () => {
62
+ const file = input.files?.[0], img = new Image();
63
+ img.onload = () => {
64
+ input.style.setProperty("--t007-input-image-src", `url(${src})`);
65
+ input.classList.add("t007-input-image-selected");
66
+ setTimeout(() => URL.revokeObjectURL(src), 1e3);
67
+ };
68
+ img.onerror = () => {
69
+ input.style.removeProperty("--t007-input-image-src");
70
+ input.classList.remove("t007-input-image-selected");
71
+ URL.revokeObjectURL(src);
72
+ };
73
+ let src;
74
+ if (file?.type?.startsWith("image")) src = URL.createObjectURL(file);
75
+ else if (file?.type?.startsWith("video")) {
76
+ src = await new Promise((resolve) => {
77
+ let video = createEl("video"), canvas = createEl("canvas"), context = canvas.getContext("2d");
78
+ video.ontimeupdate = () => {
79
+ context.drawImage(video, 0, 0, video.videoWidth, video.videoHeight);
80
+ canvas.toBlob((blob) => resolve(URL.createObjectURL(blob)));
81
+ URL.revokeObjectURL(video.src);
82
+ video = video.src = video.onloadedmetadata = video.ontimeupdate = null;
83
+ };
84
+ video.onloadeddata = () => video.currentTime = 3;
85
+ video.src = URL.createObjectURL(file);
86
+ });
87
+ }
88
+ if (!src) {
89
+ input.style.removeProperty("--t007-input-image-src");
90
+ input.classList.remove("t007-input-image-selected");
91
+ return;
92
+ }
93
+ img.src = src;
94
+ });
95
+ if (floatingLabel) floatingLabel.ontransitionend = () => floatingLabel.classList.remove("t007-input-shake");
96
+ if (eyeOpen && eyeClosed) eyeOpen.onclick = eyeClosed.onclick = () => t007.FM.togglePasswordType(input);
97
+ initScrollAssist(field.querySelector(".t007-input-helper-text-wrapper"), { vertical: false });
98
+ },
99
+ setUpField(field) {
100
+ if (field.dataset.setUp) return;
101
+ t007.FM.toggleFilled(field.querySelector(".t007-input"));
102
+ t007.FM.setFallbackHelper(field);
103
+ t007.FM.setFieldListeners(field);
104
+ field.dataset.setUp = "true";
105
+ },
106
+ field({ isWrapper = false, label = "", type = "text", placeholder = "", custom = "", minSize, maxSize, minTotalSize, maxTotalSize, options = [], indeterminate = false, eyeToggler = true, passwordMeter = true, helperText = {}, className = "", fieldClassName = "", children, startIcon = "", endIcon = "", nativeIcon = "", passwordVisibleIcon = "", passwordHiddenIcon = "", ...otherProps }) {
107
+ const isSelect = type === "select", isTextArea = type === "textarea", isCheckboxOrRadio = type === "checkbox" || type === "radio", field = createEl("div", { className: `t007-input-field${isWrapper ? " t007-input-is-wrapper" : ""}${indeterminate ? " t007-input-indeterminate" : ""}${!!nativeIcon ? " t007-input-icon-override" : ""}${helperText === false ? " t007-input-no-helper" : ""}${fieldClassName ? ` ${fieldClassName}` : ""}` }), labelEl = createEl("label", { className: isCheckboxOrRadio ? `t007-input-${type}-wrapper` : "t007-input-wrapper" });
108
+ field.append(labelEl);
109
+ if (isCheckboxOrRadio) {
110
+ labelEl.innerHTML = `
111
+ <span class="t007-input-${type}-box">
112
+ <span class="t007-input-${type}-tag"></span>
113
+ </span>
114
+ <span class="t007-input-${type}-label">${label}</span>
115
+ `;
116
+ } else {
117
+ const outline = createEl("span", { className: "t007-input-outline" });
118
+ outline.innerHTML = `
119
+ <span class="t007-input-outline-leading"></span>
120
+ <span class="t007-input-outline-notch">
121
+ <span class="t007-input-floating-label">${label}</span>
122
+ </span>
123
+ <span class="t007-input-outline-trailing"></span>
124
+ `;
125
+ labelEl.append(outline);
126
+ }
127
+ const inputEl = field.inputEl = createEl(isTextArea ? "textarea" : isSelect ? "select" : "input", { className: `t007-input${className ? ` ${className}` : ""}`, placeholder });
128
+ if (isSelect && Array.isArray(options)) inputEl.innerHTML = options.map((opt) => typeof opt === "string" ? `<option value="${opt}">${opt}</option>` : `<option value="${opt.value}">${opt.option}</option>`).join("");
129
+ if (!isSelect && !isTextArea) inputEl.type = type;
130
+ if (custom) inputEl.setAttribute("custom", custom);
131
+ if (minSize) inputEl.setAttribute("minsize", minSize);
132
+ if (maxSize) inputEl.setAttribute("maxsize", maxSize);
133
+ if (minTotalSize) inputEl.setAttribute("mintotalsize", minTotalSize);
134
+ if (maxTotalSize) inputEl.setAttribute("maxtotalsize", maxTotalSize);
135
+ Object.keys(otherProps).forEach((key) => inputEl[key] = otherProps[key]);
136
+ labelEl.append(!isWrapper ? inputEl : children);
137
+ const nativeTypes = ["date", "time", "month", "datetime-local"];
138
+ if (nativeTypes.includes(type) && nativeIcon) labelEl.append(createEl("i", { className: "t007-input-icon t007-input-native-icon", innerHTML: nativeIcon }));
139
+ else if (endIcon) labelEl.append(createEl("i", { className: "t007-input-icon", innerHTML: endIcon }));
140
+ if (type === "password" && eyeToggler) {
141
+ labelEl.append(createEl("i", { role: "button", ariaLabel: "Show password", className: "t007-input-icon t007-input-password-visible-icon", innerHTML: passwordVisibleIcon || `<svg width="24" height="24"><path fill="rgba(0,0,0,.54)" d="M12 16q1.875 0 3.188-1.312Q16.5 13.375 16.5 11.5q0-1.875-1.312-3.188Q13.875 7 12 7q-1.875 0-3.188 1.312Q7.5 9.625 7.5 11.5q0 1.875 1.312 3.188Q10.125 16 12 16Zm0-1.8q-1.125 0-1.912-.788Q9.3 12.625 9.3 11.5t.788-1.913Q10.875 8.8 12 8.8t1.913.787q.787.788.787 1.913t-.787 1.912q-.788.788-1.913.788Zm0 4.8q-3.65 0-6.65-2.038-3-2.037-4.35-5.462 1.35-3.425 4.35-5.463Q8.35 4 12 4q3.65 0 6.65 2.037 3 2.038 4.35 5.463-1.35 3.425-4.35 5.462Q15.65 19 12 19Z"/></svg>` }));
142
+ labelEl.append(createEl("i", { role: "button", ariaLabel: "Hide password", className: "t007-input-icon t007-input-password-hidden-icon", innerHTML: passwordHiddenIcon || `<svg width="24" height="24"><path fill="rgba(0,0,0,.54)" d="m19.8 22.6-4.2-4.15q-.875.275-1.762.413Q12.95 19 12 19q-3.775 0-6.725-2.087Q2.325 14.825 1 11.5q.525-1.325 1.325-2.463Q3.125 7.9 4.15 7L1.4 4.2l1.4-1.4 18.4 18.4ZM12 16q.275 0 .512-.025.238-.025.513-.1l-5.4-5.4q-.075.275-.1.513-.025.237-.025.512 0 1.875 1.312 3.188Q10.125 16 12 16Zm7.3.45-3.175-3.15q.175-.425.275-.862.1-.438.1-.938 0-1.875-1.312-3.188Q13.875 7 12 7q-.5 0-.938.1-.437.1-.862.3L7.65 4.85q1.025-.425 2.1-.638Q10.825 4 12 4q3.775 0 6.725 2.087Q21.675 8.175 23 11.5q-.575 1.475-1.512 2.738Q20.55 15.5 19.3 16.45Zm-4.625-4.6-3-3q.7-.125 1.288.112.587.238 1.012.688.425.45.613 1.038.187.587.087 1.162Z"/></svg>` }));
143
+ }
144
+ if (helperText !== false) {
145
+ const helperLine = createEl("div", { className: "t007-input-helper-line" }), helperWrapper = createEl("div", { className: "t007-input-helper-text-wrapper", tabIndex: "-1" });
146
+ if (helperText.info) helperWrapper.append(createEl("p", { className: "t007-input-helper-text", textContent: helperText.info }, { violation: "none" }));
147
+ t007.FM?.violationKeys?.forEach((key) => helperText[key] && helperWrapper.append(createEl("p", { className: "t007-input-helper-text", textContent: helperText[key] }, { violation: key })));
148
+ helperLine.append(helperWrapper);
149
+ field.append(helperLine);
150
+ }
151
+ if (passwordMeter && type === "password") {
152
+ const meter = createEl("div", { className: "t007-input-password-meter" }, { strengthLevel: "1" });
153
+ meter.innerHTML = `
154
+ <div class="t007-input-password-strength-meter">
155
+ <div class="t007-input-p-weak"></div>
156
+ <div class="t007-input-p-fair"></div>
157
+ <div class="t007-input-p-strong"></div>
158
+ <div class="t007-input-p-very-strong"></div>
159
+ </div>
160
+ `;
161
+ field.append(meter);
162
+ }
163
+ return field;
164
+ },
165
+ handleFormValidation(form) {
166
+ if (!form?.classList.contains("t007-input-form") || form.dataset?.isValidating) return;
167
+ form.dataset.isValidating = "true";
168
+ form.validateOnClient = validateFormOnClient;
169
+ form.toggleGlobalError = toggleFormGlobalError;
170
+ const fields = form.getElementsByClassName("t007-input-field"), inputs = form.getElementsByClassName("t007-input");
171
+ Array.from(fields).forEach(t007.FM.setUpField);
172
+ form.addEventListener("input", ({ target }) => {
173
+ t007.FM.toggleFilled(target);
174
+ validateInput(target);
175
+ });
176
+ form.addEventListener("focusout", ({ target }) => validateInput(target, true));
177
+ form.addEventListener("submit", async (e) => {
178
+ toggleSubmitLoader(true);
179
+ try {
180
+ e.preventDefault();
181
+ if (!validateFormOnClient()) return;
182
+ if (form.validateOnServer && !await form.validateOnServer()) {
183
+ toggleFormGlobalError(true);
184
+ form.addEventListener("input", () => toggleFormGlobalError(false), { once: true, useCapture: true });
185
+ return;
186
+ }
187
+ form.onSubmit ? form.onSubmit() : form.submit();
188
+ } catch (error) {
189
+ console.error(error);
190
+ }
191
+ toggleSubmitLoader(false);
192
+ });
193
+ function toggleSubmitLoader(bool) {
194
+ form.classList.toggle("t007-input-submit-loading", bool);
195
+ }
196
+ function toggleError(input, bool, flag = false) {
197
+ const field = input.closest(".t007-input-field"), floatingLabel = field.querySelector(".t007-input-floating-label");
198
+ if (bool && flag) {
199
+ input.setAttribute("data-error", "");
200
+ floatingLabel?.classList.add("t007-input-shake");
201
+ } else if (!bool) input.removeAttribute("data-error");
202
+ toggleHelper(input, input.hasAttribute("data-error"));
203
+ }
204
+ function toggleHelper(input, bool) {
205
+ const field = input.closest(".t007-input-field"), violation = t007.FM.violationKeys.find((violation2) => input.Validity?.[violation2] || input.validity[violation2]) ?? "", helper = field.querySelector(`.t007-input-helper-text[data-violation="${violation}"]`), fallbackHelper = field.querySelector(`.t007-input-helper-text[data-violation="auto"]`);
206
+ input.closest(".t007-input-field").querySelectorAll(`.t007-input-helper-text:not([data-violation="${violation}"])`).forEach((helper2) => helper2?.classList.remove("t007-input-show"));
207
+ if (helper) helper.classList.toggle("t007-input-show", bool);
208
+ else if (fallbackHelper) {
209
+ fallbackHelper.textContent = input.validationMessage;
210
+ fallbackHelper.classList.toggle("t007-input-show", bool);
211
+ }
212
+ }
213
+ function forceRevalidate(input) {
214
+ input.checkValidity();
215
+ input.dispatchEvent(new Event("input"));
216
+ }
217
+ function updatePasswordMeter(input) {
218
+ const passwordMeter = input.closest(".t007-input-field").querySelector(".t007-input-password-meter");
219
+ if (!passwordMeter) return;
220
+ const value = input.value?.trim();
221
+ let strengthLevel = 0;
222
+ if (value.length < Number(input.minLength ?? 0)) strengthLevel = 1;
223
+ else {
224
+ if (/[a-z]/.test(value)) strengthLevel++;
225
+ if (/[A-Z]/.test(value)) strengthLevel++;
226
+ if (/[0-9]/.test(value)) strengthLevel++;
227
+ if (/[\W_]/.test(value)) strengthLevel++;
228
+ }
229
+ passwordMeter.dataset.strengthLevel = strengthLevel;
230
+ }
231
+ function validateInput(input, flag = false) {
232
+ if (form.dataset.globalError || !input?.classList.contains("t007-input")) return;
233
+ updatePasswordMeter(input);
234
+ let value, errorBool;
235
+ switch (input.custom ?? input.getAttribute("custom")) {
236
+ case "password":
237
+ value = input.value?.trim();
238
+ if (value === "") break;
239
+ const confirmPasswordInput = Array.from(inputs).find((input2) => (input2.custom ?? input2.getAttribute("custom")) === "confirm-password");
240
+ if (!confirmPasswordInput) break;
241
+ const confirmPasswordValue = confirmPasswordInput.value?.trim();
242
+ confirmPasswordInput.setCustomValidity(value !== confirmPasswordValue ? "Both passwords do not match" : "");
243
+ toggleError(confirmPasswordInput, value !== confirmPasswordValue, flag);
244
+ break;
245
+ case "confirm_password":
246
+ value = input.value?.trim();
247
+ if (value === "") break;
248
+ const passwordInput = Array.from(inputs).find((input2) => (input2.custom ?? input2.getAttribute("custom")) === "password");
249
+ if (!passwordInput) break;
250
+ const passwordValue = passwordInput.value?.trim();
251
+ errorBool = value !== passwordValue;
252
+ input.setCustomValidity(errorBool ? "Both passwords do not match" : "");
253
+ break;
254
+ case "onward_date":
255
+ if (input.min) break;
256
+ input.min = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
257
+ forceRevalidate(input);
258
+ break;
259
+ }
260
+ if (input.type === "file") {
261
+ input.Validity = {};
262
+ const { violation, message } = t007.FM.getFilesHelper(input.files ?? [], {
263
+ accept: input.accept,
264
+ multiple: input.multiple,
265
+ maxSize: input.maxSize ?? Number(input.getAttribute("maxsize")),
266
+ minSize: input.minSize ?? Number(input.getAttribute("minsize")),
267
+ maxTotalSize: input.maxTotalSize ?? Number(input.getAttribute("maxtotalsize")),
268
+ minTotalSize: input.minTotalSize ?? Number(input.getAttribute("mintotalsize")),
269
+ maxLength: input.maxLength ?? Number(input.getAttribute("maxlength")),
270
+ minLength: input.minLength ?? Number(input.getAttribute("minLength"))
271
+ });
272
+ errorBool = !!message;
273
+ input.setCustomValidity(message);
274
+ if (violation) input.Validity[violation] = true;
275
+ }
276
+ errorBool = errorBool ?? !input.validity?.valid;
277
+ toggleError(input, errorBool, flag);
278
+ if (errorBool) return;
279
+ if (input.type === "radio")
280
+ Array.from(inputs)?.filter((i) => i.name == input.name)?.forEach((radio) => toggleError(radio, errorBool, flag));
281
+ }
282
+ function validateFormOnClient() {
283
+ Array.from(inputs).forEach((input) => validateInput(input, true));
284
+ form.querySelector("input:invalid")?.focus();
285
+ return Array.from(inputs).every((input) => input.checkValidity());
286
+ }
287
+ function toggleFormGlobalError(bool) {
288
+ form.toggleAttribute("data-global-error", bool);
289
+ form.querySelectorAll(".t007-input-field").forEach((field) => {
290
+ field.querySelector(".t007-input")?.toggleAttribute("data-error", bool);
291
+ if (bool) field.querySelector(".t007-input-floating-label")?.classList.add("t007-input-shake");
292
+ });
293
+ }
294
+ }
295
+ };
296
+ if (typeof window !== "undefined") {
297
+ window.t007 ??= {};
298
+ t007.FM = T007_Form_Manager;
299
+ t007.field = t007.FM.field;
300
+ t007.handleFormValidation = t007.FM.handleFormValidation;
301
+ window.T007_INPUT_CSS_SRC ??= `https://unpkg.com/@t007/input@latest/style.css`;
302
+ window.field ??= t007.field;
303
+ window.handleFormValidation ??= t007.handleFormValidation;
304
+ console.log("%cT007 Input helpers attached to window!", "color: darkturquoise");
305
+ loadResource(T007_INPUT_CSS_SRC);
306
+ t007.FM.init();
307
+ }
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@t007/input",
3
+ "version": "0.0.1",
4
+ "description": "A lightweight, pure JS input system.",
5
+ "author": "Oketade Oluwatobiloba <tobioketade007@gmail.com>",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/tobi007-del/t007-tools.git",
10
+ "directory": "packages/input"
11
+ },
12
+ "homepage": "https://github.com/tobi007-del/t007-tools/tree/main/packages/input#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/tobi007-del/t007-tools/issues"
15
+ },
16
+ "type": "module",
17
+ "main": "./dist/index.js",
18
+ "module": "./dist/index.js",
19
+ "browser": "./dist/index.global.js",
20
+ "unpkg": "./dist/index.global.js",
21
+ "types": "./src/ts/types/index.d.ts",
22
+ "style": "./src/css/index.css",
23
+ "sideEffects": [
24
+ "*.css"
25
+ ],
26
+ "exports": {
27
+ ".": {
28
+ "types": "./src/ts/types/index.d.ts",
29
+ "import": "./dist/index.js",
30
+ "default": "./dist/index.global.js"
31
+ },
32
+ "./style.css": "./src/css/index.css"
33
+ },
34
+ "scripts": {
35
+ "build": "tsup src/index.js --format esm,iife --clean"
36
+ },
37
+ "files": [
38
+ "dist",
39
+ "./src/ts/types/index.d.ts",
40
+ "./src/css/index.css"
41
+ ],
42
+ "keywords": [
43
+ "input",
44
+ "form",
45
+ "form-validation",
46
+ "form-manager",
47
+ "file-upload",
48
+ "password-meter",
49
+ "t007",
50
+ "ui",
51
+ "vanilla-js",
52
+ "monkey-patch"
53
+ ],
54
+ "dependencies": {
55
+ "@t007/utils": "*"
56
+ }
57
+ }