fix(books): stop the Files tab header overlapping its stats

Stack the hero bar when one row would leave the title under 12rem,
measured from the stats and actions rather than a fixed container
breakpoint, and let the folder crumb truncate once its ancestors are
spent instead of painting over the stats.
This commit is contained in:
neon
2026-10-22 00:42:23 +00:00
parent 8810d47658
commit 5ba5352e3e
2 changed files with 167 additions and 88 deletions
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, useId } from 'vue' import { computed, nextTick, ref, useId, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useResizeObserver } from '@vueuse/core'
import { ArrowDown, ArrowUp, ArrowUpDown, AudioLines, CircleCheck, FilePlus, Loader2, TriangleAlert } from '@lucide/vue' import { ArrowDown, ArrowUp, ArrowUpDown, AudioLines, CircleCheck, FilePlus, Loader2, TriangleAlert } from '@lucide/vue'
import type { BookDetail } from '@bookorbit/types' import type { BookDetail } from '@bookorbit/types'
import { formatBytes } from '@/lib/formatting' import { formatBytes } from '@/lib/formatting'
@@ -35,10 +36,55 @@ const props = defineProps<{
const emit = defineEmits<{ sort: [key: SortKey]; addFile: []; generateReadAloud: []; retryReadAloud: [] }>() const emit = defineEmits<{ sort: [key: SortKey]; addFile: []; generateReadAloud: []; retryReadAloud: [] }>()
const { t } = useI18n() const { t, locale } = useI18n()
const { coverUrl } = useCoverVersions() const { coverUrl } = useCoverVersions()
const readAloudNoteId = useId() const readAloudNoteId = useId()
/** The narrowest the title and folder may get before the strip stacks; any less reads as damage. */
const ID_MIN_REM = 12
/** Slack before stacking undoes itself, so a scrollbar coming and going cannot flip it back and forth. */
const UNSTACK_SLACK_PX = 12
const heroRef = ref<HTMLElement | null>(null)
const statsRef = ref<HTMLElement | null>(null)
const actionsRef = ref<HTMLElement | null>(null)
const stacked = ref(false)
function childrenWidth(element: HTMLElement): number {
const children = [...element.children]
const gap = parseFloat(getComputedStyle(element).columnGap) || 0
return children.reduce((total, child) => total + child.getBoundingClientRect().width, 0) + Math.max(0, children.length - 1) * gap
}
/**
* Stack once one row would leave the title less than its minimum. A container breakpoint guessed
* this and guessed wrong: the stat values, the read-aloud entry and every translation change how
* much room the row needs, and a squeezed title painted its folder over the stats. The children's
* own widths do not depend on the arrangement, so one sum decides in both directions.
*/
function measureStacking(): void {
const hero = heroRef.value
const stats = statsRef.value
const actions = actionsRef.value
if (!hero || !stats || !actions) return
const style = getComputedStyle(hero)
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16
const available = hero.clientWidth - (parseFloat(style.paddingLeft) || 0) - (parseFloat(style.paddingRight) || 0)
const cover = hero.querySelector('.hero-cover')?.getBoundingClientRect().width ?? 0
// The hairline and inset that divide the actions from the stats in a single row.
const divider = 1 + rem
const needed = cover + childrenWidth(stats) + divider + childrenWidth(actions) + ID_MIN_REM * rem + 3 * (parseFloat(style.columnGap) || 0)
stacked.value = stacked.value ? needed > available - UNSTACK_SLACK_PX : needed > available
}
useResizeObserver([heroRef, statsRef, actionsRef], measureStacking)
// A stacked stat row spans the strip, so its box does not resize when a value or label changes.
watch(
() => [props.fileCount, props.totalBytes, props.runtimeLabel, props.formatShares.length, props.showReadAloud, props.canUpload, locale.value],
() => nextTick(measureStacking),
)
const hasCover = computed(() => props.book.coverSource !== null) const hasCover = computed(() => props.book.coverSource !== null)
const coverSrc = computed(() => coverUrl(props.book.id, 'thumbnail', props.book.coverVersion)) const coverSrc = computed(() => coverUrl(props.book.id, 'thumbnail', props.book.coverVersion))
const isMissing = computed(() => props.book.status === 'missing') const isMissing = computed(() => props.book.status === 'missing')
@@ -75,7 +121,7 @@ function handleRetryReadAloud() {
<template> <template>
<section class="flex shrink-0 flex-col rounded-xl border border-border bg-card"> <section class="flex shrink-0 flex-col rounded-xl border border-border bg-card">
<div class="files-hero flex items-center gap-3.5 px-3.5 py-3" :data-wide-actions="showReadAloud ? '1' : undefined"> <div ref="heroRef" class="files-hero flex items-center gap-3.5 px-3.5 py-3" :data-stacked="stacked ? '1' : undefined">
<img <img
v-if="hasCover" v-if="hasCover"
:src="coverSrc" :src="coverSrc"
@@ -100,7 +146,7 @@ function handleRetryReadAloud() {
</nav> </nav>
</div> </div>
<dl class="hero-stats flex shrink-0 items-center"> <dl ref="statsRef" class="hero-stats flex shrink-0 items-center">
<div class="hero-stat px-4 text-right"> <div class="hero-stat px-4 text-right">
<dt class="text-[10.5px] font-semibold uppercase tracking-wide text-muted-foreground">{{ t('book.detail.files.stats.files') }}</dt> <dt class="text-[10.5px] font-semibold uppercase tracking-wide text-muted-foreground">{{ t('book.detail.files.stats.files') }}</dt>
<dd class="mt-1.5 text-base font-semibold leading-none tracking-tight tabular-nums">{{ fileCount }}</dd> <dd class="mt-1.5 text-base font-semibold leading-none tracking-tight tabular-nums">{{ fileCount }}</dd>
@@ -129,7 +175,7 @@ function handleRetryReadAloud() {
</div> </div>
</dl> </dl>
<div class="hero-actions flex shrink-0 items-center gap-2 border-l border-border pl-4"> <div ref="actionsRef" class="hero-actions flex shrink-0 items-center gap-2 border-l border-border pl-4">
<span <span
class="inline-flex h-[18px] items-center gap-1 rounded-xs px-1.5 text-[9.5px] font-bold uppercase tracking-wider" class="inline-flex h-[18px] items-center gap-1 rounded-xs px-1.5 text-[9.5px] font-bold uppercase tracking-wider"
:class="isMissing ? 'bg-destructive/15 text-destructive' : 'bg-success/15 text-success'" :class="isMissing ? 'bg-destructive/15 text-destructive' : 'bg-success/15 text-success'"
@@ -208,102 +254,60 @@ function handleRetryReadAloud() {
</template> </template>
<style scoped> <style scoped>
/* The crumb spends its truncation budget on the ancestors; the folder itself always reads whole. */ /*
* The crumb spends its truncation budget on the ancestors. They shrink a million times faster, so
* the folder's share rounds to nothing and it reads whole until they reach their minimum; only then
* does it truncate, rather than painting over the stats as it did when it could not shrink at all.
*/
.hero-crumb > .crumb-part { .hero-crumb > .crumb-part {
flex: 0 1 auto; flex: 0 1000000 auto;
} }
.hero-crumb > .crumb-part:last-child { .hero-crumb > .crumb-part:last-child {
flex: 0 0 auto; flex: 0 1 auto;
color: color-mix(in oklch, var(--foreground) 78%, var(--muted-foreground)); color: color-mix(in oklch, var(--foreground) 78%, var(--muted-foreground));
} }
/* /*
* The strip reflows on the tab's own width, not the window's: a collapsed sidebar makes a 1440 * Stacked: the actions stay beside the title and the stats take a row of their own. `:where()`
* window as roomy as a 1700 one, and the `md` sidebar makes an 834 tablet as tight as a phone. * keeps these at the plain selectors' specificity so the phone rules below still win.
*/ */
@container filestab (max-width: 45rem) { :where(.files-hero[data-stacked='1']) {
.files-hero {
flex-wrap: wrap; flex-wrap: wrap;
row-gap: 0.75rem; row-gap: 0.75rem;
}
.hero-id {
flex: 1 1 12rem;
}
/* Three truncated crumb segments read as damage; the folder the files live in survives whole. */
.hero-crumb .crumb-part:not(:last-child),
.hero-crumb span[aria-hidden='true'] {
display: none;
}
.hero-crumb::before {
content: '…/';
flex: none;
opacity: 0.5;
}
.hero-stats {
order: 3;
width: 100%;
justify-content: space-between;
}
.hero-stats .hero-stat:first-child {
padding-inline-start: 0;
}
.hero-actions {
order: 2;
margin-inline-start: auto;
border-inline-start: 0;
padding-inline-start: 0;
}
} }
/* :where(.files-hero[data-stacked='1']) .hero-id {
* The read-along entry adds a labelled button to the actions, so a hero that shows it stacks
* sooner. The same rules as above, scoped to that hero; `:where()` keeps their specificity at the
* plain selectors' so the phone rules below still win.
*/
@container filestab (max-width: 58rem) {
:where(.files-hero[data-wide-actions='1']) {
flex-wrap: wrap;
row-gap: 0.75rem;
}
:where(.files-hero[data-wide-actions='1']) .hero-id {
flex: 1 1 12rem; flex: 1 1 12rem;
} }
:where(.files-hero[data-wide-actions='1']) .hero-crumb .crumb-part:not(:last-child), /* Three truncated crumb segments read as damage; the folder the files live in survives whole. */
:where(.files-hero[data-wide-actions='1']) .hero-crumb span[aria-hidden='true'] { :where(.files-hero[data-stacked='1']) .hero-crumb .crumb-part:not(:last-child),
:where(.files-hero[data-stacked='1']) .hero-crumb span[aria-hidden='true'] {
display: none; display: none;
} }
:where(.files-hero[data-wide-actions='1']) .hero-crumb::before { :where(.files-hero[data-stacked='1']) .hero-crumb::before {
content: '…/'; content: '…/';
flex: none; flex: none;
opacity: 0.5; opacity: 0.5;
} }
:where(.files-hero[data-wide-actions='1']) .hero-stats { :where(.files-hero[data-stacked='1']) .hero-stats {
order: 3; order: 3;
width: 100%; width: 100%;
justify-content: space-between; justify-content: space-between;
} }
:where(.files-hero[data-wide-actions='1']) .hero-stats .hero-stat:first-child { :where(.files-hero[data-stacked='1']) .hero-stats .hero-stat:first-child {
padding-inline-start: 0; padding-inline-start: 0;
} }
:where(.files-hero[data-wide-actions='1']) .hero-actions { :where(.files-hero[data-stacked='1']) .hero-actions {
order: 2; order: 2;
margin-inline-start: auto; margin-inline-start: auto;
border-inline-start: 0; border-inline-start: 0;
padding-inline-start: 0; padding-inline-start: 0;
}
} }
@container filestab (max-width: 35rem) { @container filestab (max-width: 35rem) {
@@ -1,5 +1,6 @@
import { mount, type VueWrapper } from '@vue/test-utils' import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeAll, describe, expect, it } from 'vitest' import { nextTick } from 'vue'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import type { BookDetail } from '@bookorbit/types' import type { BookDetail } from '@bookorbit/types'
import { activateI18nLocale, i18n } from '@/i18n' import { activateI18nLocale, i18n } from '@/i18n'
@@ -19,7 +20,7 @@ afterEach(() => {
wrapper = null wrapper = null
}) })
function mountBar(props: { readAloudNote: string | null; readAloudRetryable?: boolean; readAloudChecking?: boolean }) { function mountBar(props: { readAloudNote?: string | null; readAloudRetryable?: boolean; readAloudChecking?: boolean } = {}) {
wrapper = mount(FilesHeroBar, { wrapper = mount(FilesHeroBar, {
attachTo: document.body, attachTo: document.body,
props: { props: {
@@ -35,6 +36,7 @@ function mountBar(props: { readAloudNote: string | null; readAloudRetryable?: bo
sortKey: 'name', sortKey: 'name',
sortDirection: 'asc', sortDirection: 'asc',
sortOptions: [], sortOptions: [],
readAloudNote: null,
...props, ...props,
}, },
}) })
@@ -94,3 +96,76 @@ describe('FilesHeroBar read-aloud note', () => {
expect(bar.find('[data-test="read-aloud-check-again"]').exists()).toBe(false) expect(bar.find('[data-test="read-aloud-check-again"]').exists()).toBe(false)
}) })
}) })
/** jsdom lays nothing out, so each part of the strip reports a fixed width and the hero a chosen one. */
const PART_WIDTHS: Record<string, number> = { 'hero-cover': 44, 'hero-stat': 100 }
const ACTION_WIDTH = 90
function stubLayout(heroWidth: { value: number }) {
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(function (this: HTMLElement) {
return this.classList.contains('files-hero') ? heroWidth.value : 0
})
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
const part = Object.keys(PART_WIDTHS).find((name) => this.classList.contains(name))
const width = part ? PART_WIDTHS[part]! : this.parentElement?.classList.contains('hero-actions') ? ACTION_WIDTH : 0
return { width, height: 0, top: 0, left: 0, right: width, bottom: 0, x: 0, y: 0, toJSON: () => ({}) } as DOMRect
})
}
class ImmediateResizeObserver {
constructor(private readonly callback: ResizeObserverCallback) {}
observe() {
this.callback([], this as unknown as ResizeObserver)
}
unobserve() {}
disconnect() {}
}
describe('FilesHeroBar stacking', () => {
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
// Cover 44, three stats at 100, the divider (1px + 1rem), three actions at 90, and a 12rem title.
const SINGLE_ROW_NEED = 44 + 300 + 17 + 270 + 192
it('stacks when one row would squeeze the title below its minimum', async () => {
vi.stubGlobal('ResizeObserver', ImmediateResizeObserver)
stubLayout({ value: SINGLE_ROW_NEED - 1 })
const bar = mountBar()
await nextTick()
expect(bar.get('.files-hero').attributes('data-stacked')).toBe('1')
})
it('keeps one row while the title has its minimum', async () => {
vi.stubGlobal('ResizeObserver', ImmediateResizeObserver)
stubLayout({ value: SINGLE_ROW_NEED })
const bar = mountBar()
await nextTick()
expect(bar.get('.files-hero').attributes('data-stacked')).toBeUndefined()
})
it('unstacks only once the row has room to spare, and re-measures when the content changes', async () => {
vi.stubGlobal('ResizeObserver', ImmediateResizeObserver)
const heroWidth = { value: SINGLE_ROW_NEED - 40 }
stubLayout(heroWidth)
const bar = mountBar()
await nextTick()
expect(bar.get('.files-hero').attributes('data-stacked')).toBe('1')
heroWidth.value = SINGLE_ROW_NEED + 4
await bar.setProps({ fileCount: 3 })
await flushPromises()
expect(bar.get('.files-hero').attributes('data-stacked')).toBe('1')
heroWidth.value = SINGLE_ROW_NEED + 40
await bar.setProps({ fileCount: 4 })
await flushPromises()
expect(bar.get('.files-hero').attributes('data-stacked')).toBeUndefined()
})
})