diff --git a/user/src/com/google/gwt/user/cellview/client/AbstractCellTable.java b/user/src/com/google/gwt/user/cellview/client/AbstractCellTable.java index 5973822488..dc4434a5c8 100644 --- a/user/src/com/google/gwt/user/cellview/client/AbstractCellTable.java +++ b/user/src/com/google/gwt/user/cellview/client/AbstractCellTable.java @@ -523,6 +523,7 @@ public final void replaceAllRows(AbstractCellTable table, TableSectionElement // Render the html. replaceAllRowsImpl(table, section, html); + table.onSectionRendered(section); /* * Reattach the section. If next section is null, the section will be @@ -585,6 +586,7 @@ public final void replaceChildren(AbstractCellTable table, TableSectionElemen // Add new child elements. TableSectionElement newSection = convertToSectionElement(table, section.getTagName(), html); + table.onSectionRendered(newSection); Element newChild = newSection.getFirstChildElement(); while (newChild != null) { Element next = newChild.getNextSiblingElement(); @@ -1706,6 +1708,18 @@ protected boolean dependsOnSelection() { */ protected abstract void doSetHeaderVisible(boolean isFooter, boolean isVisible); + /** + * Called after rows of a head, body or foot section are rendered, before + * they enter the document, so subclasses can post-process them. The default + * does nothing. + * + * @param section after a full render, the table's own section, detached; + * after a partial render of the body, a detached section of the + * same tag holding only the new rows + */ + protected void onSectionRendered(TableSectionElement section) { + } + @Override protected Element getChildContainer() { return getTableBodyElement(); diff --git a/user/src/com/google/gwt/user/cellview/client/DataGrid.java b/user/src/com/google/gwt/user/cellview/client/DataGrid.java index 47bf14f989..f43b867ae2 100644 --- a/user/src/com/google/gwt/user/cellview/client/DataGrid.java +++ b/user/src/com/google/gwt/user/cellview/client/DataGrid.java @@ -18,12 +18,15 @@ import com.google.gwt.core.client.GWT; import com.google.gwt.dom.client.Document; import com.google.gwt.dom.client.Element; +import com.google.gwt.dom.client.NodeList; import com.google.gwt.dom.client.Style.Display; import com.google.gwt.dom.client.Style.Overflow; import com.google.gwt.dom.client.Style.TableLayout; import com.google.gwt.dom.client.Style.Unit; +import com.google.gwt.dom.client.TableCellElement; import com.google.gwt.dom.client.TableColElement; import com.google.gwt.dom.client.TableElement; +import com.google.gwt.dom.client.TableRowElement; import com.google.gwt.dom.client.TableSectionElement; import com.google.gwt.event.dom.client.ScrollEvent; import com.google.gwt.event.dom.client.ScrollHandler; @@ -504,6 +507,58 @@ private static Resources getDefaultResources() { return DEFAULT_RESOURCES; } + /** + * Give the rows and cells of a rendered section explicit ARIA roles, which + * they lose to the presentation role on their table. Rows and cells that + * already have a role keep it, and a row with a role other than row (such + * as a layout row marked presentation) is left alone, cells included. An + * empty role counts as none. + */ + private static void applyTableRoles(TableSectionElement section) { + // Tag, not identity: a partial render passes a temporary tbody. + String thRole = "tbody".equalsIgnoreCase(section.getTagName()) ? "rowheader" : "columnheader"; + NodeList rows = section.getRows(); + for (int r = 0; r < rows.getLength(); r++) { + TableRowElement row = rows.getItem(r); + String rowRole = row.getAttribute("role"); + if (rowRole.isEmpty()) { + row.setAttribute("role", "row"); + } else if (!"row".equals(rowRole)) { + continue; + } + NodeList cells = row.getCells(); + for (int c = 0; c < cells.getLength(); c++) { + TableCellElement cell = cells.getItem(c); + if (cell.getAttribute("role").isEmpty()) { + boolean isTh = "th".equalsIgnoreCase(cell.getTagName()); + cell.setAttribute("role", isTh ? thRole : "cell"); + } + } + } + } + + /** + * Show a widget in a message container (loading or empty table), whose + * single cell reads as a row of the grid. A null widget disables the + * message, so its empty cell must not read as a blank row. + */ + private static void setMessageWidget(FlexTable container, Widget widget) { + container.setWidget(0, 0, widget); + TableElement table = container.getElement().cast(); + Element[] parts = { + table.getTBodies().getItem(0), + container.getRowFormatter().getElement(0), + container.getCellFormatter().getElement(0, 0)}; + String[] roles = {"rowgroup", "row", "cell"}; + for (int i = 0; i < parts.length; i++) { + if (widget == null) { + parts[i].removeAttribute("role"); + } else { + parts[i].setAttribute("role", roles[i]); + } + } + } + final TableWidget tableData; final TableWidget tableFooter; final TableWidget tableHeader; @@ -649,6 +704,20 @@ public DataGrid(int pageSize, Resources resources, ProvidesKey keyProvider, loadingIndicatorContainer = new FlexTable(); loadingIndicatorContainer.getElement().setAttribute("align", "center"); + /* + * The header, body and footer are separate tables so the header can stay + * fixed while the body scrolls. Presentation roles on those tables, plus + * explicit row and cell roles (see onSectionRendered), let assistive + * technology read them as one table. + */ + getElement().setAttribute("role", "table"); + for (TableWidget part : new TableWidget[] {tableHeader, tableData, tableFooter}) { + part.tableElem.setAttribute("role", "presentation"); + part.section.setAttribute("role", "rowgroup"); + } + emptyTableWidgetContainer.getElement().setAttribute("role", "presentation"); + loadingIndicatorContainer.getElement().setAttribute("role", "presentation"); + // Set the loading indicator. setLoadingIndicator(loadingIndicator); // Can be null. @@ -724,13 +793,13 @@ public void setAlwaysShowScrollBars(boolean alwaysShowScrollBars) { @Override public void setEmptyTableWidget(Widget widget) { - emptyTableWidgetContainer.setWidget(0, 0, widget); + setMessageWidget(emptyTableWidgetContainer, widget); super.setEmptyTableWidget(widget); } @Override public void setLoadingIndicator(Widget widget) { - loadingIndicatorContainer.setWidget(0, 0, widget); + setMessageWidget(loadingIndicatorContainer, widget); super.setLoadingIndicator(widget); } @@ -858,6 +927,11 @@ protected void onLoadingStateChanged(LoadingState state) { super.onLoadingStateChanged(state); } + @Override + protected void onSectionRendered(TableSectionElement section) { + applyTableRoles(section); + } + @Override protected void refreshColumnWidths() { super.refreshColumnWidths(); diff --git a/user/test/com/google/gwt/user/cellview/client/AbstractCellTableTestBase.java b/user/test/com/google/gwt/user/cellview/client/AbstractCellTableTestBase.java index 45f4336703..1ba893a8e0 100644 --- a/user/test/com/google/gwt/user/cellview/client/AbstractCellTableTestBase.java +++ b/user/test/com/google/gwt/user/cellview/client/AbstractCellTableTestBase.java @@ -1224,7 +1224,7 @@ public void testSortableHeaderAria() { // The sortable th keeps its columnheader role; the button is inside it. TableCellElement sortableTh = getHeaderElement(table, 0); - assertFalse(sortableTh.hasAttribute("role")); + assertHeaderCellRole(sortableTh); assertFalse(sortableTh.hasAttribute("aria-sort")); Element sortButton = sortableTh.getFirstChildElement(); assertEquals("button", sortButton.getAttribute("role")); @@ -1233,7 +1233,7 @@ public void testSortableHeaderAria() { // A column that isn't sortable gets no button. TableCellElement plainTh = getHeaderElement(table, 1); - assertFalse(plainTh.hasAttribute("role")); + assertHeaderCellRole(plainTh); assertFalse(plainTh.hasAttribute("aria-sort")); assertNull(plainTh.getFirstChildElement()); assertEquals("Column 1", plainTh.getInnerText()); @@ -1374,6 +1374,23 @@ public String getValue(String object) { return table; } + /** + * Get the role attribute a header th should carry: null for none (an + * implicit columnheader), or the explicit role the table stamps on it. + */ + protected String getHeaderCellRole() { + return null; + } + + private void assertHeaderCellRole(TableCellElement th) { + String role = getHeaderCellRole(); + if (role == null) { + assertFalse(th.hasAttribute("role")); + } else { + assertEquals(role, th.getAttribute("role")); + } + } + /** * Get a td element from the table body. * diff --git a/user/test/com/google/gwt/user/cellview/client/DataGridTest.java b/user/test/com/google/gwt/user/cellview/client/DataGridTest.java index f3515e9a96..c0a2f19a50 100644 --- a/user/test/com/google/gwt/user/cellview/client/DataGridTest.java +++ b/user/test/com/google/gwt/user/cellview/client/DataGridTest.java @@ -15,13 +15,23 @@ */ package com.google.gwt.user.cellview.client; +import com.google.gwt.cell.client.TextCell; import com.google.gwt.core.client.GWT; +import com.google.gwt.dom.builder.shared.TableRowBuilder; +import com.google.gwt.dom.client.Element; +import com.google.gwt.dom.client.NodeList; import com.google.gwt.dom.client.TableCellElement; import com.google.gwt.dom.client.TableElement; import com.google.gwt.dom.client.TableRowElement; import com.google.gwt.dom.client.TableSectionElement; import com.google.gwt.user.cellview.client.DataGrid.Resources; +import com.google.gwt.user.client.ui.Label; import com.google.gwt.user.client.ui.RootPanel; +import com.google.gwt.user.client.ui.Widget; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Locale; /** * Tests for {@link DataGrid}. @@ -89,11 +99,273 @@ protected boolean buildHeaderOrFooterImpl() { RootPanel.get().remove(table); } + /** + * Test that the header, body and footer tables read as one ARIA table. + */ + public void testTableRoles() { + DataGrid table = createRoleTestGrid(); + + assertEquals("table", table.getElement().getAttribute("role")); + assertEquals("presentation", table.tableHeader.getElement().getAttribute("role")); + assertEquals("presentation", table.tableData.getElement().getAttribute("role")); + assertEquals("presentation", table.tableFooter.getElement().getAttribute("role")); + assertEquals("rowgroup", table.getTableHeadElement().getAttribute("role")); + assertEquals("rowgroup", table.getTableBodyElement().getAttribute("role")); + assertEquals("rowgroup", table.getTableFootElement().getAttribute("role")); + + TableRowElement headerRow = table.getTableHeadElement().getRows().getItem(0); + assertEquals("row", headerRow.getAttribute("role")); + for (int c = 0; c < 3; c++) { + assertEquals("columnheader", headerRow.getCells().getItem(c).getAttribute("role")); + } + for (int r = 0; r < 3; r++) { + TableRowElement row = table.getRowElement(r); + assertEquals("row", row.getAttribute("role")); + for (int c = 0; c < 3; c++) { + assertEquals("cell", row.getCells().getItem(c).getAttribute("role")); + } + } + + RootPanel.get().remove(table); + } + + /** + * Test that rows replaced by a partial redraw get roles too. + */ + public void testTableRolesAfterPartialRedraw() { + DataGrid table = createRoleTestGrid(); + + table.setRowData(1, Arrays.asList("B")); + table.getPresenter().flush(); + TableRowElement row = table.getRowElement(1); + assertEquals("B", row.getCells().getItem(0).getInnerText()); + assertEquals("row", row.getAttribute("role")); + assertEquals("cell", row.getCells().getItem(1).getAttribute("role")); + + RootPanel.get().remove(table); + } + + /** + * Test that a partial redraw only stamps the rows it inserts, so updating + * one row doesn't walk the whole body. + */ + public void testPartialRedrawOnlyStampsNewRows() { + DataGrid table = createRoleTestGrid(); + + table.getRowElement(0).removeAttribute("role"); + table.setRowData(1, Arrays.asList("B")); + table.getPresenter().flush(); + assertFalse(table.getRowElement(0).hasAttribute("role")); + assertEquals("row", table.getRowElement(1).getAttribute("role")); + + RootPanel.get().remove(table); + } + + /** + * Test that footer rows and their th cells get roles, like the header. + */ + public void testTableRolesFooter() { + DataGrid table = createAbstractHasData(); + table.addColumn(new TextColumn() { + @Override + public String getValue(String object) { + return object; + } + }, new TextHeader("Name"), new TextHeader("Total")); + RootPanel.get().add(table); + table.setRowData(Arrays.asList("a")); + table.getPresenter().flush(); + + TableRowElement footerRow = table.getTableFootElement().getRows().getItem(0); + assertEquals("row", footerRow.getAttribute("role")); + TableCellElement footerCell = footerRow.getCells().getItem(0); + assertEquals("Total", footerCell.getInnerText()); + assertEquals("columnheader", footerCell.getAttribute("role")); + + RootPanel.get().remove(table); + } + + /** + * Test that an empty role attribute counts as no role. + */ + public void testTableRolesTreatEmptyRoleAsAbsent() { + DataGrid table = createRoleTestGrid(); + + table.setTableBuilder(new DefaultCellTableBuilder(table) { + @Override + public void buildRowImpl(String rowValue, int absRowIndex) { + TableRowBuilder tr = startRow(rowValue); + tr.attribute("role", ""); + tr.startTD().attribute("role", "").text(rowValue).endTD(); + tr.endTR(); + } + }); + table.getPresenter().flush(); + TableRowElement row = table.getRowElement(0); + assertEquals("row", row.getAttribute("role")); + assertEquals("cell", row.getCells().getItem(0).getAttribute("role")); + + RootPanel.get().remove(table); + } + + /** + * Test that a row the builder marks presentational keeps that role and its + * cells get none. + */ + public void testTableRolesKeepExplicitRoles() { + DataGrid table = createRoleTestGrid(); + + table.setTableBuilder(new DefaultCellTableBuilder(table) { + @Override + public void buildRowImpl(String rowValue, int absRowIndex) { + super.buildRowImpl(rowValue, absRowIndex); + if (absRowIndex == 0) { + TableRowBuilder tr = startRow(rowValue); + tr.attribute("role", "presentation"); + tr.startTD().colSpan(3).endTD(); + tr.endTR(); + } + } + }); + table.getPresenter().flush(); + TableRowElement layoutRow = table.getTableBodyElement().getRows().getItem(1); + assertEquals("presentation", layoutRow.getAttribute("role")); + assertFalse(layoutRow.getCells().getItem(0).hasAttribute("role")); + assertEquals("row", table.getTableBodyElement().getRows().getItem(2).getAttribute("role")); + + RootPanel.get().remove(table); + } + + /** + * Test that a sorted header carries both its role and aria-sort. + */ + public void testTableRolesKeepAriaSort() { + DataGrid table = createRoleTestGrid(); + + table.getColumnSortList().push(table.getColumn(2)); + table.redrawHeaders(); + TableCellElement th = table.getTableHeadElement().getRows().getItem(0).getCells().getItem(2); + assertEquals("columnheader", th.getAttribute("role")); + assertEquals("ascending", th.getAttribute("aria-sort")); + + RootPanel.get().remove(table); + } + + /** + * Test that a disabled (null) loading indicator leaves no blank row in the + * grid while it loads. + */ + public void testNullLoadingIndicatorAddsNoRow() { + DataGrid table = createAbstractHasData(new TextCell()); + table.setLoadingIndicator(new Label("Loading")); + table.setLoadingIndicator(null); + RootPanel.get().add(table); + table.getPresenter().flush(); + + // While loading, the only td in the grid is the indicator container's. + NodeList tds = table.getElement().getElementsByTagName("td"); + assertEquals(1, tds.getLength()); + assertFalse(tds.getItem(0).hasAttribute("role")); + assertFalse(tds.getItem(0).getParentElement().hasAttribute("role")); + + RootPanel.get().remove(table); + } + + /** + * Test that a th in the body is a row header. + */ + public void testTableRolesBodyThIsRowHeader() { + DataGrid table = createRoleTestGrid(); + + table.setTableBuilder(new DefaultCellTableBuilder(table) { + @Override + public void buildRowImpl(String rowValue, int absRowIndex) { + TableRowBuilder tr = startRow(rowValue); + tr.startTH().text(rowValue).endTH(); + tr.startTD().text(rowValue).endTD(); + tr.endTR(); + } + }); + table.getPresenter().flush(); + TableRowElement row = table.getRowElement(0); + assertEquals("rowheader", row.getCells().getItem(0).getAttribute("role")); + assertEquals("cell", row.getCells().getItem(1).getAttribute("role")); + + RootPanel.get().remove(table); + } + + /** + * Test that the loading and empty-table messages, which replace the body + * table, read as a row of the grid rather than a table of their own. + */ + public void testLoadingAndEmptyMessagesArePresentational() { + DataGrid table = createAbstractHasData(new TextCell()); + Label loading = new Label("Loading"); + Label empty = new Label("Empty"); + table.setLoadingIndicator(loading); + table.setEmptyTableWidget(empty); + + // A grid with no data yet is loading. + RootPanel.get().add(table); + table.getPresenter().flush(); + assertTrue(loading.isAttached()); + assertMessageRoles(table, loading); + + table.setRowData(new ArrayList()); + table.getPresenter().flush(); + assertTrue(empty.isAttached()); + assertMessageRoles(table, empty); + + RootPanel.get().remove(table); + } + @Override protected DataGrid createAbstractHasData() { return new DataGrid(); } + @Override + protected String getHeaderCellRole() { + return "columnheader"; + } + + /** + * Assert that a message widget shown in the grid sits in a cell of a row of + * a presentational table inside the grid. + */ + private static void assertMessageRoles(DataGrid table, Widget message) { + Element td = message.getElement().getParentElement(); + Element tr = td.getParentElement(); + Element tbody = tr.getParentElement(); + Element tableElem = tbody.getParentElement(); + assertEquals("td", td.getTagName().toLowerCase(Locale.ROOT)); + assertTrue(table.getElement().isOrHasChild(tableElem)); + assertEquals("cell", td.getAttribute("role")); + assertEquals("row", tr.getAttribute("role")); + assertEquals("rowgroup", tbody.getAttribute("role")); + assertEquals("presentation", tableElem.getAttribute("role")); + } + + /** + * Create an attached grid with three columns, the last one sortable, and + * three rows. + */ + private DataGrid createRoleTestGrid() { + DataGrid table = createAbstractHasData(new TextCell()); + TextColumn column = new TextColumn() { + @Override + public String getValue(String object) { + return object; + } + }; + column.setSortable(true); + table.addColumn(column, new TextHeader("Name")); + RootPanel.get().add(table); + table.setRowData(Arrays.asList("a", "b", "c")); + table.getPresenter().flush(); + return table; + } + @Override protected TableCellElement getBodyElement(DataGrid table, int row, int column) { TableElement tableElem = table.tableData.getElement().cast();