From d98e9c661847ca771a49d282fb3580c1d043c0d5 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 2 Jul 2025 16:34:40 +1000 Subject: [PATCH] Show filter tags in scene list toolbar (#5969) * Add filter tags to toolbar * Show overflow control if filter tags overflow * Remove second set of filter tags from top of page * Add border around filter area --- ui/v2.5/src/components/List/FilterTags.tsx | 199 +++++++++++++++++++- ui/v2.5/src/components/List/styles.scss | 17 +- ui/v2.5/src/components/Scenes/SceneList.tsx | 32 ++-- ui/v2.5/src/components/Scenes/styles.scss | 39 +++- ui/v2.5/src/components/Shared/styles.scss | 8 + ui/v2.5/src/locales/en-GB.json | 3 +- 6 files changed, 273 insertions(+), 25 deletions(-) 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 && ( + onRemoveAll()} + > + + + )} + + ); + } + return ( - - {criteria.map(renderFilterTags)} + + {filterTags} {criteria.length >= 3 && ( ; operations: IOperations[]; onToggleSidebar: () => void; + onEditCriterion: (c: Criterion) => void; + onRemoveCriterion: (criterion: Criterion, valueIndex?: number) => void; + onRemoveAllCriterion: () => void; onSelectAll: () => void; onSelectNone: () => void; onEdit: () => void; @@ -328,11 +332,14 @@ const ListToolbarContent: React.FC<{ onPlay: () => void; onCreateNew: () => void; }> = ({ - criteriaCount, + criteria, items, selectedIds, operations, onToggleSidebar, + onEditCriterion, + onRemoveCriterion, + onRemoveAllCriterion, onSelectAll, onSelectNone, onEdit, @@ -350,9 +357,16 @@ const ListToolbarContent: React.FC<{ onToggleSidebar()} - count={criteriaCount} + count={criteria.length} title={intl.formatMessage({ id: "actions.sidebar.toggle" })} /> + )} {hasSelection && ( @@ -730,11 +744,14 @@ export const FilteredSceneList = (props: IFilteredScenes) => { })} > setShowSidebar(!showSidebar)} + onEditCriterion={(c) => showEditFilter(c.criterionOption.type)} + onRemoveCriterion={removeCriterion} + onRemoveAllCriterion={() => clearAllCriteria()} onSelectAll={() => onSelectAll()} onSelectNone={() => onSelectNone()} onEdit={onEdit} @@ -752,13 +769,6 @@ export const FilteredSceneList = (props: IFilteredScenes) => { onChangeFilter={(newFilter) => setFilter(newFilter)} /> - showEditFilter(c.criterionOption.type)} - onRemoveCriterion={removeCriterion} - onRemoveAll={() => clearAllCriteria()} - /> - div:first-child { + border: 1px solid $secondary; + border-radius: 0.25rem; + flex-grow: 1; + overflow-x: hidden; + + .filter-button { + border-bottom-right-radius: 0; + border-top-right-radius: 0; + } + } + + .filter-tags { + flex-grow: 1; + flex-wrap: nowrap; + justify-content: flex-start; + margin-bottom: 0; + width: calc(100% - 35px - 0.5rem); + + @include media-breakpoint-down(xs) { + overflow-x: auto; + scrollbar-width: thin; + } + + .tag-item { + white-space: nowrap; + } + } } .scene-list-header { @@ -1092,3 +1121,9 @@ input[type="range"].blue-slider { top: 0; } } + +#more-criteria-popover { + box-shadow: 0 8px 10px 2px rgb(0 0 0 / 30%); + max-width: 400px; + padding: 0.25rem; +} diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index b3726cd4f..10e381bd8 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -788,6 +788,14 @@ $sidebar-width: 250px; margin-left: -$sidebar-width; } + &.hide-sidebar .sidebar + div { + width: 100%; + } + + &:not(.hide-sidebar) .sidebar + div { + width: calc(100% - $sidebar-width); + } + > :nth-child(2) { flex-grow: 1; padding-left: 0.5rem; diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 8095870cb..bd6308425 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1317,7 +1317,8 @@ "edit_filter": "Edit Filter", "name": "Filter", "saved_filters": "Saved filters", - "update_filter": "Update Filter" + "update_filter": "Update Filter", + "more_filter_criteria": "+{count} more" }, "second": "Second", "seconds": "Seconds",