fix(feed): deep-review remediations for the filter-bar overhaul

Correctness / behaviour:
- Move the Personal Views modal from inside the collapsible filter rail to the
  feed page (moduleRegistry FeedPage), so opening it from the always-visible
  toolbar view chip works while the rail is unpinned (its default) instead of
  no-op'ing and latching modalOpen into a later stale pop-open.
- Migrate a persisted legacy global `tagMode` into per-category `tagModes` on the
  localStorage seed paths too (loadAccountFilters), matching the saved-view and
  URL paths — an upgrading user's topic any/all no longer silently reverts to 'or'.
- DateFilterSelect: reset the custom reveal on an external clear while the popover
  is closed (guard on !open, not maxAgeDays), so the trigger reverts to "Any time"
  instead of lingering on "Custom range" with empty fields.
- PersonalViewsModal delete: only advance the detail selection when the delete
  actually happened (remove() now resolves a boolean) — cancelling the confirm no
  longer switches the selected view.
- SavedViewsProvider: prefer the explicitly-applied (sticky) view among several
  that capture identical filters, so apply(B) isn't re-attributed to an earlier
  duplicate A (wrong "modified" chip name).

A11y:
- Give the clickable panel title a descriptive accessible name (titleActionLabel,
  wired to "Personal views") instead of announcing only "Filters".
- Give the "+N" overflow pill an aria-label ("N more filters").
- Use t("views.reorder") for the saved-view drag handle instead of a hardcoded
  English "reorder" literal.

Also extract the chip-fit decision into a pure, testable lib/chipOverflow.ts and
measure the inter-chip gap live (rem-safe) instead of a frozen 6px constant.
This commit is contained in:
2026-08-22 06:33:48 +02:00
parent dd76e9dcf7
commit e90603d4db
12 changed files with 156 additions and 66 deletions
+10 -7
View File
@@ -59,7 +59,8 @@ export interface SavedViewsContext {
create: (name: string) => Promise<boolean>;
rename: (id: number, name: string) => Promise<boolean>;
toggleDefault: (v: SavedView) => Promise<void>;
remove: (v: SavedView) => Promise<void>;
/** Delete a view after confirming; resolves true only when it was actually deleted (false on cancel/fail). */
remove: (v: SavedView) => Promise<boolean>;
reorder: (nextIds: number[]) => Promise<void>;
}
@@ -84,10 +85,12 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
}, [viewsQuery.data]);
const currentKey = keyOf(filters);
const activeView = useMemo(
() => views.find((v) => keyOf(v.filters) === currentKey) ?? null,
[views, currentKey],
);
const activeView = useMemo(() => {
const matches = views.filter((v) => keyOf(v.filters) === currentKey);
// When several views capture identical filters, prefer the one the user explicitly applied
// (sticky) so apply(B) isn't silently re-attributed to an earlier duplicate A; else the first.
return matches.find((v) => v.id === stickyId) ?? matches[0] ?? null;
}, [views, currentKey, stickyId]);
// An exact match becomes the sticky selection, so filters that happen to equal a view (deep-link,
// manual, or an applied view) name that view in the chip and keep naming it after a later tweak.
useEffect(() => {
@@ -151,9 +154,9 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
confirmLabel: t("views.delete"),
danger: true,
});
if (!ok) return;
if (!ok) return false;
if (stickyId === v.id) setStickyId(null);
await runViewMutation(() => api.deleteView(v.id), t("views.actionFailed"));
return runViewMutation(() => api.deleteView(v.id), t("views.actionFailed"));
},
reorder: async (nextIds) => {
// Optimistic reorder so the drop sticks visually before the round-trip; the refetch reverts to