diff --git a/client/eslint.config.ts b/client/eslint.config.ts index dbe48ae39..bd23d49e2 100644 --- a/client/eslint.config.ts +++ b/client/eslint.config.ts @@ -36,6 +36,7 @@ export default defineConfigWithVueTs( 'src/components/FormSheet.vue', 'src/features/collection/components/CollectionMembershipList.vue', 'src/features/book/components/metadata-match/*.vue', + 'src/components/browse-header/*.vue', ], rules: { 'vue/v-on-handler-style': ['error', ['method', 'inline']] }, }, diff --git a/client/src/components/ViewHeader.vue b/client/src/components/ViewHeader.vue deleted file mode 100644 index 1de217ac4..000000000 --- a/client/src/components/ViewHeader.vue +++ /dev/null @@ -1,356 +0,0 @@ - - - diff --git a/client/src/components/__tests__/ViewHeader.spec.ts b/client/src/components/__tests__/ViewHeader.spec.ts deleted file mode 100644 index 136812716..000000000 --- a/client/src/components/__tests__/ViewHeader.spec.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { mount } from '@vue/test-utils' -import { describe, expect, it } from 'vitest' -import ViewHeader from '../ViewHeader.vue' - -const stubs = { - AppIcon: { template: '' }, - Button: { template: '' }, - Popover: { template: '
' }, - PopoverTrigger: { template: '
' }, - PopoverContent: { template: '
' }, - Sheet: { template: '
' }, - SheetContent: { template: '
' }, - SheetDescription: { template: '
' }, - SheetHeader: { template: '
' }, - SheetTitle: { template: '
' }, - Tooltip: { template: '
' }, - TooltipTrigger: { template: '
' }, - TooltipContent: { template: '
' }, - ViewHeaderDisplayControls: { template: '
' }, - ViewHeaderMobileMenu: { template: '
' }, - ViewHeaderMobileSearchSheet: { template: '
' }, -} - -function mountHeader() { - return mount(ViewHeader, { - props: { - title: 'Library', - total: 12, - coverSize: 120, - gridGap: 16, - viewMode: 'grid', - }, - global: { stubs }, - }) -} - -describe('ViewHeader icon controls', () => { - it('provides matching labels and tooltips for view modes and display settings', () => { - const wrapper = mountHeader() - - for (const label of ['Grid view', 'List view', 'Table view', 'Display']) { - expect(wrapper.get(`button[aria-label="${label}"]`)).toBeTruthy() - expect(wrapper.findAll('[data-testid="tooltip-content"]').some((content) => content.text() === label)).toBe(true) - } - }) - - it('keeps view-mode buttons interactive through the tooltip trigger', async () => { - const wrapper = mountHeader() - - await wrapper.get('button[aria-label="List view"]').trigger('click') - - expect(wrapper.emitted('update:viewMode')).toEqual([['list']]) - }) -}) diff --git a/client/src/components/browse-header/BrowseFilterControl.vue b/client/src/components/browse-header/BrowseFilterControl.vue new file mode 100644 index 000000000..86c4aaf0e --- /dev/null +++ b/client/src/components/browse-header/BrowseFilterControl.vue @@ -0,0 +1,54 @@ + + + diff --git a/client/src/components/browse-header/BrowseHeader.vue b/client/src/components/browse-header/BrowseHeader.vue new file mode 100644 index 000000000..ad1cd7b94 --- /dev/null +++ b/client/src/components/browse-header/BrowseHeader.vue @@ -0,0 +1,115 @@ + + + diff --git a/client/src/components/browse-header/BrowseInlineGroup.spec.ts b/client/src/components/browse-header/BrowseInlineGroup.spec.ts new file mode 100644 index 000000000..f378d360e --- /dev/null +++ b/client/src/components/browse-header/BrowseInlineGroup.spec.ts @@ -0,0 +1,90 @@ +import { mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, h, nextTick, provide, ref } from 'vue' +import BrowseInlineGroup from './BrowseInlineGroup.vue' +import { BROWSE_BAR_KEY } from './browse-header' + +const passthrough = { template: '
' } +const stubs = { Popover: passthrough, PopoverTrigger: passthrough, PopoverContent: { template: '
' } } + +let resizeCallbacks: (() => void)[] = [] + +class MockResizeObserver { + constructor(callback: () => void) { + resizeCallbacks.push(callback) + } + observe = vi.fn<(target: Element) => void>() + unobserve = vi.fn<(target: Element) => void>() + disconnect = vi.fn<() => void>() +} + +/** + * A bar of `barWidth` holding a 200px sibling and the search field, with the group's natural width + * reported by its measurer. Only widths matter to the fit, so layout is faked rather than rendered. + */ +function mountInBar(barWidth: number, naturalWidth: number) { + const Host = defineComponent({ + setup() { + const bar = ref(null) + provide(BROWSE_BAR_KEY, bar) + return () => + h('div', { ref: bar, 'data-testid': 'bar' }, [ + h('span', { 'data-testid': 'sibling' }), + h('div', { 'data-browse-grow': '' }), + h(BrowseInlineGroup, { label: 'Format', value: null }, { default: () => h('button', { class: 'chip' }, 'EPUB 380') }), + ]) + }, + }) + const wrapper = mount(Host, { attachTo: document.body, global: { stubs } }) + const barEl = wrapper.get('[data-testid="bar"]').element as HTMLElement + Object.defineProperty(barEl, 'clientWidth', { configurable: true, get: () => barWidth }) + for (const child of Array.from(barEl.children) as HTMLElement[]) { + Object.defineProperty(child, 'offsetParent', { configurable: true, get: () => barEl }) + } + const sibling = wrapper.get('[data-testid="sibling"]').element as HTMLElement + sibling.getBoundingClientRect = () => ({ width: 200 }) as DOMRect + const measurer = barEl.querySelector('[inert]') as HTMLElement + Object.defineProperty(measurer, 'scrollWidth', { configurable: true, get: () => naturalWidth }) + return wrapper +} + +async function remeasure() { + for (const callback of resizeCallbacks) callback() + await nextTick() +} + +beforeEach(() => { + resizeCallbacks = [] + vi.stubGlobal('ResizeObserver', MockResizeObserver) +}) + +afterEach(() => { + vi.unstubAllGlobals() + document.body.innerHTML = '' +}) + +describe('BrowseInlineGroup', () => { + it('sits inline when it fits beside a usable search field', async () => { + // 1000 - 200 sibling - 240 search floor leaves 560 for a 400px group. + const wrapper = mountInBar(1000, 400) + await remeasure() + + expect(wrapper.find('[role="group"][aria-label="Format"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="popover"]').exists()).toBe(false) + }) + + it('folds into a dropdown in the same place when it does not', async () => { + const wrapper = mountInBar(700, 400) + await remeasure() + + expect(wrapper.find('[data-testid="popover"]').exists()).toBe(true) + expect(wrapper.find('button[title="Format"]').exists()).toBe(true) + }) + + it('needs some spare room before unfolding, so it does not flicker at the edge', async () => { + // 840 - 200 - 240 = 400 exactly: an inline group stays, a folded one waits for headroom. + const wrapper = mountInBar(840, 400) + await remeasure() + expect(wrapper.find('[data-testid="popover"]').exists()).toBe(true) + }) +}) diff --git a/client/src/components/browse-header/BrowseInlineGroup.vue b/client/src/components/browse-header/BrowseInlineGroup.vue new file mode 100644 index 000000000..7fbec32df --- /dev/null +++ b/client/src/components/browse-header/BrowseInlineGroup.vue @@ -0,0 +1,96 @@ + + + diff --git a/client/src/components/browse-header/BrowseLibraryPicker.vue b/client/src/components/browse-header/BrowseLibraryPicker.vue new file mode 100644 index 000000000..67a30d46f --- /dev/null +++ b/client/src/components/browse-header/BrowseLibraryPicker.vue @@ -0,0 +1,59 @@ + + + diff --git a/client/src/components/browse-header/BrowsePageMenu.vue b/client/src/components/browse-header/BrowsePageMenu.vue new file mode 100644 index 000000000..dd40e6f7d --- /dev/null +++ b/client/src/components/browse-header/BrowsePageMenu.vue @@ -0,0 +1,27 @@ + + + diff --git a/client/src/components/browse-header/BrowseRuleChips.vue b/client/src/components/browse-header/BrowseRuleChips.vue new file mode 100644 index 000000000..c6e0d1174 --- /dev/null +++ b/client/src/components/browse-header/BrowseRuleChips.vue @@ -0,0 +1,65 @@ + + + diff --git a/client/src/components/browse-header/BrowseScanButton.spec.ts b/client/src/components/browse-header/BrowseScanButton.spec.ts new file mode 100644 index 000000000..fffdc8dfc --- /dev/null +++ b/client/src/components/browse-header/BrowseScanButton.spec.ts @@ -0,0 +1,65 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { nextTick, ref } from 'vue' +import type { ScanProgressEvent } from '@bookorbit/types' +import BrowseScanButton from './BrowseScanButton.vue' + +const mocks = vi.hoisted(() => ({ + scanLibrary: vi.fn<(library: { id: number; type: string; name: string }) => Promise>(), + progress: undefined as unknown as { value: ScanProgressEvent | undefined }, +})) + +vi.mock('@/features/scanner/composables/useLibraryScan', () => ({ useLibraryScan: () => ({ scanLibrary: mocks.scanLibrary }) })) +vi.mock('@/features/scanner/composables/useScanProgress', () => ({ + useScanProgress: () => ({ getProgress: () => mocks.progress.value }), +})) + +const library = { id: 1, type: 'books' as const, name: 'Novels' } + +function running(processed: number, total: number): ScanProgressEvent { + return { jobId: 7, libraryId: 1, status: 'running', processed, total, added: 0, updated: 0, missing: 0 } +} + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('BrowseScanButton', () => { + it('starts a scan of its library', async () => { + mocks.progress = ref(undefined) + mocks.scanLibrary.mockResolvedValue(true) + const wrapper = mount(BrowseScanButton, { props: { library } }) + + expect(wrapper.text()).toContain('Scan') + await wrapper.get('button').trigger('click') + await flushPromises() + expect(mocks.scanLibrary).toHaveBeenCalledWith(library) + }) + + it('fills with the scan progress and ignores clicks while it runs', async () => { + mocks.progress = ref(running(212, 454)) + const wrapper = mount(BrowseScanButton, { props: { library } }) + + expect(wrapper.text()).toContain('Scanning') + expect(wrapper.text()).toContain('212 / 454 books') + expect(wrapper.get('button').attributes('aria-busy')).toBe('true') + expect((wrapper.get('[style]').element as HTMLElement).style.width).toBe('47%') + + await wrapper.get('button').trigger('click') + expect(mocks.scanLibrary).not.toHaveBeenCalled() + }) + + it('announces when the scan starts and finishes', async () => { + mocks.progress = ref(undefined) + const wrapper = mount(BrowseScanButton, { props: { library } }) + const status = () => wrapper.get('[role="status"]').text() + + mocks.progress.value = running(1, 10) + await nextTick() + expect(status()).toBe('Scanning Novels') + + mocks.progress.value = { ...running(10, 10), status: 'completed' } + await nextTick() + expect(status()).toBe('Scan of Novels finished') + }) +}) diff --git a/client/src/components/browse-header/BrowseScanButton.vue b/client/src/components/browse-header/BrowseScanButton.vue new file mode 100644 index 000000000..25ec31c36 --- /dev/null +++ b/client/src/components/browse-header/BrowseScanButton.vue @@ -0,0 +1,74 @@ + + + diff --git a/client/src/components/browse-header/BrowseSearchField.spec.ts b/client/src/components/browse-header/BrowseSearchField.spec.ts new file mode 100644 index 000000000..8f213e6ee --- /dev/null +++ b/client/src/components/browse-header/BrowseSearchField.spec.ts @@ -0,0 +1,99 @@ +import { mount } from '@vue/test-utils' +import { afterEach, describe, expect, it } from 'vitest' +import BrowseSearchField from './BrowseSearchField.vue' + +const passthrough = { template: '
' } +const stubs = { + DropdownMenu: passthrough, + DropdownMenuTrigger: passthrough, + DropdownMenuContent: passthrough, + DropdownMenuItem: { template: '', emits: ['click'] }, +} + +const tokens = [ + { index: 0, field: 'Read Status', operator: 'is', value: 'Unread' }, + { index: 2, field: 'Language', operator: 'is', value: 'en' }, +] + +function mountField(props: Record = {}) { + return mount(BrowseSearchField, { + attachTo: document.body, + props: { modelValue: '', placeholder: 'Search title, author, series, narrator...', ...props }, + global: { stubs }, + }) +} + +afterEach(() => { + document.body.innerHTML = '' +}) + +describe('BrowseSearchField', () => { + it('shows each filter rule as a removable token and reports its root position', async () => { + const wrapper = mountField({ tokens, active: true }) + + expect(wrapper.text()).toContain('Read Status') + expect(wrapper.text()).toContain('Unread') + const remove = wrapper.findAll('button[aria-label^="Remove filter"]') + expect(remove).toHaveLength(2) + expect(remove[1]!.attributes('aria-label')).toBe('Remove filter Language is en') + + await remove[1]!.trigger('click') + expect(wrapper.emitted('remove-token')).toEqual([[2]]) + wrapper.unmount() + }) + + it('swaps the long placeholder for a short one once tokens take the room', () => { + expect(mountField().get('input').attributes('placeholder')).toBe('Search title, author, series, narrator...') + expect(mountField({ tokens }).get('input').attributes('placeholder')).toBe('Search') + }) + + it('clears search and filters together from one button, shown only while something narrows the view', async () => { + const idle = mountField() + expect(idle.find('button[aria-label="Clear search and filters"]').exists()).toBe(false) + expect(idle.find('kbd').text()).toBe('/') + + const active = mountField({ active: true }) + await active.get('button[aria-label="Clear search and filters"]').trigger('click') + expect(active.emitted('clear')).toHaveLength(1) + }) + + it('offers saving only when there are tokens to save', async () => { + expect(mountField({ canSaveAsSmartScope: true, canSaveFilter: true }).find('.menu-item').exists()).toBe(false) + + const wrapper = mountField({ tokens, canSaveAsSmartScope: true, canSaveFilter: true }) + const items = wrapper.findAll('.menu-item') + expect(items.map((item) => item.text())).toEqual(['Save as Smart Scope', 'Save filter']) + await items[0]!.trigger('click') + await items[1]!.trigger('click') + expect(wrapper.emitted('save-as-smart-scope')).toHaveLength(1) + expect(wrapper.emitted('save-filter')).toHaveLength(1) + }) + + it('focuses on "/" from the page but not while the reader is typing somewhere else', () => { + const wrapper = mountField() + const input = wrapper.get('input').element as HTMLInputElement + Object.defineProperty(input, 'offsetParent', { configurable: true, get: () => document.body }) + + const other = document.createElement('textarea') + document.body.appendChild(other) + other.focus() + other.dispatchEvent(new KeyboardEvent('keydown', { key: '/', bubbles: true, cancelable: true })) + expect(document.activeElement).toBe(other) + + other.blur() + document.body.dispatchEvent(new KeyboardEvent('keydown', { key: '/', bubbles: true, cancelable: true })) + expect(document.activeElement).toBe(input) + wrapper.unmount() + }) + + it('stops listening for "/" once it is unmounted', () => { + const wrapper = mountField() + const input = wrapper.get('input').element as HTMLInputElement + wrapper.unmount() + + const event = new KeyboardEvent('keydown', { key: '/', bubbles: true, cancelable: true }) + document.body.dispatchEvent(event) + expect(event.defaultPrevented).toBe(false) + expect(document.activeElement).not.toBe(input) + }) +}) diff --git a/client/src/components/browse-header/BrowseSearchField.vue b/client/src/components/browse-header/BrowseSearchField.vue new file mode 100644 index 000000000..b064d4a00 --- /dev/null +++ b/client/src/components/browse-header/BrowseSearchField.vue @@ -0,0 +1,247 @@ + + + + + diff --git a/client/src/components/browse-header/BrowseSortControl.vue b/client/src/components/browse-header/BrowseSortControl.vue new file mode 100644 index 000000000..55cb942e8 --- /dev/null +++ b/client/src/components/browse-header/BrowseSortControl.vue @@ -0,0 +1,59 @@ + + + diff --git a/client/src/components/browse-header/BrowseTitleSwitcher.spec.ts b/client/src/components/browse-header/BrowseTitleSwitcher.spec.ts new file mode 100644 index 000000000..ca663b919 --- /dev/null +++ b/client/src/components/browse-header/BrowseTitleSwitcher.spec.ts @@ -0,0 +1,80 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import BrowseTitleSwitcher from './BrowseTitleSwitcher.vue' + +const mocks = vi.hoisted(() => ({ + push: vi.fn<(to: unknown) => Promise>(), +})) + +vi.mock('vue-router', () => ({ useRouter: () => ({ push: mocks.push }) })) +vi.mock('@/features/library/composables/useLibraries', () => ({ + useLibraries: () => ({ + libraries: ref([ + { id: 1, name: 'Novels', type: 'books', icon: null, bookCount: 454 }, + { id: 2, name: 'Audiobooks', type: 'books', icon: null, bookCount: 4 }, + { id: 9, name: 'Shows', type: 'podcasts', icon: null, podcastCount: 3 }, + ]), + }), +})) +vi.mock('@/features/smart-scope/composables/useSmartScopes', () => ({ + useSmartScopes: () => ({ bookScopes: ref([{ id: 2, name: 'Kobo', icon: null, mediaType: 'books', bookCount: 172 }]) }), +})) +vi.mock('@/features/collection/composables/useCollections', () => ({ + useCollections: () => ({ bookCollections: ref([{ id: 8, name: 'Autumn Reads', icon: null, mediaType: 'books', bookCount: 2 }]) }), +})) + +const passthrough = { template: '
' } +const stubs = { + Popover: passthrough, + PopoverTrigger: passthrough, + PopoverContent: { template: '
' }, + AppIcon: true, +} + +function mountSwitcher() { + return mount(BrowseTitleSwitcher, { props: { place: { kind: 'library', id: 1 }, title: 'Novels' }, global: { stubs } }) +} + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('BrowseTitleSwitcher', () => { + it('lists the book libraries, scopes and collections, with the current place marked', () => { + const wrapper = mountSwitcher() + + const items = wrapper.findAll('[data-switch-item]') + expect(items.map((item) => item.findAll('span').map((span) => span.text()))).toEqual([ + ['Novels', '454'], + ['Audiobooks', '4'], + ['Kobo', '172'], + ['Autumn Reads', '2'], + ]) + expect(items[0]!.attributes('aria-current')).toBe('page') + expect(wrapper.text()).not.toContain('Shows') + }) + + it('narrows the list as the reader types', async () => { + const wrapper = mountSwitcher() + + await wrapper.get('input').setValue('ko') + expect(wrapper.findAll('[data-switch-item]').map((item) => item.text())).toEqual([expect.stringContaining('Kobo')]) + }) + + it('goes to the picked place and stays put on the current one', async () => { + const wrapper = mountSwitcher() + + const items = wrapper.findAll('[data-switch-item]') + await items[0]!.trigger('click') + expect(mocks.push).not.toHaveBeenCalled() + + await items[3]!.trigger('click') + await flushPromises() + expect(mocks.push).toHaveBeenCalledWith({ name: 'collection', params: { id: 8 } }) + }) + + it('names the place it switches from', () => { + expect(mountSwitcher().get('button').attributes('aria-label')).toBe('Switch from Novels') + }) +}) diff --git a/client/src/components/browse-header/BrowseTitleSwitcher.vue b/client/src/components/browse-header/BrowseTitleSwitcher.vue new file mode 100644 index 000000000..17ec47299 --- /dev/null +++ b/client/src/components/browse-header/BrowseTitleSwitcher.vue @@ -0,0 +1,201 @@ + + + diff --git a/client/src/components/browse-header/BrowseViewControls.vue b/client/src/components/browse-header/BrowseViewControls.vue new file mode 100644 index 000000000..c454b0410 --- /dev/null +++ b/client/src/components/browse-header/BrowseViewControls.vue @@ -0,0 +1,153 @@ + + + diff --git a/client/src/components/browse-header/FormatFacetChips.spec.ts b/client/src/components/browse-header/FormatFacetChips.spec.ts new file mode 100644 index 000000000..0cfa3f9be --- /dev/null +++ b/client/src/components/browse-header/FormatFacetChips.spec.ts @@ -0,0 +1,36 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import FormatFacetChips from './FormatFacetChips.vue' + +describe('FormatFacetChips', () => { + it('shows each format with its count and marks the picked ones', () => { + const wrapper = mount(FormatFacetChips, { + props: { + facets: [ + { format: 'epub', count: 380 }, + { format: 'mobi', count: 55 }, + ], + selected: ['mobi'], + }, + }) + + const chips = wrapper.findAll('button') + expect(chips.map((chip) => chip.attributes('aria-pressed'))).toEqual(['false', 'true']) + expect(chips[0]!.text()).toContain('380') + expect(chips[1]!.text()).toContain('55') + }) + + it('keeps a picked format whose count fell to nothing, so it can still be unpicked', async () => { + const wrapper = mount(FormatFacetChips, { + props: { facets: [{ format: 'epub', count: 241 }], selected: ['pdf'] }, + }) + + const chips = wrapper.findAll('button') + expect(chips).toHaveLength(2) + expect(chips[1]!.attributes('aria-pressed')).toBe('true') + expect(chips[1]!.text()).toContain('0') + + await chips[1]!.trigger('click') + expect(wrapper.emitted('toggle')).toEqual([['pdf']]) + }) +}) diff --git a/client/src/components/browse-header/FormatFacetChips.vue b/client/src/components/browse-header/FormatFacetChips.vue new file mode 100644 index 000000000..83cf20129 --- /dev/null +++ b/client/src/components/browse-header/FormatFacetChips.vue @@ -0,0 +1,44 @@ + + + diff --git a/client/src/components/browse-header/browse-header.ts b/client/src/components/browse-header/browse-header.ts new file mode 100644 index 000000000..0fb0fc1b0 --- /dev/null +++ b/client/src/components/browse-header/browse-header.ts @@ -0,0 +1,36 @@ +import type { InjectionKey, Ref } from 'vue' + +export type BrowsePlaceKind = 'library' | 'smartScope' | 'collection' + +export interface BrowsePlace { + kind: BrowsePlaceKind + id: number +} + +/** The command bar element, so a facet group can measure the room it has left. */ +export const BROWSE_BAR_KEY: InjectionKey> = Symbol('browse-bar') + +const focusRing = 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60' + +/** A borderless control inside the command bar. */ +export const barButton = `inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-transparent px-2.5 text-[13px] font-medium whitespace-nowrap text-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50 ${focusRing}` + +/** The same control while it holds a value: a picked facet, an open popover, an active filter. */ +export const barButtonOn = 'border-primary/45 bg-primary/12 text-primary hover:bg-primary/15' + +export const barIconButton = `inline-grid size-8 shrink-0 place-items-center rounded-lg border border-transparent text-foreground transition-colors hover:bg-muted ${focusRing}` + +/** The title row's page actions, which sit outside the bar and carry a border. */ +export const outlineButton = `inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-input px-3 text-[13px] font-medium whitespace-nowrap text-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50 ${focusRing}` + +export const barDivider = 'mx-1 h-5 w-px shrink-0 bg-border' + +/** The uppercase label in front of an inline facet group, the app's card-label style. */ +export const facetLabel = 'shrink-0 text-[10.5px] font-bold tracking-[0.08em] text-muted-foreground uppercase' + +/** A pill-shaped facet chip; `aria-pressed` drives the picked state. */ +export const facetChip = `inline-flex h-7 shrink-0 items-center gap-1.5 rounded-full border border-border px-2.5 text-[12.5px] font-medium whitespace-nowrap text-foreground transition-colors hover:bg-muted aria-pressed:border-primary/45 aria-pressed:bg-primary/12 aria-pressed:text-primary ${focusRing}` + +/** A segmented control's track and its segments; `aria-pressed` or `aria-selected` marks the current one. */ +export const segmentTrack = 'inline-flex h-8 shrink-0 items-center gap-0.5 rounded-[9px] border border-border bg-surface-2 p-0.5' +export const segmentItem = `inline-flex h-[26px] shrink-0 items-center gap-1.5 rounded-[7px] px-2.5 text-[12.5px] font-medium whitespace-nowrap text-foreground transition-colors hover:bg-muted aria-pressed:bg-surface-4 aria-pressed:font-semibold aria-selected:bg-surface-4 aria-selected:font-semibold ${focusRing}` diff --git a/client/src/components/sidebar/sidebar-order.ts b/client/src/components/sidebar/sidebar-order.ts index 494f4b43a..608c1119d 100644 --- a/client/src/components/sidebar/sidebar-order.ts +++ b/client/src/components/sidebar/sidebar-order.ts @@ -28,3 +28,29 @@ export function ownedInOrder(order: DisplayOrderEntry[], owned: Identified[]): I const ownedIds = new Set(owned.map((entry) => entry.id)) return order.filter((entry) => ownedIds.has(entry.id)).map((entry) => ({ id: entry.id })) } + +export type PersistOrder = (order: DisplayOrderEntry[]) => Promise + +/** + * One persist function per medium for a list whose books and podcasts share a displayOrder run. + * `books` and `podcasts` return the rows the user may reorder; each persist keeps the other + * medium's current order and rewrites the whole run in one request. + */ +export function mediaOrderPersisters(options: { + books: () => Identified[] + podcasts: () => Identified[] + reorder: PersistOrder +}): { books: PersistOrder; podcasts: PersistOrder } { + return { + async books(order) { + const books = ownedInOrder(order, options.books()) + if (books.length === 0) return + await options.reorder(mergedMediaOrder(books, options.podcasts())) + }, + async podcasts(order) { + const podcasts = ownedInOrder(order, options.podcasts()) + if (podcasts.length === 0) return + await options.reorder(mergedMediaOrder(options.books(), podcasts)) + }, + } +} diff --git a/client/src/components/view-header/ViewHeaderDisplayControls.vue b/client/src/components/view-header/ViewHeaderDisplayControls.vue index eda159474..df5d73016 100644 --- a/client/src/components/view-header/ViewHeaderDisplayControls.vue +++ b/client/src/components/view-header/ViewHeaderDisplayControls.vue @@ -25,6 +25,11 @@ withDefaults( */ showCoverFallbackToggle?: boolean coverFallback?: boolean + /** Book views only: series stacking, moved here from its own bar button. */ + showCollapseSeries?: boolean + collapseSeries?: boolean + /** Selection keeps every book visible, so stacking is locked while selecting. */ + collapseSeriesLocked?: boolean coverSizeMin?: number coverSizeMax?: number coverSizeStep?: number @@ -50,8 +55,13 @@ const emit = defineEmits<{ 'update:coverShape': [value: 'square' | 'circle'] 'update:rowDensity': [value: TableDensity] 'update:coverFallback': [value: boolean] + 'update:collapseSeries': [value: boolean] }>() +function handleCollapseSeriesChange(event: Event) { + emit('update:collapseSeries', (event.target as HTMLInputElement).checked) +} + function handleCoverFallbackChange(event: Event) { emit('update:coverFallback', (event.target as HTMLInputElement).checked) } @@ -157,6 +167,24 @@ function handleShowJumpRailsChange(event: Event) { + +
{{ t('components.viewHeader.displayControls.coverShape') }}
@@ -185,5 +213,6 @@ function handleShowJumpRailsChange(event: Event) {
+
diff --git a/client/src/features/author/components/AuthorFilterChips.vue b/client/src/features/author/components/AuthorFilterChips.vue deleted file mode 100644 index 7ea4d8692..000000000 --- a/client/src/features/author/components/AuthorFilterChips.vue +++ /dev/null @@ -1,107 +0,0 @@ - - - diff --git a/client/src/features/author/views/AuthorsView.spec.ts b/client/src/features/author/views/AuthorsView.spec.ts index 5d0e44a8a..3757b6072 100644 --- a/client/src/features/author/views/AuthorsView.spec.ts +++ b/client/src/features/author/views/AuthorsView.spec.ts @@ -4,7 +4,7 @@ import { flushPromises, shallowMount } from '@vue/test-utils' import type { AuthorSummary } from '@bookorbit/types' import type { AuthorListSort, SortDirection } from '../types/author' import AuthorsView from './AuthorsView.vue' -import AuthorFilterChips from '../components/AuthorFilterChips.vue' +import BrowseHeader from '@/components/browse-header/BrowseHeader.vue' class MockIntersectionObserver { observe = vi.fn<(target: Element) => void>() @@ -143,6 +143,20 @@ async function mountView() { return wrapper } +/** Renders the header's bar so its controls can be driven; everything else stays shallow. */ +async function mountWithBar() { + const wrapper = shallowMount(AuthorsView, { + global: { + stubs: { + BrowseHeader: { template: '
' }, + BrowseInlineGroup: { template: '
' }, + }, + }, + }) + await flushPromises() + return wrapper +} + describe('AuthorsView', () => { beforeEach(() => { vi.stubGlobal('IntersectionObserver', MockIntersectionObserver) @@ -173,14 +187,14 @@ describe('AuthorsView', () => { vi.clearAllMocks() }) - it('keeps the filter chips outside the scrolling
so they stay anchored', async () => { + it('keeps the quick filters outside the scrolling
so they stay anchored', async () => { const wrapper = await mountView() expect(wrapper.find('main').exists()).toBe(true) - const chips = wrapper.findComponent(AuthorFilterChips) - expect(chips.exists()).toBe(true) + const header = wrapper.findComponent(BrowseHeader) + expect(header.exists()).toBe(true) // Regression guard for #326: filters must NOT live inside the scroll container. - expect(chips.element.closest('main')).toBeNull() + expect(header.element.closest('main')).toBeNull() }) it('hydrates a quick filter from the route into the matching list filters', async () => { @@ -195,11 +209,13 @@ describe('AuthorsView', () => { it('treats the quick filters as mutually exclusive', async () => { mocks.route.query = { filter: 'multipleBooks' } - const wrapper = await mountView() + const wrapper = await mountWithBar() expect(mocks.minBookCount.value).toBe(2) - wrapper.findComponent(AuthorFilterChips).vm.$emit('update:quickFilter', 'recentlyAdded') + const quickFilters = wrapper.get('[data-testid="browse-header"]').findAll('button[aria-pressed]') + // All, No portrait, 2+ books, Added this week, No sort name. + await quickFilters[3]!.trigger('click') await flushPromises() expect(mocks.minBookCount.value).toBeNull() diff --git a/client/src/features/author/views/AuthorsView.vue b/client/src/features/author/views/AuthorsView.vue index a34e57b9e..c5fe050a6 100644 --- a/client/src/features/author/views/AuthorsView.vue +++ b/client/src/features/author/views/AuthorsView.vue @@ -3,12 +3,18 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import { formatNumber } from '@/i18n/formatters' import { useRoute, useRouter } from 'vue-router' -import { ArrowUpDown, CheckCheck, RefreshCcw, Trash2, X } from '@lucide/vue' +import { CheckCheck, RefreshCcw, Trash2, X } from '@lucide/vue' import { toast } from 'vue-sonner' import SelectionActionBar from '@/components/SelectionActionBar.vue' -import ViewHeader from '@/components/ViewHeader.vue' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import BrowseHeader from '@/components/browse-header/BrowseHeader.vue' +import BrowseSearchField from '@/components/browse-header/BrowseSearchField.vue' +import BrowseSortControl from '@/components/browse-header/BrowseSortControl.vue' +import BrowseInlineGroup from '@/components/browse-header/BrowseInlineGroup.vue' +import BrowseLibraryPicker from '@/components/browse-header/BrowseLibraryPicker.vue' +import BrowseViewControls from '@/components/browse-header/BrowseViewControls.vue' +import { barDivider, segmentItem, segmentTrack } from '@/components/browse-header/browse-header' +import ViewHeaderDisplayControls from '@/components/view-header/ViewHeaderDisplayControls.vue' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useDisplaySettings } from '@/composables/useDisplaySettings' import { usePermissions } from '@/features/auth/composables/usePermissions' @@ -18,7 +24,6 @@ import { useLibraryPermissions } from '@/features/library/composables/useLibrary import JumpRail from '@/features/book/components/JumpRail.vue' import { bulkRefreshAuthorsMetadata, deleteAuthors, refreshAuthorMetadata } from '../api/author' import AuthorConfirmDialog from '../components/AuthorConfirmDialog.vue' -import AuthorFilterChips from '../components/AuthorFilterChips.vue' import AuthorIndexRow from '../components/AuthorIndexRow.vue' import AuthorTile from '../components/AuthorTile.vue' import { useAuthorJumpRail } from '../composables/useAuthorJumpRail' @@ -32,6 +37,7 @@ import { showCacheFallbackToast } from '@/features/metadata-cache/lib/cache-feed import { splitMetadataCacheUsage, usageFallbackProviders, type IgnoreCacheOptions } from '@/features/metadata-cache/lib/metadata-cache' import { metadataProviderLabel } from '@/features/book/lib/metadata-refresh-feedback' import type { BookViewMode } from '@/composables/useDisplaySettings' +import type { TableDensity } from '@bookorbit/types' const { t } = useI18n() const router = useRouter() @@ -89,7 +95,46 @@ const sortLabels = computed>(() => ({ })) const isDefaultSort = computed(() => sort.value === 'name' && order.value === 'asc') -const sortSummary = computed(() => `${sortLabels.value[sort.value]} ${order.value === 'asc' ? '↑' : '↓'}`) +const AUTHOR_SORT_FIELDS: AuthorListSort[] = ['name', 'sortName', 'bookCount', 'lastAddedAt', 'lastEnrichedAt'] +const AUTHOR_QUICK_FILTERS: AuthorQuickFilter[] = ['all', 'noPortrait', 'multipleBooks', 'recentlyAdded', 'noSortName'] + +const quickFilterLabels = computed>(() => ({ + all: t('author.filters.chips.all'), + noPortrait: t('author.filters.chips.noPortrait'), + multipleBooks: t('author.filters.chips.multipleBooks'), + recentlyAdded: t('author.filters.chips.recentlyAdded'), + noSortName: t('author.filters.chips.noSortName'), +})) + +const foldedCount = computed(() => formatNumber(total.value)) + +function toggleSortDirection() { + order.value = order.value === 'asc' ? 'desc' : 'asc' +} + +function handleCoverSizeUpdate(value: number) { + authorCoverSize.value = value +} + +function handleGridGapUpdate(value: number) { + gridGap.value = value +} + +function handleShowJumpRailsUpdate(value: boolean) { + showJumpRails.value = value +} + +function handleCoverShapeUpdate(value: 'square' | 'circle') { + authorCoverShape.value = value +} + +function handleRowDensityUpdate(value: TableDensity) { + authorRowDensity.value = value +} + +function handleCoverFallbackUpdate(value: boolean) { + authorCoverFallback.value = value +} /* ── quick filters ────────────────────────────────────────────────────────── Chips are mutually exclusive, so the filter refs are always derived from one @@ -236,10 +281,6 @@ function setSortField(field: AuthorListSort) { order.value = 'asc' } -function setSortOrder(dir: SortDirection) { - order.value = dir -} - function resetSort() { sort.value = 'name' order.value = 'asc' @@ -513,96 +554,119 @@ defineOptions({ name: 'AuthorsView' })