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,19 +80,27 @@
|
|||||||
<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}
|
||||||
|
id={menuId}
|
||||||
|
class="menu"
|
||||||
|
role="menu"
|
||||||
|
popover="auto"
|
||||||
|
ontoggle={(e) => (open = e.newState === "open")}
|
||||||
|
>
|
||||||
{#each OUTPUT_FORMATS as f (f.mime)}
|
{#each OUTPUT_FORMATS as f (f.mime)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -134,12 +142,10 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
</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,8 +50,14 @@
|
|||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{#if open}
|
<div
|
||||||
<div class="menu" role="menu">
|
bind:this={menu}
|
||||||
|
id={menuId}
|
||||||
|
class="menu"
|
||||||
|
role="menu"
|
||||||
|
popover="auto"
|
||||||
|
ontoggle={(e) => (open = e.newState === "open")}
|
||||||
|
>
|
||||||
{#each pages as page (page.slug)}
|
{#each pages as page (page.slug)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -61,12 +69,10 @@
|
|||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
</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