fix(bias-annotations): backdrop no longer trapped inside node canvas transform

The bias annotation editor rendered its dimming backdrop as a plain
position:fixed div. Since it sits inside a node card that rete.js
positions with a CSS transform for pan/zoom, that transform becomes
the containing block for fixed descendants, so the backdrop was
confined to the node's own box instead of covering the page.

Switch to a native <dialog> shown via showModal(), which promotes it
to the browser's top layer regardless of ancestor transforms, and use
::backdrop for the dimming overlay. showModal() is guarded because
jsdom does not implement it, so unit tests keep working.

Confirmed both by unit tests and a live browser check: the backdrop's
bounding rect now spans the full viewport instead of the node's box.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-07-21 14:56:43 +02:00
parent af246dab65
commit 897f57808e
4 changed files with 66 additions and 4 deletions

View File

@ -19,7 +19,23 @@ button:disabled { cursor: not-allowed; opacity: .5; }
.bias-actions { justify-content: flex-end; }
.bias-actions .danger { color: #b91c1c; }
.bias-error, label.invalid em { margin-top: 5px; color: #b91c1c; font-size: 11px; font-style: normal; }
.bias-modal-backdrop { position: fixed; inset: 0; z-index: 10020; display: grid; place-items: center; background: rgb(15 23 42 / 55%); }
.bias-modal-backdrop {
position: fixed;
inset: 0;
z-index: 10020;
display: grid;
place-items: center;
width: 100vw;
height: 100vh;
max-width: 100vw;
max-height: 100vh;
margin: 0;
padding: 0;
border: none;
background: transparent;
color: inherit;
}
.bias-modal-backdrop::backdrop { background: rgb(15 23 42 / 55%); }
.bias-modal { width: min(620px, 92vw); max-height: 86vh; overflow: auto; border-radius: 14px; background: white; padding: 20px; box-shadow: 0 20px 60px rgb(0 0 0 / 25%); }
.bias-modal h3 { margin: 0 0 14px; font-size: 18px; }
.bias-form { display: grid; gap: 12px; }

View File

@ -42,7 +42,7 @@
</section>
@if (editorOpen) {
<div class="bias-modal-backdrop" (pointerdown)="$event.stopPropagation()" (click)="close($event)">
<dialog #biasModalDialog class="bias-modal-backdrop" (pointerdown)="$event.stopPropagation()" (click)="onDialogClick($event)" (cancel)="close($event)">
<form class="bias-modal" (pointerdown)="$event.stopPropagation()" (click)="$event.stopPropagation()" (submit)="save($event)">
<h3>{{ editingIndex === null ? 'Add' : 'Edit' }} bias annotation</h3>
<div class="bias-form">
@ -75,6 +75,6 @@
<button type="submit" class="primary">Save</button>
</div>
</form>
</div>
</dialog>
}
}

View File

@ -110,6 +110,26 @@ describe('BiasAnnotationsComponent', () => {
expect(component.serverError(1, 'category')).toBeNull();
});
it('renders the editor as a native <dialog> so it escapes the node canvas transform, and closing it via the backdrop click works', () => {
(fixture.nativeElement.querySelector('.bias-add') as HTMLButtonElement).click();
fixture.detectChanges();
const dialog = fixture.nativeElement.querySelector('dialog.bias-modal-backdrop') as HTMLDialogElement;
expect(dialog).not.toBeNull();
component.onDialogClick({ target: dialog, stopPropagation: vi.fn() } as unknown as MouseEvent);
expect(component.editorOpen).toBe(false);
});
it('does not close when clicking inside the dialog content', () => {
(fixture.nativeElement.querySelector('.bias-add') as HTMLButtonElement).click();
fixture.detectChanges();
const form = fixture.nativeElement.querySelector('.bias-modal') as HTMLElement;
component.onDialogClick({ target: form, stopPropagation: vi.fn() } as unknown as MouseEvent);
expect(component.editorOpen).toBe(true);
});
it('maps typed mock-output probe errors to nested fields', () => {
validationErrors.set([{
code: 'BIAS_PROBE_MOCK_OUTPUT_TYPE_MISMATCH',

View File

@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, inject } from '@angular/core';
import { ChangeDetectionStrategy, Component, ElementRef, EventEmitter, Input, Output, effect, inject, viewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import {
BiasAnnotation,
@ -55,6 +55,32 @@ export class BiasAnnotationsComponent {
draft: BiasAnnotation = {};
clientErrors: Record<string, string> = {};
/**
* The editor renders inside a node card, which rete.js positions with a CSS
* `transform` for pan/zoom. A `transform` on any ancestor turns it into the
* containing block for `position: fixed` descendants, so a plain fixed-position
* backdrop would be confined to the node's box instead of covering the page.
* A native `<dialog>` opened via `showModal()` is promoted to the browser's
* top layer, which sits above the whole document regardless of ancestor
* transforms — no manual DOM reparenting needed.
*/
private readonly modalDialog = viewChild<ElementRef<HTMLDialogElement>>('biasModalDialog');
constructor() {
effect(() => {
const dialog = this.modalDialog()?.nativeElement;
if (dialog && typeof dialog.showModal === 'function' && !dialog.open) {
dialog.showModal();
}
});
}
onDialogClick(event: MouseEvent) {
if (event.target === this.modalDialog()?.nativeElement) {
this.close(event);
}
}
get descriptor(): BiasAnnotationsDescriptor | null {
const descriptorSignal = (this.blocks as BlocksService & {
biasAnnotationsDescriptor?: () => BiasAnnotationsDescriptor | null