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:
69
frontend/src/lib/ui/ConfirmDialog.svelte
Normal file
69
frontend/src/lib/ui/ConfirmDialog.svelte
Normal 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 -->
|
||||
118
frontend/src/lib/ui/Pagination.svelte
Normal file
118
frontend/src/lib/ui/Pagination.svelte
Normal 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 -->
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user