diff --git a/apps/website/screens/components/select/code/SelectCodePage.tsx b/apps/website/screens/components/select/code/SelectCodePage.tsx index 1caa66452..8116e1229 100644 --- a/apps/website/screens/components/select/code/SelectCodePage.tsx +++ b/apps/website/screens/components/select/code/SelectCodePage.tsx @@ -9,6 +9,7 @@ import groups from "./examples/groupedOptions"; import icons from "./examples/icons"; import Code, { TableCode, ExtendedTableCode } from "@/common/Code"; import StatusBadge from "@/common/StatusBadge"; +import virtualized from "./examples/virtualized"; const optionsType = `{ label: string; @@ -295,6 +296,24 @@ const sections = [ - + + + + + virtualizedHeight + + + + string + + + A fixed height must be set to enable virtualization. If no height is provided, the select will + automatically adjust to the height of its content, and virtualization will not be applied. + + + - + + ), @@ -310,6 +329,10 @@ const sections = [ title: "Uncontrolled", content: , }, + { + title: "Virtualized", + content: , + }, { title: "Error handling", content: , diff --git a/apps/website/screens/components/select/code/examples/virtualized.ts b/apps/website/screens/components/select/code/examples/virtualized.ts new file mode 100644 index 000000000..9afea2cfd --- /dev/null +++ b/apps/website/screens/components/select/code/examples/virtualized.ts @@ -0,0 +1,27 @@ +import { DxcSelect, DxcInset } from "@dxc-technology/halstack-react"; + +const code = `() => { + const options = [ + ...Array.from({ length: 10000 }, (_, i) => ({ + label: \`Option \${String(i + 1).padStart(2, "0")}\`, + value: \`\${i + 1}\`, + })), + ]; + + return ( + + + + ); +}`; + +const scope = { + DxcSelect, + DxcInset, +}; + +export default { code, scope }; diff --git a/package-lock.json b/package-lock.json index 3fb697ed8..760cd84c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -87,9 +87,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", - "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz", + "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==", "dev": true, "license": "MIT", "engines": { @@ -103,7 +103,6 @@ "dev": true, "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-compilation-targets": "^7.27.2", @@ -128,9 +127,9 @@ } }, "node_modules/@babel/eslint-parser": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.28.0.tgz", - "integrity": "sha512-N4ntErOlKvcbTt01rr5wj3y55xnIdx1ymrfIr8C2WnM1Y9glFgWaGDEULJIazOX3XM9NRzhfJ6zZnQ1sBNWU+w==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.28.4.tgz", + "integrity": "sha512-Aa+yDiH87980jR6zvRfFuCR1+dLb00vBydhTL+zI992Rz/wQhSvuxjmOOuJOgO3XmakO6RykRGD2S1mq1AtgHA==", "dev": true, "license": "MIT", "dependencies": { @@ -437,7 +436,7 @@ "license": "MIT", "dependencies": { "@babel/template": "^7.27.2", - "@babel/types": "^7.28.2" + "@babel/types": "^7.28.4" }, "engines": { "node": ">=6.9.0" @@ -933,9 +932,9 @@ } }, "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.0.tgz", - "integrity": "sha512-gKKnwjpdx5sER/wl0WN0efUBFzF/56YZO0RJrSYP4CljXnP31ByY7fol89AzomdlLNzI36AvOTmYHsnZTCkq8Q==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.4.tgz", + "integrity": "sha512-1yxmvN0MJHOhPVmAsmoW5liWwoILobu/d/ShymZmj867bAdxGbehIrew1DuLpw2Ukv+qDSSPQdYW1dLNE7t11A==", "dev": true, "license": "MIT", "dependencies": { @@ -1387,9 +1386,9 @@ } }, "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.0.tgz", - "integrity": "sha512-9VNGikXxzu5eCiQjdE4IZn8sb9q7Xsk5EXLDBKUYg1e/Tve8/05+KJEtcxGxAgCY5t/BpKQM+JEL/yT4tvgiUA==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.4.tgz", + "integrity": "sha512-373KA2HQzKhQCYiRVIRr+3MjpCObqzDlyrM6u4I201wL8Mp2wHf7uB8GhDwis03k2ti8Zr65Zyyqs1xOxUF/Ew==", "dev": true, "license": "MIT", "dependencies": { @@ -1397,7 +1396,7 @@ "@babel/helper-plugin-utils": "^7.27.1", "@babel/plugin-transform-destructuring": "^7.28.0", "@babel/plugin-transform-parameters": "^7.27.7", - "@babel/traverse": "^7.28.0" + "@babel/traverse": "^7.28.4" }, "engines": { "node": ">=6.9.0" @@ -4213,6 +4212,17 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -4321,9 +4331,9 @@ } }, "node_modules/@next/env": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.2.tgz", - "integrity": "sha512-Qe06ew4zt12LeO6N7j8/nULSOe3fMXE4dM6xgpBQNvdzyK1sv5y4oAP3bq4LamrvGCZtmRYnW8URFCeX5nFgGg==", + "version": "15.5.3", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.3.tgz", + "integrity": "sha512-RSEDTRqyihYXygx/OJXwvVupfr9m04+0vH8vyy0HfZ7keRto6VX9BbEk0J2PUk0VGy6YhklJUSrgForov5F9pw==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -4379,9 +4389,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.2.tgz", - "integrity": "sha512-8bGt577BXGSd4iqFygmzIfTYizHb0LGWqH+qgIF/2EDxS5JsSdERJKA8WgwDyNBZgTIIA4D8qUtoQHmxIIquoQ==", + "version": "15.5.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.3.tgz", + "integrity": "sha512-nzbHQo69+au9wJkGKTU9lP7PXv0d1J5ljFpvb+LnEomLtSbJkbZyEs6sbF3plQmiOB2l9OBtN2tNSvCH1nQ9Jg==", "cpu": [ "arm64" ], @@ -4395,9 +4405,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.2.tgz", - "integrity": "sha512-2DjnmR6JHK4X+dgTXt5/sOCu/7yPtqpYt8s8hLkHFK3MGkka2snTv3yRMdHvuRtJVkPwCGsvBSwmoQCHatauFQ==", + "version": "15.5.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.3.tgz", + "integrity": "sha512-w83w4SkOOhekJOcA5HBvHyGzgV1W/XvOfpkrxIse4uPWhYTTRwtGEM4v/jiXwNSJvfRvah0H8/uTLBKRXlef8g==", "cpu": [ "x64" ], @@ -4411,9 +4421,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.2.tgz", - "integrity": "sha512-3j7SWDBS2Wov/L9q0mFJtEvQ5miIqfO4l7d2m9Mo06ddsgUK8gWfHGgbjdFlCp2Ek7MmMQZSxpGFqcC8zGh2AA==", + "version": "15.5.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.3.tgz", + "integrity": "sha512-+m7pfIs0/yvgVu26ieaKrifV8C8yiLe7jVp9SpcIzg7XmyyNE7toC1fy5IOQozmr6kWl/JONC51osih2RyoXRw==", "cpu": [ "arm64" ], @@ -4427,9 +4437,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.2.tgz", - "integrity": "sha512-s6N8k8dF9YGc5T01UPQ08yxsK6fUow5gG1/axWc1HVVBYQBgOjca4oUZF7s4p+kwhkB1bDSGR8QznWrFZ/Rt5g==", + "version": "15.5.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.3.tgz", + "integrity": "sha512-u3PEIzuguSenoZviZJahNLgCexGFhso5mxWCrrIMdvpZn6lkME5vc/ADZG8UUk5K1uWRy4hqSFECrON6UKQBbQ==", "cpu": [ "arm64" ], @@ -4443,9 +4453,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.2.tgz", - "integrity": "sha512-o1RV/KOODQh6dM6ZRJGZbc+MOAHww33Vbs5JC9Mp1gDk8cpEO+cYC/l7rweiEalkSm5/1WGa4zY7xrNwObN4+Q==", + "version": "15.5.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.3.tgz", + "integrity": "sha512-lDtOOScYDZxI2BENN9m0pfVPJDSuUkAD1YXSvlJF0DKwZt0WlA7T7o3wrcEr4Q+iHYGzEaVuZcsIbCps4K27sA==", "cpu": [ "x64" ], @@ -4459,9 +4469,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.2.tgz", - "integrity": "sha512-/VUnh7w8RElYZ0IV83nUcP/J4KJ6LLYliiBIri3p3aW2giF+PAVgZb6mk8jbQSB3WlTai8gEmCAr7kptFa1H6g==", + "version": "15.5.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.3.tgz", + "integrity": "sha512-9vWVUnsx9PrY2NwdVRJ4dUURAQ8Su0sLRPqcCCxtX5zIQUBES12eRVHq6b70bbfaVaxIDGJN2afHui0eDm+cLg==", "cpu": [ "x64" ], @@ -4475,9 +4485,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.2.tgz", - "integrity": "sha512-sMPyTvRcNKXseNQ/7qRfVRLa0VhR0esmQ29DD6pqvG71+JdVnESJaHPA8t7bc67KD5spP3+DOCNLhqlEI2ZgQg==", + "version": "15.5.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.3.tgz", + "integrity": "sha512-1CU20FZzY9LFQigRi6jM45oJMU3KziA5/sSG+dXeVaTm661snQP6xu3ykGxxwU5sLG3sh14teO/IOEPVsQMRfA==", "cpu": [ "arm64" ], @@ -4491,9 +4501,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.2.tgz", - "integrity": "sha512-W5VvyZHnxG/2ukhZF/9Ikdra5fdNftxI6ybeVKYvBPDtyx7x4jPPSNduUkfH5fo3zG0JQ0bPxgy41af2JX5D4Q==", + "version": "15.5.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.3.tgz", + "integrity": "sha512-JMoLAq3n3y5tKXPQwCK5c+6tmwkuFDa2XAxz8Wm4+IVthdBZdZGh+lmiLUHg9f9IDwIQpUjp+ysd6OkYTyZRZw==", "cpu": [ "x64" ], @@ -8583,9 +8593,9 @@ } }, "node_modules/axe-playwright": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/axe-playwright/-/axe-playwright-2.1.0.tgz", - "integrity": "sha512-tY48SX56XaAp16oHPyD4DXpybz8Jxdz9P7exTjF/4AV70EGUavk+1fUPWirM0OYBR+YyDx6hUeDvuHVA6fB9YA==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/axe-playwright/-/axe-playwright-2.2.1.tgz", + "integrity": "sha512-7M/Wm5DUd/5bhLmbx2sJDcF+uSIRLIVi1Pm4P9RBBava+sowALSkivaUJe1+/ox+XplIweDhTbkSDwJRWPN/kQ==", "dev": true, "license": "MIT", "dependencies": { @@ -16558,12 +16568,12 @@ "peer": true }, "node_modules/next": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/next/-/next-15.5.2.tgz", - "integrity": "sha512-H8Otr7abj1glFhbGnvUt3gz++0AF1+QoCXEBmd/6aKbfdFwrn0LpA836Ed5+00va/7HQSDD+mOoVhn3tNy3e/Q==", + "version": "15.5.3", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.3.tgz", + "integrity": "sha512-r/liNAx16SQj4D+XH/oI1dlpv9tdKJ6cONYPwwcCC46f2NjpaRWY+EKCzULfgQYV6YKXjHBchff2IZBSlZmJNw==", "license": "MIT", "dependencies": { - "@next/env": "15.5.2", + "@next/env": "15.5.3", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", @@ -16576,14 +16586,14 @@ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "15.5.2", - "@next/swc-darwin-x64": "15.5.2", - "@next/swc-linux-arm64-gnu": "15.5.2", - "@next/swc-linux-arm64-musl": "15.5.2", - "@next/swc-linux-x64-gnu": "15.5.2", - "@next/swc-linux-x64-musl": "15.5.2", - "@next/swc-win32-arm64-msvc": "15.5.2", - "@next/swc-win32-x64-msvc": "15.5.2", + "@next/swc-darwin-arm64": "15.5.3", + "@next/swc-darwin-x64": "15.5.3", + "@next/swc-linux-arm64-gnu": "15.5.3", + "@next/swc-linux-arm64-musl": "15.5.3", + "@next/swc-linux-x64-gnu": "15.5.3", + "@next/swc-linux-x64-musl": "15.5.3", + "@next/swc-win32-arm64-msvc": "15.5.3", + "@next/swc-win32-x64-msvc": "15.5.3", "sharp": "^0.34.3" }, "peerDependencies": { @@ -18656,6 +18666,16 @@ } } }, + "node_modules/react-virtuoso": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.14.0.tgz", + "integrity": "sha512-fR+eiCvirSNIRvvCD7ueJPRsacGQvUbjkwgWzBZXVq+yWypoH7mRUvWJzGHIdoRaCZCT+6mMMMwIG2S1BW3uwA==", + "license": "MIT", + "peerDependencies": { + "react": ">=16 || >=17 || >= 18 || >= 19", + "react-dom": ">=16 || >=17 || >= 18 || >=19" + } + }, "node_modules/reactcss": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz", @@ -18873,9 +18893,9 @@ "license": "MIT" }, "node_modules/regenerate-unicode-properties": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.0.tgz", - "integrity": "sha512-DqHn3DwbmmPVzeKj9woBadqmXxLvQoQIwu7nopMc72ztvxVmVk2SBhSnx67zuye5TP+lJsb/TBQsjLKhnDf3MA==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", + "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", "dev": true, "license": "MIT", "dependencies": { @@ -18917,18 +18937,18 @@ } }, "node_modules/regexpu-core": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.2.0.tgz", - "integrity": "sha512-H66BPQMrv+V16t8xtmq+UC0CBpiTBA60V8ibS1QVReIp8T1z8hwFxqcGzm9K6lgsN7sB5edVH8a+ze6Fqm4weA==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.3.1.tgz", + "integrity": "sha512-DzcswPr252wEr7Qz8AyAVbfyBDKLoYp6eRA1We2Fa9qirRFSdtkP5sHr3yglDKy2BbA0fd2T+j/CUSKes3FeVQ==", "dev": true, "license": "MIT", "dependencies": { "regenerate": "^1.4.2", - "regenerate-unicode-properties": "^10.2.0", + "regenerate-unicode-properties": "^10.2.2", "regjsgen": "^0.8.0", "regjsparser": "^0.12.0", "unicode-match-property-ecmascript": "^2.0.0", - "unicode-match-property-value-ecmascript": "^2.1.0" + "unicode-match-property-value-ecmascript": "^2.2.1" }, "engines": { "node": ">=4" @@ -20770,14 +20790,14 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.14", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", - "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dev": true, "license": "MIT", "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" + "fdir": "^6.5.0", + "picomatch": "^4.0.3" }, "engines": { "node": ">=12.0.0" @@ -21431,9 +21451,9 @@ } }, "node_modules/unicode-match-property-value-ecmascript": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.0.tgz", - "integrity": "sha512-4IehN3V/+kkr5YeSSDDQG8QLqO26XpL2XP3GQtqwlT/QYSECAwFztxVHjlbh0+gjJ3XmNLS0zDsbgs9jWKExLg==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", "dev": true, "license": "MIT", "engines": { @@ -22710,6 +22730,7 @@ "@radix-ui/react-tooltip": "^1.1.0", "color": "^4.2.3", "dayjs": "^1.11.11", + "react-virtuoso": "^4.12.8", "slugify": "^1.6.6" }, "devDependencies": { diff --git a/packages/lib/package.json b/packages/lib/package.json index eb916c5df..5db49e434 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -41,6 +41,7 @@ "@radix-ui/react-tooltip": "^1.1.0", "color": "^4.2.3", "dayjs": "^1.11.11", + "react-virtuoso": "^4.12.8", "slugify": "^1.6.6" }, "devDependencies": { diff --git a/packages/lib/src/dialog/Dialog.stories.tsx b/packages/lib/src/dialog/Dialog.stories.tsx index a13819bee..57ecdb360 100644 --- a/packages/lib/src/dialog/Dialog.stories.tsx +++ b/packages/lib/src/dialog/Dialog.stories.tsx @@ -317,7 +317,7 @@ const ScrollingDialog = () => ( const DialogWithDateInput = () => ( - + ); diff --git a/packages/lib/src/dropdown/Dropdown.tsx b/packages/lib/src/dropdown/Dropdown.tsx index 90084ed57..bf3b95918 100644 --- a/packages/lib/src/dropdown/Dropdown.tsx +++ b/packages/lib/src/dropdown/Dropdown.tsx @@ -298,7 +298,7 @@ const DxcDropdown = ({ - + ` + list-style: none; padding: var(--spacing-padding-none) var(--spacing-padding-xs); cursor: pointer; ${({ selected }) => selected && "background-color: var(--color-bg-secondary-lighter);"}; diff --git a/packages/lib/src/select/Listbox.tsx b/packages/lib/src/select/Listbox.tsx index a8de521c9..c01baf9f6 100644 --- a/packages/lib/src/select/Listbox.tsx +++ b/packages/lib/src/select/Listbox.tsx @@ -1,16 +1,20 @@ -import { useContext, useLayoutEffect, useRef } from "react"; +import { useContext, useLayoutEffect, useRef, forwardRef } from "react"; import styled from "@emotion/styled"; import DxcIcon from "../icon/Icon"; import { HalstackLanguageContext } from "../HalstackContext"; import ListOption from "./ListOption"; import { getGroupSelectionType, groupsHaveOptions } from "./utils"; -import { ListboxProps, ListOptionGroupType, ListOptionType } from "./types"; +import { FlattenedItem, ListboxProps, ListOptionGroupType, ListOptionType } from "./types"; import { scrollbarStyles } from "../styles/scroll"; import CheckboxContext from "../checkbox/CheckboxContext"; +import { Virtuoso, VirtuosoHandle } from "react-virtuoso"; -const ListboxContainer = styled.div` +const ListboxContainer = styled.div<{ + height?: ListboxProps["virtualizedHeight"]; +}>` box-sizing: border-box; max-height: 304px; + height: ${(props) => (props.height ? props.height : undefined)}; padding: var(--spacing-padding-xxs) var(--spacing-padding-none); background-color: var(--color-bg-neutral-lightest); border: var(--border-width-s) var(--border-style-default) var(--border-color-neutral-medium); @@ -46,7 +50,227 @@ const GroupLabel = styled.li` font-weight: var(--typography-label-semibold); `; -const Listbox = ({ +const VirtualizedListbox = ({ + ariaLabelledBy, + currentValue, + enableSelectAll, + handleOptionOnClick, + handleGroupOnClick, + handleSelectAllOnClick, + id, + lastOptionIndex, + multiple, + optional, + optionalItem, + options, + searchable, + selectionType, + styles, + virtualizedHeight, + visualFocusIndex, +}: ListboxProps) => { + const translatedLabels = useContext(HalstackLanguageContext); + const virtuosoRef = useRef(null); + + const isSearchEmpty = searchable && (options.length === 0 || !groupsHaveOptions(options)); + const isSingleSelectOptional = optional && !multiple; + const isMultipleSelectWithSelectAll = multiple && enableSelectAll; + + const flattenedOptions: FlattenedItem[] = []; + + if (!isSearchEmpty) { + if (isSingleSelectOptional) { + flattenedOptions.push({ type: "optionalItem" }); + } else if (isMultipleSelectWithSelectAll) { + flattenedOptions.push({ type: "selectAll" }); + } + } + + options.forEach((opt, groupIndex) => { + if ("options" in opt) { + const groupId = `${id}-group-${groupIndex}`; + if (opt.options.length === 0) return; + + if (multiple && enableSelectAll) { + flattenedOptions.push({ type: "groupHeader", group: opt, id: groupId }); + } else { + flattenedOptions.push({ type: "groupLabel", label: opt.label, id: groupId }); + } + + opt.options.forEach((child, childIndex) => { + flattenedOptions.push({ + type: "option", + option: child, + id: `${id}-option-${groupIndex}-${childIndex}`, + isGroupedOption: true, + }); + }); + } else { + flattenedOptions.push({ + type: "option", + option: opt, + id: `${id}-option-${groupIndex}`, + }); + } + }); + + const getGlobalIndex = (index: number) => { + const focusableOptions = flattenedOptions.filter((item) => item.type !== "groupLabel"); + if (focusableOptions[index]) { + const actualIndex = flattenedOptions.findIndex((option) => { + return option.type === focusableOptions[index]?.type && option.id === focusableOptions[index]?.id; + }); + return actualIndex; + } + return -1; + }; + + useLayoutEffect(() => { + const globalIndex = getGlobalIndex(visualFocusIndex); + if (visualFocusIndex >= 0 && virtuosoRef.current) { + virtuosoRef.current.scrollToIndex({ + index: globalIndex, + align: "center", + behavior: "auto", + }); + } + }, [visualFocusIndex]); + + const renderItem = (index: number) => { + const item = flattenedOptions[index]; + switch (item?.type) { + case "selectAll": + return ( + + + + ); + + case "optionalItem": + return ( + + ); + + case "groupLabel": + return ( + + {item.label} + + ); + + case "groupHeader": { + const groupSelectionType = getGroupSelectionType(item.group.options, currentValue as string[]); + return ( + + handleGroupOnClick(item.group)} + option={{ label: item.group.label, value: "" }} + visualFocused={getGlobalIndex(visualFocusIndex) === index} + /> + <> + + ); + } + + case "option": + return ( + + ); + + default: + return null; + } + }; + + return ( + { + event.stopPropagation(); + }} + onMouseDown={(event) => { + event.preventDefault(); + }} + style={styles} + > + item.type === "option" && item.option.value === currentValue) ?? + 0, + align: "center", + behavior: "auto", + } + : 0 + } + itemContent={(index) => renderItem(index)} + components={{ + List: forwardRef((props, ref) => ( +
+ )), + Header: () => + isSearchEmpty ? ( + + + {translatedLabels.select.noMatchesErrorMessage} + + ) : null, + }} + /> + + ); +}; + +const NonVirtualizedListbox = ({ ariaLabelledBy, currentValue, enableSelectAll, @@ -229,4 +453,8 @@ const Listbox = ({ ); }; +const Listbox = ({ ...props }: ListboxProps) => { + return props.virtualizedHeight ? : ; +}; + export default Listbox; diff --git a/packages/lib/src/select/Select.stories.tsx b/packages/lib/src/select/Select.stories.tsx index 8fa80b785..efe71ca72 100644 --- a/packages/lib/src/select/Select.stories.tsx +++ b/packages/lib/src/select/Select.stories.tsx @@ -7,6 +7,7 @@ import DxcFlex from "../flex/Flex"; import Listbox from "./Listbox"; import DxcSelect from "./Select"; import { Meta, StoryObj } from "@storybook/react"; +import { waitFor } from "@testing-library/react"; export default { title: "Select", @@ -32,6 +33,13 @@ const single_options = [ { label: "Option 04", value: "4" }, ]; +const single_options_virtualized = [ + ...Array.from({ length: 10000 }, (_, i) => ({ + label: `Option ${String(i + 1).padStart(2, "0")}`, + value: `${i + 1}`, + })), +]; + const group_options = [ { label: "Group 001", @@ -359,6 +367,13 @@ const Select = () => ( ); +const VirtualizedSelect = () => ( + + + <DxcSelect label="Virtualized" options={single_options_virtualized} virtualizedHeight="300px" /> + </ExampleContainer> +); + const SelectListbox = () => ( <> <Title title="Listbox" theme="light" level={2} /> @@ -703,6 +718,15 @@ export const Chromatic: Story = { }, }; +export const Virtualization: Story = { + render: VirtualizedSelect, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const select = canvas.getByRole("combobox"); + await userEvent.click(select); + }, +}; + export const ListboxStates: Story = { render: SelectListbox, play: async ({ canvasElement }) => { @@ -777,6 +801,7 @@ export const ListboxOptionWithEllipsisTooltip: Story = { render: TooltipOption, play: async ({ canvasElement }) => { const canvas = within(canvasElement); + await waitFor(() => canvas.findByText("Optiond123456789012345678901234567890123451231231")); await userEvent.hover(canvas.getByText("Optiond123456789012345678901234567890123451231231")); await userEvent.hover(canvas.getByText("Optiond123456789012345678901234567890123451231231")); }, diff --git a/packages/lib/src/select/Select.tsx b/packages/lib/src/select/Select.tsx index 491b59188..274077705 100644 --- a/packages/lib/src/select/Select.tsx +++ b/packages/lib/src/select/Select.tsx @@ -194,6 +194,7 @@ const DxcSelect = forwardRef<RefType, SelectPropsType>( size = "medium", tabIndex = 0, value, + virtualizedHeight, }, ref ) => { @@ -593,6 +594,7 @@ const DxcSelect = forwardRef<RefType, SelectPropsType>( </Popover.Trigger> <Popover.Portal> <Popover.Content + aria-label="Select options" onCloseAutoFocus={(event) => { // Avoid select to lose focus when the list is closed event.preventDefault(); @@ -611,6 +613,7 @@ const DxcSelect = forwardRef<RefType, SelectPropsType>( handleOptionOnClick={handleOptionOnClick} handleGroupOnClick={handleSelectAllGroup} handleSelectAllOnClick={handleSelectAllOnClick} + virtualizedHeight={virtualizedHeight} id={listboxId} lastOptionIndex={lastOptionIndex} multiple={multiple} diff --git a/packages/lib/src/select/types.ts b/packages/lib/src/select/types.ts index 9682d05be..0ea85c328 100644 --- a/packages/lib/src/select/types.ts +++ b/packages/lib/src/select/types.ts @@ -34,33 +34,43 @@ export type ListOptionGroupType = { type CommonProps = { /** - * Text to be placed above the select. + * Specifies a string to be used as the name for the select element when no `label` is provided. */ - label?: string; + ariaLabel?: string; /** - * Name attribute of the input element. This attribute will allow users - * to find the component's value during the submit event. In this event, - * the component's value will always be a regular string, for both single - * and multiple selection modes, being a single option value in the first case - * and more than one value when multiple selection is available, separated by commas. + * If true, the component will be disabled. */ - name?: string; + disabled?: boolean; /** - * An array of objects representing the selectable options. + * If it is a defined value and also a truthy string, the component will + * change its appearance, showing the error below the select component. + * If the defined value is an empty string, it will reserve a space below + * the component for a future error, but it would not change its look. In + * case of being undefined or null, both the appearance and the space for + * the error message would not be modified. */ - options: ListOptionType[] | ListOptionGroupType[]; + error?: string; /** * Helper text to be placed above the select. */ helperText?: string; /** - * Text to be put as placeholder of the select. + * Text to be placed above the select. */ - placeholder?: string; + label?: string; /** - * If true, the component will be disabled. + * Size of the margin to be applied to the component ('xxsmall' | 'xsmall' | 'small' | 'medium' | 'large' | 'xlarge' | 'xxlarge'). + * You can pass an object with 'top', 'bottom', 'left' and 'right' properties in order to specify different margin sizes. */ - disabled?: boolean; + margin?: Space | Margin; + /** + * Name attribute of the input element. This attribute will allow users + * to find the component's value during the submit event. In this event, + * the component's value will always be a regular string, for both single + * and multiple selection modes, being a single option value in the first case + * and more than one value when multiple selection is available, separated by commas. + */ + name?: string; /** * If true, the select will be optional, showing '(Optional)' * next to the label and adding a default first option with an empty string as value, @@ -70,23 +80,17 @@ type CommonProps = { */ optional?: boolean; /** - * If true, enables search functionality. + * An array of objects representing the selectable options. */ - searchable?: boolean; + options: ListOptionType[] | ListOptionGroupType[]; /** - * If it is a defined value and also a truthy string, the component will - * change its appearance, showing the error below the select component. - * If the defined value is an empty string, it will reserve a space below - * the component for a future error, but it would not change its look. In - * case of being undefined or null, both the appearance and the space for - * the error message would not be modified. + * Text to be put as placeholder of the select. */ - error?: string; + placeholder?: string; /** - * Size of the margin to be applied to the component ('xxsmall' | 'xsmall' | 'small' | 'medium' | 'large' | 'xlarge' | 'xxlarge'). - * You can pass an object with 'top', 'bottom', 'left' and 'right' properties in order to specify different margin sizes. + * If true, enables search functionality. */ - margin?: Space | Margin; + searchable?: boolean; /** * Size of the component. */ @@ -96,12 +100,17 @@ type CommonProps = { */ tabIndex?: number; /** - * Specifies a string to be used as the name for the select element when no `label` is provided. + * A fixed height must be set to enable virtualization. + * If no height is provided, the select will automatically adjust to the height of its content, and virtualization will not be applied. */ - ariaLabel?: string; + virtualizedHeight?: string; }; type SingleSelect = CommonProps & { + /** + * Initial value of the select, only when it is uncontrolled. + */ + defaultValue?: string; /** * Enables users to select multiple items from the list. */ @@ -113,14 +122,12 @@ type SingleSelect = CommonProps & { */ multiple?: false; /** - * Initial value of the select, only when it is uncontrolled. - */ - defaultValue?: string; - /** - * Value of the select. If undefined, the component will be uncontrolled - * and the value will be managed internally by the component. + * This function will be called when the select loses the focus. An + * object including the value and the error (if the value + * selected is not valid) will be passed to this function. If there is no error, + * error will not be defined. */ - value?: string; + onBlur?: (val: { value: string; error?: string }) => void; /** * This function will be called when the user selects an option. * An object including the current value and the error (if the value entered is not valid) @@ -128,15 +135,17 @@ type SingleSelect = CommonProps & { */ onChange?: (val: { value: string; error?: string }) => void; /** - * This function will be called when the select loses the focus. An - * object including the value and the error (if the value - * selected is not valid) will be passed to this function. If there is no error, - * error will not be defined. + * Value of the select. If undefined, the component will be uncontrolled + * and the value will be managed internally by the component. */ - onBlur?: (val: { value: string; error?: string }) => void; + value?: string; }; type MultipleSelect = CommonProps & { + /** + * Initial value of the select, only when it is uncontrolled. + */ + defaultValue?: string[]; /** * Enables users to select multiple items from the list. */ @@ -148,14 +157,12 @@ type MultipleSelect = CommonProps & { */ multiple: true; /** - * Initial value of the select, only when it is uncontrolled. - */ - defaultValue?: string[]; - /** - * Value of the select. If undefined, the component will be uncontrolled - * and the value will be managed internally by the component. + * This function will be called when the select loses the focus. An + * object including the selected values and the error (if the value + * selected is not valid) will be passed to this function. If there is no error, + * error will be null. */ - value?: string[]; + onBlur?: (val: { value: string[]; error?: string }) => void; /** * This function will be called when the user selects an option. * An object including the current selected values and the error (if the value entered is not valid) @@ -163,12 +170,10 @@ type MultipleSelect = CommonProps & { */ onChange?: (val: { value: string[]; error?: string }) => void; /** - * This function will be called when the select loses the focus. An - * object including the selected values and the error (if the value - * selected is not valid) will be passed to this function. If there is no error, - * error will be null. + * Value of the select. If undefined, the component will be uncontrolled + * and the value will be managed internally by the component. */ - onBlur?: (val: { value: string[]; error?: string }) => void; + value?: string[]; }; type Props = SingleSelect | MultipleSelect; @@ -207,6 +212,7 @@ export type ListboxProps = { searchable: boolean; selectionType: "checked" | "unchecked" | "indeterminate"; styles: CSSProperties; + virtualizedHeight?: string; visualFocusIndex: number; }; @@ -215,4 +221,11 @@ export type ListboxProps = { */ export type RefType = HTMLDivElement; +export type FlattenedItem = + | { type: "selectAll"; id?: never } + | { type: "optionalItem"; id?: never } + | { type: "groupLabel"; label: string; id: string } + | { type: "groupHeader"; group: ListOptionGroupType; id: string } + | { type: "option"; option: ListOptionType; id: string; isGroupedOption?: boolean }; + export default Props; diff --git a/packages/lib/src/text-input/TextInput.tsx b/packages/lib/src/text-input/TextInput.tsx index 9299563f1..9f685bccd 100644 --- a/packages/lib/src/text-input/TextInput.tsx +++ b/packages/lib/src/text-input/TextInput.tsx @@ -465,6 +465,7 @@ const DxcTextInput = forwardRef<RefType, TextInputPropsType>( </Popover.Trigger> <Popover.Portal> <Popover.Content + aria-label="Suggestions" onCloseAutoFocus={(event) => { // Avoid select to lose focus when the list is closed event.preventDefault();