feat(shared/ui): add Input and Textarea components with label and error states
This commit is contained in:
45
src/shared/ui/Card/Card.stories.svelte
Normal file
45
src/shared/ui/Card/Card.stories.svelte
Normal 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>
|
||||
40
src/shared/ui/Card/Card.svelte
Normal file
40
src/shared/ui/Card/Card.svelte
Normal 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>
|
||||
2
src/shared/ui/Card/index.ts
Normal file
2
src/shared/ui/Card/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as Card } from './Card.svelte';
|
||||
export type { CardBackground } from './types';
|
||||
1
src/shared/ui/Card/types.ts
Normal file
1
src/shared/ui/Card/types.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type CardBackground = 'ochre' | 'slate' | 'white';
|
||||
51
src/shared/ui/Input/Input.stories.svelte
Normal file
51
src/shared/ui/Input/Input.stories.svelte
Normal 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>
|
||||
39
src/shared/ui/Input/Input.svelte
Normal file
39
src/shared/ui/Input/Input.svelte
Normal 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>
|
||||
38
src/shared/ui/Input/Textarea.svelte
Normal file
38
src/shared/ui/Input/Textarea.svelte
Normal 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>
|
||||
3
src/shared/ui/Input/index.ts
Normal file
3
src/shared/ui/Input/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as Input } from './Input.svelte';
|
||||
export { default as Textarea } from './Textarea.svelte';
|
||||
export type { InputSize } from './types';
|
||||
1
src/shared/ui/Input/types.ts
Normal file
1
src/shared/ui/Input/types.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type InputSize = 'sm' | 'md' | 'lg';
|
||||
45
src/shared/ui/TechStackBrick/TechStackBrick.stories.svelte
Normal file
45
src/shared/ui/TechStackBrick/TechStackBrick.stories.svelte
Normal 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>
|
||||
25
src/shared/ui/TechStackBrick/TechStackBrick.svelte
Normal file
25
src/shared/ui/TechStackBrick/TechStackBrick.svelte
Normal 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>
|
||||
26
src/shared/ui/TechStackBrick/TechStackGrid.svelte
Normal file
26
src/shared/ui/TechStackBrick/TechStackGrid.svelte
Normal 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>
|
||||
2
src/shared/ui/TechStackBrick/index.ts
Normal file
2
src/shared/ui/TechStackBrick/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as TechStackBrick } from './TechStackBrick.svelte';
|
||||
export { default as TechStackGrid } from './TechStackGrid.svelte';
|
||||
@@ -1,2 +1,6 @@
|
||||
export * from './Badge';
|
||||
export * from './Button';
|
||||
export * from './Card';
|
||||
export * from './Section';
|
||||
export * from './Input';
|
||||
export * from './TechStackBrick';
|
||||
|
||||
Reference in New Issue
Block a user