diff --git a/docs/14_0_0/components/datatable.md b/docs/14_0_0/components/datatable.md index 00297f35c5..5ff2a986d3 100644 --- a/docs/14_0_0/components/datatable.md +++ b/docs/14_0_0/components/datatable.md @@ -23,6 +23,7 @@ DataTable displays data in tabular format. | ariaRowLabel | null | String | Label to read by screen readers on checkbox selection. | binding | null | Object | An el expression that maps to a server side UIComponent instance in a backing bean | cellEditMode | eager | String | Defines the cell edit behavior, valid values are "eager" (default) and "lazy". +| cellNavigation | true | Boolean | Enables cell navigation with the keyboard for WCAG and screen reader compliance. | cellSeparator | null | String | Separator text to use in output mode of editable cells with multiple components. | clientCache | false | Boolean | Caches the next page asynchronously, default is false. | currentPageReportTemplate | null | String | Template of the currentPageReport UI. diff --git a/docs/14_0_0/gettingstarted/whatsnew.md b/docs/14_0_0/gettingstarted/whatsnew.md index fdfb046acb..d2fb5e1fad 100644 --- a/docs/14_0_0/gettingstarted/whatsnew.md +++ b/docs/14_0_0/gettingstarted/whatsnew.md @@ -48,6 +48,7 @@ Look into [migration guide](https://primefaces.github.io/primefaces/14_0_0/#/../ * JPALazyDataModel now supports post load data enricher with `JPALazyDataModel.Builder#resultEnricher()` . * Added `filterPlaceholder` for `Column` and `Columns` * Added `rowData` to `CellEditEvent` which contains the entire row from the cell being edited. + * Added `cellNavigation` property which defaults to true to enable WCAG keyboard navigation of table cells. * Dashboard * Added `var` to allow dynamic panels in `DashboardWidget.setValue(obj)` per panel diff --git a/primefaces/src/main/java/org/primefaces/component/datatable/DataTableBase.java b/primefaces/src/main/java/org/primefaces/component/datatable/DataTableBase.java index c67d959597..f8f071b876 100644 --- a/primefaces/src/main/java/org/primefaces/component/datatable/DataTableBase.java +++ b/primefaces/src/main/java/org/primefaces/component/datatable/DataTableBase.java @@ -44,6 +44,7 @@ public enum PropertyKeys { ariaRowLabel, caseSensitiveSort, cellEditMode, + cellNavigation, cellSeparator, clientCache, dataLocale, @@ -439,7 +440,7 @@ public void setRowExpandMode(String rowExpandMode) { getStateHelper().put(PropertyKeys.rowExpandMode, rowExpandMode); } - public Object getDataLocale() { + @Override public Object getDataLocale() { return getStateHelper().eval(PropertyKeys.dataLocale, null); } @@ -768,4 +769,11 @@ public void setSelectAllFilteredOnly(boolean selectAllFilteredOnly) { getStateHelper().put(PropertyKeys.selectAllFilteredOnly, selectAllFilteredOnly); } + public boolean isCellNavigation() { + return (Boolean) getStateHelper().eval(PropertyKeys.cellNavigation, true); + } + + public void setCellNavigation(boolean cellNavigation) { + getStateHelper().put(PropertyKeys.cellNavigation, cellNavigation); + } } diff --git a/primefaces/src/main/java/org/primefaces/component/datatable/DataTableRenderer.java b/primefaces/src/main/java/org/primefaces/component/datatable/DataTableRenderer.java index 8d992f975c..6efcc8dedf 100644 --- a/primefaces/src/main/java/org/primefaces/component/datatable/DataTableRenderer.java +++ b/primefaces/src/main/java/org/primefaces/component/datatable/DataTableRenderer.java @@ -249,6 +249,7 @@ protected void encodeScript(FacesContext context, DataTable table) throws IOExce .attr("rowHover", table.isRowHover(), false) .attr("clientCache", table.isClientCache(), false) .attr("multiViewState", table.isMultiViewState(), false) + .attr("cellNavigation", table.isCellNavigation(), true) .attr("partialUpdate", table.isPartialUpdate(), true) .nativeAttr("groupColumnIndexes", table.getGroupedColumnIndexes(), null) .callback("onRowClick", "function(row)", table.getOnRowClick()); diff --git a/primefaces/src/main/resources/META-INF/primefaces.taglib.xml b/primefaces/src/main/resources/META-INF/primefaces.taglib.xml index 999b066c40..da62752edb 100644 --- a/primefaces/src/main/resources/META-INF/primefaces.taglib.xml +++ b/primefaces/src/main/resources/META-INF/primefaces.taglib.xml @@ -8956,6 +8956,14 @@ false java.lang.String + + + + + cellNavigation + false + java.lang.Boolean + diff --git a/primefaces/src/main/resources/META-INF/resources/primefaces/datatable/datatable.css b/primefaces/src/main/resources/META-INF/resources/primefaces/datatable/datatable.css index 9919b6f745..9a769be0a2 100644 --- a/primefaces/src/main/resources/META-INF/resources/primefaces/datatable/datatable.css +++ b/primefaces/src/main/resources/META-INF/resources/primefaces/datatable/datatable.css @@ -157,7 +157,17 @@ top: 0px; left: 0px; position: absolute; -} +} + +/** Accessibility **/ +.ui-datatable-data td[role="gridcell"]:focus, +.ui-datatable-data td[role="gridcell"] *:focus, +.ui-datatable-data td[role="grid"] [tabindex="0"]:focus { + outline: var(--primary-color, #90caf9); + outline-style: solid; + outline-width: 2px; + border: none; +} /* InCell Editing */ .ui-datatable .ui-cell-editor-input { diff --git a/primefaces/src/main/resources/META-INF/resources/primefaces/datatable/datatable.js b/primefaces/src/main/resources/META-INF/resources/primefaces/datatable/datatable.js index 1904c857d3..c7510b9bd6 100644 --- a/primefaces/src/main/resources/META-INF/resources/primefaces/datatable/datatable.js +++ b/primefaces/src/main/resources/META-INF/resources/primefaces/datatable/datatable.js @@ -240,6 +240,9 @@ PrimeFaces.widget.DataTable = PrimeFaces.widget.DeferredWidget.extend({ if(this.cfg.selectionMode) { this.setupSelection(); } + else { + this.cfg.selectionRowMode = this.cfg.selectionRowMode || 'none'; + } if(this.cfg.filter) { this.setupFiltering(); @@ -317,6 +320,15 @@ PrimeFaces.widget.DataTable = PrimeFaces.widget.DeferredWidget.extend({ if(this.cfg.reflow) { this.jq.css('visibility', 'visible'); } + + this.cfg.cellNavigation = this.cfg.cellNavigation === undefined ? true : this.cfg.cellNavigation; + if (this.cfg.editMode === 'cell' || this.cfg.selectionRowMode !== 'none') { + // do not allow when editing or row selection enabled + this.cfg.cellNavigation = false; + } + if (this.cfg.cellNavigation) { + this.setupNavigableCells(); + } }, /** @@ -382,6 +394,10 @@ PrimeFaces.widget.DataTable = PrimeFaces.widget.DeferredWidget.extend({ if(this.cfg.expansion) { this.initRowExpansion(); } + + if (this.cfg.cellNavigation) { + this.setupNavigableCells(); + } }, /** @@ -794,6 +810,141 @@ PrimeFaces.widget.DataTable = PrimeFaces.widget.DeferredWidget.extend({ this.bindRowHover(selector); } }, + + /** + * Sets up WCAG keyboard navigation of cells. + * @private + */ + setupNavigableCells: function() { + var $this = this; + var pageRows = this.cfg.paginator && this.cfg.paginator.rows ? this.cfg.paginator.rows : 1000; + + // helper function to set the current and next cell focus + function makeFocusable(e, cell, nextCell) { + if (cell && cell.length) { + cell.attr("tabindex", "-1"); + } + + if (nextCell && nextCell.length) { + nextCell.attr("tabindex", "0").trigger("focus"); + e.preventDefault(); + } + } + + // helper function to reset the state of the whole table + function resetFocusable(resetFirstCell) { + var selector = resetFirstCell ? "td" : 'td[tabindex="0"]'; + // default all cells to not focusable + var focusableCells = $this.getTbody().find(selector); + focusableCells.attr("tabindex", "-1"); + + if (resetFirstCell) { + // the very first cell should be focusable + focusableCells.first().attr("tabindex", "0"); + } + } + + // default all cells to not focusable except the very first cell + resetFocusable(true); + + // on click should make it focusable + this.getTbody().find("td") + .on("click.focuscell", function(e) { + if ($(e.target).is(":input")) { + return; + } + resetFocusable(false); + + makeFocusable(e, null, $(this)); + }) + .on("keydown.focuscell", function(e) { + if ($(e.target).is(":input")) { + return; + } + var cell = $(this); + + switch (e.code) { + case "ArrowLeft": + var prevCell = $this.isRTL ? cell.next('[tabindex="-1"]') : cell.prev('[tabindex="-1"]'); + makeFocusable(e, cell, prevCell); + break; + case "ArrowRight": + var nextCell = $this.isRTL ? cell.prev('[tabindex="-1"]') : cell.next('[tabindex="-1"]'); + makeFocusable(e, cell, nextCell); + break; + case "ArrowDown": + var nextCell = cell.closest("tr[data-ri]").next().find('td[tabindex="-1"]').eq(cell.index()); + makeFocusable(e, cell, nextCell); + break; + case "ArrowUp": + var prevCell = cell.closest("tr[data-ri]").prev().find('td[tabindex="-1"]').eq(cell.index()); + makeFocusable(e, cell, prevCell); + break; + case "Home": + var prevCell = cell.prevAll('[tabindex="-1"]').last(); + if (e.ctrlKey) { + prevCell = cell.closest("tr[data-ri]").prevAll().last().find('td[tabindex="-1"]').first(); + } + makeFocusable(e, cell, prevCell); + break; + case "End": + var nextCell = cell.nextAll('[tabindex="-1"]').last(); + if (e.ctrlKey) { + nextCell = cell.closest("tr[data-ri]").nextAll().last().find('td[tabindex="-1"]').last(); + } + makeFocusable(e, cell, nextCell); + break; + case "PageUp": + // Select the current record + var currentRow = cell.closest("tr[data-ri]"); + var prevCell = null; + + // Navigate back 1 page records + for (var i = 0; i < pageRows; i++) { + currentRow = currentRow.prev("tr[data-ri]"); + if (currentRow.length) { + prevCell = currentRow.find('td[tabindex="-1"]').eq(cell.index()); + } else { + break; + } + } + makeFocusable(e, cell, prevCell); + break; + case "PageDown": + // Select the current row + var currentRow = cell.closest("tr[data-ri]"); + var nextCell = null; + + // Navigate forward 1 page rows + for (var i = 0; i < pageRows; i++) { + currentRow = currentRow.next("tr[data-ri]"); + if (currentRow.length) { + nextCell = currentRow.find('td[tabindex="-1"]').eq(cell.index()); + } else { + break; + } + } + makeFocusable(e, cell, nextCell); + break; + case "Space": + case "Enter": + case "NumpadEnter": + // Find the first child element with a click event bound + var $clickable = cell.find(':button:enabled, :input:enabled, a').first(); + if ($clickable.length) { + $clickable.trigger('click'); + e.stopPropagation(); + e.preventDefault(); + } + else { + cell.trigger('click'); + } + break; + default: + break; + } + }); + }, /** * Sets up the DataTable and adds all event listener required for selecting rows.