added nested navmenus

This commit is contained in:
boris committed 2026-02-26 01:13:22 +00:00
1 parent a73c5ea002
commit 18734acaad
4 files changed
+525 -85

No files matched your search

+206 -37
View File
@@ -107,6 +107,8 @@ type TopbarMenuEntry = {
id: string;
type: "link" | "action" | "divider" | "label" | "submenu";
label?: string;
datePrefix?: string;
sourceColor?: string;
href?: string;
isExternal?: boolean;
actionId?: string;
@@ -161,6 +163,37 @@ function getElementPrimaryText(ele: Element): string {
return cleanTopbarLabel(ele.textContent || "");
}
function getElementMenuMeta(ele: Element | null): Pick<TopbarMenuEntry, "datePrefix" | "sourceColor"> {
if (!ele) {
return {};
}
const out: Pick<TopbarMenuEntry, "datePrefix" | "sourceColor"> = {};
const dateEle = ele.querySelector(":scope > .page__nav-date");
if (dateEle) {
const datePrefix = cleanTopbarLabel(dateEle.textContent || "");
if (datePrefix) {
out.datePrefix = datePrefix;
}
}
const sourceEle = ele.querySelector(":scope > .nav2-list__disp-source");
if (sourceEle instanceof HTMLElement) {
const computed = getComputedStyle(sourceEle);
const sourceColor = (computed.backgroundColor || computed.borderLeftColor || "").trim();
if (
sourceColor
&& sourceColor !== "transparent"
&& !/^rgba?\(\s*0\s*,\s*0\s*,\s*0(?:\s*,\s*0)?\s*\)$/i.test(sourceColor)
) {
out.sourceColor = sourceColor;
}
}
return out;
}
function getPathnameFromHrefLoose(hrefRaw: string): string {
const href = hrefRaw.trim();
if (!href) {
@@ -178,7 +211,7 @@ function areTopbarGroupsEquivalent(a: TopbarMenuGroup[], b: TopbarMenuGroup[]):
}
function parseNativeTopbarMenuList(
list: HTMLUListElement,
list: Element,
registerAction: (ele: HTMLElement) => string,
idPrefix: string
): TopbarMenuEntry[] {
@@ -187,6 +220,11 @@ function parseNativeTopbarMenuList(
liElements.forEach((li, index) => {
const entryId = `${idPrefix}-${index}`;
const submenuList = (
li.querySelector(":scope > ul")
|| (li.classList.contains("nav2-accord__wrp") ? li.querySelector(":scope > .nav2-accord__body") : null)
) as Element | null;
const submenuChildren = submenuList ? parseNativeTopbarMenuList(submenuList, registerAction, `${entryId}-submenu`) : [];
if (li.classList.contains("ve-dropdown-divider") || li.classList.contains("divider")) {
entries.push({ id: `${entryId}-divider`, type: "divider" });
return;
@@ -202,7 +240,6 @@ function parseNativeTopbarMenuList(
if (li.classList.contains("dropdown--navbar")) {
const toggle = li.querySelector(":scope > a") as HTMLAnchorElement | null;
const submenuList = li.querySelector(":scope > ul") as HTMLUListElement | null;
const label = toggle ? getElementPrimaryText(toggle) : "";
if (!label) {
return;
@@ -211,7 +248,24 @@ function parseNativeTopbarMenuList(
id: `${entryId}-submenu`,
type: "submenu",
label,
children: submenuList ? parseNativeTopbarMenuList(submenuList, registerAction, `${entryId}-submenu`) : []
...getElementMenuMeta(toggle),
children: submenuChildren
});
return;
}
if (li.classList.contains("nav2-accord__wrp")) {
const headPrimary = li.querySelector(":scope > .nav2-accord__head > :first-child");
const label = headPrimary ? getElementPrimaryText(headPrimary) : "";
if (!label || !submenuChildren.length) {
return;
}
entries.push({
id: `${entryId}-accordion`,
type: "submenu",
label,
...getElementMenuMeta(headPrimary),
children: submenuChildren
});
return;
}
@@ -221,11 +275,25 @@ function parseNativeTopbarMenuList(
const label = getElementPrimaryText(link);
const href = (link.getAttribute("href") || "").trim();
const isActionLike = href === "#" || href.toLowerCase().startsWith("javascript:");
if (submenuChildren.length) {
entries.push({
id: `${entryId}-submenu-link`,
type: "submenu",
label: label || "Section",
...getElementMenuMeta(link),
href: !isActionLike ? href : undefined,
actionId: isActionLike ? registerAction(link) : undefined,
isExternal: !isActionLike ? link.target === "_blank" || /^https?:\/\//i.test(href) : false,
children: submenuChildren
});
return;
}
if (isActionLike) {
entries.push({
id: `${entryId}-action`,
type: "action",
label: label || "Action",
...getElementMenuMeta(link),
actionId: registerAction(link)
});
return;
@@ -235,6 +303,7 @@ function parseNativeTopbarMenuList(
id: `${entryId}-link`,
type: "link",
label: label || href,
...getElementMenuMeta(link),
href,
isExternal: link.target === "_blank" || /^https?:\/\//i.test(href)
});
@@ -242,12 +311,26 @@ function parseNativeTopbarMenuList(
}
const spans = Array.from(li.querySelectorAll(":scope > span")).filter((it): it is HTMLSpanElement => it instanceof HTMLSpanElement);
if (submenuChildren.length && spans.length) {
const first = spans[0];
const label = cleanTopbarLabel(first.textContent || first.title || "");
entries.push({
id: `${entryId}-submenu-span`,
type: "submenu",
label: label || "Section",
...getElementMenuMeta(first),
actionId: registerAction(first),
children: submenuChildren
});
return;
}
spans.forEach((span, spanIndex) => {
const label = cleanTopbarLabel(span.textContent || span.title || "");
entries.push({
id: `${entryId}-span-${spanIndex}`,
type: "action",
label: label || "Action",
...getElementMenuMeta(span),
actionId: registerAction(span)
});
});
@@ -1913,6 +1996,7 @@ function WikiView() {
const [currentPath, setCurrentPath] = useState<string>(DEFAULT_PAGE);
const [isRenderRuntimeReady, setIsRenderRuntimeReady] = useState(false);
const [openTopMenuId, setOpenTopMenuId] = useState<string | null>(null);
const [openTopMenuPath, setOpenTopMenuPath] = useState<string[]>([]);
const [topbarSearch, setTopbarSearch] = useState("");
const [topbarGroups, setTopbarGroups] = useState<TopbarMenuGroup[]>([]);
const frameRef = useRef<HTMLIFrameElement | null>(null);
@@ -2493,6 +2577,7 @@ function WikiView() {
setCurrentPath(path);
setCharacterModalMode("closed");
setOpenTopMenuId(null);
setOpenTopMenuPath([]);
}, []);
const onSubmitTopbarSearch = useCallback((event: FormEvent<HTMLFormElement>) => {
@@ -2555,6 +2640,7 @@ function WikiView() {
return;
}
frameRef.current?.contentWindow?.postMessage({ type: "5etd:native-navbar-run-action", actionId }, "*");
setOpenTopMenuPath([]);
}, []);
const navigateTopbarHref = useCallback((hrefRaw: string, isExternal?: boolean) => {
@@ -2583,13 +2669,13 @@ function WikiView() {
}, [navigateWikiPath, resolveWikiPathFromHref]);
const activateTopbarEntry = useCallback((entry: TopbarMenuEntry) => {
if (entry.type === "action") {
if (entry.actionId) {
runTopbarAction(entry.actionId);
setOpenTopMenuId(null);
return;
}
if (entry.type === "link" && entry.href) {
if (entry.href) {
navigateTopbarHref(entry.href, entry.isExternal);
return;
}
@@ -2598,6 +2684,7 @@ function WikiView() {
const activateTopbarGroup = useCallback((group: TopbarMenuGroup) => {
if (group.kind === "dropdown") {
setOpenTopMenuId((current) => (current === group.id ? null : group.id));
setOpenTopMenuPath([]);
return;
}
@@ -2612,39 +2699,118 @@ function WikiView() {
}
}, [navigateTopbarHref, runTopbarAction]);
const renderTopbarEntries = useCallback((entries: TopbarMenuEntry[], depth = 0): JSX.Element[] => {
return entries.flatMap((entry) => {
if (entry.type === "divider") {
return [<div key={entry.id} className="topbar-dropdown-divider" />];
}
useEffect(() => {
if (!openTopMenuId) {
setOpenTopMenuPath([]);
}
}, [openTopMenuId]);
if (entry.type === "label") {
return [<div key={entry.id} className="topbar-dropdown-label">{entry.label}</div>];
const openTopbarSubmenuAtDepth = useCallback((depth: number, entryId: string) => {
setOpenTopMenuPath((current) => {
if (current[depth] === entryId && current.length === depth + 1) {
return current;
}
if (entry.type === "submenu") {
const children = entry.children?.length ? renderTopbarEntries(entry.children, depth + 1) : [];
return [
<div key={entry.id} className={depth > 0 ? "topbar-submenu topbar-submenu--nested" : "topbar-submenu"}>
<div className="topbar-submenu-title">{entry.label}</div>
<div className="topbar-submenu-items">{children}</div>
</div>
];
}
const isActive = entry.type === "link" && entry.href ? isSameAppPath(entry.href, currentPathname) : false;
return [
<button
type="button"
key={entry.id}
className={isActive ? "topbar-dropdown-item is-active" : "topbar-dropdown-item"}
onClick={() => activateTopbarEntry(entry)}
>
{entry.label}
</button>
];
return [...current.slice(0, depth), entryId];
});
}, [activateTopbarEntry, currentPathname, isSameAppPath]);
}, []);
const renderTopbarDropdown = useCallback((entries: TopbarMenuEntry[]): JSX.Element => {
const renderEntryLabel = (entry: TopbarMenuEntry): JSX.Element => (
<span className="topbar-item-content">
{entry.datePrefix ? <span className="topbar-item-date">{entry.datePrefix}</span> : null}
{entry.sourceColor ? <span className="topbar-item-source" style={{ backgroundColor: entry.sourceColor }} aria-hidden="true" /> : null}
<span className="topbar-item-text">{entry.label}</span>
</span>
);
const columns: TopbarMenuEntry[][] = [entries];
let currentEntries = entries;
for (let depth = 0; depth < openTopMenuPath.length; depth += 1) {
const activeSubmenuId = openTopMenuPath[depth];
if (!activeSubmenuId) {
break;
}
const submenuEntry = currentEntries.find((entry) => entry.type === "submenu" && entry.id === activeSubmenuId);
if (!submenuEntry?.children?.length) {
break;
}
columns.push(submenuEntry.children);
currentEntries = submenuEntry.children;
}
return (
<div className="topbar-dropdown-columns">
{columns.map((columnEntries, depth) => (
<div key={`col-${depth}`} className="topbar-dropdown-column">
{columnEntries.map((entry) => {
if (entry.type === "divider") {
return <div key={entry.id} className="topbar-dropdown-divider" />;
}
if (entry.type === "label") {
return <div key={entry.id} className="topbar-dropdown-label">{entry.label}</div>;
}
if (entry.type === "submenu") {
const isSubmenuActive = openTopMenuPath[depth] === entry.id;
const hasChildren = Boolean(entry.children?.length);
const className = [
"topbar-dropdown-item",
"topbar-dropdown-item--submenu",
isSubmenuActive ? "is-active" : ""
]
.filter(Boolean)
.join(" ");
return (
<button
type="button"
key={entry.id}
className={className}
onMouseEnter={() => {
if (!hasChildren) {
return;
}
openTopbarSubmenuAtDepth(depth, entry.id);
}}
onFocus={() => {
if (!hasChildren) {
return;
}
openTopbarSubmenuAtDepth(depth, entry.id);
}}
onClick={() => {
if (hasChildren) {
openTopbarSubmenuAtDepth(depth, entry.id);
return;
}
activateTopbarEntry(entry);
}}
>
{renderEntryLabel(entry)}
{hasChildren ? <span className="topbar-submenu-caret" aria-hidden="true">▸</span> : null}
</button>
);
}
const isActive = entry.type === "link" && entry.href ? isSameAppPath(entry.href, currentPathname) : false;
return (
<button
type="button"
key={entry.id}
className={isActive ? "topbar-dropdown-item is-active" : "topbar-dropdown-item"}
onClick={() => activateTopbarEntry(entry)}
>
{renderEntryLabel(entry)}
</button>
);
})}
</div>
))}
</div>
);
}, [activateTopbarEntry, currentPathname, isSameAppPath, openTopMenuPath, openTopbarSubmenuAtDepth]);
const sourceBuckets = characterDraft?.steps.preferences.sourceBuckets ?? [];
@@ -3649,7 +3815,7 @@ function WikiView() {
{group.kind === "dropdown" && isOpen ? (
<div className="topbar-dropdown-menu" role="menu">
{renderTopbarEntries(group.items ?? [])}
{renderTopbarDropdown(group.items ?? [])}
</div>
) : null}
</div>
@@ -3660,7 +3826,10 @@ function WikiView() {
<button
type="button"
className={characterModalMode !== "closed" ? "topbar-menu-toggle is-active" : "topbar-menu-toggle"}
onClick={() => setOpenTopMenuId((current) => (current === APP_CHARACTERS_MENU_ID ? null : APP_CHARACTERS_MENU_ID))}
onClick={() => {
setOpenTopMenuId((current) => (current === APP_CHARACTERS_MENU_ID ? null : APP_CHARACTERS_MENU_ID));
setOpenTopMenuPath([]);
}}
aria-expanded={openTopMenuId === APP_CHARACTERS_MENU_ID}
>
<span>Characters</span>