feat: add ui components
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user