From 97cb20806a6a909872e9326eb6f43aab4f08e6ab Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 17 Apr 2025 15:03:58 +0200 Subject: [PATCH] Improve `Combobox` component performance (#3697) This PR improves the performance of the `Combobox` component. This is a similar implementation as: - https://github.com/tailwindlabs/headlessui/pull/3685 - https://github.com/tailwindlabs/headlessui/pull/3688 Before this PR, the `Combobox` component is built in a way where all the state lives in the `Combobox` itself. If state changes, everything re-renders and re-computes the necessary derived state. However, if you have a 1000 items, then every time the active item changes, all 1000 items have to re-render. To solve this, we can move the state outside of the `Combobox` component, and "subscribe" to state changes using the `useSlice` hook introduced in https://github.com/tailwindlabs/headlessui/pull/3684. This will allow us to subscribe to a slice of the state, and only re-render if the computed slice actually changes. If the active item changes, only 3 things will happen: 1. The `ComboboxOptions` will re-render and have an updated `aria-activedescendant` 2. The `ComboboxOption` that _was_ active, will re-render and the `data-focus` attribute wil be removed. 3. The `ComboboxOption` that is now active, will re-render and the `data-focus` attribute wil be added. The `Combobox` component already has a `virtual` option if you want to render many many more items. This is a bit of a different model where all the options are passed in via an array instead of rendering all `ComboboxOption` components immediately. Because of this, I didn't want to batch the registration of the options as part of this PR (similar to what we do in the `Menu` and `Listbox`) because it behaves differently compared to what mode you are using (virtual or not). Since not all components will be rendered, batching the registration until everything is registered doesn't really make sense in the general case. However, it does make sense in non-virtual mode. But because of this difference, I didn't want to implement this as part of this PR and increase the complexity of the PR even more. Instead I will follow up with more PRs with more improvements. But the key improvement of looking at the slice of the data is what makes the biggest impact. This also means that we can do another release once this is merged. Last but not least, recently we fixed a bug where the `Combobox` in `virtual` mode could crash if you search for an item that doesn't exist. To solve it, we implemented a workaround in: - https://github.com/tailwindlabs/headlessui/pull/3678 Which used a double `requestAnimationFrame` to delay the scrolling to the item. While this solved this issue, this also caused visual flicker when holding down your arrow keys. I also fixed it in this PR by introducing `patch-package` and work around the issue in the `@tanstack/virtual-core` package itself. More info: 96f4da70b16d5cf259643 Before: https://github.com/user-attachments/assets/132520d3-b4d6-42f9-9152-57427de20639 After: https://github.com/user-attachments/assets/41f198fe-9326-42d1-a09f-077b60a9f65d ## Test plan 1. All tests still pass 2. Tested this in the browser with a 1000 items. In the videos below the only thing I'm doing is holding down the `ArrowDown` key. Before: https://github.com/user-attachments/assets/945692a3-96e6-4ac7-bee0-36a1fd89172b After: https://github.com/user-attachments/assets/98a151d0-16cc-4823-811c-fcee0019937a --- package-lock.json | 488 ++++++- package.json | 4 +- packages/@headlessui-react/CHANGELOG.md | 1 + packages/@headlessui-react/package.json | 2 +- .../combobox/combobox-machine-glue.tsx | 20 + .../components/combobox/combobox-machine.ts | 617 ++++++++ .../src/components/combobox/combobox.tsx | 1072 ++++---------- packages/@headlessui-vue/package.json | 2 +- .../src/components/combobox/combobox.test.ts | 1244 +++++++++-------- .../src/components/combobox/combobox.ts | 4 + patches/@tanstack+virtual-core+3.13.6.patch | 51 + .../pages/combobox/combobox-countries.tsx | 2 +- 12 files changed, 2042 insertions(+), 1465 deletions(-) create mode 100644 packages/@headlessui-react/src/components/combobox/combobox-machine-glue.tsx create mode 100644 packages/@headlessui-react/src/components/combobox/combobox-machine.ts create mode 100644 patches/@tanstack+virtual-core+3.13.6.patch 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) => (