Skip to content
Draft
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
250 changes: 250 additions & 0 deletions playwright/e2e/a11y-question-inputs.spec.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
33 changes: 32 additions & 1 deletion playwright/support/sections/QuestionSection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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',
})
Expand All @@ -27,6 +31,33 @@ export class QuestionSection {
}

async fillTitle(title: string): Promise<void> {
const saved = this.getQuestionUpdatedPromise()
await this.titleInput.fill(title)
await saved
}

async fillDescription(description: string): Promise<void> {
const saved = this.getQuestionUpdatedPromise()
await this.descriptionInput.fill(description)
await saved
}

async addAnswer(text: string): Promise<void> {
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<Response> {
return this.page.waitForResponse(
(response) =>
response.request().method() === 'PATCH'
&& response.request().url().includes('/api/v3/forms/'),
)
}
}
6 changes: 6 additions & 0 deletions playwright/support/sections/QuestionType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}
5 changes: 5 additions & 0 deletions src/components/Questions/Question.vue
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@
<!-- eslint-disable vue/no-v-html -->
<div
v-else
:id="descriptionId"
class="question__header__description__output"
v-html="computedDescription" />
<!-- eslint-enable vue/no-v-html -->
Expand Down Expand Up @@ -306,6 +307,10 @@ export default {
return 'q' + this.index + '_title'
},

descriptionId() {
return 'q' + this.index + '_desc'
},

hasDescription() {
return this.description !== ''
},
Expand Down
6 changes: 5 additions & 1 deletion src/components/Questions/QuestionColor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@
:title-placeholder="answerType.titlePlaceholder"
:warning-invalid="answerType.warningInvalid"
v-on="commonListeners">
<div class="question__content">
<div
class="question__content"
role="group"
:aria-labelledby="titleId"
:aria-describedby="description ? descriptionId : undefined">
<NcColorPicker
:model-value="pickedColor"
advanced-fields
Expand Down
4 changes: 4 additions & 0 deletions src/components/Questions/QuestionDate.vue
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,10 @@ export default {
return {
required: this.isRequired,
name: this.name || undefined,
'aria-labelledby': this.titleId,
'aria-describedby': this.description
? this.descriptionId
: undefined,
}
},

Expand Down
27 changes: 16 additions & 11 deletions src/components/Questions/QuestionDropdown.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,23 @@
{{ t('forms', 'Add multiple options') }}
</NcActionButton>
</template>
<NcSelect
<div
v-if="readOnly"
:model-value="selectedOption"
:name="name || undefined"
:placeholder="selectOptionPlaceholder"
:multiple="isMultiple"
:required="isRequired"
:options="choices"
:searchable="false"
label="text"
:aria-label-combobox="selectOptionPlaceholder"
@input="onInput" />
role="group"
:aria-labelledby="titleId"
:aria-describedby="description ? descriptionId : undefined">
<NcSelect
:model-value="selectedOption"
:name="name || undefined"
:placeholder="selectOptionPlaceholder"
:multiple="isMultiple"
:required="isRequired"
:options="choices"
:searchable="false"
label="text"
:aria-label-combobox="selectOptionPlaceholder"
@input="onInput" />
</div>
<template v-else>
<div v-if="isLoading">
<NcLoadingIcon :size="64" />
Expand All @@ -62,7 +67,7 @@
<AnswerInput
v-for="(answer, index) in choices"
:key="answer.local ? 'option-local' : answer.id"
ref="input"

Check warning on line 70 in src/components/Questions/QuestionDropdown.vue

View workflow job for this annotation

GitHub Actions / NPM lint

'input' is defined as ref, but never used
:answer="answer"
:form-id="formId"
is-dropdown
Expand Down
Loading
Loading