diff --git a/client/src/a11yConventions.test.js b/client/src/a11yConventions.test.js index 47cf7fe93c..d919410ab4 100644 --- a/client/src/a11yConventions.test.js +++ b/client/src/a11yConventions.test.js @@ -1619,15 +1619,101 @@ function hasUsableAriaLabelledByReference(src, tag) { }); } +// The element's BODY, prepared the way `hasUsableElementText` prepares it: +// comments masked, `aria-hidden` subtrees removed — so the two content-name +// checks cannot drift on what "hidden" means. +function accessibleBodyOf(src, { contentStart, matchingClose }) { + if (!matchingClose) return null; + return stripHiddenElementContent( + maskComments(src.slice(contentStart, matchingClose.index), { startMode: 'jsx-text' }), + ); +} + +// `alt` names an element only where HTML says it does. A `
` or a +// `` is inert markup as far as the accessibility tree is +// concerned — reading it as a name would exempt an unnamed link on the strength +// of an attribute no browser looks at. `` is left out +// deliberately: it is not phrasing content inside a link, and `` has its +// own name rule two branches down. +const ALT_NAMED_TAGS = new Set(['img', 'area']); + +// Which components declared in THIS file render text of their own? An anchor +// wrapping one is named by whatever that component renders — KanbanBoard's +// `` announces the ticket's key, summary, +// priority and type — and an `aria-label` on such a link would REPLACE that +// whole subtree with a terser name rather than add to it. +// +// Only a LOCAL declaration is credited, and only one that actually renders +// text. Every link this rule exists to catch wraps an IMPORTED icon +// (``, ``), and a file-local icon wrapper renders no +// text either — so neither is exempted. Crediting any component child, or +// following imports, would hand the rule the one bypass that makes it vacuous. +const textRenderingLocalComponentsBySource = new Map(); + +function textRenderingLocalComponents(src) { + const cached = textRenderingLocalComponentsBySource.get(src); + if (cached) return cached; + const names = new Set(); + forEachLocalComponent(src, (name, body) => { + if (names.has(name)) return; + for (const node of forEachOpeningTag(body)) { + if (isHiddenFromAccessibility(node.tag)) continue; + if (!hasUsableElementText(body, node)) continue; + names.add(name); + return; + } + }); + textRenderingLocalComponentsBySource.set(src, names); + return names; +} + +// Everything an can be named by that is NOT its own plain text: a +// descendant image's `alt` (a thumbnail link — an `aria-label` bolted onto one +// would OVERRIDE that alt rather than add to it, a regression dressed up as a +// fix), and a same-file component that renders text. +// +// One walk of the prepared body answers both. It is the expensive half of the +// rule, so it runs only after `hasUsableElementText` has already cleared the +// ordinary text links, and the local-component set is resolved lazily — a body +// with no component child at all never builds one. +function hasAccessibleLinkContent(src, node) { + const body = accessibleBodyOf(src, node); + if (body === null) return false; + const componentChildren = new Set(); + for (const child of forEachOpeningTag(body, undefined, { startMode: 'jsx-text' })) { + if (ALT_NAMED_TAGS.has(child.name) && hasUsableAccessibleNameAttribute(child.tag, 'alt')) return true; + if (child.name && COMPONENT_TAG_NAME.test(child.name)) componentChildren.add(child.name); + } + if (componentChildren.size === 0) return false; + const local = textRenderingLocalComponents(src); + return [...componentChildren].some((name) => local.has(name)); +} + // `type`-derived names are an -only affordance: a submit button names // itself from `value`, an image button from `alt`, and a hidden input is not in // the a11y tree at all.