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
This commit is contained in:
Robin Malfait
2025-04-17 15:03:58 +02:00
parent c5f95b02af
commit 97cb20806a
12 changed files with 2042 additions and 1465 deletions
+437 -51
View File
@@ -7,6 +7,7 @@
"": { "": {
"name": "headlessui", "name": "headlessui",
"version": "0.0.0", "version": "0.0.0",
"hasInstallScript": true,
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
"packages/*", "packages/*",
@@ -25,6 +26,7 @@
"jest": "26", "jest": "26",
"lint-staged": "^12.2.1", "lint-staged": "^12.2.1",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"patch-package": "^8.0.0",
"prettier": "^3.1.0", "prettier": "^3.1.0",
"prettier-plugin-organize-imports": "^3.2.4", "prettier-plugin-organize-imports": "^3.2.4",
"prettier-plugin-tailwindcss": "^0.6.11", "prettier-plugin-tailwindcss": "^0.6.11",
@@ -2638,12 +2640,12 @@
} }
}, },
"node_modules/@tanstack/react-virtual": { "node_modules/@tanstack/react-virtual": {
"version": "3.11.1", "version": "3.13.6",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.11.1.tgz", "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.6.tgz",
"integrity": "sha512-orn2QNe5tF6SqjucHJ6cKTKcRDe3GG7bcYqPNn72Yejj7noECdzgAyRfGt2pGDPemhYim3d1HIR/dgruCnLfUA==", "integrity": "sha512-WT7nWs8ximoQ0CDx/ngoFP7HbQF9Q2wQe4nh2NB+u2486eX3nZRE40P9g6ccCVq7ZfTSH5gFOuCoVH5DLNS/aA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@tanstack/virtual-core": "3.10.9" "@tanstack/virtual-core": "3.13.6"
}, },
"funding": { "funding": {
"type": "github", "type": "github",
@@ -2654,22 +2656,30 @@
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/@tanstack/react-virtual/node_modules/@tanstack/virtual-core": { "node_modules/@tanstack/virtual-core": {
"version": "3.10.9", "version": "3.13.6",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.10.9.tgz", "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.6.tgz",
"integrity": "sha512-kBknKOKzmeR7lN+vSadaKWXaLS0SZZG+oqpQ/k80Q6g9REn6zRHS/ZYdrIzHnpHgy/eWs00SujveUN/GJT2qTw==", "integrity": "sha512-cnQUeWnhNP8tJ4WsGcYiX24Gjkc9ALstLbHcBj1t3E7EimN6n6kHH+DPV4PpDnuw00NApQp+ViojMj1GRdwYQg==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/tannerlinsley" "url": "https://github.com/sponsors/tannerlinsley"
} }
}, },
"node_modules/@tanstack/virtual-core": { "node_modules/@tanstack/vue-virtual": {
"version": "3.0.0-beta.60", "version": "3.13.6",
"resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.6.tgz",
"integrity": "sha512-GYdZ3SJBQPzgxhuCE2fvpiH46qzHiVx5XzBSdtESgiqh4poj8UgckjGWYEhxaBbcVt1oLzh1m3Ql4TyH32TOzQ==",
"license": "MIT", "license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.13.6"
},
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/tannerlinsley" "url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"vue": "^2.7.0 || ^3.0.0"
} }
}, },
"node_modules/@testing-library/dom": { "node_modules/@testing-library/dom": {
@@ -3141,6 +3151,7 @@
}, },
"node_modules/@vue/compiler-core": { "node_modules/@vue/compiler-core": {
"version": "3.2.37", "version": "3.2.37",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/parser": "^7.16.4", "@babel/parser": "^7.16.4",
@@ -3151,6 +3162,7 @@
}, },
"node_modules/@vue/compiler-dom": { "node_modules/@vue/compiler-dom": {
"version": "3.2.37", "version": "3.2.37",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/compiler-core": "3.2.37", "@vue/compiler-core": "3.2.37",
@@ -3218,6 +3230,7 @@
}, },
"node_modules/@vue/compiler-ssr": { "node_modules/@vue/compiler-ssr": {
"version": "3.2.37", "version": "3.2.37",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.2.37", "@vue/compiler-dom": "3.2.37",
@@ -3238,6 +3251,7 @@
}, },
"node_modules/@vue/reactivity-transform": { "node_modules/@vue/reactivity-transform": {
"version": "3.2.37", "version": "3.2.37",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/parser": "^7.16.4", "@babel/parser": "^7.16.4",
@@ -3330,8 +3344,16 @@
}, },
"node_modules/@vue/shared": { "node_modules/@vue/shared": {
"version": "3.2.37", "version": "3.2.37",
"dev": true,
"license": "MIT" "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": { "node_modules/abab": {
"version": "2.0.6", "version": "2.0.6",
"dev": true, "dev": true,
@@ -3577,6 +3599,16 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/atob": {
"version": "2.1.2", "version": "2.1.2",
"dev": true, "dev": true,
@@ -3858,13 +3890,50 @@
} }
}, },
"node_modules/call-bind": { "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, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"function-bind": "^1.1.2", "call-bind-apply-helpers": "^1.0.0",
"get-intrinsic": "^1.2.1", "es-define-property": "^1.0.0",
"set-function-length": "^1.1.1" "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": { "funding": {
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
@@ -4405,16 +4474,21 @@
} }
}, },
"node_modules/define-data-property": { "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, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"get-intrinsic": "^1.2.1", "es-define-property": "^1.0.0",
"gopd": "^1.0.1", "es-errors": "^1.3.0",
"has-property-descriptors": "^1.0.0" "gopd": "^1.0.1"
}, },
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/define-properties": { "node_modules/define-properties": {
@@ -4529,6 +4603,21 @@
"node": ">=8" "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": { "node_modules/eastasianwidth": {
"version": "0.2.0", "version": "0.2.0",
"license": "MIT" "license": "MIT"
@@ -4681,6 +4770,26 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/es-get-iterator": {
"version": "1.1.3", "version": "1.1.3",
"dev": true, "dev": true,
@@ -4700,6 +4809,19 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/es-set-tostringtag": {
"version": "2.0.2", "version": "2.0.2",
"dev": true, "dev": true,
@@ -5364,6 +5486,16 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/flatpickr": {
"version": "4.6.13", "version": "4.6.13",
"license": "MIT" "license": "MIT"
@@ -5458,6 +5590,32 @@
"tslib": "^2.1.0" "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": { "node_modules/fs.realpath": {
"version": "1.0.0", "version": "1.0.0",
"dev": true, "dev": true,
@@ -5523,14 +5681,25 @@
} }
}, },
"node_modules/get-intrinsic": { "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, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "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", "function-bind": "^1.1.2",
"has-proto": "^1.0.1", "get-proto": "^1.0.1",
"has-symbols": "^1.0.3", "gopd": "^1.2.0",
"hasown": "^2.0.0" "has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
@@ -5544,6 +5713,20 @@
"node": ">=8.0.0" "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": { "node_modules/get-stream": {
"version": "6.0.1", "version": "6.0.1",
"dev": true, "dev": true,
@@ -5641,11 +5824,13 @@
} }
}, },
"node_modules/gopd": { "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, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "engines": {
"get-intrinsic": "^1.1.3" "node": ">= 0.4"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
@@ -5678,11 +5863,13 @@
} }
}, },
"node_modules/has-property-descriptors": { "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, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"get-intrinsic": "^1.2.2" "es-define-property": "^1.0.0"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
@@ -5700,7 +5887,9 @@
} }
}, },
"node_modules/has-symbols": { "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, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -5761,7 +5950,9 @@
} }
}, },
"node_modules/hasown": { "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", "license": "MIT",
"dependencies": { "dependencies": {
"function-bind": "^1.1.2" "function-bind": "^1.1.2"
@@ -6126,7 +6317,6 @@
"version": "2.2.1", "version": "2.2.1",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"bin": { "bin": {
"is-docker": "cli.js" "is-docker": "cli.js"
}, },
@@ -6372,7 +6562,6 @@
"version": "2.2.0", "version": "2.2.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"is-docker": "^2.0.0" "is-docker": "^2.0.0"
}, },
@@ -7289,6 +7478,26 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/json5": {
"version": "2.2.3", "version": "2.2.3",
"dev": true, "dev": true,
@@ -7305,6 +7514,39 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/kind-of": {
"version": "3.2.2", "version": "3.2.2",
"dev": true, "dev": true,
@@ -7316,6 +7558,16 @@
"node": ">=0.10.0" "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": { "node_modules/kleur": {
"version": "3.0.3", "version": "3.0.3",
"dev": true, "dev": true,
@@ -7820,6 +8072,7 @@
}, },
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.25.9", "version": "0.25.9",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"sourcemap-codec": "^1.4.8" "sourcemap-codec": "^1.4.8"
@@ -7932,6 +8185,16 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/memorystream": {
"version": "0.3.1", "version": "0.3.1",
"dev": true, "dev": true,
@@ -8600,6 +8863,23 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/opencollective-postinstall": {
"version": "2.0.3", "version": "2.0.3",
"dev": true, "dev": true,
@@ -8608,6 +8888,16 @@
"opencollective-postinstall": "index.js" "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": { "node_modules/p-each-series": {
"version": "2.2.0", "version": "2.2.0",
"dev": true, "dev": true,
@@ -8715,6 +9005,90 @@
"node": ">=0.10.0" "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": { "node_modules/path-exists": {
"version": "4.0.0", "version": "4.0.0",
"dev": true, "dev": true,
@@ -10029,14 +10403,18 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/set-function-length": { "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, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"define-data-property": "^1.1.1", "define-data-property": "^1.1.4",
"get-intrinsic": "^1.2.1", "es-errors": "^1.3.0",
"function-bind": "^1.1.2",
"get-intrinsic": "^1.2.4",
"gopd": "^1.0.1", "gopd": "^1.0.1",
"has-property-descriptors": "^1.0.0" "has-property-descriptors": "^1.0.2"
}, },
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -10586,6 +10964,7 @@
}, },
"node_modules/source-map": { "node_modules/source-map": {
"version": "0.6.1", "version": "0.6.1",
"dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -10628,6 +11007,7 @@
}, },
"node_modules/sourcemap-codec": { "node_modules/sourcemap-codec": {
"version": "1.4.8", "version": "1.4.8",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/spdx-correct": { "node_modules/spdx-correct": {
@@ -11163,6 +11543,19 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/tmpl": {
"version": "1.0.5", "version": "1.0.5",
"dev": true, "dev": true,
@@ -11997,7 +12390,7 @@
"@floating-ui/react": "^0.26.16", "@floating-ui/react": "^0.26.16",
"@react-aria/focus": "^3.17.1", "@react-aria/focus": "^3.17.1",
"@react-aria/interactions": "^3.21.3", "@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" "use-sync-external-store": "^1.5.0"
}, },
"devDependencies": { "devDependencies": {
@@ -12056,7 +12449,7 @@
"version": "1.7.22", "version": "1.7.22",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@tanstack/vue-virtual": "3.0.0-beta.60" "@tanstack/vue-virtual": "3.13.6"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/vue": "8.0.0", "@testing-library/vue": "8.0.0",
@@ -12070,20 +12463,6 @@
"vue": "^3.2.0" "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": { "packages/@headlessui-vue/node_modules/@testing-library/dom": {
"version": "9.3.3", "version": "9.3.3",
"dev": true, "dev": true,
@@ -12121,6 +12500,7 @@
}, },
"packages/@headlessui-vue/node_modules/@vue/compiler-sfc": { "packages/@headlessui-vue/node_modules/@vue/compiler-sfc": {
"version": "3.2.37", "version": "3.2.37",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/parser": "^7.16.4", "@babel/parser": "^7.16.4",
@@ -12137,6 +12517,7 @@
}, },
"packages/@headlessui-vue/node_modules/@vue/reactivity": { "packages/@headlessui-vue/node_modules/@vue/reactivity": {
"version": "3.2.37", "version": "3.2.37",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/shared": "3.2.37" "@vue/shared": "3.2.37"
@@ -12144,6 +12525,7 @@
}, },
"packages/@headlessui-vue/node_modules/@vue/runtime-core": { "packages/@headlessui-vue/node_modules/@vue/runtime-core": {
"version": "3.2.37", "version": "3.2.37",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/reactivity": "3.2.37", "@vue/reactivity": "3.2.37",
@@ -12152,6 +12534,7 @@
}, },
"packages/@headlessui-vue/node_modules/@vue/runtime-dom": { "packages/@headlessui-vue/node_modules/@vue/runtime-dom": {
"version": "3.2.37", "version": "3.2.37",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/runtime-core": "3.2.37", "@vue/runtime-core": "3.2.37",
@@ -12161,6 +12544,7 @@
}, },
"packages/@headlessui-vue/node_modules/@vue/server-renderer": { "packages/@headlessui-vue/node_modules/@vue/server-renderer": {
"version": "3.2.37", "version": "3.2.37",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/compiler-ssr": "3.2.37", "@vue/compiler-ssr": "3.2.37",
@@ -12201,6 +12585,7 @@
}, },
"packages/@headlessui-vue/node_modules/csstype": { "packages/@headlessui-vue/node_modules/csstype": {
"version": "2.6.21", "version": "2.6.21",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"packages/@headlessui-vue/node_modules/pretty-format": { "packages/@headlessui-vue/node_modules/pretty-format": {
@@ -12218,6 +12603,7 @@
}, },
"packages/@headlessui-vue/node_modules/vue": { "packages/@headlessui-vue/node_modules/vue": {
"version": "3.2.37", "version": "3.2.37",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.2.37", "@vue/compiler-dom": "3.2.37",
+3 -1
View File
@@ -25,7 +25,8 @@
"lint-types": "CI=true npm run lint-types --workspaces --if-present", "lint-types": "CI=true npm run lint-types --workspaces --if-present",
"release-channel": "node ./scripts/release-channel.js", "release-channel": "node ./scripts/release-channel.js",
"release-notes": "node ./scripts/release-notes.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": { "husky": {
"hooks": { "hooks": {
@@ -76,6 +77,7 @@
"jest": "26", "jest": "26",
"lint-staged": "^12.2.1", "lint-staged": "^12.2.1",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"patch-package": "^8.0.0",
"prettier": "^3.1.0", "prettier": "^3.1.0",
"prettier-plugin-organize-imports": "^3.2.4", "prettier-plugin-organize-imports": "^3.2.4",
"prettier-plugin-tailwindcss": "^0.6.11", "prettier-plugin-tailwindcss": "^0.6.11",
+1
View File
@@ -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)) - 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)) - 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)) - 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 ## [2.2.1] - 2025-04-04
+1 -1
View File
@@ -59,7 +59,7 @@
"@floating-ui/react": "^0.26.16", "@floating-ui/react": "^0.26.16",
"@react-aria/focus": "^3.17.1", "@react-aria/focus": "^3.17.1",
"@react-aria/interactions": "^3.21.3", "@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" "use-sync-external-store": "^1.5.0"
} }
} }
@@ -0,0 +1,20 @@
import { createContext, useContext, useMemo } from 'react'
import { ComboboxMachine } from './combobox-machine'
export const ComboboxContext = createContext<ComboboxMachine<unknown> | null>(null)
export function useComboboxMachineContext<T>(component: string) {
let context = useContext(ComboboxContext)
if (context === null) {
let err = new Error(`<${component} /> is missing a parent <Combobox /> component.`)
if (Error.captureStackTrace) Error.captureStackTrace(err, useComboboxMachine)
throw err
}
return context as ComboboxMachine<T>
}
export function useComboboxMachine({
virtual = null,
__demoMode = false,
}: Parameters<typeof ComboboxMachine.new>[0] = {}) {
return useMemo(() => ComboboxMachine.new({ virtual, __demoMode }), [])
}
@@ -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<T> {
current: T
}
export enum ComboboxState {
Open,
Closed,
}
export enum ValueMode {
Single,
Multi,
}
export enum ActivationTrigger {
Pointer,
Focus,
Other,
}
export type ComboboxOptionDataRef<T> = MutableRefObject<{
disabled: boolean
value: T
domRef: MutableRefObject<HTMLElement | null>
order: number | null
}>
export interface State<T> {
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<T> }[]
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<T>(
state: State<T>,
adjustment: (options: State<T>['options']) => State<T>['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<T> =
| { 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<Focus, Focus.Specific>
trigger?: ActivationTrigger
}
| {
type: ActionTypes.RegisterOption
payload: { id: string; dataRef: ComboboxOptionDataRef<T> }
}
| { 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]: <T>(state: State<T>, action: Extract<Actions<T>, { type: P }>) => State<T>
} = {
[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<T> extends Machine<State<T>, Actions<T>> {
static new<T, TMultiple extends boolean | undefined>({
virtual = null,
__demoMode = false,
}: {
virtual?: {
options: TMultiple extends true ? EnsureArray<NoInfer<T>> : NoInfer<T>[]
disabled?: (
value: TMultiple extends true ? EnsureArray<NoInfer<T>>[number] : NoInfer<T>
) => 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<T>) => {
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<Focus, Focus.Specific> },
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<T>) => {
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<T>) => {
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<T>) => {
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<T>, 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<T>, 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<State<T>>, action: Actions<T>): State<T> {
return match(action.type, reducers, state, action) as State<T>
}
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -50,6 +50,6 @@
"vue": "3.2.37" "vue": "3.2.37"
}, },
"dependencies": { "dependencies": {
"@tanstack/vue-virtual": "3.0.0-beta.60" "@tanstack/vue-virtual": "3.13.6"
} }
} }
File diff suppressed because it is too large Load Diff
@@ -151,6 +151,8 @@ let VirtualProvider = defineComponent({
}) })
let virtualizer = useVirtualizer<HTMLDivElement, HTMLLIElement>( let virtualizer = useVirtualizer<HTMLDivElement, HTMLLIElement>(
// @ts-expect-error TODO: Drop when using `pnpm` and `@tanstack/virtual-vue`
// has been rolled back to the older version.
computed(() => { computed(() => {
return { return {
scrollPaddingStart: padding.value.start, scrollPaddingStart: padding.value.start,
@@ -173,6 +175,8 @@ let VirtualProvider = defineComponent({
baseKey.value += 1 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) provide(VirtualContext, api.virtual.value ? virtualizer : null)
return () => { return () => {
@@ -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 })
@@ -75,7 +75,7 @@ export default function Home() {
<Combobox.Options <Combobox.Options
transition transition
anchor="bottom start" anchor="bottom start"
className="focus:outline-hidden data-closed:opacity-0 w-[calc(var(--input-width)+var(--button-width))] overflow-auto rounded-md bg-white py-1 text-base leading-6 shadow-lg transition duration-1000 [--anchor-gap:--spacing(1)] [--anchor-max-height:--spacing(60)] sm:text-sm sm:leading-5" className="focus:outline-hidden data-closed:opacity-0 w-[calc(var(--input-width)+var(--button-width))] overflow-auto rounded-md bg-white py-1 text-base leading-6 shadow-lg transition duration-300 [--anchor-gap:--spacing(1)] [--anchor-max-height:--spacing(60)] sm:text-sm sm:leading-5"
> >
{countries.map((country) => ( {countries.map((country) => (
<Combobox.Option <Combobox.Option