@parca/profile 0.19.125 → 0.19.126

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,10 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [0.19.126](https://github.com/parca-dev/parca/compare/@parca/profile@0.19.125...@parca/profile@0.19.126) (2026-02-23)
7
+
8
+ **Note:** Version bump only for package @parca/profile
9
+
6
10
  ## [0.19.125](https://github.com/parca-dev/parca/compare/@parca/profile@0.19.124...@parca/profile@0.19.125) (2026-02-18)
7
11
 
8
12
  **Note:** Version bump only for package @parca/profile
@@ -1 +1 @@
1
- {"version":3,"file":"Select.d.ts","sourceRoot":"","sources":["../../src/SimpleMatchers/Select.tsx"],"names":[],"mappings":"AAaA,OAAO,KAA2D,MAAM,OAAO,CAAC;AAShF,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,GAAG,CAAC,OAAO,CAAC;IACpB,QAAQ,EAAE,GAAG,CAAC,OAAO,CAAC;CACvB;AAED,MAAM,WAAW,UAAU;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,OAAO,EAAE,aAAa,CAAC;CACxB;AAED,MAAM,WAAW,eAAgB,SAAQ,UAAU;IACjD,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,UAAU,EAAE,CAAC;CACtB;AAED,UAAU,iBAAiB;IACzB,KAAK,EAAE,iBAAiB,EAAE,GAAG,UAAU,EAAE,CAAC;IAC1C,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;IAChC,WAAW,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,IAAI,CAAC,EAAE,GAAG,CAAC,OAAO,CAAC;IACnB,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,aAAa,CAAC,EAAE,MAAM,IAAI,CAAC;IAC3B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,aAAa,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IACpC,mBAAmB,CAAC,EAAE,OAAO,CAAC;CAC/B;AAED,QAAA,MAAM,YAAY,EAAE,KAAK,CAAC,EAAE,CAAC,iBAAiB,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAwUnE,CAAC;AAkDF,eAAe,YAAY,CAAC"}
1
+ {"version":3,"file":"Select.d.ts","sourceRoot":"","sources":["../../src/SimpleMatchers/Select.tsx"],"names":[],"mappings":"AAaA,OAAO,KAA0D,MAAM,OAAO,CAAC;AAU/E,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,GAAG,CAAC,OAAO,CAAC;IACpB,QAAQ,EAAE,GAAG,CAAC,OAAO,CAAC;CACvB;AAED,MAAM,WAAW,UAAU;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,OAAO,EAAE,aAAa,CAAC;CACxB;AAED,MAAM,WAAW,eAAgB,SAAQ,UAAU;IACjD,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,UAAU,EAAE,CAAC;CACtB;AAED,UAAU,iBAAiB;IACzB,KAAK,EAAE,iBAAiB,EAAE,GAAG,UAAU,EAAE,CAAC;IAC1C,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;IAChC,WAAW,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,IAAI,CAAC,EAAE,GAAG,CAAC,OAAO,CAAC;IACnB,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,aAAa,CAAC,EAAE,MAAM,IAAI,CAAC;IAC3B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,aAAa,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IACpC,mBAAmB,CAAC,EAAE,OAAO,CAAC;CAC/B;AAED,QAAA,MAAM,YAAY,EAAE,KAAK,CAAC,EAAE,CAAC,iBAAiB,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAwXnE,CAAC;AA2CF,eAAe,YAAY,CAAC"}
@@ -11,8 +11,9 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
11
11
  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
12
  // See the License for the specific language governing permissions and
13
13
  // limitations under the License.
14
- import { Fragment, useCallback, useEffect, useRef, useState } from 'react';
14
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
15
15
  import { Icon } from '@iconify/react';
16
+ import { useVirtualizer } from '@tanstack/react-virtual';
16
17
  import cx from 'classnames';
17
18
  import levenshtein from 'fast-levenshtein';
18
19
  import { Button, DividerWithLabel, RefreshButton, useParcaContext } from '@parca/components';
@@ -22,11 +23,11 @@ const CustomSelect = ({ items: itemsProp, selectedKey, onSelection, placeholder
22
23
  const [isOpen, setIsOpen] = useState(false);
23
24
  const [focusedIndex, setFocusedIndex] = useState(-1);
24
25
  const [searchTerm, setSearchTerm] = useState('');
26
+ const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('');
25
27
  const [isRefetching, setIsRefetching] = useState(false);
26
28
  const containerRef = useRef(null);
27
29
  const optionsRef = useRef(null);
28
30
  const searchInputRef = useRef(null);
29
- const optionRefs = useRef([]);
30
31
  const handleRefetch = useCallback(async () => {
31
32
  if (refetchValues == null || isRefetching)
32
33
  return;
@@ -38,26 +39,26 @@ const CustomSelect = ({ items: itemsProp, selectedKey, onSelection, placeholder
38
39
  setIsRefetching(false);
39
40
  }
40
41
  }, [refetchValues, isRefetching]);
41
- let items = [];
42
- if (itemsProp[0] != null && 'type' in itemsProp[0]) {
43
- items = itemsProp.flatMap(item => item.values.map(v => ({ ...v, type: item.type })));
44
- }
45
- else {
46
- items = itemsProp.map(item => ({ ...item, type: '' }));
47
- }
48
- const filteredItems = searchable
49
- ? items
50
- .filter(item => item.element.active.props.children
51
- .toString()
52
- .toLowerCase()
53
- .includes(searchTerm.toLowerCase()))
54
- .sort((a, b) => {
55
- if (searchTerm === '') {
56
- return a.key.localeCompare(b.key);
57
- }
58
- return levenshtein.get(a.key, searchTerm) - levenshtein.get(b.key, searchTerm);
59
- })
60
- : items;
42
+ useEffect(() => {
43
+ const timer = setTimeout(() => setDebouncedSearchTerm(searchTerm), 150);
44
+ return () => clearTimeout(timer);
45
+ }, [searchTerm]);
46
+ const items = useMemo(() => {
47
+ if (itemsProp[0] != null && 'type' in itemsProp[0]) {
48
+ return itemsProp.flatMap(item => item.values.map(v => ({ ...v, type: item.type })));
49
+ }
50
+ return itemsProp.map(item => ({ ...item, type: '' }));
51
+ }, [itemsProp]);
52
+ const filteredItems = useMemo(() => {
53
+ if (!searchable)
54
+ return items;
55
+ const lowerSearch = debouncedSearchTerm.toLowerCase();
56
+ const filtered = items.filter(item => item.element.active.props.children.toString().toLowerCase().includes(lowerSearch));
57
+ if (debouncedSearchTerm === '') {
58
+ return filtered.sort((a, b) => a.key.localeCompare(b.key));
59
+ }
60
+ return filtered.sort((a, b) => levenshtein.get(a.key, debouncedSearchTerm) - levenshtein.get(b.key, debouncedSearchTerm));
61
+ }, [items, debouncedSearchTerm, searchable]);
61
62
  const selection = editable ? selectedKey : items.find(v => v.key === selectedKey);
62
63
  useEffect(() => {
63
64
  const handleClickOutside = (event) => {
@@ -75,24 +76,6 @@ const CustomSelect = ({ items: itemsProp, selectedKey, onSelection, placeholder
75
76
  searchInputRef.current?.focus();
76
77
  }
77
78
  }, [isOpen, searchable]);
78
- useEffect(() => {
79
- if (focusedIndex !== -1 &&
80
- optionsRef.current !== null &&
81
- optionRefs.current[focusedIndex] !== null) {
82
- const optionElement = optionRefs.current[focusedIndex];
83
- const optionsContainer = optionsRef.current;
84
- if (optionElement !== null && optionsContainer !== null) {
85
- const optionRect = optionElement.getBoundingClientRect();
86
- const containerRect = optionsContainer.getBoundingClientRect();
87
- if (optionRect.bottom > containerRect.bottom) {
88
- optionsContainer.scrollTop += optionRect.bottom - containerRect.bottom;
89
- }
90
- else if (optionRect.top < containerRect.top) {
91
- optionsContainer.scrollTop -= containerRect.top - optionRect.top;
92
- }
93
- }
94
- }
95
- }, [focusedIndex]);
96
79
  const handleKeyDown = (e) => {
97
80
  if (e.key === 'Enter') {
98
81
  if (!isOpen) {
@@ -162,31 +145,69 @@ const CustomSelect = ({ items: itemsProp, selectedKey, onSelection, placeholder
162
145
  e.target.value = '';
163
146
  e.target.value = value;
164
147
  };
165
- const groupedFilteredItems = filteredItems
166
- .reduce((acc, item) => {
167
- const group = acc.find(g => g.type === item.type);
168
- if (group != null) {
169
- group.values.push(item);
148
+ const groupedFilteredItems = useMemo(() => {
149
+ return filteredItems
150
+ .reduce((acc, item) => {
151
+ const group = acc.find(g => g.type === item.type);
152
+ if (group != null) {
153
+ group.values.push(item);
154
+ }
155
+ else {
156
+ acc.push({ type: item.type, values: [item] });
157
+ }
158
+ return acc;
159
+ }, [])
160
+ .sort((a, b) => a.values.length - b.values.length);
161
+ }, [filteredItems]);
162
+ const showHeaders = useMemo(() => groupedFilteredItems.length > 1 && groupedFilteredItems.every(g => g.type !== ''), [groupedFilteredItems]);
163
+ const flatList = useMemo(() => {
164
+ const list = [];
165
+ let optionIndex = 0;
166
+ for (const group of groupedFilteredItems) {
167
+ if (showHeaders && group.type !== '') {
168
+ list.push({ type: 'header', label: group.type });
169
+ }
170
+ for (const item of group.values) {
171
+ list.push({ type: 'option', item: item, flatIndex: optionIndex });
172
+ optionIndex++;
173
+ }
170
174
  }
171
- else {
172
- acc.push({ type: item.type, values: [item] });
175
+ return list;
176
+ }, [groupedFilteredItems, showHeaders]);
177
+ const longestKey = useMemo(() => filteredItems.reduce((a, b) => (a.key.length > b.key.length ? a : b), filteredItems[0])
178
+ ?.key ?? '', [filteredItems]);
179
+ const rowVirtualizer = useVirtualizer({
180
+ count: flatList.length,
181
+ getScrollElement: () => optionsRef.current,
182
+ estimateSize: () => 36,
183
+ overscan: 500,
184
+ });
185
+ useEffect(() => {
186
+ if (focusedIndex !== -1) {
187
+ const flatIdx = flatList.findIndex(entry => entry.type === 'option' && entry.flatIndex === focusedIndex);
188
+ if (flatIdx !== -1) {
189
+ rowVirtualizer.scrollToIndex(flatIdx, { align: 'auto' });
190
+ }
173
191
  }
174
- return acc;
175
- }, [])
176
- .sort((a, b) => a.values.length - b.values.length);
192
+ }, [focusedIndex, flatList, rowVirtualizer]);
177
193
  return (_jsxs("div", { ref: containerRef, className: "relative", onKeyDown: handleKeyDown, onClick: onButtonClick, children: [_jsxs("div", { id: id, onClick: () => !disabled && setIsOpen(!isOpen), className: cx(styles, width !== undefined ? `w-${width}` : 'w-full', disabled ? 'cursor-not-allowed opacity-50 pointer-events-none' : '', primary ? primaryStyles : defaultStyles, { [className]: className.length > 0 }), tabIndex: 0, role: "button", "aria-haspopup": "listbox", "aria-expanded": isOpen, ...restProps, children: [_jsx("div", { className: cx(icon != null ? '' : 'block overflow-x-hidden text-ellipsis whitespace-nowrap'), children: renderSelection(selection) }), _jsx("div", { className: cx(icon != null ? '' : 'pointer-events-none text-gray-400'), children: icon ?? _jsx(Icon, { icon: "heroicons:chevron-up-down-20-solid", "aria-hidden": "true" }) })] }), isOpen && (_jsx("div", { ref: optionsRef, className: cx('absolute z-50 mt-1 max-h-[50vh] w-max overflow-auto rounded-md bg-gray-50 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-gray-600 dark:bg-gray-900 dark:ring-white dark:ring-opacity-20 sm:text-sm', { [optionsClassname]: optionsClassname.length > 0 }), role: "listbox", children: _jsxs("div", { className: "relative flex flex-col", children: [searchable && (_jsx("div", { className: "sticky z-10 top-[-5px] w-auto max-w-full", children: _jsx("div", { className: "flex flex-col", children: editable ? (_jsxs(_Fragment, { children: [_jsx("textarea", { ref: searchInputRef, className: "w-full px-4 py-2 text-sm border-b border-gray-200 rounded-none ring-0 outline-none bg-gray-50 dark:bg-gray-800 dark:text-white min-h-[50px]", placeholder: "Type a RegEx to add", value: searchTerm, onChange: e => setSearchTerm(e.target.value), onFocus: e => moveCaretToEnd(e) }), editable && searchTerm.length > 0 && (_jsx("div", { className: "p-2 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800", children: _jsx(Button, { variant: "primary", className: "w-full h-[30px]", onClick: () => {
178
194
  onSelection(searchTerm);
179
195
  setIsOpen(false);
180
- }, children: "Add" }) }))] })) : (_jsx("input", { ref: searchInputRef, type: "text", className: "w-full px-4 h-[45px] text-sm border-none rounded-none ring-0 outline-none bg-gray-50 dark:bg-gray-800 dark:text-white", placeholder: "Search...", value: searchTerm, onChange: e => setSearchTerm(e.target.value) })) }) })), _jsx("div", { className: "flex-1 min-h-0", children: loading === true ? (_jsx("div", { className: "w-[270px]", children: loader })) : groupedFilteredItems.length === 0 ? (_jsx("div", { className: "px-4 py-3 text-sm text-gray-500 dark:text-gray-400 text-center", ...testId(TEST_IDS.LABEL_VALUE_NO_RESULTS), children: "No values found" })) : (groupedFilteredItems.map(group => (_jsxs(Fragment, { children: [groupedFilteredItems.length > 1 &&
181
- groupedFilteredItems.every(g => g.type !== '') &&
182
- group.type !== '' ? (_jsx("div", { className: "pl-2", children: _jsx(DividerWithLabel, { label: group.type }) })) : null, group.values.map((item, index) => (_jsx(OptionItem, { item: item, index: index, optionRefs: optionRefs, focusedIndex: focusedIndex, selectedKey: selectedKey, handleSelection: handleSelection }, item.key)))] }, group.type)))) }), refetchValues !== undefined && loading !== true && (_jsx(RefreshButton, { onClick: () => void handleRefetch(), disabled: isRefetching, title: "Refresh results", testId: TEST_IDS.LABEL_VALUE_REFRESH_BUTTON, sticky: true, loading: isRefetching }))] }) }))] }));
196
+ }, children: "Add" }) }))] })) : (_jsx("input", { ref: searchInputRef, type: "text", className: "w-full px-4 h-[45px] text-sm border-none rounded-none ring-0 outline-none bg-gray-50 dark:bg-gray-800 dark:text-white", placeholder: "Search...", value: searchTerm, onChange: e => setSearchTerm(e.target.value) })) }) })), _jsx("div", { className: "flex-1 min-h-0", children: loading === true ? (_jsx("div", { className: "w-[270px]", children: loader })) : groupedFilteredItems.length === 0 ? (_jsx("div", { className: "px-4 py-3 text-sm text-gray-500 dark:text-gray-400 text-center", ...testId(TEST_IDS.LABEL_VALUE_NO_RESULTS), children: "No values found" })) : ((() => {
197
+ const virtualItems = rowVirtualizer.getVirtualItems();
198
+ const paddingTop = virtualItems.length > 0 ? virtualItems[0]?.start ?? 0 : 0;
199
+ const paddingBottom = virtualItems.length > 0
200
+ ? rowVirtualizer.getTotalSize() -
201
+ (virtualItems[virtualItems.length - 1]?.end ?? 0)
202
+ : 0;
203
+ return (_jsxs("div", { children: [_jsx("div", { "aria-hidden": true, className: "pl-3 pr-9 whitespace-nowrap overflow-hidden", style: { height: 0, visibility: 'hidden' }, children: longestKey }), paddingTop > 0 && _jsx("div", { style: { height: paddingTop } }), virtualItems.map(virtualItem => {
204
+ const entry = flatList[virtualItem.index];
205
+ return entry.type === 'header' ? (_jsx("div", { className: "pl-2", children: _jsx(DividerWithLabel, { label: entry.label }) }, virtualItem.key)) : (_jsx(OptionItem, { item: entry.item, index: entry.flatIndex, focusedIndex: focusedIndex, selectedKey: selectedKey, handleSelection: handleSelection }, virtualItem.key));
206
+ }), paddingBottom > 0 && _jsx("div", { style: { height: paddingBottom } })] }));
207
+ })()) }), refetchValues !== undefined && loading !== true && (_jsx(RefreshButton, { onClick: () => void handleRefetch(), disabled: isRefetching, title: "Refresh results", testId: TEST_IDS.LABEL_VALUE_REFRESH_BUTTON, sticky: true, loading: isRefetching }))] }) }))] }));
183
208
  };
184
- const OptionItem = ({ item, optionRefs, index, focusedIndex, selectedKey, handleSelection, }) => {
185
- return (_jsxs("div", { ref: el => {
186
- if (el !== null) {
187
- optionRefs.current[index] = el;
188
- }
189
- }, className: cx('relative cursor-default select-none py-2 pl-3 pr-9', index === focusedIndex && 'bg-indigo-600 text-white', item.key === selectedKey && 'bg-indigo-100 dark:bg-indigo-700', item.disabled !== null && item.disabled === true && 'opacity-50 cursor-not-allowed', 'focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 hover:bg-indigo-600 hover:text-white'), role: "option", "aria-selected": item.key === selectedKey, tabIndex: -1, onClick: () => {
209
+ const OptionItem = ({ item, index, focusedIndex, selectedKey, handleSelection, }) => {
210
+ return (_jsxs("div", { className: cx('relative cursor-default select-none py-2 pl-3 pr-9', index === focusedIndex && 'bg-indigo-600 text-white', item.key === selectedKey && 'bg-indigo-100 dark:bg-indigo-700', item.disabled !== null && item.disabled === true && 'opacity-50 cursor-not-allowed', 'focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 hover:bg-indigo-600 hover:text-white'), role: "option", "aria-selected": item.key === selectedKey, tabIndex: -1, onClick: () => {
190
211
  if (!(item.disabled ?? false)) {
191
212
  handleSelection(item.key);
192
213
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parca/profile",
3
- "version": "0.19.125",
3
+ "version": "0.19.126",
4
4
  "description": "Profile viewing libraries",
5
5
  "dependencies": {
6
6
  "@floating-ui/react": "^0.27.12",
@@ -20,6 +20,7 @@
20
20
  "@storybook/preview-api": "^8.4.3",
21
21
  "@tanstack/react-query": "^4.0.5",
22
22
  "@tanstack/react-table": "^8.17.3",
23
+ "@tanstack/react-virtual": "^3.5.0",
23
24
  "@tanstack/table-core": "^8.16.0",
24
25
  "@types/d3": "^7.4.3",
25
26
  "@types/d3-scale": "^4.0.8",
@@ -87,5 +88,5 @@
87
88
  "access": "public",
88
89
  "registry": "https://registry.npmjs.org/"
89
90
  },
90
- "gitHead": "e7032ad37a873cf980400210d52885c9b990e4d1"
91
+ "gitHead": "0cbcaff4a19806bfa5e3e1c7b2c3cb4a0e8eb97c"
91
92
  }
@@ -11,9 +11,10 @@
11
11
  // See the License for the specific language governing permissions and
12
12
  // limitations under the License.
13
13
 
14
- import React, {Fragment, useCallback, useEffect, useRef, useState} from 'react';
14
+ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
15
15
 
16
16
  import {Icon} from '@iconify/react';
17
+ import {useVirtualizer} from '@tanstack/react-virtual';
17
18
  import cx from 'classnames';
18
19
  import levenshtein from 'fast-levenshtein';
19
20
 
@@ -84,11 +85,11 @@ const CustomSelect: React.FC<CustomSelectProps & Record<string, any>> = ({
84
85
  const [isOpen, setIsOpen] = useState(false);
85
86
  const [focusedIndex, setFocusedIndex] = useState(-1);
86
87
  const [searchTerm, setSearchTerm] = useState('');
88
+ const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('');
87
89
  const [isRefetching, setIsRefetching] = useState(false);
88
90
  const containerRef = useRef<HTMLDivElement>(null);
89
91
  const optionsRef = useRef<HTMLDivElement>(null);
90
92
  const searchInputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
91
- const optionRefs = useRef<Array<HTMLElement | null>>([]);
92
93
 
93
94
  const handleRefetch = useCallback(async () => {
94
95
  if (refetchValues == null || isRefetching) return;
@@ -101,30 +102,34 @@ const CustomSelect: React.FC<CustomSelectProps & Record<string, any>> = ({
101
102
  }
102
103
  }, [refetchValues, isRefetching]);
103
104
 
104
- let items: TypedSelectItem[] = [];
105
- if (itemsProp[0] != null && 'type' in itemsProp[0]) {
106
- items = (itemsProp as GroupedSelectItem[]).flatMap(item =>
107
- item.values.map(v => ({...v, type: item.type}))
105
+ useEffect(() => {
106
+ const timer = setTimeout(() => setDebouncedSearchTerm(searchTerm), 150);
107
+ return () => clearTimeout(timer);
108
+ }, [searchTerm]);
109
+
110
+ const items = useMemo<TypedSelectItem[]>(() => {
111
+ if (itemsProp[0] != null && 'type' in itemsProp[0]) {
112
+ return (itemsProp as GroupedSelectItem[]).flatMap(item =>
113
+ item.values.map(v => ({...v, type: item.type}))
114
+ );
115
+ }
116
+ return (itemsProp as SelectItem[]).map(item => ({...item, type: ''}));
117
+ }, [itemsProp]);
118
+
119
+ const filteredItems = useMemo(() => {
120
+ if (!searchable) return items;
121
+ const lowerSearch = debouncedSearchTerm.toLowerCase();
122
+ const filtered = items.filter(item =>
123
+ item.element.active.props.children.toString().toLowerCase().includes(lowerSearch)
124
+ );
125
+ if (debouncedSearchTerm === '') {
126
+ return filtered.sort((a, b) => a.key.localeCompare(b.key));
127
+ }
128
+ return filtered.sort(
129
+ (a, b) =>
130
+ levenshtein.get(a.key, debouncedSearchTerm) - levenshtein.get(b.key, debouncedSearchTerm)
108
131
  );
109
- } else {
110
- items = (itemsProp as SelectItem[]).map(item => ({...item, type: ''}));
111
- }
112
-
113
- const filteredItems = searchable
114
- ? items
115
- .filter(item =>
116
- item.element.active.props.children
117
- .toString()
118
- .toLowerCase()
119
- .includes(searchTerm.toLowerCase())
120
- )
121
- .sort((a, b) => {
122
- if (searchTerm === '') {
123
- return a.key.localeCompare(b.key);
124
- }
125
- return levenshtein.get(a.key, searchTerm) - levenshtein.get(b.key, searchTerm);
126
- })
127
- : items;
132
+ }, [items, debouncedSearchTerm, searchable]);
128
133
 
129
134
  const selection = editable ? selectedKey : items.find(v => v.key === selectedKey);
130
135
 
@@ -147,28 +152,6 @@ const CustomSelect: React.FC<CustomSelectProps & Record<string, any>> = ({
147
152
  }
148
153
  }, [isOpen, searchable]);
149
154
 
150
- useEffect(() => {
151
- if (
152
- focusedIndex !== -1 &&
153
- optionsRef.current !== null &&
154
- optionRefs.current[focusedIndex] !== null
155
- ) {
156
- const optionElement = optionRefs.current[focusedIndex];
157
- const optionsContainer = optionsRef.current;
158
-
159
- if (optionElement !== null && optionsContainer !== null) {
160
- const optionRect = optionElement.getBoundingClientRect();
161
- const containerRect = optionsContainer.getBoundingClientRect();
162
-
163
- if (optionRect.bottom > containerRect.bottom) {
164
- optionsContainer.scrollTop += optionRect.bottom - containerRect.bottom;
165
- } else if (optionRect.top < containerRect.top) {
166
- optionsContainer.scrollTop -= containerRect.top - optionRect.top;
167
- }
168
- }
169
- }
170
- }, [focusedIndex]);
171
-
172
155
  const handleKeyDown = (e: React.KeyboardEvent): void => {
173
156
  if (e.key === 'Enter') {
174
157
  if (!isOpen) {
@@ -245,17 +228,66 @@ const CustomSelect: React.FC<CustomSelectProps & Record<string, any>> = ({
245
228
  e.target.value = value;
246
229
  };
247
230
 
248
- const groupedFilteredItems = filteredItems
249
- .reduce((acc: GroupedSelectItem[], item) => {
250
- const group = acc.find(g => g.type === item.type);
251
- if (group != null) {
252
- group.values.push(item);
253
- } else {
254
- acc.push({type: item.type, values: [item]});
231
+ const groupedFilteredItems = useMemo(() => {
232
+ return filteredItems
233
+ .reduce((acc: GroupedSelectItem[], item) => {
234
+ const group = acc.find(g => g.type === item.type);
235
+ if (group != null) {
236
+ group.values.push(item);
237
+ } else {
238
+ acc.push({type: item.type, values: [item]});
239
+ }
240
+ return acc;
241
+ }, [])
242
+ .sort((a, b) => a.values.length - b.values.length);
243
+ }, [filteredItems]);
244
+
245
+ const showHeaders = useMemo(
246
+ () => groupedFilteredItems.length > 1 && groupedFilteredItems.every(g => g.type !== ''),
247
+ [groupedFilteredItems]
248
+ );
249
+
250
+ const flatList = useMemo(() => {
251
+ const list: Array<
252
+ {type: 'header'; label: string} | {type: 'option'; item: TypedSelectItem; flatIndex: number}
253
+ > = [];
254
+ let optionIndex = 0;
255
+ for (const group of groupedFilteredItems) {
256
+ if (showHeaders && group.type !== '') {
257
+ list.push({type: 'header', label: group.type});
255
258
  }
256
- return acc;
257
- }, [])
258
- .sort((a, b) => a.values.length - b.values.length);
259
+ for (const item of group.values) {
260
+ list.push({type: 'option', item: item as TypedSelectItem, flatIndex: optionIndex});
261
+ optionIndex++;
262
+ }
263
+ }
264
+ return list;
265
+ }, [groupedFilteredItems, showHeaders]);
266
+
267
+ const longestKey = useMemo(
268
+ () =>
269
+ filteredItems.reduce((a, b) => (a.key.length > b.key.length ? a : b), filteredItems[0])
270
+ ?.key ?? '',
271
+ [filteredItems]
272
+ );
273
+
274
+ const rowVirtualizer = useVirtualizer({
275
+ count: flatList.length,
276
+ getScrollElement: () => optionsRef.current,
277
+ estimateSize: () => 36,
278
+ overscan: 500,
279
+ });
280
+
281
+ useEffect(() => {
282
+ if (focusedIndex !== -1) {
283
+ const flatIdx = flatList.findIndex(
284
+ entry => entry.type === 'option' && entry.flatIndex === focusedIndex
285
+ );
286
+ if (flatIdx !== -1) {
287
+ rowVirtualizer.scrollToIndex(flatIdx, {align: 'auto'});
288
+ }
289
+ }
290
+ }, [focusedIndex, flatList, rowVirtualizer]);
259
291
 
260
292
  return (
261
293
  <div ref={containerRef} className="relative" onKeyDown={handleKeyDown} onClick={onButtonClick}>
@@ -349,28 +381,45 @@ const CustomSelect: React.FC<CustomSelectProps & Record<string, any>> = ({
349
381
  No values found
350
382
  </div>
351
383
  ) : (
352
- groupedFilteredItems.map(group => (
353
- <Fragment key={group.type}>
354
- {groupedFilteredItems.length > 1 &&
355
- groupedFilteredItems.every(g => g.type !== '') &&
356
- group.type !== '' ? (
357
- <div className="pl-2">
358
- <DividerWithLabel label={group.type} />
384
+ (() => {
385
+ const virtualItems = rowVirtualizer.getVirtualItems();
386
+ const paddingTop = virtualItems.length > 0 ? virtualItems[0]?.start ?? 0 : 0;
387
+ const paddingBottom =
388
+ virtualItems.length > 0
389
+ ? rowVirtualizer.getTotalSize() -
390
+ (virtualItems[virtualItems.length - 1]?.end ?? 0)
391
+ : 0;
392
+ return (
393
+ <div>
394
+ <div
395
+ aria-hidden
396
+ className="pl-3 pr-9 whitespace-nowrap overflow-hidden"
397
+ style={{height: 0, visibility: 'hidden'}}
398
+ >
399
+ {longestKey}
359
400
  </div>
360
- ) : null}
361
- {group.values.map((item, index) => (
362
- <OptionItem
363
- key={item.key}
364
- item={item}
365
- index={index}
366
- optionRefs={optionRefs}
367
- focusedIndex={focusedIndex}
368
- selectedKey={selectedKey}
369
- handleSelection={handleSelection}
370
- />
371
- ))}
372
- </Fragment>
373
- ))
401
+ {paddingTop > 0 && <div style={{height: paddingTop}} />}
402
+ {virtualItems.map(virtualItem => {
403
+ const entry = flatList[virtualItem.index];
404
+ return entry.type === 'header' ? (
405
+ <div key={virtualItem.key} className="pl-2">
406
+ <DividerWithLabel label={entry.label} />
407
+ </div>
408
+ ) : (
409
+ <OptionItem
410
+ key={virtualItem.key}
411
+ item={entry.item}
412
+ index={entry.flatIndex}
413
+ focusedIndex={focusedIndex}
414
+ selectedKey={selectedKey}
415
+ handleSelection={handleSelection}
416
+ />
417
+ );
418
+ })}
419
+ {paddingBottom > 0 && <div style={{height: paddingBottom}} />}
420
+ </div>
421
+ );
422
+ })()
374
423
  )}
375
424
  </div>
376
425
  {refetchValues !== undefined && loading !== true && (
@@ -392,14 +441,12 @@ const CustomSelect: React.FC<CustomSelectProps & Record<string, any>> = ({
392
441
 
393
442
  const OptionItem = ({
394
443
  item,
395
- optionRefs,
396
444
  index,
397
445
  focusedIndex,
398
446
  selectedKey,
399
447
  handleSelection,
400
448
  }: {
401
449
  item: SelectItem;
402
- optionRefs: React.MutableRefObject<Array<HTMLElement | null>>;
403
450
  index: number;
404
451
  focusedIndex: number;
405
452
  selectedKey: string | undefined;
@@ -407,11 +454,6 @@ const OptionItem = ({
407
454
  }): JSX.Element => {
408
455
  return (
409
456
  <div
410
- ref={el => {
411
- if (el !== null) {
412
- optionRefs.current[index] = el;
413
- }
414
- }}
415
457
  className={cx(
416
458
  'relative cursor-default select-none py-2 pl-3 pr-9',
417
459
  index === focusedIndex && 'bg-indigo-600 text-white',