diff --git a/ui/v2.5/src/components/List/FilterTags.tsx b/ui/v2.5/src/components/List/FilterTags.tsx index b690f8781..8d9b24e40 100644 --- a/ui/v2.5/src/components/List/FilterTags.tsx +++ b/ui/v2.5/src/components/List/FilterTags.tsx @@ -1,11 +1,18 @@ -import React, { PropsWithChildren } from "react"; -import { Badge, BadgeProps, Button } from "react-bootstrap"; +import React, { + PropsWithChildren, + useEffect, + useLayoutEffect, + useReducer, + useRef, +} from "react"; +import { Badge, BadgeProps, Button, Overlay, Popover } from "react-bootstrap"; import { Criterion } from "src/models/list-filter/criteria/criterion"; import { FormattedMessage, useIntl } from "react-intl"; import { Icon } from "../Shared/Icon"; import { faTimes } from "@fortawesome/free-solid-svg-icons"; import { BsPrefixProps, ReplaceProps } from "react-bootstrap/esm/helpers"; import { CustomFieldsCriterion } from "src/models/list-filter/criteria/custom-fields"; +import { useDebounce } from "src/hooks/debounce"; type TagItemProps = PropsWithChildren< ReplaceProps<"span", BsPrefixProps<"span"> & BadgeProps> @@ -41,11 +48,59 @@ export const FilterTag: React.FC<{ ); }; +const MoreFilterTags: React.FC<{ + tags: React.ReactNode[]; +}> = ({ tags }) => { + const [showTooltip, setShowTooltip] = React.useState(false); + const target = useRef(null); + + if (!tags.length) { + return null; + } + + function handleMouseEnter() { + setShowTooltip(true); + } + + function handleMouseLeave() { + setShowTooltip(false); + } + + return ( + <> + + + {tags} + + + + + + + ); +}; + interface IFilterTagsProps { criteria: Criterion[]; onEditCriterion: (c: Criterion) => void; onRemoveCriterion: (c: Criterion, valueIndex?: number) => void; onRemoveAll: () => void; + truncateOnOverflow?: boolean; } export const FilterTags: React.FC = ({ @@ -53,8 +108,117 @@ export const FilterTags: React.FC = ({ onEditCriterion, onRemoveCriterion, onRemoveAll, + truncateOnOverflow = false, }) => { const intl = useIntl(); + const ref = useRef(null); + + const [cutoff, setCutoff] = React.useState(); + const elementGap = 10; // Adjust this value based on your CSS gap or margin + const moreTagWidth = 80; // reserve space for the "more" tag + + const [, forceUpdate] = useReducer((x) => x + 1, 0); + + const debounceResetCutoff = useDebounce( + () => { + setCutoff(undefined); + // setting cutoff won't trigger a re-render if it's already undefined + // so we force a re-render to recalculate the cutoff + forceUpdate(); + }, + 100 // Adjust the debounce delay as needed + ); + + // trigger recalculation of cutoff when control resizes + useEffect(() => { + if (!truncateOnOverflow || !ref.current) { + return; + } + + const resizeObserver = new ResizeObserver(() => { + debounceResetCutoff(); + }); + + const { current } = ref; + resizeObserver.observe(current); + + return () => { + resizeObserver.disconnect(); + }; + }, [truncateOnOverflow, debounceResetCutoff]); + + // we need to check this on every render, and the call to setCutoff _should_ be safe + /* eslint-disable-next-line react-hooks/exhaustive-deps */ + useLayoutEffect(() => { + if (!truncateOnOverflow) { + setCutoff(undefined); + return; + } + + const { current } = ref; + + if (current) { + // calculate the number of tags that can fit in the container + const containerWidth = current.clientWidth; + const children = Array.from(current.children); + + // don't recalculate anything if the more tag is visible and cutoff is already set + const moreTags = children.find((child) => { + return (child as HTMLElement).classList.contains("more-tags"); + }); + + if (moreTags && !!cutoff) { + return; + } + + const childTags = children.filter((child) => { + return ( + (child as HTMLElement).classList.contains("tag-item") || + (child as HTMLElement).classList.contains("clear-all-button") + ); + }); + + const clearAllButton = children.find((child) => { + return (child as HTMLElement).classList.contains("clear-all-button"); + }); + + // calculate the total width without the more tag + const defaultTotalWidth = childTags.reduce((total, child, idx) => { + return ( + total + + ((child as HTMLElement).offsetWidth ?? 0) + + (idx === childTags.length - 1 ? 0 : elementGap) + ); + }, 0); + + if (containerWidth >= defaultTotalWidth) { + // if the container is wide enough to fit all tags, reset cutoff + setCutoff(undefined); + return; + } + + let totalWidth = 0; + let visibleCount = 0; + + // reserve space for the more tags control + totalWidth += moreTagWidth; + + // reserve space for the clear all button if present + if (clearAllButton) { + totalWidth += (clearAllButton as HTMLElement).offsetWidth ?? 0; + } + + for (const child of children) { + totalWidth += ((child as HTMLElement).offsetWidth ?? 0) + elementGap; + if (totalWidth > containerWidth) { + break; + } + visibleCount++; + } + + setCutoff(visibleCount); + } + }); function onRemoveCriterionTag( criterion: Criterion, @@ -72,7 +236,7 @@ export const FilterTags: React.FC = ({ onEditCriterion(criterion); } - function renderFilterTags(criterion: Criterion) { + function getFilterTags(criterion: Criterion) { if ( criterion instanceof CustomFieldsCriterion && criterion.value.length > 1 @@ -105,9 +269,34 @@ export const FilterTags: React.FC = ({ return null; } + const className = "wrap-tags filter-tags"; + + const filterTags = criteria.map((c) => getFilterTags(c)).flat(); + + if (cutoff && filterTags.length > cutoff) { + const visibleCriteria = filterTags.slice(0, cutoff); + const hiddenCriteria = filterTags.slice(cutoff); + + return ( +
+ {visibleCriteria} + + {criteria.length >= 3 && ( + + )} +
+ ); + } + return ( -
- {criteria.map(renderFilterTags)} +
+ {filterTags} {criteria.length >= 3 && (