-
Notifications
You must be signed in to change notification settings - Fork 22
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 tooltips used on targets with delegated focus #4606
base: main
Are you sure you want to change the base?
Conversation
Thanks for the PR! 🎉 We've deployed an automatic preview for this PR - you can see your changes here:
Note The build needs to finish before your changes are deployed. |
@@ -96,7 +94,7 @@ const computeTooltipShift = (centerDelta, spaceLeft, spaceRight) => { | |||
|
|||
/** | |||
* A component used to display additional information when users focus or hover on a point of interest. | |||
* @slot - Default content placed inside of the tooltip | |||
* @slot - Default content placed inside of the tooltip. This content is also used as the description for the target element, and may override existing descriptors. |
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.
It will override if the target focus is delegated
if (this._target && this.id) { | ||
elemIdListRemove(this._target, 'aria-labelledby', this.id); | ||
elemIdListRemove(this._target, 'aria-describedby', this.id); | ||
if (this.#targetDelegated) { | ||
this._target.removeAttribute('aria-label'); | ||
this._target.removeAttribute('aria-description'); | ||
} | ||
} |
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 we already have a target, we're likely updating it, so clean up the existing one.
components/tooltip/tooltip.js
Outdated
if (this.forType === 'label') { | ||
this.removeAttribute('aria-labelledby'); | ||
this._target.ariaLabel = this.shadowRoot.querySelector('slot').assignedNodes().map(n => n.textContent).join('\n'); | ||
} else if (!this.announced || isInteractive) { | ||
this.removeAttribute('aria-describedby'); | ||
this._target.ariaDescription = this.shadowRoot.querySelector('slot').assignedNodes().map(n => n.textContent).join('\n'); | ||
} |
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.
When the target has been delegated, we can't use the ...by
properties, which take precedence, so remove them.
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
if (this.forType === 'label') { | ||
this._target.removeAttribute('aria-label'); | ||
elemIdListAdd(this._target, 'aria-labelledby', this.id); | ||
} else if (!this.announced || isInteractive) { | ||
this._target.removeAttribute('aria-description'); | ||
elemIdListAdd(this._target, 'aria-describedby', this.id); | ||
} |
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.
Removing the label
/description
isn't strictly necessary here, but they won't be used since the ...by
property we're setting takes precedence, and cleaning them up seems good.
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 if I'm reading this correctly, there's no way for the downstream code (e.g. d2l-button
) to leverage aria-labelledby
/aria-decribedby
to set a label/description themselves on their <button>
and then have a consumer point a tooltip at them... since that would fall into the targetDelegated
case which would then remove their label/description?
The only way it could work would be if <d2l-button>
itself rendered the tooltip, in which case they're in the same scope and therefore not delegated so the tooltip id
would get added to the list.
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.
Right, I couldn't find a convenient way to get the computed label
/description
, besides by just getting the element(s) via the id and grabbing the text content, which seems like it's just as well to handle once in the tooltip code rather than duplicating that in every delegated-focus component.
Obviously we will someday be able to use the ElementInternals/AOM stuff, but until then this seems better than having nothing.
The only way it could work would be if itself rendered the tooltip, in which case they're in the same scope and therefore not delegated so the tooltip id would get added to the list.
Yes, that's exactly right. I think there could be some much more complicated solutions, but I'm not sure it's worth the effort.
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.
Cool, makes sense! I thought of an alternate approach last night... so as a thought experiment:
What if the components themselves opted into this using something like a TooltipTargetMixin
? That mixin could come with some reactive state properties like tooltipValue
and tooltipType
, and the tooltip code would set those whenever the tooltip value changed or it was shown/hidden.
That would then put the responsibility on the target to figure out what to actually do with the value and what element to put it on -- using aria-label/description
, maybe an offscreen element hooked up with aria-labelledby
if there are multiple labels, etc.
The advantage to this approach is that it empowers the target element to "do what's best" and hooks into Lit's lifecycle to do the rendering. Tooltip could even throw
if the target didn't implement the mixin. It's obviously more work though for each one to opt in.
My worry with the more "automatic" approach is that consumers can accidentally mess up a core component's accessibility by unknowingly completely overriding its label. But maybe that's what we want anyway... if you're pointing a tooltip at a button or whatever, you probably want its value to become the label. 🤷
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 overlaps with the comments I just made.
https://github.com/BrightspaceUI/core/pull/4606/files#r1571060598
https://github.com/BrightspaceUI/core/pull/4606/files#r1571072507
Dave, if I follow you correctly, that would address my concern about modifying the DOM outside of the target custom element's render
method. That's where my mind was going too, but you put a bit more thought into the sol'n. 😄
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.
Yeah, that was my intent.
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.
Yeah, I agree with all of this. I'll investigate a little more, and try to get some numbers on how many places would need to be updated.
@@ -264,7 +264,7 @@ export const TagListItemMixin = superclass => class extends LocalizeCoreElement( | |||
} else if (options.hasTruncationTooltip || hasDescription) { | |||
const tooltipHeader = hasDescription ? html`<div class="d2l-heading-4">${tagContent}</div>` : tagContent; | |||
tooltip = html` | |||
<d2l-tooltip class="vdiff-target" for="${this._id}" ?show-truncated-only="${!hasDescription}"> | |||
<d2l-tooltip class="vdiff-target" for="${this._id}" ?show-truncated-only="${!hasDescription}" for-type="${hasDescription ? 'descriptor' : 'label'}"> |
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.
Prevents reading out the truncated contents twice.
106da3d
to
1938db8
Compare
@@ -892,19 +909,22 @@ class Tooltip extends RtlMixin(LitElement) { | |||
} | |||
|
|||
async _showingChanged(newValue, dispatch) { | |||
if (!this.isConnected) return; |
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 sure why this only started being necessary now, but input-date-range
has two layers of validation tooltips, and the upper layer (which is suppressed if there are child validation errors), was showing itself and hiding the child error before then removing itself from the DOM to allow the child tooltip to show, but that one had already been hidden, so we ended up with none. This simply bails if the tooltip that called it is no longer connected to the DOM.
await this.updateComplete; | ||
await this.updatePosition(); | ||
if (dispatch) { | ||
|
||
if (this._showing && dispatch) { | ||
await this.updatePosition(); | ||
this.dispatchEvent(new CustomEvent( |
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, after the update, another tooltip has become active (and hidden this one), don't fire the d2l-tooltip-show
event since it never actually showed. Also, don't bother updating the position since it can and should be recalculated on the next show
|
||
if (this.#targetDelegated) { | ||
if (this.forType === 'label') { | ||
this._target.removeAttribute('aria-labelledby'); |
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 it safe to remove aria-labelledby
and aria-describedby
from the target? i.e. what if the target custom element has set this attribute... this code would be wiping it out. I've always felt like modifying the target's DOM was sketchy here, violating the principle that the custom element's DOM should strictly be a function of its render
method and reactive properties.
if (this.forType === 'label') { | ||
this._target.removeAttribute('aria-labelledby'); | ||
this._target.ariaLabel = this.shadowRoot.querySelector('slot').assignedNodes().map(n => n.textContent).join('\n'); | ||
} else if (!this.announced || isInteractive) { |
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.
With your changes, do we still need the announced
property/attribute? It was intended to be used in these cases where the tooltip could not be wired up due to the id ref / dom scope limitation. But your solution appears to handle that in a better way.
There was this one place in d2l-input-text where we intentionally used announced
(... announce()
), because we didn't want the validation message buried in VO's menu. We'll want to make sure the validation message is not buried - it should be announced upon focus with no additional user interaction.
Currently, if a tooltip targets an element with delegated focus (i.e. basically everything:
d2l-button
,d2l-link
,d2l-input-text
, etc.), that target will never be properly described by the tooltip because once it delegates focus internally, the newly focused element is not described by the tooltip.Non-interactable target warnings are currently disabled on the constructor after a single warning, so most tooltips are never communicating this issue.
Once that restriction is removed, all focus-delegated targets begin to warn because they are not in
interactiveElements
and do not have arole
as the delegate should handle both of these.FocusMixin
, use their delegate as the tooltip target (which also prevents the warning)aria-label/description
directly on the targetlabel
,description
,labelledby
, anddescribedby
appropriatelyactiveTooltip
d2l-tooltip-show
on tooltips that were never shown before being hidden by another tooltiptag-list-item
truncated text as a label, instead of duplicating it as a descriptionGAUD-6266: Investigate "d2l-tooltip" non-accessible warnings