Initial commit
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
import type { IR, IRNode } from "../normalize/ir.js";
|
||||
import { isTextChild } from "../normalize/ir.js";
|
||||
import type { MenuCapture } from "../capture/interactions.js";
|
||||
|
||||
/**
|
||||
* M4b — mount-on-open menus (Radix-style portals). The panel is added to the DOM on open
|
||||
* and removed on close, so the IR never sees it and the IR-based disclosure path can't
|
||||
* reproduce it. Instead the panel is captured as a self-contained inline-styled fragment
|
||||
* and reproduced CLIENT-SIDE: `DropdownMenu` renders null on mount and only injects the panel
|
||||
* (under its trigger) on interaction — so the server-rendered base that gates 0–6 grade is
|
||||
* structurally untouched (same safety model as DittoWire). A menu that doesn't reproduce is
|
||||
* pruned by the interaction gate, so a broken menu is never shipped.
|
||||
*/
|
||||
|
||||
const TRANSPARENT_GIF = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
|
||||
|
||||
export type RTMenu = { trigger: string; hoverOpen: boolean; gap: number; align: "left" | "right"; html: string };
|
||||
|
||||
function capToNode(ir: IR): Map<string, IRNode> {
|
||||
const m = new Map<string, IRNode>();
|
||||
const walk = (n: IRNode): void => {
|
||||
const cap = n.attrs["data-cid-cap"];
|
||||
if (cap !== undefined) m.set(cap, n);
|
||||
for (const c of n.children) if (!isTextChild(c)) walk(c);
|
||||
};
|
||||
walk(ir.root);
|
||||
return m;
|
||||
}
|
||||
|
||||
function textContent(n: IRNode, max = 120): string {
|
||||
let out = "";
|
||||
const walk = (node: IRNode): void => {
|
||||
if (out.length >= max) return;
|
||||
for (const c of node.children) {
|
||||
if (isTextChild(c)) out += " " + c.text;
|
||||
else walk(c);
|
||||
if (out.length >= max) return;
|
||||
}
|
||||
};
|
||||
walk(n);
|
||||
return out.replace(/\s+/g, " ").trim().slice(0, max);
|
||||
}
|
||||
|
||||
function isLikelySkipTrigger(n: IRNode, menu: MenuCapture): boolean {
|
||||
const label = `${textContent(n, 120)} ${n.attrs["aria-label"] ?? ""}`.toLowerCase();
|
||||
const href = (n.attrs.href ?? "").trim();
|
||||
if (/\bskip\s+(?:to\s+)?(?:content|main|navigation)\b/.test(label)) return true;
|
||||
return href.startsWith("#") && menu.gap > 360;
|
||||
}
|
||||
|
||||
/** Rewrite the captured panel HTML so the clone stays self-contained: same-origin links
|
||||
* become app-relative (via the page's linkRewrite), and every image src resolves to a
|
||||
* local asset or a transparent placeholder — never a remote origin (rubric Gate 2). */
|
||||
function rewriteHtml(html: string, assetMap: Map<string, string>, sourceUrl: string, linkRewrite?: (href: string) => string): string {
|
||||
const resolve = (u: string): string => { try { return new URL(u, sourceUrl).href; } catch { return u; } };
|
||||
let out = html.replace(/\bsrc="([^"]*)"/g, (_m, u: string) => {
|
||||
const local = assetMap.get(resolve(u));
|
||||
return `src="${local ?? TRANSPARENT_GIF}"`;
|
||||
});
|
||||
if (linkRewrite) out = out.replace(/\bhref="([^"]*)"/g, (_m, u: string) => `href="${linkRewrite(u).replace(/"/g, """)}"`);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function buildMenuSpecs(
|
||||
ir: IR,
|
||||
menus: MenuCapture[] | undefined,
|
||||
assetMap: Map<string, string>,
|
||||
sourceUrl: string,
|
||||
linkRewrite?: (href: string) => string,
|
||||
include?: (cid: string) => boolean,
|
||||
): RTMenu[] {
|
||||
if (!menus || !menus.length) return [];
|
||||
const map = capToNode(ir);
|
||||
const out: RTMenu[] = [];
|
||||
for (const m of menus) {
|
||||
const trigger = map.get(m.triggerCap);
|
||||
if (!trigger) continue; // trigger pruned from the IR → drop
|
||||
if (isLikelySkipTrigger(trigger, m)) continue;
|
||||
if (include && !include(trigger.id)) continue;
|
||||
out.push({ trigger: trigger.id, hoverOpen: m.hoverOpen, gap: m.gap, align: m.align, html: rewriteHtml(m.html, assetMap, sourceUrl, linkRewrite) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function menusJsx(menus: RTMenu[], indent: number): string {
|
||||
if (!menus.length) return "";
|
||||
const pad = " ".repeat(indent);
|
||||
return `${pad}<DropdownMenu menus={${JSON.stringify(menus)}} />`;
|
||||
}
|
||||
|
||||
export function dropdownMenuImportPath(depth: number): string {
|
||||
return (depth === 0 ? "./" : "../".repeat(depth)) + "ditto/DropdownMenu";
|
||||
}
|
||||
|
||||
/** The fixed DropdownMenu client component, written once per generated app. */
|
||||
export const DROPDOWN_MENU_TSX = `"use client";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
type RTMenu = { trigger: string; hoverOpen: boolean; gap: number; align: "left" | "right"; html: string };
|
||||
const byCid = (cid: string): HTMLElement | null => document.querySelector('[data-cid="' + cid + '"]');
|
||||
|
||||
/** Reproduces mount-on-open dropdown/nav menus: renders nothing and applies NOTHING on mount; only on
|
||||
* user interaction does it inject the captured panel fragment under its trigger. The base
|
||||
* render is therefore unchanged. */
|
||||
export default function DropdownMenu({ menus }: { menus: RTMenu[] }) {
|
||||
const wired = useRef(false);
|
||||
useEffect(() => {
|
||||
if (wired.current) return;
|
||||
wired.current = true;
|
||||
for (const m of menus) {
|
||||
const trig = byCid(m.trigger);
|
||||
if (!trig) continue;
|
||||
let panel: HTMLElement | null = null;
|
||||
const place = () => {
|
||||
if (!panel) return;
|
||||
const r = trig.getBoundingClientRect();
|
||||
panel.style.position = "absolute";
|
||||
panel.style.top = (r.bottom + window.scrollY + m.gap) + "px";
|
||||
if (m.align === "right") { panel.style.left = ""; panel.style.right = (document.documentElement.clientWidth - (r.right + window.scrollX)) + "px"; }
|
||||
else { panel.style.right = ""; panel.style.left = (r.left + window.scrollX) + "px"; }
|
||||
panel.style.zIndex = "9999";
|
||||
};
|
||||
const open = () => {
|
||||
if (panel) return;
|
||||
const wrap = document.createElement("div");
|
||||
wrap.innerHTML = m.html;
|
||||
panel = wrap.firstElementChild as HTMLElement | null;
|
||||
if (!panel) return;
|
||||
document.body.appendChild(panel);
|
||||
place();
|
||||
trig.setAttribute("aria-expanded", "true");
|
||||
};
|
||||
const close = () => { if (panel) { panel.remove(); panel = null; } trig.setAttribute("aria-expanded", "false"); };
|
||||
const toggle = () => (panel ? close() : open());
|
||||
if (m.hoverOpen) {
|
||||
const root = trig.parentElement ?? trig;
|
||||
root.addEventListener("mouseenter", open);
|
||||
root.addEventListener("mouseleave", close);
|
||||
} else {
|
||||
trig.addEventListener("click", (e) => { e.preventDefault(); toggle(); });
|
||||
}
|
||||
document.addEventListener("keydown", (e) => { if (e.key === "Escape") close(); });
|
||||
document.addEventListener("click", (e) => {
|
||||
const t = e.target as Node;
|
||||
if (panel && !trig.contains(t) && !panel.contains(t)) close();
|
||||
});
|
||||
window.addEventListener("resize", place);
|
||||
window.addEventListener("scroll", place, { passive: true });
|
||||
}
|
||||
(window as any).__dittoMenuReady = true; // wiring done — lets the gate drive deterministically
|
||||
}, [menus]);
|
||||
return null;
|
||||
}
|
||||
`;
|
||||
Reference in New Issue
Block a user