chore: format codebase and move SectionAccordion to entities/Section
This commit is contained in:
@@ -1 +1 @@
|
||||
export { Input, Textarea } from './ui/Input'
|
||||
export { Input, Textarea } from './ui/Input';
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user