From 7e912af666b1ad425a0e1cac18a50d5f53bc0c22 Mon Sep 17 00:00:00 2001
From: Nathaniel Waldschmidt
<77284592+NateWaldschmidt@users.noreply.github.com>
Date: Wed, 4 Sep 2024 08:03:36 -0500
Subject: [PATCH] feat: type and refactor textarea component (#536)
---
CHANGELOG.md | 8 +
package-lock.json | 4 +-
package.json | 2 +-
src/components/Textarea/Textarea.mdx | 12 -
src/components/Textarea/Textarea.stories.js | 81 ++++-
src/components/Textarea/Textarea.vue | 326 ++++++++----------
.../Textarea/__tests__/Textarea.spec.js | 159 ---------
.../Textarea/__tests__/Textarea.spec.ts | 72 ++++
src/components/Textarea/constants.ts | 9 +
src/components/Textarea/index.ts | 2 +
src/components/index.js | 1 -
src/main.ts | 1 +
12 files changed, 315 insertions(+), 362 deletions(-)
delete mode 100644 src/components/Textarea/__tests__/Textarea.spec.js
create mode 100644 src/components/Textarea/__tests__/Textarea.spec.ts
create mode 100644 src/components/Textarea/constants.ts
create mode 100644 src/components/Textarea/index.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b842a7d0b..a04a89aa4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,13 @@
# CHANGELOG
+## v2.0.91
+
+- Type `Textarea` component and normalize props
+
+## v2.0.90
+
+-
+
## v2.0.89
- Slot `helper` added `RadioButton` as alternative to `helperText`
diff --git a/package-lock.json b/package-lock.json
index 8160ca358..4a05ad009 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@lob/ui-components",
- "version": "2.0.90",
+ "version": "2.0.91",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@lob/ui-components",
- "version": "2.0.90",
+ "version": "2.0.91",
"dependencies": {
"date-fns": "^2.29.3",
"date-fns-holiday-us": "^0.3.1",
diff --git a/package.json b/package.json
index 317bf249d..f0e698b77 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@lob/ui-components",
- "version": "2.0.90",
+ "version": "2.0.91",
"engines": {
"node": ">=20.2.0",
"npm": ">=10.2.0"
diff --git a/src/components/Textarea/Textarea.mdx b/src/components/Textarea/Textarea.mdx
index 94ecac4db..7b3b57bd5 100644
--- a/src/components/Textarea/Textarea.mdx
+++ b/src/components/Textarea/Textarea.mdx
@@ -3,20 +3,8 @@ import { Primary } from './Textarea.stories';
# Textarea
-A textarea component for entering large blocks of text in a form.
-
-## How to Use
-
-You can pass in the `v-model` prop to bind the textarea to the parent component's data model.
-
-You can pass in a `@change` handler to trigger a custom function on change events. The `event` object will be emitted along with the `change` event.
-
-```html
-
-```
-
## Props
diff --git a/src/components/Textarea/Textarea.stories.js b/src/components/Textarea/Textarea.stories.js
index ee13e25e0..1cf07232b 100644
--- a/src/components/Textarea/Textarea.stories.js
+++ b/src/components/Textarea/Textarea.stories.js
@@ -1,5 +1,6 @@
import Textarea from './Textarea.vue';
import mdx from './Textarea.mdx';
+import { TextareaColor } from './constants';
export default {
title: 'Components/Textarea',
@@ -10,15 +11,76 @@ export default {
}
},
argTypes: {
- 'v-model': {
+ color: {
+ options: Object.values(TextareaColor),
control: {
- type: null
+ type: 'select'
+ },
+ table: {
+ type: {
+ summary: Object.values(TextareaColor).join(' | ')
+ }
}
},
- maxLength: {
+ cols: {
control: {
type: 'number'
}
+ },
+ disabled: {
+ control: {
+ type: 'boolean'
+ }
+ },
+ id: {
+ control: {
+ type: 'text'
+ }
+ },
+ label: {
+ control: {
+ type: 'text'
+ }
+ },
+ maxlength: {
+ control: {
+ type: 'number'
+ }
+ },
+ minlength: {
+ control: {
+ type: 'number'
+ }
+ },
+ name: {
+ control: {
+ type: 'text'
+ }
+ },
+ placeholder: {
+ control: {
+ type: 'text'
+ }
+ },
+ readonly: {
+ control: {
+ type: 'boolean'
+ }
+ },
+ required: {
+ control: {
+ type: 'boolean'
+ }
+ },
+ rows: {
+ control: {
+ type: 'number'
+ }
+ },
+ spellcheck: {
+ control: {
+ type: 'boolean'
+ }
}
}
};
@@ -47,7 +109,7 @@ WithTooltip.args = {
id: 'description',
label: 'Description',
placeholder: 'Add a description',
- tooltipContent: 'Add a description for your campaign'
+ tooltip: 'Add a description for your campaign'
};
export const WithHelperText = Primary.bind({});
@@ -55,14 +117,5 @@ WithHelperText.args = {
id: 'description',
label: 'Description',
placeholder: 'Add a description',
- helperText: 'Add a description for your campaign'
-};
-
-export const WithMaxLength = Primary.bind({});
-WithMaxLength.args = {
- id: 'description',
- label: 'Description',
- placeholder: 'Add a description',
- showCounter: true,
- maxLength: 60
+ helper: 'Add a description for your campaign'
};
diff --git a/src/components/Textarea/Textarea.vue b/src/components/Textarea/Textarea.vue
index b3bce657c..7c10409ca 100644
--- a/src/components/Textarea/Textarea.vue
+++ b/src/components/Textarea/Textarea.vue
@@ -1,186 +1,166 @@
-
-
+
-
-
-
- {{ helperText }}
-
-
- {{ counterContent }}
-
-
+
+ {{ helper }}
+
+
-
+
+
diff --git a/src/components/Textarea/__tests__/Textarea.spec.js b/src/components/Textarea/__tests__/Textarea.spec.js
deleted file mode 100644
index 9fb50d45a..000000000
--- a/src/components/Textarea/__tests__/Textarea.spec.js
+++ /dev/null
@@ -1,159 +0,0 @@
-import '@testing-library/jest-dom';
-import { render, fireEvent } from '@testing-library/vue';
-import Textarea from '../Textarea.vue';
-import userEvent from '@testing-library/user-event';
-
-const initialProps = {
- id: 'test',
- name: 'test',
- label: 'label',
- modelValue: ''
-};
-
-const renderComponent = (options, configure = null) =>
- render(Textarea, { ...options }, configure);
-
-describe('Textarea', () => {
- it('renders correctly', () => {
- const props = initialProps;
- const { getByLabelText } = renderComponent({ props });
-
- const textarea = getByLabelText(new RegExp(props.label));
- expect(textarea).toBeInTheDocument();
- });
-
- it('requires the textarea when required prop is true', () => {
- const props = {
- ...initialProps,
- required: true
- };
-
- const { getByLabelText } = renderComponent({ props });
- const textarea = getByLabelText(new RegExp(props.label));
- expect(textarea).toBeRequired();
- });
-
- it('disables the textarea when disabled prop is true', () => {
- const props = {
- ...initialProps,
- disabled: true
- };
-
- const { getByLabelText } = renderComponent({ props });
- const textarea = getByLabelText(props.label);
- expect(textarea).toBeDisabled().toHaveClass('!bg-gray-50 !border-gray-200');
- });
-
- it('adds an error class to the textarea when error prop is true', () => {
- const props = {
- ...initialProps,
- error: true
- };
-
- const { getByLabelText } = renderComponent({ props });
- const textarea = getByLabelText(props.label);
- expect(textarea).toHaveClass('border-red-600 bg-red-50');
- });
-
- it('adds a success class to the textarea when success prop is true', () => {
- const props = {
- ...initialProps,
- success: true
- };
-
- const { getByLabelText } = renderComponent({ props });
- const textarea = getByLabelText(props.label);
- expect(textarea).toHaveClass('border-green-700 bg-green-50');
- });
-
- it('updates the v-model on textarea input', async () => {
- const props = initialProps;
- const { getByLabelText } = renderComponent({ props });
- const textarea = getByLabelText(props.label);
-
- await fireEvent.update(textarea, 'hello!');
- expect(textarea.value).toEqual('hello!');
- });
-
- it('fires the input event on textarea input', async () => {
- const props = initialProps;
- const { getByLabelText, emitted } = renderComponent({ props });
- const textarea = getByLabelText(props.label);
-
- const updatedValue = 'hello!';
- await fireEvent.update(textarea, updatedValue);
- const emittedEvent = emitted();
- expect(emittedEvent).toHaveProperty('input');
- expect(emittedEvent.input[0]).toEqual([updatedValue]);
- });
-
- it('fires the change event on textarea input', async () => {
- const props = initialProps;
- const { getByLabelText, emitted } = renderComponent({ props });
- const textarea = getByLabelText(props.label);
-
- const updatedValue = 'hello!';
- await fireEvent.update(textarea, updatedValue);
- const emittedEvent = emitted();
- expect(emittedEvent).toHaveProperty('change');
- });
-
- describe('character counter', () => {
- const propsWithCounter = {
- ...initialProps,
- showCounter: true,
- maxLength: 20
- };
- let component;
- beforeEach(() => {
- component = renderComponent({ props: propsWithCounter });
- });
-
- it('does not show the counter if the area is not on focus', () => {
- const { queryByRole } = component;
-
- const counter = queryByRole('status');
- expect(counter).not.toBeInTheDocument();
- });
-
- it('shows the counter when the textarea is focused', async () => {
- const { getByLabelText, findByRole } = component;
- const textarea = getByLabelText(propsWithCounter.label);
- userEvent.click(textarea);
-
- const counter = await findByRole('status');
- expect(counter)
- .toBeInTheDocument()
- .toHaveTextContent(/0\/20/i)
- .toHaveClass('text-gray-500');
- });
-
- it('counts the characters', async () => {
- const { getByLabelText, findByRole, rerender } = component;
- const textarea = getByLabelText(propsWithCounter.label);
- userEvent.click(textarea);
- await userEvent.type(textarea, 'thing');
-
- rerender({ modelValue: 'thing' });
- expect(textarea).toHaveValue('thing');
-
- const counter = await findByRole('status');
- expect(counter).toHaveTextContent(/5\/20/);
- expect(counter).toBeInTheDocument().toHaveClass('text-gray-500');
- });
-
- it('is the error color when value length is more than (maxLength - 5)', async () => {
- const { getByLabelText, findByRole, rerender } = component;
- const textarea = getByLabelText(propsWithCounter.label);
- userEvent.click(textarea);
- await userEvent.type(textarea, 'is 16 characters');
-
- rerender({ modelValue: 'is 16 characters' });
- expect(textarea).toHaveValue('is 16 characters');
-
- const counter = await findByRole('status');
- expect(counter).toHaveTextContent(/16\/20/);
- expect(counter).toBeInTheDocument().toHaveClass('text-red-700');
- });
- });
-});
diff --git a/src/components/Textarea/__tests__/Textarea.spec.ts b/src/components/Textarea/__tests__/Textarea.spec.ts
new file mode 100644
index 000000000..1a878cc7c
--- /dev/null
+++ b/src/components/Textarea/__tests__/Textarea.spec.ts
@@ -0,0 +1,72 @@
+import '@testing-library/jest-dom';
+import { render, RenderOptions } from '@testing-library/vue';
+import Textarea from '../Textarea.vue';
+import userEvent from '@testing-library/user-event';
+
+const initialProps = {
+ id: 'test',
+ name: 'test',
+ label: 'label',
+ modelValue: ''
+};
+
+const renderComponent = (options: RenderOptions) =>
+ render(Textarea, { ...options });
+
+describe('Textarea', () => {
+ it('renders correctly', () => {
+ const props = initialProps;
+ const { getByLabelText } = renderComponent({ props });
+
+ const textarea = getByLabelText(new RegExp(props.label));
+ expect(textarea).toBeInTheDocument();
+ });
+
+ it('requires the textarea when required prop is true', () => {
+ const props = {
+ ...initialProps,
+ required: true
+ };
+
+ const { getByLabelText } = renderComponent({ props });
+ const textarea = getByLabelText(new RegExp(props.label));
+ expect(textarea).toBeRequired();
+ });
+
+ it('disables the textarea when disabled prop is true', () => {
+ const props = {
+ ...initialProps,
+ disabled: true
+ };
+
+ const { getByLabelText } = renderComponent({ props });
+ const textarea = getByLabelText(props.label);
+ expect(textarea).toBeDisabled();
+ });
+
+ it('updates the v-model on textarea input', async () => {
+ const props = initialProps;
+ const { emitted, getByLabelText } = renderComponent({ props });
+ const textarea = getByLabelText(props.label) as HTMLTextAreaElement;
+
+ await userEvent.type(textarea, 'hello!');
+ expect(emitted()).toHaveProperty('update:modelValue', [
+ ['h'],
+ ['he'],
+ ['hel'],
+ ['hell'],
+ ['hello'],
+ ['hello!']
+ ]);
+ });
+
+ it('fires the input event on textarea input', async () => {
+ const props = initialProps;
+ const { getByLabelText, emitted } = renderComponent({ props });
+ const textarea = getByLabelText(props.label);
+
+ const updatedValue = 'hello!';
+ await userEvent.type(textarea, updatedValue);
+ expect(emitted()).toHaveProperty('input');
+ });
+});
diff --git a/src/components/Textarea/constants.ts b/src/components/Textarea/constants.ts
new file mode 100644
index 000000000..44c8e47c2
--- /dev/null
+++ b/src/components/Textarea/constants.ts
@@ -0,0 +1,9 @@
+import { Color } from '@/types';
+
+export const TextareaColor = {
+ ERROR: Color.ERROR,
+ NEUTRAL: Color.NEUTRAL,
+ SUCCESS: Color.SUCCESS,
+ WARNING: Color.WARNING
+} as const;
+export type TextareaColor = (typeof TextareaColor)[keyof typeof TextareaColor];
diff --git a/src/components/Textarea/index.ts b/src/components/Textarea/index.ts
new file mode 100644
index 000000000..c57d8737d
--- /dev/null
+++ b/src/components/Textarea/index.ts
@@ -0,0 +1,2 @@
+export * from './constants';
+export { default as Textarea } from './Textarea.vue';
diff --git a/src/components/index.js b/src/components/index.js
index 9a105b14c..0c2d5b7f2 100644
--- a/src/components/index.js
+++ b/src/components/index.js
@@ -33,7 +33,6 @@ export { default as LobTable } from './Table/Table';
export { default as TableHeader } from './Table/TableHeader';
export { default as TableBody } from './Table/TableBody';
export { default as TableRow } from './Table/TableRow';
-export { default as Textarea } from './Textarea/Textarea';
export { default as TextInput } from './TextInput/TextInput';
export { default as ToggleButton } from './ToggleButton/ToggleButton';
export { default as TopNavbar } from './TopNavbar/TopNavbar';
diff --git a/src/main.ts b/src/main.ts
index 15ba99ea8..1a61b1aff 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -46,6 +46,7 @@ export * from './components/RadioButton';
export * from './components/Skeleton';
export * from './components/Steps';
export * from './components/Table';
+export * from './components/Textarea';
export * from './components/Tooltip';
export * from './components/Tile';
export * from './types';