mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user