Skip to content

Commit

Permalink
feat/a11y tables (#22)
Browse files Browse the repository at this point in the history
* refactor: change table provider scope

* chore: change table playground components

* feat: add caption component

* test: update table test suite
  • Loading branch information
bpetermann authored Nov 15, 2024
1 parent 3a547eb commit d58550b
Show file tree
Hide file tree
Showing 24 changed files with 288 additions and 156 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- `<Th>` components with accessibility (a11y) checks to validate necessary attributes based on the complexity of table headers.
- `<Td>` components with accessibility (a11y) checks to validate necessary attributes based on the complexity of table headers.
- `<Tr>` components to support registering associated table headers.
- `<Caption>` components to detect a nested `<caption>` indside a `<TableProvider>`.
- `<TableProvider>` Component: Introduced a context provider for more fine-grained table accessibility checks.

## [0.5.0] - 2024-11-07
Expand Down
31 changes: 31 additions & 0 deletions packages/aware-components/src/components/Table/Caption.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import React, { useEffect } from 'react';
import { DEVELOPMENT } from '../../constants';
import {
addCaption,
deleteCaption,
useTable,
} from '../../context/table/actions';

type Props = React.DetailedHTMLProps<
React.HTMLAttributes<HTMLTableCaptionElement>,
HTMLTableCaptionElement
>;

export function Development(props: Props) {
const { children, ...rest } = props;
const { dispatch } = useTable();

useEffect(() => {
dispatch(addCaption());
return () => dispatch(deleteCaption());
}, [dispatch]);

return <caption {...rest}>{children}</caption>;
}

export const Caption = (props: Props) =>
DEVELOPMENT ? (
<Development {...props} />
) : (
<caption {...props}>{props.children}</caption>
);
14 changes: 7 additions & 7 deletions packages/aware-components/src/components/Table/Table.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from 'react';
import { DEVELOPMENT } from '../../constants';
import { useTable } from '../../context/table/actions';
import TableProvider from '../../context/table/provider';
import { warn } from '../../helper/consoleWarn';
import { a11yChecks } from '../../utils/a11y';
Expand All @@ -14,19 +15,18 @@ interface Props

export function Development(props: Props) {
const { a11y = true, children, ...rest } = props;
const { header, caption } = useTable();

if (a11y) a11yChecks.table(props)?.forEach(warn);
if (a11y) a11yChecks?.table?.(props, header, caption)?.forEach(warn);

return (
<TableProvider>
<table {...rest}>{children}</table>
</TableProvider>
);
return <table {...rest}>{children}</table>;
}

export const Table = (props: Props) =>
DEVELOPMENT ? (
<Development {...props} />
<TableProvider>
<Development {...props} />
</TableProvider>
) : (
<table {...props}>{props.children}</table>
);
1 change: 1 addition & 0 deletions packages/aware-components/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export { Optgroup } from './Optgroup';
export { P } from './P';
export { Section } from './Section';
export { Select } from './Select';
export { Caption } from './Table/Caption';
export { Table } from './Table/Table';
export { Td } from './Table/Td';
export { Th } from './Table/Th';
Expand Down
4 changes: 4 additions & 0 deletions packages/aware-components/src/context/table/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@ import { Scope } from './types';

export const ADD_HEADER = 'ADD_HEADER';
export const DELETE_HEADER = 'DELETE_HEADER';
export const ADD_CAPTION = 'ADD_CAPTION';
export const DELETE_CAPTION = 'DELETE_CAPTION';

export const addHeader = (scope?: Scope) => ({ type: ADD_HEADER, scope });
export const deleteHeader = (scope?: Scope) => ({ type: DELETE_HEADER, scope });
export const addCaption = () => ({ type: ADD_CAPTION });
export const deleteCaption = () => ({ type: DELETE_CAPTION });

export const useTable = () => useContext(TableContext);
1 change: 1 addition & 0 deletions packages/aware-components/src/context/table/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ import { TableContextType } from './types';

export const TableContext = createContext<TableContextType>({
header: [],
caption: undefined,
dispatch: () => null,
});
13 changes: 11 additions & 2 deletions packages/aware-components/src/context/table/reducer.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { ADD_HEADER, DELETE_HEADER } from './actions';
import {
ADD_CAPTION,
ADD_HEADER,
DELETE_CAPTION,
DELETE_HEADER,
} from './actions';
import { TableAction, TableState } from './types';

export const initialState: TableState = {
header: [],
caption: undefined,
};

export function tableReducer(
Expand Down Expand Up @@ -30,7 +36,10 @@ export function tableReducer(
]
: state.header,
};

case ADD_CAPTION:
return { ...state, caption: true };
case DELETE_CAPTION:
return { ...state, caption: false };
default:
return state;
}
Expand Down
1 change: 1 addition & 0 deletions packages/aware-components/src/context/table/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export type Scope = 'col' | 'row';

export type TableState = {
header: Scope[];
caption: boolean | undefined;
};

export type TableAction = {
Expand Down
5 changes: 4 additions & 1 deletion packages/aware-components/src/helper/tables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,5 +105,8 @@ const getCount = (scopes: Scope[], type: Scope) =>
export const isTwoHeadingTable = (scopes: Scope[]): boolean =>
getCount(scopes, COLUMN) === 1 && !!getCount(scopes, ROW);

export const isMultiHeaderTable = (scopes: Scope[]): boolean =>
export const isMultiHeadingTable = (scopes: Scope[]): boolean =>
getCount(scopes, COLUMN) > 1 && !!getCount(scopes, ROW);

export const hasColHeading = (scopes: Scope[]): boolean =>
scopes.includes(COLUMN);
1 change: 1 addition & 0 deletions packages/aware-components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export {
A,
Audio,
Button,
Caption,
Div,
Fieldset,
H1,
Expand Down
194 changes: 129 additions & 65 deletions packages/aware-components/src/test/a11y/table.test.ts
Original file line number Diff line number Diff line change
@@ -1,78 +1,142 @@
import React from 'react';
import { describe, expect, it } from 'vitest';
import { tableChecks } from '../../utils/a11y/table';
import { checkColHeader } from '../../utils/a11y/table/checks/checkColHeader';
import { checkMultiHeader } from '../../utils/a11y/table/checks/checkMultiHeader';
import { checkRowHeader } from '../../utils/a11y/table/checks/checkRowHeader';
import { Scope } from '../../context/table/types';
import { tableChecks } from '../../utils/a11y/table/index';
import { messages } from '../../utils/messages';

describe('Table element accessibility checks', () => {
const createElementWithScopeAndId = (
type: 'th' | 'td',
scope?: string,
id?: string
) => React.createElement(type, { scope, id, key: id ?? '1' });

const validHeader = createElementWithScopeAndId('th', 'col', 'header1');
const invalidHeaderNoScope = createElementWithScopeAndId('th');
const rowHeaders = [validHeader];
const colHeaders = [validHeader];
const headerColumns = [validHeader, validHeader];

describe('checkRowHeader', () => {
it('should return null if all row headers have scope', () => {
expect(checkRowHeader(colHeaders, rowHeaders)).toBe(null);
});

it('should return a message if any row header is missing scope', () => {
const result = checkRowHeader(colHeaders, [invalidHeaderNoScope]);
expect(result).toBe(messages.table.row);
});
describe('Accessibility check for table', () => {
const colHeading: Scope[] = ['col'];
const twoHeadings: Scope[] = ['col', 'row'];
const multiHeadings: Scope[] = ['col', 'col', 'row', 'row'];

const caption = React.createElement('caption', {}, 'Caption');

const createThead = (
children: React.DetailedReactHTMLElement<object, HTMLElement>
) => React.createElement('thead', {}, children);

const createTr = (
children: React.DetailedReactHTMLElement<object, HTMLElement>[]
) => React.createElement('tr', {}, ...children);

const createTh = () => React.createElement('th', {});

const createTd = () => React.createElement('td', {});

const createThWithScope = () => React.createElement('th', { scope: 'scope' });

const createThWithAttributes = () =>
React.createElement('th', { scope: 'scope', id: 'id' });

const tableHeader = {
children: [createThead(createTr([createTh(), createTh(), createTh()]))],
};

const thWithScopeAndId = {
children: [
createThead(
createTr(
Array(3)
.fill(undefined)
.map(() => createThWithAttributes())
)
),
],
};

const thWithScope = {
children: [
createThead(
createTr(
Array(3)
.fill(undefined)
.map(() => createThWithScope()) as React.DetailedReactHTMLElement<
object,
HTMLElement
>[]
)
),
],
};

const tableBody = {
children: createTr(
Array(5)
.fill(undefined)
.map((_, i) => (i === 0 ? createTh() : createTd()))
),
};

it('warns if no column heading is provided', () => {
const warnings = tableChecks({}, [], undefined);
expect(warnings).toContain(messages.table.col);
});

describe('checkColHeader', () => {
it('should return a message if no column headers are provided', () => {
expect(checkColHeader([])).toBe(messages.table.col);
});
it('passes if column heading is defined by scope', () => {
const warnings = tableChecks({}, colHeading, undefined);
expect(warnings.length).toEqual(0);
});

it('passes if column heading is defined via props', () => {
const warnings = tableChecks(tableHeader, [], undefined);
expect(warnings.length).toEqual(0);
});

it('warns if two-heading table lacks `scope`', () => {
const warnings = tableChecks(
{ children: [...tableHeader.children, tableBody.children] },
[],
undefined
);
expect(warnings).toContain(messages.table.row);
});

it('warns if two-heading table has no `scope`', () => {
const warnings = tableChecks(tableBody, twoHeadings, undefined);
expect(warnings).toContain(messages.table.row);
});

it('passes if all <th> elements in two-heading table have `scope`', () => {
const warnings = tableChecks(thWithScope, twoHeadings, undefined);
expect(warnings.length).toEqual(0);
});

it('warns if <th> in multi-heading table lacks `scope`', () => {
const warnings = tableChecks(tableHeader, multiHeadings, undefined);
expect(warnings).toContain(messages.table.multi);
});

it('passes if all <th> elements in multi-heading table have `scope`', () => {
const warnings = tableChecks(
{ children: [caption, ...thWithScopeAndId.children] },
multiHeadings,
undefined
);
expect(warnings.length).toEqual(0);
});

it('warns if multi-heading table lacks `caption`', () => {
const warnings = tableChecks(thWithScopeAndId, multiHeadings, undefined);
expect(warnings).toContain(messages.table.caption);
});

it('passes if `caption` is provided via context in multi-heading table', () => {
const warnings = tableChecks(thWithScopeAndId, multiHeadings, true);
expect(warnings.length).toEqual(0);
});

it('should return null if column headers are provided', () => {
expect(checkColHeader(headerColumns)).toBe(null);
});
it('issues one warning if no headings exist in table', () => {
const warnings = tableChecks({}, [], undefined);
expect(warnings.length).toEqual(1);
});

describe('checkMultiHeader', () => {
it('should return null if all headers have scope and id for multi-level headers', () => {
expect(
checkMultiHeader(colHeaders, rowHeaders, headerColumns.length, [])
).toBe(null);
});

it('should return a message if any header in multi-level headers is missing scope or id', () => {
const result = checkMultiHeader(
[invalidHeaderNoScope],
rowHeaders,
headerColumns.length,
[]
);
expect(result).toBe(messages.table.multi);
});
it('issues one warning if a two-heading table fails checks', () => {
const warnings = tableChecks(tableHeader, twoHeadings, undefined);
expect(warnings.length).toEqual(1);
});

describe('tableChecks', () => {
it('should return an empty array if all checks pass', () => {
const mockTableProps = {
children: [
React.createElement('caption', {}, 'Caption'),
React.createElement('tr', { key: 'table1' }, [
createElementWithScopeAndId('th', 'col', 'header1'),
createElementWithScopeAndId('th', 'col', 'header1'),
createElementWithScopeAndId('td', undefined, 'data1'),
]),
],
};

const result = tableChecks(mockTableProps);
expect(result).toEqual([]);
});
it('issues two warnings if multi-heading table fails checks', () => {
const warnings = tableChecks(tableHeader, multiHeadings, undefined);
expect(warnings.length).toEqual(2);
});
});
Original file line number Diff line number Diff line change
@@ -1,17 +1,11 @@
import { ReactElement } from 'react';
import { ARIA_DESCRIBEDBY, CAPTION } from '../../../../constants';
import { getFirstChild } from '../../../../helper/children';
import { messages } from '../../../messages';
import { TableProps } from '../types';

export const checkCaption = (
props: TableProps,
rowHeaders: ReactElement[],
headerCount: number
) =>
rowHeaders.length &&
headerCount > 1 &&
export const checkCaption = (props: TableProps, caption: boolean | undefined) =>
getFirstChild(props.children)?.type !== CAPTION &&
!caption &&
!props[ARIA_DESCRIBEDBY]
? messages.table.caption
: null;
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { ReactElement } from 'react';
import { messages } from '../../../messages';

export const checkColHeader = (colHeaders: ReactElement[]) =>
!colHeaders.length ? messages.table.col : null;
export const checkColHeader = (hasColumnHeader: boolean) =>
!hasColumnHeader ? messages.table.col : null;
Loading

0 comments on commit d58550b

Please sign in to comment.