Files
plantillas-proyectos/frontend/src/lib/components/ui/error-panel-notice.svelte
2026-04-29 09:15:30 -06:00

204 lines
5.6 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import { AlertCircle, AlertTriangle, ChevronDown, ChevronUp, X } from 'lucide-svelte';
import { cn } from '$lib/utils';
export type ErrorPanelNoticeRow = {
field: string;
message: string;
};
export type ErrorPanelNoticeVariant = 'error' | 'warning';
export type ErrorPanelNoticeLabels = {
clear: string;
columnType: string;
columnField: string;
columnMessage: string;
emptyField: string;
dismissRowAria: string;
toggleDetailsAria: string;
};
let {
open,
variant = 'error',
title,
rows = [],
durationMs = 12000,
labels,
dismissibleRows = true,
class: className = '',
onClose,
onDismissRow
}: {
open: boolean;
variant?: ErrorPanelNoticeVariant;
title: string;
rows: ErrorPanelNoticeRow[];
durationMs?: number;
labels: ErrorPanelNoticeLabels;
dismissibleRows?: boolean;
class?: string;
onClose?: () => void;
onDismissRow?: (index: number) => void;
} = $props();
let expanded = $state(true);
const isWarning = $derived(variant === 'warning');
/** Re-expand the table when the panel opens or when errors/warnings change (new batch). */
$effect(() => {
if (!open) return;
void title;
void variant;
void JSON.stringify(rows);
expanded = true;
});
$effect(() => {
if (!open || durationMs <= 0) return;
void rows.length;
const t = setTimeout(() => onClose?.(), durationMs);
return () => clearTimeout(t);
});
function toggleExpanded() {
expanded = !expanded;
}
</script>
{#if open}
<div
data-partida-notice-layer
role={isWarning ? 'status' : 'alert'}
class={cn(
// Above sonner; inset-left keeps the panel aligned with empty space in Partidas
'pointer-events-auto fixed top-4 left-0 z-[1000000000] flex w-[min(100vw-2rem,30rem)] max-h-[min(72vh,calc(100vh-2rem))] flex-col overflow-hidden rounded-lg border bg-background text-foreground shadow-xl sm:left-1',
isWarning ? 'border-amber-400/40' : 'border-border',
className
)}
>
<!-- Header -->
<div
class={cn(
'flex shrink-0 items-center gap-2 border-b px-3 py-2.5',
isWarning
? 'border-amber-400/30 bg-amber-500/10 dark:bg-amber-500/15'
: 'border-destructive/20 bg-destructive/10 dark:bg-destructive/15'
)}
>
<span
class={cn(
'flex size-7 shrink-0 items-center justify-center rounded-full',
isWarning
? 'bg-amber-500 text-white'
: 'bg-destructive text-destructive-foreground'
)}
aria-hidden="true"
>
{#if isWarning}
<AlertTriangle class="size-4" />
{:else}
<AlertCircle class="size-4" />
{/if}
</span>
<p class="min-w-0 flex-1 text-sm font-semibold text-foreground">{title}</p>
<button
type="button"
class="inline-flex h-7 cursor-pointer items-center rounded-md px-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onclick={() => onClose?.()}
>
{labels.clear}
</button>
<button
type="button"
class="inline-flex size-7 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onclick={toggleExpanded}
aria-expanded={expanded}
aria-label={labels.toggleDetailsAria}
>
{#if expanded}
<ChevronUp class="size-4" />
{:else}
<ChevronDown class="size-4" />
{/if}
</button>
</div>
{#if expanded && rows.length > 0}
<div class="min-h-0 flex-1 overflow-y-auto">
<table class="w-full border-collapse text-sm">
<thead>
<tr class="border-b border-border bg-muted/30">
<th
class="w-10 px-2 py-1.5 text-center text-[10px] font-semibold tracking-widest text-muted-foreground uppercase"
>
{labels.columnType}
</th>
<th
class="w-[38%] px-2 py-1.5 text-center text-[10px] font-semibold tracking-widest text-muted-foreground uppercase"
>
{labels.columnField}
</th>
<th
class="px-2 py-1.5 text-left text-[10px] font-semibold tracking-widest text-muted-foreground uppercase"
>
{labels.columnMessage}
</th>
{#if dismissibleRows && onDismissRow}
<th class="w-8"></th>
{/if}
</tr>
</thead>
<tbody>
{#each rows as row, i (i)}
<tr class="border-b border-border/50 hover:bg-muted/20">
<td class="px-2 py-2.5 text-center align-middle">
<span
class={cn(
'inline-flex size-5 items-center justify-center rounded-full text-[11px] font-bold leading-none',
isWarning
? 'bg-amber-500 text-white'
: 'bg-destructive text-destructive-foreground'
)}
aria-hidden="true"
>
{#if isWarning}
<AlertTriangle class="size-3" />
{:else}
×
{/if}
</span>
</td>
<td
class="break-words px-2 py-2.5 align-middle text-center font-semibold text-foreground"
>
{row.field || labels.emptyField}
</td>
<td class="break-words px-2 py-2.5 align-top text-left leading-relaxed text-muted-foreground">
{row.message}
</td>
{#if dismissibleRows && onDismissRow}
<td class="px-1 py-2 text-center align-middle">
<button
type="button"
class="inline-flex size-6 cursor-pointer items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onclick={() => onDismissRow!(i)}
aria-label={labels.dismissRowAria}
>
<X class="size-3" />
</button>
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
{/if}