From ddb741ef861f80ea9879661527626dfc72a72733 Mon Sep 17 00:00:00 2001 From: Peter Ringelmann Date: Wed, 18 Mar 2026 17:37:57 +0100 Subject: [PATCH] fix(a11y): connect question inputs to heading and description via aria Add aria-labelledby and aria-describedby to all question type inputs, connecting them to the question title and description so screen readers announce them when a respondent focuses an input field. Fixes #3172 Signed-off-by: Peter Ringelmann --- playwright/e2e/a11y-question-inputs.spec.ts | 250 ++++++++++++++++++ .../support/sections/QuestionSection.ts | 33 ++- playwright/support/sections/QuestionType.ts | 6 + src/components/Questions/Question.vue | 5 + src/components/Questions/QuestionColor.vue | 6 +- src/components/Questions/QuestionDate.vue | 4 + src/components/Questions/QuestionDropdown.vue | 27 +- src/components/Questions/QuestionFile.vue | 6 +- src/components/Questions/QuestionGrid.vue | 9 +- .../Questions/QuestionLinearScale.vue | 5 +- src/components/Questions/QuestionLong.vue | 13 +- src/components/Questions/QuestionMultiple.vue | 9 +- src/components/Questions/QuestionShort.vue | 13 +- src/mixins/QuestionMixin.js | 8 + 14 files changed, 347 insertions(+), 47 deletions(-) create mode 100644 playwright/e2e/a11y-question-inputs.spec.ts diff --git a/playwright/e2e/a11y-question-inputs.spec.ts b/playwright/e2e/a11y-question-inputs.spec.ts new file mode 100644 index 000000000..3c98e0cab --- /dev/null +++ b/playwright/e2e/a11y-question-inputs.spec.ts @@ -0,0 +1,250 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, mergeTests } from '@playwright/test' +import { test as randomUserTest } from '../support/fixtures/random-user' +import { test as appNavigationTest } from '../support/fixtures/navigation' +import { test as formTest } from '../support/fixtures/form' +import { test as topBarTest } from '../support/fixtures/topBar' +import { FormsView } from '../support/sections/TopBarSection' +import { QuestionType } from '../support/sections/QuestionType' + +const test = mergeTests(randomUserTest, appNavigationTest, formTest, topBarTest) + +test.beforeEach(async ({ page }) => { + await page.goto('apps/forms') + await page.waitForURL(/apps\/forms$/) +}) + +test.describe('Accessibility: aria attributes on question inputs', () => { + test('Short answer with description has aria-labelledby and aria-describedby', async ({ + appNavigation, + form, + topBar, + page, + }) => { + await appNavigation.clickNewForm() + await form.fillTitle('Test form') + + await form.addQuestion(QuestionType.ShortAnswer) + const questions = await form.getQuestions() + await questions[0].fillTitle('My question') + await questions[0].fillDescription('Some context') + + await topBar.toggleView(FormsView.View) + + const question = page.getByRole('listitem', { name: /Question number 1/ }) + const input = question.getByRole('textbox') + + await expect(input).toHaveAttribute('aria-labelledby', 'q1_title') + await expect(input).toHaveAttribute('aria-describedby', 'q1_desc') + + await expect(page.getByRole('heading', { name: 'My question' })).toHaveId('q1_title') + await expect(page.locator('#q1_desc')).toContainText('Some context') + }) + + test('Short answer without description has aria-labelledby but no aria-describedby', async ({ + appNavigation, + form, + topBar, + page, + }) => { + await appNavigation.clickNewForm() + await form.fillTitle('Test form') + + await form.addQuestion(QuestionType.ShortAnswer) + const questions = await form.getQuestions() + await questions[0].fillTitle('My question') + + await topBar.toggleView(FormsView.View) + + const question = page.getByRole('listitem', { name: /Question number 1/ }) + const input = question.getByRole('textbox') + + await expect(input).toHaveAttribute('aria-labelledby', 'q1_title') + await expect(input).not.toHaveAttribute('aria-describedby') + }) + + test('Checkboxes fieldset with description has aria-labelledby and aria-describedby', async ({ + appNavigation, + form, + topBar, + page, + }) => { + await appNavigation.clickNewForm() + await form.fillTitle('Test form') + + await form.addQuestion(QuestionType.Checkboxes) + const questions = await form.getQuestions() + await questions[0].fillTitle('My checkbox question') + await questions[0].fillDescription('Pick one or more') + await questions[0].addAnswer('Option 1') + + await topBar.toggleView(FormsView.View) + + const question = page.getByRole('listitem', { name: /Question number 1/ }) + const fieldset = question.getByRole('group').first() + + await expect(fieldset).toHaveAttribute('aria-labelledby', 'q1_title') + await expect(fieldset).toHaveAttribute('aria-describedby', 'q1_desc') + }) + + test('Long answer with description has aria-labelledby and aria-describedby', async ({ + appNavigation, + form, + topBar, + page, + }) => { + await appNavigation.clickNewForm() + await form.fillTitle('Test form') + + await form.addQuestion(QuestionType.LongAnswer) + const questions = await form.getQuestions() + await questions[0].fillTitle('My long question') + await questions[0].fillDescription('Please elaborate') + + await topBar.toggleView(FormsView.View) + + const question = page.getByRole('listitem', { name: /Question number 1/ }) + const textarea = question.getByRole('textbox') + + await expect(textarea).toHaveAttribute('aria-labelledby', 'q1_title') + await expect(textarea).toHaveAttribute('aria-describedby', 'q1_desc') + + await expect(page.getByRole('heading', { name: 'My long question' })).toHaveId('q1_title') + await expect(page.locator('#q1_desc')).toContainText('Please elaborate') + }) + + test('Dropdown with description has aria-describedby', async ({ + appNavigation, + form, + topBar, + page, + }) => { + await appNavigation.clickNewForm() + await form.fillTitle('Test form') + + await form.addQuestion(QuestionType.Dropdown) + const questions = await form.getQuestions() + await questions[0].fillTitle('My dropdown question') + await questions[0].fillDescription('Choose an option') + await questions[0].addAnswer('Option 1') + + await topBar.toggleView(FormsView.View) + + const question = page.getByRole('listitem', { name: /Question number 1/ }) + const group = question.getByRole('group').first() + + await expect(group).toHaveAttribute('aria-labelledby', 'q1_title') + await expect(group).toHaveAttribute('aria-describedby', 'q1_desc') + + await expect(page.getByRole('heading', { name: 'My dropdown question' })).toHaveId('q1_title') + await expect(page.locator('#q1_desc')).toContainText('Choose an option') + }) + + test('Date question with description has aria-labelledby and aria-describedby', async ({ + appNavigation, + form, + topBar, + page, + }) => { + await appNavigation.clickNewForm() + await form.fillTitle('Test form') + + await form.addQuestion(QuestionType.Date) + const questions = await form.getQuestions() + await questions[0].fillTitle('My date question') + await questions[0].fillDescription('Pick a date') + + await topBar.toggleView(FormsView.View) + + const question = page.getByRole('listitem', { name: /Question number 1/ }) + const input = question.getByRole('textbox') + + await expect(input).toHaveAttribute('aria-labelledby', 'q1_title') + await expect(input).toHaveAttribute('aria-describedby', 'q1_desc') + + await expect(page.getByRole('heading', { name: 'My date question' })).toHaveId('q1_title') + await expect(page.locator('#q1_desc')).toContainText('Pick a date') + }) + + test('Linear scale question with description has aria-labelledby and aria-describedby', async ({ + appNavigation, + form, + topBar, + page, + }) => { + await appNavigation.clickNewForm() + await form.fillTitle('Test form') + + await form.addQuestion(QuestionType.LinearScale) + const questions = await form.getQuestions() + await questions[0].fillTitle('Rate your experience') + await questions[0].fillDescription('From 1 to 5') + + await topBar.toggleView(FormsView.View) + + const question = page.getByRole('listitem', { name: /Question number 1/ }) + const fieldset = question.getByRole('group').first() + + await expect(fieldset).toHaveAttribute('aria-labelledby', 'q1_title') + await expect(fieldset).toHaveAttribute('aria-describedby', 'q1_desc') + + await expect(page.getByRole('heading', { name: 'Rate your experience' })).toHaveId('q1_title') + await expect(page.locator('#q1_desc')).toContainText('From 1 to 5') + }) + + test('File question with description has aria-labelledby and aria-describedby', async ({ + appNavigation, + form, + topBar, + page, + }) => { + await appNavigation.clickNewForm() + await form.fillTitle('Test form') + + await form.addQuestion(QuestionType.File) + const questions = await form.getQuestions() + await questions[0].fillTitle('My file question') + await questions[0].fillDescription('Upload your file') + + await topBar.toggleView(FormsView.View) + + const question = page.getByRole('listitem', { name: /Question number 1/ }) + const group = question.getByRole('group').first() + + await expect(group).toHaveAttribute('aria-labelledby', 'q1_title') + await expect(group).toHaveAttribute('aria-describedby', 'q1_desc') + + await expect(page.getByRole('heading', { name: 'My file question' })).toHaveId('q1_title') + await expect(page.locator('#q1_desc')).toContainText('Upload your file') + }) + + test('Color question with description has aria-labelledby and aria-describedby', async ({ + appNavigation, + form, + topBar, + page, + }) => { + await appNavigation.clickNewForm() + await form.fillTitle('Test form') + + await form.addQuestion(QuestionType.Color) + const questions = await form.getQuestions() + await questions[0].fillTitle('My color question') + await questions[0].fillDescription('Pick a color') + + await topBar.toggleView(FormsView.View) + + const question = page.getByRole('listitem', { name: /Question number 1/ }) + const group = question.getByRole('group').first() + + await expect(group).toHaveAttribute('aria-labelledby', 'q1_title') + await expect(group).toHaveAttribute('aria-describedby', 'q1_desc') + + await expect(page.getByRole('heading', { name: 'My color question' })).toHaveId('q1_title') + await expect(page.locator('#q1_desc')).toContainText('Pick a color') + }) +}) diff --git a/playwright/support/sections/QuestionSection.ts b/playwright/support/sections/QuestionSection.ts index 1c565f53d..be2ac491b 100644 --- a/playwright/support/sections/QuestionSection.ts +++ b/playwright/support/sections/QuestionSection.ts @@ -3,10 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { Locator, Page } from '@playwright/test' +import type { Locator, Page, Response } from '@playwright/test' export class QuestionSection { public readonly titleInput: Locator + public readonly descriptionInput: Locator public readonly newAnswerInput: Locator public readonly answerInputs: Locator @@ -18,6 +19,9 @@ export class QuestionSection { this.titleInput = this.section.getByRole('textbox', { name: /title of/i, }) + this.descriptionInput = this.section.getByPlaceholder( + 'Description (formatting using Markdown is supported)', + ) this.newAnswerInput = this.section.getByRole('textbox', { name: 'Add a new answer option', }) @@ -27,6 +31,33 @@ export class QuestionSection { } async fillTitle(title: string): Promise { + const saved = this.getQuestionUpdatedPromise() await this.titleInput.fill(title) + await saved + } + + async fillDescription(description: string): Promise { + const saved = this.getQuestionUpdatedPromise() + await this.descriptionInput.fill(description) + await saved + } + + async addAnswer(text: string): Promise { + const saved = this.page.waitForResponse( + (response) => + response.request().method() === 'POST' + && response.request().url().includes('/api/v3/forms/'), + ) + await this.newAnswerInput.fill(text) + await this.newAnswerInput.press('Enter') + await saved + } + + private getQuestionUpdatedPromise(): Promise { + return this.page.waitForResponse( + (response) => + response.request().method() === 'PATCH' + && response.request().url().includes('/api/v3/forms/'), + ) } } diff --git a/playwright/support/sections/QuestionType.ts b/playwright/support/sections/QuestionType.ts index f8261f5ae..da502e0d9 100644 --- a/playwright/support/sections/QuestionType.ts +++ b/playwright/support/sections/QuestionType.ts @@ -5,5 +5,11 @@ export enum QuestionType { Checkboxes = 'Checkboxes', + Color = 'Color', + Date = 'Date', Dropdown = 'Dropdown', + File = 'File', + LinearScale = 'Linear scale', + LongAnswer = 'Long text', + ShortAnswer = 'Short answer', } diff --git a/src/components/Questions/Question.vue b/src/components/Questions/Question.vue index e62a361aa..37e3904ed 100644 --- a/src/components/Questions/Question.vue +++ b/src/components/Questions/Question.vue @@ -142,6 +142,7 @@
@@ -306,6 +307,10 @@ export default { return 'q' + this.index + '_title' }, + descriptionId() { + return 'q' + this.index + '_desc' + }, + hasDescription() { return this.description !== '' }, diff --git a/src/components/Questions/QuestionColor.vue b/src/components/Questions/QuestionColor.vue index 192ace897..fe708eeb4 100644 --- a/src/components/Questions/QuestionColor.vue +++ b/src/components/Questions/QuestionColor.vue @@ -9,7 +9,11 @@ :title-placeholder="answerType.titlePlaceholder" :warning-invalid="answerType.warningInvalid" v-on="commonListeners"> -
+
- + role="group" + :aria-labelledby="titleId" + :aria-describedby="description ? descriptionId : undefined"> + +