From fa271a31b9ae822f94813781da3da6b0373c53da Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 29 Oct 2026 00:38:34 +0100 Subject: [PATCH] fix(notifications): Mark all read waits for the fresh list, is not offered where it cannot act, and a row says it is unread The mask over a stale read lifted when the write settled, before the refetch landed, and the band took the rows back. A band holding only what asks by its kind (an access-requests nudge) left the button enabled with nothing to do. The unread dot was only drawn: the spoken label now says it, apart from the name Back finds the row by, and follows the row while it holds the focus. --- frontend/src/i18n/locales/en/inbox.json | 1 + frontend/src/i18n/locales/hu/inbox.json | 1 + frontend/src/lib/inboxOrder.ts | 2 +- frontend/src/lib/listNav.ts | 32 ++++++- .../NotificationsPanel.order.dom.test.tsx | 92 +++++++++++++++++++ .../notifications/NotificationsPanel.tsx | 28 ++++-- 6 files changed, 144 insertions(+), 12 deletions(-) diff --git a/frontend/src/i18n/locales/en/inbox.json b/frontend/src/i18n/locales/en/inbox.json index ca0d855b..76680a88 100644 --- a/frontend/src/i18n/locales/en/inbox.json +++ b/frontend/src/i18n/locales/en/inbox.json @@ -6,6 +6,7 @@ "clearConfirmTitle": "Clear every notification?", "clearConfirmBody": "Every notification here leaves the list, read or not. They don't come back.", "markRead": "Mark read", + "unreadName": "{{name}}, unread", "dismiss": "Dismiss", "rowMenu": "Notification actions", "menu": "Notifications actions", diff --git a/frontend/src/i18n/locales/hu/inbox.json b/frontend/src/i18n/locales/hu/inbox.json index 3b7b3f29..a50c51fc 100644 --- a/frontend/src/i18n/locales/hu/inbox.json +++ b/frontend/src/i18n/locales/hu/inbox.json @@ -6,6 +6,7 @@ "clearConfirmTitle": "Törlöd az összes értesítést?", "clearConfirmBody": "Minden itteni értesítés eltűnik a listából, olvasott és olvasatlan is. Nem hozhatók vissza.", "markRead": "Olvasottnak jelöl", + "unreadName": "{{name}}, olvasatlan", "dismiss": "Elrejtés", "rowMenu": "Értesítés műveletei", "menu": "Értesítések műveletei", diff --git a/frontend/src/lib/inboxOrder.ts b/frontend/src/lib/inboxOrder.ts index 262947eb..4fdfb65d 100644 --- a/frontend/src/lib/inboxOrder.ts +++ b/frontend/src/lib/inboxOrder.ts @@ -38,7 +38,7 @@ export const entryKey = (e: InboxEntry): string => keyOf(e.src, e.n.id); /** Whether a client notice asks for the reader by what it is: an access-requests nudge for as long * as it exists (its producer removes it when nothing is pending), a notice that waits on an * interaction until it is dealt with. */ -const clientAsks = (n: ClientNotif): boolean => +export const clientAsks = (n: ClientNotif): boolean => n.meta?.kind === "access-requests" || (n.requiresInteraction && !n.dismissed); export function orderInbox( diff --git a/frontend/src/lib/listNav.ts b/frontend/src/lib/listNav.ts index b2db0268..ed1b241e 100644 --- a/frontend/src/lib/listNav.ts +++ b/frontend/src/lib/listNav.ts @@ -42,6 +42,15 @@ const lists = new Map(); // The item we gave a tabindex to, so the next move can take it back — and whether we named it too. let roved: HTMLElement | null = null; let named = false; +// A named item's label follows its spoken state while it holds the focus: Mark read hands the +// focus to the row BEFORE the row reads read, and the label given then said "unread" until the +// keyboard moved on. +const relabel = + typeof MutationObserver === "undefined" + ? null + : new MutationObserver(() => { + if (named && roved) roved.setAttribute("aria-label", spokenName(roved)); + }); const INDEX = "data-nav-index"; const LIST = "data-nav-list"; @@ -56,14 +65,24 @@ const REVEAL_FRAMES = 10; * `inset` draws the keyboard ring INSIDE the item. The ring sits 2 px outside by default, which a * card with a gap round it has room for; an item flush against its neighbours (a log line) or against * the edge of a clipping card (a Plex playlist show header) does not: the ring ran past the list, or - * was clipped away entirely. A table row needs no flag: index.css insets every `tr` by its tag. */ + * was clipped away entirely. A table row needs no flag: index.css insets every `tr` by its tag. + * + * `label` is what is SPOKEN instead of `name`, when it carries a state the item can lose while the + * page is away (a notification's "unread"): `name` is also how Back finds the item again, and a + * name that changed with the state would miss it. */ export const navItemAttrs = ( index: number, name?: string | null, - { inset = false }: { inset?: boolean } = {}, -): { "data-nav-index": number; "data-nav-name"?: string; "data-nav-inset"?: "" } => ({ + { inset = false, label }: { inset?: boolean; label?: string } = {}, +): { + "data-nav-index": number; + "data-nav-name"?: string; + "data-nav-label"?: string; + "data-nav-inset"?: ""; +} => ({ [INDEX]: index, ...(name ? { "data-nav-name": name } : null), + ...(label ? { "data-nav-label": label } : null), ...(inset ? { "data-nav-inset": "" } : null), }); @@ -204,11 +223,15 @@ function itemName(el: HTMLElement): string { ).trim(); } +/** What a screen reader hears while the item holds the focus: its `label` when it gives one. */ +const spokenName = (el: HTMLElement): string => el.getAttribute("data-nav-label") || itemName(el); + function focusItem(el: HTMLElement, reveal = true): void { if (roved !== el) { if (named) roved?.removeAttribute("role"); if (named) roved?.removeAttribute("aria-label"); roved?.removeAttribute("tabindex"); + relabel?.disconnect(); roved = null; named = false; // An item that is a Tab stop in its own right (a native