added nested navmenus
This commit is contained in:
1 parent
a73c5ea002
commit
18734acaad
4 files changed
+525
-85
No files matched your search
+206
-37
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user