Skip to content

Commit 6719152

Browse files
authored
Merge pull request #19653 from ckeditor/ck/15979
Add `aria-label` based on `figcaption` to table editing.
2 parents 8612228 + d559dd9 commit 6719152

3 files changed

Lines changed: 267 additions & 12 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
type: Fix
3+
scope:
4+
- ckeditor5-table
5+
closes:
6+
- https://github.com/ckeditor/ckeditor5/issues/15979
7+
---
8+
9+
Improved accessibility by ensuring that table captions are properly reflected in the `aria-labelledby` attribute of the figure element. This change enhances screen reader support and overall user experience for individuals relying on assistive technologies.

packages/ckeditor5-table/src/tablecaption/tablecaptionediting.ts

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,18 @@
88
*/
99

1010
import { Plugin } from 'ckeditor5/src/core.js';
11-
import { ModelElement, enableViewPlaceholder } from 'ckeditor5/src/engine.js';
11+
import {
12+
type DowncastInsertEvent,
13+
type ViewElement,
14+
ModelElement,
15+
enableViewPlaceholder
16+
} from 'ckeditor5/src/engine.js';
17+
import { uid } from 'ckeditor5/src/utils.js';
1218
import { toWidgetEditable } from 'ckeditor5/src/widget.js';
1319

1420
import { injectTableCaptionPostFixer } from '../converters/table-caption-post-fixer.js';
1521
import { ToggleTableCaptionCommand } from './toggletablecaptioncommand.js';
16-
import { isTable, matchTableCaptionViewElement } from './utils.js';
22+
import { getCaptionFromTableModelElement, isTable, matchTableCaptionViewElement } from './utils.js';
1723
import type { TableEditing } from '../tableediting.js';
1824

1925
/**
@@ -28,6 +34,11 @@ export class TableCaptionEditing extends Plugin {
2834
*/
2935
private _savedCaptionsMap = new WeakMap<ModelElement, unknown>();
3036

37+
/**
38+
* A map that keeps generated ids for table captions to reuse them if the same caption is rendered again.
39+
*/
40+
private _captionIdsMapping = new WeakMap<ModelElement, string>();
41+
3142
/**
3243
* @inheritDoc
3344
*/
@@ -116,6 +127,55 @@ export class TableCaptionEditing extends Plugin {
116127
}
117128
} );
118129

130+
editor.conversion.for( 'editingDowncast' ).add( dispatcher => {
131+
dispatcher.on<DowncastInsertEvent<ModelElement>>( 'insert:table', ( evt, data, { writer, mapper } ) => {
132+
const modelTable = data.item;
133+
const viewFigure = mapper.toViewElement( modelTable );
134+
135+
if ( !viewFigure ) {
136+
return;
137+
}
138+
139+
const viewTable = Array
140+
.from( viewFigure.getChildren() )
141+
.find( child => child.is( 'element', 'table' ) ) as ViewElement | undefined;
142+
143+
if ( !viewTable ) {
144+
return;
145+
}
146+
147+
const modelCaption = getCaptionFromTableModelElement( modelTable );
148+
149+
// Remove `aria-labelledby` from the table if there is no caption.
150+
if ( !modelCaption ) {
151+
writer.removeAttribute( 'aria-labelledby', viewTable );
152+
153+
return;
154+
}
155+
156+
const viewCaption = mapper.toViewElement( modelCaption );
157+
158+
if ( !viewCaption ) {
159+
return;
160+
}
161+
162+
// Try reusing the same id for the caption if it was already created for the given model caption.
163+
// If it was not created before, generate a new one and save it in the mapping to reuse it in the future if needed.
164+
let captionId: string;
165+
166+
if ( viewCaption.hasAttribute( 'id' ) ) {
167+
captionId = viewCaption.getAttribute( 'id' )!;
168+
} else {
169+
captionId = this._captionIdsMapping.get( modelCaption ) ?? `ck-editor__caption_${ uid() }`;
170+
}
171+
172+
this._captionIdsMapping.set( modelCaption, captionId );
173+
174+
writer.setAttribute( 'id', captionId, viewCaption );
175+
writer.setAttribute( 'aria-labelledby', captionId, viewTable );
176+
}, { priority: 'low' } );
177+
} );
178+
119179
injectTableCaptionPostFixer( editor.model );
120180
}
121181

packages/ckeditor5-table/tests/tablecaption/tablecaptionediting.js

Lines changed: 196 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Plugin } from '@ckeditor/ckeditor5-core';
1010

1111
import { TableCaptionEditing } from '../../src/tablecaption/tablecaptionediting.js';
1212
import { TableEditing } from '../../src/tableediting.js';
13+
import { priorities } from '@ckeditor/ckeditor5-utils';
1314

1415
describe( 'TableCaptionEditing', () => {
1516
let editor, model, view;
@@ -302,10 +303,13 @@ describe( 'TableCaptionEditing', () => {
302303
'<table><tableRow><tableCell><paragraph>xyz</paragraph></tableCell></tableRow><caption>Foo caption</caption></table>'
303304
);
304305

305-
expect( _getViewData( view, { withoutSelection: true } ) ).to.equal(
306-
'<figure class="ck-widget ck-widget_with-selection-handle table" contenteditable="false">' +
306+
expect( maskUIDs( _getViewData( view, { withoutSelection: true } ) ) ).to.equal(
307+
'<figure ' +
308+
'class="ck-widget ck-widget_with-selection-handle table" ' +
309+
'contenteditable="false"' +
310+
'>' +
307311
'<div class="ck ck-widget__selection-handle"></div>' +
308-
'<table>' +
312+
'<table aria-labelledby="masked-uid-1">' +
309313
'<tbody>' +
310314
'<tr>' +
311315
'<td class="ck-editor__editable ck-editor__nested-editable" contenteditable="true"' +
@@ -315,13 +319,170 @@ describe( 'TableCaptionEditing', () => {
315319
'</tr>' +
316320
'</tbody>' +
317321
'</table>' +
318-
'<figcaption class="ck-editor__editable ck-editor__nested-editable" ' +
319-
'contenteditable="true" data-placeholder="Enter table caption" role="textbox" tabindex="-1">' +
322+
'<figcaption ' +
323+
'class="ck-editor__editable ck-editor__nested-editable" ' +
324+
'contenteditable="true" ' +
325+
'data-placeholder="Enter table caption" ' +
326+
'id="masked-uid-1" ' +
327+
'role="textbox" ' +
328+
'tabindex="-1"' +
329+
'>' +
320330
'Foo caption' +
321331
'</figcaption>' +
322332
'</figure>'
323333
);
324334
} );
335+
336+
it( 'should set id on caption and aria-labelledby on table', () => {
337+
_setModelData( model,
338+
'<table><tableRow><tableCell><paragraph>xyz</paragraph></tableCell></tableRow><caption>Foo caption</caption></table>'
339+
);
340+
341+
const viewFigure = view.document.getRoot().getChild( 0 );
342+
const viewTable = viewFigure.getChild( 1 );
343+
const viewCaption = viewFigure.getChild( 2 );
344+
345+
expect( viewCaption.hasAttribute( 'id' ) ).to.be.true;
346+
expect( viewTable.hasAttribute( 'aria-labelledby' ) ).to.be.true;
347+
expect( viewTable.getAttribute( 'aria-labelledby' ) ).to.equal( viewCaption.getAttribute( 'id' ) );
348+
} );
349+
350+
it( 'should not crash when view does not contain a <table> element', () => {
351+
editor.conversion.for( 'editingDowncast' ).add( dispatcher => {
352+
dispatcher.on( 'insert:table', ( evt, data, { writer, mapper } ) => {
353+
const viewFigure = mapper.toViewElement( data.item );
354+
355+
if ( !viewFigure ) {
356+
return;
357+
}
358+
359+
const viewTable = Array
360+
.from( viewFigure.getChildren() )
361+
.find( child => child.is( 'element', 'table' ) );
362+
363+
if ( viewTable ) {
364+
writer.remove( viewTable );
365+
}
366+
}, { priority: priorities.low + 1 } );
367+
} );
368+
369+
_setModelData( model,
370+
'<table><tableRow><tableCell><paragraph>xyz</paragraph></tableCell></tableRow><caption>Foo caption</caption></table>'
371+
);
372+
373+
const viewFigure = view.document.getRoot().getChild( 0 );
374+
const viewCaption = Array
375+
.from( viewFigure.getChildren() )
376+
.find( child => child.is( 'element', 'figcaption' ) );
377+
378+
expect( viewCaption ).to.not.be.undefined;
379+
expect( viewCaption.hasAttribute( 'id' ) ).to.be.false;
380+
} );
381+
382+
it( 'should not set aria-labelledby on table when there is no caption', () => {
383+
_setModelData( model,
384+
'<table><tableRow><tableCell><paragraph>xyz</paragraph></tableCell></tableRow></table>'
385+
);
386+
387+
const viewFigure = view.document.getRoot().getChild( 0 );
388+
const viewTable = viewFigure.getChild( 1 );
389+
390+
expect( viewTable.hasAttribute( 'aria-labelledby' ) ).to.be.false;
391+
} );
392+
393+
it( 'should remove aria-labelledby when caption is removed', () => {
394+
_setModelData( model,
395+
'<table><tableRow><tableCell><paragraph>xyz</paragraph></tableCell></tableRow><caption>Foo caption</caption></table>'
396+
);
397+
398+
const viewFigure = view.document.getRoot().getChild( 0 );
399+
const viewTable = viewFigure.getChild( 1 );
400+
401+
expect( viewTable.hasAttribute( 'aria-labelledby' ) ).to.be.true;
402+
403+
model.change( writer => {
404+
const table = model.document.getRoot().getChild( 0 );
405+
const caption = table.getChild( 1 );
406+
407+
writer.remove( caption );
408+
} );
409+
410+
const viewFigureAfter = view.document.getRoot().getChild( 0 );
411+
const viewTableAfter = viewFigureAfter.getChild( 1 );
412+
413+
expect( viewTableAfter.hasAttribute( 'aria-labelledby' ) ).to.be.false;
414+
} );
415+
416+
it( 'should reuse the same id for caption when table is re-rendered', () => {
417+
_setModelData( model,
418+
'<table><tableRow><tableCell><paragraph>xyz</paragraph></tableCell></tableRow><caption>Foo caption</caption></table>'
419+
);
420+
421+
const viewFigure = view.document.getRoot().getChild( 0 );
422+
const viewCaption = viewFigure.getChild( 2 );
423+
const firstCaptionId = viewCaption.getAttribute( 'id' );
424+
425+
model.change( writer => {
426+
const table = model.document.getRoot().getChild( 0 );
427+
428+
writer.remove( table );
429+
writer.insert( table, model.document.getRoot(), 0 );
430+
} );
431+
432+
const newViewFigure = view.document.getRoot().getChild( 0 );
433+
const newViewCaption = newViewFigure.getChild( 2 );
434+
const secondCaptionId = newViewCaption.getAttribute( 'id' );
435+
436+
expect( firstCaptionId ).to.equal( secondCaptionId );
437+
} );
438+
439+
it( 'should not add aria-labelledby when caption is not bound to view', async () => {
440+
editor.conversion.for( 'editingDowncast' ).add( dispatcher => {
441+
dispatcher.on( 'insert:table', ( evt, data, { mapper } ) => {
442+
const modelCaption = Array
443+
.from( data.item.getChildren() )
444+
.find( child => child.is( 'element', 'caption' ) );
445+
446+
const viewCaption = mapper.toViewElement( modelCaption );
447+
448+
mapper.unbindViewElement( viewCaption );
449+
} );
450+
} );
451+
452+
_setModelData( editor.model,
453+
'<table><tableRow><tableCell><paragraph>xyz</paragraph></tableCell></tableRow><caption>Foo caption</caption></table>'
454+
);
455+
456+
const viewFigure = editor.editing.view.document.getRoot().getChild( 0 );
457+
const viewTable = viewFigure.getChild( 1 );
458+
459+
expect( viewTable.hasAttribute( 'aria-labelledby' ) ).to.be.false;
460+
} );
461+
462+
it( 'should reuse id when caption already has id attribute in view', async () => {
463+
editor.conversion.for( 'editingDowncast' ).add( dispatcher => {
464+
dispatcher.on( 'insert:table', ( evt, data, { writer, mapper } ) => {
465+
const modelCaption = Array
466+
.from( data.item.getChildren() )
467+
.find( child => child.is( 'element', 'caption' ) );
468+
469+
const viewCaption = mapper.toViewElement( modelCaption );
470+
471+
writer.setAttribute( 'id', 'custom-id-123', viewCaption );
472+
} );
473+
} );
474+
475+
_setModelData( editor.model,
476+
'<table><tableRow><tableCell><paragraph>xyz</paragraph></tableCell></tableRow><caption>Foo caption</caption></table>'
477+
);
478+
479+
const viewFigure = editor.editing.view.document.getRoot().getChild( 0 );
480+
const viewTable = viewFigure.getChild( 1 );
481+
const viewCaption = viewFigure.getChild( 2 );
482+
483+
expect( viewCaption.getAttribute( 'id' ) ).to.equal( 'custom-id-123' );
484+
expect( viewTable.getAttribute( 'aria-labelledby' ) ).to.equal( 'custom-id-123' );
485+
} );
325486
} );
326487
} );
327488
} );
@@ -387,10 +548,13 @@ describe( 'TableCaptionEditing - useCaptionElement = true', () => {
387548
'<table><tableRow><tableCell><paragraph>xyz</paragraph></tableCell></tableRow><caption>Foo caption</caption></table>'
388549
);
389550

390-
expect( _getViewData( view, { withoutSelection: true } ) ).to.equal(
391-
'<figure class="ck-widget ck-widget_with-selection-handle table" contenteditable="false">' +
551+
expect( maskUIDs( _getViewData( view, { withoutSelection: true } ) ) ).to.equal(
552+
'<figure ' +
553+
'class="ck-widget ck-widget_with-selection-handle table" ' +
554+
'contenteditable="false"' +
555+
'>' +
392556
'<div class="ck ck-widget__selection-handle"></div>' +
393-
'<table>' +
557+
'<table aria-labelledby="masked-uid-1">' +
394558
'<tbody>' +
395559
'<tr>' +
396560
'<td class="ck-editor__editable ck-editor__nested-editable" contenteditable="true"' +
@@ -399,8 +563,14 @@ describe( 'TableCaptionEditing - useCaptionElement = true', () => {
399563
'</td>' +
400564
'</tr>' +
401565
'</tbody>' +
402-
'<caption class="ck-editor__editable ck-editor__nested-editable" ' +
403-
'contenteditable="true" data-placeholder="Enter table caption" role="textbox" tabindex="-1">' +
566+
'<caption ' +
567+
'class="ck-editor__editable ck-editor__nested-editable" ' +
568+
'contenteditable="true" ' +
569+
'data-placeholder="Enter table caption" ' +
570+
'id="masked-uid-1" ' +
571+
'role="textbox" ' +
572+
'tabindex="-1"' +
573+
'>' +
404574
'Foo caption' +
405575
'</caption>' +
406576
'</table>' +
@@ -410,3 +580,19 @@ describe( 'TableCaptionEditing - useCaptionElement = true', () => {
410580
} );
411581
} );
412582
} );
583+
584+
function maskUIDs( str ) {
585+
const uidMap = new Map();
586+
587+
return str.replace( /ck-editor__caption_e[0-9a-f]{32}/g, uid => {
588+
if ( !uidMap.has( uid ) ) {
589+
uidMap.set( uid, maskedUID( uidMap.size + 1 ) );
590+
}
591+
592+
return uidMap.get( uid );
593+
} );
594+
}
595+
596+
function maskedUID( offset = 1 ) {
597+
return `masked-uid-${ offset }`;
598+
}

0 commit comments

Comments
 (0)