fix: move to popover realisation

This commit is contained in:
2026-10-11 17:43:12 +05:00
parent 2f4d52a9e0
commit 6166960c4b
5 changed files with 164 additions and 125 deletions
@@ -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;
+19 -12
View File
@@ -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);
-19
View File
@@ -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);
};
}