From f4f45f0b26982e99078551bf7b3b72fe8313f1a8 Mon Sep 17 00:00:00 2001 From: JD Lien Date: Fri, 23 Jan 2026 01:05:44 -0700 Subject: [PATCH 01/54] Convert to pnpm, update deps to latest, add wire size script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Convert from npm to pnpm with packageManager field - Update all dependencies to latest versions: - @jdlien/validator-utils: 1.2.8 → 2.0.0 - vite: 6.x → 7.3.1 - vitest: 3.x → 4.0.18 - jsdom: 26.x → 27.4.0 - typescript: 5.8.x → 5.9.3 - Add scripts/measure-wire-size.mjs and size:wire command - Add tests/setup.ts with CSS.supports mock for jsdom - Add v2-analysis.md with modernization planning Signed-off-by: JD Lien --- package.json | 28 +- pnpm-lock.yaml | 1785 +++++++++++++++++++++++++++++++++ scripts/measure-wire-size.mjs | 43 + tests/setup.ts | 65 ++ v2-analysis.md | 207 ++++ vite.config.js | 1 + 6 files changed, 2119 insertions(+), 10 deletions(-) create mode 100644 pnpm-lock.yaml create mode 100644 scripts/measure-wire-size.mjs create mode 100644 tests/setup.ts create mode 100644 v2-analysis.md diff --git a/package.json b/package.json index 6960316..4214936 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "@jdlien/validator", "version": "1.5.0", "type": "module", + "packageManager": "pnpm@10.11.0", "module": "dist/validator.js", "types": "dist/Validator.d.ts", "files": [ @@ -11,6 +12,7 @@ "scripts": { "dev": "vite", "build": "vite build && tsc --emitDeclarationOnly", + "size:wire": "node scripts/measure-wire-size.mjs", "preview": "vite preview", "test": "vitest", "coverage": "vitest --coverage", @@ -44,19 +46,25 @@ "homepage": "https://github.com/jdlien/validator#readme", "devDependencies": { "@sheerun/mutationobserver-shim": "^0.3.3", - "@types/jsdom": "^21.1.7", - "@vitest/coverage-v8": "^3.1.1", - "canvas": "^3.1.0", - "jsdom": "^26.1.0", + "@types/jsdom": "^27.0.0", + "@vitest/coverage-v8": "^4.0.18", + "canvas": "^3.2.1", + "jsdom": "^27.4.0", "jsdom-global": "^3.0.2", - "prettier": "^3.5.3", - "tailwindcss": "^4.1.4", - "typescript": "^5.8.3", - "vite": "^6.3.0", - "vitest": "^3.1.1" + "prettier": "^3.8.1", + "tailwindcss": "^4.1.18", + "typescript": "^5.9.3", + "vite": "^7.3.1", + "vitest": "^4.0.18" }, "sideEffects": false, "dependencies": { - "@jdlien/validator-utils": "^1.2.8" + "@jdlien/validator-utils": "^2.0.0" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "canvas", + "esbuild" + ] } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..d0e044a --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1785 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@jdlien/validator-utils': + specifier: ^2.0.0 + version: 2.0.0 + devDependencies: + '@sheerun/mutationobserver-shim': + specifier: ^0.3.3 + version: 0.3.3 + '@types/jsdom': + specifier: ^27.0.0 + version: 27.0.0 + '@vitest/coverage-v8': + specifier: ^4.0.18 + version: 4.0.18(vitest@4.0.18(@types/node@25.0.10)(jsdom@27.4.0(canvas@3.2.1))) + canvas: + specifier: ^3.2.1 + version: 3.2.1 + jsdom: + specifier: ^27.4.0 + version: 27.4.0(canvas@3.2.1) + jsdom-global: + specifier: ^3.0.2 + version: 3.0.2(jsdom@27.4.0(canvas@3.2.1)) + prettier: + specifier: ^3.8.1 + version: 3.8.1 + tailwindcss: + specifier: ^4.1.18 + version: 4.1.18 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: ^7.3.1 + version: 7.3.1(@types/node@25.0.10) + vitest: + specifier: ^4.0.18 + version: 4.0.18(@types/node@25.0.10)(jsdom@27.4.0(canvas@3.2.1)) + +packages: + + '@acemir/cssom@0.9.31': + resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} + + '@asamuzakjp/css-color@4.1.1': + resolution: {integrity: sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==} + + '@asamuzakjp/dom-selector@6.7.6': + resolution: {integrity: sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.28.6': + resolution: {integrity: sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.28.6': + resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-syntax-patches-for-csstree@1.0.25': + resolution: {integrity: sha512-g0Kw9W3vjx5BEBAF8c5Fm2NcB/Fs8jJXh85aXqwEXiL+tqtOut07TWgyaGzAAfTM+gKckrrncyeGEZPcaRgm2Q==} + engines: {node: '>=18'} + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@esbuild/aix-ppc64@0.27.2': + resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.2': + resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.2': + resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.2': + resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.2': + resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.2': + resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.2': + resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.2': + resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.2': + resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.2': + resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.2': + resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.2': + resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.2': + resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.2': + resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.2': + resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.2': + resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.2': + resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.2': + resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.2': + resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.2': + resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.2': + resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.2': + resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.2': + resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.2': + resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.2': + resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.2': + resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@exodus/bytes@1.9.0': + resolution: {integrity: sha512-lagqsvnk09NKogQaN/XrtlWeUF8SRhT12odMvbTIIaVObqzwAogL6jhR4DAp0gPuKoM1AOVrKUshJpRdpMFrww==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + + '@jdlien/validator-utils@2.0.0': + resolution: {integrity: sha512-wrO5z1Sb4oMYYLOm0XWemJf7t+zAoxBVbRSEiNtAs0aBj61tnJfb9E/oGE3xp2K4fKMxBdOc8OEi0UaTeWOuHw==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@rollup/rollup-android-arm-eabi@4.56.0': + resolution: {integrity: sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.56.0': + resolution: {integrity: sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.56.0': + resolution: {integrity: sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.56.0': + resolution: {integrity: sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.56.0': + resolution: {integrity: sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.56.0': + resolution: {integrity: sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.56.0': + resolution: {integrity: sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.56.0': + resolution: {integrity: sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.56.0': + resolution: {integrity: sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.56.0': + resolution: {integrity: sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.56.0': + resolution: {integrity: sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.56.0': + resolution: {integrity: sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.56.0': + resolution: {integrity: sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.56.0': + resolution: {integrity: sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.56.0': + resolution: {integrity: sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.56.0': + resolution: {integrity: sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.56.0': + resolution: {integrity: sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.56.0': + resolution: {integrity: sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.56.0': + resolution: {integrity: sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.56.0': + resolution: {integrity: sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.56.0': + resolution: {integrity: sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.56.0': + resolution: {integrity: sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.56.0': + resolution: {integrity: sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.56.0': + resolution: {integrity: sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.56.0': + resolution: {integrity: sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==} + cpu: [x64] + os: [win32] + + '@sheerun/mutationobserver-shim@0.3.3': + resolution: {integrity: sha512-DetpxZw1fzPD5xUBrIAoplLChO2VB8DlL5Gg+I1IR9b2wPqYIca2WSUxL5g1vLeR4MsQq1NeWriXAVffV+U1Fw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/jsdom@27.0.0': + resolution: {integrity: sha512-NZyFl/PViwKzdEkQg96gtnB8wm+1ljhdDay9ahn4hgb+SfVtPCbm3TlmDUFXTA+MGN3CijicnMhG18SI5H3rFw==} + + '@types/node@25.0.10': + resolution: {integrity: sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==} + + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + + '@vitest/coverage-v8@4.0.18': + resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==} + peerDependencies: + '@vitest/browser': 4.0.18 + vitest: 4.0.18 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.0.18': + resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + + '@vitest/mocker@4.0.18': + resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.0.18': + resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + + '@vitest/runner@4.0.18': + resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + + '@vitest/snapshot@4.0.18': + resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + + '@vitest/spy@4.0.18': + resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + + '@vitest/utils@4.0.18': + resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-v8-to-istanbul@0.3.10: + resolution: {integrity: sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + canvas@3.2.1: + resolution: {integrity: sha512-ej1sPFR5+0YWtaVp6S1N1FVz69TQCqmrkGeRvQxZeAB1nAIcjNTHVwrZtYtWFFBmQsF40/uDLehsW5KuYC99mg==} + engines: {node: ^18.12.0 || >= 20.9.0} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + css-tree@3.1.0: + resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + cssstyle@5.3.7: + resolution: {integrity: sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==} + engines: {node: '>=20'} + + data-urls@6.0.1: + resolution: {integrity: sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==} + engines: {node: '>=20'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.27.2: + resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + jsdom-global@3.0.2: + resolution: {integrity: sha512-t1KMcBkz/pT5JrvcJbpUR2u/w1kO9jXctaaGJ0vZDzwFnIvGWw9IDSRciT83kIs8Bnw4qpOl8bQK08V01YgMPg==} + peerDependencies: + jsdom: '>=10.0.0' + + jsdom@27.4.0: + resolution: {integrity: sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + lru-cache@11.2.4: + resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} + engines: {node: 20 || >=22} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.1: + resolution: {integrity: sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + mdn-data@2.12.2: + resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + + node-abi@3.87.0: + resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==} + engines: {node: '>=10'} + + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + hasBin: true + + prettier@3.8.1: + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} + engines: {node: '>=14'} + hasBin: true + + pump@3.0.3: + resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + rollup@4.56.0: + resolution: {integrity: sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tailwindcss@4.1.18: + resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} + + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + engines: {node: '>=14.0.0'} + + tldts-core@7.0.19: + resolution: {integrity: sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==} + + tldts@7.0.19: + resolution: {integrity: sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==} + hasBin: true + + tough-cookie@6.0.0: + resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.0.18: + resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.18 + '@vitest/browser-preview': 4.0.18 + '@vitest/browser-webdriverio': 4.0.18 + '@vitest/ui': 4.0.18 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@15.1.0: + resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==} + engines: {node: '>=20'} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.19.0: + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + +snapshots: + + '@acemir/cssom@0.9.31': {} + + '@asamuzakjp/css-color@4.1.1': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 11.2.4 + + '@asamuzakjp/dom-selector@6.7.6': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.1.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.4 + + '@asamuzakjp/nwsapi@2.3.9': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/parser@7.28.6': + dependencies: + '@babel/types': 7.28.6 + + '@babel/types@7.28.6': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@bcoe/v8-coverage@1.0.2': {} + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-syntax-patches-for-csstree@1.0.25': {} + + '@csstools/css-tokenizer@3.0.4': {} + + '@esbuild/aix-ppc64@0.27.2': + optional: true + + '@esbuild/android-arm64@0.27.2': + optional: true + + '@esbuild/android-arm@0.27.2': + optional: true + + '@esbuild/android-x64@0.27.2': + optional: true + + '@esbuild/darwin-arm64@0.27.2': + optional: true + + '@esbuild/darwin-x64@0.27.2': + optional: true + + '@esbuild/freebsd-arm64@0.27.2': + optional: true + + '@esbuild/freebsd-x64@0.27.2': + optional: true + + '@esbuild/linux-arm64@0.27.2': + optional: true + + '@esbuild/linux-arm@0.27.2': + optional: true + + '@esbuild/linux-ia32@0.27.2': + optional: true + + '@esbuild/linux-loong64@0.27.2': + optional: true + + '@esbuild/linux-mips64el@0.27.2': + optional: true + + '@esbuild/linux-ppc64@0.27.2': + optional: true + + '@esbuild/linux-riscv64@0.27.2': + optional: true + + '@esbuild/linux-s390x@0.27.2': + optional: true + + '@esbuild/linux-x64@0.27.2': + optional: true + + '@esbuild/netbsd-arm64@0.27.2': + optional: true + + '@esbuild/netbsd-x64@0.27.2': + optional: true + + '@esbuild/openbsd-arm64@0.27.2': + optional: true + + '@esbuild/openbsd-x64@0.27.2': + optional: true + + '@esbuild/openharmony-arm64@0.27.2': + optional: true + + '@esbuild/sunos-x64@0.27.2': + optional: true + + '@esbuild/win32-arm64@0.27.2': + optional: true + + '@esbuild/win32-ia32@0.27.2': + optional: true + + '@esbuild/win32-x64@0.27.2': + optional: true + + '@exodus/bytes@1.9.0': {} + + '@jdlien/validator-utils@2.0.0': {} + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@rollup/rollup-android-arm-eabi@4.56.0': + optional: true + + '@rollup/rollup-android-arm64@4.56.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.56.0': + optional: true + + '@rollup/rollup-darwin-x64@4.56.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.56.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.56.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.56.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.56.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.56.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.56.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.56.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.56.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.56.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.56.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.56.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.56.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.56.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.56.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.56.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.56.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.56.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.56.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.56.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.56.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.56.0': + optional: true + + '@sheerun/mutationobserver-shim@0.3.3': {} + + '@standard-schema/spec@1.1.0': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/jsdom@27.0.0': + dependencies: + '@types/node': 25.0.10 + '@types/tough-cookie': 4.0.5 + parse5: 7.3.0 + + '@types/node@25.0.10': + dependencies: + undici-types: 7.16.0 + + '@types/tough-cookie@4.0.5': {} + + '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@25.0.10)(jsdom@27.4.0(canvas@3.2.1)))': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.0.18 + ast-v8-to-istanbul: 0.3.10 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.1 + obug: 2.1.1 + std-env: 3.10.0 + tinyrainbow: 3.0.3 + vitest: 4.0.18(@types/node@25.0.10)(jsdom@27.4.0(canvas@3.2.1)) + + '@vitest/expect@4.0.18': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + chai: 6.2.2 + tinyrainbow: 3.0.3 + + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.0.10))': + dependencies: + '@vitest/spy': 4.0.18 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@25.0.10) + + '@vitest/pretty-format@4.0.18': + dependencies: + tinyrainbow: 3.0.3 + + '@vitest/runner@4.0.18': + dependencies: + '@vitest/utils': 4.0.18 + pathe: 2.0.3 + + '@vitest/snapshot@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.0.18': {} + + '@vitest/utils@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + tinyrainbow: 3.0.3 + + agent-base@7.1.4: {} + + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@0.3.10: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 9.0.1 + + base64-js@1.5.1: {} + + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + canvas@3.2.1: + dependencies: + node-addon-api: 7.1.1 + prebuild-install: 7.1.3 + + chai@6.2.2: {} + + chownr@1.1.4: {} + + css-tree@3.1.0: + dependencies: + mdn-data: 2.12.2 + source-map-js: 1.2.1 + + cssstyle@5.3.7: + dependencies: + '@asamuzakjp/css-color': 4.1.1 + '@csstools/css-syntax-patches-for-csstree': 1.0.25 + css-tree: 3.1.0 + lru-cache: 11.2.4 + + data-urls@6.0.1: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 15.1.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-extend@0.6.0: {} + + detect-libc@2.1.2: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + entities@6.0.1: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.27.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.2 + '@esbuild/android-arm': 0.27.2 + '@esbuild/android-arm64': 0.27.2 + '@esbuild/android-x64': 0.27.2 + '@esbuild/darwin-arm64': 0.27.2 + '@esbuild/darwin-x64': 0.27.2 + '@esbuild/freebsd-arm64': 0.27.2 + '@esbuild/freebsd-x64': 0.27.2 + '@esbuild/linux-arm': 0.27.2 + '@esbuild/linux-arm64': 0.27.2 + '@esbuild/linux-ia32': 0.27.2 + '@esbuild/linux-loong64': 0.27.2 + '@esbuild/linux-mips64el': 0.27.2 + '@esbuild/linux-ppc64': 0.27.2 + '@esbuild/linux-riscv64': 0.27.2 + '@esbuild/linux-s390x': 0.27.2 + '@esbuild/linux-x64': 0.27.2 + '@esbuild/netbsd-arm64': 0.27.2 + '@esbuild/netbsd-x64': 0.27.2 + '@esbuild/openbsd-arm64': 0.27.2 + '@esbuild/openbsd-x64': 0.27.2 + '@esbuild/openharmony-arm64': 0.27.2 + '@esbuild/sunos-x64': 0.27.2 + '@esbuild/win32-arm64': 0.27.2 + '@esbuild/win32-ia32': 0.27.2 + '@esbuild/win32-x64': 0.27.2 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expand-template@2.0.3: {} + + expect-type@1.3.0: {} + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + fs-constants@1.0.0: {} + + fsevents@2.3.3: + optional: true + + github-from-package@0.0.0: {} + + has-flag@4.0.0: {} + + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.9.0 + transitivePeerDependencies: + - '@noble/hashes' + + html-escaper@2.0.2: {} + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + ieee754@1.2.1: {} + + inherits@2.0.4: {} + + ini@1.3.8: {} + + is-potential-custom-element-name@1.0.1: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + js-tokens@9.0.1: {} + + jsdom-global@3.0.2(jsdom@27.4.0(canvas@3.2.1)): + dependencies: + jsdom: 27.4.0(canvas@3.2.1) + + jsdom@27.4.0(canvas@3.2.1): + dependencies: + '@acemir/cssom': 0.9.31 + '@asamuzakjp/dom-selector': 6.7.6 + '@exodus/bytes': 1.9.0 + cssstyle: 5.3.7 + data-urls: 6.0.1 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + parse5: 8.0.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 15.1.0 + ws: 8.19.0 + xml-name-validator: 5.0.0 + optionalDependencies: + canvas: 3.2.1 + transitivePeerDependencies: + - '@noble/hashes' + - bufferutil + - supports-color + - utf-8-validate + + lru-cache@11.2.4: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.5.1: + dependencies: + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.3 + + mdn-data@2.12.2: {} + + mimic-response@3.1.0: {} + + minimist@1.2.8: {} + + mkdirp-classic@0.5.3: {} + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + napi-build-utils@2.0.0: {} + + node-abi@3.87.0: + dependencies: + semver: 7.7.3 + + node-addon-api@7.1.1: {} + + obug@2.1.1: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + parse5@8.0.0: + dependencies: + entities: 6.0.1 + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.3: {} + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.87.0 + pump: 3.0.3 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + + prettier@3.8.1: {} + + pump@3.0.3: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + punycode@2.3.1: {} + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + require-from-string@2.0.2: {} + + rollup@4.56.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.56.0 + '@rollup/rollup-android-arm64': 4.56.0 + '@rollup/rollup-darwin-arm64': 4.56.0 + '@rollup/rollup-darwin-x64': 4.56.0 + '@rollup/rollup-freebsd-arm64': 4.56.0 + '@rollup/rollup-freebsd-x64': 4.56.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.56.0 + '@rollup/rollup-linux-arm-musleabihf': 4.56.0 + '@rollup/rollup-linux-arm64-gnu': 4.56.0 + '@rollup/rollup-linux-arm64-musl': 4.56.0 + '@rollup/rollup-linux-loong64-gnu': 4.56.0 + '@rollup/rollup-linux-loong64-musl': 4.56.0 + '@rollup/rollup-linux-ppc64-gnu': 4.56.0 + '@rollup/rollup-linux-ppc64-musl': 4.56.0 + '@rollup/rollup-linux-riscv64-gnu': 4.56.0 + '@rollup/rollup-linux-riscv64-musl': 4.56.0 + '@rollup/rollup-linux-s390x-gnu': 4.56.0 + '@rollup/rollup-linux-x64-gnu': 4.56.0 + '@rollup/rollup-linux-x64-musl': 4.56.0 + '@rollup/rollup-openbsd-x64': 4.56.0 + '@rollup/rollup-openharmony-arm64': 4.56.0 + '@rollup/rollup-win32-arm64-msvc': 4.56.0 + '@rollup/rollup-win32-ia32-msvc': 4.56.0 + '@rollup/rollup-win32-x64-gnu': 4.56.0 + '@rollup/rollup-win32-x64-msvc': 4.56.0 + fsevents: 2.3.3 + + safe-buffer@5.2.1: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + semver@7.7.3: {} + + siginfo@2.0.0: {} + + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-json-comments@2.0.1: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + symbol-tree@3.2.4: {} + + tailwindcss@4.1.18: {} + + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.3 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + tinybench@2.9.0: {} + + tinyexec@1.0.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinyrainbow@3.0.3: {} + + tldts-core@7.0.19: {} + + tldts@7.0.19: + dependencies: + tldts-core: 7.0.19 + + tough-cookie@6.0.0: + dependencies: + tldts: 7.0.19 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + typescript@5.9.3: {} + + undici-types@7.16.0: {} + + util-deprecate@1.0.2: {} + + vite@7.3.1(@types/node@25.0.10): + dependencies: + esbuild: 0.27.2 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.56.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 25.0.10 + fsevents: 2.3.3 + + vitest@4.0.18(@types/node@25.0.10)(jsdom@27.4.0(canvas@3.2.1)): + dependencies: + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.0.10)) + '@vitest/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + es-module-lexer: 1.7.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.3.1(@types/node@25.0.10) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.0.10 + jsdom: 27.4.0(canvas@3.2.1) + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@4.0.0: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@15.1.0: + dependencies: + tr46: 6.0.0 + webidl-conversions: 8.0.1 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wrappy@1.0.2: {} + + ws@8.19.0: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} diff --git a/scripts/measure-wire-size.mjs b/scripts/measure-wire-size.mjs new file mode 100644 index 0000000..f8a390e --- /dev/null +++ b/scripts/measure-wire-size.mjs @@ -0,0 +1,43 @@ +import { readFile } from 'node:fs/promises' +import { spawnSync } from 'node:child_process' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { brotliCompressSync, constants, gzipSync } from 'node:zlib' + +const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const args = new Set(process.argv.slice(2)) +const skipBuild = args.has('--skip-build') + +if (!skipBuild) { + const result = spawnSync('pnpm', ['-s', 'build'], { + cwd: rootDir, + stdio: 'inherit', + }) + + if (result.status !== 0) { + process.exit(result.status ?? 1) + } +} + +const targetPath = resolve(rootDir, 'dist/validator.js') +const buffer = await readFile(targetPath) + +const sizes = { + raw: buffer.byteLength, + gzip: gzipSync(buffer, { level: 9 }).byteLength, + brotli: brotliCompressSync(buffer, { + params: { [constants.BROTLI_PARAM_QUALITY]: 11 }, + }).byteLength, +} + +const formatBytes = (bytes) => { + if (bytes < 1024) return `${bytes} B` + const kib = bytes / 1024 + if (kib < 1024) return `${kib.toFixed(2)} KiB` + return `${(kib / 1024).toFixed(2)} MiB` +} + +console.log(`Target: ${targetPath}`) +console.log(`Raw: ${formatBytes(sizes.raw)}`) +console.log(`Gzip: ${formatBytes(sizes.gzip)}`) +console.log(`Brotli: ${formatBytes(sizes.brotli)}`) diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..00d8522 --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,65 @@ +/** + * Global test setup for Vitest + * Mocks CSS.supports since jsdom doesn't implement it properly + */ + +// Mock CSS.supports for color validation tests +const cssColorValidator = (prop: string, value: string): boolean => { + if (prop !== 'color') return false + + // Hex colors + if (/^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(value)) return true + + // RGB/RGBA with comma syntax: rgb(r, g, b) or rgba(r, g, b, a) + if (/^rgba?\(\s*\d{1,3}%?\s*,\s*\d{1,3}%?\s*,\s*\d{1,3}%?\s*(,\s*[\d.]+%?)?\s*\)$/i.test(value)) + return true + + // RGB/RGBA with space syntax: rgb(r g b) or rgb(r g b / a) + if (/^rgba?\(\s*\d{1,3}%?\s+\d{1,3}%?\s+\d{1,3}%?\s*(\s*\/\s*[\d.]+%?)?\s*\)$/i.test(value)) + return true + + // HSL/HSLA with comma syntax + if ( + /^hsla?\(\s*\d+(?:deg|grad|rad|turn)?\s*,\s*\d{1,3}%\s*,\s*\d{1,3}%\s*(,\s*[\d.]+%?)?\s*\)$/i.test( + value + ) + ) + return true + + // HSL/HSLA with space syntax + if ( + /^hsla?\(\s*\d+(?:deg|grad|rad|turn)?\s+\d{1,3}%\s+\d{1,3}%\s*(\s*\/\s*[\d.]+%?)?\s*\)$/i.test( + value + ) + ) + return true + + // Named colors + const namedColors = new Set([ + 'transparent', + 'currentcolor', + 'red', + 'green', + 'blue', + 'yellow', + 'cyan', + 'magenta', + 'black', + 'white', + 'orange', + 'purple', + 'pink', + 'brown', + 'gray', + 'grey', + ]) + if (namedColors.has(value.toLowerCase())) return true + + return false +} + +// Setup CSS.supports mock globally +if (typeof globalThis.CSS === 'undefined') { + ;(globalThis as any).CSS = {} +} +;(globalThis.CSS as any).supports = cssColorValidator diff --git a/v2-analysis.md b/v2-analysis.md new file mode 100644 index 0000000..412ec6c --- /dev/null +++ b/v2-analysis.md @@ -0,0 +1,207 @@ +# Validator v2.0.0 Analysis + +Analysis of the validator project for antipatterns, size reduction opportunities, and missing features. + +**Date:** 2026-01-23 +**Current Version:** 1.5.0 +**Dependency:** @jdlien/validator-utils (currently ^1.2.8, v2.0.0 available) + +--- + +## 1. Antipatterns / Bad Ideas + +### High Priority + +| Issue | Location | Problem | +|-------|----------|---------| +| **Expensive MutationObserver** | `Validator.ts:175-178` | Watching `document.body` with `subtree: true` fires on EVERY DOM change anywhere in the document just to check if the form was removed. This is O(n) for all DOM mutations across the entire page. | +| **Repeated string splitting** | Multiple locations | `hiddenClasses.split(' ')`, `errorInputClasses.split(' ')`, and `errorMainClasses.split(' ')` are called every time errors are shown/cleared instead of being pre-split once in the constructor. | +| **Outdated dependency** | `package.json:60` | Using `^1.2.8` of validator-utils but v2.0.0 is available with new date format features and smaller bundle size. | +| **Type duplication** | `types.d.ts` | Duplicates types already exported from `Validator.ts` and is missing `showMainError`. Should either be removed or auto-generated from the source. | + +### Medium Priority + +| Issue | Location | Problem | +|-------|----------|---------| +| FIXME comment | Line 145 | Known issue: "This doesn't seem to work well if I add a lot of things at once. Needs more testing." | +| Empty callback defaults | Lines 137-138 | Creating new empty arrow functions `(() => {})` each time instead of null-checking before invocation. | +| Copyright year | Line 1 | Still says "©2023 JD Lien" - should be updated. | +| Loose typing | `ValidatorOptions.messages` | Uses generic `object` type instead of `Record` for proper type safety. | + +--- + +## 2. Size Reduction Opportunities + +**Estimated savings: ~50-80 lines, cleaner architecture** + +### 2.1 Remove `types.d.ts` + +Everything is already exported from `Validator.ts`. The separate types file: +- Duplicates definitions +- Can get out of sync (already missing `showMainError`) +- Adds maintenance burden + +### 2.2 Pre-split class strings + +Instead of splitting strings every time they're used: + +```typescript +// Current (repeated throughout code): +this.hiddenClasses.split(' ').forEach((className) => { ... }) + +// Better (split once in constructor): +private hiddenClassList: string[] +private errorMainClassList: string[] +private errorInputClassList: string[] + +// In constructor: +this.hiddenClassList = (options.hiddenClasses || 'hidden opacity-0').split(' ') +``` + +### 2.3 Consolidate custom event classes + +`ValidationSuccessEvent` and `ValidationErrorEvent` (lines 29-43) are identical except for the event name: + +```typescript +// Current: Two separate classes (14 lines) +export class ValidationSuccessEvent extends Event { ... } +export class ValidationErrorEvent extends Event { ... } + +// Better: Single generic class (7 lines) +export class ValidationEvent extends Event { + constructor(type: 'validationSuccess' | 'validationError', public submitEvent: Event) { + super(type, { cancelable: true }) + } +} +``` + +### 2.4 Simplify entry point + +`index.ts` currently just re-exports: +```typescript +export default Validator from './src/Validator' +``` + +Could point vite directly at `Validator.ts` and eliminate this file. + +### 2.5 More efficient auto-destroy observer + +Instead of watching the entire document with `subtree: true`: + +**Option A:** Use `requestIdleCallback` for periodic cleanup checks +```typescript +private scheduleCleanupCheck(): void { + requestIdleCallback(() => { + if (!document.contains(this.form)) this.destroy() + else this.scheduleCleanupCheck() + }) +} +``` + +**Option B:** Only observe the form's parent, not entire document +```typescript +this.autoDestroyObserver.observe(this.form.parentElement!, { + childList: true, +}) +``` + +--- + +## 3. Missing Features + +### Expected in a Form Validator + +| Feature | Impact | Complexity | Notes | +|---------|--------|------------|-------| +| **Scroll to first error** | High | Low | TODO already noted at line 342 | +| **Min/max value validation** | High | Low | Validate numeric ranges, not just format | +| **Blur event validation** | Medium | Low | Many forms validate on blur, not just change | +| **Programmatic single-input validation** | Medium | Low | `validateSingle(input)` method | +| **Time range validation** | Low | Low | Message exists (`ERROR_TIME_RANGE`) but no implementation | + +### Nice to Have + +| Feature | Notes | +|---------|-------| +| File input validation | Validate file size, MIME type, extension | +| Credit card / CVV validation | Common in e-commerce forms | +| Accessible live region announcements | Better screen reader support | +| Built-in async validator debouncing | For expensive validation operations | +| Conditional validation | Skip validation based on other field values | +| Form reset handler | Low value - users rarely use reset buttons (bad UX), and programmatic resets can call `init()` manually | + +--- + +## 4. Recommended Actions for v2.0.0 + +### Must Do + +1. **Update validator-utils dependency** to `^2.0.0` +2. **Fix the expensive MutationObserver** - biggest performance issue +3. **Pre-split class strings** - easy win for cleaner code +4. **Remove or regenerate types.d.ts** - reduce duplication +5. **Update copyright year** to 2026 + +### Should Do + +6. **Add scroll-to-first-error option** - already noted as TODO +7. **Add min/max value validation** for number inputs +8. **Consolidate event classes** - cleaner code + +### Consider + +9. Add blur event validation (opt-in) +10. Add programmatic single-input validation API +11. Implement time range validation (message already exists) + +--- + +## 5. Code Locations Reference + +| File | Lines | Purpose | +|------|-------|---------| +| `src/Validator.ts` | 809 | Main validator class | +| `src/types.d.ts` | 24 | Type definitions (duplicate) | +| `index.ts` | 1 | Entry point re-export | +| `package.json` | 62 | Package configuration | +| `vite.config.js` | - | Build configuration | + +### Key Methods in Validator.ts + +| Method | Line | Purpose | +|--------|------|---------| +| `constructor` | 111 | Initialize validator | +| `setupAutoDestroy` | 166 | MutationObserver setup (needs optimization) | +| `init` | 202 | Register inputs and listeners | +| `validate` | 645 | Main validation orchestrator | +| `validateRequired` | 411 | Required field validation | +| `validateLength` | 452 | Min/max length validation | +| `validateInputType` | 544 | Type-specific validation | +| `validateDateRange` | 565 | Date range validation | +| `showFormErrors` | 343 | Display all errors | +| `destroy` | 777 | Cleanup method | + +--- + +## 6. Breaking Changes to Consider + +If releasing as v2.0.0, consider these potentially breaking changes: + +1. **Remove `types.d.ts`** - consumers importing from it would need to update +2. **Rename event classes** - if consolidating to single `ValidationEvent` +3. **Change callback signature** - if making callbacks nullable instead of empty functions +4. **Add blur validation by default** - might change existing behavior (make opt-in instead) + +--- + +## 7. Bundle Size Comparison + +Current validator-utils dependency: +- v1.2.8: ~8KB minified +- v2.0.0: ~6KB minified (25% smaller) + +Potential savings in validator itself: +- Remove types.d.ts: ~0.5KB +- Consolidate events: ~0.3KB +- Pre-split strings: Code quality improvement, minimal size change +- Total estimated savings: ~1KB + cleaner architecture diff --git a/vite.config.js b/vite.config.js index 8969bc3..ab4f4bd 100644 --- a/vite.config.js +++ b/vite.config.js @@ -12,6 +12,7 @@ export default defineConfig({ ts: { declaration: true, declarationDir: 'dist', declarationMap: true }, test: { environment: 'jsdom', + setupFiles: ['./tests/setup.ts'], coverage: { reporter: ['text', 'json', 'html'] }, threads: false, // suppresses errors from canvas when starting tests server: { From 82dadc1051cd0d87e981a1a290ab7b080ae8c3af Mon Sep 17 00:00:00 2001 From: JD Lien Date: Fri, 23 Jan 2026 01:27:37 -0700 Subject: [PATCH 02/54] Bump version to 2.0.0 and add CLAUDE.md Preparing for v2 release with project instructions for AI assistance. Signed-off-by: JD Lien --- CLAUDE.md | 16 ++++++++++++++++ package.json | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8d5f6f4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,16 @@ +# Validator + +This is a utility that can add user friendly, accessible validation to forms using native HTML attributes that is easy to use and powerful. + +This project has the following goals: + +1. Compactness and efficiency above all else. +2. 100% test coverage of all functionality, with a broad corpus of tests to evaluate behavior of a wide range of acceptable inputs, especially for dates. +3. Focus on high-value functionality, carefully consider whether adding new functionality is highly impactful and reusable in many projects. + +When completed edits or new features: + +1. Ensure that all tests run with 100% coverage +`pnpm coverage` +2. Compare the original size with the new size. Have we been able to keep the wire size as small or better than before? If the size has increased meaningfully, notify the user. +`pnpm size:wire` diff --git a/package.json b/package.json index 4214936..65197fc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@jdlien/validator", - "version": "1.5.0", + "version": "2.0.0", "type": "module", "packageManager": "pnpm@10.11.0", "module": "dist/validator.js", From a20a43cc59347ad40a3a5e1ea2df90c23b46fef8 Mon Sep 17 00:00:00 2001 From: JD Lien Date: Fri, 23 Jan 2026 01:40:00 -0700 Subject: [PATCH 03/54] Implement v2.0.0 features: performance optimizations, type safety, and new validation options - Add scrollToError option to scroll and focus first invalid input on validation failure - Add min/max numeric value validation via data-min/data-max attributes - Pre-split class strings in constructor for better performance - Optimize MutationObserver auto-destroy with requestIdleCallback when available - Consolidate ValidationSuccessEvent/ValidationErrorEvent into unified ValidationEvent class - Update messages option type from object to Record - Remove duplicate types.d.ts file - Update copyright year to 2026 - Add comprehensive tests for all new features (226 tests, 100% line coverage) Signed-off-by: JD Lien --- CHANGELOG.md | 39 ++++++ src/Validator.ts | 138 ++++++++++++++----- src/types.d.ts | 23 ---- tests/Validator.autoDestroy.test.ts | 30 ++++ tests/Validator.minMax.test.ts | 191 ++++++++++++++++++++++++++ tests/Validator.scrollToError.test.ts | 106 ++++++++++++++ tests/Validator.submitHandler.test.ts | 52 ++++--- 7 files changed, 503 insertions(+), 76 deletions(-) delete mode 100644 src/types.d.ts create mode 100644 tests/Validator.scrollToError.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index dec2c77..6d74db4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,45 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.0.0] - 2026-01-23 + +### Bundle Size Comparison +| | v1.5.0 | v2.0.0 | Change | +|--|--------|--------|--------| +| Raw | 21.60 KiB | 22.78 KiB | +5.5% | +| Gzip | 7.39 KiB | 7.70 KiB | +4.2% | +| Brotli | 6.59 KiB | 6.85 KiB | +3.9% | + +### Added +- `scrollToError` option to scroll to first invalid input on validation failure +- Min/max value validation via `data-min`/`data-max` attributes (also respects native `min`/`max`) +- `ERROR_MIN_VALUE` and `ERROR_MAX_VALUE` error messages +- `ValidationEvent` unified event class with `ValidationEventType` type +- Pre-split class string arrays for performance optimization + +### Changed +- Optimized MutationObserver for auto-destroy using `requestIdleCallback` when available +- MutationObserver now watches the form's parent more narrowly instead of entire document +- Updated copyright year to 2026 +- `messages` option now typed as `Record` (was `object`) + +### Removed +- `types.d.ts` file (duplicated Validator.ts exports) +- `ValidationSuccessEvent` class (use `ValidationEvent` with type `'validationSuccess'`) +- `ValidationErrorEvent` class (use `ValidationEvent` with type `'validationError'`) + +### Breaking Changes +- **Event classes consolidated:** Replace `ValidationSuccessEvent` and `ValidationErrorEvent` with unified `ValidationEvent` class + ```typescript + // Before + form.addEventListener('validationSuccess', (e: ValidationSuccessEvent) => { ... }) + + // After + form.addEventListener('validationSuccess', (e: ValidationEvent) => { ... }) + ``` +- **`messages` typing:** Now `Record` instead of `object` +- **`types.d.ts` removed:** Import types from `Validator.ts` instead + ## [1.5.0] - 2025-01-08 ### Added diff --git a/src/Validator.ts b/src/Validator.ts index 2e06e08..66239a5 100755 --- a/src/Validator.ts +++ b/src/Validator.ts @@ -1,4 +1,4 @@ -/** Form Validator used by EPL apps and www2. ©2023 JD Lien */ +/** Form Validator used by EPL apps and www2. ©2026 JD Lien */ // Import the validator utility functions import * as utils from '@jdlien/validator-utils' @@ -6,7 +6,7 @@ import * as utils from '@jdlien/validator-utils' export type FormControl = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement export interface ValidatorOptions { - messages?: object + messages?: Record debug?: boolean autoInit?: boolean preventSubmit?: boolean @@ -14,6 +14,7 @@ export interface ValidatorOptions { errorMainClasses?: string errorInputClasses?: string showMainError?: boolean + scrollToError?: boolean validationSuccessCallback?: (event: Event) => void validationErrorCallback?: (event: Event) => void } @@ -26,19 +27,14 @@ export interface InputHandlers { } } -export class ValidationSuccessEvent extends Event { - submitEvent: Event - constructor(submitEvent: Event) { - super('validationSuccess', { cancelable: true }) - this.submitEvent = submitEvent - } -} +export type ValidationEventType = 'validationSuccess' | 'validationError' -export class ValidationErrorEvent extends Event { - submitEvent: Event - constructor(submitEvent: Event) { - super('validationError', { cancelable: true }) - this.submitEvent = submitEvent +export class ValidationEvent extends Event { + constructor( + type: ValidationEventType, + public submitEvent: Event + ) { + super(type, { cancelable: true }) } } @@ -49,7 +45,7 @@ export default class Validator { inputErrors: { [key: string]: string[] } = {} // Default error messages. - messages = { + messages: Record = { ERROR_MAIN: 'There is a problem with your submission.', ERROR_GENERIC: 'Enter a valid value.', ERROR_REQUIRED: 'This field is required.', @@ -57,6 +53,8 @@ export default class Validator { CHECKED_REQUIRED: 'This must be checked.', ERROR_MAXLENGTH: 'This must be ${val} characters or fewer.', ERROR_MINLENGTH: 'This must be at least ${val} characters.', + ERROR_MIN_VALUE: 'The value must be at least ${val}.', + ERROR_MAX_VALUE: 'The value must be at most ${val}.', ERROR_NUMBER: 'This must be a number.', ERROR_INTEGER: 'This must be a whole number.', ERROR_TEL: 'This is not a valid telephone number.', @@ -84,6 +82,8 @@ export default class Validator { hiddenClasses: string // Whether to show the main error message showMainError: boolean = true + // Whether to scroll to the first error on validation failure + scrollToError: boolean = false // Classes to apply to the main error message (space-separated) errorMainClasses: string @@ -92,6 +92,11 @@ export default class Validator { // Timeout for dispatching events on input (used by syncColorInput) private dispatchTimeout: number = 0 + // Pre-split class arrays for performance (avoid repeated .split(' ') calls) + private hiddenClassesArray: string[] = [] + private errorMainClassesArray: string[] = [] + private errorInputClassesArray: string[] = [] + // Timeout ID for debounced functions private timeoutId: number = 0 // Instance of the MutationObserver used to re-initialize on DOM changes @@ -134,6 +139,13 @@ export default class Validator { this.errorInputClasses = options.errorInputClasses || 'border-red-600 dark:border-red-500' this.showMainError = options.showMainError !== undefined ? options.showMainError : true + this.scrollToError = options.scrollToError || false + + // Pre-split class strings for performance + this.hiddenClassesArray = this.hiddenClasses.split(' ').filter(Boolean) + this.errorMainClassesArray = this.errorMainClasses.split(' ').filter(Boolean) + this.errorInputClassesArray = this.errorInputClasses.split(' ').filter(Boolean) + this.validationSuccessCallback = options.validationSuccessCallback || (() => {}) this.validationErrorCallback = options.validationErrorCallback || (() => {}) @@ -164,17 +176,24 @@ export default class Validator { // Sets up automatic destruction when form is removed from DOM private setupAutoDestroy(): void { + const parent = this.form.parentElement || document.body + this.autoDestroyObserver = new MutationObserver(() => { - // Check if the form is no longer in the document - if (!document.contains(this.form)) { - this.destroy() + const check = () => { + if (!document.contains(this.form)) this.destroy() + } + // Use requestIdleCallback to defer the check when available + if (typeof requestIdleCallback !== 'undefined') { + requestIdleCallback(check, { timeout: 100 }) + } else { + setTimeout(check, 0) } }) - - // Observe the entire document for removal of the form or its ancestors - this.autoDestroyObserver.observe(document.body, { + + // Watch the parent more narrowly; only use subtree if parent is document.body + this.autoDestroyObserver.observe(parent, { childList: true, - subtree: true + subtree: parent === document.body, }) } @@ -268,14 +287,14 @@ export default class Validator { } // Apply classes and message - this.errorMainClasses.split(' ').forEach((className) => { + this.errorMainClassesArray.forEach((className) => { errorEl!.classList.add(className) }) errorEl!.innerHTML = message || this.messages.ERROR_MAIN // Ensure it's visible (might have been hidden previously) - this.hiddenClasses.split(' ').forEach((className) => { + this.hiddenClassesArray.forEach((className) => { errorEl!.classList.remove(className) }) } @@ -323,7 +342,7 @@ export default class Validator { el.setAttribute('aria-invalid', 'true') // Apply input classes to indicate an error on the input itself - this.errorInputClasses.split(' ').forEach((className) => { + this.errorInputClassesArray.forEach((className) => { el.classList.add(className) }) @@ -333,17 +352,21 @@ export default class Validator { errorEl.innerHTML = errors.join('
') - this.hiddenClasses.split(' ').forEach((className) => { + this.hiddenClassesArray.forEach((className) => { if (errorEl) errorEl.classList.remove(className) }) } // Shows all the error messages for all the inputs of the form, and a main error message - // TODO: Consider (optionally) scrolling to the first error message private showFormErrors(): void { // Show any errors from validation this.inputs.forEach((el) => this.showInputErrors(el)) + // Find the first input with errors for potential scroll + const firstErrorInput = this.inputs.find( + (el) => this.inputErrors[el.name || el.id]?.length > 0 + ) + // If there are any input errors and we should show the main error if ( this.showMainError && @@ -356,7 +379,7 @@ export default class Validator { if (!mainErrorElement.innerHTML) { mainErrorElement.innerHTML = this.messages.ERROR_MAIN } - this.hiddenClasses.split(' ').forEach((className) => { + this.hiddenClassesArray.forEach((className) => { mainErrorElement!.classList.remove(className) }) } else { @@ -364,6 +387,12 @@ export default class Validator { this.addErrorMain() } } + + // Scroll to first error and focus if enabled + if (this.scrollToError && firstErrorInput) { + firstErrorInput.scrollIntoView({ behavior: 'smooth', block: 'center' }) + firstErrorInput.focus() + } } // Clears error messages from an input and removes its errors from the inputErrors array @@ -377,12 +406,12 @@ export default class Validator { if (!errorEl) return // Remove the error style - this.errorInputClasses.split(' ').forEach((className) => { + this.errorInputClassesArray.forEach((className) => { el.classList.remove(className) }) // Hide the error element - this.hiddenClasses.split(' ').forEach((className) => { + this.hiddenClassesArray.forEach((className) => { if (errorEl) errorEl.classList.add(className) }) @@ -395,7 +424,7 @@ export default class Validator { // Find the main error element (form-specific or generic) and hide it const mainErrorElement = this._getMainErrorElement() if (mainErrorElement) { - this.hiddenClasses.split(' ').forEach((className) => { + this.hiddenClassesArray.forEach((className) => { mainErrorElement.classList.add(className) }) // Optionally clear the content after hiding @@ -482,6 +511,43 @@ export default class Validator { return valid } + // Validates min/max numeric value constraints + private validateValue(el: FormControl): boolean { + let valid = true + if (el.disabled) return valid + if (!(el instanceof HTMLInputElement) || !el.value.length) return valid + + // Only apply to numeric types + const numericTypes = ['number', 'integer', 'float', 'decimal'] + const dataType = el.dataset.type || el.type + if (!numericTypes.includes(dataType) && !numericTypes.includes(el.type)) return valid + + const numValue = parseFloat(el.value) + if (isNaN(numValue)) return valid // Let type validation handle invalid numbers + + // Get min from data-min, then native min attribute + const minAttr = el.dataset.min ?? el.min + const maxAttr = el.dataset.max ?? el.max + + if (minAttr !== undefined && minAttr !== '') { + const minValue = parseFloat(minAttr) + if (!isNaN(minValue) && numValue < minValue) { + valid = false + this.addInputError(el, this.messages.ERROR_MIN_VALUE.replace('${val}', minAttr)) + } + } + + if (maxAttr !== undefined && maxAttr !== '') { + const maxValue = parseFloat(maxAttr) + if (!isNaN(maxValue) && numValue > maxValue) { + valid = false + this.addInputError(el, this.messages.ERROR_MAX_VALUE.replace('${val}', maxAttr)) + } + } + + return valid + } + // A map of input handlers that can be used for each type of input. private inputHandlers: InputHandlers = { number: { @@ -650,6 +716,7 @@ export default class Validator { if (el.disabled) continue valid = this.validateRequired(el) && valid valid = this.validateLength(el) && valid + valid = this.validateValue(el) && valid valid = (await this.validateInput(el)) && valid // Validate custom functions here if value is empty, as they won't be // evaluated by validateInput, which only checks inputs with a value. @@ -672,20 +739,19 @@ export default class Validator { // External functions can prevent the form from submitting // by calling e.preventDefault() in the validationSuccess event - const validationSuccessEvent = new ValidationSuccessEvent(e) - const validationErrorEvent = new ValidationErrorEvent(e) + const validationEvent = new ValidationEvent(valid ? 'validationSuccess' : 'validationError', e) + + this.form.dispatchEvent(validationEvent) if (valid) { - this.form.dispatchEvent(validationSuccessEvent) if (this.validationSuccessCallback) this.validationSuccessCallback(e) } else { - this.form.dispatchEvent(validationErrorEvent) if (this.validationErrorCallback) this.validationErrorCallback(e) } if (valid && !this.preventSubmit) { this.isSubmitting = true - if (!validationSuccessEvent.defaultPrevented) this.form.submit() + if (!validationEvent.defaultPrevented) this.form.submit() this.isSubmitting = false } } diff --git a/src/types.d.ts b/src/types.d.ts deleted file mode 100644 index bf0006e..0000000 --- a/src/types.d.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** @format */ - -export type FormControl = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement - -export interface ValidatorOptions { - messages?: object - debug?: boolean - autoInit?: boolean - preventSubmit?: boolean - hiddenClasses?: string - errorMainClasses?: string - errorInputClasses?: string - validationSuccessCallback?: (event: Event) => void - validationErrorCallback?: (event: Event) => void -} - -export interface InputHandlers { - [key: string]: { - parse: (value: string, dateFormat?: string) => string - isValid: (value: string) => boolean - error: string - } -} diff --git a/tests/Validator.autoDestroy.test.ts b/tests/Validator.autoDestroy.test.ts index 982bf5c..20b9175 100644 --- a/tests/Validator.autoDestroy.test.ts +++ b/tests/Validator.autoDestroy.test.ts @@ -13,6 +13,36 @@ describe('Validator Auto-Destroy', () => { destroySpy = vi.spyOn(validator, 'destroy') }) + describe('requestIdleCallback optimization', () => { + it('should use requestIdleCallback when available', async () => { + // Mock requestIdleCallback + const mockRequestIdleCallback = vi.fn((cb: IdleRequestCallback) => { + cb({ didTimeout: false, timeRemaining: () => 50 } as IdleDeadline) + return 1 + }) + ;(globalThis as any).requestIdleCallback = mockRequestIdleCallback + + // Create a new form and validator after mocking + const testForm = document.createElement('form') + testForm.id = 'test-form-ric' + document.body.appendChild(testForm) + const testValidator = new Validator(testForm) + const testDestroySpy = vi.spyOn(testValidator, 'destroy') + + // Remove the form to trigger the MutationObserver + document.body.removeChild(testForm) + + // Wait for MutationObserver to trigger + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Verify destroy was called via requestIdleCallback + expect(testDestroySpy).toHaveBeenCalledTimes(1) + + // Clean up + delete (globalThis as any).requestIdleCallback + }) + }) + afterEach(() => { // Clean up any remaining elements if (document.body.contains(form)) { diff --git a/tests/Validator.minMax.test.ts b/tests/Validator.minMax.test.ts index 015bcaa..54082ae 100644 --- a/tests/Validator.minMax.test.ts +++ b/tests/Validator.minMax.test.ts @@ -129,4 +129,195 @@ describe('Validator', () => { expect(validator.inputErrors[formControl.name]).toEqual([]) }) }) // end validate Min/Max Length + + describe('validate Min/Max Value', () => { + it('returns true if value is within min/max range', () => { + formControl.type = 'number' + formControl.value = '50' + formControl.setAttribute('data-min', '10') + formControl.setAttribute('data-max', '100') + + const result = (validator as any).validateValue(formControl) + expect(result).toBeTruthy() + expect(validator.inputErrors[formControl.name]).toEqual([]) + }) + + it('returns true if value equals min', () => { + formControl.type = 'number' + formControl.value = '10' + formControl.setAttribute('data-min', '10') + + const result = (validator as any).validateValue(formControl) + expect(result).toBeTruthy() + expect(validator.inputErrors[formControl.name]).toEqual([]) + }) + + it('returns true if value equals max', () => { + formControl.type = 'number' + formControl.value = '100' + formControl.setAttribute('data-max', '100') + + const result = (validator as any).validateValue(formControl) + expect(result).toBeTruthy() + expect(validator.inputErrors[formControl.name]).toEqual([]) + }) + + it('returns false if value is below min', () => { + formControl.type = 'number' + formControl.value = '5' + formControl.setAttribute('data-min', '10') + + const result = (validator as any).validateValue(formControl) + expect(result).toBeFalsy() + expect(validator.inputErrors[formControl.name]).toContain( + validator.messages.ERROR_MIN_VALUE.replace('${val}', '10') + ) + }) + + it('returns false if value is above max', () => { + formControl.type = 'number' + formControl.value = '150' + formControl.setAttribute('data-max', '100') + + const result = (validator as any).validateValue(formControl) + expect(result).toBeFalsy() + expect(validator.inputErrors[formControl.name]).toContain( + validator.messages.ERROR_MAX_VALUE.replace('${val}', '100') + ) + }) + + it('works with native min/max attributes', () => { + formControl.type = 'number' + formControl.value = '5' + formControl.min = '10' + formControl.max = '100' + + const result = (validator as any).validateValue(formControl) + expect(result).toBeFalsy() + expect(validator.inputErrors[formControl.name]).toContain( + validator.messages.ERROR_MIN_VALUE.replace('${val}', '10') + ) + }) + + it('data-min/max takes precedence over native attributes', () => { + formControl.type = 'number' + formControl.value = '5' + formControl.min = '1' // native + formControl.setAttribute('data-min', '10') // data attribute takes precedence + + const result = (validator as any).validateValue(formControl) + expect(result).toBeFalsy() + expect(validator.inputErrors[formControl.name]).toContain( + validator.messages.ERROR_MIN_VALUE.replace('${val}', '10') + ) + }) + + it('validates data-type="integer" inputs', () => { + formControl.setAttribute('data-type', 'integer') + formControl.value = '5' + formControl.setAttribute('data-min', '10') + + const result = (validator as any).validateValue(formControl) + expect(result).toBeFalsy() + expect(validator.inputErrors[formControl.name]).toContain( + validator.messages.ERROR_MIN_VALUE.replace('${val}', '10') + ) + }) + + it('validates data-type="float" inputs', () => { + formControl.setAttribute('data-type', 'float') + formControl.value = '5.5' + formControl.setAttribute('data-min', '10.5') + + const result = (validator as any).validateValue(formControl) + expect(result).toBeFalsy() + expect(validator.inputErrors[formControl.name]).toContain( + validator.messages.ERROR_MIN_VALUE.replace('${val}', '10.5') + ) + }) + + it('validates data-type="decimal" inputs', () => { + formControl.setAttribute('data-type', 'decimal') + formControl.value = '99.99' + formControl.setAttribute('data-max', '50') + + const result = (validator as any).validateValue(formControl) + expect(result).toBeFalsy() + expect(validator.inputErrors[formControl.name]).toContain( + validator.messages.ERROR_MAX_VALUE.replace('${val}', '50') + ) + }) + + it('skips validation for non-numeric types', () => { + formControl.type = 'text' + formControl.value = '5' + formControl.setAttribute('data-min', '10') + + const result = (validator as any).validateValue(formControl) + expect(result).toBeTruthy() + expect(validator.inputErrors[formControl.name]).toEqual([]) + }) + + it('skips validation for empty values', () => { + formControl.type = 'number' + formControl.value = '' + formControl.setAttribute('data-min', '10') + + const result = (validator as any).validateValue(formControl) + expect(result).toBeTruthy() + expect(validator.inputErrors[formControl.name]).toEqual([]) + }) + + it('skips validation for disabled inputs', () => { + formControl.type = 'number' + formControl.value = '5' + formControl.setAttribute('data-min', '10') + formControl.disabled = true + + const result = (validator as any).validateValue(formControl) + expect(result).toBeTruthy() + expect(validator.inputErrors[formControl.name]).toEqual([]) + }) + + it('supports negative min/max values', () => { + formControl.type = 'number' + formControl.value = '-15' + formControl.setAttribute('data-min', '-10') + formControl.setAttribute('data-max', '10') + + const result = (validator as any).validateValue(formControl) + expect(result).toBeFalsy() + expect(validator.inputErrors[formControl.name]).toContain( + validator.messages.ERROR_MIN_VALUE.replace('${val}', '-10') + ) + }) + + it('can report both min and max errors', () => { + formControl.type = 'number' + formControl.value = '0' + formControl.setAttribute('data-min', '10') + formControl.setAttribute('data-max', '-10') // Invalid range but should still report + + const result = (validator as any).validateValue(formControl) + expect(result).toBeFalsy() + // Value 0 is below min 10, so min error is shown + expect(validator.inputErrors[formControl.name]).toContain( + validator.messages.ERROR_MIN_VALUE.replace('${val}', '10') + ) + }) + + it('integrates with form validation', async () => { + formControl.type = 'number' + formControl.value = '5' + formControl.setAttribute('data-min', '10') + formControl.required = true + validator.init() + + const result = await validator.validate() + expect(result).toBeFalsy() + expect(validator.inputErrors[formControl.name]).toContain( + validator.messages.ERROR_MIN_VALUE.replace('${val}', '10') + ) + }) + }) // end validate Min/Max Value }) // end describe('Validator') diff --git a/tests/Validator.scrollToError.test.ts b/tests/Validator.scrollToError.test.ts new file mode 100644 index 0000000..c3b567c --- /dev/null +++ b/tests/Validator.scrollToError.test.ts @@ -0,0 +1,106 @@ +import Validator from '../src/Validator' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' + +describe('Validator scrollToError', () => { + let form: HTMLFormElement + let input1: HTMLInputElement + let input2: HTMLInputElement + let validator: Validator + + beforeEach(() => { + form = document.createElement('form') + form.id = 'test-form' + document.body.appendChild(form) + + input1 = document.createElement('input') + input1.type = 'text' + input1.name = 'input1' + input1.id = 'input1' + // Mock scrollIntoView since jsdom doesn't implement it + input1.scrollIntoView = vi.fn() + form.appendChild(input1) + + input2 = document.createElement('input') + input2.type = 'text' + input2.name = 'input2' + input2.id = 'input2' + input2.required = true + // Mock scrollIntoView since jsdom doesn't implement it + input2.scrollIntoView = vi.fn() + form.appendChild(input2) + }) + + afterEach(() => { + if (document.body.contains(form)) { + document.body.removeChild(form) + } + }) + + it('does not scroll by default', async () => { + validator = new Validator(form, { scrollToError: false }) + const scrollSpy = vi.spyOn(input2, 'scrollIntoView') + const focusSpy = vi.spyOn(input2, 'focus') + + await validator.validate() + ;(validator as any).showFormErrors() + + expect(scrollSpy).not.toHaveBeenCalled() + expect(focusSpy).not.toHaveBeenCalled() + }) + + it('scrolls to first error when scrollToError is true', async () => { + validator = new Validator(form, { scrollToError: true }) + const scrollSpy = vi.spyOn(input2, 'scrollIntoView') + const focusSpy = vi.spyOn(input2, 'focus') + + await validator.validate() + ;(validator as any).showFormErrors() + + expect(scrollSpy).toHaveBeenCalledWith({ behavior: 'smooth', block: 'center' }) + expect(focusSpy).toHaveBeenCalled() + }) + + it('focuses the first input with errors', async () => { + // Make both inputs required + input1.required = true + input2.required = true + + validator = new Validator(form, { scrollToError: true }) + const scrollSpy1 = vi.spyOn(input1, 'scrollIntoView') + const scrollSpy2 = vi.spyOn(input2, 'scrollIntoView') + const focusSpy1 = vi.spyOn(input1, 'focus') + const focusSpy2 = vi.spyOn(input2, 'focus') + + await validator.validate() + ;(validator as any).showFormErrors() + + // Should scroll to first error (input1) + expect(scrollSpy1).toHaveBeenCalled() + expect(focusSpy1).toHaveBeenCalled() + expect(scrollSpy2).not.toHaveBeenCalled() + expect(focusSpy2).not.toHaveBeenCalled() + }) + + it('does not scroll when there are no errors', async () => { + input2.required = false // No required inputs + validator = new Validator(form, { scrollToError: true }) + const scrollSpy1 = vi.spyOn(input1, 'scrollIntoView') + const scrollSpy2 = vi.spyOn(input2, 'scrollIntoView') + + await validator.validate() + ;(validator as any).showFormErrors() + + expect(scrollSpy1).not.toHaveBeenCalled() + expect(scrollSpy2).not.toHaveBeenCalled() + }) + + it('scrollToError defaults to false', () => { + validator = new Validator(form) + expect(validator.scrollToError).toBe(false) + }) + + it('scrollToError can be set to true via options', () => { + validator = new Validator(form, { scrollToError: true }) + expect(validator.scrollToError).toBe(true) + }) +}) diff --git a/tests/Validator.submitHandler.test.ts b/tests/Validator.submitHandler.test.ts index 3248620..0bc347c 100644 --- a/tests/Validator.submitHandler.test.ts +++ b/tests/Validator.submitHandler.test.ts @@ -1,4 +1,4 @@ -import Validator, { ValidationErrorEvent, ValidationSuccessEvent } from '../src/Validator' +import Validator, { ValidationEvent } from '../src/Validator' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { setupTestForm } from './utils/setup' @@ -44,19 +44,23 @@ describe('Validator', () => { expect((validator as any).showFormErrors).toHaveBeenCalled() }) - it('dispatches ValidationSuccessEvent if form is valid', async () => { + it('dispatches ValidationEvent with validationSuccess type if form is valid', async () => { vi.spyOn(form, 'dispatchEvent') vi.spyOn(form, 'submit').mockImplementation(() => {}) vi.spyOn(validator, 'validate').mockImplementation(() => Promise.resolve(true)) await (validator as any).submitHandler(new Event('submit')) - expect(form.dispatchEvent).toHaveBeenCalledWith(expect.any(ValidationSuccessEvent)) + expect(form.dispatchEvent).toHaveBeenCalledWith(expect.any(ValidationEvent)) + const dispatchedEvent = (form.dispatchEvent as any).mock.calls[0][0] as ValidationEvent + expect(dispatchedEvent.type).toBe('validationSuccess') }) - it('dispatches ValidationErrorEvent if form is invalid', async () => { + it('dispatches ValidationEvent with validationError type if form is invalid', async () => { vi.spyOn(form, 'dispatchEvent') vi.spyOn(validator, 'validate').mockImplementation(() => Promise.resolve(false)) await (validator as any).submitHandler(new Event('submit')) - expect(form.dispatchEvent).toHaveBeenCalledWith(expect.any(ValidationErrorEvent)) + expect(form.dispatchEvent).toHaveBeenCalledWith(expect.any(ValidationEvent)) + const dispatchedEvent = (form.dispatchEvent as any).mock.calls[0][0] as ValidationEvent + expect(dispatchedEvent.type).toBe('validationError') }) it('calls validationSuccessCallback if form is valid and no default is prevented', async () => { @@ -85,25 +89,39 @@ describe('Validator', () => { }) }) - describe('ValidationEvents', () => { + describe('ValidationEvent', () => { let submitEvent: Event beforeEach(() => { submitEvent = new Event('submit') }) - it('ValidationSuccessEvent should create a new event with the correct type', () => { - const validationSuccessEvent = new ValidationSuccessEvent(submitEvent) - expect(validationSuccessEvent instanceof ValidationSuccessEvent).toBe(true) - expect(validationSuccessEvent.type).toEqual('validationSuccess') - expect(validationSuccessEvent.submitEvent).toEqual(submitEvent) + it('should create a validationSuccess event with the correct type and submitEvent', () => { + const validationEvent = new ValidationEvent('validationSuccess', submitEvent) + expect(validationEvent instanceof ValidationEvent).toBe(true) + expect(validationEvent instanceof Event).toBe(true) + expect(validationEvent.type).toBe('validationSuccess') + expect(validationEvent.submitEvent).toBe(submitEvent) }) - it('ValidationErrorEvent should create a new event with the correct type', () => { - const validationErrorEvent = new ValidationErrorEvent(submitEvent) - expect(validationErrorEvent instanceof ValidationErrorEvent).toBe(true) - expect(validationErrorEvent.type).toEqual('validationError') - expect(validationErrorEvent.submitEvent).toEqual(submitEvent) + it('should create a validationError event with the correct type and submitEvent', () => { + const validationEvent = new ValidationEvent('validationError', submitEvent) + expect(validationEvent instanceof ValidationEvent).toBe(true) + expect(validationEvent instanceof Event).toBe(true) + expect(validationEvent.type).toBe('validationError') + expect(validationEvent.submitEvent).toBe(submitEvent) }) - }) // end describe('submitHandler') + + it('should be cancelable', () => { + const validationEvent = new ValidationEvent('validationSuccess', submitEvent) + expect(validationEvent.cancelable).toBe(true) + }) + + it('should support preventDefault', () => { + const validationEvent = new ValidationEvent('validationSuccess', submitEvent) + expect(validationEvent.defaultPrevented).toBe(false) + validationEvent.preventDefault() + expect(validationEvent.defaultPrevented).toBe(true) + }) + }) }) // end describe('Validator') From 567977442934514b813d9fa1b6640071fa5d8352 Mon Sep 17 00:00:00 2001 From: JD Lien Date: Fri, 23 Jan 2026 01:49:56 -0700 Subject: [PATCH 04/54] Update documentation for v2.0.0 features README.md: - Add data-min/data-max attributes for numeric value validation - Add ERROR_MIN_VALUE and ERROR_MAX_VALUE to messages list - Add scrollToError option documentation - Add breaking changes section for ValidationEvent and types.d.ts removal Demo: - Add "Number with Min/Max" input example showing data-min/data-max - Enable scrollToError option to demonstrate scroll behavior - Update intro text to mention scroll feature Signed-off-by: JD Lien --- README.md | 27 +++++++++++++++++++++++++++ demo/index.html | 40 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a3abd5b..8825e3e 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,7 @@ On input (and sometimes select and textarea) elements, the following attributes - `data-date-format`/`data-time-format` - Applies formatting to time input types (these are interchangeable). The format must be a valid moment.js format string. See [moment.js docs](https://momentjs.com/docs/#/displaying/format/) for more information. - `data-date-range` - Applies to date input types. Supported values are `past` and `future`. +- `data-min`/`data-max` - Applies to numeric input types (`number`, `integer`, `float`, `decimal`). Validates that the numeric value is within the specified range. Also respects the native `min`/`max` attributes, but `data-` attributes take precedence. - `data-error-default` - A custom error message to display if the input is invalid. This will be used for required, pattern, and date-range validation failures. - `data-validation` - The name of a custom validation function. - `data-novalidate` - If this attribute is present, the input will not be validated when `input` or `change` events are triggered on it. @@ -209,6 +210,8 @@ messages = { CHECKED_REQUIRED: 'This must be checked.', ERROR_MAXLENGTH: 'This must be ${val} characters or fewer.', ERROR_MINLENGTH: 'This must be at least ${val} characters.', + ERROR_MIN_VALUE: 'The value must be at least ${val}.', + ERROR_MAX_VALUE: 'The value must be at most ${val}.', ERROR_NUMBER: 'This must be a number.', ERROR_INTEGER: 'This must be a whole number.', ERROR_TEL: 'This is not a valid telephone number.', @@ -234,6 +237,7 @@ messages = { - `errorMainClasses` - A string containing one or more space-separated classes to apply to the main error message. - `errorInputClasses` - A string containing one or more space-separated classes to apply to invalid `inputs. - `showMainError` - A boolean indicating whether or not to show the main error message. Defaults to `true`. +- `scrollToError` - A boolean indicating whether to scroll to and focus the first invalid input when validation fails. Defaults to `false`. - `validationSuccessCallback` - A function to be called when validation is successful. - `validationErrorCallback` - A function to be called when validation fails. @@ -303,6 +307,29 @@ Here is a list of the utility functions: - **parseColor**: Parses a color string into a standardized format. - **normalizeValidationResult**: Normalizes a validation result (like a boolean or string) into an object with a valid property and a messages array of strings. +## Breaking Changes in v2.0.0 + +### Event Classes Consolidated + +The separate `ValidationSuccessEvent` and `ValidationErrorEvent` classes have been replaced with a unified `ValidationEvent` class: + +```javascript +// Before (v1.x) +import { ValidationSuccessEvent, ValidationErrorEvent } from '@jdlien/validator' +form.addEventListener('validationSuccess', (e: ValidationSuccessEvent) => { ... }) + +// After (v2.0) +import { ValidationEvent } from '@jdlien/validator' +form.addEventListener('validationSuccess', (e: ValidationEvent) => { ... }) +form.addEventListener('validationError', (e: ValidationEvent) => { ... }) +``` + +The `ValidationEvent` class has a `type` property that is either `'validationSuccess'` or `'validationError'`, and a `submitEvent` property containing the original form submission event. + +### Types File Removed + +The separate `types.d.ts` file has been removed. All types are now exported directly from the main module. + ## Contributing Install dev dependencies: diff --git a/demo/index.html b/demo/index.html index 0e86e0b..51df569 100644 --- a/demo/index.html +++ b/demo/index.html @@ -14,7 +14,8 @@

Validator has been applied to this form to help sanitize and validate user input.
This form won't submit if any fields are invalid and helpful errors will be shown or - hidden as the user changes fields. + hidden as the user changes fields.
+ With scrollToError: true, the page will scroll to the first error on submit.

Install this Validator through npm:
@@ -246,6 +247,40 @@

+
+ +
+
+ +
+

+ Value must be between 0 and 100 (uses data-min and data-max) +

+ +
+ +
+
+
+

Dates can be entered by users in almost any format, and will be converted to the format specified in the data-date-format attribute.
@@ -871,7 +906,8 @@

From 5c2f58733b8235bf102036ead6c2745a089805f5 Mon Sep 17 00:00:00 2001 From: JD Lien Date: Fri, 23 Jan 2026 01:55:50 -0700 Subject: [PATCH 05/54] Add tests to improve branch coverage - Test error element lookup when id-based error doesn't exist but name-based does - Test validateValue with non-numeric input (NaN branch) - Test submitHandler when callbacks are not set - Test preventDefault blocking form submission - Fix ArrowDown tests to directly invoke handler 231 tests passing, 100% line coverage Signed-off-by: JD Lien --- tests/Validator.errors.test.ts | 18 ++++++++++++++ tests/Validator.inputHandlers.test.ts | 6 ++--- tests/Validator.minMax.test.ts | 13 ++++++++++ tests/Validator.submitHandler.test.ts | 34 +++++++++++++++++++++++++++ 4 files changed, 68 insertions(+), 3 deletions(-) diff --git a/tests/Validator.errors.test.ts b/tests/Validator.errors.test.ts index a7f3147..569de57 100644 --- a/tests/Validator.errors.test.ts +++ b/tests/Validator.errors.test.ts @@ -102,6 +102,24 @@ describe('Validator', () => { expect(errorEl2).toBeNull() }) + it('returns error element by name when id-based error element does not exist', () => { + // Input has both id and name, but only name-based error element exists + const formControl4 = document.createElement('input') + formControl4.type = 'text' + formControl4.id = 'control-with-different-id' + formControl4.name = 'control-by-name' + form.appendChild(formControl4) + + // Only create error element for the name, not the id + const errorDiv4 = document.createElement('div') + errorDiv4.id = 'control-by-name-error' + form.appendChild(errorDiv4) + + const errorEl4 = (validator as any).getErrorEl(formControl4) + expect(errorEl4).toBeTruthy() + expect(errorEl4.id).toBe('control-by-name-error') + }) + it('handles ids with special characters', () => { const formControl4 = document.createElement('input') formControl4.type = 'text' diff --git a/tests/Validator.inputHandlers.test.ts b/tests/Validator.inputHandlers.test.ts index 5e727a0..b4d5123 100644 --- a/tests/Validator.inputHandlers.test.ts +++ b/tests/Validator.inputHandlers.test.ts @@ -208,16 +208,16 @@ describe('Validator', () => { Object.defineProperty(event, 'key', { value: 'ArrowDown' }) formControl.value = '5' - formControl.dispatchEvent(event) + ;(validator as any).inputKeydownHandler(event) expect(formControl.value).toEqual('4') // Test that it doesn't go below 0 formControl.value = '0' - formControl.dispatchEvent(event) + ;(validator as any).inputKeydownHandler(event) expect(formControl.value).toEqual('0') formControl.value = '' - formControl.dispatchEvent(event) + ;(validator as any).inputKeydownHandler(event) expect(formControl.value).toEqual('0') }) diff --git a/tests/Validator.minMax.test.ts b/tests/Validator.minMax.test.ts index 54082ae..e129add 100644 --- a/tests/Validator.minMax.test.ts +++ b/tests/Validator.minMax.test.ts @@ -319,5 +319,18 @@ describe('Validator', () => { validator.messages.ERROR_MIN_VALUE.replace('${val}', '10') ) }) + + it('returns true and skips validation when value is non-numeric', () => { + // Type validation should handle non-numeric values, not min/max validation + formControl.type = 'number' + formControl.value = 'abc' + formControl.setAttribute('data-min', '10') + formControl.setAttribute('data-max', '100') + + const result = (validator as any).validateValue(formControl) + expect(result).toBeTruthy() + // No min/max errors should be added since value isn't a valid number + expect(validator.inputErrors[formControl.name]).toEqual([]) + }) }) // end validate Min/Max Value }) // end describe('Validator') diff --git a/tests/Validator.submitHandler.test.ts b/tests/Validator.submitHandler.test.ts index 0bc347c..362a2df 100644 --- a/tests/Validator.submitHandler.test.ts +++ b/tests/Validator.submitHandler.test.ts @@ -87,6 +87,40 @@ describe('Validator', () => { ;(validator as any).submitHandler(new Event('submit')) expect(form.submit).not.toHaveBeenCalled() }) + + it('does not call validationSuccessCallback if it is not set', async () => { + // Ensure callback is undefined + ;(validator as any).validationSuccessCallback = undefined + vi.spyOn(form, 'submit').mockImplementation(() => {}) + vi.spyOn(validator, 'validate').mockImplementation(() => Promise.resolve(true)) + + // Should not throw even without callback + await expect((validator as any).submitHandler(new Event('submit'))).resolves.not.toThrow() + }) + + it('does not call validationErrorCallback if it is not set', async () => { + // Ensure callback is undefined + ;(validator as any).validationErrorCallback = undefined + vi.spyOn(validator, 'validate').mockImplementation(() => Promise.resolve(false)) + + // Should not throw even without callback + await expect((validator as any).submitHandler(new Event('submit'))).resolves.not.toThrow() + }) + + it('does not submit the form if validationEvent.defaultPrevented is true', async () => { + vi.spyOn(form, 'submit').mockImplementation(() => {}) + vi.spyOn(validator, 'validate').mockImplementation(() => Promise.resolve(true)) + + // Add an event listener that calls preventDefault on the validation event + form.addEventListener('validationSuccess', (e) => { + e.preventDefault() + }) + + await (validator as any).submitHandler(new Event('submit')) + + // Form should NOT be submitted because defaultPrevented is true + expect(form.submit).not.toHaveBeenCalled() + }) }) describe('ValidationEvent', () => { From f68d6f6fea8c129a4564efe0153af5872a271e9e Mon Sep 17 00:00:00 2001 From: JD Lien Date: Fri, 23 Jan 2026 12:07:27 -0700 Subject: [PATCH 06/54] Fix select dropdown chevron styling and update to Tailwind v4 syntax - Add appearance-none to hide native browser dropdown arrow - Add background-repeat: no-repeat to prevent chevron tiling - Adjust chevron position for balanced padding (0.4rem from right) - Remove unnecessary !important from background-image - Migrate @layer components to @utility API per Tailwind v4 guidelines Signed-off-by: JD Lien --- demo/demo-src.css | 42 ++++++++++++++++++++---------------------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/demo/demo-src.css b/demo/demo-src.css index 2a4235f..1e63204 100644 --- a/demo/demo-src.css +++ b/demo/demo-src.css @@ -72,33 +72,31 @@ } select { - @apply pr-6; - /* background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='rgb(120 113 108)' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e") !important; */ - background-image: url('data:image/svg+xml,%3Csvg xmlns=%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22 fill=%22none%22 viewBox=%220 0 24 24%22 stroke-width=%221.5%22 stroke=%22rgb(120 113 108)%22%3E%3Cpath stroke-linecap=%22round%22 stroke-linejoin=%22round%22 d=%22M19.5 8.25l-7.5 7.5-7.5-7.5%22 %2F%3E%3C%2Fsvg%3E') !important; + @apply pr-6 appearance-none; + background-image: url('data:image/svg+xml,%3Csvg xmlns=%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22 fill=%22none%22 viewBox=%220 0 24 24%22 stroke-width=%221.5%22 stroke=%22rgb(120 113 108)%22%3E%3Cpath stroke-linecap=%22round%22 stroke-linejoin=%22round%22 d=%22M19.5 8.25l-7.5 7.5-7.5-7.5%22 %2F%3E%3C%2Fsvg%3E'); background-size: 1.2rem; - background-position: right 0.1rem center; + background-position: right 0.4rem center; + background-repeat: no-repeat; } } -@layer components { - .checked-label { - @apply ml-1.5 mr-1; - /* no styles are directly applied unless this is used with a checked input */ - } +/* Component utilities using Tailwind v4 @utility API */ +@utility checked-label { + @apply ml-1.5 mr-1; +} - input:checked + .checked-label { - @apply text-black dark:text-white; - /* This is only really visible in dark mode, giving the text a subtle glow */ - text-shadow: 0 0 5px hsl(0 0% 100%/25%); - } +/* Styles for checked-label when preceded by a checked input */ +input:checked + .checked-label { + @apply text-black dark:text-white; + text-shadow: 0 0 5px hsl(0 0% 100%/25%); +} - /* Apply checked-border to labels for checkboxes and radio buttons for a label that colors up automatically when checked */ - .checked-border { - @apply inline-flex items-center border border-zinc-300/30 bg-zinc-50 px-1 text-zinc-700 shadow-inner; - @apply hover:bg-white dark:border-zinc-500/10 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800 dark:focus:bg-zinc-800; - } +/* Apply checked-border to labels for checkboxes and radio buttons for a label that colors up automatically when checked */ +@utility checked-border { + @apply inline-flex items-center border border-zinc-300/30 bg-zinc-50 px-1 text-zinc-700 shadow-inner; + @apply hover:bg-white dark:border-zinc-500/10 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800 dark:focus:bg-zinc-800; +} - .checked-border:has([type='checkbox']:checked, [type='radio']:checked) { - @apply border-blue-600 text-black hover:border-blue-500 dark:text-white; - } +.checked-border:has([type='checkbox']:checked, [type='radio']:checked) { + @apply border-blue-600 text-black hover:border-blue-500 dark:text-white; } From 2444c1c28aa6a2b26fa876fa400ea83a2404d9e9 Mon Sep 17 00:00:00 2001 From: JD Lien Date: Fri, 23 Jan 2026 15:09:42 -0700 Subject: [PATCH 07/54] Add form-field custom element to reduce demo boilerplate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create reusable custom element (384 lines) - Refactor demo to use custom element (915 → 289 lines, 68% reduction) - Support all field types: text, number, date, select, radio, checkbox, textarea, color - Preserve all validator functionality: validation, error display, hints - Color picker with swatch sync working Signed-off-by: JD Lien --- demo/form-field.js | 384 +++++++++++++++++ demo/index.html | 1031 +++++++++----------------------------------- 2 files changed, 587 insertions(+), 828 deletions(-) create mode 100644 demo/form-field.js diff --git a/demo/form-field.js b/demo/form-field.js new file mode 100644 index 0000000..66876c8 --- /dev/null +++ b/demo/form-field.js @@ -0,0 +1,384 @@ +/** + * FormField Custom Element + * A reusable form field component that reduces boilerplate for forms using @jdlien/validator + * + * Usage: + * + * + * + */ +class FormField extends HTMLElement { + static get observedAttributes() { + return ['value', 'disabled'] + } + + connectedCallback() { + this.render() + } + + attributeChangedCallback(name, oldValue, newValue) { + if (!this.isConnected) return + const input = this.querySelector('input, select, textarea') + if (!input) return + + if (name === 'value') { + input.value = newValue || '' + // For color type, update the swatch + if (this.getAttribute('type') === 'color') { + this.updateColorSwatch(newValue) + } + } else if (name === 'disabled') { + input.disabled = newValue !== null + } + } + + render() { + const type = this.getAttribute('type') || 'text' + const name = this.getAttribute('name') + const id = this.getAttribute('id') || name + + // Remove id from the custom element itself to avoid duplicate IDs + this.removeAttribute('id') + + if (type === 'textarea') { + this.renderTextarea(id, name) + } else if (type === 'select') { + this.renderSelect(id, name) + } else if (['radio', 'checkbox'].includes(type) && this.hasAttribute('options')) { + this.renderGroup(id, name, type) + } else if (type === 'color') { + this.renderColorInput(id, name) + } else { + this.renderInput(id, name, type) + } + } + + // Parse options from CSV string or JSON array + parseOptions() { + const optionsAttr = this.getAttribute('options') + if (!optionsAttr) return [] + + // Try JSON first + if (optionsAttr.startsWith('[')) { + try { + return JSON.parse(optionsAttr) + } catch (e) { + console.warn('Failed to parse options as JSON:', e) + } + } + + // Fall back to CSV + return optionsAttr.split(',').map((opt) => { + const trimmed = opt.trim() + return { value: trimmed, label: trimmed } + }) + } + + // Get common wrapper HTML + getWrapper(id, labelText, contentHTML) { + const hint = this.getAttribute('hint') + const hintHTML = hint + ? `

${hint}

` + : '' + + return ` +
+ +
+ ${contentHTML} + ${hintHTML} +
+ +
+
+
+ ` + } + + // Copy validation and data attributes to the input element + getValidationAttrs() { + const attrs = [] + const attrMap = { + required: 'required', + disabled: 'disabled', + placeholder: 'placeholder', + value: 'value', + minlength: 'minlength', + maxlength: 'maxlength', + pattern: 'pattern', + min: 'data-min', + max: 'data-max', + 'min-length': 'data-min-length', + 'max-length': 'data-max-length', + 'date-format': 'data-date-format', + 'date-range': 'data-date-range', + 'time-format': 'data-time-format', + 'error-msg': 'data-error-default', + 'data-type': 'data-type', + } + + for (const [formFieldAttr, inputAttr] of Object.entries(attrMap)) { + if (this.hasAttribute(formFieldAttr)) { + const val = this.getAttribute(formFieldAttr) + // Boolean attributes + if (['required', 'disabled'].includes(formFieldAttr)) { + attrs.push(inputAttr) + } else { + attrs.push(`${inputAttr}="${val}"`) + } + } + } + + // Copy any data-* attributes directly + for (const attr of this.attributes) { + if (attr.name.startsWith('data-') && !Object.values(attrMap).includes(attr.name)) { + attrs.push(`${attr.name}="${attr.value}"`) + } + } + + return attrs.join(' ') + } + + // Get data-type attribute value based on field type + getDataType(type) { + const typeMap = { + integer: 'integer', + number: 'number', + date: 'date', + datetime: 'datetime', + time: 'time', + email: 'email', + tel: 'tel', + zip: 'zip', + postal: 'postal', + color: 'color', + } + return typeMap[type] || null + } + + // Get HTML input type + getInputType(type) { + const typeMap = { + integer: 'text', + number: 'text', + date: 'text', + datetime: 'text', + time: 'text', + zip: 'text', + postal: 'text', + color: 'text', + } + return typeMap[type] || type + } + + // Get inputmode attribute + getInputMode(type) { + const modeMap = { + email: 'email', + tel: 'tel', + url: 'url', + number: 'decimal', + integer: 'numeric', + } + return modeMap[type] || null + } + + renderInput(id, name, type) { + const label = this.getAttribute('label') || name + const inputType = this.getInputType(type) + const dataType = this.getDataType(type) + const inputMode = this.getInputMode(type) + const validationAttrs = this.getValidationAttrs() + + const dataTypeAttr = dataType && !this.hasAttribute('data-type') ? `data-type="${dataType}"` : '' + const inputModeAttr = inputMode ? `inputmode="${inputMode}"` : '' + const flatpickrClass = + ['date', 'datetime', 'time'].includes(type) ? 'flatpickr-input' : '' + + const inputHTML = ` +
+ +
+ ` + + this.innerHTML = this.getWrapper(id, label, inputHTML) + } + + renderColorInput(id, name) { + const label = this.getAttribute('label') || name + const value = this.getAttribute('value') || '#888888' + const validationAttrs = this.getValidationAttrs() + + const inputHTML = ` +
+ + +
+ ` + + this.innerHTML = this.getWrapper(id, label, inputHTML) + + // Set up color picker sync + this.setupColorSync(id) + } + + setupColorSync(id) { + const textInput = this.querySelector(`#${id}`) + const colorInput = this.querySelector(`#${id}-color`) + const colorLabel = this.querySelector(`#${id}-color-label`) + + if (!textInput || !colorInput || !colorLabel) return + + // Sync color picker to text input + colorInput.addEventListener('input', () => { + textInput.value = colorInput.value + colorLabel.style.backgroundColor = colorInput.value + textInput.dispatchEvent(new Event('input', { bubbles: true })) + }) + + // Sync text input to color picker (on blur to allow typing) + textInput.addEventListener('blur', () => { + const val = textInput.value + if (/^#[0-9A-Fa-f]{6}$/.test(val)) { + colorInput.value = val + colorLabel.style.backgroundColor = val + } + }) + } + + updateColorSwatch(value) { + const colorLabel = this.querySelector('[id$="-color-label"]') + const colorInput = this.querySelector('[type="color"]') + if (colorLabel && /^#[0-9A-Fa-f]{6}$/.test(value)) { + colorLabel.style.backgroundColor = value + if (colorInput) colorInput.value = value + } + } + + renderSelect(id, name) { + const label = this.getAttribute('label') || name + const options = this.parseOptions() + const validationAttrs = this.getValidationAttrs() + + const optionsHTML = options + .map((opt) => { + const val = typeof opt === 'object' ? opt.value : opt + const lbl = typeof opt === 'object' ? opt.label : opt + return `` + }) + .join('') + + const selectHTML = ` + + ` + + this.innerHTML = this.getWrapper(id, label, selectHTML) + } + + renderGroup(id, name, type) { + const label = this.getAttribute('label') || name + const options = this.parseOptions() + const isCheckbox = type === 'checkbox' + const errorMsg = this.getAttribute('error-msg') || '' + const isRequired = this.hasAttribute('required') + const roundedClass = isCheckbox ? '' : 'rounded-full' + + const optionsHTML = options + .map((opt, index) => { + const val = typeof opt === 'object' ? opt.value : opt + const lbl = typeof opt === 'object' ? opt.label : opt + const optId = `${id}-${index + 1}-${val.toLowerCase().replace(/\s+/g, '-')}` + + return ` +
+
+ +
+
+ ` + }) + .join('') + + const groupHTML = ` +
+
+ ${optionsHTML} +
+
+ ` + + this.innerHTML = this.getWrapper(id, label, groupHTML) + } + + renderTextarea(id, name) { + const label = this.getAttribute('label') || name + const validationAttrs = this.getValidationAttrs() + const placeholder = this.getAttribute('placeholder') || '' + + const textareaHTML = ` + + ` + + this.innerHTML = this.getWrapper(id, label, textareaHTML) + } +} + +customElements.define('form-field', FormField) diff --git a/demo/index.html b/demo/index.html index 51df569..5855e9f 100644 --- a/demo/index.html +++ b/demo/index.html @@ -5,6 +5,7 @@ Validator Demo + @@ -39,247 +40,69 @@

Numeric inputs won't even allow invalid values to be typed in.

-
- -
-
- -
- -
- -
-
-
- -
- -
-
- -
- -
- -
-
-
- -
- -
-
- -
-

- A letter followed by numbers (^[a-zA-Z]\d+$) -

-
- -
-
-
- -
- -
-
- -
- -
- -
-
-
- -
- -
-
- -
- -
- -
-
-
- -
- -
-
- -
-

- Up/down arrow keys increment & decrement -

-
- -
-
-
- -
- -
-
- -
-

- Negative numbers and decimals are allowed -

- -
- -
-
-
- -
- -
-
- -
-

- Value must be between 0 and 100 (uses data-min and data-max) -

- -
- -
-
-
+ + + + + + + + + + + + + + +

Dates can be entered by users in almost any format, and will be converted @@ -289,607 +112,159 @@

Flatpickr. -

-
- -
-
- -
-

- Any date will be converted to the YYYY-MMM-DD format -

-
- -
-
-
- -
- -
-
- -
-

- YYYY-MM-DD format is enforced -

- -
- -
-
-
- -
- -
-
- -
- -
- -
-
-
- -
- -
-
- -
-

- Datetime can be used to also include a time with a date -

-
- -
-
-
- -
- -
-
- -
-

- Datetime can be used to also include a time with a date -

-
- -
-
-
- -
- -
-
- -
-

- This is a text input with a provided error -

-
- -
-
-
- -
- -
-
- -
-

- A North-American phone number will be formatted on change -

- -
- -
-
-
- -
- -
-
- -
- -
- -
-
-
- -
- -
-
- -
- -
- -
-
-
- -
- -
-
- -
- -
- -
-
-
- -
- -
-
- -
-

- Anything resembling a time will be converted into the specified format -

-
- -
-
-
- -
- -
-
- - -
-

- The label will show a swatch of any valid color -

-
- -
-
-
+ + + + + + + + + + + + + + + + + + + + + + +

Selects can be required as well.

-
- -
- - -
- -
-
-
+ +

You can require groups of radio buttons or checkboxes so one must be selected.

- -
- -
-
-
-
-
- -
-
-
-
- -
-
-
-
- -
-
-
-
- -
- -
-
-
- -
- -
-
-
-
-
- -
-
-
-
- -
-
-
-
- -
-
-
-
- -
- -
-
-
+ + + +

Textarea elements can have min and max lengths as well as be required.

-
- -
- - -
- -
-
-
+ +
Date: Fri, 23 Jan 2026 15:24:35 -0700 Subject: [PATCH 08/54] Refactor form-field custom element to TypeScript with DOM API - Convert form-field.js to TypeScript (.ts) - Replace innerHTML/template strings with DOM API using el() helper function - Add proper TypeScript types for attributes, options, and field types - Fix validator initialization timing by using module script with customElements.whenDefined() to ensure custom elements render before validator attaches Signed-off-by: JD Lien --- demo/form-field.js | 384 ------------------------------------------ demo/form-field.ts | 403 +++++++++++++++++++++++++++++++++++++++++++++ demo/index.html | 7 +- 3 files changed, 408 insertions(+), 386 deletions(-) delete mode 100644 demo/form-field.js create mode 100644 demo/form-field.ts diff --git a/demo/form-field.js b/demo/form-field.js deleted file mode 100644 index 66876c8..0000000 --- a/demo/form-field.js +++ /dev/null @@ -1,384 +0,0 @@ -/** - * FormField Custom Element - * A reusable form field component that reduces boilerplate for forms using @jdlien/validator - * - * Usage: - * - * - * - */ -class FormField extends HTMLElement { - static get observedAttributes() { - return ['value', 'disabled'] - } - - connectedCallback() { - this.render() - } - - attributeChangedCallback(name, oldValue, newValue) { - if (!this.isConnected) return - const input = this.querySelector('input, select, textarea') - if (!input) return - - if (name === 'value') { - input.value = newValue || '' - // For color type, update the swatch - if (this.getAttribute('type') === 'color') { - this.updateColorSwatch(newValue) - } - } else if (name === 'disabled') { - input.disabled = newValue !== null - } - } - - render() { - const type = this.getAttribute('type') || 'text' - const name = this.getAttribute('name') - const id = this.getAttribute('id') || name - - // Remove id from the custom element itself to avoid duplicate IDs - this.removeAttribute('id') - - if (type === 'textarea') { - this.renderTextarea(id, name) - } else if (type === 'select') { - this.renderSelect(id, name) - } else if (['radio', 'checkbox'].includes(type) && this.hasAttribute('options')) { - this.renderGroup(id, name, type) - } else if (type === 'color') { - this.renderColorInput(id, name) - } else { - this.renderInput(id, name, type) - } - } - - // Parse options from CSV string or JSON array - parseOptions() { - const optionsAttr = this.getAttribute('options') - if (!optionsAttr) return [] - - // Try JSON first - if (optionsAttr.startsWith('[')) { - try { - return JSON.parse(optionsAttr) - } catch (e) { - console.warn('Failed to parse options as JSON:', e) - } - } - - // Fall back to CSV - return optionsAttr.split(',').map((opt) => { - const trimmed = opt.trim() - return { value: trimmed, label: trimmed } - }) - } - - // Get common wrapper HTML - getWrapper(id, labelText, contentHTML) { - const hint = this.getAttribute('hint') - const hintHTML = hint - ? `

${hint}

` - : '' - - return ` -
- -
- ${contentHTML} - ${hintHTML} -
- -
-
-
- ` - } - - // Copy validation and data attributes to the input element - getValidationAttrs() { - const attrs = [] - const attrMap = { - required: 'required', - disabled: 'disabled', - placeholder: 'placeholder', - value: 'value', - minlength: 'minlength', - maxlength: 'maxlength', - pattern: 'pattern', - min: 'data-min', - max: 'data-max', - 'min-length': 'data-min-length', - 'max-length': 'data-max-length', - 'date-format': 'data-date-format', - 'date-range': 'data-date-range', - 'time-format': 'data-time-format', - 'error-msg': 'data-error-default', - 'data-type': 'data-type', - } - - for (const [formFieldAttr, inputAttr] of Object.entries(attrMap)) { - if (this.hasAttribute(formFieldAttr)) { - const val = this.getAttribute(formFieldAttr) - // Boolean attributes - if (['required', 'disabled'].includes(formFieldAttr)) { - attrs.push(inputAttr) - } else { - attrs.push(`${inputAttr}="${val}"`) - } - } - } - - // Copy any data-* attributes directly - for (const attr of this.attributes) { - if (attr.name.startsWith('data-') && !Object.values(attrMap).includes(attr.name)) { - attrs.push(`${attr.name}="${attr.value}"`) - } - } - - return attrs.join(' ') - } - - // Get data-type attribute value based on field type - getDataType(type) { - const typeMap = { - integer: 'integer', - number: 'number', - date: 'date', - datetime: 'datetime', - time: 'time', - email: 'email', - tel: 'tel', - zip: 'zip', - postal: 'postal', - color: 'color', - } - return typeMap[type] || null - } - - // Get HTML input type - getInputType(type) { - const typeMap = { - integer: 'text', - number: 'text', - date: 'text', - datetime: 'text', - time: 'text', - zip: 'text', - postal: 'text', - color: 'text', - } - return typeMap[type] || type - } - - // Get inputmode attribute - getInputMode(type) { - const modeMap = { - email: 'email', - tel: 'tel', - url: 'url', - number: 'decimal', - integer: 'numeric', - } - return modeMap[type] || null - } - - renderInput(id, name, type) { - const label = this.getAttribute('label') || name - const inputType = this.getInputType(type) - const dataType = this.getDataType(type) - const inputMode = this.getInputMode(type) - const validationAttrs = this.getValidationAttrs() - - const dataTypeAttr = dataType && !this.hasAttribute('data-type') ? `data-type="${dataType}"` : '' - const inputModeAttr = inputMode ? `inputmode="${inputMode}"` : '' - const flatpickrClass = - ['date', 'datetime', 'time'].includes(type) ? 'flatpickr-input' : '' - - const inputHTML = ` -
- -
- ` - - this.innerHTML = this.getWrapper(id, label, inputHTML) - } - - renderColorInput(id, name) { - const label = this.getAttribute('label') || name - const value = this.getAttribute('value') || '#888888' - const validationAttrs = this.getValidationAttrs() - - const inputHTML = ` -
- - -
- ` - - this.innerHTML = this.getWrapper(id, label, inputHTML) - - // Set up color picker sync - this.setupColorSync(id) - } - - setupColorSync(id) { - const textInput = this.querySelector(`#${id}`) - const colorInput = this.querySelector(`#${id}-color`) - const colorLabel = this.querySelector(`#${id}-color-label`) - - if (!textInput || !colorInput || !colorLabel) return - - // Sync color picker to text input - colorInput.addEventListener('input', () => { - textInput.value = colorInput.value - colorLabel.style.backgroundColor = colorInput.value - textInput.dispatchEvent(new Event('input', { bubbles: true })) - }) - - // Sync text input to color picker (on blur to allow typing) - textInput.addEventListener('blur', () => { - const val = textInput.value - if (/^#[0-9A-Fa-f]{6}$/.test(val)) { - colorInput.value = val - colorLabel.style.backgroundColor = val - } - }) - } - - updateColorSwatch(value) { - const colorLabel = this.querySelector('[id$="-color-label"]') - const colorInput = this.querySelector('[type="color"]') - if (colorLabel && /^#[0-9A-Fa-f]{6}$/.test(value)) { - colorLabel.style.backgroundColor = value - if (colorInput) colorInput.value = value - } - } - - renderSelect(id, name) { - const label = this.getAttribute('label') || name - const options = this.parseOptions() - const validationAttrs = this.getValidationAttrs() - - const optionsHTML = options - .map((opt) => { - const val = typeof opt === 'object' ? opt.value : opt - const lbl = typeof opt === 'object' ? opt.label : opt - return `` - }) - .join('') - - const selectHTML = ` - - ` - - this.innerHTML = this.getWrapper(id, label, selectHTML) - } - - renderGroup(id, name, type) { - const label = this.getAttribute('label') || name - const options = this.parseOptions() - const isCheckbox = type === 'checkbox' - const errorMsg = this.getAttribute('error-msg') || '' - const isRequired = this.hasAttribute('required') - const roundedClass = isCheckbox ? '' : 'rounded-full' - - const optionsHTML = options - .map((opt, index) => { - const val = typeof opt === 'object' ? opt.value : opt - const lbl = typeof opt === 'object' ? opt.label : opt - const optId = `${id}-${index + 1}-${val.toLowerCase().replace(/\s+/g, '-')}` - - return ` -
-
- -
-
- ` - }) - .join('') - - const groupHTML = ` -
-
- ${optionsHTML} -
-
- ` - - this.innerHTML = this.getWrapper(id, label, groupHTML) - } - - renderTextarea(id, name) { - const label = this.getAttribute('label') || name - const validationAttrs = this.getValidationAttrs() - const placeholder = this.getAttribute('placeholder') || '' - - const textareaHTML = ` - - ` - - this.innerHTML = this.getWrapper(id, label, textareaHTML) - } -} - -customElements.define('form-field', FormField) diff --git a/demo/form-field.ts b/demo/form-field.ts new file mode 100644 index 0000000..ffcdc88 --- /dev/null +++ b/demo/form-field.ts @@ -0,0 +1,403 @@ +/** + * FormField Custom Element + * A reusable form field component that reduces boilerplate for forms using @jdlien/validator + * + * Usage: + * + * + * + */ + +// Types +type Attrs = Record +type Child = HTMLElement | string | null | undefined + +interface OptionItem { + value: string + label: string +} + +type FieldType = + | 'text' + | 'number' + | 'integer' + | 'email' + | 'url' + | 'tel' + | 'date' + | 'datetime' + | 'time' + | 'color' + | 'zip' + | 'postal' + | 'select' + | 'radio' + | 'checkbox' + | 'textarea' + +// DOM helper - our mini jQuery +function el( + tag: K, + attrs: Attrs = {}, + children: Child[] = [] +): HTMLElementTagNameMap[K] { + const element = document.createElement(tag) + + for (const [key, value] of Object.entries(attrs)) { + if (value === undefined || value === false) continue + if (value === true) { + element.setAttribute(key, '') + } else if (key === 'className') { + element.className = value + } else if (key.startsWith('data-')) { + element.setAttribute(key, value) + } else { + element.setAttribute(key, value) + } + } + + for (const child of children) { + if (child === null || child === undefined) continue + if (typeof child === 'string') { + element.appendChild(document.createTextNode(child)) + } else { + element.appendChild(child) + } + } + + return element +} + +// Helper to create element with innerHTML (for hint text with HTML) +function elHtml( + tag: K, + attrs: Attrs = {}, + html: string +): HTMLElementTagNameMap[K] { + const element = el(tag, attrs) + element.innerHTML = html + return element +} + +// Attribute mappings +const ATTR_MAP: Record = { + required: 'required', + disabled: 'disabled', + placeholder: 'placeholder', + value: 'value', + minlength: 'minlength', + maxlength: 'maxlength', + pattern: 'pattern', + min: 'data-min', + max: 'data-max', + 'min-length': 'data-min-length', + 'max-length': 'data-max-length', + 'date-format': 'data-date-format', + 'date-range': 'data-date-range', + 'time-format': 'data-time-format', + 'error-msg': 'data-error-default', +} + +const DATA_TYPE_MAP: Record = { + integer: 'integer', + number: 'number', + date: 'date', + datetime: 'datetime', + time: 'time', + email: 'email', + tel: 'tel', + zip: 'zip', + postal: 'postal', + color: 'color', +} + +const INPUT_TYPE_MAP: Record = { + integer: 'text', + number: 'text', + date: 'text', + datetime: 'text', + time: 'text', + zip: 'text', + postal: 'text', + color: 'text', +} + +const INPUT_MODE_MAP: Record = { + email: 'email', + tel: 'tel', + url: 'url', + number: 'decimal', + integer: 'numeric', +} + +class FormField extends HTMLElement { + static get observedAttributes(): string[] { + return ['value', 'disabled'] + } + + connectedCallback(): void { + this.render() + } + + attributeChangedCallback(name: string, _oldValue: string | null, newValue: string | null): void { + if (!this.isConnected) return + const input = this.querySelector( + 'input, select, textarea' + ) + if (!input) return + + if (name === 'value') { + input.value = newValue || '' + if (this.getAttribute('type') === 'color') { + this.updateColorSwatch(newValue || '') + } + } else if (name === 'disabled') { + ;(input as HTMLInputElement).disabled = newValue !== null + } + } + + private render(): void { + const type = (this.getAttribute('type') || 'text') as FieldType + const name = this.getAttribute('name') || '' + const id = this.getAttribute('id') || name + + // Remove id from custom element to avoid duplicates + this.removeAttribute('id') + + if (type === 'textarea') { + this.renderTextarea(id, name) + } else if (type === 'select') { + this.renderSelect(id, name) + } else if ((type === 'radio' || type === 'checkbox') && this.hasAttribute('options')) { + this.renderGroup(id, name, type) + } else if (type === 'color') { + this.renderColorInput(id, name) + } else { + this.renderInput(id, name, type) + } + } + + private parseOptions(): OptionItem[] { + const optionsAttr = this.getAttribute('options') + if (!optionsAttr) return [] + + if (optionsAttr.startsWith('[')) { + try { + return JSON.parse(optionsAttr) as OptionItem[] + } catch { + console.warn('Failed to parse options as JSON') + } + } + + return optionsAttr.split(',').map((opt) => { + const trimmed = opt.trim() + return { value: trimmed, label: trimmed } + }) + } + + private getValidationAttrs(): Attrs { + const attrs: Attrs = {} + + for (const [formFieldAttr, inputAttr] of Object.entries(ATTR_MAP)) { + if (this.hasAttribute(formFieldAttr)) { + const val = this.getAttribute(formFieldAttr) + if (formFieldAttr === 'required' || formFieldAttr === 'disabled') { + attrs[inputAttr] = true + } else { + attrs[inputAttr] = val || undefined + } + } + } + + // Copy data-* attributes directly + for (const attr of Array.from(this.attributes)) { + if (attr.name.startsWith('data-') && !Object.values(ATTR_MAP).includes(attr.name)) { + attrs[attr.name] = attr.value + } + } + + return attrs + } + + private buildWrapper(id: string, labelText: string, content: HTMLElement): HTMLElement { + const hint = this.getAttribute('hint') + + return el('div', { className: 'sm:grid sm:grid-cols-3 sm:items-start sm:gap-4' }, [ + el('label', { for: id, id: `${id}-label`, className: 'block font-medium sm:mt-px sm:pt-1' }, [ + labelText, + ]), + el('div', { className: 'my-0.5 sm:mt-0 sm:col-span-2' }, [ + content, + hint ? elHtml('p', { className: 'mt-1 text-sm text-gray-500 dark:text-gray-400' }, hint) : null, + el('div', { style: 'min-height: 20px' }, [ + el('div', { + className: 'error hidden text-sm text-red-600 transition dark:text-red-500', + id: `${id}-error`, + }), + ]), + ]), + ]) + } + + private renderInput(id: string, name: string, type: FieldType): void { + const label = this.getAttribute('label') || name + const inputType = INPUT_TYPE_MAP[type] || type + const dataType = DATA_TYPE_MAP[type] + const inputMode = INPUT_MODE_MAP[type] + const validationAttrs = this.getValidationAttrs() + const flatpickrClass = ['date', 'datetime', 'time'].includes(type) ? ' flatpickr-input' : '' + + const input = el('input', { + className: `block w-full px-1.5 transition${flatpickrClass}`, + type: inputType, + id, + name, + 'aria-describedby': `${id}-error`, + ...(dataType && !this.hasAttribute('data-type') ? { 'data-type': dataType } : {}), + ...(inputMode ? { inputmode: inputMode } : {}), + ...validationAttrs, + }) + + const wrapper = el('div', { className: 'flex relative sm:max-w-sm' }, [input]) + this.appendChild(this.buildWrapper(id, label, wrapper)) + } + + private renderColorInput(id: string, name: string): void { + const label = this.getAttribute('label') || name + const value = this.getAttribute('value') || '#888888' + const validationAttrs = this.getValidationAttrs() + + const textInput = el('input', { + className: 'block w-full px-1.5 transition rounded-r-none', + type: 'text', + id, + name, + 'data-type': 'color', + value, + 'aria-describedby': `${id}-error`, + ...validationAttrs, + }) + + const colorInput = el('input', { + type: 'color', + id: `${id}-color`, + className: 'invisible w-full h-full', + value, + }) + + const colorLabel = el( + 'label', + { + id: `${id}-color-label`, + for: `${id}-color`, + className: 'border border-l-0 border-gray-350 dark:border-gray-500 cursor-pointer w-20 rounded-r', + style: `background-color: ${value}`, + }, + [colorInput] + ) + + const wrapper = el('div', { className: 'flex relative sm:max-w-sm' }, [textInput, colorLabel]) + this.appendChild(this.buildWrapper(id, label, wrapper)) + + // Set up color sync with direct references (no querying needed!) + colorInput.addEventListener('input', () => { + textInput.value = colorInput.value + colorLabel.style.backgroundColor = colorInput.value + textInput.dispatchEvent(new Event('input', { bubbles: true })) + }) + + textInput.addEventListener('blur', () => { + if (/^#[0-9A-Fa-f]{6}$/.test(textInput.value)) { + colorInput.value = textInput.value + colorLabel.style.backgroundColor = textInput.value + } + }) + } + + private updateColorSwatch(value: string): void { + const colorLabel = this.querySelector('[id$="-color-label"]') + const colorInput = this.querySelector('[type="color"]') + if (colorLabel && /^#[0-9A-Fa-f]{6}$/.test(value)) { + colorLabel.style.backgroundColor = value + if (colorInput) colorInput.value = value + } + } + + private renderSelect(id: string, name: string): void { + const label = this.getAttribute('label') || name + const options = this.parseOptions() + const validationAttrs = this.getValidationAttrs() + + const select = el( + 'select', + { + className: 'block w-full sm:max-w-sm', + id, + name, + 'aria-describedby': `${id}-error`, + ...validationAttrs, + }, + [ + el('option', { value: '' }), + ...options.map((opt) => el('option', { value: opt.value }, [opt.label])), + ] + ) + + this.appendChild(this.buildWrapper(id, label, select)) + } + + private renderGroup(id: string, name: string, type: 'radio' | 'checkbox'): void { + const label = this.getAttribute('label') || name + const options = this.parseOptions() + const errorMsg = this.getAttribute('error-msg') || '' + const isRequired = this.hasAttribute('required') + const roundedClass = type === 'checkbox' ? '' : ' rounded-full' + + const optionElements = options.map((opt, index) => { + const optId = `${id}-${index + 1}-${opt.value.toLowerCase().replace(/\s+/g, '-')}` + + return el('div', { className: 'pt-2' }, [ + el('div', { className: 'flex items-start h-5' }, [ + el('label', { className: `checked-border${roundedClass}`, for: optId }, [ + el('input', { + id: optId, + name, + type, + value: opt.value, + className: 'block transition', + 'aria-describedby': `${id}-error`, + ...(isRequired ? { required: true } : {}), + ...(errorMsg ? { 'data-error-default': errorMsg } : {}), + }), + el('span', { className: 'checked-label' }, [opt.label]), + ]), + ]), + ]) + }) + + const wrapper = el('div', { className: 'flex relative sm:max-w-sm' }, [ + el('div', { className: 'mb-3 sm:col-span-2 space-y-3' }, optionElements), + ]) + + this.appendChild(this.buildWrapper(id, label, wrapper)) + } + + private renderTextarea(id: string, name: string): void { + const label = this.getAttribute('label') || name + const placeholder = this.getAttribute('placeholder') || '' + const validationAttrs = this.getValidationAttrs() + + const textarea = el('textarea', { + id, + name, + className: 'w-full', + 'aria-describedby': `${id}-error`, + ...(placeholder ? { placeholder } : {}), + ...validationAttrs, + }) + + this.appendChild(this.buildWrapper(id, label, textarea)) + } +} + +customElements.define('form-field', FormField) diff --git a/demo/index.html b/demo/index.html index 5855e9f..68314d0 100644 --- a/demo/index.html +++ b/demo/index.html @@ -5,7 +5,7 @@ Validator Demo - + @@ -279,10 +279,13 @@

- From f4cb975378516cb1208e9abbc21155fd0a223343 Mon Sep 17 00:00:00 2001 From: JD Lien Date: Fri, 23 Jan 2026 15:49:30 -0700 Subject: [PATCH 09/54] Fix CSS attribute selectors to only target input elements Add 'input' prefix to type attribute selectors in demo-src.css so they don't match custom elements like form-field that also have type attributes. Signed-off-by: JD Lien --- demo/demo-src.css | 56 +++++++++++++++++++++++------------------------ 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/demo/demo-src.css b/demo/demo-src.css index 1e63204..6ec5f34 100644 --- a/demo/demo-src.css +++ b/demo/demo-src.css @@ -1,20 +1,20 @@ @import 'tailwindcss'; @layer base { - [type='text'], - [type='email'], - [type='url'], - [type='password'], - [type='number'], - [type='date'], - [type='datetime-local'], - [type='month'], - [type='search'], - [type='tel'], - [type='time'], - [type='week'], - [type='color'], - [multiple], + input[type='text'], + input[type='email'], + input[type='url'], + input[type='password'], + input[type='number'], + input[type='date'], + input[type='datetime-local'], + input[type='month'], + input[type='search'], + input[type='tel'], + input[type='time'], + input[type='week'], + input[type='color'], + select[multiple], textarea, select { @apply border border-zinc-300 rounded bg-zinc-50 px-2 py-1.5 text-zinc-700 @@ -28,20 +28,20 @@ } /* Form input variants (do not apply when disabled) */ - [type='text']:not([disabled]), - [type='email']:not([disabled]), - [type='url']:not([disabled]), - [type='password']:not([disabled]), - [type='number']:not([disabled]), - [type='date']:not([disabled]), - [type='datetime-local']:not([disabled]), - [type='month']:not([disabled]), - [type='search']:not([disabled]), - [type='tel']:not([disabled]), - [type='time']:not([disabled]), - [type='week']:not([disabled]), - [type='color']:not([disabled]), - [multiple]:not([disabled]), + input[type='text']:not([disabled]), + input[type='email']:not([disabled]), + input[type='url']:not([disabled]), + input[type='password']:not([disabled]), + input[type='number']:not([disabled]), + input[type='date']:not([disabled]), + input[type='datetime-local']:not([disabled]), + input[type='month']:not([disabled]), + input[type='search']:not([disabled]), + input[type='tel']:not([disabled]), + input[type='time']:not([disabled]), + input[type='week']:not([disabled]), + input[type='color']:not([disabled]), + select[multiple]:not([disabled]), textarea:not([disabled]), select:not([disabled]) { @apply hover:bg-white dark:hover:bg-zinc-800; From cbffc8c11e35f97767d35c87cfdb83a40480e20c Mon Sep 17 00:00:00 2001 From: JD Lien Date: Fri, 23 Jan 2026 15:55:01 -0700 Subject: [PATCH 10/54] Add compiled form-field.js for static server compatibility TypeScript modules can't be served directly from static servers - they need to be compiled to JavaScript. Reference .js in HTML so it works on both Vite (dev) and static servers (production). Signed-off-by: JD Lien --- demo/form-field.js | 328 +++++++++++++++++++++++++++++++++++++++++++++ demo/index.html | 2 +- 2 files changed, 329 insertions(+), 1 deletion(-) create mode 100644 demo/form-field.js diff --git a/demo/form-field.js b/demo/form-field.js new file mode 100644 index 0000000..47f0c9c --- /dev/null +++ b/demo/form-field.js @@ -0,0 +1,328 @@ +/** + * FormField Custom Element + * A reusable form field component that reduces boilerplate for forms using @jdlien/validator + * + * Usage: + * + * + * + */ +// DOM helper - our mini jQuery +function el(tag, attrs = {}, children = []) { + const element = document.createElement(tag); + for (const [key, value] of Object.entries(attrs)) { + if (value === undefined || value === false) + continue; + if (value === true) { + element.setAttribute(key, ''); + } + else if (key === 'className') { + element.className = value; + } + else if (key.startsWith('data-')) { + element.setAttribute(key, value); + } + else { + element.setAttribute(key, value); + } + } + for (const child of children) { + if (child === null || child === undefined) + continue; + if (typeof child === 'string') { + element.appendChild(document.createTextNode(child)); + } + else { + element.appendChild(child); + } + } + return element; +} +// Helper to create element with innerHTML (for hint text with HTML) +function elHtml(tag, attrs = {}, html) { + const element = el(tag, attrs); + element.innerHTML = html; + return element; +} +// Attribute mappings +const ATTR_MAP = { + required: 'required', + disabled: 'disabled', + placeholder: 'placeholder', + value: 'value', + minlength: 'minlength', + maxlength: 'maxlength', + pattern: 'pattern', + min: 'data-min', + max: 'data-max', + 'min-length': 'data-min-length', + 'max-length': 'data-max-length', + 'date-format': 'data-date-format', + 'date-range': 'data-date-range', + 'time-format': 'data-time-format', + 'error-msg': 'data-error-default', +}; +const DATA_TYPE_MAP = { + integer: 'integer', + number: 'number', + date: 'date', + datetime: 'datetime', + time: 'time', + email: 'email', + tel: 'tel', + zip: 'zip', + postal: 'postal', + color: 'color', +}; +const INPUT_TYPE_MAP = { + integer: 'text', + number: 'text', + date: 'text', + datetime: 'text', + time: 'text', + zip: 'text', + postal: 'text', + color: 'text', +}; +const INPUT_MODE_MAP = { + email: 'email', + tel: 'tel', + url: 'url', + number: 'decimal', + integer: 'numeric', +}; +class FormField extends HTMLElement { + static get observedAttributes() { + return ['value', 'disabled']; + } + connectedCallback() { + this.render(); + } + attributeChangedCallback(name, _oldValue, newValue) { + if (!this.isConnected) + return; + const input = this.querySelector('input, select, textarea'); + if (!input) + return; + if (name === 'value') { + input.value = newValue || ''; + if (this.getAttribute('type') === 'color') { + this.updateColorSwatch(newValue || ''); + } + } + else if (name === 'disabled') { + ; + input.disabled = newValue !== null; + } + } + render() { + const type = (this.getAttribute('type') || 'text'); + const name = this.getAttribute('name') || ''; + const id = this.getAttribute('id') || name; + // Remove id from custom element to avoid duplicates + this.removeAttribute('id'); + if (type === 'textarea') { + this.renderTextarea(id, name); + } + else if (type === 'select') { + this.renderSelect(id, name); + } + else if ((type === 'radio' || type === 'checkbox') && this.hasAttribute('options')) { + this.renderGroup(id, name, type); + } + else if (type === 'color') { + this.renderColorInput(id, name); + } + else { + this.renderInput(id, name, type); + } + } + parseOptions() { + const optionsAttr = this.getAttribute('options'); + if (!optionsAttr) + return []; + if (optionsAttr.startsWith('[')) { + try { + return JSON.parse(optionsAttr); + } + catch { + console.warn('Failed to parse options as JSON'); + } + } + return optionsAttr.split(',').map((opt) => { + const trimmed = opt.trim(); + return { value: trimmed, label: trimmed }; + }); + } + getValidationAttrs() { + const attrs = {}; + for (const [formFieldAttr, inputAttr] of Object.entries(ATTR_MAP)) { + if (this.hasAttribute(formFieldAttr)) { + const val = this.getAttribute(formFieldAttr); + if (formFieldAttr === 'required' || formFieldAttr === 'disabled') { + attrs[inputAttr] = true; + } + else { + attrs[inputAttr] = val || undefined; + } + } + } + // Copy data-* attributes directly + for (const attr of Array.from(this.attributes)) { + if (attr.name.startsWith('data-') && !Object.values(ATTR_MAP).includes(attr.name)) { + attrs[attr.name] = attr.value; + } + } + return attrs; + } + buildWrapper(id, labelText, content) { + const hint = this.getAttribute('hint'); + return el('div', { className: 'sm:grid sm:grid-cols-3 sm:items-start sm:gap-4' }, [ + el('label', { for: id, id: `${id}-label`, className: 'block font-medium sm:mt-px sm:pt-1' }, [ + labelText, + ]), + el('div', { className: 'my-0.5 sm:mt-0 sm:col-span-2' }, [ + content, + hint ? elHtml('p', { className: 'mt-1 text-sm text-gray-500 dark:text-gray-400' }, hint) : null, + el('div', { style: 'min-height: 20px' }, [ + el('div', { + className: 'error hidden text-sm text-red-600 transition dark:text-red-500', + id: `${id}-error`, + }), + ]), + ]), + ]); + } + renderInput(id, name, type) { + const label = this.getAttribute('label') || name; + const inputType = INPUT_TYPE_MAP[type] || type; + const dataType = DATA_TYPE_MAP[type]; + const inputMode = INPUT_MODE_MAP[type]; + const validationAttrs = this.getValidationAttrs(); + const flatpickrClass = ['date', 'datetime', 'time'].includes(type) ? ' flatpickr-input' : ''; + const input = el('input', { + className: `block w-full px-1.5 transition${flatpickrClass}`, + type: inputType, + id, + name, + 'aria-describedby': `${id}-error`, + ...(dataType && !this.hasAttribute('data-type') ? { 'data-type': dataType } : {}), + ...(inputMode ? { inputmode: inputMode } : {}), + ...validationAttrs, + }); + const wrapper = el('div', { className: 'flex relative sm:max-w-sm' }, [input]); + this.appendChild(this.buildWrapper(id, label, wrapper)); + } + renderColorInput(id, name) { + const label = this.getAttribute('label') || name; + const value = this.getAttribute('value') || '#888888'; + const validationAttrs = this.getValidationAttrs(); + const textInput = el('input', { + className: 'block w-full px-1.5 transition rounded-r-none', + type: 'text', + id, + name, + 'data-type': 'color', + value, + 'aria-describedby': `${id}-error`, + ...validationAttrs, + }); + const colorInput = el('input', { + type: 'color', + id: `${id}-color`, + className: 'invisible w-full h-full', + value, + }); + const colorLabel = el('label', { + id: `${id}-color-label`, + for: `${id}-color`, + className: 'border border-l-0 border-gray-350 dark:border-gray-500 cursor-pointer w-20 rounded-r', + style: `background-color: ${value}`, + }, [colorInput]); + const wrapper = el('div', { className: 'flex relative sm:max-w-sm' }, [textInput, colorLabel]); + this.appendChild(this.buildWrapper(id, label, wrapper)); + // Set up color sync with direct references (no querying needed!) + colorInput.addEventListener('input', () => { + textInput.value = colorInput.value; + colorLabel.style.backgroundColor = colorInput.value; + textInput.dispatchEvent(new Event('input', { bubbles: true })); + }); + textInput.addEventListener('blur', () => { + if (/^#[0-9A-Fa-f]{6}$/.test(textInput.value)) { + colorInput.value = textInput.value; + colorLabel.style.backgroundColor = textInput.value; + } + }); + } + updateColorSwatch(value) { + const colorLabel = this.querySelector('[id$="-color-label"]'); + const colorInput = this.querySelector('[type="color"]'); + if (colorLabel && /^#[0-9A-Fa-f]{6}$/.test(value)) { + colorLabel.style.backgroundColor = value; + if (colorInput) + colorInput.value = value; + } + } + renderSelect(id, name) { + const label = this.getAttribute('label') || name; + const options = this.parseOptions(); + const validationAttrs = this.getValidationAttrs(); + const select = el('select', { + className: 'block w-full sm:max-w-sm', + id, + name, + 'aria-describedby': `${id}-error`, + ...validationAttrs, + }, [ + el('option', { value: '' }), + ...options.map((opt) => el('option', { value: opt.value }, [opt.label])), + ]); + this.appendChild(this.buildWrapper(id, label, select)); + } + renderGroup(id, name, type) { + const label = this.getAttribute('label') || name; + const options = this.parseOptions(); + const errorMsg = this.getAttribute('error-msg') || ''; + const isRequired = this.hasAttribute('required'); + const roundedClass = type === 'checkbox' ? '' : ' rounded-full'; + const optionElements = options.map((opt, index) => { + const optId = `${id}-${index + 1}-${opt.value.toLowerCase().replace(/\s+/g, '-')}`; + return el('div', { className: 'pt-2' }, [ + el('div', { className: 'flex items-start h-5' }, [ + el('label', { className: `checked-border${roundedClass}`, for: optId }, [ + el('input', { + id: optId, + name, + type, + value: opt.value, + className: 'block transition', + 'aria-describedby': `${id}-error`, + ...(isRequired ? { required: true } : {}), + ...(errorMsg ? { 'data-error-default': errorMsg } : {}), + }), + el('span', { className: 'checked-label' }, [opt.label]), + ]), + ]), + ]); + }); + const wrapper = el('div', { className: 'flex relative sm:max-w-sm' }, [ + el('div', { className: 'mb-3 sm:col-span-2 space-y-3' }, optionElements), + ]); + this.appendChild(this.buildWrapper(id, label, wrapper)); + } + renderTextarea(id, name) { + const label = this.getAttribute('label') || name; + const placeholder = this.getAttribute('placeholder') || ''; + const validationAttrs = this.getValidationAttrs(); + const textarea = el('textarea', { + id, + name, + className: 'w-full', + 'aria-describedby': `${id}-error`, + ...(placeholder ? { placeholder } : {}), + ...validationAttrs, + }); + this.appendChild(this.buildWrapper(id, label, textarea)); + } +} +customElements.define('form-field', FormField); diff --git a/demo/index.html b/demo/index.html index 68314d0..ee6d73f 100644 --- a/demo/index.html +++ b/demo/index.html @@ -5,7 +5,7 @@ Validator Demo - + From 7858e5f369a9eb9cfe735a606632805e3cec04a1 Mon Sep 17 00:00:00 2001 From: JD Lien Date: Fri, 23 Jan 2026 16:02:09 -0700 Subject: [PATCH 11/54] Fix checked-border/checked-label to use regular classes instead of @utility Tailwind v4 @utility classes only get included if detected in scanned content. Since these classes are generated dynamically in JS, they weren't being found. Changed to regular class definitions so they're always included. Signed-off-by: JD Lien --- demo/demo-src.css | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/demo/demo-src.css b/demo/demo-src.css index 6ec5f34..daa199e 100644 --- a/demo/demo-src.css +++ b/demo/demo-src.css @@ -47,8 +47,8 @@ @apply hover:bg-white dark:hover:bg-zinc-800; } - [type='checkbox'], - [type='radio'] { + input[type='checkbox'], + input[type='radio'] { @apply border border-zinc-300 bg-zinc-50 text-blue-600 shadow-sm checked:border checked:border-zinc-400 checked:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-400/50 focus:ring-offset-0 focus:ring-offset-transparent focus:checked:bg-blue-500; @@ -61,13 +61,13 @@ @apply disabled:checked:hover:border-zinc-400 disabled:dark:checked:bg-blue-600 dark:disabled:checked:hover:border-zinc-500; } - [type='checkbox']:not([disabled]), - [type='radio']:not([disabled]) { + input[type='checkbox']:not([disabled]), + input[type='radio']:not([disabled]) { @apply hover:bg-zinc-200 checked:hover:bg-blue-500 dark:hover:bg-zinc-300 dark:hover:checked:bg-blue-400 dark:hover:focus:checked:bg-blue-400; } - [type='radio'] { + input[type='radio'] { @apply rounded-full; } @@ -81,7 +81,7 @@ } /* Component utilities using Tailwind v4 @utility API */ -@utility checked-label { +.checked-label { @apply ml-1.5 mr-1; } @@ -92,7 +92,7 @@ input:checked + .checked-label { } /* Apply checked-border to labels for checkboxes and radio buttons for a label that colors up automatically when checked */ -@utility checked-border { +.checked-border { @apply inline-flex items-center border border-zinc-300/30 bg-zinc-50 px-1 text-zinc-700 shadow-inner; @apply hover:bg-white dark:border-zinc-500/10 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800 dark:focus:bg-zinc-800; } From 4d9a668a8d37b0a3f6c8e99256f9e6cc50e84489 Mon Sep 17 00:00:00 2001 From: JD Lien Date: Fri, 23 Jan 2026 16:02:34 -0700 Subject: [PATCH 12/54] Add build:demo script to compile form-field.ts and CSS Signed-off-by: JD Lien --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 65197fc..7e1cc10 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "preview": "vite preview", "test": "vitest", "coverage": "vitest --coverage", - "build:css": "npx @tailwindcss/cli -i demo/demo-src.css -o demo/demo.css" + "build:css": "npx @tailwindcss/cli -i demo/demo-src.css -o demo/demo.css", + "build:demo": "tsc demo/form-field.ts --outDir demo --target ES2020 --module ES2020 && pnpm build:css" }, "repository": { "type": "git", From df71d0b396ea912463a11a642a7ded466ac64854 Mon Sep 17 00:00:00 2001 From: JD Lien Date: Fri, 23 Jan 2026 16:09:14 -0700 Subject: [PATCH 13/54] Fix radio button focus ring and update demo theme to indigo - Use circular outline instead of box-shadow ring for radio button focus - Update demo color scheme from gray to indigo - Add undici-types dependency and --skipLibCheck to fix TypeScript errors Signed-off-by: JD Lien --- demo/demo-src.css | 7 ++++++ demo/index.html | 60 ++++++++++++++++++----------------------------- package.json | 3 ++- pnpm-lock.yaml | 8 +++++++ 4 files changed, 40 insertions(+), 38 deletions(-) diff --git a/demo/demo-src.css b/demo/demo-src.css index daa199e..655ddfd 100644 --- a/demo/demo-src.css +++ b/demo/demo-src.css @@ -69,6 +69,13 @@ input[type='radio'] { @apply rounded-full; + /* Override ring with circular outline for radio buttons */ + &:focus { + --tw-ring-shadow: none; + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + outline: 2px solid oklch(70.7% 0.165 254.624 / 0.5); + outline-offset: 1px; + } } select { diff --git a/demo/index.html b/demo/index.html index ee6d73f..6ad8dbd 100644 --- a/demo/index.html +++ b/demo/index.html @@ -1,4 +1,4 @@ - + @@ -8,9 +8,11 @@ - -
-

Validator Demo

+ +
+

+ Validator Demo +

Validator has been applied to this form to help sanitize and validate user input.
@@ -35,7 +37,7 @@

-

+

Fields can be required, have a minimum or maximum length, or a pattern.
Numeric inputs won't even allow invalid values to be typed in.

@@ -67,19 +69,9 @@

hint="A letter followed by numbers (^[a-zA-Z]\d+$)" > - + - + hint="Value must be between 0 and 100 (uses data-min and data-max)" > -

- Dates can be entered by users in almost any format, and will be converted - to the format specified in the data-date-format attribute.
- Dates can also be constrained to past or future dates.
+

+ Dates can be entered by users in almost any format, and will be converted to the format + specified in the data-date-format attribute.
+ Dates can also be constrained to past or future dates.
These features are compatible with datepickers like - Flatpickr. + >Flatpickr.

placeholder="____@____.___" > - + - + > -

Selects can be required as well.

+

Selects can be required as well.

options="red,green,blue" > -

+

You can require groups of radio buttons or checkboxes so one must be selected.

@@ -252,7 +238,7 @@

options="red,green,blue" > -

+

Textarea elements can have min and max lengths as well as be required.

@@ -284,7 +270,7 @@

await customElements.whenDefined('form-field') const form = document.getElementById('demo-form') // scrollToError: true will scroll to and focus the first invalid input on validation failure - const validator = new Validator(form, {debug: true, scrollToError: true}) + const validator = new Validator(form, { debug: true, scrollToError: true }) window.validator = validator // for debugging

diff --git a/package.json b/package.json index 7e1cc10..4a6889f 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "test": "vitest", "coverage": "vitest --coverage", "build:css": "npx @tailwindcss/cli -i demo/demo-src.css -o demo/demo.css", - "build:demo": "tsc demo/form-field.ts --outDir demo --target ES2020 --module ES2020 && pnpm build:css" + "build:demo": "tsc demo/form-field.ts --outDir demo --target ES2020 --module ES2020 --skipLibCheck && pnpm build:css" }, "repository": { "type": "git", @@ -55,6 +55,7 @@ "prettier": "^3.8.1", "tailwindcss": "^4.1.18", "typescript": "^5.9.3", + "undici-types": "^7.19.0", "vite": "^7.3.1", "vitest": "^4.0.18" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d0e044a..7a0f781 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,9 @@ importers: typescript: specifier: ^5.9.3 version: 5.9.3 + undici-types: + specifier: ^7.19.0 + version: 7.19.0 vite: specifier: ^7.3.1 version: 7.3.1(@types/node@25.0.10) @@ -855,6 +858,9 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici-types@7.19.0: + resolution: {integrity: sha512-Rjk2OWDZf2eiXVQjY2HyE3XPjqW/wXnSZq0QkOsPKZEnaetNNBObTp91LYfGdB8hRbRZk4HFcM/cONw452B0AQ==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -1704,6 +1710,8 @@ snapshots: undici-types@7.16.0: {} + undici-types@7.19.0: {} + util-deprecate@1.0.2: {} vite@7.3.1(@types/node@25.0.10): From c60c138500a7122529c0e04c037748460147e020 Mon Sep 17 00:00:00 2001 From: JD Lien Date: Fri, 23 Jan 2026 16:12:17 -0700 Subject: [PATCH 14/54] Use local tailwindcss CLI instead of npx to avoid npm warnings Signed-off-by: JD Lien --- package.json | 3 +- pnpm-lock.yaml | 510 +++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 501 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index 4a6889f..037df94 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "preview": "vite preview", "test": "vitest", "coverage": "vitest --coverage", - "build:css": "npx @tailwindcss/cli -i demo/demo-src.css -o demo/demo.css", + "build:css": "tailwindcss -i demo/demo-src.css -o demo/demo.css", "build:demo": "tsc demo/form-field.ts --outDir demo --target ES2020 --module ES2020 --skipLibCheck && pnpm build:css" }, "repository": { @@ -47,6 +47,7 @@ "homepage": "https://github.com/jdlien/validator#readme", "devDependencies": { "@sheerun/mutationobserver-shim": "^0.3.3", + "@tailwindcss/cli": "^4.1.18", "@types/jsdom": "^27.0.0", "@vitest/coverage-v8": "^4.0.18", "canvas": "^3.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a0f781..2abbf19 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,12 +15,15 @@ importers: '@sheerun/mutationobserver-shim': specifier: ^0.3.3 version: 0.3.3 + '@tailwindcss/cli': + specifier: ^4.1.18 + version: 4.1.18 '@types/jsdom': specifier: ^27.0.0 version: 27.0.0 '@vitest/coverage-v8': specifier: ^4.0.18 - version: 4.0.18(vitest@4.0.18(@types/node@25.0.10)(jsdom@27.4.0(canvas@3.2.1))) + version: 4.0.18(vitest@4.0.18(@types/node@25.0.10)(jiti@2.6.1)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.30.2)) canvas: specifier: ^3.2.1 version: 3.2.1 @@ -44,10 +47,10 @@ importers: version: 7.19.0 vite: specifier: ^7.3.1 - version: 7.3.1(@types/node@25.0.10) + version: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2) vitest: specifier: ^4.0.18 - version: 4.0.18(@types/node@25.0.10)(jsdom@27.4.0(canvas@3.2.1)) + version: 4.0.18(@types/node@25.0.10)(jiti@2.6.1)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.30.2) packages: @@ -284,6 +287,12 @@ packages: '@jdlien/validator-utils@2.0.0': resolution: {integrity: sha512-wrO5z1Sb4oMYYLOm0XWemJf7t+zAoxBVbRSEiNtAs0aBj61tnJfb9E/oGE3xp2K4fKMxBdOc8OEi0UaTeWOuHw==} + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -294,6 +303,88 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@parcel/watcher-android-arm64@2.5.6': + resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.5.6': + resolution: {integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.5.6': + resolution: {integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.5.6': + resolution: {integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.5.6': + resolution: {integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm-musl@2.5.6': + resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm64-glibc@2.5.6': + resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-arm64-musl@2.5.6': + resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-x64-glibc@2.5.6': + resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-linux-x64-musl@2.5.6': + resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-win32-arm64@2.5.6': + resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-ia32@2.5.6': + resolution: {integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==} + engines: {node: '>= 10.0.0'} + cpu: [ia32] + os: [win32] + + '@parcel/watcher-win32-x64@2.5.6': + resolution: {integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.5.6': + resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} + engines: {node: '>= 10.0.0'} + '@rollup/rollup-android-arm-eabi@4.56.0': resolution: {integrity: sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==} cpu: [arm] @@ -425,6 +516,95 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tailwindcss/cli@4.1.18': + resolution: {integrity: sha512-sMZ+lZbDyxwjD2E0L7oRUjJ01Ffjtme5OtjvvnC+cV4CEDcbqzbp25TCpxHj6kWLU9+DlqJOiNgSOgctC2aZmg==} + hasBin: true + + '@tailwindcss/node@4.1.18': + resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} + + '@tailwindcss/oxide-android-arm64@4.1.18': + resolution: {integrity: sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.1.18': + resolution: {integrity: sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.1.18': + resolution: {integrity: sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.1.18': + resolution: {integrity: sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': + resolution: {integrity: sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': + resolution: {integrity: sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.1.18': + resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.1.18': + resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.1.18': + resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.1.18': + resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': + resolution: {integrity: sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.1.18': + resolution: {integrity: sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.1.18': + resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==} + engines: {node: '>= 10'} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -554,6 +734,10 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enhanced-resolve@5.18.4: + resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} + engines: {node: '>=10.13.0'} + entities@6.0.1: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} @@ -597,6 +781,9 @@ packages: github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -625,6 +812,14 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} @@ -640,6 +835,10 @@ packages: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} @@ -657,6 +856,76 @@ packages: canvas: optional: true + lightningcss-android-arm64@1.30.2: + resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.30.2: + resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.30.2: + resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.30.2: + resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.30.2: + resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.30.2: + resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.30.2: + resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.30.2: + resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.30.2: + resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.30.2: + resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.30.2: + resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.30.2: + resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} + engines: {node: '>= 12.0.0'} + lru-cache@11.2.4: resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} engines: {node: 20 || >=22} @@ -684,6 +953,10 @@ packages: mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -810,6 +1083,10 @@ packages: tailwindcss@4.1.18: resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} + tapable@2.3.0: + resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + engines: {node: '>=6'} + tar-fs@2.1.4: resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} @@ -1126,6 +1403,16 @@ snapshots: '@jdlien/validator-utils@2.0.0': {} + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} @@ -1135,6 +1422,66 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@parcel/watcher-android-arm64@2.5.6': + optional: true + + '@parcel/watcher-darwin-arm64@2.5.6': + optional: true + + '@parcel/watcher-darwin-x64@2.5.6': + optional: true + + '@parcel/watcher-freebsd-x64@2.5.6': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-arm-musl@2.5.6': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.5.6': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-x64-musl@2.5.6': + optional: true + + '@parcel/watcher-win32-arm64@2.5.6': + optional: true + + '@parcel/watcher-win32-ia32@2.5.6': + optional: true + + '@parcel/watcher-win32-x64@2.5.6': + optional: true + + '@parcel/watcher@2.5.6': + dependencies: + detect-libc: 2.1.2 + is-glob: 4.0.3 + node-addon-api: 7.1.1 + picomatch: 4.0.3 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.5.6 + '@parcel/watcher-darwin-arm64': 2.5.6 + '@parcel/watcher-darwin-x64': 2.5.6 + '@parcel/watcher-freebsd-x64': 2.5.6 + '@parcel/watcher-linux-arm-glibc': 2.5.6 + '@parcel/watcher-linux-arm-musl': 2.5.6 + '@parcel/watcher-linux-arm64-glibc': 2.5.6 + '@parcel/watcher-linux-arm64-musl': 2.5.6 + '@parcel/watcher-linux-x64-glibc': 2.5.6 + '@parcel/watcher-linux-x64-musl': 2.5.6 + '@parcel/watcher-win32-arm64': 2.5.6 + '@parcel/watcher-win32-ia32': 2.5.6 + '@parcel/watcher-win32-x64': 2.5.6 + '@rollup/rollup-android-arm-eabi@4.56.0': optional: true @@ -1214,6 +1561,77 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@tailwindcss/cli@4.1.18': + dependencies: + '@parcel/watcher': 2.5.6 + '@tailwindcss/node': 4.1.18 + '@tailwindcss/oxide': 4.1.18 + enhanced-resolve: 5.18.4 + mri: 1.2.0 + picocolors: 1.1.1 + tailwindcss: 4.1.18 + + '@tailwindcss/node@4.1.18': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.18.4 + jiti: 2.6.1 + lightningcss: 1.30.2 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.1.18 + + '@tailwindcss/oxide-android-arm64@4.1.18': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.1.18': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.1.18': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.1.18': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.1.18': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.1.18': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.1.18': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.1.18': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.1.18': + optional: true + + '@tailwindcss/oxide@4.1.18': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.1.18 + '@tailwindcss/oxide-darwin-arm64': 4.1.18 + '@tailwindcss/oxide-darwin-x64': 4.1.18 + '@tailwindcss/oxide-freebsd-x64': 4.1.18 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.18 + '@tailwindcss/oxide-linux-arm64-gnu': 4.1.18 + '@tailwindcss/oxide-linux-arm64-musl': 4.1.18 + '@tailwindcss/oxide-linux-x64-gnu': 4.1.18 + '@tailwindcss/oxide-linux-x64-musl': 4.1.18 + '@tailwindcss/oxide-wasm32-wasi': 4.1.18 + '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 + '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -1235,7 +1653,7 @@ snapshots: '@types/tough-cookie@4.0.5': {} - '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@25.0.10)(jsdom@27.4.0(canvas@3.2.1)))': + '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@25.0.10)(jiti@2.6.1)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.30.2))': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.0.18 @@ -1247,7 +1665,7 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.18(@types/node@25.0.10)(jsdom@27.4.0(canvas@3.2.1)) + vitest: 4.0.18(@types/node@25.0.10)(jiti@2.6.1)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.30.2) '@vitest/expect@4.0.18': dependencies: @@ -1258,13 +1676,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.0.10))': + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@25.0.10) + vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2) '@vitest/pretty-format@4.0.18': dependencies: @@ -1359,6 +1777,11 @@ snapshots: dependencies: once: 1.4.0 + enhanced-resolve@5.18.4: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.0 + entities@6.0.1: {} es-module-lexer@1.7.0: {} @@ -1411,6 +1834,8 @@ snapshots: github-from-package@0.0.0: {} + graceful-fs@4.2.11: {} + has-flag@4.0.0: {} html-encoding-sniffer@6.0.0: @@ -1441,6 +1866,12 @@ snapshots: ini@1.3.8: {} + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + is-potential-custom-element-name@1.0.1: {} istanbul-lib-coverage@3.2.2: {} @@ -1456,6 +1887,8 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 + jiti@2.6.1: {} + js-tokens@9.0.1: {} jsdom-global@3.0.2(jsdom@27.4.0(canvas@3.2.1)): @@ -1492,6 +1925,55 @@ snapshots: - supports-color - utf-8-validate + lightningcss-android-arm64@1.30.2: + optional: true + + lightningcss-darwin-arm64@1.30.2: + optional: true + + lightningcss-darwin-x64@1.30.2: + optional: true + + lightningcss-freebsd-x64@1.30.2: + optional: true + + lightningcss-linux-arm-gnueabihf@1.30.2: + optional: true + + lightningcss-linux-arm64-gnu@1.30.2: + optional: true + + lightningcss-linux-arm64-musl@1.30.2: + optional: true + + lightningcss-linux-x64-gnu@1.30.2: + optional: true + + lightningcss-linux-x64-musl@1.30.2: + optional: true + + lightningcss-win32-arm64-msvc@1.30.2: + optional: true + + lightningcss-win32-x64-msvc@1.30.2: + optional: true + + lightningcss@1.30.2: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.30.2 + lightningcss-darwin-arm64: 1.30.2 + lightningcss-darwin-x64: 1.30.2 + lightningcss-freebsd-x64: 1.30.2 + lightningcss-linux-arm-gnueabihf: 1.30.2 + lightningcss-linux-arm64-gnu: 1.30.2 + lightningcss-linux-arm64-musl: 1.30.2 + lightningcss-linux-x64-gnu: 1.30.2 + lightningcss-linux-x64-musl: 1.30.2 + lightningcss-win32-arm64-msvc: 1.30.2 + lightningcss-win32-x64-msvc: 1.30.2 + lru-cache@11.2.4: {} magic-string@0.30.21: @@ -1516,6 +1998,8 @@ snapshots: mkdirp-classic@0.5.3: {} + mri@1.2.0: {} + ms@2.1.3: {} nanoid@3.3.11: {} @@ -1662,6 +2146,8 @@ snapshots: tailwindcss@4.1.18: {} + tapable@2.3.0: {} + tar-fs@2.1.4: dependencies: chownr: 1.1.4 @@ -1714,7 +2200,7 @@ snapshots: util-deprecate@1.0.2: {} - vite@7.3.1(@types/node@25.0.10): + vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2): dependencies: esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.3) @@ -1725,11 +2211,13 @@ snapshots: optionalDependencies: '@types/node': 25.0.10 fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.2 - vitest@4.0.18(@types/node@25.0.10)(jsdom@27.4.0(canvas@3.2.1)): + vitest@4.0.18(@types/node@25.0.10)(jiti@2.6.1)(jsdom@27.4.0(canvas@3.2.1))(lightningcss@1.30.2): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.0.10)) + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -1746,7 +2234,7 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.1(@types/node@25.0.10) + vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.0.10 From 90e6864de2b4ead7460d859d7f59d0d58ef1d4c2 Mon Sep 17 00:00:00 2001 From: JD Lien Date: Fri, 23 Jan 2026 16:18:45 -0700 Subject: [PATCH 15/54] Add watch:css script for development Signed-off-by: JD Lien --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 037df94..4828ec0 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "test": "vitest", "coverage": "vitest --coverage", "build:css": "tailwindcss -i demo/demo-src.css -o demo/demo.css", - "build:demo": "tsc demo/form-field.ts --outDir demo --target ES2020 --module ES2020 --skipLibCheck && pnpm build:css" + "build:demo": "tsc demo/form-field.ts --outDir demo --target ES2020 --module ES2020 --skipLibCheck && pnpm build:css", + "watch:css": "tailwindcss -i demo/demo-src.css -o demo/demo.css --watch" }, "repository": { "type": "git", From 9898a1b0e7e2180c8147e2431b1efd5c51dcef65 Mon Sep 17 00:00:00 2001 From: JD Lien Date: Fri, 23 Jan 2026 16:24:11 -0700 Subject: [PATCH 16/54] Fix radio focus ring to use box-shadow (follows border-radius unlike outline) Signed-off-by: JD Lien --- demo/demo-src.css | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/demo/demo-src.css b/demo/demo-src.css index 655ddfd..a6f060b 100644 --- a/demo/demo-src.css +++ b/demo/demo-src.css @@ -19,7 +19,7 @@ select { @apply border border-zinc-300 rounded bg-zinc-50 px-2 py-1.5 text-zinc-700 placeholder-zinc-500/50 shadow-inner - focus:bg-white focus:outline-none focus:ring-2 focus:ring-blue-400/50 + focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-400/50 focus:ring-offset-0 focus:ring-offset-transparent; @apply dark:border-zinc-500 dark:bg-zinc-900 dark:text-white dark:focus:bg-zinc-800; @@ -49,32 +49,30 @@ input[type='checkbox'], input[type='radio'] { - @apply border border-zinc-300 bg-zinc-50 text-blue-600 shadow-sm - checked:border checked:border-zinc-400 checked:bg-blue-600 focus:outline-none - focus:ring-2 focus:ring-blue-400/50 focus:ring-offset-0 focus:ring-offset-transparent focus:checked:bg-blue-500; + @apply border border-zinc-300 bg-zinc-50 text-indigo-600 shadow-sm + checked:border checked:border-zinc-400 checked:bg-indigo-600 focus:outline-none + focus:ring-2 focus:ring-indigo-400/50 focus:ring-offset-0 focus:ring-offset-transparent focus:checked:bg-indigo-500; - @apply dark:border-zinc-500 dark:bg-zinc-400 dark:checked:bg-blue-600 dark:focus:checked:bg-blue-400; + @apply dark:border-zinc-500 dark:bg-zinc-400 dark:checked:bg-indigo-600 dark:focus:checked:bg-indigo-400; - @apply disabled:opacity-60 disabled:checked:hover:bg-blue-600; + @apply disabled:opacity-60 disabled:checked:hover:bg-indigo-600; /* Prevents border change when disabled and hovered */ - @apply disabled:checked:hover:border-zinc-400 disabled:dark:checked:bg-blue-600 dark:disabled:checked:hover:border-zinc-500; + @apply disabled:checked:hover:border-zinc-400 disabled:dark:checked:bg-indigo-600 dark:disabled:checked:hover:border-zinc-500; } input[type='checkbox']:not([disabled]), input[type='radio']:not([disabled]) { - @apply hover:bg-zinc-200 checked:hover:bg-blue-500 - dark:hover:bg-zinc-300 dark:hover:checked:bg-blue-400 dark:hover:focus:checked:bg-blue-400; + @apply hover:bg-zinc-200 checked:hover:bg-indigo-500 + dark:hover:bg-zinc-300 dark:hover:checked:bg-indigo-400 dark:hover:focus:checked:bg-indigo-400; } input[type='radio'] { @apply rounded-full; - /* Override ring with circular outline for radio buttons */ + /* Use circular box-shadow for focus ring (box-shadow follows border-radius, outline doesn't) */ &:focus { - --tw-ring-shadow: none; - box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); - outline: 2px solid oklch(70.7% 0.165 254.624 / 0.5); - outline-offset: 1px; + outline: none; + box-shadow: 0 0 0 3px oklch(70.7% 0.165 254.624 / 0.5); } } @@ -105,5 +103,5 @@ input:checked + .checked-label { } .checked-border:has([type='checkbox']:checked, [type='radio']:checked) { - @apply border-blue-600 text-black hover:border-blue-500 dark:text-white; + @apply border-indigo-600 text-black hover:border-indigo-500 dark:text-white; } From bbce65befd1421932e5cf6974db806a768b6132a Mon Sep 17 00:00:00 2001 From: JD Lien Date: Fri, 23 Jan 2026 17:25:51 -0700 Subject: [PATCH 17/54] Add scrollToErrorDelay option and improve demo form styling - Add scrollToErrorDelay option to delay scroll after validation errors - Fix smooth scrolling by using focus({ preventScroll: true }) - Add animated error message transitions using opacity/max-height - Move focus ring to parent label for checkbox/radio groups (focus-within) - Add gradient backgrounds to select elements with chevron - Make form-field custom element display: block for space-y support - Add tests for scrollToErrorDelay and preventScroll behavior Signed-off-by: JD Lien --- demo/demo-src.css | 67 +++++++++++++++++++++------ demo/form-field.js | 6 ++- demo/form-field.ts | 10 ++-- demo/index.html | 35 ++++++++------ src/Validator.ts | 16 ++++++- tests/Validator.scrollToError.test.ts | 51 ++++++++++++++++++++ 6 files changed, 150 insertions(+), 35 deletions(-) diff --git a/demo/demo-src.css b/demo/demo-src.css index a6f060b..82a40e7 100644 --- a/demo/demo-src.css +++ b/demo/demo-src.css @@ -1,5 +1,10 @@ @import 'tailwindcss'; +/* Custom element needs block display for space-y to work */ +form-field { + display: block; +} + @layer base { input[type='text'], input[type='email'], @@ -17,12 +22,12 @@ select[multiple], textarea, select { - @apply border border-zinc-300 rounded bg-zinc-50 px-2 py-1.5 text-zinc-700 + @apply border border-zinc-300 rounded bg-zinc-50 dark:bg-indigo-900 px-2 py-1.5 text-zinc-700 placeholder-zinc-500/50 shadow-inner focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-400/50 - focus:ring-offset-0 focus:ring-offset-transparent; + focus:ring-offset-0 focus:ring-offset-transparent inset-shadow-sm; - @apply dark:border-zinc-500 dark:bg-zinc-900 dark:text-white dark:focus:bg-zinc-800; + @apply bg-linear-to-b dark:from-black/85 dark:to-black/75 dark:border-zinc-700/60 dark:text-white dark:focus:bg-zinc-800; @apply disabled:bg-zinc-200 disabled:text-zinc-500 dark:disabled:bg-zinc-800 dark:disabled:text-zinc-300; } @@ -69,19 +74,29 @@ input[type='radio'] { @apply rounded-full; - /* Use circular box-shadow for focus ring (box-shadow follows border-radius, outline doesn't) */ - &:focus { - outline: none; - box-shadow: 0 0 0 3px oklch(70.7% 0.165 254.624 / 0.5); - } } select { @apply pr-6 appearance-none; - background-image: url('data:image/svg+xml,%3Csvg xmlns=%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22 fill=%22none%22 viewBox=%220 0 24 24%22 stroke-width=%221.5%22 stroke=%22rgb(120 113 108)%22%3E%3Cpath stroke-linecap=%22round%22 stroke-linejoin=%22round%22 d=%22M19.5 8.25l-7.5 7.5-7.5-7.5%22 %2F%3E%3C%2Fsvg%3E'); - background-size: 1.2rem; - background-position: right 0.4rem center; - background-repeat: no-repeat; + /* Chevron + gradient as multiple backgrounds (chevron on top) */ + background-image: + url('data:image/svg+xml,%3Csvg xmlns=%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22 fill=%22none%22 viewBox=%220 0 24 24%22 stroke-width=%221.5%22 stroke=%22rgb(120 113 108)%22%3E%3Cpath stroke-linecap=%22round%22 stroke-linejoin=%22round%22 d=%22M19.5 8.25l-7.5 7.5-7.5-7.5%22 %2F%3E%3C%2Fsvg%3E'), + linear-gradient(to bottom, var(--color-zinc-50), var(--color-zinc-100)); + background-size: + 1.2rem, + 100% 100%; + background-position: + right 0.4rem center, + center; + background-repeat: no-repeat, no-repeat; + } + + @media (prefers-color-scheme: dark) { + select { + background-image: + url('data:image/svg+xml,%3Csvg xmlns=%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22 fill=%22none%22 viewBox=%220 0 24 24%22 stroke-width=%221.5%22 stroke=%22rgb(161 161 170)%22%3E%3Cpath stroke-linecap=%22round%22 stroke-linejoin=%22round%22 d=%22M19.5 8.25l-7.5 7.5-7.5-7.5%22 %2F%3E%3C%2Fsvg%3E'), + linear-gradient(to bottom, rgba(0, 0, 0, 0.85), rgba(0, 0, 0, 0.75)); + } } } @@ -98,10 +113,34 @@ input:checked + .checked-label { /* Apply checked-border to labels for checkboxes and radio buttons for a label that colors up automatically when checked */ .checked-border { - @apply inline-flex items-center border border-zinc-300/30 bg-zinc-50 px-1 text-zinc-700 shadow-inner; - @apply hover:bg-white dark:border-zinc-500/10 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800 dark:focus:bg-zinc-800; + @apply inline-flex items-center border bg-linear-to-b dark:from-black/85 dark:to-black/75 dark:bg-indigo-900 px-1 text-zinc-700 shadow-inner rounded; + @apply hover:bg-white dark:border-zinc-500/10 dark:text-zinc-100 dark:hover:bg-zinc-800 dark:focus:bg-zinc-800; +} + +/* Focus ring on the label when any child input is focused */ +.checked-border:focus-within { + @apply ring-2 ring-indigo-400/50 ring-offset-0; +} + +.checked-border:has(input[type='radio']) { + @apply rounded-full; +} + +/* Remove focus ring from inputs inside checked-border (the label handles it) */ +.checked-border input[type='checkbox'], +.checked-border input[type='radio'] { + @apply focus:ring-0; } .checked-border:has([type='checkbox']:checked, [type='radio']:checked) { @apply border-indigo-600 text-black hover:border-indigo-500 dark:text-white; } + +/* Error message transitions */ +.error { + @apply transition-all duration-200 ease-out max-h-20; +} + +.error.opacity-0 { + @apply max-h-0; +} diff --git a/demo/form-field.js b/demo/form-field.js index 47f0c9c..e2b78fc 100644 --- a/demo/form-field.js +++ b/demo/form-field.js @@ -183,10 +183,12 @@ class FormField extends HTMLElement { ]), el('div', { className: 'my-0.5 sm:mt-0 sm:col-span-2' }, [ content, - hint ? elHtml('p', { className: 'mt-1 text-sm text-gray-500 dark:text-gray-400' }, hint) : null, + hint + ? elHtml('p', { className: 'mt-1 text-sm text-gray-500 dark:text-gray-400' }, hint) + : null, el('div', { style: 'min-height: 20px' }, [ el('div', { - className: 'error hidden text-sm text-red-600 transition dark:text-red-500', + className: 'error opacity-0 mt-1 overflow-hidden text-sm text-red-600 dark:text-red-500', id: `${id}-error`, }), ]), diff --git a/demo/form-field.ts b/demo/form-field.ts index ffcdc88..ffe8424 100644 --- a/demo/form-field.ts +++ b/demo/form-field.ts @@ -228,10 +228,13 @@ class FormField extends HTMLElement { ]), el('div', { className: 'my-0.5 sm:mt-0 sm:col-span-2' }, [ content, - hint ? elHtml('p', { className: 'mt-1 text-sm text-gray-500 dark:text-gray-400' }, hint) : null, + hint + ? elHtml('p', { className: 'mt-1 text-sm text-gray-500 dark:text-gray-400' }, hint) + : null, el('div', { style: 'min-height: 20px' }, [ el('div', { - className: 'error hidden text-sm text-red-600 transition dark:text-red-500', + className: + 'error opacity-0 mt-1 overflow-hidden text-sm text-red-600 dark:text-red-500', id: `${id}-error`, }), ]), @@ -290,7 +293,8 @@ class FormField extends HTMLElement { { id: `${id}-color-label`, for: `${id}-color`, - className: 'border border-l-0 border-gray-350 dark:border-gray-500 cursor-pointer w-20 rounded-r', + className: + 'border border-l-0 border-gray-350 dark:border-gray-500 cursor-pointer w-20 rounded-r', style: `background-color: ${value}`, }, [colorInput] diff --git a/demo/index.html b/demo/index.html index 6ad8dbd..1ec877b 100644 --- a/demo/index.html +++ b/demo/index.html @@ -8,8 +8,10 @@ - -
+ +

Validator Demo

@@ -31,13 +33,13 @@

github.com/jdlien/validator.

-

+

Fields can be required, have a minimum or maximum length, or a pattern.
Numeric inputs won't even allow invalid values to be typed in.

@@ -96,14 +98,14 @@

data-min and data-max)" > -

+

Dates can be entered by users in almost any format, and will be converted to the format specified in the data-date-format attribute.
Dates can also be constrained to past or future dates.
These features are compatible with datepickers like Flatpickr.

@@ -206,17 +208,17 @@

Selects can be required as well.

+

Selects can be required as well.

-

+

You can require groups of radio buttons or checkboxes so one must be selected.

@@ -226,7 +228,7 @@

@@ -235,10 +237,10 @@

-

+

Textarea elements can have min and max lengths as well as be required.

@@ -254,7 +256,7 @@

@@ -270,7 +272,12 @@

- +
@@ -277,6 +282,9 @@

+ + + + Validator Demo + + + + + +
+

+ Validator Demo +

+
+

+ Validator has been applied to this form to help sanitize and validate user input.
+ This form won't submit if any fields are invalid and helpful errors will be shown or + hidden as the user changes fields.
+ With scrollToError: true, the page will scroll to the first error on submit. +

+ +
Install this Validator through npm:
+ +
+ npm install @jdlien/validator +
+ +

+ Source code and documentation at + github.com/jdlien/validator. +

+
+ +

+ Fields can be required, have a minimum or maximum length, or a pattern.
+ Numeric inputs won't even allow invalid values to be typed in. +

+ + + + + + + + + + + + + + + + + +

+ Dates can be entered by users in almost any format, and will be converted to the format + specified in the data-date-format attribute.
+ Dates can also be constrained to past or future dates.
+ These features are compatible with datepickers like + Flatpickr. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + +

Selects can be required as well.

+ + + +

+ You can require groups of radio buttons or checkboxes so one must be selected. +

+ + + + + + +

+ Textarea elements can have min and max lengths as well as be required. +

+ + + +
+ +
+ + +

+ If the form is invalid when submitted, 'form-error-main' will be displayed above. +

+ + +
+ + diff --git a/demo/index.html b/demo/index.html index 42f532e..fe4bdc0 100644 --- a/demo/index.html +++ b/demo/index.html @@ -9,9 +9,9 @@
diff --git a/index.ts b/index.ts index 5e41ea6..76ff34f 100644 --- a/index.ts +++ b/index.ts @@ -1,2 +1,4 @@ import Validator from './src/Validator' -export default Validator + +const ValidatorExport = Validator +export default ValidatorExport diff --git a/package.json b/package.json index 4828ec0..be2bc3f 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "description": "Validates and sanitizes the inputs in a form using native HTML attributes.", "scripts": { "dev": "vite", - "build": "vite build && tsc --emitDeclarationOnly", + "build": "vite build && tsc --emitDeclarationOnly && pnpm build:demo", "size:wire": "node scripts/measure-wire-size.mjs", "preview": "vite preview", "test": "vitest", @@ -49,6 +49,7 @@ "devDependencies": { "@sheerun/mutationobserver-shim": "^0.3.3", "@tailwindcss/cli": "^4.1.18", + "@tailwindcss/vite": "^4.1.18", "@types/jsdom": "^27.0.0", "@vitest/coverage-v8": "^4.0.18", "canvas": "^3.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2abbf19..2348721 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,9 @@ importers: '@tailwindcss/cli': specifier: ^4.1.18 version: 4.1.18 + '@tailwindcss/vite': + specifier: ^4.1.18 + version: 4.1.18(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)) '@types/jsdom': specifier: ^27.0.0 version: 27.0.0 @@ -605,6 +608,11 @@ packages: resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==} engines: {node: '>= 10'} + '@tailwindcss/vite@4.1.18': + resolution: {integrity: sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -1632,6 +1640,13 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 + '@tailwindcss/vite@4.1.18(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2))': + dependencies: + '@tailwindcss/node': 4.1.18 + '@tailwindcss/oxide': 4.1.18 + tailwindcss: 4.1.18 + vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2) + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 diff --git a/src/Validator.ts b/src/Validator.ts index 11c2c8a..1710e07 100755 --- a/src/Validator.ts +++ b/src/Validator.ts @@ -357,7 +357,7 @@ export default class Validator { errorEl.innerHTML = errors.join('
') this.hiddenClassesArray.forEach((className) => { - if (errorEl) errorEl.classList.remove(className) + errorEl.classList.remove(className) }) } @@ -424,7 +424,7 @@ export default class Validator { // Hide the error element this.hiddenClassesArray.forEach((className) => { - if (errorEl) errorEl.classList.add(className) + errorEl.classList.add(className) }) // Clear the error message @@ -647,8 +647,12 @@ export default class Validator { // only validate the date range if it's a valid date if (!isNaN(date.getTime()) && !utils.isDateInRange(date, range)) { let msg = el.dataset.errorDefault || this.messages.ERROR_DATE_RANGE - if (range === 'past') msg = this.messages.ERROR_DATE_PAST - else if (range === 'future') msg = this.messages.ERROR_DATE_FUTURE + if (range === 'past') { + msg = this.messages.ERROR_DATE_PAST + } + if (range === 'future') { + msg = this.messages.ERROR_DATE_FUTURE + } this.addInputError(el, msg) return false } diff --git a/tests/Validator.errors.test.ts b/tests/Validator.errors.test.ts index 569de57..e6229c2 100644 --- a/tests/Validator.errors.test.ts +++ b/tests/Validator.errors.test.ts @@ -73,6 +73,25 @@ describe('Validator', () => { expect(errorEl.getAttribute('data-flux-error')).toBe('') }) + it('falls back when a flux field has no error element', () => { + const fluxField = document.createElement('div') + fluxField.setAttribute('data-flux-field', '') + form.appendChild(fluxField) + + const fluxInput = document.createElement('input') + fluxInput.type = 'text' + fluxInput.name = 'flux-input' + fluxInput.id = 'flux-input-id' + fluxField.appendChild(fluxInput) + + const fallbackError = document.createElement('div') + fallbackError.id = 'flux-input-id-error' + form.appendChild(fallbackError) + + const errorEl = (validator as any).getErrorEl(fluxInput) + expect(errorEl).toBe(fallbackError) + }) + it('returns error element by aria-describedby if not found by name or id', () => { const formControl3 = document.createElement('input') formControl3.type = 'text' @@ -91,6 +110,22 @@ describe('Validator', () => { expect(errorEl3.id).toBe('form-control-3-error') }) + it('falls back when aria-describedby target is missing', () => { + const formControl3 = document.createElement('input') + formControl3.type = 'text' + formControl3.setAttribute('aria-describedby', 'missing-error-id') + formControl3.name = 'form-control-3' + formControl3.id = 'form-control-3' + form.appendChild(formControl3) + + const fallbackError = document.createElement('div') + fallbackError.id = 'form-control-3-error' + form.appendChild(fallbackError) + + const errorEl3 = (validator as any).getErrorEl(formControl3) + expect(errorEl3).toBe(fallbackError) + }) + it('returns null if the input does not have an error element', () => { const formControl3 = document.createElement('input') formControl3.type = 'text' @@ -175,6 +210,21 @@ describe('Validator', () => { const errorEl = document.querySelector('#form-error-main') if (errorEl) expect(errorEl.parentNode).toBe(form) }) + + it('reuses an existing form-error-main element when present', () => { + const existing = document.createElement('div') + existing.id = 'form-error-main' + existing.classList.add('hidden') + form.appendChild(existing) + + const message = 'Existing error element' + ;(validator as any).addErrorMain(message) + + const errorEl = form.querySelector('#form-error-main') + expect(errorEl).toBe(existing) + expect(errorEl?.innerHTML).toBe(message) + expect(form.querySelectorAll('#form-error-main')).toHaveLength(1) + }) }) // addErrorMain describe('addInputError', () => { diff --git a/tests/Validator.inputHandlers.test.ts b/tests/Validator.inputHandlers.test.ts index b4d5123..8fab68d 100644 --- a/tests/Validator.inputHandlers.test.ts +++ b/tests/Validator.inputHandlers.test.ts @@ -221,6 +221,15 @@ describe('Validator', () => { expect(formControl.value).toEqual('0') }) + it('should ignore non-arrow keys for integer inputs', () => { + Object.defineProperty(event, 'target', { value: formControl }) + Object.defineProperty(event, 'key', { value: 'Enter' }) + formControl.value = '5' + + ;(validator as any).inputKeydownHandler(event) + expect(formControl.value).toEqual('5') + }) + it('should not increment or decrement non-integer inputs on ArrowUp', () => { Object.defineProperty(event, 'target', { value: formControl }) Object.defineProperty(event, 'key', { value: 'ArrowUp' }) diff --git a/tests/Validator.minMax.test.ts b/tests/Validator.minMax.test.ts index e129add..1f89d65 100644 --- a/tests/Validator.minMax.test.ts +++ b/tests/Validator.minMax.test.ts @@ -322,7 +322,8 @@ describe('Validator', () => { it('returns true and skips validation when value is non-numeric', () => { // Type validation should handle non-numeric values, not min/max validation - formControl.type = 'number' + formControl.type = 'text' + formControl.setAttribute('data-type', 'number') formControl.value = 'abc' formControl.setAttribute('data-min', '10') formControl.setAttribute('data-max', '100') diff --git a/tests/Validator.required.test.ts b/tests/Validator.required.test.ts index 87baf46..247a3d6 100644 --- a/tests/Validator.required.test.ts +++ b/tests/Validator.required.test.ts @@ -140,5 +140,19 @@ describe('Validator', () => { // Check that the message was not duplicated expect(validator.inputErrors[radio1.name].length).toBe(1) }) + + it('returns true for required values on non-form-control inputs', () => { + const fakeControl = { + required: true, + value: '', + name: 'fake-control', + id: 'fake-control', + dataset: {}, + } + + const result = (validator as any).validateRequired(fakeControl) + expect(result).toBeTruthy() + expect(validator.inputErrors[fakeControl.name]).toBeUndefined() + }) }) // end validateRequired }) // end describe('Validator') diff --git a/tests/index.test.ts b/tests/index.test.ts index 9a18801..46598e9 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -1,5 +1,5 @@ import Validator from '../src/Validator' -import MainValidator from '..' +import MainValidator from '../index' import { describe, it, expect } from 'vitest' describe('index.ts', () => { diff --git a/vite.config.js b/vite.config.js index ab4f4bd..f53e2d0 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,6 +1,8 @@ import { defineConfig } from 'vite' +import tailwindcss from '@tailwindcss/vite' export default defineConfig({ + plugins: [tailwindcss()], optimizeDeps: { include: ['@jdlien/validator-utils'] }, build: { lib: { entry: 'index.ts', name: 'Validator', fileName: 'validator' }, From fb603f04f3309e7c641720a390e37e9dd6072f50 Mon Sep 17 00:00:00 2001 From: JD Lien Date: Sat, 24 Jan 2026 13:08:22 -0700 Subject: [PATCH 20/54] Add form section cards and tweak demo text contrast Signed-off-by: JD Lien --- demo/demo-src.css | 21 +- demo/form-field.ts | 9 +- demo/index.dev.html | 498 ++++++++++++++++++++++++-------------------- 3 files changed, 301 insertions(+), 227 deletions(-) diff --git a/demo/demo-src.css b/demo/demo-src.css index ec7a011..787a098 100644 --- a/demo/demo-src.css +++ b/demo/demo-src.css @@ -35,7 +35,7 @@ form-field { @apply bg-linear-to-b dark:from-black/85 dark:to-black/75 dark:text-white dark:focus:bg-zinc-800 dark:border-zinc-700/60; - @apply disabled:bg-zinc-300 disabled:text-zinc-600/50 dark:disabled:bg-zinc-600/50 dark:disabled:text-zinc-300; + @apply disabled:bg-zinc-300 disabled:text-zinc-600/50 dark:disabled:bg-zinc-200/40 dark:disabled:text-zinc-400; } /* Form input variants (do not apply when disabled) */ @@ -106,6 +106,21 @@ form-field { } } +@layer components { + .form-card { + @apply rounded-xl border border-indigo-100/80 bg-white/55 p-5 shadow-sm; + @apply dark:border-indigo-300/10 dark:bg-indigo-950/40; + } + + .form-card-title { + @apply text-lg font-semibold tracking-wide text-indigo-800/90 dark:text-indigo-200/90; + } + + .form-card-lead { + @apply text-sm leading-relaxed text-indigo-900/70 dark:text-indigo-200/70; + } +} + /* Component utilities using Tailwind v4 @utility API */ .checked-label { @apply ml-1.5 mr-1; @@ -119,8 +134,8 @@ input:checked + .checked-label { /* Apply checked-border to labels for checkboxes and radio buttons for a label that colors up automatically when checked */ .checked-border { - @apply inline-flex items-center border border-black/20 bg-linear-to-b dark:from-black/85 dark:to-black/75 dark:bg-indigo-900 px-1 text-zinc-700 shadow-inner rounded; - @apply hover:bg-white/50 dark:border-zinc-500/10 dark:text-zinc-100 dark:hover:bg-zinc-800 dark:focus:bg-zinc-800; + @apply inline-flex items-center border border-black/20 bg-linear-to-b dark:from-black/70 dark:to-black/60 dark:bg-indigo-900 px-1 text-zinc-700 shadow-inner rounded; + @apply hover:bg-white/50 dark:border-indigo-300/25 dark:text-zinc-100 dark:hover:bg-zinc-800 dark:focus:bg-zinc-800; } /* Focus ring on the label when any child input is focused */ diff --git a/demo/form-field.ts b/demo/form-field.ts index 79e943a..5e8ccdc 100644 --- a/demo/form-field.ts +++ b/demo/form-field.ts @@ -228,14 +228,19 @@ class FormField extends HTMLElement { { for: id, id: `${id}-label`, - className: 'block font-extralight sm:mt-px sm:pt-1 leading-loose', + className: + 'block font-light text-indigo-900/80 dark:text-indigo-100/80 sm:mt-px sm:pt-1 leading-loose', }, [labelText] ), el('div', { className: 'my-0.5 sm:mt-0 sm:col-span-2' }, [ content, hint - ? elHtml('p', { className: 'mt-1 text-sm text-zinc-500 dark:text-zinc-400' }, hint) + ? elHtml( + 'p', + { className: 'mt-1 text-sm text-indigo-700/75 dark:text-indigo-300/75' }, + hint + ) : null, el('div', { style: 'min-height: 20px' }, [ el('div', { diff --git a/demo/index.dev.html b/demo/index.dev.html index b5a8f6e..0bbda59 100644 --- a/demo/index.dev.html +++ b/demo/index.dev.html @@ -20,21 +20,25 @@

Validator Demo

-
-

+

+

Validator has been applied to this form to help sanitize and validate user input.
This form won't submit if any fields are invalid and helpful errors will be shown or hidden as the user changes fields.
With scrollToError: true, the page will scroll to the first error on submit.

-
Install this Validator through npm:
+
+ Install this Validator through npm: +
- npm install @jdlien/validator + + npm install @jdlien/validator +
-

+

Source code and documentation at -

- Fields can be required, have a minimum or maximum length, or a pattern.
- Numeric inputs won't even allow invalid values to be typed in. -

- - - - - - - - - - - - - - - - - -

- Dates can be entered by users in almost any format, and will be converted to the format - specified in the data-date-format attribute.
- Dates can also be constrained to past or future dates.
- These features are compatible with datepickers like -
Flatpickr. -

- - - - - - - - - - - - - - - - - - - - - - - - - - -

Selects can be required as well.

- - - -

- You can require groups of radio buttons or checkboxes so one must be selected. -

- - - - - - -

- Textarea elements can have min and max lengths as well as be required. -

- - +
+
+
+

Core Inputs

+

+ Fields can be required, have a minimum or maximum length, or a pattern. Numeric inputs + won't even allow invalid values to be typed in. +

+
+
+ + + + + + + + + +
+
+ +
+
+

Number Inputs

+

+ Numeric inputs prevent invalid values and support range constraints. +

+
+
+ + + + + +
+
+ +
+
+

Dates & Time

+

+ Dates can be entered in flexible formats and normalized to the configured format. + These features are compatible with datepickers like + Flatpickr. +

+
+
+ + + + + + + + + + + +
+
+ +
+
+

Contact & Formats

+

+ Common formats like URLs, phone numbers, and postal codes are validated and + normalized. +

+
+
+ + + + + + + + + + + +
+
+ +
+
+

Selections

+

+ Selects, radio buttons, and checkbox groups can all be required. +

+
+
+ + + + + +
+
+ +
+
+

Long Form

+

+ Textarea elements can have min and max lengths as well as be required. +

+
+
+ +
+
+

If the form is invalid when submitted, 'form-error-main' will be displayed above.

@@ -284,7 +338,7 @@

Long Form

From aca9d7f45d9791f81abc9dab732db57130adbe57 Mon Sep 17 00:00:00 2001 From: JD Lien Date: Sat, 24 Jan 2026 14:34:01 -0700 Subject: [PATCH 22/54] Add configurable dark mode toggle Signed-off-by: JD Lien --- demo/dark-toggle.ts | 233 ++++++++++++++++++++++++++++++++++++++++++++ demo/demo-src.css | 13 ++- demo/index.dev.html | 48 ++++++--- 3 files changed, 278 insertions(+), 16 deletions(-) create mode 100644 demo/dark-toggle.ts diff --git a/demo/dark-toggle.ts b/demo/dark-toggle.ts new file mode 100644 index 0000000..f0ec001 --- /dev/null +++ b/demo/dark-toggle.ts @@ -0,0 +1,233 @@ +/** + * DarkToggle Custom Element + * Supports horizontal (default) or vertical orientation via attribute. + */ + +type Attrs = Record +type Child = HTMLElement | SVGElement | string | null | undefined + +const SVG_NS = 'http://www.w3.org/2000/svg' + +function el( + tag: K, + attrs: Attrs = {}, + children: Child[] = [] +): HTMLElementTagNameMap[K] { + const element = document.createElement(tag) + + for (const [key, value] of Object.entries(attrs)) { + if (value === undefined || value === false) continue + if (value === true) { + element.setAttribute(key, '') + } else if (key === 'className') { + element.className = value + } else { + element.setAttribute(key, value) + } + } + + for (const child of children) { + if (child === null || child === undefined) continue + if (typeof child === 'string') { + element.appendChild(document.createTextNode(child)) + } else { + element.appendChild(child) + } + } + + return element +} + +function svgEl( + tag: K, + attrs: Record = {}, + children: Array = [] +): SVGElementTagNameMap[K] { + const element = document.createElementNS(SVG_NS, tag) + + for (const [key, value] of Object.entries(attrs)) { + element.setAttribute(key, value) + } + + for (const child of children) { + element.appendChild(child) + } + + return element +} + +function sunIcon(): SVGSVGElement { + return svgEl( + 'svg', + { + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + 'stroke-width': '1.5', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + class: 'size-4', + }, + [ + svgEl('circle', { cx: '12', cy: '12', r: '4' }), + svgEl('line', { x1: '12', y1: '2', x2: '12', y2: '4' }), + svgEl('line', { x1: '12', y1: '20', x2: '12', y2: '22' }), + svgEl('line', { x1: '4.93', y1: '4.93', x2: '6.34', y2: '6.34' }), + svgEl('line', { x1: '17.66', y1: '17.66', x2: '19.07', y2: '19.07' }), + svgEl('line', { x1: '2', y1: '12', x2: '4', y2: '12' }), + svgEl('line', { x1: '20', y1: '12', x2: '22', y2: '12' }), + svgEl('line', { x1: '4.93', y1: '19.07', x2: '6.34', y2: '17.66' }), + svgEl('line', { x1: '17.66', y1: '6.34', x2: '19.07', y2: '4.93' }), + ] + ) +} + +function moonIcon(): SVGSVGElement { + return svgEl( + 'svg', + { + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + 'stroke-width': '1.5', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + class: 'size-4', + }, + [svgEl('path', { d: 'M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z' })] + ) +} + +class DarkToggle extends HTMLElement { + private button: HTMLButtonElement | null = null + private onKeydown = (event: KeyboardEvent): void => { + if (event.defaultPrevented) return + if (event.metaKey || event.ctrlKey || event.altKey) return + if (event.key.toLowerCase() !== 'd') return + if (this.isInteractiveTarget(event.target)) return + + event.preventDefault() + this.setDarkMode(!this.isDarkMode()) + } + + static get observedAttributes(): string[] { + return ['orientation'] + } + + connectedCallback(): void { + this.render() + this.syncFromDocument() + document.addEventListener('keydown', this.onKeydown) + } + + attributeChangedCallback( + name: string, + _oldValue: string | null, + newValue: string | null + ): void { + if (!this.isConnected) return + if (name === 'orientation') { + if (newValue !== 'vertical' && newValue !== 'horizontal' && newValue !== null) return + this.render() + this.syncFromDocument() + } + } + + disconnectedCallback(): void { + document.removeEventListener('keydown', this.onKeydown) + } + + private isDarkMode(): boolean { + return document.documentElement.classList.contains('dark') + } + + private getOrientation(): 'horizontal' | 'vertical' { + return this.getAttribute('orientation') === 'vertical' ? 'vertical' : 'horizontal' + } + + private isInteractiveTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false + if (target.isContentEditable) return true + + const tag = target.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true + + return Boolean( + target.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"]') + ) + } + + private syncFromDocument(): void { + this.updateButton(this.isDarkMode()) + } + + private updateButton(isDarkMode: boolean): void { + if (!this.button) return + this.button.setAttribute('aria-checked', isDarkMode ? 'true' : 'false') + this.button.setAttribute('title', isDarkMode ? 'Switch to light mode' : 'Switch to dark mode') + } + + private setDarkMode(isDarkMode: boolean): void { + document.documentElement.classList.toggle('dark', isDarkMode) + try { + localStorage.setItem('theme', isDarkMode ? 'dark' : 'light') + } catch {} + this.updateButton(isDarkMode) + } + + private render(): void { + const isVertical = this.getOrientation() === 'vertical' + const directionClass = isVertical ? 'flex-col' : '' + const indicatorShiftClass = isVertical + ? 'dark:translate-y-[calc(100%+1px)]' + : 'dark:translate-x-[calc(100%+1px)]' + + const button = el( + 'button', + { + type: 'button', + className: + `shadow-rim relative inline-flex ${directionClass} cursor-pointer gap-px rounded-full bg-neutral-200/60 dark:bg-neutral-800/50`, + role: 'switch', + 'aria-checked': 'false', + 'aria-orientation': isVertical ? 'vertical' : 'horizontal', + 'aria-label': 'Toggle dark mode', + title: 'Switch to dark mode', + }, + [ + el('span', { + className: + `shadow-edge absolute left-0 top-0 size-8 rounded-full bg-white shadow-md transition-all! delay-0! duration-200! ${indicatorShiftClass} dark:bg-neutral-600/60`, + 'aria-hidden': 'true', + }), + el( + 'span', + { + className: + 'z-10 flex size-8 items-center justify-center rounded-full text-indigo-700/60 transition-all hover:text-black dark:text-indigo-200/60 dark:hover:text-indigo-200', + 'aria-hidden': 'true', + }, + [sunIcon()] + ), + el( + 'span', + { + className: + 'z-10 flex size-8 items-center justify-center rounded-full text-indigo-400/60 transition-all hover:text-indigo-700/60 dark:text-indigo-200/60 dark:hover:text-white', + 'aria-hidden': 'true', + }, + [moonIcon()] + ), + ] + ) + + button.addEventListener('click', () => { + this.setDarkMode(!this.isDarkMode()) + }) + + this.button = button + this.replaceChildren(button) + } +} + +customElements.define('dark-toggle', DarkToggle) diff --git a/demo/demo-src.css b/demo/demo-src.css index 3f19b15..76c4412 100644 --- a/demo/demo-src.css +++ b/demo/demo-src.css @@ -2,8 +2,15 @@ @import 'tailwindcss'; +/* Dark mode configuration - use class strategy */ +@custom-variant dark ( + &:where(.dark, .dark *):where(:not(.light, .light *)), + &:where(:not(.light, .light *)):where(@media (prefers-color-scheme: dark)) +); + @theme { --font-alexandria: 'Alexandria', sans-serif; + --breakpoint-xs: 380px; } /* Custom element needs block display for space-y to work */ @@ -35,7 +42,7 @@ form-field { @apply bg-linear-to-b dark:from-black/85 dark:to-black/75 dark:text-white dark:focus:bg-zinc-800 dark:border-zinc-700/60; - @apply disabled:bg-zinc-300 disabled:text-zinc-600/50 dark:disabled:bg-zinc-200/40 dark:disabled:text-zinc-400; + @apply disabled:bg-zinc-200 disabled:text-zinc-600/50 dark:disabled:bg-zinc-200/40 dark:disabled:text-zinc-400; } /* Form input variants (do not apply when disabled) */ @@ -108,8 +115,8 @@ form-field { @layer components { .form-card { - @apply rounded-xl border border-indigo-100/80 bg-white/55 p-5 shadow-sm; - @apply dark:border-indigo-300/10 dark:bg-indigo-950/40; + @apply rounded-xl border border-indigo-100/80 p-5 shadow-sm bg-linear-120 from-indigo-50/70 to-indigo-100/80; + @apply dark:border-indigo-400/10 dark:from-indigo-950/60 dark:to-indigo-950/10; } .form-card-title { diff --git a/demo/index.dev.html b/demo/index.dev.html index 5c48968..2e4b7bb 100644 --- a/demo/index.dev.html +++ b/demo/index.dev.html @@ -4,24 +4,46 @@ Validator Demo + + +
+ +

+ @jdlien/Validator +

+
+
+ Easy, Powerful Form Validation +
+
+
-

- Validator Demo -

+
-

+

Validator has been applied to this form to help sanitize and validate user input.
This form won't submit if any fields are invalid and helpful errors will be shown or hidden as the user changes fields.
@@ -47,9 +69,9 @@

+
-
+

Core Inputs

Fields can be required, have a minimum or maximum length, or a pattern. Numeric inputs @@ -102,7 +124,7 @@

Core Inputs

-
+

Number Inputs

Numeric inputs prevent invalid values and support range constraints. @@ -135,7 +157,7 @@

Number Inputs

-
+

Dates & Time

Dates can be entered in flexible formats and normalized to the configured format. @@ -209,7 +231,7 @@

Dates & Time

-
+

Contact & Formats

Common formats like URLs, phone numbers, and postal codes are validated and @@ -259,7 +281,7 @@

Contact & Formats

-
+

Selections

Selects, radio buttons, and checkbox groups can all be required. @@ -294,7 +316,7 @@

Selections

-
+

Long Form

Textarea elements can have min and max lengths as well as be required. From bd5d2f6a444c6c322a28913ab01954e9b78b1cda Mon Sep 17 00:00:00 2001 From: JD Lien Date: Sat, 24 Jan 2026 14:57:50 -0700 Subject: [PATCH 23/54] Automate demo build outputs Signed-off-by: JD Lien --- demo/dark-toggle.js | 181 ++++++++++++++ demo/demo-css.ts | 1 + demo/form-field.js | 10 +- demo/index.dev.html | 21 +- demo/index.html | 533 +++++++++++++++++++++++------------------ package.json | 7 +- pnpm-lock.yaml | 189 +-------------- scripts/build-demo.mjs | 57 +++++ 8 files changed, 571 insertions(+), 428 deletions(-) create mode 100644 demo/dark-toggle.js create mode 100644 demo/demo-css.ts create mode 100644 scripts/build-demo.mjs diff --git a/demo/dark-toggle.js b/demo/dark-toggle.js new file mode 100644 index 0000000..67239ca --- /dev/null +++ b/demo/dark-toggle.js @@ -0,0 +1,181 @@ +/** + * DarkToggle Custom Element + * Supports horizontal (default) or vertical orientation via attribute. + */ +const SVG_NS = 'http://www.w3.org/2000/svg'; +function el(tag, attrs = {}, children = []) { + const element = document.createElement(tag); + for (const [key, value] of Object.entries(attrs)) { + if (value === undefined || value === false) + continue; + if (value === true) { + element.setAttribute(key, ''); + } + else if (key === 'className') { + element.className = value; + } + else { + element.setAttribute(key, value); + } + } + for (const child of children) { + if (child === null || child === undefined) + continue; + if (typeof child === 'string') { + element.appendChild(document.createTextNode(child)); + } + else { + element.appendChild(child); + } + } + return element; +} +function svgEl(tag, attrs = {}, children = []) { + const element = document.createElementNS(SVG_NS, tag); + for (const [key, value] of Object.entries(attrs)) { + element.setAttribute(key, value); + } + for (const child of children) { + element.appendChild(child); + } + return element; +} +function sunIcon() { + return svgEl('svg', { + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + 'stroke-width': '1.5', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + class: 'size-4', + }, [ + svgEl('circle', { cx: '12', cy: '12', r: '4' }), + svgEl('line', { x1: '12', y1: '2', x2: '12', y2: '4' }), + svgEl('line', { x1: '12', y1: '20', x2: '12', y2: '22' }), + svgEl('line', { x1: '4.93', y1: '4.93', x2: '6.34', y2: '6.34' }), + svgEl('line', { x1: '17.66', y1: '17.66', x2: '19.07', y2: '19.07' }), + svgEl('line', { x1: '2', y1: '12', x2: '4', y2: '12' }), + svgEl('line', { x1: '20', y1: '12', x2: '22', y2: '12' }), + svgEl('line', { x1: '4.93', y1: '19.07', x2: '6.34', y2: '17.66' }), + svgEl('line', { x1: '17.66', y1: '6.34', x2: '19.07', y2: '4.93' }), + ]); +} +function moonIcon() { + return svgEl('svg', { + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + 'stroke-width': '1.5', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + class: 'size-4', + }, [svgEl('path', { d: 'M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z' })]); +} +class DarkToggle extends HTMLElement { + constructor() { + super(...arguments); + this.button = null; + this.onKeydown = (event) => { + if (event.defaultPrevented) + return; + if (event.metaKey || event.ctrlKey || event.altKey) + return; + if (event.key.toLowerCase() !== 'd') + return; + if (this.isInteractiveTarget(event.target)) + return; + event.preventDefault(); + this.setDarkMode(!this.isDarkMode()); + }; + } + static get observedAttributes() { + return ['orientation']; + } + connectedCallback() { + this.render(); + this.syncFromDocument(); + document.addEventListener('keydown', this.onKeydown); + } + attributeChangedCallback(name, _oldValue, newValue) { + if (!this.isConnected) + return; + if (name === 'orientation') { + if (newValue !== 'vertical' && newValue !== 'horizontal' && newValue !== null) + return; + this.render(); + this.syncFromDocument(); + } + } + disconnectedCallback() { + document.removeEventListener('keydown', this.onKeydown); + } + isDarkMode() { + return document.documentElement.classList.contains('dark'); + } + getOrientation() { + return this.getAttribute('orientation') === 'vertical' ? 'vertical' : 'horizontal'; + } + isInteractiveTarget(target) { + if (!(target instanceof HTMLElement)) + return false; + if (target.isContentEditable) + return true; + const tag = target.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') + return true; + return Boolean(target.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"]')); + } + syncFromDocument() { + this.updateButton(this.isDarkMode()); + } + updateButton(isDarkMode) { + if (!this.button) + return; + this.button.setAttribute('aria-checked', isDarkMode ? 'true' : 'false'); + this.button.setAttribute('title', isDarkMode ? 'Switch to light mode' : 'Switch to dark mode'); + } + setDarkMode(isDarkMode) { + document.documentElement.classList.toggle('dark', isDarkMode); + try { + localStorage.setItem('theme', isDarkMode ? 'dark' : 'light'); + } + catch { } + this.updateButton(isDarkMode); + } + render() { + const isVertical = this.getOrientation() === 'vertical'; + const directionClass = isVertical ? 'flex-col' : ''; + const indicatorShiftClass = isVertical + ? 'dark:translate-y-[calc(100%+1px)]' + : 'dark:translate-x-[calc(100%+1px)]'; + const button = el('button', { + type: 'button', + className: `shadow-rim relative inline-flex ${directionClass} cursor-pointer gap-px rounded-full bg-neutral-200/60 dark:bg-neutral-800/50`, + role: 'switch', + 'aria-checked': 'false', + 'aria-orientation': isVertical ? 'vertical' : 'horizontal', + 'aria-label': 'Toggle dark mode', + title: 'Switch to dark mode', + }, [ + el('span', { + className: `shadow-edge absolute left-0 top-0 size-8 rounded-full bg-white shadow-md transition-all! delay-0! duration-200! ${indicatorShiftClass} dark:bg-neutral-600/60`, + 'aria-hidden': 'true', + }), + el('span', { + className: 'z-10 flex size-8 items-center justify-center rounded-full text-indigo-700/60 transition-all hover:text-black dark:text-indigo-200/60 dark:hover:text-indigo-200', + 'aria-hidden': 'true', + }, [sunIcon()]), + el('span', { + className: 'z-10 flex size-8 items-center justify-center rounded-full text-indigo-400/60 transition-all hover:text-indigo-700/60 dark:text-indigo-200/60 dark:hover:text-white', + 'aria-hidden': 'true', + }, [moonIcon()]), + ]); + button.addEventListener('click', () => { + this.setDarkMode(!this.isDarkMode()); + }); + this.button = button; + this.replaceChildren(button); + } +} +customElements.define('dark-toggle', DarkToggle); diff --git a/demo/demo-css.ts b/demo/demo-css.ts new file mode 100644 index 0000000..115144b --- /dev/null +++ b/demo/demo-css.ts @@ -0,0 +1 @@ +import './demo-src.css' diff --git a/demo/form-field.js b/demo/form-field.js index 6d0d1cd..4876579 100644 --- a/demo/form-field.js +++ b/demo/form-field.js @@ -178,13 +178,15 @@ class FormField extends HTMLElement { buildWrapper(id, labelText, content) { const hint = this.getAttribute('hint'); return el('div', { className: 'sm:grid sm:grid-cols-3 sm:items-start sm:gap-4' }, [ - el('label', { for: id, id: `${id}-label`, className: 'block font-medium sm:mt-px sm:pt-1' }, [ - labelText, - ]), + el('label', { + for: id, + id: `${id}-label`, + className: 'block font-light text-indigo-900/80 dark:text-indigo-100/80 sm:mt-px sm:pt-1 leading-loose', + }, [labelText]), el('div', { className: 'my-0.5 sm:mt-0 sm:col-span-2' }, [ content, hint - ? elHtml('p', { className: 'mt-1 text-sm text-zinc-500 dark:text-zinc-400' }, hint) + ? elHtml('p', { className: 'mt-1 text-sm text-indigo-700/75 dark:text-indigo-300/75' }, hint) : null, el('div', { style: 'min-height: 20px' }, [ el('div', { diff --git a/demo/index.dev.html b/demo/index.dev.html index 2e4b7bb..06f4731 100644 --- a/demo/index.dev.html +++ b/demo/index.dev.html @@ -25,15 +25,23 @@ background-image: url('data:image/svg+xml,'); " > -

+
-

- @jdlien/Validator +

+ @jdlien/Validator

-
-
+
+
Easy, Powerful Form Validation
@@ -41,7 +49,6 @@

-

Validator has been applied to this form to help sanitize and validate user input.
@@ -94,7 +101,7 @@

Core Inputs

required disabled minlength="8" - value="Hi!"" + value="Hi!" > Validator Demo + + +
+ +

+ @jdlien/Validator +

+
+
+ Easy, Powerful Form Validation +
+
+
-

- Validator Demo -

-
-

+ +

+

Validator has been applied to this form to help sanitize and validate user input.
This form won't submit if any fields are invalid and helpful errors will be shown or hidden as the user changes fields.
With scrollToError: true, the page will scroll to the first error on submit.

-
Install this Validator through npm:
+
+ Install this Validator through npm: +
- npm install @jdlien/validator + + npm install @jdlien/validator +
-

+

Source code and documentation at -

- Fields can be required, have a minimum or maximum length, or a pattern.
- Numeric inputs won't even allow invalid values to be typed in. -

- - - - - - - - - - - - - - - - - -

- Dates can be entered by users in almost any format, and will be converted to the format - specified in the data-date-format attribute.
- Dates can also be constrained to past or future dates.
- These features are compatible with datepickers like -
Flatpickr. -

- - - - - - - - - - - - - - - - - - - - - - - - - - -

Selects can be required as well.

- - - -

- You can require groups of radio buttons or checkboxes so one must be selected. -

- - - - - - -

- Textarea elements can have min and max lengths as well as be required. -

- - + +
+
+

Core Inputs

+

+ Fields can be required, have a minimum or maximum length, or a pattern. Numeric inputs + won't even allow invalid values to be typed in. +

+
+
+ + + + + + + + + +
+
+ +
+
+

Number Inputs

+

+ Numeric inputs prevent invalid values and support range constraints. +

+
+
+ + + + + +
+
+ +
+
+

Dates & Time

+

+ Dates can be entered in flexible formats and normalized to the configured format. + These features are compatible with datepickers like + Flatpickr. +

+
+
+ + + + + + + + + + + +
+
+ +
+
+

Contact & Formats

+

+ Common formats like URLs, phone numbers, and postal codes are validated and + normalized. +

+
+
+ + + + + + + + + + + +
+
+ +
+
+

Selections

+

+ Selects, radio buttons, and checkbox groups can all be required. +

+
+
+ + + + + +
+
+ +
+
+

Long Form

+

+ Textarea elements can have min and max lengths as well as be required. +

+
+
+ +
+
-

+

If the form is invalid when submitted, 'form-error-main' will be displayed above.

+ diff --git a/package.json b/package.json index be2bc3f..ae004e0 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,7 @@ "preview": "vite preview", "test": "vitest", "coverage": "vitest --coverage", - "build:css": "tailwindcss -i demo/demo-src.css -o demo/demo.css", - "build:demo": "tsc demo/form-field.ts --outDir demo --target ES2020 --module ES2020 --skipLibCheck && pnpm build:css", - "watch:css": "tailwindcss -i demo/demo-src.css -o demo/demo.css --watch" + "build:demo": "tsc demo/form-field.ts --outDir demo --target ES2020 --module ES2020 --skipLibCheck && tsc demo/dark-toggle.ts --outDir demo --target ES2020 --module ES2020 --skipLibCheck && node scripts/build-demo.mjs" }, "repository": { "type": "git", @@ -48,7 +46,6 @@ "homepage": "https://github.com/jdlien/validator#readme", "devDependencies": { "@sheerun/mutationobserver-shim": "^0.3.3", - "@tailwindcss/cli": "^4.1.18", "@tailwindcss/vite": "^4.1.18", "@types/jsdom": "^27.0.0", "@vitest/coverage-v8": "^4.0.18", @@ -58,7 +55,7 @@ "prettier": "^3.8.1", "tailwindcss": "^4.1.18", "typescript": "^5.9.3", - "undici-types": "^7.19.0", + "undici-types": "^7.19.1", "vite": "^7.3.1", "vitest": "^4.0.18" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2348721..5aa10ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,9 +15,6 @@ importers: '@sheerun/mutationobserver-shim': specifier: ^0.3.3 version: 0.3.3 - '@tailwindcss/cli': - specifier: ^4.1.18 - version: 4.1.18 '@tailwindcss/vite': specifier: ^4.1.18 version: 4.1.18(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)) @@ -46,8 +43,8 @@ importers: specifier: ^5.9.3 version: 5.9.3 undici-types: - specifier: ^7.19.0 - version: 7.19.0 + specifier: ^7.19.1 + version: 7.19.1 vite: specifier: ^7.3.1 version: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2) @@ -306,88 +303,6 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@parcel/watcher-android-arm64@2.5.6': - resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [android] - - '@parcel/watcher-darwin-arm64@2.5.6': - resolution: {integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [darwin] - - '@parcel/watcher-darwin-x64@2.5.6': - resolution: {integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [darwin] - - '@parcel/watcher-freebsd-x64@2.5.6': - resolution: {integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [freebsd] - - '@parcel/watcher-linux-arm-glibc@2.5.6': - resolution: {integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==} - engines: {node: '>= 10.0.0'} - cpu: [arm] - os: [linux] - - '@parcel/watcher-linux-arm-musl@2.5.6': - resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} - engines: {node: '>= 10.0.0'} - cpu: [arm] - os: [linux] - - '@parcel/watcher-linux-arm64-glibc@2.5.6': - resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [linux] - - '@parcel/watcher-linux-arm64-musl@2.5.6': - resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [linux] - - '@parcel/watcher-linux-x64-glibc@2.5.6': - resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [linux] - - '@parcel/watcher-linux-x64-musl@2.5.6': - resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [linux] - - '@parcel/watcher-win32-arm64@2.5.6': - resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [win32] - - '@parcel/watcher-win32-ia32@2.5.6': - resolution: {integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==} - engines: {node: '>= 10.0.0'} - cpu: [ia32] - os: [win32] - - '@parcel/watcher-win32-x64@2.5.6': - resolution: {integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [win32] - - '@parcel/watcher@2.5.6': - resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} - engines: {node: '>= 10.0.0'} - '@rollup/rollup-android-arm-eabi@4.56.0': resolution: {integrity: sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==} cpu: [arm] @@ -519,10 +434,6 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@tailwindcss/cli@4.1.18': - resolution: {integrity: sha512-sMZ+lZbDyxwjD2E0L7oRUjJ01Ffjtme5OtjvvnC+cV4CEDcbqzbp25TCpxHj6kWLU9+DlqJOiNgSOgctC2aZmg==} - hasBin: true - '@tailwindcss/node@4.1.18': resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} @@ -820,14 +731,6 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} @@ -961,10 +864,6 @@ packages: mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} - mri@1.2.0: - resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} - engines: {node: '>=4'} - ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1143,8 +1042,8 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - undici-types@7.19.0: - resolution: {integrity: sha512-Rjk2OWDZf2eiXVQjY2HyE3XPjqW/wXnSZq0QkOsPKZEnaetNNBObTp91LYfGdB8hRbRZk4HFcM/cONw452B0AQ==} + undici-types@7.19.1: + resolution: {integrity: sha512-z2f4eae6/P3L9bogRUfLEZfRRxyrH4ssRq8s2/NOOgXEwwM5w0hsaj+mtDJPN7sBXQQNlagCzYUfjHywUiTETw==} util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -1430,66 +1329,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@parcel/watcher-android-arm64@2.5.6': - optional: true - - '@parcel/watcher-darwin-arm64@2.5.6': - optional: true - - '@parcel/watcher-darwin-x64@2.5.6': - optional: true - - '@parcel/watcher-freebsd-x64@2.5.6': - optional: true - - '@parcel/watcher-linux-arm-glibc@2.5.6': - optional: true - - '@parcel/watcher-linux-arm-musl@2.5.6': - optional: true - - '@parcel/watcher-linux-arm64-glibc@2.5.6': - optional: true - - '@parcel/watcher-linux-arm64-musl@2.5.6': - optional: true - - '@parcel/watcher-linux-x64-glibc@2.5.6': - optional: true - - '@parcel/watcher-linux-x64-musl@2.5.6': - optional: true - - '@parcel/watcher-win32-arm64@2.5.6': - optional: true - - '@parcel/watcher-win32-ia32@2.5.6': - optional: true - - '@parcel/watcher-win32-x64@2.5.6': - optional: true - - '@parcel/watcher@2.5.6': - dependencies: - detect-libc: 2.1.2 - is-glob: 4.0.3 - node-addon-api: 7.1.1 - picomatch: 4.0.3 - optionalDependencies: - '@parcel/watcher-android-arm64': 2.5.6 - '@parcel/watcher-darwin-arm64': 2.5.6 - '@parcel/watcher-darwin-x64': 2.5.6 - '@parcel/watcher-freebsd-x64': 2.5.6 - '@parcel/watcher-linux-arm-glibc': 2.5.6 - '@parcel/watcher-linux-arm-musl': 2.5.6 - '@parcel/watcher-linux-arm64-glibc': 2.5.6 - '@parcel/watcher-linux-arm64-musl': 2.5.6 - '@parcel/watcher-linux-x64-glibc': 2.5.6 - '@parcel/watcher-linux-x64-musl': 2.5.6 - '@parcel/watcher-win32-arm64': 2.5.6 - '@parcel/watcher-win32-ia32': 2.5.6 - '@parcel/watcher-win32-x64': 2.5.6 - '@rollup/rollup-android-arm-eabi@4.56.0': optional: true @@ -1569,16 +1408,6 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@tailwindcss/cli@4.1.18': - dependencies: - '@parcel/watcher': 2.5.6 - '@tailwindcss/node': 4.1.18 - '@tailwindcss/oxide': 4.1.18 - enhanced-resolve: 5.18.4 - mri: 1.2.0 - picocolors: 1.1.1 - tailwindcss: 4.1.18 - '@tailwindcss/node@4.1.18': dependencies: '@jridgewell/remapping': 2.3.5 @@ -1881,12 +1710,6 @@ snapshots: ini@1.3.8: {} - is-extglob@2.1.1: {} - - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 - is-potential-custom-element-name@1.0.1: {} istanbul-lib-coverage@3.2.2: {} @@ -2013,8 +1836,6 @@ snapshots: mkdirp-classic@0.5.3: {} - mri@1.2.0: {} - ms@2.1.3: {} nanoid@3.3.11: {} @@ -2211,7 +2032,7 @@ snapshots: undici-types@7.16.0: {} - undici-types@7.19.0: {} + undici-types@7.19.1: {} util-deprecate@1.0.2: {} diff --git a/scripts/build-demo.mjs b/scripts/build-demo.mjs new file mode 100644 index 0000000..5cfd9fe --- /dev/null +++ b/scripts/build-demo.mjs @@ -0,0 +1,57 @@ +import { build } from 'vite' +import tailwindcss from '@tailwindcss/vite' +import { existsSync } from 'node:fs' +import { readFile, unlink, writeFile } from 'node:fs/promises' +import path from 'node:path' + +const root = process.cwd() +const cssEntry = path.join(root, 'demo', 'demo-css.ts') +const cssOutput = path.join(root, 'demo', 'demo-css.js') +const devHtmlPath = path.join(root, 'demo', 'index.dev.html') +const outHtmlPath = path.join(root, 'demo', 'index.html') + +await build({ + configFile: false, + root, + plugins: [tailwindcss()], + logLevel: 'warn', + build: { + outDir: 'demo', + emptyOutDir: false, + cssCodeSplit: false, + lib: { + entry: cssEntry, + name: 'DemoStyles', + fileName: () => 'demo-css', + }, + rollupOptions: { + output: { + assetFileNames: 'demo.css', + entryFileNames: 'demo-css.js', + }, + }, + }, +}) + +if (existsSync(cssOutput)) { + await unlink(cssOutput) +} + +const devHtml = await readFile(devHtmlPath, 'utf8') + +let outHtml = devHtml + .replace(/demo-src\.css/g, 'demo.css') + .replace(/form-field\.ts/g, 'form-field.js') + .replace(/dark-toggle\.ts/g, 'dark-toggle.js') + +outHtml = outHtml.replace( + /^\s*import Validator from ['"]..\/index\.ts['"];?\s*\n/m, + '' +) + +outHtml = outHtml.replace( + /^(\s*)\n$1 + + + + + + ``` When initialized, Validator disables browser validation and displays error messages in an associated page element. It identifies the appropriate element by searching for an ID in the following order: @@ -117,21 +142,22 @@ On input (and sometimes select and textarea) elements, the following attributes - `pattern`/`data-pattern` - The input must match the specified regular expression. - `type`/`data-type` - The input must match the specified type. The following types are supported: - - `number` - The input must be a number (use `data-type` to avoid quirky browser behavior) + - `number` (also `float`/`decimal`) - The input must be a number (use `data-type` to avoid quirky browser behavior) - `integer` - The input must be a positive whole number. - `tel` - The input must be a valid North American phone number. - `email` - The input must be a valid email address. - `zip` - The input must be a valid US zip code. - `postal` - The input must be a valid Canadian postal code. - - `color` - The input must be a valid CSS color. - `date` - The input must be a valid date. + - `datetime` - The input must be a valid date and time. - `time` - The input must be a valid time. - `url` - The input must be a valid URL. - `color` - The input must be a valid CSS color. (This can be used in conjunction with a native color input - see Color Picker Support for details.) -- `data-date-format`/`data-time-format` - Applies formatting to time input types (these are interchangeable). The format must be a valid moment.js format string. See [moment.js docs](https://momentjs.com/docs/#/displaying/format/) for more information. +- `data-date-format`/`data-time-format` - Applies formatting to date, time, or datetime inputs (these are interchangeable). The format must be a valid moment.js format string. See [moment.js docs](https://momentjs.com/docs/#/displaying/format/) for more information. - `data-date-range` - Applies to date input types. Supported values are `past` and `future`. - `data-min`/`data-max` - Applies to numeric input types (`number`, `integer`, `float`, `decimal`). Validates that the numeric value is within the specified range. Also respects the native `min`/`max` attributes, but `data-` attributes take precedence. +- `data-arrow-step` - Applies to numeric input types (`number`, `integer`, `float`, `decimal`). Sets the arrow key step size (defaults to `1`). Set `data-arrow-step=""` to disable arrow key handling for the field. - `data-error-default` - A custom error message to display if the input is invalid. This will be used for required, pattern, and date-range validation failures. - `data-validation` - The name of a custom validation function. - `data-novalidate` - If this attribute is present, the input will not be validated when `input` or `change` events are triggered on it. @@ -175,7 +201,7 @@ If you do not use `aria-describedby`, Validator will fall back to displaying err It is recommended to initially hide error elements with a class that sets properties such as `display: none;`, `visibility: hidden;`, or `opacity: 0;`. -You can customize the class(es) that Validator uses to hide the error messages by passing in a `hideErrorClass` option to the Validator constructor. The default is `hidden opacity-0`. +You can customize the class(es) that Validator uses to hide the error messages by passing in a `hiddenClasses` option to the Validator constructor. The default is `hidden opacity-0`. ## Color Picker Support @@ -199,7 +225,7 @@ A basic example that would work: The second parameter to the Validator constructor is an options object. The following options are available: -- `messages - An object containing custom error messages. The default messages can be overridden by `passing in a custom message object. These are all the default messages: +- `messages` - An object containing custom error messages. The default messages can be overridden by passing in a custom message object. These are all the default messages: ```javascript messages = { @@ -222,6 +248,7 @@ messages = { ERROR_DATE_PAST: 'The date must be in the past.', ERROR_DATE_FUTURE: 'The date must be in the future.', ERROR_DATE_RANGE: 'The date is outside the allowed range.', + ERROR_DATETIME: 'This is not a valid date and time.', ERROR_TIME: 'This is not a valid time.', ERROR_URL: 'This is not a valid URL.', ERROR_COLOR: 'This is not a valid CSS colour.', @@ -234,16 +261,18 @@ messages = { - `preventSubmit` - A boolean indicating whether or not to prevent form submission if validation is successful. Defaults to false. - `hiddenClasses` - A string containing one or more space-separated classes to toggle the hidden mode (e.g., `display: none` CSS property) on hidden elements. Defaults to `hidden opacity-0`. - `errorMainClasses` - A string containing one or more space-separated classes to apply to the main error message. -- `errorInputClasses` - A string containing one or more space-separated classes to apply to invalid `inputs. +- `errorInputClasses` - A string containing one or more space-separated classes to apply to invalid inputs. - `showMainError` - A boolean indicating whether or not to show the main error message. Defaults to `true`. - `scrollToError` - A boolean indicating whether to scroll to and focus the first invalid input when validation fails. Defaults to `false`. +- `scrollToErrorDelay` - A number (ms) to delay scroll-to-error behavior. Defaults to `0`. +- `validateOnBlur` - A boolean indicating whether to validate fields when they lose focus, even if the value hasn't changed. Useful for showing errors on touched-but-empty required fields. Defaults to `false`. - `validationSuccessCallback` - A function to be called when validation is successful. - `validationErrorCallback` - A function to be called when validation fails. ### Example: ```javascript -import Validator from 'validator' +import Validator from '@jdlien/validator' const myForm = document.querySelector('form') const myValidator = new Validator(myForm, { @@ -291,7 +320,7 @@ If you wish to use these, you may import the functions directly from the module // Import all the functions into a validatorUtils object import * as validatorUtils from '@jdlien/validator-utils' // Or just import the functions you need -import { dateFormat, formatDateTime } from '@jdlien/validator-utils' +import { parseDate, formatDateTime } from '@jdlien/validator-utils' ``` Here is a list of the utility functions: @@ -334,11 +363,11 @@ The separate `ValidationSuccessEvent` and `ValidationErrorEvent` classes have be ```javascript // Before (v1.x) -import { ValidationSuccessEvent, ValidationErrorEvent } from '@jdlien/validator' +import type { ValidationSuccessEvent, ValidationErrorEvent } from '@jdlien/validator' form.addEventListener('validationSuccess', (e: ValidationSuccessEvent) => { ... }) // After (v2.0) -import { ValidationEvent } from '@jdlien/validator' +import type { ValidationEvent } from '@jdlien/validator' form.addEventListener('validationSuccess', (e: ValidationEvent) => { ... }) form.addEventListener('validationError', (e: ValidationEvent) => { ... }) ``` @@ -354,7 +383,7 @@ The separate `types.d.ts` file has been removed. All types are now exported dire Install dev dependencies: ```bash -npm install +pnpm install ``` When running Vite, you may get an error like @@ -364,4 +393,4 @@ Module did not self-register: '...\node_modules\canvas\build\Release\canvas.node ``` If that happens, you -need to install the canvas module manually: `npm rebuild canvas --update-binary` +need to install the canvas module manually: `pnpm rebuild canvas --update-binary` diff --git a/src/Validator.ts b/src/Validator.ts index 7b6ea9b..c456528 100755 --- a/src/Validator.ts +++ b/src/Validator.ts @@ -1,4 +1,4 @@ -/** Form Validator used by EPL apps and www2. ©2026 JD Lien */ +/** Form Validator ©2026 JD Lien */ // Import the validator utility functions import * as utils from '@jdlien/validator-utils' @@ -22,6 +22,7 @@ export interface ValidatorOptions { showMainError?: boolean scrollToError?: boolean scrollToErrorDelay?: number + validateOnBlur?: boolean validationSuccessCallback?: (event: Event) => void validationErrorCallback?: (event: Event) => void } @@ -94,6 +95,8 @@ export default class Validator { scrollToError: boolean = false // Delay in ms before scrolling to the first error (allows animations to complete) scrollToErrorDelay: number = 0 + // Whether to validate inputs when they lose focus (even if unchanged) + validateOnBlur: boolean = false // Classes to apply to the main error message (space-separated) errorMainClasses: string @@ -142,6 +145,7 @@ export default class Validator { this.showMainError = options.showMainError !== undefined ? options.showMainError : true this.scrollToError = options.scrollToError || false this.scrollToErrorDelay = options.scrollToErrorDelay || 0 + this.validateOnBlur = options.validateOnBlur || false // Pre-split class strings for performance this.hiddenClassesArray = this.hiddenClasses.split(' ').filter(Boolean) @@ -158,6 +162,7 @@ export default class Validator { private submitHandlerRef = this.submitHandler.bind(this) private inputInputHandlerRef = this.inputInputHandler.bind(this) private inputChangeHandlerRef = this.inputChangeHandler.bind(this) + private inputBlurHandlerRef = this.inputBlurHandler.bind(this) private inputKeydownHandlerRef = this.inputKeydownHandler.bind(this) public addEventListeners(): void { @@ -165,6 +170,9 @@ export default class Validator { this.form.addEventListener('input', this.inputInputHandlerRef) this.form.addEventListener('change', this.inputChangeHandlerRef) this.form.addEventListener('keydown', this.inputKeydownHandlerRef) + if (this.validateOnBlur) { + this.form.addEventListener('blur', this.inputBlurHandlerRef, true) + } } public removeEventListeners(): void { @@ -172,6 +180,7 @@ export default class Validator { this.form.removeEventListener('input', this.inputInputHandlerRef) this.form.removeEventListener('change', this.inputChangeHandlerRef) this.form.removeEventListener('keydown', this.inputKeydownHandlerRef) + this.form.removeEventListener('blur', this.inputBlurHandlerRef, true) } // Adds event listeners to all formFields in a specified form @@ -312,9 +321,7 @@ export default class Validator { this.inputs.forEach((el) => this.showInputErrors(el)) // Find the first input with errors for potential scroll - const firstErrorInput = this.inputs.find( - (el) => this.inputErrors[el.name || el.id]?.length > 0 - ) + const firstErrorInput = this.inputs.find((el) => this.inputErrors[el.name || el.id]?.length > 0) // If there are any input errors and we should show the main error if ( @@ -752,6 +759,28 @@ export default class Validator { this.showInputErrors(target) } + // Validates on blur even if value unchanged (catches touched-but-empty required fields) + private async inputBlurHandler(e: FocusEvent): Promise { + const target = e.target + if ( + !( + target instanceof HTMLInputElement || + target instanceof HTMLSelectElement || + target instanceof HTMLTextAreaElement + ) || + this.shouldSkipValidation(target) + ) + return + + this.clearInputErrors(target) + this.validateRequired(target) + this.validateLength(target) + this.validateValue(target) + await this.validateInput(target) + if (!target.value.length) await this.validateCustom(target) + this.showInputErrors(target) + } + private inputInputHandler(e: Event) { const input = e.target as HTMLInputElement @@ -778,11 +807,15 @@ export default class Validator { if (!colorInput) return // No paired text input found } - let colorLabel = this.form.querySelector(`#${cssEscape(colorInput.id)}-color-label`) as HTMLElement | null + let colorLabel = this.form.querySelector( + `#${cssEscape(colorInput.id)}-color-label` + ) as HTMLElement | null // Update the HTML color picker input and its label background when color input changes if ((input.dataset.type || '') === 'color') { - let colorPicker = this.form.querySelector(`input#${cssEscape(input.id)}-color`) as HTMLInputElement + let colorPicker = this.form.querySelector( + `input#${cssEscape(input.id)}-color` + ) as HTMLInputElement if (!colorPicker || !utils.isColor(input.value)) return colorPicker.value = utils.parseColor(input.value) @@ -825,7 +858,7 @@ export default class Validator { // Get min/max bounds const minAttr = el.dataset.min ?? el.min const maxAttr = el.dataset.max ?? el.max - const min = minAttr !== '' ? parseFloat(minAttr) : (isInteger ? 0 : -Infinity) + const min = minAttr !== '' ? parseFloat(minAttr) : isInteger ? 0 : -Infinity const max = maxAttr !== '' ? parseFloat(maxAttr) : Infinity // Clamp to bounds @@ -836,7 +869,11 @@ export default class Validator { const stepDecimals = (step.toString().split('.')[1] || '').length const valueDecimals = (el.value.split('.')[1] || '').length const decimals = Math.max(stepDecimals, valueDecimals) - el.value = isInteger ? Math.round(newVal).toString() : (decimals ? newVal.toFixed(decimals) : newVal.toString()) + el.value = isInteger + ? Math.round(newVal).toString() + : decimals + ? newVal.toFixed(decimals) + : newVal.toString() } public destroy() { diff --git a/tests/Validator.blur.test.ts b/tests/Validator.blur.test.ts new file mode 100644 index 0000000..170fe6e --- /dev/null +++ b/tests/Validator.blur.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import Validator from '../src/Validator' +import { setupTestForm, TestSetup } from './utils/setup' + +describe('Validator', () => { + let form: HTMLFormElement + let formControl: HTMLInputElement + let errorEl: HTMLDivElement + let validator: Validator + + describe('validateOnBlur option', () => { + beforeEach(() => { + ;({ form, formControl, errorEl, validator } = setupTestForm({ validateOnBlur: true })) + }) + + afterEach(() => { + document.body.removeChild(form) + }) + + it('should add blur event listener when validateOnBlur is true', () => { + const newForm = document.createElement('form') + document.body.appendChild(newForm) + const addEventListenerSpy = vi.spyOn(newForm, 'addEventListener') + + const newValidator = new Validator(newForm, { validateOnBlur: true }) + + expect(addEventListenerSpy).toHaveBeenCalledWith( + 'blur', + (newValidator as any).inputBlurHandlerRef, + true + ) + + newValidator.destroy() + document.body.removeChild(newForm) + }) + + it('should not add blur event listener when validateOnBlur is false', () => { + const newForm = document.createElement('form') + document.body.appendChild(newForm) + const addEventListenerSpy = vi.spyOn(newForm, 'addEventListener') + + const newValidator = new Validator(newForm, { validateOnBlur: false }) + + expect(addEventListenerSpy).not.toHaveBeenCalledWith( + 'blur', + expect.any(Function), + true + ) + + newValidator.destroy() + document.body.removeChild(newForm) + }) + + it('should validate required field on blur even if value unchanged', async () => { + formControl.required = true + + // Focus and blur without typing anything + formControl.focus() + formControl.dispatchEvent(new FocusEvent('blur', { bubbles: false })) + await new Promise((r) => setTimeout(r, 0)) + + expect(errorEl.textContent).toBe('This field is required.') + expect(formControl.getAttribute('aria-invalid')).toBe('true') + }) + + it('should not show error on blur when validateOnBlur is false', async () => { + // Create a new validator with validateOnBlur: false + validator.destroy() + validator = new Validator(form, { validateOnBlur: false }) + + formControl.required = true + + // Focus and blur without typing anything + formControl.focus() + formControl.dispatchEvent(new FocusEvent('blur', { bubbles: false })) + await new Promise((r) => setTimeout(r, 0)) + + expect(errorEl.textContent).toBe('') + }) + + it('should validate length constraints on blur', async () => { + formControl.setAttribute('data-min-length', '5') + formControl.value = 'ab' + + formControl.dispatchEvent(new FocusEvent('blur', { bubbles: false })) + await new Promise((r) => setTimeout(r, 0)) + + expect(errorEl.textContent).toBe('This must be at least 5 characters.') + }) + + it('should validate numeric value constraints on blur', async () => { + formControl.setAttribute('data-type', 'number') + formControl.setAttribute('data-min', '10') + formControl.value = '5' + + formControl.dispatchEvent(new FocusEvent('blur', { bubbles: false })) + await new Promise((r) => setTimeout(r, 0)) + + expect(errorEl.textContent).toBe('The value must be at least 10.') + }) + + it('should run custom validation on blur for empty fields', async () => { + const customValidation = vi.fn().mockReturnValue(false) + ;(window as any).customBlurValidation = customValidation + + formControl.setAttribute('data-validation', 'customBlurValidation') + formControl.value = '' + + formControl.dispatchEvent(new FocusEvent('blur', { bubbles: false })) + await new Promise((r) => setTimeout(r, 0)) + + expect(customValidation).toHaveBeenCalledWith('') + + delete (window as any).customBlurValidation + }) + + it('should respect data-novalidate attribute on blur', async () => { + formControl.required = true + formControl.setAttribute('data-novalidate', 'true') + + formControl.focus() + formControl.dispatchEvent(new FocusEvent('blur', { bubbles: false })) + await new Promise((r) => setTimeout(r, 0)) + + expect(errorEl.textContent).toBe('') + }) + + it('should clear previous errors before validating on blur', async () => { + formControl.required = true + + // First blur - should show error + formControl.dispatchEvent(new FocusEvent('blur', { bubbles: false })) + await new Promise((r) => setTimeout(r, 0)) + expect(errorEl.textContent).toBe('This field is required.') + + // Fill in value and blur again - should clear error + formControl.value = 'valid value' + formControl.dispatchEvent(new FocusEvent('blur', { bubbles: false })) + await new Promise((r) => setTimeout(r, 0)) + + expect(errorEl.textContent).toBe('') + expect(formControl.getAttribute('aria-invalid')).toBeNull() + }) + + it('should ignore blur events on non-form-control elements', async () => { + // Create a div inside the form + const div = document.createElement('div') + form.appendChild(div) + + // Blur event on div should not throw + div.dispatchEvent(new FocusEvent('blur', { bubbles: false })) + await new Promise((r) => setTimeout(r, 0)) + + // No error should be shown + expect(errorEl.textContent).toBe('') + }) + }) +}) diff --git a/tests/Validator.init.test.ts b/tests/Validator.init.test.ts index 7653e5d..57ee00e 100644 --- a/tests/Validator.init.test.ts +++ b/tests/Validator.init.test.ts @@ -94,7 +94,7 @@ describe('Validator', () => { (validator as any).inputKeydownHandlerRef ) - expect(removeEventListenerSpy).toHaveBeenCalledTimes(4) + expect(removeEventListenerSpy).toHaveBeenCalledTimes(5) expect(removeEventListenerSpy).toHaveBeenCalledWith( 'submit', (validator as any).submitHandlerRef @@ -114,6 +114,12 @@ describe('Validator', () => { 'keydown', (validator as any).inputKeydownHandlerRef ) + + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'blur', + (validator as any).inputBlurHandlerRef, + true + ) }) }) // init }) // describe('Validator') diff --git a/v2-analysis.md b/v2-analysis.md index 412ec6c..3f91325 100644 --- a/v2-analysis.md +++ b/v2-analysis.md @@ -1,207 +1,49 @@ -# Validator v2.0.0 Analysis +# Validator v2.0.0 - Remaining Work -Analysis of the validator project for antipatterns, size reduction opportunities, and missing features. - -**Date:** 2026-01-23 -**Current Version:** 1.5.0 -**Dependency:** @jdlien/validator-utils (currently ^1.2.8, v2.0.0 available) +**Date:** 2026-01-24 +**Current Version:** 2.0.0 --- -## 1. Antipatterns / Bad Ideas - -### High Priority - -| Issue | Location | Problem | -|-------|----------|---------| -| **Expensive MutationObserver** | `Validator.ts:175-178` | Watching `document.body` with `subtree: true` fires on EVERY DOM change anywhere in the document just to check if the form was removed. This is O(n) for all DOM mutations across the entire page. | -| **Repeated string splitting** | Multiple locations | `hiddenClasses.split(' ')`, `errorInputClasses.split(' ')`, and `errorMainClasses.split(' ')` are called every time errors are shown/cleared instead of being pre-split once in the constructor. | -| **Outdated dependency** | `package.json:60` | Using `^1.2.8` of validator-utils but v2.0.0 is available with new date format features and smaller bundle size. | -| **Type duplication** | `types.d.ts` | Duplicates types already exported from `Validator.ts` and is missing `showMainError`. Should either be removed or auto-generated from the source. | - -### Medium Priority +## Remaining Features -| Issue | Location | Problem | -|-------|----------|---------| -| FIXME comment | Line 145 | Known issue: "This doesn't seem to work well if I add a lot of things at once. Needs more testing." | -| Empty callback defaults | Lines 137-138 | Creating new empty arrow functions `(() => {})` each time instead of null-checking before invocation. | -| Copyright year | Line 1 | Still says "©2023 JD Lien" - should be updated. | -| Loose typing | `ValidatorOptions.messages` | Uses generic `object` type instead of `Record` for proper type safety. | +| Feature | Impact | Complexity | Notes | +|---------|--------|------------|-------| +| **Programmatic single-input validation** | Medium | Low | `validateSingle(input)` method for validating individual inputs on demand. | --- -## 2. Size Reduction Opportunities - -**Estimated savings: ~50-80 lines, cleaner architecture** - -### 2.1 Remove `types.d.ts` +## Implementation Notes -Everything is already exported from `Validator.ts`. The separate types file: -- Duplicates definitions -- Can get out of sync (already missing `showMainError`) -- Adds maintenance burden +### Programmatic Single-Input Validation -### 2.2 Pre-split class strings - -Instead of splitting strings every time they're used: +Expose a public method to validate a single input programmatically: ```typescript -// Current (repeated throughout code): -this.hiddenClasses.split(' ').forEach((className) => { ... }) - -// Better (split once in constructor): -private hiddenClassList: string[] -private errorMainClassList: string[] -private errorInputClassList: string[] - -// In constructor: -this.hiddenClassList = (options.hiddenClasses || 'hidden opacity-0').split(' ') -``` +public async validateSingle(input: FormControl): Promise { + if (!this.inputs.includes(input)) return true -### 2.3 Consolidate custom event classes + this.clearInputErrors(input) -`ValidationSuccessEvent` and `ValidationErrorEvent` (lines 29-43) are identical except for the event name: + let valid = true + valid = this.validateRequired(input) && valid + valid = this.validateLength(input) && valid + valid = this.validateValue(input) && valid + valid = (await this.validateInput(input)) && valid + if (!input.value.length) valid = (await this.validateCustom(input)) && valid -```typescript -// Current: Two separate classes (14 lines) -export class ValidationSuccessEvent extends Event { ... } -export class ValidationErrorEvent extends Event { ... } - -// Better: Single generic class (7 lines) -export class ValidationEvent extends Event { - constructor(type: 'validationSuccess' | 'validationError', public submitEvent: Event) { - super(type, { cancelable: true }) - } + this.showInputErrors(input) + return valid } ``` -### 2.4 Simplify entry point - -`index.ts` currently just re-exports: -```typescript -export default Validator from './src/Validator' -``` - -Could point vite directly at `Validator.ts` and eliminate this file. - -### 2.5 More efficient auto-destroy observer - -Instead of watching the entire document with `subtree: true`: - -**Option A:** Use `requestIdleCallback` for periodic cleanup checks -```typescript -private scheduleCleanupCheck(): void { - requestIdleCallback(() => { - if (!document.contains(this.form)) this.destroy() - else this.scheduleCleanupCheck() - }) -} -``` - -**Option B:** Only observe the form's parent, not entire document -```typescript -this.autoDestroyObserver.observe(this.form.parentElement!, { - childList: true, -}) -``` - --- -## 3. Missing Features - -### Expected in a Form Validator - -| Feature | Impact | Complexity | Notes | -|---------|--------|------------|-------| -| **Scroll to first error** | High | Low | TODO already noted at line 342 | -| **Min/max value validation** | High | Low | Validate numeric ranges, not just format | -| **Blur event validation** | Medium | Low | Many forms validate on blur, not just change | -| **Programmatic single-input validation** | Medium | Low | `validateSingle(input)` method | -| **Time range validation** | Low | Low | Message exists (`ERROR_TIME_RANGE`) but no implementation | - -### Nice to Have +## Nice to Have (Future Consideration) | Feature | Notes | |---------|-------| | File input validation | Validate file size, MIME type, extension | -| Credit card / CVV validation | Common in e-commerce forms | | Accessible live region announcements | Better screen reader support | | Built-in async validator debouncing | For expensive validation operations | | Conditional validation | Skip validation based on other field values | -| Form reset handler | Low value - users rarely use reset buttons (bad UX), and programmatic resets can call `init()` manually | - ---- - -## 4. Recommended Actions for v2.0.0 - -### Must Do - -1. **Update validator-utils dependency** to `^2.0.0` -2. **Fix the expensive MutationObserver** - biggest performance issue -3. **Pre-split class strings** - easy win for cleaner code -4. **Remove or regenerate types.d.ts** - reduce duplication -5. **Update copyright year** to 2026 - -### Should Do - -6. **Add scroll-to-first-error option** - already noted as TODO -7. **Add min/max value validation** for number inputs -8. **Consolidate event classes** - cleaner code - -### Consider - -9. Add blur event validation (opt-in) -10. Add programmatic single-input validation API -11. Implement time range validation (message already exists) - ---- - -## 5. Code Locations Reference - -| File | Lines | Purpose | -|------|-------|---------| -| `src/Validator.ts` | 809 | Main validator class | -| `src/types.d.ts` | 24 | Type definitions (duplicate) | -| `index.ts` | 1 | Entry point re-export | -| `package.json` | 62 | Package configuration | -| `vite.config.js` | - | Build configuration | - -### Key Methods in Validator.ts - -| Method | Line | Purpose | -|--------|------|---------| -| `constructor` | 111 | Initialize validator | -| `setupAutoDestroy` | 166 | MutationObserver setup (needs optimization) | -| `init` | 202 | Register inputs and listeners | -| `validate` | 645 | Main validation orchestrator | -| `validateRequired` | 411 | Required field validation | -| `validateLength` | 452 | Min/max length validation | -| `validateInputType` | 544 | Type-specific validation | -| `validateDateRange` | 565 | Date range validation | -| `showFormErrors` | 343 | Display all errors | -| `destroy` | 777 | Cleanup method | - ---- - -## 6. Breaking Changes to Consider - -If releasing as v2.0.0, consider these potentially breaking changes: - -1. **Remove `types.d.ts`** - consumers importing from it would need to update -2. **Rename event classes** - if consolidating to single `ValidationEvent` -3. **Change callback signature** - if making callbacks nullable instead of empty functions -4. **Add blur validation by default** - might change existing behavior (make opt-in instead) - ---- - -## 7. Bundle Size Comparison - -Current validator-utils dependency: -- v1.2.8: ~8KB minified -- v2.0.0: ~6KB minified (25% smaller) - -Potential savings in validator itself: -- Remove types.d.ts: ~0.5KB -- Consolidate events: ~0.3KB -- Pre-split strings: Code quality improvement, minimal size change -- Total estimated savings: ~1KB + cleaner architecture From 704329bd967c13b073f07802a470e2adba1c86dd Mon Sep 17 00:00:00 2001 From: JD Lien Date: Sun, 25 Jan 2026 00:17:23 -0700 Subject: [PATCH 34/54] Fix index.ts coverage by using intermediate export variable Signed-off-by: JD Lien --- index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/index.ts b/index.ts index a810e76..35a7fb3 100644 --- a/index.ts +++ b/index.ts @@ -1,5 +1,7 @@ import Validator, { ValidationEvent } from './src/Validator' +const ValidatorExport = Validator + export type { FormControl, InputHandler, @@ -8,4 +10,4 @@ export type { ValidatorOptions, } from './src/Validator' export { ValidationEvent } -export default Validator +export default ValidatorExport From 023049100c79d81a039f4f951391a3818e4e6208 Mon Sep 17 00:00:00 2001 From: JD Lien Date: Sun, 25 Jan 2026 00:18:06 -0700 Subject: [PATCH 35/54] Add CI workflow and ISC license - GitHub Actions workflow for test and build on push/PR - ISC license file Signed-off-by: JD Lien --- .github/workflows/ci.yml | 24 ++++++++++++++++++++++++ LICENSE | 7 +++++++ 2 files changed, 31 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 LICENSE diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a2db983 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10.11.0 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - name: Install canvas system dependencies + run: sudo apt-get update && sudo apt-get install -y libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev + - run: pnpm install --frozen-lockfile + - run: pnpm test + - run: pnpm build:lib diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5ea8bd9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,7 @@ +ISC License + +Copyright (c) 2026 JD Lien + +Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. From 6dd14222a1999ca3e0610c878190575062980e04 Mon Sep 17 00:00:00 2001 From: JD Lien Date: Sun, 25 Jan 2026 00:26:25 -0700 Subject: [PATCH 36/54] Add validateSingle method for programmatic single-input validation Exposes a public method to validate individual inputs on demand, useful for multi-step forms, dependent field validation, and custom UI flows. Signed-off-by: JD Lien --- CHANGELOG.md | 7 +- README.md | 42 ++++ src/Validator.ts | 28 +++ tests/Validator.validateSingle.test.ts | 333 +++++++++++++++++++++++++ v2-analysis.md | 36 +-- 5 files changed, 412 insertions(+), 34 deletions(-) create mode 100644 tests/Validator.validateSingle.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b41cfd8..a6b4125 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,12 +10,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), | | v1.5.0 | v2.0.0 | Change | | ------ | --------- | --------- | ------ | -| Raw | 21.60 KiB | 22.94 KiB | +6.2% | -| Gzip | 7.40 KiB | 7.76 KiB | +4.9% | -| Brotli | 6.59 KiB | 6.91 KiB | +4.9% | +| Raw | 21.60 KiB | 23.24 KiB | +7.6% | +| Gzip | 7.40 KiB | 7.81 KiB | +5.5% | +| Brotli | 6.59 KiB | 6.94 KiB | +5.3% | ### Added +- `validateSingle(input)` method for programmatic single-input validation on demand - `scrollToError` option to scroll to first invalid input on validation failure - `scrollToErrorDelay` option to delay scroll-to-error behavior (useful for animations) - Min/max value validation via `data-min`/`data-max` attributes (also respects native `min`/`max`) diff --git a/README.md b/README.md index 0e4be49..86a1025 100644 --- a/README.md +++ b/README.md @@ -290,6 +290,48 @@ const myValidator = new Validator(myForm, { }) ``` +## Methods + +### `validateSingle(input): Promise` + +Validates a single input programmatically and displays any error messages. Returns `true` if the input is valid, `false` otherwise. + +This is useful for: + +- **Multi-step forms/wizards** - Validate each step before allowing progression +- **Dependent field validation** - Validate field A when field B changes +- **Custom validation triggers** - Validate on demand rather than relying on events +- **Dynamic form updates** - Validate after programmatically updating a field's value + +```javascript +const validator = new Validator(form) +const emailInput = document.getElementById('email') + +// Validate a single input on demand +const isValid = await validator.validateSingle(emailInput) + +if (isValid) { + // Proceed to next step +} else { + // Error messages are automatically displayed +} +``` + +Notes: + +- Returns `true` for inputs not part of the form or disabled inputs +- Clears previous errors before validating +- Displays error messages in the associated error element +- Works with all form control types (input, select, textarea) + +### `init()` + +Re-initializes the validator, refreshing the list of form inputs. Call this after dynamically adding or removing inputs. + +### `destroy()` + +Removes all event listeners and restores the form's original `novalidate` state. Call this before removing the form from the DOM. + ## Dynamic Forms and Cleanup Validator does not watch the DOM for changes. If you add or remove inputs after initialization diff --git a/src/Validator.ts b/src/Validator.ts index c456528..67edfd0 100755 --- a/src/Validator.ts +++ b/src/Validator.ts @@ -700,6 +700,34 @@ export default class Validator { return valid } //end validate() + /** + * Validates a single input programmatically and displays any error messages. + * Useful for validating inputs on demand (e.g., in multi-step forms or custom UI flows). + * @param input The input element to validate + * @returns A promise that resolves to true if the input is valid, false otherwise + */ + public async validateSingle(input: FormControl): Promise { + // If the input isn't part of this form's inputs, consider it valid + if (!this.inputs.includes(input)) return true + + // Skip disabled inputs + if (input.disabled) return true + + this.clearInputErrors(input) + + let valid = true + valid = this.validateRequired(input) && valid + valid = this.validateLength(input) && valid + valid = this.validateValue(input) && valid + valid = (await this.validateInput(input)) && valid + // Validate custom functions if value is empty, as validateInput + // only checks inputs with a value. + if (!input.value.length) valid = (await this.validateCustom(input)) && valid + + this.showInputErrors(input) + return valid + } + private isSubmitting = false private async submitHandler(e: Event): Promise { if (this.isSubmitting) return diff --git a/tests/Validator.validateSingle.test.ts b/tests/Validator.validateSingle.test.ts new file mode 100644 index 0000000..cce9136 --- /dev/null +++ b/tests/Validator.validateSingle.test.ts @@ -0,0 +1,333 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import Validator from '../src/Validator' +import { setupTestForm } from './utils/setup' + +describe('Validator.validateSingle', () => { + let form: HTMLFormElement + let formControl: HTMLInputElement + let errorEl: HTMLDivElement + let validator: Validator + + beforeEach(() => { + ;({ form, formControl, errorEl, validator } = setupTestForm()) + }) + + afterEach(() => { + validator.destroy() + document.body.removeChild(form) + }) + + describe('basic functionality', () => { + it('should return true for valid input', async () => { + formControl.value = 'valid value' + const result = await validator.validateSingle(formControl) + expect(result).toBe(true) + }) + + it('should return true for input not in form', async () => { + const externalInput = document.createElement('input') + externalInput.required = true + const result = await validator.validateSingle(externalInput) + expect(result).toBe(true) + }) + + it('should return true for disabled input', async () => { + formControl.required = true + formControl.disabled = true + const result = await validator.validateSingle(formControl) + expect(result).toBe(true) + }) + }) + + describe('required validation', () => { + it('should return false and show error for empty required input', async () => { + formControl.required = true + formControl.value = '' + + const result = await validator.validateSingle(formControl) + + expect(result).toBe(false) + expect(errorEl.textContent).toBe('This field is required.') + expect(formControl.getAttribute('aria-invalid')).toBe('true') + }) + + it('should return true for filled required input', async () => { + formControl.required = true + formControl.value = 'filled' + + const result = await validator.validateSingle(formControl) + + expect(result).toBe(true) + expect(errorEl.textContent).toBe('') + }) + }) + + describe('length validation', () => { + it('should return false for input below minlength', async () => { + formControl.setAttribute('data-min-length', '5') + formControl.value = 'ab' + + const result = await validator.validateSingle(formControl) + + expect(result).toBe(false) + expect(errorEl.textContent).toBe('This must be at least 5 characters.') + }) + + it('should return false for input above maxlength', async () => { + formControl.setAttribute('data-max-length', '3') + formControl.value = 'toolong' + + const result = await validator.validateSingle(formControl) + + expect(result).toBe(false) + expect(errorEl.textContent).toBe('This must be 3 characters or fewer.') + }) + + it('should return true for input within length bounds', async () => { + formControl.setAttribute('data-min-length', '2') + formControl.setAttribute('data-max-length', '10') + formControl.value = 'valid' + + const result = await validator.validateSingle(formControl) + + expect(result).toBe(true) + }) + }) + + describe('numeric value validation', () => { + it('should return false for number below min', async () => { + formControl.setAttribute('data-type', 'number') + formControl.setAttribute('data-min', '10') + formControl.value = '5' + + const result = await validator.validateSingle(formControl) + + expect(result).toBe(false) + expect(errorEl.textContent).toBe('The value must be at least 10.') + }) + + it('should return false for number above max', async () => { + formControl.setAttribute('data-type', 'number') + formControl.setAttribute('data-max', '100') + formControl.value = '150' + + const result = await validator.validateSingle(formControl) + + expect(result).toBe(false) + expect(errorEl.textContent).toBe('The value must be at most 100.') + }) + }) + + describe('type validation', () => { + it('should return false for invalid email', async () => { + formControl.type = 'email' + formControl.value = 'not-an-email' + + const result = await validator.validateSingle(formControl) + + expect(result).toBe(false) + expect(errorEl.textContent).toBe('This is not a valid email address.') + }) + + it('should return true for valid email', async () => { + formControl.type = 'email' + formControl.value = 'test@example.com' + + const result = await validator.validateSingle(formControl) + + expect(result).toBe(true) + }) + + it('should return false for invalid phone number', async () => { + formControl.setAttribute('data-type', 'tel') + formControl.value = '123' + + const result = await validator.validateSingle(formControl) + + expect(result).toBe(false) + expect(errorEl.textContent).toBe('This is not a valid telephone number.') + }) + }) + + describe('pattern validation', () => { + it('should return false for value not matching pattern', async () => { + formControl.setAttribute('data-pattern', '^[A-Z]+$') + formControl.value = 'lowercase' + + const result = await validator.validateSingle(formControl) + + expect(result).toBe(false) + }) + + it('should return true for value matching pattern', async () => { + formControl.setAttribute('data-pattern', '^[A-Z]+$') + formControl.value = 'UPPERCASE' + + const result = await validator.validateSingle(formControl) + + expect(result).toBe(true) + }) + }) + + describe('custom validation', () => { + it('should run custom validation function', async () => { + const customValidation = vi.fn().mockReturnValue(true) + ;(window as any).myCustomValidation = customValidation + + formControl.setAttribute('data-validation', 'myCustomValidation') + formControl.value = 'test' + + const result = await validator.validateSingle(formControl) + + expect(customValidation).toHaveBeenCalledWith('test') + expect(result).toBe(true) + + delete (window as any).myCustomValidation + }) + + it('should show error from custom validation', async () => { + const customValidation = vi.fn().mockReturnValue('Custom error message') + ;(window as any).myCustomValidation = customValidation + + formControl.setAttribute('data-validation', 'myCustomValidation') + formControl.value = 'test' + + const result = await validator.validateSingle(formControl) + + expect(result).toBe(false) + expect(errorEl.textContent).toBe('Custom error message') + + delete (window as any).myCustomValidation + }) + + it('should run custom validation for empty inputs', async () => { + const customValidation = vi.fn().mockReturnValue(false) + ;(window as any).myCustomValidation = customValidation + + formControl.setAttribute('data-validation', 'myCustomValidation') + formControl.value = '' + + const result = await validator.validateSingle(formControl) + + expect(customValidation).toHaveBeenCalledWith('') + expect(result).toBe(false) + + delete (window as any).myCustomValidation + }) + + it('should handle async custom validation', async () => { + const customValidation = vi.fn().mockResolvedValue({ valid: false, message: 'Async error' }) + ;(window as any).myAsyncValidation = customValidation + + formControl.setAttribute('data-validation', 'myAsyncValidation') + formControl.value = 'test' + + const result = await validator.validateSingle(formControl) + + expect(result).toBe(false) + expect(errorEl.textContent).toBe('Async error') + + delete (window as any).myAsyncValidation + }) + }) + + describe('error display', () => { + it('should clear previous errors before validating', async () => { + formControl.required = true + formControl.value = '' + + // First validation - should fail + await validator.validateSingle(formControl) + expect(errorEl.textContent).toBe('This field is required.') + + // Now make it valid + formControl.value = 'valid' + await validator.validateSingle(formControl) + + expect(errorEl.textContent).toBe('') + expect(formControl.getAttribute('aria-invalid')).toBeNull() + }) + + it('should accumulate multiple errors', async () => { + formControl.required = true + formControl.setAttribute('data-min-length', '10') + formControl.value = '' + + const result = await validator.validateSingle(formControl) + + expect(result).toBe(false) + // Should show required error (minlength doesn't apply to empty values) + expect(errorEl.textContent).toContain('This field is required.') + }) + }) + + describe('select element validation', () => { + it('should validate select elements', async () => { + // Remove the input and add a select + form.removeChild(formControl) + + const select = document.createElement('select') + select.name = 'test-input' + select.id = 'test-input' + select.required = true + form.insertBefore(select, errorEl) + + const emptyOption = document.createElement('option') + emptyOption.value = '' + emptyOption.text = 'Select...' + select.appendChild(emptyOption) + + const validOption = document.createElement('option') + validOption.value = 'valid' + validOption.text = 'Valid Option' + select.appendChild(validOption) + + // Reinitialize validator to pick up the new select + validator.init() + + const result = await validator.validateSingle(select) + + expect(result).toBe(false) + expect(errorEl.textContent).toBe('This field is required.') + }) + }) + + describe('textarea validation', () => { + it('should validate textarea elements', async () => { + // Remove the input and add a textarea + form.removeChild(formControl) + + const textarea = document.createElement('textarea') + textarea.name = 'test-input' + textarea.id = 'test-input' + textarea.required = true + form.insertBefore(textarea, errorEl) + + // Reinitialize validator to pick up the new textarea + validator.init() + + const result = await validator.validateSingle(textarea) + + expect(result).toBe(false) + expect(errorEl.textContent).toBe('This field is required.') + }) + + it('should validate textarea minlength', async () => { + form.removeChild(formControl) + + const textarea = document.createElement('textarea') + textarea.name = 'test-input' + textarea.id = 'test-input' + textarea.setAttribute('data-min-length', '20') + textarea.value = 'short' + form.insertBefore(textarea, errorEl) + + validator.init() + + const result = await validator.validateSingle(textarea) + + expect(result).toBe(false) + expect(errorEl.textContent).toBe('This must be at least 20 characters.') + }) + }) +}) diff --git a/v2-analysis.md b/v2-analysis.md index 3f91325..e14af37 100644 --- a/v2-analysis.md +++ b/v2-analysis.md @@ -1,41 +1,15 @@ # Validator v2.0.0 - Remaining Work -**Date:** 2026-01-24 +**Date:** 2026-01-25 **Current Version:** 2.0.0 --- -## Remaining Features +## Completed Features -| Feature | Impact | Complexity | Notes | -|---------|--------|------------|-------| -| **Programmatic single-input validation** | Medium | Low | `validateSingle(input)` method for validating individual inputs on demand. | - ---- - -## Implementation Notes - -### Programmatic Single-Input Validation - -Expose a public method to validate a single input programmatically: - -```typescript -public async validateSingle(input: FormControl): Promise { - if (!this.inputs.includes(input)) return true - - this.clearInputErrors(input) - - let valid = true - valid = this.validateRequired(input) && valid - valid = this.validateLength(input) && valid - valid = this.validateValue(input) && valid - valid = (await this.validateInput(input)) && valid - if (!input.value.length) valid = (await this.validateCustom(input)) && valid - - this.showInputErrors(input) - return valid -} -``` +| Feature | Status | Notes | +|---------|--------|-------| +| **Programmatic single-input validation** | ✅ Done | `validateSingle(input)` method added | --- From b0b309e4e106a0391f2571e525572ebed10bd4bf Mon Sep 17 00:00:00 2001 From: JD Lien Date: Sun, 25 Jan 2026 16:43:27 -0700 Subject: [PATCH 37/54] Add file input validation with size, count, and type constraints - Add validateFileInput method supporting data-max-files, data-max-file-size, data-min-file-size, and data-accept/accept attributes - Add error messages for file type, max files, max size, and min size - Fix required validation to properly handle file inputs - Add comprehensive test suite for file validation (21 tests) - Add file drop demo component and file validation examples - Remove obsolete v2-analysis.md Signed-off-by: JD Lien --- demo/file-drop.ts | 240 +++++++++++++++++++++++++ demo/form-field.ts | 49 ++++- demo/index.dev.html | 38 +++- demo/index.html | 22 +++ src/Validator.ts | 133 ++++++++++++-- tests/Validator.fileValidation.test.ts | 214 ++++++++++++++++++++++ tests/Validator.required.test.ts | 22 +++ tests/utils/files.ts | 20 +++ v2-analysis.md | 23 --- 9 files changed, 722 insertions(+), 39 deletions(-) create mode 100644 demo/file-drop.ts create mode 100644 tests/Validator.fileValidation.test.ts create mode 100644 tests/utils/files.ts delete mode 100644 v2-analysis.md diff --git a/demo/file-drop.ts b/demo/file-drop.ts new file mode 100644 index 0000000..8ef00e1 --- /dev/null +++ b/demo/file-drop.ts @@ -0,0 +1,240 @@ +/** + * FileDrop Custom Element + * A drag-and-drop file input with file list management and remove functionality. + * + * Usage: + * + * + */ + +class FileDrop extends HTMLElement { + private input!: HTMLInputElement + private dropZone!: HTMLLabelElement + private fileListEl!: HTMLDivElement + private selectedFiles: File[] = [] + private isInternalChange = false + + static get observedAttributes(): string[] { + return ['disabled'] + } + + connectedCallback(): void { + this.render() + } + + attributeChangedCallback(name: string, _old: string | null, value: string | null): void { + if (!this.isConnected || !this.input) return + if (name === 'disabled') { + this.input.disabled = value !== null + this.dropZone.classList.toggle('opacity-50', value !== null) + this.dropZone.classList.toggle('pointer-events-none', value !== null) + } + } + + /** Access the underlying file input */ + get fileInput(): HTMLInputElement { + return this.input + } + + /** Get the current files */ + get files(): File[] { + return [...this.selectedFiles] + } + + /** Clear all files */ + clear(): void { + this.selectedFiles = [] + this.syncFilesToInput() + this.updateFileList() + } + + private render(): void { + const name = this.getAttribute('name') || '' + const isMultiple = this.hasAttribute('multiple') + + // Generate unique ID + let id = this.getAttribute('id') || name + if (!id || document.getElementById(id)) { + id = `file-${Math.random().toString(36).slice(2, 8)}` + } + this.removeAttribute('id') + + // Create hidden file input with passed-through attributes + this.input = document.createElement('input') + this.input.type = 'file' + this.input.id = id + this.input.name = name + this.input.className = 'sr-only' + this.input.setAttribute('aria-describedby', `${id}-error`) + + // Pass through validation attributes + const passthrough = ['accept', 'multiple', 'required', 'disabled'] + passthrough.forEach((attr) => { + if (this.hasAttribute(attr)) { + const val = this.getAttribute(attr) + if (val === '' || val === null) this.input.setAttribute(attr, '') + else this.input.setAttribute(attr, val) + } + }) + + // Pass through data-* attributes + Array.from(this.attributes).forEach((attr) => { + if (attr.name.startsWith('data-')) { + this.input.setAttribute(attr.name, attr.value) + } + }) + + // File list container + this.fileListEl = document.createElement('div') + this.fileListEl.id = `${id}-file-list` + this.fileListEl.className = 'hidden mt-2 text-sm text-indigo-800 dark:text-indigo-200 space-y-1' + + // Drop zone + this.dropZone = document.createElement('label') + this.dropZone.htmlFor = id + this.dropZone.className = + 'flex flex-col items-center justify-center w-full p-3 border-2 border-dashed ' + + 'border-indigo-400/60 dark:border-indigo-500/50 rounded-lg cursor-pointer ' + + 'bg-indigo-100/50 dark:bg-indigo-900/30 hover:bg-indigo-200/50 dark:hover:bg-indigo-800/30 ' + + 'hover:border-indigo-500/80 dark:hover:border-indigo-400/70 transition-colors' + + this.dropZone.innerHTML = ` +
+ + + +
+ + ${isMultiple ? 'Drop files here or click to browse' : 'Drop file here or click to browse'} + + + ${isMultiple ? 'Select one or more files' : 'Select a file'} + + ` + + // Event handlers + this.setupEventListeners() + + // Append elements + this.appendChild(this.input) + this.appendChild(this.dropZone) + this.appendChild(this.fileListEl) + } + + private setupEventListeners(): void { + const isMultiple = this.hasAttribute('multiple') + + // Drag and drop + this.dropZone.addEventListener('dragover', (e) => { + e.preventDefault() + this.dropZone.classList.add('border-indigo-600', 'bg-indigo-200/70', 'dark:bg-indigo-700/40') + }) + + this.dropZone.addEventListener('dragleave', () => { + this.dropZone.classList.remove( + 'border-indigo-600', + 'bg-indigo-200/70', + 'dark:bg-indigo-700/40' + ) + }) + + this.dropZone.addEventListener('drop', (e) => { + e.preventDefault() + this.dropZone.classList.remove( + 'border-indigo-600', + 'bg-indigo-200/70', + 'dark:bg-indigo-700/40' + ) + if (e.dataTransfer?.files.length) { + this.addFiles(e.dataTransfer.files, isMultiple) + this.input.dispatchEvent(new Event('change', { bubbles: true })) + } + }) + + // File picker selection + this.input.addEventListener('change', () => { + if (this.isInternalChange) return + if (this.input.files?.length) { + this.addFiles(this.input.files, isMultiple) + } + }) + } + + private addFiles(files: FileList, multiple: boolean): void { + if (multiple) { + this.selectedFiles.push(...Array.from(files)) + } else { + this.selectedFiles = [files[0]] + } + this.syncFilesToInput() + this.updateFileList() + } + + private syncFilesToInput(): void { + const dt = new DataTransfer() + this.selectedFiles.forEach((f) => dt.items.add(f)) + this.input.files = dt.files + } + + private removeFile(index: number): void { + this.selectedFiles.splice(index, 1) + this.syncFilesToInput() + this.updateFileList() + this.isInternalChange = true + this.input.dispatchEvent(new Event('change', { bubbles: true })) + this.isInternalChange = false + } + + private formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` + } + + private updateFileList(): void { + if (this.selectedFiles.length === 0) { + this.fileListEl.classList.add('hidden') + this.fileListEl.innerHTML = '' + return + } + + this.fileListEl.classList.remove('hidden') + this.fileListEl.innerHTML = '' + + this.selectedFiles.forEach((file, index) => { + const item = document.createElement('div') + item.className = + 'flex items-center gap-2 px-2 py-1 bg-indigo-200/50 dark:bg-indigo-800/40 rounded' + + item.innerHTML = ` + + + + ${file.name} + ${this.formatSize(file.size)} + ` + + // Remove button + const removeBtn = document.createElement('button') + removeBtn.type = 'button' + removeBtn.className = + 'ml-1 text-indigo-400 hover:text-pink-500 dark:text-indigo-500 dark:hover:text-pink-400 transition-colors' + removeBtn.setAttribute('aria-label', `Remove ${file.name}`) + removeBtn.innerHTML = ` + + + + ` + removeBtn.addEventListener('click', () => this.removeFile(index)) + + item.appendChild(removeBtn) + this.fileListEl.appendChild(item) + }) + } +} + +customElements.define('file-drop', FileDrop) + +export default FileDrop diff --git a/demo/form-field.ts b/demo/form-field.ts index 5e8ccdc..26812e1 100644 --- a/demo/form-field.ts +++ b/demo/form-field.ts @@ -8,6 +8,8 @@ * */ +import './file-drop' + // Types type Attrs = Record type Child = HTMLElement | string | null | undefined @@ -27,6 +29,7 @@ type FieldType = | 'date' | 'datetime' | 'time' + | 'file' | 'color' | 'zip' | 'postal' @@ -83,15 +86,20 @@ function elHtml( const ATTR_MAP: Record = { required: 'required', disabled: 'disabled', + multiple: 'multiple', placeholder: 'placeholder', value: 'value', minlength: 'minlength', maxlength: 'maxlength', pattern: 'pattern', + accept: 'accept', min: 'data-min', max: 'data-max', 'min-length': 'data-min-length', 'max-length': 'data-max-length', + 'max-files': 'data-max-files', + 'min-file-size': 'data-min-file-size', + 'max-file-size': 'data-max-file-size', 'date-format': 'data-date-format', 'date-range': 'data-date-range', 'time-format': 'data-time-format', @@ -159,7 +167,13 @@ class FormField extends HTMLElement { private render(): void { const type = (this.getAttribute('type') || 'text') as FieldType const name = this.getAttribute('name') || '' - const id = this.getAttribute('id') || name + let id = this.getAttribute('id') || name + + // Generate unique ID if none provided or if ID already exists in document + if (!id || document.getElementById(id)) { + const base = name || 'field' + id = `${base}-${Math.random().toString(36).slice(2, 8)}` + } // Remove id from custom element to avoid duplicates this.removeAttribute('id') @@ -172,6 +186,8 @@ class FormField extends HTMLElement { this.renderGroup(id, name, type) } else if (type === 'color') { this.renderColorInput(id, name) + } else if (type === 'file') { + this.renderFileInput(id, name) } else { this.renderInput(id, name, type) } @@ -197,11 +213,12 @@ class FormField extends HTMLElement { private getValidationAttrs(): Attrs { const attrs: Attrs = {} + const booleanAttrs = new Set(['required', 'disabled', 'multiple']) for (const [formFieldAttr, inputAttr] of Object.entries(ATTR_MAP)) { if (this.hasAttribute(formFieldAttr)) { const val = this.getAttribute(formFieldAttr) - if (formFieldAttr === 'required' || formFieldAttr === 'disabled') { + if (booleanAttrs.has(formFieldAttr)) { attrs[inputAttr] = true } else { attrs[inputAttr] = val || undefined @@ -342,6 +359,34 @@ class FormField extends HTMLElement { } } + private renderFileInput(id: string, name: string): void { + const label = this.getAttribute('label') || name + + // Create file-drop element and pass through attributes + const fileDrop = document.createElement('file-drop') + fileDrop.setAttribute('name', name) + fileDrop.setAttribute('id', id) + + // Pass through relevant attributes + const passthrough = ['accept', 'multiple', 'required', 'disabled'] + passthrough.forEach((attr) => { + if (this.hasAttribute(attr)) { + const val = this.getAttribute(attr) + if (val === '' || val === null) fileDrop.setAttribute(attr, '') + else fileDrop.setAttribute(attr, val) + } + }) + + // Pass through data-* attributes for validation + Array.from(this.attributes).forEach((attr) => { + if (attr.name.startsWith('data-')) { + fileDrop.setAttribute(attr.name, attr.value) + } + }) + + this.appendChild(this.buildWrapper(id, label, fileDrop)) + } + private renderSelect(id: string, name: string): void { const label = this.getAttribute('label') || name const options = this.parseOptions() diff --git a/demo/index.dev.html b/demo/index.dev.html index 06f4731..9f9408b 100644 --- a/demo/index.dev.html +++ b/demo/index.dev.html @@ -189,7 +189,7 @@

Dates & Time

Contact & Formats

+
+
+

File Uploads

+

+ File inputs support type, size, and count limits using accept and data attributes. +

+
+
+ + + +
+
+

Selections

@@ -297,7 +327,7 @@

Selections

Selections

Selections

Contact & Formats

+
+
+

File Uploads

+

+ File inputs support type, size, and count limits using accept and data attributes. +

+
+
+ +
+
+

Selections

diff --git a/src/Validator.ts b/src/Validator.ts index 67edfd0..077419f 100755 --- a/src/Validator.ts +++ b/src/Validator.ts @@ -79,6 +79,10 @@ export default class Validator { ERROR_TIME: 'This is not a valid time.', ERROR_URL: 'This is not a valid URL.', ERROR_COLOR: 'This is not a valid CSS colour.', + ERROR_FILE_TYPE: 'This file type is not allowed.', + ERROR_FILE_MAX_FILES: 'You can upload up to ${val} file(s).', + ERROR_FILE_MAX_SIZE: 'Each file must be ${val} or smaller.', + ERROR_FILE_MIN_SIZE: 'Each file must be at least ${val}.', ERROR_CUSTOM_VALIDATION: 'There was a problem validating this field.', } // Show debug messages in the console @@ -391,20 +395,32 @@ export default class Validator { this.inputs.forEach((el) => this.clearInputErrors(el)) } + private hasInputValue(el: FormControl): boolean { + if (el instanceof HTMLInputElement && el.type === 'file') { + return !!el.files && el.files.length > 0 + } + return el.value.length > 0 + } + // Validates a required input and returns true if it's valid. // Shows an error if the input is required and empty. private validateRequired(el: FormControl): boolean { let valid = true + const isCheckable = + el instanceof HTMLInputElement && ['checkbox', 'radio'].includes(el.type) + const isFileInput = el instanceof HTMLInputElement && el.type === 'file' + const isEmpty = isFileInput + ? !this.hasInputValue(el) + : el.value === '' || (isCheckable && !el.checked) if ( el.required && - (el.value === '' || - (el instanceof HTMLInputElement && ['checkbox', 'radio'].includes(el.type) && !el.checked)) + isEmpty ) { // Handle checkboxes and radio buttons. Check that at least one of any name group is checked // Check that any checkbox of a group of checkboxes is checked // This assumes the checkbox or radio button is in a group... if it's not, // we can specify a default error message with error= - if (el instanceof HTMLInputElement && ['checkbox', 'radio'].includes(el.type)) { + if (isCheckable) { let groupChecked = false let groupName = el.name const groupInputs = this.form.querySelectorAll(`input[name="${groupName}"]`) @@ -438,6 +454,8 @@ export default class Validator { let valid = true if (el.disabled) return valid + if (el instanceof HTMLInputElement && el.type === 'file') return valid + if ((el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) && el.value.length) { // prettier-ignore let minLength = el.minLength > 0 ? el.minLength @@ -607,9 +625,99 @@ export default class Validator { return true } + private formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` + } + + private parseAcceptList(accept: string): { mimeTypes: string[]; extensions: string[] } { + const mimeTypes: string[] = [] + const extensions: string[] = [] + + for (const raw of accept.split(',')) { + const token = raw.trim().toLowerCase() + if (!token) continue + if (token.startsWith('.')) extensions.push(token) + else if (token.includes('/')) mimeTypes.push(token) + } + + return { mimeTypes, extensions } + } + + private validateFileInput(el: HTMLInputElement): boolean { + if (el.type !== 'file') return true + const files = Array.from(el.files || []) + if (!files.length) return true + + let valid = true + + const maxFiles = Number.parseInt(el.dataset.maxFiles || '', 10) + if (Number.isFinite(maxFiles) && maxFiles >= 0 && files.length > maxFiles) { + this.addInputError( + el, + this.messages.ERROR_FILE_MAX_FILES.replace('${val}', maxFiles.toString()) + ) + valid = false + } + + const minSize = Number.parseFloat(el.dataset.minFileSize || '') + if (Number.isFinite(minSize) && minSize >= 0) { + const tooSmall = files.some((file) => file.size < minSize) + if (tooSmall) { + this.addInputError( + el, + this.messages.ERROR_FILE_MIN_SIZE.replace('${val}', this.formatBytes(minSize)) + ) + valid = false + } + } + + const maxSize = Number.parseFloat(el.dataset.maxFileSize || '') + if (Number.isFinite(maxSize) && maxSize >= 0) { + const tooLarge = files.some((file) => file.size > maxSize) + if (tooLarge) { + this.addInputError( + el, + this.messages.ERROR_FILE_MAX_SIZE.replace('${val}', this.formatBytes(maxSize)) + ) + valid = false + } + } + + const accept = (el.dataset.accept ?? el.accept ?? '').trim() + if (accept) { + const { mimeTypes, extensions } = this.parseAcceptList(accept) + if (mimeTypes.length || extensions.length) { + const matchesMime = (type: string) => + mimeTypes.some((entry) => { + if (entry === '*/*') return true + if (entry.endsWith('/*')) return type.startsWith(entry.slice(0, -1)) + return entry === type + }) + + const isAllowed = (file: File) => { + const type = file.type.toLowerCase() + const name = file.name.toLowerCase() + const allowedByMime = type && mimeTypes.length ? matchesMime(type) : false + const allowedByExt = extensions.length ? extensions.some((ext) => name.endsWith(ext)) : false + return allowedByMime || allowedByExt + } + + if (files.some((file) => !isAllowed(file))) { + this.addInputError(el, this.messages.ERROR_FILE_TYPE) + valid = false + } + } + } + + return valid + } + // Validates a pattern from data-pattern or pattern; data-pattern takes precedence // Anchors pattern to match HTML5 pattern attribute behavior (full value must match) private validatePattern(el: FormControl): boolean { + if (el instanceof HTMLInputElement && el.type === 'file') return true const pattern = el.dataset.pattern || (el instanceof HTMLInputElement && el.pattern) || null if (!pattern) return true @@ -666,15 +774,20 @@ export default class Validator { // Validates an input with a value and returns true if it's valid // Checks inputs defined in the inputHandlers map, pattern, and date range, private async validateInput(el: FormControl): Promise { - if (!(el instanceof HTMLInputElement) || !el.value.length) return true + if (!(el instanceof HTMLInputElement) || !this.hasInputValue(el)) return true let valid = true + const isFileInput = el.type === 'file' // Skip disabled inputs if (el.disabled) return valid - valid = this.validateInputType(el) && valid - valid = this.validateDateRange(el) && valid - valid = this.validatePattern(el) && valid + if (isFileInput) { + valid = this.validateFileInput(el) && valid + } else { + valid = this.validateInputType(el) && valid + valid = this.validateDateRange(el) && valid + valid = this.validatePattern(el) && valid + } valid = (await this.validateCustom(el)) && valid return valid @@ -694,7 +807,7 @@ export default class Validator { valid = (await this.validateInput(el)) && valid // Validate custom functions here if value is empty, as they won't be // evaluated by validateInput, which only checks inputs with a value. - if (!el.value.length) valid = (await this.validateCustom(el)) && valid + if (!this.hasInputValue(el)) valid = (await this.validateCustom(el)) && valid } return valid @@ -722,7 +835,7 @@ export default class Validator { valid = (await this.validateInput(input)) && valid // Validate custom functions if value is empty, as validateInput // only checks inputs with a value. - if (!input.value.length) valid = (await this.validateCustom(input)) && valid + if (!this.hasInputValue(input)) valid = (await this.validateCustom(input)) && valid this.showInputErrors(input) return valid @@ -805,7 +918,7 @@ export default class Validator { this.validateLength(target) this.validateValue(target) await this.validateInput(target) - if (!target.value.length) await this.validateCustom(target) + if (!this.hasInputValue(target)) await this.validateCustom(target) this.showInputErrors(target) } diff --git a/tests/Validator.fileValidation.test.ts b/tests/Validator.fileValidation.test.ts new file mode 100644 index 0000000..1054f26 --- /dev/null +++ b/tests/Validator.fileValidation.test.ts @@ -0,0 +1,214 @@ +import Validator from '../src/Validator' +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { setupTestForm } from './utils/setup' +import { makeFile, setInputFiles } from './utils/files' + +describe('Validator file validation', () => { + let form: HTMLFormElement + let formControl: HTMLInputElement + let validator: Validator + + beforeEach(() => { + ;({ form, formControl, validator } = setupTestForm()) + formControl.type = 'file' + }) + + afterEach(() => { + document.body.removeChild(form) + }) + + it('returns true when validateFileInput is called for non-file inputs', () => { + formControl.type = 'text' + expect((validator as any).validateFileInput(formControl)).toBe(true) + }) + + it('returns true when validateFileInput is called with no files', () => { + setInputFiles(formControl, []) + expect((validator as any).validateFileInput(formControl)).toBe(true) + }) + + it('handles missing files list by treating it as empty', () => { + Object.defineProperty(formControl, 'files', { value: null, configurable: true }) + expect((validator as any).validateFileInput(formControl)).toBe(true) + }) + + it('skips pattern validation for file inputs', () => { + formControl.pattern = '[a-z]+' + expect((validator as any).validatePattern(formControl)).toBe(true) + }) + + it('returns true when file input has no files even with constraints', async () => { + formControl.dataset.maxFiles = '1' + formControl.dataset.minFileSize = '5' + formControl.dataset.maxFileSize = '10' + formControl.accept = 'image/*,.pdf' + setInputFiles(formControl, []) + + const result = await validator.validateSingle(formControl) + expect(result).toBe(true) + expect(validator.inputErrors[formControl.name]).toEqual([]) + }) + + it('fails when max files is exceeded', async () => { + formControl.dataset.maxFiles = '1' + setInputFiles(formControl, [ + makeFile(2, 'a.txt', 'text/plain'), + makeFile(2, 'b.txt', 'text/plain'), + ]) + const firstFile = formControl.files?.item(0) + const missingFile = formControl.files?.item(10) + expect(firstFile?.name).toBe('a.txt') + expect(missingFile).toBeNull() + + const result = await validator.validateSingle(formControl) + expect(result).toBe(false) + expect(validator.inputErrors[formControl.name]).toContain( + validator.messages.ERROR_FILE_MAX_FILES.replace('${val}', '1') + ) + }) + + it('fails when any file is too large', async () => { + formControl.dataset.maxFileSize = '10' + setInputFiles(formControl, [makeFile(12, 'big.bin', 'application/octet-stream')]) + + const result = await validator.validateSingle(formControl) + expect(result).toBe(false) + expect(validator.inputErrors[formControl.name]).toContain( + validator.messages.ERROR_FILE_MAX_SIZE.replace('${val}', '10 B') + ) + }) + + it('fails when any file is too small', async () => { + formControl.dataset.minFileSize = '10' + setInputFiles(formControl, [makeFile(2, 'small.bin', 'application/octet-stream')]) + + const result = await validator.validateSingle(formControl) + expect(result).toBe(false) + expect(validator.inputErrors[formControl.name]).toContain( + validator.messages.ERROR_FILE_MIN_SIZE.replace('${val}', '10 B') + ) + }) + + it('passes when all files meet min size', async () => { + formControl.dataset.minFileSize = '5' + setInputFiles(formControl, [makeFile(6, 'ok.bin', 'application/octet-stream')]) + + const result = await validator.validateSingle(formControl) + expect(result).toBe(true) + expect(validator.inputErrors[formControl.name]).toEqual([]) + }) + + it('passes when all files meet max size', async () => { + formControl.dataset.maxFileSize = '10' + setInputFiles(formControl, [makeFile(5, 'ok.bin', 'application/octet-stream')]) + + const result = await validator.validateSingle(formControl) + expect(result).toBe(true) + expect(validator.inputErrors[formControl.name]).toEqual([]) + }) + + it('accepts files that match wildcard mime types', async () => { + formControl.accept = 'image/*' + setInputFiles(formControl, [makeFile(2, 'photo.png', 'image/png')]) + + const result = await validator.validateSingle(formControl) + expect(result).toBe(true) + expect(validator.inputErrors[formControl.name]).toEqual([]) + }) + + it('accepts files when accept is */*', async () => { + formControl.accept = '*/*' + setInputFiles(formControl, [makeFile(2, 'notes.txt', 'text/plain')]) + + const result = await validator.validateSingle(formControl) + expect(result).toBe(true) + expect(validator.inputErrors[formControl.name]).toEqual([]) + }) + + it('accepts files that match exact mime or extension rules', async () => { + formControl.accept = 'application/pdf, invalid, , .txt' + setInputFiles(formControl, [ + makeFile(2, 'doc.pdf', 'application/pdf'), + makeFile(2, 'doc.txt'), + ]) + + const result = await validator.validateSingle(formControl) + expect(result).toBe(true) + expect(validator.inputErrors[formControl.name]).toEqual([]) + }) + + it('uses data-accept when provided', async () => { + formControl.dataset.accept = '.txt' + Object.defineProperty(formControl, 'accept', { value: undefined, configurable: true }) + setInputFiles(formControl, [makeFile(2, 'notes.txt')]) + + const result = await validator.validateSingle(formControl) + expect(result).toBe(true) + expect(validator.inputErrors[formControl.name]).toEqual([]) + }) + + it('skips accept validation when parsed list is empty', async () => { + formControl.accept = 'invalidtoken' + setInputFiles(formControl, [makeFile(2, 'notes.txt', 'text/plain')]) + + const result = await validator.validateSingle(formControl) + expect(result).toBe(true) + expect(validator.inputErrors[formControl.name]).toEqual([]) + }) + + it('falls back to no accept when accept is unset', async () => { + Object.defineProperty(formControl, 'accept', { value: undefined, configurable: true }) + setInputFiles(formControl, [makeFile(2, 'notes.txt', 'text/plain')]) + + const result = await validator.validateSingle(formControl) + expect(result).toBe(true) + expect(validator.inputErrors[formControl.name]).toEqual([]) + }) + + it('fails when accept list does not match', async () => { + formControl.accept = 'application/pdf,.png' + setInputFiles(formControl, [makeFile(2, 'notes.txt', 'text/plain')]) + + const result = await validator.validateSingle(formControl) + expect(result).toBe(false) + expect(validator.inputErrors[formControl.name]).toContain(validator.messages.ERROR_FILE_TYPE) + }) + + describe('formatBytes', () => { + it('formats bytes for values under 1 KB', () => { + expect((validator as any).formatBytes(0)).toBe('0 B') + expect((validator as any).formatBytes(1)).toBe('1 B') + expect((validator as any).formatBytes(512)).toBe('512 B') + expect((validator as any).formatBytes(1023)).toBe('1023 B') + }) + + it('formats kilobytes for values from 1 KB to under 1 MB', () => { + expect((validator as any).formatBytes(1024)).toBe('1.0 KB') + expect((validator as any).formatBytes(1536)).toBe('1.5 KB') + expect((validator as any).formatBytes(10240)).toBe('10.0 KB') + expect((validator as any).formatBytes(1048575)).toBe('1024.0 KB') + }) + + it('formats megabytes for values 1 MB and above', () => { + expect((validator as any).formatBytes(1048576)).toBe('1.0 MB') + expect((validator as any).formatBytes(1572864)).toBe('1.5 MB') + expect((validator as any).formatBytes(10485760)).toBe('10.0 MB') + expect((validator as any).formatBytes(104857600)).toBe('100.0 MB') + }) + + it('displays human-readable sizes in error messages', async () => { + // Test KB range + formControl.dataset.maxFileSize = '5120' // 5 KB + setInputFiles(formControl, [makeFile(6000, 'big.bin', 'application/octet-stream')]) + await validator.validateSingle(formControl) + expect(validator.inputErrors[formControl.name]).toContain('Each file must be 5.0 KB or smaller.') + + // Test MB range + validator.inputErrors[formControl.name] = [] + formControl.dataset.maxFileSize = '1048576' // 1 MB + setInputFiles(formControl, [makeFile(2000000, 'huge.bin', 'application/octet-stream')]) + await validator.validateSingle(formControl) + expect(validator.inputErrors[formControl.name]).toContain('Each file must be 1.0 MB or smaller.') + }) + }) +}) diff --git a/tests/Validator.required.test.ts b/tests/Validator.required.test.ts index 247a3d6..b8ae33e 100644 --- a/tests/Validator.required.test.ts +++ b/tests/Validator.required.test.ts @@ -1,6 +1,7 @@ import Validator from '../src/Validator' import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { setupTestForm } from './utils/setup' +import { makeFile, setInputFiles } from './utils/files' describe('Validator', () => { let form: HTMLFormElement @@ -72,6 +73,27 @@ describe('Validator', () => { expect(validator.inputErrors[formControl.name]).toContain(errorMessage) }) + it('returns false and shows an error if a file input is required and empty', () => { + formControl.type = 'file' + formControl.required = true + setInputFiles(formControl, []) + + const result = (validator as any).validateRequired(formControl) + expect(result).toBeFalsy() + + expect(validator.inputErrors[formControl.name]).toContain(validator.messages.ERROR_REQUIRED) + }) + + it('returns true and shows no error if a required file input has files', () => { + formControl.type = 'file' + formControl.required = true + setInputFiles(formControl, [makeFile(10, 'doc.txt', 'text/plain')]) + + const result = (validator as any).validateRequired(formControl) + expect(result).toBeTruthy() + expect(validator.inputErrors[formControl.name]).toEqual([]) + }) + it('returns false and shows an error if the input is a single checkbox and not checked', () => { formControl.type = 'checkbox' formControl.required = true diff --git a/tests/utils/files.ts b/tests/utils/files.ts new file mode 100644 index 0000000..7ba16fd --- /dev/null +++ b/tests/utils/files.ts @@ -0,0 +1,20 @@ +export function makeFile(size: number, name: string, type?: string): File { + const content = new Uint8Array(size) + return new File([content], name, type ? { type } : undefined) +} + +export function setInputFiles(input: HTMLInputElement, files: File[]): void { + const fileList: { [key: number]: File; length: number; item: (i: number) => File | null } = { + length: files.length, + item: (index: number) => files[index] || null, + } + + files.forEach((file, index) => { + fileList[index] = file + }) + + Object.defineProperty(input, 'files', { + value: fileList as FileList, + configurable: true, + }) +} diff --git a/v2-analysis.md b/v2-analysis.md deleted file mode 100644 index e14af37..0000000 --- a/v2-analysis.md +++ /dev/null @@ -1,23 +0,0 @@ -# Validator v2.0.0 - Remaining Work - -**Date:** 2026-01-25 -**Current Version:** 2.0.0 - ---- - -## Completed Features - -| Feature | Status | Notes | -|---------|--------|-------| -| **Programmatic single-input validation** | ✅ Done | `validateSingle(input)` method added | - ---- - -## Nice to Have (Future Consideration) - -| Feature | Notes | -|---------|-------| -| File input validation | Validate file size, MIME type, extension | -| Accessible live region announcements | Better screen reader support | -| Built-in async validator debouncing | For expensive validation operations | -| Conditional validation | Skip validation based on other field values | From ecb2f8847e818a225b2cc491b7431d63c3a4afab Mon Sep 17 00:00:00 2001 From: JD Lien Date: Sun, 25 Jan 2026 16:49:41 -0700 Subject: [PATCH 38/54] Add human-readable file size parsing and decimal formatting - Add parseBytes() to parse strings like "5MB", "1KB", "2GB", "500000" - Update formatBytes() to use decimal (SI) units by default for friendlier output - Support optional binary mode (base-1024) via formatBytes(n, false) - Add 13 new tests for parseBytes and updated formatBytes behavior - Minor demo label capitalization fixes Signed-off-by: JD Lien --- demo/index.html | 18 ++-- src/Validator.ts | 34 +++++-- tests/Validator.fileValidation.test.ts | 118 +++++++++++++++++++++---- 3 files changed, 143 insertions(+), 27 deletions(-) diff --git a/demo/index.html b/demo/index.html index bb911aa..e1646bf 100644 --- a/demo/index.html +++ b/demo/index.html @@ -189,7 +189,7 @@

Dates & Time

File Uploads

File Uploads multiple hint="Up to 2 files; each must be 1-500 KB and match image/* or .pdf" > + +
@@ -319,7 +327,7 @@

Selections

Selections Selections = { '': 1, K: 1000, M: 1e6, G: 1e9, T: 1e12 } + return num * mult[unit] + } + + // Formats bytes as human-readable string. decimal=true uses SI units (1000-based). + private formatBytes(bytes: number, decimal = true): string { + const base = decimal ? 1000 : 1024 + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + if (bytes < base) return `${bytes} B` + + let i = 0 + let val = bytes + while (val >= base && i < units.length - 1) { + val /= base + i++ + } + const rounded = Math.round(val * 10) / 10 + return `${rounded % 1 === 0 ? rounded.toFixed(0) : rounded.toFixed(1)} ${units[i]}` } private parseAcceptList(accept: string): { mimeTypes: string[]; extensions: string[] } { @@ -661,7 +683,7 @@ export default class Validator { valid = false } - const minSize = Number.parseFloat(el.dataset.minFileSize || '') + const minSize = this.parseBytes(el.dataset.minFileSize || '') if (Number.isFinite(minSize) && minSize >= 0) { const tooSmall = files.some((file) => file.size < minSize) if (tooSmall) { @@ -673,7 +695,7 @@ export default class Validator { } } - const maxSize = Number.parseFloat(el.dataset.maxFileSize || '') + const maxSize = this.parseBytes(el.dataset.maxFileSize || '') if (Number.isFinite(maxSize) && maxSize >= 0) { const tooLarge = files.some((file) => file.size > maxSize) if (tooLarge) { diff --git a/tests/Validator.fileValidation.test.ts b/tests/Validator.fileValidation.test.ts index 1054f26..3d89309 100644 --- a/tests/Validator.fileValidation.test.ts +++ b/tests/Validator.fileValidation.test.ts @@ -174,41 +174,127 @@ describe('Validator file validation', () => { expect(validator.inputErrors[formControl.name]).toContain(validator.messages.ERROR_FILE_TYPE) }) + describe('parseBytes', () => { + it('parses plain numbers', () => { + expect((validator as any).parseBytes('500000')).toBe(500000) + expect((validator as any).parseBytes('0')).toBe(0) + expect((validator as any).parseBytes('1.5')).toBe(1.5) + }) + + it('parses KB values', () => { + expect((validator as any).parseBytes('1K')).toBe(1000) + expect((validator as any).parseBytes('1KB')).toBe(1000) + expect((validator as any).parseBytes('5kb')).toBe(5000) + expect((validator as any).parseBytes('2.5KB')).toBe(2500) + }) + + it('parses MB values', () => { + expect((validator as any).parseBytes('1M')).toBe(1000000) + expect((validator as any).parseBytes('1MB')).toBe(1000000) + expect((validator as any).parseBytes('5mb')).toBe(5000000) + expect((validator as any).parseBytes('2.5MB')).toBe(2500000) + }) + + it('parses GB values', () => { + expect((validator as any).parseBytes('1G')).toBe(1000000000) + expect((validator as any).parseBytes('1GB')).toBe(1000000000) + expect((validator as any).parseBytes('2gb')).toBe(2000000000) + }) + + it('parses TB values', () => { + expect((validator as any).parseBytes('1T')).toBe(1000000000000) + expect((validator as any).parseBytes('1TB')).toBe(1000000000000) + }) + + it('parses B suffix', () => { + expect((validator as any).parseBytes('500B')).toBe(500) + expect((validator as any).parseBytes('500b')).toBe(500) + }) + + it('handles whitespace', () => { + expect((validator as any).parseBytes(' 5MB ')).toBe(5000000) + expect((validator as any).parseBytes('5 MB')).toBe(5000000) + }) + + it('returns NaN for invalid values', () => { + expect((validator as any).parseBytes('')).toBeNaN() + expect((validator as any).parseBytes('abc')).toBeNaN() + expect((validator as any).parseBytes('MB')).toBeNaN() + }) + }) + describe('formatBytes', () => { - it('formats bytes for values under 1 KB', () => { + it('formats bytes for values under 1 KB (decimal)', () => { expect((validator as any).formatBytes(0)).toBe('0 B') expect((validator as any).formatBytes(1)).toBe('1 B') expect((validator as any).formatBytes(512)).toBe('512 B') - expect((validator as any).formatBytes(1023)).toBe('1023 B') + expect((validator as any).formatBytes(999)).toBe('999 B') + }) + + it('formats kilobytes for values 1 KB and above (decimal)', () => { + expect((validator as any).formatBytes(1000)).toBe('1 KB') + expect((validator as any).formatBytes(1500)).toBe('1.5 KB') + expect((validator as any).formatBytes(10000)).toBe('10 KB') + expect((validator as any).formatBytes(999999)).toBe('1000 KB') + }) + + it('formats megabytes for values 1 MB and above (decimal)', () => { + expect((validator as any).formatBytes(1000000)).toBe('1 MB') + expect((validator as any).formatBytes(1500000)).toBe('1.5 MB') + expect((validator as any).formatBytes(10000000)).toBe('10 MB') + expect((validator as any).formatBytes(100000000)).toBe('100 MB') + }) + + it('formats gigabytes for values 1 GB and above (decimal)', () => { + expect((validator as any).formatBytes(1000000000)).toBe('1 GB') + expect((validator as any).formatBytes(2500000000)).toBe('2.5 GB') }) - it('formats kilobytes for values from 1 KB to under 1 MB', () => { - expect((validator as any).formatBytes(1024)).toBe('1.0 KB') - expect((validator as any).formatBytes(1536)).toBe('1.5 KB') - expect((validator as any).formatBytes(10240)).toBe('10.0 KB') - expect((validator as any).formatBytes(1048575)).toBe('1024.0 KB') + it('formats terabytes for values 1 TB and above (decimal)', () => { + expect((validator as any).formatBytes(1000000000000)).toBe('1 TB') }) - it('formats megabytes for values 1 MB and above', () => { - expect((validator as any).formatBytes(1048576)).toBe('1.0 MB') - expect((validator as any).formatBytes(1572864)).toBe('1.5 MB') - expect((validator as any).formatBytes(10485760)).toBe('10.0 MB') - expect((validator as any).formatBytes(104857600)).toBe('100.0 MB') + it('uses binary mode when decimal=false', () => { + expect((validator as any).formatBytes(1024, false)).toBe('1 KB') + expect((validator as any).formatBytes(1536, false)).toBe('1.5 KB') + expect((validator as any).formatBytes(1048576, false)).toBe('1 MB') + expect((validator as any).formatBytes(1073741824, false)).toBe('1 GB') }) it('displays human-readable sizes in error messages', async () => { // Test KB range - formControl.dataset.maxFileSize = '5120' // 5 KB + formControl.dataset.maxFileSize = '5000' setInputFiles(formControl, [makeFile(6000, 'big.bin', 'application/octet-stream')]) await validator.validateSingle(formControl) - expect(validator.inputErrors[formControl.name]).toContain('Each file must be 5.0 KB or smaller.') + expect(validator.inputErrors[formControl.name]).toContain('Each file must be 5 KB or smaller.') // Test MB range validator.inputErrors[formControl.name] = [] - formControl.dataset.maxFileSize = '1048576' // 1 MB + formControl.dataset.maxFileSize = '1000000' setInputFiles(formControl, [makeFile(2000000, 'huge.bin', 'application/octet-stream')]) await validator.validateSingle(formControl) - expect(validator.inputErrors[formControl.name]).toContain('Each file must be 1.0 MB or smaller.') + expect(validator.inputErrors[formControl.name]).toContain('Each file must be 1 MB or smaller.') + }) + }) + + describe('human-readable size attributes', () => { + it('accepts human-readable max-file-size values', async () => { + formControl.dataset.maxFileSize = '5MB' + setInputFiles(formControl, [makeFile(4000000, 'ok.bin', 'application/octet-stream')]) + expect(await validator.validateSingle(formControl)).toBe(true) + + setInputFiles(formControl, [makeFile(6000000, 'big.bin', 'application/octet-stream')]) + expect(await validator.validateSingle(formControl)).toBe(false) + }) + + it('accepts human-readable min-file-size values', async () => { + formControl.dataset.minFileSize = '1KB' + setInputFiles(formControl, [makeFile(2000, 'ok.bin', 'application/octet-stream')]) + expect(await validator.validateSingle(formControl)).toBe(true) + + validator.inputErrors[formControl.name] = [] + setInputFiles(formControl, [makeFile(500, 'small.bin', 'application/octet-stream')]) + expect(await validator.validateSingle(formControl)).toBe(false) }) }) }) From f3b0e22641b6ebc4ac85a2f95661fa3f8048d1f8 Mon Sep 17 00:00:00 2001 From: JD Lien Date: Sun, 25 Jan 2026 16:55:37 -0700 Subject: [PATCH 39/54] Fix file validation attributes not passed through form-field component The form-field component was only passing through data-* attributes that were already prefixed, but max-files, min-file-size, max-file-size need to be mapped to data-max-files, etc. before passing to file-drop. Signed-off-by: JD Lien --- demo/form-field.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/demo/form-field.ts b/demo/form-field.ts index 26812e1..fd9320f 100644 --- a/demo/form-field.ts +++ b/demo/form-field.ts @@ -377,7 +377,15 @@ class FormField extends HTMLElement { } }) - // Pass through data-* attributes for validation + // Pass through file validation attributes (map to data-* attributes) + const fileAttrs = ['max-files', 'min-file-size', 'max-file-size'] + fileAttrs.forEach((attr) => { + if (this.hasAttribute(attr)) { + fileDrop.setAttribute(`data-${attr}`, this.getAttribute(attr)!) + } + }) + + // Pass through any other data-* attributes Array.from(this.attributes).forEach((attr) => { if (attr.name.startsWith('data-')) { fileDrop.setAttribute(attr.name, attr.value) From 607cdda5fa27daee294187461933432bac0d5ad1 Mon Sep 17 00:00:00 2001 From: JD Lien Date: Mon, 26 Jan 2026 08:46:57 -0700 Subject: [PATCH 40/54] Add binary units, fail-closed parsing, and boundary rounding for file sizes - Add binary unit support (KiB, MiB, GiB, TiB) to parseBytes - Hoist multiplier maps to static class properties for efficiency - Fail validation when size attributes are invalid (fail-closed) - Fix boundary rounding (999999 bytes now formats as "1 MB" not "1000 KB") Signed-off-by: JD Lien --- CHANGELOG.md | 10 ++-- src/Validator.ts | 74 +++++++++++++++++------- tests/Validator.fileValidation.test.ts | 79 +++++++++++++++++++++++++- 3 files changed, 134 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6b4125..5bfdb7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Bundle Size -| | v1.5.0 | v2.0.0 | Change | -| ------ | --------- | --------- | ------ | -| Raw | 21.60 KiB | 23.24 KiB | +7.6% | -| Gzip | 7.40 KiB | 7.81 KiB | +5.5% | -| Brotli | 6.59 KiB | 6.94 KiB | +5.3% | +| | v1.5.0 | v2.0.0 | Change | +| ------ | --------- | --------- | ------- | +| Raw | 21.60 KiB | 26.00 KiB | +20.4% | +| Gzip | 7.40 KiB | 8.74 KiB | +18.1% | +| Brotli | 6.59 KiB | 7.81 KiB | +18.5% | ### Added diff --git a/src/Validator.ts b/src/Validator.ts index 3dafea4..f30090d 100755 --- a/src/Validator.ts +++ b/src/Validator.ts @@ -54,6 +54,14 @@ export default class Validator { // Keeps track of error messages accumulated for each input inputErrors: { [key: string]: string[] } = {} + // Hoisted multiplier maps for parseBytes (avoid per-call allocation) + private static readonly SI_MULT: Record = { + '': 1, K: 1000, M: 1e6, G: 1e9, T: 1e12 + } + private static readonly BINARY_MULT: Record = { + '': 1, K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4 + } + // Default error messages. messages: Record = { ERROR_MAIN: 'There is a problem with your submission.', @@ -625,16 +633,19 @@ export default class Validator { return true } - // Parses human-readable byte strings: "500000", "5M", "5MB", "2GB", "1KB", etc. + // Parses human-readable byte strings: "500000", "5M", "5MB", "5MiB", "2GB", "1KiB", etc. private parseBytes(value: string): number { const str = value.trim() - const match = str.match(/^(\d+(?:\.\d+)?)\s*(B|KB?|MB?|GB?|TB?)?$/i) + // Accept: 500, 5B, 5K, 5KB, 5KiB, 5Ki, 5M, 5MB, 5MiB, etc. + const match = str.match(/^(\d+(?:\.\d+)?)\s*(B|Ki?B?|Mi?B?|Gi?B?|Ti?B?)?$/i) if (!match) return NaN const num = Number.parseFloat(match[1]) - const unit = match[2]?.toUpperCase().replace(/B$/, '') || '' - const mult: Record = { '': 1, K: 1000, M: 1e6, G: 1e9, T: 1e12 } - return num * mult[unit] + const rawUnit = match[2]?.toUpperCase() || '' + const isBinary = rawUnit.includes('I') + const prefix = rawUnit.replace(/I?B$/i, '').replace(/I$/i, '') || '' + const mult = isBinary ? Validator.BINARY_MULT : Validator.SI_MULT + return num * mult[prefix] } // Formats bytes as human-readable string. decimal=true uses SI units (1000-based). @@ -649,7 +660,12 @@ export default class Validator { val /= base i++ } - const rounded = Math.round(val * 10) / 10 + let rounded = Math.round(val * 10) / 10 + // If rounding pushed us to next unit threshold, bump up + if (rounded >= base && i < units.length - 1) { + rounded = 1 + i++ + } return `${rounded % 1 === 0 ? rounded.toFixed(0) : rounded.toFixed(1)} ${units[i]}` } @@ -683,27 +699,41 @@ export default class Validator { valid = false } - const minSize = this.parseBytes(el.dataset.minFileSize || '') - if (Number.isFinite(minSize) && minSize >= 0) { - const tooSmall = files.some((file) => file.size < minSize) - if (tooSmall) { - this.addInputError( - el, - this.messages.ERROR_FILE_MIN_SIZE.replace('${val}', this.formatBytes(minSize)) - ) + const minSizeAttr = el.dataset.minFileSize || '' + if (minSizeAttr) { + const minSize = this.parseBytes(minSizeAttr) + if (Number.isNaN(minSize)) { + if (this.debug) console.warn(`Validator: Invalid min-file-size "${minSizeAttr}"`) + this.addInputError(el, this.messages.ERROR_FILE_MIN_SIZE.replace('${val}', minSizeAttr)) valid = false + } else if (minSize >= 0) { + const tooSmall = files.some((file) => file.size < minSize) + if (tooSmall) { + this.addInputError( + el, + this.messages.ERROR_FILE_MIN_SIZE.replace('${val}', this.formatBytes(minSize)) + ) + valid = false + } } } - const maxSize = this.parseBytes(el.dataset.maxFileSize || '') - if (Number.isFinite(maxSize) && maxSize >= 0) { - const tooLarge = files.some((file) => file.size > maxSize) - if (tooLarge) { - this.addInputError( - el, - this.messages.ERROR_FILE_MAX_SIZE.replace('${val}', this.formatBytes(maxSize)) - ) + const maxSizeAttr = el.dataset.maxFileSize || '' + if (maxSizeAttr) { + const maxSize = this.parseBytes(maxSizeAttr) + if (Number.isNaN(maxSize)) { + if (this.debug) console.warn(`Validator: Invalid max-file-size "${maxSizeAttr}"`) + this.addInputError(el, this.messages.ERROR_FILE_MAX_SIZE.replace('${val}', maxSizeAttr)) valid = false + } else if (maxSize >= 0) { + const tooLarge = files.some((file) => file.size > maxSize) + if (tooLarge) { + this.addInputError( + el, + this.messages.ERROR_FILE_MAX_SIZE.replace('${val}', this.formatBytes(maxSize)) + ) + valid = false + } } } diff --git a/tests/Validator.fileValidation.test.ts b/tests/Validator.fileValidation.test.ts index 3d89309..a966b4d 100644 --- a/tests/Validator.fileValidation.test.ts +++ b/tests/Validator.fileValidation.test.ts @@ -1,5 +1,5 @@ import Validator from '../src/Validator' -import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { setupTestForm } from './utils/setup' import { makeFile, setInputFiles } from './utils/files' @@ -221,6 +221,20 @@ describe('Validator file validation', () => { expect((validator as any).parseBytes('abc')).toBeNaN() expect((validator as any).parseBytes('MB')).toBeNaN() }) + + it('parses binary KiB/MiB/GiB/TiB values', () => { + expect((validator as any).parseBytes('1KiB')).toBe(1024) + expect((validator as any).parseBytes('1Ki')).toBe(1024) + expect((validator as any).parseBytes('1MiB')).toBe(1048576) + expect((validator as any).parseBytes('1GiB')).toBe(1073741824) + expect((validator as any).parseBytes('1TiB')).toBe(1099511627776) + }) + + it('parses mixed case binary units', () => { + expect((validator as any).parseBytes('5kib')).toBe(5 * 1024) + expect((validator as any).parseBytes('5KIB')).toBe(5 * 1024) + expect((validator as any).parseBytes('2.5MiB')).toBe(2.5 * 1024 * 1024) + }) }) describe('formatBytes', () => { @@ -235,7 +249,10 @@ describe('Validator file validation', () => { expect((validator as any).formatBytes(1000)).toBe('1 KB') expect((validator as any).formatBytes(1500)).toBe('1.5 KB') expect((validator as any).formatBytes(10000)).toBe('10 KB') - expect((validator as any).formatBytes(999999)).toBe('1000 KB') + }) + + it('formats 999999 bytes as 1 MB not 1000 KB (boundary rounding)', () => { + expect((validator as any).formatBytes(999999)).toBe('1 MB') }) it('formats megabytes for values 1 MB and above (decimal)', () => { @@ -296,5 +313,63 @@ describe('Validator file validation', () => { setInputFiles(formControl, [makeFile(500, 'small.bin', 'application/octet-stream')]) expect(await validator.validateSingle(formControl)).toBe(false) }) + + it('accepts binary unit size values (KiB, MiB)', async () => { + formControl.dataset.maxFileSize = '1MiB' + setInputFiles(formControl, [makeFile(1000000, 'ok.bin', 'application/octet-stream')]) + expect(await validator.validateSingle(formControl)).toBe(true) + + setInputFiles(formControl, [makeFile(1100000, 'big.bin', 'application/octet-stream')]) + expect(await validator.validateSingle(formControl)).toBe(false) + }) + }) + + describe('fail-closed on invalid size attributes', () => { + it('fails validation when max-file-size attribute is invalid', async () => { + formControl.dataset.maxFileSize = '5MBX' + setInputFiles(formControl, [makeFile(100, 'small.bin')]) + expect(await validator.validateSingle(formControl)).toBe(false) + }) + + it('fails validation when min-file-size attribute is invalid', async () => { + formControl.dataset.minFileSize = 'invalid' + setInputFiles(formControl, [makeFile(100, 'small.bin')]) + expect(await validator.validateSingle(formControl)).toBe(false) + }) + + it('fails validation for locale-formatted numbers', async () => { + formControl.dataset.maxFileSize = '1,000' + setInputFiles(formControl, [makeFile(100, 'small.bin')]) + expect(await validator.validateSingle(formControl)).toBe(false) + }) + + it('includes the invalid attribute value in the error message', async () => { + formControl.dataset.maxFileSize = '5MBX' + setInputFiles(formControl, [makeFile(100, 'small.bin')]) + await validator.validateSingle(formControl) + expect(validator.inputErrors[formControl.name]).toContain( + validator.messages.ERROR_FILE_MAX_SIZE.replace('${val}', '5MBX') + ) + }) + + it('logs debug warning for invalid max-file-size when debug is enabled', async () => { + const debugValidator = new Validator(form, { debug: true }) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + formControl.dataset.maxFileSize = 'invalid' + setInputFiles(formControl, [makeFile(100, 'small.bin')]) + await debugValidator.validateSingle(formControl) + expect(warnSpy).toHaveBeenCalledWith('Validator: Invalid max-file-size "invalid"') + warnSpy.mockRestore() + }) + + it('logs debug warning for invalid min-file-size when debug is enabled', async () => { + const debugValidator = new Validator(form, { debug: true }) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + formControl.dataset.minFileSize = 'invalid' + setInputFiles(formControl, [makeFile(100, 'small.bin')]) + await debugValidator.validateSingle(formControl) + expect(warnSpy).toHaveBeenCalledWith('Validator: Invalid min-file-size "invalid"') + warnSpy.mockRestore() + }) }) }) From 5c9724f52474bae256ee00756b5ef996112ed1a0 Mon Sep 17 00:00:00 2001 From: JD Lien Date: Mon, 26 Jan 2026 09:00:00 -0700 Subject: [PATCH 41/54] Update test setup and lockfile for validator-utils v2.0.0 - Simplify CSS.supports mock comments - Add more named colors to mock for comprehensive testing - Update pnpm-lock.yaml Signed-off-by: JD Lien --- pnpm-lock.yaml | 31 +++++++++++++++---------------- tests/setup.ts | 43 +++++++++++-------------------------------- 2 files changed, 26 insertions(+), 48 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc11e12..59fd092 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -108,9 +108,8 @@ packages: peerDependencies: '@csstools/css-tokenizer': ^3.0.4 - '@csstools/css-syntax-patches-for-csstree@1.0.25': - resolution: {integrity: sha512-g0Kw9W3vjx5BEBAF8c5Fm2NcB/Fs8jJXh85aXqwEXiL+tqtOut07TWgyaGzAAfTM+gKckrrncyeGEZPcaRgm2Q==} - engines: {node: '>=18'} + '@csstools/css-syntax-patches-for-csstree@1.0.26': + resolution: {integrity: sha512-6boXK0KkzT5u5xOgF6TKB+CLq9SOpEGmkZw0g5n9/7yg85wab3UzSxB8TxhLJ31L4SGJ6BCFRw/iftTha1CJXA==} '@csstools/css-tokenizer@3.0.4': resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} @@ -272,8 +271,8 @@ packages: cpu: [x64] os: [win32] - '@exodus/bytes@1.9.0': - resolution: {integrity: sha512-lagqsvnk09NKogQaN/XrtlWeUF8SRhT12odMvbTIIaVObqzwAogL6jhR4DAp0gPuKoM1AOVrKUshJpRdpMFrww==} + '@exodus/bytes@1.10.0': + resolution: {integrity: sha512-tf8YdcbirXdPnJ+Nd4UN1EXnz+IP2DI45YVEr3vvzcVTOyrApkmIB4zvOQVd3XPr7RXnfBtAx+PXImXOIU0Ajg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} peerDependencies: '@noble/hashes': ^1.8.0 || ^2.0.0 @@ -831,8 +830,8 @@ packages: resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} engines: {node: '>= 12.0.0'} - lru-cache@11.2.4: - resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} + lru-cache@11.2.5: + resolution: {integrity: sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==} engines: {node: 20 || >=22} magic-string@0.30.21: @@ -1173,7 +1172,7 @@ snapshots: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - lru-cache: 11.2.4 + lru-cache: 11.2.5 '@asamuzakjp/dom-selector@6.7.6': dependencies: @@ -1181,7 +1180,7 @@ snapshots: bidi-js: 1.0.3 css-tree: 3.1.0 is-potential-custom-element-name: 1.0.1 - lru-cache: 11.2.4 + lru-cache: 11.2.5 '@asamuzakjp/nwsapi@2.3.9': {} @@ -1218,7 +1217,7 @@ snapshots: dependencies: '@csstools/css-tokenizer': 3.0.4 - '@csstools/css-syntax-patches-for-csstree@1.0.25': {} + '@csstools/css-syntax-patches-for-csstree@1.0.26': {} '@csstools/css-tokenizer@3.0.4': {} @@ -1300,7 +1299,7 @@ snapshots: '@esbuild/win32-x64@0.27.2': optional: true - '@exodus/bytes@1.9.0': {} + '@exodus/bytes@1.10.0': {} '@jdlien/validator-utils@2.0.0': {} @@ -1586,9 +1585,9 @@ snapshots: cssstyle@5.3.7: dependencies: '@asamuzakjp/css-color': 4.1.1 - '@csstools/css-syntax-patches-for-csstree': 1.0.25 + '@csstools/css-syntax-patches-for-csstree': 1.0.26 css-tree: 3.1.0 - lru-cache: 11.2.4 + lru-cache: 11.2.5 data-urls@6.0.1: dependencies: @@ -1676,7 +1675,7 @@ snapshots: html-encoding-sniffer@6.0.0: dependencies: - '@exodus/bytes': 1.9.0 + '@exodus/bytes': 1.10.0 transitivePeerDependencies: - '@noble/hashes' @@ -1729,7 +1728,7 @@ snapshots: dependencies: '@acemir/cssom': 0.9.31 '@asamuzakjp/dom-selector': 6.7.6 - '@exodus/bytes': 1.9.0 + '@exodus/bytes': 1.10.0 cssstyle: 5.3.7 data-urls: 6.0.1 decimal.js: 10.6.0 @@ -1804,7 +1803,7 @@ snapshots: lightningcss-win32-arm64-msvc: 1.30.2 lightningcss-win32-x64-msvc: 1.30.2 - lru-cache@11.2.4: {} + lru-cache@11.2.5: {} magic-string@0.30.21: dependencies: diff --git a/tests/setup.ts b/tests/setup.ts index 00d8522..7c7b220 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1,9 +1,6 @@ -/** - * Global test setup for Vitest - * Mocks CSS.supports since jsdom doesn't implement it properly - */ +// Mock CSS.supports for jsdom (which doesn't implement it properly) +// Copied from validator-utils tests -// Mock CSS.supports for color validation tests const cssColorValidator = (prop: string, value: string): boolean => { if (prop !== 'color') return false @@ -19,39 +16,21 @@ const cssColorValidator = (prop: string, value: string): boolean => { return true // HSL/HSLA with comma syntax - if ( - /^hsla?\(\s*\d+(?:deg|grad|rad|turn)?\s*,\s*\d{1,3}%\s*,\s*\d{1,3}%\s*(,\s*[\d.]+%?)?\s*\)$/i.test( - value - ) - ) + if (/^hsla?\(\s*\d+(?:deg|grad|rad|turn)?\s*,\s*\d{1,3}%\s*,\s*\d{1,3}%\s*(,\s*[\d.]+%?)?\s*\)$/i.test(value)) return true // HSL/HSLA with space syntax - if ( - /^hsla?\(\s*\d+(?:deg|grad|rad|turn)?\s+\d{1,3}%\s+\d{1,3}%\s*(\s*\/\s*[\d.]+%?)?\s*\)$/i.test( - value - ) - ) + if (/^hsla?\(\s*\d+(?:deg|grad|rad|turn)?\s+\d{1,3}%\s+\d{1,3}%\s*(\s*\/\s*[\d.]+%?)?\s*\)$/i.test(value)) return true - // Named colors + // Named colors (includes all tested colors) const namedColors = new Set([ - 'transparent', - 'currentcolor', - 'red', - 'green', - 'blue', - 'yellow', - 'cyan', - 'magenta', - 'black', - 'white', - 'orange', - 'purple', - 'pink', - 'brown', - 'gray', - 'grey', + 'transparent', 'currentcolor', 'black', 'white', 'red', 'green', 'blue', 'yellow', + 'orange', 'purple', 'brown', 'pink', 'gray', 'grey', 'cyan', 'magenta', 'aqua', 'fuchsia', + 'lavenderblush', 'honeydew', 'seashell', 'azure', 'lavender', 'aliceblue', 'ghostwhite', + 'mintcream', 'oldlace', 'linen', 'cornsilk', 'papayawhip', 'beige', 'bisque', + 'blanchedalmond', 'wheat', 'navajowhite', 'peachpuff', 'moccasin', 'gainsboro', + 'lightgrey', 'lightgray', 'silver', 'darkgray', 'dimgray', ]) if (namedColors.has(value.toLowerCase())) return true From 99258b76453164cc4729ad8069ce3ea8150c250b Mon Sep 17 00:00:00 2001 From: JD Lien Date: Mon, 26 Jan 2026 15:35:12 -0700 Subject: [PATCH 42/54] Add custom validation examples and sidebar navigation to demo - Add GitHub username validation (async API check) - Add credit card Luhn checksum validation - Add sidebar nav with scroll-target-group for auto scroll-spy - Add section IDs for navigation anchors Signed-off-by: JD Lien --- demo/demo-src.css | 29 +++++++++++ demo/index.dev.html | 124 ++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 142 insertions(+), 11 deletions(-) diff --git a/demo/demo-src.css b/demo/demo-src.css index 76c4412..341b221 100644 --- a/demo/demo-src.css +++ b/demo/demo-src.css @@ -114,6 +114,14 @@ form-field { } @layer components { + .btn { + @apply bg-linear-to-b from-indigo-500 to-indigo-700 hover:from-indigo-400 hover:to-indigo-600 + active:from-indigo-600 active:to-indigo-800 text-white text-lg + w-60 py-2 my-4 rounded-lg border border-indigo-600/50 border-t-indigo-300/70 border-b-indigo-900/40 + shadow-md shadow-indigo-900/20 hover:shadow-lg hover:shadow-indigo-900/30 transition duration-150 + active:translate-y-0.5 [text-shadow:0_1px_1px_rgba(15,23,42,0.35)]; + } + .form-card { @apply rounded-xl border border-indigo-100/80 p-5 shadow-sm bg-linear-120 from-indigo-50/70 to-indigo-100/80; @apply dark:border-indigo-400/10 dark:from-indigo-950/60 dark:to-indigo-950/10; @@ -182,3 +190,24 @@ input:checked + .checked-label { .color-input-wrapper input[type='text']:focus { @apply ring-0; } + +/* ============================================ + Sidebar Navigation with scroll-target-group + ============================================ */ + +#section-nav { + /* Enable scroll-spy: highlights link for currently visible section */ + scroll-target-group: auto; +} + +.nav-link { + @apply block px-3 py-1.5 rounded-lg transition-colors duration-150; + @apply text-indigo-900/70 hover:text-indigo-900 hover:bg-indigo-200/50; + @apply dark:text-indigo-300/70 dark:hover:text-indigo-200 dark:hover:bg-indigo-800/30; +} + +/* Active state for browsers supporting :target-current (Chrome 136+) */ +.nav-link:target-current { + @apply text-indigo-900 bg-indigo-300/60 font-medium; + @apply dark:text-indigo-100 dark:bg-indigo-700/40; +} diff --git a/demo/index.dev.html b/demo/index.dev.html index 9f9408b..da34311 100644 --- a/demo/index.dev.html +++ b/demo/index.dev.html @@ -46,6 +46,23 @@

+ + +
@@ -77,7 +94,7 @@

-
+

Core Inputs

@@ -130,7 +147,7 @@

Core Inputs

-
+

Number Inputs

@@ -163,7 +180,7 @@

Number Inputs

-
+

Dates & Time

@@ -226,6 +243,16 @@

Dates & Time

hint="Datetime can be used to also include a time with a date" > + + Dates & Time

-
+

Contact & Formats

@@ -287,7 +314,7 @@

Contact & Formats

-
+

File Uploads

@@ -317,7 +344,7 @@

File Uploads

-
+

Selections

@@ -352,7 +379,36 @@

Selections

-
+
+
+

Custom Validation

+

+ Use data-validation to specify a custom function for sync or async + validation. Functions can return booleans, promises, or objects with custom error + messages. +

+
+
+ + + +
+
+ +

Long Form

@@ -373,10 +429,7 @@

Long Form

- +
@@ -386,6 +439,55 @@

Long Form

+
-
- - - -
-
-

- Validator has been applied to this form to help sanitize and validate user input.
- This form won't submit if any fields are invalid and helpful errors will be shown or - hidden as the user changes fields.
- With scrollToError: true, the page will scroll to the first error on submit. -

+
+ + + +
+
+

+ Validator help sanitize and validate input into this form. This form won't submit if any + fields are invalid. Instead, helpful errors will be shown or hidden as the user changes + fields. +

+

+ With scrollToError: true, the page will scroll to the first + error on submit. +

+
-
- Install this Validator through npm: -
- -
- - npm install @jdlien/validator - -
- -

- Source code and documentation at - github.com/jdlien/validator. -

-
-
-
-
-

Core Inputs

-

- Fields can be required, have a minimum or maximum length, or a pattern. Numeric inputs - won't even allow invalid values to be typed in. -

+
+
+ Install Validator through npm or pnpm: +
+ +
+ + npm install @jdlien/validator +
-
+ +

+ Source code and documentation at + github.com/jdlien/validator. +

+
+ + + Core Inputs type="text" data-max-length="3" > -
-
+ -
-
-

Number Inputs

-

- Numeric inputs prevent invalid values and support range constraints. -

-
-
+ Number Inputs max="100" hint="Value must be between 0 and 100 (uses data-min and data-max)" > -
-
+ -
-
-

Dates & Time

-

- Dates can be entered in flexible formats and normalized to the configured format. - These features are compatible with datepickers like - Flatpickr. -

-
-
+ Dates & Time placeholder="H:MM AM" hint="Anything resembling a time will be converted into the specified format" > -
-
+ -
-
-

Contact & Formats

-

- Common formats like URLs, phone numbers, and postal codes are validated and - normalized. -

-
-
+ Contact & Formats value="#888888" hint="The label will show a swatch of any valid color" > -
-
+ -
-
-

File Uploads

-

- File inputs support type, size, and count limits using accept and data attributes. -

-
-
+ File Uploads required hint="Single file of any kind, required." > -
-
+ -
-
-

Selections

-

- Selects, radio buttons, and checkbox groups can all be required. -

-
-
+ Selections required options="red,green,indigo" > -
-
+ -
-
-

Custom Validation

-

- Use data-validation to specify a custom function for sync or async - validation. Functions can return booleans, promises, or objects with custom error - messages. -

-
-
+ Custom Validation placeholder="4111 1111 1111 1111" hint="Validates using Luhn checksum (try 4111111111111111)" > -
-
+ -
-
-

Long Form

-

- Textarea elements can have min and max lengths as well as be required. -

-
-
+ Long Form max-length="50" placeholder="Between 2 and 50 characters" > -
-
+ -
- -
-
+
+ +
+ -

- If the form is invalid when submitted, 'form-error-main' will be displayed above. -

+

+ If the form is invalid when submitted, the form-error-main div + will be displayed above. +

- -
+ + // Wait for custom elements to be defined before initializing validator + await Promise.all([ + customElements.whenDefined('form-field'), + customElements.whenDefined('form-card'), + ]) + const form = document.getElementById('demo-form') + // scrollToError: true will scroll to and focus the first invalid input on validation failure + // Custom validators are passed via the validators option (instance registry) + const validator = new Validator(form, { + debug: true, + scrollToError: true, + scrollToErrorDelay: 200, + hiddenClasses: 'opacity-0 max-h-0 overflow-hidden', + errorMainClasses: + 'm-2 border border-pink-500 bg-pink-200/50 text-pink-700 p-3 py-4 dark:bg-pink-900/80 dark:text-pink-200 text-center rounded-lg', + errorInputClasses: 'border-pink-500 dark:border-pink-400', + validators: { + validateGitHubUser, + validateLuhn, + }, + }) + window.validator = validator // for debugging + +
+ +
+ © 2026 JD Lien +
diff --git a/demo/index.html b/demo/index.html index e1646bf..d494fe1 100644 --- a/demo/index.html +++ b/demo/index.html @@ -16,76 +16,96 @@ +
-
-
-
-

- Validator has been applied to this form to help sanitize and validate user input.
- This form won't submit if any fields are invalid and helpful errors will be shown or - hidden as the user changes fields.
- With scrollToError: true, the page will scroll to the first error on submit. -

- -
- Install this Validator through npm: +
+ + + +
+
+

+ Validator has been applied to this form to help sanitize and validate user input.
+ This form won't submit if any fields are invalid and helpful errors will be shown or + hidden as the user changes fields. +

+

+ With scrollToError: true, the page will scroll to the first + error on submit. +

-
- - npm install @jdlien/validator - -
+
+
+ Install Validator through npm or pnpm: +
-

- Source code and documentation at - github.com/jdlien/validator. -

-
-
-
-
-

Core Inputs

-

- Fields can be required, have a minimum or maximum length, or a pattern. Numeric inputs - won't even allow invalid values to be typed in. -

+
+ + npm install @jdlien/validator +
-
+ +

+ Source code and documentation at + github.com/jdlien/validator. +

+
+ + Core Inputs type="text" data-max-length="3" > -
-
- -
-
-

Number Inputs

-

- Numeric inputs prevent invalid values and support range constraints. -

-
-
+ + + Number Inputs max="100" hint="Value must be between 0 and 100 (uses data-min and data-max)" > -
-
- -
-
-

Dates & Time

-

- Dates can be entered in flexible formats and normalized to the configured format. - These features are compatible with datepickers like - Flatpickr. -

-
-
+ + + Dates & Time hint="Datetime can be used to also include a time with a date" > + + Dates & Time placeholder="H:MM AM" hint="Anything resembling a time will be converted into the specified format" > -
-
- -
-
-

Contact & Formats

-

- Common formats like URLs, phone numbers, and postal codes are validated and - normalized. -

-
-
+ + + Contact & Formats value="#888888" hint="The label will show a swatch of any valid color" > -
-
- -
-
-

File Uploads

-

- File inputs support type, size, and count limits using accept and data attributes. -

-
-
+ + + File Uploads required hint="Single file of any kind, required." > -
-
- -
-
-

Selections

-

- Selects, radio buttons, and checkbox groups can all be required. -

-
-
+ + + Selections required options="red,green,indigo" > -
-
- -
-
-

Long Form

-

- Textarea elements can have min and max lengths as well as be required. -

-
-
+ + + + + + + + + Long Form max-length="50" placeholder="Between 2 and 50 characters" > + + +
+
-
+
-
- -
- - -

- If the form is invalid when submitted, 'form-error-main' will be displayed above. -

- - - - -
+

+ If the form is invalid when submitted, the form-error-main div + will be displayed above. +

+ + + + +
+ +
+ © 2026 JD Lien +
diff --git a/package.json b/package.json index beb21f1..d503c04 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "preview": "vite preview", "test": "vitest", "coverage": "vitest --coverage", - "build:demo": "tsc demo/form-field.ts --outDir demo --target ES2020 --module ES2020 --skipLibCheck && tsc demo/dark-toggle.ts --outDir demo --target ES2020 --module ES2020 --skipLibCheck && node scripts/build-demo.mjs", + "build:demo": "tsc demo/form-field.ts --outDir demo --target ES2020 --module ES2020 --skipLibCheck && tsc demo/form-card.ts --outDir demo --target ES2020 --module ES2020 --skipLibCheck && tsc demo/dark-toggle.ts --outDir demo --target ES2020 --module ES2020 --skipLibCheck && node scripts/build-demo.mjs", "prepublishOnly": "pnpm build:lib" }, "repository": { @@ -67,6 +67,7 @@ "jsdom": "^27.4.0", "jsdom-global": "^3.0.2", "prettier": "^3.8.1", + "prettier-plugin-tailwindcss": "^0.7.2", "tailwindcss": "^4.1.18", "typescript": "^5.9.3", "undici-types": "^7.19.1", @@ -75,7 +76,7 @@ }, "sideEffects": false, "dependencies": { - "@jdlien/validator-utils": "^2.0.0" + "@jdlien/validator-utils": "^2.1.0" }, "pnpm": { "onlyBuiltDependencies": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 59fd092..f54f634 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@jdlien/validator-utils': - specifier: ^2.0.0 - version: 2.0.0 + specifier: ^2.1.0 + version: 2.1.0 devDependencies: '@tailwindcss/vite': specifier: ^4.1.18 @@ -33,6 +33,9 @@ importers: prettier: specifier: ^3.8.1 version: 3.8.1 + prettier-plugin-tailwindcss: + specifier: ^0.7.2 + version: 0.7.2(prettier@3.8.1) tailwindcss: specifier: ^4.1.18 version: 4.1.18 @@ -280,8 +283,8 @@ packages: '@noble/hashes': optional: true - '@jdlien/validator-utils@2.0.0': - resolution: {integrity: sha512-wrO5z1Sb4oMYYLOm0XWemJf7t+zAoxBVbRSEiNtAs0aBj61tnJfb9E/oGE3xp2K4fKMxBdOc8OEi0UaTeWOuHw==} + '@jdlien/validator-utils@2.1.0': + resolution: {integrity: sha512-7aO3ky05KOJZC8zJFh/C6obtOkfEWbsW9T7b9X7bNgHFahBbQ9KVQdldbBrsN0HS+gprVXHRHV/wLZx4xIhEew==} '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -906,6 +909,61 @@ packages: engines: {node: '>=10'} hasBin: true + prettier-plugin-tailwindcss@0.7.2: + resolution: {integrity: sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==} + engines: {node: '>=20.19'} + peerDependencies: + '@ianvs/prettier-plugin-sort-imports': '*' + '@prettier/plugin-hermes': '*' + '@prettier/plugin-oxc': '*' + '@prettier/plugin-pug': '*' + '@shopify/prettier-plugin-liquid': '*' + '@trivago/prettier-plugin-sort-imports': '*' + '@zackad/prettier-plugin-twig': '*' + prettier: ^3.0 + prettier-plugin-astro: '*' + prettier-plugin-css-order: '*' + prettier-plugin-jsdoc: '*' + prettier-plugin-marko: '*' + prettier-plugin-multiline-arrays: '*' + prettier-plugin-organize-attributes: '*' + prettier-plugin-organize-imports: '*' + prettier-plugin-sort-imports: '*' + prettier-plugin-svelte: '*' + peerDependenciesMeta: + '@ianvs/prettier-plugin-sort-imports': + optional: true + '@prettier/plugin-hermes': + optional: true + '@prettier/plugin-oxc': + optional: true + '@prettier/plugin-pug': + optional: true + '@shopify/prettier-plugin-liquid': + optional: true + '@trivago/prettier-plugin-sort-imports': + optional: true + '@zackad/prettier-plugin-twig': + optional: true + prettier-plugin-astro: + optional: true + prettier-plugin-css-order: + optional: true + prettier-plugin-jsdoc: + optional: true + prettier-plugin-marko: + optional: true + prettier-plugin-multiline-arrays: + optional: true + prettier-plugin-organize-attributes: + optional: true + prettier-plugin-organize-imports: + optional: true + prettier-plugin-sort-imports: + optional: true + prettier-plugin-svelte: + optional: true + prettier@3.8.1: resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} engines: {node: '>=14'} @@ -1301,7 +1359,7 @@ snapshots: '@exodus/bytes@1.10.0': {} - '@jdlien/validator-utils@2.0.0': {} + '@jdlien/validator-utils@2.1.0': {} '@jridgewell/gen-mapping@0.3.13': dependencies: @@ -1880,6 +1938,10 @@ snapshots: tar-fs: 2.1.4 tunnel-agent: 0.6.0 + prettier-plugin-tailwindcss@0.7.2(prettier@3.8.1): + dependencies: + prettier: 3.8.1 + prettier@3.8.1: {} pump@3.0.3: diff --git a/scripts/build-demo.mjs b/scripts/build-demo.mjs index 5cfd9fe..7afc63f 100644 --- a/scripts/build-demo.mjs +++ b/scripts/build-demo.mjs @@ -42,6 +42,7 @@ const devHtml = await readFile(devHtmlPath, 'utf8') let outHtml = devHtml .replace(/demo-src\.css/g, 'demo.css') .replace(/form-field\.ts/g, 'form-field.js') + .replace(/form-card\.ts/g, 'form-card.js') .replace(/dark-toggle\.ts/g, 'dark-toggle.js') outHtml = outHtml.replace( diff --git a/src/Validator.ts b/src/Validator.ts index f30090d..4a81d23 100755 --- a/src/Validator.ts +++ b/src/Validator.ts @@ -25,6 +25,8 @@ export interface ValidatorOptions { validateOnBlur?: boolean validationSuccessCallback?: (event: Event) => void validationErrorCallback?: (event: Event) => void + /** Custom validators available to this instance (highest priority lookup) */ + validators?: ValidatorRegistry } export interface InputHandler { @@ -39,6 +41,22 @@ export interface InputHandlers { export type ValidationEventType = 'validationSuccess' | 'validationError' +/** Result returned by a custom validator function */ +export interface ValidationResult { + valid: boolean + message?: string + messages?: string[] + error?: boolean +} + +/** A custom validator function that validates an input value */ +export type ValidatorFunction = ( + value: string +) => boolean | string | ValidationResult | Promise + +/** A registry mapping validator names to their functions */ +export type ValidatorRegistry = Record + export class ValidationEvent extends Event { constructor( type: ValidationEventType, @@ -49,19 +67,34 @@ export class ValidationEvent extends Event { } export default class Validator { + // Static (global) validator registry + private static globalValidators: ValidatorRegistry = {} + + /** Register a validator function globally (available to all instances) */ + public static registerValidator(name: string, fn: ValidatorFunction): void { + Validator.globalValidators[name] = fn + } + + /** Remove a globally registered validator */ + public static unregisterValidator(name: string): void { + delete Validator.globalValidators[name] + } + + /** Get a copy of all globally registered validators */ + public static getValidators(): Readonly { + return { ...Validator.globalValidators } + } + + /** Remove all globally registered validators */ + public static clearValidators(): void { + Validator.globalValidators = {} + } + form: HTMLFormElement inputs: FormControl[] = [] // Keeps track of error messages accumulated for each input inputErrors: { [key: string]: string[] } = {} - // Hoisted multiplier maps for parseBytes (avoid per-call allocation) - private static readonly SI_MULT: Record = { - '': 1, K: 1000, M: 1e6, G: 1e9, T: 1e12 - } - private static readonly BINARY_MULT: Record = { - '': 1, K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4 - } - // Default error messages. messages: Record = { ERROR_MAIN: 'There is a problem with your submission.', @@ -82,6 +115,7 @@ export default class Validator { ERROR_DATE: 'This is not a valid date.', ERROR_DATE_PAST: 'The date must be in the past.', ERROR_DATE_FUTURE: 'The date must be in the future.', + ERROR_DATE_TODAY: 'The date must be today.', ERROR_DATE_RANGE: 'The date is outside the allowed range.', ERROR_DATETIME: 'This is not a valid date and time.', ERROR_TIME: 'This is not a valid time.', @@ -128,6 +162,9 @@ export default class Validator { private validationSuccessCallback: (event: Event) => void private validationErrorCallback: (event: Event) => void + // Instance validator registry (highest priority lookup) + private validators: ValidatorRegistry = {} + // Sets defaults and adds event listeners constructor(form: HTMLFormElement, options: ValidatorOptions = {}) { if (!form) throw new Error('Validator requires a form to be passed as the first argument.') @@ -167,6 +204,11 @@ export default class Validator { this.validationSuccessCallback = options.validationSuccessCallback || (() => {}) this.validationErrorCallback = options.validationErrorCallback || (() => {}) + // Merge instance validators from options + if (options.validators) { + Object.assign(this.validators, options.validators) + } + if (this.autoInit) this.init() } @@ -619,12 +661,9 @@ export default class Validator { // only validate the date range if it's a valid date if (!isNaN(date.getTime()) && !utils.isDateInRange(date, range)) { let msg = el.dataset.errorDefault || this.messages.ERROR_DATE_RANGE - if (range === 'past') { - msg = this.messages.ERROR_DATE_PAST - } - if (range === 'future') { - msg = this.messages.ERROR_DATE_FUTURE - } + if (range === 'past') msg = this.messages.ERROR_DATE_PAST + else if (range === 'future') msg = this.messages.ERROR_DATE_FUTURE + else if (range === 'today') msg = this.messages.ERROR_DATE_TODAY this.addInputError(el, msg) return false } @@ -633,42 +672,6 @@ export default class Validator { return true } - // Parses human-readable byte strings: "500000", "5M", "5MB", "5MiB", "2GB", "1KiB", etc. - private parseBytes(value: string): number { - const str = value.trim() - // Accept: 500, 5B, 5K, 5KB, 5KiB, 5Ki, 5M, 5MB, 5MiB, etc. - const match = str.match(/^(\d+(?:\.\d+)?)\s*(B|Ki?B?|Mi?B?|Gi?B?|Ti?B?)?$/i) - if (!match) return NaN - - const num = Number.parseFloat(match[1]) - const rawUnit = match[2]?.toUpperCase() || '' - const isBinary = rawUnit.includes('I') - const prefix = rawUnit.replace(/I?B$/i, '').replace(/I$/i, '') || '' - const mult = isBinary ? Validator.BINARY_MULT : Validator.SI_MULT - return num * mult[prefix] - } - - // Formats bytes as human-readable string. decimal=true uses SI units (1000-based). - private formatBytes(bytes: number, decimal = true): string { - const base = decimal ? 1000 : 1024 - const units = ['B', 'KB', 'MB', 'GB', 'TB'] - if (bytes < base) return `${bytes} B` - - let i = 0 - let val = bytes - while (val >= base && i < units.length - 1) { - val /= base - i++ - } - let rounded = Math.round(val * 10) / 10 - // If rounding pushed us to next unit threshold, bump up - if (rounded >= base && i < units.length - 1) { - rounded = 1 - i++ - } - return `${rounded % 1 === 0 ? rounded.toFixed(0) : rounded.toFixed(1)} ${units[i]}` - } - private parseAcceptList(accept: string): { mimeTypes: string[]; extensions: string[] } { const mimeTypes: string[] = [] const extensions: string[] = [] @@ -701,17 +704,17 @@ export default class Validator { const minSizeAttr = el.dataset.minFileSize || '' if (minSizeAttr) { - const minSize = this.parseBytes(minSizeAttr) + const minSize = utils.parseBytes(minSizeAttr) if (Number.isNaN(minSize)) { if (this.debug) console.warn(`Validator: Invalid min-file-size "${minSizeAttr}"`) this.addInputError(el, this.messages.ERROR_FILE_MIN_SIZE.replace('${val}', minSizeAttr)) valid = false - } else if (minSize >= 0) { + } else { const tooSmall = files.some((file) => file.size < minSize) if (tooSmall) { this.addInputError( el, - this.messages.ERROR_FILE_MIN_SIZE.replace('${val}', this.formatBytes(minSize)) + this.messages.ERROR_FILE_MIN_SIZE.replace('${val}', utils.formatBytes(minSize)) ) valid = false } @@ -720,17 +723,17 @@ export default class Validator { const maxSizeAttr = el.dataset.maxFileSize || '' if (maxSizeAttr) { - const maxSize = this.parseBytes(maxSizeAttr) + const maxSize = utils.parseBytes(maxSizeAttr) if (Number.isNaN(maxSize)) { if (this.debug) console.warn(`Validator: Invalid max-file-size "${maxSizeAttr}"`) this.addInputError(el, this.messages.ERROR_FILE_MAX_SIZE.replace('${val}', maxSizeAttr)) valid = false - } else if (maxSize >= 0) { + } else { const tooLarge = files.some((file) => file.size > maxSize) if (tooLarge) { this.addInputError( el, - this.messages.ERROR_FILE_MAX_SIZE.replace('${val}', this.formatBytes(maxSize)) + this.messages.ERROR_FILE_MAX_SIZE.replace('${val}', utils.formatBytes(maxSize)) ) valid = false } @@ -790,6 +793,23 @@ export default class Validator { return true } + /** + * Resolves a validator function by name using three-tier lookup: + * 1. Instance registry (this.validators) + * 2. Static registry (Validator.globalValidators) + * 3. Window object (legacy fallback) + */ + private resolveValidator(name: string): ValidatorFunction | undefined { + // 1. Instance registry (highest priority) + if (this.validators[name]) return this.validators[name] + // 2. Static registry + if (Validator.globalValidators[name]) return Validator.globalValidators[name] + // 3. Window object (legacy fallback) + const windowFn = window[name as keyof Window] + if (typeof windowFn === 'function') return windowFn as ValidatorFunction + return undefined + } + /** * Specify a custom function in data-validation and it gets called to validate the input * The custom function can return @@ -805,8 +825,8 @@ export default class Validator { if (el.disabled) return true const validation = el.dataset.validation if (!validation || typeof validation !== 'string') return true - const validationFn: Function = window[validation as keyof Window] as Function - if (!validationFn || typeof validationFn !== 'function') return true + const validationFn = this.resolveValidator(validation) + if (!validationFn) return true let result: any try { diff --git a/tests/Validator.dateAndPattern.test.ts b/tests/Validator.dateAndPattern.test.ts index 4c52c80..8c5a6b9 100644 --- a/tests/Validator.dateAndPattern.test.ts +++ b/tests/Validator.dateAndPattern.test.ts @@ -55,6 +55,114 @@ describe('Validator', () => { formControl.value = '2093-01-01' expect((validator as any).validateDateRange(formControl)).toBe(true) }) + + describe('today keyword', () => { + it('should return true if the date is today', () => { + formControl.dataset.dateRange = 'today' + const today = new Date() + formControl.value = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}` + expect((validator as any).validateDateRange(formControl)).toBe(true) + }) + + it('should return false with ERROR_DATE_TODAY if the date is not today', () => { + formControl.dataset.dateRange = 'today' + formControl.value = '2020-01-01' + expect((validator as any).validateDateRange(formControl)).toBe(false) + expect(validator.inputErrors[formControl.name]).toContain(validator.messages.ERROR_DATE_TODAY) + }) + }) + + describe('specific date ranges', () => { + it('should return true if date is within range', () => { + formControl.dataset.dateRange = '2023-01-01:2023-12-31' + formControl.value = '2023-06-15' + expect((validator as any).validateDateRange(formControl)).toBe(true) + }) + + it('should return false if date is before range', () => { + formControl.dataset.dateRange = '2023-01-01:2023-12-31' + formControl.value = '2022-06-15' + expect((validator as any).validateDateRange(formControl)).toBe(false) + expect(validator.inputErrors[formControl.name]).toContain(validator.messages.ERROR_DATE_RANGE) + }) + + it('should return false if date is after range', () => { + formControl.dataset.dateRange = '2023-01-01:2023-12-31' + formControl.value = '2024-06-15' + expect((validator as any).validateDateRange(formControl)).toBe(false) + }) + + it('should include boundary dates', () => { + formControl.dataset.dateRange = '2023-01-01:2023-12-31' + formControl.value = '2023-01-01' + expect((validator as any).validateDateRange(formControl)).toBe(true) + formControl.value = '2023-12-31' + expect((validator as any).validateDateRange(formControl)).toBe(true) + }) + }) + + describe('open-ended ranges', () => { + it('should validate :DATE (on or before)', () => { + formControl.dataset.dateRange = ':2025-12-31' + formControl.value = '2020-01-01' + expect((validator as any).validateDateRange(formControl)).toBe(true) + formControl.value = '2026-01-01' + validator.inputErrors[formControl.name] = [] + expect((validator as any).validateDateRange(formControl)).toBe(false) + }) + + it('should validate DATE: (on or after)', () => { + formControl.dataset.dateRange = '2020-01-01:' + formControl.value = '2025-01-01' + expect((validator as any).validateDateRange(formControl)).toBe(true) + formControl.value = '2019-01-01' + validator.inputErrors[formControl.name] = [] + expect((validator as any).validateDateRange(formControl)).toBe(false) + }) + }) + + describe('relative offsets', () => { + it('should validate -30d:+30d (within 30 days)', () => { + formControl.dataset.dateRange = '-30d:+30d' + const today = new Date() + formControl.value = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}` + expect((validator as any).validateDateRange(formControl)).toBe(true) + }) + + it('should reject dates outside relative range', () => { + formControl.dataset.dateRange = '-30d:+30d' + formControl.value = '2020-01-01' // Well outside range + expect((validator as any).validateDateRange(formControl)).toBe(false) + }) + + it('should validate :-18y (18 years ago or earlier)', () => { + formControl.dataset.dateRange = ':-18y' + formControl.value = '2000-01-01' // More than 18 years ago + expect((validator as any).validateDateRange(formControl)).toBe(true) + }) + + it('should validate :+6m (up to 6 months from now)', () => { + formControl.dataset.dateRange = ':+6m' + const threeMonths = new Date() + threeMonths.setMonth(threeMonths.getMonth() + 3) + formControl.value = `${threeMonths.getFullYear()}-${String(threeMonths.getMonth() + 1).padStart(2, '0')}-${String(threeMonths.getDate()).padStart(2, '0')}` + expect((validator as any).validateDateRange(formControl)).toBe(true) + }) + }) + + describe('single date exact match', () => { + it('should return true for exact date match', () => { + formControl.dataset.dateRange = '2023-06-15' + formControl.value = '2023-06-15' + expect((validator as any).validateDateRange(formControl)).toBe(true) + }) + + it('should return false for non-matching date', () => { + formControl.dataset.dateRange = '2023-06-15' + formControl.value = '2023-06-16' + expect((validator as any).validateDateRange(formControl)).toBe(false) + }) + }) }) // end validateDateRange describe('validatePattern', () => { diff --git a/tests/Validator.fileValidation.test.ts b/tests/Validator.fileValidation.test.ts index a966b4d..522a7af 100644 --- a/tests/Validator.fileValidation.test.ts +++ b/tests/Validator.fileValidation.test.ts @@ -174,110 +174,7 @@ describe('Validator file validation', () => { expect(validator.inputErrors[formControl.name]).toContain(validator.messages.ERROR_FILE_TYPE) }) - describe('parseBytes', () => { - it('parses plain numbers', () => { - expect((validator as any).parseBytes('500000')).toBe(500000) - expect((validator as any).parseBytes('0')).toBe(0) - expect((validator as any).parseBytes('1.5')).toBe(1.5) - }) - - it('parses KB values', () => { - expect((validator as any).parseBytes('1K')).toBe(1000) - expect((validator as any).parseBytes('1KB')).toBe(1000) - expect((validator as any).parseBytes('5kb')).toBe(5000) - expect((validator as any).parseBytes('2.5KB')).toBe(2500) - }) - - it('parses MB values', () => { - expect((validator as any).parseBytes('1M')).toBe(1000000) - expect((validator as any).parseBytes('1MB')).toBe(1000000) - expect((validator as any).parseBytes('5mb')).toBe(5000000) - expect((validator as any).parseBytes('2.5MB')).toBe(2500000) - }) - - it('parses GB values', () => { - expect((validator as any).parseBytes('1G')).toBe(1000000000) - expect((validator as any).parseBytes('1GB')).toBe(1000000000) - expect((validator as any).parseBytes('2gb')).toBe(2000000000) - }) - - it('parses TB values', () => { - expect((validator as any).parseBytes('1T')).toBe(1000000000000) - expect((validator as any).parseBytes('1TB')).toBe(1000000000000) - }) - - it('parses B suffix', () => { - expect((validator as any).parseBytes('500B')).toBe(500) - expect((validator as any).parseBytes('500b')).toBe(500) - }) - - it('handles whitespace', () => { - expect((validator as any).parseBytes(' 5MB ')).toBe(5000000) - expect((validator as any).parseBytes('5 MB')).toBe(5000000) - }) - - it('returns NaN for invalid values', () => { - expect((validator as any).parseBytes('')).toBeNaN() - expect((validator as any).parseBytes('abc')).toBeNaN() - expect((validator as any).parseBytes('MB')).toBeNaN() - }) - - it('parses binary KiB/MiB/GiB/TiB values', () => { - expect((validator as any).parseBytes('1KiB')).toBe(1024) - expect((validator as any).parseBytes('1Ki')).toBe(1024) - expect((validator as any).parseBytes('1MiB')).toBe(1048576) - expect((validator as any).parseBytes('1GiB')).toBe(1073741824) - expect((validator as any).parseBytes('1TiB')).toBe(1099511627776) - }) - - it('parses mixed case binary units', () => { - expect((validator as any).parseBytes('5kib')).toBe(5 * 1024) - expect((validator as any).parseBytes('5KIB')).toBe(5 * 1024) - expect((validator as any).parseBytes('2.5MiB')).toBe(2.5 * 1024 * 1024) - }) - }) - - describe('formatBytes', () => { - it('formats bytes for values under 1 KB (decimal)', () => { - expect((validator as any).formatBytes(0)).toBe('0 B') - expect((validator as any).formatBytes(1)).toBe('1 B') - expect((validator as any).formatBytes(512)).toBe('512 B') - expect((validator as any).formatBytes(999)).toBe('999 B') - }) - - it('formats kilobytes for values 1 KB and above (decimal)', () => { - expect((validator as any).formatBytes(1000)).toBe('1 KB') - expect((validator as any).formatBytes(1500)).toBe('1.5 KB') - expect((validator as any).formatBytes(10000)).toBe('10 KB') - }) - - it('formats 999999 bytes as 1 MB not 1000 KB (boundary rounding)', () => { - expect((validator as any).formatBytes(999999)).toBe('1 MB') - }) - - it('formats megabytes for values 1 MB and above (decimal)', () => { - expect((validator as any).formatBytes(1000000)).toBe('1 MB') - expect((validator as any).formatBytes(1500000)).toBe('1.5 MB') - expect((validator as any).formatBytes(10000000)).toBe('10 MB') - expect((validator as any).formatBytes(100000000)).toBe('100 MB') - }) - - it('formats gigabytes for values 1 GB and above (decimal)', () => { - expect((validator as any).formatBytes(1000000000)).toBe('1 GB') - expect((validator as any).formatBytes(2500000000)).toBe('2.5 GB') - }) - - it('formats terabytes for values 1 TB and above (decimal)', () => { - expect((validator as any).formatBytes(1000000000000)).toBe('1 TB') - }) - - it('uses binary mode when decimal=false', () => { - expect((validator as any).formatBytes(1024, false)).toBe('1 KB') - expect((validator as any).formatBytes(1536, false)).toBe('1.5 KB') - expect((validator as any).formatBytes(1048576, false)).toBe('1 MB') - expect((validator as any).formatBytes(1073741824, false)).toBe('1 GB') - }) - + describe('human-readable size attributes', () => { it('displays human-readable sizes in error messages', async () => { // Test KB range formControl.dataset.maxFileSize = '5000' @@ -292,9 +189,6 @@ describe('Validator file validation', () => { await validator.validateSingle(formControl) expect(validator.inputErrors[formControl.name]).toContain('Each file must be 1 MB or smaller.') }) - }) - - describe('human-readable size attributes', () => { it('accepts human-readable max-file-size values', async () => { formControl.dataset.maxFileSize = '5MB' setInputFiles(formControl, [makeFile(4000000, 'ok.bin', 'application/octet-stream')]) diff --git a/tests/Validator.validateCustom.test.ts b/tests/Validator.validateCustom.test.ts index e9d8c14..1a333c5 100644 --- a/tests/Validator.validateCustom.test.ts +++ b/tests/Validator.validateCustom.test.ts @@ -1,6 +1,7 @@ import Validator from '../src/Validator' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { setupTestForm } from './utils/setup' + describe('Validator', () => { let form: HTMLFormElement let formControl: HTMLInputElement @@ -12,6 +13,8 @@ describe('Validator', () => { afterEach(() => { document.body.removeChild(form) + // Clean up static registry between tests + Validator.clearValidators() }) let validationFnTrue = vi.fn(() => true) @@ -111,4 +114,231 @@ describe('Validator', () => { expect(result).toBe(true) }) }) // end describe('validateCustom') + + describe('Instance validator registry', () => { + it('uses validator from instance options', async () => { + const instanceValidator = vi.fn(() => true) + document.body.removeChild(form) + ;({ form, formControl, validator } = setupTestForm({ + validators: { myInstanceValidator: instanceValidator }, + })) + formControl.dataset.validation = 'myInstanceValidator' + formControl.value = 'test' + + const result = await (validator as any).validateCustom(formControl) + expect(result).toBe(true) + expect(instanceValidator).toHaveBeenCalledWith('test') + }) + + it('returns custom error message from instance validator', async () => { + const instanceValidator = vi.fn(() => ({ valid: false, message: 'Instance error' })) + document.body.removeChild(form) + ;({ form, formControl, validator } = setupTestForm({ + validators: { myValidator: instanceValidator }, + })) + formControl.dataset.validation = 'myValidator' + formControl.value = 'test' + + const result = await (validator as any).validateCustom(formControl) + expect(result).toBe(false) + expect(validator.inputErrors[formControl.name]).toContain('Instance error') + }) + + it('supports async validators in instance registry', async () => { + const asyncValidator = vi.fn( + () => new Promise((resolve) => setTimeout(() => resolve({ valid: true }), 10)) + ) + document.body.removeChild(form) + ;({ form, formControl, validator } = setupTestForm({ + validators: { asyncValidator }, + })) + formControl.dataset.validation = 'asyncValidator' + formControl.value = 'test' + + const result = await (validator as any).validateCustom(formControl) + expect(result).toBe(true) + expect(asyncValidator).toHaveBeenCalled() + }) + + it('isolates validators between instances', async () => { + const validator1Fn = vi.fn(() => ({ valid: false, message: 'Validator 1' })) + const validator2Fn = vi.fn(() => ({ valid: false, message: 'Validator 2' })) + + // Create two separate forms with different validators + const form1 = document.createElement('form') + document.body.appendChild(form1) + const input1 = document.createElement('input') + input1.name = 'input1' + input1.dataset.validation = 'sharedName' + input1.value = 'test' + form1.appendChild(input1) + const error1 = document.createElement('div') + error1.id = 'input1-error' + form1.appendChild(error1) + + const form2 = document.createElement('form') + document.body.appendChild(form2) + const input2 = document.createElement('input') + input2.name = 'input2' + input2.dataset.validation = 'sharedName' + input2.value = 'test' + form2.appendChild(input2) + const error2 = document.createElement('div') + error2.id = 'input2-error' + form2.appendChild(error2) + + const v1 = new Validator(form1, { validators: { sharedName: validator1Fn } }) + const v2 = new Validator(form2, { validators: { sharedName: validator2Fn } }) + + await (v1 as any).validateCustom(input1) + await (v2 as any).validateCustom(input2) + + expect(v1.inputErrors['input1']).toContain('Validator 1') + expect(v2.inputErrors['input2']).toContain('Validator 2') + + document.body.removeChild(form1) + document.body.removeChild(form2) + }) + }) + + describe('Static validator registry', () => { + it('registerValidator makes validator available', async () => { + const staticValidator = vi.fn(() => true) + Validator.registerValidator('staticValidator', staticValidator) + + formControl.dataset.validation = 'staticValidator' + formControl.value = 'test' + + const result = await (validator as any).validateCustom(formControl) + expect(result).toBe(true) + expect(staticValidator).toHaveBeenCalledWith('test') + }) + + it('unregisterValidator removes validator', async () => { + const staticValidator = vi.fn(() => false) + Validator.registerValidator('toRemove', staticValidator) + Validator.unregisterValidator('toRemove') + + formControl.dataset.validation = 'toRemove' + formControl.value = 'test' + + // Should return true because validator not found + const result = await (validator as any).validateCustom(formControl) + expect(result).toBe(true) + expect(staticValidator).not.toHaveBeenCalled() + }) + + it('getValidators returns copy of all validators', () => { + const fn1 = vi.fn(() => true) + const fn2 = vi.fn(() => false) + Validator.registerValidator('fn1', fn1) + Validator.registerValidator('fn2', fn2) + + const validators = Validator.getValidators() + expect(validators.fn1).toBe(fn1) + expect(validators.fn2).toBe(fn2) + + // Verify it's a copy (can't modify original) + ;(validators as any).fn3 = vi.fn() + expect(Validator.getValidators().fn3).toBeUndefined() + }) + + it('clearValidators removes all validators', async () => { + const fn = vi.fn(() => false) + Validator.registerValidator('toClear', fn) + Validator.clearValidators() + + formControl.dataset.validation = 'toClear' + formControl.value = 'test' + + const result = await (validator as any).validateCustom(formControl) + expect(result).toBe(true) + expect(fn).not.toHaveBeenCalled() + }) + }) + + describe('Lookup priority', () => { + it('instance takes priority over static', async () => { + const instanceFn = vi.fn(() => ({ valid: false, message: 'Instance wins' })) + const staticFn = vi.fn(() => ({ valid: false, message: 'Static loses' })) + + Validator.registerValidator('priorityTest', staticFn) + document.body.removeChild(form) + ;({ form, formControl, validator } = setupTestForm({ + validators: { priorityTest: instanceFn }, + })) + formControl.dataset.validation = 'priorityTest' + formControl.value = 'test' + + await (validator as any).validateCustom(formControl) + expect(instanceFn).toHaveBeenCalled() + expect(staticFn).not.toHaveBeenCalled() + expect(validator.inputErrors[formControl.name]).toContain('Instance wins') + }) + + it('static takes priority over window', async () => { + const staticFn = vi.fn(() => ({ valid: false, message: 'Static wins' })) + const windowFn = vi.fn(() => ({ valid: false, message: 'Window loses' })) + window['windowPriorityTest'] = windowFn + + Validator.registerValidator('windowPriorityTest', staticFn) + formControl.dataset.validation = 'windowPriorityTest' + formControl.value = 'test' + + await (validator as any).validateCustom(formControl) + expect(staticFn).toHaveBeenCalled() + expect(windowFn).not.toHaveBeenCalled() + expect(validator.inputErrors[formControl.name]).toContain('Static wins') + + delete (window as any)['windowPriorityTest'] + }) + + it('falls back to window when not found elsewhere', async () => { + const windowFn = vi.fn(() => ({ valid: false, message: 'Window fallback' })) + window['windowFallback'] = windowFn + + formControl.dataset.validation = 'windowFallback' + formControl.value = 'test' + + await (validator as any).validateCustom(formControl) + expect(windowFn).toHaveBeenCalled() + expect(validator.inputErrors[formControl.name]).toContain('Window fallback') + + delete (window as any)['windowFallback'] + }) + }) + + describe('Edge cases', () => { + it('handles throwing validators gracefully', async () => { + const throwingValidator = vi.fn(() => { + throw new Error('Validator exploded') + }) + Validator.registerValidator('throwingValidator', throwingValidator) + + formControl.dataset.validation = 'throwingValidator' + formControl.value = 'test' + + const result = await (validator as any).validateCustom(formControl) + expect(result).toBe(false) + expect(validator.inputErrors[formControl.name]).toContain( + validator.messages.ERROR_CUSTOM_VALIDATION + ) + }) + + it('handles rejecting async validators gracefully', async () => { + const rejectingValidator = vi.fn( + () => new Promise((_, reject) => setTimeout(() => reject(new Error('Async fail')), 10)) + ) + Validator.registerValidator('rejectingValidator', rejectingValidator) + + formControl.dataset.validation = 'rejectingValidator' + formControl.value = 'test' + + const result = await (validator as any).validateCustom(formControl) + expect(result).toBe(false) + expect(validator.inputErrors[formControl.name]).toContain( + validator.messages.ERROR_CUSTOM_VALIDATION + ) + }) + }) }) // end describe('Validator') From 541880777872e5d06e7f307155699f14dd429d7d Mon Sep 17 00:00:00 2001 From: JD Lien Date: Mon, 26 Jan 2026 18:49:32 -0700 Subject: [PATCH 44/54] feat: style tweaks for form elements Signed-off-by: JD Lien --- demo/demo-src.css | 42 ++++++++++++++++-------------------------- 1 file changed, 16 insertions(+), 26 deletions(-) diff --git a/demo/demo-src.css b/demo/demo-src.css index 318a01a..07eb347 100644 --- a/demo/demo-src.css +++ b/demo/demo-src.css @@ -36,12 +36,9 @@ form-card { select[multiple], textarea, select { - @apply border border-zinc-300 rounded-lg px-3 py-2 text-zinc-700 - placeholder-zinc-500/50 shadow-inner transition-colors duration-200 - focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-400/50 - focus:ring-offset-0 focus:ring-offset-transparent inset-shadow-sm; + @apply rounded-lg border border-zinc-300 px-3 py-2 text-zinc-700 placeholder-zinc-500/50 shadow-inner inset-shadow-sm transition-colors duration-200 focus:bg-white focus:ring-2 focus:ring-indigo-400/50 focus:ring-offset-0 focus:ring-offset-transparent focus:outline-none; - @apply bg-linear-to-b dark:from-black/85 dark:to-black/75 dark:text-white dark:focus:bg-zinc-800 dark:border-zinc-700/60; + @apply bg-linear-to-b dark:border-zinc-700/60 dark:from-black/85 dark:to-black/75 dark:text-white dark:focus:bg-zinc-800; @apply disabled:bg-zinc-200 disabled:text-zinc-600/50 dark:disabled:bg-zinc-200/40 dark:disabled:text-zinc-400; } @@ -63,14 +60,12 @@ form-card { select[multiple]:not([disabled]), textarea:not([disabled]), select:not([disabled]) { - @apply hover:bg-white dark:hover:bg-zinc-800 bg-zinc-50 dark:bg-indigo-900; + @apply bg-zinc-50 hover:bg-white dark:bg-indigo-900 dark:hover:bg-zinc-800; } input[type='checkbox'], input[type='radio'] { - @apply accent-indigo-500 border border-zinc-300 bg-zinc-50 text-indigo-600 shadow-sm - checked:border checked:border-zinc-400 checked:bg-indigo-600 focus:outline-none - focus:ring-2 focus:ring-indigo-400/50 focus:ring-offset-0 focus:ring-offset-transparent focus:checked:bg-indigo-500; + @apply border border-zinc-300 bg-zinc-50 text-indigo-600 accent-indigo-500 shadow-sm checked:border checked:border-zinc-400 checked:bg-indigo-600 focus:ring-2 focus:ring-indigo-400/50 focus:ring-offset-0 focus:ring-offset-transparent focus:outline-none focus:checked:bg-indigo-500; @apply dark:border-zinc-500 dark:bg-zinc-400 dark:checked:bg-indigo-600 dark:focus:checked:bg-indigo-400; @@ -82,8 +77,7 @@ form-card { input[type='checkbox']:not([disabled]), input[type='radio']:not([disabled]) { - @apply hover:bg-zinc-200 checked:hover:bg-indigo-500 - dark:hover:bg-zinc-300 dark:hover:checked:bg-indigo-400 dark:hover:focus:checked:bg-indigo-400; + @apply hover:bg-zinc-200 checked:hover:bg-indigo-500 dark:hover:bg-zinc-300 dark:hover:checked:bg-indigo-400 dark:hover:focus:checked:bg-indigo-400; } input[type='radio'] { @@ -91,7 +85,7 @@ form-card { } select { - @apply pr-6 appearance-none; + @apply appearance-none border-zinc-300 pr-6 dark:border-zinc-700/60; /* Chevron + gradient as multiple backgrounds (chevron on top) */ background-image: url('data:image/svg+xml,%3Csvg xmlns=%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22 fill=%22none%22 viewBox=%220 0 24 24%22 stroke-width=%221.5%22 stroke=%22rgb(120 113 108)%22%3E%3Cpath stroke-linecap=%22round%22 stroke-linejoin=%22round%22 d=%22M19.5 8.25l-7.5 7.5-7.5-7.5%22 %2F%3E%3C%2Fsvg%3E'), @@ -115,11 +109,7 @@ form-card { @layer components { .btn { - @apply bg-linear-to-b from-indigo-500 to-indigo-700 hover:from-indigo-400 hover:to-indigo-600 - active:from-indigo-600 active:to-indigo-800 text-white text-lg - w-60 py-2 my-4 rounded-lg border border-indigo-600/50 border-t-indigo-300/70 border-b-indigo-900/40 - shadow-md shadow-indigo-900/20 hover:shadow-lg hover:shadow-indigo-900/30 transition duration-150 - active:translate-y-0.5 [text-shadow:0_1px_1px_rgba(15,23,42,0.35)]; + @apply my-4 w-60 rounded-lg border border-indigo-600/50 border-t-indigo-300/70 border-b-indigo-900/40 bg-linear-to-b from-indigo-500 to-indigo-700 py-2 text-lg text-white shadow-md shadow-indigo-900/20 transition duration-150 [text-shadow:0_1px_1px_rgba(15,23,42,0.35)] hover:from-indigo-400 hover:to-indigo-600 hover:shadow-lg hover:shadow-indigo-900/30 active:translate-y-0.5 active:from-indigo-600 active:to-indigo-800; } .code { @@ -129,7 +119,7 @@ form-card { /* Component utilities using Tailwind v4 @utility API */ .checked-label { - @apply ml-1.5 mr-1; + @apply mr-1 ml-1.5; } /* Styles for checked-label when preceded by a checked input */ @@ -140,8 +130,8 @@ input:checked + .checked-label { /* Apply checked-border to labels for checkboxes and radio buttons for a label that colors up automatically when checked */ .checked-border { - @apply inline-flex items-center border border-black/20 bg-linear-to-b dark:from-black/70 dark:to-black/60 dark:bg-indigo-900 px-1 text-zinc-700 shadow-inner rounded; - @apply hover:bg-white/50 dark:border-indigo-300/25 dark:text-zinc-100 dark:hover:bg-zinc-800 dark:focus:bg-zinc-800; + @apply inline-flex items-center rounded-lg border border-black/20 bg-linear-to-b px-2 py-1 text-zinc-700 shadow-inner dark:bg-indigo-900 dark:from-black/70 dark:to-black/60; + @apply hover:bg-white/50 dark:border-indigo-300/25 dark:text-zinc-100 dark:hover:bg-zinc-800 dark:focus:bg-zinc-800; } /* Focus ring on the label when any child input is focused */ @@ -165,7 +155,7 @@ input:checked + .checked-label { /* Error message transitions */ .error { - @apply transition-all duration-200 ease-out max-h-20; + @apply max-h-20 transition-all duration-200 ease-out; } .error.opacity-0 { @@ -247,13 +237,13 @@ input:checked + .checked-label { } .nav-link { - @apply block px-3 py-1.5 rounded-lg transition-colors duration-150; - @apply text-indigo-900/70 hover:text-indigo-900 hover:bg-indigo-200/50; - @apply dark:text-indigo-300/70 dark:hover:text-indigo-200 dark:hover:bg-indigo-800/30; + @apply block rounded-lg px-3 py-1.5 transition-colors duration-150; + @apply text-indigo-900/70 hover:bg-indigo-200/50 hover:text-indigo-900; + @apply dark:text-indigo-300/70 dark:hover:bg-indigo-800/30 dark:hover:text-indigo-200; } /* Active state for browsers supporting :target-current (Chrome 136+) */ .nav-link:target-current { - @apply text-indigo-900 bg-indigo-300/60 font-medium; - @apply dark:text-indigo-100 dark:bg-indigo-700/40; + @apply bg-indigo-300/60 font-medium text-indigo-900; + @apply dark:bg-indigo-700/40 dark:text-indigo-100; } From 8f3e608d2b26f649b5bb117626f15374789ef07e Mon Sep 17 00:00:00 2001 From: JD Lien Date: Mon, 26 Jan 2026 18:49:48 -0700 Subject: [PATCH 45/54] feat: style tweaks for cards Signed-off-by: JD Lien --- demo/form-card.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/demo/form-card.ts b/demo/form-card.ts index 7094ea9..3c1d265 100644 --- a/demo/form-card.ts +++ b/demo/form-card.ts @@ -21,6 +21,9 @@ class FormCard extends HTMLElement { } private render(): void { + // Guard against double render + if (this.querySelector(':scope > section')) return + const title = this.getAttribute('title') const lead = this.getAttribute('lead') const sectionId = this.getAttribute('id') @@ -31,17 +34,20 @@ class FormCard extends HTMLElement { // Create section element const section = document.createElement('section') if (sectionId) section.id = sectionId - section.className = - 'space-y-4 scroll-mt-4 rounded-xl border border-indigo-100/80 bg-linear-120 from-indigo-50/70 to-indigo-100/80 p-5 shadow-sm dark:border-indigo-400/10 dark:from-indigo-950/60 dark:to-indigo-950/10' + section.className = `space-y-4 scroll-mt-4 rounded-xl border border-indigo-100/80 + bg-linear-120 from-indigo-50/70 to-indigo-100/80 shadow-sm dark:border-indigo-400/10 + dark:from-indigo-950/60 dark:to-indigo-950/10` // Only add header if title or lead exists if (title || lead) { const header = document.createElement('div') + header.className = + 'rounded-t-xl mb-8 p-5 pt-4 border-b border-indigo-600/10 bg-linear-180 from-white/20 to-white/50 dark:from-transparent dark:to-black/15' if (title) { const h2 = document.createElement('h2') h2.className = - 'text-lg font-semibold tracking-wide text-indigo-800/90 dark:text-indigo-200/90' + 'text-lg font-semibold tracking-wide text-indigo-800/90 dark:text-indigo-200/90 mb-2' h2.textContent = title header.appendChild(h2) } @@ -50,6 +56,7 @@ class FormCard extends HTMLElement { const p = document.createElement('p') p.className = 'text-sm leading-relaxed text-indigo-900/70 dark:text-indigo-200/70' p.innerHTML = lead + header.appendChild(p) } @@ -58,7 +65,7 @@ class FormCard extends HTMLElement { // Create content wrapper and move children const content = document.createElement('div') - content.className = 'space-y-3' + content.className = 'space-y-3 px-5 pb-5' // Move all child nodes to content wrapper while (this.firstChild) { From d85037c749098454e54717adb3eeb35fad6a8cba Mon Sep 17 00:00:00 2001 From: JD Lien Date: Mon, 26 Jan 2026 18:50:06 -0700 Subject: [PATCH 46/54] style: use semantic tags Signed-off-by: JD Lien --- demo/index.html | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/demo/index.html b/demo/index.html index d494fe1..ffb49ec 100644 --- a/demo/index.html +++ b/demo/index.html @@ -68,19 +68,19 @@

-
+

- Validator has been applied to this form to help sanitize and validate user input.
- This form won't submit if any fields are invalid and helpful errors will be shown or - hidden as the user changes fields. + Validator help sanitize and validate input into this form. This form won't submit if any + fields are invalid. Instead, helpful errors will be shown or hidden as the user changes + fields.

With scrollToError: true, the page will scroll to the first error on submit.

-
+ -
+
Install Validator through npm or pnpm:
@@ -99,7 +99,8 @@

>github.com/jdlien/validator.

-

+ +
Date: Mon, 26 Jan 2026 18:50:36 -0700 Subject: [PATCH 47/54] fix: max-w classes Signed-off-by: JD Lien --- demo/form-field.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/demo/form-field.ts b/demo/form-field.ts index e079f9e..6cd9e86 100644 --- a/demo/form-field.ts +++ b/demo/form-field.ts @@ -293,7 +293,7 @@ class FormField extends HTMLElement { ...validationAttrs, }) - const wrapper = el('div', { className: 'flex relative sm:max-w-sm' }, [input]) + const wrapper = el('div', { className: 'flex relative' }, [input]) this.appendChild(this.buildWrapper(id, label, wrapper)) } @@ -332,11 +332,10 @@ class FormField extends HTMLElement { [colorInput] ) - const wrapper = el( - 'div', - { className: 'color-input-wrapper flex relative sm:max-w-sm rounded-lg' }, - [textInput, colorLabel] - ) + const wrapper = el('div', { className: 'color-input-wrapper flex relative rounded-lg' }, [ + textInput, + colorLabel, + ]) this.appendChild(this.buildWrapper(id, label, wrapper)) // Set up color sync with direct references (no querying needed!) @@ -407,7 +406,7 @@ class FormField extends HTMLElement { const select = el( 'select', { - className: 'block w-full sm:max-w-sm', + className: 'block w-full', id, name, 'aria-describedby': `${id}-error`, @@ -452,7 +451,7 @@ class FormField extends HTMLElement { }) const wrapper = el('div', { className: 'flex relative sm:max-w-sm' }, [ - el('div', { className: 'mb-3 sm:col-span-2 space-y-3' }, optionElements), + el('div', { className: 'mb-3 sm:col-span-2 space-y-4' }, optionElements), ]) this.appendChild(this.buildWrapper(id, label, wrapper)) From 703e3ce4fb8c80dc82ca72812bd963c9564a309a Mon Sep 17 00:00:00 2001 From: JD Lien Date: Mon, 26 Jan 2026 19:53:59 -0700 Subject: [PATCH 48/54] feat: style tweaks to demo form Signed-off-by: JD Lien --- demo/demo-src.css | 60 ++++++++++++++++++++++++++++++++++++---------- demo/file-drop.ts | 3 +++ demo/form-card.ts | 2 +- demo/form-field.ts | 2 +- 4 files changed, 52 insertions(+), 15 deletions(-) diff --git a/demo/demo-src.css b/demo/demo-src.css index 07eb347..c47b094 100644 --- a/demo/demo-src.css +++ b/demo/demo-src.css @@ -63,25 +63,59 @@ form-card { @apply bg-zinc-50 hover:bg-white dark:bg-indigo-900 dark:hover:bg-zinc-800; } - input[type='checkbox'], + input[type='checkbox'] { + @apply h-4 w-4 appearance-none rounded-[0.3rem] border border-zinc-400 bg-zinc-50 shadow-sm transition; + @apply checked:border checked:border-indigo-600; + @apply focus-visible:ring-2 focus-visible:ring-indigo-400/50 focus-visible:ring-offset-0 focus-visible:outline-none; + } + input[type='radio'] { - @apply border border-zinc-300 bg-zinc-50 text-indigo-600 accent-indigo-500 shadow-sm checked:border checked:border-zinc-400 checked:bg-indigo-600 focus:ring-2 focus:ring-indigo-400/50 focus:ring-offset-0 focus:ring-offset-transparent focus:outline-none focus:checked:bg-indigo-500; + @apply h-4 w-4 appearance-none rounded-full border border-zinc-400 bg-zinc-50 shadow-sm transition; + @apply focus-visible:ring-2 focus-visible:ring-indigo-400/50 focus-visible:ring-offset-0 focus-visible:outline-none; + } + + input[type='checkbox']:not([disabled]), + input[type='radio']:not([disabled]) { + @apply hover:border-indigo-300; + } - @apply dark:border-zinc-500 dark:bg-zinc-400 dark:checked:bg-indigo-600 dark:focus:checked:bg-indigo-400; + input[type='checkbox']:not([disabled]):not(:checked), + input[type='radio']:not([disabled]):not(:checked) { + @apply hover:bg-indigo-50 dark:hover:bg-indigo-100; + } - @apply disabled:opacity-60 disabled:checked:hover:bg-indigo-600; + input[type='checkbox']:checked { + @apply border-indigo-600 bg-indigo-600 text-white; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' aria-hidden='true'%3E%3Cpath fill='none' stroke='white' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round' d='M1.73%2012.91l6.37%206.37L22.79%204.59'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: center; + background-size: 70%; + } - /* Prevents border change when disabled and hovered */ - @apply disabled:checked:hover:border-zinc-400 disabled:dark:checked:bg-indigo-600 dark:disabled:checked:hover:border-zinc-500; + input[type='checkbox']:checked:hover { + @apply border-indigo-500 bg-indigo-500; } - input[type='checkbox']:not([disabled]), - input[type='radio']:not([disabled]) { - @apply hover:bg-zinc-200 checked:hover:bg-indigo-500 dark:hover:bg-zinc-300 dark:hover:checked:bg-indigo-400 dark:hover:focus:checked:bg-indigo-400; + input[type='radio']:checked { + background-image: radial-gradient( + circle at center, + var(--color-indigo-500) 0 40%, + transparent 45% + ); } - input[type='radio'] { - @apply rounded-full; + input[type='radio']:checked:hover { + @apply border-indigo-400; + background-image: radial-gradient( + circle at center, + var(--color-indigo-400) 0 40%, + transparent 45% + ); + } + + input[type='checkbox']:disabled, + input[type='radio']:disabled { + @apply cursor-not-allowed opacity-60; } select { @@ -130,7 +164,7 @@ input:checked + .checked-label { /* Apply checked-border to labels for checkboxes and radio buttons for a label that colors up automatically when checked */ .checked-border { - @apply inline-flex items-center rounded-lg border border-black/20 bg-linear-to-b px-2 py-1 text-zinc-700 shadow-inner dark:bg-indigo-900 dark:from-black/70 dark:to-black/60; + @apply inline-flex items-center rounded-lg border border-black/20 bg-linear-to-b px-1.5 py-1 text-zinc-700 shadow-inner dark:bg-indigo-900 dark:from-black/70 dark:to-black/60; @apply hover:bg-white/50 dark:border-indigo-300/25 dark:text-zinc-100 dark:hover:bg-zinc-800 dark:focus:bg-zinc-800; } @@ -150,7 +184,7 @@ input:checked + .checked-label { } .checked-border:has([type='checkbox']:checked, [type='radio']:checked) { - @apply border-indigo-600 text-black hover:border-indigo-500 dark:text-white; + @apply border-indigo-500/70 text-black hover:border-indigo-500/80 dark:border-indigo-400/50 dark:text-white; } /* Error message transitions */ diff --git a/demo/file-drop.ts b/demo/file-drop.ts index 8ef00e1..b3b2265 100644 --- a/demo/file-drop.ts +++ b/demo/file-drop.ts @@ -13,12 +13,15 @@ class FileDrop extends HTMLElement { private fileListEl!: HTMLDivElement private selectedFiles: File[] = [] private isInternalChange = false + private rendered = false static get observedAttributes(): string[] { return ['disabled'] } connectedCallback(): void { + if (this.rendered) return + this.rendered = true this.render() } diff --git a/demo/form-card.ts b/demo/form-card.ts index 3c1d265..4c2fd8c 100644 --- a/demo/form-card.ts +++ b/demo/form-card.ts @@ -54,7 +54,7 @@ class FormCard extends HTMLElement { if (lead) { const p = document.createElement('p') - p.className = 'text-sm leading-relaxed text-indigo-900/70 dark:text-indigo-200/70' + p.className = 'leading-relaxed text-indigo-900/70 dark:text-indigo-200/70' p.innerHTML = lead header.appendChild(p) diff --git a/demo/form-field.ts b/demo/form-field.ts index 6cd9e86..341f2b4 100644 --- a/demo/form-field.ts +++ b/demo/form-field.ts @@ -439,7 +439,7 @@ class FormField extends HTMLElement { name, type, value: opt.value, - className: 'block transition', + className: 'block transition size-5', 'aria-describedby': `${id}-error`, ...(isRequired ? { required: true } : {}), ...(errorMsg ? { 'data-error-default': errorMsg } : {}), From b53b5ecef21f01d526d7a9119b202f96a5f888a1 Mon Sep 17 00:00:00 2001 From: JD Lien Date: Mon, 26 Jan 2026 20:16:57 -0700 Subject: [PATCH 49/54] Fix group selection error clearing Signed-off-by: JD Lien --- demo/form-card.ts | 1 + src/Validator.ts | 25 ++++++++- tests/Validator.errors.test.ts | 100 +++++++++++++++++++++++++++++++++ 3 files changed, 125 insertions(+), 1 deletion(-) diff --git a/demo/form-card.ts b/demo/form-card.ts index 4c2fd8c..03fed16 100644 --- a/demo/form-card.ts +++ b/demo/form-card.ts @@ -29,6 +29,7 @@ class FormCard extends HTMLElement { const sectionId = this.getAttribute('id') // Remove id from custom element to put on section + if (title) this.removeAttribute('title') if (sectionId) this.removeAttribute('id') // Create section element diff --git a/src/Validator.ts b/src/Validator.ts index 4a81d23..d65183b 100755 --- a/src/Validator.ts +++ b/src/Validator.ts @@ -413,7 +413,30 @@ export default class Validator { // Clears error messages from an input and removes its errors from the inputErrors array private clearInputErrors(el: FormControl): void { - this.inputErrors[el.name || el.id] = [] + const key = el.name || el.id + this.inputErrors[key] = [] + + if ( + el instanceof HTMLInputElement && + (el.type === 'checkbox' || el.type === 'radio') && + el.name + ) { + const groupName = cssEscape(el.name) + if (this.form.querySelector(`input[name="${groupName}"]:checked`)) { + const groupInputs = this.form.querySelectorAll(`input[name="${groupName}"]`) + groupInputs.forEach((input) => { + input.removeAttribute('aria-invalid') + input.classList.remove(...this.errorInputClassesArray) + }) + + const errorEl = this.getErrorEl(el) + if (errorEl) { + errorEl.classList.add(...this.hiddenClassesArray) + errorEl.textContent = '' + } + return + } + } // Remove the aria-invalid attribute from the input el.removeAttribute('aria-invalid') diff --git a/tests/Validator.errors.test.ts b/tests/Validator.errors.test.ts index 5398073..8bb1ddc 100644 --- a/tests/Validator.errors.test.ts +++ b/tests/Validator.errors.test.ts @@ -612,6 +612,106 @@ describe('Validator', () => { expect(validator.inputErrors['input-id']).toEqual([]) // Note: also checks that clearInputErrors will return if there's no errorEl }) + + it('clears group error styles when a checkable group becomes valid', () => { + const radio1 = document.createElement('input') + radio1.type = 'radio' + radio1.id = 'group-radio-1' + radio1.name = 'group-radio' + form.appendChild(radio1) + + const radio2 = document.createElement('input') + radio2.type = 'radio' + radio2.id = 'group-radio-2' + radio2.name = 'group-radio' + form.appendChild(radio2) + + const groupError = document.createElement('div') + groupError.id = 'group-radio-error' + groupError.classList.add('hidden') + form.appendChild(groupError) + + validator.init() + + ;(validator as any).addInputError(radio1, validator.messages.OPTION_REQUIRED) + ;(validator as any).showInputErrors(radio1) + ;(validator as any).showInputErrors(radio2) + + validator.errorInputClasses.split(' ').forEach((errorClass) => { + expect(radio1.classList.contains(errorClass)).toBeTruthy() + expect(radio2.classList.contains(errorClass)).toBeTruthy() + }) + + radio1.checked = true + ;(validator as any).clearInputErrors(radio1) + + validator.errorInputClasses.split(' ').forEach((errorClass) => { + expect(radio1.classList.contains(errorClass)).toBeFalsy() + expect(radio2.classList.contains(errorClass)).toBeFalsy() + }) + expect(groupError.classList.contains('hidden')).toBeTruthy() + expect(groupError.textContent).toBe('') + }) + + it('does not clear group styles when no option is checked', () => { + const radio1 = document.createElement('input') + radio1.type = 'radio' + radio1.id = 'unchecked-radio-1' + radio1.name = 'unchecked-radio' + form.appendChild(radio1) + + const radio2 = document.createElement('input') + radio2.type = 'radio' + radio2.id = 'unchecked-radio-2' + radio2.name = 'unchecked-radio' + form.appendChild(radio2) + + const groupError = document.createElement('div') + groupError.id = 'unchecked-radio-error' + groupError.classList.add('hidden') + form.appendChild(groupError) + + validator.init() + + ;(validator as any).addInputError(radio1, validator.messages.OPTION_REQUIRED) + ;(validator as any).showInputErrors(radio1) + ;(validator as any).showInputErrors(radio2) + + ;(validator as any).clearInputErrors(radio1) + + validator.errorInputClasses.split(' ').forEach((errorClass) => { + expect(radio1.classList.contains(errorClass)).toBeFalsy() + expect(radio2.classList.contains(errorClass)).toBeTruthy() + }) + }) + + it('clears group styles even when no error element is present', () => { + const radio1 = document.createElement('input') + radio1.type = 'radio' + radio1.id = 'no-error-radio-1' + radio1.name = 'no-error-radio' + form.appendChild(radio1) + + const radio2 = document.createElement('input') + radio2.type = 'radio' + radio2.id = 'no-error-radio-2' + radio2.name = 'no-error-radio' + form.appendChild(radio2) + + validator.init() + + ;(validator as any).addInputError(radio1, validator.messages.OPTION_REQUIRED) + ;(validator as any).showInputErrors(radio1) + ;(validator as any).showInputErrors(radio2) + + radio1.checked = true + ;(validator as any).clearInputErrors(radio1) + + validator.errorInputClasses.split(' ').forEach((errorClass) => { + expect(radio1.classList.contains(errorClass)).toBeFalsy() + expect(radio2.classList.contains(errorClass)).toBeFalsy() + }) + }) }) // end clearInputErrors describe('clearFormErrors', () => { From db7e3e6447e8f551d0f07173fb33ce2aa42ed506 Mon Sep 17 00:00:00 2001 From: JD Lien Date: Mon, 26 Jan 2026 21:44:09 -0700 Subject: [PATCH 50/54] feat: add static validation methods for standalone inputs Add static methods for validating inputs outside of forms: - Validator.validateSingle(input, options?) - validates any input - Validator.clearInputErrors(input, options?) - clears errors from any input Instance methods now enforce form membership: - validateSingle() only validates inputs in its form - clearInputErrors() and clearAllErrors() made public Demo updated to use Validator.validateSingle() for standalone field. Updated CHANGELOG and README with documentation. Signed-off-by: JD Lien --- CHANGELOG.md | 80 ++++---- README.md | 77 +++++++- demo/demo-src.css | 4 + demo/index.dev.html | 229 +++++++++++++++-------- demo/index.html | 229 +++++++++++++++-------- src/Validator.ts | 37 +++- tests/Validator.errors.test.ts | 8 +- tests/Validator.staticValidation.test.ts | 86 +++++++++ tests/Validator.submitHandler.test.ts | 6 +- tests/Validator.validateSingle.test.ts | 3 +- 10 files changed, 547 insertions(+), 212 deletions(-) create mode 100644 tests/Validator.staticValidation.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d1df7cf..70394a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,52 +4,31 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [2.1.0] - 2026-01-26 +## [2.0.0] - 2026-01-26 -### Added - -- **Hybrid Validator Registry System**: Three-tier validator lookup (instance → static → window) - - `validators` option in constructor for instance-scoped validators (highest priority) - - `Validator.registerValidator(name, fn)` for global validators shared across instances - - `Validator.unregisterValidator(name)` to remove a global validator - - `Validator.getValidators()` returns a copy of all global validators - - `Validator.clearValidators()` removes all global validators -- New type exports: `ValidatorFunction`, `ValidationResult`, `ValidatorRegistry` - -### Changed - -- Custom validation now uses three-tier lookup: instance registry → static registry → window object -- Demo page updated to use instance registry instead of window functions - -### Migration Guide - -No breaking changes. Existing code using window functions continues to work. To migrate: - -```javascript -// Before (still works) -window.myValidator = (value) => value.length > 3 - -// After (recommended) -const validator = new Validator(form, { - validators: { - myValidator: (value) => value.length > 3, - }, -}) -``` - -## [2.0.0] - 2026-01-25 +Major new release with many different features and fixes. Now depends on v2.1 of `@jdlien/validator-utils`. The bundle size has increased slightly (~1.5KB zipped), but there are lots of new features to make the size well worth it! ### Bundle Size | | v1.5.0 | v2.0.0 | Change | | ------ | --------- | --------- | ------- | -| Raw | 21.60 KiB | 26.00 KiB | +20.4% | -| Gzip | 7.40 KiB | 8.74 KiB | +18.1% | -| Brotli | 6.59 KiB | 7.81 KiB | +18.5% | +| Raw | 21.60 KiB | 27.24 KiB | +26.1% | +| Gzip | 7.40 KiB | 8.85 KiB | +19.6% | +| Brotli | 6.59 KiB | 7.90 KiB | +19.9% | ### Added - -- `validateSingle(input)` method for programmatic single-input validation on demand +- **Hybrid Validator Registry System**: Three-tier validator lookup (instance → static → window) + - `validators` option in constructor for instance-scoped validators (highest priority) + - `Validator.registerValidator(name, fn)` for global validators shared across instances + - `Validator.unregisterValidator(name)` to remove a global validator + - `Validator.getValidators()` returns a copy of all global validators + - `Validator.clearValidators()` removes all global validators +- `validateSingle(input)` instance method for programmatic single-input validation on demand +- **Static methods for standalone validation** (inputs outside forms): + - `Validator.validateSingle(input, options?)` - validates any input without needing an instance + - `Validator.clearInputErrors(input, options?)` - clears errors from any input without an instance +- `clearInputErrors(input)` instance method to clear errors from a specific input +- `clearAllErrors()` instance method to clear all form errors - `scrollToError` option to scroll to first invalid input on validation failure - `scrollToErrorDelay` option to delay scroll-to-error behavior (useful for animations) - Min/max value validation via `data-min`/`data-max` attributes (also respects native `min`/`max`) @@ -60,12 +39,16 @@ const validator = new Validator(form, { - Set `data-arrow-step=""` (empty string) to disable arrow key behavior on numeric fields - Arrow keys respect `data-min`/`data-max` bounds, clamping values appropriately - `validateOnBlur` option to validate fields when they lose focus (even if unchanged) +- New type exports: `ValidatorFunction`, `ValidationResult`, `ValidatorRegistry` ### Changed - `messages` option now typed as `Record` (was `object`) - Integer fields now respect `data-min` for negative values (previously hardcoded to min 0) +- `validateSingle(input)` instance method now only validates inputs belonging to its form (use static `Validator.validateSingle()` for standalone inputs) - Package exports now include explicit ESM/CJS entry points (UMD still available at `dist/validator.js`) +- Custom validation now uses three-tier lookup: instance registry → static registry → window object +- Demo page updated to use instance registry instead of window functions ### Removed @@ -82,9 +65,12 @@ const validator = new Validator(form, { - Stale `inputErrors` entries are cleared when `init()` is called after removing inputs - Pattern validation no longer throws for invalid regex patterns (treats as pass-through) - Pattern validation now anchors patterns for full-match behavior (matches HTML5 `pattern` attribute) +- Error border colors on required multi-check/radio inputs are now cleared when any related input is checked ### Breaking Changes +- **Manual reinitialized required after DOM changes:** Previously, Validator would attempt to reinitialize itself if form elements were added or changed in the DOM using a mutationObserver, but this was inefficient and unreliable and was mostly handling niche cases. If you add/remove inputs dynamically, call `validator.init()` after DOM updates. +- **Manual cleanup now required when removing forms:** Previously, Validator would attempt to detect if the form were removed and clean itself up. This could be useful in an SPA or a modal dialog that loads different forms to keep accumulating Validator instances. This is considered an edge-case so Validator no longer handles this itself. Now, if you remove a form from the DOM, call `validator.destroy()` before removing it. - **Event classes consolidated:** Replace `ValidationSuccessEvent` and `ValidationErrorEvent` with unified `ValidationEvent` class ```typescript @@ -97,7 +83,23 @@ const validator = new Validator(form, { - **`messages` typing:** Now `Record` instead of `object` - **`types.d.ts` removed:** Import types from `Validator.ts` instead -- **Manual lifecycle management:** If you add/remove inputs dynamically, call `validator.init()` after DOM updates. If you remove a form from the DOM, call `validator.destroy()` before removing it. + + +### Migration Guide + +No breaking changes. Existing code using window functions continues to work. To migrate: + +```javascript +// Before (still works) +window.myValidator = (value) => value.length > 3 + +// After (recommended) +const validator = new Validator(form, { + validators: { + myValidator: (value) => value.length > 3, + }, +}) +``` ## [1.5.0] - 2025-01-08 diff --git a/README.md b/README.md index 37b3fe8..172690b 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Validator includes the following built-in validation types: - Date and time - Time of day - URLs +- Files (type, size, count; see file validation attributes below) You can also add custom validation and customize error messages per field or for the whole form. @@ -155,12 +156,15 @@ On input (and sometimes select and textarea) elements, the following attributes - `color` - The input must be a valid CSS color. (This can be used in conjunction with a native color input - see Color Picker Support for details.) - `data-date-format`/`data-time-format` - Applies formatting to date, time, or datetime inputs (these are interchangeable). The format must be a valid moment.js format string. See [moment.js docs](https://momentjs.com/docs/#/displaying/format/) for more information. -- `data-date-range` - Applies to date input types. Supported values are `past` and `future`. +- `data-date-range` - Applies to date input types. Supported values are `past`, `future`, and `today`. - `data-min`/`data-max` - Applies to numeric input types (`number`, `integer`, `float`, `decimal`). Validates that the numeric value is within the specified range. Also respects the native `min`/`max` attributes, but `data-` attributes take precedence. - `data-arrow-step` - Applies to numeric input types (`number`, `integer`, `float`, `decimal`). Sets the arrow key step size (defaults to `1`). Set `data-arrow-step=""` to disable arrow key handling for the field. - `data-error-default` - A custom error message to display if the input is invalid. This will be used for required, pattern, and date-range validation failures. - `data-validation` - The name of a custom validation function. - `data-novalidate` - If this attribute is present, the input will not be validated when `input` or `change` events are triggered on it. +- `data-max-files` - Applies to file inputs. Limits the number of files a user can upload. +- `data-min-file-size`/`data-max-file-size` - Applies to file inputs. Enforces min/max size per file. Accepts human-readable sizes like `200kb`, `2mb`, `1.5gb`. +- `accept`/`data-accept` - Applies to file inputs. Restricts allowed file types using MIME types and/or extensions. `data-accept` takes precedence over `accept`. A validation function will be called with the input value as the argument. The function may either return a boolean (true/false) or an object with a `valid` property that is a boolean. If the function returns a string or an object with a `message` property, that will be used as the error message for the input. A `messages` array may also be specified which will be used to display multiple error messages for the input. @@ -314,6 +318,10 @@ messages = { ERROR_TIME: 'This is not a valid time.', ERROR_URL: 'This is not a valid URL.', ERROR_COLOR: 'This is not a valid CSS colour.', + ERROR_FILE_TYPE: 'This file type is not allowed.', + ERROR_FILE_MAX_FILES: 'You can upload up to ${val} file(s).', + ERROR_FILE_MAX_SIZE: 'Each file must be ${val} or smaller.', + ERROR_FILE_MIN_SIZE: 'Each file must be at least ${val}.', ERROR_CUSTOM_VALIDATION: 'There was a problem validating this field.', } ``` @@ -355,9 +363,11 @@ const myValidator = new Validator(myForm, { ## Methods -### `validateSingle(input): Promise` +### Instance Methods -Validates a single input programmatically and displays any error messages. Returns `true` if the input is valid, `false` otherwise. +#### `validateSingle(input): Promise` + +Validates a single input that belongs to the form and displays any error messages. Returns `true` if the input is valid, `false` otherwise. This is useful for: @@ -382,19 +392,74 @@ if (isValid) { Notes: -- Returns `true` for inputs not part of the form or disabled inputs +- Only validates inputs that belong to the validator's form +- Returns `true` for inputs not in the form or disabled inputs (use `Validator.validateSingle()` for standalone inputs) - Clears previous errors before validating - Displays error messages in the associated error element - Works with all form control types (input, select, textarea) -### `init()` +#### `clearInputErrors(input): void` + +Clears validation errors from a specific input element. + +```javascript +validator.clearInputErrors(emailInput) +``` + +#### `clearAllErrors(): void` + +Clears all validation errors from the form, including the main error message and all input errors. + +```javascript +validator.clearAllErrors() +``` + +#### `init(): void` Re-initializes the validator, refreshing the list of form inputs. Call this after dynamically adding or removing inputs. -### `destroy()` +#### `destroy(): void` Removes all event listeners and restores the form's original `novalidate` state. Call this before removing the form from the DOM. +### Static Methods + +These methods allow validation without creating a Validator instance, useful for standalone inputs outside of forms. + +#### `Validator.validateSingle(input, options?): Promise` + +Validates any input element without needing a Validator instance. Useful for standalone fields outside of forms. + +```javascript +// Validate a standalone input (not in a form) +const standaloneInput = document.getElementById('standalone-email') +const isValid = await Validator.validateSingle(standaloneInput) + +// With custom options +const isValid = await Validator.validateSingle(standaloneInput, { + validators: { + customCheck: (value) => value.length > 5 || 'Too short', + }, +}) +``` + +#### `Validator.clearInputErrors(input, options?): void` + +Clears validation errors from any input element without needing a Validator instance. + +```javascript +Validator.clearInputErrors(standaloneInput) +``` + +## Events + +Validator dispatches two custom events on the form during submission validation: + +- `validationSuccess` - Fired when the form is valid. +- `validationError` - Fired when the form is invalid. + +Both events are instances of `ValidationEvent` and include the original submit event as `submitEvent`. + ## Dynamic Forms and Cleanup Validator does not watch the DOM for changes. If you add or remove inputs after initialization diff --git a/demo/demo-src.css b/demo/demo-src.css index c47b094..967f961 100644 --- a/demo/demo-src.css +++ b/demo/demo-src.css @@ -143,6 +143,10 @@ form-card { @layer components { .btn { + @apply my-4 w-60 rounded-lg border border-zinc-600/50 border-t-zinc-300/70 border-b-zinc-900/40 bg-indigo-300 bg-linear-to-b from-zinc-500/60 to-zinc-700/60 py-2 text-lg text-white shadow-md shadow-zinc-900/20 transition duration-150 [text-shadow:0_1px_1px_rgba(15,23,42,0.35)] hover:from-zinc-400/60 hover:to-zinc-600/60 hover:shadow-lg hover:shadow-zinc-900/30 active:translate-y-0.5 active:from-zinc-600/60 active:to-zinc-800/60; + } + + .btn-primary { @apply my-4 w-60 rounded-lg border border-indigo-600/50 border-t-indigo-300/70 border-b-indigo-900/40 bg-linear-to-b from-indigo-500 to-indigo-700 py-2 text-lg text-white shadow-md shadow-indigo-900/20 transition duration-150 [text-shadow:0_1px_1px_rgba(15,23,42,0.35)] hover:from-indigo-400 hover:to-indigo-600 hover:shadow-lg hover:shadow-indigo-900/30 active:translate-y-0.5 active:from-indigo-600 active:to-indigo-800; } diff --git a/demo/index.dev.html b/demo/index.dev.html index 68d7537..e89ab50 100644 --- a/demo/index.dev.html +++ b/demo/index.dev.html @@ -62,6 +62,7 @@

  • Selections
  • Custom Validation
  • Long Form
  • +
  • Single Validation
  • @@ -395,95 +396,173 @@

    - +
    -

    +

    If the form is invalid when submitted, the form-error-main div will be displayed above.

    - + +

    +
    © 2026 JD Lien
    + + diff --git a/demo/index.html b/demo/index.html index ffb49ec..b3e13c8 100644 --- a/demo/index.html +++ b/demo/index.html @@ -62,6 +62,7 @@

  • Selections
  • Custom Validation
  • Long Form
  • +
  • Single Validation
  • @@ -395,95 +396,173 @@

    - +
    -

    +

    If the form is invalid when submitted, the form-error-main div will be displayed above.

    - - - + + +
    © 2026 JD Lien
    + + + + diff --git a/src/Validator.ts b/src/Validator.ts index d65183b..cf1ce1c 100755 --- a/src/Validator.ts +++ b/src/Validator.ts @@ -90,6 +90,24 @@ export default class Validator { Validator.globalValidators = {} } + /** Validates a single input without needing a Validator instance. */ + public static async validateSingle( + input: FormControl | null, + options: ValidatorOptions = {} + ): Promise { + if (!input || input.disabled) return true + const temp = new Validator(document.createElement('form'), { autoInit: false, ...options }) + temp.inputs = [input] // Add input so instance method will validate it + return temp.validateSingle(input) + } + + /** Clears errors from a single input without needing a Validator instance. */ + public static clearInputErrors(input: FormControl | null, options: ValidatorOptions = {}): void { + if (!input) return + const temp = new Validator(document.createElement('form'), { autoInit: false, ...options }) + temp.clearInputErrors(input) + } + form: HTMLFormElement inputs: FormControl[] = [] // Keeps track of error messages accumulated for each input @@ -411,8 +429,8 @@ export default class Validator { } } - // Clears error messages from an input and removes its errors from the inputErrors array - private clearInputErrors(el: FormControl): void { + /** Clears error messages from an input and removes its errors from the inputErrors array */ + public clearInputErrors(el: FormControl): void { const key = el.name || el.id this.inputErrors[key] = [] @@ -455,7 +473,8 @@ export default class Validator { errorEl.textContent = '' } - private clearFormErrors(): void { + /** Clears the main error banner and all input errors */ + public clearAllErrors(): void { // Find the main error element (form-specific or generic) and hide it const mainErrorElement = this._getMainErrorElement() if (mainErrorElement) { @@ -914,12 +933,12 @@ export default class Validator { * @param input The input element to validate * @returns A promise that resolves to true if the input is valid, false otherwise */ - public async validateSingle(input: FormControl): Promise { - // If the input isn't part of this form's inputs, consider it valid - if (!this.inputs.includes(input)) return true + public async validateSingle(input: FormControl | null): Promise { + // Skip null or disabled inputs + if (!input || input.disabled) return true - // Skip disabled inputs - if (input.disabled) return true + // Skip inputs not in this form - use static Validator.validateSingle() for standalone inputs + if (!this.inputs.includes(input)) return true this.clearInputErrors(input) @@ -942,7 +961,7 @@ export default class Validator { e.preventDefault() // Clear any error messages - this.clearFormErrors() + this.clearAllErrors() let valid = await this.validate(e) // Show messages for any invalid inputs and show a large error message this.showFormErrors() diff --git a/tests/Validator.errors.test.ts b/tests/Validator.errors.test.ts index 8bb1ddc..14af684 100644 --- a/tests/Validator.errors.test.ts +++ b/tests/Validator.errors.test.ts @@ -714,7 +714,7 @@ describe('Validator', () => { }) }) // end clearInputErrors - describe('clearFormErrors', () => { + describe('clearAllErrors', () => { it('clears all error messages', () => { form.id = 'clear-all-errors-form' const formControl1 = document.createElement('input') @@ -745,7 +745,7 @@ describe('Validator', () => { expect(validator.inputErrors[formControl1.name]).toContain(validator.messages.ERROR_GENERIC) expect(validator.inputErrors[formControl2.name]).toContain(validator.messages.ERROR_GENERIC) - ;(validator as any).clearFormErrors() + ;(validator as any).clearAllErrors() // validator.inputErrors should be empty expect(Object.values(validator.inputErrors).every((i) => i.length == 0)).toBeTruthy() @@ -774,11 +774,11 @@ describe('Validator', () => { const mainError = form.querySelector('#form-error-main') expect(mainError).toBeTruthy() - ;(validator as any).clearFormErrors() + ;(validator as any).clearAllErrors() const mainErrorClassList = mainError?.classList expect(mainErrorClassList?.contains('hidden')).toBeTruthy() expect(mainErrorClassList?.contains('opacity-0')).toBeTruthy() }) - }) // end clearFormErrors + }) // end clearAllErrors }) // describe('Validator') diff --git a/tests/Validator.staticValidation.test.ts b/tests/Validator.staticValidation.test.ts new file mode 100644 index 0000000..71d9fd0 --- /dev/null +++ b/tests/Validator.staticValidation.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import Validator from '../src/Validator' + +describe('Validator static methods', () => { + let input: HTMLInputElement + let errorEl: HTMLDivElement + let container: HTMLDivElement + + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + + input = document.createElement('input') + input.type = 'text' + input.name = 'test-input' + input.id = 'test-input' + container.appendChild(input) + + errorEl = document.createElement('div') + errorEl.id = 'test-input-error' + errorEl.classList.add('hidden') + container.appendChild(errorEl) + }) + + afterEach(() => { + document.body.removeChild(container) + Validator.clearValidators() + }) + + describe('Validator.validateSingle', () => { + it('should return true for null input', async () => { + expect(await Validator.validateSingle(null)).toBe(true) + }) + + it('should return true for disabled input', async () => { + input.required = true + input.disabled = true + expect(await Validator.validateSingle(input)).toBe(true) + }) + + it('should validate required field', async () => { + input.required = true + input.value = '' + expect(await Validator.validateSingle(input)).toBe(false) + expect(errorEl.textContent).toBe('This field is required.') + }) + + it('should validate email type', async () => { + input.type = 'email' + input.value = 'invalid' + expect(await Validator.validateSingle(input)).toBe(false) + + input.value = 'test@example.com' + expect(await Validator.validateSingle(input)).toBe(true) + }) + + it('should use custom validators from options', async () => { + input.setAttribute('data-validation', 'customCheck') + input.value = 'test' + + const result = await Validator.validateSingle(input, { + validators: { customCheck: () => 'Custom error' }, + }) + + expect(result).toBe(false) + expect(errorEl.textContent).toBe('Custom error') + }) + }) + + describe('Validator.clearInputErrors', () => { + it('should clear errors from input', async () => { + input.required = true + input.value = '' + await Validator.validateSingle(input) + expect(input.getAttribute('aria-invalid')).toBe('true') + + Validator.clearInputErrors(input) + expect(input.getAttribute('aria-invalid')).toBeNull() + expect(errorEl.textContent).toBe('') + }) + + it('should handle null input', () => { + expect(() => Validator.clearInputErrors(null)).not.toThrow() + }) + }) +}) diff --git a/tests/Validator.submitHandler.test.ts b/tests/Validator.submitHandler.test.ts index 362a2df..f051d3f 100644 --- a/tests/Validator.submitHandler.test.ts +++ b/tests/Validator.submitHandler.test.ts @@ -30,11 +30,11 @@ describe('Validator', () => { expect(form.submit).not.toHaveBeenCalled() }) - it('calls clearFormErrors method before validation', () => { - vi.spyOn(validator as any, 'clearFormErrors') + it('calls clearAllErrors method before validation', () => { + vi.spyOn(validator as any, 'clearAllErrors') vi.spyOn(form, 'submit').mockImplementation(() => {}) ;(validator as any).submitHandler(new Event('submit')) - expect((validator as any).clearFormErrors).toHaveBeenCalled() + expect((validator as any).clearAllErrors).toHaveBeenCalled() }) it('calls showFormErrors method after validation', async () => { diff --git a/tests/Validator.validateSingle.test.ts b/tests/Validator.validateSingle.test.ts index cce9136..c6ad993 100644 --- a/tests/Validator.validateSingle.test.ts +++ b/tests/Validator.validateSingle.test.ts @@ -24,9 +24,10 @@ describe('Validator.validateSingle', () => { expect(result).toBe(true) }) - it('should return true for input not in form', async () => { + it('should skip input not in form', async () => { const externalInput = document.createElement('input') externalInput.required = true + // Instance method skips inputs not in form - use static Validator.validateSingle() for standalone const result = await validator.validateSingle(externalInput) expect(result).toBe(true) }) From 750a469c947816dea9fc132a6953944e43bf9e6d Mon Sep 17 00:00:00 2001 From: JD Lien Date: Mon, 26 Jan 2026 21:50:11 -0700 Subject: [PATCH 51/54] Add console warning for validateSingle on input not in form When the instance method validateSingle() is called on an input that doesn't belong to the form, it now logs a warning suggesting to use the static Validator.validateSingle() method instead. Signed-off-by: JD Lien --- src/Validator.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Validator.ts b/src/Validator.ts index cf1ce1c..e168cc9 100755 --- a/src/Validator.ts +++ b/src/Validator.ts @@ -938,7 +938,13 @@ export default class Validator { if (!input || input.disabled) return true // Skip inputs not in this form - use static Validator.validateSingle() for standalone inputs - if (!this.inputs.includes(input)) return true + if (!this.inputs.includes(input)) { + console.warn( + 'Validator.validateSingle(): input is not in this form. Use static Validator.validateSingle() for standalone inputs.', + input + ) + return true + } this.clearInputErrors(input) From 48152161a0582b4eaeb559a4c7e3caabc505329f Mon Sep 17 00:00:00 2001 From: JD Lien Date: Mon, 26 Jan 2026 21:56:39 -0700 Subject: [PATCH 52/54] docs: list ESM before UMD as recommended option Signed-off-by: JD Lien --- README.md | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 172690b..32b1ff9 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Validator includes the following built-in validation types: - Date and time - Time of day - URLs -- Files (type, size, count; see file validation attributes below) +- Files (type, size, count) You can also add custom validation and customize error messages per field or for the whole form. @@ -39,10 +39,6 @@ npm install @jdlien/validator # or -yarn add @jdlien/validator - -# or - pnpm add @jdlien/validator ``` @@ -95,16 +91,16 @@ Then, create a new Validator instance and pass it the form element as the first - - - - - + From ab3750c0b9137b1a9e1f779a41a778c2404fda7f Mon Sep 17 00:00:00 2001 From: JD Lien Date: Mon, 26 Jan 2026 22:04:23 -0700 Subject: [PATCH 53/54] fix: float and decimal type validation was broken The inputHandlers map only had a 'number' entry, so 'float' and 'decimal' types weren't getting type validation (isNumber check). They got input filtering and arrow keys, but invalid values like 'abc' would pass validation. Added float/decimal as aliases pointing to the same handler. Added tests to prevent regression. Signed-off-by: JD Lien --- README.md | 4 +-- src/Validator.ts | 15 ++++++++---- tests/Validator.validateInputType.test.ts | 30 +++++++++++++++++++++++ 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 32b1ff9..cc28bd5 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ There are a few attributes that Validator looks for on the form element: - `data-prevent-submit` - If this attribute is present, the form will never be submitted, even if it is valid. This is useful if you want to handle the submission yourself. (By default, the form will be submitted if it is valid and not if it is invalid.) -- `novalidate` - This is a native HTML5 attribute that will disable browser validation on the form. If this attribute is present. Validator adds this by default and removes it if `destroy()` is called. If you add it yourself, it will not be added back by Validator. +- `novalidate` - This is a native HTML5 attribute that disables browser validation on the form. Validator adds this by default and removes it if `destroy()` is called. If you add it yourself, it will not be added back by Validator. On input (and sometimes select and textarea) elements, the following attributes are supported: @@ -159,7 +159,7 @@ On input (and sometimes select and textarea) elements, the following attributes - `data-validation` - The name of a custom validation function. - `data-novalidate` - If this attribute is present, the input will not be validated when `input` or `change` events are triggered on it. - `data-max-files` - Applies to file inputs. Limits the number of files a user can upload. -- `data-min-file-size`/`data-max-file-size` - Applies to file inputs. Enforces min/max size per file. Accepts human-readable sizes like `200kb`, `2mb`, `1.5gb`. +- `data-min-file-size`/`data-max-file-size` - Applies to file inputs. Enforces min/max size per file. Accepts human-readable sizes like `200kb` (base 10), `2mib` (base 2), `1.5gb`. - `accept`/`data-accept` - Applies to file inputs. Restricts allowed file types using MIME types and/or extensions. `data-accept` takes precedence over `accept`. A validation function will be called with the input value as the argument. The function may either return a boolean (true/false) or an object with a `valid` property that is a boolean. If the function returns a string or an object with a `message` property, that will be used as the error message for the input. A `messages` array may also be specified which will be used to display multiple error messages for the input. diff --git a/src/Validator.ts b/src/Validator.ts index e168cc9..56819fc 100755 --- a/src/Validator.ts +++ b/src/Validator.ts @@ -616,12 +616,17 @@ export default class Validator { // A map of input handlers that can be used for each type of input. // errorKey references this.messages at validation time to support custom messages + // Handler for number types (shared by float/decimal aliases) + private numberHandler: InputHandler = { + parse: utils.parseNumber, + isValid: utils.isNumber, + errorKey: 'ERROR_NUMBER', + } + private inputHandlers: InputHandlers = { - number: { - parse: utils.parseNumber, - isValid: utils.isNumber, - errorKey: 'ERROR_NUMBER', - }, + number: this.numberHandler, + float: this.numberHandler, + decimal: this.numberHandler, integer: { parse: utils.parseInteger, isValid: utils.isInteger, diff --git a/tests/Validator.validateInputType.test.ts b/tests/Validator.validateInputType.test.ts index 9db29f4..898cb87 100644 --- a/tests/Validator.validateInputType.test.ts +++ b/tests/Validator.validateInputType.test.ts @@ -345,6 +345,36 @@ describe('Validator', () => { expect(validator.inputErrors[formControl.name]).toEqual([]) }) + it('should parse and validate float type (alias for number)', () => { + formControl.setAttribute('data-type', 'float') + formControl.value = '3.14159' + + valid = (validator as any).validateInputType(formControl) + + expect(valid).toBeTruthy() + expect(formControl.value).toBe('3.14159') + expect(validator.inputErrors[formControl.name]).toEqual([]) + + formControl.value = 'abc' + valid = (validator as any).validateInputType(formControl) + expect(valid).toBeFalsy() + }) + + it('should parse and validate decimal type (alias for number)', () => { + formControl.setAttribute('data-type', 'decimal') + formControl.value = '-99.5' + + valid = (validator as any).validateInputType(formControl) + + expect(valid).toBeTruthy() + expect(formControl.value).toBe('-99.5') + expect(validator.inputErrors[formControl.name]).toEqual([]) + + formControl.value = 'not a number' + valid = (validator as any).validateInputType(formControl) + expect(valid).toBeFalsy() + }) + it('should parse and validate integer type correctly', () => { formControl.type = 'text' formControl.setAttribute('data-type', 'integer') From 97f2355c67d3e76c4e3a700e8c36ab191650d9f4 Mon Sep 17 00:00:00 2001 From: JD Lien Date: Mon, 26 Jan 2026 22:59:02 -0700 Subject: [PATCH 54/54] docs: updated docs for completeness Signed-off-by: JD Lien --- README.md | 150 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 101 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index cc28bd5..c173507 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ -# Validator - HTML Form Validation Made Easy +# Validator - Easy, Powerful Form Validation ## Introduction -Validator is a utility class that adds automatic validation to your HTML forms that works much like +Validator is a lightweight utility class that adds automatic validation to your HTML forms that works much like the HTML5 form validation provided by browsers, but it is much more powerful, flexible, and customizable. -It can sanitize and check user input in forms, resulting in clean, consistent submissions that +It can normalize and check user input in forms, resulting in clean, consistent submissions that are very user-friendly without unnecessarily constraining the user from entering data in ways that are convenient for them. @@ -159,7 +159,7 @@ On input (and sometimes select and textarea) elements, the following attributes - `data-validation` - The name of a custom validation function. - `data-novalidate` - If this attribute is present, the input will not be validated when `input` or `change` events are triggered on it. - `data-max-files` - Applies to file inputs. Limits the number of files a user can upload. -- `data-min-file-size`/`data-max-file-size` - Applies to file inputs. Enforces min/max size per file. Accepts human-readable sizes like `200kb` (base 10), `2mib` (base 2), `1.5gb`. +- `data-min-file-size`/`data-max-file-size` - Applies to file inputs. Enforces min/max size per file. Accepts human-readable sizes like `200kb` (base 10), `2mib` (base 2), `1.5g`. - `accept`/`data-accept` - Applies to file inputs. Restricts allowed file types using MIME types and/or extensions. `data-accept` takes precedence over `accept`. A validation function will be called with the input value as the argument. The function may either return a boolean (true/false) or an object with a `valid` property that is a boolean. If the function returns a string or an object with a `message` property, that will be used as the error message for the input. A `messages` array may also be specified which will be used to display multiple error messages for the input. @@ -231,29 +231,42 @@ This allows you to override global validators for specific forms when needed. ### Validator Function Examples -A simple synchronous validator: +Custom validators are functions you can write that receive the input value and can return: + +- `true` — valid +- `false` — invalid (uses default error message) +- `string` — invalid with custom error message +- `{ valid: boolean, message?: string, messages?: string[] }` — structured result + +All return types can be wrapped in a `Promise` for async validation. ```javascript +// Simple synchronous validator: function customValidation(value) { if (value === 'foo') return 'The value cannot be foo.' - return true } -``` - -An async validator using fetch: -```javascript +// Async validator using fetch: async function customValidationPromise(value) { - const response = await fetch(`https://api.example.com/validate-username?username=${value}`) - const result = await response.json() - return result.valid ? true : 'Email is invalid' + const response = await fetch(`/api/validate-username?username=${value}`) + const { valid, error } = await response.json() + return valid ? true : error +} + +// Structured result with multiple errors: +function validatePassword(value) { + const errors = [] + if (value.length < 8) errors.push('Must be at least 8 characters') + if (!/[A-Z]/.test(value)) errors.push('Must contain an uppercase letter') + if (!/[0-9]/.test(value)) errors.push('Must contain a number') + return errors.length ? { valid: false, messages: errors } : true } ``` ## Displaying Error Messages -If any form validation fails on submission, Validator displays a main error message near the top of the form. By default, it looks for an element with the ID `form-error-main`. However, if the form itself has an `id` attribute (e.g., `
    `), Validator will first look for a main error element with the ID `{form.id}-error-main` (e.g., `contact-form-error-main`). If that form-specific element is not found, it falls back to looking for `form-error-main`. +If any form validation fails on submission, Validator displays a main error message directly beneath the form. By default, it looks for an element with the ID `form-error-main`. However, if the form itself has an `id` attribute (e.g., ``), Validator will first look for a main error element with the ID `{form.id}-error-main` (e.g., `contact-form-error-main`). If that form-specific element is not found, it falls back to looking for `form-error-main`. This allows for more targeted styling and placement of the main error message per form. You can disable the display of this main error message entirely by setting the `showMainError` option to `false`. @@ -267,7 +280,7 @@ You can customize the class(es) that Validator uses to hide the error messages b ## Color Picker Support -If you need to allow a user to pick a color, you can use data-type="color" and the input will be required to be any valid CSS color supported by the browser. This type can also work in conjunction with a native color input. If you do this, you will need to add an input with `type="color"` and the name of the data-color input + `-color`. This should be inside a linked label, which will become the color preview swatch. Such a label should have an ID of the color input's name + `-color-label` so that Validator can change the background to the specified color. +Use data-type="color" and the input must contain any valid CSS color supported by the browser. To use this with a native color input, add an input with `type="color"` and the ID of the data-color input + `-color`. This should be inside a linked label, which will become the color preview swatch. Such a label should have an ID of the color input's name + `-color-label` so that Validator can change the background to the specified color. A basic example that would work: @@ -389,10 +402,10 @@ if (isValid) { Notes: - Only validates inputs that belong to the validator's form -- Returns `true` for inputs not in the form or disabled inputs (use `Validator.validateSingle()` for standalone inputs) +- Returns `true` for inputs not in the form (with console warning) or disabled inputs (use `Validator.validateSingle()` for standalone inputs) - Clears previous errors before validating - Displays error messages in the associated error element -- Works with all form control types (input, select, textarea) +- Works with all form control types (input, select, textarea) except radio/checkbox groups #### `clearInputErrors(input): void` @@ -491,58 +504,94 @@ import { parseDate, formatDateTime } from '@jdlien/validator-utils' Here is a list of the utility functions: -- **isFormControl**: Determines if an element is an HTML input, select, or textarea element. -- **isType**: Checks if an element has a type or data-type attribute matching one of the passed values. -- **momentToFPFormat**: Converts a moment.js-style format string to the flatpickr format. -- **monthToNumber**: Converts month string or number to a zero-based month number (January == 0). -- **yearToFull**: Converts a year string or number to a 4-digit year. +**Date & Time:** - **parseDate**: Parses a date string or Date object into a Date object. +- **parseDateTime**: Parses a datetime string (e.g., "tomorrow 3pm") into a Date object. - **parseTime**: Parses a time string into an object with hour, minute, and second properties. -- **parseTimeToString**: Parses a time string into a formatted string. -- **formatDateTime**: Formats a date string or Date object into a string with a specified format. -- **parseDateToString**: Parses a date string or Date object into a formatted string with the specified moment.js-style date format. +- **parseRelativeDate**: Parses relative date strings like "+3d", "-1w", "+2m" into a Date object. +- **parseDateToString**: Formats a date into a string with the specified moment.js-style format. +- **parseDateTimeToString**: Formats a datetime into a string with the specified format. +- **parseTimeToString**: Formats a time string into a formatted string. +- **formatDateTime**: Formats a Date object into a string with a specified format. +- **momentToFPFormat**: Converts a moment.js-style format string to flatpickr format. +- **monthToNumber**: Converts month string or number to zero-based month number (January = 0). +- **yearToFull**: Converts a 2-digit year to a 4-digit year. - **isDate**: Determines if a value is a valid date. -- **isDateInRange**: Determines if a date falls within a specified range (either past or future). +- **isDateTime**: Determines if a value is a valid datetime. - **isTime**: Determines if a value is a valid time. -- **isEmail**: Determines if a value is a valid email address. -- **parseNANPTel**: Parses a North American phone number string into a standardized format. -- **isNANPTel**: Determines if a value is a valid North American phone number. -- **parseInteger**: Parses an integer string into a standardized format. +- **isMeridiem**: Determines if a value is "am" or "pm". +- **isDateInRange**: Determines if a date falls within a specified range (past, future, or custom). + +**Numbers & Bytes:** +- **parseNumber**: Parses a number string, stripping non-numeric characters. +- **parseInteger**: Parses an integer string, stripping non-digit characters. +- **parseBytes**: Parses human-readable byte sizes ("1.5MB", "2GiB") into bytes. +- **formatBytes**: Formats bytes into human-readable string (e.g., "1.5 MB"). - **isNumber**: Determines if a value is a valid number. -- **parseNumber**: Parses a number string into a standardized format. - **isInteger**: Determines if a value is a valid integer. -- **parseUrl**: Parses a URL string into a standardized format. -- **isUrl**: Determines if a value is a valid URL. -- **parseZip**: Parses a zip code string into a standardized format. -- **isZip**: Determines if a value is a valid zip code. -- **parsePostalCA**: Parses a Canadian postal code string into a standardized format. + +**Contact & Location:** +- **parseNANPTel**: Parses a North American phone number into standardized format. +- **isNANPTel**: Determines if a value is a valid North American phone number. +- **isEmail**: Determines if a value is a valid email address. +- **parseZip**: Parses a US zip code into standardized format. +- **isZip**: Determines if a value is a valid US zip code. +- **parsePostalCA**: Parses a Canadian postal code into standardized format. - **isPostalCA**: Determines if a value is a valid Canadian postal code. -- **isColor**: Determines if a value is a valid color. -- **parseColor**: Parses a color string into a standardized format. -- **normalizeValidationResult**: Normalizes a validation result (like a boolean or string) into an object with a valid property and a messages array of strings. +- **parseUrl**: Parses a URL, adding https:// if missing. +- **isUrl**: Determines if a value is a valid URL. + +**Color:** +- **parseColor**: Parses any CSS color into a normalized format. +- **isColor**: Determines if a value is a valid CSS color. + +**Utilities:** +- **isFormControl**: Determines if an element is an input, select, or textarea. +- **isType**: Checks if an element's type or data-type matches specified values. +- **normalizeValidationResult**: Normalizes validation results (boolean/string/object) into a standard format. ## Breaking Changes in v2.0.0 +### Manual Lifecycle Management Required + +Previously, Validator used MutationObservers to automatically reinitialize when inputs changed and clean up +when forms were removed. This was inefficient and unreliable, so it has been removed. + +Now, if you're dynamically changing forms/form elements in a webpage, you must run the following: +```javascript +// After dynamically adding/removing inputs: +validator.init() + +// Before removing a form from the DOM: +validator.destroy() +``` + ### Event Classes Consolidated The separate `ValidationSuccessEvent` and `ValidationErrorEvent` classes have been replaced with a unified `ValidationEvent` class: ```javascript // Before (v1.x) -import type { ValidationSuccessEvent, ValidationErrorEvent } from '@jdlien/validator' form.addEventListener('validationSuccess', (e: ValidationSuccessEvent) => { ... }) // After (v2.0) -import type { ValidationEvent } from '@jdlien/validator' form.addEventListener('validationSuccess', (e: ValidationEvent) => { ... }) -form.addEventListener('validationError', (e: ValidationEvent) => { ... }) ``` The `ValidationEvent` class has a `type` property that is either `'validationSuccess'` or `'validationError'`, and a `submitEvent` property containing the original form submission event. -### Types File Removed +### TypeScript Changes -The separate `types.d.ts` file has been removed. All types are now exported directly from the main module. +- **`messages` option typing:** Now `Record` instead of `object` +- **`types.d.ts` removed:** Import types directly from the package instead: + +```typescript +// Before +import type { ValidatorOptions } from '@jdlien/validator/types' + +// After +import type { ValidatorOptions } from '@jdlien/validator' +``` ## Contributing @@ -552,11 +601,14 @@ Install dev dependencies: pnpm install ``` -When running Vite, you may get an error like +Run tests with coverage (100% required for release): +```bash +pnpm coverage ``` -Module did not self-register: '...\node_modules\canvas\build\Release\canvas.node' -``` -If that happens, you -need to install the canvas module manually: `pnpm rebuild canvas --update-binary` +Build the project and demo page for testing/release: + +```bash +pnpm build +```