From dd76e9dcf73130557e3159735df50f62fc86df12 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 22 Aug 2026 05:42:45 +0200 Subject: [PATCH] feat(feed): move sort + view into the chip row with responsive overflow Sort and the view switcher are tightly bound to the grid, so pin them to the right of the applied-filter row (directly above the cards) instead of the filter toolbar; they render there on both the feed and channel-scoped views. ActiveFilterChips now shares that row with the controls and trims itself so it never crowds them, measuring fit off a hidden mirror of the full chip set: - 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. Both popovers list the (overflowed / all) chips, each removable, plus Clear all. --- frontend/src/components/ActiveFilterChips.tsx | 265 ++++++++++++++++-- frontend/src/components/Feed.tsx | 120 ++++---- frontend/src/i18n/locales/en/feed.json | 2 + frontend/src/i18n/locales/hu/feed.json | 2 + 4 files changed, 308 insertions(+), 81 deletions(-) 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 (
- - {t("feed.chips.label")} - - {filters.map((f) => ( - - ))} - {filters.length > 1 && ( - + +
+ + {/* Visible lane. */} + {layout.mode === "count" ? ( + + ) : ( + <> + + {t("feed.chips.label")} + + {filters.slice(0, layout.visible).map((f) => ( + + ))} + {layout.mode === "some" && hiddenCount > 0 && ( + + )} + {layout.mode === "all" && n > 1 && ( + + )} + )} ); diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 5a122f70..a4253c7d 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -570,6 +570,54 @@ export default function Feed({ : SORT_DEFAULT_DIR[k as Exclude], })); + // Sort + view mode are tightly bound to the grid below, so they live pinned to the right of the + // applied-filter row (directly above the cards), not up in the filter toolbar. Extracted here so the + // same controls render on both the feed and channel-scoped rows. + const sortView = ( + <> + { + 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" ? ( + + ) : undefined + } + /> +