From 6768540f1c65d95bbc015ca22956089abe0aeb2b Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Wed, 2 Sep 2026 11:52:29 -0700 Subject: [PATCH 1/3] Unclip the settings button's librarian progress ring Vuetify 4.2.0 added `overflow: hidden` to `.v-btn` (fix(variant) #22992), which makes the button both the containing block and the clip box for the admin overlays inside it. The 32px progress ring does not fit the 28px compact icon button, so on xs it lost its left and bottom arcs; the safe-area padding clips it on notched phones in landscape too. Restore the pre-4.2 behavior for this one button. The scoped id selector is unlayered so it wins without `!important`, and nothing escapes that the Vuetify clip was meant to contain: `variant="plain"` hides the button overlay, the ripple container clips itself, and inside `v-toolbar-items` the button has no border radius. Co-Authored-By: Claude Opus 5 --- frontend/src/components/settings/button.vue | 6 ++++++ 1 file changed, 6 insertions(+) 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 { From 77de4bef0a24a4ca57afe966caabcdd097cd05a3 Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Wed, 2 Sep 2026 11:52:37 -0700 Subject: [PATCH 2/3] Keep the filter menu's extra rows keyboard reachable Vuetify 4.2.0 moved select keyboard navigation into `useScrolling`, whose list keydown capture wraps from either end of the item rows and calls `stopImmediatePropagation()`. That pre-empts VList's own focus walk, which was what used to carry the user from the bookmark rows into the rows this menu contributes through the prepend/append slots, so "Clear All Filters", "Favorites Only" and the filter sub-menus became mouse-only. A capture listener on the overlay content runs before the list's, so stepping off either end of the bookmark rows now lands on the adjacent slot row instead of wrapping. Every other key and row is left alone and Vuetify still owns the navigation. The neighbour is found by walking the content's focusable rows rather than by naming the slot rows, so it follows the rows that are actually rendered: logged out, there is no "Favorites Only" and the step lands on the first filter sub-menu. Also cover the other 4.2 delta in this menu: `closeOnSelect()` now bails when `menuProps` carries `closeOnContentClick: false`, which leaves `onSubMenuSelected` as the only thing closing the menu after a pick. Co-Authored-By: Claude Opus 5 --- .../browser/toolbars/top/filter-by-select.vue | 42 +++++ frontend/tests/unit/filter-by-select.test.js | 151 ++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 frontend/tests/unit/filter-by-select.test.js 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/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); + }); +}); From bd464ab2dc36319bf5938f7541fa42513fef5011 Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Wed, 2 Sep 2026 11:52:43 -0700 Subject: [PATCH 3/3] Drop the book change drawer's conditional width The drawer is 33vw rather than Vuetify's 256px default. Vuetify 4.1 parked an inactive layout item at `translateX(-(width prop + 1)px)`, computed from the prop and not the rendered box, so a 33vw drawer stayed partly on screen when closed. 24f8d1bd6 worked around that by applying the width only while the drawer was open, which also made closing shrink it from 33vw to 256px mid-slide. Vuetify 4.2.0 parks it at `calc(+/-100% +/- 1px)` of its own rendered box, so the width can be unconditional and the close is a plain slide. Tests pin the offscreen transform that makes this safe. Co-Authored-By: Claude Opus 5 --- .../components/reader/book-change-drawer.vue | 5 - .../tests/unit/book-change-drawer.test.js | 108 ++++++++++++++++++ 2 files changed, 108 insertions(+), 5 deletions(-) create mode 100644 frontend/tests/unit/book-change-drawer.test.js 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/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"); + } + }); +});