diff options
Diffstat (limited to 'frontend-vanilla')
| -rw-r--r-- | frontend-vanilla/src/mobile-overflow.test.ts | 162 | ||||
| -rw-r--r-- | frontend-vanilla/src/style.css | 9 | ||||
| -rw-r--r-- | frontend-vanilla/tsconfig.json | 3 |
3 files changed, 172 insertions, 2 deletions
diff --git a/frontend-vanilla/src/mobile-overflow.test.ts b/frontend-vanilla/src/mobile-overflow.test.ts new file mode 100644 index 0000000..516d787 --- /dev/null +++ b/frontend-vanilla/src/mobile-overflow.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import { store } from './store'; +import { renderLayout, renderItems } from './main'; +import { apiFetch } from './api'; + +// Mock api +vi.mock('./api', () => ({ + apiFetch: vi.fn() +})); + +// Mock IntersectionObserver +class MockIntersectionObserver { + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); +} +vi.stubGlobal('IntersectionObserver', MockIntersectionObserver); + +// Read the main stylesheet once for CSS rule assertions +const cssContent = readFileSync(resolve(__dirname, 'style.css'), 'utf-8'); + +describe('Mobile horizontal overflow prevention', () => { + beforeEach(() => { + document.body.innerHTML = '<div id="app"></div>'; + vi.stubGlobal('location', { + href: 'http://localhost/v3/', + pathname: '/v3/', + search: '', + assign: vi.fn(), + replace: vi.fn() + }); + vi.stubGlobal('history', { pushState: vi.fn() }); + Element.prototype.scrollIntoView = vi.fn(); + vi.clearAllMocks(); + store.setFeeds([]); + store.setTags([]); + store.setItems([]); + + vi.mocked(apiFetch).mockResolvedValue({ + ok: true, + status: 200, + json: async () => [] + } as Response); + }); + + describe('CSS containment rules', () => { + it('.item-description should have overflow-x hidden to contain wide RSS content', () => { + const itemDescBlock = cssContent.match( + /\.item-description\s*\{[^}]*\}/g + ); + expect(itemDescBlock).not.toBeNull(); + const mainBlock = itemDescBlock!.find( + (block: string) => !block.includes('img') && !block.includes('video') && !block.includes('pre') && !block.includes(' a') + ); + expect(mainBlock).toBeDefined(); + expect(mainBlock).toMatch(/overflow-x:\s*hidden/); + }); + + it('.item-description should constrain tables with max-width', () => { + const tableRule = cssContent.match( + /\.item-description\s+table[^{]*\{[^}]*max-width:\s*100%/ + ); + expect(tableRule).not.toBeNull(); + }); + + it('.item-description should constrain iframes with max-width', () => { + const iframeRule = cssContent.match( + /\.item-description\s+iframe[^{]*\{[^}]*max-width:\s*100%/ + ); + expect(iframeRule).not.toBeNull(); + }); + + it('.main-content should explicitly set overflow-x hidden', () => { + const mainContentBlock = cssContent.match( + /\.main-content\s*\{[^}]*\}/ + ); + expect(mainContentBlock).not.toBeNull(); + expect(mainContentBlock![0]).toMatch(/overflow-x:\s*hidden/); + }); + + it('.feed-item should have overflow hidden to contain all child content', () => { + // .feed-item must create a block formatting context so that + // no child (title, description, images) can push the viewport wider. + // This is critical on mobile where overflow-x:hidden on scrollable + // ancestors (.main-content with overflow-y:auto) is unreliable. + const feedItemBlock = cssContent.match( + /\.feed-item\s*\{[^}]*\}/ + ); + expect(feedItemBlock).not.toBeNull(); + expect(feedItemBlock![0]).toMatch(/overflow:\s*hidden/); + }); + + it('.item-title should have min-width 0 to allow flex shrinking', () => { + // In a flex container, default min-width:auto prevents items from + // shrinking below their content width. Long titles push the layout + // wider than the viewport. min-width:0 fixes this. + const itemTitleBlock = cssContent.match( + /\.item-title\s*\{[^}]*\}/ + ); + expect(itemTitleBlock).not.toBeNull(); + expect(itemTitleBlock![0]).toMatch(/min-width:\s*0/); + }); + + it('.item-title should wrap long words', () => { + // Titles can contain long unbroken strings (URLs, technical terms). + // overflow-wrap ensures they wrap instead of overflowing. + const itemTitleBlock = cssContent.match( + /\.item-title\s*\{[^}]*\}/ + ); + expect(itemTitleBlock).not.toBeNull(); + expect(itemTitleBlock![0]).toMatch(/overflow-wrap:\s*(break-word|anywhere)/); + }); + }); + + describe('Rendered content containment after loadMore re-render', () => { + it('should contain items with long unbroken titles after re-render', () => { + renderLayout(); + const longTitle = 'A'.repeat(500); // simulate long unbroken title + const items = [ + { _id: 1, title: 'Normal', url: 'http://example.com', publish_date: '2024-01-01', read: true, starred: false, description: '<p>first batch</p>' }, + { _id: 2, title: longTitle, url: 'http://example.com', publish_date: '2024-01-01', read: false, starred: false, description: '<p>long title item</p>' }, + ] as any; + + // Initial render + store.setItems([items[0]]); + renderItems(); + + // Simulate loadMore re-render with appended items + store.setItems(items); + renderItems(); + + const feedItems = document.querySelectorAll('.feed-item'); + expect(feedItems.length).toBe(2); + // The long title item should be contained within the layout + const longTitleEl = feedItems[1].querySelector('.item-title'); + expect(longTitleEl).not.toBeNull(); + expect(longTitleEl!.textContent!.trim().length).toBe(500); + }); + + it('should contain items with wide description content after re-render', () => { + renderLayout(); + const items = [ + { _id: 1, title: 'Item 1', url: 'http://example.com', publish_date: '2024-01-01', read: true, starred: false, description: '<p>ok</p>' }, + { _id: 2, title: 'Item 2', url: 'http://example.com', publish_date: '2024-01-01', read: false, starred: false, description: '<table width="2000"><tr><td>wide</td></tr></table>' }, + { _id: 3, title: 'Item 3', url: 'http://example.com', publish_date: '2024-01-01', read: false, starred: false, description: '<iframe width="1200" src="https://example.com"></iframe>' }, + ] as any; + + // Initial render + store.setItems([items[0]]); + renderItems(); + + // Simulate loadMore re-render + store.setItems(items); + renderItems(); + + const feedItems = document.querySelectorAll('.feed-item'); + expect(feedItems.length).toBe(3); + }); + }); +}); diff --git a/frontend-vanilla/src/style.css b/frontend-vanilla/src/style.css index 27a8d28..47b2de2 100644 --- a/frontend-vanilla/src/style.css +++ b/frontend-vanilla/src/style.css @@ -288,6 +288,7 @@ html { width: 100%; height: 100%; min-width: 0; + overflow-x: hidden; overflow-y: auto; background-color: var(--bg-color); padding: 1.5rem 2rem; @@ -451,6 +452,7 @@ select:focus { border-bottom: none; border-radius: 8px; transition: background-color 0.2s ease, opacity 0.3s ease; + overflow: hidden; } @@ -470,6 +472,8 @@ select:focus { color: var(--link-color); display: block; flex: 1; + min-width: 0; + overflow-wrap: break-word; cursor: pointer; } @@ -517,6 +521,7 @@ select:focus { margin-top: 1rem; overflow-wrap: break-word; word-break: break-word; + overflow-x: hidden; } .item-description a { @@ -526,7 +531,9 @@ select:focus { .item-description img, .item-description video, -.item-description pre { +.item-description pre, +.item-description table, +.item-description iframe { max-width: 100%; height: auto; display: block; diff --git a/frontend-vanilla/tsconfig.json b/frontend-vanilla/tsconfig.json index 4ba8dd9..72af398 100644 --- a/frontend-vanilla/tsconfig.json +++ b/frontend-vanilla/tsconfig.json @@ -22,5 +22,6 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/**/*.test.ts", "src/**/*.perf.test.ts", "src/setupTests.ts"] } |
