web(ui): form patterns — FormField, formsnap, directories pilot

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-05-19 11:59:25 +07:00
co-authored by Cursor
parent ac635e91eb
commit 77a2cfd426
12 changed files with 397 additions and 20 deletions
@@ -0,0 +1,45 @@
<script lang="ts">
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '$lib/ui/core/alert-dialog/index.js';
import { closeConfirm, confirmState } from './confirm-state.svelte.js';
const state = $derived(confirmState.current);
const open = $derived(!!state?.open);
function onOpenChange(v: boolean) {
if (!v) closeConfirm();
}
</script>
<AlertDialog {open} {onOpenChange}>
{#if state}
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{state.title}</AlertDialogTitle>
{#if state.description}
<AlertDialogDescription>{state.description}</AlertDialogDescription>
{/if}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={state.loading} onclick={() => closeConfirm()}>
{state.cancelLabel ?? 'Отмена'}
</AlertDialogCancel>
<AlertDialogAction
class={state.destructive ? 'bg-destructive text-destructive-foreground hover:bg-destructive/90' : ''}
disabled={state.loading}
onclick={state.onConfirm}
>
{state.loading ? '…' : (state.confirmLabel ?? 'Подтвердить')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
{/if}
</AlertDialog>
@@ -0,0 +1,41 @@
export type ConfirmOptions = {
title: string;
description?: string;
confirmLabel?: string;
cancelLabel?: string;
destructive?: boolean;
onConfirm: () => void | Promise<void>;
};
export type ConfirmState = ConfirmOptions & {
open: boolean;
loading: boolean;
};
export const confirmState = $state<{ current: ConfirmState | null }>({ current: null });
export function confirm(options: ConfirmOptions) {
return new Promise<boolean>((resolve) => {
confirmState.current = {
...options,
open: true,
loading: false,
onConfirm: async () => {
if (!confirmState.current) return;
confirmState.current = { ...confirmState.current, loading: true };
try {
await options.onConfirm();
resolve(true);
} catch {
resolve(false);
} finally {
confirmState.current = null;
}
}
};
});
}
export function closeConfirm() {
confirmState.current = null;
}
@@ -0,0 +1,147 @@
<script lang="ts" generics="T extends Record<string, unknown>">
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils.js';
import * as Table from '$lib/ui/core/table/index.js';
import { Skeleton } from '$lib/ui/core/skeleton/index.js';
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
import EmptyState from '$lib/ui/patterns/empty-state/empty-state.svelte';
import ArrowUpDown from '@lucide/svelte/icons/arrow-up-down';
import ArrowUp from '@lucide/svelte/icons/arrow-up';
import ArrowDown from '@lucide/svelte/icons/arrow-down';
import { Button } from '$lib/ui/core/button/index.js';
import type { DataTableColumn } from './types.js';
type Props = {
columns: DataTableColumn<T>[];
rows: T[];
rowKey: (row: T) => string;
loading?: boolean;
error?: string | null;
emptyTitle?: string;
emptyDescription?: string;
toolbar?: Snippet;
cell: Snippet<[{ row: T; column: DataTableColumn<T> }]>;
class?: string;
};
let {
columns,
rows,
rowKey,
loading = false,
error = null,
emptyTitle = 'Нет записей',
emptyDescription,
toolbar,
cell,
class: className
}: Props = $props();
let sortColumnId = $state<string | null>(null);
let sortDir = $state<'asc' | 'desc'>('asc');
const sortedRows = $derived.by(() => {
if (!sortColumnId) return rows;
const col = columns.find((c) => c.id === sortColumnId);
if (!col?.sortValue) return rows;
const getter = col.sortValue;
const copy = [...rows];
copy.sort((a, b) => {
const av = getter(a);
const bv = getter(b);
const as = av == null ? '' : String(av);
const bs = bv == null ? '' : String(bv);
const cmp = as.localeCompare(bs, 'ru', { numeric: true });
return sortDir === 'asc' ? cmp : -cmp;
});
return copy;
});
function toggleSort(col: DataTableColumn<T>) {
if (!col.sortable) return;
if (sortColumnId === col.id) {
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
} else {
sortColumnId = col.id;
sortDir = 'asc';
}
}
const skeletonRows = 5;
</script>
<div class={cn('flex flex-col gap-3', className)}>
{#if toolbar}
<div class="flex flex-wrap items-center justify-between gap-2">{@render toolbar()}</div>
{/if}
{#if error}
<Alert variant="destructive">
<AlertTitle>Ошибка загрузки</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
{/if}
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
{#each columns as col (col.id)}
<Table.Head class={col.headerClass ?? col.class}>
{#if col.sortable}
<Button
variant="ghost"
size="sm"
class="-ms-2 h-8 gap-1 px-2 font-medium"
onclick={() => toggleSort(col)}
>
{col.label}
{#if sortColumnId === col.id}
{#if sortDir === 'asc'}
<ArrowUp class="size-3.5" />
{:else}
<ArrowDown class="size-3.5" />
{/if}
{:else}
<ArrowUpDown class="text-muted-foreground size-3.5" />
{/if}
</Button>
{:else}
{col.label}
{/if}
</Table.Head>
{/each}
</Table.Row>
</Table.Header>
<Table.Body>
{#if loading}
{#each Array(skeletonRows) as _, i (i)}
<Table.Row>
{#each columns as col (col.id)}
<Table.Cell class={col.class}>
<Skeleton class="h-5 w-full max-w-[12rem]" />
</Table.Cell>
{/each}
</Table.Row>
{/each}
{:else if sortedRows.length === 0}
<Table.Row>
<Table.Cell colspan={columns.length} class="p-0">
<EmptyState title={emptyTitle} description={emptyDescription} />
</Table.Cell>
</Table.Row>
{:else}
{#each sortedRows as row (rowKey(row))}
<Table.Row>
{#each columns as col (col.id)}
<Table.Cell class={col.class}>
{@render cell({ row, column: col })}
</Table.Cell>
{/each}
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
</div>
@@ -0,0 +1,8 @@
export type DataTableColumn<T> = {
id: string;
label: string;
sortable?: boolean;
sortValue?: (row: T) => string | number | null | undefined;
class?: string;
headerClass?: string;
};
@@ -0,0 +1,29 @@
<script lang="ts">
import type { Component, Snippet } from 'svelte';
import { cn } from '$lib/utils.js';
type Props = {
title?: string;
description?: string;
icon?: Component;
action?: Snippet;
class?: string;
};
let { title = 'Нет данных', description, icon: Icon, action, class: className }: Props = $props();
</script>
<div class={cn('flex flex-col items-center justify-center gap-2 px-4 py-12 text-center', className)}>
{#if Icon}
<div class="text-muted-foreground/60 mb-1" aria-hidden="true">
<Icon class="size-10" />
</div>
{/if}
<p class="text-sm font-medium">{title}</p>
{#if description}
<p class="text-muted-foreground max-w-sm text-sm">{description}</p>
{/if}
{#if action}
<div class="mt-2">{@render action()}</div>
{/if}
</div>
@@ -0,0 +1,15 @@
<script lang="ts">
import { Card, CardContent, CardHeader } from '$lib/ui/core/card/index.js';
import { Skeleton } from '$lib/ui/core/skeleton/index.js';
</script>
<Card>
<CardHeader class="gap-2">
<Skeleton class="h-5 w-40" />
<Skeleton class="h-4 w-64" />
</CardHeader>
<CardContent class="flex flex-col gap-3">
<Skeleton class="h-24 w-full" />
<Skeleton class="h-4 w-3/4" />
</CardContent>
</Card>
@@ -0,0 +1,27 @@
<script lang="ts">
import { Skeleton } from '$lib/ui/core/skeleton/index.js';
import * as Table from '$lib/ui/core/table/index.js';
type Props = {
columns?: number;
rows?: number;
};
let { columns = 4, rows = 5 }: Props = $props();
</script>
<div class="rounded-md border">
<Table.Root>
<Table.Body>
{#each Array(rows) as _, ri (ri)}
<Table.Row>
{#each Array(columns) as _, ci (ci)}
<Table.Cell>
<Skeleton class="h-5 w-full max-w-[10rem]" />
</Table.Cell>
{/each}
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
@@ -0,0 +1,24 @@
<script lang="ts">
import { Input } from '$lib/ui/core/input/index.js';
import { cn } from '$lib/utils.js';
import type { ComponentProps } from 'svelte';
type Props = ComponentProps<typeof Input> & {
value?: string | number;
error?: boolean;
};
let {
class: className,
value = $bindable(''),
error = false,
...rest
}: Props = $props();
</script>
<Input
class={cn(error && 'border-destructive aria-invalid:border-destructive', className)}
aria-invalid={error || undefined}
bind:value
{...rest}
/>
@@ -0,0 +1,13 @@
<script lang="ts">
import { Textarea } from '$lib/ui/core/textarea/index.js';
import { cn } from '$lib/utils.js';
import type { ComponentProps } from 'svelte';
type Props = ComponentProps<typeof Textarea> & {
error?: boolean;
};
let { class: className, error = false, ...rest }: Props = $props();
</script>
<Textarea class={cn(error && 'border-destructive aria-invalid:border-destructive', className)} aria-invalid={error || undefined} {...rest} />
@@ -0,0 +1,30 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils.js';
import { Label } from '$lib/ui/core/label/index.js';
type Props = {
label: string;
id: string;
description?: string;
error?: string | null;
required?: boolean;
class?: string;
children: Snippet;
};
let { label, id, description, error, required = false, class: className, children }: Props = $props();
</script>
<div class={cn('flex flex-col gap-1.5', className)}>
<Label for={id}>
{label}
{#if required}<span class="text-destructive" aria-hidden="true"> *</span>{/if}
</Label>
{@render children()}
{#if error}
<p class="text-destructive text-xs" role="alert">{error}</p>
{:else if description}
<p class="text-muted-foreground text-xs">{description}</p>
{/if}
</div>
+2
View File
@@ -4,6 +4,7 @@
import './layout.css';
import favicon from '$lib/assets/favicon.svg';
import AppLayout from '$lib/ui/app/layout/app-layout.svelte';
import ConfirmDialog from '$lib/ui/patterns/confirm/confirm-dialog.svelte';
import { applyTheme, readTheme, THEME_STORAGE_KEY, type ThemePreference } from '$lib/theme.js';
import { Toaster } from 'svelte-sonner';
@@ -42,4 +43,5 @@
<title>EvoBGP</title>
</svelte:head>
<Toaster richColors theme={sonnerTheme} position="top-right" />
<ConfirmDialog />
<AppLayout bind:theme={themePref}>{@render children()}</AppLayout>
+16 -20
View File
@@ -11,8 +11,8 @@
} from '$lib/api/types.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '$lib/components/ui/card/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import FormField from '$lib/ui/patterns/form/form-field.svelte';
import AppInput from '$lib/ui/patterns/form/app-input.svelte';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs/index.js';
import {
Dialog,
@@ -308,15 +308,13 @@
<DialogHeader>
<DialogTitle>{commEdit ? 'Редактировать сообщество BGP' : 'Новое сообщество BGP'}</DialogTitle>
</DialogHeader>
<div class="space-y-4 py-2">
<div class="space-y-1.5">
<Label for="c-community">Код сообщества</Label>
<Input id="c-community" bind:value={commForm.community} placeholder="65001:120" />
</div>
<div class="space-y-1.5">
<Label for="c-title">Название</Label>
<Input id="c-title" bind:value={commForm.title} placeholder="Человекочитаемое имя для списков" />
</div>
<div class="flex flex-col gap-4 py-2">
<FormField label="Код сообщества" id="c-community" required>
<AppInput id="c-community" bind:value={commForm.community} placeholder="65001:120" />
</FormField>
<FormField label="Название" id="c-title" description="Человекочитаемое имя для списков">
<AppInput id="c-title" bind:value={commForm.title} placeholder="Название" />
</FormField>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => (commDialog = false)}>Отмена</Button>
@@ -344,15 +342,13 @@
<DialogHeader>
<DialogTitle>{dohEdit ? 'Редактировать' : 'Новый'} DoH профиль</DialogTitle>
</DialogHeader>
<div class="space-y-4 py-2">
<div class="space-y-1.5">
<Label for="doh-url">URL</Label>
<Input id="doh-url" bind:value={dohForm.url} placeholder="https://dns.google/dns-query" />
</div>
<div class="space-y-1.5">
<Label for="doh-timeout">Таймаут (мс)</Label>
<Input id="doh-timeout" type="number" bind:value={dohForm.timeout_ms} placeholder="5000" />
</div>
<div class="flex flex-col gap-4 py-2">
<FormField label="URL" id="doh-url" required>
<AppInput id="doh-url" bind:value={dohForm.url} placeholder="https://dns.google/dns-query" />
</FormField>
<FormField label="Таймаут (мс)" id="doh-timeout">
<AppInput id="doh-timeout" type="number" bind:value={dohForm.timeout_ms} placeholder="5000" />
</FormField>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => (dohDialog = false)}>Отмена</Button>