diff --git a/Style1.css b/Style1.css
index 4c4ef82..ba8d344 100644
--- a/Style1.css
+++ b/Style1.css
@@ -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),
diff --git a/common.js b/common.js
index 56afecc..ba1c9fc 100644
--- a/common.js
+++ b/common.js
@@ -16,6 +16,7 @@ export const ariaLiveAnnouncementRegions = () => {
`;
}
diff --git a/docs/accessibilityManualTest.md b/docs/accessibilityManualTest.md
index f24d653..a9b863b 100644
--- a/docs/accessibilityManualTest.md
+++ b/docs/accessibilityManualTest.md
@@ -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.
@@ -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
@@ -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
@@ -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
diff --git a/i18n/en.js b/i18n/en.js
index 7f63a26..4a3df6c 100644
--- a/i18n/en.js
+++ b/i18n/en.js
@@ -21,6 +21,7 @@ const en = {
"cancelButton": "Cancel",
"closeButton": "Close",
"progressBarLabel": "Survey progress",
+ "popoverHelpDescription": "Informational Text",
"yes": "Yes",
"no": "No",
diff --git a/i18n/es.js b/i18n/es.js
index 82e49e6..77b388e 100644
--- a/i18n/es.js
+++ b/i18n/es.js
@@ -21,6 +21,7 @@ const es = {
"cancelButton": "Cancelar",
"closeButton": "Cerrar",
"progressBarLabel": "Progreso de la encuesta",
+ "popoverHelpDescription": "Texto de ayuda",
"yes": "Sí",
"no": "No",
diff --git a/questionnaire.js b/questionnaire.js
index 0e2e111..933c4d5 100644
--- a/questionnaire.js
+++ b/questionnaire.js
@@ -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();
@@ -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' });
}
@@ -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;
@@ -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;
diff --git a/tests/e2e/popoverAccessibility.spec.js b/tests/e2e/popoverAccessibility.spec.js
index fa681cc..f9f81c0 100644
--- a/tests/e2e/popoverAccessibility.spec.js
+++ b/tests/e2e/popoverAccessibility.spec.js
@@ -6,7 +6,9 @@ import {
goNext,
harnessSnapshot,
openParticipant,
+ waitInHarness,
} from './support/harness.js';
+import { readLockedMarkdown, treeAt } from './support/corpus.js';
const DESKTOP_ENGINES = new Set([
'chromium-desktop',
@@ -33,10 +35,64 @@ const RESPONSE_POPOVER_MARKDOWN = `{"name":"TEST_RESPONSE_POPOVER_A11Y"}
[END,end] Complete.`;
+const TITLELESS_POPOVER_MARKDOWN = `{"name":"TEST_TITLELESS_POPOVER_A11Y"}
+
+[TITLELESS] Read |popup|this help|Titleless synthetic help text| before continuing.
+
+[END,end] Complete.`;
+
+const DUAL_POPOVER_MARKDOWN = `{"name":"TEST_DUAL_POPOVER_A11Y"}
+
+[DUAL] Compare |popup|first help|First help title|First synthetic help body| with |popup|second help|Second help title|Second synthetic help body| before continuing.
+
+[END,end] Complete.`;
+
+const PRODUCTION_HELP = {
+ en: {
+ fixture: 'popoverHelp.txt',
+ trigger: 'freckles',
+ title: 'Informational Text',
+ body: 'We are interested in freckling on the back and shoulders, not the face and arms. Your face and arms are more exposed to the sun and may have a different amount of freckling.',
+ prompt: 'How many freckles do you have on your back and shoulders now? Please see the pictures below for examples.',
+ },
+ es: {
+ fixture: 'popoverHelpSpanish.txt',
+ trigger: 'pecas',
+ title: 'Texto de ayuda',
+ body: 'Nos interesan las pecas en la espalda y los hombros, no en la cara ni en los brazos. La cara y los brazos están más expuestos al sol y pueden tener una cantidad diferente de pecas.',
+ prompt: '¿Cuántas pecas tiene ahora en la espalda y los hombros? Consulte las siguientes fotografías para ver algunos ejemplos.',
+ },
+};
+
function helpPopover(page, content = 'Synthetic help text') {
return page.locator('.popover').filter({ hasText: content });
}
+function helpAnnouncer(page) {
+ return page.locator('#questionnaireRoot #ariaLiveHelpAnnouncer');
+}
+
+async function expectClosedHelp(trigger, description) {
+ await expect(trigger).toHaveAttribute('aria-expanded', 'false');
+ await expect(trigger).toHaveAttribute('aria-description', description);
+ await expect(trigger).toHaveAccessibleDescription(description);
+ await expect(trigger).not.toHaveAttribute('aria-haspopup');
+}
+
+async function expectOpenHelp({ trigger, popover, announcer, title, body }) {
+ const announcement = [title, body].filter(Boolean).join(' ');
+
+ await expect(popover).toBeVisible();
+ await expect(popover).toHaveAttribute('role', 'tooltip');
+ if (title) await expect(popover).toContainText(title);
+ await expect(popover).toContainText(body);
+ await expect(trigger).toHaveAttribute('aria-expanded', 'true');
+ await expect(trigger).toHaveAttribute('aria-describedby', await popover.getAttribute('id'));
+ await expect(trigger).toHaveAccessibleDescription(announcement);
+ await expect(trigger).toBeFocused();
+ await expect(announcer).toHaveText(announcement);
+}
+
test.describe('production-shaped popover accessibility @canonical', () => {
test.beforeEach(async ({}, testInfo) => {
test.skip(
@@ -58,6 +114,7 @@ test.describe('production-shaped popover accessibility @canonical', () => {
await page.keyboard.press('Tab');
await expect(trigger).toBeFocused();
+ await expectClosedHelp(trigger, 'Help title');
await expect(trigger).toHaveCSS('outline-style', 'solid');
await expect(trigger).toHaveCSS('outline-width', '3px');
await expect(trigger).toHaveCSS('outline-color', 'rgb(28, 93, 134)');
@@ -78,7 +135,12 @@ test.describe('production-shaped popover accessibility @canonical', () => {
await expect(question.locator('legend')).toContainText('Please read the following survey help.');
const trigger = question.getByRole('button', { name: 'More information' });
+ const announcer = helpAnnouncer(page);
+ await expect(announcer).toHaveAttribute('aria-live', 'polite');
+ await expect(announcer).toHaveAttribute('aria-atomic', 'true');
+ await expect(announcer).toBeEmpty();
await expect(trigger).toBeVisible();
+ await expectClosedHelp(trigger, 'Help title');
await trigger.focus();
await expect(trigger).toBeFocused();
await expect(helpPopover(page)).toHaveCount(0);
@@ -96,35 +158,344 @@ test.describe('production-shaped popover accessibility @canonical', () => {
await page.keyboard.press('Enter');
const popover = helpPopover(page);
- await expect(popover).toBeVisible();
- await expect(popover).toHaveAttribute('role', 'tooltip');
- await expect(popover).toContainText('Help title');
- await expect(popover).toContainText('Synthetic help text');
- await expect(trigger).toHaveAttribute('aria-describedby', await popover.getAttribute('id'));
+ await expectOpenHelp({
+ trigger,
+ popover,
+ announcer,
+ title: 'Help title',
+ body: 'Synthetic help text',
+ });
await page.keyboard.press('Enter');
await expect(popover).not.toBeVisible();
expect(await trigger.getAttribute('aria-describedby')).toBeNull();
+ await expect(announcer).toBeEmpty();
+ await expectClosedHelp(trigger, 'Help title');
const scrollBeforeSpace = await page.evaluate(() => window.scrollY);
await page.keyboard.press('Space');
- await expect(helpPopover(page)).toBeVisible();
+ await expectOpenHelp({
+ trigger,
+ popover: helpPopover(page),
+ announcer,
+ title: 'Help title',
+ body: 'Synthetic help text',
+ });
await expect(trigger).toBeFocused();
expect(await page.evaluate(() => window.scrollY)).toBe(scrollBeforeSpace);
await page.keyboard.press('Escape');
await expect(helpPopover(page)).not.toBeVisible();
await expect(trigger).toBeFocused();
+ await expect(announcer).toBeEmpty();
+ await expectClosedHelp(trigger, 'Help title');
await trigger.click();
- await expect(helpPopover(page)).toBeVisible();
+ await expectOpenHelp({
+ trigger,
+ popover: helpPopover(page),
+ announcer,
+ title: 'Help title',
+ body: 'Synthetic help text',
+ });
await expect(trigger).toBeFocused();
await page.locator('#host-after').focus();
await expect(helpPopover(page)).not.toBeVisible();
expect(await trigger.getAttribute('aria-describedby')).toBeNull();
+ await expect(announcer).toBeEmpty();
+ await expectClosedHelp(trigger, 'Help title');
+ await expectHealthyHarness(page);
+ });
+
+ for (const [lang, fallback] of [['en', 'Informational Text'], ['es', 'Texto de ayuda']]) {
+ for (const [heading, markdown] of [
+ ['missing', TITLELESS_POPOVER_MARKDOWN],
+ ['blank', TITLELESS_POPOVER_MARKDOWN.replace('|this help|', '|this help| |')],
+ ]) {
+ test(`uses the ${lang} fallback for a ${heading} heading without changing the prompt or popup body`, async ({ page }) => {
+ await openParticipant(page, { markdown, lang });
+
+ const question = activeQuestion(page, 'TITLELESS');
+ const trigger = question.getByRole('button', { name: 'this help', exact: true });
+ const announcer = helpAnnouncer(page);
+ await expect(question.getByRole('group', { name: 'Read this help before continuing.', exact: true })).toHaveCount(1);
+ await expectClosedHelp(trigger, fallback);
+ await trigger.click();
+
+ await expectOpenHelp({
+ trigger,
+ popover: helpPopover(page, 'Titleless synthetic help text'),
+ announcer,
+ title: '',
+ body: 'Titleless synthetic help text',
+ });
+ await expect(announcer).not.toContainText(fallback);
+ await page.keyboard.press('Escape');
+ await expect(announcer).toBeEmpty();
+ await expectClosedHelp(trigger, fallback);
+ await expectHealthyHarness(page);
+ });
+ }
+ }
+
+ test('keeps the description and expanded state accurate when showing or hiding is canceled', async ({ page }) => {
+ await openParticipant(page, { markdown: POPOVER_MARKDOWN });
+ const trigger = activeQuestion(page, 'PLAIN').getByRole('button', { name: 'More information' });
+ const announcer = helpAnnouncer(page);
+ await trigger.focus();
+ await trigger.evaluate((element) => {
+ element.addEventListener('show.bs.popover', (event) => event.preventDefault(), { once: true });
+ window.bootstrap.Popover.getInstance(element).show();
+ });
+ await waitInHarness(page, 250);
+ await expect(helpPopover(page)).toHaveCount(0);
+ await expectClosedHelp(trigger, 'Help title');
+ await expect(announcer).toBeEmpty();
+
+ // Deliberate cancellation is a public-API contract; Bootstrap's toggle
+ // retains its own hover state when a show event is prevented.
+ await trigger.evaluate((element) => window.bootstrap.Popover.getInstance(element).show());
+ const popover = helpPopover(page);
+ await expectOpenHelp({ trigger, popover, announcer, title: 'Help title', body: 'Synthetic help text' });
+ await trigger.evaluate((element) => {
+ element.addEventListener('hide.bs.popover', (event) => event.preventDefault(), { once: true });
+ });
+ await page.keyboard.press('Escape');
+ await waitInHarness(page, 250);
+ await expectOpenHelp({ trigger, popover, announcer, title: 'Help title', body: 'Synthetic help text' });
+
+ await page.keyboard.press('Escape');
+ await expect(popover).not.toBeVisible();
+ await expectClosedHelp(trigger, 'Help title');
+ await expect(announcer).toBeEmpty();
+ await expectHealthyHarness(page);
+ });
+
+ test('keeps a newer help announcement when an older trigger emits stale lifecycle events', async ({ page }) => {
+ await openParticipant(page, { markdown: DUAL_POPOVER_MARKDOWN });
+
+ const question = activeQuestion(page, 'DUAL');
+ const first = question.getByRole('button', { name: 'first help' });
+ const second = question.getByRole('button', { name: 'second help' });
+ const announcer = helpAnnouncer(page);
+
+ await first.click();
+ await expect(announcer).toHaveText('First help title First synthetic help body');
+ await second.focus();
+ await page.keyboard.press('Enter');
+ await expect(announcer).toHaveText('Second help title Second synthetic help body');
+
+ await first.evaluate((trigger) => {
+ trigger.dispatchEvent(new Event('hide.bs.popover'));
+ trigger.dispatchEvent(new Event('hidden.bs.popover'));
+ trigger.dispatchEvent(new Event('shown.bs.popover'));
+ });
+ await waitInHarness(page, 250);
+
+ await expect(second).toBeFocused();
+ await expect(first).toHaveAttribute('aria-expanded', 'false');
+ await expect(second).toHaveAttribute('aria-expanded', 'true');
+ await expect(announcer).toHaveText('Second help title Second synthetic help body');
+ await expectHealthyHarness(page);
+ });
+
+ test('ignores a late shown event when Escape closes the popover during its opening transition', async ({ page }) => {
+ await openParticipant(page, { markdown: POPOVER_MARKDOWN });
+ await page.addStyleTag({ content: '.popover.fade { transition-duration: 0.2s !important; }' });
+
+ const trigger = activeQuestion(page, 'PLAIN').getByRole('button', { name: 'More information' });
+ const announcer = helpAnnouncer(page);
+ await trigger.focus();
+ const opening = await trigger.evaluate((element) => new Promise((resolve) => {
+ let shown = false;
+ element.addEventListener('shown.bs.popover', () => { shown = true; }, { once: true });
+ element.addEventListener('inserted.bs.popover', () => {
+ setTimeout(() => {
+ const tip = element.ownerDocument.getElementById(element.getAttribute('aria-describedby'));
+ const state = {
+ tipIsOpen: tip?.classList.contains('show'),
+ expanded: element.getAttribute('aria-expanded'),
+ shown,
+ };
+ element.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: 'Escape' }));
+ resolve(state);
+ }, 0);
+ }, { once: true });
+ element.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: 'Enter' }));
+ }));
+ expect(opening).toEqual({ tipIsOpen: true, expanded: 'true', shown: false });
+ await waitInHarness(page, 250);
+
+ await expect(helpPopover(page)).not.toBeVisible();
+ await expect(trigger).toBeFocused();
+ await expect(announcer).toBeEmpty();
+ await expectClosedHelp(trigger, 'Help title');
+ await expectHealthyHarness(page);
+ });
+
+ test('does not publish a late help announcement when focus leaves in the opening task', async ({ page }) => {
+ await openParticipant(page, { markdown: POPOVER_MARKDOWN });
+
+ const trigger = activeQuestion(page, 'PLAIN').getByRole('button', { name: 'More information' });
+ const hostAfter = page.locator('#host-after');
+ const announcer = helpAnnouncer(page);
+ await trigger.focus();
+ await trigger.evaluate((element) => {
+ element.dispatchEvent(new KeyboardEvent('keydown', {
+ bubbles: true,
+ cancelable: true,
+ key: 'Enter',
+ }));
+ element.ownerDocument.getElementById('host-after').focus();
+ });
+ await waitInHarness(page, 250);
+
+ await expect(helpPopover(page)).not.toBeVisible();
+ await expect(hostAfter).toBeFocused();
+ await expect(announcer).toBeEmpty();
+ await expect(trigger).not.toHaveAttribute('title');
+ await expectClosedHelp(trigger, 'Help title');
+ await trigger.focus();
+ await page.keyboard.press('Enter');
+ await expectOpenHelp({ trigger, popover: helpPopover(page), announcer, title: 'Help title', body: 'Synthetic help text' });
+ await expectHealthyHarness(page);
+ });
+
+ for (const [lang, expectation] of Object.entries(PRODUCTION_HELP)) {
+ test(`announces ${lang} help in the survey language under an opposite-language host`, async ({ page }) => {
+ const hostLang = lang === 'es' ? 'en' : 'es';
+ await openParticipant(page, { fixture: expectation.fixture, lang, hostLang });
+ const trigger = activeQuestion(page, 'D_352986465').getByRole('button', { name: expectation.trigger, exact: true });
+ const announcer = helpAnnouncer(page);
+
+ await expect(page.locator('html')).toHaveAttribute('lang', hostLang);
+ await expect(announcer).toHaveAttribute('lang', lang);
+ await trigger.click();
+ await expectOpenHelp({ trigger, popover: helpPopover(page, expectation.body), announcer, title: expectation.title, body: expectation.body });
+ await expect(page.locator('html')).toHaveAttribute('lang', hostLang);
+ await expectHealthyHarness(page);
+ });
+
+ test(`preserves the full ${lang} prompt around inline help and announces its production text`, async ({ page }) => {
+ await openParticipant(page, {
+ fixture: expectation.fixture,
+ lang,
+ });
+
+ const question = activeQuestion(page, 'D_352986465');
+ const legend = question.locator('legend.question-text');
+ const trigger = question.getByRole('button', { name: expectation.trigger, exact: true });
+ const announcer = helpAnnouncer(page);
+
+ await expect(legend).toHaveText(expectation.prompt);
+ await expect(question.getByRole('group', { name: expectation.prompt, exact: true })).toHaveCount(1);
+ await expect(trigger).toBeVisible();
+ await expect(trigger).toHaveCSS('color', 'rgb(42, 114, 165)');
+ await expect(trigger).toHaveCSS('padding-left', '0px');
+ await expect(trigger).toHaveCSS('padding-right', '0px');
+ await expectClosedHelp(trigger, expectation.title);
+ await trigger.click();
+ await expectOpenHelp({
+ trigger,
+ popover: helpPopover(page, expectation.body),
+ announcer,
+ title: expectation.title,
+ body: expectation.body,
+ });
+ await page.keyboard.press('Escape');
+ await expectClosedHelp(trigger, expectation.title);
+ await expectHealthyHarness(page);
+ });
+ }
+
+ for (const [lang, name] of [['en', 'Hazel'], ['es', 'Color avellana']]) {
+ test(`keeps the selected ${lang} production eye-color help styling consistent without changing the answer`, async ({ page }) => {
+ await openParticipant(page, {
+ markdown: readLockedMarkdown('module3', lang),
+ lang,
+ persistedData: { treeJSON: treeAt('D_857219333') },
+ });
+ const question = activeQuestion(page, 'D_857219333');
+ const trigger = question.getByRole('button', { name, exact: true });
+ const radio = question.locator('#D_857219333_696181630');
+ const label = question.locator('label[for="D_857219333_696181630"]');
+
+ await expect(trigger).toHaveCSS('color', 'rgb(42, 114, 165)');
+ await label.click({ position: { x: 8, y: 8 } });
+ await expect(radio).toBeChecked();
+ await expect(label).toHaveCSS('background-color', 'rgb(60, 145, 222)');
+ await expect(trigger).toHaveCSS('color', 'rgb(255, 255, 255)');
+ await radio.focus();
+ await page.keyboard.press('Tab');
+ await expect(trigger).toBeFocused();
+ await page.keyboard.press('Space');
+ await expect(trigger).toHaveAttribute('aria-expanded', 'true');
+ await expect(radio).toBeChecked();
+ expect((await harnessSnapshot(page)).state.active).toMatchObject({ D_857219333: '696181630' });
+ await page.keyboard.press('Escape');
+ await expect(trigger).toBeFocused();
+ await expect(trigger).toHaveCSS('outline-style', 'solid');
+ await expect(trigger).toHaveCSS('outline-width', '3px');
+
+ await question.locator('.response > label').first().click();
+ await expect(radio).not.toBeChecked();
+ await expect(trigger).toHaveCSS('color', 'rgb(42, 114, 165)');
+ await expectHealthyHarness(page);
+ });
+ }
+
+ test('keeps defensive checkbox help styling consistent when selecting and clearing the answer', async ({ page }) => {
+ await openParticipant(page, { markdown: RESPONSE_POPOVER_MARKDOWN.replace(/\((\d+)\)/g, '[$1]') });
+ const question = activeQuestion(page, 'EYE_COLOR');
+ const trigger = question.getByRole('button', { name: 'Hazel' });
+ const label = trigger.locator('xpath=ancestor::label');
+ const checkbox = question.locator('input[type="checkbox"]').nth(1);
+ await expect(trigger).toHaveCSS('color', 'rgb(42, 114, 165)');
+ await label.click({ position: { x: 8, y: 8 } });
+ await expect(checkbox).toBeChecked();
+ await expect(label).toHaveCSS('background-color', 'rgb(60, 145, 222)');
+ await expect(trigger).toHaveCSS('color', 'rgb(255, 255, 255)');
+ await label.click({ position: { x: 8, y: 8 } });
+ await expect(checkbox).not.toBeChecked();
+ await expect(trigger).toHaveCSS('color', 'rgb(42, 114, 165)');
await expectHealthyHarness(page);
});
+ for (const type of ['radio', 'checkbox']) {
+ test(`preserves selected help system colors for ${type} responses in forced-colors mode`, async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name !== 'chromium-desktop', 'Forced-colors rendering is checked once in Chromium; checkbox coverage protects the supported parser syntax.');
+ await page.emulateMedia({ forcedColors: 'active' });
+ await openParticipant(page, {
+ markdown: type === 'radio'
+ ? RESPONSE_POPOVER_MARKDOWN
+ : RESPONSE_POPOVER_MARKDOWN.replace(/\((\d+)\)/g, '[$1]'),
+ });
+ const question = activeQuestion(page, 'EYE_COLOR');
+ const trigger = question.getByRole('button', { name: 'Hazel' });
+ const label = trigger.locator('xpath=ancestor::label');
+ const input = question.locator(`input[type="${type}"]`).nth(1);
+ await label.click({ position: { x: 8, y: 8 } });
+ await expect(input).toBeChecked();
+ await expect(label).toHaveCSS('forced-color-adjust', 'none');
+ const selectedTextColor = await label.evaluate((element) => getComputedStyle(element).color);
+ const selectedBackground = await label.evaluate((element) => getComputedStyle(element).backgroundColor);
+ await expect(trigger).toHaveCSS('color', selectedTextColor);
+ expect(selectedTextColor).not.toBe(selectedBackground);
+ await input.focus();
+ await page.keyboard.press('Tab');
+ await expect(trigger).toBeFocused();
+ await page.keyboard.press('Space');
+ await expect(trigger).toHaveAttribute('aria-expanded', 'true');
+ await expect(trigger).toHaveCSS('color', selectedTextColor);
+ await expect(input).toBeChecked();
+ await page.keyboard.press('Escape');
+ await expect(trigger).toBeFocused();
+ await expect(trigger).toHaveCSS('outline-style', 'solid');
+ await expect(trigger).toHaveCSS('outline-width', '3px');
+ await expectHealthyHarness(page);
+ });
+ }
+
test('does not let a help control nested in a response label answer the question', async ({ page }) => {
await openParticipant(page, { markdown: RESPONSE_POPOVER_MARKDOWN });
const question = activeQuestion(page, 'EYE_COLOR');
@@ -133,22 +504,32 @@ test.describe('production-shaped popover accessibility @canonical', () => {
const radio = response.locator('input[type="radio"]');
const label = response.locator('label');
+ await expect(trigger).toHaveCSS('color', 'rgb(42, 114, 165)');
+ await expectClosedHelp(trigger, 'Informational Text');
+ await expect(label).toHaveText('Hazel');
+ await expect(radio).toHaveAccessibleName('Hazel');
await trigger.focus();
await page.keyboard.press('Space');
await expect(helpPopover(page, 'Hazel is a combination')).toBeVisible();
+ await expect(helpAnnouncer(page)).toHaveText('Informational Text Hazel is a combination of green and brown.');
await expect(radio).not.toBeChecked();
expect((await harnessSnapshot(page)).state.active).not.toHaveProperty('EYE_COLOR');
await page.keyboard.press('Escape');
+ await expect(helpAnnouncer(page)).toBeEmpty();
await trigger.click();
await expect(helpPopover(page, 'Hazel is a combination')).toBeVisible();
+ await expect(helpAnnouncer(page)).toHaveText('Informational Text Hazel is a combination of green and brown.');
await expect(radio).not.toBeChecked();
expect((await harnessSnapshot(page)).state.active).not.toHaveProperty('EYE_COLOR');
await page.keyboard.press('Escape');
+ await expect(helpAnnouncer(page)).toBeEmpty();
// Click the response-card padding, not its nested help control.
await label.click({ position: { x: 8, y: 8 } });
await expect(radio).toBeChecked();
+ await expect(label).toHaveCSS('background-color', 'rgb(60, 145, 222)');
+ await expect(trigger).toHaveCSS('color', 'rgb(255, 255, 255)');
expect((await harnessSnapshot(page)).state.active).toMatchObject({ EYE_COLOR: '696181630' });
await expectHealthyHarness(page);
});
@@ -156,26 +537,39 @@ test.describe('production-shaped popover accessibility @canonical', () => {
test('disposes an open popover during navigation and reinitializes it on Back', async ({ page }) => {
await openParticipant(page, { markdown: POPOVER_MARKDOWN });
const trigger = activeQuestion(page, 'PLAIN').getByRole('button', { name: 'More information' });
+ const detachedTrigger = await trigger.elementHandle();
await trigger.click();
await expect(helpPopover(page)).toBeVisible();
+ await expect(helpAnnouncer(page)).toHaveText('Help title Synthetic help text');
await goNext(page);
await expect(activeQuestion(page, 'FOLLOWUP')).toBeVisible();
await expect(helpPopover(page)).toHaveCount(0);
+ await expect(helpAnnouncer(page)).toBeEmpty();
+
+ await detachedTrigger.evaluate((element) => {
+ element.dispatchEvent(new Event('shown.bs.popover'));
+ });
+ await waitInHarness(page, 250);
+ await expect(helpAnnouncer(page)).toBeEmpty();
await goBack(page);
const restoredTrigger = activeQuestion(page, 'PLAIN').getByRole('button', { name: 'More information' });
+ await expectClosedHelp(restoredTrigger, 'Help title');
await restoredTrigger.focus();
await page.keyboard.press('Enter');
await expect(helpPopover(page)).toBeVisible();
+ await expect(helpAnnouncer(page)).toHaveText('Help title Synthetic help text');
await expectHealthyHarness(page);
});
test('disposes an open popover before a sequential host render', async ({ page }) => {
await openParticipant(page, { markdown: POPOVER_MARKDOWN });
const originalTrigger = activeQuestion(page, 'PLAIN').getByRole('button', { name: 'More information' });
+ const detachedTrigger = await originalTrigger.elementHandle();
await originalTrigger.click();
await expect(helpPopover(page)).toBeVisible();
+ await expect(helpAnnouncer(page)).toHaveText('Help title Synthetic help text');
// Intentionally bypass the ordinary participant harness's one-render guard:
// this is the dedicated contract for Quest's stateful public API boundary.
@@ -196,11 +590,21 @@ test.describe('production-shaped popover accessibility @canonical', () => {
expect(renderResult).toBe(true);
await expect(activeQuestion(page, 'PLAIN')).toBeVisible();
await expect(helpPopover(page)).toHaveCount(0);
+ await expect(helpAnnouncer(page)).toBeEmpty();
+
+ await detachedTrigger.evaluate((element) => {
+ element.dispatchEvent(new Event('shown.bs.popover'));
+ element.dispatchEvent(new Event('hidden.bs.popover'));
+ });
+ await waitInHarness(page, 250);
+ await expect(helpAnnouncer(page)).toBeEmpty();
const newTrigger = activeQuestion(page, 'PLAIN').getByRole('button', { name: 'More information' });
+ await expectClosedHelp(newTrigger, 'Help title');
await newTrigger.focus();
await page.keyboard.press('Enter');
await expect(helpPopover(page)).toBeVisible();
+ await expect(helpAnnouncer(page)).toHaveText('Help title Synthetic help text');
expect(await page.evaluate(() => window.sequentialQuestErrors)).toEqual([]);
await expectHealthyHarness(page);
});
diff --git a/tests/e2e/productionMarkupFidelity.spec.js b/tests/e2e/productionMarkupFidelity.spec.js
index 00fcfd4..f348a8a 100644
--- a/tests/e2e/productionMarkupFidelity.spec.js
+++ b/tests/e2e/productionMarkupFidelity.spec.js
@@ -277,7 +277,19 @@ test.describe('locked Module 4 rich response markup fidelity @canonical @corpus'
expect(initial.radioLabels.every(({ id, labelCount, labelFor }) => (
labelCount === 1 && labelFor === id
))).toBe(true);
+ const help = question.getByRole('button', { name: expectation.popupText, exact: true });
+ const helpLabel = question.locator('label[for="D_539909957_123108471"]');
+ await expect(help).toHaveCSS('color', 'rgb(42, 114, 165)');
+ await helpLabel.click({ position: { x: 8, y: 8 } });
+ await expect(question.locator('#D_539909957_123108471')).toBeChecked();
+ await expect(helpLabel).toHaveCSS('background-color', 'rgb(60, 145, 222)');
+ await expect(help).toHaveCSS('color', 'rgb(255, 255, 255)');
+ await help.click();
+ await expect(help).toHaveAttribute('aria-expanded', 'true');
+ await expect(question.locator('#D_539909957_123108471')).toBeChecked();
+ await page.keyboard.press('Escape');
await question.locator('label[for="D_539909957_463122075"]').click();
+ await expect(help).toHaveCSS('color', 'rgb(42, 114, 165)');
const selected = await richAddressProjection(question);
expect(selected.checkedRadio).toBe('D_539909957_463122075');
await goNext(page);
diff --git a/tests/fixtures/canonical/popoverHelp.txt b/tests/fixtures/canonical/popoverHelp.txt
new file mode 100644
index 0000000..66ad05f
--- /dev/null
+++ b/tests/fixtures/canonical/popoverHelp.txt
@@ -0,0 +1,11 @@
+// Production-shaped manual screen-reader fixture from locked prod/module3.txt, question D_352986465.
+// Remote response-image tokens are intentionally omitted so this canonical fixture remains offline.
+{"name":"TEST_POPOVER_HELP_EN"}
+
+[D_352986465?] How many |popup|freckles|Informational Text|We are interested in freckling on the back and shoulders, not the face and arms. Your face and arms are more exposed to the sun and may have a different amount of freckling.| do you have on your back and shoulders now? Please see the pictures below for examples.
+(259218831) No freckles
+(519761171) A few freckles
+(800179850) Some freckles
+(455684772) A lot of freckles
+
+[END,end] You have answered all of the questions in this survey. To submit your answers, select the “Submit Survey” button.
diff --git a/tests/fixtures/canonical/popoverHelpSpanish.txt b/tests/fixtures/canonical/popoverHelpSpanish.txt
new file mode 100644
index 0000000..bbda6bd
--- /dev/null
+++ b/tests/fixtures/canonical/popoverHelpSpanish.txt
@@ -0,0 +1,11 @@
+// Production-shaped manual screen-reader fixture from locked prod/module3Spanish.txt, question D_352986465.
+// Remote response-image tokens are intentionally omitted so this canonical fixture remains offline.
+{"name":"TEST_POPOVER_HELP_ES"}
+
+[D_352986465?] ¿Cuántas |popup|pecas|Texto de ayuda|Nos interesan las pecas en la espalda y los hombros, no en la cara ni en los brazos. La cara y los brazos están más expuestos al sol y pueden tener una cantidad diferente de pecas.| tiene ahora en la espalda y los hombros? Consulte las siguientes fotografías para ver algunos ejemplos.
+(259218831) Sin pecas
+(519761171) Unas cuantas pecas
+(800179850) Algunas pecas
+(455684772) Muchas pecas
+
+[END,end] Respondió a todas las preguntas de este cuestionario. Para enviar sus respuestas, seleccione el botón “Enviar Encuesta”.
diff --git a/tests/harness/connectHost.css b/tests/harness/connectHost.css
index 9a07eb2..e75872a 100644
--- a/tests/harness/connectHost.css
+++ b/tests/harness/connectHost.css
@@ -36,3 +36,10 @@ body {
#questionnaireRoot {
min-width: 0;
}
+
+/* Match Connect's blue, inline controls. */
+:where(#questionnaireRoot) .popover-dismiss.btn {
+ color: #2A72A5 !important;
+ padding: 0;
+ vertical-align: inherit;
+}
diff --git a/tests/harness/participant.js b/tests/harness/participant.js
index 69f9228..2a98439 100644
--- a/tests/harness/participant.js
+++ b/tests/harness/participant.js
@@ -228,7 +228,7 @@ async function render(config = {}) {
if (rendered) throw new Error('Quest is stateful: participant harness permits one render per page. Reload for a fresh module graph.');
rendered = true;
currentConfig = structuredClone(normalizeHarnessConfig(config));
- document.documentElement.lang = currentConfig.lang === 'es' ? 'es' : 'en';
+ document.documentElement.lang = currentConfig.hostLang ?? (currentConfig.lang === 'es' ? 'es' : 'en');
questRoot.style.visibility = 'hidden';
const params = {
activate: currentConfig.activate ?? true,
diff --git a/tests/integration/eventHandlers.spec.js b/tests/integration/eventHandlers.spec.js
index 0ca0f5f..d278702 100644
--- a/tests/integration/eventHandlers.spec.js
+++ b/tests/integration/eventHandlers.spec.js
@@ -44,6 +44,25 @@ const POPOVER_SURVEY = `
[END,end] Done.
`;
+const LOCALIZED_POPOVER_SURVEYS = {
+ en: {
+ markdown: `
+{"name":"EVENT_POPOVER_EN"}
+[HELP] Read |popup|freckles|Informational Text|We are interested in freckling on the back and shoulders, not the face and arms. Your face and arms are more exposed to the sun and may have a different amount of freckling.|.
+[END,end] Done.
+`,
+ announcement: 'Informational Text We are interested in freckling on the back and shoulders, not the face and arms. Your face and arms are more exposed to the sun and may have a different amount of freckling.',
+ },
+ es: {
+ markdown: `
+{"name":"EVENT_POPOVER_ES"}
+[HELP] Lea |popup|pecas|Texto de ayuda|Nos interesan las pecas en la espalda y los hombros, no en la cara ni en los brazos. La cara y los brazos están más expuestos al sol y pueden tener una cantidad diferente de pecas.|.
+[END,end] Fin.
+`,
+ announcement: 'Texto de ayuda Nos interesan las pecas en la espalda y los hombros, no en la cara ni en los brazos. La cara y los brazos están más expuestos al sol y pueden tener una cantidad diferente de pecas.',
+ },
+};
+
const COMPOUND_DELETION_SURVEY = `
{"name":"EVENT_COMPOUND_DELETE"}
[Q1?] Select a response or add detail.
@@ -387,6 +406,8 @@ describe('delegated runtime event handling', () => {
expect(instance).not.toBeNull();
const hidePopover = vi.spyOn(instance, 'hide');
expect(trigger.dataset.bsTrigger).toBe('manual');
+ expect(trigger.getAttribute('aria-description')).toBe('Help title');
+ expect(trigger.getAttribute('aria-expanded')).toBe('false');
trigger.focus();
expect(trigger.hasAttribute('aria-describedby')).toBe(false);
@@ -403,6 +424,7 @@ describe('delegated runtime event handling', () => {
const popoverElement = document.getElementById(popoverId);
expect(popoverElement).not.toBeNull();
expect(popoverElement.classList.contains('show')).toBe(true);
+ expect(trigger.getAttribute('aria-expanded')).toBe('true');
const escape = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: 'Escape' });
expect(trigger.dispatchEvent(escape)).toBe(false);
@@ -410,6 +432,7 @@ describe('delegated runtime event handling', () => {
expect(hidePopover).toHaveBeenCalledOnce();
expect(popoverElement.classList.contains('show')).toBe(false);
expect(trigger.hasAttribute('aria-describedby')).toBe(false);
+ expect(trigger.getAttribute('aria-expanded')).toBe('false');
expect(document.activeElement).toBe(trigger);
const click = new MouseEvent('click', { bubbles: true, cancelable: true });
@@ -418,9 +441,57 @@ describe('delegated runtime event handling', () => {
const reopenedPopoverElement = document.getElementById(trigger.getAttribute('aria-describedby'));
expect(reopenedPopoverElement).not.toBeNull();
expect(reopenedPopoverElement.classList.contains('show')).toBe(true);
+ expect(trigger.getAttribute('aria-expanded')).toBe('true');
trigger.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
expect(reopenedPopoverElement.classList.contains('show')).toBe(false);
+ expect(trigger.getAttribute('aria-expanded')).toBe('false');
+ });
+
+ it.each([
+ ['Help title', 'Help title'],
+ [' Specific explanation ', 'Specific explanation'],
+ ['more information', 'more information'],
+ ])('describes a closed popover using its existing heading %j without changing the trigger text', async (heading, description) => {
+ const quest = await renderFreshQuest({ markdown: POPOVER_SURVEY.replace('Help title', heading) });
+ const trigger = quest.root.querySelector('[data-bs-toggle="popover"]');
+
+ expect(trigger.textContent).toBe('more information');
+ expect(trigger.hasAttribute('aria-label')).toBe(false);
+ expect(trigger.getAttribute('aria-description')).toBe(description);
+ expect(trigger.getAttribute('aria-expanded')).toBe('false');
+ expect(quest.root.querySelector('#ariaLiveHelpAnnouncer').textContent).toBe('');
+ });
+
+ it('keeps expanded state tied to the visible tip after canceled show and hide events', async () => {
+ const quest = await renderFreshQuest({ markdown: POPOVER_SURVEY });
+ const trigger = quest.root.querySelector('[data-bs-toggle="popover"]');
+ const instance = bootstrap.Popover.getInstance(trigger);
+ trigger.focus();
+ trigger.addEventListener('show.bs.popover', (event) => event.preventDefault(), { once: true });
+
+ instance.show();
+ await Promise.resolve();
+ expect(trigger.getAttribute('aria-expanded')).toBe('false');
+ expect(trigger.hasAttribute('aria-describedby')).toBe(false);
+ expect(quest.root.querySelector('#ariaLiveHelpAnnouncer').textContent).toBe('');
+
+ instance.show();
+ const tip = document.getElementById(trigger.getAttribute('aria-describedby'));
+ expect(tip?.classList.contains('show')).toBe(true);
+ expect(trigger.getAttribute('aria-expanded')).toBe('true');
+
+ trigger.addEventListener('hide.bs.popover', (event) => event.preventDefault(), { once: true });
+ trigger.click();
+ await Promise.resolve();
+ expect(tip.classList.contains('show')).toBe(true);
+ expect(trigger.getAttribute('aria-expanded')).toBe('true');
+ expect(quest.root.querySelector('#ariaLiveHelpAnnouncer').textContent).toBe('Help title Synthetic help text');
+
+ trigger.click();
+ await Promise.resolve();
+ expect(tip.classList.contains('show')).toBe(false);
+ expect(trigger.getAttribute('aria-expanded')).toBe('false');
});
it('disposes an open popover after its hidden lifecycle event', async () => {
@@ -436,6 +507,7 @@ describe('delegated runtime event handling', () => {
const popoverElement = document.getElementById(trigger.getAttribute('aria-describedby'));
expect(popoverElement).not.toBeNull();
expect(popoverElement.classList.contains('show')).toBe(true);
+ expect(trigger.getAttribute('aria-expanded')).toBe('true');
const { disposePopovers } = await import('../../questionnaire.js');
disposePopovers(quest.root);
@@ -444,9 +516,147 @@ describe('delegated runtime event handling', () => {
expect(hiddenModal).not.toHaveBeenCalled();
expect(popoverElement.classList.contains('show')).toBe(false);
expect(trigger.hasAttribute('aria-describedby')).toBe(false);
+ expect(trigger.getAttribute('aria-expanded')).toBe('false');
expect(bootstrap.Popover.getInstance(trigger)).toBeNull();
});
+ it.each(['Enter', ' ', 'click'])('announces existing popover text on %s without moving focus', async (activation) => {
+ const quest = await renderFreshQuest({ markdown: POPOVER_SURVEY });
+ const trigger = quest.root.querySelector('[data-bs-toggle="popover"]');
+ const announcer = quest.root.querySelector('#ariaLiveHelpAnnouncer');
+ expect(announcer).not.toBeNull();
+ expect(announcer.getAttribute('aria-live')).toBe('polite');
+ expect(announcer.getAttribute('aria-atomic')).toBe('true');
+ expect(announcer.textContent).toBe('');
+
+ trigger.focus();
+ if (activation === 'click') trigger.click();
+ else trigger.dispatchEvent(new KeyboardEvent('keydown', { key: activation, bubbles: true, cancelable: true }));
+
+ expect(quest.root.querySelector('#ariaLiveHelpAnnouncer')).toBe(announcer);
+ expect(announcer.textContent).toBe('Help title Synthetic help text');
+ expect(document.activeElement).toBe(trigger);
+ expect(document.getElementById(trigger.getAttribute('aria-describedby'))?.getAttribute('role')).toBe('tooltip');
+ expect(quest.root.querySelector('#ariaLiveQuestionAnnouncer').textContent).toBe('');
+ expect(quest.root.querySelector('#ariaLiveSelectionAnnouncer').textContent).toBe('');
+
+ trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
+ expect(announcer.textContent).toBe('');
+ trigger.click();
+ expect(announcer.textContent).toBe('Help title Synthetic help text');
+ trigger.click();
+ expect(announcer.textContent).toBe('');
+ });
+
+ it.each([
+ ['en', 'es'],
+ ['es', 'en'],
+ ])('keeps %s help announcements in the survey language under an %s host document', async (surveyLang, hostLang) => {
+ document.documentElement.lang = hostLang;
+ const localizedSurvey = LOCALIZED_POPOVER_SURVEYS[surveyLang];
+ const quest = await renderFreshQuest({
+ markdown: localizedSurvey.markdown,
+ params: { lang: surveyLang },
+ });
+ const announcer = quest.root.querySelector('#ariaLiveHelpAnnouncer');
+ const trigger = quest.root.querySelector('[data-bs-toggle="popover"]');
+
+ expect(document.documentElement.lang).toBe(hostLang);
+ expect(announcer.getAttribute('lang')).toBe(surveyLang);
+
+ trigger.click();
+
+ expect(announcer.textContent).toBe(localizedSurvey.announcement);
+ expect(document.documentElement.lang).toBe(hostLang);
+ });
+
+ it.each([
+ ['en', '', 'Informational Text'],
+ ['es', '', 'Texto de ayuda'],
+ ['en', ' |', 'Informational Text'],
+ ['es', ' |', 'Texto de ayuda'],
+ ])('uses the %s trigger-description fallback for heading %j without inventing an opening heading', async (lang, headingToken, description) => {
+ const quest = await renderFreshQuest({
+ markdown: POPOVER_SURVEY.replace('Help title|', headingToken),
+ params: { lang },
+ });
+ const trigger = quest.root.querySelector('[data-bs-toggle="popover"]');
+ expect(trigger.getAttribute('aria-description')).toBe(description);
+ expect(trigger.getAttribute('aria-expanded')).toBe('false');
+
+ trigger.click();
+ expect(trigger.getAttribute('aria-expanded')).toBe('true');
+ expect(quest.root.querySelector('#ariaLiveHelpAnnouncer')?.textContent).toBe('Synthetic help text');
+ });
+
+ it('does not announce a late shown event after focus leaves or disposal', async () => {
+ const quest = await renderFreshQuest({ markdown: POPOVER_SURVEY });
+ const trigger = quest.root.querySelector('[data-bs-toggle="popover"]');
+ trigger.click();
+ const announcer = quest.root.querySelector('#ariaLiveHelpAnnouncer');
+ expect(announcer?.textContent).toBe('Help title Synthetic help text');
+ expect(trigger.getAttribute('aria-expanded')).toBe('true');
+
+ trigger.dispatchEvent(new Event('hidden.bs.popover'));
+ expect(trigger.getAttribute('aria-expanded')).toBe('true');
+
+ document.getElementById('afterQuest').focus();
+ expect(announcer.textContent).toBe('');
+ expect(trigger.getAttribute('aria-expanded')).toBe('false');
+ trigger.dispatchEvent(new Event('shown.bs.popover'));
+ expect(announcer.textContent).toBe('');
+ expect(trigger.getAttribute('aria-expanded')).toBe('false');
+
+ trigger.click();
+ const { disposePopovers } = await import('../../questionnaire.js');
+ disposePopovers(quest.root);
+ expect(announcer.textContent).toBe('');
+ expect(trigger.getAttribute('aria-expanded')).toBe('false');
+ trigger.dispatchEvent(new Event('shown.bs.popover'));
+ expect(announcer.textContent).toBe('');
+ expect(trigger.getAttribute('aria-expanded')).toBe('false');
+ });
+
+ it('does not let an older popover clear a newer help announcement', async () => {
+ const quest = await renderFreshQuest({
+ markdown: POPOVER_SURVEY.replace('Synthetic help text|.', 'Synthetic help text| and |popup|another explanation|Second title|Second help text|.'),
+ });
+ const [first, second] = quest.root.querySelectorAll('[data-bs-toggle="popover"]');
+ const announcer = quest.root.querySelector('#ariaLiveHelpAnnouncer');
+ first.click();
+ second.click();
+ expect(announcer?.textContent).toBe('Second title Second help text');
+ first.dispatchEvent(new Event('hide.bs.popover'));
+ first.dispatchEvent(new Event('hidden.bs.popover'));
+ first.dispatchEvent(new Event('shown.bs.popover'));
+ expect(announcer.textContent).toBe('Second title Second help text');
+ expect(first.getAttribute('aria-expanded')).toBe('false');
+ expect(second.getAttribute('aria-expanded')).toBe('true');
+ });
+
+ it('does not update the new render from an old popover lifecycle event', async () => {
+ const quest = await renderFreshQuest({ markdown: POPOVER_SURVEY });
+ const oldTrigger = quest.root.querySelector('[data-bs-toggle="popover"]');
+ oldTrigger.click();
+ const oldAnnouncer = quest.root.querySelector('#ariaLiveHelpAnnouncer');
+ expect(oldAnnouncer?.getAttribute('lang')).toBe('en');
+ expect(oldAnnouncer?.textContent).toBe('Help title Synthetic help text');
+
+ await quest.transform.render({ activate: true, text: POPOVER_SURVEY, lang: 'es', store: quest.store }, 'questionnaireRoot', {});
+ const newAnnouncer = quest.root.querySelector('#ariaLiveHelpAnnouncer');
+ const newTrigger = quest.root.querySelector('[data-bs-toggle="popover"]');
+ newTrigger.click();
+ expect(newAnnouncer?.getAttribute('lang')).toBe('es');
+ expect(newAnnouncer?.textContent).toBe('Help title Synthetic help text');
+ expect(oldAnnouncer.textContent).toBe('');
+ oldTrigger.dispatchEvent(new Event('hide.bs.popover'));
+ oldTrigger.dispatchEvent(new Event('hidden.bs.popover'));
+ oldTrigger.dispatchEvent(new Event('shown.bs.popover'));
+ expect(newAnnouncer.textContent).toBe('Help title Synthetic help text');
+ expect(oldTrigger.getAttribute('aria-expanded')).toBe('false');
+ expect(newTrigger.getAttribute('aria-expanded')).toBe('true');
+ });
+
it('does not emit duplicate popover lifecycle events for repeated show or hide calls', async () => {
const quest = await renderFreshQuest({ markdown: POPOVER_SURVEY });
const trigger = quest.root.querySelector('[data-bs-toggle="popover"]');
@@ -476,12 +686,14 @@ describe('delegated runtime event handling', () => {
expect(bootstrap.Popover.getInstance(trigger)).not.toBeNull();
expect(trigger.hasAttribute('aria-describedby')).toBe(false);
+ expect(trigger.getAttribute('aria-expanded')).toBe('false');
const { disposePopovers } = await import('../../questionnaire.js');
disposePopovers(quest.root);
expect(hiddenPopover).not.toHaveBeenCalled();
expect(bootstrap.Popover.getInstance(trigger)).toBeNull();
+ expect(trigger.getAttribute('aria-expanded')).toBe('false');
});
it('updates the live selection announcement without requiring listeners on individual controls', async () => {
@@ -508,7 +720,7 @@ describe('delegated runtime event handling', () => {
expect(fieldset.querySelectorAll('.screen-reader-focus')).toHaveLength(1);
expect(fieldset.querySelector('.screen-reader-focus').tabIndex).toBe(-1);
expect([...fieldset.querySelectorAll('br')].every((br) => br.getAttribute('aria-hidden') === 'true')).toBe(true);
- expect(quest.root.querySelectorAll('#srAnnouncerContainer [aria-live="polite"]')).toHaveLength(2);
+ expect(quest.root.querySelectorAll('#srAnnouncerContainer [aria-live="polite"]')).toHaveLength(3);
});
it('moves focus to the constructed screen-reader stop after a question transition', async () => {
diff --git a/tests/setup/jsdom.js b/tests/setup/jsdom.js
index 0036eb2..9c8c4a6 100644
--- a/tests/setup/jsdom.js
+++ b/tests/setup/jsdom.js
@@ -134,30 +134,49 @@ beforeEach(() => {
this._tip.id = `test-popover-${this.constructor.nextTipId++}`;
this._tip.classList.add('popover');
this._tip.setAttribute('role', 'tooltip');
+ const title = this._element.getAttribute('data-bs-title') || this._element.getAttribute('title');
+ if (title) {
+ const header = this._element.ownerDocument.createElement('h3');
+ header.className = 'popover-header';
+ header.textContent = title;
+ this._tip.append(header);
+ }
+ const body = this._element.ownerDocument.createElement('div');
+ body.className = 'popover-body';
+ body.textContent = this._element.getAttribute('data-bs-content') ?? '';
+ this._tip.append(body);
}
return this._tip;
}
show() {
- const tip = this._getTipElement();
- if (tip.classList.contains('show')) return;
+ if (this._tip?.classList.contains('show')) return;
+
+ const showEvent = new Event('show.bs.popover', { bubbles: true, cancelable: true });
+ if (!this._element.dispatchEvent(showEvent) || !this._element.isConnected) return;
+ const tip = this._getTipElement();
this._element.setAttribute('aria-describedby', tip.id);
- this._element.ownerDocument.body.append(tip);
+ if (!tip.isConnected) {
+ this._element.ownerDocument.body.append(tip);
+ this._element.dispatchEvent(new Event('inserted.bs.popover', { bubbles: true }));
+ }
tip.classList.add('show');
- this._element.dispatchEvent(new Event(`shown.bs.${this.constructor.eventNamespace}`));
+ this._element.dispatchEvent(new Event(`shown.bs.${this.constructor.eventNamespace}`, { bubbles: true }));
}
hide() {
const tip = this._tip;
if (!tip?.classList.contains('show')) return;
+ const hideEvent = new Event('hide.bs.popover', { bubbles: true, cancelable: true });
+ if (!this._element.dispatchEvent(hideEvent)) return;
tip.classList.remove('show');
this._element.removeAttribute('aria-describedby');
tip.remove();
this._tip = null;
- this._element.dispatchEvent(new Event(`hidden.bs.${this.constructor.eventNamespace}`));
+ this._element.dispatchEvent(new Event(`hidden.bs.${this.constructor.eventNamespace}`, { bubbles: true }));
}
toggle() {