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(Announce): add support for computing text equivalence #5724

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {StoryObj} from '@storybook/react'
import React, {useEffect, useState} from 'react'
import {Announce} from './Announce'
import {VisuallyHidden} from '../VisuallyHidden'
import RelativeTime from '../RelativeTime'

export default {
title: 'Experimental/Components/Announce/Features',
Expand Down Expand Up @@ -36,3 +37,11 @@ export const WithDelay = () => {

return <Announce delayMs={1000}>{message}</Announce>
}

export const WithCustomElement = () => {
return (
<Announce>
<RelativeTime date={new Date('2020-01-01T00:00:00Z')} noTitle={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.

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.

</Announce>
)
}
114 changes: 106 additions & 8 deletions packages/react/src/live-region/Announce.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export function Announce({
return
}

const textContent = getTextContent(element)
const textContent = computeTextEquivalent(element)
if (textContent === previousAnnouncementText) {
return
}
Expand Down Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

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

I see that setting allowHidden to true would include aria-hidden elements in the announcement and exclude hidden elements.
What do you think of renaming this to allowAriaHidden?

This makes sense to me, since I can't think of a usecase for announcing hidden elements... maybe if one wants to announce something transient that shouldn't appear in the DOM? But in that case I think they could use the announce helper directly. Thoughts?

}

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(
Copy link
Contributor

Choose a reason for hiding this comment

The 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
Copy link
Contributor

Choose a reason for hiding this comment

The 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() ?? ''
}
Loading