From d6f181abd9b15fd5c81e0332723d9f239827aaaf Mon Sep 17 00:00:00 2001 From: s3ntin3l8 <58235613+s3ntin3l8@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:19:52 +0000 Subject: [PATCH] feat(library): make list-view column headers clickable for sorting (#625) Title/Author and Status headers now sort on click, with a second click reversing direction. Reuses the existing sortKeyProxy toggle logic; the toolbar dropdown remains for the 7 sort keys without a dedicated column. Headers are keyboard-accessible (role="button", tabindex, Enter/Space handlers) and expose aria-sort, matching the existing status-badge pattern in this view. Adds a Vitest case covering click/keyboard sort + ARIA state. Co-Authored-By: Claude Opus 4.8 (1M context) --- fe/src/__tests__/AudiobooksView.spec.ts | 91 +++++++++++++++++++++++++ fe/src/views/library/AudiobooksView.vue | 57 +++++++++++++++- 2 files changed, 146 insertions(+), 2 deletions(-) diff --git a/fe/src/__tests__/AudiobooksView.spec.ts b/fe/src/__tests__/AudiobooksView.spec.ts index 718836de9..a552a14d7 100644 --- a/fe/src/__tests__/AudiobooksView.spec.ts +++ b/fe/src/__tests__/AudiobooksView.spec.ts @@ -796,3 +796,94 @@ describe('AudiobooksView Grouping', () => { expect(wrapper.find('.series-bottom-placard').exists()).toBe(true) }) }) + +describe('AudiobooksView list header sorting', () => { + beforeEach(() => { + const pinia = createPinia() + setActivePinia(pinia) + }) + + it('sorts on column header click, toggles direction, and exposes ARIA sort state', async () => { + if ( + typeof (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver === 'undefined' + ) { + ;(globalThis as unknown as Record).ResizeObserver = class { + observe() {} + disconnect() {} + } + } + if (typeof (globalThis as unknown as { WebSocket?: unknown }).WebSocket === 'undefined') { + ;(globalThis as unknown as Record).WebSocket = function () { + /* noop */ + } + } + + const pinia = createPinia() + setActivePinia(pinia) + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', name: 'home', component: { template: '
' } }, + { path: '/audiobooks', name: 'audiobooks', component: AudiobooksView }, + ], + }) + await router.push('/audiobooks') + await router.isReady().catch(() => {}) + + const store = useLibraryStore() + store.audiobooks = [ + { id: 1, title: 'Book 1', authors: ['Author A'], imageUrl: 'cover1.jpg', files: [] }, + { id: 2, title: 'Book 2', authors: ['Author B'], imageUrl: 'cover2.jpg', files: [] }, + ] as unknown as import('@/types').Audiobook[] + + store.fetchLibrary = vi.fn(async () => undefined) + // List view, books grouping so the sortable list header renders + localStorage.setItem('listenarr.groupBy', 'books') + localStorage.setItem('listenarr.viewMode', 'list') + const wrapper = mount(AudiobooksView, { + global: { + plugins: [pinia, router], + stubs: [ + 'BulkEditModal', + 'EditAudiobookModal', + 'CustomFilterModal', + 'FiltersDropdown', + 'CustomSelect', + ], + }, + }) + await new Promise((r) => setTimeout(r, 0)) + + const vm = wrapper.vm as unknown + vm.viewMode = 'list' + await wrapper.vm.$nextTick() + + const titleHeader = wrapper.find('.col-title.col-sortable') + const statusHeader = wrapper.find('.col-status.col-sortable') + expect(titleHeader.exists()).toBe(true) + expect(statusHeader.exists()).toBe(true) + // Keyboard accessibility attributes are present + expect(statusHeader.attributes('role')).toBe('button') + expect(statusHeader.attributes('tabindex')).toBe('0') + // Books default sort is title/asc, so the title header starts active and status is inactive + expect(titleHeader.attributes('aria-sort')).toBe('ascending') + expect(statusHeader.attributes('aria-sort')).toBe('none') + + // Click a new (inactive) header → sorts by it, ascending, with a direction icon + await statusHeader.trigger('click') + expect(vm.sortKey).toBe('status') + expect(vm.sortOrder).toBe('asc') + expect(wrapper.find('.col-status.col-sortable').attributes('aria-sort')).toBe('ascending') + expect(wrapper.find('.col-status.col-sortable .sort-icon').exists()).toBe(true) + + // Click the same header again → reverses direction + await wrapper.find('.col-status.col-sortable').trigger('click') + expect(vm.sortOrder).toBe('desc') + expect(wrapper.find('.col-status.col-sortable').attributes('aria-sort')).toBe('descending') + + // Keyboard activation (Enter) on a different header sorts by it, resetting to ascending + await wrapper.find('.col-title.col-sortable').trigger('keydown.enter') + expect(vm.sortKey).toBe('title') + expect(vm.sortOrder).toBe('asc') + }) +}) diff --git a/fe/src/views/library/AudiobooksView.vue b/fe/src/views/library/AudiobooksView.vue index f7b7fa4ec..680a4bf08 100644 --- a/fe/src/views/library/AudiobooksView.vue +++ b/fe/src/views/library/AudiobooksView.vue @@ -532,8 +532,38 @@
Cover
-
Title / Author
-
Status
+
+ Title / Author + + +
+
+ Status + + +
Actions