Skip to content
Merged
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
6 changes: 6 additions & 0 deletions Style1.css
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ input.styling[type="radio"] {
outline-offset: 2px;
}

/* Match inline help to the rest of the selected response text. */
.question .response > input[type="radio"]:checked + label:not(.custom-label) .popover-dismiss.btn,
.question .response > input[type="checkbox"]:checked + label:not(.custom-label) .popover-dismiss.btn {
color: inherit !important;
}

@media (forced-colors: active) {
.question .response > input[type="radio"]:focus-visible + label:not(.custom-label),
.question .response > input[type="checkbox"]:focus-visible + label:not(.custom-label),
Expand Down
1 change: 1 addition & 0 deletions common.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const ariaLiveAnnouncementRegions = () => {
<div id="srAnnouncerContainer" class="visually-hidden">
<div id="ariaLiveQuestionAnnouncer" aria-live="polite"></div>
<div id="ariaLiveSelectionAnnouncer" aria-live="polite"></div>
<div id="ariaLiveHelpAnnouncer" lang="${translate('language')}" aria-live="polite" aria-atomic="true"></div>
</div>
`;
}
Expand Down
36 changes: 36 additions & 0 deletions docs/accessibilityManualTest.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ For VoiceOver and JAWS testing, see the
`fixture=compoundRadioGroupsSpanish.txt&lang=es`; for conditionally displayed
radio subgroups use `fixture=conditionalCompoundRadioGroups.txt` and
repeat with `fixture=conditionalCompoundRadioGroupsSpanish.txt&lang=es`.
- For the popover scenario, use `fixture=popoverHelp.txt`
and repeat with `fixture=popoverHelpSpanish.txt&lang=es`.
- Keep Quest inside `#root > .row > #questionnaireRoot`.
- Start each manual scenario from a new browser page. The participant harness intentionally permits one render per page so one scenario cannot contaminate the next; Quest's sequential-render behavior is covered separately by automation.
- Do not enable browser extensions other than the screen reader under test.
Expand All @@ -48,6 +50,38 @@ includes its distinct age and unit context. In Spanish, type `18.5` with the
physical keyboard and confirm the field contains `185`: whole numbers are
accepted while decimal punctuation is filtered without a browser error.

### Inline question help (popovers)

Run this scenario in every VoiceOver and JAWS environment and required mode.
Open both language-specific URLs in a new page:

- English: `http://127.0.0.1:4173/tests/harness/participant.html?fixture=popoverHelp.txt&lang=en`
- Spanish: `http://127.0.0.1:4173/tests/harness/participant.html?fixture=popoverHelpSpanish.txt&lang=es`

For each URL:

1. Navigate through the question with the screen reader's reading cursor and
form-control navigation. Confirm the complete question remains available,
including the text after the inline **freckles** or **pecas** help button.
2. Move to the help button. Confirm its name is **freckles** or **pecas**, it is
identified as a button with collapsed state, and its short description is
**Informational Text** or **Texto de ayuda**. Exact spoken order varies by
screen reader and settings. Use the screen reader's default action. Confirm
focus remains on the button, its state becomes expanded, the visual popup
opens, and the help heading followed by the complete body is announced once.
Record the command used and the actual spoken order.
3. Press Escape. Confirm the popup closes, the button returns to collapsed state
with its short description, focus remains on it, and reading can continue
with the rest of the question after that button.
4. Open the help again and immediately press Escape. Wait at least one second
and confirm the popup stays closed, focus remains on the help button, and
the help announcement does not unexpectedly restart.
5. Open the help again, immediately move to a response, select it, and activate
Next. On the completion screen, wait at least one second and
confirm the completion prompt is announced and the help is not replayed.

Speech already queued or underway may finish after dismissal.

## Runnable dialog and asynchronous scenarios

Start each check in a new page. Use these exact harness URLs and actions so the
Expand Down Expand Up @@ -187,6 +221,7 @@ First run with Quick Nav off. Repeat response navigation and activation with Qui
10. Open and close requested-response, required-response, submit, response-confirmation, and configured store-error dialogs. Confirm dialog name and description, focus entry, keyboard containment, each close action, and immediate return to the invoking control or active question context. Leave the store-error dialog open for at least six seconds and confirm it remains open with focus contained, then dismiss it manually. After each dismissal, immediately move to or activate a response, wait at least one second, and confirm focus never jumps back.
11. Open each compound-radio fixture. Enter both activity groups and traverse all answers. Confirm VoiceOver announces the visible activity prompt as the radio-group name, each answer as the radio name, and its checked state. Confirm the activity prompt is not a separate empty or alert stop.
12. Open each conditional compound-radio fixture. Select both travel modes and continue. Confirm each mode prompt is announced once when entering its group; each answer name and checked state is announced once; and exiting does not repeat the full answer list. Use Back, remove one mode, and continue again. Confirm the removed group is absent, the retained group is still named correctly, and no duplicate, empty, or separate repeated-prompt stop is announced.
13. Run the English and Spanish inline question-help protocol above. Confirm the help heading and full body are announced exactly once, dismissal preserves the surrounding question context, and help is not unexpectedly replayed after closing or navigation.

## JAWS with Chrome and Edge — issue #1079

Expand All @@ -204,6 +239,7 @@ Run each browser once. State explicitly whether each observation occurred in Vir
10. Exit Forms Mode and confirm the Virtual Cursor resumes at a sensible location in the active question.
11. Open each compound-radio fixture. In Virtual Cursor and Forms Mode, enter both activity groups and traverse all answers. Confirm JAWS announces the visible activity prompt when entering its group, preserves the concise answer name and checked state, and does not expose the activity prompt as a separate alert or form control.
12. Open each conditional compound-radio fixture. Select both travel modes and continue. In Virtual Cursor and Forms Mode, confirm each mode prompt is announced once on group entry; each answer name and checked state is announced once; and exiting does not repeat the full answer list. Use Back, remove one mode, and continue again. Confirm the removed group is absent, the retained group remains correctly named, and no duplicate, empty, or separate repeated-prompt stop is exposed.
13. Run the English and Spanish inline question-help protocol above in Virtual Cursor and Forms Mode. Confirm the help heading and full body are announced exactly once, dismissal preserves the surrounding question context, and help is not unexpectedly replayed after closing or navigation.

The conditional fixture follows the WAI-ARIA radio-group contract: the visible
mode prompt names a `radiogroup`, and `aria-owns` associates native radios that
Expand Down
1 change: 1 addition & 0 deletions i18n/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const en = {
"cancelButton": "Cancel",
"closeButton": "Close",
"progressBarLabel": "Survey progress",
"popoverHelpDescription": "Informational Text",

"yes": "Yes",
"no": "No",
Expand Down
1 change: 1 addition & 0 deletions i18n/es.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const es = {
"cancelButton": "Cancelar",
"closeButton": "Cerrar",
"progressBarLabel": "Progreso de la encuesta",
"popoverHelpDescription": "Texto de ayuda",

"yes": "Sí",
"no": "No",
Expand Down
88 changes: 87 additions & 1 deletion questionnaire.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { beginQuestionFocusHandoff, manageAccessibleQuestion } from "./accessibl
import { focusModalDescription } from './modalFocus.js';
export const moduleParams = {};
const initializedPopoverTriggers = new WeakSet();
let activePopoverAnnouncement = null;

// The questionQueue is a Tree. It contains the question ids in the order they should be displayed.
export const questionQueue = new Tree();
Expand Down Expand Up @@ -1302,6 +1303,15 @@ function initializePopovers() {
const questDiv = moduleParams.questDiv;

[...questDiv.querySelectorAll('[data-bs-toggle="popover"]')].forEach(popoverTriggerEl => {
// Read before Bootstrap moves title into data-bs-original-title. Keep the
// visible word as the name; a description does not alter the question name.
const heading = popoverTriggerEl.getAttribute('data-bs-title')
|| popoverTriggerEl.getAttribute('title')
|| popoverTriggerEl.getAttribute('data-bs-original-title')
|| '';
popoverTriggerEl.setAttribute('aria-description',
heading.replace(/\s+/g, ' ').trim() || translate('popoverHelpDescription'));

if (!bootstrap.Popover.getInstance(popoverTriggerEl)) {
new bootstrap.Popover(popoverTriggerEl, { trigger: 'manual' });
}
Expand All @@ -1310,16 +1320,71 @@ function initializePopovers() {
popoverTriggerEl.addEventListener('click', handlePopoverClick);
popoverTriggerEl.addEventListener('keydown', handlePopoverKeydown);
popoverTriggerEl.addEventListener('focusout', handlePopoverFocusout);
popoverTriggerEl.addEventListener('shown.bs.popover', announcePopoverHelp);
for (const lifecycle of ['inserted', 'shown', 'hide', 'hidden']) {
popoverTriggerEl.addEventListener(`${lifecycle}.bs.popover`, updatePopoverExpandedState);
}
initializedPopoverTriggers.add(popoverTriggerEl);
}
synchronizePopoverExpandedState(popoverTriggerEl);
});
}

function updatePopoverExpandedState(event) {
const trigger = event.currentTarget;
if (event.type === 'inserted.bs.popover' || event.type === 'hide.bs.popover') {
// Bootstrap updates .show after these events. Wait until the current task
// finishes so canceled hides and rapid close/reopen cycles stay truthful.
queueMicrotask(() => synchronizePopoverExpandedState(trigger));
} else {
synchronizePopoverExpandedState(trigger);
}
}

function synchronizePopoverExpandedState(trigger) {
if (!trigger.isConnected || !moduleParams.questDiv?.contains(trigger)) return;
const popoverId = trigger.getAttribute('aria-describedby');
const popoverElement = popoverId ? trigger.ownerDocument.getElementById(popoverId) : null;
const expanded = Boolean(popoverElement?.matches('.popover.show'));
trigger.setAttribute('aria-expanded', String(expanded));
if (!expanded) clearPopoverHelpForTrigger(trigger);
}

function getOrCreatePopover(trigger) {
return bootstrap.Popover.getInstance(trigger)
|| new bootstrap.Popover(trigger, { trigger: 'manual' });
}

function announcePopoverHelp(event) {
const trigger = event.currentTarget;
const questDiv = moduleParams.questDiv;
const announcer = questDiv?.querySelector('#ariaLiveHelpAnnouncer');
const popoverId = trigger.getAttribute('aria-describedby');
const popoverElement = popoverId && trigger.ownerDocument.getElementById(popoverId);
if (!trigger.isConnected || !questDiv?.contains(trigger) || !announcer?.isConnected
|| trigger.ownerDocument.activeElement !== trigger || !popoverElement?.classList.contains('show')) return;
if (activePopoverAnnouncement?.popoverElement === popoverElement) return;

// Updating a pre-existing live region announces newly opened help without
// moving focus or relying on a late aria-describedby change to be spoken.
clearPopoverHelpForTrigger(activePopoverAnnouncement?.trigger);
const text = [...popoverElement.querySelectorAll('.popover-header, .popover-body')]
.map(element => element.textContent.replace(/\s+/g, ' ').trim())
.filter(Boolean)
.join(' ');
if (!text) return;

announcer.textContent = text;
activePopoverAnnouncement = { trigger, announcer, popoverElement };
}

function clearPopoverHelpForTrigger(trigger) {
// Older lifecycle events must not clear a different popover or render.
if (!activePopoverAnnouncement || activePopoverAnnouncement.trigger !== trigger) return;
activePopoverAnnouncement.announcer.textContent = '';
activePopoverAnnouncement = null;
}

function handlePopoverClick(event) {
event.preventDefault();
const trigger = event.currentTarget;
Expand Down Expand Up @@ -1347,11 +1412,32 @@ function handlePopoverKeydown(event) {
}

function handlePopoverFocusout(event) {
bootstrap.Popover.getInstance(event.currentTarget)?.hide();
const trigger = event.currentTarget;
const popover = bootstrap.Popover.getInstance(trigger);
popover?.hide();
if (!popover || trigger.hasAttribute('aria-describedby')) return;

// Bootstrap toggle queues its opening even with a zero delay. If focus
// leaves before a tip exists, hide() cannot cancel that pending show.
// Wait until an in-progress show has returned before disposing its instance.
queueMicrotask(() => {
if (bootstrap.Popover.getInstance(trigger) !== popover
|| trigger.ownerDocument.activeElement === trigger) return;
if (trigger.hasAttribute('aria-describedby')) {
popover.hide();
return;
}
popover.dispose();
if (trigger.isConnected && moduleParams.questDiv?.contains(trigger)) {
// Reinitialize immediately so Bootstrap keeps the native title hidden.
new bootstrap.Popover(trigger, { trigger: 'manual' });
}
});
}

export function disposePopovers(container) {
[...container.querySelectorAll('[data-bs-toggle="popover"]')].forEach((trigger) => {
clearPopoverHelpForTrigger(trigger);
const popover = bootstrap.Popover.getInstance(trigger);
if (!popover) return;

Expand Down
Loading
Loading