From c413fdf4d8ce2c95acd94d28e36f2d0b8ac0b6c1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Iv=C3=A1n=20G=C3=B3mez=20Pinta?=
<44321109+GomezIvann@users.noreply.github.com>
Date: Thu, 6 Feb 2025 13:06:08 +0100
Subject: [PATCH 01/15] Select reimplemented with new tokens
---
packages/lib/src/index.ts | 2 +-
packages/lib/src/select/Select.stories.tsx | 68 +-
packages/lib/src/select/Select.test.tsx | 56 +-
packages/lib/src/select/Select.tsx | 782 +++++++++------------
packages/lib/src/select/utils.ts | 43 +-
5 files changed, 387 insertions(+), 564 deletions(-)
diff --git a/packages/lib/src/index.ts b/packages/lib/src/index.ts
index fd0fc86ca5..3ded437d20 100644
--- a/packages/lib/src/index.ts
+++ b/packages/lib/src/index.ts
@@ -1,5 +1,5 @@
import "./styles/fonts.css";
-// import "./styles/variables.css";
+import "./styles/variables.css";
export { default as DxcAccordion } from "./accordion/Accordion";
export { default as DxcAccordionGroup } from "./accordion-group/AccordionGroup";
diff --git a/packages/lib/src/select/Select.stories.tsx b/packages/lib/src/select/Select.stories.tsx
index 07f3ed7c32..366b1dcd46 100644
--- a/packages/lib/src/select/Select.stories.tsx
+++ b/packages/lib/src/select/Select.stories.tsx
@@ -6,7 +6,7 @@ import Title from "../../.storybook/components/Title";
import preview from "../../.storybook/preview";
import { disabledRules } from "../../test/accessibility/rules/specific/select/disabledRules";
import DxcFlex from "../flex/Flex";
-import HalstackContext, { HalstackProvider } from "../HalstackContext";
+import HalstackContext from "../HalstackContext";
import Listbox from "./Listbox";
import DxcSelect from "./Select";
import { Meta, StoryObj } from "@storybook/react";
@@ -219,17 +219,18 @@ const optionsWithEllipsis = [
{ label: "Option 03111111111111111111111111111122222222", value: "3" },
];
-const opinionatedTheme = {
- select: {
- selectedOptionBackgroundColor: "#fabada",
- fontColor: "#333",
- optionFontColor: "#a46ede",
- hoverBorderColor: "#0095ff",
- },
-};
-
const Select = () => (
<>
+
+
+
+
@@ -361,36 +362,6 @@ const Select = () => (
>
);
-const Opinionated = () => (
- <>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
-);
-
const SelectListbox = () => {
const colorsTheme = useContext(HalstackContext);
@@ -602,9 +573,7 @@ const DefaultGroupedOptionsSelect = () => (
const DefaultGroupedOptionsSelectOpinionated = () => (
-
-
-
+
);
@@ -685,15 +654,6 @@ export const Chromatic: Story = {
},
};
-export const OpinionatedTheme: Story = {
- render: Opinionated,
- play: async ({ canvasElement }) => {
- const canvas = within(canvasElement);
- const combobox = canvas.getAllByRole("combobox")[2];
- combobox && await userEvent.click(combobox);
- },
-};
-
export const ListboxStates: Story = {
render: SelectListbox,
play: async ({ canvasElement }) => {
@@ -724,7 +684,7 @@ export const MultipleSearchableWithValue: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const combobox = canvas.getAllByRole("combobox")[0];
- combobox && await userEvent.click(combobox);
+ combobox && (await userEvent.click(combobox));
},
};
@@ -751,7 +711,7 @@ export const MultipleOptionsDisplayed: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const combobox = canvas.getAllByRole("combobox")[0];
- combobox && await userEvent.click(combobox);
+ combobox && (await userEvent.click(combobox));
},
};
diff --git a/packages/lib/src/select/Select.test.tsx b/packages/lib/src/select/Select.test.tsx
index 2da6718b04..9ea606466e 100644
--- a/packages/lib/src/select/Select.test.tsx
+++ b/packages/lib/src/select/Select.test.tsx
@@ -87,7 +87,6 @@ describe("Select component tests", () => {
await userEvent.click(label);
expect(document.activeElement).toEqual(select);
});
-
test("Renders with correct aria attributes when is in error state", () => {
const { getByText, getByRole } = render(
@@ -100,7 +99,6 @@ 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 () => {
const { getByText, getByRole } = render(
@@ -120,7 +118,6 @@ describe("Select component tests", () => {
expect(select.getAttribute("aria-controls")).toBe(list.id);
expect(list.getAttribute("aria-multiselectable")).toBe("false");
});
-
test("Renders with correct error aria label", () => {
const { getByRole } = render(
@@ -128,7 +125,6 @@ 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 () => {
const { getByText, getByRole, getAllByRole, queryByRole, container } = render(
@@ -145,7 +141,6 @@ describe("Select component tests", () => {
expect(getByText("Option 08")).toBeTruthy();
expect(submitInput?.value).toBe("8");
});
-
test("Multiple selection: Renders with correct default value", async () => {
const { getByText, getByRole, getAllByRole, queryByRole, container } = render(
{
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 () => {
const handlerOnSubmit = jest.fn((e) => {
e.preventDefault();
@@ -194,7 +188,6 @@ describe("Select component tests", () => {
options[2] && (await userEvent.click(options[2]));
await userEvent.click(submit);
});
-
test("Searching for a value with an empty list of options passed doesn't open the listbox", async () => {
const { container, getByRole, queryByRole } = render(
@@ -208,7 +201,6 @@ describe("Select component tests", () => {
expect(queryByRole("listbox")).toBeFalsy();
expect(select.getAttribute("aria-expanded")).toBe("false");
});
-
test("Disabled select - Cannot gain focus or open the listbox via click", async () => {
const { getByRole, queryByRole } = render(
@@ -219,7 +211,6 @@ describe("Select component tests", () => {
expect(queryByRole("listbox")).toBeFalsy();
expect(document.activeElement === select).toBeFalsy();
});
-
test("Disabled select - Clear all options action must be shown but not clickable", async () => {
const { getByRole, getByText } = render(
@@ -227,7 +218,6 @@ describe("Select component tests", () => {
await userEvent.click(getByRole("button"));
expect(getByText("Option 01, Option 02")).toBeTruthy();
});
-
test("Disabled select - Does not call onBlur event", async () => {
const onBlur = jest.fn();
const { getByRole } = render(
@@ -238,7 +228,6 @@ describe("Select component tests", () => {
fireEvent.keyDown(getByRole("combobox"), { key: "Tab", code: "Tab", keyCode: 9, charCode: 9 });
expect(onBlur).not.toHaveBeenCalled();
});
-
test("Disabled select - When the component gains the focus, the listbox does not open", () => {
const { getByRole, queryByRole } = render(
@@ -248,7 +237,6 @@ describe("Select component tests", () => {
expect(queryByRole("listbox")).toBeFalsy();
expect(document.activeElement === select).toBeFalsy();
});
-
test("Disabled select - Doesn't send its value when submitted", async () => {
const handlerOnSubmit = jest.fn((e) => {
e.preventDefault();
@@ -265,7 +253,6 @@ describe("Select component tests", () => {
const submit = getByText("Submit");
await userEvent.click(submit);
});
-
test("Controlled - Single selection - Not optional constraint", async () => {
const onChange = jest.fn();
const onBlur = jest.fn();
@@ -287,7 +274,6 @@ describe("Select component tests", () => {
expect(onBlur).toHaveBeenCalled();
expect(onBlur).toHaveBeenCalledWith({ value: "1" });
});
-
test("Controlled - Multiple selection - Not optional constraint", async () => {
const onChange = jest.fn();
const onBlur = jest.fn();
@@ -319,7 +305,6 @@ describe("Select component tests", () => {
expect(onBlur).toHaveBeenCalled();
expect(onBlur).toHaveBeenCalledWith({ value: [], error: "This field is required. Please, enter a value." });
});
-
test("Controlled - Optional constraint", () => {
const onChange = jest.fn();
const onBlur = jest.fn();
@@ -334,7 +319,6 @@ 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 () => {
const { getByText, getByRole, getAllByRole, queryByRole } = render(
@@ -352,7 +336,6 @@ describe("Select component tests", () => {
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 () => {
const { getByRole, queryByRole } = render();
const select = getByRole("combobox");
@@ -360,7 +343,6 @@ describe("Select component tests", () => {
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 () => {
const onChange = jest.fn();
const { getByText, getByRole, getAllByRole, queryByRole, container } = render(
@@ -379,7 +361,6 @@ describe("Select component tests", () => {
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 () => {
const onChange = jest.fn();
const { getByRole, getAllByRole, getAllByText } = render(
@@ -407,7 +388,6 @@ 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 () => {
const { getAllByRole, getByText, queryByText, container } = render(
{
expect(queryByText("Placeholder example")).toBeFalsy();
expect(getByText("No matches found")).toBeTruthy();
});
-
test("Non-Grouped Options: Arrow up key - Opens the listbox and visually focus the last option", () => {
const { getByRole, queryByRole } = render();
const select = getByRole("combobox");
@@ -438,7 +417,6 @@ describe("Select component tests", () => {
expect(queryByRole("listbox")).toBeTruthy();
expect(select.getAttribute("aria-activedescendant")).toBe("option-19");
});
-
test("Non-Grouped Options: Arrow up key - Puts the focus in last option when the first one is visually focused", () => {
const { getByRole, queryByRole } = render();
const select = getByRole("combobox");
@@ -447,7 +425,6 @@ describe("Select component tests", () => {
expect(queryByRole("listbox")).toBeTruthy();
expect(select.getAttribute("aria-activedescendant")).toBe("option-19");
});
-
test("Non-Grouped Options: Arrow down key - Opens the listbox and visually focus the first option", () => {
const { getByRole, queryByRole } = render();
const select = getByRole("combobox");
@@ -455,7 +432,6 @@ describe("Select component tests", () => {
expect(queryByRole("listbox")).toBeTruthy();
expect(select.getAttribute("aria-activedescendant")).toBe("option-0");
});
-
test("Non-Grouped Options: Arrow down key - Puts the focus in the first option when the last one is visually focused", () => {
const { getByRole, queryByRole } = render();
const select = getByRole("combobox");
@@ -464,7 +440,6 @@ 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 () => {
const onChange = jest.fn();
const { getByText, getByRole, getAllByRole, queryByRole } = render(
@@ -483,7 +458,6 @@ describe("Select component tests", () => {
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 () => {
const onChange = jest.fn();
const { container, getByText, getByRole, getAllByRole, queryByRole } = render(
@@ -503,7 +477,6 @@ describe("Select component tests", () => {
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 () => {
const onChange = jest.fn();
const { container, getByText, getByRole } = render(
@@ -516,7 +489,6 @@ describe("Select component tests", () => {
searchInput && (await 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 () => {
const onChange = jest.fn();
const { container, getByText, getByRole, getAllByRole } = render(
@@ -537,7 +509,6 @@ describe("Select component tests", () => {
});
expect(searchInput?.value).toBe("");
});
-
test("Non-Grouped Options: Searchable - Writing displays the listbox, if it was not open", async () => {
const onChange = jest.fn();
const { container, getByRole, queryByRole } = render(
@@ -551,7 +522,6 @@ describe("Select component tests", () => {
searchInput && (await userEvent.type(searchInput, "2"));
expect(getByRole("listbox")).toBeTruthy();
});
-
test("Non-Grouped Options: Searchable - Key Esc cleans the search value and closes the options", async () => {
const onChange = jest.fn();
const { container, getByRole, queryByRole } = render(
@@ -564,7 +534,6 @@ describe("Select component tests", () => {
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 () => {
const onChange = jest.fn();
const { container, getByRole, getAllByRole, queryByRole } = render(
@@ -580,7 +549,6 @@ describe("Select component tests", () => {
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 () => {
const onChange = jest.fn();
const { getByText, getAllByText, getByRole, getAllByRole, queryByRole, container } = render(
@@ -603,7 +571,6 @@ 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 () => {
const onChange = jest.fn();
const { getByText, queryByText, getByRole, getAllByRole, queryByRole } = render(
@@ -628,7 +595,6 @@ describe("Select component tests", () => {
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 () => {
const onChange = jest.fn();
const { getByText, getAllByText, getByRole, getAllByRole } = render(
@@ -650,7 +616,6 @@ describe("Select component tests", () => {
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 () => {
const { getByText, getByRole, getAllByRole } = render(
@@ -672,7 +637,6 @@ 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 () => {
const { getByText, getByRole, getAllByRole, queryByRole } = render(
@@ -697,7 +661,6 @@ 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 () => {
const { getByText, getByRole, getAllByRole, queryByRole } = render(
@@ -723,7 +686,6 @@ describe("Select component tests", () => {
expect(queryByRole("list")).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 () => {
const { getByRole, queryByRole } = render(
{
expect(queryByRole("list")).toBeFalsy();
expect(select.getAttribute("aria-expanded")).toBe("false");
});
-
test("Grouped Options - Click in an option selects it and closes the listbox", async () => {
const onChange = jest.fn();
const { getByText, getByRole, getAllByRole, queryByRole, container } = render(
@@ -760,7 +721,6 @@ describe("Select component tests", () => {
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 () => {
const onChange = jest.fn();
const { getByRole, getAllByRole, getAllByText } = render(
@@ -788,7 +748,6 @@ 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 () => {
const { getByRole, getAllByRole, getByText, queryByText, container } = render(
{
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();
const select = getByRole("combobox");
@@ -817,7 +775,6 @@ describe("Select component tests", () => {
expect(queryByRole("list")).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();
const select = getByRole("combobox");
@@ -826,7 +783,6 @@ describe("Select component tests", () => {
expect(queryByRole("list")).toBeTruthy();
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();
const select = getByRole("combobox");
@@ -834,7 +790,6 @@ describe("Select component tests", () => {
expect(queryByRole("list")).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();
const select = getByRole("combobox");
@@ -843,7 +798,6 @@ describe("Select component tests", () => {
expect(queryByRole("list")).toBeTruthy();
expect(select.getAttribute("aria-activedescendant")).toBe("option-0");
});
-
test("Grouped Options: Enter key - Selects the visually focused option and closes the listbox", async () => {
const onChange = jest.fn();
const { getByText, getByRole, getAllByRole, queryByRole } = render(
@@ -862,7 +816,6 @@ describe("Select component tests", () => {
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 () => {
const onChange = jest.fn();
const { container, getByText, getByRole, getAllByRole, queryByRole } = render(
@@ -887,7 +840,6 @@ describe("Select component tests", () => {
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 () => {
const onChange = jest.fn();
const { container, getByText, getByRole } = render(
@@ -900,7 +852,6 @@ describe("Select component tests", () => {
searchInput && (await 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 () => {
const onChange = jest.fn();
const { getByText, getAllByText, getByRole, getAllByRole, queryByRole, container } = render(
@@ -922,7 +873,6 @@ 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 () => {
const onChange = jest.fn();
const { getByText, queryByText, getByRole, getAllByRole, queryByRole } = render(
@@ -947,7 +897,6 @@ describe("Select component tests", () => {
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 () => {
const onChange = jest.fn();
const { getByText, getAllByText, getByRole, getAllByRole } = render(
@@ -969,7 +918,6 @@ describe("Select component tests", () => {
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 () => {
const { getByText, getByRole, getAllByRole } = render(
@@ -991,7 +939,6 @@ 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 () => {
const { getByText, getByRole, getAllByRole } = render(
@@ -1014,7 +961,6 @@ 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 () => {
const onChange = jest.fn();
const { getByRole, getAllByRole } = render(
@@ -1032,4 +978,4 @@ describe("Select component tests", () => {
await userEvent.click(clearSelectionButton);
expect(onChange).toHaveBeenCalledWith({ value: [] });
});
-});
+});
\ No newline at end of file
diff --git a/packages/lib/src/select/Select.tsx b/packages/lib/src/select/Select.tsx
index b1cc105805..983ce3fa9b 100644
--- a/packages/lib/src/select/Select.tsx
+++ b/packages/lib/src/select/Select.tsx
@@ -12,16 +12,16 @@ import {
useRef,
useState,
} from "react";
-import styled, { ThemeProvider } from "styled-components";
+import styled from "styled-components";
import { spaces } from "../common/variables";
-import { getMargin } from "../common/utils";
import DxcIcon from "../icon/Icon";
import { Tooltip, TooltipWrapper } from "../tooltip/Tooltip";
-import HalstackContext, { HalstackLanguageContext } from "../HalstackContext";
+import { HalstackLanguageContext } from "../HalstackContext";
import useWidth from "../utils/useWidth";
import Listbox from "./Listbox";
import {
- canOpenOptions,
+ calculateWidth,
+ canOpenListbox,
filterOptionsBySearchValue,
getLastOptionIndex,
getSelectedOption,
@@ -31,6 +31,194 @@ import {
notOptionalCheck,
} from "./utils";
import SelectPropsType, { ListOptionType, RefType } from "./types";
+import DxcActionIcon from "../action-icon/ActionIcon";
+import DxcFlex from "../flex/Flex";
+
+const SelectContainer = styled.div<{
+ margin: SelectPropsType["margin"];
+ size: SelectPropsType["size"];
+}>`
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: column;
+ width: ${(props) => calculateWidth(props.margin, props.size)};
+ ${(props) => props.size !== "fillParent" && `min-width:${calculateWidth(props.margin, props.size)}`};
+ margin: ${(props) => (props.margin && typeof props.margin !== "object" ? spaces[props.margin] : "0px")};
+ margin-top: ${(props) =>
+ props.margin && typeof props.margin === "object" && props.margin.top ? spaces[props.margin.top] : ""};
+ margin-right: ${(props) =>
+ props.margin && typeof props.margin === "object" && props.margin.right ? spaces[props.margin.right] : ""};
+ margin-bottom: ${(props) =>
+ props.margin && typeof props.margin === "object" && props.margin.bottom ? spaces[props.margin.bottom] : ""};
+ margin-left: ${(props) =>
+ props.margin && typeof props.margin === "object" && props.margin.left ? spaces[props.margin.left] : ""};
+ font-family: var(--typography-font-family);
+`;
+
+const Label = styled.label<{
+ disabled: SelectPropsType["disabled"];
+ helperText: SelectPropsType["helperText"];
+}>`
+ color: var(${({ disabled }) => (disabled ? "--color-fg-neutral-medium" : "--color-fg-neutral-dark")});
+ font-size: var(--typography-label-m);
+ font-weight: var(--typography-label-semibold);
+ ${({ helperText }) => !helperText && "margin-bottom: var(--spacing-gap-xs);"}
+
+ > span {
+ color: var(--color-fg-neutral-stronger);
+ font-weight: var(--typography-label-regular);
+ }
+`;
+
+const HelperText = styled.span<{ disabled: SelectPropsType["disabled"] }>`
+ color: var(--color-fg-neutral-stronger);
+ font-size: var(--typography-helper-text-s);
+ font-weight: var(--typography-helper-text-regular);
+ margin-bottom: var(--spacing-gap-xs);
+`;
+
+const Select = styled.div<{
+ disabled: SelectPropsType["disabled"];
+ error: SelectPropsType["error"];
+}>`
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-gap-s);
+ height: var(--height-m);
+ padding: var(--spacing-padding-none) var(--spacing-padding-xs);
+ border-radius: var(--border-radius-s);
+ border: var(--border-width-s) var(--border-style-default) var(--border-color-neutral-dark);
+ ${(props) =>
+ props.error &&
+ !props.disabled &&
+ "border: var(--border-width-m) var(--border-style-default) var(--border-color-error-medium);"}
+
+ ${(props) =>
+ !props.disabled
+ ? `
+ cursor: pointer;
+ &:hover {
+ border-color: var(${props.error ? "--border-color-error-strong;" : "--border-color-primary-strong"});
+ }
+ &:focus-within {
+ outline-offset: -2px;
+ outline: var(--border-width-m) var(--border-style-default) var(--border-color-secondary-medium);
+ }
+ `
+ : "background: var(--color-bg-neutral-lighter); border-color: var(--border-color-neutral-medium); cursor: not-allowed;"};
+
+ /* Collapse indicator */
+ > span[role="img"] {
+ color: var(${({ disabled }) => (disabled ? "--color-fg-neutral-medium" : "--color-fg-neutral-dark")});
+ font-size: var(--height-xxs);
+ }
+`;
+
+const SelectionIndicator = styled.div<{ disabled: SelectPropsType["disabled"] }>`
+ box-sizing: border-box;
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ min-width: 48px;
+ min-height: var(--height-s);
+ border-radius: var(--border-radius-xs);
+ border: var(--border-width-s) var(--border-style-default)
+ var(${({ disabled }) => (disabled ? "--border-color-neutral-strong" : "--border-color-neutral-light")});
+`;
+
+const SelectionNumber = styled.span<{ disabled: SelectPropsType["disabled"] }>`
+ display: grid;
+ place-items: center;
+ background-color: ${({ disabled }) => (disabled ? "transparent" : "var(--color-bg-neutral-lighter)")};
+ border-right: var(--border-width-s) var(--border-style-default)
+ var(${({ disabled }) => (disabled ? "--border-color-neutral-medium" : "--border-color-neutral-light")});
+ color: var(${(props) => (props.disabled ? "--color-fg-neutral-medium" : "--color-fg-neutral-dark")});
+ font-size: var(--typography-label-s);
+ font-weight: var(--typography-label-regular);
+ text-align: center;
+ user-select: none;
+ ${(props) => (props.disabled ? `cursor: not-allowed;` : `cursor: default;`)}
+`;
+
+const ClearOptionsAction = styled.button`
+ display: grid;
+ place-items: center;
+ background-color: transparent;
+ border: none;
+ padding: var(--spacing-padding-none);
+ width: 100%;
+ font-size: var(--height-xxxs);
+
+ &:focus-visible {
+ outline: none;
+ }
+ ${(props) =>
+ !props.disabled
+ ? `
+ color: var(--color-fg-neutral-dark);
+ cursor: pointer;
+ &:hover {
+ background-color: var(--color-bg-neutral-light);
+ }
+ &:active {
+ background-color: var(--color-bg-neutral-strong);
+ }
+ `
+ : "color: var(--color-fg-neutral-medium); cursor: not-allowed;"}
+`;
+
+const SearchableValueContainer = styled.div`
+ display: grid;
+ width: 100%;
+`;
+
+const SelectedOption = styled.span<{
+ disabled: SelectPropsType["disabled"];
+ atBackground: boolean;
+}>`
+ grid-area: 1 / 1 / 1 / 1;
+ color: var(
+ ${(props) =>
+ props.disabled
+ ? "--color-fg-neutral-medium"
+ : props.atBackground
+ ? "--color-fg-neutral-strong"
+ : "--color-fg-neutral-dark"}
+ );
+ font-size: var(--typography-label-m);
+ font-weight: var(--typography-label-regular);
+ user-select: none;
+ white-space: pre;
+ overflow: hidden;
+ text-overflow: ellipsis;
+`;
+
+const SearchInput = styled.input`
+ grid-area: 1 / 1 / 1 / 1;
+ background: none;
+ border: none;
+ outline: none;
+ padding: var(--spacing-padding-none);
+ color: var(--color-fg-neutral-dark);
+ font-family: var(--typography-font-family);
+ font-size: var(--typography-label-m);
+ font-weight: var(--typography-label-regular);
+`;
+
+const Error = styled.span`
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-gap-xs);
+ color: var(--color-fg-error-medium);
+ font-size: var(--typography-helper-text-s);
+ font-weight: var(--typography-helper-text-regular, 400);
+ margin-top: var(--spacing-gap-xs);
+
+ /* Error icon */
+ > span[role="img"] {
+ font-size: var(--height-xxs);
+ }
+`;
const DxcSelect = forwardRef(
(
@@ -56,10 +244,8 @@ const DxcSelect = forwardRef(
},
ref
): JSX.Element => {
- const selectId = `select-${useId()}`;
- const selectLabelId = `label-${selectId}`;
- const errorId = `error-${selectId}`;
- const listboxId = `${selectId}-listbox`;
+ const id = `select-${useId()}`;
+
const [innerValue, setInnerValue] = useState(defaultValue ?? (multiple ? [] : ""));
const [searchValue, setSearchValue] = useState("");
const [visualFocusIndex, changeVisualFocusIndex] = useState(-1);
@@ -69,10 +255,9 @@ const DxcSelect = forwardRef(
const selectSearchInputRef = useRef(null);
const width = useWidth(selectRef.current);
- const colorsTheme = useContext(HalstackContext);
const translatedLabels = useContext(HalstackLanguageContext);
- const optionalItem = { label: placeholder, value: "" };
+ const optionalItem = useMemo(() => ({ label: placeholder, value: "" }), [placeholder]);
const filteredOptions = useMemo(() => filterOptionsBySearchValue(options, searchValue), [options, searchValue]);
const lastOptionIndex = useMemo(
() => getLastOptionIndex(options, filteredOptions, searchable, optional, multiple),
@@ -84,7 +269,7 @@ const DxcSelect = forwardRef(
);
const openListbox = () => {
- if (!isOpen && canOpenOptions(options, disabled)) {
+ if (!isOpen && canOpenListbox(options, disabled)) {
changeIsOpen(true);
}
};
@@ -95,7 +280,7 @@ const DxcSelect = forwardRef(
}
};
- const handleSelectChangeValue = useCallback(
+ const handleOnChangeValue = useCallback(
(newOption: ListOptionType | undefined) => {
if (newOption) {
let newValue: string | string[];
@@ -120,8 +305,7 @@ const DxcSelect = forwardRef(
},
[multiple, value, innerValue, onChange, optional, translatedLabels]
);
-
- const handleSelectOnClick = () => {
+ const handleOnClick = () => {
if (searchable) {
selectSearchInputRef?.current?.focus();
}
@@ -132,12 +316,12 @@ const DxcSelect = forwardRef(
openListbox();
}
};
- const handleSelectOnFocus = (event: FocusEvent) => {
+ const handleOnFocus = (event: FocusEvent) => {
if (!event.currentTarget.contains(event.relatedTarget) && searchable) {
selectSearchInputRef?.current?.focus();
}
};
- const handleSelectOnBlur = (event: FocusEvent) => {
+ const handleOnBlur = (event: FocusEvent) => {
if (!event.currentTarget.contains(event.relatedTarget)) {
closeListbox();
setSearchValue("");
@@ -153,7 +337,7 @@ const DxcSelect = forwardRef(
}
}
};
- const handleSelectOnKeyDown = (event: KeyboardEvent) => {
+ const handleOnKeyDown = (event: KeyboardEvent) => {
switch (event.key) {
case "Down":
case "ArrowDown":
@@ -207,35 +391,35 @@ const DxcSelect = forwardRef(
if (searchable) {
if (filteredOptions.length > 0) {
if (optional && !multiple && visualFocusIndex === 0 && groupsHaveOptions(filteredOptions)) {
- handleSelectChangeValue(optionalItem);
+ handleOnChangeValue(optionalItem);
} else if (isArrayOfOptionGroups(filteredOptions)) {
if (groupsHaveOptions(filteredOptions)) {
filteredOptions.some((groupOption) => {
const groupLength = accLength + groupOption.options.length;
if (groupLength > visualFocusIndex) {
- handleSelectChangeValue(groupOption.options[visualFocusIndex - accLength]);
+ handleOnChangeValue(groupOption.options[visualFocusIndex - accLength]);
}
accLength = groupLength;
return groupLength > visualFocusIndex;
});
}
} else {
- handleSelectChangeValue(filteredOptions[visualFocusIndex - accLength]);
+ handleOnChangeValue(filteredOptions[visualFocusIndex - accLength]);
}
}
} else if (optional && !multiple && visualFocusIndex === 0) {
- handleSelectChangeValue(optionalItem);
+ handleOnChangeValue(optionalItem);
} else if (isArrayOfOptionGroups(options)) {
options.some((groupOption) => {
const groupLength = accLength + groupOption.options.length;
if (groupLength > visualFocusIndex) {
- handleSelectChangeValue(groupOption.options[visualFocusIndex - accLength]);
+ handleOnChangeValue(groupOption.options[visualFocusIndex - accLength]);
}
accLength = groupLength;
return groupLength > visualFocusIndex;
});
} else {
- handleSelectChangeValue(options[visualFocusIndex - accLength]);
+ handleOnChangeValue(options[visualFocusIndex - accLength]);
}
if (!multiple) {
closeListbox();
@@ -247,6 +431,10 @@ const DxcSelect = forwardRef(
break;
}
};
+ const handleOnMouseEnter = (event: MouseEvent) => {
+ const text = event.currentTarget;
+ setHasTooltip(text.scrollWidth > text.clientWidth);
+ };
const handleSearchIOnChange = (event: ChangeEvent) => {
setSearchValue(event.target.value);
@@ -269,442 +457,170 @@ const DxcSelect = forwardRef(
}
};
- const handleClearSearchActionOnClick = (event: MouseEvent) => {
- event.stopPropagation();
+ const handleClearSearchActionOnClick = () => {
setSearchValue("");
};
const handleOptionOnClick = useCallback(
(option: ListOptionType) => {
- handleSelectChangeValue(option);
+ handleOnChangeValue(option);
if (!multiple) {
closeListbox();
}
setSearchValue("");
},
- [handleSelectChangeValue, closeListbox, multiple]
+ [handleOnChangeValue, closeListbox, multiple]
);
- const handleOnMouseEnter = (event: MouseEvent) => {
- const text = event.currentTarget;
- setHasTooltip(text.scrollWidth > text.clientWidth);
- };
-
return (
-
-
- {label && (
-
- )}
- {helperText && {helperText}}
-
-
-
-
-
- {
- // Avoid select to lose focus when the list is opened
- event.preventDefault();
- }}
- onCloseAutoFocus={(event) => {
- // Avoid select to lose focus when the list is closed
- event.preventDefault();
- }}
- >
-
-
-
-
- {!disabled && typeof error === "string" && (
-
- {error}
-
- )}
-
-
+
+
+
+
+
+ {
+ // Avoid select to lose focus when the list is opened
+ event.preventDefault();
+ }}
+ onCloseAutoFocus={(event) => {
+ // Avoid select to lose focus when the list is closed
+ event.preventDefault();
+ }}
+ >
+
+
+
+
+ {!disabled && typeof error === "string" && (
+
+ {error && }
+ {error}
+
+ )}
+
);
}
);
-const sizes = {
- small: "240px",
- medium: "360px",
- large: "480px",
- fillParent: "100%",
-};
-
-const calculateWidth = (margin: SelectPropsType["margin"], size: SelectPropsType["size"]) =>
- size === "fillParent"
- ? `calc(${sizes[size]} - ${getMargin(margin, "left")} - ${getMargin(margin, "right")})`
- : size && sizes[size];
-
-const SelectContainer = styled.div<{
- margin: SelectPropsType["margin"];
- size: SelectPropsType["size"];
-}>`
- box-sizing: border-box;
- display: flex;
- flex-direction: column;
- width: ${(props) => calculateWidth(props.margin, props.size)};
- ${(props) => props.size !== "fillParent" && `min-width:${calculateWidth(props.margin, props.size)}`};
- margin: ${(props) => (props.margin && typeof props.margin !== "object" ? spaces[props.margin] : "0px")};
- margin-top: ${(props) =>
- props.margin && typeof props.margin === "object" && props.margin.top ? spaces[props.margin.top] : ""};
- margin-right: ${(props) =>
- props.margin && typeof props.margin === "object" && props.margin.right ? spaces[props.margin.right] : ""};
- margin-bottom: ${(props) =>
- props.margin && typeof props.margin === "object" && props.margin.bottom ? spaces[props.margin.bottom] : ""};
- margin-left: ${(props) =>
- props.margin && typeof props.margin === "object" && props.margin.left ? spaces[props.margin.left] : ""};
- font-family: ${(props) => props.theme.fontFamily};
-`;
-
-const Label = styled.label<{
- disabled: SelectPropsType["disabled"];
- helperText: SelectPropsType["helperText"];
-}>`
- color: ${(props) => (props.disabled ? props.theme.disabledColor : props.theme.labelFontColor)};
- font-size: ${(props) => props.theme.labelFontSize};
- font-style: ${(props) => props.theme.labelFontStyle};
- font-weight: ${(props) => props.theme.labelFontWeight};
- line-height: ${(props) => props.theme.labelLineHeight};
- cursor: default;
- ${(props) => !props.helperText && `margin-bottom: 0.25rem`}
-`;
-
-const OptionalLabel = styled.span`
- font-weight: ${(props) => props.theme.optionalLabelFontWeight};
-`;
-
-const HelperText = styled.span<{ disabled: SelectPropsType["disabled"] }>`
- color: ${(props) => (props.disabled ? props.theme.disabledColor : props.theme.helperTextFontColor)};
- font-size: ${(props) => props.theme.helperTextFontSize};
- font-style: ${(props) => props.theme.helperTextFontStyle};
- font-weight: ${(props) => props.theme.helperTextFontWeight};
- line-height: ${(props) => props.theme.helperTextLineHeight};
- margin-bottom: 0.25rem;
-`;
-
-const Select = styled.div<{
- disabled: SelectPropsType["disabled"];
- error: SelectPropsType["error"];
-}>`
- display: flex;
- position: relative;
- align-items: center;
- height: calc(2.5rem - 2px);
- padding: 0 0.5rem;
- outline: none;
- ${(props) => props.disabled && `background-color: ${props.theme.disabledInputBackgroundColor}`};
- box-shadow: 0 0 0 2px transparent;
- border-radius: 4px;
- border: 1px solid
- ${(props) => (props.disabled ? props.theme.disabledInputBorderColor : props.theme.enabledInputBorderColor)};
- ${(props) =>
- props.error &&
- !props.disabled &&
- `border-color: transparent;
- box-shadow: 0 0 0 2px ${props.theme.errorInputBorderColor};
- `}
- ${(props) => (props.disabled ? "cursor: not-allowed;" : "cursor: pointer;")};
-
- ${(props) =>
- !props.disabled &&
- `
- &:hover {
- border-color: ${props.error ? "transparent" : props.theme.hoverInputBorderColor};
- ${props.error && `box-shadow: 0 0 0 2px ${props.theme.hoverInputErrorBorderColor};`}
- }
- &:focus-within {
- border-color: transparent;
- box-shadow: 0 0 0 2px ${props.theme.focusInputBorderColor};
- }
- `};
-`;
-
-const SelectionIndicator = styled.div`
- box-sizing: border-box;
- display: grid;
- grid-template-columns: 1fr 1fr;
- min-width: 48px;
- min-height: 24px;
- border-radius: 2px;
- border: 1px solid ${(props) => props.theme.selectionIndicatorBorderColor};
-`;
-
-const SelectionNumber = styled.span<{ disabled: SelectPropsType["disabled"] }>`
- display: grid;
- place-items: center;
- border-right: 1px solid ${(props) => props.theme.selectionIndicatorBorderColor};
- user-select: none;
- ${(props) => !props.disabled && `background-color: ${props.theme.selectionIndicatorBackgroundColor}`};
- color: ${(props) => (props.disabled ? props.theme.disabledColor : props.theme.selectionIndicatorFontColor)};
- font-size: ${(props) => props.theme.selectionIndicatorFontSize};
- font-style: ${(props) => props.theme.selectionIndicatorFontStyle};
- font-weight: ${(props) => props.theme.selectionIndicatorFontWeight};
- ${(props) => (props.disabled ? `cursor: not-allowed;` : `cursor: default;`)}
-`;
-
-const ClearOptionsAction = styled.button`
- display: grid;
- place-items: center;
- border: none;
- padding: 0;
- ${(props) => (props.disabled ? `cursor: not-allowed;` : `cursor: pointer;`)}
- background-color: ${(props) =>
- props.disabled ? "transparent" : props.theme.enabledSelectionIndicatorActionBackgroundColor};
- color: ${(props) =>
- props.disabled ? props.theme.disabledColor : props.theme.enabledSelectionIndicatorActionIconColor};
- font-size: 16px;
- width: 100%;
-
- :focus-visible {
- outline: none;
- }
- ${(props) =>
- !props.disabled &&
- `
- &:hover {
- background-color: ${props.theme.hoverSelectionIndicatorActionBackgroundColor};
- color: ${props.theme.hoverSelectionIndicatorActionIconColor};
- }
- &:active {
- background-color: ${props.theme.activeSelectionIndicatorActionBackgroundColor};
- color: ${props.theme.activeSelectionIndicatorActionIconColor};
- }
- `}
-`;
-
-const SearchableValueContainer = styled.div`
- display: grid;
- width: 100%;
-`;
-
-const SelectedOption = styled.span<{
- disabled: SelectPropsType["disabled"];
- atBackground: boolean;
-}>`
- grid-area: 1 / 1 / 1 / 1;
- display: inline-flex;
- align-items: center;
- height: calc(2.5rem - 2px);
- padding: 0 0.5rem;
- user-select: none;
- overflow: hidden;
-
- color: ${(props) =>
- props.disabled
- ? props.theme.disabledColor
- : props.atBackground
- ? props.theme.placeholderFontColor
- : props.theme.valueFontColor};
-
- font-family: ${(props) => props.theme.fontFamily};
- font-size: ${(props) => props.theme.valueFontSize};
- font-style: ${(props) => props.theme.valueFontStyle};
- font-weight: ${(props) => props.theme.valueFontWeight};
-`;
-
-const SelectedOptionLabel = styled.span`
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-`;
-
-const SearchInput = styled.input`
- grid-area: 1 / 1 / 1 / 1;
- height: calc(2.5rem - 2px);
- background: none;
- border: none;
- outline: none;
- padding: 0 0.5rem;
- color: ${(props) => (props.disabled ? props.theme.disabledColor : props.theme.valueFontColor)};
- font-size: ${(props) => props.theme.valueFontSize};
- font-style: ${(props) => props.theme.valueFontStyle};
- font-weight: ${(props) => props.theme.valueFontWeight};
- line-height: 1.5em;
-`;
-
-const ErrorIcon = styled.span`
- display: flex;
- flex-wrap: wrap;
- align-content: center;
- padding: 3px;
- height: 18px;
- width: 18px;
- margin-left: 0.25rem;
- color: ${(props) => props.theme.errorIconColor};
- font-size: 1.25rem;
-`;
-
-const Error = styled.span`
- min-height: 1.5em;
- color: ${(props) => props.theme.errorMessageColor};
- font-size: 0.75rem;
- line-height: 1.5em;
- margin-top: 0.25rem;
-`;
-
-const CollapseIndicator = styled.span<{ disabled: SelectPropsType["disabled"] }>`
- display: grid;
- place-items: center;
- padding: 4px;
- font-size: 16px;
- margin-left: 0.25rem;
- color: ${(props) => (props.disabled ? props.theme.disabledColor : props.theme.collapseIndicatorColor)};
-`;
-
-const ClearSearchAction = styled.button`
- display: grid;
- place-items: center;
- min-height: 24px;
- min-width: 24px;
- margin-left: 0.25rem;
- border: none;
- border-radius: 2px;
- padding: 0;
- background-color: ${(props) => props.theme.actionBackgroundColor};
- color: ${(props) => props.theme.actionIconColor};
- font-size: 1rem;
- cursor: pointer;
-
- &:hover {
- background-color: ${(props) => props.theme.hoverActionBackgroundColor};
- color: ${(props) => props.theme.hoverActionIconColor};
- }
- &:active {
- background-color: ${(props) => props.theme.activeActionBackgroundColor};
- color: ${(props) => props.theme.activeActionIconColor};
- }
-`;
-
export default DxcSelect;
diff --git a/packages/lib/src/select/utils.ts b/packages/lib/src/select/utils.ts
index 0b341504f7..45e6c1a42c 100644
--- a/packages/lib/src/select/utils.ts
+++ b/packages/lib/src/select/utils.ts
@@ -1,9 +1,22 @@
-import { ListOptionType, ListOptionGroupType } from "./types";
+import SelectPropsType, { ListOptionType, ListOptionGroupType } from "./types";
+import { getMargin } from "../common/utils";
+
+const sizes = {
+ small: "240px",
+ medium: "360px",
+ large: "480px",
+ fillParent: "100%",
+};
+
+export const calculateWidth = (margin: SelectPropsType["margin"], size: SelectPropsType["size"]) =>
+ size === "fillParent"
+ ? `calc(${sizes[size]} - ${getMargin(margin, "left")} - ${getMargin(margin, "right")})`
+ : size && sizes[size];
/**
* Check if the value is not optional and is empty.
*/
-const notOptionalCheck = (value: string | string[], multiple: boolean, optional: boolean) =>
+export const notOptionalCheck = (value: string | string[], multiple: boolean, optional: boolean) =>
!optional && (multiple ? value.length === 0 : value === "");
/**
@@ -15,25 +28,25 @@ const isOptionGroup = (option: ListOptionType | ListOptionGroupType): option is
/**
* Checks if the options are an array of groups.
*/
-const isArrayOfOptionGroups = (options: ListOptionType[] | ListOptionGroupType[]): options is ListOptionGroupType[] =>
+export const isArrayOfOptionGroups = (options: ListOptionType[] | ListOptionGroupType[]): options is ListOptionGroupType[] =>
options[0] != null && isOptionGroup(options[0]);
/**
* Checks if the groups have options.
*/
-const groupsHaveOptions = (options: ListOptionType[] | ListOptionGroupType[]) =>
+export const groupsHaveOptions = (options: ListOptionType[] | ListOptionGroupType[]) =>
isArrayOfOptionGroups(options) ? options.some((groupOption) => groupOption.options.length > 0) : true;
/**
* Checks if the listbox can be opened.
*/
-const canOpenListbox = (options: ListOptionType[] | ListOptionGroupType[], disabled: boolean) =>
+export const canOpenListbox = (options: ListOptionType[] | ListOptionGroupType[], disabled: boolean) =>
!disabled && options.length > 0 && groupsHaveOptions(options);
/**
* Filters the options by the search value.
*/
-const filterOptionsBySearchValue = (
+export const filterOptionsBySearchValue = (
options: ListOptionType[] | ListOptionGroupType[],
searchValue: string
): ListOptionType[] | ListOptionGroupType[] => {
@@ -57,7 +70,7 @@ const filterOptionsBySearchValue = (
/**
* Returns the index of the last option, depending on several conditions.
*/
-const getLastOptionIndex = (
+export const getLastOptionIndex = (
options: ListOptionType[] | ListOptionGroupType[],
filteredOptions: ListOptionType[] | ListOptionGroupType[],
searchable: boolean,
@@ -87,7 +100,7 @@ const getLastOptionIndex = (
/**
* Return the current selection.
*/
-const getSelectedOption = (
+export const getSelectedOption = (
value: string | string[],
options: ListOptionType[] | ListOptionGroupType[],
multiple: boolean,
@@ -145,21 +158,9 @@ const getSelectedOption = (
/**
* Return the label or labels of the selected option(s), separated by commas.
*/
-const getSelectedOptionLabel = (placeholder: string, selectedOption: ListOptionType | ListOptionType[]) =>
+export const getSelectedOptionLabel = (placeholder: string, selectedOption: ListOptionType | ListOptionType[]) =>
Array.isArray(selectedOption)
? selectedOption.length === 0
? placeholder
: selectedOption.map((option) => option.label).join(", ")
: (selectedOption.label ?? placeholder);
-
-export {
- isOptionGroup,
- isArrayOfOptionGroups,
- notOptionalCheck,
- groupsHaveOptions,
- canOpenListbox as canOpenOptions,
- filterOptionsBySearchValue,
- getLastOptionIndex,
- getSelectedOption,
- getSelectedOptionLabel,
-};
From 05a72eddfc4f7c8ae60e6751b7747251575c0b7a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Iv=C3=A1n=20G=C3=B3mez=20Pinta?=
<44321109+GomezIvann@users.noreply.github.com>
Date: Thu, 6 Feb 2025 17:22:10 +0100
Subject: [PATCH 02/15] More styling updates to the Select
---
.../components/select/code/SelectCodePage.tsx | 2 +
packages/lib/src/select/ListOption.tsx | 186 ++++++++----------
packages/lib/src/select/Listbox.tsx | 102 +++++-----
packages/lib/src/select/Select.tsx | 8 +-
packages/lib/src/select/utils.ts | 51 ++---
5 files changed, 160 insertions(+), 189 deletions(-)
diff --git a/apps/website/screens/components/select/code/SelectCodePage.tsx b/apps/website/screens/components/select/code/SelectCodePage.tsx
index cdeba01db9..9055180619 100644
--- a/apps/website/screens/components/select/code/SelectCodePage.tsx
+++ b/apps/website/screens/components/select/code/SelectCodePage.tsx
@@ -123,6 +123,8 @@ const sections = [
options: List of Option instances.
+
+ You can't mix regular options and grouped options in the same array.
- |
diff --git a/packages/lib/src/select/ListOption.tsx b/packages/lib/src/select/ListOption.tsx
index e61c5f6cbb..b8d7a46d29 100644
--- a/packages/lib/src/select/ListOption.tsx
+++ b/packages/lib/src/select/ListOption.tsx
@@ -2,132 +2,72 @@ import styled from "styled-components";
import { OptionProps } from "./types";
import DxcCheckbox from "../checkbox/Checkbox";
import DxcIcon from "../icon/Icon";
-import { MouseEvent, useState } from "react";
+import { MouseEvent, useEffect, useRef, useState } from "react";
import { TooltipWrapper } from "../tooltip/Tooltip";
-const ListOption = ({
- id,
- option,
- onClick,
- multiple,
- visualFocused,
- isGroupedOption = false,
- isLastOption,
- isSelected,
-}: OptionProps): JSX.Element => {
- const [hasTooltip, setHasTooltip] = useState(false);
-
- const handleOnMouseEnter = (event: MouseEvent) => {
- const text = event.currentTarget;
- setHasTooltip(text.scrollWidth > text.clientWidth);
- };
-
- return (
-
- {
- onClick(option);
- }}
- visualFocused={visualFocused}
- selected={isSelected}
- role="option"
- aria-selected={!multiple ? isSelected : undefined}
- >
-
- {multiple && (
-
-
-
- )}
- {option.icon && (
-
- {typeof option.icon === "string" ? : option.icon}
-
- )}
-
- {option.label}
- {!multiple && isSelected && (
-
-
-
- )}
-
-
-
-
- );
-};
-
-const OptionItem = styled.li<{ visualFocused: OptionProps["visualFocused"]; selected: OptionProps["isSelected"] }>`
- padding: 0 0.5rem;
- box-shadow: inset 0 0 0 2px transparent;
- ${(props) => props.visualFocused && `box-shadow: inset 0 0 0 2px ${props.theme.focusListOptionBorderColor};`}
- ${(props) => props.selected && `background-color: ${props.theme.selectedListOptionBackgroundColor}`};
+const OptionItem = styled.li<{
+ visualFocused: OptionProps["visualFocused"];
+ selected: OptionProps["isSelected"];
+}>`
+ ${({ selected }) => selected && "background-color: var(--color-bg-secondary-lighter);"};
+ padding: var(--spacing-padding-none) var(--spacing-padding-xs);
cursor: pointer;
&:hover {
- ${(props) =>
- props.selected
- ? `background-color: ${props.theme.selectedHoverListOptionBackgroundColor};`
- : `background-color: ${props.theme.unselectedHoverListOptionBackgroundColor};`};
+ background-color: var(
+ ${({ selected }) => (selected ? "--color-bg-secondary-lighter" : "--color-bg-neutral-light")}
+ );
}
&:active {
- ${(props) =>
- props.selected
- ? `background-color: ${props.theme.selectedActiveListOptionBackgroundColor};`
- : `background-color: ${props.theme.unselectedActiveListOptionBackgroundColor};`};
+ background-color: var(${({ selected }) => (selected ? "--color-bg-secondary-medium" : "--color-bg-neutral-light")});
}
+ ${({ visualFocused }) =>
+ visualFocused &&
+ "outline: var(--border-width-m) var(--border-style-default) var(--border-color-secondary-medium); outline-offset: -2px;"}
`;
const StyledOption = styled.span<{
grouped: OptionProps["isGroupedOption"];
- multiple: OptionProps["multiple"];
- visualFocused: OptionProps["visualFocused"];
- selected: OptionProps["isSelected"];
last: OptionProps["isLastOption"];
+ selected: OptionProps["isSelected"];
+ visualFocused: OptionProps["visualFocused"];
}>`
box-sizing: border-box;
display: flex;
align-items: center;
- height: 32px;
- padding: 4px 8px 4px 0;
- ${(props) => props.grouped && props.multiple && `padding-left: 16px;`}
+ gap: var(--spacing-gap-s);
+ height: var(--height-m);
+ ${({ grouped }) => grouped && "padding-left: var(--spacing-padding-xs);"}
${(props) =>
- props.last || props.visualFocused || props.selected
- ? `border-bottom: 1px solid transparent`
- : `border-bottom: 1px solid ${props.theme.listOptionDividerColor}`};
+ `border-bottom: var(--border-width-s) var(--border-style-default)
+ ${props.last || props.visualFocused || props.selected ? "transparent" : "var(--border-color-neutral-lighter)"};`};
`;
-const OptionIcon = styled.span<{ grouped: OptionProps["isGroupedOption"]; multiple: OptionProps["multiple"] }>`
- margin-left: ${(props) => (props.grouped && !props.multiple ? "16px" : "8px")};
+const OptionIcon = styled.span`
display: grid;
place-items: center;
- color: ${(props) => props.theme.listOptionIconColor};
- font-size: 24px;
+ color: var(--color-fg-neutral-dark);
+ font-size: var(--height-xxs);
+
svg {
- height: 24px;
- width: 24px;
+ height: var(--height-xxs);
+ width: 16px;
}
`;
-const OptionContent = styled.span<{
- grouped: OptionProps["isGroupedOption"];
- multiple: OptionProps["multiple"];
- hasIcon: boolean;
-}>`
- margin-left: ${(props) => (props.grouped && !props.multiple && !props.hasIcon ? "16px" : "8px")};
+const OptionContent = styled.span`
display: flex;
+ align-items: center;
+ gap: var(--spacing-gap-s);
justify-content: space-between;
- gap: 0.25rem;
width: 100%;
overflow: hidden;
+
+ /* Option selected icon */
+ > span[role="img"] {
+ color: var(--color-fg-neutral-dark);
+ font-size: var(--height-xxs);
+ }
`;
const OptionLabel = styled.span`
@@ -136,11 +76,53 @@ const OptionLabel = styled.span`
white-space: nowrap;
`;
-const OptionSelectedIndicator = styled.span`
- display: flex;
- align-items: center;
- color: ${(props) => props.theme.selectedListOptionIconColor};
- font-size: 16px;
-`;
+const ListOption = ({
+ id,
+ isGroupedOption = false,
+ isLastOption,
+ isSelected,
+ multiple,
+ onClick,
+ option,
+ visualFocused,
+}: OptionProps): JSX.Element => {
+ const [hasTooltip, setHasTooltip] = useState(false);
+ const checkboxRef = useRef(null);
+
+ const handleOnMouseEnter = (event: MouseEvent) => {
+ const text = event.currentTarget;
+ setHasTooltip(text.scrollWidth > text.clientWidth);
+ };
+
+ useEffect(() => {
+ if (checkboxRef.current) checkboxRef.current.style.pointerEvents = "none";
+ }, []);
+
+ return (
+
+ {
+ onClick(option);
+ }}
+ role="option"
+ selected={isSelected}
+ visualFocused={visualFocused}
+ >
+
+ {multiple && }
+ {option.icon && (
+ {typeof option.icon === "string" ? : option.icon}
+ )}
+
+ {option.label}
+ {!multiple && isSelected && }
+
+
+
+
+ );
+};
export default ListOption;
diff --git a/packages/lib/src/select/Listbox.tsx b/packages/lib/src/select/Listbox.tsx
index 2152df94d4..ba7622ddb9 100644
--- a/packages/lib/src/select/Listbox.tsx
+++ b/packages/lib/src/select/Listbox.tsx
@@ -6,6 +6,46 @@ import ListOption from "./ListOption";
import { groupsHaveOptions } from "./utils";
import { ListboxProps, ListOptionGroupType, ListOptionType } from "./types";
+const ListboxContainer = styled.ul`
+ box-sizing: border-box;
+ max-height: 304px;
+ overflow-y: auto;
+ margin: 0;
+ padding: var(--spacing-padding-xxs) var(--spacing-padding-none);
+ background-color: var(--color-absolutes-white);
+ border: var(--border-width-s) var(--border-style-default) var(--border-color-neutral-medium);
+ border-radius: var(--border-radius-s);
+
+ box-shadow: var(--shadow-mid-x-position, 0px) var(--shadow-mid-y-position, 12px) var(--shadow-mid-blur, 12px)
+ var(--shadow-mid-spread, 0px) var(--shadow-light, rgba(209, 209, 209, 0.3));
+ color: var(--color-fg-neutral-dark);
+ font-family: var(--typography-font-family);
+ font-size: var(--typography-label-m);
+ font-weight: var(--typography-label-regular);
+`;
+
+const OptionsSystemMessage = styled.span`
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-gap-s);
+ height: var(--height-m);
+ padding: var(--spacing-padding-none) var(--spacing-padding-m);
+ color: var(--color-fg-neutral-stronger);
+
+ /* No matches found icon */
+ > span[role="img"] {
+ font-size: var(--height-xxs);
+ }
+`;
+
+const GroupLabel = styled.li`
+ display: flex;
+ align-items: center;
+ height: var(--height-m);
+ padding: var(--spacing-padding-none) var(--spacing-padding-m);
+ font-weight: var(--typography-label-semibold);
+`;
+
const Listbox = ({
id,
currentValue,
@@ -67,11 +107,7 @@ const Listbox = ({
multiple={multiple}
visualFocused={visualFocusIndex === globalIndex}
isLastOption={lastOptionIndex === globalIndex}
- isSelected={
- multiple
- ? currentValue.includes(option.value)
- : currentValue === option.value
- }
+ isSelected={multiple ? currentValue.includes(option.value) : currentValue === option.value}
/>
);
}
@@ -95,8 +131,6 @@ const Listbox = ({
});
}, [visualFocusIndex]);
- const hasOptionGroups = options.some((option) => "options" in option && option.options.length > 0);
-
return (
{searchable && (options.length === 0 || !groupsHaveOptions(options)) ? (
-
-
-
+
{translatedLabels.select.noMatchesErrorMessage}
) : (
@@ -129,7 +160,6 @@ const Listbox = ({
onClick={handleOptionOnClick}
multiple={multiple}
visualFocused={visualFocusIndex === 0}
- isGroupedOption={false}
isLastOption={lastOptionIndex === 0}
isSelected={multiple ? currentValue.includes(optionalItem.value) : currentValue === optionalItem.value}
/>
@@ -140,48 +170,4 @@ const Listbox = ({
);
};
-const ListboxContainer = styled.ul`
- box-sizing: border-box;
- max-height: 304px;
- overflow-y: auto;
- margin: 0;
- padding: 0.25rem 0;
- background-color: ${(props) => props.theme.listDialogBackgroundColor};
- border: 1px solid ${(props) => props.theme.listDialogBorderColor};
- border-radius: 0.25rem;
- box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
- color: ${(props) => props.theme.listOptionFontColor};
- font-family: ${(props) => props.theme.fontFamily};
- font-size: ${(props) => props.theme.listOptionFontSize};
- font-style: ${(props) => props.theme.listOptionFontStyle};
- font-weight: ${(props) => props.theme.listOptionFontWeight};
- line-height: 24px;
- cursor: default;
-`;
-
-const OptionsSystemMessage = styled.span`
- display: flex;
- padding: 4px 16px;
- color: ${(props) => props.theme.systemMessageFontColor};
- font-size: 0.875rem;
- line-height: 1.715em;
-`;
-
-const NoMatchesFoundIcon = styled.span`
- display: flex;
- flex-wrap: wrap;
- align-content: center;
- height: 16px;
- width: 16px;
- padding: 4px;
- margin-right: 0.25rem;
- font-size: 16px;
-`;
-
-const GroupLabel = styled.li`
- padding: 4px 16px;
- font-weight: ${(props) => props.theme.listGroupLabelFontWeight};
- line-height: 1.715em;
-`;
-
export default Listbox;
diff --git a/packages/lib/src/select/Select.tsx b/packages/lib/src/select/Select.tsx
index 983ce3fa9b..d1df0a968b 100644
--- a/packages/lib/src/select/Select.tsx
+++ b/packages/lib/src/select/Select.tsx
@@ -27,7 +27,7 @@ import {
getSelectedOption,
getSelectedOptionLabel,
groupsHaveOptions,
- isArrayOfOptionGroups,
+ isArrayOfGroupedOptions,
notOptionalCheck,
} from "./utils";
import SelectPropsType, { ListOptionType, RefType } from "./types";
@@ -188,9 +188,9 @@ const SelectedOption = styled.span<{
font-size: var(--typography-label-m);
font-weight: var(--typography-label-regular);
user-select: none;
- white-space: pre;
overflow: hidden;
text-overflow: ellipsis;
+ white-space: pre;
`;
const SearchInput = styled.input`
@@ -392,7 +392,7 @@ const DxcSelect = forwardRef(
if (filteredOptions.length > 0) {
if (optional && !multiple && visualFocusIndex === 0 && groupsHaveOptions(filteredOptions)) {
handleOnChangeValue(optionalItem);
- } else if (isArrayOfOptionGroups(filteredOptions)) {
+ } else if (isArrayOfGroupedOptions(filteredOptions)) {
if (groupsHaveOptions(filteredOptions)) {
filteredOptions.some((groupOption) => {
const groupLength = accLength + groupOption.options.length;
@@ -409,7 +409,7 @@ const DxcSelect = forwardRef(
}
} else if (optional && !multiple && visualFocusIndex === 0) {
handleOnChangeValue(optionalItem);
- } else if (isArrayOfOptionGroups(options)) {
+ } else if (isArrayOfGroupedOptions(options)) {
options.some((groupOption) => {
const groupLength = accLength + groupOption.options.length;
if (groupLength > visualFocusIndex) {
diff --git a/packages/lib/src/select/utils.ts b/packages/lib/src/select/utils.ts
index 45e6c1a42c..6234230b58 100644
--- a/packages/lib/src/select/utils.ts
+++ b/packages/lib/src/select/utils.ts
@@ -20,25 +20,29 @@ export const notOptionalCheck = (value: string | string[], multiple: boolean, op
!optional && (multiple ? value.length === 0 : value === "");
/**
- * Checks if the option is a group.
+ * Checks if the option is a group (contains other options).
*/
const isOptionGroup = (option: ListOptionType | ListOptionGroupType): option is ListOptionGroupType =>
"options" in option && option.options != null;
/**
- * Checks if the options are an array of groups.
+ * Checks if the options are grouped options (groups and single options can't be mixed)
*/
-export const isArrayOfOptionGroups = (options: ListOptionType[] | ListOptionGroupType[]): options is ListOptionGroupType[] =>
+export const isArrayOfGroupedOptions = (options: ListOptionType[] | ListOptionGroupType[]): options is ListOptionGroupType[] =>
options[0] != null && isOptionGroup(options[0]);
/**
- * Checks if the groups have options.
+ * 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[]) =>
- isArrayOfOptionGroups(options) ? options.some((groupOption) => groupOption.options.length > 0) : true;
+ isArrayOfGroupedOptions(options) ? options.some((groupOption) => groupOption.options.length > 0) : true;
/**
- * Checks if the listbox can be opened.
+ * Checks if the listbox can be opened. A listbox can be opened in three scenarios:
+ * - The listbox is not disabled.
+ * - 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) =>
!disabled && options.length > 0 && groupsHaveOptions(options);
@@ -49,23 +53,20 @@ export const canOpenListbox = (options: ListOptionType[] | ListOptionGroupType[]
export const filterOptionsBySearchValue = (
options: ListOptionType[] | ListOptionGroupType[],
searchValue: string
-): ListOptionType[] | ListOptionGroupType[] => {
- if (options.length > 0) {
- if (isArrayOfOptionGroups(options))
- return options.map((optionGroup) => {
- const group = {
- label: optionGroup.label,
- options: optionGroup.options.filter((option) =>
- option.label.toUpperCase().includes(searchValue.toUpperCase())
- ),
- };
- return group;
- });
- else return options.filter((option) => option.label.toUpperCase().includes(searchValue.toUpperCase()));
- } else {
- return [];
- }
-};
+): ListOptionType[] | ListOptionGroupType[] =>
+ options.length > 0
+ ? isArrayOfGroupedOptions(options)
+ ? options.map((optionGroup) => {
+ const group = {
+ label: optionGroup.label,
+ options: optionGroup.options.filter((option) =>
+ option.label.toUpperCase().includes(searchValue.toUpperCase())
+ ),
+ };
+ return group;
+ })
+ : options.filter((option) => option.label.toUpperCase().includes(searchValue.toUpperCase()))
+ : [];
/**
* Returns the index of the last option, depending on several conditions.
@@ -81,13 +82,13 @@ export const getLastOptionIndex = (
const reducer = (acc: number, current: ListOptionGroupType) => acc + (current.options.length ?? 0);
if (searchable && filteredOptions.length > 0) {
- if (isArrayOfOptionGroups(filteredOptions)) {
+ if (isArrayOfGroupedOptions(filteredOptions)) {
last = filteredOptions.reduce(reducer, 0) - 1;
} else {
last = filteredOptions.length - 1;
}
} else if (options.length > 0) {
- if (isArrayOfOptionGroups(options)) {
+ if (isArrayOfGroupedOptions(options)) {
last = options.reduce(reducer, 0) - 1;
} else {
last = options.length - 1;
From 589eda99830a0db34deeef1e24df0f04753f0cfb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Iv=C3=A1n=20G=C3=B3mez=20Pinta?=
<44321109+GomezIvann@users.noreply.github.com>
Date: Fri, 7 Feb 2025 11:29:51 +0100
Subject: [PATCH 03/15] Testing updates Select
---
packages/lib/src/select/Listbox.tsx | 5 ++--
packages/lib/src/select/Select.test.tsx | 32 ++++++++++++-------------
2 files changed, 18 insertions(+), 19 deletions(-)
diff --git a/packages/lib/src/select/Listbox.tsx b/packages/lib/src/select/Listbox.tsx
index ba7622ddb9..1cae0375c2 100644
--- a/packages/lib/src/select/Listbox.tsx
+++ b/packages/lib/src/select/Listbox.tsx
@@ -15,7 +15,6 @@ const ListboxContainer = styled.ul`
background-color: var(--color-absolutes-white);
border: var(--border-width-s) var(--border-style-default) var(--border-color-neutral-medium);
border-radius: var(--border-radius-s);
-
box-shadow: var(--shadow-mid-x-position, 0px) var(--shadow-mid-y-position, 12px) var(--shadow-mid-blur, 12px)
var(--shadow-mid-spread, 0px) var(--shadow-light, rgba(209, 209, 209, 0.3));
color: var(--color-fg-neutral-dark);
@@ -133,6 +132,7 @@ const Listbox = ({
return (
{
event.stopPropagation();
@@ -141,9 +141,8 @@ const Listbox = ({
event.preventDefault();
}}
ref={listboxRef}
- aria-multiselectable={multiple}
- style={styles}
role="listbox"
+ style={styles}
>
{searchable && (options.length === 0 || !groupsHaveOptions(options)) ? (
diff --git a/packages/lib/src/select/Select.test.tsx b/packages/lib/src/select/Select.test.tsx
index 9ea606466e..4703f56dac 100644
--- a/packages/lib/src/select/Select.test.tsx
+++ b/packages/lib/src/select/Select.test.tsx
@@ -667,7 +667,7 @@ describe("Select component tests", () => {
);
const select = getByRole("combobox");
await userEvent.click(select);
- const listbox = getByRole("list");
+ const listbox = getByRole("listbox");
expect(listbox).toBeTruthy();
expect(select.getAttribute("aria-expanded")).toBe("true");
expect(getByText("Colores")).toBeTruthy();
@@ -683,7 +683,7 @@ describe("Select component tests", () => {
expect(groups[2]?.getAttribute("aria-labelledby")).toBe(groupLabels[2]?.id);
expect(getAllByRole("option").length).toBe(18);
await userEvent.click(select);
- expect(queryByRole("list")).toBeFalsy();
+ 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 () => {
@@ -700,7 +700,7 @@ describe("Select component tests", () => {
);
const select = getByRole("combobox");
await userEvent.click(select);
- expect(queryByRole("list")).toBeFalsy();
+ 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 () => {
@@ -714,7 +714,7 @@ describe("Select component tests", () => {
let options = getAllByRole("option");
options[8] && (await userEvent.click(options[8]));
expect(onChange).toHaveBeenCalledWith({ value: "oviedo" });
- expect(queryByRole("list")).toBeFalsy();
+ expect(queryByRole("listbox")).toBeFalsy();
expect(getByText("Oviedo")).toBeTruthy();
await userEvent.click(select);
options = getAllByRole("option");
@@ -772,7 +772,7 @@ describe("Select component tests", () => {
const { getByRole, queryByRole } = render();
const select = getByRole("combobox");
fireEvent.keyDown(select, { key: "ArrowUp", code: "ArrowUp", keyCode: 38, charCode: 38 });
- expect(queryByRole("list")).toBeTruthy();
+ 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", () => {
@@ -780,14 +780,14 @@ describe("Select component tests", () => {
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 });
- expect(queryByRole("list")).toBeTruthy();
+ expect(queryByRole("listbox")).toBeTruthy();
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();
const select = getByRole("combobox");
fireEvent.keyDown(select, { key: "ArrowDown", code: "ArrowDown", keyCode: 40, charCode: 40 });
- expect(queryByRole("list")).toBeTruthy();
+ 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", () => {
@@ -795,7 +795,7 @@ describe("Select component tests", () => {
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("list")).toBeTruthy();
+ 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 () => {
@@ -810,7 +810,7 @@ describe("Select component tests", () => {
fireEvent.keyDown(select, { key: "ArrowDown", code: "ArrowDown", keyCode: 40, charCode: 40 });
fireEvent.keyDown(select, { key: "Enter", code: "Enter", keyCode: 13, charCode: 13 });
expect(onChange).toHaveBeenCalledWith({ value: "ebro" });
- expect(queryByRole("list")).toBeFalsy();
+ expect(queryByRole("listbox")).toBeFalsy();
expect(getByText("Ebro")).toBeTruthy();
await userEvent.click(select);
const options = getAllByRole("option");
@@ -824,7 +824,7 @@ describe("Select component tests", () => {
const select = getByRole("combobox");
const searchInput = container.querySelectorAll("input")[1];
await userEvent.click(select);
- expect(getByRole("list")).toBeTruthy();
+ expect(getByRole("listbox")).toBeTruthy();
searchInput && (await userEvent.type(searchInput, "ro"));
expect(getAllByRole("presentation").length).toBe(2);
expect(getAllByRole("option").length).toBe(5);
@@ -833,7 +833,7 @@ describe("Select component tests", () => {
let options = getAllByRole("option");
options[4] && (await userEvent.click(options[4]));
expect(onChange).toHaveBeenCalledWith({ value: "ebro" });
- expect(queryByRole("list")).toBeFalsy();
+ expect(queryByRole("listbox")).toBeFalsy();
expect(getByText("Ebro")).toBeTruthy();
expect(searchInput?.value).toBe("");
await userEvent.click(select);
@@ -848,7 +848,7 @@ describe("Select component tests", () => {
const select = getByRole("combobox");
const searchInput = container.querySelectorAll("input")[1];
await userEvent.click(select);
- expect(getByRole("list")).toBeTruthy();
+ expect(getByRole("listbox")).toBeTruthy();
searchInput && (await userEvent.type(searchInput, "very long string"));
expect(getByText("No matches found")).toBeTruthy();
});
@@ -863,13 +863,13 @@ describe("Select component tests", () => {
const options = getAllByRole("option");
options[10] && (await userEvent.click(options[10]));
expect(onChange).toHaveBeenCalledWith({ value: ["bilbao"] });
- expect(queryByRole("list")).toBeTruthy();
+ expect(queryByRole("listbox")).toBeTruthy();
expect(getAllByText("Bilbao").length).toBe(2);
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).toHaveBeenCalledWith({ value: ["bilbao", "guadalquivir"] });
- expect(queryByRole("list")).toBeTruthy();
+ expect(queryByRole("listbox")).toBeTruthy();
expect(getByText("Bilbao, Guadalquivir")).toBeTruthy();
expect(submitInput?.value).toBe("bilbao,guadalquivir");
});
@@ -886,13 +886,13 @@ describe("Select component tests", () => {
options[13] && (await userEvent.click(options[13]));
options[17] && (await userEvent.click(options[17]));
expect(onChange).toHaveBeenCalledWith({ value: ["blanco", "oviedo", "duero", "ebro"] });
- expect(queryByRole("list")).toBeTruthy();
+ 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);
- expect(queryByRole("list")).toBeTruthy();
+ expect(queryByRole("listbox")).toBeTruthy();
expect(queryByText("Blanco, Oviedo, Duero, Ebro")).toBeFalsy();
expect(queryByText("4")).toBeFalsy();
expect(queryByRole("button")).toBeFalsy();
From 029c43665c70cb75194ff391055de4b8945d0326 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Iv=C3=A1n=20G=C3=B3mez=20Pinta?=
<44321109+GomezIvann@users.noreply.github.com>
Date: Fri, 7 Feb 2025 12:37:33 +0100
Subject: [PATCH 04/15] Fixed Select tests
---
packages/lib/src/action-icon/types.ts | 4 +++-
packages/lib/src/select/Listbox.tsx | 2 +-
packages/lib/src/select/Select.test.tsx | 3 +--
packages/lib/src/select/Select.tsx | 30 +++++++++++++------------
4 files changed, 21 insertions(+), 18 deletions(-)
diff --git a/packages/lib/src/action-icon/types.ts b/packages/lib/src/action-icon/types.ts
index fec059254d..08ac678cbe 100644
--- a/packages/lib/src/action-icon/types.ts
+++ b/packages/lib/src/action-icon/types.ts
@@ -1,4 +1,5 @@
import { SVG } from "../common/utils";
+import { MouseEvent } from "react";
type Props = {
/**
@@ -15,8 +16,9 @@ type Props = {
icon: string | SVG;
/**
* This function will be called when the user clicks the button.
+ * @param event The event source of the callback.
*/
- onClick?: () => void;
+ onClick?: (event: MouseEvent) => void;
/**
* Value of the tabindex attribute.
*/
diff --git a/packages/lib/src/select/Listbox.tsx b/packages/lib/src/select/Listbox.tsx
index 1cae0375c2..7139d4cbbc 100644
--- a/packages/lib/src/select/Listbox.tsx
+++ b/packages/lib/src/select/Listbox.tsx
@@ -69,7 +69,7 @@ const Listbox = ({
return (
option.options.length > 0 && (
-
+
{option.label}
diff --git a/packages/lib/src/select/Select.test.tsx b/packages/lib/src/select/Select.test.tsx
index 4703f56dac..14dcd5e934 100644
--- a/packages/lib/src/select/Select.test.tsx
+++ b/packages/lib/src/select/Select.test.tsx
@@ -93,7 +93,6 @@ describe("Select component tests", () => {
);
const select = getByRole("combobox");
const errorMessage = getByText("Error message.");
-
expect(errorMessage).toBeTruthy();
expect(select.getAttribute("aria-errormessage")).toBe(errorMessage.id);
expect(select.getAttribute("aria-invalid")).toBe("true");
@@ -675,7 +674,7 @@ describe("Select component tests", () => {
expect(getByText("Negro")).toBeTruthy();
expect(getByText("Ciudades españolas")).toBeTruthy();
expect(getByText("Madrid")).toBeTruthy();
- const groups = getAllByRole("listbox");
+ const groups = getAllByRole("group");
expect(groups.length).toBe(3);
const groupLabels = getAllByRole("presentation");
expect(groups[0]?.getAttribute("aria-labelledby")).toBe(groupLabels[0]?.id);
diff --git a/packages/lib/src/select/Select.tsx b/packages/lib/src/select/Select.tsx
index d1df0a968b..ef01d4c19d 100644
--- a/packages/lib/src/select/Select.tsx
+++ b/packages/lib/src/select/Select.tsx
@@ -223,34 +223,35 @@ const Error = styled.span`
const DxcSelect = forwardRef(
(
{
- label,
- name = "",
+ ariaLabel = "Select",
defaultValue,
- value,
- options,
- helperText,
- placeholder = "",
disabled = false,
+ error,
+ helperText,
+ label,
+ margin,
multiple = false,
+ name,
+ onBlur,
+ onChange,
optional = false,
+ options,
+ placeholder = "",
searchable = false,
- onChange,
- onBlur,
- error,
- margin,
size = "medium",
tabIndex = 0,
- ariaLabel = "Select",
+ value,
},
ref
): JSX.Element => {
const id = `select-${useId()}`;
+ const [hasTooltip, setHasTooltip] = useState(false);
const [innerValue, setInnerValue] = useState(defaultValue ?? (multiple ? [] : ""));
+ const [isOpen, changeIsOpen] = useState(false);
const [searchValue, setSearchValue] = useState("");
const [visualFocusIndex, changeVisualFocusIndex] = useState(-1);
- const [isOpen, changeIsOpen] = useState(false);
- const [hasTooltip, setHasTooltip] = useState(false);
+
const selectRef = useRef(null);
const selectSearchInputRef = useRef(null);
@@ -457,7 +458,8 @@ const DxcSelect = forwardRef(
}
};
- const handleClearSearchActionOnClick = () => {
+ const handleClearSearchActionOnClick = (event: MouseEvent) => {
+ event.stopPropagation();
setSearchValue("");
};
From dfda12e76904c2eafd8ee2c7fe2affb8e96ce105 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Iv=C3=A1n=20G=C3=B3mez=20Pinta?=
<44321109+GomezIvann@users.noreply.github.com>
Date: Fri, 7 Feb 2025 15:07:08 +0100
Subject: [PATCH 05/15] More fixes to the Select accessibility
---
packages/lib/src/select/ListOption.tsx | 1 +
packages/lib/src/select/Listbox.tsx | 57 +++++++++----------
.../src/select/Select.accessibility.test.tsx | 13 +----
3 files changed, 31 insertions(+), 40 deletions(-)
diff --git a/packages/lib/src/select/ListOption.tsx b/packages/lib/src/select/ListOption.tsx
index b8d7a46d29..913d3e60eb 100644
--- a/packages/lib/src/select/ListOption.tsx
+++ b/packages/lib/src/select/ListOption.tsx
@@ -102,6 +102,7 @@ const ListOption = ({
{
onClick(option);
diff --git a/packages/lib/src/select/Listbox.tsx b/packages/lib/src/select/Listbox.tsx
index 7139d4cbbc..19085ddbe9 100644
--- a/packages/lib/src/select/Listbox.tsx
+++ b/packages/lib/src/select/Listbox.tsx
@@ -6,17 +6,16 @@ import ListOption from "./ListOption";
import { groupsHaveOptions } from "./utils";
import { ListboxProps, ListOptionGroupType, ListOptionType } from "./types";
-const ListboxContainer = styled.ul`
+const ListboxContainer = styled.div`
box-sizing: border-box;
max-height: 304px;
overflow-y: auto;
- margin: 0;
padding: var(--spacing-padding-xxs) var(--spacing-padding-none);
background-color: var(--color-absolutes-white);
border: var(--border-width-s) var(--border-style-default) var(--border-color-neutral-medium);
border-radius: var(--border-radius-s);
- box-shadow: var(--shadow-mid-x-position, 0px) var(--shadow-mid-y-position, 12px) var(--shadow-mid-blur, 12px)
- var(--shadow-mid-spread, 0px) var(--shadow-light, rgba(209, 209, 209, 0.3));
+ box-shadow: var(--shadow-mid-x-position) var(--shadow-mid-y-position) var(--shadow-mid-blur) var(--shadow-mid-spread)
+ var(--shadow-light);
color: var(--color-fg-neutral-dark);
font-family: var(--typography-font-family);
font-size: var(--typography-label-m);
@@ -59,7 +58,7 @@ const Listbox = ({
styles,
}: ListboxProps): JSX.Element => {
const translatedLabels = useContext(HalstackLanguageContext);
- const listboxRef = useRef(null);
+ const listboxRef = useRef(null);
let globalIndex = optional && !multiple ? 0 : -1;
@@ -68,31 +67,29 @@ const Listbox = ({
if ("options" in option) {
return (
option.options.length > 0 && (
- -
-
-
- {option.label}
-
- {option.options.map((singleOption) => {
- globalIndex++;
- return (
-
- );
- })}
-
-
+
+
+ {option.label}
+
+ {option.options.map((singleOption) => {
+ globalIndex++;
+ return (
+
+ );
+ })}
+
)
);
} else {
diff --git a/packages/lib/src/select/Select.accessibility.test.tsx b/packages/lib/src/select/Select.accessibility.test.tsx
index 9b374297f8..d9600d3220 100644
--- a/packages/lib/src/select/Select.accessibility.test.tsx
+++ b/packages/lib/src/select/Select.accessibility.test.tsx
@@ -1,15 +1,8 @@
import { render } from "@testing-library/react";
-import { axe, formatRules } from "../../test/accessibility/axe-helper";
+import { axe } from "../../test/accessibility/axe-helper";
import DxcFlex from "../flex/Flex";
import DxcSelect from "./Select";
-// TODO: REMOVE
-import { disabledRules as rules } from "../../test/accessibility/rules/specific/select/disabledRules";
-
-const disabledRules = {
- rules: formatRules(rules),
-};
-
const iconSVG = (