fix(client): stabilize book detail layout across screen sizes

Keep cover actions and discovery content adjacent to their related sections, and scale the synopsis preview for taller viewports.

Fixes #1259
This commit is contained in:
neon
2026-09-06 15:39:31 +00:00
parent 836186159a
commit 028d4287b4
2 changed files with 255 additions and 167 deletions
@@ -278,23 +278,32 @@ watch(coverSrc, () => {
/**
* The cover frame follows the library's aspect ratio, so a 1/1 library makes it much shorter than
* a 2/3 one. The column height is fixed by the grid, so the width is derived from the height that
* is actually available: that keeps the frame's ratio exact instead of letting it stretch.
* a 2/3 one. The column height is fixed by the grid, so the width is derived from the space left
* after the actions: that keeps the group top-packed without overflowing short viewports.
*/
const coverSlotEl = ref<HTMLElement | null>(null)
const coverSlotHeight = ref(0)
let coverSlotObserver: ResizeObserver | null = null
const coverColumnEl = ref<HTMLElement | null>(null)
const coverActionsEl = ref<HTMLElement | null>(null)
const coverColumnHeight = ref(0)
const coverActionsHeight = ref(0)
let coverLayoutObserver: ResizeObserver | null = null
const COVER_GROUP_GAP_PX = 16
const coverMaxWidth = computed(() => {
if (coverSlotHeight.value <= 0) return undefined
const availableHeight = coverColumnHeight.value - coverActionsHeight.value - COVER_GROUP_GAP_PX
if (availableHeight <= 0) return undefined
const parts = detailCoverAspectRatio.value.split('/').map((part) => Number(part.trim()))
const width = parts[0]
const height = parts[1]
if (width === undefined || height === undefined) return undefined
if (!Number.isFinite(width) || !Number.isFinite(height) || height <= 0) return undefined
return `${Math.floor(coverSlotHeight.value * (width / height))}px`
return `${Math.floor(availableHeight * (width / height))}px`
})
function measureCoverLayout() {
coverColumnHeight.value = coverColumnEl.value?.clientHeight ?? 0
coverActionsHeight.value = coverActionsEl.value?.getBoundingClientRect().height ?? 0
}
function toggleDescription() {
descriptionExpanded.value = !descriptionExpanded.value
}
@@ -304,20 +313,22 @@ function handleMobileScoreOpen(open: boolean) {
}
onMounted(() => {
coverSlotObserver = new ResizeObserver((entries) => {
coverSlotHeight.value = entries[0]?.contentRect.height ?? 0
})
if (coverSlotEl.value) coverSlotObserver.observe(coverSlotEl.value)
coverLayoutObserver = new ResizeObserver(measureCoverLayout)
if (coverColumnEl.value) coverLayoutObserver.observe(coverColumnEl.value)
if (coverActionsEl.value) coverLayoutObserver.observe(coverActionsEl.value)
measureCoverLayout()
})
onBeforeUnmount(() => {
coverSlotObserver?.disconnect()
coverSlotObserver = null
coverLayoutObserver?.disconnect()
coverLayoutObserver = null
})
watch(coverSlotEl, (element) => {
coverSlotObserver?.disconnect()
if (element) coverSlotObserver?.observe(element)
watch([coverColumnEl, coverActionsEl], ([column, actions]) => {
coverLayoutObserver?.disconnect()
if (column) coverLayoutObserver?.observe(column)
if (actions) coverLayoutObserver?.observe(actions)
measureCoverLayout()
})
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
@@ -1185,22 +1196,21 @@ watch(
</div>
</div>
<!--
Direction A. Below 46rem of pane width the page is a single scrolling column. Above it
the pane owns the height: three columns fill row one, the discovery shelf takes row two,
and any column that runs long scrolls inside itself so the page itself never does.
-->
<!-- Below 46rem of pane width the page is a single column. Above it, the shelf follows the
natural height of the three-column content instead of being pinned to the viewport bottom. -->
<div
class="flex flex-col gap-5 @min-[46rem]/book-detail:grid @min-[46rem]/book-detail:h-full @min-[46rem]/book-detail:min-h-0 @min-[46rem]/book-detail:grid-cols-[clamp(12rem,23cqi,17rem)_minmax(16rem,1fr)_clamp(15rem,26cqi,19.25rem)] @min-[46rem]/book-detail:grid-rows-[minmax(0,1fr)_clamp(11.25rem,29%,17.5rem)] @min-[46rem]/book-detail:gap-x-6 @min-[46rem]/book-detail:gap-y-5"
data-test="details-layout"
class="flex flex-col gap-5 @min-[46rem]/book-detail:grid @min-[46rem]/book-detail:content-start @min-[46rem]/book-detail:grid-cols-[clamp(12rem,23cqi,17rem)_minmax(16rem,1fr)_clamp(15rem,26cqi,19.25rem)] @min-[46rem]/book-detail:gap-x-6 @min-[46rem]/book-detail:gap-y-5"
>
<!-- Cover column -->
<div
class="flex min-w-0 flex-col gap-3 @min-[46rem]/book-detail:col-start-1 @min-[46rem]/book-detail:row-start-1 @min-[46rem]/book-detail:min-h-0"
ref="coverColumnEl"
data-test="cover-column"
class="flex min-w-0 flex-col gap-4 @min-[46rem]/book-detail:col-start-1 @min-[46rem]/book-detail:row-start-1 @min-[46rem]/book-detail:min-h-0"
>
<div class="flex items-start gap-4 sm:gap-5 @min-[46rem]/book-detail:block @min-[46rem]/book-detail:min-h-0 @min-[46rem]/book-detail:flex-1">
<div class="flex items-start gap-4 sm:gap-5 @min-[46rem]/book-detail:block @min-[46rem]/book-detail:min-h-0">
<div
ref="coverSlotEl"
class="w-28 shrink-0 sm:w-36 @min-[46rem]/book-detail:flex @min-[46rem]/book-detail:h-full @min-[46rem]/book-detail:w-full @min-[46rem]/book-detail:items-center @min-[46rem]/book-detail:justify-center"
class="w-28 shrink-0 sm:w-36 @min-[46rem]/book-detail:flex @min-[46rem]/book-detail:w-full @min-[46rem]/book-detail:items-start @min-[46rem]/book-detail:justify-end"
:class="hasCover && coverLoaded && !coverFailed ? 'cursor-zoom-in' : ''"
>
<div
@@ -1307,163 +1317,165 @@ watch(
</div>
</div>
<div class="mt-4 space-y-2">
<div class="flex gap-2">
<!-- Read/Play button: split when multiple files, plain when single -->
<div v-if="hasMultipleFiles" class="flex flex-1 h-9 rounded-md overflow-hidden">
<div ref="coverActionsEl" data-test="cover-actions">
<div class="space-y-2">
<div class="flex gap-2">
<!-- Read/Play button: split when multiple files, plain when single -->
<div v-if="hasMultipleFiles" class="flex flex-1 h-9 rounded-md overflow-hidden">
<button
class="flex flex-1 items-center justify-center gap-2 bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
:disabled="!primaryFile"
@click="openBook"
>
<BookOpen v-if="isPrimaryAudio" class="size-4" />
<BookOpen v-else class="size-4" />
{{ isPrimaryAudio ? t('book.detail.details.listen') : t('book.detail.details.read') }}
</button>
<div class="w-px bg-primary-foreground/20 shrink-0" />
<Popover :open="readMenuOpen" @update:open="(v) => (readMenuOpen = v)">
<PopoverTrigger as-child>
<button
class="w-8 shrink-0 flex items-center justify-center bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
:title="t('book.detail.details.chooseFormat')"
>
<ChevronDown class="size-3.5" />
</button>
</PopoverTrigger>
<PopoverContent class="w-52 p-1" align="end">
<button
v-for="file in openableFiles"
:key="file.id"
class="flex w-full items-center gap-2.5 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors"
@click="openBookFile(file)"
>
<span
class="text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded border shrink-0"
:style="formatBadgeStyle(file.format ?? '?')"
>{{ file.format ?? '?' }}</span
>
<span class="flex-1 text-left text-muted-foreground text-xs truncate">
<template v-if="isMultiTrackAudio && FORMAT_TO_GROUP[file.format!] === 'audio'">{{
t('book.detail.details.audiobook')
}}</template>
<template v-else>{{ formatFileSize(file.sizeBytes) }}</template>
</span>
<span v-if="file.role === 'primary' && !isMultiTrackAudio" class="text-[10px] text-primary font-medium shrink-0">{{
t('book.detail.details.primary')
}}</span>
</button>
</PopoverContent>
</Popover>
</div>
<button
class="flex flex-1 items-center justify-center gap-2 bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
v-else
class="flex flex-1 items-center justify-center gap-2 h-9 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
:disabled="!primaryFile"
@click="openBook"
>
<BookOpen v-if="isPrimaryAudio" class="size-4" />
<Headphones v-if="isPrimaryAudio" class="size-4" />
<BookOpen v-else class="size-4" />
{{ isPrimaryAudio ? t('book.detail.details.listen') : t('book.detail.details.read') }}
</button>
<div class="w-px bg-primary-foreground/20 shrink-0" />
<Popover :open="readMenuOpen" @update:open="(v) => (readMenuOpen = v)">
<Tooltip>
<TooltipTrigger as-child>
<button
class="flex items-center justify-center h-9 w-12 shrink-0 rounded-md border border-input bg-background hover:bg-muted transition-colors disabled:opacity-50"
:disabled="!primaryFile"
:aria-label="t('book.detail.details.peek')"
@click="peekBook"
>
<Eye class="size-4" />
</button>
</TooltipTrigger>
<TooltipContent>{{ t('book.detail.details.peek') }}</TooltipContent>
</Tooltip>
</div>
<div class="flex gap-2">
<div v-if="hasPermission('library_download')" class="flex-1">
<BookDownloadButton :files="book.files" :book-id="book.id" />
</div>
<button
class="flex flex-1 items-center justify-center h-9 rounded-md border border-input bg-background text-sm hover:bg-muted transition-colors"
@click="addToCollectionOpen = true"
>
<Library class="size-3.5" />
</button>
<button
v-if="hasPermission('email_send')"
class="flex flex-1 items-center justify-center h-9 rounded-md border border-input bg-background text-sm hover:bg-muted transition-colors"
:aria-label="t('book.detail.details.sendViaEmail')"
@click="handleSendFromMenu"
>
<Send class="size-3.5" />
</button>
<Popover v-if="canEditMetadata || hasPermission('library_delete_books')" :open="moreMenuOpen" @update:open="(v) => (moreMenuOpen = v)">
<PopoverTrigger as-child>
<button
class="w-8 shrink-0 flex items-center justify-center bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
:title="t('book.detail.details.chooseFormat')"
class="flex flex-1 items-center justify-center h-9 rounded-md border border-border bg-background text-foreground hover:bg-muted transition-colors"
>
<ChevronDown class="size-3.5" />
<MoreVertical class="size-3.5" />
</button>
</PopoverTrigger>
<PopoverContent class="w-52 p-1" align="end">
<PopoverContent class="w-44 p-1" align="end">
<button
v-for="file in openableFiles"
:key="file.id"
class="flex w-full items-center gap-2.5 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors"
@click="openBookFile(file)"
v-if="canEditMetadata"
class="flex w-full items-center gap-2 px-2 py-1.5 rounded text-sm text-foreground hover:bg-muted transition-colors"
@click="handleOpenResetReadingState"
>
<span
class="text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded border shrink-0"
:style="formatBadgeStyle(file.format ?? '?')"
>{{ file.format ?? '?' }}</span
>
<span class="flex-1 text-left text-muted-foreground text-xs truncate">
<template v-if="isMultiTrackAudio && FORMAT_TO_GROUP[file.format!] === 'audio'">{{
t('book.detail.details.audiobook')
}}</template>
<template v-else>{{ formatFileSize(file.sizeBytes) }}</template>
</span>
<span v-if="file.role === 'primary' && !isMultiTrackAudio" class="text-[10px] text-primary font-medium shrink-0">{{
t('book.detail.details.primary')
}}</span>
<RotateCcw class="size-3.5" />
Reset reading state
</button>
<button
v-if="hasPermission('library_edit_metadata')"
class="flex w-full items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors"
@click="handleMoveFromMenu"
>
<FolderInput class="size-3.5" />
{{ t('book.move.action') }}
</button>
<button
v-if="hasPermission('library_delete_books')"
class="flex w-full items-center gap-2 px-2 py-1.5 rounded text-sm text-destructive hover:bg-destructive/10 transition-colors"
@click="handleDeleteFromMenu"
>
<Trash2 class="size-3.5" />
Delete book
</button>
</PopoverContent>
</Popover>
</div>
<button
v-else
class="flex flex-1 items-center justify-center gap-2 h-9 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
:disabled="!primaryFile"
@click="openBook"
>
<Headphones v-if="isPrimaryAudio" class="size-4" />
<BookOpen v-else class="size-4" />
{{ isPrimaryAudio ? t('book.detail.details.listen') : t('book.detail.details.read') }}
</button>
<Tooltip>
<TooltipTrigger as-child>
<button
class="flex items-center justify-center h-9 w-12 shrink-0 rounded-md border border-input bg-background hover:bg-muted transition-colors disabled:opacity-50"
:disabled="!primaryFile"
:aria-label="t('book.detail.details.peek')"
@click="peekBook"
>
<Eye class="size-4" />
</button>
</TooltipTrigger>
<TooltipContent>{{ t('book.detail.details.peek') }}</TooltipContent>
</Tooltip>
</div>
<div class="flex gap-2">
<div v-if="hasPermission('library_download')" class="flex-1">
<BookDownloadButton :files="book.files" :book-id="book.id" />
<div v-for="held in resetHeldDevices" :key="held.deviceId" class="mt-2 flex items-start gap-1.5">
<TriangleAlert class="size-3 text-amber-500 shrink-0 mt-0.5" aria-hidden="true" />
<div class="min-w-0">
<p class="text-[11px] text-amber-500">
{{ t('book.detail.details.resetHoldNotice', { device: held.device, percent: formatPercent(held.percentage) }) }}
</p>
<button
class="mt-0.5 text-[11px] text-muted-foreground underline underline-offset-2 hover:text-foreground focus-visible:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isReleasingHold(held.deviceId)"
@click="handleReleaseResetHold(held.deviceId)"
>
{{ isReleasingHold(held.deviceId) ? t('book.detail.details.resetHoldReleasing') : t('book.detail.details.resetHoldRelease') }}
</button>
<p v-if="hasReleaseFailed(held.deviceId)" role="alert" class="text-[11px] text-destructive">
{{ t('book.detail.details.resetHoldReleaseFailed') }}
</p>
</div>
<button
class="flex flex-1 items-center justify-center h-9 rounded-md border border-input bg-background text-sm hover:bg-muted transition-colors"
@click="addToCollectionOpen = true"
>
<Library class="size-3.5" />
</button>
<button
v-if="hasPermission('email_send')"
class="flex flex-1 items-center justify-center h-9 rounded-md border border-input bg-background text-sm hover:bg-muted transition-colors"
:aria-label="t('book.detail.details.sendViaEmail')"
@click="handleSendFromMenu"
>
<Send class="size-3.5" />
</button>
<Popover v-if="canEditMetadata || hasPermission('library_delete_books')" :open="moreMenuOpen" @update:open="(v) => (moreMenuOpen = v)">
<PopoverTrigger as-child>
<button
class="flex flex-1 items-center justify-center h-9 rounded-md border border-border bg-background text-foreground hover:bg-muted transition-colors"
>
<MoreVertical class="size-3.5" />
</button>
</PopoverTrigger>
<PopoverContent class="w-44 p-1" align="end">
<button
v-if="canEditMetadata"
class="flex w-full items-center gap-2 px-2 py-1.5 rounded text-sm text-foreground hover:bg-muted transition-colors"
@click="handleOpenResetReadingState"
>
<RotateCcw class="size-3.5" />
Reset reading state
</button>
<button
v-if="hasPermission('library_edit_metadata')"
class="flex w-full items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors"
@click="handleMoveFromMenu"
>
<FolderInput class="size-3.5" />
{{ t('book.move.action') }}
</button>
<button
v-if="hasPermission('library_delete_books')"
class="flex w-full items-center gap-2 px-2 py-1.5 rounded text-sm text-destructive hover:bg-destructive/10 transition-colors"
@click="handleDeleteFromMenu"
>
<Trash2 class="size-3.5" />
Delete book
</button>
</PopoverContent>
</Popover>
</div>
<Tooltip v-if="koboAnomaly">
<TooltipTrigger as-child>
<div class="mt-2 flex items-center gap-1.5 cursor-help" tabindex="0">
<TriangleAlert class="size-3 text-amber-500 shrink-0" />
<p class="text-[11px] text-amber-500">{{ koboAnomaly.label }}</p>
</div>
</TooltipTrigger>
<TooltipContent>{{ koboAnomaly.tooltip }}</TooltipContent>
</Tooltip>
</div>
<div v-for="held in resetHeldDevices" :key="held.deviceId" class="mt-2 flex items-start gap-1.5">
<TriangleAlert class="size-3 text-amber-500 shrink-0 mt-0.5" aria-hidden="true" />
<div class="min-w-0">
<p class="text-[11px] text-amber-500">
{{ t('book.detail.details.resetHoldNotice', { device: held.device, percent: formatPercent(held.percentage) }) }}
</p>
<button
class="mt-0.5 text-[11px] text-muted-foreground underline underline-offset-2 hover:text-foreground focus-visible:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isReleasingHold(held.deviceId)"
@click="handleReleaseResetHold(held.deviceId)"
>
{{ isReleasingHold(held.deviceId) ? t('book.detail.details.resetHoldReleasing') : t('book.detail.details.resetHoldRelease') }}
</button>
<p v-if="hasReleaseFailed(held.deviceId)" role="alert" class="text-[11px] text-destructive">
{{ t('book.detail.details.resetHoldReleaseFailed') }}
</p>
</div>
</div>
<Tooltip v-if="koboAnomaly">
<TooltipTrigger as-child>
<div class="mt-2 flex items-center gap-1.5 cursor-help" tabindex="0">
<TriangleAlert class="size-3 text-amber-500 shrink-0" />
<p class="text-[11px] text-amber-500">{{ koboAnomaly.label }}</p>
</div>
</TooltipTrigger>
<TooltipContent>{{ koboAnomaly.tooltip }}</TooltipContent>
</Tooltip>
</div>
<!-- Main column -->
@@ -1847,8 +1859,9 @@ watch(
<div
v-if="book.description"
:id="`book-${book.id}-synopsis`"
data-test="synopsis-copy"
class="mt-2 text-sm leading-relaxed text-foreground"
:class="{ 'line-clamp-4': !descriptionExpanded }"
:class="{ 'synopsis-copy--clamped': !descriptionExpanded }"
v-html="safeDescription"
/>
<p v-else class="mt-2 text-sm italic text-muted-foreground">{{ t('book.detail.details.noDescription') }}</p>
@@ -2123,7 +2136,7 @@ watch(
</div>
<!-- Discovery shelf -->
<div class="min-w-0 @min-[46rem]/book-detail:col-span-3 @min-[46rem]/book-detail:row-start-2 @min-[46rem]/book-detail:min-h-0">
<div data-test="discovery-shelf" class="min-w-0 @min-[46rem]/book-detail:col-span-3 @min-[46rem]/book-detail:row-start-2">
<DiscoverRow class="h-full" :book-id="book.id" :series-name="book.seriesName" :author-count="book.authors.length" size="lg" flush />
</div>
</div>
@@ -2182,3 +2195,24 @@ watch(
</DialogPortal>
</DialogRoot>
</template>
<style scoped>
.synopsis-copy--clamped {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
}
@media (min-height: 56rem) {
.synopsis-copy--clamped {
-webkit-line-clamp: 6;
}
}
@media (min-height: 80rem) {
.synopsis-copy--clamped {
-webkit-line-clamp: 10;
}
}
</style>
@@ -230,14 +230,19 @@ describe('DetailsTab cover surface', () => {
expect(surfaces.every((surface) => surface.attributes('style')?.includes('aspect-ratio: 2 / 1'))).toBe(true)
})
it('only constrains cover width from the observed slot height in the wide layout', async () => {
it('constrains cover width to the column height remaining above its actions', async () => {
const wrapper = mountDetails(makeBook())
await flushPromises()
const surface = wrapper.get('.book-cover-surface')
const frame = surface.element.parentElement as HTMLElement
const column = wrapper.get<HTMLElement>('[data-test="cover-column"]')
const actions = wrapper.get<HTMLElement>('[data-test="cover-actions"]')
resizeObserverCallbacks.at(-1)?.([{ contentRect: { height: 220 } } as ResizeObserverEntry], {} as ResizeObserver)
Object.defineProperty(column.element, 'clientHeight', { configurable: true, value: 320 })
vi.spyOn(actions.element, 'getBoundingClientRect').mockReturnValue({ height: 84 } as DOMRect)
resizeObserverCallbacks.at(-1)?.([], {} as ResizeObserver)
await flushPromises()
expect(frame.style.maxWidth).toBe('')
@@ -245,6 +250,55 @@ describe('DetailsTab cover surface', () => {
expect(frame.classList).toContain('@min-[46rem]/book-detail:max-w-[var(--detail-cover-max-width)]')
})
it('anchors wide-layout covers beside the top of the metadata column', async () => {
const wrapper = mountDetails(makeBook())
await flushPromises()
const surface = wrapper.get('.book-cover-surface')
const slot = surface.element.parentElement?.parentElement as HTMLElement
expect(slot.classList).toContain('@min-[46rem]/book-detail:items-start')
expect(slot.classList).toContain('@min-[46rem]/book-detail:justify-end')
expect(slot.classList).not.toContain('@min-[46rem]/book-detail:items-center')
})
it('keeps wide-layout actions immediately after the cover instead of at the bottom of the column', async () => {
const wrapper = mountDetails(makeBook())
await flushPromises()
const column = wrapper.get<HTMLElement>('[data-test="cover-column"]')
const actions = wrapper.get<HTMLElement>('[data-test="cover-actions"]')
const coverRow = column.element.firstElementChild as HTMLElement
expect(actions.element.parentElement).toBe(column.element)
expect(coverRow.nextElementSibling).toBe(actions.element)
expect(coverRow.classList).not.toContain('@min-[46rem]/book-detail:flex-1')
})
it('lets the discovery shelf follow the natural height of the detail content', async () => {
const wrapper = mountDetails(makeBook())
await flushPromises()
const layout = wrapper.get<HTMLElement>('[data-test="details-layout"]')
const shelf = wrapper.get<HTMLElement>('[data-test="discovery-shelf"]')
expect(shelf.element.parentElement).toBe(layout.element)
expect(layout.element.classList).toContain('@min-[46rem]/book-detail:content-start')
expect(layout.element.classList).not.toContain('@min-[46rem]/book-detail:h-full')
expect(layout.element.classList).not.toContain('@min-[46rem]/book-detail:grid-rows-[minmax(0,1fr)_clamp(11.25rem,29%,17.5rem)]')
})
it('uses the responsive synopsis clamp until the full description is opened', async () => {
const wrapper = mountDetails(makeBook({ description: '<p>A synopsis</p>' }))
await flushPromises()
const synopsis = wrapper.get<HTMLElement>('[data-test="synopsis-copy"]')
expect(synopsis.element.classList).toContain('synopsis-copy--clamped')
await wrapper.get(`[aria-controls="book-${wrapper.props('book').id}-synopsis"]`).trigger('click')
expect(synopsis.element.classList).not.toContain('synopsis-copy--clamped')
})
it('forces spine overlay off for audiobook details covers', async () => {
bookSpineOverlay.value = 'strong'
@@ -335,7 +389,7 @@ describe('DetailsTab cover surface', () => {
)
await flushPromises()
const synopsis = wrapper.get('.line-clamp-4')
const synopsis = wrapper.get('[data-test="synopsis-copy"]')
expect(synopsis.html()).toContain('Second paragraph')
const toggle = wrapper.findAll('button').find((button) => button.text() === 'Show more')
@@ -345,7 +399,7 @@ describe('DetailsTab cover surface', () => {
expect(synopsis.attributes('id')).toBe('book-12-synopsis')
await toggle!.trigger('click')
expect(synopsis.classes()).not.toContain('line-clamp-4')
expect(synopsis.classes()).not.toContain('synopsis-copy--clamped')
expect(synopsis.classes()).not.toContain('max-h-44')
expect(synopsis.classes()).not.toContain('overflow-y-auto')
expect(toggle!.text()).toBe('Show less')
@@ -353,7 +407,7 @@ describe('DetailsTab cover surface', () => {
await toggle!.trigger('click')
expect(synopsis.classes()).toContain('line-clamp-4')
expect(synopsis.classes()).toContain('synopsis-copy--clamped')
expect(toggle!.text()).toBe('Show more')
expect(toggle!.attributes('aria-expanded')).toBe('false')
})