feat(shared/ui): add Input and Textarea components with label and error states

This commit is contained in:
Ilia Mashkov
2026-03-06 23:16:27 +03:00
parent cb3d05b094
commit 30f5d01370
14 changed files with 322 additions and 0 deletions

View File

@@ -0,0 +1,45 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import Card from './Card.svelte';
const { Story } = defineMeta({
title: 'Shared/Card',
component: Card,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: { description: { component: 'Brutalist card. 3px border, hard shadow, three background presets.' } },
},
argTypes: {
background: { control: 'select', options: ['ochre', 'slate', 'white'] },
noPadding: { control: 'boolean' },
},
});
</script>
<Story name="Ochre (default)" args={{ background: 'ochre' }}>
{#snippet template(args)}
<Card {...args}>Card content goes here</Card>
{/snippet}
</Story>
<Story name="Slate" args={{ background: 'slate' }}>
{#snippet template(args)}
<Card {...args}>Card content goes here</Card>
{/snippet}
</Story>
<Story name="White" args={{ background: 'white' }}>
{#snippet template(args)}
<Card {...args}>Card content goes here</Card>
{/snippet}
</Story>
<Story name="No Padding" args={{ noPadding: true }}>
{#snippet template(args)}
<Card {...args}>
<div class="p-4 brutal-border-bottom">Header</div>
<div class="p-4">Body</div>
</Card>
{/snippet}
</Story>

View File

@@ -0,0 +1,40 @@
<!--
Component: Card
Brutalist content container. 3px border, 8px hard shadow, zero radius.
Backgrounds: ochre (default), slate (dark), white.
-->
<script lang="ts">
import { cn } from '$shared/lib/cn';
import type { Snippet } from 'svelte';
import type { CardBackground } from './types';
interface Props {
/** Background color preset @default 'ochre' */
background?: CardBackground;
/** Remove default padding @default false */
noPadding?: boolean;
children?: Snippet;
class?: string;
}
let { background = 'ochre', noPadding = false, children, class: className }: Props = $props();
const backgroundStyles: Record<CardBackground, string> = {
ochre: 'bg-ochre-clay text-carbon-black',
slate: 'bg-slate-indigo text-ochre-clay',
white: 'bg-white text-carbon-black',
};
const classes = $derived(cn(
'brutal-border brutal-shadow',
backgroundStyles[background],
!noPadding && 'p-6 md:p-8',
className,
));
</script>
<div class={classes}>
{#if children}
{@render children()}
{/if}
</div>

View File

@@ -0,0 +1,2 @@
export { default as Card } from './Card.svelte';
export type { CardBackground } from './types';

View File

@@ -0,0 +1 @@
export type CardBackground = 'ochre' | 'slate' | 'white';

View File

@@ -0,0 +1,51 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import Input from './Input.svelte';
import Textarea from './Textarea.svelte';
const { Story } = defineMeta({
title: 'Shared/Input',
component: Input,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: { description: { component: 'Brutalist text input with optional label and error state.' } },
},
});
</script>
<Story name="Default">
{#snippet template()}
<Input placeholder="Enter text..." />
{/snippet}
</Story>
<Story name="With Label">
{#snippet template()}
<Input label="Email address" type="email" placeholder="you@example.com" />
{/snippet}
</Story>
<Story name="With Error">
{#snippet template()}
<Input label="Email address" type="email" value="bad-email" error="Please enter a valid email address." />
{/snippet}
</Story>
<Story name="Disabled">
{#snippet template()}
<Input label="Read-only field" disabled value="Cannot edit this" />
{/snippet}
</Story>
<Story name="Textarea">
{#snippet template()}
<Textarea label="Message" placeholder="Write your message..." rows={5} />
{/snippet}
</Story>
<Story name="Textarea with Error">
{#snippet template()}
<Textarea label="Message" error="Message is required." />
{/snippet}
</Story>

View File

@@ -0,0 +1,39 @@
<!--
Component: Input
Brutalist text input. 3px border, white background, burnt-oxide focus ring.
Optional label (uppercase) and inline error message.
-->
<script lang="ts">
import { cn } from '$shared/lib/cn';
import type { HTMLInputAttributes } from 'svelte/elements';
interface Props extends HTMLInputAttributes {
/** Uppercase label rendered above the input */
label?: string;
/** Inline error message rendered below the input */
error?: string;
class?: string;
}
let { label, error, class: className, ...rest }: Props = $props();
const inputClasses = $derived(cn(
'brutal-border bg-white px-4 py-3 text-carbon-black w-full',
'focus:outline-none focus:ring-2 focus:ring-burnt-oxide focus:ring-offset-2 focus:ring-offset-ochre-clay',
'transition-all duration-150',
'disabled:opacity-50 disabled:cursor-not-allowed',
className,
));
</script>
<div class="flex flex-col gap-2">
{#if label}
<label class="text-carbon-black">{label}</label>
{/if}
<input class={inputClasses} {...rest} />
{#if error}
<span class="text-sm text-burnt-oxide">{error}</span>
{/if}
</div>

View File

@@ -0,0 +1,38 @@
<!--
Component: Textarea
Brutalist multi-line input. Same styling as Input but <textarea>.
Non-resizable by default (matches prototype).
-->
<script lang="ts">
import { cn } from '$shared/lib/cn';
import type { HTMLTextareaAttributes } from 'svelte/elements';
interface Props extends HTMLTextareaAttributes {
label?: string;
error?: string;
rows?: number;
class?: string;
}
let { label, error, rows = 4, class: className, ...rest }: Props = $props();
const textareaClasses = $derived(cn(
'brutal-border bg-white px-4 py-3 text-carbon-black w-full resize-none',
'focus:outline-none focus:ring-2 focus:ring-burnt-oxide focus:ring-offset-2 focus:ring-offset-ochre-clay',
'transition-all duration-150',
'disabled:opacity-50 disabled:cursor-not-allowed',
className,
));
</script>
<div class="flex flex-col gap-2">
{#if label}
<label class="text-carbon-black">{label}</label>
{/if}
<textarea {rows} class={textareaClasses} {...rest}></textarea>
{#if error}
<span class="text-sm text-burnt-oxide">{error}</span>
{/if}
</div>

View File

@@ -0,0 +1,3 @@
export { default as Input } from './Input.svelte';
export { default as Textarea } from './Textarea.svelte';
export type { InputSize } from './types';

View File

@@ -0,0 +1 @@
export type InputSize = 'sm' | 'md' | 'lg';

View File

@@ -0,0 +1,45 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import TechStackBrick from './TechStackBrick.svelte';
import TechStackGrid from './TechStackGrid.svelte';
const { Story } = defineMeta({
title: 'Shared/TechStackBrick',
component: TechStackBrick,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: { description: { component: 'Brutalist skill pills. Collapse shadow on hover for pressed feel.' } },
},
});
</script>
<Story name="Single brick">
{#snippet template()}
<TechStackBrick name="SvelteKit" />
{/snippet}
</Story>
<Story name="Grid of skills">
{#snippet template()}
<TechStackGrid
skills={[
"Svelte", "SvelteKit", "TypeScript",
"Tailwind CSS", "Bun", "Docker"
]}
/>
{/snippet}
</Story>
<Story name="Large grid">
{#snippet template()}
<TechStackGrid
skills={[
"Svelte", "SvelteKit", "TypeScript",
"Tailwind CSS", "Bun", "Docker",
"Vite", "Vitest", "Playwright",
"Biome", "Git", "Linux"
]}
/>
{/snippet}
</Story>

View File

@@ -0,0 +1,25 @@
<!--
Component: TechStackBrick
Single skill pill. Brutal border, white bg, hard shadow that collapses on hover (lifted press feel).
-->
<script lang="ts">
import { cn } from '$shared/lib/cn';
interface Props {
name: string;
class?: string;
}
let { name, class: className }: Props = $props();
const classes = $derived(cn(
'brutal-border brutal-shadow bg-white px-4 py-3 text-center',
'transition-all duration-200',
'hover:shadow-none hover:translate-x-[2px] hover:translate-y-[2px]',
className,
));
</script>
<div class={classes}>
<span class="text-sm uppercase tracking-wide">{name}</span>
</div>

View File

@@ -0,0 +1,26 @@
<!--
Component: TechStackGrid
Responsive grid of TechStackBrick items. Adjusts columns from 2 to 6 across breakpoints.
-->
<script lang="ts">
import { cn } from '$shared/lib/cn';
import TechStackBrick from './TechStackBrick.svelte';
interface Props {
skills: string[];
class?: string;
}
let { skills, class: className }: Props = $props();
const classes = $derived(cn(
'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4',
className,
));
</script>
<div class={classes}>
{#each skills as skill (skill)}
<TechStackBrick name={skill} />
{/each}
</div>

View File

@@ -0,0 +1,2 @@
export { default as TechStackBrick } from './TechStackBrick.svelte';
export { default as TechStackGrid } from './TechStackGrid.svelte';

View File

@@ -1,2 +1,6 @@
export * from './Badge';
export * from './Button';
export * from './Card';
export * from './Section';
export * from './Input';
export * from './TechStackBrick';