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 './Badge';
|
||||||
export * from './Button';
|
export * from './Button';
|
||||||
|
export * from './Card';
|
||||||
|
export * from './Section';
|
||||||
|
export * from './Input';
|
||||||
|
export * from './TechStackBrick';
|
||||||
|
|||||||
Reference in New Issue
Block a user