Files
bodyshop/client/src/components/shop-info/shop-info.section-navigator.component.jsx

214 lines
6.0 KiB
JavaScript

import { Select } from "antd";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import "./shop-info.section-navigator.styles.scss";
const HIGHLIGHT_CLASS = "shop-info-section-navigator__target--active";
export default function ShopInfoSectionNavigator({ tabsRef, activeTabKey }) {
const { t } = useTranslation();
const targetMapRef = useRef(new Map());
const highlightedTargetRef = useRef(null);
const [options, setOptions] = useState([]);
const [selectedSection, setSelectedSection] = useState(undefined);
useEffect(() => {
const tabsContainer = tabsRef.current;
if (!tabsContainer) return undefined;
let animationFrameId = 0;
const refreshOptions = () => {
const activePane = tabsContainer.querySelector(".ant-tabs-tabpane-active");
if (!activePane) {
targetMapRef.current = new Map();
setOptions([]);
return;
}
const nextTargetMap = new Map();
const nextOptions = Array.from(activePane.querySelectorAll(".imex-form-row"))
.filter((card) => {
return shouldIncludeCardInNavigator(card, activePane);
})
.map((card, index) => {
const { title, depth, searchLabel } = getCardNavigatorInfo(card, activePane);
const value = `${activeTabKey}-shop-info-section-${index}`;
nextTargetMap.set(value, card);
return {
label: renderNavigatorOptionLabel(title, depth),
labelText: title,
searchLabel,
depth,
value
};
});
targetMapRef.current = nextTargetMap;
setOptions((currentOptions) => (areOptionsEqual(currentOptions, nextOptions) ? currentOptions : nextOptions));
};
const scheduleRefresh = () => {
cancelAnimationFrame(animationFrameId);
animationFrameId = requestAnimationFrame(refreshOptions);
};
scheduleRefresh();
const observer = new MutationObserver(scheduleRefresh);
observer.observe(tabsContainer, {
childList: true,
subtree: true,
characterData: true,
attributes: true,
attributeFilter: ["class"]
});
return () => {
cancelAnimationFrame(animationFrameId);
observer.disconnect();
};
}, [activeTabKey, tabsRef]);
useEffect(() => {
clearHighlightedTarget(highlightedTargetRef);
setSelectedSection(undefined);
}, [activeTabKey]);
const handleSectionChange = (value) => {
setSelectedSection(value);
clearHighlightedTarget(highlightedTargetRef);
if (!value) return;
const target = targetMapRef.current.get(value);
if (target) {
target.classList.add(HIGHLIGHT_CLASS);
highlightedTargetRef.current = target;
target.scrollIntoView({
behavior: "smooth",
block: "start"
});
}
window.setTimeout(() => {
setSelectedSection(undefined);
}, 0);
};
return (
<div className="shop-info-section-navigator">
<Select
allowClear
showSearch
value={selectedSection}
placeholder={t("bodyshop.labels.jump_to_section")}
options={options}
popupMatchSelectWidth={false}
disabled={options.length === 0}
filterOption={(input, option) => option?.searchLabel?.toLowerCase().includes(input.toLowerCase())}
onChange={handleSectionChange}
/>
</div>
);
}
function getOwnCardTitleNode(card) {
const headNode = Array.from(card.children).find((child) => child.classList?.contains("ant-card-head"));
return headNode?.querySelector(".ant-card-head-title");
}
function getOwnCardTitle(card) {
return getOwnCardTitleNode(card)?.textContent?.trim();
}
function getAncestorCards(card, activePane) {
const ancestors = [];
let currentCard = card.parentElement?.closest(".imex-form-row");
while (currentCard && activePane.contains(currentCard)) {
ancestors.push(currentCard);
currentCard = currentCard.parentElement?.closest(".imex-form-row");
}
return ancestors.reverse();
}
function getCardDepth(card, activePane) {
return getAncestorCards(card, activePane).length;
}
function isVisibleCard(card) {
return card.offsetParent !== null;
}
function isNavigatorEligibleSubsection(card) {
return (
!card.classList.contains("imex-form-row--compact") &&
!card.classList.contains("imex-form-row--title-only") &&
!card.querySelector(":scope > .ant-card-actions")
);
}
function shouldIncludeCardInNavigator(card, activePane) {
const title = getOwnCardTitle(card);
if (!title || !isVisibleCard(card)) return false;
const depth = getCardDepth(card, activePane);
if (depth === 0) return true;
if (depth === 1) return isNavigatorEligibleSubsection(card);
return false;
}
function getCardNavigatorInfo(card, activePane) {
const title = getOwnCardTitle(card);
const ancestors = getAncestorCards(card, activePane);
const depth = ancestors.length;
const parentTitle = depth === 1 ? getOwnCardTitle(ancestors[0]) : null;
return {
title,
depth,
searchLabel: parentTitle ? `${parentTitle} ${title}` : title
};
}
function renderNavigatorOptionLabel(title, depth) {
return (
<span
className={[
"shop-info-section-navigator__option",
depth > 0 ? "shop-info-section-navigator__option--subsection" : null
]
.filter(Boolean)
.join(" ")}
>
<span className="shop-info-section-navigator__option-label">{title}</span>
</span>
);
}
function clearHighlightedTarget(highlightedTargetRef) {
if (highlightedTargetRef.current) {
highlightedTargetRef.current.classList.remove(HIGHLIGHT_CLASS);
highlightedTargetRef.current = null;
}
}
function areOptionsEqual(currentOptions, nextOptions) {
if (currentOptions.length !== nextOptions.length) return false;
return currentOptions.every((option, index) => {
const nextOption = nextOptions[index];
return (
option.labelText === nextOption.labelText &&
option.searchLabel === nextOption.searchLabel &&
option.depth === nextOption.depth &&
option.value === nextOption.value
);
});
}