From 2cb9db7e28bd34ff0b18f99f16500f1d18582ce8 Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Wed, 17 Jun 2026 16:04:57 -0500 Subject: [PATCH 01/13] Add modernized Garnish library (TypeScript/ESM, no jQuery) as new package Introduce packages/craftcms-garnish: a modern, tree-shakeable TypeScript rewrite of the legacy jQuery-based Garnish UI library, alongside an opt-in compatibility layer so existing consumers keep working unchanged. - garnish-core: native ES-class Base, three event systems (instance EventEmitter, class-level bus, namespaced DOM listener registry), custom events (activate/textchange/resize) without $.event.special, UiLayerManager/EscManager, focusable matcher, and the full utility surface. Zero jQuery in the modern entry (dist/index.js has no jQuery references). - Modal: vertical-slice PoC component (Velocity -> Web Animations API). - compat: opt-in ./compat entry restoring Garnish.Base.extend(), this.base(), window.Garnish, and jQuery-collection args; jQuery is an optional peer dependency of this entry only. - Tooling: tsdown ESM build, Vitest (happy-dom) with 121 passing tests, a Vite playground (npm run dev), TSDoc on public APIs, README + docs/. Draggable/resizable Modal (BaseDrag/DragMove) is deferred; see docs/00-migration-plan.md for the full migration plan and effort estimate. Co-Authored-By: Claude Opus 4.8 (1M context) --- package-lock.json | 2567 +++++++++++++---- packages/craftcms-garnish/.gitignore | 4 + packages/craftcms-garnish/.nvmrc | 1 + packages/craftcms-garnish/README.md | 188 ++ .../docs/00-migration-plan.md | 318 ++ .../craftcms-garnish/docs/01-core-design.md | 669 +++++ .../docs/02-dependency-graph.md | 823 ++++++ .../craftcms-garnish/docs/03-modal-slice.md | 79 + .../docs/04-scaffold-notes.md | 95 + .../docs/05-core-impl-notes.md | 117 + .../craftcms-garnish/docs/06-api-reference.md | 304 ++ packages/craftcms-garnish/package.json | 71 + .../craftcms-garnish/playground/index.html | 123 + packages/craftcms-garnish/playground/main.ts | 322 +++ .../craftcms-garnish/playground/styles.css | 262 ++ .../craftcms-garnish/playground/tsconfig.json | 9 + packages/craftcms-garnish/src/base.ts | 362 +++ packages/craftcms-garnish/src/compat.ts | 599 ++++ packages/craftcms-garnish/src/constants.ts | 54 + .../src/custom-events/activate.ts | 149 + .../src/custom-events/index.ts | 11 + .../src/custom-events/resize.ts | 104 + .../src/custom-events/textchange.ts | 99 + .../craftcms-garnish/src/dom-listeners.ts | 292 ++ packages/craftcms-garnish/src/drag-move.ts | 26 + packages/craftcms-garnish/src/events.ts | 395 +++ packages/craftcms-garnish/src/globals.ts | 41 + .../src/icons/resize-handle.ts | 9 + packages/craftcms-garnish/src/index.ts | 230 ++ .../src/managers/esc-manager.ts | 67 + .../craftcms-garnish/src/managers/registry.ts | 53 + .../src/managers/ui-layer-manager.ts | 304 ++ packages/craftcms-garnish/src/modal.ts | 752 +++++ packages/craftcms-garnish/src/types.ts | 24 + .../craftcms-garnish/src/utils/animation.ts | 164 ++ packages/craftcms-garnish/src/utils/aria.ts | 124 + packages/craftcms-garnish/src/utils/dom.ts | 134 + packages/craftcms-garnish/src/utils/env.ts | 99 + packages/craftcms-garnish/src/utils/focus.ts | 167 ++ .../craftcms-garnish/src/utils/focusable.ts | 115 + packages/craftcms-garnish/src/utils/forms.ts | 143 + packages/craftcms-garnish/src/utils/index.ts | 73 + packages/craftcms-garnish/src/utils/misc.ts | 65 + packages/craftcms-garnish/tests/base.test.ts | 124 + .../craftcms-garnish/tests/compat.test.ts | 394 +++ .../tests/custom-events.test.ts | 79 + .../tests/dom-listeners.test.ts | 96 + .../craftcms-garnish/tests/events.test.ts | 191 ++ .../craftcms-garnish/tests/focusable.test.ts | 95 + packages/craftcms-garnish/tests/index.test.ts | 13 + .../craftcms-garnish/tests/managers.test.ts | 91 + packages/craftcms-garnish/tests/modal.test.ts | 306 ++ packages/craftcms-garnish/tests/utils.test.ts | 78 + packages/craftcms-garnish/tsconfig.json | 12 + packages/craftcms-garnish/tsdown.config.ts | 33 + packages/craftcms-garnish/vite.config.ts | 23 + packages/craftcms-garnish/vitest.config.ts | 9 + 57 files changed, 11515 insertions(+), 636 deletions(-) create mode 100644 packages/craftcms-garnish/.gitignore create mode 100644 packages/craftcms-garnish/.nvmrc create mode 100644 packages/craftcms-garnish/README.md create mode 100644 packages/craftcms-garnish/docs/00-migration-plan.md create mode 100644 packages/craftcms-garnish/docs/01-core-design.md create mode 100644 packages/craftcms-garnish/docs/02-dependency-graph.md create mode 100644 packages/craftcms-garnish/docs/03-modal-slice.md create mode 100644 packages/craftcms-garnish/docs/04-scaffold-notes.md create mode 100644 packages/craftcms-garnish/docs/05-core-impl-notes.md create mode 100644 packages/craftcms-garnish/docs/06-api-reference.md create mode 100644 packages/craftcms-garnish/package.json create mode 100644 packages/craftcms-garnish/playground/index.html create mode 100644 packages/craftcms-garnish/playground/main.ts create mode 100644 packages/craftcms-garnish/playground/styles.css create mode 100644 packages/craftcms-garnish/playground/tsconfig.json create mode 100644 packages/craftcms-garnish/src/base.ts create mode 100644 packages/craftcms-garnish/src/compat.ts create mode 100644 packages/craftcms-garnish/src/constants.ts create mode 100644 packages/craftcms-garnish/src/custom-events/activate.ts create mode 100644 packages/craftcms-garnish/src/custom-events/index.ts create mode 100644 packages/craftcms-garnish/src/custom-events/resize.ts create mode 100644 packages/craftcms-garnish/src/custom-events/textchange.ts create mode 100644 packages/craftcms-garnish/src/dom-listeners.ts create mode 100644 packages/craftcms-garnish/src/drag-move.ts create mode 100644 packages/craftcms-garnish/src/events.ts create mode 100644 packages/craftcms-garnish/src/globals.ts create mode 100644 packages/craftcms-garnish/src/icons/resize-handle.ts create mode 100644 packages/craftcms-garnish/src/index.ts create mode 100644 packages/craftcms-garnish/src/managers/esc-manager.ts create mode 100644 packages/craftcms-garnish/src/managers/registry.ts create mode 100644 packages/craftcms-garnish/src/managers/ui-layer-manager.ts create mode 100644 packages/craftcms-garnish/src/modal.ts create mode 100644 packages/craftcms-garnish/src/types.ts create mode 100644 packages/craftcms-garnish/src/utils/animation.ts create mode 100644 packages/craftcms-garnish/src/utils/aria.ts create mode 100644 packages/craftcms-garnish/src/utils/dom.ts create mode 100644 packages/craftcms-garnish/src/utils/env.ts create mode 100644 packages/craftcms-garnish/src/utils/focus.ts create mode 100644 packages/craftcms-garnish/src/utils/focusable.ts create mode 100644 packages/craftcms-garnish/src/utils/forms.ts create mode 100644 packages/craftcms-garnish/src/utils/index.ts create mode 100644 packages/craftcms-garnish/src/utils/misc.ts create mode 100644 packages/craftcms-garnish/tests/base.test.ts create mode 100644 packages/craftcms-garnish/tests/compat.test.ts create mode 100644 packages/craftcms-garnish/tests/custom-events.test.ts create mode 100644 packages/craftcms-garnish/tests/dom-listeners.test.ts create mode 100644 packages/craftcms-garnish/tests/events.test.ts create mode 100644 packages/craftcms-garnish/tests/focusable.test.ts create mode 100644 packages/craftcms-garnish/tests/index.test.ts create mode 100644 packages/craftcms-garnish/tests/managers.test.ts create mode 100644 packages/craftcms-garnish/tests/modal.test.ts create mode 100644 packages/craftcms-garnish/tests/utils.test.ts create mode 100644 packages/craftcms-garnish/tsconfig.json create mode 100644 packages/craftcms-garnish/tsdown.config.ts create mode 100644 packages/craftcms-garnish/vite.config.ts create mode 100644 packages/craftcms-garnish/vitest.config.ts 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..5197b5c71cd --- /dev/null +++ b/packages/craftcms-garnish/.gitignore @@ -0,0 +1,4 @@ +/dist +/node_modules +/coverage +/playground/dist 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/README.md b/packages/craftcms-garnish/README.md new file mode 100644 index 00000000000..36b9ac4d35e --- /dev/null +++ b/packages/craftcms-garnish/README.md @@ -0,0 +1,188 @@ +# @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'; +``` + +**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 `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 # tsdown watch build +npm run build # production build (dual `.` + `/compat` entries) +npm run test # Vitest suite +npm run check:types # tsc --noEmit +npm run format # Prettier (writes ./src) +``` + +An interactive playground is available via `npm run dev` (see the `playground/` +directory). + +## Status + +This is the vertical-slice proof of concept. The modern core, `Base`, `Modal`, and +the compat layer are complete and tested. Some surfaces are intentionally not yet +ported: + +- **`Modal` `draggable` / `resizable`** default to `false` and **throw** if enabled + — the drag system (`BaseDrag` / `DragMove`) is out of PoC scope. `DragMove`'s + constructor throws for the same reason. + +## 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 ` +
+ `; + document.body.appendChild(el); + el.querySelector('[data-modal-close]')!.addEventListener('click', () => { + // Find the owning Modal via the static registry and hide it. + const owner = Modal.instances.find((m) => m.$container === el); + owner?.hide(); + }); + return el; +} + +/** Wire every demo event of a modal into the log panel. */ +function wireModalEvents(modal: Modal, label: string): void { + for (const evt of ['show', 'hide', 'fadeIn', 'fadeOut', 'escape'] as const) { + modal.on(evt, () => log('modal', `${label}: ${evt}`)); + } +} + +/* ------------------------------------------------------------------------- * + * 1. Modal demos + * ------------------------------------------------------------------------- */ + +let basicModal: Modal | null = null; + +function getBasicModal(): Modal { + if (basicModal && Modal.instances.includes(basicModal)) { + return basicModal; + } + const container = buildModalContainer( + 'Basic modal', + '

A plain new Modal(container). Try the quickShow / quickHide buttons too.

' + ); + basicModal = new Modal(container, {autoShow: false}); + wireModalEvents(basicModal, 'basic'); + return basicModal; +} + +const modalActions: Record void> = { + 'basic-show': () => getBasicModal().show(), + 'basic-hide': () => getBasicModal().hide(), + 'quick-show': () => getBasicModal().quickShow(), + 'quick-hide': () => getBasicModal().quickHide(), + + 'esc-shade': () => { + const container = buildModalContainer( + 'hideOnEsc + hideOnShadeClick', + '

Press Esc or click the dimmed shade outside this box to close it.

' + ); + const settings: Partial = { + hideOnEsc: true, + hideOnShadeClick: true, + }; + const modal = new Modal(container, settings); + wireModalEvents(modal, 'esc/shade'); + log('modal', 'Opened modal with hideOnEsc + hideOnShadeClick'); + }, + + 'close-others': () => { + // First, ensure something else is open to be closed. + getBasicModal().show(); + const container = buildModalContainer( + 'closeOtherModals: true', + '

Opening this one auto-closed any other visible modal (watch the log for the other modal’s hide).

' + ); + const modal = new Modal(container, {closeOtherModals: true}); + wireModalEvents(modal, 'closeOthers'); + log('modal', 'Opened closeOtherModals modal — prior modal should hide'); + }, + + 'destroy-all': () => { + const count = Modal.instances.length; + // Copy: destroy() mutates Modal.instances. + [...Modal.instances].forEach((m) => m.destroy()); + basicModal = null; + log('modal', `Destroyed ${count} modal instance(s)`); + }, +}; + +document.querySelectorAll('[data-modal]').forEach((btn) => { + btn.addEventListener('click', () => { + const action = btn.dataset.modal!; + try { + modalActions[action]?.(); + } catch (err) { + log('modal', `Error: ${(err as Error).message}`, true); + } + }); +}); + +/* ------------------------------------------------------------------------- * + * 2. Focusable matcher + focus trap + * ------------------------------------------------------------------------- */ + +const focusSample = document.getElementById('focus-sample') as HTMLElement; + +const focusActions: Record void> = { + highlight: () => { + let focusableCount = 0; + let keyboardCount = 0; + + // Clear first. + focusActions.clear!(); + + const focusable = getFocusableElements(focusSample); + focusable.forEach((el) => { + el.classList.add('pg-highlight-focusable'); + focusableCount++; + if (isKeyboardFocusable(el)) { + el.classList.add('pg-highlight-keyboard'); + keyboardCount++; + } + }); + + log( + 'focus', + `getFocusableElements matched ${focusableCount}; ${keyboardCount} keyboard-focusable (blue ring)` + ); + }, + + clear: () => { + focusSample + .querySelectorAll('.pg-highlight-focusable, .pg-highlight-keyboard') + .forEach((el) => { + el.classList.remove('pg-highlight-focusable', 'pg-highlight-keyboard'); + }); + }, + + 'trap-modal': () => { + const container = buildModalContainer( + 'Focus trap', + `

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

+
` + ); + const modal = new Modal(container); + wireModalEvents(modal, 'focusTrap'); + log('focus', 'Opened focus-trap modal — Tab cycling is trapped inside'); + }, +}; + +document.querySelectorAll('[data-focus]').forEach((btn) => { + btn.addEventListener('click', () => focusActions[btn.dataset.focus!]?.()); +}); + +/* ------------------------------------------------------------------------- * + * 3. Compat upgrade-path demo + * ------------------------------------------------------------------------- */ + +interface LegacyCtorLike { + extend(instance: Record, statics?: object): LegacyCtorLike; + new (...args: unknown[]): unknown; +} + +interface GarnishGlobalLike { + Modal: LegacyCtorLike; + [key: string]: unknown; +} + +let compatGarnish: GarnishGlobalLike | null = null; +let DemoModalSubclass: LegacyCtorLike | null = null; + +const compatActions: Record void> = { + install: () => { + compatGarnish = installGarnishCompat() as unknown as GarnishGlobalLike; + const onWindow = + (window as unknown as {Garnish?: unknown}).Garnish !== undefined; + log( + 'compat', + `installGarnishCompat() ran. window.Garnish present: ${onWindow}; Garnish.Modal.extend is ${typeof compatGarnish.Modal.extend}` + ); + }, + + extend: () => { + if (!compatGarnish) { + compatActions.install!(); + } + const Garnish = compatGarnish!; + + // Define the subclass exactly the legacy way: init() trampoline + this.base(). + DemoModalSubclass = Garnish.Modal.extend({ + init(this: { + base: (...a: unknown[]) => unknown; + $container: HTMLElement | null; + }) { + const container = buildModalContainer( + 'Legacy .extend() subclass', + '

This modal was created via Garnish.Modal.extend({ init, onShow }) and uses this.base() in onShow.

' + ); + // Call the modern Modal constructor through the init trampoline. + this.base(container, {autoShow: false}); + log('compat', 'subclass init() ran, called this.base(container)'); + }, + onShow(this: {base: () => void}) { + // this.base() dispatches to Modal.prototype.onShow (fires 'show'). + this.base(); + log('compat', 'subclass onShow() ran, then called this.base()'); + }, + }) as LegacyCtorLike; + + const instance = new DemoModalSubclass() as { + on(evt: string, fn: () => void): void; + show(): void; + }; + instance.on('show', () => log('compat', "subclass 'show' event observed")); + instance.show(); + log('compat', 'Instantiated + show()ed the .extend() subclass'); + }, +}; + +document.querySelectorAll('[data-compat]').forEach((btn) => { + btn.addEventListener('click', () => { + try { + compatActions[btn.dataset.compat!]?.(); + } catch (err) { + log('compat', `Error: ${(err as Error).message}`, true); + } + }); +}); + +/* ------------------------------------------------------------------------- * + * 4. Events & utilities + * ------------------------------------------------------------------------- */ + +const utilActions: Record void> = { + hasattr: (btn) => { + const result = hasAttr(btn, 'data-demo'); + log('util', `hasAttr(button, "data-demo") → ${result}`); + }, + getdist: () => { + const d = getDist(0, 0, 3, 4); + log('util', `getDist(0, 0, 3, 4) → ${d}`); + }, + activate: (btn) => { + // Install the synthetic `activate` custom event on the button (idempotent), + // then listen for it. Click / Space / Enter all dispatch `activate`. + installActivate(btn); + if (!btn.dataset.activateWired) { + btn.addEventListener('activate', () => { + log('util', 'activate custom event fired on the button'); + }); + btn.dataset.activateWired = 'yes'; + log( + 'util', + 'installActivate(button) — now click it or press Space/Enter' + ); + } + }, +}; + +document.querySelectorAll('[data-util]').forEach((btn) => { + btn.addEventListener('click', () => utilActions[btn.dataset.util!]?.(btn)); +}); diff --git a/packages/craftcms-garnish/playground/styles.css b/packages/craftcms-garnish/playground/styles.css new file mode 100644 index 00000000000..a257cd16eac --- /dev/null +++ b/packages/craftcms-garnish/playground/styles.css @@ -0,0 +1,262 @@ +: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); + font-family: + system-ui, + -apple-system, + 'Segoe UI', + Roboto, + sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + line-height: 1.5; + padding-bottom: 4rem; +} + +.pg-header { + padding: 1.5rem 2rem; + background: var(--surface); + border-bottom: 1px solid var(--border); +} + +.pg-header h1 { + margin: 0 0 0.25rem; + font-size: 1.4rem; +} + +.pg-header p { + margin: 0; + max-width: 60ch; + color: var(--muted); +} + +.pg-main { + display: grid; + gap: 1.25rem; + padding: 1.5rem 2rem; + max-width: 70ch; +} + +.pg-section { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.25rem 1.5rem; +} + +.pg-section h2 { + margin: 0 0 0.5rem; + font-size: 1.1rem; +} + +.pg-section > p { + margin: 0 0 1rem; + color: var(--muted); +} + +.pg-controls { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +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; +} + +button:hover { + background: #eef0f3; +} + +button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +code, +kbd { + font-family: ui-monospace, 'SFMono-Regular', Menlo, monospace; + font-size: 0.85em; +} + +code { + background: #eef0f3; + padding: 0.05rem 0.3rem; + border-radius: 4px; +} + +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); + 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-primary { + background: var(--accent); + color: var(--accent-text); + border-color: var(--accent); +} + +.pg-modal-primary:hover { + background: #1a5fce; +} + +body.no-scroll { + overflow: hidden; +} + +/* --- Event log panel ---------------------------------------------------- */ +.pg-log { + position: fixed; + right: 1rem; + bottom: 1rem; + width: 340px; + max-height: 50vh; + display: flex; + flex-direction: column; + background: #11151b; + color: #e6e9ee; + border-radius: var(--radius); + box-shadow: var(--shadow); + z-index: 9999; + overflow: hidden; +} + +.pg-log-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0.75rem; + background: #0b0e13; + border-bottom: 1px solid #222834; +} + +.pg-log-head button { + padding: 0.15rem 0.5rem; + font-size: 0.75rem; + background: #222834; + color: #e6e9ee; + border-color: #333b48; +} + +.pg-log-list { + margin: 0; + padding: 0.5rem 0.75rem 0.5rem 2rem; + overflow: auto; + font-family: ui-monospace, 'SFMono-Regular', Menlo, monospace; + font-size: 0.78rem; + list-style: decimal; +} + +.pg-log-list li { + padding: 0.15rem 0; + border-bottom: 1px solid #1b212b; + word-break: break-word; +} + +.pg-log-list li .pg-log-tag { + color: #6ad08a; + font-weight: 600; +} + +.pg-log-list li.pg-log-error .pg-log-tag { + color: #ff8a7a; +} + +.pg-log-list li time { + color: #7b8493; + margin-right: 0.4rem; +} diff --git a/packages/craftcms-garnish/playground/tsconfig.json b/packages/craftcms-garnish/playground/tsconfig.json new file mode 100644 index 00000000000..4a030545df8 --- /dev/null +++ b/packages/craftcms-garnish/playground/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "..", + "types": ["node", "vite/client"] + }, + "include": ["./**/*.ts", "../vite.config.ts", "../src"] +} diff --git a/packages/craftcms-garnish/src/base.ts b/packages/craftcms-garnish/src/base.ts new file mode 100644 index 00000000000..86a51a20241 --- /dev/null +++ b/packages/craftcms-garnish/src/base.ts @@ -0,0 +1,362 @@ +/** + * Garnish base class — native `class extends`, jQuery-free. + * + * Replaces the legacy Dean Edwards `Base.extend()` + `init()` trampoline. The + * `init()` indirection and `this.base()` super-dispatch are intentionally NOT + * here — those are the compat layer's job. Subclasses use a real `constructor` + * and `super()`. + */ + +import {DomListenerRegistry, type DomListenerOptions} from './dom-listeners'; +import {EventEmitter, type GarnishEventHandler} from './events'; +import {garnishClassBus} from './globals'; +import type {ElementInput, GarnishBaseSettings} from './types'; + +function makeNamespace(): string { + const uuid = + typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : String(Math.floor(Math.random() * 1000000000)); + return `.Garnish${uuid}`; +} + +/** + * The base class every Garnish component extends — a modern, jQuery-free, + * native-`class` replacement for the legacy `Garnish.Base`. + * + * `Base` provides four facilities to subclasses: + * + * - **Settings** — a typed, shallow-merged `settings` object (see {@link setSettings}). + * - **Object pub/sub** — per-instance events via {@link on} / {@link once} / + * {@link off} / {@link trigger}, plus class-level subscription through the + * `Garnish.on/off/once` proxies. + * - **DOM listeners** — namespaced, auto-tracked bindings via + * {@link addListener} / {@link removeListener} / {@link removeAllListeners} + * that are torn down automatically on {@link destroy}. + * - **Lifecycle** — {@link enable} / {@link disable} (which gate DOM listeners) + * and {@link destroy}. + * + * Unlike the legacy library, there is **no `init()` trampoline and no + * `this.base()`** — subclasses use a real `constructor` + `super()` and call + * `super.method()` for super-dispatch. Those legacy affordances live only in the + * opt-in `@craftcms/garnish/compat` layer. + * + * @typeParam S - The subclass's settings shape; defaults to {@link GarnishBaseSettings}. + * + * @example Defining a component + * ```ts + * import {Base, type GarnishBaseSettings} from '@craftcms/garnish'; + * + * interface CounterSettings extends GarnishBaseSettings { + * step: number; + * } + * + * class Counter extends Base { + * static defaults: CounterSettings = {step: 1}; + * value = 0; + * + * constructor(button: HTMLElement, settings?: Partial) { + * super(); + * this.setSettings(settings, Counter.defaults); + * this.addListener(button, 'click', () => { + * this.value += this.settings!.step; + * this.trigger('change', {value: this.value}); + * }); + * } + * } + * ``` + */ +export abstract class Base< + S extends GarnishBaseSettings = GarnishBaseSettings, +> { + /** + * The instance's resolved settings (defaults merged with the passed + * overrides). `null` until {@link setSettings} has run — most subclasses call + * it in their constructor, so guard with `this.settings!` once you know it has. + */ + settings: S | null = null; + + protected _disabled = false; + + /** Per-instance namespace; kept for compat/debugging parity. */ + protected readonly _namespace: string = makeNamespace(); + + /** Object pub/sub registry. */ + protected readonly _emitter = new EventEmitter(this); + + /** Tracked DOM listener bindings for namespaced removal + bulk teardown. */ + protected readonly _domListeners: DomListenerRegistry = + new DomListenerRegistry(this); + + constructor() { + // No init() trampoline. Subclasses do their own setup after super(). + } + + // --- Settings ------------------------------------------------------------- + + /** + * Resolve {@link settings} by shallow-merging, in increasing precedence: + * any existing `settings`, then `defaults`, then the passed `settings`. + * + * Matches the legacy `$.extend({}, base, defaults, settings)` precedence — it + * is a **shallow** merge, not a deep one. Nested objects are replaced wholesale, + * not recursively combined. + * + * @param settings - Per-instance overrides (highest precedence). + * @param defaults - The component's default settings. + * + * @example + * ```ts + * this.setSettings(passedSettings, MyComponent.defaults); + * // this.settings is now {...defaults, ...passedSettings} + * ``` + */ + setSettings(settings?: Partial, defaults?: Partial): void { + const base = this.settings ?? {}; + this.settings = Object.assign({}, base, defaults, settings) as S; + } + + // --- Object pub/sub ------------------------------------------------------- + + /** + * Subscribe to one or more instance events. + * + * The event string follows the legacy grammar: space-separated for multiple + * events, with an optional `.namespace` suffix per token + * (e.g. `'show hide.myPlugin'`). Namespaces let you remove a related group of + * handlers later with {@link off}. + * + * @param events - Space-separated event name(s), each optionally `.namespaced`. + * @param data - Optional per-registration data, merged into the event object + * passed to the handler. + * @param handler - The callback, invoked with a {@link GarnishEvent}. + * + * @example + * ```ts + * modal.on('show', (ev) => console.log('shown', ev.target)); + * modal.on('hide.myPlugin', (ev) => cleanup()); + * modal.off('.myPlugin'); // remove every handler in that namespace + * ``` + */ + on(events: string, handler: GarnishEventHandler): void; + on( + events: string, + data: Record, + handler: GarnishEventHandler + ): void; + on( + events: string, + dataOrHandler: Record | GarnishEventHandler, + handler?: GarnishEventHandler + ): void { + // Delegate; the emitter handles the overload. + (this._emitter.on as (...a: unknown[]) => void)( + events, + dataOrHandler, + handler + ); + } + + /** + * Like {@link on}, but the handler is automatically removed after it fires + * once (per matched event token). + * + * @param events - Space-separated event name(s), each optionally `.namespaced`. + * @param data - Optional per-registration data merged into the event object. + * @param handler - The callback, invoked at most once. + */ + once(events: string, handler: GarnishEventHandler): void; + once( + events: string, + data: Record, + handler: GarnishEventHandler + ): void; + once( + events: string, + dataOrHandler: Record | GarnishEventHandler, + handler?: GarnishEventHandler + ): void { + (this._emitter.once as (...a: unknown[]) => void)( + events, + dataOrHandler, + handler + ); + } + + /** + * Unsubscribe from instance events. + * + * Matching mirrors jQuery's namespaced `.off`: a registration is removed when + * its type matches AND (the requested namespace is empty OR the namespaces + * match) AND (`handler` is omitted OR is the same function reference). + * + * - `off('show')` — remove all `show` handlers. + * - `off('show', fn)` — remove only `fn`. + * - `off('.myPlugin')` — remove every handler registered under that namespace, + * across all event types. + * + * @param events - Space-separated event name(s), each optionally `.namespaced`. + * @param handler - When given, only the matching handler reference is removed. + */ + off(events: string, handler?: GarnishEventHandler): void { + this._emitter.off(events, handler); + } + + /** + * Emit an event. Dispatches first to this instance's handlers (registered via + * {@link on} / {@link once}), then to class-level handlers registered through + * `Garnish.on(SomeClass, …)` whose target this instance is an `instanceof` + * (two-pass dispatch, matching the legacy behavior). + * + * @param type - The event type (no namespace; namespaces are a subscribe-side + * concept only). + * @param data - Optional payload, spread onto the {@link GarnishEvent} passed + * to handlers (trigger-time keys win over per-registration `data`). + * + * @example + * ```ts + * this.trigger('change', {value: this.value}); + * ``` + */ + trigger(type: string, data?: Record): void { + this._emitter.trigger(type, data); + garnishClassBus.dispatch(this, type, data); + } + + // --- DOM listeners -------------------------------------------------------- + + /** + * Bind one or more native DOM event listeners, tracked by this instance so + * they can be removed by name/namespace or torn down in bulk on + * {@link destroy}. This is the jQuery-free replacement for the legacy + * `addListener`. + * + * Signature-compatible with legacy: `(elem, events, handler)` or + * `(elem, events, data, handler)`. The `handler` may be a function (bound to + * `this`) or a **method-name string** resolved against `this` at call time. + * + * The `events` string uses the comma grammar (e.g. `'click, keydown'`), each + * token optionally `.namespaced`. Handlers do not fire while the instance is + * {@link disable | disabled}. Custom Garnish events (`activate`, `textchange`, + * `resize`) are supported transparently. + * + * Supported `data` keys: `delegate` (a selector — fire only when the event + * originates within a matching descendant) and event-specific options such as + * `delay` for `textchange`. + * + * @param elem - Target(s): an element, `window`/`document`, an array/NodeList, + * or a CSS selector string (see {@link ElementInput}). + * @param events - Comma-separated event name(s), each optionally `.namespaced`. + * @param dataOrHandler - Either the handler, or a `data` object when a 4th arg + * is supplied. + * @param handler - The handler (function or method-name string) when `data` + * is passed. + * + * @example + * ```ts + * this.addListener(this.$button, 'click', (ev) => this.handleClick(ev)); + * this.addListener(this.$shade, 'click', 'hide'); // method-name string + * this.addListener(this.$list, 'click', {delegate: 'li'}, this.onItem); + * ``` + */ + addListener( + elem: ElementInput, + events: string, + dataOrHandler: Record | GarnishEventHandler | string, + handler?: GarnishEventHandler | string + ): void { + let data: Record = {}; + let func: GarnishEventHandler | string | undefined; + + // Param mapping: if handler is undefined and `dataOrHandler` isn't a plain + // object, treat `dataOrHandler` as the handler. + if (handler === undefined && !isPlainObject(dataOrHandler)) { + func = dataOrHandler as GarnishEventHandler | string; + } else { + data = (dataOrHandler as Record) ?? {}; + func = handler; + } + + let resolved: GarnishEventHandler; + if (typeof func === 'function') { + resolved = func.bind(this); + } else if (typeof func === 'string') { + // Method-name string (e.g. UiLayerManager uses 'triggerShortcut'). + resolved = (this[func as keyof this] as GarnishEventHandler).bind(this); + } else { + return; + } + + const options: DomListenerOptions = {}; + if ('delegate' in data && typeof data.delegate === 'string') { + options.delegate = data.delegate; + } + if ('delay' in data || Object.keys(data).length) { + options.data = data; + } + + this._domListeners.add(elem, events, resolved, options); + } + + /** + * Remove specific DOM listener(s) this instance bound on the element(s). + * Honors namespaces: `removeListener(el, '.myNs')` removes every namespaced + * binding regardless of type. + * + * @param elem - The same target(s) the listener was bound on. + * @param events - Comma-separated event name(s), each optionally `.namespaced`. + */ + removeListener(elem: ElementInput, events: string): void { + this._domListeners.remove(elem, events); + } + + /** + * Remove every DOM listener this instance bound on the given element(s). + * + * @param elem - The target(s) to clear listeners from. + */ + removeAllListeners(elem: ElementInput): void { + this._domListeners.removeAllOn(elem); + } + + // --- Enable/disable/destroy ---------------------------------------------- + + /** + * Disable the instance. While disabled, DOM listeners bound via + * {@link addListener} are short-circuited (they don't fire). Object events + * ({@link trigger}) are unaffected. + */ + disable(): void { + this._disabled = true; + } + + /** Re-enable the instance, restoring DOM-listener dispatch. */ + enable(): void { + this._disabled = false; + } + + /** Whether the instance is currently {@link disable | disabled}. */ + get disabled(): boolean { + return this._disabled; + } + + /** + * Tear the instance down: emit a `destroy` event, then remove every tracked + * DOM listener and clear all object-event registrations. Subclasses that + * override this should call `super.destroy()`. + */ + destroy(): void { + this.trigger('destroy'); + this._domListeners.removeAll(); + this._emitter.clear(); + } +} + +function isPlainObject(val: unknown): val is Record { + return ( + typeof val === 'object' && + val !== null && + (Object.getPrototypeOf(val) === Object.prototype || + Object.getPrototypeOf(val) === null) + ); +} diff --git a/packages/craftcms-garnish/src/compat.ts b/packages/craftcms-garnish/src/compat.ts new file mode 100644 index 00000000000..db1a0685878 --- /dev/null +++ b/packages/craftcms-garnish/src/compat.ts @@ -0,0 +1,599 @@ +/** + * @craftcms/garnish/compat — opt-in legacy compatibility layer. + * + * Restores the entire legacy Garnish authoring contract on top of the modern, + * jQuery-free core: + * + * - `Garnish.Base.extend(instanceMembers, staticMembers)` → real subclasses + * - `init()` as the constructor trampoline (subclasses write `init`, not `constructor`) + * - `this.base(...)` super-dispatch with per-call save/restore re-entrancy, + * ported faithfully from Dean Edwards `lib/Base.js` (the `/\bbase\b/` source-sniff) + * - jQuery-collection coercion for `$container`-style constructor args + * - `isJquery`, jQuery-wrapped `$win`/`$doc`/`$bod`/`$scrollContainer` + * - `getFocusedElement()` returning a jQuery collection (legacy shape) + * - the deprecated `Menu` / `ShortcutManager` aliases + * - the `$.fn.activate/textchange/resize` chaining sugar (when jQuery is present) + * - a legacy-shaped `window.Garnish` global, guarded like the legacy code + * + * ## Opt-in story + * + * This entry is **side-effecting**: importing it installs `window.Garnish` + * (under the same `if (typeof window.Garnish === 'undefined')` guard the legacy + * library used) and gives you `.extend()`-able, jQuery-shaped classes. + * + * // legacy plugin, zero code changes beyond this one line: + * import '@craftcms/garnish/compat'; + * Craft.MyModal = Garnish.Modal.extend({ init() { … } }); + * + * To **drop** the compat layer, delete that import line and move to the modern + * surface (`import {Modal} from '@craftcms/garnish'; class extends Modal {…}`). + * Tree-shaking then removes all of this code and the `window.Garnish` global. + * + * For programmatic / non-auto-install use, call `installGarnishCompat()` (it is + * idempotent and returns the assembled namespace), or import the named + * `compatify` / `GarnishCompat` exports directly. + * + * jQuery is a **peer dependency of this entry only** — the modern `.` entry stays + * jQuery-free. jQuery is detected at runtime from the global scope; the compat + * layer degrades gracefully (or throws a clear error) when it is absent. + */ + +import * as Core from './index'; +import {Garnish as CoreGarnish, initGarnish} from './index'; +import {getFocusedElement as coreGetFocusedElement} from './utils/focus'; +import {win, doc, bod} from './globals'; + +/* ------------------------------------------------------------------------- * + * jQuery runtime detection (peer dependency of ./compat only) + * ------------------------------------------------------------------------- */ + +/** + * Minimal structural type for a jQuery function/collection. We can't depend on + * `@types/jquery` here (jQuery is only an optional peer of this entry), so this + * is intentionally loose. + */ +export interface JQueryLike { + (selectorOrEl?: unknown): JQueryCollection; + fn: Record & {jquery?: string}; + prototype: object; +} + +export interface JQueryCollection { + length: number; + [index: number]: Element; + get(index?: number): Element | Element[]; + on(...args: unknown[]): JQueryCollection; + trigger(...args: unknown[]): JQueryCollection; + toArray?(): Element[]; +} + +/** + * Resolve jQuery from the global scope (`window.jQuery` or `window.$`), or + * `null` if it isn't present. jQuery is an optional peer dependency of the + * compat entry only and is detected at runtime. + * + * @returns The global jQuery function, or `null`. + */ +export function resolveJQuery(): JQueryLike | null { + const g = globalThis as Record; + const candidate = (g.jQuery ?? g.$) as JQueryLike | undefined; + if (typeof candidate === 'function' && (candidate as JQueryLike).fn) { + return candidate as JQueryLike; + } + return null; +} + +/** Throw a clear, actionable error when jQuery is required but absent. */ +function requireJQuery(feature: string): JQueryLike { + const jq = resolveJQuery(); + if (!jq) { + throw new Error( + `@craftcms/garnish/compat: jQuery is required for ${feature} but was not ` + + `found on the global scope. Load jQuery before importing the compat ` + + `layer, or migrate to the modern jQuery-free API.` + ); + } + return jq; +} + +/** + * Whether a value is a jQuery collection — the modern home of the legacy + * `Garnish.isJquery`. Returns `false` when jQuery isn't loaded. + * + * @param val - The value to test. + * @returns `true` if `val` is a jQuery collection. + */ +export function isJquery(val: unknown): boolean { + const jq = resolveJQuery(); + if (!jq) { + return false; + } + // jQuery collections are instances of jQuery.fn.init; the cheapest reliable + // check is the prototype chain, mirroring legacy `val instanceof $`. + return val instanceof (jq as unknown as {prototype: object} & Function); +} + +/** + * Wrap a native element / list in a jQuery collection (`$(value)`). + * + * @param value - Anything jQuery's `$()` accepts. + * @returns The jQuery collection. + * @throws If jQuery is absent — the `$`-prefixed legacy surface is meaningless + * without it. + */ +export function toJq(value: unknown): JQueryCollection { + const jq = requireJQuery('jQuery-wrapped values'); + return jq(value); +} + +/** + * Coerce a possibly-jQuery constructor argument down to the native input the + * modern classes expect. Modern constructors accept + * `Element | EventTarget | string | null`; legacy callers may pass a jQuery + * collection (`$container`) — this unwraps it to its first element (or `null` + * when empty). Non-jQuery values pass through untouched. + * + * @param value - The (possibly jQuery) value. + * @returns The native element, or the value unchanged. + */ +export function unwrapJq(value: unknown): unknown { + if (value == null) { + return value; + } + if (isJquery(value)) { + const coll = value as JQueryCollection; + return coll.length ? coll[0] : null; + } + return value; +} + +/* ------------------------------------------------------------------------- * + * compatify(ModernClass) — the legacy `.extend()` / `this.base()` shim + * ------------------------------------------------------------------------- */ + +type AnyCtor = abstract new (...args: any[]) => object; +type ConcreteCtor = new (...args: any[]) => object; + +export interface LegacyMembers { + /** Legacy constructor trampoline; receives the `new` arguments. */ + init?: (...args: any[]) => void; + [key: string]: unknown; +} + +export interface LegacyCtor extends ConcreteCtor { + /** Dean-Edwards-style subclassing: returns a new legacy-shaped subclass. */ + extend(instanceMembers?: LegacyMembers, staticMembers?: object): LegacyCtor; + /** The class this one extends (legacy `klass.ancestor`). */ + ancestor?: unknown; +} + +/** + * The marker we stash on every compatified prototype so a subclass can find the + * ancestor implementation of an overridden method when synthesizing `this.base`. + * + * We walk the *prototype chain* (real inheritance) rather than cloning prototypes + * the way Dean Edwards' `new this()` did — the lookup is + * `Object.getPrototypeOf(subclassProto)[name]`. + */ + +/** + * Detect whether a function's source references `base` as an identifier — the + * exact `/\bbase\b/` source-sniff from `lib/Base.js`. Only methods that actually + * call `this.base()` get wrapped, matching legacy behavior (and avoiding the + * overhead/observable `this.base` mutation for methods that don't). + */ +const BASE_RE = /\bbase\b/; + +function referencesBase(fn: unknown): fn is (...args: any[]) => unknown { + return ( + typeof fn === 'function' && + BASE_RE.test(Function.prototype.toString.call(fn)) + ); +} + +/** + * Wrap an override so that, on entry, `this.base` is set to the ancestor method + * (bound to `this`), the override runs, and the prior `this.base` is restored on + * exit. Ported verbatim from `lib/Base.js`'s save/restore dance, with a + * try/finally so nested / re-entrant super-calls restore correctly even when an + * override throws. + */ +function wrapWithBase( + method: (...args: any[]) => unknown, + ancestor: ((...args: any[]) => unknown) | undefined +): (...args: any[]) => unknown { + const noopBase = function noBase(): void { + // Calling this.base() with no ancestor is a legacy no-op. + }; + + return function baseWrapper(this: Record, ...args: any[]) { + const previous = this.base; + this.base = ancestor ? ancestor.bind(this) : noopBase; + try { + return method.apply(this, args); + } finally { + this.base = previous; + } + }; +} + +/** + * Copy a single legacy instance member onto a subclass prototype. Functions that + * reference `base` are wrapped for super-dispatch against the ancestor found on + * the prototype chain. + */ +function defineInstanceMember( + proto: object, + parentProto: object, + key: string, + value: unknown +): void { + if (referencesBase(value)) { + const ancestor = (parentProto as Record)[key] as + | ((...args: any[]) => unknown) + | undefined; + Object.defineProperty(proto, key, { + value: wrapWithBase(value, ancestor), + writable: true, + configurable: true, + enumerable: false, + }); + } else { + Object.defineProperty(proto, key, { + value, + writable: true, + configurable: true, + enumerable: false, + }); + } +} + +/** + * Turn a modern ES class into a legacy-shaped constructor that supports the full + * Dean-Edwards authoring contract: `.extend(instanceMembers, staticMembers)`, + * `init` as the constructor trampoline, and `this.base(...)` super-dispatch. + * + * The returned constructor is directly `new`-able (it forwards to the modern + * class) and exposes a static `extend`. Each `.extend()` produces a **real + * subclass** (native prototype chain) so `instanceof` and `super` keep working, + * with the legacy affordances layered on top: `init` runs once on the most + * derived class, and methods whose source references `base` get a per-call + * `this.base` bound to the ancestor implementation. + * + * This is what makes an unmodified legacy plugin run against the modern core. + * For TypeScript-authored code, prefer plain `class extends ModernClass` with + * `super.method()` instead — `compatify` exists for the upgrade path. + * + * @typeParam T - The modern (abstract or concrete) class to wrap. + * @param Modern - The modern ES class. + * @returns A legacy-shaped constructor with `.extend()`. + * + * @example + * ```ts + * import {Modal} from '@craftcms/garnish'; + * import {compatify} from '@craftcms/garnish/compat'; + * + * const LegacyModal = compatify(Modal); + * + * const MyModal = LegacyModal.extend({ + * init(container) { + * this.base(container, {closeOtherModals: true}); // calls Modal's constructor + * }, + * onShow() { + * this.base(); // calls Modal.prototype.onShow + * // ...custom behavior + * }, + * }); + * + * new MyModal(document.querySelector('#my-modal')); + * ``` + */ +export function compatify(Modern: T): LegacyCtor { + // The root compat constructor simply forwards to the modern class. Modern + // classes have plain constructors (no init trampoline), so a bare `new` is a + // valid legacy instantiation with no `init` member. + const Root = makeSubclass(Modern as unknown as ConcreteCtor, undefined); + + // Faithful to lib/Base.js (`proto.base = function () {}`): every compatified + // class has a default no-op `base` on its prototype, so `this.base` is ALWAYS + // callable even outside a wrapped override, and the save/restore dance in + // `wrapWithBase` always restores to a function rather than `undefined`. + if (!Object.prototype.hasOwnProperty.call(Root.prototype, 'base')) { + Object.defineProperty(Root.prototype, 'base', { + value: function noBase(): void { + // call this method from any other method to invoke that method's ancestor + }, + writable: true, + configurable: true, + enumerable: false, + }); + } + + return Root; +} + +/** + * Build one level of the legacy-shaped class hierarchy. + * + * @param Parent The (modern or already-compatified) constructor to extend. + * @param members The legacy instance members for THIS level (or undefined for + * the root wrapper, which adds none). + * @param statics Static members to copy onto the constructor. + */ +function makeSubclass( + Parent: ConcreteCtor, + members: LegacyMembers | undefined, + statics?: object +): LegacyCtor { + const parentProto = Parent.prototype as object; + const hasInit = typeof members?.init === 'function'; + + // The legacy-shaped constructor. It runs the modern constructor (super), then, + // if this level (or an ancestor level) supplied an `init`, invokes it with the + // original arguments — matching legacy `this.init.apply(this, arguments)`. + class Subclass extends (Parent as ConcreteCtor) { + constructor(...args: any[]) { + // Coerce jQuery-collection / selector args down to native inputs the + // modern constructor (and the legacy `init`) understand + // ($container → element). Unwrap once so super() and init() agree. + const nativeArgs = args.map(unwrapJq) as any[]; + super(...nativeArgs); + + // Only run init from the *most derived* level that defined one. Because + // each subclass constructor runs this, we guard so `init` fires exactly + // once: only when `this.constructor === Subclass` (the instantiated class). + // Inherited `init`s are found via the prototype and run by the leaf ctor. + if (this.constructor === Subclass) { + const initFn = (this as Record).init; + if (typeof initFn === 'function') { + (initFn as (...a: any[]) => void).apply(this, nativeArgs); + } + } + } + } + + // Copy instance members onto the subclass prototype, wrapping `base`-callers. + if (members) { + for (const key of Object.keys(members)) { + if (key === 'constructor') { + continue; + } + const desc = Object.getOwnPropertyDescriptor(members, key)!; + if ('value' in desc) { + defineInstanceMember(Subclass.prototype, parentProto, key, desc.value); + } else { + // Preserve getters/setters (legacy Dean Edwards copied descriptors). + Object.defineProperty(Subclass.prototype, key, desc); + } + } + } + // Mark whether this level brought its own init (debug/parity aid). + void hasInit; + + const Ctor = Subclass as unknown as LegacyCtor; + + // Static members. + if (statics) { + Object.assign(Ctor, statics); + } + Ctor.ancestor = Parent; + + // The legacy `.extend()` entry point: build the next level down. + Ctor.extend = function extend( + instanceMembers?: LegacyMembers, + staticMembers?: object + ): LegacyCtor { + return makeSubclass( + Ctor as unknown as ConcreteCtor, + instanceMembers, + staticMembers + ); + }; + + return Ctor; +} + +/* ------------------------------------------------------------------------- * + * Legacy-shaped Garnish namespace assembly + * ------------------------------------------------------------------------- */ + +/** The set of class keys on the core namespace we run through `compatify`. */ +const CLASS_KEYS = ['Base', 'EscManager', 'UiLayerManager'] as const; + +export interface GarnishCompatNamespace { + [key: string]: unknown; +} + +let installed: GarnishCompatNamespace | undefined; + +/** + * Assemble the legacy-shaped `Garnish` namespace **without** assigning it to + * `window`: {@link compatify} each Garnish class so `.extend()` works, run + * `initGarnish()` for the manager singletons, re-add the jQuery-only members + * (`isJquery`, `$win`/`$doc`/`$bod`/`$scrollContainer`, the `$.fn.activate/…` + * chaining sugar), and restore the deprecated aliases (`Menu`, + * `ShortcutManager`). + * + * Use {@link installGarnishCompat} (or the side-effecting `import` of this + * module) if you also want `window.Garnish` set. + * + * @returns The assembled namespace. Idempotent — repeated calls return the same object. + */ +export function buildGarnishCompat(): GarnishCompatNamespace { + if (installed) { + return installed; + } + + // Start from the live core namespace so utilities, constants, flags, and the + // class-level event bus proxies (`on`/`off`/`once`) are all carried over. + const G: GarnishCompatNamespace = Object.assign({}, CoreGarnish); + + // Wrap every (currently-ported) class so `.extend()` / `init` / `this.base()` + // work. New classes (Modal, HUD, …) are picked up automatically as the core + // namespace gains them. + for (const key of Object.keys(CoreGarnish)) { + const value = (CoreGarnish as Record)[key]; + if (isCompatifiableClass(key, value)) { + G[key] = compatify(value as AnyCtor); + } + } + + // --- jQuery-only surface (re-added from core, which omitted it) ----------- + + G.isJquery = isJquery; + + // jQuery-wrapped DOM refs (lazy getters so jQuery only needs to exist at + // access time, not import time). + Object.defineProperties(G, { + $win: { + configurable: true, + enumerable: true, + get: () => toJq(win), + }, + $doc: { + configurable: true, + enumerable: true, + get: () => toJq(doc), + }, + $bod: { + configurable: true, + enumerable: true, + get: () => toJq(bod), + }, + $scrollContainer: { + configurable: true, + enumerable: true, + get: () => toJq(CoreGarnish.scrollContainer), + }, + }); + + // getFocusedElement returned a jQuery `:focus` collection in legacy. + G.getFocusedElement = (): JQueryCollection | Element | null => { + if (resolveJQuery()) { + const el = coreGetFocusedElement(); + return toJq(el ?? []); + } + return coreGetFocusedElement(); + }; + + // --- Manager singletons --------------------------------------------------- + + initGarnish(); + G.escManager = CoreGarnish.escManager; + G.uiLayerManager = CoreGarnish.uiLayerManager; + + // --- Deprecated aliases --------------------------------------------------- + + // Menu → CustomSelect (when CustomSelect is ported), else fall back to nothing. + const customSelect = (G as Record).CustomSelect; + if (customSelect) { + G.Menu = customSelect; + } + // ShortcutManager → UiLayerManager (compatified form). + G.ShortcutManager = G.UiLayerManager; + // Legacy lowercase alias used by index.js. + G.shortcutManager = CoreGarnish.uiLayerManager; + + // --- $.fn chaining sugar (only when jQuery is present) -------------------- + installJqueryFnSugar(); + + installed = G; + return installed; +} + +/** + * Re-add the legacy `$.fn.activate/textchange/resize` chaining sugar IF jQuery is + * present. Routes through jQuery's own `.on(name, …)` / `.trigger(name)` exactly + * like the legacy `$.each(['activate','textchange','resize'], …)` block. The + * underlying custom-event semantics are installed by the core + * `installActivate/Textchange/Resize` when the listeners actually bind via + * `Garnish.Base#addListener`; this sugar only restores the fluent jQuery call + * shape. Degrades to a no-op when jQuery is absent. + */ +function installJqueryFnSugar(): void { + const jq = resolveJQuery(); + if (!jq || !jq.fn) { + return; + } + for (const name of ['activate', 'textchange', 'resize'] as const) { + if (typeof jq.fn[name] === 'function') { + continue; // already installed (e.g. by the legacy bundle) + } + jq.fn[name] = function ( + this: JQueryCollection, + data?: unknown, + fn?: unknown + ): JQueryCollection { + return arguments.length > 0 + ? this.on(name, null, data, fn) + : this.trigger(name); + }; + } +} + +function isCompatifiableClass(key: string, value: unknown): boolean { + if (typeof value !== 'function') { + return false; + } + // Known core classes, plus any future class exposed on the namespace whose + // prototype descends from the (already-known) Base. + if ((CLASS_KEYS as readonly string[]).includes(key)) { + return true; + } + // Heuristic for not-yet-enumerated classes (Modal, HUD, …): a function with a + // prototype that is a Base (or subclass) instance shape. We avoid wrapping + // plain utility functions by requiring the value to be a class extending Core.Base. + try { + return value.prototype instanceof Core.Base; + } catch { + return false; + } +} + +/* ------------------------------------------------------------------------- * + * window.Garnish installation + * ------------------------------------------------------------------------- */ + +/** + * Build the legacy-shaped `Garnish` namespace and install it onto `window`, + * guarded exactly like the legacy library (`if (typeof window.Garnish === + * 'undefined')`) so it never clobbers an existing (legacy-bundle) global during + * the coexistence period. + * + * Importing `@craftcms/garnish/compat` calls this automatically (via the eager + * {@link GarnishCompat} export); call it yourself only for explicit/programmatic + * installation. + * + * @returns The assembled namespace, regardless of whether it won the `window.Garnish` + * slot. Idempotent. + * + * @example + * ```ts + * import {installGarnishCompat} from '@craftcms/garnish/compat'; + * const Garnish = installGarnishCompat(); + * const MyModal = Garnish.Modal.extend({ init() { … } }); + * ``` + */ +export function installGarnishCompat(): GarnishCompatNamespace { + const G = buildGarnishCompat(); + + const w = (typeof window !== 'undefined' ? window : undefined) as + | (Window & {Garnish?: unknown}) + | undefined; + + if (w && typeof w.Garnish === 'undefined') { + w.Garnish = G; + } + + return G; +} + +/** + * The assembled legacy-shaped `Garnish` namespace, built (and installed onto + * `window.Garnish`) eagerly when this module is imported. This is the default + * export too, so `import Garnish from '@craftcms/garnish/compat'` gives you the + * namespace directly. + */ +export const GarnishCompat = installGarnishCompat(); + +export default GarnishCompat; diff --git a/packages/craftcms-garnish/src/constants.ts b/packages/craftcms-garnish/src/constants.ts new file mode 100644 index 00000000000..746e4599c0c --- /dev/null +++ b/packages/craftcms-garnish/src/constants.ts @@ -0,0 +1,54 @@ +/** + * Garnish constants. + * + * Ported verbatim from the legacy `Garnish` singleton (src/Garnish.js). + * Plain numeric/string constants only — no jQuery dependency. + */ + +// Key code constants +export const BACKSPACE_KEY = 8; +export const TAB_KEY = 9; +export const CLEAR_KEY = 12; +export const RETURN_KEY = 13; +export const SHIFT_KEY = 16; +export const CTRL_KEY = 17; +export const ALT_KEY = 18; +export const ESC_KEY = 27; +export const SPACE_KEY = 32; +export const PAGE_UP_KEY = 33; +export const PAGE_DOWN_KEY = 34; +export const END_KEY = 35; +export const HOME_KEY = 36; +export const LEFT_KEY = 37; +export const UP_KEY = 38; +export const RIGHT_KEY = 39; +export const DOWN_KEY = 40; +export const DELETE_KEY = 46; +export const A_KEY = 65; +export const S_KEY = 83; +export const CMD_KEY = 91; +export const META_KEY = 224; + +// ARIA hidden classes +export const JS_ARIA_CLASS = 'garnish-js-aria'; +export const JS_ARIA_TRUE_CLASS = 'garnish-js-aria-true'; +export const JS_ARIA_FALSE_CLASS = 'garnish-js-aria-false'; + +// Mouse button constants +// NOTE: these match the legacy `event.which` numbering (1 = primary, 3 = secondary). +// Native code should prefer `MouseEvent.button` (0 = primary). Kept for compat-layer parity. +export const PRIMARY_CLICK = 1; +export const SECONDARY_CLICK = 3; + +// Axis constants +export const X_AXIS = 'x'; +export const Y_AXIS = 'y'; + +export const FX_DURATION = 200; + +// Node types +export const TEXT_NODE = 3; + +// Shake animation +export const SHAKE_STEPS = 10; +export const SHAKE_STEP_DURATION = 25; diff --git a/packages/craftcms-garnish/src/custom-events/activate.ts b/packages/craftcms-garnish/src/custom-events/activate.ts new file mode 100644 index 00000000000..24f3dffb08d --- /dev/null +++ b/packages/craftcms-garnish/src/custom-events/activate.ts @@ -0,0 +1,149 @@ +/** + * Garnish `activate` custom event. + * + * Native replacement for the legacy `$.event.special.activate`. Wires + * mousedown/click/keydown listeners that dispatch a synthetic `activate` + * CustomEvent, preserving the legacy focus/preventDefault behavior. + */ + +import {RETURN_KEY, SPACE_KEY} from '../constants'; +import {globals} from '../globals'; +import {isCtrlKeyPressed} from '../utils/env'; +import {hasAttr} from '../utils/dom'; + +// Track installs so installation is idempotent per element. +const installed = new WeakMap< + HTMLElement, + {count: number; dispose: () => void} +>(); + +function isButtonish(el: HTMLElement): boolean { + return el.nodeName === 'BUTTON' || el.getAttribute('role') === 'button'; +} + +function dispatchActivate(el: HTMLElement, originalEvent: Event): void { + const event = new CustomEvent('activate', { + bubbles: true, + cancelable: true, + detail: {originalEvent}, + }); + // Expose `originalEvent` directly (legacy shape). + (event as unknown as {originalEvent: Event}).originalEvent = originalEvent; + el.dispatchEvent(event); +} + +/** + * Attach Garnish `activate` semantics to an element: a unified click/keyboard + * "activate" event (Space/Enter or click) that respects disabled state, manages + * `tabindex`, and avoids hijacking modifier-clicks on real links. Dispatched as + * a native `activate` `CustomEvent`. Usually reached via + * `addListener(el, 'activate', fn)` rather than called directly. + * + * Idempotent per element: repeated calls increment a ref-count. + * + * @param el - The element to make activatable. + * @returns A disposer that decrements the ref-count and removes the listeners + * when the last consumer releases. + */ +export function installActivate(el: HTMLElement): () => void { + const existing = installed.get(el); + if (existing) { + existing.count++; + return makeDisposer(el); + } + + const onMousedown = (e: MouseEvent): void => { + // Prevent buttons from getting focus on click. + if (isButtonish(e.currentTarget as HTMLElement)) { + e.preventDefault(); + } + }; + + const onClick = (e: MouseEvent): void => { + if (globals.activateEventsMuted) { + return; + } + + const disabled = el.classList.contains('disabled'); + + // Don't interfere with Ctrl/⌘-clicks on real links. + if ( + !disabled && + el.nodeName === 'A' && + hasAttr(el, 'href') && + !['#', ''].includes(el.getAttribute('href') ?? '') && + isCtrlKeyPressed(e) + ) { + return; + } + + if (isButtonish(e.currentTarget as HTMLElement)) { + e.preventDefault(); + } + + if (!disabled) { + dispatchActivate(el, e); + } + }; + + const onKeydown = (e: KeyboardEvent): void => { + // Ignore muted, bubbled events, or non-Space/Return keys. + if ( + globals.activateEventsMuted || + e.currentTarget !== el || + ![SPACE_KEY, RETURN_KEY].includes(e.keyCode) + ) { + return; + } + + if (isButtonish(e.currentTarget as HTMLElement)) { + e.preventDefault(); + } + + if (!el.classList.contains('disabled')) { + dispatchActivate(el, e); + } + }; + + el.addEventListener('mousedown', onMousedown as EventListener); + el.addEventListener('click', onClick as EventListener); + el.addEventListener('keydown', onKeydown as EventListener); + + // Manage tabindex, mirroring the legacy setup. + if (!el.classList.contains('disabled')) { + // Make focusable unless this is the body element. + if (el !== document.body) { + el.setAttribute('tabindex', '0'); + } + } else if (!el.classList.contains('read-only')) { + el.removeAttribute('tabindex'); + } + + const dispose = (): void => { + el.removeEventListener('mousedown', onMousedown as EventListener); + el.removeEventListener('click', onClick as EventListener); + el.removeEventListener('keydown', onKeydown as EventListener); + }; + + installed.set(el, {count: 1, dispose}); + return makeDisposer(el); +} + +function makeDisposer(el: HTMLElement): () => void { + let called = false; + return () => { + if (called) { + return; + } + called = true; + const record = installed.get(el); + if (!record) { + return; + } + record.count--; + if (record.count <= 0) { + record.dispose(); + installed.delete(el); + } + }; +} diff --git a/packages/craftcms-garnish/src/custom-events/index.ts b/packages/craftcms-garnish/src/custom-events/index.ts new file mode 100644 index 00000000000..d73428cea83 --- /dev/null +++ b/packages/craftcms-garnish/src/custom-events/index.ts @@ -0,0 +1,11 @@ +/** + * Garnish custom DOM events — native installers replacing `$.event.special`. + */ + +export {installActivate} from './activate'; +export {installTextchange, type TextchangeOptions} from './textchange'; +export {installResize} from './resize'; + +export interface ActivateOptions { + /* none today */ +} diff --git a/packages/craftcms-garnish/src/custom-events/resize.ts b/packages/craftcms-garnish/src/custom-events/resize.ts new file mode 100644 index 00000000000..e1f052c6472 --- /dev/null +++ b/packages/craftcms-garnish/src/custom-events/resize.ts @@ -0,0 +1,104 @@ +/** + * Garnish `resize` custom event. + * + * Native replacement for the legacy `$.event.special.resize`. The window uses + * the native `resize` event; other elements share a single lazily-created + * `ResizeObserver` and only dispatch when their dimensions actually change and + * resize events aren't muted. + */ + +import {globals} from '../globals'; + +interface SizeRecord { + width: number; + height: number; +} + +// Per-observed-element last-known size. +const sizes = new WeakMap(); + +let resizeObserver: ResizeObserver | undefined; + +function getResizeObserver(): ResizeObserver { + if (!resizeObserver) { + resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + const size = sizes.get(entry.target); + if (!size) { + continue; + } + const {width, height} = entry.target.getBoundingClientRect(); + if (width !== size.width || height !== size.height) { + sizes.set(entry.target, {width, height}); + if (!globals.resizeEventsMuted) { + entry.target.dispatchEvent( + new CustomEvent('resize', {bubbles: false, cancelable: false}) + ); + } + } + } + }); + } + return resizeObserver; +} + +const installed = new WeakMap< + EventTarget, + {count: number; dispose: () => void} +>(); + +/** + * Attach `resize` semantics to an element (or `window`): a `resize` + * `CustomEvent` that fires when the element's dimensions actually change, backed + * by a shared `ResizeObserver` (the window uses the native `resize` event). + * Suppressed while resize events are muted (see `muteResizeEvents`). Usually + * reached via `addListener(el, 'resize', fn)`. Idempotent per target + * (ref-counted). + * + * @param el - The element (or window) to observe. + * @returns A disposer that unobserves when the last consumer releases. + */ +export function installResize(el: HTMLElement): () => void { + const existing = installed.get(el); + if (existing) { + existing.count++; + return makeDisposer(el); + } + + // The window natively supports `resize`; nothing to install. + if ((el as unknown) === window) { + installed.set(el, {count: 1, dispose: () => {}}); + return makeDisposer(el); + } + + const {width, height} = el.getBoundingClientRect(); + sizes.set(el, {width, height}); + getResizeObserver().observe(el); + + const dispose = (): void => { + getResizeObserver().unobserve(el); + sizes.delete(el); + }; + + installed.set(el, {count: 1, dispose}); + return makeDisposer(el); +} + +function makeDisposer(el: EventTarget): () => void { + let called = false; + return () => { + if (called) { + return; + } + called = true; + const record = installed.get(el); + if (!record) { + return; + } + record.count--; + if (record.count <= 0) { + record.dispose(); + installed.delete(el); + } + }; +} diff --git a/packages/craftcms-garnish/src/custom-events/textchange.ts b/packages/craftcms-garnish/src/custom-events/textchange.ts new file mode 100644 index 00000000000..4ef6c8fe53e --- /dev/null +++ b/packages/craftcms-garnish/src/custom-events/textchange.ts @@ -0,0 +1,99 @@ +/** + * Garnish `textchange` custom event. + * + * Native replacement for the legacy `$.event.special.textchange`. Stores the + * last value and dispatches a `textchange` CustomEvent only when the value + * actually changes, with optional debounce. + */ + +export interface TextchangeOptions { + delay?: number | null; +} + +const installed = new WeakMap< + HTMLElement, + {count: number; dispose: () => void} +>(); + +/** + * Attach `textchange` semantics to an input: a `textchange` `CustomEvent` that + * fires only when the value actually changes (across keypress/keyup/change/blur), + * with optional debounce. Usually reached via + * `addListener(input, 'textchange', fn)`. Idempotent per element (ref-counted). + * + * @param el - The input/textarea to watch. + * @param options - `{delay}` to debounce dispatch by N milliseconds. + * @returns A disposer that removes the listeners when the last consumer releases. + */ +export function installTextchange( + el: HTMLElement & {value: string}, + options: TextchangeOptions = {} +): () => void { + const existing = installed.get(el); + if (existing) { + existing.count++; + return makeDisposer(el); + } + + let lastValue = el.value; + let delayTimeout: ReturnType | undefined; + const delay = options.delay ?? null; + + const fire = (): void => { + el.dispatchEvent( + new CustomEvent('textchange', {bubbles: true, cancelable: true}) + ); + }; + + const onInputEvent = (): void => { + const val = el.value; + if (val !== lastValue) { + lastValue = val; + + if (delay) { + if (delayTimeout) { + clearTimeout(delayTimeout); + } + delayTimeout = setTimeout(fire, delay); + } else { + fire(); + } + } + }; + + const events = ['keypress', 'keyup', 'change', 'blur']; + for (const type of events) { + el.addEventListener(type, onInputEvent as EventListener); + } + + const dispose = (): void => { + if (delayTimeout) { + clearTimeout(delayTimeout); + } + for (const type of events) { + el.removeEventListener(type, onInputEvent as EventListener); + } + }; + + installed.set(el, {count: 1, dispose}); + return makeDisposer(el); +} + +function makeDisposer(el: HTMLElement): () => void { + let called = false; + return () => { + if (called) { + return; + } + called = true; + const record = installed.get(el); + if (!record) { + return; + } + record.count--; + if (record.count <= 0) { + record.dispose(); + installed.delete(el); + } + }; +} diff --git a/packages/craftcms-garnish/src/dom-listeners.ts b/packages/craftcms-garnish/src/dom-listeners.ts new file mode 100644 index 00000000000..6ce6bae0df6 --- /dev/null +++ b/packages/craftcms-garnish/src/dom-listeners.ts @@ -0,0 +1,292 @@ +/** + * Namespaced DOM-listener registry — jQuery-free replacement for + * `Base.addListener/removeListener/removeAllListeners`. + * + * Native `addEventListener` has no namespace concept, so the registry stores a + * tuple per binding `{element, type, namespace, wrappedHandler, capture}` and + * replays jQuery's namespaced `.off` semantics over that array. + * + * Custom Garnish event types (`activate`/`textchange`/`resize`) are routed + * through the matching installer so `addListener(el, 'activate', fn)` keeps + * working. + */ + +import {parseEvents} from './events'; +import type {GarnishEvent, GarnishEventHandler} from './events'; +import type {ElementInput} from './types'; +import {coerceElements} from './utils/dom'; +import {globals} from './globals'; +import { + installActivate, + installResize, + installTextchange, +} from './custom-events'; + +export type {ElementInput} from './types'; + +export interface DomListenerOptions { + /** Delegated target selector; handler fires only when the event target matches/closests this. */ + delegate?: string; + /** Extra data; used by custom events (e.g. textchange `delay`). Merged into the event object. */ + data?: Record; + capture?: boolean; + passive?: boolean; +} + +/** The minimal host contract the registry needs (a `Base`). */ +export interface DomListenerHost { + readonly disabled: boolean; +} + +interface Binding { + element: EventTarget; + type: string; + namespace: string | null; + wrappedHandler: EventListener; + capture: boolean; + /** Disposer for synthetic custom events; native listeners use removeEventListener. */ + dispose?: () => void; +} + +const CUSTOM_EVENT_TYPES = new Set(['activate', 'textchange', 'resize']); + +/** + * The namespaced DOM-listener registry that backs {@link Base.addListener} / + * `removeListener` / `removeAllListeners`. Each `Base` owns one. It records every + * binding so jQuery-style namespaced removal and bulk teardown work without + * jQuery, and routes the custom Garnish events (`activate`/`textchange`/`resize`) + * through their installers. Exported for advanced/standalone use; most code goes + * through `Base`. + */ +export class DomListenerRegistry { + private readonly bindings: Binding[] = []; + + constructor(private readonly host: DomListenerHost) {} + + /** + * Bind one or more (comma/space-grammar) events on the resolved elements. + * The handler runs with the host's `this` already applied by the caller, and + * is short-circuited while `host.disabled` is true. + */ + add( + elements: ElementInput, + events: string, + handler: GarnishEventHandler, + options: DomListenerOptions = {} + ): void { + const targets = coerceElements(elements); + if (targets.length === 0) { + return; + } + + // `addListener` grammar: split on commas. + const parsed = parseEvents(events, ','); + const capture = options.capture ?? false; + + for (const target of targets) { + for (const ev of parsed) { + if (CUSTOM_EVENT_TYPES.has(ev.type)) { + this.bindCustom(target, ev.type, ev.namespace, handler, options); + } else { + this.bindNative( + target, + ev.type, + ev.namespace, + handler, + options, + capture + ); + } + } + } + } + + private wrap( + target: EventTarget, + type: string, + handler: GarnishEventHandler, + options: DomListenerOptions + ): EventListener { + return (nativeEvent: Event): void => { + // Disabled gate (legacy `_disabled`). + if (this.host.disabled) { + return; + } + + let currentTarget: EventTarget | null = target; + + // Delegation: only fire when the event originated within the selector. + if (options.delegate) { + const origin = nativeEvent.target as Element | null; + const matched = origin?.closest?.(options.delegate) ?? null; + if (!matched || !(target as Element).contains?.(matched)) { + return; + } + currentTarget = matched; + } + + // Build a Garnish-style event object that also exposes native props. + const garnishEvent = this.toGarnishEvent( + nativeEvent, + type, + currentTarget, + options + ); + handler(garnishEvent); + }; + } + + private toGarnishEvent( + nativeEvent: Event, + type: string, + currentTarget: EventTarget | null, + options: DomListenerOptions + ): GarnishEvent { + // Pass through the real DOM event; only define fields that are missing so we + // never write to read-only native getters (`type`/`target` already exist). + const ev = nativeEvent as unknown as GarnishEvent; + + if (!('data' in ev) || ev.data === undefined) { + Object.defineProperty(ev, 'data', { + value: options.data ?? {}, + configurable: true, + enumerable: true, + writable: true, + }); + } + + // Expose the delegated match (parity with jQuery's delegate currentTarget). + if (options.delegate && currentTarget) { + Object.defineProperty(ev, 'garnishTarget', { + value: currentTarget, + configurable: true, + enumerable: true, + writable: true, + }); + } + + return ev; + } + + private bindNative( + target: EventTarget, + type: string, + namespace: string | null, + handler: GarnishEventHandler, + options: DomListenerOptions, + capture: boolean + ): void { + const wrapped = this.wrap(target, type, handler, options); + target.addEventListener(type, wrapped, { + capture, + passive: options.passive, + }); + this.bindings.push({ + element: target, + type, + namespace, + wrappedHandler: wrapped, + capture, + }); + } + + private bindCustom( + target: EventTarget, + type: string, + namespace: string | null, + handler: GarnishEventHandler, + options: DomListenerOptions + ): void { + const el = target as HTMLElement; + + // Install the synthetic event source (idempotent per element/type). + let installerDispose: () => void = () => {}; + if (type === 'activate') { + installerDispose = installActivate(el); + } else if (type === 'textchange') { + installerDispose = installTextchange( + el as HTMLElement & {value: string}, + {delay: (options.data?.delay as number | null | undefined) ?? null} + ); + } else if (type === 'resize') { + installerDispose = installResize(el); + } + + // The synthetic event dispatches as a native CustomEvent on the element. + const wrapped = this.wrap(target, type, handler, options); + target.addEventListener(type, wrapped); + + const binding: Binding = { + element: target, + type, + namespace, + wrappedHandler: wrapped, + capture: false, + }; + binding.dispose = () => { + target.removeEventListener(type, wrapped); + // Only tear down the installer if this was the last listener of its type. + const stillBound = this.bindings.some( + (b) => b !== binding && b.element === target && b.type === type + ); + if (!stillBound) { + installerDispose(); + } + }; + this.bindings.push(binding); + } + + /** Remove specific event(s) previously bound by this host on the element(s). */ + remove(elements: ElementInput, events: string): void { + const targets = coerceElements(elements); + const parsed = parseEvents(events, ','); + + for (const target of targets) { + for (const ev of parsed) { + this.removeWhere( + (b) => + b.element === target && + // An empty type with a namespace removes all of that namespace. + (ev.type === '' || b.type === ev.type) && + (!ev.namespace || b.namespace === ev.namespace) + ); + } + } + } + + /** Remove all listeners this host bound on the element(s). */ + removeAllOn(elements: ElementInput): void { + const targets = coerceElements(elements); + for (const target of targets) { + this.removeWhere((b) => b.element === target); + } + } + + /** Remove everything this host bound anywhere (used by destroy). */ + removeAll(): void { + this.removeWhere(() => true); + } + + private removeWhere(predicate: (b: Binding) => boolean): void { + for (let i = this.bindings.length - 1; i >= 0; i--) { + const binding = this.bindings[i]!; + if (!predicate(binding)) { + continue; + } + + if (binding.dispose) { + binding.dispose(); + } else { + binding.element.removeEventListener( + binding.type, + binding.wrappedHandler, + {capture: binding.capture} + ); + } + this.bindings.splice(i, 1); + } + } +} + +// Re-export for convenience; keeps `globals` import tree-shake friendly. +void globals; diff --git a/packages/craftcms-garnish/src/drag-move.ts b/packages/craftcms-garnish/src/drag-move.ts new file mode 100644 index 00000000000..21701a13dc7 --- /dev/null +++ b/packages/craftcms-garnish/src/drag-move.ts @@ -0,0 +1,26 @@ +/** + * DragMove — drag-to-move helper. + * + * The legacy `DragMove` is a ~15-line subclass of `BaseDrag`, which updates an + * element's `left`/`top` as the pointer moves. `BaseDrag` (~580 lines) is + * intentionally NOT part of the modern core for the vertical-slice PoC (doc 03 + * §2/§5: Tier 2), so there is nothing to subclass yet. + * + * Rather than ship a half-built dragger, this is a clearly-marked placeholder. + * `Modal` defaults `draggable:false`/`resizable:false` and throws a descriptive + * error if either is enabled, so no consumer path silently relies on this. + * + * When `BaseDrag` lands, replace this with the real `class DragMove extends + * BaseDrag` and remove the throwing constructor. + */ + +export class DragMove { + constructor() { + throw new Error( + 'Garnish.DragMove is not yet supported in the modern build: BaseDrag is out of PoC scope. ' + + 'Modal’s `draggable`/`resizable` options remain disabled until BaseDrag is ported.' + ); + } +} + +export default DragMove; diff --git a/packages/craftcms-garnish/src/events.ts b/packages/craftcms-garnish/src/events.ts new file mode 100644 index 00000000000..a119c850393 --- /dev/null +++ b/packages/craftcms-garnish/src/events.ts @@ -0,0 +1,395 @@ +/** + * Garnish event system (object pub/sub + class-level pub/sub). + * + * Reproduces the two in-memory observer registries from the legacy core: + * - `EventEmitter` backs `Base.on/off/once/trigger` (per-instance). + * - `ClassEventBus` backs `Garnish.on/off/once` (class-level, instanceof dispatch). + * + * DOM listeners are a separate concern (see dom-listeners.ts). + */ + +import type {Constructor} from './types'; + +/** Result of parsing a single event token. */ +export interface ParsedEvent { + /** Event type, e.g. `'click'`. May be `''` for the "remove by namespace" form. */ + type: string; + /** Namespace, e.g. `'foo'` for `'click.foo'`. `null` when none. */ + namespace: string | null; +} + +/** + * The event object passed to every Garnish event handler. For object events it + * carries the emitting instance as {@link target} plus any data; for DOM events + * routed through {@link Base.addListener} it is the native `Event` augmented with + * these fields, so native props (`stopPropagation`, `currentTarget`, …) are also + * available at runtime. + */ +export interface GarnishEvent { + type: string; + /** The emitting object (legacy: the Base instance). */ + target: Target; + /** Per-registration data merged with trigger-time data. */ + data: Record; + /** Present when re-emitting a DOM event (legacy `activate` etc.). */ + originalEvent?: Event; + /** Trigger-time payload is spread on (legacy `$.extend` behavior). */ + [extra: string]: unknown; +} + +/** + * A Garnish event handler: a callback receiving a {@link GarnishEvent}. This is + * the handler type accepted by {@link Base.on} / {@link Base.once} and + * {@link Base.addListener}. + */ +export type GarnishEventHandler = ( + event: E +) => void; + +/** Internal registration record. */ +interface Registration { + type: string; + namespace: string | null; + data: Record; + handler: GarnishEventHandler; +} + +/** + * Parse a Garnish event string into `{type, namespace}` records. + * + * Legacy `_normalizeEvents` splits on spaces, then each token on its **first** + * `.` into `[type, namespace]`. This preserves that grammar exactly. + * + * @param events An event string or pre-split array. + * @param splitOn Separator for multiple events: `' '` for pub/sub (default), + * `','` for `addListener` (the legacy split inconsistency). + */ +export function parseEvents( + events: string | string[], + splitOn: ' ' | ',' = ' ' +): ParsedEvent[] { + let tokens: string[]; + + if (typeof events === 'string') { + tokens = events.split(splitOn); + } else { + tokens = events; + } + + const parsed: ParsedEvent[] = []; + + for (let token of tokens) { + if (typeof token !== 'string') { + continue; + } + + // For comma splitting, legacy trims each token. + if (splitOn === ',') { + token = token.trim(); + } + + // Split on the first '.' only. 'click.a.b' => type='click', namespace='a.b'. + const dotIndex = token.indexOf('.'); + if (dotIndex === -1) { + parsed.push({type: token, namespace: null}); + } else { + parsed.push({ + type: token.slice(0, dotIndex), + namespace: token.slice(dotIndex + 1) || null, + }); + } + } + + return parsed; +} + +/** + * Format an event string for DOM binding by appending an instance namespace. + * + * Mirrors legacy `_formatEvents`: splits on commas (the `addListener` grammar), + * trims, appends the namespace, and rejoins on spaces. + * + * `formatDomEvents('click,drag', '.Garnish123')` => `'click.Garnish123 drag.Garnish123'` + */ +export function formatDomEvents( + events: string | string[], + namespace: string +): string { + let tokens: string[]; + + if (typeof events === 'string') { + tokens = events.split(','); + } else { + tokens = events.slice(); + } + + return tokens.map((token) => `${token.trim()}${namespace}`).join(' '); +} + +/** + * Build the event object passed to a handler. + * + * Reproduces the legacy precedence exactly: + * `$.extend({data: registration.data}, triggerData, {type, target})` + * + * i.e. trigger-time data keys win over registration `data`, and `type`/`target` + * always win last. This is observable — some call sites pass `target` overrides + * in `data` and legacy lets the `ev` object override them. + */ +function buildEvent( + registrationData: Record, + triggerData: Record | undefined, + type: string, + target: unknown +): GarnishEvent { + return Object.assign({data: registrationData}, triggerData, { + type, + target, + }) as GarnishEvent; +} + +/** + * Per-instance object pub/sub registry backing {@link Base.on} / `once` / `off` + * / `trigger`. One emitter exists per `Base` instance and owns that instance as + * the event {@link GarnishEvent.target}. Exported for advanced/standalone use; + * most code interacts with it through `Base`. + */ +export class EventEmitter { + private readonly registrations: Registration[] = []; + + constructor(private readonly target: Target) {} + + on(events: string, handler: GarnishEventHandler): void; + on( + events: string, + data: Record, + handler: GarnishEventHandler + ): void; + on( + events: string, + dataOrHandler: Record | GarnishEventHandler, + handler?: GarnishEventHandler + ): void { + let data: Record; + let fn: GarnishEventHandler; + + if (typeof dataOrHandler === 'function') { + fn = dataOrHandler; + data = {}; + } else { + data = dataOrHandler; + fn = handler as GarnishEventHandler; + } + + for (const ev of parseEvents(events)) { + this.registrations.push({ + type: ev.type, + namespace: ev.namespace, + data, + handler: fn, + }); + } + } + + once(events: string, handler: GarnishEventHandler): void; + once( + events: string, + data: Record, + handler: GarnishEventHandler + ): void; + once( + events: string, + dataOrHandler: Record | GarnishEventHandler, + handler?: GarnishEventHandler + ): void { + let data: Record; + let fn: GarnishEventHandler; + + if (typeof dataOrHandler === 'function') { + fn = dataOrHandler; + data = {}; + } else { + data = dataOrHandler; + fn = handler as GarnishEventHandler; + } + + // Wrapper-closure `once` (legacy behavior): `off(events, onceler)` removes it. + const onceler: GarnishEventHandler = (event) => { + this.off(events, onceler); + fn(event); + }; + + this.on(events, data, onceler); + } + + /** + * Remove matching registrations. A registration matches when its `type` === + * the parsed type, AND (the parsed namespace is empty OR namespaces match), + * AND (handler omitted OR handler ===). Iterates backwards + splices. + */ + off(events: string, handler?: GarnishEventHandler): void { + for (const ev of parseEvents(events)) { + for (let i = this.registrations.length - 1; i >= 0; i--) { + const reg = this.registrations[i]!; + + if ( + reg.type === ev.type && + (!ev.namespace || reg.namespace === ev.namespace) && + (handler === undefined || reg.handler === handler) + ) { + this.registrations.splice(i, 1); + } + } + } + } + + /** Dispatch to instance handlers matching `type`. */ + trigger(type: string, data?: Record): void { + // Snapshot the matching handlers first; a `once` handler mutates the array. + const matching = this.registrations.filter((reg) => reg.type === type); + + for (const reg of matching) { + const event = buildEvent(reg.data, data, type, this.target); + reg.handler(event); + } + } + + /** Remove every registration (used by destroy). */ + clear(): void { + this.registrations.length = 0; + } +} + +/** + * Class-level pub/sub bus backing `Garnish.on/off/once`. Handlers are registered + * against a class; any instance's {@link Base.trigger} dispatches to class-level + * handlers whose target the instance is an `instanceof`. A single shared + * instance powers the `Garnish` namespace; exported for advanced use. + */ +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; + on( + target: Constructor, + events: string, + dataOrHandler: Record | GarnishEventHandler, + handler?: GarnishEventHandler + ): void { + if (typeof target === 'undefined') { + console.warn('Garnish.on() called for an invalid target class.'); + return; + } + + let data: Record; + let fn: GarnishEventHandler; + + if (typeof dataOrHandler === 'function') { + fn = dataOrHandler; + data = {}; + } else { + data = dataOrHandler; + fn = handler as GarnishEventHandler; + } + + for (const ev of parseEvents(events)) { + this.registrations.push({ + target, + type: ev.type, + namespace: ev.namespace, + data, + handler: fn, + }); + } + } + + once(target: Constructor, events: string, handler: GarnishEventHandler): void; + once( + target: Constructor, + events: string, + data: Record, + handler: GarnishEventHandler + ): void; + once( + target: Constructor, + events: string, + dataOrHandler: Record | GarnishEventHandler, + handler?: GarnishEventHandler + ): void { + if (typeof target === 'undefined') { + console.warn('Garnish.once() called for an invalid target class.'); + return; + } + + let data: Record; + let fn: GarnishEventHandler; + + if (typeof dataOrHandler === 'function') { + fn = dataOrHandler; + data = {}; + } else { + data = dataOrHandler; + fn = handler as GarnishEventHandler; + } + + const onceler: GarnishEventHandler = (event) => { + this.off(target, events, onceler); + fn(event); + }; + + this.on(target, events, data, onceler); + } + + off( + target: Constructor, + events: string, + handler?: GarnishEventHandler + ): void { + for (const ev of parseEvents(events)) { + for (let i = this.registrations.length - 1; i >= 0; i--) { + const reg = this.registrations[i]!; + + if ( + reg.target === target && + reg.type === ev.type && + (!ev.namespace || reg.namespace === ev.namespace) && + (handler === undefined || reg.handler === handler) + ) { + this.registrations.splice(i, 1); + } + } + } + } + + /** + * Dispatch class-level handlers whose target the instance is an `instanceof` + * (legacy `Garnish._eventHandlers` walk inside `Base.trigger`). + */ + dispatch( + instance: object, + type: string, + data: Record | undefined + ): void { + const matching = this.registrations.filter( + (reg) => + reg && reg.target && instance instanceof reg.target && reg.type === type + ); + + for (const reg of matching) { + const event = buildEvent(reg.data, data, type, instance); + reg.handler(event); + } + } + + /** Remove every registration (mostly for tests). */ + clear(): void { + this.registrations.length = 0; + } +} diff --git a/packages/craftcms-garnish/src/globals.ts b/packages/craftcms-garnish/src/globals.ts new file mode 100644 index 00000000000..4ff6a41358b --- /dev/null +++ b/packages/craftcms-garnish/src/globals.ts @@ -0,0 +1,41 @@ +/** + * Garnish shared global state. + * + * Native references replacing the legacy jQuery collections (`$win`/`$doc`/ + * `$bod`/`$scrollContainer`), mutable feature flags, and the single shared + * class-level event bus instance. + * + * The compat layer re-exposes the jQuery-wrapped forms (`$win` etc.). + */ + +import {ClassEventBus} from './events'; + +/** `window` (compat exposes `$win = $(window)`). */ +export const win: Window = window; + +/** `document` (compat exposes `$doc = $(document)`). */ +export const doc: Document = document; + +/** `document.body` (compat exposes `$bod = $(document.body)`). */ +export const bod: HTMLElement = document.body; + +/** + * The scroll container. Defaults to `window`; legacy `$scrollContainer` could be + * reassigned to another element. Kept mutable for parity. + */ +export const globals = { + scrollContainer: window as EventTarget, + activateEventsMuted: false, + resizeEventsMuted: false, + // Derived from `document.body`'s `rtl` class at module init. + rtl: document.body.classList.contains('rtl'), + get ltr(): boolean { + return !this.rtl; + }, +}; + +/** + * The single shared class-level event bus. `Base.trigger` dispatches into it, + * and `Garnish.on/off/once` proxy to it. + */ +export const garnishClassBus = new ClassEventBus(); diff --git a/packages/craftcms-garnish/src/icons/resize-handle.ts b/packages/craftcms-garnish/src/icons/resize-handle.ts new file mode 100644 index 00000000000..ad9cc80f8ea --- /dev/null +++ b/packages/craftcms-garnish/src/icons/resize-handle.ts @@ -0,0 +1,9 @@ +/** + * ResizeHandle SVG markup (ltr + rtl variants), ported verbatim from + * `icons/ResizeHandle.js`. Used by resizable modals. + */ + +export const ResizeHandle = + ''; + +export default ResizeHandle; diff --git a/packages/craftcms-garnish/src/index.ts b/packages/craftcms-garnish/src/index.ts new file mode 100644 index 00000000000..3d69f17c88b --- /dev/null +++ b/packages/craftcms-garnish/src/index.ts @@ -0,0 +1,230 @@ +/** + * @craftcms/garnish — modern, tree-shakeable TypeScript rewrite of Craft CMS's + * Garnish UI library. jQuery-free, ESM-only. + * + * Exposes BOTH tree-shakeable named exports (preferred for new code) and a + * legacy-shaped `Garnish` namespace object (+ `initGarnish()`) for incremental + * migration and for the compat layer to extend. + * + * The core deliberately does NOT: assign `window.Garnish`, add `$.fn` sugar, or + * pull in jQuery — those are the compat layer's job. + */ + +import {Base} from './base'; +import { + ClassEventBus, + EventEmitter, + formatDomEvents, + parseEvents, + type GarnishEvent, + type GarnishEventHandler, +} from './events'; +import { + DomListenerRegistry, + type DomListenerOptions, + type ElementInput, +} from './dom-listeners'; +import { + installActivate, + installResize, + installTextchange, +} from './custom-events'; +import {EscManager} from './managers/esc-manager'; +import {UiLayerManager} from './managers/ui-layer-manager'; +import {setUiLayerManager} from './managers/registry'; +import {Modal, type ModalSettings} from './modal'; +import {DragMove} from './drag-move'; +import {ResizeHandle} from './icons/resize-handle'; +import {garnishClassBus, globals, win, doc, bod} from './globals'; +import type {Constructor, GarnishBaseSettings} from './types'; + +import * as constants from './constants'; +import * as utils from './utils'; + +// --- Named exports (preferred for new code) --------------------------------- + +export {Base}; +export type {GarnishBaseSettings, ElementInput}; +export { + EventEmitter, + ClassEventBus, + parseEvents, + formatDomEvents, + type GarnishEvent, + type GarnishEventHandler, +}; +export {DomListenerRegistry, type DomListenerOptions}; +export {installActivate, installTextchange, installResize}; +export type {TextchangeOptions, ActivateOptions} from './custom-events'; +export {EscManager}; +export {UiLayerManager}; +export {Modal, type ModalSettings}; +export {DragMove}; +export {ResizeHandle}; +export {win, doc, bod}; + +export * from './utils'; +export * from './constants'; + +export const VERSION = '0.0.0'; + +// --- Legacy-shaped namespace object ----------------------------------------- + +/** + * Class-level event bus proxies. Legacy `Garnish.on/off/once` register + * class-level handlers dispatched via instanceof in `Base.trigger`. + */ +function on( + target: Constructor, + events: string, + data: Record | GarnishEventHandler, + handler?: GarnishEventHandler +): void { + (garnishClassBus.on as (...a: unknown[]) => void)( + target, + events, + data, + handler + ); +} +function off( + target: Constructor, + events: string, + handler?: GarnishEventHandler +): void { + garnishClassBus.off(target, events, handler); +} +function once( + target: Constructor, + events: string, + data: Record | GarnishEventHandler, + handler?: GarnishEventHandler +): void { + (garnishClassBus.once as (...a: unknown[]) => void)( + target, + events, + data, + handler + ); +} + +/** + * The legacy-shaped `Garnish` namespace object — a single value carrying every + * Garnish class, constant, utility, and feature flag, mirroring the legacy + * `window.Garnish` singleton. + * + * This is provided for incremental migration and is what the + * `@craftcms/garnish/compat` layer wraps and assigns to `window.Garnish`. **New + * code should prefer the tree-shakeable named exports** (e.g. + * `import {Modal, trapFocusWithin} from '@craftcms/garnish'`) — importing this + * object pulls in the whole surface. + * + * The manager singletons (`escManager`, `uiLayerManager`) are `undefined` until + * {@link initGarnish} is called. + */ +export const Garnish = { + // constants + ...constants, + + // native globals (compat adds the jQuery-wrapped $win/$doc/$bod forms) + win, + doc, + bod, + get scrollContainer() { + return globals.scrollContainer; + }, + set scrollContainer(value: EventTarget) { + globals.scrollContainer = value; + }, + get rtl() { + return globals.rtl; + }, + get ltr() { + return globals.ltr; + }, + get activateEventsMuted() { + return globals.activateEventsMuted; + }, + set activateEventsMuted(value: boolean) { + globals.activateEventsMuted = value; + }, + get resizeEventsMuted() { + return globals.resizeEventsMuted; + }, + set resizeEventsMuted(value: boolean) { + globals.resizeEventsMuted = value; + }, + + // classes + Base, + EscManager, + UiLayerManager, + Modal, + DragMove, + /** @deprecated Use UiLayerManager instead. */ + ShortcutManager: UiLayerManager, + + // class-level event bus + on, + off, + once, + + // event parser (legacy alias) + _normalizeEvents: (events: string | string[]) => + parseEvents(events).map((e) => [e.type, e.namespace ?? undefined]), + + // custom-event installers + installActivate, + installTextchange, + installResize, + + // icons + ResizeHandle, + + // utilities (every §4 member that survives in core) + ...utils, + muteResizeEvents, + + // managers (attached by initGarnish) + escManager: undefined as EscManager | undefined, + uiLayerManager: undefined as UiLayerManager | undefined, +}; + +/** + * Suppress Garnish `resize` custom events while `callback` runs, then restore + * the previous muted state. Useful when programmatically resizing elements that + * have `resize` listeners and you don't want them to fire. + * + * @param callback - The work to run with resize events muted. + */ +function muteResizeEvents(callback: () => void): void { + const prior = globals.resizeEventsMuted; + globals.resizeEventsMuted = true; + callback(); + globals.resizeEventsMuted = prior; +} + +export {muteResizeEvents}; + +/** + * Lazily instantiate the manager singletons (`escManager`, `uiLayerManager`) + * and attach them to the {@link Garnish} namespace, registering the UI layer + * manager so layer-aware components (like {@link Modal}) can find it. Idempotent. + * + * The compat layer calls this during install; call it yourself if you use the + * `Garnish` namespace object directly and need the managers populated. + * + * @returns The {@link Garnish} namespace, with managers attached. + */ +export function initGarnish(): typeof Garnish { + if (!Garnish.escManager) { + Garnish.escManager = new EscManager(); + } + if (!Garnish.uiLayerManager) { + Garnish.uiLayerManager = new UiLayerManager(); + setUiLayerManager(Garnish.uiLayerManager); + } + return Garnish; +} + +export default Garnish; diff --git a/packages/craftcms-garnish/src/managers/esc-manager.ts b/packages/craftcms-garnish/src/managers/esc-manager.ts new file mode 100644 index 00000000000..5b3701d1d66 --- /dev/null +++ b/packages/craftcms-garnish/src/managers/esc-manager.ts @@ -0,0 +1,67 @@ +/** + * ESC key manager. + * + * @deprecated Use UiLayerManager instead. Kept for parity. + */ + +import {Base} from '../base'; +import {bod} from '../globals'; +import {ESC_KEY} from '../constants'; + +type EscHandlerFn = (ev: KeyboardEvent) => void; + +interface EscRegistration { + obj: { + [key: string]: unknown; + trigger?: (type: string) => void; + }; + func: EscHandlerFn | string; +} + +export class EscManager extends Base { + private handlers: EscRegistration[] = []; + + constructor() { + super(); + + this.addListener(bod, 'keyup', (ev) => { + const keyEvent = ev as unknown as KeyboardEvent; + if (keyEvent.keyCode === ESC_KEY) { + this.escapeLatest(keyEvent); + } + }); + } + + register(obj: EscRegistration['obj'], func: EscHandlerFn | string): void { + this.handlers.push({obj, func}); + } + + unregister(obj: EscRegistration['obj']): void { + for (let i = this.handlers.length - 1; i >= 0; i--) { + if (this.handlers[i]!.obj === obj) { + this.handlers.splice(i, 1); + } + } + } + + escapeLatest(ev: KeyboardEvent): void { + if (!this.handlers.length) { + return; + } + + const handler = this.handlers.pop()!; + + let func: EscHandlerFn; + if (typeof handler.func === 'function') { + func = handler.func; + } else { + func = handler.obj[handler.func] as EscHandlerFn; + } + + func.call(handler.obj, ev); + + if (typeof handler.obj.trigger === 'function') { + handler.obj.trigger('escape'); + } + } +} diff --git a/packages/craftcms-garnish/src/managers/registry.ts b/packages/craftcms-garnish/src/managers/registry.ts new file mode 100644 index 00000000000..c89bab29778 --- /dev/null +++ b/packages/craftcms-garnish/src/managers/registry.ts @@ -0,0 +1,53 @@ +/** + * Manager singleton registry. + * + * Breaks the circular dependency between `utils/aria.ts` (which needs the active + * UI layer manager) and `managers/ui-layer-manager.ts`. `initGarnish()` (or the + * compat layer) registers the live instance here. + */ + +/** Minimal shape `utils/aria.ts` needs from the UI layer manager. */ +export interface UiLayer { + $container: Element | null; + isModal?: boolean; +} + +export interface UiLayerManagerLike { + readonly currentLayer: UiLayer; + readonly highestModalLayer: UiLayer | undefined; +} + +/** + * The fuller manager surface Modal (and other layer-using components) need: + * layer registration plus shortcut registration. Kept structural so the + * registry has no hard dependency on the concrete `UiLayerManager` class + * (avoids a circular import with the `index.ts` barrel). + */ +export interface UiLayerManagerFull extends UiLayerManagerLike { + addLayer( + container?: Element | Record | null, + options?: Record + ): unknown; + removeLayer(layer?: Element): unknown; + registerShortcut( + shortcut: number | {keyCode: number}, + callback: (ev: KeyboardEvent) => void, + layer?: number + ): unknown; + unregisterShortcut( + shortcut: number | {keyCode: number}, + layer?: number + ): unknown; +} + +let uiLayerManager: UiLayerManagerFull | undefined; + +export function setUiLayerManager( + manager: UiLayerManagerFull | undefined +): void { + uiLayerManager = manager; +} + +export function getUiLayerManager(): UiLayerManagerFull | undefined { + return uiLayerManager; +} diff --git a/packages/craftcms-garnish/src/managers/ui-layer-manager.ts b/packages/craftcms-garnish/src/managers/ui-layer-manager.ts new file mode 100644 index 00000000000..4d955caff7e --- /dev/null +++ b/packages/craftcms-garnish/src/managers/ui-layer-manager.ts @@ -0,0 +1,304 @@ +/** + * UI Layer Manager. + * + * Manages the visible UI "layers" — the base document plus any open modals, + * HUDs, slideouts, or menus. jQuery-free port of the legacy class. + */ + +import {Base} from '../base'; +import {bod} from '../globals'; +import {isCtrlKeyPressed} from '../utils/env'; +import {getElement} from '../utils/dom'; +import type {UiLayer} from './registry'; + +export interface ShortcutDef { + keyCode: number; + ctrl?: boolean; + shift?: boolean; + alt?: boolean; +} + +interface NormalizedShortcut { + keyCode: number; + ctrl: boolean; + shift: boolean; + alt: boolean; +} + +interface RegisteredShortcut { + key: string; + shortcut: NormalizedShortcut; + callback: (ev: KeyboardEvent) => void; +} + +interface LayerOptions { + bubble: boolean; +} + +interface Layer extends UiLayer { + $container: Element | null; + shortcuts: RegisteredShortcut[]; + isModal?: boolean; + options: LayerOptions; +} + +type ShortcutKeyboardEvent = KeyboardEvent & { + bubbleShortcut?: () => void; +}; + +/** + * Manages the stack of visible UI "layers" — the base document plus any open + * modals, HUDs, slideouts, or menus — and the keyboard shortcuts scoped to each. + * The active instance is a singleton, created by {@link initGarnish} and exposed + * as `Garnish.uiLayerManager`; {@link Modal} uses it for layer stacking and its + * Escape shortcut. + * + * Shortcuts are dispatched top-down: a `keydown` is offered to the current + * layer's shortcuts first, then bubbles to lower layers only when the layer opts + * in (`options.bubble`) or `ev.bubbleShortcut()` is called. + */ +export class UiLayerManager extends Base { + /** The layer stack; index `0` is the base document layer. */ + layers: Layer[]; + + constructor() { + super(); + + this.layers = [ + { + $container: bod, + shortcuts: [], + options: {bubble: false}, + }, + ]; + + this.addListener(bod, 'keydown', 'triggerShortcut'); + } + + /** The index of the topmost layer (`0` = base document). */ + get layer(): number { + return this.layers.length - 1; + } + + /** The topmost (active) layer. */ + get currentLayer(): Layer { + return this.layers[this.layer]!; + } + + /** All layers flagged as modal. */ + get modalLayers(): Layer[] { + return this.layers.filter((layer) => layer.isModal === true); + } + + /** The highest modal layer, or `undefined` if no modal is open. */ + get highestModalLayer(): Layer | undefined { + return this.modalLayers.pop(); + } + + /** + * Push a new UI layer onto the stack and emit `addLayer`. A layer whose + * container has `aria-modal="true"` is flagged as modal. + * + * Accepts `(container, options)` or `(options)` (param shift when the first + * arg is a plain options object). + * + * @param container - The layer's container element (or an options object). + * @param options - Layer options, e.g. `{bubble: true}` to let unhandled + * shortcuts fall through to lower layers. + * @returns `this`, for chaining. + */ + addLayer( + container?: Element | Partial | null, + options?: Partial + ): this { + let containerEl: Element | null = null; + + if (isPlainObject(container)) { + options = container as Partial; + containerEl = null; + } else if (container) { + containerEl = (getElement(container as Element) as Element) ?? null; + } + + const resolvedOptions: LayerOptions = Object.assign( + {bubble: false}, + options ?? {} + ); + + this.layers.push({ + $container: containerEl, + shortcuts: [], + isModal: containerEl + ? containerEl.getAttribute('aria-modal') === 'true' + : false, + options: resolvedOptions, + }); + + this.trigger('addLayer', { + layer: this.layer, + $container: this.currentLayer.$container, + options: resolvedOptions, + }); + + return this; + } + + /** + * Remove a layer and emit `removeLayer`. With no argument, pops the topmost + * layer; with an element, removes the matching layer wherever it sits. + * + * @param layer - The container element of the layer to remove (optional). + * @returns `this` when the top layer was popped, otherwise `undefined`. + * @throws If asked to remove the base document layer. + */ + removeLayer(layer?: Element): this | undefined { + if (this.layer === 0) { + throw 'Can’t remove the base layer.'; + } + + if (layer) { + const layerIndex = this.getLayerIndex(layer); + if (layerIndex) { + this.removeLayerAtIndex(layerIndex); + } + return undefined; + } + + this.layers.pop(); + this.trigger('removeLayer'); + return this; + } + + getLayerIndex(layer: Element): number | undefined { + const layerEl = getElement(layer) as Element | undefined; + let layerIndex: number | undefined; + + this.layers.forEach((l, index) => { + if ( + layerIndex === undefined && + l.$container !== null && + l.$container === layerEl + ) { + layerIndex = index; + } + }); + + return layerIndex; + } + + removeLayerAtIndex(index: number): this { + this.layers.splice(index, 1); + this.trigger('removeLayer'); + return this; + } + + /** + * Register a keyboard shortcut on a layer (the current one by default). + * + * @param shortcut - A key code, or a `{keyCode, ctrl?, shift?, alt?}` definition. + * @param callback - Invoked (after `preventDefault`) when the shortcut matches. + * @param layer - Layer index to scope the shortcut to; defaults to the current layer. + * @returns `this`, for chaining. + * + * @example + * ```ts + * import {Garnish, ESC_KEY} from '@craftcms/garnish'; + * Garnish.uiLayerManager!.registerShortcut(ESC_KEY, () => closeThing()); + * ``` + */ + registerShortcut( + shortcut: number | ShortcutDef, + callback: (ev: KeyboardEvent) => void, + layer?: number + ): this { + const normalized = this._normalizeShortcut(shortcut); + if (layer === undefined) { + layer = this.layer; + } + this.layers[layer]!.shortcuts.push({ + key: JSON.stringify(normalized), + shortcut: normalized, + callback, + }); + return this; + } + + /** + * Remove a previously registered shortcut from a layer. + * + * @param shortcut - The same key code / definition passed to {@link registerShortcut}. + * @param layer - Layer index; defaults to the current layer. + * @returns `this`, for chaining. + */ + unregisterShortcut(shortcut: number | ShortcutDef, layer?: number): this { + const normalized = this._normalizeShortcut(shortcut); + const key = JSON.stringify(normalized); + if (layer === undefined) { + layer = this.layer; + } + const index = this.layers[layer]!.shortcuts.findIndex((s) => s.key === key); + if (index !== -1) { + this.layers[layer]!.shortcuts.splice(index, 1); + } + return this; + } + + private _normalizeShortcut( + shortcut: number | ShortcutDef + ): NormalizedShortcut { + let def: ShortcutDef; + if (typeof shortcut === 'number') { + def = {keyCode: shortcut}; + } else { + def = shortcut; + } + + if (typeof def.keyCode !== 'number') { + throw 'Invalid shortcut'; + } + + return { + keyCode: def.keyCode, + ctrl: !!def.ctrl, + shift: !!def.shift, + alt: !!def.alt, + }; + } + + triggerShortcut(ev: ShortcutKeyboardEvent, layerIndex?: number): void { + if (layerIndex === undefined) { + layerIndex = this.layer; + } + const layer = this.layers[layerIndex]!; + const shortcut = layer.shortcuts.find( + (s) => + s.shortcut.keyCode === ev.keyCode && + s.shortcut.ctrl === isCtrlKeyPressed(ev) && + s.shortcut.shift === ev.shiftKey && + s.shortcut.alt === ev.altKey + ); + + ev.bubbleShortcut = () => { + if (layerIndex! > 0) { + this.triggerShortcut(ev, layerIndex! - 1); + } + }; + + if (shortcut) { + ev.preventDefault(); + shortcut.callback(ev); + } else if (layer.options.bubble) { + ev.bubbleShortcut(); + } + } +} + +function isPlainObject(val: unknown): val is Record { + return ( + typeof val === 'object' && + val !== null && + !(val instanceof Element) && + (Object.getPrototypeOf(val) === Object.prototype || + Object.getPrototypeOf(val) === null) + ); +} diff --git a/packages/craftcms-garnish/src/modal.ts b/packages/craftcms-garnish/src/modal.ts new file mode 100644 index 00000000000..01f7a1371f0 --- /dev/null +++ b/packages/craftcms-garnish/src/modal.ts @@ -0,0 +1,752 @@ +/** + * Modal — modern, jQuery-free TypeScript port of Garnish's `Modal`. + * + * This is the vertical-slice PoC component. It replaces every jQuery call in the + * legacy `Modal.js` (doc 03 §2) with native DOM: + * + * - element creation/insertion → `document.createElement` + `appendChild`/`insertBefore` + * - class/attr/style → `classList` / `setAttribute` / `.style` + * - dimensions → `offsetWidth`/`offsetHeight` + * - window size → `window.innerWidth`/`innerHeight` + * - display → `.style.display` + * - data storage → a module-level `WeakMap` + * - plain-object check → a local `isPlainObject` + * - **animation** → the Web Animations API (`element.animate`), gated on + * `prefersReducedMotion`. `.velocity('stop')` → `cancel()` of the tracked + * animation. + * + * Draggable/resizable are deliberately out of PoC scope: `BaseDrag`/`DragMove` + * are not in the modern core, so `draggable`/`resizable` default to `false` and + * the relevant code paths throw a clear "not yet supported" error if turned on. + */ + +import {Base} from './base'; +import {bod, win} from './globals'; +import {ESC_KEY, FX_DURATION} from './constants'; +import {getUiLayerManager} from './managers/registry'; +import { + addModalAttributes, + hideModalBackgroundLayers, + resetModalBackgroundLayerVisibility, +} from './utils/aria'; +import { + getFocusedElement, + setFocusWithin, + trapFocusWithin, + releaseFocusWithin, +} from './utils/focus'; +import {prefersReducedMotion} from './utils/animation'; +import type {GarnishBaseSettings} from './types'; + +/** Duration (ms) of the shade fade, matching legacy. */ +const SHADE_FX_DURATION = 50; + +type ModalCallback = () => void; + +/** A focus target may be an element, or a function returning one. */ +type FocusTarget = + | Element + | null + | undefined + | (() => Element | null | undefined); + +/** + * Settings accepted by {@link Modal}. Pass a `Partial` to the + * constructor; unset keys fall back to {@link Modal.defaults}. + */ +export interface ModalSettings extends GarnishBaseSettings { + /** Show the modal immediately on construction (when a container is given). Default `true`. */ + autoShow: boolean; + /** + * Allow dragging the modal by its handle. + * @remarks Not yet supported in the modern build — enabling it throws. Default `false`. + */ + draggable: boolean; + /** Selector for the drag handle (only relevant when `draggable`). Default `null`. */ + dragHandleSelector: string | null; + /** + * Allow resizing the modal. + * @remarks Not yet supported in the modern build — enabling it throws. Default `false`. + */ + resizable: boolean; + /** Minimum gutter (px) between the modal and the viewport edge. Default `10`. */ + minGutter: number; + /** Callback invoked when the modal is shown. Default no-op. */ + onShow: ModalCallback; + /** Callback invoked when the modal is hidden. Default no-op. */ + onHide: ModalCallback; + /** Callback invoked after the modal finishes fading in. Default no-op. */ + onFadeIn: ModalCallback; + /** Callback invoked after the modal finishes fading out. Default no-op. */ + onFadeOut: ModalCallback; + /** Hide any other visible modal when this one is shown. Default `false`. */ + closeOtherModals: boolean; + /** Hide the modal when the Escape key is pressed. Default `true`. */ + hideOnEsc: boolean; + /** Hide the modal when its shade (backdrop) is clicked. Default `true`. */ + hideOnShadeClick: boolean; + /** + * The element (or a function returning one) to restore focus to when the modal + * hides. Defaults to whatever was focused at construction time. + */ + triggerElement: FocusTarget; + /** CSS class applied to the shade/backdrop element. Default `'modal-shade'`. */ + shadeClass: string; +} + +/** + * Maps a modal container element back to its `Modal` instance — the native + * replacement for the legacy `$container.data('modal', this)`. + */ +const containerModals = new WeakMap(); + +const noop: ModalCallback = () => {}; + +/** + * An accessible, animated modal dialog — the jQuery-free TypeScript port of + * Garnish's `Modal`, and the vertical-slice reference component for this package. + * + * Handles shade/backdrop, centered sizing within the viewport, fade in/out via + * the Web Animations API (respecting `prefers-reduced-motion`), focus trapping + * and restoration, ARIA backgrounding of sibling layers, and Escape / + * shade-click dismissal. Emits `show`, `hide`, `fadeIn`, `fadeOut`, + * `updateSizeAndPosition`, `escape`, and `destroy` events (subscribe with + * {@link Base.on}). + * + * The constructor accepts a container element and/or a settings object, with a + * legacy-compatible param shift: `new Modal(settings)` is treated as + * `new Modal(null, settings)` when the first argument is a plain object. + * + * @remarks `draggable` and `resizable` are **not yet supported** in the modern + * build (the drag system is out of PoC scope); both default to `false` and + * enabling either throws a descriptive error. + * + * @example Modern usage + * ```ts + * import {Modal} from '@craftcms/garnish'; + * + * const el = document.querySelector('#my-modal')!; + * const modal = new Modal(el, { + * closeOtherModals: true, + * onShow: () => console.log('shown'), + * }); + * + * modal.on('hide', () => console.log('hidden')); + * // modal.hide(); modal.destroy(); + * ``` + */ +export class Modal extends Base { + /** Every live `Modal` instance (pushed on construction, removed on {@link destroy}). */ + static instances: Modal[] = []; + + /** The currently visible modal, or `null` if none is showing. */ + static visibleModal: Modal | null = null; + + /** Default {@link ModalSettings}, merged with the per-instance overrides. */ + static readonly defaults: ModalSettings = { + autoShow: true, + draggable: false, + dragHandleSelector: null, + resizable: false, + minGutter: 10, + onShow: noop, + onHide: noop, + onFadeIn: noop, + onFadeOut: noop, + closeOtherModals: false, + hideOnEsc: true, + hideOnShadeClick: true, + triggerElement: null, + shadeClass: 'modal-shade', + }; + + /** Legacy parity constant. */ + static readonly relativeElemPadding = 8; + + // --- Instance state (named with the legacy `$`-prefix for consumer parity) --- + + /** The modal's container element, or `null` until {@link setContainer} runs. */ + $container: HTMLElement | null = null; + /** The shade/backdrop element created for this modal. */ + $shade: HTMLElement | null = null; + /** Overrides the focus-restore target; takes precedence over `settings.triggerElement`. */ + $triggerElement: Element | null = null; + /** The visually-hidden `role="status"` live region appended to the container. */ + $liveRegion: HTMLElement; + + /** Whether the modal is currently shown. */ + visible = false; + + // Draggable/resizable are unsupported in the PoC; kept for shape parity. + dragger: unknown = null; + resizeDragger: unknown = null; + resizeStartWidth: number | null = null; + resizeStartHeight: number | null = null; + + desiredWidth: number | null = null; + desiredHeight: number | null = null; + + // Tracked WAAPI animations so we can `cancel()` them (legacy `.velocity('stop')`). + private _containerAnim: Animation | null = null; + private _shadeAnim: Animation | null = null; + + /** + * @param container - The modal's container element, or a `Partial` + * object (param shift: when the first arg is a plain object and no second + * arg is given, it is treated as the settings). May be `null` to construct + * a modal whose container is set later via {@link setContainer}. + * @param settings - Optional settings overrides (see {@link ModalSettings}). + */ + constructor( + container?: Element | Partial | null, + settings?: Partial + ) { + super(); + + // Param mapping: `new Modal(settings)` when the first arg is a plain object. + let containerEl: Element | null = null; + if (settings === undefined && isPlainObject(container)) { + settings = container as Partial; + } else { + containerEl = (container as Element | null) ?? null; + } + + this.setSettings(settings, Modal.defaults); + + if (!this.settings!.triggerElement) { + this.settings!.triggerElement = getFocusedElement(); + } + + // Live region announcement element. + this.$liveRegion = document.createElement('span'); + this.$liveRegion.className = 'visually-hidden'; + this.$liveRegion.setAttribute('role', 'status'); + + // Create the shade. + this.$shade = document.createElement('div'); + this.$shade.className = this.settings!.shadeClass; + + // If the container is already set, drop the shade below it. + if (containerEl && containerEl.parentNode) { + containerEl.parentNode.insertBefore(this.$shade, containerEl); + } else { + bod.appendChild(this.$shade); + } + + if (containerEl) { + this.setContainer(containerEl); + addModalAttributes(containerEl); + + if (this.settings!.autoShow) { + this.show(); + } + } + + Modal.instances.push(this); + } + + /** Append the live region ({@link $liveRegion}) to the container, if set. */ + addLiveRegion(): void { + if (!this.$container) { + return; + } + this.$container.appendChild(this.$liveRegion); + } + + /** + * Assign (or replace) the modal's container element, wiring up its ARIA + * attributes, live region, and shade-dismiss listeners. If another modal was + * already attached to this element, it is destroyed first. + * + * @param container - The element to use as the modal container. + * @throws If `draggable` or `resizable` is enabled (not yet supported). + */ + setContainer(container: Element): void { + this.$container = container as HTMLElement; + + // Already a modal? Tear the old one down (legacy double-instantiation guard). + const existing = containerModals.get(container); + if (existing) { + console.warn('Double-instantiating a modal on an element'); + existing.destroy(); + } + + containerModals.set(container, this); + + if (this.settings!.draggable) { + throw new Error( + 'Garnish.Modal: `draggable` is not yet supported in the modern build (DragMove/BaseDrag are out of PoC scope).' + ); + } + + if (this.settings!.resizable) { + throw new Error( + 'Garnish.Modal: `resizable` is not yet supported in the modern build (BaseDrag is out of PoC scope).' + ); + } + + this.addLiveRegion(); + + // The DOM-listener registry passes the native Event augmented with Garnish + // fields (core impl note #2), so `stopPropagation` is available at runtime. + this.addListener(this.$container, 'click', (ev) => { + (ev as unknown as Event).stopPropagation(); + }); + + // Show it if we're late to the party. + if (this.visible) { + this.show(); + } + } + + /** + * Show the modal: move it to the top of ``, fade in the shade then the + * container, trap focus, register the Escape shortcut, and emit `show` (and + * later `fadeIn`). No-op visually if already visible, but always re-enables. + */ + show(): void { + // Close other modals as needed. + if ( + this.settings!.closeOtherModals && + Modal.visibleModal && + Modal.visibleModal !== this + ) { + Modal.visibleModal.hide(); + } + + if (this.$container) { + // Move shade + container to the end of for the highest sub-z-index. + this._cancelAnim('shade'); + this._cancelAnim('container'); + bod.appendChild(this.$shade!); + bod.appendChild(this.$container); + + this.$container.style.display = 'block'; + this.updateSizeAndPosition(); + + // Shade fade-in, then container fade-in. + this._fade(this.$shade!, 'in', SHADE_FX_DURATION, 'shade', () => { + this._fade(this.$container!, 'in', FX_DURATION, 'container', () => { + this.updateSizeAndPosition(); + setFocusWithin(this.$container!); + this.onFadeIn(); + }); + }); + + if (this.settings!.hideOnShadeClick) { + this.addListener(this.$shade!, 'click', 'hide'); + } + + // Focus trap. + trapFocusWithin(this.$container); + + this.addListener(win, 'resize', '_handleWindowResize'); + } + + this.enable(); + + if (!this.visible) { + this.visible = true; + Modal.visibleModal = this; + + const manager = getUiLayerManager(); + manager?.addLayer(this.$container ?? undefined); + hideModalBackgroundLayers(); + + if (this.settings!.hideOnEsc) { + manager?.registerShortcut(ESC_KEY, () => { + this.trigger('escape'); + this.hide(); + }); + } + + bod.classList.add('no-scroll'); + this.onShow(); + } + } + + /** + * Hook called when the modal is shown: emits the `show` event and invokes the + * `onShow` setting. Override to react to showing (call `super.onShow()` to + * preserve the event + callback). + */ + onShow(): void { + this.trigger('show'); + this.settings!.onShow(); + } + + /** Show the modal immediately, skipping the fade animation. */ + quickShow(): void { + this.show(); + + if (this.$container) { + this._cancelAnim('container'); + this.$container.style.display = 'block'; + this.$container.style.opacity = '1'; + + this._cancelAnim('shade'); + this.$shade!.style.display = 'block'; + this.$shade!.style.opacity = '1'; + } + } + + /** + * Hide the modal: fade out the container and shade, release the focus trap, + * pop the UI layer, restore background ARIA, emit `hide` (and later + * `fadeOut`), and restore focus to the trigger element shortly after. No-op + * if not currently visible. + * + * @param ev - The triggering DOM event, if any; its propagation is stopped. + */ + hide(ev?: Event): void { + if (!this.visible) { + return; + } + + this.disable(); + + if (ev) { + ev.stopPropagation(); + } + + if (this.$container) { + this._cancelAnim('container'); + this._fade(this.$container, 'out', FX_DURATION, 'container'); + + this._cancelAnim('shade'); + this._fade(this.$shade!, 'out', FX_DURATION, 'shade', () => { + this.onFadeOut(); + }); + + if (this.settings!.hideOnShadeClick) { + this.removeListener(this.$shade!, 'click'); + } + + releaseFocusWithin(this.$container); + this.removeListener(win, 'resize'); + } + + this.visible = false; + bod.classList.remove('no-scroll'); + Modal.visibleModal = null; + + const manager = getUiLayerManager(); + manager?.removeLayer(); + resetModalBackgroundLayerVisibility(); + this.onHide(); + + setTimeout(() => { + this._restoreTriggerFocus(); + }, 200); + } + + /** + * Hook called when the modal is hidden: emits the `hide` event and invokes the + * `onHide` setting. Override to react to hiding (call `super.onHide()` to + * preserve the event + callback). + */ + onHide(): void { + this.trigger('hide'); + this.settings!.onHide(); + } + + /** Hide the modal immediately, skipping the fade animation. */ + quickHide(): void { + this.hide(); + + if (this.$container) { + this._cancelAnim('container'); + this.$container.style.opacity = '0'; + this.$container.style.display = 'none'; + + this._cancelAnim('shade'); + this.$shade!.style.opacity = '0'; + this.$shade!.style.display = 'none'; + + this.onFadeOut(); + } + } + + /** + * Recompute the modal's width, height, and centered position so it fits within + * the viewport (honoring `minGutter` and {@link desiredWidth} / + * {@link desiredHeight}), then emit `updateSizeAndPosition`. Called + * automatically on show and window resize; call it manually after changing the + * modal's content. + */ + updateSizeAndPosition(): void { + if (!this.$container) { + return; + } + + const c = this.$container; + + // Reset sizing, then measure. + c.style.width = this.desiredWidth + ? `${Math.max(this.desiredWidth, 200)}px` + : ''; + c.style.height = this.desiredHeight + ? `${Math.max(this.desiredHeight, 200)}px` + : ''; + c.style.minWidth = ''; + c.style.minHeight = ''; + + // Width first so height can adjust for the width. + const windowWidth = window.innerWidth; + const width = Math.min( + this.getWidth(), + windowWidth - this.settings!.minGutter * 2 + ); + + c.style.width = `${width}px`; + c.style.minWidth = `${width}px`; + c.style.left = `${Math.round((windowWidth - width) / 2)}px`; + + // Now the height. + const windowHeight = window.innerHeight; + const height = Math.min( + this.getHeight(), + windowHeight - this.settings!.minGutter * 2 + ); + + c.style.height = `${height}px`; + c.style.minHeight = `${height}px`; + c.style.top = `${Math.round((windowHeight - height) / 2)}px`; + + this.trigger('updateSizeAndPosition'); + } + + /** + * Hook called after the fade-in completes: emits `fadeIn` and invokes the + * `onFadeIn` setting. Override and call `super.onFadeIn()` to extend. + */ + onFadeIn(): void { + this.trigger('fadeIn'); + this.settings!.onFadeIn(); + } + + /** + * Hook called after the fade-out completes: emits `fadeOut` and invokes the + * `onFadeOut` setting. Override and call `super.onFadeOut()` to extend. + */ + onFadeOut(): void { + this.trigger('fadeOut'); + this.settings!.onFadeOut(); + } + + /** + * Measure the container's rendered height (px), temporarily displaying it if + * hidden. + * + * @returns The container's `offsetHeight`. + * @throws If the container has not been set. + */ + getHeight(): number { + if (!this.$container) { + throw 'Attempted to get the height of a modal whose container has not been set.'; + } + + const hidden = !this.visible; + if (hidden) { + this.$container.style.display = 'block'; + } + + const height = this.$container.offsetHeight; + + if (hidden) { + this.$container.style.display = 'none'; + } + + return height; + } + + /** + * Measure the container's rendered width (px), temporarily displaying it if + * hidden. + * + * @returns The container's `offsetWidth` (+1px, matching legacy Chrome parity). + * @throws If the container has not been set. + */ + getWidth(): number { + if (!this.$container) { + throw 'Attempted to get the width of a modal whose container has not been set.'; + } + + const hidden = !this.visible; + if (hidden) { + this.$container.style.display = 'block'; + } + + // Chrome might be 1px shy here for some reason (legacy parity). + const width = this.$container.offsetWidth + 1; + + if (hidden) { + this.$container.style.display = 'none'; + } + + return width; + } + + /** + * Destroy the modal: remove its container and shade from the DOM, drop it from + * {@link Modal.instances} (and clear {@link Modal.visibleModal} if it was the + * visible one), then run the base teardown (emits `destroy`, removes all + * listeners). Override and call `super.destroy()` to clean up extra state. + */ + override destroy(): void { + if (this.$container) { + containerModals.delete(this.$container); + this.$container.remove(); + } + + if (this.$shade) { + this.$shade.remove(); + } + + Modal.instances = Modal.instances.filter((o) => o !== this); + + if (Modal.visibleModal === this) { + Modal.visibleModal = null; + } + + super.destroy(); + } + + // --- Internals ------------------------------------------------------------ + + private _handleWindowResize(ev: Event): void { + // Ignore propagated resize events. + if (ev.target === window) { + this.updateSizeAndPosition(); + } + } + + /** + * Restore focus to the trigger element after a hide (legacy behavior). + */ + private _restoreTriggerFocus(): void { + let target: FocusTarget = + this.$triggerElement ?? this.settings!.triggerElement; + + if (typeof target === 'function') { + target = target(); + } + + let el: Element | null = (target as Element | null) ?? null; + + // If the trigger is hidden, try its disclosure-menu controller. + if (el && isElementHidden(el)) { + const disclosure = el.closest('.menu--disclosure'); + if (disclosure && disclosure.id) { + el = document.querySelector(`[aria-controls="${disclosure.id}"]`); + } else { + el = null; + } + } + + if (el && el instanceof HTMLElement) { + el.focus(); + } else { + console.warn( + 'There is no trigger element set for this modal. Set one with modal.$triggerElement = ...' + ); + } + } + + /** + * Fade an element in or out with the Web Animations API. + * + * Gated on `prefersReducedMotion` (and on environments lacking + * `element.animate`, e.g. happy-dom): in those cases the end state is applied + * immediately and the completion callback runs synchronously, so tests are + * deterministic and reduced-motion users get an instant transition while real + * browsers still animate. + */ + private _fade( + el: HTMLElement, + direction: 'in' | 'out', + duration: number, + track: 'container' | 'shade', + complete?: ModalCallback + ): void { + const finalOpacity = direction === 'in' ? '1' : '0'; + + if (direction === 'in') { + el.style.display = 'block'; + } + + const finalize = (): void => { + el.style.opacity = finalOpacity; + if (direction === 'out') { + el.style.display = 'none'; + } + this._setAnim(track, null); + complete?.(); + }; + + // Feature-detect WAAPI; fall back to an immediate-complete path otherwise. + // happy-dom does not implement `element.animate`, so this keeps tests + // deterministic while letting real browsers animate. + if (prefersReducedMotion() || typeof el.animate !== 'function') { + finalize(); + return; + } + + el.style.opacity = direction === 'in' ? '0' : '1'; + const anim = el.animate( + [ + {opacity: direction === 'in' ? 0 : 1}, + {opacity: direction === 'in' ? 1 : 0}, + ], + {duration, fill: 'forwards'} + ); + this._setAnim(track, anim); + anim.onfinish = finalize; + anim.oncancel = (): void => { + this._setAnim(track, null); + }; + } + + private _setAnim(track: 'container' | 'shade', anim: Animation | null): void { + if (track === 'container') { + this._containerAnim = anim; + } else { + this._shadeAnim = anim; + } + } + + /** Cancel the tracked animation for a target (legacy `.velocity('stop')`). */ + private _cancelAnim(track: 'container' | 'shade'): void { + const anim = track === 'container' ? this._containerAnim : this._shadeAnim; + if (anim) { + anim.cancel(); + } + this._setAnim(track, null); + } +} + +function isPlainObject(val: unknown): val is Record { + return ( + typeof val === 'object' && + val !== null && + !(val instanceof Element) && + (Object.getPrototypeOf(val) === Object.prototype || + Object.getPrototypeOf(val) === null) + ); +} + +/** + * Native replacement for jQuery `:hidden`: an element is hidden if it has no + * rendered boxes. In layout-less environments (happy-dom) this falls back to a + * computed-style check. + */ +function isElementHidden(el: Element): boolean { + const html = el as HTMLElement; + if (html.offsetWidth || html.offsetHeight || el.getClientRects().length) { + return false; + } + const style = window.getComputedStyle(el); + return style.display === 'none' || style.visibility === 'hidden'; +} + +export default Modal; diff --git a/packages/craftcms-garnish/src/types.ts b/packages/craftcms-garnish/src/types.ts new file mode 100644 index 00000000000..feff5c27710 --- /dev/null +++ b/packages/craftcms-garnish/src/types.ts @@ -0,0 +1,24 @@ +/** + * Shared Garnish core types. + */ + +/** Base settings shape. Subclasses extend this with their own typed settings. */ +export interface GarnishBaseSettings { + [key: string]: unknown; +} + +/** + * Anything `addListener` / the DOM-listener registry will accept as an element + * argument. jQuery is intentionally absent; the compat layer coerces jQuery + * collections into one of these before calling core. + */ +export type ElementInput = + | EventTarget + | EventTarget[] + | NodeListOf + | string + | null + | undefined; + +/** Generic constructor type used by the class-level event bus. */ +export type Constructor = abstract new (...args: any[]) => T; diff --git a/packages/craftcms-garnish/src/utils/animation.ts b/packages/craftcms-garnish/src/utils/animation.ts new file mode 100644 index 00000000000..a85a3f1a3b4 --- /dev/null +++ b/packages/craftcms-garnish/src/utils/animation.ts @@ -0,0 +1,164 @@ +/** + * Animation / scroll utilities (jQuery-free; Velocity removed). + * + * `shake` uses the Web Animations API; `scrollContainerToElement` uses manual + * `scrollTop` math. Both respect `prefers-reduced-motion`. + */ + +import {SHAKE_STEP_DURATION, SHAKE_STEPS} from '../constants'; +import {doc, globals, win} from '../globals'; + +/** Re-export of the native RAF (vendor prefixes are long dead). */ +export const requestAnimationFrame: typeof window.requestAnimationFrame = + window.requestAnimationFrame.bind(window); + +export const cancelAnimationFrame: typeof window.cancelAnimationFrame = + window.cancelAnimationFrame.bind(window); + +/** Whether the user prefers reduced motion. */ +export function prefersReducedMotion(): boolean { + const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); + // Legacy returns true if the query is unavailable. + return !mediaQuery || mediaQuery.matches; +} + +/** + * `0` if the user prefers reduced motion, otherwise the given duration. + */ +export function getUserPreferredAnimationDuration( + duration: number | string +): number | string { + return prefersReducedMotion() ? 0 : duration; +} + +/** + * Finds the nearest scrollable ancestor of an element (native replacement for + * jQuery's `.scrollParent()`). + */ +function getScrollParent(el: Element): Element | Window { + let node: Element | null = el.parentElement; + while (node) { + const style = window.getComputedStyle(node); + const overflowY = style.overflowY; + if ( + (overflowY === 'auto' || + overflowY === 'scroll' || + overflowY === 'overlay') && + node.scrollHeight > node.clientHeight + ) { + return node; + } + node = node.parentElement; + } + return win; +} + +/** + * Scrolls a container so that an element within it is in view. + * + * Parity with legacy `scrollContainerToElement`, minus Velocity (uses + * instant `scrollTop` assignment / `window.scrollTo`). + */ +export function scrollContainerToElement( + container: Element | Window, + elem?: Element +): void { + let target: Element; + let scrollTarget: Element | Window; + + if (elem === undefined) { + // Single-arg form: container is actually the element; find its scroll parent. + target = container as Element; + scrollTarget = getScrollParent(target); + } else { + scrollTarget = container; + target = elem; + } + + // Normalize /document to window. + if ( + scrollTarget instanceof Element && + (scrollTarget.nodeName === 'HTML' || scrollTarget === doc.documentElement) + ) { + scrollTarget = win; + } + if ((scrollTarget as unknown) === doc) { + scrollTarget = win; + } + + const isWindow = scrollTarget === win; + const scrollTop = isWindow + ? window.scrollY + : (scrollTarget as Element).scrollTop; + const elemOffsetTop = target.getBoundingClientRect().top + window.scrollY; + + let elemScrollOffset: number; + if (isWindow) { + elemScrollOffset = elemOffsetTop - scrollTop; + } else { + const containerOffsetTop = + (scrollTarget as Element).getBoundingClientRect().top + window.scrollY; + elemScrollOffset = elemOffsetTop - containerOffsetTop; + } + + let targetScrollTop: number | false = false; + + if (elemScrollOffset < 0) { + targetScrollTop = scrollTop + elemScrollOffset - 10; + } else { + const elemHeight = target.getBoundingClientRect().height; + const containerHeight = isWindow + ? window.innerHeight + : (scrollTarget as Element).clientHeight; + + if (elemScrollOffset + elemHeight > containerHeight) { + targetScrollTop = + scrollTop + (elemScrollOffset - (containerHeight - elemHeight)) + 10; + } + } + + if (targetScrollTop !== false) { + if (isWindow) { + window.scrollTo({top: targetScrollTop}); + } else { + (scrollTarget as Element).scrollTop = targetScrollTop; + } + } +} + +/** + * Shakes an element by animating a CSS property back and forth. + * + * Rewritten with the Web Animations API (Velocity removed). Respects + * `prefers-reduced-motion` (no-op). Mirrors the legacy 10-step amplitude ramp. + * + * @param elem The element to shake. + * @param prop The CSS property to adjust (default `'margin-left'`). + */ +export function shake(elem: HTMLElement, prop = 'margin-left'): void { + if (prefersReducedMotion()) { + return; + } + + let startingPoint = parseInt( + window.getComputedStyle(elem).getPropertyValue(prop), + 10 + ); + if (Number.isNaN(startingPoint)) { + startingPoint = 0; + } + + // Build the keyframe ramp: same offsets the legacy setTimeout loop produced. + const keyframes: Keyframe[] = []; + for (let i = 0; i <= SHAKE_STEPS; i++) { + const value = startingPoint + (i % 2 ? -1 : 1) * (10 - i); + keyframes.push({[prop]: `${value}px`}); + } + + if (typeof elem.animate === 'function') { + elem.animate(keyframes, { + duration: SHAKE_STEP_DURATION * SHAKE_STEPS, + easing: 'linear', + }); + } +} diff --git a/packages/craftcms-garnish/src/utils/aria.ts b/packages/craftcms-garnish/src/utils/aria.ts new file mode 100644 index 00000000000..86110e3a936 --- /dev/null +++ b/packages/craftcms-garnish/src/utils/aria.ts @@ -0,0 +1,124 @@ +/** + * ARIA / modal-background utilities (jQuery-free). + */ + +import { + JS_ARIA_CLASS, + JS_ARIA_FALSE_CLASS, + JS_ARIA_TRUE_CLASS, +} from '../constants'; +import {getUiLayerManager} from '../managers/registry'; + +/** + * Add the modal ARIA + role attributes (`aria-modal="true"`, `role="dialog"`) to + * a container. Called by {@link Modal} when its container is set. + * + * @param container - The modal container element. + */ +export function addModalAttributes(container: Element): void { + container.setAttribute('aria-modal', 'true'); + container.setAttribute('role', 'dialog'); +} + +/** Whether an element is a ` - - diff --git a/packages/craftcms-garnish/playground/main.ts b/packages/craftcms-garnish/playground/main.ts deleted file mode 100644 index 1e855ca5032..00000000000 --- a/packages/craftcms-garnish/playground/main.ts +++ /dev/null @@ -1,901 +0,0 @@ -/** - * @craftcms/garnish — interactive playground. - * - * Wires the REAL source (`../src/index.ts` + `../src/compat.ts`) into clickable - * demos so we can exercise the modern, jQuery-free core in a browser with Vite - * HMR. This file is dev-only; it is never shipped and never part of the tsdown - * build. - */ - -import { - Modal, - HUD, - DisclosureMenu, - BaseDrag, - Drag, - DragDrop, - DragSort, - DragMove, - getFocusableElements, - isKeyboardFocusable, - installActivate, - hasAttr, - getDist, - type ModalSettings, -} from '../src/index'; -import {installGarnishCompat} from '../src/compat'; - -/* ------------------------------------------------------------------------- * - * Event-log panel - * ------------------------------------------------------------------------- */ - -const logList = document.getElementById('pg-log-list') as HTMLOListElement; - -function log(tag: string, message: string, isError = false): void { - const li = document.createElement('li'); - if (isError) { - li.className = 'pg-log-error'; - } - - const time = document.createElement('time'); - time.textContent = new Date().toLocaleTimeString(undefined, { - hour12: false, - }); - - const tagEl = document.createElement('span'); - tagEl.className = 'pg-log-tag'; - tagEl.textContent = `[${tag}] `; - - const text = document.createTextNode(message); - - li.append(time, tagEl, text); - logList.appendChild(li); - logList.parentElement!.scrollTop = logList.parentElement!.scrollHeight; -} - -document.getElementById('pg-log-clear')!.addEventListener('click', () => { - logList.innerHTML = ''; -}); - -log('ready', 'Playground loaded. Imports resolved from ../src directly.'); - -/* ------------------------------------------------------------------------- * - * Helpers - * ------------------------------------------------------------------------- */ - -/** Build a styled modal container with the given heading + body HTML. */ -function buildModalContainer(title: string, body: string): HTMLElement { - const el = document.createElement('div'); - el.className = 'pg-modal'; - el.innerHTML = ` -

${title}

-
${body}
-
- -
- `; - document.body.appendChild(el); - el.querySelector('[data-modal-close]')!.addEventListener('click', () => { - // Find the owning Modal via the static registry and hide it. - const owner = Modal.instances.find((m) => m.$container === el); - owner?.hide(); - }); - return el; -} - -/** Wire every demo event of a modal into the log panel. */ -function wireModalEvents(modal: Modal, label: string): void { - for (const evt of ['show', 'hide', 'fadeIn', 'fadeOut', 'escape'] as const) { - modal.on(evt, () => log('modal', `${label}: ${evt}`)); - } -} - -/** - * Wire a dragger's lifecycle events into the log panel. `drag` fires once per - * RAF frame, so it is coalesced to a single "drag (moving…)" line per gesture to - * keep the log readable; `dragStart` / `dragStop` always log. - */ -function wireDragEvents(dragger: BaseDrag, tag: string, label: string): void { - let dragging = false; - dragger.on('dragStart', () => { - dragging = false; - log(tag, `${label}: dragStart`); - }); - dragger.on('drag', () => { - if (!dragging) { - dragging = true; - log(tag, `${label}: drag (moving…)`); - } - }); - dragger.on('dragStop', () => log(tag, `${label}: dragStop`)); -} - -/* ------------------------------------------------------------------------- * - * 1. Modal demos - * ------------------------------------------------------------------------- */ - -let basicModal: Modal | null = null; - -function getBasicModal(): Modal { - if (basicModal && Modal.instances.includes(basicModal)) { - return basicModal; - } - const container = buildModalContainer( - 'Basic modal', - '

A plain new Modal(container). Try the quickShow / quickHide buttons too.

' - ); - basicModal = new Modal(container, {autoShow: false}); - wireModalEvents(basicModal, 'basic'); - return basicModal; -} - -const modalActions: Record void> = { - 'basic-show': () => getBasicModal().show(), - 'basic-hide': () => getBasicModal().hide(), - 'quick-show': () => getBasicModal().quickShow(), - 'quick-hide': () => getBasicModal().quickHide(), - - 'esc-shade': () => { - const container = buildModalContainer( - 'hideOnEsc + hideOnShadeClick', - '

Press Esc or click the dimmed shade outside this box to close it.

' - ); - const settings: Partial = { - hideOnEsc: true, - hideOnShadeClick: true, - }; - const modal = new Modal(container, settings); - wireModalEvents(modal, 'esc/shade'); - log('modal', 'Opened modal with hideOnEsc + hideOnShadeClick'); - }, - - 'close-others': () => { - // First, ensure something else is open to be closed. - getBasicModal().show(); - const container = buildModalContainer( - 'closeOtherModals: true', - '

Opening this one auto-closed any other visible modal (watch the log for the other modal’s hide).

' - ); - const modal = new Modal(container, {closeOtherModals: true}); - wireModalEvents(modal, 'closeOthers'); - log('modal', 'Opened closeOtherModals modal — prior modal should hide'); - }, - - 'destroy-all': () => { - const count = Modal.instances.length; - // Copy: destroy() mutates Modal.instances. - [...Modal.instances].forEach((m) => m.destroy()); - basicModal = null; - log('modal', `Destroyed ${count} modal instance(s)`); - }, -}; - -document.querySelectorAll('[data-modal]').forEach((btn) => { - btn.addEventListener('click', () => { - const action = btn.dataset.modal!; - try { - modalActions[action]?.(); - } catch (err) { - log('modal', `Error: ${(err as Error).message}`, true); - } - }); -}); - -/* ------------------------------------------------------------------------- * - * 2. Focusable matcher + focus trap - * ------------------------------------------------------------------------- */ - -const focusSample = document.getElementById('focus-sample') as HTMLElement; - -const focusActions: Record void> = { - highlight: () => { - let focusableCount = 0; - let keyboardCount = 0; - - // Clear first. - focusActions.clear!(); - - const focusable = getFocusableElements(focusSample); - focusable.forEach((el) => { - el.classList.add('pg-highlight-focusable'); - focusableCount++; - if (isKeyboardFocusable(el)) { - el.classList.add('pg-highlight-keyboard'); - keyboardCount++; - } - }); - - log( - 'focus', - `getFocusableElements matched ${focusableCount}; ${keyboardCount} keyboard-focusable (blue ring)` - ); - }, - - clear: () => { - focusSample - .querySelectorAll('.pg-highlight-focusable, .pg-highlight-keyboard') - .forEach((el) => { - el.classList.remove('pg-highlight-focusable', 'pg-highlight-keyboard'); - }); - }, - - 'trap-modal': () => { - const container = buildModalContainer( - 'Focus trap', - `

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

-

- - - Third -

` - ); - const modal = new Modal(container); - wireModalEvents(modal, 'focusTrap'); - log('focus', 'Opened focus-trap modal — Tab cycling is trapped inside'); - }, -}; - -document.querySelectorAll('[data-focus]').forEach((btn) => { - btn.addEventListener('click', () => focusActions[btn.dataset.focus!]?.()); -}); - -/* ------------------------------------------------------------------------- * - * 3. Compat upgrade-path demo - * ------------------------------------------------------------------------- */ - -interface LegacyCtorLike { - extend(instance: Record, statics?: object): LegacyCtorLike; - new (...args: unknown[]): unknown; -} - -interface GarnishGlobalLike { - Modal: LegacyCtorLike; - [key: string]: unknown; -} - -let compatGarnish: GarnishGlobalLike | null = null; -let DemoModalSubclass: LegacyCtorLike | null = null; - -const compatActions: Record void> = { - install: () => { - compatGarnish = installGarnishCompat() as unknown as GarnishGlobalLike; - const onWindow = - (window as unknown as {Garnish?: unknown}).Garnish !== undefined; - log( - 'compat', - `installGarnishCompat() ran. window.Garnish present: ${onWindow}; Garnish.Modal.extend is ${typeof compatGarnish.Modal.extend}` - ); - }, - - extend: () => { - if (!compatGarnish) { - compatActions.install!(); - } - const Garnish = compatGarnish!; - - // Define the subclass exactly the legacy way: init() trampoline + this.base(). - DemoModalSubclass = Garnish.Modal.extend({ - init(this: { - base: (...a: unknown[]) => unknown; - $container: HTMLElement | null; - }) { - const container = buildModalContainer( - 'Legacy .extend() subclass', - '

This modal was created via Garnish.Modal.extend({ init, onShow }) and uses this.base() in onShow.

' - ); - // Call the modern Modal constructor through the init trampoline. - this.base(container, {autoShow: false}); - log('compat', 'subclass init() ran, called this.base(container)'); - }, - onShow(this: {base: () => void}) { - // this.base() dispatches to Modal.prototype.onShow (fires 'show'). - this.base(); - log('compat', 'subclass onShow() ran, then called this.base()'); - }, - }) as LegacyCtorLike; - - const instance = new DemoModalSubclass() as { - on(evt: string, fn: () => void): void; - show(): void; - }; - instance.on('show', () => log('compat', "subclass 'show' event observed")); - instance.show(); - log('compat', 'Instantiated + show()ed the .extend() subclass'); - }, -}; - -document.querySelectorAll('[data-compat]').forEach((btn) => { - btn.addEventListener('click', () => { - try { - compatActions[btn.dataset.compat!]?.(); - } catch (err) { - log('compat', `Error: ${(err as Error).message}`, true); - } - }); -}); - -/* ------------------------------------------------------------------------- * - * 4. Draggable / resizable Modal demos - * ------------------------------------------------------------------------- */ - -/** Build a modal container styled to look draggable, optionally with a header handle. */ -function buildDragModalContainer( - title: string, - body: string, - withHandle: boolean -): HTMLElement { - const el = document.createElement('div'); - el.className = 'pg-modal pg-modal--draggable'; - const handleHtml = withHandle - ? `
${title} — drag here
` - : `

${title}

`; - el.innerHTML = ` - ${handleHtml} -
${body}
-
- -
- `; - document.body.appendChild(el); - el.querySelector('[data-modal-close]')!.addEventListener('click', () => { - const owner = Modal.instances.find((m) => m.$container === el); - owner?.hide(); - }); - return el; -} - -const dragModals: Modal[] = []; - -function openDragModal( - label: string, - settings: Partial, - body: string, - withHandle = false -): void { - const container = buildDragModalContainer(label, body, withHandle); - const modal = new Modal(container, settings); - wireModalEvents(modal, label); - // The Modal creates its dragger/resizeDragger during construction; wire their - // events so live drags/resizes appear in the log. - if (modal.dragger) { - wireDragEvents(modal.dragger, 'modal-drag', `${label} (move)`); - } - if (modal.resizeDragger) { - wireDragEvents(modal.resizeDragger, 'modal-drag', `${label} (resize)`); - } - dragModals.push(modal); - log('modal', `Opened "${label}" — drag/resize it; watch the log.`); -} - -const dragModalActions: Record void> = { - container: () => - openDragModal( - 'Draggable modal', - {draggable: true}, - '

Drag this modal from anywhere on its surface (the whole container is the handle).

' - ), - - handle: () => - openDragModal( - 'Header-handle modal', - {draggable: true, dragHandleSelector: '[data-drag-handle]'}, - '

This modal only moves when you drag the dark header bar (dragHandleSelector). Dragging the body does nothing.

', - true - ), - - resizable: () => - openDragModal( - 'Resizable modal', - {resizable: true}, - '

Grab the resize handle in the bottom-right corner and drag — the modal grows/shrinks symmetrically about its center.

' - ), - - both: () => - openDragModal( - 'Draggable + resizable', - { - draggable: true, - dragHandleSelector: '[data-drag-handle]', - resizable: true, - }, - '

Drag the header to move; drag the corner handle to resize. Both draggers are independent.

', - true - ), - - destroy: () => { - const count = dragModals.length; - dragModals.splice(0).forEach((m) => m.destroy()); - log('modal', `Destroyed ${count} draggable/resizable modal(s)`); - }, -}; - -document - .querySelectorAll('[data-dragmodal]') - .forEach((btn) => { - btn.addEventListener('click', () => { - try { - dragModalActions[btn.dataset.dragmodal!]?.(); - } catch (err) { - log('modal', `Error: ${(err as Error).message}`, true); - } - }); - }); - -/* ------------------------------------------------------------------------- * - * 5. Standalone BaseDrag / DragMove demos - * ------------------------------------------------------------------------- */ - -const dragArena = document.getElementById('drag-arena'); - -if (dragArena) { - // Initial positions keyed by data-box, so "reset" can restore them. - const boxHomes: Record = { - 'move-1': {left: 16, top: 16}, - 'move-2': {left: 170, top: 16}, - 'move-x': {left: 16, top: 100}, - 'base-1': {left: 170, top: 100}, - }; - - const placeBox = (box: HTMLElement): void => { - const home = boxHomes[box.dataset.box!]; - if (home) { - box.style.left = `${home.left}px`; - box.style.top = `${home.top}px`; - } - }; - - const boxes = Array.from( - dragArena.querySelectorAll('[data-box]') - ); - boxes.forEach(placeBox); - - // Green boxes: DragMove drives left/top for us. - const moveBoxes = dragArena.querySelectorAll( - '.pg-drag-box--move:not(.pg-drag-box--xlock)' - ); - moveBoxes.forEach((box) => { - const dragger = new DragMove(box); - wireDragEvents(dragger, 'drag', box.dataset.box!); - }); - - // X-axis-locked DragMove. - const xBox = dragArena.querySelector('.pg-drag-box--xlock'); - if (xBox) { - const dragger = new DragMove(xBox, {axis: 'x'}); - wireDragEvents(dragger, 'drag', `${xBox.dataset.box} (x-locked)`); - } - - // Blue box: raw BaseDrag with an onDrag that positions it ourselves. We track - // the box's start position and move it by the cursor delta (mouseDistX/Y) — - // robust regardless of the box's offset parent (the arena is positioned). - const baseBox = dragArena.querySelector('.pg-drag-box--base'); - if (baseBox) { - let start = {left: 0, top: 0}; - const dragger: BaseDrag = new BaseDrag(baseBox, { - onDragStart: () => { - start = {left: baseBox.offsetLeft, top: baseBox.offsetTop}; - }, - onDrag: () => { - baseBox.style.left = `${start.left + dragger.mouseDistX!}px`; - baseBox.style.top = `${start.top + dragger.mouseDistY!}px`; - }, - }); - wireDragEvents(dragger, 'drag', 'base-1 (manual onDrag)'); - } - - document - .querySelector('[data-dragbox="reset"]') - ?.addEventListener('click', () => { - boxes.forEach(placeBox); - log('drag', 'Reset box positions'); - }); -} - -/* ------------------------------------------------------------------------- * - * 6. Auto-scroll while dragging - * ------------------------------------------------------------------------- */ - -const scrollContainer = document.getElementById('scroll-container'); - -if (scrollContainer) { - const scrollBox = scrollContainer.querySelector( - '.pg-drag-box--scroll' - ); - if (scrollBox) { - const dragger = new DragMove(scrollBox); - wireDragEvents(dragger, 'autoscroll', 'scroll-box'); - - document - .querySelector('[data-autoscroll="reset"]') - ?.addEventListener('click', () => { - scrollBox.style.left = '20px'; - scrollBox.style.top = '20px'; - scrollContainer.scrollTop = 0; - log('autoscroll', 'Reset scroll item + scroll position'); - }); - } -} - -/* ------------------------------------------------------------------------- * - * 7. Drag with helpers (clones + return-to-source) - * ------------------------------------------------------------------------- */ - -/** - * Wire a Drag's lifecycle into the log. Like `wireDragEvents`, but also logs the - * `returnHelpersToDraggees` event so the return/fade tail is observable. `drag` - * is coalesced to one line per gesture. - */ -function wireHelperDragEvents(dragger: Drag, tag: string, label: string): void { - let dragging = false; - dragger.on('dragStart', () => { - dragging = false; - log(tag, `${label}: dragStart (helper clone created)`); - }); - dragger.on('drag', () => { - if (!dragging) { - dragging = true; - log(tag, `${label}: drag (helper trailing cursor…)`); - } - }); - dragger.on('dragStop', () => log(tag, `${label}: dragStop`)); - dragger.on('returnHelpersToDraggees', () => - log(tag, `${label}: returnHelpersToDraggees (helpers home)`) - ); -} - -const dragHelperList = document.getElementById('drag-helper-list'); - -if (dragHelperList) { - // Drop mode is shared across the items; the dragStop handler reads it to pick - // return-to-source (default) vs. fadeOutHelpers. - let dropMode: 'return' | 'fade' = 'return'; - - const helperItems = Array.from( - dragHelperList.querySelectorAll('[data-helper-item]') - ); - - helperItems.forEach((item) => { - const dragger = new Drag(item, { - // Hide the source while its helper clone is in flight, then reveal it - // again when the helper returns. - hideDraggee: true, - helperOpacity: 0.92, - onDragStop() { - // Default Drag does NOT auto-return; the consumer decides. Mirror the - // legacy contract: pick return-to-source or a fade-out on drop. - if (dropMode === 'fade') { - dragger.fadeOutHelpers(); - log('draghelper', `${item.dataset.helperItem}: fadeOutHelpers()`); - } else { - dragger.returnHelpersToDraggees(); - } - }, - }); - wireHelperDragEvents( - dragger, - 'draghelper', - `item ${item.dataset.helperItem}` - ); - }); - - const dragHelperActions: Record void> = { - 'mode-return': () => { - dropMode = 'return'; - log('draghelper', 'Drop mode → return-to-source'); - }, - 'mode-fade': () => { - dropMode = 'fade'; - log('draghelper', 'Drop mode → fade out'); - }, - }; - - document - .querySelectorAll('[data-draghelper]') - .forEach((btn) => { - btn.addEventListener('click', () => { - try { - dragHelperActions[btn.dataset.draghelper!]?.(); - } catch (err) { - log('draghelper', `Error: ${(err as Error).message}`, true); - } - }); - }); -} - -/* ------------------------------------------------------------------------- * - * 8. DragDrop — drop targets & hit detection - * ------------------------------------------------------------------------- */ - -const dragDropChips = document.getElementById('dragdrop-chips'); -const dragDropZones = document.getElementById('dragdrop-zones'); - -if (dragDropChips && dragDropZones) { - const chips = Array.from( - dragDropChips.querySelectorAll('[data-dragdrop-chip]') - ); - const zones = Array.from( - dragDropZones.querySelectorAll('[data-dropzone]') - ); - - // A single DragDrop owns all the chips; its dropTargets are the three zones. - const dragDrop = new DragDrop({ - dropTargets: zones, - hideDraggee: true, - helperOpacity: 0.92, - // Fires only when the active target changes (incl. → null). The class is - // toggled by DragDrop itself; we just narrate it. - onDropTargetChange(activeDropTarget) { - const name = activeDropTarget?.dataset.dropzone ?? 'none'; - log('dragdrop', `dropTargetChange → ${name}`); - }, - onDragStop() { - // Legacy contract: there is no `drop` event — read $activeDropTarget here. - const target = dragDrop.$activeDropTarget; - if (target) { - log( - 'dragdrop', - `dropped on "${target.dataset.dropzone}" (read from $activeDropTarget)` - ); - target.classList.add('pg-dropzone--hit'); - setTimeout(() => target.classList.remove('pg-dropzone--hit'), 600); - } else { - log('dragdrop', 'dropped outside any drop target'); - } - // Return the chip's helper home so the chip reappears in place. - dragDrop.returnHelpersToDraggees(); - }, - }); - dragDrop.addItems(chips); - - // Also surface the raw drag lifecycle. - let dragging = false; - dragDrop.on('dragStart', () => { - dragging = false; - log('dragdrop', 'dragStart'); - }); - dragDrop.on('drag', () => { - if (!dragging) { - dragging = true; - log('dragdrop', 'drag (over zones…)'); - } - }); - - document - .querySelector('[data-dragdrop="reset"]') - ?.addEventListener('click', () => { - zones.forEach((z) => z.classList.remove('active', 'pg-dropzone--hit')); - log('dragdrop', 'Reset chips + cleared zone highlights'); - }); -} - -/* ------------------------------------------------------------------------- * - * 9. DragSort — reorderable list - * ------------------------------------------------------------------------- */ - -const dragSortList = document.getElementById('dragsort-list'); - -if (dragSortList) { - const items = Array.from( - dragSortList.querySelectorAll('[data-sort-item]') - ); - - // Snapshot the original markup so "reset" can restore the order. - const originalOrder = items.map((el) => el.dataset.sortItem!); - - /** Read the current visible order as a list of item ids. */ - const currentOrder = (): string => - Array.from(dragSortList.querySelectorAll('[data-sort-item]')) - .map((el) => el.dataset.sortItem) - .join(', '); - - const dragSort = new DragSort(items, { - // The list itself is the heighted container the sort is constrained to. - container: dragSortList, - // Lock to the vertical axis — this is a single-column list. - axis: 'y', - // Hide the source row while its helper clone is in flight. - hideDraggee: true, - helperOpacity: 0.95, - // A dashed placeholder element shown at the landing spot. - insertion: () => { - const ph = document.createElement('li'); - ph.className = 'pg-sort-insertion'; - return ph; - }, - onInsertionPointChange() { - log('dragsort', `insertionPointChange → order now [${currentOrder()}]`); - }, - onSortChange() { - log('dragsort', `sortChange → new order [${currentOrder()}]`); - }, - }); - - // Surface the raw drag lifecycle too (coalesce the per-frame `drag`). - let dragging = false; - dragSort.on('dragStart', () => { - dragging = false; - log('dragsort', 'dragStart'); - }); - dragSort.on('drag', () => { - if (!dragging) { - dragging = true; - log('dragsort', 'drag (reordering…)'); - } - }); - dragSort.on('dragStop', () => log('dragsort', 'dragStop')); - - document - .querySelector('[data-dragsort="reset"]') - ?.addEventListener('click', () => { - // Re-append the rows in their original order. - for (const id of originalOrder) { - const el = dragSortList.querySelector( - `[data-sort-item="${id}"]` - ); - if (el) dragSortList.appendChild(el); - } - log('dragsort', `Reset list order → [${currentOrder()}]`); - }); -} - -/* ------------------------------------------------------------------------- * - * 10. Events & utilities - * ------------------------------------------------------------------------- */ - -const utilActions: Record void> = { - hasattr: (btn) => { - const result = hasAttr(btn, 'data-demo'); - log('util', `hasAttr(button, "data-demo") → ${result}`); - }, - getdist: () => { - const d = getDist(0, 0, 3, 4); - log('util', `getDist(0, 0, 3, 4) → ${d}`); - }, - activate: (btn) => { - // Install the synthetic `activate` custom event on the button (idempotent), - // then listen for it. Click / Space / Enter all dispatch `activate`. - installActivate(btn); - if (!btn.dataset.activateWired) { - btn.addEventListener('activate', () => { - log('util', 'activate custom event fired on the button'); - }); - btn.dataset.activateWired = 'yes'; - log( - 'util', - 'installActivate(button) — now click it or press Space/Enter' - ); - } - }, -}; - -document.querySelectorAll('[data-util]').forEach((btn) => { - btn.addEventListener('click', () => utilActions[btn.dataset.util!]?.(btn)); -}); - -/* ------------------------------------------------------------------------- * - * 11. HUD — anchored popover - * ------------------------------------------------------------------------- */ - -const hudArena = document.getElementById('hud-arena'); - -if (hudArena) { - // One lazily-created HUD per trigger; recreated if it was destroyed. - const huds = new Map(); - - const hudBody = (label: string): string => ` -
-

HUD: ${label}

-

- Orientation is chosen from the clearance around this trigger. Scroll or - resize the window 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', - }); - - hud.on('show', () => log('hud', `${id}: show`)); - hud.on('hide', () => log('hud', `${id}: hide`)); - hud.on('updateSizeAndPosition', () => - log('hud', `${id}: positioned → orientation "${hud.orientation}"`) - ); - - // The close button lives inside the HUD body (this.$main). - hud.$main - ?.querySelector('[data-hud-close]') - ?.addEventListener('click', () => hud.hide()); - - huds.set(trigger, hud); - return hud; - }; - - hudArena - .querySelectorAll('[data-hud-trigger]') - .forEach((trigger) => { - trigger.addEventListener('click', () => getHud(trigger).toggle()); - }); - - document - .querySelector('[data-hud="destroy"]') - ?.addEventListener('click', () => { - const count = HUD.instances.length; - [...HUD.instances].forEach((h) => h.destroy()); - huds.clear(); - log('hud', `Destroyed ${count} HUD instance(s)`); - }); -} - -/* ------------------------------------------------------------------------- * - * 12. DisclosureMenu — dropdown menu - * ------------------------------------------------------------------------- */ - -const discActionsTrigger = document.querySelector( - '[aria-controls="pg-disc-menu-actions"]' -); -const discFilterTrigger = document.querySelector( - '[aria-controls="pg-disc-menu-filter"]' -); - -if (discActionsTrigger && discFilterTrigger) { - /** Wire a menu's lifecycle events into the log. */ - const wireMenuEvents = (menu: DisclosureMenu, label: string): void => { - for (const evt of ['beforeShow', 'show', 'hide'] as const) { - menu.on(evt, () => log('disclosure', `${label}: ${evt}`)); - } - }; - - // Menu 1 — static markup. Log a selection when any item is activated. - const actionsMenu = new DisclosureMenu(discActionsTrigger); - wireMenuEvents(actionsMenu, 'actions'); - actionsMenu - .$container!.querySelectorAll('.menu-item:not(.disabled)') - .forEach((item) => { - installActivate(item); - item.addEventListener('activate', () => { - const label = item.querySelector('.menu-item-label')?.textContent ?? '?'; - log('disclosure', `actions: selected "${label}"`); - }); - }); - - // Menu 2 — items built via the API, with a live search input. - const filterMenu = new DisclosureMenu(discFilterTrigger, { - withSearchInput: true, - }); - wireMenuEvents(filterMenu, 'filter'); - const fruits = [ - 'Apple', - 'Apricot', - 'Banana', - 'Blueberry', - 'Cherry', - 'Grape', - 'Mango', - 'Orange', - 'Peach', - 'Pear', - ]; - filterMenu.addItems( - fruits.map((name) => ({ - label: name, - onActivate: () => log('disclosure', `filter: selected "${name}"`), - })) - ); - - document - .querySelector('[data-disclosure="destroy"]') - ?.addEventListener('click', () => { - const count = DisclosureMenu.instances.length; - [...DisclosureMenu.instances].forEach((m) => m.destroy()); - log('disclosure', `Destroyed ${count} DisclosureMenu instance(s)`); - }); -} diff --git a/packages/craftcms-garnish/playground/tsconfig.json b/packages/craftcms-garnish/playground/tsconfig.json deleted file mode 100644 index 4a030545df8..00000000000 --- a/packages/craftcms-garnish/playground/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../tsconfig.json", - "compilerOptions": { - "noEmit": true, - "rootDir": "..", - "types": ["node", "vite/client"] - }, - "include": ["./**/*.ts", "../vite.config.ts", "../src"] -} diff --git a/packages/craftcms-garnish/stories/_helpers.ts b/packages/craftcms-garnish/stories/_helpers.ts new file mode 100644 index 00000000000..64cbb68eb80 --- /dev/null +++ b/packages/craftcms-garnish/stories/_helpers.ts @@ -0,0 +1,56 @@ +/** + * Shared DOM builders for the modal-based stories. + * + * The Modal widget positions itself `fixed` and renders its own `.modal-shade`, + * so these containers are appended to `document.body` (not the story canvas) — + * exactly as the old playground did. + */ + +import {Modal} from '../src/index'; + +/** Build a styled modal container with the given heading + body HTML. */ +export function buildModalContainer(title: string, body: string): HTMLElement { + const el = document.createElement('div'); + el.className = 'pg-modal'; + el.innerHTML = ` +

${title}

+
${body}
+
+ +
+ `; + document.body.appendChild(el); + el.querySelector('[data-modal-close]')!.addEventListener('click', () => { + // Find the owning Modal via the static registry and hide it. + Modal.instances.find((m) => m.$container === el)?.hide(); + }); + return el; +} + +/** + * Build a modal container styled to look draggable, optionally with a header + * handle (for the `dragHandleSelector` demo). + */ +export function buildDragModalContainer( + title: string, + body: string, + withHandle: boolean +): HTMLElement { + const el = document.createElement('div'); + el.className = 'pg-modal pg-modal--draggable'; + const handleHtml = withHandle + ? `
${title} — drag here
` + : `

${title}

`; + el.innerHTML = ` + ${handleHtml} +
${body}
+
+ +
+ `; + document.body.appendChild(el); + el.querySelector('[data-modal-close]')!.addEventListener('click', () => { + Modal.instances.find((m) => m.$container === el)?.hide(); + }); + return el; +} diff --git a/packages/craftcms-garnish/stories/_log.ts b/packages/craftcms-garnish/stories/_log.ts new file mode 100644 index 00000000000..3f9534333b5 --- /dev/null +++ b/packages/craftcms-garnish/stories/_log.ts @@ -0,0 +1,123 @@ +/** + * Shared event-log helper for @craftcms/garnish stories. + * + * Mirrors the old playground's bottom-right "Event log" panel, but mounts the + * panel INSIDE a story so it scopes to that story's canvas. Drag/menu/modal + * stories use it to surface the events the real widgets fire (`show`, `hide`, + * `dragStart`, `dropTargetChange`, …) as you interact. + * + * Usage in a story `render()`: + * + * ```ts + * const log = createEventLog(); + * const main = document.createElement('div'); + * // …build the demo, call log.log('modal', 'show') from event handlers… + * return storyLayout(main, log); + * ``` + */ + +/** A subscribable lifecycle object (Garnish `Base` / draggers expose `on`). */ +export interface Subscribable { + on(events: string, handler: () => void): void; +} + +export interface EventLog { + /** The panel element to mount in the story (via {@link storyLayout}). */ + readonly panel: HTMLElement; + /** Append a line. `tag` is the green `[tag]` prefix; set `isError` to flag it red. */ + log(tag: string, message: string, isError?: boolean): void; + /** Clear all lines. */ + clear(): void; +} + +/** Build an event-log panel + its `log()`/`clear()` API. */ +export function createEventLog(initialMessage?: string): EventLog { + const panel = document.createElement('aside'); + panel.className = 'pg-log'; + panel.innerHTML = ` +
+ Event log + +
+
    `; + + const list = panel.querySelector('.pg-log-list')!; + + const log: EventLog['log'] = (tag, message, isError = false) => { + const li = document.createElement('li'); + if (isError) { + li.className = 'pg-log-error'; + } + + const time = document.createElement('time'); + time.textContent = new Date().toLocaleTimeString(undefined, { + hour12: false, + }); + + const tagEl = document.createElement('span'); + tagEl.className = 'pg-log-tag'; + tagEl.textContent = `[${tag}] `; + + li.append(time, tagEl, document.createTextNode(message)); + list.appendChild(li); + list.scrollTop = list.scrollHeight; + }; + + const clear = (): void => { + list.innerHTML = ''; + }; + + panel + .querySelector('[data-log-clear]')! + .addEventListener('click', clear); + + if (initialMessage) { + log('ready', initialMessage); + } + + return {panel, log, clear}; +} + +/** + * Wire a dragger's lifecycle into the log. `drag` fires once per RAF frame, so + * it is coalesced to a single "drag (moving…)" line per gesture to keep the log + * readable; `dragStart` / `dragStop` always log. + */ +export function wireDragEvents( + log: EventLog, + dragger: Subscribable, + tag: string, + label: string +): void { + let dragging = false; + dragger.on('dragStart', () => { + dragging = false; + log.log(tag, `${label}: dragStart`); + }); + dragger.on('drag', () => { + if (!dragging) { + dragging = true; + log.log(tag, `${label}: drag (moving…)`); + } + }); + dragger.on('dragStop', () => log.log(tag, `${label}: dragStop`)); +} + +/** + * Compose a story's demo column and (optionally) its event-log panel into the + * `.pg-story` flex layout the global styles expect. + */ +export function storyLayout(main: HTMLElement, log?: EventLog): HTMLElement { + const wrapper = document.createElement('div'); + wrapper.className = 'pg-story'; + + if (!main.classList.contains('pg-story-main')) { + main.classList.add('pg-story-main'); + } + wrapper.appendChild(main); + + if (log) { + wrapper.appendChild(log.panel); + } + return wrapper; +} diff --git a/packages/craftcms-garnish/stories/base-drag.stories.ts b/packages/craftcms-garnish/stories/base-drag.stories.ts new file mode 100644 index 00000000000..0199ed1f02a --- /dev/null +++ b/packages/craftcms-garnish/stories/base-drag.stories.ts @@ -0,0 +1,144 @@ +import type {Meta, StoryObj} from '@storybook/html-vite'; +import {BaseDrag, DragMove} from '../src/index'; +import {createEventLog, wireDragEvents, storyLayout} from './_log'; + +/** + * Standalone `BaseDrag` / `DragMove` — playground sections 5 (standalone boxes) + * and 6 (auto-scroll while dragging). + */ +const meta: Meta = { + title: 'Drag/BaseDrag', +}; +export default meta; + +type Story = StoryObj; + +export const StandaloneBoxes: Story = { + render: () => { + const log = createEventLog('BaseDrag / DragMove story loaded.'); + const main = document.createElement('div'); + main.innerHTML = ` +

    + Free-floating boxes inside the bounded area. The green boxes use + new DragMove(box) directly; the blue box uses a raw + new BaseDrag(box, {onDrag}) that sets left/top + itself. The third green box is X-axis-locked ({axis: 'x'}). +

    +
    + +
    +
    +
    DragMove #1
    +
    DragMove #2
    +
    DragMove (x-axis only)
    +
    BaseDrag + onDrag
    +
    `; + + const arena = main.querySelector('[data-arena]')!; + + const boxHomes: Record = { + 'move-1': {left: 16, top: 16}, + 'move-2': {left: 170, top: 16}, + 'move-x': {left: 16, top: 100}, + 'base-1': {left: 170, top: 100}, + }; + + const placeBox = (box: HTMLElement): void => { + const home = boxHomes[box.dataset.box!]; + if (home) { + box.style.left = `${home.left}px`; + box.style.top = `${home.top}px`; + } + }; + + const boxes = Array.from(arena.querySelectorAll('[data-box]')); + boxes.forEach(placeBox); + + // Green boxes: DragMove drives left/top for us. + arena + .querySelectorAll( + '.pg-drag-box--move:not(.pg-drag-box--xlock)' + ) + .forEach((box) => { + const dragger = new DragMove(box); + wireDragEvents(log, dragger, 'drag', box.dataset.box!); + }); + + // X-axis-locked DragMove. + const xBox = arena.querySelector('.pg-drag-box--xlock'); + if (xBox) { + const dragger = new DragMove(xBox, {axis: 'x'}); + wireDragEvents(log, dragger, 'drag', `${xBox.dataset.box} (x-locked)`); + } + + // Blue box: raw BaseDrag with an onDrag that positions it ourselves. + const baseBox = arena.querySelector('.pg-drag-box--base'); + if (baseBox) { + let start = {left: 0, top: 0}; + const dragger: BaseDrag = new BaseDrag(baseBox, { + onDragStart: () => { + start = {left: baseBox.offsetLeft, top: baseBox.offsetTop}; + }, + onDrag: () => { + baseBox.style.left = `${start.left + dragger.mouseDistX!}px`; + baseBox.style.top = `${start.top + dragger.mouseDistY!}px`; + }, + }); + wireDragEvents(log, dragger, 'drag', 'base-1 (manual onDrag)'); + } + + main + .querySelector('[data-act="reset"]')! + .addEventListener('click', () => { + boxes.forEach(placeBox); + log.log('drag', 'Reset box positions'); + }); + + return storyLayout(main, log); + }, +}; + +export const AutoScroll: Story = { + render: () => { + const log = createEventLog('Auto-scroll story loaded.'); + const main = document.createElement('div'); + main.innerHTML = ` +

    + The orange item lives inside a tall scrollable container. Grab it and drag + toward the top or bottom edge (within ~25px) — the container should + auto-scroll via the RAF loop. +

    +
    + +
    +
    +
    +
    Drag me near an edge
    +

    ↑ tall scrollable content ↑

    +

    scroll me by dragging to the edges

    +

    ↓ keep going ↓

    +
    +
    `; + + const scrollContainer = main.querySelector( + '[data-scroll-container]' + )!; + const scrollBox = scrollContainer.querySelector( + '.pg-drag-box--scroll' + )!; + + const dragger = new DragMove(scrollBox); + wireDragEvents(log, dragger, 'autoscroll', 'scroll-box'); + + main + .querySelector('[data-act="reset"]')! + .addEventListener('click', () => { + scrollBox.style.left = '20px'; + scrollBox.style.top = '20px'; + scrollContainer.scrollTop = 0; + log.log('autoscroll', 'Reset scroll item + scroll position'); + }); + + return storyLayout(main, log); + }, +}; diff --git a/packages/craftcms-garnish/stories/compat.stories.ts b/packages/craftcms-garnish/stories/compat.stories.ts new file mode 100644 index 00000000000..4b309c5f890 --- /dev/null +++ b/packages/craftcms-garnish/stories/compat.stories.ts @@ -0,0 +1,113 @@ +import type {Meta, StoryObj} from '@storybook/html-vite'; +import {installGarnishCompat} from '../src/compat'; +import {createEventLog, storyLayout} from './_log'; +import {buildModalContainer} from './_helpers'; + +/** + * Compat upgrade path — playground section 3. + * + * Calls `installGarnishCompat()`, then defines a subclass with the legacy + * `Garnish.Modal.extend({ init, onShow })` API and a `this.base()` super-call — + * proving the legacy authoring contract works in the browser against the modern + * `class Modal`. This is the `.extend()` / `this.base` upgrade story. + */ +const meta: Meta = { + title: 'Compat', +}; +export default meta; + +type Story = StoryObj; + +interface LegacyCtorLike { + extend(instance: Record, statics?: object): LegacyCtorLike; + new (...args: unknown[]): unknown; +} + +interface GarnishGlobalLike { + Modal: LegacyCtorLike; + [key: string]: unknown; +} + +export const ExtendUpgradePath: Story = { + render: () => { + const log = createEventLog('Compat story loaded.'); + const main = document.createElement('div'); + main.innerHTML = ` +

    + Calls installGarnishCompat(), then defines a subclass with the + legacy Garnish.Modal.extend({ init, onShow }) API and a + this.base() super-call — proving the legacy authoring contract + works against the modern class Modal. +

    +
    + + +
    `; + + let compatGarnish: GarnishGlobalLike | null = null; + + const actions: Record void> = { + install: () => { + compatGarnish = installGarnishCompat() as unknown as GarnishGlobalLike; + const onWindow = + (window as unknown as {Garnish?: unknown}).Garnish !== undefined; + log.log( + 'compat', + `installGarnishCompat() ran. window.Garnish present: ${onWindow}; Garnish.Modal.extend is ${typeof compatGarnish.Modal.extend}` + ); + }, + extend: () => { + if (!compatGarnish) { + actions.install!(); + } + const Garnish = compatGarnish!; + + // Define the subclass exactly the legacy way: init() trampoline + this.base(). + const DemoModalSubclass = Garnish.Modal.extend({ + init(this: { + base: (...a: unknown[]) => unknown; + $container: HTMLElement | null; + }) { + const container = buildModalContainer( + 'Legacy .extend() subclass', + '

    This modal was created via Garnish.Modal.extend({ init, onShow }) and uses this.base() in onShow.

    ' + ); + // Call the modern Modal constructor through the init trampoline. + this.base(container, {autoShow: false}); + log.log( + 'compat', + 'subclass init() ran, called this.base(container)' + ); + }, + onShow(this: {base: () => void}) { + // this.base() dispatches to Modal.prototype.onShow (fires 'show'). + this.base(); + log.log('compat', 'subclass onShow() ran, then called this.base()'); + }, + }) as LegacyCtorLike; + + const instance = new DemoModalSubclass() as { + on(evt: string, fn: () => void): void; + show(): void; + }; + instance.on('show', () => + log.log('compat', "subclass 'show' event observed") + ); + instance.show(); + log.log('compat', 'Instantiated + show()ed the .extend() subclass'); + }, + }; + + main.querySelectorAll('[data-act]').forEach((btn) => { + btn.addEventListener('click', () => { + try { + actions[btn.dataset.act!]?.(); + } catch (err) { + log.log('compat', `Error: ${(err as Error).message}`, true); + } + }); + }); + + return storyLayout(main, log); + }, +}; diff --git a/packages/craftcms-garnish/stories/disclosure-menu.stories.ts b/packages/craftcms-garnish/stories/disclosure-menu.stories.ts new file mode 100644 index 00000000000..67601be5192 --- /dev/null +++ b/packages/craftcms-garnish/stories/disclosure-menu.stories.ts @@ -0,0 +1,147 @@ +import type {Meta, StoryObj} from '@storybook/html-vite'; +import {DisclosureMenu} from '../src/index'; +import {createEventLog, storyLayout, type EventLog} from './_log'; + +/** + * `DisclosureMenu` — dropdown menu — playground section 12. + * + * Pairs a trigger button (`aria-controls` / `aria-expanded`) with a menu panel. + * Click to toggle; the panel anchors under the trigger (flipping above when + * there's no room). Arrow keys move between items, typing a letter does type-ahead + * search, Esc / outside-click closes. A second variant adds a live search input. + * + * Each story builds its menu as the trigger's `nextElementSibling` with a unique + * id, so `DisclosureMenu` resolves it even before Storybook mounts the canvas. + */ +const meta: Meta = { + title: 'DisclosureMenu', +}; +export default meta; + +type Story = StoryObj; + +// Unique-id counter so re-renders don't collide with menus moved to . +let uid = 0; +const nextId = (): string => `sb-disc-menu-${++uid}`; + +/** Wire a menu's lifecycle events into the log. */ +function wireMenuEvents( + log: EventLog, + menu: DisclosureMenu, + label: string +): void { + for (const evt of ['beforeShow', 'show', 'hide'] as const) { + menu.on(evt, () => log.log('disclosure', `${label}: ${evt}`)); + } +} + +export const Dropdown: Story = { + render: () => { + const log = createEventLog('DisclosureMenu story loaded.'); + const menuId = nextId(); + const main = document.createElement('div'); + main.innerHTML = ` +

    + A new DisclosureMenu(trigger) with static markup: groups, a + separator, plus a disabled and a destructive item. Open it, then use + /, type a letter for type-ahead, or + Esc to close. +

    +
    + + + +
    `; + + const trigger = main.querySelector('.pg-disc-trigger')!; + const menu = new DisclosureMenu(trigger); + wireMenuEvents(log, menu, 'actions'); + + menu.$container + ?.querySelectorAll('.menu-item:not(.disabled)') + .forEach((item) => { + item.addEventListener('click', () => { + const label = + item.querySelector('.menu-item-label')?.textContent ?? '?'; + log.log('disclosure', `actions: selected "${label}"`); + }); + }); + + main + .querySelector('[data-act="destroy"]')! + .addEventListener('click', () => { + menu.destroy(); + log.log('disclosure', 'Destroyed the DisclosureMenu instance'); + }); + + return storyLayout(main, log); + }, +}; + +interface FilterableArgs { + withSearchInput: boolean; +} + +export const Filterable: StoryObj = { + argTypes: { + withSearchInput: { + control: 'boolean', + description: 'Add a live search input that filters the items', + }, + }, + args: { + withSearchInput: true, + }, + render: (args) => { + const log = createEventLog('DisclosureMenu (filterable) story loaded.'); + const menuId = nextId(); + const main = document.createElement('div'); + main.innerHTML = ` +

    + Items are added via the API (addItems). With + withSearchInput enabled, a search field filters them live. +

    +
    + + +
    `; + + const trigger = main.querySelector('.pg-disc-trigger')!; + const menu = new DisclosureMenu(trigger, { + withSearchInput: args.withSearchInput, + }); + wireMenuEvents(log, menu, 'filter'); + + const fruits = [ + 'Apple', + 'Apricot', + 'Banana', + 'Blueberry', + 'Cherry', + 'Grape', + 'Mango', + 'Orange', + 'Peach', + 'Pear', + ]; + menu.addItems( + fruits.map((name) => ({ + label: name, + onActivate: () => log.log('disclosure', `filter: selected "${name}"`), + })) + ); + + return storyLayout(main, log); + }, +}; diff --git a/packages/craftcms-garnish/stories/drag-drop.stories.ts b/packages/craftcms-garnish/stories/drag-drop.stories.ts new file mode 100644 index 00000000000..fa4bb15ee07 --- /dev/null +++ b/packages/craftcms-garnish/stories/drag-drop.stories.ts @@ -0,0 +1,103 @@ +import type {Meta, StoryObj} from '@storybook/html-vite'; +import {DragDrop} from '../src/index'; +import {createEventLog, storyLayout} from './_log'; + +/** + * `DragDrop` — drop targets & hit detection — playground section 8. + * + * A `new DragDrop({ dropTargets, ... })` wired to draggable chips and three drop + * zones. While dragging, the zone under the cursor highlights + * (`activeDropTargetClass`) and `dropTargetChange` logs the new active target. + * On release, the demo reads `$activeDropTarget` in its `dragStop` handler. + */ +const meta: Meta = { + title: 'Drag/DragDrop', +}; +export default meta; + +type Story = StoryObj; + +export const DropTargets: Story = { + render: () => { + const log = createEventLog('DragDrop story loaded.'); + const main = document.createElement('div'); + main.innerHTML = ` +

    + Drag a chip over a zone: the zone under the cursor highlights and + dropTargetChange logs the new active target (or + null). On release the demo reads $activeDropTarget + to report where it landed. Overlap resolves to the first target in document + order. +

    +
    + +
    +
    +
    +
    Chip A
    +
    Chip B
    +
    Chip C
    +
    +
    +
    Inbox
    +
    Archive
    +
    Trash
    +
    +
    `; + + const chips = Array.from( + main.querySelectorAll('[data-dragdrop-chip]') + ); + const zones = Array.from( + main.querySelectorAll('[data-dropzone]') + ); + + const dragDrop = new DragDrop({ + dropTargets: zones, + hideDraggee: true, + helperOpacity: 0.92, + onDropTargetChange(activeDropTarget) { + const name = activeDropTarget?.dataset.dropzone ?? 'none'; + log.log('dragdrop', `dropTargetChange → ${name}`); + }, + onDragStop() { + // Legacy contract: no `drop` event — read $activeDropTarget here. + const target = dragDrop.$activeDropTarget; + if (target) { + log.log( + 'dragdrop', + `dropped on "${target.dataset.dropzone}" (read from $activeDropTarget)` + ); + target.classList.add('pg-dropzone--hit'); + setTimeout(() => target.classList.remove('pg-dropzone--hit'), 600); + } else { + log.log('dragdrop', 'dropped outside any drop target'); + } + dragDrop.returnHelpersToDraggees(); + }, + }); + dragDrop.addItems(chips); + + // Also surface the raw drag lifecycle (coalesce the per-frame `drag`). + let dragging = false; + dragDrop.on('dragStart', () => { + dragging = false; + log.log('dragdrop', 'dragStart'); + }); + dragDrop.on('drag', () => { + if (!dragging) { + dragging = true; + log.log('dragdrop', 'drag (over zones…)'); + } + }); + + main + .querySelector('[data-act="reset"]')! + .addEventListener('click', () => { + zones.forEach((z) => z.classList.remove('active', 'pg-dropzone--hit')); + log.log('dragdrop', 'Reset chips + cleared zone highlights'); + }); + + return storyLayout(main, log); + }, +}; diff --git a/packages/craftcms-garnish/stories/drag-sort.stories.ts b/packages/craftcms-garnish/stories/drag-sort.stories.ts new file mode 100644 index 00000000000..3ecd8ef6c7b --- /dev/null +++ b/packages/craftcms-garnish/stories/drag-sort.stories.ts @@ -0,0 +1,102 @@ +import type {Meta, StoryObj} from '@storybook/html-vite'; +import {DragSort} from '../src/index'; +import {createEventLog, storyLayout} from './_log'; + +/** + * `DragSort` — reorderable list — playground section 9. + * + * A `new DragSort(items, { insertion, ... })` wired to a sortable list. Grab a + * row and drag it: the list reflows live around a dashed `insertion` placeholder + * (`insertionPointChange` logs each move). On drop the new order is committed and + * `sortChange` logs the new index order. The list is `axis: 'y'`-locked. + */ +const meta: Meta = { + title: 'Drag/DragSort', +}; +export default meta; + +type Story = StoryObj; + +export const ReorderableList: Story = { + render: () => { + const log = createEventLog('DragSort story loaded.'); + const main = document.createElement('div'); + main.innerHTML = ` +

    + Grab a row and drag it up or down — the list reflows live around a dashed + insertion placeholder. On drop the new order commits; + sortChange logs the new order. Handles carry + touch-action: none. +

    +
    + +
    +
      +
    • Apples
    • +
    • Oranges
    • +
    • Bananas
    • +
    • Grapes
    • +
    • Mangoes
    • +
    `; + + const sortList = main.querySelector('[data-sort-list]')!; + const items = Array.from( + sortList.querySelectorAll('[data-sort-item]') + ); + const originalOrder = items.map((el) => el.dataset.sortItem!); + + const currentOrder = (): string => + Array.from(sortList.querySelectorAll('[data-sort-item]')) + .map((el) => el.dataset.sortItem) + .join(', '); + + const dragSort = new DragSort(items, { + container: sortList, + axis: 'y', + hideDraggee: true, + helperOpacity: 0.95, + insertion: () => { + const ph = document.createElement('li'); + ph.className = 'pg-sort-insertion'; + return ph; + }, + onInsertionPointChange() { + log.log( + 'dragsort', + `insertionPointChange → order now [${currentOrder()}]` + ); + }, + onSortChange() { + log.log('dragsort', `sortChange → new order [${currentOrder()}]`); + }, + }); + + // Surface the raw drag lifecycle too (coalesce the per-frame `drag`). + let dragging = false; + dragSort.on('dragStart', () => { + dragging = false; + log.log('dragsort', 'dragStart'); + }); + dragSort.on('drag', () => { + if (!dragging) { + dragging = true; + log.log('dragsort', 'drag (reordering…)'); + } + }); + dragSort.on('dragStop', () => log.log('dragsort', 'dragStop')); + + main + .querySelector('[data-act="reset"]')! + .addEventListener('click', () => { + for (const id of originalOrder) { + const el = sortList.querySelector( + `[data-sort-item="${id}"]` + ); + if (el) sortList.appendChild(el); + } + log.log('dragsort', `Reset list order → [${currentOrder()}]`); + }); + + return storyLayout(main, log); + }, +}; diff --git a/packages/craftcms-garnish/stories/drag.stories.ts b/packages/craftcms-garnish/stories/drag.stories.ts new file mode 100644 index 00000000000..cb856399362 --- /dev/null +++ b/packages/craftcms-garnish/stories/drag.stories.ts @@ -0,0 +1,103 @@ +import type {Meta, StoryObj} from '@storybook/html-vite'; +import {Drag} from '../src/index'; +import {createEventLog, storyLayout, type EventLog} from './_log'; + +/** + * `Drag` with helpers — playground section 7. + * + * Grabbing an item builds a floating helper clone that trails the cursor with + * lag (`createHelper` + the lag-follow RAF loop). On drop the helper animates + * back to its source via the Web Animations API (`returnHelpersToDraggees`) — or + * fades out (`fadeOutHelpers`), selectable via the `dropMode` control. + */ +const meta: Meta = { + title: 'Drag/Drag', +}; +export default meta; + +interface DragArgs { + dropMode: 'return' | 'fade'; +} + +type Story = StoryObj; + +/** Wire a Drag's lifecycle into the log, including `returnHelpersToDraggees`. */ +function wireHelperDragEvents( + log: EventLog, + dragger: Drag, + label: string +): void { + let dragging = false; + dragger.on('dragStart', () => { + dragging = false; + log.log('draghelper', `${label}: dragStart (helper clone created)`); + }); + dragger.on('drag', () => { + if (!dragging) { + dragging = true; + log.log('draghelper', `${label}: drag (helper trailing cursor…)`); + } + }); + dragger.on('dragStop', () => log.log('draghelper', `${label}: dragStop`)); + dragger.on('returnHelpersToDraggees', () => + log.log('draghelper', `${label}: returnHelpersToDraggees (helpers home)`) + ); +} + +export const Helpers: Story = { + argTypes: { + dropMode: { + control: 'inline-radio', + options: ['return', 'fade'], + description: 'On drop: return helpers to source, or fade them out', + }, + }, + args: { + dropMode: 'return', + }, + render: (args) => { + const log = createEventLog('Drag (helpers) story loaded.'); + const main = document.createElement('div'); + main.innerHTML = ` +

    + These rows use new Drag(items, {...}). Grabbing an item builds + a floating helper clone that trails the cursor. On drop the + helper either animates back to its source or fades out — pick via the + dropMode control. +

    +
    +
    Draggable item #1 — grab & drag me
    +
    Draggable item #2 — a helper clone trails the cursor
    +
    Draggable item #3 — release to animate home
    +
    Draggable item #4
    +
    `; + + const list = main.querySelector('[data-helper-list]')!; + const items = Array.from( + list.querySelectorAll('[data-helper-item]') + ); + + items.forEach((item) => { + const dragger = new Drag(item, { + // Hide the source while its helper clone is in flight. + hideDraggee: true, + helperOpacity: 0.92, + onDragStop() { + // Default Drag does NOT auto-return; the consumer decides. + if (args.dropMode === 'fade') { + dragger.fadeOutHelpers(); + log.log( + 'draghelper', + `${item.dataset.helperItem}: fadeOutHelpers()` + ); + } else { + dragger.returnHelpersToDraggees(); + } + }, + }); + wireHelperDragEvents(log, dragger, `item ${item.dataset.helperItem}`); + }); + + return storyLayout(main, log); + }, +}; diff --git a/packages/craftcms-garnish/stories/focus.stories.ts b/packages/craftcms-garnish/stories/focus.stories.ts new file mode 100644 index 00000000000..ea1a30b922b --- /dev/null +++ b/packages/craftcms-garnish/stories/focus.stories.ts @@ -0,0 +1,105 @@ +import type {Meta, StoryObj} from '@storybook/html-vite'; +import {Modal, getFocusableElements, isKeyboardFocusable} from '../src/index'; +import {createEventLog, storyLayout} from './_log'; +import {buildModalContainer} from './_helpers'; + +/** + * Focusable matcher & focus trap — playground section 2. + * + * Run `getFocusableElements` / `isKeyboardFocusable` over a mixed container to + * highlight what they match, then open a focus-trap modal and Tab / Shift+Tab to + * see cycling stay trapped inside. + */ +const meta: Meta = { + title: 'Focus', +}; +export default meta; + +type Story = StoryObj; + +export const FocusableMatcherAndTrap: Story = { + render: () => { + const log = createEventLog('Focus story loaded.'); + const main = document.createElement('div'); + main.innerHTML = ` +

    + The container mixes focusable and non-focusable elements. Run the matcher + to highlight what getFocusableElements / + isKeyboardFocusable select, then open the focus-trap modal and + press Tab / Shift+Tab. +

    +
    + + + +
    +
    + Link with href (focusable) + Link, no href (not focusable) + + + + + Span tabindex=0 (keyboard focusable) + Span tabindex=-1 (focusable, not keyboard) + Plain span (not focusable) + +
    `; + + 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, log); + }, +}; diff --git a/packages/craftcms-garnish/stories/hud.stories.ts b/packages/craftcms-garnish/stories/hud.stories.ts new file mode 100644 index 00000000000..5e4918407f7 --- /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('HUD story loaded.'); + 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, log); + }, +}; diff --git a/packages/craftcms-garnish/stories/modal.stories.ts b/packages/craftcms-garnish/stories/modal.stories.ts new file mode 100644 index 00000000000..5b71c32d3af --- /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('Modal story loaded.'); + 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}`, true); + } + }); + }); + + return storyLayout(main, log); + }, +}; + +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('Draggable/resizable Modal story loaded.'); + 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}`, true); + } + }); + }); + + return storyLayout(main, log); + }, +}; diff --git a/packages/craftcms-garnish/stories/utilities.stories.ts b/packages/craftcms-garnish/stories/utilities.stories.ts new file mode 100644 index 00000000000..47966e89cef --- /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('Utilities story loaded.'); + 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, log); + }, +}; diff --git a/packages/craftcms-garnish/tsconfig.json b/packages/craftcms-garnish/tsconfig.json index d35d34bce8e..5f320ebf345 100644 --- a/packages/craftcms-garnish/tsconfig.json +++ b/packages/craftcms-garnish/tsconfig.json @@ -8,5 +8,12 @@ "@src/*": ["./src/*"] } }, - "include": ["src", "tests", "tsdown.config.ts", "vitest.config.ts"] + "include": [ + "src", + "tests", + "stories", + ".storybook", + "tsdown.config.ts", + "vitest.config.ts" + ] } diff --git a/packages/craftcms-garnish/vite.config.ts b/packages/craftcms-garnish/vite.config.ts deleted file mode 100644 index 47bf018aabf..00000000000 --- a/packages/craftcms-garnish/vite.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {defineConfig} from 'vite'; - -/** - * Dev-only Vite config for the interactive playground. - * - * This is ADDITIVE and never part of the shipped package: the npm package is - * built by tsdown (`npm run build`) and tested by Vitest. Vite exists purely to - * serve `playground/` with HMR (`npm run dev`) and to compile-verify it in CI - * (`npm run playground:build`). - * - * `root: 'playground'` makes `playground/index.html` the dev entrypoint. The - * playground imports `../src/*` directly, so edits to the real source hot-reload - * instantly with no build step. - */ -export default defineConfig({ - root: 'playground', - build: { - // Keep playground build artifacts out of the package's `dist/` (tsdown owns - // that). Emitted relative to `root`, i.e. `playground/dist`. - outDir: 'dist', - emptyOutDir: true, - }, -}); From 74ec3fe7f5f6c9b7c3ceef5ba6f4845f4fbede0e Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Thu, 18 Jun 2026 12:04:38 -0500 Subject: [PATCH 10/13] Use Storybook Actions panel instead of a custom event log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hand-rolled in-canvas event-log panel with Storybook's built-in Actions panel (`storybook/actions`, part of core in 10.x — no extra addon). - stories/_log.ts: createEventLog() now wraps `action()`, memoizing one named action per tag so events group by tag in the Actions panel. Drop the panel/ clear/initialMessage API; storyLayout(main) just tags the demo container. - Update all story call sites: createEventLog(), storyLayout(main), drop the isError third arg. - preview.css: remove the .pg-log* panel styles; .pg-story is now a simple single-column demo container, not a two-column flex with a side panel. - Update docs/17-storybook-notes.md and README to describe the Actions panel. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../craftcms-garnish/.storybook/preview.css | 86 +------------- packages/craftcms-garnish/README.md | 2 +- .../docs/17-storybook-notes.md | 31 +++-- packages/craftcms-garnish/stories/_log.ts | 108 ++++++------------ .../stories/base-drag.stories.ts | 8 +- .../stories/compat.stories.ts | 6 +- .../stories/disclosure-menu.stories.ts | 8 +- .../stories/drag-drop.stories.ts | 4 +- .../stories/drag-sort.stories.ts | 4 +- .../craftcms-garnish/stories/drag.stories.ts | 4 +- .../craftcms-garnish/stories/focus.stories.ts | 4 +- .../craftcms-garnish/stories/hud.stories.ts | 4 +- .../craftcms-garnish/stories/modal.stories.ts | 12 +- .../stories/utilities.stories.ts | 4 +- 14 files changed, 88 insertions(+), 197 deletions(-) diff --git a/packages/craftcms-garnish/.storybook/preview.css b/packages/craftcms-garnish/.storybook/preview.css index ad20bc7572d..bf0e582561e 100644 --- a/packages/craftcms-garnish/.storybook/preview.css +++ b/packages/craftcms-garnish/.storybook/preview.css @@ -23,12 +23,9 @@ --shadow: 0 6px 24px rgba(0, 0, 0, 0.18); } -/* --- Story layout (demo column + embedded event log) -------------------- */ +/* --- Story layout ------------------------------------------------------- */ +/* Events surface in Storybook's Actions panel, so the canvas is just the demo. */ .pg-story { - display: flex; - gap: 1rem; - align-items: flex-start; - flex-wrap: wrap; font-family: system-ui, -apple-system, @@ -37,17 +34,12 @@ sans-serif; color: var(--text); line-height: 1.5; + max-width: 60ch; } -.pg-story-main { - flex: 1 1 360px; - min-width: 300px; -} - -.pg-story-main > p { +.pg-story > p { margin: 0 0 1rem; color: var(--muted); - max-width: 60ch; } .pg-controls { @@ -477,76 +469,6 @@ body.no-scroll { background: #e6f0ff; } -/* --- Event log panel (embedded per-story) ------------------------------- */ -.pg-log { - width: 320px; - flex: 0 0 auto; - max-height: 420px; - display: flex; - flex-direction: column; - background: #11151b; - color: #e6e9ee; - border-radius: var(--radius); - box-shadow: var(--shadow); - overflow: hidden; -} - -.pg-log-head { - display: flex; - align-items: center; - justify-content: space-between; - padding: 0.5rem 0.75rem; - background: #0b0e13; - border-bottom: 1px solid #222834; -} - -.pg-log-head strong { - font-family: - system-ui, - -apple-system, - sans-serif; - font-size: 0.85rem; -} - -.pg-log-head button { - padding: 0.15rem 0.5rem; - font-size: 0.75rem; - background: #222834; - color: #e6e9ee; - border: 1px solid #333b48; - border-radius: var(--radius); - cursor: pointer; -} - -.pg-log-list { - margin: 0; - padding: 0.5rem 0.75rem 0.5rem 2rem; - overflow: auto; - font-family: ui-monospace, 'SFMono-Regular', Menlo, monospace; - font-size: 0.78rem; - list-style: decimal; -} - -.pg-log-list li { - padding: 0.15rem 0; - border-bottom: 1px solid #1b212b; - word-break: break-word; -} - -.pg-log-list li .pg-log-tag { - color: #6ad08a; - font-weight: 600; -} - -.pg-log-list li.pg-log-error .pg-log-tag { - color: #ff8a7a; -} - -.pg-log-list li time { - color: #7b8493; - margin-right: 0.4rem; -} - /* --- HUD / anchored popover -------------------------------------------- */ .pg-hud-arena { position: relative; diff --git a/packages/craftcms-garnish/README.md b/packages/craftcms-garnish/README.md index bf4f248d9f7..fe68277bc03 100644 --- a/packages/craftcms-garnish/README.md +++ b/packages/craftcms-garnish/README.md @@ -328,7 +328,7 @@ 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 shared event-log helper, and how to add a story +for how stories are organized, the Actions-panel event logger, and how to add a story when porting a new component. ## Status diff --git a/packages/craftcms-garnish/docs/17-storybook-notes.md b/packages/craftcms-garnish/docs/17-storybook-notes.md index 2466d7a0201..1e12cfaf0e1 100644 --- a/packages/craftcms-garnish/docs/17-storybook-notes.md +++ b/packages/craftcms-garnish/docs/17-storybook-notes.md @@ -27,7 +27,7 @@ packages/craftcms-garnish/ preview.ts # global parameters + theme decorator; imports preview.css preview.css # demo globals migrated from the old playground/styles.css stories/ - _log.ts # shared event-log panel + drag-event wiring + layout helper + _log.ts # Actions-panel event logger + drag-event wiring + layout helper _helpers.ts # shared modal-container builders modal.stories.ts focus.stories.ts @@ -63,18 +63,25 @@ npm run build:storybook # static build (compiles every story — the CI proof) under the hood and supplies its own pipeline (there is no package-root `vite.config.ts` anymore — it only ever served the playground). -## The event-log helper +## The event logger (Storybook Actions) -Drag/menu/modal stories surface the events the real widgets fire. `stories/_log.ts` -exports: +Drag/menu/modal stories surface the events the real widgets fire by logging them +to Storybook's built-in **Actions** panel (the "Actions" tab in the addons panel), +rather than a hand-rolled in-canvas panel. `stories/_log.ts` wraps +`storybook/actions` and exports: -- `createEventLog(initialMessage?)` → `{panel, log, clear}`. Mount `panel` in the - story; call `log(tag, message, isError?)` from your event handlers. +- `createEventLog()` → `{log}`. Call `log(tag, message)` from your event handlers; + each `tag` becomes a named action so the panel groups events by tag (e.g. + `modal`, `drag`, `disclosure`). No DOM panel to mount. - `wireDragEvents(log, dragger, tag, label)` — subscribes a dragger's `dragStart`/`drag`/`dragStop` into the log, coalescing the per-RAF-frame `drag` - to a single line per gesture. -- `storyLayout(main, log?)` — wraps the demo column and (optional) log panel in the - `.pg-story` flex layout the global styles expect. + to a single line per gesture so the panel stays readable. +- `storyLayout(main)` — tags the demo element with `.pg-story` for the global + styles; the canvas is just the demo. + +The Actions panel is part of Storybook core in 10.x — no `@storybook/addon-actions` +entry is needed in `.storybook/main.ts`; importing `action` from +`storybook/actions` is enough. Typical story shape: @@ -89,10 +96,10 @@ type Story = StoryObj; export const Basic: Story = { render: () => { - const log = createEventLog('Modal story loaded.'); + const log = createEventLog(); const main = document.createElement('div'); - // …build controls, `new Modal(container)`, wire events to `log.log(...)`… - return storyLayout(main, log); + // …build controls, `new Modal(container)`, wire events to `log.log('modal', …)`… + return storyLayout(main); }, }; ``` diff --git a/packages/craftcms-garnish/stories/_log.ts b/packages/craftcms-garnish/stories/_log.ts index 3f9534333b5..83cf79eeba1 100644 --- a/packages/craftcms-garnish/stories/_log.ts +++ b/packages/craftcms-garnish/stories/_log.ts @@ -1,10 +1,11 @@ /** - * Shared event-log helper for @craftcms/garnish stories. + * Shared event-logging helper for @craftcms/garnish stories. * - * Mirrors the old playground's bottom-right "Event log" panel, but mounts the - * panel INSIDE a story so it scopes to that story's canvas. Drag/menu/modal - * stories use it to surface the events the real widgets fire (`show`, `hide`, - * `dragStart`, `dropTargetChange`, …) as you interact. + * Backed by Storybook's built-in **Actions** panel (`storybook/actions`) rather + * than a hand-rolled DOM panel: every logged event shows up in the "Actions" tab + * under the addons panel, grouped by `tag`. Drag/menu/modal stories use it to + * surface the events the real widgets fire (`show`, `hide`, `dragStart`, + * `dropTargetChange`, …) as you interact. * * Usage in a story `render()`: * @@ -12,76 +13,46 @@ * const log = createEventLog(); * const main = document.createElement('div'); * // …build the demo, call log.log('modal', 'show') from event handlers… - * return storyLayout(main, log); + * return storyLayout(main); * ``` */ +import {action} from 'storybook/actions'; + /** A subscribable lifecycle object (Garnish `Base` / draggers expose `on`). */ export interface Subscribable { on(events: string, handler: () => void): void; } export interface EventLog { - /** The panel element to mount in the story (via {@link storyLayout}). */ - readonly panel: HTMLElement; - /** Append a line. `tag` is the green `[tag]` prefix; set `isError` to flag it red. */ - log(tag: string, message: string, isError?: boolean): void; - /** Clear all lines. */ - clear(): void; + /** + * Log an event to the Actions panel under `tag` with a human-readable message. + */ + log(tag: string, message: string): void; } -/** Build an event-log panel + its `log()`/`clear()` API. */ -export function createEventLog(initialMessage?: string): EventLog { - const panel = document.createElement('aside'); - panel.className = 'pg-log'; - panel.innerHTML = ` -
    - Event log - -
    -
      `; - - const list = panel.querySelector('.pg-log-list')!; - - const log: EventLog['log'] = (tag, message, isError = false) => { - const li = document.createElement('li'); - if (isError) { - li.className = 'pg-log-error'; - } - - const time = document.createElement('time'); - time.textContent = new Date().toLocaleTimeString(undefined, { - hour12: false, - }); - - const tagEl = document.createElement('span'); - tagEl.className = 'pg-log-tag'; - tagEl.textContent = `[${tag}] `; - - li.append(time, tagEl, document.createTextNode(message)); - list.appendChild(li); - list.scrollTop = list.scrollHeight; - }; - - const clear = (): void => { - list.innerHTML = ''; - }; - - panel - .querySelector('[data-log-clear]')! - .addEventListener('click', clear); - - if (initialMessage) { - log('ready', initialMessage); +// Memoize one Storybook action per tag so the panel groups events by tag. +const actions = new Map>(); +function actionFor(tag: string): ReturnType { + let fn = actions.get(tag); + if (!fn) { + fn = action(tag); + actions.set(tag, fn); } + return fn; +} - return {panel, log, clear}; +/** Create a logger that writes events into Storybook's Actions panel. */ +export function createEventLog(): EventLog { + return { + log: (tag, message) => actionFor(tag)(message), + }; } /** * Wire a dragger's lifecycle into the log. `drag` fires once per RAF frame, so - * it is coalesced to a single "drag (moving…)" line per gesture to keep the log - * readable; `dragStart` / `dragStop` always log. + * it is coalesced to a single "drag (moving…)" line per gesture to keep the + * Actions panel readable; `dragStart` / `dragStop` always log. */ export function wireDragEvents( log: EventLog, @@ -104,20 +75,11 @@ export function wireDragEvents( } /** - * Compose a story's demo column and (optionally) its event-log panel into the - * `.pg-story` flex layout the global styles expect. + * Wrap a story's demo column in the `.pg-story` container the global styles + * expect. Events surface in Storybook's Actions panel, so there is no in-canvas + * log panel to mount. */ -export function storyLayout(main: HTMLElement, log?: EventLog): HTMLElement { - const wrapper = document.createElement('div'); - wrapper.className = 'pg-story'; - - if (!main.classList.contains('pg-story-main')) { - main.classList.add('pg-story-main'); - } - wrapper.appendChild(main); - - if (log) { - wrapper.appendChild(log.panel); - } - return wrapper; +export function storyLayout(main: HTMLElement): HTMLElement { + main.classList.add('pg-story'); + return main; } diff --git a/packages/craftcms-garnish/stories/base-drag.stories.ts b/packages/craftcms-garnish/stories/base-drag.stories.ts index 0199ed1f02a..7944e8c58a8 100644 --- a/packages/craftcms-garnish/stories/base-drag.stories.ts +++ b/packages/craftcms-garnish/stories/base-drag.stories.ts @@ -15,7 +15,7 @@ type Story = StoryObj; export const StandaloneBoxes: Story = { render: () => { - const log = createEventLog('BaseDrag / DragMove story loaded.'); + const log = createEventLog(); const main = document.createElement('div'); main.innerHTML = `

      @@ -94,13 +94,13 @@ export const StandaloneBoxes: Story = { log.log('drag', 'Reset box positions'); }); - return storyLayout(main, log); + return storyLayout(main); }, }; export const AutoScroll: Story = { render: () => { - const log = createEventLog('Auto-scroll story loaded.'); + const log = createEventLog(); const main = document.createElement('div'); main.innerHTML = `

      @@ -139,6 +139,6 @@ export const AutoScroll: Story = { log.log('autoscroll', 'Reset scroll item + scroll position'); }); - return storyLayout(main, log); + return storyLayout(main); }, }; diff --git a/packages/craftcms-garnish/stories/compat.stories.ts b/packages/craftcms-garnish/stories/compat.stories.ts index 4b309c5f890..9d794ae4f84 100644 --- a/packages/craftcms-garnish/stories/compat.stories.ts +++ b/packages/craftcms-garnish/stories/compat.stories.ts @@ -30,7 +30,7 @@ interface GarnishGlobalLike { export const ExtendUpgradePath: Story = { render: () => { - const log = createEventLog('Compat story loaded.'); + const log = createEventLog(); const main = document.createElement('div'); main.innerHTML = `

      @@ -103,11 +103,11 @@ export const ExtendUpgradePath: Story = { try { actions[btn.dataset.act!]?.(); } catch (err) { - log.log('compat', `Error: ${(err as Error).message}`, true); + log.log('compat', `Error: ${(err as Error).message}`); } }); }); - return storyLayout(main, log); + return storyLayout(main); }, }; diff --git a/packages/craftcms-garnish/stories/disclosure-menu.stories.ts b/packages/craftcms-garnish/stories/disclosure-menu.stories.ts index 67601be5192..7cc5925b3bd 100644 --- a/packages/craftcms-garnish/stories/disclosure-menu.stories.ts +++ b/packages/craftcms-garnish/stories/disclosure-menu.stories.ts @@ -37,7 +37,7 @@ function wireMenuEvents( export const Dropdown: Story = { render: () => { - const log = createEventLog('DisclosureMenu story loaded.'); + const log = createEventLog(); const menuId = nextId(); const main = document.createElement('div'); main.innerHTML = ` @@ -85,7 +85,7 @@ export const Dropdown: Story = { log.log('disclosure', 'Destroyed the DisclosureMenu instance'); }); - return storyLayout(main, log); + return storyLayout(main); }, }; @@ -104,7 +104,7 @@ export const Filterable: StoryObj = { withSearchInput: true, }, render: (args) => { - const log = createEventLog('DisclosureMenu (filterable) story loaded.'); + const log = createEventLog(); const menuId = nextId(); const main = document.createElement('div'); main.innerHTML = ` @@ -142,6 +142,6 @@ export const Filterable: StoryObj = { })) ); - return storyLayout(main, log); + return storyLayout(main); }, }; diff --git a/packages/craftcms-garnish/stories/drag-drop.stories.ts b/packages/craftcms-garnish/stories/drag-drop.stories.ts index fa4bb15ee07..bff10afb298 100644 --- a/packages/craftcms-garnish/stories/drag-drop.stories.ts +++ b/packages/craftcms-garnish/stories/drag-drop.stories.ts @@ -19,7 +19,7 @@ type Story = StoryObj; export const DropTargets: Story = { render: () => { - const log = createEventLog('DragDrop story loaded.'); + const log = createEventLog(); const main = document.createElement('div'); main.innerHTML = `

      @@ -98,6 +98,6 @@ export const DropTargets: Story = { log.log('dragdrop', 'Reset chips + cleared zone highlights'); }); - return storyLayout(main, log); + return storyLayout(main); }, }; diff --git a/packages/craftcms-garnish/stories/drag-sort.stories.ts b/packages/craftcms-garnish/stories/drag-sort.stories.ts index 3ecd8ef6c7b..41dea3dc713 100644 --- a/packages/craftcms-garnish/stories/drag-sort.stories.ts +++ b/packages/craftcms-garnish/stories/drag-sort.stories.ts @@ -19,7 +19,7 @@ type Story = StoryObj; export const ReorderableList: Story = { render: () => { - const log = createEventLog('DragSort story loaded.'); + const log = createEventLog(); const main = document.createElement('div'); main.innerHTML = `

      @@ -97,6 +97,6 @@ export const ReorderableList: Story = { log.log('dragsort', `Reset list order → [${currentOrder()}]`); }); - return storyLayout(main, log); + return storyLayout(main); }, }; diff --git a/packages/craftcms-garnish/stories/drag.stories.ts b/packages/craftcms-garnish/stories/drag.stories.ts index cb856399362..c7a40a901e2 100644 --- a/packages/craftcms-garnish/stories/drag.stories.ts +++ b/packages/craftcms-garnish/stories/drag.stories.ts @@ -56,7 +56,7 @@ export const Helpers: Story = { dropMode: 'return', }, render: (args) => { - const log = createEventLog('Drag (helpers) story loaded.'); + const log = createEventLog(); const main = document.createElement('div'); main.innerHTML = `

      @@ -98,6 +98,6 @@ export const Helpers: Story = { wireHelperDragEvents(log, dragger, `item ${item.dataset.helperItem}`); }); - return storyLayout(main, log); + return storyLayout(main); }, }; diff --git a/packages/craftcms-garnish/stories/focus.stories.ts b/packages/craftcms-garnish/stories/focus.stories.ts index ea1a30b922b..bdd7be39bde 100644 --- a/packages/craftcms-garnish/stories/focus.stories.ts +++ b/packages/craftcms-garnish/stories/focus.stories.ts @@ -19,7 +19,7 @@ type Story = StoryObj; export const FocusableMatcherAndTrap: Story = { render: () => { - const log = createEventLog('Focus story loaded.'); + const log = createEventLog(); const main = document.createElement('div'); main.innerHTML = `

      @@ -100,6 +100,6 @@ export const FocusableMatcherAndTrap: Story = { btn.addEventListener('click', () => actions[btn.dataset.act!]?.()); }); - return storyLayout(main, log); + return storyLayout(main); }, }; diff --git a/packages/craftcms-garnish/stories/hud.stories.ts b/packages/craftcms-garnish/stories/hud.stories.ts index 5e4918407f7..a56977420c2 100644 --- a/packages/craftcms-garnish/stories/hud.stories.ts +++ b/packages/craftcms-garnish/stories/hud.stories.ts @@ -36,7 +36,7 @@ export const AnchoredPopover: Story = { preferredOrientation: 'auto', }, render: (args) => { - const log = createEventLog('HUD story loaded.'); + const log = createEventLog(); const main = document.createElement('div'); main.innerHTML = `

      @@ -112,6 +112,6 @@ export const AnchoredPopover: Story = { log.log('hud', `Destroyed ${count} HUD instance(s)`); }); - return storyLayout(main, log); + return storyLayout(main); }, }; diff --git a/packages/craftcms-garnish/stories/modal.stories.ts b/packages/craftcms-garnish/stories/modal.stories.ts index 5b71c32d3af..a5878d82849 100644 --- a/packages/craftcms-garnish/stories/modal.stories.ts +++ b/packages/craftcms-garnish/stories/modal.stories.ts @@ -59,7 +59,7 @@ export const Basic: Story = { closeOtherModals: false, }, render: (args) => { - const log = createEventLog('Modal story loaded.'); + const log = createEventLog(); const main = document.createElement('div'); main.innerHTML = `

      @@ -113,12 +113,12 @@ export const Basic: Story = { try { actions[btn.dataset.act!]?.(); } catch (err) { - log.log('modal', `Error: ${(err as Error).message}`, true); + log.log('modal', `Error: ${(err as Error).message}`); } }); }); - return storyLayout(main, log); + return storyLayout(main); }, }; @@ -143,7 +143,7 @@ export const DraggableAndResizable: StoryObj = { resizable: true, }, render: (args) => { - const log = createEventLog('Draggable/resizable Modal story loaded.'); + const log = createEventLog(); const main = document.createElement('div'); main.innerHTML = `

      @@ -201,11 +201,11 @@ export const DraggableAndResizable: StoryObj = { try { actions[btn.dataset.act!]?.(); } catch (err) { - log.log('modal', `Error: ${(err as Error).message}`, true); + log.log('modal', `Error: ${(err as Error).message}`); } }); }); - return storyLayout(main, log); + return storyLayout(main); }, }; diff --git a/packages/craftcms-garnish/stories/utilities.stories.ts b/packages/craftcms-garnish/stories/utilities.stories.ts index 47966e89cef..cae29064e39 100644 --- a/packages/craftcms-garnish/stories/utilities.stories.ts +++ b/packages/craftcms-garnish/stories/utilities.stories.ts @@ -17,7 +17,7 @@ type Story = StoryObj; export const EventsAndUtilities: Story = { render: () => { - const log = createEventLog('Utilities story loaded.'); + const log = createEventLog(); const main = document.createElement('div'); main.innerHTML = `

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

      @@ -57,6 +57,6 @@ export const EventsAndUtilities: Story = { btn.addEventListener('click', () => actions[btn.dataset.act!]?.(btn)); }); - return storyLayout(main, log); + return storyLayout(main); }, }; From 16a796e8856a3bcad4ee86b57dc6f5862311eb23 Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Thu, 18 Jun 2026 16:21:44 -0500 Subject: [PATCH 11/13] WIP: vanilla-JS FieldLayoutDesigner (jQuery only at Craft seams) Convert the ported FieldLayoutDesigner to native DOM + WeakMaps, keeping thin jQuery only where it hands off to Craft's still-jQuery widgets (Craft.Grid/Listbox/SlidePicker/SortableCheckboxSelect/Slideout, the .disclosureMenu() plugin, and form .serialize()). - All of FLD's own jQuery converted to native: createElement/querySelector, classList, dataset, textContent/innerHTML/value, native tree ops, getOffset/getOuterHeight, Web Animations API for fades. - .data() replaced with module-level WeakMaps in support.ts (fld-tab/fld-element/hud/cvd + drag midpoints) and dataset reads. - Garnish Drag $items/$draggee/helpers used as native arrays (wrappers removed); $insertion/$caboose are native too. - README updated to describe the native conversion and remaining seams. Gates: FLD typecheck clean, eslint clean. Full vite build is env-blocked in this worktree (no vendor/) and not run. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/craftcms-legacy/cp/src/Craft.js | 1 - .../cp/src/js/FieldLayoutDesigner.js | 2195 ----------------- resources/js/cp.ts | 3 + .../field-layout-designer/CardViewDesigner.ts | 350 +++ .../modules/field-layout-designer/Element.ts | 633 +++++ .../FieldLayoutDesigner.ts | 565 +++++ .../modules/field-layout-designer/README.md | 109 + .../js/modules/field-layout-designer/Tab.ts | 411 +++ .../js/modules/field-layout-designer/drags.ts | 598 +++++ .../js/modules/field-layout-designer/index.ts | 33 + .../modules/field-layout-designer/support.ts | 56 + .../js/modules/field-layout-designer/types.ts | 24 + 12 files changed, 2782 insertions(+), 2196 deletions(-) delete mode 100644 packages/craftcms-legacy/cp/src/js/FieldLayoutDesigner.js create mode 100644 resources/js/modules/field-layout-designer/CardViewDesigner.ts create mode 100644 resources/js/modules/field-layout-designer/Element.ts create mode 100644 resources/js/modules/field-layout-designer/FieldLayoutDesigner.ts create mode 100644 resources/js/modules/field-layout-designer/README.md create mode 100644 resources/js/modules/field-layout-designer/Tab.ts create mode 100644 resources/js/modules/field-layout-designer/drags.ts create mode 100644 resources/js/modules/field-layout-designer/index.ts create mode 100644 resources/js/modules/field-layout-designer/support.ts create mode 100644 resources/js/modules/field-layout-designer/types.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/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 `

      + + + Third +