diff --git a/@plotly/dash-generator-test-component-typescript/generator.test.ts b/@plotly/dash-generator-test-component-typescript/generator.test.ts
index b188a6bcc6..7ad1546bd3 100644
--- a/@plotly/dash-generator-test-component-typescript/generator.test.ts
+++ b/@plotly/dash-generator-test-component-typescript/generator.test.ts
@@ -197,6 +197,28 @@ describe('Test Typescript component metadata generation', () => {
expect(propType.value.value[0].name).toBe('string');
expect(propType.value.value[1].name).toBe('shape');
});
+ test('Union of primitives and arrays of primitives', () => {
+ const propType = R.path(
+ propPath('TypeScriptComponent', 'array_primitive_mix').concat(
+ 'type'
+ ),
+ metadata
+ );
+ expect(propType.name).toBe('union');
+ expect(propType.value.length).toBe(4);
+ expect(propType.value[0].name).toBe('string');
+ expect(propType.value[1].name).toBe('number');
+ expect(propType.value[2].name).toBe('bool');
+ expect(propType.value[3].name).toBe('arrayOf');
+
+ // Verify that the array element type is a union of string, number, and boolean
+ const arrayElementType = propType.value[3].value;
+ expect(arrayElementType.name).toBe('union');
+ expect(arrayElementType.value.length).toBe(3);
+ expect(arrayElementType.value[0].name).toBe('string');
+ expect(arrayElementType.value[1].name).toBe('number');
+ expect(arrayElementType.value[2].name).toBe('bool');
+ });
test('Obj properties', () => {
const propType = R.path(
propPath('TypeScriptComponent', 'obj').concat('type', 'value'),
diff --git a/@plotly/dash-generator-test-component-typescript/src/components/TypeScriptComponent.tsx b/@plotly/dash-generator-test-component-typescript/src/components/TypeScriptComponent.tsx
index 71eae8d54a..e4c6b68b85 100644
--- a/@plotly/dash-generator-test-component-typescript/src/components/TypeScriptComponent.tsx
+++ b/@plotly/dash-generator-test-component-typescript/src/components/TypeScriptComponent.tsx
@@ -12,6 +12,7 @@ const TypeScriptComponent = ({
bool_default = true,
null_default = null,
obj_default = { a: 'a', b: 3 },
+ array_primitive_mix = 1,
...props
}: TypescriptComponentProps) => {
return
{required_string}
;
diff --git a/@plotly/dash-generator-test-component-typescript/src/props.ts b/@plotly/dash-generator-test-component-typescript/src/props.ts
index 8b7e9ff6a5..e576786a96 100644
--- a/@plotly/dash-generator-test-component-typescript/src/props.ts
+++ b/@plotly/dash-generator-test-component-typescript/src/props.ts
@@ -29,6 +29,11 @@ export type TypescriptComponentProps = {
union?: number | string;
union_shape?: {a: string} | string;
array_union_shape?: ({a: string} | string)[];
+ array_primitive_mix?:
+ | string
+ | number
+ | (string | number | boolean)[]
+ | boolean;
element?: JSX.Element;
array_elements?: JSX.Element[];
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8c5bfad021..6ca439960b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,11 @@
All notable changes to `dash` will be documented in this file.
This project adheres to [Semantic Versioning](https://semver.org/).
+## [UNRELEASED]
+
+## Added
+- [#3440](https://github.com/plotly/dash/pull/3440) Modernize dcc.Dropdown
+
## [4.0.0rc0] - 2025-09-11
- [#3398](https://github.com/plotly/dash/pull/3398) Modernize dcc.Input
- [#3414](https://github.com/plotly/dash/pull/3414) Modernize dcc.Slider
diff --git a/components/dash-core-components/package-lock.json b/components/dash-core-components/package-lock.json
index 5bb043d4a8..22e42b816e 100644
--- a/components/dash-core-components/package-lock.json
+++ b/components/dash-core-components/package-lock.json
@@ -13,6 +13,9 @@
"@fortawesome/free-regular-svg-icons": "^5.15.4",
"@fortawesome/free-solid-svg-icons": "^5.15.4",
"@fortawesome/react-fontawesome": "^0.1.17",
+ "@radix-ui/react-icons": "^1.3.2",
+ "@radix-ui/react-popover": "^1.1.15",
+ "@radix-ui/react-select": "^2.1.3",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-tooltip": "^1.2.8",
"base64-js": "^1.5.1",
@@ -21,6 +24,7 @@
"fast-isnumeric": "^1.1.4",
"file-saver": "^2.0.5",
"highlight.js": "^11.8.0",
+ "js-search": "^2.0.1",
"mathjax": "^3.2.2",
"moment": "^2.29.4",
"node-polyfill-webpack-plugin": "^2.0.1",
@@ -32,7 +36,6 @@
"react-dropzone": "^4.1.2",
"react-fast-compare": "^3.2.2",
"react-markdown": "^4.3.1",
- "react-select-fast-filter-options": "^0.2.3",
"react-virtualized-select": "^3.1.3",
"remark-math": "^3.0.1",
"uniqid": "^5.4.0"
@@ -50,6 +53,7 @@
"@types/d3-format": "^3.0.4",
"@types/fast-isnumeric": "^1.1.2",
"@types/jest": "^29.5.0",
+ "@types/js-search": "^1.4.4",
"@types/ramda": "^0.31.0",
"@types/react": "^16.14.8",
"@types/react-dom": "^16.9.13",
@@ -3523,6 +3527,55 @@
}
}
},
+ "node_modules/@radix-ui/react-focus-guards": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
+ "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-focus-scope": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
+ "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-icons": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.2.tgz",
+ "integrity": "sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc"
+ }
+ },
"node_modules/@radix-ui/react-id": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
@@ -3541,6 +3594,43 @@
}
}
},
+ "node_modules/@radix-ui/react-popover": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
+ "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-dismissable-layer": "1.1.11",
+ "@radix-ui/react-focus-guards": "1.1.3",
+ "@radix-ui/react-focus-scope": "1.1.7",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-popper": "1.2.8",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.6.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-popper": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
@@ -3644,6 +3734,49 @@
}
}
},
+ "node_modules/@radix-ui/react-select": {
+ "version": "2.2.6",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
+ "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/number": "1.1.1",
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-dismissable-layer": "1.1.11",
+ "@radix-ui/react-focus-guards": "1.1.3",
+ "@radix-ui/react-focus-scope": "1.1.7",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-popper": "1.2.8",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1",
+ "@radix-ui/react-use-previous": "1.1.1",
+ "@radix-ui/react-visually-hidden": "1.2.3",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.6.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-slider": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz",
@@ -4053,6 +4186,13 @@
"pretty-format": "^29.0.0"
}
},
+ "node_modules/@types/js-search": {
+ "version": "1.4.4",
+ "resolved": "https://registry.npmjs.org/@types/js-search/-/js-search-1.4.4.tgz",
+ "integrity": "sha512-NYIBuSRTi2h6nLne0Ygx78BZaiT/q0lLU7YSkjOrDJWpSx6BioIZA/i2GZ+WmMUzEQs2VNIWcXRRAqisrG3ZNA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -4809,6 +4949,18 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true
},
+ "node_modules/aria-hidden": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
+ "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/array-buffer-byte-length": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.0.tgz",
@@ -6397,6 +6549,12 @@
"node": ">=8"
}
},
+ "node_modules/detect-node-es": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
+ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
+ "license": "MIT"
+ },
"node_modules/diff-sequences": {
"version": "29.6.3",
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
@@ -7764,6 +7922,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/get-nonce": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
+ "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/get-package-type": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
@@ -10917,9 +11084,10 @@
}
},
"node_modules/js-search": {
- "version": "1.4.3",
- "resolved": "https://registry.npmjs.org/js-search/-/js-search-1.4.3.tgz",
- "integrity": "sha512-Sny5pf00kX1sM1KzvUC9nGYWXOvBfy30rmvZWeRktpg+esQKedIXrXNee/I2CAnsouCyaTjitZpRflDACx4toA=="
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/js-search/-/js-search-2.0.1.tgz",
+ "integrity": "sha512-8k12LiC3fPt7gLRJTc1azE1BFvlxIw+BG3J9YzjuYf4wSE65uqYSYP4VhweApcTfV51Fzq/ogBulQew5937A9A==",
+ "license": "MIT"
},
"node_modules/js-tokens": {
"version": "4.0.0",
@@ -12808,6 +12976,53 @@
"react-dom": "^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0"
}
},
+ "node_modules/react-remove-scroll": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz",
+ "integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==",
+ "license": "MIT",
+ "dependencies": {
+ "react-remove-scroll-bar": "^2.3.7",
+ "react-style-singleton": "^2.2.3",
+ "tslib": "^2.1.0",
+ "use-callback-ref": "^1.3.3",
+ "use-sidecar": "^1.1.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-remove-scroll-bar": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
+ "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
+ "license": "MIT",
+ "dependencies": {
+ "react-style-singleton": "^2.2.2",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/react-select": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/react-select/-/react-select-1.3.0.tgz",
@@ -12822,16 +13037,26 @@
"react-dom": "^0.14.9 || ^15.3.0 || ^16.0.0-rc || ^16.0"
}
},
- "node_modules/react-select-fast-filter-options": {
- "version": "0.2.3",
- "resolved": "https://registry.npmjs.org/react-select-fast-filter-options/-/react-select-fast-filter-options-0.2.3.tgz",
- "integrity": "sha512-rTMMRhd73MI1z2eWpes8sGoR4nBYM1IGjsYPvay2DF/kylHUmXFFIGsZJZQcXdBZnAXExKyw2kYKCGiYi4ls4Q==",
+ "node_modules/react-style-singleton": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
+ "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
+ "license": "MIT",
"dependencies": {
- "js-search": "^1.3.1"
+ "get-nonce": "^1.0.0",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
},
"peerDependencies": {
- "react": "^0.14.0 || ^15.0.0 || ^16.0.0-a",
- "react-select": "^1.0.0-beta14"
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
}
},
"node_modules/react-virtualized": {
@@ -14724,6 +14949,49 @@
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
"integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ=="
},
+ "node_modules/use-callback-ref": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
+ "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/use-sidecar": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
+ "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
+ "license": "MIT",
+ "dependencies": {
+ "detect-node-es": "^1.1.0",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/util": {
"version": "0.12.5",
"resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz",
diff --git a/components/dash-core-components/package.json b/components/dash-core-components/package.json
index cb97a6f8c4..abe43ef21e 100644
--- a/components/dash-core-components/package.json
+++ b/components/dash-core-components/package.json
@@ -40,6 +40,9 @@
"@fortawesome/free-regular-svg-icons": "^5.15.4",
"@fortawesome/free-solid-svg-icons": "^5.15.4",
"@fortawesome/react-fontawesome": "^0.1.17",
+ "@radix-ui/react-icons": "^1.3.2",
+ "@radix-ui/react-popover": "^1.1.15",
+ "@radix-ui/react-select": "^2.1.3",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-tooltip": "^1.2.8",
"base64-js": "^1.5.1",
@@ -48,6 +51,7 @@
"fast-isnumeric": "^1.1.4",
"file-saver": "^2.0.5",
"highlight.js": "^11.8.0",
+ "js-search": "^2.0.1",
"mathjax": "^3.2.2",
"moment": "^2.29.4",
"node-polyfill-webpack-plugin": "^2.0.1",
@@ -59,7 +63,6 @@
"react-dropzone": "^4.1.2",
"react-fast-compare": "^3.2.2",
"react-markdown": "^4.3.1",
- "react-select-fast-filter-options": "^0.2.3",
"react-virtualized-select": "^3.1.3",
"remark-math": "^3.0.1",
"uniqid": "^5.4.0"
@@ -77,6 +80,7 @@
"@types/d3-format": "^3.0.4",
"@types/fast-isnumeric": "^1.1.2",
"@types/jest": "^29.5.0",
+ "@types/js-search": "^1.4.4",
"@types/ramda": "^0.31.0",
"@types/react": "^16.14.8",
"@types/react-dom": "^16.9.13",
diff --git a/components/dash-core-components/src/components/Dropdown.react.js b/components/dash-core-components/src/components/Dropdown.react.js
deleted file mode 100644
index 6ff5bac387..0000000000
--- a/components/dash-core-components/src/components/Dropdown.react.js
+++ /dev/null
@@ -1,228 +0,0 @@
-import PropTypes from 'prop-types';
-import React, {Component, lazy, Suspense} from 'react';
-import dropdown from '../utils/LazyLoader/dropdown';
-
-const RealDropdown = lazy(dropdown);
-
-/**
- * Dropdown is an interactive dropdown element for selecting one or more
- * items.
- * The values and labels of the dropdown items are specified in the `options`
- * property and the selected item(s) are specified with the `value` property.
- *
- * Use a dropdown when you have many options (more than 5) or when you are
- * constrained for space. Otherwise, you can use RadioItems or a Checklist,
- * which have the benefit of showing the users all of the items at once.
- */
-export default class Dropdown extends Component {
- render() {
- return (
-
-
-
- );
- }
-}
-
-Dropdown.propTypes = {
- /**
- * An array of options {label: [string|number], value: [string|number]},
- * an optional disabled field can be used for each option
- */
- options: PropTypes.oneOfType([
- /**
- * Array of options where the label and the value are the same thing - [string|number|bool]
- */
- PropTypes.arrayOf(
- PropTypes.oneOfType([
- PropTypes.string,
- PropTypes.number,
- PropTypes.bool,
- ])
- ),
- /**
- * Simpler `options` representation in dictionary format. The order is not guaranteed.
- * {`value1`: `label1`, `value2`: `label2`, ... }
- * which is equal to
- * [{label: `label1`, value: `value1`}, {label: `label2`, value: `value2`}, ...]
- */
- PropTypes.object,
- /**
- * An array of options {label: [string|number], value: [string|number]},
- * an optional disabled field can be used for each option
- */
- PropTypes.arrayOf(
- PropTypes.exact({
- /**
- * The option's label
- */
- label: PropTypes.node.isRequired,
-
- /**
- * The value of the option. This value
- * corresponds to the items specified in the
- * `value` property.
- */
- value: PropTypes.oneOfType([
- PropTypes.string,
- PropTypes.number,
- PropTypes.bool,
- ]).isRequired,
-
- /**
- * If true, this option is disabled and cannot be selected.
- */
- disabled: PropTypes.bool,
-
- /**
- * The HTML 'title' attribute for the option. Allows for
- * information on hover. For more information on this attribute,
- * see https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/title
- */
- title: PropTypes.string,
-
- /**
- * Optional search value for the option, to use if the label
- * is a component or provide a custom search value different
- * from the label. If no search value and the label is a
- * component, the `value` will be used for search.
- */
- search: PropTypes.string,
- })
- ),
- ]),
-
- /**
- * The value of the input. If `multi` is false (the default)
- * then value is just a string that corresponds to the values
- * provided in the `options` property. If `multi` is true, then
- * multiple values can be selected at once, and `value` is an
- * array of items with values corresponding to those in the
- * `options` prop.
- */
- value: PropTypes.oneOfType([
- PropTypes.string,
- PropTypes.number,
- PropTypes.bool,
- PropTypes.arrayOf(
- PropTypes.oneOfType([
- PropTypes.string,
- PropTypes.number,
- PropTypes.bool,
- ])
- ),
- ]),
-
- /**
- * If true, the user can select multiple values
- */
- multi: PropTypes.bool,
-
- /**
- * Whether or not the dropdown is "clearable", that is, whether or
- * not a small "x" appears on the right of the dropdown that removes
- * the selected value.
- */
- clearable: PropTypes.bool,
-
- /**
- * Whether to enable the searching feature or not
- */
- searchable: PropTypes.bool,
-
- /**
- * The value typed in the DropDown for searching.
- */
- search_value: PropTypes.string,
-
- /**
- * The grey, default text shown when no option is selected
- */
- placeholder: PropTypes.string,
-
- /**
- * If true, this dropdown is disabled and the selection cannot be changed.
- */
- disabled: PropTypes.bool,
-
- /**
- * If false, the menu of the dropdown will not close once a value is selected.
- */
- closeOnSelect: PropTypes.bool,
-
- /**
- * height of each option. Can be increased when label lengths would wrap around
- */
- optionHeight: PropTypes.number,
-
- /**
- * height of the options dropdown.
- */
- maxHeight: PropTypes.number,
-
- /**
- * Defines CSS styles which will override styles previously set.
- */
- style: PropTypes.object,
-
- /**
- * className of the dropdown element
- */
- className: PropTypes.string,
-
- /**
- * The ID of this component, used to identify dash components
- * in callbacks. The ID needs to be unique across all of the
- * components in an app.
- */
- id: PropTypes.string,
-
- /**
- * Dash-assigned callback that gets fired when the input changes
- */
- setProps: PropTypes.func,
-
- /**
- * Used to allow user interactions in this component to be persisted when
- * the component - or the page - is refreshed. If `persisted` is truthy and
- * hasn't changed from its previous value, a `value` that the user has
- * changed while using the app will keep that change, as long as
- * the new `value` also matches what was given originally.
- * Used in conjunction with `persistence_type`.
- */
- persistence: PropTypes.oneOfType([
- PropTypes.bool,
- PropTypes.string,
- PropTypes.number,
- ]),
-
- /**
- * Properties whose user interactions will persist after refreshing the
- * component or the page. Since only `value` is allowed this prop can
- * normally be ignored.
- */
- persisted_props: PropTypes.arrayOf(PropTypes.oneOf(['value'])),
-
- /**
- * Where persisted user changes will be stored:
- * memory: only kept in memory, reset on page refresh.
- * local: window.localStorage, data is kept after the browser quit.
- * session: window.sessionStorage, data is cleared once the browser quit.
- */
- persistence_type: PropTypes.oneOf(['local', 'session', 'memory']),
-};
-
-Dropdown.defaultProps = {
- clearable: true,
- disabled: false,
- multi: false,
- searchable: true,
- optionHeight: 35,
- maxHeight: 200,
- closeOnSelect: true,
- persisted_props: ['value'],
- persistence_type: 'local',
-};
-
-export const propTypes = Dropdown.propTypes;
-export const defaultProps = Dropdown.defaultProps;
diff --git a/components/dash-core-components/src/components/Dropdown.tsx b/components/dash-core-components/src/components/Dropdown.tsx
new file mode 100644
index 0000000000..dc076e025c
--- /dev/null
+++ b/components/dash-core-components/src/components/Dropdown.tsx
@@ -0,0 +1,52 @@
+import React, {Component, lazy, Suspense} from 'react';
+import {DropdownProps, PersistedProps, PersistenceTypes} from '../types';
+import dropdown from '../utils/LazyLoader/dropdown';
+
+const RealDropdown = lazy(dropdown);
+
+/**
+ * Dropdown is an interactive dropdown element for selecting one or more
+ * items.
+ * The values and labels of the dropdown items are specified in the `options`
+ * property and the selected item(s) are specified with the `value` property.
+ *
+ * Use a dropdown when you have many options (more than 5) or when you are
+ * constrained for space. Otherwise, you can use RadioItems or a Checklist,
+ * which have the benefit of showing the users all of the items at once.
+ */
+export default function Dropdown({
+ clearable = true,
+ disabled = false,
+ multi = false,
+ searchable = true,
+ // eslint-disable-next-line no-magic-numbers
+ optionHeight = 36,
+ // eslint-disable-next-line no-magic-numbers
+ maxHeight = 200,
+ closeOnSelect = !multi,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ persisted_props = [PersistedProps.value],
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ persistence_type = PersistenceTypes.local,
+ ...props
+}: DropdownProps) {
+ return (
+
+
+
+ );
+}
+
+Dropdown.dashPersistence = {
+ persisted_props: [PersistedProps.value],
+ persistence_type: PersistenceTypes.local,
+};
diff --git a/components/dash-core-components/src/components/css/dcc.css b/components/dash-core-components/src/components/css/dcc.css
index ccdc8b325a..fc03ab4424 100644
--- a/components/dash-core-components/src/components/css/dcc.css
+++ b/components/dash-core-components/src/components/css/dcc.css
@@ -7,6 +7,7 @@
--Dash-Fill-Inverse-Strong: #fff;
--Dash-Text-Primary: rgba(0, 18, 77, 0.87);
--Dash-Text-Strong: rgba(0, 9, 38, 0.9);
+ --Dash-Text-Disabled: rgba(0, 21, 89, 0.3);
--Dash-Fill-Primary-Hover: rgba(0, 18, 77, 0.04);
--Dash-Fill-Primary-Active: rgba(0, 18, 77, 0.08);
--Dash-Fill-Disabled: rgba(0, 24, 102, 0.1);
diff --git a/components/dash-core-components/src/components/css/dropdown.css b/components/dash-core-components/src/components/css/dropdown.css
new file mode 100644
index 0000000000..9e1f4b2705
--- /dev/null
+++ b/components/dash-core-components/src/components/css/dropdown.css
@@ -0,0 +1,230 @@
+.dash-dropdown {
+ box-sizing: border-box;
+ margin: calc(var(--Dash-Spacing) * 2) 0;
+}
+
+.dash-dropdown-grid-container {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ justify-items: start;
+ align-items: center;
+ gap: 8px;
+}
+
+.dash-dropdown-grid-container:has(:nth-child(3)) {
+ grid-template-columns: auto 1fr auto;
+}
+
+.dash-dropdown-grid-container:has(:nth-child(4)) {
+ grid-template-columns: auto 1fr auto auto;
+}
+
+.dash-dropdown-trigger,
+.dash-dropdown-content {
+ border-radius: var(--Dash-Spacing);
+ border: 1px solid var(--Dash-Stroke-Strong);
+ color: inherit;
+ text-align: left;
+}
+
+.dash-dropdown-trigger {
+ background: inherit;
+ padding: 6px 12px;
+ width: 100%;
+ min-height: 32px;
+ height: 100%;
+ cursor: pointer;
+ font-size: inherit;
+ overflow: hidden;
+}
+
+.dash-dropdown-trigger:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.dash-dropdown-value {
+ max-width: 100%;
+ text-align: left;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.dash-dropdown-placeholder {
+ color: var(--Dash-Text-Disabled);
+}
+
+.dash-dropdown-value > * {
+ display: inline;
+}
+
+.dash-dropdown-content {
+ background: var(--Dash-Fill-Inverse-Strong);
+ min-width: fit-content;
+ width: var(--radix-popover-trigger-width);
+ overflow-y: auto;
+ z-index: 50;
+ box-shadow: 0px 10px 38px -10px rgba(22, 23, 24, 0.35),
+ 0px 10px 20px -15px rgba(22, 23, 24, 0.2);
+}
+
+.dash-dropdown-value-count,
+.dash-dropdown-trigger-icon {
+ color: var(--Dash-Text-Strong);
+ fill: var(--Dash-Text-Strong);
+ white-space: nowrap;
+ justify-self: end;
+}
+
+.dash-dropdown-trigger-icon {
+ transition: transform 0.15s;
+}
+
+[data-state='open'] .dash-dropdown-trigger-icon {
+ transform: rotate(180deg);
+}
+
+.dash-dropdown-value-count {
+ line-height: 18px;
+ padding: 0 2px;
+ background: var(--Dash-Fill-Weak);
+}
+
+.dash-dropdown-search-container {
+ margin: calc(var(--Dash-Spacing) * 2);
+ padding: var(--Dash-Spacing);
+ border-radius: 4px;
+ border: 1px solid var(--Dash-Stroke-Strong);
+ background: var(--Dash-Fill-Inverse-Strong);
+}
+
+.dash-dropdown-search-container:focus-within {
+ border-color: var(--Dash-Fill-Interactive-Strong);
+}
+
+.dash-dropdown-search-icon,
+.dash-dropdown-clear {
+ width: calc(var(--Dash-Spacing) * 3);
+ height: calc(var(--Dash-Spacing) * 3);
+}
+
+.dash-dropdown-search-container:focus-within .dash-dropdown-search-icon {
+ color: var(--Dash-Fill-Interactive-Strong);
+}
+
+.dash-dropdown-clear {
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ justify-self: end;
+ color: var(--Dash-Text-Strong);
+}
+
+.dash-dropdown-clear:hover {
+ color: var(--Dash-Fill-Interactive-Strong);
+}
+
+.dash-dropdown-clear:focus {
+ outline: 2px solid var(--Dash-Fill-Interactive-Strong);
+ outline-offset: 1px;
+ border-radius: 2px;
+}
+
+.dash-dropdown-search {
+ line-height: calc(var(--Dash-Spacing) * 6);
+ width: 100%;
+ border: none;
+ background: transparent;
+ color: var(--Dash-Text-Strong);
+ outline: none;
+ padding: 0;
+
+ /* Hide the "x" clear button in search inputs */
+ &::-webkit-search-cancel-button {
+ -webkit-appearance: none;
+ appearance: none;
+ display: none;
+ }
+
+ &::-ms-clear {
+ display: none;
+ }
+}
+
+.dash-dropdown-actions {
+ display: flex;
+ gap: calc(var(--Dash-Spacing) * 6);
+ line-height: 18px;
+ padding: var(--Dash-Spacing) calc(var(--Dash-Spacing) * 3);
+ border-top: 1px solid var(--Dash-Fill-Disabled);
+ border-bottom: 1px solid var(--Dash-Fill-Disabled);
+}
+
+.dash-dropdown-action-button {
+ background: none;
+ border: none;
+ cursor: pointer;
+ font-size: 14px;
+ font-weight: 600;
+ padding: 0;
+ text-decoration: none;
+ color: var(--Dash-Text-Disabled);
+ white-space: nowrap;
+}
+
+.dash-dropdown-action-button:hover {
+ color: var(--Dash-Fill-Interactive-Strong);
+}
+
+.dash-dropdown-action-button:focus {
+ text-decoration: underline;
+}
+
+.dash-dropdown-options {
+ overflow-y: auto;
+}
+
+.dash-dropdown-option {
+ padding: var(--Dash-Spacing) calc(var(--Dash-Spacing) * 3);
+ background: var(--Dash-Fill-Inverse-strong);
+ box-shadow: 0 -1px 0 0 var(--Dash-Fill-Disabled) inset;
+ color: var(--Dash-Text-Strong);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ user-select: none;
+ overflow: hidden;
+}
+
+.dash-dropdown-option:not(:has(input[disabled])):hover,
+.dash-dropdown-option:not(:has(input[disabled])):focus-within {
+ color: var(--Dash-Fill-Interactive-Strong);
+ background: var(--Dash-Fill-Interactive-Weak);
+}
+
+.dash-dropdown-option:has(input[disabled]) {
+ color: var(--Dash-Text-Disabled);
+ cursor: not-allowed;
+}
+
+.dash-dropdown-option-text {
+ white-space: pre;
+}
+
+.dash-dropdown-option-checkbox {
+ display: inline-block;
+ margin: 0 calc(var(--Dash-Spacing) * 2) 0 0;
+ box-sizing: border-box;
+ border: 1px solid var(--Dash-Stroke-Strong);
+}
+
+.dash-dropdown-option-checkbox:hover,
+.dash-dropdown-option-checkbox:focus,
+.dash-dropdown-option-checkbox:checked {
+ accent-color: var(--Dash-Fill-Interactive-Strong);
+}
diff --git a/components/dash-core-components/src/fragments/Dropdown.react.js b/components/dash-core-components/src/fragments/Dropdown.react.js
deleted file mode 100644
index c79228fde9..0000000000
--- a/components/dash-core-components/src/fragments/Dropdown.react.js
+++ /dev/null
@@ -1,174 +0,0 @@
-import {isNil, pluck, without, pick, isEmpty} from 'ramda';
-import React, {useState, useCallback, useEffect, useMemo, useRef} from 'react';
-import ReactDropdown from 'react-virtualized-select';
-import createFilterOptions from 'react-select-fast-filter-options';
-import 'react-virtualized-select/styles.css';
-import '../components/css/react-virtualized@9.9.0.css';
-import '../components/css/Dropdown.css';
-
-import {propTypes} from '../components/Dropdown.react';
-import {sanitizeOptions} from '../utils/optionTypes';
-import isEqual from 'react-fast-compare';
-
-// Custom tokenizer, see https://github.com/bvaughn/js-search/issues/43
-// Split on spaces
-const REGEX = /\s+/;
-const TOKENIZER = {
- tokenize(text) {
- return text.split(REGEX).filter(
- // Filter empty tokens
- text => text
- );
- },
-};
-
-const RDProps = [
- 'multi',
- 'clearable',
- 'searchable',
- 'search_value',
- 'placeholder',
- 'disabled',
- 'optionHeight',
- 'maxHeight',
- 'style',
- 'className',
- 'closeOnSelect',
-];
-
-const Dropdown = props => {
- const {
- id,
- clearable,
- multi,
- options,
- setProps,
- search_value,
- style,
- value,
- searchable,
- } = props;
- const [optionsCheck, setOptionsCheck] = useState(null);
- const persistentOptions = useRef(null);
-
- const ctx = window.dash_component_api.useDashContext();
- const loading = ctx.useLoading();
-
- if (!persistentOptions || !isEqual(options, persistentOptions.current)) {
- persistentOptions.current = options;
- }
-
- const [sanitizedOptions, filterOptions] = useMemo(() => {
- let sanitized = sanitizeOptions(options);
-
- const indexes = ['strValue'];
- 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,
- strValue: String(option.value),
- };
- })
- : sanitized;
-
- if (!hasElement) {
- indexes.push('label');
- }
- if (hasSearch) {
- indexes.push('search');
- }
-
- return [
- sanitized,
- createFilterOptions({
- options: sanitized,
- tokenizer: TOKENIZER,
- indexes,
- }),
- ];
- }, [persistentOptions.current]);
-
- const onChange = useCallback(
- selectedOption => {
- if (multi) {
- let value;
- if (isNil(selectedOption)) {
- value = [];
- } else {
- value = pluck('value', selectedOption);
- }
- setProps({value});
- } else {
- let value;
- if (isNil(selectedOption)) {
- value = null;
- } else {
- value = selectedOption.value;
- }
- setProps({value});
- }
- },
- [multi]
- );
-
- const onInputChange = useCallback(
- search_value => setProps({search_value}),
- []
- );
-
- useEffect(() => {
- if (
- !search_value &&
- !isNil(sanitizedOptions) &&
- optionsCheck !== sanitizedOptions &&
- !isNil(value) &&
- !isEmpty(value)
- ) {
- const values = sanitizedOptions.map(option => option.value);
- if (multi && Array.isArray(value)) {
- const invalids = value.filter(v => !values.includes(v));
- if (invalids.length) {
- setProps({value: without(invalids, value)});
- }
- } else {
- if (!values.includes(value)) {
- setProps({value: null});
- }
- }
- setOptionsCheck(sanitizedOptions);
- }
- }, [sanitizedOptions, optionsCheck, multi, value]);
-
- return (
-
-
-
- );
-};
-
-Dropdown.propTypes = propTypes;
-
-export default Dropdown;
diff --git a/components/dash-core-components/src/fragments/Dropdown.tsx b/components/dash-core-components/src/fragments/Dropdown.tsx
new file mode 100644
index 0000000000..ec4d001b6c
--- /dev/null
+++ b/components/dash-core-components/src/fragments/Dropdown.tsx
@@ -0,0 +1,577 @@
+import {isNil, without, isEmpty} from 'ramda';
+import React, {
+ useState,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ MouseEvent,
+} from 'react';
+import {createFilteredOptions} from '../utils/dropdownSearch';
+import {
+ CaretDownIcon,
+ MagnifyingGlassIcon,
+ Cross1Icon,
+} from '@radix-ui/react-icons';
+import * as Popover from '@radix-ui/react-popover';
+import '../components/css/dropdown.css';
+
+import isEqual from 'react-fast-compare';
+import {DetailedDropdownOption, DropdownProps, DropdownValue} from '../types';
+
+interface DropdownOptionProps {
+ index: number;
+ option: DetailedDropdownOption;
+ isSelected: boolean;
+ onClick: (option: DetailedDropdownOption) => void;
+ style?: React.CSSProperties;
+}
+
+function DropdownLabel(
+ props: DetailedDropdownOption & {index: string | number}
+): JSX.Element {
+ const ctx = window.dash_component_api.useDashContext();
+ const ExternalWrapper = window.dash_component_api.ExternalWrapper;
+
+ if (typeof props.label === 'object') {
+ return (
+
+ );
+ }
+ const displayLabel = `${props.label ?? props.value}`;
+ return {displayLabel};
+}
+
+const DropdownOption: React.FC = ({
+ option,
+ isSelected,
+ onClick,
+ style,
+ index,
+}) => {
+ return (
+
+ );
+};
+
+const Dropdown = (props: DropdownProps) => {
+ const {
+ id,
+ className,
+ closeOnSelect,
+ clearable,
+ disabled,
+ maxHeight,
+ multi,
+ options,
+ optionHeight,
+ setProps,
+ searchable,
+ search_value,
+ style,
+ value,
+ } = props;
+ const [optionsCheck, setOptionsCheck] =
+ useState();
+ const [isOpen, setIsOpen] = useState(false);
+ const [displayOptions, setDisplayOptions] = useState<
+ DetailedDropdownOption[]
+ >([]);
+ const persistentOptions = useRef([]);
+ const dropdownContainerRef = useRef(null);
+
+ const ctx = window.dash_component_api.useDashContext();
+ const loading = ctx.useLoading();
+
+ if (!persistentOptions || !isEqual(options, persistentOptions.current)) {
+ persistentOptions.current = options;
+ }
+
+ const {sanitizedOptions, filteredOptions} = useMemo(
+ () =>
+ createFilteredOptions(
+ persistentOptions.current,
+ !!searchable,
+ search_value
+ ),
+ [persistentOptions.current, searchable, search_value]
+ );
+
+ const sanitizedValues: DropdownValue[] = useMemo(() => {
+ if (value instanceof Array) {
+ return value;
+ }
+ if (isNil(value)) {
+ return [];
+ }
+ return [value];
+ }, [value]);
+
+ const toggleOption = useCallback(
+ (option: DetailedDropdownOption) => {
+ const isCurrentlySelected = sanitizedValues.includes(option.value);
+
+ // Close dropdown if closeOnSelect is true (default behavior)
+ if (closeOnSelect !== false) {
+ setIsOpen(false);
+ }
+
+ if (multi) {
+ let newValues: DropdownValue[];
+
+ if (isCurrentlySelected) {
+ // Deselecting: only allow if clearable is true or more than one option selected
+ if (clearable || sanitizedValues.length > 1) {
+ newValues = sanitizedValues.filter(
+ v => v !== option.value
+ );
+ } else {
+ // Cannot deselect the last option when clearable is false
+ return;
+ }
+ } else {
+ // Selecting: add to current selection
+ newValues = [...sanitizedValues, option.value];
+ }
+
+ setProps({value: newValues});
+ } else {
+ let newValue: DropdownValue | null;
+
+ if (isCurrentlySelected) {
+ // Deselecting: only allow if clearable is true
+ if (clearable) {
+ newValue = null;
+ } else {
+ // Cannot deselect when clearable is false
+ return;
+ }
+ } else {
+ // Selecting: set as the single value
+ newValue = option.value;
+ }
+
+ setProps({value: newValue});
+ }
+ },
+ [multi, clearable, closeOnSelect, sanitizedValues]
+ );
+
+ const onInputChange = useCallback(
+ search_value => setProps({search_value}),
+ []
+ );
+
+ const handleClearSearch = useCallback((e: MouseEvent) => {
+ if (e.currentTarget instanceof HTMLElement) {
+ const parentElement = e.currentTarget.parentElement;
+ parentElement?.querySelector('input')?.focus();
+ }
+ setProps({search_value: undefined});
+ }, []);
+
+ useEffect(() => {
+ if (
+ !search_value &&
+ !isNil(sanitizedOptions) &&
+ optionsCheck !== sanitizedOptions &&
+ !isNil(value) &&
+ !isEmpty(value)
+ ) {
+ const values = sanitizedOptions.map(option => option.value);
+ if (Array.isArray(value)) {
+ if (multi) {
+ const invalids = value.filter(v => !values.includes(v));
+ if (invalids.length) {
+ setProps({value: without(invalids, value)});
+ }
+ }
+ } else {
+ if (!values.includes(value)) {
+ setProps({value: null});
+ }
+ }
+ setOptionsCheck(sanitizedOptions);
+ }
+ }, [sanitizedOptions, optionsCheck, multi, value]);
+
+ const displayValue = useMemo(() => {
+ const labels = sanitizedValues.map((val, i) => {
+ const option = sanitizedOptions.find(
+ option => option.value === val
+ );
+ return (
+
+ {option && }
+ {i === sanitizedValues.length - 1 ? '' : ', '}
+
+ );
+ });
+ return labels;
+ }, [sanitizedOptions, sanitizedValues]);
+
+ const canDeselectAll = useMemo(() => {
+ if (clearable) {
+ return true;
+ }
+ return !sanitizedValues.every(value =>
+ displayOptions.some(option => option.value === value)
+ );
+ }, [clearable, sanitizedValues, displayOptions, search_value]);
+
+ const handleOptionClick = useCallback(
+ (option: DetailedDropdownOption) => {
+ toggleOption(option);
+ },
+ [toggleOption]
+ );
+
+ const handleClear = useCallback(() => {
+ const finalValue: DropdownProps['value'] = multi ? [] : null;
+ setProps({value: finalValue});
+ }, [multi]);
+
+ const handleSelectAll = useCallback(() => {
+ if (multi) {
+ const allValues = sanitizedValues.concat(
+ displayOptions
+ .filter(option => !sanitizedValues.includes(option.value))
+ .map(option => option.value)
+ );
+ setProps({value: allValues});
+ }
+ if (closeOnSelect) {
+ setIsOpen(false);
+ }
+ }, [multi, displayOptions, sanitizedValues, closeOnSelect]);
+
+ const handleDeselectAll = useCallback(() => {
+ if (multi) {
+ const withDeselected = sanitizedValues.filter(option => {
+ return !displayOptions.some(
+ displayOption => displayOption.value === option
+ );
+ });
+ setProps({value: withDeselected});
+ }
+ if (closeOnSelect) {
+ setIsOpen(false);
+ }
+ }, [multi, displayOptions, sanitizedValues, closeOnSelect]);
+
+ // Sort options when popover opens - selected options first
+ // Update display options when filtered options or selection changes
+ useEffect(() => {
+ if (isOpen) {
+ // Sort filtered options: selected first, then unselected
+ const sortedOptions = [...filteredOptions].sort((a, b) => {
+ const aSelected = sanitizedValues.includes(a.value);
+ const bSelected = sanitizedValues.includes(b.value);
+
+ if (aSelected && !bSelected) {
+ return -1;
+ }
+ if (!aSelected && bSelected) {
+ return 1;
+ }
+ return 0; // Maintain original order within each group
+ });
+
+ setDisplayOptions(sortedOptions);
+ }
+ }, [filteredOptions, isOpen]); // Removed sanitizedValues to prevent re-sorting on selection changes
+
+ // 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 instanceof HTMLInputElement
+ ) {
+ return;
+ }
+
+ const focusableElements = e.currentTarget.querySelectorAll(
+ 'input[type="search"], input[type="checkbox"]:not([disabled])'
+ ) as NodeListOf;
+
+ // Don't interfere with the event if there aren't any options that the user can interact with
+ if (focusableElements.length === 0) {
+ return;
+ }
+
+ e.preventDefault();
+
+ 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;
+ }
+
+ if (nextIndex > -1) {
+ focusableElements[nextIndex].focus();
+ focusableElements[nextIndex].scrollIntoView({
+ behavior: 'auto',
+ block: 'center',
+ });
+ }
+ }, []);
+
+ // Handle popover open/close
+ const handleOpenChange = useCallback(
+ (open: boolean) => {
+ setIsOpen(open);
+
+ if (open) {
+ // Sort options: selected first, then unselected
+ const selectedOptions: DetailedDropdownOption[] = [];
+ const unselectedOptions: DetailedDropdownOption[] = [];
+
+ // First, collect selected options in the order they appear in the `value` array
+ sanitizedValues.forEach(value => {
+ const option = filteredOptions.find(
+ opt => opt.value === value
+ );
+ if (option) {
+ selectedOptions.push(option);
+ }
+ });
+
+ // Then, collect unselected options in the order they appear in `options` array
+ filteredOptions.forEach(option => {
+ if (!sanitizedValues.includes(option.value)) {
+ unselectedOptions.push(option);
+ }
+ });
+ const sortedOptions = [
+ ...selectedOptions,
+ ...unselectedOptions,
+ ];
+ setDisplayOptions(sortedOptions);
+ } else {
+ setProps({search_value: undefined});
+ }
+ },
+ [filteredOptions, sanitizedValues]
+ );
+
+ return (
+
+
+
+
+
+
+
+ e.preventDefault()}
+ onKeyDown={handleKeyDown}
+ style={{
+ maxHeight: maxHeight ? `${maxHeight}px` : 'auto',
+ }}
+ >
+ {searchable && (
+
+
+
+ onInputChange(e.target.value)
+ }
+ autoFocus
+ />
+ {search_value && (
+
+ )}
+
+ )}
+ {multi && (
+
+
+ {canDeselectAll && (
+
+ )}
+
+ )}
+ {isOpen && (
+
+ {displayOptions.map((option, i) => {
+ const isSelected = multi
+ ? sanitizedValues.includes(option.value)
+ : value === option.value;
+
+ return (
+
+ );
+ })}
+ {search_value &&
+ displayOptions.length === 0 && (
+
+ No options found
+
+ )}
+
+ )}
+
+
+
+
+ );
+};
+
+export default Dropdown;
diff --git a/components/dash-core-components/src/index.ts b/components/dash-core-components/src/index.ts
index 17ba5ad2a7..6eed43faa0 100644
--- a/components/dash-core-components/src/index.ts
+++ b/components/dash-core-components/src/index.ts
@@ -6,7 +6,7 @@ import ConfirmDialogProvider from './components/ConfirmDialogProvider.react';
import DatePickerRange from './components/DatePickerRange.react';
import DatePickerSingle from './components/DatePickerSingle.react';
import Download from './components/Download.react';
-import Dropdown from './components/Dropdown.react';
+import Dropdown from './components/Dropdown';
import Geolocation from './components/Geolocation.react';
import Graph from './components/Graph.react';
import Input from './components/Input';
diff --git a/components/dash-core-components/src/types.ts b/components/dash-core-components/src/types.ts
index a1443623f9..7e51d4b68f 100644
--- a/components/dash-core-components/src/types.ts
+++ b/components/dash-core-components/src/types.ts
@@ -1,3 +1,17 @@
+import React from 'react';
+import {DashComponent} from '@dash-renderer/types/component';
+import ExternalWrapper from '@dash-renderer/wrapper/ExternalWrapper';
+import {useDashContext} from '@dash-renderer/wrapper/DashContext';
+
+declare global {
+ interface Window {
+ dash_component_api: {
+ useDashContext: typeof useDashContext;
+ ExternalWrapper: typeof ExternalWrapper;
+ };
+ }
+}
+
export enum PersistenceTypes {
'local' = 'local',
'session' = 'session',
@@ -329,3 +343,157 @@ export interface RangeSliderProps {
*/
persistence_type?: PersistenceTypes;
}
+
+export type DropdownValue = string | number | boolean;
+
+export type DetailedDropdownOption = {
+ label: string | DashComponent;
+ /**
+ * The value of the option. This value
+ * corresponds to the items specified in the
+ * `value` property.
+ */
+ value: DropdownValue;
+ /**
+ * If true, this option is disabled and cannot be selected.
+ */
+ disabled?: boolean;
+ /**
+ * The HTML 'title' attribute for the option. Allows for
+ * information on hover. For more information on this attribute,
+ * see https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/title
+ */
+ title?: string;
+ /**
+ * Optional search value for the option, to use if the label
+ * is a component or provide a custom search value different
+ * from the label. If no search value and the label is a
+ * component, the `value` will be used for search.
+ */
+ search?: string;
+};
+
+/**
+ * Array of options where the label and the value are the same thing, or an option dict
+ */
+export type DropdownOptionsArray = (DropdownValue | DetailedDropdownOption)[];
+
+/**
+ * Simpler `options` representation in dictionary format. The order is not guaranteed.
+ * {`value1`: `label1`, `value2`: `label2`, ... }
+ * which is equal to
+ * [{label: `label1`, value: `value1`}, {label: `label2`, value: `value2`}, ...]
+ */
+export type DropdownOptionsDict = Record;
+
+export interface DropdownProps {
+ /**
+ * An array of options {label: [string|number], value: [string|number]},
+ * an optional disabled field can be used for each option
+ */
+ options?: DropdownOptionsArray | DropdownOptionsDict;
+
+ /**
+ * The value of the input. If `multi` is false (the default)
+ * then value is just a string that corresponds to the values
+ * provided in the `options` property. If `multi` is true, then
+ * multiple values can be selected at once, and `value` is an
+ * array of items with values corresponding to those in the
+ * `options` prop.
+ */
+ value?: DropdownValue | DropdownValue[] | null;
+
+ /**
+ * If true, the user can select multiple values
+ */
+ multi?: boolean;
+
+ /**
+ * Whether or not the dropdown is "clearable", that is, whether or
+ * not a small "x" appears on the right of the dropdown that removes
+ * the selected value.
+ */
+ clearable?: boolean;
+
+ /**
+ * Whether to enable the searching feature or not
+ */
+ searchable?: boolean;
+
+ /**
+ * The value typed in the DropDown for searching.
+ */
+ search_value?: string;
+
+ /**
+ * The grey, default text shown when no option is selected
+ */
+ placeholder?: string;
+
+ /**
+ * If true, this dropdown is disabled and the selection cannot be changed.
+ */
+ disabled?: boolean;
+
+ /**
+ * If false, the menu of the dropdown will not close once a value is selected.
+ */
+ closeOnSelect?: boolean;
+
+ /**
+ * height of each option. Can be increased when label lengths would wrap around
+ */
+ optionHeight?: number;
+
+ /**
+ * height of the options dropdown.
+ */
+ maxHeight?: number;
+
+ /**
+ * Defines CSS styles which will override styles previously set.
+ */
+ style?: React.CSSProperties;
+
+ /**
+ * className of the dropdown element
+ */
+ className?: string;
+
+ /**
+ * The ID of this component, used to identify dash components
+ * in callbacks. The ID needs to be unique across all of the
+ * components in an app.
+ */
+ id?: string;
+
+ /**
+ * Dash-assigned callback that gets fired when the input changes
+ */
+ setProps: (props: Partial) => void;
+
+ /**
+ * Used to allow user interactions in this component to be persisted when
+ * the component - or the page - is refreshed. If `persisted` is truthy and
+ * hasn't changed from its previous value, a `value` that the user has
+ * changed while using the app will keep that change, as long as
+ * the new `value` also matches what was given originally.
+ * Used in conjunction with `persistence_type`.
+ */
+ persistence?: boolean | string | number;
+
+ /**
+ * Properties whose user interactions will persist after refreshing the
+ * component or the page. Since only `value` is allowed this prop can
+ * normally be ignored.
+ */
+ persisted_props?: PersistedProps[];
+
+ /**
+ * Where persisted user changes will be stored:
+ * memory: only kept in memory, reset on page refresh.
+ * local: window.localStorage, data is kept after the browser quit.
+ * session: window.sessionStorage, data is cleared once the browser quit.
+ */
+ persistence_type?: PersistenceTypes;
+}
diff --git a/components/dash-core-components/src/utils/LazyLoader/dropdown.js b/components/dash-core-components/src/utils/LazyLoader/dropdown.js
deleted file mode 100644
index dca0ca1445..0000000000
--- a/components/dash-core-components/src/utils/LazyLoader/dropdown.js
+++ /dev/null
@@ -1,2 +0,0 @@
-export default () => import(/* webpackChunkName: "dropdown" */ '../../fragments/Dropdown.react');
-
diff --git a/components/dash-core-components/src/utils/LazyLoader/dropdown.ts b/components/dash-core-components/src/utils/LazyLoader/dropdown.ts
new file mode 100644
index 0000000000..10aab78d41
--- /dev/null
+++ b/components/dash-core-components/src/utils/LazyLoader/dropdown.ts
@@ -0,0 +1,2 @@
+export default () =>
+ import(/* webpackChunkName: "dropdown" */ '../../fragments/Dropdown');
diff --git a/components/dash-core-components/src/utils/_LoadingElement.tsx b/components/dash-core-components/src/utils/_LoadingElement.tsx
index ec48cff765..d328f86ce7 100644
--- a/components/dash-core-components/src/utils/_LoadingElement.tsx
+++ b/components/dash-core-components/src/utils/_LoadingElement.tsx
@@ -1,17 +1,7 @@
import React from 'react';
-declare global {
- interface Window {
- dash_component_api: {
- useDashContext: () => {
- useLoading: () => boolean;
- };
- };
- }
-}
-
interface LoadingElementProps {
- children: (props: Record) => React.ReactElement;
+ children: (props: Record) => React.ReactElement;
}
/**
@@ -22,15 +12,15 @@ interface LoadingElementProps {
* See: https://dash.plotly.com/loading-states#check-loading-states-from-components
*/
function LoadingElement({children}: LoadingElementProps) {
- const ctx = window.dash_component_api.useDashContext();
- const loading = ctx.useLoading();
+ const ctx = window.dash_component_api.useDashContext();
+ const loading = ctx.useLoading();
- const additionalProps: Record = {};
- if (loading) {
- additionalProps['data-dash-is-loading'] = true;
- }
+ const additionalProps: Record = {};
+ if (loading) {
+ additionalProps['data-dash-is-loading'] = true;
+ }
- return children(additionalProps);
+ return children(additionalProps);
}
export default LoadingElement;
diff --git a/components/dash-core-components/src/utils/dropdownSearch.ts b/components/dash-core-components/src/utils/dropdownSearch.ts
new file mode 100644
index 0000000000..79e428428c
--- /dev/null
+++ b/components/dash-core-components/src/utils/dropdownSearch.ts
@@ -0,0 +1,92 @@
+import React from 'react';
+import {
+ Search,
+ AllSubstringsIndexStrategy,
+ UnorderedSearchIndex,
+} from 'js-search';
+import {sanitizeOptions} from './optionTypes';
+import {DetailedDropdownOption, DropdownProps} from '../types';
+
+// Custom tokenizer, see https://github.com/bvaughn/js-search/issues/43
+// Split on spaces
+const REGEX = /\s+/;
+const TOKENIZER = {
+ tokenize(text: string) {
+ return text.split(REGEX).filter(
+ // Filter empty tokens
+ text => text
+ );
+ },
+};
+
+interface FilteredOptionsResult {
+ sanitizedOptions: DetailedDropdownOption[];
+ filteredOptions: DetailedDropdownOption[];
+}
+
+/**
+ * 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);
+
+ 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;
+
+ if (!hasElement) {
+ indexes.push('label');
+ }
+ if (hasSearch) {
+ indexes.push('search');
+ }
+
+ // If not searchable or no search value, return all sanitized options
+ if (!searchable || !searchValue) {
+ return {
+ sanitizedOptions: sanitized || [],
+ filteredOptions: sanitized || [],
+ };
+ }
+
+ // Create js-search instance exactly like react-select-fast-filter-options
+ const search = new Search('value'); // valueKey defaults to 'value'
+ search.searchIndex = new UnorderedSearchIndex();
+ search.indexStrategy = new AllSubstringsIndexStrategy();
+ search.tokenizer = TOKENIZER;
+
+ // Add indexes
+ indexes.forEach(index => {
+ search.addIndex(index);
+ });
+
+ // Add documents
+ if (sanitized && sanitized.length > 0) {
+ search.addDocuments(sanitized);
+ }
+
+ const filtered = search.search(searchValue) as DetailedDropdownOption[];
+
+ return {
+ sanitizedOptions: sanitized || [],
+ filteredOptions: filtered || [],
+ };
+}
diff --git a/components/dash-core-components/src/utils/optionTypes.ts b/components/dash-core-components/src/utils/optionTypes.ts
new file mode 100644
index 0000000000..0484707226
--- /dev/null
+++ b/components/dash-core-components/src/utils/optionTypes.ts
@@ -0,0 +1,27 @@
+import React from 'react';
+import {DetailedDropdownOption, DropdownProps, DropdownValue} from '../types';
+
+const isDropdownValue = (option: unknown): option is DropdownValue => {
+ return ['string', 'number', 'boolean'].includes(typeof option);
+};
+
+export const sanitizeOptions = (
+ options: DropdownProps['options']
+): DetailedDropdownOption[] => {
+ if (typeof options === 'object' && !(options instanceof Array)) {
+ return Object.entries(options).map(([value, label]) => ({
+ label: React.isValidElement(label) ? label : String(label),
+ value,
+ }));
+ }
+
+ if (options instanceof Array) {
+ return options.map(option => {
+ return isDropdownValue(option)
+ ? {label: String(option), value: option}
+ : option;
+ });
+ }
+
+ return options;
+};
diff --git a/components/dash-core-components/tests/integration/dropdown/test_clearable_false.py b/components/dash-core-components/tests/integration/dropdown/test_clearable_false.py
index 377400f2de..dfb3f7696d 100644
--- a/components/dash-core-components/tests/integration/dropdown/test_clearable_false.py
+++ b/components/dash-core-components/tests/integration/dropdown/test_clearable_false.py
@@ -1,6 +1,7 @@
+import pytest
from dash import Dash, Input, Output, dcc, html
-
-from selenium.webdriver.common.keys import Keys
+from selenium.common.exceptions import TimeoutException
+from selenium.webdriver.support.ui import WebDriverWait
def test_ddcf001_clearable_false_single(dash_duo):
@@ -30,12 +31,23 @@ def update_value(val):
dash_duo.start_server(app)
- dropdown = dash_duo.find_element("#my-unclearable-dropdown input")
- dropdown.send_keys(Keys.BACKSPACE)
- dash_duo.find_element("#dropdown-value").click()
+ with pytest.raises(TimeoutException):
+ WebDriverWait(dash_duo.driver, 1).until(
+ lambda _: dash_duo.find_element(
+ "#my-unclearable-dropdown .dash-dropdown-clear"
+ )
+ )
+
+ output_text = dash_duo.find_element("#dropdown-value").text
- assert len(dash_duo.find_element("#dropdown-value").text) > 0
+ dash_duo.find_element("#my-unclearable-dropdown ").click()
+ # Clicking the selected item should not de-select it.
+ selected_item = dash_duo.find_element(
+ f'#my-unclearable-dropdown input[value="{output_text}"]'
+ )
+ selected_item.click()
+ assert dash_duo.find_element("#dropdown-value").text == output_text
assert dash_duo.get_logs() == []
@@ -52,6 +64,7 @@ def test_ddcf002_clearable_false_multi(dash_duo):
],
value=["MTL", "SF"],
multi=True,
+ closeOnSelect=False,
clearable=False,
),
html.Div(id="dropdown-value", style={"height": "10px", "width": "10px"}),
@@ -67,11 +80,22 @@ def update_value(val):
dash_duo.start_server(app)
- dropdown = dash_duo.find_element("#my-unclearable-dropdown input")
- dropdown.send_keys(Keys.BACKSPACE)
- dropdown.send_keys(Keys.BACKSPACE)
- dash_duo.find_element("#dropdown-value").click()
+ with pytest.raises(TimeoutException):
+ WebDriverWait(dash_duo.driver, 1).until(
+ lambda _: dash_duo.find_element(
+ "#my-unclearable-dropdown .dash-dropdown-clear"
+ )
+ )
+
+ assert dash_duo.find_element("#dropdown-value").text == "MTL, SF"
+
+ dash_duo.find_element("#my-unclearable-dropdown ").click()
+
+ # Attempt to deselect all items. Everything should deselect until we get to
+ # the last item which cannot be cleared.
+ selected = dash_duo.find_elements("#my-unclearable-dropdown input[checked]")
+ [el.click() for el in selected]
- assert len(dash_duo.find_element("#dropdown-value").text) > 0
+ assert dash_duo.find_element("#dropdown-value").text == "SF"
assert dash_duo.get_logs() == []
diff --git a/components/dash-core-components/tests/integration/dropdown/test_dynamic_options.py b/components/dash-core-components/tests/integration/dropdown/test_dynamic_options.py
index 2a4fc1f0ae..6a0ce8bf0c 100644
--- a/components/dash-core-components/tests/integration/dropdown/test_dynamic_options.py
+++ b/components/dash-core-components/tests/integration/dropdown/test_dynamic_options.py
@@ -27,6 +27,9 @@ def update_options(search_value):
dash_dcc.start_server(app)
+ dropdown = dash_dcc.find_element("#my-dynamic-dropdown")
+ dropdown.click()
+
# Get the inner input used for search value.
input_ = dash_dcc.find_element("#my-dynamic-dropdown input")
@@ -34,12 +37,13 @@ def update_options(search_value):
input_.send_keys("x")
# No options to be found with `x` in them, should show the empty message.
- dash_dcc.wait_for_text_to_equal(".Select-noresults", "No results found")
+ dash_dcc.wait_for_text_to_equal(".dash-dropdown-options", "No options found")
input_.clear()
input_.send_keys("o")
- options = dash_dcc.find_elements("#my-dynamic-dropdown .VirtualizedSelectOption")
+ time.sleep(0.25)
+ options = dash_dcc.find_elements("#my-dynamic-dropdown .dash-dropdown-option")
# Should show all options.
assert len(options) == 3
@@ -47,10 +51,10 @@ def update_options(search_value):
# Searching for `on`
input_.send_keys("n")
- options = dash_dcc.find_elements("#my-dynamic-dropdown .VirtualizedSelectOption")
+ time.sleep(0.25)
+ options = dash_dcc.find_elements("#my-dynamic-dropdown .dash-dropdown-option")
assert len(options) == 1
- print(options)
assert options[0].text == "Montreal"
assert dash_dcc.get_logs() == []
@@ -68,7 +72,7 @@ def test_dddo002_array_comma_value(dash_dcc):
dash_dcc.start_server(app)
- dash_dcc.wait_for_text_to_equal("#react-select-2--value-0", "San Francisco, CA\n ")
+ dash_dcc.wait_for_text_to_equal(".dash-dropdown-value", "San Francisco, CA")
assert dash_dcc.get_logs() == []
@@ -118,11 +122,15 @@ def update_options(search_value):
dash_dcc.start_server(app)
- input_ = dash_dcc.find_element("#dropdown input")
+ dropdown = dash_dcc.find_element("#dropdown")
+ dropdown.click()
+ input_ = dash_dcc.find_element(".dash-dropdown-search")
input_.send_keys("aa1")
- input_.send_keys(Keys.ENTER)
+ input_.send_keys(Keys.ESCAPE)
+ dropdown.click()
+ input_ = dash_dcc.find_element(".dash-dropdown-search")
input_.send_keys("b")
time.sleep(1)
diff --git a/components/dash-core-components/tests/integration/dropdown/test_remove_option.py b/components/dash-core-components/tests/integration/dropdown/test_remove_option.py
index 074e25c75b..89114562ab 100644
--- a/components/dash-core-components/tests/integration/dropdown/test_remove_option.py
+++ b/components/dash-core-components/tests/integration/dropdown/test_remove_option.py
@@ -1,7 +1,5 @@
import json
-
import pytest
-
from dash import Dash, html, dcc, Output, Input, State, Patch
from dash.exceptions import PreventUpdate
@@ -183,9 +181,11 @@ def on_value(value):
dash_dcc.wait_for_text_to_equal("#count-output", "1")
- select_input = dash_dcc.find_element("#drop input")
+ dropdown = dash_dcc.find_element("#drop")
+ dropdown.click()
+ select_input = dash_dcc.find_element("#drop .dash-dropdown-search")
select_input.send_keys("a")
- select_input.send_keys(Keys.ENTER)
+ dash_dcc.find_element("#drop .dash-dropdown-option").send_keys(Keys.SPACE)
dash_dcc.wait_for_text_to_equal("#output", "Value=a")
dash_dcc.wait_for_text_to_equal("#count-output", "2")
diff --git a/components/dash-core-components/tests/integration/dropdown/test_search_value.py b/components/dash-core-components/tests/integration/dropdown/test_search_value.py
index edfa26f4f6..31824a33c3 100644
--- a/components/dash-core-components/tests/integration/dropdown/test_search_value.py
+++ b/components/dash-core-components/tests/integration/dropdown/test_search_value.py
@@ -16,10 +16,15 @@ def update_output(search_value):
dash_duo.start_server(app)
# Get the inner input used for search value.
- input_ = dash_duo.find_element("#dropdown input")
+ dropdown = dash_duo.find_element("#dropdown")
+ dropdown.click()
+ input_ = dash_duo.find_element(".dash-dropdown-search")
dash_duo.wait_for_text_to_equal("#output", 'search_value="something"')
+ dash_duo.find_element(
+ ".dash-dropdown-search-container .dash-dropdown-clear"
+ ).click()
input_.send_keys("x")
dash_duo.wait_for_text_to_equal("#output", 'search_value="x"')
diff --git a/components/dash-core-components/tests/integration/dropdown/test_styles.py b/components/dash-core-components/tests/integration/dropdown/test_styles.py
index ae07f61c79..67b9e8d418 100644
--- a/components/dash-core-components/tests/integration/dropdown/test_styles.py
+++ b/components/dash-core-components/tests/integration/dropdown/test_styles.py
@@ -40,10 +40,10 @@ def test_ddst001_cursor_should_be_pointer(dash_duo):
dash_duo.start_server(app)
dash_duo.find_element("#dropdown").click()
- dash_duo.wait_for_element("#dropdown .Select-menu-outer")
+ dash_duo.wait_for_element("#dropdown .dash-dropdown-options")
items = dash_duo.find_elements(
- "#dropdown .Select-menu-outer .VirtualizedSelectOption"
+ "#dropdown .dash-dropdown-options .dash-dropdown-option"
)
assert items[0].value_of_css_property("cursor") == "pointer"
diff --git a/components/dash-core-components/tests/integration/dropdown/test_visibility.py b/components/dash-core-components/tests/integration/dropdown/test_visibility.py
index 8fa3ed3f79..f4bbea8d6e 100644
--- a/components/dash-core-components/tests/integration/dropdown/test_visibility.py
+++ b/components/dash-core-components/tests/integration/dropdown/test_visibility.py
@@ -40,7 +40,7 @@ def test_ddvi001_fixed_table(dash_duo):
dash_duo.start_server(app)
dash_duo.find_element("#dropdown").click()
- dash_duo.wait_for_element("#dropdown .Select-menu-outer")
+ dash_duo.wait_for_element("#dropdown .dash-dropdown-options")
dash_duo.percy_snapshot("dcc.Dropdown dropdown overlaps table fixed rows/columns")
@@ -59,8 +59,7 @@ def test_ddvi002_maxHeight(dash_duo):
dash_duo.start_server(app)
dash_duo.find_element("#dropdown").click()
- dash_duo.wait_for_element("#dropdown .Select-menu-outer")
-
+ dash_duo.wait_for_element("#dropdown .dash-dropdown-options")
dash_duo.percy_snapshot("dcc.Dropdown dropdown menu maxHeight 800px")
assert dash_duo.get_logs() == []
diff --git a/components/dash-core-components/tests/integration/misc/test_dcc_components_as_props.py b/components/dash-core-components/tests/integration/misc/test_dcc_components_as_props.py
index 9c357d7dab..6145477636 100644
--- a/components/dash-core-components/tests/integration/misc/test_dcc_components_as_props.py
+++ b/components/dash-core-components/tests/integration/misc/test_dcc_components_as_props.py
@@ -1,4 +1,7 @@
+from time import sleep
+from selenium.webdriver.common.keys import Keys
from dash import Dash, dcc, html
+from dash.testing import wait
def test_mdcap001_dcc_components_as_props(dash_dcc):
@@ -49,18 +52,22 @@ def test_mdcap001_dcc_components_as_props(dash_dcc):
dash_dcc.wait_for_text_to_equal("#dropdown h4", "h4")
dash_dcc.wait_for_text_to_equal("#dropdown h6", "h6")
- search_input = dash_dcc.find_element("#dropdown input")
+ search_input = dash_dcc.find_element("#dropdown .dash-dropdown-search")
search_input.send_keys("4")
- options = dash_dcc.find_elements("#dropdown .VirtualizedSelectOption")
+ options = dash_dcc.find_elements("#dropdown .dash-dropdown-option")
- assert len(options) == 1
- assert options[0].text == "h4"
+ wait.until(lambda: len(options) == 1, 1)
+ wait.until(lambda: options[0].text == "h4", 1)
+
+ search_input.send_keys(Keys.ESCAPE)
+ dash_dcc.find_element("#indexed-search").click()
def search_indexed(value, length, texts):
- search = dash_dcc.find_element("#indexed-search input")
+ search = dash_dcc.find_element("#indexed-search .dash-dropdown-search")
dash_dcc.clear_input(search)
search.send_keys(value)
- opts = dash_dcc.find_elements("#indexed-search .VirtualizedSelectOption")
+ sleep(0.25)
+ opts = dash_dcc.find_elements("#indexed-search .dash-dropdown-option")
assert len(opts) == length
assert [o.text for o in opts] == texts
@@ -68,6 +75,4 @@ def search_indexed(value, length, texts):
search_indexed("o", 2, ["one", "two"])
search_indexed("1", 1, ["one"])
search_indexed("uno", 1, ["one"])
- # FIXME clear_input doesnt work well when the input is focused. (miss the o)
- dash_dcc.clear_input("#indexed-search input")
search_indexed("dos", 1, ["two"])
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 29279e7a92..1dfa0783b3 100644
--- a/components/dash-core-components/tests/integration/misc/test_persistence.py
+++ b/components/dash-core-components/tests/integration/misc/test_persistence.py
@@ -129,13 +129,17 @@ def make_output(*args):
dash_dcc.find_element("#datepickersingle input").click()
dash_dcc.select_date_single("datepickersingle", day="20")
- dash_dcc.find_element("#dropdownsingle .Select-input input").send_keys(
+ dash_dcc.find_element("#dropdownsingle").click()
+ dash_dcc.find_element("#dropdownsingle .dash-dropdown-search").send_keys(
"one" + Keys.ENTER
)
+ dash_dcc.find_element("#dropdownsingle .dash-dropdown-option").click()
- dash_dcc.find_element("#dropdownmulti .Select-input input").send_keys(
+ dash_dcc.find_element("#dropdownmulti").click()
+ dash_dcc.find_element("#dropdownmulti .dash-dropdown-search").send_keys(
"six" + Keys.ENTER
)
+ dash_dcc.find_element("#dropdownmulti .dash-dropdown-option").click()
dash_dcc.find_element("#input").send_keys(" maybe")
diff --git a/components/dash-core-components/tests/integration/misc/test_platter.py b/components/dash-core-components/tests/integration/misc/test_platter.py
index 593fe399bd..a5a49230d6 100644
--- a/components/dash-core-components/tests/integration/misc/test_platter.py
+++ b/components/dash-core-components/tests/integration/misc/test_platter.py
@@ -16,7 +16,8 @@ def test_mspl001_dcc_components_platter(platter_app, dash_dcc):
dash_dcc.percy_snapshot("gallery")
- dash_dcc.find_element("#dropdown .Select-input input").send_keys("北")
+ dash_dcc.find_element("#dropdown").click()
+ dash_dcc.find_element("#dropdown .dash-dropdown-search").send_keys("北")
dash_dcc.percy_snapshot("gallery - chinese character")
text_input = dash_dcc.find_element("#textinput")
diff --git a/components/dash-core-components/tests/integration/test_title_props.py b/components/dash-core-components/tests/integration/test_title_props.py
index bae9a05d70..a86bac7112 100644
--- a/components/dash-core-components/tests/integration/test_title_props.py
+++ b/components/dash-core-components/tests/integration/test_title_props.py
@@ -52,8 +52,8 @@ def add_title_to_option(title):
dash_dcc.start_server(app)
elements = [
- dash_dcc.wait_for_element("#dropdown_1 .Select-value"),
- dash_dcc.wait_for_element("#dropdown_2 .Select-value"),
+ dash_dcc.wait_for_element("#dropdown_1 .dash-dropdown-value span"),
+ dash_dcc.wait_for_element("#dropdown_2 .dash-dropdown-value span"),
dash_dcc.wait_for_element("#checklist_1 .Select-value-label"),
dash_dcc.wait_for_element("#radioitems_1 .Select-value-label"),
]
@@ -61,7 +61,6 @@ def add_title_to_option(title):
component_title_input = dash_dcc.wait_for_element("#title_input")
# Empty string title ('') (default for no title)
-
for element in elements:
wait.until(lambda: element.get_attribute("title") == "", 3)
diff --git a/components/dash-core-components/tsconfig.json b/components/dash-core-components/tsconfig.json
index e5f62d430b..d33b8fa98b 100644
--- a/components/dash-core-components/tsconfig.json
+++ b/components/dash-core-components/tsconfig.json
@@ -5,7 +5,11 @@
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
- "types": ["node", "react", "react-dom", "jest"]
+ "types": ["node", "react", "react-dom", "jest"],
+ "baseUrl": ".",
+ "paths": {
+ "@dash-renderer/*": ["../../dash/dash-renderer/src/*"]
+ }
},
"include": ["src"]
}
diff --git a/dash/extract-meta.js b/dash/extract-meta.js
index 461db740a2..aaaf73692e 100755
--- a/dash/extract-meta.js
+++ b/dash/extract-meta.js
@@ -69,7 +69,12 @@ const BANNED_TYPES = [
];
const unionSupport = PRIMITIVES.concat('true', 'false', 'Element', 'enum');
-const reArray = new RegExp(`(${unionSupport.join('|')})\\[\\]`);
+/* Regex to capture typescript unions in different formats:
+ * string[]
+ * (string | number)[]
+ * SomeCustomType[]
+ */
+const reArray = new RegExp(`(${unionSupport.join('|')}|\\(.+\\)|[A-Z][a-zA-Z]*Value)\\[\\]`);
const isArray = rawType => reArray.test(rawType);
diff --git a/dash/testing/browser.py b/dash/testing/browser.py
index b6b1ee14b0..b93bfd2f9a 100644
--- a/dash/testing/browser.py
+++ b/dash/testing/browser.py
@@ -432,10 +432,10 @@ def select_dcc_dropdown(self, elem_or_selector, value=None, index=None):
dropdown = self._get_element(elem_or_selector)
dropdown.click()
- menu = dropdown.find_element(By.CSS_SELECTOR, "div.Select-menu-outer")
+ menu = dropdown.find_element(By.CSS_SELECTOR, ".dash-dropdown-options")
logger.debug("the available options are %s", "|".join(menu.text.split("\n")))
- options = menu.find_elements(By.CSS_SELECTOR, "div.VirtualizedSelectOption")
+ options = menu.find_elements(By.CSS_SELECTOR, ".dash-dropdown-option")
if options:
if isinstance(index, int):
options[index].click()
diff --git a/tests/integration/renderer/test_children_reorder.py b/tests/integration/renderer/test_children_reorder.py
index 3e92c5befe..49ac0eacc7 100644
--- a/tests/integration/renderer/test_children_reorder.py
+++ b/tests/integration/renderer/test_children_reorder.py
@@ -62,28 +62,27 @@ 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.wait_for_text_to_equal(
- f".dropdown_{i} .Select-multi-value-wrapper", "Select..."
- )
- dash_duo.find_element(f".dropdown_{i}").click()
- dash_duo.find_element(f".dropdown_{i} .VirtualizedSelectOption").click()
- dash_duo.wait_for_text_to_equal(
- f".dropdown_{i} .Select-multi-value-wrapper", "×A\n "
- )
dash_duo.find_element(f".dropdown_{i}").click()
- dash_duo.find_element(f".dropdown_{i} .VirtualizedSelectOption").click()
+ dash_duo.find_element(
+ f".dropdown_{i} .dash-dropdown-option:nth-child(1)"
+ ).click()
+ dash_duo.wait_for_text_to_equal(f".dropdown_{i} .dash-dropdown-trigger", "A")
+ dash_duo.find_element(
+ f".dropdown_{i} .dash-dropdown-option:nth-child(2)"
+ ).click()
dash_duo.wait_for_text_to_equal(
- f".dropdown_{i} .Select-multi-value-wrapper", "×A\n ×B\n "
+ f".dropdown_{i} .dash-dropdown-trigger", "A, B\n2 selected"
)
- dash_duo.find_element(f".dropdown_{i}").click()
- dash_duo.find_element(f".dropdown_{i} .VirtualizedSelectOption").click()
+ dash_duo.find_element(
+ f".dropdown_{i} .dash-dropdown-option:nth-child(3)"
+ ).click()
dash_duo.wait_for_text_to_equal(
- f".dropdown_{i} .Select-multi-value-wrapper", "×A\n ×B\n ×C\n "
+ f".dropdown_{i} .dash-dropdown-trigger", "A, B, C\n3 selected"
)
dash_duo.find_element(f".swap_button_{i}").click()
dash_duo.wait_for_text_to_equal(
- f".dropdown_{0} .Select-multi-value-wrapper", "×A\n ×B\n ×C\n "
+ f".dropdown_{0} .dash-dropdown-trigger", "A, B, C\n3 selected"
)
dash_duo.wait_for_text_to_equal(
- f".dropdown_{1} .Select-multi-value-wrapper", "×A\n ×B\n ×C\n "
+ f".dropdown_{1} .dash-dropdown-trigger", "A, B, C\n3 selected"
)
diff --git a/tests/integration/renderer/test_component_as_prop.py b/tests/integration/renderer/test_component_as_prop.py
index 96fa938f2f..157fde2ecf 100644
--- a/tests/integration/renderer/test_component_as_prop.py
+++ b/tests/integration/renderer/test_component_as_prop.py
@@ -454,7 +454,8 @@ def on_button(n_clicks):
# Initial callback
dash_duo.wait_for_text_to_equal("#counter", "1")
- search = dash_duo.wait_for_element("#my-dynamic-dropdown input")
+ dash_duo.wait_for_element("#my-dynamic-dropdown").click()
+ search = dash_duo.wait_for_element("#my-dynamic-dropdown .dash-dropdown-search")
search.send_keys("a")