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.
This commit is contained in:
2026-08-22 05:42:45 +02:00
parent fca989c098
commit dd76e9dcf7
4 changed files with 308 additions and 81 deletions
+238 -27
View File
@@ -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 (
<button
data-testid={testId}
data-filter-key={f.key}
onClick={f.remove}
title={t("feed.chips.remove", { name: f.label })}
aria-label={t("feed.chips.remove", { name: f.label })}
className={chipCls}
>
<span className="max-w-56 truncate">{f.label}</span>
<X className="w-3 h-3 shrink-0 opacity-60 group-hover:opacity-100" />
</button>
);
}
// 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 (
<>
<button
type="button"
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
className={`chip inline-flex shrink-0 items-center gap-1 px-2.5 py-1 text-sm whitespace-nowrap transition ${
open ? "border-accent text-accent" : "hover:border-accent"
}`}
>
{icon && <Filter className="w-3.5 h-3.5 shrink-0" aria-hidden="true" />}
{label}
</button>
<PopoverSurface
popover={pop}
ariaLabel={label}
className="glass-menu min-w-48 max-w-72 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
>
<div role="group" aria-label={label} className="max-h-72 overflow-y-auto no-scrollbar">
{chips.map((f) => (
<PopoverItem
key={f.key}
role="menuitem"
onSelect={f.remove}
label={f.label}
className="group flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-fg transition"
>
<span className="flex-1 truncate">{f.label}</span>
<X className="w-3.5 h-3.5 shrink-0 opacity-60 group-hover:opacity-100" />
</PopoverItem>
))}
</div>
<div className="mt-1 border-t border-border px-1 pt-1.5">
<button
type="button"
data-testid="active-filter-clear-all"
onClick={() => {
onClearAll();
setOpen(false);
}}
className="text-xs text-muted hover:text-fg underline underline-offset-2 transition"
>
{t("sidebar.clearAll")}
</button>
</div>
</PopoverSurface>
</>
);
}
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<HTMLDivElement | null>(null);
const labelRef = useRef<HTMLSpanElement | null>(null);
const chipRefs = useRef<Array<HTMLSpanElement | null>>([]);
const plusRef = useRef<HTMLSpanElement | null>(null);
const clearRef = useRef<HTMLSpanElement | null>(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 (
<div
ref={laneRef}
data-testid="active-filter-chips"
className="flex flex-wrap items-center gap-1.5 pb-3"
className={`relative flex min-w-0 items-center gap-1.5 ${className ?? ""}`}
aria-label={t("feed.chips.label")}
>
<span className="text-xs uppercase tracking-wider text-muted mr-0.5">
{t("feed.chips.label")}
</span>
{filters.map((f) => (
<button
key={f.key}
data-testid="active-filter-chip"
data-filter-key={f.key}
onClick={f.remove}
title={t("feed.chips.remove", { name: f.label })}
aria-label={t("feed.chips.remove", { name: f.label })}
className="chip group pl-2.5 pr-1.5 py-1 text-sm hover:border-accent hover:bg-accent/20 transition"
>
<span className="max-w-56 truncate">{f.label}</span>
<X className="w-3 h-3 shrink-0 opacity-60 group-hover:opacity-100" />
</button>
))}
{filters.length > 1 && (
<button
data-testid="active-filter-clear-all"
onClick={onClearAll}
className="ml-0.5 text-sm text-muted hover:text-fg underline underline-offset-2 transition"
>
{/* Hidden mirror of the full set, used only to measure natural widths. Kept out of the a11y tree
and without test ids so it never doubles the visible chip count. */}
<div
aria-hidden="true"
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1.5"
>
<span ref={labelRef} className="text-xs uppercase tracking-wider text-muted mr-0.5">
{t("feed.chips.label")}
</span>
{filters.map((f, i) => (
<span
key={f.key}
ref={(el) => {
chipRefs.current[i] = el;
}}
className={chipCls}
>
<span className="max-w-56 truncate">{f.label}</span>
<X className="w-3 h-3 shrink-0" />
</span>
))}
<span ref={plusRef} className="chip inline-flex items-center gap-1 px-2.5 py-1 text-sm">
+{n}
</span>
<span ref={clearRef} className="ml-0.5 text-sm underline underline-offset-2">
{t("sidebar.clearAll")}
</button>
</span>
</div>
{/* Visible lane. */}
{layout.mode === "count" ? (
<OverflowPopover
label={t("feed.chips.count", { count: n })}
chips={filters}
onClearAll={onClearAll}
icon
/>
) : (
<>
<span className="text-xs uppercase tracking-wider text-muted mr-0.5 shrink-0">
{t("feed.chips.label")}
</span>
{filters.slice(0, layout.visible).map((f) => (
<ChipButton key={f.key} f={f} testId="active-filter-chip" />
))}
{layout.mode === "some" && hiddenCount > 0 && (
<OverflowPopover
label={`+${hiddenCount}`}
chips={overflowChips}
onClearAll={onClearAll}
/>
)}
{layout.mode === "all" && n > 1 && (
<button
data-testid="active-filter-clear-all"
onClick={onClearAll}
className="ml-0.5 shrink-0 text-sm text-muted hover:text-fg underline underline-offset-2 transition"
>
{t("sidebar.clearAll")}
</button>
)}
</>
)}
</div>
);
+66 -54
View File
@@ -570,6 +570,54 @@ export default function Feed({
: SORT_DEFAULT_DIR[k as Exclude<SortKey, "shuffle" | "relevance">],
}));
// 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 = (
<>
<SortControl
options={sortOptions}
sort={{ key: sortKey, dir: sortDir }}
onChange={(next) => {
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" ? (
<button
onClick={() => 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"
>
<RefreshCw className="w-4 h-4" />
</button>
) : undefined
}
/>
<span className="mx-0.5 h-5 w-px bg-border" aria-hidden="true" />
<ViewSwitcher
value={view}
options={FEED_VIEWS.map((id) => ({
id,
label: t("feed.view." + id),
icon: VIEW_ICON[id],
}))}
onChange={setView}
label={t("feed.viewLabel")}
// The mode's name only earns its place once the window is wide.
labelClassName="hidden xl:inline"
/>
</>
);
const toolbar = (
<div className="pb-1.5">
<div className="flex items-center gap-1.5 gap-y-2 flex-wrap">
@@ -619,49 +667,6 @@ export default function Feed({
}
/>
)}
<div className="ml-auto flex items-center gap-1.5">
<SortControl
options={sortOptions}
sort={{ key: sortKey, dir: sortDir }}
onChange={(next) => {
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" ? (
<button
onClick={() => 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"
>
<RefreshCw className="w-4 h-4" />
</button>
) : undefined
}
/>
<span className="mx-0.5 h-5 w-px bg-border" aria-hidden="true" />
<ViewSwitcher
value={view}
options={FEED_VIEWS.map((id) => ({
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"
/>
</div>
</div>
</div>
);
@@ -677,17 +682,24 @@ export default function Feed({
<PageToolbar>
<div className={channelScoped ? "px-4 sm:px-6" : "px-4 pt-2 pb-1"}>
{toolbar}
{!channelScoped && (
<div className="flex flex-wrap items-start gap-2">
{/* The active personal view (if any) leads the applied-filter row; ActiveFilterChips
brings the row's own bottom padding. */}
<SelectedViewChip />
<ActiveFilterChips
filters={activeFilters}
onClearAll={() => setFilters(clearedFilters(filters))}
/>
</div>
)}
{/* 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. */}
<div className="flex items-center gap-2 pb-2">
{!channelScoped && (
<>
{/* The active personal view (if any) leads the applied-filter row. */}
<SelectedViewChip />
<ActiveFilterChips
filters={activeFilters}
onClearAll={() => setFilters(clearedFilters(filters))}
className="flex-1"
/>
</>
)}
<div className="ml-auto flex shrink-0 items-center gap-1.5">{sortView}</div>
</div>
</div>
</PageToolbar>
{items.length === 0 ? (
+2
View File
@@ -3,6 +3,8 @@
"label": "Filtered by",
"remove": "Remove filter: {{name}}",
"contentNone": "No content types",
"count_one": "{{count}} filter",
"count_other": "{{count}} filters",
"maxAge_one": "Last {{count}} day",
"maxAge_other": "Last {{count}} days"
},
+2
View File
@@ -3,6 +3,8 @@
"label": "Szűrve",
"remove": "Szűrő eltávolítása: {{name}}",
"contentNone": "Nincs tartalomtípus",
"count_one": "{{count}} szűrő",
"count_other": "{{count}} szűrő",
"maxAge_one": "Utolsó {{count}} nap",
"maxAge_other": "Utolsó {{count}} nap"
},