@purpurds/text-area 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/readme.mdx ADDED
@@ -0,0 +1,60 @@
1
+ import { Meta, Stories, ArgTypes, Primary, Subtitle } from "@storybook/blocks";
2
+
3
+ import * as TextAreaStories from "./src/text-area.stories";
4
+ import packageInfo from "./package.json";
5
+
6
+ <Meta name="Docs" title="Components/TextArea" of={TextAreaStories} />
7
+
8
+ # TextArea
9
+
10
+ <Subtitle>Version {packageInfo.version}</Subtitle>
11
+
12
+ ### Showcase
13
+
14
+ <Primary />
15
+
16
+ ### Properties
17
+
18
+ Except for the props below, [all "native" textarea attributes](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/textarea) are also valid props. The only exception is:
19
+
20
+ - `id` - Required
21
+
22
+ <ArgTypes />
23
+
24
+ ### Installation
25
+
26
+ #### Via NPM
27
+
28
+ Add the dependency to your consumer app like `"@purpurds/text-area": "x.y.z"`
29
+
30
+ #### From outside the monorepo (build-time)
31
+
32
+ To install this package, you need to setup access to the artifactory. [Click here to go to the guide on how to do that](https://github.com/telia-company/jfrog-documentation/blob/main/doc/JFrog/JFrog_Onboarding.md#getting-access-to-artifactory-and-other-jfrog-applications).
33
+
34
+ ---
35
+
36
+ In MyApp.tsx
37
+
38
+ ```tsx
39
+ import "@purpurds/tokens/index.css";
40
+ ```
41
+
42
+ and
43
+
44
+ ```tsx
45
+ import "@purpurds/text-area/styles";
46
+ ```
47
+
48
+ In MyComponent.tsx
49
+
50
+ ```tsx
51
+ import { TextArea } from "@purpurds/text-area";
52
+
53
+ export const MyComponent = () => {
54
+ return (
55
+ <div>
56
+ <TextArea {...someProps}>Some content</TextArea>
57
+ </div>
58
+ );
59
+ };
60
+ ```
@@ -0,0 +1,22 @@
1
+ import { purpurColorTextMedium, purpurColorTextWeak } from "@purpurds/tokens";
2
+
3
+ import fs from "fs";
4
+ import path from "path";
5
+ import dedent from "dedent";
6
+
7
+ const srcDir = path.join(process.cwd(), "src");
8
+ const assetsDir = path.join(srcDir, "assets");
9
+
10
+ [
11
+ { name: "default", color: purpurColorTextMedium },
12
+ { name: "disabled", color: purpurColorTextWeak },
13
+ ].forEach(({ name, color }) => {
14
+ const svgFileContent = dedent`
15
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
16
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M2.29298 12.9028C1.90234 12.5121 1.90234 11.8788 2.29298 11.4881L11.4881 2.29298C11.8788 1.90234 12.5121 1.90234 12.9028 2.29298C13.2934 2.68362 13.2934 3.31698 12.9028 3.70762L3.70762 12.9028C3.31698 13.2934 2.68362 13.2934 2.29298 12.9028Z" fill="${color}" />
17
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M8.2948 14.307C7.90415 13.9164 7.90415 13.283 8.2948 12.8924L12.8924 8.2948C13.283 7.90415 13.9164 7.90415 14.307 8.2948C14.6977 8.68544 14.6977 9.3188 14.307 9.70944L9.70944 14.307C9.31879 14.6977 8.68544 14.6977 8.2948 14.307Z" fill="${color}" />
18
+ </svg>
19
+ `;
20
+
21
+ fs.writeFileSync(path.join(assetsDir, `resize-handle-${name}-icon.svg`), svgFileContent);
22
+ });
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
2
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M2.29298 12.9028C1.90234 12.5121 1.90234 11.8788 2.29298 11.4881L11.4881 2.29298C11.8788 1.90234 12.5121 1.90234 12.9028 2.29298C13.2934 2.68362 13.2934 3.31698 12.9028 3.70762L3.70762 12.9028C3.31698 13.2934 2.68362 13.2934 2.29298 12.9028Z" fill="rgba(0, 0, 0, 0.62)" />
3
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M8.2948 14.307C7.90415 13.9164 7.90415 13.283 8.2948 12.8924L12.8924 8.2948C13.283 7.90415 13.9164 7.90415 14.307 8.2948C14.6977 8.68544 14.6977 9.3188 14.307 9.70944L9.70944 14.307C9.31879 14.6977 8.68544 14.6977 8.2948 14.307Z" fill="rgba(0, 0, 0, 0.62)" />
4
+ </svg>
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
2
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M2.29298 12.9028C1.90234 12.5121 1.90234 11.8788 2.29298 11.4881L11.4881 2.29298C11.8788 1.90234 12.5121 1.90234 12.9028 2.29298C13.2934 2.68362 13.2934 3.31698 12.9028 3.70762L3.70762 12.9028C3.31698 13.2934 2.68362 13.2934 2.29298 12.9028Z" fill="rgba(0, 0, 0, 0.44)" />
3
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M8.2948 14.307C7.90415 13.9164 7.90415 13.283 8.2948 12.8924L12.8924 8.2948C13.283 7.90415 13.9164 7.90415 14.307 8.2948C14.6977 8.68544 14.6977 9.3188 14.307 9.70944L9.70944 14.307C9.31879 14.6977 8.68544 14.6977 8.2948 14.307Z" fill="rgba(0, 0, 0, 0.44)" />
4
+ </svg>
@@ -0,0 +1,4 @@
1
+ declare module "*.scss" {
2
+ const styles: { [className: string]: string };
3
+ export default styles;
4
+ }
@@ -0,0 +1,118 @@
1
+ .purpur-text-area {
2
+ $root: &;
3
+
4
+ position: relative;
5
+ display: inline-flex;
6
+ flex-direction: column;
7
+ gap: var(--purpur-spacing-50);
8
+ max-width: 100%;
9
+
10
+ &__label {
11
+ display: flex;
12
+ flex-direction: column;
13
+ gap: var(--purpur-spacing-50);
14
+ width: fit-content;
15
+ }
16
+
17
+ &__label-text {
18
+ width: fit-content;
19
+ }
20
+
21
+ &__frame {
22
+ position: absolute;
23
+ inset: 0;
24
+ border: var(--purpur-border-width-xs) solid var(--purpur-color-border-interactive-subtle);
25
+ border-radius: var(--purpur-border-radius-sm);
26
+ pointer-events: none;
27
+ }
28
+
29
+ &__textarea-container {
30
+ position: relative;
31
+ display: flex;
32
+ align-items: center;
33
+ width: 100%;
34
+ padding: 0 var(--purpur-spacing-100) var(--purpur-spacing-100) 0;
35
+ border-radius: var(--purpur-border-radius-sm);
36
+ box-sizing: border-box;
37
+ background: var(--purpur-color-background-primary);
38
+
39
+ &--disabled {
40
+ background: var(--purpur-color-background-interactive-disabled);
41
+ }
42
+
43
+ &--readonly {
44
+ background: var(--purpur-color-background-interactive-read-only);
45
+ }
46
+ }
47
+
48
+ &__textarea {
49
+ $textAreaRoot: &;
50
+
51
+ width: 100%;
52
+ max-width: 100%; /* Needed to keep the textarea inside its container when resizing in Firefox */
53
+ padding: var(--purpur-spacing-150) 0 var(--purpur-spacing-200) var(--purpur-spacing-150);
54
+ outline: none;
55
+ border: none;
56
+ border-radius: var(--purpur-border-radius-sm);
57
+ font-family: var(--purpur-typography-family-default), Helvetica, Arial, "Lucida Grande",
58
+ sans-serif;
59
+ font-size: var(--purpur-typography-scale-100);
60
+ line-height: 150%;
61
+ background: transparent;
62
+
63
+ &::-webkit-resizer {
64
+ background: url("./assets/resize-handle-default-icon.svg");
65
+ background-size: cover;
66
+ }
67
+
68
+ &:hover {
69
+ ~ #{$root}__frame {
70
+ border-width: var(--purpur-border-width-sm);
71
+ border-color: var(--purpur-color-border-interactive-subtle-hover);
72
+ }
73
+ }
74
+
75
+ &:active:not(:disabled):not(:read-only),
76
+ &:focus:not(:disabled):not(:read-only) {
77
+ ~ #{$root}__frame {
78
+ outline: var(--purpur-border-width-sm) solid var(--purpur-color-border-interactive-focus);
79
+ outline-offset: calc(var(--purpur-spacing-10) * 2);
80
+ border-width: var(--purpur-border-width-xs);
81
+ border-color: var(--purpur-color-border-interactive-subtle-hover);
82
+ }
83
+ }
84
+
85
+ &:disabled {
86
+ color: var(--purpur-color-text-weak);
87
+
88
+ ~ #{$root}__frame {
89
+ border-width: var(--purpur-border-width-xs);
90
+ border-color: var(--purpur-color-border-medium);
91
+ }
92
+
93
+ &::-webkit-resizer {
94
+ background: url("./assets/resize-handle-disabled-icon.svg");
95
+ }
96
+ }
97
+
98
+ &:read-only:not(:disabled) {
99
+ color: var(--purpur-color-text-default);
100
+
101
+ ~ #{$root}__frame {
102
+ border-width: var(--purpur-border-width-xs);
103
+ }
104
+
105
+ &:not(#{$textAreaRoot}--valid):not(#{$textAreaRoot}--error) {
106
+ ~ #{$root}__frame {
107
+ border-color: var(--purpur-color-border-medium);
108
+ }
109
+ }
110
+ }
111
+
112
+ &#{$textAreaRoot}--error {
113
+ ~ #{$root}__frame {
114
+ border-color: var(--purpur-color-border-status-error);
115
+ }
116
+ }
117
+ }
118
+ }
@@ -0,0 +1,64 @@
1
+ import React from "react";
2
+ import { useArgs } from "@storybook/client-api";
3
+ import type { Meta, StoryObj } from "@storybook/react";
4
+
5
+ import "@purpurds/label/styles";
6
+ import "@purpurds/field-helper-text/styles";
7
+ import "@purpurds/field-error-text/styles";
8
+ import { TextArea } from "./text-area";
9
+
10
+ const meta: Meta<typeof TextArea> = {
11
+ title: "Inputs/TextArea",
12
+ component: TextArea,
13
+ };
14
+
15
+ export default meta;
16
+ type Story = StoryObj<typeof TextArea>;
17
+
18
+ export const Showcase: Story = {
19
+ args: {
20
+ value: "Text area",
21
+ label: "Text area label",
22
+ helperText: "Helper text",
23
+ placeholder: "Enter text",
24
+ errorText: undefined,
25
+ disabled: false,
26
+ readOnly: false,
27
+ required: false,
28
+ rows: 3,
29
+ cols: 90,
30
+ },
31
+ argTypes: {
32
+ onChange: { action: "inputChange", table: { disable: true } },
33
+ },
34
+ parameters: {
35
+ design: [
36
+ {
37
+ name: "TextArea",
38
+ type: "figma",
39
+ url: "https://www.figma.com/file/XEaIIFskrrxIBHMZDkIuIg/Purpur-DS---Component-library-%26-guidelines?type=design&node-id=1565-1896&mode=design&t=diHAdohOrRRVp09w-0",
40
+ },
41
+ ],
42
+ },
43
+ decorators: [
44
+ (Story) => (
45
+ <div style={{ maxWidth: "18.5rem" }}>
46
+ <Story />
47
+ </div>
48
+ ),
49
+ ],
50
+ render: ({ onChange, ...args }) => {
51
+ const [{ value }, updateArgs] = useArgs(); // eslint-disable-line react-hooks/rules-of-hooks
52
+ return (
53
+ <TextArea
54
+ {...args}
55
+ id="playground-textarea"
56
+ value={value}
57
+ onChange={(e) => {
58
+ onChange?.(e);
59
+ updateArgs({ value: e.target.value });
60
+ }}
61
+ />
62
+ );
63
+ },
64
+ };
@@ -0,0 +1,113 @@
1
+ import React from "react";
2
+ import * as matchers from "@testing-library/jest-dom/matchers";
3
+ import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+
6
+ import { TextArea } from "./text-area";
7
+ const rootClassName = "purpur-text-area";
8
+
9
+ expect.extend(matchers);
10
+
11
+ describe("TextArea", () => {
12
+ afterEach(cleanup);
13
+
14
+ it("should render plain", () => {
15
+ render(<TextArea id="test" data-testid="test" />);
16
+
17
+ const textarea = screen.getByTestId("test-textarea");
18
+ expect(textarea).toBeInTheDocument();
19
+ expect(textarea.className).toBe(`${rootClassName}__textarea`);
20
+ expect(screen.queryByTestId("test-label")).not.toBeInTheDocument();
21
+ expect(screen.queryByTestId("test-helper-text")).not.toBeInTheDocument();
22
+ });
23
+
24
+ it("should render with label", () => {
25
+ render(<TextArea id="test" data-testid="test" label="Test label" />);
26
+
27
+ const textarea = screen.getByTestId("test-textarea");
28
+ expect(textarea).toBeInTheDocument();
29
+ expect(textarea.className).toBe(`${rootClassName}__textarea`);
30
+ expect(screen.getByTestId("test-label")).toHaveTextContent("Test label");
31
+ expect(screen.queryByTestId("test-helper-text")).not.toBeInTheDocument();
32
+ });
33
+
34
+ it("should render with helper-text", () => {
35
+ render(<TextArea id="test" data-testid="test" label="Test label" helperText="Helper text" />);
36
+
37
+ const textarea = screen.getByTestId("test-textarea");
38
+ expect(textarea).toBeInTheDocument();
39
+ expect(textarea.className).toBe(`${rootClassName}__textarea`);
40
+ expect(screen.getByTestId("test-label")).toHaveTextContent("Test label");
41
+ expect(screen.getByTestId("test-helper-text")).toHaveTextContent("Helper text");
42
+ });
43
+
44
+ it("should render with helper-text and error-text", () => {
45
+ render(
46
+ <TextArea
47
+ errorText="Error text"
48
+ id="test"
49
+ data-testid="test"
50
+ label="Test label"
51
+ helperText="Helper text"
52
+ />
53
+ );
54
+
55
+ const textarea = screen.getByTestId("test-textarea");
56
+ expect(textarea).toBeInTheDocument();
57
+ expect(textarea).toHaveClass(`${rootClassName}__textarea--error`);
58
+ expect(screen.getByTestId("test-label")).toHaveTextContent("Test label");
59
+ expect(screen.getByTestId("test-helper-text")).toHaveTextContent("Helper text");
60
+ expect(screen.getByTestId("test-error-text")).toHaveTextContent("Error text");
61
+ expect(screen.getByTestId("test-error-text").querySelector("svg")).toBeInTheDocument();
62
+ });
63
+
64
+ it("should render required", () => {
65
+ render(
66
+ <TextArea required id="test" data-testid="test" label="Test label" helperText="Helper text" />
67
+ );
68
+
69
+ const textarea = screen.getByTestId("test-textarea");
70
+ expect(textarea).toBeRequired();
71
+ expect(textarea).not.toHaveClass(`${rootClassName}__textarea--error`);
72
+ expect(screen.getByTestId("test-label")).toHaveTextContent("* Test label");
73
+ expect(screen.getByTestId("test-helper-text")).toHaveTextContent("Helper text");
74
+ });
75
+
76
+ it("should render disabled", () => {
77
+ render(
78
+ <TextArea disabled id="test" data-testid="test" label="Test label" helperText="Helper text" />
79
+ );
80
+
81
+ const textarea = screen.getByTestId("test-textarea");
82
+ expect(textarea).toBeDisabled();
83
+ expect(textarea).not.toHaveClass(`${rootClassName}__textarea--error`);
84
+ expect(screen.getByTestId("test-label")).toHaveTextContent("Test label");
85
+ expect(screen.getByTestId("test-helper-text")).toHaveTextContent("Helper text");
86
+ });
87
+
88
+ it("should render readOnly", () => {
89
+ render(
90
+ <TextArea readOnly id="test" data-testid="test" label="Test label" helperText="Helper text" />
91
+ );
92
+
93
+ const textarea = screen.getByTestId("test-textarea");
94
+ expect(textarea).toHaveAttribute("readOnly");
95
+ expect(textarea).not.toHaveClass(`${rootClassName}__textarea--error`);
96
+ expect(screen.getByTestId("test-label")).toHaveTextContent("Test label");
97
+ expect(screen.getByTestId("test-helper-text")).toHaveTextContent("Helper text");
98
+ });
99
+
100
+ it("should render with default value and onChange", () => {
101
+ const onChangeMock = vi.fn();
102
+ render(
103
+ <TextArea id="test" data-testid="test" onChange={onChangeMock} defaultValue="Default value" />
104
+ );
105
+ const textarea = screen.getByTestId("test-textarea");
106
+ expect(textarea).toHaveValue("Default value");
107
+
108
+ fireEvent.change(textarea, { target: { value: "Changed" } });
109
+
110
+ expect(textarea).toHaveValue("Changed");
111
+ expect(onChangeMock).toHaveBeenCalled();
112
+ });
113
+ });
@@ -0,0 +1,107 @@
1
+ import React, { ComponentPropsWithoutRef, ForwardedRef, forwardRef } from "react";
2
+ import { FieldErrorText } from "@purpurds/field-error-text";
3
+ import { FieldHelperText } from "@purpurds/field-helper-text";
4
+ import { Label } from "@purpurds/label";
5
+ import c from "classnames";
6
+
7
+ import styles from "./text-area.module.scss";
8
+
9
+ export type TextAreaProps = ComponentPropsWithoutRef<"textarea"> & {
10
+ id: string;
11
+ ["data-testid"]?: string;
12
+ className?: string;
13
+ /**
14
+ * Use to render error message below the text area. The text area renders with error appearance.
15
+ * */
16
+ errorText?: string;
17
+ /**
18
+ * Use to give context about the text area's input. Renders below the text area.
19
+ * */
20
+ helperText?: string;
21
+ /**
22
+ * The label of the text area.
23
+ * */
24
+ label?: string;
25
+ /**
26
+ * The height of the text area, measured in number of rows.
27
+ * */
28
+ rows?: number;
29
+ /**
30
+ * The width of the text area, measured in number of cols.
31
+ * */
32
+ cols?: number;
33
+ };
34
+
35
+ const rootClassName = "purpur-text-area";
36
+
37
+ const TextAreaComponent = (
38
+ {
39
+ id,
40
+ ["data-testid"]: dataTestId,
41
+ className,
42
+ errorText,
43
+ helperText,
44
+ label,
45
+ rows = 3,
46
+ cols = 90,
47
+ ...props
48
+ }: TextAreaProps,
49
+ ref: ForwardedRef<HTMLTextAreaElement>
50
+ ) => {
51
+ const getTestId = (name: string) => (dataTestId ? `${dataTestId}-${name}` : undefined);
52
+
53
+ const helperTextId = helperText ? `${id}-helper-text` : undefined;
54
+
55
+ const textAreaContainerClassnames = c([
56
+ styles[`${rootClassName}__textarea-container`],
57
+ {
58
+ [styles[`${rootClassName}__textarea-container--disabled`]]: props.disabled,
59
+ [styles[`${rootClassName}__textarea-container--readonly`]]: props.readOnly && !props.disabled,
60
+ },
61
+ ]);
62
+
63
+ return (
64
+ <div className={c(className, styles[rootClassName])} data-testid={dataTestId}>
65
+ {label && (
66
+ <Label
67
+ htmlFor={id}
68
+ disabled={props.disabled}
69
+ className={styles[`${rootClassName}__label`]}
70
+ data-testid={getTestId("label")}
71
+ >
72
+ {`${props.required ? "* " : ""}${label}`}
73
+ </Label>
74
+ )}
75
+ <div className={textAreaContainerClassnames}>
76
+ <textarea
77
+ {...props}
78
+ ref={ref}
79
+ id={id}
80
+ data-testid={getTestId("textarea")}
81
+ aria-describedby={props["aria-describedby"] || helperTextId}
82
+ aria-invalid={props["aria-invalid"] || !!errorText}
83
+ className={c([
84
+ styles[`${rootClassName}__textarea`],
85
+ {
86
+ [styles[`${rootClassName}__textarea--error`]]: !!errorText,
87
+ },
88
+ ])}
89
+ rows={rows}
90
+ cols={cols}
91
+ />
92
+ <div className={styles[`${rootClassName}__frame`]} />
93
+ </div>
94
+ {helperTextId && (
95
+ <FieldHelperText data-testid={getTestId("helper-text")} id={helperTextId}>
96
+ {helperText}
97
+ </FieldHelperText>
98
+ )}
99
+ {errorText && (
100
+ <FieldErrorText data-testid={getTestId("error-text")}>{errorText}</FieldErrorText>
101
+ )}
102
+ </div>
103
+ );
104
+ };
105
+
106
+ export const TextArea = forwardRef(TextAreaComponent);
107
+ TextArea.displayName = "TextArea";