From c04cd62a5e7d90d6eb882c9ea8a2c4fd5aae1590 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Feb 2026 14:25:43 +0000 Subject: [PATCH 01/17] test: add Playwright e2e tests for ScPagination component demos https://claude.ai/code/session_01B6kSDVZhWZGycgX3H4QMfg --- .../pagination/basic-pagination-demo.spec.ts | 151 +++++++++++++++ .../buttons-pagination-demo.spec.ts | 98 ++++++++++ .../disabled-link-pagination-demo.spec.ts | 113 +++++++++++ .../ellipsis-pagination-demo.spec.ts | 107 +++++++++++ .../first-page-pagination-demo.spec.ts | 82 ++++++++ ...eyboard-navigation-pagination-demo.spec.ts | 178 ++++++++++++++++++ .../last-page-pagination-demo.spec.ts | 98 ++++++++++ .../many-pages-pagination-demo.spec.ts | 108 +++++++++++ .../page-size-pagination-demo.spec.ts | 108 +++++++++++ 9 files changed, 1043 insertions(+) create mode 100644 apps/showcase-e2e/src/demos/pagination/basic-pagination-demo.spec.ts create mode 100644 apps/showcase-e2e/src/demos/pagination/buttons-pagination-demo.spec.ts create mode 100644 apps/showcase-e2e/src/demos/pagination/disabled-link-pagination-demo.spec.ts create mode 100644 apps/showcase-e2e/src/demos/pagination/ellipsis-pagination-demo.spec.ts create mode 100644 apps/showcase-e2e/src/demos/pagination/first-page-pagination-demo.spec.ts create mode 100644 apps/showcase-e2e/src/demos/pagination/keyboard-navigation-pagination-demo.spec.ts create mode 100644 apps/showcase-e2e/src/demos/pagination/last-page-pagination-demo.spec.ts create mode 100644 apps/showcase-e2e/src/demos/pagination/many-pages-pagination-demo.spec.ts create mode 100644 apps/showcase-e2e/src/demos/pagination/page-size-pagination-demo.spec.ts diff --git a/apps/showcase-e2e/src/demos/pagination/basic-pagination-demo.spec.ts b/apps/showcase-e2e/src/demos/pagination/basic-pagination-demo.spec.ts new file mode 100644 index 00000000..2f9a73e8 --- /dev/null +++ b/apps/showcase-e2e/src/demos/pagination/basic-pagination-demo.spec.ts @@ -0,0 +1,151 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Basic Pagination Demo', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/demos/pagination/basic-pagination-demo'); + }); + + test('should render pagination navigation', async ({ page }) => { + const nav = page.locator('nav[sc-pagination]'); + await expect(nav).toBeVisible(); + await expect(nav).toHaveAttribute('role', 'navigation'); + await expect(nav).toHaveAttribute('aria-label', 'pagination'); + await expect(nav).toHaveAttribute('data-slot', 'pagination'); + }); + + test('should render pagination list', async ({ page }) => { + const list = page.locator('ul[sc-pagination-list]'); + await expect(list).toBeVisible(); + await expect(list).toHaveAttribute('data-slot', 'pagination-list'); + }); + + test('should render Previous and Next buttons', async ({ page }) => { + const previousBtn = page.getByRole('button', { name: 'Previous' }); + await expect(previousBtn).toBeVisible(); + await expect(previousBtn).toHaveAttribute('data-slot', 'pagination-previous'); + + const nextBtn = page.getByRole('button', { name: 'Next' }); + await expect(nextBtn).toBeVisible(); + await expect(nextBtn).toHaveAttribute('data-slot', 'pagination-next'); + }); + + test('should render page link buttons for 3 pages', async ({ page }) => { + const pageLinks = page.locator('button[sc-pagination-link]'); + await expect(pageLinks).toHaveCount(3); + + await expect(pageLinks.nth(0)).toHaveText(/1/); + await expect(pageLinks.nth(1)).toHaveText(/2/); + await expect(pageLinks.nth(2)).toHaveText(/3/); + }); + + test('should have aria-current on the active page', async ({ page }) => { + const activePage = page.locator( + 'button[sc-pagination-link][aria-current="page"]', + ); + await expect(activePage).toHaveCount(1); + await expect(activePage).toHaveText(/1/); + }); + + test('should apply outline variant to active page link', async ({ + page, + }) => { + const activePage = page.locator( + 'button[sc-pagination-link][aria-current="page"]', + ); + await expect(activePage).toHaveClass(/border-border/); + }); + + test('should apply ghost variant to inactive page links', async ({ + page, + }) => { + const inactiveLinks = page.locator( + 'button[sc-pagination-link]:not([aria-current])', + ); + const count = await inactiveLinks.count(); + expect(count).toBeGreaterThan(0); + for (let i = 0; i < count; i++) { + await expect(inactiveLinks.nth(i)).toHaveClass(/hover:bg-muted/); + } + }); + + test('should disable Previous button on first page', async ({ page }) => { + const previousBtn = page.locator('button[sc-pagination-previous]'); + await expect(previousBtn).toHaveAttribute('aria-disabled', 'true'); + }); + + test('should not disable Next button on first page', async ({ page }) => { + const nextBtn = page.locator('button[sc-pagination-next]'); + await expect(nextBtn).not.toHaveAttribute('aria-disabled'); + }); + + test('should navigate to next page when clicking Next', async ({ page }) => { + const nextBtn = page.getByRole('button', { name: 'Next' }); + await nextBtn.click(); + + const activePage = page.locator( + 'button[sc-pagination-link][aria-current="page"]', + ); + await expect(activePage).toHaveText(/2/); + }); + + test('should navigate to a specific page when clicking page link', async ({ + page, + }) => { + const page3Btn = page.locator('button[sc-pagination-link]').nth(2); + await page3Btn.click(); + + const activePage = page.locator( + 'button[sc-pagination-link][aria-current="page"]', + ); + await expect(activePage).toHaveText(/3/); + }); + + test('should disable Next button on last page', async ({ page }) => { + const page3Btn = page.locator('button[sc-pagination-link]').nth(2); + await page3Btn.click(); + + const nextBtn = page.locator('button[sc-pagination-next]'); + await expect(nextBtn).toHaveAttribute('aria-disabled', 'true'); + }); + + test('should enable Previous button after navigating away from first page', async ({ + page, + }) => { + const nextBtn = page.getByRole('button', { name: 'Next' }); + await nextBtn.click(); + + const previousBtn = page.locator('button[sc-pagination-previous]'); + await expect(previousBtn).not.toHaveAttribute('aria-disabled'); + }); + + test('should render SVG icons in Previous and Next buttons', async ({ + page, + }) => { + const previousSvg = page + .locator('button[sc-pagination-previous]') + .locator('svg'); + await expect(previousSvg).toBeVisible(); + + const nextSvg = page.locator('button[sc-pagination-next]').locator('svg'); + await expect(nextSvg).toBeVisible(); + }); + + test('should have data-slot on pagination items', async ({ page }) => { + const items = page.locator('li[sc-pagination-item]'); + const count = await items.count(); + expect(count).toBeGreaterThan(0); + for (let i = 0; i < count; i++) { + await expect(items.nth(i)).toHaveAttribute('data-slot', 'pagination-item'); + } + }); + + test('should be keyboard navigable', async ({ page }) => { + await page.keyboard.press('Tab'); + const previousBtn = page.locator('button[sc-pagination-previous]'); + await expect(previousBtn).toBeFocused(); + + await page.keyboard.press('Tab'); + const firstPageLink = page.locator('button[sc-pagination-link]').first(); + await expect(firstPageLink).toBeFocused(); + }); +}); diff --git a/apps/showcase-e2e/src/demos/pagination/buttons-pagination-demo.spec.ts b/apps/showcase-e2e/src/demos/pagination/buttons-pagination-demo.spec.ts new file mode 100644 index 00000000..3b567329 --- /dev/null +++ b/apps/showcase-e2e/src/demos/pagination/buttons-pagination-demo.spec.ts @@ -0,0 +1,98 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Buttons Pagination Demo', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/demos/pagination/buttons-pagination-demo'); + }); + + test('should render pagination with button elements', async ({ page }) => { + const nav = page.locator('nav[sc-pagination]'); + await expect(nav).toBeVisible(); + + const previousBtn = page.locator('button[sc-pagination-previous]'); + await expect(previousBtn).toBeVisible(); + + const nextBtn = page.locator('button[sc-pagination-next]'); + await expect(nextBtn).toBeVisible(); + }); + + test('should use button elements for page links', async ({ page }) => { + const pageLinks = page.locator('button[sc-pagination-link]'); + const count = await pageLinks.count(); + expect(count).toBeGreaterThan(0); + for (let i = 0; i < count; i++) { + const tagName = await pageLinks.nth(i).evaluate((el) => el.tagName); + expect(tagName).toBe('BUTTON'); + } + }); + + test('should use button elements for Previous and Next', async ({ + page, + }) => { + const previousBtn = page.locator('button[sc-pagination-previous]'); + const tagName = await previousBtn.evaluate((el) => el.tagName); + expect(tagName).toBe('BUTTON'); + + const nextBtn = page.locator('button[sc-pagination-next]'); + const nextTagName = await nextBtn.evaluate((el) => el.tagName); + expect(nextTagName).toBe('BUTTON'); + }); + + test('should not have href attributes on buttons', async ({ page }) => { + const pageLinks = page.locator('button[sc-pagination-link]'); + const count = await pageLinks.count(); + for (let i = 0; i < count; i++) { + await expect(pageLinks.nth(i)).not.toHaveAttribute('href'); + } + }); + + test('should have data-slot attributes', async ({ page }) => { + const pageLinks = page.locator('button[sc-pagination-link]'); + const count = await pageLinks.count(); + for (let i = 0; i < count; i++) { + await expect(pageLinks.nth(i)).toHaveAttribute( + 'data-slot', + 'pagination-link', + ); + } + }); + + test('should render 3 page links for 30 items with pageSize 10', async ({ + page, + }) => { + const pageLinks = page.locator('button[sc-pagination-link]'); + await expect(pageLinks).toHaveCount(3); + }); + + test('should navigate between pages using buttons', async ({ page }) => { + const page2 = page.locator('button[sc-pagination-link]', { + hasText: /^\s*2\s*$/, + }); + await page2.click(); + + const activePage = page.locator( + 'button[sc-pagination-link][aria-current="page"]', + ); + await expect(activePage).toHaveText(/2/); + + const page3 = page.locator('button[sc-pagination-link]', { + hasText: /^\s*3\s*$/, + }); + await page3.click(); + + await expect(activePage).toHaveText(/3/); + }); + + test('should be keyboard accessible', async ({ page }) => { + await page.keyboard.press('Tab'); + const previousBtn = page.locator('button[sc-pagination-previous]'); + await expect(previousBtn).toBeFocused(); + + await page.keyboard.press('Tab'); + const firstLink = page.locator('button[sc-pagination-link]').first(); + await expect(firstLink).toBeFocused(); + + await page.keyboard.press('Enter'); + await expect(firstLink).toHaveAttribute('aria-current', 'page'); + }); +}); diff --git a/apps/showcase-e2e/src/demos/pagination/disabled-link-pagination-demo.spec.ts b/apps/showcase-e2e/src/demos/pagination/disabled-link-pagination-demo.spec.ts new file mode 100644 index 00000000..7d24218b --- /dev/null +++ b/apps/showcase-e2e/src/demos/pagination/disabled-link-pagination-demo.spec.ts @@ -0,0 +1,113 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Disabled Link Pagination Demo', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/demos/pagination/disabled-link-pagination-demo'); + }); + + test('should render pagination with anchor elements', async ({ page }) => { + const nav = page.locator('nav[sc-pagination]'); + await expect(nav).toBeVisible(); + }); + + test('should use anchor elements for page links', async ({ page }) => { + const pageLinks = page.locator('a[sc-pagination-link]'); + const count = await pageLinks.count(); + expect(count).toBeGreaterThan(0); + for (let i = 0; i < count; i++) { + const tagName = await pageLinks.nth(i).evaluate((el) => el.tagName); + expect(tagName).toBe('A'); + } + }); + + test('should use anchor elements for Previous and Next', async ({ + page, + }) => { + const previousLink = page.locator('a[sc-pagination-previous]'); + const tagName = await previousLink.evaluate((el) => el.tagName); + expect(tagName).toBe('A'); + + const nextLink = page.locator('a[sc-pagination-next]'); + const nextTagName = await nextLink.evaluate((el) => el.tagName); + expect(nextTagName).toBe('A'); + }); + + test('should have href attributes on anchor links', async ({ page }) => { + const pageLinks = page.locator('a[sc-pagination-link]'); + const count = await pageLinks.count(); + for (let i = 0; i < count; i++) { + await expect(pageLinks.nth(i)).toHaveAttribute('href', '#'); + } + }); + + test('should have data-slot attributes on page links', async ({ page }) => { + const pageLinks = page.locator('a[sc-pagination-link]'); + const count = await pageLinks.count(); + for (let i = 0; i < count; i++) { + await expect(pageLinks.nth(i)).toHaveAttribute( + 'data-slot', + 'pagination-link', + ); + } + }); + + test('should disable Previous on first page with aria-disabled', async ({ + page, + }) => { + const previousLink = page.locator('a[sc-pagination-previous]'); + await expect(previousLink).toHaveAttribute('aria-disabled', 'true'); + }); + + test('should have href on Previous link even when disabled', async ({ + page, + }) => { + const previousLink = page.locator('a[sc-pagination-previous]'); + await expect(previousLink).toHaveAttribute('href', '#'); + }); + + test('should not disable Next link on first page', async ({ page }) => { + const nextLink = page.locator('a[sc-pagination-next]'); + await expect(nextLink).not.toHaveAttribute('aria-disabled'); + }); + + test('should have aria-current on active page link', async ({ page }) => { + const activePage = page.locator( + 'a[sc-pagination-link][aria-current="page"]', + ); + await expect(activePage).toHaveCount(1); + await expect(activePage).toHaveText(/1/); + }); + + test('should navigate to page via anchor click', async ({ page }) => { + const page2 = page.locator('a[sc-pagination-link]', { + hasText: /^\s*2\s*$/, + }); + await page2.click(); + + const activePage = page.locator( + 'a[sc-pagination-link][aria-current="page"]', + ); + await expect(activePage).toHaveText(/2/); + }); + + test('should disable Next link on last page', async ({ page }) => { + const page3 = page.locator('a[sc-pagination-link]', { + hasText: /^\s*3\s*$/, + }); + await page3.click(); + + const nextLink = page.locator('a[sc-pagination-next]'); + await expect(nextLink).toHaveAttribute('aria-disabled', 'true'); + }); + + test('should have data-slot on Previous and Next', async ({ page }) => { + const previousLink = page.locator('a[sc-pagination-previous]'); + await expect(previousLink).toHaveAttribute( + 'data-slot', + 'pagination-previous', + ); + + const nextLink = page.locator('a[sc-pagination-next]'); + await expect(nextLink).toHaveAttribute('data-slot', 'pagination-next'); + }); +}); diff --git a/apps/showcase-e2e/src/demos/pagination/ellipsis-pagination-demo.spec.ts b/apps/showcase-e2e/src/demos/pagination/ellipsis-pagination-demo.spec.ts new file mode 100644 index 00000000..c7a64a4f --- /dev/null +++ b/apps/showcase-e2e/src/demos/pagination/ellipsis-pagination-demo.spec.ts @@ -0,0 +1,107 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Ellipsis Pagination Demo', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/demos/pagination/ellipsis-pagination-demo'); + }); + + test('should render pagination navigation', async ({ page }) => { + const nav = page.locator('nav[sc-pagination]'); + await expect(nav).toBeVisible(); + await expect(nav).toHaveAttribute('role', 'navigation'); + await expect(nav).toHaveAttribute('aria-label', 'pagination'); + }); + + test('should render exactly 7 page items when on first page', async ({ + page, + }) => { + // 10 pages total (100 items / 10 per page), starting at page 1 + // Pattern: [1, 2, 3, 4, 5, ..., 10] + const pageLinks = page.locator('button[sc-pagination-link]'); + await expect(pageLinks).toHaveCount(6); + + const ellipses = page.locator('span[sc-pagination-ellipsis]'); + await expect(ellipses).toHaveCount(1); + }); + + test('should render ellipsis with aria-hidden', async ({ page }) => { + const ellipsis = page.locator('span[sc-pagination-ellipsis]'); + await expect(ellipsis.first()).toHaveAttribute('aria-hidden', 'true'); + await expect(ellipsis.first()).toHaveAttribute( + 'data-slot', + 'pagination-ellipsis', + ); + }); + + test('should render ellipsis SVG icon', async ({ page }) => { + const ellipsis = page.locator('span[sc-pagination-ellipsis]'); + const svg = ellipsis.first().locator('svg'); + await expect(svg).toBeVisible(); + }); + + test('should have sr-only text in ellipsis for accessibility', async ({ + page, + }) => { + const srOnly = page + .locator('span[sc-pagination-ellipsis]') + .first() + .locator('.sr-only'); + await expect(srOnly).toHaveText('More pages'); + }); + + test('should show both ellipses when navigating to middle page', async ({ + page, + }) => { + // Navigate to page 5 (middle) + const page2 = page.locator('button[sc-pagination-link]', { + hasText: /^\s*2\s*$/, + }); + await page2.click(); + + const nextBtn = page.getByRole('button', { name: 'Next' }); + await nextBtn.click(); + await nextBtn.click(); + await nextBtn.click(); + + // Now on page 5: [1, ..., 4, 5, 6, ..., 10] + const ellipses = page.locator('span[sc-pagination-ellipsis]'); + await expect(ellipses).toHaveCount(2); + }); + + test('should show only left ellipsis when near end', async ({ page }) => { + // Navigate to page 10 (last page) + // Click through several times to get to the end + const nextBtn = page.getByRole('button', { name: 'Next' }); + for (let i = 0; i < 9; i++) { + await nextBtn.click(); + } + + // On page 10: [1, ..., 6, 7, 8, 9, 10] + const ellipses = page.locator('span[sc-pagination-ellipsis]'); + await expect(ellipses).toHaveCount(1); + + // Verify last page is active + const activePage = page.locator( + 'button[sc-pagination-link][aria-current="page"]', + ); + await expect(activePage).toHaveText(/10/); + }); + + test('should always show first and last page numbers', async ({ page }) => { + const pageLinks = page.locator('button[sc-pagination-link]'); + await expect(pageLinks.first()).toHaveText(/1/); + await expect(pageLinks.last()).toHaveText(/10/); + }); + + test('should update active page on click', async ({ page }) => { + const page2 = page.locator('button[sc-pagination-link]', { + hasText: /^\s*2\s*$/, + }); + await page2.click(); + + const activePage = page.locator( + 'button[sc-pagination-link][aria-current="page"]', + ); + await expect(activePage).toHaveText(/2/); + }); +}); diff --git a/apps/showcase-e2e/src/demos/pagination/first-page-pagination-demo.spec.ts b/apps/showcase-e2e/src/demos/pagination/first-page-pagination-demo.spec.ts new file mode 100644 index 00000000..cc60ebb9 --- /dev/null +++ b/apps/showcase-e2e/src/demos/pagination/first-page-pagination-demo.spec.ts @@ -0,0 +1,82 @@ +import { test, expect } from '@playwright/test'; + +test.describe('First Page Pagination Demo', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/demos/pagination/first-page-pagination-demo'); + }); + + test('should render pagination starting on page 1', async ({ page }) => { + const nav = page.locator('nav[sc-pagination]'); + await expect(nav).toBeVisible(); + + const activePage = page.locator( + 'button[sc-pagination-link][aria-current="page"]', + ); + await expect(activePage).toHaveText(/1/); + }); + + test('should have 10 total pages (100 items / 10 per page)', async ({ + page, + }) => { + // On page 1: [1, 2, 3, 4, 5, ..., 10] + const pageLinks = page.locator('button[sc-pagination-link]'); + await expect(pageLinks.last()).toHaveText(/10/); + }); + + test('should disable Previous button on first page', async ({ page }) => { + const previousBtn = page.locator('button[sc-pagination-previous]'); + await expect(previousBtn).toHaveAttribute('aria-disabled', 'true'); + await expect(previousBtn).toHaveAttribute('aria-label', 'Go to previous page'); + }); + + test('should enable Next button on first page', async ({ page }) => { + const nextBtn = page.locator('button[sc-pagination-next]'); + await expect(nextBtn).not.toHaveAttribute('aria-disabled'); + await expect(nextBtn).toHaveAttribute('aria-label', 'Go to next page'); + }); + + test('should show right ellipsis only on first page', async ({ page }) => { + const ellipses = page.locator('span[sc-pagination-ellipsis]'); + await expect(ellipses).toHaveCount(1); + }); + + test('should navigate to page 2 via Next button', async ({ page }) => { + const nextBtn = page.getByRole('button', { name: 'Next' }); + await nextBtn.click(); + + const activePage = page.locator( + 'button[sc-pagination-link][aria-current="page"]', + ); + await expect(activePage).toHaveText(/2/); + }); + + test('should enable Previous after navigating away from page 1', async ({ + page, + }) => { + const nextBtn = page.getByRole('button', { name: 'Next' }); + await nextBtn.click(); + + const previousBtn = page.locator('button[sc-pagination-previous]'); + await expect(previousBtn).not.toHaveAttribute('aria-disabled'); + }); + + test('should navigate back to page 1 via Previous', async ({ page }) => { + const nextBtn = page.getByRole('button', { name: 'Next' }); + await nextBtn.click(); + + const previousBtn = page.getByRole('button', { name: 'Previous' }); + await previousBtn.click(); + + const activePage = page.locator( + 'button[sc-pagination-link][aria-current="page"]', + ); + await expect(activePage).toHaveText(/1/); + }); + + test('should render page items in list items', async ({ page }) => { + const items = page.locator('li[sc-pagination-item]'); + const count = await items.count(); + // Previous + page links + ellipsis + Next = at least 4 + expect(count).toBeGreaterThanOrEqual(4); + }); +}); diff --git a/apps/showcase-e2e/src/demos/pagination/keyboard-navigation-pagination-demo.spec.ts b/apps/showcase-e2e/src/demos/pagination/keyboard-navigation-pagination-demo.spec.ts new file mode 100644 index 00000000..c23ba1b2 --- /dev/null +++ b/apps/showcase-e2e/src/demos/pagination/keyboard-navigation-pagination-demo.spec.ts @@ -0,0 +1,178 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Keyboard Navigation Pagination Demo', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/demos/pagination/keyboard-navigation-pagination-demo'); + }); + + test('should render pagination with First and Last buttons', async ({ + page, + }) => { + const firstBtn = page.locator('button[sc-pagination-first]'); + await expect(firstBtn).toBeVisible(); + await expect(firstBtn).toHaveAttribute('data-slot', 'pagination-first'); + + const lastBtn = page.locator('button[sc-pagination-last]'); + await expect(lastBtn).toBeVisible(); + await expect(lastBtn).toHaveAttribute('data-slot', 'pagination-last'); + }); + + test('should render all navigation buttons', async ({ page }) => { + const firstBtn = page.getByRole('button', { name: 'First' }); + await expect(firstBtn).toBeVisible(); + + const previousBtn = page.getByRole('button', { name: 'Previous' }); + await expect(previousBtn).toBeVisible(); + + const nextBtn = page.getByRole('button', { name: 'Next' }); + await expect(nextBtn).toBeVisible(); + + const lastBtn = page.getByRole('button', { name: 'Last' }); + await expect(lastBtn).toBeVisible(); + }); + + test('should have aria-labels on navigation buttons', async ({ page }) => { + const firstBtn = page.locator('button[sc-pagination-first]'); + await expect(firstBtn).toHaveAttribute('aria-label', 'Go to first page'); + + const previousBtn = page.locator('button[sc-pagination-previous]'); + await expect(previousBtn).toHaveAttribute( + 'aria-label', + 'Go to previous page', + ); + + const nextBtn = page.locator('button[sc-pagination-next]'); + await expect(nextBtn).toHaveAttribute('aria-label', 'Go to next page'); + + const lastBtn = page.locator('button[sc-pagination-last]'); + await expect(lastBtn).toHaveAttribute('aria-label', 'Go to last page'); + }); + + test('should disable First and Previous on first page', async ({ page }) => { + const firstBtn = page.locator('button[sc-pagination-first]'); + await expect(firstBtn).toHaveAttribute('aria-disabled', 'true'); + + const previousBtn = page.locator('button[sc-pagination-previous]'); + await expect(previousBtn).toHaveAttribute('aria-disabled', 'true'); + }); + + test('should not disable Next and Last on first page', async ({ page }) => { + const nextBtn = page.locator('button[sc-pagination-next]'); + await expect(nextBtn).not.toHaveAttribute('aria-disabled'); + + const lastBtn = page.locator('button[sc-pagination-last]'); + await expect(lastBtn).not.toHaveAttribute('aria-disabled'); + }); + + test('should navigate to last page via Last button', async ({ page }) => { + const lastBtn = page.getByRole('button', { name: 'Last' }); + await lastBtn.click(); + + // 250 items / 10 per page = 25 pages + const activePage = page.locator( + 'button[sc-pagination-link][aria-current="page"]', + ); + await expect(activePage).toHaveText(/25/); + }); + + test('should disable Next and Last on last page', async ({ page }) => { + const lastBtn = page.getByRole('button', { name: 'Last' }); + await lastBtn.click(); + + const nextBtnDisabled = page.locator('button[sc-pagination-next]'); + await expect(nextBtnDisabled).toHaveAttribute('aria-disabled', 'true'); + + const lastBtnDisabled = page.locator('button[sc-pagination-last]'); + await expect(lastBtnDisabled).toHaveAttribute('aria-disabled', 'true'); + }); + + test('should navigate to first page via First button', async ({ page }) => { + // Go to a different page first + const nextBtn = page.getByRole('button', { name: 'Next' }); + await nextBtn.click(); + await nextBtn.click(); + + const firstBtn = page.getByRole('button', { name: 'First' }); + await firstBtn.click(); + + const activePage = page.locator( + 'button[sc-pagination-link][aria-current="page"]', + ); + await expect(activePage).toHaveText(/1/); + }); + + test('should render page size selector', async ({ page }) => { + const pageSizeSelector = page.locator('sc-pagination-page-size'); + await expect(pageSizeSelector).toBeVisible(); + + const select = page.locator('sc-pagination-page-size select'); + await expect(select).toBeVisible(); + }); + + test('should display page info text', async ({ page }) => { + const info = page.locator('text=Page 1 of 25 (250 items total)'); + await expect(info).toBeVisible(); + }); + + test('should navigate via keyboard Tab through controls', async ({ + page, + }) => { + // Tab to the page size select first (it's before the pagination list) + const select = page.locator('sc-pagination-page-size select'); + + // Tab into the pagination area + await page.keyboard.press('Tab'); + await expect(select).toBeFocused(); + }); + + test('should activate page link via keyboard Enter', async ({ page }) => { + // Tab to first focusable element and keep tabbing to a page link + const firstBtn = page.locator('button[sc-pagination-first]'); + + // Focus on First button + await firstBtn.focus(); + await expect(firstBtn).toBeFocused(); + + // Tab to Previous + await page.keyboard.press('Tab'); + const previousBtn = page.locator('button[sc-pagination-previous]'); + await expect(previousBtn).toBeFocused(); + + // Tab to first page link + await page.keyboard.press('Tab'); + const firstPageLink = page.locator('button[sc-pagination-link]').first(); + await expect(firstPageLink).toBeFocused(); + }); + + test('should change page size via keyboard', async ({ page }) => { + const select = page.locator('sc-pagination-page-size select'); + await select.focus(); + await select.selectOption('25'); + + // 250 / 25 = 10 pages + const info = page.locator('text=Page 1 of 10 (250 items total)'); + await expect(info).toBeVisible(); + }); + + test('should render keyboard navigation instructions', async ({ page }) => { + const heading = page.locator('text=Keyboard Navigation'); + await expect(heading).toBeVisible(); + + const tabInstruction = page.locator('text=Move between controls'); + await expect(tabInstruction).toBeVisible(); + + const enterInstruction = page.locator('text=Activate button'); + await expect(enterInstruction).toBeVisible(); + }); + + test('should render page link aria-labels', async ({ page }) => { + const pageLinks = page.locator('button[sc-pagination-link]'); + const count = await pageLinks.count(); + for (let i = 0; i < count; i++) { + const ariaLabel = await pageLinks.nth(i).getAttribute('aria-label'); + if (ariaLabel) { + expect(ariaLabel).toMatch(/Go to page \d+/); + } + } + }); +}); diff --git a/apps/showcase-e2e/src/demos/pagination/last-page-pagination-demo.spec.ts b/apps/showcase-e2e/src/demos/pagination/last-page-pagination-demo.spec.ts new file mode 100644 index 00000000..d354bccc --- /dev/null +++ b/apps/showcase-e2e/src/demos/pagination/last-page-pagination-demo.spec.ts @@ -0,0 +1,98 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Last Page Pagination Demo', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/demos/pagination/last-page-pagination-demo'); + }); + + test('should render pagination starting on page 10', async ({ page }) => { + const nav = page.locator('nav[sc-pagination]'); + await expect(nav).toBeVisible(); + + const activePage = page.locator( + 'button[sc-pagination-link][aria-current="page"]', + ); + await expect(activePage).toHaveText(/10/); + }); + + test('should disable Next button on last page', async ({ page }) => { + const nextBtn = page.locator('button[sc-pagination-next]'); + await expect(nextBtn).toHaveAttribute('aria-disabled', 'true'); + }); + + test('should enable Previous button on last page', async ({ page }) => { + const previousBtn = page.locator('button[sc-pagination-previous]'); + await expect(previousBtn).not.toHaveAttribute('aria-disabled'); + }); + + test('should show left ellipsis only on last page', async ({ page }) => { + // On page 10: [1, ..., 6, 7, 8, 9, 10] + const ellipses = page.locator('span[sc-pagination-ellipsis]'); + await expect(ellipses).toHaveCount(1); + }); + + test('should always show page 1 and page 10', async ({ page }) => { + const pageLinks = page.locator('button[sc-pagination-link]'); + await expect(pageLinks.first()).toHaveText(/1/); + await expect(pageLinks.last()).toHaveText(/10/); + }); + + test('should navigate to previous page', async ({ page }) => { + const previousBtn = page.getByRole('button', { name: 'Previous' }); + await previousBtn.click(); + + const activePage = page.locator( + 'button[sc-pagination-link][aria-current="page"]', + ); + await expect(activePage).toHaveText(/9/); + }); + + test('should enable Next after navigating away from last page', async ({ + page, + }) => { + const previousBtn = page.getByRole('button', { name: 'Previous' }); + await previousBtn.click(); + + const nextBtn = page.locator('button[sc-pagination-next]'); + await expect(nextBtn).not.toHaveAttribute('aria-disabled'); + }); + + test('should navigate back to last page via Next', async ({ page }) => { + const previousBtn = page.getByRole('button', { name: 'Previous' }); + await previousBtn.click(); + + const nextBtn = page.getByRole('button', { name: 'Next' }); + await nextBtn.click(); + + const activePage = page.locator( + 'button[sc-pagination-link][aria-current="page"]', + ); + await expect(activePage).toHaveText(/10/); + }); + + test('should navigate to page 1 by clicking the first page link', async ({ + page, + }) => { + const page1 = page.locator('button[sc-pagination-link]').first(); + await page1.click(); + + const activePage = page.locator( + 'button[sc-pagination-link][aria-current="page"]', + ); + await expect(activePage).toHaveText(/1/); + + const previousBtn = page.locator('button[sc-pagination-previous]'); + await expect(previousBtn).toHaveAttribute('aria-disabled', 'true'); + }); + + test('should have proper aria labels on nav buttons', async ({ page }) => { + const previousBtn = page.locator('button[sc-pagination-previous]'); + await expect(previousBtn).toHaveAttribute( + 'aria-label', + 'Go to previous page', + ); + + const nextBtn = page.locator('button[sc-pagination-next]'); + await expect(nextBtn).toHaveAttribute('aria-label', 'Go to next page'); + }); +}); diff --git a/apps/showcase-e2e/src/demos/pagination/many-pages-pagination-demo.spec.ts b/apps/showcase-e2e/src/demos/pagination/many-pages-pagination-demo.spec.ts new file mode 100644 index 00000000..8e7e120d --- /dev/null +++ b/apps/showcase-e2e/src/demos/pagination/many-pages-pagination-demo.spec.ts @@ -0,0 +1,108 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Many Pages Pagination Demo', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/demos/pagination/many-pages-pagination-demo'); + }); + + test('should render pagination navigation', async ({ page }) => { + const nav = page.locator('nav[sc-pagination]'); + await expect(nav).toBeVisible(); + await expect(nav).toHaveAttribute('role', 'navigation'); + }); + + test('should start on page 5 with both ellipses', async ({ page }) => { + // 200 items / 10 per page = 20 pages, starting at page 5 + // Pattern: [1, ..., 4, 5, 6, ..., 20] + const activePage = page.locator( + 'button[sc-pagination-link][aria-current="page"]', + ); + await expect(activePage).toHaveText(/5/); + + const ellipses = page.locator('span[sc-pagination-ellipsis]'); + await expect(ellipses).toHaveCount(2); + }); + + test('should always render exactly 7 total page slots', async ({ page }) => { + // 7 slots = page links + ellipses + const pageLinks = page.locator('button[sc-pagination-link]'); + const ellipses = page.locator('span[sc-pagination-ellipsis]'); + + const pageCount = await pageLinks.count(); + const ellipsisCount = await ellipses.count(); + + expect(pageCount + ellipsisCount).toBe(7); + }); + + test('should show first page (1) and last page (20)', async ({ page }) => { + const pageLinks = page.locator('button[sc-pagination-link]'); + await expect(pageLinks.first()).toHaveText(/1/); + await expect(pageLinks.last()).toHaveText(/20/); + }); + + test('should navigate forward and update active state', async ({ page }) => { + const nextBtn = page.getByRole('button', { name: 'Next' }); + await nextBtn.click(); + + const activePage = page.locator( + 'button[sc-pagination-link][aria-current="page"]', + ); + await expect(activePage).toHaveText(/6/); + }); + + test('should navigate backward and update active state', async ({ + page, + }) => { + const previousBtn = page.getByRole('button', { name: 'Previous' }); + await previousBtn.click(); + + const activePage = page.locator( + 'button[sc-pagination-link][aria-current="page"]', + ); + await expect(activePage).toHaveText(/4/); + }); + + test('should show only right ellipsis near the start', async ({ page }) => { + // Navigate to page 1 + const page1 = page.locator('button[sc-pagination-link]', { + hasText: /^\s*1\s*$/, + }); + await page1.click(); + + // On page 1: [1, 2, 3, 4, 5, ..., 20] + const ellipses = page.locator('span[sc-pagination-ellipsis]'); + await expect(ellipses).toHaveCount(1); + }); + + test('should show only left ellipsis near the end', async ({ page }) => { + // Navigate to last page + const lastPage = page.locator('button[sc-pagination-link]', { + hasText: /^\s*20\s*$/, + }); + await lastPage.click(); + + // On page 20: [1, ..., 16, 17, 18, 19, 20] + const ellipses = page.locator('span[sc-pagination-ellipsis]'); + await expect(ellipses).toHaveCount(1); + }); + + test('should disable Previous when on first page', async ({ page }) => { + const page1 = page.locator('button[sc-pagination-link]', { + hasText: /^\s*1\s*$/, + }); + await page1.click(); + + const previousBtn = page.locator('button[sc-pagination-previous]'); + await expect(previousBtn).toHaveAttribute('aria-disabled', 'true'); + }); + + test('should disable Next when on last page', async ({ page }) => { + const lastPage = page.locator('button[sc-pagination-link]', { + hasText: /^\s*20\s*$/, + }); + await lastPage.click(); + + const nextBtn = page.locator('button[sc-pagination-next]'); + await expect(nextBtn).toHaveAttribute('aria-disabled', 'true'); + }); +}); diff --git a/apps/showcase-e2e/src/demos/pagination/page-size-pagination-demo.spec.ts b/apps/showcase-e2e/src/demos/pagination/page-size-pagination-demo.spec.ts new file mode 100644 index 00000000..6edebed2 --- /dev/null +++ b/apps/showcase-e2e/src/demos/pagination/page-size-pagination-demo.spec.ts @@ -0,0 +1,108 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Page Size Pagination Demo', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/demos/pagination/page-size-pagination-demo'); + }); + + test('should render pagination with page size selector', async ({ + page, + }) => { + const nav = page.locator('nav[sc-pagination]'); + await expect(nav).toBeVisible(); + + const pageSizeSelector = page.locator( + 'sc-pagination-page-size', + ); + await expect(pageSizeSelector).toBeVisible(); + }); + + test('should render page size select element', async ({ page }) => { + const select = page.locator('sc-pagination-page-size select'); + await expect(select).toBeVisible(); + }); + + test('should have all page size options', async ({ page }) => { + const options = page.locator('sc-pagination-page-size select option'); + await expect(options).toHaveCount(4); + await expect(options.nth(0)).toHaveText('10'); + await expect(options.nth(1)).toHaveText('25'); + await expect(options.nth(2)).toHaveText('50'); + await expect(options.nth(3)).toHaveText('100'); + }); + + test('should default to page size 10', async ({ page }) => { + const select = page.locator('sc-pagination-page-size select'); + await expect(select).toHaveValue('10'); + }); + + test('should display page info text', async ({ page }) => { + const info = page.locator('text=Page 1 of 25 (250 items total)'); + await expect(info).toBeVisible(); + }); + + test('should display items per page label', async ({ page }) => { + const label = page.locator('text=Items per page:'); + await expect(label).toBeVisible(); + }); + + test('should change page size and reset to page 1', async ({ page }) => { + // Navigate to page 2 first + const nextBtn = page.getByRole('button', { name: 'Next' }); + await nextBtn.click(); + + // Change page size to 25 + const select = page.locator('sc-pagination-page-size select'); + await select.selectOption('25'); + + // Should reset to page 1 + const activePage = page.locator( + 'button[sc-pagination-link][aria-current="page"]', + ); + await expect(activePage).toHaveText(/1/); + + // Total pages should update: 250 / 25 = 10 + const info = page.locator('text=Page 1 of 10 (250 items total)'); + await expect(info).toBeVisible(); + }); + + test('should update total pages when changing page size to 50', async ({ + page, + }) => { + const select = page.locator('sc-pagination-page-size select'); + await select.selectOption('50'); + + // 250 / 50 = 5 pages + const info = page.locator('text=Page 1 of 5 (250 items total)'); + await expect(info).toBeVisible(); + }); + + test('should update total pages when changing page size to 100', async ({ + page, + }) => { + const select = page.locator('sc-pagination-page-size select'); + await select.selectOption('100'); + + // 250 / 100 = 3 pages + const info = page.locator('text=Page 1 of 3 (250 items total)'); + await expect(info).toBeVisible(); + }); + + test('should have data-slot on page size component', async ({ page }) => { + const pageSizeSelector = page.locator('sc-pagination-page-size'); + await expect(pageSizeSelector).toHaveAttribute( + 'data-slot', + 'pagination-page-size', + ); + }); + + test('should render Previous and Next with page size selector', async ({ + page, + }) => { + const previousBtn = page.locator('button[sc-pagination-previous]'); + await expect(previousBtn).toBeVisible(); + + const nextBtn = page.locator('button[sc-pagination-next]'); + await expect(nextBtn).toBeVisible(); + }); +}); From ca38d31055b1c038c256ad499747d853f03b8ddb Mon Sep 17 00:00:00 2001 From: kgridou <32600911+kgridou@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:41:17 +0100 Subject: [PATCH 02/17] wip --- .../pagination/keyboard-navigation-pagination-demo.spec.ts | 7 +++++++ .../src/lib/components/pagination/pagination-page-size.ts | 1 + 2 files changed, 8 insertions(+) diff --git a/apps/showcase-e2e/src/demos/pagination/keyboard-navigation-pagination-demo.spec.ts b/apps/showcase-e2e/src/demos/pagination/keyboard-navigation-pagination-demo.spec.ts index c23ba1b2..09272ce5 100644 --- a/apps/showcase-e2e/src/demos/pagination/keyboard-navigation-pagination-demo.spec.ts +++ b/apps/showcase-e2e/src/demos/pagination/keyboard-navigation-pagination-demo.spec.ts @@ -114,9 +114,16 @@ test.describe('Keyboard Navigation Pagination Demo', () => { await expect(info).toBeVisible(); }); + // WebKit (Safari) does not focus ` element handles page size options automatically + +#### Accessibility Features + +- ✅ `role="navigation"` with `aria-label="pagination"` on container +- ✅ ARIA labels on all navigation buttons: + - `aria-label="Go to first page"` + - `aria-label="Go to previous page"` + - `aria-label="Go to next page"` + - `aria-label="Go to last page"` + - `aria-label="Go to page {n}"` on page links +- ✅ `aria-current="page"` on active page link +- ✅ `aria-disabled` on disabled navigation buttons +- ✅ Semantic HTML (buttons, anchors, nav, select) +- ✅ Good e2e test coverage in Playwright + +#### Component Architecture + +- Clean separation of concerns (link, previous, next, first, last, page-size) +- Smart pagination logic with ellipsis +- Supports both button and anchor elements +- Proper disabled state computation + +--- + +## 🔴 Critical Issues (WCAG AA Violations) + +### Issue 1: Disabled Buttons Still Focusable and Activatable + +**Severity:** High +**WCAG:** 2.1.1 Keyboard (Level A) +**Effort:** Low + +#### Problem + +Components use `aria-disabled` without the native `disabled` attribute. + +**Current implementation:** + +```typescript +// pagination-previous.ts:21-22 +host: { + '[attr.aria-disabled]': 'disabled() || null', +} +``` + +**What's wrong:** + +- Buttons with `aria-disabled="true"` remain in the tab order +- They can still receive keyboard focus +- They can still be activated with Enter/Space (though onClick prevents action) +- Creates confusing UX: user tabs to disabled button, tries to activate, nothing happens + +**WCAG Failure:** +Per WCAG 2.1.1, disabled controls should not be keyboard-operable. While the `onClick` handler prevents action, the button is still technically operable (it receives and responds to keyboard events). + +#### Solution + +Use the native `disabled` attribute: + +```typescript +// Option A: Use native disabled (recommended) +host: { + '[disabled]': 'disabled()', + // Remove aria-disabled - native disabled provides it automatically +} + +// Option B: If disabled buttons must remain focusable (rare case) +host: { + '[attr.aria-disabled]': 'disabled() || null', + '(keydown)': 'onKeyDown($event)', // Prevent Enter/Space +} + +onKeyDown(event: KeyboardEvent): void { + if (this.disabled() && (event.key === 'Enter' || event.key === ' ')) { + event.preventDefault(); + event.stopPropagation(); + } +} +``` + +**Recommendation:** Use Option A (native disabled). Option B is only needed if there's a specific UX requirement for disabled buttons to remain focusable. + +#### Files to Update + +- `libs/ui/src/lib/components/pagination/pagination-previous.ts` +- `libs/ui/src/lib/components/pagination/pagination-next.ts` +- `libs/ui/src/lib/components/pagination/pagination-first.ts` +- `libs/ui/src/lib/components/pagination/pagination-last.ts` +- `libs/ui/src/lib/components/pagination/pagination-link.ts` + +--- + +### Issue 2: Page Size Select Missing Accessible Label + +**Severity:** High +**WCAG:** 1.3.1 Info and Relationships (Level A), 4.1.2 Name, Role, Value (Level A) +**Effort:** Low + +#### Problem + +The page size ` +``` + +**What's wrong:** + +- The `