diff --git a/apps/website/pages/components/textarea/code.tsx b/apps/website/pages/components/textarea/code.tsx new file mode 100644 index 0000000000..2347321b98 --- /dev/null +++ b/apps/website/pages/components/textarea/code.tsx @@ -0,0 +1,17 @@ +import Head from "next/head"; +import type { ReactElement } from "react"; +import TextareaPageLayout from "screens/components/textarea/TextareaPageLayout"; +import TextareaCodePage from "screens/components/textarea/code/TextareaCodePage"; + +const Code = () => ( + <> + + Textarea code — Halstack Design System + + + +); + +Code.getLayout = (page: ReactElement) => {page}; + +export default Code; diff --git a/apps/website/pages/components/textarea/index.tsx b/apps/website/pages/components/textarea/index.tsx index cc62fdc95f..96d811ca23 100644 --- a/apps/website/pages/components/textarea/index.tsx +++ b/apps/website/pages/components/textarea/index.tsx @@ -1,21 +1,17 @@ import Head from "next/head"; import type { ReactElement } from "react"; -import TextareaCodePage from "screens/components/textarea/code/TextareaCodePage"; +import TextareaOverviewPage from "screens/components/textarea/overview/TextareaOverviewPage"; import TextareaPageLayout from "screens/components/textarea/TextareaPageLayout"; -const Index = () => { - return ( - <> - - Textarea — Halstack Design System - - - - ); -}; +const Index = () => ( + <> + + Textarea — 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/textarea/specifications.tsx b/apps/website/pages/components/textarea/specifications.tsx deleted file mode 100644 index caffc46119..0000000000 --- a/apps/website/pages/components/textarea/specifications.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import Head from "next/head"; -import type { ReactElement } from "react"; -import TextareaSpecsPage from "screens/components/textarea/specs/TextareaSpecsPage"; -import TextareaPageLayout from "screens/components/textarea/TextareaPageLayout"; - -const Specifications = () => { - return ( - <> - - Textarea Specs — Halstack Design System - - - - ); -}; - -Specifications.getLayout = function getLayout(page: ReactElement) { - return {page}; -}; - -export default Specifications; diff --git a/apps/website/pages/components/textarea/usage.tsx b/apps/website/pages/components/textarea/usage.tsx deleted file mode 100644 index eb2d70ccad..0000000000 --- a/apps/website/pages/components/textarea/usage.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import Head from "next/head"; -import type { ReactElement } from "react"; -import TextareaPageLayout from "screens/components/textarea/TextareaPageLayout"; -import TextareaUsagePage from "screens/components/textarea/usage/TextareaUsagePage"; - -const Usage = () => { - return ( - <> - - Textarea Usage — Halstack Design System - - - - ); -}; - -Usage.getLayout = function getLayout(page: ReactElement) { - return {page}; -}; - -export default Usage; diff --git a/apps/website/screens/components/textarea/TextareaPageLayout.tsx b/apps/website/screens/components/textarea/TextareaPageLayout.tsx index 245469b840..fab3c09a40 100644 --- a/apps/website/screens/components/textarea/TextareaPageLayout.tsx +++ b/apps/website/screens/components/textarea/TextareaPageLayout.tsx @@ -6,9 +6,8 @@ import { ReactNode } from "react"; const TextareaPageHeading = ({ children }: { children: ReactNode }) => { const tabs = [ - { label: "Code", path: "/components/textarea" }, - { label: "Usage", path: "/components/textarea/usage" }, - { label: "Specifications", path: "/components/textarea/specifications" }, + { label: "Overview", path: "/components/textarea" }, + { label: "Code", path: "/components/textarea/code" }, ]; return ( @@ -17,7 +16,7 @@ const TextareaPageHeading = ({ children }: { children: ReactNode }) => { A textarea allows the users enter a multi-line, free-form text. - + {children} diff --git a/apps/website/screens/components/textarea/code/TextareaCodePage.tsx b/apps/website/screens/components/textarea/code/TextareaCodePage.tsx index aa6a5e8f65..cc55aad6f8 100644 --- a/apps/website/screens/components/textarea/code/TextareaCodePage.tsx +++ b/apps/website/screens/components/textarea/code/TextareaCodePage.tsx @@ -24,38 +24,60 @@ const sections = [ - defaultValue + ariaLabel string - Initial value of the textarea, only when it is uncontrolled. - - + + Specifies a string to be used as the name for the textarea element when no label is provided. + + 'Text area' - value + autocomplete string - Value of the textarea. If undefined, the component will be uncontrolled and the value will be managed - internally. + HTML autocomplete attribute. Lets the user specify if any permission the user agent has to + provide automated assistance in filling out the input value. Its value must be one of all the possible + values of the HTML autocomplete attribute. See{" "} + MDN{" "} + for further information. + + + 'off' - - - label + defaultValue string - Text to be placed above the textarea. + Initial value of the textarea, only when it is uncontrolled. - - name + disabled + + boolean + + If true, the component will be disabled. + + false + + + + error string - Name attribute of the textarea element. + + If it is a defined value and also a truthy string, the component will change its appearance, showing the + error below the textarea. 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. + - @@ -67,79 +89,71 @@ const sections = [ - - placeholder + label string - Text to be put as placeholder of the textarea. + Text to be placed above the textarea. - - disabled + margin - boolean + 'xxsmall' | 'xsmall' | 'small' | 'medium' | 'large' | 'xlarge' | 'xxlarge' | Margin - If true, the component will be disabled. - false + Size of the margin to be applied to the component. You can pass an object with 'top', 'bottom', 'left' and + 'right' properties in order to specify different margin sizes. + - - optional - - boolean - + maxLength - If true, the textarea will be optional, showing '(Optional)' next to the label. Otherwise, the field will - be considered required and an error will be passed as a parameter to the onBlur and{" "} - onChange functions when it has not been filled. + number - false + Specifies the maximum length allowed by the textarea. This will be checked both when the input element + loses the focus and while typing within it. If the string entered does not comply the maximum length, the{" "} + onBlur and onChange functions will be called with the current value and an + internal error informing that the value length does not comply the specified range. If a valid length is + reached, the error parameter of both events will not be defined. + - - readOnly + minLength - boolean + number - If true, the component will not be mutable, meaning the user can not edit the control. - false + Specifies the minimum length allowed by the textarea. This will be checked both when the input element + loses the focus and while typing within it. If the string entered does not comply the minimum length, the{" "} + onBlur and onChange functions will be called with the current value and an + internal error informing that the value length does not comply the specified range. If a valid length is + reached, the error parameter of both events will not be defined. + - - verticalGrow - - 'auto' | 'manual' | 'none' - - - Defines the textarea's ability to resize vertically. It can be: - - + name - 'auto' + string + Name attribute of the textarea element. + - - rows + onBlur - number + {"(val: { value: string; error?: string }) => void"} - Number of rows of the textarea. - 4 + This function will be called when the textarea loses the focus. An object including the textarea value and + the error (if the value entered is not valid) will be passed to this function. If there is no error,{" "} + error will not be defined. + - onChange @@ -154,29 +168,18 @@ const sections = [ - - onBlur - - {"(val: { value: string; error?: string }) => void"} - + optional - This function will be called when the textarea loses the focus. An object including the textarea value and - the error (if the value entered is not valid) will be passed to this function. If there is no error,{" "} - error will not be defined. + boolean - - - - - error - string + If true, the textarea will be optional, showing '(Optional)' next to the label. Otherwise, the field will + be considered required and an error will be passed as a parameter to the onBlur and{" "} + onChange functions when it has not been filled. - If it is a defined value and also a truthy string, the component will change its appearance, showing the - error below the textarea. 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. + false - - pattern @@ -193,59 +196,40 @@ const sections = [ - - minLength - - number - + placeholder - Specifies the minimum length allowed by the textarea. This will be checked both when the input element - loses the focus and while typing within it. If the string entered does not comply the minimum length, the{" "} - onBlur and onChange functions will be called with the current value and an - internal error informing that the value length does not comply the specified range. If a valid length is - reached, the error parameter of both events will not be defined. + string + Text to be put as placeholder of the textarea. - - maxLength + readOnly - number + boolean + If true, the component will not be mutable, meaning the user can not edit the control. - Specifies the maximum length allowed by the textarea. This will be checked both when the input element - loses the focus and while typing within it. If the string entered does not comply the maximum length, the{" "} - onBlur and onChange functions will be called with the current value and an - internal error informing that the value length does not comply the specified range. If a valid length is - reached, the error parameter of both events will not be defined. + false - - - autocomplete - - string - - - HTML autocomplete attribute. Lets the user specify if any permission the user agent has to - provide automated assistance in filling out the input value. Its value must be one of all the possible - values of the HTML autocomplete attribute. See{" "} - MDN{" "} - for further information. - + ref - 'off' + {"React.Ref"} + Reference to the component. + - - margin + rows - 'xxsmall' | 'xsmall' | 'small' | 'medium' | 'large' | 'xlarge' | 'xxlarge' | Margin + number + Number of rows of the textarea. - Size of the margin to be applied to the component. You can pass an object with 'top', 'bottom', 'left' and - 'right' properties in order to specify different margin sizes. + 4 - - size @@ -270,22 +254,38 @@ const sections = [ - ref + value - {"React.Ref"} + string + + + Value of the textarea. If undefined, the component will be uncontrolled and the value will be managed + internally. - Reference to the component. - - ariaLabel + verticalGrow - string + 'auto' | 'manual' | 'none' - Specifies a string to be used as the name for the textarea element when no label is provided. + Defines the textarea's ability to resize vertically. It can be: + + + + 'auto' - 'Text area' @@ -310,15 +310,13 @@ const sections = [ }, ]; -const TextareaCodePage = () => { - return ( - - - - - - - ); -}; +const TextareaCodePage = () => ( + + + + + + +); export default TextareaCodePage; diff --git a/apps/website/screens/components/textarea/code/examples/controlled.ts b/apps/website/screens/components/textarea/code/examples/controlled.ts index 0bc5de8c4b..f4a11bc67b 100644 --- a/apps/website/screens/components/textarea/code/examples/controlled.ts +++ b/apps/website/screens/components/textarea/code/examples/controlled.ts @@ -14,6 +14,7 @@ const code = `() => { + + Text areas are essential UI components that allow users to enter and interact with{" "} + multi-line text-based information. They are commonly used in forms to collect feedback, + comments, descriptions, and longer messages where a single-line input is not sufficient. Text areas support a + wide range of use cases, from support forms and user profiles to product reviews and detailed reports. + + + Providing clear labels, helpful guidance, and proper formatting improves the user's ability to input + information accurately and comfortably. Enhancing usability through accessibility, validation, and responsive + behavior leads to a more intuitive and effective user experience. + + + ), + }, + { + title: "Anatomy", + content: ( + <> + Textarea's anatomy + + + Label (Optional): a descriptive text that helps users understand what information + is expected in the input field. It should be clear, concise, and placed near the input for better + readability. + + + Optional indicator (Optional): a small indicator that signals the input field is + not mandatory. It helps users know they can leave the field empty without causing validation errors. + + + Placeholder/Value: a short hint displayed inside the input field before any text is + entered, offering a brief example or instruction on what type of data is expected. It disappears when the + user starts typing. The value represents the actual content entered by the user. Unlike the placeholder, the + value persists during interaction and is what gets submitted with the form. + + + Helper text (Optional): additional text placed below the input label that provides + guidance, examples, or explanations to assist users in filling out the field correctly. + + + Container: the visual wrapper around the input field that provides structure, ensures + accessibility, and helps differentiate the input from other UI elements. + + + Resizer (Optional): allows users to manually expand or shrink the textarea if + resizing is enabled. + + + + ), + }, + { + title: "Form inputs", + content: ( + <> + + Form inputs are essential UI elements that allow users to interact with digital products by{" "} + entering or selecting data. Choosing the right input type and structure is key to designing + efficient, user-friendly forms that support task completion and data accuracy. + + + A form input (also known as a form field) is used to capture user data. Common input types include text + fields, date pickers, number fields, radio buttons, checkboxes, toggles, and dropdowns. Forms should always + include a submission method, such as a submit button, link, or keyboard trigger, to complete the interaction. + + + ), + subSections: [ + { + title: "Shared input characteristics", + content: ( + <> + + Although input fields vary in type and purpose, they often share a common set of features: + + + + Placeholder: a short hint displayed inside the input field that describes its expected + value or purpose. + + + Size and max length: inputs can have both a visual size (width of the field) and a + character limit that defines how much text can be entered. + + + Prefix or suffix: some inputs include a visual element before or after the user input, + like currency symbols or units, to help clarify the expected data. + + + Helper text: additional information displayed below the field to guide the user in + providing the correct input. + + + Optional label: inputs that are not mandatory can be marked with an "Optional" tag to + set clear expectations. + + + + ), + }, + { + title: "Common input states", + content: ( + <> + Most inputs can also present standard interactive or informative states: + + + Disabled: this state prevents users from interacting with the field. It's typically + used when a value is not applicable or editable under certain conditions or roles. + + + Error: when a user enters invalid or incomplete data, the input shows an error state, + often accompanied by a helpful message to guide corrections. + + + Read-only: the input is visible, focusable, and hoverable, but not editable. This is + ideal for fields with auto-calculated values. Unlike disabled fields, read-only inputs can still be + submitted with the form and are part of the form data. + + + + ), + }, + ], + }, + { + title: "Using text areas", + content: ( + <> + + While{" "} + + text inputs + {" "} + are ideal for short, single-line entries such as names, email addresses, or search queries, text areas are + specifically designed to handle multi-line, freeform text. They offer users more space and + flexibility, making them the right choice for collecting messages, feedback, descriptions, or any content that + may extend beyond a sentence or two. + + + Choosing between a text input and a text area depends on the nature of the content you're asking for. For + instance, a "Job Title" field should use a standard text input, while a "Cover Letter" or "Project + Description" clearly benefits from a textarea. In some cases, even radio buttons, dropdowns, or checkboxes may + be more effective if the expected input can be predefined or simplified. + + + Understanding the user's intent and the expected length and complexity of the response is key to choosing the + right input type. Misusing a text area for short, simple answers can overwhelm users, while using a text input + for extended responses can result in frustration or poor usability. + + + ), + }, + { + title: "Best practices", + content: ( + + + Use the textarea for extended input: provide a textarea when users need to enter responses + longer than a single line, such as comments, messages, or descriptions. + + + Break down complex inputs: avoid using a textarea when a long question can be broken into + multiple, simpler fields. This helps reduce cognitive load and improves response quality. + + + Allow resizing only when beneficial: enable textarea resizing if the user may benefit from + adjusting the visible input area, especially for writing long or detailed content. + + + Avoid auto-expanding fields excessively: dynamic resizing can enhance usability, but ensure + it doesn't disrupt page layout or push key UI elements out of view. + + + + Provide error messages for clarity: + {" "} + use the error prop to surface validation messages. Make sure these are clear and actionable. + + + + Apply placeholder for hints, not instructions: + {" "} + use placeholder to provide example content or expected format (e.g., "Write your feedback + here…"). Avoid using it as a replacement for the label. + + + + Leverage helperText to guide users: + {" "} + add context, tips to help users complete the input accurately (e.g., "Avoid including personal information + like passwords or credit card numbers."). + + + ), + }, +]; + +const TextareaOverviewPage = () => ( + + + + + + +); + +export default TextareaOverviewPage; diff --git a/apps/website/screens/components/textarea/overview/images/textarea_anatomy.png b/apps/website/screens/components/textarea/overview/images/textarea_anatomy.png new file mode 100644 index 0000000000..4a96b3a1a0 Binary files /dev/null and b/apps/website/screens/components/textarea/overview/images/textarea_anatomy.png differ diff --git a/apps/website/screens/components/textarea/specs/TextareaSpecsPage.tsx b/apps/website/screens/components/textarea/specs/TextareaSpecsPage.tsx deleted file mode 100644 index 035b428f85..0000000000 --- a/apps/website/screens/components/textarea/specs/TextareaSpecsPage.tsx +++ /dev/null @@ -1,203 +0,0 @@ -import { DxcLink, DxcBulletedList, DxcFlex, DxcTable, DxcParagraph } from "@dxc-technology/halstack-react"; -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 DocFooter from "@/common/DocFooter"; -import Link from "next/link"; -import specsImage from "./images/textarea_specs.png"; -import statesImage from "./images/textarea_states.png"; -import anatomyImage from "./images/textarea_anatomy.png"; - -const sections = [ - { - title: "Specifications", - content: ( - <> -
- Textarea design specifications -
- - The textarea color, typography, border, width and{" "} - margin specifications are inherited from the text input, for reference check the{" "} - - text input - {" "} - component documentation. - - - The textarea doesn't have the following text-input elements, therefore, their listed styles don't - apply: - - - Action - Prefix / Suffix - Error indicator - - - ), - }, - { - title: "States", - content: ( - <> - - States: enabled, hover, focus, error and{" "} - disabled. - -
- Textarea states -
- - ), - }, - { - title: "Anatomy", - content: ( - <> - Textarea anatomy - - Label - - Helper text (Optional) - - Placeholder/Value - Container - Resizer - Error message - - - ), - }, - { - title: "Design tokens", - subSections: [ - { - title: "Spacing", - content: ( - - - - Property - Element - Core token - Value - - - - - - padding-left - - Texarea container - - spacing-8 - - 1rem / 16px - - - - padding-right - - Texarea container - - spacing-8 - - 1rem / 16px - - - - margin-top - - Texarea container - - spacing-4 - - 0.5rem / 8px - - - - margin-bottom - - Texarea container - - spacing-4 - - 0.5rem / 8px - - - - ), - }, - ], - }, - { - title: "Accessibility", - subSections: [ - { - title: "WCAG", - content: ( - - - Understanding WCAG 2.2 -{" "} - - 1.3.1: Information and Relationships - - - - Understanding WCAG 2.2 -{" "} - - 3.3.1: Error Identification - - - - Understanding WCAG 2.2 -{" "} - - 3.3.2: Labels and Instructions - - - - Understanding WCAG 2.2 -{" "} - - 3.3.3: Error Suggestion - - - - Understanding WCAG 2.2 -{" "} - - 4.1.2: Name, Role, Value - - - - ), - }, - { - title: "WAI-ARIA", - content: ( - - - WAI-ARIA Accessible Rich Internet Applications 1.2 -{" "} - - textbox role - - - - ), - }, - ], - }, -]; - -const TextareaSpecsPage = () => { - return ( - - - - - - - ); -}; - -export default TextareaSpecsPage; diff --git a/apps/website/screens/components/textarea/specs/images/textarea_anatomy.png b/apps/website/screens/components/textarea/specs/images/textarea_anatomy.png deleted file mode 100644 index 1042a81ce3..0000000000 Binary files a/apps/website/screens/components/textarea/specs/images/textarea_anatomy.png and /dev/null differ diff --git a/apps/website/screens/components/textarea/specs/images/textarea_specs.png b/apps/website/screens/components/textarea/specs/images/textarea_specs.png deleted file mode 100644 index 73e26ab5e4..0000000000 Binary files a/apps/website/screens/components/textarea/specs/images/textarea_specs.png and /dev/null differ diff --git a/apps/website/screens/components/textarea/specs/images/textarea_states.png b/apps/website/screens/components/textarea/specs/images/textarea_states.png deleted file mode 100644 index 7e85e7c0a3..0000000000 Binary files a/apps/website/screens/components/textarea/specs/images/textarea_states.png and /dev/null differ diff --git a/apps/website/screens/components/textarea/usage/TextareaUsagePage.tsx b/apps/website/screens/components/textarea/usage/TextareaUsagePage.tsx deleted file mode 100644 index d3f36bdc13..0000000000 --- a/apps/website/screens/components/textarea/usage/TextareaUsagePage.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { DxcBulletedList, DxcFlex } from "@dxc-technology/halstack-react"; -import QuickNavContainer from "@/common/QuickNavContainer"; -import QuickNavContainerLayout from "@/common/QuickNavContainerLayout"; -import DocFooter from "@/common/DocFooter"; - -const sections = [ - { - title: "Usage", - content: ( - - - Use the textarea when users need to enter text longer than a single line. - - - Avoid using the textarea when complex questions can break up in simpler ones. - - - ), - }, -]; - -const TextareaUsagePage = () => { - return ( - - - - - - - ); -}; - -export default TextareaUsagePage; diff --git a/packages/lib/src/select/Select.tsx b/packages/lib/src/select/Select.tsx index c2f8aab155..a6e0f1e957 100644 --- a/packages/lib/src/select/Select.tsx +++ b/packages/lib/src/select/Select.tsx @@ -68,10 +68,11 @@ const Select = styled.div<{ gap: var(--spacing-gap-s); height: var(--height-m); padding: var(--spacing-padding-none) var(--spacing-padding-xs); + cursor: pointer; ${({ disabled, error }) => inputStylesByState(disabled, error, false)} /* Collapse indicator */ - > span[role="img"] { + > div > span[role="img"] { color: ${({ disabled }) => (disabled ? "var(--color-fg-neutral-medium)" : "var(--color-fg-neutral-dark)")}; font-size: var(--height-xxs); } @@ -98,7 +99,7 @@ const SelectionNumber = styled.span<{ disabled: SelectPropsType["disabled"] }>` font-weight: var(--typography-label-regular); text-align: center; user-select: none; - ${({ disabled }) => (disabled ? "cursor: not-allowed;" : "cursor: default;")} + cursor: ${({ disabled }) => (disabled ? "not-allowed" : "default")}; `; const ClearOptionsAction = styled.button` diff --git a/packages/lib/src/styles/forms/inputStylesByState.tsx b/packages/lib/src/styles/forms/inputStylesByState.tsx index b31a02f0ac..c0c85d2d0c 100644 --- a/packages/lib/src/styles/forms/inputStylesByState.tsx +++ b/packages/lib/src/styles/forms/inputStylesByState.tsx @@ -1,6 +1,7 @@ import { css } from "styled-components"; export const inputStylesByState = (disabled: boolean, error: boolean, readOnly: boolean) => css` + background-color: ${disabled ? `var(--color-bg-neutral-lighter)` : `transparent`}; border-radius: var(--border-radius-s); border: ${!disabled && error ? "var(--border-width-m)" : "var(--border-width-s)"} var(--border-style-default) ${(() => { @@ -9,7 +10,6 @@ export const inputStylesByState = (disabled: boolean, error: boolean, readOnly: else if (readOnly) return "var(--border-color-neutral-strong)"; else return "var(--border-color-neutral-dark)"; })()}; - cursor: pointer; ${!disabled ? `&:hover { border-color: ${ diff --git a/packages/lib/src/textarea/Textarea.stories.tsx b/packages/lib/src/textarea/Textarea.stories.tsx index 21d037dd09..b009a5364f 100644 --- a/packages/lib/src/textarea/Textarea.stories.tsx +++ b/packages/lib/src/textarea/Textarea.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 DxcTextarea from "./Textarea"; export default { @@ -9,13 +8,6 @@ export default { component: DxcTextarea, } as Meta; -const opinionatedTheme = { - textarea: { - fontColor: "#000000", - hoverBorderColor: "#a46ede", - }, -}; - const TextArea = () => ( <> @@ -69,11 +61,11 @@ const TextArea = () => ( - + <Title title="With scroll" theme="light" level={4} /> <DxcTextarea label="Manual vertical grow" verticalGrow="manual" - defaultValue="Long textttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttt" + defaultValue="Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." /> </ExampleContainer> <Title title="Sizes" theme="light" level={2} /> @@ -118,58 +110,6 @@ const TextArea = () => ( <Title title="Xxlarge margin" theme="light" level={4} /> <DxcTextarea label="Xxlarge" margin="xxlarge" /> </ExampleContainer> - <Title title="Opinionated theme" theme="light" level={2} /> - <ExampleContainer pseudoState="pseudo-hover"> - <Title title="Hovered" theme="light" level={4} /> - <HalstackProvider theme={opinionatedTheme}> - <DxcTextarea label="Hovered" helperText="Sample text" placeholder="Placeholder" /> - </HalstackProvider> - </ExampleContainer> - <ExampleContainer pseudoState="pseudo-focus"> - <Title title="Focused" theme="light" level={4} /> - <HalstackProvider theme={opinionatedTheme}> - <DxcTextarea label="Focused" helperText="Sample text" /> - </HalstackProvider> - </ExampleContainer> - <ExampleContainer> - <Title title="Disabled" theme="light" level={4} /> - <HalstackProvider theme={opinionatedTheme}> - <DxcTextarea - label="Disabled" - optional - helperText="Sample text" - placeholder="Enter your text here..." - disabled - /> - </HalstackProvider> - </ExampleContainer> - <ExampleContainer> - <Title title="Disabled with value" theme="light" level={4} /> - <HalstackProvider theme={opinionatedTheme}> - <DxcTextarea label="Disabled" helperText="Sample text" defaultValue="Example text" disabled /> - </HalstackProvider> - </ExampleContainer> - <ExampleContainer> - <Title title="With error" theme="light" level={4} /> - <HalstackProvider theme={opinionatedTheme}> - <DxcTextarea - label="Textarea with error" - helperText="Helper text" - placeholder="Enter your text here..." - error="Error message." - /> - </HalstackProvider> - </ExampleContainer> - <ExampleContainer> - <Title title="Grow manual" theme="light" level={4} />{" "} - <HalstackProvider theme={opinionatedTheme}> - <DxcTextarea - label="Manual vertical grow" - verticalGrow="manual" - defaultValue="Long textttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttt" - /> - </HalstackProvider> - </ExampleContainer> </> ); diff --git a/packages/lib/src/textarea/Textarea.tsx b/packages/lib/src/textarea/Textarea.tsx index 3cacf607b5..b1bd6d491a 100644 --- a/packages/lib/src/textarea/Textarea.tsx +++ b/packages/lib/src/textarea/Textarea.tsx @@ -1,66 +1,116 @@ import { ChangeEvent, FocusEvent, forwardRef, useContext, useEffect, useId, useRef, useState } from "react"; -import styled, { ThemeProvider } from "styled-components"; +import styled from "styled-components"; import { getMargin } from "../common/utils"; import { spaces } from "../common/variables"; -import HalstackContext, { HalstackLanguageContext } from "../HalstackContext"; +import { HalstackLanguageContext } from "../HalstackContext"; import TextareaPropsType, { RefType } from "./types"; +import { scrollbarStyles } from "../styles/scroll"; +import ErrorMessage from "../styles/forms/ErrorMessage"; +import Label from "../styles/forms/Label"; +import HelperText from "../styles/forms/HelperText"; +import { inputStylesByState } from "../styles/forms/inputStylesByState"; + +const sizes = { + small: "240px", + medium: "360px", + large: "480px", + fillParent: "100%", +}; + +const calculateWidth = (margin: TextareaPropsType["margin"], size: TextareaPropsType["size"]) => + size === "fillParent" + ? `calc(${sizes[size]} - ${getMargin(margin, "left")} - ${getMargin(margin, "right")})` + : size && sizes[size]; + +const TextareaContainer = styled.div<{ + margin: TextareaPropsType["margin"]; + size: TextareaPropsType["size"]; +}>` + display: flex; + flex-direction: column; + width: ${({ margin, size }) => 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 Textarea = styled.textarea<{ + disabled: Required<TextareaPropsType>["disabled"]; + error: boolean; + readOnly: Required<TextareaPropsType>["readOnly"]; + verticalGrow: TextareaPropsType["verticalGrow"]; +}>` + ${({ verticalGrow }) => { + if (verticalGrow === "none") return "resize: none;"; + else if (verticalGrow === "auto") return `resize: none; overflow: hidden;`; + else if (verticalGrow === "manual") return "resize: vertical;"; + else return `resize: none;`; + }}; + padding: var(--spacing-padding-xs) var(--spacing-padding-xs) var(--spacing-padding-xxxs) var(--spacing-padding-xs); + ${({ disabled, error, readOnly }) => inputStylesByState(disabled, error, readOnly)} + 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); + line-height: 1.36; + ${scrollbarStyles} + ::placeholder { + color: ${({ disabled }) => (disabled ? "var(--color-fg-neutral-medium)" : "var(--color-fg-neutral-strong)")}; + } +`; const patternMatch = (pattern: string, value: string) => new RegExp(pattern).test(value); const DxcTextarea = forwardRef<RefType, TextareaPropsType>( ( { - label, - name = "", + ariaLabel = "Text area", + autocomplete = "off", defaultValue = "", - value, - helperText, - placeholder = "", disabled = false, - readOnly = false, - optional = false, - verticalGrow = "auto", - rows = 4, - onChange, - onBlur, error, - pattern, - minLength, - maxLength, - autocomplete = "off", + helperText, + label, margin, + maxLength, + minLength, + name, + onBlur, + onChange, + optional = false, + pattern, + placeholder, + readOnly = false, + rows = 4, size = "medium", tabIndex = 0, - ariaLabel = "Text area", + value, + verticalGrow = "auto", }, ref ) => { const [innerValue, setInnerValue] = useState(defaultValue); const textareaId = `textarea-${useId()}`; - - const colorsTheme = useContext(HalstackContext); + const errorId = `error-${textareaId}`; const translatedLabels = useContext(HalstackLanguageContext); - const textareaRef = useRef<HTMLTextAreaElement | null>(null); const prevValueRef = useRef<string | null>(null); - const errorId = `error-${textareaId}`; - - const isNotOptional = (value: string) => value === "" && !optional; - const isLengthIncorrect = (value: string) => + const isLengthOutOfRange = (value: string) => value !== "" && minLength && maxLength && (value.length < minLength || value.length > maxLength); const changeValue = (newValue: string) => { - if (value == null) { - setInnerValue(newValue); - } + if (value == null) setInnerValue(newValue); - if (isNotOptional(newValue)) { + if (newValue === "" && !optional) { onChange?.({ value: newValue, error: translatedLabels.formFields.requiredValueErrorMessage, }); - } else if (isLengthIncorrect(newValue)) { + } else if (isLengthOutOfRange(newValue)) { onChange?.({ value: newValue, error: translatedLabels.formFields.lengthErrorMessage?.(minLength, maxLength), @@ -70,18 +120,16 @@ const DxcTextarea = forwardRef<RefType, TextareaPropsType>( value: newValue, error: translatedLabels.formFields.formatRequestedErrorMessage, }); - } else { - onChange?.({ value: newValue }); - } + } else onChange?.({ value: newValue }); }; const handleOnBlur = (event: FocusEvent<HTMLTextAreaElement>) => { - if (isNotOptional(event.target.value)) { + if (event.target.value === "" && !optional) { onBlur?.({ value: event.target.value, error: translatedLabels.formFields.requiredValueErrorMessage, }); - } else if (isLengthIncorrect(event.target.value)) { + } else if (isLengthOutOfRange(event.target.value)) { onBlur?.({ value: event.target.value, error: translatedLabels.formFields.lengthErrorMessage?.(minLength, maxLength), @@ -91,9 +139,7 @@ const DxcTextarea = forwardRef<RefType, TextareaPropsType>( value: event.target.value, error: translatedLabels.formFields.formatRequestedErrorMessage, }); - } else { - onBlur?.({ value: event.target.value }); - } + } else onBlur?.({ value: event.target.value }); }; const handleOnChange = (event: ChangeEvent<HTMLTextAreaElement>) => { @@ -103,186 +149,53 @@ const DxcTextarea = forwardRef<RefType, TextareaPropsType>( useEffect(() => { if (verticalGrow === "auto" && prevValueRef.current !== (value ?? innerValue) && textareaRef.current) { const computedStyle = window.getComputedStyle(textareaRef.current); - const textareaLineHeight = parseInt(computedStyle.lineHeight || "0", 10); - const textareaPaddingTopBottom = parseInt(computedStyle.paddingTop || "0", 10) * 2; + const textareaLineHeight = parseInt(computedStyle.lineHeight ?? "0", 10); + const textareaPaddingTopBottom = parseInt(computedStyle.paddingTop ?? "0", 10) * 2; textareaRef.current.style.height = `${textareaLineHeight * rows}px`; const newHeight = (textareaRef.current.scrollHeight ?? 0) - textareaPaddingTopBottom; textareaRef.current.style.height = `${newHeight}px`; prevValueRef.current = value ?? innerValue; } - }, [verticalGrow, value, innerValue, rows]); + }, [innerValue, rows, value, verticalGrow]); return ( - <ThemeProvider theme={colorsTheme.textarea}> - <TextareaContainer margin={margin} size={size} ref={ref}> - {label && ( - <Label htmlFor={textareaId} disabled={disabled} helperText={helperText}> - {label} {optional && <OptionalLabel>{translatedLabels.formFields.optionalLabel}</OptionalLabel>} - </Label> - )} - {helperText && <HelperText disabled={disabled}>{helperText}</HelperText>} - <Textarea - id={textareaId} - name={name} - value={value ?? innerValue} - placeholder={placeholder} - verticalGrow={verticalGrow} - rows={rows} - onChange={handleOnChange} - onBlur={handleOnBlur} - disabled={disabled} - readOnly={readOnly} - error={error} - minLength={minLength} - maxLength={maxLength} - autoComplete={autocomplete} - ref={textareaRef} - tabIndex={tabIndex} - aria-invalid={!!error} - aria-errormessage={error ? errorId : undefined} - aria-required={!disabled && !optional} - aria-label={label ? undefined : ariaLabel} - /> - {!disabled && typeof error === "string" && ( - <ErrorMessageContainer id={errorId} role="alert" aria-live={error ? "assertive" : "off"}> - {error} - </ErrorMessageContainer> - )} - </TextareaContainer> - </ThemeProvider> + <TextareaContainer margin={margin} size={size} ref={ref}> + {label && ( + <Label disabled={disabled} hasMargin={!helperText} htmlFor={textareaId}> + {label} {optional && <span>{translatedLabels.formFields.optionalLabel}</span>} + </Label> + )} + {helperText && ( + <HelperText disabled={disabled} hasMargin> + {helperText} + </HelperText> + )} + <Textarea + aria-errormessage={error ? errorId : undefined} + aria-invalid={!!error} + aria-label={label ? undefined : ariaLabel} + aria-required={!disabled && !optional} + autoComplete={autocomplete} + disabled={disabled} + error={!!error} + id={textareaId} + maxLength={maxLength} + minLength={minLength} + name={name} + onBlur={handleOnBlur} + onChange={handleOnChange} + placeholder={placeholder} + readOnly={readOnly} + ref={textareaRef} + rows={rows} + tabIndex={tabIndex} + value={value ?? innerValue} + verticalGrow={verticalGrow} + /> + {!disabled && typeof error === "string" && <ErrorMessage error={error} id={errorId} />} + </TextareaContainer> ); } ); -const sizes = { - small: "240px", - medium: "360px", - large: "480px", - fillParent: "100%", -}; - -const calculateWidth = (margin: TextareaPropsType["margin"], size: TextareaPropsType["size"]) => - size === "fillParent" - ? `calc(${sizes[size]} - ${getMargin(margin, "left")} - ${getMargin(margin, "right")})` - : size && sizes[size]; - -const TextareaContainer = styled.div<{ - margin: TextareaPropsType["margin"]; - size: TextareaPropsType["size"]; -}>` - display: flex; - flex-direction: column; - width: ${(props) => calculateWidth(props.margin, props.size)}; - margin: ${(props) => (props.margin && typeof props.margin !== "object" ? spaces[props.margin] : "0px")}; - margin-top: ${(props) => - props.margin && typeof props.margin === "object" && props.margin.top ? spaces[props.margin.top] : ""}; - margin-right: ${(props) => - props.margin && typeof props.margin === "object" && props.margin.right ? spaces[props.margin.right] : ""}; - margin-bottom: ${(props) => - props.margin && typeof props.margin === "object" && props.margin.bottom ? spaces[props.margin.bottom] : ""}; - margin-left: ${(props) => - props.margin && typeof props.margin === "object" && props.margin.left ? spaces[props.margin.left] : ""}; -`; - -const Label = styled.label<{ - disabled: TextareaPropsType["disabled"]; - helperText: TextareaPropsType["helperText"]; -}>` - 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: 0.25rem`} -`; - -const OptionalLabel = styled.span` - font-weight: ${(props) => props.theme.optionalLabelFontWeight}; -`; - -const HelperText = styled.span<{ disabled: TextareaPropsType["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: 0.25rem; -`; - -const Textarea = styled.textarea<{ - verticalGrow: TextareaPropsType["verticalGrow"]; - error: TextareaPropsType["error"]; -}>` - ${({ verticalGrow }) => { - if (verticalGrow === "none") return "resize: none;"; - else if (verticalGrow === "auto") return `resize: none; overflow: hidden;`; - else if (verticalGrow === "manual") return "resize: vertical;"; - else return `resize: none;`; - }}; - - ${(props) => - props.disabled ? `background-color: ${props.theme.disabledContainerFillColor};` : `background-color: transparent;`} - - padding: 0.5rem 1rem; - box-shadow: 0 0 0 2px transparent; - border-radius: 0.25rem; - border: 1px solid - ${(props) => { - if (props.disabled) return props.theme.disabledBorderColor; - else if (props.error) return "transparent"; - else if (props.readOnly) return props.theme.readOnlyBorderColor; - else return props.theme.enabledBorderColor; - }}; - - ${(props) => - props.error && - !props.disabled && - `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, &:focus-within { - outline: none; - border-color: transparent; - box-shadow: 0 0 0 2px ${props.theme.focusBorderColor}; - }` - : "cursor: not-allowed;"}; - - 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; - - ::placeholder { - color: ${(props) => (props.disabled ? props.theme.disabledPlaceholderFontColor : props.theme.placeholderFontColor)}; - } -`; - -const ErrorMessageContainer = styled.span` - color: ${(props) => props.theme.errorMessageColor}; - font-family: ${(props) => props.theme.fontFamily}; - font-size: 0.75rem; - font-weight: 400; - min-height: 1.5em; - line-height: 1.5em; - margin-top: 0.25rem; -`; - export default DxcTextarea; diff --git a/packages/lib/src/textarea/types.ts b/packages/lib/src/textarea/types.ts index 5fff2da46b..b1c9d3c6a8 100644 --- a/packages/lib/src/textarea/types.ts +++ b/packages/lib/src/textarea/types.ts @@ -2,55 +2,75 @@ import { Margin, Space } from "../common/utils"; type Props = { /** - * Text to be placed above the textarea. + * Specifies a string to be used as the name for the textarea element when no `label` is provided. */ - label?: string; + ariaLabel?: string; /** - * Name attribute of the textarea element. + * HTML autocomplete attribute. Lets the user specify if any permission the user agent has to provide automated assistance in filling out the textarea value. + * Its value must be one of all the possible values of the HTML autocomplete attribute: 'on', 'off', 'email', 'username', 'new-password', ... */ - name?: string; + autocomplete?: string; + /** + * If true, the component will be disabled. + */ + disabled?: boolean; /** * Initial value of the textarea, only when it is uncontrolled. */ defaultValue?: string; /** - * Value of the textarea. If undefined, the component will be uncontrolled and the value will be managed internally. + * If it is a defined value and also a truthy string, the component will + * change its appearance, showing the error below the textarea. 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. */ - value?: string; + error?: string; /** * Helper text to be placed above the textarea. */ helperText?: string; /** - * Text to be put as placeholder of the textarea. + * Text to be placed above the textarea. */ - placeholder?: string; + label?: string; /** - * If true, the component will be disabled. + * Specifies the maximum length allowed by the textarea. + * This will be checked both when the textarea loses the + * focus and while typing within it. If the string entered does not + * comply the maximum length, the onBlur and onChange functions will be called + * with the current value and an internal error informing that the value + * length does not comply the specified range. If a valid length is + * reached, the error parameter of both events will not be defined. */ - disabled?: boolean; + maxLength?: number; /** - * If true, the component will not be mutable, meaning the user can not edit the control. + * Size of the margin to be applied to the component ('xxsmall' | 'xsmall' | 'small' | 'medium' | 'large' | 'xlarge' | 'xxlarge'). + * You can pass an object with 'top', 'bottom', 'left' and 'right' properties in order to specify different margin sizes. */ - readOnly?: boolean; + margin?: Space | Margin; /** - * If true, the textarea will be optional, showing '(Optional)' - * next to the label. Otherwise, the field will be considered required - * and an error will be passed as a parameter to the OnBlur and onChange functions - * when it has not been filled. + * Specifies the minimum length allowed by the textarea. + * This will be checked both when the textarea loses the + * focus and while typing within it. If the string entered does not + * comply the minimum length, the onBlur and onChange functions will be called + * with the current value and an internal error informing that the value + * length does not comply the specified range. If a valid length is + * reached, the error parameter of both events will not be defined. */ - optional?: boolean; + minLength?: number; /** - * Defines the textarea's ability to resize vertically. It can be: - * - 'auto': The textarea grows or shrinks automatically in order to fit the content. - * - 'manual': The height of the textarea is enabled to be manually modified. - * - 'none': The textarea has a fixed height and can't be modified. + * Name attribute of the textarea element. */ - verticalGrow?: "auto" | "manual" | "none"; + name?: string; /** - * Number of rows of the textarea. + * This function will be called when the textarea loses the focus. An + * object including the textarea value and the error (if the value entered + * is not valid) will be passed to this function. If there is no error, + * error will not be defined. */ - rows?: number; + onBlur?: (val: { value: string; error?: string }) => void; /** * This function will be called when the user types within the textarea. * An object including the current value and the error (if the value @@ -59,21 +79,12 @@ type Props = { */ onChange?: (val: { value: string; error?: string }) => void; /** - * This function will be called when the textarea loses the focus. An - * object including the textarea value and the error (if the value entered - * is not valid) will be passed to this function. If there is no error, - * error will not be defined. - */ - onBlur?: (val: { value: string; error?: string }) => void; - /** - * If it is a defined value and also a truthy string, the component will - * change its appearance, showing the error below the textarea. 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. + * If true, the textarea will be optional, showing '(Optional)' + * next to the label. Otherwise, the field will be considered required + * and an error will be passed as a parameter to the OnBlur and onChange functions + * when it has not been filled. */ - error?: string; + optional?: boolean; /** * Regular expression that defines the valid format allowed by the * textarea. This will be checked both when the textarea loses the focus @@ -85,35 +96,17 @@ type Props = { */ pattern?: string; /** - * Specifies the minimum length allowed by the textarea. - * This will be checked both when the textarea loses the - * focus and while typing within it. If the string entered does not - * comply the minimum length, the onBlur and onChange functions will be called - * with the current value and an internal error informing that the value - * length does not comply the specified range. If a valid length is - * reached, the error parameter of both events will not be defined. - */ - minLength?: number; - /** - * Specifies the maximum length allowed by the textarea. - * This will be checked both when the textarea loses the - * focus and while typing within it. If the string entered does not - * comply the maximum length, the onBlur and onChange functions will be called - * with the current value and an internal error informing that the value - * length does not comply the specified range. If a valid length is - * reached, the error parameter of both events will not be defined. + * Text to be put as placeholder of the textarea. */ - maxLength?: number; + placeholder?: string; /** - * HTML autocomplete attribute. Lets the user specify if any permission the user agent has to provide automated assistance in filling out the textarea value. - * Its value must be one of all the possible values of the HTML autocomplete attribute: 'on', 'off', 'email', 'username', 'new-password', ... + * If true, the component will not be mutable, meaning the user can not edit the control. */ - autocomplete?: string; + readOnly?: boolean; /** - * Size of the margin to be applied to the component ('xxsmall' | 'xsmall' | 'small' | 'medium' | 'large' | 'xlarge' | 'xxlarge'). - * You can pass an object with 'top', 'bottom', 'left' and 'right' properties in order to specify different margin sizes. + * Number of rows of the textarea. */ - margin?: Space | Margin; + rows?: number; /** * Size of the component. */ @@ -123,10 +116,18 @@ type Props = { */ tabIndex?: number; /** - * Specifies a string to be used as the name for the textarea element when no `label` is provided. + * Value of the textarea. If undefined, the component will be uncontrolled and the value will be managed internally. */ - ariaLabel?: string; + value?: string; + /** + * Defines the textarea's ability to resize vertically. It can be: + * - 'auto': The textarea grows or shrinks automatically in order to fit the content. + * - 'manual': The height of the textarea is enabled to be manually modified. + * - 'none': The textarea has a fixed height and can't be modified. + */ + verticalGrow?: "auto" | "manual" | "none"; }; + /** * Reference to the component. */