Compare commits

..
17 Commits
Author SHA1 Message Date
peter ca5e446fbd release: 0.85.0 (feature/e11-power-navigation)
gate / backend (push) Successful in 4m36s
gate / frontend (push) Failing after 3m13s
2026-09-13 21:02:22 +02:00
peter 4c7ab12a3e release: 0.85.0 — E11 Power Navigation
Back, dialogs and menus keep your place, and the keyboard walks every list: five sprints plus three epic-end review rounds (19 → 14, 18 → 12, 14 → 14). Minor: j/k list navigation, s and g-chords, and Back-to-item are features.
2026-09-13 21:02:22 +02:00
peter c6b552e75f fix(keys): epic-end review round 3 — the part-way clip count shows, busy buttons keep focus, cards say their names
Targeted round over 3cd0c525's fix surfaces: 14 raised, 14 fixed.

- VideoEditor: the "only X of Y clips" toast went silent exactly on the job-cap/quota refusal it was
  written for (req had already reported the error); it now always shows. The cut list holds still while
  Create queues, and a retry continues the "(i/N)" series.
- ChannelPage: Subscribe/Unsubscribe use Button `loading`, "Load more" and ChannelDiscovery's Subscribe
  block the click with aria-disabled instead of native disabled; busy Block/Hide no longer light up on
  hover.
- listNav: an item can name itself (`navItemAttrs(i, name)`) — Plex posters, seasons, episodes, message
  rows and download rows pass their title; focus on a toast's Undo keeps the removed card's slot.
- AnchoredPopover: the hand-back only for focus-managed surfaces (a hover card no longer pulls focus
  back); the dead panel-ref check is gone.
- AccountPanel: no confirmation mail offered to the demo account; send_failed copy no longer blames
  the message for an unreachable server. Pager comment points at scrollParent.
2026-09-13 20:47:13 +02:00
peter 3cd0c525e2 fix(keys): epic-end review round 2 — focus comes back to the card, the right rail and the reason
Whole-epic deep-review of E11 (18 raised, 12 kept; B and G refuted, four latent test-hardening notes dropped),
plus the admin refusal copy gap round 1 surfaced.

- A context menu opened from a card the keyboard is on gives the focus back to the card. floating-ui returns
  focus through the opener's first tabbable descendant even when handed a ref, so for a non-tabbable opener the
  surface restores it itself (measured in Chrome: j, Shift+F10, Escape left it on the thumbnail link).
- Back finds a named list (a rail) by its name: a rail that appeared above it after the cache expired sent the
  focus into the wrong rail. An unnamed list is matched only when the page has the same number of lists.
- The slot kept for a removed item is forgotten once focus moves on to page content (search, a chip), so j after
  new results starts at the first visible item; a menu or dialog opened from the item keeps it.
- The list keys reach the cheat sheet only while a list has an item.
- A j-focused card root is a named group while it holds the focus.
- The channel page's Block and Hide toggles keep the focus while their request runs.
- A split export that fails part-way says how many clips were added and deselects them, so a retry adds only
  the rest.
- Config save, the one-click share link and "log out other sessions" report failures with their reason.
- VirtualGrid uses the one scroll-parent walk listNav measures against.
- Admin refusals (activate, resend, invite, set-password link) say why in HU and EN instead of "try again";
  the email-off special cases collapse into that one reason. The contract reads the codes from localizeDetail.
  HU copy points to Konfiguráció → E-mail / SMTP; HU "unreachable" matches the EN change of round 1.
2026-09-13 19:18:04 +02:00
peter f130a1c05a fix(keys): epic-end review round 1 — Back, menus and dialogs keep the focus where it belongs
Deep-review of E11 (19 raised, 14 confirmed; 2 refuted, 3 latent test-hardening notes dropped).

- Back from a sub-view (a Plex show page, a Messages thread) restores the item it was opened from
  even when focus was still inside the view: the first attempt now waits one task, past the
  sub-view's own popstate listener (measured in Chrome: it left focus on <body>).
- The restore listener is installed at load, so the first Back after a reload finds it; a push
  cancels a restore still waiting for the last landing's lists.
- Navigating over an overlay asks it again, a render apart, while its entry stays: the Plex
  player peels one panel per Back step, so Shift+W over info + stats closed one panel and dropped
  the navigation.
- j/k: a native <button> item keeps its own Tab stop; landing twice on the same item no longer
  leaks its tabindex; after the focused item is removed (a hidden card) the next j goes on from
  its slot when that slot is on screen.
- An abandoned `g` no longer swallows a modifier chord (g, then Mod+K opened the browser search).
- A hover card above a dialog no longer switches its Tab trap off (player, Modal, wizard,
  Lightbox): new layerStack `anyBlockingLayerAbove`.
- "Manage views…" closes its menu before opening the dialog, through a shared `closeThenAct`
  that ActionMenu now uses too.
- Copy: the EN welcome says "Feed"; the EN unreachable reason no longer claims a change;
  stale comments about PlayerModal's scroll lock and dialog contract.
2026-09-13 17:17:49 +02:00
peter bbd31c7143 feat(keys): Back returns focus to the item a view was opened from; "Feed" everywhere; every backend id named
Owner answers to the E11 S5 raises (2026-09-13): 1 → A, 2 → "Feed" in every language, 3 → translate the
proxy label and fix every config key missing in BOTH languages ("maintenance", "tmdb" — old bugs).

Focus across a view change (1-A). Opening a Plex title from Home's rail, a channel from the Channels
table or a title from a Plex grid switches the page or the sub-view, which unmounts the list; Back used
to hand focus to <body>, so the next `j` started over at the top. The YouTube player (an overlay)
already restored onto the card.
- `lib/listNav`: `navPosition()` (list's place in reading order, the page's list count, item index)
  and `restoreNavPosition()`: focuses the item once the page has as many lists on screen as when it was
  left, polls on a timer (a hidden tab runs no animation frames), gives up after 2 s or at once when
  something already holds focus, focuses without scrolling (the scroll restore owns the position).
- `lib/history`: `rememberNavPosition()` stamps `sfNav` onto the entry being left and returns the state
  without it (a new entry must not inherit it); one popstate listener restores and spends the stamp,
  so a later landing on the same entry (an overlay closing over it) cannot pull focus to an old item.
- Called before every view push: `setPage`, `openChannel`, `enterYtSearch`, `useHistorySubview.open`,
  the Channels tab push. `navPosition.wiring.contract.test` fails on any `history.pushState(` without
  it (the overlay stack's own push is the one exemption).

"Feed" (2). The module is "Feed" in both languages: every "hírfolyam" in the Hungarian UI (≈45 strings,
suffixed: Feedből, Feeded, Feedben…) and the prose "feed" in English that means the module ("your
Feed", "Hide from Feed"); "RSS feed" keeps its meaning. The welcome email says it too.

Backend ids without a name (3). The Configuration page showed `maintenance`, `tmdb`,
`maintenance_revalidate_batch`, `download_upload_max_bytes`, `audit_retention_days`, `plex_overlay_map`,
`tmdb_api_key` and `tmdb_language` as raw identifiers — the lookup falls back to the id, and the i18n
parity test stayed green because both languages missed the same keys. Added labels + hints (EN/HU),
the two groups (and their tab order), the missing `scheduler.jobDesc.demo_reset`, removed the stale
`plex_sync_interval_min` field, and "YouTube API kimenő proxy". `i18n/backendIds.contract.test` reads
`sysconfig.SPECS`, `scheduler.JOB_INTERVALS` and the audit/quota action enums from the backend sources
and fails on an id without its name in either language, or a name left for an id the backend dropped.
2026-09-13 07:36:41 +02:00
peter 8731508645 feat(keys): the conversation list walks with j/k; the cheat sheet names keys in the reader's language
E11 S5 — the HU/EN sweep and the keyboard-only pass over every module.

- The keyboard cheat sheet printed "Space" and "Menu" on a Hungarian sheet: a keycap that carries a
  WORD (not a legend every layout prints alike, like Esc, Ctrl or Enter) is now named by the caller in
  the reader's language (`formatChord(chord, words)`, `keys.cap.*`: Szóköz / Menü).
- The Plex player's ⌫/Esc line said "Stop & back to feed", but the player goes back to wherever it
  was opened from (Home, a Plex page): "Stop & go back" / "Leállítás és vissza".
- Messages: the conversation list is a keyboard list (NavGrid + navItemAttrs, the name button is the
  open element; the pop-out button keeps its own Enter). D1 covers every list whose rows open one
  thing; this one was missed in S4. Notification cards have no single open action and stay out, like
  the download queue.

Sweep, measured: every static `t("…")` key used in the source exists in both languages (the only
misses are template prefixes); the HU values equal to EN are proper nouns and loanwords (Admin, Plex,
Trakt, Shorts). Palette in Hungarian: "téma" makes "Téma: Midnight" the active row ahead of the channel
"Topik - mindig témában", live region "7 találat".

Keyboard-only pass in Chrome, Hungarian UI — 1024×768: every `g` chord lands on its module, `j` rings
the first item (solid 2px accent) on Home, Feed, Channels, Playlists, Plex and Downloads, `s` focuses
the search on Feed, Channels, Playlists and Plex, no page scrolls sideways. 3440×1440: `j` → → ↓
walks Home, Feed (8 columns: 0 → 1 → 9), Channels, Playlists, Plex and Downloads, no sideways scroll.
The help sheet lists every line translated; inside the Plex player "Szóköz / K" and "⌫ / Esc
Leállítás és vissza". Messages: a real `j` rings the row, Enter opens the thread.
2026-09-13 07:00:51 +02:00
peter b5d37ca142 feat(keys): j/k and the arrows walk the channel, playlist and download lists
Third and last slice of the E11 keyboard layer (S4c). S4a/S4b covered every card grid and rail; the
table-shaped pages — the channel manager, a playlist, the download center — were still Tab-stop
country: a channel row carries a priority stepper, four action buttons and a tag picker before the next
row's name.

- `ui/DataTable keyboardNav`: the rows are the items, Enter clicks the element a column marks
  `data-nav-open`. DataTable renders its rows twice (a table, and a card list below `md`); both register
  and `lib/listNav` now walks only lists on screen (`checkVisibility`), so `j` past the last row never
  carries on into the hidden copy.
- `lib/listNav` leaves a key something before it already handled (`defaultPrevented`): during a keyboard
  drag a playlist row's grip moves the row with the arrows, and dnd-kit takes the key on the document
  before listNav's window listener. Without the guard ↓ moved the focus off the grip and dropped the drag.
- `ui/NavGrid as="tbody"` for a table whose body is the list (the playlist, under dnd-kit's
  SortableContext).
- Wired: Subscriptions and Discovery tables, the card layout (`ChannelLayoutGrid` → VirtualGrid
  `keyboardNav`), Hidden/Blocked cards (the ChannelLink name is a channel's open element); a playlist's
  rows (the title plays; the grip keeps Space/Enter); the download queue, library and shared lists (the
  play button opens, `IconBtn open`; a row without one opens nothing).
- `listNav.wiring.contract.test`: `VirtualGrid` and `DataTable` must say `keyboardNav` like `Carousel`,
  `DownloadRow` is a card; the audit log's table is the one exemption (its rows open nothing), and an
  exemption that no longer matches a tag fails the test.

Measured in Chrome: Channels table j → row, ring round the whole row; a row walked down keeps 12px above
the viewport edge, walked up clears the sticky header by 35px; Enter opens the channel. Cards 3 columns:
→ 0→1, ↓ 1→4. Hidden and Discovery: j + Enter open the channel. Playlist: j + Enter plays, Escape returns
focus to the row; a keyboard drag (Enter on the grip, ↓) moves the row with focus kept on the grip, and
Escape cancels with the order unchanged. Downloads library: Enter plays a video row, does nothing on a
plain file row.
2026-09-13 06:08:05 +02:00
peter eb0507f942 fix(keys): a card reached by Tab rings the whole card, not just its poster
Owner report: on the Plex browse grid, Tab onto the first card ringed only the poster, and the first
arrow key then moved the ring round the whole card. The poster is the card's own role=button Tab stop,
so Tab gave it the app-wide button ring, while listNav's arrows focus the card root (D2: the ring goes
round the whole item).

The card's "open" element (`data-nav-open` — the Plex poster, a video card's title link) now stands
for the card: when it holds keyboard focus the card root wears the ring and the element's own ring is
transparent. Controls inside the card (watched toggle, actions) keep their own ring.

Measured in Chrome (jsdom evaluates neither :focus-visible nor :has): Plex browse, Tab from the sort
control → poster focused, card outline 2px accent, poster outline transparent; → moves to card 1 with
the same ring. Feed, Tab onto a card's title link → the card is ringed, the link is not.
2026-09-13 05:47:43 +02:00
peter 6d09b94bbb feat(keys): j/k and the arrows walk Home's rails and every Plex grid and rail
Second slice of the E11 keyboard layer (S4b). S4a gave the feed, the library and the channel page
`j`/`k`, the arrows and Enter; Home's shelves and the whole Plex module were still Tab-stop country —
a Plex poster is its own Tab stop, and a rail's cards sat behind every control of the cards before.

- `lib/listNav`: a list that mounts every item no longer declares a count or columns — both are
  measured off the DOM when a key is pressed, so an auto-fill grid's columns follow its width and a
  rail is one row. ↑ on a list's top row and ↓ on its bottom row go on to the neighbouring list, onto
  the item horizontally nearest the one left (owner decision D4: ←→ within a rail, ↑↓ to the rail
  above or below). The first `j` skips cards a rail has scrolled out to the left.
- `Carousel keyboardNav` registers the scroller; the scroller gets a 4px inset (against a -4px margin,
  layout unchanged) so the focus ring is not clipped, and `[data-nav-index]` a 2.5rem inline scroll
  margin so a card stepped into view clears the rail's edge fade and paging arrow.
- `ui/NavGrid`: the plain, fully mounted grid that registers itself.
- `navIndex` on PlexPosterCard / SeasonCard / EpisodeCard (the card root is the item, the poster the
  open element — the ring goes round poster and title, D2), PosterTile and Home's ShelfTile (the button
  is the item); cast, related and collection-strip buttons carry the attrs directly.
- Wired: Home's comfort and resume rails; Plex Browse titles + episodes, Discover rails + genres,
  collection view, playlists/collections browse, person known-for + filmography, show seasons, season
  episodes, cast/related strips, info-page cast + collection strips.
- `listNav.wiring.contract.test`: every `Carousel` says `keyboardNav`, every card says `navIndex`, and a
  card with `navIndex` sits inside a NavGrid / Carousel / VirtualGrid — both halves are opt-in, and
  either one missing leaves a list the keyboard silently skips.
2026-09-13 05:38:36 +02:00
peter 5274216981 feat(ui): PageUp/PageDown page through every dropdown list, not just the palette
The palette got PageUp/PageDown, and the same crawl stayed everywhere else a list opens under a
control: the sidebar's channel filter, the Plex genre/studio/collection pickers, the filter and sort
menus, submenus of every playlist you own. floating-ui's list navigation has only the arrows and
Home/End.

- `lib/listPaging`: the one definition of a page (the rows wholly in view minus one, a fixed 10 with
  no layout) and of where it lands (stops at both ends, never on a row it may not take, looking back
  toward the start rather than past it). The palette now uses it instead of its own copy, and
  `listNav` takes its scroll-parent helper from here.
- `AnchoredPopover`: one keydown handler on the reference AND the surface, so a combobox (keys on its
  input, `aria-activedescendant`) and a roving menu (keys on the focused row) both page. It runs after
  floating-ui's own, which has by then switched to keyboard modality and scrolls the new row in. A
  disabled row is never the target; a submenu's key does not also page the menu around it; with no
  mounted rows (closed, a hover card, a dialog) the key keeps its native meaning.

Measured in the browser: the channel filter shows 8 rows and pages by 7 with the active row in view
after the scroll; a menu pages to its last row and back, the page behind it does not scroll; the
palette still pages by 11 of its 12 visible rows.
2026-09-13 05:20:53 +02:00
peter 75d89613d0 feat(palette): PageUp/PageDown move a page of results at a time
The jump-to palette lists every module, channel, playlist, view and action — 461 rows on the
owner's account, ~11 of them in view — and the arrows were the only way through it: forty presses to
cross the channel group.

- PageDown/PageUp step by the rows that fit in the list right now, minus one, so the row you paged
  from stays in sight at the new page's edge. Measured per press (the list is 50vh and group headings
  eat into it); a fixed 10 when there is no layout to measure.
- Unlike the arrows they stop at the ends instead of wrapping: a page-sized jump that lands back at
  the top reads as having lost your place.
- Home/End stay the text field's own.

Measured in the browser: 0 → 10 → 20 rows, stops on row 461, PageUp from the end steps 9 (a group
heading is in view), the selected row stays visible throughout.
2026-09-13 05:03:35 +02:00
peter 8fb276aa90 feat(keys): j/k walk the feed, s finds the search box, g jumps to a module
A feed card cost ~10 Tab stops, so the tenth card sat ~135 Tabs from the top of the page, and the
only keyboard focus ring (a 60% accent outline on the thumbnail) all but vanished on the dark glass.
First slice of the E11 keyboard layer (S4a); Home, Plex and the tables follow in S4b/S4c.

- `lib/listNav`: one window listener over every registered list, walked in document order. `j`/`k`
  step item to item (on into the next list — owner decision D4), the arrows move around a grid, Enter
  clicks the item's own `data-nav-open` element, so it opens exactly like a click. An item root is
  never a Tab stop: it gets `tabindex=-1` only while the keyboard is on it, and keeps it until the next
  item takes it, so the player's focus restore lands back on the card. The first `j` takes the first
  card you can see, not the top of the list.
- `VirtualGrid` takes part with `keyboardNav` (column count + scrolling an unmounted row in);
  `VirtualFeed` turns it on, so the feed, the library and the channel page all have it.
- Keymap: two-step sequences (`g h`) whose pending step belongs to the scope that started it; the
  page-level scopes (`page`, `search`, `list`) are quiet while a layer that owns the keyboard is open,
  enforced in `dispatch`. A test pins the owner's rule that none of them needs a modifier on the
  Hungarian layout.
- `g h/f/c/p/x/n/m/d/s/,` jump to a module this account can reach; `s` focuses the header search box
  and selects the old query; Escape in it hands the keyboard back to the page (Plex's typeahead spends
  its first Escape on its panel).
- Hover popovers are passive layers: an open tooltip still takes Escape first but no longer silences
  the page keys.
- Focus ring (owner decision D2 = B): solid 2px accent around the whole item; the app-wide button/
  link/row ring goes from 60% to solid too. The item keeps a transparent resting outline because the
  video card transitions `all` — without it every `j` flashed a 3px white ring (measured in Chrome).
- Help sheet: the new keys, in a fixed order after the surface's own; a `g` chord only for a reachable
  module; opened over a player or dialog it leaves out the page-level keys and the context-menu key,
  which nothing can reach there (the latter pre-existing since E10).
- CLAUDE.md: a canonical-patterns row for list/grid keys (`useListNav` + `navItemAttrs` +
  `data-nav-open`, not a private arrow-key handler).
2026-09-13 04:16:20 +02:00
peter 78a9324381 fix(ui): the palette ranks what you typed, and lib stops leaning on components
Typing "theme" put the channel "The Meltzone Podcast" (matched only through its handle
`@themeltzone`) above every "Theme: …" row, and Enter opened the channel — the palette listed
matches in pure group order and always highlighted the first one.

- CommandPalette ranks each word: label starts with it > elsewhere in the label > only the hint,
  keywords or group name; an item ranks by its worst word, then the sum. The group order stays
  fixed, rows are ranked inside each group, and the best row overall starts highlighted. The
  highlight is held by id and follows the best match until the arrows or the pointer pick a row,
  so a channel list landing mid-typing no longer shifts it.
- A11y: result groups are `role="group"` named by their heading; `aria-expanded` is false with
  nothing to choose; the listbox stays mounted (hidden) so `aria-controls` never names a missing
  element, and "Nothing matches" sits outside it; a polite status announces the count once the
  typing pauses.
- `Column`/`FilterMap`/`filterRows`/`isActive` move from `ui/tableHeader` to `lib/tableColumns`,
  and the player's auto/loop modes from `playerModalControls` to `lib/playerModes` — three lib
  helpers imported the column contract from a ui component, and `playerModalHooks` its types from a
  feature component.
- eslint: `lib/` may not import from `components/` at any depth, type-only and re-exports included;
  the boundary contract test proves it fires. CLAUDE.md and the ui README say so.
- keymap: `dispatch`'s docblock sat stacked on `matchesShortcut`'s; moved onto `dispatch`.
2026-09-13 02:14:07 +02:00
peter 8a50538275 fix(ui): a write that fails says what failed and why, wherever it fails
`req` opens its blocking dialog for 400/409/422/5xx only. A 403/404/429 was left to the caller and a
502-504 or lost connection got just the self-clearing connection notice, so 34 mutations without a
reporting onError and 15 direct api writes with a bare or missing catch failed in silence: a hidden
channel that snapped back, a share link that never revoked, a confirmed account delete that ended
with nothing said.

- lib/queryClient: one MutationCache reporter. react-query awaits it BEFORE the mutation's own
  onError, so it waits a task and skips a failure the site already reported; the toast title comes
  from `meta.failTitle`, `meta.errorShownInline` opts out.
- notifyActionError: title names WHAT failed, the line under it says WHY (`failureReason`: a
  structured localized detail, else the status). It marks the error reported (`markReported`), so
  nothing reports one failure twice; `notifyKnownFailure` for a site's own specific sentence.
- Every silent site named (45 new HU/EN titles, 4 reasons). Stale "the global dialog shows it"
  comments corrected on the job actions, channel block, Plex sync, cookies, download enqueue,
  account delete and usePref.
- Channels passed the tag hook's channel id to the reporter as the error; the tag and priority hooks
  now report themselves, with the real error.
- A contract test keeps every useMutation naming its failure; CLAUDE.md gains the pattern row.
- history.dom.test: the "navigates only once the entries are back" assertion raced an async act's
  macrotask flush (1 in 5 runs); it now asserts from a later popstate listener.
2026-09-13 01:45:28 +02:00
peter b4aea7fa5b fix(ui): dialogs, menus and busy buttons keep the keyboard focus where it belongs
Measured in Chrome before each change:

- A dialog opened from a menu lost its focus: the menu row ran its action
  and closed in one batch, and floating-ui's focus return (a microtask)
  moved the focus to the row BEHIND the confirm dialog's scrim 7 ms after
  it opened, where Enter activated the row. MenuItemList now closes first
  and runs the action a task later; the dialog also captures the row, not
  a dying menu item, as its restore target.
- A side panel opened as a flyout collapsed the moment a dialog opened
  from inside it, unmounting the row the dialog hands the focus back to.
  useHoverFocusWithin holds while the focus is inside an aria-modal dialog.
- ui/Button's loading state set native disabled; Chrome moved the focus of
  the pressed button to <body> and never gave it back. Loading is now
  aria-disabled with a click guard that also cancels implicit submission.
- Escape/Enter in the playlist rail's rename field dropped the focus on
  <body>. The row refocuses from its ref callback when it re-attaches (a
  post-commit focus sticks; the earlier attempt ran before the button
  existed).
- PlayerModal was the one aria-modal shell without the dialog contract:
  it is now named by its title, traps Tab, restores the focus to its
  opener and shares one counted body scroll lock with every dialog.
2026-09-13 01:04:11 +02:00
peter 92c305bab9 fix(nav): a navigation closes every overlay before it pushes
The overlay history stack let a navigation land ABOVE open overlays and
"buried" their entries with a counter. Measured in Chrome: Shift+W over
About navigated under the still-open dialog, the next Back both undid the
navigation and closed About, and the Back after that did nothing; Shift+W
on Home with the player open did nothing at all. The counter could also be
spent by a different overlay closing first, and callers that could not
report whether they pushed were assumed to have.

setPage and openChannel now close every open overlay and push only once
the history is back in sync (one entry per live overlay, no programmatic
back() in flight, no reconcile queued), so nothing is ever buried and the
whole mechanism goes: navigateOverOverlays, closeThenNavigate, buryUnder,
setPage's boolean, and the three test files that pinned them. The palette
closes only itself before running a row, so a theme picked over the player
keeps the player. Shift+W on Home with the player open now closes it
(owner decision 2026-09-09).

The wait is on state, not on "a popstate": the old 250 ms fallback could
run with back()s still in flight. An overlay that refuses to close within
1.5 s drops the command instead of being navigated out from under.
2026-09-13 00:17:09 +02:00
201 changed files with 6879 additions and 1241 deletions
+25
View File
@@ -7,6 +7,31 @@ edit the release notes and re-run the generator.
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
## 0.85.0 — 2026-09-13
Siftlode can now be driven from the keyboard, and Back, dialogs and menus stop losing your place. j/k and the arrow keys walk every list and grid, Enter opens what you are on, and Back puts you on the item you opened a view from instead of at the top of the page.
### Features
- j/k and the arrow keys walk the feed, the library, a channel page, the Home rails, every Plex grid and rail, the Channels, Playlists and Downloads tables and the conversation list; Enter opens the item you are on. After you hide or delete an item, the next j goes on from where it was.
- Back returns you to the item a view was opened from — the Plex title you opened from a Home rail, the channel you opened from its row — with the keyboard still on it.
- Shortcuts to get around: s jumps to the search field, g-chords jump to a module, and the cheat sheet (h) names every key in the language you are reading. PageUp/PageDown move a page at a time in the command palette and in every dropdown list.
- The command palette ranks what you typed first: “theme” now puts the Theme rows above a channel whose handle happens to contain it.
- A card reached with Tab or j shows one ring around the whole card, and a screen reader calls it by its title.
### Fixes
- Back and overlays keep their books straight: Back after navigating over About also closes About, a late clean-up no longer undoes the navigation you just made, and Shift+W on Home closes the player.
- Dialogs and menus hand the keyboard back where it came from. Escape from a card’s context menu returns to the card, not its thumbnail link; closing the player under another dialog no longer leaves the page unscrollable; a button that is busy with a request keeps the focus instead of dropping it to the page.
- A write that fails says so, with the reason — several saves, share links and admin actions used to fail silently or in English under the Hungarian interface.
- A split video export that fails part-way (the job limit) tells you how many clips were added and leaves only the rest selected, so trying again does not queue the first ones twice.
- “Feed” is called Feed everywhere, in both languages.
### Internal
- Five sprints: history integrity, one dialog/focus contract, palette ranking and accessibility, the keyboard layer, and a Hungarian/English keyboard-only pass. Focus and event-order questions were measured in a real browser before each fix, not argued.
- Three epic-end review rounds — 19 → 14, 18 → 12 and a targeted 14 → 14 — each regression test mutation-checked. The last round's findings all sat inside the previous round's own fixes, which is where the loop was closed.
## 0.84.0 — 2026-09-12
A bug-fix release, from a list you wrote while using Siftlode. Two of these were destroying your own data: a YouTube video rewound itself when you reopened it, and clearing a resume point on a half-watched Plex item told Plex nothing. Subtitles now appear in seconds instead of minutes, the Plex player's bitrate is a real measurement rather than a guess, and a channel you turn full history on for fills in front of you instead of looking frozen.
+6 -2
View File
@@ -63,8 +63,9 @@ Frontend: `main.tsx` provider tree → `App.tsx` shell → `moduleRegistry` rout
components → the **platform layer `components/ui/`** (domain-free primitives: the popover engine and
its menus, `Modal`/`Overlay`, `Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`, …). That
boundary is one-way and **lint-enforced**, not just described: a platform component may not import a
feature component or a domain API slice (`no-restricted-imports` in `eslint.config.js`, with its own
test). A new generic component goes in `ui/`; see [`frontend/src/components/ui/README.md`](frontend/src/components/ui/README.md).
feature component or a domain API slice, and one layer down `lib/` may not import from `components/` at
all, not even a type (`no-restricted-imports` in `eslint.config.js`, with its own test). A new generic
component goes in `ui/`; see [`frontend/src/components/ui/README.md`](frontend/src/components/ui/README.md).
Data via react-query (`qk` factory), per-account `localStorage`, and server prefs; API through
`lib/api/*` slices over `core.req<T>`.
@@ -89,8 +90,11 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
| FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks |
| FE action button | `ui/Button` — pick a `SIZES` rung | a hand-rolled `<button className="px-3 py-2 …">` |
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
| FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler |
| FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `<body>` |
| FE control | the chip/segmented primitives (`Select` only for >6 options) | a native `<select>` |
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)` | a bare `catch {}` or `notify()` with no error attached |
## House rules that bite (learned from real bugs)
+1 -1
View File
@@ -1 +1 @@
0.84.0
0.85.0
+2 -2
View File
@@ -175,7 +175,7 @@ MAIL_TEXT: dict[str, dict[str, str]] = {
"en": (
"Hi,\n\n"
"Someone has opened up Siftlode for you — a quiet, self-hosted reader for your YouTube "
"subscriptions. Every upload from the channels you follow lands in one feed you can "
"subscriptions. Every upload from the channels you follow lands in one Feed you can "
"actually filter and sort, with no algorithm deciding what you see, and no Shorts or "
"livestreams unless you want them.\n\n"
"{next_step}\n\n"
@@ -186,7 +186,7 @@ MAIL_TEXT: dict[str, dict[str, str]] = {
"Szia,\n\n"
"Valaki megnyitotta előtted a Siftlode-ot — ez egy csendes, saját üzemeltetésű olvasó "
"a YouTube-feliratkozásaidhoz. A követett csatornák minden feltöltése egyetlen "
"hírfolyamba érkezik, amit tényleg tudsz szűrni és rendezni; nincs algoritmus, ami "
"Feedbe érkezik, amit tényleg tudsz szűrni és rendezni; nincs algoritmus, ami "
"eldönti, mit látsz, és nincs Shorts vagy élő adás, hacsak nem kéred.\n\n"
"{next_step}\n\n"
"Nem sürget semmi — az ajtó nyitva marad.\n\n"
+1 -1
View File
@@ -46,7 +46,7 @@ WELCOME = {
"This is your private message inbox. Conversations with other members are end-to-end "
"encrypted — only you and the person you're chatting with can read them, not even an "
"admin. To start messaging someone, set up secure messaging with a passphrase first.\n\n"
"Enjoy your feed!"
"Enjoy your Feed!"
),
"hu": (
"Üdv a Siftlode-on! 👋\n\n"
+22
View File
@@ -109,5 +109,27 @@ export default tseslint.config(
],
},
},
{
// E11 S3: the same one-way rule one layer down. `lib/` is what components are built ON (hooks,
// the API slices, pure helpers), so it may not reach back UP into `components/` — not even for a
// type. That is how the `Column` contract came to be owned by a ui component that three lib
// helpers had to import from. A piece both layers need moves down into `lib/`.
files: ["src/lib/**/*.{ts,tsx}"],
rules: {
"no-restricted-imports": [
"error",
{
patterns: [
{
// Any depth (`lib/api/*` is one level deeper), and `(/|$)` so a bare barrel counts too.
regex: String.raw`^(\.\./)+components(/|$)`,
message:
"lib/ sits below components/: it may not import from components/, not even a type. Move the shared piece down into lib/.",
},
],
},
],
},
},
prettier,
);
-33
View File
@@ -1,33 +0,0 @@
// The global Shift+W wiring, pinned at its call site.
//
// What this is: a source contract, the same idiom as uiBoundary.contract.test.ts — a structural rule
// asserted directly rather than left to prose. What it is NOT: a behaviour test. The behaviour lives
// in history.dom.test.ts and history.burial.dom.test.ts, which drive `navigateOverOverlays` against
// real overlay entries; the end-to-end gesture (Shift+W over an open player, then Back) is a browser
// check, because jsdom reproduces history ordering only as bookkeeping.
//
// It exists because the composition line itself was unpinned: App.tsx is the one module no test
// renders (main.tsx lazy-imports it and nothing else does), globalKeymap.dom.test.tsx drives the
// hook with its OWN handler, and history.dom.test.ts drives the helper with its own navigate — so
// dropping `navigateOverOverlays` from App left all 818 tests green while the user-visible bug came
// straight back (round 2, verified by running that revert).
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const APP = readFileSync(fileURLToPath(new URL("./App.tsx", import.meta.url)), "utf8");
describe("App · global keymap wiring", () => {
it("routes the global home shortcut through navigateOverOverlays", () => {
const call = /useGlobalKeymap\(([\s\S]*?)\);/.exec(APP)?.[1];
// A rename or a move is a legitimate change — but then this test is the thing that has to be
// updated deliberately, which is the whole point of pinning it.
expect(
call,
"App.tsx no longer calls useGlobalKeymap in a shape this test can read",
).toBeTypeOf("string");
expect(call).toContain("navigateOverOverlays");
expect(call).toContain('setPage("home")');
});
});
+22 -13
View File
@@ -3,6 +3,7 @@ import { qk } from "./lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api, getActiveAccount, setActiveAccount, setUnauthorizedHandler } from "./lib/api";
import { notifyActionError } from "./lib/actionErrors";
import { setLanguage, isSupported, type LangCode } from "./i18n";
import { clearParams } from "./lib/urlState";
import type { Page } from "./lib/urlState";
@@ -22,7 +23,7 @@ import { useNavigation, useNavigationActions } from "./components/NavigationProv
import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider";
import { useChannelLayout } from "./components/ChannelsProvider";
import { useFeedView, useFeedViewActions } from "./components/FeedViewProvider";
import { moduleDef } from "./lib/modules";
import { moduleDef, moduleOrder } from "./lib/modules";
import { PAGE_CONTENT, PAGE_RAIL } from "./components/moduleRegistry";
// Persistent shell — always mounted, so eagerly imported.
import Welcome from "./components/Welcome";
@@ -37,7 +38,6 @@ import Toaster from "./components/ui/Toaster";
import KeyboardHelp from "./components/ui/KeyboardHelp";
import AppCommandPalette from "./components/AppCommandPalette";
import { useGlobalKeymap } from "./lib/keymap";
import { navigateOverOverlays } from "./lib/history";
import { StateMessage } from "./components/ui/QueryState";
import ErrorDialog from "./components/ErrorDialog";
import VersionBanner from "./components/VersionBanner";
@@ -98,7 +98,9 @@ export default function App() {
function setPanelLayout(panel: PanelId, next: PanelLayout) {
setPanelLayouts((prev) => ({ ...prev, [panel]: next }));
saveLayoutLocal(panel, next);
api.savePrefs({ [PREF_KEY[panel]]: next }).catch(() => {});
api
.savePrefs({ [PREF_KEY[panel]]: next })
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.prefs")));
}
// Collapse state for the two full-height panels (left nav + filter sidebar). Persisted to the
@@ -142,13 +144,16 @@ export default function App() {
// Home ("mit nézzek?"). Bound only once signed in, because that is where the overlays are mounted
// (bottom of this tree) and where the modules exist at all — see useGlobalKeymap on why binding it
// above the signed-out branch is worse than not binding it.
// Through `navigateOverOverlays`, because a global key can fire while an overlay is open and does
// not know it: the page change unmounts the player (it lives inside the module), whose reconcile
// then reclaimed the entry this push had just made — Shift+W over a playing video closed the
// player and snapped straight back to the feed. `setPage`'s boolean is load-bearing here, not
// decoration: pressed while ALREADY on Home (the default landing page) it navigates nowhere, and
// burying the open player's entry under a push that never happened orphaned it — see `buryUnder`.
useGlobalKeymap(!!meQuery.data, () => navigateOverOverlays(() => setPage("home")));
// A global key can fire while an overlay is open; `setPage` closes every overlay before it pushes,
// so Shift+W over the player lands on Home with the player closed — also when Home is already the
// page underneath (owner decision 2026-09-09: the key always ends with the answer on screen).
// The `page` scope rides the same binding: the `g` chords jump to a module, but only to one this
// account can reach (the nav rail's own list, plus Settings, which is not in the rail's order).
useGlobalKeymap(
!!meQuery.data,
setPage,
meQuery.data ? [...moduleOrder(meQuery.data), "settings"] : [],
);
// Pin this tab to whatever account it first loaded as. Without this, a tab that never picked
// an account (it was riding the session's default) would silently swap identity when ANOTHER
@@ -279,8 +284,8 @@ export default function App() {
label: t("common.accessRequestsReview"),
onClick: () => {
focusAccessRequestsTab();
// Over whatever overlay the toast was clicked in — as navigateTo does (see lib/nav).
navigateOverOverlays(() => setPage("users"));
// Clicked inside whatever overlay hosts the toast; setPage closes it before pushing.
setPage("users");
},
},
});
@@ -291,7 +296,11 @@ export default function App() {
// Language is edited outside the Settings page (sidebar/login), so it stays instant.
function changeLanguage(code: LangCode) {
setLanguage(code);
api.savePrefs({ language: code }).catch(() => {});
// Also from the signed-out login page, where the save 401s — `req` marks that as handled, so
// the reporter stays quiet there.
api
.savePrefs({ language: code })
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.prefs")));
}
// First-run: a fresh instance is in setup mode (all other routes are locked) → show the wizard.
+1 -1
View File
@@ -49,7 +49,7 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
tabIndex={0}
className="block max-w-88 truncate text-muted cursor-help rounded-sm outline-hidden focus-visible:outline-2 focus-visible:outline-accent/60 focus-visible:outline-offset-2"
className="block max-w-88 truncate text-muted cursor-help rounded-sm outline-hidden focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2"
>
{preview}
</span>
+8 -11
View File
@@ -7,8 +7,9 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
import { api } from "../lib/api";
import { qk } from "../lib/queryKeys";
import { notify } from "../lib/notifications";
import { notifyActionError } from "../lib/actionErrors";
import { copyText } from "../lib/clipboard";
import { invalidateUserAccess, isEmailOff } from "../lib/adminUsers";
import { invalidateUserAccess } from "../lib/adminUsers";
import type { AdminUserAccess, AdminUserRow } from "../lib/api/admin";
// The admin's answer to "why can this person not get in?", and the place every recovery action for
@@ -66,25 +67,19 @@ export default function AccountPanel({
const issue = useMutation({
mutationFn: () => api.createSetPasswordLink(user.id),
onSuccess: (r) => setLink({ url: r.url, expires_at: r.expires_at }),
onError: () => notify({ level: "error", message: t("users.account.linkFailed") }),
onError: (err) => notifyActionError(err, t("users.account.linkFailed")),
});
const resend = useMutation({
mutationFn: () => api.resendUserVerification(user.id),
onSuccess: () => notify({ level: "success", message: t("users.account.resendSent") }),
onError: (err) =>
notify({
level: "error",
message: isEmailOff(err)
? t("users.pendingAccess.resendEmailOff")
: t("users.pendingAccess.resendFailed"),
}),
onError: (err) => notifyActionError(err, t("users.pendingAccess.resendFailed")),
});
const activate = useMutation({
mutationFn: () => api.activateUser(user.id),
onSuccess: () => invalidateUserAccess(qc, user.id),
onError: () => notify({ level: "error", message: t("users.pendingAccess.activateFailed") }),
onError: (err) => notifyActionError(err, t("users.pendingAccess.activateFailed")),
});
const copy = async () => {
@@ -201,7 +196,9 @@ export default function AccountPanel({
<button
onClick={() => resend.mutate()}
disabled={!a.email_configured || a.email_verified || resend.isPending}
// The demo account's address is never confirmed, and the server refuses to mail it — as
// it refuses it a set-password link below (deep-review E11 round 3).
disabled={user.is_demo || !a.email_configured || a.email_verified || resend.isPending}
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs disabled:opacity-45 transition"
>
<Send className="w-4 h-4 shrink-0" />
@@ -86,7 +86,8 @@ describe("a user action that fails", () => {
await waitFor(() =>
expect(notify).toHaveBeenCalledWith({
level: "error",
message: i18n.t("errors.notFound.user"), // the request layer localizes a structured detail
title: "users.roles.failed", // what failed…
message: i18n.t("errors.notFound.user"), // …and why: the request layer localizes a structured detail
}),
);
await waitFor(() => expect(users).toHaveBeenCalledTimes(2));
+9 -32
View File
@@ -24,7 +24,7 @@ import {
type PendingAccessRow,
} from "../lib/api";
import { notify } from "../lib/notifications";
import { invalidateUserAccess, isEmailOff } from "../lib/adminUsers";
import { invalidateUserAccess } from "../lib/adminUsers";
import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab";
import { Button } from "./ui/Button";
import Tooltip from "./ui/Tooltip";
@@ -38,7 +38,7 @@ import { useConfirm } from "./ConfirmProvider";
import Tabs, { usePersistedTab } from "./ui/Tabs";
import { PageToolbar } from "./ui/PageShell";
import { useMe } from "../lib/useMe";
import { notifyActionError } from "../lib/actionErrors";
import { notifyActionError, notifyKnownFailure } from "../lib/actionErrors";
// Admin-only user & access management: roles, access requests (the Invite whitelist), and the
// demo whitelist + reset. Each section is its own tab (persisted across reloads); the Access tab
@@ -319,12 +319,6 @@ function UsersRoles({ me }: { me: Me }) {
);
}
/** Did the invitation fail because this instance has no working SMTP? */
function inviteEmailOff(err: HttpError): boolean {
const d = err.detailData as { code?: string; reason?: string } | null | undefined;
return d?.code === "invite_send" && d.reason === "email_off";
}
// --- Sign-ups that are still outside (roadmap UX-25) --------------------------------------------
//
// The gap this closes: approve an address BEFORE the person registers and their sign-up produces no
@@ -351,7 +345,7 @@ function PendingAccess() {
refresh();
notify({ level: "success", message: t("users.pendingAccess.activated", { email: u.email }) });
},
onError: () => notify({ level: "error", message: t("users.pendingAccess.activateFailed") }),
onError: (err) => notifyActionError(err, t("users.pendingAccess.activateFailed")),
});
const resend = useMutation({
@@ -360,15 +354,9 @@ function PendingAccess() {
refresh();
notify({ level: "success", message: t("users.pendingAccess.resent", { email: u.email }) });
},
// The public resend is deliberately silent; this one is not, so say WHY it failed — an instance
// with no working SMTP would otherwise show a success toast for a mail that was never sent.
onError: (err) =>
notify({
level: "error",
message: isEmailOff(err)
? t("users.pendingAccess.resendEmailOff")
: t("users.pendingAccess.resendFailed"),
}),
// The public resend is deliberately silent; this one is not, and the server's reason says WHY
// (no working SMTP, a refused send) — a success toast for a mail never sent would be the lie.
onError: (err) => notifyActionError(err, t("users.pendingAccess.resendFailed")),
});
const list = rows.data ?? [];
@@ -563,12 +551,8 @@ function AdminInvites() {
// A 409 means the list was stale — someone registered on that address since it loaded. Say
// what to do instead of a generic failure, and refetch so the row re-renders as read-only.
const conflict = e instanceof HttpError && e.status === 409;
notify({
level: "error",
message: t(
conflict ? "settings.invites.deleteHasAccount" : "settings.invites.deleteFailed",
),
});
if (conflict) notifyKnownFailure(e, t("settings.invites.deleteHasAccount"));
else notifyActionError(e, t("settings.invites.deleteFailed"));
if (conflict) refresh();
},
});
@@ -598,14 +582,7 @@ function AdminInvites() {
refresh();
notify({ level: "success", message: t("settings.invites.invited", { email: v.inv.email }) });
},
onError: (err) =>
notify({
level: "error",
message:
err instanceof HttpError && inviteEmailOff(err)
? t("settings.invites.inviteEmailOff")
: t("settings.invites.inviteFailed"),
}),
onError: (err) => notifyActionError(err, t("settings.invites.inviteFailed")),
});
// The one recipient the app has no language signal about: the admin typed this address in, so
// nobody ever told us how to write to its owner. The dialog is where that gets decided — and it
+8 -3
View File
@@ -108,9 +108,13 @@ export default function ChannelDiscovery({
}
>
<button
onClick={() => onSubscribe(c)}
disabled={!canWrite || subscribe.isPending}
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs disabled:opacity-50 transition"
// Busy blocks the click, not the focus: a focused button that turns natively `disabled`
// drops the keyboard to <body> (the rule and its measurement: ui/Button).
onClick={() => !subscribe.isPending && onSubscribe(c)}
disabled={!canWrite}
aria-disabled={subscribe.isPending || undefined}
aria-busy={subscribe.isPending || undefined}
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs disabled:opacity-50 aria-disabled:opacity-50 transition"
>
<UserPlus className="w-3.5 h-3.5" />
{t("channels.discovery.subscribe")}
@@ -238,6 +242,7 @@ export default function ChannelDiscovery({
sort={sort}
onSortChange={setSort}
emptyText={t("channels.discovery.empty")}
keyboardNav
/>
) : (
<ChannelLayoutGrid
@@ -0,0 +1,87 @@
// @vitest-environment jsdom
//
// The channel manager's card layout as a keyboard list (E11 S4c): each card is an item, and Enter opens
// the channel through the name ChannelLink renders. VirtualGrid is replaced by a stand-in that mounts
// every item — jsdom has no layout for the virtualizer to measure — and registers with listNav exactly
// when the real one would: when it is asked to (`keyboardNav`).
import { Fragment, useRef } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, render } from "@testing-library/react";
import type { VirtualGridProps } from "./ui/VirtualGrid";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
vi.mock("./ui/VirtualGrid", async () => {
const { useListNav } = await import("../lib/listNav");
return {
default: function GridStandIn({
items,
getKey,
renderItem,
keyboardNav,
}: VirtualGridProps<unknown>) {
const ref = useRef<HTMLDivElement>(null);
useListNav(ref, { enabled: !!keyboardNav });
return (
<div ref={ref}>
{items.map((item, i) => (
<Fragment key={getKey(item)}>{renderItem(item, 0, i)}</Fragment>
))}
</div>
);
},
};
});
import ChannelLayoutGrid from "./ChannelLayoutGrid";
import ChannelLink from "./ChannelLink";
import { type Column } from "./ui/DataTable";
afterEach(cleanup);
type Ch = { id: string; title: string };
const rows: Ch[] = [
{ id: "UCa", title: "Alpha" },
{ id: "UCb", title: "Beta" },
];
function press(key: string) {
act(() => {
(document.activeElement ?? document.body).dispatchEvent(
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
);
});
}
const focusedTitle = () =>
(document.activeElement as HTMLElement).querySelector("[data-nav-open]")?.textContent;
describe("ChannelLayoutGrid · keyboard", () => {
it("walks the cards with j/k and opens the channel on Enter", () => {
const opened: string[] = [];
const columns: Column<Ch>[] = [
{
key: "channel",
header: "Channel",
cardPrimary: true,
render: (c) => (
<ChannelLink
id={c.id}
title={c.title}
thumbnailUrl={null}
onView={() => opened.push(c.id)}
/>
),
},
];
render(<ChannelLayoutGrid rows={rows} columns={columns} rowKey={(c) => c.id} />);
press("j");
expect(focusedTitle()).toBe("Alpha");
press("j");
expect(focusedTitle()).toBe("Beta");
press("Enter");
expect(opened).toEqual(["UCb"]);
});
});
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { type Column } from "./ui/DataTable";
import VirtualGrid from "./ui/VirtualGrid";
import { CHANNEL_LAYOUT_SPEC } from "../lib/channelLayout";
import { navItemAttrs } from "../lib/listNav";
// The channel manager's `cards` layout (E4 S3), built by reusing the SAME `Column<T>` render closures
// the DataTable draws — so the subscribed and discovery tables (which have different columns) both get
@@ -12,6 +13,9 @@ import { CHANNEL_LAYOUT_SPEC } from "../lib/channelLayout";
// Named slots place the columns that need bespoke positions; everything else flows in the meta line.
// Unlike DataTable's cramped narrow-screen card fallback, the desktop card is roomy, so it shows the
// `hideInCard` columns too (About, priority) — the user wants those affordances in every layout.
//
// Each card is a keyboard item (lib/listNav): `j`/`k` and the arrows walk the cards, and Enter opens
// the channel — the name ChannelLink renders is the card's open element.
interface ChannelLayoutGridProps<T> {
rows: T[];
@@ -52,9 +56,10 @@ export default function ChannelLayoutGrid<T>({
// Everything else flows in the meta line: not the heading, not a slotted column.
const meta = columns.filter((c) => !c.cardPrimary && !slotted.has(c.key));
const renderCard = (row: T) => (
const renderCard = (row: T, index: number) => (
<div
{...rowProps?.(row)}
{...navItemAttrs(index)}
className={`glass-card rounded-xl p-3 h-full flex flex-col gap-2 ${rowClassName?.(row) ?? ""}`}
>
<div className="flex items-start justify-between gap-2">
@@ -91,7 +96,8 @@ export default function ChannelLayoutGrid<T>({
minCol={spec.minCol}
colMaxWidth={null}
rowEst={spec.rowEst}
renderItem={renderCard}
keyboardNav
renderItem={(row, _itemWidth, index) => renderCard(row, index)}
/>
);
}
+3
View File
@@ -30,6 +30,9 @@ export default function ChannelLink({
{onView ? (
<button
onClick={onView}
// The channel's "open" element: in a keyboard-walkable list (lib/listNav) Enter on the row or
// card clicks this.
data-nav-open=""
onAuxClick={(e) => {
// Middle-click opens the channel's YouTube page in a new tab; left-click keeps
// opening the in-app channel detail.
+1 -1
View File
@@ -11,7 +11,7 @@ import { urlWithParams } from "../lib/urlState";
// The href is the same URL `openChannel` pushes — `?chan=<id>` on the current page — so a new tab
// lands where the in-app navigation would have. A plain left click is still handled in-app, because
// the SPA path does more than navigate (the player has to close first and reclaim its own
// Back-to-close history entry; see closeThenNavigate).
// Back-to-close history entry; see lib/history.closeOverlaysThenNavigate).
type Props = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href" | "onClick" | "title"> & {
channelId: string;
title: string | null | undefined;
@@ -0,0 +1,99 @@
// @vitest-environment jsdom
//
// The channel page's Block and Hide toggles (deep-review E11 round 2) and its Subscribe / Unsubscribe
// button (round 3: the same row, missed) while their request runs. A
// focused button that turns natively `disabled` drops the keyboard to <body> in Chrome (measured for
// ui/Button in E11 S2), so a busy toggle blocks the click with aria-disabled instead — focusable, and
// still pressed only once.
import type { ReactNode } from "react";
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
vi.mock("./NavigationProvider", () => ({
useNavigationActions: () => ({ closeChannel: vi.fn(), openChannel: vi.fn() }),
}));
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
vi.mock("./FeedFiltersProvider", () => ({
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
}));
vi.mock("./Feed", () => ({ default: () => null }));
vi.mock("./ChannelAbout", () => ({ default: () => null }));
// The page's chrome portals into the shell's fixed toolbar band, which this test has no shell for.
vi.mock("./ui/PageShell", async (importOriginal) => ({
...(await importOriginal<typeof import("./ui/PageShell")>()),
PageToolbar: ({ children }: { children?: ReactNode }) => <>{children}</>,
}));
import ChannelPage from "./ChannelPage";
import { api, type ChannelDetail, type Me } from "../lib/api";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const CH = "UCsXVk37bltHxD1rDPwtNM8Q";
describe("ChannelPage — a busy toggle keeps the keyboard", () => {
it.each([
["channel.block", "blockChannel", true],
["channel.hide", "updateChannel", true],
["channel.subscribe", "subscribeChannel", false],
["channel.subscribedState", "unsubscribeChannel", true],
] as const)(
"%s stays focusable and fires once while its request runs",
async (label, call, subscribed) => {
vi.spyOn(api, "channelDetail").mockResolvedValue({
id: CH,
title: "LEMMiNO",
subscribed,
blocked: false,
hidden: false,
deep_requested: false,
backfill_done: true,
stored_videos: 10,
} as unknown as ChannelDetail);
vi.spyOn(api, "tags").mockResolvedValue([] as never);
const pending = vi.spyOn(api, call).mockReturnValue(new Promise(() => {}) as never);
render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<ChannelPage
channelId={CH}
initialName="LEMMiNO"
me={{ id: 1, can_write: true } as unknown as Me}
view="cards"
setView={() => {}}
onShowInFeed={() => {}}
/>
</QueryClientProvider>,
);
const button = await screen.findByRole("button", { name: label });
act(() => button.focus());
fireEvent.click(button);
await waitFor(() => expect(button.getAttribute("aria-disabled")).toBe("true"));
expect(button.hasAttribute("disabled")).toBe(false);
expect(document.activeElement).toBe(button);
fireEvent.click(button);
expect(pending).toHaveBeenCalledTimes(1);
},
);
});
+42 -28
View File
@@ -29,6 +29,7 @@ import { useFeedFilters } from "./FeedFiltersProvider";
import { useConfirm } from "./ConfirmProvider";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { notifyActionError } from "../lib/actionErrors";
import { api, DEFAULT_TAG_MODES, type FeedFilters, type Me, type ChannelDetail } from "../lib/api";
import type { FeedView } from "../lib/feedView";
import { channelYouTubeUrl, formatViews } from "../lib/format";
@@ -106,18 +107,23 @@ export default function ChannelPage({
const runExplore = useCallback(
(token?: string | null) => {
setExploring(true);
return api
.exploreChannel(channelId, token ?? undefined)
.then((r) => {
setNextToken(r.next_page_token);
qc.invalidateQueries({ queryKey: qk.feed() });
// Refetch the detail so `explored` flips true → the "Exploring" badge shows on the
// first visit (the GET that drove this render predated the explore that just ran).
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
})
.finally(() => setExploring(false));
return (
api
.exploreChannel(channelId, token ?? undefined)
.then((r) => {
setNextToken(r.next_page_token);
qc.invalidateQueries({ queryKey: qk.feed() });
// Refetch the detail so `explored` flips true → the "Exploring" badge shows on the
// first visit (the GET that drove this render predated the explore that just ran).
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
})
// Both the automatic first-visit explore and "load more" used to fail in silence: an empty
// channel page that looked like a channel with no videos (E11 S2b).
.catch((err: unknown) => notifyActionError(err, t("errors.writeFailed.exploreChannel")))
.finally(() => setExploring(false))
);
},
[channelId, qc],
[channelId, qc, t],
);
// First visit of an un-subscribed channel → ingest its recent uploads. Skipped for the demo
@@ -129,7 +135,7 @@ export default function ChannelPage({
if (ch.subscribed || ch.blocked) return;
if (ch.explored && ch.stored_videos > 0) return;
autoTried.current = channelId;
runExplore().catch(() => {});
void runExplore();
}, [ch, channelId, me.is_demo, runExplore]);
const block = useMutation({
@@ -139,15 +145,16 @@ export default function ChannelPage({
qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: qk.blockedChannels() });
},
// No onError: block/unblock is a local DB op (not a YouTube write, so no 403 "connect" case),
// and its 400/409/422/500 are already surfaced by the global error modal (see api.ts req) —
// a caller toast here would just double it.
// No onError: a local DB op (not a YouTube write, so no 403 "connect" case). The app-wide
// reporter names the failure the error dialog does not cover (a 404 for a channel gone).
meta: { failTitle: "errors.writeFailed.blockChannel" },
});
// "Hide from feed": the same per-subscription flag the manager toggles (updateChannel {hidden}).
// Local DB op — keeps the subscription, only drops the channel's videos from the feed/explore.
const toggleHidden = useMutation({
mutationFn: () => api.updateChannel(channelId, { hidden: !ch?.hidden }),
meta: { failTitle: "errors.writeFailed.hideChannel" },
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
qc.invalidateQueries({ queryKey: qk.feed() });
@@ -472,14 +479,18 @@ export default function ChannelPage({
)}
{!me.is_demo && (
<button
onClick={() => block.mutate()}
disabled={block.isPending}
// Busy blocks the click, not the focus: a focused button that turns natively
// `disabled` drops the keyboard to <body> (the rule and its measurement: ui/Button).
// Nor does a busy toggle light up on hover, as if a click would do something.
onClick={() => !block.isPending && block.mutate()}
aria-disabled={block.isPending || undefined}
aria-busy={block.isPending || undefined}
title={ch?.blocked ? t("channel.unblock") : t("channel.block")}
aria-label={ch?.blocked ? t("channel.unblock") : t("channel.block")}
className={`inline-flex items-center justify-center w-9 h-9 rounded-lg border disabled:opacity-50 transition ${
className={`inline-flex items-center justify-center w-9 h-9 rounded-lg border aria-disabled:opacity-50 transition ${
ch?.blocked
? "border-danger text-danger"
: "border-border text-muted hover:text-danger hover:border-danger"
: "border-border text-muted not-aria-disabled:hover:text-danger not-aria-disabled:hover:border-danger"
}`}
>
<Ban className="w-4 h-4" />
@@ -490,14 +501,15 @@ export default function ChannelPage({
from the feed. EyeOff = currently hidden (click to unhide). */}
{ch?.subscribed && !me.is_demo && (
<button
onClick={() => toggleHidden.mutate()}
disabled={toggleHidden.isPending}
onClick={() => !toggleHidden.isPending && toggleHidden.mutate()}
aria-disabled={toggleHidden.isPending || undefined}
aria-busy={toggleHidden.isPending || undefined}
title={ch.hidden ? t("channel.hiddenHint") : t("channel.hideHint")}
aria-label={ch.hidden ? t("channel.unhide") : t("channel.hide")}
className={`inline-flex items-center justify-center w-9 h-9 rounded-lg border disabled:opacity-50 transition ${
className={`inline-flex items-center justify-center w-9 h-9 rounded-lg border aria-disabled:opacity-50 transition ${
ch.hidden
? "border-accent text-accent"
: "border-border text-muted hover:text-fg hover:border-fg/40"
: "border-border text-muted not-aria-disabled:hover:text-fg not-aria-disabled:hover:border-fg/40"
}`}
>
{ch.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
@@ -509,7 +521,7 @@ export default function ChannelPage({
<Button
variant="outline"
onClick={onUnsubscribe}
disabled={unsubscribe.isPending}
loading={unsubscribe.isPending}
title={t("channel.unsubscribe")}
icon={<Check className="w-4 h-4" />}
>
@@ -519,7 +531,7 @@ export default function ChannelPage({
<Button
variant="outline"
onClick={() => subscribe.mutate()}
disabled={subscribe.isPending}
loading={subscribe.isPending}
icon={<Plus className="w-4 h-4" />}
>
{t("channel.subscribe")}
@@ -566,9 +578,11 @@ export default function ChannelPage({
{!ch?.subscribed && nextToken && (
<div className="flex justify-center pb-6">
<button
onClick={() => runExplore(nextToken)}
disabled={exploring}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm border border-border hover:bg-surface disabled:opacity-50"
// Busy blocks the click, not the focus (see Block above).
onClick={() => !exploring && runExplore(nextToken)}
aria-disabled={exploring || undefined}
aria-busy={exploring || undefined}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm border border-border not-aria-disabled:hover:bg-surface aria-disabled:opacity-50"
>
{exploring ? (
<Loader2 className="w-4 h-4 animate-spin" />
+6 -5
View File
@@ -6,7 +6,6 @@ import { Ban, Eye, EyeOff, History, LayoutGrid, Pencil, RefreshCw, Table, X } fr
import ExcludedChannelList from "./ExcludedChannelList";
import { api, NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
import { Sparkles } from "lucide-react";
import { notifyActionError } from "../lib/actionErrors";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { accountKey, LS } from "../lib/storage";
import { buildChannelColumns, applySyncFilter } from "./channelColumns";
@@ -32,7 +31,7 @@ import ChannelStatsPopover from "./ChannelStatsPopover";
import Pager from "./ui/Pager";
import { CHANNEL_LAYOUTS, type ChannelLayout } from "../lib/channelLayout";
import { sortRows } from "../lib/columnSort";
import { filterRows } from "./ui/tableHeader";
import { filterRows } from "../lib/tableColumns";
import { useCardPager } from "../lib/useCardPager";
import { PageToolbar } from "./ui/PageShell";
import ChannelDiscovery from "./ChannelDiscovery";
@@ -110,6 +109,7 @@ export default function Channels() {
const [tagManagerOpen, setTagManagerOpen] = useState(false);
const unblock = useMutation({
mutationFn: (id: string) => api.unblockChannel(id),
meta: { failTitle: "errors.writeFailed.unblockChannel" },
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.blockedChannels() });
qc.invalidateQueries({ queryKey: qk.feed() });
@@ -133,6 +133,7 @@ export default function Channels() {
id: string;
body: { priority?: number; hidden?: boolean; deep_requested?: boolean };
}) => api.updateChannel(v.id, v.body),
meta: { failTitle: "errors.writeFailed.channelSettings" },
// Requesting full history may have just pulled in a channel's recent uploads, so
// refresh the per-user status and feed too — not only the channel list.
onSuccess: () => {
@@ -182,11 +183,10 @@ export default function Channels() {
// A failed attach/detach reverts the optimistic change by refetching the list. Also refetch
// suggestions: acceptSuggestion optimistically dropped the channel from that cache, and on
// failure the tag never actually attached — so the server still suggests it and the ghost chips
// must come back rather than leaving an empty cell (honest states). Surface the write failure too.
onError: (e) => {
// must come back rather than leaving an empty cell (honest states). The hook reports the failure.
onError: () => {
qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: qk.tagSuggestions() });
notifyActionError(e, t("channels.tags.updateFailed"));
},
});
// --- Suggested tags (E8·S4): learned from the user's own manual-tag pattern, shown as accept/dismiss
@@ -623,6 +623,7 @@ export default function Channels() {
fixedLayout
emptyText={t("channels.empty")}
rowProps={pinMenu.triggerProps}
keyboardNav
/>
) : (
<ChannelLayoutGrid
+3 -1
View File
@@ -16,6 +16,7 @@ import { useSortState } from "../lib/useSortState";
import { useFilterState } from "../lib/useFilterState";
import { useActiveAccountId } from "../lib/activeAccount";
import { useUrlMirror } from "../lib/useUrlMirror";
import { rememberNavPosition } from "../lib/history";
import {
channelsParamsToState,
channelsParamUpdates,
@@ -23,7 +24,7 @@ import {
type ChannelsUrlState,
} from "../lib/channelsUrlState";
import { usePersistedTab } from "./ui/Tabs";
import type { FilterMap } from "./ui/tableHeader";
import type { FilterMap } from "../lib/tableColumns";
import { useNavigation, useNavigationActions } from "./NavigationProvider";
import type { ChannelsView } from "./Channels";
@@ -243,6 +244,7 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
if (window.history.state?.sfPage === "channels") {
// A FRESH state, like setPage's: carrying the old entry's `_chan`/`_yt` markers over would make
// Back re-open the channel overlay or a spent search on top of the tab.
rememberNavPosition();
window.history.pushState({ sfPage: "channels", _chTab: v }, "");
}
},
+4 -2
View File
@@ -30,9 +30,11 @@ const GROUP_ORDER = [
"backfill",
"shorts",
"batch",
"maintenance",
"downloads",
"audit",
"plex",
"tmdb",
];
export default function ConfigPanel() {
@@ -111,9 +113,9 @@ export default function ConfigPanel() {
setReseedToken((n) => n + 1); // re-seed the draft from the saved server state
setSaveState("saved");
setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 2500);
} catch {
} catch (e) {
setSaveState("error");
notify({ level: "error", message: t("config.saveFailed") });
notifyActionError(e, t("config.saveFailed"));
}
}
+28 -16
View File
@@ -34,6 +34,7 @@ import { useConfirm } from "./ConfirmProvider";
import { useLiveQuery } from "../lib/useLiveQuery";
import { useMe } from "../lib/useMe";
import { api, type DownloadJob } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { invalidateDownloads, quickShareLink } from "../lib/downloads";
import { libraryRowActions, sharedRowActions } from "../lib/downloadActions";
import { inputCls } from "./ui/form";
@@ -41,6 +42,7 @@ import { Button } from "./ui/Button";
import { DownloadRow, IconBtn, UsageBar } from "./downloadRow";
import { MetaEditModal, ErrorModal } from "./downloadModals";
import { AdminSystem } from "./downloadAdmin";
import NavGrid from "./ui/NavGrid";
export default function DownloadCenter() {
const me = useMe();
@@ -95,14 +97,16 @@ export default function DownloadCenter() {
? api.redownloadDownload(id)
: api.deleteDownload(id),
onSuccess: invalidate,
// No onError: a failed job action returns 400/409/422/500, already surfaced by the global
// error modal (api.ts req) with the backend's specific message — a caller toast would double it.
// A 400/409/422/500 opens the global error dialog with the backend's own message (and is then
// skipped here); a job that is gone (404) or a lost connection is said here, naming the action.
onError: (e, v) => notifyActionError(e, t(`errors.writeFailed.job.${v.op}`)),
});
// Remove a shared-with-me item from your list (only your grant; the owner's file is untouched).
const removeShared = useMutation({
mutationFn: (id: number) => api.removeSharedDownload(id),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.downloadsShared() }),
meta: { failTitle: "errors.writeFailed.removeShared" },
});
const confirmThen = async (title: string, body: string, fn: () => void) => {
@@ -222,9 +226,9 @@ export default function DownloadCenter() {
{t("downloads.page.upload")}
</Button>
</div>
<div className="space-y-2">
{queue.map((job) => (
<DownloadRow key={job.id} job={job} onShowError={setErrorJob}>
<NavGrid className="space-y-2">
{queue.map((job, i) => (
<DownloadRow key={job.id} job={job} navIndex={i} onShowError={setErrorJob}>
{(job.status === "queued" || job.status === "running") && (
<IconBtn
onClick={() => act.mutate({ id: job.id, op: "pause" })}
@@ -272,21 +276,25 @@ export default function DownloadCenter() {
{t("downloads.empty.queue")}
</div>
))}
</div>
</NavGrid>
</>
)}
{tab === "done" && (
<>
<UsageBar />
<div className="space-y-2">
{library.map((job) => {
<NavGrid className="space-y-2">
{library.map((job, i) => {
// Row actions gated on the stored media_kind + job_kind (see downloadActions).
const a = libraryRowActions(job);
return (
<DownloadRow key={job.id} job={job}>
<DownloadRow key={job.id} job={job} navIndex={i}>
{a.play && (
<IconBtn onClick={() => setPlayJob(job)} title={t("downloads.actions.play")}>
<IconBtn
open
onClick={() => setPlayJob(job)}
title={t("downloads.actions.play")}
>
<PlayCircle className="w-4 h-4" />
</IconBtn>
)}
@@ -355,19 +363,23 @@ export default function DownloadCenter() {
{t("downloads.empty.done")}
</div>
))}
</div>
</NavGrid>
</>
)}
{tab === "shared" && (
<div className="space-y-2">
{(sharedQ.data ?? []).map((job) => {
<NavGrid className="space-y-2">
{(sharedQ.data ?? []).map((job, i) => {
// Same media_kind + can_download gate as the library, via the shared helper (S5b).
const a = sharedRowActions(job);
return (
<DownloadRow key={job.id} job={job}>
<DownloadRow key={job.id} job={job} navIndex={i}>
{a.play && (
<IconBtn onClick={() => setPlayJob(job)} title={t("downloads.actions.play")}>
<IconBtn
open
onClick={() => setPlayJob(job)}
title={t("downloads.actions.play")}
>
<PlayCircle className="w-4 h-4" />
</IconBtn>
)}
@@ -405,7 +417,7 @@ export default function DownloadCenter() {
{t("downloads.empty.shared")}
</div>
))}
</div>
</NavGrid>
)}
{tab === "system" && me.role === "admin" && <AdminSystem />}
+6 -2
View File
@@ -10,6 +10,7 @@ import { invalidateDownloads } from "../lib/downloads";
// Lazy: the full profile editor only opens from the dialog's "manage presets" affordance.
const ProfileEditor = lazy(() => import("./ProfileEditor"));
import { notify } from "../lib/notifications";
import { notifyActionError } from "../lib/actionErrors";
import { navigateTo } from "../lib/nav";
import { inputCls } from "./ui/form";
@@ -51,8 +52,11 @@ export default function DownloadDialog({
action: { label: t("downloads.dialog.view"), onClick: () => navigateTo("downloads") },
});
onClose();
} catch {
// Non-quiet request: the global error dialog already surfaced the reason (e.g. quota).
} catch (e) {
// A quota or validation refusal (422/409) is already in the global error dialog, which the
// reporter skips; a 403/404/429 or a lost connection is not, and used to leave the dialog
// open with nothing said (E11 S2b).
notifyActionError(e, t("errors.writeFailed.enqueueDownload"));
setBusy(false);
}
};
@@ -0,0 +1,56 @@
// @vitest-environment jsdom
//
// The Hidden / Blocked channel cards as a keyboard list (E11 S4c): `j`/`k` walk the cards, Enter opens
// the channel — the ChannelLink name is the card's open element — and the card's own action button
// keeps its Enter.
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
import ExcludedChannelList from "./ExcludedChannelList";
afterEach(cleanup);
function press(key: string) {
act(() => {
(document.activeElement ?? document.body).dispatchEvent(
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
);
});
}
const channels = [
{ id: "UCa", title: "Alpha", thumbnail_url: null },
{ id: "UCb", title: "Beta", thumbnail_url: null },
];
describe("ExcludedChannelList · keyboard", () => {
it("walks the cards with j/k and opens the channel on Enter", () => {
const onView = vi.fn();
render(
<ExcludedChannelList
channels={channels}
icon={null}
title="Hidden"
hint=""
emptyText=""
actionLabel="Unhide"
actionIcon={null}
onAction={() => {}}
onView={onView}
/>,
);
press("j");
press("j");
expect(document.activeElement?.textContent).toContain("Beta");
press("k");
expect(document.activeElement?.textContent).toContain("Alpha");
press("Enter");
expect(onView).toHaveBeenCalledWith(channels[0]);
expect(screen.getAllByRole("button", { name: "Unhide" })).toHaveLength(2);
});
});
@@ -1,5 +1,7 @@
import { type ReactNode } from "react";
import ChannelLink from "./ChannelLink";
import NavGrid from "./ui/NavGrid";
import { navItemAttrs } from "../lib/listNav";
// The presentation shared by the channel manager's "excluded channel" tabs — Hidden and Blocked.
// A responsive grid of proper channel cards (avatar + name + open-on-YouTube, via ChannelLink) each
@@ -40,9 +42,13 @@ export default function ExcludedChannelList({
{icon}
<span className="text-xs uppercase tracking-wide text-muted">{title}</span>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{channels.map((c) => (
<div key={c.id} className="glass-card flex items-center gap-2 rounded-xl px-3 py-2">
<NavGrid className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{channels.map((c, i) => (
<div
key={c.id}
{...navItemAttrs(i)}
className="glass-card flex items-center gap-2 rounded-xl px-3 py-2"
>
<div className="min-w-0 flex-1">
<ChannelLink
id={c.id}
@@ -63,7 +69,7 @@ export default function ExcludedChannelList({
</button>
</div>
))}
</div>
</NavGrid>
<p className="text-2xs text-muted mt-3">{hint}</p>
</>
);
+24 -10
View File
@@ -23,6 +23,7 @@ import {
} from "lucide-react";
import { api, HttpError, type FeedFilters, type Video, type VideoStatus } from "../lib/api";
import i18n from "../i18n";
import { notifyActionError } from "../lib/actionErrors";
import { notify, resolveVideo } from "../lib/notifications";
import { useDebounced } from "../lib/useDebounced";
import {
@@ -145,7 +146,7 @@ export default function Feed({
(video: Video, startAt: number | null = null) => setActiveVideo({ video, startAt }),
[],
);
// Stable identity, like Playlists' `closePlayer`: PlayerModal's keydown/scroll-lock effect
// Stable identity, like Playlists' `closePlayer`: PlayerModal's keydown effect
// depends on `onClose`, so a fresh closure every Feed render re-subscribed those listeners,
// re-ran focusModal() under the open player, dropped its in-flight timers and flushed the
// debounced volume write on each pass.
@@ -308,15 +309,17 @@ export default function Feed({
resolveVideo(id);
}
})
.catch(() =>
.catch((e: unknown) => {
// Server rejected the change — drop the optimistic override so the card
// reverts to the real (unchanged) state instead of staying phantom-hidden.
// reverts to the real (unchanged) state instead of staying phantom-hidden, and say so:
// a card that silently snaps back reads as a misclick.
notifyActionError(e, i18n.t("errors.writeFailed.videoState"));
setOverrides((o) => {
const next = { ...o };
delete next[id];
return next;
}),
);
});
});
},
[qc],
);
@@ -330,13 +333,14 @@ export default function Feed({
.clearState(id)
// Stale-mark only (see setState above): the optimistic override already reset the card.
.then(() => qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" }))
.catch(() =>
.catch((e: unknown) => {
notifyActionError(e, i18n.t("errors.writeFailed.videoReset"));
setOverrides((o) => {
const next = { ...o };
delete next[id];
return next;
}),
);
});
});
},
[qc],
);
@@ -349,7 +353,15 @@ export default function Feed({
qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: qk.playlist() });
})
.catch(() => setSavedOverrides((o) => ({ ...o, [id]: !saved })));
.catch((e: unknown) => {
notifyActionError(
e,
i18n.t(
saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove",
),
);
setSavedOverrides((o) => ({ ...o, [id]: !saved }));
});
},
[qc],
);
@@ -466,7 +478,9 @@ export default function Feed({
.then((r) =>
notify({ message: i18n.t("feed.yt.cleared", { count: r.removed }) }),
)
.catch(() => {});
.catch((e: unknown) =>
notifyActionError(e, i18n.t("errors.writeFailed.clearSearch")),
);
setFilters({ ...filters, librarySource: "organic", sort: "newest" });
onExitYtSearch();
dropFeedCaches();
+49 -8
View File
@@ -372,8 +372,8 @@ describe("Home", () => {
it("an unrelated mutation does not put the reroll button into a spinner", async () => {
// The other edge of the test above. Because the pick key sits under the `["discover"]` prefix,
// every mark-watched, bookmark, pin and shelf move on this page refetches it in the BACKGROUND —
// so binding the button's `loading` to raw `isFetching` made it spin and, since `ui/Button` turns
// `loading` into `disabled`, swallow clicks after actions the user never associated with a
// so binding the button's `loading` to raw `isFetching` made it spin and, since a `loading`
// `ui/Button` swallows its clicks, ignore presses after actions the user never associated with a
// reroll (round 2). A press is outstanding only while THIS key has no answer of its own.
const rerollBtn = () =>
screen.getByText("home.hero.reroll").closest("button") as HTMLButtonElement;
@@ -391,7 +391,6 @@ describe("Home", () => {
// The positive control, and it is the half most easily broken while fixing the other: this is the
// FIRST press, so there is no previous pick for the placeholder to hold, and a predicate resting
// on `isPlaceholderData` alone would drop the spinner from exactly the press that needs it.
expect(rerollBtn().disabled).toBe(true);
expect(rerollBtn().getAttribute("aria-busy")).toBe("true");
await act(async () => {
@@ -409,7 +408,6 @@ describe("Home", () => {
});
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
expect(rerollBtn().disabled).toBe(false);
expect(rerollBtn().getAttribute("aria-busy")).toBeNull();
});
@@ -434,7 +432,7 @@ describe("Home", () => {
// `notifyManager`'s own `setTimeout(0)`, registered AFTER any timer this test schedules, so
// "await one macrotask then assert" races it. Round 4 measured that race as an intermittent red.
failPick(new Error("boom"));
await waitFor(() => expect(rerollBtn().disabled).toBe(false)); // the failure releases the button
await waitFor(() => expect(rerollBtn().getAttribute("aria-busy")).toBeNull()); // the failure releases the button
// Now an unrelated action refetches that errored key in the background.
discoverPick.mockReturnValue(new Promise(() => {}));
@@ -442,7 +440,6 @@ describe("Home", () => {
void qc.invalidateQueries({ queryKey: qk.discover() });
});
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
expect(rerollBtn().disabled).toBe(false);
expect(rerollBtn().getAttribute("aria-busy")).toBeNull();
});
@@ -479,14 +476,14 @@ describe("Home", () => {
screen.getByText("home.hero.reroll").closest("button") as HTMLButtonElement;
fireEvent.click(screen.getByText("home.hero.reroll"));
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
expect(rerollBtn().disabled).toBe(true); // the press is outstanding
expect(rerollBtn().getAttribute("aria-busy")).toBe("true"); // the press is outstanding
failPick(new Error("boom"));
// Wait for a signal that the REJECTION has landed, not for a fixed tick: react-query notifies
// the observer through its own `setTimeout(0)`, and the empty state is already on screen while
// the press is in flight — so asserting it straight away would pass with or without the fix.
// The button releasing is that signal (`isFetched` flips once the key settles, error included).
await waitFor(() => expect(rerollBtn().disabled).toBe(false));
await waitFor(() => expect(rerollBtn().getAttribute("aria-busy")).toBeNull());
expect(screen.getByText("home.hero.emptyBudget")).toBeTruthy();
expect(screen.queryByText("Hero v1")).toBeNull();
});
@@ -674,3 +671,47 @@ describe("Home", () => {
);
});
});
// E11 S4b, owner decision D4: the rails are one keyboard page — `j` walks a rail's cards and goes on
// into the next rail, whichever order the page stacks them in.
describe("Home · keyboard", () => {
it("walks the rails with j, from one rail on into the next", async () => {
discover.mockResolvedValue({
...payload("v1"),
continue_watching: [
ytCard("v1", "Hero v1"),
ytCard("v2", "Second thing"),
ytCard("v3", "Third thing"),
],
// A pinned channel has no card of its own: the shelf draws it as a ShelfTile.
comfort: [
ytCard("v4", "Comfort thing"),
{
...ytCard("UC9", "Pinned channel"),
kind: "channel",
video: null,
next: null,
unwatched_count: 3,
},
],
});
mount();
await screen.findByText("Comfort thing");
const titles = ["Comfort thing", "Pinned channel", "Second thing", "Third thing"];
const seen: (string | undefined)[] = [];
for (let i = 0; i < titles.length; i++) {
act(() => {
(document.activeElement ?? document.body).dispatchEvent(
new KeyboardEvent("keydown", { key: "j", bubbles: true, cancelable: true }),
);
});
const item = (document.activeElement as HTMLElement).closest("[data-nav-index]");
seen.push(titles.find((title) => item?.textContent?.includes(title)));
}
// Every card once, across both rails: the shelf tile, and on from one rail into the next.
expect(new Set(seen)).toEqual(new Set(titles));
// Every card of every rail is an item of it — the shelf tile too, not only the native cards.
const rails = [...document.querySelectorAll("[data-nav-list]")];
expect(rails.map((rail) => rail.querySelectorAll("[data-nav-index]").length)).toEqual([2, 2]);
});
});
+9 -2
View File
@@ -40,6 +40,7 @@ import VideoCard from "./VideoCard";
import { PlexPosterCard } from "./plexCards";
import { useNavigationActions } from "./NavigationProvider";
import { usePlexActions } from "./PlexProvider";
import { navItemAttrs } from "../lib/listNav";
// The Home surface (E24) — "Mit nézzek most?".
//
@@ -172,8 +173,9 @@ function Rail({
<Carousel
title={<span className="text-sm font-semibold">{title}</span>}
ariaLabel={ariaLabel}
keyboardNav
>
{cards.map((c) => (
{cards.map((c, i) => (
<div
key={`${c.source}:${c.kind}:${c.id}`}
className={c.aspect === "poster" ? "w-[150px] shrink-0" : "shrink-0"}
@@ -184,6 +186,7 @@ function Rail({
video={c.video}
view="cardsSmall"
width={YT_SLOT}
navIndex={i}
onState={onState}
onToggleSave={onToggleSave}
onOpen={(v, startAt) => onPlay({ ...c, video: v }, startAt)}
@@ -195,13 +198,14 @@ function Rail({
) : c.plex ? (
<PlexPosterCard
card={c.plex}
navIndex={i}
onOpen={() => onPlay(c)}
onInfo={() => onInfo(c)}
onToggleWatched={onToggleWatched}
menuProps={() => menu.triggerProps(c)}
/>
) : (
<ShelfTile card={c} onPlay={onPlay} menuProps={menu.triggerProps} />
<ShelfTile card={c} onPlay={onPlay} menuProps={menu.triggerProps} navIndex={i} />
)}
</div>
))}
@@ -218,10 +222,12 @@ function ShelfTile({
card,
onPlay,
menuProps,
navIndex,
}: {
card: DiscoverCard;
onPlay: (card: DiscoverCard) => void;
menuProps?: (c: DiscoverCard) => Record<string, unknown>;
navIndex?: number;
}) {
const { t } = useTranslation();
const count = card.kind === "channel" ? card.unwatched_count : card.item_count;
@@ -234,6 +240,7 @@ function ShelfTile({
type="button"
onClick={() => card.next && onPlay(card)}
{...menuProps?.(card)}
{...(navIndex !== undefined && navItemAttrs(navIndex))}
// `aria-disabled`, not `disabled`: a pinned channel with nothing unwatched left is not
// playable, but it IS the tile you most want to take off the shelf, and the only way to do
// that is its context menu. MEASURED in Chromium (2026-09-06), because the reason is narrower
@@ -0,0 +1,67 @@
// @vitest-environment jsdom
//
// The conversation list as a keyboard list (E11 S5): `j`/`k` walk the conversations and Enter opens
// the one the row's name button opens — not the pop-out button beside it. The live query, the key
// state and the thread are stubbed: the list itself is what is under test.
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
const conversation = (id: number, name: string) => ({
partner: { id, name, avatar_url: null },
last_message: { kind: "system", body: `hello from ${name}`, created_at: "2026-09-13T00:00:00Z" },
unread: 0,
});
const items = [conversation(0, "Siftlode"), conversation(7, "Anna"), conversation(9, "Bence")];
vi.mock("../lib/useLiveQuery", () => ({
useLiveQuery: () => ({ data: { items }, isError: false, isLoading: false, refetch: () => {} }),
}));
vi.mock("../lib/messaging", () => ({
SYSTEM_ID: 0,
POLL_MS: 20000,
partnerPub: async () => "",
useKeyState: () => "ready",
}));
const openChat = vi.fn();
vi.mock("../lib/chatDock", () => ({ openChat: (...a: unknown[]) => openChat(...a) }));
vi.mock("./ChatThread", () => ({
default: ({ partnerId }: { partnerId: number }) => <div data-testid="thread">{partnerId}</div>,
}));
import Messages from "./Messages";
afterEach(() => {
cleanup();
history.replaceState(null, "");
});
function press(key: string) {
act(() => {
(document.activeElement ?? document.body).dispatchEvent(
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
);
});
}
describe("Messages · the conversation list is a keyboard list", () => {
it("walks the conversations with j/k and opens the focused one on Enter", () => {
render(<Messages meId={1} />);
const focusedRow = () => document.activeElement?.getAttribute("data-nav-index");
press("j");
press("j");
press("j");
expect(focusedRow()).toBe("2");
expect(document.activeElement?.textContent).toContain("Bence");
press("k");
expect(focusedRow()).toBe("1");
expect(document.activeElement?.textContent).toContain("Anna");
press("Enter");
expect(screen.getByTestId("thread").textContent).toBe("7");
expect(openChat).not.toHaveBeenCalled();
});
});
+9 -3
View File
@@ -16,6 +16,8 @@ import Avatar from "./ui/Avatar";
import KeyGate from "./KeyGate";
import { inputCls } from "./ui/form";
import ChatThread from "./ChatThread";
import NavGrid from "./ui/NavGrid";
import { navItemAttrs } from "../lib/listNav";
type View =
| "list"
@@ -140,15 +142,19 @@ function ConversationList({
) : items.length === 0 ? (
<div className="glass rounded-2xl p-10 text-center text-muted">{t("messages.empty")}</div>
) : (
<div className="flex flex-col gap-1.5">
{items.map((c) => {
// A keyboard list (E11): `j`/`k` walk the conversations, Enter opens the one the name button
// opens; the pop-out button beside it keeps its own Enter.
<NavGrid className="flex flex-col gap-1.5">
{items.map((c, i) => {
const isSystem = c.partner.id === SYSTEM_ID;
return (
<div
key={c.partner.id}
{...navItemAttrs(i, c.partner.name)}
className="group flex items-center gap-1 rounded-xl hover:bg-card transition"
>
<button
data-nav-open=""
onClick={() => onOpen(c)}
className="flex items-center gap-3 p-2.5 text-left min-w-0 flex-1"
>
@@ -195,7 +201,7 @@ function ConversationList({
</div>
);
})}
</div>
</NavGrid>
)}
</div>
);
@@ -1,16 +1,14 @@
// @vitest-environment jsdom
//
// `setPage`'s no-op contract — the PRODUCER side of the history-burial fix, which nothing rendered
// before this. `setPage` early-returns when the requested page is already current with no channel
// page over it, and reports that by returning `false`; `lib/history.buryUnder` reads the boolean to
// decide whether the overlay entries beneath a navigation are still reclaimable. Get it wrong and
// the round-2 bug is back: Shift+W over the player on Home buries an entry nothing pushed past, and
// the user's next browser Back does nothing.
// The navigation actions against the overlay history stack: `setPage` and `openChannel` never push
// under an open overlay — they close it and push once its Back-to-close entry is reclaimed — and
// `setPage`'s same-page guard, which decides whether anything is pushed at all.
//
// The type does NOT fence this. `NavigationActions` pins the SHAPE (`=> boolean`), so deleting one
// `return` fails tsc — but round 3 measured that swapping the two values (early return `true`, tail
// `false`) passes typecheck and all 822 tests. Hence assertions on the value AND on the effects the
// early return skips, which is what the boolean actually stands for.
// The overlay half is what used to live at each call site (Shift+W in App, a toast's "View", the
// palette, the player's channel link) and could be forgotten at any of them; it now lives in the two
// actions, so this file is where it is pinned. The end-to-end gesture — Shift+W over the player,
// then browser Back — is still checked in a real browser: jsdom reproduces history ordering only as
// bookkeeping.
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { renderHook, act, cleanup } from "@testing-library/react";
import type { ReactNode } from "react";
@@ -27,6 +25,7 @@ vi.mock("react-i18next", async (importOriginal) => {
});
import { NavigationProvider, useNavigation, useNavigationActions } from "./NavigationProvider";
import { useBackToClose } from "../lib/history";
const wrapper = ({ children }: { children: ReactNode }) => (
<NavigationProvider>{children}</NavigationProvider>
@@ -34,6 +33,25 @@ const wrapper = ({ children }: { children: ReactNode }) => (
const render = () =>
renderHook(() => ({ ...useNavigation(), ...useNavigationActions() }), { wrapper });
const settle = () =>
act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
/** An open overlay (the player) whose close really unmounts it; its reclaiming back() is stubbed to
* land its popstate at once. */
async function openOverlay() {
const back = vi.spyOn(window.history, "back").mockImplementation(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
});
const holder: { unmount?: () => void } = {};
const onClose = vi.fn(() => holder.unmount?.());
const layer = renderHook(() => useBackToClose(onClose));
holder.unmount = layer.unmount;
await settle();
return { back, onClose };
}
beforeEach(() => {
localStorage.clear();
sessionStorage.setItem("siftlode.activeAccount", "1"); // account-scoped page memory
@@ -41,46 +59,29 @@ beforeEach(() => {
});
// Not automatic: vitest runs without `globals`, so RTL never registers its own afterEach — leaving a
// provider (and its popstate listener) mounted for the next case. Same reason Home.dom.test.tsx does.
afterEach(cleanup);
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("NavigationProvider · setPage reports whether it navigated", () => {
it("returns true and pushes an entry for a real page change", () => {
describe("NavigationProvider · setPage's same-page guard", () => {
it("pushes an entry for a real page change", () => {
const { result } = render();
const push = vi.spyOn(window.history, "pushState");
try {
let reported: boolean | undefined;
act(() => {
reported = result.current.setPage("plex");
});
expect(reported).toBe(true);
expect(push).toHaveBeenCalledTimes(1);
expect(result.current.page).toBe("plex");
} finally {
push.mockRestore();
}
act(() => result.current.setPage("plex"));
expect(push).toHaveBeenCalledTimes(1);
expect(result.current.page).toBe("plex");
});
it("returns false and pushes nothing when it is already that page", () => {
it("pushes nothing when it is already that page", () => {
const { result } = render();
act(() => {
result.current.setPage("plex");
});
act(() => result.current.setPage("plex"));
const push = vi.spyOn(window.history, "pushState");
try {
let reported: boolean | undefined;
act(() => {
reported = result.current.setPage("plex");
});
// Both halves matter: `false` is what `buryUnder` reads, and "pushed nothing" is what makes
// that answer true. A value-only assertion would pass against an inverted pair.
expect(reported).toBe(false);
expect(push).not.toHaveBeenCalled();
} finally {
push.mockRestore();
}
act(() => result.current.setPage("plex"));
expect(push).not.toHaveBeenCalled();
});
it("returns true even for the current page when a channel page is over it", () => {
it("still navigates to the current page when a channel page is over it", () => {
// The nav rail's own module while a channel page is open over it is a real navigation: it closes
// the channel page. The `&& !channelViewRef.current` half of the guard is what makes it one.
const { result } = render();
@@ -90,22 +91,64 @@ describe("NavigationProvider · setPage reports whether it navigated", () => {
// asking for "feed" was just an ordinary page change and deleting the clause left it green.
const current = result.current.page;
expect(current).toBe("home");
act(() => {
result.current.openChannel("UC1", "A channel");
});
act(() => result.current.openChannel("UC1", "A channel"));
expect(result.current.channelView).not.toBeNull();
const push = vi.spyOn(window.history, "pushState");
try {
let reported: boolean | undefined;
act(() => {
reported = result.current.setPage(current);
});
expect(reported).toBe(true);
expect(push).toHaveBeenCalledTimes(1);
expect(result.current.channelView).toBeNull();
} finally {
push.mockRestore();
}
act(() => result.current.setPage(current));
expect(push).toHaveBeenCalledTimes(1);
expect(result.current.channelView).toBeNull();
});
});
describe("NavigationProvider · never under an open overlay", () => {
it("setPage closes the overlay first and pushes only once its entry is reclaimed", async () => {
const { result } = render();
const { back, onClose } = await openOverlay();
const push = vi.spyOn(window.history, "pushState");
act(() => result.current.setPage("plex"));
expect(onClose).toHaveBeenCalledTimes(1);
expect(push).not.toHaveBeenCalled(); // not while the player's entry is still current
await settle();
await settle();
expect(back).toHaveBeenCalledTimes(1);
expect(push).toHaveBeenCalledTimes(1);
expect(result.current.page).toBe("plex");
expect(window.history.state).toMatchObject({ sfPage: "plex" });
});
// Shift+W on Home with the player open (owner decision 2026-09-09): nothing to push, but the player
// closes, and its entry is reclaimed rather than orphaned under a push that never happened.
it("setPage to the current page closes the overlay and pushes nothing", async () => {
const { result } = render();
const { back, onClose } = await openOverlay();
const push = vi.spyOn(window.history, "pushState");
act(() => result.current.setPage(result.current.page));
await settle();
await settle();
expect(onClose).toHaveBeenCalledTimes(1);
expect(back).toHaveBeenCalledTimes(1);
expect(push).not.toHaveBeenCalled();
});
// The player's channel link and the palette's channel rows.
it("openChannel closes the overlay first and pushes the channel once its entry is reclaimed", async () => {
const { result } = render();
const { back, onClose } = await openOverlay();
const push = vi.spyOn(window.history, "pushState");
act(() => result.current.openChannel("UC1", "A channel"));
expect(onClose).toHaveBeenCalledTimes(1);
expect(push).not.toHaveBeenCalled();
await settle();
await settle();
expect(back).toHaveBeenCalledTimes(1);
expect(push).toHaveBeenCalledTimes(1);
expect(result.current.channelView).toEqual({ id: "UC1", name: "A channel" });
expect(window.history.state).toMatchObject({ _chan: "UC1" });
});
});
+70 -47
View File
@@ -14,7 +14,12 @@ import { pageTitleKey } from "../lib/pageMeta";
import { setAccountRaw } from "../lib/storage";
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
import { setNavigator } from "../lib/nav";
import { nextNavId, notifyNav } from "../lib/history";
import {
closeOverlaysThenNavigate,
nextNavId,
notifyNav,
rememberNavPosition,
} from "../lib/history";
import { channelViewFrom, loadInitialPage, PAGE_KEY, type ChannelView } from "./navigationInit";
// Owns the app's navigation + browser-history state, lifted out of the App god component so pages
@@ -37,10 +42,11 @@ export interface NavigationState {
}
export interface NavigationActions {
/** Switch modules. Returns whether it actually navigated — it is a no-op when the requested page
* is already current with no channel page over it, and a caller that buries overlay history
* entries under the push has to know that (see `lib/history.navigateOverOverlays`). */
setPage: (next: Page) => boolean;
/** Switch modules. Closes every open overlay first and pushes only once their history entries are
* back (`lib/history.closeOverlaysThenNavigate`), so over an open player the push lands a moment
* after the call; with nothing open it is synchronous. The requested page being already current
* (with no channel page over it) pushes nothing — but the overlays still close. */
setPage: (next: Page) => void;
openChannel: (id: string, name?: string) => void;
closeChannel: () => void;
enterYtSearch: (q: string) => void;
@@ -53,7 +59,7 @@ const StateContext = createContext<NavigationState>({
ytSearch: null,
});
const ActionsContext = createContext<NavigationActions>({
setPage: () => false,
setPage: () => {},
openChannel: () => {},
closeChannel: () => {},
enterYtSearch: () => {},
@@ -118,53 +124,66 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
});
const setPage = useCallback(
(next: Page) => {
// A channel page overlays the content column, so a nav click must close it even when the
// underlying page is unchanged (e.g. "Feed" while a channel opened from the feed).
if (next === pageRef.current && !channelViewRef.current) return false;
markDirty();
setChannelView(null); // leave any open channel page when navigating via the rail
setPageState(next);
setAccountRaw(PAGE_KEY, next);
// Push an in-app history entry so browser/mouse Back steps through pages instead of leaving the
// app. A fresh { sfPage } (no carried-over _yt/_chan markers) so each page starts at its root, and
// the address bar reflects the page (feed is implicit) while clearing the view-specific params
// (chan/v/pl and the plex content keys pscope/psort/pshow/pf) — a page switch resets to that
// module's root. Feed filters (owned by FeedFiltersProvider) are left intact; the plex/channels
// keys re-mirror from PlexProvider / ChannelsProvider on return to their page.
window.history.pushState(
{ sfPage: next, sfScroll: nextNavId() },
"",
urlWithParams({
page: next === "feed" ? null : next,
chan: null,
v: null,
pl: null,
pscope: null,
psort: null,
pshow: null,
pf: null,
ctab: null,
csort: null,
cf: null,
}),
);
notifyNav();
return true;
},
(next: Page) =>
// Never under an open overlay — see the interface doc. The refs are read when the push
// actually happens, which over an open player is a moment after this call.
closeOverlaysThenNavigate(() => {
// A channel page overlays the content column, so a nav click must close it even when the
// underlying page is unchanged (e.g. "Feed" while a channel opened from the feed).
if (next === pageRef.current && !channelViewRef.current) return;
markDirty();
setChannelView(null); // leave any open channel page when navigating via the rail
setPageState(next);
setAccountRaw(PAGE_KEY, next);
// Push an in-app history entry so browser/mouse Back steps through pages instead of leaving
// the app. A fresh { sfPage } (no carried-over _yt/_chan markers) so each page starts at its
// root, and the address bar reflects the page (feed is implicit) while clearing the
// view-specific params (chan/v/pl and the plex content keys pscope/psort/pshow/pf) — a page
// switch resets to that module's root. Feed filters (owned by FeedFiltersProvider) are left
// intact; the plex/channels keys re-mirror from PlexProvider / ChannelsProvider on return to
// their page. The item it was opened from is still focused: the state setters above have not
// re-rendered anything yet.
rememberNavPosition();
window.history.pushState(
{ sfPage: next, sfScroll: nextNavId() },
"",
urlWithParams({
page: next === "feed" ? null : next,
chan: null,
v: null,
pl: null,
pscope: null,
psort: null,
pshow: null,
pf: null,
ctab: null,
csort: null,
cf: null,
}),
);
notifyNav();
}),
[markDirty],
);
// A sub-view (channel / YT search) is an OVERLAY, not a page change, so it does NOT markDirty: a
// pre-pin account re-hydrate should still restore the stored page underneath — the re-hydrate's
// replaceState spreads the _chan/_yt markers, so the overlay survives on top of the restored page.
//
// Like setPage it never lands under an open overlay: the player's channel link and the palette's
// channel rows call it from inside one.
const openChannel = useCallback((id: string, name?: string) => {
setChannelView({ id, name });
const st = window.history.state || {};
const url = urlWithParams({ chan: id });
if (st._chan)
window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
else window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
closeOverlaysThenNavigate(() => {
const url = urlWithParams({ chan: id });
if (window.history.state?._chan) {
const st = window.history.state;
window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
} else {
const st = rememberNavPosition();
window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
}
setChannelView({ id, name });
});
}, []);
const closeChannel = useCallback(() => {
@@ -185,7 +204,11 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
const { _ytScrape: _drop, ...st } = window.history.state || {};
if (st._yt)
window.history.replaceState({ ...st, _yt: q }, ""); // refine current search
else window.history.pushState({ ...st, sfPage: "feed", _yt: q }, ""); // new sub-view entry
else {
// New sub-view entry.
const { _ytScrape: _dropToo, ...left } = rememberNavPosition();
window.history.pushState({ ...left, sfPage: "feed", _yt: q }, "");
}
}, []);
const exitYtSearch = useCallback(() => {
+20 -4
View File
@@ -79,10 +79,26 @@ export default function NotificationsPanel() {
qc.invalidateQueries({ queryKey: qk.notifications() });
qc.invalidateQueries({ queryKey: qk.notifUnread() });
};
const readMut = useMutation({ mutationFn: api.markNotificationRead, onSuccess: refresh });
const readAllMut = useMutation({ mutationFn: api.markAllNotificationsRead, onSuccess: refresh });
const dismissMut = useMutation({ mutationFn: api.dismissNotification, onSuccess: refresh });
const clearMut = useMutation({ mutationFn: api.clearNotifications, onSuccess: refresh });
const readMut = useMutation({
mutationFn: api.markNotificationRead,
onSuccess: refresh,
meta: { failTitle: "errors.writeFailed.notifRead" },
});
const readAllMut = useMutation({
mutationFn: api.markAllNotificationsRead,
onSuccess: refresh,
meta: { failTitle: "errors.writeFailed.notifReadAll" },
});
const dismissMut = useMutation({
mutationFn: api.dismissNotification,
onSuccess: refresh,
meta: { failTitle: "errors.writeFailed.notifDismiss" },
});
const clearMut = useMutation({
mutationFn: api.clearNotifications,
onSuccess: refresh,
meta: { failTitle: "errors.writeFailed.notifClear" },
});
const serverUnread = serverItems.some((n) => !n.read);
const hasAny = serverItems.length > 0 || clientItems.length > 0;
+5 -2
View File
@@ -82,6 +82,8 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: qk.myStatus() });
},
// The step itself says the import failed and offers a retry button — no toast on top.
meta: { errorShownInline: true },
});
// First time we see read access with zero imported channels, pull the user's
@@ -123,8 +125,9 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
useDialogChrome(panelRef, rootRef);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
// Only while nothing is above us: an error dialog opened from inside the wizard owns the Tab.
if (e.key !== "Tab" || !layer.isTopmost()) return;
// Only while nothing that owns the keyboard is above us: an error dialog opened from inside the
// wizard owns the Tab, a hover card under the pointer does not.
if (e.key !== "Tab" || layer.blockingAbove()) return;
const panel = panelRef.current;
// The SAME three arguments `ui/Modal` passes, for the same two reasons — and this call had
// neither of them (round 6, on the commit that extracted the helper so the two shells could
@@ -290,3 +290,63 @@ describe("PlayerModal — toasts raised while it is open", () => {
}
});
});
// --- the dialog contract it now shares (ui/dialogFocus) -----------------------------------------
// Until E11 this was the one `aria-modal` shell with no accessible name, no Tab trap and no focus
// restore: Tab walked out of an open player into the page behind the scrim, and closing it with
// Escape left a keyboard user on <body>, their place in a long feed lost (deep-review round 7).
describe("PlayerModal — the dialog contract", () => {
const open = (onClose = () => {}) => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
return renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={onClose} onState={() => {}} />,
);
};
it("is named by the video's title", () => {
open();
expect(screen.getByRole("dialog", { name: "Video A" })).toBeTruthy();
});
it("opens focused on its card, not on a control, so its shortcuts keep working", () => {
open();
const active = document.activeElement as HTMLElement;
expect(active.tagName).not.toBe("BUTTON");
expect(screen.getByTestId("player-modal").contains(active)).toBe(true);
});
it("gives the focus back to what opened it when it closes", () => {
const opener = document.createElement("button");
document.body.appendChild(opener);
opener.focus();
try {
const view = open();
expect(document.activeElement).not.toBe(opener);
view.unmount();
expect(document.activeElement).toBe(opener);
} finally {
opener.remove();
}
});
it("keeps Tab inside the player", () => {
open();
const dialog = screen.getByTestId("player-modal");
const tabbables = [...dialog.querySelectorAll<HTMLElement>("button:not([disabled]), a[href]")];
const last = tabbables[tabbables.length - 1]!;
last.focus();
// jsdom has no layout, so the trap's visibility filter would see nothing tabbable; give every
// element an offsetParent for the duration of this check.
const spy = vi
.spyOn(HTMLElement.prototype, "offsetParent", "get")
.mockReturnValue(document.body);
try {
fireEvent.keyDown(window, { key: "Tab" });
expect(dialog.contains(document.activeElement)).toBe(true);
expect(document.activeElement).not.toBe(last);
} finally {
spy.mockRestore();
}
});
});
+35 -22
View File
@@ -1,17 +1,12 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useId, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom";
import { useNavigationActions } from "./NavigationProvider";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { usePref } from "../lib/usePref";
import {
AUTO_MODES,
LOOP_MODES,
PlayerTransportControls,
type AutoMode,
type LoopMode,
} from "./playerModalControls";
import { PlayerTransportControls } from "./playerModalControls";
import { AUTO_MODES, LOOP_MODES, type AutoMode, type LoopMode } from "../lib/playerModes";
import {
AlertTriangle,
ArrowLeft,
@@ -32,7 +27,14 @@ import {
import Avatar from "./ui/Avatar";
import AddToPlaylist from "./AddToPlaylist";
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
import { pushLayer, removeLayer, anyLayerAbove, escapeHandled } from "../lib/layerStack";
import {
pushLayer,
removeLayer,
anyBlockingLayerAbove,
anyLayerAbove,
escapeHandled,
} from "../lib/layerStack";
import { trapTab, useDialogChrome } from "./ui/dialogFocus";
import DownloadButton from "./DownloadButton";
import { api, type Video, type VideoStatus } from "../lib/api";
import type { YTPlayer } from "../lib/youtube";
@@ -46,7 +48,7 @@ import {
import { renderDescription } from "../lib/descriptionLinks";
import { fullscreenElement } from "../lib/fullscreen";
import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap";
import { closeThenNavigate, useBackToClose } from "../lib/history";
import { useBackToClose } from "../lib/history";
import ChannelNameLink from "./ChannelNameLink";
import Toaster, { useOverlayOrder } from "./ui/Toaster";
import { afterRemoval } from "../lib/playerQueue";
@@ -166,6 +168,12 @@ export default function PlayerModal({
// modal adjusts volume (not just the small player area).
const dialogRef = useRef<HTMLDivElement | null>(null);
const focusModal = () => cardRef.current?.focus();
// The dialog contract every other `aria-modal` shell shares (ui/dialogFocus): the focus goes back
// to whatever opened the player, and the page lock is the one counted lock. It opens focused on the
// CARD, not its first button, so Space/F/arrows reach the shortcuts. Until E11 this was the one
// hand-rolled shell — no accessible name, no Tab trap, no restore (deep-review round 7).
useDialogChrome(cardRef, dialogRef, { focusScope: true });
const titleId = useId();
const togglePlay = () => {
const p = playerRef.current;
if (!p || typeof p.getPlayerState !== "function") return;
@@ -397,7 +405,7 @@ export default function PlayerModal({
useKeyScope("ytPlayer");
// Keyboard shortcuts + background scroll lock. The chords live in the shared keymap (`ytPlayer`
// Keyboard shortcuts. The chords live in the shared keymap (`ytPlayer`
// scope) so the help sheet lists them; only the behaviour behind each id is here.
// These fire only while focus is on our page, not inside the cross-origin player iframe — so
// we focus the modal card on open and again on player-ready (autoplay can grab focus).
@@ -422,6 +430,14 @@ export default function PlayerModal({
nudgeSeek(secs);
};
const onKey = (e: KeyboardEvent) => {
// The shared dialog Tab trap — only while nothing that owns the keyboard is layered above the
// player: a Modal opened over it (the download dialog) traps for itself. The title's hover card
// does not, so it must not switch the trap off.
if (e.key === "Tab") {
if (layerIdRef.current != null && anyBlockingLayerAbove(layerIdRef.current)) return;
if (dialogRef.current) trapTab(e, dialogRef.current, cardRef.current);
return;
}
dispatch(e, "ytPlayer", {
close: (ev) => {
// Defer to any layer opened above the player FIRST (a Modal like the download dialog, or an
@@ -468,11 +484,9 @@ export default function PlayerModal({
};
window.addEventListener("keydown", onKey);
focusModal();
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
// (The scroll lock is the shared, counted one in useDialogChrome.)
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
// (The debounced volume write is flushed by useYtVolume's own unmount cleanup.)
};
}, [onClose]);
@@ -511,6 +525,7 @@ export default function PlayerModal({
onClick={onClose}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
{/* The card, flanked by full-height glassy strips that step through the feed's order (or a
playlist). Each strip sits just outside the card, spans its full height, and fades out at
@@ -735,7 +750,7 @@ export default function PlayerModal({
<div className="p-4 sm:p-5">
{/* Title row — title (with hover description) on the left, Close on the right. */}
<div className="flex items-start gap-3">
<h2 className="min-w-0 flex-1 text-lg font-semibold leading-snug">
<h2 id={titleId} className="min-w-0 flex-1 text-lg font-semibold leading-snug">
{/* Hover target is the text itself (inline), not the whole row. */}
<span
ref={descPop.refs.setReference}
@@ -822,13 +837,11 @@ export default function PlayerModal({
channelId={active.channel_id}
title={active.channel_title}
onOpenInApp={() => {
const cid = active.channel_id;
const cname = active.channel_title ?? undefined;
// Close first, open the channel only once this player's own Back-to-close
// entry has been reclaimed — otherwise that reclaim undoes the push and
// clicking the name merely closes the player. The rule lives in history.ts
// now; the command palette needs exactly the same one.
closeThenNavigate(onClose, () => openChannel(cid, cname));
// openChannel closes this player first and pushes only once its
// Back-to-close entry is reclaimed — otherwise that reclaim undoes the push
// and clicking the name merely closes the player
// (lib/history.closeOverlaysThenNavigate).
openChannel(active.channel_id, active.channel_title ?? undefined);
}}
className="font-medium hover:text-accent truncate text-left"
/>
+20 -1
View File
@@ -380,7 +380,26 @@ describe("Playlists — a change the server refused is taken back on screen", ()
await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
expect(rowsOnScreen()).toEqual(["Video V", "Video W"]);
expect(h.notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "error", message: "playlists.removeFailed" }),
expect.objectContaining({ level: "error", title: "playlists.removeFailed" }),
);
});
});
describe("Playlists — the rows are a keyboard list", () => {
it("walks the rows with j and plays the focused one on Enter", async () => {
mount({ 1: [vid("A", 0), vid("B", 0)] });
await screen.findByRole("button", { name: "card.play — Video B" });
const press = (key: string) =>
act(() => {
(document.activeElement ?? document.body).dispatchEvent(
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
);
});
press("j");
press("j");
expect(document.activeElement?.tagName).toBe("TR");
expect(document.activeElement?.textContent).toContain("Video B");
press("Enter");
await waitFor(() => expect(h.player?.video.id).toBe("B"));
});
});
+27 -19
View File
@@ -56,20 +56,16 @@ import { useComfortPin, pinVideo } from "./comfortPin";
import { ContextMenu } from "./ui/ContextMenu";
import { useContextMenu } from "./ui/useContextMenu";
import UndoToolbar from "./ui/UndoToolbar";
import NavGrid from "./ui/NavGrid";
import { PageToolbar } from "./ui/PageShell";
import { useConfirm } from "./ConfirmProvider";
import { usePlaylistActions } from "./usePlaylistActions";
import { usePlaylists } from "./PlaylistsProvider";
import { useMe } from "../lib/useMe";
import { usePref } from "../lib/usePref";
import {
ColumnHead,
alignClass,
filterRows,
isActive,
type Column,
type FilterMap,
} from "./ui/tableHeader";
import { navItemAttrs } from "../lib/listNav";
import { ColumnHead, alignClass } from "./ui/tableHeader";
import { filterRows, isActive, type Column, type FilterMap } from "../lib/tableColumns";
import { GRIP_KEY, playlistColumns } from "./playlistColumns";
// The playlist detail is a HYBRID table (E4 S4): real sortable/filterable column headers over a
@@ -99,12 +95,15 @@ const idsKey = (items: Video[]) =>
function Row({
video,
index,
columns,
dragDisabled,
dragHint,
menuProps,
}: {
video: Video;
/** Its position among the rows shown — the row is a keyboard item (lib/listNav). */
index: number;
columns: Column<Video>[];
dragDisabled: boolean;
dragHint: string;
@@ -131,6 +130,7 @@ function Row({
// transition-COLORS, not `transition` — the bare utility animates transform too, which is
// the same lag by another route (150ms behind the pointer, on every row).
className="border-b border-border/50 hover:bg-card/40 transition-colors"
{...navItemAttrs(index)}
{...menuProps}
>
{columns.map((col) => (
@@ -676,16 +676,20 @@ export default function Playlists() {
}
function playerState(id: string, status: string) {
api.setState(id, status).then(() => {
// Every cached list, not `qk.playlist(selectedId)`: the player may have been launched from a
// list that is no longer the selected one (see `playing`), and that list holds the row whose
// status just changed. Only lists actually on screen refetch.
qc.invalidateQueries({ queryKey: qk.playlist() });
qc.invalidateQueries({ queryKey: qk.feed() });
});
api
.setState(id, status)
.then(() => {
// Every cached list, not `qk.playlist(selectedId)`: the player may have been launched from a
// list that is no longer the selected one (see `playing`), and that list holds the row whose
// status just changed. Only lists actually on screen refetch.
qc.invalidateQueries({ queryKey: qk.playlist() });
qc.invalidateQueries({ queryKey: qk.feed() });
})
// No catch used to mean an unhandled rejection and nothing said (E11 S2b).
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.videoState")));
}
// Stable identity: PlayerModal's keydown/scroll-lock effect depends on it, so a fresh closure
// Stable identity: PlayerModal's keydown effect depends on it, so a fresh closure
// every render re-subscribed those listeners and re-ran focusModal() under the open player.
const closePlayer = useCallback(() => setPlaying(null), []);
@@ -1089,10 +1093,14 @@ export default function Playlists() {
items={shown.map((v) => v.id)}
strategy={verticalListSortingStrategy}
>
<tbody>
{shown.map((v) => (
{/* The rows are a keyboard list: `j`/`k` and the arrows walk them, Enter plays. The grip
keeps Space/Enter, and during a keyboard drag the arrows too (listNav leaves a key
dnd-kit has handled). */}
<NavGrid as="tbody">
{shown.map((v, i) => (
<Row
key={v.id}
index={i}
video={v}
columns={columns}
dragDisabled={!canEdit || viewActive}
@@ -1100,7 +1108,7 @@ export default function Playlists() {
menuProps={rowMenu.triggerProps(v)}
/>
))}
</tbody>
</NavGrid>
</SortableContext>
</table>
{shown.length === 0 && (
@@ -0,0 +1,118 @@
// @vitest-environment jsdom
//
// The rail's in-row rename, from the keyboard. Enter and Escape unmount the focused field and render
// the row as a NEW button; without a refocus the keyboard user was dropped on <body> and lost their
// place (E10 review round 1, measured again in Chrome in E11 S2). Everything around the rail that is
// not the rename path is stubbed.
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }) };
});
vi.mock("./PlaylistsProvider", () => ({
usePlaylists: () => ({ search: "", selectedId: 1 }),
usePlaylistsActions: () => ({ setSelectedId: vi.fn() }),
}));
vi.mock("../lib/useMe", () => ({ useMe: () => ({ can_write: true }) }));
vi.mock("./comfortPin", () => ({
useComfortPin: () => ({ item: () => [] }),
pinPlaylist: (id: number) => id,
}));
const rename = vi.fn(async () => {});
vi.mock("./usePlaylistActions", () => ({
usePlaylistActions: () => ({ rename, remove: vi.fn() }),
}));
// The SidePanel/PanelGroups chrome is not what is under test: render the rail's bodies directly.
vi.mock("./FilterRail", () => ({
default: ({ items }: { items: { id: string; body: ReactNode }[] }) => (
<>
{items.map((i) => (
<div key={i.id}>{i.body}</div>
))}
</>
),
}));
import PlaylistsRail from "./PlaylistsRail";
import { api, type Playlist } from "../lib/api";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
Element.prototype.scrollIntoView ??= () => {};
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
rename.mockClear();
});
const alpha = {
id: 1,
name: "alpha",
kind: "custom",
source: "local",
item_count: 3,
total_duration_seconds: 0,
cover_thumbnail: null,
dirty: false,
} as unknown as Playlist;
async function openRename() {
vi.spyOn(api, "playlists").mockResolvedValue([alpha]);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<FloatingTree>
<PlaylistsRail
layout={{} as never}
setLayout={() => {}}
collapsed={false}
onToggleCollapse={() => {}}
/>
</FloatingTree>
</QueryClientProvider>,
);
const row = (await screen.findByText("alpha")).closest("button")!;
act(() => row.focus());
fireEvent.keyDown(row, { key: "F10", shiftKey: true });
fireEvent.click(await screen.findByText("playlists.rename"));
// The menu runs its action a task after it closes (see ui/ActionMenu).
const field = await screen.findByRole("textbox", { name: "playlists.rename" });
await vi.waitFor(() => expect(document.activeElement).toBe(field));
return field as HTMLInputElement;
}
const rowButton = () => screen.getByText("alpha").closest("button");
describe("PlaylistsRail · rename from the keyboard", () => {
it("Escape cancels and puts the focus back on the row", async () => {
const field = await openRename();
fireEvent.change(field, { target: { value: "alpha2" } });
fireEvent.keyDown(field, { key: "Escape" });
await vi.waitFor(() => expect(document.activeElement).toBe(rowButton()));
expect(rename).not.toHaveBeenCalled();
});
it("Enter commits and puts the focus back on the row", async () => {
const field = await openRename();
fireEvent.change(field, { target: { value: "beta" } });
fireEvent.keyDown(field, { key: "Enter" });
expect(rename).toHaveBeenCalledWith(1, "beta");
await vi.waitFor(() => expect(document.activeElement).toBe(rowButton()));
});
});
+29 -30
View File
@@ -160,6 +160,7 @@ export default function PlaylistsRail({
const createMut = useMutation({
mutationFn: (name: string) => api.createPlaylist(name),
meta: { failTitle: "errors.writeFailed.createPlaylist" },
onSuccess: (pl: Playlist) => {
setNewName("");
qc.invalidateQueries({ queryKey: qk.playlists() });
@@ -167,35 +168,20 @@ export default function PlaylistsRail({
},
});
// KNOWN GAP: Enter and Escape unmount the focused <input> and re-render the row as a <button> that
// nothing focuses, so a keyboard user is dropped on <body> and loses their place in the rail (E10
// review round 1). A refocus-the-row fix was written in round 1 and REMOVED in round 3 because a
// real-browser check showed it never worked: Escape's handler does run (typing then Escape leaves
// the name unchanged, i.e. it cancels rather than blur-commits) and the row button is focusable
// when focused from outside — yet activeElement is still BODY afterwards. Three explanations were
// tried and none survived contact, so the machinery went rather than a fourth guess. Whoever picks
// this up: instrument the effect in a real browser first, do not reason about the commit order.
// Enter and Escape unmount the focused <input> and re-render the row as a NEW <button>, so without
// this a keyboard user was dropped on <body> and lost their place in the rail (E10 review round 1).
// The row is focused from its ref callback, i.e. as that button attaches: measured in Chrome (E11
// S2), a focus issued after the commit sticks and nothing takes it back — the round-1 attempt that
// "never worked" focused before the replacement button existed. Set by Enter/Escape only: a
// blur-commit means the user put the focus somewhere else on purpose.
const refocusRowRef = useRef<number | null>(null);
const renameInputRef = useRef<HTMLInputElement | null>(null);
// UNRESOLVED — do not "simplify" this back to `autoFocus` without reading the whole story.
//
// Opening this field from the context menu is a focus race: `MenuItemList` runs `onSelect()` and
// `onClose()` in one batch, so the field mounts in the same commit that unmounts the popover, and
// floating-ui's FloatingFocusManager hands focus back from a `queueMicrotask` queued in its
// layout-effect cleanup. If that microtask wins, the field is blurred straight back out and its
// own onBlur commits and closes it — "the rename field flashes and vanishes" (E10 review round 1).
//
// Three answers have been tried and none is VERIFIED. Round 1 turned floating-ui's returnFocus off
// platform-wide and broke two other surfaces (a non-focusable <tr> trigger; a scroll-dismiss that
// scrolled the list back). Round 2 replaced it with this effect, on the reasoning that a passive
// effect is scheduled after the microtask. Round 3 disputed that (React flushes passive effects
// synchronously at the end of a SyncLane commit, which would put the microtask LAST) — and a jsdom
// harness built to settle it reproduced no failure for ANY of autoFocus / this effect / a
// microtask, i.e. jsdom does not exercise the race, because focus never enters the menu there.
//
// So: this is the shape that is deliberate rather than accidental, and the question is open. It
// needs ONE real-browser check (open the rail's context menu, pick Rename, see whether the field
// survives) — not a fourth guess. Logged in the E10 plan.
// Opening this field from the context menu used to race the menu's own focus return (floating-ui
// hands the focus back to the trigger from a microtask queued as the popover unmounts, and the field
// mounted in that same commit). `MenuItemList` now runs the item's action on a fresh task AFTER that
// return, so the field simply mounts and is focused here; measured in Chrome (E11 S2), the field is
// focused and survives.
useEffect(() => {
if (renamingId !== null) renameInputRef.current?.focus();
}, [renamingId]);
@@ -338,9 +324,15 @@ export default function PlaylistsRail({
onChange={(e) => setRenameValue(e.target.value)}
onBlur={() => void commitRename(pl)}
onKeyDown={(e) => {
if (e.key === "Enter") void commitRename(pl);
if (e.key === "Enter") {
refocusRowRef.current = pl.id;
void commitRename(pl);
}
// Unmounting the input fires no blur in React, so Escape really does cancel.
if (e.key === "Escape") setRenamingId(null);
if (e.key === "Escape") {
refocusRowRef.current = pl.id;
setRenamingId(null);
}
}}
aria-label={t("playlists.rename")}
className="flex-1 min-w-0 bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
@@ -349,7 +341,14 @@ export default function PlaylistsRail({
) : (
<button
key={pl.id}
ref={pl.id === selectedId ? selectedRef : null}
ref={(el) => {
if (pl.id === selectedId) selectedRef.current = el;
// Back from the rename field by Enter/Escape — see refocusRowRef.
if (el && refocusRowRef.current === pl.id) {
refocusRowRef.current = null;
el.focus();
}
}}
onClick={() => setSelectedId(pl.id)}
{...railMenu.triggerProps(pl)}
className={`${ROW_BASE} text-left ${
+9 -6
View File
@@ -42,6 +42,7 @@ import { PlexPinMenu } from "./ComfortPinMenu";
import { useContextMenu } from "./ui/useContextMenu";
import { PlexInfoView, PlexPersonView, PlexSeasonView, PlexShowView } from "./plexViews";
import { dispatch, useKeyScope } from "../lib/keymap";
import NavGrid from "./ui/NavGrid";
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
const PlexPlayer = lazy(() => import("./PlexPlayer"));
@@ -607,10 +608,11 @@ export default function PlexBrowse() {
{episodes.length > 0 && (
<h2 className="text-sm font-semibold mb-2">{t("plex.unified.titles")}</h2>
)}
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
{items.map((c) => (
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
{items.map((c, i) => (
<PlexPosterCard
key={c.id}
navIndex={i}
card={c}
onOpen={() => onCard(c)}
onInfo={() => onInfo(c)}
@@ -618,24 +620,25 @@ export default function PlexBrowse() {
menuProps={pinMenu.triggerProps}
/>
))}
</div>
</NavGrid>
</>
)}
{/* Episodes section (Proposal 3) — matching episodes on a search, kept out of the title grid. */}
{episodes.length > 0 && (
<section className="mt-8">
<h2 className="text-sm font-semibold mb-3">{t("plex.unified.episodes")}</h2>
<div className="grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-x-3 gap-y-4">
{episodes.map((ep) => (
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-x-3 gap-y-4">
{episodes.map((ep, i) => (
<EpisodeCard
key={ep.id}
navIndex={i}
ep={ep}
withShowTitle
onPlay={() => onCard(ep)}
onToggleWatched={() => toggleWatched(ep)}
/>
))}
</div>
</NavGrid>
</section>
)}
</>
@@ -20,6 +20,7 @@ import { useContextMenu } from "./ui/useContextMenu";
import PlexLibraryPicker from "./PlexLibraryPicker";
import { toggleWatchState } from "../lib/plexWatch";
import { LoadingState, StateMessage } from "./ui/QueryState";
import NavGrid from "./ui/NavGrid";
// The Collection info page (ask B): a hero (art/title/cert/year-range/count/runtime/summary) + the
// ordered member grid, a Play-all / Shuffle over the playable leaves, and — for a NON-external,
@@ -398,11 +399,12 @@ export default function PlexCollectionView({
/>
</div>
</div>
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6">
{items.map((c) => (
<NavGrid className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6">
{items.map((c, i) => (
<div key={c.id} className="relative">
<PlexPosterCard
card={c}
navIndex={i}
onOpen={() => onOpenCard(c)}
onInfo={() => onInfoCard(c)}
onToggleWatched={toggleWatched}
@@ -421,7 +423,7 @@ export default function PlexCollectionView({
)}
</div>
))}
</div>
</NavGrid>
</>
)}
</>
@@ -6,7 +6,7 @@
// payload shows the empty message. i18n is key-passthrough; the api is mocked so queries resolve
// synchronously. The rails scroll inside a Carousel (useHScroll → ResizeObserver), stubbed for jsdom.
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
import { act, render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
beforeAll(() => {
@@ -263,3 +263,40 @@ describe("PlexDiscover", () => {
expect(screen.queryByLabelText("plex.markUnwatched")).toBeNull();
});
});
// E11 S4b: every rail and the genre grid are lists the keyboard walks (lib/listNav), so `j` goes on
// from one to the next in page order (owner decision D4).
describe("PlexDiscover · keyboard", () => {
it("registers every rail and the genre grid, and j walks on from rail to rail", async () => {
plexDiscover.mockResolvedValue(fullPayload);
plexSurprise.mockResolvedValue({ scope: "both", items: [card("sp1", "Random Pick")] });
wrap(
<PlexDiscover
scope="both"
onOpenCard={noop}
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={noop}
/>,
);
await screen.findAllByText("Random Pick");
// Five rails (continue, recently watched, recently added, because-you-watched, surprise) + genres.
const lists = [...document.querySelectorAll("[data-nav-list]")];
expect(lists.length).toBe(6);
// …and each holds its cards as items: the genre tiles too, not only the posters.
expect(lists.every((list) => list.querySelector("[data-nav-index]"))).toBe(true);
const step = () => {
act(() => {
(document.activeElement ?? document.body).dispatchEvent(
new KeyboardEvent("keydown", { key: "j", bubbles: true, cancelable: true }),
);
});
return (document.activeElement as HTMLElement).closest("[data-nav-index]")?.textContent;
};
const first = step();
const second = step();
expect(first).toBeTruthy();
expect(second).toBeTruthy();
expect(second).not.toBe(first); // one card per rail: the second press crossed into the next rail
});
});
+14 -5
View File
@@ -13,6 +13,7 @@ import { PlexPosterCard } from "./plexCards";
import { PlexPinMenu } from "./ComfortPinMenu";
import { useContextMenu } from "./ui/useContextMenu";
import { LoadingState, StateMessage } from "./ui/QueryState";
import NavGrid from "./ui/NavGrid";
// The Plex "Discover" landing (E5 S4): a stack of horizontal Carousel rails served from ONE
// /api/plex/discover call — Continue watching, Recently added, Browse by genre (tiles), Because you
@@ -55,11 +56,18 @@ function CardRail({
);
return (
<section className="mb-5">
<Carousel title={heading} ariaLabel={ariaLabel} onSeeAll={onSeeAll} seeAllLabel={seeAllLabel}>
{cards.map((c) => (
<Carousel
title={heading}
ariaLabel={ariaLabel}
onSeeAll={onSeeAll}
seeAllLabel={seeAllLabel}
keyboardNav
>
{cards.map((c, i) => (
<div key={c.id} className="w-[150px] shrink-0">
<PlexPosterCard
card={c}
navIndex={i}
onOpen={() => onOpenCard(c)}
onInfo={() => onInfoCard(c)}
onToggleWatched={onToggleWatched}
@@ -222,17 +230,18 @@ export default function PlexDiscover({
{d.genres.length > 0 && (
<section className="mb-5">
<h2 className="mb-3 text-sm font-semibold">{t("plex.discover.byGenre")}</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{d.genres.map((g) => (
<NavGrid className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{d.genres.map((g, i) => (
<PosterTile
key={g.value}
navIndex={i}
title={g.value}
art={g.art}
onClick={() => onGenre(g.value)}
className="h-24"
/>
))}
</div>
</NavGrid>
</section>
)}
+10 -3
View File
@@ -20,6 +20,7 @@ import { Carousel } from "./ui/Carousel";
import PlexCollectionEditor from "./PlexCollectionEditor";
import PlexPlaylistAdd from "./PlexPlaylistAdd";
import { PLAYABLE_TINT, playableClass } from "./plexCards";
import { navItemAttrs } from "../lib/listNav";
// The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating,
// genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from
@@ -649,7 +650,11 @@ export default function PlexInfo({
<h2 className={`mb-2 text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
{t("plex.info.cast")}
</h2>
<Carousel ariaLabel={t("plex.info.cast")} gap="gap-3">
<Carousel
ariaLabel={t("plex.info.cast")}
gap="gap-3"
keyboardNav={!!(onOpenPerson || onFilter)}
>
{cast.map((c, i) => {
const clickable = !!(onOpenPerson || onFilter);
const inner = (
@@ -686,6 +691,7 @@ export default function PlexInfo({
return clickable ? (
<button
key={i}
{...navItemAttrs(i)}
onClick={() =>
onOpenPerson ? onOpenPerson(c.name) : onFilter?.({ actors: [c.name] })
}
@@ -734,10 +740,11 @@ export default function PlexInfo({
</button>
)}
</div>
<Carousel ariaLabel={col.title} gap="gap-3">
{col.items.map((m) => (
<Carousel ariaLabel={col.title} gap="gap-3" keyboardNav={!!onPlayItem}>
{col.items.map((m, i) => (
<button
key={m.id}
{...navItemAttrs(i)}
onClick={() => onPlayItem?.(m.id)}
disabled={!onPlayItem}
className="group/col w-28 shrink-0 text-left"
+2 -1
View File
@@ -915,7 +915,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
info: () => setInfoOpen((v) => !v),
stats: () => setStatsOpen((v) => !v),
back: (ev) => {
// HTPC-style "stop & back to the feed" — mirrors the mouse Back button (shared cascade).
// HTPC-style "stop & go back" to wherever the player was opened from — mirrors the mouse
// Back button (shared cascade).
// Who owns ⌫ when something is open over the player. Modal stopPropagation()s Escape ONLY
// and its focused close button is not a typing target, so the key reaches us regardless.
// Two wrong answers so far: deferring to nothing at all (round 1's bug — the cheat sheet
@@ -180,6 +180,25 @@ describe("PlexSearchBox — typeahead", () => {
);
});
// E11 S4: SearchBar's Escape hands the keyboard back to the page (blur). With the panel open, the
// first Escape belongs to the panel: it must close it and leave the caret in the field; only the
// next Escape leaves.
it("spends the first Escape on the panel and only the second on leaving the field", async () => {
plexState.q = "matrix";
plexLibrary.mockResolvedValue(result([card("m1", "The Matrix")]));
renderBox();
const input = screen.getByRole("combobox");
input.focus();
await screen.findByRole("listbox");
fireEvent.keyDown(input, { key: "Escape" });
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
expect(document.activeElement).toBe(input);
fireEvent.keyDown(input, { key: "Escape" });
expect(document.activeElement).not.toBe(input);
});
it("marks the input as a combobox and toggles aria-expanded with the panel", async () => {
plexState.q = "matrix";
plexLibrary.mockResolvedValue(result([card("m1", "The Matrix")]));
@@ -96,6 +96,7 @@ export default function PlexSearchBox() {
e.preventDefault(); // suppress SearchBar's Go; open the highlighted title instead
pick(hi);
} else if (e.key === "Escape") {
e.preventDefault(); // close the panel only; SearchBar's Escape-to-blur waits for the next press
setDismissed(true);
setHi(-1); // don't leave a stale highlight to flash on the next reopen
}
+9 -3
View File
@@ -6,6 +6,7 @@ import { plexLang } from "../i18n";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ListMusic, Plus, SlidersHorizontal, X } from "lucide-react";
import { api, plexFilterCount, type PlexFilters } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { type PanelLayout } from "../lib/panelLayout";
import { type MediaSourceKind } from "../lib/mediaSource";
import { useDebounced } from "../lib/useDebounced";
@@ -377,9 +378,14 @@ function PlaylistPicker() {
];
async function createPlaylist(title: string) {
const pl = await api.plexCreatePlaylist(title);
qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
onOpenPlaylist(pl.id);
try {
const pl = await api.plexCreatePlaylist(title);
qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
onOpenPlaylist(pl.id);
} catch (e) {
// Called without an await from the picker, so a refusal was an unhandled rejection (E11 S2b).
notifyActionError(e, t("plex.playlistAdd.createFailed"));
}
}
return (
+4
View File
@@ -1,5 +1,6 @@
import { type ReactNode } from "react";
import { hueOf, hueTileGradient } from "../lib/hueTile";
import { navItemAttrs } from "../lib/listNav";
// A browse tile split in two: the TOP half shows up to 2 montage posters (from /api/plex/image/{key}),
// the BOTTOM half is a solid colour-hash band that carries the label — so the text is always readable
@@ -12,6 +13,7 @@ export default function PosterTile({
art,
onClick,
className,
navIndex,
}: {
title: string;
subtitle?: ReactNode; // a bottom sub-line under the title (e.g. "12 items · 4h 30m")
@@ -19,6 +21,7 @@ export default function PosterTile({
art?: string[]; // up to 2 poster rating_keys; fewer/none falls back to the colour hash
onClick: () => void;
className?: string; // caller sets the height (e.g. "h-20" / "h-28")
navIndex?: number; // position in a keyboard-walkable grid (lib/listNav); Enter is the button's own
}) {
const posters = (art ?? []).slice(0, 2);
const band = hueTileGradient(hueOf(title));
@@ -27,6 +30,7 @@ export default function PosterTile({
type="button"
onClick={onClick}
title={title}
{...(navIndex !== undefined && navItemAttrs(navIndex))}
className={`glass-hover group relative flex flex-col overflow-hidden rounded-xl text-left transition ${className ?? ""}`}
>
{/* Top half: 2 montage posters, or the colour hash when a group has no art. */}
@@ -77,10 +77,12 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
invalidate();
setFormOpen(false);
},
meta: { failTitle: "errors.writeFailed.downloadProfileSave" },
});
const del = useMutation({
mutationFn: (id: number) => api.deleteDownloadProfile(id),
onSuccess: invalidate,
meta: { failTitle: "errors.writeFailed.downloadProfileDelete" },
});
const onDelete = async (p: DownloadProfile) => {
@@ -0,0 +1,51 @@
// @vitest-environment jsdom
//
// "Manage views…" opens a dialog from a menu row, so it must close the menu FIRST and open the dialog
// on a fresh task (`closeThenAct`): the menu's focus return, queued as it unmounts, otherwise takes the
// dialog's focus back to the trigger behind its scrim — measured in Chrome on ActionMenu (E11 S2), and
// this row was the sibling that fix missed (deep-review E11 round 1). jsdom does not run that focus
// race, so the ORDER is what is pinned: the menu is gone before the dialog is asked to open.
import { beforeAll, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react";
const openModal = vi.fn();
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
vi.mock("./SavedViewsProvider", () => ({
useSavedViews: () => ({ views: [], activeViewId: null, apply: vi.fn(), openModal }),
}));
import SavedViewsPopover from "./SavedViewsPopover";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
describe("SavedViewsPopover · Manage views…", () => {
it("closes the menu first, then opens the dialog once", async () => {
render(
<FloatingTree>
<SavedViewsPopover trigger="views" ariaLabel="Saved views" />
</FloatingTree>,
);
fireEvent.click(screen.getByRole("button", { name: "Saved views" }));
// floating-ui never positions in jsdom, so the surface stays visibility:hidden and the row has no
// accessible name to query by: find it by its text.
fireEvent.click(screen.getByText("views.manage"));
expect(screen.queryByRole("menuitem", { hidden: true })).toBeNull();
expect(openModal).not.toHaveBeenCalled();
await vi.waitFor(() => expect(openModal).toHaveBeenCalledTimes(1));
});
});
@@ -1,7 +1,12 @@
import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Bookmark, Check, Settings2 } from "lucide-react";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
import {
PopoverItem,
PopoverSurface,
closeThenAct,
useAnchoredPopover,
} from "./ui/AnchoredPopover";
import { useSavedViews } from "./SavedViewsProvider";
// The saved-views quick-picker. The filter-panel header's icon (and the toolbar's view chip) use it as
@@ -67,10 +72,8 @@ export default function SavedViewsPopover({
<div className="mt-1 border-t border-border pt-1">
<PopoverItem
role="menuitem"
onSelect={() => {
openModal();
setOpen(false);
}}
// Opens a dialog: close first, or the menu's focus return steals its focus (closeThenAct).
onSelect={() => closeThenAct(() => setOpen(false), openModal)}
label={t("views.manage")}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-muted transition hover:text-fg"
>
+4
View File
@@ -390,6 +390,7 @@ export default function Scheduler() {
// live poll surfaces the "running" state within a tick, so we just nudge a refetch.
const runMut = useMutation({
mutationFn: (jobId: string) => api.runSchedulerJob(jobId),
meta: { failTitle: "errors.writeFailed.schedulerRun" },
onSuccess: (_d, jobId) => {
notify({
level: "success",
@@ -400,6 +401,7 @@ export default function Scheduler() {
});
const runAllMut = useMutation({
mutationFn: () => api.runAllSchedulerJobs(),
meta: { failTitle: "errors.writeFailed.schedulerRunAll" },
onSuccess: (res) => {
notify({
level: "success",
@@ -410,10 +412,12 @@ export default function Scheduler() {
});
const batchMut = useMutation({
mutationFn: (v: number) => api.updateMaintenanceBatch(v),
meta: { failTitle: "errors.writeFailed.maintenanceBatch" },
onSuccess: () => qc.invalidateQueries({ queryKey: qk.scheduler() }),
});
const purgeMut = useMutation({
mutationFn: () => api.purgeDiscovery(),
meta: { failTitle: "errors.writeFailed.purgeDiscovery" },
onSuccess: (res) => {
const removed =
(res.search_videos_deleted ?? 0) + (res.videos_deleted ?? 0) + (res.channels_deleted ?? 0);
@@ -0,0 +1,119 @@
// @vitest-environment jsdom
//
// E11 S4: `s` puts the caret in the module's search box from anywhere on the page, and Escape hands
// the keyboard back to the page — the way in needs a way out, or `j`/`k` end up typed into the query.
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
import SearchBar from "./SearchBar";
import KeyboardHelp from "./ui/KeyboardHelp";
import { closeKeyboardHelp, toggleKeyboardHelp } from "../lib/keymap";
import { pushLayer, removeLayer } from "../lib/layerStack";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
afterEach(() => {
cleanup();
closeKeyboardHelp();
});
function press(init: KeyboardEventInit, on?: Element | null): KeyboardEvent {
const e = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...init });
act(() => {
(on ?? document.activeElement ?? document.body).dispatchEvent(e);
});
return e;
}
const field = () => screen.getByPlaceholderText("Search…") as HTMLInputElement;
describe("SearchBar keys", () => {
it("focuses the box on s, selects the old query, and keeps the s out of it", () => {
render(<SearchBar value="old query" onChange={() => {}} placeholder="Search…" />);
const e = press({ key: "s" });
expect(document.activeElement).toBe(field());
expect(e.defaultPrevented).toBe(true); // the `s` is not typed into the box it just focused
expect([field().selectionStart, field().selectionEnd]).toEqual([0, "old query".length]);
});
it("leaves an s typed into another field alone", () => {
render(
<>
<input data-testid="other" />
<SearchBar value="" onChange={() => {}} placeholder="Search…" />
</>,
);
const other = screen.getByTestId("other");
act(() => other.focus());
expect(press({ key: "s" }).defaultPrevented).toBe(false);
expect(document.activeElement).toBe(other);
});
it("stays quiet under a dialog", () => {
render(<SearchBar value="" onChange={() => {}} placeholder="Search…" />);
const id = pushLayer();
press({ key: "s" });
removeLayer(id);
expect(document.activeElement).not.toBe(field());
});
it("gives the keyboard back to the page on Escape", () => {
render(<SearchBar value="abc" onChange={() => {}} placeholder="Search…" />);
press({ key: "s" });
press({ key: "Escape" });
expect(document.activeElement).toBe(document.body);
});
// Plex's typeahead uses its first Escape to close the result panel; the field keeps focus for that.
it("lets a caller keep its own Escape", () => {
render(
<SearchBar
value="abc"
onChange={() => {}}
placeholder="Search…"
onKeyDown={(e) => e.key === "Escape" && e.preventDefault()}
/>,
);
press({ key: "s" });
press({ key: "Escape" });
expect(document.activeElement).toBe(field());
});
// Through the real hook: the sheet offers `s` only where a search box is mounted.
it("is listed in the help sheet while a search box is mounted", () => {
const { unmount } = render(
<>
<SearchBar value="" onChange={() => {}} placeholder="Search…" />
<KeyboardHelp />
</>,
);
act(() => toggleKeyboardHelp());
expect(screen.getAllByRole("definition").map((d) => d.textContent)).toContain("keys.search");
unmount();
closeKeyboardHelp();
render(<KeyboardHelp />);
act(() => toggleKeyboardHelp());
expect(screen.getAllByRole("definition").map((d) => d.textContent)).not.toContain(
"keys.search",
);
});
// Opened over a player, the page keys are quiet — the sheet must not list them there.
it("is left off a sheet opened over a keyboard-owning layer", () => {
render(
<>
<SearchBar value="" onChange={() => {}} placeholder="Search…" />
<KeyboardHelp />
</>,
);
const id = pushLayer();
act(() => toggleKeyboardHelp());
expect(screen.queryAllByRole("definition").map((d) => d.textContent)).not.toContain(
"keys.search",
);
removeLayer(id);
});
});
+12 -1
View File
@@ -1,4 +1,5 @@
import {
useRef,
type FocusEventHandler,
type InputHTMLAttributes,
type KeyboardEventHandler,
@@ -6,6 +7,7 @@ import {
} from "react";
import { useTranslation } from "react-i18next";
import { Search, X } from "lucide-react";
import { useSearchFocusKey } from "../lib/keymap";
// The floating search pill in the top header. One component for every module that searches
// (feed, Plex, channels, playlists): a glass rounded box with a search icon, the input, a
@@ -52,6 +54,9 @@ export default function SearchBar({
inputProps?: InputHTMLAttributes<HTMLInputElement>;
}) {
const { t } = useTranslation();
// `s` puts the caret here from anywhere on the page (E11 S4) — the header mounts exactly one box.
const inputRef = useRef<HTMLInputElement>(null);
useSearchFocusKey(inputRef);
const fire = () => {
if (onGo && !goDisabled) onGo();
};
@@ -67,13 +72,19 @@ export default function SearchBar({
{leading ?? <Search className="w-4 h-4 shrink-0 text-muted" />}
<input
{...inputProps}
ref={inputRef}
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={onFocus}
onBlur={onBlur}
onKeyDown={(e) => {
onKeyDown?.(e);
if (e.key === "Enter" && !e.defaultPrevented) fire();
if (e.defaultPrevented) return;
if (e.key === "Enter") fire();
// Escape hands the keyboard back to the page, where `j`/`k` and the `g` chords live — `s`
// got you in, so there has to be a key that gets you out. The query stays as typed. A caller
// that uses Escape itself (Plex's typeahead closing its panel) preventDefaults first.
else if (e.key === "Escape") e.currentTarget.blur();
}}
placeholder={placeholder}
className="flex-1 min-w-0 bg-transparent outline-hidden text-sm placeholder:text-muted"
+15 -7
View File
@@ -6,6 +6,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-react";
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
import { api, HttpError, type Me } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { LS, useAccountPersistedState } from "../lib/storage";
import Avatar from "./ui/Avatar";
import { notify, type NotifSettings } from "../lib/notifications";
@@ -383,8 +384,8 @@ function SignInMethods({ me }: { me: Me }) {
try {
await api.logoutOthers();
notify({ level: "success", message: t("settings.account.sessions.done") });
} catch {
notify({ level: "error", message: t("settings.account.sessions.failed") });
} catch (e) {
notifyActionError(e, t("settings.account.sessions.failed"));
} finally {
setSessBusy(false);
}
@@ -553,10 +554,11 @@ function PlexWatchSync() {
};
// No onError on these: they're Plex, not YouTube (notifyYouTubeActionError would wrongly say
// "connect YouTube"), and their 409/500 are already surfaced by the global error modal — a caller
// toast would double it. Actual import failures come back as 200 {error}, handled by announce.
// "connect YouTube"). Their 409/500 open the global error dialog and the rest go to the app-wide
// reporter (lib/queryClient). Actual import failures come back as 200 {error}, handled by announce.
const toggle = useMutation({
mutationFn: (next: boolean) => api.plexWatchSetLink(next),
meta: { failTitle: "errors.writeFailed.plexWatchLink" },
onSuccess: (res) => {
qc.setQueryData(qk.plexWatchLink(), res);
announce(res.import);
@@ -564,6 +566,7 @@ function PlexWatchSync() {
});
const reimport = useMutation({
mutationFn: () => api.plexWatchImport(),
meta: { failTitle: "errors.writeFailed.plexWatchImport" },
onSuccess: (res) => {
qc.setQueryData(qk.plexWatchLink(), res);
announce(res.import);
@@ -637,10 +640,13 @@ export function DownloadAuth() {
notify({ level: "success", message: t("settings.downloadAuth.saved") });
},
// Validation errors (empty / not-a-cookies.txt) surface via the global error modal, which
// localizes the structured { code: "cookies" } detail (see lib/api core localizeDetail).
// localizes the structured { code: "cookies" } detail (see lib/api core localizeDetail); the
// statuses it does not cover go to the app-wide reporter.
meta: { failTitle: "settings.downloadAuth.failed" },
});
const remove = useMutation({
mutationFn: (host: string) => api.deleteDownloadCredential(host),
meta: { failTitle: "errors.writeFailed.downloadCookiesRemove" },
onSuccess: (res) => {
qc.setQueryData(qk.downloadCredentials(), res);
notify({ level: "success", message: t("settings.downloadAuth.removed") });
@@ -781,8 +787,10 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
await api.deleteAccount();
// Session cleared server-side → /api/me 401s → Welcome. The flag shows the confirmation banner.
window.location.href = "/?deleted=1";
} catch {
/* the global error dialog surfaces the reason (e.g. last admin) */
} catch (e) {
// A refusal with a reason (the last admin, a 409) is already in the global error dialog, which
// the reporter skips; anything else used to end the confirmed delete with nothing said.
notifyActionError(e, t("errors.writeFailed.deleteAccount"));
}
};
return (
+4
View File
@@ -106,15 +106,18 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
const toggle = useMutation({
mutationFn: () => api.updateDownloadLink(link.id, { allow_download: !link.allow_download }),
onSuccess: onChanged,
meta: { failTitle: "errors.writeFailed.shareLinkDownload" },
});
const revoke = useMutation({
mutationFn: () => api.revokeDownloadLink(link.id),
onSuccess: onChanged,
meta: { failTitle: "errors.writeFailed.shareLinkRevoke" },
});
// Set / change / clear the link password. Rotating (or clearing) it bumps the link's
// password_version server-side, which invalidates any watch grant issued under the old password.
const savePass = useMutation({
mutationFn: (pw: string) => api.updateDownloadLink(link.id, { password: pw }),
meta: { failTitle: "errors.writeFailed.shareLinkPassword" },
onSuccess: () => {
setEditPass(false);
setNewPass("");
@@ -282,6 +285,7 @@ function LinkShare({ job }: { job: DownloadJob }) {
setExpiryDays(null);
setPassword("");
},
meta: { failTitle: "downloads.share.linkFailed" },
});
// Open with a link already generated: sharing is almost always the goal, so mint a default
+5
View File
@@ -210,6 +210,11 @@ function SystemStats() {
const pauseResume = useMutation({
mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.myStatus() }),
onError: (e, paused) =>
notifyActionError(
e,
t(paused ? "errors.writeFailed.syncResume" : "errors.writeFailed.syncPause"),
),
});
const data = q.data;
const maxDaily = Math.max(1, ...(data?.daily ?? []).map((d) => d.total));
+6
View File
@@ -4,6 +4,7 @@ import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { CheckCircle2, Clock, Database, History, Loader2, Pause, Play } from "lucide-react";
import { api, type MyStatus } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { formatViews } from "../lib/format";
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
@@ -53,6 +54,11 @@ export default function SyncStatus({
const toggle = useMutation({
mutationFn: () => (data?.paused ? api.resumeSync() : api.pauseSync()),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.myStatus() }),
onError: (e) =>
notifyActionError(
e,
t(data?.paused ? "errors.writeFailed.syncResume" : "errors.writeFailed.syncPause"),
),
});
if (!data) return null;
+1
View File
@@ -129,6 +129,7 @@ export default function TagManager({
};
const create = useMutation({
mutationFn: (name: string) => api.createTag({ name, category: "other" }),
meta: { failTitle: "channels.tags.createFailed" },
onSuccess: () => {
setNewName("");
invalidate();
@@ -0,0 +1,89 @@
// @vitest-environment jsdom
//
// E11 S4: the video card's half of keyboard navigation (`lib/listNav`). Every layout's ROOT carries the
// list index — that is the element `j`/`k` focus and ring — and the title link is the card's `open`
// element, so Enter on a selected card runs the very click a mouse would: the in-app player.
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
vi.mock("./NavigationProvider", () => ({
useNavigationActions: () => ({ openChannel: vi.fn() }),
}));
import VideoCard from "./VideoCard";
import type { Video } from "../lib/api";
import type { FeedView } from "../lib/feedView";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
const video = {
id: "abc123",
title: "Cicada 3301",
channel_id: "UCsXVk37bltHxD1rDPwtNM8Q",
channel_title: "LEMMiNO",
status: "new",
saved: false,
position_seconds: 0,
duration_seconds: 1200,
published_at: "2026-01-01T00:00:00Z",
watch_url: "https://www.youtube.com/watch?v=abc123",
thumbnail_url: "",
} as unknown as Video;
beforeEach(cleanup);
function renderCard(view: FeedView, navIndex?: number, onOpen = vi.fn()) {
render(
<QueryClientProvider client={new QueryClient()}>
<VideoCard
video={video}
view={view}
width={0}
navIndex={navIndex}
onState={() => {}}
onToggleSave={() => {}}
onOpen={onOpen}
/>
</QueryClientProvider>,
);
return onOpen;
}
// One per layout FAMILY: the stacked card, the thumbnailed row, the one-line row — three roots.
const VIEWS: FeedView[] = ["cards", "rows", "rowsCompact"];
describe("VideoCard — keyboard navigation hooks", () => {
for (const view of VIEWS) {
it(`puts the list index on the ${view} root and opens in-app from the title`, () => {
const onOpen = renderCard(view, 7);
const root = screen.getByTestId("video-card");
expect(root.getAttribute("data-nav-index")).toBe("7");
const open = root.querySelectorAll("[data-nav-open]");
expect(open).toHaveLength(1);
expect(open[0]!.textContent).toBe("Cicada 3301");
(open[0] as HTMLElement).click();
expect(onOpen).toHaveBeenCalledWith(video);
});
}
it("carries no index outside a navigable list (Home's rails)", () => {
renderCard("cards");
expect(screen.getByTestId("video-card").hasAttribute("data-nav-index")).toBe(false);
});
});
+10
View File
@@ -18,6 +18,7 @@ import Avatar from "./ui/Avatar";
import AddToPlaylist from "./AddToPlaylist";
import DownloadButton from "./DownloadButton";
import VideoInfoCard from "./VideoInfoCard";
import { navItemAttrs } from "../lib/listNav";
import clsx from "clsx";
import type { Video, VideoStatus } from "../lib/api";
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
@@ -388,6 +389,7 @@ function VideoCard({
onToggleSave,
onOpen,
menuProps,
navIndex,
}: {
video: Video;
view: FeedView;
@@ -403,6 +405,8 @@ function VideoCard({
* blow past this component's memo for every card in the virtualized feed on every parent
* render. The card calls it with the video it already has. */
menuProps?: (v: Video) => Record<string, unknown>;
/** Its position in a keyboard-navigable list (`lib/listNav`) — omit outside one (Home's rails). */
navIndex?: number;
}) {
const { t, i18n } = useTranslation();
const { openChannel } = useNavigationActions();
@@ -465,6 +469,9 @@ function VideoCard({
target="_blank"
rel="noreferrer"
onClick={(e) => openInApp(e, video, onOpen)}
// Enter on a keyboard-selected card clicks THIS link (lib/listNav), so it opens exactly like a
// click: the in-app player, resuming where the reader left off.
data-nav-open=""
title={titleClamped ? (video.title ?? undefined) : undefined}
className={clsx(
"font-medium leading-snug hover:text-accent",
@@ -534,6 +541,7 @@ function VideoCard({
data-testid="video-card"
data-video-id={video.id}
{...menuProps?.(video)}
{...(navIndex !== undefined && navItemAttrs(navIndex))}
className={clsx(rowShell, "flex items-center gap-3 px-3 py-2", watched && "opacity-55")}
>
{/* Actions lead: this is where the eye already is (the title is the thing you read), so
@@ -597,6 +605,7 @@ function VideoCard({
data-testid="video-card"
data-video-id={video.id}
{...menuProps?.(video)}
{...(navIndex !== undefined && navItemAttrs(navIndex))}
className={clsx(rowShell, "flex gap-3 p-2", watched && "opacity-55")}
>
<Thumb
@@ -631,6 +640,7 @@ function VideoCard({
data-testid="video-card"
data-video-id={video.id}
{...menuProps?.(video)}
{...(navIndex !== undefined && navItemAttrs(navIndex))}
className={clsx(
"cv-card group glass-card glass-hover rounded-2xl transition-all duration-150",
"flex flex-col",
@@ -0,0 +1,103 @@
// @vitest-environment jsdom
//
// A split export that fails part-way (deep-review E11 rounds 2 and 3). Every clip is its own job,
// committed as it goes, so a failure at clip 2 of 2 (the job cap) left clip 1 queued while the editor
// reported a plain failure and kept both selected — and trying again queued clip 1 a second time. It
// now says how many made it (also when `req` has already shown the refusal in its dialog), deselects
// them, holds the cut list still while it queues, and a retry goes on with the same "(i/N)" series.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
const { notify } = vi.hoisted(() => ({ notify: vi.fn() }));
vi.mock("../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/notifications")>()),
notify,
}));
import VideoEditor from "./VideoEditor";
import { api, HttpError, type DownloadJob } from "../lib/api";
import { markReported } from "../lib/api/core";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
notify.mockReset();
});
const job = {
id: 7,
display_name: "Talk",
asset: { duration_s: 100, width: 1920, height: 1080, title: "Talk" },
} as unknown as DownloadJob;
describe("VideoEditor — a split export that fails part-way", () => {
it("says how many clips made it, and a retry queues only the rest", async () => {
vi.spyOn(api, "downloadStoryboard").mockResolvedValue({ available: false } as never);
let refuse: (e: unknown) => void = () => {};
const enqueue = vi
.spyOn(api, "enqueueEdit")
.mockResolvedValueOnce({} as never)
.mockImplementationOnce(
() =>
new Promise((_, reject) => {
refuse = reject;
}) as never,
)
.mockResolvedValue({} as never);
render(
<QueryClientProvider client={new QueryClient()}>
<VideoEditor job={job} onClose={() => {}} />
</QueryClientProvider>,
);
// The playhead is where the video element says it is.
const video = document.querySelector("video")!;
const playhead = (s: number) => {
Object.defineProperty(video, "currentTime", { value: s, configurable: true, writable: true });
fireEvent.timeUpdate(video);
};
playhead(50);
fireEvent.click(screen.getByText("editor.splitHere"));
fireEvent.click(screen.getByText("editor.createN"));
await waitFor(() => expect(enqueue).toHaveBeenCalledTimes(2));
// While the clips are queued the cut list holds still: a split now would be a range never queued.
playhead(75);
fireEvent.click(screen.getByText("editor.splitHere"));
// The job cap, as `req` delivers it: already shown in the error dialog, and marked reported.
const refused = new HttpError(422, "errors.generic", { code: "quota" });
markReported(refused);
await act(async () => refuse(refused));
await waitFor(() =>
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "error", title: "editor.partlyCreated" }),
),
);
expect(enqueue.mock.calls[0]![0].display_name).toBe("Talk (1/2)");
// The queued first clip is deselected: one clip left to create, and only it is sent again — as
// the second of the same two.
fireEvent.click(screen.getByText("editor.create"));
await waitFor(() => expect(enqueue).toHaveBeenCalledTimes(3));
expect(enqueue.mock.calls[2]![0].edit_spec.trim).toEqual({ start_s: 50, end_s: 100 });
expect(enqueue.mock.calls[2]![0].display_name).toBe("Talk (2/2)");
});
});
+58 -11
View File
@@ -10,7 +10,8 @@ import { api, type DownloadJob, type EditSpec } from "../lib/api";
import { contentRect, cropToSourcePx, type Frac } from "../lib/cropGeometry";
import { inputCls, btnCls } from "./ui/form";
import { Button } from "./ui/Button";
import { notifyActionError } from "../lib/actionErrors";
import { failureReason, notifyActionError } from "../lib/actionErrors";
import { markReported } from "../lib/api/core";
// mm:ss.d timecode <-> seconds.
function tc(s: number): string {
@@ -60,6 +61,13 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
// The cut-list: contiguous segments covering [0, duration]. Starts as the whole video, kept.
const [segments, setSegments] = useState<Seg[]>([{ id: 0, start: 0, end: srcDur, keep: true }]);
// Set while Create queues its clips. A part-way failure deselects the clips it queued by the ids
// Create started with, so the cut list holds still until it is done: a split or a merge in between
// would deselect a range that was never queued, or keep one that was (deep-review E11 round 3).
const creating = useRef(false);
// The "(i/N)" each clip of the last Create was named with, by segment id: a retry after a part-way
// failure goes on with that series instead of renumbering what is left ("(1/2)" beside "(1/4)").
const series = useRef<Map<number, string> | null>(null);
const [selId, setSelId] = useState(0);
const [output, setOutput] = useState<"separate" | "join">("separate");
const [cropOn, setCropOn] = useState(false);
@@ -135,6 +143,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
// --- cut-list mutations ---
const boundaries = useMemo(() => segments.slice(0, -1).map((s) => s.end), [segments]);
const splitAtPlayhead = () => {
if (creating.current) return;
const time = current;
setSegments((segs) => {
const i = segs.findIndex((s) => time > s.start + MIN_SEG && time < s.end - MIN_SEG);
@@ -147,7 +156,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
};
const moveBoundary = (bi: number, time: number) =>
setSegments((segs) => {
if (bi < 0 || bi >= segs.length - 1) return segs;
if (creating.current || bi < 0 || bi >= segs.length - 1) return segs;
// bi is in [0, length-2] here, so bi and bi+1 both index existing segments.
const lo = segs[bi]!.start + MIN_SEG;
const hi = segs[bi + 1]!.end - MIN_SEG;
@@ -159,12 +168,14 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
});
const deleteBoundary = (bi: number) =>
setSegments((segs) => {
if (segs.length < 2) return segs;
if (creating.current || segs.length < 2) return segs;
const merged = { ...segs[bi]!, end: segs[bi + 1]!.end };
return [...segs.slice(0, bi), merged, ...segs.slice(bi + 2)];
});
const toggleKeep = (id: number) =>
setSegments((segs) => segs.map((s) => (s.id === id ? { ...s, keep: !s.keep } : s)));
setSegments((segs) =>
creating.current ? segs : segs.map((s) => (s.id === id ? { ...s, keep: !s.keep } : s)),
);
// --- timeline drag (seek / boundary) + hover preview ---
const [drag, setDrag] = useState<Drag | null>(null);
@@ -291,6 +302,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
const create = useMutation({
mutationFn: async () => {
creating.current = true;
const cp = cropPx();
const base = name.trim() || defaultName;
if (willJoin) {
@@ -307,13 +319,45 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
return { files: 1 };
}
const N = kept.length;
for (let i = 0; i < N; i++) {
const s = kept[i]!; // i < N === kept.length, so kept[i] exists
const spec: EditSpec = { trim: { start_s: +s.start.toFixed(3), end_s: +s.end.toFixed(3) } };
if (cp) spec.crop = cp;
else spec.accurate = accurate;
const dn = N > 1 ? `${base} (${i + 1}/${N})` : name.trim() || undefined;
await api.enqueueEdit({ source_job_id: job.id, edit_spec: spec, display_name: dn });
const prev = series.current;
const suffix =
prev && kept.every((s) => prev.has(s.id))
? prev
: new Map(kept.map((s, i) => [s.id, N > 1 ? ` (${i + 1}/${N})` : ""]));
series.current = suffix;
const done: number[] = [];
try {
for (let i = 0; i < N; i++) {
const s = kept[i]!; // i < N === kept.length, so kept[i] exists
const spec: EditSpec = {
trim: { start_s: +s.start.toFixed(3), end_s: +s.end.toFixed(3) },
};
if (cp) spec.crop = cp;
else spec.accurate = accurate;
const dn = suffix.get(s.id) ? `${base}${suffix.get(s.id)}` : name.trim() || undefined;
await api.enqueueEdit({ source_job_id: job.id, edit_spec: spec, display_name: dn });
done.push(s.id);
}
} catch (e) {
// Every clip is its own job, committed as it goes: a failure at clip 3 of 3 (the job cap) left
// clips 1 and 2 queued while the editor reported a plain failure and kept all three selected,
// so trying again queued the first two a second time. Say how many made it, and deselect them
// so another try adds only the rest.
if (done.length) {
qc.invalidateQueries({ queryKey: qk.downloads() });
qc.invalidateQueries({ queryKey: qk.downloadUsage() });
setSegments((segs) => segs.map((s) => (done.includes(s.id) ? { ...s, keep: false } : s)));
// Its own toast, not notifyActionError: a quota or edit refusal has already opened req's
// error dialog and marked the error, so that helper would say nothing — and the count is
// what the dialog does not tell (deep-review E11 round 3).
notify({
level: "error",
title: t("editor.partlyCreated", { done: done.length, total: N }),
message: failureReason(e),
});
markReported(e);
}
throw e;
}
return { files: N };
},
@@ -324,6 +368,9 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
onClose();
},
onError: (e) => notifyActionError(e, t("editor.failed")),
onSettled: () => {
creating.current = false;
},
});
const sel = segments.find((s) => s.id === selId) ?? segments[0];
+3 -1
View File
@@ -64,10 +64,12 @@ export default function VirtualFeed({
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
fetchNextPage={fetchNextPage}
renderItem={(v, itemWidth) => (
keyboardNav
renderItem={(v, itemWidth, index) => (
<VideoCard
video={v}
view={view}
navIndex={index}
// Only the one-line row (thumb:false) reads its own width — it stands columns down as it
// narrows. Everything else takes a stable 0, so a resize (which changes itemWidth every
// frame) doesn't blow past VideoCard's memo for views that wouldn't do anything with it.
+7 -9
View File
@@ -30,7 +30,7 @@ import { setLanguage, type LangCode } from "../i18n";
import LanguageSwitcher from "./LanguageSwitcher";
import { Button } from "./ui/Button";
import { trapTab, useDialogChrome } from "./ui/dialogFocus";
import { isTopmost, pushLayer, removeLayer } from "../lib/layerStack";
import { anyBlockingLayerAbove, isTopmost, pushLayer, removeLayer } from "../lib/layerStack";
import { inputCls } from "./ui/form";
import { entryHash, entrySearch } from "../lib/entryUrl";
import type { VerifyEntry } from "../lib/useVerifyEntry";
@@ -275,11 +275,9 @@ function Lightbox({ src, label, onClose }: { src: string; label: string; onClose
// and the wizard get theirs; the body-scroll lock moved with it, and round 7 added the layer
// membership below.
//
// It is the THIRD of four to get the contract, not the last: `PlayerModal` is also
// `role="dialog" aria-modal="true"` and still hand-rolls its own scroll lock with no accessible
// name, no Tab trap and no focus restore. Saying "the fourth shell" here read as though the sweep
// were finished, which is the false-completeness that let a demo refusal sit unconverted for a
// whole round. It is on ROADMAP.md instead (round 7).
// It was the third of four `aria-modal` shells to get the contract; `PlayerModal`, the fourth,
// joined in E11 S2, so all four (`ui/Modal`, the wizard, this, the player) now share
// `useDialogChrome` and its one counted scroll lock.
const panelRef = useRef<HTMLDivElement>(null);
useDialogChrome(panelRef);
// Keep a stable handler across renders so the stack id is assigned once per mount (as in ui/Modal).
@@ -295,14 +293,14 @@ function Lightbox({ src, label, onClose }: { src: string; label: string; onClose
// landing page grows (deep-review round 7).
const id = pushLayer();
const onKey = (e: KeyboardEvent) => {
if (!isTopmost(id)) return;
if (e.key === "Escape") {
if (e.key === "Escape" && isTopmost(id)) {
e.stopPropagation();
onCloseRef.current();
return;
}
// The trap defers only to a layer that owns the keyboard, not to a hover card under the pointer.
const panel = panelRef.current;
if (e.key === "Tab" && panel) trapTab(e, panel, panel);
if (e.key === "Tab" && panel && !anyBlockingLayerAbove(id)) trapTab(e, panel, panel);
};
window.addEventListener("keydown", onKey);
return () => {
@@ -19,7 +19,7 @@ vi.mock("react-i18next", async (importOriginal) => {
});
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
const { notify } = vi.hoisted(() => ({
notify: vi.fn((_n: { level: string; message: string }) => 0),
notify: vi.fn((_n: { level: string; title?: string; message: string }) => 0),
}));
vi.mock("../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/notifications")>()),
@@ -28,6 +28,7 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
import PlexCollectionEditor from "./PlexCollectionEditor";
import UploadDialog from "./UploadDialog";
import DownloadDialog from "./DownloadDialog";
import { api, HttpError } from "../lib/api";
beforeAll(() => {
@@ -48,7 +49,9 @@ afterEach(() => {
vi.restoreAllMocks();
});
const errors = () => notify.mock.calls.filter(([n]) => n.level === "error").map(([n]) => n.message);
// What failed, as the toast names it: the title (E11 S2b — the title says WHAT, the line under it WHY).
const errors = () =>
notify.mock.calls.filter(([n]) => n.level === "error").map(([n]) => n.title ?? n.message);
const client = () => new QueryClient({ defaultOptions: { queries: { retry: false } } });
describe("the Plex collection editor", () => {
@@ -144,3 +147,20 @@ describe("the upload dialog", () => {
expect(errors()).toEqual([]);
});
});
describe("the download dialog", () => {
it("names a queueing the server refused with no dialog of its own", async () => {
// Its catch said "the global error dialog already surfaced the reason" — true for a quota 422,
// not for a 403/404/429 or a lost connection, where the dialog just stayed open (E11 S2b).
vi.spyOn(api, "downloadProfiles").mockResolvedValue([{ id: 1, name: "Best" }] as never);
vi.spyOn(api, "enqueueDownload").mockRejectedValue(new HttpError(403, "nope"));
render(
<QueryClientProvider client={client()}>
<DownloadDialog source="https://example.com/v" onClose={() => {}} />
</QueryClientProvider>,
);
await screen.findByText("Best");
fireEvent.click(screen.getByText("downloads.dialog.submit"));
await waitFor(() => expect(errors()).toEqual(["errors.writeFailed.enqueueDownload"]));
});
});
@@ -63,12 +63,12 @@ const user = {
is_suspended: false,
};
function mount() {
function mount(u: typeof user = user) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<AccountPanel user={user as any} onClose={() => {}} />
<AccountPanel user={u as any} onClose={() => {}} />
</QueryClientProvider>,
);
}
@@ -87,11 +87,13 @@ beforeEach(() => {
});
afterEach(cleanup);
// What each error toast names: its title when it has one (a failure reported with its reason under
// it — E11 S2b), else the specific sentence (the no-mail case).
const errorMessages = () =>
notifyMock.mock.calls
.map(([a]) => a as { level?: string; message?: string })
.map(([a]) => a as { level?: string; title?: string; message?: string })
.filter((a) => a.level === "error")
.map((a) => a.message);
.map((a) => a.title ?? a.message);
describe("the account panel reports a refused recovery action", () => {
it("says the link could not be minted instead of leaving the button inert", async () => {
@@ -105,13 +107,21 @@ describe("the account panel reports a refused recovery action", () => {
});
it("names the no-mail case, so a no-SMTP instance does not read as a bug", async () => {
// `req` has already localized the refusal into `detail` (lib/api/core); the toast carries it.
apiMock.resendUserVerification.mockRejectedValue(
new HttpError(409, "email off", { code: "resend", reason: "email_off" }),
new HttpError(409, "errors.admin.email_off", { code: "resend", reason: "email_off" }),
);
await clickAction("users.account.resend");
await waitFor(() => expect(errorMessages()).toContain("users.pendingAccess.resendEmailOff"));
await waitFor(() =>
expect(notifyMock).toHaveBeenCalledWith(
expect.objectContaining({
title: "users.pendingAccess.resendFailed",
message: "errors.admin.email_off",
}),
),
);
});
it("reports a genuine send failure as a failure, not as a sent mail", async () => {
@@ -138,3 +148,15 @@ describe("the account panel reports a refused recovery action", () => {
await waitFor(() => expect(errorMessages()).toContain("users.pendingAccess.activateFailed"));
});
});
describe("the account panel on the demo account", () => {
// The demo account's address is never confirmed, so "send the confirmation link again" looked
// available — and the server refused it as "nothing left to confirm", beside a row reading
// "unconfirmed" (deep-review E11 round 3).
it("does not offer it a confirmation mail, which the server refuses", async () => {
mount({ ...user, is_demo: true });
await screen.findByText("users.account.recovery");
const resend = screen.getByText("users.account.resend").closest("button")!;
expect(resend.hasAttribute("disabled")).toBe(true);
});
});
@@ -331,16 +331,20 @@ describe("inviting someone the admin whitelisted", () => {
it("says the mail was never sent when the instance has no SMTP", async () => {
listMock.mockResolvedValue([invite({ status: "approved" })]);
inviteMock.mockRejectedValue(
new HttpError(409, "ignored", { code: "invite_send", reason: "email_off" }),
new HttpError(409, "errors.admin.email_off", { code: "invite_send", reason: "email_off" }),
);
await open();
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
// A button the admin pressed must not answer with a success toast for a mail nobody received.
// `req` has already localized the refusal into `detail` (lib/api/core); the toast carries it.
await waitFor(() =>
expect(notifyMock).toHaveBeenCalledWith(
expect.objectContaining({ message: "settings.invites.inviteEmailOff" }),
expect.objectContaining({
title: "settings.invites.inviteFailed",
message: "errors.admin.email_off",
}),
),
);
});
@@ -159,17 +159,21 @@ describe("the stuck sign-ups section", () => {
const { HttpError } = await import("../lib/api");
pendingMock.mockResolvedValue([row({ email_verified: false })]);
resendMock.mockRejectedValue(
new HttpError(409, "ignored", { code: "resend", reason: "email_off" }),
new HttpError(409, "errors.admin.email_off", { code: "resend", reason: "email_off" }),
);
mount();
fireEvent.click(await screen.findByLabelText("users.pendingAccess.resend"));
// Mail is fail-soft everywhere else, which is what left the 0.78.5 sign-ups with nothing to fall
// back on. A button the admin pressed must not answer with a success toast.
// back on. A button the admin pressed must not answer with a success toast. `req` has already
// localized the refusal into `detail` (lib/api/core); the toast carries it.
await waitFor(() =>
expect(notifyMock).toHaveBeenCalledWith(
expect.objectContaining({ message: "users.pendingAccess.resendEmailOff" }),
expect.objectContaining({
title: "users.pendingAccess.resendFailed",
message: "errors.admin.email_off",
}),
),
);
});
@@ -62,10 +62,12 @@ function QuotaModal({
unlimited: state!.unlimited,
}),
onSuccess: done,
meta: { failTitle: "errors.writeFailed.downloadQuota" },
});
const reset = useMutation({
mutationFn: () => api.adminResetDownloadQuota(userId),
onSuccess: done,
meta: { failTitle: "errors.writeFailed.downloadQuotaReset" },
});
if (!state) return null;
const upd = (p: Partial<typeof state>) => setForm({ ...state, ...p });
@@ -41,6 +41,7 @@ export function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: ()
qc.invalidateQueries({ queryKey: qk.downloads() });
onClose();
},
meta: { failTitle: "errors.writeFailed.downloadDetails" },
});
return (
@@ -12,7 +12,7 @@ vi.mock("react-i18next", async (importOriginal) => {
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
import { DownloadRow } from "./downloadRow";
import { DownloadRow, IconBtn } from "./downloadRow";
import type { DownloadJob } from "../lib/api";
const baseJob: DownloadJob = {
@@ -116,3 +116,27 @@ describe("DownloadRow file kind", () => {
expect(container.querySelector("img")).not.toBeNull();
});
});
describe("DownloadRow · keyboard", () => {
it("is a list item only when it is given a position", () => {
const { container, rerender } = render(<DownloadRow job={baseJob} navIndex={3} />);
expect(container.firstElementChild?.getAttribute("data-nav-index")).toBe("3");
rerender(<DownloadRow job={baseJob} />);
expect(container.firstElementChild?.hasAttribute("data-nav-index")).toBe(false);
});
it("marks the action that opens the row, and only that one", () => {
render(
<DownloadRow job={baseJob} navIndex={0}>
<IconBtn open onClick={() => {}} title="play">
p
</IconBtn>
<IconBtn onClick={() => {}} title="delete">
d
</IconBtn>
</DownloadRow>,
);
expect(screen.getByTitle("play").hasAttribute("data-nav-open")).toBe(true);
expect(screen.getByTitle("delete").hasAttribute("data-nav-open")).toBe(false);
});
});
+12 -1
View File
@@ -8,6 +8,7 @@ import { api, type DownloadJob } from "../lib/api";
import { notify } from "../lib/notifications";
import { copyText } from "../lib/clipboard";
import { formatBytes, formatDuration, formatEta, formatSpeed, relativeTime } from "../lib/format";
import { navItemAttrs } from "../lib/listNav";
// Download-row presentation + the small shared primitives (status pill, icon button, usage bar),
// split out of DownloadCenter (all purely prop-driven, no shared state). Rendered by the queue,
@@ -112,16 +113,20 @@ export function IconBtn({
onClick,
title,
danger,
open,
children,
}: {
onClick: () => void;
title: string;
danger?: boolean;
/** The row's "open" action (play): Enter on a keyboard-walked row (lib/listNav) clicks it. */
open?: boolean;
children: ReactNode;
}) {
return (
<button
onClick={onClick}
data-nav-open={open ? "" : undefined}
title={title}
className={clsx(
"p-1.5 rounded-md hover:bg-surface text-muted transition",
@@ -201,16 +206,22 @@ function ChannelLine({ job }: { job: DownloadJob }) {
export function DownloadRow({
job,
onShowError,
navIndex,
children,
}: {
job: DownloadJob;
onShowError?: (job: DownloadJob) => void;
/** Its position in a keyboard-walkable list (lib/listNav). */
navIndex?: number;
children?: ReactNode;
}) {
const { t } = useTranslation();
const running = job.status === "running";
return (
<div className="flex gap-3 p-2.5 rounded-xl glass-card glass-hover transition">
<div
{...(navIndex !== undefined && navItemAttrs(navIndex, jobTitle(job)))}
className="flex gap-3 p-2.5 rounded-xl glass-card glass-hover transition"
>
<Thumb job={job} />
<div className="min-w-0 flex-1">
<div className="font-medium leading-snug line-clamp-1 flex items-center gap-1.5">
@@ -0,0 +1,147 @@
import { describe, expect, it } from "vitest";
// Every rail and every card of a card list is walkable by the keyboard (E11 S4, lib/listNav). Two
// opt-in halves, both checked here, because either one missing silently leaves the keyboard out —
// and a page where `j` walks two rails and skips the third reads as broken, not as unfinished:
// - a `Carousel`, a `VirtualGrid` and a `DataTable` say `keyboardNav`, and a card says `navIndex`;
// - a card that says `navIndex` sits in a list that registers with listNav — a `NavGrid`, a
// `Carousel`, or a `VirtualGrid` (its `renderItem`). In a plain `<div className="grid">` the card
// is marked as an item of a list nobody walks.
// Each exception is listed with its reason.
//
// The assertion pins the SET of offending tags, never a count, and checks the glob matched at all: a
// guard whose glob stops matching is green over nothing (E10 review round 4, round 7).
const WALKABLE = ["Carousel", "VirtualGrid", "DataTable"];
const CARDS = [
"VideoCard",
"PlexPosterCard",
"SeasonCard",
"EpisodeCard",
"PosterTile",
"ShelfTile",
"DownloadRow",
];
const LISTS = ["NavGrid", "Carousel", "VirtualGrid"];
/** `path#tag#n` → why it is not a keyboard list. An entry that no longer matches a tag fails the test. */
const EXEMPT: Record<string, string> = {
"components/AuditLog.tsx#DataTable#1":
"an admin log read in place: its rows open nothing, so stepping onto them would only move a ring",
};
interface Tag {
start: number;
end: number;
text: string;
}
const stripComments = (source: string) =>
source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:"'`])\/\/.*$/gm, "$1");
/** Each opening `<Name …>` tag, found by brace depth — a JSX attribute holds its own `>`
* (`title={<span>…</span>}`, an arrow `=>`), so the first `>` is not the end of the tag. Takes
* comment-free code, so the positions line up with `inList`. */
export function openingTags(code: string, name: string): Tag[] {
const tags: Tag[] = [];
const start = new RegExp(`<${name}(?=[\\s/>])`, "g");
for (let m = start.exec(code); m; m = start.exec(code)) {
let depth = 0;
let quote: string | null = null;
let i = m.index + name.length + 1;
for (; i < code.length; i++) {
const c = code[i];
if (quote) {
if (c === quote) quote = null;
} else if (depth === 0 && (c === '"' || c === "'")) quote = c;
else if (c === "{") depth++;
else if (c === "}") depth--;
else if (c === ">" && depth === 0) break;
}
tags.push({ start: m.index, end: i + 1, text: code.slice(m.index, i + 1) });
}
return tags;
}
/** Whether `pos` lies inside an open list element: between `<List …>` and `</List>`, or inside a
* list's own tag (a `renderItem={…}` attribute). */
export function inList(code: string, pos: number): boolean {
return LISTS.some((name) => {
let open = 0;
for (const tag of openingTags(code, name)) {
if (tag.start > pos) break;
if (tag.end > pos) return true;
if (!tag.text.endsWith("/>")) open++;
}
return open > code.slice(0, pos).split(`</${name}>`).length - 1;
});
}
const has = (tag: Tag, prop: string) => new RegExp(`\\s${prop}\\b`).test(tag.text);
function offenders(): { hits: string[]; tags: number; exempted: Set<string> } {
const files = import.meta.glob("./**/*.tsx", { query: "?raw", import: "default", eager: true });
const hits: string[] = [];
const exempted = new Set<string>();
let tags = 0;
for (const [rel, source] of Object.entries(files)) {
const path = rel.replace(/^\.\//, "components/");
if (/\.test\.tsx$/.test(path)) continue;
const code = stripComments(String(source));
const report = (id: string, why: string) => {
if (id in EXEMPT) exempted.add(id);
else hits.push(`${id} ${why}`);
};
for (const name of WALKABLE) {
openingTags(code, name).forEach((tag, n) => {
tags++;
if (!has(tag, "keyboardNav")) report(`${path}#${name}#${n + 1}`, "no keyboardNav");
});
}
for (const name of CARDS) {
openingTags(code, name).forEach((tag, n) => {
tags++;
const id = `${path}#${name}#${n + 1}`;
if (!has(tag, "navIndex")) report(id, "no navIndex");
else if (!inList(code, tag.start)) report(id, "not inside a NavGrid/Carousel/VirtualGrid");
});
}
}
return { hits: hits.sort(), tags, exempted };
}
describe("the keyboard-list wiring contract", () => {
it("has no rail or card list the keyboard cannot walk", () => {
const { hits, tags, exempted } = offenders();
expect(tags).toBeGreaterThan(15);
expect(hits).toEqual([]);
expect(Object.keys(EXEMPT).filter((id) => !exempted.has(id))).toEqual([]);
});
it("reads a tag to its real end, past the > inside its attributes", () => {
const code = stripComments(`
<Carousel
title={<span className="x">{title}</span>}
onSeeAll={() => go(1 > 0)}
keyboardNav
>
// <Carousel keyboardNav> in a comment is no tag
<Carousel ariaLabel="a > b">
`);
const tags = openingTags(code, "Carousel");
expect(tags).toHaveLength(2);
expect(has(tags[0]!, "keyboardNav")).toBe(true);
expect(has(tags[1]!, "keyboardNav")).toBe(false);
});
it("tells a card inside a list from one in a plain grid", () => {
const code = `
<NavGrid className="grid">{a.map((c, i) => <PosterTile navIndex={i} />)}</NavGrid>
<div className="grid">{a.map((c, i) => <PosterTile navIndex={i} />)}</div>
<VirtualGrid renderItem={(v, w, i) => <VideoCard navIndex={i} />} />
`;
const at = (n: number) => openingTags(code, "PosterTile")[n]!.start;
expect(inList(code, at(0))).toBe(true);
expect(inList(code, at(1))).toBe(false);
expect(inList(code, openingTags(code, "VideoCard")[0]!.start)).toBe(true);
});
});
@@ -1,16 +1,10 @@
import { useTranslation } from "react-i18next";
import { Repeat, Repeat1, Shuffle, SkipBack, SkipForward } from "lucide-react";
import type { AutoMode, LoopMode } from "../lib/playerModes";
// Presentational controls for the YouTube modal player (PlayerModal), split out of the god component —
// the sibling of plexPlayerControls.tsx. No state or effects here; all behaviour comes in via props.
// Persistent playback settings (stored in users.preferences). Auto-advance = what plays when a video
// ends; loop = whether it repeats the current video ("one"), wraps the list at its ends ("all"), or
// neither ("off"). Both apply to any queued player (feed or playlist).
export type AutoMode = "off" | "next" | "prev" | "random";
export type LoopMode = "off" | "one" | "all";
export const AUTO_MODES: AutoMode[] = ["off", "next", "prev", "random"];
export const LOOP_MODES: LoopMode[] = ["off", "one", "all"];
// The auto-advance / loop modes it renders are defined in `lib/playerModes`.
/** The queue transport cluster — prev / counter / next, then the auto-advance and loop mode toggles.
* Rendered in two places with identical logic: the modal's queue bar (`variant:"modal"`, normal glass
+7 -2
View File
@@ -2,7 +2,7 @@ import { Play, X } from "lucide-react";
import type { TFunction } from "i18next";
import type { Video } from "../lib/api";
import { formatDuration } from "../lib/format";
import type { Column } from "./ui/tableHeader";
import type { Column } from "../lib/tableColumns";
// Column definitions for the playlist detail table, in their own file like `channelColumns.tsx`.
//
@@ -68,7 +68,12 @@ export function playlistColumns({
<Play className="w-4 h-4 text-white fill-current" />
</span>
</button>
<button onClick={() => onPlay(v)} className="min-w-0 flex-1 text-left">
<button
onClick={() => onPlay(v)}
// The row's "open" element: Enter on a keyboard-walked row (lib/listNav) plays it.
data-nav-open=""
className="min-w-0 flex-1 text-left"
>
<span className="block text-[0.8125rem] text-fg truncate">{v.title}</span>
</button>
</div>
+9 -6
View File
@@ -11,6 +11,7 @@ import { useDebounced } from "../lib/useDebounced";
import { LoadingState, StateMessage } from "./ui/QueryState";
import PosterTile from "./PosterTile";
import PlexLibraryPicker from "./PlexLibraryPicker";
import NavGrid from "./ui/NavGrid";
// A collection tile's sub-line: item count · total runtime (each dropped when unknown, e.g. show-only
// collections have no movie runtime).
@@ -49,10 +50,11 @@ export function PlexPlaylistsBrowse({ onOpen }: { onOpen: (id: number) => void }
return <p className="text-muted text-sm">{t("plex.playlist.none")}</p>;
return (
<div className={GRID}>
{playlists.map((p) => (
<NavGrid className={GRID}>
{playlists.map((p, i) => (
<PosterTile
key={p.id}
navIndex={i}
title={p.title}
subtitle={t("plex.browse.items", { count: p.item_count })}
topRight={<ListMusic className="h-4 w-4 text-white/80 drop-shadow" />}
@@ -60,7 +62,7 @@ export function PlexPlaylistsBrowse({ onOpen }: { onOpen: (id: number) => void }
className="h-28"
/>
))}
</div>
</NavGrid>
);
}
@@ -215,10 +217,11 @@ export function PlexCollectionsBrowse({ onPick }: { onPick: (id: string, title:
) : collections.length === 0 ? (
<p className="text-muted text-sm">{t("plex.filter.collectionEmpty")}</p>
) : (
<div className={GRID}>
{collections.map((c) => (
<NavGrid className={GRID}>
{collections.map((c, i) => (
<PosterTile
key={c.id}
navIndex={i}
title={c.title}
art={c.art}
subtitle={collectionMeta(c, t)}
@@ -234,7 +237,7 @@ export function PlexCollectionsBrowse({ onPick }: { onPick: (id: string, title:
className="h-28"
/>
))}
</div>
</NavGrid>
)}
</div>
);
+48 -1
View File
@@ -5,7 +5,7 @@
// aria-label — a dropped label silently reintroduces an unnamed button for screen-reader users. i18n is
// key-passthrough, so the asserted names are the i18n keys.
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
@@ -100,3 +100,50 @@ describe("EpisodeCard a11y", () => {
expect(screen.getByRole("button", { name: "plex.playlist.addEpisode" })).toBeTruthy();
});
});
// E11 S4b: in a keyboard-walkable grid or rail the WHOLE card is the item (the ring goes round the
// poster and its title, D2) and the poster — the card's own role=button — is what Enter opens.
describe("Plex cards in a keyboard-walkable list", () => {
const cases = [
[
"PlexPosterCard",
(onOpen: () => void, navIndex?: number) => (
<PlexPosterCard
card={movie()}
onOpen={onOpen}
onInfo={vi.fn()}
onToggleWatched={vi.fn()}
navIndex={navIndex}
/>
),
],
[
"SeasonCard",
(onOpen: () => void, navIndex?: number) => (
<SeasonCard se={season()} onOpen={onOpen} navIndex={navIndex} />
),
],
[
"EpisodeCard",
(onOpen: () => void, navIndex?: number) => (
<EpisodeCard ep={episode()} onPlay={onOpen} navIndex={navIndex} />
),
],
] as const;
it.each(cases)("%s: the root is the item and the poster is its open element", (_, card) => {
const onOpen = vi.fn();
const { container } = render(card(onOpen, 3));
const root = container.firstElementChild as HTMLElement;
expect(root.getAttribute("data-nav-index")).toBe("3");
const open = root.querySelector<HTMLElement>("[data-nav-open]")!;
expect(open.getAttribute("role")).toBe("button");
fireEvent.click(open);
expect(onOpen).toHaveBeenCalledTimes(1);
});
it.each(cases)("%s: outside a list it is no item", (_, card) => {
const { container } = render(card(vi.fn()));
expect(container.querySelector("[data-nav-index]")).toBeNull();
});
});
+22 -5
View File
@@ -4,6 +4,7 @@ import { type CSSProperties } from "react";
import { formatRuntime } from "../lib/format";
import { Carousel } from "./ui/Carousel";
import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api";
import { navItemAttrs } from "../lib/listNav";
// Presentational Plex cards + strips, split out of PlexBrowse (all purely prop-driven, no shared
// state). Rendered by the browse grid, the person page, and the series show/season views.
@@ -52,6 +53,7 @@ export function PlexPosterCard({
onInfo,
onToggleWatched,
menuProps,
navIndex,
}: {
card: PlexCard;
onOpen: () => void;
@@ -61,6 +63,8 @@ export function PlexPosterCard({
* A function of the card rather than a ready-made object, so every card surface in the app takes
* `menuProps={menu.triggerProps}` and reads the same at every call site. */
menuProps?: (c: PlexCard) => Record<string, unknown>;
/** Position in a keyboard-walkable grid or rail (lib/listNav); the poster is what Enter opens. */
navIndex?: number;
}) {
const { t } = useTranslation();
const inProgress = (card.position_seconds ?? 0) > 0 && card.status !== "watched";
@@ -75,6 +79,7 @@ export function PlexPosterCard({
<div
className="group text-left"
{...menuProps?.(card)}
{...(navIndex !== undefined && navItemAttrs(navIndex, card.title))}
// content-visibility keeps off-screen posters cheap to render (smoother long-list scroll).
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 300px" } as CSSProperties}
>
@@ -82,6 +87,7 @@ export function PlexPosterCard({
onClick={onOpen}
role="button"
tabIndex={0}
data-nav-open=""
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
@@ -242,11 +248,13 @@ export function SeasonCard({
onOpen,
onToggleWatched,
onAdd,
navIndex,
}: {
se: PlexSeasonDetail;
onOpen: () => void;
onToggleWatched?: () => void;
onAdd?: () => void;
navIndex?: number;
}) {
const { t } = useTranslation();
const watched = se.status === "watched";
@@ -254,11 +262,15 @@ export function SeasonCard({
? t("plex.series.markSeasonUnwatched")
: t("plex.series.markSeasonWatched");
return (
<div className="group text-left">
<div
className="group text-left"
{...(navIndex !== undefined && navItemAttrs(navIndex, se.title))}
>
<div
onClick={onOpen}
role="button"
tabIndex={0}
data-nav-open=""
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
@@ -357,7 +369,7 @@ export function CastStrip({
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
>
<h2 className="text-sm font-semibold mb-3">{t("plex.info.cast")}</h2>
<Carousel gap="gap-4" ariaLabel={t("plex.info.cast")}>
<Carousel gap="gap-4" ariaLabel={t("plex.info.cast")} keyboardNav={clickable}>
{cast.map((c, i) => {
const inner = (
<>
@@ -390,6 +402,7 @@ export function CastStrip({
return clickable ? (
<button
key={i}
{...navItemAttrs(i)}
onClick={() =>
onOpenPerson ? onOpenPerson(c.name) : onFilter?.({ actors: [c.name] })
}
@@ -427,10 +440,11 @@ export function RelatedStrip({
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
>
<h2 className="text-sm font-semibold mb-3">{t("plex.series.related")}</h2>
<Carousel gap="gap-3" ariaLabel={t("plex.series.related")}>
{related.map((r) => (
<Carousel gap="gap-3" ariaLabel={t("plex.series.related")} keyboardNav>
{related.map((r, i) => (
<button
key={r.id}
{...navItemAttrs(i)}
onClick={() => onOpen(r.id)}
className="group w-[130px] shrink-0 text-left"
>
@@ -463,12 +477,14 @@ export function EpisodeCard({
onAdd,
onToggleWatched,
withShowTitle,
navIndex,
}: {
ep: PlexCard;
onPlay: () => void;
onAdd?: () => void;
onToggleWatched?: () => void;
withShowTitle?: boolean;
navIndex?: number;
}) {
const { t } = useTranslation();
const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched";
@@ -478,11 +494,12 @@ export function EpisodeCard({
? Math.min(100, Math.round(((ep.position_seconds ?? 0) / ep.duration_seconds) * 100))
: 0;
return (
<div className="group">
<div className="group" {...(navIndex !== undefined && navItemAttrs(navIndex, ep.title))}>
<div
onClick={onPlay}
role="button"
tabIndex={0}
data-nav-open=""
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
+20 -11
View File
@@ -29,6 +29,7 @@ import { Carousel } from "./ui/Carousel";
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
import PlexCollectionEditor from "./PlexCollectionEditor";
import { LoadingState, StateMessage } from "./ui/QueryState";
import NavGrid from "./ui/NavGrid";
// The Plex sub-router's view branches, split out of PlexBrowse. Each is invoked by PlexBrowse with
// shell callbacks as props (onBack/onPlay/onFilter/…) — no view reaches back into the shell's state,
@@ -151,10 +152,11 @@ export function PlexPersonView({
cards.length > 0 ? (
<section className="mb-5">
<h2 className="text-sm font-semibold mb-3">{t(titleKey, { count: cards.length })}</h2>
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
{cards.map((c) => (
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
{cards.map((c, i) => (
<PlexPosterCard
key={c.id}
navIndex={i}
card={c}
onOpen={() => onOpenCard(c)}
onInfo={() => onInfoCard(c)}
@@ -162,7 +164,7 @@ export function PlexPersonView({
menuProps={pinMenu.triggerProps}
/>
))}
</div>
</NavGrid>
</section>
) : null;
@@ -241,11 +243,16 @@ export function PlexPersonView({
{/* "Known for" — the person's most-popular titles that are in our library (E5 S6). */}
{d.known_for.length > 0 && (
<section className="mb-5">
<Carousel title={t("plex.person.knownFor")} ariaLabel={t("plex.person.knownFor")}>
{d.known_for.map((c) => (
<Carousel
title={t("plex.person.knownFor")}
ariaLabel={t("plex.person.knownFor")}
keyboardNav
>
{d.known_for.map((c, i) => (
<div key={c.id} className="w-[150px] shrink-0">
<PlexPosterCard
card={c}
navIndex={i}
onOpen={() => onOpenCard(c)}
onInfo={() => onInfoCard(c)}
onToggleWatched={toggle}
@@ -501,10 +508,11 @@ export function PlexShowView({
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
>
<h2 className="text-sm font-semibold mb-3">{t("plex.series.seasons")}</h2>
<div className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3">
{d.seasons.map((se) => (
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3">
{d.seasons.map((se, i) => (
<SeasonCard
key={se.id}
navIndex={i}
se={se}
onOpen={() => onOpenSeason(se.id)}
onToggleWatched={() => markSeason(se.id, se.status !== "watched")}
@@ -520,7 +528,7 @@ export function PlexShowView({
}
/>
))}
</div>
</NavGrid>
</section>
{show.cast.length > 0 && showCast && (
@@ -684,17 +692,18 @@ export function PlexSeasonView({
</div>
</DetailHero>
<div className="grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-x-3 gap-y-4">
{se.episodes.map((ep) => (
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-x-3 gap-y-4">
{se.episodes.map((ep, i) => (
<EpisodeCard
key={ep.id}
navIndex={i}
ep={ep}
onPlay={() => onPlay(ep.id, allKeys)}
onAdd={() => setAddTarget({ kind: "single", ratingKey: ep.id, title: ep.title })}
onToggleWatched={() => markEpisode(ep)}
/>
))}
</div>
</NavGrid>
</>
)}
@@ -54,13 +54,18 @@ describe("ActionMenu", () => {
]);
});
it("selecting an item fires its onSelect and closes the menu", () => {
// Close first, act on a later task: floating-ui hands the focus back to the trigger from a microtask
// as the menu unmounts, and an action run in that same batch (a confirm dialog) had its focus taken
// back to the trigger behind the dialog's scrim — measured in Chrome, E11 S2. jsdom does not run
// that focus race, so the ORDER is what is pinned: the menu is gone before the action runs.
it("selecting an item closes the menu first, then fires its onSelect once", async () => {
const onSelect = vi.fn();
renderMenu([{ id: "sync", label: "Sync", onSelect }]);
fireEvent.click(screen.getByRole("button", { name: "Catalog actions" }));
fireEvent.click(screen.getByText("Sync"));
expect(onSelect).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("menuitem", { hidden: true })).toBeNull();
expect(onSelect).not.toHaveBeenCalled();
await vi.waitFor(() => expect(onSelect).toHaveBeenCalledTimes(1));
});
it("a disabled item is inert — rendered disabled and never fires onSelect", () => {
@@ -125,3 +130,67 @@ describe("ActionMenu", () => {
expect(screen.getByTestId("custom-trigger")).toBeTruthy();
});
});
// PageUp/PageDown in a roving menu (E11, owner request) — the same page as the palette's and the
// combobox's (lib/listPaging); here the keyboard's row holds real DOM focus (the `tabindex=0` rover).
describe("ActionMenu · paging", () => {
it("moves the roving focus a page at a time, over disabled rows, stopping at the ends", () => {
renderMenu(
Array.from({ length: 25 }, (_, i) => ({
id: `r${i}`,
label: `Row ${i}`,
onSelect: vi.fn(),
disabled: i === 20,
})),
);
fireEvent.click(screen.getByRole("button", { name: "Catalog actions" }));
const menu = screen.getByRole("menu", { hidden: true });
const rover = () =>
screen
.getAllByRole("menuitem", { hidden: true })
.find((el) => el.getAttribute("tabindex") === "0")?.textContent;
expect(fireEvent.keyDown(menu, { key: "PageDown" })).toBe(false);
expect(rover()).toBe("Row 10");
fireEvent.keyDown(menu, { key: "PageDown" });
expect(rover()).toBe("Row 21"); // Row 20 is disabled
fireEvent.keyDown(menu, { key: "PageDown" });
expect(rover()).toBe("Row 24");
fireEvent.keyDown(menu, { key: "PageUp" });
expect(rover()).toBe("Row 14");
});
it("pages only the submenu the key was pressed in, not the menu around it", () => {
// A submenu's surface sits inside its parent's in the React tree, so the key bubbles through the
// parent's handler too; paging both would drag the focus back out to the parent's row.
renderMenu([
{
id: "move",
label: "Move to",
items: Array.from({ length: 25 }, (_, i) => ({
id: `s${i}`,
label: `Sub ${i}`,
onSelect: vi.fn(),
})),
},
...Array.from({ length: 12 }, (_, i) => ({
id: `p${i}`,
label: `Top ${i}`,
onSelect: vi.fn(),
})),
]);
fireEvent.click(screen.getByRole("button", { name: "Catalog actions" }));
fireEvent.click(screen.getByText("Move to"));
const rovers = () =>
screen
.getAllByRole("menuitem", { hidden: true })
.filter((el) => el.getAttribute("tabindex") === "0")
.map((el) => el.textContent ?? "");
const outer = () => rovers().filter((label) => !label.startsWith("Sub"));
const before = outer();
fireEvent.keyDown(screen.getByText("Sub 0").closest("button")!, { key: "PageDown" });
expect(rovers()).toContain("Sub 10");
expect(outer()).toEqual(before);
});
});
+3 -5
View File
@@ -1,6 +1,6 @@
import { Fragment, useState, type ReactNode } from "react";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
import { PopoverItem, PopoverSurface, closeThenAct, useAnchoredPopover } from "./AnchoredPopover";
// A reusable "⋯ more actions" overflow menu: a compact trigger that opens a list of one-shot actions.
// Built on the R8 AnchoredPopover platform (arrows/typeahead/Escape/anchor-flip), same as ViewSwitcher
@@ -82,10 +82,8 @@ export function MenuItemList({
disabled={it.disabled}
label={it.label}
title={it.title}
onSelect={() => {
it.onSelect();
onClose();
}}
// Any row may open a dialog (a confirm), so every row closes first — see closeThenAct.
onSelect={() => closeThenAct(onClose, it.onSelect)}
className={`${ROW}${it.danger ? " text-danger" : ""}`}
>
{it.icon && (
@@ -0,0 +1,60 @@
// @vitest-environment jsdom
//
// Which surfaces give the focus back to a card focused as a whole (deep-review E11 rounds 2 and 3). A
// menu does, by hand — floating-ui's own return would land on the card's first link (see
// ContextMenu.dom.test). A hover card never returned focus, and one closing after the reader clicked a
// link in it must not pull the focus back to the card under it.
import { describe, it, expect, beforeAll, afterEach } from "vitest";
import { render, screen, cleanup, act } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react";
import { useAnchoredPopover, PopoverSurface } from "./AnchoredPopover";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
afterEach(cleanup);
function Card({ open }: { open: boolean }) {
const pop = useAnchoredPopover({ open, onOpenChange: () => {}, mode: "hover" });
return (
<>
<div tabIndex={-1} data-testid="card" ref={pop.refs.setReference}>
card
</div>
<PopoverSurface popover={pop}>
<a href="#more" data-testid="link">
more
</a>
</PopoverSurface>
</>
);
}
const page = (open: boolean) => (
<FloatingTree>
<Card open={open} />
</FloatingTree>
);
describe("AnchoredPopover · a hover card closing", () => {
it("does not hand the focus back to the card it was opened over", async () => {
const { rerender } = render(page(false));
const card = screen.getByTestId("card");
act(() => card.focus());
rerender(page(true));
act(() => screen.getByTestId("link").focus());
rerender(page(false));
await act(async () => {}); // the hand-back runs in a microtask
expect(document.activeElement).not.toBe(card);
});
});
+103 -6
View File
@@ -3,6 +3,8 @@ import {
useContext,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
type ButtonHTMLAttributes,
@@ -37,6 +39,7 @@ import {
} from "@floating-ui/react";
import { useLayer } from "../../lib/layerStack";
import { useFullscreenPortalHost } from "../../lib/fullscreen";
import { pageStep, pageTarget, scrollParent } from "../../lib/listPaging";
// R8 popover platform. ONE anchored-popover primitive over @floating-ui/react, replacing the ~9
// hand-rolled positioning engines (measured flip / width clamp / rAF scroll-follow / scroller-relative
@@ -52,7 +55,8 @@ import { useFullscreenPortalHost } from "../../lib/fullscreen";
//
// S2 (this layer) adds the keyboard/ARIA model the roles promise, mirroring `ViewSwitcher` (the house's
// textbook menu) but via Floating UI's idioms rather than a second hand-rolled roving engine:
// - useListNavigation → roving focus over the registered items (↑/↓/Home/End, loop), menu/listbox only
// - useListNavigation → roving focus over the registered items (↑/↓/Home/End, loop), menu/listbox only;
// PageUp/PageDown (a page, no loop) are ours, for every list surface incl. the combobox
// - useTypeahead → type-to-focus a matching item by its label
// - FloatingFocusManager → focus moves into the surface on open and RETURNS to the trigger on close
// (a body-portaled popover sits at the end of the document; without this, Tab can't reach it and
@@ -272,7 +276,48 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
},
});
const interactions = useInteractions([click, hover, focus, dismiss, role, listNav, typeahead]);
// PageUp/PageDown: a page of rows at a time (lib/listPaging, shared with the palette). floating-ui's
// list navigation knows only the arrows and Home/End, and the arrows alone crawl through the sidebar's
// channel filter or a Plex studio list. On BOTH elements: a combobox's keys arrive on its input, a
// menu's on the focused row inside the surface. Last in the list, so floating-ui's own keydown has
// already switched to keyboard modality — which is what makes it scroll the new row into view. A
// nested menu's surface bubbles through its parent's in the React tree; the inner one handles the
// key first and marks it, so the outer one stands down.
const onPageKey = (e: React.KeyboardEvent) => {
if ((e.key !== "PageDown" && e.key !== "PageUp") || e.defaultPrevented) return;
const rows = elementsRef.current;
const live = rows.filter((el): el is HTMLElement => el != null);
// No mounted rows — a closed surface, a hover card, a dialog — and the key keeps its own meaning.
if (!live.length) return;
e.preventDefault();
const step = pageStep(scrollParent(live[0]!), live);
const to = pageTarget(
activeIndex ?? 0,
e.key === "PageDown" ? step : -step,
rows.length,
(i) => !!rows[i] && !rows[i]!.hasAttribute("disabled"),
);
if (to === null) return;
setActiveIndex(to);
setHoverIndex(null);
};
const pageKeyRef = useRef(onPageKey);
pageKeyRef.current = onPageKey;
const paging = useMemo(() => {
const onKeyDown = (e: React.KeyboardEvent) => pageKeyRef.current(e);
return { reference: { onKeyDown }, floating: { onKeyDown } };
}, []);
const interactions = useInteractions([
click,
hover,
focus,
dismiss,
role,
listNav,
typeahead,
paging,
]);
// `hide()` flips this true once the trigger is scrolled out of its clipping scroller; the surface is
// then painted invisible (PopoverSurface) but stays open so it can reappear on scroll-back.
@@ -282,8 +327,9 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
// underneath defers to it. Hover popovers register too (an open tooltip/hover-card should take
// Escape before the surface beneath it — and, when it is a player's own overlay card, keep the
// player alive). An invisible (referenceHidden) surface is NOT a layer: it must not silently eat
// an Escape the user aimed at whatever is actually on screen.
useLayer(opts.open && !referenceHidden, () => opts.onOpenChange(false));
// an Escape the user aimed at whatever is actually on screen. A hover surface is a PASSIVE layer:
// it does not own the keyboard, so the page's own keys keep working under a resting pointer.
useLayer(opts.open && !referenceHidden, () => opts.onOpenChange(false), { passive: isHover });
return {
nodeId,
@@ -348,6 +394,18 @@ const HIGHLIGHT_SELECTED =
const HIGHLIGHT_SELECTED_RING_ONLY =
"data-active:ring-2 data-active:ring-inset data-active:ring-accent/70";
/** A menu row whose action opens a dialog or moves the focus: close the menu FIRST and act once it
* has handed the focus back. floating-ui returns the focus to the trigger from a microtask queued as
* the menu unmounts, so an action run in the same batch had its focus taken back by it: measured in
* Chrome (E11 S2), a confirm dialog opened from the rail's menu landed its focus on Cancel and lost it
* 7 ms later to the row BEHIND its scrim, where Enter activated the row. A fresh task runs after that
* microtask — and a dialog opened there captures the trigger, not a dying menu item, as the element
* to restore the focus to. Every such row goes through here (`ActionMenu`, "Manage views…"). */
export function closeThenAct(close: () => void, act: () => void): void {
close();
setTimeout(act, 0);
}
export function PopoverItem({
children,
onSelect,
@@ -543,12 +601,51 @@ function PopoverSurfaceInner({
// in the accessible-name computation — so an explicit ariaLabel is dead unless we drop the ref.
if (ariaLabel) delete floatingProps["aria-labelledby"];
const zClass = mode === "hover" ? "z-tooltip" : "z-popover";
// A menu/listbox/dialog traps focus into the surface (and returns it on close). A hover card is
// passive, and a COMBOBOX must leave focus in the input (its list is navigated virtually) — neither
// is focus-managed.
const managed = focusManaged ?? (mode !== "hover" && !isCombobox);
// Where focus goes back on close. floating-ui returns it to the element focused before the open —
// but when that element is not a Tab stop itself, to its first TABBABLE descendant instead, even
// when handed the element as a ref (its return runs through `getFirstTabbableElement` either way).
// A card the keyboard is on (lib/listNav gives its root tabindex=-1) is exactly that: measured in
// Chrome (deep-review E11 round 2), j, Shift+F10, Escape left focus on the card's thumbnail link, the
// ring off the card and the next Enter on a control. So for such an element the library's return is
// off and this gives the focus back to it as it is — captured at open, before focus moves into the
// panel, and only when the focus was still in a floating layer (or nowhere) as the panel closed:
// focus the reader moved on to stays where they put it. A menu row's action runs a task later
// (`closeThenAct`), so a dialog it opens still captures the card as the element to return to.
//
// Only for a focus-managed surface, whose return this replaces: a hover card or a combobox never
// returned focus, and a hover card closing after the reader clicked a link in it must not pull the
// focus back to the card under it (deep-review E11 round 3).
const [returnTo] = useState(() => {
const active = document.activeElement;
return managed &&
active instanceof HTMLElement &&
active !== document.body &&
active.tabIndex < 0
? active
: null;
});
useLayoutEffect(() => {
if (!returnTo) return;
return () => {
// "Still in the layer" is read off the portal marker, not the panel: FloatingPortal renders the
// panel a commit after this surface mounts, so the panel's ref is still empty when this effect
// runs (deep-review E11 round 3).
const active = document.activeElement;
const stillOurs =
!active || active === document.body || !!active.closest("[data-floating-ui-portal]");
queueMicrotask(() => {
if (stillOurs && returnTo.isConnected) returnTo.focus({ preventScroll: true });
});
};
}, [returnTo]);
const zClass = mode === "hover" ? "z-tooltip" : "z-popover";
const surface = (
<div
ref={refs.setFloating}
@@ -609,7 +706,7 @@ function PopoverSurfaceInner({
// — in an item-less menu (e.g. a first-run tag editor with only a create input) — the
// input, so such a menu still has a keyboard entry point instead of stranding focus.
initialFocus={initialFocus ?? (isNav && selectedIndex != null ? -1 : undefined)}
returnFocus
returnFocus={!returnTo}
>
{positioned}
</FloatingFocusManager>
+35 -7
View File
@@ -39,7 +39,7 @@ describe("Button primitive (S5)", () => {
expect(btn.querySelector("[data-testid=ic]")).not.toBeNull();
});
it("loading disables the button, marks aria-busy, and blocks clicks", () => {
it("loading marks aria-busy + aria-disabled, blocks clicks, and swaps the icon", () => {
const onClick = vi.fn();
render(
<Button loading icon={<svg data-testid="ic" />} onClick={onClick}>
@@ -47,30 +47,58 @@ describe("Button primitive (S5)", () => {
</Button>,
);
const btn = screen.getByRole("button", { name: "Save" }) as HTMLButtonElement;
expect(btn.disabled).toBe(true);
expect(btn.getAttribute("aria-busy")).toBe("true");
expect(btn.getAttribute("aria-disabled")).toBe("true");
// the leading icon is swapped for a spinner
expect(btn.querySelector("[data-testid=ic]")).toBeNull();
fireEvent.click(btn);
expect(onClick).not.toHaveBeenCalled();
});
it("loading disables even when an explicit disabled={false} is passed", () => {
// E11 S2, measured in Chrome: a focused button that becomes natively disabled drops the focus to
// <body> and never gets it back, so every "Something else" press threw a keyboard user out of Home.
// jsdom has no focus fixup, so what is pinned here is the cause — native `disabled` stays off.
it("loading keeps the button natively enabled, so it keeps the focus", () => {
render(<Button loading>Reroll</Button>);
const btn = screen.getByRole("button", { name: "Reroll" }) as HTMLButtonElement;
expect(btn.disabled).toBe(false);
});
it("loading blocks clicks even when an explicit disabled={false} is passed", () => {
// Regression: `disabled ?? loading` let an explicit boolean disabled shadow loading, so a
// call-site with `disabled={!valid} loading={pending}` (valid → disabled=false) stayed
// clickable mid-mutation and double-submitted. The contract is: loading always disables.
// clickable mid-mutation and double-submitted. The contract is: loading always blocks.
const onClick = vi.fn();
render(
<Button disabled={false} loading onClick={onClick}>
Create
</Button>,
);
const btn = screen.getByRole("button", { name: "Create" }) as HTMLButtonElement;
expect(btn.disabled).toBe(true);
fireEvent.click(btn);
fireEvent.click(screen.getByRole("button", { name: "Create" }));
expect(onClick).not.toHaveBeenCalled();
});
it("a busy submit button cancels the form's submission", () => {
// Enter in a field submits through a click on the default button; the guard cancels that click.
const onSubmit = vi.fn((e: { preventDefault: () => void }) => e.preventDefault());
render(
<form onSubmit={onSubmit}>
<Button type="submit" loading>
Send
</Button>
</form>,
);
fireEvent.click(screen.getByRole("button", { name: "Send" }));
expect(onSubmit).not.toHaveBeenCalled();
});
it("a non-busy button still calls onClick", () => {
const onClick = vi.fn();
render(<Button onClick={onClick}>Go</Button>);
fireEvent.click(screen.getByRole("button", { name: "Go" }));
expect(onClick).toHaveBeenCalledTimes(1);
});
it("a link gets no button padding, and an explicit size still wins", () => {
// The size default is variant-dependent (link → "none"). Locking it because the failure is
// silent: a link that quietly inherits px-3 py-2 reads as a button, and nothing errors.
+37 -19
View File
@@ -44,28 +44,32 @@ const VARIANTS: Record<ButtonVariant, string> = {
// and the ~100 hand-rolled ones had drifted into two different looks. That split IS the
// inconsistency this epic exists to remove; the primitive follows the majority, not the reverse.
outline:
"font-medium border border-border text-muted enabled:hover:text-accent enabled:hover:border-accent",
"outline-accent": "font-medium border border-accent/40 text-accent enabled:hover:bg-accent/10",
"font-medium border border-border text-muted enabled:not-aria-disabled:hover:text-accent enabled:not-aria-disabled:hover:border-accent",
"outline-accent":
"font-medium border border-accent/40 text-accent enabled:not-aria-disabled:hover:bg-accent/10",
// Neutral until you reach for it. NOT `danger-soft`, which is already red at rest — the difference
// is whether the button should advertise the destruction before the pointer arrives.
"outline-danger":
"font-medium border border-border text-muted enabled:hover:text-danger enabled:hover:border-danger/40",
"outline-warn": "font-medium border border-warn/40 text-warn enabled:hover:bg-warn/10",
ghost: "font-medium text-muted enabled:hover:text-fg enabled:hover:bg-card",
glass: "font-medium glass-card glass-hover enabled:hover:text-accent",
"font-medium border border-border text-muted enabled:not-aria-disabled:hover:text-danger enabled:not-aria-disabled:hover:border-danger/40",
"outline-warn":
"font-medium border border-warn/40 text-warn enabled:not-aria-disabled:hover:bg-warn/10",
ghost:
"font-medium text-muted enabled:not-aria-disabled:hover:text-fg enabled:not-aria-disabled:hover:bg-card",
glass: "font-medium glass-card glass-hover enabled:not-aria-disabled:hover:text-accent",
"danger-soft":
"font-medium border border-danger/60 text-danger enabled:hover:bg-danger/10 enabled:hover:border-danger",
"font-medium border border-danger/60 text-danger enabled:not-aria-disabled:hover:bg-danger/10 enabled:not-aria-disabled:hover:border-danger",
// No font-weight here on purpose: BASE already sets font-medium, and a second font-* utility would
// be a coin-flip (both are single-class utilities of equal specificity, and without tailwind-merge
// the winner is decided by the generated stylesheet order, not by the class list order).
link: "font-semibold text-accent enabled:hover:underline",
link: "font-semibold text-accent enabled:not-aria-disabled:hover:underline",
};
// Why every hover above is `enabled:hover:` — Tailwind's `:not(:disabled)`. A disabled button that
// still lights up on hover advertises a click that does nothing, and the ONE migrated call site that
// had guarded its own hover (TagManager's Add) silently lost the guard when it moved onto this
// primitive (E10 review round 1). The contract belongs here, not in ~160 call sites: BASE dims a
// disabled button, and the variant must not paint over that dimming.
// Why every hover above is `enabled:not-aria-disabled:hover:` — neither natively disabled nor busy. A
// disabled button that still lights up on hover advertises a click that does nothing, and the ONE
// migrated call site that had guarded its own hover (TagManager's Add) silently lost the guard when it
// moved onto this primitive (E10 review round 1). `not-aria-disabled` is the `loading` half: a busy
// button stays natively enabled (see `loading` below) and must not light up either. The contract
// belongs here, not in ~160 call sites: BASE dims both, and the variant must not paint over it.
// The geometry ladder, and the whole of it — a call site picks a rung, it does not invent one. The
// rungs were derived from what the app actually used (counted during E10 S1b): `px-3 py-2 text-sm`
@@ -86,7 +90,7 @@ const SIZES: Record<ButtonSize, string> = {
// link looks like everywhere in this app) and the action buttons want medium. Two font-* utilities on
// one element is a coin flip without tailwind-merge, so exactly one has to emit it — see the test.
const BASE =
"inline-flex items-center justify-center transition disabled:opacity-50 disabled:cursor-not-allowed";
"inline-flex items-center justify-center transition disabled:opacity-50 disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:cursor-not-allowed";
interface ButtonOwnProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
@@ -98,7 +102,8 @@ interface ButtonOwnProps extends ButtonHTMLAttributes<HTMLButtonElement> {
iconOnly?: boolean;
/** Accessible name + tooltip — required for `iconOnly`, optional otherwise. */
label?: string;
/** Disable + swap the icon for a spinner + mark aria-busy. */
/** Busy: swap the icon for a spinner, mark aria-busy + aria-disabled, and swallow activation — but
* NOT native `disabled`, which would throw the focus out of the page (see the render below). */
loading?: boolean;
fullWidth?: boolean;
}
@@ -131,6 +136,7 @@ const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button
children,
disabled,
type,
onClick,
...rest
},
ref,
@@ -149,11 +155,23 @@ const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button
<button
ref={ref}
type={type ?? "button"}
// `loading` must always disable (see the prop's contract above), even when a call-site also
// passes an explicit boolean `disabled` — so `||`, not `??`. With `??`, `disabled={false}`
// would shadow `loading` and leave the button clickable mid-mutation (double-submit).
disabled={disabled || loading}
// `loading` blocks activation WITHOUT native `disabled`. A focused button that turns disabled
// loses the focus: Chrome moved it to <body> ~60 ms after the reroll click on Home and nothing
// handed it back when the button re-enabled (measured, E11 S2) — so a keyboard user was dropped
// out of the page on every press. `aria-disabled` keeps the button focusable and announced as
// unavailable; the click guard is what actually stops the double-submit, whatever `disabled`
// says, and its preventDefault also cancels a form's implicit submission (which is a click on
// the default button).
disabled={disabled}
aria-disabled={loading || undefined}
aria-busy={loading || undefined}
onClick={(e) => {
if (loading) {
e.preventDefault();
return;
}
onClick?.(e);
}}
aria-label={label}
title={label}
className={clsx(
@@ -5,7 +5,7 @@
// scroll — which is exactly the affordance under test: a button shows ONLY on the clipped edge, and
// clicking it pages the scroller. i18n is key-passthrough, so asserted names are the i18n keys.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { act, render, screen, fireEvent, cleanup } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
@@ -13,6 +13,7 @@ vi.mock("react-i18next", async (importOriginal) => {
});
import { Carousel } from "./Carousel";
import { navItemAttrs } from "../../lib/listNav";
beforeAll(() => {
// useHScroll wires a ResizeObserver (watch-the-children); jsdom ships none.
@@ -91,3 +92,38 @@ describe("Carousel", () => {
expect(screen.getByRole("button", { name: "common.scrollRight" })).toBeTruthy();
});
});
// E11 S4b: a rail on Home, Discover or an info page is a list the keyboard walks — but only when the
// caller says its cards carry `navItemAttrs`. A carousel of plain content must not claim `j`.
describe("Carousel · keyboard", () => {
const cards = () =>
[0, 1, 2].map((i) => (
<button key={i} {...navItemAttrs(i)}>
{`card ${i}`}
</button>
));
const press = (key: string) =>
act(() => {
(document.activeElement ?? document.body).dispatchEvent(
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
);
});
it("is walked by j when keyboardNav is on", () => {
render(
<Carousel ariaLabel="Rail" keyboardNav>
{cards()}
</Carousel>,
);
press("j");
expect(document.activeElement?.textContent).toBe("card 0");
press("j");
expect(document.activeElement?.textContent).toBe("card 1");
});
it("leaves j alone without it", () => {
render(<Carousel ariaLabel="Rail">{cards()}</Carousel>);
press("j");
expect(document.activeElement).toBe(document.body);
});
});
+21 -3
View File
@@ -1,8 +1,9 @@
import { useCallback, type ReactNode } from "react";
import { useCallback, useRef, type ReactNode } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import { useScrollEdges } from "../../lib/useScrollEdges";
import { useListNav } from "../../lib/listNav";
// Shared horizontal rail — the one place the app's "row of cards you scroll sideways" lives, replacing
// the hand-rolled `flex gap overflow-x-auto` strips (Cast / Related / collections) and the future
@@ -43,6 +44,7 @@ export function Carousel({
ariaLabel,
gap = "gap-3",
className,
keyboardNav = false,
children,
}: {
/** Optional built-in header (rails). Omit for a bare scroller inside a card that owns its own heading. */
@@ -55,10 +57,24 @@ export function Carousel({
/** Tailwind gap class for the scroller (e.g. "gap-4"). */
gap?: string;
className?: string;
/** The cards spread `navItemAttrs(i)` on their roots: `j`/`k`, ←→ and Enter walk the rail, and ↑↓
* leave it for the rail above or below (lib/listNav). */
keyboardNav?: boolean;
children: ReactNode;
}) {
const { t } = useTranslation();
const { ref, el, left, right } = useHScroll();
// The scroller is the list the keyboard walks. useHScroll holds the node through a callback ref,
// listNav reads a ref object — one node, both handed it.
const navRef = useRef<HTMLDivElement | null>(null);
const setScroller = useCallback(
(node: HTMLDivElement | null) => {
ref(node);
navRef.current = node;
},
[ref],
);
useListNav(navRef, { enabled: keyboardNav });
const page = useCallback(
(dir: -1 | 1) => {
@@ -112,9 +128,11 @@ export function Carousel({
<ChevronLeft className="h-5 w-5" />
</button>
)}
{/* The px-1/pt-1 against -mx-1/-mt-1 keep the layout and give the keyboard ring room: a
scroller clips its overflow, and the ring sits 4px outside the card it surrounds. */}
<div
ref={ref}
className={clsx("no-scrollbar flex overflow-x-auto pb-1", gap)}
ref={setScroller}
className={clsx("no-scrollbar -mx-1 -mt-1 flex overflow-x-auto px-1 pt-1 pb-1", gap)}
style={{ maskImage: mask, WebkitMaskImage: mask }}
role="group"
aria-label={label}
@@ -247,3 +247,44 @@ describe("Combobox", () => {
expect(screen.getByRole("combobox").getAttribute("aria-expanded")).toBe("false"); // closed
});
});
// PageUp/PageDown (E11, owner request): the sidebar's channel filter and the Plex pickers run to
// hundreds of rows, and floating-ui's list navigation has only the arrows. jsdom has no layout, so a
// page is the fixed fallback of 10 rows; the measured page is pinned in lib/listPaging.
describe("Combobox · paging", () => {
const rows = (disabled: number[] = []) =>
Array.from({ length: 25 }, (_, i) => ({ value: `Row ${i}`, disabled: disabled.includes(i) }));
const activeValue = (input: HTMLElement) =>
document.getElementById(input.getAttribute("aria-activedescendant") ?? "")?.textContent;
it("moves the active option a page at a time, stopping at both ends", () => {
wrap(<Combobox label="Channel" options={rows()} selected={[]} onChange={vi.fn()} />);
const input = screen.getByRole("combobox");
input.focus();
fireEvent.focus(input);
expect(fireEvent.keyDown(input, { key: "PageDown" })).toBe(false); // the page does not scroll
expect(activeValue(input)).toBe("Row 10");
expect(document.activeElement).toBe(input);
fireEvent.keyDown(input, { key: "PageDown" });
fireEvent.keyDown(input, { key: "PageDown" });
expect(activeValue(input)).toBe("Row 24"); // no wrap to the top
fireEvent.keyDown(input, { key: "PageUp" });
expect(activeValue(input)).toBe("Row 14");
});
it("never lands on a disabled option, and Enter commits where it landed", () => {
const onChange = vi.fn();
wrap(<Combobox label="Channel" options={rows([10])} selected={[]} onChange={onChange} />);
const input = screen.getByRole("combobox");
input.focus();
fireEvent.focus(input);
fireEvent.keyDown(input, { key: "PageDown" });
fireEvent.keyDown(input, { key: "Enter" });
expect(onChange).toHaveBeenCalledWith(["Row 11"]);
});
it("leaves the key alone while the list is closed", () => {
wrap(<Combobox label="Channel" options={rows()} selected={[]} onChange={vi.fn()} />);
expect(fireEvent.keyDown(screen.getByRole("combobox"), { key: "PageDown" })).toBe(true);
});
});
@@ -5,7 +5,7 @@
// (no i18n, no domain), so this asserts the shell only. i18n is key-passthrough.
import { useState } from "react";
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
import CommandPalette, { type CommandGroup } from "./CommandPalette";
vi.mock("react-i18next", async (importOriginal) => {
@@ -21,7 +21,7 @@ beforeAll(() => {
afterEach(cleanup);
// `choose` closes and waits for the dialog's own Back-to-close history entry to be reclaimed before
// running the command (see closeThenNavigate), so the command lands a tick later — hence the waits.
// running the command (see closeTopOverlayThenRun), so the command lands a tick later — hence the waits.
const ran: string[] = [];
const groups = (): CommandGroup[] => [
{
@@ -160,3 +160,176 @@ describe("CommandPalette", () => {
expect(ran).toEqual(["closed"]);
});
});
// The owner's case (E11 S3): typing "theme" put a channel matched only through its handle above every
// "Theme: …" row, and Enter opened the channel.
describe("CommandPalette · ranking", () => {
const noop = () => {};
const ranked = (): CommandGroup[] => [
{
id: "channels",
label: "Channels",
items: [
{ id: "ch:melt", label: "The Meltzone Podcast", hint: "@themeltzone", run: noop },
{ id: "ch:talk", label: "Talk about themes", run: noop },
],
},
{
id: "actions",
label: "Actions",
items: [
{ id: "theme:midnight", label: "Theme: Midnight", run: noop },
{ id: "theme:forest", label: "Theme: Forest", run: noop },
],
},
];
it("ranks rows inside each group, keeps the group order, and starts on the best match", () => {
render(<CommandPalette open onClose={noop} groups={ranked()} />);
type("theme");
// label elsewhere (talk) > handle only (melt); label start (midnight = forest, group order kept)
expect(rows()).toEqual(["ch:talk", "ch:melt", "theme:midnight", "theme:forest"]);
// The highlight is what Enter takes (same `activeIndex`); the full close→run cycle cannot run in
// this file — see the note on "chooses the selected row on Enter".
expect(selected()).toBe("theme:midnight");
});
it("ranks by the worst word first, so every word in the label beats a label start plus a keyword", () => {
const groups: CommandGroup[] = [
{
id: "channels",
label: "Channels",
items: [
{ id: "weekly", label: "Podcast Weekly", keywords: "melt", run: noop },
{ id: "melt", label: "The Meltzone Podcast", run: noop },
],
},
];
render(<CommandPalette open onClose={noop} groups={groups} />);
type("podcast melt");
expect(rows()).toEqual(["melt", "weekly"]);
});
// The channel and playlist lists load after the palette opens — often while you are already typing.
it("follows the best match as a list lands, but keeps a row the arrows picked", () => {
const early: CommandGroup[] = [ranked()[0]!];
const r = render(<CommandPalette open onClose={noop} groups={early} />);
type("theme");
expect(selected()).toBe("ch:talk");
r.rerender(<CommandPalette open onClose={noop} groups={ranked()} />);
expect(selected()).toBe("theme:midnight");
press("ArrowDown"); // theme:forest, picked
r.rerender(<CommandPalette open onClose={noop} groups={[...ranked()]} />);
expect(selected()).toBe("theme:forest");
type("them"); // a new query goes back to following the best match
expect(selected()).toBe("theme:midnight");
});
});
describe("CommandPalette · accessibility", () => {
it("names each group by its heading", () => {
open();
const channels = screen.getByRole("group", { name: "Channels" });
expect(
[...channels.querySelectorAll("[role='option']")].map((o) => o.getAttribute("data-cmd-id")),
).toEqual(["ch:1", "ch:2"]);
expect(screen.getByRole("group", { name: "Modules" })).toBeTruthy();
});
it("is expanded only while there is something to choose, and says 'nothing' outside the listbox", () => {
open();
expect(field().getAttribute("aria-expanded")).toBe("true");
type("zzzz");
expect(field().getAttribute("aria-expanded")).toBe("false");
const listbox = screen.getByRole("listbox", { hidden: true });
expect(listbox.hidden).toBe(true);
// aria-controls still names a real element
expect(document.getElementById(field().getAttribute("aria-controls")!)).toBe(listbox);
expect(listbox.contains(screen.getByText("palette.empty"))).toBe(false);
});
it("announces the result count politely, once the typing pauses", async () => {
open();
const status = screen.getByRole("status");
expect(status.textContent).toBe(""); // nothing to announce on a blank field
type("the");
expect(status.textContent).toBe(""); // not per keystroke
await waitFor(() => expect(status.textContent).toBe("palette.results"));
type("zzzz");
await waitFor(() => expect(status.textContent).toBe("palette.empty"));
type("");
expect(status.textContent).toBe("");
});
});
// Owner request (E11): with 461 rows and ~11 in view, the arrows alone were a slog. PageUp/PageDown
// move by what fits in the list, and — unlike the arrows — stop at the ends instead of wrapping.
describe("CommandPalette · paging", () => {
const many = (): CommandGroup[] => [
{
id: "rows",
label: "Rows",
items: Array.from({ length: 12 }, (_, i) => ({
id: `r${i}`,
label: `Row ${i}`,
run: () => {},
})),
},
];
const openMany = () => render(<CommandPalette open onClose={() => {}} groups={many()} />);
const rect = (top: number, height: number) =>
({
top,
bottom: top + height,
height,
left: 0,
right: 100,
width: 100,
x: 0,
y: top,
}) as DOMRect;
it("pages by the rows in view minus one, and stops at both ends", () => {
// The list shows 100px and the rows are 30px tall: 3 fit, so a page is 2 rows.
const spy = vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function (
this: Element,
) {
if (this.getAttribute("role") === "listbox") return rect(0, 100);
const all = [...document.querySelectorAll('[role="option"]')];
return rect(all.indexOf(this) * 30, 30);
});
// Restored in `finally`: a spy left behind by a failing assertion fails every later test too.
try {
openMany();
press("PageDown");
expect(selected()).toBe("r2");
press("PageDown");
expect(selected()).toBe("r4");
for (let i = 0; i < 10; i++) press("PageDown");
expect(selected()).toBe("r11"); // no wrap to the top
press("PageUp");
expect(selected()).toBe("r9");
for (let i = 0; i < 10; i++) press("PageUp");
expect(selected()).toBe("r0");
} finally {
spy.mockRestore();
}
});
it("steps a fixed page when there is no layout to measure", () => {
openMany();
press("PageDown");
expect(selected()).toBe("r10");
press("PageDown");
expect(selected()).toBe("r11");
});
it("leaves Home and End to the text field", () => {
openMany();
press("PageDown");
expect(fireEvent.keyDown(field(), { key: "Home" })).toBe(true); // not preventDefault-ed
expect(fireEvent.keyDown(field(), { key: "End" })).toBe(true);
expect(selected()).toBe("r10");
});
});
@@ -1,10 +1,9 @@
// @vitest-environment jsdom
//
// ONE test, ONE file, on purpose. This is the close→reclaim→run ordering contract of the palette
// (see closeThenNavigate), and it ASSERTS on a real useBackToClose entry driven through a real
// programmatic history.back() — which is why it cannot share module state with anything. (It is not
// the only place in the suite where an unstubbed back() fires; history.burial.dom.test.ts is the
// other file that asserts on one, and any test rendering a real Modal can let one go.)
// (see closeTopOverlayThenRun), and it ASSERTS on a real useBackToClose entry driven through a real
// programmatic history.back() — which is why it cannot share module state with anything. (Any test
// rendering a real Modal can let an unstubbed back() go, too.)
//
// It cannot live in CommandPalette.dom.test.tsx. jsdom gives a test FILE a single session history,
// and history.ts's overlay bookkeeping is module-global, so the sibling tests there leave an
@@ -50,7 +49,7 @@ function Harness() {
);
}
describe("CommandPalette · closeThenNavigate ordering", () => {
describe("CommandPalette · close-then-run ordering", () => {
it("closes, and runs the command only once the close has landed", async () => {
render(<Harness />);
// Load-bearing: useBackToClose pushes its entry from a coalesced microtask, so a click in the
@@ -62,8 +61,9 @@ describe("CommandPalette · closeThenNavigate ordering", () => {
fireEvent.click(screen.getByText("TheVR Podcast"));
expect(ran).toEqual(["closed"]);
// Inside closeThenNavigate's 250ms no-popstate fallback, so a pass means the RECLAIM ran the
// command — not the safety net that exists for overlays which never got an entry at all.
await waitFor(() => expect(ran).toEqual(["closed", "ch:1"]), { timeout: 200 });
// Well inside closeTopOverlayThenRun's 1500 ms give-up timer, so a pass means the RECLAIM ran the
// command — not the late path for a back() that never reports. (The margin used to be 200 ms
// under a 250 ms fallback, which made this load-sensitive by construction.)
await waitFor(() => expect(ran).toEqual(["closed", "ch:1"]), { timeout: 1000 });
});
});
+168 -72
View File
@@ -2,7 +2,8 @@ import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "rea
import { useTranslation } from "react-i18next";
import { Search } from "lucide-react";
import Modal from "./Modal";
import { closeThenNavigate } from "../../lib/history";
import { closeTopOverlayThenRun } from "../../lib/history";
import { pageStep, pageTarget } from "../../lib/listPaging";
// The app's jump-to bar: one text field over every place you can go and every app-wide thing you can
// do, opened with Ctrl/Cmd+K. It is the "one-layer operation" half of E10 — reaching a channel three
@@ -40,14 +41,37 @@ function fold(s: string): string {
.replace(/\p{Diacritic}/gu, "");
}
/** Every query word must appear somewhere in the item's text. Word-wise rather than substring so
* "thevr pod" finds "TheVR Podcast" — the way you actually half-remember a name. */
function matchesQuery(item: CommandItem, groupLabel: string, words: string[]): boolean {
if (words.length === 0) return true;
const hay = fold(`${item.label} ${item.hint ?? ""} ${item.keywords ?? ""} ${groupLabel}`);
return words.every((w) => hay.includes(w));
/** How well an item answers the query: `[worst word, sum of words]`, lower is better. */
type Rank = readonly [number, number];
const byRank = (a: Rank, b: Rank) => a[0] - b[0] || a[1] - b[1];
/** Every query word must appear somewhere in the item's text — word-wise rather than substring, so
* "thevr pod" finds "TheVR Podcast", the way you actually half-remember a name. `null` = no match.
*
* Each word scores 0 when the label STARTS with it, 1 when it is elsewhere in the label, 2 when only
* the hint, the hidden keywords or the group name carry it. An item ranks by its worst word first, so
* a row whose label holds every word beats one that needed its handle for any of them. Before this
* the list was pure group order: "theme" put the channel "The Meltzone Podcast" — matched through its
* handle `@themeltzone` alone — above every "Theme: …" row, and Enter opened a channel (E11 S3). */
function rankItem(item: CommandItem, groupLabel: string, words: string[]): Rank | null {
const label = fold(item.label);
const rest = fold(`${item.hint ?? ""} ${item.keywords ?? ""} ${groupLabel}`);
let worst = 0;
let sum = 0;
for (const w of words) {
const score = label.startsWith(w) ? 0 : label.includes(w) ? 1 : rest.includes(w) ? 2 : -1;
if (score < 0) return null;
worst = Math.max(worst, score);
sum += score;
}
return [worst, sum];
}
/** The result count reaches a screen reader only once the typing pauses: a polite region rewritten on
* every keystroke queues one announcement per letter. */
const ANNOUNCE_DELAY_MS = 400;
export default function CommandPalette({
open,
onClose,
@@ -59,42 +83,81 @@ export default function CommandPalette({
}) {
const { t } = useTranslation();
const listId = useId();
const groupId = useId();
const [query, setQuery] = useState("");
const [active, setActive] = useState(0);
// The row the arrows or the pointer picked, by id; `null` follows the best match. An id rather than
// an index because the lists arrive asynchronously (channels, playlists load on open), and an index
// would quietly point at a different row once they land.
const [picked, setPicked] = useState<string | null>(null);
// Ref on the highlighted row rather than a selector lookup: item ids come from domain data
// (playlist names, channel handles) and would need escaping to be safe in a query string.
const activeRef = useRef<HTMLButtonElement | null>(null);
const listRef = useRef<HTMLDivElement | null>(null);
// A fresh open starts from a blank field: the palette is for "take me somewhere", and a leftover
// query from last time hides everything you might want now.
useEffect(() => {
if (open) {
setQuery("");
setActive(0);
setPicked(null);
}
}, [open]);
const visible = useMemo(() => {
// The GROUP order stays fixed — modules, channels, playlists, views, actions read the same every
// time — and the rows are ranked inside each group. The best row overall starts highlighted, so
// Enter takes the best match even when it sits in a later group.
const { visible, bestId } = useMemo(() => {
const words = fold(query.trim()).split(/\s+/).filter(Boolean);
return groups
.map((g) => ({ ...g, items: g.items.filter((i) => matchesQuery(i, g.label, words)) }))
.filter((g) => g.items.length > 0);
const out: CommandGroup[] = [];
let best: { id: string; rank: Rank } | null = null;
for (const g of groups) {
const ranked: { item: CommandItem; rank: Rank }[] = [];
for (const item of g.items) {
const rank = rankItem(item, g.label, words);
if (rank) ranked.push({ item, rank });
}
if (ranked.length === 0) continue;
// `sort` is stable, so equal ranks keep the group's own order (modules in nav-rail order).
ranked.sort((a, b) => byRank(a.rank, b.rank));
const top = ranked[0]!;
if (!best || byRank(top.rank, best.rank) < 0) best = { id: top.item.id, rank: top.rank };
out.push({ ...g, items: ranked.map((r) => r.item) });
}
return { visible: out, bestId: best?.id };
}, [groups, query]);
// One flat list behind the grouped rendering — arrow keys walk results, not sections.
const flat = useMemo(() => visible.flatMap((g) => g.items), [visible]);
const activeId = flat[Math.min(active, flat.length - 1)]?.id;
const activeId = picked !== null && flat.some((i) => i.id === picked) ? picked : bestId;
const activeIndex = flat.findIndex((i) => i.id === activeId);
// Keep the highlighted row in view when the arrows walk past the fold.
useEffect(() => {
activeRef.current?.scrollIntoView({ block: "nearest" });
}, [activeId]);
const summary = !query.trim()
? ""
: flat.length === 0
? t("palette.empty")
: t("palette.results", { count: flat.length });
const [announced, setAnnounced] = useState("");
useEffect(() => {
if (!summary) {
setAnnounced("");
return;
}
const timer = window.setTimeout(() => setAnnounced(summary), ANNOUNCE_DELAY_MS);
return () => window.clearTimeout(timer);
}, [summary]);
if (!open) return null;
// Close, wait for this dialog's own Back-to-close entry to be reclaimed, THEN run. Most commands
// navigate, and a pushState issued before that reclaim is undone by it — see closeThenNavigate.
const choose = (item: CommandItem) => closeThenNavigate(onClose, item.run);
// Close, wait for this dialog's own Back-to-close entry to be reclaimed, THEN run: most commands
// navigate, and a pushState issued before that reclaim is undone by it. Only THIS dialog closes —
// a command that navigates closes whatever is underneath itself, one that does not (a theme) must
// leave it standing. See closeTopOverlayThenRun.
const choose = (item: CommandItem) => closeTopOverlayThenRun(onClose, item.run);
// These live on the input rather than in the shared keymap: they are the field's own editing keys
// (like a <select>'s arrows), not app shortcuts — and the table only describes what is global.
@@ -103,9 +166,22 @@ export default function CommandPalette({
e.preventDefault();
if (flat.length === 0) return;
const step = e.key === "ArrowDown" ? 1 : -1;
setActive((i) => (Math.min(i, flat.length - 1) + step + flat.length) % flat.length);
const from = Math.max(activeIndex, 0);
setPicked(flat[(from + step + flat.length) % flat.length]!.id);
} else if (e.key === "PageDown" || e.key === "PageUp") {
// A page at a time, stopping at the ends (lib/listPaging). The list is 50vh and group headings
// eat into it, so the page is measured. Home/End stay the field's own (caret to the query's ends).
e.preventDefault();
const list = listRef.current;
const step = pageStep(list, list?.querySelectorAll('[role="option"]') ?? []);
const to = pageTarget(
Math.max(activeIndex, 0),
e.key === "PageDown" ? step : -step,
flat.length,
);
if (to !== null) setPicked(flat[to]!.id);
} else if (e.key === "Enter") {
const item = flat[Math.min(active, flat.length - 1)];
const item = flat[activeIndex];
if (item) {
e.preventDefault();
choose(item);
@@ -122,72 +198,92 @@ export default function CommandPalette({
value={query}
onChange={(e) => {
setQuery(e.target.value);
setActive(0);
setPicked(null);
}}
onKeyDown={onInputKey}
placeholder={t("palette.placeholder")}
aria-label={t("palette.placeholder")}
// A minimal combobox tree: the arrows move a VIRTUAL selection while the caret stays in
// the field, so a screen reader has to be told which row is current.
// the field, so a screen reader has to be told which row is current. `aria-expanded`
// follows the results: with nothing to choose from there is no popup to be in.
role="combobox"
aria-expanded
aria-expanded={flat.length > 0}
aria-controls={listId}
aria-activedescendant={activeId ? `${listId}-${activeId}` : undefined}
className="w-full bg-transparent text-sm outline-hidden placeholder:text-muted"
/>
</div>
<div id={listId} role="listbox" className="mt-3 max-h-[50vh] overflow-y-auto">
{flat.length === 0 ? (
<p className="px-1 py-6 text-center text-sm text-muted">{t("palette.empty")}</p>
) : (
visible.map((g) => (
<div key={g.id} className="mb-2 last:mb-0">
<div
role="presentation"
className="px-1 pb-1 text-xs font-semibold tracking-wide text-muted uppercase"
>
{g.label}
</div>
{g.items.map((item) => (
<button
key={item.id}
id={`${listId}-${item.id}`}
ref={item.id === activeId ? activeRef : undefined}
data-cmd-id={item.id}
role="option"
aria-selected={item.id === activeId}
type="button"
// The combobox input owns the tab stop; the arrows move a VIRTUAL selection over
// these rows. Leaving them tabbable put DOM focus on a row while
// aria-activedescendant named a different one — two competing "current" options —
// and made Tab walk every result (E10 review round 1).
tabIndex={-1}
onClick={() => choose(item)}
onMouseMove={(e) => {
// Only a pointer that actually MOVED may take the selection. After the arrow keys
// scroll a row past the fold, Blink re-dispatches a mousemove at the unchanged
// cursor position to refresh hover — which handed the highlight straight back to
// whatever row slid under a resting cursor (E10 review round 1). The browser
// reports zero movement for exactly that synthetic event, so ask it directly:
// round 1 remembered the last position instead, which could not judge the FIRST
// move of a session and went stale across a close (round 2).
if (e.movementX === 0 && e.movementY === 0) return;
setActive(flat.indexOf(item));
}}
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm ${
item.id === activeId ? "bg-card text-fg" : "text-muted hover:text-fg"
}`}
>
{item.icon && <span className="shrink-0">{item.icon}</span>}
<span className="min-w-0 flex-1 truncate">{item.label}</span>
{item.hint && <span className="shrink-0 text-xs text-muted">{item.hint}</span>}
</button>
))}
</div>
))
)}
<div role="status" aria-live="polite" className="sr-only">
{announced}
</div>
{/* Stays mounted (hidden) while empty, so the input's aria-controls never names a missing
element; the "nothing matches" line sits OUTSIDE it, since a listbox may hold only options
and groups. */}
<div
ref={listRef}
id={listId}
role="listbox"
aria-label={t("palette.title")}
hidden={flat.length === 0}
className="mt-3 max-h-[50vh] overflow-y-auto"
>
{visible.map((g) => (
<div
key={g.id}
role="group"
aria-labelledby={`${groupId}-${g.id}`}
className="mb-2 last:mb-0"
>
<div
id={`${groupId}-${g.id}`}
role="presentation"
className="px-1 pb-1 text-xs font-semibold tracking-wide text-muted uppercase"
>
{g.label}
</div>
{g.items.map((item) => (
<button
key={item.id}
id={`${listId}-${item.id}`}
ref={item.id === activeId ? activeRef : undefined}
data-cmd-id={item.id}
role="option"
aria-selected={item.id === activeId}
type="button"
// The combobox input owns the tab stop; the arrows move a VIRTUAL selection over
// these rows. Leaving them tabbable put DOM focus on a row while
// aria-activedescendant named a different one — two competing "current" options —
// and made Tab walk every result (E10 review round 1).
tabIndex={-1}
onClick={() => choose(item)}
onMouseMove={(e) => {
// Only a pointer that actually MOVED may take the selection. After the arrow keys
// scroll a row past the fold, Blink re-dispatches a mousemove at the unchanged
// cursor position to refresh hover — which handed the highlight straight back to
// whatever row slid under a resting cursor (E10 review round 1). The browser
// reports zero movement for exactly that synthetic event, so ask it directly:
// round 1 remembered the last position instead, which could not judge the FIRST
// move of a session and went stale across a close (round 2).
if (e.movementX === 0 && e.movementY === 0) return;
setPicked(item.id);
}}
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm ${
item.id === activeId ? "bg-card text-fg" : "text-muted hover:text-fg"
}`}
>
{item.icon && <span className="shrink-0">{item.icon}</span>}
<span className="min-w-0 flex-1 truncate">{item.label}</span>
{item.hint && <span className="shrink-0 text-xs text-muted">{item.hint}</span>}
</button>
))}
</div>
))}
</div>
{flat.length === 0 && (
<p className="mt-3 px-1 py-6 text-center text-sm text-muted">{t("palette.empty")}</p>
)}
</Modal>
);
}
@@ -108,13 +108,13 @@ describe("useContextMenu / ContextMenu", () => {
expect(menuitems()).toEqual([]);
});
it("selecting an item fires it and closes the menu", () => {
it("selecting an item closes the menu, then fires it (see ActionMenu's order test)", async () => {
const onSelect = vi.fn();
renderList(() => [{ id: "a", label: "Remove", onSelect }]);
fireEvent.contextMenu(row("alpha"));
fireEvent.click(item("Remove"));
expect(onSelect).toHaveBeenCalledTimes(1);
expect(menuitems()).toEqual([]);
await vi.waitFor(() => expect(onSelect).toHaveBeenCalledTimes(1));
});
it("Shift+F10 opens it from the keyboard", () => {
@@ -130,6 +130,42 @@ describe("useContextMenu / ContextMenu", () => {
expect(menuitems()).toEqual([]);
});
// A card the keyboard is on is focused as a whole, through a tabindex=-1 root (lib/listNav), and
// holds tabbable controls. floating-ui handed the focus back to the first of them instead of the
// card — measured in Chrome (deep-review E11 round 2): j, Shift+F10, Escape left it on the thumbnail.
// Closed here by picking the row (the other way out; jsdom delivers no Escape to a keyboard-opened
// menu — Escape is measured in the browser).
it("gives the focus back to a card focused as a whole, not to a control inside it", async () => {
function Card() {
const menu = useContextMenu<string>();
return (
<>
<div tabIndex={-1} data-testid="card" {...menu.triggerProps("card")}>
<a href="#thumb">thumbnail</a>
</div>
<ContextMenu
menu={menu}
ariaLabel="Card actions"
items={menu.target ? [{ id: "a", label: "Pin", onSelect: vi.fn() }] : []}
/>
</>
);
}
render(
<FloatingTree>
<Card />
</FloatingTree>,
);
const card = screen.getByTestId("card");
act(() => card.focus());
fireEvent.keyDown(card, { key: "F10", shiftKey: true });
expect(menuitems()).toEqual(["Pin"]);
await vi.waitFor(() => expect(card.contains(document.activeElement)).toBe(false));
fireEvent.click(item("Pin"));
expect(menuitems()).toEqual([]);
await vi.waitFor(() => expect(document.activeElement).toBe(card));
});
describe("long press (touch)", () => {
const press = (el: HTMLElement, x = 10, y = 10) =>
fireEvent.pointerDown(el, { pointerType: "touch", clientX: x, clientY: y });
@@ -217,7 +253,7 @@ describe("useContextMenu / ContextMenu", () => {
expect(menuitems()).toEqual(["Play", "Move to", "Watch later", "Homelab"]);
});
it("choosing inside a submenu closes the WHOLE stack, not just the submenu", () => {
it("choosing inside a submenu closes the WHOLE stack, not just the submenu", async () => {
const chosen = vi.fn();
renderList(() => [
{ id: "play", label: "Play", onSelect: vi.fn() },
@@ -226,8 +262,8 @@ describe("useContextMenu / ContextMenu", () => {
fireEvent.contextMenu(row("alpha"));
fireEvent.click(item("Move to"));
fireEvent.click(item("Homelab"));
expect(chosen).toHaveBeenCalledTimes(1);
expect(menuitems()).toEqual([]);
await vi.waitFor(() => expect(chosen).toHaveBeenCalledTimes(1));
});
});
@@ -0,0 +1,87 @@
// @vitest-environment jsdom
//
// DataTable's `keyboardNav` (E11 S4c): the rows are a keyboard list — `j`/`k` walk them and Enter
// clicks the element a column marks `data-nav-open`. The table renders its rows twice (a table and,
// below `md`, a card list); only the copy on screen may be walked, or `j` past the last row would carry
// on into the hidden one.
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, render } from "@testing-library/react";
import DataTable, { type Column } from "./DataTable";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
afterEach(cleanup);
type Row = { id: string };
const rows: Row[] = [{ id: "a" }, { id: "b" }];
function table(onOpen: (id: string) => void, keyboardNav = true) {
const columns: Column<Row>[] = [
{
key: "name",
header: "Name",
cardPrimary: true,
render: (r) => (
<button data-nav-open="" onClick={() => onOpen(r.id)}>
{r.id}
</button>
),
},
{ key: "act", header: "", render: (r) => <button>act {r.id}</button> },
];
return render(
<DataTable rows={rows} columns={columns} rowKey={(r) => r.id} keyboardNav={keyboardNav} />,
);
}
function press(key: string) {
act(() => {
(document.activeElement ?? document.body).dispatchEvent(
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
);
});
}
/** What a display:none copy answers in a browser; jsdom lays nothing out, so every copy is "visible". */
const offScreen = (el: Element | null) =>
Object.defineProperty(el!, "checkVisibility", { value: () => false, configurable: true });
/** The focused row: which copy (TR / DIV) and which row. */
const focused = () => {
const el = document.activeElement as HTMLElement;
return `${el.tagName} ${el.querySelector("[data-nav-open]")?.textContent ?? ""}`;
};
describe("DataTable · keyboardNav", () => {
it("walks the table's rows, stops at the last one, and opens a row on Enter", () => {
const opened: string[] = [];
const { container } = table((id) => opened.push(id));
offScreen(container.querySelector(".md\\:hidden"));
press("j");
expect(focused()).toBe("TR a");
press("j");
expect(focused()).toBe("TR b");
// The hidden card copy follows the table in the DOM — it must not be the next stop.
press("j");
expect(focused()).toBe("TR b");
press("Enter");
expect(opened).toEqual(["b"]);
});
it("walks the card copy when that is the one on screen", () => {
const { container } = table(() => {});
offScreen(container.querySelector("tbody"));
press("j");
expect(focused()).toBe("DIV a");
});
it("leaves the rows out of the keyboard without keyboardNav", () => {
const { container } = table(() => {}, false);
expect(container.querySelector("[data-nav-index]")).toBeNull();
press("j");
expect(document.activeElement).toBe(document.body);
});
});
+22 -9
View File
@@ -3,7 +3,9 @@ import { useTranslation } from "react-i18next";
import { sortRows, cycleSort, type SortState } from "../../lib/columnSort";
import Pager, { hasPagerContent } from "./Pager";
import { PageToolbar } from "./PageShell";
import { ColumnHead, alignClass, filterRows, type Column, type FilterMap } from "./tableHeader";
import { ColumnHead, alignClass } from "./tableHeader";
import { filterRows, type Column, type FilterMap } from "../../lib/tableColumns";
import { navItemAttrs, useListNav } from "../../lib/listNav";
// A reusable, client-side data table: per-column sort + filter (in the header) + pagination,
// with its sort/filter/page state optionally persisted to localStorage so a reload (F5) keeps
@@ -11,12 +13,12 @@ import { ColumnHead, alignClass, filterRows, type Column, type FilterMap } from
// other modules can reuse it. Below `md` it falls back to a card list built from the same column
// definitions.
//
// The header row itself (sort cell + filter funnel/popover + the filter predicate) lives in
// `tableHeader.tsx` — the playlist manager renders its own drag-reorderable body under the same
// headers without inheriting this component's ordering state.
// The header row itself (sort cell + filter funnel/popover) lives in `tableHeader.tsx`, the column
// contract and the filter predicate in `lib/tableColumns` — the playlist manager renders its own
// drag-reorderable body under the same headers without inheriting this component's ordering state.
// Re-exported so the many `import { type Column } from "./DataTable"` call sites keep working.
export type { Column } from "./tableHeader";
export type { Column } from "../../lib/tableColumns";
interface Persisted {
sort: SortState;
@@ -62,6 +64,7 @@ export default function DataTable<T>({
filters: filtersProp,
onFiltersChange,
fixedLayout = false,
keyboardNav = false,
}: {
rows: T[];
columns: Column<T>[];
@@ -110,6 +113,10 @@ export default function DataTable<T>({
// the viewport. Off by default so existing content-sized tables (playlists, discovery, Plex) are
// untouched; the channel manager turns it on so its row never scrolls horizontally out of view.
fixedLayout?: boolean;
// Take part in page keyboard navigation (`lib/listNav`): `j`/`k` and the arrows walk the rows, and
// Enter clicks the element a column's render marks `data-nav-open` (the channel name). Both row
// shapes register; listNav walks only the one on screen, so the hidden copy is never a stop.
keyboardNav?: boolean;
}) {
const { t } = useTranslation();
const initial = loadPersist(persistKey);
@@ -125,6 +132,10 @@ export default function DataTable<T>({
const filters = controlledFilters ? (filtersProp ?? EMPTY_FILTERS) : internalFilters;
const [page, setPage] = useState(initial.page);
const [size, setSize] = useState(initial.size ?? pageSize);
const bodyRef = useRef<HTMLTableSectionElement>(null);
const cardsRef = useRef<HTMLDivElement>(null);
useListNav(bodyRef, { enabled: keyboardNav });
useListNav(cardsRef, { enabled: keyboardNav });
// Commit a whole new filter map through the owner (controlled) or internal state. A ref holds the
// latest resolved map so the externalFilter/reset effects can derive from it without taking
@@ -256,11 +267,12 @@ export default function DataTable<T>({
filters={filters}
onFilter={applyFilter}
/>
<tbody>
{paged.map((row) => (
<tbody ref={bodyRef}>
{paged.map((row, i) => (
<tr
key={rowKey(row)}
{...rowProps?.(row)}
{...(keyboardNav && navItemAttrs(i))}
className={`border-b border-border/50 hover:bg-(--hover) transition ${
rowClassName?.(row) ?? ""
}`}
@@ -282,11 +294,12 @@ export default function DataTable<T>({
{/* Narrow screens: a compact card per row — the primary column as the heading, the
rest flowing in a single wrapping meta line (no full-width label/value gaps). */}
<div className="md:hidden flex flex-col gap-2">
{paged.map((row) => (
<div ref={cardsRef} className="md:hidden flex flex-col gap-2">
{paged.map((row, i) => (
<div
key={rowKey(row)}
{...rowProps?.(row)}
{...(keyboardNav && navItemAttrs(i))}
className={`glass-card rounded-xl p-3 ${rowClassName?.(row) ?? ""}`}
>
{columns
@@ -14,6 +14,7 @@ import {
useKeyScope,
type KeyScope,
} from "../../lib/keymap";
import { pushLayer, removeLayer } from "../../lib/layerStack";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
@@ -27,9 +28,11 @@ afterEach(() => {
closeKeyboardHelp();
});
/** App's job: it binds the `global` scope (h / Mod+K / Shift+W) once for the whole tree. */
/** App's job: it binds the `global` scope (h / Mod+K / Shift+W) and the `page` scope (the `g` chords)
* once for the whole tree. Home is the one module this stand-in account reaches, so the page block
* is the single `G H` chord, sharing Shift+W's line. */
function Host({ children }: { children?: React.ReactNode }) {
useGlobalKeymap(true, () => {});
useGlobalKeymap(true, () => {}, ["home"]);
return (
<>
{children}
@@ -81,15 +84,16 @@ describe("KeyboardHelp", () => {
);
pressH();
const rows = descriptions();
// A plexPlayer row first, the app-wide rows last — the reading order the reader wants.
// A plexPlayer row first, the app-wide rows last — the reading order the reader wants. (Home's
// line comes from the page block, which sits between the two; Shift+W joins it there.)
expect(rows[0]).toBe("keys.playPause");
expect(rows.slice(-3)).toEqual(["keys.palette", "keys.help", "keys.home"]);
expect(rows.slice(-3)).toEqual(["keys.home", "keys.palette", "keys.help"]);
});
it("lists only the app-wide keys when no surface is registered", () => {
render(<Host />);
pressH();
expect(descriptions()).toEqual(["keys.palette", "keys.help", "keys.home"]);
expect(descriptions()).toEqual(["keys.home", "keys.palette", "keys.help"]);
});
// The context menu is NOT app-wide: it has its own scope, registered by `useContextMenu`, so the
@@ -120,6 +124,24 @@ describe("KeyboardHelp", () => {
expect(descriptions()).not.toContain("keys.contextMenu");
});
// Opened over a player, Shift+F10 has nothing to reach — focus is inside the player, not on a row.
it("drops the context-menu key from a sheet opened over a keyboard-owning layer", () => {
function WithMenu() {
useContextMenu<string>();
return null;
}
render(
<Host>
<WithMenu />
</Host>,
);
const id = pushLayer();
pressH();
removeLayer(id);
expect(descriptions()).not.toContain("keys.contextMenu");
expect(descriptions()).toContain("keys.help");
});
// Two live context menus (the Playlists page mounts one, its rail another) must not double the row.
it("lists the context-menu chords once however many surfaces register", () => {
function WithMenu() {
+16 -2
View File
@@ -5,9 +5,12 @@ import {
closeKeyboardHelp,
formatChord,
helpLines,
isPageLevelScope,
useActiveScopes,
useHelpConditionValues,
useKeyboardHelpOpen,
useKeyboardHelpOverLayer,
useNavigablePages,
} from "../../lib/keymap";
// The app's one keyboard cheat sheet. Mounted once by App; the global `h` that opens it lives in
@@ -29,11 +32,22 @@ export default function KeyboardHelp() {
const open = useKeyboardHelpOpen();
const scopes = useActiveScopes();
const conditions = useHelpConditionValues();
const overLayer = useKeyboardHelpOverLayer();
const pages = useNavigablePages();
if (!open) return null;
// A `when` row is listed only where it applies — "Previous / next episode" inside a movie would be
// a promise the player does not keep.
const lines = helpLines(scopes).filter((l) => !l.when || !!conditions[l.when]);
// Opened over a player or a dialog, the page-level keys (`s`, `g …`, `j`/`k`) are quiet — listing
// them there would promise keys that do nothing until the overlay closes. And a `g` chord is listed
// only for a module this account can actually reach.
// The context-menu key goes too: every `useContextMenu` is a page list, and with an overlay up the
// focus Shift+F10 needs is inside the overlay (pre-existing since E10, found in S4 UAT).
const live = overLayer
? scopes.filter((s) => !isPageLevelScope(s) && s !== "contextMenu")
: scopes;
const lines = helpLines(live, pages).filter((l) => !l.when || !!conditions[l.when]);
const words = { Space: t("keys.cap.space"), ContextMenu: t("keys.cap.menu") };
return (
<Modal title={t("keys.title")} onClose={closeKeyboardHelp} maxWidth="max-w-md">
<dl className="grid grid-cols-[auto_1fr] items-center gap-x-4 gap-y-2.5 text-sm">
@@ -45,7 +59,7 @@ export default function KeyboardHelp() {
key={chord}
className="rounded-sm border border-border bg-surface px-1.5 py-0.5 text-xs font-mono whitespace-nowrap"
>
{formatChord(chord)}
{formatChord(chord, words)}
</kbd>
))}
</dt>

Some files were not shown because too many files have changed in this diff Show More