-
Notifications
You must be signed in to change notification settings - Fork 784
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
Conversation
c1116b5
to
0172fbe
Compare
b6ad64b
to
d3902bb
Compare
d3902bb
to
8ab55c7
Compare
if ('option' === role) { | ||
return false; | ||
} else if (['combobox', 'listbox'].includes(role)) { |
There was a problem hiding this comment.
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.
There was a problem hiding this 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.
(node, vNode) => isWidgetType(vNode), | ||
(node, vNode) => isNotAreaElement(vNode), | ||
(node, vNode) => !svgNamespaceMatches(node, vNode), | ||
(node, vNode) => isFocusable(vNode), |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
lib/commons/dom/is-in-text-block.js
Outdated
|
||
return parentText.length > linkText.length; | ||
function isWidget(domNode) { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
test/commons/dom/find-nearby-elms.js
Outdated
|
||
beforeEach(function () { | ||
fixture = fixtureSetup( | ||
'<div id="n0" style="height:30px; margin-bottom:30px;">0</div>' + |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
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?
There was a problem hiding this 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)
let closestOffset = minOffset; | ||
for (const vNeighbor of findNearbyElms(vNode, minOffset)) { | ||
const role = getRole(vNeighbor); | ||
if (getRoleType(role) !== 'widget') { |
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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.
const nearbyElms = findNearbyElms(vNode); | ||
const obscuringElms = nearbyElms.filter(vNeighbor => { | ||
const role = getRole(vNeighbor); | ||
return getRoleType(role) === 'widget' && hasVisualOverlap(vNode, vNeighbor); |
There was a problem hiding this comment.
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
There was a problem hiding this 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) { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
@@ -0,0 +1,44 @@ | |||
import { visuallySort } from '../dom'; |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
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)); | ||
}); |
There was a problem hiding this comment.
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?
test/checks/mobile/target-size.js
Outdated
}); | ||
|
||
// IE cannot count | ||
(isIE11 ? xit : it)( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can remove this now
test/checks/mobile/target-size.js
Outdated
}); | ||
}); | ||
|
||
it('returns true for obscured targets with insufficient space', function () { |
There was a problem hiding this comment.
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.
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; |
There was a problem hiding this comment.
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)) { |
There was a problem hiding this comment.
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.
if ( | ||
role instanceof AbstractVirtualNode || | ||
(window?.Node && role instanceof window.Node) | ||
) { | ||
role = axe.commons.aria.getRole(role); |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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.
lib/commons/dom/find-nearby-elms.js
Outdated
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++) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
for (let i = 0; i <= length; i++) { | |
for (let i = 0; i < length; i++) { |
lib/commons/dom/is-in-text-block.js
Outdated
* @return {Boolean} [description] | ||
*/ | ||
function isInTextBlock(node) { | ||
function isInTextBlock(node, options) { | ||
const { getRoleType } = axe.commons.aria; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
lib/checks/mobile/target-offset.json
Outdated
"metadata": { | ||
"impact": "serious", | ||
"messages": { | ||
"pass": "Target has sufficiently offset from its closest neighbor (${data.closestOffset}px should be at least ${data.minOffset}px)", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"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'; |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
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)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
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.
Closes issue: #2652