✨(react) add TextArea component
How could we ship a design system library without a textarea?
This commit is contained in:
5
.changeset/ninety-eggs-cross.md
Normal file
5
.changeset/ninety-eggs-cross.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@openfun/cunningham-react": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
add TextArea component
|
||||||
126
packages/react/src/components/Forms/TextArea/index.mdx
Normal file
126
packages/react/src/components/Forms/TextArea/index.mdx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { Canvas, Meta, Story, Source, ArgTypes } from '@storybook/blocks';
|
||||||
|
import * as Stories from './index.stories';
|
||||||
|
import { TextArea } from '.';
|
||||||
|
|
||||||
|
<Meta of={Stories}/>
|
||||||
|
|
||||||
|
# TextArea
|
||||||
|
|
||||||
|
Cunningham provides a versatile TextArea component that you can use in your forms.
|
||||||
|
|
||||||
|
<Canvas>
|
||||||
|
<Story id="components-forms-textarea--default"/>
|
||||||
|
</Canvas>
|
||||||
|
|
||||||
|
<Source
|
||||||
|
language='ts'
|
||||||
|
dark
|
||||||
|
format={false}
|
||||||
|
code={`import { TextArea } from "@openfun/cunningham-react";`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
## States
|
||||||
|
|
||||||
|
You can use the following props to change the state of the TextArea component by using the `state` props.
|
||||||
|
|
||||||
|
<Canvas sourceState="shown">
|
||||||
|
<Story id="components-forms-textarea--default"/>
|
||||||
|
</Canvas>
|
||||||
|
|
||||||
|
<Canvas sourceState="shown">
|
||||||
|
<Story id="components-forms-textarea--success"/>
|
||||||
|
</Canvas>
|
||||||
|
|
||||||
|
<Canvas sourceState="shown">
|
||||||
|
<Story id="components-forms-textarea--error"/>
|
||||||
|
</Canvas>
|
||||||
|
|
||||||
|
## Disabled
|
||||||
|
|
||||||
|
As a regular textarea, you can disable it by using the `disabled` props.
|
||||||
|
|
||||||
|
<Canvas sourceState="shown">
|
||||||
|
<Story id="components-forms-textarea--disabled-empty"/>
|
||||||
|
<Story id="components-forms-textarea--disabled-filled"/>
|
||||||
|
</Canvas>
|
||||||
|
|
||||||
|
## Texts
|
||||||
|
|
||||||
|
You can define a text that will appear below the input by using the `text` props.
|
||||||
|
|
||||||
|
<Canvas sourceState="shown">
|
||||||
|
<Story id="components-forms-textarea--with-text"/>
|
||||||
|
</Canvas>
|
||||||
|
|
||||||
|
You can also independently add a text on the right side by using the `rightText` props.
|
||||||
|
|
||||||
|
<Canvas sourceState="shown">
|
||||||
|
<Story id="components-forms-textarea--with-both-texts"/>
|
||||||
|
</Canvas>
|
||||||
|
|
||||||
|
## Width
|
||||||
|
|
||||||
|
By default, the textarea has a default width, like all inputs. But you can force it to take the full width of its container by using the `fullWidth` props.
|
||||||
|
|
||||||
|
<Canvas sourceState="shown">
|
||||||
|
<Story id="components-forms-textarea--full-width"/>
|
||||||
|
</Canvas>
|
||||||
|
|
||||||
|
## Chars Counter
|
||||||
|
|
||||||
|
You can display a counter of the number of characters entered in the textarea by using the `charsCounter` props. Please bare
|
||||||
|
in mind to also define `charCounterMax`.
|
||||||
|
|
||||||
|
<Canvas sourceState="shown">
|
||||||
|
<Story id="components-forms-textarea--char-counter"/>
|
||||||
|
</Canvas>
|
||||||
|
|
||||||
|
## Controlled / Non Controlled
|
||||||
|
|
||||||
|
Like a native textarea, you can use the TextArea component in a controlled or non controlled way. You can see the example below
|
||||||
|
using the component in a controlled way.
|
||||||
|
|
||||||
|
<Canvas sourceState="shown">
|
||||||
|
<Story id="components-forms-textarea--controlled"/>
|
||||||
|
</Canvas>
|
||||||
|
|
||||||
|
## Ref
|
||||||
|
|
||||||
|
You can use the `ref` props to get a reference to the textarea element.
|
||||||
|
|
||||||
|
<Canvas sourceState="shown">
|
||||||
|
<Story id="components-forms-textarea--with-ref"/>
|
||||||
|
</Canvas>
|
||||||
|
|
||||||
|
## Props
|
||||||
|
|
||||||
|
You can use all the props of the native html `<textarea>` element props plus the following.
|
||||||
|
|
||||||
|
<ArgTypes of={TextArea} />
|
||||||
|
|
||||||
|
## Design tokens
|
||||||
|
|
||||||
|
Here are the custom design tokens defined by the textarea.
|
||||||
|
|
||||||
|
| Token | Description |
|
||||||
|
|--------------- |----------------------------- |
|
||||||
|
| font-weight | Value font weight |
|
||||||
|
| font-size | Value font size |
|
||||||
|
| value-color | Value color |
|
||||||
|
| value-color--disabled | Value color when disabled |
|
||||||
|
| border-radius | Border radius of the textarea |
|
||||||
|
| border-radius--hover | Border radius of the textarea on mouse hover |
|
||||||
|
| border-radius--focus | Border radius of the textarea when focused |
|
||||||
|
| border-width | Border width of the textarea |
|
||||||
|
| border-color | Border color of the textarea |
|
||||||
|
| border-color--hover | Border color of the textarea on mouse hover |
|
||||||
|
| border-color--focus | Border color of the textarea when focus |
|
||||||
|
| border-style | Border style of the textarea |
|
||||||
|
| label-color--focus | Label color when focused |
|
||||||
|
|
||||||
|
|
||||||
|
## Form Example
|
||||||
|
|
||||||
|
<Canvas>
|
||||||
|
<Story id="components-forms-textarea--form-example"/>
|
||||||
|
</Canvas>
|
||||||
108
packages/react/src/components/Forms/TextArea/index.scss
Normal file
108
packages/react/src/components/Forms/TextArea/index.scss
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
.c__field--textarea {
|
||||||
|
width: inherit;
|
||||||
|
min-width: var(--c--components--forms-field--width);
|
||||||
|
|
||||||
|
&.c__field--full-width {
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.c__textarea__wrapper {
|
||||||
|
border-radius: var(--c--components--forms-textarea--border-radius);
|
||||||
|
border-width: var(--c--components--forms-textarea--border-width);
|
||||||
|
border-color: var(--c--components--forms-textarea--border-color);
|
||||||
|
border-style: var(--c--components--forms-textarea--border-style);
|
||||||
|
background-color: var(--c--components--forms-textarea--background-color);
|
||||||
|
transition: border var(--c--theme--transitions--duration) var(--c--theme--transitions--ease-out);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.labelled-box__label {
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c__textarea {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font-size: var(--c--components--forms-textarea--font-size);
|
||||||
|
font-weight: var(--c--components--forms-textarea--font-weight);
|
||||||
|
min-width: 100%;
|
||||||
|
padding: 0 1rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: var(--c--theme--font--families--base);
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-radius: var(--c--components--forms-textarea--border-radius--hover);
|
||||||
|
border-color: var(--c--components--forms-textarea--border-color--hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
border-radius: var(--c--components--forms-textarea--border-radius--focus);
|
||||||
|
border-color: var(--c--components--forms-textarea--border-color--focus) !important;
|
||||||
|
|
||||||
|
label {
|
||||||
|
color: var(--c--components--forms-textarea--label-color--focus);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--disabled {
|
||||||
|
cursor: default;
|
||||||
|
border-color: var(--c--theme--colors--greyscale-200);
|
||||||
|
|
||||||
|
.c__textarea {
|
||||||
|
background-color: var(--c--components--forms-textarea--background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c__input {
|
||||||
|
color: var(--c--components--forms-textarea--value-color--disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--c--theme--colors--greyscale-200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.c__field {
|
||||||
|
&--success {
|
||||||
|
.c__textarea__wrapper {
|
||||||
|
border-color: var(--c--theme--colors--success-600);
|
||||||
|
|
||||||
|
.labelled-box label {
|
||||||
|
color: var(--c--theme--colors--success-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(.c__input__wrapper--disabled) {
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--c--theme--colors--success-800);
|
||||||
|
color: var(--c--theme--colors--success-800);
|
||||||
|
|
||||||
|
.labelled-box label {
|
||||||
|
color: var(--c--theme--colors--success-800);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--error {
|
||||||
|
.c__textarea__wrapper {
|
||||||
|
border-color: var(--c--theme--colors--danger-600);
|
||||||
|
|
||||||
|
&:not(.c__input__wrapper--disabled) {
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--c--theme--colors--danger-800);
|
||||||
|
color: var(--c--theme--colors--danger-800);
|
||||||
|
|
||||||
|
label {
|
||||||
|
color: var(--c--theme--colors--danger-800);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
194
packages/react/src/components/Forms/TextArea/index.spec.tsx
Normal file
194
packages/react/src/components/Forms/TextArea/index.spec.tsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { expect } from "vitest";
|
||||||
|
import React, { useRef } from "react";
|
||||||
|
import { TextArea } from ":/components/Forms/TextArea/index";
|
||||||
|
import { Button } from ":/components/Button";
|
||||||
|
|
||||||
|
describe("<TextArea/>", () => {
|
||||||
|
it("renders and can type", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<TextArea label="Describe your job" />);
|
||||||
|
const textarea: HTMLTextAreaElement = screen.getByRole("textbox", {
|
||||||
|
name: "Describe your job",
|
||||||
|
});
|
||||||
|
expect(textarea.value).toEqual("");
|
||||||
|
await user.type(textarea, "Developer");
|
||||||
|
expect(textarea.value).toEqual("Developer");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders with default value and can type", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<TextArea label="Describe your job" defaultValue="Developer" />);
|
||||||
|
const textarea: HTMLTextAreaElement = screen.getByRole("textbox", {
|
||||||
|
name: "Describe your job",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(textarea.value).toEqual("Developer");
|
||||||
|
await user.clear(textarea);
|
||||||
|
expect(textarea.value).toEqual("");
|
||||||
|
await user.type(textarea, "Developer");
|
||||||
|
expect(textarea.value).toEqual("Developer");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders with moving label", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<div>
|
||||||
|
<TextArea label="Describe your job" />
|
||||||
|
<TextArea label="Describe your hobbies" />
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const textarea: HTMLTextAreaElement = screen.getByRole("textbox", {
|
||||||
|
name: "Describe your job",
|
||||||
|
});
|
||||||
|
const textarea2: HTMLTextAreaElement = screen.getByRole("textbox", {
|
||||||
|
name: "Describe your hobbies",
|
||||||
|
});
|
||||||
|
const label = screen.getByText("Describe your job")!.parentElement!;
|
||||||
|
expect(Array.from(label.classList)).toContain("placeholder");
|
||||||
|
|
||||||
|
// Clicking on the textarea should remove the placeholder class.
|
||||||
|
await user.click(textarea);
|
||||||
|
|
||||||
|
expect(Array.from(label.classList)).not.toContain("placeholder");
|
||||||
|
|
||||||
|
// Writing something should remove the placeholder class too.
|
||||||
|
await user.type(textarea, "Developer");
|
||||||
|
|
||||||
|
expect(Array.from(label.classList)).not.toContain("placeholder");
|
||||||
|
|
||||||
|
// Clearing the textarea and focus out should add the placeholder class
|
||||||
|
await user.clear(textarea);
|
||||||
|
await user.click(textarea2);
|
||||||
|
|
||||||
|
expect(Array.from(label.classList)).toContain("placeholder");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders with state=success", async () => {
|
||||||
|
render(<TextArea label="First name" state="success" />);
|
||||||
|
expect(document.querySelector(".c__field--success")).toBeInTheDocument();
|
||||||
|
expect(document.querySelector(".c__field--success")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders with state=error", async () => {
|
||||||
|
render(<TextArea label="First name" state="error" />);
|
||||||
|
expect(document.querySelector(".c__field--error")).toBeInTheDocument();
|
||||||
|
expect(document.querySelector(".c__field--error")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders disabled", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<TextArea label="Describe your job" disabled={true} />);
|
||||||
|
const textarea: HTMLTextAreaElement = screen.getByRole("textbox", {
|
||||||
|
name: "Describe your job",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
document.querySelector(".c__textarea__wrapper--disabled"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(textarea.value).toEqual("");
|
||||||
|
// Disabled textareas should not be able to type.
|
||||||
|
await user.type(textarea, "Developer");
|
||||||
|
expect(textarea.value).toEqual("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders with text", async () => {
|
||||||
|
render(<TextArea label="Describe your job" text="Some text" />);
|
||||||
|
screen.getByText("Some text");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders with text items", async () => {
|
||||||
|
render(
|
||||||
|
<TextArea
|
||||||
|
label="Describe your job"
|
||||||
|
textItems={[
|
||||||
|
"Text too long",
|
||||||
|
"Wrong choice",
|
||||||
|
"Must contain at least 9 characters, uppercase and digits",
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getAllByRole("listitem").map((item) => item.textContent),
|
||||||
|
).toEqual([
|
||||||
|
"Text too long",
|
||||||
|
"Wrong choice",
|
||||||
|
"Must contain at least 9 characters, uppercase and digits",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders with text and text right", async () => {
|
||||||
|
render(<TextArea label="Describe your job" rightText="Some text right" />);
|
||||||
|
const text = screen.getByText("Some text right");
|
||||||
|
expect(text).toHaveClass("c__field__text__right");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders with char counter", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<TextArea
|
||||||
|
label="Describe your job"
|
||||||
|
charCounter={true}
|
||||||
|
charCounterMax={15}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const textarea: HTMLTextAreaElement = screen.getByRole("textbox", {
|
||||||
|
name: "Describe your job",
|
||||||
|
});
|
||||||
|
screen.getByText("0/15");
|
||||||
|
await user.type(textarea, "De");
|
||||||
|
screen.getByText("2/15");
|
||||||
|
await user.type(textarea, "ve");
|
||||||
|
screen.getByText("4/15");
|
||||||
|
await user.clear(textarea);
|
||||||
|
screen.getByText("0/15");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards ref", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const Wrapper = () => {
|
||||||
|
const ref = useRef<HTMLTextAreaElement>(null);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<TextArea label="Describe your job" ref={ref} />
|
||||||
|
<Button onClick={() => ref.current?.focus()}>Focus</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
render(<Wrapper />);
|
||||||
|
const textarea: HTMLTextAreaElement = screen.getByRole("textbox", {
|
||||||
|
name: "Describe your job",
|
||||||
|
});
|
||||||
|
expect(textarea).not.toHaveFocus();
|
||||||
|
await user.click(screen.getByRole("button", { name: "Focus" }));
|
||||||
|
waitFor(() => expect(textarea).toHaveFocus());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("works controlled", async () => {
|
||||||
|
const Wrapper = () => {
|
||||||
|
const [value, setValue] = React.useState("I am controlled");
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div>Value: {value}.</div>
|
||||||
|
<TextArea
|
||||||
|
label="Describe your job"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button onClick={() => setValue("")}>Reset</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<Wrapper />);
|
||||||
|
const textarea: HTMLTextAreaElement = screen.getByRole("textbox", {
|
||||||
|
name: "Describe your job",
|
||||||
|
});
|
||||||
|
screen.getByText("Value: I am controlled.");
|
||||||
|
await user.type(textarea, "John");
|
||||||
|
screen.getByText("Value: I am controlledJohn.");
|
||||||
|
await user.clear(textarea);
|
||||||
|
screen.getByText("Value: .");
|
||||||
|
});
|
||||||
|
});
|
||||||
254
packages/react/src/components/Forms/TextArea/index.stories.tsx
Normal file
254
packages/react/src/components/Forms/TextArea/index.stories.tsx
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
import { Meta } from "@storybook/react";
|
||||||
|
import React, { useRef } from "react";
|
||||||
|
import { TextArea } from ":/components/Forms/TextArea/index";
|
||||||
|
import { Input } from ":/components/Forms/Input";
|
||||||
|
import { Button } from ":/components/Button";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: "Components/Forms/TextArea",
|
||||||
|
component: TextArea,
|
||||||
|
args: {
|
||||||
|
rows: 4,
|
||||||
|
},
|
||||||
|
} as Meta<typeof TextArea>;
|
||||||
|
|
||||||
|
export const ShowCase = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<Input label="Your name" fullWidth={true} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-s">
|
||||||
|
<TextArea label="Your name" rows={4} fullWidth={true} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Default = {
|
||||||
|
args: {
|
||||||
|
defaultValue: "Hello world",
|
||||||
|
label: "Describe your job",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Success = {
|
||||||
|
args: {
|
||||||
|
defaultValue: "Hello world",
|
||||||
|
label: "Describe your job",
|
||||||
|
state: "success",
|
||||||
|
text: "This is an optional success message",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Error = {
|
||||||
|
args: {
|
||||||
|
defaultValue: "Hello world",
|
||||||
|
label: "Describe your job",
|
||||||
|
state: "error",
|
||||||
|
text: "This is an optional error message",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ErrorItems = {
|
||||||
|
args: {
|
||||||
|
defaultValue: "Hello world",
|
||||||
|
label: "Describe your job",
|
||||||
|
state: "error",
|
||||||
|
text: "This is an optional error message",
|
||||||
|
textItems: [
|
||||||
|
"Text too long",
|
||||||
|
"Wrong choice",
|
||||||
|
"Must contain at least 9 characters, uppercase and digits",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export const DisabledEmpty = {
|
||||||
|
args: {
|
||||||
|
label: "Describe your job",
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DisabledFilled = {
|
||||||
|
args: {
|
||||||
|
label: "Describe your job",
|
||||||
|
defaultValue: "John Doe",
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Empty = {
|
||||||
|
args: {
|
||||||
|
label: "Describe your job",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OverflowingText = {
|
||||||
|
args: {
|
||||||
|
label: "Describe your job",
|
||||||
|
defaultValue:
|
||||||
|
"John Dave Mike Smith Doe Junior Senior Yoda Skywalker John Dave Mike Smith Doe Junior Senior Yoda Skywalker John Dave Mike Smith Doe Junior Senior Yoda Skywalker",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithText = {
|
||||||
|
args: {
|
||||||
|
defaultValue: "Hello world",
|
||||||
|
label: "Describe your job",
|
||||||
|
text: "This is a text, you can display anything you want here like warnings, information or errors.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithTextRight = {
|
||||||
|
args: {
|
||||||
|
defaultValue: "Hello world",
|
||||||
|
label: "Describe your job",
|
||||||
|
rightText: "0/300",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithBothTexts = {
|
||||||
|
args: {
|
||||||
|
defaultValue: "Hello world",
|
||||||
|
label: "Describe your job",
|
||||||
|
text: "This is a text, you can display anything you want here like warnings, information or errors.",
|
||||||
|
rightText: "0/300",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FullWidth = {
|
||||||
|
args: {
|
||||||
|
defaultValue: "Hello world",
|
||||||
|
label: "Describe your job",
|
||||||
|
fullWidth: true,
|
||||||
|
text: "This is a text, you can display anything you want here like warnings, information or errors.",
|
||||||
|
rightText: "0/300",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CharCounter = {
|
||||||
|
args: {
|
||||||
|
defaultValue: "CEO",
|
||||||
|
label: "Describe your job",
|
||||||
|
text: "This is a text, you can display anything you want here like warnings, information or errors.",
|
||||||
|
charCounter: true,
|
||||||
|
charCounterMax: 30,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Controlled = () => {
|
||||||
|
const [value, setValue] = React.useState("I am controlled");
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="clr-greyscale-900">
|
||||||
|
Value: <span>{value}</span>
|
||||||
|
</div>
|
||||||
|
<TextArea
|
||||||
|
label="Describe your job"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
<Button onClick={() => setValue("")}>Reset</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NonControlled = () => {
|
||||||
|
return (
|
||||||
|
<TextArea
|
||||||
|
defaultValue="john.doe@example.com"
|
||||||
|
label="Describe your job"
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithRef = () => {
|
||||||
|
const ref = useRef<HTMLTextAreaElement>(null);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<TextArea label="Describe your job" ref={ref} rows={4} />
|
||||||
|
<Button onClick={() => ref.current?.focus()}>Focus</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FormExample = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<form>
|
||||||
|
<div>
|
||||||
|
<TextArea
|
||||||
|
label="Describe your job"
|
||||||
|
defaultValue="As a developer, my role involves creating, maintaining, and improving software applications and systems. I work with various programming languages and technologies to bring digital solutions to life."
|
||||||
|
rows={4}
|
||||||
|
fullWidth={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-s">
|
||||||
|
<TextArea
|
||||||
|
label="Describe your hobbies"
|
||||||
|
defaultValue="- Gym
|
||||||
|
- Running
|
||||||
|
- Coding"
|
||||||
|
rows={4}
|
||||||
|
fullWidth={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-s">
|
||||||
|
<TextArea
|
||||||
|
label="How could you improve your habits ?"
|
||||||
|
defaultValue="Wake up earlier in the morning"
|
||||||
|
text="Be exhaustive"
|
||||||
|
charCounter={true}
|
||||||
|
charCounterMax={30}
|
||||||
|
rows={2}
|
||||||
|
fullWidth={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-s">
|
||||||
|
<TextArea
|
||||||
|
label="Tell us about your favorite stack"
|
||||||
|
disabled={true}
|
||||||
|
rows={2}
|
||||||
|
fullWidth={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-s">
|
||||||
|
<TextArea
|
||||||
|
label="How to code a weather app ?"
|
||||||
|
defaultValue="Creating a weather app involves several steps, and I'll provide a high-level overview of how you might approach coding one. This example assumes you want to build a web-based weather app using HTML, CSS, and JavaScript, along with data from a weather API. "
|
||||||
|
state="error"
|
||||||
|
text="Address not found"
|
||||||
|
rows={4}
|
||||||
|
fullWidth={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-s">
|
||||||
|
<TextArea
|
||||||
|
label="How to setup a database ?"
|
||||||
|
defaultValue="Setting up a PostgreSQL database involves several steps, from installation to configuration. Below is a step-by-step guide on how to set up a PostgreSQL database on a typical Linux environment. The process is similar on other platforms, with slight variations."
|
||||||
|
text="Five numbers format"
|
||||||
|
state="success"
|
||||||
|
rows={4}
|
||||||
|
fullWidth={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-s">
|
||||||
|
<TextArea
|
||||||
|
label="Tell us about your favorite libraries"
|
||||||
|
defaultValue="- ReactQuery
|
||||||
|
- ReactJS
|
||||||
|
- AngularJS
|
||||||
|
- Django"
|
||||||
|
state="success"
|
||||||
|
rows={7}
|
||||||
|
fullWidth={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
103
packages/react/src/components/Forms/TextArea/index.tsx
Normal file
103
packages/react/src/components/Forms/TextArea/index.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import React, {
|
||||||
|
forwardRef,
|
||||||
|
TextareaHTMLAttributes,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { Field, FieldProps } from ":/components/Forms/Field";
|
||||||
|
import { LabelledBox } from ":/components/Forms/LabelledBox";
|
||||||
|
import { randomString } from ":/utils";
|
||||||
|
|
||||||
|
export type TextAreaProps = TextareaHTMLAttributes<HTMLTextAreaElement> &
|
||||||
|
FieldProps & {
|
||||||
|
label?: string;
|
||||||
|
charCounter?: boolean;
|
||||||
|
charCounterMax?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
|
||||||
|
({ label, id, defaultValue, charCounter, charCounterMax, ...props }, ref) => {
|
||||||
|
const areaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
const [inputFocus, setInputFocus] = useState(false);
|
||||||
|
const [value, setValue] = useState(defaultValue || props.value || "");
|
||||||
|
const [labelAsPlaceholder, setLabelAsPlaceholder] = useState(!value);
|
||||||
|
const idToUse = useRef(id || randomString());
|
||||||
|
const rightTextToUse = charCounter
|
||||||
|
? `${value.toString().length}/${charCounterMax}`
|
||||||
|
: props.rightText;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (inputFocus) {
|
||||||
|
setLabelAsPlaceholder(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLabelAsPlaceholder(!value);
|
||||||
|
}, [inputFocus, value]);
|
||||||
|
|
||||||
|
// If the input is used as a controlled component, we need to update the local value.
|
||||||
|
useEffect(() => {
|
||||||
|
if (defaultValue !== undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setValue(props.value || "");
|
||||||
|
}, [props.value]);
|
||||||
|
|
||||||
|
const { fullWidth, rightText, text, textItems, ...areaProps } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Field
|
||||||
|
{...props}
|
||||||
|
className="c__field--textarea"
|
||||||
|
rightText={rightTextToUse}
|
||||||
|
>
|
||||||
|
{/* We disabled linting for this specific line because we consider that the onClick props is only used for */}
|
||||||
|
{/* mouse users, so this do not engender any issue for accessibility. */}
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||||
|
<div
|
||||||
|
className={classNames("c__textarea__wrapper", {
|
||||||
|
"c__textarea__wrapper--disabled": props.disabled,
|
||||||
|
})}
|
||||||
|
onClick={() => areaRef.current?.focus()}
|
||||||
|
>
|
||||||
|
<LabelledBox
|
||||||
|
label={label}
|
||||||
|
htmlFor={idToUse.current}
|
||||||
|
labelAsPlaceholder={labelAsPlaceholder}
|
||||||
|
disabled={props.disabled}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
className="c__textarea"
|
||||||
|
{...areaProps}
|
||||||
|
id={idToUse.current}
|
||||||
|
onFocus={(e) => {
|
||||||
|
setInputFocus(true);
|
||||||
|
props.onFocus?.(e);
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
setInputFocus(false);
|
||||||
|
props.onBlur?.(e);
|
||||||
|
}}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
setValue(e.target.value);
|
||||||
|
props.onChange?.(e);
|
||||||
|
}}
|
||||||
|
ref={(nativeRef) => {
|
||||||
|
if (ref) {
|
||||||
|
if (typeof ref === "function") {
|
||||||
|
ref(nativeRef);
|
||||||
|
} else {
|
||||||
|
ref.current = nativeRef;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
areaRef.current = nativeRef;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</LabelledBox>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
18
packages/react/src/components/Forms/TextArea/tokens.ts
Normal file
18
packages/react/src/components/Forms/TextArea/tokens.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { DefaultTokens } from "@openfun/cunningham-tokens";
|
||||||
|
|
||||||
|
export const tokens = (defaults: DefaultTokens) => ({
|
||||||
|
"font-weight": defaults.theme.font.weights.regular,
|
||||||
|
"font-size": defaults.theme.font.sizes.l,
|
||||||
|
"border-radius": "8px",
|
||||||
|
"border-radius--hover": "2px",
|
||||||
|
"border-radius--focus": "2px",
|
||||||
|
"border-width": "1px",
|
||||||
|
"border-color": defaults.theme.colors["greyscale-300"],
|
||||||
|
"border-color--hover": defaults.theme.colors["greyscale-500"],
|
||||||
|
"border-color--focus": defaults.theme.colors["primary-600"],
|
||||||
|
"border-style": "solid",
|
||||||
|
"label-color--focus": defaults.theme.colors["primary-600"],
|
||||||
|
"background-color": defaults.theme.colors["greyscale-000"],
|
||||||
|
"value-color": defaults.theme.colors["greyscale-900"],
|
||||||
|
"value-color--disabled": defaults.theme.colors["greyscale-800"],
|
||||||
|
});
|
||||||
@@ -106,6 +106,20 @@
|
|||||||
--c--theme--transitions--ease-out: cubic-bezier(0.33, 1, 0.68, 1);
|
--c--theme--transitions--ease-out: cubic-bezier(0.33, 1, 0.68, 1);
|
||||||
--c--theme--transitions--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
|
--c--theme--transitions--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
|
||||||
--c--theme--transitions--duration: 250ms;
|
--c--theme--transitions--duration: 250ms;
|
||||||
|
--c--components--forms-textarea--font-weight: var(--c--theme--font--weights--regular);
|
||||||
|
--c--components--forms-textarea--font-size: var(--c--theme--font--sizes--l);
|
||||||
|
--c--components--forms-textarea--border-radius: 8px;
|
||||||
|
--c--components--forms-textarea--border-radius--hover: 2px;
|
||||||
|
--c--components--forms-textarea--border-radius--focus: 2px;
|
||||||
|
--c--components--forms-textarea--border-width: 1px;
|
||||||
|
--c--components--forms-textarea--border-color: var(--c--theme--colors--greyscale-300);
|
||||||
|
--c--components--forms-textarea--border-color--hover: var(--c--theme--colors--greyscale-500);
|
||||||
|
--c--components--forms-textarea--border-color--focus: var(--c--theme--colors--primary-600);
|
||||||
|
--c--components--forms-textarea--border-style: solid;
|
||||||
|
--c--components--forms-textarea--label-color--focus: var(--c--theme--colors--primary-600);
|
||||||
|
--c--components--forms-textarea--background-color: var(--c--theme--colors--greyscale-000);
|
||||||
|
--c--components--forms-textarea--value-color: var(--c--theme--colors--greyscale-900);
|
||||||
|
--c--components--forms-textarea--value-color--disabled: var(--c--theme--colors--greyscale-800);
|
||||||
--c--components--forms-switch--accent-color: var(--c--theme--colors--success-700);
|
--c--components--forms-switch--accent-color: var(--c--theme--colors--success-700);
|
||||||
--c--components--forms-switch--rail-background-color: var(--c--theme--colors--greyscale-500);
|
--c--components--forms-switch--rail-background-color: var(--c--theme--colors--greyscale-500);
|
||||||
--c--components--forms-switch--rail-background-color--disabled: var(--c--theme--colors--greyscale-400);
|
--c--components--forms-switch--rail-background-color--disabled: var(--c--theme--colors--greyscale-400);
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -8,6 +8,7 @@
|
|||||||
@import './components/Forms/Field';
|
@import './components/Forms/Field';
|
||||||
@import './components/Forms/FileUploader';
|
@import './components/Forms/FileUploader';
|
||||||
@import './components/Forms/Radio';
|
@import './components/Forms/Radio';
|
||||||
|
@import './components/Forms/TextArea';
|
||||||
@import './components/Forms/Input';
|
@import './components/Forms/Input';
|
||||||
@import './components/Forms/LabelledBox';
|
@import './components/Forms/LabelledBox';
|
||||||
@import './components/Forms/Select';
|
@import './components/Forms/Select';
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export * from "./components/Forms/Input";
|
|||||||
export * from "./components/Forms/Radio";
|
export * from "./components/Forms/Radio";
|
||||||
export * from "./components/Forms/Select";
|
export * from "./components/Forms/Select";
|
||||||
export * from "./components/Forms/Switch";
|
export * from "./components/Forms/Switch";
|
||||||
|
export * from "./components/Forms/TextArea";
|
||||||
export * from "./components/Loader";
|
export * from "./components/Loader";
|
||||||
export * from "./components/Pagination";
|
export * from "./components/Pagination";
|
||||||
export * from "./components/Popover";
|
export * from "./components/Popover";
|
||||||
|
|||||||
Reference in New Issue
Block a user