Skip to content

Commit abd8e17

Browse files
authored
[TASK] improve visual editor accessibility (#74)
Add labels, keyboard activation, and focus styling to visual editor controls. This makes the editor actions reachable for keyboard and assistive technology users.
1 parent 52a22a7 commit abd8e17

22 files changed

Lines changed: 427 additions & 40 deletions

Classes/Backend/Controller/PageEditController.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,11 @@ public function __invoke(ServerRequestInterface $request): ResponseInterface
188188
$view->assignMultiple([
189189
'pageId' => $this->pageRecord->getUid(),
190190
'iframeSrc' => $iframeUrl,
191+
'iframeTitle' => sprintf(
192+
'%s: %s',
193+
$this->getLanguageService()->sL('LLL:EXT:visual_editor/Resources/Private/Language/locallang_mod.xlf:edit_page'),
194+
(string)$this->pageRecord->get('title'),
195+
),
191196
]);
192197

193198
$this->makeButtons($view, $request);

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ This extension provides visual editing features for content elements in TYPO3 CM
99
- 🧲 Drag-and-drop repositioning of content elements (➕ adding and 🗑️ deleting elements)
1010
- ⚡ Real-time preview of changes without page reloads
1111
- 😊 User-friendly interface for non-technical editors
12+
- ♿ Accessibility-aware editing controls for TYPO3 editors
1213

1314
<https://github.com/user-attachments/assets/a4d2a536-40dd-4df8-a980-0b0362654d24>
1415

@@ -153,6 +154,14 @@ Visual Editor uses your regular frontend CSS for the editing view. CSS that is c
153154

154155
If your project defines custom rich-text styles, add the relevant rules to your frontend CSS so the page output and the editor share the same styling. Projects with custom `lib.parseFunc_RTE` setups may also need matching frontend rules.
155156

157+
## Accessibility
158+
159+
Visual Editor is designed with WCAG 2.2 AA as a goal, but this is not a full compliance claim for every TYPO3 project.
160+
161+
The editor interface includes accessible labels, keyboard-focusable controls, validation announcements, and semantic roles. It has been tested with axe DevTools and NVDA.
162+
163+
Final accessibility depends on the project templates, CSS, semantic HTML, and editor-authored content. Drag-and-drop workflows are pointer-oriented, so projects should verify alternative workflows for their editor needs. Project-level accessibility should be checked in the integrated TYPO3 site.
164+
156165
## License and Authors: License type, contributors, contact information
157166

158167
This extension is licensed under the [GPL-2.0-or-later](https://spdx.org/licenses/GPL-2.0-or-later.html) license.

Resources/Private/Language/de.locallang.xlf

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,24 @@
8484
<trans-unit id="frontend.resetChanges">
8585
<target>Änderungen zurücksetzen</target>
8686
</trans-unit>
87+
<trans-unit id="frontend.editContentElement">
88+
<target>Inhaltselement bearbeiten</target>
89+
</trans-unit>
90+
<trans-unit id="frontend.showContentElement">
91+
<target>Inhaltselement anzeigen</target>
92+
</trans-unit>
93+
<trans-unit id="frontend.deleteContentElement">
94+
<target>Inhaltselement löschen</target>
95+
</trans-unit>
96+
<trans-unit id="frontend.addContentElementButton">
97+
<target>Inhaltselement hinzufügen</target>
98+
</trans-unit>
99+
<trans-unit id="frontend.actionBar">
100+
<target>Aktionsleiste</target>
101+
</trans-unit>
102+
<trans-unit id="frontend.editImageButton">
103+
<target>Bild bearbeiten</target>
104+
</trans-unit>
87105
</body>
88106
</file>
89107
</xliff>

Resources/Private/Language/locallang.xlf

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,24 @@
8484
<trans-unit id="frontend.resetChanges">
8585
<source>reset changes</source>
8686
</trans-unit>
87+
<trans-unit id="frontend.editContentElement">
88+
<source>Edit content element</source>
89+
</trans-unit>
90+
<trans-unit id="frontend.showContentElement">
91+
<source>Show content element</source>
92+
</trans-unit>
93+
<trans-unit id="frontend.deleteContentElement">
94+
<source>Delete content element</source>
95+
</trans-unit>
96+
<trans-unit id="frontend.addContentElementButton">
97+
<source>Add content element</source>
98+
</trans-unit>
99+
<trans-unit id="frontend.actionBar">
100+
<source>Action Bar</source>
101+
</trans-unit>
102+
<trans-unit id="frontend.editImageButton">
103+
<source>Edit Image</source>
104+
</trans-unit>
87105
</body>
88106
</file>
89107
</xliff>

Resources/Private/Templates/PageEdit.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
<f:render partial="DocHeader" arguments="{docHeader: docHeader}" />
1212

13-
<iframe id="visual-editor-iframe" src="{iframeSrc}" style="border: none; height:100%; width: 100%; background: #fff;"></iframe>
13+
<iframe id="visual-editor-iframe" src="{iframeSrc}" title="{iframeTitle}" style="border: none; height:100%; width: 100%; background: #fff;"></iframe>
1414
</div>
1515

1616
</html>

Resources/Public/Css/editable.css

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,8 @@ img[data-veedit] {
8080
outline-offset: -5px;
8181
pointer-events: initial;
8282

83-
&:hover {
83+
&:hover,
84+
&:focus {
8485
/*filter: brightness(0.6);*/
8586
cursor: pointer;
8687
outline-color: #5432fe;
@@ -108,6 +109,7 @@ img[data-veedit] {
108109
background: center / contain no-repeat url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g fill="white"><path d="m9.293 3.293-8 8A.997.997 0 0 0 1 12v3h3c.265 0 .52-.105.707-.293l8-8-3.414-3.414zM8.999 5l.5.5-5 5-.5-.5 5-5zM4 14H3v-1H2v-1l1-1 2 2-1 1zM13.707 5.707l1.354-1.354a.5.5 0 0 0 0-.707L12.354.939a.5.5 0 0 0-.707 0l-1.354 1.354 3.414 3.414z"/></g></svg>');
109110
}
110111

111-
*:has(> img[data-veedit]:hover)::after {
112+
*:has(> img[data-veedit]:hover)::after,
113+
*:has(> img[data-veedit]:focus)::after {
112114
opacity: 1;
113115
}

Resources/Public/JavaScript/Backend/components/ve-auto-save-toggle.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ export class VeAutoSaveToggle extends LitElement {
1818
this.classList.toggle('btn-primary', this.active);
1919
this.classList.toggle('active', this.active);
2020
this.classList.toggle('btn-default', !this.active);
21+
this.setAttribute('role', 'switch');
22+
this.setAttribute('aria-checked', String(this.active));
23+
this.setAttribute('aria-disabled', String(this.hasAttribute('disabled')));
24+
this.tabIndex = this.hasAttribute('disabled') ? -1 : 0;
25+
this.setAttribute('aria-label', this.label);
2126
}
2227

2328
firstUpdated(changedProperties) {
@@ -37,6 +42,7 @@ export class VeAutoSaveToggle extends LitElement {
3742
this.label = this.innerText;
3843
this.disposeUpdateEditorStateListener = null;
3944
this.onClick = this.#onClick.bind(this);
45+
this.onKeydown = this.#onKeydown.bind(this);
4046
}
4147

4248
connectedCallback() {
@@ -47,12 +53,14 @@ export class VeAutoSaveToggle extends LitElement {
4753
}
4854

4955
this.addEventListener('click', this.onClick);
56+
this.addEventListener('keydown', this.onKeydown);
5057
}
5158

5259
disconnectedCallback() {
5360
this.disposeUpdateEditorStateListener?.();
5461
this.disposeUpdateEditorStateListener = null;
5562
this.removeEventListener('click', this.onClick);
63+
this.removeEventListener('keydown', this.onKeydown);
5664

5765
super.disconnectedCallback();
5866
}
@@ -73,6 +81,10 @@ export class VeAutoSaveToggle extends LitElement {
7381
}
7482

7583
#onClick(e) {
84+
if(this.hasAttribute('disabled')){
85+
return;
86+
}
87+
7688
e.preventDefault();
7789
this.active = !this.active;
7890

@@ -82,6 +94,13 @@ export class VeAutoSaveToggle extends LitElement {
8294
sendMessage('doSave');
8395
}
8496
}
97+
#onKeydown(e) {
98+
if (this.hasAttribute('disabled') || (e.key !== 'Enter' && e.key !== ' ')) {
99+
return;
100+
}
101+
e.preventDefault();
102+
this.#onClick(e);
103+
}
85104

86105
static styles = css`
87106
:host {

Resources/Public/JavaScript/Backend/components/ve-backend-save-button.js

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,20 @@ export class VeBackendSaveButton extends LitElement {
2121
this.classList.toggle('btn-default', this.isInteractionDisabled && !this.hasInvalidFields);
2222
this.classList.toggle('btn-warning', !this.isVisuallyDisabled);
2323
this.classList.toggle('btn-danger', this.hasInvalidFields);
24+
this.setAttribute('role', 'button');
25+
this.setAttribute('aria-disabled', String(this.isInteractionDisabled));
26+
this.setAttribute('aria-busy', String(this.saving));
27+
this.setAttribute('tabindex', this.isInteractionDisabled ? '-1' : '0');
28+
this.setAttribute('aria-label', this.getLabel());
2429
}
2530

2631
constructor() {
2732
super();
2833
this.count = 0;
2934
this.invalidCount = 0;
3035
this.saving = false;
31-
this.onClick = this.onClick.bind(this);
36+
this.onClick = this.#onClick.bind(this);
37+
this.onKeydown = this.#onKeydown.bind(this);
3238
this.disposeUpdateEditorStateListener = null;
3339
this.disposeOnSaveListener = null;
3440
this.disposeSaveEndedListener = null;
@@ -51,6 +57,7 @@ export class VeBackendSaveButton extends LitElement {
5157
}
5258

5359
this.addEventListener('click', this.onClick);
60+
this.addEventListener('keydown', this.onKeydown);
5461
}
5562

5663
disconnectedCallback() {
@@ -61,11 +68,12 @@ export class VeBackendSaveButton extends LitElement {
6168
this.disposeSaveEndedListener?.();
6269
this.disposeSaveEndedListener = null;
6370
this.removeEventListener('click', this.onClick);
71+
this.removeEventListener('keydown', this.onKeydown);
6472

6573
super.disconnectedCallback();
6674
}
6775

68-
render() {
76+
getLabel() {
6977
let label = lll('save');
7078
if (this.count > 0) {
7179
label = this.count === 1 ? lll('save.change') : lll('save.changes', this.count);
@@ -76,6 +84,11 @@ export class VeBackendSaveButton extends LitElement {
7684
if (this.saving) {
7785
label = lll('saving');
7886
}
87+
return label;
88+
}
89+
90+
render() {
91+
const label = this.getLabel();
7992
const icon = this.hasInvalidFields ? 'actions-exclamation-circle-alt' : 'actions-save';
8093
return html`
8194
<typo3-backend-icon identifier="${icon}" size="small"></typo3-backend-icon>
@@ -101,14 +114,22 @@ export class VeBackendSaveButton extends LitElement {
101114
}
102115
}
103116

104-
onClick(e) {
117+
#onClick(e) {
105118
e.preventDefault();
106119
if (this.isInteractionDisabled) {
107120
return;
108121
}
109122
sendMessage('doSave');
110123
}
111124

125+
#onKeydown(e) {
126+
if (this.disabled || (e.key !== 'Enter' && e.key !== ' ')) {
127+
return;
128+
}
129+
e.preventDefault();
130+
sendMessage('doSave');
131+
}
132+
112133
get hasChanges() {
113134
return this.count > 0;
114135
}

Resources/Public/JavaScript/Backend/components/ve-show-empty-toggle.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,18 +25,21 @@ export class VeShowEmptyToggle extends LitElement {
2525
sendMessage('showEmpty', this.active);
2626
this.onShowEmptyChange = this.#onShowEmptyChange.bind(this);
2727
this.onClick = this.#onClick.bind(this);
28+
this.onKeydown = this.#onKeydown.bind(this);
2829
}
2930

3031
connectedCallback() {
3132
super.connectedCallback();
3233

3334
showEmptyActive.addEventListener('change', this.onShowEmptyChange);
3435
this.addEventListener('click', this.onClick);
36+
this.addEventListener('keydown', this.onKeydown);
3537
}
3638

3739
disconnectedCallback() {
3840
showEmptyActive.removeEventListener('change', this.onShowEmptyChange);
3941
this.removeEventListener('click', this.onClick);
42+
this.removeEventListener('keydown', this.onKeydown);
4043

4144
super.disconnectedCallback();
4245
}
@@ -45,6 +48,10 @@ export class VeShowEmptyToggle extends LitElement {
4548
this.classList.toggle('btn-primary', this.active);
4649
this.classList.toggle('active', this.active);
4750
this.classList.toggle('btn-default', !this.active);
51+
this.setAttribute('role', 'switch');
52+
this.setAttribute('aria-checked', String(this.active));
53+
this.setAttribute('aria-label', this.label);
54+
this.tabIndex = 0;
4855
}
4956

5057
render() {
@@ -65,6 +72,14 @@ export class VeShowEmptyToggle extends LitElement {
6572
showEmptyActive.set(this.active);
6673
sendMessage('showEmpty', this.active);
6774
}
75+
76+
#onKeydown(e) {
77+
if (e.key !== 'Enter' && e.key !== ' ') {
78+
return;
79+
}
80+
e.preventDefault();
81+
this.#onClick(e);
82+
}
6883
}
6984

7085
customElements.define('ve-show-empty-toggle', VeShowEmptyToggle);

Resources/Public/JavaScript/Backend/components/ve-spotlight-toggle.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,18 +24,21 @@ export class VeSpotlightToggle extends LitElement {
2424
this.active = spotlightActive.get();
2525
this.onSpotlightChange = this.#onSpotlightChange.bind(this);
2626
this.onClick = this.#onClick.bind(this);
27+
this.onKeydown = this.#onKeydown.bind(this);
2728
}
2829

2930
connectedCallback() {
3031
super.connectedCallback();
3132

3233
spotlightActive.addEventListener('change', this.onSpotlightChange);
3334
this.addEventListener('click', this.onClick);
35+
this.addEventListener('keydown', this.onKeydown);
3436
}
3537

3638
disconnectedCallback() {
3739
spotlightActive.removeEventListener('change', this.onSpotlightChange);
3840
this.removeEventListener('click', this.onClick);
41+
this.removeEventListener('keydown', this.onKeydown);
3942

4043
super.disconnectedCallback();
4144
}
@@ -44,6 +47,10 @@ export class VeSpotlightToggle extends LitElement {
4447
this.classList.toggle('btn-primary', this.active);
4548
this.classList.toggle('active', this.active);
4649
this.classList.toggle('btn-default', !this.active);
50+
this.setAttribute('role', 'switch');
51+
this.setAttribute('aria-checked', String(this.active));
52+
this.setAttribute('aria-label', this.label);
53+
this.tabIndex = 0;
4754
}
4855

4956
render() {
@@ -63,6 +70,14 @@ export class VeSpotlightToggle extends LitElement {
6370
this.active = !this.active;
6471
spotlightActive.set(this.active);
6572
}
73+
74+
#onKeydown(e) {
75+
if (e.key !== 'Enter' && e.key !== ' ') {
76+
return;
77+
}
78+
e.preventDefault();
79+
this.#onClick(e);
80+
}
6681
}
6782

6883
customElements.define('ve-spotlight-toggle', VeSpotlightToggle);

0 commit comments

Comments
 (0)