Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(new-rule): Add WCAG 2.2 target-size rule #3616

Merged
merged 30 commits into from
Sep 21, 2022
Merged

Conversation

WilcoFiers
Copy link
Contributor

@WilcoFiers WilcoFiers commented Aug 19, 2022

  • Better handle rounding errors, so that the minimum is not 23.95, but actually 24px
  • Tests for target-offset
  • Report the offset to the closest neighbour, and indicate the expected offset
  • Update the selector to ensure all focusable widgets are included -- may need a matcher too
  • Have a matcher filter out inline controls
  • Add more integration tests that deal with the selector / matcher
  • Document the new check options

Closes issue: #2652

@WilcoFiers WilcoFiers marked this pull request as ready for review September 2, 2022 13:08
@WilcoFiers WilcoFiers requested a review from a team as a code owner September 2, 2022 13:08
Comment on lines 19 to 21
if ('option' === role) {
return false;
} else if (['combobox', 'listbox'].includes(role)) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For consideration; I could potentially change the role type of these three so that everything we want is a widget, and everything we don't isn't. I didn't explore what the wider implications of that could be.

Copy link
Contributor

@straker straker left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not done reviewing this, but figured I get this out there now so you could work on the things I did find.

lib/commons/dom/find-nearby-elms.js Outdated Show resolved Hide resolved
lib/commons/math/get-offset.js Show resolved Hide resolved
lib/commons/math/get-offset.js Outdated Show resolved Hide resolved
(node, vNode) => isWidgetType(vNode),
(node, vNode) => isNotAreaElement(vNode),
(node, vNode) => !svgNamespaceMatches(node, vNode),
(node, vNode) => isFocusable(vNode),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this exclude hidden elements? Currently an element with opacity: 0 will be included and then error in findNearbyElms as it's not in the grid and doesn't have a grid property so const gridCells = vNode._grid.cells; fails.

This is related to my comment about createGrid excluding hidden elements.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tricky. An element with opacity: 0 is still clickable. All those other on screen visibility methods do prevent focus, but this one doesn't... Also, maybe I need to think about CSS pointer-events, which makes a target non-clickable....


function findNearbyElms(vNode, margin = 0) {
/*eslint no-bitwise: 0*/
const gridSize = createGrid();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So createGrid was written with color-contrast in mind and so doesn't include visually hidden elements in the grid. However, now that we want to use it for clickable widget elements it creates a bug. Take for example the following code (modified B4 example):

<script>function clicked() { console.log('click') }</script>
<span class="failed h2 w3"></span>
<span style="opacity: 0" class="failed h2 w3" onclick="clicked()"></span>
<span class="failed h2 w3"></span>

Both of the visible spans pass the target-size rule, but the opacity 0 span is still clickable, meaning that it still would cause a problem of clicking the wrong element when too close together. I would imagine this should fail the rule still.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another question: How should we handle things on-top of these nodes? For example, taking the following code (modified B4 example again):

<style>
  .foo {
    width: 400px;
    height: 400px;
    position: absolute !important;
    background: red;
    top: -5px;
    z-index: 2;
  }
</style>
<div>
  <div class="foo"></div>
  <span class="failed h2 w3"></span>
  <span class="failed h2 w3"></span>
  <span class="failed h2 w3"></span>
</div>

The nodes still fail even though it's not possible to click on them. Same if a modal is currently open. Should we fail these nodes still?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ended up doing the overlap thing in the target-size check, rather than in the matches, since target-size is already looking at overlapping elements. Much quicker to sort out there.


return parentText.length > linkText.length;
function isWidget(domNode) {
Copy link
Contributor

@straker straker Sep 6, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the second instance of this function (the other being in widget-not-inline-matches.js, but they are slightly different. The widget-not-inline-matches.js only accounts for listbox and combobox composite roles, but this one includes all composite roles. Should this function be pulled out and made a shared function that both use?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noticed that did you?... You're right. Need to fix that. I'll go find a better solution. I don't quite like how wonky this is with the listbox / combobox exception. I might just change what type we give to them and avoid needing a function at all.


beforeEach(function () {
fixture = fixtureSetup(
'<div id="n0" style="height:30px; margin-bottom:30px;">0</div>' +
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test fails when debugging in a browser due to a Mocha element.

Test result for 'returns node from the same grid cell' showing failure due to 'mocha-stats' node being included in the results

Should probably set the width of these nodes to something small (like 30px) so it doesn't try to include extraneous elements outside of fixture.

Copy link
Contributor Author

@WilcoFiers WilcoFiers Sep 20, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do I get to this? I couldn't find it. And should we care?

Copy link
Contributor

@straker straker left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More things (still working my way through)

lib/checks/mobile/target-offset.json Outdated Show resolved Hide resolved
lib/checks/mobile/target-offset.json Outdated Show resolved Hide resolved
let closestOffset = minOffset;
for (const vNeighbor of findNearbyElms(vNode, minOffset)) {
const role = getRole(vNeighbor);
if (getRoleType(role) !== 'widget') {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this match nodes based on how the matcher does it? So something like:

(node, vNode) => isWidgetType(vNode),
(node, vNode) => isNotAreaElement(vNode),
(node, vNode) => !svgNamespaceMatches(node, vNode),
(node, vNode) => isFocusable(vNode)

This would fix a potential problem where a neighbor button is disabled (modified B4 example again). In this case the first and last button report as violations even though you can't click on the middle button.

<div>
  <button class="failed h2 w3"></button>
  <button disabled class="failed h2 w3"></button>
  <button class="failed h2 w3"></button>
</div>

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, although not exactly. Disabled buttons should probably be excluded... that's a fair enough point, but I don't think we should exclude inline links for example, or area / SVG widgets. If you put a button too close to your SVG, the button's still the problem, even if axe passes on the SVG.

lib/checks/mobile/target-size.json Outdated Show resolved Hide resolved
const nearbyElms = findNearbyElms(vNode);
const obscuringElms = nearbyElms.filter(vNeighbor => {
const role = getRole(vNeighbor);
return getRoleType(role) === 'widget' && hasVisualOverlap(vNode, vNeighbor);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same question here about filtering same as the matcher function

Copy link
Contributor

@straker straker left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finished

const centerB = rectB[start] + rectB[diameter] / 2;
const startDistance = Math.abs(centerB - rectA[start]);
const endDistance = Math.abs(centerB - rectA[end]);
if (startDistance >= endDistance) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should equal distances use the middle of B?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the distance is the same it doesn't matter.

lib/commons/math/get-offset.js Show resolved Hide resolved
lib/commons/math/get-offset.js Show resolved Hide resolved
lib/commons/math/get-offset.js Outdated Show resolved Hide resolved
lib/commons/math/has-visual-overlap.js Outdated Show resolved Hide resolved
@@ -0,0 +1,44 @@
import { visuallySort } from '../dom';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this function different than dom.visuallyOverlaps? Would hate to have multiple functions that are named the same do different things

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope... but this is a good bit confusing. dom.visuallyOverlaps looks that a rect intersects with a particular element. It doesn't check whether A is on top of B, or B is on top of A. Probably dom.visuallyOverlaps should have been called rectIntersectsElement or something like that? Don't think it impacts this PR.

Comment on lines 49 to 55
it('returns false for a non-tabbable button (widgets)', function () {
var vNode = queryFixture(
'<div role="option" tabindex="0" id="target"></div>'
);
var node = vNode.actualNode;
assert.isFalse(rule.matches(node, vNode));
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this title correct? Why would this be non-tabbable?

});

// IE cannot count
(isIE11 ? xit : it)(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can remove this now

lib/checks/mobile/target-size.json Outdated Show resolved Hide resolved
});
});

it('returns true for obscured targets with insufficient space', function () {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be sufficient space? The test below this one has the same title but a different return.

Comment on lines +328 to +333
if (rect.right <= 0 || rect.bottom <= 0) {
return;
}
// save a reference to where this element is in the grid so we
// can find it even if it's in a subgrid
vNode._grid ??= grid;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMPORTANT: These lines have been modified to fix a bug where elements outside the viewport could still be added to the grid.

const role = getRole(child);
if (getRoleType(role) === 'widget' && isFocusable(child)) {
if (getRoleType(child) === 'widget' && isFocusable(child)) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you want me to pull these into their own PR let me know. It's not unrelated to the PR... but it's not very related either.

Comment on lines +13 to +17
if (
role instanceof AbstractVirtualNode ||
(window?.Node && role instanceof window.Node)
) {
role = axe.commons.aria.getRole(role);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: I've noticed that you've lately been wanting more strict type checking and guarding in our APIs. Is this something you want to do throughout the code base for our public API methods?

The simpler check here would be to see if role is not a string and assume it's a vNode / Node. A more strict type check would throw if it's not a type we expect (instead of assuming string as default).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't noticed that. Have I really? I used instanceof here because that's what we usually do when we want to check nodes. There are lots of instanceof AbstractVirtualNode calls.. and a couple instanceof window.Node calls too.

Looking at where/how this function has been used, it's going to get null passed into it a good bit too, since getRole() can return null. I'll update it to reflect that.

for (let col = leftCol; col <= rightCol; col++) {
// Don't loop on elements outside the grid
const length = gridCells[row]?.[col]?.length ?? -1;
for (let i = 0; i <= length; i++) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
for (let i = 0; i <= length; i++) {
for (let i = 0; i < length; i++) {

* @return {Boolean} [description]
*/
function isInTextBlock(node) {
function isInTextBlock(node, options) {
const { getRoleType } = axe.commons.aria;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason you didn't import the function at the top of the file? We're trying to move away from using axe in the codebase.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ran into this error again: #3281

Looks like I can work around it by importing directly from get-role-type, rather than from aria/index. Doing that.

"metadata": {
"impact": "serious",
"messages": {
"pass": "Target has sufficiently offset from its closest neighbor (${data.closestOffset}px should be at least ${data.minOffset}px)",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"pass": "Target has sufficiently offset from its closest neighbor (${data.closestOffset}px should be at least ${data.minOffset}px)",
"pass": "Target has sufficient offset from its closest neighbor (${data.closestOffset}px should be at least ${data.minOffset}px)",

}
const roleType = getRoleType(role);
return roleType === 'widget';
return getRoleType(vNode) === 'widget';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this will return true for the option role, is that OK?

Copy link
Contributor Author

@WilcoFiers WilcoFiers Sep 21, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They do, but they're pulled out for not being focusable. There's an example that uses options (#pass2)

}
if (isEnclosedRect(vNode, vNeighbor)) {
this.data({ messageKey: 'obscured' });
return true;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should add the node as a related node

'display: inline-block; width:40px; height:30px; margin-left: -100px;' +
'">x</span>'
);
assert.isTrue(check.evaluate.apply(checkContext, checkArgs));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like this should be false. the span with tabindex=0 captures the click on the left side of the target, making the clickable width effectively 20px.

image

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is no different than if that was just a narrower button. If someone accidentally hits the span instead of the button, nothing happens. Or at least, we can't assume something is going to happen. If that span is a widget that is missing a role, a different accessibility violation exists in that page, and after they've fixed that (by adding a role to the span) this rule will work correctly.

We could maybe report this as incomplete? I'd prefer we do this later though. This PR is much larger than I want it to be already. Please open an issue for this.

'display: inline-block; width:40px; height:30px; margin-left: -100px;' +
'">x</button>'
);
assert.isTrue(check.evaluate.apply(checkContext, checkArgs));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel this should be false as well since the disabled button captures the click on the left side of the target, making the clickable width effectively 20px.

This can be debatable though as the disabled button doesn't cause a misclick to happen.

image

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same answer. I don't think we can fail this, maybe incomplete, but I'd prefer we leave that for another time.

straker
straker previously approved these changes Sep 21, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants