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:
neon
2026-09-12 09:24:32 +00:00
parent 3aebf90242
commit 7a203a03d7
18 changed files with 678 additions and 211 deletions
+45 -6
View File
@@ -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>
+167 -93
View File
@@ -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()
@@ -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>
+10 -1
View File
@@ -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",