-Added supporting library for setting of ARIA roles/states (it is currently just a wrapper around DOM.set/get/removeElementAttribute calls) -Added ARIA roles/states for CustomButton, Tree, TreeItem, MenuBar, MenuItem, TabBar, TabPanel -Added keyboard navigation support to TabBar and MenuBar -Enured that tabindexes are properly set for all widgets that derive from FocusWidget -Set a tabindex of -1 on GWT History and Script IFRAMES so that they are not part of the tab cycle Patch by: rshearer, rdayal, clchen, jgw, raman git-svn-id: https://google-web-toolkit.googlecode.com/svn/trunk@1977 8db76d5a-ed1c-0410-87a9-c151d255dfc7
diff --git a/dev/core/src/com/google/gwt/dev/linker/HostedModeTemplate.js b/dev/core/src/com/google/gwt/dev/linker/HostedModeTemplate.js index 058fe4c..d4d74ad 100644 --- a/dev/core/src/com/google/gwt/dev/linker/HostedModeTemplate.js +++ b/dev/core/src/com/google/gwt/dev/linker/HostedModeTemplate.js
@@ -1,5 +1,5 @@ /* - * Copyright 2007 Google Inc. + * Copyright 2008 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy of @@ -348,6 +348,7 @@ iframe.src = "javascript:''"; iframe.id = "__MODULE_NAME__"; iframe.style.cssText = "position:absolute;width:0;height:0;border:none"; + iframe.tabIndex = -1; // Due to an IE6/7 refresh quirk, this must be an appendChild. $doc.body.appendChild(iframe);
diff --git a/dev/core/src/com/google/gwt/dev/linker/IFrameTemplate.js b/dev/core/src/com/google/gwt/dev/linker/IFrameTemplate.js index 39a35e0..0865515 100644 --- a/dev/core/src/com/google/gwt/dev/linker/IFrameTemplate.js +++ b/dev/core/src/com/google/gwt/dev/linker/IFrameTemplate.js
@@ -1,5 +1,5 @@ /* - * Copyright 2007 Google Inc. + * Copyright 2008 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy of @@ -317,6 +317,7 @@ iframe.src = "javascript:''"; iframe.id = "__MODULE_NAME__"; iframe.style.cssText = "position:absolute;width:0;height:0;border:none"; + iframe.tabIndex = -1; // Due to an IE6/7 refresh quirk, this must be an appendChild. $doc.body.appendChild(iframe);
diff --git a/samples/dynatable/src/com/google/gwt/sample/dynatable/public/DynaTable.html b/samples/dynatable/src/com/google/gwt/sample/dynatable/public/DynaTable.html index e3cc2c5..cb6e471 100644 --- a/samples/dynatable/src/com/google/gwt/sample/dynatable/public/DynaTable.html +++ b/samples/dynatable/src/com/google/gwt/sample/dynatable/public/DynaTable.html
@@ -22,7 +22,7 @@ <title></title> </head> <body> - <iframe src="javascript:''" id='__gwt_historyFrame' style='width:0;height:0;border:0'></iframe> + <iframe src="javascript:''" id='__gwt_historyFrame' tabIndex='-1' style='width:0;height:0;border:0'></iframe> <script type="text/javascript" language='javascript' src='com.google.gwt.sample.dynatable.DynaTable.nocache.js'></script> <h1>School Schedule for Professors and Students</h1> <table width="100%" border="0" summary="School Schedule for Professors and Students">
diff --git a/samples/kitchensink/src/com/google/gwt/sample/kitchensink/public/KitchenSink.html b/samples/kitchensink/src/com/google/gwt/sample/kitchensink/public/KitchenSink.html index d5d3ef5..6844b7a 100644 --- a/samples/kitchensink/src/com/google/gwt/sample/kitchensink/public/KitchenSink.html +++ b/samples/kitchensink/src/com/google/gwt/sample/kitchensink/public/KitchenSink.html
@@ -22,6 +22,6 @@ </head> <body> <script type="text/javascript" language='javascript' src='com.google.gwt.sample.kitchensink.KitchenSink.nocache.js'></script> - <iframe src="javascript:''" id='__gwt_historyFrame' style='width:0;height:0;border:0'></iframe> + <iframe src="javascript:''" id='__gwt_historyFrame' tabIndex='-1' style='width:0;height:0;border:0'></iframe> </body> </html>
diff --git a/samples/simplerpc/src/com/google/gwt/sample/simplerpc/public/SimpleRPC.html b/samples/simplerpc/src/com/google/gwt/sample/simplerpc/public/SimpleRPC.html index 406f66b..e7f5be8 100644 --- a/samples/simplerpc/src/com/google/gwt/sample/simplerpc/public/SimpleRPC.html +++ b/samples/simplerpc/src/com/google/gwt/sample/simplerpc/public/SimpleRPC.html
@@ -22,7 +22,7 @@ <title>SimpleRPC</title> </head> <body> - <iframe src="javascript:''" id='__gwt_historyFrame' style='width:0;height:0;border:0'></iframe> + <iframe src="javascript:''" id='__gwt_historyFrame' tabIndex='-1' style='width:0;height:0;border:0'></iframe> <script type="text/javascript" language='javascript' src='com.google.gwt.sample.simplerpc.SimpleRPC.nocache.js'> </script> <h1> Simple RPC</h1>
diff --git a/user/src/com/google/gwt/core/public/history.html b/user/src/com/google/gwt/core/public/history.html index 4ba2dce..b06f46a 100644 --- a/user/src/com/google/gwt/core/public/history.html +++ b/user/src/com/google/gwt/core/public/history.html
@@ -15,7 +15,7 @@ </script></head> <body onload='hst()'> -<input type='text' id='__gwt_historyToken'> +<input type='text' id='__gwt_historyToken' tabIndex='-1'> </body> </html>
diff --git a/user/src/com/google/gwt/user/Accessibility.gwt.xml b/user/src/com/google/gwt/user/Accessibility.gwt.xml new file mode 100644 index 0000000..92f153f --- /dev/null +++ b/user/src/com/google/gwt/user/Accessibility.gwt.xml
@@ -0,0 +1,32 @@ +<!-- --> +<!-- Copyright 2008 Google Inc. --> +<!-- Licensed under the Apache License, Version 2.0 (the "License"); you --> +<!-- may not use this file except in compliance with the License. You may --> +<!-- may obtain a copy of the License at --> +<!-- --> +<!-- http://www.apache.org/licenses/LICENSE-2.0 --> +<!-- --> +<!-- Unless required by applicable law or agreed to in writing, software --> +<!-- distributed under the License is distributed on an "AS IS" BASIS, --> +<!-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or --> +<!-- implied. License for the specific language governing permissions and --> +<!-- limitations under the License. --> + +<!-- Deferred binding rules for browser selection. --> +<!-- --> +<!-- This module is typically inherited via com.google.gwt.user.User --> +<!-- --> +<module> + <inherits name="com.google.gwt.core.Core" /> + <inherits name="com.google.gwt.user.UserAgent" /> + + <!-- Mozilla has a different implementation because of support for ARIA --> + <replace-with + class="com.google.gwt.user.client.ui.impl.AccessibilityImplMozilla"> + <when-type-is + class="com.google.gwt.user.client.ui.impl.AccessibilityImpl" /> + <any> + <when-property-is name="user.agent" value="gecko1_8" /> + </any> + </replace-with> +</module>
diff --git a/user/src/com/google/gwt/user/User.gwt.xml b/user/src/com/google/gwt/user/User.gwt.xml index 4110e74..d5559fe 100644 --- a/user/src/com/google/gwt/user/User.gwt.xml +++ b/user/src/com/google/gwt/user/User.gwt.xml
@@ -35,4 +35,5 @@ <inherits name="com.google.gwt.user.SplitPanel"/> <inherits name="com.google.gwt.user.ListBox" /> <inherits name="com.google.gwt.user.TitledPanel" /> + <inherits name="com.google.gwt.user.Accessibility"/> </module>
diff --git a/user/src/com/google/gwt/user/client/DOM.java b/user/src/com/google/gwt/user/client/DOM.java index 28e724d..830f8b8 100644 --- a/user/src/com/google/gwt/user/client/DOM.java +++ b/user/src/com/google/gwt/user/client/DOM.java
@@ -33,6 +33,8 @@ private static Event currentEvent = null; private static final DOMImpl impl = GWT.create(DOMImpl.class); private static Element sCaptureElem; + // Used to generate unique DOM ids. + private static int nextDOMId = 0; // <BrowserEventPreview> private static ArrayList<EventPreview> sEventPreviewStack; @@ -358,6 +360,15 @@ } /** + * Generates a unique DOM id. The id is of the form "gwt-id-<unique integer>". + * + * @return a unique DOM id + */ + public static String createUniqueId() { + return "gwt-id-" + nextDOMId++; + } + + /** * Cancels bubbling for the given event. This will stop the event from being * propagated to parent elements. *
diff --git a/user/src/com/google/gwt/user/client/ui/Accessibility.java b/user/src/com/google/gwt/user/client/ui/Accessibility.java new file mode 100644 index 0000000..66bb5ee --- /dev/null +++ b/user/src/com/google/gwt/user/client/ui/Accessibility.java
@@ -0,0 +1,119 @@ +/* + * Copyright 2008 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.google.gwt.user.client.ui; + +import com.google.gwt.core.client.GWT; +import com.google.gwt.user.client.ui.impl.AccessibilityImpl; +import com.google.gwt.user.client.Element; + +/** + * Allows ARIA attributes to be added to widgets so that they can be + * identified by assistive technologies. FireFox 3 is the only browser that + * currently supports this feature. However, in the future, a new version of + * FireVox will be created that will support this implementation and work with + * FireFox 2. + * + * A 'role' describes the role a widget plays in a page: i.e. a checkbox widget + * is assigned a "checkbox" role. + * + * A 'state' describes the current state of the widget. For example, a checkbox + * widget has the state "checked", which is given a value of "true" or "false" + * depending on whether it is currently checked or unchecked. + * + * See {@see <a href="http://developer.mozilla.org/en/docs/Accessible_DHTML">http://developer.mozilla.org/en/docs/Accessible_DHTML</a>} + * for more information. + * + * Note that this API is package protected. At this time, the ARIA specification is still + * in flux, which means that this API is subject to change. Once we are fairly confident + * that this API will remain stable, we will make it public. + */ + +final class Accessibility { + + public static final String ROLE_TREE = "tree"; + public static final String ROLE_TREEITEM = "treeitem"; + public static final String ROLE_BUTTON = "button"; + public static final String ROLE_TABLIST = "tablist"; + public static final String ROLE_TAB = "tab"; + public static final String ROLE_TABPANEL = "tabpanel"; + public static final String ROLE_MENUBAR = "menubar"; + public static final String ROLE_MENUITEM = "menuitem"; + + public static final String STATE_ACTIVEDESCENDANT = "aria-activedescendant"; + public static final String STATE_POSINSET = "aria-posinset"; + public static final String STATE_SETSIZE = "aria-setsize"; + public static final String STATE_SELECTED = "aria-selected"; + public static final String STATE_EXPANDED = "aria-expanded"; + public static final String STATE_LEVEL = "aria-level"; + public static final String STATE_HASPOPUP = "aria-haspopup"; + + private static AccessibilityImpl impl = (AccessibilityImpl) GWT + .create(AccessibilityImpl.class); + + /** + * Requests the string value of the role with the specified namespace. + * + * @param elem the element which has the specified role + * @return the value of the role, or an empty string if none exists + */ + public static String getRole(Element elem) { + return impl.getRole(elem); + } + + /** + * Requests the string value of the state with the specified namespace. + * + * @param elem the element which has the specified state + * @param stateName the name of the state + * @return the value of the state, or an empty string if none exists + */ + public static String getState(Element elem, String stateName) { + return impl.getState(elem, stateName); + } + + /** + * Removes the state from the given element. + * + * @param elem the element which has the specified state + * @param stateName the name of the state to remove + */ + public static void removeState(Element elem, String stateName) { + impl.removeState(elem, stateName); + } + /** + * Assigns the specified element the specified role and value for that role. + * + * @param elem the element to be given the specified role + * @param roleName the name of the role + */ + public static void setRole(Element elem, String roleName) { + impl.setRole(elem, roleName); + } + + /** + * Assigns the specified element the specified state and value for that state. + * + * @param elem the element to be given the specified state + * @param stateName the name of the state + * @param stateValue the value of the state + */ + public static void setState(Element elem, String stateName, String stateValue) { + impl.setState(elem, stateName, stateValue); + } + + private Accessibility() { + } +} \ No newline at end of file
diff --git a/user/src/com/google/gwt/user/client/ui/CheckBox.java b/user/src/com/google/gwt/user/client/ui/CheckBox.java index 82a603c..6ef4f64 100644 --- a/user/src/com/google/gwt/user/client/ui/CheckBox.java +++ b/user/src/com/google/gwt/user/client/ui/CheckBox.java
@@ -35,7 +35,6 @@ * </p> */ public class CheckBox extends ButtonBase implements HasName { - private static int uniqueId; private Element inputElem, labelElem; /** @@ -79,9 +78,16 @@ DOM.appendChild(getElement(), inputElem); DOM.appendChild(getElement(), labelElem); - String uid = "check" + (++uniqueId); + String uid = DOM.createUniqueId(); DOM.setElementProperty(inputElem, "id", uid); DOM.setElementProperty(labelElem, "htmlFor", uid); + + // Accessibility: setting tab index to be 0 by default, ensuring element + // appears in tab sequence. FocusWidget's setElement method already + // calls setTabIndex, which is overridden below. However, at the time + // that this call is made, inputElem has not been created. So, we have + // to call setTabIndex again, once inputElem has been created. + setTabIndex(0); } @Override @@ -163,7 +169,13 @@ @Override public void setTabIndex(int index) { - getFocusImpl().setTabIndex(inputElem, index); + // Need to guard against call to setTabIndex before inputElem is initialized. + // This happens because FocusWidget's (a superclass of CheckBox) setElement method + // calls setTabIndex before inputElem is initialized. See CheckBox's protected + // constructor for more information. + if (inputElem != null) { + getFocusImpl().setTabIndex(inputElem, index); + } } @Override @@ -247,7 +259,7 @@ // Setup the new element DOM.sinkEvents(inputElem, sunkEvents); DOM.setElementProperty(inputElem, "id", uid); - if (accessKey != "") { + if (!accessKey.equals("")) { DOM.setElementProperty(inputElem, "accessKey", accessKey); } setTabIndex(tabIndex);
diff --git a/user/src/com/google/gwt/user/client/ui/CustomButton.java b/user/src/com/google/gwt/user/client/ui/CustomButton.java index 6810922..87da861 100644 --- a/user/src/com/google/gwt/user/client/ui/CustomButton.java +++ b/user/src/com/google/gwt/user/client/ui/CustomButton.java
@@ -411,6 +411,9 @@ sinkEvents(Event.ONCLICK | Event.MOUSEEVENTS | Event.FOCUSEVENTS); setUpFace(createFace(null, "up", UP)); setStyleName(STYLENAME_DEFAULT); + + // Add a11y role "button" + Accessibility.setRole(getElement(), Accessibility.ROLE_BUTTON); } /**
diff --git a/user/src/com/google/gwt/user/client/ui/FocusWidget.java b/user/src/com/google/gwt/user/client/ui/FocusWidget.java index ebbf56b..e1ea806 100644 --- a/user/src/com/google/gwt/user/client/ui/FocusWidget.java +++ b/user/src/com/google/gwt/user/client/ui/FocusWidget.java
@@ -1,5 +1,5 @@ /* - * Copyright 2007 Google Inc. + * Copyright 2008 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy of @@ -164,6 +164,18 @@ impl.setTabIndex(getElement(), index); } + @Override + protected void setElement(Element elem) { + super.setElement(elem); + + // Accessibility: setting tab index to be 0 by default, ensuring element + // appears in tab sequence. Note that this call will not interfere with + // any calls made to FocusWidget.setTabIndex(int) by user code, because + // FocusWidget.setTabIndex(int) cannot be called until setElement(elem) + // has been called. + setTabIndex(0); + } + /** * Fire all current {@link ClickListener}. */
diff --git a/user/src/com/google/gwt/user/client/ui/HTMLPanel.java b/user/src/com/google/gwt/user/client/ui/HTMLPanel.java index 323ed2e..fb7e18e 100644 --- a/user/src/com/google/gwt/user/client/ui/HTMLPanel.java +++ b/user/src/com/google/gwt/user/client/ui/HTMLPanel.java
@@ -26,8 +26,6 @@ */ public class HTMLPanel extends ComplexPanel { - private static int sUid; - /** * A helper method for creating unique IDs for elements within dynamically- * generated HTML. This is important because no two elements in a document @@ -36,7 +34,7 @@ * @return a new unique identifier */ public static String createUniqueId() { - return "HTMLPanel_" + (++sUid); + return DOM.createUniqueId(); } private Element hiddenDiv;
diff --git a/user/src/com/google/gwt/user/client/ui/MenuBar.java b/user/src/com/google/gwt/user/client/ui/MenuBar.java index cf1df6e..757f44e 100644 --- a/user/src/com/google/gwt/user/client/ui/MenuBar.java +++ b/user/src/com/google/gwt/user/client/ui/MenuBar.java
@@ -97,11 +97,15 @@ this.vertical = vertical; - Element outer = DOM.createDiv(); + Element outer = FocusPanel.impl.createFocusable(); DOM.appendChild(outer, table); setElement(outer); - sinkEvents(Event.ONCLICK | Event.ONMOUSEOVER | Event.ONMOUSEOUT); + Accessibility.setRole(getElement(), Accessibility.ROLE_MENUBAR); + + sinkEvents(Event.ONCLICK | Event.ONMOUSEOVER | Event.ONMOUSEOUT + | Event.ONFOCUS | Event.ONKEYDOWN); + setStyleName("gwt-MenuBar"); if (vertical) { addStyleDependentName("vertical"); @@ -239,6 +243,7 @@ MenuItem item = findItem(DOM.eventGetTarget(event)); switch (DOM.eventGetType(event)) { case Event.ONCLICK: { + FocusPanel.impl.focus(getElement()); // Fire an item's command when the user clicks on it. if (item != null) { doItemAction(item, true); @@ -259,7 +264,40 @@ } break; } - } + + case Event.ONFOCUS: { + selectFirstItemIfNoneSelected(); + break; + } + + case Event.ONKEYDOWN: { + int keyCode = DOM.eventGetKeyCode(event); + switch (keyCode) { + case KeyboardListener.KEY_LEFT: + moveLeft(); + break; + case KeyboardListener.KEY_RIGHT: + moveRight(); + break; + case KeyboardListener.KEY_UP: + moveUp(); + break; + case KeyboardListener.KEY_DOWN: + moveDown(); + break; + case KeyboardListener.KEY_ESCAPE: + closeAllParents(); + break; + case KeyboardListener.KEY_ENTER: + if (!selectFirstItemIfNoneSelected()) { + doItemAction(selectedItem, true); + } + break; + } // end switch(keyCode) + + break; + } // end case Event.ONKEYDOWN + } // end switch (DOM.eventGetType(event)) } public void onPopupClosed(PopupPanel sender, boolean autoClosed) { @@ -361,14 +399,8 @@ */ void closeAllParents() { MenuBar curMenu = this; - while (curMenu != null) { + while (curMenu.parentMenu != null) { curMenu.close(); - - if ((curMenu.parentMenu == null) && (curMenu.selectedItem != null)) { - curMenu.selectedItem.setSelectionStyle(false); - curMenu.selectedItem = null; - } - curMenu = curMenu.parentMenu; } } @@ -395,7 +427,7 @@ } // If the item has no popup, optionally fire its command. - if (item.getSubMenu() == null) { + if ((item != null) && (item.getSubMenu() == null)) { if (fireCommand) { // Close this menu and all of its parents. closeAllParents(); @@ -412,6 +444,10 @@ // Ensure that the item is selected. selectItem(item); + if (item == null) { + return; + } + // Create a new popup for this item, and position it next to // the item (below if this is a horizontal menu bar, to the // right if it's a vertical bar). @@ -457,6 +493,7 @@ // Show the popup, ensuring that the menubar's event preview remains on top // of the popup's. popup.show(); + shownChildMenu.focus(); } void itemOver(MenuItem item) { @@ -492,6 +529,9 @@ if (item != null) { item.setSelectionStyle(true); + + Accessibility.setState(getElement(), Accessibility.STATE_ACTIVEDESCENDANT, + DOM.getElementAttribute(item.getElement(), "id")); } selectedItem = item; @@ -537,16 +577,18 @@ } private MenuItem findItem(Element hItem) { - for (int i = 0; i < items.size(); ++i) { - MenuItem item = items.get(i); + for (MenuItem item : items) { if (DOM.isOrHasChild(item.getElement(), hItem)) { return item; } } - return null; } + private void focus() { + FocusPanel.impl.focus(getElement()); + } + private Element getItemContainerElement() { if (vertical) { return body; @@ -555,6 +597,76 @@ } } + private void moveDown() { + if (selectFirstItemIfNoneSelected()) { + return; + } + + if (vertical) { + selectNextItem(); + } else { + if (selectedItem.getSubMenu() != null) { + doItemAction(selectedItem, false); + } else if (parentMenu != null) { + if (parentMenu.vertical) { + parentMenu.selectNextItem(); + } else { + parentMenu.moveDown(); + } + } + } + } + + private void moveLeft() { + if (selectFirstItemIfNoneSelected()) { + return; + } + + if (!vertical) { + selectPrevItem(); + } else { + if ((parentMenu != null) && (!parentMenu.vertical)) { + parentMenu.selectPrevItem(); + } else { + close(); + } + } + } + + private void moveRight() { + if (selectFirstItemIfNoneSelected()) { + return; + } + + if (!vertical) { + selectNextItem(); + } else { + if ((shownChildMenu == null) && (selectedItem.getSubMenu() != null)) { + doItemAction(selectedItem, false); + } else if (parentMenu != null) { + if (!parentMenu.vertical) { + parentMenu.selectNextItem(); + } else { + parentMenu.moveRight(); + } + } + } + } + + private void moveUp() { + if (selectFirstItemIfNoneSelected()) { + return; + } + + if ((shownChildMenu == null) && vertical) { + selectPrevItem(); + } else if ((parentMenu != null) && parentMenu.vertical) { + parentMenu.selectPrevItem(); + } else { + close(); + } + } + /* * This method is called when a menu bar is hidden, so that it can hide any * child popups that are currently being shown. @@ -563,6 +675,7 @@ if (shownChildMenu != null) { shownChildMenu.onHide(); popup.hide(); + focus(); } } @@ -594,4 +707,69 @@ allItems.remove(idx); return true; } + + /** + * Selects the first item in the menu if no items are currently selected. This method + * assumes that the menu has at least 1 item. + * + * @return true if no item was previously selected and the first item in the list was selected, + * false otherwise + */ + private boolean selectFirstItemIfNoneSelected() { + if (selectedItem == null) { + MenuItem nextItem = items.get(0); + selectItem(nextItem); + return true; + } + + return false; + } + + private void selectNextItem() { + if (selectedItem == null) { + return; + } + + int index = items.indexOf(selectedItem); + // We know that selectedItem is set to an item that is contained in the items collection. + // Therefore, we know that index can never be -1. + assert (index != -1); + + MenuItem itemToBeSelected; + + if (index < items.size() - 1) { + itemToBeSelected = items.get(index + 1); + } else { // we're at the end, loop around to the start + itemToBeSelected = items.get(0); + } + + selectItem(itemToBeSelected); + if (shownChildMenu != null) { + doItemAction(itemToBeSelected, false); + } + } + + private void selectPrevItem() { + if (selectedItem == null) { + return; + } + + int index = items.indexOf(selectedItem); + // We know that selectedItem is set to an item that is contained in the items collection. + // Therefore, we know that index can never be -1. + assert (index != -1); + + MenuItem itemToBeSelected; + if (index > 0) { + itemToBeSelected = items.get(index - 1); + + } else { // we're at the start, loop around to the end + itemToBeSelected = items.get(items.size() - 1); + } + + selectItem(itemToBeSelected); + if (shownChildMenu != null) { + doItemAction(itemToBeSelected, false); + } + } }
diff --git a/user/src/com/google/gwt/user/client/ui/MenuItem.java b/user/src/com/google/gwt/user/client/ui/MenuItem.java index e03ae76..d201f30 100644 --- a/user/src/com/google/gwt/user/client/ui/MenuItem.java +++ b/user/src/com/google/gwt/user/client/ui/MenuItem.java
@@ -23,6 +23,9 @@ * {@link com.google.gwt.user.client.ui.MenuBar}. Menu items can either fire a * {@link com.google.gwt.user.client.Command} when they are clicked, or open a * cascading sub-menu. + * + * Each menu item is assigned a unique DOM id in order to support ARIA. See + * {@link com.google.gwt.user.client.ui.Accessibility} for more information. */ public class MenuItem extends UIObject implements HasHTML { @@ -87,6 +90,10 @@ setText(text); } setStyleName("gwt-MenuItem"); + + DOM.setElementAttribute(getElement(), "id", DOM.createUniqueId()); + // Add a11y role "menuitem" + Accessibility.setRole(getElement(), Accessibility.ROLE_MENUITEM); } /** @@ -144,6 +151,13 @@ */ public void setSubMenu(MenuBar subMenu) { this.subMenu = subMenu; + + // Change tab index from 0 to -1, because only the root menu is supposed to + // be in the tab order + FocusPanel.impl.setTabIndex(subMenu.getElement(), -1); + + // Update a11y role "haspopup" + Accessibility.setState(this.getElement(), Accessibility.STATE_HASPOPUP, "true"); } public void setText(String text) {
diff --git a/user/src/com/google/gwt/user/client/ui/TabBar.java b/user/src/com/google/gwt/user/client/ui/TabBar.java index 4d1a90d..2477531 100644 --- a/user/src/com/google/gwt/user/client/ui/TabBar.java +++ b/user/src/com/google/gwt/user/client/ui/TabBar.java
@@ -43,7 +43,7 @@ * </p> */ public class TabBar extends Composite implements SourcesTabEvents, - ClickListener { + ClickListener, KeyboardListener { /** * <code>ClickDecoratorPanel</code> decorates any widget with the minimal @@ -52,20 +52,34 @@ * single observer is needed. */ private static final class ClickDecoratorPanel extends SimplePanel { - ClickListener delegate; + ClickListener clickDelegate; + KeyboardListener keyDelegate; - ClickDecoratorPanel(Widget child, ClickListener delegate) { - this.delegate = delegate; + ClickDecoratorPanel(Widget child, ClickListener cDelegate, + KeyboardListener kDelegate) { + + // The panel needs to be able to get keyboard focus for tab navigation + super(FocusPanel.impl.createFocusable()); + + this.clickDelegate = cDelegate; + this.keyDelegate = kDelegate; setWidget(child); - sinkEvents(Event.ONCLICK); + sinkEvents(Event.ONCLICK | Event.ONKEYDOWN); } @Override public void onBrowserEvent(Event event) { // No need for call to super. switch (DOM.eventGetType(event)) { + case Event.ONCLICK: - delegate.onClick(this); + clickDelegate.onClick(this); + break; + + case Event.ONKEYDOWN: + keyDelegate.onKeyDown(this, ((char) DOM.eventGetKeyCode(event)), + KeyboardListenerCollection.getKeyboardModifiers(event)); + break; } } } @@ -83,6 +97,9 @@ sinkEvents(Event.ONCLICK); setStyleName("gwt-TabBar"); + // Add a11y role "tablist" + Accessibility.setRole(panel.getElement(), Accessibility.ROLE_TABLIST); + panel.setVerticalAlignment(HasVerticalAlignment.ALIGN_BOTTOM); HTML first = new HTML(" ", true), rest = new HTML(" ", true); @@ -192,12 +209,8 @@ item = new Label(text); } - item.setWordWrap(false); - item.addClickListener(this); - item.setStyleName(STYLENAME_DEFAULT); - panel.insert(item, beforeIndex + 1); - setStyleName(DOM.getParent(item.getElement()), STYLENAME_DEFAULT - + "-wrapper", true); + item.setWordWrap(false); + insertTabImpl(item, beforeIndex); } /** @@ -218,23 +231,25 @@ */ public void insertTab(Widget widget, int beforeIndex) { checkInsertBeforeTabIndex(beforeIndex); - - ClickDecoratorPanel decWidget = new ClickDecoratorPanel(widget, this); - decWidget.addStyleName(STYLENAME_DEFAULT); - panel.insert(decWidget, beforeIndex + 1); - setStyleName(DOM.getParent(decWidget.getElement()), STYLENAME_DEFAULT - + "-wrapper", true); + insertTabImpl(widget, beforeIndex); } public void onClick(Widget sender) { - for (int i = 1; i < panel.getWidgetCount() - 1; ++i) { - if (panel.getWidget(i) == sender) { - selectTab(i - 1); - return; - } + selectTabByTabWidget(sender); + } + + public void onKeyDown(Widget sender, char keyCode, int modifiers) { + if (keyCode == KeyboardListener.KEY_ENTER) { + selectTabByTabWidget(sender); } } + public void onKeyPress(Widget sender, char keyCode, int modifiers) { + } + + public void onKeyUp(Widget sender, char keyCode, int modifiers) { + } + /** * Removes the tab at the specified index. * @@ -323,6 +338,38 @@ } } + private void insertTabImpl(Widget widget, int beforeIndex) { + ClickDecoratorPanel decWidget = new ClickDecoratorPanel(widget, this, this); + decWidget.addStyleName(STYLENAME_DEFAULT); + // Add a11y role "tab" + Accessibility.setRole(decWidget.getElement(), Accessibility.ROLE_TAB); + + panel.insert(decWidget, beforeIndex + 1); + setStyleName(DOM.getParent(decWidget.getElement()), STYLENAME_DEFAULT + + "-wrapper", true); + } + + /** + * Selects the tab corresponding to the widget for the tab. To be clear + * the widget for the tab is not the widget INSIDE of the tab; it is the + * widget used to represent the tab itself. + * + * @param tabWidget The widget for the tab to be selected + * @return true if the tab corresponding to the widget for the tab could located and selected, + * false otherwise + */ + private boolean selectTabByTabWidget(Widget tabWidget) { + int numTabs = panel.getWidgetCount() - 1; + + for (int i = 1; i < numTabs; ++i) { + if (panel.getWidget(i) == tabWidget) { + return selectTab(i - 1); + } + } + + return false; + } + private void setSelectionStyle(Widget item, boolean selected) { if (item != null) { if (selected) {
diff --git a/user/src/com/google/gwt/user/client/ui/TabPanel.java b/user/src/com/google/gwt/user/client/ui/TabPanel.java index 4fa2c43..b4bf75d 100644 --- a/user/src/com/google/gwt/user/client/ui/TabPanel.java +++ b/user/src/com/google/gwt/user/client/ui/TabPanel.java
@@ -193,6 +193,8 @@ initWidget(panel); setStyleName("gwt-TabPanel"); deck.setStyleName("gwt-TabPanelBottom"); + // Add a11y role "tabpanel" + Accessibility.setRole(deck.getElement(), Accessibility.ROLE_TABPANEL); } public void add(Widget w) {
diff --git a/user/src/com/google/gwt/user/client/ui/Tree.java b/user/src/com/google/gwt/user/client/ui/Tree.java index 6fdc56a..a62a5fc 100644 --- a/user/src/com/google/gwt/user/client/ui/Tree.java +++ b/user/src/com/google/gwt/user/client/ui/Tree.java
@@ -57,7 +57,7 @@ private static class ImagesFromImageBase implements TreeImages { /** - * A convience image prototype that implements + * A convenience image prototype that implements * {@link AbstractImagePrototype#applyTo(Image)} for a specified image name. */ private class Prototype extends AbstractImagePrototype { @@ -110,6 +110,16 @@ } } + static native boolean shouldTreeDelegateFocusToElement(Element elem) /*-{ + var name = elem.nodeName; + return ((name == "SELECT") || + (name == "INPUT") || + (name == "TEXTAREA") || + (name == "OPTION") || + (name == "BUTTON") || + (name == "LABEL")); + }-*/; + /** * Map of TreeItem.widget -> TreeItem. */ @@ -191,6 +201,10 @@ }; root.setTree(this); setStyleName("gwt-Tree"); + + // Add a11y role "tree" + Accessibility.setRole(getElement(), Accessibility.ROLE_TREE); + Accessibility.setRole(focusable, Accessibility.ROLE_TREEITEM); } /** @@ -365,7 +379,7 @@ // to be sunk on individual items' open/close images. This leads to an // extra event reaching the Tree, which we will ignore here. if (DOM.eventGetCurrentTarget(event).equals(getElement())) { - elementClicked(root, DOM.eventGetTarget(event)); + elementClicked(DOM.eventGetTarget(event)); } break; } @@ -399,7 +413,6 @@ } case Event.ONFOCUS: - // If we already have focus, ignore the focus event. if (focusListeners != null) { focusListeners.fireFocusEvent(this, event); } @@ -713,7 +726,7 @@ chain.add(hElem); } - private boolean elementClicked(TreeItem root, Element hElem) { + private boolean elementClicked(Element hElem) { ArrayList<Element> chain = new ArrayList<Element>(); collectElementChain(chain, getElement(), hElem); @@ -759,19 +772,17 @@ } /** - * Move the tree focus to the specified selected item. - * - * @param selection + * Move the tree focus to the currently selected item. */ - private void moveFocus(TreeItem selection) { - HasFocus focusableWidget = selection.getFocusableWidget(); + private void moveFocus() { + HasFocus focusableWidget = curSelection.getFocusableWidget(); if (focusableWidget != null) { focusableWidget.setFocus(true); DOM.scrollIntoView(((Widget) focusableWidget).getElement()); } else { // Get the location and size of the given item's content element relative // to the tree. - Element selectedElem = selection.getContentElem(); + Element selectedElem = curSelection.getContentElem(); int containerLeft = getAbsoluteLeft(); int containerTop = getAbsoluteTop(); @@ -790,9 +801,12 @@ // Scroll it into view. DOM.scrollIntoView(focusable); + // Update ARIA attributes to reflect the information from the newly-selected item. + updateAriaAttributes(); + // Ensure Focus is set, as focus may have been previously delegated by // tree. - FocusPanel.impl.focus(focusable); + setFocus(true); } } @@ -853,7 +867,7 @@ curSelection = item; if (moveFocus && curSelection != null) { - moveFocus(curSelection); + moveFocus(); // Select the item and fire the selection event. curSelection.setSelected(true); @@ -863,13 +877,67 @@ } } - private native boolean shouldTreeDelegateFocusToElement(Element elem) /*-{ - var name = elem.nodeName; - return ((name == "SELECT") || - (name == "INPUT") || - (name == "TEXTAREA") || - (name == "OPTION") || - (name == "BUTTON") || - (name == "LABEL")); - }-*/; + private void updateAriaAttributes() { + + Element curSelectionContentElem = curSelection.getContentElem(); + + // Set the 'aria-level' state. To do this, we need to compute the level of the + // currently selected item. + + // We initialize itemLevel to -1 because the level value is zero-based. + // Note that the root node is not a part of the TreeItem hierachy, and we + // do not consider the root node to have a designated level. The level of + // the root's children is level 0, its children's children is level 1, etc. + + int curSelectionLevel = -1; + TreeItem tempItem = curSelection; + + while (tempItem != null) { + tempItem = tempItem.getParentItem(); + ++curSelectionLevel; + } + + Accessibility.setState(curSelectionContentElem, Accessibility.STATE_LEVEL, + String.valueOf(curSelectionLevel + 1)); + + // Set the 'aria-setsize' and 'aria-posinset' states. To do this, we need to + // compute the the number of siblings that the currently selected item has, and + // the item's position among its siblings. + + TreeItem curSelectionParent = curSelection.getParentItem(); + if (curSelectionParent == null) { + curSelectionParent = root; + } + + Accessibility.setState(curSelectionContentElem, Accessibility.STATE_SETSIZE, + String.valueOf(curSelectionParent.getChildCount())); + + int curSelectionIndex = curSelectionParent.getChildIndex(curSelection); + + Accessibility.setState(curSelectionContentElem, Accessibility.STATE_POSINSET, + String.valueOf(curSelectionIndex + 1)); + + // Set the 'aria-expanded' state. This depends on the state of the currently selected item. + // If the item has no children, we remove the 'aria-expanded' state. + + if (curSelection.getChildCount() == 0) { + Accessibility.removeState(curSelectionContentElem, Accessibility.STATE_EXPANDED); + } else { + if (curSelection.getState()) { + Accessibility.setState(curSelectionContentElem, Accessibility.STATE_EXPANDED, "true"); + } else { + Accessibility.setState(curSelectionContentElem, Accessibility.STATE_EXPANDED, "false"); + } + } + + // Make sure that 'aria-selected' is true. + + Accessibility.setState(curSelectionContentElem, Accessibility.STATE_SELECTED, "true"); + + // Update the 'aria-activedescendant' state for the focusable element to match the id + // of the currently selected item + + Accessibility.setState(focusable, Accessibility.STATE_ACTIVEDESCENDANT, + DOM.getElementAttribute(curSelectionContentElem, "id")); + } }
diff --git a/user/src/com/google/gwt/user/client/ui/TreeItem.java b/user/src/com/google/gwt/user/client/ui/TreeItem.java index cbe236d..45f6bee 100644 --- a/user/src/com/google/gwt/user/client/ui/TreeItem.java +++ b/user/src/com/google/gwt/user/client/ui/TreeItem.java
@@ -24,6 +24,10 @@ /** * An item that can be contained within a * {@link com.google.gwt.user.client.ui.Tree}. + * + * Each tree item is assigned a unique DOM id in order to support ARIA. See + * {@link com.google.gwt.user.client.ui.Accessibility} for more information. + * * <p> * <h3>Example</h3> * {@example com.google.gwt.examples.TreeExample} @@ -79,6 +83,9 @@ DOM.setStyleAttribute(getElement(), "whiteSpace", "nowrap"); DOM.setStyleAttribute(childSpanElem, "whiteSpace", "nowrap"); setStyleName(contentElem, "gwt-TreeItem", true); + + Accessibility.setRole(contentElem, Accessibility.ROLE_TREEITEM); + DOM.setElementAttribute(contentElem, "id", DOM.createUniqueId()); } /** @@ -115,10 +122,9 @@ /** * Adds another item as a child to this one. - * + * * @param item the item to be added */ - public void addItem(TreeItem item) { // Detach item from existing parent. if ((item.getParentItem() != null) || (item.getTree() != null)) { @@ -394,6 +400,13 @@ if (tree != null) { tree.adopt(widget, this); } + + // Set tabIndex on the widget to -1, so that it doesn't mess up the tab + // order of the entire tree + + if (Tree.shouldTreeDelegateFocusToElement(widget.getElement())) { + DOM.setElementAttribute(widget.getElement(), "tabIndex", "-1"); + } } } @@ -458,7 +471,7 @@ Element getImageElement() { return statusImage.getElement(); } - + void setParentItem(TreeItem parent) { this.parent = parent; }
diff --git a/user/src/com/google/gwt/user/client/ui/impl/AccessibilityImpl.java b/user/src/com/google/gwt/user/client/ui/impl/AccessibilityImpl.java new file mode 100644 index 0000000..90a9b55 --- /dev/null +++ b/user/src/com/google/gwt/user/client/ui/impl/AccessibilityImpl.java
@@ -0,0 +1,42 @@ +/* + * Copyright 2008 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.google.gwt.user.client.ui.impl; + +import com.google.gwt.user.client.Element; + +/** + * Native implementation class used with + * {@link com.google.gwt.user.client.ui.Accessibility}. + */ +public class AccessibilityImpl { + + public String getRole(Element elem) { + return ""; + } + + public String getState(Element elem, String stateName) { + return ""; + } + + public void removeState(Element elem, String stateName) { + } + + public void setRole(Element elem, String roleName) { + } + + public void setState(Element elem, String stateName, String stateValue) { + } +}
diff --git a/user/src/com/google/gwt/user/client/ui/impl/AccessibilityImplMozilla.java b/user/src/com/google/gwt/user/client/ui/impl/AccessibilityImplMozilla.java new file mode 100644 index 0000000..c3ab5f5 --- /dev/null +++ b/user/src/com/google/gwt/user/client/ui/impl/AccessibilityImplMozilla.java
@@ -0,0 +1,47 @@ +/* + * Copyright 2008 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.google.gwt.user.client.ui.impl; + +import com.google.gwt.user.client.Element; +import com.google.gwt.user.client.DOM; + +/** + * Firefox 1.5+ implementation of {@link AccessibilityImpl}. + */ +public class AccessibilityImplMozilla extends AccessibilityImpl { + + private static final String ATTR_NAME_ROLE = "role"; + + public String getRole(Element elem) { + return DOM.getElementAttribute(elem, ATTR_NAME_ROLE); + } + + public String getState(Element elem, String stateName) { + return DOM.getElementAttribute(elem, stateName); + } + + public void removeState(Element elem, String stateName) { + DOM.removeElementAttribute(elem, stateName); + } + + public void setRole(Element elem, String roleName) { + DOM.setElementAttribute(elem, ATTR_NAME_ROLE, roleName); + } + + public void setState(Element elem, String stateName, String stateValue) { + DOM.setElementAttribute(elem, stateName, stateValue); + } +}
diff --git a/user/src/com/google/gwt/user/tools/AppHtml.htmlsrc b/user/src/com/google/gwt/user/tools/AppHtml.htmlsrc index c92da8d..269f56c 100644 --- a/user/src/com/google/gwt/user/tools/AppHtml.htmlsrc +++ b/user/src/com/google/gwt/user/tools/AppHtml.htmlsrc
@@ -46,7 +46,7 @@ <body> <!-- OPTIONAL: include this if you want history support --> - <iframe src="javascript:''" id="__gwt_historyFrame" style="position:absolute;width:0;height:0;border:0"></iframe> + <iframe src="javascript:''" id="__gwt_historyFrame" tabIndex='-1' style="position:absolute;width:0;height:0;border:0"></iframe> <h1>@className</h1>