Skip to content

Commit 9aa5ec0

Browse files
authored
Merge pull request #7052 from LibreSign/backport/7051/stable33
[stable33] fix: a11y improvements
2 parents 81636ed + de71c95 commit 9aa5ec0

12 files changed

Lines changed: 746 additions & 1760 deletions

File tree

package-lock.json

Lines changed: 468 additions & 1753 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
"@codemirror/state": "^6.4.1",
3131
"@codemirror/view": "^6.36.2",
3232
"@fontsource/dancing-script": "^5.2.8",
33-
"@libresign/pdf-elements": "^1.0.2",
33+
"@libresign/pdf-elements": "^1.1.0",
3434
"@marionebl/option": "^1.0.8",
3535
"@mdi/js": "^7.4.47",
3636
"@mdi/svg": "^7.4.47",
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { expect, test } from '@playwright/test'
7+
import { login } from '../support/nc-login'
8+
import { configureOpenSsl, setAppConfig } from '../support/nc-provisioning'
9+
10+
test('sign herself with drawn signature', async ({ page }) => {
11+
await login(
12+
page.request,
13+
process.env.NEXTCLOUD_ADMIN_USER ?? 'admin',
14+
process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin',
15+
)
16+
17+
await configureOpenSsl(page.request, 'LibreSign Test', {
18+
C: 'BR',
19+
OU: ['Organization Unit'],
20+
ST: 'Rio de Janeiro',
21+
O: 'LibreSign',
22+
L: 'Rio de Janeiro',
23+
})
24+
25+
await setAppConfig(
26+
page.request,
27+
'libresign',
28+
'identify_methods',
29+
JSON.stringify([
30+
{ name: 'account', enabled: true, mandatory: true, signatureMethods: { clickToSign: { enabled: true } } },
31+
{ name: 'email', enabled: false, mandatory: false },
32+
]),
33+
)
34+
35+
await page.goto('./apps/libresign')
36+
await page.getByRole('button', { name: 'Upload from URL' }).click();
37+
await page.getByRole('textbox', { name: 'URL of a PDF file' }).click();
38+
await page.getByRole('textbox', { name: 'URL of a PDF file' }).fill('https://raw.githubusercontent.com/LibreSign/libresign/main/tests/php/fixtures/pdfs/small_valid.pdf');
39+
await page.getByRole('button', { name: 'Send' }).click();
40+
await page.getByRole('button', { name: 'Add signer' }).click();
41+
await page.getByPlaceholder('Account').click();
42+
await page.getByPlaceholder('Account').fill('a');
43+
await page.getByRole('option', { name: 'admin@email.tld' }).click();
44+
45+
await page.getByRole('textbox', { name: 'Signer name' }).click();
46+
await page.getByRole('textbox', { name: 'Signer name' }).press('ControlOrMeta+a');
47+
await page.getByRole('textbox', { name: 'Signer name' }).fill('Admin Name');
48+
49+
50+
await page.getByRole('button', { name: 'Save' }).click();
51+
await page.getByRole('button', { name: 'Setup signature positions' }).click();
52+
await expect(page.getByLabel('Page 1 of 1.')).toBeVisible();
53+
await page.getByLabel('Signature positions').getByRole('link', { name: 'Edit signer Admin Name' }).click();
54+
55+
await expect(page.getByText('Click on the place you want to add.')).toBeVisible();
56+
57+
// Placing a signature element on the PDF canvas requires three steps:
58+
// 1. hover() triggers handleMouseMove, which sets previewVisible=true inside a
59+
// requestAnimationFrame callback.
60+
// 2. Waiting for .preview-element confirms the rAF ran. Without this, finishAdding()
61+
// (bound to mouseup on document) returns early because previewVisible is still false.
62+
// 3. click() fires mouseup on the document, which triggers finishAdding() and places
63+
// the element at the current preview position.
64+
const overlay = page.getByLabel('Page 1 of 1. Press Enter or Space to place the signature here.')
65+
await overlay.hover()
66+
await page.getByLabel('Signature positions').locator('.preview-element').first().waitFor({ state: 'visible' })
67+
await overlay.click()
68+
await expect(
69+
page.getByLabel('Signature positions').getByRole('img', { name: 'Signature position for Admin Name' })
70+
).toBeVisible()
71+
72+
await page.getByRole('button', { name: 'Save' }).click();
73+
await page.getByRole('button', { name: 'Request signatures' }).click();
74+
await page.getByRole('button', { name: 'Send' }).click();
75+
await page.getByRole('button', { name: 'Sign document' }).click();
76+
77+
await expect(
78+
page.getByLabel('PDF document to sign').getByRole('img', { name: 'Signature position for Admin Name' })
79+
).toBeVisible()
80+
81+
// If a signature already exists from a previous run, delete it before creating a new one
82+
const deleteSignatureBtn = page.getByRole('button', { name: 'Delete signature' })
83+
await deleteSignatureBtn.waitFor({ state: 'visible', timeout: 3000 }).catch(() => null)
84+
if (await deleteSignatureBtn.isVisible()) {
85+
await deleteSignatureBtn.click()
86+
}
87+
88+
await page.getByRole('button', { name: 'Define your signature.' }).click();
89+
90+
await page.getByRole('dialog', { name: 'Customize your signatures' }).locator('canvas').click({
91+
position: {
92+
x: 156,
93+
y: 132
94+
}
95+
});
96+
await page.getByRole('button', { name: 'Save' }).click();
97+
await expect(page.getByRole('heading', { name: 'Confirm your signature' })).toBeVisible();
98+
await expect(page.getByRole('img', { name: 'Signature preview' })).toBeVisible();
99+
await page.getByLabel('Confirm your signature').getByRole('button', { name: 'Save' }).click();
100+
101+
await page.getByRole('button', { name: 'Sign the document.' }).click();
102+
await page.getByRole('button', { name: 'Sign document' }).click();
103+
await page.waitForURL('**/validation/**')
104+
await expect(page.getByText('This document is valid')).toBeVisible()
105+
await page.getByRole('button', { name: 'Expand details' }).click()
106+
await page.getByRole('button', { name: 'Expand validation status', exact: true }).click()
107+
await expect(page.getByRole('link', { name: 'Document integrity verified' })).toBeVisible()
108+
await page.getByRole('button', { name: 'Expand document certification', exact: true }).click()
109+
await expect(page.getByRole('link', { name: 'Document has not been modified after signing' })).toBeVisible()
110+
});

src/components/Draw/Editor.vue

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
:aria-label="t('libresign', 'Choose color')" />
1919
</NcColorPicker>
2020
</div>
21+
<!-- TRANSLATORS Accessible label for the button that clears the current drawing from the canvas. Does not delete any saved file. -->
2122
<NcButton :aria-label="t('libresign', 'Delete')"
2223
@click="clear">
2324
<template #icon>
@@ -26,8 +27,14 @@
2627
</NcButton>
2728
</div>
2829
<div ref="canvasWrapper" class="canvas-wrapper">
30+
<p class="sr-only">
31+
<!-- TRANSLATORS Screen-reader-only instruction for the signature drawing canvas. "Text" and "Upload" must match the translated labels of the other two tabs in this dialog. -->
32+
{{ t('libresign', 'Drawing area. Use a mouse or touch screen to draw your signature. If you cannot draw, use the Text or Upload tabs instead.') }}
33+
</p>
2934
<canvas ref="canvas"
3035
class="canvas"
36+
:aria-label="t('libresign', 'Draw your signature here')"
37+
role="img"
3138
_width="10px"
3239
_height="10px" />
3340
</div>
@@ -253,4 +260,15 @@ img{
253260
width: 100%;
254261
}
255262
}
263+
.sr-only {
264+
position: absolute;
265+
width: 1px;
266+
height: 1px;
267+
padding: 0;
268+
margin: -1px;
269+
overflow: hidden;
270+
clip: rect(0, 0, 0, 0);
271+
white-space: nowrap;
272+
border: 0;
273+
}
256274
</style>

src/components/PdfEditor/PdfEditor.vue

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
:init-files="files"
99
:init-file-names="fileNames"
1010
:page-count-format="t('libresign', '{currentPage} of {totalPages}')"
11+
:page-aria-label="getPageAriaLabel"
1112
:auto-fit-zoom="true"
1213
:read-only="readOnly"
1314
:emit-object-click="true"
@@ -130,6 +131,23 @@ export default {
130131
},
131132
methods: {
132133
t,
134+
getPageAriaLabel({ docIndex, docName, totalDocs, pageNumber, totalPages, isAddingMode }) {
135+
const docNumber = docIndex + 1
136+
if (totalDocs > 1 && isAddingMode) {
137+
// TRANSLATORS Accessible label for a PDF page overlay when placing a signature in a multi-document envelope. {docNumber} is the current document number, {totalDocs} is the total number of documents, {docName} is the document file name, {pageNumber} is the current page, {totalPages} is the total pages.
138+
return t('libresign', 'Document {docNumber} of {totalDocs} ({docName}), page {pageNumber} of {totalPages}. Press Enter or Space to place the signature here.', { docNumber, totalDocs, docName, pageNumber, totalPages })
139+
}
140+
if (totalDocs > 1) {
141+
// TRANSLATORS Accessible label for a PDF page in a multi-document envelope (read-only mode). {docNumber} is the current document number, {totalDocs} is the total number of documents, {docName} is the document file name, {pageNumber} is the current page, {totalPages} is the total pages.
142+
return t('libresign', 'Document {docNumber} of {totalDocs} ({docName}), page {pageNumber} of {totalPages}.', { docNumber, totalDocs, docName, pageNumber, totalPages })
143+
}
144+
if (isAddingMode) {
145+
// TRANSLATORS Accessible label for a PDF page overlay when placing a signature in a single document. {pageNumber} is the current page, {totalPages} is the total number of pages.
146+
return t('libresign', 'Page {pageNumber} of {totalPages}. Press Enter or Space to place the signature here.', { pageNumber, totalPages })
147+
}
148+
// TRANSLATORS Accessible label for a PDF page in a single document (read-only mode). {pageNumber} is the current page, {totalPages} is the total number of pages.
149+
return t('libresign', 'Page {pageNumber} of {totalPages}.', { pageNumber, totalPages })
150+
},
133151
endInit(event) {
134152
this.$nextTick(async () => {
135153
const shouldAutoFit = this.$refs.pdfElements?.autoFitZoom

src/components/PdfEditor/SignatureBox.vue

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,16 @@
33
- SPDX-License-Identifier: AGPL-3.0-or-later
44
-->
55
<template>
6-
<div class="signature-box" :style="boxStyle">
7-
<span class="label">{{ label }}</span>
6+
<div class="signature-box"
7+
:style="boxStyle"
8+
role="img"
9+
:aria-label="signatureBoxAriaLabel">
10+
<span class="label" aria-hidden="true">{{ label }}</span>
811
</div>
912
</template>
1013

1114
<script>
15+
import { t } from '@nextcloud/l10n'
1216
import { usernameToColor } from '@nextcloud/vue/functions/usernameToColor'
1317
1418
export default {
@@ -24,6 +28,10 @@ export default {
2428
},
2529
},
2630
computed: {
31+
signatureBoxAriaLabel() {
32+
// TRANSLATORS Accessible label for a placed signature box on the PDF. {name} is the signer's display name.
33+
return t('libresign', 'Signature position for {name}', { name: this.label })
34+
},
2735
boxStyle() {
2836
const signer = this.signer || {}
2937
const seed = signer.displayName || signer.name || signer.email || signer.id || this.label

src/components/PreviewSignature/PreviewSignature.vue

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
<div v-show="isLoaded" class="wrapper">
99
<img v-show="isLoaded"
1010
:src="imageData"
11+
:alt="alt"
1112
:style="{
1213
width,
1314
height,
@@ -41,6 +42,12 @@ export default {
4142
required: false,
4243
default: '',
4344
},
45+
alt: {
46+
type: String,
47+
required: false,
48+
// TRANSLATORS Alt text for an image showing the user's handwritten, typed, or uploaded signature. Used as fallback when the parent component does not pass a more specific description, for example "Current signature" or "Confirm your initials".
49+
default: () => t('libresign', 'Signature preview'),
50+
},
4451
},
4552
data() {
4653
return {

src/components/Request/VisibleElements.vue

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,26 @@
1515
<div v-else class="visible-elements-container">
1616
<div class="sign-details">
1717
<div class="modal_name">
18-
<NcChip :text="statusLabel" :variant="isDraft ? 'warning' : 'primary'" no-close />
18+
<NcChip :text="statusLabel"
19+
:variant="isDraft ? 'warning' : 'primary'"
20+
:aria-label="t('libresign', 'Document status: {status}', { status: statusLabel })"
21+
no-close />
1922
<h2 class="name">{{ document.name }}</h2>
2023
</div>
24+
<span role="status"
25+
aria-live="polite"
26+
aria-atomic="true"
27+
class="sr-only">
28+
<template v-if="!signerSelected">{{ t('libresign', 'Select a signer to set their signature position') }}</template>
29+
</span>
2130
<p v-if="!signerSelected">
2231
<NcNoteCard type="info"
2332
:text="t('libresign', 'Select a signer to set their signature position')" />
2433
</p>
2534
<ul class="view-sign-detail__sidebar">
2635
<li v-if="signerSelected"
2736
:class="{ tip: signerSelected }">
28-
{{ t('libresign', 'Click on the place you want to add.') }}
37+
<span>{{ t('libresign', 'Click on the place you want to add.') }}</span>
2938
<NcButton variant="primary"
3039
@click="stopAddSigner">
3140
{{ t('libresign', 'Cancel') }}
@@ -533,6 +542,17 @@ export default {
533542
border-radius: 4px;
534543
text-align: center;
535544
}
545+
.sr-only {
546+
position: absolute;
547+
width: 1px;
548+
height: 1px;
549+
padding: 0;
550+
margin: -1px;
551+
overflow: hidden;
552+
clip: rect(0, 0, 0, 0);
553+
white-space: nowrap;
554+
border: 0;
555+
}
536556
}
537557
}
538558
</style>

src/components/Signers/Signer.vue

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,26 +5,31 @@
55
<template>
66
<NcListItem ref="listItem"
77
:name="signerName"
8+
:link-aria-label="signerLinkAriaLabel"
89
:counter-number="counterNumber"
910
:counter-type="counterType"
1011
:force-display-actions="true"
1112
:class="signerClass"
1213
:title="disabledTooltip"
14+
:aria-disabled="isMethodDisabled || signer.signed ? true : undefined"
1315
@click="signerClickAction">
1416
<template #icon>
15-
<NcAvatar :size="44" :display-name="signer.displayName" />
17+
<NcAvatar :size="44" :display-name="signer.displayName" aria-hidden="true" />
1618
</template>
1719
<template #subname>
1820
<div class="signer-subname">
1921
<NcChip v-for="method in identifyMethodsNames"
2022
:key="method"
2123
:text="method"
24+
:aria-label="t('libresign', 'Identification method: {method}', { method })"
2225
:no-close="true" />
2326
<NcChip :text="signer.statusText"
2427
:variant="chipType"
2528
:icon-path="statusIconPath"
29+
:aria-label="t('libresign', 'Signer status: {status}', { status: signer.statusText })"
2630
:no-close="true"
2731
class="signer-status-chip" />
32+
<span v-if="disabledTooltip" class="sr-only">{{ disabledTooltip }}</span>
2833
</div>
2934
</template>
3035
<template #extra>
@@ -187,6 +192,14 @@ export default {
187192
return 'secondary'
188193
}
189194
},
195+
signerLinkAriaLabel() {
196+
if (this.signer.signed) {
197+
// TRANSLATORS Accessible label for a signed signer list item. {name} is the signer's display name.
198+
return t('libresign', 'Signer {name} (already signed)', { name: this.signerName })
199+
}
200+
// TRANSLATORS Accessible label for a signer list item. {name} is the signer's display name.
201+
return t('libresign', 'Edit signer {name}', { name: this.signerName })
202+
},
190203
statusIconPath() {
191204
switch (this.signer.status) {
192205
case SIGN_REQUEST_STATUS.SIGNED:
@@ -263,6 +276,17 @@ export default {
263276
opacity: 1;
264277
}
265278
}
279+
.sr-only {
280+
position: absolute;
281+
width: 1px;
282+
height: 1px;
283+
padding: 0;
284+
margin: -1px;
285+
overflow: hidden;
286+
clip: rect(0, 0, 0, 0);
287+
white-space: nowrap;
288+
border: 0;
289+
}
266290
.signer-signed .drag-handle {
267291
cursor: not-allowed;
268292
opacity: 0.3;

0 commit comments

Comments
 (0)