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.
This commit is contained in:
2026-10-29 00:38:34 +01:00
parent 947efaba6e
commit fa271a31b9
6 changed files with 144 additions and 12 deletions
+1 -1
View File
@@ -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(
+28 -4
View File
@@ -42,6 +42,15 @@ const lists = new Map<HTMLElement, NavList>();
// 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 <button> tile, a role=button card with
@@ -219,7 +242,7 @@ function focusItem(el: HTMLElement, reveal = true): void {
// A card root is a plain <div>: focused, it has no role and no name, and a screen reader read it
// as an unlabelled run of text. While it holds the focus it is a group named after what Enter
// opens. A row (<tr>) keeps its own role.
const name = itemName(el);
const name = spokenName(el);
if (
el.tagName === "DIV" &&
name &&
@@ -229,6 +252,7 @@ function focusItem(el: HTMLElement, reveal = true): void {
el.setAttribute("role", "group");
el.setAttribute("aria-label", name);
named = true;
relabel?.observe(el, { attributeFilter: ["data-nav-label", "data-nav-name"] });
}
}
}