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">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { ChevronLeft, ChevronRight } from '@lucide/vue'
|
||||||
|
import { useScrollOverflow } from '@/composables/useScrollOverflow'
|
||||||
import { formatNumber } from '@/i18n/formatters'
|
import { formatNumber } from '@/i18n/formatters'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -13,6 +15,8 @@ const props = defineProps<{
|
|||||||
const emit = defineEmits<{ year: [year: number | null]; quarter: [quarter: number] }>()
|
const emit = defineEmits<{ year: [year: number | null]; quarter: [quarter: number] }>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
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. */
|
/** Every year with a finish, plus this year and whichever year is open, newest first. */
|
||||||
const yearOptions = computed(() => {
|
const yearOptions = computed(() => {
|
||||||
@@ -24,6 +28,8 @@ const yearOptions = computed(() => {
|
|||||||
const SEGMENT = 'flex items-center gap-1 rounded-lg bg-muted p-1'
|
const SEGMENT = 'flex items-center gap-1 rounded-lg bg-muted p-1'
|
||||||
const BUTTON =
|
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'
|
'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) {
|
function buttonClass(active: boolean) {
|
||||||
return [BUTTON, active ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground']
|
return [BUTTON, active ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground']
|
||||||
@@ -37,14 +43,55 @@ function handleAllTime() {
|
|||||||
emit('year', null)
|
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) {
|
function handleQuarter(quarter: number) {
|
||||||
emit('quarter', quarter)
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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="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
|
<button
|
||||||
v-for="option in yearOptions"
|
v-for="option in yearOptions"
|
||||||
:key="option.year"
|
:key="option.year"
|
||||||
@@ -58,6 +105,16 @@ function handleQuarter(quarter: number) {
|
|||||||
formatNumber(option.reads)
|
formatNumber(option.reads)
|
||||||
}}</span>
|
}}</span>
|
||||||
</button>
|
</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">
|
<button type="button" :class="buttonClass(year === null)" :aria-pressed="year === null" @click="handleAllTime">
|
||||||
{{ t('readingHistory.period.allTime') }}
|
{{ t('readingHistory.period.allTime') }}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -13065,6 +13065,8 @@
|
|||||||
"period": {
|
"period": {
|
||||||
"year": "Year",
|
"year": "Year",
|
||||||
"allTime": "All time",
|
"allTime": "All time",
|
||||||
|
"newerYears": "Show newer years",
|
||||||
|
"olderYears": "Show older years",
|
||||||
"quarterGroup": "Quarter",
|
"quarterGroup": "Quarter",
|
||||||
"quarter": "Q{quarter}",
|
"quarter": "Q{quarter}",
|
||||||
"quarterYear": "Q{quarter} {year}"
|
"quarterYear": "Q{quarter} {year}"
|
||||||
|
|||||||
Reference in New Issue
Block a user