diff --git a/package-lock.json b/package-lock.json index 1368bc4..b7da196 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "headlessui", "version": "0.0.0", + "hasInstallScript": true, "license": "MIT", "workspaces": [ "packages/*", @@ -25,6 +26,7 @@ "jest": "26", "lint-staged": "^12.2.1", "npm-run-all": "^4.1.5", + "patch-package": "^8.0.0", "prettier": "^3.1.0", "prettier-plugin-organize-imports": "^3.2.4", "prettier-plugin-tailwindcss": "^0.6.11", @@ -2638,12 +2640,12 @@ } }, "node_modules/@tanstack/react-virtual": { - "version": "3.11.1", - "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.11.1.tgz", - "integrity": "sha512-orn2QNe5tF6SqjucHJ6cKTKcRDe3GG7bcYqPNn72Yejj7noECdzgAyRfGt2pGDPemhYim3d1HIR/dgruCnLfUA==", + "version": "3.13.6", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.6.tgz", + "integrity": "sha512-WT7nWs8ximoQ0CDx/ngoFP7HbQF9Q2wQe4nh2NB+u2486eX3nZRE40P9g6ccCVq7ZfTSH5gFOuCoVH5DLNS/aA==", "license": "MIT", "dependencies": { - "@tanstack/virtual-core": "3.10.9" + "@tanstack/virtual-core": "3.13.6" }, "funding": { "type": "github", @@ -2654,22 +2656,30 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/@tanstack/react-virtual/node_modules/@tanstack/virtual-core": { - "version": "3.10.9", - "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.10.9.tgz", - "integrity": "sha512-kBknKOKzmeR7lN+vSadaKWXaLS0SZZG+oqpQ/k80Q6g9REn6zRHS/ZYdrIzHnpHgy/eWs00SujveUN/GJT2qTw==", + "node_modules/@tanstack/virtual-core": { + "version": "3.13.6", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.6.tgz", + "integrity": "sha512-cnQUeWnhNP8tJ4WsGcYiX24Gjkc9ALstLbHcBj1t3E7EimN6n6kHH+DPV4PpDnuw00NApQp+ViojMj1GRdwYQg==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" } }, - "node_modules/@tanstack/virtual-core": { - "version": "3.0.0-beta.60", + "node_modules/@tanstack/vue-virtual": { + "version": "3.13.6", + "resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.6.tgz", + "integrity": "sha512-GYdZ3SJBQPzgxhuCE2fvpiH46qzHiVx5XzBSdtESgiqh4poj8UgckjGWYEhxaBbcVt1oLzh1m3Ql4TyH32TOzQ==", "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.6" + }, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "vue": "^2.7.0 || ^3.0.0" } }, "node_modules/@testing-library/dom": { @@ -3141,6 +3151,7 @@ }, "node_modules/@vue/compiler-core": { "version": "3.2.37", + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.16.4", @@ -3151,6 +3162,7 @@ }, "node_modules/@vue/compiler-dom": { "version": "3.2.37", + "dev": true, "license": "MIT", "dependencies": { "@vue/compiler-core": "3.2.37", @@ -3218,6 +3230,7 @@ }, "node_modules/@vue/compiler-ssr": { "version": "3.2.37", + "dev": true, "license": "MIT", "dependencies": { "@vue/compiler-dom": "3.2.37", @@ -3238,6 +3251,7 @@ }, "node_modules/@vue/reactivity-transform": { "version": "3.2.37", + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.16.4", @@ -3330,8 +3344,16 @@ }, "node_modules/@vue/shared": { "version": "3.2.37", + "dev": true, "license": "MIT" }, + "node_modules/@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/abab": { "version": "2.0.6", "dev": true, @@ -3577,6 +3599,16 @@ "dev": true, "license": "MIT" }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/atob": { "version": "2.1.2", "dev": true, @@ -3858,13 +3890,50 @@ } }, "node_modules/call-bind": { - "version": "1.0.5", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", "dev": true, "license": "MIT", "dependencies": { - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.1", - "set-function-length": "^1.1.1" + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -4405,16 +4474,21 @@ } }, "node_modules/define-data-property": { - "version": "1.1.1", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "dev": true, "license": "MIT", "dependencies": { - "get-intrinsic": "^1.2.1", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.0" + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" }, "engines": { "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/define-properties": { @@ -4529,6 +4603,21 @@ "node": ">=8" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "license": "MIT" @@ -4681,6 +4770,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-get-iterator": { "version": "1.1.3", "dev": true, @@ -4700,6 +4809,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-set-tostringtag": { "version": "2.0.2", "dev": true, @@ -5364,6 +5486,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/find-yarn-workspace-root": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", + "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "micromatch": "^4.0.2" + } + }, "node_modules/flatpickr": { "version": "4.6.13", "license": "MIT" @@ -5458,6 +5590,32 @@ "tslib": "^2.1.0" } }, + "node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fs-extra/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "dev": true, @@ -5523,14 +5681,25 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.2", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "dev": true, "license": "MIT", "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5544,6 +5713,20 @@ "node": ">=8.0.0" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-stream": { "version": "6.0.1", "dev": true, @@ -5641,11 +5824,13 @@ } }, "node_modules/gopd": { - "version": "1.0.1", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "dev": true, "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.1.3" + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5678,11 +5863,13 @@ } }, "node_modules/has-property-descriptors": { - "version": "1.0.1", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "dev": true, "license": "MIT", "dependencies": { - "get-intrinsic": "^1.2.2" + "es-define-property": "^1.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5700,7 +5887,9 @@ } }, "node_modules/has-symbols": { - "version": "1.0.3", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "dev": true, "license": "MIT", "engines": { @@ -5761,7 +5950,9 @@ } }, "node_modules/hasown": { - "version": "2.0.0", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -6126,7 +6317,6 @@ "version": "2.2.1", "dev": true, "license": "MIT", - "optional": true, "bin": { "is-docker": "cli.js" }, @@ -6372,7 +6562,6 @@ "version": "2.2.0", "dev": true, "license": "MIT", - "optional": true, "dependencies": { "is-docker": "^2.0.0" }, @@ -7289,6 +7478,26 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stable-stringify": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.2.1.tgz", + "integrity": "sha512-Lp6HbbBgosLmJbjx0pBLbgvx68FaFU1sdkmBuckmhhJ88kL13OA51CDtR2yJB50eCNMH9wRqtQNNiAqQH4YXnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "isarray": "^2.0.5", + "jsonify": "^0.0.1", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/json5": { "version": "2.2.3", "dev": true, @@ -7305,6 +7514,39 @@ "dev": true, "license": "MIT" }, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonfile/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/jsonify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", + "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", + "dev": true, + "license": "Public Domain", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/kind-of": { "version": "3.2.2", "dev": true, @@ -7316,6 +7558,16 @@ "node": ">=0.10.0" } }, + "node_modules/klaw-sync": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", + "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.11" + } + }, "node_modules/kleur": { "version": "3.0.3", "dev": true, @@ -7820,6 +8072,7 @@ }, "node_modules/magic-string": { "version": "0.25.9", + "dev": true, "license": "MIT", "dependencies": { "sourcemap-codec": "^1.4.8" @@ -7932,6 +8185,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/memorystream": { "version": "0.3.1", "dev": true, @@ -8600,6 +8863,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/opencollective-postinstall": { "version": "2.0.3", "dev": true, @@ -8608,6 +8888,16 @@ "opencollective-postinstall": "index.js" } }, + "node_modules/os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/p-each-series": { "version": "2.2.0", "dev": true, @@ -8715,6 +9005,90 @@ "node": ">=0.10.0" } }, + "node_modules/patch-package": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.0.tgz", + "integrity": "sha512-da8BVIhzjtgScwDJ2TtKsfT5JFWz1hYoBl9rUQ1f38MC2HwnEIkK8VN3dKMKcP7P7bvvgzNDbfNHtx3MsQb5vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@yarnpkg/lockfile": "^1.1.0", + "chalk": "^4.1.2", + "ci-info": "^3.7.0", + "cross-spawn": "^7.0.3", + "find-yarn-workspace-root": "^2.0.0", + "fs-extra": "^9.0.0", + "json-stable-stringify": "^1.0.2", + "klaw-sync": "^6.0.0", + "minimist": "^1.2.6", + "open": "^7.4.2", + "rimraf": "^2.6.3", + "semver": "^7.5.3", + "slash": "^2.0.0", + "tmp": "^0.0.33", + "yaml": "^2.2.2" + }, + "bin": { + "patch-package": "index.js" + }, + "engines": { + "node": ">=14", + "npm": ">5" + } + }, + "node_modules/patch-package/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/patch-package/node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/patch-package/node_modules/slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/patch-package/node_modules/yaml": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz", + "integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/path-exists": { "version": "4.0.0", "dev": true, @@ -10029,14 +10403,18 @@ "license": "ISC" }, "node_modules/set-function-length": { - "version": "1.1.1", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", "dev": true, "license": "MIT", "dependencies": { - "define-data-property": "^1.1.1", - "get-intrinsic": "^1.2.1", + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.0" + "has-property-descriptors": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -10586,6 +10964,7 @@ }, "node_modules/source-map": { "version": "0.6.1", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -10628,6 +11007,7 @@ }, "node_modules/sourcemap-codec": { "version": "1.4.8", + "dev": true, "license": "MIT" }, "node_modules/spdx-correct": { @@ -11163,6 +11543,19 @@ "dev": true, "license": "MIT" }, + "node_modules/tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "os-tmpdir": "~1.0.2" + }, + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/tmpl": { "version": "1.0.5", "dev": true, @@ -11997,7 +12390,7 @@ "@floating-ui/react": "^0.26.16", "@react-aria/focus": "^3.17.1", "@react-aria/interactions": "^3.21.3", - "@tanstack/react-virtual": "^3.11.1", + "@tanstack/react-virtual": "^3.13.6", "use-sync-external-store": "^1.5.0" }, "devDependencies": { @@ -12056,7 +12449,7 @@ "version": "1.7.22", "license": "MIT", "dependencies": { - "@tanstack/vue-virtual": "3.0.0-beta.60" + "@tanstack/vue-virtual": "3.13.6" }, "devDependencies": { "@testing-library/vue": "8.0.0", @@ -12070,20 +12463,6 @@ "vue": "^3.2.0" } }, - "packages/@headlessui-vue/node_modules/@tanstack/vue-virtual": { - "version": "3.0.0-beta.60", - "license": "MIT", - "dependencies": { - "@tanstack/virtual-core": "3.0.0-beta.60" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "vue": "^2.7.0 || ^3.0.0" - } - }, "packages/@headlessui-vue/node_modules/@testing-library/dom": { "version": "9.3.3", "dev": true, @@ -12121,6 +12500,7 @@ }, "packages/@headlessui-vue/node_modules/@vue/compiler-sfc": { "version": "3.2.37", + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.16.4", @@ -12137,6 +12517,7 @@ }, "packages/@headlessui-vue/node_modules/@vue/reactivity": { "version": "3.2.37", + "dev": true, "license": "MIT", "dependencies": { "@vue/shared": "3.2.37" @@ -12144,6 +12525,7 @@ }, "packages/@headlessui-vue/node_modules/@vue/runtime-core": { "version": "3.2.37", + "dev": true, "license": "MIT", "dependencies": { "@vue/reactivity": "3.2.37", @@ -12152,6 +12534,7 @@ }, "packages/@headlessui-vue/node_modules/@vue/runtime-dom": { "version": "3.2.37", + "dev": true, "license": "MIT", "dependencies": { "@vue/runtime-core": "3.2.37", @@ -12161,6 +12544,7 @@ }, "packages/@headlessui-vue/node_modules/@vue/server-renderer": { "version": "3.2.37", + "dev": true, "license": "MIT", "dependencies": { "@vue/compiler-ssr": "3.2.37", @@ -12201,6 +12585,7 @@ }, "packages/@headlessui-vue/node_modules/csstype": { "version": "2.6.21", + "dev": true, "license": "MIT" }, "packages/@headlessui-vue/node_modules/pretty-format": { @@ -12218,6 +12603,7 @@ }, "packages/@headlessui-vue/node_modules/vue": { "version": "3.2.37", + "dev": true, "license": "MIT", "dependencies": { "@vue/compiler-dom": "3.2.37", diff --git a/package.json b/package.json index 9d75aa7..9c87b7d 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "lint-types": "CI=true npm run lint-types --workspaces --if-present", "release-channel": "node ./scripts/release-channel.js", "release-notes": "node ./scripts/release-notes.js", - "package-path": "node ./scripts/package-path.js" + "package-path": "node ./scripts/package-path.js", + "postinstall": "patch-package" }, "husky": { "hooks": { @@ -76,6 +77,7 @@ "jest": "26", "lint-staged": "^12.2.1", "npm-run-all": "^4.1.5", + "patch-package": "^8.0.0", "prettier": "^3.1.0", "prettier-plugin-organize-imports": "^3.2.4", "prettier-plugin-tailwindcss": "^0.6.11", diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index da29474..e3d45a0 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improve `Listbox` component performance ([#3688](https://github.com/tailwindlabs/headlessui/pull/3688)) - Open `Menu` and `Listbox` on `mousedown` ([#3689](https://github.com/tailwindlabs/headlessui/pull/3689)) - Fix `Transition` component from incorrectly exposing the `Closing` state ([#3696](https://github.com/tailwindlabs/headlessui/pull/3696)) +- Improve `Combobox` component performance ([#3697](https://github.com/tailwindlabs/headlessui/pull/3697)) ## [2.2.1] - 2025-04-04 diff --git a/packages/@headlessui-react/package.json b/packages/@headlessui-react/package.json index 9e02672..98d0c69 100644 --- a/packages/@headlessui-react/package.json +++ b/packages/@headlessui-react/package.json @@ -59,7 +59,7 @@ "@floating-ui/react": "^0.26.16", "@react-aria/focus": "^3.17.1", "@react-aria/interactions": "^3.21.3", - "@tanstack/react-virtual": "^3.11.1", + "@tanstack/react-virtual": "^3.13.6", "use-sync-external-store": "^1.5.0" } } diff --git a/packages/@headlessui-react/src/components/combobox/combobox-machine-glue.tsx b/packages/@headlessui-react/src/components/combobox/combobox-machine-glue.tsx new file mode 100644 index 0000000..4121fbe --- /dev/null +++ b/packages/@headlessui-react/src/components/combobox/combobox-machine-glue.tsx @@ -0,0 +1,20 @@ +import { createContext, useContext, useMemo } from 'react' +import { ComboboxMachine } from './combobox-machine' + +export const ComboboxContext = createContext | null>(null) +export function useComboboxMachineContext(component: string) { + let context = useContext(ComboboxContext) + if (context === null) { + let err = new Error(`<${component} /> is missing a parent component.`) + if (Error.captureStackTrace) Error.captureStackTrace(err, useComboboxMachine) + throw err + } + return context as ComboboxMachine +} + +export function useComboboxMachine({ + virtual = null, + __demoMode = false, +}: Parameters[0] = {}) { + return useMemo(() => ComboboxMachine.new({ virtual, __demoMode }), []) +} diff --git a/packages/@headlessui-react/src/components/combobox/combobox-machine.ts b/packages/@headlessui-react/src/components/combobox/combobox-machine.ts new file mode 100644 index 0000000..aa6353c --- /dev/null +++ b/packages/@headlessui-react/src/components/combobox/combobox-machine.ts @@ -0,0 +1,617 @@ +import { Machine } from '../../machine' +import type { EnsureArray } from '../../types' +import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index' +import { sortByDomNode } from '../../utils/focus-management' +import { match } from '../../utils/match' + +interface MutableRefObject { + current: T +} + +export enum ComboboxState { + Open, + Closed, +} + +export enum ValueMode { + Single, + Multi, +} + +export enum ActivationTrigger { + Pointer, + Focus, + Other, +} + +export type ComboboxOptionDataRef = MutableRefObject<{ + disabled: boolean + value: T + domRef: MutableRefObject + order: number | null +}> + +export interface State { + dataRef: MutableRefObject<{ + value: unknown + defaultValue: unknown + disabled: boolean + invalid: boolean + mode: ValueMode + immediate: boolean + onChange: (value: T) => void + onClose?: () => void + compare(a: unknown, z: unknown): boolean + isSelected(value: unknown): boolean + + virtual: { options: T[]; disabled: (value: T) => boolean } | null + calculateIndex(value: unknown): number + + __demoMode: boolean + + optionsPropsRef: MutableRefObject<{ + static: boolean + hold: boolean + }> + }> + + virtual: { options: T[]; disabled: (value: unknown) => boolean } | null + + comboboxState: ComboboxState + + defaultToFirstOption: boolean + + options: { id: string; dataRef: ComboboxOptionDataRef }[] + activeOptionIndex: number | null + activationTrigger: ActivationTrigger + + isTyping: boolean + + inputElement: HTMLInputElement | null + buttonElement: HTMLButtonElement | null + optionsElement: HTMLElement | null + + __demoMode: boolean +} + +export enum ActionTypes { + OpenCombobox, + CloseCombobox, + + GoToOption, + SetTyping, + + RegisterOption, + UnregisterOption, + + DefaultToFirstOption, + + SetActivationTrigger, + + UpdateVirtualConfiguration, + + SetInputElement, + SetButtonElement, + SetOptionsElement, +} + +function adjustOrderedState( + state: State, + adjustment: (options: State['options']) => State['options'] = (i) => i +) { + let currentActiveOption = + state.activeOptionIndex !== null ? state.options[state.activeOptionIndex] : null + + let list = adjustment(state.options.slice()) + let sortedOptions = + list.length > 0 && list[0].dataRef.current.order !== null + ? // Prefer sorting based on the `order` + list.sort((a, z) => a.dataRef.current.order! - z.dataRef.current.order!) + : // Fallback to much slower DOM order + sortByDomNode(list, (option) => option.dataRef.current.domRef.current) + + // If we inserted an option before the current active option then the active option index + // would be wrong. To fix this, we will re-lookup the correct index. + let adjustedActiveOptionIndex = currentActiveOption + ? sortedOptions.indexOf(currentActiveOption) + : null + + // Reset to `null` in case the currentActiveOption was removed. + if (adjustedActiveOptionIndex === -1) { + adjustedActiveOptionIndex = null + } + + return { + options: sortedOptions, + activeOptionIndex: adjustedActiveOptionIndex, + } +} + +type Actions = + | { type: ActionTypes.CloseCombobox } + | { type: ActionTypes.OpenCombobox } + | { + type: ActionTypes.GoToOption + focus: Focus.Specific + idx: number + trigger?: ActivationTrigger + } + | { type: ActionTypes.SetTyping; isTyping: boolean } + | { + type: ActionTypes.GoToOption + focus: Exclude + trigger?: ActivationTrigger + } + | { + type: ActionTypes.RegisterOption + payload: { id: string; dataRef: ComboboxOptionDataRef } + } + | { type: ActionTypes.UnregisterOption; id: string } + | { type: ActionTypes.DefaultToFirstOption; value: boolean } + | { type: ActionTypes.SetActivationTrigger; trigger: ActivationTrigger } + | { + type: ActionTypes.UpdateVirtualConfiguration + options: T[] + disabled: ((value: any) => boolean) | null + } + | { type: ActionTypes.SetInputElement; element: HTMLInputElement | null } + | { type: ActionTypes.SetButtonElement; element: HTMLButtonElement | null } + | { type: ActionTypes.SetOptionsElement; element: HTMLElement | null } + +let reducers: { + [P in ActionTypes]: (state: State, action: Extract, { type: P }>) => State +} = { + [ActionTypes.CloseCombobox](state) { + if (state.dataRef.current?.disabled) return state + if (state.comboboxState === ComboboxState.Closed) return state + + return { + ...state, + activeOptionIndex: null, + comboboxState: ComboboxState.Closed, + + isTyping: false, + + // Clear the last known activation trigger + // This is because if a user interacts with the combobox using a mouse + // resulting in it closing we might incorrectly handle the next interaction + // for example, not scrolling to the active option in a virtual list + activationTrigger: ActivationTrigger.Other, + + __demoMode: false, + } + }, + [ActionTypes.OpenCombobox](state) { + if (state.dataRef.current?.disabled) return state + if (state.comboboxState === ComboboxState.Open) return state + + // Check if we have a selected value that we can make active + if (state.dataRef.current?.value) { + let idx = state.dataRef.current.calculateIndex(state.dataRef.current.value) + if (idx !== -1) { + return { + ...state, + activeOptionIndex: idx, + comboboxState: ComboboxState.Open, + __demoMode: false, + } + } + } + + return { ...state, comboboxState: ComboboxState.Open, __demoMode: false } + }, + [ActionTypes.SetTyping](state, action) { + if (state.isTyping === action.isTyping) return state + return { ...state, isTyping: action.isTyping } + }, + [ActionTypes.GoToOption](state, action) { + if (state.dataRef.current?.disabled) return state + if ( + state.optionsElement && + !state.dataRef.current?.optionsPropsRef.current.static && + state.comboboxState === ComboboxState.Closed + ) { + return state + } + + if (state.virtual) { + let { options, disabled } = state.virtual + let activeOptionIndex = + action.focus === Focus.Specific + ? action.idx + : calculateActiveIndex(action, { + resolveItems: () => options, + resolveActiveIndex: () => + state.activeOptionIndex ?? options.findIndex((option) => !disabled(option)) ?? null, + resolveDisabled: disabled, + resolveId() { + throw new Error('Function not implemented.') + }, + }) + + let activationTrigger = action.trigger ?? ActivationTrigger.Other + + if ( + state.activeOptionIndex === activeOptionIndex && + state.activationTrigger === activationTrigger + ) { + return state + } + + return { + ...state, + activeOptionIndex, + activationTrigger, + isTyping: false, + __demoMode: false, + } + } + + let adjustedState = adjustOrderedState(state) + + // It's possible that the activeOptionIndex is set to `null` internally, but + // this means that we will fallback to the first non-disabled option by default. + // We have to take this into account. + if (adjustedState.activeOptionIndex === null) { + let localActiveOptionIndex = adjustedState.options.findIndex( + (option) => !option.dataRef.current.disabled + ) + + if (localActiveOptionIndex !== -1) { + adjustedState.activeOptionIndex = localActiveOptionIndex + } + } + + let activeOptionIndex = + action.focus === Focus.Specific + ? action.idx + : calculateActiveIndex(action, { + resolveItems: () => adjustedState.options, + resolveActiveIndex: () => adjustedState.activeOptionIndex, + resolveId: (item) => item.id, + resolveDisabled: (item) => item.dataRef.current.disabled, + }) + let activationTrigger = action.trigger ?? ActivationTrigger.Other + + if ( + state.activeOptionIndex === activeOptionIndex && + state.activationTrigger === activationTrigger + ) { + return state + } + + return { + ...state, + ...adjustedState, + isTyping: false, + activeOptionIndex, + activationTrigger, + __demoMode: false, + } + }, + [ActionTypes.RegisterOption]: (state, action) => { + if (state.dataRef.current?.virtual) { + return { + ...state, + options: [...state.options, action.payload], + } + } + + let option = action.payload + + let adjustedState = adjustOrderedState(state, (options) => { + options.push(option) + return options + }) + + // Check if we need to make the newly registered option active. + if (state.activeOptionIndex === null) { + if (state.dataRef.current.isSelected?.(action.payload.dataRef.current.value)) { + adjustedState.activeOptionIndex = adjustedState.options.indexOf(option) + } + } + + let nextState = { + ...state, + ...adjustedState, + activationTrigger: ActivationTrigger.Other, + } + + if (state.dataRef.current?.__demoMode && state.dataRef.current.value === undefined) { + nextState.activeOptionIndex = 0 + } + + return nextState + }, + [ActionTypes.UnregisterOption]: (state, action) => { + if (state.dataRef.current?.virtual) { + return { + ...state, + options: state.options.filter((option) => option.id !== action.id), + } + } + + let adjustedState = adjustOrderedState(state, (options) => { + let idx = options.findIndex((option) => option.id === action.id) + if (idx !== -1) options.splice(idx, 1) + return options + }) + + return { + ...state, + ...adjustedState, + activationTrigger: ActivationTrigger.Other, + } + }, + [ActionTypes.DefaultToFirstOption]: (state, action) => { + if (state.defaultToFirstOption === action.value) return state + + return { + ...state, + defaultToFirstOption: action.value, + } + }, + [ActionTypes.SetActivationTrigger]: (state, action) => { + if (state.activationTrigger === action.trigger) { + return state + } + + return { + ...state, + activationTrigger: action.trigger, + } + }, + [ActionTypes.UpdateVirtualConfiguration]: (state, action) => { + if (state.virtual === null) { + return { + ...state, + virtual: { options: action.options, disabled: action.disabled ?? (() => false) }, + } + } + + if (state.virtual.options === action.options && state.virtual.disabled === action.disabled) { + return state + } + + let adjustedActiveOptionIndex = state.activeOptionIndex + if (state.activeOptionIndex !== null) { + let idx = action.options.indexOf(state.virtual.options[state.activeOptionIndex]) + if (idx !== -1) { + adjustedActiveOptionIndex = idx + } else { + adjustedActiveOptionIndex = null + } + } + + return { + ...state, + activeOptionIndex: adjustedActiveOptionIndex, + virtual: { options: action.options, disabled: action.disabled ?? (() => false) }, + } + }, + [ActionTypes.SetInputElement]: (state, action) => { + if (state.inputElement === action.element) return state + return { ...state, inputElement: action.element } + }, + [ActionTypes.SetButtonElement]: (state, action) => { + if (state.buttonElement === action.element) return state + return { ...state, buttonElement: action.element } + }, + [ActionTypes.SetOptionsElement]: (state, action) => { + if (state.optionsElement === action.element) return state + return { ...state, optionsElement: action.element } + }, +} + +export class ComboboxMachine extends Machine, Actions> { + static new({ + virtual = null, + __demoMode = false, + }: { + virtual?: { + options: TMultiple extends true ? EnsureArray> : NoInfer[] + disabled?: ( + value: TMultiple extends true ? EnsureArray>[number] : NoInfer + ) => boolean + } | null + __demoMode?: boolean + } = {}) { + return new ComboboxMachine({ + // @ts-expect-error TODO: Re-structure such that we don't need to ignore this + dataRef: { current: {} }, + comboboxState: __demoMode ? ComboboxState.Open : ComboboxState.Closed, + isTyping: false, + options: [], + // @ts-expect-error TODO: Ensure we use the correct type + virtual: virtual + ? { options: virtual.options, disabled: virtual.disabled ?? (() => false) } + : null, + activeOptionIndex: null, + activationTrigger: ActivationTrigger.Other, + inputElement: null, + buttonElement: null, + optionsElement: null, + __demoMode, + }) + } + + actions = { + onChange: (newValue: T) => { + let { onChange, compare, mode, value } = this.state.dataRef.current + + return match(mode, { + [ValueMode.Single]: () => { + return onChange?.(newValue) + }, + [ValueMode.Multi]: () => { + let copy = (value as T[]).slice() + + let idx = copy.findIndex((item) => compare(item, newValue)) + if (idx === -1) { + copy.push(newValue) + } else { + copy.splice(idx, 1) + } + + return onChange?.(copy as T) + }, + }) + }, + registerOption: (id: string, dataRef: ComboboxOptionDataRef) => { + this.send({ type: ActionTypes.RegisterOption, payload: { id, dataRef } }) + return () => { + // When we are unregistering the currently active option, then we also have to make sure to + // reset the `defaultToFirstOption` flag, so that visually something is selected and the next + // time you press a key on your keyboard it will go to the proper next or previous option in + // the list. + // + // Since this was the active option and it could have been anywhere in the list, resetting to + // the very first option seems like a fine default. We _could_ be smarter about this by going + // to the previous / next item in list if we know the direction of the keyboard navigation, + // but that might be too complex/confusing from an end users perspective. + if ( + this.state.activeOptionIndex === + this.state.dataRef.current.calculateIndex(dataRef.current.value) + ) { + this.send({ type: ActionTypes.DefaultToFirstOption, value: true }) + } + + this.send({ type: ActionTypes.UnregisterOption, id }) + } + }, + goToOption: ( + focus: { focus: Focus.Specific; idx: number } | { focus: Exclude }, + trigger?: ActivationTrigger + ) => { + this.send({ type: ActionTypes.DefaultToFirstOption, value: false }) + return this.send({ type: ActionTypes.GoToOption, ...focus, trigger }) + }, + setIsTyping: (isTyping: boolean) => { + this.send({ type: ActionTypes.SetTyping, isTyping }) + }, + closeCombobox: () => { + this.send({ type: ActionTypes.CloseCombobox }) + this.send({ type: ActionTypes.DefaultToFirstOption, value: false }) + this.state.dataRef.current.onClose?.() + }, + openCombobox: () => { + this.send({ type: ActionTypes.OpenCombobox }) + this.send({ type: ActionTypes.DefaultToFirstOption, value: true }) + }, + setActivationTrigger: (trigger: ActivationTrigger) => { + this.send({ type: ActionTypes.SetActivationTrigger, trigger }) + }, + selectActiveOption: () => { + let activeOptionIndex = this.selectors.activeOptionIndex(this.state) + if (activeOptionIndex === null) return + + this.actions.setIsTyping(false) + + if (this.state.virtual) { + this.actions.onChange(this.state.virtual.options[activeOptionIndex]) + } else { + let { dataRef } = this.state.options[activeOptionIndex] + this.actions.onChange(dataRef.current.value) + } + + // It could happen that the `activeOptionIndex` stored in state is actually null, but we are + // getting the fallback active option back instead. + this.actions.goToOption({ focus: Focus.Specific, idx: activeOptionIndex }) + }, + setInputElement: (element: HTMLInputElement | null) => { + this.send({ type: ActionTypes.SetInputElement, element }) + }, + setButtonElement: (element: HTMLButtonElement | null) => { + this.send({ type: ActionTypes.SetButtonElement, element }) + }, + setOptionsElement: (element: HTMLElement | null) => { + this.send({ type: ActionTypes.SetOptionsElement, element }) + }, + } + + selectors = { + activeDescendantId: (state: State) => { + let activeOptionIndex = this.selectors.activeOptionIndex(state) + if (activeOptionIndex === null) { + return undefined + } + + if (!state.virtual) { + return state.options[activeOptionIndex]?.id + } + + return state.options.find((option) => { + return ( + !option.dataRef.current.disabled && + state.dataRef.current.compare( + option.dataRef.current.value, + state.virtual!.options[activeOptionIndex] + ) + ) + })?.id + }, + + activeOptionIndex: (state: State) => { + if ( + state.defaultToFirstOption && + state.activeOptionIndex === null && + (state.virtual ? state.virtual.options.length > 0 : state.options.length > 0) + ) { + if (state.virtual) { + let { options, disabled } = state.virtual + let activeOptionIndex = options.findIndex((option) => !(disabled?.(option) ?? false)) + + if (activeOptionIndex !== -1) { + return activeOptionIndex + } + } + + let activeOptionIndex = state.options.findIndex((option) => { + return !option.dataRef.current.disabled + }) + + if (activeOptionIndex !== -1) { + return activeOptionIndex + } + } + + return state.activeOptionIndex + }, + + activeOption: (state: State) => { + let activeOptionIndex = this.selectors.activeOptionIndex(state) + return activeOptionIndex === null + ? null + : state.virtual + ? state.virtual.options[activeOptionIndex ?? 0] + : state.options[activeOptionIndex]?.dataRef.current.value ?? null + }, + + isActive: (state: State, value: T, id: string) => { + let activeOptionIndex = this.selectors.activeOptionIndex(state) + if (activeOptionIndex === null) return false + + if (state.virtual) { + return activeOptionIndex === state.dataRef.current.calculateIndex(value) + } + + return state.options[activeOptionIndex]?.id === id + }, + + shouldScrollIntoView: (state: State, value: T, id: string): boolean => { + if (state.virtual) return false + if (state.__demoMode) return false + if (state.comboboxState !== ComboboxState.Open) return false + if (state.activationTrigger === ActivationTrigger.Pointer) return false + + let active = this.selectors.isActive(state, value, id) + if (!active) return false + + return true + }, + } + + reduce(state: Readonly>, action: Actions): State { + return match(action.type, reducers, state, action) as State + } +} diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index fd364bc..f192364 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -6,11 +6,9 @@ import { Virtualizer, useVirtualizer } from '@tanstack/react-virtual' import React, { Fragment, createContext, - createRef, useCallback, useContext, useMemo, - useReducer, useRef, useState, type CSSProperties, @@ -57,12 +55,12 @@ import { FormFields } from '../../internal/form-fields' import { Frozen, useFrozenData } from '../../internal/frozen' import { useProvidedId } from '../../internal/id' import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed' +import { useSlice } from '../../react-glue' import type { EnsureArray, Props } from '../../types' import { history } from '../../utils/active-element-history' import { isDisabledReactIssue7711 } from '../../utils/bugs' -import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index' +import { Focus } from '../../utils/calculate-active-index' import { disposables } from '../../utils/disposables' -import { sortByDomNode } from '../../utils/focus-management' import { match } from '../../utils/match' import { isMobile } from '../../utils/platform' import { @@ -79,398 +77,52 @@ import { Keys } from '../keyboard' import { Label, useLabelledBy, useLabels, type _internal_ComponentLabel } from '../label/label' import { MouseButton } from '../mouse' import { Portal } from '../portal/portal' +import { + ActionTypes, + ActivationTrigger, + ComboboxState, + ValueMode, + type ComboboxOptionDataRef, +} from './combobox-machine' +import { + ComboboxContext, + useComboboxMachine, + useComboboxMachineContext, +} from './combobox-machine-glue' -enum ComboboxState { - Open, - Closed, -} - -enum ValueMode { - Single, - Multi, -} - -enum ActivationTrigger { - Pointer, - Focus, - Other, -} - -type ComboboxOptionDataRef = MutableRefObject<{ +let ComboboxDataContext = createContext<{ + value: unknown + defaultValue: unknown disabled: boolean - value: T - domRef: MutableRefObject - order: number | null -}> + invalid: boolean + mode: ValueMode + immediate: boolean -interface StateDefinition { - dataRef: MutableRefObject<_Data | null> - - virtual: { options: T[]; disabled: (value: unknown) => boolean } | null - - comboboxState: ComboboxState - - options: { id: string; dataRef: ComboboxOptionDataRef }[] - activeOptionIndex: number | null - activationTrigger: ActivationTrigger - - isTyping: boolean - - inputElement: HTMLInputElement | null - buttonElement: HTMLButtonElement | null - optionsElement: HTMLElement | null - - __demoMode: boolean -} - -enum ActionTypes { - OpenCombobox, - CloseCombobox, - - GoToOption, - SetTyping, - - RegisterOption, - UnregisterOption, - - SetActivationTrigger, - - UpdateVirtualConfiguration, - - SetInputElement, - SetButtonElement, - SetOptionsElement, -} - -function adjustOrderedState( - state: StateDefinition, - adjustment: (options: StateDefinition['options']) => StateDefinition['options'] = (i) => i -) { - let currentActiveOption = - state.activeOptionIndex !== null ? state.options[state.activeOptionIndex] : null - - let list = adjustment(state.options.slice()) - let sortedOptions = - list.length > 0 && list[0].dataRef.current.order !== null - ? // Prefer sorting based on the `order` - list.sort((a, z) => a.dataRef.current.order! - z.dataRef.current.order!) - : // Fallback to much slower DOM order - sortByDomNode(list, (option) => option.dataRef.current.domRef.current) - - // If we inserted an option before the current active option then the active option index - // would be wrong. To fix this, we will re-lookup the correct index. - let adjustedActiveOptionIndex = currentActiveOption - ? sortedOptions.indexOf(currentActiveOption) - : null - - // Reset to `null` in case the currentActiveOption was removed. - if (adjustedActiveOptionIndex === -1) { - adjustedActiveOptionIndex = null - } - - return { - options: sortedOptions, - activeOptionIndex: adjustedActiveOptionIndex, - } -} - -type Actions = - | { type: ActionTypes.CloseCombobox } - | { type: ActionTypes.OpenCombobox } - | { - type: ActionTypes.GoToOption - focus: Focus.Specific - idx: number - trigger?: ActivationTrigger - } - | { type: ActionTypes.SetTyping; isTyping: boolean } - | { - type: ActionTypes.GoToOption - focus: Exclude - trigger?: ActivationTrigger - } - | { - type: ActionTypes.RegisterOption - payload: { id: string; dataRef: ComboboxOptionDataRef } - } - | { type: ActionTypes.UnregisterOption; id: string } - | { type: ActionTypes.SetActivationTrigger; trigger: ActivationTrigger } - | { - type: ActionTypes.UpdateVirtualConfiguration - options: T[] - disabled: ((value: any) => boolean) | null - } - | { type: ActionTypes.SetInputElement; element: HTMLInputElement | null } - | { type: ActionTypes.SetButtonElement; element: HTMLButtonElement | null } - | { type: ActionTypes.SetOptionsElement; element: HTMLElement | null } - -let reducers: { - [P in ActionTypes]: ( - state: StateDefinition, - action: Extract, { type: P }> - ) => StateDefinition -} = { - [ActionTypes.CloseCombobox](state) { - if (state.dataRef.current?.disabled) return state - if (state.comboboxState === ComboboxState.Closed) return state - - return { - ...state, - activeOptionIndex: null, - comboboxState: ComboboxState.Closed, - - isTyping: false, - - // Clear the last known activation trigger - // This is because if a user interacts with the combobox using a mouse - // resulting in it closing we might incorrectly handle the next interaction - // for example, not scrolling to the active option in a virtual list - activationTrigger: ActivationTrigger.Other, - - __demoMode: false, - } - }, - [ActionTypes.OpenCombobox](state) { - if (state.dataRef.current?.disabled) return state - if (state.comboboxState === ComboboxState.Open) return state - - // Check if we have a selected value that we can make active - if (state.dataRef.current?.value) { - let idx = state.dataRef.current.calculateIndex(state.dataRef.current.value) - if (idx !== -1) { - return { - ...state, - activeOptionIndex: idx, - comboboxState: ComboboxState.Open, - __demoMode: false, - } - } - } - - return { ...state, comboboxState: ComboboxState.Open, __demoMode: false } - }, - [ActionTypes.SetTyping](state, action) { - if (state.isTyping === action.isTyping) return state - return { ...state, isTyping: action.isTyping } - }, - [ActionTypes.GoToOption](state, action) { - if (state.dataRef.current?.disabled) return state - if ( - state.optionsElement && - !state.dataRef.current?.optionsPropsRef.current.static && - state.comboboxState === ComboboxState.Closed - ) { - return state - } - - if (state.virtual) { - let { options, disabled } = state.virtual - let activeOptionIndex = - action.focus === Focus.Specific - ? action.idx - : calculateActiveIndex(action, { - resolveItems: () => options, - resolveActiveIndex: () => - state.activeOptionIndex ?? options.findIndex((option) => !disabled(option)) ?? null, - resolveDisabled: disabled, - resolveId() { - throw new Error('Function not implemented.') - }, - }) - - let activationTrigger = action.trigger ?? ActivationTrigger.Other - - if ( - state.activeOptionIndex === activeOptionIndex && - state.activationTrigger === activationTrigger - ) { - return state - } - - return { - ...state, - activeOptionIndex, - activationTrigger, - isTyping: false, - __demoMode: false, - } - } - - let adjustedState = adjustOrderedState(state) - - // It's possible that the activeOptionIndex is set to `null` internally, but - // this means that we will fallback to the first non-disabled option by default. - // We have to take this into account. - if (adjustedState.activeOptionIndex === null) { - let localActiveOptionIndex = adjustedState.options.findIndex( - (option) => !option.dataRef.current.disabled - ) - - if (localActiveOptionIndex !== -1) { - adjustedState.activeOptionIndex = localActiveOptionIndex - } - } - - let activeOptionIndex = - action.focus === Focus.Specific - ? action.idx - : calculateActiveIndex(action, { - resolveItems: () => adjustedState.options, - resolveActiveIndex: () => adjustedState.activeOptionIndex, - resolveId: (item) => item.id, - resolveDisabled: (item) => item.dataRef.current.disabled, - }) - let activationTrigger = action.trigger ?? ActivationTrigger.Other - - if ( - state.activeOptionIndex === activeOptionIndex && - state.activationTrigger === activationTrigger - ) { - return state - } - - return { - ...state, - ...adjustedState, - isTyping: false, - activeOptionIndex, - activationTrigger, - __demoMode: false, - } - }, - [ActionTypes.RegisterOption]: (state, action) => { - if (state.dataRef.current?.virtual) { - return { - ...state, - options: [...state.options, action.payload], - } - } - - let option = action.payload - - let adjustedState = adjustOrderedState(state, (options) => { - options.push(option) - return options - }) - - // Check if we need to make the newly registered option active. - if (state.activeOptionIndex === null) { - if (state.dataRef.current?.isSelected(action.payload.dataRef.current.value)) { - adjustedState.activeOptionIndex = adjustedState.options.indexOf(option) - } - } - - let nextState = { - ...state, - ...adjustedState, - activationTrigger: ActivationTrigger.Other, - } - - if (state.dataRef.current?.__demoMode && state.dataRef.current.value === undefined) { - nextState.activeOptionIndex = 0 - } - - return nextState - }, - [ActionTypes.UnregisterOption]: (state, action) => { - if (state.dataRef.current?.virtual) { - return { - ...state, - options: state.options.filter((option) => option.id !== action.id), - } - } - - let adjustedState = adjustOrderedState(state, (options) => { - let idx = options.findIndex((option) => option.id === action.id) - if (idx !== -1) options.splice(idx, 1) - return options - }) - - return { - ...state, - ...adjustedState, - activationTrigger: ActivationTrigger.Other, - } - }, - [ActionTypes.SetActivationTrigger]: (state, action) => { - if (state.activationTrigger === action.trigger) { - return state - } - - return { - ...state, - activationTrigger: action.trigger, - } - }, - [ActionTypes.UpdateVirtualConfiguration]: (state, action) => { - if (state.virtual === null) { - return { - ...state, - virtual: { options: action.options, disabled: action.disabled ?? (() => false) }, - } - } - - if (state.virtual.options === action.options && state.virtual.disabled === action.disabled) { - return state - } - - let adjustedActiveOptionIndex = state.activeOptionIndex - if (state.activeOptionIndex !== null) { - let idx = action.options.indexOf(state.virtual.options[state.activeOptionIndex]) - if (idx !== -1) { - adjustedActiveOptionIndex = idx - } else { - adjustedActiveOptionIndex = null - } - } - - return { - ...state, - activeOptionIndex: adjustedActiveOptionIndex, - virtual: { options: action.options, disabled: action.disabled ?? (() => false) }, - } - }, - [ActionTypes.SetInputElement]: (state, action) => { - if (state.inputElement === action.element) return state - return { ...state, inputElement: action.element } - }, - [ActionTypes.SetButtonElement]: (state, action) => { - if (state.buttonElement === action.element) return state - return { ...state, buttonElement: action.element } - }, - [ActionTypes.SetOptionsElement]: (state, action) => { - if (state.optionsElement === action.element) return state - return { ...state, optionsElement: action.element } - }, -} - -let ComboboxActionsContext = createContext<{ - openCombobox(): void - closeCombobox(): void - registerOption(id: string, dataRef: ComboboxOptionDataRef): () => void - goToOption(focus: Focus.Specific, idx: number, trigger?: ActivationTrigger): void - goToOption(focus: Focus, idx?: number, trigger?: ActivationTrigger): void - setIsTyping(isTyping: boolean): void - selectActiveOption(): void - setActivationTrigger(trigger: ActivationTrigger): void + virtual: { options: unknown[]; disabled: (value: unknown) => boolean } | null + calculateIndex(value: unknown): number + compare(a: unknown, z: unknown): boolean + isSelected(value: unknown): boolean onChange(value: unknown): void - setInputElement(element: HTMLInputElement | null): void - setButtonElement(element: HTMLButtonElement | null): void - setOptionsElement(element: HTMLElement | null): void -} | null>(null) -ComboboxActionsContext.displayName = 'ComboboxActionsContext' + __demoMode: boolean -function useActions(component: string) { - let context = useContext(ComboboxActionsContext) + optionsPropsRef: MutableRefObject<{ + static: boolean + hold: boolean + }> +} | null>(null) +ComboboxDataContext.displayName = 'ComboboxDataContext' + +function useData(component: string) { + let context = useContext(ComboboxDataContext) if (context === null) { let err = new Error(`<${component} /> is missing a parent component.`) - if (Error.captureStackTrace) Error.captureStackTrace(err, useActions) + if (Error.captureStackTrace) Error.captureStackTrace(err, useData) throw err } return context } -type _Actions = ReturnType +type _Data = ReturnType let VirtualContext = createContext | null>(null) @@ -478,12 +130,14 @@ function VirtualProvider(props: { slot: OptionsRenderPropArg children: (data: { option: unknown; open: boolean }) => React.ReactElement }) { + let machine = useComboboxMachineContext('VirtualProvider') let data = useData('VirtualProvider') - let d = useDisposables() let { options } = data.virtual! + let optionsElement = useSlice(machine, (state) => state.optionsElement) + let [paddingStart, paddingEnd] = useMemo(() => { - let el = data.optionsElement + let el = optionsElement if (!el) return [0, 0] let styles = window.getComputedStyle(el) @@ -492,7 +146,7 @@ function VirtualProvider(props: { parseFloat(styles.paddingBlockStart || styles.paddingTop), parseFloat(styles.paddingBlockEnd || styles.paddingBottom), ] - }, [data.optionsElement]) + }, [optionsElement]) let virtualizer = useVirtualizer({ enabled: options.length !== 0, @@ -503,7 +157,7 @@ function VirtualProvider(props: { return 40 }, getScrollElement() { - return data.optionsElement + return machine.state.optionsElement }, overscan: 12, }) @@ -515,6 +169,11 @@ function VirtualProvider(props: { let items = virtualizer.getVirtualItems() + let isPointerActivationTrigger = useSlice(machine, (state) => { + return state.activationTrigger === ActivationTrigger.Pointer + }) + let activeOptionIndex = useSlice(machine, machine.selectors.activeOptionIndex) + if (items.length === 0) { return null } @@ -528,24 +187,15 @@ function VirtualProvider(props: { height: `${virtualizer.getTotalSize()}px`, }} ref={(el) => { - if (!el) { - d.dispose() - return - } + if (!el) return // Do not scroll when the mouse/pointer is being used - if (data.activationTrigger === ActivationTrigger.Pointer) { - return - } + if (isPointerActivationTrigger) return // Scroll to the active index - // - // Workaround for: https://github.com/TanStack/virtual/issues/879 - d.nextFrame(() => { - if (data.activeOptionIndex !== null && options.length > data.activeOptionIndex) { - virtualizer.scrollToIndex(data.activeOptionIndex) - } - }) + if (activeOptionIndex !== null && options.length > activeOptionIndex) { + virtualizer.scrollToIndex(activeOptionIndex) + } }} > {items.map((item) => { @@ -578,48 +228,6 @@ function VirtualProvider(props: { ) } -let ComboboxDataContext = createContext< - | ({ - value: unknown - defaultValue: unknown - disabled: boolean - invalid: boolean - mode: ValueMode - activeOptionIndex: number | null - immediate: boolean - - virtual: { options: unknown[]; disabled: (value: unknown) => boolean } | null - calculateIndex(value: unknown): number - compare(a: unknown, z: unknown): boolean - isSelected(value: unknown): boolean - isActive(value: unknown): boolean - - __demoMode: boolean - - optionsPropsRef: MutableRefObject<{ - static: boolean - hold: boolean - }> - } & Omit, 'dataRef'>) - | null ->(null) -ComboboxDataContext.displayName = 'ComboboxDataContext' - -function useData(component: string) { - let context = useContext(ComboboxDataContext) - if (context === null) { - let err = new Error(`<${component} /> is missing a parent component.`) - if (Error.captureStackTrace) Error.captureStackTrace(err, useData) - throw err - } - return context -} -type _Data = ReturnType - -function stateReducer(state: StateDefinition, action: Actions) { - return match(action.type, reducers, state, action) -} - // --- let DEFAULT_COMBOBOX_TAG = Fragment @@ -687,7 +295,7 @@ function ComboboxFn false) } - : null, - activeOptionIndex: null, - activationTrigger: ActivationTrigger.Other, - inputElement: null, - buttonElement: null, - optionsElement: null, - __demoMode, - } as StateDefinition) - - let defaultToFirstOption = useRef(false) + let machine = useComboboxMachine({ virtual, __demoMode }) let optionsPropsRef = useRef<_Data['optionsPropsRef']['current']>({ static: false, hold: false }) @@ -735,27 +327,27 @@ function ComboboxFn compare(other, value)) } } else { - return state.options.findIndex((other) => compare(other.dataRef.current.value, value)) + return machine.state.options.findIndex((other) => compare(other.dataRef.current.value, value)) } }) let isSelected: (value: TValue) => boolean = useCallback( - (other) => - match(data.mode, { - [ValueMode.Multi]: () => - (value as EnsureArray).some((option) => compare(option, other)), + (other) => { + return match(data.mode, { + [ValueMode.Multi]: () => { + return (value as EnsureArray).some((option) => compare(option, other)) + }, [ValueMode.Single]: () => compare(value as TValue, other), - }), + }) + }, [value] ) - let isActive = useEvent((other: TValue) => { - return state.activeOptionIndex === calculateIndex(other) - }) - + let virtualSlice = useSlice(machine, (state) => state.virtual) + let onClose = useEvent(() => theirOnClose?.()) let data = useMemo<_Data>( () => ({ - ...state, + __demoMode, immediate, optionsPropsRef, value, @@ -763,45 +355,32 @@ function ComboboxFn 0 : state.options.length > 0) - ) { - if (virtual) { - let localActiveOptionIndex = virtual.options.findIndex( - (option) => !(virtual.disabled?.(option) ?? false) - ) - - if (localActiveOptionIndex !== -1) { - return localActiveOptionIndex - } - } - - let localActiveOptionIndex = state.options.findIndex((option) => { - return !option.dataRef.current.disabled - }) - - if (localActiveOptionIndex !== -1) { - return localActiveOptionIndex - } - } - - return state.activeOptionIndex - }, + virtual: virtual ? virtualSlice : null, + onChange: theirOnChange, + isSelected, calculateIndex, compare, - isSelected, - isActive, + onClose, }), - [value, defaultValue, disabled, invalid, multiple, __demoMode, state, virtual] + [ + value, + defaultValue, + disabled, + invalid, + multiple, + theirOnChange, + isSelected, + __demoMode, + machine, + virtual, + virtualSlice, + onClose, + ] ) useIsoMorphicEffect(() => { if (!virtual) return - dispatch({ + machine.send({ type: ActionTypes.UpdateVirtualConfiguration, options: virtual.options, disabled: virtual.disabled ?? null, @@ -809,147 +388,35 @@ function ComboboxFn { - state.dataRef.current = data + machine.state.dataRef.current = data }, [data]) + let [comboboxState, buttonElement, inputElement, optionsElement] = useSlice(machine, (state) => [ + state.comboboxState, + state.buttonElement, + state.inputElement, + state.optionsElement, + ]) + // Handle outside click - let outsideClickEnabled = data.comboboxState === ComboboxState.Open - useOutsideClick( - outsideClickEnabled, - [data.buttonElement, data.inputElement, data.optionsElement], - () => actions.closeCombobox() + let outsideClickEnabled = comboboxState === ComboboxState.Open + useOutsideClick(outsideClickEnabled, [buttonElement, inputElement, optionsElement], () => + machine.actions.closeCombobox() ) + let activeOptionIndex = useSlice(machine, machine.selectors.activeOptionIndex) + let activeOption = useSlice(machine, machine.selectors.activeOption) + let slot = useMemo(() => { return { - open: data.comboboxState === ComboboxState.Open, + open: comboboxState === ComboboxState.Open, disabled, invalid, - activeIndex: data.activeOptionIndex, - activeOption: - data.activeOptionIndex === null - ? null - : data.virtual - ? data.virtual.options[data.activeOptionIndex ?? 0] - : (data.options[data.activeOptionIndex]?.dataRef.current.value as TValue) ?? null, + activeIndex: activeOptionIndex, + activeOption, value, } satisfies ComboboxRenderPropArg - }, [data, disabled, value, invalid]) - - let selectActiveOption = useEvent(() => { - if (data.activeOptionIndex === null) return - - actions.setIsTyping(false) - - if (data.virtual) { - onChange(data.virtual.options[data.activeOptionIndex]) - } else { - let { dataRef } = data.options[data.activeOptionIndex] - onChange(dataRef.current.value) - } - - // It could happen that the `activeOptionIndex` stored in state is actually null, but we are - // getting the fallback active option back instead. - actions.goToOption(Focus.Specific, data.activeOptionIndex) - }) - - let openCombobox = useEvent(() => { - dispatch({ type: ActionTypes.OpenCombobox }) - defaultToFirstOption.current = true - }) - - let closeCombobox = useEvent(() => { - dispatch({ type: ActionTypes.CloseCombobox }) - defaultToFirstOption.current = false - onClose?.() - }) - - let setIsTyping = useEvent((isTyping: boolean) => { - dispatch({ type: ActionTypes.SetTyping, isTyping }) - }) - - let goToOption = useEvent((focus, idx, trigger) => { - defaultToFirstOption.current = false - - if (focus === Focus.Specific) { - return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, idx: idx!, trigger }) - } - - return dispatch({ type: ActionTypes.GoToOption, focus, trigger }) - }) - - let registerOption = useEvent((id, dataRef) => { - dispatch({ type: ActionTypes.RegisterOption, payload: { id, dataRef } }) - return () => { - // When we are unregistering the currently active option, then we also have to make sure to - // reset the `defaultToFirstOption` flag, so that visually something is selected and the next - // time you press a key on your keyboard it will go to the proper next or previous option in - // the list. - // - // Since this was the active option and it could have been anywhere in the list, resetting to - // the very first option seems like a fine default. We _could_ be smarter about this by going - // to the previous / next item in list if we know the direction of the keyboard navigation, - // but that might be too complex/confusing from an end users perspective. - if (data.isActive(dataRef.current.value)) { - defaultToFirstOption.current = true - } - - dispatch({ type: ActionTypes.UnregisterOption, id }) - } - }) - - let onChange = useEvent((value: unknown) => { - return match(data.mode, { - [ValueMode.Single]() { - return theirOnChange?.(value as TValue) - }, - [ValueMode.Multi]() { - let copy = (data.value as TValue[]).slice() - - let idx = copy.findIndex((item) => compare(item, value as TValue)) - if (idx === -1) { - copy.push(value as TValue) - } else { - copy.splice(idx, 1) - } - - return theirOnChange?.(copy as TValue[]) - }, - }) - }) - - let setActivationTrigger = useEvent((trigger: ActivationTrigger) => { - dispatch({ type: ActionTypes.SetActivationTrigger, trigger }) - }) - - let setInputElement = useEvent((element: HTMLInputElement | null) => { - dispatch({ type: ActionTypes.SetInputElement, element }) - }) - - let setButtonElement = useEvent((element: HTMLButtonElement | null) => { - dispatch({ type: ActionTypes.SetButtonElement, element }) - }) - - let setOptionsElement = useEvent((element: HTMLElement | null) => { - dispatch({ type: ActionTypes.SetOptionsElement, element }) - }) - - let actions = useMemo<_Actions>( - () => ({ - onChange, - registerOption, - goToOption, - setIsTyping, - closeCombobox, - openCombobox, - setActivationTrigger, - selectActiveOption, - setInputElement, - setButtonElement, - setOptionsElement, - }), - [] - ) + }, [data, disabled, value, invalid, activeOption, comboboxState]) let [labelledby, LabelProvider] = useLabels() @@ -966,18 +433,18 @@ function ComboboxFn - - + + - - + + ) @@ -1047,8 +514,8 @@ function InputFn< // But today is not that day.. TType = Parameters[0]['value'], >(props: ComboboxInputProps, ref: Ref) { + let machine = useComboboxMachineContext('Combobox.Input') let data = useData('Combobox.Input') - let actions = useActions('Combobox.Input') let internalId = useId() let providedId = useProvidedId() @@ -1063,18 +530,30 @@ function InputFn< ...theirProps } = props + let [inputElement] = useSlice(machine, (state) => [state.inputElement]) + let internalInputRef = useRef(null) - let inputRef = useSyncRefs(internalInputRef, ref, useFloatingReference(), actions.setInputElement) - let ownerDocument = useOwnerDocument(data.inputElement) + let inputRef = useSyncRefs( + internalInputRef, + ref, + useFloatingReference(), + machine.actions.setInputElement + ) + let ownerDocument = useOwnerDocument(inputElement) + + let [comboboxState, isTyping] = useSlice(machine, (state) => [ + state.comboboxState, + state.isTyping, + ]) let d = useDisposables() let clear = useEvent(() => { - actions.onChange(null) - if (data.optionsElement) { - data.optionsElement.scrollTop = 0 + machine.actions.onChange(null) + if (machine.state.optionsElement) { + machine.state.optionsElement.scrollTop = 0 } - actions.goToOption(Focus.Nothing) + machine.actions.goToOption({ focus: Focus.Nothing }) }) // When a `displayValue` prop is given, we should use it to transform the current selected @@ -1114,7 +593,7 @@ function InputFn< ([currentDisplayValue, state], [oldCurrentDisplayValue, oldState]) => { // When the user is typing, we want to not touch the `input` at all. Especially when they are // using an IME, we don't want to mess with the input at all. - if (data.isTyping) return + if (machine.state.isTyping) return let input = internalInputRef.current if (!input) return @@ -1130,7 +609,7 @@ function InputFn< // the user is currently typing, because we don't want to mess with the cursor position while // typing. requestAnimationFrame(() => { - if (data.isTyping) return + if (machine.state.isTyping) return if (!input) return // Bail when the input is not the currently focused element. When it is not the focused @@ -1150,7 +629,7 @@ function InputFn< input.setSelectionRange(input.value.length, input.value.length) }) }, - [currentDisplayValue, data.comboboxState, ownerDocument, data.isTyping] + [currentDisplayValue, comboboxState, ownerDocument, isTyping] ) // Trick VoiceOver in behaving a little bit better. Manually "resetting" the input makes VoiceOver @@ -1164,7 +643,7 @@ function InputFn< if (newState === ComboboxState.Open && oldState === ComboboxState.Closed) { // When the user is typing, we want to not touch the `input` at all. Especially when they are // using an IME, we don't want to mess with the input at all. - if (data.isTyping) return + if (machine.state.isTyping) return let input = internalInputRef.current if (!input) return @@ -1185,7 +664,7 @@ function InputFn< } } }, - [data.comboboxState] + [comboboxState] ) let isComposing = useRef(false) @@ -1199,13 +678,13 @@ function InputFn< }) let handleKeyDown = useEvent((event: ReactKeyboardEvent) => { - actions.setIsTyping(true) + machine.actions.setIsTyping(true) switch (event.key) { // Ref: https://www.w3.org/WAI/ARIA/apg/patterns/menu/#keyboard-interaction-12 case Keys.Enter: - if (data.comboboxState !== ComboboxState.Open) return + if (machine.state.comboboxState !== ComboboxState.Open) return // When the user is still in the middle of composing by using an IME, then we don't want to // submit this value and close the Combobox yet. Instead, we will fallback to the default @@ -1215,14 +694,14 @@ function InputFn< event.preventDefault() event.stopPropagation() - if (data.activeOptionIndex === null) { - actions.closeCombobox() + if (machine.selectors.activeOptionIndex(machine.state) === null) { + machine.actions.closeCombobox() return } - actions.selectActiveOption() + machine.actions.selectActiveOption() if (data.mode === ValueMode.Single) { - actions.closeCombobox() + machine.actions.closeCombobox() } break @@ -1230,19 +709,19 @@ function InputFn< event.preventDefault() event.stopPropagation() - return match(data.comboboxState, { - [ComboboxState.Open]: () => actions.goToOption(Focus.Next), - [ComboboxState.Closed]: () => actions.openCombobox(), + return match(machine.state.comboboxState, { + [ComboboxState.Open]: () => machine.actions.goToOption({ focus: Focus.Next }), + [ComboboxState.Closed]: () => machine.actions.openCombobox(), }) case Keys.ArrowUp: event.preventDefault() event.stopPropagation() - return match(data.comboboxState, { - [ComboboxState.Open]: () => actions.goToOption(Focus.Previous), + return match(machine.state.comboboxState, { + [ComboboxState.Open]: () => machine.actions.goToOption({ focus: Focus.Previous }), [ComboboxState.Closed]: () => { - flushSync(() => actions.openCombobox()) - if (!data.value) actions.goToOption(Focus.Last) + flushSync(() => machine.actions.openCombobox()) + if (!data.value) machine.actions.goToOption({ focus: Focus.Last }) }, }) @@ -1253,12 +732,12 @@ function InputFn< event.preventDefault() event.stopPropagation() - return actions.goToOption(Focus.First) + return machine.actions.goToOption({ focus: Focus.First }) case Keys.PageUp: event.preventDefault() event.stopPropagation() - return actions.goToOption(Focus.First) + return machine.actions.goToOption({ focus: Focus.First }) case Keys.End: if (event.shiftKey) { @@ -1267,17 +746,17 @@ function InputFn< event.preventDefault() event.stopPropagation() - return actions.goToOption(Focus.Last) + return machine.actions.goToOption({ focus: Focus.Last }) case Keys.PageDown: event.preventDefault() event.stopPropagation() - return actions.goToOption(Focus.Last) + return machine.actions.goToOption({ focus: Focus.Last }) case Keys.Escape: - if (data.comboboxState !== ComboboxState.Open) return + if (machine.state.comboboxState !== ComboboxState.Open) return event.preventDefault() - if (data.optionsElement && !data.optionsPropsRef.current.static) { + if (machine.state.optionsElement && !data.optionsPropsRef.current.static) { event.stopPropagation() } @@ -1292,14 +771,17 @@ function InputFn< } } - return actions.closeCombobox() + return machine.actions.closeCombobox() case Keys.Tab: - if (data.comboboxState !== ComboboxState.Open) return - if (data.mode === ValueMode.Single && data.activationTrigger !== ActivationTrigger.Focus) { - actions.selectActiveOption() + if (machine.state.comboboxState !== ComboboxState.Open) return + if ( + data.mode === ValueMode.Single && + machine.state.activationTrigger !== ActivationTrigger.Focus + ) { + machine.actions.selectActiveOption() } - actions.closeCombobox() + machine.actions.closeCombobox() break } }) @@ -1323,7 +805,7 @@ function InputFn< } // Open the combobox to show the results based on what the user has typed - actions.openCombobox() + machine.actions.openCombobox() }) let handleBlur = useEvent((event: ReactFocusEvent) => { @@ -1331,17 +813,17 @@ function InputFn< (event.relatedTarget as HTMLElement) ?? history.find((x) => x !== event.currentTarget) // Focus is moved into the list, we don't want to close yet. - if (data.optionsElement?.contains(relatedTarget)) return + if (machine.state.optionsElement?.contains(relatedTarget)) return // Focus is moved to the button, we don't want to close yet. - if (data.buttonElement?.contains(relatedTarget)) return + if (machine.state.buttonElement?.contains(relatedTarget)) return // Focus is moved, but the combobox is not open. This can mean two things: // // 1. The combobox was never opened, so we don't have to do anything. // 2. The combobox was closed and focus was moved already. At that point we // don't need to try and select the active option. - if (data.comboboxState !== ComboboxState.Open) return + if (machine.state.comboboxState !== ComboboxState.Open) return event.preventDefault() @@ -1355,18 +837,18 @@ function InputFn< clear() } - return actions.closeCombobox() + return machine.actions.closeCombobox() }) let handleFocus = useEvent((event: ReactFocusEvent) => { let relatedTarget = (event.relatedTarget as HTMLElement) ?? history.find((x) => x !== event.currentTarget) - if (data.buttonElement?.contains(relatedTarget)) return - if (data.optionsElement?.contains(relatedTarget)) return + if (machine.state.buttonElement?.contains(relatedTarget)) return + if (machine.state.optionsElement?.contains(relatedTarget)) return if (data.disabled) return if (!data.immediate) return - if (data.comboboxState === ComboboxState.Open) return + if (machine.state.comboboxState === ComboboxState.Open) return // In a scenario where you have this setup: // @@ -1391,13 +873,13 @@ function InputFn< // Which is why we wrap this in a `microTask` to make sure we are not in the // middle of rendering. d.microTask(() => { - flushSync(() => actions.openCombobox()) + flushSync(() => machine.actions.openCombobox()) // We need to make sure that tabbing through a form doesn't result in // incorrectly setting the value of the combobox. We will set the // activation trigger to `Focus`, and we will ignore selecting the active // option when the user tabs away. - actions.setActivationTrigger(ActivationTrigger.Focus) + machine.actions.setActivationTrigger(ActivationTrigger.Focus) }) }) @@ -1407,9 +889,11 @@ function InputFn< let { isFocused: focus, focusProps } = useFocusRing({ autoFocus }) let { isHovered: hover, hoverProps } = useHover({ isDisabled: disabled }) + let optionsElement = useSlice(machine, (state) => state.optionsElement) + let slot = useMemo(() => { return { - open: data.comboboxState === ComboboxState.Open, + open: comboboxState === ComboboxState.Open, disabled, invalid: data.invalid, hover, @@ -1424,21 +908,9 @@ function InputFn< id, role: 'combobox', type, - 'aria-controls': data.optionsElement?.id, - 'aria-expanded': data.comboboxState === ComboboxState.Open, - 'aria-activedescendant': - data.activeOptionIndex === null - ? undefined - : data.virtual - ? data.options.find( - (option) => - !option.dataRef.current.disabled && - data.compare( - option.dataRef.current.value, - data.virtual!.options[data.activeOptionIndex!] - ) - )?.id - : data.options[data.activeOptionIndex]?.id, + 'aria-controls': optionsElement?.id, + 'aria-expanded': comboboxState === ComboboxState.Open, + 'aria-activedescendant': useSlice(machine, machine.selectors.activeDescendantId), 'aria-labelledby': labelledBy, 'aria-describedby': describedBy, 'aria-autocomplete': 'list', @@ -1504,9 +976,10 @@ function ButtonFn( props: ComboboxButtonProps, ref: Ref ) { + let machine = useComboboxMachineContext('Combobox.Button') let data = useData('Combobox.Button') - let actions = useActions('Combobox.Button') - let buttonRef = useSyncRefs(ref, actions.setButtonElement) + let [localButtonElement, setLocalButtonElement] = useState(null) + let buttonRef = useSyncRefs(ref, setLocalButtonElement, machine.actions.setButtonElement) let internalId = useId() let { @@ -1516,7 +989,8 @@ function ButtonFn( ...theirProps } = props - let refocusInput = useRefocusableInput(data.inputElement) + let inputElement = useSlice(machine, (state) => state.inputElement) + let refocusInput = useRefocusableInput(inputElement) let handleKeyDown = useEvent((event: ReactKeyboardEvent) => { switch (event.key) { @@ -1526,8 +1000,8 @@ function ButtonFn( case Keys.Enter: event.preventDefault() event.stopPropagation() - if (data.comboboxState === ComboboxState.Closed) { - flushSync(() => actions.openCombobox()) + if (machine.state.comboboxState === ComboboxState.Closed) { + flushSync(() => machine.actions.openCombobox()) } refocusInput() return @@ -1535,9 +1009,10 @@ function ButtonFn( case Keys.ArrowDown: event.preventDefault() event.stopPropagation() - if (data.comboboxState === ComboboxState.Closed) { - flushSync(() => actions.openCombobox()) - if (!data.value) actions.goToOption(Focus.First) + if (machine.state.comboboxState === ComboboxState.Closed) { + flushSync(() => machine.actions.openCombobox()) + if (!machine.state.dataRef.current.value) + machine.actions.goToOption({ focus: Focus.First }) } refocusInput() return @@ -1545,20 +1020,22 @@ function ButtonFn( case Keys.ArrowUp: event.preventDefault() event.stopPropagation() - if (data.comboboxState === ComboboxState.Closed) { - flushSync(() => actions.openCombobox()) - if (!data.value) actions.goToOption(Focus.Last) + if (machine.state.comboboxState === ComboboxState.Closed) { + flushSync(() => machine.actions.openCombobox()) + if (!machine.state.dataRef.current.value) { + machine.actions.goToOption({ focus: Focus.Last }) + } } refocusInput() return case Keys.Escape: - if (data.comboboxState !== ComboboxState.Open) return + if (machine.state.comboboxState !== ComboboxState.Open) return event.preventDefault() - if (data.optionsElement && !data.optionsPropsRef.current.static) { + if (machine.state.optionsElement && !data.optionsPropsRef.current.static) { event.stopPropagation() } - flushSync(() => actions.closeCombobox()) + flushSync(() => machine.actions.closeCombobox()) refocusInput() return @@ -1580,10 +1057,10 @@ function ButtonFn( // to preserve the focus of the `ComboboxInput`, we need to also check // that the `left` mouse button was clicked. if (event.button === MouseButton.Left) { - if (data.comboboxState === ComboboxState.Open) { - actions.closeCombobox() + if (machine.state.comboboxState === ComboboxState.Open) { + machine.actions.closeCombobox() } else { - actions.openCombobox() + machine.actions.openCombobox() } } @@ -1597,26 +1074,31 @@ function ButtonFn( let { isHovered: hover, hoverProps } = useHover({ isDisabled: disabled }) let { pressed: active, pressProps } = useActivePress({ disabled }) + let [comboboxState, optionsElement] = useSlice(machine, (state) => [ + state.comboboxState, + state.optionsElement, + ]) + let slot = useMemo(() => { return { - open: data.comboboxState === ComboboxState.Open, - active: active || data.comboboxState === ComboboxState.Open, + open: comboboxState === ComboboxState.Open, + active: active || comboboxState === ComboboxState.Open, disabled, invalid: data.invalid, value: data.value, hover, focus, } satisfies ButtonRenderPropArg - }, [data, hover, focus, active, disabled]) + }, [data, hover, focus, active, disabled, comboboxState]) let ourProps = mergeProps( { ref: buttonRef, id, - type: useResolveButtonType(props, data.buttonElement), + type: useResolveButtonType(props, localButtonElement), tabIndex: -1, 'aria-haspopup': 'listbox', - 'aria-controls': data.optionsElement?.id, - 'aria-expanded': data.comboboxState === ComboboxState.Open, + 'aria-controls': optionsElement?.id, + 'aria-expanded': comboboxState === ComboboxState.Open, 'aria-labelledby': labelledBy, disabled: disabled || undefined, autoFocus, @@ -1677,8 +1159,8 @@ function OptionsFn( transition = false, ...theirProps } = props + let machine = useComboboxMachineContext('Combobox.Options') let data = useData('Combobox.Options') - let actions = useActions('Combobox.Options') let anchor = useResolvedAnchor(rawAnchor) // Always enable `portal` functionality, when `anchor` is enabled @@ -1698,11 +1180,21 @@ function OptionsFn( let optionsRef = useSyncRefs( ref, anchor ? floatingRef : null, - actions.setOptionsElement, + machine.actions.setOptionsElement, setLocalOptionsElement ) - let portalOwnerDocument = useOwnerDocument(data.buttonElement || data.inputElement) - let ownerDocument = useOwnerDocument(data.optionsElement) + let [comboboxState, inputElement, buttonElement, optionsElement, activationTrigger] = useSlice( + machine, + (state) => [ + state.comboboxState, + state.inputElement, + state.buttonElement, + state.optionsElement, + state.activationTrigger, + ] + ) + let portalOwnerDocument = useOwnerDocument(inputElement || buttonElement) + let ownerDocument = useOwnerDocument(optionsElement) let usesOpenClosedState = useOpenClosed() let [visible, transitionData] = useTransition( @@ -1710,26 +1202,22 @@ function OptionsFn( localOptionsElement, usesOpenClosedState !== null ? (usesOpenClosedState & State.Open) === State.Open - : data.comboboxState === ComboboxState.Open + : comboboxState === ComboboxState.Open ) // Ensure we close the combobox as soon as the input becomes hidden - useOnDisappear(visible, data.inputElement, actions.closeCombobox) + useOnDisappear(visible, inputElement, machine.actions.closeCombobox) // Enable scroll locking when the combobox is visible, and `modal` is enabled - let scrollLockEnabled = data.__demoMode - ? false - : modal && data.comboboxState === ComboboxState.Open + let scrollLockEnabled = data.__demoMode ? false : modal && comboboxState === ComboboxState.Open useScrollLock(scrollLockEnabled, ownerDocument) // Mark other elements as inert when the combobox is visible, and `modal` is enabled - let inertOthersEnabled = data.__demoMode - ? false - : modal && data.comboboxState === ComboboxState.Open + let inertOthersEnabled = data.__demoMode ? false : modal && comboboxState === ComboboxState.Open useInertOthers(inertOthersEnabled, { allowed: useCallback( - () => [data.inputElement, data.buttonElement, data.optionsElement], - [data.inputElement, data.buttonElement, data.optionsElement] + () => [inputElement, buttonElement, optionsElement], + [inputElement, buttonElement, optionsElement] ), }) @@ -1740,8 +1228,8 @@ function OptionsFn( data.optionsPropsRef.current.hold = hold }, [data.optionsPropsRef, hold]) - useTreeWalker(data.comboboxState === ComboboxState.Open, { - container: data.optionsElement, + useTreeWalker(comboboxState === ComboboxState.Open, { + container: optionsElement, accept(node) { if (node.getAttribute('role') === 'option') return NodeFilter.FILTER_REJECT if (node.hasAttribute('role')) return NodeFilter.FILTER_SKIP @@ -1752,19 +1240,19 @@ function OptionsFn( }, }) - let labelledBy = useLabelledBy([data.buttonElement?.id]) + let labelledBy = useLabelledBy([buttonElement?.id]) let slot = useMemo(() => { return { - open: data.comboboxState === ComboboxState.Open, + open: comboboxState === ComboboxState.Open, option: undefined, } satisfies OptionsRenderPropArg - }, [data.comboboxState]) + }, [comboboxState]) // When the user scrolls **using the mouse** (so scroll event isn't appropriate) // we want to make sure that the current activation trigger is set to pointer. let handleWheel = useEvent(() => { - actions.setActivationTrigger(ActivationTrigger.Pointer) + machine.actions.setActivationTrigger(ActivationTrigger.Pointer) }) let handleMouseDown = useEvent((event: ReactMouseEvent) => { @@ -1781,7 +1269,7 @@ function OptionsFn( // When the user clicks in the ``, we want to make sure that we // set the activation trigger to `pointer` to prevent auto scrolling to the // active option while the user is scrolling. - actions.setActivationTrigger(ActivationTrigger.Pointer) + machine.actions.setActivationTrigger(ActivationTrigger.Pointer) }) let ourProps = mergeProps(anchor ? getFloatingPanelProps() : {}, { @@ -1793,10 +1281,10 @@ function OptionsFn( style: { ...theirProps.style, ...style, - '--input-width': useElementSize(data.inputElement, true).width, - '--button-width': useElementSize(data.buttonElement, true).width, + '--input-width': useElementSize(inputElement, true).width, + '--button-width': useElementSize(buttonElement, true).width, } as CSSProperties, - onWheel: data.activationTrigger === ActivationTrigger.Pointer ? undefined : handleWheel, + onWheel: activationTrigger === ActivationTrigger.Pointer ? undefined : handleWheel, onMouseDown: handleMouseDown, ...transitionDataAttributes(transitionData), }) @@ -1804,7 +1292,7 @@ function OptionsFn( // We should freeze when the combobox is visible but "closed". This means that // a transition is currently happening and the component is still visible (for // the transition) but closed from a functionality perspective. - let shouldFreeze = visible && data.comboboxState === ComboboxState.Closed + let shouldFreeze = visible && comboboxState === ComboboxState.Closed let options = useFrozenData(shouldFreeze, data.virtual?.options) @@ -1814,18 +1302,19 @@ function OptionsFn( let isSelected = useEvent((compareValue) => data.compare(frozenValue, compareValue)) // Map the children in a scrollable container when virtualization is enabled - if (data.virtual) { + let newDataContextValue = useMemo(() => { + if (!data.virtual) return data if (options === undefined) throw new Error('Missing `options` in virtual mode') + return options !== data.virtual.options + ? { ...data, virtual: { ...data.virtual, options } } + : data + }, [data, options, data.virtual?.options]) + + if (data.virtual) { Object.assign(theirProps, { children: ( - + {/* @ts-expect-error The `children` prop now is a callback function that receives `{option}` */} {theirProps.children} @@ -1835,11 +1324,13 @@ function OptionsFn( let render = useRender() + let newData = useMemo(() => { + return data.mode === ValueMode.Multi ? data : { ...data, isSelected } + }, [data, isSelected]) + return ( - + {render({ ourProps, theirProps: { @@ -1896,7 +1387,7 @@ function OptionFn< TType = Parameters[0]['value'], >(props: ComboboxOptionProps, ref: Ref) { let data = useData('Combobox.Option') - let actions = useActions('Combobox.Option') + let machine = useComboboxMachineContext('Combobox.Option') let internalId = useId() let { @@ -1907,14 +1398,14 @@ function OptionFn< ...theirProps } = props - let refocusInput = useRefocusableInput(data.inputElement) + let [inputElement] = useSlice(machine, (state) => [state.inputElement]) - let active = data.virtual - ? data.activeOptionIndex === data.calculateIndex(value) - : data.activeOptionIndex === null - ? false - : data.options[data.activeOptionIndex]?.id === id + let refocusInput = useRefocusableInput(inputElement) + let active = useSlice( + machine, + useCallback((state) => machine.selectors.isActive(state, value, id), [value, id]) + ) let selected = data.isSelected(value) let internalOptionRef = useRef(null) @@ -1933,35 +1424,22 @@ function OptionFn< ) let select = useEvent(() => { - actions.setIsTyping(false) - actions.onChange(value) + machine.actions.setIsTyping(false) + machine.actions.onChange(value) }) - useIsoMorphicEffect(() => actions.registerOption(id, bag), [bag, id]) + useIsoMorphicEffect(() => machine.actions.registerOption(id, bag), [bag, id]) - let enableScrollIntoView = useRef(data.virtual || data.__demoMode ? false : true) - useIsoMorphicEffect(() => { - if (data.virtual) return - if (data.__demoMode) return - return disposables().requestAnimationFrame(() => { - enableScrollIntoView.current = true - }) - }, [data.virtual, data.__demoMode]) + let shouldScrollIntoView = useSlice( + machine, + useCallback((state) => machine.selectors.shouldScrollIntoView(state, value, id), [value, id]) + ) useIsoMorphicEffect(() => { - if (!enableScrollIntoView.current) return - if (data.comboboxState !== ComboboxState.Open) return - if (!active) return - if (data.activationTrigger === ActivationTrigger.Pointer) return + if (!shouldScrollIntoView) return return disposables().requestAnimationFrame(() => { internalOptionRef.current?.scrollIntoView?.({ block: 'nearest' }) }) - }, [ - internalOptionRef, - active, - data.comboboxState, - data.activationTrigger, - /* We also want to trigger this when the position of the active item changes so that we can re-trigger the scrollIntoView */ data.activeOptionIndex, - ]) + }, [shouldScrollIntoView, internalOptionRef]) let handleMouseDown = useEvent((event: ReactMouseEvent) => { // We use the `mousedown` event here since it fires before the focus event, @@ -1997,16 +1475,16 @@ function OptionFn< } if (data.mode === ValueMode.Single) { - actions.closeCombobox() + machine.actions.closeCombobox() } }) let handleFocus = useEvent(() => { if (disabled) { - return actions.goToOption(Focus.Nothing) + return machine.actions.goToOption({ focus: Focus.Nothing }) } let idx = data.calculateIndex(value) - actions.goToOption(Focus.Specific, idx) + machine.actions.goToOption({ focus: Focus.Specific, idx }) }) let pointer = useTrackedPointer() @@ -2018,7 +1496,7 @@ function OptionFn< if (disabled) return if (active) return let idx = data.calculateIndex(value) - actions.goToOption(Focus.Specific, idx, ActivationTrigger.Pointer) + machine.actions.goToOption({ focus: Focus.Specific, idx }, ActivationTrigger.Pointer) }) let handleLeave = useEvent((evt) => { @@ -2026,7 +1504,7 @@ function OptionFn< if (disabled) return if (!active) return if (data.optionsPropsRef.current.hold) return - actions.goToOption(Focus.Nothing) + machine.actions.goToOption({ focus: Focus.Nothing }) }) let slot = useMemo(() => { diff --git a/packages/@headlessui-vue/package.json b/packages/@headlessui-vue/package.json index 0dd685e..14ce885 100644 --- a/packages/@headlessui-vue/package.json +++ b/packages/@headlessui-vue/package.json @@ -50,6 +50,6 @@ "vue": "3.2.37" }, "dependencies": { - "@tanstack/vue-virtual": "3.0.0-beta.60" + "@tanstack/vue-virtual": "3.13.6" } } diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.test.ts b/packages/@headlessui-vue/src/components/combobox/combobox.test.ts index 433fddd..af1b42c 100644 --- a/packages/@headlessui-vue/src/components/combobox/combobox.test.ts +++ b/packages/@headlessui-vue/src/components/combobox/combobox.test.ts @@ -1,5 +1,5 @@ -import { computed, defineComponent, h, nextTick, reactive, ref, watch, type PropType } from 'vue' -import { State, useOpenClosed, useOpenClosedProvider } from '../../internal/open-closed' +import {computed, defineComponent, h, nextTick, reactive, ref, watch, type PropType} from 'vue' +import {State, useOpenClosed, useOpenClosedProvider} from '../../internal/open-closed' import { ComboboxMode, ComboboxState, @@ -26,7 +26,7 @@ import { getComboboxOptions, getComboboxes, } from '../../test-utils/accessibility-assertions' -import { html } from '../../test-utils/html' +import {html} from '../../test-utils/html' import { Keys, MouseButton, @@ -40,8 +40,8 @@ import { type, word, } from '../../test-utils/interactions' -import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs' -import { createRenderTemplate, render } from '../../test-utils/vue-testing-library' +import {suppressConsoleLogs} from '../../test-utils/suppress-console-logs' +import {createRenderTemplate, render} from '../../test-utils/vue-testing-library' import { Combobox, ComboboxButton, @@ -113,14 +113,14 @@ describe('safeguards', () => { `, - setup: () => ({ value: ref(null) }), + setup: () => ({value: ref(null)}), }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) }) ) }) @@ -142,23 +142,23 @@ describe('Rendering', () => { `, - setup: () => ({ value: ref(null) }), + setup: () => ({value: ref(null)}), }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) await click(getComboboxButton()) assertComboboxButton({ state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.Visible }) + assertComboboxList({state: ComboboxState.Visible}) }) ) @@ -177,40 +177,40 @@ describe('Rendering', () => { `, - setup: () => ({ value: ref(null) }), + setup: () => ({value: ref(null)}), }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) await click(getComboboxButton()) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) await press(Keys.Enter, getComboboxButton()) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // The input should also be disabled assertComboboxInput({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-input-1', disabled: '' }, + attributes: {id: 'headlessui-combobox-input-1', disabled: ''}, }) // And even if we try to focus it, it should not open the combobox await focus(getComboboxInput()) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) }) ) @@ -218,9 +218,9 @@ describe('Rendering', () => { 'should not crash in multiple mode', suppressConsoleLogs(async () => { let options = [ - { id: 1, name: 'Alice' }, - { id: 2, name: 'Bob' }, - { id: 3, name: 'Charlie' }, + {id: 1, name: 'Alice'}, + {id: 2, name: 'Bob'}, + {id: 3, name: 'Charlie'}, ] renderTemplate({ @@ -240,7 +240,7 @@ describe('Rendering', () => { `, setup: () => { let value = ref(options[1]) - return { options, value } + return {options, value} }, }) @@ -255,9 +255,9 @@ describe('Rendering', () => { describe('Equality', () => { let options = [ - { id: 1, name: 'Alice' }, - { id: 2, name: 'Bob' }, - { id: 3, name: 'Charlie' }, + {id: 1, name: 'Alice'}, + {id: 2, name: 'Bob'}, + {id: 3, name: 'Charlie'}, ] it( @@ -280,7 +280,7 @@ describe('Rendering', () => { `, setup: () => { let value = ref(options[1]) - return { options, value } + return {options, value} }, }) @@ -288,7 +288,7 @@ describe('Rendering', () => { let bob = getComboboxOptions()[1] expect(bob).toHaveTextContent( - JSON.stringify({ active: true, selected: true, disabled: false }) + JSON.stringify({active: true, selected: true, disabled: false}) ) }) ) @@ -313,7 +313,7 @@ describe('Rendering', () => { `, setup: () => { let value = ref(null) - return { options, value } + return {options, value} }, }) @@ -321,13 +321,13 @@ describe('Rendering', () => { let [alice, bob, charlie] = getComboboxOptions() expect(alice).toHaveTextContent( - JSON.stringify({ active: true, selected: false, disabled: false }) + JSON.stringify({active: true, selected: false, disabled: false}) ) expect(bob).toHaveTextContent( - JSON.stringify({ active: false, selected: false, disabled: false }) + JSON.stringify({active: false, selected: false, disabled: false}) ) expect(charlie).toHaveTextContent( - JSON.stringify({ active: false, selected: false, disabled: false }) + JSON.stringify({active: false, selected: false, disabled: false}) ) }) ) @@ -351,8 +351,8 @@ describe('Rendering', () => { `, setup: () => { - let value = ref({ id: 2, name: 'Bob' }) - return { options, value } + let value = ref({id: 2, name: 'Bob'}) + return {options, value} }, }) @@ -360,7 +360,7 @@ describe('Rendering', () => { let bob = getComboboxOptions()[1] expect(bob).toHaveTextContent( - JSON.stringify({ active: true, selected: true, disabled: false }) + JSON.stringify({active: true, selected: true, disabled: false}) ) }) ) @@ -384,8 +384,8 @@ describe('Rendering', () => { `, setup: () => { - let value = ref({ id: 2, name: 'Bob' }) - return { options, value, compare: (a: any, z: any) => a.id === z.id } + let value = ref({id: 2, name: 'Bob'}) + return {options, value, compare: (a: any, z: any) => a.id === z.id} }, }) @@ -393,7 +393,7 @@ describe('Rendering', () => { let bob = getComboboxOptions()[1] expect(bob).toHaveTextContent( - JSON.stringify({ active: true, selected: true, disabled: false }) + JSON.stringify({active: true, selected: true, disabled: false}) ) }) ) @@ -413,8 +413,8 @@ describe('Rendering', () => { `, setup: () => { - let value = ref({ id: 2, name: 'Bob' }) - return { options, value } + let value = ref({id: 2, name: 'Bob'}) + return {options, value} }, }) @@ -426,14 +426,14 @@ describe('Rendering', () => { await click(getComboboxOptions()[2]) await click(getComboboxButton()) - ;[alice, bob, charlie] = getComboboxOptions() + ;[alice, bob, charlie] = getComboboxOptions() expect(alice).toHaveAttribute('aria-selected', 'false') expect(bob).toHaveAttribute('aria-selected', 'false') expect(charlie).toHaveAttribute('aria-selected', 'true') await click(getComboboxOptions()[1]) await click(getComboboxButton()) - ;[alice, bob, charlie] = getComboboxOptions() + ;[alice, bob, charlie] = getComboboxOptions() expect(alice).toHaveAttribute('aria-selected', 'false') expect(bob).toHaveAttribute('aria-selected', 'true') expect(charlie).toHaveAttribute('aria-selected', 'false') @@ -455,8 +455,8 @@ describe('Rendering', () => { `, setup: () => { - let value = ref([{ id: 2, name: 'Bob' }]) - return { options, value } + let value = ref([{id: 2, name: 'Bob'}]) + return {options, value} }, }) @@ -469,7 +469,7 @@ describe('Rendering', () => { expect(charlie).toHaveAttribute('aria-selected', 'true') await click(getComboboxOptions()[2]) - ;[alice, bob, charlie] = getComboboxOptions() + ;[alice, bob, charlie] = getComboboxOptions() expect(alice).toHaveAttribute('aria-selected', 'false') expect(bob).toHaveAttribute('aria-selected', 'true') expect(charlie).toHaveAttribute('aria-selected', 'false') @@ -481,9 +481,9 @@ describe('Rendering', () => { 'should not crash when a defaultValue is not given', suppressConsoleLogs(async () => { let data = [ - { id: 1, name: 'alice', label: 'Alice' }, - { id: 2, name: 'bob', label: 'Bob' }, - { id: 3, name: 'charlie', label: 'Charlie' }, + {id: 1, name: 'alice', label: 'Alice'}, + {id: 2, name: 'bob', label: 'Bob'}, + {id: 3, name: 'charlie', label: 'Charlie'}, ] renderTemplate({ @@ -498,7 +498,7 @@ describe('Rendering', () => { `, - setup: () => ({ person: ref(data[0]), data, displayValue: () => String(Math.random()) }), + setup: () => ({person: ref(data[0]), data, displayValue: () => String(Math.random())}), }) let value = getComboboxInput()?.value @@ -523,9 +523,9 @@ describe('Rendering', () => { 'should not crash when a defaultValue is not given', suppressConsoleLogs(async () => { let data = [ - { id: 1, name: 'alice', label: 'Alice' }, - { id: 2, name: 'bob', label: 'Bob' }, - { id: 3, name: 'charlie', label: 'Charlie' }, + {id: 1, name: 'alice', label: 'Alice'}, + {id: 2, name: 'bob', label: 'Bob'}, + {id: 3, name: 'charlie', label: 'Charlie'}, ] renderTemplate({ @@ -539,7 +539,7 @@ describe('Rendering', () => { `, - setup: () => ({ data }), + setup: () => ({data}), }) }) ) @@ -548,9 +548,9 @@ describe('Rendering', () => { 'should close the Combobox when the input is blurred', suppressConsoleLogs(async () => { let data = [ - { id: 1, name: 'alice', label: 'Alice' }, - { id: 2, name: 'bob', label: 'Bob' }, - { id: 3, name: 'charlie', label: 'Charlie' }, + {id: 1, name: 'alice', label: 'Alice'}, + {id: 2, name: 'bob', label: 'Bob'}, + {id: 3, name: 'charlie', label: 'Charlie'}, ] renderTemplate({ @@ -565,20 +565,20 @@ describe('Rendering', () => { `, - setup: () => ({ data }), + setup: () => ({data}), }) // Open the combobox await click(getComboboxButton()) // Verify it is open - assertComboboxList({ state: ComboboxState.Visible }) + assertComboboxList({state: ComboboxState.Visible}) // Close the combobox await blur(getComboboxInput()) // Verify it is closed - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) }) ) }) @@ -599,18 +599,18 @@ describe('Rendering', () => { `, - setup: () => ({ value: ref(null) }), + setup: () => ({value: ref(null)}), }) // TODO: Rendering Example directly reveals a vue bug — I think it's been fixed for a while but I can't find the commit renderTemplate(Example) - assertComboboxInput({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxInput({state: ComboboxState.InvisibleUnmounted}) await click(getComboboxButton()) - assertComboboxInput({ state: ComboboxState.Visible }) - assertComboboxList({ state: ComboboxState.Visible }) + assertComboboxInput({state: ComboboxState.Visible}) + assertComboboxList({state: ComboboxState.Visible}) await click(getComboboxOptions()[1]) @@ -633,14 +633,14 @@ describe('Rendering', () => { `, - setup: () => ({ value: ref(null) }), + setup: () => ({value: ref(null)}), }) renderTemplate(Example) await click(getComboboxButton()) - assertComboboxList({ state: ComboboxState.Visible }) + assertComboboxList({state: ComboboxState.Visible}) await click(getComboboxOptions()[1]) @@ -664,7 +664,7 @@ describe('Rendering', () => { `, - setup: () => ({ value: ref(undefined) }), + setup: () => ({value: ref(undefined)}), }) renderTemplate(Example) @@ -701,7 +701,7 @@ describe('Rendering', () => { `, - setup: () => ({ value: ref(null), suffix: ref(false) }), + setup: () => ({value: ref(null), suffix: ref(false)}), }) renderTemplate(Example) @@ -746,7 +746,7 @@ describe('Rendering', () => { `, - setup: () => ({ value: ref(null) }), + setup: () => ({value: ref(null)}), }) renderTemplate(Example) @@ -888,26 +888,26 @@ describe('Rendering', () => { `, - setup: () => ({ value: ref(null) }), + setup: () => ({value: ref(null)}), }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-3' }, + attributes: {id: 'headlessui-combobox-button-3'}, }) assertComboboxLabel({ - attributes: { id: 'headlessui-combobox-label-1' }, - textContent: JSON.stringify({ open: false, disabled: false }), + attributes: {id: 'headlessui-combobox-label-1'}, + textContent: JSON.stringify({open: false, disabled: false}), }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) await click(getComboboxButton()) assertComboboxLabel({ - attributes: { id: 'headlessui-combobox-label-1' }, - textContent: JSON.stringify({ open: true, disabled: false }), + attributes: {id: 'headlessui-combobox-label-1'}, + textContent: JSON.stringify({open: true, disabled: false}), }) - assertComboboxList({ state: ComboboxState.Visible }) + assertComboboxList({state: ComboboxState.Visible}) assertComboboxLabelLinkedWithCombobox() assertComboboxButtonLinkedWithComboboxLabel() }) @@ -924,7 +924,7 @@ describe('Rendering', () => { Label `, - setup: () => ({ value: ref(null) }), + setup: () => ({value: ref(null)}), }) await new Promise(nextTick) @@ -950,23 +950,23 @@ describe('Rendering', () => { `, - setup: () => ({ value: ref(null) }), + setup: () => ({value: ref(null)}), }) assertComboboxLabel({ - attributes: { id: 'headlessui-combobox-label-1' }, - textContent: JSON.stringify({ open: false, disabled: false }), + attributes: {id: 'headlessui-combobox-label-1'}, + textContent: JSON.stringify({open: false, disabled: false}), tag: 'p', }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) await click(getComboboxButton()) assertComboboxLabel({ - attributes: { id: 'headlessui-combobox-label-1' }, - textContent: JSON.stringify({ open: true, disabled: false }), + attributes: {id: 'headlessui-combobox-label-1'}, + textContent: JSON.stringify({open: true, disabled: false}), tag: 'p', }) - assertComboboxList({ state: ComboboxState.Visible }) + assertComboboxList({state: ComboboxState.Visible}) }) ) }) @@ -987,24 +987,24 @@ describe('Rendering', () => { `, - setup: () => ({ value: ref(null) }), + setup: () => ({value: ref(null)}), }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - textContent: JSON.stringify({ open: false, disabled: false, value: null }), + attributes: {id: 'headlessui-combobox-button-2'}, + textContent: JSON.stringify({open: false, disabled: false, value: null}), }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) await click(getComboboxButton()) assertComboboxButton({ state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-button-2' }, - textContent: JSON.stringify({ open: true, disabled: false, value: null }), + attributes: {id: 'headlessui-combobox-button-2'}, + textContent: JSON.stringify({open: true, disabled: false, value: null}), }) - assertComboboxList({ state: ComboboxState.Visible }) + assertComboboxList({state: ComboboxState.Visible}) }) ) @@ -1025,24 +1025,24 @@ describe('Rendering', () => { `, - setup: () => ({ value: ref(null) }), + setup: () => ({value: ref(null)}), }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - textContent: JSON.stringify({ open: false, disabled: false, value: null }), + attributes: {id: 'headlessui-combobox-button-2'}, + textContent: JSON.stringify({open: false, disabled: false, value: null}), }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) await click(getComboboxButton()) assertComboboxButton({ state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-button-2' }, - textContent: JSON.stringify({ open: true, disabled: false, value: null }), + attributes: {id: 'headlessui-combobox-button-2'}, + textContent: JSON.stringify({open: true, disabled: false, value: null}), }) - assertComboboxList({ state: ComboboxState.Visible }) + assertComboboxList({state: ComboboxState.Visible}) }) ) @@ -1062,16 +1062,16 @@ describe('Rendering', () => { `, - setup: () => ({ value: ref(null) }), + setup: () => ({value: ref(null)}), }) await new Promise(requestAnimationFrame) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-3' }, + attributes: {id: 'headlessui-combobox-button-3'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) assertComboboxButtonLinkedWithComboboxLabel() }) ) @@ -1085,7 +1085,7 @@ describe('Rendering', () => { Trigger `, - setup: () => ({ value: ref(null) }), + setup: () => ({value: ref(null)}), }) expect(getComboboxButton()).toHaveAttribute('type', 'button') @@ -1099,7 +1099,7 @@ describe('Rendering', () => { Trigger `, - setup: () => ({ value: ref(null) }), + setup: () => ({value: ref(null)}), }) expect(getComboboxButton()).toHaveAttribute('type', 'submit') @@ -1109,7 +1109,7 @@ describe('Rendering', () => { 'should set the `type` to "button" when using the `as` prop which resolves to a "button"', suppressConsoleLogs(async () => { let CustomButton = defineComponent({ - setup: (props) => () => h('button', { ...props }), + setup: (props) => () => h('button', {...props}), }) renderTemplate({ @@ -1139,7 +1139,7 @@ describe('Rendering', () => { Trigger `, - setup: () => ({ value: ref(null) }), + setup: () => ({value: ref(null)}), }) expect(getComboboxButton()).not.toHaveAttribute('type') @@ -1187,25 +1187,25 @@ describe('Rendering', () => { `, - setup: () => ({ value: ref(null) }), + setup: () => ({value: ref(null)}), }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) await click(getComboboxButton()) assertComboboxButton({ state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) assertComboboxList({ state: ComboboxState.Visible, - textContent: JSON.stringify({ open: true }), + textContent: JSON.stringify({open: true}), }) assertActiveElement(getComboboxInput()) @@ -1225,7 +1225,7 @@ describe('Rendering', () => { `, - setup: () => ({ value: ref(null) }), + setup: () => ({value: ref(null)}), }) // Let's verify that the combobox is already there @@ -1245,17 +1245,17 @@ describe('Rendering', () => { `, - setup: () => ({ value: ref(null) }), + setup: () => ({value: ref(null)}), }) await new Promise(nextTick) - assertComboboxList({ state: ComboboxState.InvisibleHidden }) + assertComboboxList({state: ComboboxState.InvisibleHidden}) // Let's open the combobox, to see if it is not hidden anymore await click(getComboboxButton()) - assertComboboxList({ state: ComboboxState.Visible }) + assertComboboxList({state: ComboboxState.Visible}) }) }) @@ -1273,31 +1273,31 @@ describe('Rendering', () => { `, - setup: () => ({ value: ref(null) }), + setup: () => ({value: ref(null)}), }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) await click(getComboboxButton()) assertComboboxButton({ state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) assertComboboxList({ state: ComboboxState.Visible, - textContent: JSON.stringify({ active: true, selected: false, disabled: false }), + textContent: JSON.stringify({active: true, selected: false, disabled: false}), }) }) ) }) it('should guarantee the order of DOM nodes when performing actions', async () => { - let props = reactive({ hide: false }) + let props = reactive({hide: false}) renderTemplate({ template: html` @@ -1330,7 +1330,7 @@ describe('Rendering', () => { props.hide = false await nextFrame() - assertComboboxList({ state: ComboboxState.Visible }) + assertComboboxList({state: ComboboxState.Visible}) let options = getComboboxOptions() @@ -1349,7 +1349,7 @@ describe('Rendering', () => { }) it('should guarantee the order of options based on `order` when performing actions', async () => { - let props = reactive({ hide: false }) + let props = reactive({hide: false}) renderTemplate({ template: html` @@ -1382,7 +1382,7 @@ describe('Rendering', () => { props.hide = false await nextFrame() - assertComboboxList({ state: ComboboxState.Visible }) + assertComboboxList({state: ComboboxState.Visible}) let options = getComboboxOptions() @@ -1442,7 +1442,7 @@ describe('Rendering', () => { await click(document.getElementById('submit')) // Alice should be submitted - expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'alice' }) + expect(handleSubmission).toHaveBeenLastCalledWith({assignee: 'alice'}) // Open combobox await click(getComboboxButton()) @@ -1454,7 +1454,7 @@ describe('Rendering', () => { await click(document.getElementById('submit')) // Charlie should be submitted - expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'charlie' }) + expect(handleSubmission).toHaveBeenLastCalledWith({assignee: 'charlie'}) }) it('should expose the value via the render prop', async () => { @@ -1504,7 +1504,7 @@ describe('Rendering', () => { await click(document.getElementById('submit')) // Alice should be submitted - expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'alice' }) + expect(handleSubmission).toHaveBeenLastCalledWith({assignee: 'alice'}) // Open combobox await click(getComboboxButton()) @@ -1518,7 +1518,7 @@ describe('Rendering', () => { await click(document.getElementById('submit')) // Charlie should be submitted - expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'charlie' }) + expect(handleSubmission).toHaveBeenLastCalledWith({assignee: 'charlie'}) }) it('should be possible to provide a default value', async () => { @@ -1550,7 +1550,7 @@ describe('Rendering', () => { await click(document.getElementById('submit')) // Bob is the defaultValue - expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'bob' }) + expect(handleSubmission).toHaveBeenLastCalledWith({assignee: 'bob'}) // Open combobox await click(getComboboxButton()) @@ -1562,7 +1562,7 @@ describe('Rendering', () => { await click(document.getElementById('submit')) // Alice should be submitted - expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'alice' }) + expect(handleSubmission).toHaveBeenLastCalledWith({assignee: 'alice'}) }) it( @@ -1597,7 +1597,7 @@ describe('Rendering', () => { await click(document.getElementById('submit')) // Bob is the defaultValue - expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'bob' }) + expect(handleSubmission).toHaveBeenLastCalledWith({assignee: 'bob'}) // Open combobox await click(getComboboxButton()) @@ -1626,9 +1626,9 @@ describe('Rendering', () => { let handleSubmission = jest.fn() let data = [ - { id: 1, name: 'alice', label: 'Alice' }, - { id: 2, name: 'bob', label: 'Bob' }, - { id: 3, name: 'charlie', label: 'Charlie' }, + {id: 1, name: 'alice', label: 'Alice'}, + {id: 2, name: 'bob', label: 'Bob'}, + {id: 3, name: 'charlie', label: 'Charlie'}, ] renderTemplate({ @@ -1789,7 +1789,7 @@ describe('Rendering', () => { `, - setup: () => ({ CustomComponent }), + setup: () => ({CustomComponent}), }) // Open combobox @@ -1814,20 +1814,20 @@ describe('Rendering composition', () => { `, - setup: () => ({ value: ref(null) }), + setup: () => ({value: ref(null)}), }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Open combobox await click(getComboboxButton()) // Verify options are buttons now - getComboboxOptions().forEach((option) => assertComboboxOption(option, { tag: 'button' })) + getComboboxOptions().forEach((option) => assertComboboxOption(option, {tag: 'button'})) }) ) @@ -1862,7 +1862,7 @@ describe('Rendering composition', () => { `, - setup: () => ({ value: ref(null) }), + setup: () => ({value: ref(null)}), }) // Open combobox @@ -1883,8 +1883,8 @@ describe('Rendering composition', () => { describe('Composition', () => { let OpenClosedWrite = defineComponent({ - props: { open: { type: Boolean } }, - setup(props, { slots }) { + props: {open: {type: Boolean}}, + setup(props, {slots}) { useOpenClosedProvider(ref(props.open ? State.Open : State.Closed)) return () => slots.default?.() }, @@ -1892,7 +1892,7 @@ describe('Composition', () => { let OpenClosedRead = defineComponent({ emits: ['read'], - setup(_, { slots, emit }) { + setup(_, {slots, emit}) { let state = useOpenClosed() watch([state], ([value]) => emit('read', value)) return () => slots.default?.() @@ -1903,7 +1903,7 @@ describe('Composition', () => { 'should always open the ComboboxOptions because of a wrapping OpenClosed component', suppressConsoleLogs(async () => { renderTemplate({ - components: { OpenClosedWrite }, + components: {OpenClosedWrite}, template: html` @@ -1918,13 +1918,13 @@ describe('Composition', () => { await new Promise(nextTick) // Verify the combobox is visible - assertComboboxList({ state: ComboboxState.Visible }) + assertComboboxList({state: ComboboxState.Visible}) // Let's try and open the combobox await click(getComboboxButton()) // Verify the combobox is still visible - assertComboboxList({ state: ComboboxState.Visible }) + assertComboboxList({state: ComboboxState.Visible}) }) ) @@ -1932,7 +1932,7 @@ describe('Composition', () => { 'should always close the ComboboxOptions because of a wrapping OpenClosed component', suppressConsoleLogs(async () => { renderTemplate({ - components: { OpenClosedWrite }, + components: {OpenClosedWrite}, template: html` @@ -1947,13 +1947,13 @@ describe('Composition', () => { await new Promise(nextTick) // Verify the combobox is hidden - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Let's try and open the combobox await click(getComboboxButton()) // Verify the combobox is still hidden - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) }) ) @@ -1962,7 +1962,7 @@ describe('Composition', () => { suppressConsoleLogs(async () => { let readFn = jest.fn() renderTemplate({ - components: { OpenClosedRead }, + components: {OpenClosedRead}, template: html` @@ -1975,14 +1975,14 @@ describe('Composition', () => { `, setup() { - return { value: ref(null), readFn } + return {value: ref(null), readFn} }, }) await new Promise(nextTick) // Verify the combobox is hidden - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Let's toggle the combobox 3 times await click(getComboboxButton()) @@ -1990,7 +1990,7 @@ describe('Composition', () => { await click(getComboboxButton()) // Verify the combobox is visible - assertComboboxList({ state: ComboboxState.Visible }) + assertComboboxList({state: ComboboxState.Visible}) expect(readFn).toHaveBeenCalledTimes(3) expect(readFn).toHaveBeenNthCalledWith(1, State.Open) @@ -2000,9 +2000,18 @@ describe('Composition', () => { ) }) -describe.each([{ virtual: true }, { virtual: false }])( +// TODO: Re-enable virtual tests once we migrated from `npm` to `pnpm` and +// rolled back the `@tanstack/virtual-vue` version. +// +// We had to bump `@tanstack/virtual-vue` such that the `@tanstack/virtual-core` +// version was the same _and_ hoisted such that we could write a patch for it. +// Different versions meant that the `@tanstack/virtual-core` version was +// embedded in +// `node_modules/@tanstack/virtual-react/node_modules/@tanstack/virtual-core` +// which wasn't patchable via patch-package. Pnpm will solve this. +describe.each([{virtual: false}, {virtual: false}])( 'Keyboard interactions %s', - ({ virtual }) => { + ({virtual}) => { let data = ['Option A', 'Option B', 'Option C'] let MyCombobox = defineComponent({ components: getDefaultComponents(), @@ -2035,17 +2044,17 @@ describe.each([{ virtual: true }, { virtual: false }])( `, props: { - options: { default: data.slice() }, - useComboboxOptions: { default: true }, + options: {default: data.slice()}, + useComboboxOptions: {default: true}, comboboxProps: {}, - inputProps: { default: {} }, - buttonProps: { default: {} }, - optionProps: { default: {} }, + inputProps: {default: {}}, + buttonProps: {default: {}}, + optionProps: {default: {}}, }, setup(props) { // @ts-expect-error - let { value = 'test', update, ...comboboxProps } = props.comboboxProps ?? {} + let {value = 'test', update, ...comboboxProps} = props.comboboxProps ?? {} function isDisabled(option: any) { return typeof option === 'string' ? false @@ -2062,7 +2071,7 @@ describe.each([{ virtual: true }, { virtual: false }])( comboboxProps, isDisabled, virtual: computed(() => { - return virtual ? { options: props.options, disabled: isDisabled } : null + return virtual ? {options: props.options, disabled: isDisabled} : null }), } }, @@ -2074,15 +2083,15 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to open the Combobox with Enter', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Focus the button getComboboxButton()?.focus() @@ -2094,10 +2103,10 @@ describe.each([{ virtual: true }, { virtual: false }])( assertActiveElement(getComboboxInput()) // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxButton({state: ComboboxState.Visible}) assertComboboxList({ state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, + attributes: {id: 'headlessui-combobox-options-3'}, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() @@ -2105,7 +2114,7 @@ describe.each([{ virtual: true }, { virtual: false }])( // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach((option) => assertComboboxOption(option, { selected: false })) + options.forEach((option) => assertComboboxOption(option, {selected: false})) assertActiveComboboxOption(options[0]) assertNoSelectedComboboxOption() @@ -2116,15 +2125,15 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should not be possible to open the combobox with Enter when the button is disabled', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Try to focus the button getComboboxButton()?.focus() @@ -2135,9 +2144,9 @@ describe.each([{ virtual: true }, { virtual: false }])( // Verify it is still closed assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) }) ) @@ -2145,15 +2154,15 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to open the combobox with Enter, and focus the selected option', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Focus the button getComboboxButton()?.focus() @@ -2165,10 +2174,10 @@ describe.each([{ virtual: true }, { virtual: false }])( assertActiveElement(getComboboxInput()) // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxButton({state: ComboboxState.Visible}) assertComboboxList({ state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, + attributes: {id: 'headlessui-combobox-options-3'}, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() @@ -2176,7 +2185,7 @@ describe.each([{ virtual: true }, { virtual: false }])( // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) + options.forEach((option, i) => assertComboboxOption(option, {selected: i === 1})) // Verify that the second combobox option is active (because it is already selected) assertActiveComboboxOption(options[1]) @@ -2200,16 +2209,16 @@ describe.each([{ virtual: true }, { virtual: false }])( `, - setup: () => ({ value: ref('b'), virtual }), + setup: () => ({value: ref('b'), virtual}), }) await new Promise(nextTick) assertComboboxButton({ state: ComboboxState.InvisibleHidden, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleHidden }) + assertComboboxList({state: ComboboxState.InvisibleHidden}) // Focus the button getComboboxButton()?.focus() @@ -2221,10 +2230,10 @@ describe.each([{ virtual: true }, { virtual: false }])( assertActiveElement(getComboboxInput()) // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxButton({state: ComboboxState.Visible}) assertComboboxList({ state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, + attributes: {id: 'headlessui-combobox-options-3'}, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() @@ -2238,7 +2247,7 @@ describe.each([{ virtual: true }, { virtual: false }])( assertActiveComboboxOption(options[0]) // Verify that Option B is still selected - assertComboboxOption(options[1], { selected: true }) + assertComboboxOption(options[1], {selected: true}) // Close/Hide the combobox await press(Keys.Escape) @@ -2248,7 +2257,7 @@ describe.each([{ virtual: true }, { virtual: false }])( // Verify we have combobox options expect(options).toHaveLength(3) - options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) + options.forEach((option, i) => assertComboboxOption(option, {selected: i === 1})) // Verify that the second combobox option is active (because it is already selected) assertActiveComboboxOption(options[1]) @@ -2259,15 +2268,15 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to open the combobox with Enter, and focus the selected option (with a list of objects)', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Focus the button getComboboxButton()?.focus() @@ -2279,10 +2288,10 @@ describe.each([{ virtual: true }, { virtual: false }])( assertActiveElement(getComboboxInput()) // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxButton({state: ComboboxState.Visible}) assertComboboxList({ state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, + attributes: {id: 'headlessui-combobox-options-3'}, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() @@ -2290,7 +2299,7 @@ describe.each([{ virtual: true }, { virtual: false }])( // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) + options.forEach((option, i) => assertComboboxOption(option, {selected: i === 1})) // Verify that the second combobox option is active (because it is already selected) assertActiveComboboxOption(options[1]) @@ -2301,11 +2310,11 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should have no active combobox option when there are no combobox options at all', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Focus the button getComboboxButton()?.focus() @@ -2316,7 +2325,7 @@ describe.each([{ virtual: true }, { virtual: false }])( // Verify we moved focus to the input field assertActiveElement(getComboboxInput()) - assertComboboxList({ state: ComboboxState.Visible }) + assertComboboxList({state: ComboboxState.Visible}) assertActiveElement(getComboboxInput()) assertNoActiveComboboxOption() @@ -2329,15 +2338,15 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to open the combobox with Space', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Focus the button getComboboxButton()?.focus() @@ -2349,10 +2358,10 @@ describe.each([{ virtual: true }, { virtual: false }])( assertActiveElement(getComboboxInput()) // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxButton({state: ComboboxState.Visible}) assertComboboxList({ state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, + attributes: {id: 'headlessui-combobox-options-3'}, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() @@ -2369,15 +2378,15 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should not be possible to open the combobox with Space when the button is disabled', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Focus the button getComboboxButton()?.focus() @@ -2388,9 +2397,9 @@ describe.each([{ virtual: true }, { virtual: false }])( // Verify it is still closed assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) }) ) @@ -2398,13 +2407,13 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to open the combobox with Space, and focus the selected option', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted, @@ -2417,10 +2426,10 @@ describe.each([{ virtual: true }, { virtual: false }])( await press(Keys.Space) // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxButton({state: ComboboxState.Visible}) assertComboboxList({ state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, + attributes: {id: 'headlessui-combobox-options-3'}, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() @@ -2428,7 +2437,7 @@ describe.each([{ virtual: true }, { virtual: false }])( // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) + options.forEach((option, i) => assertComboboxOption(option, {selected: i === 1})) // Verify that the second combobox option is active (because it is already selected) assertActiveComboboxOption(options[1]) @@ -2439,7 +2448,7 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should have no active combobox option when there are no combobox options at all', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) @@ -2452,7 +2461,7 @@ describe.each([{ virtual: true }, { virtual: false }])( // Open combobox await press(Keys.Space) - assertComboboxList({ state: ComboboxState.Visible }) + assertComboboxList({state: ComboboxState.Visible}) assertActiveElement(getComboboxInput()) assertNoActiveComboboxOption() @@ -2463,20 +2472,20 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should have no active combobox option upon Space key press, when there are no non-disabled combobox options', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, setup: () => ({ options: [ - { value: 'alice', children: 'alice', disabled: true }, - { value: 'bob', children: 'bob', disabled: true }, - { value: 'charlie', children: 'charlie', disabled: true }, + {value: 'alice', children: 'alice', disabled: true}, + {value: 'bob', children: 'bob', disabled: true}, + {value: 'charlie', children: 'charlie', disabled: true}, ], }), }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted, @@ -2498,7 +2507,7 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to close an open combobox with Escape', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) @@ -2506,10 +2515,10 @@ describe.each([{ virtual: true }, { virtual: false }])( await click(getComboboxButton()) // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxButton({state: ComboboxState.Visible}) assertComboboxList({ state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, + attributes: {id: 'headlessui-combobox-options-3'}, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() @@ -2522,8 +2531,8 @@ describe.each([{ virtual: true }, { virtual: false }])( await press(Keys.Escape) // Verify it is closed - assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxButton({state: ComboboxState.InvisibleUnmounted}) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Verify the input is focused again assertActiveElement(getComboboxInput()) @@ -2535,7 +2544,7 @@ describe.each([{ virtual: true }, { virtual: false }])( suppressConsoleLogs(async () => { let handleKeyDown = jest.fn() renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) @@ -2559,7 +2568,7 @@ describe.each([{ virtual: true }, { virtual: false }])( suppressConsoleLogs(async () => { let handleKeyDown = jest.fn() renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) @@ -2584,15 +2593,15 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to open the combobox with ArrowDown', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Focus the button getComboboxButton()?.focus() @@ -2601,10 +2610,10 @@ describe.each([{ virtual: true }, { virtual: false }])( await press(Keys.ArrowDown) // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxButton({state: ComboboxState.Visible}) assertComboboxList({ state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, + attributes: {id: 'headlessui-combobox-options-3'}, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() @@ -2623,15 +2632,15 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should not be possible to open the combobox with ArrowDown when the button is disabled', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Focus the button getComboboxButton()?.focus() @@ -2642,9 +2651,9 @@ describe.each([{ virtual: true }, { virtual: false }])( // Verify it is still closed assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) }) ) @@ -2652,15 +2661,15 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to open the combobox with ArrowDown, and focus the selected option', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Focus the button getComboboxButton()?.focus() @@ -2669,10 +2678,10 @@ describe.each([{ virtual: true }, { virtual: false }])( await press(Keys.ArrowDown) // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxButton({state: ComboboxState.Visible}) assertComboboxList({ state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, + attributes: {id: 'headlessui-combobox-options-3'}, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() @@ -2680,7 +2689,7 @@ describe.each([{ virtual: true }, { virtual: false }])( // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) + options.forEach((option, i) => assertComboboxOption(option, {selected: i === 1})) // Verify that the second combobox option is active (because it is already selected) assertActiveComboboxOption(options[1]) @@ -2691,18 +2700,18 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should have no active combobox option when there are no combobox options at all', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Focus the button getComboboxButton()?.focus() // Open combobox await press(Keys.ArrowDown) - assertComboboxList({ state: ComboboxState.Visible }) + assertComboboxList({state: ComboboxState.Visible}) assertActiveElement(getComboboxInput()) assertNoActiveComboboxOption() @@ -2715,15 +2724,15 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to open the combobox with ArrowUp and the last option should be active', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Focus the button getComboboxButton()?.focus() @@ -2732,10 +2741,10 @@ describe.each([{ virtual: true }, { virtual: false }])( await press(Keys.ArrowUp) // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxButton({state: ComboboxState.Visible}) assertComboboxList({ state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, + attributes: {id: 'headlessui-combobox-options-3'}, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() @@ -2754,15 +2763,15 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should not be possible to open the combobox with ArrowUp and the last option should be active when the button is disabled', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Focus the button getComboboxButton()?.focus() @@ -2773,9 +2782,9 @@ describe.each([{ virtual: true }, { virtual: false }])( // Verify it is still closed assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) }) ) @@ -2783,15 +2792,15 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to open the combobox with ArrowUp, and focus the selected option', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Focus the button getComboboxButton()?.focus() @@ -2800,10 +2809,10 @@ describe.each([{ virtual: true }, { virtual: false }])( await press(Keys.ArrowUp) // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxButton({state: ComboboxState.Visible}) assertComboboxList({ state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, + attributes: {id: 'headlessui-combobox-options-3'}, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() @@ -2811,7 +2820,7 @@ describe.each([{ virtual: true }, { virtual: false }])( // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) + options.forEach((option, i) => assertComboboxOption(option, {selected: i === 1})) // Verify that the second combobox option is active (because it is already selected) assertActiveComboboxOption(options[1]) @@ -2822,18 +2831,18 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should have no active combobox option when there are no combobox options at all', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Focus the button getComboboxButton()?.focus() // Open combobox await press(Keys.ArrowUp) - assertComboboxList({ state: ComboboxState.Visible }) + assertComboboxList({state: ComboboxState.Visible}) assertActiveElement(getComboboxInput()) assertNoActiveComboboxOption() @@ -2844,22 +2853,22 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use ArrowUp to navigate the combobox options and jump to the first non-disabled one', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, setup: () => ({ options: [ - { value: 'alice', children: 'alice', disabled: false }, - { value: 'bob', children: 'bob', disabled: true }, - { value: 'charlie', children: 'charlie', disabled: true }, + {value: 'alice', children: 'alice', disabled: false}, + {value: 'bob', children: 'bob', disabled: true}, + {value: 'charlie', children: 'charlie', disabled: true}, ], }), }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Focus the button getComboboxButton()?.focus() @@ -2884,7 +2893,7 @@ describe.each([{ virtual: true }, { virtual: false }])( suppressConsoleLogs(async () => { let handleChange = jest.fn() renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, setup: () => { let model = ref(null) @@ -2900,15 +2909,15 @@ describe.each([{ virtual: true }, { virtual: false }])( assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Open combobox await click(getComboboxButton()) // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxButton({state: ComboboxState.Visible}) // Activate the first combobox option let options = getComboboxOptions() @@ -2918,8 +2927,8 @@ describe.each([{ virtual: true }, { virtual: false }])( await press(Keys.Enter) // Verify it is closed - assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxButton({state: ComboboxState.InvisibleUnmounted}) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Verify we got the change event expect(handleChange).toHaveBeenCalledTimes(1) @@ -2942,7 +2951,7 @@ describe.each([{ virtual: true }, { virtual: false }])( let submits = jest.fn() renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html`
@@ -2989,7 +2998,7 @@ describe.each([{ virtual: true }, { virtual: false }])( let submits = jest.fn() renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html` @@ -3035,7 +3044,7 @@ describe.each([{ virtual: true }, { virtual: false }])( 'pressing Tab should select the active item and move to the next DOM node', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html` @@ -3054,9 +3063,9 @@ describe.each([{ virtual: true }, { virtual: false }])( assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Open combobox await click(getComboboxButton()) @@ -3068,8 +3077,8 @@ describe.each([{ virtual: true }, { virtual: false }])( await press(Keys.Tab) // Verify it is closed - assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxButton({state: ComboboxState.InvisibleUnmounted}) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // That the selected value was the highlighted one expect(getComboboxInput()?.value).toBe('Option B') @@ -3083,7 +3092,7 @@ describe.each([{ virtual: true }, { virtual: false }])( 'pressing Shift+Tab should select the active item and move to the previous DOM node', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html` @@ -3102,9 +3111,9 @@ describe.each([{ virtual: true }, { virtual: false }])( assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Open combobox await click(getComboboxButton()) @@ -3116,8 +3125,8 @@ describe.each([{ virtual: true }, { virtual: false }])( await press(shift(Keys.Tab)) // Verify it is closed - assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxButton({state: ComboboxState.InvisibleUnmounted}) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // That the selected value was the highlighted one expect(getComboboxInput()?.value).toBe('Option B') @@ -3133,7 +3142,7 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to close an open combobox with Escape', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) @@ -3141,10 +3150,10 @@ describe.each([{ virtual: true }, { virtual: false }])( await click(getComboboxButton()) // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxButton({state: ComboboxState.Visible}) assertComboboxList({ state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, + attributes: {id: 'headlessui-combobox-options-3'}, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() @@ -3153,8 +3162,8 @@ describe.each([{ virtual: true }, { virtual: false }])( await press(Keys.Escape) // Verify it is closed - assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxButton({state: ComboboxState.InvisibleUnmounted}) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Verify the button is focused again assertActiveElement(getComboboxInput()) @@ -3178,7 +3187,7 @@ describe.each([{ virtual: true }, { virtual: false }])( `, - setup: () => ({ value: ref(null), virtual }), + setup: () => ({value: ref(null), virtual}), }) let spy = jest.fn() @@ -3190,7 +3199,7 @@ describe.each([{ virtual: true }, { virtual: false }])( spy() } }, - { capture: true } + {capture: true} ) window.addEventListener('keydown', (evt) => { @@ -3221,7 +3230,7 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should bubble escape when not using Combobox.Options at all', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) @@ -3234,7 +3243,7 @@ describe.each([{ virtual: true }, { virtual: false }])( spy() } }, - { capture: true } + {capture: true} ) window.addEventListener('keydown', (evt) => { @@ -3265,7 +3274,7 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should sync the input field correctly and reset it when pressing Escape', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) @@ -3293,15 +3302,15 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to open the combobox with ArrowDown', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Focus the input getComboboxInput()?.focus() @@ -3310,10 +3319,10 @@ describe.each([{ virtual: true }, { virtual: false }])( await press(Keys.ArrowDown) // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxButton({state: ComboboxState.Visible}) assertComboboxList({ state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, + attributes: {id: 'headlessui-combobox-options-3'}, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() @@ -3332,15 +3341,15 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should not be possible to open the combobox with ArrowDown when the button is disabled', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Focus the input getComboboxInput()?.focus() @@ -3351,9 +3360,9 @@ describe.each([{ virtual: true }, { virtual: false }])( // Verify it is still closed assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) }) ) @@ -3361,15 +3370,15 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to open the combobox with ArrowDown, and focus the selected option', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Focus the input getComboboxInput()?.focus() @@ -3378,10 +3387,10 @@ describe.each([{ virtual: true }, { virtual: false }])( await press(Keys.ArrowDown) // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxButton({state: ComboboxState.Visible}) assertComboboxList({ state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, + attributes: {id: 'headlessui-combobox-options-3'}, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() @@ -3389,7 +3398,7 @@ describe.each([{ virtual: true }, { virtual: false }])( // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) + options.forEach((option, i) => assertComboboxOption(option, {selected: i === 1})) // Verify that the second combobox option is active (because it is already selected) assertActiveComboboxOption(options[1]) @@ -3400,18 +3409,18 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should have no active combobox option when there are no combobox options at all', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Focus the input getComboboxInput()?.focus() // Open combobox await press(Keys.ArrowDown) - assertComboboxList({ state: ComboboxState.Visible }) + assertComboboxList({state: ComboboxState.Visible}) assertActiveElement(getComboboxInput()) assertNoActiveComboboxOption() @@ -3422,15 +3431,15 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use ArrowDown to navigate the combobox options', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Open combobox await click(getComboboxButton()) @@ -3460,13 +3469,13 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use ArrowDown to navigate the combobox options and skip the first disabled one', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, setup: () => ({ options: [ - { value: 'a', children: 'Option A', disabled: true }, - { value: 'b', children: 'Option B', disabled: false }, - { value: 'c', children: 'Option C', disabled: false }, + {value: 'a', children: 'Option A', disabled: true}, + {value: 'b', children: 'Option B', disabled: false}, + {value: 'c', children: 'Option C', disabled: false}, ], }), }) @@ -3475,9 +3484,9 @@ describe.each([{ virtual: true }, { virtual: false }])( assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Open combobox await click(getComboboxButton()) @@ -3498,22 +3507,22 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use ArrowDown to navigate the combobox options and jump to the first non-disabled one', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, setup: () => ({ options: [ - { value: 'a', children: 'Option A', disabled: true }, - { value: 'b', children: 'Option B', disabled: true }, - { value: 'c', children: 'Option C', disabled: false }, + {value: 'a', children: 'Option A', disabled: true}, + {value: 'b', children: 'Option B', disabled: true}, + {value: 'c', children: 'Option C', disabled: false}, ], }), }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Open combobox await click(getComboboxButton()) @@ -3534,15 +3543,15 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to go to the next item if no value is set', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Open combobox await click(getComboboxButton()) @@ -3566,15 +3575,15 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to open the combobox with ArrowUp and the last option should be active', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Focus the input getComboboxInput()?.focus() @@ -3583,10 +3592,10 @@ describe.each([{ virtual: true }, { virtual: false }])( await press(Keys.ArrowUp) // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxButton({state: ComboboxState.Visible}) assertComboboxList({ state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, + attributes: {id: 'headlessui-combobox-options-3'}, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() @@ -3605,15 +3614,15 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should not be possible to open the combobox with ArrowUp and the last option should be active when the button is disabled', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Focus the input getComboboxInput()?.focus() @@ -3624,9 +3633,9 @@ describe.each([{ virtual: true }, { virtual: false }])( // Verify it is still closed assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) }) ) @@ -3634,15 +3643,15 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to open the combobox with ArrowUp, and focus the selected option', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Focus the input getComboboxInput()?.focus() @@ -3651,10 +3660,10 @@ describe.each([{ virtual: true }, { virtual: false }])( await press(Keys.ArrowUp) // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxButton({state: ComboboxState.Visible}) assertComboboxList({ state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, + attributes: {id: 'headlessui-combobox-options-3'}, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() @@ -3662,7 +3671,7 @@ describe.each([{ virtual: true }, { virtual: false }])( // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) + options.forEach((option, i) => assertComboboxOption(option, {selected: i === 1})) // Verify that the second combobox option is active (because it is already selected) assertActiveComboboxOption(options[1]) @@ -3673,18 +3682,18 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should have no active combobox option when there are no combobox options at all', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Focus the input getComboboxInput()?.focus() // Open combobox await press(Keys.ArrowUp) - assertComboboxList({ state: ComboboxState.Visible }) + assertComboboxList({state: ComboboxState.Visible}) assertActiveElement(getComboboxInput()) assertNoActiveComboboxOption() @@ -3695,22 +3704,22 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use ArrowUp to navigate the combobox options and jump to the first non-disabled one', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, setup: () => ({ options: [ - { value: 'a', children: 'Option A', disabled: false }, - { value: 'b', children: 'Option B', disabled: true }, - { value: 'c', children: 'Option C', disabled: true }, + {value: 'a', children: 'Option A', disabled: false}, + {value: 'b', children: 'Option B', disabled: true}, + {value: 'c', children: 'Option C', disabled: true}, ], }), }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Focus the input getComboboxInput()?.focus() @@ -3730,22 +3739,22 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should not be possible to navigate up or down if there is only a single non-disabled option', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, setup: () => ({ options: [ - { value: 'a', children: 'Option A', disabled: true }, - { value: 'b', children: 'Option B', disabled: true }, - { value: 'c', children: 'Option C', disabled: false }, + {value: 'a', children: 'Option A', disabled: true}, + {value: 'b', children: 'Option B', disabled: true}, + {value: 'c', children: 'Option C', disabled: false}, ], }), }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Open combobox await click(getComboboxButton()) @@ -3773,15 +3782,15 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use ArrowUp to navigate the combobox options', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Focus the input getComboboxInput()?.focus() @@ -3790,10 +3799,10 @@ describe.each([{ virtual: true }, { virtual: false }])( await press(Keys.ArrowUp) // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxButton({state: ComboboxState.Visible}) assertComboboxList({ state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, + attributes: {id: 'headlessui-combobox-options-3'}, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() @@ -3824,7 +3833,7 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use the End key to go to the last combobox option', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) @@ -3846,14 +3855,14 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use the End key to go to the last non disabled combobox option', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, setup: () => ({ options: [ - { value: 'a', children: 'Option A', disabled: false }, - { value: 'b', children: 'Option B', disabled: false }, - { value: 'c', children: 'Option C', disabled: true }, - { value: 'd', children: 'Option D', disabled: true }, + {value: 'a', children: 'Option A', disabled: false}, + {value: 'b', children: 'Option B', disabled: false}, + {value: 'c', children: 'Option C', disabled: true}, + {value: 'd', children: 'Option D', disabled: true}, ], }), }) @@ -3876,14 +3885,14 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use the End key to go to the first combobox option if that is the only non-disabled combobox option', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, setup: () => ({ options: [ - { value: 'a', children: 'Option A', disabled: false }, - { value: 'b', children: 'Option B', disabled: true }, - { value: 'c', children: 'Option C', disabled: true }, - { value: 'd', children: 'Option D', disabled: true }, + {value: 'a', children: 'Option A', disabled: false}, + {value: 'b', children: 'Option B', disabled: true}, + {value: 'c', children: 'Option C', disabled: true}, + {value: 'd', children: 'Option D', disabled: true}, ], }), }) @@ -3907,14 +3916,14 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should have no active combobox option upon End key press, when there are no non-disabled combobox options', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, setup: () => ({ options: [ - { value: 'a', children: 'Option A', disabled: true }, - { value: 'b', children: 'Option B', disabled: true }, - { value: 'c', children: 'Option C', disabled: true }, - { value: 'd', children: 'Option D', disabled: true }, + {value: 'a', children: 'Option A', disabled: true}, + {value: 'b', children: 'Option B', disabled: true}, + {value: 'c', children: 'Option C', disabled: true}, + {value: 'd', children: 'Option D', disabled: true}, ], }), }) @@ -3938,7 +3947,7 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use the PageDown key to go to the last combobox option', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) @@ -3960,14 +3969,14 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use the PageDown key to go to the last non disabled Combobox option', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, setup: () => ({ options: [ - { value: 'a', children: 'Option A', disabled: false }, - { value: 'b', children: 'Option B', disabled: false }, - { value: 'c', children: 'Option C', disabled: true }, - { value: 'd', children: 'Option D', disabled: true }, + {value: 'a', children: 'Option A', disabled: false}, + {value: 'b', children: 'Option B', disabled: false}, + {value: 'c', children: 'Option C', disabled: true}, + {value: 'd', children: 'Option D', disabled: true}, ], }), }) @@ -3993,14 +4002,14 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use the PageDown key to go to the first combobox option if that is the only non-disabled combobox option', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, setup: () => ({ options: [ - { value: 'a', children: 'Option A', disabled: false }, - { value: 'b', children: 'Option B', disabled: true }, - { value: 'c', children: 'Option C', disabled: true }, - { value: 'd', children: 'Option D', disabled: true }, + {value: 'a', children: 'Option A', disabled: false}, + {value: 'b', children: 'Option B', disabled: true}, + {value: 'c', children: 'Option C', disabled: true}, + {value: 'd', children: 'Option D', disabled: true}, ], }), }) @@ -4024,14 +4033,14 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should have no active combobox option upon PageDown key press, when there are no non-disabled combobox options', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, setup: () => ({ options: [ - { value: 'a', children: 'Option A', disabled: true }, - { value: 'b', children: 'Option B', disabled: true }, - { value: 'c', children: 'Option C', disabled: true }, - { value: 'd', children: 'Option D', disabled: true }, + {value: 'a', children: 'Option A', disabled: true}, + {value: 'b', children: 'Option B', disabled: true}, + {value: 'c', children: 'Option C', disabled: true}, + {value: 'd', children: 'Option D', disabled: true}, ], }), }) @@ -4055,7 +4064,7 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use the Home key to go to the first combobox option', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) @@ -4080,14 +4089,14 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use the Home key to go to the first non disabled combobox option', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, setup: () => ({ options: [ - { value: 'a', children: 'Option A', disabled: true }, - { value: 'b', children: 'Option B', disabled: true }, - { value: 'c', children: 'Option C', disabled: false }, - { value: 'd', children: 'Option D', disabled: false }, + {value: 'a', children: 'Option A', disabled: true}, + {value: 'b', children: 'Option B', disabled: true}, + {value: 'c', children: 'Option C', disabled: false}, + {value: 'd', children: 'Option D', disabled: false}, ], }), }) @@ -4112,14 +4121,14 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use the Home key to go to the last combobox option if that is the only non-disabled combobox option', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, setup: () => ({ options: [ - { value: 'a', children: 'Option A', disabled: true }, - { value: 'b', children: 'Option B', disabled: true }, - { value: 'c', children: 'Option C', disabled: true }, - { value: 'd', children: 'Option D', disabled: false }, + {value: 'a', children: 'Option A', disabled: true}, + {value: 'b', children: 'Option B', disabled: true}, + {value: 'c', children: 'Option C', disabled: true}, + {value: 'd', children: 'Option D', disabled: false}, ], }), }) @@ -4143,14 +4152,14 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should have no active combobox option upon Home key press, when there are no non-disabled combobox options', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, setup: () => ({ options: [ - { value: 'a', children: 'Option A', disabled: true }, - { value: 'b', children: 'Option B', disabled: true }, - { value: 'c', children: 'Option C', disabled: true }, - { value: 'd', children: 'Option D', disabled: true }, + {value: 'a', children: 'Option A', disabled: true}, + {value: 'b', children: 'Option B', disabled: true}, + {value: 'c', children: 'Option C', disabled: true}, + {value: 'd', children: 'Option D', disabled: true}, ], }), }) @@ -4174,7 +4183,7 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use the PageUp key to go to the first combobox option', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) @@ -4199,14 +4208,14 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use the PageUp key to go to the first non disabled combobox option', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, setup: () => ({ options: [ - { value: 'a', children: 'Option A', disabled: true }, - { value: 'b', children: 'Option B', disabled: true }, - { value: 'c', children: 'Option C', disabled: false }, - { value: 'd', children: 'Option D', disabled: false }, + {value: 'a', children: 'Option A', disabled: true}, + {value: 'b', children: 'Option B', disabled: true}, + {value: 'c', children: 'Option C', disabled: false}, + {value: 'd', children: 'Option D', disabled: false}, ], }), }) @@ -4230,14 +4239,14 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use the PageUp key to go to the last combobox option if that is the only non-disabled combobox option', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, setup: () => ({ options: [ - { value: 'a', children: 'Option A', disabled: true }, - { value: 'b', children: 'Option B', disabled: true }, - { value: 'c', children: 'Option C', disabled: true }, - { value: 'd', children: 'Option D', disabled: false }, + {value: 'a', children: 'Option A', disabled: true}, + {value: 'b', children: 'Option B', disabled: true}, + {value: 'c', children: 'Option C', disabled: true}, + {value: 'd', children: 'Option D', disabled: false}, ], }), }) @@ -4261,14 +4270,14 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should have no active combobox option upon PageUp key press, when there are no non-disabled combobox options', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, setup: () => ({ options: [ - { value: 'a', children: 'Option A', disabled: true }, - { value: 'b', children: 'Option B', disabled: true }, - { value: 'c', children: 'Option C', disabled: true }, - { value: 'd', children: 'Option D', disabled: true }, + {value: 'a', children: 'Option A', disabled: true}, + {value: 'b', children: 'Option B', disabled: true}, + {value: 'c', children: 'Option C', disabled: true}, + {value: 'd', children: 'Option D', disabled: true}, ], }), }) @@ -4313,7 +4322,7 @@ describe.each([{ virtual: true }, { virtual: false }])( setup: () => { let value = ref('bob') watch([value], () => handleChange(value.value)) - return { value, virtual } + return {value, virtual} }, }) @@ -4384,7 +4393,7 @@ describe.each([{ virtual: true }, { virtual: false }])( props: { people: { - type: Array as PropType<{ value: string; name: string; disabled: boolean }[]>, + type: Array as PropType<{value: string; name: string; disabled: boolean}[]>, required: true, }, }, @@ -4396,24 +4405,24 @@ describe.each([{ virtual: true }, { virtual: false }])( return query.value === '' ? props.people : props.people.filter((person) => - person.name.toLowerCase().includes(query.value.toLowerCase()) - ) + person.name.toLowerCase().includes(query.value.toLowerCase()) + ) }) return { value, query, filteredPeople, - setQuery: (event: Event & { target: HTMLInputElement }) => { + setQuery: (event: Event & {target: HTMLInputElement}) => { query.value = event.target.value }, virtual: computed(() => { return virtual ? { - options: filteredPeople.value, - disabled: (person: { value: string; name: string; disabled: boolean }) => - person?.disabled ?? false, - } + options: filteredPeople.value, + disabled: (person: {value: string; name: string; disabled: boolean}) => + person?.disabled ?? false, + } : null }), } @@ -4424,7 +4433,7 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to type a full word that has a perfect match', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html` { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html` { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html` `, }) @@ -4809,7 +4827,7 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should not focus the ComboboxInput when we right click the ComboboxLabel', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) @@ -4828,24 +4846,24 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should be possible to open the combobox by focusing the input with immediate mode enabled', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Focus the input await focus(getComboboxInput()) // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxButton({state: ComboboxState.Visible}) assertComboboxList({ state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, + attributes: {id: 'headlessui-combobox-options-3'}, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() @@ -4861,24 +4879,24 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should not be possible to open the combobox by focusing the input with immediate mode disabled', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-3' }, + attributes: {id: 'headlessui-combobox-button-3'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Focus the input await focus(getComboboxInput()) // Verify it is invisible - assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxButton({state: ComboboxState.InvisibleUnmounted}) assertComboboxList({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-options-3' }, + attributes: {id: 'headlessui-combobox-options-3'}, }) }) ) @@ -4887,24 +4905,24 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should not be possible to open the combobox by focusing the input with immediate mode enabled when button is disabled', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-3' }, + attributes: {id: 'headlessui-combobox-button-3'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Focus the input await focus(getComboboxInput()) // Verify it is invisible - assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxButton({state: ComboboxState.InvisibleUnmounted}) assertComboboxList({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-options-3' }, + attributes: {id: 'headlessui-combobox-options-3'}, }) }) ) @@ -4913,7 +4931,7 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should be possible to close a combobox on click with immediate mode enabled', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) @@ -4921,14 +4939,14 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', await click(getComboboxButton()) // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxButton({state: ComboboxState.Visible}) // Click to close await click(getComboboxButton()) // Verify it is closed - assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxButton({state: ComboboxState.InvisibleUnmounted}) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) assertActiveElement(getComboboxInput()) }) ) @@ -4937,24 +4955,24 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should be possible to close a focused combobox on click with immediate mode enabled', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) - assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxButton({state: ComboboxState.InvisibleUnmounted}) // Open combobox by focusing input await focus(getComboboxInput()) assertActiveElement(getComboboxInput()) // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxButton({state: ComboboxState.Visible}) // Click to close await click(getComboboxButton()) // Verify it is closed - assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxButton({state: ComboboxState.InvisibleUnmounted}) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) assertActiveElement(getComboboxInput()) }) ) @@ -4963,24 +4981,24 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should be possible to open the combobox on click', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Open combobox await click(getComboboxButton()) // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxButton({state: ComboboxState.Visible}) assertComboboxList({ state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, + attributes: {id: 'headlessui-combobox-options-3'}, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() @@ -4996,21 +5014,21 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should not be possible to open the combobox on right click', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Try to open the combobox await click(getComboboxButton(), MouseButton.Right) // Verify it is still closed - assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxButton({state: ComboboxState.InvisibleUnmounted}) }) ) @@ -5018,7 +5036,7 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should not be possible to open the combobox on click when the button is disabled', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html` { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Open combobox await click(getComboboxButton()) // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxButton({state: ComboboxState.Visible}) assertComboboxList({ state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, + attributes: {id: 'headlessui-combobox-options-3'}, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() @@ -5072,7 +5090,7 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) + options.forEach((option, i) => assertComboboxOption(option, {selected: i === 1})) // Verify that the second combobox option is active (because it is already selected) assertActiveComboboxOption(options[1]) @@ -5083,7 +5101,7 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should be possible to close a combobox on click', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) @@ -5091,14 +5109,14 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', await click(getComboboxButton()) // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxButton({state: ComboboxState.Visible}) // Click to close await click(getComboboxButton()) // Verify it is closed - assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxButton({state: ComboboxState.InvisibleUnmounted}) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) }) ) @@ -5106,18 +5124,18 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should be a no-op when we click outside of a closed combobox', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) // Verify that the window is closed - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Click something that is not related to the combobox await click(document.body) // Should still be closed - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) }) ) @@ -5127,7 +5145,7 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should be possible to click outside of the combobox which should close the combobox', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html`
after
@@ -5136,14 +5154,14 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', // Open combobox await click(getComboboxButton()) - assertComboboxList({ state: ComboboxState.Visible }) + assertComboboxList({state: ComboboxState.Visible}) assertActiveElement(getComboboxInput()) // Click something that is not related to the combobox await click(getByText('after')) // Should be closed now - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Verify the input is focused again assertActiveElement(getByText('after')) @@ -5154,7 +5172,7 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should be possible to click outside of the combobox on another combobox button which should close the current combobox and open the new combobox', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html`
@@ -5186,20 +5204,20 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should be possible to click outside of the combobox which should close the combobox (even if we press the combobox button)', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) // Open combobox await click(getComboboxButton()) - assertComboboxList({ state: ComboboxState.Visible }) + assertComboboxList({state: ComboboxState.Visible}) assertActiveElement(getComboboxInput()) // Click the combobox button again await click(getComboboxButton()) // Should be closed now - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Verify the input is focused again assertActiveElement(getComboboxInput()) @@ -5211,7 +5229,7 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', suppressConsoleLogs(async () => { let focusFn = jest.fn() renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html`
@@ -5229,13 +5247,13 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', await click(getComboboxButton()) // Ensure the combobox is open - assertComboboxList({ state: ComboboxState.Visible }) + assertComboboxList({state: ComboboxState.Visible}) // Click the span inside the button await click(getByText('Next')) // Ensure the combobox is closed - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) // Ensure the outside button is focused assertActiveElement(document.getElementById('btn')) @@ -5249,7 +5267,7 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should be possible to hover an option and make it active', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) @@ -5288,7 +5306,7 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', `, - setup: () => ({ value: ref(null), virtual }), + setup: () => ({value: ref(null), virtual}), }) await nextTick() @@ -5313,7 +5331,7 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should make a combobox option active when you move the mouse over it', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) @@ -5331,7 +5349,7 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should be a no-op when we move the mouse and the combobox option is already active', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) @@ -5355,13 +5373,13 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should be a no-op when we move the mouse and the combobox option is disabled', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, setup: () => ({ options: [ - { value: 'alice', children: 'alice', disabled: false }, - { value: 'bob', children: 'bob', disabled: true }, - { value: 'charlie', children: 'charlie', disabled: false }, + {value: 'alice', children: 'alice', disabled: false}, + {value: 'bob', children: 'bob', disabled: true}, + {value: 'charlie', children: 'charlie', disabled: false}, ], }), }) @@ -5380,13 +5398,13 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should not be possible to hover an option that is disabled', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, setup: () => ({ options: [ - { value: 'alice', children: 'alice', disabled: false }, - { value: 'bob', children: 'bob', disabled: true }, - { value: 'charlie', children: 'charlie', disabled: false }, + {value: 'alice', children: 'alice', disabled: false}, + {value: 'bob', children: 'bob', disabled: true}, + {value: 'charlie', children: 'charlie', disabled: false}, ], }), }) @@ -5408,7 +5426,7 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should be possible to mouse leave an option and make it inactive', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) @@ -5444,13 +5462,13 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should be possible to mouse leave a disabled option and be a no-op', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, setup: () => ({ options: [ - { value: 'alice', children: 'alice', disabled: false }, - { value: 'bob', children: 'bob', disabled: true }, - { value: 'charlie', children: 'charlie', disabled: false }, + {value: 'alice', children: 'alice', disabled: false}, + {value: 'bob', children: 'bob', disabled: true}, + {value: 'charlie', children: 'charlie', disabled: false}, ], }), }) @@ -5474,7 +5492,7 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', suppressConsoleLogs(async () => { let handleChange = jest.fn() renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, setup: () => { let value = ref(null) @@ -5490,14 +5508,14 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', // Open combobox await click(getComboboxButton()) - assertComboboxList({ state: ComboboxState.Visible }) + assertComboboxList({state: ComboboxState.Visible}) assertActiveElement(getComboboxInput()) let options = getComboboxOptions() // We should be able to click the first option await click(options[1]) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) expect(handleChange).toHaveBeenCalledTimes(1) expect(handleChange).toHaveBeenCalledWith('Option B') @@ -5516,7 +5534,7 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should be possible to click a combobox option, which closes the combobox with immediate mode enabled', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) @@ -5524,13 +5542,13 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', await focus(getComboboxInput()) assertActiveElement(getComboboxInput()) - assertComboboxList({ state: ComboboxState.Visible }) + assertComboboxList({state: ComboboxState.Visible}) let options = getComboboxOptions() // We should be able to click the first option await click(options[1]) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) }) ) @@ -5539,16 +5557,16 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', suppressConsoleLogs(async () => { let handleChange = jest.fn() renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, setup: () => { let value = ref(null) return { value, options: [ - { value: 'alice', children: 'Alice', disabled: false }, - { value: 'bob', children: 'Bob', disabled: true }, - { value: 'charlie', children: 'Charlie', disabled: false }, + {value: 'alice', children: 'Alice', disabled: false}, + {value: 'bob', children: 'Bob', disabled: true}, + {value: 'charlie', children: 'Charlie', disabled: false}, ], update(newValue: any) { value.value = newValue @@ -5560,14 +5578,14 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', // Open combobox await click(getComboboxButton()) - assertComboboxList({ state: ComboboxState.Visible }) + assertComboboxList({state: ComboboxState.Visible}) assertActiveElement(getComboboxInput()) let options = getComboboxOptions() // We should not be able to click the disabled option await click(options[1]) - assertComboboxList({ state: ComboboxState.Visible }) + assertComboboxList({state: ComboboxState.Visible}) assertNotActiveComboboxOption(options[1]) assertActiveElement(getComboboxInput()) expect(handleChange).toHaveBeenCalledTimes(0) @@ -5589,7 +5607,7 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should be possible focus a combobox option, so that it becomes active', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, setup: () => { let value = ref(null) @@ -5604,7 +5622,7 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', // Open combobox await click(getComboboxButton()) - assertComboboxList({ state: ComboboxState.Visible }) + assertComboboxList({state: ComboboxState.Visible}) assertActiveElement(getComboboxInput()) let options = getComboboxOptions() @@ -5622,20 +5640,20 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should not be possible to focus a combobox option which is disabled', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, setup: () => ({ options: [ - { value: 'alice', disabled: false, children: 'alice' }, - { value: 'bob', disabled: true, children: 'bob' }, - { value: 'charlie', disabled: false, children: 'charlie' }, + {value: 'alice', disabled: false, children: 'alice'}, + {value: 'bob', disabled: true, children: 'bob'}, + {value: 'charlie', disabled: false, children: 'charlie'}, ], }), }) // Open combobox await click(getComboboxButton()) - assertComboboxList({ state: ComboboxState.Visible }) + assertComboboxList({state: ComboboxState.Visible}) assertActiveElement(getComboboxInput()) let options = getComboboxOptions() @@ -5650,23 +5668,23 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should be possible to hold the last active option', suppressConsoleLogs(async () => { renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html``, }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({state: ComboboxState.InvisibleUnmounted}) await click(getComboboxButton()) assertComboboxButton({ state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: {id: 'headlessui-combobox-button-2'}, }) - assertComboboxList({ state: ComboboxState.Visible }) + assertComboboxList({state: ComboboxState.Visible}) let options = getComboboxOptions() @@ -5716,7 +5734,7 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', `, setup: () => ({ value: ref('bob'), - virtual: computed(() => (virtual ? { options: ['alice', 'bob', 'charlie'] } : null)), + virtual: computed(() => (virtual ? {options: ['alice', 'bob', 'charlie']} : null)), }), }) @@ -5763,15 +5781,15 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', `, setup: () => { let people = [ - { id: 1, name: 'Alice' }, - { id: 2, name: 'Bob' }, - { id: 3, name: 'Charlie' }, + {id: 1, name: 'Alice'}, + {id: 2, name: 'Bob'}, + {id: 3, name: 'Charlie'}, ] return { people, value: ref(people[1]), - virtual: computed(() => (virtual ? { options: people } : null)), + virtual: computed(() => (virtual ? {options: people} : null)), } }, }) @@ -5797,12 +5815,12 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should sync the input field correctly and reset it when resetting the value from outside (when using displayValue)', suppressConsoleLogs(async () => { let people = [ - { id: 1, name: 'Alice' }, - { id: 2, name: 'Bob' }, - { id: 3, name: 'Charlie' }, + {id: 1, name: 'Alice'}, + {id: 2, name: 'Bob'}, + {id: 3, name: 'Charlie'}, ] renderTemplate({ - components: { MyCombobox }, + components: {MyCombobox}, template: html` { `, - setup: () => ({ value: ref(['bob', 'charlie']) }), + setup: () => ({value: ref(['bob', 'charlie'])}), }) // Open combobox await click(getComboboxButton()) // Verify that we have an open combobox with multiple mode - assertCombobox({ state: ComboboxState.Visible, mode: ComboboxMode.Multiple }) + assertCombobox({state: ComboboxState.Visible, mode: ComboboxMode.Multiple}) // Verify that we have multiple selected combobox options let options = getComboboxOptions() - assertComboboxOption(options[0], { selected: false }) - assertComboboxOption(options[1], { selected: true }) - assertComboboxOption(options[2], { selected: true }) + assertComboboxOption(options[0], {selected: false}) + assertComboboxOption(options[1], {selected: true}) + assertComboboxOption(options[2], {selected: true}) }) ) @@ -5890,7 +5908,7 @@ describe('Multi-select', () => { `, - setup: () => ({ value: ref(['bob', 'charlie']) }), + setup: () => ({value: ref(['bob', 'charlie'])}), }) // Open combobox @@ -5916,18 +5934,18 @@ describe('Multi-select', () => { `, - setup: () => ({ value: ref(['bob', 'charlie']) }), + setup: () => ({value: ref(['bob', 'charlie'])}), }) // Open combobox await click(getComboboxButton()) - assertCombobox({ state: ComboboxState.Visible }) + assertCombobox({state: ComboboxState.Visible}) // Verify that bob is the active option await click(getComboboxOptions()[0]) // Verify that the combobox is still open - assertCombobox({ state: ComboboxState.Visible }) + assertCombobox({state: ComboboxState.Visible}) }) ) @@ -5946,32 +5964,32 @@ describe('Multi-select', () => { `, - setup: () => ({ value: ref(['bob', 'charlie']) }), + setup: () => ({value: ref(['bob', 'charlie'])}), }) // Open combobox await click(getComboboxButton()) - assertCombobox({ state: ComboboxState.Visible }) + assertCombobox({state: ComboboxState.Visible}) let options = getComboboxOptions() - assertComboboxOption(options[0], { selected: false }) - assertComboboxOption(options[1], { selected: true }) - assertComboboxOption(options[2], { selected: true }) + assertComboboxOption(options[0], {selected: false}) + assertComboboxOption(options[1], {selected: true}) + assertComboboxOption(options[2], {selected: true}) // Click on bob await click(getComboboxOptions()[1]) - assertComboboxOption(options[0], { selected: false }) - assertComboboxOption(options[1], { selected: false }) - assertComboboxOption(options[2], { selected: true }) + assertComboboxOption(options[0], {selected: false}) + assertComboboxOption(options[1], {selected: false}) + assertComboboxOption(options[2], {selected: true}) // Click on bob again await click(getComboboxOptions()[1]) - assertComboboxOption(options[0], { selected: false }) - assertComboboxOption(options[1], { selected: true }) - assertComboboxOption(options[2], { selected: true }) + assertComboboxOption(options[0], {selected: false}) + assertComboboxOption(options[1], {selected: true}) + assertComboboxOption(options[2], {selected: true}) }) ) @@ -5992,39 +6010,39 @@ describe('Multi-select', () => { `, setup: () => { let people = [ - { id: 1, name: 'alice' }, - { id: 2, name: 'bob' }, - { id: 3, name: 'charlie' }, + {id: 1, name: 'alice'}, + {id: 2, name: 'bob'}, + {id: 3, name: 'charlie'}, ] let value = ref([people[1], people[2]]) - return { people, value } + return {people, value} }, }) // Open combobox await click(getComboboxButton()) - assertCombobox({ state: ComboboxState.Visible }) + assertCombobox({state: ComboboxState.Visible}) let options = getComboboxOptions() - assertComboboxOption(options[0], { selected: false }) - assertComboboxOption(options[1], { selected: true }) - assertComboboxOption(options[2], { selected: true }) + assertComboboxOption(options[0], {selected: false}) + assertComboboxOption(options[1], {selected: true}) + assertComboboxOption(options[2], {selected: true}) // Click on bob await click(getComboboxOptions()[1]) - assertComboboxOption(options[0], { selected: false }) - assertComboboxOption(options[1], { selected: false }) - assertComboboxOption(options[2], { selected: true }) + assertComboboxOption(options[0], {selected: false}) + assertComboboxOption(options[1], {selected: false}) + assertComboboxOption(options[2], {selected: true}) // Click on bob again await click(getComboboxOptions()[1]) - assertComboboxOption(options[0], { selected: false }) - assertComboboxOption(options[1], { selected: true }) - assertComboboxOption(options[2], { selected: true }) + assertComboboxOption(options[0], {selected: false}) + assertComboboxOption(options[1], {selected: true}) + assertComboboxOption(options[2], {selected: true}) }) ) @@ -6050,13 +6068,13 @@ describe('Multi-select', () => { let users = ['alice', 'bob', 'charlie'] let value = ref([]) - return { users, value } + return {users, value} }, }) // Open combobox await click(getComboboxButton()) - assertCombobox({ state: ComboboxState.Visible }) + assertCombobox({state: ComboboxState.Visible}) let options = getComboboxOptions() @@ -6249,19 +6267,19 @@ describe('Form compatibility', () => { id: 1, value: 'pickup', label: 'Pickup', - extra: { info: 'Some extra info' }, + extra: {info: 'Some extra info'}, }, { id: 2, value: 'home-delivery', label: 'Home delivery', - extra: { info: 'Some extra info' }, + extra: {info: 'Some extra info'}, }, { id: 3, value: 'dine-in', label: 'Dine in', - extra: { info: 'Some extra info' }, + extra: {info: 'Some extra info'}, }, ]) let value = ref(options.value[0]) diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.ts b/packages/@headlessui-vue/src/components/combobox/combobox.ts index 27b2a1c..b5149f2 100644 --- a/packages/@headlessui-vue/src/components/combobox/combobox.ts +++ b/packages/@headlessui-vue/src/components/combobox/combobox.ts @@ -151,6 +151,8 @@ let VirtualProvider = defineComponent({ }) let virtualizer = useVirtualizer( + // @ts-expect-error TODO: Drop when using `pnpm` and `@tanstack/virtual-vue` + // has been rolled back to the older version. computed(() => { return { scrollPaddingStart: padding.value.start, @@ -173,6 +175,8 @@ let VirtualProvider = defineComponent({ baseKey.value += 1 }) + // @ts-expect-error TODO: Drop when using `pnpm` and `@tanstack/virtual-vue` + // has been rolled back to the older version. provide(VirtualContext, api.virtual.value ? virtualizer : null) return () => { diff --git a/patches/@tanstack+virtual-core+3.13.6.patch b/patches/@tanstack+virtual-core+3.13.6.patch new file mode 100644 index 0000000..0f1c0f6 --- /dev/null +++ b/patches/@tanstack+virtual-core+3.13.6.patch @@ -0,0 +1,51 @@ +diff --git a/node_modules/@tanstack/virtual-core/dist/cjs/index.cjs b/node_modules/@tanstack/virtual-core/dist/cjs/index.cjs +index dd4e77f..127987e 100644 +--- a/node_modules/@tanstack/virtual-core/dist/cjs/index.cjs ++++ b/node_modules/@tanstack/virtual-core/dist/cjs/index.cjs +@@ -665,9 +665,9 @@ class Virtualizer { + this.options.getItemKey(index) + ); + if (elementInDOM) { +- const [latestOffset] = utils.notUndefined( +- this.getOffsetForIndex(index, align) +- ); ++ const result = this.getOffsetForIndex(index, align) ++ if (!result) return ++ const [latestOffset] = result + if (!utils.approxEqual(latestOffset, this.getScrollOffset())) { + this.scrollToIndex(index, { align, behavior }); + } +diff --git a/node_modules/@tanstack/virtual-core/dist/esm/index.js b/node_modules/@tanstack/virtual-core/dist/esm/index.js +index 8da519d..8c78af8 100644 +--- a/node_modules/@tanstack/virtual-core/dist/esm/index.js ++++ b/node_modules/@tanstack/virtual-core/dist/esm/index.js +@@ -663,9 +663,9 @@ class Virtualizer { + this.options.getItemKey(index) + ); + if (elementInDOM) { +- const [latestOffset] = notUndefined( +- this.getOffsetForIndex(index, align) +- ); ++ const result = this.getOffsetForIndex(index, align) ++ if (!result) return ++ const [latestOffset] = result + if (!approxEqual(latestOffset, this.getScrollOffset())) { + this.scrollToIndex(index, { align, behavior }); + } +diff --git a/node_modules/@tanstack/virtual-core/src/index.ts b/node_modules/@tanstack/virtual-core/src/index.ts +index 3a0c446..4a9e792 100644 +--- a/node_modules/@tanstack/virtual-core/src/index.ts ++++ b/node_modules/@tanstack/virtual-core/src/index.ts +@@ -1003,9 +1003,9 @@ export class Virtualizer< + ) + + if (elementInDOM) { +- const [latestOffset] = notUndefined( +- this.getOffsetForIndex(index, align), +- ) ++ const result = this.getOffsetForIndex(index, align) ++ if (!result) return ++ const [latestOffset] = result + + if (!approxEqual(latestOffset, this.getScrollOffset())) { + this.scrollToIndex(index, { align, behavior }) diff --git a/playgrounds/react/pages/combobox/combobox-countries.tsx b/playgrounds/react/pages/combobox/combobox-countries.tsx index 4afa861..045c07e 100644 --- a/playgrounds/react/pages/combobox/combobox-countries.tsx +++ b/playgrounds/react/pages/combobox/combobox-countries.tsx @@ -75,7 +75,7 @@ export default function Home() { {countries.map((country) => (