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")