mirror of
https://github.com/ruvnet/RuView
synced 2026-07-20 17:03:24 +00:00
7f5a692632
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>
154 lines
5.3 KiB
TypeScript
154 lines
5.3 KiB
TypeScript
/* 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 }));
|
||
}
|