fix(books): fully expand book synopsis

Keep expanded synopsis content in the existing page or column flow so touch users do not encounter an undiscoverable nested scroll area. Expose the toggle state to assistive technology.

Fixes #1202
This commit is contained in:
neon
2026-08-30 17:00:33 +00:00
parent abc543044f
commit 9391555859
2 changed files with 36 additions and 2 deletions
@@ -1825,7 +1825,6 @@ watch(
</div>
<section class="rounded-xl border border-border bg-card px-4 py-3.5">
<!-- Synopsis. Expanding scrolls inside this block, never the column. -->
<div>
<div class="flex items-baseline gap-3">
<p class="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
@@ -1833,7 +1832,10 @@ watch(
</p>
<button
v-if="book.description"
type="button"
class="ml-auto shrink-0 text-[11px] font-semibold text-primary transition-colors hover:underline"
:aria-controls="`book-${book.id}-synopsis`"
:aria-expanded="descriptionExpanded"
@click="toggleDescription"
>
{{ descriptionExpanded ? t('book.detail.details.showLess') : t('book.detail.details.showMore') }}
@@ -1841,8 +1843,9 @@ watch(
</div>
<div
v-if="book.description"
:id="`book-${book.id}-synopsis`"
class="mt-2 text-sm leading-relaxed text-foreground"
:class="descriptionExpanded ? 'max-h-44 overflow-y-auto pr-2' : 'line-clamp-4'"
:class="{ 'line-clamp-4': !descriptionExpanded }"
v-html="safeDescription"
/>
<p v-else class="mt-2 text-sm italic text-muted-foreground">{{ t('book.detail.details.noDescription') }}</p>
@@ -304,6 +304,37 @@ describe('DetailsTab cover surface', () => {
expect(wrapper.get('[data-test="hidden-genres"]').text()).toContain('Mystery')
})
it('fully expands the synopsis without creating a nested scroll area', async () => {
const wrapper = mountDetails(
makeBook({
description: '<p>First paragraph.</p><p>Second paragraph with enough content to continue beyond the collapsed preview.</p>',
}),
)
await flushPromises()
const synopsis = wrapper.get('.line-clamp-4')
expect(synopsis.html()).toContain('Second paragraph')
const toggle = wrapper.findAll('button').find((button) => button.text() === 'Show more')
expect(toggle).toBeDefined()
expect(toggle!.attributes('aria-controls')).toBe('book-12-synopsis')
expect(toggle!.attributes('aria-expanded')).toBe('false')
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('max-h-44')
expect(synopsis.classes()).not.toContain('overflow-y-auto')
expect(toggle!.text()).toBe('Show less')
expect(toggle!.attributes('aria-expanded')).toBe('true')
await toggle!.trigger('click')
expect(synopsis.classes()).toContain('line-clamp-4')
expect(toggle!.text()).toBe('Show more')
expect(toggle!.attributes('aria-expanded')).toBe('false')
})
it('summarizes pending Kobo sync state for each affected device', async () => {
mocks.api.mockImplementation(async (input) => {
const url = String(input)