diff --git a/news/changelog-1.11.md b/news/changelog-1.11.md index e9da3f5f958..35895e97573 100644 --- a/news/changelog-1.11.md +++ b/news/changelog-1.11.md @@ -11,6 +11,7 @@ All changes included in 1.11: ## Accessibility +- ([#730](https://github.com/quarto-dev/quarto-cli/issues/730)): Fix keyboard navigation of tabsets in HTML documents without Bootstrap (`theme: none`): Tab now moves from the selected tab into its panel instead of selecting the next tab, and the arrow keys switch tabs. - ([#13463](https://github.com/quarto-dev/quarto-cli/issues/13463)): The dark/light mode toggle is now a switch (`button` with `role="switch"`, `aria-checked`, and a localized `aria-label`) instead of a link. - ([#14615](https://github.com/quarto-dev/quarto-cli/issues/14615)): Fix invalid `role="menu"` on the website navbar's collapse toggle button, flagged by axe-core (`aria-allowed-role`) and WAVE (`aria-role-mismatch`). - ([#12116](https://github.com/quarto-dev/quarto-cli/issues/12116), [#4935](https://github.com/quarto-dev/quarto-cli/issues/4935)): Website sidebar section toggles are now ` + +::: {.panel-tabset} + +### Tab A + + + +### Tab B + + + +### Tab C + + + +::: + +::: {style="height: 300vh"} +::: diff --git a/tests/docs/playwright/revealjs/tabset-focus-order.qmd b/tests/docs/playwright/revealjs/tabset-focus-order.qmd new file mode 100644 index 00000000000..b1bd68db25b --- /dev/null +++ b/tests/docs/playwright/revealjs/tabset-focus-order.qmd @@ -0,0 +1,30 @@ +--- +pagetitle: Tabset focus order +format: revealjs +--- + +## Slide 1 + +A [link on slide 1](https://quarto.org). + +## Slide 2 + +Plain slide. + +## Slide 3 + +::: {.panel-tabset} + +### Tab A + +Content A with a [link in tab A](https://quarto.org). + +### Tab B + +Content B with a [link in tab B](https://quarto.org). + +::: + +## Slide 4 + +Plain slide after the tabset. diff --git a/tests/integration/playwright/tests/html-tabsets.spec.ts b/tests/integration/playwright/tests/html-tabsets.spec.ts index 38f5e78a7c2..35dc0479c71 100644 --- a/tests/integration/playwright/tests/html-tabsets.spec.ts +++ b/tests/integration/playwright/tests/html-tabsets.spec.ts @@ -17,4 +17,51 @@ test('Jupyter - Creates working tabsets from for loops', async ({ page }) => { const tab2Content = tabContent.locator('div.tab-pane').nth(1); await expect(tab2Content).toHaveClass(/active/); await expect(tab2Content.locator('img')).toBeVisible(); +}); + +test.describe('Tabby tabsets (theme: none) keyboard navigation', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/html/tabsets/tabby-keyboard.html'); + await expect(page.getByRole('tablist')).toBeAttached(); + }); + + test('Tab moves from the selected tab into its panel (#730)', async ({ page }) => { + await page.getByRole('button', { name: 'Button before the tabset' }).focus(); + await page.keyboard.press('Tab'); + await expect(page.getByRole('tab', { name: 'Tab A' })).toBeFocused(); + await page.keyboard.press('Tab'); + await expect(page.getByRole('button', { name: 'Button in tab A' })).toBeFocused(); + await expect(page.getByRole('tab', { name: 'Tab A' })).toHaveAttribute('aria-selected', 'true'); + }); + + test('Shift+Tab from the panel returns to the selected tab', async ({ page }) => { + await page.getByRole('tab', { name: 'Tab B' }).click(); + await page.getByRole('button', { name: 'Button in tab B' }).focus(); + await page.keyboard.press('Shift+Tab'); + await expect(page.getByRole('tab', { name: 'Tab B' })).toBeFocused(); + }); + + test('Arrow keys move to and select the adjacent tab, wrapping around', async ({ page }) => { + await page.getByRole('tab', { name: 'Tab A' }).focus(); + await page.keyboard.press('ArrowRight'); + const tabB = page.getByRole('tab', { name: 'Tab B' }); + await expect(tabB).toBeFocused(); + await expect(tabB).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByRole('button', { name: 'Button in tab B' })).toBeVisible(); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('ArrowLeft'); + const tabC = page.getByRole('tab', { name: 'Tab C' }); + await expect(tabC).toBeFocused(); + await expect(tabC).toHaveAttribute('aria-selected', 'true'); + }); + + test('Home and End select the first and last tab without scrolling the page', async ({ page }) => { + await page.getByRole('tab', { name: 'Tab A' }).focus(); + const scrollBefore = await page.evaluate(() => window.scrollY); + await page.keyboard.press('End'); + await expect(page.getByRole('tab', { name: 'Tab C' })).toHaveAttribute('aria-selected', 'true'); + expect(await page.evaluate(() => window.scrollY)).toBe(scrollBefore); + await page.keyboard.press('Home'); + await expect(page.getByRole('tab', { name: 'Tab A' })).toHaveAttribute('aria-selected', 'true'); + }); }); \ No newline at end of file diff --git a/tests/integration/playwright/tests/revealjs.spec.ts b/tests/integration/playwright/tests/revealjs.spec.ts index c15f05cfbcf..9b23742fef9 100644 --- a/tests/integration/playwright/tests/revealjs.spec.ts +++ b/tests/integration/playwright/tests/revealjs.spec.ts @@ -82,3 +82,18 @@ test('internal id for links between slides are working', async ({ page }) => { await page.getByRole('link', { name: 'Theorem' }).click(); await page.waitForURL(/theorem$/); }); + +test('Home and End on a focused tabset tab do not change slide', async ({ page }) => { + await page.goto('./revealjs/tabset-focus-order.html#/slide-3'); + // Located by attribute rather than role: if reveal leaves the slide, it marks + // the slide aria-hidden and a role query would fail before the slide check. + const tab = (name: string) => page.locator('[role="tab"]', { hasText: name }); + await expect(tab('Tab A')).toBeAttached(); + await tab('Tab A').focus(); + await page.keyboard.press('End'); + await expect(tab('Tab B')).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator('section.slide.present')).toHaveId('slide-3'); + await page.keyboard.press('Home'); + await expect(tab('Tab A')).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator('section.slide.present')).toHaveId('slide-3'); +});