diff --git a/frontend/src/components/ActiveFilterChips.tsx b/frontend/src/components/ActiveFilterChips.tsx
index 5268a59c..a90446ad 100644
--- a/frontend/src/components/ActiveFilterChips.tsx
+++ b/frontend/src/components/ActiveFilterChips.tsx
@@ -1,51 +1,262 @@
+import { useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
-import { X } from "lucide-react";
+import { Filter, X } from "lucide-react";
import type { ActiveFilter } from "../lib/useActiveFilters";
+import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
// The applied-filter row above the feed: every narrowing currently in force, each removable on its
// own. It exists because the filter panel now sits collapsed as a tab — without this you'd have to
// open the panel just to see what you'd filtered by.
+//
+// The row is a single flex line that shares its width with the Sort/View controls pinned to its right,
+// so the chips must never push those off-screen. When they'd overflow it trims responsively:
+// - wide -> show as many chips as fit, then a "+N" pill whose popover holds the rest;
+// - narrow -> collapse every chip into one "N filters" pill whose popover holds them all.
+// Fit is measured off a hidden mirror of the full chip set (so slicing the visible lane can't feed
+// back into the measurement and oscillate).
+
+// Below this lane width an overflow collapses all the way to a single count pill (mobile), instead of
+// the "fit some + N more" treatment used on wider lanes.
+const NARROW_PX = 420;
+const GAP_PX = 6; // matches gap-1.5 (px)
+
+const chipCls =
+ "chip group inline-flex items-center gap-1 pl-2.5 pr-1.5 py-1 text-sm whitespace-nowrap hover:border-accent hover:bg-accent/20 transition";
+
+function ChipButton({ f, testId }: { f: ActiveFilter; testId?: string }) {
+ const { t } = useTranslation();
+ return (
+
+ );
+}
+
+// The "+N" / "N filters" pill and its popover listing the (overflowed / all) chips, each removable.
+function OverflowPopover({
+ label,
+ chips,
+ onClearAll,
+ icon,
+}: {
+ label: string;
+ chips: ActiveFilter[];
+ onClearAll: () => void;
+ icon?: boolean;
+}) {
+ const { t } = useTranslation();
+ const [open, setOpen] = useState(false);
+ const pop = useAnchoredPopover({
+ open,
+ onOpenChange: setOpen,
+ mode: "menu",
+ placement: "bottom-end",
+ });
+ return (
+ <>
+
+
+
+ {chips.map((f) => (
+
+ {f.label}
+
+
+ ))}
+
+
+
+
+
+ >
+ );
+}
+
export default function ActiveFilterChips({
filters,
onClearAll,
+ className,
}: {
filters: ActiveFilter[];
onClearAll: () => void;
+ className?: string;
}) {
const { t } = useTranslation();
- if (filters.length === 0) return null;
+ const laneRef = useRef(null);
+ const labelRef = useRef(null);
+ const chipRefs = useRef>([]);
+ const plusRef = useRef(null);
+ const clearRef = useRef(null);
+ // How the row is currently laid out: all chips, a fit-count + "+N", or a single count pill.
+ const [layout, setLayout] = useState<{ mode: "all" | "some" | "count"; visible: number }>({
+ mode: "all",
+ visible: filters.length,
+ });
+
+ const n = filters.length;
+ // Re-measure whenever the chip set changes (labels or count) — captures both in one dep.
+ const chipsKey = filters.map((f) => f.key + f.label).join("|");
+
+ useLayoutEffect(() => {
+ const lane = laneRef.current;
+ const labelEl = labelRef.current;
+ if (!lane || !labelEl || n === 0) return;
+
+ const measure = () => {
+ const avail = lane.clientWidth;
+ const rightOf = (el: HTMLElement | null | undefined) =>
+ el ? el.offsetLeft + el.offsetWidth : 0;
+ const labelRight = rightOf(labelEl);
+ const chips = chipRefs.current.slice(0, n);
+ const plusW = plusRef.current?.offsetWidth ?? 0;
+ // "all" mode also renders a Clear all button after the chips (when n > 1), so reserve its width
+ // in the fit-all check — otherwise it spills past the lane into the Sort/View controls.
+ const clearW = n > 1 ? GAP_PX + (clearRef.current?.offsetWidth ?? 0) : 0;
+
+ // Do all chips (plus the Clear all button) fit? (last chip's right edge within the lane.)
+ const allRight = rightOf(chips[n - 1]);
+ if (allRight + clearW <= avail) {
+ setLayout({ mode: "all", visible: n });
+ return;
+ }
+ // Overflow. On a narrow lane, collapse everything into one count pill.
+ if (avail < NARROW_PX) {
+ setLayout({ mode: "count", visible: 0 });
+ return;
+ }
+ // Otherwise fit as many chips as leave room for the "+N" pill after them.
+ let k = 0;
+ for (let i = 0; i < n; i++) {
+ const right = rightOf(chips[i]);
+ if (right + GAP_PX + plusW <= avail) k = i + 1;
+ else break;
+ }
+ // If not even one chip fits beside the pill, fall back to the count pill.
+ if (k === 0 && labelRight + GAP_PX + plusW > avail) {
+ setLayout({ mode: "count", visible: 0 });
+ return;
+ }
+ setLayout({ mode: "some", visible: k });
+ };
+
+ measure();
+ const ro = new ResizeObserver(measure);
+ ro.observe(lane);
+ return () => ro.disconnect();
+ }, [n, chipsKey]);
+
+ if (n === 0) return null;
+
+ const hiddenCount = n - layout.visible;
+ const overflowChips = filters.slice(layout.visible);
return (
- {
- if (!next) return; // the feed is always sorted — no "no sort" option is offered
- setFilters({
- ...filters,
- sort: buildSort(next.key as SortKey, next.dir),
- seed: next.key === "shuffle" ? rollSeed() : undefined,
- });
- }}
- label={t("feed.sortLabel")}
- ascLabel={t("feed.dirAsc")}
- descLabel={t("feed.dirDesc")}
- trailing={
- sortKey === "shuffle" ? (
- setFilters({ ...filters, seed: rollSeed() })}
- title={t("sidebar.reshuffle")}
- aria-label={t("sidebar.reshuffle")}
- className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
- >
-
-
- ) : undefined
- }
- />
-
- ({
- id,
- label: t("feed.view." + id),
- icon: VIEW_ICON[id],
- }))}
- onChange={setView}
- label={t("feed.viewLabel")}
- // This toolbar is packed — show/content chips, source, count, sort — so the mode's name
- // only earns its place once the window is wide.
- labelClassName="hidden xl:inline"
- />
-
);
@@ -677,17 +682,24 @@ export default function Feed({
{toolbar}
- {!channelScoped && (
-
- {/* The active personal view (if any) leads the applied-filter row; ActiveFilterChips
- brings the row's own bottom padding. */}
-
- setFilters(clearedFilters(filters))}
- />
-
- )}
+ {/* The controls row sits directly above the grid: the applied-filter chips flow from the left
+ (overflowing to a "+N"/count pill so they never crowd the controls), while Sort + View stay
+ pinned to the right. Channel-scoped feeds show no chips (their baseline isn't a "filter you
+ applied"), but still get the right-pinned Sort/View. */}
+
+ {!channelScoped && (
+ <>
+ {/* The active personal view (if any) leads the applied-filter row. */}
+
+ setFilters(clearedFilters(filters))}
+ className="flex-1"
+ />
+ >
+ )}
+