diff --git a/apps/showcase-e2e/src/demos/button/as-link-button-demo.spec.ts b/apps/showcase-e2e/src/demos/button/as-link-button-demo.spec.ts index db5d4ce6..43c79a9f 100644 --- a/apps/showcase-e2e/src/demos/button/as-link-button-demo.spec.ts +++ b/apps/showcase-e2e/src/demos/button/as-link-button-demo.spec.ts @@ -5,13 +5,13 @@ test.describe('As Link Button Demo', () => { await page.goto('/demos/button/as-link-button-demo'); }); - test('should render all three link buttons', async ({ page }) => { - const links = page.locator('a[sc-button]'); - await expect(links).toHaveCount(3); + test('should render all six link buttons', async ({ page }) => { + const links = page.locator('a[sc-link]'); + await expect(links).toHaveCount(6); }); test('should use anchor elements instead of buttons', async ({ page }) => { - const links = page.locator('a[sc-button]'); + const links = page.locator('a[sc-link]'); const count = await links.count(); for (let i = 0; i < count; i++) { const tagName = await links.nth(i).evaluate((el) => el.tagName); @@ -19,8 +19,10 @@ test.describe('As Link Button Demo', () => { } }); - test('should not have type attribute on anchor elements', async ({ page }) => { - const links = page.locator('a[sc-button]'); + test('should not have type attribute on anchor elements', async ({ + page, + }) => { + const links = page.locator('a[sc-link]'); const count = await links.count(); for (let i = 0; i < count; i++) { await expect(links.nth(i)).not.toHaveAttribute('type'); @@ -28,7 +30,7 @@ test.describe('As Link Button Demo', () => { }); test('should have href attributes', async ({ page }) => { - const links = page.locator('a[sc-button]'); + const links = page.locator('a[sc-link]'); const count = await links.count(); for (let i = 0; i < count; i++) { await expect(links.nth(i)).toHaveAttribute('href', '#'); @@ -36,27 +38,45 @@ test.describe('As Link Button Demo', () => { }); test('should render Default Link', async ({ page }) => { - const link = page.getByRole('link', { name: 'Default Link' }); + const link = page.getByRole('link', { name: 'Default' }); await expect(link).toBeVisible(); await expect(link).toHaveClass(/bg-primary/); }); test('should render Outline Link', async ({ page }) => { - const link = page.getByRole('link', { name: 'Outline Link' }); + const link = page.getByRole('link', { name: 'Outline' }); await expect(link).toBeVisible(); await expect(link).toHaveClass(/border-border/); }); + test('should render Secondary Link', async ({ page }) => { + const link = page.getByRole('link', { name: 'Secondary' }); + await expect(link).toBeVisible(); + await expect(link).toHaveClass(/bg-secondary/); + }); + test('should render Ghost Link', async ({ page }) => { - const link = page.getByRole('link', { name: 'Ghost Link' }); + const link = page.getByRole('link', { name: 'Ghost' }); + await expect(link).toBeVisible(); + }); + + test('should render Destructive Link', async ({ page }) => { + const link = page.getByRole('link', { name: 'Destructive' }); + await expect(link).toBeVisible(); + await expect(link).toHaveClass(/text-destructive/); + }); + + test('should render Link variant', async ({ page }) => { + const link = page.getByRole('link', { name: 'Link' }); await expect(link).toBeVisible(); + await expect(link).toHaveClass(/underline-offset-4/); }); test('should have data-slot attribute', async ({ page }) => { - const links = page.locator('a[sc-button]'); + const links = page.locator('a[sc-link]'); const count = await links.count(); for (let i = 0; i < count; i++) { - await expect(links.nth(i)).toHaveAttribute('data-slot', 'button'); + await expect(links.nth(i)).toHaveAttribute('data-slot', 'link'); } }); }); diff --git a/apps/showcase-e2e/src/demos/button/disabled-link-button-demo.spec.ts b/apps/showcase-e2e/src/demos/button/disabled-link-button-demo.spec.ts index 64aa9601..bcbe43bd 100644 --- a/apps/showcase-e2e/src/demos/button/disabled-link-button-demo.spec.ts +++ b/apps/showcase-e2e/src/demos/button/disabled-link-button-demo.spec.ts @@ -6,12 +6,12 @@ test.describe('Disabled Link Button Demo', () => { }); test('should render all four disabled link buttons', async ({ page }) => { - const links = page.locator('a[sc-button]'); + const links = page.locator('a[sc-link]'); await expect(links).toHaveCount(4); }); test('should have aria-disabled on all link buttons', async ({ page }) => { - const links = page.locator('a[sc-button]'); + const links = page.locator('a[sc-link]'); const count = await links.count(); for (let i = 0; i < count; i++) { await expect(links.nth(i)).toHaveAttribute('aria-disabled', 'true'); @@ -43,18 +43,18 @@ test.describe('Disabled Link Button Demo', () => { }); test('should have reduced opacity when disabled', async ({ page }) => { - const links = page.locator('a[sc-button]'); + const links = page.locator('a[sc-link]'); const count = await links.count(); for (let i = 0; i < count; i++) { - const opacity = await links.nth(i).evaluate( - (el) => window.getComputedStyle(el).opacity, - ); + const opacity = await links + .nth(i) + .evaluate((el) => window.getComputedStyle(el).opacity); expect(parseFloat(opacity)).toBeLessThan(1); } }); test('should use anchor elements', async ({ page }) => { - const links = page.locator('a[sc-button]'); + const links = page.locator('a[sc-link]'); const count = await links.count(); for (let i = 0; i < count; i++) { const tagName = await links.nth(i).evaluate((el) => el.tagName); @@ -63,10 +63,10 @@ test.describe('Disabled Link Button Demo', () => { }); test('should have data-slot attribute', async ({ page }) => { - const links = page.locator('a[sc-button]'); + const links = page.locator('a[sc-link]'); const count = await links.count(); for (let i = 0; i < count; i++) { - await expect(links.nth(i)).toHaveAttribute('data-slot', 'button'); + await expect(links.nth(i)).toHaveAttribute('data-slot', 'link'); } }); }); 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..1aba9e89 --- /dev/null +++ b/apps/showcase-e2e/src/demos/pagination/keyboard-navigation-pagination-demo.spec.ts @@ -0,0 +1,183 @@ +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( + 'select[sc-pagination-page-size-select]', + ); + await expect(pageSizeSelector).toBeVisible(); + + const select = page.locator('select[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(); + }); + + // WebKit (Safari) does not focus + @for (size of pagination.pageSizes(); track size) { + + } + +

Page {{ currentPage() }} of {{ pagination.totalPages() }} ({{ diff --git a/apps/showcase/src/app/pages/docs/pagination/demos/page-size-pagination-demo-container.ts b/apps/showcase/src/app/pages/docs/pagination/demos/page-size-pagination-demo-container.ts index 431f7537..2edd9087 100644 --- a/apps/showcase/src/app/pages/docs/pagination/demos/page-size-pagination-demo-container.ts +++ b/apps/showcase/src/app/pages/docs/pagination/demos/page-size-pagination-demo-container.ts @@ -37,7 +37,7 @@ import { ScPaginationLink, ScPaginationList, ScPaginationNext, - ScPaginationPageSize, + ScPaginationPageSizeSelect, ScPaginationPrevious, } from '@semantic-components/ui'; import { @@ -56,7 +56,7 @@ import { ScPaginationPrevious, ScPaginationNext, ScPaginationEllipsis, - ScPaginationPageSize, + ScPaginationPageSizeSelect, SiChevronLeftIcon, SiChevronRightIcon, SiEllipsisIcon, @@ -75,7 +75,7 @@ import {

Items per page: - +

diff --git a/apps/showcase/src/app/pages/docs/pagination/demos/page-size-pagination-demo.ts b/apps/showcase/src/app/pages/docs/pagination/demos/page-size-pagination-demo.ts index ab1ac427..fa7e3c57 100644 --- a/apps/showcase/src/app/pages/docs/pagination/demos/page-size-pagination-demo.ts +++ b/apps/showcase/src/app/pages/docs/pagination/demos/page-size-pagination-demo.ts @@ -5,6 +5,8 @@ import { ViewEncapsulation, } from '@angular/core'; import { + ScField, + ScLabel, ScPagination, ScPaginationChange, ScPaginationEllipsis, @@ -12,7 +14,7 @@ import { ScPaginationLink, ScPaginationList, ScPaginationNext, - ScPaginationPageSize, + ScPaginationPageSizeSelect, ScPaginationPrevious, } from '@semantic-components/ui'; import { @@ -24,6 +26,8 @@ import { @Component({ selector: 'app-page-size-pagination-demo', imports: [ + ScField, + ScLabel, ScPagination, ScPaginationList, ScPaginationItem, @@ -31,7 +35,7 @@ import { ScPaginationPrevious, ScPaginationNext, ScPaginationEllipsis, - ScPaginationPageSize, + ScPaginationPageSizeSelect, SiChevronLeftIcon, SiChevronRightIcon, SiEllipsisIcon, @@ -48,9 +52,15 @@ import { (change)="onPaginationChange($event)" >

-
- Items per page: - +
+ +

diff --git a/libs/ui/src/lib/components/button/button.ts b/libs/ui/src/lib/components/button/button.ts index ba42a39d..f383e1aa 100644 --- a/libs/ui/src/lib/components/button/button.ts +++ b/libs/ui/src/lib/components/button/button.ts @@ -1,11 +1,4 @@ -import { - booleanAttribute, - computed, - Directive, - ElementRef, - inject, - input, -} from '@angular/core'; +import { booleanAttribute, computed, Directive, input } from '@angular/core'; import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from '../../utils'; @@ -49,35 +42,24 @@ export const buttonVariants = cva( export type ScButtonVariants = VariantProps; @Directive({ - selector: 'button[sc-button], a[sc-button]', + selector: 'button[sc-button]', host: { 'data-slot': 'button', - '[attr.type]': 'isButton() ? type() : null', - '[attr.href]': 'isAnchor() ? href() : null', + '[attr.type]': 'type()', + '[disabled]': 'disabled()', '[attr.aria-disabled]': 'disabled() || null', '[class]': 'class()', }, }) export class ScButton { - private readonly elementRef = inject(ElementRef); - readonly classInput = input('', { alias: 'class' }); readonly variant = input('default'); readonly size = input('default'); readonly type = input<'button' | 'submit' | 'reset'>('button'); - readonly href = input('#'); readonly disabled = input(false, { transform: booleanAttribute, }); - protected readonly isButton = computed( - () => this.elementRef.nativeElement.tagName === 'BUTTON', - ); - - protected readonly isAnchor = computed( - () => this.elementRef.nativeElement.tagName === 'A', - ); - protected readonly class = computed(() => cn( buttonVariants({ variant: this.variant(), size: this.size() }), diff --git a/libs/ui/src/lib/components/checkbox/checkbox-field.ts b/libs/ui/src/lib/components/checkbox/checkbox-field.ts index b7a57ba1..8976e355 100644 --- a/libs/ui/src/lib/components/checkbox/checkbox-field.ts +++ b/libs/ui/src/lib/components/checkbox/checkbox-field.ts @@ -4,6 +4,7 @@ import { Component, computed, contentChild, + ElementRef, inject, input, ViewEncapsulation, @@ -22,6 +23,7 @@ import { ScVisualCheckbox } from './visual-checkbox'; { provide: SC_FIELD, useExisting: ScCheckboxField }, ], host: { + '[attr.role]': 'role()', 'data-slot': 'checkbox-field', '[class]': 'class()', '[attr.data-state]': 'dataState()', @@ -36,12 +38,18 @@ import { ScVisualCheckbox } from './visual-checkbox'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class ScCheckboxField implements ScCheckboxContext { + private readonly elementRef = inject(ElementRef); private readonly checkbox = contentChild(ScCheckbox); readonly classInput = input('', { alias: 'class' }); readonly id = input(inject(_IdGenerator).getId('sc-checkbox-field-')); + protected readonly role = computed(() => { + const tagName = this.elementRef.nativeElement.tagName; + return tagName === 'LABEL' ? null : 'group'; + }); + // Computed state from input (implements ScCheckboxContext) // These read directly from the ScCheckbox's signals readonly checked = computed(() => this.checkbox()?.checked() ?? false); diff --git a/libs/ui/src/lib/components/field/field.ts b/libs/ui/src/lib/components/field/field.ts index 948ce29a..40ed9d61 100644 --- a/libs/ui/src/lib/components/field/field.ts +++ b/libs/ui/src/lib/components/field/field.ts @@ -3,6 +3,7 @@ import { computed, contentChild, Directive, + ElementRef, inject, InjectionToken, input, @@ -38,9 +39,9 @@ export interface ScFieldContext { export const SC_FIELD = new InjectionToken('SC_FIELD'); @Directive({ - selector: '[sc-field]', + selector: 'div[sc-field], label[sc-field]', host: { - role: 'group', + '[attr.role]': 'role()', 'data-slot': 'field', '[attr.id]': 'id()', '[attr.data-orientation]': 'orientation()', @@ -56,6 +57,8 @@ export const SC_FIELD = new InjectionToken('SC_FIELD'); ], }) export class ScField implements ScFieldContext { + private readonly elementRef = inject(ElementRef); + readonly id = input(inject(_IdGenerator).getId('sc-field-')); readonly classInput = input('', { alias: 'class' }); readonly orientation = input('vertical'); @@ -64,6 +67,12 @@ export class ScField implements ScFieldContext { private readonly formFieldDirective = contentChild(FormField); + protected readonly role = computed(() => { + // Only LABEL preserves native semantics, DIV gets role="group" + const tagName = this.elementRef.nativeElement.tagName; + return tagName === 'LABEL' ? null : 'group'; + }); + readonly invalid = computed(() => { if (this.invalidInput()) return true; return this.formFieldDirective()?.state().invalid() ?? false; diff --git a/libs/ui/src/lib/components/index.ts b/libs/ui/src/lib/components/index.ts index d7445c45..ec7aa177 100644 --- a/libs/ui/src/lib/components/index.ts +++ b/libs/ui/src/lib/components/index.ts @@ -12,6 +12,7 @@ export * from './barcode-scanner'; export * from './breadcrumb'; export * from './button'; export * from './button-group'; +export * from './link'; export * from './calendar'; export * from './card'; export * from './chart'; diff --git a/libs/ui/src/lib/components/link/index.ts b/libs/ui/src/lib/components/link/index.ts new file mode 100644 index 00000000..e33728e0 --- /dev/null +++ b/libs/ui/src/lib/components/link/index.ts @@ -0,0 +1 @@ +export * from './link'; diff --git a/libs/ui/src/lib/components/link/link.ts b/libs/ui/src/lib/components/link/link.ts new file mode 100644 index 00000000..caecb1cc --- /dev/null +++ b/libs/ui/src/lib/components/link/link.ts @@ -0,0 +1,31 @@ +import { booleanAttribute, computed, Directive, input } from '@angular/core'; +import { cn } from '../../utils'; +import { buttonVariants, type ScButtonVariants } from '../button'; + +@Directive({ + selector: 'a[sc-link]', + host: { + 'data-slot': 'link', + '[attr.href]': '!disabled() ? href() : null', + '[attr.role]': 'disabled() ? "link" : null', + '[attr.aria-disabled]': 'disabled() || null', + '[attr.tabindex]': 'disabled() ? -1 : null', + '[class]': 'class()', + }, +}) +export class ScLink { + readonly classInput = input('', { alias: 'class' }); + readonly variant = input('default'); + readonly size = input('default'); + readonly href = input('#'); + readonly disabled = input(false, { + transform: booleanAttribute, + }); + + protected readonly class = computed(() => + cn( + buttonVariants({ variant: this.variant(), size: this.size() }), + this.classInput(), + ), + ); +} diff --git a/libs/ui/src/lib/components/number-field/number-field.ts b/libs/ui/src/lib/components/number-field/number-field.ts index f79c32d3..dab96676 100644 --- a/libs/ui/src/lib/components/number-field/number-field.ts +++ b/libs/ui/src/lib/components/number-field/number-field.ts @@ -2,6 +2,7 @@ import { _IdGenerator } from '@angular/cdk/a11y'; import { computed, Directive, + ElementRef, inject, InjectionToken, input, @@ -16,19 +17,27 @@ export const SC_NUMBER_FIELD = new InjectionToken( ); @Directive({ - selector: '[sc-number-field]', + selector: 'div[sc-number-field], label[sc-number-field]', exportAs: 'scNumberField', providers: [ { provide: SC_NUMBER_FIELD, useExisting: ScNumberField }, { provide: SC_FIELD, useExisting: ScNumberField }, ], host: { + '[attr.role]': 'role()', 'data-slot': 'number-field', '[attr.data-disabled]': 'disabled() || null', }, }) export class ScNumberField { + private readonly elementRef = inject(ElementRef); + readonly id = input(inject(_IdGenerator).getId('sc-number-field-')); + + protected readonly role = computed(() => { + const tagName = this.elementRef.nativeElement.tagName; + return tagName === 'LABEL' ? null : 'group'; + }); readonly value = model(null); readonly min = input(null); readonly max = input(null); diff --git a/libs/ui/src/lib/components/opt-field/opt-field.ts b/libs/ui/src/lib/components/opt-field/opt-field.ts index 121fde95..f367595b 100644 --- a/libs/ui/src/lib/components/opt-field/opt-field.ts +++ b/libs/ui/src/lib/components/opt-field/opt-field.ts @@ -4,6 +4,7 @@ import { contentChildren, Directive, effect, + ElementRef, inject, input, model, @@ -13,16 +14,24 @@ import { SC_FIELD } from '../field'; import { ScOptFieldSlot } from './opt-field-slot'; @Directive({ - selector: 'div[sc-opt-field]', + selector: 'div[sc-opt-field], label[sc-opt-field]', providers: [{ provide: SC_FIELD, useExisting: ScOptField }], host: { + '[attr.role]': 'role()', 'data-slot': 'opt-field', '[class]': 'class()', '(paste)': 'onPaste($event)', }, }) export class ScOptField { + private readonly elementRef = inject(ElementRef); + readonly id = input(inject(_IdGenerator).getId('sc-opt-field-')); + + protected readonly role = computed(() => { + const tagName = this.elementRef.nativeElement.tagName; + return tagName === 'LABEL' ? null : 'group'; + }); readonly classInput = input('', { alias: 'class' }); readonly value = model(''); readonly disabled = input(false); @@ -86,8 +95,9 @@ export class ScOptField { } } - protected onPaste(event: ClipboardEvent): void { + protected onPaste(event: Event): void { if (this.disabled()) return; + if (!(event instanceof ClipboardEvent)) return; event.preventDefault(); const pastedData = event.clipboardData?.getData('text') || ''; diff --git a/libs/ui/src/lib/components/pagination/ACCESSIBILITY_REVIEW.md b/libs/ui/src/lib/components/pagination/ACCESSIBILITY_REVIEW.md new file mode 100644 index 00000000..b6a4cb3c --- /dev/null +++ b/libs/ui/src/lib/components/pagination/ACCESSIBILITY_REVIEW.md @@ -0,0 +1,530 @@ +# Pagination Component - Accessibility & Keyboard Navigation Review + +**Date:** 2026-02-12 +**Reviewer:** Claude +**Status:** Needs fixes for WCAG AA compliance + +--- + +## Executive Summary + +The pagination component has a solid foundation with good ARIA practices, but requires **3 critical fixes** to meet WCAG AA standards. All issues are low-effort, high-impact changes. + +--- + +## Current Implementation + +### ✅ What's Working Well + +#### Keyboard Navigation (Basic) + +- **Tab** - Native browser behavior works correctly through all interactive elements +- **Enter/Space** - Native button activation (handled by browser) +- **↑ ↓** - Native `` element has no programmatically associated label. + +**Current implementation:** + +```html + + +``` + +```typescript +// pagination-page-size.ts:19-23 +` doesn't have an `id` attribute +- They're not programmatically connected +- Screen readers won't announce "Items per page" when the select is focused +- Violates WCAG requirement that form inputs have accessible names + +#### Solution + +**Option A: Add aria-label to select (simplest)** + +```typescript +// pagination-page-size.ts + +``` + +```html + + + +``` + +**Recommendation:** Use Option A (aria-label). It's simpler and doesn't require consumer template changes. Option B is more semantic but requires the consumer to connect the label manually. + +#### File to Update + +- `libs/ui/src/lib/components/pagination/pagination-page-size.ts` + +--- + +### Issue 3: No Live Region for Page Changes + +**Severity:** Medium +**WCAG:** 4.1.3 Status Messages (Level AA) +**Effort:** Low + +#### Problem + +When users navigate to a different page, screen reader users receive no feedback that the page has changed. + +**What's wrong:** + +- Clicking a page button updates the visual state +- `aria-current="page"` updates on the new active button +- BUT screen readers don't announce the change unless the user navigates back to the pagination controls +- Users don't know if their action succeeded +- Particularly problematic for dynamic content updates + +#### Solution + +Add an `aria-live` region that announces page changes: + +```typescript +// pagination.ts +@Directive({ + selector: 'nav[sc-pagination]', + host: { + // ... existing bindings + }, + template: ` +

+ Page {{ currentPage() }} of {{ totalPages() }} +
+ `, +}) +``` + +**However**, `@Directive` doesn't support templates. Solutions: + +**Option A: Make ScPagination a Component** + +```typescript +@Component({ + selector: 'nav[sc-pagination]', + template: ` +
+ Page {{ currentPage() }} of {{ totalPages() }} +
+ + `, +}) +``` + +**Option B: Consumer adds live region** + +```html + + +``` + +**Option C: Dedicated component** + +```typescript +@Component({ + selector: 'sc-pagination-live-region', + template: ` +
+ {{ message() }} +
+ `, +}) +export class ScPaginationLiveRegion { + private readonly pagination = inject(ScPagination); + + protected readonly message = computed(() => `Page ${this.pagination.currentPage()} of ${this.pagination.totalPages()}`); +} +``` + +**Recommendation:** Use Option C. It's composable, doesn't break existing templates, and keeps the live region logic separate. + +#### Files to Update + +- Create: `libs/ui/src/lib/components/pagination/pagination-live-region.ts` +- Update: `libs/ui/src/lib/components/pagination/index.ts` (export new component) +- Update: Demo templates to include `` + +--- + +## 🟡 Enhancement Opportunities (Not Required for WCAG) + +### Enhancement 1: Arrow Key Navigation + +**Severity:** Low +**Standard:** Common UX pattern (not required by WCAG) +**Effort:** Medium + +#### Proposal + +Add ← → arrow key support when focused on pagination buttons. + +**Expected behavior:** + +- Focus on any pagination button +- Press → to go to next page (same as clicking Next) +- Press ← to go to previous page (same as clicking Previous) +- Respects disabled states (can't go beyond first/last page) + +#### Implementation + +```typescript +// Add to ScPagination directive +host: { + // ... existing + '(keydown.ArrowLeft)': 'onArrowLeft($event)', + '(keydown.ArrowRight)': 'onArrowRight($event)', + '[attr.tabindex]': '0', // Make nav itself focusable +} + +onArrowLeft(event: KeyboardEvent): void { + if (this.currentPage() > 1) { + event.preventDefault(); + this.goToPage(this.currentPage() - 1); + } +} + +onArrowRight(event: KeyboardEvent): void { + if (this.currentPage() < this.totalPages()) { + event.preventDefault(); + this.goToPage(this.currentPage() + 1); + } +} +``` + +**Alternative:** Only handle arrow keys when focus is on Previous/Next buttons (more conservative). + +#### Discussion Points + +- Should arrow keys work when nav is focused, or only when buttons are focused? +- Should arrow keys skip the button tab order and jump pages directly? +- Does this conflict with any other keyboard patterns? + +--- + +### Enhancement 2: Home/End Key Support + +**Severity:** Low +**Standard:** Common UX pattern (not required by WCAG) +**Effort:** Low + +#### Proposal + +Add Home/End key support to jump to first/last page. + +**Expected behavior:** + +- Focus on pagination +- Press Home → Go to page 1 +- Press End → Go to last page + +#### Implementation + +```typescript +// Add to ScPagination directive +host: { + // ... existing + '(keydown.Home)': 'onHome($event)', + '(keydown.End)': 'onEnd($event)', +} + +onHome(event: KeyboardEvent): void { + event.preventDefault(); + this.goToPage(1); +} + +onEnd(event: KeyboardEvent): void { + event.preventDefault(); + this.goToPage(this.totalPages()); +} +``` + +--- + +### Enhancement 3: Focus Management After Page Change + +**Severity:** Low +**Standard:** Best practice (not required by WCAG) +**Effort:** Medium + +#### Current Behavior + +When a user clicks a page button, the page changes but focus behavior is unclear. + +#### Proposal Options + +**Option A: Keep focus on the same button** + +- If user clicks "Next", focus stays on "Next" button after page change +- Pros: Predictable, easy to navigate through pages quickly +- Cons: If Next button becomes disabled (last page), focus is lost + +**Option B: Move focus to the new active page link** + +- If user clicks "Next" from page 2→3, focus moves to the "3" button +- Pros: Announces current page, clear state +- Cons: Harder to navigate quickly through pages + +**Option C: Move focus to content area** + +- After page change, focus moves to `#content` or first heading +- Pros: Screen reader users can immediately read new content +- Cons: Requires consumer to mark content area, harder to navigate back + +**Recommendation:** Option A for keyboard navigation. Let browser handle focus naturally. If a button becomes disabled, browser will move focus to the next focusable element. + +--- + +## 📋 Implementation Checklist + +### Phase 1: Critical Fixes (Required for WCAG AA) + +- [ ] **Issue 1:** Replace `aria-disabled` with native `disabled` attribute + - [ ] Update `pagination-previous.ts` + - [ ] Update `pagination-next.ts` + - [ ] Update `pagination-first.ts` + - [ ] Update `pagination-last.ts` + - [ ] Update `pagination-link.ts` + - [ ] Update e2e tests (disabled buttons should not be focusable) + +- [ ] **Issue 2:** Add accessible label to page size select + - [ ] Update `pagination-page-size.ts` with `aria-label` + - [ ] Update demo templates if needed + - [ ] Add e2e test for aria-label + +- [ ] **Issue 3:** Add live region for page changes + - [ ] Create `pagination-live-region.ts` component + - [ ] Export from `index.ts` + - [ ] Update demo templates to include live region + - [ ] Add e2e test for live region announcements + +### Phase 2: Enhancements (Optional) + +- [ ] **Enhancement 1:** Arrow key navigation + - [ ] Implement ← → handlers + - [ ] Add e2e tests + - [ ] Update keyboard navigation demo + +- [ ] **Enhancement 2:** Home/End key support + - [ ] Implement Home/End handlers + - [ ] Add e2e tests + - [ ] Update keyboard navigation demo + +- [ ] **Enhancement 3:** Focus management + - [ ] Decide on focus strategy + - [ ] Implement focus management + - [ ] Add e2e tests + +--- + +## Testing Strategy + +### Automated Tests (Playwright) + +- [ ] Test that disabled buttons are not focusable via Tab +- [ ] Test that disabled buttons cannot be activated with Enter/Space +- [ ] Test that page size select has accessible name (via aria-label or label[for]) +- [ ] Test that live region updates when page changes +- [ ] Test arrow key navigation (if implemented) +- [ ] Test Home/End keys (if implemented) + +### Manual Tests (Screen Reader) + +- [ ] NVDA (Windows) + Firefox +- [ ] JAWS (Windows) + Chrome +- [ ] VoiceOver (macOS) + Safari +- [ ] TalkBack (Android) + Chrome + +**Test scenarios:** + +1. Navigate pagination with Tab key +2. Activate buttons with Enter and Space +3. Verify disabled buttons are skipped +4. Verify page changes are announced +5. Verify page size select is properly labeled +6. Navigate page size options with arrow keys + +### Axe DevTools + +- [ ] Run axe on all pagination demos +- [ ] Verify no violations +- [ ] Document any intentional warnings/incomplete findings + +--- + +## Open Questions + +1. **Disabled button behavior:** Should disabled buttons be completely unfocusable (native `disabled`), or should they remain focusable for screen reader discovery? + - **Recommendation:** Unfocusable (native disabled). This is standard web behavior. + +2. **Live region verbosity:** Should the live region announce "Page 3 of 10" or just "Page 3"? + - **Recommendation:** Include "of {total}" for context. + +3. **Arrow key scope:** Should arrow keys work when the `