diff --git a/frontend/src/components/browser/toolbars/top/filter-by-select.vue b/frontend/src/components/browser/toolbars/top/filter-by-select.vue
index 60c839db3..ae22f2b34 100644
--- a/frontend/src/components/browser/toolbars/top/filter-by-select.vue
+++ b/frontend/src/components/browser/toolbars/top/filter-by-select.vue
@@ -7,6 +7,7 @@
:items="bookmarkChoices"
:menu-props="{
contentClass: filterMenuClass,
+ contentProps: { onKeydownCapture: onMenuKeydownCapture },
maxHeight: undefined,
closeOnContentClick: false,
}"
@@ -93,6 +94,14 @@ import ToolbarSelect from "@/components/toolbar-select.vue";
import { useAuthStore } from "@/stores/auth";
import { useBrowserStore } from "@/stores/browser";
+const ARROW_STEPS = Object.freeze({ ArrowDown: 1, ArrowUp: -1 });
+/*
+ * Vuetify focuses list rows programmatically with ``tabindex="-2"`` and
+ * parks the list itself at ``-1``, so excluding only ``-1`` selects exactly
+ * the rows its own focus walk would visit.
+ */
+const FOCUSABLE_ROW = '[tabindex]:not([tabindex="-1"]):not([disabled])';
+
export default {
name: "BrowserFilterBySelect",
components: {
@@ -244,6 +253,39 @@ export default {
this.loadAvailableFilterChoices();
}
},
+ /*
+ * Vuetify 4.2 moved select keyboard navigation into ``useScrolling``,
+ * which wraps from the last bookmark row straight back to the first and
+ * calls ``stopImmediatePropagation()``. That pre-empts VList's own focus
+ * walk, which is what used to carry the user into the rows this menu
+ * adds through the prepend/append slots ("Clear All Filters",
+ * "Favorites Only" and the filter sub-menus), leaving them mouse-only.
+ * A capture listener on the overlay content runs before the list's, so
+ * stepping off either end of the bookmark rows lands on the adjacent
+ * slot row instead of wrapping. Everywhere else the event is left alone
+ * and Vuetify still owns the navigation.
+ */
+ onMenuKeydownCapture(event) {
+ const step = ARROW_STEPS[event.key];
+ const content = event.currentTarget;
+ const row = document.activeElement?.closest?.("[aria-posinset]");
+ if (!step || !row || !content.contains(row)) {
+ return;
+ }
+ // Only the true ends of the bookmark list wrap; mid-list rows are fine.
+ const end = step > 0 ? Number(row.getAttribute("aria-setsize")) : 1;
+ if (Number(row.getAttribute("aria-posinset")) !== end) {
+ return;
+ }
+ const rows = [...content.querySelectorAll(FOCUSABLE_ROW)];
+ const target = rows[rows.indexOf(row) + step];
+ if (!target || target.hasAttribute("aria-posinset")) {
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ target.focus();
+ },
},
};
diff --git a/frontend/src/components/reader/book-change-drawer.vue b/frontend/src/components/reader/book-change-drawer.vue
index a8d399a01..23f5cc571 100644
--- a/frontend/src/components/reader/book-change-drawer.vue
+++ b/frontend/src/components/reader/book-change-drawer.vue
@@ -7,7 +7,6 @@
:location="drawerLocation"
:model-value="isDrawerOpen"
:scrim="false"
- :class="{ drawerActivated: isDrawerOpen }"
temporary
touchless
>
@@ -94,10 +93,6 @@ export default {
.bookChangeDrawer {
opacity: 0.75 !important;
z-index: 15 !important;
-}
-
-.drawerActivated {
- // Deactivated drawers with custom width don't move off the screen enough
width: col.$change-column-width !important;
}
diff --git a/frontend/src/components/settings/button.vue b/frontend/src/components/settings/button.vue
index 0ce04b12c..e43c9dadc 100644
--- a/frontend/src/components/settings/button.vue
+++ b/frontend/src/components/settings/button.vue
@@ -73,5 +73,11 @@ export default {
diff --git a/frontend/tests/unit/book-change-drawer.test.js b/frontend/tests/unit/book-change-drawer.test.js
new file mode 100644
index 000000000..5db76d712
--- /dev/null
+++ b/frontend/tests/unit/book-change-drawer.test.js
@@ -0,0 +1,108 @@
+/*
+ * Tests for ``book-change-drawer.vue`` — the prev/next book slide-outs.
+ *
+ * The drawer is 33vw, not Vuetify's 256px default. Vuetify 4.1 parked an
+ * inactive layout item at ``translateX(-(width prop + 1)px)``, which left a
+ * 33vw drawer partly on screen, so the width used to be applied only while
+ * open (commit 24f8d1bd6). Vuetify 4.2 parks it at ``calc(±100% ± 1px)`` of
+ * its own rendered box, so the width is now unconditional. These tests pin
+ * the offscreen transform that makes that safe.
+ */
+import { createTestingPinia } from "@pinia/testing";
+import { flushPromises, mount } from "@vue/test-utils";
+import { afterEach, describe, expect, test } from "vitest";
+
+import { VApp } from "vuetify/components";
+
+import BookChangeDrawer from "@/components/reader/book-change-drawer.vue";
+import vuetify from "@/plugins/vuetify";
+
+const MAX_PAGE = 10;
+
+let wrappers = [];
+
+async function mountDrawer(direction, { bookChange } = {}) {
+ const pinia = createTestingPinia({
+ // The drawer's location, icon and visibility all come from store actions.
+ stubActions: false,
+ initialState: {
+ reader: {
+ // Each drawer only shows at its own end of the book.
+ page: direction === "prev" ? 0 : MAX_PAGE,
+ bookChange,
+ books: {
+ current: { maxPage: MAX_PAGE },
+ prev: { pk: 1 },
+ next: { pk: 3 },
+ },
+ routes: {
+ books: { prev: { pk: 1, page: 0 }, next: { pk: 3, page: 0 } },
+ },
+ },
+ },
+ });
+ /*
+ * vite-plugin-vuetify's autoImport only rewrites SFC templates, so a
+ * runtime-compiled one has to register VApp itself. The drawer is a
+ * layout item and throws without it.
+ */
+ const Host = {
+ components: { BookChangeDrawer, VApp },
+ props: { direction: { type: String, required: true } },
+ template: `
+
+
+
+ `,
+ };
+ const wrapper = mount(Host, {
+ attachTo: document.body,
+ props: { direction },
+ global: { plugins: [pinia, vuetify], stubs: { RouterLink: true } },
+ });
+ wrappers.push(wrapper);
+ await flushPromises();
+ return wrapper.find(".v-navigation-drawer");
+}
+
+afterEach(() => {
+ for (const wrapper of wrappers) {
+ wrapper.unmount();
+ }
+ wrappers = [];
+});
+
+describe("BookChangeDrawer — offscreen when closed", () => {
+ test("the previous-book drawer parks a full width to the left", async () => {
+ const drawer = await mountDrawer("prev");
+
+ expect(drawer.exists()).toBe(true);
+ expect(drawer.attributes("style")).toContain(
+ "translateX(calc(-100% + -1px))",
+ );
+ });
+
+ test("the next-book drawer parks a full width to the right", async () => {
+ const drawer = await mountDrawer("next");
+
+ expect(drawer.attributes("style")).toContain(
+ "translateX(calc(100% + 1px))",
+ );
+ });
+
+ test("an open drawer is not translated", async () => {
+ const drawer = await mountDrawer("prev", { bookChange: "prev" });
+
+ expect(drawer.attributes("style")).toContain("translateX(0px)");
+ });
+
+ test("the width class is applied regardless of open state", async () => {
+ const closed = await mountDrawer("prev");
+ const open = await mountDrawer("prev", { bookChange: "prev" });
+
+ for (const drawer of [closed, open]) {
+ expect(drawer.classes()).toContain("bookChangeDrawer");
+ expect(drawer.classes()).not.toContain("drawerActivated");
+ }
+ });
+});
diff --git a/frontend/tests/unit/filter-by-select.test.js b/frontend/tests/unit/filter-by-select.test.js
new file mode 100644
index 000000000..964520d79
--- /dev/null
+++ b/frontend/tests/unit/filter-by-select.test.js
@@ -0,0 +1,151 @@
+/*
+ * Tests for ``filter-by-select.vue`` — the browser's "filter by" menu.
+ *
+ * Both cases guard behavior that Vuetify 4.2 changed underneath this
+ * component:
+ * - ``useSelectionMenu.closeOnSelect()`` now bails when ``menuProps``
+ * carries ``closeOnContentClick: false``, so ``onSubMenuSelected`` is
+ * the only thing left that closes this menu after a pick.
+ * - the list keydown capture added by ``useScrolling`` wraps from either
+ * end of the bookmark rows and stops propagation, which used to strand
+ * prepend/append slot rows ("Clear All Filters", "Favorites Only", the
+ * filter sub-menus) with no keyboard route in. ``onMenuKeydownCapture``
+ * hands those two edge steps to the adjacent slot row instead.
+ *
+ * Real Vuetify is mounted so the overlay, list and its keyboard handlers
+ * are the ones that ship; store actions are stubbed by createTestingPinia.
+ */
+import { createTestingPinia } from "@pinia/testing";
+import { flushPromises, mount } from "@vue/test-utils";
+import { afterEach, beforeAll, describe, expect, test } from "vitest";
+
+import FilterBySelect from "@/components/browser/toolbars/top/filter-by-select.vue";
+import vuetify from "@/plugins/vuetify";
+import { useBrowserStore } from "@/stores/browser";
+
+beforeAll(() => {
+ /*
+ * VOverlay's connected location strategy reads the bare global; happy-dom
+ * has no visual viewport, so the menu never positions without this.
+ */
+ globalThis.visualViewport ??= {
+ width: 1024,
+ height: 768,
+ offsetLeft: 0,
+ offsetTop: 0,
+ scale: 1,
+ addEventListener() {},
+ removeEventListener() {},
+ };
+});
+
+let wrappers = [];
+
+async function mountOpenMenu({ bookmark = "UNREAD", loggedIn = true } = {}) {
+ const pinia = createTestingPinia({
+ initialState: {
+ auth: { user: loggedIn ? { pk: 1 } : undefined },
+ browser: {
+ filterMode: "base",
+ // A non-default bookmark makes the "Clear All Filters" row render.
+ settings: { filters: { bookmark } },
+ choices: { dynamic: { characters: true } },
+ },
+ },
+ });
+ const wrapper = mount(FilterBySelect, {
+ attachTo: document.body,
+ global: { plugins: [pinia, vuetify] },
+ });
+ wrappers.push(wrapper);
+ wrapper.vm.menu = true;
+ await flushPromises();
+ const content = document.querySelector(".v-overlay__content");
+ const rows = [...content.querySelectorAll("[aria-posinset]")];
+ return { wrapper, content, rows, browserStore: useBrowserStore() };
+}
+
+afterEach(() => {
+ for (const wrapper of wrappers) {
+ wrapper.unmount();
+ }
+ wrappers = [];
+});
+
+describe("BrowserFilterBySelect — closing on select", () => {
+ test("picking a bookmark applies it and closes the menu", async () => {
+ const { wrapper, rows, browserStore } = await mountOpenMenu();
+ const inProgress = rows.find((row) =>
+ row.textContent.includes("In Progress"),
+ );
+
+ inProgress.click();
+ await flushPromises();
+
+ expect(browserStore.setSettings).toHaveBeenCalledWith({
+ filters: { bookmark: "IN_PROGRESS" },
+ });
+ expect(wrapper.vm.menu).toBe(false);
+ });
+});
+
+describe("BrowserFilterBySelect — keyboard reach into the slot rows", () => {
+ const arrow = (el, key) =>
+ el.dispatchEvent(
+ new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
+ );
+
+ test("ArrowDown off the last bookmark row lands on Favorites Only", async () => {
+ const { content, rows } = await mountOpenMenu();
+ const last = rows.at(-1);
+
+ last.focus();
+ arrow(last, "ArrowDown");
+
+ expect(document.activeElement).toBe(
+ content.querySelector(".favoritesOnly"),
+ );
+ });
+
+ test("ArrowUp off the first bookmark row lands on Clear All Filters", async () => {
+ const { content, rows } = await mountOpenMenu();
+ const first = rows[0];
+
+ first.focus();
+ arrow(first, "ArrowUp");
+
+ expect(document.activeElement).toBe(content.querySelector(".clearFilter"));
+ });
+
+ test("mid-list rows are left to Vuetify", async () => {
+ const { content, rows } = await mountOpenMenu();
+ const middle = rows[1];
+ let reached = 0;
+ middle.addEventListener("keydown", () => (reached += 1));
+
+ middle.focus();
+ arrow(middle, "ArrowDown");
+
+ /*
+ * Interception stops the event at the overlay content, so an untouched
+ * event is one that still reaches the row Vuetify navigates from.
+ */
+ expect(reached).toBe(1);
+ expect(document.activeElement).not.toBe(
+ content.querySelector(".favoritesOnly"),
+ );
+ });
+
+ test("logged out, the last row steps to the first filter sub-menu", async () => {
+ const { content, rows } = await mountOpenMenu({ loggedIn: false });
+ const last = rows.at(-1);
+
+ last.focus();
+ arrow(last, "ArrowDown");
+
+ // Not a bookmark row: it stepped past the list instead of wrapping.
+ expect(content.querySelector(".favoritesOnly")).toBeNull();
+ expect(content.contains(document.activeElement)).toBe(true);
+ expect(document.activeElement.hasAttribute("aria-posinset")).toBe(false);
+ });
+});