diff --git a/accessibility-checker-engine/src/v4/simulator/SRNavigator.ts b/accessibility-checker-engine/src/v4/simulator/SRNavigator.ts index d3b84bbcc..65033e61e 100644 --- a/accessibility-checker-engine/src/v4/simulator/SRNavigator.ts +++ b/accessibility-checker-engine/src/v4/simulator/SRNavigator.ts @@ -362,6 +362,31 @@ export namespace SRNavigator { if (elem && elem.nodeName.toUpperCase() === "MSUP") { return retVal = { skipCurrent: false, skipChildren: true }; } + + // is a self-announcing disclosure button โ€” its children + // are folded into the ["label", button, collapsed] announcement, + // so skip traversal into them. + if (!cursor.isEndTag() && elem.nodeName.toUpperCase() === "SUMMARY") { + const detailsParent = elem.parentElement; + if (detailsParent && detailsParent.nodeName.toUpperCase() === "DETAILS") { + return retVal = { skipCurrent: false, skipChildren: true }; + } + } + + // Skip non-summary children of a closed
element. + // Browsers hide those children via UA stylesheet (display:none), so + // they must not appear as reading stops when the widget is collapsed. + // Exempt the
element itself and (always visible). + if (!cursor.isEndTag() + && elem.nodeName.toUpperCase() !== "DETAILS" + && !elem.closest("summary") + ) { + const detailsAncestor = elem.closest("details"); + if (detailsAncestor && !detailsAncestor.hasAttribute("open")) { + return retVal = { skipCurrent: true, skipChildren: true }; + } + } + if (elem.closest(".ibma-sr-overlay")) { return retVal = { skipCurrent: true, skipChildren: true }; } diff --git a/accessibility-checker-engine/src/v4/simulator/render_rules/common.ts b/accessibility-checker-engine/src/v4/simulator/render_rules/common.ts index 745804636..6b69cc56e 100644 --- a/accessibility-checker-engine/src/v4/simulator/render_rules/common.ts +++ b/accessibility-checker-engine/src/v4/simulator/render_rules/common.ts @@ -1329,6 +1329,45 @@ export const RULES: SRRendererRule[] = [ ] }), + // Summary element โ€” the disclosure button for a
widget. + // has implicitRole: null in ARIADefinitions so no role-based + // button rule fires. We add an explicit element rule that announces it as + // a button with the collapsed / expanded state from the parent
. + new SRRendererRule({ + roles: [], + elems: ["SUMMARY"], + modes: ["item", "button", "tab_focus"], + tests: [ + (cursor: SRCursor, _oldCursor?: SRCursor, mode?: string) => { + if (cursor.isEndTag()) return ""; + const summaryElem = cursor.getElement(); + if (!summaryElem) return null; + // Only the first that is a direct child of
+ // acts as the disclosure button. + const detailsParent = summaryElem.parentElement; + if (!detailsParent || detailsParent.nodeName.toUpperCase() !== "DETAILS") return null; + const firstSummary = Array.from(detailsParent.children).find( + c => c.nodeName.toUpperCase() === "SUMMARY" + ); + if (!firstSummary || !firstSummary.isSameNode(summaryElem)) return null; + const stateStr = detailsParent.hasAttribute("open") ? "expanded" : "collapsed"; + // has implicitRole: null so AccNameUtil may not compute a name; + // read aria-label first, then fall back to visible text content. + const ariaLabel = summaryElem.getAttribute("aria-label")?.trim(); + const textContent = summaryElem.textContent?.trim() || ""; + const label = ariaLabel || textContent; + const labelStr = label ? `"${label}", ` : ""; + // Description: prefer explicit aria-describedby; when aria-label is the + // name source, the subtree text becomes the description (accname-1.2 ยง4.3). + let descStr = getDescribedByAnnouncements(summaryElem, mode); + if (!descStr && ariaLabel && textContent) { + descStr = mode === "item" ? `, \u0001${textContent}\u0002` : `, "${textContent}"`; + } + return `[${labelStr}button, ${stateStr}${descStr}]`; + } + ] + }), + // Multiple roles rules - placed at the bottom // Default mode rules - Container elements (multiple roles) diff --git a/accessibility-checker-engine/src/v4/simulator/render_rules/container_enter.ts b/accessibility-checker-engine/src/v4/simulator/render_rules/container_enter.ts index 331af510b..9c18bf6bb 100644 --- a/accessibility-checker-engine/src/v4/simulator/render_rules/container_enter.ts +++ b/accessibility-checker-engine/src/v4/simulator/render_rules/container_enter.ts @@ -170,15 +170,26 @@ export let RULES: SRRendererRule[] = [ modes: ["item", "region"], tests: [ (cursor: SRCursor) => { + if (cursor.getNode().nodeName.toUpperCase() === "DETAILS") { + const elem = cursor.getElement(); + const hasSummary = Array.from(elem.children).some( + c => c.nodeName.toUpperCase() === "SUMMARY" + ); + if (hasSummary) { + // The child speaks as the disclosure button; suppress + // the container-enter announcement entirely. + return ""; + } + // No explicit : in a real browser the UA injects a default + // "Details" summary. jsdom does not, so we announce the widget here + // as a fallback using the same default label a real browser would use. + return `["Details", button, ${elem.hasAttribute("open") ? "expanded" : "collapsed"}]`; + } if (cursor.getNameInfo() === null) return null; if (cursor.getCurrentOrParentByRoleClone(["combobox"], ["select"])?.getNode().nodeName.toUpperCase() === "SELECT") { return ""; } - if (cursor.getNode().nodeName.toUpperCase() === "DETAILS") { - return `[button, ${cursor.getElement().hasAttribute("open") ? "expanded": "collapsed"}]`; - } else { - return `[grouping${quoteNamePadBefore(cursor)}]`; - } + return `[grouping${quoteNamePadBefore(cursor)}]`; } ] }), @@ -190,15 +201,23 @@ export let RULES: SRRendererRule[] = [ modes: ["tab_focus"], tests: [ (cursor: SRCursor) => { + if (cursor.getNode().nodeName.toUpperCase() === "DETAILS") { + const elem = cursor.getElement(); + const hasSummary = Array.from(elem.children).some( + c => c.nodeName.toUpperCase() === "SUMMARY" + ); + if (hasSummary) { + // is the tab stop, not
itself. + return null; + } + // No :
itself is focusable (UA default behaviour). + return `["Details", button, ${elem.hasAttribute("open") ? "expanded" : "collapsed"}]`; + } if (cursor.getNameInfo() === null) return null; if (cursor.getCurrentOrParentByRoleClone(["combobox"], ["select"])?.getNode().nodeName.toUpperCase() === "SELECT") { return ""; } - if (cursor.getNode().nodeName.toUpperCase() === "DETAILS") { - return `[button, ${cursor.getElement().hasAttribute("open") ? "expanded": "collapsed"}]`; - } else { - return ``; - } + return ``; } ] }), diff --git a/accessibility-checker-engine/src/v4/simulator/render_rules/containter_exit.ts b/accessibility-checker-engine/src/v4/simulator/render_rules/containter_exit.ts index b89fd736b..4a22711e8 100644 --- a/accessibility-checker-engine/src/v4/simulator/render_rules/containter_exit.ts +++ b/accessibility-checker-engine/src/v4/simulator/render_rules/containter_exit.ts @@ -110,11 +110,16 @@ export let RULES: SRRendererRule[] = [ modes: ["item"], tests: [ (cursor: SRCursor) => { + //
uses the group role but has its own disclosure widget semantics; + // SRs do not announce "out of grouping" when leaving a details element. + if (cursor.getNode().nodeName.toUpperCase() === "DETAILS") { + return ""; + } if (cursor.getNameInfo() === null) return null; if (cursor.getCurrentOrParentByRoleClone(["combobox"], ["select"])?.getNode().nodeName.toUpperCase() === "SELECT") { return ""; } - else return "[out of grouping]"; + return "[out of grouping]"; } ] }), diff --git a/accessibility-checker-engine/test/v4/simulator/Details_test.js b/accessibility-checker-engine/test/v4/simulator/Details_test.js new file mode 100644 index 000000000..fd1901725 --- /dev/null +++ b/accessibility-checker-engine/test/v4/simulator/Details_test.js @@ -0,0 +1,271 @@ +/****************************************************************************** + Copyright:: 2026- IBM, Inc + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + *****************************************************************************/ + +/* + * Comprehensive unit tests for the
/ disclosure widget + * screen reader rendering. + * + * Rendering model: + * -
(role=group) emits [button, collapsed] or [button, expanded] in + * item mode to announce the widget boundary. It is NOT a tab stop. + * - (first child of
) renders as + * ["label", button, collapsed/expanded] in both item and tab_focus โ€” it is + * the focusable disclosure button. + * - Non-summary children of a CLOSED
are hidden from the AT + * (browsers apply display:none via UA stylesheet); they are skipped. + * - Non-summary children of an OPEN
are fully readable. + * - No "out of grouping" is announced when leaving a
. + */ + +let ace = require('../../../src/index'); + +// Helper: trim whitespace from item/region fields +function trimItems(results) { + return results.map(item => ({ + ...item, + item: item.item.trim(), + region: item.region.trim(), + tab_focus: item.tab_focus.trim() + })); +} + +describe('Details/Summary Disclosure Widget Screen Reader Tests', function() { + + afterEach(function() { + if (ace.SRController.getController) { + let controller = ace.SRController.getController(); + if (controller && controller.disconnect) { + controller.disconnect(); + } + } + let fixture = document.getElementById('fixture'); + if (fixture) { + document.body.removeChild(fixture); + } + }); + + // ------------------------------------------------------------------ // + // Collapsed state (no "open" attribute) + // ------------------------------------------------------------------ // + describe('Collapsed details (no "open" attribute)', function() { + + it('Should announce collapsed state with summary text and skip hidden body content', function() { + let fixture = `
+
+ More information +

Hidden content

+
+
`; + document.body.insertAdjacentHTML('afterbegin', fixture); + + let result = trimItems(ace.SRController.renderStructure(document)); + + expect(result).withContext(JSON.stringify(result, null, 2)).toEqual([ + { "region": "", "heading": "", "item": "[Start of document]", "tab_focus": "", "image": "", "selector": "body" }, + { "region": "", "heading": "", "item": `["More information", button, collapsed]`, "tab_focus": `["More information", button, collapsed]`, "image": "", "selector": "#d1 > summary" }, + { "region": "", "heading": "", "item": "[End of document]", "tab_focus": "", "image": "" } + ]); + }); + + it('Should handle details without an explicit summary', function() { + // jsdom does not inject a UA-default , so there is no + // summary item. The details container announcement still appears. + let fixture = `
+
+

No explicit summary

+
+
`; + document.body.insertAdjacentHTML('afterbegin', fixture); + + let result = trimItems(ace.SRController.renderStructure(document)); + + expect(result).withContext(JSON.stringify(result, null, 2)).toEqual([ + { "region": "", "heading": "", "item": "[Start of document]", "tab_focus": "", "image": "", "selector": "body" }, + { "region": "", "heading": "", "item": `["Details", button, collapsed]`, "tab_focus": `["Details", button, collapsed]`, "image": "", "selector": "#d2" }, + { "region": "", "heading": "", "item": "[End of document]", "tab_focus": "", "image": "" } + ]); + }); + }); + + // ------------------------------------------------------------------ // + // Expanded state ("open" attribute present) + // ------------------------------------------------------------------ // + describe('Expanded details ("open" attribute)', function() { + + it('Should announce expanded state and expose inner paragraph content', function() { + let fixture = `
+
+ Show details +

Visible content

+
+
`; + document.body.insertAdjacentHTML('afterbegin', fixture); + + let result = trimItems(ace.SRController.renderStructure(document)); + + expect(result).withContext(JSON.stringify(result, null, 2)).toEqual([ + { "region": "", "heading": "", "item": "[Start of document]", "tab_focus": "", "image": "", "selector": "body" }, + { "region": "", "heading": "", "item": `["Show details", button, expanded]`, "tab_focus": `["Show details", button, expanded]`, "image": "", "selector": "#d3 > summary" }, + { "region": "", "heading": "", "item": "Visible content", "tab_focus": "", "image": "", "selector": "#detail-body" }, + { "region": "", "heading": "", "item": "[End of document]", "tab_focus": "", "image": "" } + ]); + }); + + it('Should expose heading inside open details', function() { + let fixture = `
+
+ Expandable section +

Section Title

+

Section content

+
+
`; + document.body.insertAdjacentHTML('afterbegin', fixture); + + let result = trimItems(ace.SRController.renderStructure(document)); + + expect(result).withContext(JSON.stringify(result, null, 2)).toEqual([ + { "region": "", "heading": "", "item": "[Start of document]", "tab_focus": "", "image": "", "selector": "body" }, + { "region": "", "heading": "", "item": `["Expandable section", button, expanded]`, "tab_focus": `["Expandable section", button, expanded]`, "image": "", "selector": "#d4 > summary" }, + { "region": "", "heading": `["Section Title", heading level 2]`, "item": "[heading level 2] Section Title", "tab_focus": "", "image": "", "selector": "#inner-heading" }, + { "region": "", "heading": "", "item": "Section content", "tab_focus": "", "image": "", "selector": "#section-p" }, + { "region": "", "heading": "", "item": "[End of document]", "tab_focus": "", "image": "" } + ]); + }); + }); + + // ------------------------------------------------------------------ // + // Summary label variations + // ------------------------------------------------------------------ // + describe('Summary element content variations', function() { + + it('Should derive the button name from inline markup inside summary', function() { + let fixture = `
+
+ Bold summary +
+
`; + document.body.insertAdjacentHTML('afterbegin', fixture); + + let result = trimItems(ace.SRController.renderStructure(document)); + + expect(result).withContext(JSON.stringify(result, null, 2)).toEqual([ + { "region": "", "heading": "", "item": "[Start of document]", "tab_focus": "", "image": "", "selector": "body" }, + { "region": "", "heading": "", "item": `["Bold summary", button, collapsed]`, "tab_focus": `["Bold summary", button, collapsed]`, "image": "", "selector": "#d5 > summary" }, + { "region": "", "heading": "", "item": "[End of document]", "tab_focus": "", "image": "" } + ]); + }); + + it('Should use aria-label on summary as the button name', function() { + let fixture = `
+
+ FAQ question text +
+
`; + document.body.insertAdjacentHTML('afterbegin', fixture); + + let result = trimItems(ace.SRController.renderStructure(document)); + + expect(result).withContext(JSON.stringify(result, null, 2)).toEqual([ + { "region": "", "heading": "", "item": "[Start of document]", "tab_focus": "", "image": "", "selector": "body" }, + { "region": "", "heading": "", "item": `["Toggle FAQ answer", button, collapsed, "FAQ question text"]`, "tab_focus": `["Toggle FAQ answer", button, collapsed, "FAQ question text"]`, "image": "", "selector": `#d6 > summary[aria-label="Toggle\\ FAQ\\ answer"]` }, + { "region": "", "heading": "", "item": "[End of document]", "tab_focus": "", "image": "" } + ]); + }); + }); + + // ------------------------------------------------------------------ // + // Multiple details widgets on the same page + // ------------------------------------------------------------------ // + describe('Multiple details widgets', function() { + + it('Should render two adjacent collapsed details independently', function() { + let fixture = `
+
+ Section A +

Content A

+
+
+ Section B +

Content B

+
+
`; + document.body.insertAdjacentHTML('afterbegin', fixture); + + let result = trimItems(ace.SRController.renderStructure(document)); + + expect(result).withContext(JSON.stringify(result, null, 2)).toEqual([ + { "region": "", "heading": "", "item": "[Start of document]", "tab_focus": "", "image": "", "selector": "body" }, + { "region": "", "heading": "", "item": `["Section A", button, collapsed]`, "tab_focus": `["Section A", button, collapsed]`, "image": "", "selector": "#da > summary" }, + { "region": "", "heading": "", "item": `["Section B", button, collapsed]`, "tab_focus": `["Section B", button, collapsed]`, "image": "", "selector": "#db > summary" }, + { "region": "", "heading": "", "item": "[End of document]", "tab_focus": "", "image": "" } + ]); + }); + + it('Should render one collapsed and one expanded details correctly', function() { + let fixture = `
+
+ Collapsed +

Hidden

+
+
+ Expanded +

Shown

+
+
`; + document.body.insertAdjacentHTML('afterbegin', fixture); + + let result = trimItems(ace.SRController.renderStructure(document)); + + expect(result).withContext(JSON.stringify(result, null, 2)).toEqual([ + { "region": "", "heading": "", "item": "[Start of document]", "tab_focus": "", "image": "", "selector": "body" }, + { "region": "", "heading": "", "item": `["Collapsed", button, collapsed]`, "tab_focus": `["Collapsed", button, collapsed]`, "image": "", "selector": "#dc > summary" }, + { "region": "", "heading": "", "item": `["Expanded", button, expanded]`, "tab_focus": `["Expanded", button, expanded]`, "image": "", "selector": "#dd > summary" }, + { "region": "", "heading": "", "item": "Shown", "tab_focus": "", "image": "", "selector": "#expanded-content" }, + { "region": "", "heading": "", "item": "[End of document]", "tab_focus": "", "image": "" } + ]); + }); + }); + + // ------------------------------------------------------------------ // + // Nested details + // ------------------------------------------------------------------ // + describe('Nested details', function() { + + it('Should render outer (open) and inner (collapsed) details independently', function() { + let fixture = `
+
+ Outer summary +
+ Inner summary +

Inner content

+
+
+
`; + document.body.insertAdjacentHTML('afterbegin', fixture); + + let result = trimItems(ace.SRController.renderStructure(document)); + + expect(result).withContext(JSON.stringify(result, null, 2)).toEqual([ + { "region": "", "heading": "", "item": "[Start of document]", "tab_focus": "", "image": "", "selector": "body" }, + { "region": "", "heading": "", "item": `["Outer summary", button, expanded]`, "tab_focus": `["Outer summary", button, expanded]`, "image": "", "selector": "#outer > summary" }, + { "region": "", "heading": "", "item": `["Inner summary", button, collapsed]`, "tab_focus": `["Inner summary", button, collapsed]`, "image": "", "selector": "#inner > summary" }, + { "region": "", "heading": "", "item": "[End of document]", "tab_focus": "", "image": "" } + ]); + }); + }); +}); + +// Made with IBM Bob