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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user