diff --git a/apps/website/pages/components/select/code.tsx b/apps/website/pages/components/select/code.tsx new file mode 100644 index 0000000000..2e06ea6312 --- /dev/null +++ b/apps/website/pages/components/select/code.tsx @@ -0,0 +1,17 @@ +import Head from "next/head"; +import type { ReactElement } from "react"; +import SelectPageLayout from "screens/components/select/SelectPageLayout"; +import SelectCodePage from "screens/components/select/code/SelectCodePage"; + +const Code = () => ( + <> + + Select code — Halstack Design System + + + +); + +Code.getLayout = (page: ReactElement) => {page}; + +export default Code; diff --git a/apps/website/pages/components/select/index.tsx b/apps/website/pages/components/select/index.tsx index 80066efc7b..f957a7195d 100644 --- a/apps/website/pages/components/select/index.tsx +++ b/apps/website/pages/components/select/index.tsx @@ -1,21 +1,17 @@ import Head from "next/head"; import type { ReactElement } from "react"; import SelectPageLayout from "screens/components/select/SelectPageLayout"; -import SelectCodePage from "screens/components/select/code/SelectCodePage"; +import SelectOverviewPage from "screens/components/select/overview/SelectOverviewPage"; -const Index = () => { - return ( - <> - - Select — Halstack Design System - - - - ); -}; +const Index = () => ( + <> + + Select — Halstack Design System + + + +); -Index.getLayout = function getLayout(page: ReactElement) { - return {page}; -}; +Index.getLayout = (page: ReactElement) => {page}; export default Index; diff --git a/apps/website/pages/components/select/specifications.tsx b/apps/website/pages/components/select/specifications.tsx deleted file mode 100644 index c009fc2f1f..0000000000 --- a/apps/website/pages/components/select/specifications.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import Head from "next/head"; -import type { ReactElement } from "react"; -import SelectPageLayout from "screens/components/select/SelectPageLayout"; -import SelectSpecsPage from "screens/components/select/specs/SelectSpecsPage"; - -const Specifications = () => { - return ( - <> - - Select Specs — Halstack Design System - - - - ); -}; - -Specifications.getLayout = function getLayout(page: ReactElement) { - return {page}; -}; - -export default Specifications; diff --git a/apps/website/pages/components/select/usage.tsx b/apps/website/pages/components/select/usage.tsx deleted file mode 100644 index 31d0b44cb2..0000000000 --- a/apps/website/pages/components/select/usage.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import Head from "next/head"; -import type { ReactElement } from "react"; -import SelectPageLayout from "screens/components/select/SelectPageLayout"; -import SelectUsagePage from "screens/components/select/usage/SelectUsagePage"; - -const Usage = () => { - return ( - <> - - Select Usage — Halstack Design System - - - - ); -}; - -Usage.getLayout = function getLayout(page: ReactElement) { - return {page}; -}; - -export default Usage; diff --git a/apps/website/screens/components/select/SelectPageLayout.tsx b/apps/website/screens/components/select/SelectPageLayout.tsx index c8fbe8d076..c0fa6e7e24 100644 --- a/apps/website/screens/components/select/SelectPageLayout.tsx +++ b/apps/website/screens/components/select/SelectPageLayout.tsx @@ -6,9 +6,8 @@ import { ReactNode } from "react"; const SelectPageHeading = ({ children }: { children: ReactNode }) => { const tabs = [ - { label: "Code", path: "/components/select" }, - { label: "Usage", path: "/components/select/usage" }, - { label: "Specifications", path: "/components/select/specifications" }, + { label: "Overview", path: "/components/select" }, + { label: "Code", path: "/components/select/code" }, ]; return ( @@ -19,7 +18,7 @@ const SelectPageHeading = ({ children }: { children: ReactNode }) => { The select component allows users to make single or multiple selections from a pre-defined list of options. - + {children} diff --git a/apps/website/screens/components/select/code/SelectCodePage.tsx b/apps/website/screens/components/select/code/SelectCodePage.tsx index cdeba01db9..d714c257f4 100644 --- a/apps/website/screens/components/select/code/SelectCodePage.tsx +++ b/apps/website/screens/components/select/code/SelectCodePage.tsx @@ -32,6 +32,18 @@ const sections = [ + + ariaLabel + + string + + + Specifies a string to be used as the name for the select element when no label is provided. + + + 'Select' + + defaultValue @@ -41,16 +53,36 @@ const sections = [ - - value + disabled - string | string[] + boolean + If true, the component will be disabled. - Value of the select. If undefined, the component will be uncontrolled and the value will be managed - internally by the component. + false + + + + error + + string + + + If it is a defined value and also a truthy string, the component will change its appearance, showing the + error below the select component. If the defined value is an empty string, it will reserve a space below + the component for a future error, but it would not change its look. In case of being undefined or null, + both the appearance and the space for the error message would not be modified. - + + helperText + + string + + Helper text to be placed above the select. + - + label @@ -59,6 +91,30 @@ const sections = [ Text to be placed above the select. - + + margin + + 'xxsmall' | 'xsmall' | 'small' | 'medium' | 'large' | 'xlarge' | 'xxlarge' | Margin + + + Size of the margin to be applied to the component. You can pass an object with 'top', 'bottom', 'left' and + 'right' properties in order to specify different margin sizes. + + - + + + multiple + + boolean + + + If true, the select component will support multiple selected options. In that case, value will be an array + of strings with each selected option value. + + + false + + name @@ -72,6 +128,45 @@ const sections = [ - + + onBlur + + {"(val: { value: string | string[]; error?: string }) => void"} + + + This function will be called when the select loses the focus. An object including the value (or values) + and the error (if the value selected is not valid) will be passed to this function. If there is no error,{" "} + error will not be defined. + + - + + + onChange + + {"(val: { value: string | string[]; error?: string }) => void"} + + + This function will be called when the user selects an option. An object including the new value (or + values) and the error (if the value selected is not valid) will be passed to this function. If there is no + error, error will not be defined. + + - + + + optional + + boolean + + + If true, the select will be optional, showing '(Optional)' next to the label and adding a default first + option with an empty string as value and the placeholder (if defined) as its label. Otherwise, the field + will be considered required and an error will be passed as a parameter to the onBlur and{" "} + onChange functions if an option wasn't selected. + + + false + + @@ -123,17 +218,11 @@ const sections = [ options: List of Option instances. +
+ You can't mix single and grouped options in the same array. - - - helperText - - string - - Helper text to be placed above the select. - - - placeholder @@ -152,92 +241,6 @@ const sections = [ false - - multiple - - boolean - - - If true, the select component will support multiple selected options. In that case, value will be an array - of strings with each selected option value. - - - false - - - - disabled - - boolean - - If true, the component will be disabled. - - false - - - - optional - - boolean - - - If true, the select will be optional, showing '(Optional)' next to the label and adding a default first - option with an empty string as value and the placeholder (if defined) as its label. Otherwise, the field - will be considered required and an error will be passed as a parameter to the onBlur and{" "} - onChange functions if an option wasn't selected. - - - false - - - - onChange - - {"(val: { value: string | string[]; error?: string }) => void"} - - - This function will be called when the user selects an option. An object including the new value (or - values) and the error (if the value selected is not valid) will be passed to this function. If there is no - error, error will not be defined. - - - - - - onBlur - - {"(val: { value: string | string[]; error?: string }) => void"} - - - This function will be called when the select loses the focus. An object including the value (or values) - and the error (if the value selected is not valid) will be passed to this function. If there is no error,{" "} - error will not be defined. - - - - - - error - - string - - - If it is a defined value and also a truthy string, the component will change its appearance, showing the - error below the select component. If the defined value is an empty string, it will reserve a space below - the component for a future error, but it would not change its look. In case of being undefined or null, - both the appearance and the space for the error message would not be modified. - - - - - - margin - - 'xxsmall' | 'xsmall' | 'small' | 'medium' | 'large' | 'xlarge' | 'xxlarge' | Margin - - - Size of the margin to be applied to the component. You can pass an object with 'top', 'bottom', 'left' and - 'right' properties in order to specify different margin sizes. - - - - size @@ -269,14 +272,15 @@ const sections = [ - - ariaLabel + value - string + string | string[] - Specifies a string to be used as the name for the select element when no label is provided. + Value of the select. If undefined, the component will be uncontrolled and the value will be managed + internally by the component. - 'Select' + - @@ -309,15 +313,13 @@ const sections = [ }, ]; -const SelectCodePage = () => { - return ( - - - - - - - ); -}; +const SelectCodePage = () => ( + + + + + + +); export default SelectCodePage; diff --git a/apps/website/screens/components/select/overview/SelectOverviewPage.tsx b/apps/website/screens/components/select/overview/SelectOverviewPage.tsx new file mode 100644 index 0000000000..97579c7ac9 --- /dev/null +++ b/apps/website/screens/components/select/overview/SelectOverviewPage.tsx @@ -0,0 +1,257 @@ +import { DxcParagraph, DxcBulletedList, DxcFlex } from "@dxc-technology/halstack-react"; +import QuickNavContainer from "@/common/QuickNavContainer"; +import QuickNavContainerLayout from "@/common/QuickNavContainerLayout"; +import DocFooter from "@/common/DocFooter"; +import Example from "@/common/example/Example"; +import single from "./examples/single"; +import multiple from "./examples/multiple"; +import filterable from "./examples/filterable"; +import Image from "@/common/Image"; +import anatomy from "./images/select_anatomy.png"; + +const sections = [ + { + title: "Introduction", + content: ( + + The select component provides a structured way for users to{" "} + choose from a predefined list of options, streamlining decision-making in forms and interfaces. + It supports various configurations, including placeholder text, grouped options, and icons, allowing for better + usability and alignment with design needs. Designed for clarity and efficiency, it helps maintain a clean UI + while offering an intuitive selection process. + + ), + }, + { + title: "Anatomy", + content: ( + <> + Select's anatomy + + + Selection indicator (multiple): a visual marker, typically a checkmark, that shows which + items have been selected in the multi-select variant of the component. + + + Select all item: an option that allows users to select or deselect all available choices in + the multi-select variant of the component for efficiency. + + + List option: an individual selectable item within the dropdown list, representing a choice + available to the user. + + + List option checkbox: a checkbox placed next to each list option in a multi-select + dropdown, allowing users to select multiple items. + + + Icon: a small graphic or symbol that visually represents the list option, aiding quick + recognition of choices. + + + List option label: the text displayed next to an option in the listbox, providing a clear + description of the choice. + + + Label: a descriptive title for the select component, helping users understand its purpose + and context. + + + Helper text: additional guidance placed below the label to clarify how the user should + interact with the select component. + + + Select value(s): displays the currently selected options within the input area, allowing + users to see their selections at a glance. + + + Expand/collapse icon: a visual indicator that signals whether the dropdown list can be + expanded or collapsed. + + + Select container: the outer structure enclosing the select component, ensuring proper + spacing and alignment within the UI. + + + Select listbox: the dropdown box that appears when the select is expanded, containing all + available options. + + + List item selected indicator (single): a checkmark or highlight used to indicate which + option is currently selected in a single-selection dropdown. + + + List item (single): an individual selectable option in a single-selection dropdown, + allowing users to pick only one choice at a time. + + + + ), + }, + { + title: "Using selects", + content: ( + + The select component allows users to choose from a predefined list of options, making it a valuable input method + for collecting user-provided information. It is particularly useful in forms where users need + to submit structured data efficiently. Designed to handle more than four options, the select + component improves usability by reducing clutter and keeping the interface clean compared to radio buttons or + other selection methods. Depending on the use case, it can support both single and multiple selections, enabling + flexibility in data input. + + ), + subSections: [ + { + title: "Filtering", + content: ( + <> + + Filtering features are present in all variants of the select component, and it's a very useful attribute + of the component when dealing with long lists of options. + + + As the user types, the list dynamically narrows down to display only the matching results + , improving efficiency and ease of selection. The value updates when the user either types a string that + matches an existing option or manually selects one from the list. If no matches are found, a "No matches + found" message is displayed, providing clear feedback. + + + + ), + }, + { + title: "Required and optional", + content: ( + + The select component can be either optional or required, depending on the + context. When marked as optional, it includes a placeholder-like option that allows users + to leave the field empty if no selection is needed. On the other hand, if no optional label is present, the + field is considered required, meaning users must choose an option before proceeding. If a + required select is left empty, an error message stating "This field cannot be empty" should + appear when the component loses focus, ensuring users provide the necessary input before submitting a form. + + ), + }, + { + title: "Select vs dropdown", + content: ( + <> + + While both the select and dropdown components present a list of options, + they serve distinct purposes within an interface. The select component is primarily used + in forms to collect user input, allowing either single or multiple selections from a predefined set of + options. It is designed to replace traditional radio buttons or checkboxes when space efficiency is + needed, especially when dealing with long lists. Additionally, the select component can include a + filtering mechanism to help users find their desired option more easily. + + + On the other hand, the dropdown component is not meant for form inputs but rather for + displaying contextual actions or navigation links. It typically triggers a menu that offers options such + as commands, shortcuts, or external links, often enhancing interaction within a UI. Unlike the select, + dropdowns do not retain selected values within the component itself but instead execute an action upon + selection. + + + When deciding between the two, consider whether the component needs to collect and retain user input + (select) or provide quick access to actions and links (dropdown). + + + ), + }, + ], + }, + { + title: "Variants", + content: ( + + Depending on the number of items the user is able to select, our component can allow multiple or single + selection. + + ), + subSections: [ + { + title: "Single selection", + content: ( + <> + + Ideal for scenarios where a single, definitive choice is required, the single-selection allows users to + choose only one option from a predefined list. This variant is commonly used in forms + where users need to specify categories, pick a location, select a status, or choose from mutually + exclusive options like gender, payment methods, or subscription plans. It simplifies decision-making by + preventing multiple selections, ensuring clarity and accuracy in user inputs. + + + + ), + }, + { + title: "Multiple selection", + content: ( + <> + + The multiple-selection allows users to choose more than one option from a list, making it + perfect for scenarios where multiple selections are necessary. This variant is commonly used in filters, + permission settings, tag selection, and cases where users need to customize their choices, such as + selecting multiple product categories, preferred communication channels, or applicable document types. + + + To enhance usability, this variant includes a "Select all" feature within the listbox, + allowing users to quickly select all options at once. This functionality is especially useful in forms + with long lists, where multiple selections are likely to be valid, reducing the time and effort needed to + choose items individually. + + + + ), + }, + ], + }, + { + title: "Best practices", + content: ( + + + Use select for more than four options: if the number of choices is fewer, consider using + radio buttons (for single selection) or checkboxes (for multiple selection) to reduce user interaction effort. + + + Enable filtering for long lists: if the option list is extensive (around 15 or more items), + use the filterable variant to help users quickly find relevant choices. + + + Label optional fields clearly: when the select field is optional, ensure a placeholder option + is available to indicate that the field can be left empty. If it’s required, provide an error message when + left unselected. + + + Choose the right selection mode: use the single-selection variant when users need to pick + only one option. If multiple selections are needed, enable the multi-selection variant and consider including + the "select all" feature for better usability. + + + Keep option labels clear and concise: avoid overly long or ambiguous option labels. Each + choice should be easily scannable and self-explanatory. + + + Use placeholders wisely: a placeholder should provide guidance but not be mistaken for a + default selection. Be clear and concise when deciding which placeholder to set into the select. + + + Prevent excessive nesting: when grouping options into categories, keep the hierarchy simple + and easy to navigate to avoid overwhelming the user. + + + ), + }, +]; + +const SelectOverviewPage = () => ( + + + + + + +); + +export default SelectOverviewPage; diff --git a/apps/website/screens/components/select/usage/examples/filterable.ts b/apps/website/screens/components/select/overview/examples/filterable.ts similarity index 100% rename from apps/website/screens/components/select/usage/examples/filterable.ts rename to apps/website/screens/components/select/overview/examples/filterable.ts diff --git a/apps/website/screens/components/select/usage/examples/variants.ts b/apps/website/screens/components/select/overview/examples/multiple.ts similarity index 81% rename from apps/website/screens/components/select/usage/examples/variants.ts rename to apps/website/screens/components/select/overview/examples/multiple.ts index 02e77e65e6..3ceef9c8fe 100644 --- a/apps/website/screens/components/select/usage/examples/variants.ts +++ b/apps/website/screens/components/select/overview/examples/multiple.ts @@ -11,12 +11,6 @@ const code = `() => { return ( - { + const options = [ + { label: "Option 01", value: "1" }, + { label: "Option 02", value: "2" }, + { label: "Option 03", value: "3" }, + { label: "Option 04", value: "4" }, + ]; + + return ( + + + + + + ); +}`; + +const scope = { + DxcSelect, + DxcFlex, + DxcInset, +}; + +export default { code, scope }; diff --git a/apps/website/screens/components/select/overview/images/select_anatomy.png b/apps/website/screens/components/select/overview/images/select_anatomy.png new file mode 100644 index 0000000000..ce1fc75885 Binary files /dev/null and b/apps/website/screens/components/select/overview/images/select_anatomy.png differ diff --git a/apps/website/screens/components/select/specs/SelectSpecsPage.tsx b/apps/website/screens/components/select/specs/SelectSpecsPage.tsx deleted file mode 100644 index f4097973b6..0000000000 --- a/apps/website/screens/components/select/specs/SelectSpecsPage.tsx +++ /dev/null @@ -1,1158 +0,0 @@ -import { DxcParagraph, DxcBulletedList, DxcTable, DxcFlex, DxcLink } from "@dxc-technology/halstack-react"; -import Link from "next/link"; -import Image from "@/common/Image"; -import QuickNavContainer from "@/common/QuickNavContainer"; -import QuickNavContainerLayout from "@/common/QuickNavContainerLayout"; -import Figure from "@/common/Figure"; -import DocFooter from "@/common/DocFooter"; -import Code from "@/common/Code"; -import selectSingleSpecsStates from "./images/select_input_states_single.png"; -import selectMultipleSpecsStates from "./images/select_input_states_multiple.png"; -import selectSingleOptionState from "./images/option_item_states_single.png"; -import selectMultipleOptionState from "./images/option_item_states_multiple.png"; -import selectAnatomy from "./images/select_anatomy.png"; -import selectSpecs from "./images/select_specs.png"; - -const sections = [ - { - title: "Specifications", - content: ( -
- Select design specifications -
- ), - }, - { - title: "States", - subSections: [ - { - title: "Select input", - content: ( - <> - - States are defined for select component based on the interactions that the user can reproduce. The states - are: enabled, hover, focus, active,{" "} - error and disabled: - -
- Select states -
- - ), - }, - { - title: "Multiple selection", - content: ( - <> - Allows the user to select more than one option from the list. -
- Multiple selection select states -
- - ), - }, - { - title: "List options", - content: ( - - To indicate which items are selected and which not, the select-multiple variant integrates a checkbox - pairing with each option from the dropdown. - - ), - subSections: [ - { - title: "Single", - content: ( -
- Single select states -
- ), - }, - { - title: "Multiple", - content: ( -
- Multiple select states -
- ), - }, - ], - }, - ], - }, - { - title: "Anatomy", - content: ( - <> - Select anatomy - - Label - Helper text - Selection indicator (multiple) - List dialog - Action - Clear - Collapse indicator - List option - Divider - List option label - List option icon - List option checkbox (multiple) - Select value - List item selected indicator - - - ), - }, - { - title: "Design tokens", - subSections: [ - { - title: "Color", - content: ( - - - - Component token - Element - Core token - Value - - - - - - labelFontColor - - Label - - color-black - - #000000 - - - - helperTextFontColor - - Helper text - - color-black - - #000000 - - - - errorMessageColor - - Error message - - color-red-700 - - #d0011b - - - - valueFontColor - - Value - - color-black - - #000000 - - - - placeholderFontColor - - Value - - color-grey-800-a - - #000000b3 - - - - disabledColor - - All:disabled - - color-grey-500 - - #999999 - - - - listDialogBackgroundColor - - List dialog - - color-white - - #ffffff - - - - listDialogBorderColor - - List dialog - - color-grey-400 - - #bfbfbf - - - - listOptionFontColor - - List option - - color-black - - #000000 - - - - listOptionIconColor - - List item icon - - color-black - - #000000 - - - - listOptionDividerColor - - Divider - - color-grey-200 - - #e6e6e6 - - - - unselectedHoverListOptionBackgroundColor - - List option:hover unselected - - color-grey-100 - - #f2f2f2 - - - - unselectedActiveListOptionBackgroundColor - - List option:active unselected - - color-grey-200 - - #e6e6e6 - - - - selectedListOptionBackgroundColor - - List option selected - - color-blue-100 - - #e6f4ff - - - - selectedHoverListOptionBackgroundColor - - List option:hover selected - - color-blue-200 - - #cceaff - - - - selectedActiveListOptionBackgroundColor - - List option:active selected - - color-blue-300 - - #99d5ff - - - - selectedListOptionIconColor - - List option selected indicator - - color-blue-900 - - #003c66 - - - - focusListOptionBorderColor - - List option:hover selected - - color-blue-600 - - #0095ff - - - - systemMessageFontColor - - System message - - color-grey-700 - - #666666 - - - - ), - subSections: [ - { - title: "Input", - content: ( - - - - Component token - Element - Core token - Value - - - - - - enabledInputBorderColor - - Border:enabled - - color-black - - #000000 - - - - hoverInputBorderColor - - Border:hover - - color-purple-500 - - #a46ede - - - - focusInputBorderColor - - Border:focus - - color-blue-600 - - #0095ff - - - - errorInputBorderColor - - Border:error - - color-red-700 - - #d0011b - - - - hoverInputErrorBorderColor - - Border:hover on error - - color-red-600 - - #fe0123 - - - - disabledInputBorderColor - - Border:disabled - - color-grey-500 - - #999999 - - - - disabledInputBackgroundColor - - Background:disabled - - color-grey-100 - - #f2f2f2 - - - - errorIconColor - - Error icon - - color-red-700 - - #d0011b - - - - collapseIndicatorColor - - Collapse indicator - - color-black - - #000000 - - - - ), - }, - { - title: "Selection indicator", - content: ( - - - - Component token - Element - Core token - Value - - - - - - selectionIndicatorFontColor - - Selection indicator value - - color-black - - #000000 - - - - selectionIndicatorBorderColor - - Selection indicator - - color-grey-400 - - #bfbfbf - - - - selectionIndicatorBackgroundColor - - Selection indicator - - color-grey-50 - - #fafafa - - - - enabledSelectionIndicatorActionBackgroundColor - - Selection indicator - - color-transparent - - transparent - - - - hoverSelectionIndicatorActionBackgroundColor - - Selection indicator:hover - - color-grey-100 - - #f2f2f2 - - - - activeSelectionIndicatorActionBackgroundColor - - Selection indicator:active - - color-grey-300 - - #cccccc - - - - enabledSelectionIndicatorActionIconColor - - Selection indicator icon - - color-black - - #000000 - - - - hoverSelectionIndicatorActionIconColor - - Selection indicator icon:hover - - color-black - - #000000 - - - - activeSelectionIndicatorActionIconColor - - Selection indicator icon:active - - color-black - - #000000 - - - - ), - }, - { - title: "Clear action", - content: ( - - - - Component token - Element - Core token - Value - - - - - - actionBackgroundColor - - Action - - color-transparent - - transparent - - - - hoverActionBackgroundColor - - Action:hover - - color-grey-100 - - #f2f2f2 - - - - activeActionBackgroundColor - - Action:active - - color-grey-300 - - #cccccc - - - - actionIconColor - - Action icon - - color-black - - #000000 - - - - hoverActionIconColor - - Action icon:hover - - color-black - - #000000 - - - - activeActionIconColor - - Action icon:active - - color-black - - #000000 - - - - ), - }, - ], - }, - { - title: "Typography", - content: ( - - - - Component token - Element - Core token - Value - - - - - - fontFamily - - All - - font-family-sans - - Open Sans - - - - labelFontSize - - Label - - font-scale-02 - - 0.875rem / 14px - - - - labelFontWeight - - Label - - font-weight-semibold - - 600 - - - - labelFontStyle - - Label - - font-style-normal - - normal - - - - labelLineHeight - - Label - - font-leading-loose-01 - - 1.715em - - - - optionalLabelFontWeight - - Label optional - - font-weight-regular - - 400 - - - - valueFontSize - - Value - - font-scale-03 - - 1rem / 16px - - - - valueFontWeight - - Value - - font-weight-regular - - 400 - - - - valueFontStyle - - Value - - font-style-normal - - normal - - - - valueLineHeight - - Value - - font-leading-normal - - 1.5em - - - - helperTextFontSize - - Helper text - - font-scale-01 - - 0.75rem / 12px - - - - helperTextFontWeight - - Helper text - - font-weight-regular - - 400 - - - - helperTextFontStyle - - Helper text - - font-style-normal - - normal - - - - helperTextLineHeight - - Helper text - - font-leading-normal - - 1.5em - - - - listOptionFontSize - - List option - - font-scale-02 - - 0.875rem / 14px - - - - listOptionFontWeight - - List option - - font-weight-regular - - 400 - - - - listOptionFontStyle - - List option - - font-style-normal - - normal - - - - listGroupLabelFontWeight - - List group item - - font-weight-semibold - - 600 - - - - ), - }, - { - title: "Border", - content: ( - - - - Property - Element - Core token - Value - - - - - - border - - Input container - - border-width-1 - - 1px - - - - border - - Input container - - border-style-solid - - solid - - - - border - - Input container:focus - - border-width-1 - - 1px - - - - border - - Input container:focus - - border-style-solid - - solid - - - - box-shadow - - Input container:focus - - - 0 0 0 2px - - - - box-shadow - - Input container:error - - - 0 0 0 2px - - - - box-shadow - - List dialog - - shadow-default - - 0 4px 6px -1px rgba(0,0,0,0.1) - - - - border-radius - - Input - - border-radius-medium - - 0.25rem / 4px - - - - border-radius - - Selection indicator / Clear action - - border-radius-small - - 0.125rem / 2px - - - - ), - }, - { - title: "Spacing", - content: ( - <> - - The select component input share the same spacing tokens as the{" "} - - text input - - . - - - - - Property - Element - Core token - Value - - - - - - padding-left - - List dialog - - spacing-8 - - 0.5rem / 8px - - - - padding-right - - List dialog - - spacing-8 - - 0.5rem / 8px - - - - margin-top - - List dialog content - - spacing-4 - - 0.25rem / 4px - - - - margin-bottom - - List dialog content - - spacing-4 - - 0.25rem / 4px - - - - padding-top - - List option - - spacing-4 - - 0.25rem / 4px - - - - padding-bottom - - List option - - spacing-4 - - 0.25rem / 4px - - - - - ), - }, - { - title: "Width", - content: ( - - - - Width - Value - - - - - - small - - 240px - - - - medium - - 360px - - - - large - - 480px - - - - fillParent - - 100% - - - - ), - }, - { - title: "Margin", - content: ( - <> - - - - Margin - Value - - - - - - xxsmall - - 6px - - - - xsmall - - 16px - - - - small - - 24px - - - - medium - - 36px - - - - large - - 48px - - - - xlarge - - 64px - - - - xxlarge - - 100px - - - - - These values can be applied independently to each side of the component: top,{" "} - bottom, left and right. - - - ), - }, - ], - }, - { - title: "Accessibility", - subSections: [ - { - title: "WCAG 2.2", - content: ( - - - Understanding WCAG 2.2 -{" "} - - SC 3.2.2: On Input - - - - ), - }, - { - title: "WAI-ARIA 1.2", - content: ( - - - WAI-ARIA practices 1.2 -{" "} - - 3.8 Combobox - - - - WAI-ARIA practices 1.2 -{" "} - - 3.14 Listbox - - - - WAI-ARIA examples 1.2 -{" "} - - Editable Combobox without Autocomplete Example - - - - ), - }, - ], - }, -]; - -const SelectSpecsPage = () => { - return ( - - - - - - - ); -}; - -export default SelectSpecsPage; diff --git a/apps/website/screens/components/select/specs/images/option_item_states_multiple.png b/apps/website/screens/components/select/specs/images/option_item_states_multiple.png deleted file mode 100644 index ae0253f65b..0000000000 Binary files a/apps/website/screens/components/select/specs/images/option_item_states_multiple.png and /dev/null differ diff --git a/apps/website/screens/components/select/specs/images/option_item_states_single.png b/apps/website/screens/components/select/specs/images/option_item_states_single.png deleted file mode 100644 index 2c0fa9592f..0000000000 Binary files a/apps/website/screens/components/select/specs/images/option_item_states_single.png and /dev/null differ diff --git a/apps/website/screens/components/select/specs/images/select_anatomy.png b/apps/website/screens/components/select/specs/images/select_anatomy.png deleted file mode 100644 index db5c78bd7b..0000000000 Binary files a/apps/website/screens/components/select/specs/images/select_anatomy.png and /dev/null differ diff --git a/apps/website/screens/components/select/specs/images/select_input_states_multiple.png b/apps/website/screens/components/select/specs/images/select_input_states_multiple.png deleted file mode 100644 index 18168558bf..0000000000 Binary files a/apps/website/screens/components/select/specs/images/select_input_states_multiple.png and /dev/null differ diff --git a/apps/website/screens/components/select/specs/images/select_input_states_single.png b/apps/website/screens/components/select/specs/images/select_input_states_single.png deleted file mode 100644 index 642545add6..0000000000 Binary files a/apps/website/screens/components/select/specs/images/select_input_states_single.png and /dev/null differ diff --git a/apps/website/screens/components/select/specs/images/select_specs.png b/apps/website/screens/components/select/specs/images/select_specs.png deleted file mode 100644 index ae1f21aff5..0000000000 Binary files a/apps/website/screens/components/select/specs/images/select_specs.png and /dev/null differ diff --git a/apps/website/screens/components/select/usage/SelectUsagePage.tsx b/apps/website/screens/components/select/usage/SelectUsagePage.tsx deleted file mode 100644 index 164adfed99..0000000000 --- a/apps/website/screens/components/select/usage/SelectUsagePage.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { DxcParagraph, DxcBulletedList, DxcFlex, DxcTable } from "@dxc-technology/halstack-react"; -import QuickNavContainer from "@/common/QuickNavContainer"; -import QuickNavContainerLayout from "@/common/QuickNavContainerLayout"; -import DocFooter from "@/common/DocFooter"; -import Example from "@/common/example/Example"; -import variants from "./examples/variants"; -import requiredOptional from "./examples/requiredOptional"; -import filterable from "./examples/filterable"; -import HeaderDescriptionCell from "@/common/HeaderDescriptionCell"; - -const sections = [ - { - title: "Usage", - content: ( - <> - Considerations about the select usage: - - - A dialog element should allow the user to select one option from a list. - - - If the list of options is short (4 or less), use checkboxes instead of the select component. - - - The select component should always display a label different from any name in the option list. - - Use a pre-selected good default where possible. - Use progressive disclosure between linked select components. - - If more than one option is applicable, use the multi-select variant. - - - - ), - }, - { - title: "Variants", - content: ( - <> - - - - - Variant - Description - - - - - - Single - - Allows the user to select one option from a list - - - - Multiple - - Allows the user to select multiple options from a list - - - - - ), - }, - { - title: "Filter", - content: ( - <> - - - Both select variants can be filterable. - - Use the filter when the number of items in the optionList is extremely long (± 15 elements). - - - This list will be reduced to show only the matches as the user types. - - - The value will change when the user types a string that matches an option from the list or pick one - manually. - - - When the search does not match any result, a "No matches found" message will be displayed. - - - - ), - }, - { - title: "Required and optional", - content: ( - <> - - - - When labeled as optional, the select will display an option matching the placeholder to allow leaving it - empty. - - When no optional label appears, the select is required. - - If the select was left empty, the required should display the error "This field can not be empty" when the - select loses the focus. - - - - ), - }, -]; - -const SelectUsagePage = () => { - return ( - - - - - - - ); -}; - -export default SelectUsagePage; 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/ListOption.tsx b/packages/lib/src/select/ListOption.tsx index e61c5f6cbb..8bb2d66af4 100644 --- a/packages/lib/src/select/ListOption.tsx +++ b/packages/lib/src/select/ListOption.tsx @@ -2,132 +2,71 @@ 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"]; +}>` + padding: var(--spacing-padding-none) var(--spacing-padding-xs); cursor: pointer; - + ${({ selected }) => selected && "background-color: var(--color-bg-secondary-lighter);"}; &:hover { - ${(props) => - props.selected - ? `background-color: ${props.theme.selectedHoverListOptionBackgroundColor};` - : `background-color: ${props.theme.unselectedHoverListOptionBackgroundColor};`}; + background-color: ${({ selected }) => + selected ? "var(--color-bg-secondary-medium)" : "var(--color-bg-neutral-light)"}; } &:active { - ${(props) => - props.selected - ? `background-color: ${props.theme.selectedActiveListOptionBackgroundColor};` - : `background-color: ${props.theme.unselectedActiveListOptionBackgroundColor};`}; + background-color: ${({ selected }) => + selected ? "var(--color-bg-secondary-medium)" : "var(--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-s);"} ${(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 +75,54 @@ 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) => { + 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..394b3ea060 100644 --- a/packages/lib/src/select/Listbox.tsx +++ b/packages/lib/src/select/Listbox.tsx @@ -5,22 +5,63 @@ import { HalstackLanguageContext } from "../HalstackContext"; import ListOption from "./ListOption"; import { groupsHaveOptions } from "./utils"; import { ListboxProps, ListOptionGroupType, ListOptionType } from "./types"; +import { scrollbarStyles } from "../styles/scroll"; + +const ListboxContainer = styled.div` + box-sizing: border-box; + max-height: 304px; + padding: var(--spacing-padding-xxs) var(--spacing-padding-none); + background-color: var(--color-bg-neutral-lightest); + border: var(--border-width-s) var(--border-style-default) var(--border-color-neutral-medium); + border-radius: var(--border-radius-s); + 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); + font-weight: var(--typography-label-regular); + overflow-y: auto; + ${scrollbarStyles} +`; + +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, + ariaLabelledBy, currentValue, - options, - visualFocusIndex, + handleOptionOnClick, + id, lastOptionIndex, multiple, optional, optionalItem, + options, searchable, - handleOptionOnClick, styles, -}: ListboxProps): JSX.Element => { + visualFocusIndex, +}: ListboxProps) => { const translatedLabels = useContext(HalstackLanguageContext); - const listboxRef = useRef(null); + const listboxRef = useRef(null); let globalIndex = optional && !multiple ? 0 : -1; @@ -29,49 +70,45 @@ 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++; + const optionId = `${id}-option-${globalIndex}`; + return ( + + ); + })} +
    ) ); } else { globalIndex++; + const optionId = `${id}-option-${globalIndex}`; return ( ); } @@ -95,10 +132,10 @@ const Listbox = ({ }); }, [visualFocusIndex]); - const hasOptionGroups = options.some((option) => "options" in option && option.options.length > 0); - return ( { event.stopPropagation(); @@ -107,31 +144,26 @@ const Listbox = ({ event.preventDefault(); }} ref={listboxRef} - aria-multiselectable={!hasOptionGroups ? multiple : undefined} + role="listbox" style={styles} - role={hasOptionGroups ? "list" : "listbox"} - aria-label="List of options" > {searchable && (options.length === 0 || !groupsHaveOptions(options)) ? ( - - - + {translatedLabels.select.noMatchesErrorMessage} ) : ( optional && !multiple && ( ) )} @@ -140,48 +172,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.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 = ( @@ -115,7 +108,7 @@ describe("Select component accessibility tests", () => { />
    ); - const results = await axe(baseElement, disabledRules); + const results = await axe(baseElement); expect(results).toHaveNoViolations(); }); it("Should not have basic accessibility issues for group mode", async () => { @@ -150,7 +143,7 @@ describe("Select component accessibility tests", () => { />
    ); - const results = await axe(baseElement, disabledRules); + const results = await axe(baseElement); expect(results).toHaveNoViolations(); }); }); diff --git a/packages/lib/src/select/Select.stories.tsx b/packages/lib/src/select/Select.stories.tsx index 07f3ed7c32..b41bd121d6 100644 --- a/packages/lib/src/select/Select.stories.tsx +++ b/packages/lib/src/select/Select.stories.tsx @@ -1,12 +1,9 @@ -import { useContext } from "react"; import { userEvent, within } from "@storybook/test"; -import { ThemeProvider } from "styled-components"; import ExampleContainer from "../../.storybook/components/ExampleContainer"; 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 Listbox from "./Listbox"; import DxcSelect from "./Select"; import { Meta, StoryObj } from "@storybook/react"; @@ -219,18 +216,13 @@ const optionsWithEllipsis = [ { label: "Option 03111111111111111111111111111122222222", value: "3" }, ]; -const opinionatedTheme = { - select: { - selectedOptionBackgroundColor: "#fabada", - fontColor: "#333", - optionFontColor: "#a46ede", - hoverBorderColor: "#0095ff", - }, -}; - const Select = () => ( <> + <ExampleContainer> + <Title title="Default" theme="light" level={4} /> + <DxcSelect options={single_options} /> + </ExampleContainer> <ExampleContainer pseudoState="pseudo-hover"> <Title title="Hovered" theme="light" level={4} /> <DxcSelect label="Hovered" options={single_options} /> @@ -241,11 +233,18 @@ const Select = () => ( </ExampleContainer> <ExampleContainer> <Title title="Disabled" theme="light" level={4} /> - <DxcSelect label="Disabled" placeholder="Placeholder" disabled options={single_options} /> + <DxcSelect + label="Label" + placeholder="Placeholder" + helperText="Helper text" + optional + disabled + options={single_options} + /> </ExampleContainer> <ExampleContainer> <Title title="Disabled with value" theme="light" level={4} /> - <DxcSelect label="Disabled with value" disabled options={single_options} defaultValue="1" /> + <DxcSelect label="Label" disabled helperText="Helper text" optional options={single_options} defaultValue="1" /> </ExampleContainer> <ExampleContainer> <Title title="Error" theme="light" level={4} /> @@ -270,7 +269,7 @@ const Select = () => ( <Title title="Anatomy" theme="light" level={2} /> <ExampleContainer> <Title title="Label, placeholder and helper text" theme="light" level={4} /> - <DxcSelect label="Label" options={single_options} helperText="Helper text" placeholder="Placeholder" /> + <DxcSelect label="Label" options={single_options} helperText="Helper text" placeholder="Placeholder" optional /> </ExampleContainer> <Title title="Variants" theme="light" level={2} /> <ExampleContainer> @@ -361,41 +360,9 @@ const Select = () => ( </> ); -const Opinionated = () => ( - <> - <Title title="Opinionated theme" theme="light" level={2} /> - <ExampleContainer pseudoState="pseudo-hover"> - <Title title="Default" theme="light" level={4} /> - <HalstackProvider theme={opinionatedTheme}> - <DxcSelect label="Hovered" helperText="Helper text" placeholder="Placeholder" options={single_options} /> - </HalstackProvider> - </ExampleContainer> - <ExampleContainer pseudoState="pseudo-hover"> - <Title title="Hovered" theme="light" level={4} /> - <HalstackProvider theme={opinionatedTheme}> - <DxcSelect - label="Hovered" - helperText="Helper text" - options={single_options} - multiple - defaultValue={["1", "2"]} - /> - </HalstackProvider> - </ExampleContainer> - <ExampleContainer pseudoState="pseudo-hover" expanded> - <Title title="List opened" theme="light" level={4} /> - <HalstackProvider theme={opinionatedTheme}> - <DxcSelect label="Hovered" helperText="Helper text" options={icon_options_grouped_material} defaultValue="1" /> - </HalstackProvider> - </ExampleContainer> - </> -); - const SelectListbox = () => { - const colorsTheme = useContext(HalstackContext); - return ( - <ThemeProvider theme={colorsTheme.select}> + <> <Title title="Listbox" theme="light" level={2} /> <ExampleContainer> <Title @@ -426,7 +393,9 @@ const SelectListbox = () => { <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} @@ -442,7 +411,9 @@ const SelectListbox = () => { </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} @@ -458,7 +429,9 @@ const SelectListbox = () => { </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} @@ -474,7 +447,9 @@ const SelectListbox = () => { </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} @@ -490,7 +465,9 @@ const SelectListbox = () => { </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} @@ -507,7 +484,9 @@ const SelectListbox = () => { <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} @@ -523,7 +502,9 @@ const SelectListbox = () => { </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} @@ -539,7 +520,9 @@ const SelectListbox = () => { </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} @@ -553,7 +536,7 @@ const SelectListbox = () => { styles={{ width: 360 }} /> </ExampleContainer> - </ThemeProvider> + </> ); }; @@ -599,15 +582,6 @@ const DefaultGroupedOptionsSelect = () => ( </ExampleContainer> ); -const DefaultGroupedOptionsSelectOpinionated = () => ( - <ExampleContainer expanded> - <Title title="Grouped options simple select" theme="light" level={4} /> - <HalstackProvider theme={opinionatedTheme}> - <DxcSelect label="Label" options={group_options} defaultValue="9" placeholder="Choose an option" /> - </HalstackProvider> - </ExampleContainer> -); - const MultipleGroupedOptionsSelect = () => ( <ExampleContainer expanded> <Title title="Grouped options multiple select" theme="light" level={4} /> @@ -643,27 +617,25 @@ const TooltipValue = () => ( ); const TooltipOption = () => { - const colorsTheme = useContext(HalstackContext); - return ( - <ThemeProvider theme={colorsTheme.select}> - <ExampleContainer expanded> - <Title title="List option has tooltip when it overflows" theme="light" level={4} /> - <Listbox - id="x8" - currentValue="1" - options={optionsWithEllipsis} - visualFocusIndex={-1} - lastOptionIndex={2} - multiple={false} - optional={false} - optionalItem={{ label: "Empty", value: "" }} - searchable={false} - handleOptionOnClick={() => {}} - styles={{ width: 360 }} - /> - </ExampleContainer> - </ThemeProvider> + <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> ); }; @@ -685,15 +657,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 +687,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)); }, }; @@ -737,21 +700,12 @@ export const GroupOptionsDisplayed: Story = { }, }; -export const GroupOptionsDisplayedOpinionated: Story = { - render: DefaultGroupedOptionsSelectOpinionated, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const select = canvas.getByRole("combobox"); - await userEvent.click(select); - }, -}; - export const MultipleOptionsDisplayed: Story = { render: MultipleSelect, 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..14dcd5e934 100644 --- a/packages/lib/src/select/Select.test.tsx +++ b/packages/lib/src/select/Select.test.tsx @@ -87,20 +87,17 @@ 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( <DxcSelect label="Error label" error="Error message." options={singleOptions} /> ); 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"); expect(errorMessage.getAttribute("aria-live")).toBe("assertive"); }); - test("Renders with correct aria attributes", async () => { const { getByText, getByRole } = render( <DxcSelect label="test-select-label" placeholder="Example" options={singleOptions} /> @@ -120,7 +117,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( <DxcSelect ariaLabel="Example aria label" placeholder="Example" options={singleOptions} /> @@ -128,7 +124,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( <DxcSelect label="test-select-label" name="test" defaultValue="4" options={singleOptions} /> @@ -145,7 +140,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( <DxcSelect @@ -167,7 +161,6 @@ describe("Select component tests", () => { 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 +187,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( <DxcSelect label="test-select-label" options={[]} searchable /> @@ -208,7 +200,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( <DxcSelect label="test-select-label" value={["1", "2"]} options={singleOptions} multiple disabled /> @@ -219,7 +210,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( <DxcSelect label="test-select-label" value={["1", "2"]} options={singleOptions} disabled searchable multiple /> @@ -227,7 +217,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 +227,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( <DxcSelect label="test-select-label" value={["1", "2"]} options={singleOptions} disabled searchable multiple /> @@ -248,7 +236,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 +252,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 +273,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 +304,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 +318,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( <DxcSelect label="test-select-label" options={singleOptions} /> @@ -352,7 +335,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(<DxcSelect label="test-select-label" options={[]} />); const select = getByRole("combobox"); @@ -360,7 +342,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 +360,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 +387,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( <DxcSelect @@ -430,7 +409,6 @@ describe("Select component tests", () => { 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(<DxcSelect label="test-select-label" options={singleOptions} />); const select = getByRole("combobox"); @@ -438,7 +416,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(<DxcSelect label="test-select-label" options={singleOptions} />); const select = getByRole("combobox"); @@ -447,7 +424,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(<DxcSelect label="test-select-label" options={singleOptions} />); const select = getByRole("combobox"); @@ -455,7 +431,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(<DxcSelect label="test-select-label" options={singleOptions} />); const select = getByRole("combobox"); @@ -464,7 +439,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 +457,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 +476,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 +488,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 +508,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 +521,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 +533,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 +548,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 +570,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 +594,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 +615,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( <DxcSelect label="test-select-label" options={singleOptions} /> @@ -672,7 +636,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( <DxcSelect label="test-select-label" options={singleOptions} /> @@ -697,14 +660,13 @@ 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( <DxcSelect label="test-select-label" options={groupOptions} /> ); 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(); @@ -712,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); @@ -720,10 +682,9 @@ 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 () => { const { getByRole, queryByRole } = render( <DxcSelect @@ -738,10 +699,9 @@ 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 () => { const onChange = jest.fn(); const { getByText, getByRole, getAllByRole, queryByRole, container } = render( @@ -753,14 +713,13 @@ 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"); 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 +747,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( <DxcSelect @@ -809,41 +767,36 @@ describe("Select component tests", () => { 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 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", () => { const { getByRole, queryByRole } = render(<DxcSelect label="test-select-label" options={groupOptions} />); 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(<DxcSelect label="test-select-label" options={groupOptions} />); 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", () => { const { getByRole, queryByRole } = render(<DxcSelect label="test-select-label" options={groupOptions} />); 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 () => { const onChange = jest.fn(); const { getByText, getByRole, getAllByRole, queryByRole } = render( @@ -856,13 +809,12 @@ 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"); 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( @@ -871,7 +823,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); @@ -880,14 +832,13 @@ 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); 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( @@ -896,11 +847,10 @@ 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(); }); - 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( @@ -912,17 +862,16 @@ 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"); }); - test("Grouped Options: Multiple selection - Clear action and selection indicator", async () => { const onChange = jest.fn(); const { getByText, queryByText, getByRole, getAllByRole, queryByRole } = render( @@ -936,18 +885,17 @@ 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(); }); - 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 +917,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( <DxcSelect label="test-select-label" options={groupOptions} /> @@ -991,7 +938,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( <DxcSelect label="test-select-label" options={groupOptions} /> @@ -1014,7 +960,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 +977,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..40693d4220 100644 --- a/packages/lib/src/select/Select.tsx +++ b/packages/lib/src/select/Select.tsx @@ -12,67 +12,256 @@ 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, getSelectedOptionLabel, groupsHaveOptions, - isArrayOfOptionGroups, + isArrayOfGroupedOptions, 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] : ""}; +`; + +const Label = styled.label<{ + disabled: SelectPropsType["disabled"]; + helperText: SelectPropsType["helperText"]; +}>` + color: ${({ disabled }) => (disabled ? "var(--color-fg-neutral-medium)" : "var(--color-fg-neutral-dark)")}; + font-family: var(--typography-font-family); + font-size: var(--typography-label-m); + font-weight: var(--typography-label-semibold); + ${({ helperText }) => !helperText && "margin-bottom: var(--spacing-gap-xs);"} + + /* Optional text */ + > span { + color: ${({ disabled }) => (disabled ? "var(--color-fg-neutral-medium)" : "var(--color-fg-neutral-stronger)")}; + font-weight: var(--typography-label-regular); + } +`; + +const HelperText = styled.span<{ disabled: SelectPropsType["disabled"] }>` + margin-bottom: var(--spacing-gap-xs); + color: ${({ disabled }) => (disabled ? "var(--color-fg-neutral-medium)" : "var(--color-fg-neutral-stronger)")}; + font-family: var(--typography-font-family); + font-size: var(--typography-helper-text-s); + font-weight: var(--typography-helper-text-regular); +`; + +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); + ${({ disabled, error }) => + error && !disabled && "border: var(--border-width-m) var(--border-style-default) var(--border-color-error-medium);"} + + ${({ disabled, error }) => + !disabled + ? ` + cursor: pointer; + &:hover { + border-color: ${error ? "var(--border-color-error-strong)" : "var(--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: ${({ disabled }) => (disabled ? "var(--color-fg-neutral-medium)" : "var(--color-fg-neutral-dark)")}; + font-size: var(--height-xxs); + } +`; + +const SelectionIndicator = styled.div<{ disabled: SelectPropsType["disabled"] }>` + 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) + ${({ disabled }) => (disabled ? "var(--border-color-neutral-strong)" : "var(--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) + ${({ disabled }) => (disabled ? "var(--border-color-neutral-medium)" : "var(--border-color-neutral-light)")}; + color: ${({ disabled }) => (disabled ? "var(--color-fg-neutral-medium)" : "var(--color-fg-neutral-dark)")}; + font-size: var(--typography-label-s); + font-weight: var(--typography-label-regular); + text-align: center; + user-select: none; + ${({ disabled }) => (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 { + outline: none; + } + ${({ disabled }) => + !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; + overflow: hidden; + text-overflow: ellipsis; + white-space: pre; +`; + +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<RefType, SelectPropsType>( ( { - 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 selectId = `select-${useId()}`; - const selectLabelId = `label-${selectId}`; - const errorId = `error-${selectId}`; - const listboxId = `${selectId}-listbox`; + ) => { + const id = `select-${useId()}`; + const errorId = `error-${id}`; + const labelId = `label-${id}`; + const listboxId = `${id}-listbox`; + const selectInputId = `select-input-${id}`; + + 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<HTMLDivElement | null>(null); const selectSearchInputRef = useRef<HTMLInputElement | null>(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 +273,7 @@ const DxcSelect = forwardRef<RefType, SelectPropsType>( ); const openListbox = () => { - if (!isOpen && canOpenOptions(options, disabled)) { + if (!isOpen && canOpenListbox(options, disabled)) { changeIsOpen(true); } }; @@ -95,7 +284,7 @@ const DxcSelect = forwardRef<RefType, SelectPropsType>( } }; - const handleSelectChangeValue = useCallback( + const handleOnChangeValue = useCallback( (newOption: ListOptionType | undefined) => { if (newOption) { let newValue: string | string[]; @@ -120,8 +309,7 @@ const DxcSelect = forwardRef<RefType, SelectPropsType>( }, [multiple, value, innerValue, onChange, optional, translatedLabels] ); - - const handleSelectOnClick = () => { + const handleOnClick = () => { if (searchable) { selectSearchInputRef?.current?.focus(); } @@ -132,12 +320,12 @@ const DxcSelect = forwardRef<RefType, SelectPropsType>( openListbox(); } }; - const handleSelectOnFocus = (event: FocusEvent<HTMLInputElement>) => { + const handleOnFocus = (event: FocusEvent<HTMLInputElement>) => { if (!event.currentTarget.contains(event.relatedTarget) && searchable) { selectSearchInputRef?.current?.focus(); } }; - const handleSelectOnBlur = (event: FocusEvent<HTMLInputElement>) => { + const handleOnBlur = (event: FocusEvent<HTMLInputElement>) => { if (!event.currentTarget.contains(event.relatedTarget)) { closeListbox(); setSearchValue(""); @@ -153,7 +341,7 @@ const DxcSelect = forwardRef<RefType, SelectPropsType>( } } }; - const handleSelectOnKeyDown = (event: KeyboardEvent<HTMLDivElement>) => { + const handleOnKeyDown = (event: KeyboardEvent<HTMLDivElement>) => { switch (event.key) { case "Down": case "ArrowDown": @@ -207,35 +395,35 @@ const DxcSelect = forwardRef<RefType, SelectPropsType>( if (searchable) { if (filteredOptions.length > 0) { if (optional && !multiple && visualFocusIndex === 0 && groupsHaveOptions(filteredOptions)) { - handleSelectChangeValue(optionalItem); - } else if (isArrayOfOptionGroups(filteredOptions)) { + handleOnChangeValue(optionalItem); + } else if (isArrayOfGroupedOptions(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); - } else if (isArrayOfOptionGroups(options)) { + handleOnChangeValue(optionalItem); + } else if (isArrayOfGroupedOptions(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 +435,10 @@ const DxcSelect = forwardRef<RefType, SelectPropsType>( break; } }; + const handleOnMouseEnter = (event: MouseEvent<HTMLSpanElement>) => { + const text = event.currentTarget; + setHasTooltip(text.scrollWidth > text.clientWidth); + }; const handleSearchIOnChange = (event: ChangeEvent<HTMLInputElement>) => { setSearchValue(event.target.value); @@ -256,16 +448,18 @@ const DxcSelect = forwardRef<RefType, SelectPropsType>( const handleClearOptionsActionOnClick = (event: MouseEvent<HTMLButtonElement>) => { event.stopPropagation(); + + const empty: string[] = []; if (value == null) { - setInnerValue([]); + setInnerValue(empty); } if (!optional) { onChange?.({ - value: [] as string[] as string & string[], + value: empty as string & string[], error: translatedLabels.formFields.requiredValueErrorMessage, }); } else { - onChange?.({ value: [] as string[] as string & string[] }); + onChange?.({ value: empty as string & string[] }); } }; @@ -276,435 +470,165 @@ const DxcSelect = forwardRef<RefType, SelectPropsType>( const handleOptionOnClick = useCallback( (option: ListOptionType) => { - handleSelectChangeValue(option); + handleOnChangeValue(option); if (!multiple) { closeListbox(); } setSearchValue(""); }, - [handleSelectChangeValue, closeListbox, multiple] + [handleOnChangeValue, closeListbox, multiple] ); - const handleOnMouseEnter = (event: MouseEvent<HTMLSpanElement>) => { - const text = event.currentTarget; - setHasTooltip(text.scrollWidth > text.clientWidth); - }; - return ( - <ThemeProvider theme={colorsTheme.select}> - <SelectContainer margin={margin} size={size} ref={ref}> - {label && ( - <Label - id={selectLabelId} + <SelectContainer margin={margin} size={size} ref={ref}> + {label && ( + <Label + id={labelId} + disabled={disabled} + onClick={() => { + selectRef?.current?.focus(); + }} + helperText={helperText} + > + {label} {optional && <span>{translatedLabels.formFields.optionalLabel}</span>} + </Label> + )} + {helperText && <HelperText disabled={disabled}>{helperText}</HelperText>} + <Popover.Root open={isOpen}> + <Popover.Trigger asChild type={undefined}> + <Select + aria-activedescendant={visualFocusIndex >= 0 ? `option-${visualFocusIndex}` : undefined} + aria-controls={isOpen ? listboxId : undefined} + aria-disabled={disabled} + aria-errormessage={error ? errorId : undefined} + aria-expanded={isOpen} + aria-haspopup="listbox" + aria-invalid={!!error} + aria-label={label ? undefined : ariaLabel} + aria-labelledby={label ? labelId : undefined} + aria-required={!disabled && !optional} disabled={disabled} - onClick={() => { - selectRef?.current?.focus(); - }} - helperText={helperText} + error={error} + id={selectInputId} + onBlur={handleOnBlur} + onClick={handleOnClick} + onFocus={handleOnFocus} + onKeyDown={handleOnKeyDown} + ref={selectRef} + role="combobox" + tabIndex={disabled ? -1 : tabIndex} > - {label} {optional && <OptionalLabel>{translatedLabels.formFields.optionalLabel}</OptionalLabel>} - </Label> - )} - {helperText && <HelperText disabled={disabled}>{helperText}</HelperText>} - <Popover.Root open={isOpen}> - <Popover.Trigger asChild type={undefined}> - <Select - id={selectId} - disabled={disabled} - error={error} - onBlur={handleSelectOnBlur} - onClick={handleSelectOnClick} - onFocus={handleSelectOnFocus} - onKeyDown={handleSelectOnKeyDown} - ref={selectRef} - tabIndex={disabled ? -1 : tabIndex} - role="combobox" - aria-controls={isOpen ? listboxId : undefined} - aria-disabled={disabled} - aria-expanded={isOpen} - aria-haspopup="listbox" - aria-labelledby={label ? selectLabelId : undefined} - aria-activedescendant={visualFocusIndex >= 0 ? `option-${visualFocusIndex}` : undefined} - aria-invalid={!!error} - aria-errormessage={error ? errorId : undefined} - aria-required={!disabled && !optional} - aria-label={label ? undefined : ariaLabel} - > - {multiple && Array.isArray(selectedOption) && selectedOption.length > 0 && ( - <SelectionIndicator> - <SelectionNumber disabled={disabled}>{selectedOption.length}</SelectionNumber> - <Tooltip label={translatedLabels.select.actionClearSelectionTitle}> - <ClearOptionsAction - disabled={disabled} - onMouseDown={(event) => { - // Avoid input to lose focus when pressed - event.preventDefault(); - }} - onClick={handleClearOptionsActionOnClick} - tabIndex={-1} - aria-label={translatedLabels.select.actionClearSelectionTitle} - > - <DxcIcon icon="clear" /> - </ClearOptionsAction> - </Tooltip> - </SelectionIndicator> - )} - <TooltipWrapper condition={hasTooltip} label={getSelectedOptionLabel(placeholder, selectedOption)}> - <SearchableValueContainer> - <input - style={{ display: "none" }} - name={name} - disabled={disabled} - value={ - multiple - ? (Array.isArray(value) ? value : Array.isArray(innerValue) ? innerValue : []).join(",") - : (value ?? innerValue) - } - readOnly - aria-hidden="true" - /> - {searchable && ( - <SearchInput - value={searchValue} - disabled={disabled} - onChange={handleSearchIOnChange} - ref={selectSearchInputRef} - autoComplete="nope" - autoCorrect="nope" - size={1} - aria-labelledby={label ? selectLabelId : undefined} - /> - )} - {(!searchable || searchValue === "") && ( - <SelectedOption - disabled={disabled} - atBackground={ - (multiple ? (value ?? innerValue).length === 0 : !(value ?? innerValue)) || - (searchable && isOpen) - } - > - <SelectedOptionLabel onMouseEnter={handleOnMouseEnter}> - {getSelectedOptionLabel(placeholder, selectedOption)} - </SelectedOptionLabel> - </SelectedOption> - )} - </SearchableValueContainer> - </TooltipWrapper> - {!disabled && error && ( - <ErrorIcon> - <DxcIcon icon="filled_error" /> - </ErrorIcon> - )} - {searchable && searchValue.length > 0 && ( + {multiple && Array.isArray(selectedOption) && selectedOption.length > 0 && ( + <SelectionIndicator disabled={disabled}> + <SelectionNumber disabled={disabled}>{selectedOption.length}</SelectionNumber> <Tooltip label={translatedLabels.select.actionClearSelectionTitle}> - <ClearSearchAction + <ClearOptionsAction + disabled={disabled} onMouseDown={(event) => { - // Avoid input to lose focus + // Avoid input to lose focus when pressed event.preventDefault(); }} - onClick={handleClearSearchActionOnClick} + onClick={handleClearOptionsActionOnClick} tabIndex={-1} - aria-label={translatedLabels.select.actionClearSearchTitle} + aria-label={translatedLabels.select.actionClearSelectionTitle} > <DxcIcon icon="clear" /> - </ClearSearchAction> + </ClearOptionsAction> + </Tooltip> + </SelectionIndicator> + )} + <TooltipWrapper condition={hasTooltip} label={getSelectedOptionLabel(placeholder, selectedOption)}> + <SearchableValueContainer> + <input + type="hidden" + name={name} + disabled={disabled} + value={ + multiple + ? (Array.isArray(value) ? value : Array.isArray(innerValue) ? innerValue : []).join(",") + : (value ?? innerValue) + } + /> + {searchable && ( + <SearchInput + value={searchValue} + disabled={disabled} + onChange={handleSearchIOnChange} + ref={selectSearchInputRef} + autoComplete="nope" + autoCorrect="nope" + size={1} + aria-labelledby={label ? labelId : undefined} + /> + )} + {(!searchable || searchValue === "") && ( + <SelectedOption + disabled={disabled} + atBackground={ + (multiple ? (value ?? innerValue).length === 0 : !(value ?? innerValue)) || + (searchable && isOpen) + } + onMouseEnter={handleOnMouseEnter} + > + {getSelectedOptionLabel(placeholder, selectedOption)} + </SelectedOption> + )} + </SearchableValueContainer> + </TooltipWrapper> + <DxcFlex alignItems="center"> + {searchable && searchValue.length > 0 && ( + <Tooltip label={translatedLabels.select.actionClearSelectionTitle}> + <DxcActionIcon + icon="clear" + onClick={handleClearSearchActionOnClick} + tabIndex={-1} + title={translatedLabels.select.actionClearSearchTitle} + /> </Tooltip> )} - <CollapseIndicator disabled={disabled}> - <DxcIcon icon={isOpen ? "keyboard_arrow_up" : "keyboard_arrow_down"} /> - </CollapseIndicator> - </Select> - </Popover.Trigger> - <Popover.Portal> - <Popover.Content - sideOffset={4} - style={{ zIndex: "2147483647" }} - onOpenAutoFocus={(event) => { - // 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(); - }} - > - <Listbox - id={listboxId} - currentValue={value ?? innerValue} - options={searchable ? filteredOptions : options} - visualFocusIndex={visualFocusIndex} - lastOptionIndex={lastOptionIndex} - multiple={multiple} - optional={optional} - optionalItem={optionalItem} - searchable={searchable} - handleOptionOnClick={handleOptionOnClick} - styles={{ width }} - /> - </Popover.Content> - </Popover.Portal> - </Popover.Root> - {!disabled && typeof error === "string" && ( - <Error id={errorId} role="alert" aria-live={error ? "assertive" : "off"}> - {error} - </Error> - )} - </SelectContainer> - </ThemeProvider> + <DxcIcon icon={isOpen ? "keyboard_arrow_up" : "keyboard_arrow_down"} /> + </DxcFlex> + </Select> + </Popover.Trigger> + <Popover.Portal> + <Popover.Content + sideOffset={4} + style={{ zIndex: "2147483647" }} + onOpenAutoFocus={(event) => { + // 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(); + }} + > + <Listbox + ariaLabelledBy={labelId} + id={listboxId} + currentValue={value ?? innerValue} + options={searchable ? filteredOptions : options} + visualFocusIndex={visualFocusIndex} + lastOptionIndex={lastOptionIndex} + multiple={multiple} + optional={optional} + optionalItem={optionalItem} + searchable={searchable} + handleOptionOnClick={handleOptionOnClick} + styles={{ width }} + /> + </Popover.Content> + </Popover.Portal> + </Popover.Root> + {!disabled && typeof error === "string" && ( + <Error id={errorId} role="alert" aria-live={error ? "assertive" : "off"}> + {error && <DxcIcon icon="filled_error" />} + {error} + </Error> + )} + </SelectContainer> ); } ); -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/types.ts b/packages/lib/src/select/types.ts index 850746167e..873a1ca444 100644 --- a/packages/lib/src/select/types.ts +++ b/packages/lib/src/select/types.ts @@ -11,6 +11,7 @@ export type ListOptionGroupType = { */ options: ListOptionType[]; }; + export type ListOptionType = { /** * Element used as the icon that will be placed before the option label. @@ -130,6 +131,7 @@ type SingleSelect = CommonProps & { */ onBlur?: (val: { value: string; error?: string }) => void; }; + type MultipleSelect = CommonProps & { /** * If true, the select component will support multiple selected options. @@ -181,6 +183,7 @@ export type OptionProps = { * Listbox from the select component. */ export type ListboxProps = { + ariaLabelledBy: string; id: string; currentValue: string | string[]; options: ListOptionType[] | ListOptionGroupType[]; diff --git a/packages/lib/src/select/utils.ts b/packages/lib/src/select/utils.ts index 0b341504f7..6234230b58 100644 --- a/packages/lib/src/select/utils.ts +++ b/packages/lib/src/select/utils.ts @@ -1,63 +1,77 @@ -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 === ""); /** - * 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) */ -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. */ -const groupsHaveOptions = (options: ListOptionType[] | ListOptionGroupType[]) => - isArrayOfOptionGroups(options) ? options.some((groupOption) => groupOption.options.length > 0) : true; +export const groupsHaveOptions = (options: ListOptionType[] | ListOptionGroupType[]) => + 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. */ -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[] => { - 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. */ -const getLastOptionIndex = ( +export const getLastOptionIndex = ( options: ListOptionType[] | ListOptionGroupType[], filteredOptions: ListOptionType[] | ListOptionGroupType[], searchable: boolean, @@ -68,13 +82,13 @@ 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; @@ -87,7 +101,7 @@ const getLastOptionIndex = ( /** * Return the current selection. */ -const getSelectedOption = ( +export const getSelectedOption = ( value: string | string[], options: ListOptionType[] | ListOptionGroupType[], multiple: boolean, @@ -145,21 +159,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, -};