diff --git a/packages/mdc-data-table/README.md b/packages/mdc-data-table/README.md index 6d9cc7f13f0..f2ae1be6674 100644 --- a/packages/mdc-data-table/README.md +++ b/packages/mdc-data-table/README.md @@ -370,6 +370,13 @@ Method Signature | Description `setHeaderRowCheckboxChecked(checked: boolean) => void` | Sets header row checkbox checked or unchecked. `setHeaderRowCheckboxIndeterminate(indeterminate: boolean) => void` | Sets header row checkbox to indeterminate. `setRowCheckboxCheckedAtIndex(rowIndex: number, checked: boolean) => void` | Sets row checkbox to checked or unchecked at given row index. +`getHeaderCellCount(): number;` | Returns total count of header cells. +`getHeaderCellElements(): Element[];` | Returns array of header cell elements. +`getAttributeByHeaderCellIndex(columnIndex: number, attribute: string) => string` | Returns attribute value for given header cell index. +`setAttributeByHeaderCellIndex(columnIndex: number, attribute: string, value: string) => void` | Sets attribute of a header cell by index. +`setClassNameByHeaderCellIndex(columnIndex: number, className: string) => void` | Sets class name of a header cell by index. +`removeClassNameByHeaderCellIndex(columnIndex: number, className: string) => void` | Removes a class name of a header cell by index. +`notifySortAction(data: SortActionEventDetail) => void` | Notifies when column is sorted. ### `MDCDataTableFoundation` @@ -382,3 +389,5 @@ Method Signature | Description `getSelectedRowIds() => Array` | Returns array of selected row ids. `handleHeaderRowCheckboxChange() => void` | Handles header row checkbox change event. `handleRowCheckboxChange(event: Event) => void` | Handles change event originated from row checkboxes. +`getHeaderCells() => Elements[]` | Returns array of header cell elements. +`handleSortAction(eventData: SortActionEventData) => void` | Handles sort action on sortable header cell. diff --git a/packages/mdc-data-table/adapter.ts b/packages/mdc-data-table/adapter.ts index a2e39123a7e..e9b9217c2fe 100644 --- a/packages/mdc-data-table/adapter.ts +++ b/packages/mdc-data-table/adapter.ts @@ -29,7 +29,7 @@ * https://github.com/material-components/material-components-web/blob/master/docs/code/architecture.md */ -import {MDCDataTableRowSelectionChangedEventDetail} from './types'; +import {MDCDataTableRowSelectionChangedEventDetail, SortActionEventDetail} from './types'; export interface MDCDataTableAdapter { /** @@ -156,4 +156,42 @@ export interface MDCDataTableAdapter { * @param checked True to set checked. */ setRowCheckboxCheckedAtIndex(rowIndex: number, checked: boolean): void; + + /** + * @return Total count of header cells. + */ + getHeaderCellCount(): number; + + /** + * @return Array of header cell elements. + */ + getHeaderCellElements(): Element[]; + + /** + * @return Attribute value for given header cell index. + */ + getAttributeByHeaderCellIndex(columnIndex: number, attribute: string): string + |null; + + /** + * Sets attribute of a header cell by index. + */ + setAttributeByHeaderCellIndex( + columnIndex: number, attribute: string, value: string): void; + + /** + * Sets class name of a header cell by index. + */ + setClassNameByHeaderCellIndex(columnIndex: number, className: string): void; + + /** + * Removes a class name of a header cell by index. + */ + removeClassNameByHeaderCellIndex(columnIndex: number, className: string): + void; + + /** + * Notifies when column is sorted. + */ + notifySortAction(data: SortActionEventDetail): void; } diff --git a/packages/mdc-data-table/component.ts b/packages/mdc-data-table/component.ts index 894e3604542..b6e5e071dfc 100644 --- a/packages/mdc-data-table/component.ts +++ b/packages/mdc-data-table/component.ts @@ -100,19 +100,27 @@ export class MDCDataTable extends MDCComponent { // DO NOT INLINE this variable. For backward compatibility, foundations take a Partial. // To ensure we don't accidentally omit any methods, we need a separate, strongly typed adapter variable. // tslint:disable:object-literal-sort-keys Methods should be in the same order as the adapter interface. - const adapter: MDCDataTableAdapter = { - addClassAtRowIndex: (rowIndex: number, className: string) => this.getRows()[rowIndex].classList.add(className), + const adapter: Partial = { + addClassAtRowIndex: (rowIndex: number, className: string) => { + this.getRows()[rowIndex].classList.add(className); + }, getRowCount: () => this.getRows().length, - getRowElements: () => [].slice.call(this.root_.querySelectorAll(strings.ROW_SELECTOR)), - getRowIdAtIndex: (rowIndex: number) => this.getRows()[rowIndex].getAttribute(strings.DATA_ROW_ID_ATTR), + getRowElements: () => [].slice.call( + this.root_.querySelectorAll(strings.ROW_SELECTOR)), + getRowIdAtIndex: (rowIndex: number) => + this.getRows()[rowIndex].getAttribute(strings.DATA_ROW_ID_ATTR), getRowIndexByChildElement: (el: Element) => { return this.getRows().indexOf((closest(el, strings.ROW_SELECTOR) as HTMLElement)); }, - getSelectedRowCount: () => this.root_.querySelectorAll(strings.ROW_SELECTED_SELECTOR).length, - isCheckboxAtRowIndexChecked: (rowIndex: number) => this.rowCheckboxList_[rowIndex].checked, + getSelectedRowCount: () => + this.root_.querySelectorAll(strings.ROW_SELECTED_SELECTOR).length, + isCheckboxAtRowIndexChecked: (rowIndex: number) => + this.rowCheckboxList_[rowIndex].checked, isHeaderRowCheckboxChecked: () => this.headerRowCheckbox_.checked, - isRowsSelectable: () => !!this.root_.querySelector(strings.ROW_CHECKBOX_SELECTOR), - notifyRowSelectionChanged: (data: MDCDataTableRowSelectionChangedEventDetail) => { + isRowsSelectable: () => + !!this.root_.querySelector(strings.ROW_CHECKBOX_SELECTOR), + notifyRowSelectionChanged: ( + data: MDCDataTableRowSelectionChangedEventDetail) => { this.emit(events.ROW_SELECTION_CHANGED, { row: this.getRowByIndex_(data.rowIndex), rowId: this.getRowIdByIndex_(data.rowIndex), @@ -121,8 +129,12 @@ export class MDCDataTable extends MDCComponent { }, /** shouldBubble */ true); }, - notifySelectedAll: () => this.emit(events.SELECTED_ALL, {}, /** shouldBubble */ true), - notifyUnselectedAll: () => this.emit(events.UNSELECTED_ALL, {}, /** shouldBubble */ true), + notifySelectedAll: () => { + this.emit(events.SELECTED_ALL, {}, /** shouldBubble */ true); + }, + notifyUnselectedAll: () => { + this.emit(events.UNSELECTED_ALL, {}, /** shouldBubble */ true); + }, registerHeaderRowCheckbox: () => { if (this.headerRowCheckbox_) { this.headerRowCheckbox_.destroy(); @@ -145,9 +157,10 @@ export class MDCDataTable extends MDCComponent { removeClassAtRowIndex: (rowIndex: number, className: string) => { this.getRows()[rowIndex].classList.remove(className); }, - setAttributeAtRowIndex: (rowIndex: number, attr: string, value: string) => { - this.getRows()[rowIndex].setAttribute(attr, value); - }, + setAttributeAtRowIndex: + (rowIndex: number, attr: string, value: string) => { + this.getRows()[rowIndex].setAttribute(attr, value); + }, setHeaderRowCheckboxChecked: (checked: boolean) => { this.headerRowCheckbox_.checked = checked; }, diff --git a/packages/mdc-data-table/constants.ts b/packages/mdc-data-table/constants.ts index 07654c0e86d..aa7ffac124c 100644 --- a/packages/mdc-data-table/constants.ts +++ b/packages/mdc-data-table/constants.ts @@ -21,29 +21,72 @@ * THE SOFTWARE. */ +/** + * CSS class names used in component. + */ export const cssClasses = { CELL: 'mdc-data-table__cell', CELL_NUMERIC: 'mdc-data-table__cell--numeric', CONTENT: 'mdc-data-table__content', + HEADER_CELL_SORTED: 'mdc-data-table__header-cell--sorted', + HEADER_CELL_SORTED_DESCENDING: + 'mdc-data-table__header-cell--sorted-descending', + HEADER_CELL_WITH_SORT: 'mdc-data-table__header-cell--with-sort', HEADER_ROW: 'mdc-data-table__header-row', HEADER_ROW_CHECKBOX: 'mdc-data-table__header-row-checkbox', ROOT: 'mdc-data-table', ROW: 'mdc-data-table__row', ROW_CHECKBOX: 'mdc-data-table__row-checkbox', ROW_SELECTED: 'mdc-data-table__row--selected', + SORT_ICON_BUTTON: 'mdc-data-table__sort-icon-button', +}; + +/** + * List of data attributes used in component. + */ +export const dataAttributes = { + ROW_ID: 'data-row-id', + COLUMND_ID: 'data-columnd-id', }; +/** + * Attributes and selectors used in component. + */ export const strings = { ARIA_SELECTED: 'aria-selected', - DATA_ROW_ID_ATTR: 'data-row-id', + ARIA_SORT: 'aria-sort', + DATA_ROW_ID_ATTR: + dataAttributes.ROW_ID, // deprecated. Moved to `dataAttributes`. HEADER_ROW_CHECKBOX_SELECTOR: `.${cssClasses.HEADER_ROW_CHECKBOX}`, ROW_CHECKBOX_SELECTOR: `.${cssClasses.ROW_CHECKBOX}`, ROW_SELECTED_SELECTOR: `.${cssClasses.ROW_SELECTED}`, ROW_SELECTOR: `.${cssClasses.ROW}`, }; +/** + * Sort values defined by ARIA. + * See https://www.w3.org/WAI/PF/aria/states_and_properties#aria-sort + */ +export enum SortValue { + // Items are sorted in ascending order by this column. + ASCENDING = 'ascending', + + // Items are sorted in descending order by this column. + DESCENDING = 'descending', + + // There is no defined sort applied to the column. + NONE = 'none', + + // A sort algorithm other than ascending or descending has been applied. + OTHER = 'other', +} + +/** + * Event names used in component. + */ export const events = { ROW_SELECTION_CHANGED: 'MDCDataTable:rowSelectionChanged', SELECTED_ALL: 'MDCDataTable:selectedAll', UNSELECTED_ALL: 'MDCDataTable:unselectedAll', + SORTED: 'MDCDataTable:sorted', }; diff --git a/packages/mdc-data-table/foundation.ts b/packages/mdc-data-table/foundation.ts index 75634edcca8..3812a2ab423 100644 --- a/packages/mdc-data-table/foundation.ts +++ b/packages/mdc-data-table/foundation.ts @@ -22,13 +22,18 @@ */ import {MDCFoundation} from '@material/base/foundation'; + import {MDCDataTableAdapter} from './adapter'; -import {cssClasses, strings} from './constants'; +import {cssClasses, SortValue, strings} from './constants'; +import {SortActionEventData} from './types'; export class MDCDataTableFoundation extends MDCFoundation { static get defaultAdapter(): MDCDataTableAdapter { return { addClassAtRowIndex: () => undefined, + getAttributeByHeaderCellIndex: () => '', + getHeaderCellCount: () => 0, + getHeaderCellElements: () => [], getRowCount: () => 0, getRowElements: () => [], getRowIdAtIndex: () => '', @@ -39,11 +44,15 @@ export class MDCDataTableFoundation extends MDCFoundation { isRowsSelectable: () => false, notifyRowSelectionChanged: () => undefined, notifySelectedAll: () => undefined, + notifySortAction: () => undefined, notifyUnselectedAll: () => undefined, registerHeaderRowCheckbox: () => undefined, registerRowCheckboxes: () => undefined, removeClassAtRowIndex: () => undefined, + removeClassNameByHeaderCellIndex: () => undefined, setAttributeAtRowIndex: () => undefined, + setAttributeByHeaderCellIndex: () => undefined, + setClassNameByHeaderCellIndex: () => undefined, setHeaderRowCheckboxChecked: () => undefined, setHeaderRowCheckboxIndeterminate: () => undefined, setRowCheckboxCheckedAtIndex: () => undefined, @@ -87,6 +96,13 @@ export class MDCDataTableFoundation extends MDCFoundation { return this.adapter_.getRowElements(); } + /** + * @return Array of header cell elements. + */ + getHeaderCells(): Element[] { + return this.adapter_.getHeaderCellElements(); + } + /** * Sets selected row ids. Overwrites previously selected rows. * @param rowIds Array of row ids that needs to be selected. @@ -158,6 +174,63 @@ export class MDCDataTableFoundation extends MDCFoundation { this.adapter_.notifyRowSelectionChanged({rowId, rowIndex, selected}); } + /** + * Handles sort action on sortable header cell. + */ + handleSortAction(eventData: SortActionEventData) { + const {columnId, columnIndex, headerCell} = eventData; + + // Reset sort attributes / classes on other header cells. + for (let index = 0; index < this.adapter_.getHeaderCellCount(); index++) { + if (index === columnIndex) { + continue; + } + + this.adapter_.removeClassNameByHeaderCellIndex( + index, cssClasses.HEADER_CELL_SORTED); + this.adapter_.removeClassNameByHeaderCellIndex( + index, cssClasses.HEADER_CELL_SORTED_DESCENDING); + this.adapter_.setAttributeByHeaderCellIndex( + index, strings.ARIA_SORT, SortValue.NONE); + } + + // Set appropriate sort attributes / classes on target header cell. + this.adapter_.setClassNameByHeaderCellIndex( + columnIndex, cssClasses.HEADER_CELL_SORTED); + + const currentSortValue = this.adapter_.getAttributeByHeaderCellIndex( + columnIndex, strings.ARIA_SORT); + let sortValue = SortValue.NONE; + + // Set to descending if sorted on ascending order. + if (currentSortValue === SortValue.ASCENDING) { + this.adapter_.setClassNameByHeaderCellIndex( + columnIndex, cssClasses.HEADER_CELL_SORTED_DESCENDING); + this.adapter_.setAttributeByHeaderCellIndex( + columnIndex, strings.ARIA_SORT, SortValue.DESCENDING); + sortValue = SortValue.DESCENDING; + // Set to ascending if sorted on descending order. + } else if (currentSortValue === SortValue.DESCENDING) { + this.adapter_.removeClassNameByHeaderCellIndex( + columnIndex, cssClasses.HEADER_CELL_SORTED_DESCENDING); + this.adapter_.setAttributeByHeaderCellIndex( + columnIndex, strings.ARIA_SORT, SortValue.ASCENDING); + sortValue = SortValue.ASCENDING; + } else { + // Set to ascending by default when not sorted. + this.adapter_.setAttributeByHeaderCellIndex( + columnIndex, strings.ARIA_SORT, SortValue.ASCENDING); + sortValue = SortValue.ASCENDING; + } + + this.adapter_.notifySortAction({ + columnId, + columnIndex, + headerCell, + sortValue, + }); + } + /** * Updates header row checkbox state based on number of rows selected. */ diff --git a/packages/mdc-data-table/test/foundation.test.ts b/packages/mdc-data-table/test/foundation.test.ts index b991d3e4e76..42bf89813d2 100644 --- a/packages/mdc-data-table/test/foundation.test.ts +++ b/packages/mdc-data-table/test/foundation.test.ts @@ -23,31 +23,38 @@ import {verifyDefaultAdapter} from '../../../testing/helpers/foundation'; import {setUpFoundationTest} from '../../../testing/helpers/setup'; -import {cssClasses, strings} from '../constants'; +import {cssClasses, SortValue, strings} from '../constants'; import {MDCDataTableFoundation} from '../foundation'; describe('MDCDataTableFoundation', () => { it('default adapter returns a complete adapter implementation', () => { verifyDefaultAdapter(MDCDataTableFoundation, [ - 'isRowsSelectable', - 'registerHeaderRowCheckbox', - 'registerRowCheckboxes', + 'addClassAtRowIndex', + 'getAttributeByHeaderCellIndex', + 'getHeaderCellCount', + 'getHeaderCellElements', + 'getRowCount', 'getRowElements', + 'getRowIdAtIndex', + 'getRowIndexByChildElement', + 'getSelectedRowCount', 'isCheckboxAtRowIndexChecked', 'isHeaderRowCheckboxChecked', - 'getRowCount', - 'getSelectedRowCount', - 'addClassAtRowIndex', + 'isRowsSelectable', + 'notifyRowSelectionChanged', + 'notifySelectedAll', + 'notifySortAction', + 'notifyUnselectedAll', + 'registerHeaderRowCheckbox', + 'registerRowCheckboxes', 'removeClassAtRowIndex', + 'removeClassNameByHeaderCellIndex', 'setAttributeAtRowIndex', - 'getRowIndexByChildElement', - 'setHeaderRowCheckboxIndeterminate', + 'setAttributeByHeaderCellIndex', + 'setClassNameByHeaderCellIndex', 'setHeaderRowCheckboxChecked', - 'getRowIdAtIndex', + 'setHeaderRowCheckboxIndeterminate', 'setRowCheckboxCheckedAtIndex', - 'notifyRowSelectionChanged', - 'notifySelectedAll', - 'notifyUnselectedAll', ]); }); @@ -315,4 +322,111 @@ describe('MDCDataTableFoundation', () => { selected: false, }); }); + + it('#handleSortAction Sets header cell in ascending sorted state by default on sort action', + () => { + const {foundation, mockAdapter} = setupTest(); + mockAdapter.getAttributeByHeaderCellIndex.withArgs(2, strings.ARIA_SORT) + .and.returnValue(null); + mockAdapter.getHeaderCellCount.and.returnValue(5); + + const mockHeaderCell = document.createElement('div'); + foundation.handleSortAction({ + columnId: 'testColId-u2', + columnIndex: 2, + headerCell: mockHeaderCell, + }); + + expect(mockAdapter.setClassNameByHeaderCellIndex) + .toHaveBeenCalledWith(2, cssClasses.HEADER_CELL_SORTED); + expect(mockAdapter.setAttributeByHeaderCellIndex) + .toHaveBeenCalledWith(2, strings.ARIA_SORT, SortValue.ASCENDING); + expect(mockAdapter.notifySortAction).toHaveBeenCalledWith({ + columnId: 'testColId-u2', + columnIndex: 2, + headerCell: mockHeaderCell, + sortValue: SortValue.ASCENDING, + }); + }); + + it('#handleSortAction Sets header cell in descending sorted state when currently sorted in ascending order on sort action', + () => { + const {foundation, mockAdapter} = setupTest(); + mockAdapter.getAttributeByHeaderCellIndex.withArgs(2, strings.ARIA_SORT) + .and.returnValue(SortValue.ASCENDING); + mockAdapter.getHeaderCellCount.and.returnValue(5); + + const mockHeaderCell = document.createElement('div'); + foundation.handleSortAction({ + columnId: 'testColId-u2', + columnIndex: 2, + headerCell: mockHeaderCell, + }); + + expect(mockAdapter.setClassNameByHeaderCellIndex) + .toHaveBeenCalledWith(2, cssClasses.HEADER_CELL_SORTED); + expect(mockAdapter.setAttributeByHeaderCellIndex) + .toHaveBeenCalledWith(2, strings.ARIA_SORT, SortValue.DESCENDING); + expect(mockAdapter.setClassNameByHeaderCellIndex) + .toHaveBeenCalledWith(2, cssClasses.HEADER_CELL_SORTED_DESCENDING); + expect(mockAdapter.notifySortAction).toHaveBeenCalledWith({ + columnId: 'testColId-u2', + columnIndex: 2, + headerCell: mockHeaderCell, + sortValue: SortValue.DESCENDING, + }); + }); + + it('#handleSortAction Sets header cell in ascending sorted state when currently sorted in descending order on sort action', + () => { + const {foundation, mockAdapter} = setupTest(); + mockAdapter.getAttributeByHeaderCellIndex.withArgs(2, strings.ARIA_SORT) + .and.returnValue(SortValue.DESCENDING); + mockAdapter.getHeaderCellCount.and.returnValue(5); + + const mockHeaderCell = document.createElement('div'); + foundation.handleSortAction({ + columnId: 'testColId-u2', + columnIndex: 2, + headerCell: mockHeaderCell, + }); + + expect(mockAdapter.setClassNameByHeaderCellIndex) + .toHaveBeenCalledWith(2, cssClasses.HEADER_CELL_SORTED); + expect(mockAdapter.setAttributeByHeaderCellIndex) + .toHaveBeenCalledWith(2, strings.ARIA_SORT, SortValue.ASCENDING); + expect(mockAdapter.removeClassNameByHeaderCellIndex) + .toHaveBeenCalledWith(2, cssClasses.HEADER_CELL_SORTED_DESCENDING); + expect(mockAdapter.notifySortAction).toHaveBeenCalledWith({ + columnId: 'testColId-u2', + columnIndex: 2, + headerCell: mockHeaderCell, + sortValue: SortValue.ASCENDING, + }); + }); + + it('#handleSortAction Resets sort states of other header cells when sorted on target header cell', + () => { + const {foundation, mockAdapter} = setupTest(); + mockAdapter.getAttributeByHeaderCellIndex.withArgs(2, strings.ARIA_SORT) + .and.returnValue(null); + mockAdapter.getHeaderCellCount.and.returnValue(5); + + const mockHeaderCell = document.createElement('div'); + foundation.handleSortAction({ + columnId: 'testColId-u2', + columnIndex: 2, + headerCell: mockHeaderCell, + }); + + expect(mockAdapter.removeClassNameByHeaderCellIndex) + .toHaveBeenCalledWith( + jasmine.any(Number), cssClasses.HEADER_CELL_SORTED); + expect(mockAdapter.removeClassNameByHeaderCellIndex) + .toHaveBeenCalledWith( + jasmine.any(Number), cssClasses.HEADER_CELL_SORTED_DESCENDING); + expect(mockAdapter.setAttributeByHeaderCellIndex) + .toHaveBeenCalledWith( + jasmine.any(Number), strings.ARIA_SORT, SortValue.NONE); + }); }); diff --git a/packages/mdc-data-table/types.ts b/packages/mdc-data-table/types.ts index dd2eecd56f6..c458394c90f 100644 --- a/packages/mdc-data-table/types.ts +++ b/packages/mdc-data-table/types.ts @@ -21,8 +21,32 @@ * THE SOFTWARE. */ +import {SortValue} from './constants'; + export interface MDCDataTableRowSelectionChangedEventDetail { rowIndex: number; rowId: string | null; selected: boolean; } + +/** + * Event data required for sort action callback - `handleSortAction()`. + * Component must send this data to foundation when sort action triggered on + * sortable header cell. + */ +export interface SortActionEventData { + columnId: string|null; + columnIndex: number; + headerCell: HTMLElement; +} + +/** + * Event detail triggered by foundation on sort action. This event detail is + * used to trigger DOM event by component. + */ +export interface SortActionEventDetail { + columnId: string|null; + columnIndex: number; + headerCell: HTMLElement; + sortValue: SortValue; +}