feat: add ui components

This commit is contained in:
2026-08-27 21:10:25 +05:00
parent ac75665deb
commit 8437c95a28
14 changed files with 584 additions and 2 deletions
+69
View File
@@ -0,0 +1,69 @@
<script lang="ts">
import type { Component, Snippet } from "svelte";
import Icon from "./Icon.svelte";
interface Props {
children: Snippet;
onclick?: () => void;
type?: "button" | "submit";
variant?: "primary" | "ghost" | "accent" | "danger";
icon?: Component<{ size?: number; class?: string }>;
disabled?: boolean;
}
let {
children,
onclick,
type = "button",
variant = "primary",
icon,
disabled = false,
}: Props = $props();
</script>
<button
{type}
class="btn btn--{variant}"
{disabled}
onclick={() => onclick?.()}
>
{#if icon}<Icon {icon} size={14} />{/if}
<span class="btn-label">{@render children()}</span>
</button>
<style>
.btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.8rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: transparent;
color: var(--foreground);
font: inherit;
font-size: 0.85rem;
cursor: pointer;
}
.btn--primary {
background: var(--blue);
color: #fff;
border-color: var(--blue);
}
.btn--ghost:hover:not(:disabled) {
border-color: var(--blue);
}
.btn--accent {
background: var(--cyan);
color: #042;
border-color: var(--cyan);
}
.btn--danger {
background: var(--danger);
color: #fff;
border-color: var(--danger);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,32 @@
<script lang="ts">
interface Props {
label: string;
checked: boolean;
onchange?: (checked: boolean) => void;
}
let { label, checked = $bindable(), onchange }: Props = $props();
function handle(e: Event) {
checked = (e.target as HTMLInputElement).checked;
onchange?.(checked);
}
</script>
<label class="checkbox-field">
<input type="checkbox" {checked} onchange={handle} />
<span class="checkbox-label">{label}</span>
</label>
<style>
.checkbox-field {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: var(--foreground);
cursor: pointer;
}
.checkbox-field input {
accent-color: var(--blue);
}
</style>
@@ -0,0 +1,50 @@
<script lang="ts">
import { Copy } from "@lucide/svelte";
import IconButton from "./IconButton.svelte";
interface Props {
code: string;
lang?: string;
copyable?: boolean;
oncopy?: () => void;
}
let { code, lang, copyable = false, oncopy }: Props = $props();
</script>
<div class="code-block">
{#if lang}<span class="code-lang">{lang}</span>{/if}
<pre class="code-pre"><code>{code}</code></pre>
{#if copyable}
<div class="code-copy">
<IconButton icon={Copy} label="Copy" onclick={() => oncopy?.()} />
</div>
{/if}
</div>
<style>
.code-block {
position: relative;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--background);
padding: 0.6rem 0.8rem;
}
.code-lang {
font-family: var(--font-mono);
font-size: 10px;
text-transform: uppercase;
color: var(--muted);
}
.code-pre {
margin: 0;
overflow: auto;
font-family: var(--font-mono);
font-size: 11px;
color: var(--foreground);
}
.code-copy {
position: absolute;
top: 0.4rem;
right: 0.4rem;
}
</style>
@@ -0,0 +1,56 @@
<script lang="ts">
import Field from "./Field.svelte";
interface Props {
label: string;
value: string;
oninput?: (value: string) => void;
}
let { label, value = $bindable(), oninput }: Props = $props();
function handle(e: Event) {
value = (e.target as HTMLInputElement).value;
oninput?.(value);
}
</script>
<Field {label}>
<div class="color-field">
<input type="color" class="color-swatch" {value} oninput={handle} />
<input
type="text"
class="color-hex"
{value}
oninput={handle}
spellcheck="false"
/>
</div>
</Field>
<style>
.color-field {
display: flex;
align-items: center;
gap: 0.4rem;
}
.color-swatch {
width: 2rem;
height: 1.6rem;
padding: 0;
border: 1px solid var(--line);
border-radius: var(--radius);
background: transparent;
cursor: pointer;
}
.color-hex {
flex: 1;
font-family: var(--font-mono);
font-size: 11px;
color: var(--foreground);
background: var(--background);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.3rem 0.5rem;
text-transform: uppercase;
}
</style>
@@ -0,0 +1,23 @@
<script lang="ts">
import { Download } from "@lucide/svelte";
import Button from "./Button.svelte";
interface Props {
label: string;
size?: string;
onclick?: () => void;
}
let { label, size, onclick }: Props = $props();
</script>
<Button icon={Download} variant="primary" onclick={() => onclick?.()}>
{label}{#if size}<span class="dl-size">{size}</span>{/if}
</Button>
<style>
.dl-size {
font-family: var(--font-mono);
font-size: 10px;
opacity: 0.8;
}
</style>
@@ -0,0 +1,42 @@
<script lang="ts">
import type { Component, Snippet } from "svelte";
import Icon from "./Icon.svelte";
interface Props {
title: string;
description?: string;
icon?: Component<{ size?: number; class?: string }>;
children?: Snippet;
}
let { title, description, icon, children }: Props = $props();
</script>
<div class="empty-state">
{#if icon}<span class="empty-icon"><Icon {icon} size={28} /></span>{/if}
<strong class="empty-title">{title}</strong>
{#if description}<span class="empty-desc">{description}</span>{/if}
{#if children}<div class="empty-action">{@render children()}</div>{/if}
</div>
<style>
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 2rem 1rem;
text-align: center;
color: var(--muted);
}
.empty-icon {
color: var(--muted);
}
.empty-title {
color: var(--foreground);
font-size: 0.95rem;
}
.empty-desc {
font-size: 0.8rem;
max-width: 32ch;
}
</style>
+35
View File
@@ -0,0 +1,35 @@
<script lang="ts">
import type { Snippet } from "svelte";
import MonoLabel from "./MonoLabel.svelte";
interface Props {
label: string;
children: Snippet;
hint?: string;
}
let { label, children, hint }: Props = $props();
</script>
<div class="field">
<MonoLabel>{label}</MonoLabel>
<div class="field-control">{@render children()}</div>
{#if hint}<span class="field-hint">{hint}</span>{/if}
</div>
<style>
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.field-control {
display: flex;
align-items: center;
gap: 0.5rem;
}
.field-hint {
font-family: var(--font-mono);
font-size: 10px;
color: var(--muted);
}
</style>
+11
View File
@@ -0,0 +1,11 @@
<script lang="ts">
import type { Component } from "svelte";
interface Props {
icon: Component<{ size?: number; class?: string }>;
size?: number;
}
let { icon: IconComp, size = 16 }: Props = $props();
</script>
<IconComp {size} />
@@ -0,0 +1,64 @@
<script lang="ts">
import type { Component } from "svelte";
import Icon from "./Icon.svelte";
interface Props {
icon: Component<{ size?: number; class?: string }>;
label: string;
onclick?: () => void;
variant?: "ghost" | "solid" | "accent";
size?: number;
disabled?: boolean;
}
let {
icon,
label,
onclick,
variant = "ghost",
size = 16,
disabled = false,
}: Props = $props();
</script>
<button
type="button"
class="icon-btn icon-btn--{variant}"
aria-label={label}
{disabled}
onclick={() => onclick?.()}
>
<Icon {icon} {size} />
</button>
<style>
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: transparent;
color: var(--muted);
cursor: pointer;
}
.icon-btn:hover:not(:disabled) {
color: var(--foreground);
border-color: var(--blue);
}
.icon-btn--solid {
background: var(--blue);
color: #fff;
border-color: var(--blue);
}
.icon-btn--accent {
background: var(--cyan);
color: #042;
border-color: var(--cyan);
}
.icon-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,42 @@
<script lang="ts">
import Field from "./Field.svelte";
interface Option {
value: string;
label: string;
}
interface Props {
label: string;
value: string;
options: Option[];
onchange?: (value: string) => void;
}
let { label, value = $bindable(), options, onchange }: Props = $props();
function handle(e: Event) {
value = (e.target as HTMLSelectElement).value;
onchange?.(value);
}
</script>
<Field {label}>
<select class="select-field" {value} onchange={handle}>
{#each options as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</Field>
<style>
.select-field {
width: 100%;
font-family: var(--font-mono);
font-size: 11px;
color: var(--foreground);
background: var(--background);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.35rem 0.5rem;
}
</style>
@@ -0,0 +1,54 @@
<script lang="ts">
import Field from "./Field.svelte";
interface Props {
label: string;
value: number;
min?: number;
max?: number;
step?: number;
suffix?: string;
oninput?: (value: number) => void;
}
let {
label,
value = $bindable(),
min = 0,
max = 100,
step = 1,
suffix = "",
oninput,
}: Props = $props();
function handle(e: Event) {
value = Number((e.target as HTMLInputElement).value);
oninput?.(value);
}
</script>
<Field {label}>
<div class="slider-field">
<input type="range" {min} {max} {step} {value} oninput={handle} />
<span class="slider-value">{value}{suffix}</span>
</div>
</Field>
<style>
.slider-field {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
}
input[type="range"] {
flex: 1;
accent-color: var(--blue);
}
.slider-value {
font-family: var(--font-mono);
font-size: 11px;
color: var(--foreground);
min-width: 4ch;
text-align: right;
}
</style>
@@ -0,0 +1,47 @@
<script lang="ts">
interface Props {
value: string;
type?: string;
placeholder?: string;
readonly?: boolean;
oninput?: (value: string) => void;
}
let {
value = $bindable(),
type = "text",
placeholder = "",
readonly = false,
oninput,
}: Props = $props();
function handle(e: Event) {
value = (e.target as HTMLInputElement).value;
oninput?.(value);
}
</script>
<input
class="text-field"
{type}
{placeholder}
{readonly}
{value}
oninput={handle}
/>
<style>
.text-field {
width: 100%;
font-family: var(--font-mono);
font-size: 11px;
color: var(--foreground);
background: var(--background);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.35rem 0.5rem;
}
.text-field:focus {
outline: none;
border-color: var(--blue);
}
</style>
+56
View File
@@ -0,0 +1,56 @@
<script lang="ts">
interface Props {
checked: boolean;
label?: string;
onchange?: (checked: boolean) => void;
}
let { checked = $bindable(), label, onchange }: Props = $props();
function handle() {
checked = !checked;
onchange?.(checked);
}
</script>
<button
type="button"
class="toggle"
class:on={checked}
role="switch"
aria-checked={checked}
aria-label={label}
onclick={handle}
>
<span class="toggle-knob"></span>
</button>
<style>
.toggle {
position: relative;
width: 2.4rem;
height: 1.3rem;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--background);
cursor: pointer;
padding: 0;
}
.toggle.on {
background: var(--blue);
border-color: var(--blue);
}
.toggle-knob {
position: absolute;
top: 1px;
left: 1px;
width: calc(1.3rem - 4px);
height: calc(1.3rem - 4px);
border-radius: 50%;
background: var(--foreground);
transition: transform 0.12s ease;
}
.toggle.on .toggle-knob {
transform: translateX(1.1rem);
background: #fff;
}
</style>