Skip to content

Commit ada927b

Browse files
authored
Merge pull request #3228 from nextcloud/fix/3172-aria-labelledby-question-inputs
fix(a11y): connect question inputs to heading and description via aria
2 parents 7003d12 + 075bd83 commit ada927b

14 files changed

Lines changed: 347 additions & 47 deletions
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { expect, mergeTests } from '@playwright/test'
7+
import { test as randomUserTest } from '../support/fixtures/random-user'
8+
import { test as appNavigationTest } from '../support/fixtures/navigation'
9+
import { test as formTest } from '../support/fixtures/form'
10+
import { test as topBarTest } from '../support/fixtures/topBar'
11+
import { FormsView } from '../support/sections/TopBarSection'
12+
import { QuestionType } from '../support/sections/QuestionType'
13+
14+
const test = mergeTests(randomUserTest, appNavigationTest, formTest, topBarTest)
15+
16+
test.beforeEach(async ({ page }) => {
17+
await page.goto('apps/forms')
18+
await page.waitForURL(/apps\/forms$/)
19+
})
20+
21+
test.describe('Accessibility: aria attributes on question inputs', () => {
22+
test('Short answer with description has aria-labelledby and aria-describedby', async ({
23+
appNavigation,
24+
form,
25+
topBar,
26+
page,
27+
}) => {
28+
await appNavigation.clickNewForm()
29+
await form.fillTitle('Test form')
30+
31+
await form.addQuestion(QuestionType.ShortAnswer)
32+
const questions = await form.getQuestions()
33+
await questions[0].fillTitle('My question')
34+
await questions[0].fillDescription('Some context')
35+
36+
await topBar.toggleView(FormsView.View)
37+
38+
const question = page.getByRole('listitem', { name: /Question number 1/ })
39+
const input = question.getByRole('textbox')
40+
41+
await expect(input).toHaveAttribute('aria-labelledby', 'q1_title')
42+
await expect(input).toHaveAttribute('aria-describedby', 'q1_desc')
43+
44+
await expect(page.getByRole('heading', { name: 'My question' })).toHaveId('q1_title')
45+
await expect(page.locator('#q1_desc')).toContainText('Some context')
46+
})
47+
48+
test('Short answer without description has aria-labelledby but no aria-describedby', async ({
49+
appNavigation,
50+
form,
51+
topBar,
52+
page,
53+
}) => {
54+
await appNavigation.clickNewForm()
55+
await form.fillTitle('Test form')
56+
57+
await form.addQuestion(QuestionType.ShortAnswer)
58+
const questions = await form.getQuestions()
59+
await questions[0].fillTitle('My question')
60+
61+
await topBar.toggleView(FormsView.View)
62+
63+
const question = page.getByRole('listitem', { name: /Question number 1/ })
64+
const input = question.getByRole('textbox')
65+
66+
await expect(input).toHaveAttribute('aria-labelledby', 'q1_title')
67+
await expect(input).not.toHaveAttribute('aria-describedby')
68+
})
69+
70+
test('Checkboxes fieldset with description has aria-labelledby and aria-describedby', async ({
71+
appNavigation,
72+
form,
73+
topBar,
74+
page,
75+
}) => {
76+
await appNavigation.clickNewForm()
77+
await form.fillTitle('Test form')
78+
79+
await form.addQuestion(QuestionType.Checkboxes)
80+
const questions = await form.getQuestions()
81+
await questions[0].fillTitle('My checkbox question')
82+
await questions[0].fillDescription('Pick one or more')
83+
await questions[0].addAnswer('Option 1')
84+
85+
await topBar.toggleView(FormsView.View)
86+
87+
const question = page.getByRole('listitem', { name: /Question number 1/ })
88+
const fieldset = question.getByRole('group').first()
89+
90+
await expect(fieldset).toHaveAttribute('aria-labelledby', 'q1_title')
91+
await expect(fieldset).toHaveAttribute('aria-describedby', 'q1_desc')
92+
})
93+
94+
test('Long answer with description has aria-labelledby and aria-describedby', async ({
95+
appNavigation,
96+
form,
97+
topBar,
98+
page,
99+
}) => {
100+
await appNavigation.clickNewForm()
101+
await form.fillTitle('Test form')
102+
103+
await form.addQuestion(QuestionType.LongAnswer)
104+
const questions = await form.getQuestions()
105+
await questions[0].fillTitle('My long question')
106+
await questions[0].fillDescription('Please elaborate')
107+
108+
await topBar.toggleView(FormsView.View)
109+
110+
const question = page.getByRole('listitem', { name: /Question number 1/ })
111+
const textarea = question.getByRole('textbox')
112+
113+
await expect(textarea).toHaveAttribute('aria-labelledby', 'q1_title')
114+
await expect(textarea).toHaveAttribute('aria-describedby', 'q1_desc')
115+
116+
await expect(page.getByRole('heading', { name: 'My long question' })).toHaveId('q1_title')
117+
await expect(page.locator('#q1_desc')).toContainText('Please elaborate')
118+
})
119+
120+
test('Dropdown with description has aria-describedby', async ({
121+
appNavigation,
122+
form,
123+
topBar,
124+
page,
125+
}) => {
126+
await appNavigation.clickNewForm()
127+
await form.fillTitle('Test form')
128+
129+
await form.addQuestion(QuestionType.Dropdown)
130+
const questions = await form.getQuestions()
131+
await questions[0].fillTitle('My dropdown question')
132+
await questions[0].fillDescription('Choose an option')
133+
await questions[0].addAnswer('Option 1')
134+
135+
await topBar.toggleView(FormsView.View)
136+
137+
const question = page.getByRole('listitem', { name: /Question number 1/ })
138+
const group = question.getByRole('group').first()
139+
140+
await expect(group).toHaveAttribute('aria-labelledby', 'q1_title')
141+
await expect(group).toHaveAttribute('aria-describedby', 'q1_desc')
142+
143+
await expect(page.getByRole('heading', { name: 'My dropdown question' })).toHaveId('q1_title')
144+
await expect(page.locator('#q1_desc')).toContainText('Choose an option')
145+
})
146+
147+
test('Date question with description has aria-labelledby and aria-describedby', async ({
148+
appNavigation,
149+
form,
150+
topBar,
151+
page,
152+
}) => {
153+
await appNavigation.clickNewForm()
154+
await form.fillTitle('Test form')
155+
156+
await form.addQuestion(QuestionType.Date)
157+
const questions = await form.getQuestions()
158+
await questions[0].fillTitle('My date question')
159+
await questions[0].fillDescription('Pick a date')
160+
161+
await topBar.toggleView(FormsView.View)
162+
163+
const question = page.getByRole('listitem', { name: /Question number 1/ })
164+
const input = question.getByRole('textbox')
165+
166+
await expect(input).toHaveAttribute('aria-labelledby', 'q1_title')
167+
await expect(input).toHaveAttribute('aria-describedby', 'q1_desc')
168+
169+
await expect(page.getByRole('heading', { name: 'My date question' })).toHaveId('q1_title')
170+
await expect(page.locator('#q1_desc')).toContainText('Pick a date')
171+
})
172+
173+
test('Linear scale question with description has aria-labelledby and aria-describedby', async ({
174+
appNavigation,
175+
form,
176+
topBar,
177+
page,
178+
}) => {
179+
await appNavigation.clickNewForm()
180+
await form.fillTitle('Test form')
181+
182+
await form.addQuestion(QuestionType.LinearScale)
183+
const questions = await form.getQuestions()
184+
await questions[0].fillTitle('Rate your experience')
185+
await questions[0].fillDescription('From 1 to 5')
186+
187+
await topBar.toggleView(FormsView.View)
188+
189+
const question = page.getByRole('listitem', { name: /Question number 1/ })
190+
const fieldset = question.getByRole('group').first()
191+
192+
await expect(fieldset).toHaveAttribute('aria-labelledby', 'q1_title')
193+
await expect(fieldset).toHaveAttribute('aria-describedby', 'q1_desc')
194+
195+
await expect(page.getByRole('heading', { name: 'Rate your experience' })).toHaveId('q1_title')
196+
await expect(page.locator('#q1_desc')).toContainText('From 1 to 5')
197+
})
198+
199+
test('File question with description has aria-labelledby and aria-describedby', async ({
200+
appNavigation,
201+
form,
202+
topBar,
203+
page,
204+
}) => {
205+
await appNavigation.clickNewForm()
206+
await form.fillTitle('Test form')
207+
208+
await form.addQuestion(QuestionType.File)
209+
const questions = await form.getQuestions()
210+
await questions[0].fillTitle('My file question')
211+
await questions[0].fillDescription('Upload your file')
212+
213+
await topBar.toggleView(FormsView.View)
214+
215+
const question = page.getByRole('listitem', { name: /Question number 1/ })
216+
const group = question.getByRole('group').first()
217+
218+
await expect(group).toHaveAttribute('aria-labelledby', 'q1_title')
219+
await expect(group).toHaveAttribute('aria-describedby', 'q1_desc')
220+
221+
await expect(page.getByRole('heading', { name: 'My file question' })).toHaveId('q1_title')
222+
await expect(page.locator('#q1_desc')).toContainText('Upload your file')
223+
})
224+
225+
test('Color question with description has aria-labelledby and aria-describedby', async ({
226+
appNavigation,
227+
form,
228+
topBar,
229+
page,
230+
}) => {
231+
await appNavigation.clickNewForm()
232+
await form.fillTitle('Test form')
233+
234+
await form.addQuestion(QuestionType.Color)
235+
const questions = await form.getQuestions()
236+
await questions[0].fillTitle('My color question')
237+
await questions[0].fillDescription('Pick a color')
238+
239+
await topBar.toggleView(FormsView.View)
240+
241+
const question = page.getByRole('listitem', { name: /Question number 1/ })
242+
const group = question.getByRole('group').first()
243+
244+
await expect(group).toHaveAttribute('aria-labelledby', 'q1_title')
245+
await expect(group).toHaveAttribute('aria-describedby', 'q1_desc')
246+
247+
await expect(page.getByRole('heading', { name: 'My color question' })).toHaveId('q1_title')
248+
await expect(page.locator('#q1_desc')).toContainText('Pick a color')
249+
})
250+
})

playwright/support/sections/QuestionSection.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
55

6-
import type { Locator, Page } from '@playwright/test'
6+
import type { Locator, Page, Response } from '@playwright/test'
77

88
export class QuestionSection {
99
public readonly titleInput: Locator
10+
public readonly descriptionInput: Locator
1011
public readonly newAnswerInput: Locator
1112
public readonly answerInputs: Locator
1213

@@ -18,6 +19,9 @@ export class QuestionSection {
1819
this.titleInput = this.section.getByRole('textbox', {
1920
name: /title of/i,
2021
})
22+
this.descriptionInput = this.section.getByPlaceholder(
23+
'Description (formatting using Markdown is supported)',
24+
)
2125
this.newAnswerInput = this.section.getByRole('textbox', {
2226
name: 'Add a new answer option',
2327
})
@@ -27,6 +31,33 @@ export class QuestionSection {
2731
}
2832

2933
async fillTitle(title: string): Promise<void> {
34+
const saved = this.getQuestionUpdatedPromise()
3035
await this.titleInput.fill(title)
36+
await saved
37+
}
38+
39+
async fillDescription(description: string): Promise<void> {
40+
const saved = this.getQuestionUpdatedPromise()
41+
await this.descriptionInput.fill(description)
42+
await saved
43+
}
44+
45+
async addAnswer(text: string): Promise<void> {
46+
const saved = this.page.waitForResponse(
47+
(response) =>
48+
response.request().method() === 'POST'
49+
&& response.request().url().includes('/api/v3/forms/'),
50+
)
51+
await this.newAnswerInput.fill(text)
52+
await this.newAnswerInput.press('Enter')
53+
await saved
54+
}
55+
56+
private getQuestionUpdatedPromise(): Promise<Response> {
57+
return this.page.waitForResponse(
58+
(response) =>
59+
response.request().method() === 'PATCH'
60+
&& response.request().url().includes('/api/v3/forms/'),
61+
)
3162
}
3263
}

playwright/support/sections/QuestionType.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,11 @@
55

66
export enum QuestionType {
77
Checkboxes = 'Checkboxes',
8+
Color = 'Color',
9+
Date = 'Date',
810
Dropdown = 'Dropdown',
11+
File = 'File',
12+
LinearScale = 'Linear scale',
13+
LongAnswer = 'Long text',
14+
ShortAnswer = 'Short answer',
915
}

src/components/Questions/Question.vue

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@
142142
<!-- eslint-disable vue/no-v-html -->
143143
<div
144144
v-else
145+
:id="descriptionId"
145146
class="question__header__description__output"
146147
v-html="computedDescription" />
147148
<!-- eslint-enable vue/no-v-html -->
@@ -306,6 +307,10 @@ export default {
306307
return 'q' + this.index + '_title'
307308
},
308309
310+
descriptionId() {
311+
return 'q' + this.index + '_desc'
312+
},
313+
309314
hasDescription() {
310315
return this.description !== ''
311316
},

src/components/Questions/QuestionColor.vue

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@
99
:title-placeholder="answerType.titlePlaceholder"
1010
:warning-invalid="answerType.warningInvalid"
1111
v-on="commonListeners">
12-
<div class="question__content">
12+
<div
13+
class="question__content"
14+
role="group"
15+
:aria-labelledby="titleId"
16+
:aria-describedby="description ? descriptionId : undefined">
1317
<NcColorPicker
1418
:model-value="pickedColor"
1519
advanced-fields

src/components/Questions/QuestionDate.vue

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,10 @@ export default {
166166
return {
167167
required: this.isRequired,
168168
name: this.name || undefined,
169+
'aria-labelledby': this.titleId,
170+
'aria-describedby': this.description
171+
? this.descriptionId
172+
: undefined,
169173
}
170174
},
171175

src/components/Questions/QuestionDropdown.vue

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,18 +24,23 @@
2424
{{ t('forms', 'Add multiple options') }}
2525
</NcActionButton>
2626
</template>
27-
<NcSelect
27+
<div
2828
v-if="readOnly"
29-
:model-value="selectedOption"
30-
:name="name || undefined"
31-
:placeholder="selectOptionPlaceholder"
32-
:multiple="isMultiple"
33-
:required="isRequired"
34-
:options="choices"
35-
:searchable="false"
36-
label="text"
37-
:aria-label-combobox="selectOptionPlaceholder"
38-
@input="onInput" />
29+
role="group"
30+
:aria-labelledby="titleId"
31+
:aria-describedby="description ? descriptionId : undefined">
32+
<NcSelect
33+
:model-value="selectedOption"
34+
:name="name || undefined"
35+
:placeholder="selectOptionPlaceholder"
36+
:multiple="isMultiple"
37+
:required="isRequired"
38+
:options="choices"
39+
:searchable="false"
40+
label="text"
41+
:aria-label-combobox="selectOptionPlaceholder"
42+
@input="onInput" />
43+
</div>
3944
<template v-else>
4045
<div v-if="isLoading">
4146
<NcLoadingIcon :size="64" />

0 commit comments

Comments
 (0)