web(ui): form patterns — FormField, formsnap, directories pilot
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user