mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(client): add missing icon tooltips
Make icon-only controls discoverable to mouse and keyboard users with localized tooltips and accessible names. Fixes #588
This commit is contained in:
@@ -607,7 +607,13 @@ function formatBadgeStyle(fmt: string) {
|
||||
<!-- Mobile: search icon -->
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" :class="['md:hidden', controlClass]" @click="mobileSearchOpen = true">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:class="['md:hidden', controlClass]"
|
||||
:aria-label="t('common.search')"
|
||||
@click="mobileSearchOpen = true"
|
||||
>
|
||||
<Search :size="15" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
@@ -692,6 +698,7 @@ function formatBadgeStyle(fmt: string) {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:class="destinationClass(isStatisticsActive)"
|
||||
:aria-label="t('components.appHeader.statistics')"
|
||||
@click="navigateToStatistics"
|
||||
>
|
||||
<BarChart3 :size="15" />
|
||||
@@ -702,7 +709,13 @@ function formatBadgeStyle(fmt: string) {
|
||||
|
||||
<Tooltip v-if="achievementsEnabled">
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" :class="destinationClass(isAchievementsActive)" @click="navigateToAchievements">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:class="destinationClass(isAchievementsActive)"
|
||||
:aria-label="t('components.appHeader.achievements')"
|
||||
@click="navigateToAchievements"
|
||||
>
|
||||
<Trophy :size="15" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
@@ -711,7 +724,14 @@ function formatBadgeStyle(fmt: string) {
|
||||
|
||||
<Tooltip v-if="hasPermission('library_upload')">
|
||||
<TooltipTrigger as-child>
|
||||
<Button data-tour="upload-button" variant="ghost" size="icon" :class="controlClass" @click="uploadOpen = true">
|
||||
<Button
|
||||
data-tour="upload-button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:class="controlClass"
|
||||
:aria-label="t('components.appHeader.uploadBooks')"
|
||||
@click="uploadOpen = true"
|
||||
>
|
||||
<Upload :size="15" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
@@ -726,7 +746,13 @@ function formatBadgeStyle(fmt: string) {
|
||||
<DropdownMenu>
|
||||
<TooltipTrigger as-child>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button data-tour="documentation-link" variant="ghost" size="icon" :class="['relative', controlClass]">
|
||||
<Button
|
||||
data-tour="documentation-link"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:class="['relative', controlClass]"
|
||||
:aria-label="t('components.appHeader.help')"
|
||||
>
|
||||
<BadgeQuestionMark :size="15" />
|
||||
<span
|
||||
v-if="hasUnseenWhatsNew"
|
||||
@@ -762,7 +788,13 @@ function formatBadgeStyle(fmt: string) {
|
||||
<Popover>
|
||||
<TooltipTrigger as-child>
|
||||
<PopoverTrigger as-child>
|
||||
<Button data-tour="appearance-picker" variant="ghost" size="icon" :class="controlClass">
|
||||
<Button
|
||||
data-tour="appearance-picker"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:class="controlClass"
|
||||
:aria-label="t('components.appHeader.appearance')"
|
||||
>
|
||||
<Palette :size="15" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
@@ -824,7 +856,14 @@ function formatBadgeStyle(fmt: string) {
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button data-tour="settings-nav" variant="ghost" size="icon" :class="controlClass" @click="navigateToSettings">
|
||||
<Button
|
||||
data-tour="settings-nav"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:class="controlClass"
|
||||
:aria-label="t('components.appHeader.settings')"
|
||||
@click="navigateToSettings"
|
||||
>
|
||||
<Settings :size="15" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { CheckSquare, LayoutGrid, List, SlidersHorizontal, Square, Table2 } from
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import type { TableDensity } from '@bookorbit/types'
|
||||
import type { BookViewMode } from '@/composables/useDisplaySettings'
|
||||
import ViewHeaderDesktopSearch from '@/components/view-header/ViewHeaderDesktopSearch.vue'
|
||||
@@ -85,6 +86,62 @@ const mobileSearchOpen = ref(false)
|
||||
function handleShowJumpRailsUpdate(value: boolean) {
|
||||
emit('update:showJumpRails', value)
|
||||
}
|
||||
|
||||
function handleCoverSizeUpdate(value: number) {
|
||||
emit('update:coverSize', value)
|
||||
}
|
||||
|
||||
function handleGridGapUpdate(value: number) {
|
||||
emit('update:gridGap', value)
|
||||
}
|
||||
|
||||
function handleRowDensityUpdate(value: TableDensity) {
|
||||
emit('update:rowDensity', value)
|
||||
}
|
||||
|
||||
function handleCoverFallbackUpdate(value: boolean) {
|
||||
emit('update:coverFallback', value)
|
||||
}
|
||||
|
||||
function handleCoverShapeUpdate(value: 'square' | 'circle') {
|
||||
emit('update:coverShape', value)
|
||||
}
|
||||
|
||||
function handleViewModeUpdate(value: BookViewMode) {
|
||||
emit('update:viewMode', value)
|
||||
}
|
||||
|
||||
function handleSearchQueryUpdate(value: string) {
|
||||
emit('update:searchQuery', value)
|
||||
}
|
||||
|
||||
function handleToggleSelection() {
|
||||
emit('toggle-selection')
|
||||
}
|
||||
|
||||
function handleGridView() {
|
||||
handleViewModeUpdate('grid')
|
||||
}
|
||||
|
||||
function handleListView() {
|
||||
handleViewModeUpdate('list')
|
||||
}
|
||||
|
||||
function handleTableView() {
|
||||
handleViewModeUpdate('table')
|
||||
}
|
||||
|
||||
function handleOpenDisplay() {
|
||||
mobileDisplayOpen.value = true
|
||||
}
|
||||
|
||||
function handleOpenMobileSearch() {
|
||||
mobileSearchOpen.value = true
|
||||
}
|
||||
|
||||
function handleMobileSearchOpenUpdate(value: boolean) {
|
||||
mobileSearchOpen.value = value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -100,7 +157,7 @@ function handleShowJumpRailsUpdate(value: boolean) {
|
||||
v-if="searchable"
|
||||
:search-query="searchQuery"
|
||||
:placeholder="searchPlaceholder"
|
||||
@update:search-query="emit('update:searchQuery', $event)"
|
||||
@update:search-query="handleSearchQueryUpdate"
|
||||
/>
|
||||
|
||||
<slot name="toolbar" />
|
||||
@@ -116,7 +173,7 @@ function handleShowJumpRailsUpdate(value: boolean) {
|
||||
? 'text-primary bg-primary/10 hover:bg-primary/20 ring-1 ring-primary/20'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-primary/5'
|
||||
"
|
||||
@click="emit('toggle-selection')"
|
||||
@click="handleToggleSelection"
|
||||
>
|
||||
<CheckSquare v-if="selectionMode" :size="13" />
|
||||
<Square v-else :size="13" />
|
||||
@@ -126,88 +183,105 @@ function handleShowJumpRailsUpdate(value: boolean) {
|
||||
<div v-if="showSelection" class="mx-1.5 hidden h-3.5 w-px bg-border/40 md:block" />
|
||||
|
||||
<div v-if="showViewModeToggle && allowedViewModes.length > 0" class="hidden items-center gap-0.5 md:flex">
|
||||
<Button
|
||||
v-if="allowedViewModes.includes('grid')"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 rounded-lg"
|
||||
:class="viewMode === 'grid' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground hover:bg-primary/5'"
|
||||
:aria-label="t('components.viewHeader.viewModes.grid')"
|
||||
:aria-pressed="viewMode === 'grid'"
|
||||
@click="emit('update:viewMode', 'grid')"
|
||||
>
|
||||
<LayoutGrid :size="14" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="allowedViewModes.includes('list')"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 rounded-lg"
|
||||
:class="viewMode === 'list' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground hover:bg-primary/5'"
|
||||
:aria-label="t('components.viewHeader.viewModes.list')"
|
||||
:aria-pressed="viewMode === 'list'"
|
||||
@click="emit('update:viewMode', 'list')"
|
||||
>
|
||||
<List :size="14" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="allowedViewModes.includes('table')"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 rounded-lg"
|
||||
:class="viewMode === 'table' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground hover:bg-primary/5'"
|
||||
:aria-label="t('components.viewHeader.viewModes.table')"
|
||||
:aria-pressed="viewMode === 'table'"
|
||||
@click="emit('update:viewMode', 'table')"
|
||||
>
|
||||
<Table2 :size="14" />
|
||||
</Button>
|
||||
<Tooltip v-if="allowedViewModes.includes('grid')">
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 rounded-lg"
|
||||
:class="viewMode === 'grid' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground hover:bg-primary/5'"
|
||||
:aria-label="t('components.viewHeader.viewModes.grid')"
|
||||
:aria-pressed="viewMode === 'grid'"
|
||||
@click="handleGridView"
|
||||
>
|
||||
<LayoutGrid :size="14" aria-hidden="true" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('components.viewHeader.viewModes.grid') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip v-if="allowedViewModes.includes('list')">
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 rounded-lg"
|
||||
:class="viewMode === 'list' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground hover:bg-primary/5'"
|
||||
:aria-label="t('components.viewHeader.viewModes.list')"
|
||||
:aria-pressed="viewMode === 'list'"
|
||||
@click="handleListView"
|
||||
>
|
||||
<List :size="14" aria-hidden="true" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('components.viewHeader.viewModes.list') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip v-if="allowedViewModes.includes('table')">
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 rounded-lg"
|
||||
:class="viewMode === 'table' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground hover:bg-primary/5'"
|
||||
:aria-label="t('components.viewHeader.viewModes.table')"
|
||||
:aria-pressed="viewMode === 'table'"
|
||||
@click="handleTableView"
|
||||
>
|
||||
<Table2 :size="14" aria-hidden="true" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('components.viewHeader.viewModes.table') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<Popover v-if="showDisplayControls">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="hidden h-8 w-8 rounded-lg text-muted-foreground hover:bg-primary/5 hover:text-foreground md:flex"
|
||||
:aria-label="t('components.viewHeader.display')"
|
||||
>
|
||||
<SlidersHorizontal :size="14" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent :class="viewMode === 'table' ? 'w-80 max-w-[90vw] max-h-[80vh] overflow-y-auto p-4' : 'w-56 p-4'" align="end">
|
||||
<ViewHeaderDisplayControls
|
||||
:view-mode="viewMode"
|
||||
:cover-size="coverSize"
|
||||
:grid-gap="gridGap"
|
||||
:show-jump-rail-toggle="showJumpRailToggle"
|
||||
:show-jump-rails="showJumpRails"
|
||||
:jump-rail-modes="jumpRailModes"
|
||||
:row-density="rowDensity"
|
||||
:show-cover-fallback-toggle="showCoverFallbackToggle"
|
||||
:cover-fallback="coverFallback"
|
||||
:cover-shape="coverShape"
|
||||
:cover-size-min="coverSizeMin"
|
||||
:cover-size-max="coverSizeMax"
|
||||
:cover-size-step="coverSizeStep"
|
||||
:grid-gap-min="gridGapMin"
|
||||
:grid-gap-max="gridGapMax"
|
||||
:grid-gap-step="gridGapStep"
|
||||
@update:cover-size="emit('update:coverSize', $event)"
|
||||
@update:grid-gap="emit('update:gridGap', $event)"
|
||||
@update:show-jump-rails="handleShowJumpRailsUpdate"
|
||||
@update:row-density="emit('update:rowDensity', $event)"
|
||||
@update:cover-fallback="emit('update:coverFallback', $event)"
|
||||
@update:cover-shape="emit('update:coverShape', $event)"
|
||||
>
|
||||
<template #columns>
|
||||
<slot name="columns">
|
||||
<p class="text-xs text-muted-foreground">{{ t('components.viewHeader.columnVisibilityHint') }}</p>
|
||||
</slot>
|
||||
</template>
|
||||
</ViewHeaderDisplayControls>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Tooltip v-if="showDisplayControls">
|
||||
<Popover>
|
||||
<TooltipTrigger as-child>
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="hidden h-8 w-8 rounded-lg text-muted-foreground hover:bg-primary/5 hover:text-foreground md:flex"
|
||||
:aria-label="t('components.viewHeader.display')"
|
||||
>
|
||||
<SlidersHorizontal :size="14" aria-hidden="true" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<PopoverContent :class="viewMode === 'table' ? 'w-80 max-w-[90vw] max-h-[80vh] overflow-y-auto p-4' : 'w-56 p-4'" align="end">
|
||||
<ViewHeaderDisplayControls
|
||||
:view-mode="viewMode"
|
||||
:cover-size="coverSize"
|
||||
:grid-gap="gridGap"
|
||||
:show-jump-rail-toggle="showJumpRailToggle"
|
||||
:show-jump-rails="showJumpRails"
|
||||
:jump-rail-modes="jumpRailModes"
|
||||
:row-density="rowDensity"
|
||||
:show-cover-fallback-toggle="showCoverFallbackToggle"
|
||||
:cover-fallback="coverFallback"
|
||||
:cover-shape="coverShape"
|
||||
:cover-size-min="coverSizeMin"
|
||||
:cover-size-max="coverSizeMax"
|
||||
:cover-size-step="coverSizeStep"
|
||||
:grid-gap-min="gridGapMin"
|
||||
:grid-gap-max="gridGapMax"
|
||||
:grid-gap-step="gridGapStep"
|
||||
@update:cover-size="handleCoverSizeUpdate"
|
||||
@update:grid-gap="handleGridGapUpdate"
|
||||
@update:show-jump-rails="handleShowJumpRailsUpdate"
|
||||
@update:row-density="handleRowDensityUpdate"
|
||||
@update:cover-fallback="handleCoverFallbackUpdate"
|
||||
@update:cover-shape="handleCoverShapeUpdate"
|
||||
>
|
||||
<template #columns>
|
||||
<slot name="columns">
|
||||
<p class="text-xs text-muted-foreground">{{ t('components.viewHeader.columnVisibilityHint') }}</p>
|
||||
</slot>
|
||||
</template>
|
||||
</ViewHeaderDisplayControls>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<TooltipContent>{{ t('components.viewHeader.display') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<ViewHeaderMobileMenu
|
||||
:view-mode="viewMode"
|
||||
@@ -218,10 +292,10 @@ function handleShowJumpRailsUpdate(value: boolean) {
|
||||
:mobile-search-in-menu="mobileSearchInMenu"
|
||||
:show-display-action="mobileDisplayInMenu && showDisplayControls"
|
||||
:allowed-view-modes="allowedViewModes"
|
||||
@update:view-mode="emit('update:viewMode', $event)"
|
||||
@open-display="mobileDisplayOpen = true"
|
||||
@open-mobile-search="mobileSearchOpen = true"
|
||||
@toggle-selection="emit('toggle-selection')"
|
||||
@update:view-mode="handleViewModeUpdate"
|
||||
@open-display="handleOpenDisplay"
|
||||
@open-mobile-search="handleOpenMobileSearch"
|
||||
@toggle-selection="handleToggleSelection"
|
||||
>
|
||||
<template v-if="$slots['mobile-menu']" #mobile-menu>
|
||||
<slot name="mobile-menu" />
|
||||
@@ -254,12 +328,12 @@ function handleShowJumpRailsUpdate(value: boolean) {
|
||||
:grid-gap-min="gridGapMin"
|
||||
:grid-gap-max="gridGapMax"
|
||||
:grid-gap-step="gridGapStep"
|
||||
@update:cover-size="emit('update:coverSize', $event)"
|
||||
@update:grid-gap="emit('update:gridGap', $event)"
|
||||
@update:cover-size="handleCoverSizeUpdate"
|
||||
@update:grid-gap="handleGridGapUpdate"
|
||||
@update:show-jump-rails="handleShowJumpRailsUpdate"
|
||||
@update:row-density="emit('update:rowDensity', $event)"
|
||||
@update:cover-fallback="emit('update:coverFallback', $event)"
|
||||
@update:cover-shape="emit('update:coverShape', $event)"
|
||||
@update:row-density="handleRowDensityUpdate"
|
||||
@update:cover-fallback="handleCoverFallbackUpdate"
|
||||
@update:cover-shape="handleCoverShapeUpdate"
|
||||
>
|
||||
<template #columns>
|
||||
<slot name="columns">
|
||||
@@ -276,7 +350,7 @@ function handleShowJumpRailsUpdate(value: boolean) {
|
||||
:open="mobileSearchOpen"
|
||||
:search-query="searchQuery"
|
||||
:placeholder="searchPlaceholder"
|
||||
@update:open="mobileSearchOpen = $event"
|
||||
@update:search-query="emit('update:searchQuery', $event)"
|
||||
@update:open="handleMobileSearchOpenUpdate"
|
||||
@update:search-query="handleSearchQueryUpdate"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -328,6 +328,15 @@ describe('AppHeader global search', () => {
|
||||
expect(settingsControlIndex).toBe(languageControlIndex + 1)
|
||||
})
|
||||
|
||||
it('labels the icon-only desktop destinations and preference controls', () => {
|
||||
const wrapper = mountHeader()
|
||||
|
||||
expect(wrapper.get('[data-tour="statistics-btn"]').attributes('aria-label')).toBe('Statistics')
|
||||
expect(wrapper.get('[data-tour="documentation-link"]').attributes('aria-label')).toBe('Help')
|
||||
expect(wrapper.get('[data-tour="appearance-picker"]').attributes('aria-label')).toBe('Appearance')
|
||||
expect(wrapper.get('[data-tour="settings-nav"]').attributes('aria-label')).toBe('Settings')
|
||||
})
|
||||
|
||||
it('opens settings from the mobile overflow menu', async () => {
|
||||
const wrapper = mountHeader()
|
||||
const settingsItem = wrapper.findAllComponents({ name: 'DropdownMenuItem' }).find((item) => item.text().trim() === 'Settings')
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import ViewHeader from '../ViewHeader.vue'
|
||||
|
||||
const stubs = {
|
||||
AppIcon: { template: '<span />' },
|
||||
Button: { template: '<button type="button"><slot /></button>' },
|
||||
Popover: { template: '<div><slot /></div>' },
|
||||
PopoverTrigger: { template: '<div><slot /></div>' },
|
||||
PopoverContent: { template: '<div><slot /></div>' },
|
||||
Sheet: { template: '<div><slot /></div>' },
|
||||
SheetContent: { template: '<div><slot /></div>' },
|
||||
SheetDescription: { template: '<div><slot /></div>' },
|
||||
SheetHeader: { template: '<div><slot /></div>' },
|
||||
SheetTitle: { template: '<div><slot /></div>' },
|
||||
Tooltip: { template: '<div><slot /></div>' },
|
||||
TooltipTrigger: { template: '<div><slot /></div>' },
|
||||
TooltipContent: { template: '<div data-testid="tooltip-content"><slot /></div>' },
|
||||
ViewHeaderDisplayControls: { template: '<div />' },
|
||||
ViewHeaderMobileMenu: { template: '<div />' },
|
||||
ViewHeaderMobileSearchSheet: { template: '<div />' },
|
||||
}
|
||||
|
||||
function mountHeader() {
|
||||
return mount(ViewHeader, {
|
||||
props: {
|
||||
title: 'Library',
|
||||
total: 12,
|
||||
coverSize: 120,
|
||||
gridGap: 16,
|
||||
viewMode: 'grid',
|
||||
},
|
||||
global: { stubs },
|
||||
})
|
||||
}
|
||||
|
||||
describe('ViewHeader icon controls', () => {
|
||||
it('provides matching labels and tooltips for view modes and display settings', () => {
|
||||
const wrapper = mountHeader()
|
||||
|
||||
for (const label of ['Grid view', 'List view', 'Table view', 'Display']) {
|
||||
expect(wrapper.get(`button[aria-label="${label}"]`)).toBeTruthy()
|
||||
expect(wrapper.findAll('[data-testid="tooltip-content"]').some((content) => content.text() === label)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps view-mode buttons interactive through the tooltip trigger', async () => {
|
||||
const wrapper = mountHeader()
|
||||
|
||||
await wrapper.get('button[aria-label="List view"]').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('update:viewMode')).toEqual([['list']])
|
||||
})
|
||||
})
|
||||
@@ -1,22 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { PanelLeft } from '@lucide/vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useSidebar } from './utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const { toggleSidebar } = useSidebar()
|
||||
const { isMobile, openMobile, state, toggleSidebar } = useSidebar()
|
||||
const { t } = useI18n()
|
||||
|
||||
const actionLabel = computed(() => {
|
||||
if (isMobile.value) {
|
||||
return openMobile.value ? t('components.ui.sidebar.close') : t('components.ui.sidebar.open')
|
||||
}
|
||||
return state.value === 'expanded' ? t('components.ui.sidebar.collapse') : t('components.ui.sidebar.expand')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button data-sidebar="trigger" data-slot="sidebar-trigger" variant="ghost" size="icon" :class="cn('h-7 w-7', props.class)" @click="toggleSidebar">
|
||||
<PanelLeft />
|
||||
<span class="sr-only">{{ t('components.ui.sidebar.toggle') }}</span>
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:class="cn('h-7 w-7', props.class)"
|
||||
:aria-label="actionLabel"
|
||||
@click="toggleSidebar"
|
||||
>
|
||||
<PanelLeft aria-hidden="true" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ actionLabel }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</template>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { defineComponent } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import SidebarProvider from '../SidebarProvider.vue'
|
||||
import SidebarTrigger from '../SidebarTrigger.vue'
|
||||
import { useSidebar } from '../utils'
|
||||
|
||||
const { readDeviceValue, writeDeviceValue } = vi.hoisted(() => ({
|
||||
@@ -54,4 +55,29 @@ describe('SidebarProvider', () => {
|
||||
expect(trigger.attributes('data-state')).toBe('expanded')
|
||||
expect(writeDeviceValue).toHaveBeenLastCalledWith('collapsed', false)
|
||||
})
|
||||
|
||||
it('describes the action the sidebar trigger will perform', async () => {
|
||||
const wrapper = mount(SidebarProvider, {
|
||||
slots: {
|
||||
default: SidebarTrigger,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
TooltipProvider: { template: '<div><slot /></div>' },
|
||||
Tooltip: { template: '<div><slot /></div>' },
|
||||
TooltipTrigger: { template: '<div><slot /></div>' },
|
||||
TooltipContent: { template: '<div data-testid="tooltip-content"><slot /></div>' },
|
||||
},
|
||||
},
|
||||
})
|
||||
const trigger = wrapper.get('button')
|
||||
|
||||
expect(trigger.attributes('aria-label')).toBe('Collapse sidebar')
|
||||
expect(wrapper.get('[data-testid="tooltip-content"]').text()).toBe('Collapse sidebar')
|
||||
|
||||
await trigger.trigger('click')
|
||||
|
||||
expect(trigger.attributes('aria-label')).toBe('Expand sidebar')
|
||||
expect(wrapper.get('[data-testid="tooltip-content"]').text()).toBe('Expand sidebar')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import ViewHeaderDesktopSearch from './ViewHeaderDesktopSearch.vue'
|
||||
|
||||
const stubs = {
|
||||
Tooltip: { template: '<div><slot /></div>' },
|
||||
TooltipTrigger: { template: '<div><slot /></div>' },
|
||||
TooltipContent: { template: '<div data-testid="tooltip-content"><slot /></div>' },
|
||||
}
|
||||
|
||||
describe('ViewHeaderDesktopSearch', () => {
|
||||
it('labels and describes the collapsed search control', () => {
|
||||
const wrapper = mount(ViewHeaderDesktopSearch, { global: { stubs } })
|
||||
|
||||
expect(wrapper.get('input').attributes('aria-label')).toBe('Search')
|
||||
expect(wrapper.get('[data-testid="tooltip-content"]').text()).toBe('Search')
|
||||
})
|
||||
|
||||
it('labels the close action after search expands', async () => {
|
||||
const wrapper = mount(ViewHeaderDesktopSearch, { global: { stubs } })
|
||||
|
||||
await wrapper.get('input').trigger('focus')
|
||||
|
||||
expect(wrapper.get('button').attributes('aria-label')).toBe('Close')
|
||||
expect(wrapper.findAll('[data-testid="tooltip-content"]').map((content) => content.text())).toEqual(['Search', 'Close'])
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Search, X } from '@lucide/vue'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -56,23 +57,39 @@ function handleSearchKeydown(event: KeyboardEvent) {
|
||||
class="pointer-events-none absolute left-2 z-10 transition-colors duration-200"
|
||||
:class="searchActive ? 'text-primary' : 'text-muted-foreground'"
|
||||
/>
|
||||
<input
|
||||
ref="searchInputRef"
|
||||
:value="searchQuery ?? ''"
|
||||
@focus="openSearch"
|
||||
@input="handleSearchInput"
|
||||
@keydown="handleSearchKeydown"
|
||||
type="text"
|
||||
:placeholder="props.placeholder ?? t('components.viewHeader.searchPlaceholder')"
|
||||
class="h-8 text-[13px] transition-all duration-300 focus:outline-none"
|
||||
:class="
|
||||
searchActive
|
||||
? 'w-44 cursor-text rounded-lg border border-primary/30 bg-primary/5 pl-8 pr-6 text-foreground placeholder:text-muted-foreground focus:ring-1 focus:ring-primary/30 lg:w-72'
|
||||
: 'w-8 cursor-pointer rounded-lg border border-input bg-transparent pl-2.25 pr-0 text-transparent placeholder:text-transparent select-none hover:border-muted-foreground/30 hover:bg-primary/5'
|
||||
"
|
||||
/>
|
||||
<button v-if="searchActive" @click="closeSearch" class="absolute right-1.5 text-muted-foreground transition-colors hover:text-foreground">
|
||||
<X :size="13" />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<input
|
||||
ref="searchInputRef"
|
||||
:value="searchQuery ?? ''"
|
||||
type="text"
|
||||
:placeholder="props.placeholder ?? t('components.viewHeader.searchPlaceholder')"
|
||||
:aria-label="t('common.search')"
|
||||
class="h-8 text-[13px] transition-all duration-300 focus:outline-none"
|
||||
:class="
|
||||
searchActive
|
||||
? 'w-44 cursor-text rounded-lg border border-primary/30 bg-primary/5 pl-8 pr-6 text-foreground placeholder:text-muted-foreground focus:ring-1 focus:ring-primary/30 lg:w-72'
|
||||
: 'w-8 cursor-pointer rounded-lg border border-input bg-transparent pl-2.25 pr-0 text-transparent placeholder:text-transparent select-none hover:border-muted-foreground/30 hover:bg-primary/5'
|
||||
"
|
||||
@focus="openSearch"
|
||||
@input="handleSearchInput"
|
||||
@keydown="handleSearchKeydown"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('common.search') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip v-if="searchActive">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="t('common.close')"
|
||||
class="absolute right-1.5 text-muted-foreground transition-colors hover:text-foreground"
|
||||
@click="closeSearch"
|
||||
>
|
||||
<X :size="13" aria-hidden="true" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('common.close') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -271,6 +271,13 @@ const primaryOverlayActionIcon = computed(() => {
|
||||
})
|
||||
const primaryOverlayActionIconClass = computed(() => (thumbnailClickAction.value !== 'details' && isAudiobook.value ? 'ml-[2cqi]' : ''))
|
||||
const showExplicitReadButton = computed(() => thumbnailClickAction.value === 'details' && primaryFile.value != null && !isMissing.value)
|
||||
const readActionLabel = computed(() => {
|
||||
if (isAudiobook.value) {
|
||||
return hasProgress.value ? t('book.actions.continueListening') : t('book.actions.startListening')
|
||||
}
|
||||
return hasProgress.value ? t('book.actions.continueReading') : t('book.actions.startReading')
|
||||
})
|
||||
const primaryOverlayActionLabel = computed(() => (thumbnailClickAction.value === 'details' ? t('book.actions.bookDetails') : readActionLabel.value))
|
||||
|
||||
function handlePrimaryOverlayAction() {
|
||||
if (thumbnailClickAction.value === 'details') {
|
||||
@@ -543,29 +550,48 @@ const secondaryLabelText = computed(() => resolveBookLabel(gridCardSecondaryLabe
|
||||
>
|
||||
<!-- Top row: Quick View + explicit Read (when thumbnail click prefers details) -->
|
||||
<div class="shrink-0 flex items-center justify-end gap-1">
|
||||
<button class="p-[3cqi] rounded-[2.5cqi] bg-black/50 hover:bg-black/30 transition-colors text-white" @click="openQuickView">
|
||||
<PanelRight class="size-[12cqi]" />
|
||||
</button>
|
||||
<button
|
||||
v-if="showExplicitReadButton"
|
||||
class="p-[3cqi] rounded-[2.5cqi] bg-black/50 hover:bg-black/30 transition-colors text-white"
|
||||
@click.stop="openPrimaryFileExplicit"
|
||||
>
|
||||
<BookOpen class="size-[12cqi]" />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="p-[3cqi] rounded-[2.5cqi] bg-black/50 hover:bg-black/30 transition-colors text-white"
|
||||
:aria-label="t('book.actions.quickView')"
|
||||
@click.stop="openQuickView"
|
||||
>
|
||||
<PanelRight class="size-[12cqi]" aria-hidden="true" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('book.actions.quickView') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip v-if="showExplicitReadButton">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="p-[3cqi] rounded-[2.5cqi] bg-black/50 hover:bg-black/30 transition-colors text-white"
|
||||
:aria-label="readActionLabel"
|
||||
@click.stop="openPrimaryFileExplicit"
|
||||
>
|
||||
<BookOpen class="size-[12cqi]" aria-hidden="true" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ readActionLabel }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<!-- Center: primary thumbnail action -->
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<button
|
||||
v-if="showPrimaryOverlayAction"
|
||||
data-testid="grid-card-primary-action"
|
||||
class="size-[30cqi] flex items-center justify-center rounded-full bg-primary text-white shadow-2xl transition-all duration-300 scale-75 hover:scale-110 active:scale-90"
|
||||
:class="[showMobileOverlay || 'group-hover:scale-100', showMobileOverlay ? 'scale-100' : '']"
|
||||
@click.stop="handlePrimaryOverlayAction"
|
||||
>
|
||||
<component :is="primaryOverlayActionIcon" class="size-[16cqi]" :class="primaryOverlayActionIconClass" />
|
||||
</button>
|
||||
<Tooltip v-if="showPrimaryOverlayAction">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
data-testid="grid-card-primary-action"
|
||||
class="size-[30cqi] flex items-center justify-center rounded-full bg-primary text-white shadow-2xl transition-all duration-300 scale-75 hover:scale-110 active:scale-90"
|
||||
:class="[showMobileOverlay || 'group-hover:scale-100', showMobileOverlay ? 'scale-100' : '']"
|
||||
:aria-label="primaryOverlayActionLabel"
|
||||
@click.stop="handlePrimaryOverlayAction"
|
||||
>
|
||||
<component :is="primaryOverlayActionIcon" class="size-[16cqi]" :class="primaryOverlayActionIconClass" aria-hidden="true" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ primaryOverlayActionLabel }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<!-- Bottom: title/author (hover-overlay mode only) + kebab (when not in below-cover label row) -->
|
||||
|
||||
@@ -589,10 +589,15 @@ function handleRowClick(book: BookCard, e: MouseEvent) {
|
||||
return
|
||||
}
|
||||
if (book.collapsedSeries) {
|
||||
if (book.seriesId != null) router.push({ name: 'series-detail', params: { seriesId: book.seriesId } })
|
||||
handleOpenSeries(book)
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenSeries(book: BookCard) {
|
||||
if (book.seriesId == null) return
|
||||
void router.push({ name: 'series-detail', params: { seriesId: book.seriesId } })
|
||||
}
|
||||
|
||||
function emitLockStateUpdate(book: BookCard, nextFields: BookCard['lockedFields']) {
|
||||
const updated = buildLockStateBookUpdate(loadedBooks.value, book, nextFields)
|
||||
if (!updated) return
|
||||
@@ -873,7 +878,7 @@ defineExpose({
|
||||
@click="focusTableCell(vItem.index, colIdx, $event, rowBook(vItem.index).id, col.id)"
|
||||
>
|
||||
<template v-if="rowBook(vItem.index).collapsedSeries">
|
||||
<BookTableCollapsedSeriesCell :book="rowBook(vItem.index)" :col-id="col.id" />
|
||||
<BookTableCollapsedSeriesCell :book="rowBook(vItem.index)" :col-id="col.id" @open-series="handleOpenSeries(rowBook(vItem.index))" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<BookTableCellDispatcher
|
||||
|
||||
@@ -226,6 +226,30 @@ describe('BookCoverCard — present state', () => {
|
||||
expect(routerPushMock).not.toHaveBeenCalledWith(expect.objectContaining({ name: 'reader' }))
|
||||
})
|
||||
|
||||
it('labels and describes the quick-view and start-reading actions', () => {
|
||||
const wrapper = mountCard(presentBook)
|
||||
|
||||
expect(wrapper.get('button[aria-label="Quick View"]')).toBeTruthy()
|
||||
expect(wrapper.get('[data-testid="grid-card-primary-action"]').attributes('aria-label')).toBe('Start reading')
|
||||
expect(wrapper.findAll('[data-testid="tooltip-content"]').map((content) => content.text())).toEqual(
|
||||
expect.arrayContaining(['Quick View', 'Start reading']),
|
||||
)
|
||||
})
|
||||
|
||||
it('uses a continue-reading label when the book has progress', () => {
|
||||
const wrapper = mountCard({ ...presentBook, readingProgress: 20 })
|
||||
|
||||
expect(wrapper.get('[data-testid="grid-card-primary-action"]').attributes('aria-label')).toBe('Continue reading')
|
||||
})
|
||||
|
||||
it('labels the explicit reading action when thumbnail clicks prefer details', () => {
|
||||
thumbnailClickAction.value = 'details'
|
||||
const wrapper = mountCard(presentBook)
|
||||
|
||||
expect(wrapper.get('[data-testid="grid-card-primary-action"]').attributes('aria-label')).toBe('Book Details')
|
||||
expect(wrapper.get('button[aria-label="Start reading"]')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('opens book details for missing books when thumbnail clicks prefer details', async () => {
|
||||
thumbnailClickAction.value = 'details'
|
||||
const wrapper = mountCard(missingBook)
|
||||
|
||||
@@ -17,6 +17,7 @@ import BookTableProgressCell from './BookTableProgressCell.vue'
|
||||
import BookTableMetadataScoreCell from './BookTableMetadataScoreCell.vue'
|
||||
import BookTableLockableCell from './BookTableLockableCell.vue'
|
||||
import BookTableBooleanCell from './BookTableBooleanCell.vue'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import type { CellType } from '@/features/book/composables/tableColumnSchema'
|
||||
|
||||
type NavigationDirection = 'next' | 'prev' | 'rowUp' | 'rowDown'
|
||||
@@ -84,6 +85,14 @@ const lockStateClass = computed(() => {
|
||||
return 'text-muted-foreground hover:text-foreground'
|
||||
})
|
||||
|
||||
function handleToggleAllLocks() {
|
||||
if (props.isFullyLocked) {
|
||||
emit('unlockAll')
|
||||
return
|
||||
}
|
||||
emit('lockAll')
|
||||
}
|
||||
|
||||
const primaryFile = computed(() => props.book.files.find((file) => file.role === 'primary') ?? props.book.files[0] ?? null)
|
||||
const isAudiobook = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'audio')
|
||||
const isComic = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'cbx')
|
||||
@@ -91,15 +100,21 @@ const isComic = computed(() => primaryFile.value?.format != null && FORMAT_TO_GR
|
||||
|
||||
<template>
|
||||
<template v-if="cellType === 'lockRow'">
|
||||
<button
|
||||
class="flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-muted"
|
||||
:class="lockStateClass"
|
||||
:aria-label="isFullyLocked ? t('book.table.locks.unlockAll') : t('book.table.locks.lockAll')"
|
||||
@click.stop="isFullyLocked ? emit('unlockAll') : emit('lockAll')"
|
||||
>
|
||||
<Lock v-if="lockedFieldCount > 0" :size="13" />
|
||||
<LockOpen v-else :size="13" />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-muted"
|
||||
:class="lockStateClass"
|
||||
:aria-label="isFullyLocked ? t('book.table.locks.unlockAll') : t('book.table.locks.lockAll')"
|
||||
@click.stop="handleToggleAllLocks"
|
||||
>
|
||||
<Lock v-if="lockedFieldCount > 0" :size="13" aria-hidden="true" />
|
||||
<LockOpen v-else :size="13" aria-hidden="true" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ isFullyLocked ? t('book.table.locks.unlockAll') : t('book.table.locks.lockAll') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</template>
|
||||
|
||||
<BookTableCoverCell
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { ChevronRight, LibraryBig } from '@lucide/vue'
|
||||
import { FORMAT_TO_GROUP, type BookCard } from '@bookorbit/types'
|
||||
import BookCoverSurface from '../BookCoverSurface.vue'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -11,6 +12,10 @@ const props = defineProps<{
|
||||
colId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'open-series': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const collapsed = computed(() => props.book.collapsedSeries!)
|
||||
@@ -27,11 +32,22 @@ function thumbnailUrl(bookId: number): string {
|
||||
const version = bookId === props.book.id ? (props.book.updatedAt ?? props.book.addedAt) : collapsed.value.coverUpdatedAtByBookId?.[bookId]
|
||||
return coverUrl(bookId, 'thumbnail', version)
|
||||
}
|
||||
|
||||
function handleOpenSeries() {
|
||||
emit('open-series')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="colId === 'lockRow'" class="flex h-6 w-6 items-center justify-center">
|
||||
<LibraryBig :size="13" class="text-muted-foreground" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<span role="img" tabindex="0" :aria-label="t('book.table.series.seriesRow')" class="rounded-sm focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<LibraryBig :size="13" class="text-muted-foreground" aria-hidden="true" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('book.table.series.seriesRow') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div v-else-if="colId === 'readStatus'" class="flex w-full items-center px-1 py-0.5">
|
||||
@@ -80,7 +96,19 @@ function thumbnailUrl(bookId: number): string {
|
||||
</div>
|
||||
|
||||
<div v-else-if="colId === 'actions'" class="flex h-full w-6 items-center justify-center">
|
||||
<ChevronRight :size="16" class="text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="t('book.tableView.openSeries')"
|
||||
class="flex h-7 w-7 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
@click.stop="handleOpenSeries"
|
||||
>
|
||||
<ChevronRight :size="16" aria-hidden="true" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('book.tableView.openSeries') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<!-- Dash for all other columns -->
|
||||
|
||||
@@ -67,6 +67,9 @@ function mountDispatcher(overrides: Partial<typeof baseProps> = {}) {
|
||||
Lock: { template: '<svg data-test="lock-icon" />' },
|
||||
LockOpen: { template: '<svg data-test="lock-open-icon" />' },
|
||||
BookTableCoverCell: CoverCellStub,
|
||||
Tooltip: { template: '<div><slot /></div>' },
|
||||
TooltipTrigger: { template: '<div><slot /></div>' },
|
||||
TooltipContent: { template: '<div data-testid="tooltip-content"><slot /></div>' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -79,6 +82,7 @@ describe('BookTableCellDispatcher lock row', () => {
|
||||
|
||||
expect(button.attributes('aria-label')).toBe('Lock all fields')
|
||||
expect(button.classes()).toContain('text-muted-foreground')
|
||||
expect(wrapper.get('[data-testid="tooltip-content"]').text()).toBe('Lock all fields')
|
||||
|
||||
await button.trigger('click')
|
||||
expect(wrapper.emitted('lockAll')).toBeTruthy()
|
||||
@@ -117,6 +121,7 @@ describe('BookTableCellDispatcher lock row', () => {
|
||||
|
||||
expect(button.attributes('aria-label')).toBe('Unlock all fields')
|
||||
expect(button.classes()).toContain('text-primary')
|
||||
expect(wrapper.get('[data-testid="tooltip-content"]').text()).toBe('Unlock all fields')
|
||||
|
||||
await button.trigger('click')
|
||||
expect(wrapper.emitted('unlockAll')).toBeTruthy()
|
||||
|
||||
+29
-3
@@ -58,10 +58,17 @@ const CoverSurfaceStub = {
|
||||
template: '<div data-testid="surface" :data-comic="isComic"><slot /></div>',
|
||||
}
|
||||
|
||||
function mountCell(format: string | null) {
|
||||
function mountCell(format: string | null, colId = 'cover') {
|
||||
return mount(BookTableCollapsedSeriesCell, {
|
||||
props: { book: makeBook(format), colId: 'cover' as never },
|
||||
global: { stubs: { BookCoverSurface: CoverSurfaceStub } },
|
||||
props: { book: makeBook(format), colId },
|
||||
global: {
|
||||
stubs: {
|
||||
BookCoverSurface: CoverSurfaceStub,
|
||||
Tooltip: { template: '<div><slot /></div>' },
|
||||
TooltipTrigger: { template: '<div><slot /></div>' },
|
||||
TooltipContent: { template: '<div data-testid="tooltip-content"><slot /></div>' },
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -88,4 +95,23 @@ describe('BookTableCollapsedSeriesCell comic flag', () => {
|
||||
expect(imgs[0]!.attributes('src')).toBe('/api/v1/books/10/thumbnail?t=1704067200000')
|
||||
expect(imgs[1]!.attributes('src')).toBe('/api/v1/books/11/thumbnail?t=1706745600000')
|
||||
})
|
||||
|
||||
it('identifies the series marker with a keyboard-triggered tooltip', () => {
|
||||
const wrapper = mountCell('epub', 'lockRow')
|
||||
const marker = wrapper.get('[role="img"]')
|
||||
|
||||
expect(marker.attributes('aria-label')).toBe('Series row')
|
||||
expect(marker.attributes('tabindex')).toBe('0')
|
||||
expect(wrapper.get('[data-testid="tooltip-content"]').text()).toBe('Series row')
|
||||
})
|
||||
|
||||
it('offers a labeled button to open the series', async () => {
|
||||
const wrapper = mountCell('epub', 'actions')
|
||||
const button = wrapper.get('button')
|
||||
|
||||
expect(button.attributes('aria-label')).toBe('Open series')
|
||||
expect(wrapper.get('[data-testid="tooltip-content"]').text()).toBe('Open series')
|
||||
await button.trigger('click')
|
||||
expect(wrapper.emitted('open-series')).toEqual([[]])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import NotificationSheet from './NotificationSheet.vue'
|
||||
|
||||
vi.mock('../composables/useNotifications', () => ({
|
||||
useNotifications: () => ({
|
||||
notifications: ref([]),
|
||||
unreadCount: ref(0),
|
||||
loading: ref(false),
|
||||
hasMore: ref(false),
|
||||
fetchNotifications: vi.fn<(reset?: boolean) => Promise<void>>().mockResolvedValue(undefined),
|
||||
markAsRead: vi.fn<(id: number) => Promise<void>>().mockResolvedValue(undefined),
|
||||
markAllAsRead: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
|
||||
dismiss: vi.fn<(id: number) => Promise<void>>().mockResolvedValue(undefined),
|
||||
clearAll: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
|
||||
}),
|
||||
}))
|
||||
|
||||
const stubs = {
|
||||
Button: { template: '<button type="button"><slot /></button>' },
|
||||
Sheet: { template: '<div><slot /></div>' },
|
||||
SheetTrigger: { template: '<div><slot /></div>' },
|
||||
SheetContent: { template: '<div><slot /></div>' },
|
||||
SheetDescription: { template: '<div><slot /></div>' },
|
||||
SheetHeader: { template: '<div><slot /></div>' },
|
||||
SheetTitle: { template: '<div><slot /></div>' },
|
||||
Tooltip: { template: '<div><slot /></div>' },
|
||||
TooltipTrigger: { template: '<div><slot /></div>' },
|
||||
TooltipContent: { template: '<div data-testid="tooltip-content"><slot /></div>' },
|
||||
NotificationItemVue: { template: '<div />' },
|
||||
}
|
||||
|
||||
describe('NotificationSheet trigger', () => {
|
||||
it('provides an accessible name and matching tooltip', () => {
|
||||
const wrapper = mount(NotificationSheet, {
|
||||
props: { iconRadiusClass: 'rounded-md' },
|
||||
global: { stubs },
|
||||
})
|
||||
|
||||
expect(wrapper.get('button').attributes('aria-label')).toBe('Notifications')
|
||||
expect(wrapper.get('[data-testid="tooltip-content"]').text()).toBe('Notifications')
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { Bell, BellOff, CheckCheck, Trash2 } from '@lucide/vue'
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useNotifications } from '../composables/useNotifications'
|
||||
import NotificationItemVue from './NotificationItem.vue'
|
||||
|
||||
@@ -43,62 +44,80 @@ function handleLoadMore() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Sheet v-model:open="sheetOpen">
|
||||
<SheetTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="relative h-8 w-8 border border-(--shell-accent-line) text-foreground transition-colors duration-150 hover:bg-(--shell-accent-wash)"
|
||||
:class="iconRadiusClass"
|
||||
>
|
||||
<Bell :size="15" />
|
||||
<span
|
||||
v-if="unreadCount > 0"
|
||||
class="absolute -top-1 -right-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[11px] font-semibold text-primary-foreground tabular-nums leading-none"
|
||||
>
|
||||
{{ unreadCount > 99 ? '99+' : unreadCount }}
|
||||
</span>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
|
||||
<SheetContent side="right" class="w-[90vw] sm:max-w-md p-0 flex flex-col gap-0">
|
||||
<SheetHeader class="flex flex-row items-center justify-between border-b px-4 py-3 h-14 shrink-0 space-y-0">
|
||||
<div>
|
||||
<SheetTitle class="text-sm font-semibold text-foreground">{{ t('notifications.title') }}</SheetTitle>
|
||||
<SheetDescription class="sr-only">{{ t('notifications.description') }}</SheetDescription>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 pr-8">
|
||||
<Button v-if="unreadCount > 0" variant="ghost" size="sm" class="h-7 gap-1.5 text-xs text-muted-foreground" @click="handleMarkAllRead">
|
||||
<CheckCheck :size="14" />
|
||||
{{ t('notifications.markAllRead') }}
|
||||
<Tooltip>
|
||||
<Sheet v-model:open="sheetOpen">
|
||||
<TooltipTrigger as-child>
|
||||
<SheetTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="relative h-8 w-8 border border-(--shell-accent-line) text-foreground transition-colors duration-150 hover:bg-(--shell-accent-wash)"
|
||||
:class="iconRadiusClass"
|
||||
:aria-label="t('notifications.title')"
|
||||
>
|
||||
<Bell :size="15" aria-hidden="true" />
|
||||
<span
|
||||
v-if="unreadCount > 0"
|
||||
class="absolute -top-1 -right-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[11px] font-semibold text-primary-foreground tabular-nums leading-none"
|
||||
>
|
||||
{{ unreadCount > 99 ? '99+' : unreadCount }}
|
||||
</span>
|
||||
</Button>
|
||||
<Button v-if="notifications.length > 0" variant="ghost" size="sm" class="h-7 gap-1.5 text-xs text-muted-foreground" @click="handleClearAll">
|
||||
<Trash2 :size="14" />
|
||||
{{ t('notifications.clear') }}
|
||||
</Button>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
</SheetTrigger>
|
||||
</TooltipTrigger>
|
||||
|
||||
<div class="flex-1 overflow-y-auto min-h-0">
|
||||
<div v-if="notifications.length === 0 && !loading" class="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<BellOff :size="32" class="mb-4 opacity-20" />
|
||||
<p class="text-sm">{{ t('notifications.empty') }}</p>
|
||||
</div>
|
||||
<SheetContent side="right" class="w-[90vw] sm:max-w-md p-0 flex flex-col gap-0">
|
||||
<SheetHeader class="flex flex-row items-center justify-between border-b px-4 py-3 h-14 shrink-0 space-y-0">
|
||||
<div>
|
||||
<SheetTitle class="text-sm font-semibold text-foreground">{{ t('notifications.title') }}</SheetTitle>
|
||||
<SheetDescription class="sr-only">{{ t('notifications.description') }}</SheetDescription>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 pr-8">
|
||||
<Button v-if="unreadCount > 0" variant="ghost" size="sm" class="h-7 gap-1.5 text-xs text-muted-foreground" @click="handleMarkAllRead">
|
||||
<CheckCheck :size="14" />
|
||||
{{ t('notifications.markAllRead') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="notifications.length > 0"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 gap-1.5 text-xs text-muted-foreground"
|
||||
@click="handleClearAll"
|
||||
>
|
||||
<Trash2 :size="14" />
|
||||
{{ t('notifications.clear') }}
|
||||
</Button>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<div v-else class="flex flex-col gap-1.5 px-2 py-2">
|
||||
<NotificationItemVue v-for="item in notifications" :key="item.id" :notification="item" @read="handleMarkAsRead" @dismiss="handleDismiss" />
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto min-h-0">
|
||||
<div v-if="notifications.length === 0 && !loading" class="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<BellOff :size="32" class="mb-4 opacity-20" />
|
||||
<p class="text-sm">{{ t('notifications.empty') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex justify-center py-8">
|
||||
<div class="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-1.5 px-2 py-2">
|
||||
<NotificationItemVue
|
||||
v-for="item in notifications"
|
||||
:key="item.id"
|
||||
:notification="item"
|
||||
@read="handleMarkAsRead"
|
||||
@dismiss="handleDismiss"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="hasMore && !loading" class="p-4">
|
||||
<Button variant="ghost" size="sm" class="w-full text-xs text-muted-foreground border border-dashed" @click="handleLoadMore">
|
||||
{{ t('notifications.loadMore') }}
|
||||
</Button>
|
||||
<div v-if="loading" class="flex justify-center py-8">
|
||||
<div class="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
|
||||
<div v-if="hasMore && !loading" class="p-4">
|
||||
<Button variant="ghost" size="sm" class="w-full text-xs text-muted-foreground border border-dashed" @click="handleLoadMore">
|
||||
{{ t('notifications.loadMore') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<TooltipContent>{{ t('notifications.title') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</template>
|
||||
|
||||
@@ -4535,6 +4535,10 @@
|
||||
"actions": {
|
||||
"read": "Read",
|
||||
"listen": "Listen",
|
||||
"startReading": "Start reading",
|
||||
"continueReading": "Continue reading",
|
||||
"startListening": "Start listening",
|
||||
"continueListening": "Continue listening",
|
||||
"peek": "Peek",
|
||||
"quickView": "Quick View",
|
||||
"details": "Details",
|
||||
@@ -5922,6 +5926,7 @@
|
||||
"unread": "Unread"
|
||||
},
|
||||
"series": {
|
||||
"seriesRow": "Series row",
|
||||
"bookCount": "{count, plural, =0 {(no books)} one {(# book)} other {(# books)}}",
|
||||
"readOfCount": "{read} of {total} read"
|
||||
},
|
||||
@@ -7236,7 +7241,11 @@
|
||||
"sidebar": {
|
||||
"title": "Sidebar",
|
||||
"mobileDescription": "Displays the mobile sidebar.",
|
||||
"toggle": "Toggle Sidebar"
|
||||
"toggle": "Toggle Sidebar",
|
||||
"collapse": "Collapse sidebar",
|
||||
"expand": "Expand sidebar",
|
||||
"open": "Open sidebar",
|
||||
"close": "Close sidebar"
|
||||
},
|
||||
"chipInput": {
|
||||
"typeAndEnter": "Type and press Enter to add",
|
||||
|
||||
Reference in New Issue
Block a user