From ea2a25e735f808c65486c0520e4713d5f666bec9 Mon Sep 17 00:00:00 2001 From: Enrique Moreno Date: Mon, 9 Jun 2025 15:41:54 +0200 Subject: [PATCH 1/7] Started implementing virtualization in Select component --- package-lock.json | 12 ++ package.json | 5 +- packages/lib/src/select/Listbox.tsx | 222 ++++++++++++++++------------ 3 files changed, 141 insertions(+), 98 deletions(-) diff --git a/package-lock.json b/package-lock.json index 27dd2c1e7d..c5d41873b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,9 @@ "apps/*", "packages/*" ], + "dependencies": { + "react-virtuoso": "^4.12.8" + }, "devDependencies": { "@types/color": "^4.2.0", "prettier": "^3.2.5", @@ -16485,6 +16488,15 @@ } } }, + "node_modules/react-virtuoso": { + "version": "4.12.8", + "resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.12.8.tgz", + "integrity": "sha512-NMMKfDBr/+xZZqCQF3tN1SZsh6FwOJkYgThlfnsPLkaEhdyQo0EuWUzu3ix6qjnI7rYwJhMwRGoJBi+aiDfGsA==", + "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", diff --git a/package.json b/package.json index a1a51dde0b..e4aa413f34 100644 --- a/package.json +++ b/package.json @@ -26,5 +26,8 @@ "workspaces": [ "apps/*", "packages/*" - ] + ], + "dependencies": { + "react-virtuoso": "^4.12.8" + } } diff --git a/packages/lib/src/select/Listbox.tsx b/packages/lib/src/select/Listbox.tsx index 43fd840230..12223ea662 100644 --- a/packages/lib/src/select/Listbox.tsx +++ b/packages/lib/src/select/Listbox.tsx @@ -1,4 +1,4 @@ -import { useContext, useLayoutEffect, useRef } from "react"; +import { useContext, useEffect, useLayoutEffect, useRef } from "react"; import styled from "styled-components"; import DxcIcon from "../icon/Icon"; import { HalstackLanguageContext } from "../HalstackContext"; @@ -7,10 +7,11 @@ import { getGroupSelectionType, groupsHaveOptions } from "./utils"; import { 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` box-sizing: border-box; - max-height: 304px; + height: 304px; 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); @@ -21,8 +22,9 @@ const ListboxContainer = styled.div` font-family: var(--typography-font-family); font-size: var(--typography-label-m); font-weight: var(--typography-label-regular); - overflow-y: auto; - ${scrollbarStyles} + /* overflow-y: auto; */ + /* ${scrollbarStyles} */ + /* TODO: ADD SCROLLBARSTYLES TO SELECT AND RESULTSETTABLE */ `; const OptionsSystemMessage = styled.span` @@ -66,97 +68,102 @@ const Listbox = ({ visualFocusIndex, }: ListboxProps) => { const translatedLabels = useContext(HalstackLanguageContext); - const listboxRef = useRef(null); + const virtuosoRef = useRef(null); let globalMappingIndex = (multiple ? enableSelectAll : optional) ? 0 : -1; - const getGroupOption = (groupId: string, option: ListOptionGroupType) => { - if (multiple && enableSelectAll) { - const groupSelectionType = getGroupSelectionType(option.options, currentValue as string[]); - globalMappingIndex++; + const isSearchEmpty = searchable && (options.length === 0 || !groupsHaveOptions(options)); + const isSingleSelectOptional = optional && !multiple; + const isMultipleSelectWithSelectAll = multiple && enableSelectAll; - return ( - - handleGroupOnClick(option)} - option={{ - label: option.label, - value: "", - }} - visualFocused={visualFocusIndex === globalMappingIndex} - /> - - ); - } else - return ( - - {option.label} - - ); - }; + const hasHeader = isSearchEmpty || isSingleSelectOptional || isMultipleSelectWithSelectAll; - const mapOptionFunc = (option: ListOptionType | ListOptionGroupType, mapIndex: number) => { - if ("options" in option) { - const groupId = `${id}-group-${mapIndex}`; + const hasGroupedOptions = (option: ListOptionType | ListOptionGroupType): option is ListOptionGroupType => + "options" in option; - return ( - option.options.length > 0 && ( -
    - {getGroupOption(groupId, option)} - {option.options.map((singleOption) => { - globalMappingIndex++; - const optionId = `${id}-option-${globalMappingIndex}`; - return ( - - ); - })} -
- ) - ); - } else { - globalMappingIndex++; - const optionId = `${id}-option-${globalMappingIndex}`; - return ( - - ); - } + const mapGroupOptionFunc = (option: ListOptionGroupType, mapIndex: number) => { + const getGroupOption = (groupId: string, option: ListOptionGroupType) => { + if (isMultipleSelectWithSelectAll) { + const groupSelectionType = getGroupSelectionType(option.options, currentValue as string[]); + globalMappingIndex++; + return ( + + handleGroupOnClick(option)} + option={{ label: option.label, value: "" }} + visualFocused={globalMappingIndex === visualFocusIndex} + /> + + ); + } else { + return ( + + {option.label} + + ); + } + }; + const groupId = `${id}-group-${mapIndex}`; + return ( + option.options.length > 0 && ( +
    + {getGroupOption(groupId, option)} + {option.options.map((singleOption, childIndex) => { + const optionId = `${id}-option-${mapIndex}-${childIndex}`; + globalMappingIndex++; + return ( + + ); + })} +
+ ) + ); + }; + + const mapOptionFunc = (option: ListOptionType, mapIndex: number) => { + const optionId = `${id}-option-${mapIndex}`; + return ( + + ); }; const getFirstItem = () => { - if (searchable && (options.length === 0 || !groupsHaveOptions(options))) + if (isSearchEmpty) { return ( {translatedLabels.select.noMatchesErrorMessage} ); - else if (optional && !multiple) + } else if (isSingleSelectOptional) { return ( ); - else if (multiple && enableSelectAll) { + } else if (isMultipleSelectWithSelectAll) { return ( ); } + return null; }; useLayoutEffect(() => { - if (currentValue && !multiple) { - const listEl = listboxRef?.current; - const selectedListOptionEl = listEl?.querySelector("[aria-selected='true']") as HTMLUListElement; - listEl?.scrollTo?.({ - top: (selectedListOptionEl.offsetTop ?? 0) - (listEl.clientHeight ?? 0) / 2, - }); + if (!multiple && currentValue && virtuosoRef.current) { + // TODO: Investigate logic for grouped options + const selectedIndex = options.findIndex(({ value }) => value === currentValue); + if (selectedIndex !== -1) { + setTimeout(() => { + virtuosoRef?.current?.scrollToIndex({ + index: selectedIndex, + align: "center", + behavior: "auto", + }); + }, 1); + } } }, [currentValue, multiple]); useLayoutEffect(() => { - const visualFocusedOptionEl = listboxRef.current?.querySelectorAll("[role='option']")[visualFocusIndex]; - visualFocusedOptionEl?.scrollIntoView?.({ - block: "nearest", - inline: "start", - }); + // TODO: Investigate logic for grouped options + if (visualFocusIndex >= 0 && virtuosoRef.current) { + virtuosoRef.current.scrollToIndex({ + index: visualFocusIndex, + align: "center", + behavior: "auto", + }); + } }, [visualFocusIndex]); return ( @@ -220,12 +237,23 @@ const Listbox = ({ onMouseDown={(event) => { event.preventDefault(); }} - ref={listboxRef} role="listbox" style={styles} > - {getFirstItem()} - {options.map(mapOptionFunc)} + { + const adjustedIndex = hasHeader ? index + 1 : index; + return hasGroupedOptions(option) + ? mapGroupOptionFunc(option, adjustedIndex) + : mapOptionFunc(option, adjustedIndex); + }} + components={{ + Header: () => getFirstItem(), + }} + /> ); }; From 4da82417982fdf47cb418697be790f2c45d3c121 Mon Sep 17 00:00:00 2001 From: Enrique Moreno Date: Thu, 10 Jul 2025 14:58:00 +0200 Subject: [PATCH 2/7] Added fix for grouped values --- packages/lib/src/select/ListOption.tsx | 1 + packages/lib/src/select/Listbox.tsx | 273 +++++++++++---------- packages/lib/src/select/Select.stories.tsx | 9 + 3 files changed, 152 insertions(+), 131 deletions(-) diff --git a/packages/lib/src/select/ListOption.tsx b/packages/lib/src/select/ListOption.tsx index ee6d4f69a3..d312e6d45a 100644 --- a/packages/lib/src/select/ListOption.tsx +++ b/packages/lib/src/select/ListOption.tsx @@ -9,6 +9,7 @@ const OptionItem = styled.li<{ visualFocused: OptionProps["visualFocused"]; selected: OptionProps["isSelected"]; }>` + 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 74b6c77a62..7964ffc250 100644 --- a/packages/lib/src/select/Listbox.tsx +++ b/packages/lib/src/select/Listbox.tsx @@ -1,4 +1,4 @@ -import { useContext, useLayoutEffect, useRef } from "react"; +import { useContext, useEffect, useLayoutEffect, useRef } from "react"; import styled from "@emotion/styled"; import DxcIcon from "../icon/Icon"; import { HalstackLanguageContext } from "../HalstackContext"; @@ -69,157 +69,156 @@ const Listbox = ({ }: ListboxProps) => { const translatedLabels = useContext(HalstackLanguageContext); const virtuosoRef = useRef(null); - let globalMappingIndex = (multiple ? enableSelectAll : optional) ? 0 : -1; const isSearchEmpty = searchable && (options.length === 0 || !groupsHaveOptions(options)); const isSingleSelectOptional = optional && !multiple; const isMultipleSelectWithSelectAll = multiple && enableSelectAll; - const hasHeader = isSearchEmpty || isSingleSelectOptional || isMultipleSelectWithSelectAll; + 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 }; - const hasGroupedOptions = (option: ListOptionType | ListOptionGroupType): option is ListOptionGroupType => - "options" in option; + const flattenedOptions: FlattenedItem[] = []; - const mapGroupOptionFunc = (option: ListOptionGroupType, mapIndex: number) => { - const getGroupOption = (groupId: string, option: ListOptionGroupType) => { - if (isMultipleSelectWithSelectAll) { - const groupSelectionType = getGroupSelectionType(option.options, currentValue as string[]); - globalMappingIndex++; + 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) => { + const optionId = `${id}-option-${groupIndex}-${childIndex}`; + flattenedOptions.push({ + type: "option", + option: child, + id: optionId, + isGroupedOption: true, + }); + }); + } else { + const optionId = `${id}-option-${groupIndex}`; + flattenedOptions.push({ + type: "option", + option: opt, + id: optionId, + }); + } + }); + + const renderItem = (index: number) => { + const item = flattenedOptions[index]; + switch (item?.type) { + case "selectAll": return ( - + handleGroupOnClick(option)} - option={{ label: option.label, value: "" }} - visualFocused={globalMappingIndex === visualFocusIndex} + onClick={handleSelectAllOnClick} + option={{ label: translatedLabels.select.selectAllLabel, value: "" }} + visualFocused={getGlobalIndex(visualFocusIndex) === index} /> ); - } else { + + case "optionalItem": return ( - - {option.label} + + ); + + case "groupLabel": + return ( + + {item.label} ); - } - }; - const groupId = `${id}-group-${mapIndex}`; - return ( - option.options.length > 0 && ( -
    - {getGroupOption(groupId, option)} - {option.options.map((singleOption, childIndex) => { - const optionId = `${id}-option-${mapIndex}-${childIndex}`; - globalMappingIndex++; - return ( - - ); - })} -
- ) - ); - }; - const mapOptionFunc = (option: ListOptionType, mapIndex: number) => { - const optionId = `${id}-option-${mapIndex}`; - return ( - - ); - }; + 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} + /> + <> + + ); + } - const getFirstItem = () => { - if (isSearchEmpty) { - return ( - - - {translatedLabels.select.noMatchesErrorMessage} - - ); - } else if (isSingleSelectOptional) { - return ( - - ); - } else if (isMultipleSelectWithSelectAll) { - return ( - + case "option": + return ( - - ); + ); + + default: + return null; } - return null; }; - useLayoutEffect(() => { - if (!multiple && currentValue && virtuosoRef.current) { - // TODO: Investigate logic for grouped options - const selectedIndex = options.findIndex(({ value }) => value === currentValue); - if (selectedIndex !== -1) { - setTimeout(() => { - virtuosoRef?.current?.scrollToIndex({ - index: selectedIndex, - align: "center", - behavior: "auto", - }); - }, 1); - } + 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; } - }, [currentValue, multiple]); + return -1; + }; useLayoutEffect(() => { - // TODO: Investigate logic for grouped options + const globalIndex = getGlobalIndex(visualFocusIndex); if (visualFocusIndex >= 0 && virtuosoRef.current) { virtuosoRef.current.scrollToIndex({ - index: visualFocusIndex, + index: globalIndex, align: "center", behavior: "auto", }); @@ -243,15 +242,27 @@ const Listbox = ({ { - const adjustedIndex = hasHeader ? index + 1 : index; - return hasGroupedOptions(option) - ? mapGroupOptionFunc(option, adjustedIndex) - : mapOptionFunc(option, adjustedIndex); - }} + totalCount={flattenedOptions.length} + initialTopMostItemIndex={ + !multiple && currentValue + ? { + index: + flattenedOptions.findIndex((item) => item.type === "option" && item.option.value === currentValue) ?? + 0, + align: "center", + behavior: "auto", + } + : 0 + } + itemContent={(index) => renderItem(index)} components={{ - Header: () => getFirstItem(), + Header: () => + isSearchEmpty ? ( + + + {translatedLabels.select.noMatchesErrorMessage} + + ) : null, }} /> diff --git a/packages/lib/src/select/Select.stories.tsx b/packages/lib/src/select/Select.stories.tsx index c27a1833bd..4b3a875429 100644 --- a/packages/lib/src/select/Select.stories.tsx +++ b/packages/lib/src/select/Select.stories.tsx @@ -32,6 +32,13 @@ const single_options = [ { label: "Option 04", value: "4" }, ]; +const single_options_virtualized = [ + ...Array.from({ length: 100 }, (_, i) => ({ + label: `Option ${String(i + 1).padStart(2, "0")}`, + value: `${i + 1}`, + })), +]; + const group_options = [ { label: "Group 001", @@ -356,6 +363,8 @@ const Select = () => ( margin={{ top: "xxlarge" }} /> + + <DxcSelect label="Virtualized" options={single_options_virtualized} /> </> ); From 24d702ec24f01391f4a5901da1d53fcc900975e5 Mon Sep 17 00:00:00 2001 From: Enrique Moreno <enrique.moreno@dxc.com> Date: Fri, 11 Jul 2025 12:54:59 +0200 Subject: [PATCH 3/7] Improved accessibility --- packages/lib/src/select/Listbox.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/lib/src/select/Listbox.tsx b/packages/lib/src/select/Listbox.tsx index 7964ffc250..7f7b9a308b 100644 --- a/packages/lib/src/select/Listbox.tsx +++ b/packages/lib/src/select/Listbox.tsx @@ -1,4 +1,4 @@ -import { useContext, useEffect, 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"; @@ -227,8 +227,6 @@ const Listbox = ({ return ( <ListboxContainer - aria-labelledby={ariaLabelledBy} - aria-multiselectable={multiple} id={id} onClick={(event) => { event.stopPropagation(); @@ -236,7 +234,6 @@ const Listbox = ({ onMouseDown={(event) => { event.preventDefault(); }} - role="listbox" style={styles} > <Virtuoso @@ -256,6 +253,9 @@ const Listbox = ({ } itemContent={(index) => renderItem(index)} components={{ + List: forwardRef((props, ref) => ( + <div ref={ref} role="listbox" aria-labelledby={ariaLabelledBy} aria-multiselectable={multiple} {...props} /> + )), Header: () => isSearchEmpty ? ( <OptionsSystemMessage> From 8ead7d5bead1454d0535030dd74d9b0123abe69d Mon Sep 17 00:00:00 2001 From: Enrique Moreno <enrique.moreno@dxc.com> Date: Fri, 11 Jul 2025 13:19:49 +0200 Subject: [PATCH 4/7] Minor restructuring for typing --- packages/lib/src/select/Listbox.tsx | 60 +++++++++++++---------------- packages/lib/src/select/types.ts | 9 ++++- 2 files changed, 34 insertions(+), 35 deletions(-) diff --git a/packages/lib/src/select/Listbox.tsx b/packages/lib/src/select/Listbox.tsx index 7f7b9a308b..b7d3ad402c 100644 --- a/packages/lib/src/select/Listbox.tsx +++ b/packages/lib/src/select/Listbox.tsx @@ -4,7 +4,7 @@ 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"; @@ -74,13 +74,6 @@ const Listbox = ({ const isSingleSelectOptional = optional && !multiple; const isMultipleSelectWithSelectAll = multiple && enableSelectAll; - 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 }; - const flattenedOptions: FlattenedItem[] = []; if (!isSearchEmpty) { @@ -103,24 +96,45 @@ const Listbox = ({ } opt.options.forEach((child, childIndex) => { - const optionId = `${id}-option-${groupIndex}-${childIndex}`; flattenedOptions.push({ type: "option", option: child, - id: optionId, + id: `${id}-option-${groupIndex}-${childIndex}`, isGroupedOption: true, }); }); } else { - const optionId = `${id}-option-${groupIndex}`; flattenedOptions.push({ type: "option", option: opt, - id: optionId, + id: `${id}-option-${groupIndex}`, }); } }); + + useLayoutEffect(() => { + const globalIndex = getGlobalIndex(visualFocusIndex); + if (visualFocusIndex >= 0 && virtuosoRef.current) { + virtuosoRef.current.scrollToIndex({ + index: globalIndex, + align: "center", + behavior: "auto", + }); + } + }, [visualFocusIndex]); + + 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; + }; + const renderItem = (index: number) => { const item = flattenedOptions[index]; switch (item?.type) { @@ -203,28 +217,6 @@ const Listbox = ({ } }; - 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]); - return ( <ListboxContainer id={id} diff --git a/packages/lib/src/select/types.ts b/packages/lib/src/select/types.ts index 9682d05be2..d777e09614 100644 --- a/packages/lib/src/select/types.ts +++ b/packages/lib/src/select/types.ts @@ -215,4 +215,11 @@ export type ListboxProps = { */ export type RefType = HTMLDivElement; -export default Props; +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; From 3810af76d7e42b51b2748e4956434e041ab2c8ea Mon Sep 17 00:00:00 2001 From: Enrique Moreno <enrique.moreno@dxc.com> Date: Fri, 11 Jul 2025 13:20:05 +0200 Subject: [PATCH 5/7] Minor restructuring for typing --- packages/lib/src/select/Listbox.tsx | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/lib/src/select/Listbox.tsx b/packages/lib/src/select/Listbox.tsx index b7d3ad402c..e634055ee3 100644 --- a/packages/lib/src/select/Listbox.tsx +++ b/packages/lib/src/select/Listbox.tsx @@ -112,6 +112,16 @@ const Listbox = ({ } }); + 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); @@ -123,17 +133,6 @@ const Listbox = ({ }); } }, [visualFocusIndex]); - - 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; - }; const renderItem = (index: number) => { const item = flattenedOptions[index]; From 3b06b37585f54bc8abaa21ab66cf877f1b7bce66 Mon Sep 17 00:00:00 2001 From: Enrique Moreno <enrique.moreno@dxc.com> Date: Wed, 16 Jul 2025 13:47:40 +0200 Subject: [PATCH 6/7] Improved accessibility for radix popovers --- packages/lib/src/dropdown/Dropdown.tsx | 2 +- packages/lib/src/select/Select.stories.tsx | 2 ++ packages/lib/src/select/Select.tsx | 1 + packages/lib/src/text-input/TextInput.tsx | 1 + 4 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/lib/src/dropdown/Dropdown.tsx b/packages/lib/src/dropdown/Dropdown.tsx index 654046628a..e904af2526 100644 --- a/packages/lib/src/dropdown/Dropdown.tsx +++ b/packages/lib/src/dropdown/Dropdown.tsx @@ -298,7 +298,7 @@ const DxcDropdown = ({ </Popover.Trigger> </Tooltip> <Popover.Portal> - <Popover.Content asChild sideOffset={1}> + <Popover.Content aria-label="Dropdown options" asChild sideOffset={1}> <DropdownMenu id={menuId} dropdownTriggerId={triggerId} diff --git a/packages/lib/src/select/Select.stories.tsx b/packages/lib/src/select/Select.stories.tsx index 4b3a875429..0935c128e9 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", @@ -786,6 +787,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 6ee60b7e37..a14bfe41af 100644 --- a/packages/lib/src/select/Select.tsx +++ b/packages/lib/src/select/Select.tsx @@ -592,6 +592,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(); diff --git a/packages/lib/src/text-input/TextInput.tsx b/packages/lib/src/text-input/TextInput.tsx index 4568e2363c..48358bcd1e 100644 --- a/packages/lib/src/text-input/TextInput.tsx +++ b/packages/lib/src/text-input/TextInput.tsx @@ -461,6 +461,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(); From 2dcda8e1e838b42eceb22de3844570dfc2bb024d Mon Sep 17 00:00:00 2001 From: Enrique Moreno <enrique.moreno@dxc.com> Date: Thu, 17 Jul 2025 14:14:04 +0200 Subject: [PATCH 7/7] Fixed accessibility problems and updated API and doc --- .../components/select/code/SelectCodePage.tsx | 23 ++ .../select/code/examples/virtualized.ts | 27 +++ package-lock.json | 135 +++++++++++ packages/lib/src/select/Listbox.tsx | 212 +++++++++++++++++- packages/lib/src/select/Select.stories.tsx | 15 +- packages/lib/src/select/Select.tsx | 2 + packages/lib/src/select/types.ts | 6 + 7 files changed, 410 insertions(+), 10 deletions(-) create mode 100644 apps/website/screens/components/select/code/examples/virtualized.ts diff --git a/apps/website/screens/components/select/code/SelectCodePage.tsx b/apps/website/screens/components/select/code/SelectCodePage.tsx index 5b2f84f57e..cc21b593fc 100644 --- a/apps/website/screens/components/select/code/SelectCodePage.tsx +++ b/apps/website/screens/components/select/code/SelectCodePage.tsx @@ -10,6 +10,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; @@ -89,6 +90,24 @@ const sections = [ </td> <td>-</td> </tr> + <tr> + <td> + <DxcFlex direction="column" gap="var(--spacing-gap-xs)" alignItems="baseline"> + <StatusBadge status="new" /> + height + </DxcFlex> + </td> + <td> + <TableCode>string</TableCode> + </td> + <td> + 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. + </td> + <td> + <td>-</td> + </td> + </tr> <tr> <td>helperText</td> <td> @@ -311,6 +330,10 @@ const sections = [ title: "Uncontrolled", content: <Example example={uncontrolled} defaultIsVisible />, }, + { + title: "Virtualized", + content: <Example example={virtualized} defaultIsVisible />, + }, { title: "Error handling", content: <Example example={errorHandling} defaultIsVisible />, 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 0000000000..c85d99fc09 --- /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 ( + <DxcInset space="var(--spacing-padding-xl)"> + <DxcSelect + label="Select a virtualized value" + options={options} + height="300px" + /> + </DxcInset> + ); +}`; + +const scope = { + DxcSelect, + DxcInset, +}; + +export default { code, scope }; diff --git a/package-lock.json b/package-lock.json index fe754af21d..81c54b138e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20643,6 +20643,141 @@ "name": "@dxc-technology/typescript-config", "version": "0.0.0", "license": "MIT" + }, + "apps/website/node_modules/@next/swc-darwin-arm64": { + "version": "14.2.30", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.30.tgz", + "integrity": "sha512-EAqfOTb3bTGh9+ewpO/jC59uACadRHM6TSA9DdxJB/6gxOpyV+zrbqeXiFTDy9uV6bmipFDkfpAskeaDcO+7/g==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "apps/website/node_modules/@next/swc-darwin-x64": { + "version": "14.2.30", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.30.tgz", + "integrity": "sha512-TyO7Wz1IKE2kGv8dwQ0bmPL3s44EKVencOqwIY69myoS3rdpO1NPg5xPM5ymKu7nfX4oYJrpMxv8G9iqLsnL4A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "apps/website/node_modules/@next/swc-linux-arm64-gnu": { + "version": "14.2.30", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.30.tgz", + "integrity": "sha512-I5lg1fgPJ7I5dk6mr3qCH1hJYKJu1FsfKSiTKoYwcuUf53HWTrEkwmMI0t5ojFKeA6Vu+SfT2zVy5NS0QLXV4Q==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "apps/website/node_modules/@next/swc-linux-arm64-musl": { + "version": "14.2.30", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.30.tgz", + "integrity": "sha512-8GkNA+sLclQyxgzCDs2/2GSwBc92QLMrmYAmoP2xehe5MUKBLB2cgo34Yu242L1siSkwQkiV4YLdCnjwc/Micw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "apps/website/node_modules/@next/swc-linux-x64-gnu": { + "version": "14.2.30", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.30.tgz", + "integrity": "sha512-8Ly7okjssLuBoe8qaRCcjGtcMsv79hwzn/63wNeIkzJVFVX06h5S737XNr7DZwlsbTBDOyI6qbL2BJB5n6TV/w==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "apps/website/node_modules/@next/swc-linux-x64-musl": { + "version": "14.2.30", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.30.tgz", + "integrity": "sha512-dBmV1lLNeX4mR7uI7KNVHsGQU+OgTG5RGFPi3tBJpsKPvOPtg9poyav/BYWrB3GPQL4dW5YGGgalwZ79WukbKQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "apps/website/node_modules/@next/swc-win32-arm64-msvc": { + "version": "14.2.30", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.30.tgz", + "integrity": "sha512-6MMHi2Qc1Gkq+4YLXAgbYslE1f9zMGBikKMdmQRHXjkGPot1JY3n5/Qrbg40Uvbi8//wYnydPnyvNhI1DMUW1g==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "apps/website/node_modules/@next/swc-win32-ia32-msvc": { + "version": "14.2.30", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.30.tgz", + "integrity": "sha512-pVZMnFok5qEX4RT59mK2hEVtJX+XFfak+/rjHpyFh7juiT52r177bfFKhnlafm0UOSldhXjj32b+LZIOdswGTg==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "apps/website/node_modules/@next/swc-win32-x64-msvc": { + "version": "14.2.30", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.30.tgz", + "integrity": "sha512-4KCo8hMZXMjpTzs3HOqOGYYwAXymXIy7PEPAXNEcEOyKqkjiDlECumrWziy+JEF0Oi4ILHGxzgQ3YiMGG2t/Lg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } } } } diff --git a/packages/lib/src/select/Listbox.tsx b/packages/lib/src/select/Listbox.tsx index e634055ee3..74dfd50854 100644 --- a/packages/lib/src/select/Listbox.tsx +++ b/packages/lib/src/select/Listbox.tsx @@ -9,9 +9,12 @@ 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["height"]; +}>` box-sizing: border-box; - height: 304px; + 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); @@ -22,9 +25,8 @@ const ListboxContainer = styled.div` font-family: var(--typography-font-family); font-size: var(--typography-label-m); font-weight: var(--typography-label-regular); - /* overflow-y: auto; */ - /* ${scrollbarStyles} */ - /* TODO: ADD SCROLLBARSTYLES TO SELECT AND RESULTSETTABLE */ + overflow-y: auto; + ${scrollbarStyles} `; const OptionsSystemMessage = styled.span` @@ -49,13 +51,14 @@ const GroupLabel = styled.li` font-weight: var(--typography-label-semibold); `; -const Listbox = ({ +const VirtualizedListbox = ({ ariaLabelledBy, currentValue, enableSelectAll, handleOptionOnClick, handleGroupOnClick, handleSelectAllOnClick, + height, id, lastOptionIndex, multiple, @@ -218,6 +221,7 @@ const Listbox = ({ return ( <ListboxContainer + height={height} id={id} onClick={(event) => { event.stopPropagation(); @@ -245,7 +249,14 @@ const Listbox = ({ itemContent={(index) => renderItem(index)} components={{ List: forwardRef((props, ref) => ( - <div ref={ref} role="listbox" aria-labelledby={ariaLabelledBy} aria-multiselectable={multiple} {...props} /> + <div + ref={ref} + role="listbox" + aria-labelledby={ariaLabelledBy} + aria-multiselectable={multiple} + id={id} + {...props} + /> )), Header: () => isSearchEmpty ? ( @@ -260,4 +271,191 @@ const Listbox = ({ ); }; +const NonVirtualizedListbox = ({ + ariaLabelledBy, + currentValue, + enableSelectAll, + handleOptionOnClick, + handleGroupOnClick, + handleSelectAllOnClick, + id, + lastOptionIndex, + multiple, + optional, + optionalItem, + options, + searchable, + selectionType, + styles, + visualFocusIndex, +}: ListboxProps) => { + const translatedLabels = useContext(HalstackLanguageContext); + const listboxRef = useRef<HTMLDivElement>(null); + let globalMappingIndex = (multiple ? enableSelectAll : optional) ? 0 : -1; + + const getGroupOption = (groupId: string, option: ListOptionGroupType) => { + if (multiple && enableSelectAll) { + const groupSelectionType = getGroupSelectionType(option.options, currentValue as string[]); + globalMappingIndex++; + + return ( + <CheckboxContext.Provider value={{ partial: groupSelectionType === "indeterminate" }}> + <ListOption + id={groupId} + isLastOption={lastOptionIndex === globalMappingIndex} + isSelected={groupSelectionType === "checked"} + isSelectAllOption + key={groupId} + multiple={true} + onClick={() => handleGroupOnClick(option)} + option={{ + label: option.label, + value: "", + }} + visualFocused={visualFocusIndex === globalMappingIndex} + /> + </CheckboxContext.Provider> + ); + } else + return ( + <GroupLabel id={groupId} role="presentation"> + {option.label} + </GroupLabel> + ); + }; + + const mapOptionFunc = (option: ListOptionType | ListOptionGroupType, mapIndex: number) => { + if ("options" in option) { + const groupId = `${id}-group-${mapIndex}`; + + return ( + option.options.length > 0 && ( + <ul aria-labelledby={groupId} key={groupId} role="group" style={{ padding: 0, margin: 0 }}> + {getGroupOption(groupId, option)} + {option.options.map((singleOption) => { + globalMappingIndex++; + const optionId = `${id}-option-${globalMappingIndex}`; + return ( + <ListOption + id={optionId} + isGroupedOption + isLastOption={lastOptionIndex === globalMappingIndex} + isSelected={ + multiple ? currentValue.includes(singleOption.value) : currentValue === singleOption.value + } + key={optionId} + multiple={multiple} + onClick={handleOptionOnClick} + option={singleOption} + visualFocused={visualFocusIndex === globalMappingIndex} + /> + ); + })} + </ul> + ) + ); + } else { + globalMappingIndex++; + const optionId = `${id}-option-${globalMappingIndex}`; + return ( + <ListOption + id={optionId} + isLastOption={lastOptionIndex === globalMappingIndex} + isSelected={multiple ? currentValue.includes(option.value) : currentValue === option.value} + key={optionId} + multiple={multiple} + onClick={handleOptionOnClick} + option={option} + visualFocused={visualFocusIndex === globalMappingIndex} + /> + ); + } + }; + + const getFirstItem = () => { + if (searchable && (options.length === 0 || !groupsHaveOptions(options))) + return ( + <OptionsSystemMessage> + <DxcIcon icon="search_off" /> + {translatedLabels.select.noMatchesErrorMessage} + </OptionsSystemMessage> + ); + else if (optional && !multiple) + return ( + <ListOption + id={`${id}-option-${0}`} + isLastOption={lastOptionIndex === 0} + isSelected={currentValue === optionalItem.value} + key={`${id}-option-${optionalItem.value}`} + multiple={false} + onClick={handleOptionOnClick} + option={optionalItem} + visualFocused={visualFocusIndex === 0} + /> + ); + else if (multiple && enableSelectAll) { + return ( + <CheckboxContext.Provider value={{ partial: selectionType === "indeterminate" }}> + <ListOption + id={`${id}-option-${0}`} + isLastOption={lastOptionIndex === 0} + isSelected={selectionType === "checked"} + isSelectAllOption + key={`${id}-option-${optionalItem.value}`} + multiple={true} + onClick={handleSelectAllOnClick} + option={{ + label: translatedLabels.select.selectAllLabel, + value: "", + }} + visualFocused={visualFocusIndex === 0} + /> + </CheckboxContext.Provider> + ); + } + }; + + useLayoutEffect(() => { + if (currentValue && !multiple) { + const listEl = listboxRef?.current; + const selectedListOptionEl = listEl?.querySelector("[aria-selected='true']") as HTMLUListElement; + listEl?.scrollTo?.({ + top: (selectedListOptionEl.offsetTop ?? 0) - (listEl.clientHeight ?? 0) / 2, + }); + } + }, [currentValue, multiple]); + + useLayoutEffect(() => { + const visualFocusedOptionEl = listboxRef.current?.querySelectorAll("[role='option']")[visualFocusIndex]; + visualFocusedOptionEl?.scrollIntoView?.({ + block: "nearest", + inline: "start", + }); + }, [visualFocusIndex]); + + return ( + <ListboxContainer + aria-labelledby={ariaLabelledBy} + aria-multiselectable={multiple} + id={id} + onClick={(event) => { + event.stopPropagation(); + }} + onMouseDown={(event) => { + event.preventDefault(); + }} + ref={listboxRef} + role="listbox" + style={styles} + > + {getFirstItem()} + {options.map(mapOptionFunc)} + </ListboxContainer> + ); +}; + +const Listbox = ({ ...props }: ListboxProps) => { + return props.height ? <VirtualizedListbox {...props} /> : <NonVirtualizedListbox {...props} />; +}; + export default Listbox; diff --git a/packages/lib/src/select/Select.stories.tsx b/packages/lib/src/select/Select.stories.tsx index 0935c128e9..9cf9a677d7 100644 --- a/packages/lib/src/select/Select.stories.tsx +++ b/packages/lib/src/select/Select.stories.tsx @@ -34,7 +34,7 @@ const single_options = [ ]; const single_options_virtualized = [ - ...Array.from({ length: 100 }, (_, i) => ({ + ...Array.from({ length: 10000 }, (_, i) => ({ label: `Option ${String(i + 1).padStart(2, "0")}`, value: `${i + 1}`, })), @@ -364,11 +364,16 @@ const Select = () => ( margin={{ top: "xxlarge" }} /> </ExampleContainer> - <Title title="Virtualized" theme="light" level={4} /> - <DxcSelect label="Virtualized" options={single_options_virtualized} /> </> ); +const VirtualizedSelect = () => ( + <ExampleContainer> + <Title title="Virtualized" theme="light" level={4} /> + <DxcSelect label="Virtualized" options={single_options_virtualized} height="300px" /> + </ExampleContainer> +); + const SelectListbox = () => ( <> <Title title="Listbox" theme="light" level={2} /> @@ -713,6 +718,10 @@ export const Chromatic: Story = { }, }; +export const Virtualization: Story = { + render: VirtualizedSelect, +}; + export const ListboxStates: Story = { render: SelectListbox, play: async ({ canvasElement }) => { diff --git a/packages/lib/src/select/Select.tsx b/packages/lib/src/select/Select.tsx index a14bfe41af..dcca51bd88 100644 --- a/packages/lib/src/select/Select.tsx +++ b/packages/lib/src/select/Select.tsx @@ -179,6 +179,7 @@ const DxcSelect = forwardRef<RefType, SelectPropsType>( disabled = false, enableSelectAll = false, error, + height, helperText, label, margin, @@ -611,6 +612,7 @@ const DxcSelect = forwardRef<RefType, SelectPropsType>( handleOptionOnClick={handleOptionOnClick} handleGroupOnClick={handleSelectAllGroup} handleSelectAllOnClick={handleSelectAllOnClick} + height={height} id={listboxId} lastOptionIndex={lastOptionIndex} multiple={multiple} diff --git a/packages/lib/src/select/types.ts b/packages/lib/src/select/types.ts index d777e09614..c4bf5a9f85 100644 --- a/packages/lib/src/select/types.ts +++ b/packages/lib/src/select/types.ts @@ -49,6 +49,11 @@ type CommonProps = { * An array of objects representing the selectable options. */ options: ListOptionType[] | ListOptionGroupType[]; + /** + * 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. + */ + height?: string; /** * Helper text to be placed above the select. */ @@ -198,6 +203,7 @@ export type ListboxProps = { handleGroupOnClick: (group: ListOptionGroupType) => void; handleOptionOnClick: (option: ListOptionType) => void; handleSelectAllOnClick: () => void; + height?: string; id: string; lastOptionIndex: number; multiple: boolean;