Files
ruvnet--RuView/dashboard/src/components/nv-modal.ts
T
rUv 7f5a692632 feat(nvsim): full simulator stack — Rust crate, dashboard, server, App Store, Ghost Murmur [ADR-089/090/091/092/093]
Squashed merge of feat/nvsim-pipeline-simulator (29 commits).

## Shipped

- ADR-089 nvsim crate (Accepted) — 50/50 tests, ~4.5 M samples/s, pinned witness cc8de9b01b0ff5bd…
- ADR-092 dashboard implementation (Implemented) — 8/12 §11 gates , 4/12 ⚠ (external infra)
- ADR-093 dashboard gap analysis (Implemented) — 21/21 catalogued gaps closed
- Plus ADR-090 (proposed conditional) and ADR-091 (proposed research-only)

## Live deploy
https://ruvnet.github.io/RuView/nvsim/

## Infra

- nvsim-server Dockerfile + GHCR publish workflow (.github/workflows/nvsim-server-docker.yml)
- axe-core + Playwright cross-browser CI (.github/workflows/dashboard-a11y.yml)
- gh-pages auto-deploy workflow already in place (preserves observatory + pose-fusion siblings)

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-04-27 12:41:01 -04:00

154 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Modal dialog — opened via window.dispatchEvent('nv-modal', { title, body, buttons }). */
import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';
interface ModalButton {
label: string;
variant?: 'ghost' | 'primary' | 'danger';
onClick?: () => void;
}
interface ModalReq {
title: string;
body: string;
buttons?: ModalButton[];
}
@customElement('nv-modal')
export class NvModal extends LitElement {
@state() private open = false;
@state() private mTitle = '';
@state() private mBody = '';
@state() private buttons: ModalButton[] = [];
static styles = css`
:host {
position: fixed; inset: 0;
background: rgba(0,0,0,0.55);
backdrop-filter: blur(4px);
z-index: 200;
display: grid; place-items: center;
opacity: 0; pointer-events: none;
transition: opacity 0.18s;
}
:host([open]) { opacity: 1; pointer-events: auto; }
.modal {
background: var(--bg-1);
border: 1px solid var(--line-2);
border-radius: var(--radius);
box-shadow: 0 30px 80px -20px rgba(0,0,0,0.7);
width: min(520px, 92vw);
max-height: 86vh;
display: flex; flex-direction: column;
transform: translateY(12px) scale(0.98);
transition: transform 0.22s cubic-bezier(0.2,0.7,0.3,1);
}
:host([open]) .modal { transform: translateY(0) scale(1); }
.h {
padding: 14px 16px;
border-bottom: 1px solid var(--line);
display: flex; align-items: center; justify-content: space-between;
}
.h .ttl { font-size: 14px; font-weight: 600; }
.body { padding: 16px; overflow-y: auto; font-size: 13px; color: var(--ink-2); line-height: 1.55; }
.f {
padding: 12px 16px;
border-top: 1px solid var(--line);
display: flex; gap: 8px; justify-content: flex-end;
}
button {
padding: 6px 12px;
border-radius: 8px;
font-size: 12.5px;
cursor: pointer;
font-family: inherit;
border: 1px solid var(--line);
background: var(--bg-2); color: var(--ink);
}
button.ghost { background: transparent; }
button.primary { background: var(--accent); border-color: var(--accent); color: #1a0f00; }
button.danger { background: var(--bad); border-color: var(--bad); color: #fff; }
.close {
width: 28px; height: 28px;
background: transparent; border: 1px solid var(--line);
border-radius: 6px;
color: var(--ink-2);
}
`;
override connectedCallback(): void {
super.connectedCallback();
window.addEventListener('nv-modal', this.onModal as EventListener);
window.addEventListener('keydown', this.onKey);
}
override disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener('nv-modal', this.onModal as EventListener);
window.removeEventListener('keydown', this.onKey);
}
private onModal = (e: Event): void => {
const r = (e as CustomEvent).detail as ModalReq;
this.mTitle = r.title; this.mBody = r.body;
this.buttons = r.buttons ?? [{ label: 'Close', variant: 'primary' }];
this.open = true; this.setAttribute('open', '');
// a11y: focus the first interactive element inside the modal so keyboard
// users land in the dialog rather than behind it. Light focus trap via
// the keydown handler below catches Tab cycling.
requestAnimationFrame(() => {
const root = this.shadowRoot;
if (!root) return;
const first = root.querySelector<HTMLElement>('input, select, textarea, button:not(.close)');
first?.focus();
});
};
override updated(): void {
if (!this.open) return;
const root = this.shadowRoot;
if (!root) return;
// Trap Tab inside the modal while open.
const trap = (e: KeyboardEvent): void => {
if (e.key !== 'Tab') return;
const focusables = Array.from(
root.querySelectorAll<HTMLElement>('input, select, textarea, button, [href]'),
).filter((el) => !el.hasAttribute('disabled'));
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
const active = (root.activeElement as HTMLElement | null) ?? null;
if (e.shiftKey && active === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && active === last) { e.preventDefault(); first.focus(); }
};
root.removeEventListener('keydown', trap as EventListener);
root.addEventListener('keydown', trap as EventListener);
}
private onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape' && this.open) this.close();
};
private close(): void { this.open = false; this.removeAttribute('open'); }
private clickBtn(b: ModalButton): void { b.onClick?.(); this.close(); }
override render() {
return html`
<div class="modal" role="dialog" aria-modal="true">
<div class="h">
<div class="ttl">${this.mTitle}</div>
<button class="close" @click=${() => this.close()}>×</button>
</div>
<div class="body" .innerHTML=${this.mBody}></div>
<div class="f">
${this.buttons.map((b) => html`
<button class=${b.variant ?? ''} @click=${() => this.clickBtn(b)}>${b.label}</button>
`)}
</div>
</div>
`;
}
}
export function openModal(req: ModalReq): void {
window.dispatchEvent(new CustomEvent('nv-modal', { detail: req }));
}