mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(stats): make history years accessible when overflowing
Keep the all-time filter visible and expose keyboard-accessible controls for navigating long year ranges. Closes #1959
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
import { nextTick } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import HistoryPeriodPicker from '../components/HistoryPeriodPicker.vue'
|
||||
|
||||
const years = [
|
||||
{ year: 2026, reads: 12 },
|
||||
{ year: 2025, reads: 9 },
|
||||
{ year: 1998, reads: 3 },
|
||||
]
|
||||
|
||||
function mountPicker(year: number | null = 2026) {
|
||||
return mount(HistoryPeriodPicker, {
|
||||
props: { years, year, quarter: null, currentYear: 2026 },
|
||||
})
|
||||
}
|
||||
|
||||
describe('HistoryPeriodPicker', () => {
|
||||
it('keeps All time outside the scrolling years and emits it as a period', async () => {
|
||||
const wrapper = mountPicker()
|
||||
const scroller = wrapper.get('[data-year-scroller]')
|
||||
|
||||
expect(scroller.text()).not.toContain('All time')
|
||||
const allTime = wrapper.findAll('button').find((button) => button.text() === 'All time')!
|
||||
await allTime.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('year')).toContainEqual([null])
|
||||
})
|
||||
|
||||
it('offers mouse and keyboard controls when older years overflow', async () => {
|
||||
const wrapper = mountPicker()
|
||||
const scroller = wrapper.get('[data-year-scroller]').element as HTMLElement
|
||||
Object.defineProperties(scroller, {
|
||||
clientWidth: { configurable: true, value: 240 },
|
||||
scrollWidth: { configurable: true, value: 600 },
|
||||
})
|
||||
const scrollBy = vi.fn<(options?: ScrollToOptions) => void>()
|
||||
Object.defineProperty(scroller, 'scrollBy', { configurable: true, value: scrollBy })
|
||||
scroller.dispatchEvent(new Event('scroll'))
|
||||
await nextTick()
|
||||
|
||||
await wrapper.get('button[aria-label="Show older years"]').trigger('click')
|
||||
|
||||
expect(scrollBy).toHaveBeenCalledWith({ left: 180, behavior: 'auto' })
|
||||
})
|
||||
|
||||
it('reveals a selected year that is outside the visible range', async () => {
|
||||
const wrapper = mountPicker()
|
||||
const scroller = wrapper.get('[data-year-scroller]').element as HTMLElement
|
||||
Object.defineProperties(scroller, {
|
||||
clientWidth: { configurable: true, value: 240 },
|
||||
scrollWidth: { configurable: true, value: 600 },
|
||||
})
|
||||
scroller.getBoundingClientRect = () => ({ left: 0, right: 240 }) as DOMRect
|
||||
const oldYear = wrapper.findAll('[data-year-scroller] button').find((button) => button.text().includes('1998'))!.element as HTMLElement
|
||||
oldYear.getBoundingClientRect = () => ({ left: 480, right: 560 }) as DOMRect
|
||||
|
||||
await wrapper.setProps({ year: 1998 })
|
||||
await nextTick()
|
||||
|
||||
expect(scroller.scrollLeft).toBe(480)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ChevronLeft, ChevronRight } from '@lucide/vue'
|
||||
import { useScrollOverflow } from '@/composables/useScrollOverflow'
|
||||
import { formatNumber } from '@/i18n/formatters'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -13,6 +15,8 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{ year: [year: number | null]; quarter: [quarter: number] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const yearScroller = ref<HTMLElement | null>(null)
|
||||
const { hasStartOverflow, hasEndOverflow, measure: measureYearOverflow } = useScrollOverflow(yearScroller)
|
||||
|
||||
/** Every year with a finish, plus this year and whichever year is open, newest first. */
|
||||
const yearOptions = computed(() => {
|
||||
@@ -24,6 +28,8 @@ const yearOptions = computed(() => {
|
||||
const SEGMENT = 'flex items-center gap-1 rounded-lg bg-muted p-1'
|
||||
const BUTTON =
|
||||
'inline-flex shrink-0 items-center gap-1.5 rounded-md px-3.5 py-1.5 text-sm font-medium whitespace-nowrap transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring max-sm:flex-1 max-sm:justify-center max-sm:px-2'
|
||||
const SCROLL_BUTTON =
|
||||
'grid size-7 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-background hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
||||
|
||||
function buttonClass(active: boolean) {
|
||||
return [BUTTON, active ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground']
|
||||
@@ -37,26 +43,77 @@ function handleAllTime() {
|
||||
emit('year', null)
|
||||
}
|
||||
|
||||
function scrollYears(direction: -1 | 1) {
|
||||
const element = yearScroller.value
|
||||
if (!element) return
|
||||
const inlineDirection = getComputedStyle(element).direction === 'rtl' ? -direction : direction
|
||||
element.scrollBy({ left: inlineDirection * Math.max(160, element.clientWidth * 0.75), behavior: 'auto' })
|
||||
}
|
||||
|
||||
function handleNewerYears() {
|
||||
scrollYears(-1)
|
||||
}
|
||||
|
||||
function handleOlderYears() {
|
||||
scrollYears(1)
|
||||
}
|
||||
|
||||
function handleQuarter(quarter: number) {
|
||||
emit('quarter', quarter)
|
||||
}
|
||||
|
||||
async function revealSelectedYear() {
|
||||
await nextTick()
|
||||
const element = yearScroller.value
|
||||
const selected = element?.querySelector<HTMLElement>('[aria-pressed="true"]')
|
||||
if (element && selected && element.scrollWidth > element.clientWidth) {
|
||||
const containerRect = element.getBoundingClientRect()
|
||||
const selectedRect = selected.getBoundingClientRect()
|
||||
if (selectedRect.left < containerRect.left || selectedRect.right > containerRect.right) {
|
||||
element.scrollLeft += selectedRect.left - containerRect.left
|
||||
}
|
||||
}
|
||||
measureYearOverflow()
|
||||
}
|
||||
|
||||
watch([() => props.year, yearOptions], revealSelectedYear, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2 max-sm:w-full max-sm:flex-col max-sm:items-stretch">
|
||||
<div :class="[SEGMENT, 'max-w-full overflow-x-auto [scrollbar-width:none]']" role="group" :aria-label="t('readingHistory.period.year')">
|
||||
<div :class="[SEGMENT, 'min-w-0 max-w-full overflow-hidden']" role="group" :aria-label="t('readingHistory.period.year')">
|
||||
<button
|
||||
v-for="option in yearOptions"
|
||||
:key="option.year"
|
||||
v-if="hasStartOverflow"
|
||||
type="button"
|
||||
:class="buttonClass(year === option.year)"
|
||||
:aria-pressed="year === option.year"
|
||||
@click="handleYear(option.year)"
|
||||
:class="SCROLL_BUTTON"
|
||||
:aria-label="t('readingHistory.period.newerYears')"
|
||||
@click="handleNewerYears"
|
||||
>
|
||||
{{ option.year }}
|
||||
<span class="text-[11px] font-semibold tabular-nums" :class="year === option.year ? 'text-primary' : 'text-muted-foreground'">{{
|
||||
formatNumber(option.reads)
|
||||
}}</span>
|
||||
<ChevronLeft class="size-4 rtl:rotate-180" aria-hidden="true" />
|
||||
</button>
|
||||
<div ref="yearScroller" class="no-scrollbar flex min-w-0 items-center gap-1 overflow-x-auto" data-year-scroller>
|
||||
<button
|
||||
v-for="option in yearOptions"
|
||||
:key="option.year"
|
||||
type="button"
|
||||
:class="buttonClass(year === option.year)"
|
||||
:aria-pressed="year === option.year"
|
||||
@click="handleYear(option.year)"
|
||||
>
|
||||
{{ option.year }}
|
||||
<span class="text-[11px] font-semibold tabular-nums" :class="year === option.year ? 'text-primary' : 'text-muted-foreground'">{{
|
||||
formatNumber(option.reads)
|
||||
}}</span>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="hasEndOverflow"
|
||||
type="button"
|
||||
:class="SCROLL_BUTTON"
|
||||
:aria-label="t('readingHistory.period.olderYears')"
|
||||
@click="handleOlderYears"
|
||||
>
|
||||
<ChevronRight class="size-4 rtl:rotate-180" aria-hidden="true" />
|
||||
</button>
|
||||
<button type="button" :class="buttonClass(year === null)" :aria-pressed="year === null" @click="handleAllTime">
|
||||
{{ t('readingHistory.period.allTime') }}
|
||||
|
||||
@@ -13065,6 +13065,8 @@
|
||||
"period": {
|
||||
"year": "Year",
|
||||
"allTime": "All time",
|
||||
"newerYears": "Show newer years",
|
||||
"olderYears": "Show older years",
|
||||
"quarterGroup": "Quarter",
|
||||
"quarter": "Q{quarter}",
|
||||
"quarterYear": "Q{quarter} {year}"
|
||||
|
||||
Reference in New Issue
Block a user