From cd345368891367518a941305e7199e3f9da8d557 Mon Sep 17 00:00:00 2001 From: Charlotte Wickham Date: Thu, 22 Oct 2026 10:05:10 -0700 Subject: [PATCH 01/17] Use one two-color focus ring for all focusable elements in Bootstrap HTML Replace the four focus indicators (browser ring, Bootstrap's translucent box-shadow halo, the sidebar toggle outline, and the search box's 1px shadow) with one ring after W3C technique C40: a 2px outline in the outer color, with the inner color as a box-shadow in the 2px offset. - New Sass variables $focus-ring-outer-color and $focus-ring-inner-color. When the outer color is derived from $primary-text-emphasis, it is darkened to 9:1 against the inner color. An explicit color is used as is. - accessibleContrast() accepts a numeric ratio, and "A" now gives 3:1. - Bootstrap's focus halos are turned off at the source. - The search box draws the ring on its form; pagination gets a (0,4,0) rule because vapor and zephyr replace its box-shadow. - The TOC sidebars get padding so they no longer clip the ring, and the secondary nav paints above the navbar so the ring's top edge shows. --- .../html/bootstrap/_bootstrap-functions.scss | 9 +++-- .../html/bootstrap/_bootstrap-mixins.scss | 24 ++++++++++++ .../html/bootstrap/_bootstrap-rules.scss | 39 ++++++++++++++----- .../html/bootstrap/_bootstrap-variables.scss | 15 +++++++ .../website/navigation/quarto-nav.scss | 11 ++---- .../website/search/quarto-search.scss | 29 ++++++-------- .../.gitignore | 0 .../_quarto.yml | 0 .../about.qmd | 0 .../index.qmd | 0 ...n.spec.ts => html-focus-indicator.spec.ts} | 0 11 files changed, 89 insertions(+), 38 deletions(-) rename tests/docs/playwright/website/{bare-btn-focus => focus-indicator}/.gitignore (100%) rename tests/docs/playwright/website/{bare-btn-focus => focus-indicator}/_quarto.yml (100%) rename tests/docs/playwright/website/{bare-btn-focus => focus-indicator}/about.qmd (100%) rename tests/docs/playwright/website/{bare-btn-focus => focus-indicator}/index.qmd (100%) rename tests/integration/playwright/tests/{html-focus-indicator-bare-btn.spec.ts => html-focus-indicator.spec.ts} (100%) diff --git a/src/resources/formats/html/bootstrap/_bootstrap-functions.scss b/src/resources/formats/html/bootstrap/_bootstrap-functions.scss index 05d1780c203..51158bf061e 100644 --- a/src/resources/formats/html/bootstrap/_bootstrap-functions.scss +++ b/src/resources/formats/html/bootstrap/_bootstrap-functions.scss @@ -29,10 +29,13 @@ // A: 3:1 // AA: 4.5:1 // AAA: 7:1 - $goalContrastRatio: 3; - @if $level == "AA" { + // A number: that ratio (e.g. 9 for the two tones of the focus ring) + $goalContrastRatio: $level; + @if $level == "A" { + $goalContrastRatio: 3; + } @else if $level == "AA" { $goalContrastRatio: 4.5; - } @else { + } @else if $level == "AAA" { $goalContrastRatio: 7; } diff --git a/src/resources/formats/html/bootstrap/_bootstrap-mixins.scss b/src/resources/formats/html/bootstrap/_bootstrap-mixins.scss index 7869edf77ee..f4db1566a55 100644 --- a/src/resources/formats/html/bootstrap/_bootstrap-mixins.scss +++ b/src/resources/formats/html/bootstrap/_bootstrap-mixins.scss @@ -1,3 +1,27 @@ +// Keyboard focus ring (WCAG 2.4.7, 1.4.11), after W3C technique C40: two +// tones at least 9:1 apart, so one of them reaches 3:1 against any solid +// background. The outer tone is the outline, which theme rules rarely +// override and forced-colors mode keeps; the inner tone is a box-shadow that +// fills the outline offset. Resolved here, after all defaults, so that +// $primary-text-emphasis exists and every rules layer can use the result. +$focus-ring-inner: if($focus-ring-inner-color, $focus-ring-inner-color, $white); +$focus-ring-outer: $focus-ring-outer-color; +@if not $focus-ring-outer { + // Shift the derived color only as far as needed to reach 9:1 + $focus-ring-outer: theme-contrast($primary-text-emphasis, $focus-ring-inner, 9); +} +$focus-ring-outline-width: 2px; +$focus-ring-outline-offset: 2px; +// how far the ring reaches outside the element's border box +$focus-ring-extent: $focus-ring-outline-width + $focus-ring-outline-offset; +$focus-ring-inner-shadow: 0 0 0 $focus-ring-outline-offset $focus-ring-inner; + +@mixin focus-ring { + outline: $focus-ring-outline-width solid $focus-ring-outer; + outline-offset: $focus-ring-outline-offset; + box-shadow: $focus-ring-inner-shadow; +} + @mixin body-secondary { @if variable-exists(body-secondary) { color: $body-secondary; diff --git a/src/resources/formats/html/bootstrap/_bootstrap-rules.scss b/src/resources/formats/html/bootstrap/_bootstrap-rules.scss index 06db8a53d89..75aaa0b5e33 100644 --- a/src/resources/formats/html/bootstrap/_bootstrap-rules.scss +++ b/src/resources/formats/html/bootstrap/_bootstrap-rules.scss @@ -23,6 +23,22 @@ ol > li:not(:has(> p)) > ol > li:has(> p) { margin-top: 1rem; } +// One keyboard focus ring for every focusable element (see the focus-ring +// mixin). (0,2,0) ties with Bootstrap's .nav-link:focus-visible and +// .form-control:focus and wins on order; user rules still come later. +// Don't add per-control focus rules: they break the one-ring convention. +:root :focus-visible { + @include focus-ring; +} + +// Exception: some Bootswatch themes give pagination links their own +// box-shadow (vapor's glow on the active link, zephyr's drop shadow), which +// replaces the ring's inner tone. On vapor's dark page that leaves no +// visible ring. (0,4,0) beats those theme rules. +.pagination .page-item .page-link:focus-visible { + @include focus-ring; +} + // skip-to-content link (WCAG 2.4.1 Bypass Blocks); visually hidden via // Bootstrap's .visually-hidden-focusable helper until it receives focus #quarto-skip-link:focus { @@ -39,11 +55,17 @@ ol > li:not(:has(> p)) > ol > li:has(> p) { box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.25); } +// keep the focus ring's inner tone alongside the drop shadow +#quarto-skip-link:focus-visible { + box-shadow: $focus-ring-inner-shadow, 0 0.25rem 0.75rem rgba(0, 0, 0, 0.25); +} + // the skip-link target receives programmatic focus (tabindex="-1"); don't // draw a focus ring around the entire content area #quarto-document-content:focus, #quarto-content:focus { outline: none; + box-shadow: none; } // Grid layout @@ -1061,16 +1083,6 @@ td code:not(.sourceCode) { padding: 0.7rem; } -// These buttons are bare .btn elements (no btn-* variant class), so -// Bootstrap's .btn:focus-visible leaves them with no focus indicator. -// Restore the browser's ring; unlike box-shadow it survives forced colors. -// See https://github.com/quarto-dev/quarto-cli/issues/14774 -.quarto-btn-toggle:focus-visible, -.quarto-search-button:focus-visible, -.code-tools-button:focus-visible { - outline: revert; -} - .code-tools-button { font-size: 1rem; padding: 0.15rem 0.15rem; @@ -1120,6 +1132,13 @@ td code:not(.sourceCode) { padding-top: 1em; } +// TOC links run to the edge of these scroll containers, which would clip +// their focus ring +.sidebar.toc-left, +.sidebar.margin-sidebar { + padding-right: $focus-ring-extent; +} + .sidebar.quarto-banner-title-block-sidebar { & > * { padding-top: 1.65em; diff --git a/src/resources/formats/html/bootstrap/_bootstrap-variables.scss b/src/resources/formats/html/bootstrap/_bootstrap-variables.scss index 1c0e404f940..4ab5c6cba81 100644 --- a/src/resources/formats/html/bootstrap/_bootstrap-variables.scss +++ b/src/resources/formats/html/bootstrap/_bootstrap-variables.scss @@ -194,6 +194,21 @@ $zindex-pagelayout: 998; // above the fixed/headroom navbar ($zindex-fixed: 1030) $zindex-skip-link: 1070 !default; +// Keyboard focus ring (see the `:focus-visible` rule in _bootstrap-rules.scss). +// When unset, the outer color comes from $primary-text-emphasis and the +// inner color is $white; both resolve in the rules layer, after Bootstrap +// has defined $primary-text-emphasis. An author who sets the outer color +// is responsible for its 9:1 contrast against the inner color. +$focus-ring-outer-color: null !default; +$focus-ring-inner-color: null !default; + +// Turn off Bootstrap's translucent box-shadow focus halos; the focus ring +// replaces them. +$focus-ring-box-shadow: none !default; +$form-select-focus-box-shadow: none !default; +$form-range-thumb-focus-box-shadow: none !default; +$btn-focus-width: 0 !default; + $popover-bg: if(variable-exists(body-bg), $body-bg, null) !default; $input-bg: if(variable-exists(body-bg), $body-bg, null) !default; diff --git a/src/resources/projects/website/navigation/quarto-nav.scss b/src/resources/projects/website/navigation/quarto-nav.scss index aa3ba4065ff..b4c3697699e 100644 --- a/src/resources/projects/website/navigation/quarto-nav.scss +++ b/src/resources/projects/website/navigation/quarto-nav.scss @@ -409,14 +409,6 @@ $sidebar-section-bottom-margin: 0.2em; padding-left: 0.5rem; } -// A bare button gets no focus ring from Bootstrap's reboot, so state one -// explicitly rather than depend on the UA default (quarto-cli#14774). -.sidebar-item .sidebar-item-toggle:focus-visible, -.sidebar-item button.sidebar-item-text:focus-visible { - outline: 2px solid currentColor; - outline-offset: 2px; -} - .sidebar-item .sidebar-item-toggle .bi { // The dongle for opening and closing sections font-size: 0.7rem; @@ -454,6 +446,9 @@ $sidebar-section-bottom-margin: 0.2em; @include media-breakpoint-down(lg) { .quarto-secondary-nav { display: block; + // Paint above the navbar (position: relative), so that the focus ring of + // the buttons at the top edge is not covered + position: relative; button.quarto-search-button { padding-right: 0em; diff --git a/src/resources/projects/website/search/quarto-search.scss b/src/resources/projects/website/search/quarto-search.scss index e0283012538..d84fde59681 100644 --- a/src/resources/projects/website/search/quarto-search.scss +++ b/src/resources/projects/website/search/quarto-search.scss @@ -81,10 +81,6 @@ $quarto-search-collapse-icon-size: 26px !default; .aa-Form { background-color: inherit; border: none; - &:focus-within { - box-shadow: none; - outline: none; - } .aa-InputWrapper { display: none; &:focus-within { @@ -114,6 +110,17 @@ $quarto-search-collapse-icon-size: 26px !default; } } +// The search box is a form that holds an icon, the input, and buttons. Draw +// the focus ring on the form, which is the visible field, instead of on the +// borderless input inside it. +.aa-Form:has(.aa-Input:focus-visible) { + @include focus-ring; +} +.aa-Form .aa-Input:focus-visible { + outline: none; + box-shadow: none; +} + .aa-Autocomplete, .aa-DetachedFormContainer { // Search box @@ -128,10 +135,6 @@ $quarto-search-collapse-icon-size: 26px !default; margin: 0; position: relative; width: 100%; - &:focus-within { - box-shadow: rgba($primary, 0.6) 0 0 0 1px; - outline: currentColor none medium; - } .aa-InputWrapperPrefix { align-items: center; display: flex; @@ -183,12 +186,6 @@ $quarto-search-collapse-icon-size: 26px !default; color: $input-color; opacity: 0.8; } - // Focus is set and styled on the parent, it isn't necessary here - &:focus { - border-color: none; - box-shadow: none; - outline: none; - } // Remove native appearence &::-webkit-search-decoration, &::-webkit-search-cancel-button, @@ -581,10 +578,8 @@ $quarto-search-collapse-icon-size: 26px !default; cursor: pointer; margin: 0 0 0 0.5em; padding: 0 0.5em; - &:hover, - &:focus { + &:hover { box-shadow: rgba($primary, 0.6) 0 0 0 1px; - outline: currentColor none medium; } } } diff --git a/tests/docs/playwright/website/bare-btn-focus/.gitignore b/tests/docs/playwright/website/focus-indicator/.gitignore similarity index 100% rename from tests/docs/playwright/website/bare-btn-focus/.gitignore rename to tests/docs/playwright/website/focus-indicator/.gitignore diff --git a/tests/docs/playwright/website/bare-btn-focus/_quarto.yml b/tests/docs/playwright/website/focus-indicator/_quarto.yml similarity index 100% rename from tests/docs/playwright/website/bare-btn-focus/_quarto.yml rename to tests/docs/playwright/website/focus-indicator/_quarto.yml diff --git a/tests/docs/playwright/website/bare-btn-focus/about.qmd b/tests/docs/playwright/website/focus-indicator/about.qmd similarity index 100% rename from tests/docs/playwright/website/bare-btn-focus/about.qmd rename to tests/docs/playwright/website/focus-indicator/about.qmd diff --git a/tests/docs/playwright/website/bare-btn-focus/index.qmd b/tests/docs/playwright/website/focus-indicator/index.qmd similarity index 100% rename from tests/docs/playwright/website/bare-btn-focus/index.qmd rename to tests/docs/playwright/website/focus-indicator/index.qmd diff --git a/tests/integration/playwright/tests/html-focus-indicator-bare-btn.spec.ts b/tests/integration/playwright/tests/html-focus-indicator.spec.ts similarity index 100% rename from tests/integration/playwright/tests/html-focus-indicator-bare-btn.spec.ts rename to tests/integration/playwright/tests/html-focus-indicator.spec.ts From 48fe81ec8a8c93c4a9c30baf2d2d01b8cdf92d29 Mon Sep 17 00:00:00 2001 From: Charlotte Wickham Date: Thu, 22 Oct 2026 10:05:14 -0700 Subject: [PATCH 02/17] Test the focus ring on every website control Rename html-focus-indicator-bare-btn.spec.ts to html-focus-indicator.spec.ts and the bare-btn-focus fixture to focus-indicator. The fixture sets a distinctive outer color, so each test also catches a theme or component rule that wins the cascade. Adds the navbar, TOC, tabset, sidebar toggle, search, and listing sort/filter/pagination controls, and a forced-colors check. --- .../html/code-tools-focus-indicator.qmd | 3 +- .../website/focus-indicator/_quarto.yml | 13 +- .../website/focus-indicator/focus-ring.scss | 4 + .../website/focus-indicator/index.qmd | 27 +++- .../website/focus-indicator/listing.qmd | 9 ++ .../website/focus-indicator/posts/a/index.qmd | 6 + .../website/focus-indicator/posts/b/index.qmd | 6 + .../website/focus-indicator/posts/c/index.qmd | 6 + .../tests/html-focus-indicator.spec.ts | 124 ++++++++++++++---- 9 files changed, 166 insertions(+), 32 deletions(-) create mode 100644 tests/docs/playwright/website/focus-indicator/focus-ring.scss create mode 100644 tests/docs/playwright/website/focus-indicator/listing.qmd create mode 100644 tests/docs/playwright/website/focus-indicator/posts/a/index.qmd create mode 100644 tests/docs/playwright/website/focus-indicator/posts/b/index.qmd create mode 100644 tests/docs/playwright/website/focus-indicator/posts/c/index.qmd diff --git a/tests/docs/playwright/html/code-tools-focus-indicator.qmd b/tests/docs/playwright/html/code-tools-focus-indicator.qmd index 3b6df72a25e..cb02afc9734 100644 --- a/tests/docs/playwright/html/code-tools-focus-indicator.qmd +++ b/tests/docs/playwright/html/code-tools-focus-indicator.qmd @@ -6,8 +6,7 @@ code-tools: true Regression test for #14774: the Code button is a bare Bootstrap `.btn` (no `btn-*` variant class), so Bootstrap's `outline: 0` left it with no -keyboard focus indicator. Quarto restores the native ring with -`outline: revert`. +keyboard focus indicator. It now gets Quarto's global focus ring. ```r 1 + 1 diff --git a/tests/docs/playwright/website/focus-indicator/_quarto.yml b/tests/docs/playwright/website/focus-indicator/_quarto.yml index 32a3a3fa633..39dfb3c326f 100644 --- a/tests/docs/playwright/website/focus-indicator/_quarto.yml +++ b/tests/docs/playwright/website/focus-indicator/_quarto.yml @@ -2,18 +2,25 @@ project: type: website website: - title: "bare-btn-focus" + title: "focus-indicator" search: location: sidebar navbar: left: - href: index.qmd text: Home + - about.qmd sidebar: style: floating search: true contents: - index.qmd - - about.qmd + - listing.qmd + - section: "Section" + contents: + - about.qmd -format: html +format: + html: + toc: true + theme: [default, focus-ring.scss] diff --git a/tests/docs/playwright/website/focus-indicator/focus-ring.scss b/tests/docs/playwright/website/focus-indicator/focus-ring.scss new file mode 100644 index 00000000000..8ba0173ae58 --- /dev/null +++ b/tests/docs/playwright/website/focus-indicator/focus-ring.scss @@ -0,0 +1,4 @@ +/*-- scss:defaults --*/ +// A distinctive outer color, so the tests can tell the focus ring +// apart from any other outline. +$focus-ring-outer-color: #123456; diff --git a/tests/docs/playwright/website/focus-indicator/index.qmd b/tests/docs/playwright/website/focus-indicator/index.qmd index 9ea709a6189..7b0a08f9b9c 100644 --- a/tests/docs/playwright/website/focus-indicator/index.qmd +++ b/tests/docs/playwright/website/focus-indicator/index.qmd @@ -2,6 +2,27 @@ title: "Home" --- -Regression test for #14774: below 992px the secondary nav shows the -sidebar toggle and sidebar search buttons. Both are bare Bootstrap -`.btn` elements and must keep a visible keyboard focus indicator. +Fixture for the global focus ring. Every focusable control must show the +same ring: a `$focus-ring-outer-color` outline, with the inner color as a +box-shadow in the outline offset. + +Below 992px the secondary nav shows the sidebar toggle and sidebar search +buttons, which are bare Bootstrap `.btn` elements (#14774). + +## Links + +An [inline link](about.qmd) in the body. + +## Tabset + +::: {.panel-tabset} + +### First tab + +Content of the first tab. + +### Second tab + +Content of the second tab. + +::: diff --git a/tests/docs/playwright/website/focus-indicator/listing.qmd b/tests/docs/playwright/website/focus-indicator/listing.qmd new file mode 100644 index 00000000000..ca105ea954c --- /dev/null +++ b/tests/docs/playwright/website/focus-indicator/listing.qmd @@ -0,0 +1,9 @@ +--- +title: "Listing" +listing: + contents: posts + sort: "date desc" + sort-ui: true + filter-ui: true + page-size: 2 +--- diff --git a/tests/docs/playwright/website/focus-indicator/posts/a/index.qmd b/tests/docs/playwright/website/focus-indicator/posts/a/index.qmd new file mode 100644 index 00000000000..889cfdf7c95 --- /dev/null +++ b/tests/docs/playwright/website/focus-indicator/posts/a/index.qmd @@ -0,0 +1,6 @@ +--- +title: "Post a" +date: 2026-10-01 +--- + +Body of post a. diff --git a/tests/docs/playwright/website/focus-indicator/posts/b/index.qmd b/tests/docs/playwright/website/focus-indicator/posts/b/index.qmd new file mode 100644 index 00000000000..9c8147fd977 --- /dev/null +++ b/tests/docs/playwright/website/focus-indicator/posts/b/index.qmd @@ -0,0 +1,6 @@ +--- +title: "Post b" +date: 2026-10-02 +--- + +Body of post b. diff --git a/tests/docs/playwright/website/focus-indicator/posts/c/index.qmd b/tests/docs/playwright/website/focus-indicator/posts/c/index.qmd new file mode 100644 index 00000000000..c327af39285 --- /dev/null +++ b/tests/docs/playwright/website/focus-indicator/posts/c/index.qmd @@ -0,0 +1,6 @@ +--- +title: "Post c" +date: 2026-10-03 +--- + +Body of post c. diff --git a/tests/integration/playwright/tests/html-focus-indicator.spec.ts b/tests/integration/playwright/tests/html-focus-indicator.spec.ts index 6fe5964cef0..b373699f8a2 100644 --- a/tests/integration/playwright/tests/html-focus-indicator.spec.ts +++ b/tests/integration/playwright/tests/html-focus-indicator.spec.ts @@ -1,34 +1,76 @@ -import { expect, test } from "@playwright/test"; +import { expect, Locator, test } from "@playwright/test"; import { getUrl, tabUntilFocused } from "../src/utils"; -// Regression tests for #14774. Quarto emits three buttons that carry the -// Bootstrap `btn` class with no `btn-*` variant class: the code tools -// button, the sidebar toggle, and the sidebar search button. Bootstrap's -// .btn:focus-visible removes the native focus ring (outline: 0) and -// substitutes a box-shadow that only the variant classes define, so these -// buttons took keyboard focus with no visible indicator. Quarto restores -// the browser's native ring with `outline: revert`. +// Quarto draws one keyboard focus ring for every focusable element, from a +// single `:root :focus-visible` rule: an outline in the outer color, with +// the inner color as a box-shadow in the outline offset (W3C technique +// C40). Bootstrap's box-shadow halos are turned off at the source. +// +// The website fixture sets `$focus-ring-outer-color: #123456`, so an +// outline in that color shows that the global rule won the cascade on that +// control; a component rule that wins instead shows up as a wrong color. +// +// Focus moves with real Tab presses (tabUntilFocused) because the ring +// only applies to keyboard focus, i.e. to :focus-visible. -// Focus moves with real Tab presses (tabUntilFocused) because the fix only -// applies to keyboard focus, i.e. to :focus-visible. +const outerColor = "rgb(18, 52, 86)"; // #123456, set in focus-ring.scss +const innerShadow = /^rgb\(255, 255, 255\) 0px 0px 0px 2px/; // default $white -test("code tools button shows a focus indicator on keyboard focus", async ({ +async function expectFocusRing(control: Locator) { + await expect(control).toHaveCSS("outline-style", "solid"); + await expect(control).toHaveCSS("outline-color", outerColor); + await expect(control).toHaveCSS("outline-width", "2px"); + await expect(control).toHaveCSS("outline-offset", "2px"); + await expect(control).toHaveCSS("box-shadow", innerShadow); +} + +test.describe("website controls show the focus ring", () => { + test.use({ viewport: { width: 1400, height: 900 } }); + + const controls = [ + { name: "navbar link", page: "index", selector: "#navbarCollapse a.nav-link:not(.active)" }, + { name: "sidebar section toggle", page: "index", selector: "#quarto-sidebar button.sidebar-item-toggle" }, + { name: "inline link", page: "index", selector: "main a:not([class])" }, + { name: "TOC link", page: "index", selector: "#TOC a.nav-link:not(.active)" }, + { name: "tabset tab", page: "index", selector: ".panel-tabset a.nav-link.active" }, + { name: "listing sort", page: "listing", selector: ".quarto-listing-sort select" }, + { name: "listing filter", page: "listing", selector: ".quarto-listing-filter input" }, + { name: "listing pagination", page: "listing", selector: ".pagination li.active a.page-link" }, + ]; + + for (const { name, page: pageName, selector } of controls) { + test(name, async ({ page, browserName }) => { + await page.goto(getUrl(`website/focus-indicator/_site/${pageName}.html`), { + waitUntil: "load", + }); + const control = page.locator(selector).first(); + await expect(control).toBeVisible(); + expect(await tabUntilFocused(page, browserName, control, 40)).toBe(true); + await expectFocusRing(control); + }); + } +}); + +// The search box draws the ring on the form, which is the visible field, +// instead of on the borderless input inside it. +test("sidebar search input shows the focus ring on its form", async ({ page, browserName, }) => { - await page.goto(getUrl("html/code-tools-focus-indicator.html"), { + await page.setViewportSize({ width: 1400, height: 900 }); + await page.goto(getUrl("website/focus-indicator/_site/index.html"), { waitUntil: "load", }); - - const button = page.locator("button.code-tools-button"); - await expect(button).toBeVisible(); - expect(await tabUntilFocused(page, browserName, button)).toBe(true); - - // outline: revert restores the user-agent ring (outline-style: auto in - // every engine); any non-none outline is a visible indicator. - await expect(button).not.toHaveCSS("outline-style", "none"); + const input = page.locator("#quarto-sidebar .aa-Input"); + await expect(input).toBeVisible(); + expect(await tabUntilFocused(page, browserName, input)).toBe(true); + await expectFocusRing(page.locator("#quarto-sidebar .aa-Form")); + await expect(input).toHaveCSS("outline-style", "none"); }); +// Regression tests for #14774: these buttons carry Bootstrap's `btn` class +// with no `btn-*` variant, so Bootstrap's own .btn:focus-visible left them +// with no indicator. test.describe("website secondary nav buttons", () => { // The secondary nav holding the sidebar toggle and sidebar search // buttons only appears when the sidebar collapses, below the lg @@ -41,19 +83,53 @@ test.describe("website secondary nav buttons", () => { ]; for (const { name, selector } of buttons) { - test(`${name} button shows a focus indicator on keyboard focus`, async ({ + test(`${name} button shows the focus ring`, async ({ page, browserName, }) => { - await page.goto(getUrl("website/bare-btn-focus/_site/index.html"), { + await page.goto(getUrl("website/focus-indicator/_site/index.html"), { waitUntil: "load", }); const button = page.locator(`.quarto-secondary-nav ${selector}`); await expect(button).toBeVisible(); expect(await tabUntilFocused(page, browserName, button)).toBe(true); - - await expect(button).not.toHaveCSS("outline-style", "none"); + await expectFocusRing(button); }); } }); + +test("code tools button shows the focus ring", async ({ + page, + browserName, +}) => { + await page.goto(getUrl("html/code-tools-focus-indicator.html"), { + waitUntil: "load", + }); + + const button = page.locator("button.code-tools-button"); + await expect(button).toBeVisible(); + expect(await tabUntilFocused(page, browserName, button)).toBe(true); + + // Default theme, so no fixed outer color to compare against. + await expect(button).toHaveCSS("outline-style", "solid"); + await expect(button).toHaveCSS("outline-offset", "2px"); + await expect(button).toHaveCSS("box-shadow", innerShadow); +}); + +// Forced-colors mode drops every box-shadow, so Bootstrap's halo left +// `.nav-link` with no indicator there. The ring's outline survives. +test.describe("forced colors", () => { + test.use({ viewport: { width: 1400, height: 900 }, forcedColors: "active" }); + + test("navbar link keeps an outline", async ({ page, browserName }) => { + test.skip(browserName !== "chromium", "forced colors emulation is Chromium-only"); + await page.goto(getUrl("website/focus-indicator/_site/index.html"), { + waitUntil: "load", + }); + const link = page.locator("#navbarCollapse a.nav-link:not(.active)").first(); + expect(await tabUntilFocused(page, browserName, link)).toBe(true); + await expect(link).toHaveCSS("outline-style", "solid"); + await expect(link).toHaveCSS("outline-width", "2px"); + }); +}); From df10116a332f7191d4d30c8d5c1aba62af49e793 Mon Sep 17 00:00:00 2001 From: Charlotte Wickham Date: Thu, 22 Oct 2026 10:05:16 -0700 Subject: [PATCH 03/17] Document the focus ring in the changelog and the Sass theming rule --- .claude/rules/formats/sass-theming.md | 29 +++++++++++++++++++++++++++ news/changelog-1.11.md | 1 + 2 files changed, 30 insertions(+) diff --git a/.claude/rules/formats/sass-theming.md b/.claude/rules/formats/sass-theming.md index 99f421dc775..2ede53ce76f 100644 --- a/.claude/rules/formats/sass-theming.md +++ b/.claude/rules/formats/sass-theming.md @@ -6,6 +6,7 @@ paths: - "src/format/reveal/format-reveal-theme*" - "src/format/dashboard/format-dashboard-shared*" - "src/resources/formats/**/*.scss" + - "src/resources/projects/**/*.scss" --- # Sass Theming @@ -22,3 +23,31 @@ background-color: var(--r-background-color, $body-bg); Read `llm-docs/sass-theming-architecture.md` for full compilation pipeline details. For the three-tier callout CSS architecture (Bootstrap, RevealJS, standalone HTML), see `llm-docs/callout-styling-html.md`. + +## Keyboard focus ring (Bootstrap formats) + +One rule in `_bootstrap-rules.scss` draws the focus ring for every focusable +element: `:root :focus-visible`. The `focus-ring` mixin in +`_bootstrap-mixins.scss` defines the ring. Do not add focus rules for one +control. A rule with a higher specificity replaces part of the ring on that +control. + +- One exception is the search box. Its visible field is `.aa-Form`, which + holds a borderless `.aa-Input`. The form gets `@include focus-ring` when the + input has focus, and the input gets no ring. +- Another exception is pagination. Some Bootswatch themes (vapor, zephyr) + give `.page-link` a `box-shadow` with a higher specificity, so a + `(0,4,0)` rule includes the ring again. + +- The ring follows W3C technique C40. The outer color is a 2px `outline`. The + inner color is a `box-shadow` that fills the 2px `outline-offset`. +- The outer color is the `outline` because Bootswatch themes often set + `box-shadow` on controls, but rarely `outline`. Forced-colors mode also + keeps the `outline`. +- `_bootstrap-variables.scss` sets Bootstrap's focus halos to `none`. Do not + turn them on again. +- If an element must not show the ring, set both `outline: none` and + `box-shadow: none`. An example is `#quarto-document-content`, which gets + focus from the skip link. +- If a scroll container clips the ring, add `$focus-ring-extent` of padding + on that side. The TOC sidebars are an example. diff --git a/news/changelog-1.11.md b/news/changelog-1.11.md index 63e16dd8fd2..1693d9f45fc 100644 --- a/news/changelog-1.11.md +++ b/news/changelog-1.11.md @@ -24,6 +24,7 @@ All changes included in 1.11: - ([#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 `