feat(frontend): add Pagination and ConfirmDialog shared components

Pagination.svelte: page numbers with ellipsis, prev/next, page size selector,
'Showing X-Y of Z' summary. Replaces 9 custom pagination implementations.

ConfirmDialog.svelte: styled modal replacing native window.confirm().
Backdrop click + Escape to cancel. Supports primary/destructive variants.
Replaces 16 native confirm() calls.
This commit is contained in:
2026-06-17 14:50:42 +03:00
parent c2934aec2a
commit 87cbff9bd8
3 changed files with 189 additions and 0 deletions

View File

@@ -0,0 +1,69 @@
<!-- #region UI.ConfirmDialog [C:2] [TYPE Component] [SEMANTICS confirm,dialog,modal,delete,action,ui,atom] -->
<!-- @ingroup UI -->
<!-- @BRIEF Confirmation dialog replacing native window.confirm() with styled modal. -->
<!-- @LAYER UI -->
<!-- @UX_STATE Open -> Backdrop + centered dialog with message, confirm/cancel buttons. -->
<!-- @UX_STATE Closed -> Nothing rendered. -->
<!-- @UX_RECOVERY Cancel button or backdrop click to dismiss. -->
<script lang="ts">
import { Button } from "$lib/ui";
let {
show = $bindable(false),
title = "Confirm",
message = "",
confirmLabel = "Confirm",
cancelLabel = "Cancel",
variant = "primary" as "primary" | "destructive",
onConfirm,
onCancel,
} = $props();
function handleConfirm() {
show = false;
if (onConfirm) onConfirm();
}
function handleCancel() {
show = false;
if (onCancel) onCancel();
}
function handleBackdrop(e: MouseEvent) {
if (e.target === e.currentTarget) handleCancel();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape") handleCancel();
}
</script>
{#if show}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onclick={handleBackdrop}
onkeydown={handleKeydown}
>
<div
class="bg-surface-card rounded-xl shadow-xl p-6 max-w-md w-full mx-4 border border-border"
role="alertdialog"
aria-modal="true"
aria-label={title}
>
<h3 class="text-lg font-semibold text-text mb-2">{title}</h3>
{#if message}
<p class="text-sm text-text-muted mb-6">{message}</p>
{/if}
<div class="flex justify-end gap-3">
<Button variant="secondary" onclick={handleCancel}>
{cancelLabel}
</Button>
<Button variant={variant} onclick={handleConfirm}>
{confirmLabel}
</Button>
</div>
</div>
</div>
{/if}
<!-- #endregion UI.ConfirmDialog -->

View File

@@ -0,0 +1,118 @@
<!-- #region UI.Pagination [C:2] [TYPE Component] [SEMANTICS pagination, paging, navigation, ui, atom] -->
<!-- @ingroup UI -->
<!-- @BRIEF Reusable pagination controls with prev/next, page numbers, and "Showing X-Y of Z" summary. -->
<!-- @LAYER UI -->
<!-- @UX_STATE First -> Prev disabled, page 1 highlighted. -->
<!-- @UX_STATE Middle -> Prev/Next enabled, current page highlighted. -->
<!-- @UX_STATE Last -> Next disabled, last page highlighted. -->
<!-- @UX_STATE Single -> Only one page, both buttons disabled, no page numbers shown. -->
<script lang="ts">
import { t } from '$lib/i18n/index.svelte.js';
import { Button } from "$lib/ui";
let {
page = $bindable(0),
pageSize = 20,
total = 0,
pageSizeOptions = [] as number[],
onPageSizeChange,
class: className = "",
} = $props();
let totalPages = $derived(Math.max(1, Math.ceil(total / pageSize)));
let start = $derived(total === 0 ? 0 : page * pageSize + 1);
let end = $derived(Math.min((page + 1) * pageSize, total));
function goToPage(p: number) {
if (p >= 0 && p < totalPages) page = p;
}
// Generate page number range (max 7 buttons with ellipsis)
let pageNumbers = $derived.by(() => {
const pages: (number | "...")[] = [];
if (totalPages <= 7) {
for (let i = 0; i < totalPages; i++) pages.push(i);
} else {
pages.push(0);
if (page > 2) pages.push("...");
const start = Math.max(1, page - 1);
const end = Math.min(totalPages - 2, page + 1);
for (let i = start; i <= end; i++) pages.push(i);
if (page < totalPages - 3) pages.push("...");
pages.push(totalPages - 1);
}
return pages;
});
</script>
<div class="flex items-center justify-between {className}">
<!-- Summary -->
<div class="text-sm text-text-muted">
{#if total > 0}
{($t.dashboard?.showing ?? "Showing {start}-{end} of {total}")
.replace("{start}", String(start))
.replace("{end}", String(end))
.replace("{total}", String(total))}
{:else}
{$t.common?.no_data || "No items"}
{/if}
</div>
<div class="flex items-center gap-2">
<!-- Page size selector -->
{#if pageSizeOptions.length > 0}
<select
class="text-sm border border-border rounded px-2 py-1 bg-surface-card text-text"
value={pageSize}
onchange={(e) => {
const val = Number(e.currentTarget.value);
if (onPageSizeChange) onPageSizeChange(val);
}}
>
{#each pageSizeOptions as opt}
<option value={opt}>{opt}</option>
{/each}
</select>
{/if}
<!-- Page number buttons (only when > 1 page) -->
{#if totalPages > 1}
<div class="flex gap-1">
{#each pageNumbers as p}
{#if p === "..."}
<span class="px-2 py-1 text-sm text-text-muted"></span>
{:else}
<button
class="px-2 py-1 text-sm rounded transition-colors
{p === page
? 'bg-primary text-white'
: 'text-text hover:bg-surface-muted'}"
onclick={() => goToPage(p)}
>
{p + 1}
</button>
{/if}
{/each}
</div>
{/if}
<!-- Prev / Next -->
<Button
variant="secondary"
size="sm"
disabled={page === 0}
onclick={() => goToPage(page - 1)}
>
{$t.dashboard?.previous || "Prev"}
</Button>
<Button
variant="secondary"
size="sm"
disabled={page >= totalPages - 1}
onclick={() => goToPage(page + 1)}
>
{$t.dashboard?.next || "Next"}
</Button>
</div>
</div>
<!-- #endregion UI.Pagination -->

View File

@@ -18,6 +18,8 @@ export { default as Icon } from './Icon.svelte';
export { default as HelpTooltip } from './HelpTooltip.svelte';
export { default as Badge } from './Badge.svelte';
export { default as Skeleton } from './Skeleton.svelte';
export { default as Pagination } from './Pagination.svelte';
export { default as ConfirmDialog } from './ConfirmDialog.svelte';
// [/SECTION: EXPORTS]
// #endregion ui:Module