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

Fix toBeVisible visibility #428

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
181 changes: 159 additions & 22 deletions src/__tests__/to-be-visible.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,44 +2,181 @@ import {render} from './helpers/test-utils'
import document from './helpers/document'

describe('.toBeVisible', () => {
it('returns the visibility of an element', () => {
it('considers elements to be visible by default', () => {
const {container} = render(`
<div>
<header>
<h1 style="display: none">Main title</h1>
<h2 style="visibility: hidden">Secondary title</h2>
<h3 style="visibility: collapse">Secondary title</h3>
<h4 style="opacity: 0">Secondary title</h4>
<h5 style="opacity: 0.1">Secondary title</h5>
<h1>This is the title</h1>
</header>
<button hidden>Hidden button</button>
<section style="display: block; visibility: hidden">
<button>Hidden button</button>
<section>
<p>Hello <strong>World</strong></p>
</section>
</div>
`)

expect(container.querySelector('header')).toBeVisible()
expect(container.querySelector('h1')).not.toBeVisible()
expect(container.querySelector('h2')).not.toBeVisible()
expect(container.querySelector('h3')).not.toBeVisible()
expect(container.querySelector('h4')).not.toBeVisible()
expect(container.querySelector('h5')).toBeVisible()
expect(container.querySelector('button')).not.toBeVisible()
expect(container.querySelector('strong')).not.toBeVisible()
expect(container.querySelector('h1')).toBeVisible()
expect(container.querySelector('button')).toBeVisible()
expect(container.querySelector('strong')).toBeVisible()

expect(() =>
expect(container.querySelector('header')).not.toBeVisible(),
).toThrowError()
expect(() =>
expect(container.querySelector('p')).toBeVisible(),
).toThrowError()
})

test('detached element is not visible', () => {
const subject = document.createElement('div')
expect(subject).not.toBeVisible()
expect(() => expect(subject).toBeVisible()).toThrowError()
describe('with the "hidden" attribute', () => {
it('considers an element to not be visible', () => {

Choose a reason for hiding this comment

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

Suggested change
it('considers an element to not be visible', () => {
it('considers an element as not visible', () => {

const {container} = render('<button hidden>Click me</button>')
expect(container.querySelector('button')).not.toBeVisible()
expect(() =>
expect(container.querySelector('button')).toBeVisible(),
).toThrowError()
})
})

describe('display', () => {
it.each([
['inline'],
['block'],
['inline-block'],
['flex'],
['inline-flex'],
['grid'],
['inline-grid'],
])('considers "display: %s" as visible', display => {
const {container} = render(`
<div style="display: ${display}">
<button>Click me</button>
</div>,
`)
expect(container.querySelector('button')).toBeVisible()
expect(() =>
expect(container.querySelector('button')).not.toBeVisible(),
).toThrowError()
})

it('considers "display: none" as not visible', () => {
const {container} = render(`
<div style="display: none">
<button>Click me</button>
</div>,
`)
expect(container.querySelector('button')).not.toBeVisible()
expect(() =>
expect(container.querySelector('button')).toBeVisible(),
).toThrowError()
})

it('does not allow child elements to override invisibility by changing their own display style', () => {
const {container} = render(`
<div style="display: none">
<button style="display: block">Click me</button>
</div>
`)
expect(container.querySelector('button')).not.toBeVisible()
expect(() =>
expect(container.querySelector('button')).toBeVisible(),
).toThrowError()
})
})

describe('visibility', () => {
it('considers "visibility: visible" as visible', () => {
const {container} = render(`
<div style="visibility: visible">
<button>Click me</button>
</div>,
`)
expect(container.querySelector('button')).toBeVisible()
expect(() =>
expect(container.querySelector('button')).not.toBeVisible(),
).toThrowError()
})

it('considers "visibility: hidden" as not visible', () => {
const {container} = render(`
<div style="visibility: hidden">
<button>Click me</button>
</div>,
`)
expect(container.querySelector('button')).not.toBeVisible()
expect(() =>
expect(container.querySelector('button')).toBeVisible(),
).toThrowError()
})

it('considers "visibility: collapse" as not visible', () => {
const {container} = render(`
<div style="visibility: hidden">
<button>Click me</button>
</div>,
`)
expect(container.querySelector('button')).not.toBeVisible()
expect(() =>
expect(container.querySelector('button')).toBeVisible(),
).toThrowError()
})

it('allows child elements to override invisibility by changing their own visibility style', () => {
const {container} = render(`
<div style="visibility: hidden">
<button style="visibility: visible">Click me</button>
</div>
`)
expect(container.querySelector('button')).toBeVisible()
expect(() =>
expect(container.querySelector('button')).not.toBeVisible(),
).toThrowError()
})
})

describe('opacity', () => {
it('considers "opacity: 0" as not visible', () => {
const {container} = render(`
<div style="opacity: 0">
<button>Click me</button>
</div>,
`)
expect(container.querySelector('button')).not.toBeVisible()
expect(() =>
expect(container.querySelector('button')).toBeVisible(),
).toThrowError()
})

it('considers "opacity > 0" as visible', () => {
const {container} = render(`
<div style="opacity: 0.1">
<button>Click me</button>
</div>,
`)
expect(container.querySelector('button')).toBeVisible()
expect(() =>
expect(container.querySelector('button')).not.toBeVisible(),
).toThrowError()
})

it('does not allow child elements to override invisibility by increasing their own opacity', () => {
const {container} = render(`
<div style="opacity: 0">
<button style="opacity: 1">Click me</button>
</div>
`)
expect(container.querySelector('button')).not.toBeVisible()
expect(() =>
expect(container.querySelector('button')).toBeVisible(),
).toThrowError()
})
})

describe('detached element', () => {
it('is not visible', () => {
const subject = document.createElement('div')
expect(subject).not.toBeVisible()
expect(() => {
expect(subject).toBeVisible()
}).toThrowError()
})
})

describe('with a <details /> element', () => {
Expand Down
66 changes: 48 additions & 18 deletions src/to-be-visible.js
Original file line number Diff line number Diff line change
@@ -1,33 +1,63 @@
import {checkHtmlElement} from './utils'

function isStyleVisible(element) {
function getElementVisibilityStyle(element) {
if (!element) return 'visible'
Copy link
Member

Choose a reason for hiding this comment

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

This is a bit odd. If the element is falsy it should be considered "visible"?

Copy link
Member Author

Choose a reason for hiding this comment

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

Hmmm, good point. This clarifies your point about bailing out quickly. I'll make the change.

Copy link
Member

Choose a reason for hiding this comment

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

Yeah, for visibility you don't need to walk up since it the computed visbility of parents doesn't matter. only the computed visibility of the element asserted on matters.

This was relevant in earlier versions of JSOM where visibility wasn't inherited.

const {getComputedStyle} = element.ownerDocument.defaultView
const {visibility} = getComputedStyle(element)
return visibility || getElementVisibilityStyle(element.parentElement)
Copy link
Member

Choose a reason for hiding this comment

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

Why do you walk up the tree here? visibility should never be falsy unless the user agent has an incorrect implementation of the CSS spec. visibility has an initial value of visible

}

const {display, visibility, opacity} = getComputedStyle(element)
return (
display !== 'none' &&
visibility !== 'hidden' &&
visibility !== 'collapse' &&
opacity !== '0' &&
opacity !== 0
)
function isVisibleSummaryDetails(element, previousElement) {
return element.nodeName === 'DETAILS' &&
previousElement.nodeName !== 'SUMMARY'
? element.hasAttribute('open')
Copy link
Member

Choose a reason for hiding this comment

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

question not directly related to this PR: Is the attribute or property relevant i.e. when I set someDetailsElement.open = false will element.hasAttribute('open') return true?

Copy link
Member Author

Choose a reason for hiding this comment

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

I'm honestly not sure. I did not even implement the support for summary/detail. It may be worth checking.

: true
}

function isAttributeVisible(element, previousElement) {
function isElementTreeVisible(element, previousElement = undefined) {
const {getComputedStyle} = element.ownerDocument.defaultView
const {display, opacity} = getComputedStyle(element)
return (
display !== 'none' &&
opacity !== '0' &&
!element.hasAttribute('hidden') &&
(element.nodeName === 'DETAILS' && previousElement.nodeName !== 'SUMMARY'
? element.hasAttribute('open')
isVisibleSummaryDetails(element, previousElement) &&
(element.parentElement
? isElementTreeVisible(element.parentElement, element)
: true)
)
}

function isElementVisible(element, previousElement) {
return (
isStyleVisible(element) &&
isAttributeVisible(element, previousElement) &&
(!element.parentElement || isElementVisible(element.parentElement, element))
)
function isElementVisibilityVisible(element) {
const visibility = getElementVisibilityStyle(element)
return visibility !== 'hidden' && visibility !== 'collapse'
}

/**
* Computes the boolean value that determines if an element is considered visible from the
* `toBeVisible` custom matcher point of view.
*
* Visibility is controlled via two different sets of properties and styles.
*
* 1. One set of properties allow parent elements to fully controls its sub-tree visibility. This
* means that if higher up in the tree some element is not visible by this criteria, it makes the
* entire sub-tree not visible too, and there's nothing that child elements can do to revert it.
* This includes `display: none`, `opacity: 0`, the presence of the `hidden` attribute`, and the
Copy link
Member

Choose a reason for hiding this comment

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

Note that you can have <div hidden style="display: block" /> which will visible but may be excluded from the a11y tree. It should be documented if display or hidden has priority in your implementation.

Copy link
Member

Choose a reason for hiding this comment

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

I wouldn't include opacity to be honest. 0.00001 will suddenly be considered visible? Opacity also has no relevance to a11y tree inclusion.

Copy link
Member Author

Choose a reason for hiding this comment

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

Ok, but that's a separate discussion. Right now this is not new functionality, and I'm merely maintaining backward compatibility.

I agree that 0.0001 could be considered as such, but this was a compromise, because there's no arguing that opacity: 0 makes things not visible. toBeVisible was never about the presence of things in the a11y tree. It was more about actual visibility with your eyes.

I'll open an issue to discuss this. But I will not remove in this PR, as it will be a breaking change.

Copy link
Member

Choose a reason for hiding this comment

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

Makes sense. I missed, that you already included opacity.

* open state of a details/summary elements pair.
*
* 2. The other aspect influencing if an element is visible is the CSS `visibility` style. This one
Copy link
Member

Choose a reason for hiding this comment

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

nit:

Suggested change
* 2. The other aspect influencing if an element is visible is the CSS `visibility` style. This one
* 2. The other aspect influencing if an element is visible is the CSS `visibility` property. This one

That's how these are named officially i.e. in the spec.

* is also inherited. But unlike the previous case, this one can be reverted by child elements.
Copy link
Member

Choose a reason for hiding this comment

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

What is "also inherited" in this context? Neither display nor opacity are inherited CSS properties. The use of "inherited" is ambiguous in this context.

Copy link
Member Author

Choose a reason for hiding this comment

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

Maybe "inherited" is not the best word. What I mean is that setting display: none, opacity: 0, or visibility: hidden in a parent element always hides the child elements too. But, unlike with the former two, the last one is reversible: child elements can make themselves visible over its parent's wish.

I'll reword to not use the word inherit to avoid confusion.

* A parent element can set its visibiilty to `hidden` or `collapse`, but a child element setting
* its own styles to `visibility: visible` can rever that, and it makes itself visible. Hence,
* this criteria needs to be checked independently of the other one.
*
* Hence, the apprach taken by this function is two-fold: it first gets the first set of criteria
* out of the way, analyzing the target element and up its tree. If this branch yields that the
* element is not visible, there's nothing the element could be doing to revert that, so it returns
* false. Only if the first check is true, if proceeds to analyze the `visibility` CSS.
Copy link
Member

Choose a reason for hiding this comment

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

That's an interesting trade-off. You're basically assuming that checking visibility is more expensive than walking up the tree and checking display etc on every parent.

How was this trade-off motivated?

In the other Testing Library implementations we're checking visibility on the element first since that allows us to potentially bail out of walking up the tree i.e.

 function isElementVisible(element) {
-  return isElementTreeVisible(element) && isElementVisibilityVisible(element)
+  return isElementVisibilityVisible(element) && isElementTreeVisible(element)
 }

Copy link
Member Author

@gnapse gnapse Jan 14, 2022

Choose a reason for hiding this comment

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

I was honestly not thinking of performance, and the comment talks more about the logic we follow. Both branches walk up the tree, and either of these branches could bail out more quickly than the other. There's no way to tell.

Though the check for visibility is simpler, so maybe you're right that on average it could be faster. I'm ok with making this change.

Clarified below: #428 (comment)

*/
function isElementVisible(element) {
return isElementTreeVisible(element) && isElementVisibilityVisible(element)
}

export function toBeVisible(element) {
Expand Down