diff options
Diffstat (limited to 'app/common/components')
-rw-r--r-- | app/common/components/table/table-checkbox.js | 67 | ||||
-rw-r--r-- | app/common/components/table/table-toolbar.js | 112 | ||||
-rw-r--r-- | app/common/components/table/table.html | 223 | ||||
-rw-r--r-- | app/common/components/table/table.js | 170 |
4 files changed, 449 insertions, 123 deletions
diff --git a/app/common/components/table/table-checkbox.js b/app/common/components/table/table-checkbox.js new file mode 100644 index 0000000..b15e4ed --- /dev/null +++ b/app/common/components/table/table-checkbox.js @@ -0,0 +1,67 @@ +window.angular && (function(angular) { + 'use strict'; + + /** + * + * tableCheckbox Component + * + */ + + const controller = function($element) { + // <input> element ref needed to add indeterminate state + let inputEl; + + /** + * Callback when the input select value changes + */ + this.onSelectChange = () => { + const checked = this.ngModel; + this.emitChange({checked}); + }; + + /** + * onChanges Component lifecycle hook + */ + this.$onChanges = (onChangesObj) => { + const indeterminateChange = onChangesObj.indeterminate; + if (indeterminateChange && inputEl) { + inputEl.prop('indeterminate', this.indeterminate); + } + }; + + /** + * postLink Component lifecycle hook + */ + this.$postLink = () => { + inputEl = $element.find('input'); + }; + }; + + /** + * Component template + */ + const template = ` + <div class="bmc-table__checkbox-container"> + <label class="bmc-table__checkbox" + ng-class="{ + 'checked': $ctrl.ngModel, + 'indeterminate': $ctrl.indeterminate + }"> + <input type="checkbox" + class="bmc-table__checkbox-input" + ng-model="$ctrl.ngModel" + ng-change="$ctrl.onSelectChange()" + aria-label="Select row"/> + <span class="screen-reader-offscreen">Select row</span> + </label> + </div>` + + /** + * Register tableCheckbox component + */ + angular.module('app.common.components').component('tableCheckbox', { + controller: ['$element', controller], + template, + bindings: {ngModel: '=', indeterminate: '<', emitChange: '&'} + }) +})(window.angular);
\ No newline at end of file diff --git a/app/common/components/table/table-toolbar.js b/app/common/components/table/table-toolbar.js new file mode 100644 index 0000000..830bbd1 --- /dev/null +++ b/app/common/components/table/table-toolbar.js @@ -0,0 +1,112 @@ +window.angular && (function(angular) { + 'use strict'; + + /** + * + * tableToolbar Component + * + * To use: + * The <table-toolbar> component expects an 'actions' attribute + * that should be an array of action objects. + * Each action object should have 'type', 'label', and 'file' + * properties. + * + * actions: [ + * {type: 'Edit', label: 'Edit', file: 'icon-edit.svg'}, + * {type: 'Delete', label: 'Edit', file: 'icon-trashcan.svg'} + * ] + * + * The 'type' property is a string value that will be emitted to the + * parent component when clicked. + * + * The 'label' property is a string value that will render as text in + * the button + * + * The 'file' property is a string value of the filename of the svg icon + * to provide <icon> directive. + * + */ + + const controller = function() { + /** + * Set defaults if properties undefined + * @param {[]} actions + */ + const setActions = (actions = []) => { + return actions + .map((action) => { + if (action.type === undefined) { + return; + } + if (action.file === undefined) { + action.file = null; + } + return action; + }) + .filter((action) => action); + }; + + /** + * Callback when button action clicked + * Emits the action type to the parent component + */ + this.onClick = (action) => { + this.emitAction({action}); + }; + + this.onClickClose = () => { + this.emitClose(); + }; + + /** + * onInit Component lifecycle hook + */ + this.$onInit = () => { + this.actions = setActions(this.actions); + }; + }; + + /** + * Component template + */ + const template = ` + <div class="bmc-table__toolbar" ng-if="$ctrl.active"> + <p class="toolbar__selection-count">{{$ctrl.selectionCount}} selected</p> + <div class="toolbar__batch-actions" ng-show="$ctrl.actions.length > 0"> + <button + class="btn btn-tertiary" + type="button" + aria-label="{{action.label}}" + ng-repeat="action in $ctrl.actions" + ng-click="$ctrl.onClick(action.type)"> + <icon ng-if="action.file !== null" + ng-file="{{action.file}}" + aria-hidden="true"> + </icon> + {{action.label || action.type}} + </button> + <button + class="btn btn-tertiary btn-close" + type="button" + aria-label="Cancel" + ng-click="$ctrl.onClickClose()"> + Cancel + </button> + </div> + </div>` + + /** + * Register tableToolbar component + */ + angular.module('app.common.components').component('tableToolbar', { + controller, + template, + bindings: { + actions: '<', + selectionCount: '<', + active: '<', + emitAction: '&', + emitClose: '&' + } + }) +})(window.angular);
\ No newline at end of file diff --git a/app/common/components/table/table.html b/app/common/components/table/table.html index 96ca870..7d906a1 100644 --- a/app/common/components/table/table.html +++ b/app/common/components/table/table.html @@ -1,94 +1,131 @@ -<table class="bmc-table {{$ctrl.size}}"> - <thead class="bmc-table__head"> - <!-- Header row (non-sortable) --> - <tr ng-if="!$ctrl.sortable"> - <th ng-repeat="headerItem in $ctrl.header" - class="bmc-table__column-header"> - {{headerItem.label}} - </th> - </tr> - <!-- Header row (sortable) --> - <tr ng-if="$ctrl.sortable"> - <th ng-repeat="headerItem in $ctrl.header track by $index" - class="bmc-table__column-header"> - <span ng-if="!headerItem.sortable"> - {{headerItem.label}} - </span> - <span ng-if="headerItem.sortable" - ng-click="$ctrl.onClickSort($index)" - class="bmc-table__column-header--sortable"> - {{headerItem.label}} - <!-- Sort icons --> - <button class="sort-icon" - type="button" - aria-label="sort {{headerItem.label}}"> - <icon file="icon-arrow--up.svg" - ng-if="$index === $ctrl.activeSort" - ng-class="{ - 'sort-icon--descending': !$ctrl.sortAscending, - 'sort-icon--ascending' : $ctrl.sortAscending }" - class="sort-icon--active" - aria-hidden="true"></icon> - <span ng-if="$index !== $ctrl.activeSort" - class="sort-icon--inactive" - aria-hidden="true"> - <icon file="icon-arrow--up.svg"></icon> - <icon file="icon-arrow--down.svg"></icon> - </span> +<div class="bmc-table__container"> + <table-toolbar ng-if="$ctrl.selectable" + selection-count="$ctrl.selectedRows.size" + active="$ctrl.selectedRows.size > 0" + actions="$ctrl.batchActions" + emit-action="$ctrl.onEmitBatchAction(action)" + emit-close="$ctrl.onToolbarClose()" + ng-animate-children="true"> + </table-toolbar> + <table class="bmc-table {{$ctrl.size}}" + ng-class="{ + 'bmc-table--sortable': $ctrl.sortable, + 'bmc-table--expandable': $ctrl.expandable, + 'bmc-table--selectable': $ctrl.selectable, + 'bmc-table--row-actions-enabled': '$ctrl.rowActionsEnabled', + }"> + <thead class="bmc-table__head"> + <!-- Header row --> + <tr> + <!-- Expandable empty cell --> + <th ng-if="$ctrl.expandable" + class="bmc-table__column-header"></th> + <!-- Select checkbox --> + <th ng-if="$ctrl.selectable" + class="bmc-table__column-header"> + <table-checkbox ng-model="$ctrl.selectHeaderCheckbox" + indeterminate="$ctrl.someSelected" + emit-change="$ctrl.onHeaderSelectChange(checked)"> + </table-checkbox> + </th> + <!-- Header items --> + <th ng-repeat="headerItem in $ctrl.header track by $index" + class="bmc-table__column-header"> + <span ng-if="!$ctrl.sortable || !headerItem.sortable"> + {{headerItem.label}} + </span> + <span ng-if="$ctrl.sortable && headerItem.sortable" + ng-click="$ctrl.onClickSort($index)" + class="bmc-table__column-header--sortable"> + {{headerItem.label}} + <!-- Sort icons --> + <button class="sort-icon" + type="button" + aria-label="sort {{headerItem.label}}"> + <icon file="icon-arrow--up.svg" + ng-if="$index === $ctrl.activeSort" + ng-class="{ + 'sort-icon--descending': !$ctrl.sortAscending, + 'sort-icon--ascending' : $ctrl.sortAscending }" + class="sort-icon--active" + aria-hidden="true"></icon> + <span ng-if="$index !== $ctrl.activeSort" + class="sort-icon--inactive" + aria-hidden="true"> + <icon file="icon-arrow--up.svg"></icon> + <icon file="icon-arrow--down.svg"></icon> + </span> + </button> + </span> + </th> + <!-- Row actions empty cell --> + <th ng-if="$ctrl.rowActionsEnabled" + class="bmc-table__column-header"></th> + </tr> + </thead> + <tbody class="bmc-table__body"> + <!-- Data rows --> + <tr ng-if="$ctrl.data.length > 0" + ng-repeat-start="row in $ctrl.data track by $index" + class="bmc-table__row" + ng-class="{ + 'bmc-table__row--expanded': $ctrl.expandedRows.has($index), + 'bmc-table__row--selected': $ctrl.selectedRows.has($index), + }"> + <!-- Row expansion trigger --> + <td ng-if="$ctrl.expandable" + class="bmc-table__cell"> + <button type="button" + class="btn btn--expand" + aria-label="expand row" + ng-click="$ctrl.onClickExpand($index)"> + <icon file="icon-chevron-right.svg" aria-hidden="true"></icon> </button> - </span> - </th> - </tr> - </thead> - <tbody class="bmc-table__body"> - <!-- Data rows --> - <tr ng-if="$ctrl.data.length > 0" - ng-repeat-start="row in $ctrl.data track by $index" - class="bmc-table__row" - ng-class="{ - 'bmc-table__row--expanded': $ctrl.expandedRows.has($index) - }"> - <!-- Row expansion trigger --> - <td ng-if="$ctrl.expandable" - class="bmc-table__cell"> - <button type="button" - class="btn btn--expand" - aria-label="expand row" - ng-click="$ctrl.onClickExpand($index)"> - <icon file="icon-chevron-right.svg" aria-hidden="true"></icon> - </button> - </td> - <!-- Row item --> - <td ng-repeat="item in row.uiData track by $index" - class="bmc-table__cell"> - <ng-bind-html ng-bind-html="item"></ng-bind-html> - </td> - <!-- Row Actions --> - <td ng-if="$ctrl.rowActionsEnabled" - class="bmc-table__cell bmc-table__row-actions"> - <table-actions - actions="row.actions" - emit-action="$ctrl.onEmitTableAction(action, row)"> - </table-actions> - </td> - </tr> - <!-- Expansion row --> - <tr ng-repeat-end - ng-if="$ctrl.expandedRows.has($index)" - class="bmc-table__expansion-row"> - <td class="bmc-table__cell"></td> - <td class="bmc-table__cell" - colspan="{{$ctrl.header.length - 1}}"> - <ng-bind-html - ng-bind-html="row.expandContent || 'No data'"> - </ng-bind-html> - </td> - </tr> - <!-- Empty table --> - <tr ng-if="$ctrl.data.length === 0" - class="bmc-table__expansion-row"> - <td class="bmc-table__cell" - colspan="{{$ctrl.header.length}}">No data</td> - </tr> - </tbody> -</table>
\ No newline at end of file + </td> + <!-- Row checkbox --> + <td ng-if="$ctrl.selectable" + class="bmc-table__cell"> + <table-checkbox ng-if="row.selectable" + ng-model="row.selected" + emit-change="$ctrl.onRowSelectChange($index)"> + </table-checkbox> + </td> + <!-- Row item --> + <td ng-repeat="item in row.uiData track by $index" + class="bmc-table__cell"> + <ng-bind-html ng-bind-html="item"></ng-bind-html> + </td> + <!-- Row Actions --> + <td ng-if="$ctrl.rowActionsEnabled" + class="bmc-table__cell bmc-table__row-actions"> + <table-actions + actions="row.actions" + emit-action="$ctrl.onEmitRowAction(action, row)"> + </table-actions> + </td> + </tr> + <!-- Expansion row --> + <tr ng-repeat-end + ng-if="$ctrl.expandedRows.has($index)" + class="bmc-table__expansion-row"> + <td class="bmc-table__cell"></td> + <td class="bmc-table__cell" + colspan="{{$ctrl.header.length + $ctrl.sortable + + $ctrl.expandable + $ctrl.rowActionsEnabled}}"> + <ng-bind-html + ng-bind-html="row.expandContent || 'No data'"> + </ng-bind-html> + </td> + </tr> + <!-- Empty table --> + <tr ng-if="$ctrl.data.length === 0" + class="bmc-table__expansion-row"> + <td class="bmc-table__cell" + colspan="{{$ctrl.header.length + $ctrl.sortable + + $ctrl.expandable + $ctrl.rowActionsEnabled}}"> + No data + </td> + </tr> + </tbody> + </table> +</div>
\ No newline at end of file diff --git a/app/common/components/table/table.js b/app/common/components/table/table.js index 5db05b6..2063555 100644 --- a/app/common/components/table/table.js +++ b/app/common/components/table/table.js @@ -18,9 +18,12 @@ window.angular && (function(angular) { * Each row object can optionally have an 'expandContent' property * that should be a string value and can contain valid HTML. To render * the expanded content, set 'expandable' attribute to true. + * Each row object can optionally have a 'selectable' property. Defaults + * to true if table is selectable. If a particular row should not + * be selectable, set to false. * * data = [ - * { uiData: ['root', 'Admin', 'enabled' ] }, + * { uiData: ['root', 'Admin', 'enabled' ], selectable: false }, * { uiData: ['user1', 'User', 'disabled' ] } * ] * @@ -47,6 +50,10 @@ window.angular && (function(angular) { * The 'expandable' attribute should be a boolean value. If true each * row object in data array should contain a 'expandContent' property * + * The 'selectable' attribute should be a boolean value. + * If 'selectable' is true, include 'batch-actions' property that should + * be an array of actions to provide <table-toolbar> component. + * * The 'size' attribute which can be set to 'small' which will * render a smaller font size in the table. * @@ -56,6 +63,11 @@ window.angular && (function(angular) { this.sortAscending = true; this.activeSort; this.expandedRows = new Set(); + this.selectedRows = new Set(); + this.selectHeaderCheckbox = false; + this.someSelected = false; + + let selectableRowCount = 0; /** * Sorts table data @@ -89,34 +101,99 @@ window.angular && (function(angular) { return column; }) } - if (this.rowActionsEnabled) { - // If table actions are enabled push an empty - // string to the header array to account for additional - // table actions cell - this.header.push({label: '', sortable: false}); - } - if (this.expandable) { - // If table is expandable, push an empty string to the - // header array to account for additional expansion cell - this.header.unshift({label: '', sortable: false}); + }; + + /** + * Prep data + * When data binding changes, make adjustments to account for + * optional configurations and undefined values + */ + const prepData = () => { + selectableRowCount = 0; + this.data.forEach((row) => { + if (row.uiData === undefined) { + // Check for undefined 'uiData' property for each item in data + // array + row.uiData = []; + } + if (this.selectable) { + // If table is selectable check row for 'selectable' property + row.selectable = row.selectable === undefined ? true : row.selectable; + if (row.selectable) { + selectableRowCount++; + row.selected = false; + } + } + }); + if (this.sortable) { + if (this.activeSort !== undefined || this.defaultSort !== undefined) { + // apply default or active sort if one is defined + this.activeSort = this.defaultSort !== undefined ? this.defaultSort : + this.activeSort; + sortData(); + } } }; /** + * Select all rows + * Sets each selectable row selected property to true + * and adds index to selectedRow Set + */ + const selectAllRows = () => { + this.selectHeaderCheckbox = true; + this.someSelected = false; + this.data.forEach((row, index) => { + if (!row.selected && row.selectable) { + row.selected = true; + this.selectedRows.add(index); + } + }) + }; + + /** + * Deselect all rows + * Sets each row selected property to false + * and clears selectedRow Set + */ + const deselectAllRows = () => { + this.selectHeaderCheckbox = false; + this.someSelected = false; + this.selectedRows.clear(); + this.data.forEach((row) => { + if (row.selectable) { + row.selected = false; + } + }) + }; + + /** * Callback when table row action clicked * Emits user desired action and associated row data to * parent controller * @param {string} action : action type * @param {any} row : user object */ - this.onEmitTableAction = (action, row) => { + this.onEmitRowAction = (action, row) => { if (action !== undefined && row !== undefined) { const value = {action, row}; - this.emitAction({value}); + this.emitRowAction({value}); } }; /** + * Callback when batch action clicked from toolbar + * Emits the action type and the selected row data to + * parent controller + * @param {string} action : action type + */ + this.onEmitBatchAction = (action) => { + const filteredRows = this.data.filter((row) => row.selected); + const value = {action, filteredRows}; + this.emitBatchAction({value}); + }; + + /** * Callback when sortable table header clicked * @param {number} index : index of header item */ @@ -146,6 +223,49 @@ window.angular && (function(angular) { }; /** + * Callback when select checkbox clicked + * @param {number} row : index of selected row + */ + this.onRowSelectChange = (row) => { + if (this.selectedRows.has(row)) { + this.selectedRows.delete(row); + } else { + this.selectedRows.add(row); + } + if (this.selectedRows.size === 0) { + this.someSelected = false; + this.selectHeaderCheckbox = false; + deselectAllRows(); + } else if (this.selectedRows.size === selectableRowCount) { + this.someSelected = false; + this.selectHeaderCheckbox = true; + selectAllRows(); + } else { + this.someSelected = true; + } + }; + + /** + * Callback when header select box value changes + */ + this.onHeaderSelectChange = (checked) => { + this.selectHeaderCheckbox = checked; + if (this.selectHeaderCheckbox) { + selectAllRows(); + } else { + deselectAllRows(); + } + }; + + /** + * Callback when cancel/close button closed + * from toolbar + */ + this.onToolbarClose = () => { + deselectAllRows(); + }; + + /** * onInit Component lifecycle hook * Checking for undefined values */ @@ -157,32 +277,19 @@ window.angular && (function(angular) { this.rowActionsEnabled === undefined ? false : this.rowActionsEnabled; this.size = this.size === undefined ? '' : this.size; this.expandable = this.expandable === undefined ? false : this.expandable; - - // Check for undefined 'uiData' property for each item in data array - this.data = this.data.map((row) => { - if (row.uiData === undefined) { - row.uiData = []; - } - return row; - }) + this.selectable = this.selectable === undefined ? false : this.selectable; prepTable(); }; /** * onChanges Component lifecycle hook - * Check for changes in the data array and apply - * default or active sort if one is defined */ this.$onChanges = (onChangesObj) => { const dataChange = onChangesObj.data; if (dataChange) { - if (this.activeSort !== undefined || this.defaultSort !== undefined) { - this.activeSort = this.defaultSort !== undefined ? this.defaultSort : - this.activeSort; - sortData(); - } + prepData(); } - } + }; }; /** @@ -199,7 +306,10 @@ window.angular && (function(angular) { sortable: '<', // boolean defaultSort: '<', // number (index of sort) expandable: '<', // boolean - emitAction: '&' + selectable: '<', // boolean + batchActions: '<', // Array + emitRowAction: '&', + emitBatchAction: '&' } }) })(window.angular); |