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
This commit is contained in:
WithoutPants 2025-07-02 16:34:40 +10:00 committed by GitHub
parent f01f95ddfb
commit d98e9c6618
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 273 additions and 25 deletions

View file

@ -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 (
<>
<Overlay target={target.current} placement="bottom" show={showTooltip}>
<Popover
id="more-criteria-popover"
className="hover-popover-content"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={handleMouseLeave}
>
{tags}
</Popover>
</Overlay>
<Badge
ref={target}
className={"tag-item more-tags"}
variant="secondary"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<FormattedMessage
id="search_filter.more_filter_criteria"
values={{ count: tags.length }}
/>
</Badge>
</>
);
};
interface IFilterTagsProps {
criteria: Criterion[];
onEditCriterion: (c: Criterion) => void;
onRemoveCriterion: (c: Criterion, valueIndex?: number) => void;
onRemoveAll: () => void;
truncateOnOverflow?: boolean;
}
export const FilterTags: React.FC<IFilterTagsProps> = ({
@ -53,8 +108,117 @@ export const FilterTags: React.FC<IFilterTagsProps> = ({
onEditCriterion,
onRemoveCriterion,
onRemoveAll,
truncateOnOverflow = false,
}) => {
const intl = useIntl();
const ref = useRef<HTMLDivElement>(null);
const [cutoff, setCutoff] = React.useState<number | undefined>();
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<IFilterTagsProps> = ({
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<IFilterTagsProps> = ({
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 (
<div className="wrap-tags filter-tags">
{criteria.map(renderFilterTags)}
<div className={className} ref={ref}>
{visibleCriteria}
<MoreFilterTags tags={hiddenCriteria} />
{criteria.length >= 3 && (
<Button
variant="minimal"
className="clear-all-button"
onClick={() => onRemoveAll()}
>
<FormattedMessage id="actions.clear" />
</Button>
)}
</div>
);
}
return (
<div className={className} ref={ref}>
{filterTags}
{criteria.length >= 3 && (
<Button
variant="minimal"

View file

@ -416,13 +416,18 @@ input[type="range"].zoom-slider {
display: flex;
justify-content: center;
margin-bottom: 0.5rem;
}
.filter-tags .clear-all-button {
.more-tags {
background-color: transparent;
color: #fff;
}
.clear-all-button {
color: $text-color;
// to match filter pills
line-height: 16px;
padding: 0;
}
}
.filter-button {

View file

@ -62,6 +62,7 @@ import { FilterButton } from "../List/Filters/FilterButton";
import { Icon } from "../Shared/Icon";
import { ListViewOptions } from "../List/ListViewOptions";
import { PageSizeSelector, SortBySelect } from "../List/ListFilter";
import { Criterion } from "src/models/list-filter/criteria/criterion";
function renderMetadataByline(result: GQL.FindScenesQueryResult) {
const duration = result?.data?.findScenes?.duration;
@ -316,11 +317,14 @@ interface IOperations {
}
const ListToolbarContent: React.FC<{
criteriaCount: number;
criteria: Criterion[];
items: GQL.SlimSceneDataFragment[];
selectedIds: Set<string>;
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<{
<div>
<FilterButton
onClick={() => onToggleSidebar()}
count={criteriaCount}
count={criteria.length}
title={intl.formatMessage({ id: "actions.sidebar.toggle" })}
/>
<FilterTags
criteria={criteria}
onEditCriterion={onEditCriterion}
onRemoveCriterion={onRemoveCriterion}
onRemoveAll={onRemoveAllCriterion}
truncateOnOverflow
/>
</div>
)}
{hasSelection && (
@ -730,11 +744,14 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
})}
>
<ListToolbarContent
criteriaCount={filter.count()}
criteria={filter.criteria}
items={items}
selectedIds={selectedIds}
operations={otherOperations}
onToggleSidebar={() => 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)}
/>
<FilterTags
criteria={filter.criteria}
onEditCriterion={(c) => showEditFilter(c.criterionOption.type)}
onRemoveCriterion={removeCriterion}
onRemoveAll={() => clearAllCriteria()}
/>
<LoadedContent loading={result.loading} error={result.error}>
<SceneList
filter={effectiveFilter}

View file

@ -1020,13 +1020,13 @@ input[type="range"].blue-slider {
&:last-child {
flex-shrink: 0;
justify-content: flex-end;
margin-left: auto;
}
}
}
.scene-list-toolbar {
flex-wrap: wrap;
flex-wrap: nowrap;
gap: 1rem;
// offset the main padding
margin-top: -0.5rem;
padding-bottom: 0.5rem;
@ -1062,6 +1062,35 @@ input[type="range"].blue-slider {
display: none;
}
}
> 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;
}

View file

@ -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;

View file

@ -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",