diff --git a/package-lock.json b/package-lock.json index f64ab473299..e1a20075c25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -589,14 +589,18 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -633,10 +637,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.2", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -1826,11 +1832,13 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1916,6 +1924,10 @@ "resolved": "packages/craftcms-cp", "link": true }, + "node_modules/@craftcms/garnish": { + "resolved": "packages/craftcms-garnish", + "link": true + }, "node_modules/@craftcms/graphiql": { "resolved": "packages/craftcms-graphiql", "link": true @@ -5163,6 +5175,22 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/binding-darwin-x64": { "version": "1.0.0-rc.17", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", @@ -5425,168 +5453,557 @@ "dev": true, "license": "MIT" }, - "node_modules/@selectize/selectize": { - "version": "0.15.2", - "resolved": "git+ssh://git@github.com/selectize/selectize.js.git#e6ca6d3ba8f902c38da157f2caf7962c38e82095", - "license": "Apache-2.0", - "engines": { - "node": "*" - }, - "peerDependencies": { - "jquery": "^1.7.0 || ^2 || ^3", - "jquery-ui": "^1.13.2" - }, - "peerDependenciesMeta": { - "jquery-ui": { - "optional": true - } - } + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz", + "integrity": "sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] }, - "node_modules/@shoelace-style/animations": { - "version": "1.2.0", + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.0.tgz", + "integrity": "sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "funding": { - "type": "individual", - "url": "https://github.com/sponsors/claviska" - } + "optional": true, + "os": [ + "android" + ] }, - "node_modules/@shoelace-style/localize": { - "version": "3.2.2", + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.0.tgz", + "integrity": "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/@shoelace-style/shoelace": { - "version": "2.20.1", + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.0.tgz", + "integrity": "sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@ctrl/tinycolor": "^4.1.0", - "@floating-ui/dom": "^1.6.12", - "@lit/react": "^1.0.6", - "@shoelace-style/animations": "^1.2.0", - "@shoelace-style/localize": "^3.2.1", - "composed-offset-position": "^0.0.6", - "lit": "^3.2.1", - "qr-creator": "^1.0.0" - }, - "engines": { - "node": ">=14.17.0" - }, - "funding": { - "type": "individual", - "url": "https://github.com/sponsors/claviska" - } + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/@simplewebauthn/browser": { - "version": "13.3.0", - "license": "MIT" + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.0.tgz", + "integrity": "sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] }, - "node_modules/@sinclair/typebox": { - "version": "0.27.8", - "license": "MIT" + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.0.tgz", + "integrity": "sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] }, - "node_modules/@sindresorhus/is": { - "version": "4.6.0", + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.0.tgz", + "integrity": "sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==", + "cpu": [ + "arm" + ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@sindresorhus/merge-streams": { - "version": "2.3.0", + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.0.tgz", + "integrity": "sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "license": "MIT" + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.0.tgz", + "integrity": "sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@storybook/addon-a11y": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.4.1.tgz", - "integrity": "sha512-MGft/IXjJ20a9KbaSVG9bHTAAoanbucKrgEiJJRNqpim8DsXA01+XTdSk17LmiOCB203Rrq9mWgdQ6+79cc8iA==", + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.0.tgz", + "integrity": "sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==", + "cpu": [ + "arm64" + ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", - "dependencies": { - "@storybook/global": "^5.0.0", - "axe-core": "^4.2.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "storybook": "^10.4.1" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@storybook/addon-docs": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.4.1.tgz", - "integrity": "sha512-IYqUdjoZe4VO2LFZlKL/gwy7DsQSWCq6hX+zc1MBmZo04yycDASk1tte57n9pdlW3ajw9yYMF/+lVBi+xQjyvw==", + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.0.tgz", + "integrity": "sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==", + "cpu": [ + "loong64" + ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", - "dependencies": { - "@mdx-js/react": "^3.0.0", - "@storybook/csf-plugin": "10.4.1", - "@storybook/icons": "^2.0.2", - "@storybook/react-dom-shim": "10.4.1", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "ts-dedent": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.4.1" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@storybook/addon-themes": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@storybook/addon-themes/-/addon-themes-10.4.1.tgz", - "integrity": "sha512-nxNskZwpgptCUWyci+jiM5IrKY6xSaTpv3xzgkL6NCU+5PyCxyp0Z6jE8NDvew9jolzOC9pBhGJAW26A/jbFqg==", + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.0.tgz", + "integrity": "sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==", + "cpu": [ + "loong64" + ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", - "dependencies": { - "ts-dedent": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "storybook": "^10.4.1" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@storybook/addon-vitest": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@storybook/addon-vitest/-/addon-vitest-10.4.1.tgz", - "integrity": "sha512-ymrX9EOou1x3d21iDhjP3j3XfhOAiflhlPZWKcipULBoJCq/aZPbV68EghzovkJNuGRl9ezMYxbbKxwrMmCmGg==", + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.0.tgz", + "integrity": "sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==", + "cpu": [ + "ppc64" + ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", - "dependencies": { - "@storybook/global": "^5.0.0", - "@storybook/icons": "^2.0.2" - }, - "funding": { - "type": "opencollective", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.0.tgz", + "integrity": "sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.0.tgz", + "integrity": "sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.0.tgz", + "integrity": "sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.0.tgz", + "integrity": "sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.0.tgz", + "integrity": "sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.0.tgz", + "integrity": "sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.0.tgz", + "integrity": "sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.0.tgz", + "integrity": "sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.0.tgz", + "integrity": "sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.0.tgz", + "integrity": "sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.0.tgz", + "integrity": "sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.0.tgz", + "integrity": "sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@selectize/selectize": { + "version": "0.15.2", + "resolved": "git+ssh://git@github.com/selectize/selectize.js.git#e6ca6d3ba8f902c38da157f2caf7962c38e82095", + "license": "Apache-2.0", + "engines": { + "node": "*" + }, + "peerDependencies": { + "jquery": "^1.7.0 || ^2 || ^3", + "jquery-ui": "^1.13.2" + }, + "peerDependenciesMeta": { + "jquery-ui": { + "optional": true + } + } + }, + "node_modules/@shoelace-style/animations": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/claviska" + } + }, + "node_modules/@shoelace-style/localize": { + "version": "3.2.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@shoelace-style/shoelace": { + "version": "2.20.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.1.0", + "@floating-ui/dom": "^1.6.12", + "@lit/react": "^1.0.6", + "@shoelace-style/animations": "^1.2.0", + "@shoelace-style/localize": "^3.2.1", + "composed-offset-position": "^0.0.6", + "lit": "^3.2.1", + "qr-creator": "^1.0.0" + }, + "engines": { + "node": ">=14.17.0" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/claviska" + } + }, + "node_modules/@simplewebauthn/browser": { + "version": "13.3.0", + "license": "MIT" + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "2.3.0", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "license": "MIT" + }, + "node_modules/@storybook/addon-a11y": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.4.1.tgz", + "integrity": "sha512-MGft/IXjJ20a9KbaSVG9bHTAAoanbucKrgEiJJRNqpim8DsXA01+XTdSk17LmiOCB203Rrq9mWgdQ6+79cc8iA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "axe-core": "^4.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^10.4.1" + } + }, + "node_modules/@storybook/addon-docs": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.4.1.tgz", + "integrity": "sha512-IYqUdjoZe4VO2LFZlKL/gwy7DsQSWCq6hX+zc1MBmZo04yycDASk1tte57n9pdlW3ajw9yYMF/+lVBi+xQjyvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@mdx-js/react": "^3.0.0", + "@storybook/csf-plugin": "10.4.1", + "@storybook/icons": "^2.0.2", + "@storybook/react-dom-shim": "10.4.1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "storybook": "^10.4.1" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@storybook/addon-themes": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@storybook/addon-themes/-/addon-themes-10.4.1.tgz", + "integrity": "sha512-nxNskZwpgptCUWyci+jiM5IrKY6xSaTpv3xzgkL6NCU+5PyCxyp0Z6jE8NDvew9jolzOC9pBhGJAW26A/jbFqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^10.4.1" + } + }, + "node_modules/@storybook/addon-vitest": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@storybook/addon-vitest/-/addon-vitest-10.4.1.tgz", + "integrity": "sha512-ymrX9EOou1x3d21iDhjP3j3XfhOAiflhlPZWKcipULBoJCq/aZPbV68EghzovkJNuGRl9ezMYxbbKxwrMmCmGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "@storybook/icons": "^2.0.2" + }, + "funding": { + "type": "opencollective", "url": "https://opencollective.com/storybook" }, "peerDependencies": { @@ -6306,7 +6723,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.8", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "license": "MIT" }, "node_modules/@types/express": { @@ -6404,6 +6823,8 @@ }, "node_modules/@types/jsesc": { "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz", + "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==", "dev": true, "license": "MIT" }, @@ -8205,6 +8626,74 @@ "node": ">=12" } }, + "node_modules/ast-kit": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-3.0.0.tgz", + "integrity": "sha512-8OG92q3R35qjC/4i6BLBMg8IB+fClWu/1PEwg2Z9Rn+BuNaiEgJzpzn+pxWOdHJWDCAwu2JP0wCDTozAM4QirQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^8.0.0", + "estree-walker": "^3.0.3", + "pathe": "^2.0.3" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/ast-kit/node_modules/@babel/helper-string-parser": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", + "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/ast-kit/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.0.tgz", + "integrity": "sha512-kXxQVZHNOctSJJsqzmcbPSCEkM6oHNnDIkua7g9RCO9xRHj2eCiKvRx2KPdfWR9QxcGWnK/oArrtunmie3rL9g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/ast-kit/node_modules/@babel/parser": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0.tgz", + "integrity": "sha512-aLxAE+imI9bCcyaPrUDjBv3uSkWieifjLe0kuFOZF0zli0L6GCsTmsePnTr55adbIAgYz2zhN1vnFimCBUYcRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/ast-kit/node_modules/@babel/types": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0.tgz", + "integrity": "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.0", + "@babel/helper-validator-identifier": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, "node_modules/ast-types": { "version": "0.16.1", "license": "MIT", @@ -8215,6 +8704,25 @@ "node": ">=4" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", + "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/astral-regex": { "version": "2.0.0", "dev": true, @@ -8688,6 +9196,16 @@ "node": ">= 0.8" } }, + "node_modules/cac": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/cac/-/cac-7.0.0.tgz", + "integrity": "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/cacache": { "version": "16.1.3", "license": "ISC", @@ -13036,6 +13554,13 @@ "node": ">=0.10.0" } }, + "node_modules/hookable": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-6.1.1.tgz", + "integrity": "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==", + "dev": true, + "license": "MIT" + }, "node_modules/hookified": { "version": "1.15.0", "dev": true, @@ -15228,6 +15753,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, "node_modules/make-dir": { "version": "4.0.0", "dev": true, @@ -18697,6 +19234,204 @@ "version": "3.0.3", "license": "Unlicense" }, + "node_modules/rolldown": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", + "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-x64": "1.0.0-rc.17", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + } + }, + "node_modules/rolldown-plugin-dts": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/rolldown-plugin-dts/-/rolldown-plugin-dts-0.23.2.tgz", + "integrity": "sha512-PbSqLawLgZBGcOGT3yqWBGn4cX+wh2nt5FuBGdcMHyOhoukmjbhYAl8NT9sE4U38Cm9tqLOIQeOrvzeayM0DLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/generator": "8.0.0-rc.3", + "@babel/helper-validator-identifier": "8.0.0-rc.3", + "@babel/parser": "8.0.0-rc.3", + "@babel/types": "8.0.0-rc.3", + "ast-kit": "^3.0.0-beta.1", + "birpc": "^4.0.0", + "dts-resolver": "^2.1.3", + "get-tsconfig": "^4.13.7", + "obug": "^2.1.1", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + }, + "peerDependencies": { + "@ts-macro/tsc": "^0.3.6", + "@typescript/native-preview": ">=7.0.0-dev.20260325.1", + "rolldown": "^1.0.0-rc.12", + "typescript": "^5.0.0 || ^6.0.0", + "vue-tsc": "~3.2.0" + }, + "peerDependenciesMeta": { + "@ts-macro/tsc": { + "optional": true + }, + "@typescript/native-preview": { + "optional": true + }, + "typescript": { + "optional": true + }, + "vue-tsc": { + "optional": true + } + } + }, + "node_modules/rolldown-plugin-dts/node_modules/@babel/generator": { + "version": "8.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0-rc.3.tgz", + "integrity": "sha512-em37/13/nR320G4jab/nIIHZgc2Wz2y/D39lxnTyxB4/D/omPQncl/lSdlnJY1OhQcRGugTSIF2l/69o31C9dA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^8.0.0-rc.3", + "@babel/types": "^8.0.0-rc.3", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "@types/jsesc": "^2.5.0", + "jsesc": "^3.0.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/rolldown-plugin-dts/node_modules/@babel/helper-string-parser": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", + "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/rolldown-plugin-dts/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.0-rc.3.tgz", + "integrity": "sha512-8AWCJ2VJJyDFlGBep5GpaaQ9AAaE/FjAcrqI7jyssYhtL7WGV0DOKpJsQqM037xDbpRLHXsY8TwU7zDma7coOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/rolldown-plugin-dts/node_modules/@babel/parser": { + "version": "8.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0-rc.3.tgz", + "integrity": "sha512-B20dvP3MfNc/XS5KKCHy/oyWl5IA6Cn9YjXRdDlCjNmUFrjvLXMNUfQq/QUy9fnG2gYkKKcrto2YaF9B32ToOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.0-rc.3" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/rolldown-plugin-dts/node_modules/@babel/types": { + "version": "8.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0-rc.3.tgz", + "integrity": "sha512-mOm5ZrYmphGfqVWoH5YYMTITb3cDXsFgmvFlvkvWDMsR9X8RFnt7a0Wb6yNIdoFsiMO9WjYLq+U/FMtqIYAF8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.0-rc.3", + "@babel/helper-validator-identifier": "^8.0.0-rc.3" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", + "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz", + "integrity": "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.0", + "@rollup/rollup-android-arm64": "4.62.0", + "@rollup/rollup-darwin-arm64": "4.62.0", + "@rollup/rollup-darwin-x64": "4.62.0", + "@rollup/rollup-freebsd-arm64": "4.62.0", + "@rollup/rollup-freebsd-x64": "4.62.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.0", + "@rollup/rollup-linux-arm-musleabihf": "4.62.0", + "@rollup/rollup-linux-arm64-gnu": "4.62.0", + "@rollup/rollup-linux-arm64-musl": "4.62.0", + "@rollup/rollup-linux-loong64-gnu": "4.62.0", + "@rollup/rollup-linux-loong64-musl": "4.62.0", + "@rollup/rollup-linux-ppc64-gnu": "4.62.0", + "@rollup/rollup-linux-ppc64-musl": "4.62.0", + "@rollup/rollup-linux-riscv64-gnu": "4.62.0", + "@rollup/rollup-linux-riscv64-musl": "4.62.0", + "@rollup/rollup-linux-s390x-gnu": "4.62.0", + "@rollup/rollup-linux-x64-gnu": "4.62.0", + "@rollup/rollup-linux-x64-musl": "4.62.0", + "@rollup/rollup-openbsd-x64": "4.62.0", + "@rollup/rollup-openharmony-arm64": "4.62.0", + "@rollup/rollup-win32-arm64-msvc": "4.62.0", + "@rollup/rollup-win32-ia32-msvc": "4.62.0", + "@rollup/rollup-win32-x64-gnu": "4.62.0", + "@rollup/rollup-win32-x64-msvc": "4.62.0", + "fsevents": "~2.3.2" + } + }, "node_modules/rs-module-lexer": { "version": "2.8.0", "dev": true, @@ -21732,58 +22467,6 @@ } } }, - "node_modules/unrun/node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.17", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/unrun/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.17", - "dev": true, - "license": "MIT" - }, - "node_modules/unrun/node_modules/rolldown": { - "version": "1.0.0-rc.17", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.127.0", - "@rolldown/pluginutils": "1.0.0-rc.17" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-x64": "1.0.0-rc.17", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" - } - }, "node_modules/update-browserslist-db": { "version": "1.1.4", "funding": [ @@ -22084,68 +22767,19 @@ }, "peerDependenciesMeta": { "typescript": { - "optional": true - } - } - }, - "node_modules/vite/node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.17", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.17", - "license": "MIT" - }, - "node_modules/vite/node_modules/fsevents": { - "version": "2.3.3", - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/vite/node_modules/rolldown": { - "version": "1.0.0-rc.17", - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.127.0", - "@rolldown/pluginutils": "1.0.0-rc.17" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-x64": "1.0.0-rc.17", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, "node_modules/vitest": { @@ -23738,631 +24372,1127 @@ "lit": "3.x" } }, - "packages/craftcms-cp/node_modules/@babel/generator": { - "version": "8.0.0-rc.3", + "packages/craftcms-cp/node_modules/@types/jquery": { + "version": "4.0.0", + "dev": true, + "license": "MIT" + }, + "packages/craftcms-cp/node_modules/@vitest/coverage-v8": { + "version": "4.1.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.5", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.5", + "vitest": "4.1.5" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "packages/craftcms-cp/node_modules/@vitest/pretty-format": { + "version": "4.1.5", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/craftcms-cp/node_modules/@vitest/utils": { + "version": "4.1.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/craftcms-cp/node_modules/@wc-toolkit/storybook-helpers": { + "version": "10.3.0", + "dev": true, + "license": "MIT", + "peerDependencies": { + "lit": "^2.0.0 || ^3.0.0", + "storybook": ">=10.1.11-0 <11.0.0-0" + } + }, + "packages/craftcms-cp/node_modules/balanced-match": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "packages/craftcms-cp/node_modules/brace-expansion": { + "version": "5.0.5", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "packages/craftcms-cp/node_modules/chalk": { + "version": "5.6.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "packages/craftcms-cp/node_modules/cli-spinners": { + "version": "3.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/craftcms-cp/node_modules/del": { + "version": "8.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "globby": "^14.0.2", + "is-glob": "^4.0.3", + "is-path-cwd": "^3.0.0", + "is-path-inside": "^4.0.0", + "p-map": "^7.0.2", + "presentable-error": "^0.0.1", + "slash": "^5.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/craftcms-cp/node_modules/dom-accessibility-api": { + "version": "0.7.1", + "dev": true, + "license": "MIT" + }, + "packages/craftcms-cp/node_modules/glob": { + "version": "13.0.6", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/craftcms-cp/node_modules/globby": { + "version": "14.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^2.1.0", + "fast-glob": "^3.3.3", + "ignore": "^7.0.3", + "path-type": "^6.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/craftcms-cp/node_modules/is-interactive": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/craftcms-cp/node_modules/is-path-cwd": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/craftcms-cp/node_modules/is-path-inside": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/craftcms-cp/node_modules/is-unicode-supported": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/craftcms-cp/node_modules/log-symbols": { + "version": "7.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/craftcms-cp/node_modules/minimatch": { + "version": "10.2.5", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/craftcms-cp/node_modules/ora": { + "version": "9.4.0", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^8.0.0-rc.3", - "@babel/types": "^8.0.0-rc.3", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "@types/jsesc": "^2.5.0", - "jsesc": "^3.0.2" + "chalk": "^5.6.2", + "cli-cursor": "^5.0.0", + "cli-spinners": "^3.2.0", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.1.0", + "log-symbols": "^7.0.1", + "stdin-discarder": "^0.3.2", + "string-width": "^8.1.0" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "packages/craftcms-cp/node_modules/@babel/generator/node_modules/@babel/helper-validator-identifier": { - "version": "8.0.0-rc.4", + "packages/craftcms-cp/node_modules/p-map": { + "version": "7.0.4", "dev": true, "license": "MIT", "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "packages/craftcms-cp/node_modules/@babel/generator/node_modules/@babel/parser": { - "version": "8.0.0-rc.4", + "packages/craftcms-cp/node_modules/path-scurry": { + "version": "2.0.2", "dev": true, - "license": "MIT", + "license": "BlueOak-1.0.0", "dependencies": { - "@babel/types": "^8.0.0-rc.4" - }, - "bin": { - "parser": "bin/babel-parser.js" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "packages/craftcms-cp/node_modules/@babel/generator/node_modules/@babel/types": { - "version": "8.0.0-rc.4", + "packages/craftcms-cp/node_modules/path-type": { + "version": "6.0.0", "dev": true, "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/craftcms-cp/node_modules/rimraf": { + "version": "6.1.3", + "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "@babel/helper-string-parser": "^8.0.0-rc.4", - "@babel/helper-validator-identifier": "^8.0.0-rc.4" + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "packages/craftcms-cp/node_modules/@babel/helper-string-parser": { - "version": "8.0.0-rc.4", + "packages/craftcms-cp/node_modules/semver": { + "version": "7.7.4", "dev": true, - "license": "MIT", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=10" } }, - "packages/craftcms-cp/node_modules/@babel/helper-validator-identifier": { - "version": "8.0.0-rc.3", + "packages/craftcms-cp/node_modules/slash": { + "version": "5.1.0", "dev": true, "license": "MIT", "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "packages/craftcms-cp/node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.17", - "cpu": [ - "arm64" - ], + "packages/craftcms-cp/node_modules/tinyrainbow": { + "version": "3.1.0", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=14.0.0" } }, - "packages/craftcms-cp/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.17", - "dev": true, - "license": "MIT" - }, - "packages/craftcms-cp/node_modules/@types/jquery": { - "version": "4.0.0", - "dev": true, - "license": "MIT" - }, - "packages/craftcms-cp/node_modules/@vitest/coverage-v8": { - "version": "4.1.5", + "packages/craftcms-cp/node_modules/tsdown": { + "version": "0.21.10", "dev": true, "license": "MIT", "dependencies": { - "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.5", - "ast-v8-to-istanbul": "^1.0.0", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-reports": "^3.2.0", - "magicast": "^0.5.2", + "ansis": "^4.2.0", + "cac": "^7.0.0", + "defu": "^6.1.7", + "empathic": "^2.0.0", + "hookable": "^6.1.1", + "import-without-cache": "^0.3.3", "obug": "^2.1.1", - "std-env": "^4.0.0-rc.1", - "tinyrainbow": "^3.1.0" + "picomatch": "^4.0.4", + "rolldown": "1.0.0-rc.17", + "rolldown-plugin-dts": "^0.23.2", + "semver": "^7.7.4", + "tinyexec": "^1.1.1", + "tinyglobby": "^0.2.16", + "tree-kill": "^1.2.2", + "unconfig-core": "^7.5.0", + "unrun": "^0.2.37" + }, + "bin": { + "tsdown": "dist/run.mjs" + }, + "engines": { + "node": ">=20.19.0" }, "funding": { - "url": "https://opencollective.com/vitest" + "url": "https://github.com/sponsors/sxzz" }, "peerDependencies": { - "@vitest/browser": "4.1.5", - "vitest": "4.1.5" + "@arethetypeswrong/core": "^0.18.1", + "@tsdown/css": "0.21.10", + "@tsdown/exe": "0.21.10", + "@vitejs/devtools": "*", + "publint": "^0.3.0", + "typescript": "^5.0.0 || ^6.0.0", + "unplugin-unused": "^0.5.0" }, "peerDependenciesMeta": { - "@vitest/browser": { + "@arethetypeswrong/core": { + "optional": true + }, + "@tsdown/css": { + "optional": true + }, + "@tsdown/exe": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "publint": { + "optional": true + }, + "typescript": { + "optional": true + }, + "unplugin-unused": { "optional": true } } }, - "packages/craftcms-cp/node_modules/@vitest/pretty-format": { - "version": "4.1.5", - "dev": true, + "packages/craftcms-garnish": { + "name": "@craftcms/garnish", + "version": "0.0.0", "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" + "devDependencies": { + "@total-typescript/tsconfig": "^1.0.4", + "@types/node": "^25.6.0", + "@vitest/coverage-v8": "^4.1.5", + "happy-dom": "^20.9.0", + "tsdown": "^0.21.10", + "typescript": "^6.0.3", + "vite": "^7.1.5", + "vitest": "^4.1.5" }, - "funding": { - "url": "https://opencollective.com/vitest" + "peerDependencies": { + "jquery": "^3.5.0" + }, + "peerDependenciesMeta": { + "jquery": { + "optional": true + } } }, - "packages/craftcms-cp/node_modules/@vitest/utils": { - "version": "4.1.5", + "packages/craftcms-garnish/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.5", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" } }, - "packages/craftcms-cp/node_modules/@wc-toolkit/storybook-helpers": { - "version": "10.3.0", + "packages/craftcms-garnish/node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "peerDependencies": { - "lit": "^2.0.0 || ^3.0.0", - "storybook": ">=10.1.11-0 <11.0.0-0" + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" } }, - "packages/craftcms-cp/node_modules/ast-kit": { - "version": "3.0.0-beta.1", + "packages/craftcms-garnish/node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/parser": "^8.0.0-beta.4", - "estree-walker": "^3.0.3", - "pathe": "^2.0.3" - }, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "packages/craftcms-garnish/node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=20.19.0" - }, - "funding": { - "url": "https://github.com/sponsors/sxzz" + "node": ">=18" } }, - "packages/craftcms-cp/node_modules/ast-kit/node_modules/@babel/helper-validator-identifier": { - "version": "8.0.0-rc.4", + "packages/craftcms-garnish/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "packages/craftcms-cp/node_modules/ast-kit/node_modules/@babel/parser": { - "version": "8.0.0-rc.4", + "packages/craftcms-garnish/node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/types": "^8.0.0-rc.4" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "packages/craftcms-cp/node_modules/ast-kit/node_modules/@babel/types": { - "version": "8.0.0-rc.4", + "packages/craftcms-garnish/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^8.0.0-rc.4", - "@babel/helper-validator-identifier": "^8.0.0-rc.4" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "packages/craftcms-cp/node_modules/ast-v8-to-istanbul": { - "version": "1.0.0", + "packages/craftcms-garnish/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.31", - "estree-walker": "^3.0.3", - "js-tokens": "^10.0.0" + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" } }, - "packages/craftcms-cp/node_modules/balanced-match": { - "version": "4.0.4", + "packages/craftcms-garnish/node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "18 || 20 || >=22" + "node": ">=18" } }, - "packages/craftcms-cp/node_modules/brace-expansion": { - "version": "5.0.5", + "packages/craftcms-garnish/node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "18 || 20 || >=22" + "node": ">=18" } }, - "packages/craftcms-cp/node_modules/cac": { - "version": "7.0.0", + "packages/craftcms-garnish/node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=20.19.0" + "node": ">=18" } }, - "packages/craftcms-cp/node_modules/chalk": { - "version": "5.6.2", + "packages/craftcms-garnish/node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": ">=18" } }, - "packages/craftcms-cp/node_modules/cli-spinners": { - "version": "3.3.0", + "packages/craftcms-garnish/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18" } }, - "packages/craftcms-cp/node_modules/del": { - "version": "8.0.1", + "packages/craftcms-garnish/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", - "dependencies": { - "globby": "^14.0.2", - "is-glob": "^4.0.3", - "is-path-cwd": "^3.0.0", - "is-path-inside": "^4.0.0", - "p-map": "^7.0.2", - "presentable-error": "^0.0.1", - "slash": "^5.1.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "packages/craftcms-cp/node_modules/dom-accessibility-api": { - "version": "0.7.1", - "dev": true, - "license": "MIT" - }, - "packages/craftcms-cp/node_modules/glob": { - "version": "13.0.6", + "packages/craftcms-garnish/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=18" } }, - "packages/craftcms-cp/node_modules/globby": { - "version": "14.1.0", + "packages/craftcms-garnish/node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], "dev": true, "license": "MIT", - "dependencies": { - "@sindresorhus/merge-streams": "^2.1.0", - "fast-glob": "^3.3.3", - "ignore": "^7.0.3", - "path-type": "^6.0.0", - "slash": "^5.1.0", - "unicorn-magic": "^0.3.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "packages/craftcms-cp/node_modules/hookable": { - "version": "6.1.1", - "dev": true, - "license": "MIT" - }, - "packages/craftcms-cp/node_modules/is-interactive": { - "version": "2.0.0", + "packages/craftcms-garnish/node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18" } }, - "packages/craftcms-cp/node_modules/is-path-cwd": { - "version": "3.0.0", + "packages/craftcms-garnish/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18" } }, - "packages/craftcms-cp/node_modules/is-path-inside": { - "version": "4.0.0", + "packages/craftcms-garnish/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18" } }, - "packages/craftcms-cp/node_modules/is-unicode-supported": { - "version": "2.1.0", + "packages/craftcms-garnish/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], "engines": { "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "packages/craftcms-cp/node_modules/js-tokens": { - "version": "10.0.0", - "dev": true, - "license": "MIT" - }, - "packages/craftcms-cp/node_modules/log-symbols": { - "version": "7.0.1", + "packages/craftcms-garnish/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "is-unicode-supported": "^2.0.0", - "yoctocolors": "^2.1.1" - }, + "optional": true, + "os": [ + "openbsd" + ], "engines": { "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "packages/craftcms-cp/node_modules/magicast": { - "version": "0.5.2", + "packages/craftcms-garnish/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", - "source-map-js": "^1.2.1" - } - }, - "packages/craftcms-cp/node_modules/minimatch": { - "version": "10.2.5", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, + "optional": true, + "os": [ + "openharmony" + ], "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=18" } }, - "packages/craftcms-cp/node_modules/ora": { - "version": "9.4.0", + "packages/craftcms-garnish/node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "chalk": "^5.6.2", - "cli-cursor": "^5.0.0", - "cli-spinners": "^3.2.0", - "is-interactive": "^2.0.0", - "is-unicode-supported": "^2.1.0", - "log-symbols": "^7.0.1", - "stdin-discarder": "^0.3.2", - "string-width": "^8.1.0" - }, + "optional": true, + "os": [ + "sunos" + ], "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18" } }, - "packages/craftcms-cp/node_modules/p-map": { - "version": "7.0.4", + "packages/craftcms-garnish/node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "packages/craftcms-cp/node_modules/path-scurry": { - "version": "2.0.2", + "packages/craftcms-garnish/node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=18" } }, - "packages/craftcms-cp/node_modules/path-type": { - "version": "6.0.0", + "packages/craftcms-garnish/node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "packages/craftcms-cp/node_modules/rimraf": { - "version": "6.1.3", + "packages/craftcms-garnish/node_modules/@vitest/coverage-v8": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz", + "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "MIT", "dependencies": { - "glob": "^13.0.3", - "package-json-from-dist": "^1.0.1" + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.9", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" }, - "bin": { - "rimraf": "dist/esm/bin.mjs" + "funding": { + "url": "https://opencollective.com/vitest" }, - "engines": { - "node": "20 || >=22" + "peerDependencies": { + "@vitest/browser": "4.1.9", + "vitest": "4.1.9" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } } }, - "packages/craftcms-cp/node_modules/rolldown": { - "version": "1.0.0-rc.17", + "packages/craftcms-garnish/node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.127.0", - "@rolldown/pluginutils": "1.0.0-rc.17" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-x64": "1.0.0-rc.17", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "packages/craftcms-cp/node_modules/rolldown-plugin-dts": { - "version": "0.23.2", + "packages/craftcms-garnish/node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/generator": "8.0.0-rc.3", - "@babel/helper-validator-identifier": "8.0.0-rc.3", - "@babel/parser": "8.0.0-rc.3", - "@babel/types": "8.0.0-rc.3", - "ast-kit": "^3.0.0-beta.1", - "birpc": "^4.0.0", - "dts-resolver": "^2.1.3", - "get-tsconfig": "^4.13.7", - "obug": "^2.1.1", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=20.19.0" + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" }, "funding": { - "url": "https://github.com/sponsors/sxzz" + "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@ts-macro/tsc": "^0.3.6", - "@typescript/native-preview": ">=7.0.0-dev.20260325.1", - "rolldown": "^1.0.0-rc.12", - "typescript": "^5.0.0 || ^6.0.0", - "vue-tsc": "~3.2.0" + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { - "@ts-macro/tsc": { - "optional": true - }, - "@typescript/native-preview": { - "optional": true - }, - "typescript": { + "msw": { "optional": true }, - "vue-tsc": { + "vite": { "optional": true } } }, - "packages/craftcms-cp/node_modules/rolldown-plugin-dts/node_modules/@babel/parser": { - "version": "8.0.0-rc.3", + "packages/craftcms-garnish/node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^8.0.0-rc.3" + "tinyrainbow": "^3.1.0" }, - "bin": { - "parser": "bin/babel-parser.js" + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/craftcms-garnish/node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" }, - "engines": { - "node": "^20.19.0 || >=22.12.0" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "packages/craftcms-cp/node_modules/rolldown-plugin-dts/node_modules/@babel/types": { - "version": "8.0.0-rc.3", + "packages/craftcms-garnish/node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^8.0.0-rc.3", - "@babel/helper-validator-identifier": "^8.0.0-rc.3" + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/craftcms-garnish/node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/craftcms-garnish/node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/craftcms-garnish/node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "packages/craftcms-cp/node_modules/semver": { - "version": "7.7.4", + "packages/craftcms-garnish/node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", "dev": true, - "license": "ISC", + "license": "MIT" + }, + "packages/craftcms-garnish/node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", "bin": { - "semver": "bin/semver.js" + "esbuild": "bin/esbuild" }, "engines": { - "node": ">=10" + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" } }, - "packages/craftcms-cp/node_modules/slash": { - "version": "5.1.0", + "packages/craftcms-garnish/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, + "hasInstallScript": true, "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=14.16" + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "packages/craftcms-garnish/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=10" } }, - "packages/craftcms-cp/node_modules/tinyrainbow": { + "packages/craftcms-garnish/node_modules/tinyrainbow": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { "node": ">=14.0.0" } }, - "packages/craftcms-cp/node_modules/tsdown": { + "packages/craftcms-garnish/node_modules/tsdown": { "version": "0.21.10", + "resolved": "https://registry.npmjs.org/tsdown/-/tsdown-0.21.10.tgz", + "integrity": "sha512-3wk73yBhZe/wX7REqSdivNQ84TDs1mJ+IlnzrrEREP70xlJ/AEIzqaI04l/TzMKVIdkTdC3CPaADn2Lk/0SkdA==", "dev": true, "license": "MIT", "dependencies": { @@ -24425,6 +25555,171 @@ } } }, + "packages/craftcms-garnish/node_modules/vite": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", + "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "packages/craftcms-garnish/node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "packages/craftcms-graphiql": { "name": "@craftcms/graphiql", "version": "1.0.0", diff --git a/packages/craftcms-garnish/.gitignore b/packages/craftcms-garnish/.gitignore new file mode 100644 index 00000000000..5a2cce0d039 --- /dev/null +++ b/packages/craftcms-garnish/.gitignore @@ -0,0 +1,4 @@ +/dist +/node_modules +/coverage +/storybook-static diff --git a/packages/craftcms-garnish/.nvmrc b/packages/craftcms-garnish/.nvmrc new file mode 100644 index 00000000000..53d1c14db37 --- /dev/null +++ b/packages/craftcms-garnish/.nvmrc @@ -0,0 +1 @@ +v22 diff --git a/packages/craftcms-garnish/.storybook/main.ts b/packages/craftcms-garnish/.storybook/main.ts new file mode 100644 index 00000000000..151f9aee0c9 --- /dev/null +++ b/packages/craftcms-garnish/.storybook/main.ts @@ -0,0 +1,34 @@ +import type {StorybookConfig} from '@storybook/html-vite'; + +import {dirname} from 'path'; +import {fileURLToPath} from 'url'; + +/** + * Resolve the absolute path of a package. Needed in monorepos so Storybook finds + * the hoisted addons/framework regardless of where node_modules ends up. + */ +function getAbsolutePath(value: string): string { + return dirname(fileURLToPath(import.meta.resolve(`${value}/package.json`))); +} + +/** + * Storybook config for @craftcms/garnish. + * + * Garnish widgets are imperative (`new Modal(container)`, `new DisclosureMenu(trigger)`) + * operating on plain DOM — NOT web components — so we use the `html-vite` renderer + * (cp uses `web-components-vite`). Each story's `render()` returns an `HTMLElement` + * in which the Garnish class is instantiated against the REAL source under `../src`. + */ +const config: StorybookConfig = { + stories: ['../stories/**/*.stories.@(ts|mdx)'], + addons: [ + getAbsolutePath('@storybook/addon-docs'), + getAbsolutePath('@storybook/addon-a11y'), + getAbsolutePath('@storybook/addon-themes'), + ], + framework: { + name: getAbsolutePath('@storybook/html-vite'), + options: {}, + }, +}; +export default config; diff --git a/packages/craftcms-garnish/.storybook/preview.css b/packages/craftcms-garnish/.storybook/preview.css new file mode 100644 index 00000000000..bf0e582561e --- /dev/null +++ b/packages/craftcms-garnish/.storybook/preview.css @@ -0,0 +1,673 @@ +/** + * Global demo styles for @craftcms/garnish stories. + * + * Migrated from the old `playground/styles.css`. Stories reuse the same `.pg-*` + * class names so the imperative widgets (which the package itself renders into + * `.modal-shade`, `.hud .tip`, `.menu--disclosure`, etc.) look identical to the + * playground. The only structural change from the playground: the event-log + * panel is mounted INSIDE each story (`.pg-story` flex layout) instead of being + * a single page-fixed panel, so it scopes to the story canvas. + */ + +:root { + --bg: #f4f5f7; + --surface: #ffffff; + --border: #d6d9de; + --text: #1c1f24; + --muted: #5c636e; + --accent: #1f6feb; + --accent-text: #ffffff; + --good: #1a7f37; + --bad: #b42318; + --radius: 6px; + --shadow: 0 6px 24px rgba(0, 0, 0, 0.18); +} + +/* --- Story layout ------------------------------------------------------- */ +/* Events surface in Storybook's Actions panel, so the canvas is just the demo. */ +.pg-story { + font-family: + system-ui, + -apple-system, + 'Segoe UI', + Roboto, + sans-serif; + color: var(--text); + line-height: 1.5; + max-width: 60ch; +} + +.pg-story > p { + margin: 0 0 1rem; + color: var(--muted); +} + +.pg-controls { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.pg-story button { + font: inherit; + cursor: pointer; + padding: 0.45rem 0.9rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + color: var(--text); + transition: background 0.12s ease; +} + +.pg-story button:hover { + background: #eef0f3; +} + +.pg-story button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.pg-story code, +.pg-story kbd { + font-family: ui-monospace, 'SFMono-Regular', Menlo, monospace; + font-size: 0.85em; +} + +.pg-story code { + background: #eef0f3; + padding: 0.05rem 0.3rem; + border-radius: 4px; +} + +.pg-story kbd { + background: #2a2f37; + color: #fff; + padding: 0.05rem 0.35rem; + border-radius: 4px; +} + +/* --- Focusable sample --------------------------------------------------- */ +.pg-focus-sample { + margin-top: 1rem; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + padding: 1rem; + border: 1px dashed var(--border); + border-radius: var(--radius); +} + +.pg-focus-sample > * { + padding: 0.35rem 0.6rem; + border: 1px solid var(--border); + border-radius: 4px; + background: #fafbfc; + margin: 0; +} + +.pg-focus-sample a { + color: var(--accent); +} + +.pg-highlight-focusable { + outline: 2px solid var(--good) !important; + outline-offset: 1px; + background: #e7f6ec !important; +} + +.pg-highlight-keyboard { + box-shadow: 0 0 0 2px var(--accent); +} + +/* --- Modal + shade (the part the package itself renders) ---------------- */ +.modal-shade { + position: fixed; + inset: 0; + background: rgba(15, 18, 22, 0.55); + z-index: 100; + display: none; + opacity: 0; +} + +.pg-modal { + position: fixed; + z-index: 101; + display: none; + opacity: 0; + background: var(--surface); + color: var(--text); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 1.5rem; + min-width: 320px; + max-width: 90vw; + overflow: auto; +} + +.pg-modal h3 { + margin-top: 0; +} + +.pg-modal .pg-modal-actions { + display: flex; + gap: 0.5rem; + margin-top: 1rem; + flex-wrap: wrap; +} + +.pg-modal button { + font: inherit; + cursor: pointer; + padding: 0.45rem 0.9rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + color: var(--text); +} + +.pg-modal-primary { + background: var(--accent) !important; + color: var(--accent-text) !important; + border-color: var(--accent) !important; +} + +.pg-modal-primary:hover { + background: #1a5fce !important; +} + +body.no-scroll { + overflow: hidden; +} + +/* Draggable modal: give it a draggable look + a grabbable header. */ +.pg-modal.pg-modal--draggable { + cursor: grab; + /* Handles must opt out of browser touch gestures so pointer drags work on + touch devices (doc 08 §4). */ + touch-action: none; +} + +.pg-modal-drag-handle { + margin: -1.5rem -1.5rem 1rem; + padding: 0.75rem 1.5rem; + background: #11151b; + color: #e6e9ee; + border-radius: var(--radius) var(--radius) 0 0; + cursor: grab; + touch-action: none; + user-select: none; +} + +/* The resize handle Modal injects (.resizehandle) — give it a visible target. */ +.pg-modal .resizehandle { + position: absolute; + right: 0; + bottom: 0; + width: 20px; + height: 20px; + cursor: nwse-resize; + touch-action: none; + color: var(--muted); + display: flex; + align-items: flex-end; + justify-content: flex-end; + padding: 2px; +} + +.pg-modal .resizehandle svg { + width: 14px; + height: 14px; +} + +/* --- Standalone drag arena --------------------------------------------- */ +.pg-drag-arena { + position: relative; + margin-top: 1rem; + height: 280px; + border: 1px dashed var(--border); + border-radius: var(--radius); + background: repeating-linear-gradient( + 45deg, + #fafbfc, + #fafbfc 10px, + #f2f4f7 10px, + #f2f4f7 20px + ); + overflow: hidden; +} + +.pg-drag-box { + position: absolute; + width: 130px; + min-height: 56px; + padding: 0.5rem; + border-radius: var(--radius); + font-size: 0.8rem; + font-weight: 600; + color: #fff; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + box-shadow: var(--shadow); + cursor: grab; + user-select: none; + /* Required so pointer drags aren't eaten by touch scrolling/panning. */ + touch-action: none; +} + +.pg-drag-box:active { + cursor: grabbing; +} + +.pg-drag-box--move { + background: var(--good); +} + +.pg-drag-box--xlock { + background: #0a6d2f; +} + +.pg-drag-box--base { + background: var(--accent); +} + +.pg-drag-box--scroll { + background: #b85c00; +} + +/* --- Auto-scroll container ---------------------------------------------- */ +.pg-scroll-container { + position: relative; + margin-top: 1rem; + height: 260px; + overflow: auto; + border: 1px solid var(--border); + border-radius: var(--radius); + background: #fafbfc; +} + +.pg-scroll-tall { + position: relative; + height: 1100px; + padding: 1rem; +} + +.pg-scroll-filler { + margin: 0; + padding: 8rem 0; + text-align: center; + color: var(--muted); +} + +.pg-scroll-container .pg-drag-box--scroll { + position: absolute; + top: 20px; + left: 20px; +} + +/* --- Drag with helpers -------------------------------------------------- */ +.pg-helper-list { + margin-top: 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.pg-helper-item { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.65rem 0.9rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: #fafbfc; + font-weight: 600; + font-size: 0.85rem; + cursor: grab; + user-select: none; + /* Required so pointer drags aren't eaten by touch scrolling/panning. */ + touch-action: none; +} + +.pg-helper-item:active { + cursor: grabbing; +} + +.pg-helper-grip { + color: var(--muted); + font-size: 1.1rem; + line-height: 1; + letter-spacing: -0.15em; +} + +/* The clone Drag creates gets the .draghelper class; give it a lifted look. */ +.draghelper { + box-shadow: var(--shadow); + opacity: 0.92; + border-color: var(--accent) !important; + cursor: grabbing; +} + +/* --- DragDrop / drop targets -------------------------------------------- */ +.pg-dragdrop-layout { + margin-top: 1rem; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + align-items: start; +} + +.pg-dragdrop-chips { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.75rem; + border: 1px dashed var(--border); + border-radius: var(--radius); + background: #fafbfc; +} + +.pg-dragdrop-chip { + padding: 0.5rem 0.75rem; + border-radius: var(--radius); + background: var(--accent); + color: var(--accent-text); + font-weight: 600; + font-size: 0.85rem; + text-align: center; + cursor: grab; + user-select: none; + /* Required so pointer drags aren't eaten by touch scrolling/panning. */ + touch-action: none; +} + +.pg-dragdrop-chip:active { + cursor: grabbing; +} + +.pg-dragdrop-zones { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.pg-dropzone { + padding: 1.1rem 0.75rem; + border: 2px dashed var(--border); + border-radius: var(--radius); + text-align: center; + font-weight: 600; + font-size: 0.85rem; + color: var(--muted); + background: var(--surface); + transition: + background 0.12s ease, + border-color 0.12s ease, + color 0.12s ease; +} + +/* DragDrop toggles `activeDropTargetClass` (default: 'active') on hover. */ +.pg-dropzone.active { + border-color: var(--good); + border-style: solid; + background: #e7f6ec; + color: var(--good); +} + +/* Briefly flashed by the demo when a drop lands on a zone. */ +.pg-dropzone.pg-dropzone--hit { + border-color: var(--accent); + background: #e6f0ff; + color: var(--accent); +} + +/* --- DragSort / reorderable list ---------------------------------------- */ +.pg-sort-list { + margin-top: 1rem; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.pg-sort-item { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.65rem 0.9rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: #fafbfc; + font-weight: 600; + font-size: 0.85rem; + cursor: grab; + user-select: none; + /* Required so pointer drags aren't eaten by touch scrolling/panning. */ + touch-action: none; +} + +.pg-sort-item:active { + cursor: grabbing; +} + +.pg-sort-grip { + color: var(--muted); + font-size: 1.1rem; + line-height: 1; + letter-spacing: -0.15em; +} + +/* The placeholder DragSort inserts at the landing spot. */ +.pg-sort-insertion { + list-style: none; + height: 2.5rem; + border: 2px dashed var(--accent); + border-radius: var(--radius); + background: #e6f0ff; +} + +/* --- HUD / anchored popover -------------------------------------------- */ +.pg-hud-arena { + position: relative; + margin-top: 1rem; + height: 360px; + border: 1px dashed var(--border); + border-radius: var(--radius); + background: repeating-linear-gradient( + -45deg, + #fafbfc, + #fafbfc 10px, + #f2f4f7 10px, + #f2f4f7 20px + ); +} + +.pg-hud-trigger { + position: absolute; +} + +/* The HUD container — the HUD JS sets display/position/left/top inline. */ +.hud { + z-index: 102; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); +} + +.hud .body { + margin: 0; +} + +.hud .main-container { + overflow: hidden; +} + +.hud .main { + padding: 0.9rem 1.1rem; + min-width: 200px; + max-width: 260px; +} + +/* The tip/arrow. The JS positions it along the relevant edge (the perpendicular + offset), and the orientation class (tip-top/-bottom/-left/-right) pins it to + the correct edge. */ +.hud .tip { + position: absolute; + width: 30px; + height: 30px; + pointer-events: none; +} + +.hud .tip::after { + content: ''; + position: absolute; + left: 8px; + top: 8px; + width: 14px; + height: 14px; + background: var(--surface); + border: 1px solid var(--border); + transform: rotate(45deg); +} + +.hud .tip-top { + top: -15px; +} + +.hud .tip-bottom { + bottom: -15px; +} + +.hud .tip-left { + left: -15px; +} + +.hud .tip-right { + right: -15px; +} + +.pg-hud-content h4 { + margin: 0 0 0.4rem; + font-size: 0.95rem; +} + +.pg-hud-content p { + margin: 0 0 0.75rem; + color: var(--muted); + font-size: 0.85rem; +} + +.hud-shade { + position: fixed; + inset: 0; + z-index: 100; + background: rgba(15, 18, 22, 0.25); + display: none; +} + +/* --- DisclosureMenu / dropdown menu ------------------------------------ */ +.pg-disc-trigger { + font-weight: 600; +} + +/* The menu panel — the JS sets display/top/left/right/opacity inline. */ +.menu.menu--disclosure { + position: absolute; + display: none; + z-index: 102; + min-width: 180px; + padding: 4px; + background: var(--surface); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + opacity: 0; +} + +.menu.menu--disclosure.visible { + display: block; +} + +.menu--disclosure ul { + list-style: none; + margin: 0; + padding: 0; +} + +.menu--disclosure h3 { + margin: 0.4rem 0 0.2rem; + padding: 0 0.6rem; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--muted); +} + +.menu--disclosure hr { + margin: 0.35rem 0; + border: none; + border-top: 1px solid var(--border); +} + +.menu--disclosure .menu-item { + display: block; + width: 100%; + text-align: left; + padding: 0.4rem 0.6rem; + border: 0; + border-radius: 5px; + background: transparent; + color: inherit; + font: inherit; + cursor: pointer; +} + +.menu--disclosure .menu-item:hover, +.menu--disclosure .menu-item:focus { + background: var(--accent-soft, #eef2ff); + outline: none; +} + +.menu--disclosure .menu-item.sel { + font-weight: 600; +} + +.menu--disclosure .menu-item.error { + color: #c0392b; +} + +.menu--disclosure .menu-item.disabled { + opacity: 0.45; + cursor: default; +} + +.menu--disclosure .search-container { + padding: 0.25rem 0.25rem 0.5rem; +} + +.menu--disclosure .search-container input { + width: 100%; + box-sizing: border-box; + padding: 0.35rem 0.5rem; + border: 1px solid var(--border); + border-radius: 5px; + font: inherit; +} + +.menu--disclosure .clear-btn { + display: none; +} + +/* Hidden / filtered items + their empty groups/separators collapse. */ +.menu--disclosure li.hidden, +.menu--disclosure li.filtered, +.menu--disclosure ul.hidden, +.menu--disclosure hr.hidden, +.menu--disclosure h3.hidden { + display: none; +} diff --git a/packages/craftcms-garnish/.storybook/preview.ts b/packages/craftcms-garnish/.storybook/preview.ts new file mode 100644 index 00000000000..e7ec2d44b9d --- /dev/null +++ b/packages/craftcms-garnish/.storybook/preview.ts @@ -0,0 +1,45 @@ +import type {HtmlRenderer, Preview} from '@storybook/html-vite'; +import {withThemeByDataAttribute} from '@storybook/addon-themes'; +import './preview.css'; + +/** + * Global Storybook config for @craftcms/garnish. + * + * The global demo styles (modal/shade, drag arena/boxes, HUD tip, menu panel, + * the event-log panel) are migrated from the old `playground/styles.css` into + * `preview.css`, imported here so every story shares them. + */ +const preview: Preview = { + parameters: { + controls: { + expanded: true, + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + options: { + storySort: { + method: 'alphabetical', + }, + }, + a11y: { + // 'todo' surfaces a11y violations in the UI without failing the build — + // these are imperative widget demos, not production-accessible markup. + test: 'todo', + }, + }, + decorators: [ + withThemeByDataAttribute({ + themes: { + light: 'light', + dark: 'dark', + }, + defaultTheme: 'light', + attributeName: 'data-theme', + }), + ], + tags: ['autodocs'], +}; + +export default preview; diff --git a/packages/craftcms-garnish/README.md b/packages/craftcms-garnish/README.md new file mode 100644 index 00000000000..fe68277bc03 --- /dev/null +++ b/packages/craftcms-garnish/README.md @@ -0,0 +1,368 @@ +# @craftcms/garnish + +Modern, tree-shakeable TypeScript rewrite of Craft CMS's Garnish UI library. + +It ships **two surfaces from one package**: + +- A **modern**, jQuery-free, ESM-only API of native `class`es, native events, and + tree-shakeable utilities — the preferred surface for new code. +- An opt-in **compat** layer (`@craftcms/garnish/compat`) that restores the legacy + `Garnish.Base.extend()` / `this.base()` / jQuery authoring contract on top of the + same modern core, so existing Craft plugins can adopt it with (near) zero changes + and migrate incrementally. + +## Features + +- jQuery-free modern core (the `.` entry never imports or touches jQuery) +- Native ES classes with a real `extends` / `super` model +- Object pub/sub + namespaced DOM listeners that preserve the legacy event grammar +- Accessible, animated `Modal` (focus trapping, ARIA backgrounding, Web Animations + API fades that respect `prefers-reduced-motion`) +- A broad utility surface (DOM, focus, ARIA, forms, animation, environment helpers) +- Full TypeScript types +- An opt-in compat layer that wraps every class so `.extend()` / `init` / + `this.base()` / jQuery args keep working, and installs a legacy-shaped + `window.Garnish` + +## Installation + +```bash +npm install @craftcms/garnish +``` + +jQuery is an **optional peer dependency** — required only by the `compat` entry, and +only at runtime. The modern `.` entry never needs it. + +## Usage + +### Mode A — Modern (recommended for new code) + +Import named exports. ES classes, native events, no jQuery. Anything you don't +import is tree-shaken away. + +```ts +import {Modal} from '@craftcms/garnish'; + +const el = document.querySelector('#my-modal')!; +const modal = new Modal(el, { + closeOtherModals: true, + hideOnEsc: true, + onShow: () => console.log('shown'), +}); + +// Subscribe to events with the native emitter: +modal.on('hide', () => console.log('hidden')); +modal.on('fadeOut.myPlugin', () => cleanup()); +modal.off('.myPlugin'); // remove everything in that namespace + +// Later: +modal.hide(); +modal.destroy(); +``` + +Subclass with plain `class extends` and `super`: + +```ts +import {Modal, type ModalSettings} from '@craftcms/garnish'; + +class ConfirmModal extends Modal { + constructor(container: Element, settings?: Partial) { + super(container, settings); + // ...your setup... + } + + override onShow(): void { + super.onShow(); // preserves the `show` event + onShow callback + // ...custom behavior... + } +} +``` + +Utilities are individual named exports — import only what you use: + +```ts +import {trapFocusWithin, releaseFocusWithin, getPostData} from '@craftcms/garnish'; +``` + +#### Dragging — `BaseDrag` / `DragMove` + +`BaseDrag` is the jQuery-free drag foundation (Pointer Events, native auto-scroll). +`DragMove` is a thin subclass that positions the dragged element under the cursor. +Drag handles need `touch-action: none` so the browser doesn't eat the gesture on +touch devices. + +```ts +import {DragMove} from '@craftcms/garnish'; + +const box = document.querySelector('#draggable')!; +box.style.touchAction = 'none'; // required for touch/pen + +const dragger = new DragMove(box, { + // axis: 'x', // optionally lock to one axis + // handle: '.drag-handle', // drag only by a child selector + onDragStart: () => console.log('start'), + onDrag: () => console.log('moving'), + onDragStop: () => console.log('stop'), +}); + +dragger.on('drag', () => {/* same events are also emitted */}); +// later: dragger.destroy(); +``` + +For full control, use `BaseDrag` directly and position the element yourself in +`onDrag` (read `mouseX/mouseY`/`mouseOffsetX/mouseOffsetY` off the dragger). + +#### Helpers & drop targets — `Drag` / `DragDrop` + +`Drag` picks up the selected element(s): on drag start it builds floating +*helper* clones that trail the cursor with lag, and on drop you choose what +happens — animate them back to their source (`returnHelpersToDraggees()`, a Web +Animations API tween that respects `prefers-reduced-motion`) or fade them out +(`fadeOutHelpers()`). `DragDrop` adds drop targets on top: while dragging, the +target under the cursor is highlighted (its `activeDropTargetClass` is toggled) +and `onDropTargetChange` fires; there is no separate `drop` event — read +`$activeDropTarget` inside your own `dragStop` handler to perform the drop. + +```ts +import {DragDrop} from '@craftcms/garnish'; + +const dd = new DragDrop({ + dropTargets: '.dropzone', // selector, element(s), or a () => elements fn + onDropTargetChange: (active) => console.log('over:', active), + onDragStop() { + if (dd.$activeDropTarget) { + // a raw HTMLElement | null — NOT a jQuery object (drop the `[0]`) + console.log('dropped on', dd.$activeDropTarget); + } + dd.returnHelpersToDraggees(); // send the helper clone home + }, +}); +dd.addItems(document.querySelectorAll('.chip')); // items added after construction +``` + +As with `BaseDrag`, draggable items and handles need `touch-action: none`. + +#### Sortable lists — `DragSort` + +`DragSort` is the sortable-list dragger built on `Drag`: drag an item to reorder it +within its container, with **live insertion feedback** — as the cursor moves, the +dragged item (and an optional `insertion` placeholder) is re-inserted into the DOM at +the spot it will land, so the surrounding items reflow in real time. On drop the new +order is committed and `sortChange` fires if anything moved. `insertionPointChange` +fires whenever the landing spot moves. + +```ts +import {DragSort} from '@craftcms/garnish'; + +const list = document.querySelector('#sortable')!; +const sort = new DragSort(list.querySelectorAll('li'), { + container: list, // the sort is constrained to this (heighted) container + axis: 'y', // single-column list + insertion: () => { + const ph = document.createElement('li'); + ph.className = 'insertion'; + return ph; // a placeholder shown at the landing spot + }, + onInsertionPointChange: () => console.log('insertion moved'), + onSortChange: () => console.log('new order committed'), + // gate where items may land: + // canInsertBefore: (item) => !item.classList.contains('locked'), +}); +sort.on('sortChange', () => persistOrder()); +``` + +`magnetStrength > 1` rubber-bands the helper toward the dragged item's home; +`moveTargetItemToFront` keeps a multi-select drag's target leading the block. As with +`BaseDrag`, the sortable items / handles need `touch-action: none`. + +#### Anchored popovers — `HUD` + +`HUD` is the anchored popover/bubble. It attaches to a trigger element, picks one of +four orientations (`bottom` / `top` / `right` / `left`) from the available clearance +around the trigger, draws a tip pointing back at it, follows the trigger on +scroll/resize, traps `Tab` focus between the trigger and the body, and registers a +UI layer + Escape shortcut (the same `UiLayerManager` `Modal` uses). Append your +content into `hud.$main` — a raw `HTMLElement`, not a jQuery object. + +```ts +import {HUD} from '@craftcms/garnish'; + +const addBtn = document.querySelector('#add-btn')!; +const hud = new HUD(addBtn, { + hudClass: 'hud fld-library-hud', + orientations: ['right', 'bottom', 'left'], + showOnInit: false, +}); + +hud.on('show', () => hud.$main!.append(library)); // $main is an HTMLElement +hud.on('hide', () => addBtn.focus()); +addBtn.addEventListener('click', () => hud.show()); +// hud.toggle(); hud.updateSizeAndPosition(); hud.destroy(); +``` + +The constructor mirrors the legacy shape — `new HUD(trigger, bodyContents?, settings?)`, +with a `new HUD(trigger, settings)` param shift — so a legacy +`new Garnish.HUD($addBtn, {...})` call ports across unchanged. + +#### Dropdown menus — `DisclosureMenu` + +`DisclosureMenu` is the disclosure dropdown/menu: a trigger button (carrying +`aria-controls` / `aria-expanded`) paired with a menu panel referenced by that id. +It anchors the panel below the trigger (flipping above when there's no room), +aligns it left/center/right, manages full keyboard navigation + type-ahead search +and focus, registers a UI layer + Escape shortcut, dismisses on an outside click, +and exposes item/group builders (`addItem` / `addGroup` / `addHr`) consumers use to +populate it. An optional `withSearchInput` filters items live. + +```ts +import {DisclosureMenu} from '@craftcms/garnish'; + +// +// +const menu = new DisclosureMenu(document.querySelector('button')!); + +menu.addItem({label: 'Rename', onActivate: (el) => rename(el)}); +const group = menu.addGroup('Danger zone'); +menu.addItem({label: 'Delete', destructive: true, onActivate: () => remove()}, group); + +menu.on('show', () => console.log('opened')); +// menu.show(); menu.hide(); menu.isExpanded(); menu.destroy(); +``` + +Selection is per-item: each item's `onActivate` / `callback` runs on activation, +then the menu hides. `DisclosureMenu.getInstance(triggerOrContainer)` is the native +replacement for the legacy `$el.data('disclosureMenu')`. A legacy +`new Garnish.DisclosureMenu($trigger, settings)` call ports across unchanged. + +**No-jQuery guarantee:** importing from `@craftcms/garnish` (the `.` entry) never +pulls in jQuery, never reads `window.jQuery`/`$`, and never assigns +`window.Garnish`. Those behaviors live exclusively in the `compat` entry. + +### Mode B — Compat / upgrade path (for existing plugins) + +The compat entry restores the legacy authoring contract: a `window.Garnish` global, +`.extend()`-able classes, `init()` as the constructor, `this.base()` super-dispatch, +and jQuery-collection constructor arguments. + +#### How to ADD the compat layer + +Add a single side-effecting import at your bundle entry point: + +```ts +import '@craftcms/garnish/compat'; + +// `window.Garnish` is now populated. Existing code keeps working unchanged: +Craft.MyModal = Garnish.Modal.extend({ + init(container) { + this.base(container, {closeOtherModals: true}); // calls Modal's constructor + }, + onShow() { + this.base(); // calls Modal.prototype.onShow + // ...custom behavior... + }, +}); + +new Craft.MyModal($('#my-modal')); // jQuery collection args are unwrapped for you +``` + +That import installs `window.Garnish` (guarded by +`if (typeof window.Garnish === 'undefined')`, so it never clobbers an existing +legacy-bundle global). jQuery must be present on the global scope for the +jQuery-shaped parts (`$container` args, `$win`/`$doc`/`$bod`, `isJquery`, +`$.fn.activate/textchange/resize` sugar); the layer throws a clear, actionable +error if a jQuery-only feature is used while jQuery is absent. + +For programmatic (non-auto-install) use, call `installGarnishCompat()` or work with +the named `compatify` / `GarnishCompat` exports directly: + +```ts +import {installGarnishCompat, compatify} from '@craftcms/garnish/compat'; +import {Modal} from '@craftcms/garnish'; + +const Garnish = installGarnishCompat(); +const LegacyModal = compatify(Modal); // one class, without the global +``` + +#### How to DROP the compat layer + +The compat layer is **opt-in and tree-shakeable** — you remove it by removing its +import. Migrate one class at a time: + +1. Convert a `Garnish.X.extend({init(){…}})` subclass to + `class extends X { constructor(){ super(); … } }`, replacing `this.base(...)` + with `super.method(...)` and importing the modern named export + (`import {Modal} from '@craftcms/garnish'`). Modern and compat code can coexist + in the same bundle during the transition. +2. Once nothing depends on `window.Garnish` or the legacy affordances, **delete the + `import '@craftcms/garnish/compat'` line.** Tree-shaking then drops all of the + compat code, the `window.Garnish` global, and the jQuery peer requirement. + +## Documentation + +- [`docs/06-api-reference.md`](docs/06-api-reference.md) — the public API cheat sheet + (signatures + one-liners) for `Base`, `Modal`, the drag classes (`BaseDrag`, + `DragMove`, `Drag`, `DragDrop`), the `Garnish` namespace utilities, and the compat + exports. +- [`docs/00-migration-plan.md`](docs/00-migration-plan.md) §2 — the compat design and + upgrade-path rationale. +- [`docs/01-core-design.md`](docs/01-core-design.md) — core architecture and the + utility-by-utility port plan. +- [`docs/03-modal-slice.md`](docs/03-modal-slice.md) — the Modal PoC contract. + +Public API symbols carry TSDoc — your editor's IntelliSense is the fastest reference. + +## Development + +```bash +npm run dev # Storybook dev server at http://localhost:6006 +npm run storybook # alias of `npm run dev` +npm run build:storybook # static Storybook build +npm run build # production build (dual `.` + `/compat` entries) +npm run build:watch # tsdown watch build +npm run test # Vitest suite +npm run check:types # tsc --noEmit (includes stories) +npm run format # Prettier (writes ./src ./tests ./stories ./.storybook) +``` + +Interactive component demos live in **Storybook** (`npm run dev` or +`npm run storybook` → http://localhost:6006), with one story file per component +under `stories/`. Stories import the real source from `../src`, so edits +hot-reload instantly. See [`docs/17-storybook-notes.md`](docs/17-storybook-notes.md) +for how stories are organized, the Actions-panel event logger, and how to add a story +when porting a new component. + +## Status + +This is the vertical-slice proof of concept. The modern core, `Base`, `Modal`, the +drag foundation, and the compat layer are complete and tested. + +- **`BaseDrag` / `DragMove`** are implemented (Pointer Events, native auto-scroll) + and available as named exports. +- **`Drag` / `DragDrop` / `DragSort`** are **supported** and available as named + exports. `Drag` adds helper clones + return-to-source / fade-out (Web Animations + API, reduced-motion aware); `DragDrop` adds drop targets + hit detection; `DragSort` + adds sortable lists with live insertion feedback (the `_getClosestItem` spatial + hit-test + midpoint caching, the `insertion` placeholder, and `sortChange` / + `insertionPointChange` events). +- **`Modal` `draggable` / `resizable`** are **supported** (still `false` by default). + A draggable modal uses `DragMove` on its container — or on the element matched by + `dragHandleSelector` for a header-only handle — and a resizable modal uses + `BaseDrag` on a generated corner handle. (They previously threw; that limitation + is gone.) +- **`HUD`** is **supported** (Phase 3): the anchored popover with smart 4-way + positioning, a tip/arrow, scroll-follow, focus trapping, and `UiLayerManager` + layer + Escape integration. This was the last `FieldLayoutDesigner` overlay + blocker. +- **`DisclosureMenu`** is **supported** (Phase 3): the disclosure dropdown/menu + with above/below + left/center/right positioning, full keyboard navigation + + type-ahead search, focus management, `UiLayerManager` layer + Escape, an + optional search input, and the item/group builder surface (~19 CP sites depend + on it). It was the largest remaining component (~1,008 LOC). + +The **drag cluster is COMPLETE** — `BaseDrag`, `DragMove`, `Drag`, `DragDrop`, and +`DragSort` are all ported, with no drag modules pending. The overlay/menu set +(`Modal` + `HUD` + `DisclosureMenu`) is ported too. + +## License + +MIT © Pixel & Tonic, Inc. diff --git a/packages/craftcms-garnish/docs/00-migration-plan.md b/packages/craftcms-garnish/docs/00-migration-plan.md new file mode 100644 index 00000000000..a4a2f38cb5e --- /dev/null +++ b/packages/craftcms-garnish/docs/00-migration-plan.md @@ -0,0 +1,318 @@ +# Garnish Migration Plan & Effort Estimate + +> **Status: Authoritative plan — read this first.** This document is the go/no-go reference for converting the entire +> legacy Garnish library (`packages/craftcms-legacy/garnish/`) to the modern, jQuery-free TypeScript/ESM package +> (`packages/craftcms-garnish/`, published as `@craftcms/garnish`). It synthesizes the four design docs in this folder +> (01 core design, 02 dependency graph, 03 Modal slice, 04 scaffold) plus a source sanity-check of the hard modules, +> and turns them into a phased plan, a per-module effort table, and a cutover strategy. +> +> Companion docs: **01** is the core contract, **02** is the dependency/jQuery analysis, **03** is the Modal PoC +> template, **04** is the package toolchain. This doc references them rather than repeating them. + +--- + +## 1. Executive summary + +We are rebuilding Garnish as two layers shipped from one package. The **modern core + components** (`@craftcms/garnish`) +is a clean ESM/TypeScript rewrite: native `class extends` instead of Dean Edwards `Base.extend()`, a hand-written +emitter instead of `jQuery.on/off/trigger`, native DOM APIs instead of jQuery collections, and the Web Animations API +instead of Velocity. It is tree-shakeable, zero-runtime-dependency, and the surface every new Craft feature should +target. A **separate, opt-in compatibility layer** (`@craftcms/garnish/compat`) restores the entire legacy authoring +contract — `Garnish.Base.extend(instance, static)`, `init()`-as-constructor, `this.base()` super-calls, jQuery-collection +arguments (`$container`), namespaced jQuery-style events, `window.Garnish`, and `isJquery` — by mechanically wrapping the +modern classes. A plugin author keeps working unchanged by importing compat; they "drop it" by moving to the modern +constructor/`extends` API at their own pace. Nothing forces a flag-day rewrite of the ~107 consumer files. + +The two packages **coexist**: the legacy `garnish` webpack bundle keeps shipping the jQuery library to the legacy CP +surface until the modern package reaches parity, and the modern package is adopted incrementally — first by the Inertia/Vue +CP and new code, then by legacy modules wrapped through compat. The legacy webpack `garnish` build is retired only at the +final cutover, once every consumer resolves `Garnish` from the new package (directly or via compat) and visual-regression +parity is proven on the real CP. + +The strategy is deliberately **leaves-first** (per doc 02's topological order): foundation core first, then the drag +system and overlays, then selection/menus, then form inputs, then cutover. The two load-bearing risks are (a) the compat +layer faithfully reproducing `.extend()` + `this.base()`, and (b) the focusable-element matcher and animation parity. Both +are addressed below with explicit mitigations and test budgets. + +--- + +## 2. The compat layer design (`@craftcms/garnish/compat`) + +The compat layer is the single biggest determinant of "easy upgrade path." It must let an unmodified legacy module — +`Craft.DeleteUserModal = Garnish.Modal.extend({ init, onShow, destroy }, { defaults })` — run against the modern +`class Modal`. It is **opt-in**: importing it has side effects (it populates `window.Garnish` and wraps every class), and +not importing it gives you the clean modern surface with zero legacy weight. + +### 2.1 How a plugin author adds or drops it + +| Scenario | What they do | +| --- | --- | +| **Legacy plugin, no changes** | `import '@craftcms/garnish/compat';` once at bundle entry. This assigns `window.Garnish` with `.extend()`-able, jQuery-shaped classes. All existing `Garnish.X.extend({...})` and `$container` code keeps working. (Or: keep consuming the legacy webpack `garnish` global until it is retired — see §5.) | +| **Incremental migration** | Author migrates one class at a time from `Garnish.Modal.extend({init(){...}})` to `class extends Modal { constructor(){ super(); ... } }`, importing the modern named export `import {Modal} from '@craftcms/garnish'`. Compat and modern can be mixed in the same bundle during transition. | +| **Fully modern** | Drop the `import '@craftcms/garnish/compat'` line entirely. Tree-shaking then removes the compat code and the `window.Garnish` global. | + +### 2.2 The wrapping mechanism — `compatify(ModernClass)` + +The core of the compat layer is a single higher-order function that takes a modern ES class and returns a legacy-shaped +constructor that supports `.extend(instance, static)`. Per doc 01 §1.3, the modern core keeps plain constructors and +stable method names precisely so this can be mechanical. + +```ts +// compat/extend.ts (sketch — illustrative, not final code) +type ModernCtor = abstract new (...args: any[]) => object; + +interface LegacyCtor { + new (...args: any[]): any; + extend(instance?: object, statics?: object): LegacyCtor; +} + +function compatify(Modern: ModernCtor): LegacyCtor { /* returns Base-shaped ctor */ } +``` + +It restores each legacy behavior as follows: + +- **`.extend(instance, static)` → a subclass.** `compatify` returns a constructor exposing a static `extend(instance, + static)` that builds a new subclass of the modern class. `instance` members are copied onto the subclass prototype; + `static` members onto the constructor. This reproduces Dean Edwards `lib/Base.js` semantics (the version-1.1a file we + inspected) but backed by a real prototype chain instead of `new this()` prototype cloning. + +- **`init` → constructor.** Legacy `Garnish.Base`'s constructor calls `this.init.apply(this, arguments)`. The modern core + dropped that trampoline (doc 01 §1.2). The compat subclass therefore defines a `constructor(...args)` that calls + `super(...args)` and then, if the `instance` object supplied an `init`, invokes `this.init(...args)`. Subclass authors + keep writing `init`, never `constructor`. + +- **`this.base(...)` synthesis.** This is the subtle part. In `lib/Base.js` the `extend` wrapper detects `/\bbase\b/` in a + method's source and, at call time, sets `this.base = `, runs the override, then restores the previous + `this.base`. The compat layer reproduces this exactly: when copying an `instance` method that references `base`, wrap it + so that on entry it sets `this.base` to the **prototype method it is overriding** (found by walking the modern prototype + chain, i.e. `Object.getPrototypeOf(subclassProto)[name]`), bound to `this`, and restores the prior `this.base` on exit + (try/finally for re-entrancy). The `/\bbase\b/` source-sniff and the save/restore dance are copied verbatim — they are + observable and re-entrant (nested super-calls rely on the restore). For TypeScript-authored migrators we recommend + `super.method()` instead; `this.base` is a compat-only affordance. + +- **jQuery-collection args (`$container`, `$trigger`, `items`).** Modern constructors accept `Element | EventTarget | + string | null`. Compat wraps the constructor to coerce any incoming jQuery collection (or selector string) to the native + input the modern class expects (`jq[0]` / `Array.from(jq)`), and conversely exposes legacy `$`-prefixed properties + (`this.$container`, `this.$trigger`, `this.$shade`) as jQuery wrappers around the modern native references via a getter. + This requires jQuery as a **peer dependency of the compat entry only** (declared per doc 04 in `package.json` + `peerDependencies` and `tsdown` `deps.neverBundle`); the modern entry stays jQuery-free. + +- **`window.Garnish`.** Compat builds the legacy-shaped global by taking the modern `Garnish` namespace object (doc 01 + §6.1), running `compatify` over each class (`Base`, `Modal`, `HUD`, ...), re-adding the jQuery-only members (`$win`, + `$doc`, `$bod`, `$scrollContainer`, `isJquery`, the `$.fn.activate/textchange/resize` chaining sugar, the deprecated + `Menu`/`ShortcutManager` aliases), calling `initGarnish()` to attach the manager singletons, and assigning the result to + `window.Garnish` under the same guard the legacy code uses (`if (typeof window.Garnish === 'undefined')`). + +- **`isJquery`.** Dropped from core (doc 01 §4.B); restored in compat as `(v) => v instanceof jQuery`. + +- **jQuery-style namespaced events.** The modern emitter already preserves the legacy event-string grammar verbatim + (doc 01 §2.1 — space-split for pub/sub, comma-split for `addListener`, first-`.` namespace split) and the trigger-object + key precedence (§2.3). So `on('show.myns', fn)` / `off('.myns')` and `addListener(el, 'click.foo', fn)` already behave + identically; compat does **not** need to special-case them. Compat only adds the jQuery `$.event.special` + `activate/textchange/resize` chaining sugar (`$el.activate(fn)`), routing it to the core `install*` functions (§3 of + doc 01). + +### 2.3 Compat layer effort + +Treat compat as one substantial deliverable, not free. It needs its own Vitest suite asserting `.extend()`, `this.base()` +re-entrancy, `init` mapping, and `$`-property coercion against representative real subclasses (`Craft.CpModal`, +`BaseElementSelectorModal`). Budgeted at **8 dev-days** in the table (§4), front-loaded so the Modal PoC can be validated +through it. + +--- + +## 3. Phased migration sequence + +Ordered leaves-first using doc 02's topological order. Each phase is independently shippable (the package builds and the +already-migrated modules are usable) so we get continuous integration value rather than a big-bang merge. + +### Phase 0 — Scaffold (DONE) + +Package, tsdown dual-entry build, Vitest+happy-dom, Prettier all green (doc 04). `src/compat.ts` is a placeholder. + +### Phase 1 — Core foundation + Modal PoC (the vertical slice) + +The PoC proves the whole strategy end-to-end before we commit to the long tail. + +| Module | Difficulty | Key jQuery removals | Risks | Deps | +| --- | --- | --- | --- | --- | +| `lib/Base.js` | n/a | none — replaced wholesale by native `class` (doc 01 §1) | none | — | +| `Base.js` → `base.ts` + `events.ts` + `dom-listeners.ts` | MED | `$.noop`/`$.extend`/`$.trim`/`$.proxy` trivial; **rebuild dual event system + namespaced DOM listeners without jQuery** | emitter/`off`-matching/`once`-wrapper parity; namespaced `.off` without jQuery namespaces | core | +| `Garnish.js` → `globals.ts`/`utils/*`/`custom-events/*`/`constants.ts` | HIGH | `$.event.special`→installers; `$win/$doc/$bod`→native; `.velocity()`→WAAPI; `.scrollParent()`→finder; `:focusable`→matcher; `$.data`→WeakMap | **focusable matcher fidelity**, animation parity, the 40+ util surface | Base | +| `focusable.ts` matcher (§4.E doc 01) | HIGH | reimplement jQuery-UI `:focusable`/`:visible` | accessibility correctness; underpins all focus traps | — | +| `EscManager.js`, `UiLayerManager.js` | LOW | `$.isPlainObject`, basic selectors, `$.each` | minimal | Base | +| `icons/ResizeHandle.js` | n/a | none (SVG string) | none | — | +| `Modal.js` → `modal.ts` | MED | per doc 03 §2: element creation/insertion/class/dims trivial; **Velocity fade → WAAPI** | size/position math; focus trap; defaults parity | Base, UiLayerManager, (DragMove/BaseDrag deferred) | +| **compat layer (initial)** | HIGH | `compatify`, `init`/`this.base`, `$`-coercion, `window.Garnish`, `isJquery`, event sugar (§2) | re-entrant `this.base`; jQuery peer wiring | core + jQuery | + +PoC defers `draggable`/`resizable` Modal (defaults are `false` per doc 03 §1 Tier 2), so `BaseDrag` is **not** required for +the slice. **"PoC done"** = modern `Modal` + core build, plus an unmodified `Craft.*Modal` subclass running through compat +on the real CP with visual + a11y parity. + +### Phase 2 — Drag system + +The drag cluster is self-contained below `Base`/`Garnish` and feeds Modal's resizable/draggable mode, DragSort, DragDrop. +Do it as a unit; the `.scrollParent()` + auto-scroll machinery (BaseDrag lines ~83–210, a `setInterval` window-scroll loop +keyed off `_scrollProperty`/`_scrollAxis`/`_scrollDist`) is shared and must be ported once into a reusable scroll-parent + +auto-scroll helper. + +| Module | Difficulty | Key jQuery removals | Risks | Deps | +| --- | --- | --- | --- | --- | +| `BaseDrag.js` | HIGH | `.scrollParent()` ×2, `.offset()`, `$.data/removeData` (→WeakMap), `$.makeArray/inArray`, `.index()`, auto-scroll `setInterval` loop | scroll-parent finder correctness; pointer math; drag perf | Base, Garnish | +| `DragMove.js` | LOW | none (already pure) | none | BaseDrag | +| `Drag.js` | MED | `.clone()`→`cloneNode(true)`, `.velocity()` return-to-source, `.outerWidth/Height`, `.offset()` | helper-clone positioning; return animation parity | BaseDrag | +| `DragDrop.js` | LOW | `$(el)`, class methods, `$.extend`, `$.noop` | minimal | Drag | +| `DragSort.js` | HIGH | `.insertBefore/After/prependTo`, `.offset()`, `.index()`, `.find/not/filter`, `$.contains` | `_getClosestItem` hit-detection algorithm + midpoint caching (perf-critical, large lists); insertion visual feedback | Drag | + +### Phase 3 — Overlays & menus + +| Module | Difficulty | Key jQuery removals | Risks | Deps | +| --- | --- | --- | --- | --- | +| `HUD.js` | MED-HIGH | `.scrollParent()`, `.offset/outerW/H`, `.velocity()`, insertion, `:focusable` | smart 4-way positioning/orientation logic; scroll-follow | Base, UiLayerManager, focusable | +| `CustomSelect.js` | MED | `.velocity()`, `.offset/scrollTop/Left`, positioning, `$.data` | anchor-relative positioning (`_alignLeft/Right/Center`) | Base, UiLayerManager | +| `SelectMenu.js` | LOW | `$.extend` | thin `.sel` wrapper | CustomSelect | +| `MenuBtn.js` | LOW-MED | `.data/.attr`, class, `.contains`, MutationObserver | keyboard search/nav | Base, CustomSelect | +| `ContextMenu.js` | MED | `$('
')` etc. DOM creation, `.css`, `.mousedown`, `.show/.hide` | dynamic menu build | Base, UiLayerManager | +| `DisclosureMenu.js` | HIGH | `.velocity()` show/fadeOut, `:focusable` (6 sites), `.scrollParent()`, `.find/closest/filter/parent`, search `data('searchText')` | 1,008 LOC; complex focus + type-ahead search + positioning | Base, UiLayerManager, focusable | + +### Phase 4 — Selection & form inputs + +| Module | Difficulty | Key jQuery removals | Risks | Deps | +| --- | --- | --- | --- | --- | +| `Select.js` | HIGH | 41 jQuery sites: `.find/filter/index/not/eq/slice`, `.offset/outerW/H`, `$.data` (`select`/`select-handle`/`select-item`), `$.inArray/makeArray`, `:focusable` | highest coupling; `getClosestItem` spatial query; shift-range + Ctrl+A + keyboard; `$.data` item-back-references → WeakMap | Base, Garnish, focusable | +| `NiceText.js` | LOW-MED | `.val/.attr/.css`, `.height/.width`, `.velocity()` fade, insertion | text-measurement `getHeightForValue`; `textchange` event | Base, custom-events | +| `MixedInput.js` | LOW | `.attr/.val/.css/.prop`, insertion, `$.inArray` | caret positioning; nested `TextElement` | Base | +| `CheckboxSelect.js` | LOW | `.find/filter/not`, `.prop` | minimal | Base | +| `MultiFunctionBtn.js` | LOW | `.data/.find`, class | minimal; ARIA live region | Base | + +### Phase 5 — Integration, cutover, retire legacy build + +Wire `index.js` equivalent (`initGarnish()`), finalize the compat `window.Garnish`, run full visual-regression + a11y + +the existing legacy Garnish test suite against the new package, then retire the legacy webpack `garnish` bundle (§5). + +--- + +## 4. Per-module effort estimate + +Estimates are **dev-days for an engineer fluent in the codebase**, including unit tests but **excluding** the cross-cutting +visual-regression/QA pass (counted once in Phase 5). "Size" is legacy LOC. Difficulty drivers are honest about the hard +ones: the focusable matcher, Velocity→WAAPI, `.scrollParent()`/auto-scroll, `$.event.special`, and the two spatial hit-test +algorithms (`Select.getClosestItem`, `DragSort._getClosestItem`). + +| Module | LOC | Difficulty | Est. (dev-days) | Notes | +| --- | --- | --- | --- | --- | +| `lib/Base.js` | 160 | — | 0 | Deleted; replaced by native class | +| `Base.js` (→ base/events/dom-listeners) | 193 | MED | 4 | Dual event system + namespaced DOM listeners are the real work, not the `$.x` swaps | +| `Garnish.js` (utils/globals/constants) | 1,211 | HIGH | 6 | 40+ utils; excludes matcher + custom-events (separate rows) | +| **focusable matcher** (§4.E) | — | HIGH | 3 | Single biggest reimplementation risk; heavy test budget | +| **custom-events** (activate/textchange/resize) | — | HIGH | 3 | `$.event.special`→installers + shared ResizeObserver | +| **animation utils** (shake, scrollContainerToElement) | — | MED | 2 | Velocity→WAAPI; scroll-parent finder lives here too | +| `EscManager.js` | 55 | LOW | 0.5 | Pure JS already | +| `UiLayerManager.js` | 181 | LOW | 1.5 | Light jQuery | +| `icons/ResizeHandle.js` | 4 | — | 0 | SVG string | +| `Modal.js` | 451 | MED | 4 | PoC reference; Velocity→WAAPI is the only hard bit | +| `BaseDrag.js` | 583 | HIGH | 6 | `.scrollParent()` + auto-scroll loop + WeakMap data; foundational for drag | +| `DragMove.js` | 15 | LOW | 0.25 | Trivial | +| `Drag.js` | 462 | MED | 3.5 | Clone helpers + return animation | +| `DragDrop.js` | 116 | LOW | 1 | — | +| `DragSort.js` | 697 | HIGH | 6 | `_getClosestItem` perf + insertion feedback | +| `HUD.js` | 764 | MED-HIGH | 5 | 4-way positioning | +| `CustomSelect.js` | 333 | MED | 3 | Anchor positioning | +| `SelectMenu.js` | 83 | LOW | 0.5 | Thin wrapper | +| `MenuBtn.js` | 444 | LOW-MED | 3 | Keyboard search | +| `ContextMenu.js` | 171 | MED | 2 | DOM creation | +| `DisclosureMenu.js` | 1,008 | HIGH | 7 | Largest UI component; focus + type-ahead + positioning | +| `Select.js` | 1,018 | HIGH | 8 | Highest jQuery coupling + spatial query | +| `NiceText.js` | 343 | LOW-MED | 2.5 | Text measurement | +| `MixedInput.js` | 424 | LOW | 3 | Caret handling | +| `CheckboxSelect.js` | 97 | LOW | 1 | — | +| `MultiFunctionBtn.js` | 125 | LOW | 1 | — | +| **Compat layer** (`compatify`, `window.Garnish`, jQuery coercion) | — | HIGH | 8 | The load-bearing upgrade-path risk (§2) | +| **Integration + cutover** (init wiring, legacy-build retirement, full QA/visual-regression/a11y) | — | HIGH | 10 | Counted once; covers all phases | +| **Total** | ~9k | — | **~104 dev-days** | ≈ 21 engineer-weeks; ~5 calendar months solo, ~2.5–3 months for a pair | + +Add **15–20% contingency** for parity surprises (drag pointer math, animation timing, focus edge cases), giving a planning +range of **~120–125 dev-days**. + +--- + +## 5. Consumer migration & cutover + +There are **~107 consumer files** using `Garnish.X.extend(...)` / `new Garnish.X(...)` across `packages/craftcms-legacy/` +and `resources/js/`. `Garnish.Base` alone has ~96 subclass usages (doc 02), so the consumer story *is* the compat story. + +### 5.1 Two consumer paths + +| Path | Who | What they do | Effort | +| --- | --- | --- | --- | +| **Do nothing (compat shim)** | The ~107 existing legacy modules | Resolve `Garnish` from compat (`import '@craftcms/garnish/compat'`, or keep the legacy global until retirement). `.extend()`, `this.base()`, `$container`, namespaced events all keep working unchanged. | ~0 per file | +| **Opt-in to modern API** | New code; modules being actively touched | Use named modern imports + `class extends` + `super()`; native element args; `super.method()` instead of `this.base()`. | Per-file, voluntary, incremental | + +The contract is: **no consumer is forced to change to keep working.** Migration to the modern API is opportunistic +(touch-it-fix-it), not a scheduled mass rewrite. + +### 5.2 Retiring the legacy webpack `garnish` build + +1. **Coexist (Phases 1–4).** Legacy webpack keeps bundling the jQuery `garnish` library and assigning `window.Garnish`. + The modern package is consumed by the Vue/Inertia CP and new code. Two `Garnish` implementations may briefly exist; + they must not both claim `window.Garnish` — the legacy guard (`if (typeof window.Garnish === 'undefined')`) and the + compat guard ensure last-write-wins is controlled, and during overlap **only one** owns the global (start with legacy, + flip to compat at step 3). +2. **Parity gate.** Once all 23 modules are ported and the compat suite is green, stand up the compat `window.Garnish` in a + branch and point the legacy CP at it instead of the webpack bundle. +3. **Flip the global.** Make `@craftcms/garnish/compat` the sole provider of `window.Garnish`; stop emitting the webpack + `garnish` entry. Run the full regression battery (§5.3). +4. **Delete.** Remove `packages/craftcms-legacy/garnish/` source and its webpack entry. jQuery remains only as the compat + peer dep until the long-tail consumers themselves drop jQuery. + +### 5.3 Testing strategy + +- **Unit (Vitest + happy-dom):** Per-module, written alongside each port. Heaviest budget on the focusable matcher, the + emitter (`off`/`once`/precedence parity), and the two spatial algorithms. Compat gets its own suite asserting + `.extend()`/`this.base()`/`init`/`$`-coercion against real subclasses. +- **Existing legacy Garnish test suite:** Run it against the compat layer as a behavioral oracle — it encodes current + expected behavior and is the cheapest parity check we have. +- **Visual regression on the real CP:** The decisive gate. Drive the actual control panel (modals, HUDs, disclosure menus, + drag-sort matrix fields, selection) and diff against legacy. Animation, positioning, and drag feedback are not unit-testable + with confidence. +- **Accessibility:** Manual + automated keyboard/focus-trap/ARIA checks on every overlay and menu (focus management is the + top a11y risk — §6). + +--- + +## 6. Risks & mitigations + +| Risk | Why it matters | Mitigation | +| --- | --- | --- | +| **Focusable matcher fidelity** (§4.E) | Underpins every focus trap, modal, menu. jQuery-UI `:focusable` has subtle rules (href links, disabled controls, contenteditable, tabindex, visibility). Divergence = broken keyboard a11y. | Model on the published jQuery-UI `:focusable` algorithm; dedicated exhaustive unit suite; manual keyboard QA on all overlays. Build it in Phase 1 so everything downstream shares one tested implementation. | +| **Animation parity (Velocity → WAAPI)** | 6 modules use `.velocity()` (Garnish shake, Modal/HUD/Drag/CustomSelect/DisclosureMenu fades + return-to-source). Timing/easing mismatches read as "feels off." `.velocity('stop')` semantics. | Standardize on `element.animate()`; match legacy durations (shade ~50ms, container ~`FX_DURATION` 200ms, doc 03 §2); `stop`→`anim.cancel()`; gate all on `prefersReducedMotion`. Visual-regression catches drift. | +| **Drag system complexity** | BaseDrag's `.scrollParent()` + `setInterval` auto-scroll loop and the `_getClosestItem`/`_updateInsertion` hit-detection are the most algorithmically dense, perf-sensitive code. WeakMap replaces `$.data` back-references. | Port scroll-parent + auto-scroll into one shared, tested helper. Preserve the midpoint-caching optimization. Perf-test DragSort on large matrix fields. | +| **Dual event systems** | Object pub/sub (incl. class-level `Garnish.on(Target,...)`) *and* namespaced DOM listeners must both be reproduced without jQuery, with exact `off`/`once`/precedence semantics (doc 01 §2). | The core spec already pins the grammar and precedence as compatibility warts; replicate `once`-as-wrapper and backward-splice `off`. Emitter is the most-tested unit. | +| **`this.base()` re-entrancy** (compat) | Nested super-calls rely on save/restore of `this.base`; getting it wrong silently breaks deep subclass chains (`Craft.CpModal` etc.). | Copy the `lib/Base.js` save/restore-in-try/finally verbatim; test multi-level `extend` chains explicitly. | +| **RTL** | `rtl`/`ltr` derive from `body.classList` and affect positioning (HUD, CustomSelect, DisclosureMenu). | Keep `rtl`/`ltr` flags in core globals; include RTL cases in positioning visual-regression. | +| **Accessibility (focus/ARIA)** | Modal background-layer hiding, focus trap/release, `setFocusWithin`'s `.field:visible` heuristic (cms#15245) are correctness-critical and easy to regress. | Preserve the documented heuristics verbatim; manual a11y QA gate before cutover. | +| **jQuery peer in compat** | Compat needs jQuery for `$`-coercion and `isJquery`; must not leak into the modern entry. | jQuery is a peer dep of the `./compat` entry only; tsdown `deps.neverBundle` (doc 04); verify modern `index.js` bundle has zero jQuery. | + +--- + +## 7. Recommended milestones + +| Milestone | Scope | "Done" means | +| --- | --- | --- | +| **M1 — PoC (vertical slice)** | Core foundation (Base/events/dom-listeners/globals/constants), focusable matcher, custom-events, EscManager, UiLayerManager, Modal, initial compat layer | Modern `Modal` builds and runs; an **unmodified** real `Craft.*Modal` subclass works through `@craftcms/garnish/compat` on the live CP with visual + a11y parity. Emitter + matcher unit suites green. This is the go/no-go gate. | +| **M2 — Core GA** | Harden core surface; full util parity; compat `window.Garnish` stands up the full namespace; legacy Garnish test suite passes against compat | New code can target modern API; compat is a drop-in for any module that uses only ported classes. Core API frozen. | +| **M3 — Component wave 1 (drag + overlays)** | BaseDrag, DragMove, Drag, DragDrop, DragSort, HUD, CustomSelect, SelectMenu, MenuBtn, ContextMenu, DisclosureMenu | Each ships with parity tests; DragSort perf-validated on large lists; overlays pass positioning + a11y visual-regression. | +| **M4 — Component wave 2 (selection + inputs)** | Select, NiceText, MixedInput, CheckboxSelect, MultiFunctionBtn | All 23 modules ported; full compat namespace complete; entire legacy test suite green against the new package. | +| **M5 — Cutover** | Flip `window.Garnish` to compat as sole provider; retire legacy webpack `garnish` bundle; delete legacy source | Legacy bundle removed; full CP visual-regression + a11y battery green; jQuery present only as the compat peer dep. Long-tail consumers migrate off compat opportunistically thereafter. | + +--- + +## 8. Bottom line for the go/no-go + +The strategy is sound and de-risked by the existing core design: the modern core deliberately preserves the legacy event +grammar, precedence, and method names so the compat layer can wrap classes **mechanically** rather than re-implementing +each one twice. The upgrade path for the ~107 consumers is genuinely "do nothing or migrate at will," which is the whole +point. The real cost and risk concentrate in a handful of places — the focusable matcher, animation parity, the drag +algorithms, and the `this.base()` synthesis — all called out above with explicit budgets and mitigations. Total effort is +**~104 dev-days (~120–125 with contingency)**, sequenced so the Modal PoC validates the entire approach before the long +tail is committed. diff --git a/packages/craftcms-garnish/docs/01-core-design.md b/packages/craftcms-garnish/docs/01-core-design.md new file mode 100644 index 00000000000..c2065a7d8aa --- /dev/null +++ b/packages/craftcms-garnish/docs/01-core-design.md @@ -0,0 +1,669 @@ +# Garnish Core — Modern Design Spec (`garnish-core`) + +> Status: **Design contract.** This document is the spec the implementation team builds against. It covers the +> foundation layer (`garnish-core`) that every other Garnish module depends on: the class system, the event system, +> the utility surface, the settings system, and the module layout. +> +> Scope boundary: This is the **modern, jQuery-free, ESM/TypeScript** core. The legacy `Base.extend()` contract, +> jQuery-collection arguments, and the legacy global `window.Garnish` object are **out of scope** here — they will be +> restored by a separate, opt-in **compat layer** (`garnish-compat`). Wherever a legacy behavior is mentioned, it is +> only to define the boundary the compat layer will bridge. + +--- + +## 0. Source-of-truth inventory (what the legacy core actually is) + +The legacy core is four files: + +| File | Role | +| --- | --- | +| `src/lib/Base.js` | Dean Edwards `Base.js` 1.1a — the `.extend(instance, static)` class/prototype system with `this.base()` super-call support. | +| `src/Base.js` | `Garnish.Base` — extends Dean Edwards Base. Settings system, instance-level pub/sub events (`on`/`off`/`once`/`trigger`), namespaced DOM listeners (`addListener`/`removeListener`/`removeAllListeners`), `disable`/`enable`, `destroy`. | +| `src/Garnish.js` | The `Garnish` singleton object — ~40 utilities + constants, the class-level event bus (`Garnish.on/off/once`), `_normalizeEvents`, jQuery `$.event.special` registrations (`activate`, `textchange`, `resize`), focus/ARIA helpers, animation helpers, browser/mobile detection, form/post-data helpers. | +| `src/EscManager.js`, `src/UiLayerManager.js` | Singleton-ish managers attached to the core (`Garnish.escManager`, `Garnish.uiLayerManager`). They are `Base` subclasses and exercise the event + listener API. | + +Two distinct event systems coexist in the legacy code and **must both be reproduced**: + +1. **Pub/sub object events** — `Garnish.Base.prototype.on/off/once/trigger`, plus a **class-level** variant + `Garnish.on/off/once`. These are *not* DOM events; they are an in-memory observer registry. `trigger` dispatches to + instance handlers **and** to class-level handlers whose `target` the instance `instanceof`-matches. +2. **DOM listeners** — `Garnish.Base.prototype.addListener/removeListener/removeAllListeners`, which today are thin + wrappers over `jQuery.on/off` with a per-instance namespace and a `_disabled` gate. + +The modern core keeps both, expressed as one coherent emitter design (§2). + +--- + +## 1. Class system + +### 1.1 What legacy does + +`lib/Base.js` implements Dean Edwards inheritance: + +- `Klass = Base.extend(instanceMembers, staticMembers)` returns a constructor. +- Inside any method, `this.base(...)` calls the overridden ancestor method (re-bound per call via the `extend` + wrapper that detects `/\bbase\b/` in the function source). +- `Garnish.Base` adds a `constructor` that initializes `_eventHandlers`, `_namespace`, `_listeners`, then calls + `this.init.apply(this, arguments)`. Subclasses override `init`, **not** `constructor`. + +### 1.2 Modern replacement: native `class extends` + +The modern core uses native ES classes. The `init()` indirection is **dropped**; subclasses use a real +`constructor` and call `super(...)`. `this.base()` is replaced by `super.method()`. + +```ts +// base.ts +export interface GarnishBaseSettings { + [key: string]: unknown; +} + +export abstract class Base { + /** Resolved settings (defaults <- passed settings). Null until setSettings runs. */ + settings: S | null = null; + + protected _disabled = false; + + /** Pub/sub registry (see §2). */ + protected readonly _emitter = new EventEmitter(this); + + /** Tracked DOM listener bindings for bulk teardown (see §2.4). */ + protected readonly _domListeners = new DomListenerRegistry(); + + constructor() { + // No init() trampoline. Subclasses do their own setup after super(). + } + + // ... on/off/once/trigger/addListener/... delegate to _emitter / _domListeners (§2) + + disable(): void { this._disabled = true; } + enable(): void { this._disabled = false; } + get disabled(): boolean { return this._disabled; } + + destroy(): void { + this.trigger('destroy'); + this._domListeners.removeAll(); + this._emitter.clear(); + } +} +``` + +**Subclass shape (modern):** + +```ts +export class EscManager extends Base { + private handlers: Array<{ obj: unknown; func: EscHandler }> = []; + + constructor() { + super(); + this.addListener(document.body, 'keyup', (ev) => { + if ((ev as KeyboardEvent).key === 'Escape') this.escapeLatest(ev as KeyboardEvent); + }); + } +} +``` + +### 1.3 Boundary with the compat layer + +The compat layer is responsible — and the modern core is explicitly **not** — for: + +- A `Base.extend(instance, static)` shim that produces a class wrapping the modern `Base`. +- The `init()` trampoline (compat `extend` maps a passed `init` member to post-`super()` invocation). +- `this.base(...)` super dispatch (compat synthesizes it; modern code uses `super.`). +- Re-exposing every class on `window.Garnish`. + +The modern core MUST keep its constructors plain and its method names stable so the compat layer can wrap them +mechanically. No modern core method should depend on the `init()` indirection existing. + +### 1.4 Key decisions + +- `Base` is `abstract`. It is never instantiated directly. +- Settings are generic (`Base`) so subclasses get typed `this.settings`. +- Constants that were prototype members in legacy (none of significance) become real fields. +- Getters (e.g. `UiLayerManager.layer`, `currentLayer`) become native `get` accessors — already legal in legacy via + Dean Edwards descriptor copying, so this is a 1:1 move. + +--- + +## 2. Event system + +This is the load-bearing part. The modern emitter must reproduce three legacy behaviors: + +- **A. Object pub/sub** (`on('foo.ns', data, handler)`, `trigger('foo', data)`) — in-memory, not DOM. +- **B. Class-level pub/sub** (`Garnish.on(TargetClass, 'foo', handler)`) — dispatched on `trigger` to any instance + that is `instanceof TargetClass`. +- **C. Namespaced DOM delegation** (`addListener(el, 'click.ns', fn)`) — currently `jQuery.on`, including delegated + selectors and a per-instance namespace for bulk `.off`. + +### 2.1 Event-string grammar (preserved exactly) + +Legacy `_normalizeEvents`: + +- `on`/`off`/`trigger` strings split on **spaces** → multiple events. +- Each event splits on the **first** `.` → `[type, namespace]`. (`'click.foo'` → `type='click'`, `ns='foo'`.) +- `addListener` additionally splits the events arg on **commas** (`_splitEvents`) and appends the instance + `_namespace` to each before handing to jQuery (`_formatEvents`). + +Modern core keeps the same grammar so call sites are unchanged. Canonical parser: + +```ts +// events.ts +export interface ParsedEvent { + type: string; // '' is invalid for on/trigger; '' with a namespace is the "remove by namespace" form for off + namespace: string | null; +} + +/** 'click.ns mousedown' -> [{type:'click',namespace:'ns'},{type:'mousedown',namespace:null}] */ +export function parseEvents(events: string | string[], splitOn: ' ' | ',' = ' '): ParsedEvent[]; + +/** Format for DOM binding: appends instance namespace. 'click,drag' + '.Garnish123' -> 'click.Garnish123 drag.Garnish123' */ +export function formatDomEvents(events: string | string[], namespace: string): string; +``` + +Note the legacy split-character inconsistency (`on` uses space, `addListener` uses comma). The modern API +**preserves both**: `parseEvents(..., ' ')` for pub/sub, `parseEvents(..., ',')` for `addListener`. This is a +deliberate compatibility wart, documented so the compat layer needn't special-case it. + +### 2.2 Public types + +```ts +// events.ts + +/** Payload an emitted event carries to handlers. */ +export interface GarnishEvent { + type: string; + target: Target; // the emitting object (legacy: the Base instance) + data: Record; // per-registration data, merged with trigger-time data + originalEvent?: Event; // present when re-emitting a DOM event (legacy 'activate' etc.) + [extra: string]: unknown; // trigger-time payload is spread on (legacy $.extend behavior) +} + +export type GarnishEventHandler = (event: E) => void; + +interface Registration { + type: string; + namespace: string | null; + data: Record; + handler: GarnishEventHandler; + once: boolean; +} +``` + +### 2.3 Object pub/sub — `EventEmitter` + +Backs `Base.on/off/once/trigger`. One instance per `Base` object, owns `this` as the event target. + +```ts +// events.ts +export class EventEmitter { + constructor(private readonly target: Target) {} + + on(events: string, handler: GarnishEventHandler): void; + on(events: string, data: Record, handler: GarnishEventHandler): void; + + once(events: string, handler: GarnishEventHandler): void; + once(events: string, data: Record, handler: GarnishEventHandler): void; + + /** Remove by (type [+ namespace] [+ handler]). Omitting handler removes all of that type/namespace. */ + off(events: string, handler?: GarnishEventHandler): void; + + /** Dispatch. `data` is merged onto the event object after the per-registration `data`. */ + trigger(type: string, data?: Record): void; + + /** Remove every registration (used by destroy). */ + clear(): void; +} +``` + +**Semantics that must match legacy:** + +- `on`/`once` argument overload: if arg 2 is a function, it's the handler and `data = {}`. +- `trigger` builds `{ type, target: this }`, and for each matching handler constructs the event as + `{ data: registration.data, ...triggerData, type, target }`. In legacy this is + `$.extend({data: handler.data}, data, ev)` — i.e. trigger-time `data` keys win over registration `data`, and + `type`/`target` always win. **Preserve this precedence exactly** (it is observable; some call sites pass `target` + overrides in `data`, which legacy `$.extend` lets the `ev` object override last). +- `off` matching: a registration matches when `type` matches **and** (the parsed namespace is empty **or** namespaces + match) **and** (handler is omitted **or** handler is `===`). Iterate **backwards** and splice, like legacy. +- `once` is implemented as a self-removing wrapper registered via `on`; `off(events, wrapper)` removes it. Modern impl + uses the `once: boolean` flag on `Registration` instead of a wrapper closure, but `off` by original handler must + still work — so store the original handler reference for `once` matching too. (Simplest faithful approach: keep the + wrapper-closure approach exactly as legacy does, with `off(events, onceler)`.) + +> **Decision:** keep the legacy wrapper-closure `once` to guarantee identical `off` behavior, rather than the flag. +> The flag is a footgun for `off`-by-handler. + +### 2.4 Class-level pub/sub — `ClassEventBus` + +Backs `Garnish.on/off/once`. Legacy stores these in `Garnish._eventHandlers` (a flat array) and `Base.trigger` +walks it filtering on `this instanceof handler.target`. + +```ts +// events.ts +type Constructor = abstract new (...args: any[]) => T; + +export class ClassEventBus { + private readonly registrations: Array = []; + + on(target: Constructor, events: string, handler: GarnishEventHandler): void; + on(target: Constructor, events: string, data: Record, handler: GarnishEventHandler): void; + + once(target: Constructor, events: string, handler: GarnishEventHandler): void; + once(target: Constructor, events: string, data: Record, handler: GarnishEventHandler): void; + + off(target: Constructor, events: string, handler?: GarnishEventHandler): void; + + /** Called by Base.trigger: dispatch class-level handlers whose target the instance is an instanceof. */ + dispatch(instance: object, type: string, data: Record, baseEvent: GarnishEvent): void; +} +``` + +**Wiring:** `Base.trigger(type, data)` does two passes (exactly like legacy): + +1. Its own `_emitter.trigger(type, data)`. +2. `garnishClassBus.dispatch(this, type, data, baseEvent)`. + +The single shared `ClassEventBus` instance lives in the core module (`garnishClassBus`) and is exposed as +`Garnish.on/off/once` on the namespace object (§5.3). Legacy guards `on/once` against `undefined` target with a +`console.warn`; **preserve** the warning. + +### 2.5 DOM listeners — `DomListenerRegistry` + +Replaces `addListener`/`removeListener`/`removeAllListeners`. Legacy leans on jQuery for: element coercion +(`$(elem)`), namespaced binding, delegated binding (the `data` arg can carry a selector via jQuery's +`(events, selector, data, handler)` overloads — though Garnish's own `addListener` only uses `(events, data, handler)` +with a plain-object `data`), and the `_disabled` gate. + +Modern, jQuery-free contract: + +```ts +// dom-listeners.ts +export type ElementInput = EventTarget | EventTarget[] | NodeListOf | string | null | undefined; + +export interface DomListenerOptions { + /** Delegated target selector; handler fires only when event.target matches/closest this selector. */ + delegate?: string; + /** Debounce/throttle hooks used by textchange etc. (see §3 custom events). */ + data?: Record; + capture?: boolean; + passive?: boolean; +} + +export class DomListenerRegistry { + constructor(private readonly host: Base) {} + + /** Bind. `events` uses the comma/space grammar (§2.1). Handler is invoked with the host's `this` + * and short-circuited while host.disabled is true (legacy _disabled gate). */ + add( + elements: ElementInput, + events: string, + handler: GarnishEventHandler, + options?: DomListenerOptions, + ): void; + + /** Remove specific event(s) previously bound by this host on the element. */ + remove(elements: ElementInput, events: string): void; + + /** Remove all listeners this host bound on the element (legacy removeAllListeners). */ + removeAllOn(elements: ElementInput): void; + + /** Remove everything this host bound anywhere (used by destroy). */ + removeAll(): void; +} +``` + +**`Base` thin wrappers (signature-compatible with legacy):** + +```ts +addListener(elem: ElementInput, events: string, dataOrHandler: object | GarnishEventHandler | string, handler?: GarnishEventHandler | string): void; +removeListener(elem: ElementInput, events: string): void; +removeAllListeners(elem: ElementInput): void; +``` + +Legacy `addListener` quirks to reproduce: + +- Bails silently if no elements resolve. +- Param mapping: `(elem, events, func)` vs `(elem, events, data, func)`; if `func` is undefined and `data` is not a + plain object, treat `data` as `func`. +- `func` may be a **method name string** (`this[func].bind(this)`) — preserve, since `UiLayerManager` uses + `addListener(..., 'triggerShortcut')`. +- Handler is invoked only when `!this._disabled`. +- Tracks bound elements so `destroy` can clean them all up. + +**Implementation note (no jQuery):** Native `addEventListener` has no namespace concept. The registry must store +`{element, type, namespace, wrappedHandler, capture}` tuples so it can replicate jQuery's namespaced `.off`. Delegation +is implemented with `event.target.closest(selector)` inside the wrapped handler. The per-instance `_namespace` is kept +as a bookkeeping string but is **internal only** in the modern path (it exists in legacy purely to drive jQuery +namespaced removal; here the registry array does that job). The namespace string is still parsed from the event string +for API compatibility and for `removeAllListeners` semantics. + +### 2.6 `_namespace` + +Legacy: `'.Garnish' + Math.floor(Math.random()*1e9)`, used only to scope jQuery DOM listeners. Modern core keeps a +`protected readonly _namespace: string` for compat-layer parity and debugging, but the registry's tuple list is the +real removal mechanism. Generation should use `crypto.randomUUID?.()` with a `Garnish` prefix, falling back to the +legacy random scheme. + +--- + +## 3. Custom DOM events (`activate`, `textchange`, `resize`) + +Legacy registers these through `$.event.special` and adds `$.fn.activate/textchange/resize` chaining sugar. These are +**core-owned custom events** and must move into `garnish-core` (other modules and the legacy CP depend on them +heavily, especially `activate`). + +The modern core cannot use `$.event.special`. Design: a small **custom-event installer** that, given a real +`EventTarget`, wires the underlying native listeners and re-dispatches a synthetic `CustomEvent`. + +```ts +// custom-events/index.ts +export interface ActivateOptions { /* none today */ } +export interface TextchangeOptions { delay?: number | null; } + +/** Attach Garnish 'activate' semantics to an element (mousedown/click/keydown -> 'activate' CustomEvent). + * Returns a disposer. */ +export function installActivate(el: HTMLElement): () => void; + +/** Attach 'textchange': fires a 'textchange' CustomEvent when the input's value changes + * (keypress/keyup/change/blur), with optional debounce via options.delay. */ +export function installTextchange(el: HTMLElement & { value: string }, options?: TextchangeOptions): () => void; + +/** Attach 'resize' via a shared ResizeObserver; window uses native 'resize'. Returns a disposer. */ +export function installResize(el: HTMLElement): () => void; +``` + +Behaviors to preserve from legacy: + +- **activate:** `mousedown` on `
`; + + const sample = main.querySelector('[data-focus-sample]')!; + + const clear = (): void => { + sample + .querySelectorAll('.pg-highlight-focusable, .pg-highlight-keyboard') + .forEach((el) => + el.classList.remove('pg-highlight-focusable', 'pg-highlight-keyboard') + ); + }; + + const actions: Record void> = { + highlight: () => { + clear(); + let focusableCount = 0; + let keyboardCount = 0; + getFocusableElements(sample).forEach((el) => { + el.classList.add('pg-highlight-focusable'); + focusableCount++; + if (isKeyboardFocusable(el)) { + el.classList.add('pg-highlight-keyboard'); + keyboardCount++; + } + }); + log.log( + 'focus', + `getFocusableElements matched ${focusableCount}; ${keyboardCount} keyboard-focusable (blue ring)` + ); + }, + clear, + 'trap-modal': () => { + const container = buildModalContainer( + 'Focus trap', + `

Tab / Shift+Tab cycles only within these three controls:

+

+ + + Third +

` + ); + const modal = new Modal(container); + for (const evt of ['show', 'hide', 'escape'] as const) { + modal.on(evt, () => log.log('focus', `focusTrap: ${evt}`)); + } + log.log( + 'focus', + 'Opened focus-trap modal — Tab cycling is trapped inside' + ); + }, + }; + + main.querySelectorAll('[data-act]').forEach((btn) => { + btn.addEventListener('click', () => actions[btn.dataset.act!]?.()); + }); + + return storyLayout(main); + }, +}; diff --git a/packages/craftcms-garnish/stories/hud.stories.ts b/packages/craftcms-garnish/stories/hud.stories.ts new file mode 100644 index 00000000000..a56977420c2 --- /dev/null +++ b/packages/craftcms-garnish/stories/hud.stories.ts @@ -0,0 +1,117 @@ +import type {Meta, StoryObj} from '@storybook/html-vite'; +import {HUD, type HUDOrientation} from '../src/index'; +import {createEventLog, storyLayout} from './_log'; + +/** + * `HUD` — anchored popover — playground section 11. + * + * Each trigger in the arena anchors a `new HUD(trigger, …)`. Clicking a trigger + * toggles its HUD, which picks one of four orientations from the clearance around + * the trigger and draws a tip pointing back at it. Use the `preferredOrientation` + * control to bias which side it tries first. + */ +const meta: Meta = { + title: 'HUD', +}; +export default meta; + +interface HUDArgs { + preferredOrientation: 'auto' | HUDOrientation; +} + +type Story = StoryObj; + +const ALL_ORIENTATIONS: HUDOrientation[] = ['bottom', 'top', 'right', 'left']; + +export const AnchoredPopover: Story = { + argTypes: { + preferredOrientation: { + control: 'select', + options: ['auto', ...ALL_ORIENTATIONS], + description: + 'Bias which orientation the HUD tries first (auto = the default bottom/top/right/left order)', + }, + }, + args: { + preferredOrientation: 'auto', + }, + render: (args) => { + const log = createEventLog(); + const main = document.createElement('div'); + main.innerHTML = ` +

+ Click a trigger to toggle its HUD: it picks an orientation from the + clearance around the trigger and draws a tip pointing back + at it. Triggers sit near different edges so you can see the side flip; + scroll or resize to watch the HUD follow. +

+
+ +
+
+ + + + + +
`; + + const arena = main.querySelector('[data-arena]')!; + const huds = new Map(); + + const orientations = + args.preferredOrientation === 'auto' + ? ALL_ORIENTATIONS + : [ + args.preferredOrientation, + ...ALL_ORIENTATIONS.filter((o) => o !== args.preferredOrientation), + ]; + + const hudBody = (label: string): string => ` +
+

HUD: ${label}

+

Orientation is chosen from the clearance around this trigger. Scroll or resize to watch the HUD follow.

+ +
`; + + const getHud = (trigger: HTMLElement): HUD => { + const existing = huds.get(trigger); + if (existing && HUD.instances.includes(existing)) { + return existing; + } + const id = trigger.dataset.hudTrigger!; + const hud = new HUD(trigger, hudBody(id), { + showOnInit: false, + hudClass: 'hud pg-hud', + orientations, + }); + hud.on('show', () => log.log('hud', `${id}: show`)); + hud.on('hide', () => log.log('hud', `${id}: hide`)); + hud.on('updateSizeAndPosition', () => + log.log('hud', `${id}: positioned → orientation "${hud.orientation}"`) + ); + hud.$main + ?.querySelector('[data-hud-close]') + ?.addEventListener('click', () => hud.hide()); + huds.set(trigger, hud); + return hud; + }; + + arena + .querySelectorAll('[data-hud-trigger]') + .forEach((trigger) => { + trigger.addEventListener('click', () => getHud(trigger).toggle()); + }); + + main + .querySelector('[data-act="destroy"]')! + .addEventListener('click', () => { + const count = HUD.instances.length; + [...HUD.instances].forEach((h) => h.destroy()); + huds.clear(); + log.log('hud', `Destroyed ${count} HUD instance(s)`); + }); + + return storyLayout(main); + }, +}; diff --git a/packages/craftcms-garnish/stories/modal.stories.ts b/packages/craftcms-garnish/stories/modal.stories.ts new file mode 100644 index 00000000000..a5878d82849 --- /dev/null +++ b/packages/craftcms-garnish/stories/modal.stories.ts @@ -0,0 +1,211 @@ +import type {Meta, StoryObj} from '@storybook/html-vite'; +import {Modal, type ModalSettings} from '../src/index'; +import {createEventLog, wireDragEvents, storyLayout} from './_log'; +import {buildModalContainer, buildDragModalContainer} from './_helpers'; + +/** + * `Modal` — construct `new Modal(container)` instances and drive their lifecycle. + * + * Migrated from playground sections 1 (Modal) and 4 (Draggable & resizable Modal). + * Every `show / hide / fadeIn / fadeOut / escape` (and, for draggable/resizable + * modals, `dragStart / drag / dragStop`) event is piped into the event log. + */ + +interface ModalArgs { + autoShow: boolean; + hideOnEsc: boolean; + hideOnShadeClick: boolean; + closeOtherModals: boolean; +} + +const meta: Meta = { + title: 'Modal', +}; +export default meta; + +type Story = StoryObj; + +/** Wire every demo event of a modal into the log panel. */ +function wireModalEvents( + log: ReturnType, + modal: Modal, + label: string +): void { + for (const evt of ['show', 'hide', 'fadeIn', 'fadeOut', 'escape'] as const) { + modal.on(evt, () => log.log('modal', `${label}: ${evt}`)); + } +} + +export const Basic: Story = { + argTypes: { + autoShow: { + control: 'boolean', + description: 'Show the modal on construction', + }, + hideOnEsc: {control: 'boolean', description: 'Close when Esc is pressed'}, + hideOnShadeClick: { + control: 'boolean', + description: 'Close when the dimmed shade is clicked', + }, + closeOtherModals: { + control: 'boolean', + description: 'Opening this modal hides any other visible modal', + }, + }, + args: { + autoShow: false, + hideOnEsc: true, + hideOnShadeClick: true, + closeOtherModals: false, + }, + render: (args) => { + const log = createEventLog(); + const main = document.createElement('div'); + main.innerHTML = ` +

+ Construct new Modal() instances and drive their lifecycle. + The settings controls (Esc / shade / closeOtherModals) feed straight into + ModalSettings. Open the event log to watch events fire. +

+
+ + + + + +
`; + + let modal: Modal | null = null; + const getModal = (): Modal => { + if (modal && Modal.instances.includes(modal)) { + return modal; + } + const settings: Partial = { + autoShow: args.autoShow, + hideOnEsc: args.hideOnEsc, + hideOnShadeClick: args.hideOnShadeClick, + closeOtherModals: args.closeOtherModals, + }; + const container = buildModalContainer( + 'Basic modal', + '

A plain new Modal(container, settings). Try quickShow / quickHide, or toggle the settings controls and reopen.

' + ); + modal = new Modal(container, settings); + wireModalEvents(log, modal, 'basic'); + return modal; + }; + + const actions: Record void> = { + show: () => getModal().show(), + hide: () => getModal().hide(), + 'quick-show': () => getModal().quickShow(), + 'quick-hide': () => getModal().quickHide(), + destroy: () => { + const count = Modal.instances.length; + [...Modal.instances].forEach((m) => m.destroy()); + modal = null; + log.log('modal', `Destroyed ${count} modal instance(s)`); + }, + }; + + main.querySelectorAll('[data-act]').forEach((btn) => { + btn.addEventListener('click', () => { + try { + actions[btn.dataset.act!]?.(); + } catch (err) { + log.log('modal', `Error: ${(err as Error).message}`); + } + }); + }); + + return storyLayout(main); + }, +}; + +interface DragModalArgs { + draggable: boolean; + headerHandleOnly: boolean; + resizable: boolean; +} + +export const DraggableAndResizable: StoryObj = { + argTypes: { + draggable: {control: 'boolean', description: 'Modal can be dragged'}, + headerHandleOnly: { + control: 'boolean', + description: 'Restrict dragging to the header bar (dragHandleSelector)', + }, + resizable: {control: 'boolean', description: 'Modal can be resized'}, + }, + args: { + draggable: true, + headerHandleOnly: true, + resizable: true, + }, + render: (args) => { + const log = createEventLog(); + const main = document.createElement('div'); + main.innerHTML = ` +

+ Modal's draggable / resizable options (they used + to throw). Open the modal and drag/resize it live — dragStart / drag / + dragStop events pipe into the log. +

+
+ + +
`; + + const dragModals: Modal[] = []; + + const actions: Record void> = { + open: () => { + const withHandle = args.draggable && args.headerHandleOnly; + const container = buildDragModalContainer( + 'Drag / resize me', + '

Drag the modal (the header bar if dragHandleSelector is set) and/or grab the corner handle to resize.

', + withHandle + ); + const settings: Partial = { + draggable: args.draggable, + resizable: args.resizable, + }; + if (withHandle) { + settings.dragHandleSelector = '[data-drag-handle]'; + } + const modal = new Modal(container, settings); + wireModalEvents(log, modal, 'drag-modal'); + if (modal.dragger) { + wireDragEvents(log, modal.dragger, 'modal-drag', 'modal (move)'); + } + if (modal.resizeDragger) { + wireDragEvents( + log, + modal.resizeDragger, + 'modal-drag', + 'modal (resize)' + ); + } + dragModals.push(modal); + log.log('modal', 'Opened modal — drag/resize it; watch the log.'); + }, + destroy: () => { + const count = dragModals.length; + dragModals.splice(0).forEach((m) => m.destroy()); + log.log('modal', `Destroyed ${count} draggable/resizable modal(s)`); + }, + }; + + main.querySelectorAll('[data-act]').forEach((btn) => { + btn.addEventListener('click', () => { + try { + actions[btn.dataset.act!]?.(); + } catch (err) { + log.log('modal', `Error: ${(err as Error).message}`); + } + }); + }); + + return storyLayout(main); + }, +}; diff --git a/packages/craftcms-garnish/stories/utilities.stories.ts b/packages/craftcms-garnish/stories/utilities.stories.ts new file mode 100644 index 00000000000..cae29064e39 --- /dev/null +++ b/packages/craftcms-garnish/stories/utilities.stories.ts @@ -0,0 +1,62 @@ +import type {Meta, StoryObj} from '@storybook/html-vite'; +import {hasAttr, getDist, installActivate} from '../src/index'; +import {createEventLog, storyLayout} from './_log'; + +/** + * Events & utilities — playground section 10. + * + * Small demos of core utilities (`hasAttr`, `getDist`) and the synthetic + * `activate` custom event (click / Space / Enter all dispatch it). + */ +const meta: Meta = { + title: 'Utilities', +}; +export default meta; + +type Story = StoryObj; + +export const EventsAndUtilities: Story = { + render: () => { + const log = createEventLog(); + const main = document.createElement('div'); + main.innerHTML = ` +

Small demos of core utilities and the synthetic activate custom event.

+
+ + + +
`; + + const actions: Record void> = { + hasattr: (btn) => { + log.log( + 'util', + `hasAttr(button, "data-demo") → ${hasAttr(btn, 'data-demo')}` + ); + }, + getdist: () => { + log.log('util', `getDist(0, 0, 3, 4) → ${getDist(0, 0, 3, 4)}`); + }, + activate: (btn) => { + // Install the synthetic `activate` event (idempotent), then listen. + installActivate(btn); + if (!btn.dataset.activateWired) { + btn.addEventListener('activate', () => { + log.log('util', 'activate custom event fired on the button'); + }); + btn.dataset.activateWired = 'yes'; + log.log( + 'util', + 'installActivate(button) — now click it or press Space/Enter' + ); + } + }, + }; + + main.querySelectorAll('[data-act]').forEach((btn) => { + btn.addEventListener('click', () => actions[btn.dataset.act!]?.(btn)); + }); + + return storyLayout(main); + }, +}; diff --git a/packages/craftcms-garnish/tests/base.test.ts b/packages/craftcms-garnish/tests/base.test.ts new file mode 100644 index 00000000000..f140b8eb9a7 --- /dev/null +++ b/packages/craftcms-garnish/tests/base.test.ts @@ -0,0 +1,124 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest'; + +import {Base} from '../src/base'; +import {garnishClassBus} from '../src/globals'; + +class TestBase extends Base {} + +describe('Base settings', () => { + it('shallow-merges with precedence base < defaults < settings', () => { + const obj = new TestBase(); + obj.setSettings({a: 1, b: 2}, {a: 0, c: 3}); + expect(obj.settings).toEqual({a: 1, b: 2, c: 3}); + }); + + it('subsequent setSettings layers over existing (existing is lowest)', () => { + const obj = new TestBase(); + obj.setSettings({a: 1}); + obj.setSettings({b: 2}); + expect(obj.settings).toEqual({a: 1, b: 2}); + }); + + it('does NOT deep-merge nested objects (replaces wholesale)', () => { + const obj = new TestBase(); + obj.setSettings({nested: {x: 1, y: 2}}); + obj.setSettings({nested: {z: 3}}); + expect(obj.settings!.nested).toEqual({z: 3}); + }); + + it('skips null/undefined args (Object.assign semantics)', () => { + const obj = new TestBase(); + obj.setSettings(undefined, {a: 1}); + expect(obj.settings).toEqual({a: 1}); + }); +}); + +describe('Base pub/sub', () => { + it('on/trigger works at the instance level', () => { + const obj = new TestBase(); + const fn = vi.fn(); + obj.on('foo', fn); + obj.trigger('foo'); + expect(fn).toHaveBeenCalledOnce(); + }); + + it('trigger also dispatches class-level handlers (instanceof)', () => { + garnishClassBus.clear(); + const fn = vi.fn(); + garnishClassBus.on(Base, 'foo', fn); + const obj = new TestBase(); + obj.trigger('foo'); + expect(fn).toHaveBeenCalledOnce(); + expect((fn.mock.calls[0]![0] as {target: unknown}).target).toBe(obj); + garnishClassBus.clear(); + }); +}); + +describe('Base DOM listeners + disabled gate', () => { + let el: HTMLButtonElement; + beforeEach(() => { + el = document.createElement('button'); + document.body.appendChild(el); + }); + + it('addListener binds and invokes with the host as `this`', () => { + const obj = new TestBase(); + let receivedThis: unknown; + obj.addListener(el, 'click', function (this: unknown) { + receivedThis = this; + }); + el.dispatchEvent(new MouseEvent('click')); + expect(receivedThis).toBe(obj); + }); + + it('does not invoke handlers while disabled', () => { + const obj = new TestBase(); + const fn = vi.fn(); + obj.addListener(el, 'click', fn); + obj.disable(); + el.dispatchEvent(new MouseEvent('click')); + expect(fn).not.toHaveBeenCalled(); + obj.enable(); + el.dispatchEvent(new MouseEvent('click')); + expect(fn).toHaveBeenCalledOnce(); + }); + + it('supports a method-name string handler', () => { + class WithMethod extends Base { + called = false; + handleClick(): void { + this.called = true; + } + } + const obj = new WithMethod(); + obj.addListener(el, 'click', 'handleClick'); + el.dispatchEvent(new MouseEvent('click')); + expect(obj.called).toBe(true); + }); + + it('removeListener removes a specific event', () => { + const obj = new TestBase(); + const fn = vi.fn(); + obj.addListener(el, 'click', fn); + obj.removeListener(el, 'click'); + el.dispatchEvent(new MouseEvent('click')); + expect(fn).not.toHaveBeenCalled(); + }); + + it('destroy triggers destroy + removes all listeners', () => { + const obj = new TestBase(); + const clickFn = vi.fn(); + const destroyFn = vi.fn(); + obj.addListener(el, 'click', clickFn); + obj.on('destroy', destroyFn); + obj.destroy(); + expect(destroyFn).toHaveBeenCalledOnce(); + el.dispatchEvent(new MouseEvent('click')); + expect(clickFn).not.toHaveBeenCalled(); + }); + + it('bails silently when no elements resolve', () => { + const obj = new TestBase(); + expect(() => obj.addListener(null, 'click', () => {})).not.toThrow(); + }); +}); diff --git a/packages/craftcms-garnish/tests/compat.test.ts b/packages/craftcms-garnish/tests/compat.test.ts new file mode 100644 index 00000000000..cb34f284932 --- /dev/null +++ b/packages/craftcms-garnish/tests/compat.test.ts @@ -0,0 +1,394 @@ +/** + * Compat-layer tests — proving the legacy upgrade path works end-to-end: + * - `Garnish.Base.extend({init, foo})` produces a working instance + * - `this.base()` dispatches to the ancestor (incl. 2-level chains + override) + * - `init` runs with the constructor args + * - static members merge + * - `window.Garnish` is installed under the legacy guard + * - jQuery surface is feature-detected (degrades when jQuery is absent) + * - (conditionally) `Garnish.Modal.extend({...})` works if Modal is exported + */ + +import {describe, it, expect, beforeEach, vi} from 'vitest'; +import GarnishCompat, { + compatify, + installGarnishCompat, + isJquery, + resolveJQuery, + unwrapJq, +} from '../src/compat'; +import {Base} from '../src/index'; +import * as Core from '../src/index'; + +type AnyObj = Record; + +describe('compatify / Garnish.Base.extend', () => { + it('produces a working instance from extend({init, foo})', () => { + const LegacyBase = compatify(Base); + const calls: string[] = []; + + const Widget = LegacyBase.extend({ + init() { + calls.push('init'); + }, + foo() { + return 'foo-result'; + }, + }); + + const w = new Widget() as AnyObj; + expect(calls).toEqual(['init']); + expect(w.foo()).toBe('foo-result'); + // Real prototype chain → instanceof the modern Base. + expect(w).toBeInstanceOf(Base); + }); + + it('runs init with the constructor arguments', () => { + const LegacyBase = compatify(Base); + let received: unknown[] = []; + + const Widget = LegacyBase.extend({ + init(...args: unknown[]) { + received = args; + }, + }); + + new Widget('a', 42, {k: 1}); + expect(received).toEqual(['a', 42, {k: 1}]); + }); + + it('merges static members onto the constructor', () => { + const LegacyBase = compatify(Base); + const Widget = LegacyBase.extend( + { + init() {}, + }, + { + defaults: {speed: 5}, + staticHelper() { + return 'helped'; + }, + } + ); + + expect((Widget as AnyObj).defaults).toEqual({speed: 5}); + expect((Widget as AnyObj).staticHelper()).toBe('helped'); + }); + + it('this.base() dispatches to the ancestor method', () => { + const LegacyBase = compatify(Base); + + const Parent = LegacyBase.extend({ + init() {}, + greet() { + return 'parent'; + }, + }); + + const Child = Parent.extend({ + init() {}, + greet() { + // @ts-expect-error legacy compat affordance + return 'child+' + this.base(); + }, + }); + + const c = new Child() as AnyObj; + expect(c.greet()).toBe('child+parent'); + }); + + it('this.base() works across a 2-level extend chain', () => { + const LegacyBase = compatify(Base); + + const A = LegacyBase.extend({ + init() {}, + val() { + return 1; + }, + }); + const B = A.extend({ + init() {}, + val() { + // @ts-expect-error legacy compat affordance + return 10 + this.base(); + }, + }); + const C = B.extend({ + init() {}, + val() { + // @ts-expect-error legacy compat affordance + return 100 + this.base(); + }, + }); + + const c = new C() as AnyObj; + // C.val → 100 + B.val (10 + A.val (1)) = 111 + expect(c.val()).toBe(111); + }); + + it('restores this.base after the call (re-entrancy)', () => { + const LegacyBase = compatify(Base); + + const A = LegacyBase.extend({ + init() {}, + step() { + return 'A'; + }, + }); + const B = A.extend({ + init() {}, + step() { + // @ts-expect-error compat affordance + const inner = this.base(); + // calling base again still resolves to the same ancestor within this frame + // @ts-expect-error compat affordance + const inner2 = this.base(); + return `B(${inner})(${inner2})`; + }, + }); + + const b = new B() as AnyObj; + expect(b.step()).toBe('B(A)(A)'); + // After the call, this.base must be restored to the no-op (not leak A). + expect(typeof (b as AnyObj).base).toBe('function'); + }); + + it('only the leaf init runs (init fires once)', () => { + const LegacyBase = compatify(Base); + const order: string[] = []; + + const A = LegacyBase.extend({ + init() { + order.push('A'); + }, + }); + const B = A.extend({ + init() { + order.push('B'); + }, + }); + + new B(); + expect(order).toEqual(['B']); + }); + + it('a leaf without init inherits an ancestor init', () => { + const LegacyBase = compatify(Base); + const order: string[] = []; + + const A = LegacyBase.extend({ + init() { + order.push('A'); + }, + }); + // B adds no init of its own. + const B = A.extend({ + other() { + return 1; + }, + }); + + new B(); + expect(order).toEqual(['A']); + }); + + it('leaf init can reach the ancestor init via this.base()', () => { + const LegacyBase = compatify(Base); + const order: string[] = []; + + const A = LegacyBase.extend({ + init() { + order.push('A'); + }, + }); + const B = A.extend({ + init() { + order.push('B-before'); + // @ts-expect-error compat affordance + this.base(); + order.push('B-after'); + }, + }); + + new B(); + expect(order).toEqual(['B-before', 'A', 'B-after']); + }); + + it('preserves getters/setters from instance members', () => { + const LegacyBase = compatify(Base); + const Widget = LegacyBase.extend({ + init() {}, + _n: 0, + get doubled() { + return (this as AnyObj)._n * 2; + }, + }); + const w = new Widget() as AnyObj; + w._n = 21; + expect(w.doubled).toBe(42); + }); +}); + +describe('jQuery feature-detection / coercion', () => { + it('isJquery returns false when jQuery is absent', () => { + // No jQuery is installed in this package's test env. + expect(resolveJQuery()).toBeNull(); + expect(isJquery({})).toBe(false); + expect(isJquery(null)).toBe(false); + }); + + it('unwrapJq passes through non-jQuery values unchanged', () => { + const el = document.createElement('div'); + expect(unwrapJq(el)).toBe(el); + expect(unwrapJq('string')).toBe('string'); + expect(unwrapJq(null)).toBe(null); + }); + + it('unwrapJq unwraps a fake jQuery collection to its first element', () => { + const el = document.createElement('div'); + // Install a fake jQuery so isJquery() recognizes the collection. + const fakeProto = {}; + function FakeJq(this: any) {} + FakeJq.prototype = fakeProto; + (FakeJq as any).fn = {jquery: '3.x'}; + const coll = Object.create(fakeProto); + coll[0] = el; + coll.length = 1; + + const g = globalThis as AnyObj; + const priorJq = g.jQuery; + const prior$ = g.$; + g.jQuery = FakeJq; + g.$ = FakeJq; + try { + expect(isJquery(coll)).toBe(true); + expect(unwrapJq(coll)).toBe(el); + } finally { + g.jQuery = priorJq; + g.$ = prior$; + } + }); + + it('constructor unwraps a jQuery-collection $container arg to a native element', () => { + const el = document.createElement('div'); + const fakeProto = {}; + function FakeJq(this: any) {} + FakeJq.prototype = fakeProto; + (FakeJq as any).fn = {jquery: '3.x'}; + const coll = Object.create(fakeProto); + coll[0] = el; + coll.length = 1; + + const g = globalThis as AnyObj; + const priorJq = g.jQuery; + g.jQuery = FakeJq; + try { + const LegacyBase = compatify(Base); + let receivedArg: unknown; + const Widget = LegacyBase.extend({ + init(arg: unknown) { + receivedArg = arg; + }, + }); + new Widget(coll); + // init sees the unwrapped native element, not the collection. + expect(receivedArg).toBe(el); + } finally { + g.jQuery = priorJq; + } + }); +}); + +describe('window.Garnish installation', () => { + it('installs window.Garnish under the legacy guard and is idempotent', () => { + const w = window as AnyObj; + // GarnishCompat default export already auto-installed on import. + expect(w.Garnish).toBeDefined(); + expect(w.Garnish).toBe(GarnishCompat); + + // Re-running install is idempotent and does not clobber the existing global. + const again = installGarnishCompat(); + expect(again).toBe(GarnishCompat); + expect(w.Garnish).toBe(GarnishCompat); + }); + + it('exposes compatified, extend-able classes', () => { + const G = GarnishCompat as AnyObj; + expect(typeof G.Base.extend).toBe('function'); + expect(typeof G.UiLayerManager.extend).toBe('function'); + expect(typeof G.EscManager.extend).toBe('function'); + + const Custom = G.UiLayerManager.extend({ + init() { + // call modern super behavior (this is `any` here via AnyObj) + this.base(); + }, + }); + const inst = new Custom(); + expect(inst).toBeInstanceOf(Core.UiLayerManager); + }); + + it('attaches the manager singletons', () => { + const G = GarnishCompat as AnyObj; + expect(G.escManager).toBeInstanceOf(Core.EscManager); + expect(G.uiLayerManager).toBeInstanceOf(Core.UiLayerManager); + // legacy lowercase alias + expect(G.shortcutManager).toBe(G.uiLayerManager); + }); + + it('restores the ShortcutManager deprecated alias', () => { + const G = GarnishCompat as AnyObj; + expect(G.ShortcutManager).toBe(G.UiLayerManager); + }); + + it('carries over utilities and constants from core', () => { + const G = GarnishCompat as AnyObj; + expect(typeof G.getDist).toBe('function'); + expect(typeof G.ESC_KEY).toBe('number'); + expect(typeof G.on).toBe('function'); + expect(typeof G.off).toBe('function'); + expect(typeof G.once).toBe('function'); + }); + + it('throws a clear error when accessing $win without jQuery', () => { + expect(resolveJQuery()).toBeNull(); + const G = GarnishCompat as AnyObj; + expect(() => G.$win).toThrowError(/jQuery is required/); + }); + + it('getFocusedElement degrades to a native element when jQuery is absent', () => { + const G = GarnishCompat as AnyObj; + // No jQuery → returns the native activeElement (or null), not a collection. + const result = G.getFocusedElement(); + expect(result === null || result instanceof Element).toBe(true); + }); +}); + +describe('Modal compat (conditional)', () => { + const Modal = (Core as AnyObj).Modal; + const maybe = Modal ? it : it.skip; + + maybe('Garnish.Modal.extend({...}) works end-to-end', () => { + const G = GarnishCompat as AnyObj; + const ModalCompat = G.Modal; + expect(typeof ModalCompat.extend).toBe('function'); + + const calls: string[] = []; + const MyModal = ModalCompat.extend({ + init(container: unknown, settings: unknown) { + calls.push('init'); + // (this is `any` here via AnyObj) + this.base(container, settings); + }, + }); + + const container = document.createElement('div'); + const m = new MyModal(container, {}); + expect(calls).toContain('init'); + expect(m).toBeInstanceOf(Modal); + }); +}); + +// Keep vi referenced for environments that tree-shake unused imports in tests. +void vi; +beforeEach(() => {}); diff --git a/packages/craftcms-garnish/tests/custom-events.test.ts b/packages/craftcms-garnish/tests/custom-events.test.ts new file mode 100644 index 00000000000..855ad0dd802 --- /dev/null +++ b/packages/craftcms-garnish/tests/custom-events.test.ts @@ -0,0 +1,79 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +import {installActivate} from '../src/custom-events'; +import {globals} from '../src/globals'; + +describe('installActivate', () => { + let el: HTMLButtonElement; + + beforeEach(() => { + globals.activateEventsMuted = false; + el = document.createElement('button'); + document.body.appendChild(el); + }); + + afterEach(() => { + el.remove(); + }); + + it('dispatches an `activate` event on click', () => { + const dispose = installActivate(el); + const fn = vi.fn(); + el.addEventListener('activate', fn); + el.dispatchEvent(new MouseEvent('click', {bubbles: true})); + expect(fn).toHaveBeenCalledOnce(); + dispose(); + }); + + it('dispatches `activate` on Space/Enter keydown', () => { + const dispose = installActivate(el); + const fn = vi.fn(); + el.addEventListener('activate', fn); + el.dispatchEvent( + new KeyboardEvent('keydown', {keyCode: 32, bubbles: true} as never) + ); + expect(fn).toHaveBeenCalledOnce(); + dispose(); + }); + + it('does not dispatch `activate` when disabled (class)', () => { + el.classList.add('disabled'); + const dispose = installActivate(el); + const fn = vi.fn(); + el.addEventListener('activate', fn); + el.dispatchEvent(new MouseEvent('click', {bubbles: true})); + expect(fn).not.toHaveBeenCalled(); + dispose(); + }); + + it('does not dispatch `activate` when activate events are muted', () => { + const dispose = installActivate(el); + const fn = vi.fn(); + el.addEventListener('activate', fn); + globals.activateEventsMuted = true; + el.dispatchEvent(new MouseEvent('click', {bubbles: true})); + expect(fn).not.toHaveBeenCalled(); + dispose(); + }); + + it('sets tabindex="0" on a non-disabled element', () => { + const dispose = installActivate(el); + expect(el.getAttribute('tabindex')).toBe('0'); + dispose(); + }); + + it('ref-counts: a single dispose does not tear down a doubly-installed element', () => { + const dispose1 = installActivate(el); + const dispose2 = installActivate(el); + const fn = vi.fn(); + el.addEventListener('activate', fn); + + dispose1(); + el.dispatchEvent(new MouseEvent('click', {bubbles: true})); + expect(fn).toHaveBeenCalledOnce(); + + dispose2(); + el.dispatchEvent(new MouseEvent('click', {bubbles: true})); + expect(fn).toHaveBeenCalledOnce(); // no further activations + }); +}); diff --git a/packages/craftcms-garnish/tests/disclosure-menu.test.ts b/packages/craftcms-garnish/tests/disclosure-menu.test.ts new file mode 100644 index 00000000000..e3bdc00b25f --- /dev/null +++ b/packages/craftcms-garnish/tests/disclosure-menu.test.ts @@ -0,0 +1,667 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +import {DisclosureMenu} from '../src/disclosure-menu'; +import {UiLayerManager} from '../src/managers/ui-layer-manager'; +import {setUiLayerManager} from '../src/managers/registry'; +import {DOWN_KEY, ESC_KEY, RETURN_KEY, TAB_KEY, UP_KEY} from '../src/constants'; +import {globals} from '../src/globals'; + +// happy-dom notes: +// - No layout: `getBoundingClientRect()` / `offsetWidth` / `getClientRects()` +// are all 0/empty. Focusable elements get `getClientRects` stubbed to look +// visible (`makeVisible`), and the positioning test stubs `getBoundingClientRect` +// + `offsetWidth/Height` (`mockRect`). +// - No `element.animate`: the hide fade finalizes synchronously, so hide() is +// observable immediately (no RAF/animation mock needed — DisclosureMenu uses +// neither). + +let manager: UiLayerManager; +let idSeq = 0; + +/** Stub an element's layout boxes so the focusable matcher treats it as visible. */ +function makeVisible(el: Element): void { + vi.spyOn(el, 'getClientRects').mockReturnValue([ + {width: 10, height: 10}, + ] as unknown as DOMRectList); +} + +/** Stub an element's bounding rect + offset box (positioning math). */ +function mockRect( + el: Element, + top: number, + left: number, + width = 0, + height = 0 +): void { + vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({ + top, + left, + right: left + width, + bottom: top + height, + width, + height, + x: left, + y: top, + toJSON: () => ({}), + } as DOMRect); + Object.defineProperty(el, 'offsetWidth', {value: width, configurable: true}); + Object.defineProperty(el, 'offsetHeight', { + value: height, + configurable: true, + }); +} + +interface BuiltMenu { + trigger: HTMLButtonElement; + container: HTMLDivElement; +} + +/** + * Build a trigger + an `aria-controls`-linked container with `itemLabels` items. + * Both the trigger and the item buttons are stubbed visible. + */ +function buildMenu( + itemLabels: string[] = [], + opts: {nextSibling?: boolean} = {} +): BuiltMenu { + const id = `menu-${++idSeq}`; + + const trigger = document.createElement('button'); + trigger.type = 'button'; + trigger.textContent = 'Open'; + trigger.setAttribute('aria-controls', id); + makeVisible(trigger); + document.body.appendChild(trigger); + + const container = document.createElement('div'); + container.id = id; + container.className = 'menu menu--disclosure'; + const ul = document.createElement('ul'); + for (const labelText of itemLabels) { + const li = document.createElement('li'); + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'menu-item'; + const label = document.createElement('span'); + label.className = 'menu-item-label'; + label.textContent = labelText; + btn.appendChild(label); + li.appendChild(btn); + ul.appendChild(li); + makeVisible(btn); + } + container.appendChild(ul); + + if (opts.nextSibling) { + trigger.after(container); + } else { + document.body.appendChild(container); + } + + return {trigger, container}; +} + +beforeEach(() => { + manager = new UiLayerManager(); + setUiLayerManager(manager); + DisclosureMenu.instances = []; + idSeq = 0; + document.body.innerHTML = ''; + document.body.className = ''; + globals.scrollContainer = window; + globals.rtl = false; +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +describe('DisclosureMenu construction', () => { + it('applies defaults merged with passed settings', () => { + const {trigger} = buildMenu(); + const menu = new DisclosureMenu(trigger, {windowSpacing: 12}); + expect(menu.settings!.windowSpacing).toBe(12); + expect(menu.settings!.position).toBeNull(); + expect(menu.settings!.withSearchInput).toBe(false); + }); + + it('uses the defaults when no settings are passed', () => { + const {trigger} = buildMenu(); + const menu = new DisclosureMenu(trigger); + expect(menu.settings!.windowSpacing).toBe(5); + }); + + it('resolves the container via aria-controls and flags the trigger', () => { + const {trigger, container} = buildMenu(); + const menu = new DisclosureMenu(trigger); + expect(menu.$container).toBe(container); + expect(trigger.getAttribute('data-disclosure-trigger')).toBe('true'); + }); + + it('resolves a container that sits as the trigger’s next sibling', () => { + // The container immediately follows the trigger and carries the + // aria-controls id (the next-sibling resolution path). + const id = 'sibling-menu'; + const trigger = document.createElement('button'); + trigger.setAttribute('aria-controls', id); + document.body.appendChild(trigger); + const container = document.createElement('div'); + container.id = id; + trigger.after(container); + const menu = new DisclosureMenu(trigger); + expect(menu.$container).toBe(container); + }); + + it('throws when no disclosure container can be found', () => { + const trigger = document.createElement('button'); + trigger.setAttribute('aria-controls', 'does-not-exist'); + document.body.appendChild(trigger); + expect(() => new DisclosureMenu(trigger)).toThrow(); + }); + + it('defaults aria-expanded to false when absent', () => { + const {trigger} = buildMenu(); + new DisclosureMenu(trigger); + expect(trigger.getAttribute('aria-expanded')).toBe('false'); + }); + + it('preserves an existing aria-expanded value', () => { + const {trigger} = buildMenu(); + trigger.setAttribute('aria-expanded', 'true'); + new DisclosureMenu(trigger); + expect(trigger.getAttribute('aria-expanded')).toBe('true'); + }); + + it('registers itself in instances and via getInstance (trigger + container)', () => { + const {trigger, container} = buildMenu(); + const menu = new DisclosureMenu(trigger); + expect(DisclosureMenu.instances).toContain(menu); + expect(DisclosureMenu.getInstance(trigger)).toBe(menu); + expect(DisclosureMenu.getInstance(container)).toBe(menu); + }); + + it('moves the container to the end of ', () => { + const {container} = buildMenu(); + const trigger2 = buildMenu().trigger; // some trailing element + void trigger2; + new DisclosureMenu(document.querySelector('button')!); + expect(document.body.lastElementChild).toBe(container); + }); + + it('warns and bails on double-instantiation', () => { + const {trigger} = buildMenu(); + new DisclosureMenu(trigger); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const second = new DisclosureMenu(trigger); + expect(warn).toHaveBeenCalledOnce(); + // The second instance bailed before wiring its container. + expect(second.$container).toBeNull(); + }); + + it('coerces a selector-string trigger', () => { + const {trigger} = buildMenu(); + trigger.id = 'string-trigger'; + const menu = new DisclosureMenu('#string-trigger'); + expect(menu.$trigger).toBe(trigger); + }); +}); + +describe('DisclosureMenu show / hide', () => { + it('show() expands, fires beforeShow + show, sets aria-expanded', () => { + const {trigger} = buildMenu(['Alpha']); + const menu = new DisclosureMenu(trigger); + const beforeShow = vi.fn(); + const onShow = vi.fn(); + menu.on('beforeShow', beforeShow); + menu.on('show', onShow); + + menu.show(); + + expect(menu.isExpanded()).toBe(true); + expect(trigger.getAttribute('aria-expanded')).toBe('true'); + expect(menu.$container!.classList.contains('visible')).toBe(true); + expect(beforeShow).toHaveBeenCalledOnce(); + expect(onShow).toHaveBeenCalledOnce(); + }); + + it('hide() collapses, fires hide, clears the visible class', () => { + const {trigger} = buildMenu(['Alpha']); + const menu = new DisclosureMenu(trigger); + const onHide = vi.fn(); + menu.on('hide', onHide); + + menu.show(); + menu.hide(); + + expect(menu.isExpanded()).toBe(false); + expect(trigger.getAttribute('aria-expanded')).toBe('false'); + expect(menu.$container!.classList.contains('visible')).toBe(false); + expect(onHide).toHaveBeenCalledOnce(); + }); + + it('show() is a no-op when already expanded', () => { + const {trigger} = buildMenu(['Alpha']); + const menu = new DisclosureMenu(trigger); + menu.show(); + const onShow = vi.fn(); + menu.on('show', onShow); + menu.show(); + expect(onShow).not.toHaveBeenCalled(); + }); + + it('hide() is a no-op when not expanded', () => { + const {trigger} = buildMenu(); + const menu = new DisclosureMenu(trigger); + const onHide = vi.fn(); + menu.on('hide', onHide); + menu.hide(); + expect(onHide).not.toHaveBeenCalled(); + }); + + it('show() does nothing when the trigger is disabled', () => { + const {trigger} = buildMenu(['Alpha']); + trigger.classList.add('disabled'); + const menu = new DisclosureMenu(trigger); + menu.show(); + expect(menu.isExpanded()).toBe(false); + }); + + it('handleTriggerClick toggles open/closed', () => { + const {trigger} = buildMenu(['Alpha']); + const menu = new DisclosureMenu(trigger); + menu.handleTriggerClick(); + expect(menu.isExpanded()).toBe(true); + menu.handleTriggerClick(); + expect(menu.isExpanded()).toBe(false); + }); +}); + +describe('DisclosureMenu layer + Escape', () => { + it('adds a UI layer on show and removes it on hide', () => { + const {trigger} = buildMenu(['Alpha']); + const menu = new DisclosureMenu(trigger); + expect(manager.layer).toBe(0); + menu.show(); + expect(manager.layer).toBe(1); + menu.hide(); + expect(manager.layer).toBe(0); + }); + + it('closes on Escape via the registered shortcut', () => { + const {trigger} = buildMenu(['Alpha']); + const menu = new DisclosureMenu(trigger); + menu.show(); + const ev = new KeyboardEvent('keydown', {keyCode: ESC_KEY} as never); + manager.triggerShortcut(ev); + expect(menu.isExpanded()).toBe(false); + }); +}); + +describe('DisclosureMenu focus management', () => { + it('focuses the first item on show', () => { + const {trigger} = buildMenu(['Alpha', 'Beta']); + const menu = new DisclosureMenu(trigger); + menu.show(); + const first = menu.$container!.querySelector('button')!; + expect(document.activeElement).toBe(first); + }); + + it('falls back to focusing the container when there are no items', () => { + const {trigger} = buildMenu(); + const menu = new DisclosureMenu(trigger); + menu.show(); + expect(menu.$container!.getAttribute('tabindex')).toBe('-1'); + expect(document.activeElement).toBe(menu.$container); + }); + + it('restores focus to the trigger on hide when focus was inside', () => { + const {trigger} = buildMenu(['Alpha']); + const menu = new DisclosureMenu(trigger); + menu.show(); + const item = menu.$container!.querySelector('button')!; + item.focus(); + expect(document.activeElement).toBe(item); + menu.hide(); + expect(document.activeElement).toBe(trigger); + }); +}); + +describe('DisclosureMenu keyboard navigation', () => { + it('Arrow Down / Up move focus between items', () => { + const {trigger, container} = buildMenu(['Alpha', 'Beta', 'Gamma']); + const menu = new DisclosureMenu(trigger); + menu.show(); + const [a, b] = Array.from(container.querySelectorAll('button')); + a!.focus(); + + container.dispatchEvent( + new KeyboardEvent('keydown', {keyCode: DOWN_KEY, bubbles: true} as never) + ); + expect(document.activeElement).toBe(b); + + container.dispatchEvent( + new KeyboardEvent('keydown', {keyCode: UP_KEY, bubbles: true} as never) + ); + expect(document.activeElement).toBe(a); + }); + + it('Shift+Tab on the first item returns focus to the trigger', () => { + const {trigger, container} = buildMenu(['Alpha', 'Beta']); + const menu = new DisclosureMenu(trigger); + menu.show(); + const first = container.querySelector('button')!; + first.focus(); + const ev = new KeyboardEvent('keydown', { + keyCode: TAB_KEY, + shiftKey: true, + bubbles: true, + } as never); + Object.defineProperty(ev, 'target', {value: first}); + container.dispatchEvent(ev); + expect(document.activeElement).toBe(trigger); + }); + + it('Tab on the trigger while expanded moves focus into the menu', () => { + const {trigger} = buildMenu(['Alpha']); + const menu = new DisclosureMenu(trigger); + menu.show(); + trigger.focus(); + const ev = new KeyboardEvent('keydown', {keyCode: TAB_KEY} as never); + trigger.dispatchEvent(ev); + const first = menu.$container!.querySelector('button')!; + expect(document.activeElement).toBe(first); + }); +}); + +describe('DisclosureMenu type-ahead search', () => { + it('focuses the first item whose text starts with the typed string', () => { + const {trigger, container} = buildMenu(['Apple', 'Banana', 'Cherry']); + const menu = new DisclosureMenu(trigger); + menu.show(); + + container.dispatchEvent( + new KeyboardEvent('keydown', {key: 'b', bubbles: true} as never) + ); + + const banana = Array.from(container.querySelectorAll('button')).find((b) => + b.textContent!.includes('Banana') + ); + expect(document.activeElement).toBe(banana); + }); + + it('accumulates the search buffer across keystrokes and clears it', () => { + const {trigger, container} = buildMenu(['Car', 'Cat', 'Dog']); + const menu = new DisclosureMenu(trigger); + menu.show(); + + container.dispatchEvent( + new KeyboardEvent('keydown', {key: 'c', bubbles: true} as never) + ); + container.dispatchEvent( + new KeyboardEvent('keydown', {key: 'a', bubbles: true} as never) + ); + container.dispatchEvent( + new KeyboardEvent('keydown', {key: 't', bubbles: true} as never) + ); + expect(menu.searchStr).toBe('cat'); + const cat = Array.from(container.querySelectorAll('button')).find((b) => + b.textContent!.includes('Cat') + ); + expect(document.activeElement).toBe(cat); + + menu.clearSearchStr(); + expect(menu.searchStr).toBe(''); + }); +}); + +describe('DisclosureMenu outside-click dismissal', () => { + it('hides when a mousedown lands outside the menu', () => { + const {trigger} = buildMenu(['Alpha']); + const outside = document.createElement('div'); + document.body.appendChild(outside); + const menu = new DisclosureMenu(trigger); + menu.show(); + + outside.dispatchEvent(new MouseEvent('mousedown', {bubbles: true})); + expect(menu.isExpanded()).toBe(false); + }); + + it('does not hide when the mousedown is inside the menu', () => { + const {trigger, container} = buildMenu(['Alpha']); + const menu = new DisclosureMenu(trigger); + menu.show(); + + const item = container.querySelector('button')!; + item.dispatchEvent(new MouseEvent('mousedown', {bubbles: true})); + expect(menu.isExpanded()).toBe(true); + }); +}); + +describe('DisclosureMenu item building', () => { + it('addItem builds a button item and returns its element', () => { + const {trigger} = buildMenu(); + const menu = new DisclosureMenu(trigger); + const el = menu.addItem({label: 'Rename'}); + expect(el.tagName).toBe('BUTTON'); + expect(el.classList.contains('menu-item')).toBe(true); + expect(el.querySelector('.menu-item-label')!.textContent).toBe('Rename'); + }); + + it('infers a link item from a url', () => { + const {trigger} = buildMenu(); + const menu = new DisclosureMenu(trigger); + const el = menu.addItem({label: 'Edit', url: '/edit'}); + expect(el.tagName).toBe('A'); + expect((el as HTMLAnchorElement).getAttribute('href')).toBe('/edit'); + }); + + it('applies selected / destructive / disabled flags', () => { + const {trigger} = buildMenu(); + const menu = new DisclosureMenu(trigger); + const el = menu.addItem({ + label: 'Delete', + selected: true, + destructive: true, + disabled: true, + }); + expect(el.classList.contains('sel')).toBe(true); + expect(el.classList.contains('error')).toBe(true); + expect(el.getAttribute('data-destructive')).toBe('true'); + expect(el.classList.contains('disabled')).toBe(true); + }); + + it('sets the formsubmit class + data-action for action items', () => { + const {trigger} = buildMenu(); + const menu = new DisclosureMenu(trigger); + const el = menu.addItem({label: 'Save', action: 'entries/save'}); + expect(el.classList.contains('formsubmit')).toBe(true); + expect(el.getAttribute('data-action')).toBe('entries/save'); + }); + + it('throws on an unsupported item configuration', () => { + const {trigger} = buildMenu(); + const menu = new DisclosureMenu(trigger); + expect(() => menu.createItem(42 as never)).toThrow(); + }); + + it('addGroup creates a heading + ul and addItem fills the last group', () => { + const {trigger} = buildMenu(); + const menu = new DisclosureMenu(trigger); + const ul = menu.addGroup('Actions'); + expect(ul.tagName).toBe('UL'); + expect(menu.$container!.querySelector('h3')!.textContent).toBe('Actions'); + const el = menu.addItem({label: 'One'}); + expect(el.closest('ul')).toBe(ul); + }); + + it('getFirstDestructiveGroup finds the group with a destructive item', () => { + const {trigger} = buildMenu(); + const menu = new DisclosureMenu(trigger); + const safe = menu.addGroup(); + menu.addItem({label: 'Safe'}, safe); + const danger = menu.addGroup(); + menu.addItem({label: 'Delete', destructive: true}, danger); + expect(menu.getFirstDestructiveGroup()).toBe(danger); + }); + + it('removeItem drops the item and empties the group', () => { + const {trigger} = buildMenu(); + const menu = new DisclosureMenu(trigger); + const ul = menu.addGroup(); + const el = menu.addItem({label: 'Lonely'}, ul); + menu.removeItem(el); + expect(document.body.contains(ul)).toBe(false); + }); + + it('addHr inserts a horizontal rule', () => { + const {trigger} = buildMenu(); + const menu = new DisclosureMenu(trigger); + const hr = menu.addHr(); + expect(hr.tagName).toBe('HR'); + expect(menu.$container!.contains(hr)).toBe(true); + }); + + it('hasVisibleItems reflects whether any item is shown', () => { + const {trigger} = buildMenu(); + const menu = new DisclosureMenu(trigger); + const el = menu.addItem({label: 'Visible'}); + expect(menu.hasVisibleItems()).toBe(true); + menu.hideItem(el); + expect(menu.hasVisibleItems()).toBe(false); + }); +}); + +describe('DisclosureMenu item selection', () => { + it('runs onActivate and hides the menu on activate', () => { + vi.useFakeTimers(); + const {trigger} = buildMenu(); + const menu = new DisclosureMenu(trigger); + const onActivate = vi.fn(); + const el = menu.addItem({label: 'Go', onActivate}); + makeVisible(el); + menu.show(); + + el.dispatchEvent(new CustomEvent('activate')); + expect(onActivate).toHaveBeenCalledOnce(); + expect(onActivate).toHaveBeenCalledWith(el); + + // hide() is scheduled a tick later. + vi.advanceTimersByTime(1); + expect(menu.isExpanded()).toBe(false); + }); + + it('falls back to the legacy `callback` when onActivate is absent', () => { + vi.useFakeTimers(); + const {trigger} = buildMenu(); + const menu = new DisclosureMenu(trigger); + const callback = vi.fn(); + const el = menu.addItem({label: 'Go', callback}); + el.dispatchEvent(new CustomEvent('activate')); + expect(callback).toHaveBeenCalledOnce(); + }); +}); + +describe('DisclosureMenu search input', () => { + it('builds a search input when withSearchInput is set', () => { + const {trigger} = buildMenu(['Alpha']); + const menu = new DisclosureMenu(trigger, {withSearchInput: true}); + expect(menu.$searchInput).not.toBeNull(); + expect(menu.$container!.querySelector('.search-container')).not.toBeNull(); + }); + + it('enables search via the data-with-search-input attribute', () => { + const {trigger, container} = buildMenu(['Alpha']); + container.setAttribute('data-with-search-input', ''); + const menu = new DisclosureMenu(trigger); + expect(menu.$searchInput).not.toBeNull(); + }); + + it('filters items by typed text and clears the filter', () => { + const {trigger, container} = buildMenu(['Apple', 'Banana']); + const menu = new DisclosureMenu(trigger, {withSearchInput: true}); + + menu.$searchInput!.value = 'app'; + menu.$searchInput!.dispatchEvent(new Event('input')); + + const items = Array.from(container.querySelectorAll('li')); + const apple = items.find((li) => li.textContent!.includes('Apple'))!; + const banana = items.find((li) => li.textContent!.includes('Banana'))!; + expect(apple.classList.contains('filtered')).toBe(false); + expect(banana.classList.contains('filtered')).toBe(true); + + menu.$searchInput!.value = ''; + menu.$searchInput!.dispatchEvent(new Event('input')); + expect(banana.classList.contains('filtered')).toBe(false); + }); + + it('Return inside the search input does not submit (preventDefault)', () => { + const {trigger} = buildMenu(['Alpha']); + const menu = new DisclosureMenu(trigger, {withSearchInput: true}); + const ev = new KeyboardEvent('keydown', { + keyCode: RETURN_KEY, + cancelable: true, + } as never); + menu.$searchInput!.dispatchEvent(ev); + expect(ev.defaultPrevented).toBe(true); + }); +}); + +describe('DisclosureMenu positioning (mocked layout)', () => { + it('positions below the trigger when there is room and sets top/left', () => { + const {trigger, container} = buildMenu(['Alpha']); + const menu = new DisclosureMenu(trigger); + // Trigger near the top; menu short → fits below. + mockRect(trigger, 50, 100, 80, 24); + mockRect(container, 0, 0, 160, 120); + menu.$alignmentElement = trigger; + + menu.setContainerPosition(); + + // top is the trigger's bottom (offset.top 50 + height 24 = 74). + expect(container.style.top).toBe('74px'); + expect(container.style.left).not.toBe(''); + }); + + it('positions above the trigger when there is no room below', () => { + const {trigger, container} = buildMenu(['Alpha']); + const menu = new DisclosureMenu(trigger); + // Trigger near the bottom of an 768px viewport, tall menu → must flip above. + mockRect(trigger, 740, 100, 80, 24); + mockRect(container, 0, 0, 160, 400); + menu.$alignmentElement = trigger; + + menu.setContainerPosition(); + + // Above → top is less than the trigger's own top (740). + expect(parseFloat(container.style.top)).toBeLessThan(740); + }); + + it('honors position: "below" even when above has more room', () => { + const {trigger, container} = buildMenu(['Alpha']); + const menu = new DisclosureMenu(trigger, {position: 'below'}); + mockRect(trigger, 700, 100, 80, 24); + mockRect(container, 0, 0, 160, 400); + menu.$alignmentElement = trigger; + + menu.setContainerPosition(); + // Forced below → top is the trigger bottom (724). + expect(container.style.top).toBe('724px'); + }); +}); + +describe('DisclosureMenu destroy', () => { + it('drops registrations, removes from instances, fires destroy', () => { + const {trigger, container} = buildMenu(['Alpha']); + const menu = new DisclosureMenu(trigger); + const onDestroy = vi.fn(); + menu.on('destroy', onDestroy); + + menu.destroy(); + + expect(onDestroy).toHaveBeenCalledOnce(); + expect(DisclosureMenu.instances).not.toContain(menu); + expect(DisclosureMenu.getInstance(trigger)).toBeUndefined(); + expect(DisclosureMenu.getInstance(container)).toBeUndefined(); + }); +}); diff --git a/packages/craftcms-garnish/tests/dom-listeners.test.ts b/packages/craftcms-garnish/tests/dom-listeners.test.ts new file mode 100644 index 00000000000..9a4a93018a5 --- /dev/null +++ b/packages/craftcms-garnish/tests/dom-listeners.test.ts @@ -0,0 +1,96 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest'; + +import {DomListenerRegistry} from '../src/dom-listeners'; + +function makeHost(disabled = false) { + return {disabled}; +} + +describe('DomListenerRegistry', () => { + let el: HTMLElement; + + beforeEach(() => { + el = document.createElement('div'); + document.body.appendChild(el); + }); + + it('binds and fires a native event', () => { + const reg = new DomListenerRegistry(makeHost()); + const fn = vi.fn(); + reg.add(el, 'click', fn); + el.dispatchEvent(new MouseEvent('click')); + expect(fn).toHaveBeenCalledOnce(); + }); + + it('short-circuits while the host is disabled', () => { + const host = makeHost(true); + const reg = new DomListenerRegistry(host); + const fn = vi.fn(); + reg.add(el, 'click', fn); + el.dispatchEvent(new MouseEvent('click')); + expect(fn).not.toHaveBeenCalled(); + }); + + it('supports comma-split multiple events', () => { + const reg = new DomListenerRegistry(makeHost()); + const fn = vi.fn(); + reg.add(el, 'click,mousedown', fn); + el.dispatchEvent(new MouseEvent('click')); + el.dispatchEvent(new MouseEvent('mousedown')); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('removes by namespace only (namespaced remove)', () => { + const reg = new DomListenerRegistry(makeHost()); + const a = vi.fn(); + const b = vi.fn(); + reg.add(el, 'click.ns1', a); + reg.add(el, 'click.ns2', b); + reg.remove(el, '.ns1'); + el.dispatchEvent(new MouseEvent('click')); + expect(a).not.toHaveBeenCalled(); + expect(b).toHaveBeenCalledOnce(); + }); + + it('removeAllOn removes every listener on an element', () => { + const reg = new DomListenerRegistry(makeHost()); + const fn = vi.fn(); + reg.add(el, 'click', fn); + reg.add(el, 'mousedown', fn); + reg.removeAllOn(el); + el.dispatchEvent(new MouseEvent('click')); + el.dispatchEvent(new MouseEvent('mousedown')); + expect(fn).not.toHaveBeenCalled(); + }); + + it('delegation: only fires when the event originates within the selector', () => { + el.innerHTML = + 'y'; + const reg = new DomListenerRegistry(makeHost()); + const fn = vi.fn(); + reg.add(el, 'click', fn, {delegate: '.target'}); + + el.querySelector('.target')!.dispatchEvent( + new MouseEvent('click', {bubbles: true}) + ); + expect(fn).toHaveBeenCalledOnce(); + + el.querySelector('.other')!.dispatchEvent( + new MouseEvent('click', {bubbles: true}) + ); + expect(fn).toHaveBeenCalledOnce(); // unchanged + }); + + it('removeAll tears down everything', () => { + const reg = new DomListenerRegistry(makeHost()); + const fn = vi.fn(); + const el2 = document.createElement('div'); + document.body.appendChild(el2); + reg.add(el, 'click', fn); + reg.add(el2, 'click', fn); + reg.removeAll(); + el.dispatchEvent(new MouseEvent('click')); + el2.dispatchEvent(new MouseEvent('click')); + expect(fn).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/craftcms-garnish/tests/drag-drop.test.ts b/packages/craftcms-garnish/tests/drag-drop.test.ts new file mode 100644 index 00000000000..2566204fd9d --- /dev/null +++ b/packages/craftcms-garnish/tests/drag-drop.test.ts @@ -0,0 +1,831 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +import {Drag} from '../src/drag/drag'; +import {DragDrop} from '../src/drag/drag-drop'; +import {globals} from '../src/globals'; + +// happy-dom notes (see doc 09 §7): +// - No layout: `getBoundingClientRect()` returns zeros and `offsetWidth/Height` +// are 0. We mock `getOuterWidth/getOuterHeight` (utils/dom) for the geometry +// that matters, and `getBoundingClientRect` where getOffset is read. +// - `element.animate` is `undefined`, so the return-to-source / fade WAAPI paths +// take the no-WAAPI fallback and resolve synchronously — exactly the path we +// can assert. The real tweens are playground-only. +// - The RAF-deferred hooks + the lag-follow loop are made synchronous by +// stubbing the animation module's `requestAnimationFrame`. (Note: the lag loop +// re-schedules itself every frame; with a sync stub that would recurse +// forever, so tests that touch `startDragging` cancel the loop or never drive +// a real pointer move — see `runOneFrame` below.) +vi.mock('../src/utils/animation', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + // Default: defer to a manual queue so the self-rescheduling lag loop does + // not infinitely recurse. Tests flush the queue explicitly when needed. + requestAnimationFrame: (cb: FrameRequestCallback): number => { + rafQueue.push(cb); + return rafQueue.length; + }, + cancelAnimationFrame: (handle: number): void => { + // handles are 1-based indices into rafQueue + if (handle > 0 && handle <= rafQueue.length) { + rafQueue[handle - 1] = undefined; + } + }, + }; +}); + +let rafQueue: Array = []; + +/** Run every currently-queued RAF callback exactly once (new ones are deferred). */ +function flushRaf(): void { + const current = rafQueue; + rafQueue = []; + for (const cb of current) { + cb?.(0); + } +} + +/** + * Private-method shape used to `vi.spyOn` Drag internals. `vi.spyOn(d as never, + * ...)` typechecks for reads but not for `.mockImplementation`, so we cast + * through this when we need to stub. + */ +interface DragPrivate { + _createHelper: (index: number) => void; + _updateHelperPos: () => void; + _getHelperTarget: (i: number, real?: boolean) => {left: number; top: number}; +} + +function makeItem(cls = ''): HTMLElement { + const el = document.createElement('div'); + if (cls) el.className = cls; + document.body.appendChild(el); + return el; +} + +/** Stub an element's bounding rect so getOffset/hitTest read a known box. */ +function mockRect( + el: Element, + top: number, + left: number, + width = 0, + height = 0 +): void { + vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({ + top, + left, + right: left + width, + bottom: top + height, + width, + height, + x: left, + y: top, + toJSON: () => ({}), + } as DOMRect); +} + +beforeEach(() => { + document.body.innerHTML = ''; + document.body.className = ''; + rafQueue = []; + globals.rtl = false; + globals.activateEventsMuted = false; +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// --------------------------------------------------------------------------- +// Drag — settings + defaults +// --------------------------------------------------------------------------- + +describe('Drag settings + defaults', () => { + it('merges Drag.defaults with passed settings, preserving BaseDrag keys', () => { + const d = new Drag(null, {singleHelper: true, minMouseDist: 7}); + expect(d.settings!.singleHelper).toBe(true); + expect(d.settings!.minMouseDist).toBe(7); + // Drag default survives. + expect(d.settings!.hideDraggee).toBe(true); + expect(d.settings!.helperBaseZindex).toBe(1000); + // BaseDrag default survives. + expect(d.settings!.ignoreHandleSelector).toBe( + 'input, textarea, button, select, .btn' + ); + }); + + it('supports the param-shift form: new Drag(settingsObj)', () => { + const d = new Drag({helperOpacity: 0.5}); + expect(d.settings!.helperOpacity).toBe(0.5); + expect(d.$items).toEqual([]); + }); + + it('exposes the documented Drag defaults', () => { + expect(Drag.defaults.filter).toBeNull(); + expect(Drag.defaults.singleHelper).toBe(false); + expect(Drag.defaults.helperLagBase).toBe(3); + expect(Drag.defaults.helperLagIncrementDividend).toBe(1.5); + expect(Drag.defaults.helperSpacingX).toBe(5); + expect(Drag.defaults.helperSpacingY).toBe(5); + }); + + it('starts with empty draggee/helper state', () => { + const d = new Drag(); + expect(d.$draggee).toEqual([]); + expect(d.helpers).toEqual([]); + expect(d.allowDragging()).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Drag — findDraggee +// --------------------------------------------------------------------------- + +describe('Drag.findDraggee', () => { + it('returns [$targetItem] when filter is null', () => { + const a = makeItem(); + const d = new Drag([a]); + d.$targetItem = a; + expect(d.findDraggee()).toEqual([a]); + }); + + it('returns [] when filter is null and no target', () => { + const d = new Drag(); + expect(d.findDraggee()).toEqual([]); + }); + + it('calls a function filter and coerces the result', () => { + const a = makeItem(); + const b = makeItem(); + const d = new Drag([a], {filter: () => [a, b]}); + d.$targetItem = a; + expect(d.findDraggee()).toEqual([a, b]); + }); + + it('treats a string filter as a selector over $items', () => { + const a = makeItem('sel'); + const b = makeItem(); + const c = makeItem('sel'); + const d = new Drag([a, b, c], {filter: '.sel'}); + expect(d.findDraggee()).toEqual([a, c]); + }); +}); + +// --------------------------------------------------------------------------- +// Drag — setDraggee + helper creation +// --------------------------------------------------------------------------- + +describe('Drag.setDraggee', () => { + function setup(settings = {}): {d: Drag; a: HTMLElement; b: HTMLElement} { + const a = makeItem(); + const b = makeItem(); + const d = new Drag([a, b], settings); + d.$targetItem = a; + d.draggeeDisplay = 'block'; + vi.spyOn(d as unknown as DragPrivate, '_createHelper').mockImplementation( + () => {} + ); + return {d, a, b}; + } + + it('forces the target to index 0 and records its position', () => { + const {d, a, b} = setup(); + d.setDraggee([b, a]); + expect(d.$draggee[0]).toBe(a); + expect(d.$draggee).toEqual([a, b]); + // The target was at index 1 in the input set ([b, a]). + expect(d.targetItemPositionInDraggee).toBe(1); + }); + + it('adds the target to the draggee set if missing', () => { + const {d, a, b} = setup(); + d.setDraggee([b]); + expect(d.$draggee).toEqual([a, b]); + }); + + it('creates one helper per draggee by default', () => { + const {d, a, b} = setup(); + const spy = vi.spyOn(d as unknown as DragPrivate, '_createHelper'); + d.setDraggee([a, b]); + expect(spy).toHaveBeenCalledTimes(2); + }); + + it('creates a single helper when singleHelper is set', () => { + const {d, a, b} = setup({singleHelper: true}); + const spy = vi.spyOn(d as unknown as DragPrivate, '_createHelper'); + d.setDraggee([a, b]); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(0); + }); + + it('hideDraggee → visibility:hidden on every draggee', () => { + const {d, a, b} = setup({hideDraggee: true}); + d.setDraggee([a, b]); + expect(a.style.visibility).toBe('hidden'); + expect(b.style.visibility).toBe('hidden'); + }); + + it('removeDraggee → display:none on every draggee', () => { + const {d, a, b} = setup({ + hideDraggee: false, + removeDraggee: true, + }); + d.setDraggee([a, b]); + expect(a.style.display).toBe('none'); + expect(b.style.display).toBe('none'); + }); + + it('collapseDraggees → target hidden via visibility, others via display', () => { + const {d, a, b} = setup({ + hideDraggee: false, + collapseDraggees: true, + }); + d.setDraggee([a, b]); + expect(a.style.visibility).toBe('hidden'); + expect(b.style.display).toBe('none'); + }); +}); + +describe('Drag.appendDraggee', () => { + it('appends new draggees and creates helpers for the new indices', () => { + const a = makeItem(); + const b = makeItem(); + const c = makeItem(); + const d = new Drag([a, b, c]); + d.$targetItem = a; + d.draggeeDisplay = 'block'; + vi.spyOn(d as unknown as DragPrivate, '_createHelper').mockImplementation( + () => {} + ); + d.$draggee = [a]; + const spy = vi.spyOn(d as unknown as DragPrivate, '_createHelper'); + d.appendDraggee([b, c]); + expect(d.$draggee).toEqual([a, b, c]); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenCalledWith(1); + expect(spy).toHaveBeenCalledWith(2); + }); + + it('early-returns on an empty new set', () => { + const a = makeItem(); + const d = new Drag([a]); + d.$draggee = [a]; + const spy = vi.spyOn(d as unknown as DragPrivate, '_createHelper'); + d.appendDraggee([]); + expect(spy).not.toHaveBeenCalled(); + expect(d.$draggee).toEqual([a]); + }); +}); + +// --------------------------------------------------------------------------- +// Drag — _createHelper (clone shape) +// --------------------------------------------------------------------------- + +describe('Drag._createHelper', () => { + function setup(settings = {}): {d: Drag; a: HTMLElement} { + const a = makeItem(); + const d = new Drag([a], settings); + d.$targetItem = a; + d.$draggee = [a]; + d.draggeeDisplay = 'block'; + d.mouseX = 100; + d.mouseY = 50; + d.mouseOffsetX = 10; + d.mouseOffsetY = 5; + // happy-dom returns 0 for offsetWidth/Height; the helper just needs *some* + // sizing call to succeed. getOuterWidth/Height read offsetWidth/Height (0). + return {d, a}; + } + + function callCreate(d: Drag, index: number): void { + (d as unknown as {_createHelper: (i: number) => void})._createHelper(index); + } + + it('clones the draggee, adds .draghelper, and appends to body', () => { + const {d, a} = setup(); + a.appendChild(document.createElement('span')); + callCreate(d, 0); + expect(d.helpers).toHaveLength(1); + const helper = d.helpers[0]!; + expect(helper.classList.contains('draghelper')).toBe(true); + expect(helper.parentElement).toBe(document.body); + // Deep clone preserved the child. + expect(helper.querySelector('span')).not.toBeNull(); + }); + + it('blanks every [name] attribute on the clone', () => { + const {d, a} = setup(); + const input = document.createElement('input'); + input.setAttribute('name', 'foo'); + a.appendChild(input); + callCreate(d, 0); + const helperInput = d.helpers[0]!.querySelector('input')!; + expect(helperInput.getAttribute('name')).toBe(''); + }); + + it('sets absolute position, border-box sizing, z-index, and display', () => { + const {d, a} = setup(); + callCreate(d, 0); + const helper = d.helpers[0]!; + expect(helper.style.position).toBe('absolute'); + expect(helper.style.boxSizing).toBe('border-box'); + expect(helper.style.display).toBe('block'); + expect(helper.style.pointerEvents).toBe('none'); + // zIndex = base(1000) + draggeeLength(1) - index(0) = 1001 + expect(helper.style.zIndex).toBe('1001'); + // real=true target: mouseX - mouseOffsetX = 90, mouseY - mouseOffsetY = 45 + expect(helper.style.left).toBe('90px'); + expect(helper.style.top).toBe('45px'); + void a; + }); + + it('applies helperOpacity when not 1', () => { + const {d} = setup({helperOpacity: 0.4}); + callCreate(d, 0); + expect(d.helpers[0]!.style.opacity).toBe('0.4'); + }); + + it('invokes a function helper wrapper', () => { + const wrapper = document.createElement('div'); + wrapper.className = 'wrap'; + const helperFn = vi.fn((clone: HTMLElement) => { + wrapper.appendChild(clone); + return wrapper; + }); + const {d} = setup({helper: helperFn}); + callCreate(d, 0); + expect(helperFn).toHaveBeenCalled(); + expect(d.helpers[0]).toBe(wrapper); + expect(wrapper.parentElement).toBe(document.body); + }); + + it('wraps the clone into an element helper', () => { + const wrapper = document.createElement('div'); + wrapper.className = 'wrap'; + document.body.appendChild(wrapper); + const {d} = setup({helper: wrapper}); + callCreate(d, 0); + expect(d.helpers[0]).toBe(wrapper); + expect(wrapper.querySelector('.draghelper')).not.toBeNull(); + }); + + it('copies input values when copyDraggeeInputValuesToHelper is set', () => { + const {d, a} = setup({copyDraggeeInputValuesToHelper: true}); + const input = document.createElement('input'); + input.value = 'hello'; + a.appendChild(input); + callCreate(d, 0); + // The clone's [name] is blanked but the value is copied across. + const helperInput = d.helpers[0]!.querySelector('input')!; + expect(helperInput.value).toBe('hello'); + }); +}); + +// --------------------------------------------------------------------------- +// Drag — helper geometry +// --------------------------------------------------------------------------- + +describe('Drag.getHelperTargetX/Y + _getHelperTarget', () => { + function setup(settings = {}): Drag { + const d = new Drag(null, settings); + d.mouseX = 200; + d.mouseY = 120; + d.mouseOffsetX = 30; + d.mouseOffsetY = 20; + return d; + } + + it('subtracts the grab offset by default', () => { + const d = setup(); + expect(d.getHelperTargetX()).toBe(170); + expect(d.getHelperTargetY()).toBe(100); + }); + + it('snaps to the cursor when moveHelperToCursor is set (real=false)', () => { + const d = setup({moveHelperToCursor: true}); + expect(d.getHelperTargetX()).toBe(200); + expect(d.getHelperTargetY()).toBe(120); + }); + + it('ignores moveHelperToCursor when real=true', () => { + const d = setup({moveHelperToCursor: true}); + expect(d.getHelperTargetX(true)).toBe(170); + expect(d.getHelperTargetY(true)).toBe(100); + }); + + it('applies per-index spacing in _getHelperTarget', () => { + const d = setup({helperSpacingX: 5, helperSpacingY: 8}); + const pos = ( + d as unknown as { + _getHelperTarget: ( + i: number, + r?: boolean + ) => {left: number; top: number}; + } + )._getHelperTarget(2, true); + // 170 + 5*2 = 180 ; 100 + 8*2 = 116 + expect(pos).toEqual({left: 180, top: 116}); + }); +}); + +// --------------------------------------------------------------------------- +// Drag — lag-follow loop +// --------------------------------------------------------------------------- + +describe('Drag._updateHelperPos', () => { + it('eases each helper toward its target and recomputes targets on move', () => { + const helperEl = makeItem(); + const d = new Drag(null, {helperLagBase: 2}); + d.helpers = [helperEl]; + d.helperPositions = [{left: 0, top: 0}]; + d.helperTargets = [{left: 0, top: 0}]; + d.helperLagIncrement = 0; + d.mouseX = 100; + d.mouseY = 40; + d.mouseOffsetX = 0; + d.mouseOffsetY = 0; + d.lastMouseX = null; + d.lastMouseY = null; + + (d as unknown as {_updateHelperPos: () => void})._updateHelperPos(); + + // Mouse moved → target recomputed to (100, 40). Eased by 1/lag (lag=2): + // 0 + (100-0)/2 = 50 ; 0 + (40-0)/2 = 20 + expect(d.helperPositions[0]).toEqual({left: 50, top: 20}); + expect(helperEl.style.left).toBe('50px'); + expect(helperEl.style.top).toBe('20px'); + expect(d.lastMouseX).toBe(100); + }); +}); + +// --------------------------------------------------------------------------- +// Drag — startDragging / stopDragging ordering +// --------------------------------------------------------------------------- + +describe('Drag.startDragging / stopDragging', () => { + it('fires onBeforeDragStart first, snapshots, builds otherItems, mutes events', () => { + const a = makeItem(); + const b = makeItem(); + const order: string[] = []; + const d = new Drag([a, b], { + onBeforeDragStart: () => order.push('before'), + }); + d.$targetItem = a; + // setDraggee is the snapshot step; record ordering relative to onBefore. + vi.spyOn(d, 'setDraggee').mockImplementation((set) => { + order.push('setDraggee'); + d.$draggee = set; + }); + // Avoid driving the self-rescheduling lag loop; we only assert wiring. + vi.spyOn( + d as unknown as DragPrivate, + '_updateHelperPos' + ).mockImplementation(() => {}); + + d.startDragging(); + + expect(order).toEqual(['before', 'setDraggee']); + expect(d.dragging).toBe(true); + expect(globals.activateEventsMuted).toBe(true); + // otherItems = $items not in $draggee. $draggee = [a]. + expect(d.otherItems).toEqual([b]); + expect(d.totalOtherItems).toBe(1); + // Lag loop was scheduled. + expect(d.updateHelperPosFrame).not.toBeNull(); + }); + + it('cancels the lag-follow RAF in stopDragging', () => { + const a = makeItem(); + const d = new Drag([a]); + d.$targetItem = a; + vi.spyOn( + d as unknown as DragPrivate, + '_updateHelperPos' + ).mockImplementation(() => {}); + d.startDragging(); + expect(d.updateHelperPosFrame).not.toBeNull(); + d.stopDragging(); + expect(d.updateHelperPosFrame).toBeNull(); + expect(d.dragging).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Drag — drag() virtual midpoint +// --------------------------------------------------------------------------- + +describe('Drag.drag', () => { + it('computes the draggee virtual midpoint from fresh coords', () => { + const a = makeItem(); + const d = new Drag([a]); + d.mouseX = 100; + d.mouseY = 60; + d.mouseOffsetX = 20; + d.mouseOffsetY = 10; + d.targetItemWidth = 40; + d.targetItemHeight = 20; + // super.drag walks the auto-scroll path; stub onDrag to avoid RAF noise. + vi.spyOn(d, 'onDrag').mockImplementation(() => {}); + d.drag(false); + // mouseX - mouseOffsetX + width/2 = 100 - 20 + 20 = 100 + expect(d.draggeeVirtualMidpointX).toBe(100); + // mouseY - mouseOffsetY + height/2 = 60 - 10 + 10 = 60 + expect(d.draggeeVirtualMidpointY).toBe(60); + }); +}); + +// --------------------------------------------------------------------------- +// Drag — returnHelpersToDraggees / fadeOutHelpers (no-WAAPI sync path) +// --------------------------------------------------------------------------- + +describe('Drag.returnHelpersToDraggees', () => { + it('restores draggees, removes helpers, toggles the flag, fires the hook', () => { + const a = makeItem(); + const helper = makeItem(); + mockRect(a, 12, 8); // getOffset(a) → {top:12, left:8} + const d = new Drag([a], {hideDraggee: true}); + d.$draggee = [a]; + d.helpers = [helper]; + d.draggeeDisplay = 'block'; + const onReturn = vi.fn(); + d.settings!.onReturnHelpersToDraggees = onReturn; + + d.returnHelpersToDraggees(); + + // No element.animate in happy-dom → finalize() ran synchronously. + expect(helper.parentElement).toBeNull(); // removed + expect(d.helpers).toEqual([]); + // _show() restores the stashed inline display (none was stashed here, so it + // clears to ''), and visibility is cleared — matching jQuery .show().css(). + expect(a.style.visibility).toBe(''); + expect(d.allowDragging()).toBe(true); // flag toggled back off + + // onReturnHelpersToDraggees is RAF-deferred — flush it. + flushRaf(); + expect(onReturn).toHaveBeenCalledTimes(1); + }); + + it('short-circuits to _showDraggee when there are no helpers', () => { + const a = makeItem(); + const d = new Drag([a]); + d.$draggee = [a]; + d.helpers = []; + d.draggeeDisplay = 'block'; + d.returnHelpersToDraggees(); + expect(d.allowDragging()).toBe(true); + }); + + it('sets _returningHelpersToDraggees true mid-flight (gates allowDragging)', () => { + // Force a pending state by stubbing prefersReducedMotion false AND providing + // a helper with a fake animate that never finishes. + const a = makeItem(); + const helper = makeItem(); + mockRect(a, 0, 0); + helper.animate = vi.fn(() => { + const listeners: Record void> = {}; + return { + addEventListener: (type: string, cb: () => void) => { + listeners[type] = cb; + }, + cancel: () => {}, + } as unknown as Animation; + }) as never; + const d = new Drag([a]); + d.$draggee = [a]; + d.helpers = [helper]; + d.draggeeDisplay = 'block'; + d.returnHelpersToDraggees(); + // Animation never fired finish → still returning → dragging blocked. + expect(d.allowDragging()).toBe(false); + }); +}); + +describe('Drag.fadeOutHelpers', () => { + it('removes helpers immediately on the no-WAAPI path', () => { + const h1 = makeItem(); + const h2 = makeItem(); + const d = new Drag(); + d.helpers = [h1, h2]; + d.fadeOutHelpers(); + expect(h1.parentElement).toBeNull(); + expect(h2.parentElement).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Drag — allowDragging + destroy +// --------------------------------------------------------------------------- + +describe('Drag.allowDragging + destroy', () => { + it('blocks dragging while returning helpers', () => { + const d = new Drag(); + expect(d.allowDragging()).toBe(true); + ( + d as unknown as {_returningHelpersToDraggees: boolean} + )._returningHelpersToDraggees = true; + expect(d.allowDragging()).toBe(false); + }); + + it('destroy cancels the lag RAF, cancels return anims, and removes helpers', () => { + const helper = makeItem(); + const a = makeItem(); + const d = new Drag([a]); + d.helpers = [helper]; + d.updateHelperPosFrame = 5; + const cancel = vi.fn(); + ( + d as unknown as {_returnAnimations: Array<{cancel: () => void}>} + )._returnAnimations = [{cancel}]; + + d.destroy(); + + expect(cancel).toHaveBeenCalledTimes(1); + expect(helper.parentElement).toBeNull(); + expect(d.helpers).toEqual([]); + expect(d.updateHelperPosFrame).toBeNull(); + expect(d.$items).toEqual([]); // super.destroy ran + }); +}); + +// --------------------------------------------------------------------------- +// DragDrop — settings + updateDropTargets +// --------------------------------------------------------------------------- + +describe('DragDrop settings + defaults', () => { + it('merges DragDrop.defaults over Drag/BaseDrag defaults', () => { + const dd = new DragDrop({activeDropTargetClass: 'over'}); + expect(dd.settings!.activeDropTargetClass).toBe('over'); + expect(dd.settings!.dropTargets).toBeNull(); + // Inherited Drag default. + expect(dd.settings!.hideDraggee).toBe(true); + // Inherited BaseDrag default. + expect(dd.settings!.ignoreHandleSelector).toBe( + 'input, textarea, button, select, .btn' + ); + }); + + it('exposes the documented DragDrop defaults', () => { + expect(DragDrop.defaults.dropTargets).toBeNull(); + expect(DragDrop.defaults.activeDropTargetClass).toBe('active'); + }); +}); + +describe('DragDrop.updateDropTargets', () => { + it('resolves an element list', () => { + const t1 = makeItem(); + const t2 = makeItem(); + const dd = new DragDrop({dropTargets: [t1, t2]}); + dd.updateDropTargets(); + expect(dd.$dropTargets).toEqual([t1, t2]); + }); + + it('resolves a selector string', () => { + const t1 = makeItem('target'); + const t2 = makeItem('target'); + const dd = new DragDrop({dropTargets: '.target'}); + dd.updateDropTargets(); + expect(dd.$dropTargets).toEqual([t1, t2]); + }); + + it('resolves a resolver function', () => { + const t1 = makeItem(); + const dd = new DragDrop({dropTargets: () => [t1]}); + dd.updateDropTargets(); + expect(dd.$dropTargets).toEqual([t1]); + }); + + it('collapses an empty result to null', () => { + const dd = new DragDrop({dropTargets: () => []}); + dd.updateDropTargets(); + expect(dd.$dropTargets).toBeNull(); + }); + + it('leaves $dropTargets null when dropTargets is null', () => { + const dd = new DragDrop({dropTargets: null}); + dd.updateDropTargets(); + expect(dd.$dropTargets).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// DragDrop — onDrag hit-detection +// --------------------------------------------------------------------------- + +describe('DragDrop.onDrag hit-detection', () => { + function setup(): { + dd: DragDrop; + t1: HTMLElement; + t2: HTMLElement; + } { + const t1 = makeItem(); + const t2 = makeItem(); + // t1 box: 0..10 x 0..10 (page coords). t2 box: 100..110 x 100..110. + mockRect(t1, 0, 0, 10, 10); + mockRect(t2, 100, 100, 10, 10); + const dd = new DragDrop(); + dd.$dropTargets = [t1, t2]; + // Avoid the RAF-deferred super.onDrag emitting noise. + return {dd, t1, t2}; + } + + it('adds the active class and fires onDropTargetChange on enter', () => { + const onChange = vi.fn(); + const {dd, t1} = setup(); + dd.settings!.onDropTargetChange = onChange; + dd.mouseX = 5; + dd.mouseY = 5; + dd.onDrag(); + expect(dd.$activeDropTarget).toBe(t1); + expect(t1.classList.contains('active')).toBe(true); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(t1); + }); + + it('does not re-fire onDropTargetChange when the target is unchanged', () => { + const onChange = vi.fn(); + const {dd} = setup(); + dd.settings!.onDropTargetChange = onChange; + dd.mouseX = 5; + dd.mouseY = 5; + dd.onDrag(); + dd.onDrag(); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('swaps classes and fires on a target→target change', () => { + const onChange = vi.fn(); + const {dd, t1, t2} = setup(); + dd.settings!.onDropTargetChange = onChange; + dd.mouseX = 5; + dd.mouseY = 5; + dd.onDrag(); + dd.mouseX = 105; + dd.mouseY = 105; + dd.onDrag(); + expect(t1.classList.contains('active')).toBe(false); + expect(t2.classList.contains('active')).toBe(true); + expect(dd.$activeDropTarget).toBe(t2); + expect(onChange).toHaveBeenCalledTimes(2); + expect(onChange).toHaveBeenLastCalledWith(t2); + }); + + it('fires with null on a target→none change and removes the class', () => { + const onChange = vi.fn(); + const {dd, t1} = setup(); + dd.settings!.onDropTargetChange = onChange; + dd.mouseX = 5; + dd.mouseY = 5; + dd.onDrag(); + dd.mouseX = 500; + dd.mouseY = 500; + dd.onDrag(); + expect(t1.classList.contains('active')).toBe(false); + expect(dd.$activeDropTarget).toBeNull(); + expect(onChange).toHaveBeenLastCalledWith(null); + }); + + it('first match wins for overlapping targets', () => { + const {dd, t1, t2} = setup(); + // Make both targets contain the cursor; t1 comes first in the list. + mockRect(t1, 0, 0, 1000, 1000); + mockRect(t2, 0, 0, 1000, 1000); + dd.mouseX = 50; + dd.mouseY = 50; + dd.onDrag(); + expect(dd.$activeDropTarget).toBe(t1); + void t2; + }); + + it('does nothing when there are no drop targets', () => { + const dd = new DragDrop(); + dd.$dropTargets = null; + const onChange = vi.fn(); + dd.settings!.onDropTargetChange = onChange; + dd.mouseX = 5; + dd.mouseY = 5; + expect(() => dd.onDrag()).not.toThrow(); + expect(onChange).not.toHaveBeenCalled(); + }); +}); + +describe('DragDrop.onDragStart / onDragStop', () => { + it('onDragStart resolves drop targets and resets the active target', () => { + const t1 = makeItem(); + const dd = new DragDrop({dropTargets: [t1]}); + dd.$activeDropTarget = t1; + dd.onDragStart(); + expect(dd.$dropTargets).toEqual([t1]); + expect(dd.$activeDropTarget).toBeNull(); + }); + + it('onDragStop strips the active class when a target is active', () => { + const t1 = makeItem(); + t1.classList.add('active'); + const dd = new DragDrop(); + dd.$dropTargets = [t1]; + dd.$activeDropTarget = t1; + dd.onDragStop(); + expect(t1.classList.contains('active')).toBe(false); + }); +}); diff --git a/packages/craftcms-garnish/tests/drag-sort.test.ts b/packages/craftcms-garnish/tests/drag-sort.test.ts new file mode 100644 index 00000000000..8a76bd194a5 --- /dev/null +++ b/packages/craftcms-garnish/tests/drag-sort.test.ts @@ -0,0 +1,604 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +import {DragSort} from '../src/drag/drag-sort'; +import {X_AXIS, Y_AXIS} from '../src/constants'; +import {globals} from '../src/globals'; + +// happy-dom notes (see doc 11 §6): +// - No layout: `getBoundingClientRect()` returns zeros and `offsetWidth/Height` +// are 0. We mock `getBoundingClientRect` where midpoint math matters. +// - `element.animate` is `undefined`, so `returnHelpersToDraggees` (called by +// DragSort.onDragStop) takes the synchronous no-WAAPI fallback. +// - The RAF-deferred hooks + the lag-follow loop are deferred onto a manual +// queue (a synchronous stub would recurse forever via the lag loop). Tests +// flush the queue explicitly with `flushRaf()`. +vi.mock('../src/utils/animation', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + requestAnimationFrame: (cb: FrameRequestCallback): number => { + rafQueue.push(cb); + return rafQueue.length; + }, + cancelAnimationFrame: (handle: number): void => { + if (handle > 0 && handle <= rafQueue.length) { + rafQueue[handle - 1] = undefined; + } + }, + }; +}); + +let rafQueue: Array = []; + +/** Run every currently-queued RAF callback once (newly-scheduled ones defer). */ +function flushRaf(): void { + const current = rafQueue; + rafQueue = []; + for (const cb of current) { + cb?.(0); + } +} + +/** + * Private-method shape used to `vi.spyOn`/read DragSort internals. `vi.spyOn(d + * as never, ...)` typechecks for reads but not for `.mockImplementation`, so we + * cast through this when stubbing. + */ +interface DragSortPrivate { + _updateInsertion: () => void; + _getClosestItem: () => HTMLElement | null; + _precalculateMidpoints: () => void; + _getDraggeeIndexes: () => number[]; + _moveDraggeeToItem: (item: HTMLElement) => void; + _placeInsertionWithDraggee: () => void; + _removeInsertion: () => void; + _allMidpoints: Map | null; +} + +function makeItem(cls = ''): HTMLElement { + const el = document.createElement('div'); + if (cls) el.className = cls; + document.body.appendChild(el); + return el; +} + +/** Dispatch a PointerEvent with page coords overridden (happy-dom zeros them). */ +function firePointer( + el: EventTarget, + type: string, + opts: { + pageX?: number; + pageY?: number; + pointerId?: number; + button?: number; + } = {} +): void { + const ev = new PointerEvent(type, {bubbles: true, cancelable: true} as never); + Object.defineProperty(ev, 'pageX', { + value: opts.pageX ?? 0, + configurable: true, + }); + Object.defineProperty(ev, 'pageY', { + value: opts.pageY ?? 0, + configurable: true, + }); + Object.defineProperty(ev, 'pointerId', { + value: opts.pointerId ?? 1, + configurable: true, + }); + Object.defineProperty(ev, 'button', { + value: opts.button ?? 0, + configurable: true, + }); + el.dispatchEvent(ev); +} + +/** Stub an element's bounding rect so midpoint/hit-test math reads a known box. */ +function mockRect( + el: Element, + top: number, + left: number, + width = 0, + height = 0 +): void { + vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({ + top, + left, + right: left + width, + bottom: top + height, + width, + height, + x: left, + y: top, + toJSON: () => ({}), + } as DOMRect); +} + +beforeEach(() => { + document.body.innerHTML = ''; + document.body.className = ''; + rafQueue = []; + globals.rtl = false; + globals.activateEventsMuted = false; +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// --------------------------------------------------------------------------- +// Settings + defaults +// --------------------------------------------------------------------------- + +describe('DragSort settings + defaults', () => { + it('exposes the documented DragSort defaults', () => { + expect(DragSort.defaults.container).toBeNull(); + expect(DragSort.defaults.insertion).toBeNull(); + expect(DragSort.defaults.moveTargetItemToFront).toBe(false); + expect(DragSort.defaults.magnetStrength).toBe(1); + expect(DragSort.defaults.canInsertBefore(makeItem())).toBe(true); + expect(DragSort.defaults.canInsertAfter(makeItem())).toBe(true); + }); + + it('merges DragSort.defaults over Drag/BaseDrag defaults', () => { + const ds = new DragSort(null, {magnetStrength: 4, minMouseDist: 9}); + expect(ds.settings!.magnetStrength).toBe(4); + expect(ds.settings!.minMouseDist).toBe(9); + // Inherited Drag default. + expect(ds.settings!.hideDraggee).toBe(true); + // Inherited BaseDrag default. + expect(ds.settings!.ignoreHandleSelector).toBe( + 'input, textarea, button, select, .btn' + ); + }); + + it('supports the param-shift form: new DragSort(settingsObj)', () => { + const ds = new DragSort({moveTargetItemToFront: true}); + expect(ds.settings!.moveTargetItemToFront).toBe(true); + expect(ds.$items).toEqual([]); + }); + + it('starts with empty insertion/sort state', () => { + const ds = new DragSort(); + expect(ds.$insertion).toBeNull(); + expect(ds.insertionVisible).toBe(false); + expect(ds.closestItem).toBeNull(); + expect(ds.oldDraggeeIndexes).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// createInsertion +// --------------------------------------------------------------------------- + +describe('DragSort.createInsertion', () => { + it('returns null when no insertion is configured', () => { + const ds = new DragSort(); + expect(ds.createInsertion()).toBeNull(); + }); + + it('invokes a function insertion with the draggee set', () => { + const el = document.createElement('li'); + const fn = vi.fn(() => el); + const a = makeItem(); + const ds = new DragSort([a], {insertion: fn}); + ds.$draggee = [a]; + expect(ds.createInsertion()).toBe(el); + expect(fn).toHaveBeenCalledWith([a]); + }); + + it('parses an HTML-string insertion into an element', () => { + const ds = new DragSort(null, {insertion: '
  • x
  • '}); + const el = ds.createInsertion(); + expect(el).not.toBeNull(); + expect(el!.tagName).toBe('LI'); + expect(el!.classList.contains('ins')).toBe(true); + }); + + it('passes an element insertion through unchanged', () => { + const el = document.createElement('div'); + const ds = new DragSort(null, {insertion: el}); + expect(ds.createInsertion()).toBe(el); + }); +}); + +// --------------------------------------------------------------------------- +// canInsertBefore / canInsertAfter +// --------------------------------------------------------------------------- + +describe('DragSort.canInsertBefore / canInsertAfter', () => { + it('delegate to the settings callbacks', () => { + const item = makeItem(); + const before = vi.fn(() => false); + const after = vi.fn(() => true); + const ds = new DragSort(null, { + canInsertBefore: before, + canInsertAfter: after, + }); + expect(ds.canInsertBefore(item)).toBe(false); + expect(ds.canInsertAfter(item)).toBe(true); + expect(before).toHaveBeenCalledWith(item); + expect(after).toHaveBeenCalledWith(item); + }); +}); + +// --------------------------------------------------------------------------- +// _getDraggeeIndexes +// --------------------------------------------------------------------------- + +describe('DragSort draggee indexes', () => { + it('maps each draggee to its index within $items', () => { + const a = makeItem(); + const b = makeItem(); + const c = makeItem(); + const ds = new DragSort([a, b, c]); + ds.$draggee = [c, a]; + expect((ds as unknown as DragSortPrivate)._getDraggeeIndexes()).toEqual([ + 2, 0, + ]); + }); +}); + +// --------------------------------------------------------------------------- +// _getClosestItem — spatial hit-test math +// --------------------------------------------------------------------------- + +describe('DragSort._getClosestItem', () => { + /** A 4-item vertical list (each 20px tall) with `b` as the dragged item. */ + function verticalList(settings = {}): { + ds: DragSort; + items: HTMLElement[]; + } { + const [a, b, c, d] = [makeItem(), makeItem(), makeItem(), makeItem()]; + // midpoints y: a=10, b=30, c=50, d=70 (top + height/2, height 20); x all 10. + mockRect(a, 0, 0, 20, 20); + mockRect(b, 20, 0, 20, 20); + mockRect(c, 40, 0, 20, 20); + mockRect(d, 60, 0, 20, 20); + const ds = new DragSort([a, b, c, d], settings); + ds.$draggee = [b]; + (ds as unknown as DragSortPrivate)._precalculateMidpoints(); + return {ds, items: [a, b, c, d]}; + } + + it('returns the nearest insertable item (and ignores the draggee itself)', () => { + const {ds, items} = verticalList(); + const [, , c] = items; + ds.draggeeVirtualMidpointX = 10; + ds.draggeeVirtualMidpointY = 55; // closest to c (mid 50) + expect((ds as unknown as DragSortPrivate)._getClosestItem()).toBe(c); + }); + + it('finds an item above the draggee when the cursor moves up', () => { + const {ds, items} = verticalList(); + const [a] = items; + ds.draggeeVirtualMidpointX = 10; + ds.draggeeVirtualMidpointY = 8; // closest to a (mid 10) + expect((ds as unknown as DragSortPrivate)._getClosestItem()).toBe(a); + }); + + it('returns null when no item is insertable (only the draggee is closest)', () => { + const {ds} = verticalList({ + canInsertBefore: () => false, + canInsertAfter: () => false, + }); + ds.draggeeVirtualMidpointX = 10; + ds.draggeeVirtualMidpointY = 55; + // Every non-draggee item is gated out, so the seed (draggee) wins → null. + expect((ds as unknown as DragSortPrivate)._getClosestItem()).toBeNull(); + }); + + it('uses only the Y distance under axis: y', () => { + const {ds, items} = verticalList({axis: Y_AXIS}); + const [, , c] = items; + // A wildly different X must not matter when axis-locked to Y. + ds.draggeeVirtualMidpointX = 9999; + ds.draggeeVirtualMidpointY = 52; + expect((ds as unknown as DragSortPrivate)._getClosestItem()).toBe(c); + }); +}); + +// --------------------------------------------------------------------------- +// Insertion placement + DOM reorder +// --------------------------------------------------------------------------- + +describe('DragSort insertion placement', () => { + it('_placeInsertionWithDraggee inserts before the first draggee and marks visible', () => { + const a = makeItem(); + const ds = new DragSort([a]); + ds.$draggee = [a]; + ds.$insertion = document.createElement('div'); + (ds as unknown as DragSortPrivate)._placeInsertionWithDraggee(); + expect(ds.insertionVisible).toBe(true); + expect(a.previousSibling).toBe(ds.$insertion); + }); + + it('_removeInsertion pulls the placeholder out and clears the flag', () => { + const a = makeItem(); + const ds = new DragSort([a]); + ds.$draggee = [a]; + ds.$insertion = document.createElement('div'); + (ds as unknown as DragSortPrivate)._placeInsertionWithDraggee(); + (ds as unknown as DragSortPrivate)._removeInsertion(); + expect(ds.insertionVisible).toBe(false); + expect(ds.$insertion.parentNode).toBeNull(); + }); + + it('_moveDraggeeToItem inserts the draggee after a later sibling (going down)', () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const a = document.createElement('div'); + const b = document.createElement('div'); + const c = document.createElement('div'); + container.append(a, b, c); + const ds = new DragSort([a, b, c]); + ds.$draggee = [a]; + // a (index 0) → after c (index 2): goingDown branch. + (ds as unknown as DragSortPrivate)._moveDraggeeToItem(c); + expect(Array.from(container.children)).toEqual([b, c, a]); + }); + + it('_moveDraggeeToItem inserts the draggee before an earlier sibling (going up)', () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const a = document.createElement('div'); + const b = document.createElement('div'); + const c = document.createElement('div'); + container.append(a, b, c); + const ds = new DragSort([a, b, c]); + ds.$draggee = [c]; + // c (index 2) → before a (index 0): goingUp branch. + (ds as unknown as DragSortPrivate)._moveDraggeeToItem(a); + expect(Array.from(container.children)).toEqual([c, a, b]); + }); +}); + +// --------------------------------------------------------------------------- +// onDragStart +// --------------------------------------------------------------------------- + +describe('DragSort.onDragStart', () => { + it('records old draggee indexes and precalculates midpoints', () => { + const a = makeItem(); + const b = makeItem(); + const ds = new DragSort([a, b]); + ds.$draggee = [b]; + ds.$targetItem = b; + ds.onDragStart(); + expect(ds.oldDraggeeIndexes).toEqual([1]); + expect((ds as unknown as DragSortPrivate)._allMidpoints).not.toBeNull(); + expect(ds.closestItem).toBeNull(); + }); + + it('honors moveTargetItemToFront by reordering the draggee in the DOM', () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const a = document.createElement('div'); + const b = document.createElement('div'); + const c = document.createElement('div'); + container.append(a, b, c); + const ds = new DragSort([a, b, c], {moveTargetItemToFront: true}); + // Target c is the first draggee but sits after b in $items → move it front. + ds.$draggee = [c, b]; + ds.$targetItem = c; + ds.onDragStart(); + // c moved before b: DOM order a, c, b. + expect(Array.from(container.children)).toEqual([a, c, b]); + }); + + it('creates and places the insertion when configured', () => { + const a = makeItem(); + const ds = new DragSort([a], {insertion: '
    '}); + ds.$draggee = [a]; + ds.$targetItem = a; + ds.onDragStart(); + expect(ds.$insertion).not.toBeNull(); + expect(ds.insertionVisible).toBe(true); + expect(a.previousElementSibling).toBe(ds.$insertion); + }); +}); + +// --------------------------------------------------------------------------- +// onDrag +// --------------------------------------------------------------------------- + +describe('DragSort.onDrag', () => { + it('updates the insertion when the closest item changes', () => { + const a = makeItem(); + const b = makeItem(); + const ds = new DragSort([a, b]); + ds.$draggee = [a]; + ds.draggeeVirtualMidpointX = 0; + ds.draggeeVirtualMidpointY = 0; + vi.spyOn(ds, 'onDrag'); // keep, but stub the internals below + vi.spyOn( + ds as unknown as DragSortPrivate, + '_getClosestItem' + ).mockReturnValue(b); + const update = vi + .spyOn(ds as unknown as DragSortPrivate, '_updateInsertion') + .mockImplementation(() => {}); + ds.closestItem = null; + ds.onDrag(); + expect(ds.closestItem).toBe(b); + expect(update).toHaveBeenCalledTimes(1); + }); + + it('does not update the insertion when the closest item is unchanged', () => { + const a = makeItem(); + const b = makeItem(); + const ds = new DragSort([a, b]); + ds.$draggee = [a]; + vi.spyOn( + ds as unknown as DragSortPrivate, + '_getClosestItem' + ).mockReturnValue(b); + const update = vi + .spyOn(ds as unknown as DragSortPrivate, '_updateInsertion') + .mockImplementation(() => {}); + ds.closestItem = b; // already on b + ds.onDrag(); + expect(update).not.toHaveBeenCalled(); + }); + + it('clears the closest item + insertion when the cursor leaves the container', () => { + const a = makeItem(); + const ds = new DragSort([a]); + ds.$draggee = [a]; + const container = makeItem(); + mockRect(container, 0, 0, 10, 10); // box 0..10 + ds.$heightedContainer = container; + ds.$insertion = document.createElement('div'); + (ds as unknown as DragSortPrivate)._placeInsertionWithDraggee(); + ds.closestItem = a; + ds.mouseX = 500; // far outside the container box + ds.mouseY = 500; + ds.onDrag(); + expect(ds.closestItem).toBeNull(); + expect(ds.insertionVisible).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// onDragStop + sortChange +// --------------------------------------------------------------------------- + +describe('DragSort.onDragStop', () => { + function setup(): {ds: DragSort; a: HTMLElement; b: HTMLElement} { + const a = makeItem(); + const b = makeItem(); + const ds = new DragSort([a, b]); + ds.$draggee = [b]; + ds.$targetItem = b; + ds.targetItemPositionInDraggee = 0; + ds.draggeeDisplay = 'block'; + ds.helpers = []; + return {ds, a, b}; + } + + it('returns helpers and removes the insertion', () => { + const {ds} = setup(); + const ret = vi.spyOn(ds, 'returnHelpersToDraggees'); + const remove = vi.spyOn( + ds as unknown as DragSortPrivate, + '_removeInsertion' + ); + ds.oldDraggeeIndexes = [1]; + ds.onDragStop(); + expect(remove).toHaveBeenCalled(); + expect(ret).toHaveBeenCalled(); + }); + + it('fires sortChange when the draggee order changed', () => { + const {ds} = setup(); + const onSort = vi.fn(); + ds.settings!.onSortChange = onSort; + const triggered = vi.fn(); + ds.on('sortChange', triggered); + // Pretend the draggee used to be at index 0; it is now at index 1. + ds.oldDraggeeIndexes = [0]; + ds.onDragStop(); + flushRaf(); + expect(triggered).toHaveBeenCalledTimes(1); + expect(onSort).toHaveBeenCalledTimes(1); + }); + + it('does not fire sortChange when the order is unchanged', () => { + const {ds} = setup(); + const triggered = vi.fn(); + ds.on('sortChange', triggered); + // b is at index 1 both before and after. + ds.oldDraggeeIndexes = [1]; + ds.onDragStop(); + flushRaf(); + expect(triggered).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// Event hooks +// --------------------------------------------------------------------------- + +describe('DragSort event hooks', () => { + it('onInsertionPointChange emits the event + runs the callback (RAF-deferred)', () => { + const cb = vi.fn(); + const ds = new DragSort(null, {onInsertionPointChange: cb}); + const triggered = vi.fn(); + ds.on('insertionPointChange', triggered); + ds.onInsertionPointChange(); + expect(triggered).not.toHaveBeenCalled(); // deferred + flushRaf(); + expect(triggered).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('onSortChange emits the event + runs the callback (RAF-deferred)', () => { + const cb = vi.fn(); + const ds = new DragSort(null, {onSortChange: cb}); + const triggered = vi.fn(); + ds.on('sortChange', triggered); + ds.onSortChange(); + flushRaf(); + expect(triggered).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenCalledTimes(1); + }); +}); + +// --------------------------------------------------------------------------- +// getHelperTargetX/Y — magnetStrength +// --------------------------------------------------------------------------- + +describe('DragSort helper-target magnetStrength', () => { + it('tracks the cursor exactly at magnetStrength 1 (delegates to Drag)', () => { + const a = makeItem(); + const ds = new DragSort([a], {magnetStrength: 1}); + ds.$draggee = [a]; + ds.mouseX = 200; + ds.mouseY = 120; + ds.mouseOffsetX = 30; + ds.mouseOffsetY = 20; + expect(ds.getHelperTargetX()).toBe(170); + expect(ds.getHelperTargetY()).toBe(100); + }); + + it('rubber-bands toward the draggee home at magnetStrength > 1', () => { + const a = makeItem(); + mockRect(a, 100, 50); // getOffset(a) → {top:100, left:50} + const ds = new DragSort([a], {magnetStrength: 2}); + ds.$draggee = [a]; + ds.mouseX = 250; + ds.mouseY = 300; + ds.mouseOffsetX = 10; + ds.mouseOffsetY = 10; + // X: 50 + (250 - 10 - 50) / 2 = 50 + 95 = 145 + expect(ds.getHelperTargetX()).toBe(145); + // Y: 100 + (300 - 10 - 100) / 2 = 100 + 95 = 195 + expect(ds.getHelperTargetY()).toBe(195); + }); +}); + +// --------------------------------------------------------------------------- +// Event wiring via synthetic PointerEvents +// --------------------------------------------------------------------------- + +describe('DragSort pointer wiring', () => { + it('starts a drag and emits dragStart after a past-threshold pointer move', () => { + const a = makeItem(); + const b = makeItem(); + const c = makeItem(); + const ds = new DragSort([a, b, c]); + const started = vi.fn(); + ds.on('dragStart', started); + + firePointer(a, 'pointerdown', {pageX: 0, pageY: 0, pointerId: 1}); + firePointer(document, 'pointermove', {pageX: 0, pageY: 10, pointerId: 1}); + flushRaf(); + + expect(ds.dragging).toBe(true); + expect(started).toHaveBeenCalled(); + expect(ds.$draggee[0]).toBe(a); + + firePointer(document, 'pointerup', {pointerId: 1}); + void X_AXIS; + }); +}); diff --git a/packages/craftcms-garnish/tests/drag.test.ts b/packages/craftcms-garnish/tests/drag.test.ts new file mode 100644 index 00000000000..4ea9cde871a --- /dev/null +++ b/packages/craftcms-garnish/tests/drag.test.ts @@ -0,0 +1,577 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +import {BaseDrag, getItemDragger} from '../src/drag/base-drag'; +import {DragMove} from '../src/drag-move'; +import {getScrollParent, isWindowScrollContainer} from '../src/utils/scroll'; +import {X_AXIS, Y_AXIS} from '../src/constants'; +import {globals, win} from '../src/globals'; + +// happy-dom notes (see doc 07 §7): +// - `PointerEvent` exists but `pageX/pageY/pointerId` are always 0 (no +// geometry), so we dispatch a real PointerEvent and override those props. +// - There is no layout, so `getBoundingClientRect()` returns zeros — we mock it +// where coordinate math depends on it. +// - The RAF-deferred hooks (`onDragStart`/`onDrag`/`onDragStop`) are driven by +// stubbing the animation module's `requestAnimationFrame` to run synchronously +// (below), so we can assert the emitted events without a real frame. +vi.mock('../src/utils/animation', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + requestAnimationFrame: (cb: FrameRequestCallback): number => { + cb(0); + return 0; + }, + cancelAnimationFrame: (): void => {}, + }; +}); + +/** Dispatch a PointerEvent on `el` with page coords overridden (happy-dom zeros them). */ +function firePointer( + el: EventTarget, + type: string, + opts: { + pageX?: number; + pageY?: number; + pointerId?: number; + button?: number; + } = {} +): PointerEvent { + const ev = new PointerEvent(type, {bubbles: true, cancelable: true} as never); + Object.defineProperty(ev, 'pageX', { + value: opts.pageX ?? 0, + configurable: true, + }); + Object.defineProperty(ev, 'pageY', { + value: opts.pageY ?? 0, + configurable: true, + }); + Object.defineProperty(ev, 'pointerId', { + value: opts.pointerId ?? 1, + configurable: true, + }); + Object.defineProperty(ev, 'button', { + value: opts.button ?? 0, + configurable: true, + }); + el.dispatchEvent(ev); + return ev; +} + +function makeItem(): HTMLElement { + const el = document.createElement('div'); + document.body.appendChild(el); + return el; +} + +/** Stub an element's bounding rect so getOffset returns a known top/left. */ +function mockRect(el: Element, top: number, left: number): void { + vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({ + top, + left, + right: left, + bottom: top, + width: 0, + height: 0, + x: left, + y: top, + toJSON: () => ({}), + } as DOMRect); +} + +beforeEach(() => { + document.body.innerHTML = ''; + document.body.className = ''; + globals.rtl = false; + globals.activateEventsMuted = false; +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('BaseDrag settings + defaults', () => { + it('merges defaults with passed settings', () => { + const d = new BaseDrag(null, {axis: X_AXIS, minMouseDist: 5}); + expect(d.settings!.axis).toBe(X_AXIS); + expect(d.settings!.minMouseDist).toBe(5); + // Untouched defaults survive. + expect(d.settings!.ignoreHandleSelector).toBe( + 'input, textarea, button, select, .btn' + ); + }); + + it('supports the param-shift form: new BaseDrag(settings)', () => { + const d = new BaseDrag({axis: Y_AXIS}); + expect(d.settings!.axis).toBe(Y_AXIS); + expect(d.$items).toEqual([]); + }); + + it('exposes the static tunables', () => { + expect(BaseDrag.minMouseDist).toBe(1); + expect(BaseDrag.windowScrollTargetSize).toBe(25); + }); + + it('starts with the documented initial state', () => { + const d = new BaseDrag(); + expect(d.dragging).toBe(false); + expect(d.$targetItem).toBeNull(); + expect(d.$items).toEqual([]); + expect(d.mouseX).toBeNull(); + expect(d.scrollProperty).toBeNull(); + }); +}); + +describe('BaseDrag item management + registry', () => { + it('addItems registers items in $items and the WeakMap registry', () => { + const a = makeItem(); + const b = makeItem(); + const d = new BaseDrag(); + d.addItems([a, b]); + expect(d.$items).toEqual([a, b]); + expect(getItemDragger(a)).toBe(d); + expect(getItemDragger(b)).toBe(d); + }); + + it('addItems accepts a selector string', () => { + const a = makeItem(); + a.className = 'draggable'; + const d = new BaseDrag('.draggable'); + expect(d.$items).toContain(a); + }); + + it('dedupes items already owned by the same dragger', () => { + const a = makeItem(); + const d = new BaseDrag([a]); + d.addItems([a]); + expect(d.$items).toEqual([a]); + }); + + it('warns and steals an item from another dragger', () => { + const a = makeItem(); + const d1 = new BaseDrag([a]); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const d2 = new BaseDrag(); + d2.addItems([a]); + expect(warn).toHaveBeenCalledWith( + 'Element was added to more than one dragger' + ); + expect(getItemDragger(a)).toBe(d2); + expect(d1.$items).not.toContain(a); + expect(d2.$items).toContain(a); + }); + + it('removeItems drops the item and its registry entry', () => { + const a = makeItem(); + const b = makeItem(); + const d = new BaseDrag([a, b]); + d.removeItems(a); + expect(d.$items).toEqual([b]); + expect(getItemDragger(a)).toBeUndefined(); + }); + + it('removeAllItems clears everything', () => { + const d = new BaseDrag([makeItem(), makeItem()]); + d.removeAllItems(); + expect(d.$items).toEqual([]); + }); + + it('getPrevItem / getNextItem walk the $items array', () => { + const a = makeItem(); + const b = makeItem(); + const c = makeItem(); + const d = new BaseDrag([a, b, c]); + expect(d.getPrevItem(b)).toBe(a); + expect(d.getNextItem(b)).toBe(c); + expect(d.getPrevItem(a)).toBeNull(); + expect(d.getNextItem(c)).toBeNull(); + expect(d.getPrevItem(makeItem())).toBeNull(); + }); + + it('destroy removes all items + clears the registry', () => { + const a = makeItem(); + const d = new BaseDrag([a]); + d.destroy(); + expect(d.$items).toEqual([]); + expect(getItemDragger(a)).toBeUndefined(); + }); +}); + +describe('BaseDrag handle resolution', () => { + it('uses the item itself when handle is null', () => { + const a = makeItem(); + const child = document.createElement('span'); + a.appendChild(child); + const onBefore = vi.fn(); + const d = new BaseDrag([a], {onBeforeDragStart: onBefore, minMouseDist: 0}); + mockRect(a, 0, 0); + // pointerdown on the item starts capture. + firePointer(a, 'pointerdown', {pageX: 5, pageY: 5}); + expect(d.$targetItem).toBe(a); + }); + + it('resolves a string handle via querySelector within the item', () => { + const a = makeItem(); + const handle = document.createElement('div'); + handle.className = 'handle'; + a.appendChild(handle); + const d = new BaseDrag([a], {handle: '.handle'}); + mockRect(a, 0, 0); + // pointerdown on the handle captures; pointerdown elsewhere on item does not. + firePointer(handle, 'pointerdown', {pageX: 1, pageY: 1}); + expect(d.$targetItem).toBe(a); + }); + + it('resolves a function handle', () => { + const a = makeItem(); + const handle = document.createElement('div'); + a.appendChild(handle); + const d = new BaseDrag([a], {handle: () => handle}); + mockRect(a, 0, 0); + firePointer(handle, 'pointerdown', {pageX: 1, pageY: 1}); + expect(d.$targetItem).toBe(a); + }); +}); + +describe('BaseDrag pointer-down gating + coordinate capture', () => { + it('ignores secondary (button !== 0) clicks', () => { + const a = makeItem(); + const d = new BaseDrag([a]); + mockRect(a, 0, 0); + firePointer(a, 'pointerdown', {pageX: 5, pageY: 5, button: 2}); + expect(d.$targetItem).toBeNull(); + }); + + it('ignores pointerdown on an ignoreHandleSelector descendant', () => { + const a = makeItem(); + const button = document.createElement('button'); + a.appendChild(button); + const d = new BaseDrag([a]); // default ignoreHandleSelector includes `button` + mockRect(a, 0, 0); + firePointer(button, 'pointerdown', {pageX: 5, pageY: 5}); + expect(d.$targetItem).toBeNull(); + }); + + it('captures the target, mousedown coords, and the grab offset', () => { + const a = makeItem(); + mockRect(a, 30, 20); // top=30, left=20 + const d = new BaseDrag([a]); + firePointer(a, 'pointerdown', {pageX: 50, pageY: 60}); + expect(d.$targetItem).toBe(a); + expect(d.mousedownX).toBe(50); + expect(d.mousedownY).toBe(60); + expect(d.mouseOffsetX).toBe(50 - 20); + expect(d.mouseOffsetY).toBe(60 - 30); + }); +}); + +describe('BaseDrag drag start threshold + axis locking', () => { + it('starts dragging once minMouseDist is exceeded and fires the hooks', () => { + const a = makeItem(); + mockRect(a, 0, 0); + const onStart = vi.fn(); + const onDrag = vi.fn(); + const d = new BaseDrag([a], { + minMouseDist: 5, + onDragStart: onStart, + onDrag, + }); + + firePointer(a, 'pointerdown', {pageX: 0, pageY: 0}); + // Small move — below threshold, no drag. + firePointer(document, 'pointermove', {pageX: 2, pageY: 0}); + expect(d.dragging).toBe(false); + + // Big move — crosses threshold. + firePointer(document, 'pointermove', {pageX: 20, pageY: 0}); + expect(d.dragging).toBe(true); + // RAF is stubbed synchronous, so the deferred hooks have fired. + expect(onStart).toHaveBeenCalled(); + expect(onDrag).toHaveBeenCalled(); + }); + + it('locks to the X axis (mouseY stays at mousedown)', () => { + const a = makeItem(); + mockRect(a, 0, 0); + const d = new BaseDrag([a], {axis: X_AXIS, minMouseDist: 0}); + firePointer(a, 'pointerdown', {pageX: 0, pageY: 0}); + firePointer(document, 'pointermove', {pageX: 40, pageY: 40}); + expect(d.mouseX).toBe(40); + // mouseY was never reassigned past mousedown (axis-locked). + expect(d.mouseY).toBe(0); + expect(d.realMouseY).toBe(40); // realMouse is always tracked + }); + + it('locks to the Y axis (mouseX stays at mousedown)', () => { + const a = makeItem(); + mockRect(a, 0, 0); + const d = new BaseDrag([a], {axis: Y_AXIS, minMouseDist: 0}); + firePointer(a, 'pointerdown', {pageX: 0, pageY: 0}); + firePointer(document, 'pointermove', {pageX: 40, pageY: 40}); + expect(d.mouseY).toBe(40); + expect(d.mouseX).toBe(0); + expect(d.realMouseX).toBe(40); + }); + + it('computes mouseDistX/Y from mousedown', () => { + const a = makeItem(); + mockRect(a, 0, 0); + const d = new BaseDrag([a], {minMouseDist: 0}); + firePointer(a, 'pointerdown', {pageX: 10, pageY: 10}); + firePointer(document, 'pointermove', {pageX: 30, pageY: 25}); + expect(d.mouseDistX).toBe(20); + expect(d.mouseDistY).toBe(15); + }); + + it('ignores pointermove from a different pointerId (multi-touch)', () => { + const a = makeItem(); + mockRect(a, 0, 0); + const d = new BaseDrag([a], {minMouseDist: 0}); + firePointer(a, 'pointerdown', {pageX: 0, pageY: 0, pointerId: 1}); + firePointer(document, 'pointermove', {pageX: 40, pageY: 40, pointerId: 2}); + // The second pointer is ignored — no coords updated. + expect(d.mouseX).toBe(0); + expect(d.dragging).toBe(false); + }); +}); + +describe('BaseDrag pointer-up / stop', () => { + it('stops dragging and clears the target on pointerup', () => { + const a = makeItem(); + mockRect(a, 0, 0); + const onStop = vi.fn(); + const d = new BaseDrag([a], {minMouseDist: 0, onDragStop: onStop}); + firePointer(a, 'pointerdown', {pageX: 0, pageY: 0}); + firePointer(document, 'pointermove', {pageX: 20, pageY: 0}); + expect(d.dragging).toBe(true); + + firePointer(document, 'pointerup', {pageX: 20, pageY: 0}); + expect(d.dragging).toBe(false); + expect(d.$targetItem).toBeNull(); + expect(onStop).toHaveBeenCalled(); + }); + + it('treats pointercancel like pointerup', () => { + const a = makeItem(); + mockRect(a, 0, 0); + const d = new BaseDrag([a], {minMouseDist: 0}); + firePointer(a, 'pointerdown', {pageX: 0, pageY: 0}); + firePointer(document, 'pointermove', {pageX: 20, pageY: 0}); + firePointer(document, 'pointercancel', {pageX: 20, pageY: 0}); + expect(d.dragging).toBe(false); + expect(d.$targetItem).toBeNull(); + }); +}); + +describe('BaseDrag event hooks', () => { + it('onBeforeDragStart fires synchronously; trigger + callback', () => { + const d = new BaseDrag(null, {}); + const onBefore = vi.fn(); + d.settings!.onBeforeDragStart = onBefore; + const triggered = vi.fn(); + d.on('beforeDragStart', triggered); + d.onBeforeDragStart(); + expect(triggered).toHaveBeenCalled(); + expect(onBefore).toHaveBeenCalled(); + }); + + it('onDragStart / onDrag / onDragStop emit their events (RAF stubbed sync)', () => { + const d = new BaseDrag(); + const start = vi.fn(); + const drag = vi.fn(); + const stop = vi.fn(); + d.on('dragStart', start); + d.on('drag', drag); + d.on('dragStop', stop); + d.onDragStart(); + d.onDrag(); + d.onDragStop(); + expect(start).toHaveBeenCalled(); + expect(drag).toHaveBeenCalled(); + expect(stop).toHaveBeenCalled(); + }); + + it('startDragging mutes activate events; stopDragging unmutes them', () => { + const a = makeItem(); + mockRect(a, 0, 0); + const d = new BaseDrag([a], {minMouseDist: 0}); + firePointer(a, 'pointerdown', {pageX: 0, pageY: 0}); + firePointer(document, 'pointermove', {pageX: 20, pageY: 0}); + expect(globals.activateEventsMuted).toBe(true); + firePointer(document, 'pointerup', {pageX: 20, pageY: 0}); + // stopDragging schedules the unmute in a RAF (stubbed synchronous here). + expect(globals.activateEventsMuted).toBe(false); + }); +}); + +describe('DragMove', () => { + it('positions the target at the cursor minus the grab offset', () => { + const a = makeItem(); + const d = new DragMove(); + d.$targetItem = a; + d.mouseX = 100; + d.mouseY = 80; + d.mouseOffsetX = 10; + d.mouseOffsetY = 5; + d.onDrag(); + expect(a.style.left).toBe('90px'); + expect(a.style.top).toBe('75px'); + }); + + it('still emits the drag event (Option A: calls super.onDrag)', () => { + const a = makeItem(); + const d = new DragMove(); + d.$targetItem = a; + d.mouseX = 0; + d.mouseY = 0; + d.mouseOffsetX = 0; + d.mouseOffsetY = 0; + const drag = vi.fn(); + d.on('drag', drag); + d.onDrag(); + expect(drag).toHaveBeenCalled(); + }); + + it('subtracts page scroll for a position:fixed target (no scroll-jump)', () => { + // A fixed element's containing block is the viewport (anchored at the page + // scroll offset), so a page-coordinate target must have the scroll + // subtracted — otherwise the element jumps down by scrollY on the first drag + // frame. Repro of the draggable-Modal "jumps down the page" bug. + const a = makeItem(); + vi.spyOn(window, 'getComputedStyle').mockReturnValue({ + position: 'fixed', + } as CSSStyleDeclaration); + Object.defineProperty(window, 'scrollX', {value: 0, configurable: true}); + Object.defineProperty(window, 'scrollY', {value: 300, configurable: true}); + + const d = new DragMove(); + d.$targetItem = a; + d.mouseX = 100; // pageX + d.mouseY = 380; // pageY = viewport 80 + scrollY 300 + d.mouseOffsetX = 10; + d.mouseOffsetY = 5; + d.onDrag(); + + // left: 100 - 10 - 0 = 90; top: 380 - 5 - 300 = 75 (viewport-relative). + expect(a.style.left).toBe('90px'); + expect(a.style.top).toBe('75px'); + + Object.defineProperty(window, 'scrollX', {value: 0, configurable: true}); + Object.defineProperty(window, 'scrollY', {value: 0, configurable: true}); + }); +}); + +describe('getScrollParent (axis-aware)', () => { + it('returns window when no scrollable ancestor exists', () => { + const a = makeItem(); + expect(getScrollParent(a)).toBe(win); + expect(isWindowScrollContainer(getScrollParent(a))).toBe(true); + }); + + it('returns a vertically-scrollable ancestor for the y axis', () => { + const container = document.createElement('div'); + const child = document.createElement('div'); + container.appendChild(child); + document.body.appendChild(container); + + vi.spyOn(window, 'getComputedStyle').mockReturnValue({ + overflow: 'auto', + overflowX: 'visible', + overflowY: 'auto', + } as CSSStyleDeclaration); + Object.defineProperty(container, 'scrollHeight', { + value: 500, + configurable: true, + }); + Object.defineProperty(container, 'clientHeight', { + value: 100, + configurable: true, + }); + Object.defineProperty(container, 'scrollWidth', { + value: 100, + configurable: true, + }); + Object.defineProperty(container, 'clientWidth', { + value: 100, + configurable: true, + }); + + expect(getScrollParent(child, Y_AXIS)).toBe(container); + }); + + it('skips a container not scrollable on the requested axis', () => { + const container = document.createElement('div'); + const child = document.createElement('div'); + container.appendChild(child); + document.body.appendChild(container); + + // overflow auto, but only vertically scrollable. + vi.spyOn(window, 'getComputedStyle').mockReturnValue({ + overflow: 'auto', + overflowX: 'auto', + overflowY: 'auto', + } as CSSStyleDeclaration); + Object.defineProperty(container, 'scrollHeight', { + value: 500, + configurable: true, + }); + Object.defineProperty(container, 'clientHeight', { + value: 100, + configurable: true, + }); + Object.defineProperty(container, 'scrollWidth', { + value: 100, + configurable: true, + }); + Object.defineProperty(container, 'clientWidth', { + value: 100, + configurable: true, + }); + + // Asking for X-axis scrollability: container isn't horizontally scrollable, + // and there is no other scrollable ancestor → window. + expect(getScrollParent(child, X_AXIS)).toBe(win); + }); + + it('normalizes / to the window', () => { + const a = makeItem(); // parented to + // has no overflow scroll; getScrollParent climbs to body → window. + expect(getScrollParent(a)).toBe(win); + }); +}); + +describe('BaseDrag auto-scroll decision (window edges)', () => { + // Drive `_computeEdgeScroll` (private) via `drag(true)` after setting up state. + // We assert the public scroll* fields it populates. + function setupDraggingOnWindow(): BaseDrag { + const a = makeItem(); + mockRect(a, 0, 0); + const d = new BaseDrag([a], {minMouseDist: 0}); + firePointer(a, 'pointerdown', {pageX: 0, pageY: 0}); + // Now dragging; container resolves to window (no scrollable ancestor). + return d; + } + + it('claims a downward scroll when the pointer nears the bottom edge', () => { + const d = setupDraggingOnWindow(); + // Window: innerHeight, scrollY known from happy-dom (0). Edge band = 25px. + const innerHeight = win.innerHeight; + // Put mouseY past (innerHeight - 25) so it triggers a down-scroll. + firePointer(document, 'pointermove', { + pageX: 0, + pageY: innerHeight - 5, + }); + expect(d.scrollProperty).toBe('scrollTop'); + expect(d.scrollAxis).toBe('Y'); + expect(d.scrollDist).toBeGreaterThan(0); + }); + + it('does not claim a scroll when the pointer is mid-viewport', () => { + const d = setupDraggingOnWindow(); + firePointer(document, 'pointermove', { + pageX: Math.round(win.innerWidth / 2), + pageY: Math.round(win.innerHeight / 2), + }); + expect(d.scrollProperty).toBeNull(); + }); +}); diff --git a/packages/craftcms-garnish/tests/events.test.ts b/packages/craftcms-garnish/tests/events.test.ts new file mode 100644 index 00000000000..b677501e83a --- /dev/null +++ b/packages/craftcms-garnish/tests/events.test.ts @@ -0,0 +1,191 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest'; + +import { + ClassEventBus, + EventEmitter, + formatDomEvents, + parseEvents, + type GarnishEvent, +} from '../src/events'; + +describe('parseEvents', () => { + it('splits on spaces by default and on the first dot', () => { + expect(parseEvents('click.ns mousedown')).toEqual([ + {type: 'click', namespace: 'ns'}, + {type: 'mousedown', namespace: null}, + ]); + }); + + it('only splits on the first dot', () => { + expect(parseEvents('click.a.b')).toEqual([ + {type: 'click', namespace: 'a.b'}, + ]); + }); + + it('splits on commas and trims when asked (addListener grammar)', () => { + expect(parseEvents('click, drag.ns', ',')).toEqual([ + {type: 'click', namespace: null}, + {type: 'drag', namespace: 'ns'}, + ]); + }); + + it('supports the empty-type / namespace-only form for off', () => { + expect(parseEvents('.focus-trap')).toEqual([ + {type: '', namespace: 'focus-trap'}, + ]); + }); +}); + +describe('formatDomEvents', () => { + it('appends the namespace to each comma-split token', () => { + expect(formatDomEvents('click,drag', '.Garnish123')).toBe( + 'click.Garnish123 drag.Garnish123' + ); + }); +}); + +describe('EventEmitter', () => { + let emitter: EventEmitter<{id: string}>; + const target = {id: 'host'}; + + beforeEach(() => { + emitter = new EventEmitter(target); + }); + + it('on/trigger invokes handlers with type + target', () => { + const events: GarnishEvent[] = []; + emitter.on('foo', (ev) => events.push(ev)); + emitter.trigger('foo'); + expect(events).toHaveLength(1); + expect(events[0]!.type).toBe('foo'); + expect(events[0]!.target).toBe(target); + }); + + it('on with a data object merges per-registration data', () => { + let received: GarnishEvent | undefined; + emitter.on('foo', {a: 1}, (ev) => (received = ev)); + emitter.trigger('foo'); + expect(received!.data).toEqual({a: 1}); + }); + + it('trigger precedence: trigger data wins over registration data; type/target always win', () => { + let received: GarnishEvent | undefined; + emitter.on('foo', {a: 1}, (ev) => (received = ev)); + // trigger-time data tries to override `type`, `target`, and a data key. + emitter.trigger('foo', {a: 2, type: 'evil', target: 'evil'}); + // trigger data `a` wins; type/target are forced back. + expect(received!.a).toBe(2); + expect(received!.type).toBe('foo'); + expect(received!.target).toBe(target); + // registration data is still present under `.data`. + expect(received!.data).toEqual({a: 1}); + }); + + it('off by type removes all handlers of that type', () => { + const fn = vi.fn(); + emitter.on('foo', fn); + emitter.on('foo', fn); + emitter.off('foo'); + emitter.trigger('foo'); + expect(fn).not.toHaveBeenCalled(); + }); + + it('off by handler removes only the matching registration', () => { + const a = vi.fn(); + const b = vi.fn(); + emitter.on('foo', a); + emitter.on('foo', b); + emitter.off('foo', a); + emitter.trigger('foo'); + expect(a).not.toHaveBeenCalled(); + expect(b).toHaveBeenCalledTimes(1); + }); + + it('off by namespace only removes that namespace; empty namespace removes all', () => { + const a = vi.fn(); + const b = vi.fn(); + emitter.on('foo.ns1', a); + emitter.on('foo.ns2', b); + emitter.off('foo.ns1'); + emitter.trigger('foo'); + expect(a).not.toHaveBeenCalled(); + expect(b).toHaveBeenCalledTimes(1); + }); + + it('once fires exactly once and is removable by original handler', () => { + const fn = vi.fn(); + emitter.once('foo', fn); + emitter.trigger('foo'); + emitter.trigger('foo'); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('a once registration can be removed by type before firing', () => { + // Legacy wart: `once` stores a wrapper closure, so `off(events, originalFn)` + // does NOT match it (only the wrapper does). Removing by type still works. + const fn = vi.fn(); + emitter.once('foo', fn); + emitter.off('foo'); + emitter.trigger('foo'); + expect(fn).not.toHaveBeenCalled(); + }); + + it('clear removes every registration', () => { + const fn = vi.fn(); + emitter.on('foo', fn); + emitter.clear(); + emitter.trigger('foo'); + expect(fn).not.toHaveBeenCalled(); + }); +}); + +describe('ClassEventBus instanceof dispatch', () => { + class Animal {} + class Dog extends Animal {} + class Cat extends Animal {} + + let bus: ClassEventBus; + beforeEach(() => { + bus = new ClassEventBus(); + }); + + it('dispatches to handlers whose target the instance is an instanceof', () => { + const animalFn = vi.fn(); + const dogFn = vi.fn(); + const catFn = vi.fn(); + + bus.on(Animal, 'bark', animalFn); + bus.on(Dog, 'bark', dogFn); + bus.on(Cat, 'bark', catFn); + + const dog = new Dog(); + bus.dispatch(dog, 'bark', undefined); + + expect(animalFn).toHaveBeenCalledTimes(1); // dog is an Animal + expect(dogFn).toHaveBeenCalledTimes(1); // dog is a Dog + expect(catFn).not.toHaveBeenCalled(); // dog is not a Cat + }); + + it('warns and no-ops on undefined target', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + bus.on(undefined as never, 'bark', vi.fn()); + expect(warn).toHaveBeenCalledOnce(); + warn.mockRestore(); + }); + + it('off removes class-level handlers', () => { + const fn = vi.fn(); + bus.on(Animal, 'bark', fn); + bus.off(Animal, 'bark', fn); + bus.dispatch(new Dog(), 'bark', undefined); + expect(fn).not.toHaveBeenCalled(); + }); + + it('once fires once at the class level', () => { + const fn = vi.fn(); + bus.once(Animal, 'bark', fn); + bus.dispatch(new Dog(), 'bark', undefined); + bus.dispatch(new Dog(), 'bark', undefined); + expect(fn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/craftcms-garnish/tests/focusable.test.ts b/packages/craftcms-garnish/tests/focusable.test.ts new file mode 100644 index 00000000000..a1837fab6bb --- /dev/null +++ b/packages/craftcms-garnish/tests/focusable.test.ts @@ -0,0 +1,95 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest'; + +import { + getFocusableElements, + isFocusable, + isKeyboardFocusable, +} from '../src/utils/focusable'; + +// happy-dom doesn't compute layout, so offsetWidth/Height are 0. Force the +// visibility check to pass by stubbing getClientRects to report a box and +// getComputedStyle visibility to 'visible'. +function forceVisible(): void { + vi.spyOn(Element.prototype, 'getClientRects').mockImplementation( + () => [{width: 10, height: 10}] as unknown as DOMRectList + ); +} + +describe('isFocusable', () => { + let container: HTMLElement; + + beforeEach(() => { + forceVisible(); + container = document.createElement('div'); + document.body.appendChild(container); + }); + + it('treats an as focusable', () => { + const a = document.createElement('a'); + a.setAttribute('href', '/x'); + container.appendChild(a); + expect(isFocusable(a)).toBe(true); + }); + + it('treats an without href and without tabindex as NOT focusable', () => { + const a = document.createElement('a'); + container.appendChild(a); + expect(isFocusable(a)).toBe(false); + }); + + it('treats an enabled input as focusable, a disabled one as not', () => { + const input = document.createElement('input'); + container.appendChild(input); + expect(isFocusable(input)).toBe(true); + + input.disabled = true; + expect(isFocusable(input)).toBe(false); + }); + + it('treats a plain div as focusable only with a tabindex', () => { + const div = document.createElement('div'); + container.appendChild(div); + expect(isFocusable(div)).toBe(false); + + div.setAttribute('tabindex', '0'); + expect(isFocusable(div)).toBe(true); + + div.setAttribute('tabindex', '-1'); + expect(isFocusable(div)).toBe(true); // still focusable (just not by keyboard) + }); +}); + +describe('isKeyboardFocusable', () => { + beforeEach(() => forceVisible()); + + it('excludes tabindex="-1"', () => { + const div = document.createElement('div'); + div.setAttribute('tabindex', '-1'); + document.body.appendChild(div); + expect(isFocusable(div)).toBe(true); + expect(isKeyboardFocusable(div)).toBe(false); + }); + + it('includes tabindex="0"', () => { + const div = document.createElement('div'); + div.setAttribute('tabindex', '0'); + document.body.appendChild(div); + expect(isKeyboardFocusable(div)).toBe(true); + }); +}); + +describe('getFocusableElements', () => { + beforeEach(() => forceVisible()); + + it('returns focusable descendants in document order, excluding the container', () => { + const container = document.createElement('div'); + container.setAttribute('tabindex', '0'); // container itself is focusable + container.innerHTML = + 'ax'; + document.body.appendChild(container); + + const focusable = getFocusableElements(container); + const tags = focusable.map((el) => el.tagName.toLowerCase()); + expect(tags).toEqual(['a', 'button']); + }); +}); diff --git a/packages/craftcms-garnish/tests/hud.test.ts b/packages/craftcms-garnish/tests/hud.test.ts new file mode 100644 index 00000000000..445b0df695a --- /dev/null +++ b/packages/craftcms-garnish/tests/hud.test.ts @@ -0,0 +1,493 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +import {HUD} from '../src/hud'; +import {UiLayerManager} from '../src/managers/ui-layer-manager'; +import {setUiLayerManager} from '../src/managers/registry'; +import {ESC_KEY, TAB_KEY} from '../src/constants'; +import {globals} from '../src/globals'; + +// happy-dom notes: +// - No layout: `getBoundingClientRect()` returns zeros, `offsetWidth/Height` +// and `clientWidth/Height` are 0. We stub those where the positioning math +// needs known values (`mockRect` / `setContentSize`). +// - `requestAnimationFrame` is mocked onto a manual queue so the deferred +// `updateSizeAndPositionInternal` runs only when we `flushRaf()` (a synchronous +// stub is fine for HUD, but the queue keeps the scheduling observable). +// - The focusable matcher reads layout boxes, so focusable elements get their +// `getClientRects` stubbed to look visible (per the Modal/focusable tests). +vi.mock('../src/utils/animation', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + requestAnimationFrame: (cb: FrameRequestCallback): number => { + rafQueue.push(cb); + return rafQueue.length; + }, + cancelAnimationFrame: (handle: number): void => { + if (handle > 0 && handle <= rafQueue.length) { + rafQueue[handle - 1] = undefined; + } + }, + }; +}); + +let rafQueue: Array = []; + +/** Run every currently-queued RAF callback once. */ +function flushRaf(): void { + const current = rafQueue; + rafQueue = []; + for (const cb of current) { + cb?.(0); + } +} + +let manager: UiLayerManager; + +/** A visible, focusable trigger button. */ +function makeTrigger(): HTMLButtonElement { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.textContent = 'Open'; + makeVisible(btn); + document.body.appendChild(btn); + return btn; +} + +/** Stub an element's layout boxes so the focusable matcher treats it as visible. */ +function makeVisible(el: Element): void { + vi.spyOn(el, 'getClientRects').mockReturnValue([ + {width: 10, height: 10}, + ] as unknown as DOMRectList); +} + +/** Stub an element's bounding rect (positioning math). */ +function mockRect( + el: Element, + top: number, + left: number, + width = 0, + height = 0 +): void { + vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({ + top, + left, + right: left + width, + bottom: top + height, + width, + height, + x: left, + y: top, + toJSON: () => ({}), + } as DOMRect); +} + +beforeEach(() => { + manager = new UiLayerManager(); + setUiLayerManager(manager); + HUD.instances = []; + HUD.activeHUDs = {}; + rafQueue = []; + document.body.innerHTML = ''; + document.body.className = ''; + globals.scrollContainer = window; + globals.rtl = false; +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('HUD construction', () => { + it('applies defaults merged with passed settings', () => { + const hud = new HUD(makeTrigger(), { + showOnInit: false, + triggerSpacing: 25, + }); + expect(hud.settings!.triggerSpacing).toBe(25); + expect(hud.settings!.windowSpacing).toBe(10); + expect(hud.settings!.hudClass).toBe('hud'); + expect(hud.settings!.withShade).toBe(true); + expect(hud.settings!.orientations).toEqual([ + 'bottom', + 'top', + 'right', + 'left', + ]); + }); + + it('supports the param-shift form: new HUD(trigger, settings)', () => { + const hud = new HUD(makeTrigger(), { + showOnInit: false, + hudClass: 'hud custom', + }); + expect(hud.settings!.hudClass).toBe('hud custom'); + expect(hud.$main!.innerHTML).toBe(''); + }); + + it('treats a third-arg settings object as settings (full signature)', () => { + const hud = new HUD(makeTrigger(), '

    body

    ', {showOnInit: false}); + expect(hud.settings!.showOnInit).toBe(false); + expect(hud.$main!.querySelector('p')!.textContent).toBe('body'); + }); + + it('builds the hud > tip / body > mainContainer > main tree', () => { + const hud = new HUD(makeTrigger(), {showOnInit: false}); + expect(hud.$hud!.classList.contains('hud')).toBe(true); + expect(hud.$tip!.parentElement).toBe(hud.$hud); + expect(hud.$body!.tagName).toBe('FORM'); + expect(hud.$body!.parentElement).toBe(hud.$hud); + // ($body is a
    ; happy-dom hands back distinct proxies through the form, + // so assert the nesting via a selector from $hud rather than node identity.) + expect( + hud.$hud!.querySelector('form.body > .main-container > .main') + ).not.toBeNull(); + expect(hud.$main!.classList.contains('main')).toBe(true); + }); + + it('creates a shade with the configured class when withShade', () => { + const hud = new HUD(makeTrigger(), { + showOnInit: false, + shadeClass: 'custom-shade', + }); + expect(hud.$shade).not.toBeNull(); + expect(hud.$shade!.classList.contains('custom-shade')).toBe(true); + }); + + it('omits the shade when withShade is false', () => { + const hud = new HUD(makeTrigger(), {showOnInit: false, withShade: false}); + expect(hud.$shade).toBeNull(); + }); + + it('sets aria-expanded=false on the trigger', () => { + const trigger = makeTrigger(); + new HUD(trigger, {showOnInit: false}); + expect(trigger.getAttribute('aria-expanded')).toBe('false'); + }); + + it('registers itself in the static instances list', () => { + const hud = new HUD(makeTrigger(), {showOnInit: false}); + expect(HUD.instances).toContain(hud); + }); + + it('positions absolute by default (no fixed ancestor)', () => { + const hud = new HUD(makeTrigger(), {showOnInit: false}); + expect(hud.$hud!.style.position).toBe('absolute'); + expect(hud.$fixedTriggerParent).toBeNull(); + }); + + it('auto-shows when showOnInit is true (the default)', () => { + const trigger = makeTrigger(); + const hud = new HUD(trigger); + expect(hud.showing).toBe(true); + expect(trigger.getAttribute('aria-expanded')).toBe('true'); + }); +}); + +describe('HUD updateBody', () => { + it('extracts a header and footer and flags the hud', () => { + const hud = new HUD(makeTrigger(), {showOnInit: false}); + hud.updateBody( + '
    H

    mid

    ' + ); + expect(hud.$header).not.toBeNull(); + expect(hud.$footer).not.toBeNull(); + expect(hud.$hud!.classList.contains('has-header')).toBe(true); + expect(hud.$hud!.classList.contains('has-footer')).toBe(true); + // Header hoisted before the main container; footer after it. + expect(hud.$header!.nextElementSibling).toBe(hud.$mainContainer); + expect(hud.$footer!.previousElementSibling).toBe(hud.$mainContainer); + }); + + it('clears a previous header/footer when the body is replaced', () => { + const hud = new HUD(makeTrigger(), {showOnInit: false}); + hud.updateBody('
    H
    '); + expect(hud.$hud!.classList.contains('has-header')).toBe(true); + hud.updateBody('

    plain

    '); + expect(hud.$header).toBeNull(); + expect(hud.$hud!.classList.contains('has-header')).toBe(false); + }); + + it('appends node content', () => { + const hud = new HUD(makeTrigger(), {showOnInit: false}); + const node = document.createElement('span'); + node.textContent = 'hi'; + hud.updateBody(node); + expect(hud.$main!.contains(node)).toBe(true); + }); +}); + +describe('HUD show/hide', () => { + it('show() toggles showing and fires show', () => { + const hud = new HUD(makeTrigger(), {showOnInit: false}); + const onShow = vi.fn(); + hud.on('show', onShow); + hud.show(); + expect(hud.showing).toBe(true); + expect(onShow).toHaveBeenCalledOnce(); + }); + + it('invokes the onShow settings callback (registered as a handler)', () => { + const onShow = vi.fn(); + const hud = new HUD(makeTrigger(), {showOnInit: false, onShow}); + hud.show(); + expect(onShow).toHaveBeenCalledOnce(); + }); + + it('sets aria-expanded=true on show and false on hide', () => { + const trigger = makeTrigger(); + const hud = new HUD(trigger, {showOnInit: false}); + hud.show(); + expect(trigger.getAttribute('aria-expanded')).toBe('true'); + hud.hide(); + expect(trigger.getAttribute('aria-expanded')).toBe('false'); + }); + + it('show() is a no-op when already showing', () => { + const hud = new HUD(makeTrigger(), {showOnInit: false}); + hud.show(); + const onShow = vi.fn(); + hud.on('show', onShow); + hud.show(); + expect(onShow).not.toHaveBeenCalled(); + }); + + it('hide() toggles showing and fires hide', () => { + const hud = new HUD(makeTrigger(), {showOnInit: true}); + const onHide = vi.fn(); + hud.on('hide', onHide); + hud.hide(); + expect(hud.showing).toBe(false); + expect(onHide).toHaveBeenCalledOnce(); + }); + + it('hide() is a no-op when not showing', () => { + const hud = new HUD(makeTrigger(), {showOnInit: false}); + const onHide = vi.fn(); + hud.on('hide', onHide); + hud.hide(); + expect(onHide).not.toHaveBeenCalled(); + }); + + it('toggle() flips between show and hide', () => { + const hud = new HUD(makeTrigger(), {showOnInit: false}); + hud.toggle(); + expect(hud.showing).toBe(true); + hud.toggle(); + expect(hud.showing).toBe(false); + }); + + it('tracks itself in activeHUDs while showing', () => { + const hud = new HUD(makeTrigger(), {showOnInit: true}); + expect(Object.values(HUD.activeHUDs)).toContain(hud); + hud.hide(); + expect(Object.values(HUD.activeHUDs)).not.toContain(hud); + }); + + it('closeOtherHUDs hides any other open HUD on show', () => { + const a = new HUD(makeTrigger(), {showOnInit: true, closeOtherHUDs: true}); + expect(a.showing).toBe(true); + const b = new HUD(makeTrigger(), {showOnInit: true, closeOtherHUDs: true}); + expect(b.showing).toBe(true); + expect(a.showing).toBe(false); + }); + + it('hideContainer/showContainer toggle the container display', () => { + const hud = new HUD(makeTrigger(), {showOnInit: false}); + hud.hideContainer(); + expect(hud.$hud!.style.display).toBe('none'); + hud.showContainer(); + expect(hud.$hud!.style.display).toBe('block'); + }); +}); + +describe('HUD layer + Esc + shade click', () => { + it('adds a UI layer on show and removes it on hide', () => { + const hud = new HUD(makeTrigger(), {showOnInit: false}); + expect(manager.layer).toBe(0); + hud.show(); + expect(manager.layer).toBe(1); + hud.hide(); + expect(manager.layer).toBe(0); + }); + + it('closes on Escape when hideOnEsc is true', () => { + const hud = new HUD(makeTrigger(), {showOnInit: true, hideOnEsc: true}); + const ev = new KeyboardEvent('keydown', {keyCode: ESC_KEY} as never); + manager.triggerShortcut(ev); + expect(hud.showing).toBe(false); + }); + + it('does not register an Esc shortcut when hideOnEsc is false', () => { + const hud = new HUD(makeTrigger(), {showOnInit: true, hideOnEsc: false}); + const ev = new KeyboardEvent('keydown', {keyCode: ESC_KEY} as never); + manager.triggerShortcut(ev); + expect(hud.showing).toBe(true); + }); + + it('closes when the shade is clicked (hideOnShadeClick)', () => { + const hud = new HUD(makeTrigger(), { + showOnInit: true, + hideOnShadeClick: true, + }); + hud.$shade!.dispatchEvent(new MouseEvent('click', {bubbles: true})); + expect(hud.showing).toBe(false); + }); +}); + +describe('HUD submit', () => { + it('submit() fires the submit event', () => { + const hud = new HUD(makeTrigger(), {showOnInit: false}); + const onSubmit = vi.fn(); + hud.on('submit', onSubmit); + hud.submit(); + expect(onSubmit).toHaveBeenCalledOnce(); + }); + + it('a body form submit is intercepted and re-fired as submit', () => { + const hud = new HUD(makeTrigger(), {showOnInit: false}); + const onSubmit = vi.fn(); + hud.on('submit', onSubmit); + const ev = new Event('submit', {bubbles: true, cancelable: true}); + hud.$body!.dispatchEvent(ev); + expect(onSubmit).toHaveBeenCalledOnce(); + expect(ev.defaultPrevented).toBe(true); + }); + + it('invokes the onSubmit settings callback', () => { + const onSubmit = vi.fn(); + const hud = new HUD(makeTrigger(), {showOnInit: false, onSubmit}); + hud.submit(); + expect(onSubmit).toHaveBeenCalledOnce(); + }); +}); + +describe('HUD focus management', () => { + it('Tab on the trigger while showing moves focus into the HUD', () => { + const trigger = makeTrigger(); + const hud = new HUD(trigger, {showOnInit: false}); + const input = document.createElement('input'); + makeVisible(input); + hud.updateBody(input); + hud.show(); + + const ev = new KeyboardEvent('keydown', {keyCode: TAB_KEY} as never); + trigger.dispatchEvent(ev); + expect(document.activeElement).toBe(input); + }); + + it('restores focus to the trigger on hide when focus was inside', () => { + const trigger = makeTrigger(); + const hud = new HUD(trigger, {showOnInit: false}); + const input = document.createElement('input'); + makeVisible(input); + hud.updateBody(input); + hud.show(); + input.focus(); + expect(document.activeElement).toBe(input); + hud.hide(); + expect(document.activeElement).toBe(trigger); + }); +}); + +describe('HUD updateSizeAndPosition (mocked layout)', () => { + /** Build a HUD with a mocked trigger rect + body content box. */ + function buildPositioned( + triggerTop: number, + triggerLeft: number, + triggerW: number, + triggerH: number, + bodyW: number, + bodyH: number, + settings: Partial[1] & object> = {} + ): HUD { + const trigger = makeTrigger(); + mockRect(trigger, triggerTop, triggerLeft, triggerW, triggerH); + // outerWidth/Height read offsetWidth/Height — stub those too. + Object.defineProperty(trigger, 'offsetWidth', { + value: triggerW, + configurable: true, + }); + Object.defineProperty(trigger, 'offsetHeight', { + value: triggerH, + configurable: true, + }); + const hud = new HUD(trigger, {showOnInit: false, ...settings}); + // The body's content box (jQuery `.width()/.height()` → rect minus 0 pad/border). + mockRect(hud.$body!, 0, 0, bodyW, bodyH); + hud.windowWidth = 1000; + hud.windowHeight = 800; + return hud; + } + + it('picks "bottom" when there is room below the trigger', () => { + const hud = buildPositioned(100, 450, 50, 20, 200, 200); + hud.updateSizeAndPositionInternal(); + expect(hud.orientation).toBe('bottom'); + // tip points back up at the trigger. + expect(hud.$tip!.classList.contains('tip-top')).toBe(true); + }); + + it('falls through to "right" when top/bottom are too short for a tall body', () => { + // Trigger near the vertical center, against the left edge; body taller than + // top/bottom clearance but narrower than the right clearance. + const hud = buildPositioned(400, 0, 50, 20, 200, 700); + hud.updateSizeAndPositionInternal(); + expect(hud.orientation).toBe('right'); + expect(hud.$tip!.classList.contains('tip-left')).toBe(true); + }); + + it('respects the orientations preference order', () => { + const hud = buildPositioned(400, 0, 50, 20, 200, 200, { + orientations: ['right', 'bottom', 'left'], + }); + hud.updateSizeAndPositionInternal(); + expect(hud.orientation).toBe('right'); + }); + + it('sets the hud left/top and fires updateSizeAndPosition', () => { + const hud = buildPositioned(100, 450, 50, 20, 200, 200); + const onUpdate = vi.fn(); + hud.on('updateSizeAndPosition', onUpdate); + hud.updateSizeAndPositionInternal(); + expect(onUpdate).toHaveBeenCalledOnce(); + expect(hud.$hud!.style.left).not.toBe(''); + expect(hud.$hud!.style.top).not.toBe(''); + }); + + it('updateSizeAndPosition(true) schedules a deferred reposition', () => { + const hud = buildPositioned(100, 450, 50, 20, 200, 200); + const internal = vi.spyOn(hud, 'updateSizeAndPositionInternal'); + hud.updateSizeAndPosition(true); + expect(internal).not.toHaveBeenCalled(); // deferred to RAF + flushRaf(); + expect(internal).toHaveBeenCalledOnce(); + }); +}); + +describe('HUD updateRecords', () => { + it('reports a change on first measurement and stability afterward', () => { + const hud = new HUD(makeTrigger(), {showOnInit: false}); + expect(hud.updateRecords()).toBe(true); + expect(hud.updateRecords()).toBe(false); + }); +}); + +describe('HUD destroy', () => { + it('removes the hud + shade, drops from instances/activeHUDs, fires destroy', () => { + const trigger = makeTrigger(); + const hud = new HUD(trigger, {showOnInit: true}); + const onDestroy = vi.fn(); + hud.on('destroy', onDestroy); + const shade = hud.$shade!; + const container = hud.$hud!; + + hud.destroy(); + + expect(onDestroy).toHaveBeenCalledOnce(); + expect(HUD.instances).not.toContain(hud); + expect(Object.values(HUD.activeHUDs)).not.toContain(hud); + expect(document.body.contains(container)).toBe(false); + expect(document.body.contains(shade)).toBe(false); + }); +}); diff --git a/packages/craftcms-garnish/tests/index.test.ts b/packages/craftcms-garnish/tests/index.test.ts new file mode 100644 index 00000000000..5edd479e832 --- /dev/null +++ b/packages/craftcms-garnish/tests/index.test.ts @@ -0,0 +1,13 @@ +import {describe, expect, it} from 'vitest'; + +import {VERSION} from '../src/index'; + +describe('@craftcms/garnish', () => { + it('exposes a VERSION constant', () => { + expect(VERSION).toBe('0.0.0'); + }); + + it('runs in a DOM environment', () => { + expect(typeof document).toBe('object'); + }); +}); diff --git a/packages/craftcms-garnish/tests/managers.test.ts b/packages/craftcms-garnish/tests/managers.test.ts new file mode 100644 index 00000000000..fd7f2b0d484 --- /dev/null +++ b/packages/craftcms-garnish/tests/managers.test.ts @@ -0,0 +1,91 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest'; + +import {EscManager} from '../src/managers/esc-manager'; +import {UiLayerManager} from '../src/managers/ui-layer-manager'; + +describe('EscManager', () => { + it('escapeLatest pops the most recently registered handler', () => { + const mgr = new EscManager(); + const first = vi.fn(); + const second = vi.fn(); + const objA = {}; + const objB = {}; + mgr.register(objA, first); + mgr.register(objB, second); + + mgr.escapeLatest(new KeyboardEvent('keyup')); + expect(second).toHaveBeenCalledOnce(); + expect(first).not.toHaveBeenCalled(); + + mgr.escapeLatest(new KeyboardEvent('keyup')); + expect(first).toHaveBeenCalledOnce(); + }); + + it('unregister removes a handler by object', () => { + const mgr = new EscManager(); + const fn = vi.fn(); + const obj = {}; + mgr.register(obj, fn); + mgr.unregister(obj); + mgr.escapeLatest(new KeyboardEvent('keyup')); + expect(fn).not.toHaveBeenCalled(); + }); + + it('triggers `escape` on the registered object when possible', () => { + const mgr = new EscManager(); + const trigger = vi.fn(); + const obj = {trigger, handle: vi.fn()}; + mgr.register(obj, 'handle'); + mgr.escapeLatest(new KeyboardEvent('keyup')); + expect(obj.handle).toHaveBeenCalledOnce(); + expect(trigger).toHaveBeenCalledWith('escape'); + }); +}); + +describe('UiLayerManager', () => { + let mgr: UiLayerManager; + + beforeEach(() => { + mgr = new UiLayerManager(); + }); + + it('starts with a single base layer', () => { + expect(mgr.layer).toBe(0); + expect(mgr.currentLayer.$container).toBe(document.body); + }); + + it('addLayer pushes a new layer and detects modals', () => { + const container = document.createElement('div'); + container.setAttribute('aria-modal', 'true'); + mgr.addLayer(container); + expect(mgr.layer).toBe(1); + expect(mgr.currentLayer.isModal).toBe(true); + expect(mgr.highestModalLayer?.$container).toBe(container); + }); + + it('addLayer accepts an options-only call (param shift)', () => { + mgr.addLayer({bubble: true}); + expect(mgr.currentLayer.$container).toBeNull(); + expect(mgr.currentLayer.options.bubble).toBe(true); + }); + + it('removeLayer pops the top layer', () => { + mgr.addLayer(document.createElement('div')); + mgr.removeLayer(); + expect(mgr.layer).toBe(0); + }); + + it('throws when removing the base layer', () => { + expect(() => mgr.removeLayer()).toThrow(); + }); + + it('triggerShortcut fires a registered shortcut callback', () => { + const cb = vi.fn(); + mgr.registerShortcut(27, cb); + const ev = new KeyboardEvent('keydown', {keyCode: 27} as never); + vi.spyOn(ev, 'preventDefault'); + mgr.triggerShortcut(ev); + expect(cb).toHaveBeenCalledOnce(); + expect(ev.preventDefault).toHaveBeenCalled(); + }); +}); diff --git a/packages/craftcms-garnish/tests/modal.test.ts b/packages/craftcms-garnish/tests/modal.test.ts new file mode 100644 index 00000000000..4f4665d4c35 --- /dev/null +++ b/packages/craftcms-garnish/tests/modal.test.ts @@ -0,0 +1,391 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +import {Modal} from '../src/modal'; +import {BaseDrag} from '../src/drag/base-drag'; +import {DragMove} from '../src/drag-move'; +import {UiLayerManager} from '../src/managers/ui-layer-manager'; +import {setUiLayerManager} from '../src/managers/registry'; +import {ESC_KEY} from '../src/constants'; +import {globals} from '../src/globals'; + +// happy-dom does not implement `element.animate`, so Modal's `_fade` takes its +// immediate-complete fallback path — fade-in/out resolve synchronously here, +// which keeps these assertions deterministic. (Real browsers still animate.) + +let manager: UiLayerManager; + +function makeContainer(): HTMLElement { + const el = document.createElement('div'); + el.className = 'modal'; + // Give it something focusable so setFocusWithin has a target. + const input = document.createElement('input'); + input.type = 'text'; + // happy-dom has no layout; stub getClientRects so the focusable matcher + // treats the input as visible (per core impl note #7). + vi.spyOn(input, 'getClientRects').mockReturnValue([ + {width: 10, height: 10}, + ] as unknown as DOMRectList); + el.appendChild(input); + document.body.appendChild(el); + return el; +} + +beforeEach(() => { + // Fresh UI layer manager per test; register it so Modal can find it. + manager = new UiLayerManager(); + setUiLayerManager(manager); + Modal.instances = []; + Modal.visibleModal = null; + document.body.innerHTML = ''; + document.body.className = ''; +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('Modal construction', () => { + it('applies defaults merged with passed settings', () => { + const container = makeContainer(); + const modal = new Modal(container, {autoShow: false, minGutter: 25}); + expect(modal.settings!.minGutter).toBe(25); + expect(modal.settings!.hideOnEsc).toBe(true); + expect(modal.settings!.shadeClass).toBe('modal-shade'); + expect(modal.settings!.draggable).toBe(false); + }); + + it('supports the param-shift form: new Modal(settings)', () => { + const modal = new Modal({autoShow: false, closeOtherModals: true}); + expect(modal.$container).toBeNull(); + expect(modal.settings!.closeOtherModals).toBe(true); + expect(modal.settings!.autoShow).toBe(false); + }); + + it('registers itself in the static instances list', () => { + const modal = new Modal({autoShow: false}); + expect(Modal.instances).toContain(modal); + }); + + it('creates a shade element with the configured class', () => { + const modal = new Modal({autoShow: false, shadeClass: 'custom-shade'}); + expect(modal.$shade).not.toBeNull(); + expect(modal.$shade!.classList.contains('custom-shade')).toBe(true); + }); + + it('auto-shows when autoShow is true and a container is given', () => { + const container = makeContainer(); + const modal = new Modal(container, {autoShow: true}); + expect(modal.visible).toBe(true); + expect(Modal.visibleModal).toBe(modal); + }); + + it('does not auto-show with autoShow:false', () => { + const container = makeContainer(); + const modal = new Modal(container, {autoShow: false}); + expect(modal.visible).toBe(false); + }); +}); + +describe('Modal ARIA + focus', () => { + it('applies dialog/aria-modal attributes to the container', () => { + const container = makeContainer(); + new Modal(container, {autoShow: false}); + expect(container.getAttribute('role')).toBe('dialog'); + expect(container.getAttribute('aria-modal')).toBe('true'); + }); + + it('adds a live region inside the container', () => { + const container = makeContainer(); + const modal = new Modal(container, {autoShow: false}); + expect(container.contains(modal.$liveRegion)).toBe(true); + expect(modal.$liveRegion.getAttribute('role')).toBe('status'); + }); + + it('moves focus inside the container on show', () => { + const container = makeContainer(); + const input = container.querySelector('input')!; + const modal = new Modal(container, {autoShow: false}); + modal.show(); + expect(document.activeElement).toBe(input); + }); +}); + +describe('Modal show/hide', () => { + it('show() toggles visible and fires the show event', () => { + const container = makeContainer(); + const modal = new Modal(container, {autoShow: false}); + const onShow = vi.fn(); + modal.on('show', onShow); + modal.show(); + expect(modal.visible).toBe(true); + expect(onShow).toHaveBeenCalledOnce(); + expect(document.body.classList.contains('no-scroll')).toBe(true); + }); + + it('fires fadeIn after the fade completes', () => { + const container = makeContainer(); + const modal = new Modal(container, {autoShow: false}); + const onFadeIn = vi.fn(); + modal.on('fadeIn', onFadeIn); + modal.show(); + expect(onFadeIn).toHaveBeenCalledOnce(); + }); + + it('calls the onShow settings callback', () => { + const container = makeContainer(); + const onShow = vi.fn(); + const modal = new Modal(container, {autoShow: false, onShow}); + modal.show(); + expect(onShow).toHaveBeenCalledOnce(); + }); + + it('hide() toggles visible and fires the hide event', () => { + const container = makeContainer(); + const modal = new Modal(container, {autoShow: true}); + const onHide = vi.fn(); + modal.on('hide', onHide); + modal.hide(); + expect(modal.visible).toBe(false); + expect(onHide).toHaveBeenCalledOnce(); + expect(Modal.visibleModal).toBeNull(); + expect(document.body.classList.contains('no-scroll')).toBe(false); + }); + + it('hide() is a no-op when not visible', () => { + const container = makeContainer(); + const modal = new Modal(container, {autoShow: false}); + const onHide = vi.fn(); + modal.on('hide', onHide); + modal.hide(); + expect(onHide).not.toHaveBeenCalled(); + }); + + it('show() registers a layer and hide() removes it', () => { + const container = makeContainer(); + const modal = new Modal(container, {autoShow: false}); + expect(manager.layer).toBe(0); + modal.show(); + expect(manager.layer).toBe(1); + modal.hide(); + expect(manager.layer).toBe(0); + }); + + it('closeOtherModals hides the currently visible modal', () => { + const a = new Modal(makeContainer(), {autoShow: true}); + const b = new Modal(makeContainer(), { + autoShow: false, + closeOtherModals: true, + }); + expect(a.visible).toBe(true); + b.show(); + expect(a.visible).toBe(false); + expect(b.visible).toBe(true); + }); + + it('quickShow leaves the modal visible with full opacity', () => { + const container = makeContainer(); + const modal = new Modal(container, {autoShow: false}); + modal.quickShow(); + expect(modal.visible).toBe(true); + expect(container.style.opacity).toBe('1'); + }); + + it('quickHide leaves the modal hidden', () => { + const container = makeContainer(); + const modal = new Modal(container, {autoShow: true}); + modal.quickHide(); + expect(modal.visible).toBe(false); + expect(container.style.display).toBe('none'); + }); +}); + +describe('Modal ESC + shade-click closing', () => { + it('closes on ESC when hideOnEsc is true', () => { + const container = makeContainer(); + const modal = new Modal(container, {autoShow: true, hideOnEsc: true}); + const onEscape = vi.fn(); + modal.on('escape', onEscape); + + const ev = new KeyboardEvent('keydown', {keyCode: ESC_KEY} as never); + manager.triggerShortcut(ev); + + expect(onEscape).toHaveBeenCalledOnce(); + expect(modal.visible).toBe(false); + }); + + it('does not register an ESC shortcut when hideOnEsc is false', () => { + const container = makeContainer(); + const modal = new Modal(container, {autoShow: true, hideOnEsc: false}); + + const ev = new KeyboardEvent('keydown', {keyCode: ESC_KEY} as never); + manager.triggerShortcut(ev); + + expect(modal.visible).toBe(true); + }); + + it('closes when the shade is clicked (hideOnShadeClick)', () => { + const container = makeContainer(); + const modal = new Modal(container, { + autoShow: true, + hideOnShadeClick: true, + }); + modal.$shade!.dispatchEvent(new MouseEvent('click', {bubbles: true})); + expect(modal.visible).toBe(false); + }); + + it('does not close on shade click when hideOnShadeClick is false', () => { + const container = makeContainer(); + const modal = new Modal(container, { + autoShow: true, + hideOnShadeClick: false, + }); + modal.$shade!.dispatchEvent(new MouseEvent('click', {bubbles: true})); + expect(modal.visible).toBe(true); + }); +}); + +describe('Modal getWidth/getHeight/updateSizeAndPosition', () => { + it('getWidth/getHeight throw when no container is set', () => { + const modal = new Modal({autoShow: false}); + expect(() => modal.getWidth()).toThrow(); + expect(() => modal.getHeight()).toThrow(); + }); + + it('updateSizeAndPosition is a no-op without a container', () => { + const modal = new Modal({autoShow: false}); + expect(() => modal.updateSizeAndPosition()).not.toThrow(); + }); + + it('updateSizeAndPosition centers the container and fires its event', () => { + const container = makeContainer(); + const modal = new Modal(container, {autoShow: false}); + const onUpdate = vi.fn(); + modal.on('updateSizeAndPosition', onUpdate); + modal.updateSizeAndPosition(); + expect(onUpdate).toHaveBeenCalled(); + expect(container.style.left).not.toBe(''); + expect(container.style.top).not.toBe(''); + }); +}); + +describe('Modal destroy', () => { + it('removes the instance, container, and shade and fires destroy', () => { + const container = makeContainer(); + const modal = new Modal(container, {autoShow: false}); + const onDestroy = vi.fn(); + modal.on('destroy', onDestroy); + + modal.destroy(); + + expect(onDestroy).toHaveBeenCalledOnce(); + expect(Modal.instances).not.toContain(modal); + expect(document.body.contains(container)).toBe(false); + expect(document.body.contains(modal.$shade!)).toBe(false); + }); + + it('clears visibleModal if the destroyed modal was visible', () => { + const container = makeContainer(); + const modal = new Modal(container, {autoShow: true}); + expect(Modal.visibleModal).toBe(modal); + modal.destroy(); + expect(Modal.visibleModal).toBeNull(); + }); +}); + +describe('Modal draggable/resizable', () => { + it('constructs without throwing and creates a DragMove when draggable', () => { + const container = makeContainer(); + const modal = new Modal(container, {autoShow: false, draggable: true}); + expect(modal.dragger).toBeInstanceOf(DragMove); + // The container itself is the default handle (registered with the dragger). + expect(modal.dragger!.$items).toContain(container); + }); + + it('uses the dragHandleSelector as the DragMove handle when given', () => { + const container = makeContainer(); + const handle = document.createElement('div'); + handle.className = 'drag-handle'; + container.appendChild(handle); + const modal = new Modal(container, { + autoShow: false, + draggable: true, + dragHandleSelector: '.drag-handle', + }); + // The item is still the container; only the handle differs (drag from it). + expect(modal.dragger).toBeInstanceOf(DragMove); + expect(modal.dragger!.$items).toContain(container); + }); + + it('constructs without throwing and creates a BaseDrag + resize handle when resizable', () => { + const container = makeContainer(); + const modal = new Modal(container, {autoShow: false, resizable: true}); + expect(modal.resizeDragger).toBeInstanceOf(BaseDrag); + const handle = container.querySelector('.resizehandle'); + expect(handle).not.toBeNull(); + expect(modal.resizeDragger!.$items).toContain(handle); + }); + + it('does not create draggers by default', () => { + const container = makeContainer(); + const modal = new Modal(container, {autoShow: false}); + expect(modal.dragger).toBeNull(); + expect(modal.resizeDragger).toBeNull(); + }); + + it('_handleResize grows width/height symmetrically (ltr) from the start size', () => { + const container = makeContainer(); + const modal = new Modal(container, {autoShow: false, resizable: true}); + document.body.classList.remove('rtl'); + globals.rtl = false; + + vi.spyOn(modal, 'getWidth').mockReturnValue(400); + vi.spyOn(modal, 'getHeight').mockReturnValue(300); + const update = vi + .spyOn(modal, 'updateSizeAndPosition') + .mockImplementation(() => {}); + + // Drive the resize handlers directly with a fake dragger delta. + (modal as unknown as {_handleResizeStart(): void})._handleResizeStart(); + modal.resizeDragger!.mouseDistX = 10; + modal.resizeDragger!.mouseDistY = 5; + (modal as unknown as {_handleResize(): void})._handleResize(); + + expect(modal.desiredWidth).toBe(400 + 10 * 2); + expect(modal.desiredHeight).toBe(300 + 5 * 2); + expect(update).toHaveBeenCalled(); + }); + + it('_handleResize mirrors the horizontal direction in rtl', () => { + const container = makeContainer(); + const modal = new Modal(container, {autoShow: false, resizable: true}); + globals.rtl = true; + + vi.spyOn(modal, 'getWidth').mockReturnValue(400); + vi.spyOn(modal, 'getHeight').mockReturnValue(300); + vi.spyOn(modal, 'updateSizeAndPosition').mockImplementation(() => {}); + + (modal as unknown as {_handleResizeStart(): void})._handleResizeStart(); + modal.resizeDragger!.mouseDistX = 10; + modal.resizeDragger!.mouseDistY = 5; + (modal as unknown as {_handleResize(): void})._handleResize(); + + expect(modal.desiredWidth).toBe(400 - 10 * 2); + expect(modal.desiredHeight).toBe(300 + 5 * 2); + + globals.rtl = false; + }); + + it('tears down both draggers on destroy', () => { + const container = makeContainer(); + const modal = new Modal(container, { + autoShow: false, + draggable: true, + resizable: true, + }); + const dragDestroy = vi.spyOn(modal.dragger!, 'destroy'); + const resizeDestroy = vi.spyOn(modal.resizeDragger!, 'destroy'); + modal.destroy(); + expect(dragDestroy).toHaveBeenCalledOnce(); + expect(resizeDestroy).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/craftcms-garnish/tests/utils.test.ts b/packages/craftcms-garnish/tests/utils.test.ts new file mode 100644 index 00000000000..8dd976b2efd --- /dev/null +++ b/packages/craftcms-garnish/tests/utils.test.ts @@ -0,0 +1,78 @@ +import {describe, expect, it} from 'vitest'; + +import {getDist, within, isString, isTextNode} from '../src/utils/misc'; +import {getInputPostVal, getPostData, findInputs} from '../src/utils/forms'; +import {hasAttr} from '../src/utils/dom'; + +describe('misc utils', () => { + it('getDist computes Euclidean distance', () => { + expect(getDist(0, 0, 3, 4)).toBe(5); + }); + it('within clamps', () => { + expect(within(5, 0, 10)).toBe(5); + expect(within(-1, 0, 10)).toBe(0); + expect(within(11, 0, 10)).toBe(10); + }); + it('isString', () => { + expect(isString('x')).toBe(true); + expect(isString(1)).toBe(false); + }); + it('isTextNode', () => { + expect(isTextNode(document.createTextNode('x'))).toBe(true); + expect(isTextNode(document.createElement('div'))).toBe(false); + }); +}); + +describe('dom utils', () => { + it('hasAttr reflects attribute presence', () => { + const el = document.createElement('a'); + expect(hasAttr(el, 'href')).toBe(false); + el.setAttribute('href', '/x'); + expect(hasAttr(el, 'href')).toBe(true); + }); +}); + +describe('forms utils', () => { + it('getInputPostVal returns null for an unchecked checkbox', () => { + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.value = 'yes'; + expect(getInputPostVal(cb)).toBeNull(); + cb.checked = true; + expect(getInputPostVal(cb)).toBe('yes'); + }); + + it('findInputs collects inputs within a container', () => { + const container = document.createElement('div'); + container.innerHTML = + ''; + expect(findInputs(container)).toHaveLength(4); + }); + + it('getPostData serializes name[] arrays with indexing', () => { + const container = document.createElement('div'); + const i1 = document.createElement('input'); + i1.name = 'tags[]'; + i1.value = 'x'; + const i2 = document.createElement('input'); + i2.name = 'tags[]'; + i2.value = 'y'; + container.appendChild(i1); + container.appendChild(i2); + expect(getPostData(container)).toEqual({'tags[0]': 'x', 'tags[1]': 'y'}); + }); + + it('getPostData skips disabled and unnamed inputs', () => { + const container = document.createElement('div'); + const named = document.createElement('input'); + named.name = 'a'; + named.value = '1'; + const disabled = document.createElement('input'); + disabled.name = 'b'; + disabled.value = '2'; + disabled.disabled = true; + container.appendChild(named); + container.appendChild(disabled); + expect(getPostData(container)).toEqual({a: '1'}); + }); +}); diff --git a/packages/craftcms-garnish/tsconfig.json b/packages/craftcms-garnish/tsconfig.json new file mode 100644 index 00000000000..5f320ebf345 --- /dev/null +++ b/packages/craftcms-garnish/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "@total-typescript/tsconfig/bundler/dom", + "compilerOptions": { + "outDir": "./dist", + "rootDir": ".", + "types": ["node", "vitest/globals"], + "paths": { + "@src/*": ["./src/*"] + } + }, + "include": [ + "src", + "tests", + "stories", + ".storybook", + "tsdown.config.ts", + "vitest.config.ts" + ] +} diff --git a/packages/craftcms-garnish/tsdown.config.ts b/packages/craftcms-garnish/tsdown.config.ts new file mode 100644 index 00000000000..793d08f15cc --- /dev/null +++ b/packages/craftcms-garnish/tsdown.config.ts @@ -0,0 +1,33 @@ +import {defineConfig} from 'tsdown'; + +export default defineConfig({ + // Multiple entry points so consumers can opt into the legacy compatibility + // layer separately from the modern core. tsdown/Rolldown will code-split + // shared chunks between these automatically. + entry: { + index: './src/index.ts', + compat: './src/compat.ts', + }, + format: ['esm'], + target: 'es2022', + platform: 'browser', + // Emit TypeScript declarations alongside the JS output. + dts: true, + sourcemap: true, + // Clean dist before each build. + clean: true, + // Tree-shakeable, ESM-only output. + treeshake: true, + // Keep readable output during the scaffold phase; flip on for releases. + minify: false, + // No external deps yet — this is a zero-dependency library by design + // (explicitly no jQuery). List peerDependencies under `deps.neverBundle` + // as they appear so they are not inlined into the bundle. + // jQuery is an OPTIONAL peer dependency of the `./compat` entry only. It is + // resolved at runtime from the global scope, never imported, so it can't leak + // into the bundle — but list it here so the build never inlines it should a + // future `import 'jquery'` be added. + deps: { + neverBundle: ['jquery'], + }, +}); diff --git a/packages/craftcms-garnish/vitest.config.ts b/packages/craftcms-garnish/vitest.config.ts new file mode 100644 index 00000000000..dbaee5d6c12 --- /dev/null +++ b/packages/craftcms-garnish/vitest.config.ts @@ -0,0 +1,9 @@ +import {defineConfig} from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'happy-dom', + include: ['tests/**/*.{test,spec}.ts'], + }, +}); diff --git a/packages/craftcms-legacy/cp/src/Craft.js b/packages/craftcms-legacy/cp/src/Craft.js index 5e31a09b3c4..2124da41d0e 100644 --- a/packages/craftcms-legacy/cp/src/Craft.js +++ b/packages/craftcms-legacy/cp/src/Craft.js @@ -69,7 +69,6 @@ import './js/EntrySelectInput.js'; import './js/EntryTypeSelectInput.js'; import './js/EnvVarGenerator.js'; import './js/EntryMover.js'; -import './js/FieldLayoutDesigner.js'; import './js/FormObserver.js'; import './js/VolumeFolderSelectorModal.js'; import './js/FieldToggle.js'; diff --git a/packages/craftcms-legacy/cp/src/css/craft.scss b/packages/craftcms-legacy/cp/src/css/craft.scss index ff419da226b..105f235f9bd 100644 --- a/packages/craftcms-legacy/cp/src/css/craft.scss +++ b/packages/craftcms-legacy/cp/src/css/craft.scss @@ -7,19 +7,17 @@ @import 'icon-picker'; @import 'login'; -.cp-legacy { - @import 'main'; - @import 'cp'; - @import 'range'; - @import 'global-sidebar'; - @import 'craft-disclosure'; - @import 'craft-spinner'; - @import 'craft-tooltip'; - @import 'preview'; - @import 'entry-type-select'; - @import 'fld'; - @import 'grouped-entry-type-select'; - @import 'image_editor'; - @import 'shame'; - @import 'debug_toolbar'; -} +@import 'main'; +@import 'cp'; +@import 'range'; +@import 'global-sidebar'; +@import 'craft-disclosure'; +@import 'craft-spinner'; +@import 'craft-tooltip'; +@import 'preview'; +@import 'entry-type-select'; +@import 'fld'; +@import 'grouped-entry-type-select'; +@import 'image_editor'; +@import 'shame'; +@import 'debug_toolbar'; diff --git a/packages/craftcms-legacy/cp/src/js/FieldLayoutDesigner.js b/packages/craftcms-legacy/cp/src/js/FieldLayoutDesigner.js deleted file mode 100644 index cc9a7f4f81e..00000000000 --- a/packages/craftcms-legacy/cp/src/js/FieldLayoutDesigner.js +++ /dev/null @@ -1,2195 +0,0 @@ -/** global: Craft */ -/** global: Garnish */ -/** global: $ */ -/** global: jQuery */ - -Craft.FieldLayoutDesigner = Garnish.Base.extend( - { - $container: null, - $innerContainer: null, - $configInput: null, - $tabContainer: null, - $newTabBtn: null, - $libraryContainer: null, - $selectedLibrary: null, - $fieldLibrary: null, - $uiLibrary: null, - $uiLibraryElements: null, - $fieldSearch: null, - $clearFieldSearchBtn: null, - $fieldGroups: null, - $fields: null, - $createFieldBtn: null, - - libraryPicker: null, - tabGrid: null, - elementDrag: null, - - cvd: null, - $cvd: null, - - _config: null, - _$selectedFields: null, - - init: function (container, settings) { - this.$container = $(container); - this.setSettings(settings, Craft.FieldLayoutDesigner.defaults); - - this.$configInput = this.$container.children('input[data-config-input]'); - this._config = JSON.parse(this.$configInput.val()); - if (!this._config.tabs) { - this._config.tabs = []; - } - - this._fieldHandles = {}; - - this.$innerContainer = this.$container.children('.fld-container'); - const $workspace = this.$innerContainer.children('.fld-workspace'); - this.$tabContainer = $workspace.children('.fld-tabs'); - this.$newTabBtn = $workspace.children('.fld-new-tab-btn'); - this.$libraryContainer = this.$innerContainer.children('.fld-library'); - - this.$fieldLibrary = this.$selectedLibrary = - this.$libraryContainer.children('.fld-field-library'); - let $fieldSearchContainer = this.$fieldLibrary.children('.search'); - this.$fieldSearch = $fieldSearchContainer.children('input'); - this.$clearFieldSearchBtn = $fieldSearchContainer.children('.clear-btn'); - this.$fieldGroups = this.$libraryContainer.find('.fld-field-group'); - this.$fields = this.$fieldGroups.children('.fld-element'); - this.$uiLibrary = this.$libraryContainer.children('.fld-ui-library'); - this.$uiLibraryElements = this.$uiLibrary.children(); - - if (this.settings.readOnly) { - this.$fieldLibrary.attr('tabindex', '-1'); - } - // Set up the layout grids - this.tabGrid = new Craft.Grid(this.$tabContainer, { - itemSelector: '.fld-tab', - minColWidth: 24 * 13, - fillMode: 'grid', - snapToGrid: 24, - }); - - let $tabs = this.$tabContainer.children(); - for (let i = 0; i < $tabs.length; i++) { - this.initTab($($tabs[i])); - } - - this.elementDrag = new Craft.FieldLayoutDesigner.ElementDrag(this); - if (!this.settings.readOnly) { - this.initLibraryElements(this.$libraryContainer.find('.fld-element')); - } - - if (this.settings.customizableTabs) { - this.tabDrag = new Craft.FieldLayoutDesigner.TabDrag(this); - - this.addListener(this.$newTabBtn, 'activate', 'addTab'); - } - - // Set up the library - if (this.settings.customizableUi) { - const $libraryPicker = this.$libraryContainer.children('.btngroup'); - this.libraryPicker = new Craft.Listbox($libraryPicker, { - onChange: ($selectedOption) => { - const library = $selectedOption.data('library'); - switch (library) { - case 'field': - this.$fieldLibrary.removeClass('hidden'); - this.$uiLibrary.addClass('hidden'); - this.$createFieldBtn.removeClass('hidden'); - break; - case 'ui': - this.$fieldLibrary.addClass('hidden'); - this.$uiLibrary.removeClass('hidden'); - this.$createFieldBtn.addClass('hidden'); - break; - } - }, - }); - } - - this.addListener(this.$fieldSearch, 'input', () => { - this.updateFieldSearchResults(); - }); - - this.addListener(this.$fieldSearch, 'keydown', (ev) => { - switch (ev.keyCode) { - case Garnish.ESC_KEY: - this.$fieldSearch.val('').trigger('input'); - break; - case Garnish.RETURN_KEY: - // they most likely don't want to submit the form from here - ev.preventDefault(); - break; - } - }); - - // Clear the search when the X button is clicked - this.addListener(this.$clearFieldSearchBtn, 'click', () => { - this.clearSearch(); - }); - - this.refreshSelectedFields(); - - // Add the “New Field” button - this.$createFieldBtn = Craft.ui - .createButton({ - label: Craft.t('app', 'New field'), - class: 'mt-m fullwidth add icon dashed', - disabled: this.settings.readOnly, - }) - .appendTo(this.$libraryContainer); - - this.addListener(this.$createFieldBtn, 'activate', async () => { - this.createField(); - }); - - // initiate Card Attributes Designer - if (this.settings.withCardViewDesigner) { - this.$cvd = this.$container - .parents('.fld-cvd') - .find('.card-view-designer'); - this.initCvd(); - } - }, - - updateFieldSearchResults() { - const val = this.$fieldSearch.val().toLowerCase().replace(/['"]/g, ''); - if (!val) { - this.$fieldLibrary.find('.filtered').removeClass('filtered'); - this.$clearFieldSearchBtn.addClass('hidden'); - return; - } - - this.$clearFieldSearchBtn.removeClass('hidden'); - const $matches = this.$fields - .filter(`[data-keywords*="${val}"]`) - .add( - this.$fieldGroups - .filter(`[data-name*="${val}"]`) - .children('.fld-element') - ) - .removeClass('filtered'); - this.$fields.not($matches).addClass('filtered'); - - // hide any groups that don't have any results - for (let i = 0; i < this.$fieldGroups.length; i++) { - const $group = this.$fieldGroups.eq(i); - if ($group.find('.fld-element:not(.hidden):not(.filtered)').length) { - $group.removeClass('filtered'); - } else { - $group.addClass('filtered'); - } - } - }, - - clearSearch: function () { - this.$fieldSearch.val('').trigger('input'); - }, - - initCvd: function () { - this.cvd = new Craft.FieldLayoutDesigner.CardViewDesigner( - this, - this.$cvd - ); - - // Add skip link - const skipLinkAnchor = this.cvd.$container.attr('id'); - - if (skipLinkAnchor) { - const $skipLink = $('', { - class: 'skip-link btn', - text: Craft.t('app', 'Skip to card view designer'), - href: `#${skipLinkAnchor}`, - }); - - this.cvd.$container.attr('tabindex', '-1'); - this.$innerContainer.prepend($skipLink); - } - }, - - initTab: function ($tab) { - return new Craft.FieldLayoutDesigner.Tab(this, $tab); - }, - - removeFieldByHandle: function (attribute) { - this.$fields - .filter(`[data-attribute="${attribute}"]:first`) - .removeClass('hidden') - .closest('.fld-field-group') - .removeClass('hidden'); - }, - - addTab: function () { - if (!this.settings.customizableTabs) { - return; - } - - let defaultValue = ''; - if (this.tabGrid.$items.length === 0) { - defaultValue = Craft.t('app', 'Content'); - } - const name = Craft.escapeHtml( - prompt(Craft.t('app', 'Give your tab a name.'), defaultValue) - ); - - if (!name) { - return; - } - - const $tab = $(` -
    -
    -
    -

    ${name}

    -
    -
    -
    - -
    -
    -`); - // keep it before the resize object - const $lastTab = this.$tabContainer.children('.fld-tab:last'); - if ($lastTab.length) { - $tab.insertAfter($lastTab); - } else { - $tab.prependTo(this.$tabContainer); - } - - this.tabGrid.addItems($tab); - this.tabDrag.addItems($tab); - - const tab = this.initTab($tab); - tab.updatePositionInConfig(); - }, - - get config() { - return this._config; - }, - - set config(config) { - this._config = config; - this.$configInput.val(JSON.stringify(config)); - }, - - updateConfig: function (callback) { - const config = callback(this.config); - if (config !== false) { - this.config = config; - } - }, - - refreshSelectedFields: function () { - this._$selectedFields = this.$tabContainer.find('.fld-field'); - }, - - refreshLibraryFields() { - this.$fields = this.$fieldGroups.children('.fld-element'); - - for (let i = 0; i < this.$fieldGroups.length; i++) { - const $fieldGroup = this.$fieldGroups.eq(i); - const $fields = $fieldGroup.children('.fld-element'); - $fields - .sort((a, b) => { - return $(a).data('ui-label') > $(b).data('ui-label') ? 1 : -1; - }) - .appendTo($fieldGroup); - } - - this.updateFieldSearchResults(); - }, - - hasHandle: function (handle) { - for (let i = 0; i < this._$selectedFields.length; i++) { - const element = this._$selectedFields.eq(i).data('fld-element'); - const elementHandle = element.config.handle || element.attribute; - if (handle === elementHandle) { - return true; - } - } - - return false; - }, - - createField() { - const slideout = new Craft.CpScreenSlideout('fields/edit-field'); - - slideout.on('submit', async ({response}) => { - // add the library selector - const $selector = $(response.data.selectorHtml); - this.$fieldGroups.last().append($selector).removeClass('hidden'); - this.refreshLibraryFields(); - this.initLibraryElements($selector); - - // add it to the active tab - this.addLibraryElementToActiveTab($selector); - - Garnish.requestAnimationFrame(() => { - this.getActiveHud()?.hide(); - }); - }); - }, - - initLibraryElements($elements) { - this.elementDrag.addItems($elements); - - this.addListener($elements, 'activate', (ev) => { - // ignore if is on a dragged element - if (ev.currentTarget.style.visibility === 'hidden') { - return; - } - ev.stopPropagation(); - ev.originalEvent.preventDefault(); - this.addLibraryElementToActiveTab(ev.currentTarget); - }); - }, - - cloneLibraryElementForSelection(libraryElement) { - // Create a new element based on that one - const $libraryElement = $(libraryElement); - const $element = $libraryElement - .clone() - .removeClass('unused') - .removeAttr('tabindex'); - - if (!Garnish.hasAttr($libraryElement, 'data-is-multi-instance')) { - // Hide the library element - $libraryElement - .css({visibility: 'inherit', display: 'field'}) - .addClass('hidden'); - - // Hide the group too? - if ($libraryElement.siblings('.fld-field:not(.hidden)').length === 0) { - $libraryElement.closest('.fld-field-group').addClass('hidden'); - } - } - - // Add it to the element dragger - this.elementDrag.addItems($element); - - return $element; - }, - - getActiveHud: function () { - return this.$libraryContainer.closest('.fld-library-hud').data('hud'); - }, - - addLibraryElementToActiveTab: function (libraryElement) { - const hud = this.getActiveHud(); - if (!hud) { - return; - } - - const $element = this.cloneLibraryElementForSelection(libraryElement); - const tab = hud.$trigger.closest('.fld-tab').data('fld-tab'); - $element.insertBefore(hud.$trigger); - const element = tab.initElement($element); - element.onSelect(); - element.updatePositionInConfig(); - }, - }, - { - defaults: { - elementType: null, - customizableTabs: true, - customizableUi: true, - withCardViewDesigner: false, - alwaysShowThumbAlignmentBtns: false, - readOnly: false, - }, - - async createSlideout(data, js, settings = {}) { - const $body = $('
    ', {class: 'fld-element-settings-body'}); - $('
    ', {class: 'fields', html: data.settingsHtml}).appendTo($body); - const $footer = $('
    ', {class: 'fld-element-settings-footer'}); - $('
    ', {class: 'flex-grow'}).appendTo($footer); - const $cancelBtn = Craft.ui - .createButton({ - label: Craft.t('app', 'Close'), - spinner: true, - }) - .appendTo($footer); - Craft.ui - .createSubmitButton({ - class: 'secondary', - label: Craft.t('app', 'Apply'), - spinner: true, - }) - .appendTo($footer); - const $contents = $body.add($footer); - - const slideout = new Craft.Slideout( - $contents, - Object.assign( - { - containerElement: 'form', - containerAttributes: { - action: '', - method: 'post', - novalidate: '', - class: 'fld-element-settings', - }, - }, - settings - ) - ); - slideout.on('open', () => { - // Hold off a sec until it's positioned... - Garnish.requestAnimationFrame(() => { - // Focus on the first text input - slideout.$container.find('.text:first').focus(); - }); - }); - - $cancelBtn.on('click', () => { - slideout.close(); - }); - - if (data.headHtml) { - await Craft.appendHeadHtml(data.headHtml); - } - if (data.bodyHtml) { - await Craft.appendBodyHtml(data.bodyHtml); - } - if (js) { - eval(js); - } - - Craft.initUiElements(slideout.$container); - - return slideout; - }, - } -); - -Craft.FieldLayoutDesigner.Tab = Garnish.Base.extend({ - designer: null, - uid: null, - $container: null, - $addBtn: null, - slideout: null, - destroyed: false, - - init: function (designer, $container) { - this.designer = designer; - this.$container = $container; - this.$container.data('fld-tab', this); - this.uid = this.$container.data('uid'); - - // New tab? - if (!this.uid) { - this.uid = Craft.uuid(); - this.config = { - uid: this.uid, - name: this.$container.find('.tabs .tab .fld-tab__name').text(), - elements: [], - }; - } - - if (this.designer.settings.customizableTabs) { - this.createMenu(); - } - - // initialize the elements - const $tabContent = this.$container.children('.fld-tabcontent'); - - $tabContent.on('resize', () => { - this.designer.tabGrid.refreshCols(true); - }); - - this.$addBtn = $tabContent.children('.fld-add-btn'); - - const hud = new Garnish.HUD(this.$addBtn, { - hudClass: 'hud fld-library-hud cp-legacy', - listenToMainResize: false, - showOnInit: false, - orientations: ['right', 'bottom', 'left'], - }); - hud.on('show', () => { - this.designer.$libraryContainer.appendTo(hud.$main); - this.designer.libraryPicker?.select(0); - this.designer.$fieldSearch.focus(); - this.designer.clearSearch(); - this.designer.$fieldLibrary.scrollTop(0); - }); - hud.on('hide', () => { - this.$addBtn.focus(); - }); - - this.$addBtn.on('activate', () => { - hud.show(); - }); - - const $elements = $tabContent.children().not(this.$addBtn); - - for (let i = 0; i < $elements.length; i++) { - this.initElement($($elements[i])); - } - }, - - createMenu: function () { - const $tab = this.$container.find('.tabs .tab'); - const menuId = `actionmenu${Math.floor(Math.random() * 1000000)}`; - const $btn = $(' +
    +
    +`); + // keep it before the resize object + const $tabs = this.$tabContainer.querySelectorAll(':scope > .fld-tab'); + const $lastTab = $tabs[$tabs.length - 1]; + if ($lastTab) { + $lastTab.after($tab); + } else { + this.$tabContainer.prepend($tab); + } + + this.tabGrid.addItems($($tab)); + this.tabDrag!.addItems($tab); + + const tab = this.initTab($tab); + tab.updatePositionInConfig(); + } + + get config(): FieldLayoutConfig { + return this._config!; + } + + set config(config: FieldLayoutConfig) { + this._config = config; + this.$configInput.value = JSON.stringify(config); + } + + updateConfig( + callback: (config: FieldLayoutConfig) => FieldLayoutConfig | false + ): void { + const config = callback(this.config); + if (config !== false) { + this.config = config; + } + } + + refreshSelectedFields(): void { + this._$selectedFields = Array.from( + this.$tabContainer.querySelectorAll('.fld-field') + ); + } + + refreshLibraryFields(): void { + this.$fields = this.collectLibraryFields(); + + for (const $fieldGroup of this.$fieldGroups) { + const $fields = Array.from( + $fieldGroup.querySelectorAll(':scope > .fld-element') + ) as HTMLElement[]; + $fields.sort((a, b) => + (a.dataset.uiLabel ?? '') > (b.dataset.uiLabel ?? '') ? 1 : -1 + ); + for (const el of $fields) { + $fieldGroup.appendChild(el); + } + } + + this.updateFieldSearchResults(); + } + + hasHandle(handle: string): boolean { + for (const el of this._$selectedFields) { + const element = fldElementData.get(el); + if (!element) { + continue; + } + const elementHandle = element.config.handle || element.attribute; + if (handle === elementHandle) { + return true; + } + } + + return false; + } + + createField(): void { + const slideout = new Craft.CpScreenSlideout('fields/edit-field'); + + slideout.on('submit', async ({response}: any) => { + // add the library selector + const $selector = htmlToElement(response.data.selectorHtml); + const $lastGroup = this.$fieldGroups[this.$fieldGroups.length - 1]; + $lastGroup.appendChild($selector); + $lastGroup.classList.remove('hidden'); + this.refreshLibraryFields(); + this.initLibraryElements($selector); + + // add it to the active tab + this.addLibraryElementToActiveTab($selector); + + requestAnimationFrame(() => { + this.getActiveHud()?.hide(); + }); + }); + } + + initLibraryElements($elements: any): void { + this.elementDrag!.addItems($elements); + + this.addListener($elements, 'activate', (ev: any) => { + // ignore if is on a dragged element + if (ev.currentTarget.style.visibility === 'hidden') { + return; + } + ev.stopPropagation(); + ev.originalEvent.preventDefault(); + this.addLibraryElementToActiveTab(ev.currentTarget); + }); + } + + cloneLibraryElementForSelection(libraryElement: any): HTMLElement { + // Create a new element based on that one + const $libraryElement = libraryElement as HTMLElement; + const $element = $libraryElement.cloneNode(true) as HTMLElement; + $element.classList.remove('unused'); + $element.removeAttribute('tabindex'); + + if (!hasAttr($libraryElement, 'data-is-multi-instance')) { + // Hide the library element + $libraryElement.style.visibility = 'inherit'; + $libraryElement.style.display = 'field'; + $libraryElement.classList.add('hidden'); + + // Hide the group too? + const visibleSibling = Array.from( + $libraryElement.parentElement?.children ?? [] + ).some( + (el) => el !== $libraryElement && el.matches('.fld-field:not(.hidden)') + ); + if (!visibleSibling) { + $libraryElement.closest('.fld-field-group')?.classList.add('hidden'); + } + } + + // Add it to the element dragger + this.elementDrag!.addItems($element); + + return $element; + } + + getActiveHud(): any { + const hudEl = this.$libraryContainer.closest('.fld-library-hud'); + return hudEl ? hudData.get(hudEl) : undefined; + } + + addLibraryElementToActiveTab(libraryElement: any): void { + const hud = this.getActiveHud(); + if (!hud) { + return; + } + + const $element = this.cloneLibraryElementForSelection(libraryElement); + const tab = fldTabData.get(hud.$trigger.closest('.fld-tab')); + hud.$trigger.before($element); + const element = tab!.initElement($element); + element.onSelect(); + element.updatePositionInConfig(); + } + + static async createSlideout( + data: any, + js: string | null, + settings: any = {} + ): Promise { + const $body = document.createElement('div'); + $body.className = 'fld-element-settings-body'; + const $fields = document.createElement('div'); + $fields.className = 'fields'; + $fields.innerHTML = data.settingsHtml; + $body.appendChild($fields); + + const $footer = document.createElement('div'); + $footer.className = 'fld-element-settings-footer'; + const $flexGrow = document.createElement('div'); + $flexGrow.className = 'flex-grow'; + $footer.appendChild($flexGrow); + + // Craft.ui returns jQuery — unwrap to native at the seam. + const $cancelBtn = Craft.ui.createButton({ + label: Craft.t('app', 'Close'), + spinner: true, + })[0]; + $footer.appendChild($cancelBtn); + $footer.appendChild( + Craft.ui.createSubmitButton({ + class: 'secondary', + label: Craft.t('app', 'Apply'), + spinner: true, + })[0] + ); + + // Craft.Slideout is a jQuery widget — pass a jQuery wrapper at the seam. + const slideout = new Craft.Slideout( + $([$body, $footer]), + Object.assign( + { + containerElement: 'form', + containerAttributes: { + action: '', + method: 'post', + novalidate: '', + class: 'fld-element-settings', + }, + }, + settings + ) + ); + slideout.on('open', () => { + // Hold off a sec until it's positioned... + requestAnimationFrame(() => { + // Focus on the first text input + ( + slideout.$container[0].querySelector('.text') as HTMLElement | null + )?.focus(); + }); + }); + + $cancelBtn.addEventListener('click', () => { + slideout.close(); + }); + + if (data.headHtml) { + await Craft.appendHeadHtml(data.headHtml); + } + if (data.bodyHtml) { + await Craft.appendBodyHtml(data.bodyHtml); + } + if (js) { + eval(js); + } + + Craft.initUiElements(slideout.$container); + + return slideout; + } +} diff --git a/resources/js/modules/field-layout-designer/README.md b/resources/js/modules/field-layout-designer/README.md new file mode 100644 index 00000000000..65d1b91d102 --- /dev/null +++ b/resources/js/modules/field-layout-designer/README.md @@ -0,0 +1,109 @@ +# Field Layout Designer (FLD) + +A native, jQuery-free port of the legacy jQuery `Craft.FieldLayoutDesigner` +(`packages/craftcms-legacy/cp/src/js/FieldLayoutDesigner.js`) onto the modern +**`@craftcms/garnish`** package. FLD's own DOM/data work is plain DOM + WeakMaps; +jQuery (`$`) survives **only** at the boundary with Craft's still-jQuery widgets. + +## What changed + +- **Class system.** `Garnish.Base.extend({...})` → `class extends Base`, + `Garnish.Drag.extend({...})` → `class extends Drag`, `init()` → + `constructor()`, `this.base(...)` → `super.method(...)`, `new Garnish.HUD(...)` + → `new HUD(...)`. Garnish utilities/constants + (`hasAttr`, `getDist`, `hitTest`, `getOffset`, `getOuterWidth/Height`, + `firstFocusableElement`, `prefersReducedMotion`, + `getUserPreferredAnimationDuration`, `requestAnimationFrame`, `FX_DURATION`, + `ESC_KEY`, `RETURN_KEY`, `bod`) are named imports. +- **All of FLD's own jQuery is now native DOM:** + - `$('
    ')` / `$(html)` → `document.createElement` / a `