Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/solid-button-backgrounds.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@stackoverflow/stacks": patch
---

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.
Original file line number Diff line number Diff line change
Expand Up @@ -12250,7 +12250,7 @@ body.theme-highcontrast.theme-dark .s-bling.s-bling__filled.s-bling__bronze {
--_bu-badge-px: var(--su6);
--_bu-badge-py: var(--su2);
--_bu-dropdown-bw: var(--su4);
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;
background: var(--_bu-bg);
border: var(--_bu-baw) solid var(--_bu-bc, transparent);
color: var(--_bu-fc);
font-size: var(--_bu-fs);
Expand Down Expand Up @@ -12611,8 +12611,9 @@ p .s-btn.s-btn__link {
--_bu-bg-selected-overlay: color-mix(in srgb, var(--_bu-bg-selected, var(--_bu-bg)), var(--white) var(--_bu-bg-selected-overlay-o));
--_bu-bg-gradient-top: var(--_bu-bg-selected-overlay, var(--_bu-bg));
--_bu-bg-gradient-bottom: var(--_bu-bg-selected, var(--_bu-bg));
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;
background: var(--_bu-bg-selected, var(--_bu-bg));
color: var(--_bu-fc-selected, var(--_bu-fc));
isolation: isolate;
}
@media (prefers-color-scheme: dark) {
body.theme-system .s-btn.is-selected,
Expand Down Expand Up @@ -12640,6 +12641,23 @@ body.theme-system .theme-dark__forced .s-btn--radio:checked + .s-btn {
.s-btn--radio:checked + .s-btn .s-btn--badge .s-btn--number {
color: var(--_bu-badge-fc, var(--_bu-fc));
}
.s-btn.is-selected:before,
.s-btn--radio:checked + .s-btn:before {
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;
border: 0;
border-radius: inherit;
content: "";
inset: 0;
pointer-events: none;
position: absolute;
z-index: -1;
}
@media (forced-colors: active) {
.s-btn.is-selected:before,
.s-btn--radio:checked + .s-btn:before {
display: none;
}
}
.s-btn .s-btn--badge {
background-color: var(--_bu-badge-bg);
border-radius: var(--br-pill);
Expand Down Expand Up @@ -12697,9 +12715,15 @@ body.theme-system .theme-dark__forced .s-btn--radio:checked + .s-btn {
outline: var(--su2) solid transparent !important;
}
.s-btn:not(.is-selected):hover {
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;
background: var(--_bu-bg-hover, var(--_bu-bg));
color: var(--_bu-fc-hover, var(--_bu-fc));
}
.s-btn--radio:checked + .s-btn:where(:not(.is-selected):hover) {
background: var(--_bu-bg-hover, var(--_bu-bg-selected, var(--_bu-bg)));
}
.s-btn--radio:checked + .s-btn:where(:not(.is-selected):hover):before {
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;
}
fieldset[disabled] .s-btn,
.s-btn[disabled],
.s-btn[aria-disabled="true"] {
Expand All @@ -12715,6 +12739,14 @@ fieldset[disabled] .s-btn:hover,
border-color: var(--_bu-bc-disabled, var(--_bu-bc, transparent));
color: var(--_bu-fc-disabled, var(--_bu-fc));
}
fieldset[disabled] .s-btn:before,
.s-btn[disabled]:before,
.s-btn[aria-disabled="true"]:before,
fieldset[disabled] .s-btn:hover:before,
.s-btn[disabled]:hover:before,
.s-btn[aria-disabled="true"]:hover:before {
content: none;
}
fieldset[disabled] .s-btn .s-btn--badge,
.s-btn[disabled] .s-btn--badge,
.s-btn[aria-disabled="true"] .s-btn--badge,
Expand Down
121 changes: 121 additions & 0 deletions packages/stacks-classic/lib/components/button/button.a11y.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import testArgs from "./button.test.setup";
import { runA11yTests } from "../../test/a11y-test-utils";
import { expect, fixture, html } from "@open-wc/testing";
import { resetMouse, sendMouse } from "@web/test-runner-commands";
import axe from "axe-core";
import Color from "colorjs.io";
import "../../index";

describe("button", () => {
Expand All @@ -9,4 +13,121 @@ describe("button", () => {
/^s-btn-(?=.*unset).*badge$/, // s-btn with badge and unset variant not supported
],
});

[
"theme-light",
"theme-dark",
"theme-light theme-highcontrast",
"theme-dark theme-highcontrast",
].forEach((theme) => {
it(`measures ordinary and selected backgrounds in ${theme}, including hover`, async () => {
const originalTheme = document.body.className;
try {
document.body.className = theme;
const el = await fixture<HTMLElement>(html`
<div class="bg-white p16 d-flex fd-column ai-start g8">
<button class="s-btn" type="button">
<span>Ordinary</span>
</button>
${[
"",
"s-btn__danger",
"s-btn__featured",
"s-btn__tonal",
"s-btn__clear",
"s-btn__danger s-btn__clear",
].map(
(variant) => html`
<button
class="s-btn is-selected ${variant}"
type="button"
aria-pressed="true"
>
<span>Selected</span>
</button>
`
)}
<input
class="s-btn--radio"
id="selected-radio"
type="radio"
checked
/>
<label class="s-btn" for="selected-radio"
><span>Selected radio</span></label
>
</div>
`);
for (const hovered of [false, true]) {
for (const button of el.querySelectorAll<HTMLElement>(
".s-btn"
)) {
if (hovered) {
const before = getComputedStyle(
button,
"::before"
).backgroundImage;
const { x, y, width, height } =
button.getBoundingClientRect();
await sendMouse({
type: "move",
position: [
Math.round(x + width / 2),
Math.round(y + height / 2),
],
});
if (button.matches("label")) {
expect(
getComputedStyle(button, "::before")
.backgroundImage
).not.to.equal(before);
}
}
expect(
getComputedStyle(button).backgroundImage
).to.equal("none");
const rule = "color-contrast-apca-custom";
const results = await axe.run(button, {
runOnly: [rule],
rules: { [rule]: { enabled: true } },
});
expect(results.incomplete).to.be.empty;
expect(results.violations).to.be.empty;
expect(results.passes).to.have.lengthOf(1);
expect(results.passes[0].nodes).to.have.lengthOf(1);

if (!button.matches(".is-selected, label")) {
continue;
}
// Axe measures the solid base, so check both visible gradient colors separately.
const stops = getComputedStyle(
button,
"::before"
).backgroundImage.match(/(?:rgba?|color)\([^)]*\)/g);
expect(stops).to.have.lengthOf(4);
const foreground = new Color(
getComputedStyle(button).color
);
const highcontrast = theme.includes("highcontrast");
for (const stop of stops!.slice(0, 2)) {
const background = new Color(stop);
const contrast = Math.abs(
background.contrast(
foreground,
highcontrast ? "WCAG21" : "APCA"
)
);
expect(
contrast,
`${theme}: ${button.className} against ${stop}`
).to.be.at.least(highcontrast ? 7 : 60);
}
}
}
} finally {
document.body.className = originalTheme;
await resetMouse();
}
});
});
});
76 changes: 50 additions & 26 deletions packages/stacks-classic/lib/components/button/button.less
Original file line number Diff line number Diff line change
Expand Up @@ -284,15 +284,33 @@
background-color: var(--_bu-badge-bg);
}

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;
&:before {
// Keep decoration inside the actual border, including border-width utility overrides.
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;
border: 0;
border-radius: inherit;
content: "";
inset: 0;
pointer-events: none;
position: absolute;
z-index: -1;

@media (forced-colors: active) {
display: none;
}
}

// The solid base is measurable by axe; tests check both decorative colors separately.
background: var(--_bu-bg-selected, var(--_bu-bg));
color: var(--_bu-fc-selected, var(--_bu-fc));
isolation: isolate;
}

// CHILD ELEMENTS
Expand Down Expand Up @@ -359,23 +377,36 @@
}

&:not(.is-selected):hover {
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;
background: var(--_bu-bg-hover, var(--_bu-bg));
color: var(--_bu-fc-hover, var(--_bu-fc));
}

// Preserve checked radio hover colors, without increasing specificity over disabled states.
.s-btn--radio:checked + &:where(:not(.is-selected):hover) {
&:before {
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;
}

background: var(--_bu-bg-hover, var(--_bu-bg-selected, var(--_bu-bg)));
}

fieldset[disabled] &,
&[disabled],
&[aria-disabled="true"] {
&,
&:hover {
&:before {
content: none;
}

.s-btn--badge {
.s-btn--number {
color: var(--_bu-badge-fc-disabled);
Expand All @@ -392,15 +423,8 @@
cursor: auto;
}

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;
// Selected gradients are decorative layers; the button itself always has a solid background.
background: var(--_bu-bg);
border: var(--_bu-baw) solid var(--_bu-bc, transparent);
color: var(--_bu-fc);
font-size: var(--_bu-fs);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,39 @@ describe("button", () => {
</div>
`,
});

runVisualTests({
baseClass: "s-btn-selected",
template: ({ testid }) => html`
<div class="bg-white p16 d-flex fw-wrap g8" data-testid="${testid}">
${[
"",
"s-btn__danger",
"s-btn__featured",
"s-btn__tonal",
"s-btn__clear",
"s-btn__danger s-btn__clear",
].map(
(variant) => html`
<button
class="s-btn is-selected ${variant}"
type="button"
aria-pressed="true"
>
Selected
</button>
`
)}
<input
class="s-btn--radio"
id="selected-visual-radio"
type="radio"
checked
/>
<label class="s-btn" for="selected-visual-radio"
>Selected radio</label
>
</div>
`,
});
});
Git LFS file not shown
Git LFS file not shown
Git LFS file not shown
Git LFS file not shown
Git LFS file not shown
Git LFS file not shown
Git LFS file not shown
Git LFS file not shown
Git LFS file not shown
Loading
Loading