Skip to content

Commit 438c5b9

Browse files
authored
Use one focus ring for all focusable elements in Bootstrap HTML (#15163)
Quarto's HTML output used four different keyboard focus indicators. Two of them fail the 3:1 contrast minimum of WCAG 1.4.11, and in forced-colors mode some controls had no indicator at all. One two-tone ring, defined in a single rule, now covers every focusable element. It follows W3C technique C40: a 2px outline in the outer tone, with the inner tone as a box-shadow filling a 2px offset. The tones follow the page. The dark tone is outer on light pages and the white tone is outer on dark pages, so the outer ring always contrasts with the background. The derived pair is darkened to at least 9:1 contrast, so one tone has at least 3:1 against any solid background. - Sass variables $focus-ring-outer-color and $focus-ring-inner-color set the tones. An explicit outer color is used as given. - $focus-ring-outline-width and $focus-ring-outline-offset are public and default to 2px. Scroll containers get padding so a larger ring is not clipped. - Bootstrap's focus halos are turned off at the source, and the variables that set them are no longer used by Quarto. - Author rules win over the ring. The global rule is (0,1,0), and --quarto-focus-ring-shadow removes the inner tone. - Bootstrap controls that remove their outline at (0,2,0) get the ring back, and MathJax's runtime outline: none is overridden at (0,2,1). - The search box draws the ring on its form, and pagination gets a (0,4,0) rule for themes that replace its box-shadow. - Per-control focus rules are removed, including the outline: revert from #14785. The skip link (#14685) loses its drop shadow and takes the standard ring. - accessibleContrast() takes a numeric ratio, and "A" now means 3:1. Supersedes #14867 Fixes #15139
1 parent 338be04 commit 438c5b9

24 files changed

Lines changed: 490 additions & 110 deletions

‎.claude/rules/formats/sass-theming.md‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ paths:
66
- "src/format/reveal/format-reveal-theme*"
77
- "src/format/dashboard/format-dashboard-shared*"
88
- "src/resources/formats/**/*.scss"
9+
- "src/resources/projects/**/*.scss"
910
---
1011

1112
# Sass Theming
@@ -22,3 +23,50 @@ background-color: var(--r-background-color, $body-bg);
2223
Read `llm-docs/sass-theming-architecture.md` for full compilation pipeline details.
2324

2425
For the three-tier callout CSS architecture (Bootstrap, RevealJS, standalone HTML), see `llm-docs/callout-styling-html.md`.
26+
27+
## Keyboard focus ring (Bootstrap formats)
28+
29+
One rule in `_bootstrap-rules.scss` draws the focus ring for every focusable
30+
element: `:focus-visible`. The `focus-ring` mixin in
31+
`_bootstrap-mixins.scss` defines the ring. Do not add focus rules for one
32+
control. A rule with a higher specificity replaces part of the ring on that
33+
control.
34+
35+
- The global rule has specificity (0,1,0) on purpose, so that author rules
36+
such as `a:focus-visible` (0,1,1) win. Do not raise it.
37+
- Bootstrap removes the outline with (0,2,0) rules (`.btn:focus-visible`,
38+
`.nav-link:focus-visible`, `.form-control:focus`, ...). A second rule lists
39+
these classes and includes the ring again. MathJax removes the outline on
40+
`mjx-speech` at runtime, so `:root mjx-speech:focus-visible` (0,2,1) puts
41+
it back. Add a rule only if Bootstrap or a bundled library removes the
42+
outline.
43+
- The inner tone is `box-shadow: var(--quarto-focus-ring-shadow)`. The
44+
property is set on `:root`. An author can remove the inner tone with
45+
`--quarto-focus-ring-shadow: none`. Use the property, not
46+
`$focus-ring-inner-shadow`, in any rule that adds to the ring.
47+
48+
- One exception is the search box. Its visible field is `.aa-Form`, which
49+
holds a borderless `.aa-Input`. The form gets `@include focus-ring` when the
50+
input has focus, and the input gets no ring.
51+
- Another exception is pagination. Some Bootswatch themes (vapor, zephyr)
52+
give `.page-link` a `box-shadow` with a higher specificity, so a
53+
`(0,4,0)` rule includes the ring again.
54+
55+
- The ring follows W3C technique C40. The outer color is an `outline`
56+
(`$focus-ring-outline-width`, 2px). The inner color is a `box-shadow` that
57+
fills the `outline-offset` (`$focus-ring-outline-offset`, 2px). Both are
58+
public `!default` variables in `_bootstrap-variables.scss`.
59+
- On a dark page (`$body-bg` has more contrast with white than with black),
60+
the derived tones swap: white goes on the `outline`. The test is per
61+
stylesheet, not per `light:`/`dark:` key. If a user sets
62+
`$focus-ring-outer-color` or `$focus-ring-inner-color`, nothing swaps.
63+
- The outer color is the `outline` because Bootswatch themes often set
64+
`box-shadow` on controls, but rarely `outline`. Forced-colors mode also
65+
keeps the `outline`.
66+
- `_bootstrap-variables.scss` sets Bootstrap's focus halos to `none`. Do not
67+
turn them on again.
68+
- If an element must not show the ring, set both `outline: none` and
69+
`box-shadow: none`. An example is `#quarto-document-content`, which gets
70+
focus from the skip link.
71+
- If a scroll container clips the ring, add `$focus-ring-extent` of padding
72+
on that side. The TOC sidebars and the collapsed navbar menu are examples.

‎news/changelog-1.11.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ All changes included in 1.11:
2525
- ([#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`).
2626
- ([#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 `<button>` elements, so they are keyboard accessible and properly announced.
2727
- ([#14774](https://github.com/quarto-dev/quarto-cli/issues/14774)): Fix missing keyboard focus indicator on the code tools button and on a website's sidebar toggle and sidebar search buttons.
28+
- ([#15139](https://github.com/quarto-dev/quarto-cli/issues/15139)): HTML output now uses one high-contrast keyboard focus ring for all focusable elements, which stays visible in forced-colors mode. Set its colors with the new `$focus-ring-outer-color` and `$focus-ring-inner-color` Sass variables, and its size with `$focus-ring-outline-width` and `$focus-ring-outline-offset`. Quarto no longer uses Bootstrap's `$focus-ring-color`, `$focus-ring-box-shadow`, `$btn-focus-width`, and `$btn-focus-box-shadow`.
2829
- ([#14376](https://github.com/quarto-dev/quarto-cli/issues/14376)): Add a distinct, localizable `aria-label` to each navigation landmark of websites and books: the navbar (`Site`), the sidebar (`Section`, or `Site` when there is no navbar), the narrow-viewport toolbar (`Toolbar`), the previous/next page navigation (`Page`), and the breadcrumbs (`Breadcrumbs`) (previously hardcoded English `breadcrumb`). The new `navigation-*-label` language keys can be overridden with `language:` metadata.
2930
- ([#14376](https://github.com/quarto-dev/quarto-cli/issues/14376)): Translate the new `navigation-*-label` keys in all 33 built-in language files. The values are drawn from existing human-translated interface strings (LibreOffice, GNOME, Wikidata) and each one carries a comment naming its source; values that were adapted rather than used verbatim are marked `needs review`.
3031
- ([#14376](https://github.com/quarto-dev/quarto-cli/issues/14376)): Label the table of contents `<nav>` with its localized title (`aria-labelledby`), in `html` and `revealjs` output, so assistive technology can tell it apart from other navigation landmarks.

‎src/resources/formats/html/bootstrap/_bootstrap-functions.scss‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,13 @@
2929
// A: 3:1
3030
// AA: 4.5:1
3131
// AAA: 7:1
32-
$goalContrastRatio: 3;
33-
@if $level == "AA" {
32+
// A number: that ratio (e.g. 9 for the two tones of the focus ring)
33+
$goalContrastRatio: $level;
34+
@if $level == "A" {
35+
$goalContrastRatio: 3;
36+
} @else if $level == "AA" {
3437
$goalContrastRatio: 4.5;
35-
} @else {
38+
} @else if $level == "AAA" {
3639
$goalContrastRatio: 7;
3740
}
3841

‎src/resources/formats/html/bootstrap/_bootstrap-mixins.scss‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,42 @@
1+
// Keyboard focus ring (WCAG 2.4.7, 1.4.11), after W3C technique C40: two
2+
// tones at least 9:1 apart, so one of them reaches 3:1 against any solid
3+
// background. The outer tone is the outline, which theme rules rarely
4+
// override and forced-colors mode keeps; the inner tone is a box-shadow that
5+
// fills the outline offset. Resolved here, after all defaults, so that
6+
// $primary-text-emphasis exists and every rules layer can use the result.
7+
$focus-ring-inner: if($focus-ring-inner-color, $focus-ring-inner-color, $white);
8+
$focus-ring-outer: $focus-ring-outer-color;
9+
@if not $focus-ring-outer {
10+
// Shift the derived color only as far as needed to reach 9:1
11+
$focus-ring-outer: theme-contrast($primary-text-emphasis, $focus-ring-inner, 9);
12+
}
13+
// On a dark page the tones swap, so the outline (the part that touches the
14+
// page) is the tone that contrasts with the page background. Colors set
15+
// explicitly are never swapped.
16+
$focus-ring-page-is-dark: not $focus-ring-outer-color and
17+
not $focus-ring-inner-color and
18+
quarto-contrast($body-bg, $white) > quarto-contrast($body-bg, $black);
19+
$focus-ring-outline-color: if(
20+
$focus-ring-page-is-dark,
21+
$focus-ring-inner,
22+
$focus-ring-outer
23+
);
24+
$focus-ring-shadow-color: if(
25+
$focus-ring-page-is-dark,
26+
$focus-ring-outer,
27+
$focus-ring-inner
28+
);
29+
// how far the ring reaches outside the element's border box
30+
$focus-ring-extent: $focus-ring-outline-width + $focus-ring-outline-offset;
31+
$focus-ring-inner-shadow: 0 0 0 $focus-ring-outline-offset $focus-ring-shadow-color;
32+
33+
@mixin focus-ring {
34+
outline: $focus-ring-outline-width solid $focus-ring-outline-color;
35+
outline-offset: $focus-ring-outline-offset;
36+
// --quarto-focus-ring-shadow is set on :root in _bootstrap-rules.scss
37+
box-shadow: var(--quarto-focus-ring-shadow);
38+
}
39+
140
@mixin body-secondary {
241
@if variable-exists(body-secondary) {
342
color: $body-secondary;

‎src/resources/formats/html/bootstrap/_bootstrap-rules.scss‎

Lines changed: 65 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,55 @@ ol > li:not(:has(> p)) > ol > li:has(> p) {
2323
margin-top: 1rem;
2424
}
2525

26+
// The inner tone is a custom property, so an author can remove it with
27+
// one line (`--quarto-focus-ring-shadow: none`) without matching the
28+
// specificity of the rules below.
29+
:root {
30+
--quarto-focus-ring-shadow: #{$focus-ring-inner-shadow};
31+
}
32+
33+
// One keyboard focus ring for every focusable element (see the focus-ring
34+
// mixin). The rule has (0,1,0) specificity on purpose, so that author rules
35+
// such as `a:focus-visible` (0,1,1) win over it.
36+
:focus-visible {
37+
@include focus-ring;
38+
}
39+
40+
// Bootstrap removes the outline on these controls with (0,2,0) rules
41+
// (`.btn:focus-visible { outline: 0 }`, `.form-control:focus`, ...), which
42+
// beat the (0,1,0) rule above. These rules tie with Bootstrap and win on
43+
// order; author rules on the same classes come later and still win.
44+
// Don't add rules for other controls: only outline removals by Bootstrap
45+
// or a bundled library belong here.
46+
.btn:focus-visible,
47+
.btn-close:focus-visible,
48+
.nav-link:focus-visible,
49+
.navbar-toggler:focus-visible,
50+
.accordion-button:focus-visible,
51+
.carousel-control-prev:focus-visible,
52+
.carousel-control-next:focus-visible,
53+
.form-control:focus-visible,
54+
.form-select:focus-visible,
55+
.form-check-input:focus-visible,
56+
.form-range:focus-visible {
57+
@include focus-ring;
58+
}
59+
60+
// MathJax injects `mjx-speech:focus { outline: none }` (0,1,1) at runtime,
61+
// after this stylesheet, so a tie loses on order. (0,2,1) puts the ring back
62+
// on the focusable speech element of each equation.
63+
:root mjx-speech:focus-visible {
64+
@include focus-ring;
65+
}
66+
67+
// Exception: some Bootswatch themes give pagination links their own
68+
// box-shadow (vapor's glow on the active link, zephyr's drop shadow), which
69+
// replaces the ring's inner tone. On vapor's dark page that leaves no
70+
// visible ring. (0,4,0) beats those theme rules.
71+
.pagination .page-item .page-link:focus-visible {
72+
@include focus-ring;
73+
}
74+
2675
// skip-to-content link (WCAG 2.4.1 Bypass Blocks); visually hidden via
2776
// Bootstrap's .visually-hidden-focusable helper until it receives focus
2877
#quarto-skip-link:focus {
@@ -36,14 +85,14 @@ ol > li:not(:has(> p)) > ol > li:has(> p) {
3685
text-decoration: none;
3786
border: 1px solid $border-color;
3887
border-radius: $border-radius;
39-
box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.25);
4088
}
4189

4290
// the skip-link target receives programmatic focus (tabindex="-1"); don't
4391
// draw a focus ring around the entire content area
4492
#quarto-document-content:focus,
4593
#quarto-content:focus {
4694
outline: none;
95+
box-shadow: none;
4796
}
4897

4998
// Grid layout
@@ -1061,16 +1110,6 @@ td code:not(.sourceCode) {
10611110
padding: 0.7rem;
10621111
}
10631112

1064-
// These buttons are bare .btn elements (no btn-* variant class), so
1065-
// Bootstrap's .btn:focus-visible leaves them with no focus indicator.
1066-
// Restore the browser's ring; unlike box-shadow it survives forced colors.
1067-
// See https://github.com/quarto-dev/quarto-cli/issues/14774
1068-
.quarto-btn-toggle:focus-visible,
1069-
.quarto-search-button:focus-visible,
1070-
.code-tools-button:focus-visible {
1071-
outline: revert;
1072-
}
1073-
10741113
.code-tools-button {
10751114
font-size: 1rem;
10761115
padding: 0.15rem 0.15rem;
@@ -1120,6 +1159,21 @@ td code:not(.sourceCode) {
11201159
padding-top: 1em;
11211160
}
11221161

1162+
// TOC links run to the edge of these scroll containers, which would clip
1163+
// their focus ring
1164+
.sidebar.toc-left,
1165+
.sidebar.margin-sidebar {
1166+
padding-right: $focus-ring-extent;
1167+
}
1168+
1169+
// The collapsed navbar menu scrolls, and its links run to its edges. The
1170+
// padding makes room for the focus ring; the negative margin keeps the links
1171+
// where they were.
1172+
.navbar-nav-scroll {
1173+
padding: $focus-ring-extent;
1174+
margin: -$focus-ring-extent;
1175+
}
1176+
11231177
.sidebar.quarto-banner-title-block-sidebar {
11241178
& > * {
11251179
padding-top: 1.65em;

‎src/resources/formats/html/bootstrap/_bootstrap-variables.scss‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,25 @@ $zindex-pagelayout: 998;
194194
// above the fixed/headroom navbar ($zindex-fixed: 1030)
195195
$zindex-skip-link: 1070 !default;
196196

197+
// Keyboard focus ring (see the `:focus-visible` rule in _bootstrap-rules.scss).
198+
// When unset, the outer color comes from $primary-text-emphasis and the
199+
// inner color is $white; both resolve in the rules layer, after Bootstrap
200+
// has defined $primary-text-emphasis. An author who sets the outer color
201+
// is responsible for its 9:1 contrast against the inner color.
202+
$focus-ring-outer-color: null !default;
203+
$focus-ring-inner-color: null !default;
204+
// Width of the outer tone, and of the gap that the inner tone fills. Keep
205+
// the width at 2px or more for WCAG 2.4.13 (Focus Appearance).
206+
$focus-ring-outline-width: 2px !default;
207+
$focus-ring-outline-offset: 2px !default;
208+
209+
// Turn off Bootstrap's translucent box-shadow focus halos; the focus ring
210+
// replaces them.
211+
$focus-ring-box-shadow: none !default;
212+
$form-select-focus-box-shadow: none !default;
213+
$form-range-thumb-focus-box-shadow: none !default;
214+
$btn-focus-width: 0 !default;
215+
197216
$popover-bg: if(variable-exists(body-bg), $body-bg, null) !default;
198217
$input-bg: if(variable-exists(body-bg), $body-bg, null) !default;
199218

‎src/resources/projects/website/navigation/quarto-nav.scss‎

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -409,14 +409,6 @@ $sidebar-section-bottom-margin: 0.2em;
409409
padding-left: 0.5rem;
410410
}
411411

412-
// A bare button gets no focus ring from Bootstrap's reboot, so state one
413-
// explicitly rather than depend on the UA default (quarto-cli#14774).
414-
.sidebar-item .sidebar-item-toggle:focus-visible,
415-
.sidebar-item button.sidebar-item-text:focus-visible {
416-
outline: 2px solid currentColor;
417-
outline-offset: 2px;
418-
}
419-
420412
.sidebar-item .sidebar-item-toggle .bi {
421413
// The dongle for opening and closing sections
422414
font-size: 0.7rem;
@@ -454,6 +446,9 @@ $sidebar-section-bottom-margin: 0.2em;
454446
@include media-breakpoint-down(lg) {
455447
.quarto-secondary-nav {
456448
display: block;
449+
// Paint above the navbar (position: relative), so that the focus ring of
450+
// the buttons at the top edge is not covered
451+
position: relative;
457452

458453
button.quarto-search-button {
459454
padding-right: 0em;

‎src/resources/projects/website/search/quarto-search.scss‎

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,6 @@ $quarto-search-collapse-icon-size: 26px !default;
8181
.aa-Form {
8282
background-color: inherit;
8383
border: none;
84-
&:focus-within {
85-
box-shadow: none;
86-
outline: none;
87-
}
8884
.aa-InputWrapper {
8985
display: none;
9086
&:focus-within {
@@ -114,6 +110,17 @@ $quarto-search-collapse-icon-size: 26px !default;
114110
}
115111
}
116112

113+
// The search box is a form that holds an icon, the input, and buttons. Draw
114+
// the focus ring on the form, which is the visible field, instead of on the
115+
// borderless input inside it.
116+
.aa-Form:has(.aa-Input:focus-visible) {
117+
@include focus-ring;
118+
}
119+
.aa-Form .aa-Input:focus-visible {
120+
outline: none;
121+
box-shadow: none;
122+
}
123+
117124
.aa-Autocomplete,
118125
.aa-DetachedFormContainer {
119126
// Search box
@@ -128,10 +135,6 @@ $quarto-search-collapse-icon-size: 26px !default;
128135
margin: 0;
129136
position: relative;
130137
width: 100%;
131-
&:focus-within {
132-
box-shadow: rgba($primary, 0.6) 0 0 0 1px;
133-
outline: currentColor none medium;
134-
}
135138
.aa-InputWrapperPrefix {
136139
align-items: center;
137140
display: flex;
@@ -183,12 +186,6 @@ $quarto-search-collapse-icon-size: 26px !default;
183186
color: $input-color;
184187
opacity: 0.8;
185188
}
186-
// Focus is set and styled on the parent, it isn't necessary here
187-
&:focus {
188-
border-color: none;
189-
box-shadow: none;
190-
outline: none;
191-
}
192189
// Remove native appearence
193190
&::-webkit-search-decoration,
194191
&::-webkit-search-cancel-button,
@@ -581,10 +578,8 @@ $quarto-search-collapse-icon-size: 26px !default;
581578
cursor: pointer;
582579
margin: 0 0 0 0.5em;
583580
padding: 0 0.5em;
584-
&:hover,
585-
&:focus {
581+
&:hover {
586582
box-shadow: rgba($primary, 0.6) 0 0 0 1px;
587-
outline: currentColor none medium;
588583
}
589584
}
590585
}

‎tests/docs/playwright/html/code-tools-focus-indicator.qmd‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@ code-tools: true
66

77
Regression test for #14774: the Code button is a bare Bootstrap `.btn`
88
(no `btn-*` variant class), so Bootstrap's `outline: 0` left it with no
9-
keyboard focus indicator. Quarto restores the native ring with
10-
`outline: revert`.
9+
keyboard focus indicator. It now gets Quarto's global focus ring.
1110

1211
```r
1312
1 + 1
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
title: Focus ring author override
3+
format:
4+
html:
5+
theme: [default, focus-ring-author-override.scss]
6+
---
7+
8+
Fixture for author focus styles. The theme sets its own outline on
9+
`a:focus-visible` and removes the Quarto inner tone with
10+
`--quarto-focus-ring-shadow: none`. The author rule must win over the
11+
global focus ring.
12+
13+
An [inline link](#target) in the body.
14+
15+
## Target {#target}

0 commit comments

Comments
 (0)