diff --git a/apps/website/pages/components/radio-group/code.tsx b/apps/website/pages/components/radio-group/code.tsx new file mode 100644 index 0000000000..7500e86777 --- /dev/null +++ b/apps/website/pages/components/radio-group/code.tsx @@ -0,0 +1,17 @@ +import Head from "next/head"; +import type { ReactElement } from "react"; +import RadioGroupPageLayout from "screens/components/radio-group/RadioGroupPageLayout"; +import RadioGroupCodePage from "screens/components/radio-group/code/RadioGroupCodePage"; + +const Code = () => ( + <> + + Radio group code — Halstack Design System + + + +); + +Code.getLayout = (page: ReactElement) => {page}; + +export default Code; diff --git a/apps/website/pages/components/radio-group/index.tsx b/apps/website/pages/components/radio-group/index.tsx index 766ab409f2..4de6d90268 100644 --- a/apps/website/pages/components/radio-group/index.tsx +++ b/apps/website/pages/components/radio-group/index.tsx @@ -1,21 +1,17 @@ import Head from "next/head"; import type { ReactElement } from "react"; import RadioGroupPageLayout from "screens/components/radio-group/RadioGroupPageLayout"; -import RadioGroupCodePage from "screens/components/radio-group/code/RadioGroupCodePage"; +import RadioGroupOverviewPage from "screens/components/radio-group/overview/RadioGroupOverviewPage"; -const Index = () => { - return ( - <> - - Radio Group — Halstack Design System - - - - ); -}; +const Index = () => ( + <> + + Radio group — 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/radio-group/specifications.tsx b/apps/website/pages/components/radio-group/specifications.tsx deleted file mode 100644 index 7738a750f5..0000000000 --- a/apps/website/pages/components/radio-group/specifications.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import Head from "next/head"; -import type { ReactElement } from "react"; -import RadioGroupPageLayout from "screens/components/radio-group/RadioGroupPageLayout"; -import RadioGroupSpecsPage from "screens/components/radio-group/specs/RadioGroupSpecsPage"; - -const Specifications = () => { - return ( - <> - - Radio Group Specs — Halstack Design System - - - - ); -}; - -Specifications.getLayout = function getLayout(page: ReactElement) { - return {page}; -}; - -export default Specifications; diff --git a/apps/website/pages/components/radio-group/usage.tsx b/apps/website/pages/components/radio-group/usage.tsx deleted file mode 100644 index 4cb0df574e..0000000000 --- a/apps/website/pages/components/radio-group/usage.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import Head from "next/head"; -import type { ReactElement } from "react"; -import RadioGroupPageLayout from "screens/components/radio-group/RadioGroupPageLayout"; -import RadioGroupUsagePage from "screens/components/radio-group/usage/RadioGroupUsagePage"; - -const Usage = () => { - return ( - <> - - Radio Group Usage — Halstack Design System - - - - ); -}; - -Usage.getLayout = function getLayout(page: ReactElement) { - return {page}; -}; - -export default Usage; diff --git a/apps/website/screens/common/pagesList.ts b/apps/website/screens/common/pagesList.ts index 546dad4589..dbb8e5a0b1 100644 --- a/apps/website/screens/common/pagesList.ts +++ b/apps/website/screens/common/pagesList.ts @@ -68,9 +68,7 @@ const getCurrentLinkIndex = (links: LinkDetails[], currentPath: string) => { export const getNavigationLinks = (currentPath: string): NavigationLinks => { const links = LinksSections.flatMap((section) => section.links); const currentLinkIndex = getCurrentLinkIndex(links, currentPath); - if (currentLinkIndex === -1) { - return {}; - } + if (currentLinkIndex === -1) return {}; return { previousLink: currentLinkIndex + 1 < links.length ? links[currentLinkIndex + 1] : undefined, nextLink: currentLinkIndex - 1 >= 0 ? links[currentLinkIndex - 1] : undefined, diff --git a/apps/website/screens/components/dialog/overview/DialogOverviewPage.tsx b/apps/website/screens/components/dialog/overview/DialogOverviewPage.tsx index ec25f5966e..7301f2cb25 100644 --- a/apps/website/screens/components/dialog/overview/DialogOverviewPage.tsx +++ b/apps/website/screens/components/dialog/overview/DialogOverviewPage.tsx @@ -44,7 +44,7 @@ const sections = [ Actions: a set of buttons at the bottom of the dialog that guide users toward completing or - canceling the intended action. + cancelling the intended action. diff --git a/apps/website/screens/components/radio-group/RadioGroupPageLayout.tsx b/apps/website/screens/components/radio-group/RadioGroupPageLayout.tsx index 961904f34e..046ef4d367 100644 --- a/apps/website/screens/components/radio-group/RadioGroupPageLayout.tsx +++ b/apps/website/screens/components/radio-group/RadioGroupPageLayout.tsx @@ -6,20 +6,19 @@ import { ReactNode } from "react"; const RadioGroupPageHeading = ({ children }: { children: ReactNode }) => { const tabs = [ - { label: "Code", path: "/components/radio-group" }, - { label: "Usage", path: "/components/radio-group/usage" }, - { label: "Specifications", path: "/components/radio-group/specifications" }, + { label: "Overview", path: "/components/radio-group" }, + { label: "Code", path: "/components/radio-group/code" }, ]; return ( - + - A radio group let the user make a mutually exclusive selection from a group of options. + A radio group allows users to select one option from a set of related, mutually exclusive choices. - + {children} diff --git a/apps/website/screens/components/radio-group/code/RadioGroupCodePage.tsx b/apps/website/screens/components/radio-group/code/RadioGroupCodePage.tsx index 20541fdb37..b16048b3c0 100644 --- a/apps/website/screens/components/radio-group/code/RadioGroupCodePage.tsx +++ b/apps/website/screens/components/radio-group/code/RadioGroupCodePage.tsx @@ -24,6 +24,16 @@ const sections = [ + + ariaLabel + + string + + Specifies a string to be used as the name for the radio group when no `label` is provided. + + 'Radio group' + + defaultValue @@ -33,14 +43,34 @@ const sections = [ - - value + disabled + + boolean + + If true, the component will be disabled. + + false + + + + error string - Value of the radio group. If undefined, the component will be uncontrolled and the value will be managed - internally by the component. + If it is a defined value and also a truthy string, the component will change its appearance, showing the + error below the radio group. 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 radio group. - @@ -63,49 +93,26 @@ const sections = [ - - helperText - - string - - Helper text to be placed above the radio group. - - - - - - - - options - - + onBlur - {"{ value: string; label: string; disabled?: boolean; }[]"} + {"(val: { value?: string; error?: string }) => void"} - An array of objects representing the selectable options. Each object Option has the following properties: -
    -
  • - label: Label of the option placed next to the radio input. -
  • -
  • - value: Value of the option. It should be unique and not an empty string, which is reserved to - the optional item added by the optional prop. -
  • -
  • - disabled: disables the option. -
  • -
+ This function will be called when the radio group loses the focus. An object including the value and the + error will be passed to this function. If there is no error, error will not be defined. - - disabled + onChange - boolean + {"(value: string) => void"} - If true, the component will be disabled. - false + This function will be called when the user chooses an option. The new value will be passed to this + function. + - optional @@ -132,59 +139,59 @@ const sections = [ - readOnly - - boolean - - If true, the component will not be mutable, meaning the user can not edit the control. - false + + + options + - - - stacking - 'row' | 'column' + {"{ disabled?: boolean; label: string; value: string; }[]"} - Sets the orientation of the options within the radio group. - 'column' + An array of objects representing the selectable options. Each object Option has the following properties: +
    +
  • + label: Label of the option placed next to the radio input. +
  • +
  • + value: Value of the option. It should be unique and not an empty string, which is reserved to + the optional item added by the optional prop. +
  • +
  • + disabled: disables the option. +
  • +
+ - - onChange + readOnly - {"(value: string) => void"} + boolean + If true, the component will not be mutable, meaning the user can not edit the control. - This function will be called when the user chooses an option. The new value will be passed to this - function. + false - - - onBlur - - {"(val: { value?: string; error?: string }) => void"} - + ref - This function will be called when the radio group loses the focus. An object including the value and the - error will be passed to this function. If there is no error, error will not be defined. + {"React.Ref"} + Reference to the component. - - error + stacking - string + 'row' | 'column' + Sets the orientation of the options within the radio group. - If it is a defined value and also a truthy string, the component will change its appearance, showing the - error below the radio group. 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. + 'column' - - tabIndex @@ -199,20 +206,15 @@ const sections = [ - ref + value - {"React.Ref"} + string - Reference to the component. - - - - - ariaLabel - string + Value of the radio group. If undefined, the component will be uncontrolled and the value will be managed + internally by the component. - Specifies a string to be used as the name for the radio group when no `label` is provided. - 'Radio group' + - @@ -247,15 +249,13 @@ const sections = [ }, ]; -const RadioGroupCodePage = () => { - return ( - - - - - - - ); -}; +const RadioGroupCodePage = () => ( + + + + + + +); export default RadioGroupCodePage; diff --git a/apps/website/screens/components/radio-group/overview/RadioGroupOverviewPage.tsx b/apps/website/screens/components/radio-group/overview/RadioGroupOverviewPage.tsx new file mode 100644 index 0000000000..365833a937 --- /dev/null +++ b/apps/website/screens/components/radio-group/overview/RadioGroupOverviewPage.tsx @@ -0,0 +1,189 @@ +import { DxcBulletedList, DxcParagraph, DxcFlex, DxcTable, DxcLink } 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 stacking from "./examples/stacking"; +import HeaderDescriptionCell from "@/common/HeaderDescriptionCell"; +import Image from "@/common/Image"; +import anatomy from "./images/radio_group_anatomy.png"; +import Link from "next/link"; + +const sections = [ + { + title: "Introduction", + content: ( + + The radio group component allows users to select a single option from a predefined set of + choices. Each option is represented by a radio button, ensuring clear and mutually exclusive selection within + the group. This component is commonly used in forms, surveys, and settings where users need to make a definitive + choice. By organizing options in a structured manner, the radio group enhances usability and readability, + guiding users toward a straightforward decision-making process. + + ), + }, + { + title: "Anatomy", + content: ( + <> + Radio group's anatomy + + + Label (Optional): the main heading for the radio group. It clearly communicates + what decision the user is being asked to make. + + + Helper text (Optional): optional supporting text that provides additional context + or instructions to guide the user's choice. + + + Radio input: the circular selection control representing each option. Only one radio input + can be selected within the group at a time. + + + Radio input label: the descriptive text placed next to each radio input. It communicates + the meaning of the option and is always visible for clarity. + + + Error message: displayed below the group when validation fails. It explains what went wrong + and helps users correct their input. + + + + ), + }, + { + title: "Stacking radio buttons", + content: ( + <> + + Radio buttons can be arranged vertically or horizontally depending on the context, layout constraints, and + user experience considerations. + + + + ), + subSections: [ + { + title: "Vertical stacking", + content: ( + + Vertical stacking is the preferred layout for radio groups, especially in forms or when presenting more than + two options. It enhances readability by allowing users to scan and compare choices with + minimal cognitive load. This format supports accessibility and scales well with longer option labels. + + ), + }, + { + title: "Horizontal stacking", + content: ( + + Horizontal stacking is suitable when screen space is limited or when options are short and easily + distinguishable. This layout can reduce vertical scroll but should only be used when it + doesn't compromise readability. Always maintain visual grouping and alignment, and ensure that labels remain + clear and unambiguous. + + ), + }, + ], + }, + { + title: "Radio group vs. checkbox vs. switch", + content: ( + <> + + Although radio groups,{" "} + + checkboxes + + , and{" "} + + switches + {" "} + may appear as selection controls, they serve distinct purposes in a user interface: + + + + + Component + Use case + + + + + + Radio group + + + Use when the user must select only one option from a list of predefined, mutually + exclusive choices. Ideal for short, static lists where all options should be visible at once to support + decision-making. + + + + + Checkbox + + + Use when users can select multiple options independently. Each checkbox represents an + on/off decision, making them suitable for filters, preference settings, or multi-select tasks. A group + may allow none, some, or all options to be selected. + + + + + Switch + + + Use for a single, immediate toggle between two states, like on/off or enabled/disabled. + Switches should act instantly and are best for system or UI-level settings. + + + + + + ), + }, + { + title: "Best practices", + content: ( + + + Use for mutually exclusive choices: radio groups are best suited when users need to select{" "} + only one option from a predefined list. Avoid using them for multiple selections — checkboxes + are more appropriate in that case. + + + Keep option labels short and clear: use concise, descriptive labels so users can quickly + understand each choice. Avoid using long sentences or technical jargon, which can overwhelm the layout and + slow down decision-making. + + + Default to vertical layout for clarity: stacking options vertically improves readability and + makes scanning easier, especially when there are more than two options. Use horizontal layout only when space + is limited or options are very short and simple. + + + Group related options together: ensure all radio buttons in a group are logically related and + fall under the same question or decision point. Never separate radio buttons from their group label or helper + text — they should feel like a cohesive unit. + + + Handle errors gracefully: use validation to prevent submission without a selection if + required, and display clear, specific error messages. + + + ), + }, +]; + +const RadioGroupOverviewPage = () => ( + + + + + + +); + +export default RadioGroupOverviewPage; diff --git a/apps/website/screens/components/radio-group/usage/examples/stacking.ts b/apps/website/screens/components/radio-group/overview/examples/stacking.ts similarity index 100% rename from apps/website/screens/components/radio-group/usage/examples/stacking.ts rename to apps/website/screens/components/radio-group/overview/examples/stacking.ts diff --git a/apps/website/screens/components/radio-group/overview/images/radio_group_anatomy.png b/apps/website/screens/components/radio-group/overview/images/radio_group_anatomy.png new file mode 100644 index 0000000000..e2f09b8c0d Binary files /dev/null and b/apps/website/screens/components/radio-group/overview/images/radio_group_anatomy.png differ diff --git a/apps/website/screens/components/radio-group/specs/RadioGroupSpecsPage.tsx b/apps/website/screens/components/radio-group/specs/RadioGroupSpecsPage.tsx deleted file mode 100644 index 2bd61eaeb6..0000000000 --- a/apps/website/screens/components/radio-group/specs/RadioGroupSpecsPage.tsx +++ /dev/null @@ -1,733 +0,0 @@ -import { DxcBulletedList, DxcParagraph, DxcFlex, DxcTable, DxcLink } from "@dxc-technology/halstack-react"; -import DocFooter from "@/common/DocFooter"; -import Figure from "@/common/Figure"; -import QuickNavContainer from "@/common/QuickNavContainer"; -import QuickNavContainerLayout from "@/common/QuickNavContainerLayout"; -import Image from "@/common/Image"; -import Code from "@/common/Code"; -import radioGroupBaseStates from "./images/radio_group_base_states.png"; -import radioGroupStates from "./images/radio_group_states.png"; -import radioGroupAnatomy from "./images/radio_group_anatomy.png"; -import radioGroupSpecs from "./images/radio_group_specs.png"; - -const sections = [ - { - title: "Specifications", - content: ( -
- Radio group design specifications -
- ), - }, - { - title: "States", - content: ( - <> - - The following states are defined in the life cycle of the component: unselected enabled,{" "} - unselected hover, unselected focus, unselected disabled,{" "} - selected enabled, selected hover, selected focus and{" "} - selected disabled. - -
- Radio button states -
- - The following states are defined in the life cycle of the component: enabled,{" "} - error, readOnly and disabled. - -
- Radio group states -
- - ), - }, - { - title: "Anatomy", - content: ( - <> - Radio group anatomy - - Label - Helper text - Radio input - Radio input label - Error message - - - ), - }, - { - title: "Design tokens", - subSections: [ - { - title: "Color", - content: ( - - - - Component token - Element - Core token - Value - - - - - - radioInputColor - - Radio input - - color-blue-700 - - #0086e6 - - - - hoverRadioInputColor - - Radio input:hover - - color-blue-800 - - #0067b3 - - - - focusBorderColor - - Radio input:focus - - color-blue-600 - - #0095ff - - - - activeRadioInputColor - - Radio input:active - - color-blue-900 - - #003c66 - - - - errorRadioInputColor - - Radio input:error - - color-red-700 - - #d0011b - - - - hoverErrorRadioInputColor - - Radio input:error:hover - - color-red-800 - - #980115 - - - - activeErrorRadioInputColor - - Radio input:error:active - - color-red-900 - - #65010e - - - - disabledRadioInputColor - - Radio input:disabled - - color-grey-500 - - #999999 - - - - readOnlyRadioInputColor - - Radio input:readonly - - color-grey-500 - - #999999 - - - - hoverReadOnlyRadioInputColor - - Radio input:readonly:hover - - color-grey-600 - - #808080 - - - - activeReadOnlyRadioInputColor - - Radio input:readonly:active - - color-grey-700 - - #666666 - - - - labelFontColor - - Label - - color-black - - #000000 - - - - disabledLabelFontColor - - Label:disabled - - color-grey-500 - - #999999 - - - - helperTextFontColor - - Helper text - - color-black - - #000000 - - - - disabledHelperTextFontColor - - Helper text:disabled - - color-grey-500 - - #999999 - - - - radioInputLabelFontColor - - Input label - - color-black - - #000000 - - - - disabledRadioInputLabelFontColor - - Input label:disabled - - color-grey-500 - - #999999 - - - - errorMessageColor - - Error message - - color-red-700 - - #d0011b - - - - ), - }, - { - title: "Typography", - content: ( - <> - - - - Component token - Element - Core token - Value - - - - - - fontFamily - - Label - - font-family-sans - - 'Open Sans', sans-serif - - - - labelFontSize - - Label - - font-scale-02 - - 0.875rem / 14px - - - - labelFontWeight - - Label - - font-weight-semibold - - 600 - - - - labelLineHeight - - Label - - font-leading-loose-01 - - 1.715em - - - - labelFontStyle - - Label - - font-style-normal - - normal - - - - 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 - - - - radioInputLabelFontSize - - Input label - - font-scale-02 - - 0.875rem / 14px - - - - radioInputLabelFontWeight - - Input label - - font-weight-regular - - 400 - - - - radioInputLabelFontStyle - - Input label - - font-style-normal - - normal - - - - radioInputLabelLineHeight - - Helper text - - font-leading-loose-01 - - 1.715em - - - - - - - Property - Element - Core token - Value - - - - - - font-size - - Error message - - font-scale-01 - - 0.75rem / 12px - - - - font-weight - - Error message - - font-weight-regular - - 400 - - - - line-height - - Error message - - font-leading-normal - - 1.5em - - - - - ), - }, - { - title: "Border", - content: ( - - - - Property - Element - Core token - Value - - - - - - border-width - - Radio input - - border-width-2 - - 2px - - - - border-style - - Radio input - - border-style-solid - - solid - - - - border-width - - Focus border - - border-width-2 - - 2px - - - - border-style - - Focus border - - border-style-solid - - solid - - - - ), - }, - { - title: "Spacing", - content: ( - <> - - - - Component token - Element - Core token - Value - - - - - - groupLabelMargin - - Label/Helper text - - spacing-8 - - 0.5rem / 8px - - - - radioInputLabelMargin - - Input Label - - spacing-8 - - 0.5rem / 8px - - - - groupVerticalGutter - - Radio item* - - spacing-4 - - 0.25rem / 4px - - - - groupHorizontalGutter - - Radio item - - spacing-32 - - 2rem / 32px - - - - (*) Radio item = Radio input + Radio label - - ), - }, - { - title: "Size", - content: ( - - - - Property - Element - Value - - - - - - width - - Radio input - 18px - - - - width - - focus outline - 24px - - - - height - - Radio input - 18px - - - - height - - focus outline - 24px - - - - ), - }, - { - title: "Margin", - content: ( - <> - - Margin can be set independently for top, right, bottom, and{" "} - left. - - - - - Margin - Value - - - - - - xxsmall - - 6px - - - - xsmall - - 16px - - - - small - - 24px - - - - medium - - 36px - - - - large - - 48px - - - - xlarge - - 64px - - - - xxlarge - - 100px - - - - - ), - }, - ], - }, - { - title: "Accessibility", - subSections: [ - { - title: "WCAG 2.2", - content: ( - - - Understanding WCAG 2.2 -{" "} - - SC 1.3.1: Info and Relationships - - - - Understanding WCAG 2.2 -{" "} - - SC 3.3.2: Labels or Instructions - - - - Understanding WCAG 2.2 -{" "} - - SC 2.4.6: Headings and Labels - - - - ), - }, - { - title: "WAI-ARIA 1.2", - content: ( - - - WAI-ARIA Authoring Practices 1.2 -{" "} - - 3.12 Radio group - - - - ), - }, - ], - }, -]; - -const RadioGroupSpecsPage = () => { - return ( - - - - - - - ); -}; - -export default RadioGroupSpecsPage; diff --git a/apps/website/screens/components/radio-group/specs/images/radio_group_anatomy.png b/apps/website/screens/components/radio-group/specs/images/radio_group_anatomy.png deleted file mode 100644 index 418fc6265b..0000000000 Binary files a/apps/website/screens/components/radio-group/specs/images/radio_group_anatomy.png and /dev/null differ diff --git a/apps/website/screens/components/radio-group/specs/images/radio_group_base_states.png b/apps/website/screens/components/radio-group/specs/images/radio_group_base_states.png deleted file mode 100644 index 093b4d1568..0000000000 Binary files a/apps/website/screens/components/radio-group/specs/images/radio_group_base_states.png and /dev/null differ diff --git a/apps/website/screens/components/radio-group/specs/images/radio_group_specs.png b/apps/website/screens/components/radio-group/specs/images/radio_group_specs.png deleted file mode 100644 index 432ff4efc7..0000000000 Binary files a/apps/website/screens/components/radio-group/specs/images/radio_group_specs.png and /dev/null differ diff --git a/apps/website/screens/components/radio-group/specs/images/radio_group_states.png b/apps/website/screens/components/radio-group/specs/images/radio_group_states.png deleted file mode 100644 index f0fb9ec003..0000000000 Binary files a/apps/website/screens/components/radio-group/specs/images/radio_group_states.png and /dev/null differ diff --git a/apps/website/screens/components/radio-group/usage/RadioGroupUsagePage.tsx b/apps/website/screens/components/radio-group/usage/RadioGroupUsagePage.tsx deleted file mode 100644 index 86b36de0d7..0000000000 --- a/apps/website/screens/components/radio-group/usage/RadioGroupUsagePage.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { DxcBulletedList, DxcParagraph, 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 stacking from "./examples/stacking"; -import HeaderDescriptionCell from "@/common/HeaderDescriptionCell"; - -const sections = [ - { - title: "Usage", - content: ( - - - Labelling should be concise and clearly differentiated from other options. - - - One option of the radio group can be pre-selected. Select the safest or convenient option. - - Single radio button should not be used. - - If the question that the user needs to respond is as easier as yes/no, it is recommended to use a checkbox - instead of radio group. - - - ), - }, - { - title: "Stacking", - content: ( - <> - - - - - Name - Description - - - - - - Vertical - - - Short lists of radio buttons should be stacked vertically below a descriptive label to better associate - the group. Options that are listed vertically are easier to read. - - - - - Horizontal - - - Multiple radio buttons may be displayed horizontally across the page while keeping them aligned within - their respective columns. Here, it is needed to have in consideration that the linear radio buttons - represent some challenge, because it's difficult to scan and localize. - - - - - - *In any case, in the specification it is specified the ideal distance between component with label in the same - horizontal edge to avoid the problem of pairing and scalability. - - - ), - }, -]; - -const RadioGroupUsagePage = () => { - return ( - - - - - - - ); -}; - -export default RadioGroupUsagePage; diff --git a/packages/lib/src/radio-group/Radio.tsx b/packages/lib/src/radio-group/Radio.tsx deleted file mode 100644 index 3b8580601e..0000000000 --- a/packages/lib/src/radio-group/Radio.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import { memo, useContext, useEffect, useId, useRef, useState } from "react"; -import styled, { ThemeProvider } from "styled-components"; -import { AdvancedTheme } from "../common/variables"; -import DxcFlex from "../flex/Flex"; -import HalstackContext from "../HalstackContext"; -import { RadioProps } from "./types"; - -const DxcRadio = ({ - label, - checked, - onClick, - error, - disabled, - focused, - readOnly, - tabIndex, -}: RadioProps): JSX.Element => { - const radioLabelId = `radio-${useId()}`; - const ref = useRef(null); - const colorsTheme = useContext(HalstackContext); - - const handleOnClick = () => { - onClick(); - document.activeElement !== ref.current && ref.current?.focus(); - }; - - const [firstUpdate, setFirstUpdate] = useState(true); - useEffect(() => { - // Don't apply in the first render - if (firstUpdate) { - setFirstUpdate(false); - return; - } - focused && ref.current?.focus(); - }, [focused]); - - return ( - - - - - - {checked && } - - - - - - - ); -}; - -type CommonStylingProps = { - error: RadioProps["error"]; - disabled: RadioProps["disabled"]; - readOnly: RadioProps["readOnly"]; -}; -const getRadioInputStateColor = ( - props: CommonStylingProps & { theme: AdvancedTheme["radioGroup"] }, - state: "enabled" | "hover" | "active" -) => { - switch (state) { - case "enabled": - return props.disabled - ? props.theme.disabledRadioInputColor - : props.error - ? props.theme.errorRadioInputColor - : props.readOnly - ? props.theme.readOnlyRadioInputColor - : props.theme.radioInputColor; - case "hover": - return props.error - ? props.theme.hoverErrorRadioInputColor - : props.readOnly - ? props.theme.hoverReadOnlyRadioInputColor - : props.theme.hoverRadioInputColor; - case "active": - return props.error - ? props.theme.activeErrorRadioInputColor - : props.readOnly - ? props.theme.activeReadOnlyRadioInputColor - : props.theme.activeRadioInputColor; - } -}; - -const RadioInputContainer = styled.span` - display: flex; - align-items: center; - justify-content: center; - height: 24px; - width: 24px; -`; - -const RadioInput = styled.span` - box-sizing: border-box; - display: flex; - align-items: center; - justify-content: center; - width: 18px; - height: 18px; - border: 2px solid ${(props) => getRadioInputStateColor(props, "enabled")}; - border-radius: 50%; - - &:focus { - outline: 2px solid ${(props) => props.theme.focusBorderColor}; - outline-offset: 1px; - } - ${(props) => props.disabled && "pointer-events: none;"} -`; - -const Dot = styled.span` - height: 10px; - width: 10px; - border-radius: 50%; - background-color: ${(props) => getRadioInputStateColor(props, "enabled")}; -`; - -const Label = styled.span<{ disabled: RadioProps["disabled"] }>` - margin-left: ${(props) => props.theme.radioInputLabelMargin}; - font-family: ${(props) => props.theme.fontFamily}; - font-size: ${(props) => props.theme.radioInputLabelFontSize}; - font-style: ${(props) => props.theme.radioInputLabelFontStyle}; - font-weight: ${(props) => props.theme.radioInputLabelFontWeight}; - line-height: ${(props) => props.theme.radioInputLabelLineHeight}; - ${(props) => - props.disabled - ? `color: ${props.theme.disabledRadioInputLabelFontColor};` - : `color: ${props.theme.radioInputLabelFontColor}`} -`; - -const RadioContainer = styled.span` - display: inline-flex; - align-items: center; - cursor: ${(props) => (props.disabled ? "not-allowed" : props.readOnly ? "default" : "pointer")}; - - &:hover { - ${RadioInput} { - border-color: ${(props) => !props.disabled && getRadioInputStateColor(props, "hover")}; - } - ${Dot} { - background-color: ${(props) => !props.disabled && getRadioInputStateColor(props, "hover")}; - } - } - &:active { - ${RadioInput} { - border-color: ${(props) => !props.disabled && getRadioInputStateColor(props, "active")}; - } - ${Dot} { - background-color: ${(props) => !props.disabled && getRadioInputStateColor(props, "active")}; - } - } -`; - -export default memo(DxcRadio); diff --git a/packages/lib/src/radio-group/RadioGroup.stories.tsx b/packages/lib/src/radio-group/RadioGroup.stories.tsx index 82d7356921..ab8a93d340 100644 --- a/packages/lib/src/radio-group/RadioGroup.stories.tsx +++ b/packages/lib/src/radio-group/RadioGroup.stories.tsx @@ -1,7 +1,6 @@ import { Meta, StoryObj } from "@storybook/react"; import ExampleContainer from "../../.storybook/components/ExampleContainer"; import Title from "../../.storybook/components/Title"; -import { HalstackProvider } from "../HalstackContext"; import DxcRadioGroup from "./RadioGroup"; export default { @@ -20,18 +19,11 @@ const options = [ const single_disabled_options = [{ label: "Option A", value: "A", disabled: true }]; -const opinionatedTheme = { - radioGroup: { - baseColor: "#0086e6", - fontColor: "#000000", - }, -}; - const RadioGroup = () => ( <> - + <Title title="Enabled" theme="light" level={2} /> <ExampleContainer> - <Title title="Enabled" theme="light" level={4} /> + <Title title="Default" theme="light" level={4} /> <DxcRadioGroup label="Label" helperText="Helper text" defaultValue="A" options={single_option} /> </ExampleContainer> <ExampleContainer pseudoState="pseudo-hover"> @@ -46,13 +38,14 @@ const RadioGroup = () => ( <Title title="Focused" theme="light" level={4} /> <DxcRadioGroup label="Label" helperText="Helper text" defaultValue="A" options={single_option} /> </ExampleContainer> + <Title title="Disabled" theme="light" level={2} /> <ExampleContainer> <Title title="Disabled" theme="light" level={4} /> <DxcRadioGroup label="Label" helperText="Helper text" options={single_disabled_options} defaultValue="A" /> </ExampleContainer> - <Title title="Readonly radio input sub-states" theme="light" level={3} /> + <Title title="Readonly" theme="light" level={2} /> <ExampleContainer> - <Title title="Enabled" theme="light" level={4} /> + <Title title="Default" theme="light" level={4} /> <DxcRadioGroup label="Label" helperText="Helper text" options={single_option} defaultValue="A" readOnly /> </ExampleContainer> <ExampleContainer pseudoState="pseudo-hover"> @@ -63,7 +56,7 @@ const RadioGroup = () => ( <Title title="Active" theme="light" level={4} /> <DxcRadioGroup label="Label" helperText="Helper text" options={single_option} defaultValue="A" readOnly /> </ExampleContainer> - <Title title="Error radio input sub-states" theme="light" level={3} /> + <Title title="Error" theme="light" level={2} /> <ExampleContainer> <Title title="Enabled" theme="light" level={4} /> <DxcRadioGroup @@ -125,91 +118,6 @@ const RadioGroup = () => ( <Title title="Error" theme="light" level={4} /> <DxcRadioGroup label="Label" error="Error message" helperText="Helper text" options={options} /> </ExampleContainer> - <Title title="Opinionated theme" theme="light" level={2} /> - <ExampleContainer> - <Title title="Enabled" theme="light" level={4} /> - <HalstackProvider theme={opinionatedTheme}> - <DxcRadioGroup label="Label" helperText="Helper text" defaultValue="A" options={single_option} /> - </HalstackProvider> - </ExampleContainer> - <ExampleContainer pseudoState="pseudo-hover"> - <Title title="Hovered" theme="light" level={4} /> - <HalstackProvider theme={opinionatedTheme}> - <DxcRadioGroup label="Label" helperText="Helper text" defaultValue="A" options={single_option} /> - </HalstackProvider> - </ExampleContainer> - <ExampleContainer pseudoState="pseudo-active"> - <Title title="Active" theme="light" level={4} /> - <HalstackProvider theme={opinionatedTheme}> - <DxcRadioGroup label="Label" helperText="Helper text" defaultValue="A" options={single_option} /> - </HalstackProvider> - </ExampleContainer> - <ExampleContainer pseudoState="pseudo-focus"> - <Title title="Focused" theme="light" level={4} /> - <HalstackProvider theme={opinionatedTheme}> - <DxcRadioGroup label="Label" helperText="Helper text" defaultValue="A" options={single_option} /> - </HalstackProvider> - </ExampleContainer> - <ExampleContainer> - <Title title="Disabled" theme="light" level={4} /> - <HalstackProvider theme={opinionatedTheme}> - <DxcRadioGroup label="Label" helperText="Helper text" options={single_disabled_options} defaultValue="A" /> - </HalstackProvider> - </ExampleContainer> - <ExampleContainer> - <Title title="Readonly enabled" theme="light" level={4} /> - <HalstackProvider theme={opinionatedTheme}> - <DxcRadioGroup label="Label" options={single_option} defaultValue="A" readOnly /> - </HalstackProvider> - </ExampleContainer> - <ExampleContainer pseudoState="pseudo-hover"> - <Title title="Readonly hovered" theme="light" level={4} /> - <HalstackProvider theme={opinionatedTheme}> - <DxcRadioGroup label="Label" options={single_option} defaultValue="A" readOnly /> - </HalstackProvider> - </ExampleContainer> - <ExampleContainer pseudoState="pseudo-active"> - <Title title="Readonly active" theme="light" level={4} /> - <HalstackProvider theme={opinionatedTheme}> - <DxcRadioGroup label="Label" options={single_option} defaultValue="A" readOnly /> - </HalstackProvider> - </ExampleContainer> - <ExampleContainer pseudoState="pseudo-focus"> - <Title title="Readonly focused" theme="light" level={4} /> - <HalstackProvider theme={opinionatedTheme}> - <DxcRadioGroup label="Label" options={single_option} defaultValue="A" readOnly /> - </HalstackProvider> - </ExampleContainer> - <ExampleContainer> - <Title title="Enabled" theme="light" level={4} /> - <HalstackProvider theme={opinionatedTheme}> - <DxcRadioGroup label="Label" options={single_option} defaultValue="A" error="Error message" /> - </HalstackProvider> - </ExampleContainer> - <ExampleContainer pseudoState="pseudo-hover"> - <Title title="Hovered" theme="light" level={4} /> - <HalstackProvider theme={opinionatedTheme}> - <DxcRadioGroup label="Label" options={single_option} defaultValue="A" error="Error message" /> - </HalstackProvider> - </ExampleContainer> - <ExampleContainer pseudoState="pseudo-active"> - <Title title="Active" theme="light" level={4} /> - <HalstackProvider theme={opinionatedTheme}> - <DxcRadioGroup label="Label" options={single_option} defaultValue="A" error="Error message" /> - </HalstackProvider> - </ExampleContainer> - <ExampleContainer pseudoState="pseudo-focus"> - <Title title="Focused" theme="light" level={4} /> - <HalstackProvider theme={opinionatedTheme}> - <DxcRadioGroup label="Label" options={single_option} defaultValue="A" error="Error message" /> - </HalstackProvider> - </ExampleContainer> - <ExampleContainer> - <Title title="Disabled" theme="light" level={4} /> - <HalstackProvider theme={opinionatedTheme}> - <DxcRadioGroup label="Label" helperText="Helper text" options={options} disabled defaultValue="A" /> - </HalstackProvider> - </ExampleContainer> </> ); diff --git a/packages/lib/src/radio-group/RadioGroup.tsx b/packages/lib/src/radio-group/RadioGroup.tsx index dce962d848..80b732dbf3 100644 --- a/packages/lib/src/radio-group/RadioGroup.tsx +++ b/packages/lib/src/radio-group/RadioGroup.tsx @@ -1,46 +1,52 @@ import { FocusEvent, forwardRef, KeyboardEvent, useCallback, useContext, useId, useMemo, useState } from "react"; -import styled, { ThemeProvider } from "styled-components"; -import HalstackContext, { HalstackLanguageContext } from "../HalstackContext"; -import DxcRadio from "./Radio"; -import RadioGroupPropsType, { RadioOption, RefType } from "./types"; +import styled from "styled-components"; +import { HalstackLanguageContext } from "../HalstackContext"; +import RadioInput from "./RadioInput"; +import RadioGroupPropsType, { RefType } from "./types"; +import Label from "../styles/forms/Label"; +import HelperText from "../styles/forms/HelperText"; +import ErrorMessage from "../styles/forms/ErrorMessage"; -const getInitialFocusIndex = (innerOptions: RadioOption[], value?: string) => { - const initialSelectedOptionIndex = innerOptions.findIndex((option) => option.value === value); - return initialSelectedOptionIndex !== -1 ? initialSelectedOptionIndex : 0; -}; +const RadioGroupContainer = styled.div` + box-sizing: border-box; + display: inline-flex; + flex-direction: column; +`; + +const RadioGroup = styled.div<{ stacking: RadioGroupPropsType["stacking"] }>` + display: flex; + flex-wrap: wrap; + flex-direction: ${({ stacking }) => stacking}; + column-gap: var(--spacing-gap-l); + row-gap: var(--spacing-gap-xs); +`; const DxcRadioGroup = forwardRef<RefType, RadioGroupPropsType>( ( { + ariaLabel = "Radio group", + defaultValue, + disabled = false, + error, + helperText, label, name, - helperText, - options, - disabled = false, + onBlur, + onChange, optional = false, optionalItemLabel, + options, readOnly = false, stacking = "column", - defaultValue, - value, - onChange, - onBlur, - error, tabIndex = 0, - ariaLabel = "Radio group", + value, }, ref - ): JSX.Element => { - const radioGroupId = `radio-group-${useId()}`; - const radioGroupLabelId = `label-${radioGroupId}`; - const errorId = `error-${radioGroupId}`; - - const [innerValue, setInnerValue] = useState(defaultValue); - const [firstTimeFocus, setFirstTimeFocus] = useState(true); - - const colorsTheme = useContext(HalstackContext); + ) => { + const id = `radio-group-${useId()}`; + const labelId = `label-${id}`; + const errorId = `error-${id}`; const translatedLabels = useContext(HalstackLanguageContext); - const innerOptions = useMemo( () => optional @@ -53,42 +59,40 @@ const DxcRadioGroup = forwardRef<RefType, RadioGroupPropsType>( }, ] : options, - [optional, options, optionalItemLabel, translatedLabels] + [optional, optionalItemLabel, options, translatedLabels] ); - - const [currentFocusIndex, setCurrentFocusIndex] = useState(getInitialFocusIndex(innerOptions, value ?? innerValue)); + const [innerValue, setInnerValue] = useState(defaultValue); + const [currentFocusIndex, setCurrentFocusIndex] = useState(() => { + const initialSelectedOptionIndex = innerOptions.findIndex((option) => option.value === (value ?? innerValue)); + return initialSelectedOptionIndex !== -1 ? initialSelectedOptionIndex : 0; + }); + const [firstTimeFocus, setFirstTimeFocus] = useState(true); const handleOnChange = useCallback( (newValue: string) => { const currentValue = value ?? innerValue; if (newValue !== currentValue && !readOnly) { - if (value == null) { - setInnerValue(newValue); - } + if (value == null) setInnerValue(newValue); onChange?.(newValue); } }, - [value, innerValue, onChange] + [innerValue, onChange, value] ); + const handleOnBlur = (event: FocusEvent<HTMLDivElement>) => { // If the radio group loses the focus to an element not contained inside it... if (!event.currentTarget.contains(event.relatedTarget as Node)) { setFirstTimeFocus(true); const currentValue = value ?? innerValue; - if (!optional && !currentValue) { - onBlur?.({ - value: currentValue, - error: translatedLabels.formFields.requiredSelectionErrorMessage, - }); - } else { - onBlur?.({ value: currentValue }); - } + onBlur?.({ + value: currentValue, + error: !optional && !currentValue ? translatedLabels.formFields.requiredSelectionErrorMessage : undefined, + }); } }; + const handleOnFocus = () => { - if (firstTimeFocus) { - setFirstTimeFocus(false); - } + if (firstTimeFocus) setFirstTimeFocus(false); }; const setPreviousRadioChecked = () => { @@ -98,12 +102,11 @@ const DxcRadioGroup = forwardRef<RefType, RadioGroupPropsType>( index = index === 0 ? innerOptions.length - 1 : index - 1; } const option = innerOptions[index]; - if (option != null) { - handleOnChange(option.value); - } + if (option != null) handleOnChange(option.value); return index; }); }; + const setNextRadioChecked = () => { setCurrentFocusIndex((currentFocusIndexValue) => { let index = currentFocusIndexValue === innerOptions.length - 1 ? 0 : currentFocusIndexValue + 1; @@ -111,12 +114,11 @@ const DxcRadioGroup = forwardRef<RefType, RadioGroupPropsType>( index = index === innerOptions.length - 1 ? 0 : index + 1; } const option = innerOptions[index]; - if (option != null) { - handleOnChange(option.value); - } + if (option != null) handleOnChange(option.value); return index; }); }; + const handleOnKeyDown = (event: KeyboardEvent<HTMLDivElement>) => { switch (event.key) { case "Left": @@ -135,9 +137,7 @@ const DxcRadioGroup = forwardRef<RefType, RadioGroupPropsType>( break; case " ": event.preventDefault(); - if (innerOptions[currentFocusIndex] != null) { - handleOnChange(innerOptions[currentFocusIndex].value); - } + if (innerOptions[currentFocusIndex] != null) handleOnChange(innerOptions[currentFocusIndex].value); break; default: break; @@ -145,112 +145,55 @@ const DxcRadioGroup = forwardRef<RefType, RadioGroupPropsType>( }; return ( - <ThemeProvider theme={colorsTheme.radioGroup}> - <RadioGroupContainer ref={ref}> - {label && ( - <Label id={radioGroupLabelId} helperText={helperText} disabled={disabled}> - {label} - {optional && <OptionalLabel>{` ${translatedLabels.formFields.optionalLabel}`}</OptionalLabel>} - </Label> - )} - {helperText && <HelperText disabled={disabled}>{helperText}</HelperText>} - <RadioGroup - onBlur={handleOnBlur} - onFocus={handleOnFocus} - onKeyDown={handleOnKeyDown} - stacking={stacking} - role="radiogroup" - aria-disabled={disabled} - aria-labelledby={label ? radioGroupLabelId : undefined} - aria-invalid={!!error} - aria-errormessage={error ? errorId : undefined} - aria-required={!disabled && !readOnly && !optional} - aria-readonly={readOnly} - aria-orientation={stacking === "column" ? "vertical" : "horizontal"} - aria-label={label ? undefined : ariaLabel} - > - <ValueInput name={name} disabled={disabled} value={value ?? innerValue ?? ""} readOnly /> - {innerOptions.map((option, index) => ( - <DxcRadio - key={`radio-${index}`} - label={option.label ?? ""} - checked={(value ?? innerValue) === option.value} - onClick={() => { - handleOnChange(option.value); - setCurrentFocusIndex(index); - }} - error={error} - disabled={option.disabled || disabled} - focused={currentFocusIndex === index} - readOnly={readOnly} - tabIndex={tabIndex} - /> - ))} - </RadioGroup> - {!disabled && typeof error === "string" && ( - <ErrorMessageContainer id={errorId} role="alert" aria-live={error ? "assertive" : "off"}> - {error} - </ErrorMessageContainer> - )} - </RadioGroupContainer> - </ThemeProvider> + <RadioGroupContainer ref={ref}> + {label && ( + <Label disabled={disabled} hasMargin={!helperText} id={labelId}> + {label} + {optional && <span>{` ${translatedLabels.formFields.optionalLabel}`}</span>} + </Label> + )} + {helperText && ( + <HelperText disabled={disabled} hasMargin> + {helperText} + </HelperText> + )} + <RadioGroup + aria-disabled={disabled} + aria-errormessage={error ? errorId : undefined} + aria-invalid={!!error} + aria-labelledby={label ? labelId : undefined} + aria-orientation={stacking === "column" ? "vertical" : "horizontal"} + aria-readonly={readOnly} + aria-required={!disabled && !readOnly && !optional} + aria-label={label ? undefined : ariaLabel} + onBlur={handleOnBlur} + onFocus={handleOnFocus} + onKeyDown={handleOnKeyDown} + role="radiogroup" + stacking={stacking} + > + <input disabled={disabled} name={name} readOnly type="hidden" value={value ?? innerValue ?? ""} /> + {innerOptions.map((option, index) => ( + <RadioInput + checked={(value ?? innerValue) === option.value} + disabled={option.disabled || disabled} + error={error} + focused={currentFocusIndex === index} + key={`radio-${index}`} + label={option.label ?? ""} + onClick={() => { + handleOnChange(option.value); + setCurrentFocusIndex(index); + }} + readOnly={readOnly} + tabIndex={tabIndex} + /> + ))} + </RadioGroup> + {!disabled && typeof error === "string" && <ErrorMessage error={error} id={errorId} />} + </RadioGroupContainer> ); } ); -const RadioGroupContainer = styled.div` - box-sizing: border-box; - display: inline-flex; - flex-direction: column; -`; - -const Label = styled.span<{ - helperText: RadioGroupPropsType["helperText"]; - disabled: RadioGroupPropsType["disabled"]; -}>` - color: ${(props) => (props.disabled ? props.theme.disabledLabelFontColor : props.theme.labelFontColor)}; - font-family: ${(props) => props.theme.fontFamily}; - font-size: ${(props) => props.theme.labelFontSize}; - font-style: ${(props) => props.theme.labelFontStyle}; - font-weight: ${(props) => props.theme.labelFontWeight}; - line-height: ${(props) => props.theme.labelLineHeight}; - ${(props) => !props.helperText && `margin-bottom: ${props.theme.groupLabelMargin}`} -`; - -const OptionalLabel = styled.span` - font-weight: ${(props) => props.theme.optionalLabelFontWeight}; -`; - -const HelperText = styled.span<{ disabled: RadioGroupPropsType["disabled"] }>` - color: ${(props) => (props.disabled ? props.theme.disabledHelperTextFontColor : props.theme.helperTextFontColor)}; - font-family: ${(props) => props.theme.fontFamily}; - 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: ${(props) => props.theme.groupLabelMargin}; -`; - -const RadioGroup = styled.div<{ stacking: RadioGroupPropsType["stacking"] }>` - display: flex; - flex-wrap: wrap; - flex-direction: ${(props) => props.stacking}; - row-gap: ${(props) => props.theme.groupVerticalGutter}; - column-gap: ${(props) => props.theme.groupHorizontalGutter}; -`; - -const ValueInput = styled.input` - display: none; -`; - -const ErrorMessageContainer = styled.span` - min-height: 1.5em; - color: ${(props) => props.theme.errorMessageColor}; - font-family: ${(props) => props.theme.fontFamily}; - font-size: 0.75rem; - font-weight: 400; - line-height: 1.5em; - margin-top: 0.5rem; -`; - export default DxcRadioGroup; diff --git a/packages/lib/src/radio-group/RadioInput.tsx b/packages/lib/src/radio-group/RadioInput.tsx new file mode 100644 index 0000000000..d3244dd449 --- /dev/null +++ b/packages/lib/src/radio-group/RadioInput.tsx @@ -0,0 +1,94 @@ +import { memo, useEffect, useId, useRef, useState } from "react"; +import styled from "styled-components"; +import { RadioInputProps } from "./types"; +import { icons, getRadioInputStyles } from "./utils"; + +const Label = styled.span<{ disabled: RadioInputProps["disabled"] }>` + 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-regular); +`; + +type CommonStylingProps = { + disabled: RadioInputProps["disabled"]; + error: boolean; + readOnly: RadioInputProps["readOnly"]; +}; + +const RadioButton = styled.span<CommonStylingProps>` + display: grid; + place-items: center; + height: var(--height-s); + width: 24px; + border-radius: 50%; + ${({ disabled }) => disabled && "pointer-events: none;"} + + &:focus { + outline: var(--border-width-m) var(--border-style-default) var(--border-color-secondary-medium); + outline-offset: -2px; + } +`; + +const RadioInputContainer = styled.span<CommonStylingProps>` + display: inline-flex; + align-items: center; + gap: var(--spacing-gap-s); + color: ${({ disabled, error, readOnly }) => getRadioInputStyles(disabled, error, readOnly, "default")}; + cursor: ${({ disabled, readOnly }) => (disabled ? "not-allowed" : readOnly ? "default" : "pointer")}; + + &:hover ${RadioButton} { + color: ${({ disabled, error, readOnly }) => getRadioInputStyles(disabled, error, readOnly, "hover")}; + } + &:active ${RadioButton} { + color: ${({ disabled, error, readOnly }) => getRadioInputStyles(disabled, error, readOnly, "active")}; + } +`; + +const RadioInput = ({ checked, disabled, error, focused, label, onClick, readOnly, tabIndex }: RadioInputProps) => { + const radioLabelId = `radio-${useId()}`; + const ref = useRef<HTMLSpanElement>(null); + const [firstUpdate, setFirstUpdate] = useState(true); + + useEffect(() => { + // Don't apply in the first render + if (firstUpdate) { + setFirstUpdate(false); + return; + } + focused && ref.current?.focus(); + }, [focused]); + + const handleOnClick = () => { + onClick(); + document.activeElement !== ref.current && ref.current?.focus(); + }; + + return ( + <RadioInputContainer + disabled={disabled} + error={!!error} + onClick={disabled ? undefined : handleOnClick} + readOnly={readOnly} + > + <RadioButton + aria-checked={checked} + aria-disabled={disabled} + aria-labelledby={radioLabelId} + disabled={disabled} + error={!!error} + readOnly={readOnly} + ref={ref} + role="radio" + tabIndex={disabled ? -1 : focused ? tabIndex : -1} + > + {checked ? icons.checked : icons.unchecked} + </RadioButton> + <Label disabled={disabled} id={radioLabelId}> + {label} + </Label> + </RadioInputContainer> + ); +}; + +export default memo(RadioInput); diff --git a/packages/lib/src/radio-group/types.ts b/packages/lib/src/radio-group/types.ts index 0926a4838e..81c86de584 100644 --- a/packages/lib/src/radio-group/types.ts +++ b/packages/lib/src/radio-group/types.ts @@ -1,4 +1,4 @@ -export type RadioOption = { +type Option = { /** * Label of the option placed next to the radio input. */ @@ -17,26 +17,39 @@ export type RadioOption = { type RadioGroupProps = { /** - * Text to be placed above the radio group. + * Specifies a string to be used as the name for the radio group when no `label` is provided. */ - label?: string; + ariaLabel?: string; /** - * Name attribute of the input element. This attribute will allow users - * to find the component's value during the submit event. + * Initial value of the radio group, only when it is uncontrolled. */ - name?: string; + defaultValue?: string; + /** + * If true, the component will be disabled. + */ + disabled?: boolean; + /** + * If it is a defined value and also a truthy string, the component will + * change its appearance, showing the error below the radio group. 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. + */ + error?: string; /** * Helper text to be placed above the radio group. */ helperText?: string; /** - * An array of objects representing the selectable options. + * Text to be placed above the radio group. */ - options: RadioOption[]; + label?: string; /** - * If true, the component will be disabled. + * Name attribute of the input element. This attribute will allow users + * to find the component's value during the submit event. */ - disabled?: boolean; + name?: string; /** * If true, the radio group will be optional, showing * (Optional) next to the label and adding a default last @@ -50,51 +63,38 @@ type RadioGroupProps = { */ optionalItemLabel?: string; /** - * If true, the component will not be mutable, meaning the user can not edit the control. - */ - readOnly?: boolean; - /** - * Sets the orientation of the options within the radio group. - */ - stacking?: "row" | "column"; - /** - * Initial value of the radio group, only when it is uncontrolled. + * An array of objects representing the selectable options. */ - defaultValue?: string; + options: Option[]; /** - * Value of the radio group. If undefined, the component will be - * uncontrolled and the value will be managed internally by the - * component. + * This function will be called when the radio group loses the focus. An + * object including the value and the error will be passed to this + * function. If there is no error, error will not be defined. */ - value?: string; + onBlur?: (val: { value?: string; error?: string }) => void; /** * This function will be called when the user chooses an option. The new * value will be passed to this function. */ onChange?: (value: string) => void; /** - * This function will be called when the radio group loses the focus. An - * object including the value and the error will be passed to this - * function. If there is no error, error will not be defined. + * If true, the component will not be mutable, meaning the user can not edit the control. */ - onBlur?: (val: { value?: string; error?: string }) => void; + readOnly?: boolean; /** - * If it is a defined value and also a truthy string, the component will - * change its appearance, showing the error below the radio group. 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. + * Sets the orientation of the options within the radio group. */ - error?: string; + stacking?: "row" | "column"; /** * Value of the tabindex attribute. */ tabIndex?: number; /** - * Specifies a string to be used as the name for the radio group when no `label` is provided. + * Value of the radio group. If undefined, the component will be + * uncontrolled and the value will be managed internally by the + * component. */ - ariaLabel?: string; + value?: string; }; /** @@ -103,15 +103,15 @@ type RadioGroupProps = { export type RefType = HTMLDivElement; /** - * Single radio prop types. + * Radio input prop types. */ -export type RadioProps = { - label: string; +export type RadioInputProps = { checked: boolean; - onClick: () => void; - error?: string; disabled: boolean; + error?: string; focused: boolean; + label: string; + onClick: () => void; readOnly: boolean; tabIndex: number; }; diff --git a/packages/lib/src/radio-group/utils.tsx b/packages/lib/src/radio-group/utils.tsx new file mode 100644 index 0000000000..a6904886eb --- /dev/null +++ b/packages/lib/src/radio-group/utils.tsx @@ -0,0 +1,52 @@ +export function getRadioInputStyles( + disabled: boolean, + error: boolean, + readOnly: boolean, + status: "default" | "hover" | "active" +) { + switch (true) { + case disabled: + return "var(--color-fg-neutral-medium)"; + case error: + return status === "default" + ? "var(--color-fg-error-medium)" + : status === "hover" + ? "var(--color-fg-error-strong)" + : "var(--color-fg-error-stronger)"; + case readOnly: + return status === "default" + ? "var(--color-fg-neutral-medium)" + : status === "hover" + ? "var(--color-fg-neutral-strong)" + : "var(--color-fg-neutral-stronger)"; + default: + return status === "default" + ? "var(--color-fg-secondary-medium)" + : status === "hover" + ? "var(--color-fg-secondary-strong)" + : "var(--color-fg-secondary-stronger)"; + } +} + +export const icons = { + checked: ( + <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"> + <path + d="M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12ZM5.00194 12C5.00194 15.8649 8.13508 18.9981 12 18.9981C15.8649 18.9981 18.9981 15.8649 18.9981 12C18.9981 8.13508 15.8649 5.00194 12 5.00194C8.13508 5.00194 5.00194 8.13508 5.00194 12Z" + fill="currentColor" + /> + <path + d="M17 12C17 14.7614 14.7614 17 12 17C9.23858 17 7 14.7614 7 12C7 9.23858 9.23858 7 12 7C14.7614 7 17 9.23858 17 12Z" + fill="currentColor" + /> + </svg> + ), + unchecked: ( + <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"> + <path + d="M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12ZM5.00194 12C5.00194 15.8649 8.13508 18.9981 12 18.9981C15.8649 18.9981 18.9981 15.8649 18.9981 12C18.9981 8.13508 15.8649 5.00194 12 5.00194C8.13508 5.00194 5.00194 8.13508 5.00194 12Z" + fill="currentColor" + /> + </svg> + ), +}; diff --git a/packages/lib/src/select/Select.tsx b/packages/lib/src/select/Select.tsx index 40693d4220..c2f8aab155 100644 --- a/packages/lib/src/select/Select.tsx +++ b/packages/lib/src/select/Select.tsx @@ -33,6 +33,10 @@ import { import SelectPropsType, { ListOptionType, RefType } from "./types"; import DxcActionIcon from "../action-icon/ActionIcon"; import DxcFlex from "../flex/Flex"; +import ErrorMessage from "../styles/forms/ErrorMessage"; +import HelperText from "../styles/forms/HelperText"; +import Label from "../styles/forms/Label"; +import { inputStylesByState } from "../styles/forms/inputStylesByState"; const SelectContainer = styled.div<{ margin: SelectPropsType["margin"]; @@ -54,34 +58,9 @@ const SelectContainer = styled.div<{ 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"]; + disabled: Required<SelectPropsType>["disabled"]; + error: boolean; }>` position: relative; display: flex; @@ -89,24 +68,7 @@ const Select = styled.div<{ 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;"}; + ${({ disabled, error }) => inputStylesByState(disabled, error, false)} /* Collapse indicator */ > span[role="img"] { @@ -204,21 +166,6 @@ const SearchInput = styled.input` 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>( ( { @@ -476,24 +423,28 @@ const DxcSelect = forwardRef<RefType, SelectPropsType>( } setSearchValue(""); }, - [handleOnChangeValue, closeListbox, multiple] + [closeListbox, handleOnChangeValue, multiple] ); return ( - <SelectContainer margin={margin} size={size} ref={ref}> + <SelectContainer margin={margin} ref={ref} size={size}> {label && ( <Label - id={labelId} disabled={disabled} + hasMargin={!helperText} + id={labelId} onClick={() => { selectRef?.current?.focus(); }} - helperText={helperText} > {label} {optional && <span>{translatedLabels.formFields.optionalLabel}</span>} </Label> )} - {helperText && <HelperText disabled={disabled}>{helperText}</HelperText>} + {helperText && ( + <HelperText disabled={disabled} hasMargin> + {helperText} + </HelperText> + )} <Popover.Root open={isOpen}> <Popover.Trigger asChild type={undefined}> <Select @@ -508,7 +459,7 @@ const DxcSelect = forwardRef<RefType, SelectPropsType>( aria-labelledby={label ? labelId : undefined} aria-required={!disabled && !optional} disabled={disabled} - error={error} + error={!!error} id={selectInputId} onBlur={handleOnBlur} onClick={handleOnClick} @@ -523,14 +474,14 @@ const DxcSelect = forwardRef<RefType, SelectPropsType>( <SelectionNumber disabled={disabled}>{selectedOption.length}</SelectionNumber> <Tooltip label={translatedLabels.select.actionClearSelectionTitle}> <ClearOptionsAction + aria-label={translatedLabels.select.actionClearSelectionTitle} disabled={disabled} + onClick={handleClearOptionsActionOnClick} onMouseDown={(event) => { // Avoid input to lose focus when pressed event.preventDefault(); }} - onClick={handleClearOptionsActionOnClick} tabIndex={-1} - aria-label={translatedLabels.select.actionClearSelectionTitle} > <DxcIcon icon="clear" /> </ClearOptionsAction> @@ -540,9 +491,9 @@ const DxcSelect = forwardRef<RefType, SelectPropsType>( <TooltipWrapper condition={hasTooltip} label={getSelectedOptionLabel(placeholder, selectedOption)}> <SearchableValueContainer> <input - type="hidden" - name={name} disabled={disabled} + name={name} + type="hidden" value={ multiple ? (Array.isArray(value) ? value : Array.isArray(innerValue) ? innerValue : []).join(",") @@ -551,23 +502,23 @@ const DxcSelect = forwardRef<RefType, SelectPropsType>( /> {searchable && ( <SearchInput - value={searchValue} + aria-labelledby={label ? labelId : undefined} + autoComplete="nope" + autoCorrect="nope" disabled={disabled} onChange={handleSearchIOnChange} ref={selectSearchInputRef} - autoComplete="nope" - autoCorrect="nope" size={1} - aria-labelledby={label ? labelId : undefined} + value={searchValue} /> )} {(!searchable || searchValue === "") && ( <SelectedOption - disabled={disabled} atBackground={ (multiple ? (value ?? innerValue).length === 0 : !(value ?? innerValue)) || (searchable && isOpen) } + disabled={disabled} onMouseEnter={handleOnMouseEnter} > {getSelectedOptionLabel(placeholder, selectedOption)} @@ -592,40 +543,35 @@ const DxcSelect = forwardRef<RefType, SelectPropsType>( </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(); }} + onOpenAutoFocus={(event) => { + // Avoid select to lose focus when the list is opened + event.preventDefault(); + }} + sideOffset={4} + style={{ zIndex: "2147483647" }} > <Listbox ariaLabelledBy={labelId} - id={listboxId} currentValue={value ?? innerValue} - options={searchable ? filteredOptions : options} - visualFocusIndex={visualFocusIndex} + handleOptionOnClick={handleOptionOnClick} + id={listboxId} lastOptionIndex={lastOptionIndex} multiple={multiple} optional={optional} optionalItem={optionalItem} + options={searchable ? filteredOptions : options} searchable={searchable} - handleOptionOnClick={handleOptionOnClick} styles={{ width }} + visualFocusIndex={visualFocusIndex} /> </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> - )} + {!disabled && typeof error === "string" && <ErrorMessage error={error} id={errorId} />} </SelectContainer> ); } diff --git a/packages/lib/src/slider/Slider.tsx b/packages/lib/src/slider/Slider.tsx index 34782b2e02..ce493ef363 100644 --- a/packages/lib/src/slider/Slider.tsx +++ b/packages/lib/src/slider/Slider.tsx @@ -4,6 +4,8 @@ import { spaces } from "../common/variables"; import SliderPropsType, { RefType } from "./types"; import { calculateWidth, roundUp, stepPrecision } from "./utils"; import DxcNumberInput from "../number-input/NumberInput"; +import HelperText from "../styles/forms/HelperText"; +import Label from "../styles/forms/Label"; const SliderContainer = styled.div<{ margin: SliderPropsType["margin"]; @@ -23,20 +25,6 @@ const SliderContainer = styled.div<{ width: ${(props) => calculateWidth(props.margin, props.size)}; `; -const Label = styled.label<{ disabled: SliderPropsType["disabled"] }>` - 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); -`; - -const HelperText = styled.span<{ disabled: SliderPropsType["disabled"] }>` - 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 MainContainer = styled.div<{ showInput: SliderPropsType["showInput"] }>` display: grid; gap: var(--spacing-gap-l); @@ -135,7 +123,7 @@ const SliderInput = styled.input<{ } `; -const TicksContainer = styled.div` +const TicksContainer = styled.datalist` position: absolute; display: flex; align-items: center; @@ -144,10 +132,11 @@ const TicksContainer = styled.div` pointer-events: none; `; -const Tick = styled.span<{ +const Tick = styled.option<{ disabled: SliderPropsType["disabled"]; currentTick: boolean; }>` + all: unset; background-color: ${({ disabled }) => disabled ? "var(--color-fg-neutral-medium)" : "var(--color-fg-secondary-medium)"}; border-radius: 50%; @@ -161,7 +150,7 @@ const DxcSlider = forwardRef<RefType, SliderPropsType>( { ariaLabel = "Slider", defaultValue = 0, - disabled, + disabled = false, helperText, label, labelFormatCallback, @@ -224,7 +213,11 @@ const DxcSlider = forwardRef<RefType, SliderPropsType>( {label} </Label> )} - {helperText && <HelperText disabled={disabled}>{helperText}</HelperText>} + {helperText && ( + <HelperText disabled={disabled}> + {helperText} + </HelperText> + )} <MainContainer showInput={showInput}> <LimitsValueGrid showLimitsValues={showLimitsValues}> {showLimitsValues && <LimitLabel disabled={disabled}>{minLabel}</LimitLabel>} @@ -255,6 +248,7 @@ const DxcSlider = forwardRef<RefType, SliderPropsType>( currentTick={roundedUpValue === stepPrecision(tick, step)} disabled={disabled} key={`tickmark-${index}`} + value={tick.toString()} /> ); })} diff --git a/packages/lib/src/styles/forms/ErrorMessage.tsx b/packages/lib/src/styles/forms/ErrorMessage.tsx new file mode 100644 index 0000000000..9dbe45fb29 --- /dev/null +++ b/packages/lib/src/styles/forms/ErrorMessage.tsx @@ -0,0 +1,26 @@ +import styled from "styled-components"; +import DxcIcon from "../../icon/Icon"; + +const ErrorMessageContainer = styled.div` + 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); + margin-top: var(--spacing-gap-xs); + + /* Error icon */ + > span[role="img"] { + font-size: var(--height-xxs); + } +`; + +const ErrorMessage = ({ error, id }: { error: string; id: string }) => ( + <ErrorMessageContainer aria-live={error ? "assertive" : "off"} id={id} role="alert"> + {error && <DxcIcon icon="filled_error" />} + {error} + </ErrorMessageContainer> +); + +export default ErrorMessage; diff --git a/packages/lib/src/styles/forms/HelperText.tsx b/packages/lib/src/styles/forms/HelperText.tsx new file mode 100644 index 0000000000..800dae63f1 --- /dev/null +++ b/packages/lib/src/styles/forms/HelperText.tsx @@ -0,0 +1,11 @@ +import styled from "styled-components"; + +const HelperText = styled.span<{ disabled: boolean, hasMargin?: boolean }>` + 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); + ${({ hasMargin = false }) => hasMargin && "margin-bottom: var(--spacing-padding-xxs);"} +`; + +export default HelperText; \ No newline at end of file diff --git a/packages/lib/src/styles/forms/Label.tsx b/packages/lib/src/styles/forms/Label.tsx new file mode 100644 index 0000000000..37f30d3c5a --- /dev/null +++ b/packages/lib/src/styles/forms/Label.tsx @@ -0,0 +1,20 @@ +import styled from "styled-components"; + +const Label = styled.label<{ + disabled: boolean; + hasMargin?: boolean; +}>` + 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); + ${({ hasMargin = false }) => hasMargin && "margin-bottom: var(--spacing-padding-xxs);"} + + /* Optional text */ + > span { + color: ${({ disabled }) => (disabled ? "var(--color-fg-neutral-medium)" : "var(--color-fg-neutral-stronger)")}; + font-weight: var(--typography-label-regular); + } +`; + +export default Label; diff --git a/packages/lib/src/styles/forms/inputStylesByState.tsx b/packages/lib/src/styles/forms/inputStylesByState.tsx new file mode 100644 index 0000000000..b31a02f0ac --- /dev/null +++ b/packages/lib/src/styles/forms/inputStylesByState.tsx @@ -0,0 +1,29 @@ +import { css } from "styled-components"; + +export const inputStylesByState = (disabled: boolean, error: boolean, readOnly: boolean) => css` + border-radius: var(--border-radius-s); + border: ${!disabled && error ? "var(--border-width-m)" : "var(--border-width-s)"} var(--border-style-default) + ${(() => { + if (disabled) return "var(--border-color-neutral-strong)"; + else if (error) return "var(--border-color-error-medium)"; + else if (readOnly) return "var(--border-color-neutral-strong)"; + else return "var(--border-color-neutral-dark)"; + })()}; + cursor: pointer; + ${!disabled + ? `&:hover { + border-color: ${ + error + ? "var(--border-color-error-strong)" + : readOnly + ? "var(--border-color-neutral-stronger)" + : "var(--border-color-primary-strong)" + }; + } + &:focus, &:focus-within { + border-color: transparent; + outline-offset: -2px; + outline: var(--border-width-m) var(--border-style-default) var(--border-color-secondary-medium); + }` + : "cursor: not-allowed;"}; +`; diff --git a/packages/lib/src/text-input/Suggestion.tsx b/packages/lib/src/text-input/Suggestion.tsx index f5fcd8c00c..97336517f0 100644 --- a/packages/lib/src/text-input/Suggestion.tsx +++ b/packages/lib/src/text-input/Suggestion.tsx @@ -2,49 +2,33 @@ import { memo, useMemo } from "react"; import styled from "styled-components"; import { SuggestionProps } from "./types"; import { transformSpecialChars } from "./utils"; +import DxcDivider from "../divider/Divider"; +import DxcFlex from "../flex/Flex"; const SuggestionContainer = styled.li<{ visuallyFocused: SuggestionProps["visuallyFocused"]; }>` display: flex; - padding: 0 0.5rem; - line-height: 1.715em; + flex-direction: column; + height: var(--height-m); + padding: var(--spacing-padding-none) var(--spacing-padding-xs); cursor: pointer; - box-shadow: inset 0 0 0 2px - ${(props) => (props.visuallyFocused ? props.theme.focusListOptionBorderColor : "transparent")}; - - &:hover { - background-color: ${(props) => props.theme.hoverListOptionBackgroundColor}; - } + &:hover, &:active { - background-color: ${(props) => props.theme.activeListOptionBackgroundColor}; + background-color: var(--color-bg-neutral-light); } + ${({ visuallyFocused }) => + visuallyFocused && + "outline: var(--border-width-m) var(--border-style-default) var(--border-color-secondary-medium); outline-offset: -2px;"} `; -const StyledSuggestion = styled.span<{ - visuallyFocused: SuggestionProps["visuallyFocused"]; - isLast: SuggestionProps["isLast"]; -}>` - width: 100%; +const StyledSuggestion = styled.span` overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - padding: 0.25rem 0.5rem 0.188rem 0.5rem; - ${(props) => - props.isLast || props.visuallyFocused - ? `border-bottom: 1px solid transparent` - : `border-bottom: 1px solid ${props.theme.listOptionDividerColor}`}; `; -const Suggestion = ({ - id, - value, - onClick, - suggestion, - isLast, - visuallyFocused, - highlighted, -}: SuggestionProps): JSX.Element => { +const Suggestion = ({ highlighted, id, isLast, onClick, suggestion, value, visuallyFocused }: SuggestionProps) => { const matchedSuggestion = useMemo(() => { const regEx = new RegExp(transformSpecialChars(value), "i"); return { matchedWords: suggestion.match(regEx), noMatchedWords: suggestion.replace(regEx, "") }; @@ -52,24 +36,27 @@ const Suggestion = ({ return ( <SuggestionContainer + aria-selected={visuallyFocused ? true : undefined} id={id} onClick={() => { onClick(suggestion); }} - visuallyFocused={visuallyFocused} role="option" - aria-selected={visuallyFocused ? true : undefined} + visuallyFocused={visuallyFocused} > - <StyledSuggestion isLast={isLast} visuallyFocused={visuallyFocused}> - {highlighted ? ( - <> - <strong>{matchedSuggestion.matchedWords}</strong> - {matchedSuggestion.noMatchedWords} - </> - ) : ( - suggestion - )} - </StyledSuggestion> + <DxcFlex alignItems="center" grow={1}> + <StyledSuggestion> + {highlighted ? ( + <> + <strong>{matchedSuggestion.matchedWords}</strong> + {matchedSuggestion.noMatchedWords} + </> + ) : ( + suggestion + )} + </StyledSuggestion> + </DxcFlex> + {!isLast && <DxcDivider />} </SuggestionContainer> ); }; diff --git a/packages/lib/src/text-input/Suggestions.tsx b/packages/lib/src/text-input/Suggestions.tsx index d78912df20..a4b4eacd66 100644 --- a/packages/lib/src/text-input/Suggestions.tsx +++ b/packages/lib/src/text-input/Suggestions.tsx @@ -4,64 +4,57 @@ import { HalstackLanguageContext } from "../HalstackContext"; import Suggestion from "./Suggestion"; import { SuggestionsProps } from "./types"; import DxcIcon from "../icon/Icon"; +import { scrollbarStyles } from "../styles/scroll"; -const SuggestionsContainer = styled.ul<{ error: boolean }>` +const SuggestionsContainer = styled.div` + 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); box-sizing: border-box; max-height: 304px; - overflow-y: auto; - margin: 0; - padding: 0.25rem 0; - background-color: ${(props) => - props.error ? props.theme.errorListDialogBackgroundColor : props.theme.listDialogBackgroundColor}; - border: 1px solid - ${(props) => (props.error ? props.theme.errorListDialogBorderColor : 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}; + padding: var(--spacing-padding-xxs) 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); + overflow: auto; + ${scrollbarStyles} `; const SuggestionsSystemMessage = styled.span` display: flex; - padding: 0.25rem 1rem; - color: ${(props) => props.theme.systemMessageFontColor}; - line-height: 1.715em; -`; - -const SuggestionsErrorIcon = styled.span` - display: flex; - flex-wrap: wrap; - align-content: center; - margin-right: 0.5rem; - height: 18px; - width: 18px; - font-size: 18px; - color: ${(props) => props.theme.errorIconColor}; + align-items: center; + color: var(--color-fg-neutral-strong); + height: var(--height-m); + padding: var(--spacing-padding-none) var(--spacing-padding-xs); `; -const SuggestionsError = styled.span` +const SuggestionsErrorMessage = styled.div` display: flex; - padding: 0.25rem 1rem; align-items: center; - line-height: 1.715em; - color: ${(props) => props.theme.errorListDialogFontColor}; + gap: var(--spacing-gap-s); + color: var(--color-fg-error-medium); + height: var(--height-m); + padding: var(--spacing-padding-none) var(--spacing-padding-xs); + /* Error icon */ + > span[role="img"] { + font-size: var(--height-xxs); + } `; const Suggestions = ({ - id, - value, - suggestions, - visualFocusIndex, highlightedSuggestions, - searchHasErrors, + id, isSearching, - suggestionOnClick, + searchHasErrors, styles, -}: SuggestionsProps): JSX.Element => { + suggestionOnClick, + suggestions, + value, + visualFocusIndex, +}: SuggestionsProps) => { const translatedLabels = useContext(HalstackLanguageContext); const listboxRef = useRef<HTMLUListElement | null>(null); @@ -74,44 +67,38 @@ const Suggestions = ({ }, [visualFocusIndex]); return ( - <SuggestionsContainer - id={id} - error={!!searchHasErrors} - onMouseDown={(event) => { - event.preventDefault(); - }} - ref={listboxRef} - role="listbox" - style={styles} - aria-label="Suggestions" - > - {!isSearching && - !searchHasErrors && - suggestions.length > 0 && - suggestions.map((suggestion, index) => ( - <Suggestion - key={`${id}-suggestion-${index}`} - id={`${id}-suggestion-${index}`} - value={value} - onClick={suggestionOnClick} - suggestion={suggestion} - isLast={index === suggestions.length - 1} - visuallyFocused={visualFocusIndex === index} - highlighted={highlightedSuggestions} - /> - ))} - {isSearching && ( - <SuggestionsSystemMessage role="option">{translatedLabels.textInput.searchingMessage}</SuggestionsSystemMessage> - )} - {searchHasErrors && ( - <span role="option"> - <SuggestionsError role="alert" aria-live="assertive"> - <SuggestionsErrorIcon> - <DxcIcon icon="filled_error" /> - </SuggestionsErrorIcon> - {translatedLabels.textInput.fetchingDataErrorMessage} - </SuggestionsError> - </span> + <SuggestionsContainer style={styles}> + {isSearching ? ( + <SuggestionsSystemMessage aria-live="polite">{translatedLabels.textInput.searchingMessage}</SuggestionsSystemMessage> + ) : searchHasErrors ? ( + <SuggestionsErrorMessage aria-live="assertive" role="alert"> + <DxcIcon icon="filled_error" /> + {translatedLabels.textInput.fetchingDataErrorMessage} + </SuggestionsErrorMessage> + ) : ( + <ul + aria-label="Suggestions" + id={id} + onMouseDown={(event) => { + event.preventDefault(); + }} + ref={listboxRef} + role="listbox" + style={{ margin: 0, padding: 0 }} + > + {suggestions.map((suggestion, index) => ( + <Suggestion + highlighted={highlightedSuggestions} + id={`${id}-suggestion-${index}`} + isLast={index === suggestions.length - 1} + key={`${id}-suggestion-${index}`} + onClick={suggestionOnClick} + suggestion={suggestion} + value={value} + visuallyFocused={visualFocusIndex === index} + /> + ))} + </ul> )} </SuggestionsContainer> ); diff --git a/packages/lib/src/text-input/TextInput.stories.tsx b/packages/lib/src/text-input/TextInput.stories.tsx index 140b97dcd7..6e4b9ec975 100644 --- a/packages/lib/src/text-input/TextInput.stories.tsx +++ b/packages/lib/src/text-input/TextInput.stories.tsx @@ -35,15 +35,10 @@ const actionLargeIconSVG = { title: "Clock", }; -const actionLargeIconURL = { - onClick: () => {}, - icon: "search", - title: "Search", -}; - const country = ["Afghanistan"]; const countries = [ + "A very long country name just to test the ellipsis when text overflows in a suggestion", "Afghanistan", "Albania", "Algeria", @@ -68,75 +63,40 @@ const countries = [ "Djibouti", ]; -const opinionatedTheme = { - textInput: { - fontColor: "#000000", - hoverBorderColor: "#a46ede", - }, -}; - const TextInput = () => ( <> - <ExampleContainer pseudoState="pseudo-hover"> - <Title title="Hovered input" theme="light" level={4} /> - <DxcTextInput label="Text input" /> - </ExampleContainer> - <ExampleContainer pseudoState="pseudo-focus-within"> - <Title title="Focused input" theme="light" level={4} /> - <DxcTextInput label="Text input" /> - </ExampleContainer> - <ExampleContainer pseudoState="pseudo-hover"> - <Title title="Hovered action" theme="light" level={4} /> - <DxcTextInput label="Text input" defaultValue="Text" clearable /> - </ExampleContainer> - <ExampleContainer pseudoState="pseudo-active"> - <Title title="Actived action" theme="light" level={4} /> - <DxcTextInput label="Text input" action={action} clearable /> - </ExampleContainer> - <ExampleContainer pseudoState="pseudo-focus"> - <Title title="Focused action" theme="light" level={4} /> - <DxcTextInput label="Text input" action={action} clearable /> - </ExampleContainer> + <Title title="States" theme="light" level={2} /> <ExampleContainer> - <Title title="Without label" theme="light" level={4} /> + <Title title="Default" theme="light" level={4} /> <DxcTextInput /> </ExampleContainer> - <ExampleContainer> - <Title title="With label and placeholder" theme="light" level={4} /> - <DxcTextInput label="Text input" placeholder="Placeholder" /> + <ExampleContainer pseudoState="pseudo-hover"> + <Title title="Hovered" theme="light" level={4} /> + <DxcTextInput /> </ExampleContainer> - <ExampleContainer> - <Title title="Helper text, optional, and clearable" theme="light" level={4} /> - <DxcTextInput label="Text input" clearable defaultValue="Text" helperText="Help message" optional /> + <ExampleContainer pseudoState="pseudo-focus-within"> + <Title title="Focused" theme="light" level={4} /> + <DxcTextInput /> </ExampleContainer> <ExampleContainer> - <Title title="Clearable and large icon action (SVG)" theme="light" level={4} /> - <DxcTextInput - label="Text input" - defaultValue="Text text text text text text text text text text" - clearable - action={actionLargeIconSVG} - /> + <Title title="Disabled" theme="light" level={4} /> + <DxcTextInput disabled placeholder="Name" /> </ExampleContainer> <ExampleContainer> - <Title title="Clearable and large icon action (URL)" theme="light" level={4} /> + <Title title="Disabled - Complete example" theme="light" level={4} /> <DxcTextInput - label="Text input" - defaultValue="Text text text text text text text text text text" - clearable - action={actionLargeIconURL} + label="Disabled" + helperText="Help text" + disabled + defaultValue="John Doe" + action={action} + optional + prefix="+34" + suffix="USD" /> </ExampleContainer> <ExampleContainer> - <Title title="Prefix" theme="light" level={4} /> - <DxcTextInput label="With prefix" prefix="+34" /> - </ExampleContainer> - <ExampleContainer> - <Title title="Suffix and action" theme="light" level={4} /> - <DxcTextInput label="With suffix" suffix="USD" action={action} /> - </ExampleContainer> - <ExampleContainer> - <Title title="Invalid" theme="light" level={4} /> + <Title title="Error" theme="light" level={4} /> <DxcTextInput label="Error text input" helperText="Help message" @@ -148,7 +108,7 @@ const TextInput = () => ( /> </ExampleContainer> <ExampleContainer pseudoState="pseudo-hover"> - <Title title="Invalid and hovered" theme="light" level={4} /> + <Title title="Hovered error" theme="light" level={4} /> <DxcTextInput label="Error text input" helperText="Help message" @@ -156,34 +116,6 @@ const TextInput = () => ( error="Error message." /> </ExampleContainer> - <ExampleContainer> - <Title title="Disabled and placeholder" theme="light" level={4} /> - <DxcTextInput label="Disabled text input" disabled placeholder="Placeholder" /> - </ExampleContainer> - <ExampleContainer> - <Title title="Disabled, helper text, optional, value and action" theme="light" level={4} /> - <DxcTextInput - label="Disabled text input" - helperText="Help message" - disabled - optional - defaultValue="Text" - action={action} - /> - </ExampleContainer> - <ExampleContainer> - <Title title="Disabled with prefix and suffix" theme="light" level={4} /> - <DxcTextInput - label="Disabled text input" - helperText="Help message" - disabled - optional - prefix="+34" - suffix="USD" - defaultValue="Text" - action={action} - /> - </ExampleContainer> <ExampleContainer> <Title title="Read only" theme="light" level={4} /> <DxcTextInput @@ -210,17 +142,28 @@ const TextInput = () => ( action={action} /> </ExampleContainer> - <ExampleContainer pseudoState="pseudo-active"> - <Title title="Active read only" theme="light" level={4} /> + <Title title="Anatomy" theme="light" level={2} />{" "} + <ExampleContainer> + <Title title="Complete example" theme="light" level={4} /> <DxcTextInput - label="Example label" - helperText="Help message" - clearable - readOnly + label="Insert your phone number" + helperText="Help text" + defaultValue="983 023 123" + action={action} optional prefix="+34" - defaultValue="Text" - action={action} + suffix="USD" + clearable + /> + </ExampleContainer> + <ExampleContainer> + <Title title="Text ellipsis and large icon action (SVG)" theme="light" level={4} /> + <DxcTextInput + label="Text input" + defaultValue="Text text text text text text text text text text" + clearable + action={actionLargeIconSVG} + suffix="SUFFIX" /> </ExampleContainer> <Title title="Margins" theme="light" level={2} /> @@ -277,168 +220,104 @@ const TextInput = () => ( <DxcTextInput label="Text input" size="large" /> </DxcFlex> </ExampleContainer> - <Title title="Opinionated theme" theme="light" level={2} /> - <ExampleContainer> - <HalstackProvider theme={opinionatedTheme}> - <ExampleContainer pseudoState="pseudo-hover"> - <Title title="Hovered input" theme="light" level={4} /> - <DxcTextInput label="Text input" helperText="Help message" /> - </ExampleContainer> - <ExampleContainer pseudoState="pseudo-focus-within"> - <Title title="Focused input" theme="light" level={4} /> - <DxcTextInput label="Text input" helperText="Help message" /> - </ExampleContainer> - <ExampleContainer pseudoState="pseudo-hover"> - <Title title="Hovered action" theme="light" level={4} /> - <DxcTextInput label="Text input" helperText="Help message" defaultValue="Text" clearable /> - </ExampleContainer> - <ExampleContainer pseudoState="pseudo-active"> - <Title title="Actived action" theme="light" level={4} /> - <DxcTextInput label="Text input" helperText="Help message" action={action} clearable /> - </ExampleContainer> - <ExampleContainer pseudoState="pseudo-focus"> - <Title title="Focused action" theme="light" level={4} /> - <DxcTextInput label="Text input" helperText="Help message" action={action} clearable /> - </ExampleContainer> - <ExampleContainer> - <Title title="Prefix" theme="light" level={4} /> - <DxcTextInput label="With prefix" prefix="+34" helperText="Help message" /> - </ExampleContainer> - <ExampleContainer> - <Title title="Suffix and action" theme="light" level={4} /> - <DxcTextInput label="With suffix" helperText="Help message" suffix="USD" action={action} /> - </ExampleContainer> - <ExampleContainer> - <Title title="Invalid" theme="light" level={4} /> - <DxcTextInput - label="Error text input" - helperText="Help message" - error="Error message." - defaultValue="Text" - clearable - optional - action={action} - /> - </ExampleContainer> - <ExampleContainer> - <Title title="Disabled and placeholder" theme="light" level={4} /> - <DxcTextInput label="Disabled text input" disabled placeholder="Placeholder" prefix="+34" suffix="USD" /> - </ExampleContainer> - <ExampleContainer> - <Title title="Disabled, helper text, optional, value and action" theme="light" level={4} /> - <DxcTextInput - label="Disabled text input" - helperText="Help message" - disabled - optional - defaultValue="Text" - action={action} - /> - </ExampleContainer> - </HalstackProvider> - </ExampleContainer> </> ); -const AutosuggestListbox = () => { - const colorsTheme: any = useContext(HalstackContext); - - return ( - <ThemeProvider theme={colorsTheme.textInput}> +const AutosuggestListbox = () => ( + <> + <ExampleContainer> + <Title title="Autosuggest listbox" theme="light" level={2} /> <ExampleContainer> - <Title title="Autosuggest listbox" theme="light" level={2} /> - <ExampleContainer> - <Title - title="List dialog uses a Radix Popover to appear over elements with a certain z-index" - theme="light" - level={3} - /> - <div - style={{ - display: "flex", - flexDirection: "column", - gap: "20px", - height: "150px", - width: "500px", - marginBottom: "250px", - padding: "20px", - border: "1px solid black", - borderRadius: "4px", - overflow: "auto", - zIndex: "1300", - position: "relative", - }} - > - <DxcTextInput - label="Label" - suggestions={countries} - optional - placeholder="Choose an option" - size="fillParent" - /> - <button style={{ zIndex: "1", width: "100px" }}>Submit</button> - </div> - </ExampleContainer> - <Title title="Listbox suggestion states" theme="light" level={3} /> - <ExampleContainer pseudoState="pseudo-hover"> - <Title title="Hovered suggestion" theme="light" level={4} /> - <Suggestions - id="x1" - value="" - suggestions={country} - visualFocusIndex={-1} - highlightedSuggestions={false} - searchHasErrors={false} - isSearching={false} - suggestionOnClick={() => {}} - styles={{ width: 350 }} - /> - </ExampleContainer> - <ExampleContainer pseudoState="pseudo-active"> - <Title title="Active suggestion" theme="light" level={4} /> - <Suggestions - id="x2" - value="" - suggestions={country} - visualFocusIndex={-1} - highlightedSuggestions={false} - searchHasErrors={false} - isSearching={false} - suggestionOnClick={(suggestion) => {}} - styles={{ width: 350 }} - /> - </ExampleContainer> - <ExampleContainer> - <Title title="Focused suggestion" theme="light" level={4} /> - <Suggestions - id="x3" - value="" - suggestions={country} - visualFocusIndex={0} - highlightedSuggestions={false} - searchHasErrors={false} - isSearching={false} - suggestionOnClick={(suggestion) => {}} - styles={{ width: 350 }} - /> - </ExampleContainer> - <ExampleContainer> - <Title title="Highlighted suggestion" theme="light" level={4} /> - <Suggestions - id="x4" - value="Afgh" - suggestions={country} - visualFocusIndex={-1} - highlightedSuggestions={true} - searchHasErrors={false} - isSearching={false} - suggestionOnClick={(suggestion) => {}} - styles={{ width: 350 }} + <Title + title="List dialog uses a Radix Popover to appear over elements with a certain z-index" + theme="light" + level={3} + /> + <div + style={{ + display: "flex", + flexDirection: "column", + gap: "20px", + height: "150px", + width: "500px", + marginBottom: "250px", + padding: "20px", + border: "1px solid black", + borderRadius: "4px", + overflow: "auto", + zIndex: "1300", + position: "relative", + }} + > + <DxcTextInput + label="Label" + suggestions={countries} + optional + placeholder="Choose an option" + size="fillParent" /> - </ExampleContainer> + <button style={{ zIndex: "1", width: "100px" }}>Submit</button> + </div> + </ExampleContainer> + <Title title="Listbox suggestion states" theme="light" level={3} /> + <ExampleContainer pseudoState="pseudo-hover"> + <Title title="Hovered" theme="light" level={4} /> + <Suggestions + id="x1" + value="" + suggestions={country} + visualFocusIndex={-1} + highlightedSuggestions={false} + searchHasErrors={false} + isSearching={false} + suggestionOnClick={() => {}} + styles={{ width: 350 }} + /> + </ExampleContainer> + <ExampleContainer pseudoState="pseudo-active"> + <Title title="Active" theme="light" level={4} /> + <Suggestions + id="x2" + value="" + suggestions={country} + visualFocusIndex={-1} + highlightedSuggestions={false} + searchHasErrors={false} + isSearching={false} + suggestionOnClick={() => {}} + styles={{ width: 350 }} + /> + </ExampleContainer> + <ExampleContainer> + <Title title="Focused" theme="light" level={4} /> + <Suggestions + id="x3" + value="" + suggestions={country} + visualFocusIndex={0} + highlightedSuggestions={false} + searchHasErrors={false} + isSearching={false} + suggestionOnClick={() => {}} + styles={{ width: 350 }} + /> </ExampleContainer> <ExampleContainer> - <Title title="Autosuggest Error" theme="light" level={3} /> + <Title title="Highlighted" theme="light" level={4} /> + <Suggestions + id="x4" + value="Afgh" + suggestions={country} + visualFocusIndex={-1} + highlightedSuggestions={true} + searchHasErrors={false} + isSearching={false} + suggestionOnClick={() => {}} + styles={{ width: 350 }} + /> + </ExampleContainer> + <ExampleContainer> + <Title title="Error" theme="light" level={4} /> <Suggestions id="x5" value="" @@ -447,12 +326,12 @@ const AutosuggestListbox = () => { highlightedSuggestions={false} searchHasErrors={true} isSearching={false} - suggestionOnClick={(suggestion) => {}} + suggestionOnClick={() => {}} styles={{ width: 350 }} /> </ExampleContainer> <ExampleContainer> - <Title title="Autosuggest Searching message" theme="light" level={3} /> + <Title title="Searching" theme="light" level={4} /> <Suggestions id="x6" value="" @@ -461,13 +340,13 @@ const AutosuggestListbox = () => { highlightedSuggestions={false} searchHasErrors={false} isSearching={true} - suggestionOnClick={(suggestion) => {}} + suggestionOnClick={() => {}} styles={{ width: 350 }} /> </ExampleContainer> - </ThemeProvider> - ); -}; + </ExampleContainer> + </> +); type Story = StoryObj<typeof DxcTextInput>; diff --git a/packages/lib/src/text-input/TextInput.test.tsx b/packages/lib/src/text-input/TextInput.test.tsx index 22be2c6ff8..aab2912912 100644 --- a/packages/lib/src/text-input/TextInput.test.tsx +++ b/packages/lib/src/text-input/TextInput.test.tsx @@ -816,8 +816,10 @@ describe("TextInput component asynchronous autosuggest tests", () => { ); const input = getByRole("combobox") as HTMLInputElement; fireEvent.focus(input); - expect(getByRole("listbox")).toBeTruthy(); + expect(getByText("Searching...")).toBeTruthy(); + expect(getByText("Searching...").getAttribute("aria-live")).toBe("polite"); await waitForElementToBeRemoved(() => getByText("Searching...")); + expect(getByRole("listbox")).toBeTruthy(); expect(getByText("Afghanistan")).toBeTruthy(); expect(getByText("Albania")).toBeTruthy(); expect(getByText("Algeria")).toBeTruthy(); @@ -844,12 +846,12 @@ describe("TextInput component asynchronous autosuggest tests", () => { return result; }); const onChange = jest.fn(); - const { getByRole, queryByText, queryByRole } = render( + const { getByRole, getByText, queryByText, queryByRole } = render( <DxcTextInput label="Autosuggest Countries" suggestions={callbackFunc} onChange={onChange} /> ); const input = getByRole("combobox") as HTMLInputElement; fireEvent.focus(input); - expect(getByRole("listbox")).toBeTruthy(); + expect(getByText("Searching...")).toBeTruthy(); userEvent.type(input, "Ab"); fireEvent.keyDown(input, { key: "Esc", code: "Esc", keyCode: 27, charCode: 27 }); expect(queryByRole("listbox")).toBeFalsy(); @@ -874,16 +876,15 @@ describe("TextInput component asynchronous autosuggest tests", () => { ); const input = getByRole("combobox") as HTMLInputElement; fireEvent.focus(input); - const list = getByRole("listbox"); - expect(list).toBeTruthy(); + expect(getByText("Searching...")).toBeTruthy(); userEvent.type(input, "Ab"); fireEvent.keyDown(input, { key: "Esc", code: "Esc", keyCode: 27, charCode: 27 }); expect(queryByRole("listbox")).toBeFalsy(); expect(queryByText("Searching...")).toBeFalsy(); expect(input.value).toBe(""); fireEvent.keyDown(input, { key: "ArrowDown", code: "ArrowDown", keyCode: 40, charCode: 40 }); - expect(list).toBeTruthy(); await waitForElementToBeRemoved(() => getByText("Searching...")); + expect(getByRole("listbox")).toBeTruthy(); expect(getByText("Afghanistan")).toBeTruthy(); expect(getByText("Albania")).toBeTruthy(); expect(getByText("Algeria")).toBeTruthy(); diff --git a/packages/lib/src/text-input/TextInput.tsx b/packages/lib/src/text-input/TextInput.tsx index a28111da97..5cdf8d88df 100644 --- a/packages/lib/src/text-input/TextInput.tsx +++ b/packages/lib/src/text-input/TextInput.tsx @@ -12,13 +12,12 @@ import { useState, WheelEvent, } from "react"; -import styled, { ThemeProvider } from "styled-components"; +import styled from "styled-components"; import DxcActionIcon from "../action-icon/ActionIcon"; import { spaces } from "../common/variables"; import DxcFlex from "../flex/Flex"; -import DxcIcon from "../icon/Icon"; import NumberInputContext from "../number-input/NumberInputContext"; -import HalstackContext, { HalstackLanguageContext } from "../HalstackContext"; +import { HalstackLanguageContext } from "../HalstackContext"; import useWidth from "../utils/useWidth"; import Suggestions from "./Suggestions"; import TextInputPropsType, { AutosuggestWrapperProps, RefType } from "./types"; @@ -31,6 +30,10 @@ import { makeCancelable, patternMismatch, } from "./utils"; +import HelperText from "../styles/forms/HelperText"; +import Label from "../styles/forms/Label"; +import ErrorMessage from "../styles/forms/ErrorMessage"; +import { inputStylesByState } from "../styles/forms/inputStylesByState"; const TextInputContainer = styled.div<{ margin: TextInputPropsType["margin"]; @@ -39,162 +42,62 @@ const TextInputContainer = styled.div<{ 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}; + width: ${({ margin, size }) => calculateWidth(margin, size)}; + ${({ margin, size }) => size !== "fillParent" && `min-width:${calculateWidth(margin, size)}`}; + margin: ${({ margin }) => (margin && typeof margin !== "object" ? spaces[margin] : "0px")}; + margin-top: ${({ margin }) => (margin && typeof margin === "object" && margin.top ? spaces[margin.top] : "")}; + margin-right: ${({ margin }) => (margin && typeof margin === "object" && margin.right ? spaces[margin.right] : "")}; + margin-bottom: ${({ margin }) => + margin && typeof margin === "object" && margin.bottom ? spaces[margin.bottom] : ""}; + margin-left: ${({ margin }) => (margin && typeof margin === "object" && margin.left ? spaces[margin.left] : "")}; `; -const Label = styled.label<{ - disabled: TextInputPropsType["disabled"]; - hasHelperText: boolean; -}>` - color: ${(props) => (props.disabled ? props.theme.disabledLabelFontColor : 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}; - ${(props) => !props.hasHelperText && `margin-bottom: 0.25rem`} -`; - -const OptionalLabel = styled.span` - font-weight: ${(props) => props.theme.optionalLabelFontWeight}; -`; - -const HelperText = styled.span<{ disabled: TextInputPropsType["disabled"] }>` - color: ${(props) => (props.disabled ? props.theme.disabledHelperTextFontColor : 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 InputContainer = styled.div<{ - disabled: TextInputPropsType["disabled"]; - readOnly: TextInputPropsType["readOnly"]; +const TextInput = styled.div<{ + disabled: Required<TextInputPropsType>["disabled"]; error: boolean; + readOnly: Required<TextInputPropsType>["readOnly"]; }>` position: relative; display: flex; align-items: center; - height: calc(2.5rem - 2px); - padding: 0 0.5rem; - - ${(props) => props.disabled && `background-color: ${props.theme.disabledContainerFillColor};`} - box-shadow: 0 0 0 2px transparent; - border-radius: 4px; - border: 1px solid - ${(props) => - props.disabled - ? props.theme.disabledBorderColor - : props.readOnly - ? props.theme.readOnlyBorderColor - : props.theme.enabledBorderColor}; - ${(props) => - props.error && - !props.disabled && - `border-color: transparent; - box-shadow: 0 0 0 2px ${props.theme.errorBorderColor}; - `} - ${(props) => - !props.disabled - ? ` - &:hover { - border-color: ${ - props.error - ? "transparent" - : props.readOnly - ? props.theme.hoverReadOnlyBorderColor - : props.theme.hoverBorderColor - }; - ${props.error ? `box-shadow: 0 0 0 2px ${props.theme.hoverErrorBorderColor};` : ""} - } - &:focus-within { - border-color: transparent; - box-shadow: 0 0 0 2px ${props.theme.focusBorderColor}; - } - ` - : "cursor: not-allowed;"}; + gap: var(--spacing-gap-s); + height: var(--height-m); + padding: var(--spacing-padding-none) var(--spacing-padding-xs); + ${({ disabled, error, readOnly }) => inputStylesByState(disabled, error, readOnly)} `; const Input = styled.input` - height: calc(2.5rem - 2px); - width: 100%; background: none; border: none; outline: none; - padding: 0 0.5rem; + padding: 0; + flex-grow: 1; + 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-regular); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - color: ${(props) => (props.disabled ? props.theme.disabledValueFontColor : 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}; - line-height: 1.5em; - ${(props) => props.disabled && `cursor: not-allowed;`} ::placeholder { - color: ${(props) => (props.disabled ? props.theme.disabledPlaceholderFontColor : props.theme.placeholderFontColor)}; + color: ${({ disabled }) => (disabled ? "var(--color-fg-neutral-medium)" : "var(--color-fg-neutral-strong)")}; } + ${({ disabled }) => disabled && "cursor: not-allowed;"} `; -const Prefix = styled.span<{ disabled: TextInputPropsType["disabled"] }>` - height: 1.5rem; - margin-left: 0.25rem; - padding-right: ${(props) => props.theme.prefixDividerPaddingRight}; - ${(props) => { - const color = props.disabled ? props.theme.disabledPrefixColor : props.theme.prefixColor; - return `color: ${color}; border-right: ${props.theme.prefixDividerBorderWidth} ${props.theme.prefixDividerBorderStyle} ${color};`; - }}; - font-size: 1rem; - line-height: 1.5rem; - pointer-events: none; -`; - -const Suffix = styled.span<{ disabled: TextInputPropsType["disabled"] }>` - height: 1.5rem; - margin: 0 0.25rem; - padding-left: ${(props) => props.theme.suffixDividerPaddingLeft}; - ${(props) => { - const color = props.disabled ? props.theme.disabledSuffixColor : props.theme.suffixColor; - return `color: ${color}; border-left: ${props.theme.suffixDividerBorderWidth} ${props.theme.suffixDividerBorderStyle} ${color};`; +const Addon = styled.span<{ disabled: TextInputPropsType["disabled"]; type: "prefix" | "suffix" }>` + ${({ disabled, type }) => { + const color = disabled ? "var(--color-fg-neutral-medium)" : "var(--color-fg-neutral-stronger)"; + return `color: ${color}; border-${type === "prefix" ? "right" : "left"}: var(--border-width-s) var(--border-style-default) ${color};`; }}; - font-size: 1rem; - line-height: 1.5rem; + font-family: var(--typography-font-family); + font-size: var(--typography-label-m); + font-weight: var(--typography-label-regular); + ${({ type }) => `padding-${type === "prefix" ? "right" : "left"}: var(--spacing-padding-xs);`} pointer-events: none; `; -const ErrorIcon = styled.span` - display: flex; - flex-wrap: wrap; - align-content: center; - padding: 3px; - height: 18px; - width: 18px; - font-size: 18px; - color: ${(props) => props.theme.errorIconColor}; -`; - -const ErrorMessageContainer = styled.span` - min-height: 1.5em; - color: ${(props) => props.theme.errorMessageColor}; - font-size: 0.75rem; - font-weight: 400; - line-height: 1.5em; - margin-top: 0.25rem; -`; - const AutosuggestWrapper = ({ condition, wrapper, children }: AutosuggestWrapperProps): JSX.Element => ( <>{condition ? wrapper(children) : children}</> ); @@ -202,53 +105,48 @@ const AutosuggestWrapper = ({ condition, wrapper, children }: AutosuggestWrapper const DxcTextInput = forwardRef<RefType, TextInputPropsType>( ( { - label, - name = "", - defaultValue = "", - value, - helperText, - placeholder = "", action, + ariaLabel = "Text input", + autocomplete = "off", clearable = false, + defaultValue = "", disabled = false, - readOnly = false, + error, + helperText, + label, + margin, + maxLength, + minLength, + name = "", optional = false, + placeholder = "", prefix = "", + readOnly = false, suffix = "", - onChange, onBlur, - error, - suggestions, + onChange, pattern, - minLength, - maxLength, - autocomplete = "off", - margin, size = "medium", + suggestions, tabIndex = 0, - ariaLabel = "Text input", + value, }, ref - ): JSX.Element => { + ) => { const inputId = `input-${useId()}`; const autosuggestId = `suggestions-${inputId}`; const errorId = `error-${inputId}`; - - const colorsTheme = useContext(HalstackContext); const translatedLabels = useContext(HalstackLanguageContext); const numberInputContext = useContext(NumberInputContext); - const inputRef = useRef<HTMLInputElement | null>(null); const inputContainerRef = useRef<HTMLDivElement | null>(null); const actionRef = useRef<HTMLButtonElement | null>(null); - const [innerValue, setInnerValue] = useState(defaultValue); const [isOpen, changeIsOpen] = useState(false); const [isSearching, changeIsSearching] = useState(false); const [isAutosuggestError, changeIsAutosuggestError] = useState(false); const [filteredSuggestions, changeFilteredSuggestions] = useState<string[]>([]); const [visualFocusIndex, changeVisualFocusIndex] = useState(-1); - const width = useWidth(inputContainerRef.current); const getNumberErrorMessage = (checkedValue: number) => @@ -489,18 +387,10 @@ const DxcTextInput = forwardRef<RefType, TextInputPropsType>( }; const setNumberProps = (type?: string, min?: number, max?: number, step?: number) => { - if (min != null) { - inputRef.current?.setAttribute("min", min.toString()); - } - if (max != null) { - inputRef.current?.setAttribute("max", max.toString()); - } - if (step != null) { - inputRef.current?.setAttribute("step", step.toString()); - } - if (type != null) { - inputRef.current?.setAttribute("type", type); - } + if (min != null) inputRef.current?.setAttribute("min", min.toString()); + if (max != null) inputRef.current?.setAttribute("max", max.toString()); + if (step != null) inputRef.current?.setAttribute("step", step.toString()); + if (type != null) inputRef.current?.setAttribute("type", type); }; useEffect(() => { @@ -538,167 +428,164 @@ const DxcTextInput = forwardRef<RefType, TextInputPropsType>( numberInputContext.typeNumber, numberInputContext.minNumber, numberInputContext.maxNumber, - numberInputContext.stepNumber, + numberInputContext.stepNumber ); } return undefined; }, [value, innerValue, suggestions, numberInputContext]); return ( - <ThemeProvider theme={colorsTheme.textInput}> - <TextInputContainer margin={margin} size={size} ref={ref}> - {label && ( - <Label htmlFor={inputId} disabled={disabled} hasHelperText={!!helperText}> - {label} {optional && <OptionalLabel>{translatedLabels.formFields.optionalLabel}</OptionalLabel>} - </Label> - )} - {helperText && <HelperText disabled={disabled}>{helperText}</HelperText>} - <AutosuggestWrapper - condition={hasSuggestions(suggestions)} - wrapper={(children) => ( - <Popover.Root open={isOpen && (filteredSuggestions.length > 0 || isSearching || isAutosuggestError)}> - <Popover.Trigger - asChild - type={undefined} - aria-controls={undefined} - aria-haspopup={undefined} - aria-expanded={undefined} + <TextInputContainer margin={margin} ref={ref} size={size}> + {label && ( + <Label disabled={disabled} hasMargin={!helperText} htmlFor={inputId}> + {label} {optional && <span>{translatedLabels.formFields.optionalLabel}</span>} + </Label> + )} + {helperText && <HelperText disabled={disabled} hasMargin={false}>{helperText}</HelperText>} + <AutosuggestWrapper + condition={hasSuggestions(suggestions)} + wrapper={(children) => ( + <Popover.Root open={isOpen && (filteredSuggestions.length > 0 || isSearching || isAutosuggestError)}> + <Popover.Trigger + aria-controls={undefined} + aria-expanded={undefined} + aria-haspopup={undefined} + asChild + type={undefined} + > + {children} + </Popover.Trigger> + <Popover.Portal> + <Popover.Content + onCloseAutoFocus={(event) => { + // Avoid select to lose focus when the list is closed + event.preventDefault(); + }} + onOpenAutoFocus={(event) => { + // Avoid select to lose focus when the list is opened + event.preventDefault(); + }} + sideOffset={4} + style={{ zIndex: "2147483647" }} > - {children} - </Popover.Trigger> - <Popover.Portal> - <Popover.Content - sideOffset={5} - 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(); + <Suggestions + highlightedSuggestions={typeof suggestions !== "function"} + id={autosuggestId} + isSearching={isSearching} + searchHasErrors={isAutosuggestError} + suggestionOnClick={(suggestion) => { + changeValue(suggestion); + closeSuggestions(); }} - > - <Suggestions - id={autosuggestId} - value={value ?? innerValue} - suggestions={filteredSuggestions} - visualFocusIndex={visualFocusIndex} - highlightedSuggestions={typeof suggestions !== "function"} - searchHasErrors={isAutosuggestError} - isSearching={isSearching} - suggestionOnClick={(suggestion) => { - changeValue(suggestion); - closeSuggestions(); - }} - styles={{ width }} - /> - </Popover.Content> - </Popover.Portal> - </Popover.Root> - )} + suggestions={filteredSuggestions} + styles={{ width }} + value={value ?? innerValue} + visualFocusIndex={visualFocusIndex} + /> + </Popover.Content> + </Popover.Portal> + </Popover.Root> + )} + > + <TextInput + disabled={disabled} + error={!!error} + onClick={handleInputContainerOnClick} + onMouseDown={handleInputContainerOnMouseDown} + readOnly={readOnly} + ref={inputContainerRef} > - <InputContainer - error={!!error} + {prefix && ( + <Addon disabled={disabled} type="prefix"> + {prefix} + </Addon> + )} + <Input + aria-activedescendant={ + hasSuggestions(suggestions) && isOpen && visualFocusIndex !== -1 + ? `suggestion-${visualFocusIndex}` + : undefined + } + aria-autocomplete={hasSuggestions(suggestions) ? "list" : undefined} + aria-controls={hasSuggestions(suggestions) ? autosuggestId : undefined} + aria-errormessage={error ? errorId : undefined} + aria-expanded={hasSuggestions(suggestions) ? isOpen : undefined} + aria-haspopup={hasSuggestions(suggestions) ? "listbox" : undefined} + aria-invalid={!!error} + aria-label={label ? undefined : ariaLabel} + aria-required={!disabled && !optional} + autoComplete={autocomplete === "off" ? "nope" : autocomplete} disabled={disabled} + id={inputId} + name={name} + onBlur={handleInputOnBlur} + onChange={handleInputOnChange} + onFocus={!readOnly ? openSuggestions : undefined} + onKeyDown={!readOnly ? handleInputOnKeyDown : undefined} + onMouseDown={(event) => { + event.stopPropagation(); + }} + onWheel={numberInputContext?.typeNumber === "number" ? handleNumberInputWheel : undefined} + placeholder={placeholder} + pattern={pattern} readOnly={readOnly} - onClick={handleInputContainerOnClick} - onMouseDown={handleInputContainerOnMouseDown} - ref={inputContainerRef} - > - {prefix && <Prefix disabled={disabled}>{prefix}</Prefix>} - <DxcFlex gap="0.25rem" alignItems="center" grow={1}> - <Input - id={inputId} - name={name} - value={value ?? innerValue} - placeholder={placeholder} - onBlur={handleInputOnBlur} - onChange={handleInputOnChange} - onFocus={!readOnly ? openSuggestions : undefined} - onKeyDown={!readOnly ? handleInputOnKeyDown : undefined} - onMouseDown={(event) => { - event.stopPropagation(); - }} - onWheel={numberInputContext?.typeNumber === "number" ? handleNumberInputWheel : undefined} - disabled={disabled} - readOnly={readOnly} - ref={inputRef} - pattern={pattern} - minLength={minLength} - maxLength={maxLength} - autoComplete={autocomplete === "off" ? "nope" : autocomplete} + ref={inputRef} + role={hasSuggestions(suggestions) ? "combobox" : undefined} + maxLength={maxLength} + minLength={minLength} + tabIndex={tabIndex} + type="text" + value={value ?? innerValue} + /> + <DxcFlex> + {!disabled && !readOnly && clearable && (value ?? innerValue).length > 0 && ( + <DxcActionIcon + icon="close" + onClick={handleClearActionOnClick} tabIndex={tabIndex} - type="text" - role={hasSuggestions(suggestions) ? "combobox" : undefined} - aria-autocomplete={hasSuggestions(suggestions) ? "list" : undefined} - aria-controls={hasSuggestions(suggestions) ? autosuggestId : undefined} - aria-expanded={hasSuggestions(suggestions) ? isOpen : undefined} - aria-haspopup={hasSuggestions(suggestions) ? "listbox" : undefined} - aria-activedescendant={ - hasSuggestions(suggestions) && isOpen && visualFocusIndex !== -1 - ? `suggestion-${visualFocusIndex}` - : undefined - } - aria-invalid={!!error} - aria-errormessage={error ? errorId : undefined} - aria-required={!disabled && !optional} - aria-label={label ? undefined : ariaLabel} + title={translatedLabels.textInput.clearFieldActionTitle} /> - {!disabled && error && ( - <ErrorIcon aria-hidden="true"> - <DxcIcon icon="filled_error" /> - </ErrorIcon> - )} - {!disabled && !readOnly && clearable && (value ?? innerValue).length > 0 && ( + )} + {numberInputContext?.typeNumber === "number" && numberInputContext?.showControls && ( + <> <DxcActionIcon - onClick={handleClearActionOnClick} - icon="close" + disabled={disabled} + icon="remove" + onClick={!readOnly ? handleDecrementActionOnClick : undefined} + ref={actionRef} tabIndex={tabIndex} - title={translatedLabels.textInput.clearFieldActionTitle} + title={translatedLabels.numberInput.decrementValueTitle} /> - )} - {numberInputContext?.typeNumber === "number" && numberInputContext?.showControls && ( - <> - <DxcActionIcon - onClick={!readOnly ? handleDecrementActionOnClick : undefined} - icon="remove" - tabIndex={tabIndex} - ref={actionRef} - title={translatedLabels.numberInput.decrementValueTitle} - disabled={disabled} - /> - <DxcActionIcon - onClick={!readOnly ? handleIncrementActionOnClick : undefined} - icon="add" - tabIndex={tabIndex} - ref={actionRef} - title={translatedLabels.numberInput.incrementValueTitle} - disabled={disabled} - /> - </> - )} - {action && ( <DxcActionIcon - onClick={!readOnly ? action.onClick : undefined} - icon={action.icon} - tabIndex={tabIndex} - ref={actionRef} - title={action.title ?? ""} disabled={disabled} + icon="add" + onClick={!readOnly ? handleIncrementActionOnClick : undefined} + ref={actionRef} + tabIndex={tabIndex} + title={translatedLabels.numberInput.incrementValueTitle} /> - )} - </DxcFlex> - {suffix && <Suffix disabled={disabled}>{suffix}</Suffix>} - </InputContainer> - </AutosuggestWrapper> - {!disabled && typeof error === "string" && ( - <ErrorMessageContainer id={errorId} role="alert" aria-live={error ? "assertive" : "off"}> - {error} - </ErrorMessageContainer> - )} - </TextInputContainer> - </ThemeProvider> + </> + )} + {action && ( + <DxcActionIcon + disabled={disabled} + icon={action.icon} + onClick={!readOnly ? action.onClick : undefined} + ref={actionRef} + tabIndex={tabIndex} + title={action.title ?? ""} + /> + )} + </DxcFlex> + {suffix && ( + <Addon disabled={disabled} type="suffix"> + {suffix} + </Addon> + )} + </TextInput> + </AutosuggestWrapper> + {!disabled && typeof error === "string" && <ErrorMessage error={error} id={errorId} />} + </TextInputContainer> ); } );