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:
neon
2026-10-28 14:27:24 +00:00
parent a9e63a60e1
commit 62e8690b30
3 changed files with 133 additions and 11 deletions
@@ -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,14 +43,55 @@ 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-if="hasStartOverflow"
type="button"
:class="SCROLL_BUTTON"
:aria-label="t('readingHistory.period.newerYears')"
@click="handleNewerYears"
>
<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"
@@ -58,6 +105,16 @@ function handleQuarter(quarter: number) {
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') }}
</button>
+2
View File
@@ -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}"