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

+126 -34
View File
@@ -54,6 +54,11 @@ interface SourceBookRecord {
published?: string;
}
interface GeneratedNavSourceRecord extends SourceBookRecord {
parentSource?: string;
parentName?: string;
}
interface ClassRecord {
name?: string;
source?: string;
@@ -130,6 +135,15 @@ interface CacheValue {
rootDir: string;
options: CharacterSelectionOptionSets;
detailsByRef: Map<string, CharacterSelectionDetail>;
rawLocators: Map<string, RawEntityLocator>;
}
type RawEntityArrayKey = "class" | "background" | "race";
interface RawEntityLocator {
filePath: string;
arrayKey: RawEntityArrayKey;
index: number;
}
let cache: CacheValue | null = null;
@@ -436,24 +450,25 @@ function classifySourceMeta(input: {
function buildSourceMetaMap(rootDir: string): Map<string, SourceMeta> {
const rawMap = new Map<string, { sourceName?: string; sourceGroup?: string | null; sourcePublished?: string | null }>();
const addRawSourceRecord = (record: SourceBookRecord): void => {
const source = normalizeString(record.source).toUpperCase();
if (!source || rawMap.has(source)) {
return;
}
rawMap.set(source, {
sourceName: normalizeString(record.name) || source,
sourceGroup: normalizeString(record.group) || null,
sourcePublished: normalizeString(record.published) || null
});
};
const booksPath = path.join(rootDir, "data", "books.json");
if (fs.existsSync(booksPath)) {
const parsed = readJsonFile(booksPath);
const books = isRecord(parsed) && Array.isArray(parsed.book) ? (parsed.book as SourceBookRecord[]) : [];
for (const book of books) {
const source = normalizeString(book.source).toUpperCase();
if (!source) {
continue;
}
if (!rawMap.has(source)) {
rawMap.set(source, {
sourceName: normalizeString(book.name) || source,
sourceGroup: normalizeString(book.group) || null,
sourcePublished: normalizeString(book.published) || null
});
}
addRawSourceRecord(book);
}
}
@@ -465,16 +480,22 @@ function buildSourceMetaMap(rootDir: string): Map<string, SourceMeta> {
: [];
for (const adventure of adventures) {
const source = normalizeString(adventure.source).toUpperCase();
if (!source || rawMap.has(source)) {
continue;
}
addRawSourceRecord(adventure);
}
}
rawMap.set(source, {
sourceName: normalizeString(adventure.name) || source,
sourceGroup: normalizeString(adventure.group) || null,
sourcePublished: normalizeString(adventure.published) || null
});
const generatedNavIndexPath = path.join(rootDir, "data", "generated", "gendata-nav-adventure-book-index.json");
if (fs.existsSync(generatedNavIndexPath)) {
const parsed = readJsonFile(generatedNavIndexPath);
const books = isRecord(parsed) && Array.isArray(parsed.book)
? (parsed.book as GeneratedNavSourceRecord[])
: [];
const adventures = isRecord(parsed) && Array.isArray(parsed.adventure)
? (parsed.adventure as GeneratedNavSourceRecord[])
: [];
for (const record of [...books, ...adventures]) {
addRawSourceRecord(record);
}
}
@@ -814,8 +835,32 @@ function createOption(
};
}
function readRawJsonFromLocator(locator: RawEntityLocator): string {
try {
if (!fs.existsSync(locator.filePath)) {
return "";
}
const parsed = readJsonFile(locator.filePath);
const arr = isRecord(parsed) ? parsed[locator.arrayKey] : null;
if (!Array.isArray(arr)) {
return "";
}
const entry = arr[locator.index];
if (entry == null) {
return "";
}
return JSON.stringify(entry, null, 2);
} catch {
return "";
}
}
function buildCache(rootDir: string): CacheValue {
const detailsByRef = new Map<string, CharacterSelectionDetail>();
const rawLocators = new Map<string, RawEntityLocator>();
const sourceMetaMap = buildSourceMetaMap(rootDir);
const classFluffMap = loadClassFluff(rootDir);
@@ -827,7 +872,7 @@ function buildCache(rootDir: string): CacheValue {
const classIndexJson = readJsonFile(classIndexPath);
const classFileNames = isRecord(classIndexJson) ? Object.values(classIndexJson).filter((it): it is string => typeof it === "string") : [];
const allClasses: ClassRecord[] = [];
const allClasses: Array<{ record: ClassRecord; filePath: string; index: number }> = [];
const allClassFeatures: ClassFeatureRecord[] = [];
const allSubclasses: SubclassRecord[] = [];
@@ -843,7 +888,10 @@ function buildCache(rootDir: string): CacheValue {
}
if (Array.isArray(parsed.class)) {
allClasses.push(...(parsed.class as ClassRecord[]));
const classRecords = parsed.class as ClassRecord[];
classRecords.forEach((record, classIndex) => {
allClasses.push({ record, filePath, index: classIndex });
});
}
if (Array.isArray(parsed.classFeature)) {
@@ -871,7 +919,8 @@ function buildCache(rootDir: string): CacheValue {
}
const classOptions: CharacterSelectionOption[] = [];
for (const cls of allClasses) {
for (const clsInfo of allClasses) {
const cls = clsInfo.record;
const name = normalizeString(cls.name);
const source = normalizeString(cls.source).toUpperCase();
if (!name || !source) {
@@ -994,11 +1043,16 @@ function buildCache(rootDir: string): CacheValue {
const detail: CharacterSelectionDetail = {
...option,
featureCards,
rawJson: JSON.stringify(cls, null, 2)
rawJson: ""
};
classOptions.push(option);
detailsByRef.set(option.entityRef, detail);
rawLocators.set(option.entityRef, {
filePath: clsInfo.filePath,
arrayKey: "class",
index: clsInfo.index
});
}
const backgroundPath = path.join(rootDir, "data", "backgrounds.json");
@@ -1008,7 +1062,8 @@ function buildCache(rootDir: string): CacheValue {
: [];
const backgroundOptions: CharacterSelectionOption[] = [];
for (const bg of backgroundRecords) {
for (let bgIndex = 0; bgIndex < backgroundRecords.length; bgIndex += 1) {
const bg = backgroundRecords[bgIndex];
const name = normalizeString(bg.name);
const source = normalizeString(bg.source).toUpperCase();
if (!name || !source) {
@@ -1035,11 +1090,16 @@ function buildCache(rootDir: string): CacheValue {
const detail: CharacterSelectionDetail = {
...option,
featureCards,
rawJson: JSON.stringify(bg, null, 2)
rawJson: ""
};
backgroundOptions.push(option);
detailsByRef.set(option.entityRef, detail);
rawLocators.set(option.entityRef, {
filePath: backgroundPath,
arrayKey: "background",
index: bgIndex
});
}
const speciesPath = path.join(rootDir, "data", "races.json");
@@ -1049,7 +1109,8 @@ function buildCache(rootDir: string): CacheValue {
: [];
const speciesOptions: CharacterSelectionOption[] = [];
for (const race of speciesRecords) {
for (let raceIndex = 0; raceIndex < speciesRecords.length; raceIndex += 1) {
const race = speciesRecords[raceIndex];
const name = normalizeString(race.name);
const source = normalizeString(race.source).toUpperCase();
if (!name || !source) {
@@ -1078,11 +1139,16 @@ function buildCache(rootDir: string): CacheValue {
const detail: CharacterSelectionDetail = {
...option,
featureCards,
rawJson: JSON.stringify(race, null, 2)
rawJson: ""
};
speciesOptions.push(option);
detailsByRef.set(option.entityRef, detail);
rawLocators.set(option.entityRef, {
filePath: speciesPath,
arrayKey: "race",
index: raceIndex
});
}
const sortOptions = (arr: CharacterSelectionOption[]): CharacterSelectionOption[] =>
@@ -1097,16 +1163,18 @@ function buildCache(rootDir: string): CacheValue {
backgrounds: sortOptions(backgroundOptions),
species: sortOptions(speciesOptions)
},
detailsByRef
detailsByRef,
rawLocators
};
}
function getCache(rootDir: string): CacheValue {
if (cache && cache.rootDir === rootDir) {
const resolvedRootDir = path.resolve(rootDir);
if (cache && cache.rootDir === resolvedRootDir) {
return cache;
}
const value = buildCache(rootDir);
const value = buildCache(resolvedRootDir);
cache = value;
return value;
}
@@ -1121,8 +1189,32 @@ export function getCharacterSelectionDetail(rootDir: string, entityRefRaw: strin
return null;
}
const detail = getCache(rootDir).detailsByRef.get(entityRef);
return detail || null;
const cacheValue = getCache(rootDir);
const detail = cacheValue.detailsByRef.get(entityRef);
if (!detail) {
return null;
}
if (detail.rawJson) {
return detail;
}
const locator = cacheValue.rawLocators.get(entityRef);
if (!locator) {
return detail;
}
const nextRawJson = readRawJsonFromLocator(locator);
if (!nextRawJson) {
return detail;
}
const nextDetail: CharacterSelectionDetail = {
...detail,
rawJson: nextRawJson
};
cacheValue.detailsByRef.set(entityRef, nextDetail);
return nextDetail;
}
export function clearCharacterSelectionCache(): void {
+97 -3
View File
@@ -367,6 +367,32 @@ function getDesktopReskinBootstrap(externalImageRoot: string | null): string {
return cleanNavLabel(ele.textContent || "");
};
const getNavEntryMeta = (ele) => {
if (!ele) return {};
const out = {};
const dateEle = ele.querySelector(":scope > .page__nav-date");
if (dateEle) {
const datePrefix = cleanNavLabel(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 = String(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;
};
const isActionLikeHref = (hrefRaw) => {
const href = String(hrefRaw || "").trim().toLowerCase();
return !href || href === "#" || href.startsWith("javascript:");
@@ -378,6 +404,9 @@ function getDesktopReskinBootstrap(externalImageRoot: string | null): string {
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);
const submenuChildren = submenuList ? parseNavbarMenuList(submenuList, \`\${entryId}-submenu\`) : [];
if (li.classList.contains("ve-dropdown-divider") || li.classList.contains("divider")) {
entries.push({ id: \`\${entryId}-divider\`, type: "divider" });
@@ -392,7 +421,6 @@ function getDesktopReskinBootstrap(externalImageRoot: string | null): string {
if (li.classList.contains("dropdown--navbar")) {
const toggle = li.querySelector(":scope > a");
const submenuList = li.querySelector(":scope > ul");
const label = toggle ? getNavPrimaryText(toggle) : "";
if (!label) return;
@@ -400,7 +428,23 @@ function getDesktopReskinBootstrap(externalImageRoot: string | null): string {
id: \`\${entryId}-submenu\`,
type: "submenu",
label,
children: submenuList ? parseNavbarMenuList(submenuList, \`\${entryId}-submenu\`) : []
...getNavEntryMeta(toggle),
children: submenuChildren
});
return;
}
if (li.classList.contains("nav2-accord__wrp")) {
const headPrimary = li.querySelector(":scope > .nav2-accord__head > :first-child");
const label = headPrimary ? getNavPrimaryText(headPrimary) : "";
if (!label || !submenuChildren.length) return;
entries.push({
id: \`\${entryId}-accordion\`,
type: "submenu",
label,
...getNavEntryMeta(headPrimary),
children: submenuChildren
});
return;
}
@@ -409,6 +453,33 @@ function getDesktopReskinBootstrap(externalImageRoot: string | null): string {
if (link) {
const label = getNavPrimaryText(link);
const href = String(link.getAttribute("href") || "").trim();
if (submenuChildren.length) {
if (isActionLikeHref(href)) {
const actionId = \`\${entryId}-submenu-action\`;
link.setAttribute(NAVBAR_ACTION_ATTR, actionId);
entries.push({
id: \`\${entryId}-submenu-link\`,
type: "submenu",
label: label || "Section",
...getNavEntryMeta(link),
actionId,
children: submenuChildren
});
return;
}
entries.push({
id: \`\${entryId}-submenu-link\`,
type: "submenu",
label: label || href,
...getNavEntryMeta(link),
href,
isExternal: link.target === "_blank" || /^https?:\\/\\//i.test(href),
children: submenuChildren
});
return;
}
if (isActionLikeHref(href)) {
const actionId = \`\${entryId}-action\`;
link.setAttribute(NAVBAR_ACTION_ATTR, actionId);
@@ -420,6 +491,7 @@ function getDesktopReskinBootstrap(externalImageRoot: string | null): string {
id: \`\${entryId}-link\`,
type: "link",
label: label || href,
...getNavEntryMeta(link),
href,
isExternal: link.target === "_blank" || /^https?:\\/\\//i.test(href)
});
@@ -427,11 +499,33 @@ function getDesktopReskinBootstrap(externalImageRoot: string | null): string {
}
const spans = Array.from(li.querySelectorAll(":scope > span")).filter((it) => it instanceof HTMLSpanElement);
if (submenuChildren.length && spans.length) {
const first = spans[0];
const label = cleanNavLabel(first.textContent || first.title || "");
const actionId = \`\${entryId}-submenu-span\`;
first.setAttribute(NAVBAR_ACTION_ATTR, actionId);
entries.push({
id: \`\${entryId}-submenu-span\`,
type: "submenu",
label: label || "Section",
...getNavEntryMeta(first),
actionId,
children: submenuChildren
});
return;
}
spans.forEach((span, spanIndex) => {
const label = cleanNavLabel(span.textContent || span.title || "");
const actionId = \`\${entryId}-span-\${spanIndex}\`;
span.setAttribute(NAVBAR_ACTION_ATTR, actionId);
entries.push({ id: \`\${entryId}-span-\${spanIndex}\`, type: "action", label: label || "Action", actionId });
entries.push({
id: \`\${entryId}-span-\${spanIndex}\`,
type: "action",
label: label || "Action",
...getNavEntryMeta(span),
actionId
});
});
});
+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>
+96 -11
View File
@@ -152,14 +152,12 @@ body {
position: absolute;
left: 0;
top: calc(100% + 8px);
min-width: 260px;
max-width: min(520px, calc(100vw - 32px));
min-width: 280px;
max-width: min(1100px, calc(100vw - 32px));
max-height: min(70vh, 680px);
overflow: auto;
display: flex;
flex-direction: column;
gap: 3px;
padding: 8px;
display: block;
padding: 0;
border: 1px solid rgba(126, 181, 234, 0.38);
border-radius: 12px;
background: linear-gradient(180deg, rgba(11, 20, 33, 0.985), rgba(7, 13, 24, 0.992));
@@ -167,23 +165,55 @@ body {
z-index: 60;
}
.topbar-dropdown-columns {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(280px, 340px);
align-items: start;
}
.topbar-dropdown-column {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
padding: 8px;
border-right: 1px solid rgba(111, 167, 224, 0.32);
}
.topbar-dropdown-column:last-child {
border-right: 0;
}
.topbar-dropdown-item {
width: 100%;
border: 1px solid transparent;
border-radius: 9px;
border-radius: 8px;
background: transparent;
color: #d3e4f8;
font-size: 0.88rem;
font-size: 0.86rem;
text-align: left;
padding: 8px 10px;
padding: 7px 8px;
cursor: pointer;
white-space: normal;
line-height: 1.3;
}
.topbar-dropdown-item--submenu {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.topbar-submenu-caret {
color: #a7c3e3;
font-size: 0.8rem;
}
.topbar-dropdown-item:hover {
border-color: rgba(113, 171, 230, 0.45);
background: rgba(40, 71, 107, 0.42);
background: rgba(36, 67, 103, 0.36);
color: #f4f8ff;
}
@@ -201,13 +231,39 @@ body {
}
.topbar-dropdown-label {
padding: 4px 8px;
padding: 4px 8px 2px;
color: #9db5d3;
font-size: 0.78rem;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.topbar-item-content {
min-width: 0;
display: inline-flex;
align-items: center;
gap: 8px;
}
.topbar-item-date {
color: #8ea8c8;
min-width: 2.15rem;
text-align: right;
font-size: 0.74rem;
line-height: 1;
}
.topbar-item-source {
width: 3px;
height: 0.98rem;
border-radius: 999px;
flex: 0 0 3px;
}
.topbar-item-text {
min-width: 0;
}
.topbar-submenu {
display: grid;
gap: 4px;
@@ -227,6 +283,22 @@ body {
font-weight: 620;
}
.topbar-submenu-title--interactive {
width: 100%;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: #c9dbf1;
text-align: left;
cursor: pointer;
}
.topbar-submenu-title--interactive:hover {
border-color: rgba(113, 171, 230, 0.45);
background: rgba(40, 71, 107, 0.42);
color: #f4f8ff;
}
.topbar-submenu-items {
display: grid;
gap: 2px;
@@ -1232,6 +1304,19 @@ body {
margin-top: 6px;
}
.topbar-dropdown-columns {
display: block;
}
.topbar-dropdown-column {
border-right: 0;
border-bottom: 1px solid rgba(111, 167, 224, 0.2);
}
.topbar-dropdown-column:last-child {
border-bottom: 0;
}
.character-modal-backdrop {
padding: 12px;
}