diff --git a/apps/website/screens/components/select/code/SelectCodePage.tsx b/apps/website/screens/components/select/code/SelectCodePage.tsx index f7eb17c85c..5b2f84f57e 100644 --- a/apps/website/screens/components/select/code/SelectCodePage.tsx +++ b/apps/website/screens/components/select/code/SelectCodePage.tsx @@ -61,6 +61,21 @@ const sections = [ false + + + + + enableSelectAll + + + + boolean + + Enables users to select multiple items from the list. + + false + + error @@ -305,7 +320,7 @@ const sections = [ content: , }, { - title: "Icons", + title: "Icon usage", content: , }, ], diff --git a/apps/website/screens/components/select/code/examples/groupedOptions.ts b/apps/website/screens/components/select/code/examples/groupedOptions.ts index 984d930118..5959da8a3c 100644 --- a/apps/website/screens/components/select/code/examples/groupedOptions.ts +++ b/apps/website/screens/components/select/code/examples/groupedOptions.ts @@ -3,30 +3,26 @@ import { DxcSelect, DxcInset } from "@dxc-technology/halstack-react"; const code = `() => { const options = [ { - label: "Managers", + label: "Designers", options: [ - { label: "Pablo", value: "pablo" }, - { label: "Marcos", value: "marcos" }, - { label: "Rachel", value: "rachel" }, - { label: "Margaret", value: "margaret" }, + { label: "Lara", value: "lara" }, + { label: "Irene", value: "irene" } ], }, { - label: "Engineers", + label: "Developers", options: [ - { label: "Yiminghe", value: "yiminghe" }, - { label: "Manuel", value: "manuel" }, - { label: "Bryan", value: "bryan" }, - { label: "Anand", value: "anand" }, + { label: "Jairo", value: "jairo" }, + { label: "Enrique", value: "enrique" }, { label: "Jiale", value: "jiale" }, + { label: "Iván", value: "ivan" } ], }, { - label: "Designers", + label: "Managers", options: [ - { label: "Alex", value: "alex" }, - { label: "Tim", value: "tim" }, - { label: "Jairo", value: "Jairo" }, + { label: "Aitor", value: "aitor" }, + { label: "Raquel", value: "Raquel" } ], }, ]; diff --git a/apps/website/screens/components/select/code/examples/icons.ts b/apps/website/screens/components/select/code/examples/icons.ts index 35d6558819..6fcbf9f830 100644 --- a/apps/website/screens/components/select/code/examples/icons.ts +++ b/apps/website/screens/components/select/code/examples/icons.ts @@ -54,9 +54,11 @@ const code = `() => { return ( ); diff --git a/apps/website/screens/components/select/overview/SelectOverviewPage.tsx b/apps/website/screens/components/select/overview/SelectOverviewPage.tsx index 97579c7ac9..bfd1594738 100644 --- a/apps/website/screens/components/select/overview/SelectOverviewPage.tsx +++ b/apps/website/screens/components/select/overview/SelectOverviewPage.tsx @@ -8,6 +8,7 @@ import multiple from "./examples/multiple"; import filterable from "./examples/filterable"; import Image from "@/common/Image"; import anatomy from "./images/select_anatomy.png"; +import Code from "@/common/Code"; const sections = [ { @@ -158,6 +159,61 @@ const sections = [ ), }, + { + title: "Select all and grouped selection", + content: ( + + Select all and grouped selection options provide users with efficient ways + to manage large sets of checkable items within a list, dropdown, or multi-select component. These options + help reduce interaction costs and minimize repetitive actions, especially when dealing with categorized data + or bulk selection scenarios. + + ), + subSections: [ + { + title: "Select all", + content: ( + <> + + The select all option (enableSelectAll) allows users to quickly select + or deselect all items in a list with a single action. + + + When the flag is set to true, a checkbox labelled with "Select all" text is placed at the top + of the list or above grouped items. It should visually reflect the current state: + + + Unselected when no items are selected. + Selected when all items are selected. + Indeterminate when only some items are selected. + + + ), + }, + { + title: "Grouped selection", + content: ( + <> + + Grouped selection enables users to manage selections within categorized sections of a + list. Each group has its own header with a group-level checkbox. This allows users to: + + + Quickly select all items within a specific group. + Understand how items are organized. + + Maintain more granular control over selection without losing the efficiency of bulk actions. + + + + Just like the global select all, group checkboxes also reflect the selection state (selected, + unselected, indeterminate) based on the individual items in that group. + + + ), + }, + ], + }, ], }, { diff --git a/apps/website/screens/components/select/overview/images/select_anatomy.png b/apps/website/screens/components/select/overview/images/select_anatomy.png index ce1fc75885..f6358f86af 100644 Binary files a/apps/website/screens/components/select/overview/images/select_anatomy.png and b/apps/website/screens/components/select/overview/images/select_anatomy.png differ diff --git a/apps/website/screens/principles/localization/LocalizationPage.tsx b/apps/website/screens/principles/localization/LocalizationPage.tsx index c206fe1310..1c18708f5a 100644 --- a/apps/website/screens/principles/localization/LocalizationPage.tsx +++ b/apps/website/screens/principles/localization/LocalizationPage.tsx @@ -539,12 +539,6 @@ const sections = [ - - - noMatchesErrorMessage - - No matches found - actionClearSelectionTitle @@ -557,6 +551,18 @@ const sections = [ Clear search + + + noMatchesErrorMessage + + No matches found + + + + selectAllLabel + + Select all + ), diff --git a/packages/lib/src/checkbox/Checkbox.tsx b/packages/lib/src/checkbox/Checkbox.tsx index 471992656d..2542536b16 100644 --- a/packages/lib/src/checkbox/Checkbox.tsx +++ b/packages/lib/src/checkbox/Checkbox.tsx @@ -3,6 +3,7 @@ import styled from "styled-components"; import { HalstackLanguageContext } from "../HalstackContext"; import CheckboxPropsType, { RefType } from "./types"; import { calculateWidth, icons, spaces } from "./utils"; +import CheckboxContext from "./CheckboxContext"; const Label = styled.span<{ disabled: CheckboxPropsType["disabled"]; @@ -93,6 +94,7 @@ const DxcCheckbox = forwardRef( const [innerChecked, setInnerChecked] = useState(defaultChecked); const checkboxRef = useRef(null); const translatedLabels = useContext(HalstackLanguageContext); + const { partial } = useContext(CheckboxContext) ?? {}; const handleOnChange = () => { if (!disabled && !readOnly) { @@ -142,7 +144,7 @@ const DxcCheckbox = forwardRef( ref={checkboxRef} tabIndex={disabled ? -1 : tabIndex} > - {(checked ?? innerChecked) ? icons.checked : icons.unchecked} + {partial ? icons.partial : (checked ?? innerChecked) ? icons.checked : icons.unchecked} ), + partial: ( + + + + ), unchecked: ( ` + ${({ isSelectAllOption }) => isSelectAllOption && "font-weight: var(--typography-label-semibold);"} overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -77,6 +77,7 @@ const ListOption = ({ isGroupedOption = false, isLastOption, isSelected, + isSelectAllOption = false, multiple, onClick, option, @@ -107,13 +108,15 @@ const ListOption = ({ selected={isSelected} visualFocused={visualFocused} > - + {multiple && } {option.icon && ( {typeof option.icon === "string" ? : option.icon} )} - {option.label} + + {option.label} + {!multiple && isSelected && } diff --git a/packages/lib/src/select/Listbox.tsx b/packages/lib/src/select/Listbox.tsx index 394b3ea060..43fd840230 100644 --- a/packages/lib/src/select/Listbox.tsx +++ b/packages/lib/src/select/Listbox.tsx @@ -3,9 +3,10 @@ import styled from "styled-components"; import DxcIcon from "../icon/Icon"; import { HalstackLanguageContext } from "../HalstackContext"; import ListOption from "./ListOption"; -import { groupsHaveOptions } from "./utils"; +import { getGroupSelectionType, groupsHaveOptions } from "./utils"; import { ListboxProps, ListOptionGroupType, ListOptionType } from "./types"; import { scrollbarStyles } from "../styles/scroll"; +import CheckboxContext from "../checkbox/CheckboxContext"; const ListboxContainer = styled.div` box-sizing: border-box; @@ -49,7 +50,10 @@ const GroupLabel = styled.li` const Listbox = ({ ariaLabelledBy, currentValue, + enableSelectAll, handleOptionOnClick, + handleGroupOnClick, + handleSelectAllOnClick, id, lastOptionIndex, multiple, @@ -57,31 +61,61 @@ const Listbox = ({ optionalItem, options, searchable, + selectionType, styles, visualFocusIndex, }: ListboxProps) => { const translatedLabels = useContext(HalstackLanguageContext); const listboxRef = useRef(null); + let globalMappingIndex = (multiple ? enableSelectAll : optional) ? 0 : -1; - let globalIndex = optional && !multiple ? 0 : -1; + const getGroupOption = (groupId: string, option: ListOptionGroupType) => { + if (multiple && enableSelectAll) { + const groupSelectionType = getGroupSelectionType(option.options, currentValue as string[]); + globalMappingIndex++; + + return ( + + handleGroupOnClick(option)} + option={{ + label: option.label, + value: "", + }} + visualFocused={visualFocusIndex === globalMappingIndex} + /> + + ); + } else + return ( + + {option.label} + + ); + }; const mapOptionFunc = (option: ListOptionType | ListOptionGroupType, mapIndex: number) => { - const groupId = `${id}-group-${mapIndex}`; if ("options" in option) { + const groupId = `${id}-group-${mapIndex}`; + return ( option.options.length > 0 && ( -
    - - {option.label} - +
      + {getGroupOption(groupId, option)} {option.options.map((singleOption) => { - globalIndex++; - const optionId = `${id}-option-${globalIndex}`; + globalMappingIndex++; + const optionId = `${id}-option-${globalMappingIndex}`; return ( ); })} @@ -97,20 +131,63 @@ const Listbox = ({ ) ); } else { - globalIndex++; - const optionId = `${id}-option-${globalIndex}`; + globalMappingIndex++; + const optionId = `${id}-option-${globalMappingIndex}`; return ( + ); + } + }; + + const getFirstItem = () => { + if (searchable && (options.length === 0 || !groupsHaveOptions(options))) + return ( + + + {translatedLabels.select.noMatchesErrorMessage} + + ); + else if (optional && !multiple) + return ( + ); + else if (multiple && enableSelectAll) { + return ( + + + + ); } }; @@ -147,26 +224,7 @@ const Listbox = ({ role="listbox" style={styles} > - {searchable && (options.length === 0 || !groupsHaveOptions(options)) ? ( - - - {translatedLabels.select.noMatchesErrorMessage} - - ) : ( - optional && - !multiple && ( - - ) - )} + {getFirstItem()} {options.map(mapOptionFunc)} ); diff --git a/packages/lib/src/select/Select.stories.tsx b/packages/lib/src/select/Select.stories.tsx index 857c953253..c27a1833bd 100644 --- a/packages/lib/src/select/Select.stories.tsx +++ b/packages/lib/src/select/Select.stories.tsx @@ -1,4 +1,4 @@ -import { userEvent, within } from "@storybook/test"; +import { fireEvent, userEvent, within } from "@storybook/test"; import ExampleContainer from "../../.storybook/components/ExampleContainer"; import Title from "../../.storybook/components/Title"; import preview from "../../.storybook/preview"; @@ -218,10 +218,9 @@ const optionsWithEllipsis = [ const Select = () => ( <> - <ExampleContainer> <Title title="Default" theme="light" level={4} /> - <DxcSelect options={single_options} /> + <DxcSelect label="Default" options={single_options} /> </ExampleContainer> <ExampleContainer pseudoState="pseudo-hover"> <Title title="Hovered" theme="light" level={4} /> @@ -360,190 +359,220 @@ const Select = () => ( </> ); -const SelectListbox = () => { - return ( - <> - <Title title="Listbox" theme="light" level={2} /> - <ExampleContainer> - <Title - title="List dialog uses a Radix Popover to appear over elements with a certain z-index" - theme="light" - level={3} - /> - <div - style={{ - position: "relative", - display: "flex", - flexDirection: "column", - gap: "20px", - height: "150px", - width: "min-content", - marginBottom: "100px", - padding: "20px", - border: "1px solid black", - borderRadius: "4px", - overflow: "auto", - zIndex: "1300", - }} - > - <DxcSelect label="Label" options={single_options} optional placeholder="Choose an option" /> - <button style={{ zIndex: "1", width: "100px" }}>Submit</button> - </div> - </ExampleContainer> - <Title title="Listbox option states" theme="light" level={3} /> - <ExampleContainer pseudoState="pseudo-hover"> - <Title title="Hovered option" theme="light" level={4} /> - <label id="x8-label">Choose an option</label> - <Listbox - ariaLabelledBy="x8-label" - id="x8" - currentValue="" - options={one_option} - visualFocusIndex={-1} - lastOptionIndex={0} - multiple={false} - optional={false} - optionalItem={{ label: "Empty", value: "" }} - searchable={false} - handleOptionOnClick={() => {}} - styles={{ width: 360 }} - /> - </ExampleContainer> - <ExampleContainer pseudoState="pseudo-active"> - <Title title="Active option" theme="light" level={4} /> - <label id="x9-label">Choose an option</label> - <Listbox - ariaLabelledBy="x9-label" - id="x9" - currentValue="" - options={one_option} - visualFocusIndex={-1} - lastOptionIndex={0} - multiple={false} - optional={false} - optionalItem={{ label: "Empty", value: "" }} - searchable={false} - handleOptionOnClick={() => {}} - styles={{ width: 360 }} - /> - </ExampleContainer> - <ExampleContainer> - <Title title="Focused option" theme="light" level={4} /> - <label id="x10-label">Choose an option</label> - <Listbox - ariaLabelledBy="x10-label" - id="x10" - currentValue="" - options={one_option} - visualFocusIndex={0} - lastOptionIndex={0} - multiple={false} - optional={false} - optionalItem={{ label: "Empty", value: "" }} - searchable={false} - handleOptionOnClick={() => {}} - styles={{ width: 360 }} - /> - </ExampleContainer> - <ExampleContainer pseudoState="pseudo-hover"> - <Title title="Hovered selected option" theme="light" level={4} /> - <label id="x11-label">Choose an option</label> - <Listbox - ariaLabelledBy="x11-label" - id="x11" - currentValue="1" - options={single_options} - visualFocusIndex={-1} - lastOptionIndex={3} - multiple={false} - optional={false} - optionalItem={{ label: "Empty", value: "" }} - searchable={false} - handleOptionOnClick={() => {}} - styles={{ width: 360 }} - /> - </ExampleContainer> - <ExampleContainer pseudoState="pseudo-active"> - <Title title="Active selected option" theme="light" level={4} /> - <label id="x12-label">Choose an option</label> - <Listbox - ariaLabelledBy="x12-label" - id="x12" - currentValue="2" - options={single_options} - visualFocusIndex={0} - lastOptionIndex={3} - multiple={false} - optional={false} - optionalItem={{ label: "Empty", value: "" }} - searchable={false} - handleOptionOnClick={() => {}} - styles={{ width: 360 }} - /> - </ExampleContainer> - <Title title="Listbox with icons" theme="light" level={3} /> - <ExampleContainer> - <Title title="Icons (SVGs)" theme="light" level={4} /> - <label id="x13-label">Choose an option</label> - <Listbox - ariaLabelledBy="x13-label" - id="x13" - currentValue="3" - options={icon_options} - visualFocusIndex={-1} - lastOptionIndex={3} - multiple={false} - optional={false} - optionalItem={{ label: "Empty", value: "" }} - searchable={false} - handleOptionOnClick={() => {}} - styles={{ width: 360 }} - /> - </ExampleContainer> - <ExampleContainer> - <Title title="Grouped icons (Material Symbols)" theme="light" level={4} /> - <label id="x14-label">Choose an option</label> - <Listbox - ariaLabelledBy="x14-label" - id="x14" - currentValue={"4"} - options={icon_options_grouped_material} - visualFocusIndex={-1} - lastOptionIndex={3} - multiple={false} - optional={false} - optionalItem={{ label: "Empty", value: "" }} - searchable={false} - handleOptionOnClick={() => {}} - styles={{ width: 360 }} - /> - </ExampleContainer> - <ExampleContainer> - <Title title="Grouped icons (Material)" theme="light" level={4} /> - <label id="x15-label">Choose an option</label> - <Listbox - ariaLabelledBy="x15-label" - id="x15" - currentValue={["car", "motorcycle", "train"]} - options={options_material} - visualFocusIndex={-1} - lastOptionIndex={6} - multiple={true} - optional={false} - optionalItem={{ label: "Empty", value: "" }} - searchable={false} - handleOptionOnClick={() => {}} - styles={{ width: 360 }} - /> - </ExampleContainer> - </> - ); -}; +const SelectListbox = () => ( + <> + <Title title="Listbox" theme="light" level={2} /> + <ExampleContainer> + <Title + title="List dialog uses a Radix Popover to appear over elements with a certain z-index" + theme="light" + level={3} + /> + <div + style={{ + position: "relative", + display: "flex", + flexDirection: "column", + gap: "20px", + height: "150px", + width: "min-content", + marginBottom: "100px", + padding: "20px", + border: "1px solid black", + borderRadius: "4px", + overflow: "auto", + zIndex: "1300", + }} + > + <DxcSelect label="Label" options={single_options} optional placeholder="Choose an option" /> + <button style={{ zIndex: "1", width: "100px" }}>Submit</button> + </div> + </ExampleContainer> + <Title title="Listbox option states" theme="light" level={3} /> + <ExampleContainer pseudoState="pseudo-hover"> + <Title title="Hovered option" theme="light" level={4} /> + <label id="x8-label">Choose an option</label> + <Listbox + ariaLabelledBy="x8-label" + id="x8" + currentValue="" + options={one_option} + visualFocusIndex={-1} + lastOptionIndex={0} + multiple={false} + optional={false} + optionalItem={{ label: "Empty", value: "" }} + searchable={false} + handleOptionOnClick={() => {}} + handleSelectAllOnClick={() => {}} + handleGroupOnClick={() => {}} + styles={{ width: 360 }} + enableSelectAll={false} + selectionType="unchecked" + /> + </ExampleContainer> + <ExampleContainer pseudoState="pseudo-active"> + <Title title="Active option" theme="light" level={4} /> + <label id="x9-label">Choose an option</label> + <Listbox + ariaLabelledBy="x9-label" + id="x9" + currentValue="" + options={one_option} + visualFocusIndex={-1} + lastOptionIndex={0} + multiple={false} + optional={false} + optionalItem={{ label: "Empty", value: "" }} + searchable={false} + handleOptionOnClick={() => {}} + handleSelectAllOnClick={() => {}} + handleGroupOnClick={() => {}} + styles={{ width: 360 }} + enableSelectAll={false} + selectionType="unchecked" + /> + </ExampleContainer> + <ExampleContainer> + <Title title="Focused option" theme="light" level={4} /> + <label id="x10-label">Choose an option</label> + <Listbox + ariaLabelledBy="x10-label" + id="x10" + currentValue="" + options={one_option} + visualFocusIndex={0} + lastOptionIndex={0} + multiple={false} + optional={false} + optionalItem={{ label: "Empty", value: "" }} + searchable={false} + handleOptionOnClick={() => {}} + handleSelectAllOnClick={() => {}} + handleGroupOnClick={() => {}} + styles={{ width: 360 }} + enableSelectAll={false} + selectionType="unchecked" + /> + </ExampleContainer> + <ExampleContainer pseudoState="pseudo-hover"> + <Title title="Hovered selected option" theme="light" level={4} /> + <label id="x11-label">Choose an option</label> + <Listbox + ariaLabelledBy="x11-label" + id="x11" + currentValue="1" + options={single_options} + visualFocusIndex={-1} + lastOptionIndex={3} + multiple={false} + optional={false} + optionalItem={{ label: "Empty", value: "" }} + searchable={false} + handleOptionOnClick={() => {}} + handleSelectAllOnClick={() => {}} + handleGroupOnClick={() => {}} + styles={{ width: 360 }} + enableSelectAll={false} + selectionType="unchecked" + /> + </ExampleContainer> + <ExampleContainer pseudoState="pseudo-active"> + <Title title="Active selected option" theme="light" level={4} /> + <label id="x12-label">Choose an option</label> + <Listbox + ariaLabelledBy="x12-label" + id="x12" + currentValue="2" + options={single_options} + visualFocusIndex={0} + lastOptionIndex={3} + multiple={false} + optional={false} + optionalItem={{ label: "Empty", value: "" }} + searchable={false} + handleOptionOnClick={() => {}} + handleSelectAllOnClick={() => {}} + handleGroupOnClick={() => {}} + styles={{ width: 360 }} + enableSelectAll={false} + selectionType="unchecked" + /> + </ExampleContainer> + <Title title="Listbox with icons" theme="light" level={3} /> + <ExampleContainer> + <Title title="Icons (SVGs)" theme="light" level={4} /> + <label id="x13-label">Choose an option</label> + <Listbox + ariaLabelledBy="x13-label" + id="x13" + currentValue="3" + options={icon_options} + visualFocusIndex={-1} + lastOptionIndex={3} + multiple={false} + optional={false} + optionalItem={{ label: "Empty", value: "" }} + searchable={false} + handleOptionOnClick={() => {}} + handleSelectAllOnClick={() => {}} + handleGroupOnClick={() => {}} + styles={{ width: 360 }} + enableSelectAll={false} + selectionType="unchecked" + /> + </ExampleContainer> + <ExampleContainer> + <Title title="Grouped icons (Material Symbols)" theme="light" level={4} /> + <label id="x14-label">Choose an option</label> + <Listbox + ariaLabelledBy="x14-label" + id="x14" + currentValue={"4"} + options={icon_options_grouped_material} + visualFocusIndex={-1} + lastOptionIndex={3} + multiple={false} + optional={false} + optionalItem={{ label: "Empty", value: "" }} + searchable={false} + handleOptionOnClick={() => {}} + handleSelectAllOnClick={() => {}} + handleGroupOnClick={() => {}} + styles={{ width: 360 }} + enableSelectAll={false} + selectionType="unchecked" + /> + </ExampleContainer> + <ExampleContainer> + <Title title="Grouped icons (Material)" theme="light" level={4} /> + <label id="x15-label">Choose an option</label> + <Listbox + ariaLabelledBy="x15-label" + id="x15" + currentValue={["car", "motorcycle", "train"]} + options={options_material} + visualFocusIndex={-1} + lastOptionIndex={6} + multiple={true} + optional={false} + optionalItem={{ label: "Empty", value: "" }} + searchable={false} + handleOptionOnClick={() => {}} + handleSelectAllOnClick={() => {}} + handleGroupOnClick={() => {}} + styles={{ width: 360 }} + enableSelectAll={false} + selectionType="unchecked" + /> + </ExampleContainer> + </> +); const SearchableSelect = () => ( <ExampleContainer expanded> <Title title="Searchable select" theme="light" level={4} /> - <DxcSelect label="Select Label" searchable options={single_options} placeholder="Choose an option" /> + <DxcSelect label="Select Label" searchable optional options={single_options} placeholder="Choose an option" /> </ExampleContainer> ); @@ -616,28 +645,30 @@ const TooltipValue = () => ( </ExampleContainer> ); -const TooltipOption = () => { - return ( - <ExampleContainer expanded> - <Title title="List option has tooltip when it overflows" theme="light" level={4} /> - <label id="x1-label">Choose an option</label> - <Listbox - ariaLabelledBy="x1-label" - id="x1" - currentValue="1" - options={optionsWithEllipsis} - visualFocusIndex={-1} - lastOptionIndex={2} - multiple={false} - optional={false} - optionalItem={{ label: "Empty", value: "" }} - searchable={false} - handleOptionOnClick={() => {}} - styles={{ width: 360 }} - /> - </ExampleContainer> - ); -}; +const TooltipOption = () => ( + <ExampleContainer expanded> + <Title title="List option has tooltip when it overflows" theme="light" level={4} /> + <label id="x1-label">Choose an option</label> + <Listbox + ariaLabelledBy="x1-label" + id="x1" + currentValue="1" + options={optionsWithEllipsis} + visualFocusIndex={-1} + lastOptionIndex={2} + multiple={false} + optional={false} + optionalItem={{ label: "Empty", value: "" }} + searchable={false} + handleOptionOnClick={() => {}} + handleSelectAllOnClick={() => {}} + handleGroupOnClick={() => {}} + selectionType="unchecked" + styles={{ width: 360 }} + enableSelectAll={false} + /> + </ExampleContainer> +); const TooltipClear = () => ( <ExampleContainer expanded> @@ -646,6 +677,21 @@ const TooltipClear = () => ( </ExampleContainer> ); +const SelectAll = () => ( + <ExampleContainer> + <Title title="Select all with grouped options" theme="light" level={4} /> + <DxcSelect + defaultValue={["1", "3", "4"]} + enableSelectAll + label="Select an option" + multiple + options={group_options} + placeholder="Select an available option" + searchable + /> + </ExampleContainer> +); + type Story = StoryObj<typeof DxcSelect>; export const Chromatic: Story = { @@ -754,3 +800,12 @@ export const SearchableClearActionTooltip: Story = { await userEvent.hover(clearSelectionButton); }, }; + +export const SelectAllOptions: Story = { + render: SelectAll, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const select = canvas.getByRole("combobox"); + await userEvent.click(select); + }, +}; diff --git a/packages/lib/src/select/Select.test.tsx b/packages/lib/src/select/Select.test.tsx index 14dcd5e934..5cfc9ff9bf 100644 --- a/packages/lib/src/select/Select.test.tsx +++ b/packages/lib/src/select/Select.test.tsx @@ -13,6 +13,13 @@ import DxcSelect from "./Select"; disconnect() {} }; +const reducedSingleOptions = [ + { label: "Option 01", value: "1" }, + { label: "Option 02", value: "2" }, + { label: "Option 03", value: "3" }, + { label: "Option 04", value: "4" }, +]; + const singleOptions = [ { label: "Option 01", value: "1" }, { label: "Option 02", value: "2" }, @@ -36,7 +43,34 @@ const singleOptions = [ { label: "Option 20", value: "20" }, ]; -const groupOptions = [ +const reducedGroupedOptions = [ + { + label: "Colores", + options: [ + { label: "Azul", value: "azul" }, + { label: "Rojo", value: "rojo" }, + { label: "Rosa", value: "rosa" }, + ], + }, + { + label: "Ciudades españolas", + options: [ + { label: "Madrid", value: "madrid" }, + { label: "Oviedo", value: "oviedo" }, + { label: "Sevilla", value: "sevilla" }, + ], + }, + { + label: "Ríos españoles", + options: [ + { label: "Miño", value: "miño" }, + { label: "Duero", value: "duero" }, + { label: "Tajo", value: "tajo" }, + ], + }, +]; + +const groupedOptions = [ { label: "Colores", options: [ @@ -73,7 +107,7 @@ const groupOptions = [ ]; describe("Select component tests", () => { - test("When clicking the label, the focus goes to the select", async () => { + test("When clicking the label, the focus goes to the select", () => { const { getByText, getByRole } = render( <DxcSelect label="test-select-label" @@ -84,7 +118,7 @@ describe("Select component tests", () => { ); const select = getByRole("combobox"); const label = getByText("test-select-label"); - await userEvent.click(label); + userEvent.click(label); expect(document.activeElement).toEqual(select); }); test("Renders with correct aria attributes when is in error state", () => { @@ -98,7 +132,7 @@ describe("Select component tests", () => { expect(select.getAttribute("aria-invalid")).toBe("true"); expect(errorMessage.getAttribute("aria-live")).toBe("assertive"); }); - test("Renders with correct aria attributes", async () => { + test("Renders with correct aria attributes", () => { const { getByText, getByRole } = render( <DxcSelect label="test-select-label" placeholder="Example" options={singleOptions} /> ); @@ -112,7 +146,7 @@ describe("Select component tests", () => { expect(select.getAttribute("aria-activedescendant")).toBeNull(); expect(select.getAttribute("aria-invalid")).toBe("false"); expect(select.getAttribute("aria-label")).toBeNull(); - await userEvent.click(select); + userEvent.click(select); const list = getByRole("listbox"); expect(select.getAttribute("aria-controls")).toBe(list.id); expect(list.getAttribute("aria-multiselectable")).toBe("false"); @@ -124,7 +158,7 @@ describe("Select component tests", () => { const select = getByRole("combobox"); expect(select.getAttribute("aria-label")).toBe("Example aria label"); }); - test("Single selection: Renders with correct default value", async () => { + test("Single selection: Renders with correct default value", () => { const { getByText, getByRole, getAllByRole, queryByRole, container } = render( <DxcSelect label="test-select-label" name="test" defaultValue="4" options={singleOptions} /> ); @@ -133,14 +167,14 @@ describe("Select component tests", () => { expect(queryByRole("listbox")).toBeFalsy(); expect(getByText("Option 04")).toBeTruthy(); expect(submitInput?.value).toBe("4"); - await userEvent.click(select); + userEvent.click(select); const options = getAllByRole("option"); expect(options[3]?.getAttribute("aria-selected")).toBe("true"); - options[7] && (await userEvent.click(options[7])); + options[7] && userEvent.click(options[7]); expect(getByText("Option 08")).toBeTruthy(); expect(submitInput?.value).toBe("8"); }); - test("Multiple selection: Renders with correct default value", async () => { + test("Multiple selection: Renders with correct default value", () => { const { getByText, getByRole, getAllByRole, queryByRole, container } = render( <DxcSelect label="test-select-label" @@ -155,13 +189,13 @@ describe("Select component tests", () => { expect(queryByRole("listbox")).toBeFalsy(); expect(getByText("Option 02, Option 04, Option 06")).toBeTruthy(); expect(submitInput?.value).toBe("4,2,6"); - await userEvent.click(select); + userEvent.click(select); const options = getAllByRole("option"); - options[2] && (await userEvent.click(options[2])); + options[2] && userEvent.click(options[2]); expect(getByText("Option 02, Option 03, Option 04, Option 06")).toBeTruthy(); expect(submitInput?.value).toBe("4,2,6,3"); }); - test("Sends its value when submitted", async () => { + test("Sends its value when submitted", () => { const handlerOnSubmit = jest.fn((e) => { e.preventDefault(); const formData = new FormData(e.target); @@ -182,48 +216,48 @@ describe("Select component tests", () => { ); const select = getByRole("combobox"); const submit = getByText("Submit"); - await userEvent.click(select); + userEvent.click(select); const options = getAllByRole("option"); - options[2] && (await userEvent.click(options[2])); - await userEvent.click(submit); + options[2] && userEvent.click(options[2]); + userEvent.click(submit); }); - test("Searching for a value with an empty list of options passed doesn't open the listbox", async () => { + test("Searching for a value with an empty list of options passed doesn't open the listbox", () => { const { container, getByRole, queryByRole } = render( <DxcSelect label="test-select-label" options={[]} searchable /> ); const select = getByRole("combobox"); const searchInput = container.querySelectorAll("input")[1]; - await userEvent.click(select); - await act(async () => { + userEvent.click(select); + act(() => { searchInput && userEvent.type(searchInput, "test"); }); expect(queryByRole("listbox")).toBeFalsy(); expect(select.getAttribute("aria-expanded")).toBe("false"); }); - test("Disabled select - Cannot gain focus or open the listbox via click", async () => { + test("Disabled select - Cannot gain focus or open the listbox via click", () => { const { getByRole, queryByRole } = render( <DxcSelect label="test-select-label" value={["1", "2"]} options={singleOptions} multiple disabled /> ); const select = getByRole("combobox"); expect(select.getAttribute("aria-disabled")).toBe("true"); - await userEvent.click(select); + userEvent.click(select); expect(queryByRole("listbox")).toBeFalsy(); expect(document.activeElement === select).toBeFalsy(); }); - test("Disabled select - Clear all options action must be shown but not clickable", async () => { + test("Disabled select - Clear all options action must be shown but not clickable", () => { const { getByRole, getByText } = render( <DxcSelect label="test-select-label" value={["1", "2"]} options={singleOptions} disabled searchable multiple /> ); - await userEvent.click(getByRole("button")); + userEvent.click(getByRole("button")); expect(getByText("Option 01, Option 02")).toBeTruthy(); }); - test("Disabled select - Does not call onBlur event", async () => { + test("Disabled select - Does not call onBlur event", () => { const onBlur = jest.fn(); const { getByRole } = render( <DxcSelect label="test-select-label" options={singleOptions} disabled onBlur={onBlur} /> ); const select = getByRole("combobox"); - await userEvent.click(select); + userEvent.click(select); fireEvent.keyDown(getByRole("combobox"), { key: "Tab", code: "Tab", keyCode: 9, charCode: 9 }); expect(onBlur).not.toHaveBeenCalled(); }); @@ -236,7 +270,7 @@ describe("Select component tests", () => { expect(queryByRole("listbox")).toBeFalsy(); expect(document.activeElement === select).toBeFalsy(); }); - test("Disabled select - Doesn't send its value when submitted", async () => { + test("Disabled select - Doesn't send its value when submitted", () => { const handlerOnSubmit = jest.fn((e) => { e.preventDefault(); const formData = new FormData(e.target); @@ -250,9 +284,9 @@ describe("Select component tests", () => { </form> ); const submit = getByText("Submit"); - await userEvent.click(submit); + userEvent.click(submit); }); - test("Controlled - Single selection - Not optional constraint", async () => { + test("Controlled - Single selection - Not optional constraint", () => { const onChange = jest.fn(); const onBlur = jest.fn(); const { getByRole, getAllByRole } = render( @@ -264,16 +298,16 @@ describe("Select component tests", () => { fireEvent.blur(select); expect(onBlur).toHaveBeenCalled(); expect(onBlur).toHaveBeenCalledWith({ value: "", error: "This field is required. Please, enter a value." }); - await userEvent.click(select); + userEvent.click(select); const options = getAllByRole("option"); - options[0] && (await userEvent.click(options[0])); + options[0] && userEvent.click(options[0]); expect(onChange).toHaveBeenCalled(); expect(onChange).toHaveBeenCalledWith({ value: "1" }); fireEvent.blur(select); expect(onBlur).toHaveBeenCalled(); expect(onBlur).toHaveBeenCalledWith({ value: "1" }); }); - test("Controlled - Multiple selection - Not optional constraint", async () => { + test("Controlled - Multiple selection - Not optional constraint", () => { const onChange = jest.fn(); const onBlur = jest.fn(); const { getByRole, getAllByRole } = render( @@ -285,19 +319,19 @@ describe("Select component tests", () => { fireEvent.blur(select); expect(onBlur).toHaveBeenCalled(); expect(onBlur).toHaveBeenCalledWith({ value: [], error: "This field is required. Please, enter a value." }); - await userEvent.click(select); + userEvent.click(select); let options = getAllByRole("option"); - options[0] && (await userEvent.click(options[0])); - options[1] && (await userEvent.click(options[1])); + options[0] && userEvent.click(options[0]); + options[1] && userEvent.click(options[1]); expect(onChange).toHaveBeenCalled(); expect(onChange).toHaveBeenCalledWith({ value: ["1", "2"] }); fireEvent.blur(select); expect(onBlur).toHaveBeenCalled(); expect(onBlur).toHaveBeenCalledWith({ value: ["1", "2"] }); - await userEvent.click(select); + userEvent.click(select); options = getAllByRole("option"); - options[0] && (await userEvent.click(options[0])); - options[1] && (await userEvent.click(options[1])); + options[0] && userEvent.click(options[0]); + options[1] && userEvent.click(options[1]); expect(onChange).toHaveBeenCalled(); expect(onChange).toHaveBeenCalledWith({ value: [], error: "This field is required. Please, enter a value." }); fireEvent.blur(select); @@ -318,12 +352,12 @@ describe("Select component tests", () => { expect(onBlur).toHaveBeenCalledWith({ value: "" }); expect(select.getAttribute("aria-invalid")).toBe("false"); }); - test("Non-Grouped Options - Opens listbox and renders correctly or closes it with a click on select", async () => { + test("Non-Grouped Options - Opens listbox and renders correctly or closes it with a click on select", () => { const { getByText, getByRole, getAllByRole, queryByRole } = render( <DxcSelect label="test-select-label" options={singleOptions} /> ); const select = getByRole("combobox"); - await userEvent.click(select); + userEvent.click(select); expect(getByRole("listbox")).toBeTruthy(); expect(select.getAttribute("aria-expanded")).toBe("true"); expect(getByText("Option 01")).toBeTruthy(); @@ -331,36 +365,36 @@ describe("Select component tests", () => { expect(getByText("Option 08")).toBeTruthy(); expect(getByText("Option 09")).toBeTruthy(); expect(getAllByRole("option").length).toBe(20); - await userEvent.click(select); + userEvent.click(select); expect(queryByRole("listbox")).toBeFalsy(); expect(select.getAttribute("aria-expanded")).toBe("false"); }); - test("Non-Grouped Options - If an empty list of options is passed, the select is rendered but doesn't open the listbox", async () => { + test("Non-Grouped Options - If an empty list of options is passed, the select is rendered but doesn't open the listbox", () => { const { getByRole, queryByRole } = render(<DxcSelect label="test-select-label" options={[]} />); const select = getByRole("combobox"); - await userEvent.click(select); + userEvent.click(select); expect(queryByRole("listbox")).toBeFalsy(); expect(select.getAttribute("aria-expanded")).toBe("false"); }); - test("Non-Grouped Options - Click in an option selects it and closes the listbox", async () => { + test("Non-Grouped Options - Click in an option selects it and closes the listbox", () => { const onChange = jest.fn(); const { getByText, getByRole, getAllByRole, queryByRole, container } = render( <DxcSelect name="test" label="test-select-label" options={singleOptions} onChange={onChange} /> ); const select = getByRole("combobox"); const submitInput = container.querySelector<HTMLInputElement>(`input[name="test"]`); - await userEvent.click(select); + userEvent.click(select); let options = getAllByRole("option"); - options[2] && (await userEvent.click(options[2])); + options[2] && userEvent.click(options[2]); expect(onChange).toHaveBeenCalledWith({ value: "3" }); expect(queryByRole("listbox")).toBeFalsy(); expect(getByText("Option 03")).toBeTruthy(); - await userEvent.click(select); + userEvent.click(select); options = getAllByRole("option"); expect(options[2]?.getAttribute("aria-selected")).toBe("true"); expect(submitInput?.value).toBe("3"); }); - test("Non-Grouped Options - Optional renders an empty first option (selected by default) with the placeholder as its label", async () => { + test("Non-Grouped Options - Optional renders an empty first option (selected by default) with the placeholder as its label", () => { const onChange = jest.fn(); const { getByRole, getAllByRole, getAllByText } = render( <DxcSelect @@ -372,11 +406,11 @@ describe("Select component tests", () => { /> ); const select = getByRole("combobox"); - await userEvent.click(select); + userEvent.click(select); expect(getAllByText("Choose an option").length).toBe(2); const options = getAllByRole("option"); expect(options[0]?.getAttribute("aria-selected")).toBe("true"); - options[0] && (await userEvent.click(options[0])); + options[0] && userEvent.click(options[0]); expect(onChange).toHaveBeenCalledWith({ value: "" }); expect(getAllByText("Choose an option").length).toBe(1); fireEvent.keyDown(select, { key: "ArrowDown", code: "ArrowDown", keyCode: 40, charCode: 40 }); @@ -387,7 +421,7 @@ describe("Select component tests", () => { fireEvent.keyDown(select, { key: "ArrowUp", code: "ArrowUp", keyCode: 38, charCode: 38 }); expect(select.getAttribute("aria-activedescendant")).toBe("option-0"); }); - test("Non-Grouped Options - Filtering options never affects the optional item until there are no coincidences", async () => { + test("Non-Grouped Options - Filtering options never affects the optional item until there are no coincidences", () => { const { getAllByRole, getByText, queryByText, container } = render( <DxcSelect label="test-select-label" @@ -398,12 +432,12 @@ describe("Select component tests", () => { /> ); const searchInput = container.querySelectorAll("input")[1]; - await act(async () => { + act(() => { searchInput && userEvent.type(searchInput, "1"); }); expect(getByText("Placeholder example")).toBeTruthy(); expect(getAllByRole("option").length).toBe(12); - await act(async () => { + act(() => { searchInput && userEvent.type(searchInput, "123"); }); expect(queryByText("Placeholder example")).toBeFalsy(); @@ -439,7 +473,7 @@ describe("Select component tests", () => { expect(queryByRole("listbox")).toBeTruthy(); expect(select.getAttribute("aria-activedescendant")).toBe("option-0"); }); - test("Non-Grouped Options: Enter key - Selects the visually focused option and closes the listbox", async () => { + test("Non-Grouped Options: Enter key - Selects the visually focused option and closes the listbox", () => { const onChange = jest.fn(); const { getByText, getByRole, getAllByRole, queryByRole } = render( <DxcSelect label="test-select-label" options={singleOptions} onChange={onChange} optional /> @@ -453,49 +487,49 @@ describe("Select component tests", () => { expect(onChange).toHaveBeenCalledWith({ value: "20" }); expect(queryByRole("listbox")).toBeFalsy(); expect(getByText("Option 20")).toBeTruthy(); - await userEvent.click(select); + userEvent.click(select); const options = getAllByRole("option"); expect(options[20]?.getAttribute("aria-selected")).toBe("true"); }); - test("Non-Grouped Options: Searchable - Displays an input for filtering the list of options", async () => { + test("Non-Grouped Options: Searchable - Displays an input for filtering the list of options", () => { const onChange = jest.fn(); const { container, getByText, getByRole, getAllByRole, queryByRole } = render( <DxcSelect label="test-select-label" options={singleOptions} onChange={onChange} searchable /> ); const select = getByRole("combobox"); const searchInput = container.querySelectorAll("input")[1]; - await userEvent.click(select); + userEvent.click(select); expect(getByRole("listbox")).toBeTruthy(); - searchInput && (await userEvent.type(searchInput, "08")); - await userEvent.click(getByRole("option")); + searchInput && userEvent.type(searchInput, "08"); + userEvent.click(getByRole("option")); expect(onChange).toHaveBeenCalledWith({ value: "8" }); expect(queryByRole("listbox")).toBeFalsy(); expect(getByText("Option 08")).toBeTruthy(); expect(searchInput?.value).toBe(""); - await userEvent.click(select); + userEvent.click(select); const options = getAllByRole("option"); expect(options[7]?.getAttribute("aria-selected")).toBe("true"); }); - test("Non-Grouped Options: Searchable - Displays 'No matches found' when there are no filtering results", async () => { + test("Non-Grouped Options: Searchable - Displays 'No matches found' when there are no filtering results", () => { const onChange = jest.fn(); const { container, getByText, getByRole } = render( <DxcSelect label="test-select-label" options={singleOptions} onChange={onChange} searchable /> ); const select = getByRole("combobox"); const searchInput = container.querySelectorAll("input")[1]; - await userEvent.click(select); + userEvent.click(select); expect(getByRole("listbox")).toBeTruthy(); - searchInput && (await userEvent.type(searchInput, "abc")); + searchInput && userEvent.type(searchInput, "abc"); expect(getByText("No matches found")).toBeTruthy(); }); - test("Non-Grouped Options: Searchable - Clicking the select, when the list is open, clears the search value", async () => { + test("Non-Grouped Options: Searchable - Clicking the select, when the list is open, clears the search value", () => { const onChange = jest.fn(); const { container, getByText, getByRole, getAllByRole } = render( <DxcSelect label="test-select-label" options={singleOptions} onChange={onChange} searchable /> ); const select = getByRole("combobox"); const searchInput = container.querySelectorAll("input")[1]; - await act(async () => { + act(() => { searchInput && userEvent.type(searchInput, "2"); }); expect(getByRole("listbox")).toBeTruthy(); @@ -503,62 +537,62 @@ describe("Select component tests", () => { expect(getByText("Option 12")).toBeTruthy(); expect(getByText("Option 20")).toBeTruthy(); expect(getAllByRole("option").length).toBe(3); - await act(async () => { + act(() => { userEvent.click(select); }); expect(searchInput?.value).toBe(""); }); - test("Non-Grouped Options: Searchable - Writing displays the listbox, if it was not open", async () => { + test("Non-Grouped Options: Searchable - Writing displays the listbox, if it was not open", () => { const onChange = jest.fn(); const { container, getByRole, queryByRole } = render( <DxcSelect label="test-select-label" options={singleOptions} onChange={onChange} searchable /> ); const select = getByRole("combobox"); const searchInput = container.querySelectorAll("input")[1]; - await userEvent.click(select); - await userEvent.click(select); + userEvent.click(select); + userEvent.click(select); expect(queryByRole("listbox")).toBeFalsy(); - searchInput && (await userEvent.type(searchInput, "2")); + searchInput && userEvent.type(searchInput, "2"); expect(getByRole("listbox")).toBeTruthy(); }); - test("Non-Grouped Options: Searchable - Key Esc cleans the search value and closes the options", async () => { + test("Non-Grouped Options: Searchable - Key Esc cleans the search value and closes the options", () => { const onChange = jest.fn(); const { container, getByRole, queryByRole } = render( <DxcSelect label="test-select-label" options={singleOptions} onChange={onChange} searchable /> ); const select = getByRole("combobox"); const searchInput = container.querySelectorAll("input")[1]; - searchInput && (await userEvent.type(searchInput, "Option 02")); + searchInput && userEvent.type(searchInput, "Option 02"); fireEvent.keyDown(select, { key: "Esc", code: "Esc", keyCode: 27, charCode: 27 }); expect(searchInput?.value).toBe(""); expect(queryByRole("listbox")).toBeFalsy(); }); - test("Non-Grouped Options: Searchable - While user types, a clear action is displayed for cleaning the search value", async () => { + test("Non-Grouped Options: Searchable - While user types, a clear action is displayed for cleaning the search value", () => { const onChange = jest.fn(); const { container, getByRole, getAllByRole, queryByRole } = render( <DxcSelect label="test-select-label" options={singleOptions} onChange={onChange} searchable /> ); const searchInput = container.querySelectorAll("input")[1]; - searchInput && (await userEvent.type(searchInput, "Option 02")); + searchInput && userEvent.type(searchInput, "Option 02"); expect(getAllByRole("option").length).toBe(1); const clearSearchButton = getByRole("button"); expect(clearSearchButton.getAttribute("aria-label")).toBe("Clear search"); - await userEvent.click(clearSearchButton); + userEvent.click(clearSearchButton); expect(getByRole("listbox")).toBeTruthy(); expect(getAllByRole("option").length).toBe(20); expect(queryByRole("button")).toBeFalsy(); }); - test("Non-Grouped Options: Multiple selection - Displays a checkbox per option and enables the multi-selection", async () => { + test("Non-Grouped Options: Multiple selection - Displays a checkbox per option and enables the multi-selection", () => { const onChange = jest.fn(); const { getByText, getAllByText, getByRole, getAllByRole, queryByRole, container } = render( <DxcSelect name="test" label="test-select-label" options={singleOptions} onChange={onChange} multiple /> ); const select = getByRole("combobox"); const submitInput = container.querySelector<HTMLInputElement>(`input[name="test"]`); - await userEvent.click(select); + userEvent.click(select); expect(getByRole("listbox").getAttribute("aria-multiselectable")).toBe("true"); const options = getAllByRole("option"); - options[10] && (await userEvent.click(options[10])); + options[10] && userEvent.click(options[10]); expect(onChange).toHaveBeenCalledWith({ value: ["11"] }); expect(queryByRole("listbox")).toBeTruthy(); expect(getAllByText("Option 11").length).toBe(2); @@ -570,31 +604,31 @@ describe("Select component tests", () => { expect(getByText("Option 11, Option 19")).toBeTruthy(); expect(submitInput?.value).toBe("11,19"); }); - test("Non-Grouped Options: Multiple selection - Clear action and selection indicator", async () => { + test("Non-Grouped Options: Multiple selection - Clear action and selection indicator", () => { const onChange = jest.fn(); const { getByText, queryByText, getByRole, getAllByRole, queryByRole } = render( <DxcSelect label="test-select-label" options={singleOptions} onChange={onChange} multiple /> ); const select = getByRole("combobox"); - await userEvent.click(select); + userEvent.click(select); const options = getAllByRole("option"); - options[5] && (await userEvent.click(options[5])); - options[8] && (await userEvent.click(options[8])); - options[13] && (await userEvent.click(options[13])); + options[5] && userEvent.click(options[5]); + options[8] && userEvent.click(options[8]); + options[13] && userEvent.click(options[13]); expect(onChange).toHaveBeenCalledWith({ value: ["6", "9", "14"] }); expect(queryByRole("listbox")).toBeTruthy(); expect(getByText("Option 06, Option 09, Option 14")).toBeTruthy(); expect(getByText("3", { exact: true })).toBeTruthy(); const clearSelectionButton = getByRole("button"); expect(clearSelectionButton.getAttribute("aria-label")).toBe("Clear selection"); - await userEvent.click(clearSelectionButton); + userEvent.click(clearSelectionButton); expect(onChange).toHaveBeenCalledWith({ value: [], error: "This field is required. Please, enter a value." }); expect(queryByRole("listbox")).toBeTruthy(); expect(queryByText("Option 06, Option 09, Option 14")).toBeFalsy(); expect(queryByText("3")).toBeFalsy(); expect(queryByRole("button")).toBeFalsy(); }); - test("Non-Grouped Options: Multiple selection - Optional option should not be added when the select is marked as multiple", async () => { + test("Non-Grouped Options: Multiple selection - Optional option should not be added when the select is marked as multiple", () => { const onChange = jest.fn(); const { getByText, getAllByText, getByRole, getAllByRole } = render( <DxcSelect @@ -608,21 +642,21 @@ describe("Select component tests", () => { ); const select = getByRole("combobox"); expect(getByText("(Optional)")).toBeTruthy(); - await userEvent.click(select); + userEvent.click(select); expect(getAllByText("Choose an option").length).toBe(1); const options = getAllByRole("option"); - options[0] && (await userEvent.click(options[0])); + options[0] && userEvent.click(options[0]); expect(onChange).toHaveBeenCalledWith({ value: ["1"] }); expect(getAllByText("Option 01").length).toBe(2); }); - test("Non-Grouped Options - If an options was previously selected when its opened (by key press), the visual focus appears always in the selected option", async () => { + test("Non-Grouped Options - If an options was previously selected when its opened (by key press), the visual focus appears always in the selected option", () => { const { getByText, getByRole, getAllByRole } = render( <DxcSelect label="test-select-label" options={singleOptions} /> ); const select = getByRole("combobox"); - await userEvent.click(select); + userEvent.click(select); const options = getAllByRole("option"); - options[4] && (await userEvent.click(options[4])); + options[4] && userEvent.click(options[4]); expect(getByText("Option 05")).toBeTruthy(); fireEvent.keyDown(select, { key: "ArrowUp", code: "ArrowUp", keyCode: 38, charCode: 38 }); expect(select.getAttribute("aria-activedescendant")).toBe("option-4"); @@ -636,21 +670,21 @@ describe("Select component tests", () => { fireEvent.keyDown(select, { key: "Enter", code: "Enter", keyCode: 13, charCode: 13 }); expect(getByText("Option 06")).toBeTruthy(); }); - test("Non-Grouped Options - If an options was previously selected when its opened (by click and key press), the visual focus appears always in the selected option", async () => { + test("Non-Grouped Options - If an options was previously selected when its opened (by click and key press), the visual focus appears always in the selected option", () => { const { getByText, getByRole, getAllByRole, queryByRole } = render( <DxcSelect label="test-select-label" options={singleOptions} /> ); const select = getByRole("combobox"); - await userEvent.click(select); + userEvent.click(select); const options = getAllByRole("option"); - options[15] && (await userEvent.click(options[15])); + options[15] && userEvent.click(options[15]); expect(queryByRole("listbox")).toBeFalsy(); expect(getByText("Option 16")).toBeTruthy(); - await userEvent.click(select); + userEvent.click(select); expect(select.getAttribute("aria-activedescendant")).toBeNull(); fireEvent.keyDown(select, { key: "ArrowUp", code: "ArrowUp", keyCode: 38, charCode: 38 }); expect(select.getAttribute("aria-activedescendant")).toBe("option-15"); - await userEvent.click(select); + userEvent.click(select); expect(queryByRole("listbox")).toBeFalsy(); fireEvent.keyDown(select, { key: "ArrowDown", code: "ArrowDown", keyCode: 40, charCode: 40 }); expect(select.getAttribute("aria-activedescendant")).toBe("option-15"); @@ -660,12 +694,12 @@ describe("Select component tests", () => { fireEvent.keyDown(select, { key: "Enter", code: "Enter", keyCode: 13, charCode: 13 }); expect(getByText("Option 17")).toBeTruthy(); }); - test("Grouped Options - Opens listbox and renders it correctly or closes it with a click on select", async () => { + test("Grouped Options - Opens listbox and renders it correctly or closes it with a click on select", () => { const { getByText, getByRole, getAllByRole, queryByRole } = render( - <DxcSelect label="test-select-label" options={groupOptions} /> + <DxcSelect label="test-select-label" options={groupedOptions} /> ); const select = getByRole("combobox"); - await userEvent.click(select); + userEvent.click(select); const listbox = getByRole("listbox"); expect(listbox).toBeTruthy(); expect(select.getAttribute("aria-expanded")).toBe("true"); @@ -681,11 +715,11 @@ describe("Select component tests", () => { expect(groups[1]?.getAttribute("aria-labelledby")).toBe(groupLabels[1]?.id); expect(groups[2]?.getAttribute("aria-labelledby")).toBe(groupLabels[2]?.id); expect(getAllByRole("option").length).toBe(18); - await userEvent.click(select); + userEvent.click(select); expect(queryByRole("listbox")).toBeFalsy(); expect(select.getAttribute("aria-expanded")).toBe("false"); }); - test("Grouped Options - If an empty list of options in a group is passed, the select is rendered but doesn't open the listbox", async () => { + test("Grouped Options - If an empty list of options in a group is passed, the select is rendered but doesn't open the listbox", () => { const { getByRole, queryByRole } = render( <DxcSelect label="test-select-label" @@ -698,45 +732,45 @@ describe("Select component tests", () => { /> ); const select = getByRole("combobox"); - await userEvent.click(select); + userEvent.click(select); expect(queryByRole("listbox")).toBeFalsy(); expect(select.getAttribute("aria-expanded")).toBe("false"); }); - test("Grouped Options - Click in an option selects it and closes the listbox", async () => { + test("Grouped Options - Click in an option selects it and closes the listbox", () => { const onChange = jest.fn(); const { getByText, getByRole, getAllByRole, queryByRole, container } = render( - <DxcSelect name="test" label="test-select-label" options={groupOptions} onChange={onChange} /> + <DxcSelect name="test" label="test-select-label" options={groupedOptions} onChange={onChange} /> ); const select = getByRole("combobox"); const submitInput = container.querySelector<HTMLInputElement>(`input[name="test"]`); - await userEvent.click(select); + userEvent.click(select); let options = getAllByRole("option"); - options[8] && (await userEvent.click(options[8])); + options[8] && userEvent.click(options[8]); expect(onChange).toHaveBeenCalledWith({ value: "oviedo" }); expect(queryByRole("listbox")).toBeFalsy(); expect(getByText("Oviedo")).toBeTruthy(); - await userEvent.click(select); + userEvent.click(select); options = getAllByRole("option"); expect(options[8]?.getAttribute("aria-selected")).toBe("true"); expect(submitInput?.value).toBe("oviedo"); }); - test("Grouped Options - Optional renders an empty first option (out of any group) with the placeholder as its label", async () => { + test("Grouped Options - Optional renders an empty first option (out of any group) with the placeholder as its label", () => { const onChange = jest.fn(); const { getByRole, getAllByRole, getAllByText } = render( <DxcSelect label="test-select-label" placeholder="Placeholder example" - options={groupOptions} + options={groupedOptions} onChange={onChange} optional /> ); const select = getByRole("combobox"); - await userEvent.click(select); + userEvent.click(select); expect(getAllByText("Placeholder example").length).toBe(2); const options = getAllByRole("option"); expect(options[0]?.getAttribute("aria-selected")).toBe("true"); - options[0] && (await userEvent.click(options[0])); + options[0] && userEvent.click(options[0]); expect(onChange).toHaveBeenCalledWith({ value: "" }); expect(getAllByText("Placeholder example").length).toBe(1); fireEvent.keyDown(select, { key: "ArrowDown", code: "ArrowDown", keyCode: 40, charCode: 40 }); @@ -747,35 +781,35 @@ describe("Select component tests", () => { fireEvent.keyDown(select, { key: "ArrowUp", code: "ArrowUp", keyCode: 38, charCode: 38 }); expect(select.getAttribute("aria-activedescendant")).toBe("option-0"); }); - test("Grouped Options - Filtering options never affects the optional item until there are no coincidence", async () => { + test("Grouped Options - Filtering options never affects the optional item until there are no coincidence", () => { const { getByRole, getAllByRole, getByText, queryByText, container } = render( <DxcSelect label="test-select-label" placeholder="Placeholder example" - options={groupOptions} + options={groupedOptions} optional searchable /> ); const select = getByRole("combobox"); const searchInput = container.querySelectorAll("input")[1]; - await userEvent.click(select); - searchInput && (await userEvent.type(searchInput, "ro")); + userEvent.click(select); + searchInput && userEvent.type(searchInput, "ro"); expect(getByText("Placeholder example")).toBeTruthy(); expect(getAllByRole("option").length).toBe(6); - searchInput && (await userEvent.type(searchInput, "roro")); + searchInput && userEvent.type(searchInput, "roro"); expect(queryByText("Placeholder example")).toBeFalsy(); expect(getByText("No matches found")).toBeTruthy(); }); test("Grouped Options: Arrow up key - Opens the listbox and visually focus the last option", () => { - const { getByRole, queryByRole } = render(<DxcSelect label="test-select-label" options={groupOptions} />); + const { getByRole, queryByRole } = render(<DxcSelect label="test-select-label" options={groupedOptions} />); const select = getByRole("combobox"); fireEvent.keyDown(select, { key: "ArrowUp", code: "ArrowUp", keyCode: 38, charCode: 38 }); expect(queryByRole("listbox")).toBeTruthy(); expect(select.getAttribute("aria-activedescendant")).toBe("option-17"); }); test("Grouped Options: Arrow up key - Puts the focus in last option when the first one is visually focused", () => { - const { getByRole, queryByRole } = render(<DxcSelect label="test-select-label" options={groupOptions} />); + const { getByRole, queryByRole } = render(<DxcSelect label="test-select-label" options={groupedOptions} />); const select = getByRole("combobox"); fireEvent.keyDown(select, { key: "ArrowDown", code: "ArrowDown", keyCode: 40, charCode: 40 }); fireEvent.keyDown(select, { key: "ArrowUp", code: "ArrowUp", keyCode: 38, charCode: 38 }); @@ -783,24 +817,24 @@ describe("Select component tests", () => { expect(select.getAttribute("aria-activedescendant")).toBe("option-17"); }); test("Grouped Options: Arrow down key - Opens the listbox and visually focus the first option", () => { - const { getByRole, queryByRole } = render(<DxcSelect label="test-select-label" options={groupOptions} />); + const { getByRole, queryByRole } = render(<DxcSelect label="test-select-label" options={groupedOptions} />); const select = getByRole("combobox"); fireEvent.keyDown(select, { key: "ArrowDown", code: "ArrowDown", keyCode: 40, charCode: 40 }); expect(queryByRole("listbox")).toBeTruthy(); expect(select.getAttribute("aria-activedescendant")).toBe("option-0"); }); test("Grouped Options: Arrow down key - Puts the focus in the first option when the last one is visually focused", () => { - const { getByRole, queryByRole } = render(<DxcSelect label="test-select-label" options={groupOptions} />); + const { getByRole, queryByRole } = render(<DxcSelect label="test-select-label" options={groupedOptions} />); const select = getByRole("combobox"); fireEvent.keyDown(select, { key: "ArrowUp", code: "ArrowUp", keyCode: 38, charCode: 38 }); fireEvent.keyDown(select, { key: "ArrowDown", code: "ArrowDown", keyCode: 40, charCode: 40 }); expect(queryByRole("listbox")).toBeTruthy(); expect(select.getAttribute("aria-activedescendant")).toBe("option-0"); }); - test("Grouped Options: Enter key - Selects the visually focused option and closes the listbox", async () => { + test("Grouped Options: Enter key - Selects the visually focused option and closes the listbox", () => { const onChange = jest.fn(); const { getByText, getByRole, getAllByRole, queryByRole } = render( - <DxcSelect label="test-select-label" options={groupOptions} onChange={onChange} optional /> + <DxcSelect label="test-select-label" options={groupedOptions} onChange={onChange} optional /> ); const select = getByRole("combobox"); fireEvent.keyDown(select, { key: "ArrowUp", code: "ArrowUp", keyCode: 38, charCode: 38 }); @@ -811,56 +845,56 @@ describe("Select component tests", () => { expect(onChange).toHaveBeenCalledWith({ value: "ebro" }); expect(queryByRole("listbox")).toBeFalsy(); expect(getByText("Ebro")).toBeTruthy(); - await userEvent.click(select); + userEvent.click(select); const options = getAllByRole("option"); expect(options[18]?.getAttribute("aria-selected")).toBe("true"); }); - test("Grouped Options: Searchable - Displays an input for filtering the list of options", async () => { + test("Grouped Options: Searchable - Displays an input for filtering the list of options", () => { const onChange = jest.fn(); const { container, getByText, getByRole, getAllByRole, queryByRole } = render( - <DxcSelect label="test-select-label" options={groupOptions} onChange={onChange} searchable /> + <DxcSelect label="test-select-label" options={groupedOptions} onChange={onChange} searchable /> ); const select = getByRole("combobox"); const searchInput = container.querySelectorAll("input")[1]; - await userEvent.click(select); + userEvent.click(select); expect(getByRole("listbox")).toBeTruthy(); - searchInput && (await userEvent.type(searchInput, "ro")); + searchInput && userEvent.type(searchInput, "ro"); expect(getAllByRole("presentation").length).toBe(2); expect(getAllByRole("option").length).toBe(5); expect(getByText("Colores")).toBeTruthy(); expect(getByText("Ríos españoles")).toBeTruthy(); let options = getAllByRole("option"); - options[4] && (await userEvent.click(options[4])); + options[4] && userEvent.click(options[4]); expect(onChange).toHaveBeenCalledWith({ value: "ebro" }); expect(queryByRole("listbox")).toBeFalsy(); expect(getByText("Ebro")).toBeTruthy(); expect(searchInput?.value).toBe(""); - await userEvent.click(select); + userEvent.click(select); options = getAllByRole("option"); expect(options[17]?.getAttribute("aria-selected")).toBe("true"); }); - test("Grouped Options: Searchable - Displays 'No matches found' when there are no filtering results", async () => { + test("Grouped Options: Searchable - Displays 'No matches found' when there are no filtering results", () => { const onChange = jest.fn(); const { container, getByText, getByRole } = render( - <DxcSelect label="test-select-label" options={groupOptions} onChange={onChange} searchable /> + <DxcSelect label="test-select-label" options={groupedOptions} onChange={onChange} searchable /> ); const select = getByRole("combobox"); const searchInput = container.querySelectorAll("input")[1]; - await userEvent.click(select); + userEvent.click(select); expect(getByRole("listbox")).toBeTruthy(); - searchInput && (await userEvent.type(searchInput, "very long string")); + searchInput && userEvent.type(searchInput, "very long string"); expect(getByText("No matches found")).toBeTruthy(); }); - test("Grouped Options: Multiple selection - Displays a checkbox per option and enables the multi-selection", async () => { + test("Grouped Options: Multiple selection - Displays a checkbox per option and enables the multi-selection", () => { const onChange = jest.fn(); const { getByText, getAllByText, getByRole, getAllByRole, queryByRole, container } = render( - <DxcSelect name="test" label="test-select-label" options={groupOptions} onChange={onChange} multiple /> + <DxcSelect name="test" label="test-select-label" options={groupedOptions} onChange={onChange} multiple /> ); const select = getByRole("combobox"); const submitInput = container.querySelector<HTMLInputElement>(`input[name="test"]`); - await userEvent.click(select); + userEvent.click(select); const options = getAllByRole("option"); - options[10] && (await userEvent.click(options[10])); + options[10] && userEvent.click(options[10]); expect(onChange).toHaveBeenCalledWith({ value: ["bilbao"] }); expect(queryByRole("listbox")).toBeTruthy(); expect(getAllByText("Bilbao").length).toBe(2); @@ -872,37 +906,37 @@ describe("Select component tests", () => { expect(getByText("Bilbao, Guadalquivir")).toBeTruthy(); expect(submitInput?.value).toBe("bilbao,guadalquivir"); }); - test("Grouped Options: Multiple selection - Clear action and selection indicator", async () => { + test("Grouped Options: Multiple selection - Clear action and selection indicator", () => { const onChange = jest.fn(); const { getByText, queryByText, getByRole, getAllByRole, queryByRole } = render( - <DxcSelect label="test-select-label" options={groupOptions} onChange={onChange} multiple /> + <DxcSelect label="test-select-label" options={groupedOptions} onChange={onChange} multiple /> ); const select = getByRole("combobox"); - await userEvent.click(select); + userEvent.click(select); const options = getAllByRole("option"); - options[5] && (await userEvent.click(options[5])); - options[8] && (await userEvent.click(options[8])); - options[13] && (await userEvent.click(options[13])); - options[17] && (await userEvent.click(options[17])); + options[5] && userEvent.click(options[5]); + options[8] && userEvent.click(options[8]); + options[13] && userEvent.click(options[13]); + options[17] && userEvent.click(options[17]); expect(onChange).toHaveBeenCalledWith({ value: ["blanco", "oviedo", "duero", "ebro"] }); expect(queryByRole("listbox")).toBeTruthy(); expect(getByText("Blanco, Oviedo, Duero, Ebro")).toBeTruthy(); expect(getByText("4", { exact: true })).toBeTruthy(); const clearSelectionButton = getByRole("button"); expect(clearSelectionButton.getAttribute("aria-label")).toBe("Clear selection"); - await userEvent.click(clearSelectionButton); + userEvent.click(clearSelectionButton); expect(queryByRole("listbox")).toBeTruthy(); expect(queryByText("Blanco, Oviedo, Duero, Ebro")).toBeFalsy(); expect(queryByText("4")).toBeFalsy(); expect(queryByRole("button")).toBeFalsy(); }); - test("Grouped Options: Multiple selection - Optional option should not be added when the select is marked as multiple", async () => { + test("Grouped Options: Multiple selection - Optional option should not be added when the select is marked as multiple", () => { const onChange = jest.fn(); const { getByText, getAllByText, getByRole, getAllByRole } = render( <DxcSelect label="test-select-label" placeholder="Choose an option" - options={groupOptions} + options={groupedOptions} onChange={onChange} multiple optional @@ -910,21 +944,21 @@ describe("Select component tests", () => { ); const select = getByRole("combobox"); expect(getByText("(Optional)")).toBeTruthy(); - await userEvent.click(select); + userEvent.click(select); expect(getAllByText("Choose an option").length).toBe(1); const options = getAllByRole("option"); - options[0] && (await userEvent.click(options[0])); + options[0] && userEvent.click(options[0]); expect(onChange).toHaveBeenCalledWith({ value: ["azul"] }); expect(getAllByText("Azul").length).toBe(2); }); - test("Grouped Options - If an options was previously selected when its opened (by key press), the visual focus appears always in the selected option", async () => { + test("Grouped Options - If an options was previously selected when its opened (by key press), the visual focus appears always in the selected option", () => { const { getByText, getByRole, getAllByRole } = render( - <DxcSelect label="test-select-label" options={groupOptions} /> + <DxcSelect label="test-select-label" options={groupedOptions} /> ); const select = getByRole("combobox"); - await userEvent.click(select); + userEvent.click(select); const options = getAllByRole("option"); - options[2] && (await userEvent.click(options[2])); + options[2] && userEvent.click(options[2]); expect(getByText("Rosa")).toBeTruthy(); fireEvent.keyDown(select, { key: "ArrowUp", code: "ArrowUp", keyCode: 38, charCode: 38 }); expect(select.getAttribute("aria-activedescendant")).toBe("option-2"); @@ -938,20 +972,20 @@ describe("Select component tests", () => { fireEvent.keyDown(select, { key: "Enter", code: "Enter", keyCode: 13, charCode: 13 }); expect(getByText("Verde")).toBeTruthy(); }); - test("Grouped Options - If an options was previously selected when its opened (by click and key press), the visual focus appears always in the selected option", async () => { + test("Grouped Options - If an options was previously selected when its opened (by click and key press), the visual focus appears always in the selected option", () => { const { getByText, getByRole, getAllByRole } = render( - <DxcSelect label="test-select-label" options={groupOptions} /> + <DxcSelect label="test-select-label" options={groupedOptions} /> ); const select = getByRole("combobox"); - await userEvent.click(select); + userEvent.click(select); const options = getAllByRole("option"); - options[17] && (await userEvent.click(options[17])); + options[17] && userEvent.click(options[17]); expect(getByText("Ebro")).toBeTruthy(); - await userEvent.click(select); + userEvent.click(select); expect(select.getAttribute("aria-activedescendant")).toBeNull(); fireEvent.keyDown(select, { key: "ArrowUp", code: "ArrowUp", keyCode: 38, charCode: 38 }); expect(select.getAttribute("aria-activedescendant")).toBe("option-17"); - await userEvent.click(select); + userEvent.click(select); fireEvent.keyDown(select, { key: "ArrowDown", code: "ArrowDown", keyCode: 40, charCode: 40 }); expect(select.getAttribute("aria-activedescendant")).toBe("option-17"); fireEvent.keyDown(select, { key: "ArrowDown", code: "ArrowDown", keyCode: 40, charCode: 40 }); @@ -960,21 +994,201 @@ describe("Select component tests", () => { fireEvent.keyDown(select, { key: "Enter", code: "Enter", keyCode: 13, charCode: 13 }); expect(getByText("Azul")).toBeTruthy(); }); - test("Multiple selection and optional - Clear action cleans every selected option but does not display an error", async () => { + test("Multiple selection and optional - Clear action cleans every selected option but does not display an error", () => { const onChange = jest.fn(); const { getByRole, getAllByRole } = render( <DxcSelect label="test-select-label" options={singleOptions} onChange={onChange} multiple optional /> ); const select = getByRole("combobox"); - await userEvent.click(select); + userEvent.click(select); const options = getAllByRole("option"); - options[5] && (await userEvent.click(options[5])); - options[8] && (await userEvent.click(options[8])); - options[13] && (await userEvent.click(options[13])); + options[5] && userEvent.click(options[5]); + options[8] && userEvent.click(options[8]); + options[13] && userEvent.click(options[13]); expect(onChange).toHaveBeenCalledWith({ value: ["6", "9", "14"] }); const clearSelectionButton = getByRole("button"); expect(clearSelectionButton.getAttribute("aria-label")).toBe("Clear selection"); - await userEvent.click(clearSelectionButton); + userEvent.click(clearSelectionButton); expect(onChange).toHaveBeenCalledWith({ value: [] }); }); -}); \ No newline at end of file + test("Select all (single) - 'Select all' option is included and (un)selects all the options available", () => { + const onChange = jest.fn(); + const { getByRole, getByText } = render( + <DxcSelect + enableSelectAll + label="Select an option" + multiple + options={reducedSingleOptions} + placeholder="Select an available option" + onChange={onChange} + optional + /> + ); + const select = getByRole("combobox"); + userEvent.click(select); + const selectAllOption = getByText("Select all"); + selectAllOption && userEvent.click(selectAllOption); + expect(onChange).toHaveBeenCalledWith({ value: ["1", "2", "3", "4"] }); + selectAllOption && userEvent.click(selectAllOption); + expect(onChange).toHaveBeenCalledWith({ value: [] }); + }); + test("Select all (groups) - 'Select all' option is included and (un)selects all the options available", () => { + const onChange = jest.fn(); + const { getByRole, getByText } = render( + <DxcSelect + enableSelectAll + label="Select an option" + multiple + options={reducedGroupedOptions} + placeholder="Select an available option" + onChange={onChange} + /> + ); + const select = getByRole("combobox"); + userEvent.click(select); + const selectAllOption = getByText("Select all"); + selectAllOption && userEvent.click(selectAllOption); + expect(onChange).toHaveBeenCalledWith({ + value: ["azul", "rojo", "rosa", "madrid", "oviedo", "sevilla", "miño", "duero", "tajo"], + }); + selectAllOption && userEvent.click(selectAllOption); + expect(onChange).toHaveBeenCalledWith({ error: "This field is required. Please, enter a value.", value: [] }); + }); + test("Select all - Keyboard navigation is correct", () => { + const onChange = jest.fn(); + const { getByRole, getByText } = render( + <DxcSelect + enableSelectAll + label="Select an option" + multiple + options={reducedGroupedOptions} + placeholder="Select an available option" + onChange={onChange} + /> + ); + const select = getByRole("combobox"); + fireEvent.keyDown(select, { key: "ArrowDown", code: "ArrowDown", keyCode: 40, charCode: 40 }); + expect(getByText("Select all")).toBeTruthy(); + fireEvent.keyDown(select, { key: "Enter", code: "Enter", keyCode: 13, charCode: 13 }); + expect(onChange).toHaveBeenCalledWith({ + value: ["azul", "rojo", "rosa", "madrid", "oviedo", "sevilla", "miño", "duero", "tajo"], + }); + fireEvent.keyDown(select, { key: "Enter", code: "Enter", keyCode: 13, charCode: 13 }); + expect(onChange).toHaveBeenCalledWith({ error: "This field is required. Please, enter a value.", value: [] }); + }); + test("Select all (groups) - 'Select all' option selects all the options when there's a partial selection", () => { + const onChange = jest.fn(); + const { getByRole, getByText } = render( + <DxcSelect + enableSelectAll + label="Select an option" + multiple + options={reducedGroupedOptions} + placeholder="Select an available option" + onChange={onChange} + defaultValue={["azul", "rojo", "rosa"]} + /> + ); + const select = getByRole("combobox"); + userEvent.click(select); + const selectAllOption = getByText("Select all"); + selectAllOption && userEvent.click(selectAllOption); + expect(onChange).toHaveBeenCalledWith({ + value: ["azul", "rojo", "rosa", "madrid", "oviedo", "sevilla", "miño", "duero", "tajo"], + }); + selectAllOption && userEvent.click(selectAllOption); + expect(onChange).toHaveBeenCalledWith({ error: "This field is required. Please, enter a value.", value: [] }); + }); + test("Select all options from a group - The header of a group is selectable and (un)selects all the options from its group", () => { + const onChange = jest.fn(); + const { getByRole, getByText } = render( + <DxcSelect + enableSelectAll + label="Select an option" + multiple + options={reducedGroupedOptions} + placeholder="Select an available option" + onChange={onChange} + /> + ); + const select = getByRole("combobox"); + userEvent.click(select); + const thirdGroupHeader = getByText("Ríos españoles"); + thirdGroupHeader && userEvent.click(thirdGroupHeader); + expect(onChange).toHaveBeenCalledWith({ + value: ["miño", "duero", "tajo"], + }); + thirdGroupHeader && userEvent.click(thirdGroupHeader); + expect(onChange).toHaveBeenCalledWith({ error: "This field is required. Please, enter a value.", value: [] }); + }); + test("Select all options from a group - The header of a group selects all the options when there's a partial selection", () => { + const onChange = jest.fn(); + const { getByRole, getByText } = render( + <DxcSelect + enableSelectAll + label="Select an option" + multiple + options={reducedGroupedOptions} + placeholder="Select an available option" + onChange={onChange} + defaultValue={["miño", "duero"]} + /> + ); + const select = getByRole("combobox"); + userEvent.click(select); + const thirdGroupHeader = getByText("Ríos españoles"); + thirdGroupHeader && userEvent.click(thirdGroupHeader); + expect(onChange).toHaveBeenCalledWith({ + value: ["miño", "duero", "tajo"], + }); + thirdGroupHeader && userEvent.click(thirdGroupHeader); + expect(onChange).toHaveBeenCalledWith({ error: "This field is required. Please, enter a value.", value: [] }); + }); + test("Select all options from a group - Keyboard navigation is correct", () => { + const onChange = jest.fn(); + const { getByRole } = render( + <DxcSelect + enableSelectAll + label="Select an option" + multiple + options={reducedGroupedOptions} + placeholder="Select an available option" + onChange={onChange} + /> + ); + const select = getByRole("combobox"); + fireEvent.keyDown(select, { key: "ArrowDown", code: "ArrowDown", keyCode: 40, charCode: 40 }); + fireEvent.keyDown(select, { key: "ArrowDown", code: "ArrowDown", keyCode: 40, charCode: 40 }); + fireEvent.keyDown(select, { key: "Enter", code: "Enter", keyCode: 13, charCode: 13 }); + expect(onChange).toHaveBeenLastCalledWith({ + value: ["azul", "rojo", "rosa"], + }); + fireEvent.keyDown(select, { key: "ArrowDown", code: "ArrowDown", keyCode: 40, charCode: 40 }); + fireEvent.keyDown(select, { key: "Enter", code: "Enter", keyCode: 13, charCode: 13 }); + expect(onChange).toHaveBeenLastCalledWith({ + value: ["rojo", "rosa"], + }); + fireEvent.keyDown(select, { key: "ArrowUp", code: "ArrowUp", keyCode: 38, charCode: 38 }); + fireEvent.keyDown(select, { key: "Enter", code: "Enter", keyCode: 13, charCode: 13 }); + expect(onChange).toHaveBeenLastCalledWith({ + value: ["rojo", "rosa", "azul"], + }); + fireEvent.keyDown(select, { key: "ArrowUp", code: "ArrowUp", keyCode: 38, charCode: 38 }); + fireEvent.keyDown(select, { key: "ArrowUp", code: "ArrowUp", keyCode: 38, charCode: 38 }); + fireEvent.keyDown(select, { key: "ArrowUp", code: "ArrowUp", keyCode: 38, charCode: 38 }); + fireEvent.keyDown(select, { key: "ArrowUp", code: "ArrowUp", keyCode: 38, charCode: 38 }); + fireEvent.keyDown(select, { key: "ArrowUp", code: "ArrowUp", keyCode: 38, charCode: 38 }); + fireEvent.keyDown(select, { key: "Enter", code: "Enter", keyCode: 13, charCode: 13 }); + expect(onChange).toHaveBeenLastCalledWith({ + value: ["rojo", "rosa", "azul", "miño", "duero", "tajo"], + }); + fireEvent.keyDown(select, { key: "Esc", code: "Esc", keyCode: 27, charCode: 27 }); + fireEvent.keyDown(select, { key: "ArrowDown", code: "ArrowDown", keyCode: 40, charCode: 40 }); + fireEvent.keyDown(select, { key: "Enter", code: "Enter", keyCode: 13, charCode: 13 }); + expect(onChange).toHaveBeenLastCalledWith({ + value: ["azul", "rojo", "rosa", "madrid", "oviedo", "sevilla", "miño", "duero", "tajo"], + }); + fireEvent.keyDown(select, { key: "Enter", code: "Enter", keyCode: 13, charCode: 13 }); + expect(onChange).toHaveBeenLastCalledWith({ error: "This field is required. Please, enter a value.", value: [] }); + }); +}); diff --git a/packages/lib/src/select/Select.tsx b/packages/lib/src/select/Select.tsx index a6e0f1e957..713dd0db0b 100644 --- a/packages/lib/src/select/Select.tsx +++ b/packages/lib/src/select/Select.tsx @@ -29,8 +29,12 @@ import { groupsHaveOptions, isArrayOfGroupedOptions, notOptionalCheck, + getSelectableOptionsValues, + getSelectionType, + getGroupSelectionType, + computeNewValue, } from "./utils"; -import SelectPropsType, { ListOptionType, RefType } from "./types"; +import SelectPropsType, { ListOptionGroupType, ListOptionType, RefType } from "./types"; import DxcActionIcon from "../action-icon/ActionIcon"; import DxcFlex from "../flex/Flex"; import ErrorMessage from "../styles/forms/ErrorMessage"; @@ -173,6 +177,7 @@ const DxcSelect = forwardRef<RefType, SelectPropsType>( ariaLabel = "Select", defaultValue, disabled = false, + enableSelectAll = false, error, helperText, label, @@ -212,19 +217,25 @@ const DxcSelect = forwardRef<RefType, SelectPropsType>( const optionalItem = useMemo(() => ({ label: placeholder, value: "" }), [placeholder]); const filteredOptions = useMemo(() => filterOptionsBySearchValue(options, searchValue), [options, searchValue]); const lastOptionIndex = useMemo( - () => getLastOptionIndex(options, filteredOptions, searchable, optional, multiple), - [options, filteredOptions, searchable, optional, multiple] + () => getLastOptionIndex(options, filteredOptions, searchable, optional, multiple, enableSelectAll), + [options, filteredOptions, searchable, optional, multiple, enableSelectAll] ); const { selectedOption, singleSelectionIndex } = useMemo( () => getSelectedOption(value ?? innerValue, options, multiple, optional, optionalItem), [value, innerValue, options, multiple, optional, optionalItem] ); + const selectableOptionsValues = useMemo(() => getSelectableOptionsValues(options), [options]); + const selectionType = useMemo( + () => getSelectionType(options, (value ?? innerValue) as string[]), + [innerValue, options, value] + ); const openListbox = () => { if (!isOpen && canOpenListbox(options, disabled)) { changeIsOpen(true); } }; + const closeListbox = () => { if (isOpen) { changeIsOpen(false); @@ -233,62 +244,72 @@ const DxcSelect = forwardRef<RefType, SelectPropsType>( }; const handleOnChangeValue = useCallback( - (newOption: ListOptionType | undefined) => { + (newOption?: ListOptionType) => { if (newOption) { - let newValue: string | string[]; - if (multiple) { - const currentValue = (value ?? innerValue) as string[]; - newValue = currentValue.includes(newOption.value) - ? currentValue.filter((optionVal) => optionVal !== newOption.value) - : [...currentValue, newOption.value]; - } else newValue = newOption.value; - - if (value == null) { - setInnerValue(newValue); + if (value == null) { + // uncontrolled mode: safely update using functional updates + setInnerValue((prev) => { + const newValue = computeNewValue(prev as string[], newOption); + onChange?.({ + value: newValue as string & string[], + error: notOptionalCheck(newValue, multiple, optional) + ? translatedLabels.formFields.requiredValueErrorMessage + : undefined, + }); + return newValue; + }); + } else { + // controlled mode: just call onChange + const newValue = computeNewValue((value ?? innerValue) as string[], newOption); + onChange?.({ + value: newValue as string & string[], + error: notOptionalCheck(newValue, multiple, optional) + ? translatedLabels.formFields.requiredValueErrorMessage + : undefined, + }); + } + } else { + if (value == null) setInnerValue(newOption.value); + onChange?.({ + value: newOption.value as string & string[], + error: notOptionalCheck(newOption.value, multiple, optional) + ? translatedLabels.formFields.requiredValueErrorMessage + : undefined, + }); } - onChange?.({ - value: newValue as string & string[], - error: notOptionalCheck(newValue, multiple, optional) - ? translatedLabels.formFields.requiredValueErrorMessage - : undefined, - }); } }, - [multiple, value, innerValue, onChange, optional, translatedLabels] + [multiple, value, onChange, optional, translatedLabels] ); + const handleOnClick = () => { - if (searchable) { - selectSearchInputRef?.current?.focus(); - } + if (searchable) selectSearchInputRef?.current?.focus(); if (isOpen) { closeListbox(); setSearchValue(""); - } else { - openListbox(); - } + } else openListbox(); }; + const handleOnFocus = (event: FocusEvent<HTMLInputElement>) => { - if (!event.currentTarget.contains(event.relatedTarget) && searchable) { - selectSearchInputRef?.current?.focus(); - } + if (!event.currentTarget.contains(event.relatedTarget) && searchable) selectSearchInputRef?.current?.focus(); }; + const handleOnBlur = (event: FocusEvent<HTMLInputElement>) => { if (!event.currentTarget.contains(event.relatedTarget)) { closeListbox(); setSearchValue(""); const currentValue = value ?? innerValue; - if (notOptionalCheck(currentValue, multiple, optional)) { + if (notOptionalCheck(currentValue, multiple, optional)) onBlur?.({ value: currentValue as string & string[], error: translatedLabels.formFields.requiredValueErrorMessage, }); - } else { - onBlur?.({ value: currentValue as string & string[] }); - } + else onBlur?.({ value: currentValue as string & string[] }); } }; + const handleOnKeyDown = (event: KeyboardEvent<HTMLDivElement>) => { switch (event.key) { case "Down": @@ -298,16 +319,12 @@ const DxcSelect = forwardRef<RefType, SelectPropsType>( singleSelectionIndex != null && (!isOpen || (visualFocusIndex === -1 && singleSelectionIndex > -1 && singleSelectionIndex <= lastOptionIndex)) - ) { + ) changeVisualFocusIndex(singleSelectionIndex); - } else { - changeVisualFocusIndex((currentVisualFocusIndex) => { - if (currentVisualFocusIndex < lastOptionIndex) { - return currentVisualFocusIndex + 1; - } - return 0; - }); - } + else + changeVisualFocusIndex((currentVisualFocusIndex) => + currentVisualFocusIndex < lastOptionIndex ? currentVisualFocusIndex + 1 : 0 + ); openListbox(); break; case "Up": @@ -317,65 +334,82 @@ const DxcSelect = forwardRef<RefType, SelectPropsType>( singleSelectionIndex != null && (!isOpen || (visualFocusIndex === -1 && singleSelectionIndex > -1 && singleSelectionIndex <= lastOptionIndex)) - ) { + ) changeVisualFocusIndex(singleSelectionIndex); - } else { + else changeVisualFocusIndex((currentVisualFocusIndex) => currentVisualFocusIndex === 0 || currentVisualFocusIndex === -1 ? lastOptionIndex : currentVisualFocusIndex - 1 ); - } openListbox(); break; case "Esc": case "Escape": event.preventDefault(); - if (isOpen) { - event.stopPropagation(); - } + if (isOpen) event.stopPropagation(); closeListbox(); setSearchValue(""); break; case "Enter": if (isOpen && visualFocusIndex >= 0) { - let accLength = optional && !multiple ? 1 : 0; - if (searchable) { - if (filteredOptions.length > 0) { - if (optional && !multiple && visualFocusIndex === 0 && groupsHaveOptions(filteredOptions)) { - handleOnChangeValue(optionalItem); - } else if (isArrayOfGroupedOptions(filteredOptions)) { - if (groupsHaveOptions(filteredOptions)) { - filteredOptions.some((groupOption) => { - const groupLength = accLength + groupOption.options.length; - if (groupLength > visualFocusIndex) { - handleOnChangeValue(groupOption.options[visualFocusIndex - accLength]); - } - accLength = groupLength; - return groupLength > visualFocusIndex; - }); - } + let accLength = (multiple ? enableSelectAll : optional) ? 1 : 0; + if (searchable && filteredOptions.length > 0) { + if (!multiple && visualFocusIndex === 0 && optional) handleOnChangeValue(optionalItem); + else if (multiple && visualFocusIndex === 0 && enableSelectAll) handleSelectAllOnClick(); + else if (isArrayOfGroupedOptions(filteredOptions) && enableSelectAll) { + if (groupsHaveOptions(filteredOptions)) + filteredOptions.some((group) => { + if (visualFocusIndex === accLength) { + handleSelectAllGroup(group); + return true; + } else { + accLength++; + return group.options.some((option) => { + if (visualFocusIndex === accLength) { + handleOnChangeValue(option); + return true; + } else accLength++; + }); + } + }); + } else if (isArrayOfGroupedOptions(filteredOptions)) { + if (groupsHaveOptions(filteredOptions)) + filteredOptions.some((group) => { + const groupLength = accLength + group.options.length; + if (groupLength > visualFocusIndex) + handleOnChangeValue(group.options[visualFocusIndex - accLength]); + accLength = groupLength; + return groupLength > visualFocusIndex; + }); + } else handleOnChangeValue(filteredOptions[visualFocusIndex - accLength]); + } else if (!multiple && visualFocusIndex === 0 && optional) handleOnChangeValue(optionalItem); + else if (multiple && visualFocusIndex === 0 && enableSelectAll) handleSelectAllOnClick(); + else if (isArrayOfGroupedOptions(options) && enableSelectAll) + options.some((group) => { + if (visualFocusIndex === accLength) { + handleSelectAllGroup(group); + return true; } else { - handleOnChangeValue(filteredOptions[visualFocusIndex - accLength]); - } - } - } else if (optional && !multiple && visualFocusIndex === 0) { - handleOnChangeValue(optionalItem); - } else if (isArrayOfGroupedOptions(options)) { - options.some((groupOption) => { - const groupLength = accLength + groupOption.options.length; - if (groupLength > visualFocusIndex) { - handleOnChangeValue(groupOption.options[visualFocusIndex - accLength]); + accLength++; + return group.options.some((option) => { + if (visualFocusIndex === accLength) { + handleOnChangeValue(option); + return true; + } else accLength++; + }); } + }); + else if (isArrayOfGroupedOptions(options)) + options.some((group) => { + const groupLength = accLength + group.options.length; + if (groupLength > visualFocusIndex) handleOnChangeValue(group.options[visualFocusIndex - accLength]); accLength = groupLength; return groupLength > visualFocusIndex; }); - } else { - handleOnChangeValue(options[visualFocusIndex - accLength]); - } - if (!multiple) { - closeListbox(); - } + else handleOnChangeValue(options[visualFocusIndex - accLength]); + + if (!multiple) closeListbox(); setSearchValue(""); } break; @@ -383,6 +417,7 @@ const DxcSelect = forwardRef<RefType, SelectPropsType>( break; } }; + const handleOnMouseEnter = (event: MouseEvent<HTMLSpanElement>) => { const text = event.currentTarget; setHasTooltip(text.scrollWidth > text.clientWidth); @@ -394,21 +429,16 @@ const DxcSelect = forwardRef<RefType, SelectPropsType>( openListbox(); }; - const handleClearOptionsActionOnClick = (event: MouseEvent<HTMLButtonElement>) => { - event.stopPropagation(); - + const handleClearOptionsActionOnClick = (event?: MouseEvent<HTMLButtonElement>) => { + event?.stopPropagation(); const empty: string[] = []; - if (value == null) { - setInnerValue(empty); - } - if (!optional) { + if (value == null) setInnerValue(empty); + if (!optional) onChange?.({ value: empty as string & string[], error: translatedLabels.formFields.requiredValueErrorMessage, }); - } else { - onChange?.({ value: empty as string & string[] }); - } + else onChange?.({ value: empty as string & string[] }); }; const handleClearSearchActionOnClick = (event: MouseEvent<HTMLButtonElement>) => { @@ -419,14 +449,32 @@ const DxcSelect = forwardRef<RefType, SelectPropsType>( const handleOptionOnClick = useCallback( (option: ListOptionType) => { handleOnChangeValue(option); - if (!multiple) { - closeListbox(); - } + if (!multiple) closeListbox(); setSearchValue(""); }, [closeListbox, handleOnChangeValue, multiple] ); + const handleSelectAllOnClick = useCallback(() => { + if (selectionType === "checked") handleClearOptionsActionOnClick(); + else { + if (value == null) setInnerValue(selectableOptionsValues); + onChange?.({ value: selectableOptionsValues as string & string[] }); + } + }, [handleClearOptionsActionOnClick, innerValue, multiple, onChange, options, value]); + + const handleSelectAllGroup = useCallback( + (group: ListOptionGroupType) => { + const groupSelectionType = getGroupSelectionType(group.options, (value ?? innerValue) as string[]); + if (groupSelectionType === "indeterminate") + group.options.forEach( + (option) => !(value ?? innerValue).includes(option.value) && handleOptionOnClick(option) + ); + else group.options.forEach((option) => handleOptionOnClick(option)); + }, + [handleOptionOnClick, innerValue, value] + ); + return ( <SelectContainer margin={margin} ref={ref} size={size}> {label && ( @@ -558,7 +606,10 @@ const DxcSelect = forwardRef<RefType, SelectPropsType>( <Listbox ariaLabelledBy={labelId} currentValue={value ?? innerValue} + enableSelectAll={enableSelectAll} handleOptionOnClick={handleOptionOnClick} + handleGroupOnClick={handleSelectAllGroup} + handleSelectAllOnClick={handleSelectAllOnClick} id={listboxId} lastOptionIndex={lastOptionIndex} multiple={multiple} @@ -566,6 +617,7 @@ const DxcSelect = forwardRef<RefType, SelectPropsType>( optionalItem={optionalItem} options={searchable ? filteredOptions : options} searchable={searchable} + selectionType={selectionType} styles={{ width }} visualFocusIndex={visualFocusIndex} /> diff --git a/packages/lib/src/select/types.ts b/packages/lib/src/select/types.ts index 873a1ca444..9682d05be2 100644 --- a/packages/lib/src/select/types.ts +++ b/packages/lib/src/select/types.ts @@ -1,17 +1,6 @@ import { CSSProperties } from "react"; import { Margin, SVG, Space } from "../common/utils"; -export type ListOptionGroupType = { - /** - * Label of the group to be shown in the select's listbox. - */ - label: string; - /** - * List of the grouped options. - */ - options: ListOptionType[]; -}; - export type ListOptionType = { /** * Element used as the icon that will be placed before the option label. @@ -32,6 +21,17 @@ export type ListOptionType = { value: string; }; +export type ListOptionGroupType = { + /** + * Label of the group to be shown in the select's listbox. + */ + label: string; + /** + * List of the grouped options. + */ + options: ListOptionType[]; +}; + type CommonProps = { /** * Text to be placed above the select. @@ -102,6 +102,10 @@ type CommonProps = { }; type SingleSelect = CommonProps & { + /** + * Enables users to select multiple items from the list. + */ + enableSelectAll?: never; /** * If true, the select component will support multiple selected options. * In that case, value will be an array of strings with each selected @@ -133,6 +137,10 @@ type SingleSelect = CommonProps & { }; type MultipleSelect = CommonProps & { + /** + * Enables users to select multiple items from the list. + */ + enableSelectAll?: boolean; /** * If true, the select component will support multiple selected options. * In that case, value will be an array of strings with each selected @@ -170,13 +178,14 @@ type Props = SingleSelect | MultipleSelect; */ export type OptionProps = { id: string; - option: ListOptionType; - onClick: (option: ListOptionType) => void; - multiple: boolean; - visualFocused: boolean; isGroupedOption?: boolean; isLastOption: boolean; isSelected: boolean; + isSelectAllOption?: boolean; + multiple: boolean; + option: ListOptionType; + onClick: (option: ListOptionType) => void; + visualFocused: boolean; }; /** @@ -184,17 +193,21 @@ export type OptionProps = { */ export type ListboxProps = { ariaLabelledBy: string; - id: string; currentValue: string | string[]; - options: ListOptionType[] | ListOptionGroupType[]; - visualFocusIndex: number; + enableSelectAll: boolean; + handleGroupOnClick: (group: ListOptionGroupType) => void; + handleOptionOnClick: (option: ListOptionType) => void; + handleSelectAllOnClick: () => void; + id: string; lastOptionIndex: number; multiple: boolean; optional: boolean; optionalItem: ListOptionType; + options: ListOptionType[] | ListOptionGroupType[]; searchable: boolean; - handleOptionOnClick: (option: ListOptionType) => void; + selectionType: "checked" | "unchecked" | "indeterminate"; styles: CSSProperties; + visualFocusIndex: number; }; /** diff --git a/packages/lib/src/select/utils.ts b/packages/lib/src/select/utils.ts index 6234230b58..c73e248bdd 100644 --- a/packages/lib/src/select/utils.ts +++ b/packages/lib/src/select/utils.ts @@ -1,5 +1,6 @@ import SelectPropsType, { ListOptionType, ListOptionGroupType } from "./types"; import { getMargin } from "../common/utils"; +import Props from "./types"; const sizes = { small: "240px", @@ -28,14 +29,14 @@ const isOptionGroup = (option: ListOptionType | ListOptionGroupType): option is /** * Checks if the options are grouped options (groups and single options can't be mixed) */ -export const isArrayOfGroupedOptions = (options: ListOptionType[] | ListOptionGroupType[]): options is ListOptionGroupType[] => +export const isArrayOfGroupedOptions = (options: Props["options"]): options is ListOptionGroupType[] => options[0] != null && isOptionGroup(options[0]); /** * Checks if the groups have options. If the options parameter is not an array of grouped options, * it will return true and not check nothing else. */ -export const groupsHaveOptions = (options: ListOptionType[] | ListOptionGroupType[]) => +export const groupsHaveOptions = (options: Props["options"]) => isArrayOfGroupedOptions(options) ? options.some((groupOption) => groupOption.options.length > 0) : true; /** @@ -44,16 +45,13 @@ export const groupsHaveOptions = (options: ListOptionType[] | ListOptionGroupTyp * - The listbox has more than one single option. * - The listbox has more than one group with options contained. */ -export const canOpenListbox = (options: ListOptionType[] | ListOptionGroupType[], disabled: boolean) => +export const canOpenListbox = (options: Props["options"], disabled: boolean) => !disabled && options.length > 0 && groupsHaveOptions(options); /** * Filters the options by the search value. */ -export const filterOptionsBySearchValue = ( - options: ListOptionType[] | ListOptionGroupType[], - searchValue: string -): ListOptionType[] | ListOptionGroupType[] => +export const filterOptionsBySearchValue = (options: Props["options"], searchValue: string): Props["options"] => options.length > 0 ? isArrayOfGroupedOptions(options) ? options.map((optionGroup) => { @@ -72,30 +70,26 @@ export const filterOptionsBySearchValue = ( * Returns the index of the last option, depending on several conditions. */ export const getLastOptionIndex = ( - options: ListOptionType[] | ListOptionGroupType[], - filteredOptions: ListOptionType[] | ListOptionGroupType[], + options: Props["options"], + filteredOptions: Props["options"], searchable: boolean, optional: boolean, - multiple: boolean + multiple: boolean, + enableSelectAll: boolean ) => { let last = 0; - const reducer = (acc: number, current: ListOptionGroupType) => acc + (current.options.length ?? 0); + const reducer = (acc: number, current: ListOptionGroupType) => + acc + (current.options.length ?? 0) + (enableSelectAll ? 1 : 0); if (searchable && filteredOptions.length > 0) { - if (isArrayOfGroupedOptions(filteredOptions)) { - last = filteredOptions.reduce(reducer, 0) - 1; - } else { - last = filteredOptions.length - 1; - } + if (isArrayOfGroupedOptions(filteredOptions)) last = filteredOptions.reduce(reducer, 0) - 1; + else last = filteredOptions.length - 1; } else if (options.length > 0) { - if (isArrayOfGroupedOptions(options)) { - last = options.reduce(reducer, 0) - 1; - } else { - last = options.length - 1; - } + if (isArrayOfGroupedOptions(options)) last = options.reduce(reducer, 0) - 1; + else last = options.length - 1; } - return optional && !multiple ? last + 1 : last; + return (multiple ? enableSelectAll : optional) ? last + 1 : last; }; /** @@ -103,7 +97,7 @@ export const getLastOptionIndex = ( */ export const getSelectedOption = ( value: string | string[], - options: ListOptionType[] | ListOptionGroupType[], + options: Props["options"], multiple: boolean, optional: boolean, optionalItem: ListOptionType @@ -165,3 +159,61 @@ export const getSelectedOptionLabel = (placeholder: string, selectedOption: List ? placeholder : selectedOption.map((option) => option.label).join(", ") : (selectedOption.label ?? placeholder); + +/** + * Returns a determined string value depending on the amount of options selected: + * - All options are selected -> "checked" + * - Partial selection -> "indeterminate" + * - No option is selected -> "unchecked" + * @param options + * @param value + * @returns + */ +export const getSelectionType = (options: Props["options"], value: string[]) => { + if (value.length > 0) { + if ( + isArrayOfGroupedOptions(options) + ? options.flatMap((group) => group.options.map((option) => option.value)).length === value.length + : options.length === value.length + ) + return "checked"; + else return "indeterminate"; + } else return "unchecked"; +}; + +/** + * Returns a determined string value depending on the amount of options selected from a group: + * - All grouped options are selected -> "checked" + * - Partial selection -> "indeterminate" + * - No option from the group is selected -> "unchecked" + * @param options + * @param value + * @returns boolean + */ +export const getGroupSelectionType = (options: ListOptionType[], value: string[]) => + options.every((option) => value.includes(option.value)) + ? "checked" + : options.some((option) => value.includes(option.value)) + ? "indeterminate" + : "unchecked"; + +/** + * Return an array with all the values from the options passed by the user, whether grouped or not, that can be selected. + * @param options + * @returns + */ +export const getSelectableOptionsValues = (options: Props["options"]) => + isArrayOfGroupedOptions(options) + ? options.flatMap((group) => group.options.map((option) => option.value)) + : options.map((option) => option.value); + +/** + * (Un)Selects the option passed as parameter. + * @param currentValue + * @param newOption + * @returns + */ +export const computeNewValue = (currentValue: string[], newOption: ListOptionType) => + currentValue.includes(newOption.value) + ? currentValue.filter((val) => val !== newOption.value) + : [...currentValue, newOption.value];