Skip to content

Commit e85e156

Browse files
committed
feat: configure chromatic
1 parent e91afd0 commit e85e156

File tree

15 files changed

+215
-74
lines changed

15 files changed

+215
-74
lines changed

.github/workflows/ci.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,7 @@ jobs:
3030
run: pnpm run lint
3131
- name: Run Unit Tests
3232
run: pnpm test
33+
- name: Run Storybook Tests
34+
run: pnpm run build-storybook
35+
- name: Run Chromatic
36+
run: pnpm run chromatic

eslint.config.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,6 @@ export default defineConfig([
5555
'internal', // Internal modules (if you have path aliases)
5656
'parent', // Parent directory imports
5757
'sibling', // Same directory imports
58-
'index', // Index file imports
59-
'object', // Object-imports
6058
],
6159
'newlines-between': 'always',
6260
},

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"test": "vitest",
1616
"new:component": "npx generate-react-cli component",
1717
"storybook": "storybook dev -p 6006",
18-
"storybook:build": "storybook build",
18+
"build-storybook": "storybook build",
1919
"test:browser": "vitest --config=vitest.browser.config.ts",
2020
"test:ui": "vitest --ui",
2121
"format": "prettier --write .",
@@ -97,6 +97,7 @@
9797
"@vitejs/plugin-react-swc": "^3.5.0",
9898
"@vitest/browser": "3.2.4",
9999
"@vitest/coverage-v8": "3.2.4",
100+
"chromatic": "^13.1.4",
100101
"cspell": "^8.0.0",
101102
"eslint": "^9.34.0",
102103
"eslint-config-prettier": "^10.1.8",

pnpm-lock.yaml

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/components/ui/Button/Button.test.tsx

Lines changed: 0 additions & 22 deletions
This file was deleted.

src/components/ui/Button/Button.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ import type { ComponentProps } from 'react';
55

66
import { cn } from '@/lib/utils';
77

8-
type Appearance = 'default' | 'primary' | 'secondary' | 'warning' | 'destructive' | 'link';
8+
export const Appearance = ['default', 'primary', 'secondary', 'warning', 'destructive', 'link'] as const;
9+
export type Appearance = (typeof Appearance)[number];
910

10-
type Size = 'small' | 'medium' | 'large';
11+
export const Size = ['small', 'medium', 'large'] as const;
12+
export type Size = (typeof Size)[number];
1113

1214
export type ButtonProps = ComponentProps<'button'> & {
1315
/**
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import type { Meta, StoryObj } from '@storybook/react-vite';
2+
import { expect, userEvent, within } from 'storybook/test';
3+
4+
import TextArea from '.';
5+
6+
const meta = {
7+
title: 'Components/TextArea',
8+
component: TextArea,
9+
args: {
10+
label: 'Text Area Label',
11+
placeholder: 'Enter some text here…',
12+
disabled: false,
13+
required: false,
14+
},
15+
argTypes: {
16+
label: {
17+
name: 'Label',
18+
control: 'text',
19+
description: 'Label of the text area',
20+
},
21+
placeholder: {
22+
name: 'Placeholder',
23+
control: 'text',
24+
description: 'Placeholder text of the text area',
25+
},
26+
disabled: {
27+
name: 'Disabled',
28+
control: 'boolean',
29+
description: 'Disables the text area',
30+
},
31+
required: {
32+
name: 'Required',
33+
control: 'boolean',
34+
description: 'Marks the text area as required',
35+
},
36+
},
37+
} as Meta<typeof TextArea>;
38+
39+
export default meta;
40+
type Story = StoryObj<typeof TextArea>;
41+
42+
export const Primary: Story = {
43+
play: async ({ canvasElement }) => {
44+
const canvas = within(canvasElement);
45+
const textArea = canvas.getByRole('textbox');
46+
await userEvent.type(textArea, 'Hello, world!');
47+
expect(textArea).toHaveValue('Hello, world!');
48+
},
49+
};
50+
51+
export const Disabled: Story = {
52+
args: {
53+
disabled: true,
54+
},
55+
56+
play: async ({ canvasElement }) => {
57+
const canvas = within(canvasElement);
58+
const textArea = canvas.getByRole('textbox');
59+
expect(textArea).toBeDisabled();
60+
61+
await userEvent.type(textArea, 'Hello, world!');
62+
expect(textArea).toHaveValue('');
63+
},
64+
};
65+
66+
export const WithCount: Story = {
67+
args: {
68+
maxLength: 100,
69+
},
70+
play: async ({ canvasElement }) => {
71+
const canvas = within(canvasElement);
72+
const textArea = canvas.getByRole('textbox');
73+
await userEvent.type(textArea, 'Hello, world!');
74+
expect(textArea).toHaveValue('Hello, world!');
75+
76+
// The text area should be valid
77+
expect(textArea).toHaveAttribute('aria-invalid', 'false');
78+
79+
// The text area should be invalid
80+
await userEvent.type(
81+
textArea,
82+
'This text is too long and will surpass the max length and be invalid. It should trigger the invalid state.',
83+
);
84+
const charCount = canvas.getByTestId('length');
85+
expect(charCount).toHaveTextContent('119');
86+
87+
expect(textArea).toHaveAttribute('aria-invalid', 'true');
88+
},
89+
};
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import clsx from 'clsx';
2+
import { useMemo, useState, type ComponentProps } from 'react';
3+
4+
import { getLength, isTooLong } from './utils';
5+
6+
export type TextAreaProps = ComponentProps<'textarea'> & { label: string };
7+
8+
const TextArea = ({ label, required, maxLength, ...props }: TextAreaProps) => {
9+
const [value, setValue] = useState(props.value ?? '');
10+
const tooLong = useMemo(() => isTooLong(value, maxLength), [value, maxLength]);
11+
const length = useMemo(() => getLength(value), [value]);
12+
13+
return (
14+
<label className="flex flex-col gap-1.5">
15+
<span
16+
className={clsx(
17+
'inline-flex items-center gap-1 text-sm font-medium',
18+
required && 'after:bg-accent-500 after:h-1.5 after:w-1.5 after:rounded-full',
19+
)}
20+
>
21+
{label}
22+
</span>
23+
24+
<textarea
25+
className={clsx(
26+
'invalid:bg-danger-50 focus:bg-primary-50 focus:ring-primary-600 w-full gap-2 rounded-md bg-white p-4 text-sm placeholder-slate-400 shadow-sm ring-1 ring-slate-500 ring-inset focus:ring-2 focus:outline-none disabled:cursor-not-allowed disabled:bg-slate-50 dark:bg-slate-800 dark:placeholder-slate-300',
27+
tooLong && 'ring-danger-500 dark:ring-danger-500 ring-2',
28+
)}
29+
{...props}
30+
onChange={(e) => {
31+
setValue(e.target.value);
32+
if (typeof props.onChange === 'function') props.onChange(e);
33+
}}
34+
value={value}
35+
required={required}
36+
aria-invalid={tooLong}
37+
/>
38+
39+
{maxLength && (
40+
<div className="gap-1.4 flex justify-end text-xs">
41+
<p className={clsx(tooLong ? 'text-danger-500' : 'text-slate-600')}>
42+
<span data-testid="length">{length}</span>/{maxLength}
43+
</p>
44+
</div>
45+
)}
46+
</label>
47+
);
48+
};
49+
50+
export default TextArea;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './TextArea';
2+
export { default } from './TextArea';
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { ComponentProps } from 'react';
2+
3+
export const getLength = (value: ComponentProps<'textarea'>['value']): number => {
4+
if (typeof value !== 'string') return 0;
5+
return value.length;
6+
};
7+
8+
export const isTooLong = (
9+
value: ComponentProps<'textarea'>['value'],
10+
maxLength: ComponentProps<'textarea'>['maxLength'],
11+
): boolean => {
12+
if (typeof value !== 'string') return false;
13+
if (!maxLength) return false;
14+
return value.length > maxLength;
15+
};

0 commit comments

Comments
 (0)