-
Notifications
You must be signed in to change notification settings - Fork 599
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(Announce): add support for computing text equivalence #5724
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -66,7 +66,7 @@ export function Announce({ | |
return | ||
} | ||
|
||
const textContent = getTextContent(element) | ||
const textContent = computeTextEquivalent(element) | ||
if (textContent === previousAnnouncementText) { | ||
return | ||
} | ||
|
@@ -132,12 +132,110 @@ export function Announce({ | |
) | ||
} | ||
|
||
function getTextContent(element: HTMLElement): string { | ||
let value = '' | ||
if (element.hasAttribute('aria-label')) { | ||
value = element.getAttribute('aria-label')! | ||
} else if (element.textContent) { | ||
value = element.textContent | ||
type TextEquivalentOptions = { | ||
allowHidden: boolean | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I see that setting This makes sense to me, since I can't think of a usecase for announcing |
||
} | ||
|
||
const defaultOptions: TextEquivalentOptions = { | ||
allowHidden: false, | ||
} | ||
|
||
/** | ||
* Simplified version of the algorithm to compute the text equivalent of an | ||
* element. | ||
* | ||
* @see https://www.w3.org/TR/accname-1.2/#computation-steps | ||
*/ | ||
function computeTextEquivalent( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This seems like a good start - I think we can refine this as we identify different usecases (alongside stories/tests). |
||
elementOrText: HTMLElement | Text, | ||
options: TextEquivalentOptions = defaultOptions, | ||
): string { | ||
if (elementOrText instanceof HTMLElement && elementOrText.shadowRoot) { | ||
return Array.from(elementOrText.shadowRoot.childNodes) | ||
.map(node => { | ||
if (node instanceof Text) { | ||
return computeTextEquivalent(node, options) | ||
} | ||
|
||
if (node instanceof HTMLElement) { | ||
return computeTextEquivalent(node, options) | ||
} | ||
|
||
return null | ||
}) | ||
.filter(Boolean) | ||
.join(' ') | ||
} | ||
|
||
if (elementOrText instanceof Text) { | ||
return elementOrText.textContent?.trim() ?? '' | ||
} | ||
|
||
const style = window.getComputedStyle(elementOrText) | ||
if (style.display === 'none' || style.visibility === 'hidden') { | ||
return '' | ||
} | ||
|
||
if (options.allowHidden === false && elementOrText.getAttribute('aria-hidden') === 'true') { | ||
return '' | ||
} | ||
|
||
if (elementOrText.hasAttribute('aria-labelledby')) { | ||
const idrefs = elementOrText.getAttribute('aria-labelledby')! | ||
return idrefs | ||
.split(' ') | ||
.map(idref => { | ||
const item = document.getElementById(idref) | ||
if (item) { | ||
return computeTextEquivalent(item, {allowHidden: true}) | ||
} | ||
return null | ||
}) | ||
.filter(Boolean) | ||
.join(' ') | ||
} | ||
|
||
const role = elementOrText.getAttribute('role') | ||
|
||
if (role === 'combobox' || role === 'listbox') { | ||
const options = elementOrText.querySelectorAll('option[aria-selected="true"]') | ||
Comment on lines
+200
to
+201
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wonder how much if/how much support we need for complex interactive controls. Ideally, consumers aren't wrapping entire interactive components with a live region, since semantics won't properly be conveyed 🤔 |
||
return Array.from(options) | ||
.map(option => { | ||
if (option instanceof HTMLOptionElement) { | ||
return option.value.trim() | ||
} | ||
return null | ||
}) | ||
.filter(Boolean) | ||
.join(', ') | ||
} | ||
return value ? value.trim() : '' | ||
|
||
if (role === 'range') { | ||
if (elementOrText.hasAttribute('aria-valuetext')) { | ||
return elementOrText.getAttribute('aria-valuetext')!.trim() | ||
} | ||
|
||
if (elementOrText.hasAttribute('aria-valuenow')) { | ||
return elementOrText.getAttribute('aria-valuenow')!.trim() | ||
} | ||
return elementOrText.textContent?.trim() ?? '' | ||
} | ||
|
||
if (elementOrText.hasAttribute('aria-label')) { | ||
return elementOrText.getAttribute('aria-label')!.trim() | ||
} | ||
|
||
if (elementOrText.childNodes.length > 0) { | ||
return Array.from(elementOrText.childNodes) | ||
.map(node => { | ||
if (node instanceof Text || node instanceof HTMLElement) { | ||
return computeTextEquivalent(node, options) | ||
} | ||
return null | ||
}) | ||
.filter(Boolean) | ||
.join(' ') | ||
} | ||
|
||
return elementOrText.textContent?.trim() ?? '' | ||
} |
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.
Note, the announcement in this story will still be the default date (and not the "on ..." message) since when the story renders and gets announced the custom element hasn't updated yet.