refactor accessibility

- remove @ts-ignore comments
- rename vitest test and describe title
- remove unnecessary types, e.i, `null` and `number`
- clean `addSVGa11yTitleDescription` and `setA11yDiagramInfo` functions
This commit is contained in:
Yokozuna59 2023-06-27 18:07:35 +03:00
parent d9db2edf5a
commit 228bafa909
3 changed files with 94 additions and 112 deletions

View File

@ -1,27 +1,24 @@
import { MockedD3 } from './tests/MockedD3.js'; import { MockedD3 } from './tests/MockedD3.js';
import { setA11yDiagramInfo, addSVGa11yTitleDescription } from './accessibility.js'; import { setA11yDiagramInfo, addSVGa11yTitleDescription } from './accessibility.js';
import { D3Element } from './mermaidAPI.js'; import type { D3Element } from './mermaidAPI.js';
describe('accessibility', () => { describe('accessibility', () => {
const fauxSvgNode = new MockedD3(); const fauxSvgNode: MockedD3 = new MockedD3();
describe('setA11yDiagramInfo', () => { describe('setA11yDiagramInfo', () => {
it('sets the svg element role to "graphics-document document"', () => { it('should set svg element role to "graphics-document document"', () => {
// @ts-ignore Required to easily handle the d3 select types
const svgAttrSpy = vi.spyOn(fauxSvgNode, 'attr').mockReturnValue(fauxSvgNode); const svgAttrSpy = vi.spyOn(fauxSvgNode, 'attr').mockReturnValue(fauxSvgNode);
setA11yDiagramInfo(fauxSvgNode, 'flowchart'); setA11yDiagramInfo(fauxSvgNode, 'flowchart');
expect(svgAttrSpy).toHaveBeenCalledWith('role', 'graphics-document document'); expect(svgAttrSpy).toHaveBeenCalledWith('role', 'graphics-document document');
}); });
it('sets the aria-roledescription to the diagram type', () => { it('should set aria-roledescription to the diagram type', () => {
// @ts-ignore Required to easily handle the d3 select types
const svgAttrSpy = vi.spyOn(fauxSvgNode, 'attr').mockReturnValue(fauxSvgNode); const svgAttrSpy = vi.spyOn(fauxSvgNode, 'attr').mockReturnValue(fauxSvgNode);
setA11yDiagramInfo(fauxSvgNode, 'flowchart'); setA11yDiagramInfo(fauxSvgNode, 'flowchart');
expect(svgAttrSpy).toHaveBeenCalledWith('aria-roledescription', 'flowchart'); expect(svgAttrSpy).toHaveBeenCalledWith('aria-roledescription', 'flowchart');
}); });
it('does not set the aria-roledescription if the diagram type is empty', () => { it('should not set aria-roledescription if the diagram type is empty', () => {
// @ts-ignore Required to easily handle the d3 select types
const svgAttrSpy = vi.spyOn(fauxSvgNode, 'attr').mockReturnValue(fauxSvgNode); const svgAttrSpy = vi.spyOn(fauxSvgNode, 'attr').mockReturnValue(fauxSvgNode);
setA11yDiagramInfo(fauxSvgNode, ''); setA11yDiagramInfo(fauxSvgNode, '');
expect(svgAttrSpy).toHaveBeenCalledTimes(1); expect(svgAttrSpy).toHaveBeenCalledTimes(1);
@ -32,8 +29,8 @@ describe('accessibility', () => {
describe('addSVGa11yTitleDescription', () => { describe('addSVGa11yTitleDescription', () => {
const givenId = 'theBaseId'; const givenId = 'theBaseId';
describe('with the given svg d3 object:', () => { describe('with svg d3 object', () => {
it('does nothing if there is no insert defined', () => { it('should do nothing if there is no insert defined', () => {
const noInsertSvg = { const noInsertSvg = {
attr: vi.fn(), attr: vi.fn(),
}; };
@ -42,26 +39,25 @@ describe('accessibility', () => {
expect(noInsertAttrSpy).not.toHaveBeenCalled(); expect(noInsertAttrSpy).not.toHaveBeenCalled();
}); });
// ---------------- // convenience functions to DRY up the spec
// Convenience functions to DRY up the spec
function expectAriaLabelledByIsTitleId( function expectAriaLabelledByItTitleId(
svgD3Node: D3Element, svgD3Node: D3Element,
title: string | null | undefined, title: string | undefined,
desc: string | null | undefined, desc: string | undefined,
givenId: string givenId: string
) { ): void {
const svgAttrSpy = vi.spyOn(svgD3Node, 'attr').mockReturnValue(svgD3Node); const svgAttrSpy = vi.spyOn(svgD3Node, 'attr').mockReturnValue(svgD3Node);
addSVGa11yTitleDescription(svgD3Node, title, desc, givenId); addSVGa11yTitleDescription(svgD3Node, title, desc, givenId);
expect(svgAttrSpy).toHaveBeenCalledWith('aria-labelledby', `chart-title-${givenId}`); expect(svgAttrSpy).toHaveBeenCalledWith('aria-labelledby', `chart-title-${givenId}`);
} }
function expectAriaDescribedByIsDescId( function expectAriaDescribedByItDescId(
svgD3Node: D3Element, svgD3Node: D3Element,
title: string | null | undefined, title: string | undefined,
desc: string | null | undefined, desc: string | undefined,
givenId: string givenId: string
) { ): void {
const svgAttrSpy = vi.spyOn(svgD3Node, 'attr').mockReturnValue(svgD3Node); const svgAttrSpy = vi.spyOn(svgD3Node, 'attr').mockReturnValue(svgD3Node);
addSVGa11yTitleDescription(svgD3Node, title, desc, givenId); addSVGa11yTitleDescription(svgD3Node, title, desc, givenId);
expect(svgAttrSpy).toHaveBeenCalledWith('aria-describedby', `chart-desc-${givenId}`); expect(svgAttrSpy).toHaveBeenCalledWith('aria-describedby', `chart-desc-${givenId}`);
@ -69,154 +65,148 @@ describe('accessibility', () => {
function a11yTitleTagInserted( function a11yTitleTagInserted(
svgD3Node: D3Element, svgD3Node: D3Element,
title: string | null | undefined, title: string | undefined,
desc: string | null | undefined, desc: string | undefined,
givenId: string, givenId: string,
callNumber: number callNumber: number
) { ): void {
a11yTagInserted(svgD3Node, title, desc, givenId, callNumber, 'title', title); a11yTagInserted(svgD3Node, title, desc, givenId, callNumber, 'title', title);
} }
function a11yDescTagInserted( function a11yDescTagInserted(
svgD3Node: D3Element, svgD3Node: D3Element,
title: string | null | undefined, title: string | undefined,
desc: string | null | undefined, desc: string | undefined,
givenId: string, givenId: string,
callNumber: number callNumber: number
) { ): void {
a11yTagInserted(svgD3Node, title, desc, givenId, callNumber, 'desc', desc); a11yTagInserted(svgD3Node, title, desc, givenId, callNumber, 'desc', desc);
} }
function a11yTagInserted( function a11yTagInserted(
svgD3Node: D3Element, _svgD3Node: D3Element,
title: string | null | undefined, title: string | undefined,
desc: string | null | undefined, desc: string | undefined,
givenId: string, givenId: string,
callNumber: number, callNumber: number,
expectedPrefix: string, expectedPrefix: string,
expectedText: string | null | undefined expectedText: string | undefined
) { ): void {
const fauxInsertedD3 = new MockedD3(); const fauxInsertedD3: MockedD3 = new MockedD3();
const svgInsertSpy = vi.spyOn(fauxSvgNode, 'insert').mockReturnValue(fauxInsertedD3); const svginsertpy = vi.spyOn(fauxSvgNode, 'insert').mockReturnValue(fauxInsertedD3);
// @ts-ignore Required to easily handle the d3 select types
const titleAttrSpy = vi.spyOn(fauxInsertedD3, 'attr').mockReturnValue(fauxInsertedD3); const titleAttrSpy = vi.spyOn(fauxInsertedD3, 'attr').mockReturnValue(fauxInsertedD3);
const titleTextSpy = vi.spyOn(fauxInsertedD3, 'text'); const titleTextSpy = vi.spyOn(fauxInsertedD3, 'text');
addSVGa11yTitleDescription(fauxSvgNode, title, desc, givenId); addSVGa11yTitleDescription(fauxSvgNode, title, desc, givenId);
expect(svgInsertSpy).toHaveBeenCalledWith(expectedPrefix, ':first-child'); expect(svginsertpy).toHaveBeenCalledWith(expectedPrefix, ':first-child');
expect(titleAttrSpy).toHaveBeenCalledWith('id', `chart-${expectedPrefix}-${givenId}`); expect(titleAttrSpy).toHaveBeenCalledWith('id', `chart-${expectedPrefix}-${givenId}`);
expect(titleTextSpy).toHaveBeenNthCalledWith(callNumber, expectedText); expect(titleTextSpy).toHaveBeenNthCalledWith(callNumber, expectedText);
} }
// ----------------
describe('given an a11y title', () => { describe('with a11y title', () => {
const a11yTitle = 'a11y title'; const a11yTitle = 'a11y title';
describe('given an a11y description', () => { describe('with a11y description', () => {
const a11yDesc = 'a11y description'; const a11yDesc = 'a11y description';
it('sets aria-labelledby to the title id inserted as a child', () => { it('shold set aria-labelledby to the title id inserted as a child', () => {
expectAriaLabelledByIsTitleId(fauxSvgNode, a11yTitle, a11yDesc, givenId); expectAriaLabelledByItTitleId(fauxSvgNode, a11yTitle, a11yDesc, givenId);
}); });
it('sets aria-describedby to the description id inserted as a child', () => { it('should set aria-describedby to the description id inserted as a child', () => {
expectAriaDescribedByIsDescId(fauxSvgNode, a11yTitle, a11yDesc, givenId); expectAriaDescribedByItDescId(fauxSvgNode, a11yTitle, a11yDesc, givenId);
}); });
it('inserts a title tag as the first child with the text set to the accTitle given', () => { it('should insert title tag as the first child with the text set to the accTitle given', () => {
a11yTitleTagInserted(fauxSvgNode, a11yTitle, a11yDesc, givenId, 2); a11yTitleTagInserted(fauxSvgNode, a11yTitle, a11yDesc, givenId, 2);
}); });
it('inserts a desc tag as the 2nd child with the text set to accDescription given', () => { it('should insert desc tag as the 2nd child with the text set to accDescription given', () => {
a11yDescTagInserted(fauxSvgNode, a11yTitle, a11yDesc, givenId, 1); a11yDescTagInserted(fauxSvgNode, a11yTitle, a11yDesc, givenId, 1);
}); });
}); });
describe(`no a11y description`, () => { describe(`without a11y description`, () => {
const a11yDesc = undefined; const a11yDesc = undefined;
it('sets aria-labelledby to the title id inserted as a child', () => { it('should set aria-labelledby to the title id inserted as a child', () => {
expectAriaLabelledByIsTitleId(fauxSvgNode, a11yTitle, a11yDesc, givenId); expectAriaLabelledByItTitleId(fauxSvgNode, a11yTitle, a11yDesc, givenId);
}); });
it('no aria-describedby is set', () => { it('should not set aria-describedby', () => {
// @ts-ignore Required to easily handle the d3 select types
const svgAttrSpy = vi.spyOn(fauxSvgNode, 'attr').mockReturnValue(fauxSvgNode); const svgAttrSpy = vi.spyOn(fauxSvgNode, 'attr').mockReturnValue(fauxSvgNode);
addSVGa11yTitleDescription(fauxSvgNode, a11yTitle, a11yDesc, givenId); addSVGa11yTitleDescription(fauxSvgNode, a11yTitle, a11yDesc, givenId);
expect(svgAttrSpy).not.toHaveBeenCalledWith('aria-describedby', expect.anything()); expect(svgAttrSpy).not.toHaveBeenCalledWith('aria-describedby', expect.anything());
}); });
it('inserts a title tag as the first child with the text set to the accTitle given', () => { it('should insert title tag as the first child with the text set to the accTitle given', () => {
a11yTitleTagInserted(fauxSvgNode, a11yTitle, a11yDesc, givenId, 1); a11yTitleTagInserted(fauxSvgNode, a11yTitle, a11yDesc, givenId, 1);
}); });
it('no description tag is inserted', () => { it('should not insert description tag', () => {
const fauxTitle = new MockedD3(); const fauxTitle: MockedD3 = new MockedD3();
const svgInsertSpy = vi.spyOn(fauxSvgNode, 'insert').mockReturnValue(fauxTitle); const svginsertpy = vi.spyOn(fauxSvgNode, 'insert').mockReturnValue(fauxTitle);
addSVGa11yTitleDescription(fauxSvgNode, a11yTitle, a11yDesc, givenId); addSVGa11yTitleDescription(fauxSvgNode, a11yTitle, a11yDesc, givenId);
expect(svgInsertSpy).not.toHaveBeenCalledWith('desc', ':first-child'); expect(svginsertpy).not.toHaveBeenCalledWith('desc', ':first-child');
}); });
}); });
}); });
describe('no a11y title', () => { describe('without a11y title', () => {
const a11yTitle = undefined; const a11yTitle = undefined;
describe('given an a11y description', () => { describe('with a11y description', () => {
const a11yDesc = 'a11y description'; const a11yDesc = 'a11y description';
it('no aria-labelledby is set', () => { it('should not set aria-labelledby', () => {
// @ts-ignore Required to easily handle the d3 select types
const svgAttrSpy = vi.spyOn(fauxSvgNode, 'attr').mockReturnValue(fauxSvgNode); const svgAttrSpy = vi.spyOn(fauxSvgNode, 'attr').mockReturnValue(fauxSvgNode);
addSVGa11yTitleDescription(fauxSvgNode, a11yTitle, a11yDesc, givenId); addSVGa11yTitleDescription(fauxSvgNode, a11yTitle, a11yDesc, givenId);
expect(svgAttrSpy).not.toHaveBeenCalledWith('aria-labelledby', expect.anything()); expect(svgAttrSpy).not.toHaveBeenCalledWith('aria-labelledby', expect.anything());
}); });
it('no title tag inserted', () => { it('should not insert title tag', () => {
const fauxTitle = new MockedD3(); const fauxTitle: MockedD3 = new MockedD3();
const svgInsertSpy = vi.spyOn(fauxSvgNode, 'insert').mockReturnValue(fauxTitle); const svginsertpy = vi.spyOn(fauxSvgNode, 'insert').mockReturnValue(fauxTitle);
addSVGa11yTitleDescription(fauxSvgNode, a11yTitle, a11yDesc, givenId); addSVGa11yTitleDescription(fauxSvgNode, a11yTitle, a11yDesc, givenId);
expect(svgInsertSpy).not.toHaveBeenCalledWith('title', ':first-child'); expect(svginsertpy).not.toHaveBeenCalledWith('title', ':first-child');
}); });
it('sets aria-describedby to the description id inserted as a child', () => { it('should set aria-describedby to the description id inserted as a child', () => {
expectAriaDescribedByIsDescId(fauxSvgNode, a11yTitle, a11yDesc, givenId); expectAriaDescribedByItDescId(fauxSvgNode, a11yTitle, a11yDesc, givenId);
}); });
it('inserts a desc tag as the 2nd child with the text set to accDescription given', () => { it('should insert desc tag as the 2nd child with the text set to accDescription given', () => {
a11yDescTagInserted(fauxSvgNode, a11yTitle, a11yDesc, givenId, 1); a11yDescTagInserted(fauxSvgNode, a11yTitle, a11yDesc, givenId, 1);
}); });
}); });
describe('no a11y description', () => { describe('without a11y description', () => {
const a11yDesc = undefined; const a11yDesc = undefined;
it('no aria-labelledby is set', () => { it('should not set aria-labelledby', () => {
// @ts-ignore Required to easily handle the d3 select types
const svgAttrSpy = vi.spyOn(fauxSvgNode, 'attr').mockReturnValue(fauxSvgNode); const svgAttrSpy = vi.spyOn(fauxSvgNode, 'attr').mockReturnValue(fauxSvgNode);
addSVGa11yTitleDescription(fauxSvgNode, a11yTitle, a11yDesc, givenId); addSVGa11yTitleDescription(fauxSvgNode, a11yTitle, a11yDesc, givenId);
expect(svgAttrSpy).not.toHaveBeenCalledWith('aria-labelledby', expect.anything()); expect(svgAttrSpy).not.toHaveBeenCalledWith('aria-labelledby', expect.anything());
}); });
it('no aria-describedby is set', () => { it('should not set aria-describedby', () => {
// @ts-ignore Required to easily handle the d3 select types
const svgAttrSpy = vi.spyOn(fauxSvgNode, 'attr').mockReturnValue(fauxSvgNode); const svgAttrSpy = vi.spyOn(fauxSvgNode, 'attr').mockReturnValue(fauxSvgNode);
addSVGa11yTitleDescription(fauxSvgNode, a11yTitle, a11yDesc, givenId); addSVGa11yTitleDescription(fauxSvgNode, a11yTitle, a11yDesc, givenId);
expect(svgAttrSpy).not.toHaveBeenCalledWith('aria-describedby', expect.anything()); expect(svgAttrSpy).not.toHaveBeenCalledWith('aria-describedby', expect.anything());
}); });
it('no title tag inserted', () => { it('should not insert title tag', () => {
const fauxTitle = new MockedD3(); const fauxTitle: MockedD3 = new MockedD3();
const svgInsertSpy = vi.spyOn(fauxSvgNode, 'insert').mockReturnValue(fauxTitle); const svginsertpy = vi.spyOn(fauxSvgNode, 'insert').mockReturnValue(fauxTitle);
addSVGa11yTitleDescription(fauxSvgNode, a11yTitle, a11yDesc, givenId); addSVGa11yTitleDescription(fauxSvgNode, a11yTitle, a11yDesc, givenId);
expect(svgInsertSpy).not.toHaveBeenCalledWith('title', ':first-child'); expect(svginsertpy).not.toHaveBeenCalledWith('title', ':first-child');
}); });
it('no description tag inserted', () => { it('should not insert description tag', () => {
const fauxDesc = new MockedD3(); const fauxDesc: MockedD3 = new MockedD3();
const svgInsertSpy = vi.spyOn(fauxSvgNode, 'insert').mockReturnValue(fauxDesc); const svginsertpy = vi.spyOn(fauxSvgNode, 'insert').mockReturnValue(fauxDesc);
addSVGa11yTitleDescription(fauxSvgNode, a11yTitle, a11yDesc, givenId); addSVGa11yTitleDescription(fauxSvgNode, a11yTitle, a11yDesc, givenId);
expect(svgInsertSpy).not.toHaveBeenCalledWith('desc', ':first-child'); expect(svginsertpy).not.toHaveBeenCalledWith('desc', ':first-child');
}); });
}); });
}); });

View File

@ -1,13 +1,11 @@
/** /**
* Accessibility (a11y) functions, types, helpers * Accessibility (a11y) functions, types, helpers.
*
* @see https://www.w3.org/WAI/ * @see https://www.w3.org/WAI/
* @see https://www.w3.org/TR/wai-aria-1.1/ * @see https://www.w3.org/TR/wai-aria-1.1/
* @see https://www.w3.org/TR/svg-aam-1.0/ * @see https://www.w3.org/TR/svg-aam-1.0/
*
*/ */
import { D3Element } from './mermaidAPI.js'; import type { D3Element } from './mermaidAPI.js';
import isEmpty from 'lodash-es/isEmpty.js';
/** /**
* SVG element role: * SVG element role:
@ -21,50 +19,47 @@ import isEmpty from 'lodash-es/isEmpty.js';
const SVG_ROLE = 'graphics-document document'; const SVG_ROLE = 'graphics-document document';
/** /**
* Add role and aria-roledescription to the svg element * Add role and aria-roledescription to the svg element.
* *
* @param svg - d3 object that contains the SVG HTML element * @param svg - d3 object that contains the SVG HTML element
* @param diagramType - diagram name for to the aria-roledescription * @param diagramType - diagram name for to the aria-roledescription
*/ */
export function setA11yDiagramInfo(svg: D3Element, diagramType: string | null | undefined) { export function setA11yDiagramInfo(svg: D3Element, diagramType: string) {
svg.attr('role', SVG_ROLE); svg.attr('role', SVG_ROLE);
if (!isEmpty(diagramType)) { if (diagramType !== '') {
svg.attr('aria-roledescription', diagramType); svg.attr('aria-roledescription', diagramType);
} }
} }
/** /**
* Add an accessible title and/or description element to a chart. * Add an accessible title and/or description element to a chart.
* The title is usually not displayed and the description is never displayed. * The title is usually not displayed and the description is never displayed.
* *
* The following charts display their title as a visual and accessibility element: gantt * The following charts display their title as a visual and accessibility element: gantt.
* *
* @param svg - d3 node to insert the a11y title and desc info * @param svg - d3 node to insert the a11y title and desc info
* @param a11yTitle - a11y title. null and undefined are meaningful: means to skip it * @param a11yTitle - a11y title. undefined or empty strings mean to skip them
* @param a11yDesc - a11y description. null and undefined are meaningful: means to skip it * @param a11yDesc - a11y description. undefined or empty strings mean to skip them
* @param baseId - id used to construct the a11y title and description id * @param baseId - id used to construct the a11y title and description id
*/ */
export function addSVGa11yTitleDescription( export function addSVGa11yTitleDescription(
svg: D3Element, svg: D3Element,
a11yTitle: string | null | undefined, a11yTitle: string | undefined,
a11yDesc: string | null | undefined, a11yDesc: string | undefined,
baseId: string baseId: string
) { ): void {
if (svg.insert === undefined) { if (svg.insert === undefined) {
return; return;
} }
if (a11yTitle || a11yDesc) { if (a11yDesc) {
if (a11yDesc) { const descId = `chart-desc-${baseId}`;
const descId = 'chart-desc-' + baseId; svg.attr('aria-describedby', descId);
svg.attr('aria-describedby', descId); svg.insert('desc', ':first-child').attr('id', descId).text(a11yDesc);
svg.insert('desc', ':first-child').attr('id', descId).text(a11yDesc); }
} if (a11yTitle) {
if (a11yTitle) { const titleId = `chart-title-${baseId}`;
const titleId = 'chart-title-' + baseId; svg.attr('aria-labelledby', titleId);
svg.attr('aria-labelledby', titleId); svg.insert('title', ':first-child').attr('id', titleId).text(a11yTitle);
svg.insert('title', ':first-child').attr('id', titleId).text(a11yTitle);
}
} else {
return;
} }
} }

View File

@ -1,5 +1,3 @@
import type {} from '@vitest/spy/dist/index.js';
/** /**
* This is a mocked/stubbed version of the d3 Selection type. Each of the main functions are all * This is a mocked/stubbed version of the d3 Selection type. Each of the main functions are all
* mocked (via vi.fn()) so you can track if they have been called, etc. * mocked (via vi.fn()) so you can track if they have been called, etc.
@ -7,9 +5,8 @@ import type {} from '@vitest/spy/dist/index.js';
* Note that node() returns a HTML Element with tag 'svg'. It is an empty element (no innerHTML, no children, etc). * Note that node() returns a HTML Element with tag 'svg'. It is an empty element (no innerHTML, no children, etc).
* This potentially allows testing of mermaidAPI render(). * This potentially allows testing of mermaidAPI render().
*/ */
export class MockedD3 { export class MockedD3 {
public attribs = new Map<string, string | null>(); public attribs = new Map<string, string>();
public id: string | undefined = ''; public id: string | undefined = '';
_children: MockedD3[] = []; _children: MockedD3[] = [];
@ -72,9 +69,9 @@ export class MockedD3 {
return newMock; return newMock;
}; };
attr(attrName: string): null | undefined | string | number; attr(attrName: string): undefined | string;
// attr(attrName: string, attrValue: string): MockedD3; attr(attrName: string, attrValue: string): MockedD3;
attr(attrName: string, attrValue?: string): null | undefined | string | number | MockedD3 { attr(attrName: string, attrValue?: string): undefined | string | MockedD3 {
if (arguments.length === 1) { if (arguments.length === 1) {
return this.attribs.get(attrName); return this.attribs.get(attrName);
} else { } else {