Skip to content

Commit 9a3fe2c

Browse files
feat: Add colspan to Table (#7638)
* Add colSpan support to table cells * Add aria-col-index to cells * Don't set colspan in useGridCell * Add colspan and colindex in collections * Add colspan unit tests * Add check to validate that cells total length match col length to react-aria-components * Don't throw when isSSR * Remove interfering test * Re-add throwing tests * Add check to validate number of cells match col length in GridCollection * Rename colspan to colSpan * Move colSpan prop down to useGridCell * remove unneeded prop assignments --------- Co-authored-by: Robert Snow <[email protected]> Co-authored-by: GitHub <[email protected]>
1 parent 3dbdc1e commit 9a3fe2c

File tree

20 files changed

+614
-44
lines changed

20 files changed

+614
-44
lines changed

packages/@react-aria/grid/src/GridKeyboardDelegate.ts

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import {Direction, DisabledBehavior, Key, KeyboardDelegate, LayoutDelegate, Node, Rect, RefObject, Size} from '@react-types/shared';
1414
import {DOMLayoutDelegate} from '@react-aria/selection';
1515
import {getChildNodes, getFirstItem, getLastItem, getNthItem} from '@react-stately/collections';
16-
import {GridCollection} from '@react-types/grid';
16+
import {GridCollection, GridNode} from '@react-types/grid';
1717

1818
export interface GridKeyboardDelegateOptions<C> {
1919
collection: C,
@@ -103,6 +103,35 @@ export class GridKeyboardDelegate<T, C extends GridCollection<T>> implements Key
103103
return null;
104104
}
105105

106+
protected getKeyForItemInRowByIndex(key: Key, index: number = 0): Key | null {
107+
if (index < 0) {
108+
return null;
109+
}
110+
111+
let item = this.collection.getItem(key);
112+
if (!item) {
113+
return null;
114+
}
115+
116+
let i = 0;
117+
for (let child of getChildNodes(item, this.collection) as Iterable<GridNode<T>>) {
118+
if (child.colSpan && child.colSpan + i > index) {
119+
return child.key ?? null;
120+
}
121+
122+
if (child.colSpan) {
123+
i = i + child.colSpan - 1;
124+
}
125+
126+
if (i === index) {
127+
return child.key ?? null;
128+
}
129+
130+
i++;
131+
}
132+
return null;
133+
}
134+
106135
getKeyBelow(fromKey: Key) {
107136
let key: Key | null = fromKey;
108137
let startItem = this.collection.getItem(key);
@@ -123,11 +152,8 @@ export class GridKeyboardDelegate<T, C extends GridCollection<T>> implements Key
123152
if (key != null) {
124153
// If focus was on a cell, focus the cell with the same index in the next row.
125154
if (this.isCell(startItem)) {
126-
let item = this.collection.getItem(key);
127-
if (!item) {
128-
return null;
129-
}
130-
return getNthItem(getChildNodes(item, this.collection), startItem.index ?? 0)?.key ?? null;
155+
let startIndex = startItem.colIndex ? startItem.colIndex : startItem.index;
156+
return this.getKeyForItemInRowByIndex(key, startIndex);
131157
}
132158

133159
// Otherwise, focus the next row
@@ -158,11 +184,8 @@ export class GridKeyboardDelegate<T, C extends GridCollection<T>> implements Key
158184
if (key != null) {
159185
// If focus was on a cell, focus the cell with the same index in the previous row.
160186
if (this.isCell(startItem)) {
161-
let item = this.collection.getItem(key);
162-
if (!item) {
163-
return null;
164-
}
165-
return getNthItem(getChildNodes(item, this.collection), startItem.index ?? 0)?.key || null;
187+
let startIndex = startItem.colIndex ? startItem.colIndex : startItem.index;
188+
return this.getKeyForItemInRowByIndex(key, startIndex);
166189
}
167190

168191
// Otherwise, focus the previous row

packages/@react-aria/grid/src/useGridCell.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ export interface GridCellProps {
3030
focusMode?: 'child' | 'cell',
3131
/** Whether selection should occur on press up instead of press down. */
3232
shouldSelectOnPressUp?: boolean,
33+
/** Indicates how many columns the data cell spans. */
34+
colSpan?: number,
3335
/**
3436
* Handler that is called when a user performs an action on the cell.
3537
* Please use onCellAction at the collection level instead.
@@ -251,6 +253,9 @@ export function useGridCell<T, C extends GridCollection<T>>(props: GridCellProps
251253
let gridCellProps: DOMAttributes = mergeProps(itemProps, {
252254
role: 'gridcell',
253255
onKeyDownCapture,
256+
'aria-colspan': node.colSpan,
257+
'aria-colindex': node.colIndex != null ? node.colIndex + 1 : undefined, // aria-colindex is 1-based
258+
colSpan: isVirtualized ? undefined : node.colSpan,
254259
onFocus
255260
});
256261

packages/@react-aria/table/docs/useTable.mdx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -245,9 +245,8 @@ function TableColumnHeader({column, state}) {
245245
return (
246246
<th
247247
{...mergeProps(columnHeaderProps, focusProps)}
248-
colSpan={column.colspan}
249248
style={{
250-
textAlign: column.colspan > 1 ? 'center' : 'left',
249+
textAlign: column.colSpan > 1 ? 'center' : 'left',
251250
padding: '5px 10px',
252251
outline: 'none',
253252
boxShadow: isFocusVisible ? 'inset 0 0 0 2px orange' : 'none',
@@ -767,7 +766,7 @@ function AsyncSortTable() {
767766

768767
### Nested columns
769768

770-
Columns can be nested to create column groups. This will result in more than one header row to be created, with the `colspan`
769+
Columns can be nested to create column groups. This will result in more than one header row to be created, with the `colSpan`
771770
attribute of each column header cell set to the appropriate value so that the columns line up. Data for the leaf columns
772771
appears in each row of the table body.
773772

packages/@react-aria/table/src/TableKeyboardDelegate.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {getChildNodes, getFirstItem, getNthItem} from '@react-stately/collections';
13+
import {getChildNodes, getFirstItem} from '@react-stately/collections';
1414
import {GridKeyboardDelegate} from '@react-aria/grid';
1515
import {Key, Node} from '@react-types/shared';
1616
import {TableCollection} from '@react-types/table';
@@ -44,7 +44,8 @@ export class TableKeyboardDelegate<T> extends GridKeyboardDelegate<T, TableColle
4444
if (!firstItem) {
4545
return null;
4646
}
47-
return getNthItem(getChildNodes(firstItem, this.collection), startItem.index)?.key ?? null;
47+
48+
return super.getKeyForItemInRowByIndex(firstKey, startItem.index);
4849
}
4950

5051
return super.getKeyBelow(key);

packages/@react-aria/table/src/useTableColumnHeader.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ export function useTableColumnHeader<T>(props: AriaTableColumnHeaderProps<T>, st
9999
),
100100
role: 'columnheader',
101101
id: getColumnHeaderId(state, node.key),
102-
'aria-colspan': node.colspan && node.colspan > 1 ? node.colspan : undefined,
102+
'aria-colspan': node.colSpan && node.colSpan > 1 ? node.colSpan : undefined,
103103
'aria-sort': ariaSort
104104
}
105105
};

packages/@react-aria/table/stories/example-backwards-compat.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,9 +103,8 @@ export function TableColumnHeader({column, state}) {
103103
return (
104104
<th
105105
{...mergeProps(columnHeaderProps, focusProps)}
106-
colSpan={column.colspan}
107106
style={{
108-
textAlign: column.colspan > 1 ? 'center' : 'left',
107+
textAlign: column.colSpan > 1 ? 'center' : 'left',
109108
padding: '5px 10px',
110109
outline: isFocusVisible ? '2px solid orange' : 'none',
111110
cursor: 'default'

packages/@react-aria/table/stories/example-resizing.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,10 +171,9 @@ export function TableColumnHeader({column, state, layout, onResizeStart, onResiz
171171
return (
172172
<th
173173
{...mergeProps(columnHeaderProps, focusProps)}
174-
colSpan={column.colspan}
175174
style={{
176175
width: layout.getColumnWidth(column.key),
177-
textAlign: column.colspan > 1 ? 'center' : 'left',
176+
textAlign: column.colSpan > 1 ? 'center' : 'left',
178177
padding: '5px 10px',
179178
outline: isFocusVisible ? '2px solid orange' : 'none',
180179
cursor: 'default',

packages/@react-aria/table/stories/example.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,8 @@ export function TableColumnHeader({column, state}) {
102102
return (
103103
<th
104104
{...mergeProps(columnHeaderProps, focusProps)}
105-
colSpan={column.colspan}
106105
style={{
107-
textAlign: column.colspan > 1 ? 'center' : 'left',
106+
textAlign: column.colSpan > 1 ? 'center' : 'left',
108107
padding: '5px 10px',
109108
outline: isFocusVisible ? '2px solid orange' : 'none',
110109
cursor: 'default'

packages/@react-spectrum/table/src/TableViewBase.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,7 @@ function TableViewBase<T extends object>(props: TableBaseProps<T>, ref: DOMRef<H
337337
<div
338338
role="gridcell"
339339
aria-colindex={item.index + 1}
340-
aria-colspan={item.colspan != null && item.colspan > 1 ? item.colspan : undefined} />
340+
aria-colspan={item.colSpan != null && item.colSpan > 1 ? item.colSpan : undefined} />
341341
);
342342
case 'column':
343343
if (item.props.isSelectionCell) {
@@ -783,7 +783,7 @@ function TableColumnHeader(props) {
783783
stylesOverrides,
784784
'react-spectrum-Table-cell',
785785
{
786-
'react-spectrum-Table-cell--alignCenter': columnProps.align === 'center' || column.colspan > 1,
786+
'react-spectrum-Table-cell--alignCenter': columnProps.align === 'center' || column.colSpan > 1,
787787
'react-spectrum-Table-cell--alignEnd': columnProps.align === 'end'
788788
}
789789
)
@@ -914,7 +914,7 @@ function ResizableTableColumnHeader(props) {
914914
let showResizer = !isEmpty && ((headerRowHovered && getInteractionModality() !== 'keyboard') || resizingColumn != null);
915915
let alignment = 'start';
916916
let menuAlign = 'start' as 'start' | 'end';
917-
if (columnProps.align === 'center' || column.colspan > 1) {
917+
if (columnProps.align === 'center' || column.colSpan > 1) {
918918
alignment = 'center';
919919
} else if (columnProps.align === 'end') {
920920
alignment = 'end';

packages/@react-spectrum/table/stories/Table.stories.tsx

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,105 @@ export const DynamicNestedColumnsWithResizing: TableStory = {
423423
name: 'dynamic with nested columns with resizing'
424424
};
425425

426+
427+
let timeTableColumns = [
428+
{name: 'Time', key: 'time', isRowHeader: true},
429+
{name: 'Monday', key: 'monday'},
430+
{name: 'Tuesday', key: 'tuesday'},
431+
{name: 'Wednesday', key: 'wednesday'},
432+
{name: 'Thursday', key: 'thursday'},
433+
{name: 'Friday', key: 'friday'}
434+
];
435+
436+
437+
let timeTableRows = [
438+
{id: 9, time: '09:00 - 10:00', name: 'Break', type: 'break'},
439+
{id: 1, time: '08:00 - 09:00', monday: 'Math', tuesday: 'History', wednesday: 'Science', thursday: 'English', friday: 'Art'},
440+
{id: 2, time: '09:00 - 10:00', name: 'Break', type: 'break'},
441+
{id: 3, time: '10:00 - 11:00', monday: 'Math', tuesday: 'History', wednesday: 'Science', thursday: 'English', friday: 'Art'},
442+
{id: 4, time: '11:00 - 12:00', monday: 'Math', tuesday: 'History', wednesday: 'Science', thursday: 'English', friday: 'Art'},
443+
{id: 5, time: '12:00 - 13:00', name: 'Break', type: 'break'},
444+
{id: 6, time: '13:00 - 14:00', monday: 'History', tuesday: 'Math', wednesday: 'English', thursday: 'Science', friday: 'Art'}
445+
];
446+
447+
export const TableColSpanExample = () => {
448+
return (
449+
<TableView aria-label="Timetable">
450+
<TableHeader columns={timeTableColumns}>
451+
{(column) => (
452+
<Column isRowHeader={column.isRowHeader}>{column.name}</Column>
453+
)}
454+
</TableHeader>
455+
<TableBody items={timeTableRows}>
456+
{(item) => (
457+
<Row key={item.id}>
458+
{item.type === 'break' ? (
459+
[
460+
<Cell>{item.time}</Cell>,
461+
<Cell colSpan={5}>{item.name}</Cell>
462+
]) : ([
463+
<Cell>{item.time}</Cell>,
464+
<Cell>{item.monday}</Cell>,
465+
<Cell>{item.tuesday}</Cell>,
466+
<Cell>{item.wednesday}</Cell>,
467+
<Cell>{item.thursday}</Cell>,
468+
<Cell>{item.friday}</Cell>]
469+
)}
470+
</Row>
471+
)}
472+
</TableBody>
473+
</TableView>
474+
);
475+
};
476+
477+
export const TableCellColSpanWithVariousSpansExample = () => {
478+
return (
479+
<TableView aria-label="Table with various colspans">
480+
<TableHeader>
481+
<Column isRowHeader>Col 1</Column>
482+
<Column >Col 2</Column>
483+
<Column >Col 3</Column>
484+
<Column >Col 4</Column>
485+
</TableHeader>
486+
<TableBody>
487+
<Row>
488+
<Cell>Cell</Cell>
489+
<Cell colSpan={2}>Span 2</Cell>
490+
<Cell>Cell</Cell>
491+
</Row>
492+
<Row>
493+
<Cell>Cell</Cell>
494+
<Cell>Cell</Cell>
495+
<Cell>Cell</Cell>
496+
<Cell>Cell</Cell>
497+
</Row>
498+
<Row>
499+
<Cell colSpan={4}>Span 4</Cell>
500+
</Row>
501+
<Row>
502+
<Cell>Cell</Cell>
503+
<Cell>Cell</Cell>
504+
<Cell>Cell</Cell>
505+
<Cell>Cell</Cell>
506+
</Row>
507+
<Row>
508+
<Cell colSpan={3}>Span 3</Cell>
509+
<Cell>Cell</Cell>
510+
</Row>
511+
<Row>
512+
<Cell>Cell</Cell>
513+
<Cell>Cell</Cell>
514+
<Cell>Cell</Cell>
515+
<Cell>Cell</Cell>
516+
</Row>
517+
<Row>
518+
<Cell>Cell</Cell>
519+
<Cell colSpan={3}>Span 3</Cell>
520+
</Row>
521+
</TableBody>
522+
</TableView>
523+
);
524+
};
426525
export const FocusableCells: TableStory = {
427526
args: {
428527
'aria-label': 'TableView with focusable cells',

0 commit comments

Comments
 (0)