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