chore: format codebase and move SectionAccordion to entities/Section

This commit is contained in:
Ilia Mashkov
2026-04-23 20:52:43 +03:00
parent 8aff27f8ac
commit 1d333fd945
73 changed files with 1201 additions and 1153 deletions
+1 -1
View File
@@ -1 +1 @@
export { Input, Textarea } from './ui/Input'
export { Input, Textarea } from './ui/Input';
+11 -11
View File
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { Input, Textarea } from './Input'
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { Input, Textarea } from './Input';
const meta: Meta<typeof Input> = {
title: 'Shared/Input',
@@ -11,35 +11,35 @@ const meta: Meta<typeof Input> = {
</div>
),
],
}
};
export default meta
export default meta;
type Story = StoryObj<typeof Input>
type Story = StoryObj<typeof Input>;
export const Default: Story = {
args: {},
}
};
export const WithLabel: Story = {
args: {
label: 'Email address',
},
}
};
export const WithError: Story = {
args: {
label: 'Email',
error: 'This field is required',
},
}
};
export const WithPlaceholder: Story = {
args: {
placeholder: 'Enter your email',
type: 'email',
},
}
};
export const TextareaStory: Story = {
name: 'Textarea',
@@ -48,7 +48,7 @@ export const TextareaStory: Story = {
<Textarea label="Message" rows={4} />
</div>
),
}
};
export const TextareaWithError: Story = {
render: () => (
@@ -56,4 +56,4 @@ export const TextareaWithError: Story = {
<Textarea label="Message" error="Too short" rows={4} />
</div>
),
}
};
+80 -80
View File
@@ -1,110 +1,110 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { Input, Textarea } from './Input'
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Input, Textarea } from './Input';
describe('Input', () => {
describe('rendering', () => {
it('renders an input element', () => {
render(<Input />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
render(<Input />);
expect(screen.getByRole('textbox')).toBeInTheDocument();
});
it('renders label when provided', () => {
render(<Input label="Email" />)
expect(screen.getByText('Email')).toBeInTheDocument()
})
render(<Input label="Email" />);
expect(screen.getByText('Email')).toBeInTheDocument();
});
it('does not render label when omitted', () => {
const { container } = render(<Input />)
expect(container.querySelector('label')).toBeNull()
})
const { container } = render(<Input />);
expect(container.querySelector('label')).toBeNull();
});
it('renders error message when provided', () => {
render(<Input error="Required" />)
expect(screen.getByText('Required')).toBeInTheDocument()
})
render(<Input error="Required" />);
expect(screen.getByText('Required')).toBeInTheDocument();
});
it('does not render error when omitted', () => {
render(<Input />)
expect(screen.queryByText('Required')).toBeNull()
})
})
render(<Input />);
expect(screen.queryByText('Required')).toBeNull();
});
});
describe('accessibility', () => {
it('label is associated with input via htmlFor/id', () => {
render(<Input label="Email" />)
expect(screen.getByLabelText('Email')).toBeInTheDocument()
})
render(<Input label="Email" />);
expect(screen.getByLabelText('Email')).toBeInTheDocument();
});
it('error span is referenced by aria-describedby', () => {
render(<Input error="Required" />)
const input = screen.getByRole('textbox')
const errorId = input.getAttribute('aria-describedby')
expect(errorId).toBeTruthy()
expect(document.getElementById(errorId!)).toHaveTextContent('Required')
})
render(<Input error="Required" />);
const input = screen.getByRole('textbox');
const errorId = input.getAttribute('aria-describedby');
expect(errorId).toBeTruthy();
expect(document.getElementById(errorId!)).toHaveTextContent('Required');
});
it('no aria-describedby when no error', () => {
render(<Input />)
expect(screen.getByRole('textbox')).not.toHaveAttribute('aria-describedby')
})
render(<Input />);
expect(screen.getByRole('textbox')).not.toHaveAttribute('aria-describedby');
});
it('uses provided id prop', () => {
render(<Input id="my-input" label="Email" />)
expect(screen.getByLabelText('Email')).toHaveAttribute('id', 'my-input')
})
})
render(<Input id="my-input" label="Email" />);
expect(screen.getByLabelText('Email')).toHaveAttribute('id', 'my-input');
});
});
describe('styling', () => {
it('has brutal-border class', () => {
render(<Input />)
expect(screen.getByRole('textbox')).toHaveClass('brutal-border')
})
render(<Input />);
expect(screen.getByRole('textbox')).toHaveClass('brutal-border');
});
it('applies custom className', () => {
render(<Input className="w-full" />)
expect(screen.getByRole('textbox')).toHaveClass('w-full')
})
})
render(<Input className="w-full" />);
expect(screen.getByRole('textbox')).toHaveClass('w-full');
});
});
describe('forwarded props', () => {
it('passes placeholder to input', () => {
render(<Input placeholder="Enter email" />)
expect(screen.getByPlaceholderText('Enter email')).toBeInTheDocument()
})
render(<Input placeholder="Enter email" />);
expect(screen.getByPlaceholderText('Enter email')).toBeInTheDocument();
});
it('passes type to input', () => {
render(<Input type="email" />)
expect(screen.getByRole('textbox')).toHaveAttribute('type', 'email')
})
})
})
render(<Input type="email" />);
expect(screen.getByRole('textbox')).toHaveAttribute('type', 'email');
});
});
});
describe('Textarea', () => {
describe('rendering', () => {
it('renders a textarea element', () => {
render(<Textarea />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
render(<Textarea />);
expect(screen.getByRole('textbox')).toBeInTheDocument();
});
it('renders label when provided', () => {
render(<Textarea label="Message" />)
expect(screen.getByText('Message')).toBeInTheDocument()
})
render(<Textarea label="Message" />);
expect(screen.getByText('Message')).toBeInTheDocument();
});
it('renders error when provided', () => {
render(<Textarea error="Too short" />)
expect(screen.getByText('Too short')).toBeInTheDocument()
})
render(<Textarea error="Too short" />);
expect(screen.getByText('Too short')).toBeInTheDocument();
});
it('defaults to 4 rows', () => {
render(<Textarea />)
expect(screen.getByRole('textbox')).toHaveAttribute('rows', '4')
})
render(<Textarea />);
expect(screen.getByRole('textbox')).toHaveAttribute('rows', '4');
});
it('accepts custom rows', () => {
render(<Textarea rows={8} />)
expect(screen.getByRole('textbox')).toHaveAttribute('rows', '8')
})
})
render(<Textarea rows={8} />);
expect(screen.getByRole('textbox')).toHaveAttribute('rows', '8');
});
});
describe('accessibility', () => {
it('label is associated with textarea via htmlFor/id', () => {
render(<Textarea label="Message" />)
expect(screen.getByLabelText('Message')).toBeInTheDocument()
})
render(<Textarea label="Message" />);
expect(screen.getByLabelText('Message')).toBeInTheDocument();
});
it('error span is referenced by aria-describedby', () => {
render(<Textarea error="Too short" />)
const textarea = screen.getByRole('textbox')
const errorId = textarea.getAttribute('aria-describedby')
expect(errorId).toBeTruthy()
expect(document.getElementById(errorId!)).toHaveTextContent('Too short')
})
render(<Textarea error="Too short" />);
const textarea = screen.getByRole('textbox');
const errorId = textarea.getAttribute('aria-describedby');
expect(errorId).toBeTruthy();
expect(document.getElementById(errorId!)).toHaveTextContent('Too short');
});
it('no aria-describedby when no error', () => {
render(<Textarea />)
expect(screen.getByRole('textbox')).not.toHaveAttribute('aria-describedby')
})
})
})
render(<Textarea />);
expect(screen.getByRole('textbox')).not.toHaveAttribute('aria-describedby');
});
});
});
+37 -20
View File
@@ -1,68 +1,81 @@
import { useId, type InputHTMLAttributes, type TextareaHTMLAttributes } from 'react'
import { cn } from '$shared/lib'
import { useId, type InputHTMLAttributes, type TextareaHTMLAttributes } from 'react';
import { cn } from '$shared/lib';
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
/**
* Visible label rendered above the input
*/
label?: string
label?: string;
/**
* Validation error shown below the input
*/
error?: string
error?: string;
}
const INPUT_BASE = 'brutal-border bg-white px-4 py-3 text-carbon-black focus:outline-none focus:ring-2 focus:ring-burnt-oxide focus:ring-offset-2 focus:ring-offset-ochre-clay transition-all'
const INPUT_BASE =
'brutal-border bg-white px-4 py-3 text-carbon-black focus:outline-none focus:ring-2 focus:ring-burnt-oxide focus:ring-offset-2 focus:ring-offset-ochre-clay transition-all';
/**
* Text input with optional label and error state.
*/
export function Input({ label, error, className, id, ...props }: InputProps) {
const generatedId = useId()
const inputId = id ?? generatedId
const errorId = `${inputId}-error`
const generatedId = useId();
const inputId = id ?? generatedId;
const errorId = `${inputId}-error`;
return (
<div className="flex flex-col gap-2">
{label && <label htmlFor={inputId} className="text-carbon-black">{label}</label>}
{label && (
<label htmlFor={inputId} className="text-carbon-black">
{label}
</label>
)}
<input
id={inputId}
className={cn(INPUT_BASE, className)}
aria-describedby={error ? errorId : undefined}
{...props}
/>
{error && <span id={errorId} className="text-sm text-burnt-oxide">{error}</span>}
{error && (
<span id={errorId} className="text-sm text-burnt-oxide">
{error}
</span>
)}
</div>
)
);
}
interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
/**
* Visible label rendered above the textarea
*/
label?: string
label?: string;
/**
* Validation error shown below the textarea
*/
error?: string
error?: string;
/**
* Number of visible rows
* @default 4
*/
rows?: number
rows?: number;
}
/**
* Multiline textarea with optional label and error state.
*/
export function Textarea({ label, error, rows = 4, className, id, ...props }: TextareaProps) {
const generatedId = useId()
const textareaId = id ?? generatedId
const errorId = `${textareaId}-error`
const generatedId = useId();
const textareaId = id ?? generatedId;
const errorId = `${textareaId}-error`;
return (
<div className="flex flex-col gap-2">
{label && <label htmlFor={textareaId} className="text-carbon-black">{label}</label>}
{label && (
<label htmlFor={textareaId} className="text-carbon-black">
{label}
</label>
)}
<textarea
id={textareaId}
rows={rows}
@@ -70,7 +83,11 @@ export function Textarea({ label, error, rows = 4, className, id, ...props }: Te
aria-describedby={error ? errorId : undefined}
{...props}
/>
{error && <span id={errorId} className="text-sm text-burnt-oxide">{error}</span>}
{error && (
<span id={errorId} className="text-sm text-burnt-oxide">
{error}
</span>
)}
</div>
)
);
}