Skip to content

Commit 8e61cd8

Browse files
authored
fix(button): use measurable backgrounds in all states (#2452)
* fix(button): use solid backgrounds outside selected states * fix(button): move selected gradients to decorative layers * test(button): simplify contrast regression coverage
1 parent 706b3f9 commit 8e61cd8

33 files changed

Lines changed: 314 additions & 61 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@stackoverflow/stacks": patch
3+
---
4+
5+
Use solid backgrounds for buttons so APCA checks can measure their base color contrast. Render the two-tone selected styling on an inner decorative layer for buttons and checked radio labels, with separate regression tests for contrast against both visible colors. Transparent borders now show a thin solid-color rim around that layer. Axe's built-in WCAG contrast rules still report selected pseudo-element backgrounds as incomplete.

‎packages/stacks-classic/lib/__snapshots__/stacks.less.test.ts.snap‎

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12250,7 +12250,7 @@ body.theme-highcontrast.theme-dark .s-bling.s-bling__filled.s-bling__bronze {
1225012250
--_bu-badge-px: var(--su6);
1225112251
--_bu-badge-py: var(--su2);
1225212252
--_bu-dropdown-bw: var(--su4);
12253-
background: linear-gradient(0deg, var(--_bu-bg-selected-overlay, var(--_bu-bg)) 50%, var(--_bu-bg) 50%) padding-box, linear-gradient(0deg, var(--_bu-bg-selected-overlay, var(--_bu-bg)) 50%, var(--_bu-bg) 50%) border-box;
12253+
background: var(--_bu-bg);
1225412254
border: var(--_bu-baw) solid var(--_bu-bc, transparent);
1225512255
color: var(--_bu-fc);
1225612256
font-size: var(--_bu-fs);
@@ -12611,8 +12611,9 @@ p .s-btn.s-btn__link {
1261112611
--_bu-bg-selected-overlay: color-mix(in srgb, var(--_bu-bg-selected, var(--_bu-bg)), var(--white) var(--_bu-bg-selected-overlay-o));
1261212612
--_bu-bg-gradient-top: var(--_bu-bg-selected-overlay, var(--_bu-bg));
1261312613
--_bu-bg-gradient-bottom: var(--_bu-bg-selected, var(--_bu-bg));
12614-
background: linear-gradient(0deg, var(--_bu-bg-gradient-top) 50%, var(--_bu-bg-gradient-bottom) 50%) padding-box, linear-gradient(0deg, var(--_bu-bg-gradient-top) 50%, var(--_bu-bg-gradient-bottom) 50%) border-box;
12614+
background: var(--_bu-bg-selected, var(--_bu-bg));
1261512615
color: var(--_bu-fc-selected, var(--_bu-fc));
12616+
isolation: isolate;
1261612617
}
1261712618
@media (prefers-color-scheme: dark) {
1261812619
body.theme-system .s-btn.is-selected,
@@ -12640,6 +12641,23 @@ body.theme-system .theme-dark__forced .s-btn--radio:checked + .s-btn {
1264012641
.s-btn--radio:checked + .s-btn .s-btn--badge .s-btn--number {
1264112642
color: var(--_bu-badge-fc, var(--_bu-fc));
1264212643
}
12644+
.s-btn.is-selected:before,
12645+
.s-btn--radio:checked + .s-btn:before {
12646+
background: linear-gradient(0deg, var(--_bu-bg-gradient-top) 50%, var(--_bu-bg-gradient-bottom) 50%) padding-box, linear-gradient(0deg, var(--_bu-bg-gradient-top) 50%, var(--_bu-bg-gradient-bottom) 50%) border-box;
12647+
border: 0;
12648+
border-radius: inherit;
12649+
content: "";
12650+
inset: 0;
12651+
pointer-events: none;
12652+
position: absolute;
12653+
z-index: -1;
12654+
}
12655+
@media (forced-colors: active) {
12656+
.s-btn.is-selected:before,
12657+
.s-btn--radio:checked + .s-btn:before {
12658+
display: none;
12659+
}
12660+
}
1264312661
.s-btn .s-btn--badge {
1264412662
background-color: var(--_bu-badge-bg);
1264512663
border-radius: var(--br-pill);
@@ -12697,9 +12715,15 @@ body.theme-system .theme-dark__forced .s-btn--radio:checked + .s-btn {
1269712715
outline: var(--su2) solid transparent !important;
1269812716
}
1269912717
.s-btn:not(.is-selected):hover {
12700-
background: linear-gradient(0deg, var(--_bu-bg-selected-overlay, var(--_bu-bg-hover, var(--_bu-bg))) 50%, var(--_bu-bg-hover, var(--_bu-bg-selected, var(--_bu-bg))) 50%) padding-box, linear-gradient(0deg, var(--_bu-bg-selected-overlay, var(--_bu-bg-hover, var(--_bu-bg))) 50%, var(--_bu-bg-hover, var(--_bu-bg-selected, var(--_bu-bg))) 50%) border-box;
12718+
background: var(--_bu-bg-hover, var(--_bu-bg));
1270112719
color: var(--_bu-fc-hover, var(--_bu-fc));
1270212720
}
12721+
.s-btn--radio:checked + .s-btn:where(:not(.is-selected):hover) {
12722+
background: var(--_bu-bg-hover, var(--_bu-bg-selected, var(--_bu-bg)));
12723+
}
12724+
.s-btn--radio:checked + .s-btn:where(:not(.is-selected):hover):before {
12725+
background: linear-gradient(0deg, var(--_bu-bg-selected-overlay, var(--_bu-bg-hover, var(--_bu-bg))) 50%, var(--_bu-bg-hover, var(--_bu-bg-selected, var(--_bu-bg))) 50%) padding-box, linear-gradient(0deg, var(--_bu-bg-selected-overlay, var(--_bu-bg-hover, var(--_bu-bg))) 50%, var(--_bu-bg-hover, var(--_bu-bg-selected, var(--_bu-bg))) 50%) border-box;
12726+
}
1270312727
fieldset[disabled] .s-btn,
1270412728
.s-btn[disabled],
1270512729
.s-btn[aria-disabled="true"] {
@@ -12715,6 +12739,14 @@ fieldset[disabled] .s-btn:hover,
1271512739
border-color: var(--_bu-bc-disabled, var(--_bu-bc, transparent));
1271612740
color: var(--_bu-fc-disabled, var(--_bu-fc));
1271712741
}
12742+
fieldset[disabled] .s-btn:before,
12743+
.s-btn[disabled]:before,
12744+
.s-btn[aria-disabled="true"]:before,
12745+
fieldset[disabled] .s-btn:hover:before,
12746+
.s-btn[disabled]:hover:before,
12747+
.s-btn[aria-disabled="true"]:hover:before {
12748+
content: none;
12749+
}
1271812750
fieldset[disabled] .s-btn .s-btn--badge,
1271912751
.s-btn[disabled] .s-btn--badge,
1272012752
.s-btn[aria-disabled="true"] .s-btn--badge,

‎packages/stacks-classic/lib/components/button/button.a11y.test.ts‎

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import testArgs from "./button.test.setup";
22
import { runA11yTests } from "../../test/a11y-test-utils";
3+
import { expect, fixture, html } from "@open-wc/testing";
4+
import { resetMouse, sendMouse } from "@web/test-runner-commands";
5+
import axe from "axe-core";
6+
import Color from "colorjs.io";
37
import "../../index";
48

59
describe("button", () => {
@@ -9,4 +13,121 @@ describe("button", () => {
913
/^s-btn-(?=.*unset).*badge$/, // s-btn with badge and unset variant not supported
1014
],
1115
});
16+
17+
[
18+
"theme-light",
19+
"theme-dark",
20+
"theme-light theme-highcontrast",
21+
"theme-dark theme-highcontrast",
22+
].forEach((theme) => {
23+
it(`measures ordinary and selected backgrounds in ${theme}, including hover`, async () => {
24+
const originalTheme = document.body.className;
25+
try {
26+
document.body.className = theme;
27+
const el = await fixture<HTMLElement>(html`
28+
<div class="bg-white p16 d-flex fd-column ai-start g8">
29+
<button class="s-btn" type="button">
30+
<span>Ordinary</span>
31+
</button>
32+
${[
33+
"",
34+
"s-btn__danger",
35+
"s-btn__featured",
36+
"s-btn__tonal",
37+
"s-btn__clear",
38+
"s-btn__danger s-btn__clear",
39+
].map(
40+
(variant) => html`
41+
<button
42+
class="s-btn is-selected ${variant}"
43+
type="button"
44+
aria-pressed="true"
45+
>
46+
<span>Selected</span>
47+
</button>
48+
`
49+
)}
50+
<input
51+
class="s-btn--radio"
52+
id="selected-radio"
53+
type="radio"
54+
checked
55+
/>
56+
<label class="s-btn" for="selected-radio"
57+
><span>Selected radio</span></label
58+
>
59+
</div>
60+
`);
61+
for (const hovered of [false, true]) {
62+
for (const button of el.querySelectorAll<HTMLElement>(
63+
".s-btn"
64+
)) {
65+
if (hovered) {
66+
const before = getComputedStyle(
67+
button,
68+
"::before"
69+
).backgroundImage;
70+
const { x, y, width, height } =
71+
button.getBoundingClientRect();
72+
await sendMouse({
73+
type: "move",
74+
position: [
75+
Math.round(x + width / 2),
76+
Math.round(y + height / 2),
77+
],
78+
});
79+
if (button.matches("label")) {
80+
expect(
81+
getComputedStyle(button, "::before")
82+
.backgroundImage
83+
).not.to.equal(before);
84+
}
85+
}
86+
expect(
87+
getComputedStyle(button).backgroundImage
88+
).to.equal("none");
89+
const rule = "color-contrast-apca-custom";
90+
const results = await axe.run(button, {
91+
runOnly: [rule],
92+
rules: { [rule]: { enabled: true } },
93+
});
94+
expect(results.incomplete).to.be.empty;
95+
expect(results.violations).to.be.empty;
96+
expect(results.passes).to.have.lengthOf(1);
97+
expect(results.passes[0].nodes).to.have.lengthOf(1);
98+
99+
if (!button.matches(".is-selected, label")) {
100+
continue;
101+
}
102+
// Axe measures the solid base, so check both visible gradient colors separately.
103+
const stops = getComputedStyle(
104+
button,
105+
"::before"
106+
).backgroundImage.match(/(?:rgba?|color)\([^)]*\)/g);
107+
expect(stops).to.have.lengthOf(4);
108+
const foreground = new Color(
109+
getComputedStyle(button).color
110+
);
111+
const highcontrast = theme.includes("highcontrast");
112+
for (const stop of stops!.slice(0, 2)) {
113+
const background = new Color(stop);
114+
const contrast = Math.abs(
115+
background.contrast(
116+
foreground,
117+
highcontrast ? "WCAG21" : "APCA"
118+
)
119+
);
120+
expect(
121+
contrast,
122+
`${theme}: ${button.className} against ${stop}`
123+
).to.be.at.least(highcontrast ? 7 : 60);
124+
}
125+
}
126+
}
127+
} finally {
128+
document.body.className = originalTheme;
129+
await resetMouse();
130+
}
131+
});
132+
});
12133
});

‎packages/stacks-classic/lib/components/button/button.less‎

Lines changed: 50 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -284,15 +284,33 @@
284284
background-color: var(--_bu-badge-bg);
285285
}
286286

287-
background: linear-gradient(0deg,
288-
var(--_bu-bg-gradient-top) 50%,
289-
var(--_bu-bg-gradient-bottom) 50%
290-
) padding-box,
291-
linear-gradient(0deg,
292-
var(--_bu-bg-gradient-top) 50%,
293-
var(--_bu-bg-gradient-bottom) 50%
294-
) border-box;
287+
&:before {
288+
// Keep decoration inside the actual border, including border-width utility overrides.
289+
background: linear-gradient(0deg,
290+
var(--_bu-bg-gradient-top) 50%,
291+
var(--_bu-bg-gradient-bottom) 50%
292+
) padding-box,
293+
linear-gradient(0deg,
294+
var(--_bu-bg-gradient-top) 50%,
295+
var(--_bu-bg-gradient-bottom) 50%
296+
) border-box;
297+
border: 0;
298+
border-radius: inherit;
299+
content: "";
300+
inset: 0;
301+
pointer-events: none;
302+
position: absolute;
303+
z-index: -1;
304+
305+
@media (forced-colors: active) {
306+
display: none;
307+
}
308+
}
309+
310+
// The solid base is measurable by axe; tests check both decorative colors separately.
311+
background: var(--_bu-bg-selected, var(--_bu-bg));
295312
color: var(--_bu-fc-selected, var(--_bu-fc));
313+
isolation: isolate;
296314
}
297315

298316
// CHILD ELEMENTS
@@ -359,23 +377,36 @@
359377
}
360378

361379
&:not(.is-selected):hover {
362-
background:
363-
linear-gradient(0deg,
364-
var(--_bu-bg-selected-overlay, var(--_bu-bg-hover, var(--_bu-bg))) 50%,
365-
var(--_bu-bg-hover, var(--_bu-bg-selected, var(--_bu-bg))) 50%
366-
) padding-box,
367-
linear-gradient(0deg,
368-
var(--_bu-bg-selected-overlay, var(--_bu-bg-hover, var(--_bu-bg))) 50%,
369-
var(--_bu-bg-hover, var(--_bu-bg-selected, var(--_bu-bg))) 50%
370-
) border-box;
380+
background: var(--_bu-bg-hover, var(--_bu-bg));
371381
color: var(--_bu-fc-hover, var(--_bu-fc));
372382
}
373383

384+
// Preserve checked radio hover colors, without increasing specificity over disabled states.
385+
.s-btn--radio:checked + &:where(:not(.is-selected):hover) {
386+
&:before {
387+
background:
388+
linear-gradient(0deg,
389+
var(--_bu-bg-selected-overlay, var(--_bu-bg-hover, var(--_bu-bg))) 50%,
390+
var(--_bu-bg-hover, var(--_bu-bg-selected, var(--_bu-bg))) 50%
391+
) padding-box,
392+
linear-gradient(0deg,
393+
var(--_bu-bg-selected-overlay, var(--_bu-bg-hover, var(--_bu-bg))) 50%,
394+
var(--_bu-bg-hover, var(--_bu-bg-selected, var(--_bu-bg))) 50%
395+
) border-box;
396+
}
397+
398+
background: var(--_bu-bg-hover, var(--_bu-bg-selected, var(--_bu-bg)));
399+
}
400+
374401
fieldset[disabled] &,
375402
&[disabled],
376403
&[aria-disabled="true"] {
377404
&,
378405
&:hover {
406+
&:before {
407+
content: none;
408+
}
409+
379410
.s-btn--badge {
380411
.s-btn--number {
381412
color: var(--_bu-badge-fc-disabled);
@@ -392,15 +423,8 @@
392423
cursor: auto;
393424
}
394425

395-
background:
396-
linear-gradient(0deg,
397-
var(--_bu-bg-selected-overlay, var(--_bu-bg)) 50%,
398-
var(--_bu-bg) 50%
399-
) padding-box,
400-
linear-gradient(0deg,
401-
var(--_bu-bg-selected-overlay, var(--_bu-bg)) 50%,
402-
var(--_bu-bg) 50%
403-
) border-box;
426+
// Selected gradients are decorative layers; the button itself always has a solid background.
427+
background: var(--_bu-bg);
404428
border: var(--_bu-baw) solid var(--_bu-bc, transparent);
405429
color: var(--_bu-fc);
406430
font-size: var(--_bu-fs);

‎packages/stacks-classic/lib/components/button/button.visual.test.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,39 @@ describe("button", () => {
1515
</div>
1616
`,
1717
});
18+
19+
runVisualTests({
20+
baseClass: "s-btn-selected",
21+
template: ({ testid }) => html`
22+
<div class="bg-white p16 d-flex fw-wrap g8" data-testid="${testid}">
23+
${[
24+
"",
25+
"s-btn__danger",
26+
"s-btn__featured",
27+
"s-btn__tonal",
28+
"s-btn__clear",
29+
"s-btn__danger s-btn__clear",
30+
].map(
31+
(variant) => html`
32+
<button
33+
class="s-btn is-selected ${variant}"
34+
type="button"
35+
aria-pressed="true"
36+
>
37+
Selected
38+
</button>
39+
`
40+
)}
41+
<input
42+
class="s-btn--radio"
43+
id="selected-visual-radio"
44+
type="radio"
45+
checked
46+
/>
47+
<label class="s-btn" for="selected-visual-radio"
48+
>Selected radio</label
49+
>
50+
</div>
51+
`,
52+
});
1853
});
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
version https://git-lfs.github.com/spec/v1
2+
oid sha256:29d5b1e033606792fc68dd5926aad550f2a90875a07f83908995102b365d1c59
3+
size 13604
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
version https://git-lfs.github.com/spec/v1
2+
oid sha256:2b8d97f4060f7cc9a667086be373d652b122d7e48f81f84338ac4724051f9672
3+
size 14432
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
version https://git-lfs.github.com/spec/v1
2+
oid sha256:2e477bba3273bb749d1fb83ce5681bdb211678e78c5487aa898cde7f5003953b
3+
size 14870
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
version https://git-lfs.github.com/spec/v1
2+
oid sha256:ad477a98060bb458064831cfd0736eec5c6be44fd2b39a3942dda4a012108245
3+
size 14013
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
version https://git-lfs.github.com/spec/v1
2+
oid sha256:35a37e3f07e923aaae170984cb213fb71f449240672c24a5d910866f0b5761a3
3+
size 14219

0 commit comments

Comments
 (0)