mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-11-13 17:08:20 +00:00
fix: move to popover realisation
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import RangeControl from "$lib/components/fields/schema/RangeControl.svelte";
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import { onDismiss } from "$lib/components/ui/dismiss";
|
||||
import { trackPopover } from "$lib/components/ui/popover-position";
|
||||
import type { OutputMime } from "$lib/core/io";
|
||||
import { OUTPUT_FORMATS, outputFormatByMime } from "$lib/output-formats";
|
||||
import type { SliderSpec } from "$lib/registry-schema";
|
||||
@@ -43,8 +43,16 @@
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const menuId = $props.id();
|
||||
let open = $state(false);
|
||||
let root = $state<HTMLDivElement | null>(null);
|
||||
let btn = $state<HTMLButtonElement | null>(null);
|
||||
let menu = $state<HTMLDivElement | null>(null);
|
||||
|
||||
// While open, keep the popover placed next to the button (scroll/resize).
|
||||
$effect(() => {
|
||||
if (!open || !menu || !btn) return;
|
||||
return trackPopover(menu, btn, "end");
|
||||
});
|
||||
|
||||
function choose(mime: OutputMime) {
|
||||
// Menu doubles as a format-settings panel: keep it open on select
|
||||
@@ -56,23 +64,15 @@
|
||||
const raw = (e.target as HTMLInputElement).valueAsNumber;
|
||||
onlimit(Number.isFinite(raw) && raw >= 1 ? Math.round(raw) : undefined);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
return onDismiss(
|
||||
() => root,
|
||||
() => (open = false),
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="download-split" bind:this={root}>
|
||||
<div class="download-split">
|
||||
<button
|
||||
type="button"
|
||||
class="main"
|
||||
disabled={running}
|
||||
onclick={() => {
|
||||
open = false;
|
||||
menu?.hidePopover();
|
||||
ondownload();
|
||||
}}
|
||||
>
|
||||
@@ -80,66 +80,72 @@
|
||||
<span>{t("actions.download")}</span>
|
||||
</button>
|
||||
<button
|
||||
bind:this={btn}
|
||||
type="button"
|
||||
class="toggle"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-controls={menuId}
|
||||
aria-label={t("actions.formatAria")}
|
||||
disabled={running}
|
||||
onclick={() => (open = !open)}
|
||||
popovertarget={menuId}
|
||||
>
|
||||
<span>{current.label}</span>
|
||||
<Icon icon={ChevronDown} size={14} />
|
||||
</button>
|
||||
{#if open}
|
||||
<div class="menu" role="menu">
|
||||
{#each OUTPUT_FORMATS as f (f.mime)}
|
||||
<button
|
||||
type="button"
|
||||
class="item"
|
||||
class:selected={f.mime === format}
|
||||
role="menuitemradio"
|
||||
aria-checked={f.mime === format}
|
||||
onclick={() => choose(f.mime)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
{/each}
|
||||
{#if qualitySpec && quality !== undefined}
|
||||
<div class="quality">
|
||||
<RangeControl
|
||||
label={t("fields.quality")}
|
||||
value={quality}
|
||||
spec={qualitySpec}
|
||||
onchange={(q) => onquality(q)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="limit">
|
||||
<label class="limit-field">
|
||||
<span>{t("actions.sizeLimitKb")}</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
placeholder="∞"
|
||||
value={limitKb ?? ""}
|
||||
oninput={handleLimit}
|
||||
/>
|
||||
</label>
|
||||
{#if limitKb !== undefined && format === "image/bmp"}
|
||||
<p class="limit-warn" role="status">
|
||||
{t("actions.sizeLimitBmp")}
|
||||
</p>
|
||||
{/if}
|
||||
<div
|
||||
bind:this={menu}
|
||||
id={menuId}
|
||||
class="menu"
|
||||
role="menu"
|
||||
popover="auto"
|
||||
ontoggle={(e) => (open = e.newState === "open")}
|
||||
>
|
||||
{#each OUTPUT_FORMATS as f (f.mime)}
|
||||
<button
|
||||
type="button"
|
||||
class="item"
|
||||
class:selected={f.mime === format}
|
||||
role="menuitemradio"
|
||||
aria-checked={f.mime === format}
|
||||
onclick={() => choose(f.mime)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
{/each}
|
||||
{#if qualitySpec && quality !== undefined}
|
||||
<div class="quality">
|
||||
<RangeControl
|
||||
label={t("fields.quality")}
|
||||
value={quality}
|
||||
spec={qualitySpec}
|
||||
onchange={(q) => onquality(q)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="limit">
|
||||
<label class="limit-field">
|
||||
<span>{t("actions.sizeLimitKb")}</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
placeholder="∞"
|
||||
value={limitKb ?? ""}
|
||||
oninput={handleLimit}
|
||||
/>
|
||||
</label>
|
||||
{#if limitKb !== undefined && format === "image/bmp"}
|
||||
<p class="limit-warn" role="status">
|
||||
{t("actions.sizeLimitBmp")}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.download-split {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
.main,
|
||||
@@ -171,19 +177,24 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--space-s));
|
||||
right: 0;
|
||||
z-index: var(--z-dropdown);
|
||||
/* Native popover: top layer + light dismiss + Escape из коробки;
|
||||
позиционирование — trackPopover (anchor() поддержан не везде). */
|
||||
position: fixed;
|
||||
margin: 0;
|
||||
translate: 0 var(--space-s);
|
||||
/* Ширина под строку качества: числовое поле + степперы + сброс. */
|
||||
min-width: calc(var(--space-xxxl) * 9);
|
||||
display: grid;
|
||||
gap: var(--space-s);
|
||||
padding: var(--space-m);
|
||||
border: var(--size-border) solid var(--color-border);
|
||||
border-radius: var(--radius-m);
|
||||
background: var(--color-panel);
|
||||
}
|
||||
/* display задаётся только в открытом состоянии: авторский display
|
||||
перебивает UA-правило [popover]:not(:popover-open) { display: none }. */
|
||||
.menu:popover-open {
|
||||
display: grid;
|
||||
}
|
||||
.item {
|
||||
padding: var(--space-m) var(--space-l);
|
||||
border: var(--size-border) solid transparent;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import { onDismiss } from "$lib/components/ui/dismiss";
|
||||
import { trackPopover } from "$lib/components/ui/popover-position";
|
||||
import { pageTitle } from "$lib/i18n/schema-tool-strings";
|
||||
import { chainablePages } from "$lib/pipeline.svelte";
|
||||
import type { Page } from "$lib/registry";
|
||||
@@ -15,31 +15,33 @@
|
||||
|
||||
const pages = chainablePages();
|
||||
|
||||
const menuId = $props.id();
|
||||
let open = $state(false);
|
||||
let root = $state<HTMLDivElement | null>(null);
|
||||
let btn = $state<HTMLButtonElement | null>(null);
|
||||
let menu = $state<HTMLDivElement | null>(null);
|
||||
|
||||
// While open, keep the popover placed next to the button (scroll/resize).
|
||||
$effect(() => {
|
||||
if (!open || !menu || !btn) return;
|
||||
return trackPopover(menu, btn, "end");
|
||||
});
|
||||
|
||||
function choose(page: Page) {
|
||||
onadd(page.steps[0].id);
|
||||
open = false;
|
||||
menu?.hidePopover();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
return onDismiss(
|
||||
() => root,
|
||||
() => (open = false),
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="tool-picker" bind:this={root}>
|
||||
<div class="tool-picker">
|
||||
<button
|
||||
bind:this={btn}
|
||||
type="button"
|
||||
class={iconOnly ? "icon-btn" : "add-btn"}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-controls={menuId}
|
||||
aria-label={iconOnly ? label : undefined}
|
||||
onclick={() => (open = !open)}
|
||||
popovertarget={menuId}
|
||||
>
|
||||
{#if iconOnly}
|
||||
<Icon icon={Repeat} size={14} />
|
||||
@@ -48,25 +50,29 @@
|
||||
<span>{label}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if open}
|
||||
<div class="menu" role="menu">
|
||||
{#each pages as page (page.slug)}
|
||||
<button
|
||||
type="button"
|
||||
class="item"
|
||||
role="menuitem"
|
||||
onclick={() => choose(page)}
|
||||
>
|
||||
{pageTitle(page)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
bind:this={menu}
|
||||
id={menuId}
|
||||
class="menu"
|
||||
role="menu"
|
||||
popover="auto"
|
||||
ontoggle={(e) => (open = e.newState === "open")}
|
||||
>
|
||||
{#each pages as page (page.slug)}
|
||||
<button
|
||||
type="button"
|
||||
class="item"
|
||||
role="menuitem"
|
||||
onclick={() => choose(page)}
|
||||
>
|
||||
{pageTitle(page)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tool-picker {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
.add-btn {
|
||||
@@ -99,20 +105,25 @@
|
||||
color: var(--color-text);
|
||||
}
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--space-s));
|
||||
right: 0;
|
||||
z-index: var(--z-dropdown);
|
||||
/* Native popover: top layer + light dismiss + Escape из коробки;
|
||||
позиционирование — trackPopover (anchor() поддержан не везде). */
|
||||
position: fixed;
|
||||
margin: 0;
|
||||
translate: 0 var(--space-s);
|
||||
min-width: calc(var(--space-xxxl) * 8);
|
||||
max-height: calc(var(--space-xxxl) * 8);
|
||||
overflow-y: auto;
|
||||
display: grid;
|
||||
gap: var(--space-s);
|
||||
padding: var(--space-m);
|
||||
border: var(--size-border) solid var(--color-border);
|
||||
border-radius: var(--radius-m);
|
||||
background: var(--color-panel);
|
||||
}
|
||||
/* display задаётся только в открытом состоянии: авторский display
|
||||
перебивает UA-правило [popover]:not(:popover-open) { display: none }. */
|
||||
.menu:popover-open {
|
||||
display: grid;
|
||||
}
|
||||
.item {
|
||||
padding: var(--space-m) var(--space-l);
|
||||
border: var(--size-border) solid transparent;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { t } from "$lib/i18n/t";
|
||||
import Badge from "./Badge.svelte";
|
||||
import { trackPopover } from "./popover-position";
|
||||
|
||||
interface Props {
|
||||
tone: "warning" | "danger" | "info";
|
||||
tone: "warning" | "danger";
|
||||
label: string;
|
||||
detail?: string;
|
||||
role?: "alert" | "status";
|
||||
@@ -11,19 +12,24 @@
|
||||
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);
|
||||
let btn = $state<HTMLButtonElement | null>(null);
|
||||
let popover = $state<HTMLSpanElement | null>(null);
|
||||
|
||||
// While open, keep the popover placed next to the "?" button.
|
||||
$effect(() => {
|
||||
if (!open || !popover || !btn) return;
|
||||
return trackPopover(popover, btn, "start");
|
||||
});
|
||||
</script>
|
||||
|
||||
<span class="detail-badge">
|
||||
<Badge {tone} {label} {role} />
|
||||
{#if detail}
|
||||
<button
|
||||
bind:this={btn}
|
||||
type="button"
|
||||
class="more"
|
||||
style:anchor-name={anchorName}
|
||||
aria-label={t("ui.detailsAria")}
|
||||
aria-expanded={open}
|
||||
aria-controls={id}
|
||||
@@ -32,12 +38,12 @@
|
||||
?
|
||||
</button>
|
||||
<span
|
||||
bind:this={popover}
|
||||
{id}
|
||||
class="popover"
|
||||
role="tooltip"
|
||||
popover="auto"
|
||||
style:position-anchor={anchorName}
|
||||
onbeforetoggle={(e) => (open = e.newState === "open")}
|
||||
ontoggle={(e) => (open = e.newState === "open")}
|
||||
>
|
||||
{detail}
|
||||
</span>
|
||||
@@ -73,16 +79,17 @@
|
||||
border-color: var(--color-main);
|
||||
}
|
||||
/* Нативный popover живёт в top layer: light dismiss, Escape и раскладка
|
||||
поверх всего — из коробки, без z-index и ручных подписок. */
|
||||
поверх всего — из коробки. Позиционирование — trackPopover: anchor()
|
||||
поддержан не во всех браузерах. */
|
||||
.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));
|
||||
max-width: min(
|
||||
calc(var(--space-xxxl) * 12),
|
||||
calc(100vw - 2 * 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);
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
// Popover dismiss: close on pointerdown outside the root and on Escape.
|
||||
// Subscribe inside `$effect` while the popover is open; returns unsubscribe.
|
||||
export function onDismiss(
|
||||
root: () => HTMLElement | null,
|
||||
close: () => void,
|
||||
): () => void {
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (!root()?.contains(e.target as Node)) close();
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") close();
|
||||
}
|
||||
window.addEventListener("pointerdown", onPointerDown);
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
window.removeEventListener("pointerdown", onPointerDown);
|
||||
window.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Position a top-layer popover next to its anchor button. The Popover API
|
||||
// itself does not place the element; CSS anchor positioning is not available
|
||||
// in every browser, so placement is computed from the anchor rect and kept
|
||||
// in sync with scroll/resize while the popover stays open.
|
||||
export function trackPopover(
|
||||
popover: HTMLElement,
|
||||
anchor: HTMLElement,
|
||||
align: "start" | "end" = "end",
|
||||
): () => void {
|
||||
const place = () => {
|
||||
const a = anchor.getBoundingClientRect();
|
||||
// The UA stylesheet gives popovers inset: 0; without resetting it the
|
||||
// over-constrained box (left: 0 vs our right) resolves to left: 0.
|
||||
popover.style.inset = "auto";
|
||||
popover.style.top = `${a.bottom}px`;
|
||||
if (align === "end" || a.left + popover.offsetWidth > window.innerWidth) {
|
||||
popover.style.right = `${window.innerWidth - a.right}px`;
|
||||
} else {
|
||||
popover.style.left = `${a.left}px`;
|
||||
}
|
||||
};
|
||||
place();
|
||||
window.addEventListener("scroll", place, true);
|
||||
window.addEventListener("resize", place);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", place, true);
|
||||
window.removeEventListener("resize", place);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user