diff --git a/backend/Cargo.lock b/backend/Cargo.lock index c34799a..da0d003 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.98.0" +version = "0.99.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 8539c0c..f4d0b44 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.98.0" +version = "0.99.0" edition = "2021" default-run = "mangalord" diff --git a/frontend/e2e/overflow-tooltip.spec.ts b/frontend/e2e/overflow-tooltip.spec.ts new file mode 100644 index 0000000..0256b8d --- /dev/null +++ b/frontend/e2e/overflow-tooltip.spec.ts @@ -0,0 +1,66 @@ +import { test, expect, type Page } from './fixtures'; + +// The overflowTooltip action sets a native `title` on a manga card's title +// only when it's actually truncated. Verified in a real browser (jsdom can't +// lay out, so scrollWidth/clientWidth are meaningless there). + +const LONG_TITLE = + 'An Extraordinarily Long Manga Title That Cannot Possibly Fit Within Two Clamped Lines Of A Narrow Card'; +const SHORT_TITLE = 'Bleach'; + +function manga(id: string, title: string) { + return { + id, + title, + status: 'ongoing', + alt_titles: [], + description: null, + cover_image_path: null, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + authors: [], + genres: [] + }; +} + +async function mockHome(page: Page) { + await page.route('**/api/v1/auth/config', (r) => + r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ self_register_enabled: true, private_mode: false }) }) + ); + await page.route('**/api/v1/auth/me', (r) => + r.fulfill({ status: 401, contentType: 'application/json', body: JSON.stringify({ error: { code: 'unauthenticated', message: 'no' } }) }) + ); + await page.route('**/api/v1/auth/me/preferences', (r) => + r.fulfill({ status: 401, contentType: 'application/json', body: '{}' }) + ); + await page.route('**/api/v1/genres*', (r) => + r.fulfill({ status: 200, contentType: 'application/json', body: '[]' }) + ); + await page.route('**/api/v1/me/read-progress*', (r) => + r.fulfill({ status: 401, contentType: 'application/json', body: JSON.stringify({ error: { code: 'unauthenticated', message: 'no' } }) }) + ); + await page.route('**/api/v1/mangas*', (r) => + r.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + items: [manga('m1', LONG_TITLE), manga('m2', SHORT_TITLE)], + page: { limit: 50, offset: 0, total: 2 } + }) + }) + ); +} + +test('adds a title tooltip to truncated card titles but not to ones that fit', async ({ page }) => { + await mockHome(page); + // Narrow (mobile) grid so the long title clamps and overflows. + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/'); + + const longLink = page.getByRole('link', { name: LONG_TITLE }); + await expect(longLink).toBeVisible(); + await expect(longLink).toHaveAttribute('title', LONG_TITLE); + + const shortLink = page.getByRole('link', { name: SHORT_TITLE }); + await expect(shortLink).not.toHaveAttribute('title'); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ba9a4f1..425dfa2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mangalord-frontend", - "version": "0.98.0", + "version": "0.99.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mangalord-frontend", - "version": "0.98.0", + "version": "0.99.0", "devDependencies": { "@lucide/svelte": "^1.16.0", "@playwright/test": "^1.48.0", diff --git a/frontend/package.json b/frontend/package.json index 69cde4d..ab85d0c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.98.0", + "version": "0.99.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/actions/overflowTooltip.test.ts b/frontend/src/lib/actions/overflowTooltip.test.ts new file mode 100644 index 0000000..7ec325b --- /dev/null +++ b/frontend/src/lib/actions/overflowTooltip.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { isOverflowing, overflowTooltip } from './overflowTooltip'; + +// jsdom doesn't lay out, so scrollWidth/clientWidth are always 0. Stub them +// to model a clipped vs fitting element. +function makeEl(dims: { + scrollWidth?: number; + clientWidth?: number; + scrollHeight?: number; + clientHeight?: number; +}): HTMLElement { + const el = document.createElement('span'); + for (const [k, v] of Object.entries(dims)) { + Object.defineProperty(el, k, { configurable: true, value: v }); + } + document.body.appendChild(el); + return el; +} + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('isOverflowing', () => { + it('is true when content is clipped horizontally', () => { + expect(isOverflowing(makeEl({ scrollWidth: 200, clientWidth: 100 }))).toBe(true); + }); + + it('is true when content is clipped vertically (line-clamp)', () => { + expect( + isOverflowing(makeEl({ scrollWidth: 100, clientWidth: 100, scrollHeight: 80, clientHeight: 40 })) + ).toBe(true); + }); + + it('is false when the content fits', () => { + expect(isOverflowing(makeEl({ scrollWidth: 100, clientWidth: 100 }))).toBe(false); + }); +}); + +describe('overflowTooltip action', () => { + it('sets a title with the text when the element is truncated', () => { + const el = makeEl({ scrollWidth: 200, clientWidth: 100 }); + overflowTooltip(el, 'A very long manga title'); + expect(el.getAttribute('title')).toBe('A very long manga title'); + }); + + it('does not set a title when the text fits', () => { + const el = makeEl({ scrollWidth: 100, clientWidth: 100 }); + overflowTooltip(el, 'Short'); + expect(el.getAttribute('title')).toBeNull(); + }); + + it('updates the title text when the bound value changes', () => { + const el = makeEl({ scrollWidth: 200, clientWidth: 100 }); + const action = overflowTooltip(el, 'First'); + expect(el.getAttribute('title')).toBe('First'); + action.update('Second'); + expect(el.getAttribute('title')).toBe('Second'); + }); +}); diff --git a/frontend/src/lib/actions/overflowTooltip.ts b/frontend/src/lib/actions/overflowTooltip.ts new file mode 100644 index 0000000..4fb7e7a --- /dev/null +++ b/frontend/src/lib/actions/overflowTooltip.ts @@ -0,0 +1,45 @@ +// Svelte action: show the full text in a native `title` tooltip only when +// the element is actually truncated (ellipsis or line-clamp), and keep it in +// sync as the element resizes or the text changes. +// +// Native `title` is deliberate: it's keyboard/touch reachable and zero-dep, +// unlike a hover-only custom popover. Applied to clipped titles across list +// surfaces (MangaCard, BookmarkList, HistoryList). + +/** True when the element's content is clipped horizontally (ellipsis) or + * vertically (line-clamp). A 1px slack absorbs sub-pixel rounding. */ +export function isOverflowing(node: HTMLElement): boolean { + return ( + node.scrollWidth - node.clientWidth > 1 || + node.scrollHeight - node.clientHeight > 1 + ); +} + +export function overflowTooltip(node: HTMLElement, text: string) { + let current = text; + + function sync() { + if (isOverflowing(node)) node.setAttribute('title', current); + else node.removeAttribute('title'); + } + + // Re-check on size changes (viewport resize, font load, layout shifts). + // Guarded for environments without ResizeObserver (SSR / jsdom). + let ro: ResizeObserver | undefined; + if (typeof ResizeObserver !== 'undefined') { + ro = new ResizeObserver(() => sync()); + ro.observe(node); + } + + sync(); + + return { + update(next: string) { + current = next; + sync(); + }, + destroy() { + ro?.disconnect(); + } + }; +} diff --git a/frontend/src/lib/components/BookmarkList.svelte b/frontend/src/lib/components/BookmarkList.svelte index 9218c78..cfa9feb 100644 --- a/frontend/src/lib/components/BookmarkList.svelte +++ b/frontend/src/lib/components/BookmarkList.svelte @@ -1,6 +1,7 @@