Skip to content

Commit 9bc6156

Browse files
author
Hector Arce De Las Heras
committed
Add ARIA props to tbody when scroll is shown
This commit introduces the ability to add ARIA properties to the tbody element when a scroll is displayed. The new tBodyScrollAria prop allows for the configuration of these labels, accepting an object with optional aria-label and aria-labelledby properties. This enhancement improves accessibility by providing descriptive information to assistive technologies when tbody has a scroll.
1 parent f2dbf0f commit 9bc6156

File tree

10 files changed

+209
-12
lines changed

10 files changed

+209
-12
lines changed

src/components/table/__tests__/table.test.tsx

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import * as useMediaDevice from '@/hooks/useMediaDevice/useMediaDevice';
1111
import { renderProvider } from '@/tests/renderProvider/renderProvider.utility';
1212
import { windowMatchMedia } from '@/tests/windowMatchMedia';
1313
import { DeviceBreakpointsType, ROLES } from '@/types';
14+
import * as hasScrollUtils from '@/utils/scroll/hasScroll';
1415

1516
import { Table } from '../table';
1617
import { FormatListHeaderPriorityType } from '../types';
@@ -378,6 +379,25 @@ const mockDividerFromHeader = {
378379
};
379380

380381
describe('Table component', () => {
382+
beforeAll(() => {
383+
global.ResizeObserver = class ResizeObserver {
384+
callback;
385+
constructor(callback) {
386+
this.callback = callback;
387+
}
388+
observe() {
389+
// Call the callback
390+
this.callback();
391+
}
392+
unobserve() {
393+
// do nothing
394+
}
395+
disconnect() {
396+
// do nothing
397+
}
398+
};
399+
});
400+
381401
it('Renders with a valid HTML structure', async () => {
382402
const { container } = renderProvider(
383403
<Table
@@ -577,4 +597,28 @@ describe('Table component', () => {
577597
expect(container).toHTMLValidate();
578598
expect(results).toHaveNoViolations();
579599
});
600+
601+
it('When scroll appears in the tbody, tBody surface is focusable and and labels can be added', async () => {
602+
// Mock hasScrollUtils and resize observer
603+
jest.spyOn(hasScrollUtils, 'hasScroll').mockImplementation(() => true);
604+
const { container } = renderProvider(
605+
<Table
606+
aria-label={'aria table label'}
607+
captionDescription={'caption description'}
608+
tBodyScrollArias={{ 'aria-label': 'aria-label' }}
609+
{...mockBase}
610+
/>
611+
);
612+
const results = await axe(container);
613+
const tbody = screen.getByRole(ROLES.REGION, { name: 'aria-label' });
614+
615+
expect(tbody).toBeDefined();
616+
expect(tbody).toHaveAttribute('tabindex', '0');
617+
expect(container).toHTMLValidate({
618+
rules: {
619+
'prefer-native-element': 'off',
620+
},
621+
});
622+
expect(results).toHaveNoViolations();
623+
});
580624
});

src/components/table/component/table.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import * as React from 'react';
44
import { Footer } from '@/components/footer';
55
import { Text, TextComponentType } from '@/components/text';
66
import { useId } from '@/hooks';
7+
import { ROLES } from '@/types';
78
import { pickAriaProps } from '@/utils/aria/aria';
89

910
import {
@@ -106,6 +107,10 @@ export const TableComponent = (
106107
styles={props.styles.header?.[props.headerVariant]}
107108
>
108109
<Text
110+
align={
111+
headerValue?.config?.alignHeader?.[props.device] ||
112+
headerValue?.config?.alignHeader
113+
}
109114
component={TextComponentType.SPAN}
110115
customTypography={props.styles.header?.[props.headerVariant]?.typography}
111116
dataTestId={`${props.dataTestId}Header${index}`}
@@ -133,7 +138,15 @@ export const TableComponent = (
133138
)}
134139
</TableRowHeaderStyled>
135140
</TableRowGroupHeaderStyled>
136-
<TableRowGroupBodyStyled styles={props.styles}>
141+
<TableRowGroupBodyStyled
142+
aria-label={props.hasScroll ? props.tBodyScrollArias?.['aria-label'] : undefined}
143+
aria-labelledby={
144+
props.hasScroll ? props.tBodyScrollArias?.['aria-labelledby'] : undefined
145+
}
146+
role={props.hasScroll ? ROLES.REGION : undefined}
147+
styles={props.styles}
148+
tabIndex={props.hasScroll ? 0 : undefined}
149+
>
137150
{props.values.map((value, indexValue) => {
138151
return (
139152
<TableRow

src/components/table/stories/argtypes.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,19 @@ export const argtypes = (
205205
category: CATEGORY_CONTROL.MODIFIERS,
206206
},
207207
},
208+
tBodyScrollArias: {
209+
description: 'Aria label for tbody when scroll',
210+
type: { name: 'object' },
211+
control: { type: 'object' },
212+
table: {
213+
type: {
214+
summary: 'TBodyScrollAriasType',
215+
detail:
216+
'TBodyScrollAriasType: { ["aria-label"]?: string; ["aria-labelledby"]?: string; }',
217+
},
218+
category: CATEGORY_CONTROL.ACCESIBILITY,
219+
},
220+
},
208221
formatListHeaderPriority: {
209222
description: 'When format list format, allows to select the list direction (from the table)',
210223
type: { name: 'string' },

src/components/table/stories/table.stories.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export const Table: Story = {
4040
['aria-label']: 'ariaLabel table',
4141
formatListInMobile: true,
4242
formatSideBySideInList: true,
43+
tBodyScrollArias: { 'aria-label': 'ariaLabel tBody when scroll' },
4344
themeArgs: themesObject[themeSelected][STYLES_NAME.TABLE],
4445
...mockTableWithLineSeparatorAndCenterFooter,
4546
},
@@ -53,6 +54,7 @@ export const TableWithDivider: Story = {
5354
)[0] as string,
5455
captionDescription: 'Table caption',
5556
['aria-label']: 'ariaLabel table',
57+
tBodyScrollArias: { 'aria-label': 'ariaLabel tBody when scroll' },
5658
themeArgs: themesObject[themeSelected][STYLES_NAME.TABLE],
5759
...mockTableWithDivider,
5860
},
@@ -66,6 +68,7 @@ export const TableBasic: Story = {
6668
)[0] as string,
6769
captionDescription: 'Table caption',
6870
['aria-label']: 'ariaLabel table',
71+
tBodyScrollArias: { 'aria-label': 'ariaLabel tBody when scroll' },
6972
themeArgs: themesObject[themeSelected][STYLES_NAME.TABLE],
7073
...mockBasicTable,
7174
},
@@ -78,6 +81,7 @@ export const TableCustomizable: Story = {
7881
['aria-label']: 'ariaLabel table',
7982
lineSeparatorTopOnHeader: true,
8083
lineSeparatorBottomOnHeader: true,
84+
tBodyScrollArias: { 'aria-label': 'ariaLabel tBody when scroll' },
8185
themeArgs: themesObject[themeSelected][STYLES_NAME.TABLE],
8286
...mockCustomizableTable,
8387
},
@@ -93,6 +97,7 @@ export const TableWithCtv: Story = {
9397
['aria-label']: 'ariaLabel table',
9498
formatListInMobile: true,
9599
formatSideBySideInList: true,
100+
tBodyScrollArias: { 'aria-label': 'ariaLabel tBody when scroll' },
96101
...mockTableWithLineSeparatorAndCenterFooter,
97102
ctv: {
98103
table: {

src/components/table/table.tsx

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import React from 'react';
1+
import React, { useImperativeHandle } from 'react';
22

33
import { LineSeparatorLinePropsStylesType } from '@/components/lineSeparator';
44
import { useMediaDevice } from '@/hooks';
55
import { useStyles } from '@/hooks/useStyles/useStyles';
66
import { ErrorBoundary, FallbackComponent } from '@/provider/errorBoundary';
7+
import { hasScroll as checkHasScroll } from '@/utils';
78

89
import { TableStandAlone } from './tableStandAlone';
910
import { ITable, ITableStandAlone, TableRowHeaderTypes } from './types';
@@ -27,27 +28,47 @@ const TableComponent = React.forwardRef(
2728
);
2829
const device = useMediaDevice();
2930

31+
const innerRef = React.useRef<HTMLTableElement>(null);
32+
useImperativeHandle(ref, () => innerRef.current as HTMLTableElement, []);
33+
3034
// Indicates if there is scrolling behind the table body to add shadow to column headers.
3135
const [scrollPosition, setScrollPosition] = React.useState(0);
32-
const refTableBody = React.useRef<HTMLTableSectionElement>(null);
33-
3436
React.useEffect(() => {
37+
const tBody = innerRef.current?.querySelector('tbody');
3538
const updatePosition = () => {
36-
setScrollPosition(refTableBody?.current?.scrollTop ?? 0);
39+
setScrollPosition(tBody?.scrollTop ?? 0);
3740
};
41+
tBody?.addEventListener('scroll', updatePosition);
42+
return () => tBody?.removeEventListener('scroll', updatePosition);
43+
}, []);
3844

39-
refTableBody?.current?.addEventListener('scroll', updatePosition);
40-
41-
return () => refTableBody?.current?.removeEventListener('scroll', updatePosition);
45+
// Indicates if tBody has scroll in order to add accesibility aria props
46+
const [hasScroll, setHasScroll] = React.useState(false);
47+
React.useEffect(() => {
48+
const tBody = innerRef.current?.querySelector('tbody');
49+
let resizeObserver: ResizeObserver;
50+
if (tBody) {
51+
const handleTBodyResize = (tBody: HTMLTableSectionElement) => {
52+
setHasScroll(checkHasScroll(tBody));
53+
};
54+
handleTBodyResize(tBody);
55+
resizeObserver = new ResizeObserver(() => {
56+
handleTBodyResize(tBody);
57+
});
58+
resizeObserver.observe(tBody);
59+
}
60+
return () => {
61+
resizeObserver?.disconnect();
62+
};
4263
}, []);
4364

4465
return (
4566
<TableStandAlone
4667
{...props}
47-
ref={ref}
68+
ref={innerRef}
4869
device={device}
70+
hasScroll={hasScroll}
4971
lineSeparatorLineStyles={lineSeparatorLineStyles}
50-
refTableBody={refTableBody}
5172
scrolling={scrollPosition > 0}
5273
styles={styles}
5374
/>

src/components/table/types/table.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,14 +150,18 @@ type TableAriaAttributes = Pick<
150150
'aria-label' | 'aria-labelledby' | 'aria-describedby'
151151
>;
152152

153+
type TBodyScrollAriasType = {
154+
['aria-label']?: string;
155+
['aria-labelledby']?: string;
156+
};
157+
153158
/**
154159
* @description
155160
* Table props
156161
* @interface ITableStandAlone
157162
*/
158163
export interface ITableStandAlone extends TableAriaAttributes {
159164
styles: TableRowHeaderTypes<string, string>;
160-
refTableBody?: React.Ref<HTMLTableSectionElement>;
161165
lineSeparatorLineStyles: LineSeparatorLinePropsStylesType;
162166
lineSeparatorTopOnHeader?: boolean;
163167
lineSeparatorBottomOnHeader?: boolean;
@@ -173,6 +177,8 @@ export interface ITableStandAlone extends TableAriaAttributes {
173177
hiddenHeaderOn?: HiddenType;
174178
device: DeviceBreakpointsType;
175179
scrolling: boolean;
180+
hasScroll: boolean;
181+
tBodyScrollArias?: TBodyScrollAriasType;
176182
headerVariant?: string;
177183
expandedContentHelpMessage?: string;
178184
formatList?: { [key in DeviceBreakpointsType]?: boolean };
@@ -195,7 +201,7 @@ export interface ITableStandAlone extends TableAriaAttributes {
195201
export interface ITable<V = undefined extends string ? unknown : string>
196202
extends Omit<
197203
ITableStandAlone,
198-
'styles' | 'lineSeparatorLineStyles' | 'device' | 'scrolling' | 'refTableBody'
204+
'styles' | 'lineSeparatorLineStyles' | 'device' | 'scrolling' | 'hasScroll'
199205
>,
200206
Omit<CustomTokenTypes<TableRowHeaderTypes<string, string>>, 'cts' | 'extraCt'> {
201207
variant: V;

src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,4 @@ export * from './buildPropsDecorativeElement/buildPropsDecorativeElement';
3939
export * from './syntheticComponents';
4040
export * from './aria/aria';
4141
export * from './structuredClone';
42+
export * from './scroll';
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { hasHorizontalScroll, hasScroll, hasVerticalScroll } from '../hasScroll';
2+
3+
describe('Scroll detection functions', () => {
4+
let element: HTMLElement;
5+
6+
beforeEach(() => {
7+
// Create a new element for each test
8+
element = document.createElement('div');
9+
document.body.appendChild(element);
10+
});
11+
12+
afterEach(() => {
13+
// Clean up the element after each test
14+
document.body.removeChild(element);
15+
});
16+
17+
it('hasVerticalScroll returns true when an element has a vertical scroll', () => {
18+
Object.defineProperty(element, 'scrollHeight', {
19+
value: 200,
20+
});
21+
Object.defineProperty(element, 'clientHeight', {
22+
value: 100,
23+
});
24+
25+
expect(hasVerticalScroll(element)).toBe(true);
26+
});
27+
28+
it('hasVerticalScroll returns false when an element does not have a vertical scroll', () => {
29+
Object.defineProperty(element, 'scrollHeight', {
30+
value: 50,
31+
});
32+
Object.defineProperty(element, 'clientHeight', {
33+
value: 100,
34+
});
35+
36+
expect(hasVerticalScroll(element)).toBe(false);
37+
});
38+
39+
it('hasHorizontalScroll returns true when an element has a horizontal scroll', () => {
40+
Object.defineProperty(element, 'scrollWidth', {
41+
value: 200,
42+
});
43+
Object.defineProperty(element, 'clientWidth', {
44+
value: 100,
45+
});
46+
47+
expect(hasHorizontalScroll(element)).toBe(true);
48+
});
49+
50+
it('hasHorizontalScroll returns false when an element does not have a horizontal scroll', () => {
51+
Object.defineProperty(element, 'scrollWidth', {
52+
value: 50,
53+
});
54+
Object.defineProperty(element, 'clientWidth', {
55+
value: 100,
56+
});
57+
58+
expect(hasHorizontalScroll(element)).toBe(false);
59+
});
60+
61+
it('hasScroll returns true when an element has a vertical or horizontal scroll', () => {
62+
Object.defineProperty(element, 'scrollWidth', {
63+
value: 200,
64+
});
65+
Object.defineProperty(element, 'clientWidth', {
66+
value: 100,
67+
});
68+
69+
expect(hasHorizontalScroll(element)).toBe(true);
70+
});
71+
72+
it('hasScroll returns false when an element does not have a vertical or horizontal scroll', () => {
73+
Object.defineProperty(element, 'scrollWidth', {
74+
value: 50,
75+
});
76+
Object.defineProperty(element, 'clientWidth', {
77+
value: 100,
78+
});
79+
80+
expect(hasScroll(element)).toBe(false);
81+
});
82+
});

src/utils/scroll/hasScroll.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export const hasVerticalScroll = (element: HTMLElement): boolean => {
2+
return element.scrollHeight > element.clientHeight;
3+
};
4+
5+
export const hasHorizontalScroll = (element: HTMLElement): boolean => {
6+
return element.scrollWidth > element.clientWidth;
7+
};
8+
9+
export const hasScroll = (element: HTMLElement): boolean => {
10+
return hasVerticalScroll(element) || hasHorizontalScroll(element);
11+
};

src/utils/scroll/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './hasScroll';

0 commit comments

Comments
 (0)