From 2cfaafbbc523550d8c1c667b027d0a36648c5b26 Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Thu, 26 Feb 2026 15:05:03 -0700 Subject: [PATCH 1/6] Implement virtualized lists for large collections of options --- .../dash-core-components/package-lock.json | 124 ++------ components/dash-core-components/package.json | 5 +- .../src/components/Checklist.tsx | 2 +- .../src/components/RadioItems.tsx | 2 +- .../src/components/css/dropdown.css | 12 + .../src/fragments/Dropdown.tsx | 208 +++++++------ .../src/utils/dropdownSearch.ts | 81 +++-- .../src/utils/optionRendering.tsx | 280 +++++++++++++++--- .../src/utils/optionTypes.ts | 36 ++- .../tests/integration/dropdown/test_a11y.py | 23 +- .../dash-core-components/webpack.config.js | 2 +- 11 files changed, 462 insertions(+), 313 deletions(-) diff --git a/components/dash-core-components/package-lock.json b/components/dash-core-components/package-lock.json index c55972d3aa..298daceb32 100644 --- a/components/dash-core-components/package-lock.json +++ b/components/dash-core-components/package-lock.json @@ -35,7 +35,7 @@ "react-fast-compare": "^3.2.2", "react-input-autosize": "^3.0.0", "react-markdown": "^4.3.1", - "react-virtualized-select": "^3.1.3", + "react-window": "^1.8.11", "remark-math": "^3.0.1", "uniqid": "^5.4.0" }, @@ -59,6 +59,7 @@ "@types/react": "^16.14.8", "@types/react-dom": "^16.9.13", "@types/react-input-autosize": "^2.2.4", + "@types/react-window": "^1.8.8", "@types/uniqid": "^5.3.4", "@typescript-eslint/eslint-plugin": "^5.59.7", "@typescript-eslint/parser": "^5.59.7", @@ -4949,6 +4950,16 @@ "@types/react": "*" } }, + "node_modules/@types/react-window": { + "version": "1.8.8", + "resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz", + "integrity": "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/scheduler": { "version": "0.16.8", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", @@ -6135,20 +6146,6 @@ "@babel/core": "^7.0.0" } }, - "node_modules/babel-runtime": { - "version": "6.26.0", - "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", - "integrity": "sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==", - "dependencies": { - "core-js": "^2.4.0", - "regenerator-runtime": "^0.11.0" - } - }, - "node_modules/babel-runtime/node_modules/regenerator-runtime": { - "version": "0.11.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", - "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==" - }, "node_modules/bail": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/bail/-/bail-1.0.5.tgz", @@ -6659,11 +6656,6 @@ "dev": true, "license": "MIT" }, - "node_modules/classnames": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", - "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==" - }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -6695,14 +6687,6 @@ "node": ">=6" } }, - "node_modules/clsx": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", - "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", - "engines": { - "node": ">=6" - } - }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -7130,6 +7114,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, "license": "MIT" }, "node_modules/d3-format": { @@ -7363,15 +7348,6 @@ "dev": true, "license": "MIT" }, - "node_modules/dom-helpers": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", - "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", - "dependencies": { - "@babel/runtime": "^7.8.7", - "csstype": "^3.0.2" - } - }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -12593,6 +12569,12 @@ "unist-util-visit-parents": "1.1.2" } }, + "node_modules/memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", + "license": "MIT" + }, "node_modules/memorystream": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", @@ -14054,11 +14036,6 @@ "node": ">=0.4.0" } }, - "node_modules/react-lifecycles-compat": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", - "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" - }, "node_modules/react-markdown": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-4.3.1.tgz", @@ -14124,32 +14101,6 @@ } } }, - "node_modules/react-select": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/react-select/-/react-select-1.3.0.tgz", - "integrity": "sha512-g/QAU1HZrzSfxkwMAo/wzi6/ezdWye302RGZevsATec07hI/iSxcpB1hejFIp7V63DJ8mwuign6KmB3VjdlinQ==", - "dependencies": { - "classnames": "^2.2.4", - "prop-types": "^15.5.8", - "react-input-autosize": "^2.1.2" - }, - "peerDependencies": { - "react": "^0.14.9 || ^15.3.0 || ^16.0.0-rc || ^16.0", - "react-dom": "^0.14.9 || ^15.3.0 || ^16.0.0-rc || ^16.0" - } - }, - "node_modules/react-select/node_modules/react-input-autosize": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/react-input-autosize/-/react-input-autosize-2.2.2.tgz", - "integrity": "sha512-jQJgYCA3S0j+cuOwzuCd1OjmBmnZLdqQdiLKRYrsMMzbjUrVDS5RvJUDwJqA7sKuksDuzFtm6hZGKFu7Mjk5aw==", - "license": "MIT", - "dependencies": { - "prop-types": "^15.5.8" - }, - "peerDependencies": { - "react": "^0.14.9 || ^15.3.0 || ^16.0.0-rc || ^16.0" - } - }, "node_modules/react-style-singleton": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", @@ -14172,36 +14123,21 @@ } } }, - "node_modules/react-virtualized": { - "version": "9.22.5", - "resolved": "https://registry.npmjs.org/react-virtualized/-/react-virtualized-9.22.5.tgz", - "integrity": "sha512-YqQMRzlVANBv1L/7r63OHa2b0ZsAaDp1UhVNEdUaXI8A5u6hTpA5NYtUueLH2rFuY/27mTGIBl7ZhqFKzw18YQ==", + "node_modules/react-window": { + "version": "1.8.11", + "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.11.tgz", + "integrity": "sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==", + "license": "MIT", "dependencies": { - "@babel/runtime": "^7.7.2", - "clsx": "^1.0.4", - "dom-helpers": "^5.1.3", - "loose-envify": "^1.4.0", - "prop-types": "^15.7.2", - "react-lifecycles-compat": "^3.0.4" + "@babel/runtime": "^7.0.0", + "memoize-one": ">=3.1.1 <6" }, - "peerDependencies": { - "react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0", - "react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0" - } - }, - "node_modules/react-virtualized-select": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/react-virtualized-select/-/react-virtualized-select-3.1.3.tgz", - "integrity": "sha512-u6j/EfynCB9s4Lz5GGZhNUCZHvFQdtLZws7W/Tcd/v03l19OjpQs3eYjK82iYS0FgD2+lDIBpqS8LpD/hjqDRQ==", - "dependencies": { - "babel-runtime": "^6.11.6", - "prop-types": "^15.5.8", - "react-select": "^1.0.0-rc.2", - "react-virtualized": "^9.0.0" + "engines": { + "node": ">8.0.0" }, "peerDependencies": { - "react": "^15.3.0 || ^16.0.0-alpha", - "react-dom": "^15.3.0 || ^16.0.0-alpha" + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/read-pkg": { diff --git a/components/dash-core-components/package.json b/components/dash-core-components/package.json index 79a35daaac..04ae289e65 100644 --- a/components/dash-core-components/package.json +++ b/components/dash-core-components/package.json @@ -47,12 +47,12 @@ "@radix-ui/react-tooltip": "^1.2.8", "base64-js": "^1.5.1", "d3-format": "^1.4.5", + "date-fns": "^4.1.0", "fast-isnumeric": "^1.1.4", "file-saver": "^2.0.5", "highlight.js": "^11.8.0", "js-search": "^2.0.1", "mathjax": "^3.2.2", - "date-fns": "^4.1.0", "node-polyfill-webpack-plugin": "^2.0.1", "prop-types": "^15.8.1", "ramda": "^0.30.1", @@ -62,7 +62,7 @@ "react-fast-compare": "^3.2.2", "react-input-autosize": "^3.0.0", "react-markdown": "^4.3.1", - "react-virtualized-select": "^3.1.3", + "react-window": "^1.8.11", "remark-math": "^3.0.1", "uniqid": "^5.4.0" }, @@ -86,6 +86,7 @@ "@types/react": "^16.14.8", "@types/react-dom": "^16.9.13", "@types/react-input-autosize": "^2.2.4", + "@types/react-window": "^1.8.8", "@types/uniqid": "^5.3.4", "@typescript-eslint/eslint-plugin": "^5.59.7", "@typescript-eslint/parser": "^5.59.7", diff --git a/components/dash-core-components/src/components/Checklist.tsx b/components/dash-core-components/src/components/Checklist.tsx index f7b6481adc..c1f2a5a526 100644 --- a/components/dash-core-components/src/components/Checklist.tsx +++ b/components/dash-core-components/src/components/Checklist.tsx @@ -30,7 +30,7 @@ export default function Checklist({ inline = false, }: ChecklistProps) { const sanitizedOptions = useMemo(() => { - return sanitizeOptions(options); + return sanitizeOptions(options).options; }, [options]); const stylingProps = { diff --git a/components/dash-core-components/src/components/RadioItems.tsx b/components/dash-core-components/src/components/RadioItems.tsx index 093108073b..e4e575543f 100644 --- a/components/dash-core-components/src/components/RadioItems.tsx +++ b/components/dash-core-components/src/components/RadioItems.tsx @@ -31,7 +31,7 @@ export default function RadioItems({ inline = false, }: RadioItemsProps) { const sanitizedOptions = useMemo(() => { - return sanitizeOptions(options); + return sanitizeOptions(options).options; }, [options]); const stylingProps = { diff --git a/components/dash-core-components/src/components/css/dropdown.css b/components/dash-core-components/src/components/css/dropdown.css index a224163d37..5667b85060 100644 --- a/components/dash-core-components/src/components/css/dropdown.css +++ b/components/dash-core-components/src/components/css/dropdown.css @@ -218,6 +218,18 @@ overflow-y: auto; } +.dash-dropdown-content:has(.dash-options-list-virtualized) { + overflow-y: hidden; + display: flex; + flex-direction: column; +} + +.dash-dropdown-options:has(.dash-options-list-virtualized) { + overflow-y: visible; + flex: 1; + min-height: 0; +} + .dash-dropdown-option { padding: calc(var(--Dash-Spacing) * 2) calc(var(--Dash-Spacing) * 3); box-shadow: 0 -1px 0 0 var(--Dash-Fill-Disabled) inset; diff --git a/components/dash-core-components/src/fragments/Dropdown.tsx b/components/dash-core-components/src/fragments/Dropdown.tsx index 8338d03689..44f1cdbc30 100644 --- a/components/dash-core-components/src/fragments/Dropdown.tsx +++ b/components/dash-core-components/src/fragments/Dropdown.tsx @@ -7,7 +7,7 @@ import React, { useRef, MouseEvent, } from 'react'; -import {createFilteredOptions} from '../utils/dropdownSearch'; +import {sanitizeDropdownOptions, filterOptions} from '../utils/dropdownSearch'; import { CaretDownIcon, MagnifyingGlassIcon, @@ -18,7 +18,11 @@ import '../components/css/dropdown.css'; import isEqual from 'react-fast-compare'; import {DetailedOption, DropdownProps, OptionValue} from '../types'; -import {OptionsList, OptionLabel} from '../utils/optionRendering'; +import { + OptionsList, + OptionsListHandle, + OptionLabel, +} from '../utils/optionRendering'; import uuid from 'uniqid'; const Dropdown = (props: DropdownProps) => { @@ -48,6 +52,8 @@ const Dropdown = (props: DropdownProps) => { document.createElement('div') ); const searchInputRef = useRef(null); + const optionsListRef = useRef(null); + const focusedIndexRef = useRef(-1); const ctx = window.dash_component_api.useDashContext(); const loading = ctx.useLoading(); @@ -56,14 +62,18 @@ const Dropdown = (props: DropdownProps) => { persistentOptions.current = options; } - const {sanitizedOptions, filteredOptions} = useMemo( + const sanitized = useMemo( + () => sanitizeDropdownOptions(persistentOptions.current), + [persistentOptions.current] + ); + const sanitizedOptions = sanitized.options; + + const filteredOptions = useMemo( () => - createFilteredOptions( - persistentOptions.current, - !!searchable, - search_value - ), - [persistentOptions.current, searchable, search_value] + searchable + ? filterOptions(sanitized, search_value) + : sanitizedOptions, + [sanitized, searchable, search_value] ); const sanitizedValues: OptionValue[] = useMemo(() => { @@ -134,16 +144,16 @@ const Dropdown = (props: DropdownProps) => { !isNil(value) && !isEmpty(value) ) { - const values = sanitizedOptions.map(option => option.value); + const {valueSet} = sanitized; if (Array.isArray(value)) { if (multi) { - const invalids = value.filter(v => !values.includes(v)); + const invalids = value.filter(v => !valueSet.has(v)); if (invalids.length) { setProps({value: without(invalids, value)}); } } } else { - if (!values.includes(value)) { + if (!valueSet.has(value)) { setProps({value: null}); } } @@ -235,128 +245,108 @@ const Dropdown = (props: DropdownProps) => { } }, [filteredOptions, isOpen]); - // Focus first selected item or search input when dropdown opens + // Focus first selected item or search input when dropdown opens. + // Depends on displayOptions so it fires after OptionsList is mounted. useEffect(() => { - if (!isOpen || search_value) { + if (!isOpen || search_value || !displayOptions.length) { return; } - // waiting for the DOM to be ready after the dropdown renders requestAnimationFrame(() => { - // Try to focus the first selected item (for single-select) if (!multi) { const selectedValue = sanitizedValues[0]; if (selectedValue) { - const selectedElement = - dropdownContentRef.current.querySelector( - `.dash-options-list-option-checkbox[value="${selectedValue}"]` - ); - - if (selectedElement instanceof HTMLElement) { - selectedElement.focus(); + const selectedIndex = displayOptions.findIndex( + o => o.value === selectedValue + ); + if (selectedIndex >= 0) { + focusedIndexRef.current = selectedIndex; + optionsListRef.current?.focusItem(selectedIndex); return; } } } - // Fallback: focus search input if available and no selected item was focused if (searchable && searchInputRef.current) { searchInputRef.current.focus(); } }); }, [isOpen, multi, displayOptions]); - // Handle keyboard navigation in popover - const handleKeyDown = useCallback((e: React.KeyboardEvent) => { - const relevantKeys = [ - 'ArrowDown', - 'ArrowUp', - 'PageDown', - 'PageUp', - 'Home', - 'End', - ]; - if (!relevantKeys.includes(e.key)) { - return; - } - - // Don't interfere with the event if the user is using Home/End keys on the search input - if ( - ['Home', 'End'].includes(e.key) && - document.activeElement === searchInputRef.current - ) { - return; - } + // Handle keyboard navigation in popover. + // Index -1 = search input, 0..N-1 = option index in displayOptions. + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + const relevantKeys = [ + 'ArrowDown', + 'ArrowUp', + 'PageDown', + 'PageUp', + 'Home', + 'End', + ]; + if (!relevantKeys.includes(e.key)) { + return; + } - const focusableElements = e.currentTarget.querySelectorAll( - 'input[type="search"], input:not([disabled])' - ) as NodeListOf; + if ( + ['Home', 'End'].includes(e.key) && + document.activeElement === searchInputRef.current + ) { + return; + } - // Don't interfere with the event if there aren't any options that the user can interact with - if (focusableElements.length === 0) { - return; - } + if (displayOptions.length === 0) { + return; + } - e.preventDefault(); + e.preventDefault(); + + const hasSearch = !!searchable; + const current = focusedIndexRef.current; + const maxIndex = displayOptions.length - 1; + const minIndex = hasSearch ? -1 : 0; + let nextIndex: number; + + switch (e.key) { + case 'ArrowDown': + nextIndex = current < maxIndex ? current + 1 : minIndex; + break; + case 'ArrowUp': + nextIndex = current > minIndex ? current - 1 : maxIndex; + break; + case 'PageDown': + nextIndex = Math.min(current + 10, maxIndex); + break; + case 'PageUp': + nextIndex = Math.max(current - 10, minIndex); + break; + case 'Home': + nextIndex = minIndex; + break; + case 'End': + nextIndex = maxIndex; + break; + default: + return; + } - const currentIndex = Array.from(focusableElements).indexOf( - document.activeElement as HTMLElement - ); - let nextIndex = -1; - - switch (e.key) { - case 'ArrowDown': - nextIndex = - currentIndex < focusableElements.length - 1 - ? currentIndex + 1 - : 0; - break; - - case 'ArrowUp': - nextIndex = - currentIndex > 0 - ? currentIndex - 1 - : focusableElements.length - 1; - - break; - case 'PageDown': - nextIndex = Math.min( - currentIndex + 10, - focusableElements.length - 1 - ); - break; - case 'PageUp': - nextIndex = Math.max(currentIndex - 10, 0); - break; - case 'Home': - nextIndex = 0; - break; - case 'End': - nextIndex = focusableElements.length - 1; - break; - default: - break; - } + focusedIndexRef.current = nextIndex; - if (nextIndex > -1) { - focusableElements[nextIndex].focus(); - if (nextIndex === 0) { - // first element is a sticky search bar, so if we are focusing - // on that, also move the scroll to the top + if (nextIndex === -1) { + searchInputRef.current?.focus(); dropdownContentRef.current?.scrollTo({top: 0}); } else { - focusableElements[nextIndex].scrollIntoView({ - behavior: 'auto', - block: 'nearest', - }); + optionsListRef.current?.focusItem(nextIndex); } - } - }, []); + }, + [displayOptions.length, searchable] + ); - // Handle popover open/close const handleOpenChange = useCallback( (open: boolean) => { setIsOpen(open); + focusedIndexRef.current = -1; if (!open) { setProps({search_value: undefined}); @@ -512,17 +502,19 @@ const Dropdown = (props: DropdownProps) => { {isOpen && !!displayOptions.length && ( <> )} diff --git a/components/dash-core-components/src/utils/dropdownSearch.ts b/components/dash-core-components/src/utils/dropdownSearch.ts index b2d5ed285a..41ad649bd3 100644 --- a/components/dash-core-components/src/utils/dropdownSearch.ts +++ b/components/dash-core-components/src/utils/dropdownSearch.ts @@ -5,7 +5,7 @@ import { UnorderedSearchIndex, } from 'js-search'; import {sanitizeOptions} from './optionTypes'; -import {DetailedOption, DropdownProps} from '../types'; +import {DetailedOption, DropdownProps, OptionValue} from '../types'; // Custom tokenizer, see https://github.com/bvaughn/js-search/issues/43 // Split on spaces @@ -19,38 +19,34 @@ const TOKENIZER = { }, }; -interface FilteredOptionsResult { - sanitizedOptions: DetailedOption[]; - filteredOptions: DetailedOption[]; +export interface SanitizedOptions { + options: DetailedOption[]; + indexes: string[]; + valueSet: Set; } -/** - * Creates filtered dropdown options using js-search with the exact same behavior - * as react-select-fast-filter-options - */ -export function createFilteredOptions( - options: DropdownProps['options'], - searchable: boolean, - searchValue?: string -): FilteredOptionsResult { - // Sanitize and prepare options - let sanitized = sanitizeOptions(options); +// Single-pass sanitization via sanitizeOptions, plus detection of +// search/element labels for indexing. +export function sanitizeDropdownOptions( + options: DropdownProps['options'] +): SanitizedOptions { + const {options: sanitized, valueSet} = sanitizeOptions(options); const indexes = ['value']; let hasElement = false, hasSearch = false; - sanitized = Array.isArray(sanitized) - ? sanitized.map(option => { - if (option.search) { - hasSearch = true; - } - if (React.isValidElement(option.label)) { - hasElement = true; - } - return option; - }) - : sanitized; + for (const option of sanitized) { + if (option.search) { + hasSearch = true; + } + if (React.isValidElement(option.label)) { + hasElement = true; + } + if (hasSearch && hasElement) { + break; + } + } if (!hasElement) { indexes.push('label'); @@ -59,34 +55,29 @@ export function createFilteredOptions( indexes.push('search'); } - // If not searchable or no search value, return all sanitized options - if (!searchable || !searchValue) { - return { - sanitizedOptions: sanitized || [], - filteredOptions: sanitized || [], - }; + return {options: sanitized, indexes, valueSet}; +} + +export function filterOptions( + options: SanitizedOptions, + searchValue?: string +): DetailedOption[] { + if (!searchValue) { + return options.options; } - // Create js-search instance exactly like react-select-fast-filter-options - const search = new Search('value'); // valueKey defaults to 'value' + const search = new Search('value'); search.searchIndex = new UnorderedSearchIndex(); search.indexStrategy = new AllSubstringsIndexStrategy(); search.tokenizer = TOKENIZER; - // Add indexes - indexes.forEach(index => { + options.indexes.forEach(index => { search.addIndex(index); }); - // Add documents - if (sanitized && sanitized.length > 0) { - search.addDocuments(sanitized); + if (options.options.length > 0) { + search.addDocuments(options.options); } - const filtered = search.search(searchValue) as DetailedOption[]; - - return { - sanitizedOptions: sanitized || [], - filteredOptions: filtered || [], - }; + return (search.search(searchValue) as DetailedOption[]) || []; } diff --git a/components/dash-core-components/src/utils/optionRendering.tsx b/components/dash-core-components/src/utils/optionRendering.tsx index 014f42e6b8..fa546c7b36 100644 --- a/components/dash-core-components/src/utils/optionRendering.tsx +++ b/components/dash-core-components/src/utils/optionRendering.tsx @@ -1,8 +1,20 @@ -import React from 'react'; +import React, { + forwardRef, + memo, + useCallback, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; import {append, includes, without} from 'ramda'; +import {VariableSizeList, ListChildComponentProps} from 'react-window'; import {DetailedOption, OptionValue} from 'src/types'; import '../components/css/optionslist.css'; +const DEFAULT_ITEM_HEIGHT = 35; + interface StylingProps { id?: string; className?: string; @@ -91,6 +103,7 @@ export const Option: React.FC = ({ role="option" aria-selected={isSelected} style={optionStyle} + data-option-index={index} > = ({ ); }; -interface OptionsListProps extends StylingProps { +interface RowData { options: DetailedOption[]; selected: OptionValue[]; - onSelectionChange: (selected: OptionValue[]) => void; + onChange: (option: DetailedOption) => void; + passThruProps: StylingProps; + setOptionHeight: (index: number, height: number) => void; } -export const OptionsList: React.FC = ({ - options, - selected, - onSelectionChange, - id, - className, - style, - ...passThruProps -}) => { - const classNames = ['dash-options-list', className].filter(Boolean); +const Row = memo(({index, style, data}: ListChildComponentProps) => { + const {options, selected, onChange, passThruProps, setOptionHeight} = data; + const option = options[index]; + const isSelected = includes(option.value, selected); + return ( -
- {options.map((option, i) => { - const isSelected = includes(option.value, selected); - return ( -
); } diff --git a/components/dash-core-components/tests/integration/dropdown/test_a11y.py b/components/dash-core-components/tests/integration/dropdown/test_a11y.py index f7f8756bdf..115174d968 100644 --- a/components/dash-core-components/tests/integration/dropdown/test_a11y.py +++ b/components/dash-core-components/tests/integration/dropdown/test_a11y.py @@ -389,6 +389,7 @@ def get_focused_option_text(): # Now arrow down to first option send_keys(Keys.ARROW_DOWN) + sleep(0.1) assert get_focused_option_text() == "Option 0" # Test End key - should go to last option diff --git a/components/dash-core-components/tests/integration/misc/test_persistence.py b/components/dash-core-components/tests/integration/misc/test_persistence.py index 890626841f..bdeca8ea0f 100644 --- a/components/dash-core-components/tests/integration/misc/test_persistence.py +++ b/components/dash-core-components/tests/integration/misc/test_persistence.py @@ -121,7 +121,7 @@ def make_output(*args): dash_dcc.driver.set_window_size(1024, 768) dash_dcc.wait_for_text_to_equal("#settings", json.dumps(initial_settings)) - dash_dcc.find_element("#checklist label:last-child input").click() # 🚀 + dash_dcc.find_element('#checklist [data-option-index="2"] input').click() # 🚀 dash_dcc.select_date_range("datepickerrange", day_range=(4,)) dash_dcc.select_date_range("datepickerrange", day_range=(14,), start_first=False) @@ -145,7 +145,7 @@ def make_output(*args): dash_dcc.find_element("#input").send_keys(" maybe") - dash_dcc.find_element("#radioitems label:first-child input").click() # red + dash_dcc.find_element('#radioitems [data-option-index="0"] input').click() # red range_slider = dash_dcc.find_element("#rangeslider") dash_dcc.click_at_coord_fractions(range_slider, 0.5, 0.25) # 5 diff --git a/tests/integration/renderer/test_children_reorder.py b/tests/integration/renderer/test_children_reorder.py index 11c1f0e660..af2c4a6154 100644 --- a/tests/integration/renderer/test_children_reorder.py +++ b/tests/integration/renderer/test_children_reorder.py @@ -63,12 +63,12 @@ def swap_button_action(n_clicks, children): for i in range(2): dash_duo.wait_for_text_to_equal("h1", f"I am section {i}") dash_duo.find_element(f".dropdown_{i}").click() - dash_duo.find_element(".dash-dropdown-option:nth-child(1)").click() + dash_duo.find_element('.dash-dropdown-option[data-option-index="0"]').click() dash_duo.wait_for_text_to_equal(f".dropdown_{i} .dash-dropdown-trigger", "A") - dash_duo.find_element(".dash-dropdown-option:nth-child(2)").click() + dash_duo.find_element('.dash-dropdown-option[data-option-index="1"]').click() value_items = dash_duo.find_elements(f".dropdown_{i} .dash-dropdown-value-item") assert [item.text for item in value_items] == ["A", "B"] - dash_duo.find_element(".dash-dropdown-option:nth-child(3)").click() + dash_duo.find_element('.dash-dropdown-option[data-option-index="2"]').click() value_items = dash_duo.find_elements(f".dropdown_{i} .dash-dropdown-value-item") assert [item.text for item in value_items] == ["A", "B", "C"] diff --git a/tests/integration/renderer/test_component_as_prop.py b/tests/integration/renderer/test_component_as_prop.py index 8c620977d5..f623b645bd 100644 --- a/tests/integration/renderer/test_component_as_prop.py +++ b/tests/integration/renderer/test_component_as_prop.py @@ -357,13 +357,13 @@ def demo(n_clicks): dash_duo.start_server(app) dash_duo.wait_for_element("#add-option").click() - for i in range(1, n + 2): + for i in range(n + 1): dash_duo.wait_for_text_to_equal( - f"#options label:nth-child({i}) span.label-result", "" + f'#options [data-option-index="{i}"] span.label-result', "" ) - dash_duo.wait_for_element(f"#options label:nth-child({i}) button").click() + dash_duo.wait_for_element(f'#options [data-option-index="{i}"] button').click() dash_duo.wait_for_text_to_equal( - f"#options label:nth-child({i}) span.label-result", "1" + f'#options [data-option-index="{i}"] span.label-result', "1" ) @@ -393,13 +393,17 @@ def opts(n): dash_duo.wait_for_text_to_equal("#counter", "0") dash_duo.find_element("#a").click() - assert len(dash_duo.find_elements("#b label input")) == 2 + assert ( + len(dash_duo.find_elements('#b label:not([data-option-index="-1"]) input')) == 2 + ) dash_duo.wait_for_text_to_equal("#counter", "0") dash_duo.find_element("#a").click() - assert len(dash_duo.find_elements("#b label input")) == 3 + assert ( + len(dash_duo.find_elements('#b label:not([data-option-index="-1"]) input')) == 3 + ) dash_duo.wait_for_text_to_equal("#counter", "0") - dash_duo.find_elements("#b label input")[0].click() + dash_duo.find_elements('#b label:not([data-option-index="-1"]) input')[0].click() dash_duo.wait_for_text_to_equal("#counter", "1") From c5c8a652dc7116a48a88a9949e709b23fc5e12fc Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Thu, 12 Mar 2026 09:50:25 -0600 Subject: [PATCH 3/6] add changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a57736f98f..b1f827b486 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## Fixed - [#3629](https://github.com/plotly/dash/pull/3629) Fix date pickers not showing date when initially rendered in a hidden container. - [#3627][(](https://github.com/plotly/dash/pull/3627)) Make dropdowns searchable wheen focused, without requiring to open them first +- [#3656][(](https://github.com/plotly/dash/pull/3656)) Improved dropdown performance for large collections of options From e05d073d155044f369e04345ee1c61252c769db4 Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Thu, 12 Mar 2026 11:00:10 -0600 Subject: [PATCH 4/6] empty commit for ci From e4cd2f897d99360032fa2d2f7e944089533705e1 Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Thu, 12 Mar 2026 15:16:00 -0600 Subject: [PATCH 5/6] empty commit for ci From 922c9404bf7491d3df2a2d261729e410eeb1a9cf Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Fri, 13 Mar 2026 16:33:51 -0600 Subject: [PATCH 6/6] Fix bug with inline checklists and dynamically sized option labels --- .../src/utils/optionRendering.tsx | 59 ++++++++++++++++--- .../tests/integration/calendar/test_portal.py | 10 ---- 2 files changed, 52 insertions(+), 17 deletions(-) diff --git a/components/dash-core-components/src/utils/optionRendering.tsx b/components/dash-core-components/src/utils/optionRendering.tsx index 42da2807a4..fc3a21d525 100644 --- a/components/dash-core-components/src/utils/optionRendering.tsx +++ b/components/dash-core-components/src/utils/optionRendering.tsx @@ -151,14 +151,35 @@ const Row = memo(({index, style, data}: ListChildComponentProps) => { const option = options[index]; const isSelected = includes(option.value, selected); + const measureRef = useCallback( + (el: HTMLDivElement | null) => { + if (!el) { + return; + } + // Synchronous measurement for string labels. + const immediateHeight = el.getBoundingClientRect().height; + if (immediateHeight > 0) { + setOptionHeight(index, immediateHeight); + } + + // ResizeObserver catches async Dash component labels + // that render after the initial commit. + const observer = new ResizeObserver(([entry]) => { + const height = + entry.borderBoxSize?.[0]?.blockSize ?? + entry.contentRect.height; + if (height > 0) { + setOptionHeight(index, height); + } + }); + observer.observe(el); + }, + [index, setOptionHeight] + ); + return (
-
- el && - setOptionHeight(index, el.getBoundingClientRect().height) - } - > +