diff --git a/.ai/rules/js.md b/.ai/rules/js.md index 0cc0a31d61c..def22444f0f 100644 --- a/.ai/rules/js.md +++ b/.ai/rules/js.md @@ -16,3 +16,8 @@ Check VueUse before writing lifecycle-managed composables. PHP-derived TypeScrip ## Treat custom elements as native Vue treats tags containing a hyphen as native custom elements. Let them pass through instead of registering them as Vue components. + +## Bind Lit boolean properties with `.prop`, not `:attr` +Vue only strips a `false` boolean attribute for the seven names in its `isSpecialBooleanAttr` list (itemscope, allowfullscreen, formnovalidate, ismap, nomodule, novalidate, readonly). `open`, `disabled`, `active` and friends are in the wider `isBooleanAttr` list, which `patchAttr` never consults — so `:open="false"` on a custom element writes `open="false"`, and Lit's `{type: Boolean}` converter reads attribute *presence* and turns it on. + +Use the property binding instead: `.opened="isOpen"` on `craft-dialog`, not `:open="isOpen"`. It also survives the element not being upgraded yet, since Lit re-applies shadowed instance properties. Regression test: `resources/js/modules/image-editor/components/dialogOpenBinding.test.ts`. diff --git a/package-lock.json b/package-lock.json index 90950245c7a..8b23840ad92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@tanstack/vue-table": "^8.21.3", "@vueuse/core": "^14.3.0", "axios": "^1.17.0", + "fabric": "^7.4.0", "laravel-vite-plugin": "^3.1.0", "lit": "^3.3.2", "overtype": "^2.4.0", @@ -234,6 +235,122 @@ "node": ">=14.17" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC", + "optional": true + }, "node_modules/@atlaskit/pragmatic-drag-and-drop": { "version": "1.8.1", "license": "Apache-2.0", @@ -1921,6 +2038,26 @@ "resolved": "packages/craftcms-webpack", "link": true }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "optional": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@csstools/css-calc": { "version": "3.2.0", "dev": true, @@ -8985,6 +9122,29 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-globals": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-1.0.9.tgz", + "integrity": "sha512-j3/4pkfih8W4NK22gxVSXcEonTpAHOHh0hu5BoZrKcOsW/4oBPxTi4Yk3SAj+FhC1f3+bRTkXdm4019gw1vg9g==", + "license": "MIT", + "optional": true, + "dependencies": { + "acorn": "^2.1.0" + } + }, + "node_modules/acorn-globals/node_modules/acorn": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-2.7.0.tgz", + "integrity": "sha512-pXK8ez/pVjqFdAgBkF1YPVRacuLQ9EXBKaKWaeh58WNfMkCmZhOZzu+NtKSPD5PHmCCHheQ5cD29qM1K4QTxIg==", + "license": "MIT", + "optional": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/acorn-import-phases": { "version": "1.0.4", "license": "MIT", @@ -9037,7 +9197,6 @@ "node_modules/ajv": { "version": "6.15.0", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -9311,11 +9470,31 @@ "dev": true, "license": "MIT" }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/assert-never": { "version": "1.4.0", "dev": true, "license": "MIT" }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "license": "MIT", @@ -9439,6 +9618,23 @@ "node": ">=18" } }, + "node_modules/aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": "*" + } + }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "license": "MIT", + "optional": true + }, "node_modules/axe-core": { "version": "4.11.0", "dev": true, @@ -9538,7 +9734,7 @@ }, "node_modules/base64-js": { "version": "1.5.1", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -9569,6 +9765,16 @@ "version": "0.6.1", "license": "MIT" }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, "node_modules/big.js": { "version": "5.2.2", "license": "MIT", @@ -9599,7 +9805,7 @@ }, "node_modules/bl": { "version": "4.1.0", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "buffer": "^5.5.0", @@ -9727,6 +9933,15 @@ "node": ">=8" } }, + "node_modules/browser-request": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/browser-request/-/browser-request-0.3.3.tgz", + "integrity": "sha512-YyNI4qJJ+piQG6MMEuo7J3Bzaqssufx04zpEKYfSrl/1Op59HWali9zMtBpXnkmqMcOuWJPZvudrm9wISmnCbg==", + "engines": [ + "node" + ], + "optional": true + }, "node_modules/browserslist": { "version": "4.28.0", "funding": [ @@ -9760,7 +9975,7 @@ }, "node_modules/buffer": { "version": "5.7.1", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -10074,6 +10289,28 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/canvas/-/canvas-3.2.3.tgz", + "integrity": "sha512-PzE5nJZPz72YUAfo8oTp0u3fqqY7IzlTubneAihqDYAUcBk7ryeCmBbdJBEdaH0bptSOe2VT2Zwcb3UaFyaSWw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.3" + }, + "engines": { + "node": "^18.12.0 || >= 20.9.0" + } + }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", + "license": "Apache-2.0", + "optional": true + }, "node_modules/chai": { "version": "5.3.3", "license": "MIT", @@ -10775,6 +11012,20 @@ "node": ">= 0.6" } }, + "node_modules/contextify": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/contextify/-/contextify-0.1.15.tgz", + "integrity": "sha512-NpM4b6u5Mzig1/ux3ReVv42L/og3WcKKvkmTWxZpIjhZ/S23BViWZD/7hds9LGNzEL3W9ItfoZ+p6eRhCQMH6Q==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "bindings": "^1.2.1", + "nan": "^2.1.0" + }, + "engines": { + "node": ">=0.10.11" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "license": "MIT" @@ -11207,6 +11458,27 @@ "version": "2.0.28", "license": "CC0-1.0" }, + "node_modules/cssom": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", + "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", + "license": "MIT", + "optional": true + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.2.3", "license": "MIT" @@ -11565,6 +11837,43 @@ "node": ">=12" } }, + "node_modules/dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "license": "MIT", + "optional": true, + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "dev": true, @@ -11637,6 +11946,29 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT", + "optional": true + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/deep-eql": { "version": "5.0.2", "license": "MIT", @@ -11644,10 +11976,19 @@ "node": ">=6" } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/default-browser": { "version": "5.5.0", @@ -12034,6 +12375,17 @@ "version": "0.2.0", "license": "MIT" }, + "node_modules/ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", + "license": "MIT", + "optional": true, + "dependencies": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/ee-first": { "version": "1.1.1", "license": "MIT" @@ -12084,8 +12436,18 @@ "node": ">=0.10.0" } }, - "node_modules/enhanced-resolve": { - "version": "5.20.1", + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -12350,6 +12712,83 @@ "node": ">=0.8.0" } }, + "node_modules/escodegen": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.14.3.tgz", + "integrity": "sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==", + "license": "BSD-2-Clause", + "optional": true, + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^4.2.0", + "esutils": "^2.0.2", + "optionator": "^0.8.1" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=4.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/escodegen/node_modules/levn": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", + "license": "MIT", + "optional": true, + "dependencies": { + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/escodegen/node_modules/optionator": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", + "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", + "license": "MIT", + "optional": true, + "dependencies": { + "deep-is": "~0.1.3", + "fast-levenshtein": "~2.0.6", + "levn": "~0.3.0", + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2", + "word-wrap": "~1.2.3" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/escodegen/node_modules/prelude-ls": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", + "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==", + "optional": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/escodegen/node_modules/type-check": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", + "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", + "license": "MIT", + "optional": true, + "dependencies": { + "prelude-ls": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/eslint": { "version": "9.39.4", "license": "MIT", @@ -12834,6 +13273,16 @@ "node": ">= 8" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/expand-tilde": { "version": "2.0.2", "dev": true, @@ -12945,19 +13394,30 @@ }, "node_modules/extend": { "version": "3.0.2", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "optional": true + }, "node_modules/fabric": { - "version": "1.7.22", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/fabric/-/fabric-7.4.0.tgz", + "integrity": "sha512-NalYDc3eifTl1C33zryQwpH6+XA/2ClxQrH9vkASkZw3tbkRmorpikhYMmxhUTmi7O3e9ODz0vOT8qfaCh9IVA==", "license": "MIT", "engines": { - "node": ">=0.4.0" + "node": ">=20.0.0" }, "optionalDependencies": { - "canvas": "1.6.x", - "jsdom": "3.x.x", - "xmldom": "0.1.x" + "canvas": "^3.2.0", + "jsdom": "^26.1.0" } }, "node_modules/fast-deep-equal": { @@ -12985,13 +13445,11 @@ }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fast-levenshtein": { "version": "2.0.6", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fast-uri": { "version": "3.1.0", @@ -13338,6 +13796,16 @@ "node": ">= 8" } }, + "node_modules/forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": "*" + } + }, "node_modules/form-data": { "version": "4.0.5", "license": "MIT", @@ -13402,6 +13870,13 @@ "node": ">= 0.6" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT", + "optional": true + }, "node_modules/fs-minipass": { "version": "2.1.0", "license": "ISC", @@ -13645,6 +14120,23 @@ "node": ">=6.0" } }, + "node_modules/getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT", + "optional": true + }, "node_modules/glob": { "version": "10.4.5", "license": "ISC", @@ -13942,6 +14434,31 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/har-validator": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", + "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", + "deprecated": "this library is no longer supported", + "license": "MIT", + "optional": true, + "dependencies": { + "ajv": "^6.12.3", + "har-schema": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/has-bigints": { "version": "1.1.0", "dev": true, @@ -14108,6 +14625,19 @@ "dev": true, "license": "MIT" }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/html-entities": { "version": "2.6.0", "funding": [ @@ -14138,6 +14668,90 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/htmlparser2": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz", + "integrity": "sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "domelementtype": "^1.3.1", + "domhandler": "^2.3.0", + "domutils": "^1.5.1", + "entities": "^1.1.1", + "inherits": "^2.0.1", + "readable-stream": "^3.1.1" + } + }, + "node_modules/htmlparser2/node_modules/dom-serializer": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz", + "integrity": "sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==", + "license": "MIT", + "optional": true, + "dependencies": { + "domelementtype": "^2.0.1", + "entities": "^2.0.0" + } + }, + "node_modules/htmlparser2/node_modules/dom-serializer/node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause", + "optional": true + }, + "node_modules/htmlparser2/node_modules/dom-serializer/node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "license": "BSD-2-Clause", + "optional": true, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/htmlparser2/node_modules/domelementtype": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz", + "integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==", + "license": "BSD-2-Clause", + "optional": true + }, + "node_modules/htmlparser2/node_modules/domhandler": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz", + "integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==", + "license": "BSD-2-Clause", + "optional": true, + "dependencies": { + "domelementtype": "1" + } + }, + "node_modules/htmlparser2/node_modules/domutils": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz", + "integrity": "sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==", + "license": "BSD-2-Clause", + "optional": true, + "dependencies": { + "dom-serializer": "0", + "domelementtype": "1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.2.tgz", + "integrity": "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==", + "license": "BSD-2-Clause", + "optional": true + }, "node_modules/htmx.org": { "version": "1.9.12", "license": "0BSD" @@ -14218,6 +14832,22 @@ "version": "4.0.7", "license": "MIT" }, + "node_modules/http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + }, + "engines": { + "node": ">=0.8", + "npm": ">=1.3.7" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "license": "MIT", @@ -14297,7 +14927,7 @@ }, "node_modules/ieee754": { "version": "1.2.1", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -14470,7 +15100,7 @@ }, "node_modules/ini": { "version": "1.3.8", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/inputmask": { @@ -15024,6 +15654,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "license": "MIT", + "optional": true + }, "node_modules/is-primitive": { "version": "3.0.1", "license": "MIT", @@ -15144,6 +15781,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "license": "MIT", + "optional": true + }, "node_modules/is-unc-path": { "version": "1.0.0", "dev": true, @@ -15251,6 +15895,13 @@ "node": ">=0.10.0" } }, + "node_modules/isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", + "license": "MIT", + "optional": true + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "devOptional": true, @@ -15477,6 +16128,127 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", + "license": "MIT", + "optional": true + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "license": "MIT", + "optional": true, + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "optional": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "optional": true, + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, "node_modules/jsesc": { "version": "3.1.0", "license": "MIT", @@ -15517,16 +16289,29 @@ "version": "2.3.1", "license": "MIT" }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)", + "optional": true + }, "node_modules/json-schema-traverse": { "version": "0.4.1", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "license": "MIT", "peer": true }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC", + "optional": true + }, "node_modules/json5": { "version": "2.2.3", "license": "MIT", @@ -15553,6 +16338,22 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsprim": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", + "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.4.0", + "verror": "1.10.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/jstransformer": { "version": "1.0.0", "dev": true, @@ -16603,6 +17404,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/min-indent": { "version": "1.0.1", "license": "MIT", @@ -16817,6 +17631,13 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT", + "optional": true + }, "node_modules/mlly": { "version": "1.8.0", "dev": true, @@ -16916,6 +17737,13 @@ "picocolors": "^1.1.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT", + "optional": true + }, "node_modules/natural-compare": { "version": "1.4.0", "license": "MIT", @@ -16937,6 +17765,32 @@ "dev": true, "license": "MIT" }, + "node_modules/node-abi": { + "version": "3.96.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz", + "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==", + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-addon-api": { "version": "7.1.1", "license": "MIT", @@ -17181,6 +18035,30 @@ "version": "1.1.1", "license": "MIT" }, + "node_modules/nwmatcher": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/nwmatcher/-/nwmatcher-1.4.4.tgz", + "integrity": "sha512-3iuY4N5dhgMpCUrOVnuAdGrgxVqV2cJpM+XNccjR2DKOB1RUP0aA+wGXEiNziG/UKboFyGBIoKOaNlJxx8bciQ==", + "license": "MIT", + "optional": true + }, + "node_modules/nwsapi": { + "version": "2.2.27", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.27.tgz", + "integrity": "sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==", + "license": "MIT", + "optional": true + }, + "node_modules/oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": "*" + } + }, "node_modules/object-assign": { "version": "4.1.1", "license": "MIT", @@ -17915,6 +18793,13 @@ "node": ">= 14.16" } }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "license": "ISC" @@ -18962,6 +19847,34 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "license": "MIT", @@ -19104,6 +20017,19 @@ "version": "1.0.2", "license": "ISC" }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "optional": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, "node_modules/pug": { "version": "3.0.4", "dev": true, @@ -19216,6 +20142,17 @@ "dev": true, "license": "MIT" }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "license": "MIT", @@ -19246,6 +20183,16 @@ "dev": true, "license": "MIT" }, + "node_modules/qs": { + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.5.tgz", + "integrity": "sha512-mzR4sElr1bfCaPJe7m8ilJ6ZXdDaGoObcYR0ZHSsktM/Lt21MVHj5De30GQH2eiZ1qGRTO7LCAzQsUeXTNexWQ==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.6" + } + }, "node_modules/quansync": { "version": "0.2.11", "dev": true, @@ -19320,6 +20267,32 @@ "node": ">=0.10.0" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "optional": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react": { "version": "19.2.5", "license": "MIT", @@ -19657,10 +20630,83 @@ "version": "0.13.0", "license": "BSD-2-Clause", "dependencies": { - "jsesc": "~3.1.0" + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/request": { + "version": "2.88.2", + "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", + "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", + "deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "har-validator": "~5.1.3", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "~6.5.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~2.5.0", + "tunnel-agent": "^0.6.0", + "uuid": "^3.3.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/request/node_modules/form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/request/node_modules/tough-cookie": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", + "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "psl": "^1.1.28", + "punycode": "^2.1.1" }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/request/node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "optional": true, "bin": { - "regjsparser": "bin/parser" + "uuid": "bin/uuid" } }, "node_modules/require-directory": { @@ -19830,6 +20876,13 @@ "version": "3.0.3", "license": "Unlicense" }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "license": "MIT", + "optional": true + }, "node_modules/rs-module-lexer": { "version": "2.8.0", "dev": true, @@ -20051,6 +21104,19 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "license": "ISC", + "optional": true, + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "license": "MIT" @@ -20450,6 +21516,53 @@ "node": ">=6" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/singleton-manager": { "version": "1.8.0", "dev": true, @@ -20637,6 +21750,32 @@ "wbuf": "^1.7.3" } }, + "node_modules/sshpk": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", + "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + }, + "bin": { + "sshpk-conv": "bin/sshpk-conv", + "sshpk-sign": "bin/sshpk-sign", + "sshpk-verify": "bin/sshpk-verify" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ssr-window": { "version": "4.0.2", "license": "MIT" @@ -21510,6 +22649,13 @@ "node": ">= 4.7.0" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "license": "MIT", + "optional": true + }, "node_modules/tabbable": { "version": "6.4.0", "license": "MIT" @@ -21678,6 +22824,43 @@ "node": ">=10" } }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "license": "MIT", + "optional": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC", + "optional": true + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tar/node_modules/minipass": { "version": "5.0.0", "license": "ISC", @@ -21871,6 +23054,26 @@ "dev": true, "license": "MIT" }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "license": "MIT", + "optional": true + }, "node_modules/to-regex-range": { "version": "5.0.1", "license": "MIT", @@ -21904,6 +23107,32 @@ "node": ">=6" } }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "license": "MIT", + "optional": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/ts-dedent": { "version": "2.2.0", "dev": true, @@ -22036,6 +23265,26 @@ "node": ">=14" } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense", + "optional": true + }, "node_modules/type-check": { "version": "0.4.0", "license": "MIT", @@ -22396,7 +23645,6 @@ "node_modules/uri-js": { "version": "4.4.1", "license": "BSD-2-Clause", - "peer": true, "dependencies": { "punycode": "^2.1.0" } @@ -22521,6 +23769,28 @@ "version": "1.5.2", "license": "MIT" }, + "node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "node_modules/verror/node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "license": "MIT", + "optional": true + }, "node_modules/vite": { "name": "@voidzero-dev/vite-plus-core", "version": "0.2.6", @@ -23620,6 +24890,19 @@ "vue": "^2.0.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "license": "MIT", + "optional": true, + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/watchpack": { "version": "2.4.4", "license": "MIT", @@ -23646,6 +24929,16 @@ "defaults": "^1.0.3" } }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "optional": true, + "engines": { + "node": ">=12" + } + }, "node_modules/webpack": { "version": "5.102.1", "license": "MIT", @@ -24020,6 +25313,33 @@ "node": ">=0.8.0" } }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/whatwg-mimetype": { "version": "3.0.0", "devOptional": true, @@ -24028,6 +25348,20 @@ "node": ">=12" } }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "1.3.1", "dev": true, @@ -24198,7 +25532,6 @@ "node_modules/word-wrap": { "version": "1.2.5", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -24401,14 +25734,44 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT", + "optional": true + }, "node_modules/xmldom": { "version": "0.1.31", + "resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.1.31.tgz", + "integrity": "sha512-yS2uJflVQs6n+CyjHoaBmVSqIDevTAWrzMmjG1Gc7h1qQ7uVozNhEPJAwZXWyGQ/Gafo3fCwrcaokezLPupVyQ==", + "deprecated": "Deprecated due to CVE-2021-21366 resolved in 0.5.0", "license": "(LGPL-2.0 or MIT)", "optional": true, "engines": { "node": ">=0.1" } }, + "node_modules/xmlhttprequest": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/xmlhttprequest/-/xmlhttprequest-1.8.0.tgz", + "integrity": "sha512-58Im/U0mlVBLM38NdZjHyhuMtCqa61469k2YP/AaPbvCoV9aQGUpbJBj1QRm2ytRiVQBD/fsw7L2bJGDVQswBA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/xregexp": { "version": "5.1.2", "license": "MIT", @@ -24814,6 +26177,18 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "packages/craftcms-legacy/node_modules/acorn": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-0.11.0.tgz", + "integrity": "sha512-fvlA412hssF7l+22DIRlhXUKIuUz9Z9IrCMn/gPyWx90R75X/xB4SsqU8Y1vhiq9xd72P6duzQn5kKojqFN6cw==", + "optional": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, "packages/craftcms-legacy/node_modules/ansi-regex": { "version": "5.0.1", "dev": true, @@ -24827,6 +26202,20 @@ "dev": true, "license": "MIT" }, + "packages/craftcms-legacy/node_modules/canvas": { + "version": "1.6.13", + "resolved": "https://registry.npmjs.org/canvas/-/canvas-1.6.13.tgz", + "integrity": "sha512-XAfzfEOHZ3JIPjEV+WSI6PpISgUta3dgmndWbsajotz+0TQOX/jDpp2kawjRERatOGv9sMMzk5auB3GKEKA6hg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "nan": "^2.10.0" + }, + "engines": { + "node": ">=0.8.0" + } + }, "packages/craftcms-legacy/node_modules/chokidar": { "version": "3.6.0", "dev": true, @@ -24850,6 +26239,30 @@ "fsevents": "~2.3.2" } }, + "packages/craftcms-legacy/node_modules/cssstyle": { + "version": "0.2.37", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-0.2.37.tgz", + "integrity": "sha512-FUpKc+1FNBsHUr9IsfSGCovr8VuGOiiuzlgCyppKBjJi2jYTOFLN3oiiNRMIvYqbFzF38mqKj4BgcevzU5/kIA==", + "license": "MIT", + "optional": true, + "dependencies": { + "cssom": "0.3.x" + } + }, + "packages/craftcms-legacy/node_modules/fabric": { + "version": "1.7.22", + "resolved": "https://registry.npmjs.org/fabric/-/fabric-1.7.22.tgz", + "integrity": "sha512-VRfQKuDQyTqRzWvJSjSbkOb9+988N2d522LDLI3GP8N4tEORNAttMbsh4HncJwY5LFYTKVZPfE2Ei39Npe72og==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + }, + "optionalDependencies": { + "canvas": "1.6.x", + "jsdom": "3.x.x", + "xmldom": "0.1.x" + } + }, "packages/craftcms-legacy/node_modules/globby": { "version": "11.1.0", "dev": true, @@ -24904,6 +26317,28 @@ "jiti": "bin/jiti.js" } }, + "packages/craftcms-legacy/node_modules/jsdom": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-3.1.2.tgz", + "integrity": "sha512-GA+u4MzID/sjkAH2zzzSHDRStSLK2qwSockuy2X/ixqSGNv8OmCbnF49NGXLQUakOhGRm/wf7CLdF3omdAN/0g==", + "license": "MIT", + "optional": true, + "dependencies": { + "acorn": "0.11.0", + "acorn-globals": "^1.0.2", + "browser-request": ">= 0.3.1 < 0.4.0", + "contextify": ">= 0.1.9 < 0.2.0", + "cssom": ">= 0.3.0 < 0.4.0", + "cssstyle": ">= 0.2.21 < 0.3.0", + "escodegen": "^1.6.1", + "htmlparser2": ">= 3.7.3 < 4.0.0", + "nwmatcher": ">= 1.3.4 < 2.0.0", + "parse5": ">= 1.3.1 < 2.0.0", + "request": ">= 2.44.0 < 3.0.0", + "xml-name-validator": "^1.0.0", + "xmlhttprequest": ">= 1.6.0 < 2.0.0" + } + }, "packages/craftcms-legacy/node_modules/mathml-tag-names": { "version": "2.1.3", "dev": true, @@ -24924,6 +26359,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/craftcms-legacy/node_modules/parse5": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-1.5.1.tgz", + "integrity": "sha512-w2jx/0tJzvgKwZa58sj2vAYq/S/K1QJfIB3cWYea/Iu1scFPDQQ3IQiVZTHWtRBwAjv2Yd7S/xeZf3XqLDb3bA==", + "optional": true + }, "packages/craftcms-legacy/node_modules/pkg-dir": { "version": "5.0.0", "dev": true, @@ -25232,6 +26673,13 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "packages/craftcms-legacy/node_modules/xml-name-validator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-1.0.0.tgz", + "integrity": "sha512-XDmXffxxQs/+0VLW9NB2oSIbSoSINj6dQdhegY3kEM81LOoLr6NfsFE9RR59qrwsKEHaZLxa1MOSahFA0CE9Ow==", + "license": "WTFPL", + "optional": true + }, "packages/craftcms-playwright": { "name": "@craftcms/playwright", "version": "1.0.0", diff --git a/package.json b/package.json index 52501724198..cd3ac16442d 100644 --- a/package.json +++ b/package.json @@ -70,9 +70,9 @@ "stylelint-use-logical": "^2.1.3", "typescript": "^6.0.3", "vite": "npm:@voidzero-dev/vite-plus-core@0.2.6", + "vite-plus": "0.2.6", "vitest": "4.1.10", - "vue-tsc": "^3.2.7", - "vite-plus": "0.2.6" + "vue-tsc": "^3.2.7" }, "optionalDependencies": { "@awesome.me/kit-ddaed3f5c5": "^1.0.74" @@ -80,14 +80,15 @@ "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "^1.8.1", "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0", - "@craftcms/ui": "file:packages/craftcms-ui", "@craftcms/graphiql": "file:packages/craftcms-graphiql", + "@craftcms/ui": "file:packages/craftcms-ui", "@headlessui/vue": "^1.7.23", "@inertiajs/vite": "^3.0.3", "@inertiajs/vue3": "^3.0.3", "@tanstack/vue-table": "^8.21.3", "@vueuse/core": "^14.3.0", "axios": "^1.17.0", + "fabric": "^7.4.0", "laravel-vite-plugin": "^3.1.0", "lit": "^3.3.2", "overtype": "^2.4.0", diff --git a/packages/craftcms-ui/scripts/generate-colors.js b/packages/craftcms-ui/scripts/generate-colors.js index 7627b126b8e..d65c921f8ce 100644 --- a/packages/craftcms-ui/scripts/generate-colors.js +++ b/packages/craftcms-ui/scripts/generate-colors.js @@ -145,7 +145,16 @@ function buildStyleBlock(color) { function generateStyles(paletteColors, semanticColors) { return `/* Auto-generated by scripts/generate-colors.js — do not edit manually */ -:root { +/* + Also on [data-theme], not just :root. These tokens are built out of the raw + palette with var(), and a custom property carrying a var() is substituted + where it is declared -- so resolving them only at :root bakes in the root's + palette, and re-declaring the palette further down can never reach back into + them. Repeating the block on a themed element re-resolves it against that + element's palette, which is what makes a themed subtree possible. +*/ +:root, +[data-theme] { ${buildColorableTokens(paletteColors)} ${buildSemanticTokens(semanticColors)} } diff --git a/packages/craftcms-ui/src/components/button-group/button-group.stories.ts b/packages/craftcms-ui/src/components/button-group/button-group.stories.ts index 7c584576fe5..60b6ac4c3f3 100644 --- a/packages/craftcms-ui/src/components/button-group/button-group.stories.ts +++ b/packages/craftcms-ui/src/components/button-group/button-group.stories.ts @@ -1,4 +1,5 @@ import type {Meta, StoryObj} from '@storybook/web-components-vite'; +import {expect} from 'storybook/test'; import {html} from 'lit'; @@ -161,3 +162,31 @@ export const WithActions: Story = { `, }; + +/** + * A group handed its selection in markup keeps it: with no `value` of its own, + * the group adopts the one from whichever child is marked `active`. + * + * Without that it would clear `active` from every child on its first sync — + * and a consumer setting `active` itself would find it stripped back off, + * with nothing to say why. Drive the selection through `value` where you can; + * this is for markup that states its own. + */ +export const SelectionFromMarkup: Story = { + name: 'Selection from markup', + render: () => html` + + Landscape + Portrait + + `, + async play({canvasElement}) { + const group = canvasElement.querySelector('craft-button-group')!; + await group.updateComplete; + + const landscape = canvasElement.querySelector('craft-button')!; + + await expect(group.value).toBe('landscape'); + await expect(landscape.getAttribute('aria-pressed')).toBe('true'); + }, +}; diff --git a/packages/craftcms-ui/src/components/button-group/button-group.test.ts b/packages/craftcms-ui/src/components/button-group/button-group.test.ts index 8aad88d01c5..866ee04f10b 100644 --- a/packages/craftcms-ui/src/components/button-group/button-group.test.ts +++ b/packages/craftcms-ui/src/components/button-group/button-group.test.ts @@ -56,4 +56,84 @@ describe('craft-button-group', () => { expect(values).toEqual(['news']); expect(setFormValue).toHaveBeenCalled(); }); + + it('adopts its value from a child marked active in markup', async () => { + // A group given its selection in markup used to have it stripped: with no + // `value`, the first sync cleared `active` from every child. + document.body.innerHTML = ` + + + + + `; + + const group = document.querySelector('craft-button-group')!; + await group.updateComplete; + + const [landscape, portrait] = + document.querySelectorAll('craft-button'); + + expect(group.value).toBe('landscape'); + expect(landscape.hasAttribute('active')).toBe(true); + expect(landscape.getAttribute('aria-pressed')).toBe('true'); + expect(portrait.hasAttribute('active')).toBe(false); + }); + + it('lets an explicit value win over a child marked active', async () => { + document.body.innerHTML = ` + + + + + `; + + const group = document.querySelector('craft-button-group')!; + await group.updateComplete; + + const [landscape, portrait] = + document.querySelectorAll('craft-button'); + + expect(group.value).toBe('portrait'); + expect(landscape.hasAttribute('active')).toBe(false); + expect(portrait.hasAttribute('active')).toBe(true); + }); + + it('adopts only once, so a later sync cannot resurrect the old value', async () => { + document.body.innerHTML = ` + + + + + `; + + const group = document.querySelector('craft-button-group')!; + await group.updateComplete; + + group.value = 'portrait'; + await group.updateComplete; + + const [landscape, portrait] = + document.querySelectorAll('craft-button'); + + expect(group.value).toBe('portrait'); + expect(landscape.hasAttribute('active')).toBe(false); + expect(portrait.hasAttribute('active')).toBe(true); + }); + + it('leaves multi-select alone, which already reads active off its children', async () => { + document.body.innerHTML = ` + + + + + `; + + const group = document.querySelector('craft-button-group')!; + await group.updateComplete; + + const buttons = document.querySelectorAll('craft-button'); + + expect(group.value).toBeUndefined(); + expect([...buttons].every((b) => b.hasAttribute('active'))).toBe(true); + }); }); diff --git a/packages/craftcms-ui/src/components/button-group/button-group.ts b/packages/craftcms-ui/src/components/button-group/button-group.ts index ac1b99692e9..9beed65aca7 100644 --- a/packages/craftcms-ui/src/components/button-group/button-group.ts +++ b/packages/craftcms-ui/src/components/button-group/button-group.ts @@ -26,7 +26,14 @@ export default class CraftButtonGroup extends LitElement { /** Form field name. When set, enables selection mode. */ @property({reflect: true}) name: string; - /** The currently selected value in single-selection mode. */ + /** + * The currently selected value in single-selection mode. + * + * The group owns the children's `active` state and rewrites it from this, so + * drive the selection here rather than on the buttons. Left unset, it is + * seeded from whichever child is marked `active`, so markup that states its + * own selection keeps it. + */ @property({reflect: true}) value: string; /** Whether multiple buttons can be selected. */ @@ -103,7 +110,39 @@ export default class CraftButtonGroup extends LitElement { ); }; + /** + * Seeds `value` from a child already marked `active`, for a group handed its + * selection in markup rather than through the property. + * + * Without this a single-select group with no `value` clears `active` from + * every child on its first sync, silently destroying the selection instead of + * leaving it alone — and a consumer setting `active` itself gets it stripped + * back off on the next sync, with nothing to say why. + * `craft-radio-group` adopts its `name` from slotted inputs for the same + * reason. + * + * Multi-select needs none of this: it already reads `active` off the + * children rather than writing it. + */ + private _adoptSlottedValue() { + if (this.multiple || this.value !== undefined) { + return; + } + + const selected = this.querySelector('craft-button[active]'); + const value = selected?.getAttribute('value'); + + // No active child yet — leave `value` unset so a later sync can still + // adopt once the children have been parsed. There is nothing to clobber in + // the meantime. + if (value != null) { + this.value = value; + } + } + private _syncChildren() { + this._adoptSlottedValue(); + const buttons = this.querySelectorAll('craft-button'); buttons.forEach((btn) => { if (btn.getAttribute('type') !== 'button') { diff --git a/packages/craftcms-ui/src/components/button/button.styles.ts b/packages/craftcms-ui/src/components/button/button.styles.ts index 6d005f27495..d9f08224ee1 100644 --- a/packages/craftcms-ui/src/components/button/button.styles.ts +++ b/packages/craftcms-ui/src/components/button/button.styles.ts @@ -237,8 +237,11 @@ export default css` } :host([variant~='primary']:active), + :host([variant~='primary'][aria-pressed='true']), :host([variant~='danger']:active), - :host([variant~='solid']:active) { + :host([variant~='danger'][aria-pressed='true']), + :host([variant~='solid']:active), + :host([variant~='solid'][aria-pressed='true']) { --_active-background-color: hsl( from var(--c-color-fill-loud, var(--c-color-neutral-fill-loud)) h s calc(l - 10) @@ -440,6 +443,7 @@ export default css` the (non-positioned) anchor and swallows every pointer click before it can activate the link. Recreate the overlay on the anchor instead, so the full target navigates. */ + &::before { display: none; } @@ -466,6 +470,7 @@ export default css` /* Same minimum click area as Lion's :host::before (WCAG 2.5.5), but as part of the anchor so clicks on it follow the link. */ + &::before { content: ''; position: absolute; diff --git a/packages/craftcms-ui/src/components/button/button.test.ts b/packages/craftcms-ui/src/components/button/button.test.ts index 4b1fc925ce4..60aa969406d 100644 --- a/packages/craftcms-ui/src/components/button/button.test.ts +++ b/packages/craftcms-ui/src/components/button/button.test.ts @@ -262,3 +262,68 @@ describe('craft-button actions', () => { expect(fired).toBe(false); }); }); + +describe('craft-button toggle', () => { + it('derives aria-pressed from active', async () => { + const button = document.createElement('craft-button'); + button.toggle = true; + document.body.append(button); + await button.updateComplete; + + expect(button.getAttribute('aria-pressed')).toBe('false'); + + button.active = true; + await button.updateComplete; + + expect(button.getAttribute('aria-pressed')).toBe('true'); + }); + + it('leaves aria-pressed alone on a button that is not a toggle', async () => { + // craft-button-group sets aria-pressed on its children; overwriting it + // would be worse than leaving it be. + const button = document.createElement('craft-button'); + button.setAttribute('aria-pressed', 'true'); + document.body.append(button); + await button.updateComplete; + + button.active = false; + await button.updateComplete; + + expect(button.getAttribute('aria-pressed')).toBe('true'); + }); + + it('reports the state being asked for without changing active itself', async () => { + const button = document.createElement('craft-button'); + button.toggle = true; + document.body.append(button); + await button.updateComplete; + + let asked: boolean | undefined; + button.addEventListener('craft-toggle', (event) => { + asked = (event as CustomEvent<{active: boolean}>).detail.active; + }); + + button.click(); + await button.updateComplete; + + expect(asked).toBe(true); + // The owner of `active` decides; the button does not move on its own. + expect(button.active).toBe(false); + }); + + it('stays quiet when disabled', async () => { + const button = document.createElement('craft-button'); + button.toggle = true; + button.setAttribute('disabled', ''); + document.body.append(button); + await button.updateComplete; + + let fired = false; + button.addEventListener('craft-toggle', () => { + fired = true; + }); + button.click(); + + expect(fired).toBe(false); + }); +}); diff --git a/packages/craftcms-ui/src/components/button/button.ts b/packages/craftcms-ui/src/components/button/button.ts index bb06657c71d..50d046db50f 100644 --- a/packages/craftcms-ui/src/components/button/button.ts +++ b/packages/craftcms-ui/src/components/button/button.ts @@ -41,6 +41,10 @@ export type ButtonVariant = (typeof ButtonVariant)[keyof typeof ButtonVariant]; * @csspart suffix - The button's suffix slot. * @csspart spinner - Spinner that shows when the button is in a loading state. * @csspart link - The anchor element rendered when the button has an href. + * + * @event craft-toggle - Fired when a `toggle` button is activated. `detail.active` + * is the state being asked for. Cancelable — `active` is owned by whoever set + * it, and the button never changes it itself. */ export default class CraftButton extends LionButtonSubmit { static override get styles() { @@ -71,11 +75,13 @@ export default class CraftButton extends LionButtonSubmit { super.connectedCallback(); this.syncLinkHostState(); this.addEventListener('click', this.#handleActionClick); + this.addEventListener('click', this.#handleToggleClick); } override disconnectedCallback() { super.disconnectedCallback(); this.removeEventListener('click', this.#handleActionClick); + this.removeEventListener('click', this.#handleToggleClick); if (this.announcementTimer) { clearTimeout(this.announcementTimer); @@ -103,12 +109,43 @@ export default class CraftButton extends LionButtonSubmit { this.loading = false; } }; + /** + * Reports that a toggle was activated, for the owner of `active` to act on. + * + * Cancelable, so a consumer can refuse the change; `active` is left alone + * either way. + */ + #handleToggleClick = (event: Event) => { + if (!this.toggle || this.disabled || this.loading) { + return; + } + + this.dispatchEvent( + new CustomEvent('craft-toggle', { + bubbles: true, + composed: true, + cancelable: true, + detail: {active: !this.active, sourceEvent: event}, + }) + ); + }; + override updated(changedProperties: Map) { super.updated(changedProperties); if (changedProperties.has('href') || changedProperties.has('disabled')) { this.syncLinkHostState(); } + // Only while `toggle` is set: a plain button may carry an `aria-pressed` + // its owner manages (`craft-button-group` sets one on every child), and + // overwriting that would be worse than leaving it alone. + if ( + this.toggle && + (changedProperties.has('active') || changedProperties.has('toggle')) + ) { + this.setAttribute('aria-pressed', String(this.active)); + } + if (changedProperties.has('loading')) { if (this.loading) { this.announceLoading(); @@ -198,6 +235,25 @@ export default class CraftButton extends LionButtonSubmit { /** Whether the button is in a selected/active state (e.g. inside a radio button-group) */ @property({reflect: true, type: Boolean}) override active: boolean = false; + /** + * Makes the button a toggle: something that turns a thing on and off and + * stays that way, rather than firing a one-shot action. + * + * `aria-pressed` is then derived from `active`, so the pressed state a screen + * reader hears and the one people see can't drift apart — setting one and + * forgetting the other is the easy mistake here, and `active` already carries + * the visible state. + * + * Deliberately does *not* flip `active` itself. Selection may be owned + * elsewhere — `craft-button-group` writes `active` on its children, and a + * consumer may reject the change — so the button reports the intent through + * `toggle` and lets the owner decide. + * + * For an on/off setting, reach for `craft-switch` instead; a toggle button is + * for a control that acts on something, like a toolbar. + */ + @property({type: Boolean, reflect: true}) toggle: boolean = false; + /** Show a spinner instead of the label */ @property({reflect: true, type: Boolean}) loading: boolean = false; diff --git a/packages/craftcms-ui/src/components/dialog/dialog.stories.ts b/packages/craftcms-ui/src/components/dialog/dialog.stories.ts index a817c77c951..d52de3d9777 100644 --- a/packages/craftcms-ui/src/components/dialog/dialog.stories.ts +++ b/packages/craftcms-ui/src/components/dialog/dialog.stories.ts @@ -91,8 +91,25 @@ export const NonModal: Story = { }, }; +/** + * Fills the viewport, so the body row can hand a definite height down to + * whatever it slots — a canvas, say, that measures its container to decide how + * big to draw. + */ export const Fullscreen: Story = { - args: {fullscreen: true}, + args: {fullscreen: true, open: true}, + async play({canvasElement}) { + const dialog = canvasElement.querySelector('craft-dialog') as CraftDialog; + await dialog.updateComplete; + + const surface = dialog.shadowRoot!.querySelector('.surface')!; + const {width, height} = surface.getBoundingClientRect(); + + // The height is the half worth pinning: the surface used to carry only a + // `max-block-size`, which left it full-width but as short as its content. + await expect(Math.round(width)).toBe(window.innerWidth); + await expect(Math.round(height)).toBe(window.innerHeight); + }, }; /** Long content scrolls inside the body rather than growing the surface. */ @@ -113,3 +130,36 @@ export const Scrolling: Story = { export const ClosesOnOutsideClick: Story = { args: {'close-on-outside-click': true}, }; + +/** + * A dialog can carry its own theme. `[data-theme]` re-resolves the semantic + * tokens against the palette it names, so everything slotted in follows — + * including each component's shadow root, since custom properties inherit past + * the boundary. + */ +export const Themed: Story = { + args: {label: 'Dark dialog', open: true}, + render: (args) => + template({...args, 'data-theme': 'dark'}, html`${body}${footerClose}`), + async play({canvasElement}) { + const dialog = canvasElement.querySelector('craft-dialog') as CraftDialog; + await dialog.updateComplete; + + const inside = getComputedStyle(dialog); + const outside = getComputedStyle(canvasElement); + + // The raw palette and the semantic tokens built out of it both follow the + // theme. The semantic half is the one that used to stay light: a custom + // property carrying a `var()` is substituted where it's declared, so + // resolving these only at `:root` baked in the root's palette. + await expect(inside.getPropertyValue('--color-base-50').trim()).not.toBe( + outside.getPropertyValue('--color-base-50').trim() + ); + await expect(inside.getPropertyValue('--c-surface-raised').trim()).not.toBe( + outside.getPropertyValue('--c-surface-raised').trim() + ); + await expect( + inside.getPropertyValue('--c-color-neutral-fill-quiet').trim() + ).not.toBe(outside.getPropertyValue('--c-color-neutral-fill-quiet').trim()); + }, +}; diff --git a/packages/craftcms-ui/src/components/dialog/dialog.styles.ts b/packages/craftcms-ui/src/components/dialog/dialog.styles.ts index b99dc6b0710..ae85c2f1fa6 100644 --- a/packages/craftcms-ui/src/components/dialog/dialog.styles.ts +++ b/packages/craftcms-ui/src/components/dialog/dialog.styles.ts @@ -19,6 +19,7 @@ export default css` --c-dialog-max-inline-size, min(90vw, 40rem) ); + --_dialog-block-size: var(--c-dialog-block-size, auto); --_dialog-max-block-size: var(--c-dialog-max-block-size, 85dvh); } @@ -26,6 +27,13 @@ export default css` --_dialog-inline-size: var(--c-dialog-inline-size, 100vw); --_dialog-min-inline-size: var(--c-dialog-min-inline-size, 100vw); --_dialog-max-inline-size: var(--c-dialog-max-inline-size, 100vw); + /* + A real size, not just a cap: with \`max-block-size\` alone the surface is + only as tall as its content, so a fullscreen dialog came out full-width + but short. Its body row can then hand a definite height to whatever it + slots. + */ + --_dialog-block-size: var(--c-dialog-block-size, 100dvh); --_dialog-max-block-size: var(--c-dialog-max-block-size, 100dvh); } @@ -68,6 +76,7 @@ export default css` inline-size: var(--_dialog-inline-size); min-inline-size: var(--_dialog-min-inline-size); max-inline-size: var(--_dialog-max-inline-size); + block-size: var(--_dialog-block-size); max-block-size: var(--_dialog-max-block-size); background-color: var(--c-surface-raised); border-radius: var(--c-radius-md); diff --git a/packages/craftcms-ui/src/components/dialog/dialog.test.ts b/packages/craftcms-ui/src/components/dialog/dialog.test.ts index 1a827333f14..f0839d0f333 100644 --- a/packages/craftcms-ui/src/components/dialog/dialog.test.ts +++ b/packages/craftcms-ui/src/components/dialog/dialog.test.ts @@ -360,3 +360,56 @@ describe('craft-dialog', () => { }); }); }); + +describe('craft-dialog before-hide', () => { + it('stays open when a listener cancels the dismissal', async () => { + const dialog = document.createElement('craft-dialog'); + dialog.opened = true; + document.body.append(dialog); + await dialog.updateComplete; + + dialog.addEventListener('craft-before-hide', (event) => { + event.preventDefault(); + }); + + dialog.shadowRoot!.querySelector('.close')!.click(); + await dialog.updateComplete; + + expect(dialog.opened).toBe(true); + }); + + it('closes when nothing objects', async () => { + const dialog = document.createElement('craft-dialog'); + dialog.opened = true; + document.body.append(dialog); + await dialog.updateComplete; + + let asked = false; + dialog.addEventListener('craft-before-hide', () => { + asked = true; + }); + + dialog.shadowRoot!.querySelector('.close')!.click(); + await dialog.updateComplete; + + expect(asked).toBe(true); + expect(dialog.opened).toBe(false); + }); + + it('does not ask when opened is set to false directly', async () => { + const dialog = document.createElement('craft-dialog'); + dialog.opened = true; + document.body.append(dialog); + await dialog.updateComplete; + + let asked = false; + dialog.addEventListener('craft-before-hide', () => { + asked = true; + }); + + dialog.opened = false; + await dialog.updateComplete; + + expect(asked).toBe(false); + }); +}); diff --git a/packages/craftcms-ui/src/components/dialog/dialog.ts b/packages/craftcms-ui/src/components/dialog/dialog.ts index 0136d002dcf..e6c29f2ff5b 100644 --- a/packages/craftcms-ui/src/components/dialog/dialog.ts +++ b/packages/craftcms-ui/src/components/dialog/dialog.ts @@ -58,6 +58,9 @@ function releasePageScroll(): void { * @slot footer - Footer content, typically buttons. * @csspart dialog - The native `` element. * @csspart surface - The visible panel inside it. + * + * @cssproperty --c-dialog-block-size - The surface's height. Defaults to + * `auto`, and to `100dvh` under `fullscreen`. * @csspart header - The header row. * @csspart title - The heading. * @csspart close - The header close button. @@ -65,6 +68,10 @@ function releasePageScroll(): void { * @csspart footer - The footer row. * * @fires craft-show - The dialog has opened. + * @fires craft-before-hide - A dismissal was asked for, by the close button, + * Escape or the backdrop. Cancelable: call `preventDefault()` to keep the + * dialog open — for confirming away unsaved work, say. Does not fire when + * `opened` is set to false directly, which is a decision already made. * @fires craft-hide - The dialog has closed. * @fires craft-after-show - The dialog has opened and finished updating. * @fires craft-after-hide - The dialog has closed and finished updating. @@ -248,7 +255,7 @@ export default class CraftDialog extends LitElement { protected renderFooter(): TemplateResult { return html`
- +
`; } @@ -337,7 +344,20 @@ export default class CraftDialog extends LitElement { * intercepting four listeners and the platform's own Escape handling. */ protected requestClose(): void { - this.opened = false; + // Every dismissal — the close button, Escape, the backdrop — comes through + // here, so asking once covers all of them. Cancelable, for a dialog holding + // work that would be lost. + const allowed = this.dispatchEvent( + new CustomEvent('craft-before-hide', { + bubbles: true, + composed: true, + cancelable: true, + }) + ); + + if (allowed) { + this.opened = false; + } } /** diff --git a/packages/craftcms-ui/src/components/slide-rule/slide-rule.stories.ts b/packages/craftcms-ui/src/components/slide-rule/slide-rule.stories.ts index 8ac024dd600..b54d1522e46 100644 --- a/packages/craftcms-ui/src/components/slide-rule/slide-rule.stories.ts +++ b/packages/craftcms-ui/src/components/slide-rule/slide-rule.stories.ts @@ -1,4 +1,6 @@ import type {Meta, StoryObj} from '@storybook/web-components-vite'; +import {html} from 'lit'; +import {expect} from 'storybook/test'; import type CraftSlideRule from './slide-rule.js'; import {getStorybookHelpers} from '@wc-toolkit/storybook-helpers'; import './slide-rule.js'; @@ -31,3 +33,479 @@ export const StartingAngle: Story = { value: 15, }, }; + +/** + * The cursor is pinned to the middle of the rule, and the strip is centred on + * the width of the window it slides behind — so the two only agree while that + * window is the rule's width rather than the strip's. It used to size to its + * content, leaving the strip centred on a box wider than the visible one and + * the cursor sitting about 20 degrees off zero. + */ +export const CentredOnZero: Story = { + args: {}, + async play({canvasElement}) { + const rule = canvasElement.querySelector( + 'craft-slide-rule' + ) as CraftSlideRule; + await rule.updateComplete; + + const graduations = + rule.shadowRoot!.querySelector('.graduations')!; + const strip = + rule.shadowRoot!.querySelector('.graduations ul')!; + + // The window is the rule, not the strip behind it. + await expect(graduations.offsetWidth).toBeLessThanOrEqual(rule.offsetWidth); + await expect(strip.offsetWidth).toBeGreaterThan(graduations.offsetWidth); + + // And zero lands under the cursor, at the middle of the window. + const zero = rule.shadowRoot!.querySelector( + '[data-graduation="0"]' + )!; + const offset = + zero.getBoundingClientRect().left - + graduations.getBoundingClientRect().left; + + await expect(Math.abs(offset - graduations.offsetWidth / 2)).toBeLessThan( + 6 + ); + + // Each graduation is 10px, which is what the positioning maths assumes. + await expect(strip.offsetWidth).toBe(141 * 10); + }, +}; + +/** + * The graduations fade towards the edges by masking their own alpha, not by + * painting a gradient of the page background over them. The overlay this + * replaces was hard-coded to `--gray-900`, so anywhere lighter got a dark band + * smeared across both ends instead of a fade — which this story would show. + */ +export const OnALightBackground: Story = { + args: {}, + decorators: [ + (story) => + html`
${story()}
`, + ], + async play({canvasElement}) { + const rule = canvasElement.querySelector( + 'craft-slide-rule' + ) as CraftSlideRule; + await rule.updateComplete; + + const graduations = + rule.shadowRoot!.querySelector('.graduations')!; + + // Nothing paints over the graduations any more. + await expect(rule.shadowRoot!.querySelector('.overlay')).toBe(null); + + // A real browser resolves the mask, so this checks more than the + // stylesheet text the happy-dom test can see. + const mask = getComputedStyle(graduations).maskImage; + + await expect(mask).not.toBe('none'); + + // Resolved, so `transparent` comes back as zero-alpha black: see-through + // at both ends, fully opaque across the middle where the value is read. + await expect(mask).toBe( + 'linear-gradient(to right, rgba(0, 0, 0, 0) 0%, rgb(0, 0, 0) 15%, ' + + 'rgb(0, 0, 0) 85%, rgba(0, 0, 0, 0) 100%)' + ); + }, +}; + +/** + * The graduations size from `--c-slide-rule-*` tokens. The width is the + * load-bearing one: the strip's positioning is in units of one graduation, so + * the component measures what was rendered rather than trusting the default. + * Assume the default and a wider graduation slides zero out from under the + * cursor — the same failure the whitespace between inline-block graduations + * used to cause. + */ +export const CustomGraduations: Story = { + args: {}, + decorators: [ + (story) => html` +
+ ${story()} +
+ `, + ], + async play({canvasElement}) { + const rule = canvasElement.querySelector( + 'craft-slide-rule' + ) as CraftSlideRule; + await rule.updateComplete; + + const graduations = + rule.shadowRoot!.querySelector('.graduations')!; + const strip = + rule.shadowRoot!.querySelector('.graduations ul')!; + + // The token reached the graduations, so the strip is wider than default. + await expect(strip.offsetWidth).toBe(141 * 16); + + // The height token is the whole control; the graduations take whatever the + // cursor row and the gap above them leave, down to the bottom of it. + await expect(rule.offsetHeight).toBe(52); + + const cursor = rule.shadowRoot!.querySelector('.cursor')!; + const gap = + graduations.getBoundingClientRect().top - + cursor.getBoundingClientRect().bottom; + + await expect(gap).toBeCloseTo(4, 1); + await expect( + graduations.getBoundingClientRect().bottom - + rule.getBoundingClientRect().bottom + ).toBeCloseTo(0, 1); + + // Every fifth tick is half again as tall, derived from the one token. + const tickHeight = (selector: string) => + getComputedStyle(rule.shadowRoot!.querySelector(selector)!) + .blockSize; + + await expect(tickHeight('[data-graduation="1"]')).toBe('10px'); + await expect(tickHeight('[data-graduation="5"]')).toBe('15px'); + + // And zero still lands under the cursor, which only holds if the maths + // measured 16px rather than assuming 10. + const zero = rule.shadowRoot!.querySelector( + '[data-graduation="0"]' + )!; + const offset = + zero.getBoundingClientRect().left - + graduations.getBoundingClientRect().left; + + await expect(Math.abs(offset - graduations.offsetWidth / 2)).toBeLessThan( + 10 + ); + }, +}; + +/** + * The ticks, labels and accent all come from tokens now, so the rule follows + * the CP's scheme instead of the legacy image editor's hard-coded `#63a6e1` + * and `--white`. + */ +export const CustomColors: Story = { + args: {value: 12}, + decorators: [ + (story) => html` +
+ ${story()} +
+ `, + ], + async play({canvasElement}) { + const rule = canvasElement.querySelector( + 'craft-slide-rule' + ) as CraftSlideRule; + await rule.updateComplete; + + const cursor = rule.shadowRoot!.querySelector('.cursor')!; + const tick = rule.shadowRoot!.querySelector('.graduation')!; + + await expect(getComputedStyle(cursor).borderBlockStartColor).toBe( + 'rgb(249, 115, 22)' + ); + await expect(getComputedStyle(tick).backgroundColor).toBe( + 'rgb(124, 58, 237)' + ); + + // Labels have no colour of their own — they take whatever text colour + // they land in, so they can never be the one thing that doesn't match. + const label = rule.shadowRoot!.querySelector( + '.main-graduation .label' + )!; + + await expect(getComputedStyle(label).color).toBe('rgb(124, 58, 237)'); + + // And the digits are monospaced, so they don't jitter as the strip slides. + await expect(getComputedStyle(label).fontFamily).toContain('monospace'); + }, +}; + +/** + * The colours are tokens, so they re-resolve per theme rather than staying at + * the legacy image editor's hard-coded `#63a6e1` and `--white` — which only + * ever read correctly on a dark background. + */ +export const AcrossThemes: Story = { + args: {}, + decorators: [ + (story) => html` +
+ ${story()} +
+
+ ${story()} +
+ `, + ], + async play({canvasElement}) { + const [light, dark] = [ + ...canvasElement.querySelectorAll('craft-slide-rule'), + ] as CraftSlideRule[]; + + await light.updateComplete; + await dark.updateComplete; + + const tickColor = (rule: CraftSlideRule) => + getComputedStyle( + rule.shadowRoot!.querySelector('.graduation')! + ).backgroundColor; + + // Dark slate on light, light slate on dark — a hard-coded colour would + // come back the same in both. + await expect(tickColor(light)).toBe('rgb(58, 69, 90)'); + await expect(tickColor(dark)).toBe('rgb(203, 213, 224)'); + + const cursorColor = (rule: CraftSlideRule) => + getComputedStyle(rule.shadowRoot!.querySelector('.cursor')!) + .borderBlockStartColor; + + await expect(cursorColor(light)).toBe('rgb(33, 56, 167)'); + await expect(cursorColor(dark)).toBe('rgb(188, 213, 251)'); + }, +}; + +/** + * The cursor sits in its own grid row above the ruler, centred by the layout + * rather than by `left: 50%` and a margin pulling it back by half its width. + * That margin was a magic -4px against a 10px-wide triangle, so the cursor + * landed a pixel off the centre the strip is positioned against — and a tap, + * which measures its delta from the cursor, inherited the error. + */ +export const CursorCentring: Story = { + args: {}, + async play({canvasElement}) { + const rule = canvasElement.querySelector( + 'craft-slide-rule' + ) as CraftSlideRule; + await rule.updateComplete; + + const cursor = rule.shadowRoot!.querySelector('.cursor')!; + const graduations = + rule.shadowRoot!.querySelector('.graduations')!; + + const cursorBox = cursor.getBoundingClientRect(); + const windowBox = graduations.getBoundingClientRect(); + + // The triangle is drawn out of the borders of a zero-width box, so its + // centre is half the border box across — the same point the JS uses. + const cursorCentre = cursorBox.left + cursor.offsetWidth / 2; + const windowCentre = windowBox.left + graduations.offsetWidth / 2; + + await expect(Math.abs(cursorCentre - windowCentre)).toBeLessThan(0.5); + + // And the mark it points at is centred on that same point. A graduation is + // a position on the ruler, so the mark straddles it -- begin the mark + // there instead and it sits half its own thickness to the right, which is + // the cursor pointing into the gap beside it. + const zero = rule.shadowRoot!.querySelector( + '[data-graduation="0"]' + )!; + const zeroBox = zero.getBoundingClientRect(); + + await expect( + Math.abs(zeroBox.left + zeroBox.width / 2 - cursorCentre) + ).toBeLessThan(0.5); + + // Its own row, so it sits above the ruler rather than over the ticks, with + // a little air between it and the marks it points at. + await expect(windowBox.top - cursorBox.bottom).toBeCloseTo(4, 1); + + // And that air is taken out of the space above the cursor, not added to + // the total -- the control is still the height the token asks for. + const root = rule.shadowRoot!.querySelector('.slide-rule')!; + + await expect( + windowBox.bottom - root.getBoundingClientRect().bottom + ).toBeCloseTo(0, 1); + + // The focus ring still anchors to the cursor, which is the only reason + // the cursor is positioned at all now. + const ring = getComputedStyle(cursor, '::after'); + + await expect(ring.position).toBe('absolute'); + await expect(parseFloat(ring.width)).toBeGreaterThan(0); + }, +}; + +/** + * Each graduation is the mark, not a box with a pseudo-element drawing one. + * The grid track supplies the spacing the box used to, so the item is free to + * be the tick — which also means the marks are now a couple of pixels wide, + * and the strip behind them has to stay the thing a drag lands on. + */ +export const GraduationIsTheMark: Story = { + args: {}, + async play({canvasElement}) { + const rule = canvasElement.querySelector( + 'craft-slide-rule' + ) as CraftSlideRule; + await rule.updateComplete; + + const minor = rule.shadowRoot!.querySelector( + '[data-graduation="1"]' + )!; + const major = rule.shadowRoot!.querySelector( + '[data-graduation="5"]' + )!; + + // Nothing is drawn by a pseudo-element any more. + await expect(getComputedStyle(minor, '::before').content).toBe('none'); + + // The element is the tick: 2px by 6px, twice as thick and half again as + // tall every fifth mark. + await expect(minor.offsetWidth).toBe(2); + await expect(minor.offsetHeight).toBe(6); + await expect(major.offsetWidth).toBe(4); + await expect(major.offsetHeight).toBe(9); + + // Both sit on the same centre line, which is what the strip is positioned + // against — the thinner mark is inset by half the difference. + const centre = (el: HTMLElement) => + el.getBoundingClientRect().left + el.offsetWidth / 2; + + await expect(centre(major) - centre(minor)).toBeCloseTo(40, 1); + + // Exactly one graduation apart, thick mark to thin -- they share a centre + // line because each is centred on its own point, not nudged onto one. + const next = rule.shadowRoot!.querySelector( + '[data-graduation="2"]' + )!; + + await expect(centre(next) - centre(minor)).toBeCloseTo(10, 1); + + // The label clears the mark rather than butting against it, and still + // ends up inside the clip -- the floor on the graduations accounts for + // the gap as well as the label. + const label = rule.shadowRoot!.querySelector( + '.main-graduation .label' + )!; + const window_ = + rule.shadowRoot!.querySelector('.graduations')!; + + await expect( + label.getBoundingClientRect().top - major.getBoundingClientRect().bottom + ).toBeCloseTo(2, 1); + await expect( + window_.getBoundingClientRect().bottom - + label.getBoundingClientRect().bottom + ).toBeGreaterThan(0); + + // A press in the gap between two marks still has something to land on: + // the strip covers the window, and only the window itself is excluded. + const graduations = + rule.shadowRoot!.querySelector('.graduations')!; + const strip = + rule.shadowRoot!.querySelector('.graduations ul')!; + const box = minor.getBoundingClientRect(); + + const inTheGap = rule.shadowRoot!.elementFromPoint( + box.right + 3, + box.top + 2 + )!; + + await expect(inTheGap).not.toBe(minor); + await expect(graduations.contains(inTheGap)).toBe(true); + await expect(inTheGap).not.toBe(graduations); + await expect(strip.contains(inTheGap) || inTheGap === strip).toBe(true); + }, +}; + +/** + * The span between zero and the current value, in the accent fill a selected + * table row or menu item uses. It's a band rather than lit-up graduations + * because the value is continuous: 12.4 degrees falls between two marks, and + * there is nothing there to light up. + */ +export const ValueIndicator: Story = { + args: {value: 12.4}, + async play({canvasElement}) { + const rule = canvasElement.querySelector( + 'craft-slide-rule' + ) as CraftSlideRule; + await rule.updateComplete; + + const indicator = + rule.shadowRoot!.querySelector('.indicator')!; + const cursor = rule.shadowRoot!.querySelector('.cursor')!; + const zero = rule.shadowRoot!.querySelector( + '[data-graduation="0"]' + )!; + + const box = indicator.getBoundingClientRect(); + const cursorCentre = + cursor.getBoundingClientRect().left + cursor.offsetWidth / 2; + const zeroCentre = + zero.getBoundingClientRect().left + + zero.getBoundingClientRect().width / 2; + + // It runs from the mark for zero to the value under the cursor, landing + // 4px past the 12th graduation rather than on it. + await expect(box.left).toBeCloseTo(zeroCentre, 0); + await expect(box.right).toBeCloseTo(cursorCentre, 0); + await expect(box.width).toBeCloseTo(124, 0); + + // The same accent a selected row takes, so a selection reads the same + // wherever it is in the CP. + const style = getComputedStyle(indicator); + const accent = getComputedStyle(rule).getPropertyValue( + '--c-color-accent-fill-quiet' + ); + + await expect(accent.trim()).not.toBe(''); + await expect(style.backgroundColor).not.toBe('rgba(0, 0, 0, 0)'); + + // And it sits behind the marks rather than over them. + const atZero = rule.shadowRoot!.elementFromPoint( + zeroCentre, + zero.getBoundingClientRect().top + 2 + ); + + await expect(atZero).toBe(zero); + }, +}; + +/** + * At zero the band has no width, so all that would show is the pair of + * borders standing either side of the cursor. It stays out of the way until + * there is a value to describe. + */ +export const NoValueYet: Story = { + args: {value: 0}, + async play({canvasElement}) { + const rule = canvasElement.querySelector( + 'craft-slide-rule' + ) as CraftSlideRule; + await rule.updateComplete; + + const indicator = + rule.shadowRoot!.querySelector('.indicator')!; + + // Nothing in the sheet sets `display` on it, so `hidden` is free to. + await expect(getComputedStyle(indicator).display).toBe('none'); + await expect(indicator.getBoundingClientRect().width).toBe(0); + }, +}; diff --git a/packages/craftcms-ui/src/components/slide-rule/slide-rule.styles.ts b/packages/craftcms-ui/src/components/slide-rule/slide-rule.styles.ts index 88f3ef8d924..1a1a42b12d6 100644 --- a/packages/craftcms-ui/src/components/slide-rule/slide-rule.styles.ts +++ b/packages/craftcms-ui/src/components/slide-rule/slide-rule.styles.ts @@ -1,35 +1,83 @@ import {css} from 'lit'; // Ported from the legacy packages/craftcms-legacy/cp/src/css/_image_editor.scss -// `.slide-rule` block into the component's shadow root. `--focus-ring` / `--white` -// inherit from the page; the accent color is the legacy hard-coded value. +// `.slide-rule` block into the component's shadow root. The legacy hard-coded +// colours (`#63a6e1`, `--white`, `--gray-900`) are gone: everything here comes +// from the `--c-slide-rule-*` tokens, so the rule follows the CP's scheme and +// its themes rather than assuming a dark image editor around it. +// +// Nothing paints the page background any more either -- see the mask on +// `.graduations` -- so it can sit on anything. export default css` :host { display: block; } .slide-rule { - position: relative; - padding-block: 10px; - padding-inline: 0; + /* + Seeded from the public tokens so the rest of the sheet reads off one + name apiece. The graduation width is the load-bearing one: the strip's + positioning maths is in units of it, and the JS measures what was + actually rendered rather than assuming the default. + */ + --_graduation-width: var(--c-slide-rule-graduation-width, 8px); + --_graduation-height: var(--c-slide-rule-graduation-height, 10px); + --_graduation-color: var(--c-slide-rule-graduation-color, currentColor); + --_accent-color: var(--c-slide-rule-accent-color, currentColor); + + /* Every fifth graduation, half again as tall and twice as thick as the + rest. Derived rather than their own knobs, so they can't drift out of + proportion with the graduation they are a louder version of. */ + --_graduation-main-height: calc(var(--_graduation-height) * 1.5); + --_graduation-thickness: 2px; + --_graduation-main-thickness: calc(var(--_graduation-thickness) * 2); + + /* A label sits this far below the tallest mark, and takes about this much + room once it is there -- the floor on the graduations uses both. */ + --_label-gap: var(--c-spacing-xs); + --_label-space: calc(12rem / 16); + + /* + The cursor stacked above the ruler it points at. It used to be absolutely + positioned at 50% with a -4px margin pulling it back by half its width -- + a magic number that landed it a pixel off the centre the strip is + positioned against, which taps then measured their delta from. Letting + the layout centre it is exact, and stacking the two means they can no + longer overlap by accident. + */ + display: flex; + flex-direction: column; + /* Sets the cursor off the marks it points at. Taken out of the space that + used to sit above the cursor rather than added to the total, so the + control stays the height the token asks for. That slack is now spent: + widening this again grows the control. */ + gap: var(--c-spacing-sm); outline: none; + height: var(--c-slide-rule-height, 40px); + + /* Digits that don't change width as the strip slides past. */ + font-family: var(--c-font-mono); } .slide-rule:focus-visible .cursor::after { - box-shadow: var(--focus-ring); + outline: var(--c-focus-outline-width) var(--c-focus-outline-style) + var(--c-color-focus-outline); + outline-offset: var(--c-focus-outline-offset); + border-radius: var(--c-radius-sm); } .cursor { - position: absolute; - margin-inline-start: calc(-4 / 16 * 1rem); - margin-block-start: 4px; - inset-inline-start: 50%; - z-index: 1; + /* The cross axis of a column, so this is the horizontal centring that + replaced the margin. justify-self is the grid spelling and does + nothing here. */ + align-self: center; + /* Not for placement -- the focus ring below anchors to it. */ + position: relative; width: 0; height: 0; border-inline-start: calc(5 / 16 * 1rem) solid transparent; border-inline-end: calc(5 / 16 * 1rem) solid transparent; - border-block-start: calc(5 / 16 * 1rem) solid #63a6e1; + border-block-start: calc(5 / 16 * 1rem) solid var(--_accent-color); } .cursor::after { @@ -42,91 +90,193 @@ export default css` transform: translateX(-50%); } - .overlay { - z-index: 2; - position: absolute; - inset-block: 0 1px; - inset-inline: 0; - pointer-events: none; - background-image: linear-gradient( + .graduations { + /* + Takes the height the cursor row leaves behind. A height has to come from + somewhere: the graduations' only content is an absolutely positioned + label, so there is nothing here to size to. It also has to end up + definite, because the strip sizes to it and the graduations are the + pointer targets -- a zero-height strip is one a drag can never hit. + + The floor keeps a taller tick from pushing its label out of the clip. + */ + flex: 1; + min-block-size: calc( + var(--_graduation-main-height) + var(--_label-gap) + var(--_label-space) + ); + position: relative; + /* + This element is the window the strip slides behind, and the positioning + maths centres the strip on its width — so it has to be the width of the + rule, not of the strip inside it. Without the clip it sizes to its + content, and the strip is centred on a box wider than the one the cursor + sits in the middle of: about 20 degrees out for the default range. + + The legacy markup got this from a wrapper around the component -- + .straightening, with a max-width and its own clip -- so the component + owns it now rather than depending on where it is placed. + */ + overflow: hidden; + + /* + The graduations fade out towards the edges. This masks their own alpha + rather than painting a gradient of the page background over them, so the + rule holds up on any background -- the overlay it replaces was a hard + --gray-900 and smeared a dark band across anything lighter. + + Masking the window rather than the graduations themselves keeps it free + as the strip slides underneath: the fade belongs to the visible box, so + dragging doesn't have to recompute anything per graduation. + */ + mask-image: linear-gradient( to right, - var(--gray-900) 0%, - transparent 15%, - transparent 85%, - var(--gray-900) 100% + transparent 0%, + #000 15%, + #000 85%, + transparent 100% ); } - .graduations { - white-space: nowrap; - height: 40px; - position: relative; + /* + The span between zero and the current value, in the accent fill a selected + row or menu item uses -- so a selection reads the same here as it does + anywhere else in the CP. + + A band rather than lit-up graduations: the value is continuous, so it + usually falls between two marks and there is nothing there to light up. + + Hidden at zero, where the band has no width and would be nothing but the + two borders standing either side of the cursor. + + Behind the strip, which is why both are positioned and this one comes + first. + */ + .indicator { + position: absolute; + /* The width the JS sets is the span from zero to the value, borders and + all -- a content box would make the band two pixels wider than the + value it stands for. */ + box-sizing: border-box; + inset-block: 0; + inset-inline-start: 50%; + inline-size: 0; + background: var(--c-color-accent-fill-quiet); + border-inline: 1px solid var(--c-color-accent-border-quiet); + pointer-events: none; + transition: + 200ms inline-size linear, + 200ms translate linear; + } + + .slide-rule.dragging .indicator { + transition: none; } .graduations ul { position: relative; - float: inline-start; - height: 40px; + /* + One column per graduation, sized by the track. The width is declared once + here rather than on every item, and a fixed track has no reason to shrink + -- which is what the flex: none on each item was for. + + Whatever lays this out has to put the graduations flush against each + other. As inline-blocks the newlines between them in the template each + rendered as a space, making the strip wider than the graduation apiece + the positioning maths works in, which put zero nowhere near the cursor. + */ + display: grid; + grid-auto-flow: column; + grid-auto-columns: var(--_graduation-width); + + /* + Shrink-to-fit around those tracks, which is what the float here used to + buy. As a plain block the strip would size to the window it slides + behind instead, and the JS measures this element to learn how wide a + graduation actually came out. + */ + width: max-content; + + height: 100%; margin: 0; padding: 0; list-style: none; - /* "left" (not a logical property) because that's what the JS sets. */ - transition: 200ms left linear; /* stylelint-disable-line */ + /* + Translated rather than offset: sliding the strip is then a compositor + job with no layout behind it, which is what a drag is doing every frame. + */ + transition: 200ms transform linear; + + /* On the strip, not the marks: a press anywhere in it drags, and the marks + are only a couple of pixels wide to aim at. */ + cursor: pointer; } - .slide-rule.dragging .graduations ul { + .slide-rule.dragging .graduations ul, + .slide-rule.dragging .indicator { transition: none; } - .graduations ul li { - display: inline-block; - font-size: 8px; - position: relative; - width: 10px; + /* Until the first placement has been rendered -- see #markPlaced(). */ + .slide-rule:not(.placed) .graduations ul, + .slide-rule:not(.placed) .indicator { + transition: none; } - .graduations ul li:hover { - cursor: pointer; - } + .graduations ul li { + /* + The graduation is the mark, rather than a box holding a pseudo-element + that draws one. The grid track supplies the spacing the box used to, so + the item is free to be exactly the tick it represents. + */ + justify-self: start; + /* + A graduation is a point on the ruler, and the maths puts that point at + the start of the track -- so the mark has to straddle it rather than + begin at it, or every mark sits half its own thickness to the right of + the value it stands for, and the cursor points between them. - .graduations ul li:hover::before { - border-inline-start-color: #63a6e1; + Centring on the point rather than in the track also means marks of + different thicknesses line up on it for free. + */ + translate: -50%; + inline-size: var(--_graduation-thickness); + block-size: var(--_graduation-height); + background: var(--_graduation-color); + + font-size: 8px; + /* The label is positioned against this. */ + position: relative; } - .graduations ul li:not(.main-graduation) { - inset-inline-start: 1px; + .graduations ul li.main-graduation { + inline-size: var(--_graduation-main-thickness); + block-size: var(--_graduation-main-height); } - .graduations ul li.main-graduation::before { - border-inline-start-width: 4px; - height: 10px; + .graduations ul li:hover { + background: var(--_accent-color); } .graduations ul li .label { width: 20px; position: absolute; - inset-block-start: 10px; - inset-inline-start: -9px; + /* Clear of the tallest mark, so a taller graduation pushes the labels + down with it rather than crowding them. */ + inset-block-start: calc(var(--_graduation-main-height) + var(--_label-gap)); + /* Centred on the mark, whatever it is -- the -9px this replaces was half + of a 20px label against a box that is no longer 10px wide. */ + inset-inline-start: 50%; + transform: translateX(-50%); display: none; text-align: center; + /* Narrower than some labels are long, and a leading minus is a break + opportunity -- so they stay on one line explicitly. */ + white-space: nowrap; } .graduations ul li.main-graduation .label { display: block; cursor: default; } - - .graduations ul li.selected::before { - border-inline-start-color: #63a6e1; - } - - .graduations ul li::before { - content: ''; - position: absolute; - border-inline-start: 2px solid var(--white); - height: 6px; - inset-block-start: 0; - inset-inline-start: 0; - } `; diff --git a/packages/craftcms-ui/src/components/slide-rule/slide-rule.test.ts b/packages/craftcms-ui/src/components/slide-rule/slide-rule.test.ts index dae98b43d2d..f47a421259c 100644 --- a/packages/craftcms-ui/src/components/slide-rule/slide-rule.test.ts +++ b/packages/craftcms-ui/src/components/slide-rule/slide-rule.test.ts @@ -49,13 +49,14 @@ describe('craft-slide-rule', () => { expect(main).toHaveLength(29); }); - it('selects graduations between zero and the current value', async () => { - const element = await createSlideRule({value: 10}); - const selected = graduations(element) - .filter((g) => g.classList.contains('selected')) - .map((g) => Number(g.dataset.graduation)); - - expect(selected).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + it('leaves the graduations themselves unmarked', async () => { + // The span from zero is the indicator's job. Lighting up graduations + // can't say where a continuous value is -- it usually falls between two. + const element = await createSlideRule({value: 10.4}); + + expect( + graduations(element).filter((g) => g.classList.contains('selected')) + ).toHaveLength(0); }); it('moves the value with the keyboard and emits change', async () => { @@ -105,3 +106,147 @@ describe('craft-slide-rule', () => { expect(r.getAttribute('aria-valuetext')).toContain('12'); }); }); + +describe('craft-slide-rule positioning', () => { + it('waits for a measurable window rather than centring against zero', async () => { + // Rendered inside something with no layout — a closed dialog — the window + // measures 0, and centring against that lands the strip half its own + // length out: 20 degrees for the default range. + const host = document.createElement('div'); + host.style.display = 'none'; + document.body.append(host); + + const rule = document.createElement('craft-slide-rule'); + host.append(rule); + await rule.updateComplete; + + const list = + rule.shadowRoot!.querySelector('.graduations ul')!; + + expect(list.style.transform).toBe(''); + }); +}); + +describe('craft-slide-rule edge fade', () => { + it('fades the graduations with a mask rather than the page background', async () => { + // The fade used to be a gradient of --gray-900 painted on top, which only + // reads as a fade over a --gray-900 background and smears a dark band + // across anything lighter. Masking the window fades the graduations + // themselves, so the rule holds up wherever it is put. + const element = await createSlideRule(); + const window_ = + element.shadowRoot!.querySelector('.graduations')!; + + expect(element.shadowRoot!.querySelector('.overlay')).toBe(null); + + const mask = getComputedStyle(window_).getPropertyValue('mask-image'); + + expect(mask).toContain('linear-gradient'); + expect(mask).not.toBe('none'); + }); + + it('leaves the middle unmasked, where the value is read', async () => { + const element = await createSlideRule(); + const mask = getComputedStyle( + element.shadowRoot!.querySelector('.graduations')! + ).getPropertyValue('mask-image'); + + // Transparent only at the two ends; fully opaque across the middle, where + // the cursor sits and the value is read. + expect(mask.replace(/\s+/g, ' ')).toMatch( + /transparent 0%, [^,]+ 15%, [^,]+ 85%, transparent 100%/ + ); + }); +}); + +describe('craft-slide-rule value indicator', () => { + function indicator(element: CraftSlideRule): HTMLElement { + return element.shadowRoot!.querySelector('.indicator') as HTMLElement; + } + + it('spans from the middle of the window out to zero', async () => { + // 10px a degree by default, and the value always sits under the cursor in + // the middle -- so the band is however far zero is from there. + const element = await createSlideRule({value: 12}); + + expect(indicator(element).style.inlineSize).toBe('120px'); + }); + + it('lands on a fractional value, which no graduation could', async () => { + const element = await createSlideRule({value: 12.4}); + + expect(indicator(element).style.inlineSize).toBe('124px'); + }); + + it('grows the other way once the value goes negative', async () => { + // Positive values slide the strip left, putting zero to the left of the + // cursor; negative values put it to the right. + const positive = await createSlideRule({value: 12}); + const negative = await createSlideRule({value: -12}); + + expect(indicator(positive).style.translate).toBe('-100%'); + expect(indicator(negative).style.translate).toBe('0'); + expect(indicator(negative).style.inlineSize).toBe('120px'); + }); + + it('is hidden at zero, where it would be nothing but its own borders', async () => { + const element = await createSlideRule({value: 0}); + + expect(indicator(element).hidden).toBe(true); + }); + + it('comes back as soon as the value leaves zero', async () => { + const element = await createSlideRule({value: 0}); + + pressKey(element, 'ArrowUp'); + await element.updateComplete; + + expect(indicator(element).hidden).toBe(false); + expect(indicator(element).style.inlineSize).toBe('10px'); + }); + + it('is hidden again on the way back to zero', async () => { + const element = await createSlideRule({value: 1}); + + expect(indicator(element).hidden).toBe(false); + + pressKey(element, 'ArrowDown'); + await element.updateComplete; + + expect(indicator(element).hidden).toBe(true); + }); +}); + +describe('craft-slide-rule change events', () => { + it('keeps the value continuous rather than settling on a graduation', async () => { + // The image editor straightens by fractions of a degree where the backend + // can, so the rule must not round on its way through. + const element = await createSlideRule({min: -45.3}); + + pressKey(element, 'Home'); + await element.updateComplete; + + expect(element.value).toBe(-45.3); + }); + + it('stays quiet when a move lands on the value already showing', async () => { + const element = await createSlideRule({value: 45}); + const changes: number[] = []; + + element.addEventListener('change', (event) => { + changes.push((event as CustomEvent<{value: number}>).detail.value); + }); + + pressKey(element, 'ArrowUp'); + pressKey(element, 'End'); + await element.updateComplete; + + // Already at the ceiling, so neither key moved it anywhere new. + expect(changes).toEqual([]); + + pressKey(element, 'ArrowDown'); + await element.updateComplete; + + expect(changes).toEqual([44]); + }); +}); diff --git a/packages/craftcms-ui/src/components/slide-rule/slide-rule.ts b/packages/craftcms-ui/src/components/slide-rule/slide-rule.ts index 8e5718423a1..33385baf2b5 100644 --- a/packages/craftcms-ui/src/components/slide-rule/slide-rule.ts +++ b/packages/craftcms-ui/src/components/slide-rule/slide-rule.ts @@ -6,6 +6,9 @@ import styles from './slide-rule.styles.js'; const SENSITIVITY = 3; +/** Matches `--c-slide-rule-graduation-width`, for before anything is laid out. */ +const DEFAULT_GRADUATION_WIDTH = 10; + /** * @summary Ruler-style slider for fine rotation adjustment — the port of the * legacy `Craft.SlideRuleInput`, used by the image editor's straighten control. @@ -44,12 +47,14 @@ export default class CraftSlideRule extends LitElement { @query('.graduations') private _graduations!: HTMLElement; @query('.graduations ul') private _list!: HTMLElement; @query('.cursor') private _cursor!: HTMLElement; + @query('.indicator') private _indicator!: HTMLElement; #dragging = false; #rotateIntent = false; #startPositionX = 0; #startLeft = 0; #calculatedWidth = 0; + #placed = false; #resizeObserver: ResizeObserver | null = null; #graduations(): number[] { @@ -73,8 +78,10 @@ export default class CraftSlideRule extends LitElement { } override firstUpdated(): void { - // (n - 1) graduations because each border sits on the left of its 10px box. - this.#calculatedWidth = (this.#graduations().length - 1) * 10; + // A starting point for the unmeasurable case below; `#reposition()` + // replaces it with what was actually rendered. + this.#calculatedWidth = + (this.#graduations().length - 1) * DEFAULT_GRADUATION_WIDTH; this.#reposition(); } @@ -92,10 +99,114 @@ export default class CraftSlideRule extends LitElement { /** Slides the ruler so the current value lines up under the cursor. */ #reposition(): void { - if (!this._list) { + // The strip is centred against the width of its window, so an unmeasurable + // window puts it half its own length out — 20 degrees off for the default + // range. That happens whenever the rule first renders inside something not + // yet laid out, a closed dialog being the usual case. The resize observer + // calls back the moment there is a size, so waiting costs nothing. + if (this._graduations?.offsetWidth) { + this.#measureGraduations(); + } + + // Drawn before the bail below: the indicator is a function of the value + // and the graduation width, so unlike the strip it doesn't need the + // window to have been laid out to know where it goes. + this.#drawIndicator(); + + if (!this._list || !this._graduations?.offsetWidth) { + return; + } + + this._list.style.transform = `translateX(${this.#valueToPosition( + this.value + )}px)`; + + this.#markPlaced(); + } + + /** + * Lets the strip and the indicator start animating, once they have been put + * where they belong. + * + * Neither resting place is something to animate into. `left` never did, + * because a transition can't run from `auto` to a length -- but `none` to a + * matrix interpolates fine, so both would slide into place every time the + * rule first appears. The stylesheet holds their transitions off until this + * class lands, a frame later. + */ + #markPlaced(): void { + if (this.#placed) { + return; + } + + this.#placed = true; + requestAnimationFrame(() => this._root?.classList.add('placed')); + } + + /** + * Sizes the band running from zero to the current value. + * + * The graduations can't carry this themselves: the value is continuous, so + * it usually falls between two of them and there is no mark to light up. + * The band is measured in the same pixels-per-unit the strip is positioned + * in, so it lands exactly on a fractional value. + * + * It grows from the middle of the window, because that is where the cursor + * is and so where the current value always sits. Zero is however far away + * the value says it is -- to the left once the value goes positive, since + * that is the direction the strip slides. + */ + #drawIndicator(): void { + if (!this._indicator) { return; } - this._list.style.left = `${this.#valueToPosition(this.value)}px`; + + const scaleMax = (this.graduationMin - this.graduationMax) * -1; + const perUnit = this.#calculatedWidth / scaleMax; + + this._indicator.style.inlineSize = `${Math.abs(this.value) * perUnit}px`; + this._indicator.style.translate = this.value > 0 ? '-100%' : '0'; + } + + /** + * Reads the rendered graduation width instead of assuming the default. + * + * The positioning maths is in units of one graduation, and + * `--c-slide-rule-graduation-width` can be set to anything — so a strip that + * doesn't match what the maths assumes puts zero nowhere near the cursor. + * That is the shape of two bugs already: graduations rendered as + * inline-blocks picked up the template's newlines as whitespace, and an + * unmeasurable window centred the strip against zero. + * + * Measured off the strip rather than one graduation, so a fractional width + * doesn't accumulate a rounding error across the whole range. + */ + #measureGraduations(): void { + const count = this.#graduations().length; + const width = this._list.getBoundingClientRect().width / count; + + if (width > 0) { + // (n - 1) because each border sits at the start of its own box. + this.#calculatedWidth = (count - 1) * width; + } + } + + /** + * Where the strip is sitting right now, read back off the transform. + * + * Not the value `#reposition()` last wrote: a drag can start while the + * transition from a tap or a keypress is still running, and this has to be + * the position on screen rather than the one being animated towards. + */ + #currentOffset(): number { + const {transform} = getComputedStyle(this._list); + + // Before the first reposition, and whenever a stylesheet hasn't applied. + if (!transform || transform === 'none') { + return 0; + } + + return new DOMMatrixReadOnly(transform).m41; } #valueText(value: number): string { @@ -131,6 +242,12 @@ export default class CraftSlideRule extends LitElement { #setValue(rawValue: number, emitChange = true): void { const value = Math.min(Math.max(rawValue, this.min), this.max); + // A move that lands on the value already showing shouldn't ask a consumer + // to redraw for it. + if (value === this.value) { + return; + } + this.value = value; if (emitChange) { @@ -163,7 +280,7 @@ export default class CraftSlideRule extends LitElement { event.preventDefault(); this.#startPositionX = event.clientX; - this.#startLeft = this._list.offsetLeft; + this.#startLeft = this.#currentOffset(); this._root.setPointerCapture(event.pointerId); this.#emit('start'); } @@ -246,13 +363,6 @@ export default class CraftSlideRule extends LitElement { event.preventDefault(); } - #isSelected(graduation: number): boolean { - return ( - graduation >= Math.min(0, this.value) && - graduation <= Math.max(0, this.value) - ); - } - override render() { return html`
-
+