Skip to content

Commit 10b5eb3

Browse files
committed
♿️(frontend) make html export accessible to screen reader users
adjusted structure and semantics to ensure proper sr interpretation Signed-off-by: Cyril <c.gromoff@gmail.com>
1 parent 344e9a8 commit 10b5eb3

File tree

3 files changed

+169
-2
lines changed

3 files changed

+169
-2
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,13 @@ and this project adheres to
2121
- 🌐(backend) internationalize demo #1644
2222
- ♿(frontend) improve accessibility:
2323
- ♿️Improve keyboard accessibility for the document tree #1681
24+
- ♿(frontend) make html export accessible to screen reader users #1743
2425

2526
### Fixed
2627

2728
- 🐛(frontend) paste content with comments from another document #1732
2829
- 🐛(frontend) Select text + Go back one page crash the app #1733
2930

30-
3131
## [4.1.0] - 2025-12-09
3232

3333
### Added

src/frontend/apps/impress/src/features/docs/doc-export/components/ModalExport.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
addMediaFilesToZip,
3434
downloadFile,
3535
generateHtmlDocument,
36+
improveHtmlAccessibility,
3637
} from '../utils';
3738

3839
enum DocDownloadFormat {
@@ -161,10 +162,12 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
161162

162163
const zip = new JSZip();
163164

165+
improveHtmlAccessibility(parsedDocument, documentTitle);
164166
await addMediaFilesToZip(parsedDocument, zip, mediaUrl);
165167

166168
const lang = i18next.language || fallbackLng;
167-
const editorHtmlWithLocalMedia = parsedDocument.body.innerHTML;
169+
const body = parsedDocument.body;
170+
const editorHtmlWithLocalMedia = body ? body.innerHTML : '';
168171

169172
const htmlContent = generateHtmlDocument(
170173
documentTitle,

src/frontend/apps/impress/src/features/docs/doc-export/utils.ts

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,170 @@ ${editorHtmlWithLocalMedia}
293293
</html>`;
294294
};
295295

296+
/**
297+
* Enrich the HTML produced by the editor with semantic tags and basic a11y defaults.
298+
*
299+
* Notes:
300+
* - We work directly on the parsed Document so modifications are reflected before we zip files.
301+
* - We keep the editor inner structure but upgrade the key block types to native elements.
302+
*/
303+
export const improveHtmlAccessibility = (
304+
parsedDocument: Document,
305+
documentTitle: string,
306+
) => {
307+
const body = parsedDocument.body;
308+
if (!body) {
309+
return;
310+
}
311+
312+
// 1) Headings: convert heading blocks to h1-h6 based on data-level
313+
const headingBlocks = Array.from(
314+
body.querySelectorAll<HTMLElement>("[data-content-type='heading']"),
315+
);
316+
317+
headingBlocks.forEach((block) => {
318+
const rawLevel = Number(block.getAttribute('data-level')) || 1;
319+
const level = Math.min(Math.max(rawLevel, 1), 6);
320+
const heading = parsedDocument.createElement(`h${level}`);
321+
heading.innerHTML = block.innerHTML;
322+
block.replaceWith(heading);
323+
});
324+
325+
// 2) Lists: group consecutive list items into UL/OL with LI children
326+
const listItemSelector =
327+
"[data-content-type='bulletListItem'], [data-content-type='numberedListItem']";
328+
const listItems = Array.from(
329+
body.querySelectorAll<HTMLElement>(listItemSelector),
330+
);
331+
332+
listItems.forEach((item) => {
333+
const parent = item.parentElement;
334+
if (!parent) {
335+
return;
336+
}
337+
338+
const isBullet =
339+
item.getAttribute('data-content-type') === 'bulletListItem';
340+
const listTag = isBullet ? 'ul' : 'ol';
341+
342+
// If the previous sibling is already the right list, reuse it; otherwise create a new one.
343+
let previousSibling = item.previousElementSibling;
344+
let listContainer: HTMLElement | null = null;
345+
346+
if (previousSibling?.tagName.toLowerCase() === listTag) {
347+
listContainer = previousSibling as HTMLElement;
348+
} else {
349+
listContainer = parsedDocument.createElement(listTag);
350+
parent.insertBefore(listContainer, item);
351+
}
352+
353+
const li = parsedDocument.createElement('li');
354+
li.innerHTML = item.innerHTML;
355+
listContainer.appendChild(li);
356+
parent.removeChild(item);
357+
});
358+
359+
// 3) Quotes -> <blockquote>
360+
const quoteBlocks = Array.from(
361+
body.querySelectorAll<HTMLElement>("[data-content-type='quote']"),
362+
);
363+
quoteBlocks.forEach((block) => {
364+
const quote = parsedDocument.createElement('blockquote');
365+
quote.innerHTML = block.innerHTML;
366+
block.replaceWith(quote);
367+
});
368+
369+
// 4) Callouts -> <aside role="note">
370+
const calloutBlocks = Array.from(
371+
body.querySelectorAll<HTMLElement>("[data-content-type='callout']"),
372+
);
373+
calloutBlocks.forEach((block) => {
374+
const aside = parsedDocument.createElement('aside');
375+
aside.setAttribute('role', 'note');
376+
aside.innerHTML = block.innerHTML;
377+
block.replaceWith(aside);
378+
});
379+
380+
// 5) Checklists -> list + checkbox semantics
381+
const checkListItems = Array.from(
382+
body.querySelectorAll<HTMLElement>("[data-content-type='checkListItem']"),
383+
);
384+
checkListItems.forEach((item) => {
385+
const parent = item.parentElement;
386+
if (!parent) {
387+
return;
388+
}
389+
390+
let previousSibling = item.previousElementSibling;
391+
let listContainer: HTMLElement | null = null;
392+
393+
if (previousSibling?.tagName.toLowerCase() === 'ul') {
394+
listContainer = previousSibling as HTMLElement;
395+
} else {
396+
listContainer = parsedDocument.createElement('ul');
397+
listContainer.setAttribute('role', 'list');
398+
parent.insertBefore(listContainer, item);
399+
}
400+
401+
const li = parsedDocument.createElement('li');
402+
li.innerHTML = item.innerHTML;
403+
404+
// Ensure checkbox has an accessible state; fall back to aria-checked if missing.
405+
const checkbox = li.querySelector<HTMLInputElement>(
406+
"input[type='checkbox']",
407+
);
408+
if (checkbox && !checkbox.hasAttribute('aria-checked')) {
409+
checkbox.setAttribute(
410+
'aria-checked',
411+
checkbox.checked ? 'true' : 'false',
412+
);
413+
}
414+
415+
listContainer.appendChild(li);
416+
parent.removeChild(item);
417+
});
418+
419+
// 6) Code blocks -> <pre><code>
420+
const codeBlocks = Array.from(
421+
body.querySelectorAll<HTMLElement>("[data-content-type='codeBlock']"),
422+
);
423+
codeBlocks.forEach((block) => {
424+
const pre = parsedDocument.createElement('pre');
425+
const code = parsedDocument.createElement('code');
426+
code.innerHTML = block.innerHTML;
427+
pre.appendChild(code);
428+
block.replaceWith(pre);
429+
});
430+
431+
// 7) Ensure images have alt text (empty when not provided)
432+
body.querySelectorAll<HTMLImageElement>('img').forEach((img) => {
433+
if (!img.hasAttribute('alt')) {
434+
img.setAttribute('alt', '');
435+
}
436+
});
437+
438+
// 8) Wrap content in an article with a title landmark if none exists
439+
const existingH1 = body.querySelector('h1');
440+
if (!existingH1) {
441+
const titleHeading = parsedDocument.createElement('h1');
442+
titleHeading.id = 'doc-title';
443+
titleHeading.textContent = documentTitle;
444+
body.insertBefore(titleHeading, body.firstChild);
445+
}
446+
447+
// If there is no article, group the body content inside one for better semantics.
448+
const hasArticle = body.querySelector('article');
449+
if (!hasArticle) {
450+
const article = parsedDocument.createElement('article');
451+
article.setAttribute('role', 'document');
452+
article.setAttribute('aria-labelledby', 'doc-title');
453+
while (body.firstChild) {
454+
article.appendChild(body.firstChild);
455+
}
456+
body.appendChild(article);
457+
}
458+
};
459+
296460
export const addMediaFilesToZip = async (
297461
parsedDocument: Document,
298462
zip: JSZip,

0 commit comments

Comments
 (0)