UNPKG

@neo4j-ndl/react-charts

Version:

React implementation of charts from Neo4j Design System

363 lines 17.5 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __rest = (this && this.__rest) || function (s, e) { var t = {}; for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) t[p] = s[p]; if (s != null && typeof Object.getOwnPropertySymbols === "function") for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) { if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) t[p[i]] = s[p[i]]; } return t; }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.LegendLayout = void 0; const jsx_runtime_1 = require("react/jsx-runtime"); /** * * Copyright (c) "Neo4j" * Neo4j Sweden AB [http://neo4j.com] * * This file is part of Neo4j. * * Neo4j is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ const react_1 = require("@neo4j-ndl/react"); const classnames_1 = __importDefault(require("classnames")); const react_2 = __importStar(require("react")); const legend_layout_1 = require("./utils/legend-layout"); const getLegendElementsContentHeight = (legendContent, elements) => { const legendContentStyle = getComputedStyle(legendContent); const paddingBottom = parseFloat(legendContentStyle.paddingBottom) || 0; const elementsBottom = Math.max(...elements.map((element) => element.offsetTop + element.offsetHeight)); return elementsBottom + paddingBottom; }; const getCollapsedLegendContentHeight = ({ availableRowWidth, elements, legendContent, }) => { const legendContentStyle = getComputedStyle(legendContent); const paddingTop = parseFloat(legendContentStyle.paddingTop) || 0; const paddingBottom = parseFloat(legendContentStyle.paddingBottom) || 0; const rowGap = parseFloat(legendContentStyle.rowGap) || 0; let currentRowHeight = 0; let currentRowWidth = 0; const rowHeights = []; elements.forEach((element) => { const elementWidth = (0, legend_layout_1.getComputedElementWidth)(element); const elementHeight = element.getBoundingClientRect().height; const shouldWrap = currentRowWidth > 0 && currentRowWidth + elementWidth > availableRowWidth; if (shouldWrap) { rowHeights.push(currentRowHeight); currentRowHeight = 0; currentRowWidth = 0; } currentRowWidth += elementWidth; currentRowHeight = Math.max(currentRowHeight, elementHeight); }); if (currentRowHeight > 0) { rowHeights.push(currentRowHeight); } const rowsHeight = rowHeights.reduce((height, rowHeight) => { return height + rowHeight; }, 0); return (paddingTop + rowsHeight + rowGap * Math.max(rowHeights.length - 1, 0) + paddingBottom); }; /** * Tracks legend container width for responsive collapsed-layout recalculation. * * The width is kept in state so `useCollapsibleLegendLayout` can re-measure item * wrapping whenever the available legend width changes. */ const useLegendContainerWidth = (legendContainerRef) => { const [legendWidth, setLegendWidth] = (0, react_2.useState)(Infinity); const updateLegendWidth = (0, react_2.useCallback)(() => { var _a; const nextWidth = (_a = legendContainerRef.current) === null || _a === void 0 ? void 0 : _a.clientWidth; if (nextWidth === undefined) { return; } setLegendWidth(nextWidth); }, [legendContainerRef]); (0, react_2.useEffect)(() => { updateLegendWidth(); const legendContainer = legendContainerRef.current; if (!legendContainer) { return; } const resizeObserver = new ResizeObserver(updateLegendWidth); resizeObserver.observe(legendContainer); return () => { resizeObserver.disconnect(); }; }, [legendContainerRef, updateLegendWidth]); return legendWidth; }; /** * Owns the collapsed, measuring, and expanded layout state for wrapping legends. * * Collapsed legends only render the visible subset of items. When the item count * or container width changes, the hook temporarily renders every item in an * invisible measuring pass so it can calculate wrapping, overflow, and the next * visible item count. The last collapsed height is preserved during that pass so * the chart area does not shrink while the hidden full legend is being measured. */ const useCollapsibleLegendLayout = ({ itemCount, legendContainerRef, legendContentRef, legendMeasurementRef, viewMoreButtonMeasurementRef, }) => { const [isExpanded, setIsExpanded] = (0, react_2.useState)(false); const [isMeasuring, setIsMeasuring] = (0, react_2.useState)(false); const [layoutSnapshot, setLayoutSnapshot] = (0, react_2.useState)({ contentHeight: 0, hasOverflow: false, visibleItemCount: itemCount, }); const skipNextCollapseMeasurementRef = (0, react_2.useRef)(false); const legendWidth = useLegendContainerWidth(legendContainerRef); const updateVisibleItems = (0, react_2.useCallback)(() => { var _a; const legendContent = legendMeasurementRef.current; const viewMoreButton = viewMoreButtonMeasurementRef.current; if (!legendContent || !viewMoreButton) { setIsMeasuring(false); return; } const legendItems = Array.from(legendContent.querySelectorAll('.ndl-chart-legend-item')).filter((element) => element.offsetParent !== null); if (legendItems.length === 0) { setLayoutSnapshot((snapshot) => (Object.assign(Object.assign({}, snapshot), { contentHeight: 0, hasOverflow: false, visibleItemCount: itemCount }))); setIsMeasuring(false); return; } const rowTops = Array.from(new Set(legendItems.map((element) => element.offsetTop))).sort((a, b) => a - b); const nextLegendContentHeight = legendContent.scrollHeight; const nextLegendItemsContentHeight = getLegendElementsContentHeight(legendContent, legendItems); if (rowTops.length <= legend_layout_1.COLLAPSED_LEGEND_ROWS) { setLayoutSnapshot((snapshot) => (Object.assign(Object.assign({}, snapshot), { contentHeight: nextLegendItemsContentHeight, hasOverflow: false, visibleItemCount: itemCount }))); setIsMeasuring(false); return; } const legendContainer = legendContainerRef.current; const legendContentStyle = getComputedStyle(legendContent); const availableRowWidth = ((_a = legendContainer === null || legendContainer === void 0 ? void 0 : legendContainer.clientWidth) !== null && _a !== void 0 ? _a : legendContent.clientWidth) - (parseFloat(legendContentStyle.paddingLeft) || 0) - (parseFloat(legendContentStyle.paddingRight) || 0); const viewMoreButtonWidth = (0, legend_layout_1.getComputedElementWidth)(viewMoreButton); const legendItemWidths = legendItems.map(legend_layout_1.getComputedElementWidth); const nextVisibleItemCount = (0, legend_layout_1.getCollapsedVisibleItemCount)(legendItemWidths, availableRowWidth, viewMoreButtonWidth); const nextCollapsedContentHeight = getCollapsedLegendContentHeight({ availableRowWidth, elements: [...legendItems.slice(0, nextVisibleItemCount), viewMoreButton], legendContent, }); setLayoutSnapshot((snapshot) => (Object.assign(Object.assign({}, snapshot), { collapsedHeight: Math.min(nextCollapsedContentHeight, legend_layout_1.COLLAPSED_LEGEND_MAX_HEIGHT), contentHeight: nextLegendContentHeight, hasOverflow: nextVisibleItemCount < itemCount, visibleItemCount: nextVisibleItemCount }))); setIsMeasuring(false); }, [ itemCount, legendContainerRef, legendMeasurementRef, viewMoreButtonMeasurementRef, ]); (0, react_2.useEffect)(() => { setIsExpanded(false); }, [itemCount]); (0, react_2.useEffect)(() => { if (isExpanded) { setIsMeasuring(false); return; } if (skipNextCollapseMeasurementRef.current) { skipNextCollapseMeasurementRef.current = false; setIsMeasuring(false); return; } setIsMeasuring(true); }, [isExpanded, itemCount, legendWidth]); (0, react_2.useEffect)(() => { const isMeasuringCollapsed = !isExpanded && isMeasuring; const animationFrame = requestAnimationFrame(() => { if (isMeasuringCollapsed) { updateVisibleItems(); } else { setLayoutSnapshot((snapshot) => { var _a, _b; return (Object.assign(Object.assign({}, snapshot), { contentHeight: (_b = (_a = legendContentRef.current) === null || _a === void 0 ? void 0 : _a.scrollHeight) !== null && _b !== void 0 ? _b : 0 })); }); if (!isMeasuring && !isExpanded && layoutSnapshot.hasOverflow) { setLayoutSnapshot((snapshot) => { var _a; return (Object.assign(Object.assign({}, snapshot), { collapsedHeight: (_a = legendContentRef.current) === null || _a === void 0 ? void 0 : _a.getBoundingClientRect().height })); }); } } }); return () => { cancelAnimationFrame(animationFrame); }; }, [ isExpanded, isMeasuring, legendContentRef, layoutSnapshot.hasOverflow, updateVisibleItems, ]); const expandLegend = (0, react_2.useCallback)(() => { setIsMeasuring(false); setIsExpanded(true); }, []); const collapseLegend = (0, react_2.useCallback)(() => { skipNextCollapseMeasurementRef.current = true; setIsExpanded(false); }, []); return { collapseLegend, expandLegend, isExpanded, isMeasuring, layoutSnapshot, }; }; /** * Renders the responsive legend layout shell. * * The component switches between a two-row collapsed view, an invisible * measurement view, and an expanded scrollable view while keeping the * "View more"/"View less" action wired to the measured overflow state. */ const LegendLayout = (_a) => { var { children, className, isLayoutReady = true, itemCount, onLayoutReady, htmlAttributes } = _a, restProps = __rest(_a, ["children", "className", "isLayoutReady", "itemCount", "onLayoutReady", "htmlAttributes"]); const legendContainerRef = (0, react_2.useRef)(null); const legendContentRef = (0, react_2.useRef)(null); const legendMeasurementRef = (0, react_2.useRef)(null); const pendingFocusLegendItemIndexRef = (0, react_2.useRef)(null); const viewMoreButtonMeasurementRef = (0, react_2.useRef)(null); const { collapseLegend, expandLegend, isExpanded, isMeasuring, layoutSnapshot, } = useCollapsibleLegendLayout({ itemCount, legendContainerRef, legendContentRef, legendMeasurementRef, viewMoreButtonMeasurementRef, }); const childList = react_2.default.Children.toArray(children); const childrenToRender = !isExpanded ? childList.slice(0, layoutSnapshot.visibleItemCount) : childList; const legendHeight = !isMeasuring && !isExpanded && layoutSnapshot.hasOverflow ? layoutSnapshot.collapsedHeight : !isMeasuring && !layoutSnapshot.hasOverflow && layoutSnapshot.contentHeight ? layoutSnapshot.contentHeight : undefined; (0, react_2.useEffect)(() => { if (!isMeasuring && layoutSnapshot.contentHeight > 0) { let secondFrame; const firstFrame = requestAnimationFrame(() => { secondFrame = requestAnimationFrame(() => { onLayoutReady === null || onLayoutReady === void 0 ? void 0 : onLayoutReady(); }); }); return () => { cancelAnimationFrame(firstFrame); if (secondFrame !== undefined) { cancelAnimationFrame(secondFrame); } }; } }, [isMeasuring, layoutSnapshot.contentHeight, onLayoutReady]); (0, react_2.useEffect)(() => { var _a, _b; const pendingFocusLegendItemIndex = pendingFocusLegendItemIndexRef.current; if (!isExpanded || pendingFocusLegendItemIndex === null) { return; } const legendItems = (_a = legendContentRef.current) === null || _a === void 0 ? void 0 : _a.querySelectorAll('.ndl-chart-legend-item'); (_b = legendItems === null || legendItems === void 0 ? void 0 : legendItems[pendingFocusLegendItemIndex]) === null || _b === void 0 ? void 0 : _b.focus(); pendingFocusLegendItemIndexRef.current = null; }, [isExpanded]); const handleLegendActionClick = (0, react_2.useCallback)(() => { if (isExpanded) { collapseLegend(); return; } pendingFocusLegendItemIndexRef.current = layoutSnapshot.visibleItemCount; expandLegend(); }, [ collapseLegend, expandLegend, isExpanded, layoutSnapshot.visibleItemCount, ]); const classes = (0, classnames_1.default)('ndl-chart-legend', { 'ndl-chart-legend-calculating': !isExpanded && isMeasuring, 'ndl-chart-legend-collapsed': layoutSnapshot.hasOverflow && !isExpanded, 'ndl-chart-legend-expanded': layoutSnapshot.hasOverflow && isExpanded, 'ndl-chart-legend-hidden': !isLayoutReady, 'ndl-chart-legend-scrollable': layoutSnapshot.hasOverflow && isExpanded && layoutSnapshot.contentHeight > legend_layout_1.COLLAPSED_LEGEND_MAX_HEIGHT, 'ndl-chart-legend-wrapping': true, }, className); return ((0, jsx_runtime_1.jsx)("div", Object.assign({ ref: legendContainerRef, className: "ndl-chart-legend-container" }, restProps, htmlAttributes, { children: (0, jsx_runtime_1.jsxs)("div", { className: classes, ref: legendContentRef, style: { height: legendHeight, maxHeight: layoutSnapshot.hasOverflow && isExpanded ? 'var(--ndl-chart-legend-expanded-max-height)' : layoutSnapshot.hasOverflow ? legend_layout_1.COLLAPSED_LEGEND_MAX_HEIGHT : undefined, }, children: [childrenToRender, layoutSnapshot.hasOverflow && ((0, jsx_runtime_1.jsx)(react_1.TextLink, { as: "button", className: "ndl-chart-legend-action", htmlAttributes: { 'aria-expanded': isExpanded, onClick: handleLegendActionClick, type: 'button', }, children: isExpanded ? 'View less' : 'View more' })), isMeasuring && ((0, jsx_runtime_1.jsxs)("div", { "aria-hidden": "true", className: "ndl-chart-legend-measurement", ref: legendMeasurementRef, children: [childList, (0, jsx_runtime_1.jsx)(react_1.TextLink, { as: "button", ref: viewMoreButtonMeasurementRef, className: "ndl-chart-legend-action", htmlAttributes: { tabIndex: -1, type: 'button', }, children: "View more" })] }))] }) }))); }; exports.LegendLayout = LegendLayout; //# sourceMappingURL=LegendLayout.js.map