feat: add tooltip for errors\warnings

This commit is contained in:
2026-10-11 14:06:10 +05:00
parent 3c9175f8a3
commit be9ecd0e8e
9 changed files with 175 additions and 39 deletions
@@ -3,6 +3,7 @@
import { RefreshCw } from "@lucide/svelte";
import { t } from "$lib/i18n/t";
import Badge from "$lib/components/ui/Badge.svelte";
import DetailBadge from "$lib/components/ui/DetailBadge.svelte";
import type { Snippet } from "svelte";
interface Props {
@@ -15,11 +16,11 @@
dims?: string;
note?: string;
noteTone?: "warning" | "info";
// Runtime warnings (chain/download events): shown as badges next to the
// derived result note, but from a different source.
warnings?: string[];
// Runtime errors: danger badges; detail goes to the native tooltip until
// a popover with full text exists.
// Runtime warnings (chain/source/download events): shown as badges next
// to the derived result note, full message in the "?" popover.
warnings?: { label: string; detail?: string }[];
// Runtime errors: danger badges; the full message lives in the
// DetailBadge popover behind the "?" button.
errors?: { label: string; detail?: string }[];
actions?: Snippet;
}
@@ -61,15 +62,20 @@
<Badge tone={noteTone} label={note} />
{/if}
{#each errors as error (error.label + (error.detail ?? ""))}
<Badge
<DetailBadge
tone="danger"
label={error.label}
title={error.detail}
detail={error.detail}
role="alert"
/>
{/each}
{#each warnings as warning (warning)}
<Badge tone="warning" label={warning} role="status" />
{#each warnings as warning (warning.label + (warning.detail ?? ""))}
<DetailBadge
tone="warning"
label={warning.label}
detail={warning.detail}
role="status"
/>
{/each}
</span>
</figcaption>
@@ -1,7 +1,7 @@
<script lang="ts">
import DownloadButton from "$lib/components/ui/DownloadButton.svelte";
import UploadButton from "$lib/components/ui/UploadButton.svelte";
import Badge from "$lib/components/ui/Badge.svelte";
import DetailBadge from "$lib/components/ui/DetailBadge.svelte";
import type { OutputMime } from "$lib/core/io";
import { outputFormatByMime } from "$lib/output-formats";
import { t } from "$lib/i18n/t";
@@ -18,7 +18,7 @@
limitKb?: number;
alphaLoss?: boolean;
// Download-zone feedback: size-limit misses and action failures.
warnings?: string[];
warnings?: { label: string; detail?: string }[];
error?: { label: string; detail?: string } | null;
onupload: (file: File) => void;
ondownload: () => void;
@@ -67,14 +67,19 @@
disabled={running}
/>
{/if}
{#each warnings as warning (warning)}
<Badge tone="warning" label={warning} role="status" />
{#each warnings as warning (warning.label + (warning.detail ?? ""))}
<DetailBadge
tone="warning"
label={warning.label}
detail={warning.detail}
role="status"
/>
{/each}
{#if error}
<Badge
<DetailBadge
tone="danger"
label={error.label}
title={error.detail}
detail={error.detail}
role="alert"
/>
{/if}
@@ -13,7 +13,7 @@
resultKind?: ResultKind;
result: PixelImage | null;
resultNote?: ResultNote | null;
warnings?: string[];
warnings?: { label: string; detail?: string }[];
errors?: { label: string; detail?: string }[];
fileResult?: FileResult | null;
textResult?: string | null;
@@ -15,7 +15,7 @@
textSource?: string;
running?: boolean;
fileCount?: number;
warnings?: string[];
warnings?: { label: string; detail?: string }[];
errors?: { label: string; detail?: string }[];
ontextinput?: (text: string) => void;
onrendertext?: () => void;
@@ -9,9 +9,10 @@
import type { PixelImage } from "$lib/core/types";
import {
chainStepTitle,
chainWarningText,
chainWarningNotice,
pageDescription,
pageTitle,
type WarningNotice,
} from "$lib/i18n/schema-tool-strings";
import { t } from "$lib/i18n/t";
import { moveStep, type ChainStep } from "$lib/pipeline.svelte";
@@ -124,20 +125,18 @@
const stepFileSets = $derived(runner.stepFileSets);
const runWarnings = $derived(runner.runWarnings);
// Feedback zones: source tile / step tiles / download button area.
const sourceWarningTexts = $derived(
upload.sourceWarnings.map(chainWarningText),
);
const sourceNotices = $derived(upload.sourceWarnings.map(chainWarningNotice));
const warningsByStep = $derived.by(() => {
const byStep: string[][] = steps.map(() => []);
const byStep: WarningNotice[][] = steps.map(() => []);
for (const w of runWarnings) {
if ("step" in w && w.step >= 1 && w.step <= byStep.length) {
byStep[w.step - 1].push(chainWarningText(w));
byStep[w.step - 1].push(chainWarningNotice(w));
}
}
return byStep;
});
const downloadWarningTexts = $derived(
runner.downloadWarnings.map(chainWarningText),
const downloadNotices = $derived(
runner.downloadWarnings.map(chainWarningNotice),
);
const stepError = $derived(runner.stepError);
const sourceError = $derived(runner.sourceError);
@@ -305,7 +304,7 @@
textSource={upload.textSource}
running={busy}
fileCount={upload.sourceFiles.length}
warnings={sourceWarningTexts}
warnings={sourceNotices}
errors={errorBadge(sourceError)}
ontextinput={(v) => (upload.textSource = v)}
onrendertext={run}
@@ -407,7 +406,7 @@
quality={output.quality}
limitKb={output.limitKb}
alphaLoss={output.alphaLoss}
warnings={downloadWarningTexts}
warnings={downloadNotices}
error={actionError
? {
label: t("actions.actionFailed"),
@@ -0,0 +1,98 @@
<script lang="ts">
import { t } from "$lib/i18n/t";
import Badge from "./Badge.svelte";
interface Props {
tone: "warning" | "danger" | "info";
label: string;
detail?: string;
role?: "alert" | "status";
}
let { tone, label, detail = undefined, role = "status" }: Props = $props();
const id = $props.id();
// Anchor name is per-instance: with a shared name several popovers would
// fight over the same anchor element.
let anchorName = `--badge-${id}`;
let open = $state(false);
</script>
<span class="detail-badge">
<Badge {tone} {label} {role} />
{#if detail}
<button
type="button"
class="more"
style:anchor-name={anchorName}
aria-label={t("ui.detailsAria")}
aria-expanded={open}
aria-controls={id}
popovertarget={id}
>
?
</button>
<span
{id}
class="popover"
role="tooltip"
popover="auto"
style:position-anchor={anchorName}
onbeforetoggle={(e) => (open = e.newState === "open")}
>
{detail}
</span>
{/if}
</span>
<style>
.detail-badge {
display: inline-flex;
align-items: center;
gap: var(--space-s);
}
.more {
display: inline-flex;
align-items: center;
justify-content: center;
/* Мини-размер цели в 24px: попадание пальцем без промаха. */
width: calc(var(--space-xxxl) * 0.66);
height: calc(var(--space-xxxl) * 0.66);
padding: 0;
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
background: var(--color-panel);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
font-weight: bold;
line-height: 1;
cursor: pointer;
}
.more:hover,
.more:focus-visible {
color: var(--color-text);
border-color: var(--color-main);
}
/* Нативный popover живёт в top layer: light dismiss, Escape и раскладка
поверх всего — из коробки, без z-index и ручных подписок. */
.popover {
position: fixed;
margin: 0;
top: anchor(bottom);
left: anchor(left);
translate: 0 var(--space-s);
/* Не уезжает за правый край: доступная ширина считается от якоря. */
width: max-content;
max-width: calc(100vw - anchor(left) - var(--space-xl));
padding: var(--space-m) var(--space-l);
border: var(--size-border-thick) solid var(--color-text-muted);
border-radius: var(--radius-m);
background: var(--color-background);
color: var(--color-text);
font: var(--font-size-s) var(--font-mono);
font-weight: normal;
text-align: left;
text-transform: none;
white-space: normal;
overflow-wrap: anywhere;
}
</style>