feat(client): make the sidebar Browse zone collapsible

Browse was the only labelled zone that could not be collapsed, leaving no way to reclaim
those rows for someone who never browses by author or series. Reuse the section header,
persistence and rail behaviour the entity sections already have rather than introducing a
second mechanism, and extract the shared collapse transition so both call sites stay in
step.

Browse has no entity list to bound, so make the persisted section cap optional and keep it
off that section entirely. Zones now carry their own section id, so adding another
collapsible zone needs no change in the template.
This commit is contained in:
neon
2026-08-11 20:58:26 +00:00
parent 867295cb63
commit f199e46d1c
10 changed files with 300 additions and 140 deletions
+7 -1
View File
@@ -183,7 +183,13 @@ onUnmounted(() => stopLibraryUploadListener())
<SidebarContent>
<!-- Fixed destinations come first: they are a known height, so the variable-length
entity sections below can never push them out of the first screenful. -->
<SidebarZone v-for="zone in zones" :key="zone.id" :label="zone.labelKey ? t(zone.labelKey) : null">
<SidebarZone
v-for="zone in zones"
:key="zone.id"
:label="zone.labelKey ? t(zone.labelKey) : null"
:section-id="zone.sectionId ?? undefined"
:always-open="isRail"
>
<SidebarNavItem
v-for="entry in zone.entries"
:key="entry.id"
@@ -3,10 +3,11 @@ import { computed, ref, toRef, useId, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ArrowRight, GripVertical, Search, X } from '@lucide/vue'
import { VueDraggable } from 'vue-draggable-plus'
import { SIDEBAR_DEFAULT_CAP, type SidebarCap, type SidebarSectionId } from '@bookorbit/types'
import { SIDEBAR_DEFAULT_CAP, type SidebarCap, type SidebarCappedSectionId } from '@bookorbit/types'
import { SidebarGroup, SidebarGroupContent, SidebarMenu } from '@/components/ui/sidebar'
import SidebarNavItem from '@/components/sidebar/SidebarNavItem.vue'
import SidebarSectionHeader from '@/components/sidebar/SidebarSectionHeader.vue'
import SidebarSectionBody from '@/components/sidebar/SidebarSectionBody.vue'
import SidebarBadge from '@/components/sidebar/SidebarBadge.vue'
import { formatCompactNumber, formatNumber } from '@/i18n/formatters'
import { useDraggableOrder } from '@/composables/useDraggableOrder'
@@ -22,7 +23,7 @@ export interface SidebarEntity {
const props = withDefaults(
defineProps<{
sectionId: SidebarSectionId
sectionId: SidebarCappedSectionId
label: string
items: SidebarEntity[]
routeName: string
@@ -69,7 +70,7 @@ const {
const sectionState = computed(() => sections[props.sectionId])
const isOpen = computed(() => props.alwaysOpen || sectionState.value.open)
const cap = computed<SidebarCap>(() => sectionState.value.cap)
const cap = computed<SidebarCap>(() => sectionState.value.cap ?? SIDEBAR_DEFAULT_CAP)
const capValue = computed(() => (cap.value === 'all' ? Number.POSITIVE_INFINITY : cap.value))
const total = computed(() => localItems.value.length)
@@ -185,124 +186,95 @@ function onGripBlur(id: number) {
@toggle-reorder="toggleReorder"
/>
<Transition name="section">
<div v-if="isOpen" :id="contentId">
<SidebarGroupContent>
<div v-if="showFilter" class="mb-1 flex h-8 items-center gap-1.5 rounded-md border border-(--shell-accent-line) px-2">
<Search :size="13" aria-hidden="true" class="shrink-0 text-muted-foreground" />
<label :for="filterInputId" class="sr-only">{{ filterLabel }}</label>
<input
:id="filterInputId"
v-model="query"
type="text"
:placeholder="filterPlaceholder"
class="h-full w-full min-w-0 bg-transparent text-[14px] text-sidebar-foreground outline-none placeholder:text-muted-foreground"
@keydown="handleFilterKeydown"
/>
<button
v-if="isFiltering"
type="button"
class="shrink-0 rounded-sm text-muted-foreground transition-colors duration-150 hover:text-sidebar-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring"
:aria-label="t('components.sidebar.clearFilter')"
@click="clearQuery"
>
<X :size="13" aria-hidden="true" />
</button>
</div>
<SidebarMenu>
<VueDraggable
v-model="localItems"
tag="div"
class="contents"
:animation="150"
handle=".drag-handle"
:disabled="!dragEnabled"
:delay="200"
:delay-on-touch-only="true"
@start="onDragStart"
@end="onDragEnd"
>
<SidebarNavItem
v-for="item in visibleItems"
:key="item.id"
:is-active="activeId === item.id"
:tooltip="item.name"
:to="itemRoute(item.id)"
:icon="item.icon || fallbackIcon"
:fallback-icon="fallbackIcon"
:label="item.name"
@navigate="handleNavigate"
>
<template #badge>
<slot name="itemBadge" :item="item">
<SidebarBadge v-if="itemCount(item) !== null">{{ formatCompactNumber(itemCount(item) ?? 0) }}</SidebarBadge>
</slot>
</template>
<template v-if="dragEnabled" #trailing>
<button
type="button"
class="drag-handle flex h-8 w-4 shrink-0 cursor-grab items-center justify-center rounded-sm transition-colors duration-150 hover:text-sidebar-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring"
:class="liftedId === item.id ? 'text-primary' : 'text-muted-foreground'"
:aria-label="gripLabel(item)"
:aria-pressed="liftedId === item.id"
@keydown="onGripKeydown($event, item.id)"
@blur="onGripBlur(item.id)"
>
<GripVertical :size="13" aria-hidden="true" />
</button>
</template>
<template #extra>
<slot name="itemExtra" :item="item" />
</template>
</SidebarNavItem>
</VueDraggable>
</SidebarMenu>
<p v-if="isFiltering" class="px-2 pt-1 text-[13px] text-muted-foreground" aria-live="polite">{{ filterSummary }}</p>
<p v-else-if="total === 0" class="px-2 py-1 text-[13px] text-muted-foreground">{{ emptyText }}</p>
<p v-if="isFiltering && canReorder" class="px-2 text-[13px] text-muted-foreground">{{ t('components.sidebar.reorder.filteredHint') }}</p>
<RouterLink
v-if="showSeeAll && !isFiltering"
:to="{ name: indexRouteName }"
class="mt-0.5 flex h-8 items-center gap-1 rounded-md px-2 text-[13px] font-medium text-muted-foreground outline-hidden transition-colors duration-150 hover:bg-(--shell-accent-wash) hover:text-sidebar-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring"
@click="handleNavigate"
<SidebarSectionBody :open="isOpen" :content-id="contentId">
<SidebarGroupContent>
<div v-if="showFilter" class="mb-1 flex h-8 items-center gap-1.5 rounded-md border border-(--shell-accent-line) px-2">
<Search :size="13" aria-hidden="true" class="shrink-0 text-muted-foreground" />
<label :for="filterInputId" class="sr-only">{{ filterLabel }}</label>
<input
:id="filterInputId"
v-model="query"
type="text"
:placeholder="filterPlaceholder"
class="h-full w-full min-w-0 bg-transparent text-[14px] text-sidebar-foreground outline-none placeholder:text-muted-foreground"
@keydown="handleFilterKeydown"
/>
<button
v-if="isFiltering"
type="button"
class="shrink-0 rounded-sm text-muted-foreground transition-colors duration-150 hover:text-sidebar-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring"
:aria-label="t('components.sidebar.clearFilter')"
@click="clearQuery"
>
<span class="min-w-0 truncate">{{ seeAllLabel }}</span>
<ArrowRight :size="13" aria-hidden="true" class="shrink-0" />
</RouterLink>
<X :size="13" aria-hidden="true" />
</button>
</div>
<p class="sr-only" role="status" aria-live="polite">{{ reorderAnnouncement }}</p>
</SidebarGroupContent>
</div>
</Transition>
<SidebarMenu>
<VueDraggable
v-model="localItems"
tag="div"
class="contents"
:animation="150"
handle=".drag-handle"
:disabled="!dragEnabled"
:delay="200"
:delay-on-touch-only="true"
@start="onDragStart"
@end="onDragEnd"
>
<SidebarNavItem
v-for="item in visibleItems"
:key="item.id"
:is-active="activeId === item.id"
:tooltip="item.name"
:to="itemRoute(item.id)"
:icon="item.icon || fallbackIcon"
:fallback-icon="fallbackIcon"
:label="item.name"
@navigate="handleNavigate"
>
<template #badge>
<slot name="itemBadge" :item="item">
<SidebarBadge v-if="itemCount(item) !== null">{{ formatCompactNumber(itemCount(item) ?? 0) }}</SidebarBadge>
</slot>
</template>
<template v-if="dragEnabled" #trailing>
<button
type="button"
class="drag-handle flex h-8 w-4 shrink-0 cursor-grab items-center justify-center rounded-sm transition-colors duration-150 hover:text-sidebar-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring"
:class="liftedId === item.id ? 'text-primary' : 'text-muted-foreground'"
:aria-label="gripLabel(item)"
:aria-pressed="liftedId === item.id"
@keydown="onGripKeydown($event, item.id)"
@blur="onGripBlur(item.id)"
>
<GripVertical :size="13" aria-hidden="true" />
</button>
</template>
<template #extra>
<slot name="itemExtra" :item="item" />
</template>
</SidebarNavItem>
</VueDraggable>
</SidebarMenu>
<p v-if="isFiltering" class="px-2 pt-1 text-[13px] text-muted-foreground" aria-live="polite">{{ filterSummary }}</p>
<p v-else-if="total === 0" class="px-2 py-1 text-[13px] text-muted-foreground">{{ emptyText }}</p>
<p v-if="isFiltering && canReorder" class="px-2 text-[13px] text-muted-foreground">{{ t('components.sidebar.reorder.filteredHint') }}</p>
<RouterLink
v-if="showSeeAll && !isFiltering"
:to="{ name: indexRouteName }"
class="mt-0.5 flex h-8 items-center gap-1 rounded-md px-2 text-[13px] font-medium text-muted-foreground outline-hidden transition-colors duration-150 hover:bg-(--shell-accent-wash) hover:text-sidebar-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring"
@click="handleNavigate"
>
<span class="min-w-0 truncate">{{ seeAllLabel }}</span>
<ArrowRight :size="13" aria-hidden="true" class="shrink-0" />
</RouterLink>
<p class="sr-only" role="status" aria-live="polite">{{ reorderAnnouncement }}</p>
</SidebarGroupContent>
</SidebarSectionBody>
</SidebarGroup>
</template>
<style scoped>
.section-enter-active,
.section-leave-active {
transition:
opacity 0.2s ease,
max-height 0.2s ease;
overflow: hidden;
}
.section-enter-from,
.section-leave-to {
opacity: 0;
max-height: 0;
}
.section-enter-to,
.section-leave-from {
opacity: 1;
max-height: 60rem;
}
@media (prefers-reduced-motion: reduce) {
.section-enter-active,
.section-leave-active {
transition: none;
}
}
</style>
@@ -0,0 +1,38 @@
<script setup lang="ts">
defineProps<{ open: boolean; contentId?: string }>()
</script>
<template>
<Transition name="section">
<div v-if="open" :id="contentId">
<slot />
</div>
</Transition>
</template>
<style scoped>
.section-enter-active,
.section-leave-active {
transition:
opacity 0.2s ease,
max-height 0.2s ease;
overflow: hidden;
}
.section-enter-from,
.section-leave-to {
opacity: 0;
max-height: 0;
}
.section-enter-to,
.section-leave-from {
opacity: 1;
max-height: 60rem;
}
@media (prefers-reduced-motion: reduce) {
.section-enter-active,
.section-leave-active {
transition: none;
}
}
</style>
+38 -7
View File
@@ -1,21 +1,52 @@
<script setup lang="ts">
import { computed, useId } from 'vue'
import type { SidebarSectionId } from '@bookorbit/types'
import { SidebarGroup, SidebarGroupContent, SidebarMenu } from '@/components/ui/sidebar'
import SidebarSectionHeader from '@/components/sidebar/SidebarSectionHeader.vue'
import SidebarSectionBody from '@/components/sidebar/SidebarSectionBody.vue'
import { useSidebarPrefs } from '@/composables/useSidebarPrefs'
defineProps<{ label?: string | null }>()
const props = withDefaults(
defineProps<{
label?: string | null
/** Zones without a section id are always rendered and get a plain, non-interactive label. */
sectionId?: SidebarSectionId
alwaysOpen?: boolean
}>(),
{ alwaysOpen: false },
)
const { sections, toggleSection } = useSidebarPrefs()
const contentId = `sidebar-zone-${useId()}`
const isOpen = computed(() => props.alwaysOpen || !props.sectionId || sections[props.sectionId].open)
function handleToggle() {
if (props.sectionId) toggleSection(props.sectionId)
}
</script>
<template>
<SidebarGroup>
<SidebarSectionHeader
v-if="label && sectionId"
:label="label"
:is-open="isOpen"
:content-id="contentId"
:collapsible="!alwaysOpen"
@toggle="handleToggle"
/>
<p
v-if="label"
v-else-if="label"
class="mb-0.5 px-2 pt-1.5 pb-1 text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground group-data-[collapsible=icon]:hidden"
>
{{ label }}
</p>
<SidebarGroupContent>
<SidebarMenu>
<slot />
</SidebarMenu>
</SidebarGroupContent>
<SidebarSectionBody :open="isOpen" :content-id="sectionId ? contentId : undefined">
<SidebarGroupContent>
<SidebarMenu>
<slot />
</SidebarMenu>
</SidebarGroupContent>
</SidebarSectionBody>
</SidebarGroup>
</template>
@@ -1,10 +1,11 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { reactive, nextTick } from 'vue'
import type { SidebarCap, SidebarSectionId } from '@bookorbit/types'
import type { SidebarCap, SidebarCappedSectionId, SidebarSectionId } from '@bookorbit/types'
import SidebarEntitySection from '../SidebarEntitySection.vue'
const sections = reactive<Record<SidebarSectionId, { open: boolean; cap: SidebarCap }>>({
const sections = reactive<Record<SidebarSectionId, { open: boolean; cap?: SidebarCap }>>({
browse: { open: true },
libraries: { open: true, cap: 8 },
smartScopes: { open: true, cap: 8 },
collections: { open: true, cap: 8 },
@@ -64,7 +65,7 @@ function makeItems(count: number) {
function mountSection(itemCount: number, overrides: Record<string, unknown> = {}) {
return mount(SidebarEntitySection, {
props: {
sectionId: 'collections' as SidebarSectionId,
sectionId: 'collections' as SidebarCappedSectionId,
label: 'Collections',
items: makeItems(itemCount),
routeName: 'collection',
@@ -0,0 +1,86 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick, reactive } from 'vue'
import type { SidebarCap, SidebarSectionId } from '@bookorbit/types'
import SidebarZone from '../SidebarZone.vue'
const sections = reactive<Record<SidebarSectionId, { open: boolean; cap?: SidebarCap }>>({
browse: { open: true },
libraries: { open: true, cap: 8 },
smartScopes: { open: true, cap: 8 },
collections: { open: true, cap: 8 },
})
vi.mock('@/composables/useSidebarPrefs', () => ({
useSidebarPrefs: () => ({
sections,
toggleSection: (id: SidebarSectionId) => {
sections[id].open = !sections[id].open
},
}),
}))
vi.mock('@/components/ui/sidebar', () => ({
SidebarGroup: { name: 'SidebarGroup', template: '<section><slot /></section>' },
SidebarGroupContent: { name: 'SidebarGroupContent', template: '<div><slot /></div>' },
SidebarMenu: { name: 'SidebarMenu', template: '<ul><slot /></ul>' },
}))
vi.mock('@/stores/theme', () => ({
useThemeStore: () => ({ radius: 'default' }),
}))
type ZoneProps = { label?: string | null; sectionId?: SidebarSectionId; alwaysOpen?: boolean }
function mountZone(overrides: ZoneProps = {}) {
return mount(SidebarZone, {
props: { label: 'Browse', sectionId: 'browse' as SidebarSectionId, ...overrides },
slots: { default: '<li>Authors</li>' },
global: {
stubs: {
Transition: { name: 'Transition', template: '<slot />' },
},
},
})
}
describe('SidebarZone', () => {
beforeEach(() => {
sections.browse.open = true
})
it('toggles a persisted section from its header', async () => {
const wrapper = mountZone()
const toggle = wrapper.get('button[aria-expanded]')
expect(toggle.attributes('aria-expanded')).toBe('true')
const contentId = toggle.attributes('aria-controls')
expect(wrapper.find(`#${contentId}`).exists()).toBe(true)
await toggle.trigger('click')
await nextTick()
expect(sections.browse.open).toBe(false)
expect(wrapper.get('button[aria-expanded]').attributes('aria-expanded')).toBe('false')
expect(wrapper.text()).not.toContain('Authors')
})
it('stays open in the icon rail regardless of the persisted state', () => {
sections.browse.open = false
const wrapper = mountZone({ alwaysOpen: true })
expect(wrapper.text()).toContain('Authors')
expect(wrapper.find('button[aria-expanded]').exists()).toBe(false)
})
it('renders a plain label and no toggle for a zone with no persisted state', () => {
sections.browse.open = false
const wrapper = mountZone({ sectionId: undefined })
expect(wrapper.find('button').exists()).toBe(false)
expect(wrapper.get('p').text()).toBe('Browse')
expect(wrapper.text()).toContain('Authors')
})
})
@@ -33,6 +33,7 @@ describe('useSidebarPrefs', () => {
const { parseSidebarConfig } = await loadModule()
expect(parseSidebarConfig(undefined)).toEqual({
browse: { open: true },
libraries: { open: true, cap: 8 },
smartScopes: { open: true, cap: 8 },
collections: { open: true, cap: 8 },
@@ -60,6 +61,12 @@ describe('useSidebarPrefs', () => {
expect(parsed.smartScopes).toEqual({ open: true, cap: 'all' })
expect(parsed.collections).toEqual({ open: false, cap: 20 })
})
it('keeps no cap on a section that has no entity list to bound', async () => {
const { parseSidebarConfig } = await loadModule()
expect(parseSidebarConfig({ sections: { browse: { open: false, cap: 20 } } }).browse).toEqual({ open: false })
})
})
describe('clampCap', () => {
@@ -131,6 +138,7 @@ describe('useSidebarPrefs', () => {
settings: {
sidebarConfig: {
sections: {
browse: { open: true },
libraries: { open: true, cap: 8 },
smartScopes: { open: true, cap: 8 },
collections: { open: false, cap: 20 },
@@ -165,6 +173,7 @@ describe('useSidebarPrefs', () => {
const { sections } = useSidebarPrefs()
await nextTick()
expect(sections.browse.open).toBe(true)
expect(sections.libraries.open).toBe(false)
expect(sections.smartScopes.open).toBe(true)
expect(sections.collections.open).toBe(false)
+9 -1
View File
@@ -2,7 +2,7 @@ import { computed, type Component } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, type RouteLocationNormalizedLoaded, type RouteLocationRaw } from 'vue-router'
import { Highlighter, LayoutDashboard, Library, PackageOpen, Users, Wrench } from '@lucide/vue'
import type { BrowseCounts } from '@bookorbit/types'
import type { BrowseCounts, SidebarSectionId } from '@bookorbit/types'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useBookDockSummary } from '@/features/book-dock/composables/useBookDockSummary'
import { useBrowseCounts } from '@/composables/useBrowseCounts'
@@ -43,6 +43,7 @@ export interface ResolvedSidebarNavEntry {
export interface SidebarNavZone {
id: SidebarZoneId
labelKey: string | null
sectionId: SidebarSectionId | null
entries: ResolvedSidebarNavEntry[]
}
@@ -51,6 +52,12 @@ const ZONE_LABEL_KEYS: Record<SidebarZoneId, string | null> = {
browse: 'components.sidebar.zones.browse',
}
/** Zones with a section id remember whether the user collapsed them; the rest always render. */
const ZONE_SECTION_IDS: Record<SidebarZoneId, SidebarSectionId | null> = {
primary: null,
browse: 'browse',
}
function routeNameStartsWith(route: RouteLocationNormalizedLoaded, prefix: string): boolean {
return typeof route.name === 'string' && route.name.startsWith(prefix)
}
@@ -158,6 +165,7 @@ export function useSidebarNav() {
SIDEBAR_ZONE_IDS.map((zoneId) => ({
id: zoneId,
labelKey: ZONE_LABEL_KEYS[zoneId],
sectionId: ZONE_SECTION_IDS[zoneId],
entries: SIDEBAR_NAV_REGISTRY.filter((entry) => entry.zone === zoneId && isNavEntryAllowed(entry, context.value)).map((entry) =>
resolveNavEntry(entry, context.value, route, t(entry.labelKey)),
),
+11 -6
View File
@@ -1,9 +1,11 @@
import { reactive, watch } from 'vue'
import {
SIDEBAR_CAPPED_SECTION_IDS,
SIDEBAR_CAP_OPTIONS,
SIDEBAR_DEFAULT_CAP,
SIDEBAR_SECTION_IDS,
type SidebarCap,
type SidebarCappedSectionId,
type SidebarConfig,
type SidebarSectionId,
type SidebarSectionState,
@@ -15,7 +17,7 @@ import { useAuth } from '@/features/auth/composables/useAuth'
const PERSIST_DEBOUNCE_MS = 600
/** Section open/closed state predating the account-scoped sidebarConfig blob. */
const LEGACY_SECTION_KEYS: Record<SidebarSectionId, string> = {
const LEGACY_SECTION_KEYS: Partial<Record<SidebarSectionId, string>> = {
libraries: 'bookorbit:sidebar:libraries',
smartScopes: 'bookorbit:sidebar:smart-scopes',
collections: 'bookorbit:sidebar:collections',
@@ -23,8 +25,13 @@ const LEGACY_SECTION_KEYS: Record<SidebarSectionId, string> = {
export const LEGACY_SIDEBAR_WIDTH_KEY = 'bookorbit:sidebar:width'
function isCappedSection(id: SidebarSectionId): id is SidebarCappedSectionId {
return (SIDEBAR_CAPPED_SECTION_IDS as readonly SidebarSectionId[]).includes(id)
}
function defaultSections(): Record<SidebarSectionId, SidebarSectionState> {
return {
browse: { open: true },
libraries: { open: true, cap: SIDEBAR_DEFAULT_CAP },
smartScopes: { open: true, cap: SIDEBAR_DEFAULT_CAP },
collections: { open: true, cap: SIDEBAR_DEFAULT_CAP },
@@ -47,10 +54,7 @@ export function parseSidebarConfig(raw: unknown): Record<SidebarSectionId, Sideb
const stored = (storedSections as Record<string, unknown>)[id]
if (typeof stored !== 'object' || stored === null) continue
const entry = stored as { open?: unknown; cap?: unknown }
sections[id] = {
open: entry.open !== false,
cap: clampCap(entry.cap),
}
sections[id] = isCappedSection(id) ? { open: entry.open !== false, cap: clampCap(entry.cap) } : { open: entry.open !== false }
}
return sections
}
@@ -101,6 +105,7 @@ function migrateLegacySectionKeys(userId: number): boolean {
let migrated = false
for (const id of SIDEBAR_SECTION_IDS) {
const legacyKey = LEGACY_SECTION_KEYS[id]
if (!legacyKey) continue
const stored = storage.get<unknown>(legacyKey, undefined)
if (stored === undefined) continue
sections[id].open = stored !== false
@@ -160,7 +165,7 @@ export function useSidebarPrefs() {
sections[id].open = !sections[id].open
}
function setSectionCap(id: SidebarSectionId, cap: SidebarCap): void {
function setSectionCap(id: SidebarCappedSectionId, cap: SidebarCap): void {
sections[id].cap = clampCap(cap)
}