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">
|
<script lang="ts">
|
||||||
import RangeControl from "$lib/components/fields/schema/RangeControl.svelte";
|
import RangeControl from "$lib/components/fields/schema/RangeControl.svelte";
|
||||||
import Icon from "$lib/components/ui/Icon.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 type { OutputMime } from "$lib/core/io";
|
||||||
import { OUTPUT_FORMATS, outputFormatByMime } from "$lib/output-formats";
|
import { OUTPUT_FORMATS, outputFormatByMime } from "$lib/output-formats";
|
||||||
import type { SliderSpec } from "$lib/registry-schema";
|
import type { SliderSpec } from "$lib/registry-schema";
|
||||||
@@ -43,8 +43,16 @@
|
|||||||
: undefined,
|
: undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const menuId = $props.id();
|
||||||
let open = $state(false);
|
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) {
|
function choose(mime: OutputMime) {
|
||||||
// Menu doubles as a format-settings panel: keep it open on select
|
// Menu doubles as a format-settings panel: keep it open on select
|
||||||
@@ -56,23 +64,15 @@
|
|||||||
const raw = (e.target as HTMLInputElement).valueAsNumber;
|
const raw = (e.target as HTMLInputElement).valueAsNumber;
|
||||||
onlimit(Number.isFinite(raw) && raw >= 1 ? Math.round(raw) : undefined);
|
onlimit(Number.isFinite(raw) && raw >= 1 ? Math.round(raw) : undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
return onDismiss(
|
|
||||||
() => root,
|
|
||||||
() => (open = false),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="download-split" bind:this={root}>
|
<div class="download-split">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="main"
|
class="main"
|
||||||
disabled={running}
|
disabled={running}
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
open = false;
|
menu?.hidePopover();
|
||||||
ondownload();
|
ondownload();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -80,66 +80,72 @@
|
|||||||
<span>{t("actions.download")}</span>
|
<span>{t("actions.download")}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
bind:this={btn}
|
||||||
type="button"
|
type="button"
|
||||||
class="toggle"
|
class="toggle"
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
|
aria-controls={menuId}
|
||||||
aria-label={t("actions.formatAria")}
|
aria-label={t("actions.formatAria")}
|
||||||
disabled={running}
|
disabled={running}
|
||||||
onclick={() => (open = !open)}
|
popovertarget={menuId}
|
||||||
>
|
>
|
||||||
<span>{current.label}</span>
|
<span>{current.label}</span>
|
||||||
<Icon icon={ChevronDown} size={14} />
|
<Icon icon={ChevronDown} size={14} />
|
||||||
</button>
|
</button>
|
||||||
{#if open}
|
<div
|
||||||
<div class="menu" role="menu">
|
bind:this={menu}
|
||||||
{#each OUTPUT_FORMATS as f (f.mime)}
|
id={menuId}
|
||||||
<button
|
class="menu"
|
||||||
type="button"
|
role="menu"
|
||||||
class="item"
|
popover="auto"
|
||||||
class:selected={f.mime === format}
|
ontoggle={(e) => (open = e.newState === "open")}
|
||||||
role="menuitemradio"
|
>
|
||||||
aria-checked={f.mime === format}
|
{#each OUTPUT_FORMATS as f (f.mime)}
|
||||||
onclick={() => choose(f.mime)}
|
<button
|
||||||
>
|
type="button"
|
||||||
{f.label}
|
class="item"
|
||||||
</button>
|
class:selected={f.mime === format}
|
||||||
{/each}
|
role="menuitemradio"
|
||||||
{#if qualitySpec && quality !== undefined}
|
aria-checked={f.mime === format}
|
||||||
<div class="quality">
|
onclick={() => choose(f.mime)}
|
||||||
<RangeControl
|
>
|
||||||
label={t("fields.quality")}
|
{f.label}
|
||||||
value={quality}
|
</button>
|
||||||
spec={qualitySpec}
|
{/each}
|
||||||
onchange={(q) => onquality(q)}
|
{#if qualitySpec && quality !== undefined}
|
||||||
/>
|
<div class="quality">
|
||||||
</div>
|
<RangeControl
|
||||||
{/if}
|
label={t("fields.quality")}
|
||||||
<div class="limit">
|
value={quality}
|
||||||
<label class="limit-field">
|
spec={qualitySpec}
|
||||||
<span>{t("actions.sizeLimitKb")}</span>
|
onchange={(q) => onquality(q)}
|
||||||
<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>
|
</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>
|
</div>
|
||||||
{/if}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.download-split {
|
.download-split {
|
||||||
position: relative;
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
.main,
|
.main,
|
||||||
@@ -171,19 +177,24 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
.menu {
|
.menu {
|
||||||
position: absolute;
|
/* Native popover: top layer + light dismiss + Escape из коробки;
|
||||||
top: calc(100% + var(--space-s));
|
позиционирование — trackPopover (anchor() поддержан не везде). */
|
||||||
right: 0;
|
position: fixed;
|
||||||
z-index: var(--z-dropdown);
|
margin: 0;
|
||||||
|
translate: 0 var(--space-s);
|
||||||
/* Ширина под строку качества: числовое поле + степперы + сброс. */
|
/* Ширина под строку качества: числовое поле + степперы + сброс. */
|
||||||
min-width: calc(var(--space-xxxl) * 9);
|
min-width: calc(var(--space-xxxl) * 9);
|
||||||
display: grid;
|
|
||||||
gap: var(--space-s);
|
gap: var(--space-s);
|
||||||
padding: var(--space-m);
|
padding: var(--space-m);
|
||||||
border: var(--size-border) solid var(--color-border);
|
border: var(--size-border) solid var(--color-border);
|
||||||
border-radius: var(--radius-m);
|
border-radius: var(--radius-m);
|
||||||
background: var(--color-panel);
|
background: var(--color-panel);
|
||||||
}
|
}
|
||||||
|
/* display задаётся только в открытом состоянии: авторский display
|
||||||
|
перебивает UA-правило [popover]:not(:popover-open) { display: none }. */
|
||||||
|
.menu:popover-open {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
.item {
|
.item {
|
||||||
padding: var(--space-m) var(--space-l);
|
padding: var(--space-m) var(--space-l);
|
||||||
border: var(--size-border) solid transparent;
|
border: var(--size-border) solid transparent;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from "$lib/components/ui/Icon.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 { pageTitle } from "$lib/i18n/schema-tool-strings";
|
import { pageTitle } from "$lib/i18n/schema-tool-strings";
|
||||||
import { chainablePages } from "$lib/pipeline.svelte";
|
import { chainablePages } from "$lib/pipeline.svelte";
|
||||||
import type { Page } from "$lib/registry";
|
import type { Page } from "$lib/registry";
|
||||||
@@ -15,31 +15,33 @@
|
|||||||
|
|
||||||
const pages = chainablePages();
|
const pages = chainablePages();
|
||||||
|
|
||||||
|
const menuId = $props.id();
|
||||||
let open = $state(false);
|
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) {
|
function choose(page: Page) {
|
||||||
onadd(page.steps[0].id);
|
onadd(page.steps[0].id);
|
||||||
open = false;
|
menu?.hidePopover();
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
return onDismiss(
|
|
||||||
() => root,
|
|
||||||
() => (open = false),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="tool-picker" bind:this={root}>
|
<div class="tool-picker">
|
||||||
<button
|
<button
|
||||||
|
bind:this={btn}
|
||||||
type="button"
|
type="button"
|
||||||
class={iconOnly ? "icon-btn" : "add-btn"}
|
class={iconOnly ? "icon-btn" : "add-btn"}
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
|
aria-controls={menuId}
|
||||||
aria-label={iconOnly ? label : undefined}
|
aria-label={iconOnly ? label : undefined}
|
||||||
onclick={() => (open = !open)}
|
popovertarget={menuId}
|
||||||
>
|
>
|
||||||
{#if iconOnly}
|
{#if iconOnly}
|
||||||
<Icon icon={Repeat} size={14} />
|
<Icon icon={Repeat} size={14} />
|
||||||
@@ -48,25 +50,29 @@
|
|||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{#if open}
|
<div
|
||||||
<div class="menu" role="menu">
|
bind:this={menu}
|
||||||
{#each pages as page (page.slug)}
|
id={menuId}
|
||||||
<button
|
class="menu"
|
||||||
type="button"
|
role="menu"
|
||||||
class="item"
|
popover="auto"
|
||||||
role="menuitem"
|
ontoggle={(e) => (open = e.newState === "open")}
|
||||||
onclick={() => choose(page)}
|
>
|
||||||
>
|
{#each pages as page (page.slug)}
|
||||||
{pageTitle(page)}
|
<button
|
||||||
</button>
|
type="button"
|
||||||
{/each}
|
class="item"
|
||||||
</div>
|
role="menuitem"
|
||||||
{/if}
|
onclick={() => choose(page)}
|
||||||
|
>
|
||||||
|
{pageTitle(page)}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.tool-picker {
|
.tool-picker {
|
||||||
position: relative;
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
.add-btn {
|
.add-btn {
|
||||||
@@ -99,20 +105,25 @@
|
|||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
.menu {
|
.menu {
|
||||||
position: absolute;
|
/* Native popover: top layer + light dismiss + Escape из коробки;
|
||||||
top: calc(100% + var(--space-s));
|
позиционирование — trackPopover (anchor() поддержан не везде). */
|
||||||
right: 0;
|
position: fixed;
|
||||||
z-index: var(--z-dropdown);
|
margin: 0;
|
||||||
|
translate: 0 var(--space-s);
|
||||||
min-width: calc(var(--space-xxxl) * 8);
|
min-width: calc(var(--space-xxxl) * 8);
|
||||||
max-height: calc(var(--space-xxxl) * 8);
|
max-height: calc(var(--space-xxxl) * 8);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
display: grid;
|
|
||||||
gap: var(--space-s);
|
gap: var(--space-s);
|
||||||
padding: var(--space-m);
|
padding: var(--space-m);
|
||||||
border: var(--size-border) solid var(--color-border);
|
border: var(--size-border) solid var(--color-border);
|
||||||
border-radius: var(--radius-m);
|
border-radius: var(--radius-m);
|
||||||
background: var(--color-panel);
|
background: var(--color-panel);
|
||||||
}
|
}
|
||||||
|
/* display задаётся только в открытом состоянии: авторский display
|
||||||
|
перебивает UA-правило [popover]:not(:popover-open) { display: none }. */
|
||||||
|
.menu:popover-open {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
.item {
|
.item {
|
||||||
padding: var(--space-m) var(--space-l);
|
padding: var(--space-m) var(--space-l);
|
||||||
border: var(--size-border) solid transparent;
|
border: var(--size-border) solid transparent;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { t } from "$lib/i18n/t";
|
import { t } from "$lib/i18n/t";
|
||||||
import Badge from "./Badge.svelte";
|
import Badge from "./Badge.svelte";
|
||||||
|
import { trackPopover } from "./popover-position";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
tone: "warning" | "danger" | "info";
|
tone: "warning" | "danger";
|
||||||
label: string;
|
label: string;
|
||||||
detail?: string;
|
detail?: string;
|
||||||
role?: "alert" | "status";
|
role?: "alert" | "status";
|
||||||
@@ -11,19 +12,24 @@
|
|||||||
let { tone, label, detail = undefined, role = "status" }: Props = $props();
|
let { tone, label, detail = undefined, role = "status" }: Props = $props();
|
||||||
|
|
||||||
const id = $props.id();
|
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 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>
|
</script>
|
||||||
|
|
||||||
<span class="detail-badge">
|
<span class="detail-badge">
|
||||||
<Badge {tone} {label} {role} />
|
<Badge {tone} {label} {role} />
|
||||||
{#if detail}
|
{#if detail}
|
||||||
<button
|
<button
|
||||||
|
bind:this={btn}
|
||||||
type="button"
|
type="button"
|
||||||
class="more"
|
class="more"
|
||||||
style:anchor-name={anchorName}
|
|
||||||
aria-label={t("ui.detailsAria")}
|
aria-label={t("ui.detailsAria")}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-controls={id}
|
aria-controls={id}
|
||||||
@@ -32,12 +38,12 @@
|
|||||||
?
|
?
|
||||||
</button>
|
</button>
|
||||||
<span
|
<span
|
||||||
|
bind:this={popover}
|
||||||
{id}
|
{id}
|
||||||
class="popover"
|
class="popover"
|
||||||
role="tooltip"
|
role="tooltip"
|
||||||
popover="auto"
|
popover="auto"
|
||||||
style:position-anchor={anchorName}
|
ontoggle={(e) => (open = e.newState === "open")}
|
||||||
onbeforetoggle={(e) => (open = e.newState === "open")}
|
|
||||||
>
|
>
|
||||||
{detail}
|
{detail}
|
||||||
</span>
|
</span>
|
||||||
@@ -73,16 +79,17 @@
|
|||||||
border-color: var(--color-main);
|
border-color: var(--color-main);
|
||||||
}
|
}
|
||||||
/* Нативный popover живёт в top layer: light dismiss, Escape и раскладка
|
/* Нативный popover живёт в top layer: light dismiss, Escape и раскладка
|
||||||
поверх всего — из коробки, без z-index и ручных подписок. */
|
поверх всего — из коробки. Позиционирование — trackPopover: anchor()
|
||||||
|
поддержан не во всех браузерах. */
|
||||||
.popover {
|
.popover {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
top: anchor(bottom);
|
|
||||||
left: anchor(left);
|
|
||||||
translate: 0 var(--space-s);
|
translate: 0 var(--space-s);
|
||||||
/* Не уезжает за правый край: доступная ширина считается от якоря. */
|
|
||||||
width: max-content;
|
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);
|
padding: var(--space-m) var(--space-l);
|
||||||
border: var(--size-border-thick) solid var(--color-text-muted);
|
border: var(--size-border-thick) solid var(--color-text-muted);
|
||||||
border-radius: var(--radius-m);
|
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